每日定时任务幂等:日期主键、state 文件与执行顺序

举报
yd_254065487 发表于 2026/11/06 05:21:09 2026/11/06
【摘要】 日结任务重试跑了两遍,账单多出一条。用业务日期做主键、用 state 文件记进度,把重复执行挡在业务写入之前。

凌晨 00:05,账单多了一条

日结任务跑在 ECS 上,凌晨 00:05 由 crontab 触发。某天上游接口超时,任务抛了异常,调度器按配置重试了三次。第二天对账时发现,同一天的结算记录有三条,金额翻了三倍。日志里三次执行的入参一模一样,唯一不同的是 created_at——时间戳每次都不一样,它天生当不了幂等键。

把时间戳换成业务日期

幂等键必须是“同一次业务”在所有重试中都相同、不同业务之间必然不同的值。对每日任务来说,这个值就是业务日期 2026-11-05,不是 datetime.now()。定下来之后,剩下的交给数据库。

SQLite:主键约束加 upsert

建表时直接把业务日期设成主键,重复插入天然报 UNIQUE constraint failed:

CREATE TABLE IF NOT EXISTS daily_settlement ( biz_date TEXT PRIMARY KEY, amount INTEGER NOT NULL, status TEXT NOT NULL, updated_at TEXT NOT NULL );

写入时用 upsert 覆盖,重跑多少次结果都只有一行(ON CONFLICT ... DO UPDATE 需要 SQLite 3.24 以上):

def settle(conn, biz_date, amount): with conn: conn.execute( '''INSERT INTO daily_settlement(biz_date, amount, status, updated_at) VALUES (?, ?, 'done', datetime('now')) ON CONFLICT(biz_date) DO UPDATE SET amount = excluded.amount, status = excluded.status, updated_at = excluded.updated_at''', (biz_date, amount))

这里有个前提:写进去的必须是根据当天全量数据算出来的结果,不能是增量。如果 SQL 写成 UPDATE balance = balance + ?,upsert 反而会让重复执行被放大:重试三次就累加三次。要做增量,就得改成“先查全量、算完再覆盖写”。

发布类任务:JSON state 记 lastDate

推送、发短信这类动作没有天然主键,发出去就收不回来,只能在本地记“发到哪了”。state 文件最怕写到一半进程被 kill,所以落盘必须走“临时文件 + fsync + 原子替换”:

import json, os, tempfile STATE = '/var/lib/job/publish_state.json' def load_state(): try: with open(STATE) as f: return json.load(f) except FileNotFoundError: return {} def save_state(s): fd, tmp = tempfile.mkstemp(dir=os.path.dirname(STATE)) with os.fdopen(fd, 'w') as f: json.dump(s, f) f.flush() os.fsync(f.fileno()) os.replace(tmp, STATE)

少了 os.replace,某次 kill -9 之后你会得到一个截断的 JSON,第二天 json.load 直接抛 JSONDecodeError,任务再也起不来。另外 state 文件别放在容器本地盘,Pod 重建后就没了,要么挂持久卷,要么写到对象存储。

先写标记还是先执行:两种都会丢,丢的不是同一边

  • 先执行、后写标记:执行成功 → 进程崩 → 标记没落盘 → 重跑 → 重复发。语义是至少一次。
  • 先写标记、后执行:标记落盘 → 进程崩 → 没执行 → 重跑看到已标记就跳过。语义是至多一次,漏了就永远漏了。

所以更该问的是“哪边代价更大”,而不是简单判断哪个更安全。发短信、扣款、对外通知,重复的代价远大于漏一条,选先写标记;日志聚合、缓存预热这类,漏了无所谓、重跑一次更省事,选先执行后写标记。如果两边代价都大呢?那就把标记拆成两段。

折中:先写意图,再写完成

state = load_state() if state.get('lastDate') == biz_date and state.get('status') == 'done': return save_state({'lastDate': biz_date, 'status': 'pending'}) publish(biz_date) save_state({'lastDate': biz_date, 'status': 'done'})

重跑时遇到 pending,说明“上次执行到一半失联了”,结果未知:既不能盲目跳过,也不能直接重发,只能去下游查一次“这天到底发出去没有”。这要求发布动作能带一个幂等键过去(把 biz_date 当 request_id),下游自己去做去重。如果没有这个查询接口,pending 会永久卡住,那就必须配一条告警,让人工介入。

几条踩过的坑

  • 给任务加分布式锁只防并发,不防“昨天跑过了今天又被调度跑一次”,两件事要分开做。
  • 重试不要用固定间隔无脑重放,重试前先调一次 load_state(),成本几乎为零。
  • 补数(手动跑历史日期)走同一个入口,靠 biz_date 参数区分,别另写一套脚本——另一套脚本通常没有幂等保护。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

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

全部回复

上滑加载中

设置昵称

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

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

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