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

大多数应用需要在稍后或按计划执行一些任务:发送后续邮件、运行夜间账单检查、清理旧记录、重建报表,或刷新缓存。
起初,把完整的队列系统加进来很诱人,因为它看起来像是处理后台任务的“正确”方式。但队列引入了额外的可动部件:又一项服务要运行、监控、部署和调试。对于小团队(或独立创始人)来说,这种额外负担会拖慢进度。
真正的问题是:如何在不搭建更多基础设施的情况下,可靠地运行定时任务?
一个常见的初步做法很简单:添加一个 cron 条目去访问某个端点,让那个端点去做工作。它能工作,直到出现问题。一旦你有多台服务器、在错误时间部署,或者某个任务耗时比预期长,你就会看到令人困惑的故障。
定时任务通常以几种可预测的方式出问题:
cron + 数据库 模式是一条折中路径。你仍然使用 cron 来“唤醒”定期运行的进程,但将作业意图和作业状态存储在数据库中,这样系统就能协调、重试并记录发生的事情。
当你只有一个数据库(通常是 PostgreSQL)、作业类型不多、并且希望在最小运维工作下获得可预测行为时,这种方法非常合适。对于使用现代栈快速构建的应用(例如 React + Go + PostgreSQL),它也很自然。
当你需要非常高的吞吐量、必须流式报告进度的长时间运行任务、跨多种作业类型的严格顺序,或大量 fan-out(每分钟成千上万子任务)时,这种方法就不适合。这些情况通常需要真正的队列和专用 worker。
cron + 数据库 模式允许你在不运行完整队列系统的情况下运行定时后台工作。你仍然使用 cron(或任何调度器),但 cron 不负责决定执行什么。它只是频繁唤醒 worker(每分钟一次很常见)。数据库决定哪些工作到期,并确保只有一个 worker 接管每个作业。
把它想象成白板上的共享清单。Cron 是每分钟走进房间并问“现在有人需要做什么吗?”的人。数据库是白板,显示哪些到期、哪些已被领取、哪些已完成。
组成部分很直接:
举例:你想每天早上发送发票提醒,每 10 分钟刷新一次缓存,和夜间清理旧会话。与其为三件事各自写三个 cron(每个都有重叠和失败模式),不如把作业条目统一存放。Cron 启动相同的 worker 进程,worker 问 Postgres:“现在有哪些到期的工作?”Postgres 会安全地让 worker 按次序认领作业。
这能逐步扩展。你可以从一台服务器上的一个 worker 开始,之后扩展到多台服务器上的多个 worker。契约保持不变:表就是契约。
思维方式的变化很小:cron 只是唤醒器,数据库是交通指挥官,决定允许什么运行、记录发生了什么,并在出现问题时提供清晰历史。
这种模式在你把数据库作为“应该运行什么、何时运行以及上次发生了什么”的真相来源时效果最佳。模式(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,前提是你仍然拥有该作业。
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 的规则。选一种、记录并保持一致:
当依赖项宕机并恢复时,抖动很重要。没有抖动,会出现成百上千个作业同时重试并再次失败的情形。
存储足够的错误细节以便故障可见并可调试。你不需要完整的日志系统,但需要基本信息:
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),这些唯一性检查快速且易于把逻辑靠近数据层实现。关键思想很简单:重试是常态,重复是可选的。
选一个普通的运行时并坚持下去。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);
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。
第一天不需要完整仪表盘,但需要足够的信息来发现问题:
如果你已经在 Go + PostgreSQL 栈上,这能很好地映射为单个 worker 二进制文件加上 cron。
想象一个小型 SaaS 应用,有两类定时工作:
保持简单:一张 PostgreSQL 表保存作业,和一个由 cron 每分钟触发的 worker。worker 认领到期作业、运行并记录成功或失败。
你可以从几处入队:
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 + 数据库 模式很简单,但小错误会导致重复、作业卡住或意外负载。大多数问题在第一次崩溃、部署或流量突增时出现。
现实中的事故往往来自几个陷阱:
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 以过快频率访问数据库。
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 应用,减少手工接线,让你专注于锁、重试与幂等性规则。
如果以后你超出该方案的能力范围,你也会清晰地理解作业生命周期,这些思想同样适用于完整的队列系统。