在不被 cron 折磨的情况下调度后台作业
学习使用工作流与 jobs 表调度后台作业的模式,以可靠地运行提醒、每日摘要和清理任务,避免 cron 带来的常见问题。

为什么 cron 看起来简单,直到它不再简单
Cron 在第一天看起来很棒:写一行,选个时间,然后忘了它。对于一台服务器和一项任务,它通常能运行良好。
问题在于当你依赖调度来驱动真实产品行为时:提醒、每日摘要、清理或同步作业。大多数“错过运行”的故事并不是 cron 自身出错,而是围绕它的各种问题:服务器重启、部署覆盖了 crontab、某个作业运行比预期久,或者时钟/时区不同步。一旦你运行多个应用实例,就可能出现相反的失败模式:重复运行,因为两台机器都认为自己应该运行相同任务。
测试也是薄弱环节。一个 cron 行并不能提供一个干净的方法来在可重复的测试中运行“明天上午 9:00 会发生什么”。因此调度演变成手动检查、生产环境惊喜和日志排查。
在选择方案前,先弄清你要调度什么。大多数后台工作落入几个常见类别:
- 提醒(在特定时间发送,且只发送一次)
- 每日摘要(先聚合数据,然后发送)
- 清理任务(删除、归档、过期)
- 周期性同步(拉取或推送更新)
有时你可以完全跳过调度。如果一件事可以在事件发生时立即处理(用户注册、支付成功、工单状态改变),事件驱动通常比基于时间的方式更简单、更可靠。
当你确实需要基于时间执行时,可靠性主要归结为可视性和控制。你需要一个地方记录应该运行的内容、已运行的内容和失败的内容,并且需要一种安全的重试方式而不会产生重复。
基本模式:调度器、jobs 表、工作器
避免 cron 麻烦的简单方法是分离职责:
- 调度器决定什么应该在何时运行。
- 工作器负责实际执行。
把这些角色分开有两个好处:你可以不动业务逻辑就改变执行时间,也可以不动调度就更改业务逻辑。
一个 jobs 表成为事实来源。不要把状态藏在服务器进程或 cron 行里,每一项工作都是一行:要做什么、对象是谁、什么时候运行、上次发生了什么。出现问题时,你可以检查、重试或取消,而不必猜测。
典型流程如下:
- 调度器扫描到期的作业(例如
run_at <= now且status = queued)。 - 它声明(claim)一个作业,确保只有一个工作器拿到它。
- 工作器读取作业细节并执行动作。
- 工作器将结果记录回同一行。
关键理念是让工作可恢复,而不是神秘化。如果工作器在中途崩溃,作业行仍应告诉你发生了什么以及下一步该做什么。
设计一个有用的 jobs 表
jobs 表应能快速回答两个问题:下一步需要运行什么,以及上次发生了什么。
从一组少量字段开始,覆盖标识、时间和进度:
- id, type:唯一 id 和简短类型,如
send_reminder或daily_summary。 - payload:只包含工作器需要的经校验 JSON(比如
user_id,而不是整个用户对象)。 - run_at:作业变为可运行的时间。
- status:
queued、running、succeeded、failed、canceled。 - attempts:每次尝试递增。
然后添加一些运维列,以便并发安全和便于处理事件。locked_at、locked_by 和 locked_until 允许一个工作器声明作业,避免重复执行。last_error 应该是一个简短信息(可选错误码),而不是把完整堆栈往行里塞,这会膨胀行大小。
最后保留一些时间戳便于支持与报表:created_at、updated_at 和 finished_at。这些能让你快速回答“今天有多少提醒失败?”之类的问题,而不用翻日志。
索引很重要,因为系统不断问“下一个是什么?”通常有两个索引收益最大:
(status, run_at)用于快速获取到期作业(type, status)用于检查或在出现问题时暂停某类作业
对于 payload,优先使用小而聚焦的 JSON,并在插入前校验。存识别符和参数,而不是业务数据快照。把 payload 形状当成 API 合约,这样老的队列作业在应用改动后仍能运行。
作业生命周期:状态、锁和幂等性
当每个作业遵循一个小而可预测的生命周期时,作业运行器才可靠。这个生命周期是在两台工作器几乎同时启动、服务器中途重启或需要无重复重试时的安全网。
一个简单的状态机通常就足够了:
- queued:在
run_at到达或之后准备运行 - running:已被工作器声明
- succeeded:完成,不应再运行
- failed:出错结束,需要关注
- canceled:被有意停止(例如用户退订)
在不重复工作的前提下声明作业
为了防止重复,声明作业必须是原子操作。常见做法是带超时的锁(租约):工作器通过设置 status=running 并写入 locked_by 与 locked_until 来声明作业。如果工作器崩溃,锁到期后其他工作器可再次声明它。
一个实用的声明规则集:
- 只声明
run_at <= now的queued作业 - 在同一次更新中设置
status、locked_by和locked_until - 仅当
locked_until < now时才可回收处于running状态的作业 - 将租期设短,并在作业运行时间较长时续租
幂等性(能救你的习惯)
幂等性意味着:同一项作业运行两次,结果仍然正确。
最简单的工具是唯一键。例如,对于每日摘要,你可以通过 summary:user123:2026-01-25 这样的键来强制每天每个用户只有一个作业。如果发生重复插入,它会指向同一行而不是创建第二个作业。
只有在副作用真正完成(邮件发送、记录更新)时才标记成功。如果需要重试,重试路径不得产生第二封邮件或重复写入。
无戏剧化的重试与失败处理
重试是作业系统要么变得可靠、要么变成噪音的地方。目标很直接:在失败很可能是临时的情况下重试,在不是临时的情况下停止。
一个默认重试策略通常包括:
- 最大尝试次数(例如最多 5 次)
- 延迟策略(固定延迟或指数退避)
- 停止条件(不要重试“无效输入”之类的错误)
- 抖动(小的随机偏移以避免重试峰值)
与其为重试发明新状态,通常可以复用 queued:把 run_at 设置为下一次尝试时间并把作业放回队列。这样状态机更小。
当作业可能有部分进度时,把这当作正常情况。存储检查点以便重试可以安全继续,检查点可以放在 job payload(如 last_processed_id)或相关表中。
示例:一个每日摘要作业需要为 500 位用户生成消息。如果它在处理到第 320 位用户时失败,记录最后成功的用户 ID,并从 321 继续重试。如果你也为每个用户每天存了 summary_sent 记录,重新运行时可以跳过已完成的用户。
真正有用的日志
记录足够的信息以便在几分钟内调试:
- 作业 id、类型和尝试次数
- 关键输入(user/team id、日期范围)
- 时间(started_at、finished_at、下一次运行时间)
- 简短错误摘要(如果有再附堆栈)
- 副作用计数(发送邮件数、更新的行数)
逐步构建:简单的调度循环
调度循环是一个小进程,按固定频率唤醒,查找到期工作并交付执行。目标是平淡可靠,而不是完美时钟。对许多应用来说,“每分钟唤醒一次”就够了。
根据作业对时间敏感程度和数据库承载能力选择唤醒频率。如果提醒需要近实时,选择每 30 到 60 秒唤醒一次。如果每日摘要允许有一定漂移,5 分钟一次既便宜又够用。
一个简单循环:
- 唤醒并获取当前时间(使用 UTC)。
- 选择
status = 'queued'且run_at <= now的到期作业。 - 安全声明作业,确保只有一个工作器能拿到它们。
- 将声明了的每个作业交给工作器。
- 睡眠直到下一次唤醒。
声明步骤是许多系统出问题的地方。你希望在选择作业的同一事务中将其标记为 running(并存储 locked_by 与 locked_until)。许多数据库支持 “skip locked” 读取,以便多个调度器并行运行而不会互相干扰。
-- concept example
BEGIN;
SELECT id FROM jobs
WHERE status='queued' AND run_at <= NOW()
ORDER BY run_at
LIMIT 100
FOR UPDATE SKIP LOCKED;
UPDATE jobs
SET status='running', locked_until=NOW() + INTERVAL '5 minutes'
WHERE id IN (...);
COMMIT;
将批量大小保持在较小范围(如 50 到 200)。更大的批次会拖慢数据库并让崩溃代价更高。
如果调度器在处理中途崩溃,租约会救你。处于 running 的作业在 locked_until 到期后再次变为可领取。你的工作器应做到幂等,以便被回收的作业不会造成重复邮件或重复扣款。
针对提醒、每日摘要和清理的模式
大多数团队最终会遇到三类后台工作:需要按时发送的消息、按计划运行的报告,以及保持存储与性能健康的清理。相同的 jobs 表和工作器循环可以处理它们。
提醒
对于提醒,把发送消息所需的一切都存入作业行:目标对象、渠道(email、SMS、Telegram、应用内)、使用的模板和精确发送时间。工作器应该能在无需“到处查找”额外上下文的情况下运行该作业。
如果很多提醒在同一时间到期,需要做速率限制。为每个渠道设定每分钟上限,让超出的作业等待下一次运行。
每日摘要
每日摘要失败的原因往往是时间窗口模糊。选定一个稳定的截止时间(例如用户本地时间的 08:00),并明确定义窗口(例如“昨天 08:00 到今天 08:00”)。在作业中存储截止时间和用户时区,这样重跑时结果一致。
让每个摘要作业保持小颗粒。如果需要处理成千上万条记录,拆分为多个子任务(按团队、按账号或按 ID 范围)并入队后续作业。
清理任务
把“删除”与“归档”分开会更安全。决定哪些可以永久删除(临时令牌、过期会话),哪些应该归档(审计日志、发票)。以可预测的小批量运行清理,避免长时间锁定和突发负载。
时间与时区:错误的隐蔽来源
许多故障来自时间:提醒提前一小时发出、每日摘要漏掉周一或清理运行了两次。
一个好的默认做法是以 UTC 存储调度时间,并单独存储用户时区。你的 run_at 应该是一个 UTC 时刻。当用户说“我的时间早上 9:00”时,在调度时把它转换成 UTC。
夏令时是容易出错的地方。“每天 9:00”并不等同于“每隔 24 小时”。在 DST 切换时,9:00 对应的 UTC 时间会变,有些本地时间不存在(春季向前跳)或出现两次(秋季回拨)。更安全的做法是每次重新计算下一个本地出现时间,然后再转换成 UTC。
对于每日摘要,先决定“一天”是什么意思再写代码。日历日(用户时区的午夜到午夜)符合人的预期。“过去 24 小时”更简单但会漂移并带来惊讶。
晚到的数据不可避免:事件在重试后到达,或在午夜后几分钟添加了备注。决定延迟事件应该归入“昨天”(并设宽限期)还是“今天”,并保持一致。
一个实用的缓冲可以防止漏掉:
- 扫描到期作业时包括过去 2 到 5 分钟内的作业
- 使作业幂等以保证重跑安全
- 在 payload 中记录已覆盖的时间范围以保持摘要一致
导致错过或重复运行的常见错误
大多数痛点来自几个可预测的假设。
最大的问题是假设“恰好执行一次”。在真实系统中,工作器会重启、网络调用会超时、锁会丢失。你通常得到的是“至少一次”投递,这意味着重复是正常的,你的代码必须能容忍它们。
另一点是先执行副作用(发送邮件、扣款)而没有去重检查。一个简单的防护通常能解决:sent_at 时间戳、像 (user_id, reminder_type, date) 这样的唯一键,或存一个去重令牌。
可视性是下一个缺口。如果你不能回答“什么卡住了、从什么时候卡住、为什么卡住”,你就会一直猜测。最低限度要随手保留的信息是状态、尝试次数、下一次计划时间、最后错误和工作器 id。
最常见的错误包括:
- 按照只运行一次来设计作业,然后被重复吓到
- 在未做去重检查的情况下执行副作用
- 把所有事情都塞进一个超大的作业,结果中途超时
- 无上限地重试
- 缺少基本队列可视性(没有清晰的积压、失败、长时运行项视图)
一个具体例子:一个每日摘要作业对 50,000 个用户循环,在处理到第 20,000 个用户时超时。在重试时它从头开始,前 20,000 个用户会再次收到摘要,除非你追踪每个用户的完成情况或把任务拆成按用户的子作业。
可靠作业系统的快速清单
作业运行器只有在你能在凌晨 2 点信赖它时才算“完成”。
确保你拥有:
- 队列可视性: 队列、运行中、失败的计数,以及最早的队列作业时间。
- 默认幂等: 假设每个作业可能运行两次;使用唯一键或“已处理”标记。
- 按作业类型的重试策略: 重试次数、退避策略和明确的停止条件。
- 一致的时间存储: 将
run_at保持为 UTC;仅在输入和展示时转换。 - 可恢复的锁: 使用租约,防止崩溃导致作业永远被标记为运行中。
同时限制 批量大小(一次声明多少作业)和 工作器并发数(同时运行多少个作业)。没有这些限制,一次流量峰值就可能压垮数据库或挤占其他工作。
一个现实示例:小团队的提醒与摘要
一家小型 SaaS 有 30 个客户账号。每个账号要两件事:每天 9:00 针对未完成任务发送提醒,和每天 18:00 发送一份当日变更的日报。他们还需要每周清理,防止数据库被旧日志和过期令牌填满。
他们使用 jobs 表和一个轮询到期作业的工作器。当新客户注册时,后端根据客户时区调度首次提醒和摘要运行时间。
作业通常在几个时刻被创建:注册时(创建周期性计划)、特定事件发生时(入队一次性通知)、调度 tick 时(插入即将运行的任务)、以及维护日(入队清理)。
某个星期二,邮件服务在 8:59 暂时不可用。工作器尝试发送提醒超时,并通过将 run_at 按退避策略重设(例如 2 分钟、然后 10 分钟、然后 30 分钟)来重新安排这些作业,同时每次递增 attempts。因为每个提醒作业都有像 account_id + date + job_type 的幂等键,即使服务在中途恢复,重试也不会产生重复。
清理以小批量每周运行,因此不会阻塞其他工作。不是一次删除百万行,而是每次删除最多 N 行并在未完成时重新排队自己直到完成。
当有客户投诉“我没收到摘要”时,团队只需检查该账号当天的 jobs 表:作业状态、尝试次数、当前锁字段以及提供方返回的最后错误。这样就能把“应该发送了”变成“这就是确切发生的情况”。
下一步:实现、观察,然后扩展
先挑一种作业类型端到端实现再扩展。一个单一的提醒作业是良好的入门,因为它涵盖所有内容:调度、声明到期工作、发送消息并记录结果。
从一个你能信赖的版本开始:
- 创建 jobs 表并实现一个处理单一作业类型的工作器
- 添加一个声明并运行到期作业的调度循环
- 存储足够的 payload 以便在不猜测的情况下运行作业
- 记录每次尝试和结果,让“它运行了吗?”变成 10 秒内能回答的问题
- 添加手动重跑路径以便恢复不需要部署
它运行后,让它对人可观测。即使是一个基础的管理视图也很快有回报:按状态搜索作业、按时间过滤、检查 payload、取消卡住的作业、重跑特定作业 id。
如果你更愿意用可视化后端逻辑来构建这种调度器与工作器流程,AppMaster (appmaster.io) 可以在 PostgreSQL 中建模 jobs 表,并将 claim-process-update 循环实现为 Business Process,同时仍然为部署生成真实的源代码。


