构建分布式系统:从基础到容错集群
1. 分布式原语
在分布式系统中,我们可以通过一些操作来验证和利用其特性。首先,我们启动两个节点并进行连接,同时从节点1的shell设置对节点2的shell的监控:
$ iex --sname node1@localhost
$ iex --sname node2@localhost
在节点2的shell中执行以下操作:
iex(node2@localhost)1> Node.connect(:node1@localhost)
iex(node2@localhost)2> :global.register_name({:todo_list, "bob"}, self())
在节点1的shell中执行监控操作:
iex(node1@localhost)1> Process.monitor(
:global.whereis_name({:todo_list, "bob"})
)
现在,我们可以终止节点2并刷新节点1中的消息:
iex(node1@localhost)2> flush()
{:DOWN, #Reference<0.0.0.99>, :process, #PID<7954.90.0>, :noconnection}
从输出结果可以看到,我们得到了一个通知,表明被监控的进程不再运行。这使得我们能够检测分布式系统中的错误并从中恢复。实际上,错误检测机制与并发系统中的机制相同,因为并发本身也是一种分布式原语。
2. 其他分布式服务
Erlang标准库提供了一些其他有趣的分布式服务,下面为你简要介绍:
-
基本模块
:许多基本原语可以在
Node
模块(https://hexdocs.pm/elixir/Node.html)中找到。此外,
:net_kernel
(https://www.erlang.org/doc/man/net_kernel.html)和
:net_adm
(https://www.erlang.org/doc/man/net_adm.html)模块也提供了一些有用的服务。
-
远程过程调用(RPC)
:有时我们需要在其他节点上调用函数。
Node.spawn
可以实现这一功能,但它是一种“发射后不管”的操作,我们无法得知其执行结果。更多时候,我们希望获取远程函数调用的结果,或者在多个节点上调用函数并收集所有结果。这时可以使用
:rpc
Erlang模块(https://erlang.org/doc/man/rpc.html)。例如,要在另一个节点上调用函数并获取其结果,可以使用
:rpc.call/4
:
iex(node1@localhost)1> :rpc.call(:node2@localhost, Kernel, :abs, [-1])
1
-
集群范围的锁
:
:global模块实现了集群范围的锁,允许我们获取任意命名的锁。一旦获取了特定的锁,在释放之前,集群中的其他进程无法获取该锁。以下是一个示例:
# 在节点1上尝试获取锁
iex(node1@localhost)1> :global.set_lock({:some_resource, self()})
true
# 在节点2上尝试获取锁
iex(node2@localhost)1> :global.set_lock({:some_resource, self()})
节点2的shell进程将无限期等待,直到节点1释放锁。当节点1释放锁后,节点2将获取该锁:
iex(node1@localhost)2> :global.del_lock({:some_resource, self()})
3. 消息传递是核心分布式原语
许多服务(如
:rpc
)都是用纯Erlang实现的。与
:global
和
:pg
一样,
:rpc
依赖于透明的消息传递以及向远程节点上本地注册的进程发送消息的能力。例如,
:rpc
依赖于本地注册的
:rex
进程(在Erlang的
:kernel
应用启动时启动)。在其他节点上进行RPC调用相当于向目标节点上的
:rex
进程发送包含MFA的消息,从这些服务器调用
apply/3
,并返回响应。
4. 构建容错集群
在构建分布式系统时,我们通常希望避免使用锁,因为它会导致与传统同步方法相同的问题,过度依赖锁会增加死锁、活锁或饥饿的可能性。但在某些情况下,合理使用锁可以提高性能。例如,当需要确保大量数据的处理在整个集群中串行化时,通常的做法是将数据传递给一个作为同步点的进程,但传递大量数据可能会引入性能损失。此时可以使用锁来同步不同节点上的多个进程,然后在调用者上下文中处理数据:
def process(large_data) do
:global.trans(
{:some_resource, self},
fn ->
do_something_with(large_data)
end
)
end
调用
:global.trans/2
可以确保集群范围的隔离,在任何时候,集群中最多只有一个进程可以在
:some_resource
上运行
do_something_with/1
。由于
do_something_with/1
在调用者进程中运行,避免了向另一个同步进程发送大量消息,从而节省了带宽。
5. 集群设计
我们的目标是构建一个由多个节点组成的集群,这些节点运行相同的代码,提供相同的服务(一个用于管理多个待办事项列表的Web界面)。具体目标如下:
-
数据一致性
:一个节点上对单个待办事项列表的修改应在所有其他节点上可见,客户端无需关心访问的是哪个节点。
-
容错性
:单个节点的崩溃不应影响集群的正常运行,服务应持续提供,且崩溃节点的数据不应丢失。
以下是一个简单的流程图,展示了待办事项缓存的工作原理:
graph LR
classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px
A[客户端]:::process -->|请求Bob的列表| B[待办事项缓存]:::process
B -->|谁负责Bob的列表?| C[待办事项服务器]:::process
6. 分布式待办事项缓存
待办事项缓存是系统的核心,它维护数据的一致性。当我们想要修改待办事项列表时,会向待办事项缓存请求相应的待办事项服务器进程,该服务器进程作为单个待办事项列表的同步点,确保一致性并防止竞态条件。
7. 发现待办事项服务器
为了实现集群范围的发现,我们可以使用
:global
模块进行全局注册。具体步骤如下:
1.
修改
Todo.Server
模块
:将其从使用
Registry
(适用于单节点注册)改为使用
:global
模块进行分布式进程注册和发现。
# 原代码
defmodule Todo.Server do
def start_link(name) do
GenServer.start_link(Todo.Server, name, name: via_tuple(name))
end
defp via_tuple(name) do
Todo.ProcessRegistry.via_tuple({__MODULE__, name})
end
...
end
# 修改后的代码
defmodule Todo.Server do
...
def start_link(name) do
GenServer.start_link(Todo.Server, name, name: global_name(name))
end
defp global_name(name) do
{:global, {__MODULE__, name}}
end
...
end
-
修改
Todo.Cache模块 :使其与新的注册方式兼容。
不同注册设施的区别如下表所示:
| 注册设施 | 描述 |
| ---- | ---- |
| 本地注册 | 允许使用简单原子作为节点上单个进程的别名 |
| Registry | 扩展了本地注册,允许使用任何术语作为别名 |
| :global | 允许注册集群范围的别名 |
| :pg | 适用于在集群范围的别名(进程组)后注册多个进程,通常用于分布式发布 - 订阅场景 |
8. 性能优化
当前的实现可能存在性能问题,每次查找子进程时都会尝试进行
:global
注册,即使服务器进程已经在运行。为了改进这一点,我们可以先进行显式查找,只有在查找结果为空时才尝试启动服务器。具体操作如下:
1.
扩展
Todo.Server
模块
:添加
whereis/1
函数,用于返回已注册进程的PID,如果没有进程以给定名称注册,则返回
nil
。
defmodule Todo.Server do
...
def whereis(name) do
case :global.whereis_name({__MODULE__, name}) do
:undefined -> nil
pid -> pid
end
end
...
end
-
修改
Todo.Cache模块 :
defmodule Todo.Cache do
...
def server_process(todo_list_name) do
existing_process(todo_list_name) || new_process(todo_list_name)
end
defp existing_process(todo_list_name) do
Todo.Server.whereis(todo_list_name)
end
defp new_process(todo_list_name) do
case DynamicSupervisor.start_child(
__MODULE__,
{Todo.Server, todo_list_name}
) do
{:ok, pid} -> pid
{:error, {:already_started, pid}} -> pid
end
end
end
9. 替代发现方法
全局注册存在一些局限性,它会产生大量通信且是串行的,对于不同待办事项列表的数量或集群中的节点数量,扩展性较差。我们可以通过引入一个规则,将相同的待办事项列表名称始终映射到网络中的同一个节点,从而减少网络通信:
def node_for_list(todo_list_name) do
all_sorted_nodes = Enum.sort(Node.list([:this, :visible]))
node_index = :erlang.phash2(
todo_list_name,
length(all_sorted_nodes)
)
Enum.at(all_sorted_nodes, node_index)
end
通过以上步骤,我们可以构建一个更加高效、容错的分布式待办事项系统。在实际应用中,还需要考虑网络分区等复杂情况,以确保系统的稳定性和可靠性。
构建分布式系统:从基础到容错集群
10. 网络分区问题
在分布式系统中,网络分区是一个较为棘手的问题。网络分区指的是两个节点之间的通信通道中断,导致节点断开连接。这种情况下,可能会出现“裂脑”现象,即集群分裂成两个或多个相互断开的小集群,而这些小集群都能正常工作并提供服务。这会引发一系列问题,因为多个孤立的系统各自接收用户输入,最终可能导致数据冲突且无法调和。在之前的讨论中,我们暂时忽略了这个问题,但在实际构建分布式系统时,必须对其后果进行深入考虑。
11. 构建分布式系统的总结
在构建分布式系统时,我们运用了多种技术和方法,下面对这些内容进行总结:
| 技术点 | 描述 | 操作步骤 |
| ---- | ---- | ---- |
| 分布式原语 | 通过启动节点、连接节点和设置监控来检测分布式系统中的错误并恢复 | 1. 启动两个节点:
$ iex --sname node1@localhost
和
$ iex --sname node2@localhost
;2. 在节点2连接节点1:
iex(node2@localhost)1> Node.connect(:node1@localhost)
;3. 在节点2进行全局注册:
iex(node2@localhost)2> :global.register_name({:todo_list, "bob"}, self())
;4. 在节点1设置监控:
iex(node1@localhost)1> Process.monitor(:global.whereis_name({:todo_list, "bob"}))
;5. 终止节点2并刷新节点1消息:
iex(node1@localhost)2> flush()
|
| 其他分布式服务 | 包括基本模块、远程过程调用(RPC)和集群范围的锁 | 1. 基本模块:使用
Node
、
:net_kernel
和
:net_adm
模块;2. RPC:使用
:rpc.call/4
进行远程函数调用;3. 集群范围的锁:使用
:global.set_lock/1
获取锁,
:global.del_lock/1
释放锁 |
| 消息传递 | 是核心分布式原语,许多服务依赖于它 | 无 |
| 容错集群 | 通过合理使用锁来提高性能,确保数据处理串行化 | 调用
:global.trans/2
确保集群范围的隔离 |
| 集群设计 | 构建由多个节点组成的集群,实现数据一致性和容错性 | 无 |
| 分布式待办事项缓存 | 待办事项缓存是系统核心,维护数据一致性 | 无 |
| 发现待办事项服务器 | 使用
:global
模块进行全局注册 | 1. 修改
Todo.Server
模块;2. 修改
Todo.Cache
模块 |
| 性能优化 | 先进行显式查找,再启动服务器 | 1. 扩展
Todo.Server
模块添加
whereis/1
函数;2. 修改
Todo.Cache
模块 |
| 替代发现方法 | 引入规则将待办事项列表名称映射到固定节点 | 实现
node_for_list/1
函数 |
以下是一个流程图,展示了构建分布式待办事项系统的整体流程:
graph LR
classDef process fill:#E5F6FF,stroke:#73A6FF,stroke-width:2px
A[启动节点]:::process --> B[连接节点]:::process
B --> C[全局注册]:::process
C --> D[设置监控]:::process
D --> E[检测错误并恢复]:::process
B --> F[远程过程调用]:::process
B --> G[集群范围的锁]:::process
B --> H[消息传递]:::process
H --> I[构建容错集群]:::process
I --> J[集群设计]:::process
J --> K[分布式待办事项缓存]:::process
K --> L[发现待办事项服务器]:::process
L --> M[性能优化]:::process
L --> N[替代发现方法]:::process
12. 未来展望
虽然我们已经构建了一个基本的分布式待办事项系统,但在实际应用中,还有许多方面需要进一步完善。例如,针对网络分区问题,需要设计相应的解决方案,如使用仲裁机制、数据复制策略等,以确保系统在网络分区情况下仍能保持数据的一致性和可用性。此外,随着系统规模的扩大,还需要考虑性能优化、负载均衡等问题,以提高系统的整体性能和稳定性。
总之,构建分布式系统是一个复杂而富有挑战性的过程,需要我们不断学习和实践,运用各种技术和方法来解决遇到的问题,从而构建出高效、可靠的分布式系统。
超级会员免费看

被折叠的 条评论
为什么被折叠?



