34.7 多级分流 (透明多级分流)
一句话
分流不是「加一层缓存」,而是让同一个请求在到达单点之前被逐级减掉:每一级只做「便宜地挡掉一部分」这一件事,所以判断要不要加某一级,只看它省下的下游容量是否大于它带来的复杂度。
一次请求要过几道关
浏览器 → 客户端缓存 → DNS → 传输链路 → CDN/边缘 → 负载均衡 → 网关 → 服务端缓存 → 应用 → 数据库
└─ 边缘卸载 ─┘ └──────── 网络 ────────┘ └── 集群内 ──┘ └── 单点 ──┘
应用与数据库这两跳是你的业务,前面几跳全是通用设施。这正是分流的价值:在业务代码之外把流量解决掉,应用与数据库看到的只是「被减过几轮之后剩下的那点流量」。
| 视角 | 看到的 QPS | 谁在承担 |
|---|---|---|
| 用户与运营商 | 100%(入口总量) | 用户自己的带宽与设备 |
| CDN 与 DNS | 静态部分的大头 | CDN 厂商的机房 |
| 负载均衡与网关 | 回源 + 动态 | 你的集群入口(第一道自有单点) |
| 应用 | 动态全部 | 无状态副本(可线性扩) |
| 数据库 | 缓存没挡住的那部分 | 主库(最难拆的单点,见 37.9 单点与拆分) |
四类部件:价值不同,策略不能一样
周志明在《凤凰架构》里把这套设施分成四类,这个分法比记具体产品有用:
| 类别 | 典型部件 | 特性 | 设计策略 |
|---|---|---|---|
| 边缘卸载类 | 客户端缓存、CDN、反向代理 | 命中即免费,不消耗你的 CPU;但你控制不了用户 | 把「可缓存」的东西尽量推到最外层 |
| 线性扩容类 | 无状态服务、消息消费者 | 加机器就加容量,成本可预测 | 业务逻辑的主力载体 |
| 全局稳定类 | 注册中心、配置中心、名字服务 | 数量少、影响全局,挂了全站失联 | 只求「小、稳、有备份」,不求快 |
| 天生单点类 | 入口 LB、网关、主库 | 一次请求必然经过至少一个,加一个也消不掉 | 减少到达它的流量,而不是想办法消灭它 |
🔑 单点消不掉。哪怕把 LB 做成集群,一次 TCP 连接最终仍会落到某一台上——架构能做的是让更少的流量到达它,而不是让它不存在。
六级分流:每一级挡什么、代价是什么
| 级 | 挡掉什么 | 主要手段 | 代价 / 副作用 | 这一级失效时 |
|---|---|---|---|---|
| 客户端缓存 | 重复请求(静态资源 + 可缓存的 API 响应) | Cache-Control: max-age、ETag + If-None-Match(命中返回 304,不传 body) | 数据滞后;发版后用户可能仍看旧页面 | 全量回源 |
| DNS | 跨地域、跨机房的流量(就近接入) | 多 A 记录、GeoDNS / GSLB、按权重切流 | TTL 决定切换速度:TTL=300s 是 5 分钟,TTL=3600s 是 1 小时 | 解析失败 = 全站不可用 |
| 传输链路 | 每请求的 RTT 与字节数(不减少请求数) | 长连接、HTTP/2 多路复用、gzip(文本通常压到 20% 左右) | HTTP/2 单连接有队头阻塞;压缩耗 CPU | 延迟上升,流量不放大 |
| CDN / 边缘 | 静态资源(图片、CSS、JS、视频切片) | 边缘缓存、预热、按目录设 TTL | 回源率与一致性;刷新要等全网生效 | 回源风暴 |
| 负载均衡 | 不减流量,只改分布 | L4(LVS/IPVS)/ L7(Nginx/Envoy)、一致性哈希、加权 | 自身成为入口单点;会话粘性会造成倾斜 | 全站不可用 |
| 服务端缓存 | 回源到数据库的读 | 旁路缓存、多级缓存、热点探测 | 一致性窗口;击穿时打穿 DB | 数据库被读穿 |
注意第三行:传输链路优化减少的是「每个请求的成本」,不是「请求的数量」。打开 gzip 不会让 DB 少查一次,它只是让同样的请求更便宜——这是两条完全不同的收益曲线,不要混在一张账上算。
分流的复利:算一遍
分流的收益是乘法的:每级各挡掉一部分,剩下的才进入下一级。
#!/usr/bin/env python3
"""多级分流复利:入口 100 万 QPS,逐级卸载后到达"后端"(应用 + DB) 的流量。"""
QPS = 1_000_000 # 入口总 QPS
STATIC_SHARE = 0.70 # 静态资源占比(图片/CSS/JS/字体)
H_CLIENT = 0.60 # 客户端缓存命中率(对静态资源)
H_CDN = 0.95 # CDN 命中率(对到达 CDN 的静态资源)
H_SRV = 0.80 # 服务端缓存命中率(对动态 API 的读请求)
def backend(h_client=H_CLIENT, h_cdn=H_CDN, h_srv=H_SRV):
static = QPS * STATIC_SHARE
dyn = QPS * (1 - STATIC_SHARE)
after_client = static * (1 - h_client) # 客户端缓存挡完
after_cdn = after_client * (1 - h_cdn) # CDN 再挡
after_srv = dyn * (1 - h_srv) # 动态请求靠服务端缓存
return after_cdn + after_srv
base = backend()
for name, v in [
("正常", backend()),
("客户端缓存全失效", backend(h_client=0)),
("CDN 全部回源", backend(h_cdn=0)),
("服务端缓存全失效", backend(h_srv=0)),
("三级全部失效", backend(h_client=0, h_cdn=0, h_srv=0)),
]:
print(f"{name:<16}{v:>12,.0f} QPS{v / base:>8.1f}x")
本机实测(macOS / Apple M1 / Python 3.9.6,参数就是上面这四行假设,改参数结论会变、形状不会变):
正常 74,000 QPS 1.0x
客户端缓存全失效 95,000 QPS 1.3x
CDN 全部回源 340,000 QPS 4.6x
服务端缓存全失效 314,000 QPS 4.2x
三级全部失效 1,000,000 QPS 13.5x
读法:
| 结论 | 数字 | 含义 |
|---|---|---|
| 正常分流后,后端只承受入口的 | 7.4% | 100 万 QPS 的入口,后端按 7.4 万 QPS 建 |
| 任何一级失效,后端压力放大 | 4.2–4.6 倍 | 单级失效就能打穿按「正常值」建的容量 |
| 三级同时失效 | 13.5 倍 | 一次缓存雪崩加一次 CDN 配置失误,就是 100 万 QPS 直冲 DB |
🔑 分流的副作用是瀑布式放大:按 7.4 万 QPS 建的 DB,必须扛得住某一刻的 34 万。所以每一级的出口都要有兜底限流,阈值按「这一级失效后下游会看到的流量」设,而不是按正常值设(35.7 限流)。
该按什么顺序加
规则只有一条:按「加一层的单位成本」从低到高,而不是按「哪层听起来更高级」。
| 顺序 | 加什么 | 单位成本 | 生效速度 | 典型挡掉比例 |
|---|---|---|---|---|
| 1 | 客户端缓存(Cache-Control / ETag) | 0(只改响应头) | 立即,但受用户缓存影响 | 静态资源 50–70% |
| 2 | CDN | 带宽费,低于自建出口 | 分钟级(预热) | 静态资源 90–99% |
| 3 | 服务端缓存 | 内存 | 分钟级 | 读请求 70–90% |
| 4 | 读写分离 | 一台从库 | 小时级 | 缓存未命中的读 |
| 5 | 分片 / 分区 | 复杂度,见 37.9 | 月级 | 写请求与单库容量 |
奥卡姆剃刀:什么时候别加
分流设施不是越多越好,每加一层就多一个故障点、多一份配置、多一个「他为什么看到旧数据」的工单。
| 你的情况 | 建议 |
|---|---|
| 日活 < 1 万、峰值 QPS < 100 | 单体 + 一层 Nginx + 一个 Redis 足够,别上 CDN 和网关 |
| 内部管理系统、员工后台 | 不需要 CDN、不需要多级缓存;加了只多一处不一致 |
| 强一致读(余额、库存校验) | 缓存挡不住,要挡在「前置校验 + 令牌」上,见 67.6 秒杀与抢购 |
| 写多读少 | 分流挡不住写;该做的是排队与削峰 |
| 团队没有 CDN 运维经验 | 先做「静态资源独立域名 + 长 TTL」,这已经能拿到大部分收益 |
反模式
| 反模式 | 后果 |
|---|---|
| 先上 CDN,再谈动静分离 | 静态资源与 API 同域同名,缓存键混乱,命中率个位数,白付带宽费 |
| 用缓存挡写请求 | 写请求 100% 穿透到主库,缓存只放大了「读快写死」的错觉 |
| 加了六层,却从不统计每层命中率 | 以为有六级分流,实际只有一级生效;容量按错误的数字建 |
客户端缓存 max-age 设一年且文件名不带 hash | 发版后用户看不到新版本,只能换域名或让用户清缓存 |
| 用 DNS 做故障切换但 TTL=3600 | 故障后一小时才切完,等于没有切换 |
| 分流层全挂时没有兜底限流 | 回源风暴:按 7.4 万建的容量瞬间面对 100 万 |
| 把「LB 集群」当成「消除了入口单点」 | 一次连接仍落到单台;真正的解法是减少到达入口的流量 + 多 VIP / anycast |
关联
自检
Q1 一个团队把 CDN 命中率从 0% 优化到 95%,为什么「数据库 QPS 几乎没变」是正常现象?
▸ 答案:CDN 只挡静态资源,而静态资源本来就不查数据库。它减少的是出口带宽和边缘回源,受益方是 Nginx 与带宽账单,不是 DB。要判断一次优化是否影响 DB,先看这条路径上是否有 DB 调用——分流的每一级只对自己的下游负责,跨级套收益是最常见的归因错误。
Q2 按本节实测,正常分流后后端只承受入口的 7.4%,那为什么不能用 7.4 万 QPS 的容量去建后端?
▸ 答案:因为 7.4 万是「所有分流层都正常」时的值,而单级失效就会放大 4.2–4.6 倍(约 31–34 万),三级同时失效是 13.5 倍(100 万)。容量是给「故障态」建的,不是给「稳态」建的。正确做法是:按稳态建资源,按故障态配限流与降级,让溢出流量在网关被拒绝,而不是让 DB 被打挂——限流器是分流链的保险丝。
Q3 负载均衡器在这一节里被归为「不减流量,只改分布」,那它到底算不算分流?
▸ 答案:算,但它是分流的另一种维度:前几级减的是「总量」,LB 减的是「单点上的量」。8 台机器各接 1/8 的流量,总量不变,但任何一台的故障域从 100% 降到 12.5%,容量从 1× 变成 8×。判断时问一句「它改变的是分子还是分母」:减少请求数是分子,改变分布是分母。
Q4 为什么 DNS 的 TTL 同时是「性能参数」和「可用性参数」?
▸ 答案:TTL 长 → 解析请求少、解析快(性能好),但故障切换要等 TTL 过期(可用性差);TTL 短 → 切换快,但解析 QPS 高、更容易被 DNS 拖慢首屏。工程上的折中是:稳定的记录用长 TTL,可能需要切流的记录用短 TTL(60–300s),并把「切流」设计成在应用层可完成(网关按权重转发),而不是依赖 DNS。
Q5 一个电商首页 QPS 100 万,静态资源 70%,CDN 命中 95%。此时应用层 QPS 是多少?如果服务端缓存命中率从 80% 掉到 50%,DB 的读 QPS 变成原来的几倍?
▸ 答案:应用层 = 动态请求 = 100 万 × 30% = 30 万 QPS(静态回源另有 100 万 × 70% × 5% = 3.5 万,落在源站但不进应用)。DB 读 = 30 万 × (1 − 命中率):80% 时 6 万,50% 时 15 万,2.5 倍。这也解释了为什么缓存命中率的监控要精确到「每一级」——命中率掉 30 个百分点,DB 的读压力涨 1.5 倍,而报表上只有一条「缓存命中率下降」的曲线。
Q6 什么情况下应该主动去掉一层分流设施?举两个例子。
▸ 答案:① 内部后台系统上了 CDN,静态资源是内网专属且更新频繁,缓存刷新成本高于省下的带宽 → 去掉 CDN,改成 Nginx 直出;② 业务量小(QPS < 100)却上了网关 + 多级缓存 + 服务发现,任何一层抖动都会变成一次故障排查会议 → 退回单体 + Nginx + 一个 Redis。判据是本节开头那条:它省下的下游容量,是否大于它带来的复杂度——省不下就不该加。
Q7 「把 LB 做成集群」和「减少到达 LB 的流量」,哪个更能提升系统的可用性?为什么?
▸ 答案:后者。LB 集群解决的是「某一台 LB 挂了」,但入口总还是要落在一台上,集群只把单台故障的概率降低,不降低「入口这个位置」的暴露面;而减少到达入口的流量(CDN 挡住 95% 静态、DNS 把用户分到最近机房)直接缩小了这个位置的负载与攻击面。工程上两件事都做,但优先级是:先在入口之外把流量减掉,再考虑入口本身怎么做冗余。