1. 从单机到集群:@DisallowConcurrentExecution为何“失灵”了?

我记得第一次用Quartz做定时任务的时候,感觉特别省心。在本地开发环境,一个@DisallowConcurrentExecution注解加上去,任务执行时间再长,也不用担心它会自己跟自己“打架”——前一个实例没跑完,后一个实例就乖乖等着。这种单机下的“岁月静好”,让我一度以为分布式定时任务也就这么回事儿。直到我们把服务部署到线上,上了两台机器做高可用,问题就来了。

那天监控报警疯狂响,一看日志,同一个定时任务在两台机器上同时跑了起来,把依赖上一次执行结果的计算逻辑全打乱了。我当时第一反应是:“注解没生效?配置错了?”查了半天代码,注解明明加得好好的。后来才恍然大悟,@DisallowConcurrentExecution这个注解,它的“势力范围”仅限于单个JVM进程内部。你可以把它理解成一个贴在单个“车间”(服务器节点)大门上的告示:“本车间一次只允许一个工人(线程)操作这台机器(JobDetail)”。但是,当你有了两个、三个甚至更多的“车间”(多机部署)时,每个车间门口都贴了同样的告示,但车间之间是互相不知道对方情况的。A车间的工人在操作,B车间的工人一看自己门口的告示:“哦,我这里没人,我可以进去操作了。”于是,并发就发生了。

这种场景在实际业务中太常见了。比如我做过的一个电商对账任务,每3分钟拉取一次上游平台的订单数据,和本地数据库进行比对、核销。这个任务跑一次可能要4-5分钟,比执行间隔还长。在单机时,@DisallowConcurrentExecution能确保下一次执行等上一次结束,数据是连贯的。但一上双机,两台机器各自为政,都在同一时刻去拉取和核销,轻则数据重复处理,重则核销状态错乱,导致财务对不上账。这时候你面临一个两难选择:要么退回到单节点,放弃高可用,一旦这台机器挂了,所有定时任务全停;要么就忍受并发带来的数据混乱风险。显然,两者都不是好选择。

问题的核心在于,Quartz自带的并发控制是“节点内”的,它依赖的是本地内存和数据库里QRTZ_TRIGGERS表中的STATE字段状态来协调。但在多机环境下,Quartz集群模式虽然能通过数据库行锁避免同一个任务被多个节点同时触发(即一个Trigger不会在多个节点同时fire),但它无法阻止同一个JobDetail的不同实例在不同节点上同时执行。特别是当你配置了任务恢复(requestRecovery)或者有misfire策略时,情况会更复杂。所以,我们需要一个能跨JVM、跨机器的“全局告示牌”,这就是分布式锁要干的事儿。

2. 分布式锁选型:为什么是Redisson?

既然知道了问题的根源是缺一个跨节点的协调机制,那解决方案自然就指向了分布式锁。市面上实现分布式锁的方案不少,比如基于Redis的SETNX命令自己封装,用ZooKeeper的临时顺序节点,或者用数据库的悲观锁。我自己这几个方案都折腾过,最后在Quartz这个场景里,我倾向于直接用Redisson。

先说说为什么不自己用Redis写。用SETNX加锁,你得自己处理锁的超时时间吧?防止死锁。加锁成功了,你还要考虑锁的自动续期吧?万一任务执行时间比你预设的锁超时时间长,锁中途失效了,别的节点不就闯进来了吗?自己实现续期逻辑,又得搞个看门狗线程,复杂度一下就上来了。还有,锁释放的时候要保证原子性,只能由加锁的客户端释放,这又得用Lua脚本来保证。一套组合拳打下来,代码量不小,还容易有隐藏的坑。

ZooKeeper呢?它的强一致性和临时节点特性确实非常适合做分布式锁,性能也OK。但问题在于,它引入了另一个需要维护的基础设施。如果你的项目本身没用到ZooKeeper,为了一个分布式锁专门部署一套ZK集群,架构复杂度就增加了,运维成本也高了。当然,如果你本来就有ZK集群,那这确实是个好选择。

数据库悲观锁就更重了,直接在数据库表上加行锁,对数据库连接是种消耗,而且性能瓶颈明显,在高频定时任务场景下不太合适。

相比之下,Redisson的优势就很突出了。首先,它几乎已经是Java项目使用Redis的“标配”客户端了,很多项目本来就在用,引入它没有额外的架构负担。其次,它提供的分布式锁RLock接口,用法和JDK的Lock非常像,lock(), unlock(), tryLock(),学习成本极低。最重要的是,它帮你把那些麻烦事都做了:看门狗自动续期机制。你拿到锁之后,只要你的业务线程还活着,Redisson会在后台定期帮你延长锁的持有时间,根本不用担心任务没执行完锁就过期了。释放锁时也保证了原子性和客户端匹配。这种“开箱即用”的体验,能让我们把精力集中在业务整合上,而不是锁的实现细节上。

Redisson还支持**红锁(RedLock)**算法,这个我们后面会详细说。它的核心思想是为了在Redis主从架构下提供更强的安全性,虽然会牺牲一些性能,但对于要求绝对准确性的财务、订单类定时任务,多花这点开销是值得的。综合来看,在Quartz多机并发的场景下,Redisson是一个在功能、易用性和架构简洁性上取得很好平衡的选择。

3. 增强方案核心:改造BaseQuartzJob

知道了用什么工具,接下来就是怎么把它和Quartz优雅地结合起来。我们的目标很明确:在不改变现有使用习惯的前提下,让@DisallowConcurrentExecution注解具备跨节点的能力。也就是说,原来加了注解的Job类,我们不动它,只是让它继承一个我们改造过的基类,就能自动获得分布式锁的保护。

我设计了一个BaseQuartzJob抽象类,它同时实现Quartz的Job接口和Java的Runnable接口。为什么这么设计?主要是为了把Quartz的调度执行逻辑(execute方法)和我们实际的业务运行逻辑(run方法)分离开,让加锁的代码只存在于execute方法中,保持清晰。

public abstract class BaseQuartzJob implements Job, Runnable {
    // 锁名称的后缀,避免与其他业务锁冲突
    private static final String CONCURRENT_LOCK_SUFFIX = "_QUARTZ_CONCURRENT_LOCK";
    // 依赖注入Redisson客户端
    @Autowired
    private RedissonClient redissonClient;
    // 如果Job类有@DisallowConcurrentExecution注解,这个字段就不为null
    private String distributedLockKey;

    public BaseQuartzJob() {
        // 在构造器中,通过反射检查当前类是否被@DisallowConcurrentExecution标记
        DisallowConcurrentExecution annotation = this.getClass().getAnnotation(DisallowConcurrentExecution.class);
        if (annotation != null) {
            // 生成一个唯一的锁键,通常用类名+后缀,确保全局唯一
            this.distributedLockKey = this.getClass().getSimpleName() + CONCURRENT_LOCK_SUFFIX;
        }
    }

    @Override
    public void execute(JobExecutionContext context) throws JobExecutionException {
        // 这里可以加一些系统初始化状态的检查,比如Spring容器是否已加载完毕
        // if (!systemReady) { return; }

        // 关键逻辑:如果需要分布式锁
        if (distributedLockKey != null) {
            RLock lock = redissonClient.getLock(distributedLockKey);
            // 使用tryLock,尝试获取锁,获取不到立即返回false,不会阻塞
            // 你也可以用lock()方法,但那样会阻塞直到获取,可能影响调度器线程池
            boolean isLockAcquired = lock.tryLock();
            if (isLockAcquired) {
                try {
                    // 拿到锁了,安全地执行业务逻辑
                    this.run();
                } finally {
                    // 无论如何,最终都要释放锁
                    lock.unlock();
                }
            } else {
                // 没拿到锁,说明这个任务正在其他节点上执行
                // 我们可以记录一条日志,或者什么都不做,静默跳过本次执行
                // 这对于短周期、长执行时间的任务很关键,避免无意义的排队等待
                System.out.println("Job " + distributedLockKey + " is already running on another node, skip this execution.");
            }
        } else {
            // 如果Job类没有加@DisallowConcurrentExecution注解,则按原方式执行,不进行跨节点互斥
            this.run();
        }
    }

    // 业务逻辑放在run方法里,由子类实现
    @Override
    public abstract void run();
}

我来解释一下这段代码的几个关键点。首先,在构造函数里通过getAnnotation检查注解,并把结果缓存在distributedLockKey变量里。这样在每次execute被Quartz调用时,就不用再反射了,提升性能。锁的键(Key)我用的是类名加后缀,确保全局唯一性,也方便在Redis里查看和管理。

其次,在execute方法里,我们用了tryLock()而不是lock()。这一点非常重要。Quartz的调度线程池是有限的,如果你用阻塞式的lock(),当一个节点持有了锁,其他所有节点的调度线程都会阻塞在这个锁上等待。如果任务执行时间很长,可能会导致大量调度线程被挂起,影响其他定时任务的触发。而tryLock()是非阻塞的,拿不到锁立刻返回false,本次触发就被优雅地跳过了。对于那种“宁可少执行,不可错执行”的任务,这种行为是符合预期的。当然,如果你的业务要求必须排队执行,那可以考虑使用tryLock(long waitTime, TimeUnit unit)方法,设置一个合理的等待时间。

最后,锁的释放一定要放在finally块里。这是保证锁能被释放的最后一道防线,即使你的run()方法里抛出了异常,锁也能被正确释放,避免死锁。

4. 实战:将现有Job无缝升级

理论说再多,不如看实际怎么用。假设我们原来有一个数据同步的Job,它已经使用了@DisallowConcurrentExecution,但只对单机有效。

原来的代码可能是这样的:

@Component
@DisallowConcurrentExecution
public class DataSyncJob implements Job {
    @Autowired
    private DataSyncService syncService;

    @Override
    public void execute(JobExecutionContext context) throws JobExecutionException {
        syncService.syncFromExternalAPI();
    }
}

为了让它支持多机并发控制,我们只需要做两步简单的改造:

第一步,让这个Job继承我们刚才写的BaseQuartzJob。 第二步,把原来execute方法里的逻辑,移到run方法里。

改造后:

@Component
@DisallowConcurrentExecution // 这个注解保留,BaseQuartzJob的构造器会识别它
public class DataSyncJob extends BaseQuartzJob { // 改为继承BaseQuartzJob
    @Autowired
    private DataSyncService syncService;

    @Override
    public void run() { // 实现run方法,这里放核心业务逻辑
        syncService.syncFromExternalAPI();
    }
    // 注意:我们不再需要覆盖execute方法了,父类已经处理了加锁逻辑
}

看,是不是非常简单?原有的@DisallowConcurrentExecution注解原封不动,业务代码也只是换了个方法名(从execute搬到run)。对于团队其他成员来说,他们几乎感知不到底层已经从一个节点锁升级成了分布式锁。这就是我们设计这个方案想要达到的效果:增强而非改变。

部署到多机环境后,我们可以通过Redis的命令行来观察锁的状态。当任务在节点A上开始执行时,你连接上Redis,执行keys *_QUARTZ_CONCURRENT_LOCK,应该能看到对应的锁键。用redissonClient.getLock(key).isLocked()或者直接看Redis里该键的剩余生存时间,都能验证锁正在被持有。此时在节点B上,相同的Job被触发时,tryLock()会失败,并打印我们预设的跳过日志。这就完美实现了跨节点的任务互斥。

5. 深入配置:锁参数调优与红锁考量

用上了分布式锁,不代表就万事大吉了。锁的各种参数配置,直接影响到系统的稳定性和性能。Redisson的RLock提供了丰富的API,我们需要根据定时任务的特点来仔细调校。

首先是锁的超时时间。虽然Redisson有看门狗自动续期,但我们初始化锁的时候,还是可以指定一个leaseTime。在tryLock(long waitTime, long leaseTime, TimeUnit unit)方法中,leaseTime就是锁自动释放的时间。即使设置了看门狗,这个值也决定了续期的基准周期。我建议这个时间设置得比任务的最大可能执行时间再长一些,比如任务平时跑5分钟,但极端情况下可能要10分钟,那你就设15分钟。给一个缓冲,避免网络抖动或Full GC导致任务暂停几秒,就被看门狗误判为线程挂掉而释放锁(虽然概率极低)。

其次是获取锁的等待时间(waitTime)。前面我们用的是tryLock(),即waitTime=0。对于绝大多数定时任务场景,这是推荐的。因为定时任务通常是周期性的,错过一次,下次周期还会再来。但如果你的任务非常重要,不允许任何一次错过,可以考虑设置一个较短的等待时间,比如tryLock(5, 30, TimeUnit.SECONDS),意思是“我最多等5秒拿锁,锁的租期是30秒”。这样能在一定程度上平衡“绝对执行”和“线程阻塞”的矛盾。

接下来重点说说红锁(RedLock)。我们之前的代码redissonClient.getLock()获取的是普通的分布式锁,它只针对Redis集群中的一个主节点进行操作。在Redis哨兵或集群模式下,如果主节点宕机,但锁还没来得及同步到从节点,此时哨兵选举出了新的主节点,另一个客户端就可能再次获得同样的锁,导致互斥失效。虽然这种情况在完善的运维下很少发生,但对于金融、交易等场景,这是不可接受的风险。

红锁算法就是为了解决这个问题而生的。它的原理是,你同时向Redis集群中多个独立的主节点(不是主从,是真正独立的Redis实例)申请加锁,只有当超过半数的节点都加锁成功,才算真正获取到锁。这样,即使个别节点崩溃,也不会影响锁的安全性。在Redisson中使用红锁也很简单:

// 假设你有多个独立的RedissonClient实例,指向不同的Redis主节点
RedissonClient client1, client2, client3;
RLock lock1 = client1.getLock(distributedLockKey);
RLock lock2 = client2.getLock(distributedLockKey);
RLock lock3 = client3.getLock(distributedLockKey);

// 创建红锁
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
try {
    // 使用红锁尝试加锁,参数含义和普通锁一样
    if (redLock.tryLock()) {
        this.run();
    }
} finally {
    redLock.unlock();
}

使用红锁的代价是性能。每次加锁、解锁都需要与多个节点通信,网络开销和延迟都增加了。所以,你需要做一个权衡:如果你的定时任务处理的是“积分发放”、“优惠券计算”这类业务,偶尔的并发可能问题不大,用普通锁就行,追求性能。但如果处理的是“库存扣减”、“订单状态终态变更”,那必须上红锁,追求绝对安全。在我的经验里,大部分后台定时任务属于前者,所以普通分布式锁已经足够;而对于核心链路中的调度任务,红锁带来的那点性能损耗,在数据一致性面前根本不值一提。

6. 可能遇到的“坑”与最佳实践

方案落地过程中,我踩过几个坑,这里分享给你,希望能帮你绕过去。

第一个坑是锁的粒度。 我们方案里锁的Key是基于Job类名的。这意味着,同一个Job类的所有实例(即使JobDataMap参数不同)都共享同一把锁。这通常是符合@DisallowConcurrentExecution语义的。但如果你有特殊需求,比如同一个Job类,但根据传入的companyId参数执行不同公司的数据,你希望不同公司的任务可以并发,只有相同公司的任务才互斥。那你就需要改造锁Key的生成逻辑,把companyId也拼接进去,比如lockKey = jobClassSimpleName + "_" + companyId + SUFFIX。这需要你在BaseQuartzJob的构造器或execute方法里,能从JobExecutionContext中获取到这些参数。

第二个坑是关于任务恢复(Recovery)和错失触发(Misfire)。 Quartz集群中,如果一个节点正在执行任务时崩溃了,Quartz会检测到,并将这个任务标记为“恢复”,在另一个节点上重新触发。我们的加锁逻辑是在execute方法里,如果节点崩溃,finally块里的unlock可能没有执行,导致锁无法释放。好在Redisson的锁有超时机制,即使没有显式释放,超时后也会自动清除,不会造成永久死锁。但这就可能产生一个时间窗口:A节点崩溃,锁还没超时,B节点恢复执行任务时尝试获取锁失败,导致恢复的任务无法执行。为了处理这种情况,你可以考虑在BaseQuartzJob里判断context.isRecovering(),如果是恢复执行,也许可以采用更激进的锁获取策略(比如稍微长一点的waitTime),或者记录告警,让人工介入核查。

第三个坑是Redis本身的可用性。 你的分布式锁强依赖Redis。如果Redis挂了,所有定时任务都会因为拿不到锁而跳过执行。因此,Redis自身必须是高可用的,至少要用主从哨兵模式。同时,在BaseQuartzJob的execute方法里,要对redissonClient.getLock()等操作进行try-catch,捕获网络异常或Redis连接异常。在异常发生时,你需要决定是降级执行(不加锁直接运行run,这有并发风险),还是直接抛出异常让本次触发失败。我通常的做法是,如果捕获到的是明确的Redis连接失败异常,我会记录错误日志并降级执行,因为对于大多数业务来说,“有风险地执行”比“完全不执行”可能后果更轻一些。但这需要你和业务方确认。

基于这些经验,我总结了几条最佳实践:

  1. 锁Key命名清晰:使用包含项目前缀、Job类名、必要业务参数的格式,方便在Redis中识别和清理。
  2. 默认使用tryLock(0):避免阻塞调度线程,对于周期短的任务尤其重要。
  3. 合理设置锁超时:一定要大于任务最坏情况下的执行时间,并预留缓冲。
  4. 务必在finally中解锁:这是铁律。
  5. 监控锁的状态:在运维层面,监控Redis中这些锁Key的持有时间和等待情况,可以及时发现执行时间异常变长的任务或死锁苗头。
  6. 做好降级预案:考虑Redis不可用时的处理策略,并在代码中体现。

7. 性能影响与效果验证

最后,我们来聊聊大家最关心的问题:加了这么一层分布式锁,对性能影响大不大?以及,怎么验证它真的起作用了?

先说性能。额外的开销主要来自两个方面:一是与Redis的网络通信(加锁、解锁、续期),二是锁竞争带来的任务跳过或等待。对于网络通信,一次tryLock()和unlock()操作,在局域网内通常能在1毫秒内完成,这对于动辄数分钟执行时间的定时任务来说,损耗几乎可以忽略不计。Redisson的看门狗续期是异步进行的,对业务线程没有直接影响。所以,通信开销不是问题。

锁竞争的影响取决于你的任务执行时间(T_execute)和任务触发周期(T_interval)。如果T_execute远小于T_interval,比如任务跑1秒,每1分钟触发一次,那么锁几乎不会发生竞争,所有节点都能顺利拿到锁执行,没有额外损耗。如果T_execute接近或大于T_interval,比如任务跑10秒,每5秒触发一次,那么竞争就会很激烈。在我们的非阻塞tryLock(0)策略下,除了持有锁的节点,其他节点上的触发都会立即失败跳过。这实际上减少了不必要的计算资源消耗,避免了多个节点重复做同一件长耗时的事情,从系统整体负载角度看,可能反而是有益的。

那么如何验证呢?光看日志不够直观,我教你一个实用的测试方法。你可以写一个简单的测试Job,继承BaseQuartzJob,在run方法里先Thread.sleep(10000)模拟10秒工作,然后打印一行带时间戳和主机名的日志。把这个Job部署到两台机器,设置每3秒触发一次。运行起来后,观察日志输出。你会看到,在任意一个10秒的时间窗口内,只有一台机器在输出日志,另一台机器的触发会被跳过。10秒过后,锁释放,可能由另一台机器抢到锁并开始执行。通过这种方式,你可以清晰地看到任务在集群中被串行化执行的效果,而不是像以前那样两台机器同时打印日志。

另外,你还可以通过Redisson提供的API,或者直接连接Redis,查看锁的详细信息,比如哪个客户端(对应哪台机器)持有锁,锁的剩余生存时间等。这些都能成为你验证方案有效性的有力证据。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐