跳转至

Redis:数据结构、执行模型、持久化与缓存工程

0. 这一章要真正掌握什么

理解 Redis 不能停在“内存、单线程、I/O 多路复用”这些标签上。真正需要掌握的是一条请求从 socket 进入 Redis 后怎样被处理,以及数据结构编码、持久化、过期、淘汰和大 key 如何共同影响延迟与资源占用。

这一章从执行路径出发,把 Redis 的几个核心问题连起来:请求如何进入事件循环;逻辑数据类型与底层编码如何对应;字典为什么要渐进式 rehash;过期删除和 maxmemory 淘汰为什么是两套机制;RDB 与 AOF 分别在持久化什么;Cache-Aside 为什么通常是“先更新数据库,再删除缓存”;缓存穿透、击穿、雪崩分别是哪一种失效模式;主从、Sentinel、Cluster 分别解决什么;线上 Redis 变慢时应该看什么证据。


1. Redis 在后端系统里承担什么角色

Redis 首先是一个内存数据结构服务器。应用通过 key 访问 String、Hash、List、Set、Sorted Set、Stream 等数据结构。和 MySQL 这类关系型数据库相比,Redis 的常见优势不是“数据都应该放 Redis”,而是它适合需要低延迟、频繁访问、可以用 key 或数据结构直接表达的状态。

典型场景包括缓存、计数器、会话状态、排行榜、限流状态、短生命周期中间结果、分布式协调等。核心判断依据是访问模式、容量、持久性要求和一致性要求。

假设模型平台需要查询模型版本详情:

model_version:123 -> 4 KB 元数据

MySQL 是事实来源,Redis 保存热点副本。一次读取通常是:

客户端
  -> 应用服务
      -> GET model_version:123
          -> 命中:直接返回
          -> 未命中:查询 MySQL -> SET Redis -> 返回

这里 Redis 的价值是把大量重复读取从数据库移走。Redis 中的数据可以丢失后重建,因此“缓存”和“事实来源”的职责必须区分。如果 Redis 本身承担唯一状态,例如库存扣减、锁状态、任务队列,就必须额外考虑持久化、复制、故障切换和一致性语义。


2. 一条 Redis 命令是怎样执行的

Redis 使用事件驱动模型处理客户端连接。网络层通过非阻塞 socket 配合 I/O 多路复用等待多个连接上的可读、可写事件。事件就绪后,Redis 读取请求、解析协议、执行命令并写回响应。

经典 Redis 的命令执行路径可以抽象成:

多个客户端 socket
      ↓
I/O 多路复用等待就绪事件
      ↓
读取请求 / 解析 RESP
      ↓
主线程执行命令
      ↓
产生响应
      ↓
socket 写回客户端

Redis 的核心命令执行长期保持“主要由单个主线程串行执行”的设计,因此普通命令之间不需要像多线程共享内存程序那样给每个数据结构加复杂的细粒度锁。Redis 6 开始引入 I/O 线程处理网络读写和协议解析;Redis 8 对 I/O threading 又进行了较大增强,但主数据结构上的命令执行仍以主线程串行为核心。后台还会存在持久化、异步释放等线程或子进程,所以“Redis 完全只有一个线程”是错误表述。

2.1 为什么这种模型仍然快

第一,绝大多数读写直接操作内存,不需要为普通 GET/SET 等命令等待磁盘随机 I/O。

第二,Redis 的核心数据结构对常用操作做了针对性设计,例如哈希表平均 O(1) 查找、Sorted Set 通过跳表维持有序区间访问,小集合使用 listpack/intset 等紧凑编码减少内存和指针开销。

第三,事件循环 + I/O 多路复用使一个主线程可以管理大量连接,不需要“一个连接一个线程”。

第四,命令串行执行避免了大量锁竞争和线程上下文切换,但这也是 Redis 的限制:一个耗时很长的命令会阻塞主线程,让其他客户端一起等待。因此性能设计的核心不是“单线程天然快”,而是让单次主线程命令保持足够短,并把网络等待和后台慢任务移出关键执行路径。

2.2 一个慢命令为什么会拖住所有人

假设某个请求执行:

SUNION huge_set_1 huge_set_2 huge_set_3

如果三个集合都很大,主线程需要遍历大量元素。即使其他客户端只是执行一个 O(1) 的 GET,也必须等当前命令执行结束后才能被服务。

所以 Redis 的 p99/p999 延迟往往比“平均 QPS”更值得关注。大 key、大集合操作、KEYS、复杂 Lua 脚本、一次性删除巨型对象等,都可能制造主线程停顿。


3. Redis 的 key space 本身也是字典

一个 Redis database 可以抽象成一个从 key 到 value 对象的映射。逻辑上:

key -> Redis object

顶层 key space 使用哈希表类结构管理,因此按 key 查找通常具有平均 O(1) 的定位能力。除了主 key space,Redis 还需要维护过期时间等附加元数据。

理解这一层很重要,因为许多行为都来自“Redis 需要维护额外状态”:

  • 设置 TTL,不只是 value 多一个字段,还需要维护可过期 key 的时间信息;
  • 开启 LRU/LFU 淘汰时,需要保存访问相关元数据;
  • key 很多时,顶层字典扩容会触发 rehash;
  • SCAN 可以渐进遍历 key space,而 KEYS 会一次性遍历整个 key space。

4. Redis 逻辑数据类型与底层编码不是一回事

String、Hash、List、Set、Sorted Set 是应用看到的逻辑类型;Redis 内部会根据元素数量、元素大小和数据特征选择不同编码。这样做的原因是:小对象更适合紧凑连续布局,规模变大后才值得换成支持更高效随机访问或更新的数据结构。

分析时不要只背“Hash 底层是哈希表”。更准确的表述是:同一种 Redis 数据类型可以有多种内部编码,并在达到阈值后转换。

4.1 String:不仅是字符串

Redis String 可以保存字节序列,也可以用作整数计数器。常见命令:

SET / GET
INCR / DECR
MGET / MSET
SETNX

Redis 内部字符串使用 SDS(Simple Dynamic String)一类的动态字符串实现,而不是直接把所有字符串当作裸 C 字符串。SDS 会保存长度和容量信息,因此获取长度不需要每次扫描到 \0;扩容时也可以减少频繁重新分配。

String 对象还可能对整数或短字符串使用更紧凑的编码,以减少额外内存分配。工程实践中真正需要抓住的是:Redis 不把所有值都机械地存成一块“char*”,而会根据值特征选择编码。

4.2 Hash:小对象优先紧凑,大对象再切哈希表

Redis Hash 表达一个 key 下的 field-value 集合:

HSET user:42 name Alice age 24 city Shenzhen
HGET user:42 age

对于字段少、字段和值较短的小 Hash,Redis 7.x 常使用 listpack 这类紧凑连续编码。它的优点是内存占用小、缓存局部性好,缺点是查某个 field 需要在小结构中线性定位。

当 Hash 超过配置的元素数量或元素长度阈值时,会转换为真正的哈希表编码。此时 HGET/HSET 的平均查找成本更接近 O(1),代价是指针、bucket 和分配器开销变大。

因此“listpack 是 O(N),为什么还要用”并不矛盾:当 N 很小时,连续内存 + 更少对象分配的常数优势可能比哈希表更划算。Redis 官方的内存优化文档也明确把这看成 CPU 与内存的权衡。

4.3 List:quicklist + listpack

Redis List 支持两端插入、弹出和区间读取:

LPUSH / RPUSH
LPOP / RPOP
LRANGE

现代 Redis 的 List 主要使用 quicklist。quicklist 可以理解成一个双向链结构,但每个节点里不是只放一个元素,而是放一组紧凑编码的元素(listpack)。

如果每个元素都做成普通链表节点,会有大量前后指针和独立内存分配;如果所有元素都塞进一块超大的连续数组,中间修改又很昂贵。quicklist 在“链表易于分段修改”和“连续块节约内存、缓存友好”之间做折中。

所以:

List != 普通双向链表
List ≈ 双向连接的多个紧凑块

4.4 Set:唯一性集合

Set 适合:

SADD / SREM / SISMEMBER
SINTER / SUNION / SDIFF

如果元素都是整数且集合较小,可以使用 intset;Redis 7.2 起,小型非纯整数 Set 也可以使用 listpack 编码;规模增大后则会转换为哈希表。

这意味着 SISMEMBER 在哈希表编码下通常是平均 O(1),但小集合时 Redis 可能优先用更紧凑的结构减少内存。

4.5 Sorted Set:为什么同时需要字典和跳表

Sorted Set(ZSet)保存唯一 member,并给每个 member 一个 score:

ZADD ranking 98.5 user:1
ZADD ranking 87.0 user:2
ZRANGE ranking 0 9 REV WITHSCORES
ZRANGEBYSCORE ranking 80 100

小型 ZSet 可以使用 listpack。规模增大后,经典实现同时维护:

1. dict:member -> score
2. skiplist:按 score + member 排序

为什么要两个?

如果只有跳表,按 member 精确查 score 需要在按 score 排序的结构里寻找,不适合直接定位;字典提供 member -> score 的快速查找。

如果只有字典,又无法高效回答“score 在 80 到 100 之间的所有 member”或“排行榜前 100 名”,因为哈希表没有全局顺序。

所以两个结构分别服务两类访问模式:

按 member 定位 -> dict
按 score / rank 范围访问 -> skiplist

跳表通过多级前向指针减少线性扫描距离,平均查找、插入和删除为 O(log N),同时天然支持从定位点向后做范围遍历。相比红黑树,跳表实现更直接,范围扫描也很自然;Redis 同时已有字典负责精确 member 查找,因此不需要让单一结构承担全部职责。

4.6 Stream:Redis 自带的追加日志结构

Stream 是按 ID 有序的追加结构,支持 consumer group、pending entries 等机制,可用于事件流和轻量队列场景。它与 Kafka 的设计目标和持久化/扩展模型不同,因此后续 Kafka 模块会单独比较;这里只要知道 Redis 不只有 List 能表达队列语义。


5. Redis 字典为什么要渐进式 rehash

哈希表装载增加后需要扩容。如果一次性把数百万个 bucket 中的 entry 全部迁移到新表,主线程会停顿很久,这和 Redis 的低延迟目标冲突。

Redis 的经典做法是同时保留旧表和新表,并逐步迁移 bucket:

ht[0] 旧表
ht[1] 新表
rehashidx = 当前迁移位置

rehash 过程中:

  1. 新写入倾向进入新表;
  2. 查询需要按当前状态在相关表中查找;
  3. 每次普通字典操作顺便迁移少量 bucket;
  4. Redis 的周期性任务也可以推进迁移;
  5. 最终旧表清空,新表成为主表。

这就是“渐进式 rehash”。它把一次很长的 O(N) 停顿拆成很多小步,换取迁移期间需要同时维护两张表的复杂度和临时内存。


6. TTL 过期删除与 maxmemory 淘汰是两件事

TTL 回答的是:这个 key 什么时候应该失效?

maxmemory eviction 回答的是:内存到上限时,为了继续写入应该删谁?

二者不能混为一谈。

6.1 Redis 如何删除过期 key

Redis 同时使用被动过期和主动过期。

被动过期:客户端访问某个 key 时,Redis 发现它已经超过过期时间,于是把它删除并返回不存在。

主动过期:如果一个已经过期的 key 再也没人访问,只靠被动删除会一直占内存,因此 Redis 会周期性抽样检查带 TTL 的 key 并清理已过期项。

这也是为什么不能简单理解为“TTL 到点就一定有一个独立定时器立刻删除这个 key”。给每个 key 建一个精确 timer 会带来很高的调度和内存成本;Redis 采用的是适合大 keyspace 的混合策略。

6.2 大量 key 同时过期为什么危险

如果大量 key 在同一时刻集中失效,会同时产生两类压力:

  1. Redis 自己需要花 CPU 清理大量过期对象;
  2. 应用缓存 miss 激增,大量请求回源数据库。

所以缓存 TTL 通常不是统一的固定时刻,而会加入随机抖动:

base TTL = 30 min
实际 TTL = 30 min + random(0, 5 min)

这不是为了“让数据更随机”,而是为了摊平过期事件和回源流量。

6.3 maxmemory 和淘汰策略

当 Redis 配置 maxmemory 后,写入导致内存超过上限时,可以按策略淘汰 key。常见策略包括:

noeviction
allkeys-lru
allkeys-lfu
allkeys-random
volatile-lru
volatile-lfu
volatile-random
volatile-ttl

allkeys-* 可以从所有 key 中选;volatile-* 只在设置了过期时间的 key 中选。

noeviction 不主动淘汰已有数据,达到上限后会让需要更多内存的写操作失败。

Redis 的 LRU/LFU 是工程化近似策略,不需要维护一个全局严格排序链表。严格 LRU 在每次访问都维护完整顺序会增加明显开销;近似采样可以用更低成本得到足够好的淘汰结果。


7. RDB:把某个时刻的数据集做成快照

RDB(Redis Database)是数据集快照。核心目标是得到某个时间点的数据状态,并写成紧凑文件。

BGSAVE 的核心执行过程可以抽象成:

主进程
  |
  | fork()
  v
父进程继续服务请求     子进程遍历内存数据并生成 RDB

关键问题是:父进程继续接收写请求后,子进程怎样还能看到 fork 那一刻的“旧视图”?

答案是操作系统的 Copy-on-Write(COW,写时复制)。fork 后父子进程最初共享物理页;如果父进程随后修改某个共享页,内核先复制对应页,再让父进程写自己的副本。子进程仍然看到原页,于是可以继续把 fork 时刻的数据写入 RDB。

7.1 RDB 的优点

RDB 是紧凑的点时间快照,适合备份、灾难恢复和较快重启。生成快照的大量磁盘写由子进程完成,主进程不需要自己顺序写完整数据集。

7.2 RDB 的成本

第一,fork 本身不是“完全免费”。数据集很大时,创建页表等操作可能制造延迟尖峰。

第二,如果 BGSAVE 期间写入很多,COW 会复制大量内存页,额外内存可能很高。一个 20 GB Redis,在快照期间如果大量页面被修改,并不意味着只需要 20 GB 常驻内存就安全。

第三,RDB 是周期快照。如果每 5 分钟做一次快照,实例在下一次快照前崩溃,就可能丢失这段时间的数据。

所以 RDB 的主要权衡是:文件紧凑、恢复快,但持久性粒度较粗,并且 fork/COW 会带来瞬时延迟和内存压力。


8. AOF:记录写命令,而不是周期性保存完整内存

AOF(Append Only File)记录会改变数据集的写操作。恢复时 Redis 重放这些操作,重新构造内存状态。

简单理解:

客户端 SET x 1
        ↓
Redis 修改内存
        ↓
把对应写操作追加到 AOF

真正的持久性取决于数据什么时候从用户态/内核页缓存刷到稳定存储,所以 AOF 提供不同 fsync 策略:

always    每次写都 fsync:持久性更强,延迟更高
everysec  大约每秒 fsync:常用折中
no        由操作系统决定刷盘时机

Redis 官方文档指出,everysec 是性能与持久性的常见折中,极端故障下可能损失约一秒量级的最新写入。

8.1 为什么 AOF 需要 rewrite

如果业务执行:

SET x 1
SET x 2
SET x 3
SET x 4

恢复当前状态其实只需要最终等价状态,而原始 AOF 会不断增长。AOF rewrite 会基于当前数据集生成一份更紧凑的重建表示,再切换到新 AOF。

Redis 7 起采用 multi-part AOF:通常包含 base 文件、后续 incremental AOF 和 manifest。rewrite 时生成新的 base,并继续记录增量。

8.2 RDB 和 AOF 怎样比较

不要用“RDB 快、AOF 安全”一句话结束。应该按几个维度比较:

维度 RDB AOF
保存内容 某时间点完整数据集 写操作日志 / 可重建状态
数据丢失窗口 取决于快照间隔 取决于 fsync 策略
文件大小 通常更紧凑 通常更大
恢复 大数据集通常较快 需要加载 base / 重放增量
运行时成本 fork + COW + 快照 I/O 持续追加 + fsync + rewrite

如果 Redis 是纯缓存并且数据库可以重建全部内容,可以关闭持久化;如果 Redis 保存重要业务状态,则需要明确可接受的数据丢失窗口,而不是默认“开一个持久化就安全了”。


9. Pipeline、事务和 Lua:三个经常混淆的工具

9.1 Pipeline 解决网络 RTT,不提供事务语义

普通调用:

发送 GET A -> 等响应
发送 GET B -> 等响应
发送 GET C -> 等响应

如果每次 RTT 是 0.5 ms,即使服务端每条命令只花几十微秒,客户端仍把大量时间消耗在往返等待上。

Pipeline 允许客户端连续发送多条命令,再批量读取响应:

GET A
GET B
GET C
---------- 一起发 ---------->
<--------- 批量结果 ---------

它主要减少 RTT 和 socket 调用开销,不意味着这几条命令构成一个不可被其他客户端命令插入的事务。批量太大也会让服务端缓存大量待返回响应,因此通常分批发送。

9.2 MULTI/EXEC 保证一组命令连续执行,但没有传统数据库回滚

MULTI 后命令先排队,EXEC 时按顺序执行。执行期间其他客户端命令不会插到事务中间。

Redis 事务不提供传统关系型数据库那种“其中一条运行时报错就自动回滚已经成功的前几条”。WATCH 提供乐观并发控制:被 WATCH 的 key 在 EXEC 前发生改变,则事务可以放弃执行。

9.3 Lua 适合把读-判断-写放到服务端原子执行

例如限流或安全释放锁经常需要:

GET lock_key
如果 value == my_token
    DEL lock_key

如果客户端分两次请求 GET 和 DEL,中间可能发生状态变化。Lua 可以把判断和删除放在服务端一次执行,避免被其他命令插入。

但是 Lua 脚本运行在 Redis 执行线程上,脚本太重同样会阻塞整个实例,所以“原子”不等于“可以写任意复杂逻辑”。


10. Cache-Aside:缓存一致性最常见的基础模型

假设数据库保存:

model_version 123 -> revision=8

Redis 缓存:

model:123 -> revision=8

10.1 读路径

1. GET Redis
2. 命中 -> 返回
3. miss -> 查询 DB
4. 把 DB 结果写入 Redis
5. 返回

10.2 写路径为什么通常是“更新 DB,再删除缓存”

常见流程:

1. UPDATE DB
2. DEL cache key

为什么不是“先删缓存,再更新数据库”?考虑并发:

写请求 A:DEL cache
读请求 B:cache miss
读请求 B:从 DB 读到旧值
读请求 B:把旧值写回 cache
写请求 A:UPDATE DB 为新值

最终数据库是新值,缓存却留下旧值。

“先更新数据库,再删缓存”也不是数学意义上的绝对强一致。仍然存在窗口:数据库已经更新,但缓存删除还没执行;或者删除缓存失败。因此工程上还需要:

  • 删除失败重试;
  • 把待删除 key 记录到可靠介质,后台补偿;
  • 设置合理 TTL,让脏值有最终上界;
  • 对特别高一致性的数据直接绕过缓存,或者使用版本校验等更强方案。

10.3 为什么通常删除缓存,而不是直接更新缓存

如果一次 DB 写会影响多个缓存派生结果,应用很难保证所有缓存都正确更新;并发写时还可能发生更新顺序错乱。删除缓存让下一次读从事实来源重建,逻辑通常更简单。

但是删除意味着下一次读取需要回源,因此热点 key 的失效必须和“击穿”问题一起考虑。


11. 缓存穿透、击穿、雪崩:看清失败模式而不是背名字

11.1 穿透:数据根本不存在

请求:

GET user:999999999999
Redis miss
DB miss
返回不存在

如果攻击者不断生成不存在 ID,每次都 miss,就会反复打数据库。

常见手段:

  • 参数和权限校验,尽早拒绝明显非法请求;
  • 缓存空结果并设置较短 TTL;
  • Bloom Filter 在回源前判断 key 是否可能存在;
  • 对异常来源限流。

Bloom Filter 的语义是“说不存在时一定不存在;说可能存在时仍可能误判”,所以它可以挡掉大量必不存在请求,但不能代替最终数据库查询。

11.2 击穿:一个热点 key 失效

例如:

model:hot
平时 8000 QPS
TTL 到期

瞬间 8000 个请求同时 miss,如果都去查 DB,数据库会被一个 key 的重建流量打穿。

常见办法是 singleflight / 互斥回源:只允许一个请求去 DB 重建缓存,其他请求等待或使用旧值。另一种办法是逻辑过期:物理 key 继续存在,value 中保存逻辑过期时间,过期后一个后台任务刷新,读请求暂时返回旧值。

11.3 雪崩:大量 key 或整个缓存层一起失效

成千上万个 key 同时过期、Redis 集群故障、网络隔离,都可能让大量流量同时回源。

治理重点是:

  • TTL 加随机抖动;
  • 分批预热;
  • Redis 高可用;
  • 数据库限流和连接池保护;
  • 必要时本地缓存兜底;
  • 故障时允许降级返回非关键字段缺失或稍旧数据。

击穿是“一个热点被打穿”,雪崩是“大面积缓存一起失守”。


12. 大 key、热 key 和慢命令

12.1 大 key 不是只看 key 名字长度

所谓大 key,通常指 value 本身占用很大,或者集合中元素数量极多,例如:

String value = 20 MB
Hash = 500 万 fields
List = 1000 万 elements

风险包括:

  • 网络传输一次返回大量字节;
  • 序列化/反序列化耗时;
  • 集合命令执行时间变长;
  • 删除时释放大量对象造成停顿;
  • RDB/AOF/复制成本放大;
  • Cluster slot 迁移困难。

典型解决是拆 key、限制单 key 容量、分页/分片读取、避免一次 HGETALL 巨型 Hash、用 UNLINK 等异步释放方式降低删除阻塞。

12.2 热 key 是访问集中,不一定大

一个只有 100 字节的 key,如果承受几十万 QPS,也可能成为热 key。Cluster 中一个 key 只属于一个 slot,最终落在一个 shard,因此热 key 会让一个 shard CPU 和网络满载,而其他 shard 很空闲。

常见办法:

  • 应用本地缓存吸收读流量;
  • 对可拆分统计做 key 分片;
  • 复制只读数据到多个逻辑 key 并在应用侧分散读取;
  • 重新设计访问模型,避免所有请求集中到一个全局 key。

12.3 不要在生产环境随便跑 KEYS

KEYS pattern 会遍历整个 key space。数据量大时会长时间占用主线程。需要在线渐进遍历时使用 SCAN 家族,把一次大 O(N) 操作拆成多次增量迭代。

SLOWLOG 可以记录执行时间超过阈值的命令;INFO commandstats、INFO latencystats、LATENCY 工具可以提供命令统计和延迟证据。排障应先拿证据,再猜“是不是 Redis 慢了”。


13. 主从复制:高可用的基础,但不是同步强一致

Redis replication 常见结构:

Primary
  ├─ Replica A
  └─ Replica B

主节点接收写入并把复制流发送给 replica。复制主要用于:

  • 读扩展;
  • 故障时提供可提升的副本;
  • 降低单节点数据丢失风险。

Redis 复制通常是异步的。主节点向客户端确认写成功时,不代表所有 replica 已经持久保存了这次写。因此如果 primary 在写入刚确认后立刻永久故障,故障切换到尚未收到该写的 replica,最新写仍可能丢失。

这也是“有主从就绝对不丢数据”不成立的原因。

13.1 初次全量同步与后续增量

新 replica 连接 primary 时,需要建立一个一致的数据基线,再接收同步期间和之后的增量写流。正常连接中断后,如果复制 backlog 等状态仍可覆盖断线期间的数据,可以做部分重同步,避免每次都重新传完整数据集。

学习追问“同步过程中还有新写入怎么办”,本质是在问:全量基线生成和发送期间,主节点不能停止业务,所以新的写必须被暂存/记录,并在基线之后继续同步给 replica。


14. Sentinel:给非 Cluster 主从体系做自动故障转移

Sentinel 解决的不是数据分片,而是非 Cluster Redis 的高可用。

多个 Sentinel 会监控 primary 和 replicas。当足够 Sentinel 判断 primary 不可用后,会协商并执行 failover:

旧 Primary 故障
      ↓
选择一个 Replica
      ↓
提升为新 Primary
      ↓
其他 Replica 改为复制新 Primary
      ↓
客户端发现新地址

Sentinel 自己也要多实例部署,否则“负责高可用的组件自己是单点”没有意义。

需要明确:Sentinel 提供自动故障转移,但不会把异步复制 magically 变成强一致。故障切换期间仍然要考虑未复制完成的最新写。


15. Redis Cluster:把 keyspace 分散到多个主节点

当单机容量或单核命令吞吐达到瓶颈时,需要把数据分片。Redis Cluster 使用 16384 个 hash slots。一个 key 经过哈希后映射到某个 slot,再由当前负责该 slot 的主节点处理。

抽象为:

key -> hash slot -> shard/node

客户端通常需要感知 slot 到节点的映射,并根据 MOVED/ASK 等响应更新路由。

15.1 为什么 Cluster 不能让一个热 key 自动用多个主节点一起算

因为单个 key 只能属于一个 slot,一个 slot 在某时刻由一个主分片负责。因此 Cluster 可以把不同 key 分散到多个主节点,但无法自动把一个巨大 Hash 或一个超热 key 的内部元素拆到多个节点。

要解决单 key 热点,需要改 key 设计,例如:

counter:global:0
counter:global:1
...
counter:global:63

写请求分散到 64 个 key,读取时再聚合。

15.2 多 key 操作为什么有 slot 限制

如果一次原子操作涉及两个位于不同节点的 key,就从单机内存操作变成跨节点协调问题。Redis Cluster 对很多多 key 命令、事务、Lua 脚本要求相关 key 位于同一 slot。

可以使用 hash tag:

user:{42}:profile
user:{42}:quota

花括号内相同部分用于 slot 计算,使相关 key 落在同一 slot,从而支持需要的多 key 操作。但过度使用相同 hash tag 也会制造数据倾斜。


16. Redis 分布式锁:最小正确版本要包含所有权

最基础的加锁通常不是:

SETNX lock 1
EXPIRE lock 10

因为两条命令之间进程可能崩溃,留下永不过期锁。

更合理的是单条命令:

SET lock_key unique_token NX PX 10000

这里:

  • NX:只有 key 不存在时才设置;
  • PX 10000:10 秒租约,避免客户端永久持锁;
  • unique_token:标识锁的所有者。

释放时不能直接:

DEL lock_key

因为自己的锁可能已经过期,随后另一个客户端拿到了新锁,此时旧客户端执行 DEL 会删掉别人的锁。

正确释放需要原子地:

如果 GET lock_key == my_token
    DEL lock_key

通常用 Lua 把比较和删除放在一次原子执行里。

还要继续问业务语义:锁超时后任务仍在执行怎么办;主从切换时锁是否可能丢失;是否需要 fencing token 防止旧持有者继续写下游资源。分布式锁不是“SETNX 成功就万事大吉”。


17. 线上 Redis 变慢时应该拿什么证据

一个可执行的排查顺序可以是:

17.1 先确认现象

看客户端和服务端:

p50 / p95 / p99 latency
超时率
连接池等待
Redis CPU
Redis 网络吞吐
used_memory / maxmemory

17.2 看 Redis 本身的统计

INFO
INFO memory
INFO stats
INFO commandstats
INFO latencystats
INFO replication
INFO persistence
SLOWLOG GET
LATENCY LATEST

需要关注:

  • 是否某类命令调用量突然上升;
  • 是否出现慢命令;
  • 是否正在 BGSAVE / AOF rewrite;
  • 是否达到 maxmemory 并频繁 eviction;
  • 是否存在大量 expired keys;
  • 是否某个 shard CPU 独高;
  • replication lag 是否上升。

17.3 再定位数据形态

可使用 MEMORY USAGE、redis-cli --memkeys、bigkey/hotkey 相关工具识别异常 key。MONITOR 影响较大,只适合谨慎短时使用,不应该作为第一选择。

证据链应类似:

某 shard CPU 95%
  -> commandstats 显示 HGETALL 调用暴涨
  -> slowlog 出现单次几十毫秒 HGETALL
  -> memkeys 找到 80 MB Hash
  -> 业务刚上线全量拉取接口

这比“Redis 是不是网络抖了”有用得多。


18. 一个完整可推演例子:热点缓存过期导致 p99 爆炸

某模型详情接口正常时:

总读取 QPS = 20,000
热点 model:42 = 6,000 QPS
DB 能稳定承受该查询约 1,500 QPS
Redis TTL = 30 分钟

正常状态:

6,000 QPS -> Redis -> 命中

30 分钟后 model:42 过期。如果没有保护:

请求 1 cache miss -> DB
请求 2 cache miss -> DB
请求 3 cache miss -> DB
...
短时间大量请求一起回源

DB 容量只有 1,500 QPS,很快连接池排队,接口 p99 急剧上升。

如果使用单飞回源:

请求 1:拿到 rebuild 锁 -> DB -> SET Redis
请求 2..N:等待 / 读旧值 / 快速失败

此时回源并发从几千下降到 1 或一个很小的上限。

如果这个模型一直是超热点,还可以进一步做本地缓存:

进程内缓存 TTL 1~5 秒
       ↓ miss
Redis
       ↓ miss
DB

本地缓存即使只缓存几秒,也可以把数千 QPS 的同 key Redis 请求压缩很多。但代价是每个应用实例都有自己的短期副本,一致性窗口会更大,因此需要根据字段是否允许短暂旧值决定。



参考资料

  1. Redis data types https://redis.io/docs/latest/develop/data-types/

  2. Memory optimization / small aggregate encodings https://redis.io/docs/latest/operate/oss_and_stack/management/optimization/memory-optimization/

  3. Redis persistence: RDB / AOF https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/

  4. Key expiration https://redis.io/docs/latest/commands/expire/

  5. Key eviction https://redis.io/docs/latest/develop/reference/eviction/

  6. Redis pipelining https://redis.io/docs/latest/develop/using-commands/pipelining/

  7. Redis transactions https://redis.io/docs/latest/develop/using-commands/transactions/

  8. Diagnosing latency issues https://redis.io/docs/latest/operate/oss_and_stack/management/optimization/latency/

  9. Redis Sentinel https://redis.io/docs/latest/operate/oss_and_stack/management/sentinel/

  10. Redis replication https://redis.io/docs/latest/manual/replication/