分布式 OTP 应用程序

虽然 Erlang 给我们留下了很多工作要做,但它仍然提供了一些解决方案。其中之一是**分布式 OTP 应用程序**的概念。分布式 OTP 应用程序(在 OTP 上下文中简称为**分布式应用程序**)允许定义**接管**和**故障转移**机制。我们将了解这意味着什么,它是如何工作的,并编写一个简单的演示应用程序。

为 OTP 添加更多内容

如果您还记得关于 OTP 应用程序的章节,我们简要地看到了应用程序的结构,它使用中央应用程序控制器,将请求分派给应用程序主进程,每个主进程监控应用程序的顶级监督者。

The Application controller stands over three application masters (in this graphic, in real life it has many more), which each stand on top of a supervisor process

在标准 OTP 应用程序中,应用程序可以加载、启动、停止或卸载。在分布式应用程序中,我们改变了工作方式;现在应用程序控制器与其工作分享给**分布式应用程序控制器**,它是另一个与它并行的进程(通常称为**dist_ac**)。

The application controller supervises two master which in turn supervise two supervisors. In parallel to the application cantroller is the dist_ac, also supervising its own application

根据应用程序文件,应用程序的所有权将发生变化。dist_ac 将在所有节点上启动,所有 dist_ac 将相互通信。它们之间谈论的内容并不重要,只有一件事除外。如前所述,四种应用程序状态是加载、启动、停止和卸载;分布式应用程序将启动应用程序的概念拆分为**启动**和**运行**。

两者之间的区别在于,您可以定义一个应用程序在一个集群内是全局的。这种类型的应用程序一次只能在一个节点上运行,而常规 OTP 应用程序并不关心其他节点上发生的事情。

因此,分布式应用程序将在集群的所有节点上启动,但只在一个节点上运行。

这对未运行应用程序的节点意味着什么?它们唯一要做的事情就是等待运行应用程序的节点死亡。这意味着,当运行应用程序的节点死亡时,另一个节点会开始运行它。这可以通过在不同子系统之间移动来避免服务中断。

让我们更详细地看看这意味着什么。

接管和故障转移

分布式应用程序处理了两个重要的概念。第一个是**故障转移**的概念。故障转移是指将应用程序重新启动到与停止运行的位置不同的位置的想法。

当您拥有冗余硬件时,这是一种特别有效的策略。您在“主”计算机或服务器上运行某些东西,如果它失败了,您将其移动到备用计算机。在更大规模的部署中,您可能会有 50 台服务器运行您的软件(所有服务器的负载都在 60-70% 左右),并希望运行的服务器能够吸收故障服务器的负载。故障转移的概念在前一种情况下非常重要,而在后一种情况下则不太重要。

分布式 OTP 应用程序的第二个重要概念是**接管**。接管是指一个死节点从死亡中恢复,被认为比备份节点更重要(也许它拥有更好的硬件),并决定再次运行应用程序。这通常通过优雅地终止备份应用程序并启动主应用程序来完成。

**注意:**从分布式编程谬误的角度来看,分布式 OTP 应用程序假设,当发生故障时,很可能是由于硬件故障,而不是网络分割。如果您认为网络分割比硬件故障更可能发生,那么您必须意识到应用程序可能同时以备份和主程序的形式运行,并且当网络问题解决时,可能会发生奇怪的事情。在这种情况下,分布式 OTP 应用程序可能不适合您。

让我们想象一下,我们有一个包含三个节点的系统,其中只有第一个节点运行给定应用程序。

Three nodes, A, B and C, and the application runs under A

节点**B**和**C**被声明为备份节点,以防**A**死亡,我们假装它刚刚发生了。

Two nodes, B and C, and no application

短暂的一段时间内,没有任何东西在运行。过了一会儿,**B**意识到了这一点,并决定接管应用程序。

Two nodes, B and C, and the application runs under B

这就是故障转移。然后,如果**B**死亡,应用程序将在**C**上重新启动。

Node C with an application running under it

另一个故障转移,一切都好。现在,假设**A**恢复运行。**C**现在正在愉快地运行应用程序,但**A**是我们定义为主节点的节点。这时就会发生接管:应用程序在**C**上被自愿关闭,并在**A**上重新启动。

Two nodes, A andbC, and the application runs under A

所有其他故障也是如此。

您会看到一个明显的问题是,像这样不断终止应用程序很可能会丢失重要的状态。不幸的是,那是您的问题。您必须考虑在出现故障之前将所有这些重要状态放在哪里以及如何共享它们。分布式应用程序的 OTP 机制对此没有特殊情况。

无论如何,让我们继续看看如何在实践中让它运行。

神奇的八球

神奇的八球是一种简单的玩具,您可以随机摇动它以获得神圣和有用的答案。您问一些问题,比如“我喜欢的球队今晚能赢吗?”球摇晃后会回答类似“毫无疑问”之类的话;然后您就可以放心地用房屋的价值押注最终得分。其他问题,比如“我应该为将来进行谨慎的投资吗?”,可能会返回“不太可能”或“我不确定”。神奇的八球在过去几十年中一直是西方世界政治决策中的重要工具,我们用它作为容错的示例是再正常不过的事情了。

我们的实现不会使用现实生活中用于自动查找服务器的切换机制,比如 DNS 轮询或负载均衡器。我们将更多地停留在纯 Erlang 中,并有三个节点(下面分别表示为**A**、**B**和**C**)作为分布式 OTP 应用程序的一部分。节点**A**将代表运行神奇八球服务器的主节点,节点**B**和**C**将是备份节点。

three nodes, A, B, and C, connected together

每当**A**失败时,八球应用程序应该在**B**或**C**上重新启动,并且两个节点仍然能够透明地使用它。

在为分布式 OTP 应用程序设置环境之前,我们首先要构建应用程序本身。它的设计将是令人难以置信的简单。

A supervisor supervising a server

总共我们将有 3 个模块:监督者、服务器和应用程序回调模块来启动一切。监督者将非常简单。我们将把它命名为**m8ball_sup**(即*Magic 8 Ball Supervisor*),并将其放在标准 OTP 应用程序的**src/** 目录中。

-module(m8ball_sup).
-behaviour(supervisor).
-export([start_link/0, init/1]).

start_link() ->
    supervisor:start_link({global,?MODULE}, ?MODULE, []).

init([]) ->
    {ok, {{one_for_one, 1, 10},
          [{m8ball,
            {m8ball_server, start_link, []},
            permanent,
            5000,
            worker,
            [m8ball_server]
          }]}}.

这是一个监督者,它将启动单个服务器(**m8ball_server**),一个永久的工作进程。它每 10 秒允许一次失败。

神奇的八球服务器将稍微复杂一些。我们将它构建为一个带有以下接口的 gen_server。

-module(m8ball_server).
-behaviour(gen_server).
-export([start_link/0, stop/0, ask/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2,
         code_change/3, terminate/2]).

%%%%%%%%%%%%%%%%%
%%% INTERFACE %%%
%%%%%%%%%%%%%%%%%
start_link() ->
    gen_server:start_link({global, ?MODULE}, ?MODULE, [], []).

stop() ->
    gen_server:call({global, ?MODULE}, stop).

ask(_Question) -> % the question doesn't matter!
    gen_server:call({global, ?MODULE}, question).

请注意,服务器是如何使用**{global, ?MODULE}**作为名称启动的,以及它如何使用相同的元组来进行每次调用。这就是我们在上一章中看到的**global** 模块,应用于行为。

接下来是回调,也就是真正的实现。在我展示如何构建它之前,我会提到我希望它如何工作。神奇的八球应该从某个配置文件中随机选择众多可能的回复之一。我想要一个配置文件,因为这样可以轻松地添加或删除答案,如我们所愿。

首先,如果我们想随机执行操作,我们需要在我们的 init 函数中设置一些随机性。

%%%%%%%%%%%%%%%%%
%%% CALLBACKS %%%
%%%%%%%%%%%%%%%%%
init([]) ->
    <<A:32, B:32, C:32>> = crypto:rand_bytes(12),
    random:seed(A,B,C),
    {ok, []}.

我们在**套接字章节**中已经见过这种模式:我们使用 12 个随机字节来设置初始随机种子,该种子将与**random:uniform/1** 函数一起使用。

下一步是从配置文件中读取答案并选择一个。如果您还记得**OTP 应用程序章节**,设置配置的最简单方法是使用**app** 文件来执行它(在**env** 元组中)。以下是我们将如何执行此操作。

handle_call(question, _From, State) ->
    {ok, Answers} = application:get_env(m8ball, answers),
    Answer = element(random:uniform(tuple_size(Answers)), Answers),
    {reply, Answer, State};
handle_call(stop, _From, State) ->
    {stop, normal, ok, State};
handle_call(_Call, _From, State) ->
    {noreply, State}.

第一个子句显示了我们想要做的事情。我期望在**env** 元组的**answers** 值中有一个包含所有可能答案的元组。为什么要使用元组?仅仅因为访问元组的元素是一个恒定时间操作,而从列表中获取它则是线性的(因此在较大的列表上花费的时间更长)。然后我们将答案发送回去。

**注意:**服务器在每次询问时都会使用**application:get_env(m8ball, answers)** 读取答案。如果您要使用类似**application:set_env(m8ball, answers, {"yes","no","maybe"})** 的调用设置新的答案,那么这三个答案将立即成为未来调用中可能的选项。

在启动时读取它们一次从长远来看可能更有效,但这意味着更新可能答案的唯一方法是重新启动应用程序。

您可能已经注意到,我们实际上并不关心所提出的问题——它甚至没有传递给服务器。因为我们返回的是随机答案,所以从一个进程复制到另一个进程完全没有用。我们只是通过完全忽略它来节省工作。我们仍然将答案保留在那里,因为它会让最终的接口感觉更自然。如果我们想这样做,我们也可以欺骗我们的神奇八球,让它对同一个问题始终返回相同的答案,但现在我们不会为此烦恼。

模块的其余部分与通常执行无事可做的通用 gen_server 几乎相同。

handle_cast(_Cast, State) ->
    {noreply, State}.

handle_info(_Info, State) ->
    {noreply, State}.

code_change(_OldVsn, State, _Extra) ->
    {ok, State}.

terminate(_Reason, _State) ->
    ok.

现在我们可以进行更严肃的事情了,即应用程序文件和回调模块。我们将从后者开始,**m8ball.erl**。

-module(m8ball).
-behaviour(application).
-export([start/2, stop/1]).
-export([ask/1]).

%%%%%%%%%%%%%%%%%
%%% CALLBACKS %%%
%%%%%%%%%%%%%%%%%

start(normal, []) ->
    m8ball_sup:start_link().

stop(_State) ->
    ok.

%%%%%%%%%%%%%%%%%
%%% INTERFACE %%%
%%%%%%%%%%%%%%%%%
ask(Question) ->
    m8ball_server:ask(Question).

这很简单。以下是关联的**.app** 文件,**m8ball.app**。

{application, m8ball,
 [{vsn, "1.0.0"},
  {description, "Answer vital questions"},
  {modules, [m8ball, m8ball_sup, m8ball_server]},
  {applications, [stdlib, kernel, crypto]},
  {registered, [m8ball, m8ball_sup, m8ball_server]},
  {mod, {m8ball, []}},
  {env, [
    {answers, {<<"Yes">>, <<"No">>, <<"Doubtful">>,
               <<"I don't like your tone">>, <<"Of course">>,
               <<"Of course not">>, <<"*backs away slowly and runs away*">>}}
  ]}
 ]}.

我们依赖于**stdlib** 和**kernel**,就像所有 OTP 应用程序一样,还依赖于**crypto** 为服务器中的随机种子提供支持。请注意答案都位于一个元组中:这与服务器中所需的元组相匹配。在本例中,答案都是二进制的,但字符串格式并不重要——列表也可以工作。

使应用程序分布式

到目前为止,一切就像一个完美的普通 OTP 应用程序。我们需要在我们的文件中添加很少的更改才能使其适用于分布式 OTP 应用程序;实际上,只需要添加一个函数子句,回到**m8ball.erl** 模块中。

%%%%%%%%%%%%%%%%%
%%% CALLBACKS %%%
%%%%%%%%%%%%%%%%%

start(normal, []) ->
    m8ball_sup:start_link();
start({takeover, _OtherNode}, []) ->
    m8ball_sup:start_link().

当一个更重要的节点接管备份节点时,{takeover, OtherNode} 参数将传递给 start/2。在神奇8球应用程序的情况下,它实际上并没有改变任何东西,我们可以照常启动 supervisor。

重新编译您的代码,它已经准备就绪了。但是等等,我们如何定义哪些节点是主节点,哪些节点是备份节点呢?答案在配置文件中。因为我们想要一个包含三个节点(abc)的系统,所以我们需要三个配置文件(我将它们命名为 a.configb.configc.config,然后将它们全部放在应用程序目录内的 config/ 中)。

[{kernel,
  [{distributed, [{m8ball,
                   5000,
                  [a@ferdmbp, {b@ferdmbp, c@ferdmbp}]}]},
   {sync_nodes_mandatory, [b@ferdmbp, c@ferdmbp]},
   {sync_nodes_timeout, 30000}
  ]}].
[{kernel,
  [{distributed, [{m8ball,
                   5000,
                  [a@ferdmbp, {b@ferdmbp, c@ferdmbp}]}]},
   {sync_nodes_mandatory, [a@ferdmbp, c@ferdmbp]},
   {sync_nodes_timeout, 30000}
  ]}].
[{kernel,
  [{distributed, [{m8ball,
                   5000,
                  [a@ferdmbp, {b@ferdmbp, c@ferdmbp}]}]},
   {sync_nodes_mandatory, [a@ferdmbp, b@ferdmbp]},
   {sync_nodes_timeout, 30000}
  ]}].

不要忘记将节点重命名以适合您自己的主机。否则,一般结构始终相同。

[{kernel,
  [{distributed, [{AppName,
                   TimeOutBeforeRestart,
                   NodeList}]},
   {sync_nodes_mandatory, NecessaryNodes},
   {sync_nodes_optional, OptionalNodes},
   {sync_nodes_timeout, MaxTime}
 ]}].

NodeList 值通常可以采用类似 [A, B, C, D] 的形式,其中 A 是主节点,B 是第一个备份,C 是下一个备份,等等。还有一种语法,给出一个类似 [A, {B, C}, D] 的列表,因此 A 仍然是主节点,BC 是相等的次要备份,然后是其他节点,等等。

A magic 8-ball showing 'I don't think so'

sync_nodes_mandatory 元组将与 sync_nodes_timeout 协同工作。当您使用为此设置的值启动分布式虚拟机时,它将一直处于锁定状态,直到所有必需节点也启动并锁定。然后它们会同步,一切开始运行。如果要启动所有节点花费的时间超过 MaxTime,则所有节点将在启动之前崩溃。

还有更多可用的选项,如果您想了解更多信息,我建议查看 内核应用程序文档

我们现在将尝试使用 m8ball 应用程序进行操作。如果您不确定 30 秒是否足以启动所有三个虚拟机,您可以根据需要增加 sync_nodes_timeout。然后,启动三个虚拟机。

$ erl -sname a -config config/a -pa ebin/
$ erl -sname b -config config/b -pa ebin/
$ erl -sname c -config config/c -pa ebin/

当您启动第三个虚拟机时,它们应该同时解锁。进入三个虚拟机中的每一个,依次启动 cryptom8ball,使用 application:start(AppName)

然后您应该能够从任何连接的节点调用神奇 8 球。

(a@ferdmbp)3> m8ball:ask("If I crash, will I have a second life?").
<<"I don't like your tone">>
(a@ferdmbp)4> m8ball:ask("If I crash, will I have a second life, please?").
<<"Of Course">>
(c@ferdmbp)3> m8ball:ask("Am I ever gonna be good at Erlang?").
<<"Doubtful">>

多么励志。要查看情况,请在所有节点上调用 application:which_applications()。只有节点 a 应该正在运行它。

(b@ferdmbp)3> application:which_applications().
[{crypto,"CRYPTO version 2","2.1"},
 {stdlib,"ERTS  CXC 138 10","1.18"},
 {kernel,"ERTS  CXC 138 10","2.15"}]
(a@ferdmbp)5> application:which_applications().
[{m8ball,"Answer vital questions","1.0.0"},
 {crypto,"CRYPTO version 2","2.1"},
 {stdlib,"ERTS  CXC 138 10","1.18"},
 {kernel,"ERTS  CXC 138 10","2.15"}]

在这种情况下,c 节点应该显示与 b 节点相同的内容。现在,如果您终止 a 节点(只需粗暴地关闭包含 Erlang shell 的窗口),应用程序显然将不再在那里运行。让我们看看它在哪里。

(c@ferdmbp)4> application:which_applications().
[{crypto,"CRYPTO version 2","2.1"},
 {stdlib,"ERTS  CXC 138 10","1.18"},
 {kernel,"ERTS  CXC 138 10","2.15"}]
(c@ferdmbp)5> m8ball:ask("where are you?!").
<<"I don't like your tone">>

这是预期的,因为 b 的优先级更高。在 5 秒后(我们将超时设置为 5000 毫秒),b 应该显示应用程序正在运行。

(b@ferdmbp)4> application:which_applications().
[{m8ball,"Answer vital questions","1.0.0"},
 {crypto,"CRYPTO version 2","2.1"},
 {stdlib,"ERTS  CXC 138 10","1.18"},
 {kernel,"ERTS  CXC 138 10","2.15"}]

它仍然运行良好。现在以您用来摆脱 a 的相同野蛮方式终止 bc 应该在 5 秒后运行应用程序。

(c@ferdmbp)6> application:which_applications().
[{m8ball,"Answer vital questions","1.0.0"},
 {crypto,"CRYPTO version 2","2.1"},
 {stdlib,"ERTS  CXC 138 10","1.18"},
 {kernel,"ERTS  CXC 138 10","2.15"}]

如果您使用之前相同的命令重新启动节点 a,它将挂起。配置文件指定我们需要 b 才能使 a 工作。如果您无法期望节点都以这种方式启动,则可能需要使 bc 可选,例如。因此,如果我们启动 ab,则应用程序应该自动恢复,对吧?

(a@ferdmbp)4> application:which_applications().
[{crypto,"CRYPTO version 2","2.1"},
 {stdlib,"ERTS  CXC 138 10","1.18"},
 {kernel,"ERTS  CXC 138 10","2.15"}]
(a@ferdmbp)5> m8ball:ask("is the app gonna move here?").
<<"Of course not">>

哦,太可惜了。问题在于,为了使机制正常工作,应用程序需要作为节点的引导过程的一部分启动。例如,您可以以这种方式启动 a,使一切正常工作。

erl -sname a -config config/a -pa ebin -eval 'application:start(crypto), application:start(m8ball)'
...
(a@ferdmbp)1> application:which_applications().
[{m8ball,"Answer vital questions","1.0.0"},
 {crypto,"CRYPTO version 2","2.1"},
 {stdlib,"ERTS  CXC 138 10","1.18"},
 {kernel,"ERTS  CXC 138 10","2.15"}]

c 的角度来看。

=INFO REPORT==== 8-Jan-2012::19:24:27 ===
    application: m8ball
    exited: stopped
    type: temporary

这是因为 -eval 选项作为 VM 引导过程的一部分进行评估。显然,更干净的方法是使用发布来正确设置,但如果示例必须组合我们之前看到的所有内容,则会非常麻烦。

请记住,一般来说,分布式 OTP 应用程序在使用确保系统所有相关部分都到位的发行版时效果最好。

正如我之前提到的,在许多应用程序(包括神奇 8 球)的情况下,有时只需同时运行多个实例并同步数据,而不是强制应用程序仅在一个地方运行,这更简单。一旦选择了这种设计,扩展起来也更简单。如果您需要某些故障转移/接管机制,分布式 OTP 应用程序可能正是您需要的。