我们给十几家连锁门店做的预约系统,平时风平浪静,一到周一早上十点放周末号就出状况。某热门门店一天就放几十个体检名额,几百人同一秒往里挤,接口 RT 从几十毫秒飙到几秒,更麻烦的是同一个时段竟然约出了容量之外的人——后台显示名额约满了,预约记录却比容量多出七八条,店员只能一个个打电话道歉改期。这篇把那次治理拆开讲:预约这种"固定时刻、有限名额、瞬时并发"的场景,光加一把锁是不够的,得靠"原子扣减 + 限流削峰 + 排队兜底 + 幂等"四层一起兜,附上关键代码和五个踩坑。
很多人第一反应是"加个锁就好了",但锁只解决其中一件事。把那一秒拆开,其实是三个问题叠在一起。
第一是超约。旧逻辑是先查余量、再判断够不够、最后插一条记录,三步不在一个原子操作里。两个并发请求同时查到"还剩 1 个",于是都判断可约、都插入,名额就超了。这是典型的读改写竞态,跟你用不用锁、用什么语言没关系,是逻辑本身有缝。
第二是重复单。用户点一下没反应就再点,弱网下请求重试,网关也可能重放,同一人的预约进来两次甚至三次。
第三是流量本身。固定时刻放号,瞬时 QPS 是平时的几十倍,就算逻辑上不超约,数据库连接池也会被占满,结果是那些本该成功的请求也跟着超时。三个问题要分开治,指望一个方案全包,最后哪个都没堵住。
最稳的底线是别靠"先查再写",而是让数据库在一条语句里完成判断和扣减,靠行锁保证不会超:
UPDATE slot
SET remaining = remaining - 1
WHERE store_id = ? AND slot_time = ? AND remaining > 0;
-- 影响行数 affectedRows = 1 才算抢到,= 0 就是已经约满remaining > 0 这个条件是关键,数据库会替你挡住"扣成负数"。应用层只看影响行数,是 1 才继续建预约单,是 0 直接返回约满。
但每个请求都去打数据库条件更新,热点时段照样扛不住。读多写少、抢的是固定名额,更合适的是用 Redis 记余量,把"判断 + 扣减"用一段 Lua 合成一次原子操作:
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 下很慢,一堆请求排队等锁,反而更容易超时。扣减类问题优先用原子操作,把锁留给真正需要"读—改—写"的地方——比如后面要说的取消递补,要先读队列队首、再改预约状态,那才是锁该出现的场景。
原子扣减解决了"不超约",解决不了"流量太大把系统压垮"。固定时刻放号,超过系统承载的请求应该在进核心逻辑之前就被挡下来,而不是放进来一起抢连接池。我们在接口入口按"门店 + 时段"维度做了令牌桶限流:
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 占位:
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 最终只会成一单。
incr 回补并记日志。预约、秒杀、抢号这类问题的内核都一样:固定时刻、有限资源、瞬时并发,别指望某一个中间件包治百病。正确的顺序是先把"判断和扣减"合成原子操作,从根上杜绝超约;再用限流把超过承载的流量挡在存储之前;用排队把"没抢到"变成一次可接受的等待;最后用幂等保证"点几次都只成一单"。下次再遇到某个接口一到固定时间点就崩,先别急着加机器,按这四层从下往上对一遍,基本都能定位到是哪一层缺了。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。