资讯动态

Redis缓存与分布式锁实战:穿透、击穿、雪崩与锁机制演进

发布时间:2026/10/21 16:27:47 来源:尧图企业网站定制
我最早接触Redis的时候以为它就是个大号的HashMap存个session、顶个热榜数据就完事了。直到线上出现第一个事故大家围在一起排查才真正意识到Redis作为缓存的威力与杀伤力是并存的。这个系列写到这里前面几篇把基础数据结构、持久化和集群方案都过了这次集中聊两个在工作中最常用的场景——缓存和分布式锁把那些踩过的坑、用过的方案以及为什么有些写法看起来对、实际却在埋雷一次性讲清楚。缓存这块核心是穿透、击穿、雪崩三兄弟分布式锁这块核心是从手工SETNX到框架化续期再到RedLock争议的完整演进。这篇文章没有高深的理论堆砌就按实操逻辑一步步说先讲清楚问题本质再给可落地的解决方案最后附上排查清单和自己的经验体会。1. 缓存三大灾难穿透、击穿、雪崩1.1 缓存穿透一个查啥都没有的key缓存穿透说的是请求查的数据在缓存里没有在数据库里也没有于是每次请求都直接打到数据库上。正常缓存是“缓存不命中→查数据库→回填缓存”但如果数据库里压根没有这条记录缓存永远回填不了相当于这层防线直接失效了。一个恶意脚本请求十万个不存在的用户ID数据库连接池瞬间被打满这就是穿透的典型杀伤力。穿透的根源是数据不存在这个“空状态”没有被缓存。解决思路也简单把空结果也缓存起来Key设置为查不到数据的请求键Value标记个空占位同时给它一个较短的过期时间比如60秒。这样同一ID的重复请求在1分钟内不会再打穿数据库。空值缓存有个细节要注意过期时间别太长否则数据真正写入后用户会有长达一个过期周期的“假空窗期”也别太短否则在高频攻击下碎片化回源失去了保护意义。我一般习惯设成30到60秒结合业务容忍度权衡。另一种更高级的防御手段是布隆过滤器。把数据库里所有存在的ID提前加载进布隆过滤器请求进来先问布隆过滤器“这个ID存在吗”如果回答不存在直接返回连Redis都不查了。布隆过滤器本质是一个位数组加上多个哈希函数用极少的内存空间容纳海量key代价是存在误判率——它说“不存在”一定不存在说“存在”可能不存在。所以布隆过滤器的正确使用场景是挡“确定没有的”而不是验证“一定有的”。1.2 缓存击穿热点key突然失效击穿和穿透经常会搞混一字之差场景完全不同。穿透是查一个永远不存在的数据击穿是查一个“曾经很热、刚刚过期”的数据。某个热点key比如热门商品详情平时缓存命中率极高结果在某一个瞬间过期了此时恰好同时涌来几千个请求缓存不命中全部穿透到数据库。数据库单点压力瞬间飙高严重的直接宕机。处理击穿的核心思路是“不让并发回源一把梭”。最常见的手段是互斥锁请求发现缓存过期先加锁只让一个请求去数据库加载数据并把缓存写好其余请求等待或者快速失败。用Redis自带的能力就能实现SET key value NX EX拿不到锁的请求要么自旋重试要么直接返回降级数据。还有一种思路是逻辑过期。热点数据本身不设置物理过期时间而是Key里塞一个逻辑过期戳每次读取时检查这个戳发现过期了主动异步刷新。这种方案对HP极热数据友好但会引入短暂的不一致和“过期了还在继续用老数据”的问题需要业务上接受。1.3 缓存雪崩大面积过期引发的连锁反应雪崩是击穿的放大版。击穿是单个热点key失效雪崩是大批key在同一时间段集中失效比如缓存统一设置了30分钟过期零点整一大片缓存全部清空所有请求同时回源数据库直接被一波流量带走。还有一种隐形的雪崩是缓存节点宕机整个缓存层不可用流量全部倾泻到数据库。解决方案有三个层次过期时间随机化。不要设置统一的过期时间基础过期时间加一个随机偏移量比如30分钟变成30分钟加0到5分钟的随机值让过期时间点散开。多级缓存兜底。Redis上层加一层进程内缓存比如本地Map或CaffeineRedis挂了还有本地撑着。本地缓存容量小、时效短但胜在抗大流量冲击。服务降级熔断。数据库侧的保护性措施当回源请求数超过阈值时直接短路返回默认兜底数据。我在实际项目里见过一个非常典型的雪崩案例运营后台做一次配置批量更新代码里把所有相关Key都调用了DEL或更新接口忘了每批之间做延迟导致上百个热点Key在同一秒集体失效线上监控报警瞬间炸锅。后来改成分批释放每批间隔几百毫秒问题才平息。所以人为批量操作触发的缓存失效远比自然过期更加凶险这一点在系统设计时经常被忽略。为了方便记忆和对照我顺手整理了下面这个对比表场景核心成因表现特征首选解法穿透查询不存在的数据缓存和数据库都无值DB被打空值缓存、布隆过滤器击穿单个热点key过期失效一个key的并发请求全回源互斥锁、逻辑过期雪崩大面积key集中过期或节点宕机大量请求同时回源DB过载过期随机化、多级缓存、熔断2. 缓存一致性数据改了缓存怎么办2.1 为什么缓存和数据会不一致只要用了缓存不一致问题就绕不开。数据库里商品价格从100改成80缓存里还存着100用户看到的还是旧价格这就是典型的不一致。不一致的来源无非三类更新数据库成功但更新缓存失败更新数据库和更新缓存之间发生了并发请求没有更新缓存而是删缓存但删除时机不对。实际项目中最常见的模式是Cache Aside也就是旁路缓存。读请求先查缓存不命中则查库并回填写请求优先更新数据库然后让缓存失效。之所以选择“更新数据库后删缓存”而不是“更新数据库后更新缓存”背后有很现实的考量更新缓存的成本高而且并发条件下可能出现脏写。比如两个线程同时修改数据线程A改完写缓存100线程B改完写缓存80但因为调度顺序错乱最后缓存的可能是100这个旧值。删缓存则不同下次读时重新加载天然纠正错乱状态。2.2 更新数据库后删缓存顺序该怎么排Cache Aside的写流程有两种先更新库再删缓存或者先删缓存再更新库。先删缓存再更新库有一个明显的竞态窗口线程A删掉缓存还没更新数据库线程B来读缓存没命中就去数据库读到了旧值并回填等线程A更新完库缓存里存的还是旧值。要解决这种问题需要延迟双删先删缓存然后更新库再延时一段时间后二次删除缓存。先更新库再删缓存则是另一种竞态线程A更新库为新值此时线程B刚好读库拿到了新值回填缓存线程A随后删除缓存缓存里是新值看起来没问题。但如果不巧线程B在A更新库之前读到了旧值回填A再删掉这个旧缓存反而安全。总体上先更新库再删缓存因为删除动作发生在最后其实已经自带了一定程度的自我纠错能力这也是它更主流的原因。真正麻烦的是删除缓存这步可能失败。Redis连接超时、命令执行报错都会导致旧缓存残留。常规做法是引入重试机制但同步阻塞重试会拖慢主流程我建议把删除失败的Key投递到消息队列由异步任务专门消费重试而不是在原请求路径上反复折腾。2.3 延迟双删的细节问题延迟双删的具体实现大概是这样的伪代码1. 删除缓存Key 2. 更新数据库 3. Thread.sleep(延迟时间) 4. 再次删除缓存Key这个延迟时间到底取多少很多人拍脑袋定为500毫秒。它其实应该大于“读请求从数据库读取数据到回填缓存这个动作的耗时”。如果读请求在把旧值回填缓存之前你二次删除已经执行完了等于白删如果延迟时间太长期间用户读到的都是旧缓存一致性窗口变长。正常QPS的业务200到500毫秒通常够用需要看实际监控。延迟双删有个天生的缺限如果第4步再次失败依旧留下旧值。它只是降低了出问题的概率并没有根除。根除的方案也有但要付出架构代价。一是订阅数据库的binlog变更把缓存失效动作完全和业务代码解耦由专门的同步服务订阅变更事件解析出变更的Key再去删除Redis缓存。二是使用带版本号或时间戳的缓存框架写入时带上版本标识读取时校验版本过旧则放弃。这两种方案分别带来了消息中间件和缓存框架的选型成本不是所有项目都值得上。我个人的处理原则是业务允许秒级短暂不一致的用Cache Aside加延迟双删就够要求强一致的放弃Redis缓存改用数据库直接读或者引入缓存版本机制中间地带的加个消息队列兜底重试。做架构方案要能接受去掉理想主义后的现实代价——与其追求理论完美不如框定一个可容忍范围。3. 分布式锁从SETNX到Redisson的演进之路3.1 为什么单机锁不够用单机应用里Java的synchronized或ReentrantLock就能解决互斥问题。一旦应用部署多个实例或者微服务拆分成多个节点每个实例拥有独立的锁单机锁就失效了。两个节点同时执行同一条扣减库存逻辑各自拿到一把“自己进程内的锁”以为自己独占资源实际却同时在操作同一行的库存数据最终数据就超卖了。分布式锁的本质是什么把“锁”的状态存放在所有节点都能访问的第三方组件上所有节点对这个组件做原子操作抢到锁的节点才允许执行业务。组件可以是数据库的唯一索引可以是ZooKeeper的临时顺序节点可以etcd的租约机制但实际落地项目里最普及的还是Redis因为大部分公司本来就部署了Redis不需要额外引入新的基础设施。Redis执行命令是单线程的命令天然具备原子性这是它适合做分布式锁的重要前提。3.2 手撸一把锁从SETNX开始但别停留在SETNX早期版本的Redis提供了SETNX命令意思是“如果Key不存在则设置成功”正好可以用来抢锁。最早的写法是这样的SETNX lock_key unique_value # 抢锁 EXPIRE lock_key 10 # 设置过期时间防止死锁这个写法有一个很致命的问题SETNX和EXPIRE是两条命令不是原子操作。如果SETNX执行成功之后进程崩溃或者GC停顿导致EXPIRE没执行锁没有过期时间其他节点永远拿不到锁这就是死锁。后来Redis升级了SET命令的参数支持原子地完成写入和设置过期SET lock_key unique_value NX EX 10一条命令搞定既保证只有第一次能写入成功又给锁加了过期时间。但新问题来了锁过期了但业务还没执行完怎么办这里引出两个经典问题——误删锁和需要续期。先看误删代码很多人会这么写if (jedis.get(lock_key).equals(unique_value)) { jedis.del(lock_key); }这个逻辑自己跑当然没问题但放在并发环境里就埋了雷。线程A拿到锁业务执行中阻塞超过了10秒锁自动过期释放线程B抢到锁开始执行此时A终于完成业务去执行删除锁的代码检查发现Key还在就直接DEL了——它删除的是B持有的锁。正确做法是删除前做两步原子校验加删除用Lua脚本把“比对Key的value是否等于自己的唯一标识相等则删除”绑定成一个原子操作if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这个Lua脚本是分布式锁从“能跑”到“健壮”的重要转折点值不再是随便一个字符串而是每个请求唯一的UUID或者业务流水号删除不再是先查再删而是脚本内比较。这里也解释了为什么Redis单线程在分布式锁里如此重要——正因为命令串行执行Lua脚本的检查和删除之间不会插入其他客户端命令保证整个操作原子进行。3.3 看门狗续期锁的自动续命机制锁过期时间设短了业务没执行完锁就没了设长了如果持有锁的节点宕机锁会闲置很长时间。这个矛盾靠“锁续期”解决技术圈喜欢把这套机制比喻成看门狗。持有锁的节点在锁快过期时主动为锁续期只要业务还在运行锁就一直被续命业务结束释放锁同时停止续期。用原生Redis实现续期需要自己写定时任务逻辑复杂不说多线程编排容易出错。实际工作中我直接用Redisson框架它的RLock封装了完整的分布式锁语义RLock lock redissonClient.getLock(order:create: orderId); try { // 加锁等待时间为5秒leaseTime不传则走看门狗自动续期 boolean locked lock.tryLock(5, TimeUnit.SECONDS); if (locked) { // 执行扣库存、生成订单等业务逻辑 } } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }Redisson默认的看门狗续期时间是30秒每10秒检查一次如果业务没执行完自动续期到30秒续期动作由框架后台线程完成不需要业务侧感知。一旦节点崩溃看门狗不再续期锁最多存活30秒后被自动释放不会死锁。这套方案在国内项目里非常普及是平衡可靠性、易用性和团队熟悉度的主流选择。3.4 RedLock争议分布式锁的终极考题Redis分布式锁最常见的担忧是主从切换时的锁丢失。比如锁被写入主节点主节点还没来得及把数据同步给从节点就挂了哨兵把某个从节点提升为新的主节点此时新主节点上没有这把锁另一个节点就能同时拿到同一把锁互斥失效。有一些场景对这个风险不可接受于是有人提出了基于独立Redis节点的RedLock算法部署多个互相独立的Redis实例客户端依次尝试对每个实例加锁只有超过半数实例加锁成功才视为最终获得锁。RedLock理论模型看着漂亮但实践中有很多争议。比如锁的有效期需要减去各节点加锁消耗的耗时时钟跳跃会导致锁的过期时间不准确任何一个节点的GC停顿都可能影响整体判断。业界知名分布式研究者对此提出过系统性质疑核心观点是分布式锁的可靠性与分布式共识算法的正确性同等重要RedLock的容错假设存在漏洞它并不比单实例加锁更安全却付出了更大的复杂代价。我的态度是绝大多数业务场景单实例Redis加锁配合看门狗已经足够可靠。超卖控制、幂等控制、任务互斥这些场景本身就能容忍极端情况下的少数并发穿透真到了需要严格互斥、锁丢失不可接受的金融级场景不如直接选ZooKeeper或etcd这类具备强一致性共识协议的工具。用Redis做分布式锁是用“可用性换一致性”用ZooKeeper是用“一致性损耗可用性”。先把业务对一致性的真实需求想清楚再决定技术选型比引入一整套复杂算法更有价值。4. 实战避坑指南超卖、重入与主从切换4.1 经典超卖事故复盘我职业生涯里遇到过一起典型的超卖事故起因非常简单库存扣减逻辑只判断了数据库里的库存大于0就执行UPDATE库存减1部署了两台应用节点没有加任何分布式锁。并发高峰期两线程同时读到剩余库存为1各自执行UPDATE最终卖出的商品数量大于实际库存。当时监控系统在流量洪峰之后抛出了库存负数告警盘中紧急下线商品事后核对报表超卖了几十单。如果当时有分布式锁正确姿势是这样的流程抢锁→读取库存→判断库存足够→扣减库存→释放锁。锁粒度要控制在单个商品的维度比如锁定“商品ID库存扣减”这个操作而不是给整个系统的所有写操作上一把大锁。锁粒度越大并发度越低锁粒度太小又增加了锁管理的复杂度。经验法则是锁应该覆盖临界区的最小范围扣库存只扣库存别把生成订单、通知物流、更新用户积分全塞进一个锁里不然系统吞吐量会被锁拖垮。补充一个细节在高并发扣库存场景下纯靠分布式锁限流不是万能药因为锁内的读改写操作依然存在先读后写的窗口。更彻底的做法是用原子化的Lua脚本在Redis里完成“检查库存→扣减库存→返回结果”全流程直接把并发操作压缩成单点的原子操作数据库只需要承担最终落库。分布式锁解决的是跨节点的互斥问题Lua脚本解决的是单点Redis上的原子性问题两者配合效果最佳。4.2 重入锁和高并发下的等待策略有些设计场景要求同一个线程能够重复获取同一把锁比如一个方法内部嵌套调用另一个也需要加锁的方法。普通SETNX锁在同一线程里是拿不到第二次的因为Key已经存在。Redisson的RLock是可重入的内部用类似引用计数的机制维护线程持有次数加锁一次计数加一释放一次计数减一全部释放完毕其他线程才能抢锁。关于获取锁的等待策略经验丰富的人和经验不足的人写出的代码差别很大。新手经常默认所有拿不到锁的请求都要阻塞等待结果在极端高并发下业务线程全被锁等待卡死线程池占满链路雪崩。所谓“先到先服务”的公平队列等待在分布式场景很难做到实践时更稳妥的是限时等待加快速失败每个请求最多等待几百毫秒超时就返回失败或降级结果让客户端决定是重试还是提示用户稍后再来。对待分布式锁要像对待稀缺资源一样设计避免无界等待吞掉系统剩余容量。4.3 缓存和锁搭配使用时的隐藏坑Redis缓存和分布式锁经常同时出现在一条业务链路上配合不当会互相拆台。典型的错误做法是在缓存击穿的互斥锁里把锁的过期时间设置成固定值但回填缓存的时间可能因为数据库慢查询或网络抖动而远超锁的超时时间。锁一过期其他线程就能再次拿到锁导致多个线程同时在回源击穿防护实效。更隐蔽的场景是删除缓存这件事本身也参与了锁。比如一个线程持有分布式锁在更新数据另一个线程读取数据时走了缓存过期回源的逻辑也尝试获取同一把锁但拿不到就等待更新方释放锁后旧缓存还没删完读方又把旧库数据回填了。解决思路是把缓存回填和缓存失效都放进同一个锁保护范围内或者用上面提到的逻辑过期方案彻底避免“锁持有期间读取方回源”的问题。总结起来一句话用锁保护的是业务流程不是单个Redis命令凡是跨越多个缓存操作的一致性区域都要纳入锁的边界。4.4 主从切换、网络分区和锁丢失的现实权衡主从切换导致锁丢失的风险在实际环境中到底有多大这里要分两种情况看。一种是单个客户端并发极低、业务量极小的系统主从切换期间产生重复锁的概率微乎其微就算发生对业务影响也有限另一种是亿级流量、并发抢购类业务一旦互斥失效超卖金额可能就是几十万这种就必须严肃对待。处理主从切换锁丢失有三条路线。第一条是继续用Redis但接受万一不做额外措施适用于绝大多数普通业务第二条是引入RedLock或者切换到独立部署的多节点成本高但能规避单点丢锁第三条是换用ZooKeeper或etcd它们的分布式锁基于一致性协议锁状态是强一致复制的主节点切换也不会丢锁代价是性能比Redis低一到两个数量级但对于低频加锁的强一致场景完全够用。我的真实体会是不要为了追求极端可靠把系统复杂性推到远超业务需求的高度。架构设计本质是Trade-off能用简单方案解决90%的问题尽量不引入那套重武器。等业务真正到了需要那剩下的10%可靠性的阶段拥抱强一致组件也来得及组件选型没那么难难的是想明白自己到底需要哪一种一致性。5. 常见问题与排查思路速查下面这份清单是从我的故障复盘记录里整理出来的每一个都对应过真实的线上问题。现象可能原因排查手段解决方案操作提示成功但数据没变缓存沿用旧值未失效对比缓存与数据库值延迟双删或MQ补偿删除高并发下库存超卖未加锁或锁失效查看日志中的锁等待记录分布式锁Lua原子操作大量线程同时回源热点key过期未加互斥监控缓存命中率突降互斥锁/逻辑过期多个数据库连接爆满缓存大面积失效观察Key过期时间分布过期时间随机化锁等待导致接口超时锁持有时间过长检查锁内耗时日志缩小锁粒度、控制等待时间业务执行中锁被自动释放leaseTime短于业务耗时监控锁被抢占现象使用看门狗自动续期节点宕机后锁一直不释放忘记设置过期时间检查常量配置SET NX EX或框架默认续期排查类问题的核心原则是先看监控曲线确认问题范围再判断是缓存层还是数据库层最后结合业务日志定位具体Key或者锁的持有方。我习惯在缓存访问的入口处统一埋点记录Key、命中率、耗时、回源次数等关键指标平时看着平平无奇故障时排查效率至少翻几倍。这里再补充一个很多文章不会提的小技巧给锁的Key设计清晰的命名和唯一标识。比如“lock:order:pay:10001”这种格式比简单的“lock”要好用得多。一旦出现问题你能直接通过这个Key定位到是哪个业务、哪个资源、哪次操作排查效率高一个量级。写在最后这个系列写到这里关于Redis缓存和分布式锁的内容基本就完整了。我个人在实际操作中的体会是Redis本身并不难学难的是把它的特性和业务场景结合起来找到一个足够简单又足够可靠的平衡点。缓存穿透和击穿虽然听着恐怖但只要想明白“回源是需要成本的动作”方案自然就清晰了分布式锁虽然演进了很多轮但只要理解“锁的本质是让并发操作串行化”再复杂的框架也逃不出那几步。最后分享一个我一直沿用的设计习惯每次引入缓存或者锁之前先问自己三个问题——这个数据必须要缓存吗不缓存会出什么问题缓存了不一致能接受多久想清楚这三个问题大部分方案选型其实已经完成了。Redis是好工具但好工具的正确使用依赖于使用者的克制和判断。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑