《阿里开发者手册 Redis专题》

You might also like

Download as pdf or txt
Download as pdf or txt
You are on page 1of 119

封面页

(PDF 内更新)
阿里云开发者“藏经阁”
海量电子手册免费下载
卷首语

《阿里开发者手册》每期将聚焦一个当下热门的技术领域,收纳阿里技术大神
的实战精华,手册共分「乘云上」、「正当时」和「创新汇」三个栏目。「乘云上」
涵盖开发者上云时遇到的难题解决方案,为开发者提供相应的技术指导,并提供体
系化、集成式的实操案例;「正当时」主打纯技术分享,帮助开发者突破技术难题;
「创新汇」聚焦技术发展的未来,带领开发者学习新技术,为开发者分享新产品、
解读新趋势。

本期《阿里开发者手册》将走进 Redis 的世界,以默达、春潭、一洺、仲肥、


驱动的 5 位优秀的阿里资深程序员的技术文章为“探测仪”,深入探索 Redis 的缤
纷世界。Redis 是一个开源的、键值对存储数据库,它可以用作数据库、缓存和消息
中间件。在云存储无处不在的今天,拥有一款读写性能优异且能够支持持久化存储
的缓存技术产品显得尤为重要,而 Redis 恰恰好能满足开发者日常云上缓存需求。

本书将详解 Redis 的开发应用、性能优化以及 Redis7.0 的前世今生,让开发者


全方面了解 Redis 的产品功能及应用实战,帮助更多的开发者减轻数据压力,提升
日常工作的研发效率。 《阿里开发者手册》旨在帮助开发者了解到多种技术产品,
聚焦专业的技术领域。同时,帮助读者了解并学习到该产品创建以来的生态环境、
设计思想、开发模式和习惯用法,实际运用于日常工作与运维。让我们下期再见!
目录

「乘云上」 ............................................................................. 5

基于 Redis 实现特殊的消息队列 ................................................................................. 6

「正当时」 ........................................................................... 20

Redis 性能优化 ........................................................................................................... 21

一文搞懂 Redis ........................................................................................................... 45

「创新汇」 ........................................................................... 92

从 Redis7.0 发布看 Redis 的过去与未来 ................................................................... 93

Redis 7.0 Multi Part AOF 的设计和实现 .................................................................. 103


「乘云上」 5

「乘云上」

(间隔页,PDF 中更新)
基于 Redis 实现特殊的消息队列 6

基于 Redis 实现特殊的消息队列

作者:默达

说到消息队列,首先映入脑海的就是 Kafka 等,消息队列在各个领域都发挥了很大


的作用。但是,在一些场景下,传统的消息队列 Kafka 无法满足需求,比如以下场
景:

• 消息重复概率比较高时,需要对重复消息进行合并处理避免浪费有限的资源,
减少消费延迟;
• 需要根据业务自定义优先级进行消息处理,高优先级的消息比低优先级的消息
先处理;
• 消息需要定时消费的场景,消息只有在设定的消费时间到了之后立马被消费。

本文将介绍一种基于 Redis 实现的消息队列(Redis message queue, RMQ),RMQ


可以作为传统消息队列的互补选择,在传统消息队列没有涉及的场景中使用 RMQ。

一、 功能介绍

RMQ 设计为一个二方库,可以帮助用户基于 Redis 快速实现消息队列的功能,RMQ


消息队列具有消息合并、区分优先级、支持定时消息等特性。RMQ 消息队列可以用
于异步解耦、削峰填谷,支持亿级数据堆积。

RMQ 消息队列目前支持三种类型的消息,分别是:RangeMergeMessage(区间重
复合并消息)、PriorityMessage(优先级消息)、FixedTimeMessage(任意定时消
息)。

1. 区间重复合并消息

RangeMergeMessage 支持区间重复消息合并,发送消息时需要设置时间区间,消
息延迟该时间区间长度后被消费,在该时间区间内如果发送重复的消息,重复消息
基于 Redis 实现特殊的消息队列 7

将会被合并。如果消息在 Redis 服务端发生堆积,重复到来的消息依然会被合并处


理。该类型消息适用于消息重复率较高且希望重复消息合并处理的场景,对重复消
息进行合并可以减少下游消费系统的压力,减少不必要的资源消耗,将有限的资源
最大化的利用,提升消费效率。

2. 优先级消息

PriorityMessage 支持给消息设置任意等级的优先级,优先级高的消息会被优先消费,
相同优先级的消息被随机消费。如果消息在 Redis 服务端发生堆积,重复的消息将
被合并处理,合并后消息的优先级等于最后存储的消息的优先级。该类型消息适用
于希望重复消息合并处理且需要设置优先级的场景,下游消费者资源有限时,合并
重复消息且优先处理优先级高的消息将可以合理利用有限的资源。

3. 任意定时消息

FixedTimeMessage 支持给消息设置任意消费时间,只有消费时间到了之后消息才
被消费,消费时间可精确到秒。消息到期后没有及时被消费时,消费者将按照时间
由远及近进行消费。如果消息在 Redis 服务端发生堆积,重复的消息将被合并处理,
合并后消息的消费时间等于最后存储的消息的消费时间。该类型消息适用于希望重
复消息合并处理且需要定时消费的场景,定时消息应用场景非常丰富,比如定时打
标去标、活动结束后清理动作、订单超时关闭等。

4. 并发消费控制

使用传统消息中间件进行集群消费的时候,为了避免并发处理同一元数据导致不一
致问题,通常需要对元数据加分布式锁,频繁的锁冲突会导致消费效率低下。加分
布式锁的最终目的其实就是保障属于同一元数据的消息被串行消费。加分布式锁并
不是最好的方案,最好的方案应该是从根上解决并发问题,让属于同一元数据的消
息串行消费。
基于 Redis 实现特殊的消息队列 8

RMQ 消息队列具有并发消费控制能力,属于同一元数据的消息只会被分配给全局唯
一一个线程进行消费,因此属于同一元数据的消息将被串行消费。使用方如果需要
该能力,除了需要提供 Redis,还需要提供 ZooKeeper。

5. 重试次数控制

RMQ 消息队列支持失败重试消费 16 次,业务返回消费失败后,消息会被回滚并等


待重试消费,重试 16 次后消息进入死信队列,消息不再被消费,除非人工干预。

二、 技术原理

1. 总体框架
基于 Redis 实现特殊的消息队列 9

RMQ 消息队列由三部分组成,分别为 ZooKeeper、RMQ 二方库、Redis。

• ZooKeeper 负责维护集群 worker 的信息,将 topic 的所有 slot 分配给全局的


woker;
• Redis 负责存储消息,采用 Sorted Set 结构存储,Store Queue 是消息存放的队
列,Prepare Queue 是采用二阶段消费方式正在消费的消息存放队列,Dead
Queue 是死信队列;
• RMQ 二方库由 RmqClient、Consumer、Producer 三部分组成:
ü RmqClient 负 责 RMQ 的 启 动 工 作 , 包 括 上 报 TopicDef 、 Worker 给
ZooKeeper,分配 Slot 给 Worker,扫描业务定义的 MessageListener Bean;
ü Producer 负责根据不用消息类型将消息按照指定的方式存储到 Redis;
ü Consumer 负责根据不用消息类型按照指定方式从 Redis 弹出消息并调用
业务的 MessageListener。

2. 消息存储
基于 Redis 实现特殊的消息队列 10

1) Topic 的设计

Topic 的定义有三部分组成,topic 表示主题名称、slotAmount 表示消息存储划分


的槽数量、topicType 表示消息的类型。

主题名称是一个 Topic 的唯一标示,相同主题名称 Topic 的 slotAmount 和 topicType


一定是一样的消息存储采用 Redis 的 Sorted Set 结构,为了支持大量消息的堆积,
需要把消息分散存储到很多个槽中,slotAmount 表示该 Topic 消息存储共使用的槽
数量,槽数量一定需要是 2 的 n 次幂。在消息存储的时候,采用对指定数据或者消
息体哈希求余得到槽位置。

2) StoreQueue 的设计

上图中 topic 划分了 8 个槽位,编号 0-7。

如果发送方指定了消息的 slotBasis,则计算 slotBasis 的 CRC32 值,CRC32 值对槽


数量进行取模得到槽序号,SlotKey 设计为#{topic}_#{index}(也即 Redis 的键),
其中#{}表示占位符。

发送方需要保证相同内容的消息的 slotBasis 相同,如果没有指定 slotBasis 则采用


消息内容计算 SlotKey,这样内容相同的消息体就会落在同一个 Sorted Set 里面,
所以内容相同的消息会进行合并。

Redis 的 Sorted Set 中的数据按照分数排序,实现不同类型的消息的关键就在于如


何利用分数、如何添加消息到 Sorted Set、如何从 Sorted Set 中弹出消息。

• 优先级消息将优先级作为分数,消费时每次弹出分数最大的消息;
• 任意定时消息将时间戳作为分数,消费时每次弹出分数大于当前时间戳的一个
消息;
• 区间重复合并消息将时间戳作为分数,添加消息时将(当前时间戳+时间区间)
作为分数,消费时每次弹出分数大于当前时间戳的一个消息。
基于 Redis 实现特殊的消息队列 11

3) PrepareQueue 的设计

为了保障 RMQ 消息队列的可用性,做到每条消息至少消费一次,消费者不是直接


pop 有序集合中的元素,而是将元素从 StoreQueue 移动到 PrepareQueue 并返回
消息给消费者,等消费成功后再从 PrepareQueue 从删除,或者消费失败后从
PreapreQueue 重新移动到 StoreQueue,这便是根据二阶段提交的思想实现的二
阶段消费。

在后面将会详细介绍二阶段消费的实现思路,这里重点介绍下 PrepareQueue 的存
储设计。

StoreQueue 中每一个 Slot 对应 PrepareQueue 中的 Slot,PrepareQueue 的 SlotKey


设计为 prepare{#{topic}#{index}}。PrepareQueue 采用 Sorted Set 作为存储,消
息移动到 PrepareQueue 时刻对应的(秒级时间戳*1000+重试次数)作为分数,字
符串存储的是消息体内容。这里分数的设计与重试次数的设计密切相关,所以在重
试次数设计章节详细介绍。

PrepareQueue 的 SlotKey 设计中需要注意的一点,由于消息从 StoreQueue 移动


到 PrepareQueue 是通过 Lua 脚本操作的,因此需要保证 Lua 脚本操作的 Slot 在
同一个 Redis 节点上。

如何保证 PrepareQueue 的 SlotKey 和对应的 StoreQueue 的 SlotKey 被 hash 到


同一个 Redis 槽中呢?Redis 的 hash tag 功能可以指定 SlotKey 中只有某一部分参
与计算 hash,这一部分采用{}包括,因此 PrepareQueue 的 SlotKey 中采用{}包括
了 StoreQueue 的 SlotKey。

4) DeadQueue 的设计

消息重试消费 16 次后,消息将进入 DeadQueue。DeadQueue 的 SlotKey 设计为


prepare{#{topic}#{index}},这里同样采用 hash tag 功能保证 DeadQueue 的
SlotKey 与对应 StoreQueue 的 SlotKey 存储在同一 Redis 节点。
基于 Redis 实现特殊的消息队列 12

3. 生产者

生产者的任务就是将消息添加到 Redis 的 Sorted Set 中。首先,需要计算出消息添


加到 Redis 的 SlotKey,如果发送方指定了消息的 slotBasis(否则采用 content 代
替),则计算 slotBasis 的 CRC32 值,CRC32 值对槽数量进行取模得到槽序号,
SlotKey 设计为#{topic}_#{index},其中#{}表示占位符。然后,不同类型的消息有不
同的添加方式,因此分布讲述三种类型消息的添加过程。

1) 区间重复合并消息

发送该消息时需要设置 timeRange,timeRange 必须大于 0,单位为毫秒,表示消


息将延迟 timeRange 毫秒后被消费,期间到来的重复消息将被合并,合并后的消息
依然维持原来的消费时间。因此在存储该类型消息的时候,采用(当前时间戳
+timeRange)作为分数,添加消息采用 Lua 脚本执行,保证操作的原子性,Lua 脚
本首先采用 zscore 命令检查消息是否已经存在,如果已经存在则直接返回,如果不
存在则执行 zadd 命令添加。

2) 优先级消息

发送该消息时需要设置 priority,priority 必须大于 16,表示消息的优先级,数值越


大表示优先级越高。因此在存储该类型消息的时候,采用 priority 作为分数,采用
zadd 命令直接添加。

3) 任意定时消息

发送该类型消息时需要设置 fixedTime,fixedTime 必须大于当前时间,表示消费时


间戳,当前时间大于该消费时间戳的时候,消息才会被消费。因此在存储该类型消
息的时候,采用 fixedTime 作为分数,采用命令 zadd 直接添加。

4. 消费者

1) 二阶段消费方式
基于 Redis 实现特殊的消息队列 13

a) 三种消费模式

一般消息队列存在三种消费模式,分别是:最多消费一次、至少消费一次、只消费
一次。

• 最多消费一次模式消息可能丢失,一般不怎么使用;
• 至少消费一次模式消息不会丢失,但是可能存在重复消费,比较常用;
• 只消费一次模式消息被精确只消费一次,实现较困难,一般需要业务记录幂等
ID 来实现。

RMQ 实现了至少消费一次的模式,那么如何保证消息至少被消费一次呢?

b) 至少消费一次模式实现的难点

从最简单的消费模式——最多消费一次说起,消费者端只需要从消息队列服务中取
出消息就行,即执行 Redis 的 zpopmax 命令,不伦消费者是否接收到该消息并成
功消费,消息队列服务都认为消息消费成功。

最多一次消费模式导致消息丢失的因素可能有:

• 网络丢包导致消费者没有接收到消息;
• 消费者接收到消息但在消费的时候宕机了;
• 消费者接收到消息但消费失败。

针对消费失败导致消息丢失的情况比较好解决,只需要把消费失败的消息重新放入
消息队列服务就行,但是网络丢包和消费系统异常导致的消息丢失问题不好解决。
可能有人会想到,我们不把元素从有序集合中 pop 出来,我们先查询优先级最高的
元素,然后消费,再删除消费成功的元素,但是这样消息服务队列就变成了同步阻
塞队列,性能会很差。
基于 Redis 实现特殊的消息队列 14

c) 至少消费一次模式的实现

至少消费一次的问题比较类似银行转账问题,A 向 B 账户转账 100 元,如何保障 A


账户扣减 100 同时 B 账户增加 100,因此我们可以想到二阶段提交的思想。

• 第一个准备阶段,A、B 分别进行资源冻结并持久化 undo 和 redo 日志,A、B


分别告诉协调者已经准备好;
• 第二个提交阶段,协调者告诉 A、B 进行提交,A、B 分别提交事务。

RMQ 基于二阶段提交的思想来实现至少消费一次的模式。RMQ 存储设计中


PrepareQueue 的作用就是用来冻结资源并记录事务日志,消费者端既是参与者也
是协调者。

• 第一个准备阶段,消费者端通过执行 Lua 脚本从 StoreQueue 中 Pop 消息并存


储到 PrepareQueue,同时消息传输到消费者端,消费者端消费该消息;
• 第二个提交阶段,消费者端根据消费结果是否成功协调消息队列服务是提交还
是回滚,如果消费成功则提交事务,该消息从 PrepareQueue 中删除,如果消
费失败则回滚事务,消费者端将该消息从 PrepareQueue 移动到 StoreQueue,
如果因为各种异常导致 PrepareQueue 中消息滞留超时,超时后将自动执行回
滚操作。

二阶段消费的流程图如下所示:
基于 Redis 实现特殊的消息队列 15

d) 实现方案的异常情况分析

我们来分析下采用二阶段消费方案可能存在的异常情况,从以下分析来看二阶段消
费方案可以保障消息至少被消费一次。

• 网络丢包导致消费者没有接收到消息,这时消息已经记录到 PrepareQueue,
如果到了超时时间,消息被回滚放回 StoreQueue,等待下次被消费,消息不丢
失。
• 消费者接收到了消息,但是消费者还没来得及消费完成系统就宕机了,消息消
费超时到了后,消息会被重新放入 StoreQueue,等待下次被消费,消息不丢失。
基于 Redis 实现特殊的消息队列 16

• 消费者接收到了消息并消费成功,消费者端在协调事务提交的时候宕机了,消
息消费超时到了后,消息会被重新放入 StoreQueue,等待下次被消费,消息被
重复消费。
• 消费者接收到了消息但消费失败,消费者端在协调事务提交的时候宕机了,消
息消费超时到了后,消息会被重新放入 StoreQueue,等待下次被消费,消息不
丢失。
• 消费者接收到了消息并消费成功,但是由于 fullgc 等原因使消费时间太长,
PrepareQueue 中的消息由于超时已经回滚到 StoreQueue,等待下次被消费,
消息被重复消费。

2) 重试次数控制的实现

采用二阶段消费方式,需要将消息在 StoreQueue 和 PrepareQueue 之间移动,如


何实现重试次数控制呢,其关键在 StoreQueue 和 PrepareQueue 的分数设计。

PrepareQueue 的分数需要与时间相关,正常情况下,消费者不管消费失败还是消
费成功,都会从 PrepareQueue 删除消息,当消费者系统发生异常或者宕机的时候,
消息就无法从 PrepareQueue 中删除,我们也不知道消费者是否消费成功,为保障
消息至少被消费一次,我们需要做到超时回滚,因此分数需要与消费时间相关。

当 PrepareQueue 中的消息发生超时的时候,将消息从 PrepareQueue 移动到


StoreQueue。因此 PrepareQueue 的分数设计为:秒级时间戳*1000+重试次数。不
同类型的消息首次存储到 StoreQueue 中的分数表示的含义不尽相同,区间重复合
并消息和任意定时消息存储时的分数表示消费时间戳,优先级消息存储时的分数表
示优先级。

如果消息消费失败,消息从 PrepareQueue 回滚到 StoreQueue,所有类型的消息


存储时的分数都表示剩余重试次数,剩余重试次数从 16 次不断降低最后为 0,消息
进入死信队列。消息在 StoreQueue 和 PrepareQueue 之间移动流程如下:
基于 Redis 实现特殊的消息队列 17

3) Pop 消息

不同类型的消息在消费的时候 Pop 消息的方式不一样,因此接下来分别讲述三种类


型消息的 Pop 方式。

a) 区间重复合并消息

该消息存储的分数设计为消费时间戳,当前时间大于消息的消费时间戳时,该消息
应该被消费。因此采用 Redis 命令 ZRANGEBYSCORE 弹出分数小于当前时间戳的一
条消息。
基于 Redis 实现特殊的消息队列 18

b) 优先级消息

该消息存储的分数设计为优先级,优先级越高分数越大,因此采用 Redis 命令
ZPOPMAX 弹出分数最大的一条消息。

任意定时消息该消息存储的分数设计为消费时间戳,当前时间大于消息的消费时间
戳时,该消息应该被消费。因此采用 Redis 命令 ZRANGEBYSCORE 弹出分数小于当
前时间戳的一条消息。

三、 相关应用

1. 主图价格表达项目

在主图价格表达中需要实现一个功能,商品价格发生变化时将商品价格打印在商品
主图上面,那么需要在价格发生变动的时候触发合成一张带价格的图片,每一次触
发合图时计算价格都是获取当前最新的价格。

上游价格变化的因素很多,变化很频繁,下游合图消耗 GPU 资源较大,处理容量较


低。因此需要尽可能合并触发合图消息,减轻下游处理压力,于是使用了 RMQ 作为
消息队列来进行削峰填谷、消息合并。不仅如此,还可以根据商家等级划分触发合
图消息的等级,使 KA 商家能够优先得到处理,缩短价格变化的延迟。
基于 Redis 实现特殊的消息队列 19

在线上实际环境中,集群共 130 台机器,RMQ 消息队列的发送消息能力和消费消息


能力均可以达到 5w tps,而且这并不是峰值,理论上可以达到 10w tps。

2. 在线数据圈选引擎

在线数据圈选引擎需要处理各种来源的大量动态数据,需要将一段时间区间内的消
息合并处理,减少处理压力,并且在对同一元数据进行并发处理需要加分布式锁,
锁冲突导致消费效率下降。RMQ 的区间重复合并消息和并发消费控制能力可以帮助
解决这些问题。目前,在线数据圈选引擎已经采用了 RMQ 消息队列作为核心组件,
RMQ 消息队列发挥了很大的作用。

四、 总结

本文提出了一种可实现的基于 Redis 的消息队列,充分利用 Sorted Set 结构设计了


消息合并、优先级、定时等特性,与传统消息队列形成互补,弥补传统消息队列这
方面特性的缺失。为了实现高可用,本文在二阶段提交的思想上进行改进设计了二
阶段消费方式,保障消息至少被消费一次。未来将基于 Redis 的特性打造更多独特
的功能,与传统消息中间件形成互补。在消费控制方面会增加流量自动调控能力,
根据消息类型调控消费速度,减少因为某种类型消息消费瓶颈导致整体消费性能下
降。
「正当时」 20

「正当时」

(间隔页,PDF 中更新)
Redis 性能优化 21

Redis 性能优化

作者:春潭

一、 Redis 为什么变慢了

1. Redis 真的变慢了吗?

对 Redis 进行基准性能测试

例如,我的机器配置比较低,当延迟为 2ms 时,我就认为 Redis 变慢了,但是如果


你的硬件配置比较高,那么在你的运行环境下,可能延迟是 0.5ms 时就可以认为
Redis 变慢了。所以,你只有了解了你的 Redis 在生产环境服务器上的基准性能,
才能进一步评估,当其延迟达到什么程度时,才认为 Redis 确实变慢了。

为了避免业务服务器到 Redis 服务器之间的网络延迟,你需要直接在 Redis 服务器


上测试实例的响应延迟情况。执行以下命令,就可以测试出这个实例 60 秒内的最
大响应延迟:

./Redis-cli --intrinsic-latency 120


Max latency so far: 17 microseconds.
Max latency so far: 44 microseconds.
Max latency so far: 94 microseconds.
Max latency so far: 110 microseconds.
Max latency so far: 119 microseconds.

36481658 total runs (avg latency: 3.2893 microseconds / 3289.32 nanoseconds


per run).
Worst run took 36x longer than the average latency.

从输出结果可以看到,这 60 秒内的最大响应延迟为 119 微秒(0.119 毫秒)。

你还可以使用以下命令,查看一段时间内 Redis 的最小、最大、平均访问延迟。

$ Redis-cli -h 127.0.0.1 -p 6379 --latency-history -i 1


min: 0, max: 1, avg: 0.13 (100 samples) -- 1.01 seconds range
min: 0, max: 1, avg: 0.12 (99 samples) -- 1.01 seconds range
min: 0, max: 1, avg: 0.13 (99 samples) -- 1.01 seconds range
min: 0, max: 1, avg: 0.10 (99 samples) -- 1.01 seconds range
Redis 性能优化 22

min: 0, max: 1, avg: 0.13 (98 samples) -- 1.00 seconds range


min: 0, max: 1, avg: 0.08 (99 samples) -- 1.01 seconds range

如果你观察到的 Redis 运行时延迟是其基线性能的 2 倍及以上,就可以认定 Redis


变慢了。

网络对 Redis 性能的影响,一个简单的方法是用 iPerf 这样的工具测试网络极限带


宽。

服务器端

# iperf -s -p 12345 -i 1 -M
iperf: option requires an argument -- M
------------------------------------------------------------
Server listening on TCP port 12345
TCP window size: 4.00 MByte (default)
------------------------------------------------------------
[ 4] local 172.20.0.113 port 12345 connected with 172.20.0.114 port 56796
[ ID] Interval Transfer Bandwidth
[ 4] 0.0- 1.0 sec 614 MBytes 5.15 Gbits/sec
[ 4] 1.0- 2.0 sec 622 MBytes 5.21 Gbits/sec
[ 4] 2.0- 3.0 sec 647 MBytes 5.42 Gbits/sec
[ 4] 3.0- 4.0 sec 644 MBytes 5.40 Gbits/sec
[ 4] 4.0- 5.0 sec 651 MBytes 5.46 Gbits/sec
[ 4] 5.0- 6.0 sec 652 MBytes 5.47 Gbits/sec
[ 4] 6.0- 7.0 sec 669 MBytes 5.61 Gbits/sec
[ 4] 7.0- 8.0 sec 670 MBytes 5.62 Gbits/sec
[ 4] 8.0- 9.0 sec 667 MBytes 5.59 Gbits/sec
[ 4] 9.0-10.0 sec 667 MBytes 5.60 Gbits/sec
[ 4] 0.0-10.0 sec 6.35 GBytes 5.45 Gbits/sec
客户端

# iperf -c 服务器端 IP -p 12345 -i 1 -t 10 -w 20K


------------------------------------------------------------
Client connecting to 172.20.0.113, TCP port 12345
TCP window size: 40.0 KByte (WARNING: requested 20.0 KByte)
------------------------------------------------------------
[ 3] local 172.20.0.114 port 56796 connected with 172.20.0.113 port 12345
[ ID] Interval Transfer Bandwidth
[ 3] 0.0- 1.0 sec 614 MBytes 5.15 Gbits/sec
[ 3] 1.0- 2.0 sec 622 MBytes 5.21 Gbits/sec
[ 3] 2.0- 3.0 sec 646 MBytes 5.42 Gbits/sec
[ 3] 3.0- 4.0 sec 644 MBytes 5.40 Gbits/sec
[ 3] 4.0- 5.0 sec 651 MBytes 5.46 Gbits/sec
[ 3] 5.0- 6.0 sec 652 MBytes 5.47 Gbits/sec
[ 3] 6.0- 7.0 sec 669 MBytes 5.61 Gbits/sec
[ 3] 7.0- 8.0 sec 670 MBytes 5.62 Gbits/sec
[ 3] 8.0- 9.0 sec 667 MBytes 5.59 Gbits/sec
[ 3] 9.0-10.0 sec 668 MBytes 5.60 Gbits/sec
[ 3] 0.0-10.0 sec 6.35 GBytes 5.45 Gbits/sec

2. 使用复杂度过高的命令

首先,第一步,你需要去查看一下 Redis 的慢日志(slowlog)。


Redis 性能优化 23

Redis 提供了慢日志命令的统计功能,它记录了有哪些命令在执行时耗时比较久。
查看 Redis 慢日志之前,你需要设置慢日志的阈值。例如,设置慢日志的阈值为 5
毫秒,并且保留最近 500 条慢日志记录:

# 命令执行耗时超过 5 毫秒,记录慢日志
CONFIG SET slowlog-log-slower-than 5000
# 只保留最近 500 条慢日志
CONFIG SET slowlog-max-len 500

• 经常使用 O 以上复杂度的命令,例如 SORT、SUNION、ZUNIONSTORE 聚合类


命令;
• 使用 O(N)复杂度的命令,但 N 的值非常大。

第一种情况导致变慢的原因在于,Redis 在操作内存数据时,时间复杂度过高,要
花费更多的 CPU 资源。

第二种情况导致变慢的原因在于,Redis 一次需要返回给客户端的数据过多,更多
时间花费在数据协议的组装和网络传输过程中。

另外,我们还可以从资源使用率层面来分析,如果你的应用程序操作 Redis 的 OPS


不是很大,但 Redis 实例的 CPU 使用率却很高,那么很有可能是使用了复杂度过高
的命令导致的。

3. 操作 bigkey
Redis 性能优化 24

如果你查询慢日志发现,并不是复杂度过高的命令导致的,而都是 SET/DEL 这种简


单命令出现在慢日志中,那么你就要怀疑你的实例否写入了 bigkey。

Redis-cli -h 127.0.0.1 -p 6379 --bigkeys -i 1


-------- summary -------

Sampled 829675 keys in the keyspace!


Total key length in bytes is 10059825 (avg len 12.13)

Biggest string found 'key:291880' has 10 bytes


Biggest list found 'mylist:004' has 40 items
Biggest set found 'myset:2386' has 38 members
Biggest hash found 'myhash:3574' has 37 fields
Biggest zset found 'myzset:2704' has 42 members

36313 strings with 363130 bytes (04.38% of keys, avg size 10.00)
787393 lists with 896540 items (94.90% of keys, avg size 1.14)
1994 sets with 40052 members (00.24% of keys, avg size 20.09)
1990 hashs with 39632 fields (00.24% of keys, avg size 19.92)
1985 zsets with 39750 members (00.24% of keys, avg size 20.03)

这里我需要提醒你的是,当执行这个命令时,要注意 2 个问题:

• 对线上实例进行 bigkey 扫描时,Redis 的 OPS 会突增,为了降低扫描过程中对


Redis 的影响,最好控制一下扫描的频率,指定-i 参数即可,它表示扫描过程中
每次扫描后休息的时间间隔,单位是秒;
• 扫描结果中,对于容器类型(List、Hash、Set、ZSet)的 key,只能扫描出元素
最多的 key。但一个 key 的元素多,不一定表示占用内存也多,你还需要根据业
务情况,进一步评估内存占用情况。

4. 集中过期

如果你发现,平时在操作 Redis 时,并没有延迟很大的情况发生,但在某个时间点


突然出现一波延时,其现象表现为:变慢的时间点很有规律,例如某个整点,或者
每间隔多久就会发生一波延迟。

如果是出现这种情况,那么你需要排查一下,业务代码中是否存在设置大量 key 集
中过期的情况。如果有大量的 key 在某个固定时间点集中过期,在这个时间点访问
Redis 时,就有可能导致延时变大。
Redis 性能优化 25

Redis 的过期数据采用被动过期+主动过期两种策略:

• 被动过期:只有当访问某个 key 时,才判断这个 key 是否已过期,如果已过期,


则从实例中删除;
• 主动过期:Redis 内部维护了一个定时任务,默认每隔 100 毫秒(1 秒 10 次)
就会从全局的过期哈希表中随机取出 20 个 key,然后删除其中过期的 key,如
果过期 key 的比例超过了 25%,则继续重复此过程,直到过期 key 的比例下降
到 25%以下,或者这次任务的执行耗时超过了 25 毫秒,才会退出循环。

注意
这个主动过期 key 的定时任务,是在 Redis 主线程中执行的。

也就是说如果在执行主动过期的过程中,出现了需要大量删除过期 key 的情况,那


么此时应用程序在访问 Redis 时,必须要等待这个过期任务执行结束,Redis 才可
以服务这个客户端请求。

如果此时需要过期删除的是一个 bigkey,那么这个耗时会更久。而且,这个操作延
迟的命令并不会记录在慢日志中。因为慢日志中只记录一个命令真正操作内存数据
的耗时,而 Redis 主动删除过期 key 的逻辑,是在命令真正执行之前执行的。

5. 实例内存达到上限

当 我 们 把 Redis 当 做 纯 缓 存 使 用 时 , 通 常 会 给 这 个 实 例 设 置 一 个 内 存 上 限
maxmemory,然后设置一个数据淘汰策略。

当 Redis 内存达到 maxmemory 后,每次写入新的数据之前,Redis 必须先从实例


中踢出一部分数据,让整个实例的内存维持在 maxmemory 之下,然后才能把新数
据写进来。

这个踢出旧数据的逻辑也是需要消耗时间的,而具体耗时的长短,要取决于你配置
的淘汰策略:
Redis 性能优化 26

• allkeys-lru:不管 key 是否设置了过期,淘汰最近最少访问的 key;


• volatile-lru:只淘汰最近最少访问、并设置了过期时间的 key;
• allkeys-random:不管 key 是否设置了过期,随机淘汰 key;
• volatile-random:只随机淘汰设置了过期时间的 key;
• allkeys-ttl:不管 key 是否设置了过期,淘汰即将过期的 key;
• noeviction:不淘汰任何 key,实例内存达到 maxmeory 后,再写入新数据直接
返回错误;
• allkeys-lfu:不管 key 是否设置了过期,淘汰访问频率最低的 key(4.0+版本支
持);
• volatile-lfu:只淘汰访问频率最低、并设置了过期时间 key(4.0+版本支持)。

一般最常使用的是 allkeys-lru/volatile-lru 淘汰策略,它们的处理逻辑是,每次从实


例中随机取出一批 key(这个数量可配置),然后淘汰一个最少访问的 key,之后把
剩下的 key 暂存到一个池子中,继续随机取一批 key,并与之前池子中的 key 比较,
再淘汰一个最少访问的 key。以此往复,直到实例内存降到 maxmemory 之下。

需要注意的是,Redis 的淘汰数据的逻辑与删除过期 key 的一样,也是在命令真正


执行之前执行的,也就是说它也会增加我们操作 Redis 的延迟,而且,写 OPS 越高,
延迟也会越明显。

如果此时你的 Redis 实例中还存储了 bigkey,那么在淘汰删除 bigkey 释放内存时,


也会耗时比较久。

6. fork 耗时严重

当 Redis 开启了后台 RDB 和 AOF rewrite 后,在执行时,它们都需要主进程创建出


一个子进程进行数据的持久化。
Redis 性能优化 27

主进程创建子进程,会调用操作系统提供的 fork 函数。而 fork 在执行过程中,主进


程需要拷贝自己的内存页表给子进程,如果这个实例很大,那么这个拷贝的过程也
会比较耗时。而且这个 fork 过程会消耗大量的 CPU 资源,在完成 fork 之前,整个
Redis 实例会被阻塞住,无法处理任何客户端请求。

如果此时你的 CPU 资源本来就很紧张,那么 fork 的耗时会更长,甚至达到秒级,这


会严重影响 Redis 的性能。

那如何确认确实是因为 fork 耗时导致的 Redis 延迟变大呢?你可以在 Redis 上执行


INFO 命令,查看 latest_fork_usec 项,单位微秒。

# 上一次 fork 耗时,单位微秒


latest_fork_usec:59477

这个时间就是主进程在 fork 子进程期间,整个实例阻塞无法处理客户端请求的时间。


如果你发现这个耗时很久,就要警惕起来了,这意味在这期间,你的整个 Redis 实
例都处于不可用的状态。

除了数据持久化会生成 RDB 之外,当主从节点第一次建立数据同步时,主节点也创


建子进程生成 RDB,然后发给从节点进行一次全量同步,所以,这个过程也会对
Redis 产生性能影响。

7. 开启内存大页

除了上面讲到的子进程 RDB 和 AOF rewrite 期间,fork 耗时导致的延时变大之外,


这里还有一个方面也会导致性能问题,这就是操作系统是否开启了内存大页机制。
Redis 性能优化 28

什么是内存大页?

我们都知道,应用程序向操作系统申请内存时,是按内存页进行申请的,而常规的
内存页大小是 4KB。Linux 内核从 2.6.38 开始,支持了内存大页机制,该机制允许
应用程序以 2MB 大小为单位,向操作系统申请内存。应用程序每次向操作系统申请
的内存单位变大了,但这也意味着申请内存的耗时变长。

这对 Redis 会有什么影响呢?

当 Redis 在执行后台 RDB,采用 fork 子进程的方式来处理。但主进程 fork 子进程


后,此时的主进程依旧是可以接收写请求的,而进来的写请求,会采用 Copy On Write
(写时复制)的方式操作内存数据。也就是说,主进程一旦有数据需要修改,Redis
并不会直接修改现有内存中的数据,而是先将这块内存数据拷贝出来,再修改这块
新内存的数据,这就是所谓的「写时复制」。

写时复制你也可以理解成,谁需要发生写操作,谁就需要先拷贝,再修改。这样做
的好处是,父进程有任何写操作,并不会影响子进程的数据持久化(子进程只持久
化 fork 这一瞬间整个实例中的所有数据即可,不关心新的数据变更,因为子进程只
需要一份内存快照,然后持久化到磁盘上)。

但是请注意,主进程在拷贝内存数据时,这个阶段就涉及到新内存的申请,如果此
时操作系统开启了内存大页,那么在此期间,客户端即便只修改 10B 的数据,Redis
在申请内存时也会以 2MB 为单位向操作系统申请,申请内存的耗时变长,进而导
致每个写请求的延迟增加,影响到 Redis 性能。

同样地,如果这个写请求操作的是一个 bigkey,那主进程在拷贝这个 bigkey 内存


块时,一次申请的内存会更大,时间也会更久。可见,bigkey 在这里又一次影响到
了性能。
Redis 性能优化 29

8. 开启 AOF

前面我们分析了 RDB 和 AOF rewrite 对 Redis 性能的影响,主要关注点在 fork 上。


其实,关于数据持久化方面,还有影响 Redis 性能的因素,这次我们重点来看 AOF
数据持久化。

如果你的 AOF 配置不合理,还是有可能会导致性能问题。当 Redis 开启 AOF 后,


其工作原理如下:

• Redis 执行写命令后,把这个命令写入到 AOF 文件内存中(write 系统调用);


• Redis 根据配置的 AOF 刷盘策略,把 AOF 内存数据刷到磁盘上(fsync 系统调
用)。

为了保证 AOF 文件数据的安全性,Redis 提供了 3 种刷盘机制:

• appendfsync always:主线程每次执行写操作后立即刷盘,此方案会占用比
较大的磁盘 IO 资源,但数据安全性最高;
• appendfsync no:主线程每次写操作只写内存就返回,内存数据什么时候刷
到磁盘,交由操作系统决定,此方案对性能影响最小,但数据安全性也最低,
Redis 宕机时丢失的数据取决于操作系统刷盘时机;
• appendfsync everysec:主线程每次写操作只写内存就返回,然后由后台线
程每隔 1 秒执行一次刷盘操作(触发 fsync 系统调用),此方案对性能影响相
对较小,但当 Redis 宕机时会丢失 1 秒的数据。

看到这里,我猜你肯定和大多数人的想法一样,选比较折中的方案 appendfsync
everysec 就没问题了吧?

这个方案优势在于,Redis 主线程写完内存后就返回,具体的刷盘操作是放到后台
线程中执行的,后台线程每隔 1 秒把内存中的数据刷到磁盘中。这种方案既兼顾了
性能,又尽可能地保证了数据安全,是不是觉得很完美?
Redis 性能优化 30

但是,这里我要给你泼一盆冷水了,采用这种方案你也要警惕一下,因为这种方案
还是存在导致 Redis 延迟变大的情况发生,甚至会阻塞整个 Redis。

你试想这样一种情况:当 Redis 后台线程在执行 AOF 文件刷盘时,如果此时磁盘的


IO 负载很高,那这个后台线程在执行刷盘操作(fsync 系统调用)时就会被阻塞住。
此时的主线程依旧会接收写请求,紧接着,主线程又需要把数据写到文件内存中
(write 系统调用),当主线程使用后台子线程执行了一次 fsync,需要再次把新接
收的操作记录写回磁盘时,如果主线程发现上一次的 fsync 还没有执行完,那么它
就会阻塞。

所以,如果后台子线程执行的 fsync 频繁阻塞的话(比如 AOF 重写占用了大量的磁


盘 IO 带宽),主线程也会阻塞,导致 Redis 性能变慢。

看到了么?在这个过程中,主线程依旧有阻塞的风险。所以,尽管你的 AOF 配置为


appendfsync everysec,也不能掉以轻心,要警惕磁盘压力过大导致的 Redis 有性
能问题。

那什么情况下会导致磁盘 IO 负载过大?以及如何解决这个问题呢?我总结了以下
几种情况,你可以参考进行问题排查:

• 子进程正在执行 AOF rewrite,这个过程会占用大量的磁盘 IO 资源;


Redis 性能优化 31

• 有其他应用程序在执行大量的写文件操作,也会占用磁盘 IO 资源。

对于情况 1,说白了就是,Redis 的 AOF 后台子线程刷盘操作,撞上了子进程 AOF


rewrite!

9. 绑定 CPU

很多时候,我们在部署服务时,为了提高服务性能,降低应用程序在多个 CPU 核心
之间的上下文切换带来的性能损耗,通常采用的方案是进程绑定 CPU 的方式提高性
能。

我们都知道,一般现代的服务器会有多个 CPU,而每个 CPU 又包含多个物理核心,


每个物理核心又分为多个逻辑核心,每个物理核下的逻辑核共用 L1/L2 Cache。而
Redis Server 除了主线程服务客户端请求之外,还会创建子进程、子线程。其中子
进程用于数据持久化,而子线程用于执行一些比较耗时操作,例如异步释放 fd、异
步 AOF 刷盘、异步 lazy-free 等等。

如果你把 Redis 进程只绑定了一个 CPU 逻辑核心上,那么当 Redis 在进行数据持久


化时,fork 出的子进程会继承父进程的 CPU 使用偏好。而此时的子进程会消耗大量
的 CPU 资源进行数据持久化(把实例数据全部扫描出来需要耗费 CPU),这就会导
致子进程会与主进程发生 CPU 争抢,进而影响到主进程服务客户端请求,访问延迟
变大。

这就是 Redis 绑定 CPU 带来的性能问题。

10.使用 Swap

如果你发现 Redis 突然变得非常慢,每次的操作耗时都达到了几百毫秒甚至秒级,


那此时你就需要检查 Redis 是否使用到了 Swap,在这种情况下 Redis 基本上已经
无法提供高性能的服务了。
Redis 性能优化 32

什么是 Swap?为什么使用 Swap 会导致 Redis 的性能下降?

如果你对操作系统有些了解,就会知道操作系统为了缓解内存不足对应用程序的影
响,允许把一部分内存中的数据换到磁盘上,以达到应用程序对内存使用的缓冲,
这些内存数据被换到磁盘上的区域,就是 Swap。

问题就在于,当内存中的数据被换到磁盘上后,Redis 再访问这些数据时,就需要
从磁盘上读取,访问磁盘的速度要比访问内存慢几百倍!尤其是针对 Redis 这种对
性能要求极高、性能极其敏感的数据库来说,这个操作延时是无法接受的。

此时,你需要检查 Redis 机器的内存使用情况,确认是否存在使用了 Swap。你可


以通过以下方式来查看 Redis 进程是否使用到了 Swap:

# 先找到 Redis 的进程 ID


$ ps -aux | grep Redis-server

# 查看 Redis Swap 使用情况


$ cat /proc/$pid/smaps | egrep '^(Swap|Size)'

输出结果如下:

Size: 1256 kB
Swap: 0 kB
Size: 4 kB
Swap: 0 kB
Size: 132 kB
Swap: 0 kB
Size: 63488 kB
Swap: 0 kB
Size: 132 kB
Swap: 0 kB
Size: 65404 kB
Swap: 0 kB
Size: 1921024 kB
Swap: 0 kB

每一行 Size 表示 Redis 所用的一块内存大小,Size 下面的 Swap 就表示这块 Size


大小的内存,有多少数据已经被换到磁盘上了,如果这两个值相等,说明这块内存
的数据都已经完全被换到磁盘上了。
Redis 性能优化 33

如果只是少量数据被换到磁盘上,例如每一块 Swap 占对应 Size 的比例很小,那影


响并不是很大。如果是几百兆甚至上 GB 的内存被换到了磁盘上,那么你就需要警
惕了,这种情况 Redis 的性能肯定会急剧下降。

11.碎片整理

Redis 的数据都存储在内存中,当我们的应用程序频繁修改 Redis 中的数据时,就


有可能会导致 Redis 产生内存碎片。

内存碎片会降低 Redis 的内存使用率,我们可以通过执行 INFO 命令,得到这个实


例的内存碎片率:

# Memory
used_memory:5709194824
used_memory_human:5.32G
used_memory_rss:8264855552
used_memory_rss_human:7.70G
...
mem_fragmentation_ratio:1.45

这个内存碎片率是怎么计算的?

很简单,mem_fragmentation_ratio = used_memory_rss / used_memory。

其中 used_memory 表示 Redis 存储数据的内存大小,而 used_memory_rss 表示


操作系统实际分配给 Redis 进程的大小。

如果 mem_fragmentation_ratio>1.5,说明内存碎片率已经超过了 50%,这时我们
就需要采取一些措施来降低内存碎片了。

解决的方案一般如下:

• 如果你使用的是 Redis4.0 以下版本,只能通过重启实例来解决;


• 如果你使用的是 Redis4.0 版本,它正好提供了自动碎片整理的功能,可以通过
配置开启碎片自动整理。
Redis 性能优化 34

但是,开启内存碎片整理,它也有可能会导致 Redis 性能下降。原因在于,Redis 的


碎片整理工作是也在主线程中执行的,当其进行碎片整理时,必然会消耗 CPU 资源,
产生更多的耗时,从而影响到客户端的请求。所以,当你需要开启这个功能时,最
好提前测试评估它对 Redis 的影响。

Redis 碎片整理的参数配置如下:

• 手动清理内存碎片 memory purge

# 开启自动内存碎片整理(总开关)
activedefrag yes

# 内存使用 100MB 以下,不进行碎片整理


active-defrag-ignore-bytes 100mb

# 内存碎片率超过 10%,开始碎片整理
active-defrag-threshold-lower 10
# 内存碎片率超过 100%,尽最大努力碎片整理
active-defrag-threshold-upper 100

# 内存碎片整理占用 CPU 资源最小百分比


active-defrag-cycle-min 1
# 内存碎片整理占用 CPU 资源最大百分比
active-defrag-cycle-max 25

# 碎片整理期间,对于 List/Set/Hash/ZSet 类型元素一次 Scan 的数量


active-defrag-max-scan-fields 1000

二、 Redis 如何优化

1. 慢查询优化

• 尽量不使用 O(N)以上复杂度过高的命令,对于数据的聚合操作,放在客户端
做;
• 执行 O(N)命令,保证 N 尽量的小(推荐 N<=300),每次获取尽量少的数据,
让 Redis 可以及时处理返回。

2. 集中过期优化

一般有两种方案来规避这个问题:
Redis 性能优化 35

• 集中过期 key 增加一个随机过期时间,把集中过期的时间打散,降低 Redis 清


理过期 key 的压力;
• 如果你使用的 Redis 是 4.0 以上版本,可以开启 lazy-free 机制,当删除过期 key
时,把释放内存的操作放到后台线程中执行,避免阻塞主线程。

第一种方案,在设置 key 的过期时间时,增加一个随机时间,伪代码可以这么写:

# 在过期时间点之后的 5 分钟内随机过期掉
Redis.expireat(key, expire_time + random(300))

第二种方案,Redis4.0 以上版本,开启 lazy-free 机制:

# 释放过期 key 的内存,放到后台线程执行


lazyfree-lazy-expire yes

运维层面,你需要把 Redis 的各项运行状态数据监控起来,在 Redis 上执行 INFO 命


令就可以拿到这个实例所有的运行状态数据。

在这里我们需要重点关注 expired_keys 这一项,它代表整个实例到目前为止,累计


删除过期 key 的数量。你需要把这个指标监控起来,当这个指标在很短时间内出现
了突增,需要及时报警出来,然后与业务应用报慢的时间点进行对比分析,确认时
间是否一致,如果一致,则可以确认确实是因为集中过期 key 导致的延迟变大。

3. 实例内存达到上限优化

• 避免存储 bigkey,降低释放内存的耗时;
• 淘汰策略改为随机淘汰,随机淘汰比 LRU 要快很多(视业务情况调整);
• 拆分实例,把淘汰 key 的压力分摊到多个实例上;
• 如果使用的是 Redis4.0 以上版本,开启 lazy-free 机制,把淘汰 key 释放内存
的操作放到后台线程中执行(配置 lazyfree-lazy-eviction=yes)。
Redis 性能优化 36

4. fork 耗时严重优化

• 控制 Redis 实例的内存:尽量在 10G 以下,执行 fork 的耗时与实例大小有关,


实例越大,耗时越久;
• 合理配置数据持久化策略:在 slave 节点执行 RDB 备份,推荐在低峰期执行,
而对于丢失数据不敏感的业务(例如把 Redis 当做纯缓存使用),可以关闭 AOF
和 AOF rewrite;
• Redis 实例不要部署在虚拟机上:fork 的耗时也与系统也有关,虚拟机比物理机
耗时更久;
• 降低主从库全量同步的概率:适当调大 repl-backlog-size 参数,避免主从全量
同步。

1) 从建立同步时,优先检测是否可以尝试只同步部分数据,这种情况就是针对于
之前已经建立好了复制链路,只是因为故障导致临时断开,故障恢复后重新建
立同步时,为了避免全量同步的资源消耗,Redis 会优先尝试部分数据同步,如
果条件不符合,才会触发全量同步。

2) 这个判断依据就是在 master 上维护的复制缓冲区大小,如果这个缓冲区配置的


过小,很有可能在主从断开复制的这段时间内,master 产生的写入导致复制缓
冲区的数据被覆盖,重新建立同步时的 slave 需要同步的 offset 位置在 master
的缓冲区中找不到,那么此时就会触发全量同步。

3) 如何避免这种情况?解决方案就是适当调大复制缓冲区 repl-backlog-size 的
大小,这个缓冲区的大小默认为 1MB,如果实例写入量比较大,可以针对性调
大此配置。

5. 多核 CPU 优化

那如何解决这个问题呢?
Redis 性能优化 37

如果你确实想要绑定 CPU,可以优化的方案是,不要让 Redis 进程只绑定在一个 CPU


逻辑核上,而是绑定在多个逻辑核心上,而且,绑定的多个逻辑核心最好是同一个
物理核心,这样它们还可以共用 L1/L2 Cache。

当然,即便我们把 Redis 绑定在多个逻辑核心上,也只能在一定程度上缓解主线程、


子进程、后台线程在 CPU 资源上的竞争。因为这些子进程、子线程还是会在这多个
逻辑核心上进行切换,存在性能损耗。

如何再进一步优化?

可能你已经想到了,我们是否可以让主线程、子进程、后台线程,分别绑定在固定
的 CPU 核心上,不让它们来回切换,这样一来,他们各自使用的 CPU 资源互不影
响。

其实,这个方案 Redis 官方已经想到了。

Redis 在 6.0 版本已经推出了这个功能,我们可以通过以下配置,对主线程、后台线


程、后台 RDB 进程、AOF rewrite 进程,绑定固定的 CPU 逻辑核心:

• Redis6.0 前绑定 CPU 核

taskset -c 0 ./Redis-server

• Redis6.0 后绑定 CPU 核

# Redis Server 和 IO 线程绑定到 CPU 核心 0,2,4,6


server_cpulist 0-7:2

# 后台子线程绑定到 CPU 核心 1,3


bio_cpulist 1,3

# 后台 AOF rewrite 进程绑定到 CPU 核心 8,9,10,11


aof_rewrite_cpulist 8-11

# 后台 RDB 进程绑定到 CPU 核心 1,10,11


# bgsave_cpulist 1,10-1
Redis 性能优化 38

如果你使用的正好是 Redis6.0 版本,就可以通过以上配置,来进一步提高 Redis 性


能。

这里我需要提醒你的是,一般来说,Redis 的性能已经足够优秀,除非你对 Redis 的


性能有更加严苛的要求,否则不建议你绑定 CPU。

6. 查看 Redis 内存是否发生 Swap

$ Redis-cli info | grep process_id


process_id: 5332

然后,进入 Redis 所在机器的/proc 目录下的该进程目录中:

$ cd /proc/5332

最后,运行下面的命令,查看该 Redis 进程的使用情况。在这儿,我只截取了部分


结果:

$cat smaps | egrep '^(Swap|Size)'


Size: 584 kB
Swap: 0 kB
Size: 4 kB
Swap: 4 kB
Size: 4 kB
Swap: 0 kB
Size: 462044 kB
Swap: 462008 kB
Size: 21392 kB
Swap: 0 kB

一旦发生内存 swap,最直接的解决方法就是增加机器内存。如果该实例在一个
Redis 切片集群中,可以增加 Redis 集群的实例个数,来分摊每个实例服务的数据
量,进而减少每个实例所需的内存量。

7. 内存大页

如果采用了内存大页,那么,即使客户端请求只修改 100B 的数据,Redis 也需要拷


贝 2MB 的大页。相反,如果是常规内存页机制,只用拷贝 4KB。两者相比,你可以
Redis 性能优化 39

看到,当客户端请求修改或新写入数据较多时,内存大页机制将导致大量的拷贝,
这就会影响 Redis 正常的访存操作,最终导致性能变慢。

首先,我们要先排查下内存大页。方法是:在 Redis 实例运行的机器上执行如下命


令:

$ cat /sys/kernel/mm/transparent_hugepage/enabled
[always] madvise never

如果执行结果是 always,就表明内存大页机制被启动了;如果是 never,就表示,


内存大页机制被禁止。

在实际生产环境中部署时,我建议你不要使用内存大页机制,操作也很简单,只需
要执行下面的命令就可以了:

echo never /sys/kernel/mm/transparent_hugepage/enabled

为了保证重启后也能生效:可以在/etc/rc.local 中追加命令。

其实,操作系统提供的内存大页机制,其优势是,可以在一定程序上降低应用程序
申请内存的次数。但是对于 Redis 这种对性能和延迟极其敏感的数据库来说,我们
希望 Redis 在每次申请内存时,耗时尽量短,所以我不建议你在 Redis 机器上开启
这个机制。

8. 删除使用 Lazy Free

支持版本:Redis4.0+

1) 主动删除键使用 lazy free

• UNLINK 命令

127.0.0.1:7000> LLEN mylist


(integer) 2000000
127.0.0.1:7000> UNLINK mylist
(integer) 1
Redis 性能优化 40

127.0.0.1:7000> SLOWLOG get


1) 1) (integer) 1
2) (integer) 1505465188
3) (integer) 30
4) 1) "UNLINK"
2) "mylist"
5) "127.0.0.1:17015"
6) ""

注意
DEL 命令,还是并发阻塞的删除操作。

• FLUSHALL/FLUSHDB ASYNC

127.0.0.1:7000> DBSIZE
(integer) 1812295
127.0.0.1:7000> flushall //同步清理实例数据,180 万个 key 耗时 1020 毫秒
OK
(1.02s)
127.0.0.1:7000> DBSIZE
(integer) 1812637
127.0.0.1:7000> flushall async //异步清理实例数据,180 万个 key 耗时约 9 毫秒
OK
127.0.0.1:7000> SLOWLOG get
1) 1) (integer) 2996109
2) (integer) 1505465989
3) (integer) 9274 //指令运行耗时 9.2 毫秒
4) 1) "flushall"
2) "async"
5) "127.0.0.1:20110"
6) ""

2) 被动删除键使用 lazy free

lazy free 应用于被动删除中,目前有 4 种场景,每种场景对应一个配置参数,默认


都是关闭。

lazyfree-lazy-eviction no
lazyfree-lazy-expire no
lazyfree-lazy-server-del no
slave-lazy-flush no

• lazyfree-lazy-eviction

ü 针对 Redis 内存使用达到 maxmeory,并设置有淘汰策略时;在被动淘汰


键时,是否采用 lazy free 机制;
Redis 性能优化 41

ü 因为此场景开启 lazy free,可能使用淘汰键的内存释放不及时,导致 Redis


内存超用,超过 maxmemory 的限制。此场景使用时,请结合业务测试。
(生产环境不建议设置 yes)。

• lazyfree-lazy-expire

ü 针对设置有 TTL 的键,达到过期后,被 Redis 清理删除时是否采用 lazy free


机制;
ü 此场景建议开启,因 TTL 本身是自适应调整的速度。

• lazyfree-lazy-server-del

ü 针对有些指令在处理已存在的键时,会带有一个隐式的 DEL 键的操作。如


rename 命令,当目标键已存在,Redis 会先删除目标键,如果这些目标键
是一个 big key,那就会引入阻塞删除的性能问题。此参数设置就是解决这
类问题,建议可开启。

• slave-lazy-flush
ü 针对 slave 进行全量数据同步,slave 在加载 master 的 RDB 文件前,会运
行 flushall 来清理自己的数据场景,参数设置决定是否采用异常 flush 机制。
如果内存变动不大,建议可开启。可减少全量同步耗时,从而减少主库因输
出缓冲区爆涨引起的内存使用增长。

3) lazy free 的监控

lazy free 能监控的数据指标,只有一个值:lazyfree_pending_objects,表示 Redis


执行 lazy free 操作,在等待被实际回收内容的键个数。并不能体现单个大键的元素
个数或等待 lazy free 回收的内存大小。

所以此值有一定参考值,可监测 Redis lazy free 的效率或堆积键数量;比如在


flushall async 场景下会有少量的堆积。
Redis 性能优化 42

# info memory

# Memory
lazyfree_pending_objects:0

注意
unlink 命令入口函数 unlinkCommand()和 del 调用相同函数 delGenericCommand()
进行删除 KEY 操作,使用 lazy 标识是否为 lazyfree 调用。

如果是 lazyfree,则调用 dbAsyncDelete()函数。但并非每次 unlink 命令就一定启用


lazy free , Redis 会 先 判 断 释 放 KEY 的 代 价 ( cost ) , 当 cost 大 于
LAZYFREE_THRESHOLD(64)才进行 lazy free。

释放 key 代价计算函数 lazyfreeGetFreeEffort(),集合类型键,且满足对应编码,


cost 就是集合键的元数个数,否则 cost 就是 1。

举例
• 一个包含 100 元素的 list key,它的 free cost 就是 100;
• 一个 512MB 的 string key,它的 free cost 是 1。

所以可以看出,Redis 的 lazy free 的 cost 计算主要时间复杂度相关。

9. AOF 优化

Redis 提供了一个配置项,当子进程在 AOF rewrite 期间,可以让后台子线程不执行


刷盘(不触发 fsync 系统调用)操作。

这相当于在 AOF rewrite 期间,临时把 appendfsync 设置为了 none,配置如下:

# AOF rewrite 期间,AOF 后台子线程不进行刷盘操作


# 相当于在这期间,临时把 appendfsync 设置为了 none
no-appendfsync-on-rewrite yes

当然,开启这个配置项,在 AOF rewrite 期间,如果实例发生宕机,那么此时会丢


失更多的数据,性能和数据安全性,你需要权衡后进行选择。
Redis 性能优化 43

如果占用磁盘资源的是其他应用程序,那就比较简单了,你需要定位到是哪个应用
程序在大量写磁盘,然后把这个应用程序迁移到其他机器上执行就好了,避免对
Redis 产生影响。

当然,如果你对 Redis 的性能和数据安全都有很高的要求,那么建议从硬件层面来


优化,更换为 SSD 磁盘,提高磁盘的 IO 能力,保证 AOF 期间有充足的磁盘资源可
以使用。同时尽可能让 Redis 运行在独立的机器上。

10.Swap 优化

1) 增加机器的内存,让 Redis 有足够的内存可以使用;


2) 整理内存空间,释放出足够的内存供 Redis 使用,然后释放 Redis 的 Swap,让
Redis 重新使用内存。

释放 Redis 的 Swap 过程通常要重启实例,为了避免重启实例对业务的影响,一般


会先进行主从切换,然后释放旧主节点的 Swap,重启旧主节点实例,待从库数据
同步完成后,再进行主从切换即可。

预防的办法就是,你需要对 Redis 机器的内存和 Swap 使用情况进行监控,在内存


不足或使用到 Swap 时报警出来,及时处理。

3) 降低系统使用 swap 的优先级,如 echo10>/proc/sys/vm/swappiness。

永久生效使用:
echo vm.swappiness=10>>/etc/sysctl.conf

三、 Redis 变慢了排查步骤

1) 获取 Redis 实例在当前环境下的基线性能;

2) 是否用了慢查询命令?如果是的话,就使用其他命令替代慢查询命令,或者把
聚合计算命令放在客户端做;
Redis 性能优化 44

3) 是否对过期 key 设置了相同的过期时间?对于批量删除的 key,可以在每个 key


的过期时间上加一个随机数,避免同时删除;

4) 是否存在 bigkey?对于 bigkey 的删除操作,如果你的 Redis 是 4.0 及以上的版


本,可以直接利用异步线程机制减少主线程阻塞;如果是 Redis 4.0 以前的版本,
可以使用 SCAN 命令迭代删除;对于 bigkey 的集合查询和聚合操作,可以使用
SCAN 命令在客户端完成;

5) Redis AOF 配置级别是什么?业务层面是否的确需要这一可靠性级别?如果我


们需要高性能,同时也允许数据丢失,可以将配置项 no-appendfsync-on-
rewrite 设置为 yes,避免 AOF 重写和 fsync 竞争磁盘 IO 资源,导致 Redis 延
迟增加。当然,如果既需要高性能又需要高可靠性,最好使用高速固态盘作为
AOF 日志的写入盘;

6) Redis 实例的内存使用是否过大?发生 swap 了吗?如果是的话,就增加机器


内存,或者是使用 Redis 集群,分摊单机 Redis 的键值对数量和内存压力。同
时,要避免出现 Redis 和其他内存需求大的应用共享机器的情况;

7) 在 Redis 实例的运行环境中,是否启用了透明大页机制?如果是的话,直接关
闭内存大页机制就行了;

8) 是否运行了 Redis 主从集群?如果是的话,把主库实例的数据量大小控制在


2~4GB,以免主从复制时,从库因加载大的 RDB 文件而阻塞;

9) 是否使用了多核 CPU 或 NUMA 架构的机器运行 Redis 实例?使用多核 CPU 时,


可以给 Redis 实例绑定物理核;使用 NUMA 架构时,注意把 Redis 实例和网络
中断处理程序运行在同一个 CPU Socket 上。
一文搞懂 Redis 45

一文搞懂 Redis

作者:一洺

一、 什么是 NoSQL

1. Nosql=not only sql(不仅仅是 SQL)

• 关系型数据库:列+行,同一个表下数据的结构是一样的。
• 非关系型数据库:数据存储没有固定的格式,并且可以进行横向扩展。

NoSQL 泛指非关系型数据库,随着 web2.0 互联网的诞生,传统的关系型数据库很


难对付 web2.0 大数据时代!尤其是超大规模的高并发的社区,暴露出来很多难以
克服的问题,NoSQL 在当今大数据环境下发展的十分迅速,Redis 是发展最快的。

2. 传统 RDBMS 和 NoSQL

1) RDBMS

• 组织化结构
• 固定 SQL
• 数据和关系都存在单独的表中(行列)
• DML(数据操作语言)、DDL(数据定义语言)等
• 严格的一致性(ACID):原子性、一致性、隔离性、持久性
• 基础的事务

2) NoSQL

• 不仅仅是数据
• 没有固定查询语言
一文搞懂 Redis 46

• 键值对存储(Redis)、列存储(HBase)、文档存储(MongoDB)、图形数据
库(不是存图形,放的是关系)(Neo4j)
• 最终一致性(BASE):基本可用、软状态/柔性事务、最终一致性

二、 Redis 是什么?

Redis=Remote Dictionary Server,即远程字典服务。

Redis 是一个开源的使用 ANSI C 语言编写、支持网络、可基于内存亦可持久化的日


志型、Key-Value 数据库,并提供多种语言的 API。与 memcached 一样,为了保
证效率,数据都是缓存在内存中。区别是 Redis 会周期性的把更新的数据写入磁盘
或者把修改操作写入追加的记录文件,并且在此基础上实现了 master-slave(主从)
同步。

三、 Redis 五大基本类型

Redis 是一个开源,内存存储的数据结构服务器,可用作数据库,高速缓存和消息
队列代理。它支持字符串、哈希表、列表、集合、有序集合,位图,hyperloglogs
等数据类型。内置复制、Lua 脚本、LRU 收回、事务以及不同级别磁盘持久化功能,
同时通过 Redis Sentinel 提供高可用,通过 Redis Cluster 提供自动分区。

由于 Redis 类型大家很熟悉,且网上命令使用介绍很多,下面重点介绍五大基本类
型的底层数据结构与应用场景,以便当开发时,可以熟练使用 Redis。

1. String(字符串)

String 类型是 Redis 的最基础的数据结构,也是最经常使用到的类型。而且其他的


四种类型多多少少都是在字符串类型的基础上构建的,所以 String 类型是 Redis 的
基础。
一文搞懂 Redis 47

String 类型的值最大能存储 512MB,这里的 String 类型可以是简单字符串、复杂的


xml/json 的字符串、二进制图像或者音频的字符串、以及可以是数字的字符串。

应用场景

1) 缓存功能

String 字符串是最常用的数据类型,不仅仅是 Redis,各个语言都是最基本类型,因


此,利用 Redis 作为缓存,配合其它数据库作为存储层,利用 Redis 支持高并发的
特点,可以大大加快系统的读写速度、以及降低后端数据库的压力。

2) 计数器

许多系统都会使用 Redis 作为系统的实时计数器,可以快速实现计数和查询的功能。


而且最终的数据结果可以按照特定的时间落地到数据库或者其它存储介质当中进行
永久保存。

3) 统计多单位的数量

eg,uid:gongming,count:0,根据不同的 uid 更新 count 数量。

4) 共享用户 session

用户重新刷新一次界面,可能需要访问一下数据进行重新登录,或者访问页面缓存
cookie,这两种方式做有一定弊端:

• 每次都重新登录效率低下;
• cookie 保存在客户端,有安全隐患。

这时可以利用 Redis 将用户的 session 集中管理,在这种模式只需要保证 Redis 的


高可用,每次用户 session 的更新和获取都可以快速完成。大大提高效率。
一文搞懂 Redis 48

2. List(列表)

list 类型是用来存储多个有序的字符串的,列表当中的每一个字符看做一个元素,一
个列表当中可以存储有一个或者多个元素,Redis 的 list 支持存储 2^32 次方-1 个
元素。

Redis 可以从列表的两端进行插入(pubsh)和弹出(pop)元素,支持读取指定范
围的元素集,或者读取指定下标的元素等操作。Redis 列表是一种比较灵活的链表
数据结构,它可以充当队列或者栈的角色。

Redis 列表是链表型的数据结构,所以它的元素是有序的,而且列表内的元素是可
以重复的。意味着它可以根据链表的下标获取指定的元素和某个范围内的元素集。

应用场景

1) 消息队列

Reids 的链表结构,可以轻松实现阻塞队列,可以使用左进右出的命令组成来完成
队列的设计。比如:数据的生产者可以通过 Lpush 命令从左边插入数据,多个数据
消费者,可以使用 BRpop 命令阻塞的“抢”列表尾部的数据。

2) 文章列表或者数据分页展示的应用

比如,我们常用的博客网站的文章列表,当用户量越来越多时,而且每一个用户都
有自己的文章列表,而且当文章多时,都需要分页展示,这时可以考虑使用 Reids
的列表,列表不但有序同时还支持按照范围内获取元素,可以完美解决分页查询功
能。大大提高查询效率。
一文搞懂 Redis 49

3. Set(集合)

Redis 集合(set)类型和 list 列表类型类似,都可以用来存储多个字符串元素的集


合。但是和 list 不同的是 set 集合当中不允许重复的元素。而且 set 集合当中元素是
没有顺序的,不存在元素下标。

Redis 的 set 类型是使用哈希表构造的,因此复杂度是 O(1),它支持集合内的增


删改查,并且支持多个集合间的交集、并集、差集操作。可以利用这些集合操作,
解决程序开发过程当中很多数据集合间的问题。

应用场景

1) 标签

比如我们博客网站常常使用到的兴趣标签,把一个个有着相同爱好,关注类似内容
的用户利用一个标签把他们进行归并。

2) 共同好友功能

共同喜好,或者可以引申到二度好友之类的扩展应用。

3) 统计网站的独立 IP

利用 set 集合当中元素不唯一性,可以快速实时统计访问网站的独立 IP。

数据结构

set 的底层结构相对复杂写,使用了 intset 和 hashtable 两种数据结构存储,intset


可以理解为数组。
一文搞懂 Redis 50

4. Sorted set(有序集合)

Redis 有序集合也是集合类型的一部分,所以它保留了集合中元素不能重复的特性,
但是不同的是,有序集合给每个元素多设置了一个分数。

Redis 有序集合也是集合类型的一部分,所以它保留了集合中元素不能重复的特性,
但是不同的是,有序集合给每个元素多设置了一个分数,利用该分数作为排序的依
据。

应用场景

1) 排行榜

有序集合经典使用场景。例如视频网站需要对用户上传的视频做排行榜,榜单维护
可能是多方面:按照时间、按照播放量、按照获得的赞数等。

2) 用 Sorted Sets 来做带权重的队列

比如普通消息的 score 为 1,重要消息的 score 为 2,然后工作线程可以选择按 score


的倒序来获取工作任务。让重要的任务优先执行。

5. hash(哈希)

Redis hash 数据结构是一个键值对(key-value)集合,它是一个 string 类型的 field


和 value 的映射表,Redis 本身就是一个 key-value 型数据库,因此 hash 数据结构
相当于在 value 中又套了一层 key-value 型数据,所以 Redis 中 hash 数据结构特
别适合存储关系型对象。
一文搞懂 Redis 51

应用场景

1) 由于 hash 数据类型的 key-value 的特性,用来存储关系型数据库中表记录,是


Redis 中哈希类型最常用的场景。一条记录作为一个 key-value,把每列属性值
对应成 field-value 存储在哈希表当中,然后通过 key 值来区分表当中的主键。

2) 经常被用来存储用户相关信息。优化用户信息的获取,不需要重复从数据库当
中读取,提高系统性能。

四、 五大基本类型底层数据存储结构

在学习基本类型底层数据存储结构前,首先看下 Redis 整体的存储结构。

Redis 内部整体的存储结构是一个大的 hashmap,内部是数组实现的 hash,key 冲


突通过挂链表去实现,每个 dictEntry 为一个 key/value 对象,value 为定义的
RedisObject。

结构图如下:

dictEntry 是存储 key->value 的地方,再让我们看一下 dictEntry 结构体。


一文搞懂 Redis 52

/*
* 字典
*/
typedef struct dictEntry {
// 键
void *key;
// 值
union {
// 指向具体 RedisObject
void *val;
//
uint64_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;

1. RedisObject

我们接着再往下看 RedisObject 究竟是什么结构的:

/*
* Redis 对象
*/
typedef struct RedisObject {
// 类型 4bits
unsigned type:4;
// 编码方式 4bits
unsigned encoding:4;
// LRU 时间(相对于 server.lruclock) 24bits
unsigned lru:22;
// 引用计数 Redis 里面的数据可以通过引用计数进行共享 32bits
int refcount;
// 指向对象的值 64-bit
void *ptr;
} robj;

ptr 指 向 具 体 的 数 据 结 构 的 地 址 ; type 表 示 该 对 象 的 类 型 , 即
String,List,Hash,Set,Zset 中的一个,但为了提高存储效率与程序执行效率,每种对
象的底层数据结构实现都可能不止一种,encoding 表示对象底层所使用的编码。

Redis 对象底层的八种数据结构:

REDIS_ENCODING_INT(long 类型的整数)
REDIS_ENCODING_EMBSTR embstr (编码的简单动态字符串)
REDIS_ENCODING_RAW (简单动态字符串)
REDIS_ENCODING_HT (字典)
REDIS_ENCODING_LINKEDLIST (双端链表)
REDIS_ENCODING_ZIPLIST (压缩列表)
REDIS_ENCODING_INTSET (整数集合)
REDIS_ENCODING_SKIPLIST (跳跃表和字典)
一文搞懂 Redis 53

好了,通过 RedisObject 就可以具体指向 Redis 数据类型了,总结一下每种数据类


型都使用了哪些数据结构,如下图所示:

前期准备知识已准备完毕,下面分每种基本类型来讲。

2. String 数据结构

String 类型的转换顺序:

1) 当保存的值为整数且值的大小不超过 long 的范围,使用整数存储;


2) 当字符串长度不超过 44 字节时,使用 EMBSTR 编码;

它只分配一次内存空间,RedisObject 和 sds 是连续的内存,查询效率会快很多,


也正是因为 RedisObject 和 sds 是连续在一起,伴随了一些缺点:当字符串增加的
时候,它长度会增加,这个时候又需要重新分配内存,导致的结果就是整个
RedisObject 和 sds 都需要重新分配空间,这样是会影响性能的,所以 Redis 用
embstr 实现一次分配而后,只允许读,如果修改数据,那么它就会转成 raw 编码,
不再用 embstr 编码了。
一文搞懂 Redis 54

3) 大于 44 字符时,使用 raw 编码。

1) SDS

embstr 和 raw 都为 SDS 编码,看一下 SDS 的结构体:

/* 针对不同长度整形做了相应的数据结构
* Note: sdshdr5 is never used, we just access the flags byte directly.
* However is here to document the layout of type 5 SDS strings.
*/
struct __attribute__ ((__packed__)) sdshdr5 {
unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
char buf[];
};

struct __attribute__ ((__packed__)) sdshdr8 {


uint8_t len; /* used */
uint8_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};

struct __attribute__ ((__packed__)) sdshdr16 {


uint16_t len; /* used */
uint16_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};

struct __attribute__ ((__packed__)) sdshdr32 {


uint32_t len; /* used */
uint32_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};

struct __attribute__ ((__packed__)) sdshdr64 {


uint64_t len; /* used */
uint64_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};

由于 Redis 底层使用 C 语言实现,可能会有疑问为什么不用 C 语言的字符串呢,而


是用 SDS 结构体。

a) 低复杂度获取字符串长度

由于 len 存在,可以直接查出字符串长度,复杂度 O(1);如果用 c 语言字符串,


查询字符串长度需要遍历整个字符串,复杂度为 O(n);
一文搞懂 Redis 55

b) 避免缓冲区溢出

进行两个字符串拼接 c 语言可使用 strcat 函数,但如果没有足够的内存空间。就会


造成缓冲区溢出;而用 SDS 在进行合并时会先用 len 检查内存空间是否满足需求,
如果不满足,进行空间扩展,不会造成缓冲区溢出;

c) 减少修改字符串的内存重新分配次数

C 语言字符串不记录字符串长度,如果要修改字符串要重新分配内存,如果不进行
重新分配会造成内存缓冲区泄露。

Redis SDS 实现了空间预分配和惰性空间释放两种策略。

a) 如果 SDS 修改后,SDS 长度(len 的值)将于 1mb,那么会分配与 len 相同大


小的未使用空间,此时 len 与 free 值相同。例如,修改之后字符串长度为 100
字节,那么会给分配 100 字节的未使用空间。最终 SDS 空间实际为 100+100+1
(保存空字符'\0');

b) 如果大于等于 1mb,每次给分配 1mb 未使用空间惰性空间释放:对字符串进


行缩短操作时,程序不立即使用内存重新分配来回收缩短后多余的字节,而是
使用 free 属性将这些字节的数量记录下来,等待后续使用(SDS 也提供 API,
我们可以手动触发字符串缩短);

c) 二进制安全:因为 C 字符串以空字符作为字符串结束的标识,而对于一些二进
制文件(如图片等),内容可能包括空字符串,因此 C 字符串无法正确存取;
而所有 SDS 的 API 都是以处理二进制的方式来处理 buf 里面的元素,并且 SDS
不是以空字符串来判断是否结束,而是以 len 属性表示的长度来判断字符串是
否结束;

d) 遵从每个字符串都是以空字符串结尾的惯例,这样可以重用 C 语言库<string.h>
中的一部分函数。
一文搞懂 Redis 56

学习完 SDS,我们回到上面讲到的,为什么小于 44 字节用 embstr 编码呢?

再看一下 rejectObject 和 SDS 定义的结构(短字符串的 embstr 用最小的 sdshdr8):

typedef struct RedisObject {


// 类型 4bits
unsigned type:4;
// 编码方式 4bits
unsigned encoding:4;
// LRU 时间(相对于 server.lruclock) 24bits
unsigned lru:22;
// 引用计数 Redis 里面的数据可以通过引用计数进行共享 32bits
int refcount;
// 指向对象的值 64-bit
void *ptr;
} robj;

struct __attribute__ ((__packed__)) sdshdr8 {


uint8_t len; /* used */
uint8_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};

• RedisObject 占用空间

4+4+24+32+64=128bits=16 字节。

• sdshdr8 占用空间

1(uint8_t)+1(uint8_t)+1(unsigned char)+1(buf[]中结尾的'\0'字符)=4 字
节。

初始最小分配为 64 字节,所以只分配一次空间的 embstr 最大为 64-16-4=44 字节。

3. List 存储结构

1) Redis3.2 之前的底层实现方式

压缩列表 ziplist 或者双向循环链表 linkedlist。当 list 存储的数据量比较少且同时满


足下面两个条件时,list 就使用 ziplist 存储数据:
一文搞懂 Redis 57

• list 中保存的每个元素的长度小于 64 字节;


• 列表中数据个数少于 512 个。

2) Redis3.2 及之后的底层实现方式:quicklist

quicklist 是一个双向链表,而且是一个基于 ziplist 的双向链表,quicklist 的每个节


点都是一个 ziplist,结合了双向链表和 ziplist 的优点。

a) ziplist

ziplist 是一种压缩链表,它的好处是更能节省内存空间,因为它所存储的内容都是
在连续的内存区域当中的。当列表对象元素不大,每个元素也不大的时候,就采用
ziplist 存储。但当数据量过大时就 ziplist 就不是那么好用了。因为为了保证他存储
内容在内存中的连续性,插入的复杂度是 O(N),即每次插入都会重新进行 realloc。

如下图所示,RedisObject 对象结构中 ptr 所指向的就是一个 ziplist。整个 ziplist 只


需要 malloc 一次,它们在内存中是一块连续的区域。

ziplist 结构如下:

• Zlbytes:用于记录整个压缩列表占用的内存字节数;
• zltail:记录要列表尾节点距离压缩列表的起始地址有多少字节;
• zllen:记录了压缩列表包含的节点数量;
• entryX:要说列表包含的各个节点;
• zlend:用于标记压缩列表的末端。

为什么数据量大时不用 ziplist?
一文搞懂 Redis 58

因为 ziplist 是一段连续的内存,插入的时间复杂化度为 O(N),而且每当插入新


的元素需要 realloc 做内存扩展;而且如果超出 ziplist 内存大小,还会做重新分配
的内存空间,并将内容复制到新的地址。如果数量大的话,重新分配内存和拷贝内
存会消耗大量时间。所以不适合大型字符串,也不适合存储量多的元素。

b) 快速列表(quickList)

快速列表是 ziplist 和 linkedlist 的混合体,是将 linkedlist 按段切分,每一段用 ziplist


来紧凑存储,多个 ziplist 之间使用双向指针链接。

为什么不直接使用 linkedlist?

linkedlist 的附加空间相对太高,prev 和 next 指针就要占去 16 个字节,而且每一


个结点都是单独分配,会加剧内存的碎片化,影响内存管理效率。

• quicklist 结构:

typedef struct quicklist {


// 指向 quicklist 的头部
quicklistNode *head;
// 指向 quicklist 的尾部
quicklistNode *tail;
unsigned long count;
unsigned int len;
// ziplist 大小限定,由 list-max-ziplist-size 给定
int fill : 16;
// 节点压缩深度设置,由 list-compress-depth 给定
unsigned int compress : 16;
} quicklist;

typedef struct quicklistNode {


// 指向上一个 ziplist 节点
struct quicklistNode *prev;
// 指向下一个 ziplist 节点
struct quicklistNode *next;
// 数据指针,如果没有被压缩,就指向 ziplist 结构,反之指向 quicklistLZF 结构
unsigned char *zl;
// 表示指向 ziplist 结构的总长度(内存占用长度)
unsigned int sz;
// ziplist 数量
unsigned int count : 16; /* count of items in ziplist */
unsigned int encoding : 2; /* RAW==1 or LZF==2 */
// 预留字段,存放数据的方式,1--NONE,2--ziplist
unsigned int container : 2; /* NONE==1 or ZIPLIST==2 */
// 解压标记,当查看一个被压缩的数据时,需要暂时解压,标记此参数为 1,之后再重新进行压缩
unsigned int recompress : 1; /* was this node previous compressed? */
unsigned int attempted_compress : 1; /* node can't compress; too small */
// 扩展字段
一文搞懂 Redis 59

unsigned int extra : 10; /* more bits to steal for future usage */
} quicklistNode;

typedef struct quicklistLZF {


// LZF 压缩后占用的字节数
unsigned int sz; /* LZF size in bytes*/
// 柔性数组,存放压缩后的 ziplist 字节数组
char compressed[];
} quicklistLZF;

• 结构图如下:

• ziplist 的长度

quicklist 内部默认单个 ziplist 长度为 8k 字节,超出了这个字节数,就会新起一个


ziplist。关于长度可以使用 list-max-ziplist-size 决定。

• 压缩深度

我们上面说到了 quicklist 下是用多个 ziplist 组成的,同时为了进一步节约空间,


Redis 还会对 ziplist 进行压缩存储,使用 LZF 算法压缩,可以选择压缩深度。quicklist
默认的压缩深度是 0,也就是不压缩。压缩的实际深度由配置参数 list-compress-
depth 决定。为了支持快速 push/pop 操作,quicklist 的首尾两个 ziplist 不压缩,
此时深度就是 1。如果深度为 2,就表示 quicklist 的首尾第一个 ziplist 以及首尾第
二个 ziplist 都不压缩。
一文搞懂 Redis 60

4. Hash 类型

当 Hash 中数据项比较少的情况下,Hash 底层才用压缩列表 ziplist 进行存储数据,


随着数据的增加,底层的 ziplist 就可能会转成 dict,具体配置如下:

hash-max-ziplist-entries 512
hash-max-ziplist-value 64

在 List 中已经介绍了 ziplist,下面来介绍下 dict,看下数据结构:

typedef struct dict {


dictType *type;
void *privdata;
dictht ht[2];
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
int iterators; /* number of iterators currently running */
} dict;

typedef struct dictht {


//指针数组,这个 hash 的桶
dictEntry **table;
//元素个数
unsigned long size;
unsigned long sizemask;
unsigned long used;
} dictht;

dictEntry 大家应该熟悉,在上面有讲,使用来真正存储 key->value 的地方


typedef struct dictEntry {
// 键
void *key;
// 值
union {
// 指向具体 RedisObject
void *val;
//
uint64_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;

我们可以看到每个 dict 中都有两个 hashtable,结构图如下:


一文搞懂 Redis 61

虽然 dict 结构有两个 hashtable,但是通常情况下只有一个 hashtable 是有值的。


但是在 dict 扩容缩容的时候,需要分配新的 hashtable,然后进行渐近式搬迁,这
时候两个 hashtable 存储的旧的 hashtable 和新的 hashtable。搬迁结束后,旧
hashtable 删除,新的取而代之。

下面让我们学习下 rehash 全貌。

5. 渐进式 rehash

所谓渐进式 rehash 是指我们的大字典的扩容是比较消耗时间的,需要重新申请新


的数组,然后将旧字典所有链表的元素重新挂接到新的数组下面,是一个 O(N)的
操作。但是因为我们的 Redis 是单线程的,无法承受这样的耗时过程,所以采用了
渐进式 rehash 小步搬迁,虽然慢一点,但是可以搬迁完毕。

1) 扩容条件

我们的扩容一般会在 Hash 表中的元素个数等于第一维数组的长度的时候,就会开


始扩容。扩容的大小是原数组的两倍。不过在 Redis 在做 bgsave(RDB 持久化操
作的过程),为了减少内存页的过多分离(Copy On Write),Redis 不会去扩容。
但是如果 hash 表的元素个数已经到达了第一维数组长度的 5 倍的时候,就会强制
扩容,不管你是否在持久化。

不扩容主要是为了尽可能减少内存页过多分离,系统需要更多的开销去回收内存。
一文搞懂 Redis 62

2) 缩容条件

当我们的 hash 表元素逐渐删除的越来越少的时候。Redis 于是就会对 hash 表进行


缩容来减少第一维数组长度的空间占用。缩容的条件是元素个数低于数组长度的
10%,并且缩容不考虑是否在做 Redis 持久化。

不用考虑 bgsave 主要是因为我们的缩容的内存都是已经使用过的,缩容的时候可


以直接置空,而且由于申请的内存比较小,同时会释放掉一些已经使用的内存,不
会增大系统的压力。

3) rehash 步骤

• 为 ht[1]分配空间,让字典同时持有 ht[0]和 ht[1]两个哈希表;


• 定时维持一个索引计数器变量 rehashidx,并将它的值设置为 0,表示 rehash
开始;
• 在 rehash 进行期间,每次对字典执行 CRUD 操作时,程序除了执行指定的操作
以外,还会将 ht[0]中的数据 rehash 到 ht[1]表中,并且将 rehashidx 加一;
• 当 ht[0]中所有数据转移到 ht[1]中时,将 rehashidx 设置成-1,表示 rehash 结
束;

说明
采用渐进式 rehash 的好处在于它采取分而治之的方式,避免了集中式 rehash 带来
的庞大计算量。特别的在进行 rehash 时只能对 h[0]元素减少的操作,如查询和删
除;而查询是在两个哈希表中查找的,而插入只能在 ht[1]中进行,ht[1]也可以查询
和删除。

• 将 ht[0]释放,然后将 ht[1]设置成 ht[0],最后为 ht[1]分配一个空白哈希表。

过程如下图:
一文搞懂 Redis 63

6. set 数据结构

Redis 的集合相当于 Java 中的 HashSet,它内部的键值对是无序、唯一的。它的内


部实现相当于一个特殊的字典,字典中所有的 value 都是一个值 NULL。集合 Set
类型底层编码包括 hashtable 和 inset。

当存储的数据同时满足下面这样两个条件的时候,Redis 就采用整数集合 intset 来


实现 set 这种数据类型:

• 存储的数据都是整数;
• 存储的数据元素个数小于 512 个。

当不能同时满足这两个条件的时候,Redis 就使用 dict 来存储集合中的数据。


hashtable 在上面介绍过了,我们就只介绍 inset。

1) inset 结构体

typedef struct intset {


uint32_t encoding;
// length 就是数组的实际长度
uint32_t length;
// contents 数组是实际保存元素的地方,数组中的元素有以下两个特性:
// 1.没有重复元素
// 2.元素在数组中从小到大排列
int8_t contents[];
} intset;
一文搞懂 Redis 64

// encoding 的值可以是以下三个常量的其中一个
#define INTSET_ENC_INT16 (sizeof(int16_t))
#define INTSET_ENC_INT32 (sizeof(int32_t))
#define INTSET_ENC_INT64 (sizeof(int64_t))

2) inset 的查询

intset 是一个有序集合,查找元素的复杂度为 O(logN)(采用二分法),但插入


时不一定为 O(logN),因为有可能涉及到升级操作。比如当集合里全是 int16_t 型
的整数,这时要插入一个 int32_t,那么为了维持集合中数据类型的一致,那么所有
的数据都会被转换成 int32_t 类型,涉及到内存的重新分配,这时插入的复杂度就为
O(N)了。是 intset 不支持降级操作。

说明
inset 是有序不要和我们 zset 搞混,zset 是设置一个 score 来进行排序,而 inset 这
里只是单纯的对整数进行升序而已。

7. Zset 数据结构

Zset 有序集合和 set 集合有着必然的联系,他保留了集合不能有重复成员的特性,


但不同的是,有序集合中的元素是可以排序的,但是它和列表的使用索引下标作为
排序依据不同的是,它给每个元素设置一个分数,作为排序的依据。

zet 的底层编码有两种数据结构,一个 ziplist,一个是 skiplist。Zset 也使用了 ziplist


做了排序,所以下面讲一下 ziplist 如何做排序。

1) ziplist 做排序

每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的
成员(member),而第二个元素则保存元素的分值(score)。

存储结构图如下一目了然:
一文搞懂 Redis 65

2) skiplist 跳表

结构体如下,skiplist 是与 dict 结合来使用的,这个结构比较复杂。

/*
* 跳跃表
*/
typedef struct zskiplist {
// 头节点,尾节点
struct zskiplistNode *header, *tail;
// 节点数量
unsigned long length;
// 目前表内节点的最大层数
int level;
} zskiplist;

/*
* 跳跃表节点
*/
typedef struct zskiplistNode {
// member 对象
robj *obj;
// 分值
double score;
// 后退指针
struct zskiplistNode *backward;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 这个层跨越的节点数量
unsigned int span;
} level[];
} zskiplistNode;

跳表是什么?我们先看下链表:

如果想查找到 node5 需要从 node1 查到 node5,查询耗时。但如果在 node 上加


上索引:
一文搞懂 Redis 66

这样通过索引就能直接从 node1 查找到 node5。

3) Redis 跳跃表

让我们再看下 Redis 的跳表结构(图太复杂,直接从网上找了张图说明)。

• Header:指向跳跃表的表头节点,通过这个指针程序定位表头节点的时间复杂
度就为 O(1);
• Tail:指向跳跃表的表尾节点,通过这个指针程序定位表尾节点的时间复杂度就
为 O(1);
• Level:记录目前跳跃表内,层数最大的那个节点的层数(表头节点的层数不计算
在内),通过这个属性可以再 O(1)的时间复杂度内获取层高最好的节点的层
数;
• Length:记录跳跃表的长度,也即是,跳跃表目前包含节点的数量(表头节点不
计算在内),通过这个属性,程序可以再 O(1)的时间复杂度内返回跳跃表的
长度。

结构右方的是四个 zskiplistNode 结构,该结构包含以下属性:

1) 层(level)
一文搞懂 Redis 67

节点中用 L1、L2、L3 等字样标记节点的各个层,L1 代表第一层,L2 代表第二层,


以此类推。

每个层都带有两个属性:前进指针和跨度。前进指针用于访问位于表尾方向的其他
节点,而跨度则记录了前进指针所指向节点和当前节点的距离(跨度越大、距离越
远)。在上图中,连线上带有数字的箭头就代表前进指针,而那个数字就是跨度。当
程序从表头向表尾进行遍历时,访问会沿着层的前进指针进行。

每次创建一个新跳跃表节点的时候,程序都根据幂次定律(powerlaw,越大的数出
现的概率越小)随机生成一个介于 1 和 32 之间的值作为 level 数组的大小,这个大
小就是层的“高度”。

2) 后退(backward)指针

节点中用 BW 字样标记节点的后退指针,它指向位于当前节点的前一个节点。后退
指针在程序从表尾向表头遍历时使用。与前进指针所不同的是每个节点只有一个后
退指针,因此每次只能后退一个节点。

3) 分值(score)

各个节点中的 1.0、2.0 和 3.0 是节点所保存的分值。在跳跃表中,节点按各自所保


存的分值从小到大排列。

4) 成员对象(oj)

各个节点中的 o1、o2 和 o3 是节点所保存的成员对象。在同一个跳跃表中,各个节


点保存的成员对象必须是唯一的,但是多个节点保存的分值却可以是相同的:分值相
同的节点将按照成员对象在字典序中的大小来进行排序,成员对象较小的节点会排
在前面(靠近表头的方向),而成员对象较大的节点则会排在后面(靠近表头的方
向)。
一文搞懂 Redis 68

五、 三大特殊数据类型

1. geospatial(地理位置)

1) geospatial 将指定的地理空间位置(纬度、经度、名称)添加到指定的 key 中。


这些数据将会存储到 sorted set 这样的目的是为了方便使用 GEORADIUS 或者
GEORADIUSBYMEMBER 命令对数据进行半径查询等操作;
2) sorted set 使用一种称为 Geohash 的技术进行填充。经度和纬度的位是交错的,
以形成一个独特的 52 位整数。sorted set 的 double score 可以代表一个 52 位
的整数,而不会失去精度。(有兴趣的同学可以学习一下 Geohash 技术,使用
二分法构建唯一的二进制串);
3) 有效的经度是-180 度到 180 度有效的纬度是-85.05112878 度到 85.05112878
度。

应用场景

• 查看附近的人;
• 微信位置共享;
• 地图上直线距离的展示。

2. Hyperloglog(基数)

什么是基数?不重复的元素。

hyperloglog 是用来做基数统计的,其优点是:输入的提及无论多么大,hyperloglog
使用的空间总是固定的 12KB,利用 12KB,它可以计算 2^64 个不同元素的基数!
非常节省空间!但缺点是估算的值,可能存在误差。

应用场景

网页统计 UV(浏览用户数量,同一天同一个 ip 多次访问算一次访问,目的是计数,


而不是保存用户)。传统的方式,set 保存用户的 id,可以统计 set 中元素数量作为
一文搞懂 Redis 69

标准判断。但如果这种方式保存大量用户 id,会占用大量内存,我们的目的是为了
计数,而不是去保存 id。

3. Bitmaps(位存储)

Redis 提供的 Bitmaps 这个“数据结构”可以实现对位的操作。Bitmaps 本身不是


一种数据结构,实际上就是字符串,但是它可以对字符串的位进行操作。可以把
Bitmaps 想象成一个以位为单位数组,数组中的每个单元只能存 0 或者 1,数组的
下标在 bitmaps 中叫做偏移量。单个 bitmaps 的最大长度是 512MB,即 2^32 个
比特位。

应用场景

两种状态的统计都可以使用 bitmaps,例如:统计用户活跃与非活跃数量、登录与
非登录、上班打卡等等。

六、 Redis 事务

事务本质:一组命令的集合。

1. 数据库事务与 Redis 事务

1) 数据库的事务

数据库事务通过 ACID(原子性、一致性、隔离性、持久性)来保证。

数据库中除查询操作以外,插入(Insert)、删除(Delete)和更新(Update)这三
种操作都会对数据造成影响,因为事务处理能够保证一系列操作可以完全地执行或
者完全不执行,因此在一个事务被提交以后,该事务中的任何一条 SQL 语句在被执
行的时候,都会生成一条撤销日志(Undo Log)。
一文搞懂 Redis 70

2) Redis 事务

Redis 事务提供了一种“将多个命令打包,然后一次性、按顺序地执行”的机制,
并且事务在执行的期间不会主动中断——服务器在执行完事务中的所有命令之后,
才会继续处理其他客户端的其他命令。

Redis 中一个事务从开始到执行会经历开始事务(muiti)、命令入队和执行事务(xec)
三个阶段,事务中的命令在加入时都没有被执行,直到提交时才会开始执行(exec)
一次性完成。

一组命令中存在两种错误不同处理方式:

• 代码语法错误(编译时异常)所有命令都不执行;
• 代码逻辑错误(运行时错误),其他命令可以正常执行(该点不保证事务的原子
性)。

为什么 Redis 不支持回滚来保证原子性?这种做法的优点:

• Redis 命令只会因为错误的语法而失败(并且这些问题不能在入队时发现),或
是命令用在了错误类型的键上面:这也就是说,从实用性的角度来说,失败的
命令是由编程错误造成的,而这些错误应该在开发的过程中被发现,而不应该
出现在生产环境中。
• 因为不需要对回滚进行支持,所以 Redis 的内部可以保持简单且快速。

说明
鉴于没有任何机制能避免程序员自己造成的错误,并且这类错误通常不会在生产环
境中出现,所以 Redis 选择了更简单、更快速的无回滚方式来处理事务。

2. 事务监控

1) 悲观锁:认为什么时候都会出现问题,无论做什么操作都会加锁;
一文搞懂 Redis 71

2) 乐观锁:认为什么时候都不会出现问题,所以不会上锁!更新数据的时候去判
断一下,在此期间是否有人修改过这个数据。

使用 cas 实现乐观锁。Redis 使用 watch key 监控指定数据,相当于加乐观锁。

watch 保证事务只能在所有被监视键都没有被修改的前提下执行,如果这个前提不
能满足的话,事务就不会被执行。

watch 执行流程

七、 Redis 持久化

Redis 是一种内存型数据库,一旦服务器进程退出,数据库的数据就会丢失,为了
解决这个问题 Redis 供了两种持久化的方案,将内存中的数据保存到磁盘中,避免
数据的丢失。
一文搞懂 Redis 72

两种持久化方式:快照(RDB 文件)和追加式文件(AOF 文件),下面分别为大家


介绍两种方式的原理。

• RDB 持久化方式会在一个特定的间隔保存那个时间点的数据快照;
• AOF 持久化方式则会记录每一个服务器收到的写操作。在服务启动时,这些记
录的操作会逐条执行从而重建出原来的数据。写操作命令记录的格式跟 Redis
协议一致,以追加的方式进行保存;
• Redis 的持久化是可以禁用的,就是说你可以让数据的生命周期只存在于服务
器的运行时间里;
• 两种方式的持久化是可以同时存在的,但是当 Redis 重启时,AOF 文件会被优
先用于重建数据。

1. RDB 持久化

RDB 持久化产生的文件是一个经过压缩的二进制文件,这个文件可以被保存到硬盘
中,可以通过这个文件还原数据库的状态,它可以手动执行,也可以在 Redis.conf
配置文件中配置,定时执行。

1) 工作原理

在进行 RDB 时,Redis 的主进程不会做 io 操作,会 fork 一个子进程来完成该操作:

• Redis 调用 forks。同时拥有父进程和子进程;
• 子进程将数据集写入到一个临时 RDB 文件中;
• 当子进程完成对新 RDB 文件的写入时,Redis 用新 RDB 文件替换原来的 RDB 文
件,并删除旧的 RDB 文件。

这种工作方式使得 Redis 可以从写时复制(copy-on-write)机制中获益(因为是


使用子进程进行写操作,而父进程依然可以接收来自客户端的请求)。
一文搞懂 Redis 73

2) 触发机制

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

a) 主动触发

• save 命令是同步的命令,会占用主进程,会造成阻塞,阻塞所有客户端的请求
• bgsave,bgsave 是异步进行,进行持久化的时候,Redis 还可以将继续响应客
户端请求。

bgsave 和 save 对比

b) 自动触发

• save 自动触发配置,见下面配置,满足 m 秒内修改 n 次 key,触发 rdb。

# 时间策略 save m n m 秒内修改 n 次 key,触发 rdb


save 900 1
save 300 10
save 60 10000

# 文件名称
dbfilename dump.rdb

# 文件保存路径
dir /home/work/app/Redis/data/

# 如果持久化出错,主进程是否停止写入
stop-writes-on-bgsave-error yes

# 是否压缩
rdbcompression yes
一文搞懂 Redis 74

# 导入时是否检查
rdbchecksum yes

• 从节点全量复制时,主节点发送 rdb 文件给从节点完成复制操作,主节点会触


发 bgsave 命令;
• 执行 flushall 命令,会触发 rdb;
• 退出 Redis,且没有开启 aof 时。

优点

• RDB 的内容为二进制的数据,占用内存更小,更紧凑,更适合做为备份文件;
• RDB 对灾难恢复非常有用,它是一个紧凑的文件,可以更快的传输到远程服务
器进行 Redis 服务恢复;
• RDB 可以更大程度的提高 Redis 的运行速度,因为每次持久化时 Redis 主进程
都会 fork()一个子进程,进行数据持久化到磁盘,Redis 主进程并不会执行磁盘
I/O 等操作;
• 与 AOF 格式的文件相比,RDB 文件可以更快的重启。

缺点

• 因为 RDB 只能保存某个时间间隔的数据,如果中途 Redis 服务被意外终止了,


则会丢失一段时间内的 Redis 数据;
• RDB 需要经常 fork()才能使用子进程将其持久化在磁盘上。如果数据集很大,
fork()可能很耗时,并且如果数据集很大且 CPU 性能不佳,则可能导致 Redis 停
止为客户端服务几毫秒甚至一秒钟。

2. AOF(Append Only File)

以日志的形式来记录每个写的操作,将 Redis 执行过的所有指令记录下来(读操作


不记录),只许追加文件但不可以改写文件,Redis 启动之初会读取该文件重新构
建数据,换言之,Redis 重启的话就根据日志文件的内容将写指令从前到后执行一
次以完成数据的恢复工作。
一文搞懂 Redis 75

1) AOF 配置项

# 默认不开启 aof 而是使用 rdb 的方式


appendonly no

# 默认文件名
appendfilename "appendonly.aof"

# 每次修改都会 sync 消耗性能


# appendfsync always
# 每秒执行一次 sync 可能会丢失这一秒的数据
appendfsync everysec
# 不执行 sync ,这时候操作系统自己同步数据,速度最快
# appendfsync no

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

2) AOF 重写机制

随着 Redis 的运行,AOF 的日志会越来越长,如果实例宕机重启,那么重放整个 AOF


将会变得十分耗时,而在日志记录中,又有很多无意义的记录,比如我现在将一个
数据 incr 一千次,那么就不需要去记录这 1000 次修改,只需要记录最后的值即可。
所以就需要进行 AOF 重写。

Redis 提供了 bgrewriteaof 指令用于对 AOF 日志进行重写,该指令运行时会开辟


一个子进程对内存进行遍历,然后将其转换为一系列的 Redis 的操作指令,再序列
化到一个日志文件中。完成后再替换原有的 AOF 文件,至此完成。

同样的也可以在 Redis.config 中对重写机制的触发进行配置:

通过将 no-appendfsync-on-rewrite 设置为 yes,开启重写机制;auto-aof-rewrite-


percentage 100 意为比上次从写后文件大小增长了 100%再次触发重写;auto-aof-
rewrite-min-size 64mb 意为当文件至少要达到 64mb 才会触发制动重写。

3) 触发方式

• 手动触发:bgrewriteaof;
一文搞懂 Redis 76

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

优点

• 数据安全,aof 持久化可以配置 appendfsync 属性,有 always,每进行一次命


令操作就记录到 aof 文件中一次;
• 通过 append 模式写文件,即使中途服务器宕机,可以通过 Redis-check-aof
工具解决数据一致性问题;
• AOF 机制的 rewrite 模式。AOF 文件没被 rewrite 之前(文件过大时会对命令进
行合并重写),可以删除其中的某些命令(比如误操作的 flushall))。

缺点

• AOF 文件比 RDB 文件大,且恢复速度慢;


• 数据集大的时候,比 rdb 启动效率低。

3. rdb 与 aof 对比

八、 发布与订阅

Redis 发布与订阅是一种消息通信的模式:发送者(pub)发送消息,订阅者(sub)
接收消息。
一文搞懂 Redis 77

Redis 通过 PUBLISH 和 SUBSCRIBE 等命令实现了订阅与发布模式,这个功能提供两


种信息机制,分别是订阅/发布到频道、订阅/发布到模式的客户端。

1. 频道(channel)

1) 订阅

2) 发布
一文搞懂 Redis 78

3) 完整流程

a) 发布者发布消息:发布者向频道 channel:1 发布消息 hi。

127.0.0.1:6379> publish channel:1 hi


(integer) 1

b) 订阅者订阅消息

127.0.0.1:6379> subscribe channel:1


Reading messages... (press Ctrl-C to quit)
1) "subscribe" // 消息类型
2) "channel:1" // 频道
3) "hi" // 消息内容

执行 subscribe 后客户端会进入订阅状态,仅可以使 subscribe、unsubscribe、


psubscribe 和 punsubscribe 这四个属于“发布/订阅”之外的命令。

订阅频道后的客户端可能会收到三种消息类型:

• subscribe。表示订阅成功的反馈信息。第二个值是订阅成功的频道名称,第三
个是当前客户端订阅的频道数量。
• message。表示接收到的消息,第二个值表示产生消息的频道名称,第三个值
是消息的内容。
• unsubscribe。表示成功取消订阅某个频道。第二个值是对应的频道名称,第三
个值是当前客户端订阅的频道数量,当此值为 0 时客户端会退出订阅状态,之
后就可以执行其他非“发布/订阅”模式的命令了。

4) 数据结构

于频道的发布订阅模式是通过字典数据类型实现的。

struct RedisServer {
// ...
dict *pubsub_channels;
// ...
};
一文搞懂 Redis 79

其中,字典的键为正在被订阅的频道,而字典的值则是一个链表,链表中保存了所
有订阅这个频道的客户端。

订阅

当使用 subscribe 订阅时,在字典中找到频道 key(如没有则创建),并将订阅的


client 关联在链表后面。

当 client10 执行 subscribe channel1 channel2 channel3 时,会将 client10 分别加


到 channel1 channel2 channel3 关联的链表尾部。

发布

发布时,根据 key,找到字典汇总 key 的地址,然后将 msg 发送到关联的链表每一


台机器。
一文搞懂 Redis 80

退订

遍历关联的链表,将指定的地址删除即可。

2. 模式(pattern)

pattern 使用了通配符的方式来订阅。通配符中?表示 1 个占位符,*表示任意个占位


符(包括 0),?*表示 1 个以上占位符。

所以当使用 publish 命令发送信息到某个频道时,不仅所有订阅该频道的客户端会


收到信息,如果有某个/某些模式和这个频道匹配的话,那么所有订阅这个/这些频
道的客户端也同样会收到信息。

1) 订阅发布完整流程

a) 发布者发布消息

127.0.0.1:6379> publish b m1
(integer) 1
127.0.0.1:6379> publish b1 m1
(integer) 1
127.0.0.1:6379> publish b11 m1
(integer) 1
一文搞懂 Redis 81

b) 订阅者订阅消息

127.0.0.1:6379> psubscribe b*
Reading messages... (press Ctrl-C to quit)
1) "psubscribe"
2) "b*"
3) (integer) 3
1) "pmessage"
2) "b*"
3) "b"
4) "m1"
1) "pmessage"
2) "b*"
3) "b1"
4) "m1"
1) "pmessage"
2) "b*"
3) "b11"
4) "m1"

2) 数据结构

pattern 属性是一个链表,链表中保存着所有和模式相关的信息。

struct RedisServer {
// ...
list *pubsub_patterns;
// ...
};
// 链表中的每一个节点结构如下,保存着客户端与模式信息
typedef struct pubsubPattern {
RedisClient *client;
robj *pattern;
} pubsubPattern;

数据结构图如下:
一文搞懂 Redis 82

订阅

当有信的订阅时,会将订阅的客户端和模式信息添加到链表后面。

发布

当发布者发布消息时,首先会发送到对应的频道上,在遍历模式列表,根据 key 匹
配模式,匹配成功将消息发给对应的订阅者。

完成的发布伪代码如下:

def PUBLISH(channel, message):


# 遍历所有订阅频道 channel 的客户端
for client in server.pubsub_channels[channel]:
# 将信息发送给它们
send_message(client, message)
# 取出所有模式,以及订阅模式的客户端
for pattern, client in server.pubsub_patterns:
# 如果 channel 和模式匹配
if match(channel, pattern):
# 那么也将信息发给订阅这个模式的客户端
send_message(client, message)

退订

使用 punsubscribe,可以将订阅者退订,将改客户端移除出链表。
一文搞懂 Redis 83

九、 主从复制

什么是主从复制?

主从复制,是指将一台 Redis 服务器的数据,复制到其他的 Redis 服务器。前者称


为主节点(master),后者称为从节点(slave);数据的复制是单向的,只能由主
节点到从节点默认情况下,每台 Redis 服务器都是主节点;且一个主节点可以有多
个从节点(或者没有),但一个从节点只有一个主。

主从复制的作用主要包括:

• 数据冗余:主从复制实现了数据的热备份,是持久化之外的一种数据冗余方式。
• 故障恢复:当主节点出现问题时,可以由从节点提供服务,实现快速的故障恢
复;实际上是一种服务的冗余。
• 负载均衡:在主从复制的基础上,配合读写分离,可以由主节点提供写服务,由
从节点提供读服务(即写 Redis 数据时应用连接主节点,读 Redis 数据时应用
连接从节点),分担服务器负载;尤其是在写少读多的场景下,通过多个从节点
分担读负载,可以大大提高 Redis 服务器的并发量。
• 高可用基石:除了上述作用以外,主从复制还是哨兵和集群能够实施的基础,
因此说主从复制是 Redis 高可用的基础。

主从库采用的是读写分离的方式:
一文搞懂 Redis 84

1. 原理

分为全量复制与增量复制。

• 全量复制:发生在第一次复制时;
• 增量复制:只会把主从库网络断连期间主库收到的命令,同步给从库。

2. 全量复制的三个阶段

1) 第一阶段是主从库间建立连接、协商同步的过程

主要是为全量复制做准备。从库和主库建立起连接,并告诉主库即将进行同步,主
库确认回复后,主从库间就可以开始同步了。

具体来说,从库给主库发送 psync 命令,表示要进行数据同步,主库根据这个命令


的参数来启动复制。psync 命令包含了主库的 runID 和复制进度 offset 两个参数。
runID,是每个 Redis 实例启动时都会自动生成的一个随机 ID,用来唯一标记这个
实例。

当从库和主库第一次复制时,因为不知道主库的 runID,所以将 runID 设为“?”。


offset,此时设为-1,表示第一次复制。主库收到 psync 命令后,会用 FULLRESYNC
响应命令带上两个参数:主库 runID 和主库目前的复制进度 offset,返回给从库。

从库收到响应后,会记录下这两个参数。这里有个地方需要注意,FULLRESYNC 响
应表示第一次复制采用的全量复制,也就是说,主库会把当前所有的数据都复制给
从库。

2) 第二阶段,主库将所有数据同步给从库

从库收到数据后,在本地完成数据加载。这个过程依赖于内存快照生成的 RDB 文件。


一文搞懂 Redis 85

具体来说,主库执行 bgsave 命令,生成 RDB 文件,接着将文件发给从库。从库接


收到 RDB 文件后,会先清空当前数据库,然后加载 RDB 文件。这是因为从库在通
过 replicaof 命令开始和主库同步前,可能保存了其他数据。

为了避免之前数据的影响,从库需要先把当前数据库清空。在主库将数据同步给从
库的过程中,主库不会被阻塞,仍然可以正常接收请求。否则,Redis 的服务就被中
断了。但是,这些请求中的写操作并没有记录到刚刚生成的 RDB 文件中。为了保证
主从库的数据一致性,主库会在内存中用专门的 replication buffer,记录 RDB 文件
生成后收到的所有写操作。

3) 第三个阶段,主库会把第二阶段执行过程中新收到的写命令,再发送给从库

具体的操作是,当主库完成 RDB 文件发送后,就会把此时 replication buffer 中的


修改操作发给从库,从库再重新执行这些操作。这样一来,主从库就实现同步了。

十、 哨兵机制

哨兵的核心功能是主节点的自动故障转移。下图是一个典型的哨兵集群监控的逻辑
图:
一文搞懂 Redis 86

Redis Sentinel 包含了若个 Sentinel 节点,这样做也带来了两个好处:

• 对于节点的故障判断是由多个 Sentinel 节点共同完成,这样可以有效地防止误


判;
• 即使个别 Sentinel 节点不可用,整个 Sentinel 集群依然是可用的。

哨兵实现了以下功能:

• 监控:每个 Sentinel 节点会对数据节点(Redis master/slave 节点)和其余


Sentinel 节点进行监控;
• 通知:Sentinel 节点会将故障转移的结果通知给应用方;
• 故障转移:实现 slave 晋升为 master,并维护后续正确的主从关系;
• 配置中心:在 Redis Sentinel 模式中,客户端在初始化的时候连接的是 Sentinel
节点集合,从中获取主节点信息。
一文搞懂 Redis 87

其中,监控和自动故障转移功能,使得哨兵可以及时发现主节点故障并完成转移;
而配置中心和通知功能,则需要在与客户端的交互中才能体现。

1. 原理

1) 监控

Sentinel 节点需要监控 master、slave 以及其它 Sentinel 节点的状态。这一过程是


通过 Redis 的 pub/sub 系统实现的。Redis Sentinel 一共有三个定时监控任务,完
成对各个节点发现和监控:

• 监控主从拓扑信息:每隔 10 秒,每个 Sentinel 节点,会向 master 和 slave 发


送 INFO 命令获取最新的拓扑结构;
• Sentinel 节点信息交换:每隔 2 秒,每个 Sentinel 节点,会向 Redis 数据节点
的__sentinel__:hello 频道上,发送自身的信息,以及对主节点的判断信息。这
样,Sentinel 节点之间就可以交换信息;
• 节点状态监控:每隔 1 秒,每个 Sentinel 节点,会向 master、slave、其余
Sentinel 节点发送 PING 命令做心跳检测,来确认这些节点当前是否可达。

2) 主观/客观下线

a) 主观下线

每个 Sentinel 节点,每隔 1 秒会对数据节点发送 ping 命令做心跳检测,当这些节


点超过 down-after-milliseconds 没有进行有效回复时,Sentinel 节点会对该节点
做失败判定,这个行为叫做主观下线。

b) 客观下线

客观下线,是指当大多数 Sentinel 节点,都认为 master 节点宕机了,那么这个判


定就是客观的,叫做客观下线。
一文搞懂 Redis 88

那么这个大多数是指多少呢?这其实就是分布式协调中的 quorum 判定了,大多数


就是过半数,比如哨兵数量是 5,那么大多数就是 5/2+1=3 个,哨兵数量是 10 大
多数就是 10/2+1=6 个。

注意
Sentinel 节点的数量至少为 3 个,否则不满足 quorum 判定条件。

3) 哨兵选举

如果发生了客观下线,那么哨兵节点会选举出一个 Leader 来进行实际的故障转移


工作。Redis 使用了 Raft 算法来实现哨兵领导者选举,大致思路如下:

• 每个 Sentinel 节点都有资格成为领导者,当它主观认为某个数据节点宕机后,
会向其他 Sentinel 节点发送 sentinel is-master-down-by-addr 命令,要求自
己成为领导者;
• 收到命令的 Sentinel 节点,如果没有同意过其他 Sentinel 节点的 sentinelis-
master-down-by-addr 命令,将同意该请求,否则拒绝(每个 Sentinel 节点只
有 1 票);
• 如 果 该 Sentinel 节 点 发 现 自 己 的 票 数 已 经 大 于 等 于 MAX ( quorum,
num(sentinels)/2+1),那么它将成为领导者。

如果此过程没有选举出领导者,将进入下一次选举。

4) 故障转移

选举出的 Leader Sentinel 节点将负责故障转移,也就是进行 master/slave 节点的


主从切换。故障转移,首先要从 slave 节点中筛选出一个作为新的 master,主要考
虑以下 slave 信息:

• 跟 master 断开连接的时长:如果一个 slave 跟 master 的断开连接时长已经超


过了 down-after-milliseconds 的 10 倍,外加 master 宕机的时长,那么该
slave 就被认为不适合选举为 master;
• slave 的优先级配置:slave priority 参数值越小,优先级就越高;
一文搞懂 Redis 89

• 复制 offset:当优先级相同时,哪个 slave 复制了越多的数据(offset 越靠后),


优先级越高;
• run id:如果 offset 和优先级都相同,则哪个 slave 的 run id 越小,优先级越
高。

接着,筛选完 slave 后,会对它执行 slaveof no one 命令,让其成为主节点。

最后,Sentinel 领导者节点会向剩余的 slave 节点发送命令,让它们成为新的 master


节点的从节点,复制规则与 parallel-syncs 参数有关。

Sentinel 节点集合会将原来的 master 节点更新为 slave 节点,并保持着对其关注,


当其恢复后命令它去复制新的主节点。

注意
Leader Sentinel 节点,会从新的 master 节点那里得到一个 configuration epoch,
本质是个 version 版本号,每次主从切换的 version 号都必须是唯一的。其他的哨
兵都是根据 version 来更新自己的 master 配置。

十一、 缓存穿透、击穿、雪崩

1. 缓存穿透

1) 问题来源

缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求。由于缓存是不
命中时被动写的,并且出于容错考虑,如果从存储层查不到数据则不写入缓存,这
将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。在流
量大时,可能 DB 就挂掉了,要是有人利用不存在的 key 频繁攻击我们的应用,这
就是漏洞。

如发起为 id 为“-1”的数据或 id 为特别大不存在的数据。这时的用户很可能是攻


击者,攻击会导致数据库压力过大。
一文搞懂 Redis 90

2) 解决方案

• 接口层增加校验,如用户鉴权校验,id 做基础校验,id<=0 的直接拦截;


• 从缓存取不到的数据,在数据库中也没有取到,这时也可以将 key-value 对写
为 key-null,缓存有效时间可以设置短点,如 30 秒(设置太长会导致正常情况
也没法使用)。这样可以防止攻击用户反复用同一个 id 暴力攻击;
• 布隆过滤器。类似于一个 hash set,用于快速判某个元素是否存在于集合中,
其典型的应用场景就是快速判断一个 key 是否存在于某容器,不存在就直接返
回。布隆过滤器的关键就在于 hash 算法和容器大小。

2. 缓存击穿

1) 问题来源

缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于
并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库
压力瞬间增大,造成过大压力。

2) 解决方案

• 设置热点数据永远不过期;
• 接口限流与熔断,降级。重要的接口一定要做好限流策略,防止用户恶意刷接
口,同时要降级准备,当接口中的某些服务不可用时候,进行熔断,失败快速返
回机制;
• 加互斥锁。

3. 缓存雪崩

1) 问题来源
一文搞懂 Redis 91

缓存雪崩是指缓存中数据大批量到过期时间,而查询数据量巨大,引起数据库压力
过大甚至 down 机。和缓存击穿不同的是,缓存击穿指并发查同一条数据,缓存雪
崩是不同数据都过期了,很多数据都查不到从而查数据库。

2) 解决方案

• 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生;
• 如果缓存数据库是分布式部署,将热点数据均匀分布在不同的缓存数据库中;
• 设置热点数据永远不过期。
「创新汇」 92

「创新汇」

(间隔页,PDF 中更新)
从 Redis7.0 发布看 Redis 的过去与未来 93

从 Redis7.0 发布看 Redis 的过去与未来

作者:仲肥

前言
经历接近一年的开发、三个候选版本,Redis7.0 终于正式发布,这是 Redis 历史上
改变最多的一个大版本,它不仅包含了 50 多个新命令,还有大量核心新特性与改
进,这些不仅能够解决用户使用中的诸多问题,还进一步拓展了 Redis 的使用场景。

虽然 Redis7.0 做了许多大胆的尝试,但是稳定性依然是最重要的基石。Redis 官方
在 7.0 的 GA 博文中也强调这一点:“While user-facing features are easy to boast
of, the real “unsung heroes”in this version are efforts to make Redis more
performant, stable, and lean.”(虽然面向用户的功能更容易得到称赞,但这个版
本中的无名英雄是我们持续不断的对 Redis 的优化,使其变得更加精简、高效、稳
定)。相信从这里可以看出,Redis 发展至今稳定性始终被摆在最重要的位置,因此
对于 7.0 的稳定性担忧大可打消。

下面请先跟随我们一同了解 Redis7.0 的几个核心新特性。

一、 Redis7.0 核心新特性概览

1. Function

Function 是 Redis 脚本方案的全新实现,在 Redis7.0 之前用户只能使用 EVAL 命令


族来执行 Lua 脚本,但是 Redis 对 Lua 脚本的持久化和主从复制一直是 undefined
状态,在各个大版本甚至 release 版本中也都有不同的表现。因此社区也直接要求
用户在使用 Lua 脚本时必须在本地保存一份(这也是最为安全的方式),以防止实
例重启、主从切换时可能造成的 Lua 脚本丢失,维护 Redis 中的 Lua 脚本一直是广
大用户的痛点。

Function 的出现很好的对 Lua 脚本进行了补充,它允许用户向 Redis 加载自定义的


函数库,一方面相对于 EVALSHA 的调用方式用户自定义的函数名可以有更为清晰
从 Redis7.0 发布看 Redis 的过去与未来 94

的语义,另一方面 Function 加载的函数库明确会进行主从复制和持久化存储,彻底


解决了过去 Lua 脚本在持久化上含糊不清的问题。

那么自 7.0 开始,Function 命令族和 EVAL 命令族有了各自明确的定义:FUNCTION


LOAD 会把函数库自动进行主从复制和持久化存储;而 SCRIPT LOAD 则不会进行持
久化和主从复制,脚本仅保存在当前执行节点。并且社区也在计划后续版本中让
Function 支持更多语言,例如 JavaScript、Python 等,敬请期待。

总的来说,Function 在 7.0 中被设计为数据的一部分,因此能够被保存在 RDB、AOF


文件中,也能通过主从复制将 Function 由主库复制到所有从库,可以有效解决之前
Lua 脚本丢失的问题,我们也非常建议大家逐步将 Redis 中的 Lua 脚本替换为
Function。

2. Multi-part AOF

AOF 是 Redis 数据持久化的核心解决方案,其本质是不断追加数据修改操作的 redo


log,那么既然是不断追加就需要做回收也即 compaction,在 Redis 中称为 AOF
rewrite。

然而 AOF rewrite 期间的增量数据如何处理一直是个问题,在过去 rewrite 期间的增


量数据需要在内存中保留,rewrite 结束后再把这部分增量数据写入新的 AOF 文件
中以保证数据完整性。可以看出来 AOF rewrite 会额外消耗内存和磁盘 IO,这也是
Redis AOF rewrite 的痛点,虽然之前也进行过多次改进但是资源消耗的本质问题一
直没有解决。

阿里云的 Redis 企业版在最初也遇到了这个问题,在内部经过多次迭代开发,实现


了 Multi-part AOF 机制来解决,同时也贡献给了社区并随此次 7.0 发布。具体方法
是采用 base(全量数据)+inc(增量数据)独立文件存储的方式,彻底解决内存和
IO 资源的浪费,同时也支持对历史 AOF 文件的保存管理,结合 AOF 文件中的时间
信息还可以实现 PITR 按时间点恢复(阿里云企业版 Tair 已支持),这进一步增强了
Redis 的数据可靠性,满足用户数据回档等需求。

对具体实现感兴趣的同学可以查看本文末尾参考资料。
从 Redis7.0 发布看 Redis 的过去与未来 95

3. Sharded-pubsub

Redis 自 2.0 开始便支持发布订阅机制,使用 pubsub 命令族用户可以很方便地建


立消息通知订阅系统,但是在 cluster 集群模式下 Redis 的 pubsub 存在一些问题,
最为显著的就是在大规模集群中带来的广播风暴。

Redis 的 pubsub 是按 channel 频道进行发布订阅,然而在集群模式下 channel 不


被当做数据处理,也即不会参与到 hash 值计算无法按 slot 分发,所以在集群模式
下 Redis 对用户发布的消息采用的是在集群中广播的方式。

那么问题显而易见,假如一个集群有 100 个节点,用户在节点 1 对某个 channel 进


行 publish 发布消息,该节点就需要把消息广播给集群中其他 99 个节点,如果其他
节点中只有少数节点订阅了该频道,那么绝大部分消息都是无效的,这对网络、CPU
等资源造成了极大的浪费。

Sharded-pubsub 便是用来解决这个问题,意如其名,sharded-pubsub 会把
channel 按分片来进行分发,一个分片节点只负责处理属于自己的 channel 而不会
进行广播,以很简单的方法避免了资源的浪费。

4. Client-eviction

Redis 支持内存规格配置,maxmemory 和 maxmemory-policy 大家已经都很熟悉


了,但是在这里我还是想再解释一下:maxmemory 控制的是 Redis 的整体运行内
存而非数据内存,例如 client buffer、lua cache、fucntion cache、db metadata
等这些都会算在运行内存中,如果运行内存超过了 maxmemory 就会触发 evict 删
除数据,这也是用户在使用 Redis 时的一大痛点。

在这些非数据内存使用当中,又以 client buffer 消耗最大,在大流量场景下 client


需要缓存很多用户读写数据(想象一下 keys 的结果需要先缓存在 client output
buffer 中再发送给用户),由于网络流量的内存消耗导致触发 eviction 删除数据的
情况非常之多。虽然 Redis 很早就支持对 client-output-buffer-limit 配置项,但其
限制的也只是单个连接维度的 output buffer,没有全局统计 client 使用内存和限制。
从 Redis7.0 发布看 Redis 的过去与未来 96

为了解决这个问题,7.0 新增了 maxmemory-clients 配置项,来对所有 client 使用


的内存做限制,如果超过了这个限制就会挑选内存消耗最大的 client 释放,用来缓
解内存使用的消耗。

Client-eviction 并不是终点,还有很多元数据的内存使用会对用户造成困扰,Redis
是基于内存的数据库,我们需要对各个模块的内存进行更精确的统计和控制,让用
户能够对数据存储有更为清晰的理解和规划。

二、 Redis 历史回顾

Redis 发展至今已历经 7 个大版本,而每个大版本都有大量的新特性产生。例如从


3.0 开始支持 cluster 集群模式;4.0 开发的 lazyfree 和 PSYNC2 解决了 Redis 长久
的大 key 删除阻塞问题及同步中断无法续传的问题;5.0 新增了 stream 数据结构使
Redis 具备功能完整的轻量级消息队列能力;6.0 更是发布了诸多企业级特性如
threaded-io、TLS 和 ACL 等,大幅提升了 Redis 的性能和安全性。

国内开发者与 Redis 社区建设

• 感谢国内开发者,中国区已经成为 Redis 社区最大的贡献来源之一

阿里云从 Redis4.0 开始就深度参与到 Redis 开源社区当中,向社区贡献了大量能


力,目前阿里云在 Redis 社区共有 1 名 core team member(核心维护者)和 2 名
contributor(核心贡献者),是原作者和 Redis Labs 以外对 Redis 社区贡献最大的
组织。

我们见证了 Redis 用户在国内的快速增长,阿里云与 Redis 社区的深度合作也逐渐


吸引了更多的国内开发者参与到 Redis 建设中去。尤其是在 Redis core team 成立
之后,社区 maintainer 也和 Redis 一样变成了多线程(1->5),对于 issue 和 PR
的快速处理大幅提升了社区的活跃度。这次 7.0 的 release note 中也可以看到已经
有超过 5 名国内开发者贡献了核心 feature,并贡献了近半数 commit。
从 Redis7.0 发布看 Redis 的过去与未来 97

这些变化与我们在阿里云上的统计结果也是相符合的:Redis 目前同样也已是国内
用户使用规模最大的 NoSQL 数据库之一,并一直处于高速增长中,越来越多的泛
互联网甚至传统行业也在逐步接纳 Redis,用于快速高效的构建业务应用服务。

• 虽然 Redis 发展迅速,但国内用户却大都无法享受技术红利

Redis 社区目前主流维护版本是 6.0,5.0 版本已经进入低维护阶段,而国内还有大


量 2.8,3.0,4.0 版本在使用中。这就很矛盾,一方面,一些对用法,性能,稳定性
和抖动控制的能力都贡献在了新版本上,另一方面,国内用户却“看的到,用不上”。

除去 6.0,7.0 上的高级稳定性优化不说,比如在 4.0 前,没有 lazyfree 删除一个大


key 是同步的会卡住数据库引擎直接导致业务中断。又比如,Redis 其实安全性尤其
是 lua 是一直有较大问题的,这些升级也都“隐含”在了新版本中。虽然阿里云仍
然在坚持把这些漏洞的修复拉回到低版本上,但是在公网当中还是有大量的低版本
实例存在安全风险。

过多用户担心升级版本的兼容性,一方面阿里云也在要求社区来提供一些兼容性验
证工具,另一方面阿里云对版本跟进是很快的,可以让用户在新版发布后尽快进行
验证。从 Redis 整体和阿里云的大量客户升级情况来看,版本的向前兼容性是非常
好的,大可放心升级。

• 希望更多的国内用户深度参与社区建设

国外使用 Redis 的方式和国内使用明显有差异,比如国外更多的是使用在缓存场景,


而国内大多数的用法却是内存数据库。社区的发展和 roadmap 的制定,目前国内
用户的声音较小。

目前国内开发者在社区活跃度很高,但更多的是围绕在 bugfix,和 feature 上。我


们还是建议无论是国内开发者还是使用者可以深层参与进去,举个简单的例子,提
需求、讲明白需求、证明需求的合理性都是参与社区建设,这样才能更好的把我们
国内的需求传达,后续甚至可以深度参与开发,跟踪交付,这些社区工作的含金量
无疑更高。
从 Redis7.0 发布看 Redis 的过去与未来 98

在功能上,不仅仅包括 API 层面的增加,包括接入,可观测性,一致性,一致性和


安全等目前社区都在等待需求中。虽然 core team member 可以代表国内用户来把
一些需求写进 roadmap,但是真实用户的发声会提供更有力的支撑。

三、 Redis 使用场景拓展与展望--Redis 还能做什么?

1. 多模服务进入爆发期

Redis 是一直贴着用户使用发展起来的,它如此受欢迎主要因为两个特点,极高的
性能以及丰富、方便使用的数据结构,这些简单好用的数据结构能够大幅度降低开
发业务复杂度。

我们可以看到,以 Redislabs 为代表的厂商正在大力的丰富 Redis 的数据结构


(modules),如 RedisSearch、Redis-Json、RedisGraph、RedisTimeSeries 和
RedisBloom 等。

同样的,阿里云内存数据库 Tair 很早就在研发各类增强数据结构和模块,目前我们


在公共云 Tair 服务中提供的商业化扩展型数据结构比 Redislabs 提供的更多,部分
模块功能更强,有兴趣可参见文档(本文末尾参考资料)。

目前已有大量云上用户在利用 Tair 增强型数据结构来构建代码,提高开发效率,甚


至完成此前难以完成的工作。2022 年是一个 Redis 扩展结构的爆发年,业内已经渡
过接纳期,进入高速发展阶段。无论是 Tair 还是 Redis,我们相信不断丰富的数据
结构能够让它们走的更远,从缓存演变为高性能的计算型内存数据库,突破、并解
决更多场景问题。

2. 一致性能力上的发展

落盘一致性和副本一致性是使用数据库绕不开的两个话题。长期以来许多人对
Redis 的应用场景仅仅认定为缓存(尤其是国外用户)。Redis 自诞生之初便支持持
久化机制 RDB 和 AOF,并且 AOF 还提供了不同级别的持久化语义:如 appendfsync
从 Redis7.0 发布看 Redis 的过去与未来 99

采用最高级别 always 时可以保证数据完全落盘不丢失,具备与传统数据库一样的


强落盘一致性。

在多副本一致性上,主要是指主备一致性上,原生的 Redis 仍旧采用异步复制,数


据修改操作只要在本地执行完成就会返回结果,相比于其他数据库没有提供副本间
数据强一致的语义。这也限制了 Redis 的使用场景,在对数据可靠性有极高要求的
用户(例如金融行业,和传统行业)中还没有被完全接纳。

目前业内也都在对 Redis 的持久化能力上进行发展。比较有代表性的,是阿里云的


Tair 持久内存版、AWS 的 MemoryDB、和业内开源的一些 Redis-like 的基于 rocksdb
的系统,商业化如 Tair 的容量存储版(前混合存储)。

基 于 rocksdb 开 源
s 社区版 Tair 持久内存 MemoryDB
Redis-like 系统
落盘一致性
可配置 optane 全持久化 云盘全持久化 无,部分可配置

开启强落盘后 较低,大写 低,大写入易 stall 后


略低,约 90% [1] 低,约 15~25% [3]
写入性能 入约 60% 停服
副本一致性
无 强一致:半同步 强一致:物理复制
(主备) 无
开启副本一致
- 低,约 70% [2] 低,约 15~25% [4]
性后写入性能 -

表 1:社区 Redis 和其他商业化、开源产品的落盘一致性与副本一致性对比

• 注 1:与开源 Redis 社区版比较


• 注 2:与开源 Redis 基于内存的使用成本比较
• 注 3,4:来自 AWS 官方博客测试数据

综合来看,随着用户对 Redis 的应用范围的扩大,与此同时对于容量、成本和数据


可靠性的需求也在不断提升,这些也逐渐成为了衡量 Redis 企业级能力的重要指标。
各大厂商和开源产品也都在构建这些能力上提出了许多解决方案。下面介绍一些典
型产品和方案。
从 Redis7.0 发布看 Redis 的过去与未来 100

3. AWS 的 MemoryDB

AWS MemoryDB 的思路是基于类似 Aurora 的共享存储概念,把日志存放在远端共


享存储中,同时内存中仍然保留 Redis 原有的结构。通过这种方式提升数据的持久
化一致性,同时也保证了数据读取的延时和吞吐;而缺点则同样因为日志保存在远
端,写入性能严重下降(仅有 ElastiCache 也即 Redis 社区版的 15%~25%,该数据
来自 AWS 官方评测,见本文末尾参考资料)。在主备一致性上,由于直接采取日志
的物理复制,所以主备一致性近似接近落盘一致性。

值得一提的是原来 AOF rewrite 这种压缩(compaction)引起的开销也因为在远端


做掉而规避掉,因此是一种很彻底的云原生解法。

4. 阿里云自研内存数据库 Tair

在持久化上阿里云走了另外一条路,通过引入新介质持久化内存来解决成本,大容
量和持久化能力的问题。这个方案带来的挑战是使用持久化内存存储结构设计上较
为复杂,既要控制性能衰减,又要保证兼容性。Tair 持久内存很好的解决了这些问
题,对比 MemoryDB 成本更低,读性能基本持平的情况下写入的速度也更快(见本
文末尾参考资料),更关键的是基本原封原样的兼容了 Redis API,大幅降低了用户
的切换成本。

并且,Tair 持久内存型还支持半同步和强写入一致性,无论 MemoryDB 还是 Tair 持


久内存都真正的做到了内存数据库的数据容错性要求。

5. 其他开源产品的发展

国内也出现了一些原创性的优秀落盘开源产品(Redis-Like 系统),这些产品大都
基于 LSM 存储结构如 rocksdb 上的。它们的优点主要是磁盘介质相对内存更为便
宜,但同样目前存在的缺点也非常多:运维复杂度较高,直接映射为运维成本、KV
无法原生的支持 Redis 的数据结构、把 Redis 的强类型变成弱类型等等。

目前这类系统在一致性和容错性上仍有很大的改善空间,而在用户使用体感上,由
于很多用户使用习惯还是把 Redis-like 系统用在业务的同步链路上,对于 LSM KV
从 Redis7.0 发布看 Redis 的过去与未来 101

引擎的延时上抖动整体吞吐的影响直接映射成了用户体感,因此很难作为一款通用
型产品,而这些痛点也同样存在与 Tair 容量存储型中(过去叫混合存储版),这也
是一个需要长期在存储和兼容性上优化的方向。

综上所述,容量版本可以很好地解决用户的使用成本问题,但是只有更好地解决了
落盘一致性问题和副本一致性问题,才能够把 Redis 类系统的使用场景拓展到企业
级。这也是目前看来云厂商一个激烈竞争的企业级产品主流赛道,也有较高的技术
门槛。

四、 写在最后

Redislabs 在 2021 年 8 月正式更名为 Redis,大家看到社区版 Redis 的主页也已经


重构修改过了。本身 Redis 的商业化进度非常快,比如在主页上“夹带”Redis Stack;
又比如在 github 上将一众常用的 SDK 都购买后,开始添加部分 Redislabs 商业化
开源的支持等。最后 Redis 可能也会像 MongoDB,ElasticSearch 一样走向彻底的
商业化。但目前社区仍是非常公开和活跃的。

Redis8.0 的 feature 计划已经开始,一方面我们也如上文所述,建议国内开发者更


多的参与到深层次的社区讨论,让社区更多的向国内使用习惯靠拢,这对重度依赖
Redis 的国内企业情况是非常现实的;另一方面,能够通过社区参与来提升我们的
人才竞争力,除去持久化系统,还有分布式架构,高吞吐低延时核心引擎,多模服
务和脚本引擎,安全与审计等都需要持续投入。如果国内在 Redis 领域也能够有 10~
20 名内存数据库的顶尖专家,那么即便是 Redis 走向商业化闭源其影响对国内用户
都会非常小。

最后,欢迎大家使用 Redis7.0,也愿我们一起在 Redis 等内存数据库技术上走得更


远!(本文作者:仲肥)

参考资料
[1] Redis7.0 Multi Part AOF 的设计和实现:
https://developer.aliyun.com/article/866957
从 Redis7.0 发布看 Redis 的过去与未来 102

[2] Amazon MemoryDB 与 Amazon ElastiCache 比较:


https://aws.amazon.com/cn/blogs/china/comparison-of-amazon-
memorydb-and-amazon-elasticache/?nc1=b_nrp

[3] Tair 扩展数据结构概览:


https://help.aliyun.com/document_detail/146579.html

[4] Tair 持久内存型性能白皮书:


https://help.aliyun.com/document_detail/185189.html
Redis7.0 Multi Part AOF 的设计和实现 103

Redis7.0 Multi Part AOF 的设计和实现

作者:驱动

Redis 作为一种非常流行的内存数据库,通过将数据保存在内存中,Redis 得以拥有


极高的读写性能。但是一旦进程退出,Redis 的数据就会全部丢失。

为了解决这个问题,Redis 提供了 RDB 和 AOF 两种持久化方案,将内存中的数据保


存到磁盘中,避免数据丢失。本文将重点讨论 AOF 持久化方案,以及其存在的一些
问题,并探讨在 Redis7.0(已发布 RC1)中 Multi Part AOF(下文简称为 MP-AOF,
本特性由阿里云数据库 Tair 团队贡献)设计和实现细节。

一、 AOF

AOF(append only file)持久化以独立日志文件的方式记录每条写命令,并在 Redis


启动时回放 AOF 文件中的命令以达到恢复数据的目的。

由于 AOF 会以追加的方式记录每一条 Redis 的写命令,因此随着 Redis 处理的写命


令增多,AOF 文件也会变得越来越大,命令回放的时间也会增多,为了解决这个问
题,Redis 引入了 AOF rewrite 机制(下文称之为 AOFRW)。AOFRW 会移除 AOF
中冗余的写命令,以等效的方式重写、生成一个新的 AOF 文件,来达到减少 AOF 文
件大小的目的。

二、 AOFRW

图 1 展示的是 AOFRW 的实现原理。当 AOFRW 被触发执行时,Redis 首先会 fork 一


个子进程进行后台重写操作,该操作会将执行 fork 那一刻 Redis 的数据快照全部重
写到一个名为 temp-rewriteaof-bg-pid.aof 的临时 AOF 文件中。

由于重写操作为子进程后台执行,主进程在 AOF 重写期间依然可以正常响应用户命


令。因此,为了让子进程最终也能获取重写期间主进程产生的增量变化,主进程除
Redis7.0 Multi Part AOF 的设计和实现 104

了会将执行的写命令写入 aof_buf,还会写一份到 aof_rewrite_buf 中进行缓存。在


子进程重写的后期阶段,主进程会将 aof_rewrite_buf 中累积的数据使用 pipe 发送
给子进程,子进程会将这些数据追加到临时 AOF 文件中(详细原理可参考[1])。

当主进程承接了较大的写入流量时,aof_rewrite_buf 中可能会堆积非常多的数据,
导致在重写期间子进程无法将 aof_rewrite_buf 中的数据全部消费完。此时,
aof_rewrite_buf 剩余的数据将在重写结束时由主进程进行处理。

当子进程完成重写操作并退出后,主进程会在 backgroundRewriteDoneHandler
中处理后续的事情。首先,将重写期间 aof_rewrite_buf 中未消费完的数据追加到
临时 AOF 文件中。其次,当一切准备就绪时,Redis 会使用 rename 操作将临时
AOF 文件原子的重命名为 server.aof_filename,此时原来的 AOF 文件会被覆盖。
至此,整个 AOFRW 流程结束。

图 1 AOFRW 实现原理
Redis7.0 Multi Part AOF 的设计和实现 105

三、 AOFRW 存在的问题

1. 内存开销

由图 1 可以看到,在 AOFRW 期间,主进程会将 fork 之后的数据变化写进


aof_rewrite_buf 中,aof_rewrite_buf 和 aof_buf 中的内容绝大部分都是重复的,
因此这将带来额外的内存冗余开销。

在 Redis INFO 中 的 aof_rewrite_buffer_length 字 段 可 以 看 到 当 前 时 刻


aof_rewrite_buf 占 用 的 内 存 大 小 。 如 下 面 显 示 的 , 在 高 写 入 流 量 下
aof_rewrite_buffer_length 几乎和 aof_buffer_length 占用了同样大的内存空间,
几乎浪费了一倍的内存。

aof_pending_rewrite:0
aof_buffer_length:35500
aof_rewrite_buffer_length:34000
aof_pending_bio_fsync:0

当 aof_rewrite_buf 占用的内存大小超过一定阈值时,我们将在 Redis 日志中看到


如下信息。可以看到,aof_rewrite_buf 占用了 100MB 的内存空间且主进程和子进
程之间传输了 2135MB 的数据(子进程在通过 pipe 读取这些数据时也会有内部读
buffer 的内存开销)。

对于内存型数据库 Redis 而言,这是一笔不小的开销。

3351:M 25 Jan 2022 09:55:39.655 * Background append only file rewriting started
by pid 6817
3351:M 25 Jan 2022 09:57:51.864 * AOF rewrite child asks to stop sending diffs.
6817:C 25 Jan 2022 09:57:51.864 * Parent agreed to stop sending diffs.
Finalizing AOF...
6817:C 25 Jan 2022 09:57:51.864 * Concatenating 2135.60 MB of AOF diff received
from parent.
3351:M 25 Jan 2022 09:57:56.545 * Background AOF buffer size: 100 MB

AOFRW 带来的内存开销有可能导致 Redis 内存突然达到 maxmemory 限制,从而


影响正常命令的写入,甚至会触发操作系统限制被 OOM Killer 杀死,导致 Redis 不
可服务。
Redis7.0 Multi Part AOF 的设计和实现 106

2. CPU 开销

CPU 的开销主要有三个地方,分别解释如下:

1) 在 AOFRW 期间,主进程需要花费 CPU 时间向 aof_rewrite_buf 写数据,并使


用 eventloop 事件循环向子进程发送 aof_rewrite_buf 中的数据:

/* Append data to the AOF rewrite buffer, allocating new blocks if needed. */
void aofRewriteBufferAppend(unsigned char *s, unsigned long len) {
// 此处省略其他细节...

/* Install a file event to send data to the rewrite child if there is


* not one already. */
if (!server.aof_stop_sending_diff &&
aeGetFileEvents(server.el,server.aof_pipe_write_data_to_child) == 0)
{
aeCreateFileEvent(server.el, server.aof_pipe_write_data_to_child,
AE_WRITABLE, aofChildWriteDiffData, NULL);
}

// 此处省略其他细节...
}

2) 在子进程执行重写操作的后期,会循环读取 pipe 中主进程发送来的增量数据,


然后追加写入到临时 AOF 文件:

int rewriteAppendOnlyFile(char *filename) {


// 此处省略其他细节...

/* Read again a few times to get more data from the parent.
* We can't read forever (the server may receive data from clients
* faster than it is able to send data to the child), so we try to read
* some more data in a loop as soon as there is a good chance more data
* will come. If it looks like we are wasting time, we abort (this
* happens after 20 ms without new data). */
int nodata = 0;
mstime_t start = mstime();
while(mstime()-start < 1000 && nodata < 20) {
if (aeWait(server.aof_pipe_read_data_from_parent, AE_READABLE, 1) <= 0)
{
nodata++;
continue;
}
nodata = 0; /* Start counting from zero, we stop on N *contiguous*
timeouts. */
aofReadDiffFromParent();
}

// 此处省略其他细节...
}
Redis7.0 Multi Part AOF 的设计和实现 107

3) 在子进程完成重写操作后,主进程会在 backgroundRewriteDoneHandler 中进
行收尾工作。其中一个任务就是将在重写期间 aof_rewrite_buf 中没有消费完
成的数据写入临时 AOF 文件。如果 aof_rewrite_buf 中遗留的数据很多,这里
也将消耗 CPU 时间。

void backgroundRewriteDoneHandler(int exitcode, int bysignal) {


// 此处省略其他细节...

/* Flush the differences accumulated by the parent to the rewritten AOF.


*/
if (aofRewriteBufferWrite(newfd) == -1) {
serverLog(LL_WARNING,
"Error trying to flush the parent diff to the rewritten AOF: %s",
strerror(errno));
close(newfd);
goto cleanup;
}

// 此处省略其他细节...
}

AOFRW 带来的 CPU 开销可能会造成 Redis 在执行命令时出现 RT 上的抖动,甚至


造成客户端超时的问题。

3. 磁盘 IO 开销

如前文所述,在 AOFRW 期间,主进程除了会将执行过的写命令写到 aof_buf 之外,


还会写一份到 aof_rewrite_buf 中。aof_buf 中的数据最终会被写入到当前使用的
旧 AOF 文件中,产生磁盘 IO。同时,aof_rewrite_buf 中的数据也会被写入重写生
成的新 AOF 文件中,产生磁盘 IO。因此,同一份数据会产生两次磁盘 IO。

4. 代码复杂度

Redis 使用下面所示的六个 pipe 进行主进程和子进程之间的数据传输和控制交互,


这使得整个 AOFRW 逻辑变得更为复杂和难以理解。

/* AOF pipes used to communicate between parent and child during rewrite. */
int aof_pipe_write_data_to_child;
int aof_pipe_read_data_from_parent;
int aof_pipe_write_ack_to_parent;
int aof_pipe_read_ack_from_child;
int aof_pipe_write_ack_to_child;
int aof_pipe_read_ack_from_parent;
Redis7.0 Multi Part AOF 的设计和实现 108

四、 MP-AOF 实现

1. 方案概述

顾名思义,MP-AOF 就是将原来的单个 AOF 文件拆分成多个 AOF 文件。在 MP-AOF


中,我们将 AOF 分为三种类型,分别为:

• BASE:表示基础 AOF,它一般由子进程通过重写产生,该文件最多只有一个;
• INCR:表示增量 AOF,它一般会在 AOFRW 开始执行时被创建,该文件可能存
在多个;
• HISTORY:表示历史 AOF,它由 BASE 和 INCR AOF 变化而来,每次 AOFRW 成
功完成时,本次 AOFRW 之前对应的 BASE 和 INCR AOF 都将变为 HISTORY,
HISTORY 类型的 AOF 会被 Redis 自动删除。

为了管理这些 AOF 文件,我们引入了一个 manifest(清单)文件来跟踪、管理这些


AOF。同时,为了便于 AOF 备份和拷贝,我们将所有的 AOF 文件和 manifest 文件
放入一个单独的文件目录中,目录名由 appenddirname 配置(Redis7.0 新增配置
项)决定。

图 2 MP-AOF Rewrite 原理
Redis7.0 Multi Part AOF 的设计和实现 109

图 2 展示的是在 MP-AOF 中执行一次 AOFRW 的大致流程。在开始时我们依然会


fork 一个子进程进行重写操作,在主进程中,我们会同时打开一个新的 INCR 类型
的 AOF 文件,在子进程重写操作期间,所有的数据变化都会被写入到这个新打开的
INCR AOF 中。子进程的重写操作完全是独立的,重写期间不会与主进程进行任何的
数据和控制交互,最终重写操作会产生一个 BASE AOF。新生成的 BASE AOF 和新
打开的 INCR AOF 就代表了当前时刻 Redis 的全部数据。AOFRW 结束时,主进程会
负责更新 manifest 文件,将新生成的 BASE AOF 和 INCR AOF 信息加入进去,并将
之前的 BASE AOF 和 INCR AOF 标记为 HISTORY(这些 HISTORY AOF 会被 Redis 异
步删除)。一旦 manifest 文件更新完毕,就标志整个 AOFRW 流程结束。

由图 2 可以看到,我们在 AOFRW 期间不再需要 aof_rewrite_buf,因此去掉了对应


的内存消耗。同时,主进程和子进程之间也不再有数据传输和控制交互,因此对应
的 CPU 开销也全部去掉。对应的,前文提及的六个 pipe 及其对应的代码也全部删
除,使得 AOFRW 逻辑更加简单清晰。

2. 关键实现

1) Manifest

a) 在内存中的表示

MP-AOF 强依赖 manifest 文件,manifest 在内存中表示为如下结构体,其中:

• aofInfo:表示一个 AOF 文件信息,当前仅包括文件名、文件序号和文件类型;


• base_aof_info:表示 BASE AOF 信息,当不存在 BASE AOF 时,该字段为 NULL;
• incr_aof_list:用于存放所有 INCR AOF 文件的信息,所有的 INCR AOF 都会按
照文件打开顺序排放;
• history_aof_list:用于存放 HISTORY AOF 信息,history_aof_list 中的元素都是
从 base_aof_info 和 incr_aof_list 中 move 过来的。
Redis7.0 Multi Part AOF 的设计和实现 110

typedef struct {
sds file_name; /* file name */
long long file_seq; /* file sequence */
aof_file_type file_type; /* file type */
} aofInfo;

typedef struct {
aofInfo *base_aof_info; /* BASE file information. NULL if there
is no BASE file. */
list *incr_aof_list; /* INCR AOFs list. We may have multiple
INCR AOF when rewrite fails. */
list *history_aof_list; /* HISTORY AOF list. When the AOFRW
success, The aofInfo contained in
`base_aof_info` and `incr_aof_list` will be
moved to this list. We
will delete these AOF files when AOFRW
finish. */
long long curr_base_file_seq; /* The sequence number used by the
current BASE file. */
long long curr_incr_file_seq; /* The sequence number used by the
current INCR file. */
int dirty; /* 1 Indicates that the aofManifest in the
memory is inconsistent with
disk, we need to persist it immediately. */
} aofManifest;

为了便于原子性修改和回滚操作,我们在 RedisServer 结构中使用指针的方式引用


aofManifest。

struct RedisServer {
// 此处省略其他细节...

aofManifest *aof_manifest; /* Used to track AOFs. */

// 此处省略其他细节...
}

b) 在磁盘上的表示

Manifest 本质就是一个包含多行记录的文本文件,每一行记录对应一个 AOF 文件


信息,这些信息通过 key/value 对的方式展示,便于 Redis 处理、易于阅读和修改。
下面是一个可能的 manifest 文件内容:

file appendonly.aof.1.base.rdb seq 1 type b


file appendonly.aof.1.incr.aof seq 1 type i
file appendonly.aof.2.incr.aof seq 2 type i

Manifest 格式本身需要具有一定的扩展性,以便将来添加或支持其他的功能。比如
可以方便的支持新增 key/value 和注解(类似 AOF 中的注解),这样可以保证较好
的 forward compatibility。
Redis7.0 Multi Part AOF 的设计和实现 111

file appendonly.aof.1.base.rdb seq 1 type b newkey newvalue


file appendonly.aof.1.incr.aof type i seq 1
# this is annotations
seq 2 type i file appendonly.aof.2.incr.aof

2) 文件命名规则

在 MP-AOF 之前,AOF 的文件名为 appendfilename 参数的设置值(默认为


appendonly.aof)。

在 MP-AOF 中,我们使用 basename.suffix 的方式命名多个 AOF 文件。其中,


appendfilename 配置内容将作为 basename 部分,suffix 则由三个部分组成,格
式为 seq.type.format,其中:

• seq 为文件的序号,由 1 开始单调递增,BASE 和 INCR 拥有独立的文件序号;


• type 为 AOF 的类型,表示这个 AOF 文件是 BASE 还是 INCR;
• format 用来表示这个 AOF 内部的编码方式,由于 Redis 支持 RDB preamble 机
制,因此 BASE AOF 可能是 RDB 格式编码也可能是 AOF 格式编码:

#define BASE_FILE_SUFFIX ".base"


#define INCR_FILE_SUFFIX ".incr"
#define RDB_FORMAT_SUFFIX ".rdb"
#define AOF_FORMAT_SUFFIX ".aof"
#define MANIFEST_NAME_SUFFIX ".manifest"

因此,当使用 appendfilename 默认配置时,BASE、INCR 和 manifest 文件的可能


命名如下:

appendonly.aof.1.base.rdb // 开启 RDB preamble


appendonly.aof.1.base.aof // 关闭 RDB preamble
appendonly.aof.1.incr.aof
appendonly.aof.2.incr.aof

3) 兼容老版本升级

由于 MP-AOF 强依赖 manifest 文件,Redis 启动时会严格按照 manifest 的指示加


载对应的 AOF 文件。但是在从老版本 Redis(指 Redis7.0 之前的版本)升级到 Redis
Redis7.0 Multi Part AOF 的设计和实现 112

7.0 时,由于此时并无 manifest 文件,因此如何让 Redis 正确识别这是一个升级过


程并正确、安全的加载旧 AOF 是一个必须支持的能力。

识别能力是这一重要过程的首要环节,在真正加载 AOF 文件之前,我们会检查 Redis


工作目录下是否存在名为 server.aof_filename 的 AOF 文件。如果存在,那说明我
们可能在从一个老版本 Redis 执行升级,接下来,我们会继续判断,当满足下面三
种情况之一时我们会认为这是一个升级启动:

• 如果 appenddirname 目录不存在;
• 或者 appenddirname 目录存在,但是目录中没有对应的 manifest 清单文件;
• 如果 appenddirname 目录存在且目录中存在 manifest 清单文件,且清单文件
中只有 BASE AOF 相关信息,且这个 BASE AOF 的名字和 server.aof_filename
相同,且 appenddirname 目录中不存在名为 server.aof_filename 的文件。

/* Load the AOF files according the aofManifest pointed by am. */


int loadAppendOnlyFiles(aofManifest *am) {
// 此处省略其他细节...

/* If the 'server.aof_filename' file exists in dir, we may be starting


* from an old Redis version. We will use enter upgrade mode in three
situations.
*
* 1. If the 'server.aof_dirname' directory not exist
* 2. If the 'server.aof_dirname' directory exists but the manifest file
is missing
* 3. If the 'server.aof_dirname' directory exists and the manifest file
it contains
* has only one base AOF record, and the file name of this base AOF is
'server.aof_filename',
* and the 'server.aof_filename' file not exist in 'server.aof_dirname'
directory
* */
if (fileExist(server.aof_filename)) {
if (!dirExists(server.aof_dirname) ||
(am->base_aof_info == NULL && listLength(am->incr_aof_list) == 0)
||
(am->base_aof_info != NULL && listLength(am->incr_aof_list) == 0 &&
!strcmp(am->base_aof_info->file_name, server.aof_filename)
&& !aofFileExist(server.aof_filename)))
{
aofUpgradePrepare(am);
}
}

// 此处省略其他细节...
}

一旦被识别为这是一个升级启动,我们会使用 aofUpgradePrepare 函数进行升级


前的准备工作。
Redis7.0 Multi Part AOF 的设计和实现 113

升级准备工作主要分为三个部分:

• 使用 server.aof_filename 作为文件名来构造一个 BASE AOF 信息;


• 将该 BASE AOF 信息持久化到 manifest 文件;
• 使用 rename 将旧 AOF 文件移动到 appenddirname 目录中。

void aofUpgradePrepare(aofManifest *am) {


// 此处省略其他细节...

/* 1. Manually construct a BASE type aofInfo and add it to aofManifest. */


if (am->base_aof_info) aofInfoFree(am->base_aof_info);
aofInfo *ai = aofInfoCreate();
ai->file_name = sdsnew(server.aof_filename);
ai->file_seq = 1;
ai->file_type = AOF_FILE_TYPE_BASE;
am->base_aof_info = ai;
am->curr_base_file_seq = 1;
am->dirty = 1;

/* 2. Persist the manifest file to AOF directory. */


if (persistAofManifest(am) != C_OK) {
exit(1);
}

/* 3. Move the old AOF file to AOF directory. */


sds aof_filepath = makePath(server.aof_dirname, server.aof_filename);
if (rename(server.aof_filename, aof_filepath) == -1) {
sdsfree(aof_filepath);
exit(1);;
}

// 此处省略其他细节...
}

升级准备操作是 Crash Safety 的,以上三步中任何一步发生 Crash 我们都能在下一


次的启动中正确的识别并重试整个升级操作。

4) 多文件加载及进度计算

Redis 在 加 载 AOF 时 会 记 录 加 载 的 进 度 , 并 通 过 Redis INFO 的


loading_loaded_perc 字段展示出来。在 MP-AOF 中,loadAppendOnlyFiles 函数
会根据传入的 aofManifest 进行 AOF 文件加载。在进行加载之前,我们需要提前计
算 所 有 待 加 载 的 AOF 文 件 的 总 大 小 , 并 传 给 startLoading 函 数 , 然 后 在
loadSingleAppendOnlyFile 中不断的上报加载进度。
Redis7.0 Multi Part AOF 的设计和实现 114

接下来,loadAppendOnlyFiles 会根据 aofManifest 依次加载 BASE AOF 和 INCR


AOF。当前加载完所有的 AOF 文件,会使用 stopLoading 结束加载状态。

int loadAppendOnlyFiles(aofManifest *am) {


// 此处省略其他细节...

/* Here we calculate the total size of all BASE and INCR files in
* advance, it will be set to `server.loading_total_bytes`. */
total_size = getBaseAndIncrAppendOnlyFilesSize(am);
startLoading(total_size, RDBFLAGS_AOF_PREAMBLE, 0);

/* Load BASE AOF if needed. */


if (am->base_aof_info) {
aof_name = (char*)am->base_aof_info->file_name;
updateLoadingFileName(aof_name);
loadSingleAppendOnlyFile(aof_name);
}

/* Load INCR AOFs if needed. */


if (listLength(am->incr_aof_list)) {
listNode *ln;
listIter li;

listRewind(am->incr_aof_list, &li);
while ((ln = listNext(&li)) != NULL) {
aofInfo *ai = (aofInfo*)ln->value;
aof_name = (char*)ai->file_name;
updateLoadingFileName(aof_name);
loadSingleAppendOnlyFile(aof_name);
}
}

server.aof_current_size = total_size;
server.aof_rewrite_base_size = server.aof_current_size;
server.aof_fsync_offset = server.aof_current_size;

stopLoading();

// 此处省略其他细节...
}

5) AOFRW Crash Safety

当子进程完成重写操作,子进程会创建一个名为 temp-rewriteaof-bg-pid.aof 的临
时 AOF 文件,此时这个文件对 Redis 而言还是不可见的,因为它还没有被加入到
manifest 文件中。要想使得它能被 Redis 识别并在 Redis 启动时正确加载,我们还
需要将它按照前文提到的命名规则进行 rename 操作,并将其信息加入到 manifest
文件中。

AOF 文件 rename 和 manifest 文件修改虽然是两个独立操作,但我们必须保证这


两个操作的原子性,这样才能让 Redis 在启动时能正确的加载对应的 AOF。MP-AOF
使用两个设计来解决这个问题:
Redis7.0 Multi Part AOF 的设计和实现 115

• BASE AOF 的名字中包含文件序号,保证每次创建的 BASE AOF 不会和之前的


BASE AOF 冲突;
• 先执行 AOF 的 rename 操作,再修改 manifest 文件。

为了便于说明,我们假设在 AOFRW 开始之前,manifest 文件内容如下:

file appendonly.aof.1.base.rdb seq 1 type b


file appendonly.aof.1.incr.aof seq 1 type i

则在 AOFRW 开始执行后 manifest 文件内容如下:

file appendonly.aof.1.base.rdb seq 1 type b


file appendonly.aof.1.incr.aof seq 1 type i
file appendonly.aof.2.incr.aof seq 2 type i

子进程重写结束后,在主进程中,我们会将 temp-rewriteaof-bg-pid.aof 重命名为


appendonly.aof.2.base.rdb,并将其加入 manifest 中,同时会将之前的 BASE 和
INCR AOF 标记为 HISTORY。此时 manifest 文件内容如下:

file appendonly.aof.2.base.rdb seq 2 type b


file appendonly.aof.1.base.rdb seq 1 type h
file appendonly.aof.1.incr.aof seq 1 type h
file appendonly.aof.2.incr.aof seq 2 type i

此时,本次 AOFRW 的结果对 Redis 可见,HISTORY AOF 会被 Redis 异步清理。

backgroundRewriteDoneHandler 函数通过七个步骤实现了上述逻辑:

a) 在修改内存中的 server.aof_manifest 前,先 dup 一份临时的 manifest 结构,


接下来的修改都将针对这个临时的 manifest 进行。这样做的好处是,一旦后面
的步骤出现失败,我们可以简单的销毁临时 manifest 从而回滚整个操作,避免
污染 server.aof_manifest 全局数据结构;

b) 从临时 manifest 中获取新的 BASE AOF 文件名(记为 new_base_filename),


并将之前(如果有)的 BASE AOF 标记为 HISTORY;
Redis7.0 Multi Part AOF 的设计和实现 116

c) 将 子 进 程 产 生 的 temp-rewriteaof-bg-pid.aof 临 时 文 件 重 命 名 为
new_base_filename;

d) 将临时 manifest 结构中上一次的 INCR AOF 全部标记为 HISTORY 类型;

e) 将临时 manifest 对应的信息持久化到磁盘(persistAofManifest 内部会保证


manifest 本身修改的原子性);

f) 如果上述步骤都成功了,我们可以放心的将内存中的 server.aof_manifest 指针
指向临时的 manifest 结构(并释放之前的 manifest 结构),至此整个修改对
Redis 可见;

g) 清理 HISTORY 类型的 AOF,该步骤允许失败,因为它不会导致数据一致性问题。

void backgroundRewriteDoneHandler(int exitcode, int bysignal) {


snprintf(tmpfile, 256, "temp-rewriteaof-bg-%d.aof",
(int)server.child_pid);

/* 1. Dup a temporary aof_manifest for subsequent modifications. */


temp_am = aofManifestDup(server.aof_manifest);

/* 2. Get a new BASE file name and mark the previous (if we have)
* as the HISTORY type. */
new_base_filename = getNewBaseFileNameAndMarkPreAsHistory(temp_am);

/* 3. Rename the temporary aof file to 'new_base_filename'. */


if (rename(tmpfile, new_base_filename) == -1) {
aofManifestFree(temp_am);
goto cleanup;
}

/* 4. Change the AOF file type in 'incr_aof_list' from AOF_FILE_TYPE_INCR


* to AOF_FILE_TYPE_HIST, and move them to the 'history_aof_list'. */
markRewrittenIncrAofAsHistory(temp_am);

/* 5. Persist our modifications. */


if (persistAofManifest(temp_am) == C_ERR) {
bg_unlink(new_base_filename);
aofManifestFree(temp_am);
goto cleanup;
}

/* 6. We can safely let `server.aof_manifest` point to 'temp_am' and free


the previous one. */
aofManifestFreeAndUpdate(temp_am);

/* 7. We don't care about the return value of `aofDelHistoryFiles`, because


the history
* deletion failure will not cause any problems. */
aofDelHistoryFiles();
}
Redis7.0 Multi Part AOF 的设计和实现 117

6) 支持 AOF truncate

在进程出现 Crash 时 AOF 文件很可能出现写入不完整的问题,如一条事务里只写


了 MULTI,但是还没写 EXEC 时 Redis 就 Crash。默认情况下,Redis 无法加载这种
不完整的 AOF,但是 Redis 支持 AOF truncate 功能(通过 aof-load-truncated 配
置打开)。其原理是使用 server.aof_current_size 跟踪 AOF 最后一个正确的文件偏
移,然后使用 ftruncate 函数将该偏移之后的文件内容全部删除,这样虽然可能会
丢失部分数据,但可以保证 AOF 的完整性。

在 MP-AOF 中,server.aof_current_size 已经不再表示单个 AOF 文件的大小而是所


有 AOF 文件的总大小。因为只有最后一个 INCR AOF 才有可能出现不完整写入的问
题,因此我们引入了一个单独的字段 server.aof_last_incr_size 用于跟踪最后一个
INCR AOF 文件的大小。当最后一个 INCR AOF 出现不完整写入时,我们只需要将
server.aof_last_incr_size 之后的文件内容删除即可。

if (ftruncate(server.aof_fd, server.aof_last_incr_size) == -1) {


//此处省略其他细节...
}

7) AOFRW 限流

Redis 在 AOF 大小超过一定阈值时支持自动执行 AOFRW,当出现磁盘故障或者触


发了代码 bug 导致 AOFRW 失败时,Redis 将不停的重复执行 AOFRW 直到成功为
止。在 MP-AOF 出现之前,这看似没有什么大问题(顶多就是消耗一些 CPU 时间和
fork 开销)。但是在 MP-AOF 中,因为每次 AOFRW 都会打开一个 INCR AOF,并且
只有在 AOFRW 成功时才会将上一个 INCR 和 BASE 转为 HISTORY 并删除。因此,
连续的 AOFRW 失败势必会导致多个 INCR AOF 并存的问题。极端情况下,如果
AOFRW 重试频率很高我们将会看到成百上千个 INCR AOF 文件。

为此,我们引入了 AOFRW 限流机制。即当 AOFRW 已经连续失败三次时,下一次


的 AOFRW 会被强行延迟 1 分钟执行,如果下一次 AOFRW 依然失败,则会延迟 2
分钟,依次类推延迟 4、8、16...,当前最大延迟时间为 1 小时。

在 AOFRW 限流期间,我们依然可以使用 bgrewriteaof 命令立即执行一次 AOFRW。


Redis7.0 Multi Part AOF 的设计和实现 118

if (server.aof_state == AOF_ON &&


!hasActiveChildProcess() &&
server.aof_rewrite_perc &&
server.aof_current_size > server.aof_rewrite_min_size &&
!aofRewriteLimited())
{
long long base = server.aof_rewrite_base_size ?
server.aof_rewrite_base_size : 1;
long long growth = (server.aof_current_size*100/base) - 100;
if (growth >= server.aof_rewrite_perc) {
rewriteAppendOnlyFileBackground();
}
}

AOFRW 限流机制的引入,还可以有效的避免 AOFRW 高频重试带来的 CPU 和 fork


开销。Redis 中很多的 RT 抖动都和 fork 有关系。

五、 总结

MP-AOF 的引入,成功的解决了之前 AOFRW 存在的内存和 CPU 开销对 Redis 实例


甚至业务访问带来的不利影响。同时,在解决这些问题的过程中,我们也遇到了很
多未曾预料的挑战,这些挑战主要来自于 Redis 庞大的使用群体、多样化的使用场
景,因此我们必须考虑用户在各种场景下使用 MP-AOF 可能遇到的问题。如兼容性、
易用性以及对 Redis 代码尽可能的减少侵入性等。这都是 Redis 社区功能演进的重
中之重。

同时,MP-AOF 的引入也为 Redis 的数据持久化带来了更多的想象空间。如在开启


aof-use-rdb-preamble 时,BASE AOF 本质是一个 RDB 文件,因此我们在进行全
量备份的时候无需在单独执行一次 BGSAVE 操作。直接备份 BASE AOF 即可。MP-
AOF 支持关闭自动清理 HISTORY AOF 的能力,因此那些历史的 AOF 有机会得以保
留,并且目前 Redis 已经支持在 AOF 中加入 timestamp annotation,因此基于这
些我们甚至可以实现一个简单的 PITR 能力(point-in-time recovery)。

MP-AOF 的设计原型来自于 Tair for Redis 企业版[2]的 binlog 实现,这是一套在阿


里云 Tair 服务上久经验证的核心功能,在这个核心功能上阿里云 Tair 成功构建了全
球多活、PITR 等企业级能力,使用户的更多业务场景需求得到满足。今天我们将这
个核心能力贡献给 Redis 社区,希望社区用户也能享受这些企业级特性,并通过这
Redis7.0 Multi Part AOF 的设计和实现 119

些企业级特性更好的优化,创造自己的业务代码。有关 MP-AOF 的更多细节,请移


步参考相关 PR(#9788),那里有更多的原始设计和完整代码。

参考资料
[1] http://mysql.taobao.org/monthly/2018/12/06/
[2] https://help.aliyun.com/document_detail/145956.html

You might also like