分布式系统
Outlinks (1)
Backlinks (1)
Backlinks (1)
1 · 分布式系统
分布式系统就是把计算和数据放到多台机器上协同完成任务,用来解决单机在性能、容量、可用性和规模化演进上的瓶颈;
代价是必须面对网络不可靠、多节点协调和一致性这些复杂问题。
S 单机搞不定:性能瓶颈、单点故障、地理延迟、业务复杂度 C 拆成多台机器后,机器之间的通信不可靠、时钟不同步、随时可能崩溃 Q 怎么让多台不可靠的机器协同工作,对外表现得像一个整体? A 通过复制、分区、一致性协议、共识算法等手段,在不可靠的基础设施上构建可靠的系统
1.1 · 协调:三个视角
分布式系统的协调难题可以从三个视角来看,它们指向同一个底层张力:
- 等待:何时能继续前进?
- 排序:需要多少顺序?
- 提交:什么时候承诺不可撤回?
容易的情况:对已有证据做反应 — 消息到了就处理,因果序自然产生,暴露观察到的事实 困难的情况:排除尚未发生的可能性 — 确认没人再发消息,强制全序,暴露”最终结论”
如果你想排除的未来会被信息的到来自动排除,系统保持正确很容易; 如果不会,系统就需要额外的协调机制。
1.2 · 1. 等待:何时能继续前进?#
两种根本不同的等待:等一件事发生(容易) vs 等确认什么都不会再发生(困难)
1.2.1 · 等待某件事发生
因果依赖:输入到了,就能继续。消息到了就处理,ACK 回来就释放资源。 这和单机的函数调用没有本质区别 — 分布式只是把依赖拉长到网络上。
1.2.2 · 等待确认什么都不会再发生
“没有事发生” 无法被观察到。要确认”没有”,必须听到所有人的回复。 等待”无”悄悄变成了等待”所有人”。
这带来两个麻烦:
- 谁算”所有人”?如果参与者动态变化,目标在移动
- 某人慢了/挂了/不可达怎么办?能不能不等它?凭什么?
1.2.3 · 不可靠的网络:等待的根源
分布式系统的根本困难:网络是异步的,你无法区分”对方挂了”和”消息还在路上”
网络问题的表现:
- 消息丢失(请求或响应)
- 消息延迟(到达时间不确定)
- 消息乱序
- 网络分区(一部分节点无法与另一部分通信)
你发了一个请求,没收到响应 — 可能请求丢了、对方挂了、对方处理了但响应丢了、对方处理慢还没回。 你无法区分这些情况 — 这就是”等待确认什么都不会再发生”的困难场景。
应对手段:
- 超时(timeout):等一段时间没回就认为失败,但超时时间怎么定?太短误判,太长浪费
- 重试(retry):失败就重试,但操作必须是幂等的,否则重复执行会出问题
- 心跳(heartbeat):周期性探测对方是否存活
- 故障检测器(failure detector):不给”是/否”的结论,给”可能挂了”的概率
1.2.4 · 同步复制 vs 异步复制:等谁?#
把同一份数据放到多个节点上(复制),核心问题就是等不等从节点确认:
- 同步:主节点等所有从节点确认后才返回 — “等所有人”,强一致但慢,任一从节点故障就阻塞
- 异步:主节点写完立即返回 — “不等”,快但从节点可能落后(复制滞后 replication lag)
- 半同步:一个从节点同步,其余异步 — 折中
复制方式的选择也是在做等待的取舍:
| 方式 | 思路 | 取舍 |
|---|---|---|
| 单主复制 | 一个主节点写,从节点复制 | 简单,但主节点是瓶颈和单点 |
| 多主复制 | 多个节点都能写 | 写入吞吐高,但要处理写冲突 |
| 无主复制 | 任何节点都能写,读取多个节点 | 高可用,但一致性更复杂 |
1.2.5 · 复制滞后:不等带来的代价
异步复制时,用户写完立刻读从节点,可能读不到自己刚写的数据:
- 读己之写(read-your-writes):保证用户能读到自己写入的数据
- 单调读(monotonic reads):保证用户不会看到时间倒流(先看到新数据再看到旧数据)
- 一致前缀读(consistent prefix reads):保证因果顺序不被打破
1.2.6 · 2PC 的阻塞:等协调者#
两阶段提交中,参与者投了”能提交”之后,必须等协调者的最终决定。 协调者挂了 → 参与者永远等待。这是”等某件事发生”退化为”不知道在等什么”的典型场景。
1.3 · 2. 排序:需要多少顺序?#
偏序自然产生,全序需要付出代价
1.3.1 · 偏序:因果关系自然形成
因果事件构成一个 DAG(偏序):有 happened-before 边的事件有先后,其余并发。 偏序只捕获必须有的顺序,不多不少。
happened-before 关系(A → B):
- 同一进程内:A 在 B 之前执行,则 A → B
- 跨进程:A 是发送消息,B 是接收该消息,则 A → B
- 传递性:A → B 且 B → C,则 A → C
- 如果 A 和 B 之间没有因果关系,则它们是并发的(A ‖ B)
1.3.2 · 全序:代价高昂的承诺
全序不仅说”a 在 b 之前”,还说”a 和 b 之间没有别的事件”。 第二句是对未见事件的声明 — 包括未来事件和延迟到达的事件。
偏序可以随着事件发生自然增长;全序强制对并发事件做出排序决定,即使没有因果理由,即使还没看到所有相关事件。一旦决定,不能反悔。
这就是线性一致性和可串行化代价高的原因:成本不在于维护因果已经要求的顺序,而在于过早提交 — 在系统还没看到足够信息时就决定全序。
1.3.3 · 不可靠的时钟:排序的陷阱
每台机器有自己的时钟,它们不一样快,也不一样准:
- 墙上时钟(wall clock):和真实世界对应,可以用 NTP 同步,但可能跳变(回拨)
- 单调时钟(monotonic clock):只增不减,适合测量间隔,但不同机器之间无法比较
如果用时间戳来排序事件(比如 last-write-wins),时钟偏差会导致数据丢失: 机器A写入 t=100,机器B写入 t=99(B的时钟慢了),B的写入被A覆盖,但实际上B更晚
1.3.4 · 排序工具
- 逻辑时钟(Lamport clock):不关心物理时间,只关心因果顺序 — 维护偏序
- 向量时钟(vector clock):能判断两个事件是因果关系还是并发关系 — 精确偏序
- 混合逻辑时钟(HLC):结合物理时钟和逻辑时钟
- TrueTime(Google Spanner):用 GPS + 原子钟,给出时间的置信区间
[earliest, latest]— 逼近全序 - 全序广播(total order broadcast):所有节点以相同顺序收到相同消息 — 直接实现全序
1.3.5 · 一致性模型:对排序的承诺等级
一致性 = 系统承诺维护多少顺序。越强的一致性,排序越严格,代价越高:
线性一致性(Linearizability) 系统表现得像只有一个副本,所有操作按实际时间顺序排列 — 全序
- 一旦某个读返回了新值,后续所有读都必须返回新值
- 代价:需要协调,延迟高,网络分区时不可用
顺序一致性(Sequential Consistency) 所有操作有一个全局顺序,且每个进程内的操作顺序被保留 — 全序,但不要求和实际时间一致
因果一致性(Causal Consistency) 只保证有因果关系的事件顺序一致,并发事件可以乱序 — 偏序
- 用向量时钟或因果依赖追踪实现
- 是”不牺牲可用性的最强一致性”
最终一致性(Eventual Consistency) 如果不再写入,所有副本最终会收敛到一致状态 — 最弱的排序承诺
- 最高可用
- “最终”是多久?不保证
1.4 · 3. 提交:什么时候承诺不可撤回?#
暴露观察到的事实是安全的;暴露”最终结论”需要协调
1.4.1 · 安全的暴露 vs 危险的暴露#
安全:“这条消息到了”、“a 在 b 之前” — 这些是对已观察证据的报告。 不同副本可能在不同时间看到,但看到的内容只会被补充,不会被推翻。 副本可以独立暴露这些事实,观察者看到的视图只会增长(单调)。
危险:“不会再有别的了”、“这个结果是最终的”、“这就是顺序” — 这些排除了未来观察的可能性。 一旦对外暴露,系统就被绑定了。未来的事件必须兼容这个声明 — 即使它们还没被所有节点看到。
一个副本可能基于自己的信息做出声明,而另一个副本还有可能观察到与之矛盾的事件。 此时系统无法简单”更新”观察者的视图而不违反已经说过的话。
1.4.2 · 共识:多节点对一个值做出不可撤回的承诺
共识 = 分布式系统最核心也最困难的问题
FLP 不可能定理 在异步网络中,即使只有一个节点可能崩溃,也不存在一个确定性的共识算法能保证终止
- 意义:完美的共识是不可能的,实际算法都做了妥协(如引入超时、随机化)
Paxos Lamport 提出,理论上最经典:
- 角色:proposer、acceptor、learner
- 两阶段:prepare → accept
- 保证:只要大多数 acceptor 存活,就能达成共识
- 问题:难理解、难实现、活锁
Raft 详见 raft
为可理解性而设计,问题拆分为:
- Leader Election
- Log Replication
- Safety
ZAB(ZooKeeper) 和 Raft 类似,也是基于主节点的共识协议
1.4.3 · 共识的等价问题
这些问题本质上都等价于共识 — 都需要做出不可撤回的承诺:
- 原子广播(全序广播):所有节点以相同顺序收到相同消息
- 领导选举:选出唯一的领导
- 分布式锁 / 租约
- 原子事务提交
1.4.4 · 事务提交:跨节点的原子承诺
事务 = 把多个操作打包成一个原子操作 单机事务已经很成熟,分布式事务是另一个难度级别
操作涉及多个节点,要么全成功,要么全失败 — 系统对外承诺”这组操作是原子的”。
两阶段提交(2PC)
- Prepare:协调者问所有参与者”你能提交吗?”
- Commit/Abort:所有参与者都说能,协调者说”提交”;否则”中止”
问题:
- 参与者投了”能”之后,必须等协调者的决定,此时参与者被锁住(等待问题)
- 协调者挂了 → 参与者永远等待(阻塞问题)
- 性能差:需要两轮通信 + 持久化日志
三阶段提交(3PC) 在 2PC 基础上加了一个 pre-commit 阶段,减少阻塞窗口 但在网络分区场景下仍然有问题,实际使用少
Saga 长事务拆成一系列本地事务,每个本地事务有对应的补偿操作:
- 正向执行:T1 → T2 → T3
- 某步失败,反向补偿:C2 → C1
- 最终一致性,不保证隔离性
- 本质上放弃了原子提交的承诺,换取可用性
1.4.5 · 写冲突:并发时的提交决策
多主 / 无主复制时,两个节点同时修改同一条数据 — 系统必须决定承诺哪个版本:
- Last-Write-Wins(LWW):时间戳大的赢,简单但会丢数据(依赖有问题的全序)
- 合并(merge):应用层合并冲突(推迟提交,让用户决定)
- CRDT:数据结构本身支持自动合并,无冲突 — 避免了提交决策,因为所有并发写入都能单调合并
1.5 · 4. 工程取舍:三个视角的汇合#
1.5.1 · CAP 定理#
在网络分区(P)发生时,系统只能在一致性(C)和可用性(A)之间选一个:
- CP:分区时拒绝服务,保证一致性(如 ZooKeeper、etcd) — 宁可等待,也要正确排序和提交
- AP:分区时继续服务,但数据可能不一致(如 Cassandra、DynamoDB) — 不等待,放松排序,推迟提交
CAP 是一个取舍框架,不是非黑即白的分类 实际系统在不同操作上可能做不同选择
1.5.2 · PACELC#
对 CAP 的扩展:
- 如果有 Partition:选 A 还是 C?(和 CAP 一样)
- Else(正常情况):选 Latency 还是 Consistency?— 即使没分区,全序和强提交也要付出延迟代价
| 系统 | P 时 | 正常时 |
|---|---|---|
| DynamoDB | A | L(低延迟优先) |
| MongoDB | C | L |
| Spanner | C | C(用 TrueTime 付出延迟代价换一致性) |
1.6 · links#
- https://martin.kleppmann.com/2015/09/17/critique-of-the-cap-theorem.html
- https://martin.kleppmann.com/2015/05/11/please-stop-calling-databases-cp-or-ap.html
- https://aphyr.com/posts/281-jepsen-on-the-perils-of-network-partitions
- https://raft.github.io
- https://understandingdistributed.systems
- https://www.oreilly.com/library/view/designing-data-intensive-applications/9781491903063
- https://www.atlassian.com/zh/microservices/microservices-architecture/distributed-architecture