缓存击穿核心解法只有两个:用分布式锁堵住并发洪流,或用逻辑过期让缓存“假装不死”;选哪个取决于能否接受脏数据,前者强一致但性能低,后者高性能但存在短暂不一致。

缓存击穿不是“会不会发生”的问题,而是“什么时候发生、打垮哪条链路”的问题。线上一旦出现,往往伴随 DB CPU 100%、连接池耗尽、接口超时雪崩。核心解法只有两个:用分布式锁堵住并发洪流,或用逻辑过期让缓存“假装不死”。选哪个,取决于你能不能接受脏数据。
为什么 SETNX 直接裸用会出事
很多人一上来就写 redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 5, TimeUnit.SECONDS),以为加了锁就安全了——实际生产中这几乎等于没锁。
-
SETNX不支持自动续期:如果业务查 DB 耗时超过 5 秒(比如连慢 SQL + 网络抖动),锁提前释放,其他线程立刻闯入,双重检查失效 - 没有可重入性:同一线程重复进方法会自己卡死自己
- 主从异步复制下,
SETNX可能返回 true,但从节点还没同步,后续GET在从节点读不到锁状态,导致多个“合法”线程同时重建缓存 - 没处理锁释放异常:try 块里抛了 RuntimeException,
unlock()没执行,锁永久残留
所以别手写 SETNX,直接上 RedissonClient.getLock(),它内置看门狗、可重入、自动续期、主从强一致性保障。
用 Redisson 写互斥锁必须带双重检查
加锁只是第一步,不加二次检查,照样会重建多次缓存。典型错误是加锁后直接查 DB,忽略了“加锁过程中别人可能已经写好了”。
- 第一次
GET缓存为空 → 进入加锁流程 -
tryLock(3, 10, TimeUnit.SECONDS)成功 → 立刻再GET一次缓存 - 如果此时缓存已存在(被其他刚释放锁的线程写入),直接返回,不查 DB
- 只有二次检查仍为空,才走
productDao.findById()+setex()
注意 setex() 的 TTL 要加随机偏移,比如 30 + RandomUtil.randomInt(10),避免大量 key 同一时刻过期引发连锁击穿。
Redis 缓存和数据结构管理技能。通过自然语言操作 Redis,支持 String、Hash、List、Set、ZSet、Stream 等数据结构操作。当用户提到 Redis、缓存、消息队列、会话存储时使用此技能。
逻辑过期方案里,expire 字段必须和业务数据一起序列化
所谓“逻辑过期”,不是 Redis key 不设 TTL,而是 value 里塞一个 expireTime 字段(毫秒时间戳),靠代码判断是否过期。这个字段不能单独存,否则缓存和过期时间不同步。
- 写缓存时:把
Product对象和当前时间 + 有效期封装成新对象,如CacheData<product></product>,其中含data和expireTime字段,再JSON.toJSONString()存入 Redis - 读缓存时:反序列化后先比对
System.currentTimeMillis() > cacheData.expireTime - 过期后:只对这个 key 尝试加锁,加成功就起异步线程刷新缓存,原线程直接返回
cacheData.data(旧值)
关键点:异步刷新线程必须在写入新缓存后,更新新的 expireTime,否则下次还是立刻过期;且刷新失败要记录日志,不能静默吞掉异常。
两种方案真正容易被忽略的细节
互斥锁方案最常漏的是降级兜底:tryLock() 失败后,不能简单 throw Exception 或返回 null,得查本地缓存、DB 从库、甚至返回上一次成功的缓存快照(需配合 CacheAside 模式预热)。
逻辑过期方案最难控的是“脏数据窗口”:如果业务要求“库存不能超卖”,那逻辑过期绝对不能用;但如果只是首页广告位、用户积分概览这类弱一致性场景,它反而比互斥锁更稳——因为不会因锁阻塞拖垮整个接口 TP99。
真实事故现场,90% 不是方案选错,而是没配好锁等待时间、没做二次检查、没给逻辑过期加监控告警(比如连续 5 次刷新失败就触发告警)。方案本身很薄,填坑的细节才厚。










