第 8 章 容错基础(Fault tolerance basics)学习笔记
本章内容为原创学习笔记与概念整理,不是原书逐段翻译。
前一章已经把多个服务进程组合成一个小系统。本章继续回答一个更现实的问题:当其中某个进程出错时,系统应该如何反应?
Elixir/OTP 的容错观念不是“让每个函数永远不出错”,而是把错误限制在清晰的边界内,并让外层结构决定如何恢复。要做到这一点,需要理解进程崩溃、链接、监控和监督树。
8.1 错误不可避免
任何长期运行的系统都会遇到错误:
- 输入数据不符合预期。
- 外部服务超时。
- 网络连接断开。
- 文件系统写入失败。
- 代码存在未覆盖的边界条件。
- 进程状态被业务逻辑带到非法组合。
在单进程脚本里,错误往往意味着整个程序退出。但在 BEAM 系统中,程序通常由大量进程组成。一个进程失败后,理想结果是:相关的一小部分功能重启或降级,系统其他部分继续运行。
这要求我们把故障边界设计清楚。进程不是单纯的并发工具,也是隔离失败影响的单位。
异常、退出和抛出
Elixir 中常见的错误控制机制包括:
raise抛出异常。exit让进程退出。throw/catch做非局部返回。
业务代码中最常见的是异常。异常可以被 try/rescue 捕获,但在 OTP 风格代码里,不应该把所有异常都吞掉。许多错误代表当前进程已经处于不可信状态,继续运行可能比重启更危险。
更重要的是区分两类情况:
- 可预期的业务失败:例如找不到记录、权限不足、输入校验失败。
- 不可预期的程序错误:例如模式匹配失败、状态不一致、外部依赖异常。
前者通常应该用返回值表达,例如 {:ok, value}、:error、{:error, reason}。后者往往可以让进程崩溃,再交给监督结构恢复。
8.2 并发系统中的错误传播
默认情况下,一个 BEAM 进程崩溃不会自动杀死其他进程。这提供了隔离,但也带来一个问题:如果父进程启动了子进程,而子进程悄悄崩溃,父进程可能完全不知道。
为了建立进程间的失败关系,可以使用链接和监控。
链接
链接让两个进程之间形成双向失败传播。一个被链接的进程异常退出时,退出信号会传给另一个进程。常见启动函数 spawn_link/1 会创建并链接新进程。
链接适合表达“这些进程命运相关”。如果工作进程失败,拥有者也应该知道;如果拥有者退出,工作进程通常也不该孤零零继续运行。
GenServer.start_link/3 里的 link 就是这个含义。监督者用它启动子进程,从而能感知子进程失败。
监控
监控是单向观察。监控者会在目标进程结束时收到一条 :DOWN 消息,但不会因为目标进程崩溃而自动退出。
概念上可以这样理解:
- 链接:我们同生共死,异常会传播。
- 监控:我只想知道你死没死,不想被你拖下水。
监控适合请求/任务型场景。例如调用方启动一个临时任务,想在它完成或崩溃时得到通知,但调用方自身不应该因为任务失败而崩溃。
捕获退出
进程可以设置 Process.flag(:trap_exit, true) 捕获退出信号,把退出转换成普通消息。这样进程不会因为链接进程退出而直接死亡,而是收到类似 {:EXIT, pid, reason} 的消息。
这个能力很强,但不要过早使用。随意捕获退出可能会破坏“让失败快速暴露”的模型,也可能让进程在依赖已经消失后继续带病运行。实际项目里,大部分重启和清理工作应交给监督者,而不是每个业务进程自己捕获所有退出。
8.3 监督者
监督者是专门负责管理其他进程生命周期的进程。它不处理业务请求,而是启动子进程、监控它们,并在失败时根据策略重启。
监督者让容错规则从业务代码中分离出来:
- 业务进程负责业务状态和消息。
- 监督者负责启动顺序、失败检测和重启策略。
这种分离非常重要。没有监督者时,每个模块都可能自己发明一套重启逻辑,系统会变得混乱。
子进程规格
监督者需要知道如何启动子进程。这个信息通常称为 child spec,包含:
- 子进程 id。
- 启动函数。
- 重启策略。
- 关闭策略。
- 子进程类型。
在现代 Elixir 中,很多模块通过 child_spec/1 提供默认规格,因此监督树可以写得比较简洁:
children = [
{Todo.Cache, []},
{Todo.Database, []}
]
Supervisor.start_link(children, strategy: :one_for_one)
这里的重点不是记住语法,而是理解声明式思路:你描述系统需要哪些子进程,以及它们失败后应该怎样处理。
重启策略
常见监督策略包括:
:one_for_one:某个子进程崩溃,只重启它自己。:one_for_all:某个子进程崩溃,终止并重启所有子进程。:rest_for_one:某个子进程崩溃,重启它以及在它之后启动的子进程。
选择策略时,要看进程之间的依赖关系。
如果子进程彼此独立,one_for_one 通常最合适。比如多个互不相关的缓存 worker,其中一个失败不应影响其他 worker。
如果子进程共享强依赖,one_for_all 可能更合理。例如一个状态进程和一个依赖该状态的 worker,如果状态进程崩溃,worker 继续运行可能没有意义。
rest_for_one 适合启动顺序表达依赖链的场景:后启动的进程依赖先启动的进程。
重启强度
监督者不会无限快速重启子进程。它通常有最大重启频率限制。如果短时间内重启次数过多,监督者自己也会放弃并退出,把失败交给更上层监督者。
这能避免系统陷入无意义的重启循环。假设配置文件缺失,子进程每次启动都失败,疯狂重启只会浪费资源。让上层知道“这一层恢复不了”更合理。
8.4 “让它崩溃”的真实含义
“let it crash” 经常被误解成不处理错误。更准确的理解是:不要在错误发生的位置做无意义的局部修补,而是在清晰的故障边界上恢复。
它依赖几个前提:
- 进程状态是隔离的。
- 进程职责足够小。
- 有监督者负责重启。
- 启动过程能建立健康初始状态。
- 外部状态恢复策略清楚。
如果没有这些前提,“让它崩溃”只是让系统随机失败。容错不是放弃设计,而是把错误处理从散乱的 try/rescue 移到系统结构中。
什么时候不要崩溃
可预期的业务失败不应该靠崩溃表达。比如用户输入密码错误、查询结果不存在、请求参数缺失,这些都是正常业务路径。用返回值或表单错误处理更清楚。
适合崩溃的通常是不可恢复的局部错误:
- 进程内部状态不一致。
- 必需配置缺失。
- 协议假设被破坏。
- 不可能分支实际发生。
- 外部资源初始化失败且没有替代路径。
8.5 重启后的状态
监督者可以重启进程,但它不能自动恢复业务状态。状态恢复需要你自己设计。
常见策略包括:
- 进程状态可以丢弃,重启后从空状态继续。
- 启动时从数据库或文件加载状态。
- 状态由上游事件流重放恢复。
- 状态只是缓存,可按需重新计算。
- 状态由另一个可靠组件拥有,本进程只是访问者。
这会影响进程设计。如果某个 GenServer 持有关键业务数据,却没有持久化或恢复策略,那么重启只会让进程活过来,数据却可能丢失。
在待办列表系统中,如果列表服务器崩溃后从数据库重新加载数据,重启才有业务意义。否则,监督树只是让空列表进程重新出现。
8.6 监督树设计
监督树是层级结构。上层监督者管理下层监督者和关键服务,下层监督者再管理具体 worker。这样可以把不同故障区域分开。
一个简单系统可能是:
Todo.Application.Supervisor
|
+-- Todo.Database
|
+-- Todo.Cache
|
+-- Todo.Server("work")
|
+-- Todo.Server("home")
不过动态创建的列表服务器通常不应直接由普通 GenServer 手动管理。更好的方式是使用动态监督者,让“按需启动 worker”也纳入 OTP 生命周期管理。这会在后续章节继续展开。
设计监督树时可以问:
- 哪些进程必须随应用启动?
- 哪些进程是按需动态创建?
- 哪些进程失败会影响一组相关进程?
- 哪些进程失败只需要重启自己?
- 哪些状态能重建,哪些状态必须持久化?
8.7 容错代码的反面信号
下面这些迹象通常说明容错设计需要重看:
- 到处都是宽泛的
try/rescue,但没有清楚的恢复逻辑。 - 进程崩溃后虽然被重启,但新状态不正确。
- 子进程由业务进程手动启动,没有监督者接管。
- 失败日志很多,但系统没有明确降级或恢复路径。
- 单个进程承担太多职责,崩溃影响范围过大。
- 重启策略选得过宽,一个小 worker 失败导致大量无关进程重启。
- 重启策略选得过窄,依赖已失效的进程继续运行。
容错设计追求的是“错误影响范围可解释”。不一定所有错误都能自动恢复,但系统应该让你知道错误在哪里、影响什么、下一层怎么处理。
练习:为待办系统设计监督树
基于前一章的待办列表系统,尝试完成以下设计:
- 顶层监督者启动数据库服务和缓存服务。
- 缓存服务不再直接
start_link列表服务器,而是通过动态监督者创建。 - 单个列表服务器崩溃后可以重新启动。
- 列表服务器启动时从数据库加载对应列表。
- 思考数据库服务崩溃时,列表服务器是否应该一起重启。
不急着写代码也可以,先画出监督树并标注策略:
RootSupervisor (:one_for_one?)
Database
TodoServerSupervisor
Cache
然后解释每条边的依赖关系。能解释清楚,代码通常就不会太离谱。
本章复盘
- BEAM 进程隔离让局部失败不会自动破坏整个系统。
- 链接用于建立双向失败关系,监控用于单向观察进程结束。
- 监督者负责启动、监控和重启子进程,把容错规则从业务逻辑中分离。
:one_for_one、:one_for_all、:rest_for_one应根据依赖关系选择。- “让它崩溃”不是不处理错误,而是在清晰边界上恢复。
- 可预期业务失败应使用返回值,不要滥用崩溃表达正常流程。
- 重启不等于状态恢复;关键状态需要持久化、重放或重新计算策略。
- 好的监督树让错误影响范围可解释,并为后续动态监督和生产化打基础。