首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >一张核销码为什么不能用两次:幂等表、分布式锁与状态机的配合

一张核销码为什么不能用两次:幂等表、分布式锁与状态机的配合

原创
作者头像
用户5598620
发布2026-09-13 11:39:01
发布2026-09-13 11:39:01
230
举报

兑换券、团购券、体验课次卡,本质都是"一张只能核销一次的凭证"。线上跑起来后最棘手的 bug 不是核销失败,而是同一张码被核销了两次:顾客网络差连点了两下、两台收银设备同时扫到同一张码、请求超时后前端重试,都可能让库存或次卡被扣两遍。本文拆解一套可直接落地的核销防重方案:状态机定边界、幂等表兜重复、分布式锁挡并发,三者各管一段,缺一个都会漏。

一、先看清重复核销是怎么发生的

重复核销不是单一原因,按发生层次可以分成三类,解法也不同:

类型

触发场景

特征

主要对策

重复提交

用户连点、前端超时重试

同一请求几乎同时到两次

幂等表

并发竞争

两台设备/两个店员同时扫一张码

两个请求都读到"未核销"

分布式锁+原子更新

消息重复

MQ 至少一次投递、补偿任务重跑

两次请求有时间差

状态机+幂等键

只靠其中任何一种手段都会漏:只加锁不做状态机,锁释放后的迟到请求仍可能再扣;只做状态机不加幂等表,无法识别"这是同一次操作的重试"。

二、状态机:先把凭证的生命周期定死

防重的前提是状态本身没有歧义。一张核销凭证只允许下面这条单向流转:

待核销(UNUSED) → 核销中(LOCKED) → 已核销(USED);任意状态 → 已作废(REVOKED)

关键是中间加一个「核销中」过渡态。很多事故源于只有"未核销/已核销"两个状态:第一个请求还在调库存、写流水的过程中,第二个请求进来一看还是"未核销",就又进了一遍。加了过渡态后,正在处理的凭证对其他请求是"占住"的,不会被二次进入。状态流转必须单向,已核销不允许回到任何前态。

三、原子更新:用一条 SQL 把"判断+占用"合成一步

过渡态能不能挡住并发,取决于"读状态"和"改状态"是不是原子的。错误写法是先 SELECT 判断、再 UPDATE,两步之间有并发缝隙。正确做法是用带状态条件的单条更新,靠数据库行锁一次完成判断与占用:

代码语言:sql
复制
-- 只有当前处于 UNUSED 的凭证才能被占用,影响行数=1 才算抢到
UPDATE coupon
SET status = 'LOCKED', locked_by = ?, locked_at = NOW()
WHERE code = ? AND status = 'UNUSED';

执行后看影响行数:返回 1 表示本次抢到了核销权,可以继续;返回 0 说明这张码已经不是待核销(被别人占了、已核销或已作废),直接返回"凭证不可用",不再往下走。这一步把并发竞争收敛成了"谁先把状态从 UNUSED 改成 LOCKED 谁赢",数据库层面就不会有两个赢家。

四、幂等表:让"同一次操作的重试"只生效一次

原子更新解决了并发,但识别不了"这是同一笔业务的重复请求"。比如核销成功、响应却丢了,前端用同一个请求号重试,这时凭证已是 LOCKED/USED,单纯判状态会误报失败。解法是为每次核销请求生成一个幂等键(request_id),落一张幂等记录表:

代码语言:sql
复制
CREATE TABLE idempotent_record (
  request_id   VARCHAR(64) PRIMARY KEY,
  biz_code     VARCHAR(64) NOT NULL,
  result_state TINYINT NOT NULL,   -- 0处理中 1成功 2失败
  response     TEXT,
  created_at   DATETIME NOT NULL,
  UNIQUE KEY uk_req (request_id)
);

处理逻辑:进入核销先尝试插入 request_id,主键冲突说明是重复请求——不要重新执行,直接返回上一次的处理结果;插入成功才是首次请求,继续执行。这样无论前端重试多少次、MQ 重投多少遍,同一 request_id 背后的真实扣减只发生一次。幂等键要由调用方在"发起一次业务动作"时生成并在重试中保持不变,而不是每次 HTTP 请求都换新。

五、分布式锁:跨资源、跨步骤时的第二道闸

当一次核销不止改一张表(要同时占凭证、扣库存、写核销流水、累加员工业绩),单条 UPDATE 锁不住整条流程,这时用分布式锁把"同一凭证"的处理串行化。以 Redis 为例,锁要带过期时间、要用唯一标识保证只解自己的锁:

代码语言:javascript
复制
// 抢锁:SET NX PX 原子加锁,value 用唯一 token
const token = crypto.randomUUID();
const ok = await redis.set(`lock:coupon:${code}`, token, "NX", "PX", 5000);
if (!ok) return { code: "LOCKED", msg: "该凭证正在被处理,请稍后" };
try {
  // 双检:拿到锁后再确认状态仍是 UNUSED,再走原子占用
  const affected = await occupyCoupon(code);
  if (affected === 0) return { code: "USED", msg: "凭证已核销或不可用" };
  await deductStockAndWriteFlow(code);
  await markUsed(code);
  return { code: "OK" };
} finally {
  // 只释放自己持有的锁,避免误删别人的锁
  const lua = "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
  await redis.eval(lua, 1, `lock:coupon:${code}`, token);
}

注意锁不是银弹:锁过期时间要覆盖正常耗时、业务执行时间可能超过锁租期时要续租;锁只负责"把并发排队",最终一致性仍要靠前面的状态条件更新兜底——即使锁意外失效,WHERE status='UNUSED' 也不会让一张码被核销两次。

六、异常分支:占用之后失败了怎么办

引入 LOCKED 过渡态后,必须处理"占住了但没走完"的悬挂状态,否则凭证会一直卡在核销中:

  1. 业务异常:扣库存或写流水失败,主动把状态从 LOCKED 回滚为 UNUSED,并删除幂等记录中的处理中标记,允许用户重新核销。
  2. 进程崩溃:没有机会回滚,靠一个定时清扫任务,把 locked_at 超过阈值(如 2 分钟)仍停在 LOCKED 的凭证复位为 UNUSED;复位前确认没有对应的成功流水,避免把实际已成功的单子误开。
  3. 响应丢失:客户端拿不到结果来重试,幂等表命中首次结果直接回放成功响应,不会重复扣减。

七、踩坑清单

后果

解法

先查后改、两步走

并发时两个请求都判为未核销

带状态条件的单条原子更新

只有未核销/已核销两态

处理过程中被第二个请求进入

增加 LOCKED 过渡态

重试每次换新请求号

幂等表认不出是同一笔

一次业务动作一个固定 request_id

分布式锁不带过期

持锁进程崩溃造成死锁

NX+PX、唯一 token、Lua 安全释放

不处理 LOCKED 悬挂

凭证卡死无法再核销

失败回滚+超时清扫双保险

锁失效就没有兜底

极端情况下仍重复扣

锁内再做状态条件更新

八、工程落地建议

落地顺序建议是:第一步先上"状态机 + 条件更新",这是成本最低、能挡掉绝大多数并发重复的一环;第二步补幂等表,把重试和消息重复收敛掉;第三步在涉及多表多步骤的复杂核销里再加分布式锁。三者是分层防线而不是三选一:数据库条件更新保证状态正确,幂等表保证同一请求不重复生效,分布式锁保证跨资源流程串行。上线后建议加两个监控指标:核销接口的重复请求命中率、停留在 LOCKED 超过阈值的凭证数量,它们能直接反映防重链路是否在正常工作。

九、复盘清单

  • 凭证是否有 UNUSED→LOCKED→USED 的单向状态机
  • "判断+占用"是否是一条带状态条件的原子 SQL
  • 每次业务动作是否有固定的幂等键并落表
  • 分布式锁是否带过期、唯一 token、安全释放
  • LOCKED 悬挂是否有失败回滚和超时清扫
  • 是否有重复命中率、悬挂量的监控

结语

防重复核销的本质,是把"会不会重复"从"指望网络和用户别乱来"变成"系统在任何时序下都只允许一次生效"。状态机划清合法边界,条件更新和锁挡住同一时刻的竞争,幂等表接住错开时间的重试——把这三层各就各位,一张码被用两次的问题,就从偶发疑难 bug 变成了结构上不可能发生的事。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 一、先看清重复核销是怎么发生的
  • 二、状态机:先把凭证的生命周期定死
  • 三、原子更新:用一条 SQL 把"判断+占用"合成一步
  • 四、幂等表:让"同一次操作的重试"只生效一次
  • 五、分布式锁:跨资源、跨步骤时的第二道闸
  • 六、异常分支:占用之后失败了怎么办
  • 七、踩坑清单
  • 八、工程落地建议
  • 九、复盘清单
  • 结语
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档