一、遇到问题时的第一反应
写代码最怕什么?不是功能做不出来,而是上线之后半夜被电话吵醒,告诉你服务挂了。你迷迷糊糊打开电脑,日志里全是红色的报错,但你压根不知道从哪看起。尤其是用 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 和结构化日志的接入需要额外配置,初期成本略高。
八、注意事项
- 不要用
rescue处理所有异常。能返回{:error, reason}的就不要去抛异常,这在 Elixir 里是更好的设计。 - 保留原始堆栈信息。
__STACKTRACE__只在你捕获异常的时候才有用,别直接用Exception.message替换掉全部堆栈。 - 处理好
exit信号。如果用Link连接了两个进程,一个进程异常退出会让另一个也退出。要明确哪些进程需要trap_exit。 - 别把
catch_all放在顶层。如果你是库的作者,更不应该接住所有异常,这会让调用方失去控制权。 - 日志里不要打印敏感信息。用户邮箱、密码、token 尽量脱敏。
- 定期演练故障。模拟下游服务挂了,看看你的处理逻辑是否真的行。别等到半夜再发现日志里什么都没有。
九、总结
Elixir 的错误处理并不是“try-catch 到底”那么无聊。它给我们提供了 pattern matching、with、supervisor、Telemetry 这么多好用的工具,目的不是让你躲避错误,而是让你能理直气壮地说:“这个错误我看到了,我知道它在哪,我还能继续干活。”
生产环境是残酷的,但如果你能把“快速失败、记录日志、监控告警、自动恢复”这四个环节做到位,再难搞的问题也只会变成一页告警策略。希望这篇文章能让你在写 Elixir 的时候多一些底气,少一些被半夜电话吵醒的恐惧。
评论
围绕“Elixir错误处理机制优化:生产环境中异常捕获与处理的高效方案”参与讨论