redis核心技术

因为redis与mysql经常搭配使用,也是面试常问,这里记录一些常见基础题.

数据类型与单线程模型

常用数据类型实现以及使用场景

img

String

实现:简单动态字符串

Redis 的字符串是二进制安全的,这意味着它们可以存储任何类型的数据,例如文本、图片或序列化的对象。

  • 短字符串: 当字符串较短时(小于 44 字节),Redis 会使用 embstr 编码,将 redisObject 结构和字符串本身一起分配在一块连续的内存区域,从而减少内存碎片和提高存取效率。
  • 长字符串: 当字符串较长时,会使用 raw 编码,redisObject 指向一个单独分配的 SDS (Simple Dynamic String) 结构。SDS 不仅存储字符串内容,还包含长度信息和可用空间,使得字符串操作(如追加)更高效,并避免 C 语言字符串常见的缓冲区溢出问题。
  • 如果存储的是整数值,并且大小在LONG_MAX范围则会采用INT编码,直接保存在redisObject的ptr位置,不再需要SDS

使用场景:

  • 缓存: 最常见的用途,存储用户会话、商品信息、HTML 片段等。
  • 计数器: 使用 INCRDECR 命令实现网站访问量、点赞数、商品库存等。
  • 分布式锁: 利用 SETNX (SET if Not eXists) 命令实现分布式锁。
  • 限流: 结合过期时间对某个操作进行限流。

List

Redis 列表是有序的字符串列表,底层实现随着版本和元素数量、大小的变化而变化,目的是优化内存和性能。

  • ziplist (压缩列表): 当列表元素较少且每个元素较小时,Redis 会使用 ziplist 编码。ziplist 是一块连续的内存,元素紧凑存储,内存效率很高。
  • quicklist (快速列表): Redis 3.2 之后,quicklist 成为了列表的主要底层实现。quicklist 是一个由多个 ziplist 组成的双向链表。每个 quicklist 节点内部是一个 ziplist,存储少量元素,这样既利用了 ziplist 的内存效率,又保留了链表的快速增删和两端操作的特性。
  • linkedlist (双向链表): 在很旧的版本中,当元素较多或较大时,会使用 linkedlist。但现在 quicklist 已经取代了它的地位。

使用场景:

  • 消息队列: LPUSH (左边入队) 和 RPOP (右边出队) 实现先进先出 (FIFO) 队列。
  • 任务队列: BRPOP (阻塞右边出队) 实现消费者阻塞等待任务。
  • 最新消息/动态: LPUSH 添加新消息,LRANGE 获取最新消息列表(如微博时间线、文章评论)。
  • 栈: LPUSH (入栈) 和 LPOP (出栈) 实现后进先出 (LIFO) 栈。

Hash

Redis 哈希表用于存储字段-值对的集合,类似于 Java 的 HashMap 或 Python 的字典。

  • ziplist (压缩列表): 当哈希表的字段数量较少且字段值较小时,Redis 会使用 ziplist 编码。
  • hashtable (哈希表): 当哈希表的字段数量或字段值较大时,Redis 会使用 hashtable 编码,这是一个真正的哈希表,基于数组和链表实现,支持动态扩容和缩容。

使用场景:

  • 存储对象: 存储用户资料、商品信息等,可以将一个对象的所有字段存储为一个哈希表的字段。
  • 购物车: 用户 ID 作为键,商品 ID 和数量作为哈希表的字段和值。
  • 计数器: 存储多个相关联的计数器,例如文章的点赞数、评论数和阅读数。

Set

Redis 集合是无序的、不重复的字符串集合

  • intset (整数集合): 当集合中只包含整数值,且元素数量较少时,Redis 会使用 intset 编码,它将整数有序地存储在一块连续的内存区域中,内存效率高。
  • hashtable (哈希表): 当集合中包含非整数值,或者元素数量较多时,Redis 会使用 hashtable 编码。哈希表的键用于存储集合元素,值则被设置为 NULL,表示只有键有意义。

使用场景:

  • 标签系统: 存储文章的标签、用户的兴趣爱好。
  • 共同好友/关注: 使用 SINTER 命令计算共同好友。
  • 随机抽取: SRANDMEMBER 随机抽取集合中的元素(如抽奖)。
  • 判断某个元素是否存在: SISMEMBER 复杂度为 O(1)。
  • 去重: 自动去重。

ZSet

Redis 有序集合是字符串集合,每个成员都会关联一个分数 (score)。集合中的成员是唯一的,但分数可以重复。元素总是按照分数进行升序排列,如果分数相同,则按字典序排列。

  • ziplist (压缩列表): 当有序集合的成员数量较少且成员和分数较小时,Redis 会使用 ziplist 编码。
  • skiplist (跳跃表) 和 hashtable (哈希表): 当有序集合的成员数量或大小超出 ziplist 限制时,Redis 会同时使用 skiplisthashtable
    • skiplist 用于按分数范围或成员排名快速查找,高效支持范围查询和排名操作。
    • hashtable 用于存储成员到分数的映射,实现 O(1) 复杂度的按成员查找分数。

使用场景:

  • 排行榜: 游戏积分榜、商品销量榜、点赞数排行榜等。
  • 带权重的任务队列: 优先级队列,分数代表任务优先级或执行时间。
  • 范围查询: 获取分数在某个范围内的成员列表。
  • 按距离排序: 例如地理位置附近的人(结合地理哈希)。

使用的底层数据结构

简单动态字符串

编码类型:RAW,EMBSTR,INT

相比于普通c语言字符数组优点:

不仅可以保存文本数据还可以保存二进制数据. 使用len属性表示长度,不以’\0’结尾,二进制安全

获取字符串长度O(1),拼接字符串等操作不会造成内存溢出,因为会进行预先分配空间.

基本编码方式是RAW,存储上限512MB,如果存储的SDS长度小于44字节,则会采用EMBSTR编码,内存变为连续的(而不是通过redisObject的ptr指向). 原因是redis底层分配内存以2^n使得redisObject小于64字节,减少了内存分配.

如果存储的是整数值,并且大小在LONG_MAX范围则会采用INT编码,直接保存在redisObject的ptr位置,不再需要SDS.

img

intset

intset中元素唯一有序,具备类型升级机制,节省内存空间,底层采用二分查找

intset包含编码类型,元素个数与整数数组. 编码类型指定包含元素的类型

img

ziplist

特殊的双端链表,由一系列特殊编码的连续内存块组成. 可以在任意一端进行压入/弹出操作O(1)

img

ntry每个节点也包含三个结构

img

包含上一个节点长度,编码属性,实际数据

编码属性记录了content的数据类型以及该entry内容的总长度.

如果是字符串

img

img

ziplist连锁更新问题,注意到其中每个节点有个previous_entry_length包含前一个节点占用字节数,而且是动态变化的,也就是说如果一个节点存储数据增加,导致占用字节数增加,当达到previous_entry_length一个阈值时导致后续节点字节数也增加,又恰好导致后续的节点占用字节数增加…

img

listpack引入为 Redis 设计的紧凑型(compact)数据结构,用于高效地存储小型的列表(List)、哈希(Hash)、有序集合(Sorted Set)和集合(Set)的元素。它的主要目标是减少内存开销,特别是当这些数据结构包含少量、小尺寸的元素时

quicklist

dict

dict包括字典,哈希表以及哈希节点.

img

dict的渐进式rehash

img

img

Dict内存不连续,使用指针过多,造成内存浪费

skiplist

ziplist与quicklist都需要不断遍历查询,跳表目的是提高查询效率

其元素按序排列存储,节点可能包含多个指针,指针跨度不同.

相当于分层的指针,一级指针包含所有元素,从最高级出发,如果没找到就跳到下一级

img

img

img

img

listpack

在 Redis 5.0 中,listpack 是一种全新的、更高效的编码方式,它被设计用来完全取代 ziplist,成为 Hash、List 和 Sorted Set 等数据结构在元素数量较少且元素值较小时的底层优化存储。

ziplist 存在一个著名的缺陷叫做连锁更新 (Cascade Update)

  • ziplist 中的每个元素在存储时,除了自身内容,还会存储前一个元素的长度
  • 如果前一个元素的长度发生变化,导致其存储的“前一个元素的长度”字段本身的编码长度也需要变化,就会引起一系列的连锁反应,导致后面所有元素的“前一个元素长度”字段都需要更新,可能带来 O(N) 的性能开销。
  • 尽管 Redis 会尽量避免这种情况,但在特定场景下(例如大量小元素紧密排列,然后头部元素长度变化),连锁更新依然可能发生,影响性能。

为了解决 ziplist 的这个根本性问题,Redis 5.0 引入了 listpack

listpack 的设计目标是:保持 ziplist 的紧凑内存布局,同时彻底避免连锁更新问题。

  1. 连续内存块: 类似于 ziplistlistpack 也是将所有元素存储在一块连续的内存区域中,没有指针开销,内存效率极高。
  2. 独立的元素编码: 这是 listpackziplist 最根本的区别。
    • listpack 中,每个元素不再存储前一个元素的长度。
    • 每个元素在编码时,会先记录自身内容的长度,然后才是实际内容。这意味着每个元素都是独立编码的,修改一个元素的长度不会影响到它前后的元素。
  3. 支持向前和向后遍历: 虽然不存储前一个元素的长度,但 listpack 仍然支持双向遍历。
    • 每个元素在编码自身长度时,会使用一种特殊的编码方式,使得可以从当前位置快速地计算出下一个元素的起始位置,以及从当前位置快速地找到前一个元素的起始位置。这通过在元素的末尾额外存储一个小的“反向跳跃”信息来实现(通常是元素总长度)。
  4. 变长编码: 元素内容和长度信息都采用变长编码,根据实际大小动态调整,进一步节省空间。

Listpack 的优缺点

优点:

  • 彻底解决连锁更新问题: 这是 listpack 最核心的优势。因为它移除了对前一个元素长度的依赖,修改一个元素不会引起连锁反应,从而保证了 O(1) 的插入和删除复杂度(在不考虑内存重新分配和数据移动的情况下)。
  • 内存效率高: 依然保持了紧凑的内存布局,没有指针开销,与 ziplist 相似。
  • 支持双向遍历: 虽然没有前驱长度信息,但巧妙的编码方式使其依然支持从两端高效地遍历。
  • 实现和维护相对简单: 相较于 ziplist 处理连锁更新的复杂逻辑,listpack 的内部逻辑更清晰。

缺点:

  • 插入和删除的内存移动: 尽管避免了连锁更新,但由于是连续内存,在中间位置进行插入或删除操作时,仍然需要对后续元素进行内存拷贝和移动,最坏情况下是 O(N) 复杂度。因此,listpack 仍然不适合存储大量元素或频繁在中间位置变动的场景。

BitMap

Bitmap,即位图,是一串连续的二进制数组(0和1),可以通过偏移量(offset)定位元素。BitMap通过最小的单位bit来进行的设置,表示某个元素的值或者状态,时间复杂度为O(1)。

Bitmap 本身是用 String 类型作为底层数据结构实现的一种统计二值状态的数据类型。

Bitmap 实际上就是普通的 Redis 字符串。一个字节有 8 位,所以 Redis 会将你设置的位映射到字符串的相应字节和位上。例如,设置第 0 位会影响第一个字节的第一个位,设置第 8 位会影响第二个字节的第一个位。

Hyperloglog

HyperLogLog 是一种用于基数估算(即统计一个集合中不重复元素的数量,例如独立访客 UV)的概率型数据结构。它不是精确计数,而是以极小的内存开销(Redis 中每个 HyperLogLog 键固定占用 12KB 内存)来估算海量数据的基数,标准误差通常在 0.81% 左右。

底层实现: HyperLogLog 基于 LogLog 算法的改进版,通过对输入元素进行哈希处理,并观察哈希值中前导零的数量来估算基数。它内部维护一个稀疏或密集表示的寄存器数组。

geo

Geo 是 Redis 3.2 引入的数据结构,用于存储地理空间信息(经度、纬度),并能够执行基于距离的查询。它实际上是有序集合 (Sorted Set) 的一个特化,利用 GeoHash 算法将二维的经纬度数据转换为一维的字符串,并存储在 Sorted Set 中。

stream

消息队列,相比于基于list实现的消息队列,支持:生成全局唯一id以及以消费者组形式消费数据.

Stream 是 Redis 5.0 引入的全新数据结构,它是一个只追加的(append-only)\数据结构,主要用于实现*消息队列事件日志时间序列数据存储*。它支持多消费者组模式,能够持久化消息,并允许消费者从指定位置开始读取。

常用操作

bitmaps

BITFILED key GET u[dayOfMonth] 0

获得这个月截止到今天的签到情况

BITCOUNT key start end

获取从start到end范围为1的值

stream消息队列

XADD key id field value

XREAD

XGROUP CREATE key groupname ID

XREADGROUP GROUP groupname

XACK key groupname ID 确认处理

XPENDING 查看待处理的(未ACK)的消息

单线程模型

Redis 的“单线程”指的是其核心命令执行引擎是单线程的

Redis 为什么选择单线程?

在多线程并发编程中,为了保证数据的一致性,通常需要引入锁(互斥锁、读写锁等)来同步对共享资源的访问。锁会带来以下问题:

  • 性能开销: 锁的获取和释放会消耗 CPU 资源,并且可能导致上下文切换。
  • 死锁和活锁: 多线程编程中常见的复杂并发问题。
  • 代码复杂性: 编写和维护正确的并发代码非常困难,容易出错。

Redis 的作者认为,CPU 并不是 Redis 的主要瓶颈。对于内存数据库来说,瓶颈通常在于:

  • 内存访问: Redis 是内存数据库,数据操作主要在内存中进行,速度非常快。
  • 网络 I/O: 客户端与 Redis 服务器之间的网络传输。

因此,如果能避免多线程带来的复杂性和开销,而将主要精力放在优化内存操作和网络 I/O 上,反而能实现更高的性能和更简洁的设计。

Redis 的单线程模型主要基于 I/O 多路复用 (I/O Multiplexing) 技术,例如 Linux 上的 epoll、macOS 上的 kqueue 等。

其工作流程可以概括为:

  1. I/O 多路复用器: Redis 的主线程使用 I/O 多路复用器来监听多个套接字(客户端连接)上的事件,例如连接建立、数据可读、数据可写等。
  2. 事件循环 (Event Loop): 主线程在一个无限循环中,不断地从 I/O 多路复用器中获取已经就绪的事件。
  3. 串行执行: 每当一个事件就绪时(例如某个客户端发送了命令),Redis 主线程会将其对应的命令从事件队列中取出,串行地执行该命令。
  4. 返回结果: 命令执行完成后,结果会被放入响应缓冲区,并等待网络 I/O 就绪后发送给客户端。

核心优势:

  • 无锁竞争: 因为所有命令都在一个线程中串行执行,Redis 内部的数据结构(如哈希表、跳跃表等)无需加锁,避免了锁带来的性能损耗和复杂性。
  • 简单高效: 避免了多线程并发控制的复杂性,使得代码更简洁,更容易维护和优化。
  • 高吞吐量: 内存操作速度快,I/O 多路复用使得单个线程能够同时处理大量并发连接,充分利用 CPU 的等待时间。

虽然核心命令执行是单线程的,但为了进一步提升性能和处理一些耗时的后台任务,Redis 引入了少量其他线程:

img

后台线程相当于一个消费者,生产者把耗时任务丢到任务队列中,消费者(BIO)不停轮询这个队列,拿出任务就去执行对应的方法即可。

关闭文件、AOF 刷盘、释放内存这三个任务都有各自的任务队列:

  • BIO_CLOSE_FILE,关闭文件任务队列:当队列有任务后,后台线程会调用 close(fd) ,将文件关闭;
  • BIO_AOF_FSYNC,AOF刷盘任务队列:当 AOF 日志配置成 everysec 选项后,主线程会把 AOF 写日志操作封装成一个任务,也放到队列中。当发现队列有任务后,后台线程会调用 fsync(fd),将 AOF 文件刷盘.
  • BIO_LAZY_FREE,lazy free 任务队列:当队列有任务后,后台线程会 free(obj) 释放对象 / free(dict) 删除数据库所有对象 / free(skiplist) 释放跳表对象

  • Redis 6.0 引入多线程 I/O (可选):

    • 这是 Redis 单线程模型在网络 I/O 层面的重大突破。
    • 在 Redis 6.0 之后,你可以选择开启多线程 I/O。这意味着在解析客户端请求数据向客户端回写响应数据这两个阶段,Redis 可以使用多个 I/O 线程并行处理。
    • 关键点: 即使开启了多线程 I/O,核心的命令执行(读写内存数据)仍然是单线程的。多线程 I/O 只是将网络数据的读取、协议解析以及响应的序列化、发送等任务并行化,从而减少了主线程在这些 I/O 上的耗时。
    • 这对于处理大量小请求的场景,可以显著提高吞吐量。

核心命令执行: 单线程,保证数据一致性,避免锁开销。

I/O 多路复用: 单线程也能高效处理大量并发连接。

后台任务: 部分耗时任务(如大键删除)或通过 fork 子进程(AOF 重写、RDB 持久化)或通过少量后台线程异步执行,避免阻塞主线程。

Redis 6.0+ 的网络 I/O: 可以选择性地开启多线程,用于网络数据的读写和协议解析,但命令执行仍然单线程。

缓存

缓存更新策略

常见的缓存更新策略共有3种:

  • Cache Aside(旁路缓存)策略;
  • Read/Write Through(读穿 / 写穿)策略;
  • Write Back(写回)策略;

Cache Aside(旁路缓存)策略是最常用的,应用程序直接与「数据库、缓存」交互,并负责对缓存的维护,该策略又可以细分为「读策略」和「写策略」。

写策略的步骤:

  • 先更新数据库中的数据,再删除缓存中的数据。

读策略的步骤:

  • 如果读取的数据命中了缓存,则直接返回数据;
  • 如果读取的数据没有命中缓存,则从数据库中读取数据,然后将数据写入到缓存,并且返回给用户。

注意,写策略的步骤的顺序不能倒过来,即不能先删除缓存再更新数据库,原因是在「读+写」并发的时候,会出现缓存和数据库的数据不一致性的问题。

Read/Write Through(读穿 / 写穿)策略

Read/Write Through(读穿 / 写穿)策略原则是应用程序只和缓存交互,不再和数据库交互,而是由缓存和数据库交互,相当于更新数据库的操作由缓存自己代理了。

1、Read Through 策略

先查询缓存中数据是否存在,如果存在则直接返回,如果不存在,则由缓存组件负责从数据库查询数据,并将结果写入到缓存组件,最后缓存组件将数据返回给应用。

2、Write Through 策略

当有数据更新的时候,先查询要写入的数据在缓存中是否已经存在:

  • 如果缓存中数据已经存在,则更新缓存中的数据,并且由缓存组件同步更新到数据库中,然后缓存组件告知应用程序更新完成
  • 如果缓存中数据不存在,直接更新数据库,然后返回;

Read Through/Write Through 策略的特点是由缓存节点而非应用程序来和数据库打交道,在我们开发过程中相比 Cache Aside 策略要少见一些,原因是我们经常使用的分布式缓存组件,无论是 Memcached 还是 Redis 都不提供写入数据库和自动加载数据库中的数据的功能。而在使用本地缓存的时候可以考虑使用这种策略。

Write Back(写回)策略

Write Back(写回)策略在更新数据的时候,只更新缓存,同时将缓存数据设置为脏的,然后立马返回,并不会更新数据库。对于数据库的更新,会通过批量异步更新的方式进行。

实际上,Write Back(写回)策略也不能应用到我们常用的数据库和缓存的场景中,因为 Redis 并没有异步更新数据库的功能。

Write Back 是计算机体系结构中的设计,比如 CPU 的缓存、操作系统中文件系统的缓存都采用了 Write Back(写回)策略。

Write Back 策略特别适合写多的场景,因为发生写操作的时候, 只需要更新缓存,就立马返回了。比如,写文件的时候,实际上是写入到文件系统的缓存就返回了,并不会写磁盘。

但是带来的问题是,数据不是强一致性的,而且会有数据丢失的风险,因为缓存一般使用内存,而内存是非持久化的,所以一旦缓存机器掉电,就会造成原本缓存中的脏数据丢失。所以你会发现系统在掉电之后,之前写入的文件会有部分丢失,就是因为 Page Cache 还没有来得及刷盘造成的。

缓存雪崩、穿透、击穿

缓存穿透

当用户访问的数据,既不在缓存中,也不在数据库中,导致请求在访问缓存时,发现缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据,没办法构建缓存数据,来服务后续的请求。那么当有大量这样的请求到来时,数据库的压力骤增,这就是缓存穿透的问题。

应对缓存穿透的方案,常见的方案有三种。

  • 非法请求的限制:当有大量恶意请求访问不存在的数据的时候,也会发生缓存穿透,因此在 API 入口处我们要判断求请求参数是否合理,请求参数是否含有非法值、请求字段是否存在,如果判断出是恶意请求就直接返回错误,避免进一步访问缓存和数据库。
  • 设置空值或者默认值:当我们线上业务发现缓存穿透的现象时,可以针对查询的数据,在缓存中设置一个空值或者默认值,这样后续请求就可以从缓存中读取到空值或者默认值,返回给应用,而不会继续查询数据库。
  • 使用布隆过滤器快速判断数据是否存在,避免通过查询数据库来判断数据是否存在:我们可以在写入数据库数据时,使用布隆过滤器做个标记,然后在用户请求到来时,业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在,就不用通过查询数据库来判断数据是否存在,即使发生了缓存穿透,大量请求只会查询 Redis 和布隆过滤器,而不会查询数据库,保证了数据库能正常运行,Redis 自身也是支持布隆过滤器的。

缓存击穿

如果缓存中的某个热点数据过期了,此时大量的请求访问了该热点数据,就无法从缓存中读取,直接访问数据库,数据库很容易就被高并发的请求冲垮,这就是缓存击穿的问题。

应对缓存击穿可以采取前面说到两种方案:

  • 互斥锁方案(Redis 中使用 setNX 方法设置一个状态位,表示这是一种锁定状态),保证同一时间只有一个业务线程请求缓存,未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。
  • 不给热点数据设置过期时间,由后台异步更新缓存,或者在热点数据准备要过期前,提前通知后台线程更新缓存以及重新设置过期时间;

缓存雪崩

大量缓存数据在同一时间过期(失效)或者 Redis 故障宕机时,如果此时有大量的用户请求,都无法在 Redis 中处理,于是全部请求都直接访问数据库,从而导致数据库的压力骤增,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃,这就是缓存雪崩的问题。

可以看到,发生缓存雪崩有两个原因:

  • 大量数据同时过期;
  • Redis 故障宕机;

不同的诱因,应对的策略也会不同。

大量数据同时过期

针对大量数据同时过期而引发的缓存雪崩问题,常见的应对方法有下面这几种:

  • 均匀设置过期时间;
  • 互斥锁;
  • 后台更新缓存;
  1. 均匀设置过期时间

如果要给缓存数据设置过期时间,应该避免将大量的数据设置成同一个过期时间。我们可以在对缓存数据设置过期时间时,给这些数据的过期时间加上一个随机数,这样就保证数据不会在同一时间过期。

  1. 互斥锁

当业务线程在处理用户请求时,如果发现访问的数据不在 Redis 里,就加个互斥锁,保证同一时间内只有一个请求来构建缓存(从数据库读取数据,再将数据更新到 Redis 里),当缓存构建完成后,再释放锁。未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。

实现互斥锁的时候,最好设置超时时间,不然第一个请求拿到了锁,然后这个请求发生了某种意外而一直阻塞,一直不释放锁,这时其他请求也一直拿不到锁,整个系统就会出现无响应的现象。

  1. 后台更新缓存

业务线程不再负责更新缓存,缓存也不设置有效期,而是让缓存“永久有效”,并将更新缓存的工作交由后台线程定时更新

事实上,缓存数据不设置有效期,并不是意味着数据一直能在内存里,因为当系统内存紧张的时候,有些缓存数据会被“淘汰”,而在缓存被“淘汰”到下一次后台定时更新缓存的这段时间内,业务线程读取缓存失败就返回空值,业务的视角就以为是数据丢失了。

解决上面的问题的方式有两种。

第一种方式,后台线程不仅负责定时更新缓存,而且也负责频繁地检测缓存是否有效,检测到缓存失效了,原因可能是系统紧张而被淘汰的,于是就要马上从数据库读取数据,并更新到缓存。

这种方式的检测时间间隔不能太长,太长也导致用户获取的数据是一个空值而不是真正的数据,所以检测的间隔最好是毫秒级的,但是总归是有个间隔时间,用户体验一般。

第二种方式,在业务线程发现缓存数据失效后(缓存数据被淘汰),通过消息队列发送一条消息通知后台线程更新缓存,后台线程收到消息后,在更新缓存前可以判断缓存是否存在,存在就不执行更新缓存操作;不存在就读取数据库数据,并将数据加载到缓存。这种方式相比第一种方式缓存的更新会更及时,用户体验也比较好。

在业务刚上线的时候,我们最好提前把数据缓起来,而不是等待用户访问才来触发缓存构建,这就是所谓的缓存预热,后台更新缓存的机制刚好也适合干这个事情。

故障宕机

针对 Redis 故障宕机而引发的缓存雪崩问题,常见的应对方法有下面这几种:

  • 服务熔断或请求限流机制;
  • 构建 Redis 缓存高可靠集群;
  1. 服务熔断或请求限流机制

因为 Redis 故障宕机而导致缓存雪崩问题时,我们可以启动服务熔断机制,暂停业务应用对缓存服务的访问,直接返回错误,不用再继续访问数据库,从而降低对数据库的访问压力,保证数据库系统的正常运行,然后等到 Redis 恢复正常后,再允许业务应用访问缓存服务。

服务熔断机制是保护数据库的正常允许,但是暂停了业务应用访问缓存服系统,全部业务都无法正常工作

为了减少对业务的影响,我们可以启用请求限流机制,只将少部分请求发送到数据库进行处理,再多的请求就在入口直接拒绝服务,等到 Redis 恢复正常并把缓存预热完后,再解除请求限流的机制。

  1. 构建 Redis 缓存高可靠集群

服务熔断或请求限流机制是缓存雪崩发生后的应对方案,我们最好通过主从节点的方式构建 Redis 缓存高可靠集群

如果 Redis 缓存的主节点故障宕机,从节点可以切换成为主节点,继续提供缓存服务,避免了由于 Redis 故障宕机而导致的缓存雪崩问题。

数据库与缓存如何保证一致性

由于引入了缓存,那么在数据更新时,不仅要更新数据库,而且要更新缓存,这两个更新操作存在前后的问题

删除缓存还是更新缓存

如果是更新缓存,不管是先更新数据库还是先更新缓存都可能存在并发问题导致后执行操作的缓存被覆盖

图片

使用旁路缓存策略,写策略的步骤:

  • 更新数据库中的数据;
  • 删除缓存中的数据。

读策略的步骤:

  • 如果读取的数据命中了缓存,则直接返回数据;
  • 如果读取的数据没有命中缓存,则从数据库中读取数据,然后将数据写入到缓存,并且返回给用户。

先更新数据库还是先删除缓存

如果是先删除缓存,当一个写请求到来,删除缓存后并更新数据库,若还没更新数据时另一个读请求读到了空缓存然后读取数据库内容并写入缓存,之后写请求才更新数据库.

图片

先删除缓存,再更新数据库,在「读 + 写」并发的时候,还是会出现缓存和数据库的数据不一致的问题

如果是先更新数据库再删除缓存,也可能出现问题. 例如一个写请求到来,而此时另一个读请求读到了空缓存然后读取数据库内容,这时写请求更新数据库并删除缓存,然后读请求更新缓存.

图片

先更新数据库,再删除缓存也是会出现数据不一致性的问题,但是在实际中,这个问题出现的概率并不高

因为缓存的写入通常要远远快于数据库的写入,所以在实际中很难出现请求 B 已经更新了数据库并且删除了缓存,请求 A 才更新完缓存的情况。

而一旦请求 A 早于请求 B 删除缓存之前更新了缓存,那么接下来的请求就会因为缓存不命中而从数据库中重新读取数据,所以不会出现这种不一致的情况。

所以,「先更新数据库 + 再删除缓存」的方案,是可以保证数据一致性的

但是仍然可能存在问题,可以采用两种做法:

  • 在更新缓存前先加个分布式锁,保证同一时间只运行一个请求更新缓存,就会不会产生并发问题了,当然引入了锁后,对于写入的性能就会带来影响。
  • 在更新完缓存时,给缓存加上较短的过期时间,这样即时出现缓存不一致的情况,缓存的数据也会很快过期,对业务还是能接受的。

针对”先删除缓存,再更新数据库”方案在「读 + 写」并发请求而造成缓存不一致的解决办法是「延迟双删

延迟双删实现的伪代码如下:

1
2
3
4
5
6
7
8
#删除缓存
redis.delKey(X)
#更新数据库
db.update(X)
#睡眠
Thread.sleep(N)
#再删除缓存
redis.delKey(X)

加了个睡眠时间,主要是为了确保请求 A 在睡眠的时候,请求 B 能够在这这一段时间完成「从数据库读取数据,再把缺失的缓存写入缓存」的操作,然后请求 A 睡眠完,再删除缓存。

所以,请求 A 的睡眠时间就需要大于请求 B 「从数据库读取数据 + 写入缓存」的时间。

但是具体睡眠多久其实是个玄学,很难评估出来,所以这个方案也只是尽可能保证一致性而已,极端情况下,依然也会出现缓存不一致的现象。

因此,还是比较建议用「先更新数据库,再删除缓存」的方案。

如何保证先更新数据库 ,再删除缓存这两个操作能执行成功

“先更新数据库, 再删除缓存”其实是两个操作,问题在于,在删除缓存(第二个操作)的时候失败了,导致缓存中的数据是旧值,而数据库是最新值

有两种方法:

  • 消息队列重试机制。
  • 订阅 MySQL binlog,再操作缓存。

可以引入消息队列,将第二个操作(删除缓存)要操作的数据加入到消息队列,由消费者来操作数据。

  • 如果应用删除缓存失败,可以从消息队列中重新读取数据,然后再次删除缓存,这个就是重试机制。当然,如果重试超过的一定次数,还是没有成功,我们就需要向业务层发送报错信息了。
  • 如果删除缓存成功,就要把数据从消息队列中移除,避免重复操作,否则就继续重试。

订阅 MySQL binlog,再删除缓存

先更新数据库,再删缓存的策略的第一步是更新数据库,那么更新数据库成功,就会产生一条变更日志,记录在 binlog 里。

于是我们就可以通过订阅 binlog 日志,拿到具体要操作的数据,然后再执行缓存删除,阿里巴巴开源的 Canal 中间件就是基于这个实现的。

Canal 模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向 MySQL 主节点发送 dump 请求,MySQL 收到请求后,就会开始推送 Binlog 给 Canal,Canal 解析 Binlog 字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用。

将binlog日志采集发送到MQ队列里面,然后编写一个简单的缓存删除消息者订阅binlog日志,根据更新log删除缓存,并且通过ACK机制确认处理这条更新log,保证数据缓存一致性.

必须是删除缓存成功,再回 ack 机制给消息队列,否则可能会造成消息丢失的问题,比如消费服务从消息队列拿到事件之后,直接回了 ack,然后再执行删除缓存操作的话,如果删除缓存的操作还是失败了,那么因为提前给消息队列回 ack了,就没办重试了。

所以,如果要想保证”先更新数据库,再删缓存”策略第二个操作能执行成功,我们可以使用:

  • 消息队列来重试缓存的删除,优点是保证缓存一致性的问题,缺点会对业务代码入侵
  • 订阅 MySQL binlog + 消息队列 + 重试缓存的删除,优点是规避了代码入侵问题,也很好的保证缓存一致性的问题,缺点就是引入的组件比较多,对团队的运维能力比较有高要求。

这两种方法有一个共同的特点,都是采用异步操作缓存

持久化机制

Redis 的读写操作都是在内存中,所以 Redis 性能才会高,但是当 Redis 重启后,内存中的数据就会丢失,那为了保证内存中的数据不会丢失,Redis 实现了数据持久化的机制,这个机制会把数据存储到磁盘,这样在 Redis 重启就能够从磁盘中恢复原有的数据。

Redis的三种持久化机制:RDB AOF以及混合持久化

RDB

将某一时刻的内存数据以二进制的方式写入磁盘.

Redis 的 RDB (Redis Database) 快照是一种二进制格式的紧凑存储,它记录了 Redis 在某个时间点上的全量数据

RDB 快照就是记录某一个瞬间的内存数据,记录的是实际数据,而 AOF 文件记录的是命令操作的日志,而不是实际的数据。因此在 Redis 恢复数据时, RDB 恢复数据的效率会比 AOF 高些,因为直接将 RDB 文件读入内存就可以,不需要像 AOF 那样还需要额外执行操作命令的步骤才能恢复数据.

RDB 快照的生成可以由以下几种方式触发:

  1. 手动触发:

    • SAVE 命令: 阻塞 Redis 主进程。在 RDB 文件生成期间,Redis 不会响应任何客户端请求。这在生产环境几乎不使用。
    • BGSAVE 命令: 非阻塞。Redis 会 fork 一个子进程来执行 RDB 文件生成任务。这是生产环境推荐的方式。
  2. 自动触发:

    • 通过配置 redis.conf 中的 save 规则。例如:
      • save 900 1:表示 900 秒内至少 1 个键被修改,则自动执行 BGSAVE
      • save 300 10:表示 300 秒内至少 10 个键被修改,则自动执行 BGSAVE
      • save 60 10000:表示 60 秒内至少 10000 个键被修改,则自动执行 BGSAVE
    • 每次自动触发时,都会执行一次 BGSAVE
    • 主从复制时,主节点向从节点同步数据也会触发 BGSAVE
    • 执行 SHUTDOWN 命令且配置了 RDB 持久化时,也会执行 SAVE

    Redis+的快照是全量快照,也就是说每次执行快照,都是把内存中的「所有数据」都记录到磁盘中。所以执行快照是一个比较重的操作,如果频率太频繁,可能会对+Redis+性能产生影响。如果频率太低,服务器故障时,丢失的数据会更多

其中重要的是BGSAVE命令,使用fork创建新进程利用os提供的写时复制(COW),

BGSAVE 命令的执行流程充分利用了操作系统的特性,以达到非阻塞持久化的目的:

  1. 客户端发送 BGSAVE 命令或自动触发条件满足。
  2. 主进程判断是否可以执行:
    • 如果当前已经有一个 BGSAVEBGREWRITEAOF 子进程正在运行,主进程会拒绝新的 BGSAVE 请求,以避免同时产生多个快照进程。
  3. 主进程 fork() 子进程:
    • Redis 主进程会调用操作系统提供的 fork() 系统调用,创建一个子进程
    • fork() 操作会复制父进程的页表,并创建一个与父进程几乎完全相同的子进程。这个子进程继承了父进程的所有内存副本、文件描述符等。
    • fork() 是唯一的可能导致主进程短暂阻塞的阶段。阻塞时间取决于服务器的 CPU 性能和 Redis 实例的内存大小。对于几十 GB 的实例,fork 阻塞通常在几十到几百毫秒。
  4. 写时复制 (Copy-on-Write, COW) 机制:
    • fork() 完成后,主进程和子进程会共享相同的物理内存页面。这些页面在此时被操作系统标记为只读
    • 子进程: 子进程会遍历它所“看到”的内存数据(即 fork 瞬间的内存快照),并将其以 RDB 格式写入到磁盘上的一个临时文件 (temp-XXXX.rdb)。子进程只负责读取这些共享的内存页面,它不会修改它们。
    • 主进程: 主进程继续处理客户端的请求。
      • 如果主进程执行读操作,它会直接访问这些共享的、未被修改的内存页面。
      • 如果主进程执行写操作(例如 SETDEL),当它尝试修改某个共享的内存页面时,操作系统会触发 COW 机制:
        • 操作系统会为这个即将被修改的内存页面创建一个私有的副本
        • 主进程的内存地址映射会被更新,使其指向这个新复制出来的页面。
        • 主进程的写操作会在这个新复制的页面上完成。
        • 子进程仍然读取原始的、未被修改的共享内存页面
  5. 子进程完成写入并通知主进程:
    • 子进程完成 RDB 文件的写入后,会向主进程发送一个信号。
    • 在子进程写入完成之前,即使子进程崩溃,也不会影响主进程的正常运行和数据。
  6. 主进程替换 RDB 文件:
    • 主进程收到子进程的成功信号后,会原子地用新生成的临时 RDB 文件替换掉旧的 RDB 文件(通常是重命名操作)。
    • 这个替换操作是极快的,不会造成服务阻塞

img

也就是利用写时复制技术实现了在子进程进行读取内存写入新RDB文件时,主线程能够修改数据.

执行 bgsave 命令的时候,会通过 fork() 创建子进程,此时子进程和父进程是共享同一片内存数据的,因为创建子进程的时候,会复制父进程的页表,但是页表指向的物理内存还是一个,此时如果主线程执行读操作,则主线程和 bgsave 子进程互相不影响。

如果主线程执行写操作,则被修改的数据会复制一份副本,然后 bgsave 子进程会把该副本数据写入 RDB 文件,在这个过程中,主线程仍然可以直接修改原来的数据。

优点:

  • 恢复速度快: RDB 文件是经过压缩的二进制格式,恢复时直接加载到内存即可,速度远快于 AOF 重放命令。
  • 文件紧凑: RDB 文件比 AOF 文件小得多,适合做备份和传输。
  • 更适合灾难恢复: 对数据的恢复点清晰。

缺点:

  • 数据丢失风险: 无法做到实时持久化。如果在两次 RDB 快照之间 Redis 发生崩溃,最后一次快照之后的所有数据都将丢失。丢失的数据量取决于 save 配置的间隔时间。
  • fork() 阻塞: BGSAVE 命令在 fork() 阶段会短暂阻塞主进程,对于内存非常大的实例,这个阻塞可能比较明显。
  • 频繁 fork() 的开销: 如果配置的 save 规则过于频繁,或者写操作过于集中,可能导致频繁的 fork() 操作,增加系统开销。

AOF

Redis 在执行完一条写操作命令后,就会把该命令以追加的方式写入到一个文件里,然后 Redis 重启时,会读取该文件记录的命令,然后逐一执行命令的方式来进行数据恢复。

Reids 是先执行写操作命令后,才将该命令记录到 AOF 日志里的,这么做其实有两个好处。

  • 避免额外的检查开销:因为如果先将写操作命令记录到 AOF 日志里,再执行该命令的话,如果当前的命令语法有问题,那么如果不进行命令语法检查,该错误的命令记录到 AOF 日志里后,Redis 在使用日志恢复数据时,就可能会出错。
  • 不会阻塞当前写操作命令的执行:因为当写操作命令执行成功后,才会将命令记录到 AOF 日志。

当然,这样做也会带来风险:

  • 数据可能会丢失: 执行写操作命令和记录日志是两个过程,那当 Redis 在还没来得及将命令写入到硬盘时,服务器发生宕机了,这个数据就会有丢失的风险。
  • 可能阻塞其他操作: 由于写操作命令执行成功后才记录到 AOF 日志,所以不会阻塞当前命令的执行,但因为 AOF 日志也是在主线程中执行,所以当 Redis 把日志文件写入磁盘的时候,还是会阻塞后续的操作无法执行。

AOF回写策略

img

  1. Redis 执行完写操作命令后,会将命令追加到 server.aof_buf 缓冲区;
  2. 然后通过 write() 系统调用,将 aof_buf 缓冲区的数据写入到 AOF 文件,此时数据并没有写入到硬盘,而是拷贝到了内核缓冲区 page cache,等待内核将数据写入硬盘;
  3. 具体内核缓冲区的数据什么时候写入到硬盘,由内核决定。

Redis 提供了 3 种写回硬盘的策略,控制的就是上面说的第三步的过程。 在 Redis.conf 配置文件中的 appendfsync 配置项可以有以下 3 种参数可填:

  • Always,这个单词的意思是「总是」,所以它的意思是每次写操作命令执行完后,同步将 AOF 日志数据写回硬盘; 每次有新的写命令追加到 AOF 缓冲区时,都会立即执行 fsync() 操作,将缓冲区中的所有数据同步到磁盘。
  • Everysec,这个单词的意思是「每秒」,所以它的意思是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后每隔一秒将缓冲区里的内容写回到硬盘;Redis 会将 AOF 缓冲区的数据写入操作系统内存缓冲区,然后启动一个后台线程每秒将这些数据同步到磁盘一次。
  • No,意味着不由 Redis 控制写回硬盘的时机,转交给操作系统控制写回的时机,也就是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘。Redis 只负责将 AOF 缓冲区的数据写入操作系统内存缓冲区,不主动进行 fsync() 操作。数据何时同步到磁盘完全由操作系统决定(通常是每 30 秒或当缓冲区满时)。

img

AOF日志过大会触发什么机制

AOF 日志是一个文件,随着执行的写操作命令越来越多,文件的大小会越来越大。 如果当 AOF 日志文件过大就会带来性能问题,比如重启 Redis 后,需要读 AOF 文件的内容以恢复数据,如果文件过大,整个恢复的过程就会很慢。

所以,Redis 为了避免 AOF 文件越写越大,提供了 AOF 重写机制,当 AOF 文件的大小超过所设定的阈值后,Redis 就会启用 AOF 重写机制,来压缩 AOF 文件。

AOF重写

AOF 重写机制是在重写时,读取当前数据库中的所有键值对,然后将每一个键值对用一条命令记录到「新的 AOF 文件」,等到全部记录完后,就将新的 AOF 文件替换掉现有的 AOF 文件。

img

Redis 的重写 AOF 过程是由子进程 *bgrewriteaof* 来完成的,这么做可以达到两个好处:

  • 子进程进行 AOF 重写期间,主进程可以继续处理命令请求,从而避免阻塞主进程;
  • 子进程带有主进程的数据副本,这里使用子进程而不是线程,因为如果是使用线程,多线程之间会共享内存,那么在修改共享内存数据的时候,需要通过加锁来保证数据的安全,而这样就会降低性能。而使用子进程,创建子进程时,父子进程是共享内存数据的,不过这个共享的内存只能以只读的方式,而当父子进程任意一方修改了该共享内存,就会发生「写时复制」,于是父子进程就有了独立的数据副本,就不用加锁来保证数据安全。

触发重写机制后,主进程就会创建重写 AOF 的子进程,此时父子进程共享物理内存,重写子进程只会对这个内存进行只读,重写 AOF 子进程会读取数据库里的所有数据,并逐一把内存数据的键值对转换成一条命令,再将命令记录到重写日志(新的 AOF 文件)。

但是重写过程中,主进程依然可以正常处理命令,那问题来了,重写 AOF 日志过程中,如果主进程修改了已经存在 key-value,那么会发生写时复制,此时这个 key-value 数据在子进程的内存数据就跟主进程的内存数据不一致了,这时要怎么办呢?

为了解决这种数据不一致问题,Redis 设置了一个 AOF 重写缓冲区,这个缓冲区在创建 bgrewriteaof 子进程之后开始使用。

在重写 AOF 期间,当 Redis 执行完一个写命令之后,它会同时将这个写命令写入到 AOF 缓冲区和 AOF 重写缓冲区

img

在 bgrewriteaof 子进程执行 AOF 重写期间,主进程需要执行以下三个工作:

  • 执行客户端发来的命令;
  • 将执行后的写命令追加到 「AOF 缓冲区」;这是为了确保即使 AOF 重写失败,旧的 AOF 文件仍然是完整且最新的,不会丢失任何数据。
  • 将执行后的写命令追加到 「AOF 重写缓冲区」;所有在重写期间新发生的写命令都会被缓存到这个独立的缓冲区中。

当子进程完成 AOF 重写工作(扫描数据库中所有数据,逐一把内存数据的键值对转换成一条命令,再将命令记录到重写日志)后,会向主进程发送一条信号,信号是进程间通讯的一种方式,且是异步的。

主进程收到该信号后,会调用一个信号处理函数,该函数主要做以下工作:

  • 将 AOF 重写缓冲区中的所有内容追加到新的 AOF 的文件中,使得新旧两个 AOF 文件所保存的数据库状态一致;
  • 新的 AOF 的文件进行改名,覆盖现有的 AOF 文件。

混合持久化

RDB 优点是数据恢复速度快,但是快照的频率不好把握。频率太低,丢失的数据就会比较多,频率太高,就会影响性能。

AOF 优点是丢失数据少,但是数据恢复不快。

为了集成了两者的优点, Redis 4.0 提出了混合使用 AOF 日志和内存快照,也叫混合持久化,既保证了 Redis 重启速度,又降低数据丢失风险。

混合持久化工作在 AOF 日志重写过程,当开启了混合持久化时,在 AOF 重写日志时,fork 出来的重写子进程会先将与主线程共享的内存数据以 RDB 方式写入到 AOF 文件,然后主线程处理的操作命令会被记录在重写缓冲区里,重写缓冲区里的增量命令会以 AOF 方式写入到 AOF 文件,写入完成后通知主进程将新的含有 RDB 格式和 AOF 格式的 AOF 文件替换旧的的 AOF 文件。

也就是说,使用了混合持久化,AOF 文件的前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据

重启 Redis 加载数据的时候,由于前半部分是 RDB 内容,这样加载的时候速度会很快

加载完 RDB 的内容后,才会加载后半部分的 AOF 内容,这里的内容是 Redis 后台子进程重写 AOF 期间,主线程处理的操作命令,可以使得数据更少的丢失

混合持久化优点:

  • 混合持久化结合了 RDB 和 AOF 持久化的优点,开头为 RDB 的格式,使得 Redis 可以更快的启动,同时结合 AOF 的优点,有减低了大量数据丢失的风险。

混合持久化缺点:

  • AOF 文件中添加了 RDB 格式的内容,使得 AOF 文件的可读性变得很差;
  • 兼容性差,如果开启混合持久化,那么此混合持久化 AOF 文件,就不能用在 Redis 4.0 之前版本了

大 Key 对持久化影响

对AOF日志影响

AOF有三种写回磁盘策略,分别是:

  • Always,这个单词的意思是「总是」,所以它的意思是每次写操作命令执行完后,同步将 AOF 日志数据写回硬盘;
  • Everysec,这个单词的意思是「每秒」,所以它的意思是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后每隔一秒将缓冲区里的内容写回到硬盘;
  • No,意味着不由 Redis 控制写回硬盘的时机,转交给操作系统控制写回的时机,也就是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘。

这三种策略只是在控制 fsync() 函数的调用时机。

当应用程序向文件写入数据时,内核通常先将数据复制到内核缓冲区中,然后排入队列,然后由内核决定何时写入硬盘。

如果想要应用程序向文件写入数据后,能立马将数据同步到硬盘,就可以调用 fsync() 函数,这样内核就会将内核缓冲区的数据直接写入到硬盘,等到硬盘写操作完成后,该函数才会返回。

  • Always 策略就是每次写入 AOF 文件数据后,就执行 fsync() 函数;
  • Everysec 策略就会创建一个异步任务来执行 fsync() 函数;
  • No 策略就是永不执行 fsync() 函数;

在使用 Always 策略的时候,主线程在执行完命令后,会把数据写入到 AOF 日志文件,然后会调用 fsync() 函数,将内核缓冲区的数据直接写入到硬盘,等到硬盘写操作完成后,该函数才会返回。

当使用 Always 策略的时候,如果写入是一个大 Key,主线程在执行 fsync() 函数的时候,阻塞的时间会比较久,因为当写入的数据量很大的时候,数据同步到硬盘这个过程是很耗时的

当使用 Everysec 策略的时候,由于是异步执行 fsync() 函数,所以大 Key 持久化的过程(数据同步磁盘)不会影响主线程。

当使用 No 策略的时候,由于永不执行 fsync() 函数,所以大 Key 持久化的过程不会影响主线程。

AOF重写和RDB影响

当 AOF 日志写入了很多的大 Key,AOF 日志文件的大小会很大,那么很快就会触发 AOF 重写机制

AOF 重写机制和 RDB 快照(bgsave 命令)的过程,都会分别通过 fork() 函数创建一个子进程来处理任务。

在创建子进程的过程中,操作系统会把父进程的「页表」复制一份给子进程,这个页表记录着虚拟地址和物理地址映射关系,而不会复制物理内存,也就是说,两者的虚拟空间不同,但其对应的物理空间是同一个。

子进程就共享了父进程的物理内存数据了,这样能够节约物理内存资源,页表对应的页表项的属性会标记该物理内存的权限为只读

随着 Redis 存在越来越多的大 Key,那么 Redis 就会占用很多内存,对应的页表就会越大。

在通过 fork() 函数创建子进程的时候,虽然不会复制父进程的物理内存,但是内核会把父进程的页表复制一份给子进程,如果页表很大,那么这个复制过程是会很耗时的,那么在执行 fork 函数的时候就会发生阻塞现象fork 函数是由 Redis 主线程调用的,如果 fork 函数发生阻塞,那么意味着就会阻塞 Redis 主线程。由于 Redis 执行命令是在主线程处理的,所以当 Redis 主线程发生阻塞,就无法处理后续客户端发来的命令。

当父进程或者子进程在向共享内存发起写操作时,CPU 就会触发写保护中断,这个「写保护中断」是由于违反权限导致的,然后操作系统会在「写保护中断处理函数」里进行物理内存的复制,并重新设置其内存映射关系,将父子进程的内存读写权限设置为可读写,最后才会对内存进行写操作,这个过程被称为「写时复制(Copy On Write)」。

写时复制顾名思义,在发生写操作的时候,操作系统才会去复制物理内存,这样是为了防止 fork 创建子进程时,由于物理内存数据的复制时间过长而导致父进程长时间阻塞的问题。

如果创建完子进程后,父进程对共享内存中的大 Key 进行了修改,那么内核就会发生写时复制,会把物理内存复制一份,由于大 Key 占用的物理内存是比较大的,那么在复制物理内存这一过程中,也是比较耗时的,于是父进程(主线程)就会发生阻塞

所以,有两个阶段会导致阻塞父进程:

  • 创建子进程的途中,由于要复制父进程的页表等数据结构,阻塞的时间跟页表的大小有关,页表越大,阻塞的时间也越长;
  • 创建完子进程后,如果子进程或者父进程修改了共享数据,就会发生写时复制,这期间会拷贝物理内存,如果内存越大,自然阻塞的时间也越长;

当 AOF 写回策略配置了 Always 策略,如果写入是一个大 Key,主线程在执行 fsync() 函数的时候,阻塞的时间会比较久,因为当写入的数据量很大的时候,数据同步到硬盘这个过程是很耗时的。

AOF 重写机制和 RDB 快照(bgsave 命令)的过程,都会分别通过 fork() 函数创建一个子进程来处理任务。会有两个阶段会导致阻塞父进程(主线程):

  • 创建子进程的途中,由于要复制父进程的页表等数据结构,阻塞的时间跟页表的大小有关,页表越大,阻塞的时间也越长;
  • 创建完子进程后,如果父进程修改了共享数据中的大 Key,就会发生写时复制,这期间会拷贝物理内存,由于大 Key 占用的物理内存会很大,那么在复制物理内存这一过程,就会比较耗时,所以有可能会阻塞父进程。

大 key 除了会影响持久化之外,还会有以下的影响:

  • 客户端超时阻塞。由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。
  • 引发网络阻塞。每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。
  • 阻塞工作线程。如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。
  • 内存分布不均。集群模型在 slot 分片均匀情况下,会出现数据和查询倾斜情况,部分有大 key 的 Redis 节点占用内存多。

如何避免大 Key 呢?

最好在设计阶段,就把大 key 拆分成一个一个小 key。或者,定时检查 Redis 是否存在大 key ,如果该大 key 是可以删除的,不要使用 DEL 命令删除,因为该命令删除过程会阻塞主线程,而是用 unlink 命令(Redis 4.0+)删除大 key,因为该命令的删除过程是异步的,不会阻塞主线程。

高可用

Redis集群

主从如何进行同步

哨兵的节点故障转移

cluster集群的哈希槽

脑裂产生以及解决

主从复制

主从复制是 Redis 高可用服务的最基础的保证,实现方案就是将从前的一台 Redis 服务器,同步数据到多台从 Redis 服务器上,即一主多从的模式,且主从服务器之间采用的是「读写分离」的方式。

img

主服务器可以进行读写操作,当发生写操作时自动将写操作同步给从服务器,而从服务器一般是只读,并接受主服务器同步过来写操作命令,然后执行这条命令。

具体来说,在主从服务器命令传播阶段,主服务器收到新的写命令后,会发送给从服务器。但是,主服务器并不会等到从服务器实际执行完命令后,再把结果返回给客户端,而是主服务器自己在本地执行完命令后,就会向客户端返回结果了。如果从服务器还没有执行主服务器同步过来的命令,主从服务器间的数据就不一致了。所以无法实现强一致性保证(主从数据时时刻刻保持一致),数据不一致是难以避免的。

哨兵模式

在使用 Redis 主从服务的时候,会有一个问题,就是当 Redis 的主从服务器出现故障宕机时,需要手动进行恢复。

兵模式做到了可以监控主从服务器,并且提供主从节点故障转移的功能。

img

cluster分片集群

当 Redis 缓存数据量大到一台服务器无法缓存时,就需要使用 Redis 切片集群(Redis Cluster )方案,它将数据分布在不同的服务器上,以此来降低系统对单主节点的依赖,从而提高 Redis 服务的读写性能。

使用哈希槽来处理数据和节点之间的映射,一个切片集群共有 16384 个哈希槽,这些哈希槽类似于数据分区,每个键值对都会根据它的 key,被映射到一个哈希槽中,具体执行过程分为两大步:

  • 根据键值对的 key,按照 CRC16计算一个 16 bit 的值。
  • 再用 16bit 值对 16384 取模,得到 0~16383 范围内的模数,每个模数代表一个相应编号的哈希槽。

这些哈希槽怎么被映射到具体的 Redis 节点上的呢?有两种方案:

  • 平均分配: 在使用 cluster create 命令创建 Redis 集群时,Redis 会自动把所有哈希槽平均分布到集群节点上。比如集群中有 9 个节点,则每个节点上槽的个数为 16384/9 个。
  • 手动分配: 可以使用 cluster meet 命令手动建立节点间的连接,组成集群,再使用 cluster addslots 命令,指定每个节点上的哈希槽个数。

当读取或设置key时的流程.在Redis cluster模式下,节点对请求的处理过程如下:

  • 通过哈希槽映射,检查当前Redis key是否存在当前节点
  • 若哈希槽不是由自身节点负责,就返回MOVED重定向
  • 若哈希槽确实由自身负责,且key在slot中,则返回该key对应结果
  • 若Redis key不存在此哈希槽中,检查该哈希槽是否正在迁出(MIGRATING)?
  • 若Redis key正在迁出,返回ASK错误重定向客户端到迁移的目的服务器上
  • 若哈希槽未迁出,检查哈希槽是否导入中?
  • 若哈希槽导入中且有ASKING标记,则直接操作,否则返回MOVED重定向

cluster集群管理重要命令

1
2
3
4
5
6
redis-cli --cluster create 127.0.0.1:7000 127.0.0.1:7001 127.0.0.1:7002 \
127.0.0.1:7003 127.0.0.1:7004 127.0.0.1:7005 \
--cluster-replicas 1
redis-cli --cluster check 127.0.0.1:7000
redis-cli --cluster reshard 127.0.0.1:7000
redis-cli --cluster rebalance 127.0.0.1:7000

CLUSTER命令是redis-cli —cluster分片集群管理的底层指令,在要移除一个节点时,

1
2
redis-cli --cluster reshard <any_existing_node_ip>:<any_existing_node_port>
redis-cli --cluster del-node <any_existing_node_ip>:<any_existing_node_port> <node_id_to_delete>

客户端给一个Redis实例发送数据读写操作时,如果这个实例上并没有相应的数据,会怎么样呢?

在Redis cluster模式下,节点对请求的处理过程如下:

  • 通过哈希槽映射,检查当前Redis key是否存在当前节点
  • 若哈希槽不是由自身节点负责,就返回MOVED重定向
  • 若哈希槽确实由自身负责,且key在slot中,则返回该key对应结果
  • 若Redis key不存在此哈希槽中,检查该哈希槽是否正在迁出(MIGRATING)?
  • 若Redis key正在迁出,返回ASK错误重定向客户端到迁移的目的服务器上
  • 若哈希槽未迁出,检查哈希槽是否导入中?
  • 若哈希槽导入中且有ASKING标记,则直接操作,否则返回MOVED重定向

MOVED重定向

其中MOVED重定向MOVED 重定向表示某个哈希槽(及其包含的数据)已经永久性地从当前节点迁移到了另一个节点。这意味着集群的拓扑结构已经发生了变化。

  • 哈希槽迁移完成: 当一个哈希槽的迁移过程(通过 CLUSTER SETSLOT <slot> NODE <node_id> 命令)完全结束,该槽位被正式分配给新的目标节点时。
  • 客户端请求不属于当前节点的键: 客户端向一个节点发送了对某个键的请求,但计算得出该键所属的哈希槽不属于当前节点,而是由集群中的另一个节点负责。这通常发生在客户端的哈希槽映射缓存过期或不准确时。

客户端给一个Redis实例发送数据读写操作时,如果计算出来的槽不是在该节点上,这时候它会返回MOVED重定向错误,MOVED重定向错误中,会将哈希槽所在的新实例的IP和port端口带回去。这就是Redis Cluster的MOVED重定向机制。

img

ASK重定向

Ask重定向一般发生于集群伸缩的时候。集群伸缩会导致槽迁移,当去源节点访问时,此时数据已经可能已经迁移到了目标节点,使用Ask重定向可以解决此种情况

ASK 重定向表示某个哈希槽正在进行迁移(resharding)操作,当前键可能已经迁移到目标节点,或者在源节点上不存在(新写入的键)。这是一个临时性的重定向,发生在哈希槽迁移过程中,当源节点处于 MIGRATING 状态,而目标节点处于 IMPORTING 状态时。

在哈希槽迁移(例如 reshard 命令执行期间)的过程中,当客户端向源节点MIGRATING 状态)发送对一个键的请求时:

  1. 如果该键在源节点上不存在(可能是新创建的键,或者该键已经迁移到了目标节点,但客户端仍然向源节点请求),源节点会返回 ASK <hash_slot> <target_node_ip>:<target_node_port> 错误。
  2. 如果该键在源节点上存在且尚未被迁移,源节点会直接处理该命令,不会进行 ASK 重定向。

img

各个节点之间通过gossip协议互相通信,一个节点想要分享一些信息给网络中的其他的一些节点。于是,它周期性的随机选择一些节点,并把信息传递给这些节点。这些收到信息的节点接下来会做同样的事情,即把这些信息传递给其他一些随机选择的节点。一般而言,信息会周期性的传递给N个目标节点,而不只是一个。这个N被称为fanout

节点之前不断交换信息,交换的信息内容包括节点出现故障、新节点加入、主从节点变更信息、slot信息等等。gossip协议包含多种消息类型,包括ping,pong,meet,fail等等

img

  • meet消息:通知新节点加入。消息发送者通知接收者加入到当前集群,meet消息通信正常完成后,接收节点会加入到集群中并进行周期性的ping、pong消息交换。
  • ping消息:节点每秒会向集群中其他节点发送 ping 消息,消息中带有自己已知的两个节点的地址、槽、状态信息、最后一次通信时间等
  • pong消息:当接收到ping、meet消息时,作为响应消息回复给发送方确认消息正常通信。消息中同样带有自己已知的两个节点信息。
  • fail消息:当节点判定集群内另一个节点下线时,会向集群内广播一个fail消息,其他节点接收到fail消息之后把对应节点更新为下线状态。

故障转移

Redis集群实现了高可用,当集群内节点出现故障时,通过故障转移,以保证集群正常对外提供服务。

redis集群通过ping/pong消息,实现故障发现。这个环境包括主观下线和客观下线。

  • 主观下线:某个节点认为另一个节点不可用,即下线状态,这个状态并不是最终的故障判定,只能代表一个节点的意见,可能存在误判情况。

  • 客观下线:指标记一个节点真正的下线,集群内多个节点都认为该节点不可用,从而达成共识的结果。如果是持有槽的主节点故障,需要为该节点进行故障转移。

  • 假如节点A标记节点B为主观下线,一段时间后,节点A通过消息把节点B的状态发到其它节点,当节点C接受到消息并解析出消息体时,如果发现节点B的pfail状态时,会触发客观下线流程;

  • 当下线为主节点时,此时Redis Cluster集群为统计持有槽的主节点投票,看投票数是否达到一半,当下线报告统计数大于一半时,被标记为客观下线状态。

  • 故障恢复:故障发现后,如果下线节点的是主节点,则需要在它的从节点中选一个替换它,以保证集群的高可用。流程如下:

img

  • 资格检查:检查从节点是否具备替换故障主节点的条件。
  • 准备选举时间:资格检查通过后,更新触发故障选举时间。
  • 发起选举:到了故障选举时间,进行选举。
  • 选举投票:只有持有槽的主节点才有票,从节点收集到足够的选票(大于一半),触发替换主节点

Redis Cluster的Hash Slot 是16384

减少节点之间传递哈希槽的数据量

减少哈希碰撞概率

哨兵模式已经实现了故障自动转移的能力,但业务规模的不断扩展,用户量膨胀,并发量持续提升,会出现了 Redis 响应慢的情况。

使用 Redis Cluster 集群,主要解决了大数据量存储导致的各种慢问题,同时也便于横向拓展。在面对千万级甚至亿级别的流量的时候,很多大厂的做法是在千百台的实例节点组成的集群上进行流量调度、服务治理的。

整个Redis数据库划分为16384个哈希槽,Redis集群可能有n个实例节点,每个节点可以处理0个 到至多 16384 个槽点,这些节点把 16384个槽位瓜分完成。

Cluster 是具备Master 和 Slave模式,Redis 集群中的每个实例节点都负责一些槽位,节点之间保持TCP通信,当Master发生了宕机, Redis Cluster自动会将对应的Slave节点选为Master,来继续提供服务。

客户端能够快捷的连接到服务端,主要是将slots与实例节点的映射关系存储在本地,当需要访问的时候,对key进行CRC16计算后,再对16384 取模得到对应的 Slot 索引,再定位到相应的实例上。实现高效的连接。

集群脑裂导致的数据丢失

导致集群脑裂的原因:主节点与集群中其他节点出现网络问题失去连接.

在 Redis 主从架构中,部署方式一般是「一主多从」,主节点提供写操作,从节点提供读操作。 如果主节点的网络突然发生了问题,它与所有的从节点都失联了,但是此时的主节点和客户端的网络是正常的,这个客户端并不知道 Redis 内部已经出现了问题,还在照样的向这个失联的主节点写数据(过程A),此时这些数据被旧主节点缓存到了缓冲区里,因为主从节点之间的网络问题,这些数据都是无法同步给从节点的

这时,哨兵也发现主节点失联了,它就认为主节点挂了(但实际上主节点正常运行,只是网络出问题了),于是哨兵就会在「从节点」中选举出一个 leader 作为主节点,这时集群就有两个主节点了 —— 脑裂出现了

然后,网络突然好了,哨兵因为之前已经选举出一个新主节点了,它就会把旧主节点降级为从节点(A),然后从节点(A)会向新主节点请求数据同步,因为第一次同步是全量同步的方式,此时的从节点(A)会清空掉自己本地的数据,然后再做全量同步。所以,之前客户端在过程 A 写入的数据就会丢失了,也就是集群产生脑裂数据丢失的问题

由于网络问题,集群节点之间失去联系。主从数据不同步;重新平衡选举,产生两个主服务。等网络恢复,旧主节点会降级为从节点,再与新主节点进行同步复制的时候,由于会从节点会清空自己的缓冲区,所以导致之前客户端写入的数据丢失了。

解决方案

当主节点发现从节点下线或者通信超时的总数量小于阈值时,那么禁止主节点进行写数据,直接把错误返回给客户端。

在 Redis 的配置文件中有两个参数我们可以设置:

  • min-slaves-to-write x,主节点必须要有至少 x 个从节点连接,如果小于这个数,主节点会禁止写数据。
  • min-slaves-max-lag x,主从数据复制和同步的延迟不能超过 x 秒,如果超过,主节点会禁止写数据。

我们可以把 min-slaves-to-write 和 min-slaves-max-lag 这两个配置项搭配起来使用,分别给它们设置一定的阈值,假设为 N 和 T。

这两个配置项组合后的要求是,主库连接的从库中至少有 N 个从库,和主库进行数据复制时的 ACK 消息延迟不能超过 T 秒,否则,主库就不会再接收客户端的写请求了。

即使原主库是假故障,它在假故障期间也无法响应哨兵心跳,也不能和从库进行同步,自然也就无法和从库进行 ACK 确认了。这样一来,min-slaves-to-write 和 min-slaves-max-lag 的组合要求就无法得到满足,原主库就会被限制接收客户端写请求,客户端也就不能在原主库中写入新数据了

等到新主库上线时,就只有新主库能接收和处理客户端请求,此时,新写的数据会被直接写到新主库中。而原主库会被哨兵降为从库,即使它的数据被清空了,也不会有新数据丢失。

如何判断Redis某个节点是否正常

判断 Redis 某个节点是否正常工作是一个常见的运维和开发需求。接下来我会详细讲述判断 Redis 节点正常工作五种常见方式。

第一种是采用 PING 命令,它是 Redis 内置命令,用于测试 Redis 服务是否可用。如果 Redis 节点正常工作,执行 PING 命令会返回 PONG。如果未收到 PONG 或连接超时,则说明节点可能存在问题。

第二种是采用 INFO 命令,它也是 Redis 内置命令,INFO 命令可以返回 Redis 节点的详细运行信息,包括内存使用、连接数、持久化状态等。通过解析这些信息,可以判断节点是否处于正常状态。例如,检查 role 字段可以确认节点是主节点还是从节点,检查 connected_clients 可以确认是否有过多的客户端连接。

第三种是采用 CLUSTER INFO 命令(集群模式),它还是 Redis 内置命令,如果 Redis 运行在集群模式下,可以使用 CLUSTER INFO 命令查看集群的状态。重点关注 cluster_state 字段,如果值为 ok,则表示集群正常;如果是 fail,则说明集群中有节点不可用。

第四种是采用 Telnet 或 Netcat,它们属于外部工具,用于测试 Redis 节点的端口是否可达。例如,尝试连接到 Redis 的默认端口 6379,如果连接失败,说明节点可能宕机或网络有问题。

第五种是采用监控系统,配置 Prometheus、Grafana 等监控工具,实时监控 Redis 的性能指标(如内存使用率、QPS、延迟等)。如果某些指标超出阈值或出现异常波动,可能是节点出现问题

过期删除与内存淘汰策略

Redis 是可以对 key 设置过期时间的,因此需要有相应的机制将已过期的键值对删除,而做这个工作的就是过期键值删除策略。

通过expire以及setex, set key \等设置过期时间,ttl查看剩余时间.

如何判定过期

当对一个 key 设置了过期时间时,Redis 会把该 key 带上过期时间存储到一个过期字典(expires dict)中,也就是说「过期字典」保存了数据库中所有 key 的过期时间。

1
2
3
4
5
typedef struct redisDb {
dict *dict; /* 数据库键空间,存放着所有的键值对 */
dict *expires; /* 键的过期时间 */
....
} redisDb;

过期字典数据结构结构如下:

  • 过期字典的 key 是一个指针,指向某个键对象;
  • 过期字典的 value 是一个 long long 类型的整数,这个整数保存了 key 的过期时间;

字典实际上是哈希表,哈希表的最大好处就是让我们可以用 O(1) 的时间复杂度来快速查找。当我们查询一个 key 时,Redis 首先检查该 key 是否存在于过期字典中:

  • 如果不在,则正常读取键值;
  • 如果存在,则会获取该 key 的过期时间,然后与当前系统时间进行比对,如果比系统时间大,那就没有过期,否则判定该 key 已过期。

过期删除策略

Redis 选择「惰性删除+定期删除」这两种策略配和使用,以求在合理使用 CPU 时间和避免内存浪费之间取得平衡。

定期删除策略的做法是,每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期key。

定期删除策略的优点

  • 通过限制删除操作执行的时长和频率,来减少删除操作对 CPU 的影响,同时也能删除一部分过期的数据减少了过期键对空间的无效占用。

定期删除策略的缺点

  • 内存清理方面没有定时删除效果好,同时没有惰性删除使用的系统资源少。
  • 难以确定删除操作执行的时长和频率。如果执行的太频繁,定期删除策略变得和定时删除策略一样,对CPU不友好;如果执行的太少,那又和惰性删除一样了,过期 key 占用的内存不会及时得到释放

定期删除策略的做法:每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期key。

1、这个间隔检查的时间是多长呢?

在 Redis 中,默认每秒进行 10 次过期检查一次数据库,此配置可通过 Redis 的配置文件 redis.conf 进行配置,配置键为 hz 它的默认值是 hz 10。

特别强调下,每次检查数据库并不是遍历过期字典中的所有 key,而是从数据库中随机抽取一定数量的 key 进行过期检查。

2、随机抽查的数量是多少呢?

我查了下源码,定期删除的实现在 expire.c 文件下的 activeExpireCycle 函数中,其中随机抽查的数量由 ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP 定义的,它是写死在代码中的,数值是 20。

也就是说,数据库每轮抽查时,会随机选择 20 个 key 判断是否过期。

接下来,详细说说 Redis 的定期删除的流程:

  1. 从过期字典中随机抽取 20 个 key;
  2. 检查这 20 个 key 是否过期,并删除已过期的 key;
  3. 如果本轮检查的已过期 key 的数量,超过 5 个(20/4),也就是「已过期 key 的数量」占比「随机抽取 key 的数量」大于 25%,则继续重复步骤 1;如果已过期的 key 比例小于 25%,则停止继续删除过期 key,然后等待下一轮再检查。

可以看到,定期删除是一个循环的流程。

那 Redis 为了保证定期删除不会出现循环过度,导致线程卡死现象,为此增加了定期删除循环流程的时间上限,默认不会超过 25ms。

惰性删除策略的做法是,不主动删除过期键,每次从数据库访问 key 时,都检测 key 是否过期,如果过期则删除该 key。

惰性删除策略的优点

  • 因为每次访问时,才会检查 key 是否过期,所以此策略只会使用很少的系统资源,因此,惰性删除策略对 CPU 时间最友好。

惰性删除策略的缺点

  • 如果一个 key 已经过期,而这个 key 又仍然保留在数据库中,那么只要这个过期 key 一直没有被访问,它所占用的内存就不会释放,造成了一定的内存空间浪费。所以,惰性删除策略对内存不友

内存淘汰策略

当 Redis 的运行内存已经超过 Redis 设置的最大内存之后,则会使用内存淘汰策略删除符合条件的 key,以此来保障 Redis 高效的运行。在配置文件 redis.conf 中,可以通过参数 maxmemory <bytes> 来设定最大运行内存,只有在 Redis 的运行内存达到了我们设置的最大运行内存,才会触发内存淘汰策略。 不同位数的操作系统,maxmemory 的默认值是不同的:

  • 在 64 位操作系统中,maxmemory 的默认值是 0,表示没有内存大小限制,那么不管用户存放多少数据到 Redis 中,Redis 也不会对可用内存进行检查,直到 Redis 实例因内存不足而崩溃也无作为。
  • 在 32 位操作系统中,maxmemory 的默认值是 3G,因为 32 位的机器最大只支持 4GB 的内存,而系统本身就需要一定的内存资源来支持运行,所以 32 位操作系统限制最大 3 GB 的可用内存是非常合理的,这样可以避免因为内存不足而导致 Redis 实例崩溃。

Redis 内存淘汰策略共有八种,这八种策略大体分为「不进行数据淘汰」和「进行数据淘汰」两类策略。

1、不进行数据淘汰的策略

noeviction(Redis3.0之后,默认的内存淘汰策略) :它表示当运行内存超过最大设置内存时,不淘汰任何数据,这时如果有新的数据写入,会报错通知禁止写入,不淘汰任何数据,但是如果没用数据写入的话,只是单纯的查询或者删除操作的话,还是可以正常工作。

2、进行数据淘汰的策略

针对「进行数据淘汰」这一类策略,又可以细分为「在设置了过期时间的数据中进行淘汰」和「在所有数据范围内进行淘汰」这两类策略。

在设置了过期时间的数据中进行淘汰:

  • volatile-random:随机淘汰设置了过期时间的任意键值;
  • volatile-ttl:优先淘汰更早过期的键值。
  • volatile-lru(Redis3.0 之前,默认的内存淘汰策略):淘汰所有设置了过期时间的键值中,最久未使用的键值;
  • volatile-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰所有设置了过期时间的键值中,最少使用的键值;

在所有数据范围内进行淘汰:

  • allkeys-random:随机淘汰任意键值;
  • allkeys-lru:淘汰整个键值中最久未使用的键值;
  • allkeys-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰整个键值中最少使用的键值。

什么是 LRU 算法?

LRU 全称是 Least Recently Used 翻译为最近最少使用,会选择淘汰最近最少使用的数据。

传统 LRU 算法的实现是基于「链表」结构,链表中的元素按照操作顺序从前往后排列,最新操作的键会被移动到表头,当需要内存淘汰时,只需要删除链表尾部的元素即可,因为链表尾部的元素就代表最久未被使用的元素。

Redis 并没有使用这样的方式实现 LRU 算法,因为传统的 LRU 算法存在两个问题:

  • 需要用链表管理所有的缓存数据,这会带来额外的空间开销;
  • 当有数据被访问时,需要在链表上把该数据移动到头端,如果有大量数据被访问,就会带来很多链表移动操作,会很耗时,进而会降低 Redis 缓存性能。

Redis 是如何实现 LRU 算法的?

Redis 实现的是一种近似 LRU 算法,目的是为了更好的节约内存,它的实现方式是在 Redis 的对象结构体中添加一个额外的字段,用于记录此数据的最后一次访问时间

当 Redis 进行内存淘汰时,会使用随机采样的方式来淘汰数据,它是随机取 5 个值(此值可配置),然后淘汰最久没有使用的那个

Redis 实现的 LRU 算法的优点:

  • 不用为所有的数据维护一个大链表,节省了空间占用;
  • 不用在每次数据访问时都移动链表项,提升了缓存的性能;

    LRU 算法有一个问题,无法解决缓存污染问题,比如应用一次读取了大量的数据,而这些数据只会被读取这一次,那么这些数据会留存在 Redis 缓存中很长一段时间,造成缓存污染。

所以引入了LFU 算法,根据数据访问次数来淘汰数据,它的核心思想是“如果数据过去被访问多次,那么将来被访问的频率也更高”。

所以, LFU 算法会记录每个数据的访问次数。当一个数据被再次访问时,就会增加该数据的访问次数。这样就解决了偶尔被访问一次之后,数据留存在缓存中很长一段时间的问题,相比于 LRU 算法也更合理一些。LFU 算法相比于 LRU 算法的实现,多记录了“数据的访问频次”的信息

在 LRU 算法中,Redis 对象头的 24 bits 的 lru 字段是用来记录 key 的访问时间戳,因此在 LRU 模式下,Redis可以根据对象头中的 lru 字段记录的值,来比较最后一次 key 的访问时间长,从而淘汰最久未被使用的 key。

在 LFU 算法中,Redis对象头的 24 bits 的 lru 字段被分成两段来存储,高 16bit 存储 ldt(Last Decrement Time),低 8bit 存储 logc(Logistic Counter)。

在每次 key 被访问时,会先对 logc 做一个衰减操作,衰减的值跟前后访问时间的差距有关系,如果上一次访问的时间与这一次访问的时间差距很大,那么衰减的值就越大,这样实现的 LFU 算法是根据访问频率来淘汰数据的,而不只是访问次数。访问频率需要考虑 key 的访问是多长时间段内发生的。key 的先前访问距离当前时间越长,那么这个 key 的访问频率相应地也就会降低,这样被淘汰的概率也会更大。

  • ldt 是用来记录 key 的访问时间戳
  • logc 是用来记录 key 的访问频次,它的值越小表示使用频率越低,越容易淘汰,每个新加入的 key 的logc 初始值为 5。

注意,logc 并不是单纯的访问次数,而是访问频次(访问频率),因为 logc 会随时间推移而衰减的

对 logc 做完衰减操作后,就开始对 logc 进行增加操作,增加操作并不是单纯的 + 1,而是根据概率增加,如果 logc 越大的 key,它的 logc 就越难再增加。

所以,Redis 在访问 key 时,对于 logc 是这样变化的:

  1. 先按照上次访问距离当前的时长,来对 logc 进行衰减;
  2. 然后,再按照一定概率增加 logc 的值

redis.conf 提供了两个配置项,用于调整 LFU 算法从而控制 logc 的增长和衰减:

  • lfu-decay-time 用于调整 logc 的衰减速度,它是一个以分钟为单位的数值,默认值为1,lfu-decay-time 值越大,衰减越慢;
  • lfu-log-factor 用于调整 logc 的增长速度,lfu-log-factor 值越大,logc 增长越慢

Redis在项目中应用

如何设计缓存策略动态缓存热点数据

由于数据存储受限,系统并不是将所有数据都需要存放到缓存中的,而只是将其中一部分热点数据缓存起来,所以我们要设计一个热点数据动态缓存的策略。

热点数据动态缓存的策略总体思路:通过数据最新访问时间来做排名,并过滤掉不常访问的数据,只留下经常访问的数据

以电商平台场景中的例子,现在要求只缓存用户经常访问的 Top 1000 的商品。具体细节如下:

  • 先通过缓存系统做一个排序队列(比如存放 1000 个商品),系统会根据商品的访问时间,更新队列信息,越是最近访问的商品排名越靠前;
  • 同时系统会定期过滤掉队列中排名最后的 200 个商品,然后再从数据库中随机读取出 200 个商品加入队列中;
  • 这样当请求每次到达的时候,会先从队列中获取商品 ID,如果命中,就根据 ID 再从另一个缓存数据结构中读取实际的商品信息,并返回。

在 Redis 中可以用 zadd 方法和 zrange 方法来完成排序队列和获取 200 个商品的操

实现分布锁

img

Redis 本身可以被多个客户端共享访问,正好就是一个共享存储系统,可以用来保存分布式锁,而且 Redis 的读写性能高,可以应对高并发的锁操作场景。

Redis 的 SET 命令有个 NX 参数可以实现「key不存在才插入」,所以可以用它来实现分布式锁:

  • 如果 key 不存在,则显示插入成功,可以用来表示加锁成功;
  • 如果 key 存在,则会显示插入失败,可以用来表示加锁失败。

基于 Redis 节点实现分布式锁时,对于加锁操作,我们需要满足三个条件。

  • 加锁包括了读取锁变量、检查锁变量值和设置锁变量值三个操作,但需要以原子操作的方式完成,所以,我们使用 SET 命令带上 NX 选项来实现加锁;
  • 锁变量需要设置过期时间,以免客户端拿到锁后发生异常,导致锁一直无法释放,所以,我们在 SET 命令执行时加上 EX/PX 选项,设置其过期时间;
  • 锁变量的值需要能区分来自不同客户端的加锁操作,以免在释放锁时,出现误释放操作,所以,我们使用 SET 命令设置锁变量值时,每个客户端设置的值是一个唯一值,用于标识客户端;

满足这三个条件的分布式命令如下:

1
SET lock_key unique_value NX PX 10000 
  • lock_key 就是 key 键;
  • unique_value 是客户端生成的唯一的标识,区分来自不同客户端的锁操作;
  • NX 代表只在 lock_key 不存在时,才对 lock_key 进行设置操作;
  • PX 10000 表示设置 lock_key 的过期时间为 10s,这是为了避免客户端发生异常而无法释放锁。

而解锁的过程就是将 lock_key 键删除(del lock_key),但不能乱删,要保证执行操作的客户端就是加锁的客户端。所以,解锁的时候,我们要先判断锁的 unique_value 是否为加锁客户端,是的话,才将 lock_key 键删除。

可以看到,解锁是有两个操作,这时就需要 Lua 脚本来保证解锁的原子性,因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,保证了锁释放操作的原子性。

1
2
3
4
5
6
// 释放锁时,先比较 unique_value 是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end

这样一来,就通过使用 SET 命令和 Lua 脚本在 Redis 单节点上完成了分布式锁的加锁和解锁。

基于 Redis 实现分布式锁有什么优缺点?

基于 Redis 实现分布式锁的优点

  1. 性能高效(这是选择缓存实现分布式锁最核心的出发点)。
  2. 实现方便。很多研发工程师选择使用 Redis 来实现分布式锁,很大成分上是因为 Redis 提供了 setnx 方法,实现分布式锁很方便。
  3. 避免单点故障(因为 Redis 是跨集群部署的,自然就避免了单点故障)。

基于 Redis 实现分布式锁的缺点

  • 超时时间不好设置

    。如果锁的超时时间设置过长,会影响性能,如果设置的超时时间过短会保护不到共享资源。比如在有些场景中,一个线程 A 获取到了锁之后,由于业务代码执行时间可能比较长,导致超过了锁的超时时间,自动失效,注意 A 线程没执行完,后续线程 B 又意外的持有了锁,意味着可以操作共享资源,那么两个线程之间的共享资源就没办法进行保护了。

    • 那么如何合理设置超时时间呢? 我们可以基于续约的方式设置超时时间:先给锁设置一个超时时间,然后启动一个守护线程,让守护线程在一段时间后,重新设置这个锁的超时时间。实现方式就是:写一个守护线程,然后去判断锁的情况,当锁快失效的时候,再次进行续约加锁,当主线程执行完成后,销毁续约锁即可,不过这种方式实现起来相对复杂。
  • Redis 主从复制模式中的数据是异步复制的,这样导致分布式锁的不可靠性。如果在 Redis 主节点获取到锁后,在没有同步到其他节点时,Redis 主节点宕机了,此时新的 Redis 主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁。

Redis如何解决集群情况下锁的可靠性

为了保证集群环境下分布式锁的可靠性,Redis 官方已经设计了一个分布式锁算法 Redlock

它是基于多个 Redis 节点的分布式锁,即使有节点发生了故障,锁变量仍然是存在的,客户端还是可以完成锁操作。官方推荐是至少部署 5 个 Redis 节点,而且都是主节点,它们之间没有任何关系,都是一个个孤立的节点。

Redlock 算法的基本思路,是让客户端和多个独立的 Redis 节点依次请求申请加锁,如果客户端能够和半数以上的节点成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败

这样一来,即使有某个 Redis 节点发生故障,因为锁的数据在其他节点上也有保存,所以客户端仍然可以正常地进行锁操作,锁的数据也不会丢失。

Redlock 算法加锁三个过程:

  • 第一步是,客户端获取当前时间(t1)。
  • 第二步是,客户端按顺序依次向 N 个 Redis 节点执行加锁操作:
    • 加锁操作使用 SET 命令,带上 NX,EX/PX 选项,以及带上客户端的唯一标识。
    • 如果某个 Redis 节点发生故障了,为了保证在这种情况下,Redlock 算法能够继续运行,我们需要给「加锁操作」设置一个超时时间(不是对「锁」设置超时时间,而是对「加锁操作」设置超时时间),加锁操作的超时时间需要远远地小于锁的过期时间,一般也就是设置为几十毫秒。
  • 第三步是,一旦客户端从超过半数(大于等于 N/2+1)的 Redis 节点上成功获取到了锁,就再次获取当前时间(t2),然后计算计算整个加锁过程的总耗时(t2-t1)。如果 t2-t1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为加锁失败。

可以看到,加锁成功要同时满足两个条件(简述:如果有超过半数的 Redis 节点成功的获取到了锁,并且总耗时没有超过锁的有效时间,那么就是加锁成功):

  • 条件一:客户端从超过半数(大于等于 N/2+1)的 Redis 节点上成功获取到了锁;
  • 条件二:客户端从大多数节点获取锁的总耗时(t2-t1)小于锁设置的过期时间。

锁成功后,客户端需要重新计算这把锁的有效时间,计算的结果是「锁最初设置的过期时间」减去「客户端从大多数节点获取锁的总耗时(t2-t1)」。如果计算的结果已经来不及完成共享数据的操作了,可以释放锁,以免出现还没完成数据操作,锁就过期了的情况。

加锁失败后,客户端向所有 Redis 节点发起释放锁的操作,释放锁的操作和在单节点上释放锁的操作一样,只要执行释放锁的 Lua 脚本就可以了。

stream实现延迟队列

延迟队列是指把当前要做的事情,往后推迟一段时间再做。延迟队列的常见使用场景有以下几种:

  • 在淘宝、京东等购物平台上下单,超过一定时间未付款,订单会自动取消;
  • 打车的时候,在规定时间没有车主接单,平台会取消你的单并提醒你暂时没有车主接单;
  • 点外卖的时候,如果商家在10分钟还没接单,就会自动取消订单;

在 Redis 可以使用有序集合(ZSet)的方式来实现延迟消息队列的,ZSet 有一个 Score 属性可以用来存储延迟执行的时间。

img

数据库与缓存的一致性

更新缓存还是删缓存

先更新数据库还是先删缓存

延迟双删解决并发读写请求下的缓存不一致问题

如何保证先更新数据库再删除缓存操作能执行成功.

订阅mysql binlog,消息队列

订阅 MySQL Binlog 来解决缓存删除失败导致的数据不一致问题,是一种最终一致性的解决方案,也是目前业界公认的最可靠最推荐的方式之一。它通过将数据变更的事件流作为驱动,确保数据库和缓存之间的数据保持同步。

为什么会出现缓存删除失败问题?

在“先更新数据库再删除缓存”的策略中,最常见的失败场景是:

  • 数据库更新成功,但删除缓存操作失败。 这可能是由于网络问题、缓存服务宕机、Redis 连接超时等原因造成的。
  • 一旦缓存删除失败,缓存中就会保留旧数据,而数据库中已经是新数据,导致数据不一致,用户可能会读取到脏数据。

订阅 Binlog 的解决方案原理

这种方案的核心思想是:应用程序只负责更新数据库,而缓存的更新或删除则由一个独立的、专门的服务来完成,该服务通过监听 MySQL 的 Binlog 来感知数据库的变化

Binlog(Binary Log)是 MySQL 的二进制日志,它记录了所有对数据库进行更改的事件,包括数据插入、更新、删除等操作的详细信息。

具体流程如下:

  1. 应用程序操作数据库:
    • 业务应用层只进行数据库操作(INSERTUPDATEDELETE)。
    • 应用程序不再直接负责删除缓存。
  2. Binlog 实时同步到中间件:
    • 变更数据捕获 (Change Data Capture, CDC) 工具(例如 CanalDebezium 等)作为 Binlog 的消费者,连接到 MySQL 数据库,并模拟成一个 MySQL 从库。
    • 它会实时地读取 MySQL 的 Binlog,捕获所有的数据变更事件。
    • 捕获到的变更事件会被发送到一个消息队列(例如 KafkaRabbitMQ 等)。消息队列在这里起到了缓冲、解耦和削峰的作用,确保事件不会丢失,并且能够异步处理。
  3. 缓存同步服务消费消息:
    • 一个独立的缓存同步服务(或称为“数据同步服务”)作为消息队列的消费者,订阅 Binlog 变更事件对应的消息主题。
    • 当该服务收到数据库变更事件时,它会解析事件内容,知道是哪个表、哪条记录发生了什么变化。
  4. 根据事件类型操作缓存:
    • 对于更新 (UPDATE) 或删除 (DELETE) 事件: 缓存同步服务根据事件中的主键或唯一标识符,找到对应的缓存键,并执行缓存删除操作
    • 对于插入 (INSERT) 事件: 如果业务需要,也可以选择预热缓存,将新数据插入到缓存中。但通常删除旧缓存是更常见的操作。
  5. 失败重试与告警:
    • 如果缓存同步服务在删除缓存时遇到问题(例如,Redis 服务不可用),它会将该操作标记为失败,并利用消息队列的重试机制(如 Kafka 的死信队列、Spring Cloud Stream 的重试策略等)进行重试。
    • 如果多次重试仍然失败,可以触发告警通知运维人员介入

其他方法

使用Redis + Kafka实现缓存与数据库的一致性,在写入数据时,可以将操作信息发送到Kafka等消息队列,然后由消费者(可以是一个专门的服务)来处理数据库和缓存的同步。这种方案通过消息队列解耦了数据库和缓存的操作,确保两者的一致性。Kafka的可靠性保证了消息不丢失,因此可以保障一致性,但需要额外的基础设施来管理消息队列。

其次是使用Redis + TCC事务管理,TCC(Try-Confirm-Cancel)事务模型适用于分布式事务管理。在写入Redis和MySQL时,可以先在Redis进行预写操作(Try),然后确认MySQL的数据更新(Confirm),如果遇到失败,可以取消Redis的操作(Cancel)。这种方式通过分布式事务的处理,能够确保两者一致性,但需要额外的事务管理中间件,增加系统复杂度。

最后是使用分布式数据库中间件(如Sentinel, Canal等),Redis的高可用架构可以借助Sentinel实现主从复制,保证缓存的高可用性和一致性。与此同时,Canal可以作为MySQL的增量数据订阅工具,实时同步数据库变更到Redis缓存。通过这种方式,可以实现高效的数据一致性保障,但配置和维护较为复杂。

相关资料

  1. Redis 常见面试题 | 小林coding
  2. 黑马程序员Redis入门到实战教程,深度透析redis底层原理+redis分布式锁+企业解决方案+黑马点评实战项目_哔哩哔哩_bilibili
-------------本文结束感谢您的阅读-------------
感谢阅读.

欢迎关注我的其它发布渠道