分布式定时任务重复执行与故障恢复
一、业务背景
某金融系统每天凌晨需要为大量用户计算收益,并更新账户余额。
随着业务规模扩大,系统部署了多个工作节点,由调度中心分配任务。
某天凌晨,系统出现以下问题:
- 部分用户的收益被重复计算。
- 部分用户没有更新收益。
- 部分任务执行时间过长,影响后续任务。
- 工作节点执行到一半突然宕机,调度中心无法确定哪些账户已经处理完成。
核心目标:保证任务能够可靠执行、故障后能够恢复,并确保收益不会重复入账或遗漏。
二、整体解决方案
采用以下机制协同保障:
- 任务分片:提高大批量任务的处理效率。
- 任务执行记录表:持久化任务状态和处理进度。
- Redis 分布式锁:减少多个节点同时执行同一任务的情况。
- 幂等机制:防止同一笔业务重复生效。
- 数据库唯一约束:从数据层防止重复创建同一笔业务记录。
- 数据库事务:保证收益入账和处理记录的一致性。
- 超时检测、重试与补偿:恢复异常中断的任务。
- 定期对账:发现并修复漏处理或错误处理的业务。
这些机制各司其职,不能简单地相互替代。
三、任务分片:解决大批量任务的处理效率问题
假设系统需要处理 100 万个账户。
如果由一个工作节点串行处理全部账户,任务耗时可能过长。
可以将任务拆分成多个批次:
| 任务 ID | 处理范围 | 状态 |
|---|---|---|
| TASK_001 | 账户 1~10000 | 成功 |
| TASK_002 | 账户 10001~20000 | 成功 |
| TASK_003 | 账户 20001~30000 | 执行中 |
| TASK_004 | 账户 30001~40000 | 待执行 |
多个工作节点可以并行处理不同批次。
设计注意事项
- 分片之间应尽可能互不重叠。
- 每个分片需要有唯一的任务标识。
- 分片状态必须持久化,不能只保存在工作节点内存中。
- 应根据数据库负载、任务耗时和数据量合理设置分片大小。
- 任务分片解决的是处理效率问题,不能单独解决重复执行问题。
四、任务执行记录表:实现故障恢复
1. 为什么需要任务记录表?
假设某个工作节点处理到一半突然宕机。
如果系统只知道任务处于「执行中」,却没有保存处理进度,那么恢复时就不知道哪些账户已经完成处理。
因此,需要将执行状态持久化到数据库。
可以设计两类记录。
任务批次表:
记录任务的整体状态,例如:
- 任务 ID
- 业务日期
- 分片编号
- 执行状态
- 执行节点
- 开始时间
- 最近进度更新时间
- 完成时间
- 重试次数
账户级处理记录表:
记录每个账户在指定计息业务中的处理状态,例如:
- 账户 ID
- 计息日期
- 收益类型
- 业务唯一标识
- 处理状态
- 处理时间
账户级记录可以帮助系统精确识别哪些账户已经完成,哪些账户尚未完成。
如果采用更小的独立提交批次,也可以使用批次级进度记录减少记录数量,但必须保证恢复粒度与实际提交粒度匹配。
2. 如何恢复任务?
假设 100 万个账户中,80 万个已经成功完成,20 万个尚未完成。
恢复流程如下:
- 调度中心检测到任务异常或长时间没有进展。
- 将任务标记为待恢复,并确认原执行者不再拥有有效的执行权限。
- 查询持久化的任务状态和账户处理记录。
- 找出尚未完成的账户或子批次。
- 将剩余工作重新分配给可用的工作节点。
- 工作节点处理任务时再次执行幂等校验。
- 全部处理完成后,更新任务状态为成功。
核心原则:恢复任务时,以持久化的业务状态为依据,而不是单纯根据工作节点是否返回成功响应来判断。
五、幂等:防止重复计息
1. 为什么需要幂等?
假设账户 A 在某天应获得 100 元收益。
第一次执行时:
- 收益成功入账。
- 数据库事务提交成功。
- 工作节点突然宕机,没有向调度中心返回成功响应。
调度中心认为任务可能失败,于是重新分配任务。
如果新节点再次入账,账户就会获得 200 元收益。
因此,不能只依赖任务执行状态防止重复处理,还必须保证业务操作本身具有幂等性。
2. 设计稳定的业务唯一标识
可以根据业务规则设计唯一键:
账户ID + 计息日期 + 收益类型
例如:
ACCOUNT_001 + 2026-10-21 + DAILY_INTEREST
同一账户在同一计息日期、同一收益类型下的同一笔计息业务,无论重试多少次,都应该能够识别为同一笔业务。
注意:
- 不能在每次重试时随意生成新的业务唯一标识。
- 如果业务允许同一天内多个独立计息批次,唯一键需要纳入批次或版本等业务维度。
- 不能为了方便而将所有操作错误地视为同一笔业务。
3. 数据库唯一约束
例如:
UNIQUE(account_id, interest_date, interest_type)
这个约束能够防止相同业务标识对应的成功记录被重复创建。
但必须注意:唯一约束本身不能保证账户余额不会被重复更新。
它必须与事务和正确的业务执行流程结合使用。
六、数据库事务:保证收益入账与处理记录一致
这是整个设计中最重要的部分之一。
假设系统需要完成两项操作:
- 更新账户收益或余额。
- 写入计息成功记录。
如果这两个操作不在同一个事务中,就可能出现部分成功的情况。
错误方案 A:先入账,再记录成功
text
更新账户收益
提交成功
工作节点宕机
写入处理记录
此时,收益已经入账,但处理记录缺失。
恢复任务时,系统可能再次入账。
错误方案 B:先记录成功,再入账
text
写入处理记录
提交成功
工作节点宕机
更新账户收益
此时,系统可能认为任务已完成,但实际收益并没有入账。
正确方案:在同一数据库事务中完成
text
BEGIN TRANSACTION
1. 尝试创建唯一的计息业务记录
2. 确认本事务成功取得该笔业务的处理权
3. 更新账户收益或余额
4. 将计息业务记录更新为成功
COMMIT
如果任何一步失败,整个事务回滚。
如果两个工作节点并发执行,只有成功取得该笔业务处理权的事务才能执行入账;另一方需要根据唯一约束冲突及已提交的业务结果进行处理。
这里有两个关键要求:
- 只有取得处理权的事务才能更新余额。
- 业务记录与余额更新必须在同一事务中提交。
如果数据库事务已经提交,但工作节点在收到提交结果之前宕机,恢复时应查询数据库中的真实结果,不能直接认定事务失败。
这才是避免重复入账的核心保障。
七、Redis 分布式锁:减少并发竞争
1. 为什么使用分布式锁?
当多个工作节点同时收到同一笔任务时,可以使用 Redis 分布式锁,减少重复执行。
例如:
text
服务器 A 获取锁 → 成功
服务器 B 获取同一把锁 → 失败
服务器 A 执行任务 → 完成
服务器 A 释放锁
锁的粒度应根据任务特点设计,可以是任务级或分片级。
2. 为什么不能只依赖 Redis 锁?
考虑以下场景:
- 服务器 A 获取锁。
- A 执行时间过长,锁自动过期。
- 服务器 B 获取同一把锁。
- A 和 B 同时继续处理任务。
此时,两个节点可能同时操作数据库。
即使使用锁自动续期,也仍然需要考虑进程暂停、网络异常等情况。
3. 推荐的保障组合
| 机制 | 职责 |
|---|---|
| Redis 分布式锁 | 减少并发竞争 |
| 数据库唯一约束 | 防止同一业务记录重复创建 |
| 数据库事务 | 保证业务结果与处理记录一致 |
| 幂等机制 | 让重复请求不会重复产生业务效果 |
| 任务状态管理 | 支持任务恢复和进度跟踪 |
对于要求较高的金融业务,还可以根据系统架构引入数据库条件更新、租约校验或 fencing token 等机制,避免失去执行权的旧节点继续写入。
Redis 锁是一种并发控制手段,不应成为金融业务正确性的唯一保障。
八、超时检测、重试与补偿
1. 超时检测
调度中心定期检查任务是否长时间没有进展。
例如:
- 任务一直处于执行中。
- 最近进度更新时间过旧。
- 执行节点已经失联。
满足条件时,将任务识别为疑似异常任务。
但不能只因为心跳超时,就立即认定原任务已经停止。
原节点可能只是网络延迟,仍在执行任务。
因此,在重新分配任务前,需要结合租约、执行权校验或其他协调机制,确保原节点失去继续写入的权限。
2. 失败重试
对可恢复的临时故障,可以进行有限次数的重试。
例如:
- 数据库短暂不可用。
- 网络请求超时。
- 依赖服务短暂异常。
应设置重试次数、退避间隔和最大执行时长,避免大量任务同时重试,进一步加剧系统压力。
3. 补偿机制
如果重试多次仍然失败,可以将任务记录为异常,交由补偿任务继续处理。
补偿前应先确认:
- 业务是否已经成功提交。
- 是否存在尚未完成的事务。
- 是否需要重新计算收益。
- 是否可以安全地再次执行。
补偿任务本身也必须具备幂等性。
九、对账:发现并修复业务不一致
即使系统已经具备幂等、事务、分布式锁和任务恢复机制,仍然建议建立定期对账机制。
对账可以检查:
- 应计息的账户是否全部完成。
- 计息业务记录与实际入账结果是否一致。
- 是否存在缺失的收益流水。
- 是否存在重复入账。
- 账户余额是否与有效的业务流水一致。
发现异常后,应根据权威业务数据和流水记录进行核对。
如果发现收益漏记,可以补记;如果发现重复入账,则应根据业务规则进行冲正或生成反向调整流水,而不是直接修改余额掩盖问题。
所有修复操作同样需要幂等,并保留审计记录。
十、任务执行时间过长:先定位瓶颈,再扩容
任务执行缓慢时,不能简单地认为增加服务器就能解决问题。
应先分析瓶颈:
| 问题 | 可能的解决方案 |
|---|---|
| 单节点处理数据量过大 | 任务分片 |
| 数据库查询缓慢 | SQL 优化、索引优化 |
| 数据库锁竞争严重 | 优化事务范围、调整并发度 |
| 大量逐条写入 | 合理使用批量操作 |
| 工作节点 CPU 或内存不足 | 增加工作节点或扩容 |
| 下游服务成为瓶颈 | 限流、批量处理、异步化 |
扩容时还应考虑数据库连接数、数据库吞吐量和下游服务的承载能力。
否则,增加工作节点可能使数据库压力更大,反而导致任务整体变慢。
十一、Outbox 在定时计息场景中的作用
Outbox 并不是所有定时任务都必须使用的机制。
如果计息结果和处理记录位于同一个数据库中,可以通过数据库事务保证两者的一致性,通常不需要专门引入 Outbox。
但如果计息完成后还需要发送 MQ 消息通知其他系统,例如:
- 通知报表系统。
- 通知账户流水服务。
- 触发收益明细生成。
- 更新下游业务系统。
就可以使用 Transactional Outbox。
在同一个数据库事务中:
- 保存计息结果。
- 写入待发送事件。
随后由后台发布程序将事件发送到 MQ。
发送成功后,再更新 Outbox 记录状态。
如果发送成功后、更新发送状态前程序宕机,事件可能再次发送,因此消费者仍然需要实现幂等处理。
Outbox 主要解决业务事务与事件发布之间的可靠性问题,而不是替代任务执行记录表或计息业务幂等。
十二、完整故障恢复流程
假设某天凌晨需要处理 100 万个账户,系统已经成功完成 80 万个账户。
随后,某个工作节点突然宕机。
推荐的处理流程如下:
text
调度中心分配计息任务
↓
将任务拆分成多个批次
↓
工作节点获取任务执行权
↓
读取账户处理状态
↓
执行计息业务
↓
在数据库事务中:
创建或取得唯一业务记录
更新账户收益
记录计息成功状态
↓
事务提交成功
↓
更新批次进度
↓
所有账户完成?
↙ ↘
否 是
↓ ↓
继续处理 标记任务成功
如果工作节点宕机:
text
检测到任务超时或节点异常
↓
确认原节点不再拥有有效执行权
↓
读取任务记录与账户处理记录
↓
识别未完成的账户或批次
↓
重新分配剩余任务
↓
执行幂等校验
↓
已成功的业务跳过
未完成的业务继续执行
↓
全部完成后进行必要的对账
需要特别注意:即使某个账户的业务记录显示成功,也应保证这个状态与真实入账结果具有事务一致性,不能只依赖一个可能滞后的批次进度字段。
十三、关键知识点总结
| 知识点 | 解决的问题 |
|---|---|
| 任务分片 | 提高大批量任务的处理效率 |
| 任务执行记录表 | 记录任务状态和执行进度 |
| 账户级处理记录 | 精确识别已完成与未完成的业务 |
| 幂等 | 防止同一笔业务重复生效 |
| 稳定的业务唯一标识 | 识别重试请求是否属于同一笔业务 |
| 数据库唯一约束 | 防止重复创建相同业务记录 |
| 数据库事务 | 保证收益入账与处理记录一致 |
| Redis 分布式锁 | 减少多个节点同时执行同一任务 |
| 超时检测与重试 | 发现异常并恢复执行 |
| 补偿机制 | 处理多次重试仍然失败的任务 |
| 对账机制 | 发现和修复业务结果不一致 |
| Transactional Outbox | 可靠关联业务事务与事件发布 |
最重要的三个原则
原则一:任务可以重复执行,但业务结果不能重复生效。
原则二:任务进度必须持久化,系统不能依赖工作节点的内存状态进行故障恢复。
原则三:分布式锁负责减少竞争,数据库事务、唯一约束与幂等机制共同保障业务正确性。