Elixir实战:9 隔离错误影响 (1) 监督树
myzbx 2025-01-21 20:01 25 浏览
本章涵盖
- 理解监督树
- 动态启动工人
- “让它崩溃”
在第 8 章中,您了解了基于监视器概念的并发系统中错误处理的基本理论。这个想法是有一个进程,其唯一的工作是监督其他进程,并在它们崩溃时重新启动它们。这为您提供了一种处理系统中各种意外错误的方法。无论工作进程中发生了什么错误,您都可以确信监视器会检测到错误并重新启动工作进程。
除了提供基本的错误检测和恢复功能,监督者在隔离错误影响方面也发挥着重要作用。通过将个别工人直接置于监督者之下,可以将错误的影响限制在单个工人身上。这有一个重要的好处:它使您的系统对客户的可用性更高。无论您多么努力地避免,意外错误总会发生。隔离这些错误的影响可以让系统的其他部分在您从错误中恢复时继续运行并提供服务。
例如,本书示例中的待办事项系统中的数据库错误不应阻止缓存的工作。在您尝试从数据库部分出现的问题中恢复时,您应该继续提供现有的缓存数据,从而至少提供部分服务。更进一步,单个数据库工作者中的错误不应影响其他数据库操作。最终,如果您能够将错误的影响限制在系统的一小部分,您的系统就可以始终提供大部分服务。
隔离错误并最小化其负面影响是本章的主题。主要思想是让每个工作者在一个监督者的管理下运行,这使得可以单独重启每个工作者。您将在下一节中看到这一点,在该节中您将开始构建一个细粒度的监督树。
9.1 监督树
在本节中,我们将讨论如何减少错误对整个系统的影响。基本工具是流程、链接和监督者,整体方法相对简单。您必须始终考虑如果由于错误导致某个流程崩溃,系统的其余部分会发生什么,并且当错误的影响过大(当错误影响到太多流程时)时,您应该采取纠正措施。
9.1.1 分离松散依赖的部分
让我们看看错误是如何在待办事项系统中传播的。过程之间的链接如图 9.1 所示。
图 9.1 待办系统中的流程链接
如图所示,整个结构是相连的。无论哪个进程崩溃,退出信号都会传播到其链接的进程。最终,待办缓存进程也会崩溃,这将被 Todo.System 注意到, Todo.System 将会重新启动缓存进程。
这是一种正确的错误处理方法,因为你重启系统,不会留下任何悬挂的进程。但这种恢复方法过于粗糙。无论错误发生在哪里,整个系统都会重启。在数据库错误的情况下,整个待办事项缓存将终止。同样,一个待办事项服务器进程中的错误将导致所有数据库工作进程停止。
这种粗粒度的错误恢复是由于您从其他工作进程中启动工作进程。例如,数据库服务器是从待办缓存中启动的。为了减少错误影响,您需要从主管进程启动各个工作进程。这种方案使主管能够单独监督和重启每个工作进程。
让我们看看如何做到这一点。首先,您将移动数据库服务器,以便它直接从主管启动。这将使您能够将数据库错误与缓存中发生的错误隔离开来。
将数据库服务器置于监督之下是相当简单的。您必须从 Todo.Cache.init/1 中删除对 Todo.Database.start_link 的调用。然后,在调用 Supervisor.start_link/2 时,您必须添加另一个子规范。
清单 9.1 监督数据库服务器 (supervise_database/lib/todo/system.ex)
defmodule Todo.System do
def start_link do
Supervisor.start_link(
[
Todo.Database,
Todo.Cache
],
strategy: :one_for_one
)
end
end
包括规格列表中的数据库
还有一个小改动需要进行。就像你对 Todo.Cache 所做的那样,你需要调整 Todo.Database.start_link 以接受一个参数并忽略它。这将使得可以依赖于通过 use GenServer 获得的自动生成的 Todo.Database.child_spec/1 。
清单 9.2 调整 start_link (supervise_database/lib/todo/database.ex)
defmodule Todo.Database do
...
def start_link(_) do
...
end
...
end
这些更改确保缓存和数据库是分开的,如图 9.2 所示。在主管下运行数据库和缓存进程使得可以单独重启每个工作进程。数据库工作进程中的错误将导致整个数据库结构崩溃,但缓存将保持不受影响。这意味着所有从缓存中读取的客户端在数据库部分重启时仍然能够获取结果。
让我们验证一下。进入 supervise_database 文件夹,启动 shell ( iex -S mix )。然后,启动系统:
iex(1)> Todo.System.start_link()
Starting database server.
Starting database worker.
Starting database worker.
Starting database worker.
Starting to-do cache.
现在,杀死数据库服务器:
iex(2)> Process.exit(Process.whereis(Todo.Database), :kill)
Starting database server.
Starting database worker.
Starting database worker.
Starting database worker.
从输出中可以看出,只有与数据库相关的进程被重启。如果您终止待办事项缓存,情况也是如此。通过将这两个进程放在一个监控者下,您可以将错误的负面影响局限于局部。缓存错误不会对数据库部分产生影响,反之亦然。
回顾第 8 章关于进程隔离的讨论。由于每个部分在单独的进程中实现,数据库服务器和待办事项缓存是隔离的,互不影响。当然,这些进程通过监控器间接链接,但监控器正在捕获退出信号,从而防止进一步传播。这是 one_for_one 监控器的一个特性:它将错误的影响限制在单个工作进程上,并仅对该进程采取纠正措施(重启)。
在这个例子中,主管启动了两个子进程。重要的是要意识到子进程是同步启动的,按照指定的顺序。主管启动一个子进程,等待它完成,然后再开始下一个子进程。当工作者是一个 GenServer 时,只有在当前子进程的 init/1 回调函数完成后,下一个子进程才会启动。
您可能还记得第 7 章提到的 init/1 不应该长时间运行。这正是原因。如果 Todo.Database 需要,比如说,5 分钟才能启动,那么在这段时间内您将无法使用待办缓存。始终确保您的 init/1 函数运行迅速,并在需要更复杂的初始化时使用第 7 章中提到的技术(通过 handle_continue/2 回调进行后初始化继续)。
9.1.2 丰富的过程发现
尽管您现在有了一些基本的错误隔离,但仍有很多需要改进的地方。一个数据库工作者中的错误将导致整个数据库结构崩溃,并终止所有正在运行的数据库操作。理想情况下,您希望将数据库错误限制在单个工作者中。这意味着每个数据库工作者必须受到直接监督。
这种方法有一个问题。回想一下,在当前版本中,数据库服务器启动工作进程并将它们的 PID 保存在其内部列表中。但是,如果一个进程是从监视器启动的,你无法访问工作进程的 PID。这是监视器的一个特性。你不能长时间保留工作进程的 PID,因为该进程可能会被重启,而其后续进程将具有不同的 PID。
因此,您需要一种方法为受监督的进程赋予符号名称,并通过该名称访问每个进程。当一个进程重新启动时,后续进程将以相同的名称注册自己,这将使您能够在多次重启后仍然找到正确的进程。
您可以使用注册名称来实现此目的。问题在于名称只能是原子,而在这种情况下,您需要更复杂的东西,以便能够使用任意术语,例如 {:database_worker, 1} 、 {:database_worker, 2} 等等。您需要的是一个进程注册表,它维护一个键值映射,其中键是名称,值是 PID。进程注册表与标准本地注册的不同之处在于名称可以是任意复杂的。
每次创建一个进程时,它可以以一个名称注册到注册表中。如果一个进程被终止并重新启动,新进程将重新注册自己。拥有一个注册表将为您提供一个固定的点,您可以在此发现进程(它们的 PID)。这个想法在图 9.3 中进行了说明。
首先,工作进程在初始化期间注册自己。稍后,客户端进程将查询注册表以获取所需工作进程的 PID。然后,客户端可以向服务器进程发出请求。
Elixir 的标准库在 Registry 模块中包含了进程注册表的实现。该模块允许您将进程与一个或多个任意复杂的键关联,然后通过执行基于键的查找来找到该进程(其 PID)。
让我们看几个例子。进程注册表本身就是一个进程。您可以通过调用 Registry.start_link/1 来启动它:
iex(1)> Registry.start_link(name: :my_registry, keys: :unique)
单个参数是注册表选项的关键字列表。必需的选项是 :name 和 :keys 。
:name 选项是一个原子,它指定了注册表进程的名称。您将使用此名称与注册表进行交互。
:keys 选项可以是 :unique 或 :duplicate 。在唯一注册表中,名称是唯一的——任何键下只能注册一个进程。这在您想要为进程分配唯一角色时非常有用。例如,在您的系统中,只有一个进程可以与 {:database_worker, 1} 相关联。相比之下,在重复注册表中,多个进程可以具有相同的名称。重复注册表在单个发布者进程需要向动态数量的订阅者进程发送通知的场景中非常有用,这些订阅者进程会随着时间的推移而出现和消失。
一旦您启动了注册表,您可以在某个键下注册一个进程。让我们试试看。您将生成一个模拟的 {:database_worker, 1} 进程,该进程等待消息,然后将其打印到控制台:
iex(2)> spawn(fn ->
Registry.register(:my_registry, {:database_worker, 1}, nil)
receive do
msg -> IO.puts("got message #{inspect(msg)}")
end
end)
在注册表中注册该进程
关键部分发生在调用 Registry.register/3 时。在这里,您传递了注册表的名称 ( :my_registry )、生成的进程的期望名称 ( {:database_worker, 1} ) 和一个任意值。 Registry 将存储名称与提供的值和调用进程的 PID 之间的映射。
此时,注册的进程可以被其他进程发现。请注意,在前面的代码片段中,您没有获取数据库工作进程的 PID。这是因为您不需要它。您可以通过调用 Registry.lookup/2 在注册表中查找它:
iex(3)> [{db_worker_pid, _value}] =
Registry.lookup(
:my_registry,
{:database_worker, 1}
)
Registry.lookup/2 以注册表的名称和键(进程名称)为参数,返回一个 {pid, value} 元组的列表。当注册表是唯一的时,这个列表可以是空的(没有进程在给定的键下注册),或者可以有一个元素。对于重复的注册表,这个列表可以有任意数量的条目。每个元组中的 pid 元素是注册进程的 PID,而 value 是提供给 Registry.register/3 的值。
现在您已经发现了模拟数据库工作者,您可以给它发送一条消息:
iex(4)> send(db_worker_pid, :some_message)
got message :some_message
Registry 的一个非常有用的属性是它链接到所有注册的进程。这使得注册表能够注意到这些进程的终止,并从其内部结构中移除相应的条目。
您可以立即验证这一点。数据库工作者模拟是一次性过程。它接收了一条消息,打印出来,然后停止。尝试再次发现它:
iex(5)> Registry.lookup(:my_registry, {:database_worker, 1})
[]
如您所见,在给定的键下未找到任何条目,因为数据库工作进程已终止。
注意值得一提的是, Registry 是用纯 Elixir 实现的。你可以把 Registry 想象成类似于 GenServer 的东西,它在其状态中保存名称到 PID 的映射。实际上,实施更为复杂,并依赖于 ETS 表特性,你将在第 10 章中了解。ETS 表允许 Registry 非常高效和可扩展。查找和写入速度非常快,在许多情况下,它们不会相互阻塞,这意味着对同一注册表的多个操作可以并行运行。
Registry 具有更多的功能和属性,我们在这里不讨论。您可以查看官方文档 https://hexdocs.pm/elixir/Registry.xhtml 以获取更多详细信息。但有一个非常重要的 OTP 进程特性您需要了解:通过元组。
9.1.3 通过元组
一个通过元组的机制允许您使用任意第三方注册表来注册符合 OTP 的进程,例如 GenServer 和监督者。请记住,您可以在启动 GenServer 时提供 :name 选项:
GenServer.start_link(callback_module, some_arg, name: some_name)
到目前为止,您仅将原子作为 :name 选项传递,这导致启动的进程在本地注册。但是 :name 选项也可以以 {:via, some_module, some_arg} 的形式提供。这样的元组也称为通过元组。
如果您提供一个通过元组作为名称选项, GenServer 将从 some_module 调用一个明确定义的函数来注册该进程。同样,您可以将一个通过元组作为第一个参数传递给 GenServer.cast 和 GenServer.call , GenServer 将使用 some_module 发现 PID。从这个意义上说, some_module 像一个自定义的第三方进程注册表,而通过元组是将这样的注册表与 GenServer 及类似的 OTP 抽象连接起来的方式。
via 元组的第三个元素 some_arg 是传递给 some_module 函数的一段数据。该数据的确切形状由注册模块定义。至少,这段数据必须包含进程应注册和查找的名称。
在 Registry 的情况下,第三个参数应该是一个对, {registry_name, process_key} ,因此整个通过元组的形状为 {:via, Registry, {registry_name, process_key}} 。
让我们看一个例子。我们将重温第 6 章中的老朋友: EchoServer 。这是一个简单的 GenServer ,通过返回请求负载来处理呼叫请求。现在,您将向回声服务器添加注册。当您启动服务器时,您将提供服务器 ID——一个唯一标识服务器的任意术语。当您想向服务器发送请求时,您将传递此 ID,而不是 PID。
这是完整的实现:
defmodule EchoServer do
use GenServer
def start_link(id) do
GenServer.start_link(__MODULE__, nil, name: via_tuple(id))
end
def init(_), do: {:ok, nil}
def call(id, some_request) do
GenServer.call(via_tuple(id), some_request)
end
defp via_tuple(id) do
{:via, Registry, {:my_registry, {__MODULE__, id}}}
end
def handle_call(some_request, _, state) do
{:reply, some_request, state}
end
end
通过元组注册服务器
通过元组发现服务器
通过元组符合注册表标准
在这里,您在 via_tuple/1 辅助函数中整合了 via 元组的形状。该进程的注册名称将是 {__MODULE__, id} ,或者在这种情况下是 {EchoServer, id} 。
试试看。启动 iex 会话,复制并粘贴模块定义,然后启动 :my_registry :
iex(1)> defmodule EchoServer do ... end
iex(2)> Registry.start_link(name: :my_registry, keys: :unique)
现在,您可以启动并与多个回声服务器进行交互,而无需跟踪它们的 PID:
iex(3)> EchoServer.start_link("server one")
iex(4)> EchoServer.start_link("server two")
iex(5)> EchoServer.call("server one", :some_request)
:some_request
iex(6)> EchoServer.call("server two", :another_request)
:another_request
请注意,这里的 ID 是字符串,并且还要记住,整个注册的密钥实际上是 {EchoServer, some_id} ,这证明您正在使用任意复杂的术语来注册进程并发现它们。
9.1.4 注册数据库工作者
现在您已经学习了 Registry 的基础知识,您可以实现数据库工作者的注册和发现。首先,您需要创建 Todo.ProcessRegistry 模块。
清单 9.3 待办事项进程注册表 (pool_supervision/lib/todo/process_registry.ex)
defmodule Todo.ProcessRegistry do
def start_link do
Registry.start_link(keys: :unique, name: __MODULE__)
end
def via_tuple(key) do
{:via, Registry, {__MODULE__, key}}
end
def child_spec(_) do
Supervisor.child_spec(
Registry,
id: __MODULE__,
start: {__MODULE__, :start_link, []}
)
end
end
儿童规格
接口功能很简单。 start_link 函数只是转发到 Registry 模块以启动一个唯一的注册表。 via_tuple/1 函数可以被其他模块使用,例如 Todo.DatabaseWorker ,以创建适当的元组,从而将进程注册到此注册表。
因为注册是一个过程,所以应该进行监督。因此,您在模块中包含了 child_spec/1 。在这里,您使用 Supervisor.child_spec/2 来调整 Registry 模块的默认规范。这个调用本质上表明您将使用 Registry 提供的任何子规范,同时更改 :id 和 :start 字段。通过这样做,您不需要了解 Registry 实现的内部细节,例如注册过程是一个工作者还是一个监督者。
有了这个,你可以立即将注册表置于 Todo.System 监督之下。
清单 9.4 监督注册 (pool_supervision/lib/todo/system.ex)
defmodule Todo.System do
def start_link do
Supervisor.start_link(
[
Todo.ProcessRegistry,
Todo.Database,
Todo.Cache
],
strategy: :one_for_one
)
end
end
启动进程注册表
请记住,进程是同步启动的,按照您指定的顺序。因此,子规范列表中的顺序很重要,并不是随意选择的。子项必须始终在其依赖项之后指定。在这种情况下,您必须首先启动注册表,因为数据库工作者将依赖于它。
在 Todo.ProcessRegistry 到位后,您可以开始调整数据库工作者。相关更改在以下列表中呈现。
列表 9.5 注册工作者 (pool_supervision/lib/todo/database_worker.ex)
defmodule Todo.DatabaseWorker do
use GenServer
def start_link({db_folder, worker_id}) do
GenServer.start_link(
__MODULE__,
db_folder,
name: via_tuple(worker_id)
)
end
def store(worker_id, key, data) do
GenServer.cast(via_tuple(worker_id), {:store, key, data})
end
def get(worker_id, key) do
GenServer.call(via_tuple(worker_id), {:get, key})
end
defp via_tuple(worker_id) do
Todo.ProcessRegistry.via_tuple({__MODULE__, worker_id})
end
...
end
注册
发现
此代码引入了 worker_id 的概念,它是范围 1..pool_size 内的一个整数。 start_link 函数现在将此参数与 db_ folder 一起使用。然而,请注意,该函数将两个参数作为一个单一的 {db_folder, worker_id} 元组。原因再次符合自动生成的 child_spec/1 ,它转发到 start_link/1 。要在主管下管理一个工作者,您现在可以使用 {Todo.DatabaseWorker, {db_folder, worker_id}} 子规范。
当调用 GenServer.start_link 时,您提供 via 元组作为名称选项。元组的确切形状被包装在内部 via_tuple/1 函数中,该函数接受工作者 ID 并返回相应的 via 元组。此函数仅委托给 Todo.ProcessRegistry ,将所需的名称以 {__MODULE__, worker_id} 的形式传递给它。因此,工作者使用键 {Todo.DatabaseWorker, worker_id} 注册。这样的名称消除了与可能在同一注册表中注册的其他类型进程的冲突。
同样,您可以使用 via_tuple/1 辅助工具在调用 GenServer.call 和 GenServer.cast 时发现进程。请注意, store/3 和 get/2 函数现在将工作者 ID 作为第一个参数。这意味着它们的客户端不再需要跟踪 PID。
9.1.5 监督数据库工作人员
现在,您可以创建一个新的监督者来管理工人池。为什么要引入一个单独的监督者?理论上,将工人放在 Todo.System 之下是可以的。但请记住,在上一章中提到,如果重启发生得太频繁,监督者会在某个时刻放弃并终止所有子进程。如果您在同一个监督者下保持太多子进程,您可能会更早达到最大重启强度——在这种情况下,所有进程都会被重启。换句话说,单个进程中的问题可能会轻易波及到系统的大部分。
在这种情况下,我做出了一个任意的决定,将系统的一个独立部分(数据库)置于一个单独的监督者之下。这种方法可能会将重启失败的影响限制在数据库操作上。如果重启一个数据库工作进程失败,监督者将终止,这意味着父监督者将尝试重启整个数据库服务,而不影响系统中的其他进程。
无论哪种方式,这些变化的结果是您不再需要数据库 GenServer 。该服务器的目的是启动一组工作进程并管理工作 ID 到 PID 的映射。随着这些新变化,工作进程由主管启动;映射已经由注册表处理。因此,数据库 GenServer 是多余的。
您可以保留 Todo.Database 模块。它现在将实现数据库工作进程的监督者,并保留与之前相同的接口功能。因此,您完全不需要更改客户端 Todo.Server 模块的代码,并且可以将 Todo.Database 保留在 Todo.System 子项的列表中。
接下来,您将把数据库转换为监督者。
清单 9.6 监督工人 (pool_supervision/lib/todo/database.ex)
defmodule Todo.Database do
@pool_size 3
@db_folder "./persist"
def start_link do
File.mkdir_p!(@db_folder)
children = Enum.map(1..@pool_size, &worker_spec/1)
Supervisor.start_link(children, strategy: :one_for_one)
end
defp worker_spec(worker_id) do
default_worker_spec = {Todo.DatabaseWorker, {@db_folder, worker_id}}
Supervisor.child_spec(default_worker_spec, id: worker_id)
end
...
end
您首先创建一个包含三个子规范的列表,每个子规范描述一个数据库工作者。然后,您将此列表传递给 Supervisor.start_link/2 。
每个工作者的规范是在 worker_spec/1 中创建的。您从数据库工作者的默认规范 {Todo.DatabaseWorker, {@db_folder, worker_id}} 开始。然后,您使用 Supervisor.child_spec/2 设置工作者的唯一 ID。
没有这个,你将会有多个孩子拥有相同的 ID。回想第 8 章,默认的 child_spec/1 通过 use GenServer 生成,提供了 :id 字段中的模块名称。因此,如果你使用那个默认规格并尝试启动两个数据库工作者,它们都会获得相同的 ID Todo.DatabaseWorker 。然后, Supervisor 模块会对此表示抱怨并引发错误。
您还需要实现 Todo.Database.child_spec/1 。您刚刚将数据库转换为一个监督者,因此该模块不再包含 use GenServer ,这意味着 child_spec/1 不会自动生成。代码如下所示。
清单 9.7 数据库操作 (pool_supervision/lib/todo/database.ex)
defmodule Todo.Database do
...
def child_spec(_) do
%{
id: __MODULE__,
start: {__MODULE__, :start_link, []},
type: :supervisor
}
end
...
end
该规范包含字段 :type ,该字段之前未提及。此字段可用于指示已启动进程的类型。有效值为 :supervisor (如果子进程是监督进程)或 :worker (对于任何其他类型的进程)。如果省略此字段,则使用默认值 :worker 。
因此,列表 9.7 中的 child_spec/1 指定 Todo.Database 是一个监督者,并且可以通过调用 Todo.Database.start_link/0 来启动它。
这是一个很好的例子,说明 child_spec/1 如何帮助您将实现细节保留在驱动过程的模块中。您只是将数据库变成了一个监督者,并且您更改了其 start_link 函数的元数(现在它不接受任何参数),但 Todo.System 模块中不需要进行任何更改。
接下来,您需要调整 store/2 和 get/1 函数。
清单 9.8 数据库操作 (pool_supervision/lib/todo/database.ex)
defmodule Todo.Database do
...
def store(key, data) do
key
|> choose_worker()
|> Todo.DatabaseWorker.store(key, data)
end
def get(key) do
key
|> choose_worker()
|> Todo.DatabaseWorker.get(key)
end
defp choose_worker(key) do
:erlang.phash2(key, @pool_size) + 1
end
...
end
与之前版本的唯一区别在于 choose_worker/1 函数。之前,该函数会向数据库服务器发出调用。现在,它只是在范围 1..@pool_size 内选择工作者 ID。然后,这个 ID 会被传递给 Todo.DatabaseWorker 函数,这些函数将执行注册查找并将请求转发给相应的数据库工作者。
在这一点上,您可以测试系统的工作方式。启动所有内容:
iex(1)> Todo.System.start_link()
Starting database server.
Starting database worker.
Starting database worker.
Starting database worker.
Starting to-do cache.
现在,验证您是否可以正确重启单个工作者。为此,您需要获取工作者的 PID。因为您了解系统的内部结构,这可以通过在注册表中查找轻松完成。一旦您获得了 PID,就可以终止该工作者:
iex(2)> [{worker_pid, _}] =
Registry.lookup(
Todo.ProcessRegistry,
{Todo.DatabaseWorker, 2}
)
iex(3)> Process.exit(worker_pid, :kill)
Starting database worker.
工人如预期般重新启动,其余系统未受干扰。
值得重申的是,注册表如何支持系统中关于重启进程的正确行为。当一个工作进程被重启时,新进程会有一个不同的 PID。但由于注册表的存在,客户端代码对此并不关心。您在最后可能的时刻解析 PID,在向数据库工作进程发出请求之前进行注册表查找。因此,在大多数情况下,查找将成功,您将与正确的进程进行通信。
在某些情况下,数据库工作者的发现可能会返回无效值,例如如果数据库工作者在客户端进程找到其 PID 后但在请求发送之前崩溃。在这种情况下,客户端进程拥有一个过时的 PID,因此请求将失败。如果客户端想要找到刚刚崩溃的数据库工作者,也可能会出现类似的问题。重启和注册与客户端并发运行,因此客户端可能无法在注册表中找到工作者 PID。
这两种情况导致相同的结果:客户端进程——在这种情况下是一个待办事项服务器——将崩溃,错误将传播给最终用户。这是系统高度并发特性的结果。故障恢复在监控进程中并发执行,因此系统的某些部分在短时间内可能处于不一致状态。
9.1.6 组织监督树
让我们停下来思考一下你到目前为止所做的事情。过程之间的关系如图 9.4 所示。
这是一个简单的监督树的示例——一个由监督者和工作者组成的嵌套结构。该树描述了系统如何组织成服务的层次结构。在这个例子中,系统由三个服务组成:进程注册、数据库和缓存。
每个服务可以进一步细分为子服务。例如,数据库由多个工作者组成,缓存由多个待办服务器组成。即使注册表也进一步细分为多个进程,但那是 Registry 模块的实现细节,因此在图中未显示。
尽管监督者在容错和错误恢复的上下文中经常被提及,但定义正确的启动顺序是他们最重要的角色。监督树描述了系统是如何启动的以及如何关闭的。
更细粒度的树允许您关闭系统的任意部分,而不影响其他部分。在当前版本中,停止数据库服务就像请求其父节点( Todo.System )停止 Todo.Database 子节点一样简单,使用 Supervisor.terminate_child/2 函数。这将关闭数据库进程及其后代。
如果工作进程是系统中的小服务,您可以将监督者视为服务经理——相当于 systemd、Windows 服务管理器等的内置等价物。它们负责直接管理的服务的生命周期。如果任何关键服务停止,其父进程将尝试重新启动它。
查看监督树,您可以推断错误是如何在系统中处理和传播的。如果数据库工作进程崩溃,数据库监督者将重新启动它,而不影响系统的其他部分。如果这没有帮助,您将超过最大重启频率,数据库监督者将终止所有数据库工作进程,然后自身也会终止。
这将被系统监督者注意到,随后将启动一个新的数据库池,以期解决问题。所有这些重启能给你带来什么?通过重启整个工作组,你实际上终止了所有待处理的数据库操作,并重新开始。如果这没有帮助,你就无能为力了,因此你将错误向上传播(在这种情况下,终止所有内容)。这就是监督树中错误恢复的工作方式——你尝试在本地恢复错误,尽量影响尽可能少的进程。如果这不起作用,你就向上移动,尝试重启系统的更大部分。
OTP 合规流程
所有直接从监督者启动的进程都应符合 OTP 标准。要实现符合 OTP 标准的进程,仅仅生成或链接一个进程是不够的;您还必须以特定方式处理一些特定于 OTP 的消息。具体需要做什么的详细信息可以在 Erlang 文档中找到,网址为 https://www.erlang.org/doc/design_principles/spec_proc.xhtml#special-processes。
幸运的是,您通常不需要从头开始实现一个符合 OTP 的过程。相反,您可以使用各种更高级的抽象,例如 GenServer 、 Supervisor 和 Registry 。使用这些模块启动的过程将符合 OTP 标准。Elixir 还附带了 Task 和 Agent 模块,可以用来运行符合 OTP 标准的过程。您将在下一章中学习任务和代理。
由 spawn_link 启动的普通流程不符合 OTP 标准,因此不应直接从主管启动此类流程。您可以自由地从工人那里启动普通流程,例如 GenServer ,但通常最好在可能的情况下使用符合 OTP 标准的流程。
关闭进程
监督树的一个重要好处是能够在不留下悬挂进程的情况下停止整个系统。当你终止一个监督者时,它的所有直接子进程也会被终止。如果所有其他进程直接或间接地与这些子进程相连,它们最终也会被终止。因此,你可以通过终止顶层监督者进程来停止整个系统。
通常,监督者子树以受控方式终止。监督者进程会指示其子进程优雅地终止,从而给它们机会进行最终清理。如果其中一些子进程本身也是监督者,它们将以相同的方式关闭自己的树。优雅终止一个 GenServer 工作进程涉及调用 terminate/2 回调,但仅在工作进程捕获退出时。因此,如果您想从 GenServer 进程中进行一些清理,请确保从 init/1 回调设置退出捕获。
因为优雅终止涉及可能执行清理代码,因此可能会比预期花费更长的时间。子规范中的 :shutdown 选项让您控制监督者将等待子进程优雅终止的时间。如果子进程在此时间内未终止,它将被强制终止。您可以通过在 child_spec/1 中指定 shutdown: shutdown_strategy 并传递一个表示毫秒的整数来选择关闭时间。或者,您可以传递原子 :infinity ,指示监督者无限期等待子进程终止。最后,您可以传递原子 :brutal_kill ,告诉监督者立即以强制方式终止子进程。强制终止是通过向进程发送 :kill 退出信号来完成的,就像您对 Process.exit(pid, :kill) 所做的那样。 :shutdown 选项的默认值是工作进程的 5_000 或监督进程的 :infinity 。
避免进程重启
默认情况下,监视器会重新启动一个终止的进程,无论退出原因是什么。即使进程因 :normal 原因终止,它也会被重新启动。有时,您可能希望更改这种行为。
例如,考虑一个处理 HTTP 请求或 TCP 连接的进程。如果这样的进程失败,套接字将被关闭,重启该进程没有意义(远程方无论如何会断开连接)。您仍然希望将这样的进程放在监督树下,因为这使得可以终止整个监督子树,而不会留下悬挂的进程。在这种情况下,您可以通过在 child_spec/1 中提供 restart: :temporary 来设置一个临时工作者。临时工作者在终止时不会被重启。
另一个选项是临时工作者,仅在异常终止时重新启动。临时工作者可以用于可能正常终止的进程,作为标准系统工作流的一部分。一个典型的例子是您希望在系统启动时执行的一次性任务。您可以在监督树中启动相应的进程(通常由 Task 模块提供支持),然后将其配置为临时。临时工作者可以通过在 child_spec/1 中提供 restart: :transient 来指定。
重启策略
到目前为止,您只使用了 :one_for_one 重启策略。在此模式下,监视器通过启动一个新进程来处理进程终止,保持其他子进程不变。还有两种额外的重启策略:
- :one_for_all —当一个孩子崩溃时,监督员终止所有其他孩子,然后重新启动所有孩子。
- :rest_for_one —当一个子进程崩溃时,监视器终止崩溃子进程的所有较小兄弟进程。然后,监视器在被终止的进程的位置启动新的子进程。
这些策略在兄弟之间紧密耦合时非常有用,在这种情况下,某个子服务没有其兄弟服务就没有意义。一个例子是当一个进程在其自身状态中保持某个兄弟的 PID。在这种情况下,该进程与兄弟的一个实例紧密耦合。如果兄弟终止,依赖的进程也应该终止。
通过选择 :one_for_all 或 :rest_for_one ,您可以实现这一点。前者在所有方向上都有紧密依赖时很有用(每个兄弟姐妹都依赖其他兄弟姐妹)。后者适用于较小的兄弟姐妹依赖于较大的兄弟姐妹的情况。
例如,在待办事项系统中,如果注册进程终止,您可以使用 :rest_for_one 关闭数据库工作进程。没有注册表,这些进程无法发挥任何作用,因此关闭它们是正确的做法。然而,在这种情况下,您不需要这样做,因为 Registry 将每个注册的进程与注册进程链接在一起。因此,注册进程的终止会正确地传播到注册的进程。任何不捕获退出的进程将自动被关闭;捕获退出的进程将收到通知消息。
这结束了我们对细粒度监督的初步了解。您已经进行了几项更改,以最小化错误的影响,但仍有很大的改进空间。在下一部分中,您将继续扩展系统,学习如何动态启动工作者。
相关推荐
- 零基础入门AI智能体:详细了解什么是变量类型、JSON结构、Markdown格式
-
当品牌跳出固有框架,以跨界联动、场景创新叩击年轻群体的兴趣点,一场关于如何在迭代中保持鲜活的探索正在展开,既藏着破圈的巧思,也映照着与新一代对话的密码。在创建AI智能体时,我们会调用插件或大模型,而在...
- C# 13模式匹配:递归模式与属性模式在真实代码中的性能影响分析
-
C#13对模式匹配的增强让复杂数据处理代码更简洁,但递归模式与属性模式的性能差异一直是开发者关注的焦点。在实际项目中,选择合适的模式不仅影响代码可读性,还可能导致执行效率的显著差异。本文结合真实测试...
- 零基础快速入门 VBA 系列 6 —— 常用对象(工作簿、工作表和区域)
-
上一节,我介绍了VBA内置函数以及如何自动打字和自动保存文件。这一节,我们来了解一下Excel常用对象。Excel常用对象Excel有很多对象,其中最常用也最重要的包括以下3个:1.Workbo...
- 不同生命数字的生肖龙!准到雷普!
-
属龙的人总在自信爆棚和自讨苦吃之间反复横跳?看完这届龙宝宝的日常我悟了。属龙的人好像天生自带矛盾体:领导力超强可人缘时好时坏,工作雷厉风行却总在爱情里翻车。关键年份的龙性格差异更大——76年龙靠谱但不...
- 仓颉编程语言基础-面向对象编程-属性(Properties)
-
属性是仓颉颉中一种强大的机制,它允许你封装对类(或接口interface、结构体struct、枚举enum、扩展extend)内部状态的访问。它看起来像一个普通的成员变量(字段),但在其背后,它通过...
- Python中class对象/属性/方法/继承/多态/魔法方法详解
-
一、基础入门:认识类和对象1.类和对象的概念在Python中,类(class)是一种抽象的概念,用于定义对象的属性和行为,而对象(也称为实例)则是类的具体表现。比如,“汽车”可以是一个类,它有...
- VBA基础入门:搞清楚对象、属性和方法就成功了一半
-
如果你刚接触VBA(VisualBasicforApplications),可能会被“对象”“属性”“方法”这些术语搞得一头雾水。但事实上,这三个概念是VBA编程的基石。只要理解它们之间的关系,...
- P.O类型文推荐|年度编推合集(一百九十五篇)
-
点击左上方关注获取更多精彩推文目录2019年度编推35篇(1V1)《悖论》作者:流苏.txt(1V1)《桂花蒸》作者:大姑娘浪.txt(1V1)《豪门浪女》作者:奚行.txt...
- Python参数传递内存大揭秘:可变对象 vs 不可变对象
-
90%的Python程序员不知道,函数参数传递中可变对象的修改竟会导致意想不到的副作用!一、参数传递的本质:对象引用传递在Python中,所有参数传递都是对象引用的传递。这意味着函数调用时传递的不是对...
- JS 开发者必看!TC39 2025 最新动向,这些新语法要火?
-
大家好,很高兴又见面了,我是"高级前端进阶",由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发,您的支持是我不断创作的动力。TC39第...
- 2025 年值得尝试的 5 个被低估的 JavaScript 库
-
这些JavaScript库可能不会在社交媒体或HackerNews上流行起来,但它们会显著提高您的工作效率和代码质量。JavaScript不再只是框架。虽然React、Vue和Sv...
- Python自动化办公应用学习笔记30—函数的参数
-
一、函数的参数1.形参:o定义:在函数定义时,声明在函数名后面括号中的变量。o作用:它们是函数内部的占位符变量,用于接收函数被调用时传入的实际值。o生命周期:在函数被调用时创建,在函数执...
- 16种MBTI人格全解析|测完我沉默了三秒:原来我是这样的人?
-
MBTI性格测试火了这么久,你还不知道自己是哪一型?有人拿它当社交话题,有人拿它分析老板性格,还有人干脆当成择偶参考表。不废话,今天我一次性给你整理全部16种MBTI人格类型!看完你不仅能知道自己是谁...
- JS基础与高级应用: 性能优化
-
在现代Web开发中,性能优化已成为前端工程师必须掌握的核心技能之一。本文从URL输入到页面加载完成的全过程出发,深入分析了HTTP协议的演进、域名解析、代码层面性能优化以及编译与渲染的最佳实践。通过节...
- 爱思创CSP-J/S初赛模拟赛线上开赛!助力冲入2024年CSP-J/S复赛!
-
CSP-J/S组初赛模拟赛爱思创,专注信奥教育19年,2022年CSP-J/S组赛事指定考点,特邀NOIP教练,开启全真实CSP-J/S组线上初赛模拟大赛!一、比赛对象:2024年备考CSP-J/S初...
- 一周热门
- 最近发表
-
- 零基础入门AI智能体:详细了解什么是变量类型、JSON结构、Markdown格式
- C# 13模式匹配:递归模式与属性模式在真实代码中的性能影响分析
- 零基础快速入门 VBA 系列 6 —— 常用对象(工作簿、工作表和区域)
- 不同生命数字的生肖龙!准到雷普!
- 仓颉编程语言基础-面向对象编程-属性(Properties)
- Python中class对象/属性/方法/继承/多态/魔法方法详解
- VBA基础入门:搞清楚对象、属性和方法就成功了一半
- P.O类型文推荐|年度编推合集(一百九十五篇)
- Python参数传递内存大揭秘:可变对象 vs 不可变对象
- JS 开发者必看!TC39 2025 最新动向,这些新语法要火?
- 标签列表
-
- HTML 简介 (30)
- HTML 响应式设计 (31)
- HTML URL 编码 (32)
- HTML Web 服务器 (31)
- HTML 表单属性 (32)
- HTML 音频 (31)
- HTML5 支持 (33)
- HTML API (36)
- HTML 总结 (32)
- HTML 全局属性 (32)
- HTML 事件 (31)
- HTML 画布 (32)
- HTTP 方法 (30)
- 键盘快捷键 (30)
- CSS 语法 (35)
- CSS 轮廓宽度 (31)
- CSS 谷歌字体 (33)
- CSS 链接 (31)
- CSS 定位 (31)
- CSS 图片库 (32)
- CSS 图像精灵 (31)
- SVG 文本 (32)
- 时钟启动 (33)
- HTML 游戏 (34)
- JS Loop For (32)