监督树:系统的主治医生
第 6 章欠的账,本章连本带息还清。「让它崩溃」的完整版从来不是一句口号,而是一套分工:工人进程只写快乐路径,死了干脆;监督者进程不写业务,专职收尸和重启。把这个分工递归地叠起来,就是监督树——BEAM 应用的骨架,九个九传说的真正主角。本章有全书第一台旗舰模拟器:你亲手杀进程,看主治医生怎么救。
先能感知死亡:link 与 monitor
第 7 章留了个钩子:「隔离是免费的,感知死亡需要登记」。登记方式有两种,分工清楚:
iex(1)> {pid, ref} = spawn_monitor(fn -> exit(:heart_stopped) end)
{#PID<0.123.0>, #Reference<0.1204.2233...>}
iex(2)> flush()
{:DOWN, #Reference<0.1204...>, :process, #PID<0.123.0>, :heart_stopped}
:ok
monitor(监视)是单向的旁观:我监视你,你死时我邮箱里收到一条 {:DOWN, ref, :process, pid, 死因} 普通消息,仅此而已——适合「关心但不共命运」的场合,比如等一个外包任务的结果。
link(链接)是双向的生死契约:两个进程 link 之后,一方非正常死亡,exit 信号会顺着链接传过去,把对方也杀死。听起来像连坐,但想想第 6 章的 exit:这是刻意的「要死一起死」——一组共同完成任务的进程,一个坏了,剩下的继续跑只会产出错误结果,不如全部倒下重来。
连坐链上要有一个终点:某个进程声明 Process.flag(:trap_exit, true) 之后,exit 信号到它这里不再杀人,而是降级成一条 {:EXIT, pid, 死因} 消息躺进邮箱。谁需要「别人死了我不死,还要知道死因」?——监督者。link + trap_exit,就是监督树的全部生理学基础。
Supervisor:不写业务的进程
监督者(Supervisor)是一种特殊进程:启动时按清单拉起一组子进程并与它们 link,然后什么业务都不干,守着 trap_exit 等讣告,按预先声明的策略重启死者。「不干业务」是铁律——监督者的代码由 OTP 提供、久经四十年锤炼,自身几乎不可能崩,整个救援体系的可靠性正是锚定在这一点上。OTP 把它做成了标准件:
defmodule Ward.Application do
use Application
@impl true
def start(_type, _args) do
children = [
Ward.Cache, # 缓存 GenServer
Ward.Orders, # 订单 GenServer
{Ward.Notifier, retries: 3} # 通知 GenServer,带参数
]
Supervisor.start_link(children, strategy: :one_for_one, name: Ward.Supervisor)
end
end
这个文件不是示意——每个 mix 生成的 Elixir 应用里都有一份 application.ex,长得就是这样。一个 Elixir 应用 = 一棵监督树:children 列表既是启动顺序的声明(从上到下依次拉起),也是故障恢复的预案。strategy 是给整组孩子选的救治方案,一共三种:
| 策略 | 一个孩子死了 | 适用场景 |
|---|---|---|
:one_for_one | 只重启死者 | 孩子彼此独立(最常用) |
:one_for_all | 全组杀掉重启 | 孩子互相依赖,少一个都不完整 |
:rest_for_one | 死者 + 清单里排它后面的一起重启 | 后者依赖前者(链式依赖) |
children 列表里那些看似随意的写法(裸模块名、带参数的元组),背后是统一的 child_spec(子进程说明书):每个可被监督的模块都提供一份,声明「怎么启动我(start)、我死了怎么办(restart)、给我多久优雅关闭(shutdown)」。use GenServer 已经替你生成了默认说明书,所以平时感觉不到它;需要定制时在 use 行传参即可:use GenServer, restart: :transient, shutdown: 10_000。运行时想看监督者手里的花名册,一行 Supervisor.which_children(Ward.Supervisor),每个孩子的 pid、类型、状态尽收眼底——又一个「活着即可查」的例子。
每个孩子还可以声明自己的「重启资格」::permanent(默认,死了必重启)、:transient(异常死亡才重启,正常退出不管)、:temporary(死了就死了)。一次性任务用 temporary,长活服务用 permanent,语义都在声明里,没有一行 if。
重启强度:主治医生也会宣布无能为力
如果病因不是偶发——比如配置文件坏了,进程起来秒崩、崩了再起——无限重启就是原地空转。所以监督者有一条重启强度熔断线,默认「5 秒内最多 3 次」:超线,监督者判定「这病我治不了」,杀掉全部孩子,自己也退出。
熔断阈值可调(max_restarts/max_seconds),但先别急着调大——「重启不够多」几乎从不是真正的病因,阈值频繁被打穿,该查的是那个反复崩的进程本身。
自己退出不是撂挑子,而是升级(escalation):监督者通常也是上一级监督者的孩子,它一死,上一级会按自己的策略重启它——相当于把「重启一个进程」升级成「重启一整个子系统」。故障像气泡一样往树根方向冒,每高一层,重启的半径就大一圈,直到某一层重启后世界恢复正常;真到树根(Application)还治不好,整个节点退出,交给更外层(容器编排、心跳拉起)。「重启多大范围」不再是值班工程师凌晨三点的临场决断,而是写在树形结构里的预案。
优雅停机:启动的镜像
监督树管生也管死,而且死得比生更讲究。节点收到停机指令(部署、缩容、Ctrl+C 两次),Application 会按启动的严格逆序关闭整棵树:每个监督者给孩子发 exit(:shutdown) 信号,孩子有 shutdown 时限(默认 5 秒)做善后——GenServer 若设了 trap_exit,此时会走 terminate/2 回调,冲刷缓冲、断开连接、道别;逾期不退,直接 :kill。整个过程的含义是:「谁依赖谁」声明一次,启动顺序和停机顺序同时正确。对比一下手工管理关闭顺序的语言里,「优雅停机」通常是上线半年后才补的专项工程。
为什么「重启」真的能治病
「重启大法好」被运维玩笑了三十年,OTP 把它变成了严肃医学,机理有二。①生产系统里最难缠的从来不是必现 bug(那种测试就抓住了),而是 heisenbug:特定消息顺序、特定竞态、特定脏状态累积出的幽灵,复现要几周,根因查不到。而重启把进程放回 init/1 的出厂状态——幽灵赖以存在的脏状态被整个丢弃,系统在毫秒内回到已知良好点。②崩溃的半径被监督树限制在最小子树,治疗本身不产生新创伤。「查不到根因就先恢复服务,原始现场留日志慢慢查」——let it crash 把这句运维智慧做成了默认行为。
现在可以把第 6 章的话说完整了:防御式编程在 BEAM 上是反模式,因为每一个「以防万一」的 rescue 都在拦截本该触发重启的崩溃,把进程滞留在幽灵状态里继续服务——你以为在提高可用性,实际在阻止免疫系统工作。正确的分工:可预期的失败用 {:error, _} 处理(那是业务),不可预期的一律放行,让它崩给监督者看。
重启不是银弹,两个边界要门儿清:①重启即失忆——进程内存状态清零。所以「重要状态」要么本来就该在库里/ETS 里(第 10 章),要么接受丢失。设计 GenServer 时先自问:这个 state 丢了要紧吗?②重启治不了外因——数据库挂了、下游持续超时,重启多少次都一样,只会撞熔断升级。对外部依赖要配的是退避重试和断路器(生态里有现成库),监督树管的是「自己人的猝死」,不是「外面的天灾」。
动手:监督树模拟器
旗舰仪器上线。一位监督者带三个工人,你可以:换三种策略分别杀同一个工人,对比波及范围;连点「连续打击」触发重启强度熔断,看监督者自杀、升级、整棵树被 Application 重建。留意日志里的两个细节:①每次拉起后 pid 都换了新号——重启是「新生」不是「复活」,谁攥着旧 pid 谁扑空(所以第 8 章教你用名字);②时间戳的间隔——从崩溃到拉起毫秒级,真实系统同样如此,用户端几乎无感。
你其实生活在一棵巨型监督树里:App 进程被系统杀掉,用户回来时 Activity 带着 savedInstanceState 重建——「进程随时会死,重建回已知状态」正是 Android 生存法则,只是你是被监督的那一方。BEAM 让你第一次站到主治医生的位置:决定谁跟谁连坐(link)、谁只是旁观(monitor)、哪个子系统值得整体重启(策略)、试几次该放弃(强度)。另外,协程的 supervisorScope/结构化并发借的正是这套思想——「子协程失败不放倒兄弟」,名字都没改。
设计准则:错误内核
会摆树之后,摆什么形状?Erlang 圈的总纲叫错误内核(error kernel):把系统按「必须正确的」和「允许出错的」分成两圈——持有关键状态、逻辑简单到几乎不会崩的进程放内核(树的高处、稳定不重启),复杂易错的业务逻辑推到叶子(树的边缘,崩了随便重启,反正不带贵重物品)。上一章的「GenServer 保护状态,不搬重物」是这条准则的进程版;树的版本是:状态往根走,风险往叶走。评审一棵监督树,就看两件事:哪个节点死了会丢不能丢的东西(它该更靠根、更简单)?哪个节点崩得最频繁(它该更靠叶、被隔离得更彻底)?树的形状,就是你对各部分代码信任度的拓扑图。
树,不只是救火图
最后校准一个视角:监督树不是「出事才用的应急预案」,它是应用的常驻解剖图。启动顺序(叶子先于依赖它的枝)、停机顺序(启动的严格逆序,Supervisor 保证优雅关闭)、资源归属(连接池挂在哪棵子树下)——全部由树形声明。第 11 章打开 :observer,你会在 Applications 页签里亲眼看到这棵树:每个节点是活的进程,每根线是一条 link。到那时,「系统」这个词对你来说将不再是一个抽象名词,而是一棵看得见、点得开、每片叶子都在心跳的树。
- 在模拟器上:同样杀「订单」,三种策略各来一次,记下每种策略的波及名单。
- 真机版:mix new ward --sup 生成带监督树的项目,把上一章的 Counter 挂进 children,iex -S mix 后
Process.whereis(Counter) |> Process.exit(:kill),再Counter.value()——它复活了吗?值还在吗?为什么? - 设计题:缓存、数据库连接池、依赖连接池的订单服务,三者该用什么策略、什么顺序摆进树里?
- 向同事讲清:为什么 try/rescue 包住一切,反而让系统更不可用?