定时任务幂等三板斧:日期主键、状态文件与两阶段标记
现场:一次驱逐换来双倍账单
凌晨 2:10,值班群弹出一条告警:昨日结算金额是平时的 2.3 倍。查下来不是业务涨了,是 2:00 的结算任务跑了两次。1:58 一台节点被驱逐,Pod 重建,第二轮从流水表重新聚合了一遍,把已经写进库的 3000 条结算记录按新主键又插了一遍。
单机 cron 加单库,看着简单,但重复触发源比你想象的多:上一轮没跑完 cron 又起一轮、systemd timer 停机补跑、CCE 节点驱逐后 CronJob 重建 Pod、运维手动补数据。任何一个都会让「每天跑一次」变成「至少跑一次」。所以问题不是怎么防止重跑,而是重跑之后会发生什么。
一、有天然幂等键的:日期作主键 + upsert
结算、日报汇总、日活统计这类任务有一个天然幂等键:业务日期。把 biz_date 设成主键,重复执行只是覆盖同一行,不会累加。
最容易踩的坑是写法本身不幂等:UPDATE ... SET amount = amount + ? 每跑一次金额就多一份。金额必须是从流水表重算出来的结果,不是累加出来的增量。这一条比后面所有技巧都重要。
conn = sqlite3.connect("settle.db", timeout=10, isolation_level=None)
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=10000")
建表和写入:
CREATE TABLE IF NOT EXISTS daily_settle(biz_date TEXT PRIMARY KEY, amount_cent INTEGER NOT NULL, updated_at TEXT NOT NULL);
INSERT INTO daily_settle(biz_date, amount_cent, updated_at) VALUES(?, ?, datetime('now')) ON CONFLICT(biz_date) DO UPDATE SET amount_cent = excluded.amount_cent, updated_at = excluded.updated_at WHERE daily_settle.amount_cent != excluded.amount_cent;
末尾的 WHERE 让金额没变时不产生实际写入,避免无意义的 WAL 增长。两个版本细节要留意:
- upsert 需要 SQLite 3.24+,先跑
sqlite3 --version确认。低版本只能退回INSERT OR REPLACE,但它实际上是先 DELETE 整行再 INSERT,行上的触发器、外键级联会重跑一遍——记账表上这么干等于又埋了一个重复源。 - 多实例并发时用
BEGIN IMMEDIATE显式抢写锁,不要依赖默认的 deferred 事务,后者在锁升级阶段很容易撞上sqlite3.OperationalError: database is locked。配合 10s 的 busy_timeout,冲突的进程会排队而不是直接抛错。
二、没有幂等键的:JSON state 记 lastDate
发日报邮件、推 IM、调短信网关,这类动作没有主键可以覆盖,发出去了就是发出去了。这时候落一个状态文件:
{"lastPublishedDate": "2026-10-26", "status": "done", "pendingDate": null}
写入必须原子,否则掉电会留下一个被截断的 JSON,下次启动直接抛 json.decoder.JSONDecodeError,任务卡死等人修。用 mkstemp 加 os.replace:
fd, tmp = tempfile.mkstemp(dir=d, prefix=".state-")
with os.fdopen(fd, "w") as f: json.dump(st, f); f.flush(); os.fsync(f.fileno())
os.replace(tmp, STATE)
dirfd = os.open(d, os.O_DIRECTORY); os.fsync(dirfd); os.close(dirfd)
最后一行很多人会省掉。os.replace 保证的是「要么是旧内容要么是新内容」,但不保证目录项已经落盘;不 fsync 目录,掉电后可能连新文件都不存在。
三、先写标记还是先执行
这是全文最需要想清楚的一段,两种顺序对应两种语义:
- 先执行、后写标记:动作成功了,进程在写标记之前被 SIGKILL(退出码 137、节点回收、OOM),下次进来看到 lastDate 还是昨天,再发一遍。语义是 at-least-once,结果是可能重复。
- 先写标记、后执行:标记刚落盘进程就死了,这次动作永远不会发生。语义是 at-most-once,结果是可能漏。
没有免费午餐,选哪个取决于两件事:重复的代价是否可撤销,以及漏了能不能被发现和补上。对外付款、发营销短信、调第三方扣费,重复不可撤销往往还要赔钱,那就先写标记,宁可漏,漏了靠对账补。内部日报、可重发的通知,重复只是烦人,漏了会被追问,那就先执行,宁重不漏。
但纯二选一都太糙。工程做法是把标记拆成两阶段,文件里加 status:执行前写 {"pendingDate": "2026-10-27", "status": "pending"} 并 fsync;动作成功后再写 status: done 和 lastPublishedDate。进程启动时先检查残留状态:
st = load_state()
if st.get("status") == "pending":
if downstream_has(st["pendingDate"]): mark_done(st, d)
else: retry(d)
这里的 downstream_has 才是问题的核心——回查发送记录表,或者直接用下游网关的 requestId 幂等键去重。大多数短信、邮件、IM 网关都支持按业务 ID 去重;下游只要支持,两阶段标记就退化成一个查询,问题彻底解决。下游什么都不支持时,你才只能靠「先写标记」赌不漏。
对第一节的 SQLite 记账版,pending 标记不必单开文件,同库同事务就能做,而且它天然跨机器有效:
INSERT INTO job_run(job_name, biz_date, status, updated_at) VALUES('daily_settle', ?, 'pending', datetime('now')) ON CONFLICT(job_name, biz_date) DO NOTHING;
表上以 (job_name, biz_date) 为主键,执行后判断 cursor.rowcount == 0 就说明这一天已经有人抢到了,直接退出。这比 flock 强,因为 flock 只在本机生效。
四、调度器这一层别帮倒忙
- cron 加互斥:
0 2 * * * flock -n /var/lock/settle.lock /opt/job/settle.py。它只防同机重叠,不防补跑和多机。 - systemd timer 的
Persistent=true会在停机后补跑错过的窗口。如果你已经做了日期判断,这个补跑通常有益;但它会把「先执行」顺序下的重复风险放大一次。 - CCE 上的 CronJob 把
concurrencyPolicy设为Forbid,同时接受「节点驱逐后 Pod 重建会重跑同一个 job」这个事实——最后兜底的仍然是业务层的幂等键。
幂等不是加一个 if exists 就完事。它是明确写下「重复执行时会发生什么」,并让这个答案在你被 SIGKILL 的那一刻依然成立。
- 点赞
- 收藏
- 关注作者
评论(0)