SpringBoot分布式定时任务实战:ShedLock解决方案 1. 为什么需要分布式定时任务在传统单机部署的SpringBoot应用中使用Scheduled注解就能轻松实现定时任务。但随着业务规模扩大当应用需要水平扩展部署多个实例时问题就出现了——每个实例上的定时任务都会同时执行导致任务重复执行、数据错乱等严重问题。我去年就遇到过这样的生产事故一个每天凌晨统计前日数据的报表任务由于部署了3个实例结果同一份报表被重复生成了3次导致下游系统数据异常。这就是典型的分布式环境下的定时任务问题。分布式定时任务的核心诉求可以总结为三点任务在集群中只执行一次任务失败后能够自动恢复任务执行情况可监控目前主流的解决方案有基于数据库锁如ShedLock基于Redis的分布式锁使用专门的分布式任务调度框架如XXL-JOB接下来我将重点介绍最轻量级的ShedLock方案它不需要引入额外的中间件仅依靠数据库就能实现分布式锁功能。2. ShedLock集成实战2.1 基础环境搭建首先创建一个标准的SpringBoot项目我习惯用Spring Initializr生成curl https://start.spring.io/starter.zip -d dependenciesweb,jdbc \ -d typegradle-project -d languagejava \ -d packageNamecom.example.scheduler -d namedistributed-scheduler \ -o scheduler.zip关键依赖如下Gradle配置implementation org.springframework.boot:spring-boot-starter-jdbc implementation net.javacrumbs.shedlock:shedlock-spring:4.29.0 implementation net.javacrumbs.shedlock:shedlock-provider-jdbc-template:4.29.0 runtimeOnly mysql:mysql-connector-java2.2 数据库表准备ShedLock需要在数据库中创建一张锁表MySQL建表语句如下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) );这个表结构有几个关键设计点name字段作为主键对应任务名lock_until记录锁的过期时间locked_at记录获取锁的时间locked_by记录获取锁的实例标识2.3 核心配置类创建ShedLock配置类Configuration EnableScheduling EnableSchedulerLock(defaultLockAtMostFor 10m) public class ShedLockConfig { Bean public LockProvider lockProvider(DataSource dataSource) { return new JdbcTemplateLockProvider( JdbcTemplateLockProvider.Configuration.builder() .withJdbcTemplate(new JdbcTemplate(dataSource)) .usingDbTime() // 使用数据库时间避免服务器时间不同步问题 .build() ); } }这里有几个实践经验值得分享defaultLockAtMostFor设置了默认最大锁定时长防止任务卡死导致锁永远不释放usingDbTime()非常重要可以避免不同服务器时间不同步导致的锁异常生产环境建议将锁表单独放在一个业务库避免影响主业务3. 定时任务实现细节3.1 基础任务示例下面是一个带ShedLock保护的定时任务Component public class ReportGenerator { Scheduled(cron 0 0 2 * * ?) // 每天凌晨2点执行 SchedulerLock(name dailyReport, lockAtLeastFor 5m, lockAtMostFor 14h) public void generateDailyReport() { // 复杂的报表生成逻辑 log.info(开始生成日报表...); // ... } }关键参数说明name锁的唯一标识集群内相同name的任务会互斥lockAtLeastFor最短持有锁时间防止任务执行过快导致锁被其他节点获取lockAtMostFor最大持有锁时间超时后锁自动释放3.2 动态参数任务有时我们需要从数据库读取任务配置Scheduled(fixedDelayString ${report.interval:300000}) SchedulerLock(name dynamicReport) public void dynamicTask() { ListReportConfig configs reportConfigRepository.findAll(); configs.forEach(config - { // 处理每个报表配置 }); }这种方式的优势在于执行间隔可以通过配置中心动态调整任务逻辑与配置解耦结合RefreshScope可以实现配置热更新3.3 任务监控与告警我们可以在切面中添加监控逻辑Aspect Component RequiredArgsConstructor public class SchedulerMonitor { private final MeterRegistry meterRegistry; Around(annotation(schedulerLock)) public Object monitor(ProceedingJoinPoint pjp, SchedulerLock schedulerLock) throws Throwable { String taskName schedulerLock.name(); Timer.Sample sample Timer.start(meterRegistry); try { return pjp.proceed(); } catch (Exception e) { Counter.builder(scheduler.error) .tag(task, taskName) .register(meterRegistry) .increment(); throw e; } finally { sample.stop(Timer.builder(scheduler.duration) .tag(task, taskName) .register(meterRegistry)); } } }这样就能实现任务执行耗时监控失败次数统计与PrometheusGrafana集成可视化4. 生产环境进阶配置4.1 多数据源场景当项目使用多数据源时需要指定锁表所在的数据源Bean public LockProvider lockProvider(Qualifier(schedulerDataSource) DataSource dataSource) { return new JdbcTemplateLockProvider( JdbcTemplateLockProvider.Configuration.builder() .withJdbcTemplate(new JdbcTemplate(dataSource)) .withTableName(my_shedlock) // 自定义表名 .build() ); }4.2 锁竞争优化当集群节点较多时可以调整锁获取策略Bean public ScheduledLockConfiguration taskScheduler(LockProvider lockProvider) { return ScheduledLockConfigurationBuilder .withLockProvider(lockProvider) .withPoolSize(10) // 调度线程池大小 .withDefaultLockAtMostFor(Duration.ofMinutes(30)) .build(); }4.3 故障转移处理通过实现LockExtender接口可以增强故障转移能力public class CustomLockExtender implements LockExtender { Override public void extendLock(LockConfiguration lockConfig) { // 在任务执行期间定期延长锁有效期 } }5. 常见问题排查5.1 锁不释放问题现象任务执行完成后锁仍然存在 可能原因任务执行时间超过lockAtMostFor设置数据库连接异常导致释放失败服务器时间不同步解决方案-- 手动释放锁 UPDATE shedlock SET lock_until NOW() WHERE name taskName;5.2 任务重复执行现象多个实例同时执行了任务 排查步骤检查shedlock表中对应任务的locked_by字段确认各实例的系统时间是否同步检查数据库隔离级别需要READ_COMMITTED以上5.3 性能优化建议对于高频任务适当调小lockAtLeastFor考虑使用Redis等内存数据库替代JDBC将锁表放在单独的数据库实例我在实际项目中发现当QPS超过100时MySQL版的ShedLock会成为瓶颈这时就需要考虑升级到Redis或专业的任务调度系统了。

本月热点