首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >同一节体验课被两个家长同时约走:预约超卖怎么从数据库到锁层层挡住

同一节体验课被两个家长同时约走:预约超卖怎么从数据库到锁层层挡住

原创
作者头像
用户5598620
发布2026-09-14 09:23:30
发布2026-09-14 09:23:30
20
举报

线下教培的体验课、试听课通常名额很少,一节 6 人班、1 对 1 的时段更是只有一个坑位。真正出问题的不是没人约,而是同一节课在开抢、在顾问同时跟进时被"约超了":系统里显示预约成功的人数,比实际名额多。这篇文章把预约超卖的成因拆清楚,并给出一套可以直接落地的多层防护方案,文末附一张防护层对照表和排查清单。

一、先想清楚:超卖到底是在哪一步发生的

一个最朴素的扣名额写法,是先查再改:

代码语言:sql
复制
SELECT remain FROM lesson_slot WHERE slot_id = 1001;
UPDATE lesson_slot SET remain = remain - 1 WHERE slot_id = 1001;

单线程跑没有任何问题,但两个请求并发进来时,它们可能都在 UPDATE 之前读到 remain=1:A 看到剩 1 个、B 也看到剩 1 个,两边都认为自己约上了,最终 remain 被扣成 -1,一节课卖出了两个最后名额。问题的根源是"查询"和"扣减"之间存在时间差,读和写不是一个原子操作。

这类竞争在教培场景里比电商秒杀更隐蔽:秒杀是瞬时高并发,而预约往往是两三个顾问、家长端小程序在同一秒各自操作,并发量不高却足够踩中,压测时还很难复现。

二、第一层:数据库约束,最后一道不能省的底线

无论上层加了多少锁,数据库这一层都必须能独立兜底,因为应用层的锁会过期、会失效、会因为 bug 而没加上。

1)名额非负约束。给剩余名额加上检查约束,从根上杜绝扣成负数:

代码语言:sql
复制
ALTER TABLE lesson_slot
ADD CONSTRAINT chk_remain CHECK (remain >= 0);

这样即使并发把两个扣减都放了过来,第二个 UPDATE 影响行数为 0,数据库会直接拒绝,应用层捕获到"影响 0 行"就知道名额没了,可以回滚并提示用户。更稳的写法是把条件直接写进 UPDATE:

代码语言:sql
复制
UPDATE lesson_slot
SET remain = remain - 1, version = version + 1
WHERE slot_id = #{slotId} AND remain > 0;

2)防同一用户重复预约的唯一约束。超卖之外还有"同一个学生被重复约进同一节课",靠的是唯一索引而不是先查一遍:

代码语言:sql
复制
CREATE UNIQUE INDEX uk_student_slot
ON lesson_booking(slot_id, student_id);

第二次插入会触发唯一键冲突,直接幂等返回"你已经约过了",不用额外加判断。

三、第二层:乐观锁,冲突少的场景优先用

体验课预约不是高频秒杀,绝大多数课节根本不会撞车,为每一次预约都上重锁并不划算。乐观锁的思路是"先放行、提交时校验",用版本号判断这段时间名额有没有被别人动过:

代码语言:java
复制
public boolean book(long slotId, long studentId) {
    for (int i = 0; i < 3; i++) {
        LessonSlot slot = slotMapper.selectById(slotId);
        if (slot.getRemain() <= 0) {
            return false; // 名额已空
        }
        int rows = slotMapper.decreaseWithVersion(slotId, slot.getVersion());
        if (rows == 1) {
            bookingMapper.insertIgnore(slotId, studentId);
            return true;  // 扣减成功
        }
        // rows=0 说明版本号已被别人改了,重试一次
    }
    throw new BusyException("当前预约人数较多,请稍后再试");
}

对应的扣减 SQL 带上版本条件:

代码语言:sql
复制
UPDATE lesson_slot
SET remain = remain - 1, version = version + 1
WHERE slot_id = #{slotId} AND version = #{version} AND remain > 0;

乐观锁在冲突概率低时几乎没有额外开销,只在真正撞车时重试。要注意给重试设上限(比如 3 次),避免极端情况下自旋把数据库打满。

四、第三层:分布式锁,把同一课节的请求串行化

当某节热门公开课确实会在同一秒涌入大量预约,乐观锁会出现大量重试,这时对"同一个课节"加一把细粒度分布式锁,让针对同一 slot_id 的请求排队处理,不同课节之间依然并行:

代码语言:java
复制
String lockKey = "lesson:book:lock:" + slotId;
String token = UUID.randomUUID().toString();
try {
    boolean ok = redis.set(lockKey, token, "NX", "PX", 3000);
    if (!ok) {
        throw new BusyException("正在排队,请稍候重试");
    }
    // 拿到锁后,仍然走"条件扣减 + 唯一约束",锁只负责串行,不负责正确性
    return doBook(slotId, studentId);
} finally {
    // 用 Lua 保证"只删自己的锁",避免误删别人的锁
    releaseLock(lockKey, token);
}

释放锁要用 Lua 脚本做"比对 token 再删除"的原子操作,防止锁超时后被别的请求持有、却被当前线程误删:

代码语言:lua
复制
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

几个容易踩的点:锁的粒度必须是课节而不是整个预约接口,否则会把所有课程无谓地串成一队;锁要设过期时间防止宕机死锁;锁内逻辑尽量短,只包"读名额—扣减—写预约单"这一小段。

五、第四层:前端与接口的防重复,只解决体验问题

按钮置灰、提交后 loading、请求携带幂等号,这些手段能挡掉用户手抖连点、网络重试造成的重复请求,但它们不能替代后端的并发控制——攻击者和脚本根本不走你的按钮。前端防重的定位是"减少无效请求、改善体验",真正的正确性仍然由数据库约束、乐观锁、分布式锁保证。

一个值得做的后端细节是预约接口的幂等:客户端在进入预约页时拿一个一次性的 request_id,提交时带上,服务端用它去重,同一次动作的网络重试不会产生第二条预约单。

六、可直接抄走的防护层对照表

防护层

解决什么

能不能单独依赖

关键实现

数据库约束

名额扣负、同人重复

能兜底,必须有

remain>=0 检查约束、(slot_id,student_id) 唯一索引

乐观锁

低冲突下的并发扣减

冲突低时可作为主力

version 版本号 + 条件 UPDATE + 有限重试

分布式锁

热门课节瞬时并发

不能,锁内仍要条件扣减

按 slot_id 加锁、Lua 安全释放、设过期

前端/接口防重

连点、重试等重复提交

不能,只管体验

按钮置灰、request_id 幂等

判断方案是否到位有个简单标准:把上面三层后端防护任意抽掉两层,只留数据库约束,数据也不应该出错——约束是底线,锁和版本号是为了在高并发下少报错、体验更好。

七、踩坑清单

  • 只在应用层"先查后扣"、数据库没有 remain>0 条件,是超卖的头号原因。
  • 唯一约束记得建在业务唯一键上,只靠自增主键挡不住同人重复预约。
  • 乐观锁忘记给重试次数上限,热点课节会引发重试风暴。
  • 分布式锁锁了整个接口而不是单个课节,导致无关课程互相阻塞。
  • 删锁不校验 token,锁超时后可能误删他人持有的新锁。
  • 把"按钮置灰"当成并发方案,绕过前端就原形毕露。

八、工程落地建议

预约这类"库存极少、冲突局部、对一致性敏感"的业务,推荐的组合是:数据库非负约束与唯一索引作为永久底线;常态用乐观锁承担大部分流量;对预判会火爆的公开课,再叠加按课节粒度的分布式锁;前端与幂等号负责消除人为重复。上线前用并发脚本对同一课节同时发起几十个预约,断言"成功数恰好等于名额数、没有负数、没有同人两条",把这套断言加进回归,比事后查账可靠得多。以上为通用工程实践,具体实现请结合自身技术栈与数据库版本特性为准。

复盘清单

  • 扣名额是否是带条件的原子 UPDATE,而不是先 SELECT 再 UPDATE。
  • 数据库是否同时具备非负约束和业务唯一索引。
  • 乐观锁重试是否有上限,分布式锁是否按课节细粒度。
  • 释放锁是否原子地校验了归属。
  • 是否有并发压测断言"成功数=名额数"。

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

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

目录
  • 一、先想清楚:超卖到底是在哪一步发生的
  • 二、第一层:数据库约束,最后一道不能省的底线
  • 三、第二层:乐观锁,冲突少的场景优先用
  • 四、第三层:分布式锁,把同一课节的请求串行化
  • 五、第四层:前端与接口的防重复,只解决体验问题
  • 六、可直接抄走的防护层对照表
  • 七、踩坑清单
  • 八、工程落地建议
  • 复盘清单
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档