系统设计里最被低估的成本:扇出放大,以及它怎么把你的 p99 从 200ms 拖到 4 秒

🔑 关键词:系统设计,扇出放大,重试风暴,缓存雪崩,singleflight

📖 摘要:从一次凌晨告警复盘出发,聊系统设计中被普遍忽略的扇出成本:重试放大、写扩散、缓存同时失效,以及为什么扇出的成本是超线性而不是线性的,最后给出几个可以直接照抄的收敛手法。

系统设计里最被低估的成本:扇出放大,以及它怎么把你的 p99 从 200ms 拖到 4 秒

图片

先讲个我自己踩的坑

大概是去年冬天,一个工作日的凌晨两点多,告警群开始刷屏。核心下单接口的 p99 从平时的 180ms 涨到 4 秒出头,但几个关键指标都很平静:应用 CPU 40% 不到,内存没动静,数据库的 QPS 也就平时的一点三倍。这种"哪儿都不忙但就是慢"的情况最烦人,因为你能下手的地方看起来都没问题。

我们几个人对着监控看了快四十分钟,中间怀疑过连接池、怀疑过 GC、怀疑过某个下游的 DNS 解析。最后是隔壁组一个同事把网关和订单服务的日志拉出来做了个对比,发现订单服务收到的下单请求数,是真实用户下单数的三倍多。

根因其实很朴素:网关调订单服务的超时设的是 800ms,订单服务里有个同步调风控的步骤,平时 p99 大概 300ms,那天风控那边有点抖动,p99 到了 1.2 秒。于是网关超时,重试,再超时,再重试,配的是三次重试。每一次网关重试,订单服务就完整跑一遍逻辑,包括那个慢下来的风控调用。风控那边看到的流量翻了三倍多,于是更慢,p99 涨到两秒,超时的比例更高,重试更多。一个标准的正反馈循环。

图片

那天晚上的修复动作很简单:网关超时从 800ms 放到 3 秒,重试次数从 3 改成 1,并且给重试加了退避和全局预算。五分钟之后流量曲线就掉回去了。当时我盯着那条曲线想了很久,因为改完之后,接口的"理论可用性"其实是降低的,重试少了,偶发失败会直接暴露给用户。但实际观测到的错误率反而下降了。

大多数人在讨论扩展,很少有人讨论扇出

我这些年看过的系统设计材料里,扩容、分片、缓存、消息队列,这些话题都被讲烂了。但有一个东西很少被单独拎出来当第一性概念讨论,就是扇出(fan-out):一次请求或者一次操作,在下游被放大成了多少次。

扇出至少有三种形态,而且它们的坑位完全不一样。第一种是请求扇出,一个入口请求调用 N 个下游,比如前端一个页面渲染触发二十个 RPC。第二种是写扇出,一次写变成 N 次写。第三种我管它叫失效扇出,一次缓存失效导致 N 个 key 同时过期,N 个请求同时打到数据库。这三种的应对方式完全不同,但如果只盯着 QPS 看,它们长得一模一样。

图片

写扩散最经典的案例还是 Twitter 的时间线。早期他们用的是 fan-out on write,你发一条推文,系统会把这条推文写进所有粉丝的 timeline 里。普通人几百个粉丝无所谓,但一个千万级粉丝的账号发一条推,就是实打实的千万次写。后来他们改成了混合模式,粉丝数低于某个阈值的账号继续走写扩散,超过阈值的走读扩散,读的时候再实时合并。具体阈值他们没完整公开过,社区里流传的说法是十万这个量级。这个方案本身没什么玄机,关键在于它承认了一件事:同一个系统里,不同数据的扇出宽度可以差三个数量级,那就应该用不同的策略。

缓存失效扇出更隐蔽。假设你有两千个 key,TTL 都设成 300 秒,而它们是在同一个批次里被写入的,那么 300 秒之后它们会在同一秒内全部过期。如果平时缓存扛着 5000 QPS,那一秒钟里这 5000 QPS 会原封不动地拍到数据库上。很多数据库不是被持续高负载打死的,就是被这种一秒钟的尖峰打死的。

扇出的成本是超线性的,这才是它讨厌的地方

如果扇出的成本只是线性放大,那问题就简单了,容量乘以 N 就行。真正麻烦的是它超线性。

图片

先说尾延迟。Google 那篇 2013 年发在 CACM 上的论文《The Tail at Scale》里有个数字我印象很深:假设每台机器有 1% 的概率出现慢请求,那么一个需要并行调用 100 台机器的请求,整体超时的概率是 1 减去 0.99 的 100 次方,大约 63%。注意这不是 1%,是 63%。扇出宽度一旦上去,单机的尾部延迟会被指数级放大成整体的失败率。这也是为什么很多团队把所有下游调用的 p99 加起来算总耗时,算出来是对的,但感觉上永远对不上。

再说重试。重试本身就是一种扇出,而且是会自我复制的扇出。单层调用配 2 次重试,等于把请求数乘以 3。如果三层调用链每一层都配 2 次重试,那就是 3 的 3 次方,27 倍。配 3 次重试的话,4 的 3 次方是 64 倍。更糟的是重试通常发生在系统已经出问题的时候,恰好是你最没有富余容量的时候。

所以社区里比较通行的做法是给重试设预算,而不是给单次请求设次数。思路是把重试当成一种稀缺资源,用令牌桶全局控制,重试流量控制在总请求量的 10% 到 20% 以内,超出预算就直接失败返回。这个思路的价值在于它把重试从"每个调用方自己决定"变成了"整个系统统一决策",避免了下游已经快挂的时候上游还在热情地帮它加压。

几个能直接照抄的收敛手法

图片

第一,缓存 TTL 一定要加抖动。最省事的写法是 TTL 等于基础值乘以 0.8 到 1.2 之间的随机数,比如 300 秒变成 240 到 360 秒。再讲究一点,把同一批业务数据的过期时间错开。千万别图省事把 TTL 和整点对齐,那是主动给自己制造尖峰。

第二,用请求合并挡在缓存和数据库之间。Go 生态里 golang.org/x/sync/singleflight 就是干这个的,同一个 key 的并发请求只会有一个真正去做回源,其他都等结果。Java 这边 Caffeine 的加载逻辑本身就是单飞的,也可以自己用分布式锁兜底,Redis 里就是 SET key value NX PX 3000 这一套,但要注意锁的持有时间得比回源耗时长,不然锁过期了回源还没结束,等于没锁。

第三,退避必须加抖动。常见的指数退避是 100ms、200ms、400ms,如果不加随机抖动,所有客户端会在同一时刻齐步走,形成一波一波的脉冲。加上 [0, 100ms) 的随机量,虽然看起来不太优雅,但效果立竿见影。

第四,把扇出宽度本身变成一个被监控的指标。大多数团队的监控面板上只有 QPS、p99、错误率,很少有人把"平均一次入口请求触发了多少次下游调用"画成一条曲线。但这条曲线往往是最早出问题的那条。

图片

我现在的看法

做系统设计这些年,我自己的判断变了。以前觉得难的是怎么扩,怎么让系统扛住更大的量。现在觉得真正难的是怎么收,是怎么在加了一堆缓存、队列、异步、重试之后,还能保证一次用户操作不会在下游变成几十次。

每一次你选择加重试、加异步、加多级缓存,本质上都是在借债,利息就是扇出。借的时候很爽,还得时候通常是在凌晨两点。

所以现在评审方案的时候,我基本会先问两个问题:这次操作能不能不做?能不能只做一小部分?而不是上来就问扛多少 QPS、上不上 Redis。前两个问题问清楚了,后面那些答案往往就变了。

🏷️ 标签: