兑换券、团购券、体验课次卡,本质都是"一张只能核销一次的凭证"。线上跑起来后最棘手的 bug 不是核销失败,而是同一张码被核销了两次:顾客网络差连点了两下、两台收银设备同时扫到同一张码、请求超时后前端重试,都可能让库存或次卡被扣两遍。本文拆解一套可直接落地的核销防重方案:状态机定边界、幂等表兜重复、分布式锁挡并发,三者各管一段,缺一个都会漏。
重复核销不是单一原因,按发生层次可以分成三类,解法也不同:
类型 | 触发场景 | 特征 | 主要对策 |
|---|---|---|---|
重复提交 | 用户连点、前端超时重试 | 同一请求几乎同时到两次 | 幂等表 |
并发竞争 | 两台设备/两个店员同时扫一张码 | 两个请求都读到"未核销" | 分布式锁+原子更新 |
消息重复 | MQ 至少一次投递、补偿任务重跑 | 两次请求有时间差 | 状态机+幂等键 |
只靠其中任何一种手段都会漏:只加锁不做状态机,锁释放后的迟到请求仍可能再扣;只做状态机不加幂等表,无法识别"这是同一次操作的重试"。
防重的前提是状态本身没有歧义。一张核销凭证只允许下面这条单向流转:
待核销(UNUSED) → 核销中(LOCKED) → 已核销(USED);任意状态 → 已作废(REVOKED)
关键是中间加一个「核销中」过渡态。很多事故源于只有"未核销/已核销"两个状态:第一个请求还在调库存、写流水的过程中,第二个请求进来一看还是"未核销",就又进了一遍。加了过渡态后,正在处理的凭证对其他请求是"占住"的,不会被二次进入。状态流转必须单向,已核销不允许回到任何前态。
过渡态能不能挡住并发,取决于"读状态"和"改状态"是不是原子的。错误写法是先 SELECT 判断、再 UPDATE,两步之间有并发缝隙。正确做法是用带状态条件的单条更新,靠数据库行锁一次完成判断与占用:
-- 只有当前处于 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),落一张幂等记录表:
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 为例,锁要带过期时间、要用唯一标识保证只解自己的锁:
// 抢锁: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 过渡态后,必须处理"占住了但没走完"的悬挂状态,否则凭证会一直卡在核销中:
坑 | 后果 | 解法 |
|---|---|---|
先查后改、两步走 | 并发时两个请求都判为未核销 | 带状态条件的单条原子更新 |
只有未核销/已核销两态 | 处理过程中被第二个请求进入 | 增加 LOCKED 过渡态 |
重试每次换新请求号 | 幂等表认不出是同一笔 | 一次业务动作一个固定 request_id |
分布式锁不带过期 | 持锁进程崩溃造成死锁 | NX+PX、唯一 token、Lua 安全释放 |
不处理 LOCKED 悬挂 | 凭证卡死无法再核销 | 失败回滚+超时清扫双保险 |
锁失效就没有兜底 | 极端情况下仍重复扣 | 锁内再做状态条件更新 |
落地顺序建议是:第一步先上"状态机 + 条件更新",这是成本最低、能挡掉绝大多数并发重复的一环;第二步补幂等表,把重试和消息重复收敛掉;第三步在涉及多表多步骤的复杂核销里再加分布式锁。三者是分层防线而不是三选一:数据库条件更新保证状态正确,幂等表保证同一请求不重复生效,分布式锁保证跨资源流程串行。上线后建议加两个监控指标:核销接口的重复请求命中率、停留在 LOCKED 超过阈值的凭证数量,它们能直接反映防重链路是否在正常工作。
防重复核销的本质,是把"会不会重复"从"指望网络和用户别乱来"变成"系统在任何时序下都只允许一次生效"。状态机划清合法边界,条件更新和锁挡住同一时刻的竞争,幂等表接住错开时间的重试——把这三层各就各位,一张码被用两次的问题,就从偶发疑难 bug 变成了结构上不可能发生的事。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。