导读:会员次卡被多划一次,多半不是前台手滑,而是核销链路上同一笔请求被执行了两遍。真正能兜住的是三件事:每笔核销一个幂等键、扣次用带余额条件的一次更新、卡次流水加日结对账兜底。
一、先说背景:那次"10次卡变7次"是怎么发生的
先交代下门店的系统环境。这是一家有 6 家分店的连锁美容养生馆,办次卡、月卡的会员大约 1200 人,前台开卡、收银、划卡扣次这些日常动作,都放在唯顿收银系统(线下门店智慧收银与会员运营系统)这类门店系统上,会员档案、次卡开卡与划卡、卡项资产台账由它承载。
客户后来要的"会员在小程序里自助约项目、到店自助核销、次卡跨店通用",是我们在它开放接口上自己接的一段。问题就出在这段:一位会员买的 10 次卡,做完一次护理后剩了 7 次,等于一次服务被扣了 3 次。
查流水发现,同一笔服务单在 20 秒内落了三条扣次记录:前台划卡一次,小程序核销回调因为超时重试又触发两次。根因在我们自己这段核销回调没做幂等,和收银系统本身无关。
二、一次次卡核销,链路上哪些环节会重复扣
门店的核销链路比看起来长,任何一段重试都可能让同一笔服务被扣多次:
- 前台收银端点了划卡,接口超时但实际已成功,店员再点一次;
- 小程序核销回调网络抖动,调用方按超时策略重试;
- 技师端、前台端同时为同一个服务单操作;
- 消息队列里核销消息被重复投递。
这些场景的共同点是:同一笔业务被当成多笔独立请求执行。靠"按钮点完置灰"挡不住,因为重试发生在服务端和多端之间,必须让后端自己能识别"这笔我已经处理过"。
三、核销幂等:给每笔核销一个唯一键
做法是给每笔核销流水加唯一约束,键由服务单号、次卡、核销端共同组成。同一笔重复请求,第二次插入会被数据库直接挡下。
-- 核销流水:同一服务单在同一核销端只能成功落一笔
ALTER TABLE card_consume_log
ADD UNIQUE KEY uk_serve_terminal (serve_order_id, card_id, terminal_type);
核销逻辑先尝试落流水,撞到唯一键就说明这笔已经处理过,直接返回首次结果,不再扣次。
public ConsumeResult deduct(long cardId, long serveOrderId, String terminal) {
// 先落核销流水,靠唯一键挡住同一笔的重复请求
try {
logMapper.insertProcessing(serveOrderId, cardId, terminal);
} catch (DuplicateKeyException e) {
return ConsumeResult.alreadyDone(serveOrderId);
}
int rows = cardMapper.deductByVersion(cardId, cardMapper.readVersion(cardId));
if (rows == 0) {
logMapper.markFailed(serveOrderId);
throw new BizException("次卡余额不足或已被变更,请刷新后重试");
}
logMapper.markSuccess(serveOrderId);
return ConsumeResult.ok();
}
注意流水先落"处理中",扣次成功再置"成功",这样中途宕机也能靠流水状态对账找回,不会出现扣了次却没记录。
四、并发扣次:把"查余额"和"扣次"合成一次更新
第二个坑是超扣。如果代码先查一次剩余次数、判断够扣,再单独发更新,两个并发请求可能都读到"还剩 1 次",最后各扣一次,扣成负数。
正确做法是把条件判断和扣减放进同一条带余额条件的 UPDATE,用影响行数判断成败。
UPDATE member_times_card
SET remaining_times = remaining_times - 1,
version = version + 1,
update_time = NOW()
WHERE card_id = :cardId
AND remaining_times >= 1
AND version = :version;
- 影响行数为 1:扣减成功;
- 为 0:要么余额不足,要么版本号已被别的请求改动,本次放弃并重读次卡,由上层提示重试。
version 乐观锁处理的是"同一张卡被多个入口同时操作",remaining_times >= 1 保证不会扣成负次数,两个条件缺一不可。
五、限频规则与跨店:按门店营业日口径核对
有些卡项有扣次规则,比如某个项目一天只能用一次。这类判断要按门店的营业日口径统计,而不是简单按自然日,否则跨零点的服务最容易起纠纷。
SELECT COUNT(1)
FROM card_consume_log
WHERE card_id = :cardId
AND project_id = :projectId
AND biz_date = :bizDate
AND status = 'SUCCESS';
跨店通用的次卡,扣次必须落在总部统一台账上,门店只是发起端。各店本地各扣各的,同一张卡就会出现总店看到的次数和门店对不上的情况。
六、卡次流水与日结对账:让差异自己浮出来
幂等和锁挡的是绝大多数问题,对账负责发现漏网的那一笔。前提是每一次余额变动都有流水,包括正向扣减和撤销、退次的反向回补。
次卡的账面剩余应当满足:
剩余次数 = 开卡总次数 − 成功扣次合计 + 回补合计
日结跑一遍核对,两边对不上的卡直接列出来:
SELECT c.card_id,
c.remaining_times AS card_remain,
c.total_times
- SUM(CASE WHEN l.direction = 'DEDUCT' THEN l.times ELSE 0 END)
+ SUM(CASE WHEN l.direction = 'REFUND' THEN l.times ELSE 0 END) AS ledger_remain
FROM member_times_card c
LEFT JOIN card_consume_log l
ON l.card_id = c.card_id AND l.status = 'SUCCESS'
GROUP BY c.card_id, c.remaining_times, c.total_times
HAVING card_remain <> ledger_remain;
上线这套之后,这家连锁每月大约 3000 笔划卡,重复扣次的客诉从每月四五起降到 0;日结时次卡对账这一项,从平均要翻二十分钟流水,变成脚本跑完两分钟内确认完。
七、踩过的五个坑
- 坑1:只在前端置灰按钮。后端没有幂等键,回调重试和多端操作照样重复扣,防重复必须落在服务端。
- 坑2:先查后扣、两步走。并发下两个请求都读到余额足够导致超扣,要改成带
remaining_times >= 1条件的一次 UPDATE。 - 坑3:扣次没带版本号。重试和正常核销交错时互相覆盖,乐观锁和余额条件要同时上。
- 坑4:撤销退次直接改余额。不写反向流水,当时看着对、月底对账怎么都对不上也没法追溯,任何变动都要有流水。
- 坑5:限频按自然日、跨店各扣各的。跨零点服务口径不一致引发纠纷,跨店次卡必须走总部统一台账和统一营业日。
结语
次卡这类预付资产,会员对"次数"的敏感度远高于几块钱余额,多划一次损伤的是门店最看重的信任。复盘下来没有什么奇技淫巧:幂等键让重复请求只生效一次,条件更新加乐观锁让并发扣不穿底,流水和对账让任何一笔异常都能被发现、被追回。把这三层做扎实,次卡才真正经得起前台、移动端和跨店场景的反复折腾。