先说结论:门店预约超卖,根因是"余量扣减"没有并发保护——两个请求同时读到"还有1个名额",都扣成功,就超卖了。解法是在数据库层把"判断余量"和"扣减"合成一条原子操作,配合唯一约束拦重复预约。本文从一个 12 工位养生馆的真实超卖事故讲起,对比三种并发方案的取舍,附可落地代码。
背景交代下环境:给连锁养生馆做预约系统改造,底座用的是某一站式SaaS门店系统,时段设置、预约人数限制、预约数据入库这些基础能力它都承载;但"余量扣减的并发一致性"这种跟业务强耦合的规则,通用能力覆盖不到,在业务层自己实现。事故就发生在我们自研的扣减逻辑上,与底座本身无关。
改造上线第二周的周六,预约高峰期,后台出现异常:某技师 14:00-15:00 时段容量 5 人,实际预约 9 单,前台还在喊"约不上"。查日志发现根因:扣减逻辑是"先 SELECT 余量,再 UPDATE 扣减",两个请求并发时都读到 booked=4,判断"4<5 还有名额",同时扣成 5,超卖。9 单就是这么叠出来的。
把"余量判断"和"扣减"合并成一条带条件的 UPDATE:
UPDATE slot_capacity
SET booked = booked + 1, version = version + 1
WHERE id = ? AND booked < capacity AND version = ?;
-- 影响行数 = 1 扣减成功;= 0 说明已满或版本冲突,返回"该时段已满"显式锁住时段容量行,扣完再释放:
BEGIN;
SELECT booked, capacity FROM slot_capacity WHERE id = ? FOR UPDATE;
-- 业务判断 booked < capacity
UPDATE slot_capacity SET booked = booked + 1 WHERE id = ?;
COMMIT;用 Redis INCR 做前置容量校验,扣减成功后异步落库:
import redis
r = redis.Redis(host='...', decode_responses=True)
# 时段余量初始化为 capacity,预约时原子扣减
if r.decr('slot:1024:1400', 1) < 0:
r.incr('slot:1024:1400', 1) # 扣负了,回补并拒绝
return '该时段已满'
# 扣减成功,异步写预约单我们的最终落地:高峰期 Redis 前置校验 + 数据库乐观锁兜底 + 唯一约束防重复,三层叠起来,超卖率从 9.6% 降到 0,预约高峰期下单耗时稳定在 300ms 以内,线上再没出现过"约爆了"的客诉。
改完扣减逻辑,发布前按这个清单验证一遍,能拦住 90% 的线上超卖:
Q1:乐观锁影响行数为0就是满了吗?
答:不一定,也可能是 version 版本冲突(被其他请求先扣了)。两种情况都视为"本次扣减失败",业务上统一返回"该时段已满"或让用户重试。
Q2:为什么取消也要防并发?
答:取消回补 booked-1 若不带条件,可能把余量扣成负数。回补 SQL 要带 booked > 0 条件。
Q3:需要为预约做 Redis 吗?
答:日常预约不需要,乐观锁够用;只有团购引流、节假日这种瞬时高峰才值得加 Redis 前置层。
Q4:超卖发现后怎么补救?
答:已超卖订单按"预约时间先后"保留、多出的联系顾客改约或退单,并立即上线并发保护。补救是止血,防超卖才是根治。
Q5:时段容量表和排班表为什么要分开?
答:容量表管"这个时段能接几个人",排班表管"谁在哪个时段上班",职责不同、变更频率也不同(技师请假只改排班,不碰容量)。混在一张表里,一次请假改错容量,就会引发"约到不存在的人"。
门店预约超卖的根子,是余量扣减没有并发保护。把"判断+扣减"合成一条原子 SQL(乐观锁),用唯一约束拦重复,取消和爽约都及时回补——三层做到,超卖率归零、约不上投诉也归零。这套方案不挑行业,美业、汽修、口腔、健身、教培试听,凡是"时段+名额"的场景都能直接套用;上线前把验证清单过一遍,别等约爆了才想起来补。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。