PAYATHON 2026

Java 中如何实现并发安全的 reservation refund

支付阿杰

结论

不要依赖 Java 的 synchronized,也不要指望用一个数据库事务把 MySQL 更新和支付机构的退款 API 变成原子操作。数据库事务无法回滚已经完成的外部退款。

更可靠的处理方式是:

  1. 在数据库中原子地将 reservation 从 ACCEPTED 改为 REFUNDING,取得这笔退款的处理权。
  2. 调用支付机构时,传入稳定且唯一的 idempotency key。
  3. 退款成功后,将状态改为 CANCELED。失败或结果未知时记录相应状态,交给重试或对账流程处理。
  4. 结合数据库条件更新、唯一约束和支付机构的幂等能力,防止重复退款。

这样,同一个 reservation 最多只有一个请求能取得处理权,而不同 reservation 仍可并发处理。

为什么 synchronized 不够

synchronized 只能协调同一个 JVM、同一个对象实例中的线程。遇到以下情况时,它无法提供保护:

  • 应用部署了多个实例;
  • 服务发生重启;
  • Spring Bean 不是同一个实例;
  • 第三方退款已经成功,但进程在更新数据库前崩溃;
  • 请求超时后,调用方再次发起退款。

即使改成按 reservation ID 加 Java 锁,也只能控制单个进程内的并发,无法解决分布式部署和崩溃恢复问题。金融操作的幂等边界必须同时落在数据库和支付机构一侧。

推荐方案:状态机、条件更新和幂等键

可以增加中间状态和退款记录,例如:

ALTER TABLE reservation
    ADD COLUMN refund_status VARCHAR(20) NOT NULL DEFAULT 'NONE';

CREATE TABLE reservation_refund (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    reservation_id BIGINT NOT NULL,
    idempotency_key VARCHAR(100) NOT NULL,
    status VARCHAR(20) NOT NULL,
    provider_refund_id VARCHAR(100),
    last_error VARCHAR(500),
    created_at DATETIME NOT NULL,
    updated_at DATETIME NOT NULL,
    UNIQUE KEY uk_refund_reservation (reservation_id),
    UNIQUE KEY uk_refund_idempotency (idempotency_key)
);

将 reservation 的业务状态与退款执行状态分开,含义会更清楚:

  • reservation.status:ACCEPTED、CANCELED 等业务状态;
  • refund_status:NONE、PROCESSING、SUCCEEDED、FAILED、UNKNOWN。

如果业务模型较简单,也可以直接在 reservation.status 中增加 REFUNDING。不过,分开建模更便于处理超时、重试和人工对账。

第一步:原子地取得退款处理权

使用带条件的 UPDATE,不要先查询,再根据查询结果决定是否更新:

UPDATE reservation
SET refund_status = 'PROCESSING'
WHERE id = ?
  AND user_id = ?
  AND status = 'ACCEPTED'
  AND refund_status = 'NONE';

根据受影响的行数判断结果:

  • 返回 1:当前请求已取得处理权;
  • 返回 0:reservation 不存在、状态不是 ACCEPTED,或者退款已由其他请求处理。

这一步由 MySQL 原子完成。两个并发请求执行同一条语句时,最多只有一个请求能将 NONE 改为 PROCESSING。

对应的 DAO 可以写成:

@Modifying
@Query("""
    update Reservation r
       set r.refundStatus = :processing
     where r.id = :id
       and r.userId = :userId
       and r.status = :accepted
       and r.refundStatus = :none
    """)
int claimRefund(
        long id,
        long userId,
        ReservationStatus accepted,
        RefundStatus none,
        RefundStatus processing
);

取得处理权后,事务应尽快提交。不要一边持有数据库锁,一边等待支付机构响应。

@Transactional
public boolean claimRefund(long id, long userId) {
    int updated = reservationDao.claimRefund(
        id,
        userId,
        ReservationStatus.ACCEPTED,
        RefundStatus.NONE,
        RefundStatus.PROCESSING
    );

    if (updated == 0) {
        return false;
    }

    reservationRefundDao.insert(
        id,
        "reservation-refund-" + id,
        RefundStatus.PROCESSING
    );
    return true;
}

reservation_refund.reservation_id 上的唯一约束提供了额外保护,确保每个 reservation 只会创建一条退款记录。

第二步:使用支付机构的幂等退款接口

数据库成功标记为 PROCESSING 后,再调用支付机构:

public RefundResult executeRefund(long reservationId, long userId) {
    boolean claimed = refundClaimService.claimRefund(reservationId, userId);

    if (!claimed) {
        return refundQueryService.getCurrentResult(reservationId);
    }

    RefundOrder refund = refundQueryService.getByReservationId(reservationId);

    try {
        ProviderRefundResult result = paymentProcessor.refund(
            refund.getAmount(),
            refund.getIdempotencyKey()
        );

        refundCompletionService.markSucceeded(
            reservationId,
            result.getRefundId()
        );

        return RefundResult.succeeded(result.getRefundId());
    } catch (PaymentResultUnknownException e) {
        refundCompletionService.markUnknown(reservationId, e.getMessage());
        throw e;
    } catch (PaymentRejectedException e) {
        refundCompletionService.markFailed(reservationId, e.getMessage());
        throw e;
    }
}

重试时必须继续使用同一个幂等键,例如:

reservation-refund-123456

不要在每次重试时生成新的 UUID,否则支付机构会将它们视为不同的退款请求。

如果支付机构支持 idempotency key,多次提交同一个 key 时,应返回相同的退款结果,或拒绝重复执行。具体行为以所用支付机构的 API 文档为准。

退款完成后的状态可以在独立事务中更新:

@Transactional
public void markSucceeded(long reservationId, String providerRefundId) {
    reservationRefundDao.markSucceeded(
        reservationId,
        providerRefundId,
        RefundStatus.SUCCEEDED
    );

    reservationDao.markCanceled(
        reservationId,
        ReservationStatus.ACCEPTED,
        ReservationStatus.CANCELED,
        RefundStatus.SUCCEEDED
    );
}

条件更新示例:

UPDATE reservation
SET status = 'CANCELED',
    refund_status = 'SUCCEEDED'
WHERE id = ?
  AND status = 'ACCEPTED'
  AND refund_status IN ('PROCESSING', 'UNKNOWN');

必须处理的崩溃窗口

最危险的情况是:

  1. 支付机构已经退款成功;
  2. 应用还没来得及将数据库状态改为 SUCCEEDED;
  3. 进程崩溃或网络超时。

此时,数据库中的状态仍可能是 PROCESSING 或 UNKNOWN。不能简单地将状态恢复为 NONE,再通过新请求重新退款,否则可能造成重复退款。

具体处理方式取决于支付机构提供的能力:

  • 支持 idempotency key:使用原来的 key 重试;
  • 支持按退款单号查询:先查询实际结果,再更新本地状态;
  • 两者都支持:重试和对账都使用同一个业务退款号;
  • 两者都不支持:系统无法严格保证外部副作用的 exactly-once,只能通过人工对账、限制重试和异常告警来降低风险。

从分布式系统的角度看,单靠 @Transactional 无法让”调用外部 API”和”提交本地数据库事务”真正实现原子提交。实际可行的方案是:保证本地状态转换的原子性,让外部操作具备幂等性,并使失败后的操作能够恢复、查询和对账。

是否可以使用 SELECT ... FOR UPDATE

可以使用行级悲观锁,例如:

START TRANSACTION;

SELECT id, status, refund_status, total_price
FROM reservation
WHERE id = ?
FOR UPDATE;

UPDATE reservation
SET refund_status = 'PROCESSING'
WHERE id = ?;

COMMIT;

不同 reservation 通常锁定不同的行,因此仍然可以并发执行。不过需要注意:

  • 必须有合适的主键或索引,避免扫描并锁定过多记录;
  • 事务要尽量短;
  • 不要在事务中调用支付机构;
  • 如果在退款调用期间一直持有行锁,可能造成长时间阻塞、锁等待超时和连接池耗尽;
  • 事务提交后,仍需通过中间状态和幂等键处理崩溃窗口。

如果需求只是保证”只有一个请求能取得处理权”,条件 UPDATE 通常比先执行 SELECT ... FOR UPDATE 再更新更直接。

不建议在数据库事务中直接调用退款 API

下面这种写法看似把两个操作放进了同一个事务,实际上并不具备原子性:

@Transactional
public void refund() {
    paymentProcessor.refund(amount);
    reservationDao.markCanceled(id);
}

如果数据库更新失败,Spring 可以回滚 MySQL 事务,却无法撤销支付机构已经执行的退款。反过来,如果先将数据库状态改为 CANCELED,事务提交后外部退款又失败,本地状态就会错误地表示退款已经成功。

因此,需要明确区分以下状态:

  • 退款已申请或正在处理;
  • 支付机构已确认退款成功;
  • 结果未知,需要查询或对账;
  • 已确定失败,可以按业务规则重试。

其他注意事项

  • 退款金额必须从服务端的持久化数据中读取,不要信任客户端传入的金额。
  • 金额建议使用数据库 DECIMAL 和 Java BigDecimal,不要使用 double。
  • 保存支付机构退款单号、请求时间、响应摘要和错误信息,以便审计和对账。
  • 定期扫描长期停留在 PROCESSING 状态的记录。扫描后应先查询支付机构的实际结果,不要直接生成新的退款请求。
  • API 层可以将重复请求当作幂等请求处理,返回当前退款状态,而不是一律报错。
  • 如果允许部分退款或多次部分退款,就不能再简单地为 reservation_id 添加唯一约束。每次合法退款都应有独立的业务退款单号,同时原子校验累计退款金额不能超过可退款金额。
  • MySQL 的默认事务隔离级别、索引设计和 ORM 生成的 SQL 都会影响锁行为。上线前应检查实际 SQL 和执行计划。

核心原则不是”让两行代码看起来处于同一个 Java 锁中”,而是确保同一个 reservation 只有一个持久化退款任务,并让该任务调用支付机构时始终使用稳定的幂等身份。

备注:内容仅供参考。