引言
两星期前读了Manning出版的一本新书 ------ 《Think Distributed Systems》。作者是德国人,他说自己写这本书的目的是想通过一个完整的理论模型来帮助读者系统性的理解分布式。作者认为,分布式是个极其复杂的问题,而我们平时阅读的大多数分布式相关的书籍和论文,都是侧重于某些具体的场景、某个专门的系统或者是某个特定的问题,这就像是盲人摸象,难以从整体全局的高度来透彻地理解分布式系统。
然后作者便从正确性、可伸缩性和可靠性入手,分多个不同的方面来介绍分布式:时间和顺序、容错、消息的传递、事务、分区、复制、一致性、共识,还有长事务(Durable execution)以及云服务。
但我觉着这种按照不同方面去介绍的做法,依然不够系统,倒不是说作者写得这些不够全面,而是真正的系统性应该是一种"因果链"的形式:我们要通过分布式来解决什么问题,解决这些问题分别有哪些手段,当我们解决既有问题时,又总是不可避免地引入了新的问题,分布式系统会引入哪些新问题,以及如何应对这些问题,最后,在我们设计分布式架构时,应该依赖哪些实践原则。
然后我就按照因果链的形式来重新整理了一篇关于分布式的文章。从分布式解决的问题开始,再到常见的解决问题的手段,然后是分布式引入的新问题 (四个不可靠),最后是我自己认为的一些重要设计原则。
这篇文章不会太多展开每个方面的细节,更突出的是因果链,可用于日常架构设计的参考以及快速学习理解一个新的分布式系统,最重要的,当你以后再次面对一篇复杂的分布式系统论文,你知道它是在解决这条因果链上哪一个环节的问题,它在这"四个不可靠"当中做了哪些取舍。
一、分布式所解决的问题
分布式系统本质上解决两个核心问题:规模 和可靠。
规模
单机的计算能力、存储容量和网络带宽都存在物理上限。当业务增长超出单机承载能力时,我们需要将负载分散到多台机器上。这包括:
- 计算规模:单机CPU无法处理的计算量
- 存储规模:单机磁盘无法容纳的数据量
- 并发规模:单机网络无法承载的请求量
可靠
单点意味着单点故障。任何硬件都会失效,任何软件都有Bug,任何机房都可能断电。要让系统在部分组件失效时继续运行,就必须引入冗余------而冗余天然意味着分布式。
垂直扩展的可能性
值得强调的是,分布式并非唯一选择。在很多场景下,垂直扩展(更强的CPU、更大的内存、更快的SSD)可能成本更低、复杂度更小。分布式引入的协调开销、一致性挑战和运维复杂度都是实实在在的成本。只有当垂直扩展触及天花板,或者可靠性要求超出单机能提供的保障时,分布式才是必要的选择。
二、解决问题的手段
分区:解决规模问题
分区是将数据或计算切分到多个节点上的基本手段。采用的分区算法决定了系统之后的查询模式、延迟特性和吞吐能力------这个选择往往在系统设计早期就需要确定,后期更改代价极高。
静态分区:分区数固定
范围分区将数据按键的范围划分。例如,用户ID 1-10000分到节点A,10001-20000分到节点B。优点是支持高效的范围查询;缺点是容易产生热点------如果大量新用户注册,最后一个分区会承受巨大压力。
哈希分区对键进行哈希运算,根据哈希值决定归属分区。优点是数据分布均匀,避免热点;缺点是牺牲了范围查询能力,只能支持点查。
静态分区的分区数在系统启动时确定。当负载变化需要调整时,可以通过分区预分配进行Rebalance------预先创建远多于当前节点数的分区,扩容时只需将部分分区迁移到新节点。
动态分区:分区数可变
路由表方案维护一个独立的映射表,记录每个键(或键范围)归属哪个节点。扩容时可以更灵活地控制迁移粒度,或者在无状态处理的情况下将迁移控制在路由层面 (比如Actor集群)。代价是引入了额外的组件和通信开销,路由表本身也成为需要维护一致性的状态。
一致性哈希将节点和数据都映射到同一个哈希环上,数据归属于环上顺时针方向的第一个节点。节点增减时,只影响相邻节点之间的数据,大大减少了Rebalance时的数据迁移量。
复制:解决可靠问题
复制通过创建数据的多个副本来实现冗余。冗余具备三重意义:
- 持久化层面的备份:即使部分副本丢失,数据仍然存在
- 运行时层面的更替:主节点故障时,备节点可以接管服务
- 负载均衡:读请求可以分散到多个副本,实现读写分离
冗余带来的一致性挑战
多个副本意味着同一份数据存在多个版本,如何保持它们的一致是核心挑战。
逻辑上的一致性 涉及复制的内容。状态复制 直接传输数据的完整状态或增量变化;日志复制传输操作日志,由各副本自行重放。日志复制更灵活,支持回放和审计,是现代分布式系统的主流选择。
通信上的一致性 涉及复制的时机。同步复制 要求所有副本确认后才返回客户端,一致性强但延迟高、可用性差;异步复制立即返回,后台传播,延迟低但存在数据丢失风险。
法定人数策略(Quorum) 是一种折中:只要大多数副本(如3个中的2个)成功复制即可返回。写入W个副本,读取R个副本,只要W+R>N(总副本数),就能保证读到最新版本数据(可能需要客户端提供版本合并策略)。
冗余的架构形态
单主架构所有写入都经过一个主节点,再同步到从节点。实现简单,一致性容易保证,但主节点是可用性瓶颈。
多主架构允许多个节点接受写入,一般用于跨数据中心的复制。每个数据中心有本地主节点,降低写入延迟。代价是需要处理主节点之间的写入冲突。
无主架构没有特定的主节点,客户端可以向任意节点写入。需要解决并发冲突,常见策略包括最后写入胜出(Last Write Wins)、向量时钟或者交给业务侧做状态合并。
三、分布式所带来的四个新问题
分布式解决了规模和可靠问题,但也引入了新的复杂性。这些问题可以归结为"四个不可靠":通信不可靠、时钟不可靠、顺序不可靠、状态不可靠。
通信不可靠
网络是分布式系统的基础设施,而网络本质上是不可靠的:消息可能丢失、延迟、重复或乱序。
"谁发送谁负责" 是基本原则。发送方无法假设消息一定送达,必须主动确认。
确认机制要求接收方收到消息后发送确认(ACK)。发送方收到确认才认为传输成功。
重试机制 在超时未收到确认时重新发送。这引出了双重超时 的概念:一是重试间隔超时 ,控制何时重发;二是业务有效期超时,控制整体重试的时限------超过业务有效期后,即使消息最终送达也已失去意义,此时系统可能选择放弃重试,这可能导致消息丢失。
重试带来新问题:接收方可能收到重复消息。在通信层面无法真正实现Exactly Once ------消息要么可能丢失(At Most Once),要么可能重复(At Least Once)。只能在逻辑层面实现Exactly Once语义,通过幂等性设计或去重机制确保重复消息不会导致重复的业务效果。
时钟不可靠
每个节点有自己的物理时钟,由本地晶振驱动。即使初始同步,也会逐渐漂移。NTP协议可以校正,但无法消除毫秒级的误差。在分布式系统中,"现在几点"这个看似简单的问题没有统一答案。
Lamport时钟是一种逻辑时钟,通过消息传递来同步计数器。它能确定事件的因果先后顺序------如果A导致了B,那么A的时间戳一定小于B。但它无法判断并发事件的先后,也无法告诉我们两个事件之间是否是连续发生的。
向量时钟扩展了Lamport时钟,能够识别并发事件,但空间开销随节点数增长。
当需要真正的全局顺序时,往往需要基于共识的全局逻辑时钟------通过Paxos或Raft等协议,让集群就事件顺序达成一致。
Google的TrueTime则采用另一种思路:通过GPS和原子钟将物理时钟误差控制在已知范围内,用误差区间而非精确时间点来处理时序问题。
顺序不可靠
事件具有三重时间语义:
- 真实发生的时间:事件在现实世界中实际发生的时刻
- 进入系统的时间:事件被系统记录的时刻
- 处理时间:事件被系统处理的时刻
即便真实发生的时间来自可靠的时钟,也不保证事件会按照发生的顺序被处理。网络延迟、队列积压、节点负载差异都可能导致后发生的事件先被处理。
这在流处理系统中尤为突出。窗口机制 将事件按时间段聚合处理;Watermark机制 声明"早于此时间的事件应该都已到达",允许系统在完整性和及时性之间折中。这本质上是在延迟、空间和准确性三者之间进行权衡:等待越久,结果越准确,但延迟越高,需要缓存的状态也越多。
状态不可靠
状态不可靠是最复杂的问题,其根源有多个层面:
网络层面 :通信不可靠已如前述;更严重的是网络分裂,集群被分割成多个无法互相通信的部分,每个部分都可能认为自己是"正统"而继续服务。
单节点层面:节点可能宕机;可能因并发访问产生竞态条件;可能因GC、换页等资源分配操作产生延迟和抖动。
一致性与可用性的冲突
这就是著名的CAP定理的背景:在网络分区发生时,系统必须在一致性(C)和可用性(A)之间选择。值得注意的是,可用性不仅指"能否响应",也包括"响应是否足够快" ------一个需要等待10秒才能返回的系统,对用户而言可能和不可用没有区别。
强一致性确保所有节点看到相同的数据视图。实现手段包括同步复制(所有副本确认后才返回)和Quorum策略(多数派确认)。代价是延迟增加、可用性降低。
最终一致性允许副本暂时不一致,但保证最终收敛。实现手段包括异步复制和Gossip协议(节点间随机交换信息,逐渐扩散更新)。优点是高可用低延迟,代价是应用需要处理读到旧数据的情况。
分布式事务
当一个业务操作涉及多个节点时,如何保证原子性?
2PC(两阶段提交) 分为准备阶段和提交阶段。协调者先询问所有参与者是否可以提交,全部同意后再发送提交指令。问题是协调者故障会导致参与者阻塞,在等待最终指令期间资源被锁定。
3PC(三阶段提交) 增加了预提交阶段,参与者在收到预提交后可以在超时时自行决定提交。这极大降低了协调者不可用时的阻塞时间,但仍然存在极端情况下的不一致风险。
Saga事务 将长事务拆分为一系列本地事务,每个本地事务都有对应的补偿操作。出现故障时可以向前恢复 (重试失败的步骤直到成功)或向后恢复(按逆序执行补偿操作回滚已完成的步骤)。Saga牺牲了隔离性,但避免了长时间持有锁,适合长时间运行的业务流程。
共识算法
共识与一致性是不同的概念:一致性关注的是节点之间状态的同步------所有副本的数据是否相同;共识则是允许系统内部可以不一致,但系统对外展现的状态总是一致的------集群作为整体就某个值达成一致的决定。
Raft是目前最流行的共识算法,因其易于理解而广泛应用。核心机制包括:
日志复制:Leader接受客户端请求,将操作追加到本地日志,然后复制到Follower。当多数节点确认后,该条目被视为已提交,Leader应用该操作并响应客户端。
Leader选举与任期机制:集群在任一时刻最多只有一个Leader。每个Leader有一个单调递增的任期号。当Follower超时未收到Leader心跳时,会发起选举,获得多数票者成为新Leader。任期机制确保了旧Leader不会干扰新Leader的决策。
共识算法的证明与测试:共识算法的正确性极难保证。形式化证明(如TLA+)可以验证算法逻辑,但实现中的Bug仍然常见。混沌测试(Chaos Testing)通过注入网络分区、节点故障、消息延迟等故障来验证实现的健壮性。Jepsen等工具专门用于发现分布式系统中的一致性问题,已经在众多知名系统中发现过严重Bug。
四、分布式系统的架构原则
理解了分布式的问题、手段和挑战后,以下是一些重要的实践原则。
"理论绝对"对工程实践没有意义
不要简单地说"我的系统是CP的"或"我的系统是AP的"。CAP定理描述的是极端情况下的必然取舍,而现实中的系统设计是一个连续的光谱。
更有意义的讨论是:业务场景需要在多大程度上一致和可用? 购物车可以容忍短暂的不一致,但银行转账不行。首页展示可以容忍几秒的延迟,但交易下单不行。针对不同的业务场景,可以在同一个系统中采用不同的策略。
允许漏洞存在
就像2PC,在逻辑上是能找出漏洞的------协调者在发送部分提交消息后崩溃,就会导致参与者状态不一致。但这并不妨碍我们在现实中广泛使用它。
关键在于确保两点:
- 这些漏洞引发的故障是小概率事件:协调者恰好在那个时间窗口崩溃的概率很低
- 当故障不可避免地发生时,可以通过外部干预进行修复,且修复引发的延迟可以接受:运维人员可以手动介入,检查各参与者状态,决定统一提交或回滚
追求理论上的完美往往意味着巨大的复杂度和性能代价。工程上的智慧在于识别哪些漏洞可以接受,哪些必须堵住。
减少问题概率就是价值
漏洞不必从理论上完全解决,能够减少问题出现的概率就是好的。
3PC就是一个例子。它并没有从根本上解决2PC的问题------在网络分区的情况下仍然可能不一致。但它极大降低了事务参与者不可用时的整体阻塞时间。在工程实践中,这种"不完美但更好"的改进往往比"完美但复杂"的方案更有价值。
类似地,Gossip协议不保证传播的时间上限,但在实践中能以很高的概率在可预期的时间内完成传播。对于状态同步这类场景,这种概率性保证就足够了。
局部最大化原则
在没有意外的情况下,所有的工作应该尽可能局限在单个分区之内。 只有在发生故障、Rebalance或者更新配置时,才需要整个集群参与协调达成共识。
这个原则的背后是一个基本事实:跨节点协调的代价远高于本地操作。网络延迟、消息序列化、共识协议的多轮通信,都会显著增加延迟和降低吞吐。
好的分区设计应该让绝大多数请求都能在单个分区内完成。好的复制设计应该让正常的读写路径尽量简单,把复杂的协调逻辑留给异常处理。好的事务设计应该尽量避免跨分区事务,或者将其拆解为可以独立执行的子事务。
分布式系统的艺术,很大程度上就是如何把分布式的复杂性隐藏在边界情况中,让常见路径保持简单高效。
结语
分布式系统的复杂性源于一个根本矛盾:我们希望多台独立的机器表现得像一台机器一样------具有统一的状态、一致的行为、可预测的响应。但物理现实不允许:光速有限、硬件会坏、网络会断。
理解分布式系统,就是理解这个矛盾的各种表现形式,以及工程上的各种折中方案。没有银弹,只有权衡。每一个设计决策都是在一致性、可用性、性能、复杂度之间寻找适合特定场景的平衡点。
沿着本文的因果链思考:我们因为规模和可靠的需求而引入分布式;通过分区和复制来解决这些需求;分区和复制带来了通信、时钟、顺序、状态四个不可靠;我们用确认重试、逻辑时钟、窗口水位、共识算法等手段来应对这些不可靠;最后,在工程实践中,我们接受不完美、追求概率性保证、坚持局部最大化。
这就是分布式系统的完整图景。
这篇文章仅是纲要性质,如果要详细展开其中的每个方面,需要一本巨著的篇幅,不过,说不定哪天我还真有可能写本这方面的巨著 :)