如何重新定义 Spring Batch Job 的数据库事务边界?
明确结论
Spring Batch Step 的事务传播行为可以调整,但不建议只是把现有 chunk 的事务移到 REST API 调用之后。
支付网关调用和本地数据库事务无法组成普通的原子事务。数据库事务可以回滚,已经发送给支付网关的请求却无法自动撤销。改变事务边界确实能缩短锁的持有时间,但处理不当会带来重复扣款、状态丢失等更严重的问题。
更合适的做法是把处理流程拆成几个可恢复的状态:
- 在短事务中领取待处理记录,并将其标记为
PROCESSING。 - 在数据库事务之外调用支付网关。
- 在另一个短事务中保存支付结果,将状态更新为
SUCCEEDED或FAILED。 - 通过幂等键、唯一约束和状态机处理重试、崩溃恢复与并发执行。
Spring Batch 能够实现这种模式,不需要因为存在远程 API 调用而更换批处理框架。
默认 chunk 为什么会持有较长事务
典型的 chunk-oriented Step 大致在下面的事务范围内执行:
BEGIN TRANSACTION
read
process
write
COMMIT
Spring Batch 会在达到 commit-interval 或 chunk size 后提交当前 chunk。
如果 ItemWriter 在事务中调用支付网关,等待网络响应的时间也会计入事务持续时间。数据库是否真的产生表锁,取决于数据库类型、隔离级别、SQL、索引,以及是否使用了 SELECT ... FOR UPDATE 等机制。不过,长事务通常会增加以下风险:
- 行锁持有时间更长;
- MVCC 版本或 undo 数据积压;
- 数据库连接被长期占用;
- 并发 Job 更容易互相等待;
- API 超时导致整个 chunk 回滚;
- chunk 重试时可能再次调用支付接口。
最后一种情况对支付系统尤其危险。本地事务回滚,不代表网关没有完成扣款。
推荐的处理流程
可以为支付记录设计一套明确的状态机:
PENDING -> PROCESSING -> SUCCEEDED
-> FAILED
-> UNKNOWN
各状态的含义如下:
PENDING:等待处理;PROCESSING:记录已经被某个 worker 领取;SUCCEEDED:网关确认支付成功;FAILED:网关明确返回失败;UNKNOWN:请求超时或响应丢失,无法判断网关是否已经处理。
第一阶段:用短事务领取记录
在短事务中选择记录并更新状态:
UPDATE payment
SET status = 'PROCESSING',
worker_id = :workerId,
processing_started_at = CURRENT_TIMESTAMP
WHERE id = :id
AND status = 'PENDING';
执行后必须检查受影响的行数。只有更新成功的 worker 才能继续处理这条记录。这种乐观领取方式可以防止多个实例重复处理同一笔支付。
如果数据库支持,也可以通过 FOR UPDATE SKIP LOCKED 批量领取记录。不过,具体语法和行为取决于数据库产品,使用前需要确认对应数据库的事务语义。
第二阶段:在事务外调用支付网关
领取事务提交后,再发起 HTTP 请求:
PaymentGatewayResponse response =
paymentGateway.charge(payment, payment.getIdempotencyKey());
这样,即使网关需要 30 秒才能响应,也不会延长领取事务中的数据库锁。
支付请求必须携带稳定的幂等键。同一笔业务发生重试时,应继续使用原来的键,不能每次生成新值。
第三阶段:用短事务保存结果
收到明确结果后,在新的短事务中更新数据库:
UPDATE payment
SET status = 'SUCCEEDED',
gateway_transaction_id = :gatewayTransactionId,
processed_at = CURRENT_TIMESTAMP
WHERE id = :id
AND status = 'PROCESSING';
失败结果也需要持久化:
UPDATE payment
SET status = 'FAILED',
failure_code = :failureCode,
processed_at = CURRENT_TIMESTAMP
WHERE id = :id
AND status = 'PROCESSING';
连接超时不能直接视为支付失败。请求可能已经到达网关,只是响应没有返回。遇到这种情况,应将记录标记为 UNKNOWN,随后通过幂等重试或网关查询接口确认最终状态。
使用 Spring Batch 实现
一种直接的实现方式是使用自定义 Tasklet。让 Step 的主体不在数据库事务中运行,只用 TransactionTemplate 包裹必要的数据库操作。
下面是一个简化示例:
public class PaymentTasklet implements Tasklet {
private final PaymentRepository paymentRepository;
private final PaymentGateway paymentGateway;
private final TransactionTemplate transactionTemplate;
public PaymentTasklet(
PaymentRepository paymentRepository,
PaymentGateway paymentGateway,
PlatformTransactionManager transactionManager) {
this.paymentRepository = paymentRepository;
this.paymentGateway = paymentGateway;
this.transactionTemplate = new TransactionTemplate(transactionManager);
this.transactionTemplate.setPropagationBehavior(
TransactionDefinition.PROPAGATION_REQUIRES_NEW);
}
@Override
public RepeatStatus execute(
StepContribution contribution,
ChunkContext chunkContext) {
ClaimedPayment payment = transactionTemplate.execute(
status -> paymentRepository.claimNextPayment());
if (payment == null) {
return RepeatStatus.FINISHED;
}
PaymentGatewayResponse response;
try {
// 不放在 transactionTemplate 中
response = paymentGateway.charge(
payment.id(),
payment.amount(),
payment.idempotencyKey());
} catch (SocketTimeoutException ex) {
transactionTemplate.executeWithoutResult(
status -> paymentRepository.markUnknown(
payment.id(), ex.getMessage()));
return RepeatStatus.CONTINUABLE;
} catch (Exception ex) {
transactionTemplate.executeWithoutResult(
status -> paymentRepository.markRetryableFailure(
payment.id(), ex.getMessage()));
return RepeatStatus.CONTINUABLE;
}
transactionTemplate.executeWithoutResult(status -> {
if (response.isSuccessful()) {
paymentRepository.markSucceeded(
payment.id(),
response.getTransactionId());
} else {
paymentRepository.markFailed(
payment.id(),
response.getFailureCode());
}
});
return RepeatStatus.CONTINUABLE;
}
}
这里需要注意,Tasklet 自身不能再被外层数据库事务完整包裹,否则 REST 调用仍会落在长事务范围内。可以把 Step 设置为 PROPAGATION_NOT_SUPPORTED,数据库写入则显式使用 REQUIRES_NEW:
@Bean
public Step paymentStep(
JobRepository jobRepository,
PlatformTransactionManager transactionManager,
PaymentTasklet paymentTasklet) {
DefaultTransactionAttribute stepTransactionAttribute =
new DefaultTransactionAttribute();
stepTransactionAttribute.setPropagationBehavior(
TransactionDefinition.PROPAGATION_NOT_SUPPORTED);
return new StepBuilder("paymentStep", jobRepository)
.tasklet(paymentTasklet, transactionManager)
.transactionAttribute(stepTransactionAttribute)
.build();
}
这段配置主要用于说明事务边界。实际可用的构建 API 取决于 Spring Batch 版本和项目的配置方式,应以项目当前版本的 API 为准。
Spring Batch 的 JobRepository 仍需维护 StepExecution、重启状态等元数据。调整 Step 事务后,需要专门测试 Job 中断、重启和元数据更新行为,不能只验证正常执行路径。
还能继续使用 chunk-oriented Step 吗
可以,但最好把职责拆开,不要在同一个 writer 中同时调用远程接口和更新数据库。
例如:
- Step 1:从
PENDING记录中领取任务,写入支付发件箱或将其标记为PROCESSING; - Step 2:消费已领取的记录,在事务外调用网关;
- Step 3:批量归档、对账或处理失败记录。
如果第二阶段仍使用标准 chunk,而且 API 调用发生在 processor 或 writer 中,Step 的 chunk 事务通常仍会覆盖等待远程响应的过程。仅仅把调用从 writer 移到 processor,并不会让它自动离开事务。
AsyncItemProcessor 也不能直接解决这个问题。writer 最终仍要等待异步结果,失败恢复、处理顺序和幂等性也会更复杂。使用前需要实际测量事务持续时间,不能仅凭”异步”就判断锁已经消失。
暂时无法重构时的缓解措施
如果暂时不能重构,可以先通过以下方式降低影响:
- 减小 chunk size,极端情况下设为
1; - 避免在读取阶段使用不必要的悲观锁;
- 为状态查询和更新字段建立合适的索引;
- 为 HTTP 设置连接超时、读取超时和总调用超时;
- 限制支付接口的并发度;
- 为死锁、瞬时数据库错误和网关限流设置有上限的重试;
- 持久化支付网关响应和幂等键,方便后续对账。
将 chunk size 设为 1,只能减少单次事务涉及的数据量。只要 API 调用仍在 chunk 事务中,一次耗时 30 秒的请求仍可能产生持续 30 秒的事务。因此,这只能缓解问题,不能彻底解决问题。
必须处理的异常窗口
事务拆分后,会出现无法完全消除的故障窗口:
网关扣款成功
↓
应用在保存 SUCCEEDED 前崩溃
应用重启后,本地记录可能仍处于 PROCESSING 或 UNKNOWN 状态。此时不能依赖数据库回滚,而需要通过以下机制恢复:
- 支付网关支持的幂等请求键;
- 网关交易号的唯一约束;
- 支付状态查询接口;
- 超时记录扫描与补偿任务;
- 定期对账;
- 带条件的状态更新,防止旧结果覆盖新状态。
例如,可以通过唯一约束限制同一个幂等键只能对应一笔本地支付:
ALTER TABLE payment
ADD CONSTRAINT uk_payment_idempotency_key
UNIQUE (idempotency_key);
保存网关交易号时也可以设置唯一约束,避免同一笔网关交易关联到多条本地记录。
是否需要更换批处理工具
通常不需要。问题不在 Spring Batch,而在于不可回滚的远程副作用被放进了数据库事务。
Spring Batch 可以继续承担这些工作:
- 定时扫描并领取待处理记录;
- 控制并发、重试和跳过策略;
- 保存执行元数据;
- 支持失败重启;
- 执行补偿和对账 Job。
如果支付任务需要持续、低延迟地处理大量请求,可以考虑引入消息队列和 transactional outbox:
短数据库事务
更新业务状态
写入 outbox
↓
消息发布
↓
支付消费者调用网关
↓
短事务保存结果
消息队列可以改善任务分发、削峰和重试,但无法自动保证支付网关与数据库之间的原子性。幂等处理、状态查询和定期对账仍不可缺少。
因此,可以保留 Spring Batch,同时把当前由一个长 chunk 完成全部工作的设计,改成”短事务领取、事务外调用、短事务落库”的可恢复流程。
备注:内容仅供参考。
