第 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 也进入监督体系。
为什么不能只在缓存进程里 start_link
如果缓存进程手动启动列表服务器,会出现几个问题:
- 列表服务器崩溃后,谁负责重启?
- 缓存里保存的 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 有时合理;对于需要持续存在的连接或列表服务,可能更适合 transient 或 permanent。选择取决于业务语义。
9.3 进程注册与查找
动态启动 worker 后,还需要解决“如何找到它”。直接把 PID 存在缓存进程里可行,但必须处理 PID 失效和并发创建问题。
更系统的做法是使用注册机制。Elixir 标准库提供 Registry,可以把业务 key 和进程关联起来。调用方通过 key 查找进程,而不是自己保存 PID。
概念上,注册表让系统从:
name -> cache map -> pid
变成:
name -> registry -> pid
注册表的好处包括:
- 进程退出时注册项会自动消失。
- 可以避免缓存里保存过期 PID。
- 能支持
viatuple,让GenServer直接用业务名启动和调用。
via tuple
GenServer 支持用 {:via, module, term} 形式命名进程。配合 Registry,可以写出这种风格:
{:via, Registry, {Todo.Registry, {:todo_list, name}}}
这样客户端 API 可以根据列表名构造名字,再调用 GenServer.call 或 cast。具体 PID 由注册表解析。
使用注册表后,缓存进程有时可以变得更薄,甚至被移除。但这不是绝对规则。如果缓存还承担业务逻辑、惰性加载、访问统计或权限控制,它仍然可能有存在价值。
9.4 “让它崩溃”的隔离条件
本章再次强调 “let it crash”,但重点更偏向隔离影响。让进程崩溃成立的前提是:崩溃进程的职责足够小,监督树能重启它,状态能恢复或可丢弃,调用方能处理暂时失败。
如果一个进程承载太多职责,它一崩溃就会影响大量无关功能。这不是 “let it crash” 的问题,而是边界设计的问题。
一个适合崩溃重启的 worker 通常满足:
- 状态范围小。
- 可以从参数、数据库或事件恢复。
- 崩溃原因不会污染其他进程状态。
- 重启后能重新接入系统。
- 调用方有超时或重试策略。
一个不适合简单崩溃重启的进程则可能:
- 持有大量不可恢复内存状态。
- 同时管理多个无关业务领域。
- 失败后需要复杂人工补偿。
- 启动过程依赖不稳定外部资源,容易重启风暴。
9.5 错误隔离与数据一致性
隔离错误不等于忽略数据一致性。进程重启后如果状态恢复不正确,系统只是表面活着。
在待办列表系统中,列表进程崩溃后可能从数据库读取数据。但如果它崩溃前有异步写入尚未落盘,就可能丢失最近更新。设计时必须明确:
- 写入何时算成功?
- 回复调用方前是否已经持久化?
- 异步持久化失败后谁负责重试?
- 重启时以内存、磁盘还是事件日志为准?
不同系统有不同权衡。学习阶段可以先采用简单同步持久化,保证概念清楚;性能优化阶段再引入异步写入、批处理或外部数据库。
9.6 避免重启风暴
如果进程启动后立刻崩溃,监督者会尝试重启;如果反复发生,就会触发最大重启强度,监督者也会退出。这个机制能阻止无限重启,但仍应从设计上避免重启风暴。
常见原因包括:
- 配置错误导致所有 worker 启动失败。
- 外部服务不可用,初始化阶段强依赖它。
- 坏数据每次加载都会触发崩溃。
- 重启策略过于激进,临时任务也被永久重启。
改进方向:
- 启动阶段只做必要初始化,外部依赖失败用明确状态表达。
- 对坏数据做隔离或迁移,而不是让所有实例重复崩溃。
- 为临时任务设置合适的
:temporary或:transient。 - 在日志和监控中记录崩溃原因与重启频率。
9.7 观察和调试监督树
容错结构需要可观察。即使监督树设计正确,也需要知道它何时在工作。
可以关注:
- 子进程崩溃日志。
- 监督者重启次数。
- 动态 worker 数量。
- 关键 worker 的邮箱长度。
- 调用超时率。
- 持久化失败次数。
Elixir/Erlang 生态提供许多观察工具,后续生产章节会更系统地展开。此处先记住一点:容错不是静态结构,运行时信号同样重要。
练习:把列表进程迁入动态监督者
基于前面待办系统,尝试设计:
- 增加
Todo.ListSupervisor,使用DynamicSupervisor。 Todo.Server支持按列表名启动。Todo.Cache请求动态监督者启动列表进程。- 同名列表只创建一个进程。
- 列表进程退出后,缓存或注册表不会返回过期 PID。
- 思考列表进程应该使用
:permanent、:transient还是:temporary。
再画出失败场景:
- 单个列表进程崩溃。
- 数据库进程崩溃。
- 动态监督者崩溃。
- 缓存进程崩溃。
分别说明哪些进程会重启,哪些调用会短暂失败,哪些状态需要恢复。
本章复盘
- 监督树不仅是启动结构,也是故障边界图。
- 松耦合 worker 应尽量独立监督,避免无关进程被连带重启。
- 强依赖进程可以放进同一恢复区域,或用子监督者表达小系统边界。
- 动态 worker 应由
DynamicSupervisor管理,而不是散落在业务进程中手动启动。 - 注册机制能减少过期 PID 和并发创建带来的复杂度。
- “让它崩溃”的关键是职责小、状态可恢复、调用方有失败处理路径。
- 错误隔离必须和数据一致性一起设计。
- 重启风暴通常暴露了配置、坏数据、外部依赖或重启策略问题。