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%

读法有三条,每条都指向不同的决策:

  1. 应用 333 台是「便宜并行」:加机器就完事,唯一成本是钱和发布复杂度。所以业务逻辑应该尽量压在这一层(无状态)。
  2. 数据库 10 个分片是「昂贵并行」:每个分片都要付跨分片查询、分布式事务、扩容迁移三笔债(44.14 分库分表)。所以能挡在缓存里、能归档、能读写分离的,绝不要走到分片。
  3. 把 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、主库必然是单点;目标应是「最小化到达单点的流量」,不是「消灭单点」

关联

自检

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 缓存穿透/击穿/雪崩);③ 对「必须读最新」的场景(余额、库存校验),用令牌/预扣/读主库,而不是加缓存。