首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >周末早高峰门店预约接口被挤爆:分布式锁、限流与排队兜底的并发治理实践

周末早高峰门店预约接口被挤爆:分布式锁、限流与排队兜底的并发治理实践

原创
作者头像
数字化落地笔记
发布2026-09-15 09:14:41
发布2026-09-15 09:14:41
100
举报

导读

我们给十几家连锁门店做的预约系统,平时风平浪静,一到周一早上十点放周末号就出状况。某热门门店一天就放几十个体检名额,几百人同一秒往里挤,接口 RT 从几十毫秒飙到几秒,更麻烦的是同一个时段竟然约出了容量之外的人——后台显示名额约满了,预约记录却比容量多出七八条,店员只能一个个打电话道歉改期。这篇把那次治理拆开讲:预约这种"固定时刻、有限名额、瞬时并发"的场景,光加一把锁是不够的,得靠"原子扣减 + 限流削峰 + 排队兜底 + 幂等"四层一起兜,附上关键代码和五个踩坑。

一、先看清:被挤爆的那一秒同时出了三个问题

很多人第一反应是"加个锁就好了",但锁只解决其中一件事。把那一秒拆开,其实是三个问题叠在一起。

第一是超约。旧逻辑是先查余量、再判断够不够、最后插一条记录,三步不在一个原子操作里。两个并发请求同时查到"还剩 1 个",于是都判断可约、都插入,名额就超了。这是典型的读改写竞态,跟你用不用锁、用什么语言没关系,是逻辑本身有缝。

第二是重复单。用户点一下没反应就再点,弱网下请求重试,网关也可能重放,同一人的预约进来两次甚至三次。

第三是流量本身。固定时刻放号,瞬时 QPS 是平时的几十倍,就算逻辑上不超约,数据库连接池也会被占满,结果是那些本该成功的请求也跟着超时。三个问题要分开治,指望一个方案全包,最后哪个都没堵住。

二、第一层:把"判断 + 扣减"做成原子操作

数据库条件更新,作为最终防线

最稳的底线是别靠"先查再写",而是让数据库在一条语句里完成判断和扣减,靠行锁保证不会超:

代码语言:sql
复制
UPDATE slot
SET remaining = remaining - 1
WHERE store_id = ? AND slot_time = ? AND remaining > 0;
-- 影响行数 affectedRows = 1 才算抢到,= 0 就是已经约满

remaining > 0 这个条件是关键,数据库会替你挡住"扣成负数"。应用层只看影响行数,是 1 才继续建预约单,是 0 直接返回约满。

Redis 预扣加 Lua,作为扛量主力

但每个请求都去打数据库条件更新,热点时段照样扛不住。读多写少、抢的是固定名额,更合适的是用 Redis 记余量,把"判断 + 扣减"用一段 Lua 合成一次原子操作:

代码语言:python
复制
LUA_DEDUCT = """
local left = tonumber(redis.call('get', KEYS[1]) or '0')
if left <= 0 then
    return 0
end
redis.call('decr', KEYS[1])
return 1
"""
# Redis 单线程执行 Lua,期间不会插入别的命令,天然不存在"两个都判断还有1个"
ok = redis.eval(LUA_DEDUCT, 1, f"slot:{store_id}:{slot_time}")

Redis 单线程顺序执行脚本,判断和扣减之间插不进第二个请求,从根上消掉竞态。预扣成功的请求才允许去写数据库。

这里要单独说一句分布式锁的位置。很多教程一上来就让你对名额加锁、串行化,思路没错但在热点 key 下很慢,一堆请求排队等锁,反而更容易超时。扣减类问题优先用原子操作,把锁留给真正需要"读—改—写"的地方——比如后面要说的取消递补,要先读队列队首、再改预约状态,那才是锁该出现的场景。

三、第二层:限流削峰,别让全部流量砸到存储

原子扣减解决了"不超约",解决不了"流量太大把系统压垮"。固定时刻放号,超过系统承载的请求应该在进核心逻辑之前就被挡下来,而不是放进来一起抢连接池。我们在接口入口按"门店 + 时段"维度做了令牌桶限流:

代码语言:javascript
复制
async function reserveRateLimit(ctx, next) {
  const key = `rl:${ctx.storeId}:${ctx.slotTime}`;
  const pass = await tokenBucket.tryAcquire(key, 50); // 该门店每秒放 50 个
  if (!pass) {
    ctx.status = 429;
    ctx.body = { code: 'BUSY', msg: '当前人数较多' };
    return;
  }
  await next();
}

限流维度一定要细到门店和时段。我们最早只做了一个全局 QPS 阈值,结果热门门店瞬间把全局额度吃光,冷门门店的正常预约也被误杀,调了半天才定位到是限流 key 太粗。被限流挡下的请求也别直接甩一句"系统繁忙"让用户干瞪眼,交给下一层排队。

四、第三层:排队兜底加幂等,治"抢不到"和"点两次"

排队递补,把失败变成等待

名额几十毫秒内抢光后,多出来的请求写进一个有序等待队列,用 Redis zset、分数取时间戳,先来后到。一旦有人取消,就按顺序把队首的人补进来并发通知。用户端看到的是"已为你排队,前面还有 X 人",而不是一个冷冰冰的约满失败,体验和投诉率都好很多。

服务端幂等,挡住重复提交

前端把预约按钮点一下就置灰,这是必须的,但远远不够——弱网重试、双击、网关重放都发生在前端之外。我们在进入预约页时先发一个幂等 token,提交时带上,服务端用 SET NX 占位:

代码语言:javascript
复制
async function createBooking(req) {
  const idem = req.body.idemToken;
  // NX:只有第一个请求能设置成功;EX 10 秒后自动释放,防止死占位
  const first = await redis.set(`idem:${idem}`, 1, 'NX', 'EX', 10);
  if (!first) {
    return { code: 'DUP', msg: '请勿重复提交,结果处理中' };
  }
  // 通过后才走 Lua 原子扣减 + 写库
}

第一个请求占位成功继续往下走,重复进来的请求直接拦下。这样无论用户点几次、网络重试几次,一个 token 最终只会成一单。

五、踩坑清单

  • 坑1:用"先查余量、再判断、再插入"判断约满。三步非原子,并发下必然超约;要么数据库条件更新,要么 Redis Lua 原子扣减,没有第三种省事的办法。
  • 坑2:Redis 预扣成功、写库却失败,没把余量补回去。一次数据库抖动就永久占住一个名额,外面看着约满、实际有空缺。扣减和落库必须放在同一套补偿逻辑里,写库失败立刻 incr 回补并记日志。
  • 坑3:一上来就用分布式锁把整个预约串行化。正确但极慢,热点 key 下锁等待堆积反而超时。扣库存优先原子操作,锁只留给取消递补这种真正读改写的环节。
  • 坑4:限流只做全局 QPS。热门门店打爆、冷门门店被误伤,限流 key 必须细到"门店 + 时段",阈值按单店承载而不是整体均值定。
  • 坑5:幂等只在前端置灰按钮。弱网重试和双击照样穿透,服务端必须用唯一 token 加 SET NX 兜底;而且幂等 key 要和"用户 + 门店 + 时段"绑定,不能只用用户 ID,否则同一个人约两个不同时段会被误判成重复。

结语

预约、秒杀、抢号这类问题的内核都一样:固定时刻、有限资源、瞬时并发,别指望某一个中间件包治百病。正确的顺序是先把"判断和扣减"合成原子操作,从根上杜绝超约;再用限流把超过承载的流量挡在存储之前;用排队把"没抢到"变成一次可接受的等待;最后用幂等保证"点几次都只成一单"。下次再遇到某个接口一到固定时间点就崩,先别急着加机器,按这四层从下往上对一遍,基本都能定位到是哪一层缺了。

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

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

目录
  • 导读
  • 一、先看清:被挤爆的那一秒同时出了三个问题
  • 二、第一层:把"判断 + 扣减"做成原子操作
    • 数据库条件更新,作为最终防线
    • Redis 预扣加 Lua,作为扛量主力
  • 三、第二层:限流削峰,别让全部流量砸到存储
  • 四、第三层:排队兜底加幂等,治"抢不到"和"点两次"
    • 排队递补,把失败变成等待
    • 服务端幂等,挡住重复提交
  • 五、踩坑清单
  • 结语
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档