如何处理服务器停机时GenServer中Process.send_after()的计时异常?
问题分析与解决方案
首先直接给你核心结论:当服务器停机时,你的GenServer和它通过Process.send_after设置的计时器都会完全终止,重启后不会自动恢复未触发的任务。因为Process.send_after是绑定到当前Erlang VM进程的,进程一旦退出,所有未触发的定时消息都会被丢弃,这就是你遇到流程失效的根本原因。
接下来给你两种可行的解决思路:
方案一:使用成熟的持久化任务调度库(推荐)
Elixir生态里有专门处理持久化定时任务的库,最常用的是Oban。它会把任务持久化到数据库中,服务器重启后会自动检测并重新调度未执行的任务,完美解决停机丢失的问题。
实现示例
- 先在项目中添加Oban依赖并配置好数据库连接(具体步骤可以参考官方文档,这里就不展开了)
- 创建一个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
- 当需要调度任务时,不再手动启动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重启后会自动扫描数据库里未执行的定时任务,重新计算剩余时间并调度,完全不会丢失任务。
方案二:手动实现持久化调度逻辑
如果不想引入第三方库,也可以自己实现任务的持久化逻辑,步骤如下:
具体步骤
- 创建任务表:在数据库中新建一个
scheduled_tasks表,字段至少包含:任务类型(比如"disable_question_prediction")、参数(存JSON格式,比如%{"question_id" => 123})、执行时间(对应question的game_time)、状态(未执行/已执行)。 - 存储任务:当需要调度任务时,不再直接调用
Process.send_after,而是把任务信息插入到这个表中。 - 实现调度器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
- 加入监督树:把这个调度器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
相关产品推荐
相关产品推荐

