ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

缓存雪崩复盘实录,从整点故障到随机 TTL 的实战改造

缓存雪崩复盘实录,从整点故障到随机 TTL 的实战改造 事故现场整点发券引发的连锁反应晚上八点整电商大促的流量洪峰如期而至。监控大屏上订单服务的 QPS 曲线瞬间拉升了六倍这本是预期内的热闹景象。然而仅仅过了几十秒原本平滑的响应时间RT曲线突然垂直飙升从稳定的 80ms 直接跳变到 8 秒甚至 15 秒。紧接着Nginx 网关开始大面积返回 504 Gateway Timeout应用日志里充斥着Cannot get a connection from pool的报错。这不是普通的网络抖动而是一次典型的缓存雪崩。在事故发生的前几分钟运营团队为了预热活动通过脚本批量加载了数千个活动详情的缓存 Key。按照当时的代码逻辑这些 Key 被统一设置了 5 分钟的固定 TTLTime To Live。于是在 19:55 到 19:56 之间写入的大量缓存不约而同地定在了 20:00 到 20:01 之间过期。当整点流量洪峰到来时恰好撞上了这批缓存的“集体死亡”。Redis 命中率瞬间从 99% 跌落至 20% 以下原本应该被缓存层挡住的数万并发请求如同决堤的洪水一般直接冲向了后端的 MySQL 数据库。数据库连接池迅速被耗尽慢查询堆积如山应用服务器的 Tomcat 线程因等待数据库响应而全部阻塞最终导致整个链路瘫痪。这次事故持续了约 23 分钟直到我们紧急限流、降级并回滚配置后才得以恢复。根因深挖固定 TTL 在洪峰下的致命缺陷复盘这次事故表面看是流量过大实则是缓存策略与流量特征的错误匹配。在常规的低并发场景下固定 TTL 策略简单高效几乎不会出问题。但在大促这种特定场景下它暴露了两个致命弱点过期时间集中化当大量热点数据在同一时间段写入且设置相同的过期时长它们的失效时间点就会高度重合。一旦这个时间点撞上流量高峰缓存层会瞬间失去防护能力。缺乏互斥保护在缓存失效的瞬间成百上千个线程同时发现缓存未命中Cache Miss它们会毫无阻拦地并发查询数据库。对于聚合接口而言一次 miss 可能触发多张表的关联查询这使得数据库的压力呈指数级放大。这种“雪崩”不同于“击穿”。击穿通常指单个热点 Key 失效引发的冲击而雪崩则是大面积 Key 同时失效导致的系统性崩溃。在这次事故中由于没有设置随机过期时间也没有引入互斥锁机制系统在面对并发 miss 时完全处于“裸奔”状态。核心改造TTL 随机化打破时间同步解决雪崩的第一道防线是打散缓存的过期时间。我们必须避免所有 Key 在同一秒过期让失效时间点均匀分布在一个时间窗口内。实现非常简单只需在设置 TTL 时增加一个随机因子。不要直接使用固定的300秒而是基于基础时间加上一个随机波动值。public void setCacheWithRandomTTL(String key, String value) { int baseTTL 300; // 基础过期时间 5 分钟 int randomOffset ThreadLocalRandom.current().nextInt(0, 60); // 随机增加 0-60 秒 int finalTTL baseTTL randomOffset; redisTemplate.opsForValue().set(key, value, finalTTL, TimeUnit.SECONDS); }通过这段简单的改造原本集中在 20:00:00 过期的 Key现在会分散在 20:00:00 到 20:01:00 之间陆续失效。即使流量洪峰依旧数据库受到的冲击也会被拉长、稀释从而避免连接池瞬间被打满。这是预防雪崩成本最低、收益最高的手段应作为所有缓存写入的标准规范。进阶防御互斥锁实现“单飞”回源解决了雪崩还得防住击穿。即便 TTL 已经随机化某个极热的 Key 在过期瞬间仍可能有大量请求同时_miss_。如果放任这些请求全部查库数据库依然可能扛不住。这时候需要引入互斥锁Mutex Lock确保同一时刻只有一个线程去查数据库重建缓存其他线程等待或重试。利用 Redis 的SETNX命令可以轻松实现分布式锁。以下是具体的 Java 伪代码实现展示了如何在缓存未命中时加锁public ActivityDetailVO getActivityDetail(Long activityId) { String key activity:detail: activityId; // 1. 先查缓存 String json redisTemplate.opsForValue().get(key); if (json ! null) { return parseJson(json); } // 2. 缓存未命中尝试获取分布式锁 String lockKey lock: key; // 设置锁过期时间为 3 秒防止死锁 boolean isLocked redisTemplate.opsForValue() .setIfAbsent(lockKey, 1, 3, TimeUnit.SECONDS); if (!isLocked) { // 3. 没抢到锁说明有其他线程正在查库重建缓存 // 短暂休眠后重试读取缓存避免直接查库 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } // 重试读取 String retryJson redisTemplate.opsForValue().get(key); if (retryJson ! null) { return parseJson(retryJson); } // 如果重试还是没有说明重建失败或超时返回兜底数据或抛异常 throw new BusinessException(系统繁忙请稍后重试); } try { // 4. 抢到锁再次 Double Check 缓存防止锁等待期间其他线程已重建 String doubleCheck redisTemplate.opsForValue().get(key); if (doubleCheck ! null) { return parseJson(doubleCheck); } // 5. 执行昂贵的数据库查询 ActivityDetailVO vo queryFromDatabase(activityId); // 6. 写回缓存记得带上随机 TTL int ttl 300 ThreadLocalRandom.current().nextInt(60); redisTemplate.opsForValue().set(key, toJson(vo), ttl, TimeUnit.SECONDS); return vo; } finally { // 7. 释放锁 redisTemplate.delete(lockKey); } }这套逻辑的核心在于只允许一个请求“单飞”回源数据库其余请求要么等待锁释放后读取新缓存要么在短暂等待后直接返回。这能将数据库的并发压力降低数个数量级。体验优化软过期与异步刷新互斥锁虽然能保护数据库但会导致抢不到锁的请求出现延迟等待锁或重试。对于对实时性要求不那么苛刻的活动页、商品详情页我们可以采用**软过期Logical Expiration**策略进一步提升用户体验。思路是将过期时间存储在缓存值的内部而不是依赖 Redis 原生的 TTL。数据结构改造缓存 Value 不再只是业务数据而是一个包含data和expireAt的对象。读取逻辑请求进来后先判断expireAt是否已过。若未过期直接返回数据。若已过期立即返回旧数据保证用户无感知同时异步触发一个刷新任务去更新缓存。异步刷新刷新任务同样需要加锁确保只有一个线程去查库更新更新完成后新的expireAt生效。这种方式实现了“后台静默更新前台始终有数”彻底消除了用户侧的等待抖动非常适合读多写少的热点场景。验证与监控从压测到告警的闭环代码改造完成后必须通过严格的压测来验证效果。我们不能只在正常场景下跑分而要模拟故障场景模拟集中过期在压测脚本中强制删除一批热点 Key或者将它们的 TTL 设置为即将过期然后发起高并发请求如 5000 QPS。观察指标重点监控数据库的 QPS 和 RT。在未加锁前DB QPS 会随并发量线性飙升加入互斥锁后DB QPS 应维持在极低水平接近单线程查询能力且应用端 RT 不应出现秒级抖动。故障演练模拟 Redis 响应变慢或数据库超时的场景验证降级策略是否按预期触发确保系统不会因依赖项故障而整体挂掉。除了压测完善的监控告警是最后一道防线。建议重点关注以下指标缓存命中率一旦在短时间内如 1 分钟跌幅超过 20%立即触发告警。数据库连接池使用率活跃连接数超过阈值如 80%时预警。接口响应时间RTP99 RT 出现异常飙升时自动通知。Key 过期分布如果有条件监控单位时间内过期 Key 的数量波动防止集中过期。结语缓存雪崩并非不可战胜的难题它往往源于对细节的忽视。从固定 TTL 到随机化从无锁并发到互斥单飞再到软过期异步刷新每一步优化都是在为系统的稳定性添砖加瓦。真正的架构韧性不在于永远不出问题而在于当问题发生时我们有足够的机制将影响控制在最小范围让用户无感让系统自愈。这次复盘不仅修复了一个 Bug更建立了一套应对高并发场景的标准防御体系这才是技术成长的核心价值。
返回列表