门店大促或直播发券时,核销是最容易出资金事故的环节:同一张券被店员和顾客同时扫码核销两次、网络重试导致一笔核销执行多遍、核销成功但出餐失败后券却回不来。本文案例来自我们用乔拓云为连锁门店做券核销模块的改造实践,核销状态机与幂等流水均为自研。下面把状态机、并发控制、幂等、对账补偿这条链路讲透,并复盘五个踩坑点。
券核销看似是"把券标记成已使用",但在并发和重试下有三类典型事故:
事故类型 | 触发过程 | 资金后果 |
|---|---|---|
重复核销 | 店员扫码与顾客小程序同时提交,或双击按钮 | 一张券抵扣两次,直接资损 |
状态错乱 | 先核销后判断,或并发把已退券又核销 | 已退款的券被再次使用 |
核销悬挂 | 券标记成功,后续业务(出餐/下单)失败 | 顾客券被扣却没拿到商品,客诉 |
根因和库存超卖类似:把"读状态→判断→改状态"拆成了多步,且缺少幂等约束。正确做法是用状态机约束流转方向、用条件更新保证原子、用幂等流水兜住重复请求。
先给券定义清晰的状态和合法流转路径,任何核销都只能沿合法边迁移,不允许跳跃和回退到非法状态:
待使用 UNUSED →(核销请求)→ 核销中 REDEEMING →(成功)→ 已核销 REDEEMED 核销中 REDEEMING →(失败/超时释放)→ 回到待使用 UNUSED 已核销 REDEEMED →(撤销)→ 已退回 REFUNDED;REFUNDED 为终态,不允许再回到核销流程,从状态机层面堵死"退了再用"
之所以要把"核销中"单独拆成一个中间态,而不是待使用直接跳到已核销,是因为抢占券和执行后续业务之间存在时间窗。如果没有中间态,券要么一直停在待使用被别的请求重复抢占,要么直接置成已核销却在业务失败时难以区分"该不该释放"。有了核销中,就能精确表达"这张券已经被某个核销单占用、结果尚未最终确认",补偿任务也能据此识别哪些是卡住的悬挂单。
状态迁移用数据库条件更新原子完成,where 里带上"当前必须是待使用/核销中"的前置状态,靠 affected rows 判断是否迁移成功:
-- 抢占式核销:只有处于待使用的券才能被推进到核销中
UPDATE coupon
SET status = 'REDEEMING',
redeem_no = #{redeemNo},
version = version + 1,
update_time = NOW()
WHERE coupon_id = #{couponId}
AND status = 'UNUSED';
-- affected rows=1 抢占成功;=0 说明券已被处理或状态非法,直接拒绝这一步把"判断和变更"合成一条语句,两个并发请求只有一个能把状态从 UNUSED 推进走,另一个拿到 affected rows=0,天然防住了双核销。
状态机解决并发后,主流程要保证"券状态"和"后续业务"最终一致。模式是先抢占券、再执行业务、成功则确认、失败则释放:
def redeem(coupon_id, req_id, biz_service, db, redis):
# 1) 幂等:同一核销请求只处理一次
if not redis.set(f"redeem:lock:{req_id}", "1", nx=True, ex=300):
return db.find_redeem_flow(req_id) # 重复请求,返回首次结果,不再执行
# 2) 写一条核销流水(核销单号唯一),全程以它为对账锚点
redeem_no = f"RD{req_id}"
db.insert_redeem_flow(redeem_no, coupon_id, status="PROCESSING")
# 3) 条件更新抢占券
if db.occupy_coupon(coupon_id, redeem_no) == 0:
db.update_flow(redeem_no, status="REJECT") # 券状态非法
return {"code": "COUPON_NOT_AVAILABLE"}
# 4) 执行后续业务(出餐/下单),失败则释放券回到待使用
try:
order = biz_service.create_order(coupon_id)
except Exception:
db.release_coupon(coupon_id, redeem_no) # 状态回退,避免悬挂
db.update_flow(redeem_no, status="RELEASED")
raise
# 5) 确认核销,置为终态
db.confirm_coupon(coupon_id, redeem_no, order.id)
db.update_flow(redeem_no, status="SUCCESS", order_id=order.id)
return {"code": "OK", "redeemNo": redeem_no}关键点是每一步都落一条核销流水,它既是幂等依据,也是事后对账的锚点。无论请求重试多少次,同一个 req_id 只会有一条成功流水。
这里也对比一下分布式锁方案。不少人第一反应是给每张券加一把分布式锁,核销时先拿锁、处理完再释放。锁确实能串行化同一券的并发,但它有两个绕不开的问题:一是锁有过期时间,业务执行超过租约就会出现锁提前释放、第二个请求进入的并发窗口;二是锁只管"同一时刻只让一个进",管不了请求重试和消息重投——上一个请求已经释放锁,重试请求依然能再执行一遍。所以分布式锁只能作为降低并发冲突的辅助,真正的正确性还是要靠状态机条件更新和幂等流水。实践中可以在单券热点冲突时用短锁削峰,但最终是否核销成功,一律以数据库条件更新和唯一流水为准,锁失效也不会导致资损。
核销的重复请求不只来自用户双击,必须分层挡住:
重复来源 | 防护手段 |
|---|---|
用户/店员重复点击、弱网重试 | 请求唯一号 req_id + Redis 短期幂等锁 |
消息队列重复投递(至少一次) | 消费端按 redeem_no 唯一约束去重 |
定时补偿任务重复执行 | 流水状态机,已 SUCCESS 的不再处理 |
数据库层给核销流水的核销单号加唯一索引,是所有幂等的最后兜底:即使 Redis 失效、并发同时穿透,数据库也不会插入两条相同核销单。
问题:select 和 update 之间存在窗口,两个核销请求都查到"待使用"。
解决:用带前置状态的条件更新,以 affected rows 判定抢占结果。
问题:已退款/已退回的券被重新推进核销流程,造成资损。
解决:状态机明确 REFUNDED 为终态,条件更新只接受 UNUSED 前置态。
问题:前端按钮置灰挡不住接口重放、MQ 重投和脚本调用。
解决:幂等必须落在服务端,req_id 幂等锁加流水唯一索引双保险。
问题:券标记已核销却没出餐,顾客权益凭空消失。
解决:业务失败走释放回退,并由对账任务扫描长时间 PROCESSING 的悬挂流水兜底。
问题:超时补偿释放券的同时,原请求刚好执行成功,状态互相覆盖。
解决:补偿也走条件更新并带版本号,只有状态仍是核销中且超时才允许释放。
高并发券核销的可靠性来自四件事:状态机限定单向合法流转、条件更新把判断与变更原子化、核销流水配合唯一索引实现全链路幂等、失败回退加对账补偿消除悬挂。判断一套核销系统是否安全,就问三句话:两个请求同时来会不会扣两次、重试多遍会不会重复执行、业务失败后券能不能正确回来。这三问都能稳稳回答,门店大促的券核销才不会变成资金事故。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。