You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何处理服务器停机时GenServer中Process.send_after()的计时异常?

问题分析与解决方案

首先直接给你核心结论:当服务器停机时,你的GenServer和它通过Process.send_after设置的计时器都会完全终止,重启后不会自动恢复未触发的任务。因为Process.send_after是绑定到当前Erlang VM进程的,进程一旦退出,所有未触发的定时消息都会被丢弃,这就是你遇到流程失效的根本原因。

接下来给你两种可行的解决思路:

方案一:使用成熟的持久化任务调度库(推荐)

Elixir生态里有专门处理持久化定时任务的库,最常用的是Oban。它会把任务持久化到数据库中,服务器重启后会自动检测并重新调度未执行的任务,完美解决停机丢失的问题。

实现示例

  1. 先在项目中添加Oban依赖并配置好数据库连接(具体步骤可以参考官方文档,这里就不展开了)
  2. 创建一个Worker来封装原来的业务逻辑:
defmodule Statcasters.Workers.DisableQuestionPrediction do
  use Oban.Worker, queue: :scheduled_jobs

  @impl Oban.Worker
  def perform(%Oban.Job{args: %{"question_id" => question_id}}) do
    # 从数据库取出对应的question
    {:ok, question} = Statcasters.Questions.get_question!(question_id)
    Statcasters.Scheduler.disable_question_prediction(question)
    :ok
  end
end
  1. 当需要调度任务时,不再手动启动GenServer,而是直接向Oban插入定时任务:
# 计算从现在到game_time的延迟毫秒数
delay = Timex.diff(question.game_time, DateTime.utc_now(), :milliseconds)

# 插入定时任务,Oban会在指定时间自动执行
Statcasters.Workers.DisableQuestionPrediction.new(
  %{"question_id" => question.id}, 
  schedule_in: delay
)
|> Oban.insert()

这样哪怕服务器中途停机,Oban重启后会自动扫描数据库里未执行的定时任务,重新计算剩余时间并调度,完全不会丢失任务。

方案二:手动实现持久化调度逻辑

如果不想引入第三方库,也可以自己实现任务的持久化逻辑,步骤如下:

具体步骤

  1. 创建任务表:在数据库中新建一个scheduled_tasks表,字段至少包含:任务类型(比如"disable_question_prediction")、参数(存JSON格式,比如%{"question_id" => 123})、执行时间(对应question的game_time)、状态(未执行/已执行)。
  2. 存储任务:当需要调度任务时,不再直接调用Process.send_after,而是把任务信息插入到这个表中。
  3. 实现调度器GenServer:这个GenServer负责定期扫描数据库中的未执行任务,计算剩余时间后调用Process.send_after触发执行:
defmodule Statcasters.Scheduler.Persistent do
  use GenServer

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

  @impl GenServer
  def init(_) do
    # 启动后立即扫描一次,之后每隔1分钟重复扫描
    schedule_scan()
    {:ok, nil}
  end

  @impl GenServer
  def handle_info(:scan_tasks, state) do
    # 查询所有未执行且执行时间未到的任务
    tasks = Statcasters.ScheduledTasks.get_pending_tasks()

    Enum.each(tasks, fn task ->
      delay = Timex.diff(task.execute_at, DateTime.utc_now(), :milliseconds)
      if delay > 0 do
        # 发送延迟消息触发任务执行
        Process.send_after(self(), {:execute_task, task.id}, delay)
      else
        # 如果已经到了执行时间,立即执行
        execute_task(task.id)
      end
    end)

    schedule_scan()
    {:noreply, state}
  end

  @impl GenServer
  def handle_info({:execute_task, task_id}, state) do
    execute_task(task_id)
    {:noreply, state}
  end

  defp schedule_scan do
    # 每隔1分钟扫描一次,可根据业务调整间隔
    Process.send_after(self(), :scan_tasks, 60_000)
  end

  defp execute_task(task_id) do
    # 查询任务、标记为已执行,再执行业务逻辑
    {:ok, task} = Statcasters.ScheduledTasks.get_task!(task_id)
    Statcasters.ScheduledTasks.mark_as_executed(task)

    case task.type do
      "disable_question_prediction" ->
        {:ok, question} = Statcasters.Questions.get_question!(task.params["question_id"])
        Statcasters.Scheduler.disable_question_prediction(question)
    end
  end
end
  1. 加入监督树:把这个调度器GenServer添加到应用的supervision tree中,确保服务器启动时自动运行。

注意事项

  • 业务逻辑最好设计成幂等的:比如任务执行中途服务器停机,重启后可能会再次执行,多次执行结果要一致,避免出现数据异常。
  • 扫描间隔可以根据任务精度调整:如果需要精确到秒,可以缩短扫描间隔(比如10秒一次)。

对你现有代码的小修正

另外看你现有GenServer的代码有两处小问题:

  • start_link没有接收参数,但init期望接收question,启动时init会拿到%{},后续{:ok, question} = question会直接崩溃(map无法匹配{:ok, _}格式)。
  • schedule_work里的时间计算反了:Timex.diff(DateTime.utc_now(), question.game_time, :milliseconds)会得到负数(如果game_time在未来),应该改成Timex.diff(question.game_time, DateTime.utc_now(), :milliseconds),这样得到的才是从现在到game_time的毫秒数。

内容的提问来源于stack exchange,提问作者Bitwise

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.05.15 03:53:40