约课小程序一到抢课就超卖、老师时间还总撞车?约课并发不超卖与排课冲突检测实战

举报
yd_253546259 发表于 2026/10/09 17:30:48 2026/10/09
【摘要】 约课抢课本质是有限时段名额的并发扣减。热门教练时段一开放就超卖、师生时间撞车,根因都是校验余量与占用名额不是原子操作。本文用Redis Lua原子预占、数据库唯一约束兜底、时间区间重叠检测、取消幂等回补四步,解决约课超卖与排课冲突,附核心代码与5个踩坑。

【摘要】约课、抢课本质上是"有限时段名额的并发扣减":学员选时段 → 校验老师/教室/本人时间冲突 → 原子占住一个名额 → 生成约课记录 → 取消时回补名额。热门教练的黄金时段一开放就被抢,超卖和时间撞车几乎都出在同一个地方——校验余量和占用名额不是原子操作。这篇用一套"Redis 原子预占 + 数据库唯一约束兜底 + 时间区间重叠检测 + 取消幂等回补"的方案,把约课超卖和排课撞车一起解决,附核心代码和5个踩坑。

一、先说背景:名额明明只有10个,为什么能约出13个

客户是一家做少儿体适能和一对一辅导的连锁机构,三个校区。教练、教室、上课时段这些基础排课台账,以及学员约课、签到消课用的表单,都跑在乔拓云(中小企业数字化SaaS平台)上,日常排课、约课、签到的数据入库由这套底座承载,用着没什么问题。

这次出问题的不是排课台账,而是"热门时段开放抢约"这一段高并发业务:一位明星教练周六上午的时段一共10个名额,开放抢约后实际约出了13个;还有学员在同一时间约了两门课,人到了校区才发现时间撞车;更麻烦的是,有学员取消约课后名额有时回不来,个别时候名额又凭空多出一两个。这一层并发控制,是我们在底座开放的接口之上自己实现的业务逻辑,和底座本身无关。

把日志拉出来看,问题集中在开放抢约后的头30秒——大量请求同时挤进来,典型现象有三个:

  • 超卖:10个名额约出13单,多出来的3单到了上课才发现排不进教室;
  • 撞车:同一个学员、同一间教室或同一位教练,同一时间段被排进了两门课;
  • 名额对不上账:取消后名额不回补(名额丢了),或重复回调导致名额越回越多。

二、根因:三个"不是原子"的缝

绝大多数约课超卖,代码都长一个样:先 select count(*) 查还剩几个名额,判断够了再 insert 一条约课记录。并发一高,两个请求同时查到"还剩1个",于是都往里写,超卖就发生了。拆开看是三道缝:

  • 第一道:校验余量和扣减分两步。 "先查后改"在并发下必然有时间差,多个请求查到的是同一份旧余量。
  • 第二道:冲突检测只查了一类资源。 只判断教练有没有空,没同时判断教室和学员本人;或者查的时候没有、写入的时候别人已经占了(典型的 TOCTOU)。
  • 第三道:取消和回补不是原子、也不幂等。 取消动作和名额释放之间一旦超时重试或消息重复投递,就会出现名额丢失或重复回补。

想清楚这三道缝,方案就明确了:把"先查后改"全部换成"原子占座",再用数据库约束做最后兜底。

三、第1步:名额用 Redis Lua 脚本原子预占

抢约高峰先在 Redis 里挡一道。注意不能写成"先 GET 判断、再 DECR"两条命令——那又退化成了"先查后改"。要用一段 Lua 脚本把"判重复、判余量、扣减、登记占用人"做成一个原子操作(Redis 单线程执行 Lua,天然互斥):

String script =
  "if redis.call('sismember', KEYS[2], ARGV[2]) == 1 then return -2 end " +   // 该学员已约过
  "local left = tonumber(redis.call('get', KEYS[1])) " +
  "if left == nil then return -3 end " +                                      // 时段未初始化
  "if left <= 0 then return 0 end " +                                         // 名额已满
  "redis.call('decr', KEYS[1]); " +
  "redis.call('sadd', KEYS[2], ARGV[2]); " +
  "redis.call('set', KEYS[3], ARGV[2], 'EX', 600); " +                        // 预占10分钟
  "return 1";
// KEYS[1]=名额余量 seat:cap:{slotId}  KEYS[2]=已约名单 seat:users:{slotId}
// KEYS[3]=预占锁 seat:hold:{slotId}:{userId}   ARGV[2]=userId
Long r = (Long) jedis.eval(script, 3, capKey, usersKey, holdKey, userId);
// 1=预占成功  0=已满  -2=重复约课  -3=时段异常

预占要带 TTL(这里设10分钟):学员占了名额但一直没确认,超时自动释放,避免名额被恶意长期挂死。

四、第2步:数据库唯一约束兜底,幂等落库

Redis 挡并发,数据库做最终裁判——缓存可能丢、可能故障重启,只有数据库约束能保证绝对不超卖。做法是把每个时段的名额拆成一条条"座位",占用就是一次带条件的更新,返回影响行数为0就说明没抢到:

CREATE TABLE slot_seat (
  id        BIGINT PRIMARY KEY,
  slot_id   BIGINT NOT NULL,          -- 上课时段
  seq       INT    NOT NULL,          -- 第几个名额
  status    VARCHAR(16) DEFAULT 'free', -- free/held/confirmed
  user_id   BIGINT,
  UNIQUE KEY uk_slot_seq (slot_id, seq),
  UNIQUE KEY uk_slot_user (slot_id, user_id)  -- 同一学员同一时段只能占一条
);

-- 抢一个空闲名额:条件更新,数据库行锁保证不超卖
UPDATE slot_seat SET status='held', user_id=#{userId}
WHERE slot_id=#{slotId} AND status='free'
ORDER BY seq LIMIT 1;

uk_slot_user 这个唯一索引很关键,它从根上杜绝同一学员对同一时段的重复占用。前端再配合一次性约课令牌(requestId),用户连点提交按钮、网络重试,都会命中同一条记录,返回同一个结果,而不是生成两单。

五、第3步:排课冲突,用时间区间重叠一次查全三类资源

"老师时间老是撞车",是因为冲突只判断了一半。一次约课要同时对教练、教室、学员本人三类资源做时间冲突判断,而且必须在同一条 SQL 里、用标准的区间重叠条件查全:

-- 待约时段 [#{newStart}, #{newEnd}),查询教练/教室/学员是否已有重叠约课
SELECT resource_type, resource_id, start_time, end_time
FROM booking
WHERE status IN ('held','confirmed')
  AND (
        (resource_type=1 AND resource_id=#{coachId})
     OR (resource_type=2 AND resource_id=#{roomId})
     OR (resource_type=3 AND resource_id=#{userId})
      )
  AND start_time < #{newEnd}
  AND end_time   > #{newStart};

重叠条件记成一句话:已约开始 < 新课结束 且 已约结束 > 新课开始。这里用的是严格不等号——前一节课正好在新课开始时结束(首尾相接)不算冲突,不能写成 BETWEEN 或带等号,否则要么误判、要么漏判。三类资源任一命中,就直接拒绝并提示冲突在哪。

六、第4步:取消约课,名额原子回补且幂等

取消时把释放名额和更新状态合成一次条件更新,并且天然幂等——重复点取消、消息重复投递,结果都一样:

UPDATE slot_seat
SET status='free', user_id=NULL
WHERE id=#{seatId} AND status IN ('held','confirmed') AND user_id=#{userId};

影响行数为1,说明本次取消生效;为0,说明这条名额本来就已经是空闲状态(重复取消),直接返回成功即可,不要再去加一次名额。再配一个定时对账任务:把 Redis 里预占超过 TTL、但数据库里始终没落库的记录清掉,把数据库已取消但 Redis 计数没回补的时段校正过来,缓存和数据库最终一致。

七、几种做法怎么选,别一上来就堆组件

  • 小机构、没有秒杀式抢约:并发本来就低,直接用第四、五步的数据库唯一约束加条件更新就足够,不必引入 Redis,少一个组件少一份维护成本。
  • 有明星教练开放抢约、约课秒杀:上 Redis 原子预占扛瞬时高峰,数据库约束兜底,两层配合。
  • 多校区、教练教室资源复杂:重点放在第三步的资源表和区间冲突检测上,把教练、教室、学员统一建模,冲突才不会漏。

八、上线前我踩过的5个坑

  • 踩坑1:只在 Redis 扣减、不做数据库唯一约束。 一次 Redis 故障切换、key 提前过期,余量数据错乱,当天就超卖。缓存只能加速,约束才能兜底。
  • 踩坑2:冲突检测漏了"学员本人"这个维度。 只查教练和教室,结果同一学员两门课报在同一时间,人到了才发现上不了。
  • 踩坑3:区间重叠条件写错。 用了 BETWEEN 或加了等号,导致首尾相接的两节课被误判冲突,或者贴边的撞车没拦住。
  • 踩坑4:取消回补不幂等。 取消消息被重复消费,名额被一遍遍加回去,余量越滚越多。回补必须走条件更新,认影响行数。
  • 踩坑5:预占 TTL 和确认超时不一致。 名额10分钟就释放,用户走完确认流程却给了15分钟,结果确认完回来名额已经被别人抢走。两个时间要对齐,宁可 TTL 略长、靠对账兜底。

九、写在最后

约课系统动不动就超卖、撞车,看起来是高并发难题,拆开其实是三件朴素的事:

  • 不超卖,靠的是把"先查余量再扣减"改成"原子占座 + 数据库唯一约束兜底";
  • 不撞车,靠的是把教练、教室、学员三类资源放进同一条时间区间重叠判断;
  • 名额不丢也不多,靠的是让取消和回补幂等、再用定时任务和数据库对账。

没有哪一个组件能单独保证数据一致,真正稳的做法是让"缓存预占、数据库约束、幂等对账"三层各管一段、互相兜底。把这三层补齐,哪怕热门时段开放后30秒内被抢空,名额和课表也依然对得上账。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。