37.9 单点与拆分 (高并发的第一性原理)
一句话
高并发只有一个动作:找出「几乎全部流量都必经的那一处」,把它变成「一个更小的单点 + N 份并行资源」;拆不动的地方,就是这套系统的容量上限。
单点不是缺陷,是语义要求
先说一个反直觉的结论:数据库之所以是单点,不是因为它写得不好,而是因为系统需要一个「排队」的地方。
用户逐个注册、ID 不能重复、订单不能混淆、库存不能超卖——这些需求的共同点是「先来后到」。API 可以并发执行,但写入必须逐个定序。InnoDB 的自增、事务、行锁提供的原子性,正是多台应用服务器能被称为「一个系统」的逻辑基础。
由此得到一条判据,它比任何架构图都好用:
🔑 两个系统的数据库不在一起,它们就不是同一个系统;两个 API 的数据落到同一张表上,它们就属于同一个系统。
拼多多 7.5 亿月活、淘宝 8.5 亿,没人会说「拼宝宝电商」有 16 亿用户——因为库不在一起。「一个系统」的边界,是共享同一份可变状态的边界。
三问判定:它到底是不是单点
| 判定 | 问题 | 是单点的表现 |
|---|---|---|
| 必经性 | 是否几乎所有流量都要经过它? | 去掉它,业务直接不可用(不是变慢) |
| 可复制性 | 多复制一份,能否独立承担一份流量? | 复制后必须同步状态,否则结果不一致 |
| 协调成本 | 两份副本之间要不要互相协商? | 要(写同一行、抢同一个 ID、争同一把锁) |
三条全「是」= 真单点(主库、全局 ID 生成器、单个热点 key)。 只有第一条「是」= 假单点(无状态的 Web 层,复制即解决,见 37.2 无状态化)。 第二、三条「是」但第一条「否」= 局部热点(单个大 V 的计数器),拆开它就行(41.6 热点 key 与分片)。
一条公式:拆分的收益与代价
把一个大单点拆成 N 份并行资源,理论吞吐是 N × C,但每份之间要协调,协调会吃掉一部分:
实际吞吐 ≈ N × C / (1 + k × (N − 1))
k 是「每增加一份并行,需要付出的协调比例」。这解释了三种典型曲线的差别:
| 拆的是什么 | k 的量级 | N 增大时的表现 | 例子 |
|---|---|---|---|
| 无状态副本 | ≈ 0 | 近似线性 | Web 层、消费者 |
| 按 key 分区 | 小(跨分片事务才付) | 近线性,跨分片查询变慢 | 分库分表、Redis Cluster |
| 共享同一行 | 大 | 很快饱和甚至下降 | 热点账户扣减、全局自增 ID |
🔑 拆分的顺序不是按「难度」,是按「k 的大小」:先拆 k≈0 的(复制),再拆 k 小的(分区),最后才碰 k 大的(同一行上的竞争)——后者要靠「把一行拆成多行」(分桶)或「把同步改成异步」(排队),见 44.22 计数系统。
五把刀:从最便宜到最贵
| 刀 | 动作 | 典型手段 | 收益 | 代价 | 主场 |
|---|---|---|---|---|---|
| ① 卸 | 让流量根本不到它 | 缓存、CDN、静态分离、限流 | 直接减少到达量,最便宜 | 一致性窗口、命中率运维 | 34.7 多级分流 |
| ② 复制 | 一份变多份,各自独立 | 无状态多副本、读写分离、多从库 | 近线性(k≈0) | 只扩读,写仍压在主库 | 37.2、44.13 |
| ③ 分区 | 按 key 切成互不重叠的片 | 分库分表、一致性哈希、Range 分区 | 写与容量都能扩 | 跨分片查询、分布式事务、扩容迁移 | 37.3、19.1 |
| ④ 分家 | 拆成两个可以独立决策的系统 | 业务分库、微服务、地域拆分 | 故障域与团队边界同时缩小 | 从「本地事务」降级为「最终一致」 | 本节 |
| ⑤ 换 | 单点本身变强 | 加核、换 NVMe、换机型 | 立刻见效、不改代码 | 有天花板、单价非线性 | 37.1 |
前四把刀都是「把一个单点变成 N 个」,第五把刀是「让这个单点更猛」。顺序错了会白花钱:先换硬件(⑤)再上缓存(①),等于给一个注定要被挡掉的流量买最好的服务器。
拆分顺序:五步,每一步的收益都能算
《高并发的哲学原理》给的顺序是:静态分离 → 计算与存储分离 → 负载均衡 → 数据库集群/分布式 → 地域拆分。这个顺序的精髓是「每一步都让下一步更容易」。
| 步 | 动作 | 这一步之后单机能力 | 典型数字(书中的量级) |
|---|---|---|---|
| 1 | 动静分离 | 静态资源基本不进应用 | 书中的说法是:可以把 99% 的流量压力转移出去 |
| 2 | 计算与存储分离 | 应用与 DB 各自扩,互不绑架 | 单机 Apache 直出 HTTPS 约 200 QPS;分离后应用可复制 |
| 3 | 负载均衡 | 单机变 N 台 | 单机 Nginx 反向代理约 1 万 QPS 到顶 |
| 4 | 数据库集群 / 分布式 | 单库变多库 | 1 核 2G 的 MySQL 约 200 QPS,现代硬件可达几万 QPS |
| 5 | 地域拆分 | 全国一盘棋变成「本地系统」 | 双 11 峰值 58.3 万笔/秒,北京一地约 2.5 万单/秒 |
这几个数字要连起来读:200(应用)→ 1 万(Nginx)→ 200~5000(单库)。任何一层被单机的天花板卡住,加机器都没用,因为瓶颈在下一层——这就是 37.7 扩展瓶颈识别 要解决的问题。这里补的是另一半:识别出瓶颈之后,用哪把刀拆。
算一遍:100 万 QPS 的预算表
参数是假设,结论看数量级(脚本可复算):
#!/usr/bin/env python3
"""100 万 QPS 的容量预算:每一层要多少台(参数为假设,结论看数量级)。"""
TOTAL = 1_000_000 # 入口 QPS(含静态)
STATIC_SHARE = 0.90 # 静态资源占比
CDN_HIT = 0.99 # CDN 命中率
API_CPU_MS = 8 # 每个 API 的 CPU 耗时
CORE = 4 # 单机核数
TARGET_UTIL = 0.60 # 目标 CPU 水位
NGINX_MAX = 10_000 # 单机 Nginx 反向代理 QPS 极限
GATEWAY_UTIL = 0.40 # 网关目标水位
DB_Q_PER_API = 5 # 每个 API 的 DB 查询次数
CACHE_HIT = 0.90 # 缓存挡住的读比例
DB_MAX_QPS = 5_000 # 单分片 DB 能力(读写混合)
REDIS_MAX = 100_000 # 单实例 Redis QPS
static = TOTAL * STATIC_SHARE # 静态资源
dyn = TOTAL * (1 - STATIC_SHARE) # 动态请求
origin = static * (1 - CDN_HIT) + dyn # 回源 + 动态 = 到源站的量
db_qps = dyn * DB_Q_PER_API * (1 - CACHE_HIT) # 缓存没挡住的 DB 查询
rows = [
("网关/Nginx", origin, NGINX_MAX * GATEWAY_UTIL, f"单机 {NGINX_MAX:,},按 {GATEWAY_UTIL:.0%} 水位"),
("应用", dyn, CORE * TARGET_UTIL * 1000 / API_CPU_MS, f"{CORE} 核 {TARGET_UTIL:.0%},单请求 {API_CPU_MS}ms CPU"),
("Redis", dyn, REDIS_MAX * 0.5, "单实例 10 万,按 50% 水位"),
("数据库", db_qps, DB_MAX_QPS, f"每 API {DB_Q_PER_API} 次查询,缓存挡 {CACHE_HIT:.0%}"),
]
print(f"{'层':<12}{'承载 QPS':>12}{'单机能力':>10}{'台/分片数':>10}")
for name, qps, per, note in rows:
print(f"{name:<12}{qps:>12,.0f}{per:>10,.0f}{qps / per:>10.1f} {note}")
本机实测(macOS / Apple M1 / Python 3.9.6,参数即上面这些假设):
层 承载 QPS 单机能力 台/分片数
网关/Nginx 109,000 4,000 27.2 单机 10,000,按 40% 水位
应用 100,000 300 333.3 4 核 60%,单请求 8ms CPU
Redis 100,000 50,000 2.0 单实例 10 万,按 50% 水位
数据库 50,000 5,000 10.0 每 API 5 次查询,缓存挡 90%
读法有三条,每条都指向不同的决策:
- 应用 333 台是「便宜并行」:加机器就完事,唯一成本是钱和发布复杂度。所以业务逻辑应该尽量压在这一层(无状态)。
- 数据库 10 个分片是「昂贵并行」:每个分片都要付跨分片查询、分布式事务、扩容迁移三笔债(44.14 分库分表)。所以能挡在缓存里、能归档、能读写分离的,绝不要走到分片。
- 把 DB 单机能力换成默认小规格(1 核 2G / 约 200 QPS),分片数会变成 250 个——这就是「用默认配置的云数据库顶百万 QPS」必然失败的算术原因,不是运维不努力。
「是不是同一个系统」:拆分边界的判据
微服务拆分的哲学,用上一节的判据可以一句话说完:
| 现象 | 结论 | 该怎么做 |
|---|---|---|
| 两个服务写同一张表 | 它们是同一个系统,拆错了 | 合并,或先拆数据所有权(30.3 分布式单体反模式) |
| 一个头部 API 要写两个服务 | 边界画错了 | 重新按「一次业务动作只写一个域」划界(29.3 聚合与事务边界) |
| 一次请求写一个服务、读多个服务 | 正常的微服务形态 | 读走 API 聚合 + 缓存 |
| 两个地区用户的库完全不交互 | 两个可以独立的系统 | 按地域拆(本节下一小节) |
🔑 判据是写,不是读:读可以跨域(加缓存、加宽表、加聚合层),写不能跨域(跨域写就要分布式事务)。所以「按写拆分」几乎总能落地,「按读拆分」永远拆不干净。
地域拆分:最后一刀,也最容易被忽略
全国一盘棋不是必须的。北京和上海的用户,在系统里真正会发生联系的地方只有四处,把这四处拆掉,就能得到两个可以独立扩容的「本地系统」:
| 跨地域单点 | 为什么它必须全局唯一 | 拆法 |
|---|---|---|
| 用户表自增 ID | 两个库各自自增会撞号 | 预分配号段,或按奇偶/取模分流(44.24 发号器与全局 ID) |
| 商品库存检查 | 库存是全国共享的一个数 | 按区域预分配库存,异步刷新(67.6 秒杀与抢购) |
| 商家订单聚合 | 商家要看全国的订单 | 两个区域各拉一次,在展示层合并 |
| 离线数据分析 | 报表要全国口径 | 异步同步到数仓,本来就不要求实时 |
拆完之后用 DNS 把用户导向最近的那一套(8.4 DNS 解析链路与缓存)。算一笔账:双 11 峰值 58.3 万笔/秒,北京占 4.33%,北京一地只有约 2.5 万单/秒——这个量级,一个主从架构的数据库就能扛,不需要任何奇技淫巧。
⚠️ 地域拆分的前提是「两个地区之间没有实时一致性需求」。资金、库存、账号这类全局资源,拆之前先想清楚:你愿不愿意接受「北京的余额和上海不一致」。不愿意,就别拆这一刀。
拆不动的时候:削峰
有些单点短期拆不掉(库存扣减、支付记账),这时不是继续拆,而是改变流量的形状:
| 手段 | 削的是哪种峰 | 原理 | 代价 |
|---|---|---|---|
| 缓存 | 读峰 | 把「每次读库」变成「偶尔读库 + 多数读缓存」 | 一致性窗口;缓存失效时反噬(34.7) |
| 队列 | 写峰 | 把「同时写」变成「排队写」,用户看到「处理中」 | 不提升绝对容量,只把峰值摊平;积压会变成延迟 |
| 预生成 / 预分配 | 写峰(下单类) | 提前把订单/库存准备好,下单时只写用户维度 | 需要预测;预生成错了要回收 |
关键认知:队列不提升系统容量,它只是把「瞬间 10 万 QPS 压 3 秒」变成「3 万 QPS 持续 10 秒」。如果平均负载本身就超过容量,队列只会让积压无限增长。真正的容量提升仍然来自拆分,队列争取的是「把峰值摊到低谷」的时间。
一个真实的数量级参考:100 万 API QPS 的电商系统,在削峰之后,200 万 QPS 的 PolarDB 集群 + Redis 集群就能顶住——因为绝大多数请求根本到不了数据库。
三条硬边界:拆到哪必须停
| 边界 | 表现 | 停下来的信号 |
|---|---|---|
| 一致性 | 跨库之后没有本地事务,只能最终一致 | 业务要求「扣款与发货必须同时成功」→ 别拆,或改用 TCC/Saga(24.9 事务模型选型) |
| 协调成本 | 注册、配置、事务、ID、锁,全部变成分布式问题 | 每多一个服务,就多一套配置与一次跨服务排障 |
| 组织 | 拆出来的服务要有人端到端负责 | 团队规模撑不住服务数时,拆分只会把耦合变成协调会(74.1 康威定律) |
反模式
| 反模式 | 后果 |
|---|---|
| 只扩容,不拆单点 | 应用从 10 台加到 100 台,DB 还是那一台,吞吐一寸没涨(判据见 37.7) |
| 先拆微服务,再拆数据库 | 得到 N 个服务共用一个库的分布式单体,成本翻倍、收益为零 |
| 把队列当容量 | 峰值过去后积压还在,延迟越拖越长,最终还是要落库 |
| 把缓存当免费的读优化 | 缓存削弱了数据库的「单点性」,把一致性问题埋进系统;失效那天就是事故日 |
| 用跨地域强一致换取地域拆分 | 拆完再用 2PC 把两个区域绑回去,等于用更高的成本得到更差的性能 |
| 拆得比团队还细 | 每个服务都要人值班、要 CI、要监控;组织成本超过技术收益 |
| 追求「无单点」 | 入口 LB、主库必然是单点;目标应是「最小化到达单点的流量」,不是「消灭单点」 |
关联
- 前置:37.1 垂直扩展与水平扩展、37.5 加速比与阿姆达尔定律、34.7 多级分流
- 并列:37.2 无状态化、37.3 分片与分区、37.7 扩展瓶颈识别
- 下游:36.5 单元化 / Set 化架构、44.14 分库分表
自检
Q1 一个系统有 8 台应用服务器、1 个主库,QPS 从 5000 涨到 8000 后接口超时。有人说「再加 8 台应用服务器」。用本节的三问判定,这个方案的问题在哪?
▸ 答案:应用层是「假单点」——必经但不需协调,复制即可;DB 是「真单点」——三条都满足(必经、复制要同步、写要抢锁)。加应用只是让更多请求更快地挤到同一个主库门口,吞吐由主库决定,不会涨。正确动作是先看 DB 的利用率与连接数(37.7),再决定是加缓存挡读、加从库扩读,还是分片扩写。
Q2 为什么说「两个系统的数据库不在一起,它们就不是同一个系统」?这句话在架构上有什么用?
▸ 答案:因为「同一个系统」的实质是共享同一份可变状态:能一起做原子写、能保证 ID 不撞、能保证顺序。库分开了,原子性就降级为最终一致,用户看到的「一个系统」只是前端拼出来的。用途是划边界:拆微服务时先问「这两块要不要写同一份数据」,要写就别拆;反过来,如果两块数据从不互相写,拆开它们几乎不需要分布式事务。
Q3 按本节预算表,应用需要 333 台、DB 需要 10 个分片。为什么同样是「加一份」,应用加得、DB 加不得?
▸ 答案:因为 k(协调比例)不同。应用副本之间 k≈0,加一台就是多 300 QPS;DB 分片之间 k 很大——每加一个分片,跨分片查询要多一次网络聚合、分布式事务多一个参与方、扩容要多一次数据迁移。333 台机器的运维成本是线性的,10 个分片的复杂度是组合式的(每个分片都要与其余分片正确协作)。所以工程上永远优先「把流量挡在 DB 之外」,而不是「把 DB 拆成更多片」。
Q4 一个电商把库存扣减做成「按城市分库,各扣各的」,双 11 当天出现超卖。问题出在哪一步?
▸ 答案:出在「拆之前没有处理跨地域的全局资源」。库存是全局共享的一个数,按城市分库后每个库都以为自己是全量,等于把 1 份库存复制成了 N 份。正确做法是预分配:总库存按历史比例预分到各城市(如北京 4.33%),各城市扣自己的份额,卖完就没了;跨城市的调拨走异步。这也说明地域拆分的前提是「该资源可以预分配」——不能预分配的资源(如全局唯一账号)只能留在中心。
Q5 队列「削峰但不提升容量」,那它到底解决了什么问题?什么情况下加队列是自欺欺人?
▸ 答案:它解决的是形状问题:把「瞬时 10 万 QPS 持续 3 秒」摊成「3 万 QPS 持续 10 秒」,让系统在容量不变的情况下不被瞬时压垮,用户以「处理中」换等待。如果平均负载已经超过容量(比如持续 10 万 QPS、容量只有 5 万),队列只会让积压单调增长,延迟从秒变小时,最终还是要丢请求或落库——这时加队列只是把「立刻失败」换成「晚点失败」。
Q6 有人说「微服务就是按业务拆库」,用本节的判据评价这句话。
▸ 答案:方向对,但顺序反了。判据是「写不写同一份数据」——按写拆才是真拆,按业务名字拆而不动数据,得到的是共享库的分布式单体。正确顺序是:先确认两块数据的写入互不交叉(读可以交叉),再把数据所有权拆开,最后才把进程拆开。只拆进程不拆数据,成本(网络调用、跨服务排障)全付了,收益(独立演进、故障隔离)一点没有。
Q7 「追求无单点」为什么是个错误目标?举一个无论怎么设计都消不掉的单点。
▸ 答案:因为「必经」是拓扑性质,不是实现缺陷。入口负载均衡就是典型:用户只能解析到一个(或一组)IP,一次 TCP 连接最终落到某一台设备上;即使做成集群 + anycast,同一时刻同一条连接仍只属于一台。正确目标是把这类单点的流量与故障域压到最小:入口只做转发(不做业务)、用多 VIP/多可用区降低整机故障概率、把 95% 的静态流量挡在它之前。同理,主库的写也是消不掉的——能做的只是把写变少(异步、合并、预生成)。
Q8 本节说「缓存削弱了数据库的单点性」。这句话是褒义还是贬义?该怎么用?
▸ 答案:中性偏贬。缓存让同一份数据有了多个副本,读变快了,但「谁是最新值」不再由单一主库回答——这就是把「单点的一致性」换成了「多副本的最终一致」。用法是:① 只在能接受短暂旧值的读路径上加缓存;② 明确失效策略与击穿兜底(41.3 缓存穿透/击穿/雪崩);③ 对「必须读最新」的场景(余额、库存校验),用令牌/预扣/读主库,而不是加缓存。