事故现场:整点发券引发的连锁反应
晚上八点整,电商大促的流量洪峰如期而至。监控大屏上,订单服务的 QPS 曲线瞬间拉升了六倍,这本是预期内的热闹景象。然而,仅仅过了几十秒,原本平滑的响应时间(RT)曲线突然垂直飙升,从稳定的 80ms 直接跳变到 8 秒甚至 15 秒。紧接着,Nginx 网关开始大面积返回 504 Gateway Timeout,应用日志里充斥着"Cannot get a connection from pool"的报错。
这不是普通的网络抖动,而是一次典型的缓存雪崩。
在事故发生的前几分钟,运营团队为了预热活动,通过脚本批量加载了数千个活动详情的缓存 Key。按照当时的代码逻辑,这些 Key 被统一设置了 5 分钟的固定 TTL(Time 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%)时预警。
- 接口响应时间(RT):P99 RT 出现异常飙升时自动通知。
- Key 过期分布:如果有条件,监控单位时间内过期 Key 的数量波动,防止集中过期。
结语
缓存雪崩并非不可战胜的难题,它往往源于对细节的忽视。从固定 TTL 到随机化,从无锁并发到互斥单飞,再到软过期异步刷新,每一步优化都是在为系统的稳定性添砖加瓦。真正的架构韧性,不在于永远不出问题,而在于当问题发生时,我们有足够的机制将影响控制在最小范围,让用户无感,让系统自愈。这次复盘不仅修复了一个 Bug,更建立了一套应对高并发场景的标准防御体系,这才是技术成长的核心价值。