Skip to main content

第 12 章 构建分布式系统(Building a distributed system)学习笔记

本章内容为原创学习笔记与概念整理,不是原书逐段翻译。

前面的并发和容错都发生在单个 BEAM 节点内部。本章把范围扩展到多个节点:不同机器或不同 VM 实例上的进程如何互相通信,如何组成一个集群,以及网络失败会给系统带来哪些额外问题。

Elixir/Erlang 的分布式能力很有吸引力,因为它把“远程进程”纳入了和本地进程相似的抽象:进程有 PID,可以发消息,可以链接和监控。但分布式系统不会因为语法简单而变简单。网络、分区、延迟和一致性仍然需要认真设计。

12.1 分布式 Erlang 的基本模型

一个运行中的 BEAM VM 可以被称为一个节点。给节点命名并启用分布式能力后,它可以连接其他节点,形成一个 Erlang distribution 集群。

在集群中,你可以:

  • 连接节点。
  • 查看当前可见节点。
  • 给远程进程发消息。
  • 在远程节点上启动进程。
  • 监控远程进程或节点。
  • 使用全局注册、分布式任务等服务。

这套能力的基础仍然是进程和消息。也就是说,你前面学到的模型没有被替换,而是跨越了节点边界。

节点名称

节点通常有短名或长名:

iex --sname node1
iex --name [email protected]

短名适合同一主机或简单局域网实验;长名包含主机名,更适合真实网络环境。节点名必须能被其他节点解析,否则连接会失败。

Erlang distribution 使用 cookie 做节点间认证。只有 cookie 相同的节点才能互相连接。开发时可以用命令行指定:

iex --sname node1 --cookie secret
iex --sname node2 --cookie secret

cookie 不是完整安全模型,不应把它等同于现代网络安全认证。生产环境中还需要考虑网络边界、TLS、端口暴露和访问控制。

12.2 节点间通信

连接节点后,可以用 Node.connect/1 建立连接,用 Node.list/0 查看已连接节点:

Node.connect(:node2@myhost)
Node.list()

远程进程通信的基本形式仍然是消息发送。只要知道远程 PID,就可以 send(pid, message)。也可以通过远程节点上的注册名发送消息。

这很强大,也很危险。强大在于分布式编程模型统一;危险在于远程调用看起来像本地调用,容易让人忽略网络失败。

远程调用不是本地调用

即使 API 看起来相似,跨节点通信和本地消息有本质差异:

  • 网络可能延迟或丢失。
  • 远程节点可能断开。
  • 两个节点可能发生网络分区。
  • 消息可能在目标进程崩溃后无人处理。
  • 本地和远程的性能成本完全不同。

因此,分布式边界应尽量显式。不要让业务代码在不知情的情况下频繁跨节点调用。更好的做法是把跨节点通信封装在清晰模块中,并为超时、重试、幂等和失败返回设计策略。

12.3 远程进程发现

单节点系统可以用本地 Registry 或命名进程查找服务。分布式系统中,进程发现更复杂。

常见选择包括:

  • 约定某个服务只运行在特定节点。
  • 在每个节点运行同类服务,调用方按本地优先或哈希路由。
  • 使用全局注册或第三方集群注册库。
  • 使用外部服务发现系统。
  • 通过 PubSub 或 gossip 传播节点和进程信息。

进程发现设计要回答:

  • 同一个业务 key 是否只能有一个 owner?
  • owner 节点断开时,其他节点能否接管?
  • 两个节点同时认为自己是 owner 时怎么办?
  • 节点重新连接后如何合并状态?

这些问题本质上已经进入分布式一致性领域。不要只把它看成“找 PID”。

12.4 分布式链接和监控

链接和监控也可以跨节点工作。监控远程进程时,如果远程进程退出或节点不可达,监控者会收到 :DOWN 消息。链接远程进程时,退出信号可以跨节点传播。

不过,远程失败原因有时无法精确区分。节点断开可能意味着:

  • 远程 VM 崩溃。
  • 网络断了。
  • 网络极慢。
  • 防火墙阻断连接。
  • 本地节点和远程节点之间发生分区。

调用方不应过度依赖“精确原因”。更可靠的是设计在“不确定远程状态”下仍然安全的恢复策略。

12.5 构建容错集群

把待办系统扩展到多个节点时,目标可能包括:

  • 某个节点宕机后,其他节点仍能服务。
  • 请求可以分散到多个节点。
  • 列表数据可以复制或恢复。
  • 新节点加入后能承担部分负载。

一个简单思路是:每个节点都运行完整 application,并在节点之间复制或同步必要数据。

概念结构:

node_a                         node_b
Todo.Application Todo.Application
Todo.Cache Todo.Cache
Todo.Database Todo.Database

如果每个节点都有数据库副本,就需要考虑复制。如果只有一个主节点拥有数据,其他节点依赖它,那么主节点仍然是单点。

复制不是自动一致

复制数据可以提高可用性,但也会带来一致性问题:

  • 写入在哪些节点成功后才算成功?
  • 两个节点同时写同一份列表如何解决冲突?
  • 节点断开期间各自接受写入,重连后如何合并?
  • 删除和更新如何传播?
  • 旧消息或重复消息是否会破坏状态?

学习阶段可以先接受简化模型,例如单 owner 写入、其他节点只读,或者所有写入都路由到确定节点。真正高可用写入需要更严肃的一致性协议或成熟存储系统。

12.6 网络分区

网络分区是分布式系统绕不开的问题。两个节点都还活着,但彼此无法通信。这时最危险的情况是:两边都以为自己可以继续独立处理同一份关键状态。

例如,同一个待办列表在两个分区里都接受写入。网络恢复后,你必须决定:

  • 保留哪边?
  • 如何合并?
  • 冲突条目如何处理?
  • 调用方曾经收到的“成功”是否仍然有效?

这就是为什么分布式系统设计必须明确一致性和可用性的取舍。有些业务可以接受最终一致,有些业务必须强一致,有些业务宁愿拒绝写入也不能产生冲突。

保守策略

对于初学系统,一个保守策略是:检测到分区或无法确认 owner 时,拒绝关键写入,只允许只读或降级操作。

这会牺牲可用性,但能避免状态分裂。等业务需求明确后,再引入更复杂的复制、仲裁或冲突解决机制。

12.7 网络和部署注意事项

让节点在开发机上互联很容易,让它们在生产网络中稳定互联则更复杂。

需要考虑:

  • 节点名称是否能被 DNS 解析。
  • 节点通信端口是否固定并开放。
  • cookie 如何安全分发。
  • 是否需要 TLS。
  • 容器、Kubernetes 或 NAT 环境下节点名如何映射。
  • 节点断开和重连的监控。
  • 集群滚动升级期间的兼容性。

Erlang distribution 默认更适合受信任网络。不要把节点通信端口暴露到不可信网络上。对公网 API,应使用 HTTP、gRPC 或其他明确安全边界的协议。

12.8 分布式系统设计清单

把单节点系统扩展到多节点前,可以先回答:

  • 为什么需要分布式:容错、扩容、地理位置,还是部署隔离?
  • 哪些状态必须跨节点共享?
  • 哪些状态可以只存在本地?
  • 谁拥有某个业务 key?
  • 节点断开时是否继续接受写入?
  • 重连后如何处理冲突?
  • 跨节点调用的超时是多少?
  • 是否需要幂等请求?
  • 节点发现和认证怎么做?
  • 如何观察节点连接、分区和复制延迟?

很多系统并不需要一开始就做分布式 BEAM。可以先用单节点 OTP application,配合外部数据库和负载均衡。只有当明确需要 BEAM 节点协作时,再引入分布式原语。

练习:为待办系统设计分布式 owner

尝试设计一个简单路由规则:

  1. 根据列表名计算哈希。
  2. 把列表映射到某个节点。
  3. 所有写入都路由到 owner 节点。
  4. 读取可以本地缓存,但需要知道缓存是否过期。
  5. owner 节点不可达时,关键写入返回 {:error, :unavailable}

继续思考:

  • 节点数量变化后,哈希映射如何迁移?
  • owner 节点恢复后,其他节点是否有离线写入需要同步?
  • 调用方是否可以重试?重试是否幂等?
  • 如果两个节点都认为自己是 owner,系统如何发现?

这份练习的重点不是实现完美集群,而是把分布式设计中的关键问题摆到台面上。

本章复盘

  • BEAM 节点可以连接成集群,跨节点仍以进程和消息为基本模型。
  • 远程通信看起来像本地通信,但必须显式处理延迟、断开和不确定性。
  • 分布式进程发现本质上牵涉 owner、接管和一致性问题。
  • 链接和监控可以跨节点工作,但远程失败原因常常无法精确判断。
  • 容错集群需要明确数据复制和恢复策略,复制并不自动带来一致性。
  • 网络分区会迫使系统在可用性和一致性之间做选择。
  • 生产网络中还要考虑节点名、cookie、端口、防火墙、TLS 和部署平台。
  • 引入分布式 BEAM 前,应先确认问题真的需要节点级协作。