Cron + 数据库模式:无需队列的定时后台任务
学习 cron + 数据库 模式,在不搭建完整队列系统的情况下,运行带重试、锁定和幂等性的定时后台任务。

问题:在不增加额外基础设施的情况下运行定时工作
大多数应用需要在稍后或按计划执行一些任务:发送后续邮件、运行夜间账单检查、清理旧记录、重建报表,或刷新缓存。
起初,把完整的队列系统加进来很诱人,因为它看起来像是处理后台任务的“正确”方式。但队列引入了额外的可动部件:又一项服务要运行、监控、部署和调试。对于小团队(或独立创始人)来说,这种额外负担会拖慢进度。
真正的问题是:如何在不搭建更多基础设施的情况下,可靠地运行定时任务?
一个常见的初步做法很简单:添加一个 cron 条目去访问某个端点,让那个端点去做工作。它能工作,直到出现问题。一旦你有多台服务器、在错误时间部署,或者某个任务耗时比预期长,你就会看到令人困惑的故障。
定时任务通常以几种可预测的方式出问题:
- 重复执行:两台服务器都执行了相同的任务,导致发出两次发票或两次邮件。\n- 丢失执行:cron 调用在部署时失败,没有人注意到,直到用户抱怨。\n- 无声失败:作业出错一次后再也没有运行,因为没有重试方案。\n- 部分执行:作业半途崩溃,留下不一致的数据状态。\n- 没有审计记录:无法回答“上次什么时候执行?”或“昨晚发生了什么?”。
cron + 数据库 模式是一条折中路径。你仍然使用 cron 来“唤醒”定期运行的进程,但将作业意图和作业状态存储在数据库中,这样系统就能协调、重试并记录发生的事情。
当你只有一个数据库(通常是 PostgreSQL)、作业类型不多、并且希望在最小运维工作下获得可预测行为时,这种方法非常合适。对于使用现代栈快速构建的应用(例如 React + Go + PostgreSQL),它也很自然。
当你需要非常高的吞吐量、必须流式报告进度的长时间运行任务、跨多种作业类型的严格顺序,或大量 fan-out(每分钟成千上万子任务)时,这种方法就不适合。这些情况通常需要真正的队列和专用 worker。
核心思想(通俗版本)
cron + 数据库 模式允许你在不运行完整队列系统的情况下运行定时后台工作。你仍然使用 cron(或任何调度器),但 cron 不负责决定执行什么。它只是频繁唤醒 worker(每分钟一次很常见)。数据库决定哪些工作到期,并确保只有一个 worker 接管每个作业。
把它想象成白板上的共享清单。Cron 是每分钟走进房间并问“现在有人需要做什么吗?”的人。数据库是白板,显示哪些到期、哪些已被领取、哪些已完成。
组成部分很直接:
- 一个频繁触发的调度器。\n- 一个 jobs 表保存“做什么”和“什么时候做”(到期时间),以及状态和尝试计数。\n- 一个或多个 worker 轮询该表,认领作业并执行。\n- 认领使用数据库锁或租约,确保两台 worker 不会抢到同一行。\n- 数据库保持运行记录的单一可信来源,记录成功、失败以及应如何重试。
举例:你想每天早上发送发票提醒,每 10 分钟刷新一次缓存,和夜间清理旧会话。与其为三件事各自写三个 cron(每个都有重叠和失败模式),不如把作业条目统一存放。Cron 启动相同的 worker 进程,worker 问 Postgres:“现在有哪些到期的工作?”Postgres 会安全地让 worker 按次序认领作业。
这能逐步扩展。你可以从一台服务器上的一个 worker 开始,之后扩展到多台服务器上的多个 worker。契约保持不变:表就是契约。
思维方式的变化很小:cron 只是唤醒器,数据库是交通指挥官,决定允许什么运行、记录发生了什么,并在出现问题时提供清晰历史。
设计 jobs 表(实用模式)
这种模式在你把数据库作为“应该运行什么、何时运行以及上次发生了什么”的真相来源时效果最佳。模式(schema)并不复杂,但一些小细节(锁字段与恰当的索引)在负载增长时能省下很多麻烦。
一张表还是两张表?
两种常见做法:
- 单表合并:当你只关心每个作业的最新状态时(简单,少 JOIN)。\n- 双表分离:当你希望清晰区分“这个作业是什么”和“每次运行的记录”时(有更好的历史记录,调试更方便)。
如果你预计会频繁调试失败,保留历史。若想最小化初始设置,先用一张表,之后需要时再加历史表。
实用 schema(双表版本)
下面是一个对 PostgreSQL 友好的布局。如果你用 Go + PostgreSQL 构建,这些列可以很自然映射到结构体。
-- What should exist (the definition)
create table job_definitions (
id bigserial primary key,
job_type text not null,
payload jsonb not null default '{}'::jsonb,
schedule text, -- optional: cron-like text if you store it
max_attempts int not null default 5,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
-- What should run (each run / attempt group)
create table job_runs (
id bigserial primary key,
definition_id bigint references job_definitions(id),
job_type text not null,
payload jsonb not null default '{}'::jsonb,
run_at timestamptz not null,
status text not null, -- queued | running | succeeded | failed | dead
attempts int not null default 0,
max_attempts int not null default 5,
locked_by text,
locked_until timestamptz,
last_error text,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
一些细节可以在后期少走弯路:
- 保持
job_type为短字符串以便路由(例如send_invoice_emails)。\n- 把payload存为jsonb,这样可以在不改迁移的情况下演进字段。\n-run_at是你的“下次到期时间”。Cron(或调度脚本)设置它,worker 消费它。\n-locked_by和locked_until让 worker 在不互相踩踏的情况下认领作业。\n-last_error应该简短且可读。如果需要堆栈信息,放到别处。
建议的索引
没有索引时,worker 会扫描太多数据。至少要有:
- 一个能快速查找到期工作的索引:
(status, run_at)\n- 一个帮助检测过期锁的索引:(locked_until)\n- 可选:针对活跃工作(例如 status 在queued和failed)的部分索引
这些索引能保证“查找下一个可运行作业”的查询在表变大时依然快速。
安全锁定与认领作业
目标很简单:允许多个 worker 并行运行,但同一个具体作业只能被一个 worker 处理。如果两个 worker 同时处理同一行,你就会得到重复邮件、重复扣款或混乱的数据。
一种安全做法是把作业认领视作“租约(lease)”。worker 将作业标记为在短时间内被锁定。如果 worker 崩溃,租约过期后别的 worker 可以接手。这就是 locked_until 的用途。
使用租约以免崩溃导致作业永久阻塞
没有租约的话,worker 可能锁定作业后永不释放(进程被杀、服务器重启、部署异常)。有了 locked_until,时间一到作业变为可用。
一个典型规则是:当 locked_until 为 NULL 或 locked_until <= now() 时,作业可被认领。
用单个原子更新来认领作业
关键细节是用单条语句(或一个事务)完成认领。你希望让数据库做裁判。
下面是一个常见的 PostgreSQL 模式:选取一个到期作业,锁定它,并把它返回给 worker(此示例使用单表 jobs;在 job_runs 上同样适用)。
WITH next_job AS (
SELECT id
FROM jobs
WHERE status = 'queued'
AND run_at <= now()
AND (locked_until IS NULL OR locked_until <= now())
ORDER BY run_at ASC
LIMIT 1
FOR UPDATE SKIP LOCKED
)
UPDATE jobs j
SET status = 'running',
locked_until = now() + interval '2 minutes',
locked_by = $1,
attempts = attempts + 1,
updated_at = now()
FROM next_job
WHERE j.id = next_job.id
RETURNING j.*;
为什么这样可行:
FOR UPDATE SKIP LOCKED允许多个 worker 竞争而不互相阻塞。\n- 租约在认领时设置,其他 worker 在租约过期前会忽略该作业。\n-RETURNING把胜出的那一行交给认领的 worker。
租约应多久,以及如何续期?
把租约设为比正常运行时间长,但又足够短以便崩溃能迅速恢复。如果大多数作业在 10 秒内完成,2 分钟的租约就绰绰有余。
对于长任务,在运行时续期(心跳)。一个简单做法是:每 30 秒扩展一次 locked_until,前提是你仍然拥有该作业。
- 租约长度:通常为典型作业时间的 5 到 20 倍\n- 心跳间隔:租约的 1/4 到 1/2\n- 续期更新应包含
WHERE id = $job_id AND locked_by = $worker_id
最后一个条件很重要。它防止某个 worker 在已不再拥有该作业时续期租约。
可预测的重试与退避策略
重试是让该模式平稳或变得混乱的关键。目标简单:当作业失败时,按可解释、可度量并能停止的方式稍后再试。
先把作业状态显式且有限化:queued、running、succeeded、failed、dead。在实践中,大多数团队把 failed 当作“失败但会重试”,把 dead 当作“失败且放弃”。这个区分防止了无限循环。
尝试计数是第二道护栏。存储 attempts(已尝试次数)和 max_attempts(允许的最大尝试次数)。当 worker 捕获到错误时,它应该:
- 把
attempts加 1\n- 如果attempts < max_attempts就把状态设为failed,否则设为dead\n- 为下一次尝试计算run_at(仅对failed)
退避策略是决定下一次 run_at 的规则。选一种、记录并保持一致:
- 固定延迟:总是等待 1 分钟\n- 指数退避:1m、2m、4m、8m\n- 带上限的指数退避:指数增长但不超过例如 30m\n- 加抖动(jitter):稍微随机化,以免所有作业同时重试
当依赖项宕机并恢复时,抖动很重要。没有抖动,会出现成百上千个作业同时重试并再次失败的情形。
存储足够的错误细节以便故障可见并可调试。你不需要完整的日志系统,但需要基本信息:
last_error(短消息,可在管理界面显示)\n-error_code或error_type(便于分组)\n-failed_at与next_run_at\n- 可选的last_stack(仅在你能控制大小时)
一个行之有效的具体规则:在 10 次尝试后把作业标记为 dead,并对重试使用带抖动的指数退避。这能让短暂性故障得到重试,但防止坏作业不断消耗 CPU。
幂等性:即使作业重复运行也不会出问题
幂等性意味着你的作业可以运行两次且仍产生相同的最终结果。在此模式中很重要,因为同一行可能会在崩溃、超时或重试后被再次处理。如果你的作业是“发送发票邮件”,重复执行就不是无害的。
实际思路是把每个作业分为(1)执行业务逻辑和(2)应用副作用。你希望副作用只发生一次,即使工作被多次尝试。
使用与业务事件绑定的幂等键
幂等键应来自作业所代表的业务事件,而不是 worker 的尝试。好的键稳定且易说明,例如 invoice_id、user_id + day 或 report_name + report_date。如果两个作业尝试指向相同的真实世界事件,它们应共享相同键。
示例:"为 2026-01-14 生成每日销售报表" 可以使用 sales_report:2026-01-14。"对发票 812 扣款" 可以使用 invoice_charge:812。
用数据库约束强制“只做一次”
最简单的护栏是让 PostgreSQL 拒绝重复项。把幂等键存储在可索引的位置,然后加唯一约束。
-- Example: ensure one logical job/effect per business key
ALTER TABLE jobs
ADD COLUMN idempotency_key text;
CREATE UNIQUE INDEX jobs_idempotency_key_uniq
ON jobs (idempotency_key)
WHERE idempotency_key IS NOT NULL;
这能防止存在两个相同键的行。如果你的设计允许保留历史(多行),就把唯一性放在“副作用”表上,例如 sent_emails(idempotency_key) 或 payments(idempotency_key)。
常见需要保护的副作用:
- 邮件:在发送前创建
sent_emails行并加唯一键,或在发送后记录邮件提供商的消息 id。\n- Webhook:存储delivered_webhooks(event_id),若存在则跳过。\n- 支付:总是同时使用支付提供商的幂等特性和你自己的数据库唯一键。\n- 文件写入:先写到临时名再重命名,或存一条按(type, date)键控的file_generated记录。
如果你基于 Postgres 的栈(例如 Go + PostgreSQL),这些唯一性检查快速且易于把逻辑靠近数据层实现。关键思想很简单:重试是常态,重复是可选的。
逐步构建最小 worker 与调度器
选一个普通的运行时并坚持下去。cron + 数据库 模式的要点是更少的可动部件,所以一个小型的 Go、Node 或 Python 进程与 PostgreSQL 通信通常就足够了。
五个小步骤实现它
-
创建表和索引。添加一个
jobs表(以及你日后可能要的查表),然后对run_at建索引,并添加一个帮助 worker 快速查找可用作业的索引(例如(status, run_at))。 -
写一个小小的入队函数。你的应用插入一行,
run_at设为“现在”或将来时间。保持 payload 简洁(ID 和 job type,而不是大块数据)。
INSERT INTO jobs (type, payload, status, run_at, attempts, max_attempts)
VALUES ($1, $2::jsonb, 'queued', $3, 0, 10);
- 实现认领循环。在事务中运行。选出几条到期的作业,锁定它们让其他 worker 跳过,并在同一事务中标记为
running。
WITH picked AS (
SELECT id
FROM jobs
WHERE status = 'queued' AND run_at <= now()
ORDER BY run_at
FOR UPDATE SKIP LOCKED
LIMIT 10
)
UPDATE jobs
SET status = 'running', started_at = now()
WHERE id IN (SELECT id FROM picked)
RETURNING *;
-
处理并结束作业。对每个认领的作业执行工作,然后更新为
done并写入finished_at。若失败,记录错误信息并把它移回queued并设置新的run_at(退避)。把终结更新写得尽量简短,并在进程关机时也尽量运行提交它们。 -
添加可解释的重试规则。用简单公式如
run_at = now() + (attempts^2) * interval '10 seconds',并在到达max_attempts后把状态设为dead。
添加基本可视化
第一天不需要完整仪表盘,但需要足够的信息来发现问题:
- 每个作业记录一行日志:认领、成功、失败、重试、dead。\n- 创建一个简单的管理查询或视图用于“dead 作业”和“运行时间过长的作业”。\n- 对计数设置告警(例如过去一小时内 dead 作业超过 N)。
如果你已经在 Go + PostgreSQL 栈上,这能很好地映射为单个 worker 二进制文件加上 cron。
一个可复制的真实示例
想象一个小型 SaaS 应用,有两类定时工作:
- 每夜清理:删除过期会话和旧的临时文件。\n- 每周给每位用户发送的“活动报告”邮件,每周一早上发送。
保持简单:一张 PostgreSQL 表保存作业,和一个由 cron 每分钟触发的 worker。worker 认领到期作业、运行并记录成功或失败。
哪些事情会被入队,何时入队
你可以从几处入队:
- 每天 02:00:入队一个当天的
cleanup_nightly作业。\n- 用户注册时:入队该用户下次周一要发送的send_weekly_report作业。\n- 在某个事件后(例如“用户点击导出报告”):立即为特定日期范围入队send_weekly_report作业。
payload 只需包含 worker 运行所需的最小信息。保持小巧以便重试时容易处理。
{
"type": "send_weekly_report",
"payload": {
"user_id": 12345,
"date_range": {
"from": "2026-01-01",
"to": "2026-01-07"
}
}
}
幂等性如何防止重复发送
worker 可能在最糟糕的时刻崩溃:在发送邮件之后但在把作业标记为“完成”之前。重启后,可能再次认领同一作业。
为防止重复发送,给工作一个自然的去重键,并把它存到数据库可强制的地方。对于周报,一个好的键是 (user_id, week_start_date)。发送前,worker 记录“我即将发送报表 X”。如果该记录已存在,就跳过发送。
这可以简单地用 sent_reports 表并对 (user_id, week_start_date) 加唯一约束,或在作业本身上使用唯一的 idempotency_key。
失败的样子(以及如何恢复)
假设你的邮件提供方超时。作业失败,worker 会:
- 把
attempts加 1\n- 保存错误信息以便调试\n- 按退避策略安排下一次重试(例如:+1 分钟、+5 分钟、+30 分钟、+2 小时)
如果反复失败超过你的次数上限(例如 10 次),把它标记为 dead 并停止重试。作业要么成功一次,要么按明确计划重试并在最终变为 dead 前保持幂等性保证安全重试。
常见错误与陷阱
cron + 数据库 模式很简单,但小错误会导致重复、作业卡住或意外负载。大多数问题在第一次崩溃、部署或流量突增时出现。
导致重复或卡住的错误
现实中的事故往往来自几个陷阱:
- 在没有租约的情况下从多个 cron 条目运行相同作业。若两台服务器在同一分钟触发,除非你的认领步骤是原子且在同一事务中设置了锁(或租约),否则两者都可能认领相同作业。\n- 忽略
locked_until。如果 worker 在认领后崩溃,那一行可能永远显示“进行中”。租约时间戳让别的 worker 在租约过期后安全接手。\n- 失败时立即重试。当某个 API 宕机时,立即重试只会制造高峰,触发速率限制,并持续失败。总是把下一次尝试安排在未来。\n- 把“至少一次”(at least once)误当成“恰好一次”(exactly once)。作业可能会运行两次(超时、worker 重启、网络问题)。如果两次运行有害,请让副作用具备幂等性。\n- 在作业行里存入超大 payload。大 JSON 会膨胀表、慢索引,并加重锁负担。存引用(如user_id、invoice_id或文件 key),在运行时再取详细数据。
示例:你发送周报发票邮件。若 worker 在发送后超时但未标记作业完成,作业可能被重试并再次发送重复邮件。除非你加上护栏(例如记录按 invoice id 唯一的“邮件已发送”事件),否则这种重复是该模式的正常表现。
不那么明显的注意点
避免在同一个长事务里同时做调度与执行。如果在做网络调用时保持事务打开,会把锁保持得比需要的更久,阻塞其他 worker。
注意机器间的时钟差异。把数据库时间(Postgres 的 NOW())作为 run_at 和 locked_until 的时间来源,而不是应用服务器的时钟。
为作业设置明确的最长运行时间。如果作业可能耗时 30 分钟,就把租约设得比这更长,并在需要时续期,否则另一个 worker 可能在中途接手该作业。
保持 jobs 表健康。如果完成的作业无限积累,查询会变慢并增加锁争用。在表变巨大前设定简单的保留策略(归档或删除旧行)。
快速检查清单与下一步
快速检查清单
在上线此模式前,检查基本要点。这里的小遗漏通常会导致作业卡住、意外重复或 worker 以过快频率访问数据库。
- 你的 jobs 表包含必要字段:
run_at、status、attempts、locked_until、max_attempts(以及last_error或类似字段以便查看发生了什么)。\n- 每个作业在两次运行下都是安全的。如果不确定,添加幂等键或对副作用添加唯一约束(例如每个invoice_id只允许一条发票记录)。\n- 有清晰的查看失败的入口并决定如何处理:查看失败作业、重新运行某个作业或在应停止重试时把它标记为 dead。\n- 你的租约(锁)超时时间对该工作来说是合理的。它应当足够长以覆盖正常运行,但又足够短以便崩溃的 worker 不会阻塞很久。\n- 重试退避是可预测的。它应减缓重复失败,并在达到max_attempts后停止。
如果这些都满足,cron + 数据库 模式通常足以应对真实负载。
下一步
在检查清单通过后,关注日常运行:
- 添加两个小的管理操作:“立即重试”(设置
run_at = now()并清除锁)和“取消”(把状态迁移到终态)。这在事故处理时节省时间。\n- 让 worker 为每个作业记录一行日志:作业类型、作业 id、尝试次数和结果。为异常增长的失败数添加告警。\n- 用真实的突发场景做压力测试:许多作业同时在同一分钟到期。如果认领作业变慢,添加合适的索引(通常是(status, run_at))。
如果你想快速构建这种设置,Koder.ai (koder.ai) 能帮助你从 schema 到部署的 Go + PostgreSQL 应用,减少手工接线,让你专注于锁、重试与幂等性规则。
如果以后你超出该方案的能力范围,你也会清晰地理解作业生命周期,这些思想同样适用于完整的队列系统。
常见问题
什么时候应该用 cron 和数据库处理后台作业?
当你已经有数据库、作业类型不多,又希望无需维护独立队列就能运行定时任务时,可以采用这种模式。它适合发送邮件提醒、清理、生成报告和刷新缓存等任务。
在这种架构中,cron 的作用是什么?
Cron 只应按固定间隔启动工作进程,通常每分钟一次。数据库负责决定哪些作业到期、记录它们的状态,并让一个工作进程领取每个作业。
jobs 表应包含哪些字段?
应存储作业类型、小型 JSON 负载、到期时间、状态、尝试次数、重试上限、锁持有者、锁过期时间以及简短的错误信息。这些字段能让工作进程获得足够信息,以安全地运行和恢复作业。
多个工作进程如何避免运行同一个作业?
在单个数据库事务中通过行锁领取作业,例如 PostgreSQL 的 FOR UPDATE SKIP LOCKED。在工作进程开始实际任务前,将该行更新为运行中并设置其租约。
为什么后台作业需要租约超时?
租约是通过 locked_until 记录的临时锁。如果工作进程崩溃,租约到期后,另一个工作进程可以接手该作业,避免它永远卡住。
失败的作业应如何重试?
按照明确的退避规则稍后重试,例如 1 分钟、2 分钟、4 分钟,之后使用有上限且带少许随机性的延迟。达到允许的尝试次数后,将作业标记为死亡,避免它继续占用工作进程时间。
什么是幂等性,为什么它很重要?
让副作用可安全地重复执行。例如,使用稳定的幂等键,如发票 ID,或用户 ID 加报告日期,然后在 PostgreSQL 或外部服务提供商处强制唯一性。
这种模式能保证恰好一次处理吗?
不能。工作进程可能在发送邮件或调用支付服务提供商后、保存成功状态前崩溃。应按至少一次处理来设计,再用幂等性防止重复副作用。
工作进程执行作业时应一直持有数据库事务吗?
数据库事务应保持简短。先领取作业并提交,在事务外执行网络调用,然后通过单独的更新保存成功或失败状态。长事务会持有锁,并拖慢其他工作进程。
如何监控 cron 和数据库作业?
跟踪死亡作业和长时间运行的作业,记录每次领取和执行结果,并在失败增多时发出告警。添加简单的管理操作,以便立即重试作业、取消作业或查看其最新错误。