WEBKT

如何通过BizId和时间戳机制拦截Confirm后的Cancel悬挂请求?

33 0 0 0

背景:那个让人夜不能寐的“悬挂”事务

在做支付或订单系统时,最怕的不是系统挂了,而是系统“乱了”。

最近有个兄弟在群里吐槽了一个经典的**悬挂事务(Suspended Transaction)**场景:

  1. Try阶段:资源锁定,准备扣款。
  2. Confirm阶段:业务执行成功,数据库状态变更为 CONFIRMED
  3. 用户骚操作:紧接着,用户手贱(或者系统逻辑重试)发起了 Cancel 请求。
  4. 灾难:如果 Cancel 逻辑没兜住,它可能会再次尝试回滚或关闭订单,甚至因为网络延迟,导致 Cancel 消息在 Confirm 之后才到达,这就产生了脏数据

如何在 Try 阶段(或者 Cancel 阶段的入口)就把这种“过期”的请求挡在外面?核心在于 业务ID(BizId) + 时间戳/版本号机制

核心解法:基于 BizId 的前置拦截器

我们需要一个无状态的、高效的拦截层,它能在任何操作触达数据库之前,先判断这个请求是否“合法”。

1. 设计思路:状态机 + 乐观锁

不要把所有逻辑都堆在业务代码里,我们需要一个状态机拦截器

  • BizId:作为全局唯一键,串联所有操作。
  • Timestamp/Version:记录请求发生的时间或版本。
  • 状态流转
    • INIT -> CONFIRMED (允许)
    • CONFIRMED -> CANCEL (拒绝,除非有特殊补偿逻辑)
    • CANCEL -> CONFIRMED (绝对禁止)

2. 实战代码逻辑(伪代码)

假设我们使用 Redis + MySQL 的组合来实现这个轻量级拦截。

// 伪代码示例
public class TransactionInterceptor {

    // 拦截器入口,通常在 Try 阶段开始前,或者 Cancel 阶段执行前调用
    public boolean preCheck(String bizId, long requestTimestamp, OperationType type) {
        
        // 1. 获取当前业务单据的最新快照
        // 这里最好查缓存,如果缓存穿透再查DB
        OrderSnapshot snapshot = cache.get(bizId);
        
        if (snapshot == null) {
            // 第一次请求,直接放行
            return true; 
        }

        // 2. 核心逻辑:判断是否属于“悬挂”场景
        
        // 场景 A: Confirm 已经执行,但 Cancel 还没死心
        if (snapshot.getStatus() == Status.CONFIRMED && type == OperationType.CANCEL) {
            // 关键点:时间戳判断
            // 如果 Cancel 请求的时间戳,远晚于 Confirm 的完成时间
            // 说明这是脏请求
            if (requestTimestamp > snapshot.getConfirmTime() + 5000) {
                log.warn("拦截悬挂请求: BizId={}, Confirm已结束但收到Cancel", bizId);
                return false; // 拒绝执行
            }
        }

        // 场景 B: Cancel 已执行,Try/Confirm 又来了(重试风暴)
        if (snapshot.getStatus() == Status.CANCELED && type == OperationType.CONFIRM) {
             log.warn("拦截脏提交: BizId={}, Cancel已结束", bizId);
             return false;
        }

        return true;
    }
}

3. 关键细节:如何定义“悬挂”?

Try 阶段进行前置拦截时,最稳妥的做法是引入版本号(Version)

  1. Try 阶段

    • 检查数据库 SELECT version, status FROM order WHERE biz_id = ?
    • 如果 statusCONFIRMED,且当前 Try 请求携带的 version 小于数据库 version,说明这是一个过期的重试请求,直接丢弃。
    • 如果 statusCANCELED,直接报错回滚。
  2. Cancel 阶段

    • 同理,检查 status。如果已经是 CONFIRMED,通常不建议自动 Cancel(除非是严格的反悔机制)。
    • 如果是定时任务触发的 Cancel,务必带上任务创建时间
    • 在执行 Cancel 逻辑前,比对 任务创建时间订单 Confirm 时间
    • 如果 任务创建时间 < 订单 Confirm 时间,说明这个 Cancel 任务是“旧时代的遗物”,必须丢弃。

总结

解决 Confirm 后 Cancel 的悬挂问题,本质上是分布式系统的时序问题

不要信任前端的调用顺序,也不要信任网络的稳定性。在 Try 阶段(或补偿阶段)的入口,永远要先查一次当前状态,利用 BizId 做索引,利用 TimestampVersion 做时序判断,把那些“迟到”的脏数据请求直接挡在门外。

这不仅能防止数据错乱,还能省去后续无数个半夜起来回滚数据的烦恼。

架构师老王 分布式事务状态机悬挂事务

评论点评