问题背景定时触发不等于可靠执行在 Node.js 服务中用setInterval或 cron 库启动任务很容易难点是让任务在重复触发、进程崩溃和服务停机之后仍然产生正确的业务结果。例如每天给符合条件的账户发放积分。部署多个实例时同一时刻可能有多个执行者任务执行中重启可能只处理了一部分账户服务停机一天恢复后也不能只等下一次触发。设计时需要分别回答三个问题哪个业务周期需要处理重复执行会不会重复发放没有完成的工作如何重新被发现定时器只负责唤醒数据库负责记录事实。## 原理稳定标识、原子提交与持久化进度幂等的关键是为一次业务操作定义稳定标识。积分发放可以使用“任务类型、业务周期、账户 ID”作为唯一键。重复触发必须生成相同的键不能每次创建随机 UUID 来判断重复。业务周期也不能直接取执行时刻。补跑昨天的任务应继续使用昨天的周期标识。跨时区业务需要先确定业务时区和周期边界再转换成统一的存储格式避免夏令时或服务器时区改变任务含义。对于同一个 PostgreSQL 数据库中的操作可以把业务修改和任务完成标记放进一个事务。提交成功两者一起生效提交前崩溃两者一起回滚。恢复时重新扫描未完成任务即可。这实现的是业务效果的幂等。任务函数仍可能执行多次数据库事务和唯一约束负责限制最终结果。## 代码示例用事务处理积分任务下面使用 TypeScript 和pg。假设已有accounts(id, points)表每个任务给一个账户增加 10 积分。sqlCREATE TABLE scheduled_jobs ( task_name text NOT NULL, slot timestamptz NOT NULL, account_id bigint NOT NULL, status text NOT NULL DEFAULT pending CHECK (status IN (pending, done)), completed_at timestamptz, PRIMARY KEY (task_name, slot, account_id));调度端按确定的周期写入任务重复写入不会生成第二份工作。参数slot应由业务日历计算不应直接使用当前时间。tsimport { Pool } from pg;const pool new Pool();async function enqueue(slot: Date, accountId: string) { await pool.query( INSERT INTO scheduled_jobs (task_name, slot, account_id) VALUES (daily_points, $1, $2) ON CONFLICT DO NOTHING, [slot, accountId] );}执行端通过行锁领取任务。SKIP LOCKED让其他实例跳过已被领取的行继续处理不同任务。tsasync function runOne(): Promiseboolean { const client await pool.connect(); let reusable true; try { await client.query(BEGIN); const result await client.query( SELECT task_name, slot, account_id FROM scheduled_jobs WHERE status pending AND slot now() ORDER BY slot, task_name, account_id FOR UPDATE SKIP LOCKED LIMIT 1 ); const job result.rows[0]; if (!job) { await client.query(COMMIT); return false; } const changed await client.query( UPDATE accounts SET points points 10 WHERE id $1, [job.account_id] ); if (changed.rowCount ! 1) { throw new Error(Account does not exist); } await client.query( UPDATE scheduled_jobs SET status done, completed_at now() WHERE task_name $1 AND slot $2 AND account_id $3, [job.task_name, job.slot, job.account_id] ); await client.query(COMMIT); return true; } catch (error) { try { await client.query(ROLLBACK); } catch { reusable false; } throw error; } finally { client.release(!reusable); }}积分增加和状态更新共享事务行锁一直保留到事务结束。连接断开并被数据库识别后未提交事务会回滚任务仍为pending。如果提交成功但客户端没有收到响应重新扫描会看到done不会再次增加积分。示例只展示一次领取。调用端应限制并发在空队列或失败后延迟轮询生产环境还应配置事务超时并为持续失败的任务保存重试次数、下次执行时间和错误信息避免坏任务不断占用资源。## 恢复补齐遗漏的任务扫描pending只能恢复已经入库的任务。服务停机期间没有生成的任务需要调度端主动补齐。可以为每种调度规则保存“已生成到哪个周期”的游标。恢复时从游标之后逐批生成任务并在同一事务里写入任务和推进游标。多个调度实例还需要锁定对应游标行。这样既不会因为提前推进游标而漏任务也能依靠唯一约束处理重复生成。历史补跑还需要明确账户资格按历史快照发放还是按恢复时的账户状态发放。这属于业务规则应在生成任务前确定。## 常见错误- 使用进程内布尔变量防重。它只能约束当前进程重启和多实例部署都会失效。- 先标记完成再修改业务数据。中途失败会留下无法自动恢复的漏处理反过来分两次提交则可能重复修改。- 把数据库事务当作外部接口的事务。邮件、支付和 HTTP 请求不会随数据库回滚。应使用事务发件箱并要求接收方按稳定业务键去重无法去重时需要查询结果或对账补偿。- 删除完成记录却允许历史补跑。去重依据消失后旧任务可能再次产生业务效果。保留期限应覆盖补跑窗口或另外保留业务流水唯一键。## 总结可靠的定时任务需要把周期生成、任务执行和失败恢复连接起来稳定业务键识别重复事务保护数据库内的业务效果持久化游标补齐遗漏周期。定时器负责触发检查任务记录负责告诉系统哪些工作还没有完成。