OTP 监督树:Application / Supervisor / 条件 child / test 环境
OTP 监督树不是"启动一些进程"那么简单:它规定了"哪些进程必须活"、"哪些可以暂时缺"、"哪些只在测试环境出现"。
一、什么是 Application
OTP Application 不是一个进程,而是一个启动回调 + 一棵监督树:
defmodule MyApp.Application do
use Application
@impl true
def start(_type, _args) do
children = [
MyApp.Repo,
{Phoenix.PubSub, name: MyApp.PubSub},
MyAppWeb.Telemetry,
MyAppWeb.Endpoint
]
opts = [strategy: :one_for_one, name: MyApp.Supervisor]
Supervisor.start_link(children, opts)
end
@impl true
def config_change(changed, _new, removed) do
MyAppWeb.Endpoint.config_change(changed, removed)
:ok
end
end
三个关键点:
- start/2 返回
{:ok, pid}或{:ok, pid, state}。 - config_change/3 在热升级时调用,处理运行时配置变更。
- mix.exs 里
mod: {MyApp.Application, []}才会触发启动。
二、Supervisor 的三种重启策略
| 策略 | 含义 | 典型场景 |
|---|---|---|
| :one_for_one | 一个 child 死了,只重启它 | Repo、缓存、Registry |
| :one_for_all | 一个 child 死了,全部重启 | 一组强耦合的连接 |
| :rest_for_one | 一个 child 死了,它和它后面启动的全部重启 | 先启动连接,再启动消费者 |
:one_for_one。这是最常见的策略,耦合只在进程内部显式表达。三、child_spec 三种写法
# 1. 模块形式(最常见,要求模块实现 start_link/1)
children = [MyApp.Repo, MyAppWeb.Endpoint]
# 2. 元组形式(带 init 参数)
children = [{Phoenix.PubSub, name: MyApp.PubSub, adapter: Phoenix.PubSub.PG2}]
# 3. 显式 child_spec
children = [
%{
id: MyApp.Worker,
start: {MyApp.Worker, :start_link, [[name: MyApp.Worker]]},
restart: :transient,
type: :worker
}
]
id 必须唯一;start 是 MFA;restart 是 :permanent / :transient / :temporary。
- :permanent:死了永远重启(默认)。
- :transient:仅在异常退出时重启,正常退出不重启。
- :temporary:从来不重启。
四、let it crash 的真实含义
“let it crash” 不是”放任崩溃”。它的真实含义是:
让监督进程处理崩溃,而不是在每个函数里写防御性的 try/rescue。原因是 OTP 已经为重启做了正确的事,业务代码不需要重复处理。
# 错误:业务代码里抓 + 重启
def handle_call(:bad, _from, state) do
try do
do_thing()
rescue
_ -> {:stop, :crash, state}
end
end
# 正确:让 GenServer 抛出去
def handle_call(:bad, _from, state) do
do_thing() # 异常会让进程退出,supervisor 重启
end
但所有 IO 操作都应该写。崩溃只用于不可恢复错误,比如数据库连接丢失、第三方 API 关键响应错误。
五、测试环境的条件 child
测试时不该启动 Phoenix endpoint、不该连真实 Redis。最直接的做法是用 Application 环境判断:
def start(_type, _args) do
children = base_children() ++ env_children()
Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
end
defp base_children do
[
MyApp.Repo,
{Phoenix.PubSub, name: MyApp.PubSub},
{Ecto.Migrator, repos: [MyApp.Repo], skip: skip_migrations_for?()}
]
end
defp env_children do
if Application.compile_env(:my_app, :env) == :test do
[]
else
[
MyAppWeb.Endpoint,
MyApp.Cache
]
end
end
defp skip_migrations_for?, do: Application.compile_env(:my_app, :env) == :test
更稳的写法是配置驱动,把启停条件放进 config:
# config/test.exs
config :my_app, MyAppWeb.Endpoint, server: false
# config/runtime.exs
config :my_app, cache: System.get_env("REDIS_URL") != nil
六、热升级、shutdown 与 max_restarts
Supervisor 还有两个经常被忽视的参数:
Supervisor.start_link(children,
strategy: :one_for_one,
max_restarts: 3,
max_seconds: 5,
name: MyApp.Supervisor
)
含义:5 秒内最多重启 3 次,超过则整个 supervisor 崩溃,节点退出。这是为了防止重启风暴掩盖根本问题。
shutdown 字段控制停止子进程的方式:
:brutal_kill:立即杀(适合不响应 terminate 的进程)。timeout: 5000:5 秒内让进程优雅退出,超时则强杀。
{
MyAppWeb.Endpoint,
shutdown: 30_000 # Phoenix 接受请求处理可能慢
}
七、动态 child:start_child / terminate_child
Supervisor 还可以在运行时增减 child:
def add_tenant_worker(tenant_id) do
child_spec = %{
id: {MyApp.TenantWorker, tenant_id},
start: {MyApp.TenantWorker, :start_link, [tenant_id]},
restart: :transient
}
case Supervisor.start_child(MyApp.TenantSupervisor, child_spec) do
{:ok, pid} -> {:ok, pid}
{:error, {:already_started, pid}} -> {:ok, pid}
end
end
def remove_tenant_worker(tenant_id) do
Supervisor.terminate_child(MyApp.TenantSupervisor, {MyApp.TenantWorker, tenant_id})
end
id 必须唯一。常见做法:id: {Mod, identifier},让一个模块的不同实例共存。
八、典型混合监督树
MyApp.Application
├── MyApp.Repo (连接池)
├── MyAppWeb.Telemetry (logger reporter)
├── Phoenix.PubSub (广播)
├── MyAppWeb.Endpoint (HTTP)
├── MyApp.Scheduler (DynamicSupervisor → cron workers)
│ ├── MyApp.Worker.EmailDigest
│ └── MyApp.Worker.HouseKeeping
└── MyApp.CacheSupervisor (Registry + Finch pool)
├── Finch instance
└── Cache.Redis
九、生产清单
- 每个进程一个 child_spec,id 唯一。
- 重启策略要慎重,默认
:transient减少重启风暴。 - test 环境跳过 endpoint / cache / email。
- max_restarts: 3 / 5,避免无限循环。
- shutdown 给足够时间,让 graceful terminate 跑完。
- 动态 child 配 Registry,方便按 id 查找。
十、测验
Application 的 start/2 应该返回?
:one_for_one 重启策略的含义是?
:transient 与 :permanent 的区别?
"let it crash" 的真实含义?
测试环境通常应该跳过哪个 child?
max_restarts: 3, max_seconds: 5 的含义?
shutdown: 30_000 的含义?
动态 child 的 id 推荐写法?
Supervisor.start_child/2 返回 {:error, {:already_started, pid}} 时应该?
哪种重启策略适合"先连接数据库,再启动消费者"?
**下一步:**监督树稳定后,下一课看 Finch:进程复用、pool 大小、超时和 SSE 都来自监督树的配置。