如何确保Spring Batch作业实例在多节点并发启动时仅执行一次
我们在两个节点部署了两个Spring Boot后端副本,两个后端会并发启动同一个作业实例。该作业实例通过参数execution-date标识,此参数为向下取整到分钟起始时刻的Java日期(秒、毫秒设为0),执行代码如下:
try { var executionDate = DateTimeUtils.roundDownToBeginOfMinute(new Date()); var params = new HashMap<String, JobParameter>(); params.put("execution-date", new JobParameter(executionDate, true)); var jobExecution = jobLauncher.run(job, new JobParameters(params)); ... } catch (JobInstanceAlreadyCompleteException | JobExecutionAlreadyRunningException e) { log.info("Job already executed on other node"); } catch (Exception e) { log.error("Unexpected exception", e); }
遇到的问题
- 副本1抛出预期的
org.springframework.batch.core.repository.JobExecutionAlreadyRunningException - 副本2在327毫秒后抛出:
org.springframework.dao.DuplicateKeyException: PreparedStatementCallback; SQL [INSERT into BATCH_JOB_INSTANCE(JOB_INSTANCE_ID, JOB_NAME, JOB_KEY, VERSION) values (?, ?, ?, ?)]; ORA-00001: unique constraint (APP.JOB_INST_UN) violated - 所有副本均未执行作业
环境版本
- Spring Boot: 2.7.15
- spring-core: 5.3.29
- spring-batch-core: 4.3.9
- DB: Oracle Database 19c Enterprise Edition Release 19.0.0.0.0 - Production Version 19.19.0.0.0, Case sensitivity: plain=upper, delimited=exact
已尝试的方案及新问题
设置spring.batch.jdbc.isolation-level-for-create=SERIALIZABLE后,出现新异常行为:
- 多次出现两个副本启动作业时均抛出
CannotSerializeTransactionException,且均未执行作业 - 出现一次同一作业实例被执行两次的情况,作业仓库表(
BATCH_JOB_EXECUTION、BATCH_JOB_EXECUTION_PARAMS)中存在两条相同作业参数的记录
我们已实现一个基于数据库表(含execution-date唯一约束)的可行方案,但希望借助Spring Batch作业仓库实现并发作业执行的锁机制,请问该如何处理?
1. 理解并发冲突根源
Spring Batch的作业实例由JOB_NAME + JOB_KEY(作业参数生成)唯一标识。并发场景下,两个节点同时尝试创建同一作业实例时,会触发BATCH_JOB_INSTANCE表的唯一约束冲突。而SERIALIZABLE隔离级别在Oracle中易导致双方事务回滚,甚至因间隙锁逻辑漏洞出现重复执行的极端情况。
2. 配置作业启动器的专属事务管理器
不要全局设置序列化隔离级别,而是为作业启动器单独配置REPEATABLE_READ隔离级别的事务管理器(Oracle默认是READ_COMMITTED,REPEATABLE_READ更适合避免幻读):
@Bean public PlatformTransactionManager batchTransactionManager(DataSource dataSource) { DataSourceTransactionManager transactionManager = new DataSourceTransactionManager(dataSource); transactionManager.setDefaultTransactionIsolationLevel(Connection.TRANSACTION_REPEATABLE_READ); return transactionManager; } @Bean public JobLauncher jobLauncher(JobRepository jobRepository, PlatformTransactionManager batchTransactionManager) { SimpleJobLauncher jobLauncher = new SimpleJobLauncher(); jobLauncher.setJobRepository(jobRepository); jobLauncher.setTransactionManager(batchTransactionManager); return jobLauncher; }
3. 扩展异常处理逻辑
补充捕获DuplicateKeyException,将其作为作业实例已被其他节点创建的判定依据,确保冲突时只有一个节点执行作业:
try { var executionDate = DateTimeUtils.roundDownToBeginOfMinute(new Date()); var params = new HashMap<String, JobParameter>(); params.put("execution-date", new JobParameter(executionDate, true)); var jobExecution = jobLauncher.run(job, new JobParameters(params)); ... } catch (JobInstanceAlreadyCompleteException | JobExecutionAlreadyRunningException e) { log.info("Job already executed on other node"); } catch (DuplicateKeyException e) { // 捕获唯一约束冲突,判定为其他节点已创建作业实例 log.info("Job instance already created by other node"); } catch (Exception e) { log.error("Unexpected exception", e); }
4. 确保作业参数的一致性
确认DateTimeUtils.roundDownToBeginOfMinute方法在所有节点的时间一致性,避免时钟偏差导致execution-date参数不一致。推荐改用数据库统一生成时间参数:
// 调用Oracle数据库的TRUNC函数获取分钟起始时间 String sql = "SELECT TRUNC(SYSDATE, 'MI') FROM DUAL"; Date executionDate = jdbcTemplate.queryForObject(sql, Date.class);
5. 启用Spring Batch分布式锁(可选)
Spring Batch 4.3+支持基于数据库的分布式锁,可通过配置JobRepository启用:
@Bean public JobRepository jobRepository(DataSource dataSource, PlatformTransactionManager transactionManager) throws Exception { JobRepositoryFactoryBean factory = new JobRepositoryFactoryBean(); factory.setDataSource(dataSource); factory.setTransactionManager(transactionManager); factory.setIsolationLevelForCreate("ISOLATION_REPEATABLE_READ"); // 启用数据库分布式锁 factory.setLockProvider(new DefaultLockProvider(dataSource)); return factory.getObject(); }
关键注意事项
- 避免使用
SERIALIZABLE隔离级别,Oracle该级别下的事务逻辑会大幅降低可用性; DuplicateKeyException是并发创建作业实例冲突的核心信号,必须纳入异常处理;- 时间参数的全局一致性是避免重复作业的基础,建议使用NTP同步节点时间或统一依赖数据库时间源。
内容的提问来源于stack exchange,提问作者bahnson

