分布式定时任务别重复跑:租约、围栏令牌与幂等收口

分布式定时任务协调示意

单机上的 cron 很直接:时间到了就执行。但服务扩成多个副本后,每个实例都会在同一分钟醒来;日报被发三次、账单被结算三次,往往不是“锁没加好”,而是没有先界定谁有资格执行、资格失效后旧执行者还能不能写入。可靠的方案要同时处理抢占、失联和重复副作用。

先承认:恰好一次不是默认能力

网络超时、进程暂停和数据库主从切换都会制造不确定性。实例 A 拿到锁后暂停,锁到期;实例 B 接手并完成任务;A 恢复后继续提交结果。此时即使锁服务本身没有故障,仍可能出现两个执行者。因而调度层能提供的通常是“尽量单实例执行”,业务层必须接受至少执行一次。

把职责拆开会更清楚:调度器决定何时尝试,租约决定谁暂时拥有执行权,业务表或外部接口的幂等键负责让重复尝试只产生一个结果。不要把“抢到锁”误当作“结果一定正确”。

层次 要解决的问题 可靠的落点
调度 哪些时间点需要执行 固定时区、明确错过任务的补跑规则
互斥 同一时刻谁可以开始 带过期时间的租约
写入 旧执行者是否还能落库 围栏令牌与条件更新
副作用 重跑会不会重复扣费或通知 幂等键、唯一约束、可查询结果

用租约代替永不释放的锁

租约是一份有截止时间的执行许可。实例领取任务时,在一个原子操作里写入持有者、到期时间和递增版本;持有期间定期续租。进程崩溃不需要清理锁,其他实例在到期后可以接手。租期要显著长于一次正常执行时间,也要留出 GC 停顿、网络抖动和续租延迟的余量。

租约到期后的安全交接

下面用关系型数据库表达“仅在未被有效持有时领取”。不同数据库的时间函数和行锁语义不同,上线前应按实际数据库验证并发行为:

1
2
3
4
5
6
7
UPDATE scheduled_runs
SET owner = :worker_id,
lease_until = CURRENT_TIMESTAMP + INTERVAL '90 seconds',
fence = fence + 1
WHERE job_name = :job_name
AND (lease_until IS NULL OR lease_until < CURRENT_TIMESTAMP)
RETURNING fence, lease_until;

没有返回行就说明别人仍持有租约,应安静跳过,不要立刻忙等重试。续租也必须带上 owner 和本次 fence 作为条件;否则已经失去资格的旧实例可能把新持有者的租约延长。把领取成功、开始时间、结束时间和错误摘要写入运行记录,才能区分“没触发”“被跳过”和“执行失败”。

围栏令牌堵住“过期但仍在跑”

仅靠 lease_until 无法阻止 A 在暂停后恢复。解决办法是让每次领取产生单调递增的 fence,并把它带到受保护的写入端。资源端只接受比已保存版本更新的令牌:B 拿到 42 后,即便 A 携带旧的 41 晚到,也会被拒绝。

围栏令牌拒绝过期写入

1
2
3
4
5
6
UPDATE daily_report
SET generated_at = CURRENT_TIMESTAMP,
content = :content,
last_fence = :fence
WHERE report_date = :report_date
AND last_fence < :fence;

围栏令牌不是业务主键,也不能替代权限校验;它只回答“这次写入是不是当前最新的持有者”。若写入对象是第三方支付、邮件或对象存储,优先使用对方提供的幂等键;若没有,就在本地事务中持久化请求状态和业务唯一键,必要时进入人工核对,而不是盲目重发。

幂等收口:以业务结果为准

例如“为某商户生成 2026-08-16 的日结”可定义唯一键 merchant_id + settlement_date。无论调度器触发几次,先尝试创建那一条结算记录;唯一约束冲突时读取已有结果并退出。这样锁服务短暂异常、部署期间重叠或人工补跑,都不会变成第二笔结算。

1
2
3
INSERT INTO settlements (merchant_id, settlement_date, status)
VALUES (:merchant_id, :date, 'running')
ON CONFLICT (merchant_id, settlement_date) DO NOTHING;

插入成功的执行者才继续计算;失败者只查询并报告既有状态。对于不可逆动作,还应保存外部请求的幂等键与响应摘要。将“生成成功”更新和业务数据变更放进同一事务;外部调用跨不过事务边界时,用状态机标记 pendingsendingsucceeded,通过查询或补偿处理不确定结果。

上线前逐项演练失败路径

不要只在正常时间点观察一次成功日志。至少演练以下场景:持有者在执行中被杀掉,续租请求超时,暂停超过租期后恢复,以及同一日期被手动补跑。每次都检查最终只有一份业务结果,并且运行记录能说明发生了什么。

检查项 通过标准
租约失效 崩溃后能接手,且不会永久占用
旧实例恢复 旧围栏令牌无法覆盖新结果
重复触发 唯一键确保只有一次业务副作用
可观测性 可查看最近运行、耗时、跳过原因和失败告警
人工补跑 仍走同一套幂等与审计路径

分布式定时任务的关键不是选一个“绝不会重复”的锁,而是把重复当成正常输入:用租约恢复可用性,用围栏拒绝过期写入,用业务幂等固定最终结果。这样即使实例重启、网络迟到或运维补跑,系统仍能给出可验证的唯一结果。