news 2026/8/30 19:38:52

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
缓存雪崩复盘实录,从整点故障到随机 TTL 的实战改造

事故现场:整点发券引发的连锁反应

晚上八点整,电商大促的流量洪峰如期而至。监控大屏上,订单服务的 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 策略简单高效,几乎不会出问题。但在大促这种特定场景下,它暴露了两个致命弱点:

  1. 过期时间集中化:当大量热点数据在同一时间段写入,且设置相同的过期时长,它们的失效时间点就会高度重合。一旦这个时间点撞上流量高峰,缓存层会瞬间失去防护能力。
  2. 缺乏互斥保护:在缓存失效的瞬间,成百上千个线程同时发现缓存未命中(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。

  1. 数据结构改造:缓存 Value 不再只是业务数据,而是一个包含dataexpireAt的对象。
  2. 读取逻辑:请求进来后,先判断expireAt是否已过。
    • 若未过期,直接返回数据。
    • 若已过期,立即返回旧数据保证用户无感知,同时异步触发一个刷新任务去更新缓存。
  3. 异步刷新:刷新任务同样需要加锁,确保只有一个线程去查库更新,更新完成后新的expireAt生效。

这种方式实现了“后台静默更新,前台始终有数”,彻底消除了用户侧的等待抖动,非常适合读多写少的热点场景。

验证与监控:从压测到告警的闭环

代码改造完成后,必须通过严格的压测来验证效果。我们不能只在正常场景下跑分,而要模拟故障场景:

  • 模拟集中过期:在压测脚本中,强制删除一批热点 Key,或者将它们的 TTL 设置为即将过期,然后发起高并发请求(如 5000 QPS)。
  • 观察指标:重点监控数据库的 QPS 和 RT。在未加锁前,DB QPS 会随并发量线性飙升;加入互斥锁后,DB QPS 应维持在极低水平(接近单线程查询能力),且应用端 RT 不应出现秒级抖动。
  • 故障演练:模拟 Redis 响应变慢或数据库超时的场景,验证降级策略是否按预期触发,确保系统不会因依赖项故障而整体挂掉。

除了压测,完善的监控告警是最后一道防线。建议重点关注以下指标:

  • 缓存命中率:一旦在短时间内(如 1 分钟)跌幅超过 20%,立即触发告警。
  • 数据库连接池使用率:活跃连接数超过阈值(如 80%)时预警。
  • 接口响应时间(RT):P99 RT 出现异常飙升时自动通知。
  • Key 过期分布:如果有条件,监控单位时间内过期 Key 的数量波动,防止集中过期。

结语

缓存雪崩并非不可战胜的难题,它往往源于对细节的忽视。从固定 TTL 到随机化,从无锁并发到互斥单飞,再到软过期异步刷新,每一步优化都是在为系统的稳定性添砖加瓦。真正的架构韧性,不在于永远不出问题,而在于当问题发生时,我们有足够的机制将影响控制在最小范围,让用户无感,让系统自愈。这次复盘不仅修复了一个 Bug,更建立了一套应对高并发场景的标准防御体系,这才是技术成长的核心价值。

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

XSLT 实例、元素与转换:从入门到实战

1. 引言XSLT(可扩展样式表语言转换)是一种用于将 XML 文档转换为其他格式(如 HTML、文本或其他 XML)的语言。它通过 XSLT 样式表定义转换规则,将源 XML 树映射为目标输出树。本文将通过丰富的代码实例,系统…

作者头像 李华
网站建设 2026/8/30 19:36:03

拿走计算器后,50个LLM原生算术能力评测揭秘

你有没有遇到过这种场景:一个日常对话、写代码、整理文档都很顺手的 LLM,却会在“9.11 和 9.8 哪个大”这种问题上翻车,或者算不对“23 47”? 很多开发者第一反应是换更强的模型,但真正的问题不在模型名字&#xff0…

作者头像 李华
网站建设 2026/8/30 19:35:36

DisplayWave:开源macOS显示管理工具,轻松解决外接显示器痛点

DisplayWave 是一个以 Show HN 形式出现在 Hacker News 上的开源项目,定位很直接:给 Mac 用户做一个好用的显示管理工具。作者在标题里写了两个关键词——open-source 和 simple。这基本说明了它的立场:不是闭源商业工具,而是希望…

作者头像 李华
网站建设 2026/8/30 19:21:13

Obsidian 报错 Vault not found解决方案

1. 背景说明摘要:本文记录了 Obsidian Web Clipper 剪藏插件报错 Vault not found 的完整排查与解决过程。文章先介绍安装环境,再重点讲解如何提前创建并正确配置 Obsidian Vault(知识库),随后演示 Web Clipper 的安装…

作者头像 李华
网站建设 2026/8/30 19:17:53

FreeRTOS任务相关API函数

一、FreeRTOS任务相关API函数介绍 任务相关的API主要如下: 函数 描述 uxTaskPriorityGet() 获取任务优先级 vTaskPrioritySet() 设置任务优先级 uxTaskGetNumberOfTasks() 获取系统中任务的数量 uxTaskGetSystemState() 获取所有任务状态信息 vTaskGetIn…

作者头像 李华
网站建设 2026/8/30 19:17:34

Stable Diffusion实战:从男生照片生成女生形象并与兄弟同框合影

男人变成性感美女的样子跟好兄弟走在了一起?第一次看到这个需求,我还以为是个搞笑段子。但仔细拆解一下,这其实是一个很典型的 AI 创意图像生成项目:把人脸做“性别变换”,再让变换后的形象与另一个真实人物生成同框合…

作者头像 李华