揭开面纱,追着 redis 进行七连问

8,823 阅读23分钟

Hello Redis 有几个问题想请教你

Hello,Redis! 我们相处已经很多年了,从模糊的认识到现在我们已经深入结合,你的好我一直都知道也一直都记住,能否在让我多问问你的几个问题,让我更加深入的去了解你。

1. redis的通讯协议是什么

image

redis的通讯协议是文本协议,是的,Redis服务器与客户端通过RESP(REdis Serialization Protocol)协议通信,没错,文本协议确实是会浪费流量,不过它的优点在于直观,非常的简单,解析性能及其的好,我们不需要一个特殊的redis客户端仅靠telnet或者是文本流就可以跟redis进行通讯。

客户端的命令格式:

  • 简单字符串 Simple Strings, 以 "+"加号 开头
  • 错误 Errors, 以"-"减号 开头
  • 整数型 Integer, 以 ":" 冒号开头
  • 大字符串类型 Bulk Strings, 以 "$"美元符号开头
  • 数组类型 Arrays,以 "*"星号开头
set hello abc
一个简单的文本流就可以是redis的客户端

image

简单总结

具体可以见 redis.io/topics/prot… ,redis文档认为简单的实现,快速的解析,直观理解是采用RESP文本协议最重要的地方,有可能文本协议会造成一定量的流量浪费,但却在性能上和操作上快速简单,这中间也是一个权衡和协调的过程。

2. redis究竟有没有ACID事务

image

要弄清楚redis有没有事务,其实很简单,上redis的官网查看文档,发现:

image
redis确实是有事务,不过按照传统的事务定义ACID来看,redis是不是都具备了ACID的特性,ACID指的是 1.原子性 2.一致性 3.隔离性 4.持久性,我们将使用以上redis事务的命令来检验是否redis都具备了ACID的各个特征。

原子性

事务具备原子性指的是,数据库将事务中多个操作当作一个整体来执行,服务要么执行事务中所有的操作,要不一个操作也不会执行。

事务队列

首先弄清楚redis开始事务multi命令后,redis会为这个事务生成一个队列,每次操作的命令都会按照顺序插入到这个队列中,这个队列里面的命令不会被马上执行,知道exec命令提交事务,所有队列里面的命令会被一次性,并且排他的进行执行。

image
对应 ->
image

从上面的例子可以看出,当执行一个成功的事务,事务里面的命令都是按照队列里面顺序的并且排他的执行。但原子性又一个特点就是要么全部成功,要不全部失败,也就是我们传统DB里面说的回滚。

当我们执行一个失败的事务:

image

可以发现,就算中间出现了失败,set abc x这个操作也已经被执行了,并没有进行回滚,从严格的意义上来将redis并不具备原子性。

为何redis不支持回滚

这个其实跟redis的定位和设计有关系,先看看为何我们的mysql可以支持回滚,这个还是跟写log有关系,redis是完成操作之后才会进行aof日志记录,aof日志的定位只是记录操作的指令记录,而mysql有完善的redolog,并且是在事务进行commit之前就会写完成redolog,binlog

image

要知道mysql为了能进行回滚是花了不少的代价,redis应用的场景更多是对抗高并发具备高性能,所以redis选择更简单,更快速无回滚的方式处理事务也是符合场景。

一致性

事务具备一致性指的是,如果数据库在执行事务之前是一致的,那么在事务执行之后,无论事务是否成功,数据库也应该是一致的。

从redis来说可以从2个层面,一个是执行错误是否有确保一致性,另一个是宕机时,redis是否有确保一致性的机制。

执行错误是否有确保一致性

image

依然去执行一个错误的事务,在事务执行的过程中会识别出来并进行错误处理,这些错误并不会对数据库作出修改,也不会对事务的一致性产生影响。

宕机对一致性的影响

暂不考虑分布式高可用的redis解决方案,先从单机看宕机恢复是否能满意数据完整性约束。

无论是rdb还是aof持久化方案,可以使用rdb文件或aof文件进行恢复数据,从而将数据库还原到一个一致的状态。

再议一致性 ❓❓

上面 执行错误 和 宕机 对一致性的影响的观点摘自黄健宏 《Redis设计与实现》,当在读这章的时候还是有一些存疑的点,归根到底redis并非关系型数据库,如果仅仅就ACID的表述上来说,一致性就是从A状态经过事务到达B状态没有破坏各种约束性,仅就redis而已不谈实现的业务,那显然就是满意一致性。

但如果加上业务去谈一致性,例如,A转账给B,A减少10块钱,B增加10块钱,因为redis并不具备回滚,也就不具备传统意义上的原子性,所以从redis也应该不具备传统的一致性。

其实,这里只是简单讨论下redis在传统ACID上的概念怎么进行对接,或许,有可能是我想多了,用传统关系型数据库的ACID去审核redis是没有意义的,redis本来就没有意愿去实现ACID的事务。

隔离性

隔离性指的是,数据库中有多个事务并发的执行,各个事务之间不会相互影响,并且在并发状态下执行的事务和串行执行的事务产生的结果是完全相同的。

redis 因为是单线程操作,所以在隔离性上有天生的隔离机制,当redis执行事务时,redis的服务端保证在执行事务期间不会对事务进行中断,所以,redis事务总是以串行的方式运行,事务也具备隔离性。

持久性

事务的持久性指的是,当一个事务执行完毕,执行这个事务所得到的结果被保存在持久化的存储中,即使服务器在事务执行完成后停机了,执行的事务的结果也不会被丢失。

redis是否具备持久化,这个取决于redis的持久化模式

  • 纯内存运行,不具备持久化,服务一旦停机,所有数据将丢失
  • RDB模式,取决于RDB策略,只有在满足策略才会执行bgsave,异步执行并不能保证redis具备持久化
  • aof模式,只有将appendfsync设置为always,程序才会在执行命令同步保存到磁盘,这个模式下,redis具备持久化

(将appendfsync设置为always,只是在理论上持久化可行,但一般不会这么操作)

简单总结

  • redis具备了一定的原子性,但不支持回滚
  • redis不具备ACID中一致性的概念(或者说redis在设计就无视这点)
  • redis具备隔离性
  • redis通过一定策略可以保证持久性

redis和ACID纯属站在使用者的角度去思想,redis设计更多的是追求简单与高性能,不会受制于传统ACID的束缚。

3. redis的乐观锁watch是怎么实现的?

当我们一提到乐观锁就会想起CAS(Compare And Set),CAS操作包含三个操作数—— 内存位置的值(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置更新为新值。否则,处理器不做任何操作。

在redis的事务中使用watch实现,watch 会在事务开始之前盯住 1 个或多个关键变量,当事务执行时 也就是服务器收到了 exec 指令要顺序执行缓存的事务队列时, Redis 会检查关键变量自 watch 之后,是否被修改了。

image

java的AtomicXXX的乐观锁机制

在java中我们也经常的使用到一些乐观锁的参数,例如AtomicXXX,这些机制的背后是怎么去实现的,是否redis也跟java的CAS实现机制是一样,先来看看java的Atomic类,我们追一下源码,可以看到它的背后其实是 Unsafe_CompareAndSwapObject

image

可以看见compareAndSwapObject是native方法,需要在继续追查,可以下载源码或打开 hg.openjdk.java.net/jdk8u/

image

image

cmpxchg

可以发现追查到最终cas,“比较并修改”,本来是两个语意,但是最终确实一条cpu指令cmpxchg完成,cmpxchg是一条CPU指令的命令而不是多条cpu指令,所以它不会被多线程的调度所打断,所以能够保证CAS的操作是一个原子操作。当然cmpxchg的机制其实存在ABA还有多次重试的问题,这个不在这里讨论。

redis的watch机制

redis的watch也是使用cmpxchg吗,两者存在相似之处也用法上也有一些不同,redis的watch不存在aba问题,也没有多次重试机制,其中有一个最重大的不同是:

redis事务执行其实是串行的,简单追一下源码: 摘录出来的源码可能有些凌乱,不错可以简单总结出来数据结构图和简单的流程图,之后再看源码就会清晰很多

image

image

image

image

存储

image

redisDb存放了一个watched_keys的dcit结构,每个被watch的key的值是一个链表结构,存放的是一组redis客户端标志。

流程

image

每一次watch,multi,exec时都会去查询这个watched_keys结构进行判断,每次touch到被watch的key时都会标志为 CLIENT_DIRTY_CAS

因为在redis中所有的事务都是串行的,假设有客户端A和客户端B都watch同一个key,当客户端A进行touch修改或者A率先执行完,会把客户端A从这个watched_keys的这个key的列表删除然后把这个列表所有的客户端都设置成CLIENT_DIRTY_CAS,当后面的客户端B开始执行时,判断到自己的状态是CLIENT_DIRTY_CAS,便discardTransaction终止事务。

简单总结

cmpxchg 的 实现主要是利用了cpu指令,看似两个操作使用一条cpu指令完成,所以不会被多线程进行打断。而redis的watch机制,更多是利用了redis本身单线程的机制,采用了watched_keys的数据结构和串行流程实现了乐观锁机制。

4. redis是如何持久化的

image

redis的持久化有两种机制,一个是RDB,也就是快照,快照就是一次全量的备份,会把所有redis的内存数据进行二进制的序列化存储到磁盘。另一种是aof日记,aof日志记录的是数据操作修改的指令记录日志,可以类比mysql的binlog,aof日期随着时间的推移只会无限增量。

在对redis进行恢复时,rdb快照直接读取磁盘既可以恢复,而aof需要对所有的操作指令进行重放进行恢复,这个过程有可能非常漫长。

image

RDB

redis在进行RDB的快照生成有两种方法,一种是save,由于redis是单进程单线程,直接使用save,redis会进行一个庞大的文件io操作,由于单进程单线程势必会阻塞线上的业务,一般的话不会直接采用save,而是采用bgsave,之前一直说redis是单进程单线程,其实不然,在使用bgsave的时候,redis会fork一个子进程,快照的持久化就交给子进程去处理,而父进程继续处理线上业务的请求。

fork机制

想要弄清楚RDB快照的生成原理就必须弄清楚fork机制,fork机制是linux操作系统的一个进程机制,当父进程fork出来一个子进程,子进程和夫进程拥有共同的内存数据结构,子进程刚刚产生时,它和父进程共享内存里面的代码段和数据段。

image

一开始两个进程都具备了相同的内存段,子进程在做数据持久化时,不会去修改现在的内存数据,而是会采用cow(copy on write)的方式将数据段页面进行分离,当父进程修改了某一个数据段时,被共享的页面就会复制一份分离出来,然后父进程再在新的数据段进行修改。

image

分裂

这个过程也成为分裂的过程,本来父子进程都指向很多相同的内存块,但是如果父进程对其中某个内存块进行该修改,就会将其复制出来,进行分裂再在新的内存块上面进行修改。

因为子进程在fork的时候就可以固定内存,这个时间点的数据将不会产生变化,所以我们可以安心的产生快照不用担心快照的内容收到父进程业务请求的影响,另外可以想象,如果在bgsave的过程中,redis没有任何操作,父进程没有接收到任何业务请求也没有任何的背后例如过期移除等操作,父进程和子进程将会使用相同的内存块。

AOF

AOF是redis操作指令的日志存储,类同于为mysql的binlog,假设AOF从redis创建以来就一直执行,那么AOF就记录了所有的redis指令的记录,如果要恢复redis,可以对AOF进行指令重放,便可修复整个redis实例,不过AOF日志也有两个比较大的问题,一个是AOF的日志会随着时间递增,如果一个数据量大运行的时间久,AOF日志量将变得异常庞大,另一个问题是AOF在做数据恢复时,由于重放的量非常庞大,恢复的时间将会非常的长。

AOF写操作是在redis处理完业务逻辑之后,按照一定的策略才会进行些aof日志存盘,这点跟mysql的redolog和binlog有很大的不同,其实也因为此原因,redis因为处理逻辑在前而记录操作日志在后,也是导致redis无法进行回滚的一个原因。

bgrewriteaof

针对上述的问题,redis在2.4之后也使用了bgrewriteaof对aof日志进行瘦身,bgrewriteaof 命令用于异步执行一个AOF文件重写操作。重写会创建一个当前AOF文件的体积优化版本。

RDB和AOF混合搭配模式

在对redis进行恢复的时候,如果我们采用了RDB的方式,因为bgsave的策略,可能会导致我们丢失大量的数据。如果我们采用了AOF的模式,通过AOF操作日志重放恢复,重放AOF日志比RDB要长久很多。

image

redis4.0之后,为了解决这个问题,引入了新的持久化模式,混合持久化,将rdb的文件和局部增量的AOF文件相结合,rdb可以使用相隔较长的时间保存策略,aof不需要是全量日志,只需要保存前一次rdb存储开始到这段时间增量aof日志即可,一般来说,这个日志量是非常小的。

5. redis在内存使用上是如何开源节流

image

redis跟其他传统数据库不同,redis是一个纯内存的数据库,并且存储了都是一些数据结构的数据,如果不对内存加以控制的话,redis很可能会因为数据量过大导致系统的奔溃

ziplist

127.0.0.1:6379> hset hash_test abc 1
(integer) 1
127.0.0.1:6379> object encoding hash_test
"ziplist"
127.0.0.1:6379> zadd z_test 10 key
(integer) 1
127.0.0.1:6379> object encoding z_test
"ziplist"

当最开始尝试开启一个小数据量的hash结构和一个zset结构时,发现他们在redis里面的真正结构类型是一个ziplist,ziplist是一个紧凑的数据结构,每一个元素之间都是连续的内存,如果在redis中,redis启用的数据结构数据量很小时,redis就会切换到使用紧凑存储的形式来进行压缩存储。

image

例如,上面的例子,我们采用了hash结构进行存储,hash结构是一个二维的结构,是一个典型的用空间换取时间的结构。但是如果使用的数据量很小,使用二维结构反而浪费了空间,在时间的性能上也并没有得到太大的提升,还不如直接使用一维结构进行存储,在查找的时候,虽然复杂度是O(n),但是因为数据量少遍历也非常快,增至比hash结构本身的查询更快。

如果当集合对象的元素不断的增加,或者某个value的值过大,这种小对象存储也会升级生成标准的结构。redis也可以在配置中进行定义紧凑结构和标准结构的转换参数:

hash-max-ziplist-entries 512  # hash的元素个数超过512就必须用标准结构存储
hash-max-ziplist-value 64     # hash的任意元素的key/value的长度超过 64 就必须用标准结构存储
list-max-ziplist-entries 512  
list-max-ziplist-value 64  
zset-max-ziplist-entries 128 
zset-max-ziplist-value 64  
set-max-intset-entries 512 

quicklist

127.0.0.1:6379> rpush key v1
(integer) 1
127.0.0.1:6379> object encoding key
"quicklist"

quicklist数据结构是redis在3.2才引入的一个双向链表的数据结构,确实来说是一个ziplist的双向链表。quicklist的每一个数据节点是一个ziplist,ziplist本身就是一个紧凑列表,假使,quicklist包含了5个ziplist的节点,每个ziplist列表又包含了5个数据,那么在外部看来,这个quicklist就包含了25个数据项。

image

quicklist的结构的设计简单总结起来,是一个空间和时间的折中方案:

  • 双向链表可以在两端进行push和pop操作,但是它在每一个节点除了保存自身的数据外,还要保存两个指针,增加额外的内存开销。其次是由于每个节点都是独立的,在内存地址上并不连续,节点多了容易产生内存碎片。
  • ziplist本身是一块连续的内存,存储和查询效率很高,但是,它不利于修改操作,每次数据变动时都会引发内存realloc,如果ziplist长度很长时,一次realloc会导致大批量数据拷贝。

所以,结合ziplist和双向链表的优点,quciklist就孕育而生。

对象共享

redis在自己的对象系统中构建了一个引用计数方法,通过这个方法程序可以跟踪对象的引用计数信息,除了可以在适当的时候进行释放对象,还可以用来作为对象共享。 举个例子,假使健A创建了一个整数值100的字符串作为值对象,这个时候键B也创建保存同样整数值100的字符串对象作为值对象,那么在redis的操作时:

  • 讲数据库键的指针指向一个现有的值对象
  • 讲被共享的值对象引用计数加一

image

假使,我们的数据库中指向整数值100的键不止键A和键B,而是有几百个,那么redis服务器中只需要一个字符串对象的内存就可以保存原本需要几百个字符串对象的内存才能保存的数据。

6. redis是如何实现主从复制

image

几个定义

  • runID 服务器运行的ID
  • offset 主服务器的复制偏移量和从服务器复制的偏移量
  • replication backlog 主服务器的复制积压缓冲区

在redis2.8之后,使用psync命令代替sync命令来执行复制的同步操作,psync命令具有完整重同步和部分重同步两种模式:

  • 其中完整同步用于处理初次复制情况:完整重同步的执行步骤和sync命令执行步骤一致,都是通过让主服务器创建并发送rdb文件,以及向从服务器发送保存在缓冲区的写命令来进行同步。
  • 部分重同步是用于处理断线后重复制情况:当从服务器在断线后重新连接主服务器时,主服务可以讲主从服务器连接断开期间执行的写命令发送给从服务器,从服务器只要接收并执行这些写命令,就可以讲数据库更新至主服务器当前所处的状态。

完整重同步:

image

  1. slave发送psync给master,由于是第一次发送,不带上runid和offset
  2. master接收到请求,发送master的runid和offset给从节点
  3. master生成保存rdb文件
  4. master发送rdb文件给slave
  5. 在发送rdb这个操作的同时,写操作会复制到缓冲区replication backlog buffer中,并从buffer区发送到slave
  6. slave将rdb文件的数据装载,并更新自身数据

如果网络的抖动或者是短时间的断链也需要进行完整同步就会导致大量的开销,这些开销包括了,bgsave的时间,rdb文件传输的时间,slave重新加载rdb时间,如果slave有aof,还会导致aof重写。这些都是大量的开销所以在redis2.8之后也实现了部分重同步的机制。

部分重同步:

image

  1. 网络发生错误,master和slave失去连接
  2. master依然向buffer缓冲区写入数据
  3. slave重新连接上master
  4. slave向master发送自己目前的runid和offset
  5. master会判断slave发送给自己的offset是否存在buffer队列中,如果存在,则发送continue给slave,如果不存在,意味着可能错误了太多的数据,缓冲区已经被清空,这个时候就需要重新进行全量的复制
  6. master发送从offset偏移后的缓冲区数据给slave
  7. slave获取数据更新自身数据

7. redis是怎么制定过期删除策略的

当一个键处于过期的状态,其实在redis中这个内存并不是实时就被从内存中进行摘除,而是redis通过一定的机制去把一些处于过期键进行移除,进而达到内存的释放,那么当一个键处于过期,redis会在什么时候去删除?几时被删除存在三种可能性,这三种可能性也代表了redis的三种不同的删除策略。

  • 定时删除: 在设置键过去的时间同时,创建一个定时器,让定时器在键过期时间来临,立即执行对键的删除操作。
  • 惰性删除: 放任键过期不管,但是每次从键空间获取键时,都会检查该键是否过期,如果过期的话,就删除该键。
  • 定期删除: 每隔一段时间,程序都要对数据库进行一次检查,删除里面的过期键,至于要删除多少过期键,由算法而定。

定时删除

设置键的过期时间,创建定时器,一旦过期时间来临,就立即对键进行操作操作,这种对内存是友好的,但是对cpu的时间是最不友好的,特别是在业务繁忙,过期键很多的时候,删除过期键这个操作就会占据很大一部分cpu的时间,要知道redis是单线程操作,在内存不紧张而cpu紧张的时候,将cpu的时间浪费在与业务无关的删除过期键上面,会对redis的服务器的响应时间和吞吐量造成影响。 另外,创建一个定时器需要用到redis服务器中的时间事件,而当亲时间事件的实现方式是无序链表,时间复杂度为O(n),让服务器大量创建定时器去实现定时删除策略,会产生较大的性能影响,所以,定时删除并不是一种好的删除策略。

惰性删除

与定时删除相反,惰性删除策略对cpu来说是最友好的,程序只有在取出键的时候才会进行检查,是一种被动的过程。与此同时,惰性删除对内存来说又是最不友好的,一个键过期,只要不再被取出,这个过期键就不会被删除,它占用的内存也不会被释放。 很明显,惰性删除也不是一个很好的策略,redis是非常依赖内存和骁好内存的,如果一些长期键长期没有被访问,就会造成大量的内存垃圾,甚至会操成内存的泄漏。

在对执行数据写入时,通过expireIfNeeded函数对写入的key进行过期判断,其中expireIfNeeded在内部做了三件事情,分别是:

  • 查看key是否过期
  • 向slave节点传播执行过去key的动作
  • 删除过期key

image

定期删除

上面两种删除策略,无论是定时删除和惰性删除,这两种删除方式在单一的使用上都存在明显的缺陷,要么占用太多cpu时间,要么浪费太多内存。定期删除策略是前两种策略的一个整合和折中

  • 定期删除策略每隔一段时间执行一次删除过期键操作,并通过限制删除操作执行的时间和频率来减少删除操作对cpu时间影响
  • 通过合理的删除执行的时长和频率,来达到合理的删除过期键

总结

可以说redis可谓博大精深,简单的七连问或者只是盲人摸象,或者这次只是摸到了一根象鼻子,或者还应该顺着鼻子向下摸,下次可能摸到了一只象耳朵,只要愿意往下深入去了解去摸索,而不只应用不思考,总有一天会把redis这种大象给摸透了。

[ 注:部分章节参考和引用黄健宏 《Redis设计与实现》]