定时任务幂等三板斧:日期主键、状态文件与两阶段标记

举报
yd_254065487 发表于 2026/10/28 05:21:28 2026/10/28
【摘要】 定时任务被重复调度一次,账单就翻倍。本文给出日期主键 upsert、JSON 状态文件、pending/done 两阶段标记三种落法,并讲清先写标记还是先执行的取舍。

现场:一次驱逐换来双倍账单

凌晨 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 的那一刻依然成立。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。