把一台机器拆成很多台之后,会多出哪些问题
单机容量不够时,把计算和数据分散到更多机器上,是一条很自然的扩展路径。普通服务器可以逐步增加,故障也有机会被限制在局部,系统不必长期依赖价格高昂且扩展空间有限的单机配置。
但分布式并没有消除复杂度,只是改变了复杂度出现的位置。原来由进程内调用、本地磁盘和同一个时钟隐含保证的事情,拆开后都要通过网络协议重新建立:服务要找到彼此,数据要分片和复制,节点要判断远端是否存活,还要对操作顺序和最终结果达成一致。
单机遇到瓶颈以后,为什么会选择横向扩展
分布式系统并不是在摩尔定律放缓之后才出现,不过单核性能增速下降、数据规模扩大和通用服务器成本降低,确实让横向扩展越来越常见。与不断更换更大的单机相比,增加普通服务器可以更细粒度地扩容,也能把副本放在不同机器甚至不同机房,减少单点故障的影响。
扩展计算和存储之外,拆分还可能带来部署隔离和团队边界。不同服务可以采用不同的发布节奏,热点模块也可以单独扩容。这些收益都有前提:服务边界需要合理,自动化部署和观测能力要跟上,跨节点通信的额外成本不能吞掉拆分带来的好处。
所以“分布式一定更便宜、更快、更容易迭代”并不成立。更多节点意味着更多网络调用、数据副本、故障组合和运维对象。规模较小时,单机或结构简单的集中式系统往往更经济;只有容量、可用性、地域或组织边界已经形成实际约束时,拆分的收益才可能覆盖协调成本。
服务拆开以后,一次调用先要解决“去哪里”
单体内部调用一个函数,不需要关心它运行在哪个地址。服务独立部署后,调用方需要先把逻辑服务名转换成一组可访问实例,再从中选择一个具体的 IP 和端口。这两个问题通常由服务注册发现和负载均衡共同解决。
1 | 逻辑服务名 |
服务启动后向注册中心报告自身位置,并通过心跳、租约或控制器同步健康状态。调用方可以主动查询,也可以订阅实例变化并在本地保存缓存。注册中心里的数据量通常不大,麻烦在于状态变化频繁,而且发现链路本身不能轻易成为业务调用的单点。
这类系统经常强调可用性,但不能简单得出“注册中心只要 AP 就够了”。过时的列表可能把流量送向已经下线的实例,互相冲突的注册状态也会影响路由。工程上常见的处理是让注册与变更过程有明确的一致性规则,同时允许调用方使用最近一次可用缓存,在控制面短暂异常时继续调用;实例摘除速度、误判概率和缓存时长需要一起权衡。
一次远程调用失败,原因可能永远无法当场确认
在单机里,一个函数要么返回,要么抛出错误。跨网络调用超时后,调用方只能确认自己没有按时收到响应:请求可能没到,对方可能执行到一半,也可能已经执行成功,只是响应在返回途中丢失。这种只有部分节点失败、其他节点仍在工作的情况,是分布式系统最常见的故障形态。
超时和重试能够处理一部分瞬时问题,但重试会制造重复请求。如果扣款已经成功,客户端因为响应丢失再次发起扣款,没有幂等保护就会产生第二次副作用。稳定的请求标识、结果记录和幂等处理需要与重试同时设计。
重试本身也要有边界。连续立即重试会给正在恢复的下游增加压力,多个调用方同时重试还可能形成新的流量峰值。常见做法是限制尝试次数和总时间,加入指数退避与随机抖动,并把重试额度纳入整条调用链,而不是让每一层各自无限尝试。
当下游持续异常时,快速熔断可以减少无效等待,降级为调用方提供一个可接受的次级结果,限流则保护系统不被超过处理能力的请求压垮。弹性扩容有助于处理持续负载,却需要指标采集、调度和实例启动时间,无法替代超时、隔离和过载保护。
一台机器放不下数据时,分片只是第一步
数据超过单机容量或吞吐上限后,首先要决定每条数据放在哪个分片。哈希分片容易把大量键分散到不同节点,适合按键访问;范围分片把相邻键放在一起,更适合范围扫描。两者都不是一次配置后永久不变:节点增减时要迁移数据,访问分布不均时还要处理热点分片。
分片解决的是容量和并行处理问题,没有解决故障。一份数据只存在一个节点上,该节点宕机后数据就暂时不可用,因此还需要复制。副本可以提高容错能力,也让读取有机会分散到多台机器,但从这一刻开始,系统必须回答更多问题:一次写入要等多少副本,哪个副本可以对外提供读取,多个节点同时修改时以谁为准。
1 | 数据量过大 → 分片 → 出现迁移、热点和跨分片操作 |
把数据复制出去并不困难,让所有副本在故障和并发写入下仍认同同一个版本才困难。中心化复制可以由主节点决定写入顺序,但要处理主节点失效和切换;去中心化复制可以减少单点依赖,却需要 Quorum、版本信息或冲突合并等机制。具体选择取决于读写模式以及业务能够接受多长的不一致窗口。
每台机器都有时钟,却没有天然统一的时间线
不同节点的物理时钟会有误差,时钟同步也只能把误差控制在一定范围内。两个日志时间戳相差几毫秒,并不能可靠证明两个远程事件的因果顺序;时钟回拨还可能让后发生的事件看起来更早。
需要表达因果关系时,可以使用逻辑时钟。Lamport 提出的 happened-before 关系把“同一进程内先后发生”和“消息发送先于接收”作为可确认的顺序;互相没有信息传递的事件可能并发发生,不必凭本地时间强行判断谁先谁后。逻辑时钟适合解决顺序问题,却不代表真实世界的具体时刻。
Spanner 展示了另一种做法。TrueTime 通过 GPS 和原子钟等时间源给出带误差边界的时间区间,Spanner 利用这个边界分配事务提交时间戳,并在必要时等待不确定区间过去,从而提供外部一致性。这里的时间机制服务于事务排序与一致性,不是用来“生成分布式事务 ID”。
分布式 ID 只要保证所需范围内唯一,常见设计会组合时间、节点编号和本地序列号。一个 ID 可以大致有序,却不会自动提供事务原子性,也不能证明两个没有因果关系的事件在业务上应该按 ID 顺序执行。
多个节点修改同一份状态时,协调无法省略
分布式锁看起来像把本地互斥锁搬到了网络上,实际还要处理持锁节点失联、租约过期和旧持有者恢复的问题。仅有“锁到期自动释放”并不充分:旧节点可能在长时间暂停后继续写入,而新节点已经取得同一把锁。常见的防护是给每次成功获取锁分配递增的 fencing token,让真正保存数据的资源拒绝旧 token 的操作。
跨多个资源的事务同样需要协调。2PC 先询问参与者能否提交,再统一发送提交或回滚决定,可以保证各参与者采用同一结果;如果参与者已经进入 Prepared 状态而协调者失联,事务可能等待协调信息恢复。3PC、带高可用日志的协调者或基于共识复制的提交协议,都是在不同故障假设下缩小这段不确定状态。
共识处理的是另一个基础问题:多个节点各自看到不同消息和故障时,怎样仍对一个值、一个领导者或一段日志顺序作出相同决定。它可以支撑主节点选举、配置变更和复制日志,但会引入多数派、任期、日志持久化与故障恢复等成本。服务发现、分布式事务和共识彼此有关,却不能用一个协议名互相替代。
CAP 讨论的是分区发生后的选择
CAP 中的一致性指线性一致性,可用性指到达未故障节点的每个请求最终都能得到响应,分区容错性指节点间任意消息丢失时系统仍按规则运行。它描述的约束是:网络分区发生后,无法通信的两侧不能同时保证线性一致性和请求都得到成功处理。
现实系统无法保证网络永远可靠,因此 P 通常不是可以主动关闭的选项。分区期间坚持一致性的操作,需要让无法确认最新状态的一侧拒绝或等待请求;希望两侧继续受理请求,则要接受暂时不一致,并在网络恢复后检测和合并冲突。系统可以针对不同数据和操作做不同选择,不必给整个产品贴上固定的 CP 或 AP 标签。
这也解释了服务注册发现中的取舍。注册信息通常更在意故障期间仍能找到可用实例,但错误或陈旧路由同样有成本;权限、配置或领导者信息可能需要更强的一致性。具体设计要看一次旧读或一次拒绝分别会造成什么后果。
ACID 与 BASE 也不是 CAP 的两个直接阵营。ACID 描述事务需要提供的原子性、一致性、隔离性和持久性;BASE 是一组偏向基本可用、软状态和最终一致性的设计思路。关系数据库可以部署成不同一致性与可用性模型,NoSQL 系统也可能提供线性一致读或事务能力。选择存储系统时,仍然要回到具体操作需要什么保证。
拆分以后,原来的限制变成了一组协调问题
| 遇到的问题 | 常用机制 | 随之出现的成本 |
|---|---|---|
| 调用方不知道实例地址 | 服务注册发现、负载均衡、本地缓存 | 状态过期、健康误判、控制面依赖 |
| 下游变慢或部分失败 | 超时、幂等重试、退避、熔断、降级、限流 | 重复请求、误判、部分请求被拒绝 |
| 单机放不下全部数据 | 哈希或范围分片 | 数据迁移、热点、跨分片操作 |
| 节点故障后仍要读取 | 多副本、主从或 Quorum | 副本延迟、冲突、领导者切换 |
| 需要判断事件顺序 | 逻辑时钟、带误差边界的物理时钟 | 只能表达特定顺序,需要处理时钟不确定性 |
| 多节点必须采用同一决定 | 事务协议、租约、共识 | 额外通信、日志、阻塞或多数派要求 |
采用分布式架构之前,最好先写清楚单机遇到的具体限制,以及哪些状态在故障期间必须保持强一致。进入实现后,请求标识与幂等、超时和重试预算、分片迁移、数据恢复、时钟误差与故障演练都应成为明确设计,而不是依赖框架默认值。
分布式系统能够突破单机容量,也能把副本和计算放到更合适的位置。交换条件是:任何一次远程调用都可能没有明确结果,任何一份复制数据都可能暂时出现多个版本。系统能否稳定运行,最终取决于这些不确定状态有没有被识别、记录并纳入恢复流程。