ElixirSwarm浅析
Elixir是一门很有意思的语言,它的社区把很多的关注重点放在了解决集群化带来的挑战上。libcluster和swarm就是为此诞生的两个库。但是由于这门语言还很年轻,很多文档还很不成熟(Swarm的文档就是这样),所以有很多时候你要自己亲手做实验才能明白其中的门道。今天我们就来聊聊Swarm这个库。
前言
也许你早就听说过CAP定律。简单说,就是“一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance)三者在一个分布式数据容器里最多只能要两个”。
Elixir Swarm给了我们选择CP(一致性+分区容错性)或AP(可用性+分区容错性)的自由。
Elixir Swarm基础
我们就做一个分布式计数器,它只支持两个操作:获取当前值(value)和+1(increment)。首先创建一个项目:
$ mix new --sup counter
然后创建一个worker模块。我这里用GenServer而不用Agent是因为接下来会往里加Elixir Swarm生命周期的钩子,如果用Agent不方便。
defmodule Counter.Worker do use GenServer
def start_link(_) do
GenServer.start_link(__MODULE__, 0)
end
def value do
GenServer.call({:via, :swarm, __MODULE__}, :value)
end
def increment do
GenServer.cast({:via, :swarm, __MODULE__}, :increment)
end
@impl true
def init(initial_state) do
{:ok, initial_state}
end
@impl true
def handle_call(:value, _from, state) do
{:reply, state, state}
end
@impl true
def handle_cast(:increment, state) do
{:noreply, state + 1}
end
end
相信写个计数器对大家都不是难事。这里要注意的是,GenServer.cast
和GenServer.call
的第一个参数是{:via, :swarm, 进程在Elixir Swarm里注册的名称}
。也许你会问“为什么不用GenServer.start_link
里指定的名称”,那是因为如果用那个名称,则Erlang运行时会在当前节点找进程,而在Elixir Swarm里,这个进程很可能不在当前节点!我们要委托Elixir Swarm帮我们找进程,所以当然要用它在Elixir Swarm里注册的名称啦。
由于Elixir Swarm管理的进程并不会自动被加进supervision tree,所以我们要创建一个supervisor并显式地把它加进supervision tree。另外,Elixir Swarm需要你手动注册进程创建手段,然后它接管进程的所有生命周期,所以我们的supervisor不需要预先有子进程,也就是说,我们需要一个DynamicSupervisor
。
defmodule Counter.Supervisor do use DynamicSupervisor
def start_link(_) do
DynamicSupervisor.start_link(__MODULE__, nil, name: __MODULE__)
end
def init(_) do
DynamicSupervisor.init(strategy: :one_for_one)
end
@doc """
这个函数会作为`Counter.Worker`进程的创建函数被注册进Elixir Swarm。
此函数必须在成功创建进程时返回`{:ok, pid}`。
返回的pid会被Elixir Swarm管理生命周期。
"""
def register(child_spec) do
DynamicSupervisor.start_child(__MODULE__, child_spec)
end
end
这只是一个最最普通的dynamic supervisor。这个模块会在每个节点创建一个supervisor进程。
接下来处理注册。在lib/counter/application.ex里,
defmodule Counter.Application do def start(_type, _args) do
children = [
Counter.Supervisor
]
{:ok, sup} = Supervisor.start_link(children, strategy: :one_for_one, name: Couter.TopSupervisor)
Swarm.register_name(Counter.Worker, Counter.Supervisor, :register, [Counter.Worker])
{:ok, sup}
end
end
Swarm.register_name
的参数:
- 进程注册的名称
- 负责创建进程的模块
- 上述模块中负责创建进程的函数
- 调用上述函数时传入的参数列表
- 超时(单位:毫秒,可选,默认
:infinity
)
注意,Swarm.register_name
必须在Counter.Supervisor
正常启动后执行(好象是废话)。另外,我们不应该模式匹配Swarm.register_name
的返回值,理由是在一个分布式系统里,各个节点的启动会有时间差。如果一个进程在某个节点注册成功,在其他节点就会注册失败,因为同名进程已经注册过了,但这并不表示其他节点不应该正常启动。
由此也可以看出,正常情况下,在一个集群里,不会有两个同名进程。这里所说的“同名”的“名”指的是在Elixir Swarm里注册的名称,即Swarm.register_name
的第一个参数,而不是GenServer.start_link
里的:name
选项。注意我说的“正常情况”,因为在“不正常情况”下在集群里会有多个同名进程哦。
网络断裂(netsplit)和进程移交(handoff of processes)
注意:Elixir Swarm官方README的示例是错误的!!
网络断裂又称网络分区(network partition),是指集群中的部分节点和其他节点失联但仍然持续运行的异常情况。
Elixir Swarm会保证在每个注册的名称在每个分区都有一个进程,所以在这种情况下,会出现上述的多个同名进程的情况。
当出现网络断裂时,Elixir Swarm会监测到“网络拓扑变更”,具体机制和配置有关(后述),并作出相应的处理。
- 在注册进程不存在的分区,根据注册名称的哈希值计算进程应在的节点,并根据注册时的信息在该节点创建新进程。
- 在注册进程已存在的分区,重新计算进程应在的节点,如果该节点不是进程所在的当前节点,则在目标节点上创建新进程,并让老进程把当前状态移交给新进程,然后杀死老进程。
从这里我们可以看出两点:
- Elixir Swarm并不提供进程的副本,也不提供从副本恢复数据的机制!
- 各个网络分区中的同名进程的状态是不一样的!
如果你不能接受这个现实,则你应该考虑使用其他库,例如Raft算法的Erlang实现:rabbitmq/ra。
处理进程移交的代码如下:
defmodule Counter.Worker do [...]
@doc """
这个回调会在老进程死之前被Elixir Swarm调用,
用来获取老进程的需要移交的状态。
这里我把整个状态都移交出去了。
"""
def handle_call({:swarm, :begin_handoff}, _from, local_state) do
{:reply, {:resume, local_state}, local_state}
end
@doc """
这个回调会在新进程里被调用,
用来接收并合并老进程移交过来的状态。
这里我只是简单地抛弃本地状态,完全采用老进程状态。
"""
def handle_cast({:swarm, :end_handoff, remote_state}, _local_state) do
{:noreply, remote_state}
end
@doc """
这个回调会在老进程死之前被调用。
你可以在这里做一些善后工作(比如清除本地硬盘上的状态文件之类的)。
我这里只是简单地让进程优雅地死去。
"""
def handle_info({:swarm, :die}, state) do
{:stop, :shutdown, state}
end
end
当网络断裂愈合后,Elixir Swarm会尝试解决冲突并在多个同名进程中保留一个,可能会发生进程移交(未考证)。处理冲突的代码如下:
defmodule Counter.Worker do [...]
@doc """
这个回调会在网络断裂愈合时被调用。
我这里只是简单地取两个计数中较大的作为冲突解决后的状态。
"""
def handle_cast({:swarm, :resolve_conflict, remote_state}, local_state) do
{:noreply, if(remote_state > local_state, do: remote_state, else: local_state)}
end
end
不要吐槽我的实现。要完美处理冲突从来不是件容易的事,你需从数据结构开始重新考虑问题。我这里不展开了。
如果跑进程的那个节点宕机了,会发生什么?
很简单,在某个活着的节点上重新启动进程,仅此而已。
那之前的状态呢?
丢了呗。如果你不能接受这个现实,请考虑持久化或者PAXOS/RAFT。这不是Elixir Swarm能解决的问题。
关于Elixir Swarm的分布策略
Elixir Swarm提供了两种分布策略:Ring
和StaticQuorumRing
。这两者的区别是什么?
Ring
当客户端进程访问注册的服务器进程时,客户端进程访问到的是自己所在的网络分区中的服务器进程,因此它获取到的数据可能是不对的,但能获取到。如果客户端进程想要修改服务器进程的状态,它改到的也是自己所在的分区里的服务器进程状态。当网络断裂愈合时,很可能因此发生冲突。
如果你选择Ring
,则你选择了AP系统。
StaticQuorumRing
当客户端进程访问注册的服务器进程时,如果客户端所在分区的节点数小于所配置的:static_quorum_size
,则访问会失败。推荐把:static_quorum_size
的值设置成集群节点数量 / 2 + 1
。
如果你选择StaticQuorumRing
并按上述配置:static_quorum_size
,则你选择了CP系统。
以上是 ElixirSwarm浅析 的全部内容, 来源链接: utcz.com/z/514665.html