调度器启动的是一次尝试,业务关心的通常是某个逻辑任务是否完成。一笔每日额度可能因为报错、重启或人工补跑而尝试多次。如果每次尝试都生成新的身份,重复执行就无法与新工作区分。应使用能够代表预期任务的键,例如任务名称与名义计划时间,并在修改业务状态之前定义重复键的含义。尝试日志中的唯一编号可以保留,但不能代替业务层面的去重身份。
调度触发与业务执行是两件事
不能假设所有调度器都会补跑遗漏工作。普通 cron 的具体行为随实现变化,没有统一补跑承诺。启用 Persistent=true 的 systemd 日历定时器,在停用期间至少错过一次触发时,可以在重新启用后补触发;这不等于把所有错过的时间点逐个重放。Kubernetes 文档也明确说明,某些情况下 CronJob 可能重复创建 Job,或者没有创建对应任务。
因此要先确定业务规则:过时任务直接跳过、每个缺失周期都处理,还是对当前状态做一次校正。去重键应使用名义上的逻辑周期,并明确时区。实际启动时间会随重试变化,不能用于识别同一次计划工作。诊断需要的尝试编号与业务身份应分开记录,这样才能同时看见执行了几次尝试,以及到底完成了几个逻辑周期。
坏掉的样子
先查询有没有处理过,再单独修改数据,存在竞态。两个工作进程可能都看到键不存在,然后都应用业务效果。进程锁也许能阻止一台机器上的重叠运行,却不能让业务更新与完成标记文件自动形成原子操作。如果更新成功后、写标记之前失败,重试就会重复生效;先写标记再执行业务,则可能把尚未发生的工作误记为完成。调整两个步骤的先后顺序不能消除中间窗口。
当业务状态与完成记录位于同一个事务数据库时,应在唯一约束下共同提交。提交之前失败,两者都不应留下;提交之后重试,应识别已经完成的逻辑键。SQLite 的 BEGIN IMMEDIATE 会提前开始写事务,其他写入者可能等待,也可能因为繁忙而失败。超时限制等待长度,并不保证任何并发量下都不会冲突;调用方仍需正确区分重试、失败和重复完成。
这个原子性边界不覆盖外部邮件、支付或 HTTP 调用。本地回滚无法撤销远端已经接受的效果。此类流程需要远端提供合适的幂等契约,或采用持久投递设计并在接收方去重。同一个键如果可能携带不同负载,还需要明确拒绝这种冲突的规则。下面示例始终增加固定额度,因此只验证同一逻辑操作的重复尝试,没有把变更参数的重用键视为新请求。
快速验证
本轮以 Python 3.12.14 与 SQLite 3.53.1 完整运行下列示例,实际使用临时数据库与并发线程。
保存为 idempotent-jobs.py 后运行 python3 idempotent-jobs.py。脚本使用临时 SQLite 数据库和八个工作线程,每个线程建立独立数据库连接。业务效果是给余额增加十个单位;非空主键记录已经完成的每日逻辑额度发放,并与余额修改处于同一个事务中。实验不需要实际修改系统定时器,也不会访问外部服务。
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from tempfile import TemporaryDirectory
from threading import Barrier
import json
import sqlite3
with TemporaryDirectory() as directory:
database = str(Path(directory) / 'jobs.db')
with sqlite3.connect(database) as connection:
connection.executescript('''
CREATE TABLE balance (id INTEGER PRIMARY KEY, units INTEGER NOT NULL);
INSERT INTO balance VALUES (1, 0);
CREATE TABLE completed (logical_key TEXT PRIMARY KEY NOT NULL);
''')
def run(key, fail=False, gate=None):
connection = sqlite3.connect(database, timeout=10, isolation_level=None)
try:
if gate:
gate.wait(timeout=5)
connection.execute('BEGIN IMMEDIATE')
inserted = connection.execute(
'INSERT INTO completed VALUES (?) ON CONFLICT(logical_key) DO NOTHING', (key,))
if not inserted.rowcount:
connection.execute('COMMIT')
return 'duplicate'
connection.execute('UPDATE balance SET units = units + 10 WHERE id = 1')
if fail:
raise RuntimeError('injected failure before commit')
connection.execute('COMMIT')
return 'applied'
except Exception:
if connection.in_transaction:
connection.execute('ROLLBACK')
raise
finally:
connection.close()
key = 'daily-credit:2026-09-13T00:00:00Z'
try:
run(key, fail=True)
except RuntimeError as error:
assert str(error) == 'injected failure before commit'
with sqlite3.connect(database) as connection:
assert connection.execute('SELECT units FROM balance').fetchone() == (0,)
assert connection.execute('SELECT count(*) FROM completed').fetchone() == (0,)
gate = Barrier(8)
with ThreadPoolExecutor(max_workers=8) as pool:
results = list(pool.map(lambda _: run(key, gate=gate), range(8)))
assert results.count('applied') == 1 and results.count('duplicate') == 7
assert run(key) == 'duplicate'
assert run('daily-credit:2026-09-14T00:00:00Z') == 'applied'
with sqlite3.connect(database) as connection:
units = connection.execute('SELECT units FROM balance').fetchone()[0]
completed = connection.execute('SELECT count(*) FROM completed').fetchone()[0]
assert (units, completed) == (20, 2)
print(json.dumps({'rolled_back_attempt_left_no_state': True,
'concurrent_applied': results.count('applied'),
'concurrent_duplicates': results.count('duplicate'),
'final_units': units, 'completed_logical_jobs': completed}))
第一次尝试在完成插入和余额更新之后、提交之前故意抛出异常。脚本验证回滚之后余额仍为零,也不存在完成键。随后八个工作线程同时处理同一个逻辑时间点,必须只有一个返回 applied,另七个返回 duplicate。唯一键判断与业务修改依靠真实数据库串行化,不是通过假的内存锁预先安排好结果。并发开始屏障让竞争条件实际存在。
成功提交后再重放同一个键,仍然是重复;换成下一个名义日期,才是新的逻辑任务,并再次增加十个单位。最终余额应为二十,完成键应为两个。这个结果覆盖单个 SQLite 数据库中的回滚、并发去重和提交后重放,没有验证调度器一定送达、进程被强制终止时的恢复,也没有证明外部系统的效果可以恰好发生一次。
让失败与完成都能被看见
记录逻辑键、每次尝试的结果、耗时与最近成功完成的业务周期。正常重复与数据库错误要区分;如果捕获所有异常后都标成完成,就会把丢失工作隐藏起来。还要定义完成记录需要保存多久,删除记录后再收到旧重试,可能重新应用业务效果。可用的运行证据应让值班人员判断预期周期是否完成,而不只是证明定时器曾经启动过某个进程。
来源
- SQLite: transactions,写入串行化、提交和回滚语义,已核验 2026-10-15。
- SQLite: UPSERT,针对指定唯一约束冲突的处理,已核验 2026-10-15。
- systemd.timer,持久日历定时器补触发的适用范围,已核验 2026-10-15。
- Kubernetes: CronJob,近似调度以及重复或遗漏任务的可能性,已核验 2026-10-15。