String 是最基本的 key-value 结构,key 是唯一标识,value 是具体的值,value其实不仅是字符串, 也可以是数字(整数或浮点数),value 最多可以容纳的数据长度是 512M。
内部实现
String 类型的底层数据结构实现主要是 int 和 SDS(简单动态字符串)。
如果字符串对象保存的是整数值,会将整数值保存在字符串对象结构的ptr属性里面。
如果字符串对象保存的是字符串,那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串,并将对象的编码设置为embstr或raw。
使用场景
缓存对象:直接缓存整个对象的JSON(一般对象用这种方式)、采用不同的key将对象属性进行分离
常规计数:计算访问次数、点赞、转发、库存数量等等
共享 Session 信息:使用同一个 Redis 存储分布式系统下的 Session 信息
List 列表是简单的字符串列表,按照「插入顺序」排序,可以从头部或尾部向 List 列表添加元素。
内部实现
List 类型的底层数据结构是由双向链表或压缩列表实现的:
在 Redis 3.2 版本之后,List 类型的底层数据结构就只由 quicklist 实现了,替代了双向链表和压缩列表。
应用场景
消息队列:
消息队列在存取消息时,必须要满足三个需求,分别是消息保序、处理重复的消息和保证消息的可靠性。
List 本身就是按先进先出的顺序对数据进行存取的,所以,如果使用 List 作为消息队列保存消息的话,就已经能满足消息保序的需求了。
List 可以使用 LPUSH + RPOP (或者反过来,RPUSH+LPOP)命令实现消息队列。
Redis提供了「BRPOP」命令。BRPOP命令也称为阻塞式读取,客户端在没有读到队列数据时,自动阻塞,直到有新的数据写入队列,再开始读取新数据。和消费者程序自己不停地调用RPOP命令相比,这种方式能节省CPU开销。
消费者要实现重复消息的判断,需要满足 2 个方面的要求:
为了留存消息,List 类型提供了 BRPOPLPUSH 命令,这个命令的作用是让消费者程序从一个 List 中读取消息,同时,Redis 会把这个消息再插入到另一个 List(可以叫作备份 List)留存。
这样一来,如果消费者程序读了消息但没能正常处理,等它重启后,就可以从备份 List 中重新读取消息并进行处理了。
基于 List 类型的消息队列,满足消息队列的三大需求(消息保序、处理重复的消息和保证消息的可靠性):
List 不支持多个消费者消费同一条消息,因为一旦消费者拉取一条消息后,这条消息就从 List 中删除了,无法被其它消费者再次消费。
要实现一条消息可以被多个消费者消费,那么就要将多个消费者组成一个消费组,使得多个消费者可以消费同一条消息,但是 List 类型并不支持消费组的实现。
Hash 是一个键值对(key - value)集合,其中 value 的形式如: value=[{field1,value1},...{fieldN,valueN}]。Hash 特别适合用于存储对象。
内部实现
Hash 类型的底层数据结构是由压缩列表或哈希表实现的:
在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。
应用场景
Hash 类型的 (key,field, value) 的结构与对象的(对象id, 属性, 值)的结构相似,也可以用来存储对象。
以用户 id 为 key,商品 id 为 field,商品数量为 value,恰好构成了购物车的3个要素。
Set 类型是一个无序并唯一的键值集合,它的存储顺序不会按照插入的先后顺序进行存储。
Set 类型除了支持集合内的增删改查,同时还支持多个集合取交集、并集、差集。
Set 类型和 List 类型的区别如下:
内部实现
Set 类型的底层数据结构是由哈希表或整数集合实现的:
应用场景
在主从集群中,为了避免主库因为 Set 做聚合计算(交集、差集、并集)时导致主库被阻塞,我们可以选择一个从库完成聚合统计,或者把数据返回给客户端,由客户端来完成聚合统计。
Set 类型可以保证一个用户只能点一个赞,key 是文章id,value 是用户id。
Set 类型支持交集运算,所以可以用来计算共同关注的好友、公众号等。
key 可以是用户id,value 则是已关注的公众号id。
存储某活动中中奖的用户名 ,Set 类型因为有去重功能,可以保证同一个用户不会中奖两次。
key为抽奖活动名,value为员工名称,把所有员工名称放入抽奖箱。
如果允许重复中奖,可以使用 SRANDMEMBER 命令。
如果不允许重复中奖,可以使用 SPOP 命令。
Zset 类型(有序集合类型)相比于 Set 类型多了一个排序属性 score(分值),对于有序集合 ZSet 来说,每个存储元素相当于由两个值组成,一个是有序集合的元素值,一个是排序值。
有序集合保留了集合中不能有重复成员的特性(分值可以重复),但不同的是,有序集合中的元素可以排序。
内部实现
Zset 类型的底层数据结构是由压缩列表或跳表实现的:
在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。
应用场景
Zset 类型(Sorted Set,有序集合) 可以根据元素的权重来排序,我们可以自己来决定每个元素的权重值。比如说,我们可以根据元素插入 Sorted Set 的时间确定权重值,先插入的元素权重小,后插入的元素权重大。
有序集合比较典型的使用场景就是排行榜。例如学生成绩的排名榜、游戏积分排行榜、视频播放排名、电商系统中商品的销量排名等。
使用有序集合的 ZRANGEBYLEX 或 ZREVRANGEBYLEX 可以帮助我们实现电话号码或姓名的排序(返回指定区间内的成员,按 key 正序排列,分数必须相同)
Bitmap,即位图,是一串连续的二进制数组(0和1),可以通过偏移量(offset)定位元素。BitMap通过最小的单位bit来进行0|1的设置,表示某个元素的值或者状态,时间复杂度为O(1)。
由于 bit 是计算机中最小的单位,使用它进行存储将非常节省空间,特别适合一些数据量大且使用二值统计的场景。

内部实现
Bitmap 本身是用 String 类型作为底层数据结构实现的一种统计二值状态的数据类型。
String 类型是会保存为二进制的字节数组,所以,Redis 就把字节数组的每个 bit 位利用起来,用来表示每个元素的二值状态,你可以把 Bitmap 看作是一个 bit 数组。
应用场景
在签到打卡的场景中,我们只用记录签到(1)或未签到(0),所以它就是非常典型的二值状态。
HyperLogLog 提供不精确的去重计数,标准误算率是0.81%。
每个 HyperLogLog 键只需要花费 12 KB 内存,就可以计算接近 2^64 个不同元素的基数。
应用场景
GEO 主要用于存储地理位置信息,并对存储的信息进行操作。
内部实现
GEO 本身并没有设计新的底层数据结构,而是直接使用了 Sorted Set 集合类型。
应用场景
滴滴打车:
可以把经纬度保存到 Sorted Set 中,利用 Sorted Set 提供的“按权重进行有序范围查找”的特性,实现 LBS 服务中频繁使用的“搜索附近”的需求。
Stream 是 Redis 专门为消息队列设计的数据类型。
Stream 用于完美地实现消息队列,它支持消息的持久化、支持自动生成全局唯一 ID、支持 ack 确认消息的模式、支持消费组模式等,让消息队列更加稳定和可靠。
常见命令
Stream 消息队列操作命令:
XADD:插入消息,保证有序,可以自动生成全局唯一 ID;XLEN :查询消息长度;XREAD:用于读取消息,可以按 ID 读取数据;XDEL : 根据消息 ID 删除消息;DEL :删除整个 Stream;XRANGE :读取区间消息XREADGROUP:按消费组形式读取消息;XPENDING 和 XACK:
应用场景
消息队列:
生产者插入一条消息,成功后会返回全局唯一的 ID,由两部分组成:消息插入的时间、消息序号。
如果想要实现阻塞读(当没有数据时,阻塞住),可以在调用 XRAED 时设定 BLOCK 配置项,实现类似于 BRPOP 的阻塞读取操作。
Stream 可以使用 XGROUP 创建消费组,创建消费组之后,Stream 可以使用 XREADGROUP 命令让消费组内的消费者读取消息。
消息队列中的消息一旦被消费组里的一个消费者读取了,就不能再被该消费组内的其他消费者读取了,即同一个消费组里的消费者不能消费同一条消息。
但是,不同消费组的消费者可以消费同一条消息(但是有前提条件,创建消息组的时候,不同消费组指定了从相同位置开始读取消息)。
使用消费组的目的是让组内的多个消费者共同分担读取消息,所以,我们通常会让每个消费者读取部分消息,从而实现消息读取负载在多个消费者间是均衡分布的。
Streams 会自动使用内部队列(也称为 PENDING List)留存消费组里每个消费者读取的消息,直到消费者使用 XACK 命令通知 Streams“消息已经处理完成”。
消费确认增加了消息的可靠性,一般在业务处理完成之后,需要执行 XACK 命令确认消息已经被消费完成,整个流程的执行如下图所示:

如果消费者没有成功处理消息,它就不会给 Streams 发送 XACK 命令,消息仍然会留存。此时,消费者可以在重启后,用 XPENDING 命令查看已读取、但尚未确认处理完成的消息。
Redis 消息中间件会不会丢消息?
会,Redis 在以下 2 个场景中,都会导致数据丢失:
Redis Stream 消息可堆积吗?
Redis 的数据都存储在内存中,这就意味着一旦发生消息积压,则会导致 Redis 的内存持续增长,如果超过机器内存上限,就会面临被 OOM 的风险。
所以 Redis 的 Stream 提供了可以指定队列最大长度的功能,就是为了避免这种情况发生。
当指定队列最大长度时,队列长度超过上限后,旧消息会被删除,只保留固定长度的新消息。这么来看,Stream 在消息积压时,如果指定了最大长度,还是有可能丢失消息的。
能不能将 Redis 作为消息队列来使用,关键看你的业务场景:
Redis 发布/订阅机制为什么不可以作为消息队列?
注意,Redis 数据结构并不是指 String(字符串)对象、List(列表)对象、Hash(哈希)对象、Set(集合)对象和 Zset(有序集合)对象,因为这些都是 Redis 键值对中值的数据类型,也就是数据的保存形式,这些对象的底层实现方式就用到了数据结构。

键值对在 Redis 中是怎么实现的呢?
Redis 的键值对中的 key 就是字符串对象,而 value 可以是字符串对象,也可以是集合数据类型的对象,比如 List 对象、Hash 对象、Set 对象和 Zset 对象。
为了实现从键到值的快速访问,Redis 使用一个哈希表来保存所有键值对(全局哈希表)。
一个哈希表其实就是一个数组,数组中的每个元素称为一个哈希桶。一个哈希表是由多个哈希桶组成的,每个哈希桶中保存了键值对数据。
哈希桶中的元素保存的并不是键值对本身,而是指向具体键值的指针。也就是说,不管值是 String,还是集合类型,哈希桶中的元素都是指向它们的指针。
哈希桶中的 entry 元素保存了 key 和 value 指针,分别指向了实际的键和值,这样一来,即使值是一个集合,也可以通过 value 指针被查找到。

哈希表的最大特点是可以用 O(1) 的时间复杂度来快速查找到键值对。
我们只需要计算键的哈希值,就可以知道它所对应的哈希桶位置,然后就可以访问到相应的 entry 元素。
为什么哈希表操作变慢了?
举例:哈希冲突
entry1、entry2 和 entry3 都需要保存在哈希桶 3 中,这就导致了哈希冲突。
此时,entry1 元素会通过一个 next 指针指向 entry2,同样,entry2 也会通过 next 指针指向 entry3。
这样一来,即使哈希桶 3 中的元素有 100 个,我们也可以通过 entry 元素中的指针,把它们连起来。这就形成了一个链表,也叫作哈希冲突链。
如果哈希冲突越来越多,形成的冲突链表就会越来越长,导致在这个链表上的元素查找耗时较长,效率降低。
针对于上面存在的问题,Redis 引入了 rehash 操作
rehash 操作更加高效,Redis 默认使用了两个全局哈希表:哈希表 1 和哈希表 2。rehash,这个过程分为三步:
把哈希表 1 中的数据重新映射并拷贝到哈希表 2 中 这一步又会存在问题,如果一次性把哈希表 1 中的数据都迁移完,会造成 Redis 线程阻塞,无法服务其他请求。此时,Redis 就无法快速访问数据了。
渐进式rehash。entries 拷贝到哈希表 2 中;entries。
渐进式 rehash 技术使用了离散的概念,将迁移数据的工作量均摊到每次操作中,避免一次性迁移造成 Redis 不可用。代价就是在较长的时间内会存在两个哈希表。
渐进式 rehash 执行时,除了根据键值对的操作来进行数据迁移,Redis 本身还会有一个定时任务在执行 rehash;
如果没有键值对操作时,这个定时任务会周期性地(例如每100ms一次)迁移一些数据到新的哈希表中,这样就可以缩短整个 rehash 的时间。
什么情况下会触发 rehash 操作呢?
rehash 的触发条件跟**负载因子(load factor)**有关。


O(1)。alloc - len 计算出剩余的空间大小,可以用来判断空间是否满足修改需求,如果不满足的话,就会自动将 SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用 SDS 既不需要手动修改 SDS 的空间大小,也不会出现缓冲区溢出的问题。链表节点结构设计:

链表结构设计:
list 结构为链表提供了链表头指针 head、链表尾节点 tail、链表节点数量 len、以及可以自定义实现的 dup、free、match 函数。
下面是由 list 结构和 3 个 listNode 结构组成的链表。

压缩列表的最大特点,就是它被设计成一种内存紧凑型的数据结构,占用一块连续的内存空间,不仅可以利用 CPU 缓存,而且会针对不同长度的数据,进行相应的编码,这种方式可以有效地节省内存开销。
压缩列表结构设计:
压缩列表是 Redis 为了节约内存而开发的,它是由连续内存块组成的顺序型数据结构,有点类似于数组。

在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段直接定位,时间复杂度是 O(1)。而在查找其他元素时,就没那么高效了,只能逐个查找,此时的时间复杂度就是 O(N) 了,因此压缩列表不适合保存过多的元素。
压缩列表节点(
entry)的构成如下:

encoding 决定;当我们往压缩列表中插入数据时,压缩列表就会根据数据类型是字符串还是整数,以及数据的大小,会使用不同空间大小的 prevlen 和 encoding 这两个元素里保存的信息,这种根据数据大小和类型进行不同的空间大小分配的设计思想,正是 Redis 为了节省内存而采用的。
压缩列表除了查找时间复杂度较高的问题,还存在「连锁更新」的问题。
压缩列表新增某个元素或修改某个元素时,如果空间不够,压缩列表占用的内存空间就需要重新分配。而当新插入的元素较大时,可能会导致后续元素的 prevlen 占用空间都发生变化,从而引起「连锁更新」问题,导致每个元素的空间都要重新分配,造成访问压缩列表性能的下降。
压缩列表节点的
prevlen属性会根据前一个节点的长度进行不同的空间大小分配:
现在假设一个压缩列表中有多个连续的、长度在 250~253 之间的节点,如下图:

因为这些节点长度值小于 254 字节,所以 prevlen 属性需要用 1 字节的空间来保存这个长度值。
这时,如果将一个长度大于等于 254 字节的新节点加入到压缩列表的表头节点,即新节点将成为 e1 的前置节点,如下图:

因为 e1 节点的 prevlen 属性只有 1 字节大小,无法保存新节点的长度,此时就需要对压缩列表的空间进行重分配操作,并将 e1 节点的 prevlen 属性从原来的 1 字节大小扩展为 5 字节大小。
多米诺牌的效应就此开始。

压缩列表的缺陷
空间扩展操作也就是重新分配内存,因此连锁更新一旦发生,就会导致压缩列表占用的内存空间需要多次重新分配,这将会直接影响到压缩列表的访问性能。
虽然压缩列表紧凑型的内存布局能节省内存开销,但如果保存的元素数量增加了,或者是元素变大了,就会导致内存重新分配,最糟糕的是会有「连锁更新」的问题。
因此,压缩列表只会用于保存节点数量不多的场景,只要节点数量足够小,即使发生连锁更新,也是能接受的。
Redis 只有 Zset 对象的底层实现用到了跳表,跳表的优势是能支持平均 O(logN) 复杂度的节点查找。
Zset 结构体里有两个数据结构:一个是跳表,一个是哈希表。这样设计的好处是既能支持高效的范围查询,也能支持高效的单点查询。
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
Zset 对象在执行数据插入和数据更新的过程中,会依次在跳表和哈希表中插入和更新相应的数据,从而保证了跳表和哈希表中记录的一致性。
Zset 对象能支持范围查询(如 ZRANGEBYSCORE 操作),这是因为它的数据结构设计采用了跳表,而又能以常数复杂度获取元素权重(如 ZSCORE 操作),这是因为它同时采用了哈希表进行索引。
跳表结构设计
链表在查找元素的时候,因为需要逐一查找,所以查询效率非常低,时间复杂度是O(N),于是就出现了跳表。跳表是在链表基础上改进过来的,实现了一种「多层」的有序链表,这样的好处是能快速定位数据。

图中头节点有 L0~L2 三个头指针,分别指向了不同层级的节点,然后每个层级的节点都通过指针连接起来:
如果我们要在链表中查找节点 4 这个元素,只能从头开始遍历链表,需要查找 4 次。
而使用了「跳表」后,只需要查找 2 次就能定位到节点 4,因为可以在头节点直接从 L2 层级跳到节点 3,然后再往前遍历找到节点 4。
这个查找的过程就是在多个层级上跳来跳去,最后定位到元素。当数据量很大时,跳表的查找复杂度是 O(logN)。
跳表节点层数设置
跳表的相邻两层的节点数量最理想的比例是 2:1,查找复杂度可以降低到 O(logN)。
下图的跳表就是,相邻两层的节点数量的比例是 2 : 1。

怎样才能维持相邻两层的节点数量的比例为 2 : 1 呢?
跳表在创建节点的时候,会生成一个范围在 [0-1] 的随机数,如果这个随机数小于 0.25(相当于概率 25%),那么层数就增加 1 层,然后继续生成下一个随机数,直到随机数的结果大于 0.25 结束,最终确定该节点的层数。
这样的做法,相当于每增加一层的概率不超过 25%,层数越高,概率越低,层高最大限制是 64。
为什么 Zset 的实现用跳表而不用平衡树(如 AVL树、红黑树等)?
在 Redis 3.0 之前,List 对象的底层数据结构是双向链表或压缩列表。在 Redis 3.2 之后,List 对象的底层改由 quicklist 数据结构实现。
其实 quicklist 就是「双向链表 + 压缩列表」的组合,因为 quicklist 就是一个链表,而链表中的每个元素又是一个压缩列表。
压缩列表有「连锁更新」的风险,而 quicklist 的解决办法是:通过控制每个链表节点中的压缩列表的大小或者元素个数,来规避连锁更新的问题。因为压缩列表的元素越少或越小,连锁更新带来的影响就越小,从而提供了更好的访问性能。
quicklist 结构设计

在向 quicklist 添加一个元素的时候,不会像普通的链表那样,直接新建一个链表节点。而是会检查插入位置的压缩列表是否能容纳该元素,如果能容纳就直接保存到 quicklistNode 结构里的压缩列表中,如果不能容纳,才会新建一个新的 quicklistNode 结构。
quicklist 会控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来规避潜在的「连锁更新」的风险,但是这并没有完全解决连锁更新的问题。
Redis 在 5.0 新设计了一个数据结构叫做 listpack,目的是替代压缩列表,它最大的特点是 listpack 中每个节点不再包含前一个节点的长度了,压缩列表正因为每个节点需要保存前一个节点的长度字段,就会有「连锁更新」的隐患。
listpack 结构设计
listpack 是用一块连续的内存空间来紧凑地保存数据,并且为了节省内存开销,listpack 节点会采用不同的编码方式来保存不同大小的数据。

每个 listpack 的节点结构如下:

可以看到,listpack 没有压缩列表中记录前一个节点长度的字段了,listpack 只记录当前节点的长度,当我们向 listpack 加入一个新元素的时候,不会影响其他节点的长度字段的变化,从而避免了压缩列表的「连锁更新」问题。
Redis 是可以对 key 设置过期时间的,因此需要有相应的机制将已过期的键值对删除,而做这个工作的就是过期键值删除策略。
expire :设置 key 在 n 秒后过期
设置键值对的时候,同时指定过期时间(精确到秒):(下面两个命令都可以)
set
setex
TTL :查看某个 key 剩余的存活时间
如何判断一个 key 已过期了?
对一个 key 设置了过期时间,Redis 会把该 key 带上过期时间存储到一个过期字典(expires dict)中,也就是说「过期字典」保存了数据库中所有 key 的过期时间。
过期字典的数据结构如下:
字典实际上是哈希表,哈希表的最大好处就是让我们可以用 O(1) 的时间复杂度来快速查找。当我们查询一个 key 时,Redis 首先检查该 key 是否存在于「过期字典」中:
常见的三种过期删除策略:
定时删除策略是怎样的?
在设置 key 的过期时间时,同时创建一个定时事件,当时间到达时,由事件处理器自动执行 key 的删除操作。
优点:
可以保证过期 key 会被尽快删除,也就是内存可以被尽快释放,因此定时删除策略对内存是最友好的。
缺点:
在过期 key 比较多的情况下,删除过期 key 可能会占用相当一部分的 CPU 时间,在内存不紧张但 CPU 时间紧张的情况下,将 CPU 时间用于删除和当前任务无关的过期键上,无疑会对服务器的响应时间和吞吐量造成影响。所以,定时删除策略对 CPU 不友好。
惰性删除策略是怎样的?
不主动删除过期键,每次从数据库访问 key 时,都会检测 key 是否过期,如果过期则删除该 key。
优点:
因为在每次访问时才会检查 key 是否过期,所以此策略只会占用很少的系统资源。因此,惰性删除策略对 CPU 时间最友好。
缺点:
如果一个 key 已经过期,而这个 key 又仍然保留在数据库中,那么只要这个过期 key 一直没有被访问到,它所占用的内存就不会被释放,造成了一定的内存空间浪费。所以,惰性删除策略对内存空间不友好。
定期删除策略是怎样的?
每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期key。
优点:
通过限制删除操作执行的时长和频率,来减少删除操作对 CPU 的影响。同时也能删除一部分过期的key,减少了过期key对空间的无效占用。
缺点:
每一种策略都有其优缺点,仅使用其中一种策略都不能满足实际需求。
Redis 选择「惰性删除+定期删除」这两种策略配合使用,以求在合理使用 CPU 时间和避免内存浪费之间取得平衡。
关于定期删除策略
默认每秒进行10次过期检查,每次检查数据库并不是遍历过期字典中的所有key,而是从数据库中随机抽取一定数量的 key 进行过期检查。
数据库每轮抽查时,会随机选择 「20」 个 key 判断是否过期。
Redis 定期删除的完整流程:
Redis 规定定期删除的循环流程默认不会超过 25ms。
do {
//已过期的数量
expired = 0;
//随机抽取的数量
num = 20;
while (num--) {
//1. 从过期字典中随机抽取 1 个 key
//2. 判断该 key 是否过期,如果已过期则进行删除,同时对 expired++
}
// 超过时间限制则退出
if (timelimit_exit) return;
/* 如果本轮检查的已过期 key 的数量,超过 25%,则继续随机抽查,否则退出本轮检查 */
} while (expired > 20/4);

当 Redis 的运行内存超过 Redis 设置的最大内存时,会使用内存淘汰策略删除符合条件的 key,以此来保障 Redis 的高效运行。
如何设置 Redis 最大运行内存?
在配置文件 redis.conf 中,可以通过参数 maxmemory 来设定最大运行内存,只有在 Redis 的运行内存达到了我们设置的最大运行内存时,才会触发内存淘汰策略。
maxmemory 的默认值是 0,表示没有内存大小限制,那么不管用户存放多少数据到 Redis 中,Redis 也不会对可用内存进行检查,直到 Redis 实例因内存不足而崩溃。maxmemory 的默认值是 3 GB,因为 32 位的机器最大只支持 4GB 的内存,而系统本身就需要一定的内存资源来支撑运行,所以 32 位操作系统限制最大 3 GB 的可用内存是非常合理的,这样可以避免因为内存不足而导致 Redis 实例崩溃。Redis 内存淘汰策略共有八种,这八种策略大体分为「不进行数据淘汰」和「进行数据淘汰」两类策略。
noeviction(Redis3.0之后,默认的内存淘汰策略) :它表示当运行内存超过最大设置内存时,不淘汰任何数据,而是不再提供服务,直接返回错误。
针对「进行数据淘汰」这一类策略,又可以细分为「在设置了过期时间的数据中进行淘汰」和「在所有数据范围内进行淘汰」这两类策略。
在设置了过期时间的数据中进行淘汰:
在所有数据范围内进行淘汰:
查看当前 Redis 使用的内存淘汰策略:config get maxmemory-policy
修改 Redis 内存淘汰策略
通过 config set maxmemory-policy <策略> 命令设置。它的优点是设置之后立即生效,不需要重启 Redis 服务,缺点是重启 Redis 服务之后,设置就会失效。
通过修改 Redis 配置文件,设置 maxmemory-policy <策略> 。它的优点是重启 Redis 服务后配置不会丢失,缺点是必须重启 Redis 服务,设置才能生效。
什么是 LRU 算法?
LRU 全称是:最近最少使用,会选择淘汰最近最少使用的数据。
传统 LRU 算法的实现是基于「链表」结构,链表中的元素按照操作顺序从前往后排列,最新操作的键会被移动到表头,当需要内存淘汰时,只需要删除链表尾部的元素即可,因为链表尾部的元素就代表最久未被使用的元素。
Redis 并没有使用这样的方式实现 LRU 算法,因为传统的 LRU 算法存在两个问题:
Redis 是如何实现 LRU 算法的?
Redis 实现的是一种近似 LRU 算法,目的是为了更好的节约内存,它的实现方式是在 Redis 的对象结构体中添加一个额外的字段,用于记录此数据的最后一次访问时间。
当 Redis 进行内存淘汰时,会使用随机采样的方式来淘汰数据,它是随机取 5 个值(此值可配置),然后淘汰最久没有使用的那个。
优点:
缺点:无法解决缓存污染问题
比如应用一次性读取了大量的数据,而这些数据只会被读取这一次,那么这些数据会留存在 Redis 缓存中很长一段时间,造成缓存污染。
什么是 LFU 算法?
LFU 全称是:最不经常使用的,LFU 算法是根据数据访问次数来淘汰数据的,它的核心思想是:如果数据过去被访问多次,那么将来被访问的频率也更高。
所以, LFU 算法会记录每个数据的访问次数。当一个数据被再次访问时,就会增加该数据的访问次数。这样就解决了偶尔被访问一次之后,数据留存在缓存中很长一段时间的问题,相比于 LRU 算法也更合理一些。
Redis 是如何实现 LFU 算法的?
LFU 算法相比于 LRU 算法的实现,多记录了「数据的访问频次」的信息。Redis 对象的结构如下:
typedef struct redisObject {
...
// 24 bits,用于记录对象的访问信息
unsigned lru:24;
...
} robj;
Redis 对象头中的 lru 字段,在 LRU 算法和 LFU 算法下的使用方式并不相同。
ldt,低 8bit 存储 logc。ldt 是用来记录 key 的访问时间戳。
logc 是用来记录 key 的访问频率,它的值越小表示使用频率越低,越容易淘汰,每个新加入的 key 的 logc 初始值为 5。
Redis 在访问 key 时,对于 logc 是这样变化的:

当大量缓存数据在同一时间过期(失效)或者 Redis 故障宕机时,如果此时有大量的用户请求都无法在 Redis 中进行处理,于是全部请求都直接访问数据库,从而导致数据库的压力骤增,严重的话会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃,这就是「缓存雪崩」的问题。
发生「缓存雪崩」有两个原因:
大量缓存数据同时过期,应该怎么解决呢?
如果要给缓存数据设置过期时间,应该避免将大量的数据设置成相同的过期时间。
如果业务层的确要求有些数据需要同时失效,可以在用 EXPIRE 命令给每个数据设置过期时间时,增加一个较小的随机数(例如,随机增加 1~3 分钟)
这样一来,不同数据的过期时间有所差别,但差别又不会太大,既避免了大量数据同时过期,也保证了这些数据基本在相近的时间失效,仍然能满足业务需求。
服务降级来应对缓存雪崩所谓服务降级,是指发生缓存雪崩时,针对不同的数据采取不同的处理方式:
Redis 故障宕机,应该怎么解决呢?
服务熔断或请求限流机制在因为 Redis 故障宕机而发生缓存雪崩时,我们可以启动「服务熔断」机制,暂停业务应用对缓存系统的访问,直接返回错误,不用再继续访问数据库,从而降低对数据库的访问压力,保证数据库的正常运行,然后等到 Redis 恢复正常后,再允许业务应用访问缓存系统。
服务熔断虽然可以保证数据库的正常运行,但是暂停了整个缓存系统的访问,对业务应用的影响范围很大。
为了减少对业务的影响,我们可以启用「请求限流」机制,只将少部分请求发送到数据库进行处理,再多的请求就在入口处直接拒绝服务,等到 Redis 恢复正常并把缓存预热后,再解除「请求限流」的机制。
「服务熔断」或「请求限流」机制是缓存雪崩发生后的应对方案,我们最好通过主从节点的方式构建 Redis 缓存高可靠集群。
如果 Redis 缓存的主节点故障宕机,从节点可以切换成为主节点,继续提供缓存服务,避免了由于 Redis 故障宕机而导致的「缓存雪崩」问题。
我们的业务通常会有几个数据会被频繁地访问,比如秒杀活动,这类频繁访问的数据被称为「热点数据」。
如果缓存中的某个热点数据过期了,此时大量的请求访问了该热点数据,就无法从缓存中读取,因此直接访问数据库,数据库很容易就被高并发的请求冲垮,这就是缓存击穿的问题。
针对「热点数据」过期导致的缓存击穿,应该怎么解决呢?
对于访问特别频繁的热点数据,不设置过期时间。
这样一来,对热点数据的访问请求,都可以在缓存中进行处理,而 Redis 数万级别的高吞吐量可以很好地应对大量的并发请求访问。
当用户访问的数据,既不在缓存中,也不在数据库中,导致请求在访问缓存时,发现缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据,没办法构建缓存数据来服务后续的请求。
那么当有大量这样的请求到来时,数据库的压力骤增,这就是缓存穿透的问题。
缓存穿透的发生一般有两种情况:
针对于「缓存穿透」有三种解决方案:
缓存穿透的一个原因是有大量的恶意请求访问不存在的数据,所以一个有效的应对方案是在请求入口的前端,对业务系统接收到的请求进行合法性检测。
把恶意的请求(例如请求参数不合理、请求参数是非法值、请求字段不存在)直接过滤掉,不让它们访问后端缓存和数据库。
这样一来,也就不会出现缓存穿透问题了。
当我们的线上业务发现「缓存穿透」的现象时,可以针对查询的数据,在缓存中设置一个空值或者默认值,这样后续请求就可以直接从缓存中读取到空值或者默认值,返回给应用,而不会继续查询数据库。
布隆过滤器是由一个初值都为 0 的 bit 数组和 N 个哈希函数组成的,可以用来快速判断某个数据是否存在。
当我们想标记某个数据存在时(例如,数据已被写入数据库),布隆过滤器会通过三个操作完成标记:
如果数据不存在(例如,数据库里没有写入数据),我们也就没有用布隆过滤器标记过该数据,那么 bit 数组对应 bit 位的值仍然为 0。
当需要查询某个数据时,我们就执行刚刚说的计算过程,先得到这个数据在 bit 数组中对应的 N 个位置。
紧接着,我们查看 bit 数组中这 N 个位置上的 bit 值。
只要这 N 个 bit 值有一个不为 1,这就表明布隆过滤器没有对该数据做过标记,所以,查询的数据一定没有在数据库中保存。

正是基于布隆过滤器的快速检测特性,我们可以在把数据写入数据库时,使用布隆过滤器做个标记。
当缓存缺失后,应用查询数据库时,可以先通过查询布隆过滤器快速判断数据是否存在。如果不存在,就不用再去数据库中查询了。
这样一来,即使发生缓存穿透了,大量请求只会查询 Redis 和布隆过滤器,而不会积压到数据库,也就不会影响到数据库的正常运行。
布隆过滤器可以使用 Redis 实现,本身就能承担较大的并发访问压力。
Redis 提供了主从复制模式,可以保证多台服务器的数据一致性,且主从服务器之间采用的是「读写分离」的方式。
所有的数据修改只在主服务器上进行,然后将最新的数据同步给从服务器,这样就使得主从服务器的数据是一致的。
从服务器通过执行 replicaof <主服务器的 IP 地址> <主服务器的 Redis 端口号> 命令,与主服务器建立连接,然后就可以和主服务器进行第一次同步。
主从服务器间的第一次同步的过程可以分为三个阶段:

执行了 replicaof 命令后,从服务器就会给主服务器发送 psync 命令,表示要进行数据同步。
psync 命令包含两个参数,分别是主服务器的 runID 和复制进度 offset。
runID:每个 Redis 服务器在启动时都会自动生成一个随机的 ID 来唯一标识自己,因为是第一次同步,还不知道主服务器的 runID,所以将其设置为 “?”。
offset:表示复制进度,第一次同步时,其值为 -1。
主服务器收到 psync 命令后,会用 FULLRESYNC 作为响应命令返回给对方。
并且这个响应命令会带上两个参数:主服务器的 runID 和主服务器目前的复制进度 offset。从服务器收到响应后,会记录这两个值。
FULLRESYNC 响应命令的意图是采用全量复制的方式,也就是主服务器会把所有的数据都同步给从服务器。
接着,主服务器会执行 bgsave 命令来生成 RDB 文件,然后把文件发送给从服务器。
从服务器收到 RDB 文件后,会先清空当前的数据,然后再载入 RDB 文件。
注意:主服务器生成 RDB 文件这个过程是不会阻塞主线程的,因为 bgsave 命令会创建一个子进程来做生成 RDB 文件的工作,是异步的,这样 Redis 依然可以正常处理命令。
但是这期间的写操作命令并没有记录到刚刚生成的 RDB 文件中,这时主从服务器间的数据就不一致了。为了保证主从服务器的数据一致性,主服务器在下面这三个时间间隙中将收到的写操作命令,写入到 replication buffer 缓冲区里。
「从服务器」加载完 RDB 文件后,主服务器再将 replication buffer 缓冲区中记录的写操作命令发送给从服务器,然后「从服务器」重新执行这些操作,至此主从服务器的数据就一致了。
第一次同步的工作就完成了。
主从库第一次数据同步时,对主库来说有两个耗时的操作:
fork 子进程这个操作会阻塞主线程处理正常请求,从而导致主库响应应用程序的请求速度变慢。
传输RDB文件会占用主库的网络带宽,同样会给主库的资源使用带来压力。
针对上面两个耗时操作,“主-从-从” 模式可以有效分担主库的压力
通过 “主 - 从 - 从” 模式将主库生成 RDB 和传输 RDB 的压力,以级联的方式分散到从库上。
在部署主从集群的时候,可以手动选择一个内存资源配置较高的从库,用于级联其他的从库。
然后,可以再选择一些从库,在这些从库上执行如下命令,让它们和刚才所选的「从库」建立起主从关系。
replicaof 所选从库的IP 6379
这样一来,这些从库就会知道,在进行同步时,不用再和主库进行交互了,只要和级联的从库进行写操作同步就行了,这就可以减轻主库上的压力,如下图所示:

“主从从” 模式能够减少主库同步给所有从库的压力。
一旦主库和从库完成全量复制,它们之间就会一直维护一个网络连接,主库会把之后收到的命令通过这个连接同步给从库,这个过程就称为基于长连接的命令传播。
如果主从服务器间的网络连接断开了,那么就无法进行命令传播了,这时从服务器的数据就没办法和主服务器保持一致了,客户端就可能从「从服务器」读到旧的数据。
如果此时断开的网络又恢复正常了,要怎么继续保证主从服务器的数据一致性呢?
在 Redis 2.8 之前,如果主从服务器在命令同步时出现了网络断开又恢复的情况,从服务器就会和主服务器重新进行一次全量复制,很明显这样的开销太大了,必须要改进一波。
在 Redis 2.8 之后,网络断开又恢复,主从服务器会采用增量复制的方式继续同步,也就是只会把网络断开期间主服务器接收到的写操作命令,同步给从服务器。
主要有三个步骤:
psync 命令给主服务器,此时的 psync 命令中的 offset 参数不是 -1主服务器怎么知道要将哪些增量数据发送给从服务器呢?
repl_backlog_buffer,是一个「环形」缓冲区,用于主从服务器断连后,从中找到差异的数据
replication offset,标记「环形」缓冲区的同步进度,主从服务器都有各自的偏移量,主服务器使用 master_repl_offset 来记录自己「写」到的位置,从服务器使用 slave_repl_offset 来记录自己「读」到的位置。
那 repl_backlog_buffer 缓冲区是什么时候写入的呢
在主服务器进行命令传播时,不仅会将写命令发送给从服务器,还会将写命令写入到 repl_backlog_buffer 「环形」缓冲区中,因此这个缓冲区会保存着最近传播的写命令。
网络断开后,当从服务器重新连上主服务器时,从服务器会通过 psync 命令将自己的复制偏移量 slave_repl_offset 发送给主服务器,主服务器根据自己的 master_repl_offset 和 slave_repl_offset 之间的差距,来决定对从服务器执行哪种同步操作:
当主服务器在 repl_backlog_buffer 「环形」缓冲区中找到主从库差异(增量)的数据后,就会将增量的数据写入到 replication buffer 缓冲区,这个缓冲区就是缓存将要传播给从服务器的命令。

repl_backlog_buffer 「环形」缓冲区的默认大小是 1M,并且由于它是一个环形缓冲区,当缓冲区写满后,主服务器继续写入的话,就会覆盖之前的数据。
在网络恢复时,如果从服务器想读的数据已经被覆盖了,那么主服务器就会采用全量复制的方式,这个方式比增量复制的性能损耗要大很多。
为了避免在网络恢复时,主服务器频繁地使用全量复制的方式,我们应该调整下 repl_backlog_buffer 「环形」缓冲区的大小,减少从服务器要读的数据被覆盖的概率
repl_backlog_buffer 「环形」缓冲区大小的计算公式:
缓冲空间大小 = 主库写入命令的速度 * 每个操作的大小 - 主从库间网络传输命令的速度 * 每个操作的大小
在实际应用中,考虑到可能存在一些突发的请求压力,我们通常把这个缓冲空间扩大一倍,即 repl_backlog_size = 缓冲空间大小 * 2
举个例子,如果主库每秒写入 2000 个操作,每个操作的大小为 2KB,网络每秒能传输 1000 个操作,那么,有 1000 个操作需要缓冲起来,这就至少需要 2MB 的缓冲空间。否则,新写的命令就会覆盖掉旧操作了。为了应对可能的突发压力,我们最终把 repl_backlog_size 设置为 4MB。

这种保存写操作命令到日志的持久化方式,就是 Redis 里的 AOF(Append Only File) 持久化功能。
在 Redis 中 AOF 持久化功能默认是不开启的,需要我们修改 redis.conf 配置文件中的以下参数:

将命令写入到日志的这个操作也是在主进程中完成的(执行命令也是在主进程),也就是说这两个操作是同步的。
Redis 写入 AOF 日志的过程,如下图:
Redis 提供了 3 种写回硬盘的策略,在 redis.conf 配置文件中的 appendfsync 配置项可以有以下 3 种参数可填:
需要根据自己的业务场景进行选择:
No 策略;Always 策略;Everysec 策略。
这三种策略只是在控制
fsync()函数的调用时机
如果想要应用程序向文件写入数据后,能立马将数据同步到硬盘,就可以调用 fsync() 函数,这样内核就会将内核缓冲区的数据直接写入到硬盘,等到硬盘写操作完成后,该函数才会返回。
fsync() 函数;fsync() 函数;fsync() 函数。Redis 为了避免 AOF 文件越写越大,提供了 AOF 重写机制,当 AOF 文件的大小超过所设定的阈值后,Redis 就会启用 AOF 重写机制,来压缩 AOF 文件。
假设前后执行了「
set name xiaolin」和「set name xiaolincoding」这两个命令

在使用重写机制后,就会读取 name 最新的 value(键值对) ,然后用一条 「set name xiaolincoding」命令记录到新的 AOF 文件,之前的第一个命令就没有必要记录了。
重写机制的妙处在于,尽管某个键值对被多条写命令反复修改,最终也只需要根据这个「键值对」当前的最新状态,然后用一条命令去记录键值对,代替之前记录这个键值对的多条命令,这样就减少了 AOF 文件中的命令数量。最后在重写工作完成后,将新的 AOF 文件覆盖现有的 AOF 文件。
为什么重写 AOF 的时候,不直接复用现有的 AOF 文件,而是先写到新的 AOF 文件再覆盖过去呢?
因为如果 AOF 重写过程中失败了,现有的 AOF 文件就会造成污染,可能无法用于恢复使用。
所以 AOF 重写过程,先重写到新的 AOF 文件,重写失败的话,就直接删除这个文件就好,不会对现有的 AOF 文件造成影响。
当 AOF 文件大于 64M 时,就会对 AOF 文件进行重写,这时是需要读取所有缓存的键值对数据,并为每个键值对生成一条命令,然后将其写入到新的 AOF 文件,重写完后,就把现在的 AOF 文件替换掉。
这个过程其实是很耗时的,所以重写的操作不能放在主进程里。
Redis 的重写 AOF 过程是由后台子进程 bgrewriteaof 来完成的
只读的方式,而当父子进程任意一方修改了该共享内存,就会发生「写时复制」,于是父子进程就有了独立的数据副本。子进程是怎么拥有和主进程一样的数据副本呢?
主进程在通过 fork 系统调用生成 bgrewriteaof 子进程时,操作系统会把主进程的「页表」复制一份给子进程,这个页表记录着虚拟地址和物理地址映射关系,而不会复制物理内存,也就是说,两者的虚拟空间不同,但其对应的物理空间是同一个。
这样一来,子进程就共享了父进程的物理内存数据了,这样能够节约物理内存资源,页表对应的页表项的属性会标记该物理内存的权限为只读。
不过,当父进程或者子进程在向这个内存发起写操作时,CPU 就会触发缺页中断,这个缺页中断是由于违反权限导致的,然后操作系统会在「缺页异常处理函数」中进行物理内存的复制,并重新设置其内存映射关系,将父子进程的内存读写权限设置为可读写,最后才会对内存进行写操作,这个过程被称为 写时复制(Copy On Write)。
写时复制顾名思义,在发生写操作的时候,操作系统才会去复制物理内存,这样是为了防止 fork 创建子进程时,由于物理内存数据的复制时间过长而导致父进程长时间阻塞的问题。
当然,操作系统复制父进程页表的时候,父进程也是阻塞中的,不过页表的大小相比于实际的物理内存要小很多,所以通常复制页表的过程是比较快的。
不过,如果父进程的内存数据非常大,那自然页表也会很大,这时父进程在通过 fork 创建子进程的时候,阻塞的时间也越久。
有两个阶段会导致阻塞父进程:
创建子进程的途中,由于要复制父进程的页表等数据结构,阻塞的时间跟页表的大小有关,页表越大,阻塞的时间也越长;
创建完子进程后,如果子进程或者父进程修改了共享数据,就会发生写时复制,这期间会拷贝物理内存,如果内存越大,自然阻塞的时间也越长;
触发重写机制后,主进程就会创建重写 AOF 的子进程,此时父子进程共享物理内存,重写子进程只会对这个内存进行只读,重写 AOF 子进程会读取数据库里的所有数据,并逐一把内存数据的键值对转换成一条命令,再将命令记录到重写日志(新的 AOF 文件)。
但是在子进程重写过程中,主进程依然可以正常处理命令。
如果此时主进程修改了已经存在的 key-value,就会发生「写时复制」,注意这里只会复制主进程修改的物理内存数据,没修改的物理内存还是和子进程共享的。
如果这个阶段修改的是一个 bigkey,,这时复制物理内存数据的过程就会比较耗时,有阻塞主进程的风险。
此外,Redis 还设置了一个 AOF 重写缓冲区,这个缓冲区在创建 bgrewriteaof 子进程后开始使用。
在重写 AOF 期间,当 Redis 执行完一个写命令之后,它会同时将这个写命令写入到 「AOF 缓冲区」和 「AOF 重写缓冲区」。
在 bgrewriteaof 子进程执行 AOF 重写期间,主进程需要执行以下三个工作:
当子进程完成 AOF 重写工作后,会向主进程发送一条信号,信号是进程间通讯的一种方式,且是异步的。
主进程收到该信号后,会调用一个信号处理函数,该函数主要做以下工作:
信号函数执行完后,主进程就可以继续像往常一样处理命令了。
在整个 AOF 后台重写过程中,除了发生「写时复制」会对主进程造成阻塞,还有信号处理函数执行时也会对主进程造成阻塞,在其他时候,AOF 后台重写都不会阻塞主进程。
AOF 和 RDB 有什么区别?
RDB 快照就是记录某一个瞬间的内存数据,记录的是实际数据,而 AOF 文件记录的是命令操作的日志,而不是实际的数据。
因此在 Redis 恢复数据时, RDB 恢复数据的效率会比 AOF 高些,因为直接将 RDB 文件读入内存就可以,不需要像 AOF 那样还需要额外执行操作命令的步骤才能恢复数据。
因为 RDB 快照是全量快照的方式,所以执行的频率不能太频繁,否则会影响 Redis 性能,因此在服务器发生故障时,丢失的数据会比 AOF 持久化的方式更多(AOF 日志可以以秒级的方式记录操作命令)。
Redis 提供了两个命令来生成 RDB 文件,分别是
save和bgsave,他们的区别在于是否在「主线程」中执行:
save 命令,就会在主线程生成 RDB 文件,由于和执行操作命令在同一个线程,所以如果写入 RDB 文件的时间太长,会阻塞主线程;bgsave 命令,会创建一个子进程来生成 RDB 文件,这样可以避免主线程的阻塞。Redis 还可以通过配置文件的选项来实现每隔一段时间自动执行一次 bgsave 命令,默认会提供以下配置:
save 900 1
save 300 10
save 60 10000
只要满足上面条件的任意一个,就会执行 bgsave:
Redis 的快照是全量快照,也就是说每次执行快照时,都是把内存中的「所有数据」都记录到磁盘上。
执行 bgsave 命令时,会通过 fork() 创建子进程,此时子进程和父进程是共享同一片内存数据的,因为创建子进程的时候,会复制父进程的页表,但是页表指向的物理内存还是同一个。
只有在发生修改内存数据的情况时,物理内存才会被复制一份。
这样做的目的是为了减少创建子进程时的性能损耗,从而加快创建子进程的速度,毕竟创建子进程的过程中,是会阻塞主线程的。
创建 bgsave 子进程后,由于共享父进程的所有内存数据,于是就可以直接读取主线程(父进程)里的内存数据,并将数据写入到 RDB 文件。
如果主线程(父进程)要修改共享数据里的某一块数据(比如键值对 A)时,就会发生「写时复制」,于是这块数据的物理内存就会被复制一份(键值对 A'),然后主线程在这个数据副本(键值对 A')进行修改操作。与此同时,bgsave 子进程可以继续把原来的数据(键值对 A)写入到 RDB 文件。
就是这样,Redis 使用 bgsave 对当前内存中的所有数据做快照,这个操作是由 bgsave 子进程在后台完成的,执行时不会阻塞主线程,这就使得主线程同时可以修改数据。
bgsave 快照过程中,如果主线程修改了共享数据,发生了写时复制后,RDB 快照保存的是原本的内存数据,而主线程刚修改的数据,是没办法在这一时间写入 RDB 文件的,只能交由下一次的 bgsave 快照。
如果系统恰好在 RDB 快照文件创建完毕后崩溃了,那么 Redis 将会丢失主线程在快照期间修改的数据。
写时复制的时候会出现这么个极端的情况:
在 Redis 执行 RDB 持久化期间,刚 fork 时,主进程和子进程共享同一物理内存,但是途中主进程处理了写操作,修改了共享内存,于是当前被修改的数据的物理内存就会被复制一份。
那么极端情况下,如果所有的共享内存都被修改,则此时的内存占用是原先的 2 倍。
所以,针对写操作多的场景,我们要留意快照过程中的内存变化,防止内存被占满了。
尽管 RDB 比 AOF 的数据恢复速度快,但是快照的频率不好把握:
有没有什么方法不仅有 RDB 恢复速度快的优点,又有 AOF 丢失数据少的优点呢?
当然有,那就是将 RDB 和 AOF 合体使用,这个方法是在 Redis 4.0 提出的,该方法叫混合使用 AOF 日志和内存快照,也叫「混合持久化」。
如果想要开启混合持久化功能,可以在 Redis 配置文件中将下面这个配置项设置成 yes:
aof-use-rdb-preamble yes
混合持久化工作在 AOF 日志重写过程。
fork 出来的重写子进程会先将与主线程共享的内存数据以 RDB 方式写入到 AOF 文件,也就是说,使用了混合持久化,AOF 文件的前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据。
这样做的好处在于,重启 Redis 加载数据时,由于前半部分是 RDB 内容,这样加载的时候速度会很快。
加载完 RDB 的内容后,才会加载后半部分的 AOF 内容,这里的内容是 Redis 后台子进程重写 AOF 期间,主线程处理的写操作命令,可以使得数据更少的丢失。
先更新数据库,再删除缓存
订阅数据库变更日志,再操作缓存。
我们的业务应用在修改数据时,「只需」修改数据库,无需操作缓存。
那什么时候操作缓存呢?这就和数据库的「变更日志」有关了。
当一条数据发生修改时,MySQL 就会产生一条变更日志(Binlog),我们可以订阅这个日志,拿到具体操作的数据,然后再根据这条数据,去删除对应的缓存。
订阅变更日志,目前也有了比较成熟的开源中间件,例如阿里的 canal,使用这种方案的优点在于:
当然,与此同时,我们需要投入精力去维护 canal 的高可用和稳定性。
在「先删除缓存,再更新数据库」方案下,2 个线程要并发「读写」数据,可能会发生以下场景:
最终 X 的值在缓存中是 1(旧值),在数据库中是 2(新值),发生不一致。
解决方案:在线程 A 删除缓存、更新完数据库之后,先「休眠一会」,再「删除」一次缓存。
延迟时间要大于线程 B 读取数据库 + 写入缓存的时间
在「先更新数据库,再删除缓存」方案下,「读写分离 + 主从库延迟」其实也会导致不一致:
最终 X 的值在缓存中是 1(旧值),在主从库中是 2(新值),也会发生不一致。
解决方案:线程 A 可以生成一条「延时消息」,写到消息队列中,消费者延时「删除」缓存。
延迟时间要大于「主从复制」的延迟时间
// 一条命令保证原子性执行
127.0.0.1:6379> SET lock 1 EX 10 NX
OK
设想这样一种场景:
这里存在两个严重的问题:
客户端在加锁时,可以设置一个只有自己知道的「唯一标识」进去。
这个唯一标识,可以是自己的线程ID,也可以是一个UUID(随机且唯一)
// 锁的VALUE设置为UUID
127.0.0.1:6379> SET lock $uuid EX 20 NX
OK
在释放锁时,要先判断这把锁是否还归自己持有,伪代码可以这么写:
// 锁是自己的,才释放
if redis.get("lock") == $uuid:
redis.del("lock")
这里释放锁使用的是 GET + DEL 两条命令,可能会存在原子性问题:
所以,这两个命令还是必须要原子执行才行。
使用 Lua 脚本解决原子性问题
我们可以把这个逻辑,写成 Lua 脚本,让 Redis 来执行。
因为 Redis 处理每一个请求都是「单线程」执行的,在执行一个 Lua 脚本时,其它请求必须等待,直到这个 Lua 脚本处理完成,这样一来,GET + DEL 之间就不会插入其它命令了。
安全释放锁的 Lua 脚本如下:
// 判断锁是自己的,才释放
if redis.call("GET",KEYS[1]) == ARGV[1]
then
return redis.call("DEL",KEYS[1])
else
return 0
end
小结一下,基于 Redis 实现的分布式锁,一个严谨的的流程如下:
SET lock_key $unique_id EX $expire_time NX
加锁时,先设置一个过期时间,然后我们开启一个「守护线程」,定时去检测这个锁的失效时间,如果锁快要过期了,操作共享资源还未完成,那么就自动对锁进行「续期」,重新设置过期时间。
Java 的
Redisson库已经封装了上面的解决方案
Redisson 是一个 Java 语言实现的 Redis SDK 客户端,在使用分布式锁时,它就采用了「自动续期」的方案来避免锁过期,这个守护线程我们一般把它叫做「看门狗」线程。
小结一下,基于 Redis 实现的分布式锁,对应的解决方案:
之前分析的场景都是,锁在「单个」Redis 实例中可能产生的问题,并没有涉及到 Redis 的部署架构细节。
而我们在使用 Redis 时,一般会采用 主从集群 + 哨兵 的模式部署,这样做的好处是,当主库发生异常宕机时,哨兵可以实现「故障自动切换」,把从库提升为主库,继续提供服务,以此保证高可用性。
当「主从发生切换」时,设想这样一种场景:
SET 命令,加锁成功SET 命令还未同步到从库上(主从复制是异步的)
可见,当引入 Redis 副本后,分布式锁还是可能会受到影响。
针对上面的问题,Redis 作者提出了一种解决方案:Redlock(红锁)。
Redlock 的方案基于 2 个前提:
也就是说,想要使用 Redlock,至少需要部署 5 个 Redis 实例,而且都是主库,它们之间没有任何关系,都是一个个孤立的实例。
注意:不是部署 Redis Cluster,而是部署 5 个简单的 Redis 实例。
Redlock 整体流程,一共分为五步:
T2 - T1 < 锁的过期时间,此时认为客户端加锁成功,否则认为加锁失败总结一下重点:
为什么要在多个实例上加锁?
本质上是为了「容错」,部分实例异常宕机,剩下的实例加锁成功,整个锁服务依旧可用。
为什么大多数加锁成功,才算成功?
多个 Redis 实例一起使用,其实就组成了一个「分布式系统」。
在分布式系统中,总会出现「异常节点」,所以在讨论分布式系统问题时,需要考虑异常节点达到多少个,也依旧不会影响整个系统的「正确性」。
这是一个分布式系统「容错」问题,这个问题的结论是:如果只存在「故障」节点,只要大多数节点正常,那么整个系统依旧是可以提供正常服务的。
为什么步骤 3 加锁成功后,还要计算加锁的累计耗时?
因为操作的是多个 Redis 节点,所以耗时肯定会比操作单个实例耗时更久,而且因为是网络请求,网络情况是复杂的,有可能存在延迟、丢包、超时等情况发生,网络请求越多,异常发生的概率就越大。
所以,即使大多数节点加锁成功,但如果加锁的累计耗时已经「超过」了锁的过期时间,那么此时有些实例上的锁可能已经失效了,这个锁就没有意义了。
为什么释放锁,要操作所有节点?
在某一个 Redis 节点加锁时,可能因为「网络原因」导致加锁失败。
例如,客户端在一个 Redis 实例上加锁成功,但在读取响应结果时,可能因为「网络问题」导致读取失败,那这把锁其实已经在 Redis 上加锁成功了。
所以在释放锁时,不管之前有没有加锁成功,都需要释放「所有节点」的锁,以保证清理某些节点上「残留」的锁。