PAYATHON 2026

如何重新定义 Spring Batch Job 的数据库事务边界?

支付阿杰

明确结论

Spring Batch Step 的事务传播行为可以调整,但不建议只是把现有 chunk 的事务移到 REST API 调用之后。

支付网关调用和本地数据库事务无法组成普通的原子事务。数据库事务可以回滚,已经发送给支付网关的请求却无法自动撤销。改变事务边界确实能缩短锁的持有时间,但处理不当会带来重复扣款、状态丢失等更严重的问题。

更合适的做法是把处理流程拆成几个可恢复的状态:

  1. 在短事务中领取待处理记录,并将其标记为 PROCESSING。
  2. 在数据库事务之外调用支付网关。
  3. 在另一个短事务中保存支付结果,将状态更新为 SUCCEEDED 或 FAILED。
  4. 通过幂等键、唯一约束和状态机处理重试、崩溃恢复与并发执行。

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 完成全部工作的设计,改成”短事务领取、事务外调用、短事务落库”的可恢复流程。

备注:内容仅供参考。