PAYATHON 2026

Spring Batch 是否适合每秒执行一次的支付任务?

支付阿杰

结论

Spring Batch 可以定时扫描和处理支付任务,但不适合把”每秒触发一次支付”直接做成普通的 Reader -> Processor -> 多个 Writer 流程。

风险不在执行频率或数据量,而在于支付是无法随本地事务一起回滚的外部操作。数据库事务可以回滚本地的 INSERT 和 UPDATE,却不能自动撤销支付渠道已经受理的请求。如果支付成功后数据库写入失败,Spring Batch 重试或下一轮扫描时可能再次调用支付接口,造成重复支付。

更稳妥的设计是:

  1. 使用消息队列或定时调度发现待处理任务。
  2. 通过数据库状态机声明任务处理权。
  3. 为每笔业务生成稳定且唯一的幂等键。
  4. 每次调用支付接口都携带同一个幂等键。
  5. 单独持久化支付结果。
  6. 使用 Outbox 模式异步发送推送消息。
  7. 支付结果不确定时,先查询渠道状态,不要直接重试支付。

Spring Batch 仍可用于对账、补偿、历史数据处理和批量重试,但不应承担防止重复支付的核心职责。

为什么普通 Batch 流程存在风险

假设一次执行包含以下操作:

调用支付接口成功
        ↓
更新本地订单失败
        ↓
本地事务回滚
        ↓
下一秒重新读取订单
        ↓
再次调用支付接口

从数据库的角度看,事务失败后订单仍处于”待支付”状态。支付渠道却可能已经完成了第一次支付,两个系统的状态因此出现分歧。

即使把多个 Writer 放进同一个 Spring 事务,也只能保证本地事务资源的一致性。普通 HTTP 支付接口通常不会参与数据库事务,更不会在 Spring 事务回滚时自动取消支付。

另外,不能把 writer_1 -> writer_2 -> writer_3 当成三个天然独立且可靠的阶段。即使通过 CompositeItemWriter 依次调用多个 Writer,只要其中包含外部支付和消息推送,仍可能出现以下情况:

  • 支付成功,但数据库事务提交失败;
  • 数据库提交成功,但推送失败;
  • 请求超时,但支付渠道实际已经完成支付;
  • Worker 处理成功后,在记录完成状态前崩溃;
  • 两个 Job 实例同时读到同一条待处理记录。

这些问题需要通过业务幂等和状态恢复解决,不能只依赖 Batch 的事务和重试机制。

推荐的处理流程

1. 为支付任务建立明确的状态机

例如:

PENDING
  ↓
PROCESSING
  ↓
PAYMENT_SUBMITTED
  ↓
PAID
  ↓
COMPLETED

异常状态可以包括:

RETRYABLE_FAILURE
PAYMENT_UNKNOWN
COMPENSATION_REQUIRED
FAILED

不能只通过”查询未支付记录”来判断是否需要再次支付。每次执行前,应以原子方式将任务从 PENDING 改为 PROCESSING。只有成功取得处理权的 Worker 才能继续执行。

示例 SQL:

UPDATE payment_task
SET status = 'PROCESSING',
    worker_id = :workerId,
    updated_at = CURRENT_TIMESTAMP
WHERE id = :id
  AND status = 'PENDING';

更新行数为 1 时,说明当前 Worker 已取得任务。更新行数为 0 则表示任务已被其他实例处理,当前执行应立即结束。

如果数据库支持,也可以通过 SELECT ... FOR UPDATE SKIP LOCKED 获取任务,但具体语法和行为取决于所用数据库及其版本。

2. 使用稳定的支付幂等键

每笔业务支付都必须有一个不会随重试改变的标识,例如:

paymentRequestId = orderId + paymentAttemptNo

不要在每次执行时生成新的随机请求号,否则支付渠道无法识别重复请求。

数据库还应为该标识添加唯一约束:

ALTER TABLE payment_attempt
ADD CONSTRAINT uk_payment_request_id
UNIQUE (payment_request_id);

调用支付接口时携带相同的幂等键:

PaymentResponse response = paymentClient.pay(
    new PaymentRequest(
        task.getOrderId(),
        task.getAmount(),
        task.getPaymentRequestId()
    )
);

这里有一个前提:支付渠道必须明确支持幂等请求,并保证相同的幂等键不会产生多笔扣款。如果渠道不支持,就需要在本地进一步限制重试,并在超时或结果不明时优先查询渠道订单状态。

3. 区分”明确失败”和”结果未知”

不能把支付调用中的所有异常都当作失败处理。

try {
    PaymentResponse response = paymentClient.pay(request);
    savePaymentResult(response);
} catch (PaymentRejectedException e) {
    markFailed(task.getId(), e.getMessage());
} catch (SocketTimeoutException e) {
    markPaymentUnknown(task.getId());
}

余额不足、参数错误等明确拒绝可以标记为失败。网络超时、连接中断或响应解析失败,只能说明调用方没有收到确定结果,不能证明支付没有发生。

对于 PAYMENT_UNKNOWN,下一步应查询支付渠道:

PaymentStatus remoteStatus =
    paymentClient.queryByIdempotencyKey(task.getPaymentRequestId());

switch (remoteStatus) {
    case SUCCEEDED -> markPaid(task.getId());
    case FAILED -> markFailed(task.getId(), "Remote payment failed");
    case NOT_FOUND -> scheduleControlledRetry(task.getId());
    case PROCESSING, UNKNOWN -> keepPendingForReconciliation(task.getId());
}

只有当渠道明确返回”该请求不存在”或确认请求尚未执行,并且业务重试策略允许时,才能重新发起支付。

4. 将支付、数据库更新和通知拆开

建议把流程分成三个可恢复的阶段:

支付任务处理
    ↓
记录支付结果并更新业务状态
    ↓
写入通知 Outbox
    ↓
独立发送推送

支付完成后,可以在一个本地事务中更新订单并写入 Outbox:

@Transactional
public void confirmPayment(
        long taskId,
        String providerPaymentId,
        PaymentResult result) {

    paymentTaskRepository.markPaid(
        taskId,
        providerPaymentId,
        result.completedAt()
    );

    orderRepository.markPaid(result.orderId());

    outboxRepository.insert(
        UUID.randomUUID().toString(),
        "PAYMENT_SUCCEEDED",
        result.orderId(),
        result.toJson()
    );
}

事务提交后,再由独立消费者发送推送:

public void publishPendingMessage(OutboxEvent event) {
    pushClient.send(event.payload());
    outboxRepository.markPublished(event.id());
}

推送失败不应触发重新支付。通知本身也应有唯一的事件 ID,供接收方去重。

支付后发生异常是否应立即取消

通常不能把”本地写库异常”直接等同于”立即取消支付”。

首先要确认支付的真实状态。如果支付渠道已经成功扣款,后续可能需要执行撤销、冲正或退款,但这些操作在不同渠道中的含义并不相同:

  • void 或撤销:通常用于尚未完成清算的交易;
  • reversal 或冲正:用于纠正状态不一致的交易;
  • refund:通常是一笔新的资金操作;
  • cancel:部分渠道只允许取消尚未完成的支付。

具体采取哪种操作,必须根据支付渠道协议和业务规则决定。

较稳妥的处理方式是:

  1. 将任务标记为 PAYMENT_UNKNOWN 或 COMPENSATION_REQUIRED。
  2. 使用幂等键查询渠道的最终状态。
  3. 如果支付成功,优先修复本地订单状态。
  4. 只有业务已经无法履约时,才发起撤销或退款。
  5. 补偿请求也必须使用独立且稳定的幂等键。
  6. 确认最终结果后再通知用户。

如果支付已经成功,只是本地更新暂时失败,修复数据库状态通常比立即退款更合理。否则可能出现支付成功、退款也成功,但本地仍记录为失败的另一种不一致。

如果仍然使用 Spring Batch

可以用 Spring Batch 扫描和恢复任务,但支付调用本身必须具备幂等性,同时应避免每秒创建无限增长的 Job 实例。

需要注意:

  • 相同的 Job 参数通常会被视为同一个 Job Instance;
  • 使用时间戳作为参数可以持续创建新实例,但 Batch 元数据也会不断积累;
  • 不要为支付异常配置无条件重试;
  • retry、restart 和 skip 都不能代替支付接口幂等;
  • 多实例部署时必须处理任务抢占和并发执行;
  • 对长时间停留在 PROCESSING 状态的任务,需要设置租约或超时恢复机制;
  • 恢复任务时应先查询支付状态,不能直接重新扣款。

一个相对安全的 Writer 轮廓如下:

@Component
public class PaymentItemWriter implements ItemWriter<PaymentTask> {

    private final PaymentService paymentService;

    public PaymentItemWriter(PaymentService paymentService) {
        this.paymentService = paymentService;
    }

    @Override
    public void write(Chunk<? extends PaymentTask> chunk) {
        for (PaymentTask task : chunk) {
            paymentService.processIdempotently(task.getId());
        }
    }
}

核心逻辑不能依赖 Batch 是否会重新执行:

public void processIdempotently(long taskId) {
    PaymentTask task = paymentTaskRepository.findById(taskId)
        .orElseThrow();

    if (task.isTerminal()) {
        return;
    }

    if (!paymentTaskRepository.claim(taskId, workerId)) {
        return;
    }

    PaymentStatus existing =
        paymentClient.queryByIdempotencyKey(task.getPaymentRequestId());

    if (existing == PaymentStatus.SUCCEEDED) {
        paymentResultService.confirmExistingPayment(task, existing);
        return;
    }

    if (existing == PaymentStatus.PROCESSING ||
        existing == PaymentStatus.UNKNOWN) {
        paymentTaskRepository.markPaymentUnknown(taskId);
        return;
    }

    paymentSubmissionService.submit(task);
}

这段代码只展示整体结构。实际实现还需要根据支付渠道是否提供状态查询、幂等键、撤销和退款接口进行调整。

技术选型建议

如果每秒只处理少量实时支付任务,可以优先考虑事件驱动方案:

业务请求
  → 创建支付任务
  → 投递消息
  → 支付 Worker
  → 更新状态和写入 Outbox
  → 通知 Worker

如果暂时没有消息队列,也可以使用 Spring Scheduling 定期扫描数据库,但仍需实现状态抢占、唯一约束、幂等键和结果对账:

@Scheduled(fixedDelay = 1000)
public void poll() {
    List<Long> taskIds = paymentTaskRepository.findClaimableTaskIds(100);

    for (Long taskId : taskIds) {
        paymentExecutor.execute(taskId);
    }
}

fixedDelay = 1000 表示上一次调用结束后等待一秒再执行,不代表严格的每秒吞吐量或执行时限。多实例运行时,数据库抢占机制仍然不可缺少。

Spring Batch 更适合以下场景:

  • 日终或定时对账;
  • 批量导入支付结果;
  • 大量历史订单迁移;
  • 可分区、可重启的批量处理;
  • 对异常支付进行受控补偿;
  • 根据清算文件校正本地状态。

对于实时、小批量且涉及外部资金操作的流程,消息驱动或带状态机的任务执行器通常更直接。无论使用 Spring Batch、定时任务还是消息队列,避免重复支付都要依靠稳定的幂等键、原子任务抢占、明确的状态机、结果查询、唯一约束和可审计的补偿流程。

备注:内容仅供参考。