在单机世界里,你以为理所当然的事情,到了分布式世界全部失效。网络会断、时钟会漂、节点会挂——欢迎来到真实的分布式世界。
一个转账的故事
假设你要实现一个银行转账功能:张三给李四转 100 元。
单机版本,简单得令人发指:
BEGIN TRANSACTION;
UPDATE accounts SET balance = balance - 100 WHERE user = '张三';
UPDATE accounts SET balance = balance + 100 WHERE user = '李四';
COMMIT;
数据库保证:要么两条都成功,要么两条都失败。这就是 ACID 中的 A(原子性)。
现在,假设张三的账户在北京机房,李四的账户在上海机房。
同样的转账,你需要:
- 北京机房:扣张三 100 元
- 上海机房:加李四 100 元
问题来了:如果北京扣款成功,但发给上海的消息丢了呢?
- 张三少了 100 元
- 李四没收到钱
- 100 元凭空消失了
这不是假设,这是分布式系统的日常。
单机世界的三个假设
在单机世界里,数据库默默帮你解决了很多问题。它依赖三个隐含假设:
| 假设 | 单机世界 | 分布式世界 |
|---|---|---|
| 通信可靠 | 内存读写不会失败 | 网络随时可能断 |
| 时间一致 | 只有一个时钟 | 每台机器的时钟都不一样 |
| 节点可靠 | 机器不会中途消失 | 任何节点随时可能挂掉 |
分布式系统的本质困难:这三个假设全部失效。
假设一崩塌:网络不可靠
消息可能丢失
北京机房 上海机房
│ │
│─── 扣款成功 ───────X (消息丢了)
│ │
│ (等待确认...) │
│ │ (什么都没收到)
北京扣了钱,上海不知道。怎么办?
重试? 可能导致重复扣款。
不重试? 可能数据不一致。
消息可能重复
北京机房 上海机房
│ │
│─── 加 100 元 ──────►│ (收到了)
│ │
│ (没收到确认,重试) │
│ │
│─── 加 100 元 ──────►│ (又收到了)
│ │
│ │ 余额多了 100!
消息可能乱序
北京机房 上海机房
│ │
│─── 消息1 (通过连接A) ────────►│
│─── 消息2 (通过连接B) ────────►│
│ │
│ 收到顺序:消息2, 消息1
注意:单个 TCP 连接保证顺序,但分布式系统常使用多连接、消息队列、或重试机制,这些都可能导致乱序。如果消息 1 是「创建订单」,消息 2 是「取消订单」,乱序会导致什么?
最可怕的:不知道对方是死是活
北京机房 上海机房
│ │
│─── 请求 ───────────►│
│ │
│ (等待响应...) │
│ │
│ 3秒过去了... │
│ │
│ 对方是: │
│ A) 挂了? │
│ B) 太忙? │
│ C) 网络断了? │
│ D) 响应在路上? │
你无法区分「对方挂了」和「网络慢」。 这是分布式系统最根本的困难之一。
假设二崩塌:时钟不一致
在单机上,时间是确定的。在分布式系统中,每台机器都有自己的时钟。
物理时钟会漂移
两台机器的时钟,即使同时校准,几小时后也会产生几十毫秒的偏差。
这会导致什么问题?
假设用时间戳判断「谁先谁后」:
真实时间线:
T=0ms: A 写入 x=1
T=20ms: B 写入 x=2(发生在 A 之后)
各机器记录的时间戳:
机器 A (时钟快 50ms): 记录 10:00:00.050
机器 B (时钟准): 记录 10:00:00.020
结果:
按时间戳:A 更新(10:00:00.050 > 10:00:00.020)
按真实时间:B 更新(B 发生在 A 之后 20ms)
时间戳骗了你。 A 的时钟快,导致 A 的时间戳虽然更大,但它实际上发生得更早。
NTP 同步也不可靠
NTP(网络时间协议)可以同步时钟,但:
- 同步本身有网络延迟
- 精度因网络环境而异:局域网可达亚毫秒,广域网通常在几十毫秒级别
- 同步可能失败,时钟可能跳变
在分布式系统中,你不能依赖物理时钟来判断事件顺序。
假设三崩塌:节点会挂
单机程序崩溃,大不了重启。分布式系统中,部分节点挂掉是常态。
脑裂问题
假设你有 3 台数据库,配置了自动故障转移(如 MySQL Group Replication):
正常状态:
主节点 A ◄──── 从节点 B
│
└──────── 从节点 C
网络分区后:
[机房1] [机房2]
主节点 A 从节点 B
从节点 C
B 和 C 联系不上 A
自动故障转移机制认为 A 挂了
选举 B 为新主节点
现在有两个主节点!
两个主节点都在接受写入,数据开始分叉。这就是「脑裂」。
网络恢复后,怎么合并两边的数据?没有通用答案。
僵尸节点
节点 A 以为自己还是主节点
│
│ (实际上它已经被网络隔离)
│ (其他节点已经选了新主节点)
│
▼
继续接受写入
│
▼
数据与集群不一致
一个节点可能不知道自己已经「死了」。
一个真实的例子
让我们回到开头的转账问题,看看分布式世界会发生什么:
北京机房 上海机房
张三余额=1000 李四余额=500
│ │
│ [1] 扣张三 100 元 │
│ 张三余额=900 │
│ │
│─── 通知上海:加李四 100 ────►│
│ │
│ (网络延迟 2 秒) │
│ │
│ 此时有人查询: │
│ 张三=900, 李四=500 │
│ 总额少了 100! │
│ │
│ [2] 加李四 100 元
│ 李四余额=600
在那 2 秒的窗口期,系统处于「不一致」状态。
更糟的情况:如果网络消息丢失,上海永远收不到通知,100 元就真的消失了。
为什么不能简单地「加锁」?
在单机上,你可以用锁来保证一致性:
let _lock = mutex.lock();
// 临界区操作
// 自动释放锁
在分布式系统中,锁也是分布式的:
节点 A: 我要加锁
│
├─► 锁服务: OK,锁给你了
│
│ (A 开始干活)
│
│ (A 和锁服务之间网络断了)
│
│ 锁服务: A 好久没心跳了,锁过期,释放
│
│ 节点 B: 我要加锁
│ 锁服务: OK,锁给你了
│
│ (B 开始干活)
│
│ (A 的网络恢复了)
│ A: 我还拿着锁呢,继续干活
│
▼
A 和 B 都认为自己持有锁!
分布式锁也不能解决根本问题。
单机 vs 分布式:对比总结
| 维度 | 单机 | 分布式 |
|---|---|---|
| 通信 | 内存读写,纳秒级,100% 可靠 | 网络调用,毫秒级,可能失败 |
| 时间 | 单一时钟,绝对可信 | 多个时钟,各有偏差 |
| 故障 | 整体挂或整体活 | 部分节点挂,部分节点活 |
| 事务 | 数据库原生支持 ACID | 需要额外协议(2PC、Saga…) |
| 锁 | 进程内互斥,简单可靠 | 分布式锁,可能失效 |
| 顺序 | 程序顺序 = 执行顺序 | 无全局顺序,需要额外机制 |
那怎么办?
面对这些困难,计算机科学家们发明了各种协议和算法:
| 问题 | 解决方案 |
|---|---|
| 跨节点事务 | 两阶段提交(2PC)、Saga 模式 |
| 一致性 vs 可用性 | CAP 定理指导下的权衡 |
| 多节点达成共识 | Paxos、Raft 算法 |
| 无需强一致的场景 | 最终一致性、CRDT |
没有银弹。 每种方案都是在一致性、可用性、性能之间做权衡。
总结
为什么分布式一致性这么难?
因为你习以为常的三个假设全部失效了:
- 网络不可靠:消息会丢、会重复、会乱序,你分不清对方是死是活
- 时钟不一致:每台机器的时钟都不一样,你不能用时间戳判断先后
- 节点会挂:部分节点随时可能失联,可能出现脑裂和僵尸节点
核心认知:
分布式系统不是「多台机器组成的单机」,而是一个充满不确定性的环境。
你需要假设一切都会出错,然后设计系统在出错时仍能正确工作。
这是一种完全不同的思维方式。
下一篇,我们来看分布式系统的第一个尝试:两阶段提交(2PC)——它试图在分布式环境中实现 ACID,结果发现了 CAP 定理这个「不可能三角」。
下一篇:2PC 与 CAP:理想的破灭
本系列: