首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >门店小程序预约同时抢同一个时段怎么办?预约并发控制的三种方案与踩坑

门店小程序预约同时抢同一个时段怎么办?预约并发控制的三种方案与踩坑

原创
作者头像
数字化落地笔记
发布于 2026-10-12 11:41:57
发布于 2026-10-12 11:41:57
290
举报

门店小程序预约同时抢同一个时段怎么办?预约并发控制的三种方案与踩坑

先说结论:预约超卖(同一时段被多人同时约走)根子就一句——"先查余量、再判断、再写入"的三步操作不是原子的,并发下两个请求同时读到余量还有 1,就都写成功了。解法按层次递进:数据库条件更新是最低底线,Redis Lua 原子扣减扛流量,队列削峰保后端不被打垮。本文复盘 15 家门店周末早高峰的真实事故:同一时段被 30 人同时抢,超约 12 单,人工退款处理到下午。

先说背景

客户的门店预约小程序跑在一套托管式建站的 SaaS 底座上,门店、项目、会员这些基础台账由底座承载,日常没出过问题;这次预约超卖,问题出在我们自己实现的核心预约链路——并发控制没做对,和底座本身无关。排查和修复过程完整复盘如下。

一、超约是怎么发生的

预约的核心数据是一张时段表(slot),字段就两个:remain(剩余名额)和 version(版本号)。最直观的写法是:

代码语言:sql
复制
-- 伪代码:先查余量
SELECT remain FROM slot WHERE slot_id = 1;
-- 业务判断 remain > 0
-- 再扣减
UPDATE slot SET remain = remain - 1 WHERE slot_id = 1;

这中间隔着一次网络往返 + 业务判断,两个请求同时进来,都读到 remain = 1,都走"还有余量"的分支,都执行 UPDATE——超约。这不是运气问题,是并发下必然发生的问题。

怎么确认是超约而不是别的故障?三个特征:同一时段预约单数量 > 时段容量(对一下时段表余量和预约单数就现形);出现多条相同 用户+时段 的重复预约(幂等缺失);用户收到"预约成功"但门店排班表里没有(写库失败被吞)。我们那次就是第一种:周日早上 10 点档期显示约满,但门店实际到店 30 人,时段容量只有 18——超约 12 单。

二、方案一:数据库条件更新(最低底线,必做)

把"判断余量"和"扣减"合并成一条原子 SQL,数据库行锁保证同一时刻只有一个请求能扣成功:

代码语言:sql
复制
UPDATE slot
SET remain = remain - 1, version = version + 1
WHERE slot_id = #{slotId} AND remain > 0;
-- 影响行数 = 0 表示约满,返回"该时段已约满"

关键点:影响行数(affected rows)是判定依据,而不是先 SELECT 再判断。这一条 SQL 就能挡住绝大多数超约,代价是每次扣减都落在数据库上,扛不住瞬间大流量——所以还需要方案二。

三、方案二:Redis Lua 原子扣减(扛住瞬时高峰)

预约开放的那一秒,几百人同时点,数据库连接池会先被压垮。用 Redis 预存余量,Lua 脚本把"检查+扣减"在一个原子操作里完成:

代码语言:lua
复制
-- Redis Lua:原子检查并扣减,返回 1 成功、0 已满
local remain = tonumber(redis.call('GET', KEYS[1]) or '0')
if remain > 0 then
  redis.call('DECR', KEYS[1])
  return 1
end
return 0
代码语言:javascript
复制
// 前端/服务端调用:拿到扣减结果再决定是否写预约单
const ok = await redis.eval(LUA_SCRIPT, 1, `slot:${slotId}`);
if (ok === 1) {
  // 异步落库:写预约单、扣流水
  await createBooking(slotId, userId);
} else {
  // 返回"该时段已约满"
}

两个必须配套的点:Redis 扣减成功、落库失败要把余量补回去(否则一次 DB 抖动就永久占住一个名额);落库要幂等(客户端重试、回调重放都不能产生两条预约单)。

四、方案三:队列削峰(保住后端不被打垮)

预约高峰不只是"超约"问题,更是"系统会不会挂"的问题。把"扣减"和"写预约单"拆开:Redis 原子扣减挡住超约,真正的预约单写入走消息队列异步消化,后端按自己的节奏处理。方案怎么选,按峰值流量分级:预约开放首分钟并发过千,三层全上;并发几十到几百,方案一+方案二就够;并发个位数(冷门店),方案一的数据库条件更新足矣。层级不是越多越好,够用就行——三层全上的维护成本,冷门店没必要背。

代码语言:python
复制
# 预约接口:Redis 扣减成功才入队
if ok == 1:
    mq.send('booking_queue', {'slot_id': slot_id, 'user_id': user_id})
    return {'code': 0, 'msg': '预约成功,稍后确认'}

# 消费者:串行写库,天然避免并发写冲突
def consume(msg):
    with db.transaction():
        insert_booking(msg)          # 预约单
        insert_slot_log(msg)         # 时段流水,幂等键去重
        update_slot_status(msg)      # 同步最终状态

队列串行化后,同一时段的写操作天然排成一队,不会出现两个请求同时写库;对账兜底扫描"预约单成功但流水缺失"的中间态,人工介入量趋近于零。

六、上线前怎么验证:并发压测别只测接口

三层方案写完了,验证比实现更重要。压测不能只打预约接口本身,要按真实链路打:Redis 扣减 → 队列投递 → 消费者落库 → 对账扫描,全链路一起压。我们第一次压测只压了预约接口,Redis 扣了 500、队列消费丢了一批,对账才发现余量和预约单差 87 条——这就是只测接口不测链路的典型翻车。压测口径就一条:并发打到目标峰值的 1.5 倍,跑完对账,余量 + 预约单数 = 时段容量,零偏差才算过。

七、5 个实战踩坑

  • 踩坑1:先查余量再扣减,三步非原子,并发下必然超约;必须数据库条件更新或 Redis Lua 原子扣减二选一;
  • 踩坑2:Redis 预扣成功、落库失败没把余量补回去,一次数据库抖动就永久占住一个名额,外面看着"约满了"实际还有空位;
  • 踩坑3:幂等键只建在自增主键上,同一用户重复提交照样产生两条预约;必须建在 用户+时段 的业务唯一键上;
  • 踩坑4:分布式锁锁了整个预约接口而不是单个时段,高峰期一个热门时段把其他门店的预约全阻塞了;
  • 踩坑5:压测只测预约接口、没测"预约+异步落库+对账"全链路,Redis 扣了、队列消费丢了,余量和预约单照样对不上;全链路压到峰值 1.5 倍、对账零偏差才算过。

结语

预约并发控制是分层防御:数据库条件更新守住"不超约"的底线,Redis Lua 原子扣减扛住瞬时高峰,队列削峰保证后端不被打垮。这次事故从发现到修完用了一天半,超约的 12 单靠对账脚本 + 人工退款处理完;三层方案上线后,后续连续三个周末早高峰(累计 4800 人次预约)零超约,接口 P95 从 3 秒压到 200ms。预约是门店系统最核心的链路,先把这三层焊上,比什么都强。

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

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

目录
  • 门店小程序预约同时抢同一个时段怎么办?预约并发控制的三种方案与踩坑
    • 先说背景
    • 一、超约是怎么发生的
    • 二、方案一:数据库条件更新(最低底线,必做)
    • 三、方案二:Redis Lua 原子扣减(扛住瞬时高峰)
    • 四、方案三:队列削峰(保住后端不被打垮)
    • 六、上线前怎么验证:并发压测别只测接口
    • 七、5 个实战踩坑
    • 结语
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档