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

Oban cron / queue / retry / discarded / 失败通知

Elixir 编程 · 01 JAN 1970 · 7 min read · 1,132 words
· · ·

Oban 不只是"入队-出队"。它在数据库里维护 Job 状态机,提供 cron 调度、唯一约束、retry 策略、discarded 告警。掌握这些钩子,才能让后台任务真正可靠。

本课目标
学完后,你应该能写出定时 Worker;理解 Job 状态机(available → scheduled → executing → completed / retryable / discarded);用 unique / period 约束任务;写 discarded 通知 Plug;设计失败降级路径。

一、Job 的完整生命周期

Oban 把 Job 当成状态机:

状态含义
available可被 worker 拉取
scheduled有 scheduled_at,未来才执行
executing被 worker 持有
retryable执行失败,等下次重试
completed执行成功
discarded重试次数耗尽,最终失败
cancelled被手动取消

状态迁移由 Oban 内部驱动,但你可以通过 cancel_job/1retry_job/1 等 API 干预。

二、基础 Worker

defmodule MyApp.Workers.RebuildIndex do
  use Oban.Worker,
    queue: :maintenance,
    max_attempts: 3

  @impl Oban.Worker
  def perform(%Oban.Job{args: %{"index" => idx}}) do
    MyApp.Search.rebuild(idx)
    :ok
  end
end

# 入队
%{index: "products"}
|> MyApp.Workers.RebuildIndex.new()
|> Oban.insert()

三、cron:定时任务

Oban 提供 Oban.Workers.Cron,用 crontab 表达式触发:

# mix.exs
{:oban, "~> 2.16"}

# 配置
config :my_app, Oban,
  repo: MyApp.Repo,
  queues: [default: 10, mailers: 5, reports: 2],
  plugins: [
    {Oban.Plugins.Cron,
     crontab: [
       {"0 * * * *", MyApp.Workers.HourlyReport},
       {"*/5 * * * *", MyApp.Workers.PingExternal},
       {"0 3 * * *", MyApp.Workers.NightlyCleanup}
     ]}
  ]

每个 cron Worker 也是普通 Worker,可以读 args:

defmodule MyApp.Workers.HourlyReport do
  use Oban.Worker, queue: :reports, max_attempts: 5

  @impl Oban.Worker
  def perform(%Oban.Job{}) do
    since = DateTime.utc_now() |> DateTime.add(-3600, :second)
    MyApp.Reports.generate(since)
    :ok
  end
end
cron 表达式
Oban 用 crontab 格式(5 字段),不是 Unix 8 字段。* 表示任意;0 3 * * * = 每天 3:00;*/15 * * * * = 每 15 分钟。

四、retry 与 backoff

Oban 默认用指数退避。可以用 backoff 覆盖:

defmodule MyApp.Workers.FlakyCall do
  use Oban.Worker,
    queue: :default,
    max_attempts: 5,
    backoff: fn attempt -> :timer.seconds(2 << attempt) end
  # 2, 4, 8, 16, 32 秒
end

或者用预置策略:

backoff: :timer.seconds(30)

重试的本质:

  • 第一次失败:retryable,scheduled_at = now + backoff。
  • 第二次失败:再 backoff。
  • attempt >= max_attempts:变 discarded

主动放弃可以用 :discard

def perform(%Oban.Job{args: args}) do
  case validate(args) do
    :ok -> do_work(args)
    {:error, reason} -> {:discard, reason}
  end
end
discard vs error
  • {:discard, reason}:停止重试,记入 discarded 队列(可观测)。
  • 抛异常 / {:error, ...}:按 max_attempts 重试。
  • 校验失败的参数重试没有意义,应该 discard

五、唯一约束(unique)

很多时候,同一时刻只能有一个特定任务在跑(比如”重建某个产品的搜索索引”)。

defmodule MyApp.Workers.RebuildProduct do
  use Oban.Worker,
    queue: :maintenance,
    max_attempts: 2,
    unique: [
      period: 60,            # 60 秒内不重复
      fields: [:args],       # 按 args 判断重复
      keys: [&product_id/1], # 提取 args 里的字段
      states: [:available, :scheduled, :executing, :retryable]
    ]

  @impl Oban.Worker
  def perform(%Oban.Job{args: %{"product_id" => id}}) do
    MyApp.Search.rebuild_product(id)
    :ok
  end

  defp product_id(%{"product_id" => id}), do: id
end

再调用 Oban.insert/1,如果 60 秒内有相同 product_id 的 Job 在跑,会返 :invalid

unique 不是幂等键
unique 只防"同时入队",不防"重复消费"。幂等性要靠你的业务逻辑(数据库唯一约束、状态机、idempotency_key)。

六、discarded 监听与告警

discarded Job 是最严重的信号——业务失败了且不打算再试。Oban 提供 Oban.Plugins.Lifeline 救援卡住的 Job,但 discarded 必须主动告警。

方案 A:定期轮询

defmodule MyApp.Janitors.DiscardedAlerter do
  use Oban.Worker,
    queue: :default,
    crontab: "*/15 * * * *"  # 每 15 分钟

  @impl Oban.Worker
  def perform(%Oban.Job{}) do
    %{rows: rows} = Oban.Job
      |> where([j], j.state == "discarded" and j.inserted_at > ago(15, "minute"))
      |> Repo.all()

    if rows != [] do
      MyApp.Alerting.notify("discarded jobs in 15min: \#{length(rows)}", rows)
    end

    :ok
  end
end

方案 B:用 Telemetry

Oban 触发 [:oban, :job, :exception] 等事件,max_attempts 耗尽后进入 discarded。可以在 plugin 配置里挂 hook:

def attach_telemetry do
  :telemetry.attach_many(
    "my-app-oban-alerts",
    [
      [:oban, :job, :exception],
      [:oban, :job, :discard]
    ],
    &__MODULE__.handle_event/4,
    nil
  )
end

def handle_event([:oban, :job, :exception], _m, %{job: job}, _cfg) do
  Logger.warning("Oban exception: \#{job.worker} \#{inspect(job.args)}")
end

def handle_event([:oban, :job, :discard], _m, %{job: job}, _cfg) do
  MyApp.Alerting.notify("Oban job discarded", worker: job.worker, args: job.args)
end

七、队列监控

实战里几个核心指标:

def queue_depth(queue) do
  Repo.aggregate(
    from(j in Oban.Job,
      where: j.state in ["available", "scheduled", "retryable"],
      where: j.queue == ^to_string(queue)),
    :count
  )
end

或者查 Oban 官方提供的 Oban.Peek

%{limit: 10} |> Oban.Peek.call(queue: :default, state: "discarded")

把这些指标推到 Prometheus:

defp emit_metrics do
  for q <- ~w(default reports mailers maintenance) do
    :telemetry.execute([:my_app, :oban, :queue_depth], %{value: queue_depth(q)}, %{queue: q})
  end
end

八、失败降级:DLQ 模式

即使 Oban 帮你重试,最终也会 discarded。对于不能丢的关键任务,需要 DLQ:

# 关键任务的 Worker
defmodule MyApp.Workers.CriticalCharge do
  use Oban.Worker, queue: :payments, max_attempts: 5

  @impl Oban.Worker
  def perform(%Oban.Job{args: args} = job) do
    case MyApp.Payments.charge(args) do
      :ok -> :ok
      {:error, :network} -> {:error, :network}  # 重试
      {:error, :card_invalid} ->
        # 业务上的硬错,重试没意义,但要进 DLQ 不丢弃
        MyApp.Alerting.notify("payment invalid: \#{inspect(args)}", level: :error)
        {:discard, :card_invalid}
    end
  end
end

九、Oban Plugins 实战

  • Pruner:定期清理老 completed Job(默认 60s 跑一次)。
  • Lifeline:把卡住的 executing Job 重新放回 available(rescue_after 默认 60s)。
  • Cron:定时触发 Worker。
  • PromEx:导出 Oban 指标到 Prometheus(社区 plugin)。
plugins: [
  {Oban.Plugins.Pruner, max_age: 60 * 60 * 24 * 7},  # 7 天
  {Oban.Plugins.Lifeline, rescue_after: :timer.minutes(30)},
  {Oban.Plugins.Cron, crontab: [...]}
]
rescue_after 不是超时
Lifeline 是"executing 超过 N 分钟就重新放回队列",不替代 Worker 的 :timeout。生产中两者都要设置。

十、生产清单

  • 每个 Worker 显式设置 queuemax_attempts,不要只写默认值。
  • 关键 Worker 加 unique,但要配合业务幂等键。
  • discarded 必须有告警;不能”日志写一下就完事”。
  • 配置 Lifeline 避免 Job 卡死。
  • 监控每个队列的 depth、discarded 速率、平均执行时长。
  • 部署新版本时确保旧 Job 的 Worker 模块还能加载(不要删模块)。

十一、测验

1

Oban Job 最终失败(重试耗尽)的状态是?

2

Oban.Workers.Cron 的 crontab 是几字段?

3

Worker 返回 {:discard, reason} 会怎样?

4

unique 配置主要防什么?

5

Oban 默认的重试策略是?

6

Oban.Plugins.Lifeline 的作用?

7

discarded Job 的告警应该?

8

部署新版本时,正在排队的旧 Job 需要?

9

unique + 业务幂等键的关系?

10

校验失败的参数应该如何处理?

**下一步:**Oban 让生产任务可靠执行,但要让它长期不出 bug,必须有好的测试金字塔。下一课看 ExUnit / DataCase / Sandbox / Behaviour 替换 / 集成测试。