Redis缓存穿透雪崩击穿与分布式锁三代实现
缓存这套东西,写得再熟,真到了线上出问题的时候,往往还是那几个老面孔在作祟:某个不存在的 ID 被打了几万次、零点一过一批 key 集体失效、热点商品下架瞬间数据库连接池拉满、两个人同时改同一个配置互相覆盖。这些问题单独看都不复杂,难的是它们经常结伴出现,而且现象互相伪装——你以为是缓存击穿,实际是并发竞争把值写脏了;你以为是 Redis 挂了,实际是雪崩把请求全推到了数据库。这篇就把Redis缓存这一类问题从头捋一遍,穿透、雪崩、击穿、双写不一致、并发竞争,再顺着并发竞争这条线聊到分布式锁的三代实现:watch乐观锁、setnx、Redisson。适合已经用过 Redis、但还没被线上问题真正捶打过的同学,也适合准备面试时想听点"人话版"原理的同行。
1. 缓存穿透的真正形态:不是查不到,是有人故意让你查不到
穿透这个词听着抽象,落到代码里其实特别具体:一个请求进来,先查缓存,没命中,转去查数据库,数据库也没有,于是什么都不会被写进缓存。下一次同样的请求打进来,重复一遍上述流程。单次请求没什么,但当这个 key 被高频重复访问,数据库就成了一道没有闸门的门,所有流量原样灌进去。
1.1 一次空结果被反复放大的完整链路
很多人对穿透的理解停留在"缓存没命中",这不准确。缓存没命中叫 miss,是正常现象;穿透的关键在于这次 miss 之后没有任何东西被沉淀下来。正常的查询会把结果写回缓存,下次再来就命中了;而查不存在的数据,结果为空,如果代码里没有对空结果做特殊处理,那就等于每次都要重新走一遍数据库。
我见过最典型的一个场景是商品详情页。前端路由里带着?id=12345,商品下架之后数据库里删掉了,但外部页面还留着旧链接,搜索引擎、爬虫、用户收藏夹、别人转发的分享卡片,全都在往外喷这个已经不存在的 id。请求量不大,但特别持久——一天二十四小时不间断地打。数据库里select * from item where id = 12345走主键索引,单次很快,但一天几十万次,连接池就被这种无效查询占住了。
还有一种更恶劣的:参数没做校验。接口GET /user/{id},id 是字符串类型,攻击方直接传1 or 1=1或者-1、999999999999。前者在 MyBatis 里如果用了${}拼接就是注入,后者是纯粹的数字越界。这类请求在缓存里永远查不到,在数据库里也永远查不到,但每一次都要消耗一次数据库连接和一次 SQL 解析。
提示:排查穿透时不要只看 QPS,要看数据库的
Com_select增长曲线和慢查询里有没有大量"主键查空"的记录。慢查询日志里出现执行时间极短、返回行数为 0 的 SQL 高频重复,基本就是穿透的指纹。
1.2 空值缓存、布隆过滤器、参数校验的取舍
解决穿透的路子有三条,代价和收益差别很大,不能无脑全上。
空值缓存是最省事的。查到数据库为空时,往缓存里写一个带特殊标记的值,TTL 设短一点,比如 60 秒。这样同一个不存在的 id 在一分钟内只会打一次数据库。它的优点是实现成本几乎为零,缺点是会污染缓存空间——如果有大量随机 id 攻击,你等于用攻击方的参数把缓存撑爆,把真正有用的热点数据挤出去。
所以空值缓存必须配一个前置条件:参数合法性校验。id 必须是正整数、长度在合理区间、符合业务编号规则(比如订单号有固定前缀),不合法直接返回,连缓存都不用查。这一步能挡掉绝大部分随机参数攻击。
布隆过滤器是更彻底的方案,适合数据集合相对固定、能提前全量预热的场景。原理是用一个很长的位数组加多个哈希函数,把存在的 key 映射到若干位上,查询时只要有一位是 0,就百分之百不存在;全是 1 则"可能存在"。注意是"可能",因为不同 key 的哈希可能撞到同一位上,这就是误判。
参数怎么定?给个实际算例:假设业务数据量 n = 1000 万,我们能接受 1% 的误判率 p = 0.01。位数组长度 m = -n·ln(p) / (ln2)² ≈ 1.44 × n × log₂(1/p),代入得约 9600 万位,也就是约 11.4 MB。哈希函数个数 k = (m/n)·ln2 ≈ 6.6,取 7 个。11 MB 内存换 1% 的误判(误判时仍然会去查数据库,只是概率低),这个账是划得来的。
| 方案 | 实现成本 | 内存开销 | 适用场景 | 主要缺陷 |
|---|---|---|---|---|
| 参数校验 | 极低 | 无 | 所有接口 | 只能挡住明显非法参数 |
| 空值缓存 | 低 | 与攻击量正相关 | 数据集合不可预知 | 恶意随机 key 会撑爆缓存 |
| 布隆过滤器 | 中 | 与数据量线性相关 | 集合可全量预热 | 不支持删除,误判无法归零 |
1.3 我在实际项目里的组合拳
单用一种都不够。我一般的做法是三层:入口做参数格式校验,挡掉明显不合法的;中间加布隆过滤器,挡掉集合外的;最后加空值缓存兜底,挡住布隆过滤器那 1% 的误判。三层叠下来,打到数据库的无效请求基本能压到千分之一以下。
布隆过滤器有个坑要提前想清楚:它不支持删除。商品下架、用户注销,你没法把对应 key 从位数组里抹掉。如果业务上删除很频繁,位数组会逐渐被"占满",误判率飙升,最后退化成形同虚设。这时候要么换 Counting Bloom Filter(用计数器代替单 bit,能删除但要几倍内存),要么干脆老老实实只用空值缓存。
还有一个容易被忽略的细节:空值缓存写进去的那个"空",序列化方式要和正常值区分开。我们用 Jackson 序列化,正常对象反序列化成 POJO,空值我写的是字符串"NULL",读取时先判断类型再判断内容。曾经有同事直接往里面塞了个null,结果 Redis 里存的是"\xac\xed\x00\x05..."这种 Java 序列化头,读出来反序列化报错,整个接口 500,排查了半天才发现是"缓存了 null"这件事本身没被设计过。
2. 缓存雪崩:过期时间撞车比 Redis 宕机更常见
雪崩这个词容易让人联想到 Redis 整个挂掉,但实际上,绝大多数雪、是"人造"的——批量预热、统一 TTL、定时刷新,都会让一批 key 在同一秒集体失效。那一瞬间,所有请求同时穿透到数据库,数据库连接池瞬间打满,然后所有依赖它的服务一起卡死。
2.1 为什么整点过期的 key 会形成雪崩
想象一下这个场景:运营在后台配置了 200 个首页楼层,代码里统一写死expire = 3600,全部在凌晨 2 点整点通过定时任务预热。到了 3 点整,这 200 个 key 在同一秒到期,首页的请求全部落空,全部去查数据库。首页 QPS 平时是 5000,这时候 5000 个请求同时查库,每个 SQL 要 join 三张表,就算单条 20ms,5000 并发需要的连接数也远超连接池的 50。
更麻烦的是连锁反应。数据库被压住之后,不光是首页查不动,所有连同一个库的业务全部排队,超时时间一到,线程池里的线程全被占满,网关开始 504,用户刷新,请求量进一步上升,形成正反馈。
这里有个反直觉的点要强调:雪崩的杀伤力主要不来自数据库变慢,而来自连接池和线程池的"占满"。数据库其实没死,它只是排队;但你的应用层线程都在等数据库响应,一分钟内就被耗光,然后连正常的健康检查都过不去,容器编排平台判定实例不健康,开始重启,重启之后缓存是空的,又来一次全量回源,雪上加霜。
2.2 随机过期窗口该开多大
最直接的对策是给 TTL 加随机抖动,让过期时间散开。但抖动范围开多大,是有讲究的。
一个经验公式:抖动范围取基础 TTL 的 10% 到 20%。基础 TTL 是 1 小时(3600 秒),抖动就取 0 到 600 秒之间的随机数。这样 200 个 key 的过期时间会摊在 10 分钟内,平均每分钟只失效 20 个,数据库完全吃得消。
实际 TTL = 基础 TTL + random(0, 基础 TTL × 0.2)如果抖动开得太小,比如只加 60 秒,那 200 个 key 还是挤在 1 分钟内,等于没解决。如果开得太大,比如基础 1 小时抖动 1 小时,那有些 key 可能 2 小时才过期,数据新鲜度会受影响,对时效性要求高的业务(比如库存、价格)就不能这么干。
对于库存、价格这种强时效数据,我的做法是不用抖动,改用主动刷新:在 key 快要过期前的 1 到 5 分钟内,由一个后台任务异步重建缓存,重建成功后再延长 TTL。这样缓存永远不会"自然过期到空",而是"旧值撑着,新值后台算"。
2.3 多级缓存与降级开关的实际配法
抖动只解决了"同时过期",解决不了"Redis 集群整体不可用"。后者要靠多级缓存和降级。
多级缓存的典型结构是本地缓存(Caffeine)+ 远程缓存(Redis)。本地缓存扛高频只读的热点数据,命中率往往能到 60% 以上,即使 Redis 全挂,这部分请求也不会打穿到数据库。代价是一致性:本地缓存更新有延迟,多个实例之间不同步。
我的做法是给本地缓存设一个很短的 TTL,比如 3 秒,同时订阅 Redis 的失效通道(__keyevent@0__:expired),收到消息立刻清本地。3 秒是兜底,防止消息丢失导致本地缓存长期脏着。3 秒的延迟对大部分读多写少的场景(商品详情、文章内容、配置项)完全可接受,但对库存这种绝对不能忍,库存我就不放本地缓存。
降级开关是最后一道闸。用 Sentinel 或类似组件在数据库前面挡一层,当数据库的慢 SQL 比例或连接池使用率超过阈值,直接对非核心接口返回兜底数据(比如空列表、默认推荐、静态文案),保证核心链路(下单、支付)的连接不被抢走。
// 降级开关的伪代码示意,阈值要按实际连接池容量反推 if (dbConnectionPoolUsedRate > 0.85) { degradeSwitch.on(); // 打开降级 return Collections.emptyList(); // 非核心查询直接返回空 }这里有个经验:降级阈值不能拍脑袋定。连接池 50 个连接,如果单条 SQL 平均 20ms,那理论吞吐是 2500 QPS。降到 85% 使用率时大约是 2100 QPS,此时响应已经开始变慢,再有 10% 的流量增长就会雪崩。所以阈值应该从"最大安全吞吐量"倒推,而不是从"连接数快满了"这个直觉出发。
3. 缓存击穿:热点 key 失效那一秒,慢查询会被放大成风暴
击穿和雪崩的区别在粒度:雪崩是一大批 key 同时失效,击穿是单个热点 key 失效。听起来范围小,但杀伤力不见得低——因为热点 key 的访问量可能占整个系统的 30%,它失效的那 1 秒钟,就是 1500 个并发同时去查同一条数据。
3.1 互斥重建和逻辑过期的本质差别
解决击穿有两大流派,本质区别在于要不要让读请求等待。
互斥重建:谁发现缓存空了,谁去抢一把锁,抢到的那个去查数据库、写缓存,没抢到的等待或重试。好处是数据一定是最新的,坏处是等待期间接口响应变慢,如果重建耗时 200ms,那这 200ms 内所有请求都在等,用户体验是有感知的。
逻辑过期:缓存里存的不是业务对象本身,而是一个包装体,里面带着真正的数据和它的"逻辑过期时间"。缓存物理上永不过期(或者设一个很长的 TTL),每次读出来先判断逻辑时间,没过期就直接用,过期了也不阻塞——先返回旧数据,同时提交一个异步任务去重建。好处是几乎没有等待,坏处是会有短暂的脏数据窗口。
| 维度 | 互斥重建 | 逻辑过期 |
|---|---|---|
| 数据新鲜度 | 强一致,重建完就是新值 | 最终一致,有秒级脏窗口 |
| 响应延迟 | 冷启动时有尖刺 | 平稳,无尖刺 |
| 实现复杂度 | 低,一把分布式锁即可 | 中,需要异步线程池 + 包装体 |
| 失效场景 | 锁服务不可用时会退化成穿透 | 异步任务失败时长期返回旧值 |
| 适用业务 | 价格、库存、权限 | 商品详情、文章、推荐位 |
选择标准很朴素:你能不能忍几秒钟的旧数据。商品详情能忍,价格不能忍;文章内容能忍,登录态不能忍。
3.2 用 SETNX 做重建锁的完整写法
互斥重建的核心是"只有一个线程去重建"。用 Redis 的SET key value NX PX ttl就能实现,但细节很多,写错了就是给自己埋雷。
public String getWithMutex(String key) { // 1. 先查缓存 String cached = redis.get(key); if (cached != null) { return cached; } String lockKey = "lock:rebuild:" + key; String lockValue = UUID.randomUUID().toString(); // 必须是唯一值 // 2. 尝试加锁,30 秒自动过期,防止死锁 Boolean locked = redis.set(lockKey, lockValue, "NX", "PX", 30000); if (Boolean.TRUE.equals(locked)) { try { // 3. 双重检查:拿锁期间可能别人已经重建完了 cached = redis.get(key); if (cached != null) { return cached; } // 4. 查库并回写 String dbValue = loadFromDb(key); redis.set(key, dbValue, "PX", 3600_000); return dbValue; } finally { // 5. 用 Lua 脚本比对 value 后再删除,防止误删别人的锁 String lua = "if redis.call('get', KEYS[1]) == ARGV[1] then " + "return redis.call('del', KEYS[1]) else return 0 end"; redis.eval(lua, Collections.singletonList(lockKey), Collections.singletonList(lockValue)); } } else { // 6. 没抢到锁,短暂休眠后重试,不要死循环 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getWithMutex(key); // 生产环境建议改成有限次重试 } }这份代码里有三个不能省的细节。第一是lockValue用 UUID,不能用固定值,否则释放锁时无法区分"是我的锁"还是"别人的锁"。第二是释放必须用 Lua 脚本做原子比对,get再del中间有个窗口,锁可能刚好过期被别人拿走,你这一del就把别人的锁删了。第三是重试要有限次,上面的写法在极端情况下可能栈溢出,实际项目里我会用一个 for 循环限定最多重试 3 次,超过就返回兜底值。
3.3 逻辑过期方案里的时间窗设计
逻辑过期的实现要点是包装体设计。
public class LogicalExpireWrapper<T> { private T data; // 真正的业务数据 private long expireAt; // 逻辑过期时间戳,毫秒 public boolean isExpired() { return System.currentTimeMillis() > expireAt; } }写入时,expireAt设成"当前时间 + 业务 TTL",同时把缓存本身的物理 TTL 设成一个远大于它的值,比如逻辑 5 分钟、物理 2 小时。这样即使异步重建失败,最多 2 小时内还能拿到旧数据,不至于全空。
读取时的逻辑是:命中且未过期,直接返回;命中且已过期,返回旧值,同时提交异步重建任务;未命中(物理过期了),走互斥重建。
异步重建的线程池必须隔离,不能和业务线程池共用,否则重建任务排队会拖慢正常业务。我给重建任务单独配了一个ThreadPoolExecutor,核心线程 2、最大 4、队列容量 1000、拒绝策略用DiscardPolicy——队列满了说明重建积压严重,这时候多丢一个任务无所谓,反正下一次读还会再触发。
还有个坑:重建任务要按 key 去重。同一个过期 key 在 1 秒内可能被读几千次,如果不做去重,几千个重建任务同时提交,等于自己造了一次小型的击穿。我的做法是用一个并发的Set记录"正在重建中的 key",提交任务前先add,add失败就跳过,任务结束后remove。这个 Set 是单机内存里的,多实例之间还是可能重复,但重复度从几千降到实例数,可以接受。
4. 双写不一致:删缓存和写库的顺序,决定了不一致的概率
缓存和数据库是两套存储,两个写操作不可能原子完成,所以不一致是必然存在的,我们能做的是把不一致的窗口压到最小、把不一致的持续时间压到最短。
4.1 四种更新顺序的推演
理论上就四种组合,逐个推一遍就知道为什么大家最后都选了"先更新库、再删缓存"。
| 顺序 | 并发场景 | 结果 | 结论 |
|---|---|---|---|
| 先删缓存,再更新库 | 删完、更新前,读请求进来回填旧值 | 缓存里是旧值,长期不一致 | 不推荐 |
| 先更新库,再删缓存 | 删缓存前,读请求拿旧值回填 | 极端并发下短暂不一致 | 主流方案 |
| 先更新库,再更新缓存 | 两个写并发,后提交的可能先写缓存 | 缓存被覆盖成旧值 | 不推荐 |
| 先删缓存,再更新库,延迟再删 | 延迟期内读请求回填旧值 | 第二次删除把旧值清掉 | 可用但复杂 |
第一种"先删缓存"的问题最严重:删完之后、数据库还没更新,这几十毫秒里任何一个读请求都会把旧值重新写进缓存,而数据库更新完之后不会再管缓存,于是缓存就长期是旧的,直到 TTL 到期。这个窗口概率不低,因为是并发场景。
第二种"先更新库再删缓存"的问题窗口非常小:只有"读请求恰好查到旧值、还没来得及写缓存、写请求已经把库更新完并删了缓存、读请求再把旧值写进去"这四个动作严格按这个顺序发生,概率大概是毫秒级窗口里的竞争。理论上存在,实际很难复现。
第三种"更新缓存"问题最大:两个写请求并发,A 先提交数据库但后写缓存,B 后提交数据库但先写缓存,结果缓存里是 A 的值(旧值),数据库里是 B 的值(新值),从此长期不一致。所以永远不要用更新缓存代替删除缓存。
第四种"延迟双删"是第一和第二种的折中,本质是想弥补第一种的缺陷:先删一次,更新数据库,再延迟删一次。延迟时间要大于"一次读请求从查库到回写缓存的耗时",通常设 500ms 到 1s。但它引入了新的不确定性——延迟时间没法精确估,而且第二次删除失败就没兜底了。
我的实践结论是:绝大多数业务用"先更新库、再删缓存",加 TTL 兜底就够了,别为了追求理论一致把系统搞复杂。真正不能容忍不一致的场景(比如账户余额),根本不该用缓存方案,应该直接读库或者用专门的强一致存储。
4.2 删除失败的补偿与 binlog 兜底
"先更新库再删缓存"也有一个必须处理的问题:删缓存失败怎么办。Redis 网络抖动、集群切换,del命令是有可能失败的。缓存如果不删,TTL 又设得长,那不一致会持续很久。
常规的补偿手段是重试。但重试不能直接在业务线程里循环,会拖慢响应。我的做法是:
- 第一次删除失败,把 key 丢进一个本地队列或消息队列;
- 由消费者做二次删除,失败则按指数退避重试(1s、2s、4s、8s,最多 5 次);
- 5 次都失败,记一条告警日志,人工介入或者依赖 TTL 兜底。
更彻底的办法是订阅数据库的 binlog。数据库的每次变更都会写 binlog,用一个中间件(比如 Canal)伪装成从库去订阅,解析出变更的表和主键,然后异步删缓存。这个方案的好处是把"删缓存"从业务代码里彻底剥离,业务只需要写库,缓存删除由数据管道负责,不会漏。坏处是引入了一个新的组件,复杂度上升,小团队维护成本不小。
我的建议是分阶段:单表数据量不大、写入不频繁的业务,用"删缓存 + 重试 + TTL"就够;写 QPS 上千、涉及多张表级联更新的,再考虑上 binlog 方案。别一上来就搞数据管道,最后运维成本把自己压垮。
4.3 TTL 兜底不是偷懒,是最后一道保险
很多同学觉得加了 TTL 就是"承认自己方案不完美"。我反倒认为 TTL 是必须的,因为它兜住的是所有你没想到的异常路径:代码 bug 漏删了、网络分区导致删除丢了、有人绕过应用直接改了库、上游服务回滚了数据。这些情况你没法在代码里一一枚举,只能靠过期时间来强制对齐。
TTL 设多长,是个业务问题,没有标准答案。我的经验区间:
| 数据特性 | TTL 建议 | 理由 |
|---|---|---|
| 商品详情、文章内容 | 10 分钟 ~ 1 小时 | 可接受长一点,减少回源 |
| 用户资料、配置项 | 5 ~ 30 分钟 | 变更不频繁 |
| 库存、价格 | 10 ~ 60 秒 | 宁可多回源,不能错 |
| 榜单、推荐位 | 1 ~ 5 分钟 | 本身就是准实时 |
关键是要有人对 TTL 负责。我们团队的做法是在缓存写入的地方统一走一个工具类,TTL 必须从枚举里取,不允许直接写数字。这样 review 的时候一眼就能看出"这个地方为什么用这么短的 TTL",而不是半年后谁都不知道 3600 这个数是哪来的。
5. 数据并发竞争:锁加错对象,比不加锁更危险
前面说的都是缓存和数据库之间的问题,这一段说的是多个客户端同时操作同一个 key。最典型的两个场景:库存扣减和配置更新。
5.1 读-改-写序列里的丢失更新
考虑这个流程:两个线程都要给某个商品的库存减 1。
线程 A:GET stock:1001 → 10 线程 B:GET stock:1001 → 10 线程 A:SET stock:1001 = 9 线程 B:SET stock:1001 = 9最终库存是 9,但实际卖出了 2 件,应该是 8。这就是经典的丢失更新。Redis 的命令是原子的,但GET和SET是两条命令,中间可以被其他客户端插进来。
有人会说"Redis 是单线程的,不会有并发问题"。这句话只对了一半:Redis 处理单条命令是串行的,但你的业务逻辑不是一条命令,是多个命令组成的序列,序列之间完全可能被其他客户端穿插。
解决方案按成本从低到高排:
第一条路是原子命令。能用INCR、DECR、HINCRBY、SETNX解决的就别加锁。库存扣减完全可以用DECRBY stock:1001 1,原子、高效。但要注意,DECRBY不会在库存变成负数时自动拦截,需要配一个 Lua 脚本做"判断 + 扣减":
-- KEYS[1] = stock key, ARGV[1] = 扣减数量 local current = tonumber(redis.call('GET', KEYS[1])) if current == nil or current < tonumber(ARGV[1]) then return -1 -- 库存不足 end redis.call('DECRBY', KEYS[1], ARGV[1]) return current - tonumber(ARGV[1])这个脚本在 Redis 里是原子执行的,判断和扣减之间不会被插入。
第二条路是乐观锁。如果逻辑比"扣个数"复杂,比如要先算价格再扣库存,Lua 脚本写起来就费劲了,这时候可以用WATCH配合事务。WATCH会监视一个 key,如果在EXEC之前这个 key 被其他客户端改过,整个事务就会失败返回 nil,业务侧需要重试。
WATCH的缺点很明显:冲突高的时候重试次数会爆炸。100 个线程抢同一个 key,平均只有一个能成功,其余 99 个都要重试,重试的 99 个里又只有一个成功……这就是典型的活锁。所以WATCH只适合冲突率很低的场景,比如"读配置、改配置"这种平时没什么人动的操作。
第三条路是分布式锁。加锁把事情串行化,虽然吞吐下降,但语义最清晰。这也是下一节要展开的内容。
这三条路的选型判断其实很简单:问自己"冲突概率有多高"。低冲突,用WATCH或者乐观重试;中高冲突,用分布式锁;能用原子命令解决的,永远优先用原子命令,别上锁。
5.2 加了锁还出问题,通常是锁的粒度错了
我见过一个案例:某系统为了防止库存超卖,在服务入口加了一把分布式锁,锁的 key 是lock:order,所有下单请求串行执行。结果压测时 QPS 从 3000 掉到 80。
问题不在锁本身,在锁的粒度。所有订单共用一把锁,等于把整个下单链路串行化,跟没做并发设计一样。正确的粒度应该是lock:stock:{itemId},按商品维度加锁,不同商品之间互不影响,同一商品的并发被串行化——这才是业务真正需要的。
粒度错误还有一个反面:锁得太细。比如按userId加锁,但业务是"全局限量 100 件",那用户的锁根本挡不住超卖,因为不同用户之间的竞争没被覆盖。
判断粒度是否正确的标准是:锁保护的临界区,里面的读-改-写序列是不是都在操作同一份共享资源。是,就用这把锁;不是,就说明锁的范围和资源不匹配。
6. 分布式锁的三代实现:watch、setnx、Redisson 各自解决了什么
顺着并发竞争往下走,绕不开分布式锁。这一块的历史演进特别有意思,每一代方案都是为了补上一代的漏洞,理解这个演进链条,比死记 API 有用得多。
6.1 WATCH 乐观锁:适合低冲突,但 ABA 和重试是硬伤
WATCH是 Redis 原生事务的一部分。用法是先WATCH key,然后MULTI开启事务,把要执行的命令入队,最后EXEC提交。如果被 watch 的 key 在EXEC之前被别人改过,EXEC返回 nil,事务整体不执行。
WATCH lock:config # 读取当前值 GET lock:config # 业务判断... MULTI SET lock:config "new-value" EXEC # 返回 nil 说明被改过,需要重试整个流程它最大的价值是不加锁也能实现互斥语义,对 Redis 的侵入最小。但三个问题绕不过去:
第一,重试成本高。冲突时整个业务逻辑要重跑,如果业务逻辑里有耗时操作,重跑代价很大。
第二,ABA 问题。WATCH判断的是"key 有没有被改过",如果 A 改成 B 又改回 A,WATCH是感知不到的。好在 Redis 的WATCH实现是基于版本号/客户端修改标记的,实际上比单纯的"值比较"要严格一些,但如果你自己实现 CAS(比如自己GET出来比较再SET),ABA 就是实打实的问题。
第三,它不能跨客户端串行化。WATCH事务失败后,所有客户端都在重试,大量无效计算。在写多读少或者冲突频繁的场景,性能会塌。
所以WATCH的实际定位是:读多写少、冲突极低的配置类数据。它不适合用来做业务上的分布式锁。
6.2 SETNX 锁的四个必须处理的细节
SETNX是很多人手写分布式锁的起点。早期写法是SETNX lock 1加上EXPIRE lock 30,但这两条命令不是原子的,如果SETNX成功后客户端崩了,EXPIRE没执行,锁就永远不释放。后来 Redis 2.6.12 提供了SET的扩展参数,一条命令搞定:
SET lock:order:1001 "uuid-xxxx" NX PX 30000NX表示 key 不存在才设置,PX 30000表示 30 秒后自动过期。这一条命令是原子的,解决了死锁问题。但要做到生产可用,还有四个细节:
第一,value 必须唯一。用 UUID 或者"实例 IP + 线程 ID"。如果 value 是固定值,释放锁的时候你没法判断这把锁是不是自己加的。
第二,释放必须用 Lua 比对。前面击穿那一节的代码已经写过,GET再DEL不是原子的,中间锁可能过期被抢走。
第三,超时时间要估准。锁的 TTL 必须大于业务执行时间,否则业务没跑完锁就过期了,另一个线程进来,两个线程同时操作共享资源,互斥失效。但 TTL 设太长,客户端崩了之后锁要等很久才释放。这个矛盾无解——除非你引入"续期"机制。
第四,不可重入。同一线程第二次加同一把锁会失败,如果业务里有嵌套调用,就会自己把自己锁死。
我见过最惨的一次线上事故就是超时时间估短了:锁 TTL 设了 10 秒,某次数据库抖动,业务执行了 15 秒,锁在第 10 秒过期,第二个线程进来,两个线程同时更新同一个订单状态,最终状态错乱,人工排查了两天才定位到。
6.3 Redisson 看门狗解决了什么,又没解决什么
Redisson 是把手写锁的坑基本填平的一个库。它的核心机制有两块:看门狗续期和可重入计数。
看门狗的原理是:加锁成功后,启动一个后台任务,每隔锁超时时间 / 3的时间检查一次,如果业务还在执行(锁还被当前线程持有),就把 TTL 重置回默认值(30 秒)。默认情况下每 10 秒续一次。这样业务执行多久,锁就自动续多久,彻底解决了"超时时间估不准"的问题。
但看门狗有个前提:Redisson 客户端必须还活着。如果整个进程崩了,看门狗也跟着没了,锁在 30 秒后自然过期,这反而是我们想要的——崩掉的进程本来就不该继续持锁。
可重入的实现是把 value 从字符串换成 Hash 结构:
key: lock:order:1001 field: <客户端UUID>:<线程ID> value: 重入次数加锁时如果发现 field 已经存在,就把 value 加 1;释放时减 1,减到 0 才真正删除 key。这样同一线程多次加锁不会死锁。
RLock lock = redissonClient.getLock("lock:order:" + orderId); try { // 最多等 3 秒,拿到锁后由看门狗自动续期 boolean acquired = lock.tryLock(3, TimeUnit.SECONDS); if (!acquired) { throw new BizException("操作太频繁,请稍后再试"); } // 业务逻辑 } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }注意tryLock的写法:只传等待时间,不传租期(leaseTime),才会启看门狗。如果写成tryLock(3, 30, TimeUnit.SECONDS),就固定了 30 秒租期,看门狗不生效,又回到了"超时时间估不准"的老问题。这个坑非常多人踩。
那 Redisson 没解决什么?它解决不了 Redis 自身的高可用问题。主从架构下,主节点加锁成功后还没同步到从节点就宕机了,从节点升主,锁就丢了。Redisson 提供了 RedLock 算法(向多个独立的 Redis 实例加锁,超过半数成功才算成功),但 RedLock 的争议一直很大,因为它的正确性依赖于各实例的时钟不发生大幅漂移,网络分区时也可能出问题。
我的态度比较务实:如果你的业务不能容忍锁丢失,那就别用 Redis 锁,用支持一致性协议的组件(比如基于 ZooKeeper 或者关系库的行锁)。Redis 锁的定位是"高性能、允许极低概率失效"的场景,比如防重复提交、任务调度去重。真正涉及钱的场景——扣库存、扣余额——最终的兜底应该是数据库层面的乐观锁(版本号)或者唯一索引,Redis 锁只是第一道拦截网,不是唯一防线。
7. 把这几件事串起来:我在实际项目里的分层防御
单独看每个问题,方案都很清楚。但它们在实际系统里是交织的,需要有个整体的分层思路。
我的分层大致是这样的:
第一层,入口校验。参数格式、长度、取值范围,在网关或者 Controller 层挡掉。这一层不涉及任何存储,成本最低,收益最大。
第二层,本地缓存。Caffeine 存最热的那一小部分数据,TTL 3 到 5 秒,扛住极高并发的只读请求。这一层挡掉的是"击穿"和一部分"雪崩"的冲击。
第三层,Redis 缓存。统一走工具类写入,TTL 从枚举取,热点 key 用逻辑过期,普通 key 用随机抖动。这一层是主力。
第四层,分布式锁。只用在写路径和有竞争的读路径上,粒度按业务资源划分,用 Redisson 的tryLock加看门狗。锁的 key 命名规范统一为lock:业务:资源ID,方便排查。
第五层,数据库兜底。乐观锁版本号、唯一索引、行锁,这些是真正的最后防线。缓存可以错,锁可以丢,但数据库的约束不能破。
第六层,监控告警。缓存的命中率、Redis 的慢查询、锁的等待时间、数据库连接池使用率,这四个指标我是必看的。命中率突然从 95% 掉到 70%,说明有大量 key 失效或者被穿透;锁等待时间突然拉长,说明有热点资源被争抢;连接池使用率超过 80%,说明缓存层没扛住。
有个小技巧分享一下:我在每个缓存写入的地方都埋了一个"来源标记",记录这个 key 是"正常回源"写的还是"预热任务"写的。排查雪崩的时候,看一眼标记就知道失效的 key 是定时任务批量过期造成的,还是业务逻辑里 TTL 设错了。这个字段就存在 value 的包装体里,几乎不增加成本,但省了很多排查时间。
还有一个反直觉的体会:缓存问题的大部分根因不在 Redis,在业务代码的写法。TTL 写死、删缓存漏写、锁粒度搞错、空值没处理,这些都是代码层面的疏忽。所以与其花时间研究 Redis 的高级特性,不如先把这几个基础模式在团队里统一成工具类,用代码规范把它们固化下来。工具类里该做的判断都做了,业务代码想写错都难——这比写多少文档都管用。