故障概述#
一套经过常规压测的订单系统在大促当天遭遇数倍于预期的瞬时流量。Redis 中大量缓存同时过期,缓存未命中请求直接回源数据库,连接池很快被打满;下游消费随之超时,最终出现订单创建失败和用户投诉。
表面上看,这是限流和熔断不足;沿故障链继续追查,根因其实同时落在容量规划、缓存失效策略和故障隔离三处。只补限流或再加一个熔断器,无法避免同类事故再次发生。
一个真正能扛住极端流量的高并发方案,必须建立三层防御。
第一层:基于缓存的弹性治理#
热 Key 的发现与拆分:通过 Redis LFU 统计、redis-cli --hotkeys、代理层或应用层采样识别热点;读多写少场景可拆成多个副本分散压力,或使用 Caffeine 本地缓存做二层缓冲。
缓存击穿与雪崩防护:对热点数据加互斥锁或逻辑过期,禁止批量同时失效,缓存过期时间加随机偏移。
多级缓存(浏览器、CDN、IndexedDB 本地、Redis、DB)逐层降级。
第二层:流量控制与削峰填谷的精准设计#
限流不是拍脑袋,需基于压测确定单机阈值,结合集群总容量动态调整。使用令牌桶、滑动窗口控制 QPS/TPS。
排队与漏斗机制:不是所有请求都进 MQ,先令牌过滤,再队列缓冲,设置队列最大积压阈值,超出则快速失败。
熔断降级:下游超时或错误率达到阈值时,熔断并返回兜底数据,防止单点故障。
第三层:全链路可观测与故障自愈#
实时流量演练:定期引入影子流量、混沌工程注入,验证系统自愈能力。
自动弹性伸缩:基于 CPU、内存、RT 指标自动扩容实例,热点数据自动预热。
可观测性三件套:链路追踪加指标监控加日志上下文,做到 5 分钟内定位热 key 或慢 SQL。
总结#
这次故障暴露的不是单个组件缺失,而是系统没有围绕真实峰值、缓存集中失效和下游过载建立完整防线。
Redis 和 MQ 只是基础设施。在巨量流量、热 Key、雪崩和依赖故障面前,系统还必须具备可降级、可隔离、可观测和可弹性的能力。
故障演进与根因分析#
一、先修正“限流没做好”的诊断#
限流、熔断、降级都是”兜底补救”,不是根因。这次故障真正的演进链是:
容量按"预期峰值"压测 → 真实流量数倍于预期(容量规划错) → Redis key 批量同时过期(TTL 无随机抖动 → 雪崩) → 缓存 miss 风暴全部打到 DB(无互斥/逻辑过期 → 击穿) → DB 连接池打满(缓存层与 DB 之间无隔离、无快速失败) → 下游消费超时 → 订单创建硬失败复盘结论应该是:根因是容量规划没按真实峰值做 + 缓存层没有防雪崩/击穿 + 故障域没有隔离。 限流熔断降级是在前面失守之后才用的第二、第三道防线。把”限流没做好”当根因,等于在说”我着火了是因为灭火器不够”,而没提屋里堆的是易燃物。
二、四个关键问题的落地方案#
限流阈值如何确定#
不是拍脑袋,按”保护最稀缺下游资源”来定:
- 基线靠压测:单机找到安全水位 —— p99 RT 不破 SLO、CPU < 70%、无错误时的 QPS。不是压到挂的那个值,是压到”刚要变形”前留余量的值。
- 集群容量 = 单机安全 QPS × 节点数 × 安全系数(0.7~0.8)。
- 按瓶颈资源限,不只按 QPS:如果 DB 连接池是瓶颈,就用并发信号量限并发,而不是限 QPS —— QPS 限不住慢请求堆积。
- 双层:网关集群级(Sentinel 集群流控 / Nginx limit_req)+ 单机级(in-process,网关挂了也能自保)。
- 算法:入口用令牌桶(允许短突发到容量),严格资源用滑动窗口或并发限制。
- 动态:最好自适应(Sentinel 系统自适应,按 CPU/RT 动态收放),静态阈值定高了没保护、定低了误杀。
熔断如何避免误判#
误判 = 下游其实没坏就打开熔断。防误判靠这几条:
- 最小样本量:窗口内请求数 < 阈值(如 20~50)时不评估错误率,否则低流量时一个慢请求 = 100% 错误率直接熔断。
- 滑动窗口 + 半开探测:半开只放 1~5 个探测请求,成功才全开,失败继续半开 —— 限制爆炸半径。
- 错误类型区分:业务错误(4xx、参数错)不计入熔断统计,只算 5xx / 超时 / 连接异常。否则一次参数错误风暴就把下游熔了。
- 多维度:错误率 + 慢调用比例结合,单一信号别轻易 trip。
- 超时要合理:超时按正常 RT 的 p99 定,按 p50 会频繁误判慢调用。
- 渐进恢复:半开成功后逐步放量,别一次性全开,防止抖动(thrashing)。
- 舱壁隔离:每个下游独立熔断器,一个挂别连累共享路径。
热 Key 如何发现与拆分#
文章这句的术语需要纠正:
- LRU/LFU 是内存淘汰策略,不是识别手段。它们是
maxmemory-policy(allkeys-lru / allkeys-lfu),管的是”内存满了驱逐谁”,不会给你一份热 key 清单。 - 能列名单的是
redis-cli --hotkeys,而它的前提是把 policy 设成 lfu,扫 LFU 访问计数器输出 TopN;或OBJECT FREQ <key>查单 key 频次。 - 其中 LRU 根本不适合做这件事:LRU 衡量”最近有没有被访问”,而热 key 的定义是”高频访问”。一个很久没碰、刚被扫一次的 key 在 LRU 里也算”热”。识别高频访问该用 LFU(频次衰减计数)或基于采样的频次统计。
正确做法:
- 先发现:Redis
--hotkeys(需 LFU maxmemory-policy)、代理层统计、客户端采样上报、应用层 TopN 计数;同时监控各分片 QPS/CPU 倾斜。 - 读热 key 拆分(多副本):同一个逻辑 key 写成 N 份物理 key(
k:1…k:N),读时随机/轮询选一份,把压力摊到多分片。代价是写放大 —— 只适合读多写少。 - L1 本地缓存:Caffeine 做一级缓存,热 key 本地命中根本不打 Redis。LRU/LFU 用在这里(本地缓存的容量淘汰),设短 TTL + 异步刷新(refresh-ahead)防本地同时过期回源风暴。这才是 LRU/LFU 的正解位置。
- 写热 key 拆分(分片累加):比如计数器热 key,本地累加 + 异步合并写回,别让一个计数 key 卡死单分片。
- 业务侧打散:把”大促所有 SKU”这种聚合 key 按用户分片/时间窗拆细。
如何设计降级以保障核心链路#
- 先定义核心:订单创建是核心;推荐、评论、积分、富信息详情是非核心。
- 核心链路同步依赖最小化:下单同步只保留必须(库存校验、账户),其余全异步化到 MQ。这是”不断”的根本。
- 舱壁 + 资源预留:核心路径独占线程池/连接池,非核心故障不传染、不抢占。
- 分级降级:
- L1 关非核心功能(推荐/评论返回空或静态)
- L2 非核心写异步化、读走兜底
- L3 核心也简化 —— 先接单后处理:订单意图先持久化(落库/MQ),前端返回”处理中”,异步完成扣减与最终一致
- L4 极端排队,前端排队页
- 兜底数据:缓存即使过期也返回旧值(stale-while-revalidate),别抛错。
- 开关:配置中心动态开关,可按机房/用户比例灰度,自动触发 + 手动兜底。
核心就一句:下单不丢 > 下单实时,极端时走异步接单保不丢,比硬同步失败强得多。
三、两类容易混淆的问题#
落地这套方案时,仍有两处容易混淆:
- LRU/LFU 概念错位:LRU 和 LFU 本质上是淘汰策略,不能直接等同于热 Key 识别手段。在线诊断应结合 LFU 计数、
--hotkeys、OBJECT FREQ、代理层统计或应用层采样。 - 根因分析遗漏容量规划:故障第一因是按预期峰值压测,没有覆盖真实峰值,也没有模拟缓存集中失效。后续的限流、熔断与降级都是必要防线,但不能代替容量与失效场景验证。
故障复盘的第一步不应急着补限流和熔断,而是先确认根因:容量规划、防雪崩与击穿、故障域隔离,然后才是第二、第三层防御。顺序反了,治理重点也会随之偏离。
技术词汇解释与举例#
一、缓存与热 Key#
LRU(Least Recently Used,最近最少使用) 内存淘汰策略:内存满时优先驱逐”最久没被访问”的 key,看的是”最近性”。 举例:缓存只够存 2 个商品,访问顺序 A→B→C→A,要存 D 时淘汰最久没碰的 B(A 刚被访问、C 是最近的)。Redis 用近似 LRU 采样实现。
LFU(Least Frequently Used,最不经常使用) 内存淘汰策略:内存满时优先驱逐”访问频次最低”的 key,看的是”频率”。 举例:商品 A 半小时被访问 1000 次、商品 B 只 2 次,内存满先淘汰 B。LFU 更适合识别热点,因为热点 = 高频访问。Redis 的 LFU 用带衰减的对数计数器。
为什么识别热 key 用 LFU 不用 LRU
LRU 只看”最近有没有被碰”,一个冷 key 刚被扫一次也会排前面;LFU 看”被碰了多少次”,真正高频的才靠前。所以 --hotkeys 要求把 policy 设成 LFU。
maxmemory-policy Redis 内存达上限时的淘汰策略开关,可选 allkeys-lru / allkeys-lfu / volatile-lru / noeviction 等,决定”占满后驱逐谁”。
redis-cli —hotkeys Redis 4.0+ 的热点 key 识别命令,扫描全库 LFU 计数器输出 TopN。前提是 maxmemory-policy 设为 LFU,否则报错。
OBJECT FREQ
缓存雪崩 大量 key 同一时刻集体过期(或 Redis 整挂),请求全部穿透到 DB 把库压垮。 举例:上线时给所有热点商品都设 30 分钟 TTL,30 分钟整这些 key 同时失效,几万请求瞬间一起回源 DB。对策:TTL 加随机偏移(如 30min ± 5min)。
缓存击穿 单个热点 key 失效瞬间,大量并发请求同时回源 DB。 举例:爆款秒杀商品缓存刚好过期,一秒上万请求同时查 DB。对策:互斥锁或逻辑过期。
缓存穿透
查询根本不存在的数据,缓存永远 miss,每次都打 DB。
举例:恶意请求 userId=-1,DB 也没这条记录、缓存没法存,每次都查库。对策:缓存空值 + 短 TTL,或布隆过滤器。
互斥锁重建缓存 热点 key 失效时只让一个线程查 DB 回写,其余等待或读旧值,避免回源风暴。 举例:商品详情 miss,线程 A 抢到锁去查库回写,线程 B~Z 在锁上等,A 写完后大家一起读缓存。
逻辑过期
不设物理 TTL,而在 value 里存”逻辑过期时间”;读到过期时不删 key(继续返回旧值),同时异步触发刷新。本质是”用一点点过期数据换不断流”。
举例:热点数据 value 带 expireAt=10:30,10
Caffeine Java 高性能本地缓存库(W-TinyLFU 淘汰算法),常作 Redis 前面的一级缓存(L1)。热 key 本地命中直接返回,根本不打 Redis。
多级缓存 从近到远逐层兜底:浏览器 → CDN → 应用本地缓存 → Redis → DB。每层挡掉一部分流量,最后到 DB 的只是极少量。
refresh-ahead(提前/异步刷新)
key 快过期但还没过期时后台异步刷新它,前端永远拿新鲜值无需等回源。Caffeine refreshAfterWrite 配异步 CacheLoader 实现。
stale-while-revalidate 缓存过期后先返回旧值(stale),同时在后台异步刷新(revalidate)。和逻辑过期同思路:宁可返回稍过期的数据,也不让用户等回源。
热 key 多副本拆分
同一逻辑 key 写成 N 份物理 key(k:1…k:N),读时随机/轮询选一份,把单分片压力摊到 N 个分片。代价是写放大,适合读多写少。
举例:库存 key stock:1001 写成 stock:1001:1~stock:1001:8,8 个分片各扛 1/8 读流量。
二、限流算法#
令牌桶(Token Bucket) 固定速率往桶里放令牌,请求来时拿一个令牌才通过;桶满令牌溢出、桶空请求被拒。允许短突发(攒下的令牌一次用掉)。 举例:桶容量 100、每秒补 50 个令牌——平时匀速 50 QPS,但短时可突发到 100。
漏桶(Leaky Bucket) 请求像水倒进漏桶,以恒定速率漏出处理。进来再猛出去也匀速,只平滑不防突发。Nginx limit_req 用的是这个。
滑动窗口(Sliding Window) 把时间切成小格子,窗口随时间滑动统计最近请求数,比固定窗口平滑、避免边界突发。 举例:固定窗口”每分钟 100 次”会在 59s 和 61s 各打 100 次(边界双倍尖刺);滑动窗口按秒计格子统计”最近 60 秒”,平滑掉边界。
QPS / TPS QPS=每秒查询数,TPS=每秒事务数。限流常用 QPS,关注”每秒成功完成”时用 TPS 更准。
并发限流(信号量限流) 不限制”每秒多少个”,而是限制”同时在跑多少个”,适合保护会被慢请求拖垮的资源。 举例:DB 连接池 50 个连接,用信号量限同时 40 个请求进 DB;来 1 万 QPS 也最多 40 并发,其余快速失败。QPS 限流防不住”8 个慢请求各占 5 秒把池占满”。
Sentinel 阿里开源流量治理组件,支持限流、熔断、系统自适应、热点参数限流,Spring Cloud 生态常用。
系统自适应限流 不设固定 QPS 阈值,按 CPU/RT/负载等系统指标动态收放——吃紧自动降流量、空闲自动放开,借鉴 TCP BBR 思路。
集群流控 集群总阈值限流(不是每台机器各自限),需集中统计,Sentinel 配 Token Server 实现。
三、熔断与隔离#
熔断器(Circuit Breaker) 下游故障率/慢调用超阈值时”跳闸”——直接快速失败不再调下游,防止故障蔓延和线程堆积。三态:CLOSED(正常)/ OPEN(熔断中)/ HALF_OPEN(半开探测)。代表实现 Resilience4j、Sentinel。
半开(HALF_OPEN) 熔断一段时间后只放少量探测请求试下游是否恢复:成功→全开恢复,失败→继续熔断,限制探测爆炸半径。
最小样本量 窗口内请求数太少时不评估错误率,避免低流量下一个偶发错误被判成 100% 错误率误熔。 举例:窗口内 5 个请求 1 个失败=20%,但样本太小不评估;攒到 50 个再开始算错误率。
慢调用比例熔断 不只看”错误率”,还看”慢调用占比”——下游没报错但全在超时也该熔断,否则线程被慢调用占满。
舱壁隔离(Bulkhead) 把资源(线程池/连接池)按下游或业务隔离,一个舱漏水不影响其他舱,源自船只分舱设计。 举例:调推荐服务和调库存用各自独立线程池,推荐挂了把它的池占满,也不影响下单用库存池。
Resilience4j 轻量级函数式容错库,提供熔断、限流、重试、舱壁、限时等模块,是 Spring 推荐的 Hystrix 替代。
快速失败(fail-fast) 超限/熔断时不排队等待,立刻返回失败或兜底,不让请求堆积占资源。
四、降级与削峰#
降级 核心链路压力大时主动牺牲非核心功能(返回空/静态/兜底),把资源让给核心,分级触发。
兜底数据 降级时返回的”不够新但能用”的默认数据。例如缓存过期了也返回上次旧值,而不是抛 500。
MQ 削峰填谷 瞬时大流量先进消息队列排队,消费端按自己能扛的速率匀速消费,把尖峰削平填到低谷。 举例:下单请求先落 MQ,订单服务每秒只处理 500 个,多余的排队,不直接压垮服务。
队列最大积压阈值 队列不能无限积压,超过阈值(如 10 万条)就拒新请求快速失败——否则积压几小时用户早投诉,还得处理一堆迟到订单。
先接单后处理 极端流量下的终极降级:下单请求不实时完成扣减,只把”订单意图”先持久化(落库/MQ),前端立刻返回”处理中”,后台异步完成库存扣减、最终一致。承诺”下单不丢”优先于”下单实时”。
五、可观测与韧性#
影子流量(shadow traffic) 把生产真实流量复制一份打到影子环境(不影响真实请求),用真流量验证容量、压测、回归,线上出问题前提前暴露。
混沌工程(Chaos Engineering) 主动注入故障(杀进程、断网、延迟、CPU 打满)验证系统在故障下还能不能撑,Netflix Chaos Monkey 是代表。
可观测性三件套 Tracing(链路追踪,如 SkyWalking/Jaeger,看一次请求经过哪些服务)+ Metrics(指标监控,如 Prometheus,看 QPS/RT/错误率趋势)+ Logging(结构化日志带 traceId 串上下文)。三者结合才能在 5 分钟内定位”是哪个热 key / 哪条慢 SQL 拖垮了系统”。
RT / p99 RT=响应时间;p99=99% 的请求在这个时间内完成,衡量长尾体验。容量规划和限流阈值都该看 p99 而非平均——平均值会被少数正常请求拉低,掩盖真实长尾痛苦。