|

Aimee

Write the Code. Change the World.

同一张券被核销了两次,你才意识到状态机有多重要

· 分享镜

同一张券被核销了两次,你才意识到状态机有多重要

一张券被核销了两次

移动端弱网环境下,核销接口在 100ms 内被调用了两次(客户端自动重试),两次都返回成功,券被消耗了两张的价值,但库里只扣了一次用量。

接口没有 bug,问题出在缺少两样东西:状态机并发保护


优惠券有状态,状态流转不能随便跳

很多人把优惠券做成一个 is_used 字段,用了就置 1。这是最常见的错误。

优惠券本质上是一个状态机

UNUSED(未使用)→ LOCKING(核销中)→ USED(已使用)
                                   ↘ UNUSED(核销失败回滚)

为什么要有 LOCKING 状态?

因为核销不是一步完成的,中间可能要扣优惠金额、写订单记录、发通知——这段时间里,券必须被"锁住",不能被第二个请求再次核销。


CAS 更新:只有一个请求能赢

最关键的一步是用 CAS(Compare And Set) 做状态变更:

-- 只有状态是 unused 的时候才能更新成功
-- 返回 affected rows = 0 → 说明已经被别的请求抢走了
UPDATE coupon
SET status = 'locking', lock_time = NOW(), order_id = ?
WHERE id = ?
  AND status = 'unused'

两个请求并发进来,数据库行锁保证只有一个 UPDATE 能影响到行,另一个 affected rows = 0,直接返回"券不可用"。

代码逻辑长这样:

int rows = couponDao.lockCoupon(couponId, orderId);
if (rows == 0) {
    throw new BizException("优惠券已被使用或不可用");
}
// 继续后续核销逻辑...
doVerify(couponId, orderId);
// 核销完成,状态推进到 used
couponDao.markUsed(couponId, orderId);

核销失败时记得把状态回滚到 unused,否则券就永远卡在 locking 了。


接口幂等:重复调用要返回相同结果

光有 CAS 还不够。客户端弱网重试会发多次请求,我们要保证同一次核销操作,不管调几次,结果一样

做法是引入 requestId(业务侧生成,每次核销唯一):

-- 核销记录表,requestId 唯一索引
CREATE TABLE coupon_verify_log (
    request_id   VARCHAR(64) PRIMARY KEY,
    coupon_id    BIGINT,
    order_id     BIGINT,
    result       VARCHAR(16),  -- success / fail
    created_at   DATETIME
);

接口进来先查这张表,requestId 已存在就直接返回上次结果,不再重复执行核销逻辑。


并发核销时序图

流程示意图


两把锁,哪个都不能少

并发安全靠 CAS(WHERE status='unused'),重试安全靠幂等 requestId。两道防线各司其职,不能互相替代——光有 CAS 拦不住重试,光有幂等拦不住并发。

评论0

登录后参与评论。

还没有评论,来抢沙发吧。

回到顶部