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

1895

积分

0

好友

247

主题
发表于 9 小时前 | 查看: 2| 回复: 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;
    }
    // 真正的任务逻辑
}

这种方案的缺点显而易见:它严重依赖于单点。一旦指定的那台服务器宕机,整个定时任务就会彻底中断,系统的可靠性无法保证。

方案二:基于 Redis 的分布式锁
另一种常见做法是利用 Redis 的 SETNX 命令(或借助 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

ShedLock 是一个轻量级的库,它的设计目标非常纯粹:确保你的定时任务在多实例部署时最多只执行一次。它支持多种存储后端(如关系型数据库、Redis、MongoDB 等)来记录锁的状态。

其核心机制简单而有效:在每个任务触发时,所有实例都会尝试去存储层“抢占”一个以任务名命名的锁。只有一个实例能成功抢到锁,只有抢到锁的实例才会执行任务逻辑,其他实例则自动跳过。

集成步骤详解(以JDBC为例)

1. 添加项目依赖
首先,在你的 pom.xml 中添加 ShedLock 的核心依赖以及你选用的 LockProvider 实现。这里我们以使用数据库(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
创建一个配置类,声明一个 LockProvider Bean。这里配置了使用我们刚刚创建的数据表和默认的数据源。

@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 这个任务在每天凌晨2点都只会被一个实例执行一次。

关键注解参数解析

@SchedulerLock 注解有几个非常重要的参数,理解它们有助于你更好地使用 ShedLock:

  • name:锁的唯一标识。所有同名任务会互斥执行。务必为每个任务起一个独特的名字。
  • lockAtMostFor锁的最大持有时间。这是一个安全机制,用于防止任务执行过程中实例崩溃,导致锁永远不被释放(死锁)。例如设置为 "5m",即使任务执行超时或实例宕机,锁也会在5分钟后自动过期,允许其他实例接手。建议设置为任务预期执行时间的2-3倍。
  • lockAtLeastFor锁的最小持有时间。这个参数用于处理执行速度非常快的定时任务。例如,一个每分钟执行一次的任务可能仅需5秒就完成了。如果没有最小持有时间,锁可能立即释放,导致在同一分钟周期内,另一个实例又抢到锁并再次执行。设置 lockAtLeastFor = "55s" 可以确保锁至少持有55秒,从而避免在同一分钟内重复执行。

背后的工作原理

ShedLock 的实现原理清晰且高效。它本质上是在存储层进行一种“乐观锁”式的抢占。以数据库为例,当一个任务触发时,它会执行类似下面的SQL:

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 小于当前时间),则更新成功,当前实例抢到锁,可以执行任务。
  • 如果锁记录尚未过期,则更新操作影响行数为0,当前实例抢锁失败,直接跳过任务执行。

任务完成后,实例无需主动删除锁记录,只需等待锁的 lock_until 时间自然过期即可。这种“自动过期”的设计,是其可靠性的重要保障。

适用场景与边界

清楚工具的定位,才能更好地使用它。ShedLock 是一个专门为定时任务设计的协调器

它非常适合以下场景:

  • 需要确保多实例环境下定时任务(如每日报表、数据同步、缓存刷新)的互斥执行。
  • 希望以声明式(注解)方式管理锁,减少样板代码。
  • 需要内置的锁超时机制来防止死锁。

它并不适合以下场景:

  • 高并发业务场景下的锁竞争(如秒杀、库存扣减)。ShedLock 的锁粒度粗,且基于定时触发,并非为毫秒级高频竞争设计。
  • 需要可重入锁、读写锁、公平锁等复杂特性的场景。
  • 需要精细控制锁获取和释放逻辑的业务流程。

简单来说,ShedLock 和 Redisson 这类通用分布式锁是互补关系。如果你的业务代码中需要复杂的锁逻辑,请选择后者;如果你只想简单优雅地解决定时任务重复执行的问题,ShedLock 是更佳选择。

实践中的注意事项

  1. 存储后端的选择:本文演示了基于 JDBC 的用法。你也可以根据项目现有技术栈,轻松切换为 Redis、MongoDB 等 Provider。
  2. 自定义锁标识:数据库表中的 locked_by 字段默认记录主机名。你可以在配置时通过 .withLockedByValue() 方法将其自定义为 IP 或更易识别的实例ID。
  3. 主从数据库环境:如果使用数据库作为存储,请确保 ShedLock 的 LockProvider 连接的是主库(写库),以避免因主从延迟导致锁状态不一致。

总结

面对 Spring Boot 多实例部署时定时任务重复执行的经典问题,ShedLock 提供了一套专注且优雅的解决方案。它通过注解将分布式锁的逻辑对开发者透明化,集成了自动过期机制保障可靠性,并支持多种存储后端以适应不同技术栈。

其优势在于简单、专注、低侵入性;其局限在于仅适用于定时任务调度场景。相比于手动编写 Redis 分布式锁代码,ShedLock 让代码更加清晰简洁。下次当你的 Spring Boot 应用需要横向扩展时,不妨考虑用它来轻松管理你的定时任务。




上一篇:一根网线引发的全网瘫痪:二层环路真实事故复盘与防环技术解析
下一篇:微服务架构决策指南:何时拆分与治理的实战思考
您需要登录后才可以回帖 登录 | 立即注册

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

GMT+8, 2026-1-27 18:14 , Processed in 0.260913 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

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