订单与支付状态机
支付、主动取消和超时消息竞争同一份订单状态,回调重复与消息提前到达分别处理。
订单与支付单分别记账
订单 0 / 1 / 2 / 3 对应待支付、已支付、已取消、已退款,其中退款流程为后续扩展。支付单独立记录一次支付尝试,失败的支付单不意味着订单失败,用户可在订单有效期内重新发起支付。
stateDiagram-v2
[*] --> 待支付: 锁座并创建订单
待支付 --> 已支付: 支付成功且订单CAS成功
待支付 --> 已取消: 主动取消或到期取消CAS成功
待支付 --> 待支付: 支付失败,可重试
已支付 --> [*]
已取消 --> [*]
关键是 UPDATE ... WHERE status = 0。支付和取消竞争时,只能有一个操作成功推进待支付订单;另一个必须读取已有状态决定后续动作,不能无条件覆盖。
三份状态各自说明什么
| 状态来源 | 回答的问题 | 不能单独推断的结论 |
|---|---|---|
order_info | 这笔交易目前待支付、已支付还是已取消 | 支付渠道是否已经发生收款 |
payment_order | 一次支付尝试成功、失败还是等待结果 | 座位是否已在 Redis 修复为已售 |
| Redis 座位 Hash | 当前座位显示与预占状态 | 数据库是否已有完整有效订单 |
用户点一次“支付”会进入支付尝试的生命周期,而订单可能经历失败后重试。把支付单和订单合成一份状态,会很难区分“支付失败但仍可再次付款”和“交易已结束、库存应释放”。
金额和订单归属也属于服务端检查范围。浏览器显示的支付按钮只是操作入口,不是授权依据;用户查询与操作订单时仍要检查订单所属用户。
延迟消息不是截止时间
下单事务提交后才向 RocketMQ 发送超时消息,避免消费者看见尚未提交的订单。发送失败记录日志,由定时对账补偿,不能用“发送成功”替代持久化订单。
消息可能重复、提前到达,或消费延迟。消费端先查询订单,已完成的订单跳过;仍待支付时,以数据库 expire_time 为准,未到期就按剩余时间重投。
这使超时逻辑对唤醒方式保持一致:正常延迟消息、提前消息重投和定时补偿最终都检查同一份业务截止时间。消费者积压会使释放晚于截止时间,因此“十五分钟支付有效期”不能解释为所有座位一定在第十五分钟的同一毫秒完成释放。
public void handle(String orderNo) {
OrderInfo order = orderInfoMapper.selectByOrderNo(orderNo);
if (order == null || !Objects.equals(order.getStatus(), OrderService.STATUS_PENDING)) {
log.info(
"timeout message ignored: orderNo={}, status={}",
orderNo,
order == null ? null : order.getStatus());
return;
}
LocalDateTime now = LocalDateTime.now();
if (order.getExpireTime() != null && now.isBefore(order.getExpireTime())) {
Duration remaining = Duration.between(now, order.getExpireTime());
timeoutMessageSender.send(orderNo, remaining);
log.info("order not expired, timeout message re-armed: orderNo={}, remaining={}", orderNo, remaining);
return;
}
orderCancelService.cancelAndRelease(orderNo, CANCEL_REASON);
}
取消与库存释放
取消事务先竞争订单状态,再读取并删除 order_seat 有效占用。事务外释放 Redis 预占及限购额度;这样取消、库存释放失败可以被识别并补偿,而不是长时间把数据库事务和网络调用绑在一起。
只有 CAS 成功的取消执行者会拿到待释放座位集合。重复取消或重复超时消息不会再次推进订单,也不会重复走同一份有效占用删除。Redis 释放依然可能失败,取消成功和座位显示恢复之间允许有短暂时间差。
@Transactional
public CancelResult cancelPending(String orderNo, String reason) {
int changed = orderInfoMapper.cancelPending(orderNo, reason);
if (changed == 0) {
return new CancelResult(false, List.of());
}
OrderInfo order = orderInfoMapper.selectByOrderNo(orderNo);
if (order == null) {
log.error("order not found after cancel CAS: orderNo={}", orderNo);
return new CancelResult(true, List.of());
}
List<Long> seatIds = orderSeatMapper.listByOrderId(order.getId()).stream()
.map(OrderSeat::getSeatId)
.toList();
orderSeatMapper.deleteByOrderId(order.getId());
log.info("order cancelled: orderNo={}, reason={}, seatCount={}", orderNo, reason, seatIds.size());
return new CancelResult(true, seatIds);
}
重复回调为什么不能直接返回
回调先检查 HMAC-SHA256 签名、字段完整性和时间戳偏差,再以 callback_id 唯一键记录通知。唯一键用于识别重复投递,但不能把“收到过通知”等同于“所有副作用已完成”。
例如第一次回调已把订单更新为已支付,Redis 写入失败。重复回调仍应识别已支付订单,重试座位标记为已售。PaymentCallbackService 的 insertCallbackLog 标记重复后继续状态检查,markSold 是修复入口。
签名校验与幂等各有职责。HMAC 校验通知内容与签名是否匹配,时间戳校验限制接受的时间偏差,callback_id 唯一键识别是否收到过同一通知,支付单和订单 CAS 约束业务状态推进。通过其中任意一项,都不能跳过另外几项。
private boolean insertCallbackLog(CallbackRequest request) {
PaymentCallbackLog callbackLog = new PaymentCallbackLog();
callbackLog.setCallbackId(request.callbackId());
callbackLog.setPaymentNo(request.paymentNo());
callbackLog.setRawBody(rawBody(request));
callbackLog.setResult(RESULT_ACCEPTED);
try {
paymentCallbackLogMapper.insert(callbackLog);
return true;
} catch (DuplicateKeyException e) {
log.info("duplicate payment callback, self-heal continues: callbackId={}", request.callbackId());
paymentCallbackLogMapper.markDuplicate(request.callbackId());
return false;
}
}
支付与取消竞争的结果
| 顺序或故障 | 应有结果 | 后续动作 |
|---|---|---|
| 支付先成功推进订单,随后收到超时消息 | 订单维持已支付 | 消费端跳过取消 |
| 取消先成功,随后收到支付成功 | 订单保持已取消,出现资金差异 | 记录差异,自动退款尚未实现 |
| 同一个成功通知重复投递 | 不重复推进状态 | 仍允许修复已支付座位 |
| 支付失败,订单尚未结束 | 订单仍待支付,库存继续预占 | 用户在有效期内重试 |
| 数据库状态与 Redis 座位短暂不一致 | 不能只根据页面判断交易失败 | 结合回调重试与对账收敛 |
当前回调服务在数据库事务中执行支付单、通知日志和订单更新,同时调用 Redis 标记座位已售;Redis 不属于该数据库事务。相较于取消链路的事务外释放,回调链路存在另一种跨存储时序,仍需要已售孤儿清理和已支付座位修复,不能把两条链路描述成相同的事务边界。
已接受的边界
订单已经取消后才收到支付成功,真实后端记录差异日志及 discrepancy 指标,自动退款尚未实现。不能声称系统已经完成资金闭环。order_info.paid_at 目前预留,真实支付时间需关联 payment_order,不能把空值解释为未支付。
当前前端显式模拟失败后保留待支付订单并允许重试;自动成功回调只处理仍待支付的支付单,避免覆盖用户刚选择的失败结果。浏览器演示没有后台自动回调,用显式成功、失败按钮控制流程。
页面用轮询刷新支付单和订单状态,没有以 WebSocket 推送替代查询。轮询看到的短暂旧状态与业务失败应分开判断:是否支付成功看服务端记录,是否应释放看订单最终状态,是否存在资金差异看回调和对账信息。
