Learning
VOL. XIII · NO. 20 · Elixir · 01 JAN 1970

OTP 监督树:Application / Supervisor / 条件 child / test 环境

Elixir 编程 · 01 JAN 1970 · 6 min read · 1,084 words
· · ·

OTP 监督树不是"启动一些进程"那么简单:它规定了"哪些进程必须活"、"哪些可以暂时缺"、"哪些只在测试环境出现"。

本课目标
学完后,你应该能写出 Application + Supervisor 的标准结构;选择正确的 child_spec 重启策略;用条件 child 让测试环境不连真实 Redis/数据库;解释"let it crash"的工程含义。

一、什么是 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.exsmod: {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 查找。

十、测验

1

Application 的 start/2 应该返回?

2

:one_for_one 重启策略的含义是?

3

:transient:permanent 的区别?

4

"let it crash" 的真实含义?

5

测试环境通常应该跳过哪个 child?

6

max_restarts: 3, max_seconds: 5 的含义?

7

shutdown: 30_000 的含义?

8

动态 child 的 id 推荐写法?

9

Supervisor.start_child/2 返回 {:error, {:already_started, pid}} 时应该?

10

哪种重启策略适合"先连接数据库,再启动消费者"?

**下一步:**监督树稳定后,下一课看 Finch:进程复用、pool 大小、超时和 SSE 都来自监督树的配置。