Skip to main content

Redis知识点总结

· 25 min read
何轲

📝Redis知识点总结。

数据结构

Redis常用数据结构5种:String、List、Set、ZSet和Hash,这些数据结构又由如下底层数据结构支撑。

SDS

Redis的字符串类型基于SDS(Simple Dynamic String),相比于C语言的字符串,它具有如下优势:

  1. 字段len记录字符串长度,不需要使用strlen方法遍历字符串计算;
  2. 空间预分配:为字符串分配空间时会申请额外的空间;
  3. 惰性空间释放:SDS缩短时不会回收多余空间,而是使用free字段记录多余空间,后续append操作直接使用减少内存申请次数。

zipList

压缩列表是list、hash和sorted set底层实现,压缩列表并不是指使用某种压缩算法来节省空间,而是使用连续内存空间来节省,ziplist又分为如下部分:

  • zlbytes:4bytes,保存ziplist占用的内存字节数;
  • zltail:4bytes,保存尾节点到起始地址的偏移量,可以快速定位到尾节点;
  • zllen:2bytes,保存压缩列表中的节点个数;
  • entry:列表节点
    • previous_entry_length:前一个节点的长度;
    • encoding:content的内容类型和长度;
    • content:节点内容。
  • zlend:表示压缩列表结束的特殊符号0xFF

skipList

跳表skipList是一种有序的数据结构,Redis的sorted set(zset)基于它实现。

Redis为什么快?

单线程模型

Redis的单线程模式是指Redis网络IO(Redis 5.x之后为多线程)以及K-V读写由一个线程来执行,而Redis持久化、集群同步和异步删除是由其他线程执行,并不是说Redis程序就一个线程工作。Redis键值对的读写是单线程的,具有如下优势:

  1. 避免多线程上下文切换时的开销;
  2. 避免线程创建开销;
  3. 避免多线程竞争问题;
  4. 代码清晰,逻辑简单。

I/O多路复用

Redis使用epoll加自行实现的事件框架来处理连接请求,不会阻塞在某个特定的客户端请求处理上,因此可以同时和多个客户端连接处理请求,提升并发性。

全局Hash表

Redis使用一个全局Hash表来保存所有键值对,如下图所示。key类型为String,value类型为redisObject。

如何处理Hash冲突

Redis使用链表方解决hash冲突,但是当链表长度过长时就会导致查询性能下降,此时需要增加底层hash数组的长度并rehash来进行扩容。和Java HashMap在hash数组负载达到0.75不同,Redis hash表在负载为1时进行扩容rehash,并且采用渐进式rehash。hash表有2个底层数组以及rehashidx标识,初始hash表长度为4,rehashidx为-1,当元素个数达到4时扩容为原来2倍。

  1. 为ht[1]分配空间,让全局hash表同时指向ht[0]和ht[1]
  2. 设置rehashxid为0,标识rehash开始;
  3. 每次增删改查时,将ht[0]的元素rehash到ht[1],rehashidx加1;
  4. 随着操作执行,最终ht[0]的元素都会被rehash到ht[1]上,此时将rehashidx置为-1,标识rehash结束。

持久化

为了避免放在内存中的缓存数据因为故障而丢失,Redis提供RDB和AOF两种持久化机制。

RDB

RDB(Redis Database)是一种快照持久化方式,它将Redis某一时刻的内存数据保存到硬盘文件中,默认文件名为dump.rdb,在Redis服务启动时会重新加载该文件到内存中以恢复数据。RDB通过如下方式开启:

  1. save命令:同步操作,Redis服务会阻塞save命令之后的所有客户端请求,当快照数据量过大时save操作执行时间较长,因此避免在生产环境中使用;
  2. bgsave命令:异步操作,Redis服务主进程forks一个子进程将数据保存到dump.rdb文件,主进程仍可以接收其他请求但子进程是同步的;
  3. 配置文件:在redis.conf配置文件中配置,格式为save N M,表示在N秒内达到M条写命令则进行一次数据保存(与bgsave类似)。

无论是由主进程还是子进程,生成dump.rdb文件步骤为:生成临时rdb文件并写入数据,完成数据写入用临时文件代替原rdb文件,删除原rdb文件。与rdb相关的配置有:

  1. 是否开启压缩:rdbcompression yes[no];
  2. 文件名称:dbfilename <filename>;
  3. 存储路径:dir <path>

😄优点:

  • 与aof相比恢复数据速度更快;
  • 文件格式紧凑,适合数据备份;
  • 使用子进程备份数据,有Redis主进程服务影响小

😠缺点:

  • 服务器故障会丢失上一次成功备份以来的数据;
  • 使用save命令会阻塞主进程,直到保存完成;
  • 使用bgsave命令在数据量太大时也会发生阻塞。

AOF

AOF(Append-only File)记录客户端每次的写操作命令到缓冲区,然后将缓冲区数据以Redis协议追加保存到appendonly.aof文件尾部,在Redis服务启动时会加载并执行aof文件中的命令,从而恢复数据。与aof相关的配置有:

  1. 是否开启aof:appendonly yes[no]
  2. 文件名称:appendfilename <filename>
  3. 写入策略:appendfsync always[everysec][no]
  4. 是否重写aof文件,默认否:no-appendfsync-on-rewrite no
  5. 存储路径:dir <path>

其中,写入策略可选值及其含义如下:

  1. always:每个写操作都保存到aof文件中,速度慢;
  2. everysec:每秒写入一次aof文件,最多会丢失1s的数据;
  3. no:由操作系统决定什么时候写入aof文件,不推荐。

aof重写值将多个写操作合并生成为等价的最小命令集,比如incr num 1...incr num 10000可以重写为set num 10000(aof是二进制文件,并不是直接存储命令,仅是示例说明)。由于重写在每次fsync操作时进行,会影响服务性能,因此默认关闭。客户端可以通过bgrewriteaof命令让服务端进行aof重写。重写aof可以压缩aof文件大小,并且加快数据恢复速度。

AOF文件损坏怎么办?

在写入aof文件时Redis服务发生故障,此时aof文件会出现格式错误,可以先复制aof文件,然后通过redis-check-aof -fix <appendonly.aof>修复。

😄优点:AOF只是追加文件,对服务器性能小,保存数据时比RDB快且消耗内存少;

😠缺点:生成日志文件太大(即使经过重写),恢复速度比RDB慢。

RDB vs AOF

对比项\方案RDBAOF
工作负荷
恢复速度
文件体积
数据安全会丢数据由策略决定

当同时开启AOF和RDB时,Redis优先使用AOF来恢复数据,因为AOF保存记录比RDB更加完整

数据淘汰策略

这里将Redis的数据淘汰策略分为2类:针对过期键值对的删除策略和针对所有键值对的内存淘汰策略。

过期键删除策略

通过EXPIRE和PERSIST命令分别设置键过期时间或者永久有效,Redis通过如下3种策略删除过期键值对:

删除策略工作机制优点缺点
定时删除每个有过期时间的key创建对应的定时器,到点立即删除减少内存占用占用大量CPU资源,影响缓存响应时间和吞吐量
惰性删除当访问key时判断其是否过期再删除最大化地节省CPU资源大量过期key没被访问会占用大量内存
定期删除每隔一段时间扫描过期key并清除定时、惰性删除的折中方案难以确定间隔时长

Redis实际使用惰性删除和定期删除的组合在CPU资源和内存资源之间平衡。

内存淘汰策略

当Redis用于缓存的内存不足时,通过如下淘汰策略处理数据:

淘汰策略作用范围工作机制
noeviction(默认)所有key内存不足时不淘汰数据,抛出OOM异常
allkeys-lru所有key移除最近最少被使用key
allkeys-random所有key随机删除key
volatile-lru过期key移除最近最少被使用key
volatile-random过期key随机删除key
volatile-ttl过期key删除过期时间更早key

Redis内存淘汰策略不会影响过期key删除的处理,前者用于内存不足时,后者用于处理过期的缓存数据。

缓存失效情况

缓存雪崩

出现原因:极短时间内,查询大量key集中失效或者缓存服务失效,导致所有请求转到数据库,对数据库造成压力。

解决方案:

  1. 加锁排队,控制请求;
  2. 设置过期标记更新缓存;
  3. 构建多级缓存架构;
  4. 不同key过期时间分散开,避免集中失效。

缓存穿透

出现原因:redis查不到数据,再深一点的就是这个key没有值,或者恶意请求不存在的key,redis没有并且数据库也没有,进行了2次无用的查询。

解决方案:

  1. 设置null缓存,设置较短的过期时间
  2. 设置白名单,排除恶意请求ip
  3. 使用布隆过滤器

缓存击穿

出现原因:某个key过期,大量访问请求该key,导致数据库压力增大。

解决方案:

  1. 预先设置热门数据;
  2. 实时调整过期时间;
  3. 使用锁。
总结

缓存雪崩是大量记录过期失效导致大量请求查询不到,缓存击穿是某条记录失效又有大量请求查询它导致查询不到,结果都是请求压力转到了数据库。而缓存穿透是指查询根本不存在的缓存导致请求都转到数据库。

缓存预热

系统上线后先直接把缓存数据加载到redis,不是等到第一次用户请求时先查询数据库再加载到redis。

缓存一致性

更新缓存的策略有如下4种方案:

  1. 先更新缓存,再更新数据库;
  2. 先更新数据库,再更新缓存;
  3. 先删除缓存,再更新数据库;
  4. 先更新数据库,再删除缓存;

制定正确的缓存更新策略第一点需要考虑更新操作失败的情况:对于方案1,如果缓存更新成功但数据库更新失败,出现缓存不一致。读操作先拿到缓存的最新值,但缓存失效后就会从数据库取到“旧值”,因此排除这种方案。对于方案2,如果数据库更新成功但是缓存更新失败,此后读缓存是“旧值”,当缓存失效后又得到正确值。

第二需要考虑的是操作并发性:对于方案3,如果更新数据库之前有查询请求,则将会脏数据刷新到缓存。对于方案4,如果更新数据库之前有查询请求,并且缓存失效,则会查询数据库“旧值”并更新缓存。

那到底是选择删除缓存还是更新缓存?一般选择删除缓存,理由如下:

  1. 更新缓存有维护成本,存在并发更新问题;
  2. 写多读少场景下,读请求还没来,缓存就被频繁更新,没有起到缓存作用;
  3. 更新缓存值可能经过复杂计算,每次更新缓存值浪费性能;
  4. 删除缓存操作简单,缺点仅是造成一次cache miss。

如果更新缓存开销小并且读多写少,基本没有并发写时才更新缓存,否则一般使用删除缓存。

延迟双删

当选择先删除缓存再更新数据库时,可能会出现如下并发操作:

  1. 线程A更新X为2,旧值为1;
  2. 线程A先删除缓存;
  3. 线程B读缓存未命中,于是从数据库读到旧值1并设置缓存;
  4. 线程A将新值2写入数据库;
  5. 线程B将旧值1写入缓存。

如此形成了缓存为旧值,数据库为新值的不一致情况,为此延迟双删被提出以解决该问题,其流程如下:

  1. 线程A更新X为2,旧值为1;
  2. 线程A删除缓存;
  3. 线程B读缓存未命中,从数据库读到旧值1并设置缓存;
  4. 线程A根据估算休眠一段时间,该时间大于线程B读数据加设置缓存的耗时,结束后再次删除缓存;
  5. 其他线程读缓存未命中,从数据库获取最新值并设置缓存。

延迟双删解决了并发读写缓存导致的旧值回写问题,通过第2次的延迟删除确保其他线程写的旧值被删掉,其缺点是休眠时间难以评估。

消息队列

无论是先操作缓存还是先操作数据库,都有可能执行失败,此时需要通过重试来确保操作成功。但是在本地的立即重试大概率还是失败,并且占用线程资源,并且程序重启会丢失该重试请求。为此,提出异步重试:将重试请求写入到消息队列中,由专门的消费者来重试直到成功,它利用消息队列保证可靠性和消息成功投递的特性。

更进一步地,为了免去应用程序与消息中间件交互,可以通过监听数据库bin log日志的中间件(如Canal)来投递删除请求。这样应用程序只需要和MySQL交互,无需考虑写消息队列失败请求,只要MySQL更新记录成功,bin log肯定产生相应日志,并由Canal自动投递删除请求到消息队列。

小结

延迟双删解决的是并发场景下缓存写回旧值问题,消息队列解决的是删除操作失败问题,本质是异步重试。

高可用

Redis实现高可用有2种方案:主从复制、哨兵模式。

主从复制

主从复制指将Redis主节点数据复制到从节点,又分为全量复制和部分复制2种。Redis的全量复制又分新旧版本,在旧版本中全量复制通过sync命令实现,其流程为:

  1. 从服务器向主服务器发送sync命令(从节点拉);
  2. 主服务器收到sync命令后,调用bgsave命令生成rdb文件,将其发送给从服务器;
  3. 主服务器将命令缓冲区中的写操作发送到从服务器,后者执行这些命令后,状态和主服务器当前状态保持一致。

新版本中Redis使用psync代替sync,该命令既可以实现全量复制也可实现部分复制。相关概念:

  • 复制偏移量:主、从服务器都会维护一个复制偏移量,当其同步了N字节数据后便将复制偏移量加N;
  • 复制积压缓冲区:主服务器维护的先进先出队列,默认大小1MB,主服务器发送写命令给从服务器时,也将命令写入到该缓冲区;
  • 服务器运行id:每个Redis服务器启动时生成的id,主服务器将其运行id发送给从服务器。

基于如上概念,psync命令执行流程如下图所示:

JMM.svg

从服务器发送psync命令后,主服务响应有如下3种情况:

  1. 返回+fullresync <runid> <offset>,表示全量复制;
  2. 返回+continue,表示部分复制;
  3. 返回-err,表示主服务器Redis版本低于2.8,无法识别psync命令,此时从服务器发送sync命令执行全量复制。

😠主从复制的缺点:

  1. 主节点宕机后,从节点省升级为主节点,此时需要手动修改客户端应用的主节点地址以及执行命令让所有从节点复制数据;
  2. 主节点存储、写性能受单机限制,没有用到多机扩展性能;
  3. 全量同步会造成毫秒级卡顿。

哨兵模式

哨兵是Redis服务的一种运行模式,Redis哨兵节点的功能有:

  1. 监控:持续监控master、slave节点是否处于预期工作状态;
  2. 切换主节点:当master运行故障,哨兵模式自动从slave中选择一台作为新master;
  3. 通知:让slave执行replicaof命令与新master同步,通知客户端与新master连接。

主观下线与客观下线

哨兵节点向其他所有节点发送PING命令,如果slave节点没有返回有效回复则任务标记该节点为主观下线(哨兵自己认为该节点异常)。PING命令回复有两种情况:

  1. 有效回复:+PONG、-LOADING或-MASTERDOWN任意一种;
  2. 无效回复:有效回复外的回复,或者未在指定时间内返回任务回复。

对于master节点,不能仅靠一个哨兵就判断其是否故障,只有当超过半数的哨兵判断master主观下线时,才标记其为客观下线(客观事实,真的不行了),此后触发主从切换流程。

新master选举

当master被标记为客观下线后,需要从slave中选举出新master,该流程分为两步:先过滤再评比。

  1. 过滤:筛选掉下线的slave节点,或者根据slave节点和master节点断开次数是否超过阈值;
  2. 评比:按照如下优先顺序依次比较选择优先slave
    1. slave优先级,通过slave-priority参数配置,优先级高选为新master;
    2. 复制进度,通过比较slave_repl_offsetmaster_repl_offset的差距选择新master;
    3. slave runID,id最小的选为新master

通知

哨兵将新master连接信息发送给其他slave节点,让其执行replaceof命令和新master建立连接并开始复制数据,同时要将新master连接信息发送给客户端。

哨兵集群

在了解完主从切换的流程后,接来下需要解答的问题有:

  • 哨兵如何发现彼此? 哨兵与master建立连接后通过master的发布/订阅机制发布自己信息,彼此之间互相发现建立连接。
  • 哨兵如何发现并监控slave? 哨兵向master发送INFO命令,由master返回所有slave节点信息。
  • 哪个哨兵来执行主从切换? 任何一个哨兵判断master主观下线后,向其他哨兵发送is-master-down-by-addr命令,其他哨兵响应投票,如果超过一半哨兵返回Y,则可以标记master客观下线。或者多数赞成票的哨兵可以向其他哨兵声明想执行主从切换,并让其他哨兵投票,该过程称为Leader选举。成为leader的条件是:或者过半赞成票并且赞成票数必须大于等于哨兵配置中quorum值。
哨兵配置

通过sentinel monitor <master-name> <ip> <redis-port> <quorum>配置该哨兵节点连接的master节点信息和quorum值。

  • 如何通知客户端? 还是通过订阅发布机制。