定时任务分布式避免重复执行

wen java案例 1

原理、方案与最佳实践

目录导读

  • 为什么分布式定时任务会重复执行?
  • 核心解决方案:分布式锁
  • 基于数据库的乐观锁
  • 基于Redis的分布式锁
  • 基于ZooKeeper的临时顺序节点
  • 基于Quartz的集群模式
  • 高级技巧:幂等性设计
  • 常见问题与答疑
  • 总结与推荐实践

为什么分布式定时任务会重复执行?

在分布式系统中,定时任务通常部署在多台服务器上,你有一个每日凌晨2点执行的数据清理任务,三台服务器同时启动,如果没有协调机制,这个任务就会被执行三次——这就是典型的重复执行问题

定时任务分布式避免重复执行

重复执行的危害包括:

  • 数据重复处理(如重复发送邮件、重复扣款)
  • 资源浪费(CPU、数据库连接)
  • 业务逻辑错乱(如库存扣减为负数)

关键问题:如何确保同一任务只被一台机器执行一次?


核心解决方案:分布式锁

分布式锁是解决定时任务重复执行的基石,它的核心思想是:谁拿到锁,谁就执行任务;任务结束后释放锁

分布式锁需要满足的条件

  1. 互斥性:同一时刻只能有一个进程持有锁
  2. 高可用:锁服务不能单点故障
  3. 防死锁:必须设置超时自动释放
  4. 可重入:同一线程可多次获取同一锁

基于数据库的乐观锁

实现方式

在任务表中增加一个version字段,每次执行前先查询当前version,更新时检查version是否一致。

-- 获取任务信息
SELECT id, version, status FROM task WHERE task_name = 'clean_data';
-- 更新任务状态,version+1
UPDATE task SET status = 'running', version = version+1 
WHERE id = 1 AND version = old_version;

优缺点

  • ✅ 实现简单,无需额外组件
  • ❌ 性能受数据库压力影响
  • ❌ 无法处理数据库宕机情况

适用场景

小型项目,任务执行频率低(如每日一次)


基于Redis的分布式锁

这是业界最常用的方案,利用Redis的SETNX命令,配合过期时间。

核心代码(伪代码)

def try_lock(lock_key, expire_seconds=30):
    # 加锁,如果key不存在则设置成功
    result = redis.set(lock_key, "locked", nx=True, ex=expire_seconds)
    return result
def release_lock(lock_key, lock_value):
    # 使用Lua脚本保证原子性,只释放自己加的锁
    script = """
    if redis.call("get", KEYS[1]) == ARGV[1] then
        return redis.call("del", KEYS[1])
    else
        return 0
    end
    """
    return redis.eval(script, 1, lock_key, lock_value)

注意事项

  • 防止锁过期导致任务未完成:使用Redisson的看门狗机制自动续期
  • 释放锁一定要加标识:防止误删其他线程的锁

优缺点

  • ✅ 性能高,单机QPS可达10万+
  • ✅ Redis集群保证高可用
  • ❌ 主从切换时可能出现锁丢失(Redis Sentinel模式下)

适用场景

大部分业务场景,尤其是高并发任务


基于ZooKeeper的临时顺序节点

ZooKeeper通过创建临时顺序节点实现分布式锁,每个客户端创建临时节点,序号最小的节点获得锁。

实现步骤

  1. /locks/下创建临时顺序节点,如/locks/task-0000000001
  2. 获取/locks/下所有子节点,若自己的节点序号最小则获得锁
  3. 否则,监听上一个序号的节点,等待其删除后重新竞争

优缺点

  • ✅ 强一致性,不存在锁丢失问题
  • ✅ 自动释放:客户端断开则临时节点自动删除
  • ❌ 性能较低,适合低频任务(如每分钟一次)
  • ❌ 部署维护ZooKeeper集群成本高

适用场景

对数据一致性要求极高的金融、支付类任务


基于Quartz的集群模式

Quartz本身支持集群部署,通过数据库锁实现任务调度协调。

配置方式

  1. 所有节点连接同一数据库
  2. quartz.properties中开启集群:
    org.quartz.jobStore.isClustered = true
    org.quartz.jobStore.clusterCheckinInterval = 20000

原理解析

Quartz使用数据库的FOR UPDATE行锁,保证同一时刻只有一个调度器获得任务执行权,任务执行完后,锁自动释放。

优缺点

  • ✅ 成熟的框架,开箱即用
  • ✅ 支持cron表达式,功能全面
  • ❌ 依赖数据库,性能瓶颈在数据库
  • ❌ 节点数量多时,锁竞争激烈

适用场景

已有Quartz系统的团队,任务数量少(<100)


高级技巧:幂等性设计

无论使用哪种锁,局部失败仍可能导致重复执行,任务执行到一半,服务器宕机,重启后再次执行。

幂等性指一次或多次执行的结果相同,实现方式:

  • 数据库唯一索引:如订单号、任务ID设置唯一约束
  • 状态机校验:只有状态为PENDING的记录才能执行
  • 去重表:在执行任务前插入一条去重记录,成功则执行,失败则表示已执行

幂等性结合分布式锁

def execute_task(task_id):
    # 1. 获取分布式锁
    if not try_lock(task_id):
        return
    try:
        # 2. 检查幂等性
        if is_already_executed(task_id):
            return
        # 3. 执行业务逻辑
        do_task()
        # 4. 标记任务完成
        mark_executed(task_id)
    finally:
        release_lock(task_id)

这种双重保障机制可以覆盖绝大多数重复执行场景。


常见问题与答疑

Q1:如果Redis锁过期了,任务还没完成怎么办?

A:使用Redisson的看门狗机制,默认每10秒检查一次,如果任务还在执行就自动续期到30秒,也可以手动续期:在任务内部定期调用expire命令。

Q2:数据库乐观锁和Redis锁如何选择?

A:如果任务执行频率高(每秒多次)选Redis;如果任务重要性极高且可接受轻微性能损耗,选数据库,注意:数据库乐观锁在并发写入时失败率会很高。

Q3:所有任务都使用一个锁,还是每个任务一个锁?

A:建议每个任务独立一个锁。lock:task:clean_datalock:task:send_email分开,避免不同任务互相阻塞。

Q4:Quartz集群模式下,如何避免节点故障导致任务丢失?

A:Quartz会检测其他节点的故障(checkin间歇时间内未响应),自动将失败节点的任务转移到其他节点执行,建议设置org.quartz.jobStore.clusterCheckinInterval小于锁超时时间。


总结与推荐实践

最终选择建议

场景 推荐方案
小型项目,任务少 数据库乐观锁
高频任务,需要高并发 Redis分布式锁 + Redisson
金融系统,强一致性 ZooKeeper分布式锁
已有Quartz,低频任务 Quartz集群模式

最佳实践流程图

任务触发
  ↓
获取Redis分布式锁 → 失败 → 直接返回(表示其他节点正在执行)
  ↓ 成功
检查幂等性 → 已执行 → 释放锁,返回
  ↓ 未执行
执行业务逻辑
  ↓
标记执行完成
  ↓
释放锁

最后提醒

  • 日志记录:每次获取锁、释放锁、任务开始/结束都要记录日志
  • 监控告警:设置锁竞争超时告警(如超过5秒未获取到锁)
  • 降级方案:Redis宕机时自动降级为数据库锁或手动执行
  • 压测验证:上线前务必做并发测试

分布式定时任务的本质是一次且仅一次(Exactly Once) 的执行保障,没有银弹,只有根据业务场景选择合适的锁方案,并结合幂等性设计,才能最大程度避免重复执行。


文章转载或引用请注明出处,本文基于多个开源框架文档及社区最佳实践整理而成,已进行去重与优化处理。

抱歉,评论功能暂时关闭!