1. 分布式定时任务的挑战与解决方案
在分布式系统中实现定时任务的精确控制一直是个经典难题。我最近在金融支付系统架构升级中就遇到了这个痛点:原本单机运行的日终对账任务,在迁移到Spring Cloud微服务集群后,出现了多个节点重复执行的问题。这直接导致账务数据被重复处理,产生了严重的业务逻辑错误。
1.1 问题本质分析
当我们在JAVA应用中通过@Scheduled注解创建定时任务时,默认情况下每个服务实例都会独立执行任务。这在分布式环境下会产生三个典型问题:
- 重复执行:所有节点同时触发任务,导致业务逻辑被多次执行
- 资源竞争:多个实例同时操作共享资源(如数据库行锁)
- 状态不一致:不同节点读取到中间状态数据
以我们系统的对账任务为例,伪代码如下:
@Scheduled(cron = "0 0 23 * * ?") public void reconcileAccounts() { // 读取交易记录 // 比对账户余额 // 生成差异报告 }当这个服务以3个节点部署时,每天23点就会同时产生3份对账报告,完全违背了业务需求。
1.2 解决方案选型
目前主流解决方案可分为三类:
| 方案类型 | 代表实现 | 优点 | 缺点 |
|---|---|---|---|
| 数据库锁 | ShedLock | 实现简单,依赖少 | 性能较差,锁粒度粗 |
| 分布式协调 | ZooKeeper | 可靠性高 | 引入额外组件 |
| 中间件特性 | Redis SETNX | 性能好 | 需处理锁续期问题 |
经过压测对比,我们最终选择了ShedLock方案。主要基于以下考虑:
- 系统已使用MySQL,不希望引入新组件
- 对账任务执行频率低(每日一次)
- 需要最小化改造现有代码
2. ShedLock实现详解
2.1 核心机制解析
ShedLock通过创建锁表记录来实现分布式协调,其工作原理可分为四个阶段:
- 锁获取阶段:任务启动时尝试插入锁记录
- 执行判定阶段:检查插入结果判断是否获得锁
- 任务执行阶段:持有锁的节点执行业务逻辑
- 锁释放阶段:更新记录状态(或等待自动过期)
锁表结构示例:
CREATE TABLE shedlock( name VARCHAR(64) PRIMARY KEY, lock_until TIMESTAMP(3) NULL, locked_at TIMESTAMP(3) NULL, locked_by VARCHAR(255) );2.2 具体实现步骤
2.2.1 环境配置
- 添加Maven依赖:
<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>- 创建锁表(以MySQL为例):
CREATE TABLE shedlock ( name VARCHAR(64) NOT NULL, lock_until TIMESTAMP(3) NOT NULL, locked_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), locked_by VARCHAR(255) NOT NULL, PRIMARY KEY (name) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;2.2.2 Spring Boot集成
配置类示例:
@Configuration @EnableScheduling @EnableSchedulerLock(defaultLockAtMostFor = "10m") public class SchedulerConfig { @Bean public LockProvider lockProvider(DataSource dataSource) { return new JdbcTemplateLockProvider( JdbcTemplateLockProvider.Configuration.builder() .withJdbcTemplate(new JdbcTemplate(dataSource)) .usingDbTime() // 使用数据库时间避免时钟不同步 .build() ); } }2.2.3 定时任务改造
原始任务改造后:
@Scheduled(cron = "0 0 23 * * ?") @SchedulerLock( name = "accountReconciliation", lockAtLeastFor = "5m", lockAtMostFor = "60m" ) public void reconcileAccounts() { // 业务逻辑保持不变 }关键参数说明:
name:全局唯一的锁标识lockAtLeastFor:最短持有时间(防止过早释放)lockAtMostFor:最大持有时间(防止死锁)
3. 生产环境优化实践
3.1 性能调优技巧
锁超时设置:根据任务执行时间动态调整
- 短任务(<1分钟):lockAtMostFor="2m"
- 长任务(>10分钟):lockAtMostFor="任务时间×1.5"
数据库优化:
ALTER TABLE shedlock ADD INDEX idx_lock_until (lock_until);监控配置:
@Bean public LockProvider lockProvider(DataSource dataSource) { return new JdbcTemplateLockProvider( Configuration.builder() .withJdbcTemplate(new JdbcTemplate(dataSource)) .withTableName("system_shedlock") // 自定义表名 .withColumnNames(new ColumnNames("lock_name", "lock_timeout", "lock_acquired", "lock_owner")) .withIsolationLevel(Connection.TRANSACTION_READ_COMMITTED) .build() ); }
3.2 高可用方案
对于关键任务建议采用双保险策略:
- 主方案:ShedLock保证分布式协调
- 备方案:任务表增加执行状态检查
@Transactional @SchedulerLock(...) public void criticalTask() { TaskRecord record = taskRepository.findByTaskNameAndDate(); if (record != null && record.getStatus() == Status.COMPLETED) { return; } // 执行业务逻辑 }
4. 常见问题排查指南
4.1 锁失效场景
现象:多个节点同时执行任务
排查步骤:
- 检查数据库时间是否同步
SELECT NOW() FROM dual; -- 在所有节点执行 - 验证锁表记录
SELECT * FROM shedlock WHERE name = 'taskName'; - 检查事务隔离级别(需要READ_COMMITTED以上)
4.2 性能瓶颈处理
现象:任务延迟执行
优化方案:
- 将锁表单独放在高性能实例
- 调整锁超时时间
@SchedulerLock(lockAtMostFor = "${reconcile.timeout:30m}") - 考虑改用Redis实现
@Bean public LockProvider lockProvider(RedisTemplate redisTemplate) { return new RedisLockProvider(redisTemplate.getConnectionFactory()); }
4.3 锁监控方案
建议通过Spring Actuator添加健康检查:
@Bean public HealthIndicator lockHealthIndicator(LockProvider lockProvider) { return () -> { try { lockProvider.lock(new LockConfiguration( Instant.now(), "health_check", Duration.ofSeconds(1), Duration.ZERO )).ifPresent(Lock::unlock); return Health.up().build(); } catch (Exception e) { return Health.down(e).build(); } }; }5. 替代方案对比
5.1 XXL-JOB方案
适用于需要集中管理的场景:
@XxlJob("accountReconciliation") public void reconcileAccounts() { // 通过XXL-JOB控制台管理触发 }优势:
- 提供可视化任务管理
- 支持故障转移
- 完善的日志追踪
5.2 Redis分布式锁
适合高频短任务:
private final RedissonClient redisson; @Scheduled(...) public void task() { RLock lock = redisson.getLock("taskLock"); try { if (lock.tryLock(0, 30, TimeUnit.SECONDS)) { // 业务逻辑 } } finally { lock.unlock(); } }5.3 Quartz集群方案
需要配置quartz.properties:
org.quartz.jobStore.isClustered=true org.quartz.jobStore.clusterCheckinInterval=20000数据库需要创建11张Quartz专用表,适合重型调度系统。