Redis 核心机制汇总

从数据结构、事务与 Lua、缓存一致性到集群与内存管理的整体梳理

Posted by Ekko on July 14, 2024

这篇笔记的目标,是把 Redis 的高频问题和工程实践放到一张图里理解:它是什么、适合存什么、事务和 Lua 的边界在哪里、缓存一致性怎么收敛、集群和内存管理要关注什么。

内容以概念梳理、关键差异辨析和工程边界说明为主。结构上尽量按“基础能力 -> 运行机制 -> 工程问题”的顺序收口,避免只堆术语不解释原因。

参考资料:

官方文档:Redis DocumentationRedis Data TypesRedis Transactions

高可用与持久化:Redis PersistenceRedis ReplicationRedis SentinelRedis Cluster Specification

可编程与消息:Redis Lua ScriptingRedis FunctionsRedis StreamsRedis Pub/Sub

实践参考:Distributed Locks with Redis看完这20道Redis面试题,阿里面试可以约起来了

[TOC]


1.什么是Redis,Redis有哪些特点

Redis 全称为 Remote Dictionary Server。更准确地说,它是一种内存数据结构存储系统:所有数据都以 key-value 的形式组织,但 value 可以承载多种数据结构,而不只是简单字符串。

Redis 常被用作缓存,也常出现在计数器、排行榜、消息队列、会话共享、发布订阅等场景中。

它的核心特征可以概括为:基于内存、支持丰富数据结构、单命令原子执行、可通过 RDB/AOF 持久化到磁盘。

特点1:丰富的数据类型

很多数据库只能处理一种数据结构:

  • 传统SQL数据库处理二维关系数据
  • Memcached 数据库,键和值都是字符串
  • 文档数据库(MongoDB)是由 JSON/BSON 组成的文档

不是他们这些数据库不好,而是一旦数据库提供数据结构不适合去做某件事情的话,程序写起来就非常麻烦和不自然

Redis 虽然也是键值对数据库,但是和 Memcached 不同的是:Redis 的值不仅可以是字符串,还可以是其他多种数据结构中的任意一种。

通过选用不同的数据结构,Redis 可以覆盖很多原本实现起来并不自然的业务场景。面对具体问题时,通常先要判断更适合哪一种数据结构,再决定如何落地对应能力。

特点2:内存存储

数据库有两种:一种是硬盘数据库,一种是内存数据库

硬盘数据库是把值存储在硬盘上,在内存中就存储一下索引,当硬盘数据库想访问硬盘的值时,它先在内存里找到索引,然后再找值

问题在于,在读取和写入硬盘的时候,如果读写比较多的时候,它会把硬盘的IO功能堵死

内存存储是将所有的数据都存储在内存里面,数据读取和写入速度非常快

特点3:持久化功能

将数据存储在内存里面的数据保存到硬盘中,保证数据安全,方便进行数据备份和恢复


2.Redis有哪些数据结构?

Redis 是 key-value 数据库,key 的类型只能是 String,但 value 的数据类型很丰富。高频场景里最常见的是五种基础结构,工程实践里还会经常碰到 Stream 以及基于底层结构扩展出的 Bitmap、HyperLogLog、GEO 等能力。

  1. String
  2. Hash
  3. List
  4. Set
  5. Sorted Set
  6. Stream(Redis 5.0 引入,适合消息流场景)
数据结构 核心特征 典型命令 典型场景 选型提醒
String 最基础的二进制安全值类型 SET GET INCR 缓存对象、计数器、分布式锁 值过大或字段频繁局部更新时不够灵活
Hash field-value 结构,适合对象属性拆分 HSET HGET HINCRBY 购物车、对象属性、统计维度 字段非常多时要关注大 key 问题
List 有序、可重复、两端操作高效 LPUSH RPUSH LPOP BRPOP 简单队列、时间线、定时榜单 不适合按 score 实时排序
Set 无序、元素唯一 SADD SREM SMEMBERS 标签、去重、共同好友、收藏关系 需要排序时应考虑 Sorted Set
Sorted Set 元素唯一且带 score 排序 ZADD ZRANGE ZINCRBY 实时排行榜、延时任务、权重排序 score 设计决定排序含义
Stream 带消息 ID、消费组与待确认列表 XADD XREADGROUP XACK 消息流、消费组、可追踪异步处理 适合中等规模消息流,不等价于专业 MQ

Redis数据结构.png


String 字符串

1
SET KEY_NAME VALUE

String 类型是二进制安全的,意味着 Redis 的 String 可以包含任意字节序列,比如 JPG 图片或者序列化后的对象。

String 类型是 Redis 最基本的数据类型,一个键最大能存储 512MB


String 使用场景

信息缓存、计数器、分布式锁等等

常用命令:get/set/del/incr/decr/incrby/decrby

实战场景1: 记录每一个用户的访问次数,或者记录每一个商品的浏览次数

常用键名:userid:pageview 或者 pageview:userid

如果某个用户的 id 为 123,那么对应的 Redis key 就可以写成 pageview:123

value就为用户的访问次数

增加次数可以使用命令:incr

使用理由:每一个用户访问次数或者商品浏览次数的修改都很频繁,如果直接落到 MySQL 这类磁盘数据库上,会带来明显写入压力,效率也更低。

redis 的好处有二:使用内存,访问速度快;同时很多常见更新可以直接用原子命令完成,不需要先查再改,天然适合计数、限流这类场景。

实战场景2: 缓存频繁读取,但是不常修改的信息,如用户信息,视频信息

业务逻辑上:先从 Redis 读取,有值就直接返回;没有则从 MySQL 读取,并写一份到 Redis 中作为缓存,同时设置过期时间。

通常会将一条 MySQL 用户记录序列化为 JSON 作为 value

例如使用 userInfo:123 作为 key,value 存储对应用户信息的 JSON 字符串。

实战场景3: 限定某个ip特定时间内的访问次数

用key记录IP,value记录访问次数,同时key的过期时间设置为60秒

如果key过期了则重新设置,否则进行判断,当一分钟内访问超过100次,则禁止访问

实战场景4: 分布式 Session

session是以文件的形式保存在服务器中的

如果应用做了负载均衡,将网站的项目放在多个服务器上,当用户在服务器A上进行登陆,session文件会写在A服务器

当用户跳转页面时,请求被分配到B服务器上的时候,就找不到这个session文件,用户就要重新登陆

想要多个服务器共享一个session,可以将session存放在redis中,redis可以独立于所有负载均衡服务器

也可以放在其中一台负载均衡服务器上;但是所有应用所在的服务器连接的都是同一个redis服务器


Hash 哈希

1
HSET KEY_NAME FIELD VALUE

Redis hash 是一个键值(key=>value)对集合

Redis Hash 是一个 String 类型的 field 和 value 的映射表,特别适合用于存储对象。


Hash 使用场景

实战场景1: 购物车

用户id设置为key,那么购物车里所有的商品就是用户key对应的值了

每个商品有id和购买数量,对应hash的结构就是商品id为field,商品数量为value

将商品 id 和商品数量序列化成 JSON 字符串,也可以用上面讲的 String 类型存储

当对象的某个属性需要频繁修改时,不适合用string+json

因为它不够灵活,每次修改都需要重新将整个对象序列化并赋值

如果使用hash类型,则可以针对某个属性单独修改,没有序列化,也不需要修改整个对象

比如,商品的价格、销量、关注数、评价数等可能经常发生变化的属性,就适合存储在hash类型里


List 列表

1
2
3
4
5
6
7
8
9
10
11
//在 key 对应 list 的头部添加字符串元素
LPUSH KEY_NAME VALUE1.. VALUEN

//在 key 对应 list 的尾部添加字符串元素
RPUSH KEY_NAME VALUE1..VALUEN

//对应 list 中删除 count 个和 value 相同的元素
LREM KEY_NAME COUNT VALUE

//返回 key 对应 list 的长度
LLEN KEY_NAME 

Redis 列表是简单的字符串列表,按照插入顺序排序

可以添加一个元素到列表的头部(左边)或者尾部(右边)


List 使用场景

列表本质是一个有序的,元素可重复的队列

实战场景1: 定时排行榜

list类型的lrange命令可以分页查看队列中的数据

可将每隔一段时间计算一次的排行榜存储在list类型中

例如 QQ 音乐内地排行榜,每周计算一次后存储在 List 类型中

访问接口时通过page和size分页转化成lrange命令获取排行榜数据

并不是所有的排行榜都能用list类型实现,只有定时计算的排行榜才适合使用list类型存储

实时计算的排行榜 有序集合sorted set


Set 集合

1
SADD KEY_NAME VALUE1...VALUEn

Redis 的 Set 是 String 类型的无序集合

集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是O(1)


Set 使用场景

集合的特点是无序性和确定性(不重复)

实战场景1: 收藏夹

例如 QQ 音乐中的“喜欢”能力,本质上就可以建模为一个收藏集合

每一个用户做一个收藏的集合,每个收藏的集合存放用户收藏过的歌曲id

key为用户id,value为歌曲id的集合


Sorted Set 有序集合

1
ZADD KEY_NAME SCORE1 VALUE1.. SCOREN VALUEN

Redis zset 和 set 一样,也是 String 类型元素的集合,且不允许重复的成员

不同的是每个元素都会关联一个double类型的分数

redis正是通过分数来为集合中的成员进行从小到大的排序

zset的成员是唯一的,但分数(score)却可以重复


Sorted Set 使用场景

有序集合的特点是有序,无重复值

与set不同的是sorted set每个元素都会关联一个score属性

redis正是通过score来为集合中的成员进行从小到大的排序

实战场景1: 实时排行榜

QQ 音乐中有多种实时榜单,比如飙升榜、热歌榜、新歌榜

可以用 Redis key 存储榜单类型,score 为点击量,value 为歌曲 id

用户每点击一首歌曲会更新redis数据,sorted set会依据score即点击量将歌曲id排序


补充:Redis 中常见的扩展能力

上面五种基础结构加上 Stream,已经覆盖了大多数日常场景;再往下学时,通常还会遇到下面这些“能力型”特性:

  • Bitmap:底层通常基于 String 的位操作能力,适合签到、活跃标记、布尔状态压缩存储。
  • HyperLogLog:适合做近似去重统计,例如 UV 统计,优点是占用空间极小,但结果是概率估计值。
  • GEO:底层基于有序集合实现,适合附近的人、门店距离、地理范围检索。
  • Stream:适合消息流和消费组场景,支持消息 ID、消费确认、消费者组等能力。

理解这些能力的关键,不只是记住名字,而是知道它们和底层基础数据结构之间的映射关系。


3.Redis 的线程模型

经常会听到“Redis 是单线程的”这种说法,但更准确的表述应该是:

  1. Redis 的命令执行主路径主要是单线程串行处理的。
  2. Redis 采用 IO 多路复用机制同时监听多个 socket,根据 socket 上的事件选择对应处理器。
  3. 从 Redis 4.0/6.0 开始,部分工作已经可以交给后台线程或 IO 线程,例如异步删除、AOF 刷盘、网络读写等。

所以,Redis 快并不只是因为“单线程”,而是因为下面几个因素叠加在一起:

  • 纯内存操作,避免大量磁盘 IO
  • 核心命令执行路径短,数据结构设计针对高频场景做了优化
  • 基于非阻塞 IO 多路复用机制,单个线程就能高效处理大量连接
  • 主线程串行执行命令,省去了锁竞争和频繁上下文切换

需要注意的是,KEYS、Lua 脚本、大 key 删除、复杂聚合等耗时操作,依然会阻塞命令执行主路径。


4.Redis有事务机制吗

有。Redis 事务以 MULTIEXECDISCARDWATCH 为核心命令,生命周期大致可以拆成下面几步:

  1. 开启事务:使用 MULTI 开启一个事务。
  2. 命令入队:后续命令会进入队列,此时不会立即执行。
  3. 提交事务:使用 EXEC 提交事务,Redis 会按顺序执行队列中的命令。
  4. 放弃事务:使用 DISCARD 清空队列并退出事务状态。

如果要实现类似 CAS 的乐观锁语义,还可以在 MULTI 之前使用 WATCH 监视一个或多个 key。


5.Redis事务到底是不是原子性的

这个问题要分两层来看。

从 Redis 官方文档的角度看,事务有两个重要保证:

  • 事务中的命令会被序列化、按顺序执行,中间不会插入其他客户端的命令。
  • 如果客户端在 EXEC 之前断线,那么队列里的命令都不会执行;如果成功调用 EXEC,队列里的命令会被依次执行。

但是从关系型数据库 ACID 的“原子性 + 回滚”语义来看,Redis 事务并不等价于传统数据库事务:

  • 排队阶段出错:例如命令语法错误、参数个数错误。Redis 会在 EXEC 时拒绝执行整个事务。
  • 执行阶段出错:例如把列表命令作用在字符串 key 上。Redis 不会回滚已执行成功的命令,也不会停止后续命令。

所以更稳妥的结论是:Redis 事务具备隔离性和整体提交语义,但不具备关系型数据库那种可回滚的完整原子性。


6.Redis为什么不支持回滚

在事务运行期间,虽然 Redis 命令可能会执行失败,但 Redis 依然会执行事务内剩余命令,而不会执行回滚。

Redis 官方给出的核心理由可以概括为两点:

  1. 很多错误其实可以提前发现,例如语法错误、参数错误,会在入队阶段被识别出来。
  2. 对运行时错误提供完整回滚机制,会显著增加实现复杂度,也会影响 Redis 一直强调的简单性和性能。

另外一个常见观点是:“程序有 bug 怎么办?” 这个问题本身也说明,回滚并不能解决所有业务错误。

比如某位程序员本来打算更新键 A,结果错误地更新了键 B,即使 Redis 具备回滚机制,也无法自动理解“业务上真正想改的是谁”。

因此 Redis 的设计取舍是:把事务做成轻量级的顺序执行模型,把更多一致性控制交给应用层、WATCH、Lua 脚本或更上层的业务补偿机制。


7.Redis事务相关的命令有哪几个

WATCH

Redis事务提供 check-and-set (CAS)行为

被WATCH的键会被监视,并会发觉这些键是否被改动过了

如果有至少一个被监视的键在 EXEC 执行之前被修改了, 那么整个事务都会被取消, EXEC 返回nil-reply来表示事务已经失败


MULTI

用于开启一个事务,它总是返回OK

MULTI执行之后,客户端可以继续向服务器发送任意多条命令, 这些命令不会立即被执行,而是被放到一个队列中,当 EXEC命令被调用时, 所有队列中的命令才会被执行


UNWATCH

取消 WATCH 命令对所有 key 的监视,一般用于DISCARD和EXEC命令之前

如果在执行 WATCH 命令之后, EXEC 命令或 DISCARD 命令先被执行了的话,那么就不需要再执行 UNWATCH 了

因为 EXEC 命令会执行事务,因此 WATCH 命令的效果已经产生了

而 DISCARD 命令在取消事务的同时也会取消所有对 key 的监视,因此这两个命令执行之后,就没有必要执行 UNWATCH 了


DISCARD

当执行 DISCARD 命令时, 事务会被放弃, 事务队列会被清空,并且客户端会从事务状态中退出


EXEC

负责触发并执行事务中的所有命令

如果客户端成功开启事务后执行EXEC,那么事务中的所有命令都会被执行

如果客户端在使用MULTI开启了事务后,却因为断线而没有成功执行EXEC,那么事务中的所有命令都不会被执行

需要特别注意的是:即使事务中有某条/某些命令执行失败了,事务队列中的其他命令仍然会继续执行,Redis不会停止执行事务中的命令,更不会像通常使用的关系型数据库一样进行回滚


8.Redis 持久化

Redis 虽然以内存为主,但并不意味着“重启就一定丢数据”。是否能恢复、会丢多少数据,取决于持久化配置。

RDB

RDB 是快照方式,按时间点把内存中的数据生成一份紧凑的 dump 文件。

  • 优点:文件紧凑、适合备份、恢复速度快
  • 缺点:两次快照之间如果实例宕机,期间新增的数据可能丢失

AOF

AOF 是追加日志方式,把写命令按协议格式追加到文件中。

  • 优点:更容易做到更小的数据丢失窗口
  • 缺点:文件通常比 RDB 更大,恢复时需要重放命令

常见刷盘策略:

  • appendfsync always:每条写命令都刷盘,最安全也最慢
  • appendfsync everysec:默认常见选择,性能和可靠性相对均衡
  • appendfsync no:由操作系统决定刷盘时机,性能最好但风险更高

AOF 重写与混合持久化

AOF 的问题不只是“能不能恢复”,还包括文件会不会不断膨胀。

Redis 会通过 AOF 重写把一长串历史写命令压缩为更紧凑的恢复表示,降低文件体积和重放成本。这个过程并不是原地修改老文件,而是后台生成新的持久化文件,再在合适时机切换。

需要补充区分两个概念:

概念 作用 关注点
AOF 追加 把新的写命令持续写入 AOF 决定数据丢失窗口
AOF 重写 把当前数据集重写成更紧凑的恢复表示 决定文件体积与恢复效率

在较新的 Redis 版本中,还可以进一步结合混合持久化思路,用更紧凑的基线数据配合增量命令,兼顾恢复速度和持久化语义。

如何理解选择

如果更看重备份恢复速度,RDB 很常见;如果更看重数据持久性,通常会打开 AOF;很多线上环境会同时使用两者。


补充:主从复制与哨兵

只讨论 Cluster 容易把 Redis 的部署形态理解得过于单一。在线上环境里,通常还需要先分清主从复制、哨兵和 Cluster 分别在解决什么问题。

形态 主要解决的问题 是否支持分片 是否自动故障切换 适合场景
单机 最简单的缓存或状态存储 开发环境、小规模单实例
主从复制 读扩展、数据副本、基础高可用准备 读多写少、需要副本冗余
哨兵 监控主从、自动选主与通知客户端 单分片但希望自动故障切换
Cluster 分片扩容 + 高可用 数据量和写流量都超过单机上限

主从复制解决的是什么

主从复制的核心是:主节点负责写入,从节点复制主节点的数据。

它主要带来三类价值:

  1. 为数据提供副本,提高基础容灾能力。
  2. 让部分读流量可以分散到副本节点。
  3. 为故障切换提供候选节点。

但主从复制本身并不等于“自动高可用”,因为它并不会自行完成主节点故障检测、选主和客户端切换。

哨兵补的是哪一层能力

哨兵的职责不是分片,而是监控和故障转移。

可以把哨兵理解为 Redis 的高可用控制平面,它主要负责:

  • 监控主从节点是否存活
  • 在主节点故障时协调故障转移
  • 选出新的主节点
  • 通知客户端新的主节点地址
flowchart TD
    A[主节点] --> B[从节点1]
    A --> C[从节点2]
    D[Sentinel集群] --> A
    D --> B
    D --> C
    A --> E{主节点故障?}
    E -- 是 --> D
    D --> F[选举新的主节点]
    F --> G[通知客户端切换]

这里最容易混淆的一点是:

哨兵解决的是单主多从架构的高可用问题,不解决数据分片和容量线性扩展问题。

哨兵和 Cluster 的边界

这两者都带有故障切换能力,但关注点不同:

对比项 哨兵 Cluster
是否分片
key 路由 不涉及 slot 路由 基于 16384 个 slot
典型形态 单主多从 + 监控选主 多主多从 + 分片
核心价值 单分片高可用 扩容与高可用同时解决

如果数据量和写入压力都还在单机可承受范围内,但希望主节点故障后自动切换,那么主从复制 + 哨兵往往已经足够;只有在单机容量和写入吞吐成为瓶颈时,才需要进一步转向 Cluster。


补充:过期删除与内存淘汰

Redis 以“内存存储”为核心优势,但也正因为数据放在内存里,所以两个问题非常关键:

  • key 到期后,Redis 什么时候真正把它删掉
  • 内存打满后,Redis 到底淘汰谁

这两个问题经常被混在一起讨论,但它们不是一回事。

主题 关注点 触发条件
过期删除 已设置 TTL 的 key 什么时候失效并被清理 key 到达过期时间
内存淘汰 内存不够时保留谁、删掉谁 达到 maxmemory 限制

过期删除的 3 种理解方式

Redis 并不是“key 一到时间点就立刻后台精确删除”。

更准确地说,Redis 主要通过下面两类机制配合处理过期 key:

机制 含义 特点
惰性删除 访问 key 时才检查是否过期 节省 CPU,但可能让过期 key 暂时留在内存里
定期删除 Redis 周期性抽样扫描过期字典 能持续清理垃圾,但不保证瞬时清空所有过期 key

很多资料还会把“定时删除”一起列出来,即“给每个 key 单独建一个定时器,到点立刻删”。这个思路概念上很好理解,但如果为大量 key 都维护独立定时器,系统开销会非常高,所以 Redis 并不采用这种朴素实现作为主方案。

可以把过期删除理解成下面这条主路径:

flowchart TD
    A[key 设置了 TTL] --> B{请求是否访问该 key}
    B -- 是 --> C[惰性检查是否过期]
    C -- 已过期 --> D[删除并返回不存在]
    C -- 未过期 --> E[正常返回]
    B -- 否 --> F[等待定期扫描]
    F --> G{抽样扫描到该 key?}
    G -- 是且已过期 --> D
    G -- 否 --> H[继续留在内存中]

真正需要记住的是:

TTL 到期表示“逻辑上已经无效”,不等于“物理内存会在同一时刻立刻回收”。

为什么过期 key 还会短时间占内存

这是很多线上排查里非常容易困惑的一点。

比如已经给 key 设置了 60s 过期,60s 之后为什么内存看起来还没明显下降?

根源通常有两层:

  1. 这个 key 过期后没人访问,惰性删除没机会触发
  2. 定期删除是抽样式清理,不会为每个过期 key 立刻逐个精确回收

所以“key 过期了”和“内存马上下降”之间,不是严格等号关系。

内存淘汰解决的是什么问题

过期删除解决的是“该不该继续保留一个已经失效的 key”。

内存淘汰解决的是另一类问题:

即使很多 key 还没过期,但 Redis 可用内存已经不够了,这时该优先牺牲谁。

这时就要靠 maxmemory-policy

常见策略可以先按两类记忆:

类别 策略 含义
不淘汰 noeviction 不删数据,写请求直接报错
针对设置过期时间的 key volatile-lru / volatile-lfu / volatile-ttl / volatile-random 只在带 TTL 的 key 里挑选淘汰对象
面向所有 key allkeys-lru / allkeys-lfu / allkeys-random 整个键空间都可能成为淘汰对象

工程上最常见的直觉可以概括为:

  • 如果 Redis 里放的几乎都是缓存,通常更容易接受 allkeys-lruallkeys-lfu
  • 如果 Redis 里混有很多不该被随便淘汰的数据,就不能只靠淘汰策略“赌运气”
  • 如果业务不能接受因淘汰丢失缓存命中率波动,就需要提前做容量规划,而不是等打满了再临时处理

LRU、LFU、TTL 优先淘汰怎么理解

策略 核心思想 适合场景
LRU 更久没访问的 key 更可能被淘汰 访问局部性明显的普通缓存场景
LFU 访问频率低的 key 更可能被淘汰 热点 key 比较稳定、希望尽量保住高频热点
TTL 更快到期的 key 更可能先被淘汰 已普遍设置 TTL,且希望优先淘汰“本来就快过期”的数据

需要注意的是,Redis 的 LRU/LFU 更偏近似实现,目标是用更低成本取得足够好的淘汰效果,而不是追求教科书式的绝对精确。

过期删除和内存淘汰为什么经常一起被问

因为线上现象很容易混淆:

  • key 明明设置了 TTL,为什么内存还是很高
  • Redis 明明设置了过期时间,为什么还是触发淘汰
  • 缓存明明是临时数据,为什么写请求开始报错

这些问题背后,往往是下面几件事叠在一起:

  • 过期 key 没有被立刻物理回收
  • 业务新增写入速度很快
  • maxmemory 触发后开始执行淘汰策略
  • 策略与业务建模并不匹配

所以从排查角度,至少要同时看三件事:

  • 是否大量 key 根本没设置 TTL
  • 是否存在大量已过期但暂未清理的 key
  • maxmemory-policy 是否与当前 Redis 的角色匹配

工程上通常怎么选

Redis 角色 常见建议
纯缓存实例 明确设置 TTL,并优先考虑 allkeys-lruallkeys-lfu
混合用途实例 尽量拆分实例,避免把可淘汰缓存和关键状态硬塞在一起
强依赖写成功的实例 提前做容量规划,不要把 noeviction 触发当成常态

这一节真正要记住的是:

  • TTL 决定数据何时逻辑失效
  • 过期删除决定失效数据何时被清理
  • 内存淘汰决定内存不足时谁先被牺牲

9.集群模式

引入 Cluster 模式的原因,是单机 Redis 即使通过主从和哨兵解决了高可用问题,写流量依然集中在单个主节点上,在海量数据和高并发场景下容易遇到容量和写入瓶颈。

Redis Cluster 采用无中心结构,多个 master 共同承接写请求,并通过 replica 提供高可用能力。它的几个关键点如下:

  • Redis Cluster 把整个 key 空间划分为 16384 个 hash slot
  • 每个 master 负责其中一部分 slot,因此不同 key 可以分散到不同节点写入
  • replica 负责复制对应 master 的数据,并在故障时参与提升
  • 客户端访问错误节点时,会收到 MOVEDASK 重定向
  • 多 key 操作通常要求相关 key 落在同一个 slot,必要时可通过 hash tag 控制

可以把 Cluster 理解为“分片 + 高可用”同时解决的方案,而不是单纯把多个主从结构机械拼在一起。

集群模式.png

Cluster 模式里,一个主从复制组通常可以视为一个分片;多个分片共同构成整个集群。


10.遍历

以某个固定的已知的前缀开头遍历

1
keys pre*

KEYS 会一次性扫描整个键空间。由于 Redis 的命令执行主路径主要是单线程的,这种命令可能在生产环境造成明显阻塞,因此一般只适合开发环境或非常确定的数据量场景。

更常见的做法是使用 SCAN

1
SCAN 0 MATCH pre* COUNT 100

SCAN 的特点是分批返回结果,对服务更友好,但也要注意:

  • 它不是严格快照遍历
  • 可能返回重复 key,需要客户端自行去重
  • 一次调用不保证取全,需要根据游标持续迭代直到游标回到 0

11.Redis 内存碎片

graph LR
    A[Redis内存申请] --> B[分配器分配内存块]
    B --> C[数据写入]
    C --> D[数据删除/修改]
    D --> E[产生空闲内存块]
    E --> F{空闲块是否连续且足够大?}
    F -- 是 --> G[被新数据重用]
    F -- 否 --> H[内存碎片]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 内存碎片率 = 操作系统分配的内存 / Redis实际使用的内存
mem_fragmentation_ratio = used_memory_rss / used_memory

# 查看命令
redis-cli info memory

# 关键指标输出示例:
used_memory: 1073741824       # Redis分配的内存总量
used_memory_rss: 1610612736   # 操作系统看到Redis使用的内存
mem_fragmentation_ratio: 1.5  # 碎片率 = 1610612736 / 1073741824

# 其他相关指标:
mem_fragmentation_bytes: 536870912  # 碎片内存字节数
active_defrag_running: 0            # 是否正在执行碎片整理

1
2
3
4
5
6
# 碎片率含义:
mem_fragmentation_ratio < 1.0  # 内存交换到磁盘,性能极差
mem_fragmentation_ratio1.0  # 理想状态,几乎没有碎片
mem_fragmentation_ratio 1.0 - 1.5  # 正常范围
mem_fragmentation_ratio > 1.5  # 碎片较多,需要考虑优化
mem_fragmentation_ratio > 2.0  # 严重碎片,必须处理

Redis 使用 jemalloc 作为默认内存分配器

mem_fragmentation_ratio 只是观察指标,不要机械理解为“只要大于 1 就一定有严重碎片”。因为 RSS 里还可能包含分配器预留空间、复制缓冲区、页对齐等额外开销。

解决方法:

  1. 重启 Redis(最简单有效)

  2. 启用自动内存碎片整理(Redis 4.0+)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# redis.conf 配置
# 启用自动碎片整理
activedefrag yes

# 碎片整理触发条件
active-defrag-ignore-bytes 100mb          # 碎片达到100MB开始整理
active-defrag-threshold-lower 10          # 碎片率10%开始整理
active-defrag-threshold-upper 100         # 碎片率100%尽力整理

# CPU占用控制
active-defrag-cycle-min 5                 # 最小CPU使用百分比
active-defrag-cycle-max 75                # 最大CPU使用百分比

# 应用配置
redis-cli config set activedefrag yes
redis-cli config rewrite

12.Redis 碎片整理

flowchart TD
    A[Redis Server 运行中] --> B{监控碎片条件<br>active-defrag-ignore-bytes<br>active-defrag-threshold-lower};
    B -- 条件满足 --> C[启动自动碎片整理];
    C --> D[主线程循环中<br>增量执行碎片整理];
    D --> E[查找并搬运键值对<br>合并空闲内存块];
    E --> F{监控CPU使用<br>active-defrag-cycle-min/max};
    F -- CPU占用低 --> D;
    F -- CPU占用达到上限 --> G[暂停整理<br>等待下一个周期];
    G --> D;
    B -- 条件不满足 --> H[保持监控状态];

1、 查找待移动的键 Redis 会在自己的键空间中扫描,寻找那些值在物理内存中存储位置比较分散,或者其旁边有足够空闲空间的键。这部分工作会分多次执行,避免长时间阻塞

2、 搬运数据与释放旧空间 对于找到的符合条件的键,Redis 会为它重新分配一块连续的内存空间,然后将数据复制过去。接着,释放原来的内存空间。这个”释放”动作,是把空间交还给内存分配器(如jemalloc),后续可以重新利用

3、 合并空闲内存块 内存分配器在察觉到这些被释放回来的小块空闲内存,并且当这些空闲块相邻时,就会尝试将它们合并成一个更大的连续空闲内存块。这样,之后需要存储较大数据时,就有连续空间可用了


问题一:redis碎片整理,是主线程吗?会不会阻塞读写请求

总结:内存碎片整理主要是在事件循环中增量推进的,会与正常请求共享 CPU 时间,因此不是一次性长时间 stop-the-world,但仍然可能带来短暂抖动。

也就是说,它更接近“分多次做一点整理”,而不是“先停下来整理完再继续服务”。

1
2
3
4
5
6
7
8
9
10
11
12
13
1. CPU时间限制

# redis.conf 中的关键配置
active-defrag-cycle-min 5    # 最少使用5%的CPU时间进行整理
active-defrag-cycle-max 75   # 最多使用75%的CPU时间

2. 单次处理量限制
#define DEFRAG_MAX_SCAN 1000  // 每次循环最多处理1000个键
#define DEFRAG_MAX_SLOT 10    // 每次最多整理10个内存slot

3. 避免大键
# 如果一个Hash有100万个字段,整理它可能需要几十毫秒
# 在此期间,主线程会被这个键的整理操作占用
1
2
3
4
5
6
7
8
9
10
# 假设配置:active-defrag-cycle-max 50 # 最多使用50%的CPU时间

时间线 (1ms为单位)[请求][请求][整理][请求][整理][请求][请求][整理][请求]...
   1ms  1ms  1ms  1ms  1ms  1ms  1ms  1ms  1ms

# 在这个例子中:
# - 碎片整理占用约 30% 的CPU时间 (3/10)
# - 正常请求占用约 70% 的CPU时间 (7/10)
# - 每个整理操作都很短暂,不会长期阻塞

13.Redis 事务和 Lua 脚本怎么区分

核心结论

Redis 事务和 Lua 脚本都能把多步操作放到 Redis 服务端连续执行,但它们解决的问题并不完全相同。

这一部分可以先收敛为下面几个判断:

  1. Redis 事务更偏“多条命令顺序打包执行”。
  2. Lua 脚本更偏“把读、判断、写合并成一段原子逻辑”。
  3. 事务不方便直接依赖前一条命令的结果,Lua 脚本则天然适合这类场景。
  4. 两者都不是关系型数据库式的回滚事务。

Redis 事务是什么

Redis 事务的核心思想是将多个命令打包,然后顺序、连续地执行。

  • 工作原理:事务生命周期包含三个阶段:

    1. 开始事务 (MULTI):此后客户端发出的命令不会立即执行。

    2. 命令入队 (QUEUED):命令被放入一个队列中暂存。

    3. 提交/放弃 (EXEC/DISCARD):EXEC 命令会触发队列中所有命令的执行,而 DISCARD 则清空队列。

  • 乐观锁 (WATCH):这是一个关键机制。可以在 MULTI 之前 WATCH 一个或多个键。如果在事务执行前,这些键被其他客户端修改,那么当前事务将会失败。这为实现类似“检查-然后-更新”的原子操作提供了可能

1
2
3
4
5
6
7
客户端A:

WATCH money
MULTI
DECRBY money 20
INCRBY out 20
// 暂不执行 EXEC
1
2
3
客户端B(在A执行EXEC前):

SET money 1000      // 修改了被监视的键
1
2
3
4
5
客户端A:

EXEC

执行结果:(nil),表示事务执行失败,因为被监视的 money 值已被其他客户端更改
  • ACID 特性:

    1. 原子性:事务中的命令在 EXEC 执行时,会作为一个独立的操作序列运行,不会被其他命令打断。但需要注意,Redis事务在执行中发生错误时不会回滚。如果某个命令执行失败(例如对字符串使用 INCR),Redis 会继续执行队列中的后续命令。

    2. 隔离性:由命令串行执行保证,事务执行过程中不会被其他操作打断。

    3. 持久性:取决于 Redis 配置的持久化方式(RDB或AOF)

执行时错误:继续后续命令,不回滚、不自动中断整个事务


Lua 脚本是什么

Lua 脚本能够在 Redis 服务端原子性地执行一段自定义逻辑。

原子性的保证是 Lua 脚本最核心的优势:脚本执行期间,不会有其他命令插入执行,因此非常适合“先读再判断再写回”这一类依赖中间结果的逻辑。

graph TD
    A[客户端] --> B[发送整个Lua脚本]
    B --> C[Redis服务器接收脚本]
    C --> D[解析并编译Lua脚本]
    D --> E[在内存中执行所有Redis命令]
    E --> F[收集最终结果]
    F --> G[返回结果给客户端]
    G --> A

Lua 代码中的运行时错误会中断脚本执行;已经执行成功的写命令不会像关系型数据库事务那样自动回滚

Redis 事务和 Lua 脚本的核心差异

对比项 Redis 事务 Lua 脚本
执行单位 多条已入队命令 一段服务端脚本
是否可读中间结果 不方便直接依赖前一条命令结果 可以
执行期间是否被插队 不会 不会
执行时报错 可能继续后续命令 通常中断脚本
是否自动回滚 不支持 也不支持

怎么选择更合适

如果场景只是把几条命令按顺序连续提交,事务通常已经足够。

如果场景里包含:

  • 先读结果
  • 再做条件判断
  • 再决定后续写入

那么 Lua 脚本通常更合适。

Redis 7.0 之后,如果需要复用更长期、可管理的服务端逻辑,也可以继续了解 Redis Functions。

如果要继续看 Lua 的执行原理、Cluster 约束和线上踩坑点,可以直接接着阅读后面的 ## 16


14.高频实践问题

缓存穿透、击穿、雪崩

这三个问题经常放在一起问,但含义并不相同:

  • 缓存穿透:请求的数据本来就不存在,导致每次都打到后端数据库。常见做法是缓存空值、布隆过滤器、参数校验。
  • 缓存击穿:某个热点 key 在失效瞬间,大量并发请求同时回源。常见做法是互斥重建、逻辑过期、热点数据不过期。
  • 缓存雪崩:大量 key 在同一时间集中失效,造成后端瞬时压力暴涨。常见做法是给过期时间加随机值、多级缓存、限流降级。

如果放到排障和方案选择里看,这三者可以进一步压缩成下面这张表:

问题 本质 典型现象 常见治理手段
穿透 请求的数据本来不存在 大量 miss 持续打到 DB 空值缓存、布隆过滤器、参数校验
击穿 单个热点 key 在失效点被并发回源 某一时刻 DB/QPS 突增 互斥重建、逻辑过期、热点 key 保护
雪崩 大量 key 集中失效或缓存层异常 整体流量大面积回源 过期时间打散、多级缓存、限流降级

big key 和 hot key

  • big key 指单个 key 占用内存特别大,或成员特别多,删除、迁移、序列化时都可能引发卡顿。
  • hot key 指少数 key 被极高频访问,容易把流量打到单个分片或单个实例。

这两类问题都会直接影响 Redis 的稳定性,线上排障时通常会结合 --bigkeys、监控命令耗时、访问分布、慢查询日志一起观察。

问题 主要风险 常见信号 常见治理方向
big key 删除、迁移、序列化成本高,容易卡顿 RT 抖动、慢查询、迁移耗时长 拆 key、控制成员规模、渐进删除
hot key 热点流量集中到单个分片 单实例 CPU 飙升、局部热点明显 本地缓存、热点保护、分片打散、限流

15.缓存和数据库双写一致性怎么保证

核心结论

这个问题如果只用一句话概括:

缓存和数据库双写一致性,默认最推荐的起点通常不是“先更新缓存再更新数据库”,而是采用 Cache Aside 模式,在写请求里先更新数据库,再删除缓存;如果一致性要求更高,再补延迟双删、binlog 订阅、消息通知和读路径兜底。

进一步展开时,可以先归纳为:

  1. 先区分“强一致”还是“最终一致”,大多数业务只追求最终一致。
  2. 默认写路径优先用“更新数据库,再删除缓存”,而不是直接双写两个存储。
  3. 对高并发竞争窗口,可以增加延迟双删、消息失效通知或 binlog 订阅修正。
  4. 对极端一致性敏感接口,不要迷信缓存,必要时直接绕过缓存读库或做版本校验。

为什么这个问题本质上难

难点不在于“会不会删缓存”,而在于缓存和数据库是两个独立系统:

  • 先更缓存后更库,数据库失败会导致缓存脏数据。
  • 先更库后更缓存,并发读可能把旧值重新写回缓存。
  • 删除缓存成功与否、本地缓存是否同步失效、MQ 是否可靠,都会影响一致性窗口。

所以这个问题本质上考察的是:

如何把“双写”改造成“以数据库为准,缓存尽快收敛”的体系。


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

缓存更新顺序经常被放在一起比较。

方案 是否推荐作为默认方案 主要问题
先更新缓存,再更新数据库 不推荐 DB 失败时缓存已经变脏
先更新数据库,再更新缓存 也不推荐做默认起点 并发读写下容易把旧值再次写回缓存
先更新数据库,再删除缓存 最常见默认方案 仍有短暂竞争窗口,但整体最稳

因此更常见的工程结论通常是:

不是“更新 DB 后更新缓存”,而是“更新 DB 后删缓存,让下一次读请求回源重建缓存”。


为什么“更新数据库,再删缓存”更常用

因为它把缓存从“真值来源”降成了“可失效副本”。

典型流程如下:

sequenceDiagram
    participant Client as 写请求
    participant App as 应用服务
    participant DB as 数据库
    participant Cache as Redis

    Client->>App: 更新请求
    App->>DB: 更新数据库
    DB-->>App: 更新成功
    App->>Cache: 删除缓存
    Cache-->>App: 删除完成

这个方案的优点是:

  • 数据库永远是主数据源。
  • 缓存删除失败比“缓存被写成错误新值”更容易补救。
  • 下一次读可以自然回源重建。

它的问题在于仍存在并发窗口:

  1. 线程 A 更新数据库成功。
  2. 线程 B 恰好在删缓存前读取旧值。
  3. 线程 B 把旧值重新写回缓存。
  4. 线程 A 再删除缓存如果失败,就会留下脏值。

这也是为什么很多场景还要继续补“延迟双删”或异步失效通知。


常见落地方案怎么选

方案 适用场景 优点 风险或代价
更新 DB 后删缓存 默认首选 简单、稳定、最常见 有短暂并发窗口
延迟双删 高并发读写竞争较明显 缩小旧值回写窗口 延迟时间不好拍脑袋决定
binlog 订阅删缓存 多系统更新 DB、希望统一修正缓存 解耦写路径,修正能力强 依赖订阅链路和运维能力
MQ 通知失效本地缓存 多实例本地缓存 + Redis 适合两级缓存架构 系统复杂度更高
强一致读绕过缓存 极度敏感数据 一致性最直接 牺牲缓存收益

默认推荐通常是:

更新 DB -> 删除 Redis 作为基础方案;如果有本地缓存,再补消息通知失效;如果写路径很多且难统一,再补 binlog 驱动修正。


方案一:更新 DB 后删缓存

这是最常见、也最适合作为默认起点的方案。

典型流程如下:

sequenceDiagram
    participant Client as 写请求
    participant App as 应用服务
    participant DB as 数据库
    participant Cache as Redis

    Client->>App: 更新请求
    App->>DB: 更新数据库
    DB-->>App: 更新成功
    App->>Cache: 删除缓存

这个方案的核心思想是:

  • 数据库是真值源。
  • 缓存不是“被直接写入的新真值”,而是“等待失效后重建的副本”。

为什么它经常是默认首选:

优点 说明
逻辑简单 写路径容易统一
语义清晰 先改真值,再清副本
容错更好 删缓存失败比写错缓存值更容易补救

它真正的问题在于并发窗口,而不是主流程本身:

  1. 线程 A 更新数据库成功。
  2. 线程 B 恰好在缓存删除前读到了旧缓存。
  3. 线程 B 回源后又把旧值重新写进缓存。
  4. 如果线程 A 后续删缓存失败,旧值就可能继续留在缓存里。

所以这个方案不是完美无缺,但通常是复杂度和稳定性的平衡点。


方案二:延迟双删到底在删什么

延迟双删不是一个新架构,而是在“更新 DB 后删缓存”的基础上多补一刀。

标准流程通常是:

  1. 更新数据库。
  2. 立即删除缓存。
  3. 等待一小段时间。
  4. 再删除一次缓存。

可以先看它要解决的并发时序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
初始状态:
DB=新值前的旧数据
Cache=旧值

线程A(写):
1. 更新 DB 为新值
2. 删除 Cache

线程B(读):
3. 恰好在线程A删缓存前,读到了旧 Cache
4. 或者在线程A删完缓存后,回源时又读到了未更新完成前的旧结果
5. 线程B把旧值重新写回 Cache

线程A(补偿):
6. 延迟一小段时间后,再删一次 Cache
7. 把线程B误写回去的旧值再清掉

所以第二次删除删掉的不是“新值”,而是:

竞争窗口里被并发读请求重新写回去的旧值。

可以用下面这张图理解:

sequenceDiagram
    participant A as 写线程A
    participant B as 读线程B
    participant DB as 数据库
    participant Cache as Redis

    A->>DB: 更新为新值
    A->>Cache: 第一次删除缓存
    B->>Cache: 读取缓存未命中
    B->>DB: 回源查询
    B->>Cache: 把旧值或过渡态重新写回
    A->>A: 等待一小段时间
    A->>Cache: 第二次删除缓存

延迟双删真正解决的是:

能解决什么 为什么
缩小旧值回写窗口 第二次删除会把竞争窗口里的脏缓存清掉
提高高并发下的一致性体验 让脏数据存活时间更短

它解决不了什么:

解决不了什么 原因
数学意义上的绝对强一致 中间仍可能出现新的并发竞争
删除失败后的所有场景 第二次删除本身也可能失败
所有系统统一写库导致的失效问题 如果别的系统直接改库,这条链路根本没触发

延迟多久也不是拍脑袋决定的,通常要结合:

  • 数据库更新耗时
  • 读请求回源耗时
  • 应用线程调度延迟
  • 缓存重建逻辑耗时

所以更准确的结论是:

延迟双删是“缩小脏数据窗口”的补强手段,不是严格强一致方案。


方案三:binlog 订阅删缓存

当系统里不止一个写入口时,单靠应用代码里手动删缓存往往不够稳。

例如:

  • 后台管理系统直接改数据库
  • 定时任务批量修正数据
  • 其他微服务绕过当前应用直接写库

这时常见思路是:

  1. 所有写请求先落数据库。
  2. 订阅数据库 binlog 变更。
  3. 根据变更记录反推出需要删除哪些缓存 key。
  4. 异步执行缓存失效。

这种方案的核心价值在于:

优点 说明
写入口统一 不要求每个应用都手工删缓存
修正能力强 只要 DB 变了,缓存最终都会被修正
适合多系统协作 适合复杂系统的统一治理

它的代价也很明显:

  • 依赖 binlog 订阅链路稳定性
  • key 映射规则要设计清楚
  • 异步链路意味着它通常仍然是最终一致

所以这种方案更适合“写路径复杂、统一治理比局部简单更重要”的系统。


方案四:MQ 通知失效本地缓存

如果系统是 Caffeine + Redis + DB 这种两级缓存,问题就不止是 Redis 一层了。

即使 Redis 已经删掉,多实例上的本地缓存也可能还保留旧值。

常见做法通常是:

  1. 更新数据库。
  2. 删除 Redis。
  3. 发送缓存失效消息。
  4. 各应用实例收到消息后,删除本地缓存。

可以拆成下面这张表:

层级 处理方式 目标
数据库 先更新 DB 保证真值正确
Redis 删除或失效 key 清理共享缓存
本地缓存 通过 MQ、订阅通道或广播通知失效 清理各实例副本

这个方案适合:

  • 多实例部署
  • 本地缓存命中率很重要
  • 允许短暂最终一致,但希望尽量缩短多实例脏数据窗口

它的代价是系统复杂度上升,需要同时维护:

  • 缓存 key 规则
  • 失效消息可靠性
  • 消息幂等和补偿处理

方案五:强一致读绕过缓存

这个方案很朴素,但在真实系统里非常实用。

并不是所有接口都必须坚持“先查缓存再说”。对于极度敏感的读请求,可以直接选择:

  • 跳过本地缓存
  • 跳过 Redis
  • 直接查数据库
  • 或带版本号、时间戳校验后再决定是否相信缓存

更适合的场景通常有:

场景 为什么适合绕过缓存
支付完成后的订单结果页 用户刚完成关键操作,对旧值极敏感
券核销、库存确认页 短时间脏读代价很高
风控、额度、余额类查询 错读比慢一点更危险

它不是通用方案,但适合作为“关键时刻的兜底策略”。


经典并发异常案例

仅仅知道几种方案还不够,缓存双写一致性真正容易出问题的地方,通常都在并发时序里。

这一节把最常见的 3 类异常单独拆开。

为什么“先删缓存,再更新 DB”也很危险

一种直观但风险很高的写法如下:

  1. 先删除缓存。
  2. 再更新数据库。

看起来像是在避免脏缓存,实际上它也有明显风险。

典型问题是:

1
2
3
4
5
6
7
8
9
10
11
线程A(写):
1. 删除缓存
2. 还没来得及更新数据库

线程B(读):
3. 发现缓存 miss
4. 回源数据库,读到旧值
5. 把旧值重新写回缓存

线程A(写):
6. 最后才把数据库更新为新值

最终结果会变成:

  • 数据库已经是新值
  • 缓存却被线程 B 用旧值重新填回去了

这比“更新 DB 后删缓存”的窗口更危险,因为旧值回填几乎是必然会发生的。

可以概括成一句话:

“先删缓存,再更新 DB” 的问题在于删缓存把读请求提前放进来了,但此时数据库里的真值还没更新,读请求只能把旧值重新写回缓存。

读写并发下旧值回填的 3 种典型时序

旧值回填并不是只会以一种方式出现,常见至少有下面 3 种。

时序类型 发生过程 结果
先删缓存再更新 DB 写线程删缓存,读线程立刻回源旧 DB 并写回缓存 旧值重新进入缓存
更新 DB 后删缓存失败 写线程已改 DB,但缓存删除失败 缓存继续保留旧值
更新 DB 后删缓存成功,但并发读又重建旧值 读线程在竞争窗口里回源旧结果并回填 出现短时间脏缓存

其中第三种最容易让人误判,因为表面上流程已经是推荐顺序了,但仍然会在竞争窗口里出问题。

可以把第三种时序单独看一遍:

sequenceDiagram
    participant A as 写线程A
    participant B as 读线程B
    participant DB as 数据库
    participant Cache as Redis

    A->>DB: 更新数据库
    B->>Cache: 读取缓存
    Note over B,Cache: 此时可能还读到旧值\n或即将进入回源
    A->>Cache: 删除缓存
    B->>DB: 回源查询旧结果或过渡态
    B->>Cache: 把旧值重新写回

这也是为什么“更新 DB 后删缓存”虽然是默认最优起点,但在高并发场景里,很多系统还会继续补:

  • 延迟双删
  • 缓存失效消息
  • binlog 修正
  • 强一致接口绕过缓存

为什么有时明明已经删了缓存,还是读到了旧值

这类现象通常不是“缓存没删成功”这么简单,还可能有下面几种原因:

原因 说明
本地缓存没失效 Redis 已删,但应用实例内存里还有旧值
删除成功后又被旧读请求回填 竞争窗口导致脏值重新写入
多个写入口不统一 别的系统直接改了 DB,但没删缓存
主从或副本延迟 回源读没有读到最新结果
删除操作异步失败 应用以为删了,实际上没真正成功

所以线上看到“为什么删了缓存还有旧值”,第一反应不应该只盯着 Redis,而要把整条读写路径一起看。


删除缓存失败时怎么补偿最稳

删除缓存失败不是小问题,因为它意味着:

  • 真值已经在数据库里改了
  • 副本却还停留在旧版本

如果没有补偿,脏缓存可能会停留很久。

最简单的补偿思路:同步重试

最基础的方案是:

  1. 更新数据库成功后删除缓存。
  2. 如果删除失败,立即在当前请求里重试几次。

它的优点是实现简单,但问题也明显:

  • 当前请求时延会被拉长
  • 如果 Redis 短时故障,重试也可能全部失败
  • 应用线程被占住,不适合高并发链路

所以同步重试更适合作为第一层兜底,而不是唯一补偿手段。

更稳的做法:异步补偿任务

很多系统更常见的做法是:

  1. 主流程尝试删除缓存。
  2. 删除失败后,把失败记录写入补偿表或消息队列。
  3. 后台任务异步重试删除,直到成功或人工介入。

可以拆成下面这张表:

步骤 动作 目的
1 更新 DB 成功 保证真值已经正确
2 删除缓存 尝试主路径失效
3 删除失败则记录补偿任务 不让失败直接丢失
4 后台扫描任务重试删除 最终把脏缓存清掉
5 多次失败则告警 防止长期无人感知

这类方案的核心优势是:

  • 主请求不必无限阻塞
  • 删除失败不会悄悄吞掉
  • 可以形成“失败可追踪、最终可修正”的闭环

更进一步:binlog 修正作为最终兜底

如果系统已经有 binlog 订阅链路,那么最稳的体系通常不是只靠应用自己补偿,而是:

  • 写路径里先做“更新 DB -> 删除缓存”
  • 删除失败进入异步补偿
  • 即使补偿漏掉,binlog 订阅链路仍然能在后面再次修正缓存

也就是说,真正稳的做法通常是多层兜底:

层级 作用
同步删除 主路径第一时间清缓存
同步重试 解决瞬时网络抖动
异步补偿 保证失败不丢失
binlog 修正 给复杂系统最后一道统一修正能力

一个更完整的补偿闭环

flowchart TD
    A[更新数据库成功] --> B[删除缓存]
    B --> C{删除成功?}
    C -- 是 --> D[主流程结束]
    C -- 否 --> E[记录补偿任务]
    E --> F[异步任务重试删除]
    F --> G{是否成功}
    G -- 是 --> H[补偿完成]
    G -- 否 --> I[继续重试或告警]
    A --> J[binlog订阅链路]
    J --> K[再次修正缓存]

这个闭环的价值在于:

  • 主路径负责“尽快删”
  • 补偿任务负责“删失败了也不能算了”
  • binlog 负责“即使主路径漏掉,最终还能纠偏”

删除缓存失败时最怕什么

最危险的不是“失败一次”,而是下面几种情况:

情况 风险
失败后直接吞异常 脏缓存长期存在,系统毫无感知
只有重试没有记录 重试全失败后没有后续补偿依据
只有补偿没有告警 长时间失败没人知道
只有 Redis 删除,没有本地缓存失效 共享缓存是新值,本地实例仍然读旧值

所以更稳的结论通常是:

删除缓存失败不是“重试一下就行”,而是要形成“记录、重试、告警、最终修正”的完整闭环。


缓存穿透 / 击穿 / 雪崩和双写一致性的关系

这几个词经常会和“双写一致性”一起出现,但它们其实不是同一个问题层级。

更准确地说:

问题 核心矛盾 主要影响
双写一致性 数据库和缓存值是否一致 读到旧值、脏值、过渡态
缓存穿透 查询的数据根本不存在 请求不断打到数据库
缓存击穿 热点 key 恰好失效 瞬时大量回源打爆下游
缓存雪崩 大量 key 同时失效或缓存集群异常 整体流量一起压向数据库

所以它们的关系不是“谁包含谁”,而是:

双写一致性解决的是“值对不对”,穿透 / 击穿 / 雪崩解决的是“流量会不会把下游打穿”。

为什么它们经常一起出现

因为真实系统里,这几类问题会互相放大。

例如:

  • 写请求更新了数据库,但缓存删除失败,形成脏值。
  • 热点 key 又恰好过期,触发大量并发回源。
  • 这些回源请求如果没有重建保护,就会把数据库打出高延迟。
  • 一旦数据库变慢,读请求读到过渡态、旧值回填、延迟双删时机失准的问题都会进一步变严重。

也就是说:

  • 双写一致性偏“数据正确性”
  • 穿透 / 击穿 / 雪崩偏“系统抗压能力”
  • 两者在高并发场景下会互相耦合

这 4 类问题分别该怎么防

问题 常见防法 和双写一致性的连接点
穿透 空值缓存、布隆过滤器、参数校验 防止不存在的数据不断回源,把写后重建链路拖慢
击穿 热点 key 永不过期、互斥重建、singleflight 防止缓存刚删或刚过期时大量并发同时回源
雪崩 过期时间打散、多级缓存、限流降级、缓存集群高可用 防止大面积失效把数据库和一致性补偿链路一起压垮
双写一致性 Cache Aside、延迟双删、失效通知、binlog 修正 保证缓存值最终收敛到正确结果

可以把这张表记成一句话:

双写一致性关心“值”,穿透 / 击穿 / 雪崩关心“量”;工程上必须同时处理值和量。

一个最容易被忽略的连接点:热点 key 重建

缓存双写一致性里有一个高频场景:

  1. 写请求更新数据库。
  2. 应用删除缓存。
  3. 大量读请求同时发现缓存 miss。
  4. 所有请求一起回源数据库。

如果这个 key 恰好又是热点 key,那么这件事在工程上就不只是“一致性”,而已经演变成了“击穿”。

所以很多系统会把这两件事一起设计:

  • 写路径负责“更新 DB、删缓存”
  • 读路径负责“热点 key 重建保护”

旁路缓存 + 读写锁 / singleflight / 热点 key 重建保护

旁路缓存 Cache Aside 只定义了最基本的读写顺序:

  • 读时先查缓存,miss 后查 DB 并回填缓存
  • 写时先更新 DB,再删缓存

但如果读路径没有保护,缓存 miss 或缓存刚被删除时,热点 key 仍然可能把数据库打穿。

所以真正工程化的做法,通常要在旁路缓存外面再补“重建保护层”。

为什么旁路缓存本身还不够

看下面这个场景:

  1. 一个热点 key 被删除或过期。
  2. 瞬间来了 1000 个并发读请求。
  3. 这 1000 个请求都发现缓存 miss。
  4. 如果没有保护,它们会一起查数据库。

这时问题就来了:

  • 数据库被瞬时打爆
  • 慢查询增多
  • 回填顺序混乱
  • 旧值、新值、过渡态都有可能被重复写入缓存

所以这里真正要解决的,不只是“怎么回填”,而是:

同一时刻只能允许少数请求去做缓存重建,其它请求等待重建结果或直接复用已有结果。

读写锁在这里解决什么

最直观的思路是加一把锁,让同一个 key 的重建过程串行化。

典型流程是:

  1. 读请求发现缓存 miss。
  2. 尝试获取这个 key 的重建锁。
  3. 抢到锁的线程去查 DB 并回填缓存。
  4. 没抢到锁的线程等待一小段时间后重试缓存,或快速失败。

这种思路通常叫:

  • 互斥重建
  • per-key lock
  • 热点 key rebuild lock

它适合:

  • 热点 key 非常明显
  • 单 key 重建成本较高
  • 可以接受少量等待

它的问题也要讲清楚:

风险 说明
锁粒度过大 不同 key 互相阻塞,吞吐下降
锁超时设计不好 容易出现死等或误判
持锁线程挂掉 需要锁自动过期和兜底
等待线程过多 请求堆积、RT 抖动

singleflight 本质上是什么

singleflight 可以理解成:

同一个 key 的并发请求里,只让一个请求真正执行“查 DB / 重建缓存”,其它并发请求直接等待并复用这个结果。

它和“分布式锁”很像,但更偏应用层去重,而不是资源互斥的通用语义。

更直观地说:

  • 分布式锁强调“谁能做这件事”
  • singleflight 强调“同样的事不要重复做很多遍”

如果把场景放在缓存重建里,流程通常是:

flowchart TD
    A[多个请求同时访问热点 key] --> B{缓存命中?}
    B -- 是 --> C[直接返回缓存]
    B -- 否 --> D{是否已有重建进行中?}
    D -- 否 --> E[登记 singleflight]
    E --> F[单个请求查 DB 并回填缓存]
    F --> G[唤醒等待请求并复用结果]
    D -- 是 --> H[等待已有重建结果]
    H --> G

它的核心价值是:

价值 说明
抑制热点 key 并发回源 防止同一个 miss 同时打 DB
降低重复重建成本 同一结果只查一次数据库
比粗粒度锁更贴近业务语义 关注的是重复计算去重

热点 key 重建保护到底该怎么选

常见几种做法可以放在一起看:

方案 核心思路 适用场景 代价
本地互斥锁 单实例内同 key 只允许一个线程重建 单机或低复杂度服务 多实例下不能天然协同
分布式锁 多实例间同 key 只允许一个实例重建 分布式部署、热点 key 明显 锁管理复杂、时延更高
singleflight 同 key 并发请求结果复用 应用层去重最自然 需要自己维护等待/复用逻辑
逻辑过期 + 异步刷新 先返回旧值,后台异步重建 可以接受短暂旧值 强一致要求高时不适用

默认经验通常是:

  • 热点 key 明显时,至少要做同 key 级别的重建保护
  • 对可接受短暂旧值的场景,逻辑过期 + 异步刷新体验更平滑
  • 对一致性敏感场景,更偏向互斥重建或 singleflight

一个热点 key 的实战案例

假设商品详情页里有一个超热点 key:

  • product:1001

某次运营活动开始后:

  1. 后台修改商品价格。
  2. 应用更新 DB 并删除缓存。
  3. 下一秒大量请求同时访问 product:1001

如果没有重建保护,可能发生:

  • 成千上万请求同时回源 DB
  • 商品表瞬间被打满
  • 某些请求查到过渡态
  • 多个线程反复覆盖回填缓存

更稳的做法通常是:

层级 做法
写路径 更新 DB 后删除缓存
读路径 product:1001singleflight 或互斥重建
热点治理 热点 key 识别与限流
一致性补偿 删除失败进入异步补偿,binlog 最终修正

这样设计后:

  • 写路径保证真值更新
  • 读路径控制回源风暴
  • 补偿链路保证脏缓存最终被清理

这部分真正要记住什么

旁路缓存解决的是“基本读写顺序”,但并不自动解决热点重建风暴。

真正完整的工程化设计通常是:

  • 写路径:更新 DB,删除缓存
  • 读路径:热点 key 做互斥重建或 singleflight
  • 补偿路径:删除失败进入异步补偿与最终修正
  • 保护路径:对穿透、击穿、雪崩做额外治理

可以压缩成一句话:

双写一致性负责“值最终正确”,重建保护负责“热点 miss 时系统别被打穿”。


主从延迟 / 读写分离 / 副本延迟会怎样影响缓存一致性

这一组问题非常容易把“缓存不一致”和“数据库副本没追上”混在一起。

很多线上现象看起来像是:

  • 明明已经删了缓存,为什么还是读到旧值
  • 明明数据库已经更新成功,为什么回源查出来还是老数据
  • 明明缓存 miss 后已经去查库,为什么新缓存里还是旧值

这时候真正的问题,往往不一定在缓存,而可能在:

  • 写请求写了主库
  • 读请求却走了从库或只读副本
  • 从库复制还没追上主库

也就是说,回源读拿到的并不是真正的“最新真值”。

先区分 3 个概念

概念 含义 对缓存一致性的影响
主从延迟 主库已经提交,从库还没完成复制 回源读可能读到旧值
读写分离 写走主库,读优先走从库 删除缓存后重建时更容易读到副本旧值
副本延迟 广义上的只读副本、异地副本、分析副本没有及时同步 不只是 MySQL 主从,任何副本体系都可能出现旧读

可以概括成一句话:

缓存删掉以后,系统回源到数据库时,如果读的是“落后副本”,就会把旧值当成新值重新写回缓存。

一个最典型的错误时序

sequenceDiagram
    participant W as 写请求
    participant M as 主库
    participant C as Redis
    participant R as 读请求
    participant S as 从库

    W->>M: 更新为新值
    M-->>W: 提交成功
    W->>C: 删除缓存
    R->>C: 读取缓存 miss
    R->>S: 回源查询
    Note over S: 从库复制未追平
    S-->>R: 返回旧值
    R->>C: 把旧值重新写回缓存

这个时序说明了一件事:

  • 缓存删除动作本身可能完全成功
  • 但只要回源读走了落后副本,缓存仍然会被旧值重新污染

所以很多场景里真正的问题不是“缓存没删掉”,而是:

缓存重建读到了落后的数据库副本。

为什么它会把问题看起来变得更复杂

因为这类场景会同时叠加两层窗口:

窗口 含义
缓存并发窗口 删缓存和并发读之间的竞争时间
数据库复制窗口 主库提交到副本追平之间的时间

如果这两个窗口叠在一起,就会出现很迷惑的现象:

  • 删除缓存明明成功了
  • 读请求也确实 miss 后回源了
  • 但回源结果仍然是旧值
  • 旧值又被合法地重建进缓存

这也是为什么线上排查时,不能只盯着 Redis,而要同时看:

  • 当前回源读走的是主库还是从库
  • 从库复制延迟有多大
  • 是否有读写分离中间件自动路由读请求

对不同方案的具体影响

方案 遇到主从延迟时会怎样
更新 DB 后删缓存 删除后回源若读从库,容易把旧值重新写回缓存
延迟双删 可以缩短部分窗口,但不能消灭副本旧读
binlog 修正删缓存 最终能再次清掉脏缓存,但中间仍可能出现旧读窗口
强一致读绕过缓存 如果仍然读从库,也一样不强一致
本地缓存 + Redis 一旦旧值重建进 Redis,还可能继续扩散到本地缓存

这里最容易误判的是“强一致读绕过缓存”。

绕过缓存不等于一定读到最新值,只有在关键读请求明确走主库或带读后写一致策略时,才更接近强一致。

工程上通常怎么收敛这个问题

常见做法不是只靠一种手段,而是按敏感程度分层处理。

场景 常见处理方式
普通读请求 允许短时间最终一致,读从库即可
写后立即读 一段时间内强制读主库
支付结果、库存确认、余额查询 关键链路绕过缓存并强制走主库
热点 key 重建 重建时优先读主库或使用读后写一致策略
多副本复杂系统 结合版本号、时间戳或 binlog 事件做收敛

比较常见的几种工程化策略有:

  • 写后短时间内读主库
  • 给用户维持会话级读主一致
  • 热点 key 重建时不走只读副本
  • 缓存 value 带版本号,回填时做新旧比较
  • 副本延迟过大时降级到主库读取

一个订单状态的实战例子

假设订单支付成功后,系统执行下面这条链路:

  1. 主库把 order_statusUNPAID 改成 PAID
  2. 应用删除 order:{id} 缓存
  3. 用户立刻刷新订单页
  4. 订单页读请求因为读写分离被路由到从库
  5. 从库还没追平,返回 UNPAID
  6. 系统把 UNPAID 再次写回 Redis

这时候用户看到的现象就是:

  • 数据库主库里已经支付成功
  • 缓存里却又出现了未支付
  • 看起来像是缓存双写一致性出错

但本质上是:

缓存重建阶段用了落后副本,导致旧值被合法回填。

这一节真正要记住什么

缓存一致性问题并不总是“缓存写错了”,也可能是“缓存回源读错了”。

更准确地说:

  • 如果回源查询走的是落后副本,那么删缓存也无法保证下一次读一定拿到新值
  • 读写分离会把缓存一致性问题从“缓存层”扩展成“缓存 + 数据库复制链路”的组合问题
  • 对写后立即读、支付结果、库存确认这类敏感场景,关键不是只讨论缓存,而是要明确读请求是否强制走主库

一个订单详情缓存的案例

假设订单详情页读多写少,走 Redis 缓存,写请求会修改订单状态。

更稳的写法通常是:

步骤 动作 说明
1 更新 orders 以数据库为准提交真值
2 删除 order:{id} Redis 缓存 不直接更新缓存值
3 若存在本地缓存,则发送失效消息 通知各应用实例清理本地副本
4 读请求发现缓存 miss 后回源 DB 用新值重建缓存
5 删除失败则重试或异步补偿 防止脏缓存长期滞留

如果这个接口对一致性特别敏感,比如订单支付结果刚确认后的展示页,还可以在短时间内:

  • 直接查 DB
  • 或带版本号比对
  • 或暂时绕过本地缓存

常见误区

以为双写一致性就是“同时更新两个地方”

这个误区最常见的来源,是把“数据库”和“缓存”想成了两个地位完全对等的数据存储。

一旦这样理解,就很容易自然得出一个看起来很直观、但工程上很危险的结论:

既然两个地方都要有最新值,那写请求到来时就把两个地方一起更新掉。

这个想法的问题在于,它默认把缓存也当成了主写入目标,但缓存本质上不是主存储,而是为了提升读性能而引入的副本。

一旦把缓存和数据库都当成真值源,就会立刻遇到一组无法绕开的工程问题:

  • 先写谁
  • 后写谁
  • 中间失败怎么办
  • 并发读写时谁算最新
  • 重试时会不会把旧值再写回去

也就是说,所谓“双写一致性”最容易让人误解的地方,就是“看到有两个存储,就默认要同时写两个地方”。

但真正更稳的思路其实是:

只保留一个真值源,另一个只作为可失效副本存在。

在大多数缓存场景里:

  • 数据库是真值源
  • Redis 是副本

所以更稳妥的做法不是“同时更新数据库和缓存值”,而是:

  1. 先把数据库更新成功
  2. 再删除缓存
  3. 让后续读流量按新值重建缓存

这也是 Cache Aside 常被默认推荐的根本原因。

它不是完全没有并发窗口,而是主动减少“同时维护两个真值源”带来的复杂度。

这个误区为什么很容易出现

因为从业务视角看,开发者最先看到的是“页面既要快,又要新”。

于是很容易把问题误写成:

1
如何同时把 DB 和缓存都改成最新值

但从系统设计视角,真正的问题应该写成:

1
如何让缓存始终围绕数据库这个真值源进行失效和重建

问题定义不同,方案方向就会完全不同。

更稳的收敛方式是什么

更稳的收敛方式通常是下面这张表里的思路:

设计点 更稳的选择 原因
真值源 数据库 只有一个地方负责最终正确性
缓存职责 副本 可以删除、重建,不承担主写入责任
写路径 更新 DB,再删缓存 避免长期维护两个真值写入链路
失败处理 重试、补偿、订阅修正 让副本最终回到与真值一致

以为延迟双删是银弹

延迟双删之所以容易被误当成银弹,是因为它名字里带着“双删”,很容易让人产生一种错觉:

删了两次,旧值总该彻底消失了。

但延迟双删真正解决的,只是某一类并发窗口问题,而不是所有缓存一致性问题。

它的标准做法通常是:

  1. 更新数据库
  2. 立即删除缓存
  3. 等一小段时间
  4. 再删除一次缓存

第二次删除的目的,不是“做个保险动作”那么简单,而是专门针对下面这种竞争时序:

sequenceDiagram
    participant W as 写请求
    participant C as 缓存
    participant D as 数据库
    participant R as 读请求

    W->>D: 更新新值
    W->>C: 第一次删除缓存
    R->>C: 发现缓存 miss
    R->>D: 回源读取
    Note over D: 如果此时仍读到旧值或旧链路结果
    R->>C: 把旧值回填到缓存
    W->>C: 延迟后第二次删除缓存

这个方案的本质,是试图把“旧值被回填”的窗口再压缩一次。

为什么这个误区会出现

因为延迟双删在很多文章里经常被写成一句非常简化的话:

1
更新 DB 后删两次缓存,就可以解决一致性问题

这会让读者误以为:

  • 它适用于所有场景
  • 它可以消灭所有旧值回填
  • 只要多删一次就天然安全

但这些理解都不准确。

它解决不了什么

延迟双删通常解决不了下面这些问题:

  • 删除请求本身就没有成功发出去
  • 回源读走的是落后副本
  • 多级缓存里只有一层被删掉
  • 删除时间间隔拍脑袋配置,根本覆盖不到真实竞争窗口
  • 写路径很多,只有部分写路径实现了双删

也就是说,它只能缩小一部分时序竞争窗口,不能把系统自动升级成强一致。

更稳的做法是什么

延迟双删更适合作为增强手段,而不是唯一手段。

更稳的组合通常是:

  • 主方案仍然是 更新 DB,再删缓存
  • 删除失败要有重试和异步补偿
  • 关键写链路尽量统一出口
  • 如果写来源复杂,可以引入 binlog 订阅做最终兜底
  • 如果存在主从延迟,关键读路径要考虑是否强制读主

这样延迟双删才是在一个完整体系里发挥作用,而不是被单独神化。

以为删除缓存失败影响不大

这个误区往往来自一种过度乐观的想法:

反正缓存会过期,就算这次删失败了,过一会儿自然也会恢复。

问题在于,工程上的“过一会儿”可能根本不是一个可以接受的窗口。

如果删除失败后没有任何补救措施,旧值会继续留在缓存里,而读流量还会不断命中这份旧副本。

结果通常会表现成:

  • 用户持续看到旧数据
  • 不同接口返回不一致
  • 上游业务误以为数据库没更新成功
  • 排查时数据库明明是新值,但缓存长期还是旧值
为什么这个误区容易被忽略

因为删除缓存这个动作在代码里看起来往往很轻:

1
del(key)

很多实现里甚至只是“删一下,失败了打个日志”。

但从一致性视角看,删除缓存并不是一个“附带动作”,而是写链路里非常关键的一环。

因为整个 Cache Aside 模型成立的前提就是:

数据库写完以后,旧缓存必须被尽快移除,否则后续读流量没有机会重建新值。

也就是说,删缓存虽然不是写真值,但它承担的是“让副本失效”的职责;这一环失效,整个一致性链路就断了。

更稳的补救机制是什么

更稳的做法通常不是只依赖一次删除,而是形成闭环:

flowchart TD
    A[更新数据库成功] --> B[删除缓存]
    B --> C{删除是否成功}
    C -- 是 --> D[等待后续读请求重建缓存]
    C -- 否 --> E[同步重试]
    E --> F{重试是否成功}
    F -- 是 --> D
    F -- 否 --> G[写入异步补偿任务]
    G --> H[后台继续删除缓存]
    H --> I[必要时由 binlog 订阅兜底修正]

真正稳的重点不在“删一次”,而在:

  • 失败是否可观测
  • 是否会自动重试
  • 是否有异步补偿
  • 是否有最终一致性兜底

以为所有接口都必须依赖缓存

这个误区本质上是把“缓存”从一种性能手段误用成了强制架构约束。

很多系统一旦引入 Redis,就会出现一种惯性:

既然有缓存,所有读请求都必须先查缓存。

但这会把“性能最优”错误地等同成“架构唯一正确”。

实际上,接口的一致性要求是分层的。

有些接口非常适合依赖缓存,例如:

  • 商品详情页
  • 榜单
  • 推荐结果

但有些接口对“刚写完立刻读到新值”极度敏感,例如:

  • 支付结果确认页
  • 库存扣减后的确认读
  • 配额、额度、状态流转这类关键结果页

如果这些接口仍然机械地强依赖缓存,就很容易把原本只是“短暂副本延迟”的问题,放大成用户可感知的错误。

为什么这个误区经常出现

因为很多系统在做性能优化时,习惯先统一抽出一个缓存层,然后默认所有读请求都走这层。

这种做法在普通查询场景里很方便,但在一致性敏感场景里会掩盖一个关键事实:

并不是所有读请求都应该追求同一个优化目标。

有些读请求优先追求吞吐和 RT。

有些读请求优先追求最新值。

这两类接口不应该被同一套缓存策略强行绑定。

更合理的解决方式是什么

更合理的方式通常是分级治理:

接口类型 优先目标 更合适的读策略
普通高频查询 性能、吞吐 走缓存
热点数据展示 性能为主,一致性可短暂放宽 走缓存并设置保护策略
写后立刻读 最新值 短时间绕过缓存或强制读主
核心状态确认 正确性 优先查 DB,再决定是否刷新缓存

所以缓存不是“所有接口都必须经过的关卡”,而是应当服务于具体的一致性目标和性能目标。


结论归纳

缓存和数据库双写一致性,默认不建议采用“先更新缓存再更新数据库”,而是更常使用 Cache Aside。也就是写请求先更新数据库,再删除缓存,让下一次读请求回源重建缓存。因为数据库才是真值源,缓存只是副本。这个方案虽然仍然有并发窗口,但比直接双写两个地方稳定得多。如果并发竞争明显,可以补延迟双删;如果是多实例或两级缓存,还要通过 MQ 或订阅机制通知各实例失效本地缓存;如果写路径很多、来源复杂,也可以通过 binlog 订阅统一删缓存。对极度敏感的接口,则可以短时间绕过缓存直接查库。


小结

缓存和数据库双写一致性真正要记住的是:

  • 数据库是真值源,缓存是可失效副本。
  • 默认首选“更新 DB,再删缓存”。
  • 高并发场景用延迟双删缩小窗口。
  • 多级缓存场景要补失效传播。
  • 强一致接口必要时直接绕过缓存。

16.Redis Lua 脚本怎么理解

核心结论

Redis Lua 脚本的核心价值,不是“在 Redis 里写业务代码”,而是:

把一段必须连续完成的“读 + 判断 + 写”逻辑放到 Redis 服务端一次性执行,从而避免多次网络往返和并发竞争窗口。

进一步展开时,可以先归纳为:

  1. Lua 脚本本质上是 Redis 服务端的一段可执行逻辑。
  2. 脚本执行期间,Redis 不会插入执行其他命令,所以非常适合原子化的复合操作。
  3. 它比简单事务更适合“先读结果、再判断、再写回”的场景。
  4. 但它不是回滚事务,也不是跨节点事务,尤其在 Cluster 模式下只能在单节点、同 slot 范围内工作。
  5. 线上最常见的问题不是“不会写”,而是脚本执行太久、key 传递不规范、Cluster 跨槽、脚本缓存失效和 busy script。

Lua 脚本到底解决什么问题

如果没有 Lua,很多业务逻辑会拆成多条 Redis 命令:

  1. GET
  2. 再在客户端判断
  3. SETINCR

这样会有两个典型问题:

  • 多次网络往返,延迟更高
  • 在并发下容易出现竞态条件

一个最常见的例子是库存扣减:

1
2
3
4
客户端A:GET stock = 1
客户端B:GET stock = 1
客户端A:DECR stock
客户端B:DECR stock

如果逻辑分散在客户端执行,就可能把原本只能卖 1 件的库存卖成 -1

Lua 的价值就在于把它压缩成一段服务端逻辑:

1
2
3
4
5
6
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
if stock <= 0 then
    return 0
end
redis.call('DECR', KEYS[1])
return 1

这段脚本执行期间,Redis 不会插入别的命令,所以“读库存 -> 判断 -> 扣减”是一个连续动作。


Lua 脚本的执行原理

Lua 脚本不是在客户端执行,而是在 Redis 服务端执行。

可以把执行过程拆成下面几步:

flowchart TD
    A[客户端提交 EVAL 或 EVALSHA] --> B[Redis 接收脚本与参数]
    B --> C[校验 KEYS 和 ARGV]
    C --> D[Lua 解释器执行脚本]
    D --> E[脚本内部调用 redis.call 或 redis.pcall]
    E --> F[Redis 串行执行命令]
    F --> G[收集返回值]
    G --> H[返回给客户端]

这里有几个关键点:

含义
服务端执行 逻辑在 Redis 进程内完成,不需要多次客户端往返
单线程串行 脚本执行期间不会被其他命令插队
原子窗口 指的是脚本执行窗口内命令连续,不是数据库式回滚事务
返回值转换 Lua 类型会按 Redis 协议规则转换成返回结果

更准确地说,Lua 的“原子性”是:

同一个 Redis 实例上,脚本执行期间不会穿插其他命令。

它不等于:

  • 执行失败自动回滚
  • 跨多个 Redis 节点一起原子提交
  • 对外部 MySQL、MQ、HTTP 调用也原子

redis.callredis.pcall 有什么区别

这是 Lua 脚本里最容易被忽略,但非常实用的一组细节。

调用方式 行为 适用场景
redis.call Redis 命令出错时直接抛异常,中断脚本 希望失败立刻暴露
redis.pcall Redis 命令出错时返回错误对象,不直接中断脚本 希望自行处理分支错误

更常见的经验是:

  • 业务路径正常、错误不该被吞掉时,用 redis.call
  • 需要兼容某些可预期异常时,再考虑 redis.pcall

但要注意一点:

即使 Lua 脚本因为错误中断,也不代表已经执行成功的写命令会自动回滚。

这和关系型数据库事务是两回事。


EVALEVALSHASCRIPT LOAD 有什么差异

Lua 脚本线上使用时,通常不会只停留在 EVAL

方式 含义 特点
EVAL 直接提交脚本文本并执行 最直接,但脚本文本每次都随请求发送
SCRIPT LOAD 先把脚本加载到服务端缓存,返回 SHA1 适合重复执行同一脚本
EVALSHA 通过脚本 SHA1 执行已缓存脚本 网络开销更小,适合高频调用

常见流程通常是:

  1. 应用启动或首次调用时 SCRIPT LOAD
  2. 业务调用时优先 EVALSHA
  3. 如果遇到 NOSCRIPT,再退回 EVAL 或重新加载

这也是很多线上 SDK 的默认做法。

为什么会出现 NOSCRIPT

脚本缓存不是永久不丢的,下面几种情况都可能导致缓存失效:

  • Redis 重启
  • 执行了 SCRIPT FLUSH
  • 主从切换后新主节点没有这份脚本缓存
  • Cluster 中路由到了另一台之前没加载过脚本的节点

所以更稳的工程实践通常不是“只依赖一次 EVALSHA 调用”,而是:

EVALSHA 失败时自动回退重载。


Lua 脚本和事务、Functions 的差异

前面的 ## 13 已经给出了事务和 Lua 的基础对比,这里更关注它们在工程上的职责边界。

对比项 Redis 事务 Lua 脚本 Redis Functions
逻辑形态 命令批量入队 服务端脚本 服务端可复用函数
是否方便依赖中间结果 不方便 方便 方便
典型用途 多命令顺序执行 复合原子逻辑 长期维护的服务端逻辑
复用方式 每次重新发命令 常配合脚本缓存复用 以函数库形式管理
复杂度 最低 中等 更高

可以概括为:

  • 事务偏“顺序打包”
  • Lua 偏“带判断的原子逻辑”
  • Functions 偏“更长期、可管理的服务端逻辑封装”

如果场景只是“把几条命令连续发出去”,事务往往够用。

如果场景是“必须先读、再判断、再写”,Lua 更合适。


单机、主从、Cluster 模式下有什么差异

Lua 脚本在不同部署模式下,语义边界差异非常大。

单机模式

单机模式是最容易理解的。

  • 所有 key 都在同一个实例
  • 脚本原子窗口完整成立
  • 不存在跨节点路由和跨槽问题

只要脚本本身不太慢,行为通常最稳定。

主从模式

主从模式下,写脚本通常仍然在主节点执行。

核心影响有两点:

说明
写脚本仍走主节点 脚本里的写命令本质上还是主库写
从节点状态可能滞后 读请求若走从节点,仍然要面对复制延迟

所以 Lua 脚本虽然能保证主节点执行窗口内的原子性,但它并不能自动消灭:

  • 主从复制延迟
  • 读写分离带来的旧读
  • 故障切换后的脚本缓存缺失

也就是说:

Lua 解决的是单实例上的复合操作原子性,不解决副本同步时差。

Cluster 模式

Cluster 模式是 Lua 脚本最容易踩坑的地方。

因为 Redis Cluster 不是一个“全局单节点”,而是多个分片节点共同组成的集群。

脚本在 Cluster 里的核心限制可以概括为:

一次 Lua 脚本执行,仍然只能落在某一个节点上完成,不能把多个分片当成一个全局原子空间。

这意味着:

限制 说明
所有参与脚本的 key 必须在同一 slot 否则会报 CROSSSLOT 或直接无法执行
脚本不能跨多个 master 做全局原子逻辑 Cluster 没有跨分片事务语义
客户端必须正确声明 key Redis 需要据此决定把脚本路由到哪个节点

最常见的工程做法是:

  • 把同一业务对象的 key 设计到同一个 hash tag
  • 例如 order:{1001}:infoorder:{1001}:stock
  • 让相关 key 强制落到同一个 slot

这样脚本才能在 Cluster 里对这一组 key 做原子操作。

为什么 Cluster 会要求显式传 KEYS

Redis Cluster 需要先知道这次脚本访问哪些 key,才能:

  • 校验这些 key 是否同槽
  • 决定把脚本路由到哪个节点

所以脚本里涉及的 key,必须通过 KEYS[] 显式传入,而不是随意在脚本里拼接出新的 key 再访问。

更准确地说,工程上应该遵循:

1
2
-- 推荐:key 由 KEYS 传入
redis.call('GET', KEYS[1])

而不要依赖:

1
2
3
-- 不推荐:在脚本内拼出未知 key
local dynamicKey = ARGV[1] .. ':suffix'
redis.call('GET', dynamicKey)

在单机里这种写法也许“能跑”,但在 Cluster 里经常会带来路由和同槽校验问题。


Cluster 模式下最常见的几个误区

以为 Lua 天然支持跨节点原子操作

这是 Cluster 模式下最常见的认识偏差之一。

误区的根源在于,Lua 脚本在单机模式下看起来非常像“全局原子逻辑”,很容易让人顺势以为到了 Cluster 里也还能继续保持这种感觉。

但 Redis Cluster 的基本事实是:

  • 每个 master 只负责自己的一部分 slot
  • 不同 slot 的 key 分布在不同节点
  • 节点之间没有为 Lua 脚本提供跨分片事务协调器

所以 Lua 的原子性边界始终是:

单个 Redis 节点内部的执行窗口原子,而不是整个 Cluster 的全局原子。

这也是为什么下面这类想法在 Cluster 里天然就不成立:

  • 在脚本里同时修改两个不同业务分片的 key
  • 试图用一个脚本同时完成跨节点扣库存和记流水
  • 把 Lua 当成“分布式事务简化版”

Cluster 不会因为脚本是 Lua 就突然获得跨节点提交能力。

以为只要脚本里不多写几行,就不算跨槽

是否跨槽和“脚本长短”无关,关键在于脚本访问的 key 是否落在同一个 slot。

这里最容易误判的地方是:

  • 脚本里只写了两三条命令
  • 逻辑上看也只是“顺手改两个 key”
  • 但这两个 key 只要 hash slot 不同,本质上就是跨分片访问

也就是说,Cluster 判断的不是“脚本复杂不复杂”,而是:

这次脚本访问的 key 集合,能不能在同一个节点上一次性完成。

如果不能,Cluster 就不会替脚本做跨节点协调。

这一点需要进一步落到具体执行结果上理解,而不能只停留在抽象表述上。

Redis Cluster 面对 Lua 脚本时,真正的运行模型更接近下面三种情况:

情况 具体表现
所有 key 同槽 请求会被路由到对应节点,脚本在该节点上正常执行
key 明确跨槽 客户端或服务端会直接返回 CROSSSLOT 一类错误,脚本不会进入“跨节点各执行一部分”的状态
key 集合不透明 如果脚本里隐式拼 key,路由和校验边界会变模糊,问题通常表现为报错、行为受限或排查困难

最关键的一点是:

Cluster 不会把一个 Lua 脚本自动拆成“先去节点 A 执行一半,再去节点 B 执行另一半”。

也不会提供类似分布式事务协调器的能力,例如:

  • 先在节点 A 预执行
  • 再去节点 B 提交
  • 两边都成功后再统一确认
  • 一边失败时再自动回滚另一边

这些能力都不在 Redis Cluster 的脚本执行模型里。

明确跨槽时,最常见的表现是什么

最常见的表现就是直接报错,而且脚本根本不会按“跨多个节点分别执行”。

例如业务上想在一个脚本里同时访问:

1
2
user:{1001}:profile
order:{9001}:status

如果这两个 key 不在同一个 slot,那么 Cluster 不会帮脚本继续往下跑,而是会在路由或执行前置检查阶段直接拒绝这次请求。

实际结果不是:

1
2
3
节点A先执行 profile 相关命令
节点B再执行 order 相关命令
最后把结果拼起来返回

而是更接近:

1
2
3
发现这批 key 不能在同一个节点完成
直接返回跨槽错误
整次脚本调用失败

这也是为什么这里要强调“脚本不会被自动拆成跨节点协同执行”。

为什么说“脚本往往压根不会执行”

因为 Cluster 的设计前提就是:

  • 一次脚本调用必须能明确落到一个节点
  • 这个节点要能独立完成本次 key 访问

只要这个前提不成立,Cluster 就更倾向于拒绝请求,而不是冒险做“半执行”。

原因也很直接:

  • 一旦允许脚本跨多个节点分别执行,就要处理中间状态一致性问题
  • 还要定义失败补偿、部分成功、结果聚合等复杂语义
  • 这些能力会把 Redis Cluster 推向分布式事务协调器,而这不是它的设计目标

所以在显式跨槽的典型场景里,最值得记住的结论是:

常见结果就是直接报错,脚本整体不按预期执行,而不是部分成功、部分失败后再自动收敛。

用一张图看这个拒绝过程
sequenceDiagram
    participant App as 应用
    participant Cluster as Redis Cluster
    participant N1 as 节点A
    participant N2 as 节点B

    App->>Cluster: EVAL script KEYS=[keyA,keyB]
    Note over Cluster: 检查 keyA / keyB 是否可在同一节点完成
    Cluster-->>App: 如果跨槽,直接返回错误
    Note over N1,N2: 不会自动拆成脚本分别在两个节点协同执行

这张图想说明的核心不是“报哪个错误码”,而是:

在 Cluster 语义下,拒绝跨槽脚本调用,本身就是为了避免进入无法定义清楚的一半执行状态。

以为绕过 KEYS、在脚本里拼 key 更灵活

这种写法在 Cluster 下恰恰更危险,因为路由和 slot 校验都依赖调用侧显式提供 key。

这个问题的本质是“客户端视角的 key 集合”和“脚本真正访问的 key 集合”发生了脱钩。

例如调用侧只传了:

1
KEYS[1] = order:{1001}:info

但脚本内部又拼出了:

1
2
order:{1001}:stock
order:{1002}:log

这时就会出现几个问题:

  • 客户端路由判断不完整
  • Cluster 无法准确做同槽校验
  • 代码阅读者也很难知道脚本到底会访问哪些 key

所以在工程上,KEYS 不只是传参方式,更是:

  • 路由声明
  • 槽位声明
  • 访问边界声明

它本身就是 Cluster 运行机制的一部分。

以为脚本成功执行后,从节点立刻可见

脚本成功只代表主节点执行成功,不代表所有副本立刻追平。

这个误区之所以常见,是因为“脚本原子执行成功”很容易被误解成“整个主从体系已经同步完成”。

实际上这里是两层完全不同的事情:

层次 含义
脚本执行原子性 主节点内部执行窗口不被插队
主从复制可见性 从节点什么时候追上主节点的最新结果

脚本成功返回后,仍然可能出现:

  • 主库已经写成功
  • 从库还没收到复制流
  • 读请求如果走从库,仍然读到旧值

所以 Lua 并不会天然消灭:

  • 主从延迟
  • 读写分离旧读
  • 切主后的短暂不一致窗口

常见线上踩坑点

Lua 脚本真正难的地方,往往不在语法,而在运行时边界、阻塞特性、Cluster 路由和异常处理方式。

这一节把线上最常见的问题按“为什么会出现 -> 会造成什么结果 -> 排查时看什么”拆开。

脚本执行过长,为什么会阻塞 Redis

Redis 的命令执行主路径本来就是串行的,Lua 脚本执行期间也会占用这条主路径。

这件事的关键不是“Lua 很慢”,而是:

脚本一旦开始执行,Redis 会持续跑完整段脚本,中间不会像普通应用线程那样随意切出去处理别的客户端请求。

可以把这个过程理解成下面这条时间线:

sequenceDiagram
    participant C1 as 客户端1
    participant R as Redis
    participant C2 as 客户端2
    participant C3 as 客户端3

    C1->>R: 执行长 Lua 脚本
    Note over R: 主线程持续执行脚本
    C2->>R: GET key
    C3->>R: INCR key
    Note over C2,C3: 后续命令只能排队等待
    R-->>C1: 脚本执行完成
    R-->>C2: 开始处理排队请求
    R-->>C3: 开始处理排队请求

真正会把实例拖慢的常见原因有:

原因 为什么危险
循环次数过大 主线程长时间被脚本占住
扫描大量 key 单次逻辑范围超出“短脚本”边界
处理大集合、大 value 单条命令本身就可能很重
在脚本里做复杂计算 把 Redis 当成通用计算引擎

线上通常会表现成:

  • RT 突然抬高
  • QPS 下滑
  • 慢查询增多
  • 应用侧超时堆积

所以更稳的原则不是“Lua 不能复杂”,而是:

Lua 适合把多步 Redis 操作压缩成一个短原子窗口,不适合承接大规模遍历、重计算和长时间循环。

为什么 Lua 不能当成完整事务

Lua 和关系型数据库事务最本质的差异,不在“能不能一次执行多步”,而在“失败后怎么处理”。

关系型数据库事务强调的是:

  • 提交前可见性隔离
  • 出错后可以回滚到一致状态

而 Lua 强调的是:

  • 执行窗口内不被插队
  • 脚本可以直接依赖前面的读结果

但如果脚本是下面这种结构:

1
2
3
redis.call('SET', KEYS[1], 'v1')
redis.call('INCR', KEYS[2])
redis.call('BADCOMMAND', KEYS[3])

那么结果不是“前两步自动回滚”,而是:

  • 已经执行成功的写操作仍然保留
  • 错误在出错点抛出
  • 后续未执行部分停止

所以这里真正容易踩坑的地方是:

把“脚本执行不被插队”误解成“脚本内部天然可回滚”。

这也是为什么 Lua 更适合:

  • 原子判断
  • 原子计数
  • 原子扣减
  • 原子写入一组紧密相关的 Redis 状态

而不适合承担那种“一旦中间失败就必须完全回滚”的复杂事务语义。

为什么业务 key 设计不当会在 Cluster 下频繁出错

这是 Cluster 模式最常见的落地问题之一。

脚本逻辑本身可能完全正确,但只要业务 key 分片设计没提前统一,到了线上就会频繁出现:

  • CROSSSLOT
  • 路由错误
  • 某些环境能跑,某些环境不能跑

根源在于业务建模和分片建模没有对齐。

例如订单场景里,如果 key 设计成:

  • order:1001:info
  • order:1001:stock

这两个 key 未必一定同槽。

但如果设计成:

  • order:{1001}:info
  • order:{1001}:stock

那么 {1001} 这段 hash tag 会把它们强制压到同一个 slot。

也就是说,Cluster 下 Lua 能不能稳定落地,往往不是脚本写得漂不漂亮,而是:

key 模型在设计阶段有没有为“同槽访问”预留约束。

为什么 EVALSHA 经常在线上报 NOSCRIPT

很多系统上线后会遇到这样的问题:

  • 本地压测完全正常
  • 上线运行一段时间后偶发 NOSCRIPT

这不是 Lua 本身“不稳定”,而是脚本缓存机制本来就不是永久持久的。

脚本缓存会失效的典型场景包括:

  • Redis 重启
  • SCRIPT FLUSH
  • 主从切换
  • Cluster 路由到一台未加载脚本的新节点

所以线上真正稳的模式通常不是:

1
只会 EVALSHA

而是:

1
优先 EVALSHA -> 失败捕获 NOSCRIPT -> SCRIPT LOAD 或 EVAL 重新加载 -> 再执行

这里的问题本质上不是“命令不会写”,而是:

脚本缓存属于节点局部状态,不能假设整套 Redis 拓扑永远都已经持有同一份缓存。

为什么 Lua 不适合承接外部系统调用语义

Redis Lua 只能安全地组织 Redis 内部命令,不适合把它想象成通用业务脚本引擎。

这个限制背后的原因不是“语法不支持”,而是运行模型决定的:

  • Lua 跑在 Redis 主线程附近
  • Redis 主要职责是高性能内存访问
  • 一旦把长链路外部调用语义混进来,就会破坏 Redis 的时延模型

所以 Lua 真正适合表达的是:

  • 读 Redis
  • 判断
  • 写 Redis

而不适合承接这种思路:

  • 调 MySQL 再决定 Redis 怎么写
  • 调 HTTP 后再回 Redis 改状态
  • 在脚本里模拟完整业务编排

换句话说,Lua 的边界不是“技术上能不能写更多”,而是:

Redis 作为内存数据引擎,只适合承接短平快的本地状态转换。

业务代码并发执行时,Lua 能解决什么,解决不了什么

这是一个非常容易被误解的问题。

很多业务代码在应用层本身就是并发执行的:

  • 多个请求线程同时进入
  • 多个应用实例同时调用 Redis
  • 同一段业务逻辑可能在集群里被并发触发

这时候经常会自然地产生一个疑问:

业务都在并发执行了,把其中一段逻辑放进 Lua 之后,是不是还是会乱?

更准确的答案是:

  • 对同一个 Redis 实例来说,Lua 可以解决“Redis 内部多步操作的并发竞争”
  • 但它解决不了“整条业务链路在多个系统之间的一致性和幂等问题”

也就是说,Lua 能收拢的是 Redis 内部的竞争窗口,而不是把整个业务链路自动升级成全局事务。

Lua 真正能解决的是什么

Lua 最擅长解决的,是原本分散在客户端的多步 Redis 操作,在并发下会互相穿插的问题。

例如一段库存预扣逻辑,如果写成客户端多次往返:

  1. GET stock
  2. 判断是否大于 0
  3. DECR stock

那么两个并发请求可能都先读到同一个旧库存值。

1
2
3
4
5
6
请求A: GET stock = 1
请求B: GET stock = 1
请求A: 判断可扣减
请求B: 判断可扣减
请求A: DECR -> 0
请求B: DECR -> -1

这时问题的根源不是 DECR 不原子,而是:

“读取旧值 -> 做判断 -> 再写回” 这一整段逻辑被拆散在客户端,多线程可以基于同一个旧状态同时作出决策。

如果把它改成 Lua:

1
2
3
4
5
6
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
if stock <= 0 then
    return 0
end
redis.call('DECR', KEYS[1])
return 1

那么在同一个 Redis 实例内,这段逻辑执行时不会被别的命令插队。

也就是说,Lua 真正能解决的是:

  • 原子校验并更新
  • 原子计数
  • 原子扣减
  • 原子限流
  • 原子“比对后删除”

这些场景有一个共同点:

核心状态就在 Redis 里,而且逻辑可以收敛成一段短小的“读 + 判断 + 写”。

一个真实业务场景:秒杀下单时为什么会出问题

假设一个商品只剩最后 1 件库存,系统下单链路大致是:

  1. 先查 Redis 库存
  2. 判断是否还能下单
  3. 扣减 Redis 库存
  4. 写数据库订单
  5. 发 MQ 做后续履约

如果第 1 到第 3 步写在应用代码里,且由多个业务线程并发执行,那么“库存判断”和“库存扣减”之间就会存在竞争窗口。

最典型的错误时序如下:

sequenceDiagram
    participant A as 请求A
    participant B as 请求B
    participant App as 应用
    participant R as Redis
    participant DB as 数据库

    A->>App: 提交下单
    B->>App: 提交下单
    App->>R: GET stock -> 1
    App->>R: GET stock -> 1
    Note over App: 两个线程都判断“还能下单”
    App->>R: DECR stock -> 0
    App->>R: DECR stock -> -1
    App->>DB: 写订单A
    App->>DB: 写订单B

这时候表面现象是:

  • 两个请求都下单成功了
  • Redis 库存甚至可能变成负数
  • 数据库里多出两笔订单

但真正的根源并不是数据库写慢,也不是 DECR 不原子,而是:

“读库存 -> 判断 -> 扣减” 这三步没有被当成一个整体执行。

也就是说,两个线程虽然最终都调用了原子命令,但它们是在基于同一个旧库存值做判断,所以仍然会超卖。

用 Lua 后,这个场景到底修掉了什么

如果把第 1 到第 3 步收进 Lua,那么真正被修掉的是 Redis 内部的这段竞争窗口:

sequenceDiagram
    participant A as 请求A
    participant B as 请求B
    participant App as 应用
    participant R as Redis
    participant DB as 数据库

    A->>App: 提交下单
    B->>App: 提交下单
    App->>R: 执行 Lua(查库存+判断+扣减)
    Note over R: Lua 原子执行,请求B必须等待
    R-->>App: 请求A 扣减成功
    App->>R: 执行 Lua(查库存+判断+扣减)
    R-->>App: 请求B 库存不足,返回失败
    App->>DB: 只写订单A

这时 Lua 真正带来的变化是:

  • 请求 A 先完整执行“查库存 -> 判断 -> 扣减”
  • 请求 B 只能在 A 执行完之后再看新的库存状态
  • 因此 B 不会再基于旧库存 1 做出错误判断

所以 Lua 修掉的,是:

  • Redis 内部的并发判断窗口
  • 由此导致的超卖、重复初始化、计数超限这类问题
但这个真实场景里,Lua 仍然没解决什么

即使上面这段 Lua 已经正确运行,后面的业务链路仍然可能出问题。

例如:

  1. Lua 扣减 Redis 库存成功
  2. 数据库订单写入失败
  3. 或 MQ 发送失败
  4. 或订单最终回滚,但 Redis 库存没补回

对应的链路更像下面这样:

flowchart LR
    A[并发请求] --> B[Lua 原子扣减 Redis 库存]
    B --> C[写数据库订单]
    C --> D[发 MQ]
    D --> E[后续履约]

Lua 只能强约束 B 这一段。

B -> C -> D -> E 之间仍然会遇到:

  • Redis 成功但 DB 失败
  • DB 成功但 MQ 失败
  • 业务重试导致重复下单
  • 订单回滚后库存补偿不及时

所以真实业务里更准确的结论是:

Lua 能解决“Redis 内部并发判断和更新”的问题,但秒杀下单这种完整链路是否真正正确,还要继续依赖订单幂等、落库事务、事务消息或补偿机制。

Lua 解决不了什么

Lua 一旦离开 Redis 内部状态边界,就开始失去控制力。

例如下面这种链路:

  1. 业务线程调用 Lua 扣减 Redis 库存
  2. 应用再写数据库订单
  3. 再发 MQ
  4. 再调下游服务

Lua 即使执行完全正确,也只能保证第 1 步这段 Redis 内部逻辑是原子的。

它不能自动保证:

  • 数据库一定写成功
  • MQ 一定发送成功
  • 下游服务一定执行成功
  • 整条链路失败时自动回滚前面的 Redis 结果

所以业务并发下更容易踩坑的地方,恰恰不是“Lua 本身会不会乱”,而是:

脚本外的链路仍然是并发的、分布式的,而且失败语义各不相同。

为什么很多人会误以为“用了 Lua 就全都安全了”

因为 Lua 确实能非常有效地消灭一类高频竞态问题。

比如:

  • 超卖
  • 计数超限
  • 重复初始化
  • 锁校验与删除分离

这些问题一旦被 Lua 有效收敛,很容易继续顺势得出一个过度乐观的结论:

1
既然 Redis 这一段已经原子了,那整个业务也就安全了

但这中间其实跳过了一个很关键的边界:

  • Redis 内部原子性
  • 业务全链路一致性

这两者不是同一件事。

更准确地说,Lua 解决的是:

  • 多线程并发下,Redis 状态如何不被交叉写坏

它解决不了的是:

  • Redis 和 DB 双写如何一致
  • Redis 和 MQ 如何一致
  • 多次业务重试如何避免重复落单
  • 业务线程暂停、重放、补偿时如何维持全局正确性
用一张图看“Lua 能管到哪一层”
flowchart LR
    A[并发业务线程] --> B[Lua 脚本]
    B --> C[Redis 状态原子更新]
    C --> D[写数据库]
    D --> E[发 MQ]
    E --> F[调用下游]

这条链路里,Lua 真正强约束的只有:

  • B -> C

C -> D -> E -> F 这段,仍然需要各自的一致性和失败处理设计。

业务并发下最常见的误区是什么

最常见的误区通常有三类:

误区 真正的问题
用了 Lua 就不会超卖之外的错误 Lua 只解决 Redis 内部原子更新,不解决 DB/MQ 一致性
Lua 成功了,整条业务就算成功 Redis 成功不等于订单、消息、下游都成功
Lua 能替代幂等设计 业务重试、重复请求仍然需要幂等键和状态校验
工程上应该怎么配合使用

更稳的做法通常是把 Lua 放在它最擅长的位置,而不是让它承担超出边界的职责。

场景 Lua 的角色 还需要补什么
限流 原子计数与阈值判断 降级、熔断、监控
库存预扣 原子扣减和校验 订单落库、超时回补、幂等
分布式锁解锁 原子比对 token 再删除 TTL 设计、续约、业务补偿
缓存重建保护 原子抢占重建资格 DB 回源、版本控制、热点治理

真正更稳的原则是:

  • 用 Lua 解决 Redis 内部的并发窗口
  • 用幂等、状态机、事务消息、补偿机制解决链路级一致性
  • 不把“Redis 原子”误读成“业务全局原子”

Busy Script 和超时为什么危险

如果脚本执行太久,Redis 可能进入 busy script 状态,后续请求会受到明显影响。

危险之处不只是“一个脚本慢”,而是:

  • Redis 的请求队列会被持续拖长
  • 应用侧会开始堆积超时
  • 上游重试可能反过来加重实例压力

可以把它理解成:

1
长脚本 -> Redis 主线程被占住 -> 普通请求排队 -> 应用超时 -> 上游重试 -> 压力继续放大

常见排查抓手通常有:

  • 实例 RT 突然升高
  • 慢查询监控异常
  • CPU 飙升
  • 应用侧大量超时

需要特别注意的是:

SCRIPT KILL 并不是总能安全终止脚本;如果脚本已经产生写操作,Redis 对强杀会更谨慎,因为它要避免把实例状态停在更不可控的中间态。

所以线上真正更重要的不是“出了问题怎么 kill”,而是:

  • 脚本提前做复杂度控制
  • 单次处理范围可预估
  • 大循环和大集合操作拆到脚本外

为什么脚本里的非确定性依赖更难排查

涉及时间、随机数、隐式 key 访问等逻辑时,脚本行为会更难推导和排查。

这个问题本质上是:

  • 输入边界不清楚
  • 同一脚本在不同时间、不同节点、不同参数下行为差异变大
  • 出问题后不容易复现实例当时的上下文

所以工程上更稳的习惯通常是:

  • 输入显式化
  • key 显式化
  • 参数通过 ARGV 传入
  • 避免让脚本依赖隐藏上下文

换句话说,越是希望脚本稳定,越要把它写成:

输入清晰、访问边界清晰、结果清晰的纯 Redis 状态转换逻辑。


一个典型实战案例:分布式限流

Lua 在 Redis 里最常见的落地之一,就是限流。

前面给出的固定窗口脚本本身没有问题,但如果只看到代码,很容易不明白它到底解决了什么并发问题。

先看脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
local limit = tonumber(ARGV[1])
local expire = tonumber(ARGV[2])

if current == 0 then
    redis.call('SET', KEYS[1], 1)
    redis.call('EXPIRE', KEYS[1], expire)
    return 1
end

if current < limit then
    redis.call('INCR', KEYS[1])
    return 1
end

return 0

这个脚本想解决的到底是什么

这个脚本把下面几个动作合并成一次原子执行:

  1. 读取当前计数
  2. 判断是否超过阈值
  3. 递增计数
  4. 首次访问时设置过期时间

看起来只是把几条命令写在一起,但它真正防止的是两个高频并发问题:

问题 不用 Lua 时为什么会出现
计数超限 多个并发请求可能同时读到同一个旧值,再分别通过判断
过期时间漏设 第一次初始化计数和设置 TTL 不是一个原子动作,中间失败或并发打断会留下永不过期 key

为什么拆到客户端会出问题

假设限流阈值是 1,两个并发请求同时到来,客户端逻辑如果拆成:

  1. GET
  2. 判断 < limit
  3. INCR
  4. 如果是首次访问再 EXPIRE

那么就可能出现下面这条时序:

sequenceDiagram
    participant A as 请求A
    participant B as 请求B
    participant R as Redis

    A->>R: GET rate:user:1 -> 0
    B->>R: GET rate:user:1 -> 0
    A->>A: 判断通过
    B->>B: 判断通过
    A->>R: INCR rate:user:1 -> 1
    B->>R: INCR rate:user:1 -> 2

这时真正的问题不是 INCR 不原子,而是:

“读取旧值并判断是否允许” 这一段发生在客户端,两个请求都基于同一个旧状态做出了放行决策。

所以最后结果会变成:

  • 两个请求都被放行
  • 实际计数却已经到 2
  • 限流语义被破坏

为什么会出现过期时间漏设

另一个常见问题是 TTL 丢失。

如果客户端这么写:

1
2
3
1. GET key 发现不存在
2. SET key = 1
3. EXPIRE key 60

那么在 SETEXPIRE 之间,只要出现下面任一情况:

  • 应用线程异常
  • 网络闪断
  • 请求超时重试
  • 中间逻辑被打断

就可能留下:

  • 值已经写进去了
  • 但过期时间没来得及设置

最终形成一个“本该是窗口计数,却变成长期累积”的脏 key。

Lua 把首次写入和设置过期时间放在同一个执行窗口里,就是为了把这个洞补上。

用图看 Lua 限流的执行机制

flowchart TD
    A[请求到达] --> B[执行 Lua 脚本]
    B --> C[读取当前计数]
    C --> D{当前计数是否为0}
    D -- 是 --> E[写入1并设置TTL]
    D -- 否 --> F{是否小于阈值}
    F -- 是 --> G[INCR并放行]
    F -- 否 --> H[拒绝请求]
    E --> I[返回放行]
    G --> I

图里最关键的不是流程分支本身,而是:

整个判断链路都在 Redis 服务端一次性完成,不会被其他请求插进来篡改中间状态。

放到 Cluster 模式下还要注意什么

如果这个限流脚本放在 Cluster 里使用,仍然要满足一个前提:

  • 限流 key 的路由必须明确

也就是说:

  • 不要在脚本里临时拼出新的跨槽 key
  • 如果一个限流逻辑会同时依赖多个 key,这些 key 必须同 slot
  • 最好让调用侧把真正访问的 key 显式通过 KEYS[] 传入

例如:

1
2
rate:{user:1001}:apiA
rate:{user:1001}:apiB

只有在确实需要同槽访问多个 key 时,才通过同一个 hash tag 进行建模。

这个案例真正说明了什么

分布式限流这个例子之所以常被拿来解释 Lua,不是因为它“代码短”,而是因为它把 Lua 的核心价值暴露得非常清楚:

  • 多步 Redis 操作本来分散在客户端
  • 每一步单独看都没问题
  • 但组合起来会产生并发窗口
  • Lua 把这个窗口收拢成一个服务端原子执行单元

所以这个案例真正说明的是:

Lua 最适合处理“多步都不复杂,但连起来必须原子”的场景。


结论归纳

Redis Lua 脚本的核心价值,是把一段需要连续完成的读写判断逻辑放到服务端一次性执行,避免客户端多次往返和并发竞争窗口。它比事务更适合先读再判断再写的场景,因为脚本内部可以直接依赖前面的结果。Lua 的原子性本质上是单实例执行窗口内不会被其他命令插队,但它不是回滚事务,也不是跨节点事务。到了 Cluster 模式,脚本仍然只能在单节点执行,所有 key 必须同 slot,并且要通过 KEYS 显式传入。线上最常见的问题通常是脚本执行太久阻塞 Redis、EVALSHA 遇到 NOSCRIPT 没兜底、以及业务 key 没按同槽规则设计。


小结

Redis Lua 脚本真正要记住的是:

  • 它解决的是 Redis 内部复合操作的原子执行问题。
  • 它擅长“读 + 判断 + 写”,不擅长大计算和长脚本。
  • 它不是数据库回滚事务,也不是跨分片全局事务。
  • 在 Cluster 模式下,key 同槽和 KEYS 显式传递是第一原则。
  • 线上稳定性的关键,不只是脚本正确,还包括缓存、路由、超时和脚本重载兜底。

17.Redis 里的消息能力怎么区分

核心结论

Redis 里常被拿来做“消息”的能力,至少有三类:

  • Pub/Sub
  • 基于 List 的阻塞队列
  • Stream

它们都能把数据从生产方交给消费方,但语义差异非常大。

这一部分可以先收敛为下面这个结论:

能力 更像什么 最适合解决什么问题
Pub/Sub 即时广播 在线通知、订阅推送、对丢消息不敏感的广播场景
List + BRPOP/BLPOP 简单任务队列 轻量异步处理、单条取走即消费的任务模型
Stream 带消费位点和消费组的消息流 希望支持消费组、重试、待确认追踪的消息处理

如果把它们简单粗暴地都称为“消息队列”,就很容易在丢消息、重试、顺序和消费位点这些问题上踩坑。


先区分消息场景到底在要什么

很多时候问题不是“Redis 能不能发消息”,而是业务到底需要哪种语义。

通常至少要先分清下面几个问题:

问题 解释
消费者不在线时,消息要不要保留 决定是否需要持久化式留存
一条消息是广播给所有人,还是只给一个消费者 决定是广播模型还是队列模型
消费失败后,要不要重新处理 决定是否需要重试和待确认机制
是否需要追踪“谁消费了、谁没消费” 决定是否需要消费位点和消费组
是否需要强顺序或长时间堆积能力 决定 Redis 是否适合继续承接

如果这些问题没有先分清,很容易出现的情况就是:

  • 明明需要“失败后重试”,却用了 Pub/Sub
  • 明明需要广播,却用了单消费者队列
  • 明明需要积压追踪,却用了只会弹出元素的 List

Pub/Sub 的原理和边界

Pub/Sub 的核心模型非常直接:

  1. 生产者向某个 channel 发布消息
  2. Redis 把消息转发给当前已经订阅这个 channel 的客户端
  3. 在线订阅者立即收到消息
sequenceDiagram
    participant P as 生产者
    participant R as Redis
    participant C1 as 订阅者A
    participant C2 as 订阅者B

    C1->>R: SUBSCRIBE news
    C2->>R: SUBSCRIBE news
    P->>R: PUBLISH news msg
    R-->>C1: 转发 msg
    R-->>C2: 转发 msg

它的优点很明显:

  • 模型简单
  • 广播语义天然成立
  • 在线通知延迟低

但它的边界也非常明确:

特性 Pub/Sub 的情况
是否广播
是否保留离线消息
是否记录消费位点
是否追踪失败重试
是否知道某个订阅者有没有真正处理成功

也就是说,Pub/Sub 更像“在线广播总线”,而不是“可追踪、可补偿的可靠消息队列”。

为什么 Pub/Sub 容易被误用

因为从调用形式上看,PUBLISH 和传统 MQ 的“发消息”太像了。

但两者最关键的差别在于:

Pub/Sub 只负责把消息推给当前在线订阅者,不负责替业务保管消息生命周期。

所以只要消费者当时不在线、网络闪断、应用正在重启,消息就可能直接错过。

这也是为什么 Pub/Sub 更适合:

  • 配置刷新通知
  • 在线事件广播
  • 聊天室广播
  • 对单条丢失不敏感的实时推送

而不适合承担:

  • 订单事件可靠投递
  • 支付结果异步处理
  • 失败必须重试的核心任务分发

基于 List 的阻塞队列在做什么

Redis 早期很常见的一种队列做法,是使用 List 配合阻塞弹出命令:

  • 生产者:LPUSH / RPUSH
  • 消费者:BRPOP / BLPOP

它的本质是:

List 当成一个先进先出或先进后出的任务容器,消费者阻塞等待有新元素可取。

flowchart LR
    A[生产者 RPUSH] --> B[List 队列]
    B --> C[消费者 BRPOP]
    C --> D[消息被取走]

这种模型的优点是:

  • 简单直接
  • 容易实现一个消费者取走一条任务
  • 轻量异步任务场景成本低

但它的限制也非常明显。

List 队列为什么会有“取走即丢控制权”的问题

BRPOP/BLPOP 的语义是“把元素从队列里弹出来并返回给消费者”。

这意味着一旦消息被取走:

  • Redis 侧这条消息就不在队列里了
  • 后续是否处理成功,Redis 不再感知

所以它天然缺少:

  • 消费确认状态
  • 待确认列表
  • 消费者宕机后的自动接管机制

如果消费者在“取到消息后、业务处理完成前”崩掉,就会出现经典问题:

消息已经从队列里拿走,但业务其实还没处理完。

这也是很多人后来会引入 RPOPLPUSH / BRPOPLPUSH 的原因,即先把消息转移到“处理中队列”,处理完成后再显式删除,试图自己模拟 ACK 机制。

为什么 List 队列仍然有人用

因为在很多内部轻量任务场景里,业务并不需要完整 MQ 语义,只需要:

  • 有个地方能堆任务
  • 有个消费者阻塞拿任务
  • 处理失败由业务自己补偿

例如:

  • 图片缩略图生成
  • 异步发邮件
  • 非核心日志归档
  • 单体或轻量服务里的后台任务处理

这类场景里,List 队列的简单性本身就是优势。

但如果业务需要:

  • 消费组
  • 待确认追踪
  • 消费者宕机接管
  • 明确重试和重放

那么就要开始怀疑 List 是否还够用。


Stream 到底比前两者多了什么

Stream 是 Redis 里更接近“消息流系统”的能力。

它和 Pub/SubList 最大的差别不是命令多,而是它引入了下面几层更完整的消息语义:

  • 消息有 ID
  • 消息会保留在流里
  • 可以按 offset/ID 持续读取
  • 可以建立消费组
  • 可以追踪待确认消息
flowchart TD
    A[生产者 XADD] --> B[Stream]
    B --> C[消费组]
    C --> D[消费者A XREADGROUP]
    C --> E[消费者B XREADGROUP]
    D --> F[进入 PEL 待确认列表]
    E --> F
    F --> G[XACK 确认]

这使得 Stream 能够回答很多 List 回答不了的问题:

问题 Stream 怎么回答
消息有没有唯一位置或 ID
消费到哪了 通过 ID / 消费组位点追踪
哪些消息已经投递但还没确认 通过 PEL 追踪
某个消费者挂了,未确认消息怎么办 可以通过 XPENDINGXCLAIM 等机制接管

PEL 为什么重要

PEL 是 Pending Entries List,也就是“待确认消息列表”。

它的意义在于:

  • 消息已经被投递给某个消费者
  • 但 Redis 还没收到 XACK
  • 因此这条消息仍然处于“处理中但未完成”的状态

这正是 Stream 相比 List 很重要的进步:

Redis 不再只是把消息弹出去,而是开始跟踪“投递了但还没确认”的中间状态。

这使得业务可以更清楚地区分:

  • 尚未投递
  • 已投递未确认
  • 已确认处理完成

Stream 也不等于传统专业 MQ

即便 Stream 语义更丰富,也不意味着它在所有消息场景都能完全替代 Kafka、RocketMQ 这类专业 MQ。

通常还要继续看:

  • 积压规模有多大
  • 是否需要海量顺序日志流
  • 是否要求更成熟的重平衡、重试、死信、回溯治理能力
  • 是否需要跨机房、跨集群的消息治理体系

所以更准确的理解是:

Stream 解决的是 Redis 体系内部更完整的消息流和消费组问题,但它仍然首先是 Redis 的一种数据结构能力,而不是专门为超大规模消息平台设计的独立中间件。


三种能力到底怎么选

场景 更合适的选择 原因
在线广播通知 Pub/Sub 只关心在线转发,不强调消息留存
轻量异步任务 List 阻塞队列 简单、直接、实现成本低
需要消费组和待确认追踪 Stream 有 ID、消费位点、PEL、ACK
海量积压、复杂重试治理、长期堆积 专业 MQ Redis 不是这类场景的最优中心方案

工程上更稳的选择原则通常是:

  • 要广播,用 Pub/Sub
  • 要简单任务分发,用 List
  • 要 Redis 内部较完整的消息流语义,用 Stream
  • 要真正的大规模可靠消息平台,优先考虑专业 MQ

常见误区

以为 Pub/Sub 也能做可靠队列

这个误区的根源,是把“发出去”误当成“被可靠保管并最终消费”。

Pub/Sub 不保留离线消息,也不追踪 ACK,所以它不具备可靠队列语义。

以为 List 队列天然支持失败重试

List 的默认阻塞弹出语义本质上是“取走即离开队列”。

如果要支持失败重试,往往要业务自己补:

  • 处理中队列
  • 超时回收
  • 重试次数
  • 死信处理

它不是没有办法做,而是很多能力需要自己补齐。

以为 Stream 就等于 Kafka

Stream 解决了 Redis 内部更丰富的消息流问题,但它不自动等价于成熟的专业消息平台。

是否够用,仍然取决于:

  • 数据规模
  • 消息生命周期治理复杂度
  • 可靠性和运维体系要求

小结

Redis 里的“消息能力”真正要记住的是:

  • Pub/Sub 是广播,不是可靠队列
  • List 是轻量任务队列,不天然带 ACK 和待确认追踪
  • Stream 提供了更接近消息流系统的能力,核心增量是 ID、消费组和 PEL
  • 场景越靠近“海量积压、可靠投递、复杂治理”,越应该慎重评估 Redis 是否还是合适中心方案

18.Redis 分布式锁怎么理解

核心结论

Redis 分布式锁最常见的目标,是让多个进程或多个节点在同一时间只有一个执行者进入某段临界区。

但“能加锁”不等于“锁一定安全”,因为真正困难的地方不在于 SETNX 这一个命令,而在于下面这些边界:

  • 锁怎么加
  • 锁怎么解
  • 锁过期了任务还没做完怎么办
  • 持锁进程卡顿、GC、网络抖动时会发生什么
  • 主从切换或多节点环境下语义还能不能成立

这一部分最重要的结论是:

Redis 分布式锁本质上是一种基于过期时间的租约机制,而不是数据库那种严格阻塞锁。


锁到底想解决什么问题

所谓“分布式锁”,本质上是在回答:

当多个节点都可能执行同一段逻辑时,如何尽量保证同一时刻只有一个执行者进入。

典型场景包括:

  • 定时任务只允许一个实例执行
  • 库存补偿任务不能并发跑多次
  • 同一资源的后台重建逻辑只允许一个节点处理

但这里要先区分两类目标:

目标 更像什么 典型例子
互斥进入 同时只允许一个执行者 定时任务、初始化动作、热点重建
业务正确性 即使锁失效也不把状态搞坏 扣库存、扣余额、状态机推进

很多人踩坑的起点,就是把“拿到 Redis 锁”直接当成了“业务绝对正确”的保证。

但更准确的理解应该是:

  • 锁只能帮助缩小并发进入窗口
  • 真正的业务正确性,仍然要靠幂等、状态机、数据库约束等手段兜底

最小可用实现到底是什么

Redis 分布式锁最常见的最小可用写法是:

1
SET lock:order:1001 unique_token NX PX 30000

这里每个部分都不是可有可无的。

部分 作用
lock:order:1001 锁对象
unique_token 锁持有者身份标识
NX 只有不存在时才能加锁成功
PX 30000 锁自动过期,避免死锁

它之所以常被当成最小可用实现,是因为它同时解决了两件基础问题:

  1. 互斥加锁
  2. 锁最终自动释放

如果只写成:

1
SETNX lock:order:1001 1

那么一旦持锁进程宕机、卡死、机器断电,就可能留下一个永远不释放的死锁 key。


为什么解锁一定要带唯一 token

很多错误实现的问题不在加锁,而在解锁。

错误示意通常是:

1
DEL lock:order:1001

问题在于,锁是会过期的。

可能发生下面这条时序:

sequenceDiagram
    participant A as 线程A
    participant R as Redis
    participant B as 线程B

    A->>R: SET lock tokenA NX PX 3000
    Note over A: 执行任务过久
    Note over R: 锁过期
    B->>R: SET lock tokenB NX PX 3000
    B-->>R: 加锁成功
    A->>R: DEL lock
    Note over R: A 把 B 的锁删掉

这就是为什么解锁时不能只看 key,而必须确认:

现在锁里的值,还是不是自己当初写进去的那个 token。

因此更稳的解锁方式通常是 Lua:

1
2
3
4
if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
end
return 0

它的核心价值不是“代码更帅”,而是把“比对 token + 删除锁”放进一个原子执行窗口里。


为什么说 Redis 锁本质上是租约

Redis 锁带 TTL,这一点非常关键。

TTL 的含义不是“锁永远安全”,而是:

  • Redis 只承诺在这段时间内,key 存在
  • 超过这个时间,锁就可能失效
  • 失效后别人可以重新加锁

所以它更像一个带过期时间的租约:

  • 租约期内,默认只有当前持有者能进入
  • 租约期结束,如果还没续租,其他人可以接管

这和数据库里的阻塞锁差异很大。

数据库锁更像:

  • 没释放之前别人就进不来

Redis 锁更像:

  • 给一段时间的占用权
  • 超时后系统默认认为这段占用权已经失效

这也是为什么 Redis 锁最怕的问题之一就是:

业务执行时间超过锁租约时间。


续约机制到底在补什么洞

如果临界区执行时间可能超过初始 TTL,就不能只靠一次性 PX 30000 硬顶。

这时通常要引入续约,也就是“看门狗”式机制:

  1. 成功拿锁
  2. 启动后台续约线程
  3. 在锁快过期前检查 token 是否仍属于自己
  4. 如果仍属于自己,则延长过期时间
flowchart TD
    A[加锁成功] --> B[启动续约线程]
    B --> C{锁还属于自己?}
    C -- 是 --> D[延长 TTL]
    D --> B
    C -- 否 --> E[停止续约]
    A --> F[业务执行完成]
    F --> G[比对 token 后解锁]

续约解决的是:

  • 任务执行时间不可预测
  • 锁租约过短导致别人误以为锁已失效

但续约也不是银弹,因为还会继续遇到这些问题:

  • 持锁进程长时间 STW/GC,续约线程也卡住
  • 机器被暂停,续约来不及发送
  • 网络分区时,应用以为自己还活着,但 Redis 侧租约其实已经失效

所以续约只能降低风险,不能把 Redis 锁变成绝对线性一致的互斥原语。


主从和 Cluster 环境下为什么边界会更复杂

Redis 锁在单实例里最容易理解,但一旦放进主从或 Cluster,很多人会把“可用”误读成“语义自动升级”。

主从复制为什么会引出锁安全边界

考虑这条链路:

  1. 客户端 A 在主节点加锁成功
  2. 锁还没复制到从节点
  3. 主节点故障
  4. 从节点提升为新主
  5. 客户端 B 又在新主上加锁成功

这时就可能出现:

A 和 B 都认为自己拿到了锁。

问题根源不在于 SET NX PX 不原子,而在于:

  • 锁写入只在旧主上成功
  • 复制还没来得及传播
  • 主从切换让“锁状态”出现了时间裂缝

这也是为什么很多关于 Redis 锁的讨论,最后都会落到:

  • 单实例锁
  • 主从复制窗口
  • 故障切换安全性

这些边界上。

Cluster 又意味着什么

Cluster 并不会自动把锁升级成“全局分布式锁系统”。

它只意味着:

  • key 会路由到某一个分片
  • 某个资源锁最终落在某一个节点上

如果锁 key 的路由是稳定的,那么单个资源锁仍然可以在它所属节点上工作。

但 Cluster 并不会额外解决:

  • 主从复制窗口
  • 故障切换时的锁状态漂移
  • 多资源组合锁的全局事务协调

所以更准确地说:

Cluster 只是让不同资源锁分散在不同分片上,不是让锁语义天然变强。


Redlock 应该怎么理解

Redlock 常被提起,是因为它试图通过多个独立 Redis 节点来降低单点故障带来的锁安全问题。

它的大体思想是:

  1. 向多个独立节点尝试加锁
  2. 在限定时间内拿到多数派成功
  3. 认为当前客户端获得锁

这个设计试图缓解的是:

  • 单实例故障
  • 主从复制窗口
  • 单点丢锁状态

但它之所以长期有争议,也正是因为:

  • 分布式系统里的时钟、网络、暂停、故障模型并不简单
  • “多数派成功”并不自动等于“业务层绝对安全”

所以工程上更重要的不是把 Redlock 当成银弹,而是先问:

  • 这个业务到底需要多强的锁语义
  • 锁失效后,业务还能不能靠幂等和状态机兜底

对于大多数普通业务,真正更现实的问题往往不是“要不要先上 Redlock”,而是:

  • 单实例 Redis 锁的边界有没有讲清楚
  • 解锁是否正确
  • TTL 和续约是否合理
  • 业务是否具备幂等和补偿

常见误区

以为 SETNX 就已经足够

只用 SETNX 不带过期时间,会把锁做成永久 key,一旦持锁进程异常退出,就可能产生死锁。

以为解锁直接 DEL 就可以

如果没有 token 校验,就可能把别人的新锁删掉。

以为拿到锁就等于业务绝对安全

锁只是互斥辅助工具,不是业务正确性的唯一来源。

对于扣库存、扣余额、状态推进这类场景,真正的安全性还需要:

  • 幂等
  • 状态机校验
  • DB 约束
  • 补偿机制

以为加了续约就不会失锁

续约只能降低“租约过短”的风险,不能消灭 GC、网络分区、进程暂停等问题。

以为 Redis 锁适合所有关键互斥场景

如果业务对锁语义要求极高,例如:

  • 绝对不能双写
  • 绝对不能并发进入
  • 锁失效一次都不可接受

那么应当非常谨慎地评估 Redis 是否是合适的锁中心,必要时要考虑更强一致的协调组件。


一个典型实战场景:定时任务抢占执行

假设有 10 个应用实例,都部署了同一个定时任务,但要求同一时间只能有一个实例真正执行。

常见做法是:

  1. 任务触发前先尝试 SET lock:job:daily-report token NX PX 60000
  2. 成功的实例进入任务
  3. 失败的实例直接退出本轮
  4. 任务完成后通过 Lua 校验 token 再删除锁

这个场景适合 Redis 锁的原因是:

  • 目标主要是“避免多个实例同时跑”
  • 即使某次抢锁异常,通常还有下一轮任务
  • 业务可以额外做幂等和重复执行保护

但如果把同样的锁模型直接搬去做:

  • 扣库存最终互斥
  • 余额扣减绝对唯一执行

就会非常危险,因为这类业务的正确性不能只押在“锁大概率有效”上。


小结

Redis 分布式锁真正要记住的是:

  • 最小可用实现是 SET key token NX PX ttl
  • 安全解锁必须做“比对 token + 删除”的原子操作
  • Redis 锁本质上是租约,不是数据库阻塞锁
  • 续约只能补租约过短的问题,不能消灭所有分布式故障边界
  • 真正关键的业务正确性,不能只依赖 Redis 锁,还要靠幂等、状态机和存储约束共同兜底