如何通过BizId和时间戳机制拦截Confirm后的Cancel悬挂请求?
33
0
0
0
背景:那个让人夜不能寐的“悬挂”事务
在做支付或订单系统时,最怕的不是系统挂了,而是系统“乱了”。
最近有个兄弟在群里吐槽了一个经典的**悬挂事务(Suspended Transaction)**场景:
- Try阶段:资源锁定,准备扣款。
- Confirm阶段:业务执行成功,数据库状态变更为
CONFIRMED。 - 用户骚操作:紧接着,用户手贱(或者系统逻辑重试)发起了
Cancel请求。 - 灾难:如果
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)。
Try 阶段:
- 检查数据库
SELECT version, status FROM order WHERE biz_id = ?。 - 如果
status是CONFIRMED,且当前Try请求携带的version小于数据库version,说明这是一个过期的重试请求,直接丢弃。 - 如果
status是CANCELED,直接报错回滚。
- 检查数据库
Cancel 阶段:
- 同理,检查
status。如果已经是CONFIRMED,通常不建议自动 Cancel(除非是严格的反悔机制)。 - 如果是定时任务触发的 Cancel,务必带上任务创建时间。
- 在执行 Cancel 逻辑前,比对
任务创建时间和订单 Confirm 时间。 - 如果
任务创建时间 < 订单 Confirm 时间,说明这个 Cancel 任务是“旧时代的遗物”,必须丢弃。
- 同理,检查
总结
解决 Confirm 后 Cancel 的悬挂问题,本质上是分布式系统的时序问题。
不要信任前端的调用顺序,也不要信任网络的稳定性。在 Try 阶段(或补偿阶段)的入口,永远要先查一次当前状态,利用 BizId 做索引,利用 Timestamp 或 Version 做时序判断,把那些“迟到”的脏数据请求直接挡在门外。
这不仅能防止数据错乱,还能省去后续无数个半夜起来回滚数据的烦恼。