Java 中如何实现并发安全的 reservation refund
结论
不要依赖 Java 的 synchronized,也不要指望用一个数据库事务把 MySQL 更新和支付机构的退款 API 变成原子操作。数据库事务无法回滚已经完成的外部退款。
更可靠的处理方式是:
- 在数据库中原子地将 reservation 从
ACCEPTED改为REFUNDING,取得这笔退款的处理权。 - 调用支付机构时,传入稳定且唯一的 idempotency key。
- 退款成功后,将状态改为
CANCELED。失败或结果未知时记录相应状态,交给重试或对账流程处理。 - 结合数据库条件更新、唯一约束和支付机构的幂等能力,防止重复退款。
这样,同一个 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');
必须处理的崩溃窗口
最危险的情况是:
- 支付机构已经退款成功;
- 应用还没来得及将数据库状态改为
SUCCEEDED; - 进程崩溃或网络超时。
此时,数据库中的状态仍可能是 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和 JavaBigDecimal,不要使用double。 - 保存支付机构退款单号、请求时间、响应摘要和错误信息,以便审计和对账。
- 定期扫描长期停留在
PROCESSING状态的记录。扫描后应先查询支付机构的实际结果,不要直接生成新的退款请求。 - API 层可以将重复请求当作幂等请求处理,返回当前退款状态,而不是一律报错。
- 如果允许部分退款或多次部分退款,就不能再简单地为
reservation_id添加唯一约束。每次合法退款都应有独立的业务退款单号,同时原子校验累计退款金额不能超过可退款金额。 - MySQL 的默认事务隔离级别、索引设计和 ORM 生成的 SQL 都会影响锁行为。上线前应检查实际 SQL 和执行计划。
核心原则不是”让两行代码看起来处于同一个 Java 锁中”,而是确保同一个 reservation 只有一个持久化退款任务,并让该任务调用支付机构时始终使用稳定的幂等身份。
备注:内容仅供参考。
