news 2026/10/27 20:51:47

Redis缓存高并发实战:穿透雪崩一致性与分布式锁避坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Redis缓存高并发实战:穿透雪崩一致性与分布式锁避坑

1. 先把这六个词摆到一张地图上看

Redis 缓存用起来是真舒服,一个get就把数据库挡在身后,QPS 从几百干到几万,老板看着监控曲线笑,你也跟着笑。但真到线上,出问题的时候往往不是 Redis 挂了,而是 Redis 好好的,数据库先躺了。穿透、雪崩、击穿、数据不一致、并发竞争这几个词,几乎每一个做过后端的人都遇到过至少一个,而分布式锁的 watch 乐观锁、setnx、redisson 这三种实现,又基本覆盖了从单体到集群的大部分加锁需求。这篇文章我想干的事很简单:把这几个概念从"背八股"的层面拽回到工程现场,讲清楚每个问题在什么流量形态下会爆发、参数该怎么算、代码该怎么写、踩过的坑长什么样。

内容偏向有 Java 后端基础、已经开始或者准备把 Redis 放到核心链路里的同学。如果你是纯新手,也没关系,每个概念我都会先用生活化的类比铺一遍,再上代码和参数。我不会给你一堆"缓存一致性很重要"这种正确的废话,而是直接告诉你:延迟双删的延迟到底该设多少、布隆过滤器的位数组该开多大、tryLock只传一个时间参数和传两个参数有什么区别、看门狗到底在什么情况下不生效。这些细节,文档里通常一句话带过,但线上出事往往就出在这一句话上。

先把整体思路说清楚:这六个问题不是并列关系,而是有层次、有因果的。穿透是"查不存在的数据",击穿是"单个热点 key 失效",雪崩是"大批 key 同时失效",这三个属于"读路径上的流量放大问题";数据不一致和并发竞争属于"写路径上的状态同步问题";分布式锁则是解决后两类问题的一把通用工具,但工具本身也有成本和新问题。理解了这个分层,你在设计方案时就不会眉毛胡子一把抓。

1.1 六个问题各自打在哪一环

我习惯用一张"请求生命周期"的视角来看。一个读请求进来,先到应用层,再查 Redis,未命中查数据库,回填 Redis。这条链路上有三个脆弱点:第一个是"Redis 没命中但数据库也没有",请求会持续打到数据库,这是穿透;第二个是"Redis 命中了但刚好在这一刻过期",大量并发请求同时去重建,这是击穿;第三个是"大量 key 因为相同的过期策略在同一时间段集中失效",这是雪崩。三者表象相似,根因完全不同,所以解法也不能混用——拿空值缓存去解雪崩是无效的,拿随机过期时间去解穿透也是无效的,这一点在实际排查中最容易搞混。

写路径就更微妙了。一个更新请求进来,你要动数据库,也要动缓存,两次写操作之间必然存在时间窗口,这个窗口就是数据不一致的根源。而并发竞争是另一个维度:两个请求同时改同一行数据,谁后写谁生效,但"后写"的那个可能拿到的是旧快照,于是更新丢失。分布式锁在这里扮演的角色是"把并发变成串行",但它只能解决互斥,解决不了"你拿到的是不是最新数据",所以还得配合版本号或者 CAS 思路。

1.2 三种加锁手段的定位差异

watch 乐观锁、setnx、redisson 这三者,很多人以为是"三种实现同一个东西",其实它们的适用边界差别很大。watch 是 Redis 事务里的 CAS 机制,本质是"我先盯着这个 key,如果有人在我提交前改了它,我的事务就失败",它不加锁、不阻塞,适合冲突概率低的场景,比如库存扣减这种最终会重试的操作。但 watch 有个硬伤:它监控的是整个 key,一旦有其他客户端频繁修改,你的重试次数会飙升,而且事务里的命令是批量提交的,做不了复杂逻辑。

setnx 是最朴素的互斥锁,一条命令就能实现"只有一个人能进来"。但朴素意味着责任全在你身上:锁的 value 要带唯一标识,否则可能删掉别人的锁;锁要设置过期时间,否则进程崩了就是死锁;解锁要用 Lua 保证原子性,否则get和del之间有一个时间窗口。这些坑不填,setnx 就是一颗定时炸弹。

redisson 是把上面这些坑全部封装好的成熟方案,可重入、带看门狗续期、支持公平锁和读写锁,还提供红锁做多节点容错。它的代价是引入了一个不小的依赖,并且需要理解它的续期机制,否则容易出现"锁没释放"或者"锁提前释放"的问题。我的经验是:业务简单、只有一两个场景,setnx 加 Lua 足矣;只要涉及嵌套调用、执行时间不确定,直接上 redisson,不要自己造轮子。

2. 缓存穿透:查不到的数据才是最危险的

穿透这个词听起来很技术,其实场景很朴素:有人拿一个根本不存在的 ID 来查数据,Redis 里没有,数据库里也没有,于是每次请求都实打实地打到数据库。正常业务里这不算大问题,一次查库几毫秒,撑得住。但如果是被恶意构造的请求刷,或者上游传了个脏 ID 在循环里跑,数据库连接池几分钟就会被打满。我见过最典型的一次,是某个下游服务把用户 ID 传成了 0,而 0 这个 ID 在业务上永远不存在,结果缓存永远不命中,数据库 QPS 直接翻了十倍。

这里的关键认知是:缓存只能挡住"存在过的数据"。数据库查不到,就没有东西可以回填,缓存对这类请求天然无效。所以穿透的解法不是"优化缓存",而是"在缓存层之前加一道判断",把不可能存在的数据挡在门外。

2.1 空值缓存:最省事的挡板

第一种做法是空值缓存:数据库查不到,就往 Redis 里写一个特殊标记,比如空字符串或者"NULL",给一个比较短的过期时间。下次同样的请求进来,直接命中这个标记,业务层判断是空标记就返回"不存在",不再查库。

这个方案的好处是零额外组件,代码改动极小,几行就能落地。坏处也很明显:如果攻击者每次都换一个新的随机 ID,你缓存的全是空值,Redis 内存会被快速打满。所以空值缓存的 TTL 一定要短,而且要想清楚容量上限。我的做法是给空值 key 单独一个前缀,比如empty:user:,方便后续批量清理和监控。

具体参数上,TTL 设多少合适?我的经验值是60 到 300 秒。太短了起不到防护作用,太长了又会拖慢"数据后来被创建"的可见性——比如用户注册后立刻查详情,如果之前有条空值缓存还在,就会显示不存在。这里有个细节要处理好:写操作成功后,要主动删掉对应的空值 key,不能只等它自然过期。这一条经常被漏掉,导致新注册的用户刷新页面看不到自己。

2.2 布隆过滤器:用一点点内存换全局防线

如果随机 ID 攻击是真实存在的风险,空值缓存就不够了,得上布隆过滤器。它的原理是把每个存在的 key 用几个不同的哈希函数映射到位数组的多个位置上,查询时看这几个位置是不是都为 1。如果有一个是 0,那这个 key一定不存在,可以直接拒绝;如果全是 1,那它可能存在,放行去查缓存和数据库。

注意"可能存在"这四个字,这就是布隆过滤器的核心特性:有假阳性,没有假阴性。它可能把不存在的 key 误判为存在,但绝不会把存在的 key 误判为不存在。对于穿透防护来说,这个特性刚好够用——误判了最多多查一次库,不影响正确性。

代价是两件事:一是内存,二是数据同步。内存怎么算?公式是m = -n * ln(p) / (ln2)^2,其中 n 是预期元素个数,p 是目标误判率。举个实际例子:假设你要挡 1000 万个用户 ID,希望误判率控制在 1% 以内,代入算一下,m ≈ 9.6 * 10^7位,也就是大约 12 MB,哈希函数个数k = (m/n) * ln2 ≈ 7。12 MB 换一道全局防线,这个买卖很划算。

数据同步才是麻烦的地方。布隆过滤器不支持删除,用户注销了,那个 ID 还留在里面,只能靠定期重建。我的做法是用一个定时任务,比如每天凌晨低峰期,从数据库全量拉一遍有效 ID,构建一个新的过滤器,然后原子替换掉旧的。重建期间用双缓冲,新老并存,切换时改一下指向就行,不会出现空窗。如果数据量特别大,全量重建耗时长,可以考虑分片构建,或者用 Redis 提供的布隆过滤器模块,但要注意模块的版本兼容性,别在生产环境踩坑。

提示:布隆过滤器只适合"判断存在性",不要拿它去存业务数据,也不要指望它能替代缓存。它的定位就是一道前置的、粗粒度的筛子。

2.3 两种方案怎么选

我一般的判断标准是这样的:如果只是防止偶发的脏参数,空值缓存足够了,成本最低;如果面对的是可预期的扫描行为,或者业务本身就有大量"不存在"的正常查询(比如搜索联想、商品编码校验),那就上布隆过滤器。两者也可以叠加:布隆过滤器挡掉绝大部分,剩下的漏网之鱼用空值缓存兜底。

还有一个容易被忽略的点:参数校验要在最前面。ID 是负数、是 0、长度不对、格式不对,这些在进入缓存查询之前就该被拦掉。很多穿透问题的根因根本不在缓存设计,而在于上游把脏数据放进来了。加一层参数校验,成本几乎为零,收益却很大。

3. 缓存击穿与雪崩:一个是点,一个是面

这两个概念经常被混着讲,但它们的爆发形态完全不同。击穿是单个热点 key过期的那一瞬间,比如首页的 banner 配置、秒杀商品的详情,这些 key 的 QPS 可能是几万,一旦失效,这几万个并发请求会同时发现缓存没了,然后同时去查数据库、同时回填。注意"同时"这两个字,它意味着数据库在极短时间内承受的是一种脉冲式冲击,而不是持续压力。很多数据库不是被持续高负载压垮的,而是被这种瞬时脉冲打挂的。

雪崩则是大面积 key 同时失效,比如你上线时批量预热了 10 万个 key,全部设了 30 分钟过期,那么 30 分钟后它们会集体失效。或者更糟的情况:Redis 实例整个挂了,所有请求瞬间穿透到数据库。这两种情况的区别在于,击穿是"点",可以针对性地对热点 key 做特殊处理;雪崩是"面",需要从过期策略和架构层面解决。

3.1 击穿:互斥重建与逻辑过期

解决击穿有两条主流路线,我分别说说适用场景。

第一条是互斥重建,也就是常说的"只让一个请求去查库"。缓存失效后,第一个拿到锁的请求去查数据库并回填,其他请求短暂等待或者直接返回旧值。伪代码大概是这样:

public String getWithMutex(String key) { String value = redis.get(key); if (value != null) { return value; } String lockKey = "lock:rebuild:" + key; // setnx 抢锁,过期时间给短一点,防止死锁 boolean locked = redis.setnx(lockKey, "1", 10); if (locked) { try { // 双重检查,防止在抢锁期间别人已经回填 value = redis.get(key); if (value != null) { return value; } value = db.query(key); redis.set(key, value, 1800); return value; } finally { redis.del(lockKey); } } else { // 没抢到锁,短暂休眠后重试,或者直接返回兜底值 Thread.sleep(50); return getWithMutex(key); } }

这里有两个细节值得展开。第一个是双重检查,抢到锁之后必须再查一次缓存,因为在你抢锁的那几毫秒里,前一个持锁者可能已经回填完成了,不检查就会重复查库。第二个是没抢到锁的请求怎么办,直接递归重试有栈溢出风险,实际项目中我会限制重试次数,超过就直接返回兜底值或者抛一个"系统繁忙"的友好提示。这个取舍很现实:宁可让 1% 的用户看到降级页面,也不能让数据库崩掉导致 100% 的用户不可用。

第二条路线是逻辑过期,思路是不给 key 设真实的过期时间,而是在 value 里存一个逻辑过期时间戳。读到数据后发现逻辑过期了,就异步起一个线程去重建,当前请求直接返回旧值。

class CacheItem { Object data; long expireAt; // 逻辑过期时间戳 }

这个方案的好处是永远不会阻塞,所有请求都能立刻拿到数据,哪怕它是旧的。坏处是会有一段时间读到旧数据,对时效性要求高的业务不适用。

我的选型经验是:强一致性要求高、能接受短暂等待的场景用互斥重建,比如订单详情、账户余额;可用性优先、能接受短时陈旧数据的场景用逻辑过期,比如商品列表、内容资讯。别小看这个选择,它本质上是在一致性和可用性之间做取舍,没有标准答案,得看业务能不能忍。

3.2 雪崩:把失效时间打散

雪崩的第一道防线非常简单:不要给批量 key 设相同的过期时间。在基础 TTL 上加一个随机偏移,比如基础 1800 秒,偏移范围 0 到 300 秒:

int ttl = 1800 + ThreadLocalRandom.current().nextInt(300); redis.set(key, value, ttl);

这一个小改动,就能把"同一秒集中失效"变成"在 5 分钟内平滑散开",数据库的压力峰值能下降一个数量级。这是性价比最高的优化,我建议所有缓存写入都默认加上随机偏移。

第二道防线是多级缓存。本地缓存扛住绝大部分读请求,Redis 作为二级,数据库作为最终兜底。本地缓存用 Caffeine 之类的组件,容量小但速度极快,适合存那些变化不频繁的热点数据。这样即使 Redis 整体不可用,本地缓存还能撑一段时间,给运维争取恢复窗口。

第三道防线是熔断降级。当检测到数据库的响应时间或者错误率超过阈值,直接让部分请求走降级逻辑,返回默认值或者缓存快照。这里的关键是阈值要提前压测出来,不能拍脑袋定。我一般会做一次全链路压测,找到数据库在 CPU 70% 时的最大承载 QPS,然后把这个值的 60% 设为熔断阈值,留出缓冲。

3.3 预热与限流:被低估的两个动作

还有一个常被忽略的环节是缓存预热。服务刚启动或者 Redis 刚重启的时候,缓存是空的,这时候所有请求都会打到数据库,这就是一次人造雪崩。我的做法是应用启动后不立刻接流量,先跑一个预热任务,把 Top N 的热点 key 批量加载进缓存,加载完成再开放流量入口。N 取多少?看你的内存和热点集中度,一般 Top 5000 到 Top 20000 就能覆盖 80% 以上的请求。

限流则是最后一道闸门。不管前面做得多好,总会有意外情况,比如突发热点事件、爬虫扫站。在网关层或者应用层做 QPS 限流,把超过承载能力的请求直接拒掉,虽然体验不好,但至少系统还活着。这里的原则是宁可拒绝一部分,也不要全部拖死。

4. 数据不一致:删缓存还是更缓存

写路径的问题比读路径更隐蔽,因为它不会立刻表现为"数据库被打挂",而是表现为"用户看到的数据不对"。这种问题排查起来极其折磨人,因为它是偶发的、和时间窗口相关的,日志里往往看不出异常。

核心矛盾在于:更新数据库和更新缓存是两个操作,它们不在一个事务里,中间必然有时间窗口。这个窗口里,如果有读请求进来,就会读到旧值并回填到缓存,导致缓存里的数据比数据库还旧,而且这个旧值会一直存在到下次过期。

4.1 四种更新策略的取舍

常见的组合有四种,我列个表对比一下:

策略操作顺序一致性并发风险适用场景
先更库再删缓存更新 DB → 删除缓存较高极低概率读到旧值绝大多数业务首选
先删缓存再更库删除缓存 → 更新 DB较低读请求易回填旧值不推荐
先更库再更缓存更新 DB → 更新缓存中等并发写会乱序写少读多且值计算简单
只更库,缓存靠过期更新 DB,等 TTL最终一致无时效性要求低的场景

先更库再删缓存是我默认推荐的方案。为什么是删除而不是更新?因为更新缓存有两个问题:一是如果缓存的值需要复杂计算(比如多表关联聚合),每次写都算一遍成本太高;二是并发写的时候,两个线程可能以相反的顺序更新数据库和缓存,导致缓存里留下旧值。删除操作则简单粗暴,下次读的时候自然回源重建,虽然会有一次缓存未命中,但换来的是更低的出错概率。

那"先删缓存再更库"为什么不行?想象一下:线程 A 删了缓存,还没来得及更新数据库,线程 B 来读,发现缓存空了,去数据库读到旧值并回填,然后线程 A 才完成数据库更新。结果就是缓存里是旧值,数据库里是新值,而且这个不一致会持续到缓存过期。这个窗口虽然短,但在高并发下命中概率并不低。

4.2 延迟双删:延迟到底该设多少

即便用了"先更库再删缓存",理论上仍然存在一个极小概率的不一致场景:缓存刚好在删除的瞬间失效,读请求回源读到的是更新前的旧数据,然后回填。这个概率很低,但在强一致要求下不能忽视,于是有了延迟双删:更新数据库后删一次缓存,隔一段时间再删一次。

public void updateAndEvict(String key, Object newValue) { db.update(key, newValue); redis.del(key); // 异步延迟再删一次 scheduler.schedule(() -> redis.del(key), delayMs, TimeUnit.MILLISECONDS); }

关键在于delayMs怎么定。这个时间的逻辑是:要大于"读请求从数据库读到旧值并回填缓存"的最大耗时。它由两部分组成:主从复制延迟(如果读走从库)加上业务查询本身的耗时。主从延迟通常在 10 到 100 毫秒,业务查询几毫秒到几十毫秒,所以总时间一般在几百毫秒量级。

我的经验取值是500 毫秒到 1 秒。低于 500 毫秒可能覆盖不住慢查询,高于 1 秒又会拖长不一致的持续时间。当然这个值不是拍脑袋定的,最好在压测环境里实际测一下 P99 的读耗时,然后取它的两到三倍。另外,延迟删除一定要用异步线程池,不要阻塞主流程,否则一次更新请求要等一秒才返回,接口 RT 直接爆炸。

注意:延迟双删只是降低概率,不是消除。它无法应对"第二次删除也失败"的情况,也无法应对删除后立刻又有读请求回填。要真正做到强一致,只能靠分布式事务或者订阅数据库变更日志。

4.3 订阅变更日志:走向最终一致

如果业务对一致性要求真的很高,比如涉及资金、库存,那就别在应用层做删缓存了,直接订阅数据库的变更日志。数据库的 binlog 里记录了每一行数据的变更,用一个中间件把这些变更投递到消息队列,再由消费者去删除或者更新缓存。

这个方案的好处是缓存的操作和业务代码完全解耦,业务只管写数据库,缓存由独立链路维护。而且 binlog 是有序的,能保证同一个 key 的变更按顺序处理,避免并发写乱序的问题。坏处是链路变长,延迟变高,从数据写入到缓存更新可能有几十到几百毫秒的延迟。

所以这个方案适合"可以接受短暂延迟,但不能接受永久不一致"的场景。对于那些"必须实时看到自己刚提交的数据"的场景(比如用户改完昵称立刻刷新),还是得在业务层做一次主动删除,把 binlog 链路当作兜底。

5. 并发竞争:同一行数据被同时改

并发竞争和前面说的不一致是两回事,但经常被混为一谈。不一致是"缓存和数据库对不上",竞争是"两个写操作互相覆盖"。后者即使没有缓存也会发生,缓存只是让问题更容易暴露。

典型场景:两个请求同时要给某个账户加积分,都先读出来是 100,各自加 10,然后都写回 110。正确结果应该是 120,但实际是 110,丢了 10 分。这种问题在单机时代用 synchronized 就解决了,但在多实例部署下,进程内的锁完全失效,必须用分布式的手段。

5.1 三种收口思路

第一种是数据库层面收口,用行锁或者乐观锁。行锁就是SELECT ... FOR UPDATE,把这一行锁住直到事务提交。简单可靠,但并发度低,容易造成锁等待甚至死锁。乐观锁则是加一个版本号字段,更新时带上版本号做条件:

UPDATE account SET points = points + 10, version = version + 1 WHERE id = 1001 AND version = 5;

如果影响行数是 0,说明版本号被人改过了,重试即可。这个方式并发度高,但需要重试逻辑,冲突激烈时重试次数会很多。

第二种是Redis 层面收口,用INCR、HINCRBY这类原子命令。Redis 的单线程模型保证了单个命令的原子性,多条命令的组合则要用 Lua 脚本。这种方式性能极好,适合计数器、库存这种简单累加场景,但要注意持久化策略,Redis 重启可能丢数据。

第三种是分布式锁收口,把整个读-改-写过程串行化。这是最通用的方案,代价是性能最差,因为所有请求排队执行。所以我的原则是:能用原子命令就别用锁,能用数据库乐观锁就别用分布式锁。锁是最后的手段,不是第一选择。

5.2 缓存里的并发写怎么处理

如果数据是直接写在 Redis 里的(比如购物车),并发竞争就更直接了。两个用户操作同一个购物车,或者同一个用户开了两个标签页同时改,都可能互相覆盖。

解决办法是尽量用增量操作代替全量覆盖。比如购物车加商品,不要先读出来整个列表再写回去,而是用 Hash 结构,HSET cart:1001 sku:2001 2这种按字段写,天然避免覆盖。如果必须做整体替换,那就得在 value 里带版本号,写入时用 Lua 脚本比对版本。

local current = redis.call('HGET', KEYS[1], 'version') if current and tonumber(current) >= tonumber(ARGV[2]) then return 0 end redis.call('HMSET', KEYS[1], 'data', ARGV[1], 'version', ARGV[2]) return 1

这个脚本的意思是:只有当传入的版本号大于当前版本号时才允许写入。这样即使两个请求乱序到达,最终也是版本号大的赢,不会出现"旧数据覆盖新数据"。这个思路和数据库的乐观锁是一样的,只是把比对逻辑放到了 Redis 里,省了一次网络往返。

6. 分布式锁:watch、setnx、redisson 三条路线

前面反复提到锁,这一节就专门讲清楚。Redis 分布式锁的本质,是利用 Redis 的原子命令实现"多个进程之间的互斥"。听起来简单,但要写得对,需要考虑的事不少:加锁的原子性、锁的归属、锁的过期、锁的续期、锁的释放。

6.1 watch 乐观锁:CAS 思路在 Redis 上的落地

WATCH是 Redis 事务机制的一部分。执行WATCH key之后,如果在MULTI和EXEC之间这个 key 被其他客户端修改过,那么EXEC会返回 nil,事务里的命令全部不执行。这本质上就是一次 CAS(Compare And Swap)。

用 Jedis 写出来大概是这样:

public boolean deductStock(String key, int count) { while (true) { jedis.watch(key); String value = jedis.get(key); if (value == null || Integer.parseInt(value) < count) { jedis.unwatch(); return false; } Transaction tx = jedis.multi(); tx.decrBy(key, count); List<Object> result = tx.exec(); if (result != null) { return true; // 事务成功 } // result 为 null 说明 key 被改过,重试 } }

这段代码有几个必须注意的地方。一是一定要有重试上限,虽然理论上冲突概率低,但在热点 key 上无限重试是真的会卡死线程的。我的做法是设个最大重试次数,比如 3 次或者 5 次,超过就返回失败让上层处理。二是unwatch不能漏,在任何提前 return 的分支上都要调用,否则 watch 状态会残留到下一次操作,导致莫名其妙的事务失败。

watch 的优点是不阻塞、不加锁,性能好,没有死锁风险。缺点是冲突时要做全量重试,如果这个 key 竞争激烈,大部分请求都会失败重试,实际吞吐反而下降。所以它适合冲突概率低的场景,比如单个商品的库存扣减,正常业务里同一时刻的并发不会特别高。如果这个 key 是秒杀热点,那还是老老实实用分布式锁。

另一个限制是事务里不能做条件判断。MULTI之后的命令只是被排队,不执行,所以你不能根据读到的值决定后面执行什么。这也是为什么上面那段代码要把判断放在MULTI之前。如果逻辑复杂,这个模式就会很难写,得改用 Lua 脚本。

6.2 setnx:简单背后全是坑

SETNX就是"set if not exists",key 不存在才设置成功。利用这个特性,谁设置成功谁就拿到锁。最朴素的写法:

boolean locked = redis.setnx("lock:order:1001", "1") == 1;

但这段代码有三个致命问题,一个个说。

问题一:没有过期时间。如果拿到锁的进程崩了,这把锁永远不会释放,其他人都得等到天荒地老。解决办法是设置过期时间,但SETNX和EXPIRE是两条命令,中间如果进程挂了,还是死锁。Redis 2.6.12 之后提供了SET key value NX EX seconds这条原子命令,一条搞定:

SET lock:order:1001 uuid-value NX EX 30

问题二:锁可能被别人删掉。假设线程 A 拿到锁,设置了 30 秒过期,但业务执行了 35 秒。30 秒时锁自动释放,线程 B 拿到了锁。然后 A 执行完,执行DEL lock:order:1001,把 B 的锁删了。这时候线程 C 又能拿到锁,出现两个线程同时持锁的情况。

解决办法是给锁的 value 加唯一标识,比如 UUID 加线程 ID,解锁前先判断是不是自己的锁。但GET和DEL之间又有时间窗口,必须用 Lua 保证原子性:

if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end

问题三:业务执行时间不确定。锁的过期时间设短了会提前释放,设长了会降低并发度。这个问题在 setnx 方案里没有优雅的解法,你只能尽量把过期时间设得足够长,然后用"锁的唯一标识校验"来兜底。这本质上是一种妥协,也正是 redisson 存在的理由。

我踩过最惨的一次坑是:某个接口加锁后调用了一个外部服务,那个服务偶发超时,把整个持锁时间拖到了 40 多秒,而锁的过期时间是 30 秒。结果高峰期出现了多笔重复扣款,排查了两天才定位到。后来改成 redisson 之后,看门狗会自动续期,这类问题就没了。

6.3 redisson:看门狗、可重入与红锁

redisson 把上面那些坑都封装好了。最基本的使用方式:

RLock lock = redissonClient.getLock("lock:order:" + orderId); try { if (lock.tryLock(3, TimeUnit.SECONDS)) { // 业务逻辑 } else { // 获取锁失败,走降级 } } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }

这里有几个点必须说清楚。第一,tryLock的参数个数决定看门狗是否生效。如果只传waitTime(如上),那么锁的默认租期是lockWatchdogTimeout,默认 30 秒,并且会启动看门狗自动续期,每 10 秒续一次,续到 30 秒。如果传了leaseTime,也就是tryLock(3, 30, TimeUnit.SECONDS),那么看门狗不生效,锁固定 30 秒后自动释放,不管业务有没有执行完。

这个区别极其重要。我见过不少团队的代码是tryLock(3, 10, SECONDS),写的本意是"最多等 3 秒,锁 10 秒",但业务偶尔执行 15 秒,锁就提前释放了,互斥失效。要么把leaseTime设得足够大,要么干脆别传,让看门狗管。

第二,unlock一定要判断isHeldByCurrentThread。如果不判断,当前线程没持锁却调用unlock,会抛IllegalMonitorStateException。这个异常在finally里抛出会掩盖真正的问题,让排查变得很痛苦。

第三,看门狗也不是万能的。如果整个 JVM 卡住了,比如长时间 Full GC 导致看门狗线程也停了,锁还是会过期。所以业务本身要有幂等设计,不能完全信任锁的互斥性。这一点很多人忽略,觉得加了锁就万事大吉,实际上分布式锁提供的是"大概率互斥",不是"绝对互斥"。

可重入是 redisson 相对 setnx 的一大优势。同一个线程可以重复获取同一把锁,内部用 Hash 结构记录了锁的持有者和重入次数。这在有嵌套调用的场景里非常必要——比如 service A 加了锁又调了 service B,B 里也加同一把锁,用 setnx 就会直接卡死自己。

红锁(RedLock)解决的是主从切换导致的锁丢失问题。单节点或者主从架构下,主节点写入锁后还没同步到从节点就宕机了,从节点升级成主节点,锁就丢了。红锁的思路是向多个独立的 Redis 节点同时加锁,超过半数成功才算加锁成功。代价是性能下降明显(要多轮网络往返)和运维成本上升(要维护多个独立节点)。我的看法是:绝大多数业务用单节点或主从就够,挂的概率和能造成的损失是可接受的;只有资金类的强一致场景才值得上红锁。

7. 线上排查与避坑实录

理论讲完,说几个真实的排查场景。这些事写不进文档,但真遇到的时候,能不能快速定位,全看有没有踩过。

7.1 三个高频故障的复盘

故障一:接口 RT 突增,数据库连接池耗尽。监控上看到 Redis 命中率从 98% 掉到 60%,数据库连接数打满。第一反应是缓存大面积失效,但检查了一下过期时间分布,并没有集中。后来用MONITOR抓了一下命令,发现有个前缀的 key 在被频繁查询,而且每次都 miss。顺着 key 找到了代码,是某个定时任务批量查了一批已经不存在的 ID。这类问题的排查套路是:先看命中率曲线,找出下滑的时间点,再对照 key 前缀统计 miss 的分布。Redis 的INFO stats里有keyspace_hits和keyspace_misses,能算整体命中率,但定位到具体前缀需要自己埋点。

故障二:偶发的数据不一致。用户反馈"改完资料,刷新还是旧的"。日志里查不到异常,因为两次删除都成功了。后来加了一段日志,记录每次删除的时间戳和当时缓存里的值,才发现是延迟双删的第二次删除太早了,只有 200 毫秒,而那个接口的回源查询 P99 是 400 毫秒。改成 1 秒后就再没出现过。这个教训是:延迟时间一定要按实际压测数据来定,不能凭感觉。

故障三:分布式锁失效导致重复处理。消息队列的消费端加了锁,但还是出现了重复消费。排查后发现是tryLock传了leaseTime,业务处理时间超过了锁的租期。改法很简单,去掉leaseTime让看门狗接管,同时在消费端加了幂等表兜底。这两步缺一不可,锁是性能优化,幂等才是正确性保证。

7.2 速查表

现象最可能的原因快速验证方式处理动作
命中率骤降,DB 压力飙升大量 key 同时过期或 Redis 重启看INFO stats命中率曲线加随机 TTL、做预热
单接口 RT 抖动,DB 有尖峰热点 key 失效击穿抓该 key 的访问量并对照过期时间加互斥重建或逻辑过期
查询全 miss,DB QPS 异常高缓存穿透统计 miss 的 key 是否大量重复或随机参数校验、空值缓存、布隆过滤器
用户看到旧数据删除缓存时机不对记录删除时间戳与回源时间对比调整延迟双删时间或改用 binlog 订阅
同一数据被重复修改并发竞争查是否有版本号或原子操作加乐观锁或改用原子命令
锁不生效,出现并发锁租期短于业务耗时打印持锁开始和结束时间启用看门狗或加幂等

7.3 几条不带文档的经验

容量要按峰值算,不要按平均算。缓存内存规划的时候,很多人用日均 QPS 去估,结果大促一来内存打满,触发了淘汰策略,把热点 key 挤了出去,直接引发雪崩。我的做法是按峰值的 1.5 倍预留,并且把maxmemory-policy设成volatile-lru或者allkeys-lru,确保淘汰的是最不常用的数据。如果数据重要到不能淘汰,那就得单独拆实例。

压测一定要压缓存失效的场景。正常压测全是缓存命中,测出来的 QPS 再高也没意义。正确的做法是在压测中途手动删掉热点 key,看系统的恢复能力和数据库的承受能力。这一压,往往能暴露出互斥锁的等待时间过长、降级逻辑没生效、连接池配置不够等一系列问题。

监控要盯三类指标:Redis 的命中率和慢查询数、数据库的连接数和慢 SQL、业务侧的 RT P99 和错误率。这三类指标要能联动看,光看一个容易误判。比如命中率下降但数据库正常,可能是缓存过期策略调整了;命中率正常但 RT 上升,可能是网络或者慢命令。

降级开关一定要提前做好,并且定期演练。写一个开关把缓存层整个绕过,直接查库,看看系统还能不能撑住。撑不住说明缓存已经变成了单点依赖,得考虑多级缓存或者本地兜底。这个演练我建议每个季度做一次,别等到真出事的时候才发现开关是个摆设。

锁的粒度要尽可能细。见过有人用lock:order锁住所有订单操作,并发度直接归零。正确的做法是锁到具体的订单 ID,lock:order:1001。粒度越细,冲突越少,吞吐越高。但也不能无限细,细到每个字段一把锁,管理成本就上来了,取个中间值,按业务实体的粒度来。

最后分享一个我自己的习惯:每次设计缓存方案,我都会在文档里写清楚三件事——key 的命名规则和 TTL 策略、失效后的回源路径、以及最坏情况下的兜底方案。这三件事写不清楚,方案就是不完备的。线上问题的根因,十有八九能在"兜底方案没想清楚"里找到。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 20:51:00

Ponytail:轻量级本地AI工作流胶水层,FastAPI+React+Claude一键启动

1. 项目概述&#xff1a;Ponytail 是什么&#xff0c;它解决的是哪一类真实开发痛点&#xff1f;Ponytail 这个名字乍看像一个发型&#xff0c;但在当前开发者社区里&#xff0c;它正快速成为一个指向明确、功能聚焦的技术代号——它不是一个通用框架&#xff0c;也不是某个大厂…

作者头像 李华
网站建设 2026/10/27 20:48:17

Playwright测试依赖管理:隔离与复用的最佳实践

1. Playwright测试用例依赖管理的核心挑战在现代Web自动化测试中&#xff0c;测试用例的依赖管理一直是个令人头疼的问题。我最近在一个电商项目中用Playwright重构测试套件时&#xff0c;就深刻体会到了这一点。当你有200多个测试用例需要维护时&#xff0c;如何平衡测试独立性…

作者头像 李华
网站建设 2026/10/27 20:46:38

CSES算法题库:面试高频考点与隐式状态建模解析

简介&#xff1a;CSES Problem Set 是面向算法学习者与C编程进阶者的高质量在线题集配套代码资源&#xff0c;覆盖基础语法、动态规划、图论、贪心、数论、位运算等10大核心模块&#xff0c;专为ACM/ICPC、力扣刷题及大厂技术面试准备者设计。资源包为ZIP格式&#xff0c;共28个…

作者头像 李华
网站建设 2026/10/27 20:46:20

热词营销实战指南:从捕捉信号到快速行动,低成本赢取流量红利

聊“营销技能”这四个字&#xff0c;很多人第一反应是销售话术、谈判技巧或者写文案的能力。但做了十来年一线营销&#xff0c;我的体会是&#xff1a;真正拉开差距的&#xff0c;往往是“捕捉信号并快速行动”的功夫。就拿最近特别火的网络热词来说&#xff0c;一个词从出现到…

作者头像 李华
网站建设 2026/10/27 20:45:46

联邦学习在电网负载测试中的实践与优化

1. 电网负载测试的技术演进与挑战现代电力系统正面临前所未有的复杂性和不确定性。随着新能源大规模并网和用电负荷多元化&#xff0c;传统的集中式负载测试方法已难以满足实时性、隐私保护和计算效率的需求。我在参与某省级电网智能化改造项目时&#xff0c;曾亲眼目睹传统测试…

作者头像 李华
网站建设 2026/10/27 20:45:20

GCC C++17编译环境搭建实战:CentOS/Ubuntu/macOS多系统安装与验证

简介&#xff1a;本资源为Windows平台下开箱即用的C/C标准GCC编译环境&#xff0c;面向初学者、嵌入式开发入门者及跨平台编程学习者&#xff0c;解决Windows用户缺乏原生GNU工具链、难以搭建C/C编译环境的核心痛点。压缩包含1508个文件&#xff0c;总大小71.1MB&#xff0c;主…

作者头像 李华