一、遇到问题时的第一反应

写代码最怕什么?不是功能做不出来,而是上线之后半夜被电话吵醒,告诉你服务挂了。你迷迷糊糊打开电脑,日志里全是红色的报错,但你压根不知道从哪看起。尤其是用 Elixir 这种跑在虚拟机上的语言,很多新手会说:不是有“let it crash”的说法吗?那就让它崩呗,反正有 supervisor 重启。

话是没错,但“让它崩”不等于“啥也不管”。生产环境里,一个异常如果没人管,轻则请求失败,重则整个节点的内存被撑爆,最后连重启都救不回来。所以我们需要一套真正能在生产环境落地的错误捕获和处理方案。

二、Elixir 的容错哲学

先别急着写代码,得先理解 Elixir 的核心思路。很多从 Java、Python 转过来的朋友,习惯用 try-catch 包住所有可能出错的地方。但在 Elixir 里,更推荐的做法是“让正确的进程正常工作,让出错的对象快速失败,然后由监督者来处理后事”。

简单说,我们不是要阻止错误发生,而是要把它限制在一个小范围内,不要殃及池鱼。一个 worker 崩了,supervisor 重启它,其他 worker 照样服务,整个应用还活着。这就是 Erlang/OTP 的设计精髓。

但“快速失败”不等于“不用捕获”。对于可以预料的错误,比如参数不合法、数据库暂时连不上,我们还是得用 Elixir 提供的机制去优雅地处理。重点在于:哪些错误需要接管,哪些错误直接抛给上层,这个得靠经验。

三、常用错误处理写法

3.1 try-rescue 和 try-catch 的区别

很多同学分不清 rescue 和 catch。简单说:

  • rescue 是捕获“异常”(Exception),比如 RuntimeError、ArgumentError,这类因为代码逻辑或外部条件导致的错误。
  • catch 是捕获“抛出值”(throw),也可以捕获 :exit 和 :error 这类底层信号,但日常用得少。

看个最基础的例子:


# 技术栈:Elixir

defmodule Demo do
  # 一个可能出错的函数
  def divide(a, b) do
    # 如果 b 是 0,会抛 ArithmeticError
    a / b
  end

  # 用 rescue 捕获异常
  def safe_divide(a, b) do
    try do
      divide(a, b)
    rescue
      ArithmeticError ->
        # 返回一个友好的提示
        {:error, "除数不能为零"}
    end
  end

  # 用 catch 捕获 throw
  def check_age(age) do
    try do
      if age < 18 do
        # 主动抛出一个值
        throw({:underage, age})
      end
      {:ok, "成年"}
    catch
      {:underage, value} -> {:error, "未满18岁: #{value}"}
    end
  end
end

try-rescue 适合处理你预判到的异常。但要注意,rescue 不能捕获 exit 和 throw,它们各有各的用途。

3.2 更常用的 with 语句

在实际业务中,我们经常遇到“先做A,成功再做B,再成功做C”的流程。如果用 try 嵌套,代码会很难看。Elixir 提供了 with,它能把失败逻辑提前短路,非常优雅。


# 技术栈:Elixir

defmodule OrderService do
  # 模拟查询用户
  def find_user(id) do
    if id == 1 do
      {:ok, %{name: "张三", bonus: 100}}
    else
      {:error, :user_not_found}
    end
  end

  # 模拟扣款
  def deduct_bonus(user, amount) do
    if user.bonus >= amount do
      {:ok, Map.update!(user, :bonus, &(&1 - amount))}
    else
      {:error, :insufficient_bonus}
    end
  end

  # 模拟创建订单
  def create_order(user) do
    {:ok, %{order_id: 123, user: user}}
  end

  # 用 with 串联整个流程
  def place_order(user_id, amount) do
    # 如果任何一步返回 {:error, reason},直接返回那个错误
    with {:ok, user} <- find_user(user_id),
         {:ok, user} <- deduct_bonus(user, amount),
         {:ok, order} <- create_order(user) do
      # 全都成功,返回订单
      {:ok, order}
    else
      # 统一处理错误,也可以在这里写成日志
      {:error, reason} -> {:error, reason}
    end
  end
end

# 执行示例(在 IEx 里可以试)
IO.inspect(OrderService.place_order(1, 50))
# 输出 {:ok, %{order_id: 123, user: %{bonus: 50, name: "张三"}}}

IO.inspect(OrderService.place_order(2, 50))
# 输出 {:error, :user_not_found}

with 的 else 分支可以只写你关心的错误,剩下的会自动原样返回。这样代码既清晰又安全。

3.3 谨慎使用 apply 和 catch_all

有些场景下,我们可能想捕获所有类型的错误,比如在做某个第三方接口调用,不想让任何异常穿透出去。这时可以这样写:


# 技术栈:Elixir

defmodule HttpHelper do
  # 模拟一个可能出各种错的请求
  def request(url) do
    if url == "http://bad.example" do
      # 故意抛一个运行时错误
      raise "DNS解析失败"
    else
      {:ok, 200}
    end
  end

  # 捕获所有异常并转为元组返回
  def safe_request(url) do
    try do
      case request(url) do
        {:ok, status} -> {:ok, status}
        error -> error
      end
    rescue
      exception ->
        # 这里记录了堆栈,方便排查
        {:error, Exception.message(exception)}
    catch
      kind, value ->
        # kind 可能是 :throw、:exit、:error
        {:error, {kind, value}}
    end
  end
end

# 测试
IO.inspect(HttpHelper.safe_request("http://good.example"))
IO.inspect(HttpHelper.safe_request("http://bad.example"))

不过要提醒一句:catch_all 是双刃剑。它会掩盖错误,让你以为一切正常。生产环境里最好把捕获到的错误都记下日志,并且设置监控告警。

四、生产环境的高效捕获方案

纸上谈兵结束,现在进入正题,怎么在真实项目里用。

4.1 用 Logger 记录上下文

捕获到异常只解决了一半问题,另一半是可观测性。你要知道某个错误影响了哪个用户、哪个订单。Elixir 的 Logger 也支持带有 metadata,你可以把当前请求的信息塞进去。


# 技术栈:Elixir

defmodule LogHelper do
  require Logger

  def log_error(error, context) do
    # metadata 会出现在日志里,方便用日志系统过滤
    Logger.error("操作失败: #{inspect(error)}",
      user_id: context.user_id,
      action: context.action,
      request_id: context.request_id
    )
  end
end

defmodule BusinessProcess do
  require Logger

  def run(user_id, request_id) do
    # 模拟一个会出错的流程
    result = try do
      # do_something() 或者调用其它模块
      if user_id == 0, do: raise("无效用户"), else: {:ok, user_id}
    rescue
      exception ->
        # 记录上下文
        Logger.error("业务处理异常", user_id: user_id, request_id: request_id, error: Exception.message(exception))
        {:error, :internal_error}
    end

    result
  end
end

有了这些结构化日志,你用 ELK 或者 Loki 就能快速搜出某个用户的所有报错,比看裸堆栈舒爽多了。

4.2 善用 Supervisor 的规格

很多人不知道 child_spec 可以设置重启频率和退出信号。你可以根据业务风险调整阈值。


# 技术栈:Elixir

defmodule MyApp.Worker do
  use GenServer

  # 定义一个 child_spec,限制 10 秒内最多重启 3 次
  def child_spec(arg) do
    %{
      id: __MODULE__,
      start: {__MODULE__, :start_link, [arg]},
      restart: :permanent,
      shutdown: 5_000,
      type: :worker
    }
  end

  def start_link(arg) do
    GenServer.start_link(__MODULE__, arg, name: __MODULE__)
  end

  def handle_call(:crash, _from, state) do
    # 故意抛错
    raise "worker崩溃"
  end

  @impl true
  def init(arg) do
    {:ok, arg}
  end
end

# 把 worker 挂到 supervisor 时,可以设置 intensity 和 period
children = [
  {MyApp.Worker, %{}}
]

supervisor_options = [
  strategy: :one_for_one,
  max_restarts: 3,
  max_seconds: 10
]

如果业务允许,你也可以用 :rest_for_one 策略,让崩溃的 worker 之后的其它 worker 一起重启,避免它们状态不一致。

4.3 用 Telemetry 做事件埋点

Telemetry 是 Elixir 生态里的标准事件库。我们可以把错误变成一种事件,然后在统计面板里看趋势。


# 技术栈:Elixir

defmodule ErrorMonitor do
  require Logger

  # 在应用启动时,订阅所有 :my_app, :error 事件
  def setup do
    :telemetry.attach(
      "error-monitor-handler",
      [:my_app, :error],
      &__MODULE__.handle_event/4,
      nil
    )
  end

  def handle_event(_event_name, measurements, metadata, _config) do
    Logger.error("错误事件", measurements, metadata)
  end
end

# 在业务代码里发布事件
defmodule OrderProcess do
  def process(order) do
    try do
      # 业务逻辑...
      {:ok, order}
    rescue
      exception ->
        :telemetry.execute([:my_app, :error], %{count: 1}, %{
          exception: Exception.message(exception),
          order_id: order.id,
          stacktrace: __STACKTRACE__
        })
        {:error, :failed}
    end
  end
end

这样你可以在 Grafana 上显示每分钟错误数,和请求量对比,系统健不健康一眼就看出来了。

五、一个完整的生产示例

下面我们写一个模拟用户注册的服务,把前面讲的点都串起来。这个服务会检查邮箱格式、检查数据库是否已有用户、发送欢迎邮件。每一步都可能失败,我们用一个比较合理的结构来组织。


# 技术栈:Elixir

defmodule UserRegistration do
  require Logger

  # 对外入口,传入用户参数和请求ID
  def register(name, email, request_id) do
    context = %{name: name, email: email, request_id: request_id}

    # 用 with 处理主要的业务异常
    result =
      with {:ok, email} <- validate_email(email),
           {:ok, user} <- create_user(name, email, context),
           {:ok, _mail} <- send_welcome_email(user, context) do
        {:ok, user}
      else
        # 在这里统一处理业务型错误
        {:error, reason} ->
          # 记录业务错误(带有上下文)
          Logger.warn("注册流程失败: #{inspect(reason)}", Map.merge(context, %{reason: reason}))
          {:error, reason}
      end

    # 同时捕获意外异常(比如数据库连接池崩了)
    case result do
      {:ok, _} = ok -> ok
      {:error, reason} ->
        # 这里可以决定是否需要告警
        if reason in [:invalid_email, :email_taken] do
          # 用户输入问题,不需要疯狂告警
          {:error, reason}
        else
          # 系统内部问题,记录为错误,并且触发指标
          :telemetry.execute([:my_app, :registration, :error], %{count: 1}, %{request_id: request_id, reason: reason})
          {:error, :internal_error}
        end
    end
  end

  # 校验邮箱格式(简单的正则)
  defp validate_email(email) do
    # 用正则检查是否含有 @ 符号
    if Regex.match?(~r/^[^@\s]+@[^@\s]+$/, email) do
      {:ok, email}
    else
      {:error, :invalid_email}
    end
  end

  # 模拟创建用户,检查是否已存在
  defp create_user(name, email, _context) do
    # 假设内存里有个集合存已有邮箱
    existing = MapSet.new(["test@example.com"])

    if MapSet.member?(existing, email) do
      {:error, :email_taken}
    else
      user = %{id: System.unique_integer([:positive]), name: name, email: email}
      {:ok, user}
    end
  end

  # 模拟发送邮件
  defp send_welcome_email(user, _context) do
    # 这里如果调外部API,可能超时,这里模拟一个随机失败
    if :rand.uniform(5) == 1 do
      # 抛一个意外错误,比如HTTP连接超时
      raise "SMTP服务超时"
    else
      {:ok, %{mail_id: 999}}
    end
  end
end

# 测试代码
for i <- 1..10 do
  result = UserRegistration.register("用户#{i}", "user#{i}@example.com", "req_#{i}")
  IO.inspect(result, label: "第 #{i} 次")
end

这个示例里,validate_email 和 create_user 返回 {:error, reason},所以用 with 处理很舒服。而 send_welcome_email 可能抛异常,我故意没有用 rescue 包住它,让外层 case 不会捕获异常,异常会继续往外抛。等等,这不对,如果 send_welcome_email 抛异常,整个函数会崩。实际生产中我们应该用 try-rescue 把可能抛异常的外部调用包住。让我修正一下:


# 技术栈:Elixir

defmodule UserRegistration do
  require Logger

  def register(name, email, request_id) do
    context = %{name: name, email: email, request_id: request_id}

    # 用 try 把可能抛异常的外部调用包住
    try do
      with {:ok, email} <- validate_email(email),
           {:ok, user} <- create_user(name, email, context),
           {:ok, mail} <- send_welcome_email(user, context) do
        {:ok, user, mail}
      else
        {:error, reason} ->
          Logger.warn("业务流程失败", Map.merge(context, %{reason: reason}))
          {:error, reason}
      end
    rescue
      exception ->
        # 意外异常统一处理
        Logger.error("意外异常: #{Exception.message(exception)}", Map.merge(context, %{stacktrace: __STACKTRACE__}))

        # 发布指标
        :telemetry.execute([:my_app, :registration, :error], %{count: 1}, %{
          request_id: request_id,
          error: Exception.message(exception)
        })

        {:error, :internal_error}
    end
  end

  defp validate_email(email) do
    if email =~ ~r/@/ do
      {:ok, email}
    else
      {:error, :invalid_email}
    end
  end

  defp create_user(name, email, _context) do
    existing = MapSet.new(["taken@example.com"])

    if MapSet.member?(existing, email) do
      {:error, :email_taken}
    else
      {:ok, %{id: System.unique_integer([:positive]), name: name, email: email}}
    end
  end

  defp send_welcome_email(user, _context) do
    # 模拟随机失败
    if :rand.uniform(5) == 1 do
      raise "SMTP连接超时"
    end
    {:ok, %{mail_id: 123}}
  end
end

# 测试运行
Enum.each(1..10, fn i ->
  result = UserRegistration.register("用户#{i}", "user#{i}@example.com", "#req_#{i}")
  IO.inspect(result, label: "第 #{i} 次注册")
end)

这样外部的异常被 rescue 拦住了,内部的业务错误由 with 的 else 处理。两层结构很清楚。

六、应用场景

  • 第三方接口调用:比如支付回调、短信服务,超时或返回异常需要兜底。
  • 数据库和高频消息处理:连接池耗尽、集群故障,不能因为一条坏数据把整个消费者拖死。
  • 后台任务批处理:比如每天跑定时任务,如果任务崩溃,要确保错误被记录,并且不影响其它任务。
  • Web API:用户请求带来的参数错误和数据库异常需要用不同的策略处理——前者直接返回 400,后者返回 500 并告警。
  • 核心账务系统:不能有裸奔的异常,每一步都要有检查点和恢复机制。

七、技术优缺点分析

7.1 优点

  • 容错能力强:Erlang VM 的隔离机制让进程崩溃成本极低,配合 supervisor 自动重启,绝大多数问题都不需要人工介入。
  • 可组合性好:with 让多步流程的错误传播清晰明了。
  • 日志自带上下文:配合 metadata 和 Telemetry,生产排查问题的速度非常快。
  • 语言层面支持“规范回复”模式:{:ok, _} / {:error, _} 已经成为 Elixir 社区的事实标准,阅读代码几乎没有认知负担。

7.2 缺点

  • 学习曲线陡峭:rescue、catch、throw、exit 四种机制容易让人晕,新手容易滥用。
  • 过度使用 try-rescue 会破坏函数式风格,让代码变啰嗦。
  • 完全依赖 supervisor 也可能掩盖问题:如果重启频率很高,反而会疯狂重启占满 CPU。需要调好 max_restarts。
  • Telemetry 和结构化日志的接入需要额外配置,初期成本略高。

八、注意事项

  1. 不要用 rescue 处理所有异常。能返回 {:error, reason} 的就不要去抛异常,这在 Elixir 里是更好的设计。
  2. 保留原始堆栈信息。__STACKTRACE__ 只在你捕获异常的时候才有用,别直接用 Exception.message 替换掉全部堆栈。
  3. 处理好 exit 信号。如果用 Link 连接了两个进程,一个进程异常退出会让另一个也退出。要明确哪些进程需要 trap_exit。
  4. 别把 catch_all 放在顶层。如果你是库的作者,更不应该接住所有异常,这会让调用方失去控制权。
  5. 日志里不要打印敏感信息。用户邮箱、密码、token 尽量脱敏。
  6. 定期演练故障。模拟下游服务挂了,看看你的处理逻辑是否真的行。别等到半夜再发现日志里什么都没有。

九、总结

Elixir 的错误处理并不是“try-catch 到底”那么无聊。它给我们提供了 pattern matching、with、supervisor、Telemetry 这么多好用的工具,目的不是让你躲避错误,而是让你能理直气壮地说:“这个错误我看到了,我知道它在哪,我还能继续干活。”

生产环境是残酷的,但如果你能把“快速失败、记录日志、监控告警、自动恢复”这四个环节做到位,再难搞的问题也只会变成一页告警策略。希望这篇文章能让你在写 Elixir 的时候多一些底气,少一些被半夜电话吵醒的恐惧。