找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

723

积分

0

好友

97

主题
发表于 9 小时前 | 查看: 1| 回复: 0

Spring Boot 应用中使用 @Scheduled 注解开发定时任务非常便捷,但在多实例部署的架构中,一个棘手的问题就浮现了:同一个定时任务会在集群中的每一个实例上同时触发执行。

设想一下,如果你的应用部署了两台服务器,那么原本计划在凌晨2点执行的数据统计任务,就会在两台机器上各跑一遍。这不仅会导致统计数据重复计算,还可能引发文件重复生成、消息重复发送等一系列数据一致性问题。

为了解决这个多实例环境下定时任务互斥执行的需求,通常有几种思路。

常见解决方案对比

方案一:指定单机执行

最直接的想法是让任务只在某一台指定的机器上运行。实现方式通常是在任务方法开始时,判断当前服务器的主机名或IP。

@Scheduled(cron = "0 0 2 * * ?")
public void scheduledTask() {
    String hostname = InetAddress.getLocalHost().getHostName();
    if (!"app-server-01".equals(hostname)) {
        return;
    }
    // 执行实际的业务逻辑
}

这种方式简单粗暴,但缺陷也非常明显:存在单点故障风险。如果指定的那台机器(app-server-01)宕机了,那么这个定时任务就彻底无法执行了,系统的可靠性大打折扣。

方案二:基于Redis实现分布式锁

另一种常见的做法是借助 RedisSETNX 命令(或使用 Redisson 等客户端)来实现一个简单的分布式锁。

@Scheduled(cron = "0 0 2 * * ?")
public void scheduledTask() {
    Boolean locked = stringRedisTemplate.opsForValue()
        .setIfAbsent("task:data-sync", "1", 10, TimeUnit.MINUTES);
    if (Boolean.FALSE.equals(locked)) {
        return;
    }
    try {
        // 执行实际的业务逻辑
    } finally {
        stringRedisTemplate.delete("task:data-sync");
    }
}

这个方案是可行的,并且被广泛应用。但它也有一些小麻烦:每个定时任务都需要手动编写加锁、释放锁的样板代码。如果你的项目里没有引入 Redis,还需要额外引入和配置,增加了复杂度。

那么,有没有一种更优雅、更专注于此场景的解决方案呢?答案是肯定的。

引入专为此场景设计的 ShedLock

ShedLock 是一个轻量级的库,它的设计目标非常单一且明确:确保你的定时任务在多实例部署时,同一时间最多只有一个实例在执行。

它的核心思想并不复杂:在某个共享的存储中(如数据库、Redis等)记录每个任务的“锁”状态。任务执行前,实例会尝试去获取这个锁,只有成功获取到锁的实例才能执行任务逻辑。

相比自己实现分布式锁,ShedLock 通过注解将锁逻辑透明化,让开发者可以更专注于业务代码。

集成步骤详解(以数据库存储为例)

1. 添加Maven依赖

首先,在项目的 pom.xml 中添加 ShedLock 的核心依赖以及你选用的锁提供者(这里以JDBC为例)。

<dependency>
    <groupId>net.javacrumbs.shedlock</groupId>
    <artifactId>shedlock-spring</artifactId>
    <version>4.42.0</version>
</dependency>
<dependency>
    <groupId>net.javacrumbs.shedlock</groupId>
    <artifactId>shedlock-provider-jdbc-template</artifactId>
    <version>4.42.0</version>
</dependency>

2. 创建锁记录表

在你的业务数据库中创建一张表,用于存储锁信息。ShedLock 需要这张表来协调各个实例。

CREATE TABLE shedlock (
    name VARCHAR(64) NOT NULL,
    lock_until TIMESTAMP(3) NOT NULL,
    locked_at TIMESTAMP(3) NOT NULL,
    locked_by VARCHAR(255) NOT NULL,
    PRIMARY KEY (name)
);

3. 配置 LockProvider

创建一个Spring配置类,用于声明 LockProvider Bean。@EnableSchedulerLock 注解用于启用 ShedLock 的支持。

@Configuration
@EnableSchedulerLock(defaultLockAtMostFor = "10m")
public class ShedLockConfig {

    @Bean
    public LockProvider lockProvider(DataSource dataSource) {
        return new JdbcTemplateLockProvider(
            JdbcTemplateLockProvider.Configuration.builder()
                .withDataSource(dataSource)
                .withTableName("shedlock")
                .build()
        );
    }
}

4. 为定时任务添加注解

最后,也是最简单的一步:在你原有的 @Scheduled 定时任务方法上,额外添加一个 @SchedulerLock 注解。

@Scheduled(cron = “0 0 2 * * ?”)
@SchedulerLock(name = “dataSyncTask”, lockAtMostFor = “5m”)
public void syncData() {
    // 现在,这段逻辑在集群中只会被执行一次
}

完成以上四步后,无论你的应用部署了多少个实例,syncData 这个任务在每次触发时,都只会有一个实例成功执行,完美解决了重复执行的问题。

@SchedulerLock 注解参数详解

为了让 ShedLock 更好地工作,理解 @SchedulerLock 注解的几个关键参数至关重要。

  • name (必填):锁的唯一名称。所有同名任务的执行会互斥。建议使用能清晰标识任务功能的字符串。
  • lockAtMostFor锁的最大持有时间。这是一个安全机制,用于防止任务执行过程中实例崩溃,导致锁永远无法释放(死锁)。例如设置为 “5m”,即使任务执行超时或实例宕机,锁也会在5分钟后自动失效,其他实例便可接手。通常建议设置为任务预估执行时间的2-3倍。
  • lockAtLeastFor锁的最小持有时间。这个参数用于解决“执行过快”导致的问题。想象一个场景:你的任务每分钟执行一次,但实际逻辑5秒就完成了。如果没有最小持有时间,锁会立即释放,可能导致另一个实例在同一分钟周期内再次获取锁并执行。设置 lockAtLeastFor = “1m” 可以确保锁至少被持有到下一个执行周期开始。

ShedLock 是如何工作的?

ShedLock 的实现原理简洁而有效。当定时任务触发时,框架会尝试执行一条类似下面的SQL语句(以MySQL为例):

INSERT INTO shedlock (name, lock_until, locked_at, locked_by)
VALUES ('dataSyncTask’, ‘2025-01-25 02:05:00’, NOW(), ‘192.168.1.10’)
ON DUPLICATE KEY UPDATE
   lock_until = ‘2025-01-25 02:05:00’,
   locked_at = NOW(),
   locked_by = ‘192.168.1.10’
WHERE lock_until <= NOW();

关键在于最后的 WHERE lock_until <= NOW() 条件:

  • 如果数据库中该任务名的锁记录已过期(lock_until 小于当前时间),则 UPDATE 成功,当前实例抢到锁,任务得以执行。
  • 如果锁仍未过期(其他实例正持有锁),则 UPDATE 影响行数为0,抢锁失败,当前实例直接跳过任务执行。

任务执行完成后,不需要主动删除锁记录,只需等待其 lock_until 时间自然过期即可。这种“基于时间过期”的机制是其设计的一大亮点。

适用场景与局限性

ShedLock 的定位非常清晰,理解其边界能帮助你做出正确的技术选型。

它非常适合以下场景:

  • 需要确保在多实例环境下,定时任务(如数据同步、报表生成、缓存清理)不会重复执行。
  • 希望用声明式(注解)的方式管理锁,减少样板代码。
  • 需要自动的锁超时释放机制来避免死锁。

它并不适合以下场景:

  • 高并发抢锁的业务场景,例如秒杀、库存扣减。ShedLock 是为调度设计的,而非高并发控制
  • 需要可重入锁、读写锁、公平锁等复杂特性的场景。
  • 需要精细控制锁获取和释放时机的业务逻辑。

简而言之,ShedLockRedisson 这类通用的分布式锁框架是互补关系。如果你的业务代码本身需要复杂的锁机制,请选择后者;如果只是想解决 @Scheduled 任务重复执行这个特定问题,ShedLock 是更简洁、更专注的选择。

实践中的注意事项

  1. 存储后端可选:本文演示了基于数据库(JDBC)的集成方式。ShedLock 还支持 RedisMongoDBZooKeeper 等多种存储,你可以根据项目现有的技术栈灵活选择。
  2. 自定义表名和机器标识:在使用JDBC提供者时,你可以通过配置自定义锁表名(非默认的 shedlock)。locked_by 字段默认记录主机名,你也可以通过 .withLockedByValue() 方法将其设置为IP地址或任何有意义的实例标识符。
  3. 注意主从数据源:如果你的项目配置了数据库主从复制,请确保 ShedLockLockProvider 使用的是主库(Master) 数据源,以避免因主从延迟导致的锁状态感知错误。

总结

ShedLock 是一个为解决特定痛点而生的优秀工具,它让 Spring Boot 应用在多实例部署时管理定时任务变得轻而易举。

  • 优点:注解驱动、集成简单、自动处理锁过期、支持多种存储后端,极大减少了开发负担。
  • 局限:仅适用于定时任务调度场景,不能替代通用的业务分布式锁。

与手动编写 Redis 分布式锁代码相比,ShedLock 将定时任务特有的互斥逻辑抽象成了框架行为,使代码更加清晰、维护性更高。下次当你面临多实例定时任务的协调问题时,不妨考虑一下这个轻量而高效的解决方案。




上一篇:Vue应用未授权接口漏洞挖掘实战复盘:从登录框到批量数据泄露
下一篇:Python数据压缩利器Blosc:实测比gzip快百倍,轻松应对海量数值存储
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-1-27 18:15 , Processed in 0.272129 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表