并发与一致性

Article / 并发与一致性

回调为什么必须幂等

从验签、唯一键到状态机与自愈,拆解支付回调链路上的三层幂等。

SeatFlow 幂等HMAC状态机支付

支付网关对回调的承诺只有一个词:至少一次。网络超时会让网关重试,主动对账会让网关重推,机房切换可能带来重复投递——同一条「支付成功」通知到达两次甚至更多次,是完全正常的。更麻烦的是顺序:先到的未必是最终结果,失败通知后面可能跟着成功通知。SeatFlow 的支付链路按「重复是常态」来设计:请求层、状态层、缓存层各挡一次,挡不住的部分用自愈收尾。

验签先行

回调端点不是业务接口,任何人构造一个 POST 就能声称「订单已支付」。第一道门是 HMAC 签名:回调携带 callbackIdpaymentNostatustimestamp 与签名,签名是对四段内容做 HMAC-SHA256 的十六进制摘要:

private static String canonical(String callbackId, String paymentNo, Integer status, Long timestamp) {
    return callbackId + "\n" + paymentNo + "\n" + status + "\n" + timestamp;
}

public static boolean verify(String secret, ..., String sign) {
    String expected = sign(secret, callbackId, paymentNo, status, timestamp);
    return MessageDigest.isEqual(
            expected.getBytes(StandardCharsets.UTF_8),
            sign.toLowerCase(Locale.ROOT).getBytes(StandardCharsets.UTF_8));
}

三个细节。其一,比较用 MessageDigest.isEqual 而不是 String.equals——字符串比较在第一个不同字节就返回,攻击者能通过响应时间逐字节试探签名(时序侧信道)。其二,时间戳限制在五分钟窗口内:过期的签名即使合法也拒绝,压缩重放空间。其三,status 只接受「成功」或「失败」两个取值,其他一律 PARAM_INVALID——这是审查时补的,最初的实现把所有非成功值都当成失败,一个手工构造的 status=3 也能把支付单置为失败。

验签在一切之前执行:未通过验签的请求不写日志、不查库、不产生任何副作用。

三层幂等

重复投递会在三个层面被拦截,每一层的机制不同:

第一层,请求级:callback_id 唯一键。 每一条回调都携带全局唯一的 callbackId,写入 payment_callback_log 时撞唯一索引即说明这条通知来过。这一层挡住的是「完全相同的重投」。

第二层,状态级:状态机 CAS。 支付单的流转是 待支付(0) → 成功(1)待支付(0) → 失败(2),以及重试成功时的 失败(2) → 成功(1);订单是 待支付(0) → 已支付(1)。所有流转都是条件更新,每条语句最多生效一次,天然幂等。

stateDiagram-v2
    s0 : 待支付(0)
    s1 : 成功(1)
    s2 : 失败(2)
    [*] --> s0
    s0 --> s1 : 成功回调
    s0 --> s2 : 失败回调
    s2 --> s1 : 用户重试成功

第三层,缓存级:Redis 座位只做 1→2。 座位从「锁定」到「已售」的脚本只认 1 → 2,重复执行不会再把已售改回其他状态(见《座位是怎么锁住的》)。

值得展开的是第一层的一个反直觉设计:撞了唯一键,不早退,继续走。 很多实现遇到重复回调会直接 return success,理由是「已经处理过了」。但「处理过」不等于「处理完整」——如果上一次回调成功提交了订单状态,而 Redis 的座位状态因为其他原因漂移了(被清理、被对账误碰),一条重复回调恰好是免费的自愈机会:

private boolean insertCallbackLog(CallbackRequest request) {
    try {
        paymentCallbackLogMapper.insert(callbackLog);
        return true;                                   // 首次投递
    } catch (DuplicateKeyException e) {
        paymentCallbackLogMapper.markDuplicate(request.callbackId());
        return false;                                  // 重复投递:标记,但流程继续
    }
}

流程继续之后,处理逻辑对「已支付订单再次收到成功回调」的处理是再执行一次 markSold——幂等的写操作,重复执行没有代价,反而修掉了漂移。重复与首次走的是同一条代码路径:没有第二条「重复专用」分支需要单独维护和测试。

失败之后又成功

模拟网关允许手动触发失败(比如演示「支付失败」的场景),于是状态机必须回答一个问题:失败之后还能成功吗?答案是能——用户会重试支付,同一个支付单从失败回到成功是真实需求:

private void markPaymentSuccess(String paymentNo) {
    if (paymentOrderMapper.casStatus(paymentNo, PENDING, SUCCESS) > 0) {
        return;                                        // 0 → 1
    }
    paymentOrderMapper.casStatus(paymentNo, FAILED, SUCCESS);   // 2 → 1
}

这段代码来自一次代码审查发现的缺陷。最初的实现是「只要 status 不是成功,一律置为失败」;等到真正的成功回调到达时,0 → 1 的 CAS 匹配不到(行已经是失败态 2),而后面的订单 CAS 和卖座照常执行——最终留下「订单已支付、座位已售、支付单永久停留在失败」的错位,且再无回调能修正它。修复方案就是允许 2 → 1,并保证转成功时才写入 paid_at。回归测试补了两个用例:失败后再成功(断言支付单为成功、paid_at 非空、订单已支付、座位已售),以及非法状态值被拒绝且无副作用。这类缺陷不会在单次流程测试里出现,只有把「乱序、重试」当作正常输入才会暴露。

取消之后收到支付

还有一种真实场景:订单超时被取消、座位已经释放(甚至可能卖给了别人),这时一条支付成功回调到达——用户在另一个设备上刚好完成了付款。系统怎么处理?

订单保持已取消,不做任何修改,记一条 warn 日志和 seatflow.payment.callback{result=discrepancy} 指标。不做「强行复活订单」的原因很直接:座位可能已经售出,复活会直接导致两人一票。正确动作是退款,而退款(含与渠道的对账)是 P2 范围;当前用指标把差异暴露出来,并写进了 README 的已知限制。

事务边界与回滚证据

整个回调处理是一个事务:日志写入、支付单 CAS、订单 CAS、座位置已售。markSold 失败会抛异常,让事务整体回滚——包括刚写入的回调日志。这意味着网关的重试会作为「首次投递」完整重放,而不是被日志表挡住变成「重复投递、跳过处理」。测试用 Mockito 注入一次 markSold 失败,断言三张表全部未变:支付单仍待支付、订单仍待支付、回调日志无记录。这条用例证明的是「回调失败不留半成品」。

另一个事务边界细节是自动回调的调度:开发环境创建支付单后,模拟网关在事务提交后延迟两秒触发回调(afterCommit 注册),避免回调先于支付单可见到达。测试环境关闭自动回调,用例直接调用服务层,把时序控制权握在测试手里。

Mock 网关与真实链路同构

模拟网关不是另写一条捷径,而是与真实渠道共用同一条验签和幂等链路

  • MockGatewayService.build 生成与真实回调结构一致的 CallbackRequest,每次生成新的 callbackId,并用同一个密钥签名;
  • 触发方式有两种:开发环境的自动回调(两秒后成功)、以及仅 dev 可用的手动端点 POST /api/mock-gateway/pay(可选成功或失败);
  • 测试直接调用 PaymentCallbackService.handle,覆盖重复、乱序、自愈、失败重试等全部路径。

换句话说,演示时走的链路与线上唯一的区别只是「谁来发起 HTTP」——幂等逻辑一行都不省略。

代价与边界

  • 回调日志无清理策略:日志表只增不减,P2 需要按时间分区或定期归档。
  • discrepancy 无人工处理入口:差异有指标和日志,但没有运营侧的查看与退款操作台,P2 补齐。
  • order_info.paid_at 不写:订单支付时间以 payment_order.paid_at 为准(统计接口也这么查),订单表该字段预留给后续退款/对账扩展。
  • 五分钟时间窗对补投递不友好:渠道补推一条很旧的回调会被拒;当前选择偏向安全。

幂等不是加一个注解或去重表就结束的事情,而是把「重复到达」当作正常输入来设计:请求层拦一次、状态层拦一次、缓存层拦一次,然后故意不留「重复就早退」的捷径。但回调只能修自己这一侧;当两层存储漂移时,还需要一个专门收场的角色。