关于Redis你可能不了解的一些事

52次阅读
没有评论

共计 6304 个字符,预计需要花费 16 分钟才能阅读完成。

Redis 是一个高性能分布式的 key-value 数据库。它支持多种数据结构,并可应用于缓存、队列等多种场景下。使用过 Redis 的小伙伴们可能对这些已经非常熟知了,下面我想来谈谈 Redis 也许并不被每个人了解的那点事。

Redis 持久化机制

刚看到标题你可能会说,我知道,不就是 RDB 和 AOF 嘛。这些已经是老生常谈了。那么我们今天就深入谈谈这两种持久化方式的逻辑和原理。

RDB 的原理

在 Redis 中 RDB 持久化的触发分为两种:自己手动触发与 Redis 定时触发。

针对 RDB 方式的持久化,手动触发可以使用:

(1)save:会阻塞当前 Redis 服务器,直到持久化完成,线上应该禁止使用。
(2)bgsave:该触发方式会 fork 一个子进程,由子进程负责持久化过程,因此阻塞只会发生在 fork 子进程的时候。

而自动触发的场景如下:

根据我们的 save m n 配置规则自动触发;
从节点全量复制时,主节点发送 rdb 文件给从节点完成复制操作,主节点会触发 bgsave;
执行 debug reload 时处罚;
执行 shutdown 时,如果没有开启 aof,也会触发。

由于 save 基本不会被使用到,我们来看看 bgsave 这个命令是如何完成 RDB 的持久化的。

关于 Redis 你可能不了解的一些事

RDB 文件保存过程

(1)redis 调用 fork, 现在有了子进程和父进程。
(2)父进程继续处理 client 请求,子进程负责将内存内容写入到临时文件。由于 os 的写时复制机制(copy on write)父子进程会共享相同的物理页面,当父进程处理写请求时 os 会为父进程要修改的页面创建副本,而不是写共享的页面。所以子进程的地址空间内的数据是 fork 时刻整个数据库的一个快照。
(3)当子进程将快照写入临时文件完毕后,用临时文件替换原来的快照文件,然后子进程退出。

PS:fork 操作会阻塞,导致 Redis 读写性能下降。我们可以控制单个 Redis 实例的最大内存,来尽可能降低 Redis 在 fork 时的时间消耗;或者控制自动触发的频率减少 fork 次数。

AOF 的原理

AOF 的整个流程大体来看可以分为两步,一步是命令的实时写入(如果是 appendfsync everysec 配置,会有 1s 损耗),第二步是对 aof 文件的重写。

对于增量追加到文件这一步主要的流程是:

(1)命令写入
(2)追加到 aof_buf
(3)同步到 aof 磁盘

那么这里为什么要先写入 buf 再同步到磁盘呢?如果实时写入磁盘会带来非常高的磁盘 IO,影响整体性能。

AOF 重写

你可以会想,每一条写命令都生成一条日志,那么 AOF 文件是不是会很大?答案是肯定的,AOF 文件会越来越大,所以 Redis 又提供了一个功能,叫做 AOF rewrite。其功能就是重新生成一份 AOF 文件,新的 AOF 文件中一条记录的操作只会有一次,而不像一份老文件那样,可能记录了对同一个值的多次操作。

手动触发:bgrewriteaof

自动触发就是根据配置规则来触发,当然自动触发的整体时间还跟 Redis 的定时任务频率有关系。

下面来看看重写的流程图:

关于 Redis 你可能不了解的一些事

(1)redis 调用 fork,现在有父子两个进程
(2)子进程根据内存中的数据库快照,往临时文件中写入重建数据库状态的命令
(3)父进程继续处理 client 请求,除了把写命令写入到原来的 aof 文件中。同时把收到的写命令缓存起来。这样就能保证如果子进程重写失败的话并不会出问题。
(4)当子进程把快照内容写到临时文件中后,子进程发信号通知父进程。然后父进程把缓存的写命令也写入到临时文件。
(5)现在父进程可以使用临时文件替换老的 aof 文件,并重命名,后面收到的写命令也开始往新的 aof 文件中追加。

PS:需要注意到是重写 aof 文件的操作,并没有读取旧的 aof 文件,而是将整个内存中的数据库内容用命令的方式重写了一个新的 aof 文件, 这点和快照有点类似。

Redis 为什么这么快?

Redis 采用的是基于内存的单进程单线程模型的 KV 数据库,由 C 语言编写,官方提供的数据是可以达到 100000+ 的 QPS(每秒内查询次数)。这个数据不比采用单进程多线程的同样基于内存的 KV 数据库 Memcached 差!原因如下:

1、完全基于内存,绝大部分请求是纯粹的内存操作,非常快速;
2、数据结构简单,对数据操作也简单,Redis 中的数据结构是专门进行设计的;
3、采用单线程,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗 CPU,不用去考虑各种锁的问题,不存在加锁释放锁的操作,也不可能出现死锁而导致的性能消耗;
4、使用多路 I / O 复用模型,非阻塞 IO;
5、使用的底层模型不同,底层实现方式以及与客户端之间通信的应用协议不一样,Redis 直接构建了自己的 VM 机制。

多路 I / O 复用模型

多路 I / O 复用模型是利用 select、poll、epoll 可以同时监察多个流的 I/O 事件的能力,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有 I/O 事件时,就从阻塞态中唤醒,于是程序就会轮询一遍所有的流(epoll 是只轮询那些真正发出了事件的流),并且只依次顺序的处理就绪的流,这种做法就避免了大量的无用操作。这里“多路”指的是多个网络连接,“复用”指的是复用同一个线程。采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络 IO 的时间消耗)。

Redis 事务

Redis 中的事务 (transaction) 是一组命令的集合。事务同命令一样都是 Redis 最小的执行单位,一个事务中的命令要么都执行,要么都不执行。Redis 事务的实现需要 MULTI 和 EXEC 两个命令,事务开始的时候先向 Redis 服务器发送 MULTI 命令,然后依次发送需要在本次事务中处理的命令,最后再发送 EXEC 命令表示事务命令结束。

举个例子,使用 redis-cli 连接 redis,然后在命令行工具中输入如下命令:

关于 Redis 你可能不了解的一些事

从输出中可以看到,当输入 MULTI 命令后,服务器返回 OK 表示事务开始成功,然后依次输入需要在本次事务中执行的所有命令,每次输入一个命令服务器并不会马上执行,而是返回”QUEUED”,这表示命令已经被服务器接受并且暂时保存起来,最后输入 EXEC 命令后,本次事务中的所有命令才会被依次执行,可以看到最后服务器一次性返回了三个 OK,这里返回的结果与发送的命令是按顺序,这说明这次事务中的命令全都执行成功了。

再举个例子,在命令行工具中输入如下命令:

关于 Redis 你可能不了解的一些事

和前面的例子一样,先输入 MULTI 最后输入 EXEC 表示中间的命令属于一个事务,不同的是中间输入的命令有一个错误(set 写成了 sett),这样因为有一个错误的命令导致事务中的其他命令都不执行了,可见事务中的所有命令是保持一致的。

如果客户端在发送 EXEC 命令之前断线了,则服务器会清空事务队列,事务中的所有命令都不会被执行。而一旦客户端发送了 EXEC 命令之后,事务中的所有命令都会被执行,即使此后客户端断线也没关系,因为服务器已经保存了事务中的所有命令。

除了保证事务中的所有命令要么全执行要么全不执行外,Redis 的事务还能保证一个事务中的命令依次执行而不会被其他命令插入。试想一个客户端 A 需要执行几条命令,同时客户端 B 发送了几条命令,如果不使用事务,则客户端 B 的命令有可能会插入到客户端 A 的几条命令中,如果想避免这种情况发生,也可以使用事务。

Redis 事务错误处理

如果一个事务中的某个命令执行出错,Redis 会怎样处理呢?要回答这个问题,首先要搞清楚是什么原因导致命令执行出错:

1. 语法错误: 就像上面的例子一样,语法错误表示命令不存在或者参数错误,这种情况需要区分 Redis 的版本,Redis 2.6.5 之前的版本会忽略错误的命令,执行其他正确的命令,2.6.5 之后的版本会忽略这个事务中的所有命令,都不执行

2. 运行错误 运行错误表示命令在执行过程中出现错误,比如用 GET 命令获取一个散列表类型的键值。这种错误在命令执行之前 Redis 是无法发现的,所以在事务里这样的命令会被 Redis 接受并执行。如果食物里有一条命令执行错误,其他命令依旧会执行(包括出错之后的命令)。

关于 Redis 你可能不了解的一些事

Redis 中的事务并没有关系型数据库中的事务回滚 (rollback) 功能,因此使用者必须自己收拾剩下的烂摊子。不过由于 Redis 不支持事务回滚功能,这也使得 Redis 的事务简洁快速。

WATCH、UNWATCH、DISCARD 命令

从上面的例子我们可以看到,事务中的命令要全部执行完之后才能获取每个命令的结果,但是如果一个事务中的命令 B 依赖于他上一个命令 A 的结果的话该怎么办呢?就比如说实现类似 Java 中的 i ++ 的功能,先要获取当前值,才能在当前值的基础上做加一操作。这种场合仅仅使用上面介绍的 MULTI 和 EXEC 是不能实现的,因为 MULTI 和 EXEC 中的命令是一起执行的,并不能将其中一条命令的执行结果作为另一条命令的执行参数,所以这个时候就需要引进 Redis 事务家族中的另一成员:WATCH 命令

换个角度思考上面说到的实现 i ++ 的方法,可以这样实现:

监控 i 的值,保证 i 的值不被修改
获取 i 的原值
如果过程中 i 的值没有被修改,则将当前的 i 值 +1,否则不执行

WATCH 命令可以监控一个或多个键,一旦其中有一个键被修改(或删除),之后的事务就不会执行,监控一直持续到 EXEC 命令(事务中的命令是在 EXEC 之后才执行的,EXEC 命令执行完之后被监控的键会自动被 UNWATCH)。举个例子:

关于 Redis 你可能不了解的一些事

上面的例子中,首先设置 mykey 的键值为 1,然后使用 WATCH 命令监控 mykey,随后更改 mykey 的值为 2,然后进入事务,事务中设置 mykey 的值为 3,然后执行 EXEC 运行事务中的命令,最后使用 get 命令查看 mykey 的值,发现 mykey 的值还是 2,也就是说事务中的命令根本没有执行(因为 WATCH 监控 mykey 的过程中,mykey 被修改了,所以随后的事务便会被取消)。

UNWATCH 命令可以在 WATCH 命令执行之后、MULTI 命令执行之前取消对某个键的监控。举个例子:

关于 Redis 你可能不了解的一些事

上面的例子中,首先设置 mykey 的键值为 1,然后使用 WATCH 命令监控 mykey,随后更改 mykey 的值为 2,然后取消对 mykey 的监控,再进入事务,事务中设置 mykey 的值为 3,然后执行 EXEC 运行事务中的命令,最后使用 get 命令查看 mykey 的值,发现 mykey 的值还是 3,也就是说事务中的命令运行成功。

DISCARD 命令则可以在 MULTI 命令执行之后,EXEC 命令执行之前取消 WATCH 命令并清空事务队列,然后从事务状态中退出。举个例子:

关于 Redis 你可能不了解的一些事

上面的例子中,首先设置 mykey 的键值为 1,然后使用 WATCH 命令监控 mykey,随后更改 mykey 的值为 2,然后进入事务,事务中设置 mykey 的值为 3,然后执行 DISCARD 命令,再执行 EXEC 运行事务中的命令,发现报错“ERR EXEC without MULTI”,说明 DISCARD 命令成功执行——取消 WATCH 命令并清空事务队列,然后从事务状态中退出。

Redis 分布式锁

上面介绍的 Redis 的 WATCH、MULTI 和 EXEC 命令,只会在数据被其他客户端抢先修改的情况下,通知执行这些命令的客户端,让它撤销对数据的修改操作,并不能阻止其他客户端对数据进行修改,所以只能称之为乐观锁(optimistic locking)。

而这种乐观锁并不具备可扩展性——当客户端尝试完成一个事务的时候,可能会因为事务执行失败而进行反复的重试。保证数据准确性非常重要,但是当负载变大的时候,使用乐观锁的做法并不完美。这时就需要使用 Redis 实现分布式锁。

分布式锁:是控制分布式系统之间同步访问共享资源的一种方式。在分布式系统中,常常需要协调他们的动作。如果不同的系统或是同一个系统的不同主机之间共享了一个或一组资源,那么访问这些资源的时候,往往需要互斥来防止彼此干扰来保证一致性,在这种情况下,便需要使用到分布式锁。

Redis 命令介绍:

Redis 实现分布式锁主要用到命令是 SETNX 命令(SET if Not eXists)。

语法:SETNX key value

功能:当且仅当 key 不存在,将 key 的值设为 value,并返回 1;若给定的 key 已经存在,则 SETNX 不做任何动作,并返回 0。

使用 Redis 构建锁:

思路:将“lock:”+ 参数名设置为锁的键,使用 SETNX 命令尝试将一个随机的 uuid 设置为锁的值,并为锁设置过期时间,使用 SETNX 设置锁的值可以防止锁被其他进程获取。如果尝试获取锁的时候失败,那么程序将不断重试,直到成功获取锁或者超过给定是时限为止。

代码:

public String acquireLockWithTimeout(Jedis conn, String lockName, long acquireTimeout, long lockTimeout)
 String identifier = UUID.randomUUID().toString(); // 锁的值
 String lockKey = "lock:" + lockName; // 锁的键
 int lockExpire = (int)(lockTimeout / 1000); // 锁的过期时间
 long end = System.currentTimeMillis() + acquireTimeout; // 尝试获取锁的时限
 while (System.currentTimeMillis() end) { // 判断是否超过获取锁的时限
 if (conn.setnx(lockKey, identifier) == 1){ // 判断设置锁的值是否成功
 conn.expire(lockKey, lockExpire); // 设置锁的过期时间
 return identifier; // 返回锁的值
 if (conn.ttl(lockKey) == -1) { // 判断锁是否超时
 conn.expire(lockKey, lockExpire);
 try {Thread.sleep(1000); // 等待 1 秒后重新尝试设置锁的值
 }catch(InterruptedException ie){Thread.currentThread().interrupt();
 // 获取锁失败时返回 null
 return null;
 }

锁的释放:

思路:使用 WATCH 命令监视代表锁的键,然后检查键的值是否和加锁时设置的值相同,并在确认值没有变化后删除该键。

代码:

public boolean releaseLock(Jedis conn, String lockName, String identifier) {
 String lockKey = "lock:" + lockName; // 锁的键
 while (true){conn.watch(lockKey); // 监视锁的键
 if (identifier.equals(conn.get(lockKey))){ // 判断锁的值是否和加锁时设置的一致,即检查进程是否仍然持有锁
 Transaction trans = conn.multi();
 trans.del(lockKey); // 在 Redis 事务中释放锁
 List Object results = trans.exec();
 if (results == null){ 
 continue; // 事务执行失败后重试(监视的键被修改导致事务失败,重新监视并释放锁)return true;
 conn.unwatch(); // 解除监视
 break;
 return false;
 }

总结

以上就是这篇文章的全部内容了,希望本文的内容对大家的学习或者工作具有一定的参考学习价值,谢谢大家对丸趣 TV 的支持。

向 AI 问一下细节

丸趣 TV 网 – 提供最优质的资源集合!

正文完
 
丸趣
版权声明:本站原创文章,由 丸趣 2023-12-18发表,共计6304字。
转载说明:除特殊说明外本站除技术相关以外文章皆由网络搜集发布,转载请注明出处。
评论(没有评论)