问题背景:定时触发不等于可靠执行
在 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(): Promise<boolean> { 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 请求不会随数据库回滚。应使用事务发件箱,并要求接收方按稳定业务键去重;无法去重时需要查询结果或对账补偿。- 删除完成记录却允许历史补跑。去重依据消失后,旧任务可能再次产生业务效果。保留期限应覆盖补跑窗口,或另外保留业务流水唯一键。## 总结可靠的定时任务需要把周期生成、任务执行和失败恢复连接起来:稳定业务键识别重复,事务保护数据库内的业务效果,持久化游标补齐遗漏周期。定时器负责触发检查,任务记录负责告诉系统哪些工作还没有完成。