速读#
这次故障表面上是大促流量超过预期,Redis 大量未命中后把请求压到数据库,连接池耗尽,最终导致订单创建失败;真正需要修复的却不是“再加一个限流器”。容量评估没有覆盖缓存集中失效,缓存重建缺少并发收敛,入口到数据库之间没有按稀缺资源做隔离,系统也没有在过载时主动牺牲非核心功能,这些条件叠在一起才形成了完整事故链。
原始案例没有提供真实公司的监控截图、日志和精确时间线,因此这篇不能假装已经证明了每个根因。下面按一份脱敏故障模型来复盘:已知现象与待验证假设分开,配置数字只作为起点,最终阈值必须由本系统的压测、SLO 和依赖容量决定。
先把故障链还原,而不是先挑组件#
已知现象包括:活动开始后入口流量达到原预估的数倍,缓存命中率明显下降,数据库活动连接迅速接近上限,下游调用开始超时,订单创建错误率上升。要把它正式定性为“缓存雪崩”,还应从 Redis 指标和访问日志中确认同一时间段的 expired_keys、命中率、各分片 QPS、CPU、网络流量与错误数;如果只是一个爆款 Key 失效,则更接近缓存击穿,如果是 Redis 节点不可用或 Key 版本整体切换,修复方向也不同。
在证据成立的前提下,故障大致沿下面的路径扩散:
真实流量超过容量模型 -> 一批缓存集中失效或不可用,命中率骤降 -> 同一数据被大量请求并发回源,数据库查询数和并发数激增 -> 连接池耗尽,请求开始排队和超时 -> 调用方重试进一步放大流量,下游线程池与消费链路被拖慢 -> 订单创建失败,部分请求状态不明,需要事后对账这里应区分直接原因、促成因素和流程缺口。数据库连接池耗尽是订单失败前的直接原因;集中失效、无单飞重建、缺少并发隔离与无界重试是促成因素;压测只覆盖正常缓存命中、没有演练集中失效和依赖变慢,则是容量与发布流程上的缺口。限流、熔断和降级当然需要补,但它们是控制爆炸半径的机制,不能替代对缓存失效原因的调查。
事故现场先止损,再谈完整修复#
过载发生后,第一目标是保护仍然正确的数据和最稀缺的下游资源,而不是立刻把所有缓存重新灌满。入口先关闭推荐、评论、积分展示等非核心调用,降低或停掉会放大压力的自动重试;订单入口按数据库安全并发收紧准入,超过能力的请求明确返回排队、稍后重试或系统繁忙,不能继续在应用线程池里无限等待。对于允许短时间陈旧的商品详情,可以临时返回旧缓存;库存、余额、优惠核销等强约束数据不能因为“保可用”就直接使用旧值完成交易。
随后应分批预热最重要的 Key,并观察 Redis、数据库和下游的恢复斜率。一次性全量回填会制造第二次回源尖峰,恢复流量也应该逐级放开,而不是看到连接池下降就立即解除全部限流。服务稳定后还要按请求幂等键、订单号、支付流水和库存流水核对状态不明的请求,确认没有重复订单、重复扣减或“前端失败但后端成功”的悬挂结果。
这套顺序看似保守,实际比直接扩容可靠。应用实例可以很快扩出来,数据库连接数、Redis 单分片吞吐和外部依赖却不会按同样速度增长;如果瓶颈在数据库,扩更多应用只会更快地把连接池打满。
缓存层要解决集中失效和并发重建#
普通 Key 最低成本的改法,是在基础 TTL 上增加随机偏移,避免同一批写入的缓存整点一起过期。下面是 Java 中最小的示例,随机区间只是演示,实际应结合数据更新频率和可接受陈旧时间确定:
Duration baseTtl = Duration.ofMinutes(30);long jitterSeconds = ThreadLocalRandom.current().nextLong(0, 301);
redisTemplate.opsForValue().set( cacheKey, value, baseTtl.plusSeconds(jitterSeconds));TTL 抖动只能摊开自然过期,解决不了批量删缓存、统一切换 Key 版本、Redis 故障或部署时重新写入同一 TTL。活动前还需要按优先级预热热点数据,发布流程避免一次性清空大范围缓存,并为重建设置并发上限。热点 Key 失效时可使用 single-flight 思路:只有一个执行者回源,拿不到锁的请求读取可接受的旧值、短暂等待或快速失败;锁必须有超时,回源前应再次检查缓存,避免持锁者完成后其他请求重复查询。
对商品介绍、榜单等允许短时间陈旧的数据,逻辑过期加异步刷新通常比让所有请求等待更稳:缓存值保留 expireAt,读到过期数据时先返回旧值,再由一个后台任务刷新。这个办法用一致性换可用性,不适用于成交价确认、实时库存和账户余额。Caffeine 一类本地缓存也能挡住 Redis 热点,但必须设置容量上限、短 TTL 或主动失效,并接受多实例之间短暂不一致;本地缓存不是免费的“再加一层”。
热点定位也不能把 LRU、LFU 和热 Key 统计混为一谈。LRU/LFU 首先是 Redis 的淘汰策略,redis-cli --hotkeys 借助 LFU 计数寻找高频 Key,使用前要确认策略,并注意扫描本身会增加线上负载:
redis-cli CONFIG GET maxmemory-policyredis-cli --hotkeys -i 0.01redis-cli OBJECT FREQ product:detail:1001生产环境更稳妥的来源通常是代理层统计、客户端采样和各分片 QPS/CPU 倾斜,命令行扫描只作为受控诊断手段。确认热点后,商品详情等读多写少、允许副本短暂不一致的数据可以做多副本 Key 或本地缓存;库存、计数扣减和余额不能简单复制后随机读取,否则压力虽然分散了,一致性问题却被放大。写热点应考虑分片累计、批量合并或重新设计业务聚合方式,而不是照搬读热点方案。
限流保护的是瓶颈,不是一个好看的 QPS 数字#
限流阈值应从依赖的安全水位反推。先在接近生产的数据量和缓存状态下逐级加压,找到 p99、错误率、CPU、数据库活动连接、连接等待时间和队列积压开始持续恶化的位置,再为单机故障、流量抖动和发布波动留出余量。固定的“CPU 低于 70%”或“集群容量乘 0.8”可以当初始假设,不能当通用答案;如果数据库连接已经是瓶颈,即使应用 CPU 只有 30%,系统也可能早已过载。
入口层适合用令牌桶吸收有限突发并控制总速率,服务内部还要用并发信号量或有界线程池保护数据库和关键下游。只限制 QPS 防不住慢请求:同样 500 QPS,请求耗时从 20 毫秒升到 2 秒时,在途并发会放大约一百倍。队列也必须有最大长度、最大等待时间和过期策略,超过处理能力就明确拒绝;无界排队只是把即时错误换成几分钟后的超时和更难清理的积压。
熔断器则用于依赖已经变慢或失败时快速切断传播。配置至少要考虑统计窗口、最小样本量、失败率、慢调用比例、调用超时和半开探测数;恢复成功后状态应从半开回到关闭,并逐步放量,避免刚恢复就被旧流量再次压垮。哪些错误计入失败必须按依赖语义定义,不能笼统地排除所有 4xx,例如下游返回的 429 就可能正是过载信号。超时也应来自整条请求链的时间预算和实测分布,而不是机械地等于历史 p99。
限流和熔断之外还需要舱壁隔离。推荐、库存、支付和日志上报不应共享一个无界线程池或同一份连接预算;非核心依赖变慢时,它只能耗尽自己的资源配额,不能抢走订单主链路最后的处理能力。
降级必须先定义什么叫“订单成功”#
降级不是统一返回一份假数据,而是先给业务能力排序。第一层可以关闭推荐、评论和复杂详情,静态内容走 CDN;第二层把积分、通知等非核心写入异步化;再严重时,订单入口可以进入排队或“受理中”模式。但只要前端显示“已受理”,系统就必须先持久化一条可查询的请求记录,并带有稳定的幂等键,后续消费、库存预留、支付和取消都要能重试与对账。仅仅把消息发进 MQ,既不自动保证消息成功落盘,也不自动保证订单只生效一次。
因此“先接单后处理”有明确前提:写入成功后返回受理编号,用户可以查询状态;重复请求命中同一幂等记录;消息超过有效期能够取消;库存不足或支付失败有补偿路径;消费者恢复后可以按业务记录重建消息。做不到这些条件时,诚实地返回排队或繁忙,比给用户一个最终无法兑现的“下单成功”更安全。
不同数据的降级边界也不同。商品图片和介绍可以旧,推荐可以空,积分可以延迟;成交价、库存扣减、账户余额和支付结果必须遵守各自的一致性规则。所谓“核心链路不断”不是所有请求都成功,而是系统在压力下仍能做出明确、可追踪且不会破坏数据的决定。
可观测性要能回答事故中的具体问题#
“日志、指标、链路追踪三件套”本身不是目标,关键是它们能否把同一时间线拼起来。缓存侧至少要看到命中率、expired_keys、evicted_keys、命令延迟、各节点 QPS/CPU/网络和热 Key 分布;数据库侧要看到活动连接、等待连接、查询延迟、锁等待与拒绝数;应用侧要记录准入拒绝、在途并发、线程池队列、重试次数、熔断状态和各接口错误率;MQ 除了队列长度,还要监控最老消息年龄,因为长度相同的积压可能只持续几秒,也可能已经延迟半小时。
每个订单请求还需要贯穿网关、应用、数据库和消息的 traceId、幂等键与业务单号。否则监控只能说明“系统很慢”,无法回答某个用户到底是被拒绝、仍在排队、已经成功但响应丢失,还是被重复执行。告警也应尽量领先于用户错误,例如缓存命中率快速下跌同时数据库等待连接上升,通常比等 5xx 达到高位更早暴露事故。
整改完成后不能只重跑一次正常压测。至少应覆盖集中失效一批热点 Key、单个超级热 Key、Redis 延迟或节点不可用、数据库变慢、调用方重试风暴和恢复阶段批量预热。验收标准应写成系统行为:数据库并发不越过安全水位,核心请求在既定错误预算内,超限请求能快速得到明确结果,缓存恢复不会制造第二次尖峰,最终对账没有重复和丢失。只有这些场景真实跑过,限流数字和熔断参数才不是纸面配置。
整改清单#
| 优先级 | 整改项 | 如何验证 |
|---|---|---|
| P0 | 为数据库和关键下游增加并发上限、有限重试与资源隔离 | 依赖变慢时连接池不耗尽,非核心故障不拖垮订单入口 |
| P0 | 热点缓存采用 TTL 抖动、受控预热和 single-flight 重建 | 批量过期压测时回源并发被限制,命中率可平滑恢复 |
| P0 | 明确订单受理、幂等、状态查询和事后对账 | 重试与响应丢失场景下不重复扣减,也不存在无法解释的订单状态 |
| P1 | 建立按业务优先级的动态降级开关和有界排队 | 压力上升时先关闭非核心功能,超过容量的请求不会无限等待 |
| P1 | 补齐缓存、数据库、线程池、熔断器和 MQ 年龄指标 | 能沿同一时间线定位命中率下降到订单错误的传播路径 |
| P2 | 活动前做故障注入、容量复测和分阶段恢复演练 | Redis、数据库或下游异常时,值班人员能按预案止损并安全恢复 |
这次事故真正暴露的不是 Redis、MQ 或熔断器数量不够,而是系统只在“缓存正常、依赖正常、流量符合预测”的前提下成立。大促稳定性要验证的恰恰是这些前提被打破以后,系统是否仍能限制回源、保护数据库、牺牲非核心功能,并给每一笔订单留下可恢复、可对账的结果。
