34、分布式系统基础:构建容错集群的关键要素

分布式系统基础:构建容错集群的关键要素

1. 分布式系统简介

设计良好的并发系统在很多方面已为跨多台机器的分布式部署做好准备。虽然将系统从单机转换为分布式并非易事,会带来额外的挑战,但借助现有的简单分布式构建块,我们可以聚焦于分布式系统的核心挑战。通过一些基本的分布式原语,就能将一个简单的待办事项系统转变为基本的容错集群。

2. 分布式原语:节点集群搭建

分布式 BEAM 系统是通过在集群中连接多个节点来构建的。节点是具有关联名称的 BEAM 实例,可在同一主机或不同机器上启动多个节点并进行连接。连接后,不同节点上的进程可通过熟悉的消息传递机制进行通信。

2.1 启动集群

启动集群需先启动几个节点,启动节点可使用 --sname 参数:

$ iex --sname node1@localhost
iex(node1@localhost)1>

--sname 将 BEAM 实例转换为名为 node1@localhost 的节点。 @ 前的部分是在单台机器上唯一标识节点的前缀,后半部分标识主机。若省略主机部分,将自动使用主机名。 --sname 设置的是短名称,也可提供长名称,通过完全限定的符号名称或 IP 地址标识主机。启动节点后,可通过 Kernel.node/0 函数获取节点名称:

iex(node1@localhost)1> node()
:node1@localhost

节点名称在内部表示为原子。要连接节点,可在保持 node1 运行的情况下,启动新的 OS shell 会话,启动 node2 并连接到 node1

$ iex --sname node2@localhost
iex(node2@localhost)1> Node.connect(:node1@localhost)
true

Node.connect/1 的参数是表示目标节点名称的原子,调用时 BEAM 会尝试与目标 BEAM 实例建立 TCP 连接。连接建立后,节点视为已连接,它们之间的所有通信都通过此连接进行。可通过 Node.list/0 验证节点是否连接:

iex(node1@localhost)2> Node.list()
[:node2@localhost]
iex(node2@localhost)2> Node.list()
[:node1@localhost]

BEAM 默认尝试建立全连接集群,启动第三个节点 node3 并连接到 node2 时,它会自动连接到 node2 连接的所有其他节点:

$ iex --sname node3@localhost
iex(node3@localhost)1> Node.connect(:node2@localhost)
iex(node3@localhost)2> Node.list()
[:node2@localhost, :node1@localhost]

要获取集群中包括当前节点在内的所有节点列表,可使用 Node.list/1

iex(node1@localhost)3> Node.list([:this, :visible])
[:node1@localhost, :node2@localhost, :node3@localhost]

:this 选项表示希望当前节点出现在列表中, :visible 选项表示希望获取所有可见节点的列表。节点断开连接时,每个节点会定期向其连接的对等节点发送心跳消息,若某个节点连续四次未响应心跳消息,则视为断开连接并从连接节点列表中移除。

以下是启动和连接节点的流程图:

graph LR
    A[启动 node1] --> B[启动 node2]
    B --> C[Node.connect(node1)]
    C --> D{连接成功?}
    D -- 是 --> E[节点通信]
    D -- 否 --> F[重试连接]
2.2 节点间通信

节点启动并连接后,可让它们协同工作。简单的方法是使用 Node.spawn/2 ,它接收节点名称(原子)和 lambda 函数,在目标节点上生成新进程并运行该 lambda 函数:

iex(node1@localhost)4> Node.spawn(
:node2@localhost,
fn -> IO.puts("Hello from #{node()}") end
)
Hello from node2@localhost

这证明 lambda 函数已在另一个节点上执行。另一个重要的原语是向进程发送消息的能力,即位置透明性。无论目标进程位于哪个节点,发送操作的工作方式始终相同。例如,从 node1 启动在 node2 上运行的计算,然后将结果发送回 node1

iex(node1@localhost)5> caller = self()
iex(node1@localhost)6> Node.spawn(
:node2@localhost,
fn -> send(caller, {:response, 1+2}) end
)
iex(node1@localhost)7> flush()
{:response, 3}

这里使用了 iex shell 的 flush 辅助函数,它会从当前进程邮箱中取出所有消息并打印到控制台,证明消息已在调用节点上接收。消息可以是任何类型,当目标进程在另一个节点上时,消息会使用 :erlang.term_to_binary/1 编码,在目标节点上使用 :erlang.binary_to_term/1 解码。但应避免在不同节点间传递 lambda 函数,建议使用 Node.spawn/4 函数,它接受模块、函数和参数列表(MFA)来标识要在目标节点上调用的函数。

在多节点环境中,本地注册具有实际意义,同一注册名称可在不同节点上使用,但每个节点上只能使用一次。例如,为 node1 node2 的 shell 进程注册名称:

iex(node1@localhost)8> Process.register(self(), :shell)
true
iex(node2@localhost)3> Process.register(self(), :shell)
true

调用 send(:shell, some_message) 会根据调用节点将消息发送到 node1 node2 。可使用 {some_alias, some_node} 引用另一个节点上的本地注册进程,例如从 node1 node2 的 shell 发送消息:

iex(node1@localhost)9> send(
{:shell, :node2@localhost},
"Hello from node1!"
)
iex(node2@localhost)4> flush()
"Hello from node1!"

也可在进行 GenServer 请求时使用 {some_alias, some_node} 形式,还有 GenServer.abcast/3 GenServer.multi_call/4 函数可向给定节点上的所有本地注册进程发出请求。

以下是节点间通信的步骤列表:
1. 启动并连接节点
2. 使用 Node.spawn/2 在目标节点上生成进程
3. 发送消息并处理响应
4. 避免传递 lambda 函数,使用 Node.spawn/4
5. 利用本地注册和 {some_alias, some_node} 形式进行通信

2.3 进程发现

进程发现是集群中非常重要的操作,分布式系统和非分布式系统中进程通信的典型模式通常相同:
1. 客户端进程必须获取服务器的 PID。
2. 客户端向服务器发送消息。

在分布式环境中,由于 Registry 模块不支持集群,需要使用其他发现方法。

2.3.1 全局注册

集群范围内发现进程的最简单方法是使用 :global 模块,它提供全局名称注册功能。例如,在多节点集群中运行待办事项系统时,可使用全局名称注册确保每个待办事项列表只运行一个进程:

iex(node1@localhost)10> :global.register_name({:todo_list, "bob"}, self())
:yes

结果 :yes 表示全局注册成功,当前进程的全局别名是 {:todo_list, "bob"} 。此时,集群中所有节点上的进程都可找到该别名注册的进程。若尝试在 node2 上使用相同别名进行全局注册,会失败:

iex(node2@localhost)5> :global.register_name({:todo_list, "bob"}, self())
:no

可使用 :global.whereis_name/1 查找进程:

iex(node2@localhost)6> :global.whereis_name({:todo_list, "bob"})
#PID<7954.90.0>

查找操作是本地的,注册时会通知所有节点并缓存注册信息,后续查找在本地进行。全局注册涉及集群范围内的锁,防止其他节点上的竞争注册,注册成功后会通知所有节点。若注册进程崩溃或所属节点断开连接,别名会在所有其他机器上自动注销。全局注册也可与 GenServer 一起使用:

GenServer.start_link(
__MODULE__,
arg,
name: {:global, some_global_alias}
)
GenServer.call({:global, some_global_alias}, ...)

以下是全局注册的步骤表格:
| 步骤 | 操作 | 说明 |
| ---- | ---- | ---- |
| 1 | 尝试注册 | 设置集群范围的锁,检查别名是否已注册 |
| 2 | 通知节点 | 若未注册,通知所有节点新的注册信息 |
| 3 | 释放锁 | 完成注册后释放锁 |

2.3.2 进程组

当需要将多个进程注册到同一别名时,可使用 :pg (进程组)模块。例如,在冗余集群中,为了在节点崩溃时数据仍可用,可使用 :pg 模块创建集群范围内的组并添加多个进程:

iex(node1@localhost)1> :pg.start_link()
iex(node2@localhost)1> Node.connect(:node1@localhost)
iex(node2@localhost)2> :pg.start_link()
iex(node1@localhost)2> :pg.join({:todo_list, "bob"}, self())
:ok
iex(node2@localhost)3> :pg.join({:todo_list, "bob"}, self())
:ok

可通过 :pg.get_members/1 获取组内所有进程的列表:

iex(node1@localhost)3> :pg.get_members({:todo_list, "bob"})
[#PID<8531.90.0>, #PID<0.90.0>]
iex(node2@localhost)4> :pg.get_members({:todo_list, "bob"})
[#PID<0.90.0>, #PID<7954.90.0>]

更新数据时,可查询相应的进程组并向所有进程发出请求;查询数据时,可选择组内的单个进程进行操作。 :pg 模块的组创建和加入操作会在集群中传播,查找操作在本地缓存的 ETS 表中进行,会自动检测进程崩溃和节点断开连接并移除不存在的进程。

2.4 链接和监控

即使进程位于不同节点,链接和监控仍然有效。当以下事件发生时,进程会收到退出信号或 :DOWN 通知消息:
- 链接或监控的进程崩溃
- 链接或监控的进程所在的 BEAM 实例或整个机器崩溃
- 网络连接丢失

综上所述,通过掌握这些分布式原语,我们可以构建出强大的分布式系统,应对各种复杂的场景和挑战。

分布式系统基础:构建容错集群的关键要素

3. 分布式系统的实际应用与注意事项

在实际应用分布式系统时,除了掌握上述的分布式原语,还需要注意一些细节和特殊情况,以确保系统的稳定性和可靠性。

3.1 避免 Lambda 函数的远程传递

在前面提到,应避免将 lambda 函数传递到远程节点。这是因为 shell 定义的 lambda 函数会嵌入自身代码并在每次调用时动态解释,而模块函数中定义的 lambda 函数只有在所有节点运行相同编译代码时才能在远程节点生成或通过消息发送。在多节点集群中更新代码时,很难同时升级所有节点,导致节点间代码不一致。因此,建议使用 Node.spawn/4 函数,它接受模块、函数和参数列表(MFA)来标识要在目标节点上调用的函数,只要目标节点上存在该模块并导出相应函数,使用就是安全的。

以下是使用 Node.spawn/4 的示例:

# 假设存在一个模块 MyModule,其中有一个函数 my_function
defmodule MyModule do
  def my_function do
    IO.puts("Function called on target node")
  end
end

# 在 node1 上调用 Node.spawn/4 在 node2 上执行 MyModule.my_function
iex(node1@localhost)11> Node.spawn(:node2@localhost, MyModule, :my_function, [])
3.2 远程进程的识别

PID 可以标识本地和远程进程,在大多数情况下,无需担心进程的物理位置,但需要了解一些与网络相关的 PID 细节。之前看到的 PID 形式通常为 <0.X.0> ,其中 X 是正整数,内部每个进程在节点范围内有唯一标识符,可在字符串表示的最后两个数字中看到。如果在单个节点上创建足够多的进程,第三个数字也会大于零。当 PID 的第一个数字不为 0 时,表示这是一个来自其他节点的远程进程。可以使用 Kernel.node/1 函数来确定进程所在的节点:

iex(node1@localhost)12> pid = :global.whereis_name({:todo_list, "bob"})
#PID<7954.90.0>
iex(node1@localhost)13> Kernel.node(pid)
:node2@localhost

以下是识别远程进程的步骤列表:
1. 获取进程的 PID
2. 检查 PID 的第一个数字是否为 0
3. 使用 Kernel.node/1 函数确定进程所在的节点

3.3 分布式系统的异常处理

在分布式系统中,节点断开连接、进程崩溃等异常情况是不可避免的。可以使用 Node.monitor/1 函数注册并接收节点断开连接的通知,使用 :net_kernel.monitor_nodes 监控所有节点的连接和断开情况。

以下是使用 Node.monitor/1 的示例:

# 在 node1 上监控 node2
iex(node1@localhost)14> Node.monitor(:node2@localhost)
true

# 当 node2 断开连接时,node1 会收到 :DOWN 消息
iex(node1@localhost)15> receive do
  {:nodedown, :node2@localhost} ->
    IO.puts("Node node2@localhost is down")
end

以下是分布式系统异常处理的流程图:

graph LR
    A[系统运行] --> B{是否有异常发生?}
    B -- 是 --> C{异常类型}
    C -- 节点断开连接 --> D[使用 Node.monitor/1 接收通知]
    C -- 进程崩溃 --> E[使用链接和监控机制处理]
    B -- 否 --> A
    D --> F[进行相应处理]
    E --> F
4. 总结与最佳实践

通过以上内容的学习,我们了解了分布式系统的基本概念、分布式原语的使用以及一些特殊情况的处理方法。以下是构建分布式系统的一些最佳实践总结:

4.1 节点管理
  • 合理使用 --sname --name 参数启动节点,根据实际情况选择短名称或长名称。
  • 定期检查节点连接状态,使用 Node.list/0 Node.list/1 函数获取连接节点信息。
  • 处理节点断开连接的情况,使用 Node.monitor/1 函数监控节点状态。
4.2 进程通信
  • 使用 Node.spawn/2 Node.spawn/4 函数在远程节点上生成进程。
  • 利用位置透明性向进程发送消息,确保消息传递的一致性。
  • 避免传递 lambda 函数到远程节点,使用 MFA 形式调用远程函数。
4.3 进程发现
  • 使用 :global 模块进行全局名称注册,确保每个资源在集群中只有一个处理进程。
  • 使用 :pg 模块创建进程组,处理需要多个进程协作的场景。
4.4 异常处理
  • 使用链接和监控机制处理进程崩溃和节点断开连接的情况。
  • 注册节点断开连接的通知,及时进行相应处理。

以下是最佳实践的表格总结:
| 类别 | 最佳实践 |
| ---- | ---- |
| 节点管理 | 合理启动节点,定期检查连接状态,处理节点断开 |
| 进程通信 | 使用合适的函数生成进程,避免传递 lambda 函数 |
| 进程发现 | 使用全局注册和进程组进行进程发现 |
| 异常处理 | 利用链接和监控机制,注册节点断开通知 |

通过遵循这些最佳实践,可以构建出更加稳定、可靠的分布式系统,应对各种复杂的业务场景和挑战。在实际开发中,还需要根据具体需求和场景进行灵活调整和优化。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值