分布式系统:从"修好"的执念到八大谬误
在分布式系统里,不存在"修好",只存在"把不一致的窗口压到业务可接受"。承认这一点之后,工程问题就从"如何消除竞态"变成"如何选择 SLA + 让残余竞态可观测、可恢复"。
一、关于"不存在修好"这句话
方向上认可,但"不存在修好"这句话要打个折扣——它作为一个反矫正的口号是好的,作为一个普适命题就太强了。
认可的部分
把"消除竞态"作为目标确实是错觉,尤其是跨服务、跨数据中心的场景下。CAP / PACELC / FLP 已经把边界画得很清楚了。把工程注意力从"追求理论纯净"转移到"SLA + 可观测 + 可恢复",是从初级到高级的关键心智转换。Pat Helland 那套"数据在外是事实,在内是状态"的思路本质上也是这个意思。
三点反驳
第一,有些类别的问题是真的能"修好"的,不是压缩窗口。 共识组内的 linearizability(Raft/Paxos 副本组)就是真线性一致,不是"压到 200ms 业务能接受"。CRDT 的强最终一致是代数上证明收敛,不是概率收敛。带 fencing token 的单主写入对 split-brain 是真正闭合的。把这些和"窗口压缩"混为一谈,会让人误以为分布式系统里不存在硬保证——实际上保证一直存在,只是要为之付出延迟/可用性的代价。
第二,“消除竞态 vs 压窗口+可观测"是个假二分。 第三条也是更常用的路是改写问题让竞态在语义层失效:分片让冲突域不相交、操作做成幂等让重放无害、事件溯源让"先写谁"这件事不再有意义。这类设计上你既没消除竞态也没在压窗口,你是把竞态本身从"问题"降级成了"无关现象”。这条路比 SLA 妥协更值得优先考虑。
第三,“业务可接受"在做太多隐性工作。 账本、库存清零、唯一性约束这些场景,业务能接受的窗口就是 0,强行往"压到可接受"的框架里塞,会逼着你去做 Saga 补偿这种本不该做的事情。这种时候老老实实上共识、上事务、上同步复制,是更便宜的选择。把所有问题都先验地当 AP 处理,是另一种偏见。
更准确的版本
在你选定的一致性模型之外,竞态不可消除,只能管理;选错模型,多少可观测性都救不回来。
工程问题真正的起点是先问"这块业务到底是 CP 还是 AP”,而不是"我怎么把窗口压小"。
二、为什么分布式系统是真正的挑战
分布式系统是少数几个人类直觉会系统性骗你的工程领域。
写单机程序,你的直觉大致是对的:函数调用要么成功要么抛错、时间是单调的、内存写完下一行就能读到。这些假设迁移到分布式环境全部失效,而且失效得很隐蔽。
具体难在几个层次:
理论层面有真正的不可能性结果
FLP 证明了纯异步环境下没有确定性算法能解决共识;CAP 在网络分区下逼你二选一。这不是"目前没解决",是数学上证明了无解。其他工程领域很少这么干净的"此路不通"。
故障模型是组合爆炸的
单机故障基本就是崩溃。分布式系统里你要面对:消息丢、消息重、消息乱序、消息延迟到下个纪元才到、节点假死、节点慢但没死(这个最恶心)、时钟漂移、时钟跳变、网络分区、不对称分区(A 能发给 B,B 发不回 A)、拜占庭故障。任何两个故障的组合都可能产生新的失效模式。
调试手段塌缩
单机程序你有断点、有 stack trace、有 core dump。生产分布式系统出问题时,你手上经常只有一堆时钟不同步的日志,要从中重建一个因果顺序——而因果在分布式系统里本身就是个需要 Lamport clock / vector clock 才能定义的概念。
正确性边界是滑动的
单机算法对就是对。分布式算法的"对"取决于你假设什么故障模型、什么一致性模型、什么活性条件。同一份 Raft 实现,在异步网络下不保证活性、在拜占庭下不保证安全性,这两句话都对,但听起来像在说不同的算法。
人也是系统的一部分
运维操作、配置变更、灰度发布,这些非技术因素是真实的故障来源。Google SRE 那本书里反复出现的主题就是:大多数事故不是技术 bug,是人在变更系统状态时的不当操作和系统对这种操作的脆弱性。
一点冷水
“分布式系统很难"经常被滥用成不写好代码的借口。很多线上事故的根因不是 CAP 那种深刻问题,是没做超时、没做重试退避、没做幂等、没做熔断、共享了不该共享的状态——这些是 80 年代就解决了的工程实践。难的部分确实难,但日常工作中遇到的更多是"没做基本功”,不是"撞上了 FLP"。
真正的从业者画像是这样的:对理论边界保持敬畏,对工程基本功毫不松懈,对一切"应该不会发生"的事情保持怀疑。三者缺一就会出事故。
三、Deutsch 的八大谬误(配图详解)
Peter Deutsch 在 Sun Microsystems 时总结了七条,后来 James Gosling 加了第八条。三十年过去,每一条都还在线上系统里反复重演。
1. 网络可靠(The network is reliable)
故事:某支付公司的"双扣"事故。用户点击"购买",App 发请求到服务端,服务端扣款成功并写库,但返回的 ACK 在客户端运营商网络丢了。App 30 秒超时后自动重试,服务端没识别这是同一笔,又扣了一次。客服电话被打爆。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
Client (App) Network Server
│ │ │
│── POST /charge $100 ───┼──────────────────▶│
│ │ │ 扣款 -100
│ │ │ DB commit ✓
│◀───── 200 OK ──────────┼─── X (丢包) ──────│
│ │ │
│ (等待 30s 超时) │ │
│ │ │
│── POST /charge $100 ───┼──────────────────▶│
│ (自动重试) │ │ 扣款 -100 ⚠️
│◀───── 200 OK ──────────┼───────────────────│ DB commit ✓
│ │ │
结果:用户余额 -200,订单只有 1 笔
|
教训:TCP 的"可靠"只在连接内有效,连接两端中任何一端的 ACK 丢失都会重演这个故事。所有写操作都必须有 idempotency key,让重试在语义层无害。
2. 延迟为零(Latency is zero)
故事:电商的商品详情页,本地开发秒开,上线东京机房后页面 6 秒才出来。一查代码,渲染时调用 /api/product/{id},服务端为每个商品又顺序拉取了价格、库存、评论数、推荐——总计 8 次串行调用。开发环境里数据库在 localhost,每次 0.1ms,无感;生产环境跨可用区每次 5ms,再叠加几次 RPC,单页面累计延迟爆炸。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
开发环境(同机):
App ──0.1ms──▶ DB
◀────────
8 次串行 ≈ 1ms(人感知不到)
生产环境(跨 AZ):
App ────5ms────▶ Service ────5ms────▶ DB
◀────────── ◀────────────
单次 RTT ≈ 20ms
8 次串行 ≈ 160ms(仅 RPC)
再加上 N+1 查询 100 个商品:
┌─────────────────────────────────────┐
│ Q1 ████ 20ms │
│ Q2 ████ 20ms │
│ Q3 ████ 20ms │
│ ... │
│ Q100 ████ 20ms│
└─────────────────────────────────────┘
总计: 2000ms 仅在等网络
并行化后:
┌─────────────────────────────────────┐
│ Q1-Q100 ████ 20ms (一次往返) │
└─────────────────────────────────────┘
|
教训:延迟是 RTT 乘以串行调用次数。优化的核心不是让每次 RPC 更快(那是基础设施的事),而是减少串行次数——批量、并行、缓存、把"查询次数"作为 code review 的一等公民。
3. 带宽无限(Bandwidth is infinite)
故事:某社交 App 的"发现页",调用 /api/users/recommended 返回 50 个推荐用户。后端图省事返回完整 User 对象,每个含头像 base64、最近 10 条动态、关注列表预览,单个 80KB。在 Wi-Fi 下没人察觉,海外版上线后东南亚用户在 4G 上加载发现页要 40 秒,应用商店评分跌到 2.1。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
返回单个 User 对象:
┌──────────────────────────────────────┐
│ id: 12345 8B ◀ 实际只用这个
│ name: "Alice" 20B
│ avatar_base64: "..." 32KB ◀ 列表里只显示缩略图
│ recent_posts: [...] 28KB ◀ 用户根本看不到
│ follow_preview: [...] 12KB ◀ 同上
│ preferences: {...} 8KB
│ activity_log: [...] ...
└──────────────────────────────────────┘
单个: 80KB
× 50 个推荐: 4MB / 次
带宽对比(理论 vs 实际):
光纤 DC: ████████████████████████ 25Gbps
家庭宽带: ███ 100Mbps
4G 良好: ██ 30Mbps
4G 拥塞: █ 2Mbps ← 你的海外用户
地铁/电梯: ▏ 0.3Mbps ← 真实场景
4MB @ 0.3Mbps = 100+ 秒
|
教训:API 应该按消费方需要返回字段。GraphQL、Protobuf 的 field mask、REST 的 ?fields= 参数都是为这个存在的。带宽便宜是数据中心的局部真理,对端口越走越远,这个真理越站不住。
4. 网络安全(The network is secure)
故事:Capital One 2019 年泄露 1 亿用户数据。攻击路径不是从外网直接打进数据库,而是利用了一个 WAF 的 SSRF 漏洞,让 WAF 替自己访问 AWS 元数据服务(169.254.169.254),拿到了 EC2 实例的 IAM 临时凭据,然后用这套凭据访问了 S3——所有内部跳转都基于"内网=可信"的假设,没有任何二次验证。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
传统架构("硬壳软心"):
Internet
│
┌────▼────┐
│ WAF │ ◀── 攻击者发现 SSRF 漏洞
└────┬────┘
│
━━━━━━━━━┿━━━━━━━━━━━━━━━━━━━━━━━━━━━
│ "可信内网"边界
━━━━━━━━━┿━━━━━━━━━━━━━━━━━━━━━━━━━━━
│
├──▶ [元数据服务] 无鉴权 ✓ 拿到凭据
├──▶ [内部 API] 无鉴权 ✓
├──▶ [Redis] 无密码 ✓
├──▶ [S3 bucket] IAM 信任 ✓ 拖走数据
└──▶ [k8s API] 默认开放 ✓
一个外部漏洞 = 全内网沦陷
零信任架构:
Internet
│
┌────▼────┐
│ WAF │ ◀── 同样的 SSRF
└────┬────┘
│
├──▶ [元数据] mTLS + token ✗ 拒绝
├──▶ [内部 API] mTLS ✗ 拒绝
├──▶ [Redis] AUTH + ACL ✗ 拒绝
└──▶ ...
每一跳都是新的信任边界
|
教训:内网不是信任边界,身份才是。每个服务都应该独立验证调用方,不论调用方来自内网还是外网。这就是 BeyondCorp / Zero Trust 的核心。
5. 拓扑不变(Topology doesn’t change)
故事:某 Java 服务连 RDS。AWS 凌晨做了一次主备切换(failover),DNS 记录已经更新指向新主库。但 JVM 默认的 networkaddress.cache.ttl 是 -1(永久缓存),应用端依然把域名解析为旧 IP,连接失败、自动重连还是连旧 IP,疯狂重试直到运维介入手动重启所有应用实例。事故持续 47 分钟。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
T0: 正常状态
┌─[App]─┐ DNS cache:
│ │ db.prod → 10.0.0.5
│ │ TTL: ∞ (Java 默认)
└───┬───┘
│
▼
┌─[10.0.0.5]─┐ ┌─[10.0.0.6]─┐
│ Primary │◀──▶│ Standby │
└────────────┘ └────────────┘
─────────────────────────────────
T1: 主备切换发生
┌─[App]─┐ DNS cache:
│ │ db.prod → 10.0.0.5 ◀ 还是旧的!
└───┬───┘
│ (尝试连旧 IP)
▼
┌─[10.0.0.5]─┐ ┌─[10.0.0.6]─┐
│ ✗ DOWN │ │ Primary │
└────────────┘ └─────▲──────┘
│
│ DNS 已指向这里
│ 但 App 不知道
─────────────────────────────────
解决: 重启所有 App 实例 (47 分钟)
或 设置合理的 DNS TTL + 连接池健康检查
|
教训:任何"对端地址"都应该被视为时变量。服务发现、连接池健康检查、合理的 DNS TTL、断路器,这些不是锦上添花,是基础设施。云原生时代尤其如此——Pod 一秒钟前还在的 IP,下一秒可能属于另一个 Pod 了。
6. 只有一个管理员(There is one administrator)
故事:黑五前夜。业务团队凌晨 2 点开始压测要保大促。同一时间,云供应商例行轮换了某区域的 TLS 证书根;同一时间,安全团队按计划推送了一条新的 NetworkPolicy;同一时间,DBA 团队按工单升级 RDS 小版本。四件事单独都"无影响",组合起来:新证书链让某老服务校验失败 → 触发重试风暴 → NetworkPolicy 把重试流量识别为异常并限流 → 上游连接堆积 → 此时 RDS 进入升级窗口短暂不可用 → 全线雪崩。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
|
时间轴(同一个凌晨):
01:00 ┃
┃ [DBA 团队] 工单 #4521: RDS 小版本升级
┃ "维护窗口 02:00-04:00, 影响极小"
┃
01:30 ┃ [安全团队] 推送新 NetworkPolicy
┃ "限制异常流量, 已审批"
┃
02:00 ┃ [业务团队] 启动黑五压测
┃ "周末安静, 不影响线上"
┃
02:00 ┃ [云供应商] 证书根轮换
┃ "已提前 30 天通知"
┃
┃ ╔═══════════════════════════╗
02:05 ┃ ║ ✗ 故障发生 ║
┃ ║ ║
┃ ║ 老服务 ─证书校验失败──┐ ║
┃ ║ │ ║
┃ ║ ┌────重试风暴────────┘ ║
┃ ║ ▼ ║
┃ ║ NetworkPolicy 限流 ║
┃ ║ │ ║
┃ ║ ▼ ║
┃ ║ 连接池堆积 ║
┃ ║ │ ║
┃ ║ ▼ ║
┃ ║ RDS 升级窗口短暂不可用 ║
┃ ║ │ ║
┃ ║ ▼ ║
┃ ║ 💥 雪崩 ║
┃ ╚═══════════════════════════╝
┃
┃ 事后复盘: 没有任何单一变更"有错"
|
教训:变更协调是工程问题,不是社交礼仪。变更日历、change freeze 窗口、跨团队 dependency map、dry-run 演练——这些机制存在的全部理由就是这条谬误。再加上对外部依赖(云供应商、SaaS)的"他们也是一个管理员"的觉知。
7. 传输代价为零(Transport cost is zero)
故事:某创业公司把单体拆成微服务,本地开发一切正常。上云后第二个月 AWS 账单暴涨 300%,CFO 找上门。一查:服务间调用全走的是跨 AZ 流量(高可用部署默认行为),而且为了"日志集中",每条 RPC 都同步推一份 trace 到另一个区域的日志集群。每天产生 8TB 跨区流量,光 egress 费用每月 2.4 万美元。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
|
架构(看起来很合理):
┌──── us-east-1a ────┐ ┌──── us-east-1b ────┐
│ │ │ │
│ [Frontend] │ │ [Frontend] │
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ [API Gateway]─────┼──┼─▶[Order Service] │
│ │ │ │ │ │
│ ▼ │ │ ▼ │
│ [User Service]◀───┼──┼─[Inventory] │
│ │ │ │
└────────┬───────────┘ └────────┬───────────┘
│ │
└───────────┬───────────┘
▼
┌── us-west-2 ──┐
│ [日志集群] │ ◀ 跨区!每条 trace 都来一次
│ [Metrics] │
└───────────────┘
流量计费表:
┌─────────────────────────────────────────┐
│ 同 AZ 内: 免费 │
│ 跨 AZ (同区域): $0.01/GB × 2 (双向) │
│ 跨区域: $0.02/GB │
│ 出公网 (egress): $0.09/GB │
└─────────────────────────────────────────┘
实际账单分布:
计算 (EC2): ████████████ $12K
存储 (S3/EBS): ████ $4K
跨 AZ 流量: ██████████ $10K ◀ 谁也没注意
跨区域流量: ████████████████ $14K ◀ 日志同步
公网 egress: ██ $2K
优化后(同区日志 + cell 化部署):
跨 AZ 流量: ██ $2K
跨区域流量: ▏ $0.5K
节省 $21K/月
|
教训:带宽免费是云供应商最大的谎言。架构 review 必须包含数据流向 review——每条线代表多少 GB/天、跨什么边界、多少钱。“日志/Metrics/Trace 不算业务流量"是新人最爱犯的错误,这部分常常占总流量的一半以上。
8. 网络同质(The network is homogeneous)
故事:某公司内部用 gRPC 做服务间通信,性能数据漂亮。客户端 SDK 也用 gRPC 直连后端,希望"一套技术栈到底”。结果移动端用户大规模反馈"App 间歇性所有请求一起失败"——而服务端监控完全正常。最后定位到:HTTP/2 多路复用在数据中心是优势,在移动网络上是诅咒。运营商 NAT 静默回收空闲超过 30 秒的 TCP 连接,连接一断,复用在上面的所有流(streams)一起死,head-of-line blocking 雪上加霜。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
|
数据中心内(gRPC 大放异彩):
[Service A] ═══════════════ [Service B]
25Gbps 光纤
丢包: 10⁻⁹
RTT: 0.5ms
NAT: 无
单 TCP 连接复用 100 个 stream:
┌─Stream 1─┐ ┌─Stream 2─┐ ┌─Stream 3─┐
│ ████████ │ │ ████████ │ │ ████████ │ 全部正常
└──────────┘ └──────────┘ └──────────┘
─────────────────────────────────────────
移动网络(gRPC 翻车):
[App] ~~~ [家 Wi-Fi] ~~~ [运营商 NAT] ~~~ [Server]
丢包 1-5%
RTT 50-500ms (抖动剧烈)
NAT idle timeout: 30s
T+0s: ✓ 建立 HTTP/2 连接, 50 stream 复用
┌─Stream 1─┐ ... ┌─Stream 50─┐
│ ████████ │ │ ████████ │
└──────────┘ └───────────┘
T+30s: 用户进电梯, 30 秒无活动
运营商 NAT 静默回收连接
T+31s: 用户出电梯, 同时发起 5 个请求
┌─Stream 1─┐ ┌─Stream 2─┐ ... ┌─Stream 50─┐
│ ✗ │ │ ✗ │ │ ✗ │
└──────────┘ └──────────┘ └───────────┘
全部失败 (连接已死)
╔══════════════════════════════════════╗
║ 复用的代价: 一断全断 ║
║ HTTP/2 head-of-line blocking 让 ║
║ 应用层感知到的延迟 = 最慢那个 stream ║
╚══════════════════════════════════════╝
传统 HTTP/1.1 反而更鲁棒:
每个请求独立连接, 死一个不影响其他
代价是连接建立开销大
|
教训:协议选择必须匹配链路特性。数据中心、家庭宽带、移动网络、卫星、IoT——每种链路的丢包模型、抖动、NAT 行为、可用带宽都不同。一种协议在一种环境最优,不代表在另一种环境也最优。Google 自己也意识到这点,所以才搞了 QUIC(基于 UDP,端到端解决 HTTP/2 在不稳定网络的问题)。
四、八条谬误的共同结构
把八条放一起看,能看到三层规律:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
┌─────────────────────────────────────────────────────────┐
│ 第一层:物理约束被低估 │
│ 延迟(光速)、带宽(信道)、能耗(电池) │
│ → 表现为 #2 #3 #7 │
├─────────────────────────────────────────────────────────┤
│ 第二层:动态性被低估 │
│ 网络会丢包、拓扑会变、协议特性不一致 │
│ → 表现为 #1 #5 #8 │
├─────────────────────────────────────────────────────────┤
│ 第三层:边界被低估 │
│ 信任边界、控制边界都比想象中小 │
│ → 表现为 #4 #6 │
└─────────────────────────────────────────────────────────┘
|
更深一层的洞察:这八条本质上都是把"单机系统的免费午餐"误当成了"宇宙规律"。在单机里:
- 函数调用一定返回(→ 错觉 #1)
- 调用瞬间完成(→ 错觉 #2)
- 内存读写无成本(→ 错觉 #3 #7)
- 同进程天然信任(→ 错觉 #4)
- 地址稳定不变(→ 错觉 #5)
- 你是唯一控制者(→ 错觉 #6)
- 执行环境一致(→ 错觉 #8)
写分布式代码的本质,是显式地花成本去重新购买这些在单机里免费获得的属性:
| 单机里免费的 |
分布式里要花成本买回的 |
| 调用一定成功 |
重试 + 幂等 |
| 调用瞬间完成 |
批量 + 并行 + 缓存 |
| 字节流不要钱 |
字段裁剪 + 数据本地性 |
| 同进程互信 |
mTLS + 鉴权 + 零信任 |
| 地址不变 |
服务发现 + 健康检查 |
| 唯一控制者 |
变更协调 + 依赖管理 |
| 环境一致 |
协议选型 + 链路适配 |
Deutsch 这八条之所以三十年不过时,是因为它在告诉每个新入行的人:你以前的工程直觉,需要逐条重新校准。
五、写在最后
回到开头那句话。如果用八条谬误重读它,会发现"不存在修好"和"不可能消除竞态"指向的恰恰是这八种错觉的总和——它们都是单机直觉的延伸,都是在分布式环境下必须重新购买的属性。
但同样地,正如反驳的那部分指出的,承认困难不等于放弃保证。共识算法、CRDT、零信任、服务发现、idempotency key——这些工具的存在本身就在说明:每一条谬误都有它对应的"反谬误工程实践"。分布式系统的成熟度,某种程度上就是看一个团队是否在每一条上都建立了对应的工程肌肉。
所以最终的工程姿态可能是:
对理论边界保持敬畏,对工程基本功毫不松懈,对一切"应该不会发生"的事情保持怀疑。
三者缺一,事故迟早会来。