每日定时任务幂等:日期主键、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参数区分,别另写一套脚本——另一套脚本通常没有幂等保护。
- 点赞
- 收藏
- 关注作者
评论(0)