Skip to main content

第 9 章 隔离错误影响(Isolating error effects)学习笔记

本章内容为原创学习笔记与概念整理,不是原书逐段翻译。

上一章介绍了监督者和基础重启策略。本章进一步讨论如何用监督树把错误影响限制在尽可能小的区域内。换句话说,容错不只是“崩了就重启”,而是要让某个进程的失败不会拖垮无关功能。

在并发系统中,错误隔离的核心是结构设计:哪些进程放在同一个监督者下,哪些动态 worker 应该被独立监督,哪些失败需要连带重启,哪些失败应该只影响自己。

9.1 监督树是故障边界图

监督树表面上是进程启动结构,实际上也是故障传播结构。每个监督者都定义了一个恢复区域:区域内某个子进程失败后,监督者按策略决定重启谁。

因此,设计监督树时不要只问“这些进程如何启动”,还要问:

  • 哪些进程失败时应该一起恢复?
  • 哪些进程彼此独立,失败不应相互影响?
  • 哪些进程是动态创建的,数量和名称运行时才知道?
  • 哪些状态可以丢弃,哪些必须恢复?
  • 哪个组件失败后应该向上升级为更大范围的失败?

一个监督树设计得好,局部错误就会停留在局部。设计得差,一个小 worker 崩溃可能导致整个应用重启,或者相反,关键依赖失效后大量进程还在无意义运行。

松耦合进程应分开监督

如果两个 worker 没有强依赖,就不要让它们因为对方失败而一起重启。比如两个不同用户的待办列表进程通常是独立的。某个用户列表因为坏数据崩溃,不应该导致所有列表重启。

这类场景适合:

  • 每个 worker 直接挂在监督者下。
  • 使用 :one_for_one 策略。
  • 每个 worker 自己从持久化层恢复状态。

这样错误影响被限制在单个 worker。

强依赖进程可以放在同一恢复区域

如果一组进程共享强依赖,一个进程失败后其他进程继续运行会产生错误结果,那么可以让它们处在同一个恢复区域。

例如:

  • 一个连接管理进程和依赖该连接的协议处理进程。
  • 一个状态拥有者和多个只对该状态有意义的辅助 worker。
  • 一个分片监督者下的一组缓存、索引和写入队列。

这时可以考虑 :one_for_all 或者更细粒度地创建子监督者,把相关进程包成一个小子系统。

9.2 动态启动 worker

很多系统的 worker 数量不是固定的。例如:

  • 每个用户会话一个进程。
  • 每个房间、任务、设备或连接一个进程。
  • 每个待办列表按需启动一个服务进程。

这些进程不适合写死在静态 children 列表中。OTP 提供 DynamicSupervisor 管理这类按需启动的 worker。

一个典型结构是:

Todo.RootSupervisor
|
+-- Todo.Database
|
+-- Todo.ListSupervisor (DynamicSupervisor)
|
+-- Todo.Cache

Todo.Cache 不再直接 Todo.Server.start_link(name),而是请求 Todo.ListSupervisor 启动或找到对应列表进程。这样动态 worker 也进入监督体系。

如果缓存进程手动启动列表服务器,会出现几个问题:

  • 列表服务器崩溃后,谁负责重启?
  • 缓存里保存的 PID 何时失效?
  • 缓存进程崩溃后,它启动过的列表服务器如何处理?
  • 同时创建同名列表时如何避免重复?

动态监督者把“启动和重启 worker”的职责从缓存中拿走。缓存只负责名称到 worker 的定位逻辑,监督者负责生命周期。

动态 worker 的 child spec

动态监督者启动 worker 时仍然需要 child spec。可以让 worker 模块实现 child_spec/1,使启动参数更明确:

def child_spec(name) do
%{
id: {__MODULE__, name},
start: {__MODULE__, :start_link, [name]},
restart: :temporary
}
end

id 要能区分不同 worker。动态 worker 如果都用同一个模块名当 id,可能导致监督者认为它们是同一个 child。

重启策略也要谨慎选择:

  • :permanent:无论退出原因如何都重启。
  • :transient:异常退出才重启。
  • :temporary:从不自动重启。

对于按需创建、可从外部请求重新创建的进程,temporary 有时合理;对于需要持续存在的连接或列表服务,可能更适合 transientpermanent。选择取决于业务语义。

9.3 进程注册与查找

动态启动 worker 后,还需要解决“如何找到它”。直接把 PID 存在缓存进程里可行,但必须处理 PID 失效和并发创建问题。

更系统的做法是使用注册机制。Elixir 标准库提供 Registry,可以把业务 key 和进程关联起来。调用方通过 key 查找进程,而不是自己保存 PID。

概念上,注册表让系统从:

name -> cache map -> pid

变成:

name -> registry -> pid

注册表的好处包括:

  • 进程退出时注册项会自动消失。
  • 可以避免缓存里保存过期 PID。
  • 能支持 via tuple,让 GenServer 直接用业务名启动和调用。

via tuple

GenServer 支持用 {:via, module, term} 形式命名进程。配合 Registry,可以写出这种风格:

{:via, Registry, {Todo.Registry, {:todo_list, name}}}

这样客户端 API 可以根据列表名构造名字,再调用 GenServer.callcast。具体 PID 由注册表解析。

使用注册表后,缓存进程有时可以变得更薄,甚至被移除。但这不是绝对规则。如果缓存还承担业务逻辑、惰性加载、访问统计或权限控制,它仍然可能有存在价值。

9.4 “让它崩溃”的隔离条件

本章再次强调 “let it crash”,但重点更偏向隔离影响。让进程崩溃成立的前提是:崩溃进程的职责足够小,监督树能重启它,状态能恢复或可丢弃,调用方能处理暂时失败。

如果一个进程承载太多职责,它一崩溃就会影响大量无关功能。这不是 “let it crash” 的问题,而是边界设计的问题。

一个适合崩溃重启的 worker 通常满足:

  • 状态范围小。
  • 可以从参数、数据库或事件恢复。
  • 崩溃原因不会污染其他进程状态。
  • 重启后能重新接入系统。
  • 调用方有超时或重试策略。

一个不适合简单崩溃重启的进程则可能:

  • 持有大量不可恢复内存状态。
  • 同时管理多个无关业务领域。
  • 失败后需要复杂人工补偿。
  • 启动过程依赖不稳定外部资源,容易重启风暴。

9.5 错误隔离与数据一致性

隔离错误不等于忽略数据一致性。进程重启后如果状态恢复不正确,系统只是表面活着。

在待办列表系统中,列表进程崩溃后可能从数据库读取数据。但如果它崩溃前有异步写入尚未落盘,就可能丢失最近更新。设计时必须明确:

  • 写入何时算成功?
  • 回复调用方前是否已经持久化?
  • 异步持久化失败后谁负责重试?
  • 重启时以内存、磁盘还是事件日志为准?

不同系统有不同权衡。学习阶段可以先采用简单同步持久化,保证概念清楚;性能优化阶段再引入异步写入、批处理或外部数据库。

9.6 避免重启风暴

如果进程启动后立刻崩溃,监督者会尝试重启;如果反复发生,就会触发最大重启强度,监督者也会退出。这个机制能阻止无限重启,但仍应从设计上避免重启风暴。

常见原因包括:

  • 配置错误导致所有 worker 启动失败。
  • 外部服务不可用,初始化阶段强依赖它。
  • 坏数据每次加载都会触发崩溃。
  • 重启策略过于激进,临时任务也被永久重启。

改进方向:

  • 启动阶段只做必要初始化,外部依赖失败用明确状态表达。
  • 对坏数据做隔离或迁移,而不是让所有实例重复崩溃。
  • 为临时任务设置合适的 :temporary:transient
  • 在日志和监控中记录崩溃原因与重启频率。

9.7 观察和调试监督树

容错结构需要可观察。即使监督树设计正确,也需要知道它何时在工作。

可以关注:

  • 子进程崩溃日志。
  • 监督者重启次数。
  • 动态 worker 数量。
  • 关键 worker 的邮箱长度。
  • 调用超时率。
  • 持久化失败次数。

Elixir/Erlang 生态提供许多观察工具,后续生产章节会更系统地展开。此处先记住一点:容错不是静态结构,运行时信号同样重要。

练习:把列表进程迁入动态监督者

基于前面待办系统,尝试设计:

  1. 增加 Todo.ListSupervisor,使用 DynamicSupervisor
  2. Todo.Server 支持按列表名启动。
  3. Todo.Cache 请求动态监督者启动列表进程。
  4. 同名列表只创建一个进程。
  5. 列表进程退出后,缓存或注册表不会返回过期 PID。
  6. 思考列表进程应该使用 :permanent:transient 还是 :temporary

再画出失败场景:

  • 单个列表进程崩溃。
  • 数据库进程崩溃。
  • 动态监督者崩溃。
  • 缓存进程崩溃。

分别说明哪些进程会重启,哪些调用会短暂失败,哪些状态需要恢复。

本章复盘

  • 监督树不仅是启动结构,也是故障边界图。
  • 松耦合 worker 应尽量独立监督,避免无关进程被连带重启。
  • 强依赖进程可以放进同一恢复区域,或用子监督者表达小系统边界。
  • 动态 worker 应由 DynamicSupervisor 管理,而不是散落在业务进程中手动启动。
  • 注册机制能减少过期 PID 和并发创建带来的复杂度。
  • “让它崩溃”的关键是职责小、状态可恢复、调用方有失败处理路径。
  • 错误隔离必须和数据一致性一起设计。
  • 重启风暴通常暴露了配置、坏数据、外部依赖或重启策略问题。