到店服务的高并发排队系统:Redis ZSET + Lua 实战,以及那些我踩过的坑

说实话,排队这种业务,在到店服务里看着简单,不就是取号、叫号、入座吗?但你要是真的用数据库给它实现一遍,高并发一来,你就等着哭吧。

以前我们也是图省事,直接用一张表存排队记录,每次叫号就是拿一个待服务的记录,然后update一下状态。听起来没毛病吧?直到某一天,门店搞了一场’五星级体验’活动,晚上六点整,线上排号请求瞬间冲到2000QPS,数据库的连接池瞬间打满,update阻塞,然后就是连锁反应——订单卡死,服务员手动叫号,顾客堵在门口……那场面,简直没法看。

那次事故以后,我痛定思痛。琢磨着能不能用内存数据结构去做队列——比如Redis的有序集合ZSET。今天就把这套方案拆开讲透,顺便讲几个坑。

一、为什么数据库行锁做不了排队核心

很多人第一反应:排队不就是一个有顺序的集合吗?用数据库的auto_increment或者时间戳排序,不就是天然队列吗?对,理论上没错,但实际工程里,你得考虑并发更新同一行数据的锁竞争。

拿上述事故来说,我们用一张表queue,字段有id, store_id, status, created_at。请求进入时插一条status=0,叫号时SELECT id FROM queue WHERE store_id=? AND status=0 ORDER BY id LIMIT 1 FOR UPDATE。你猜怎么着?Redis压测没做,数据库先跪了。

这里有个本质问题:数据库的行锁是有代价的。当并发更新落在同一行(或者相邻索引区间)时,InnoDB要维护锁等待队列,还要做死锁检测。在高并发写入下,锁竞争导致上下文切换剧烈,最终吞吐量断崖式下降。

我们当时压测数据:4核8G的MySQL实例,2000QPS纯插入和update混合操作,延迟P99直飙到850ms,CPU打满。更可怕的是,连接池一满,新的请求直接排队等待,然后雪崩。

你就说,这种体验,谁敢给顾客用?

二、Redis ZSET:队列的本质是排序,不是存储

Redis的ZSET(有序集合)恰好就是为这种场景设计的。成员(member)是用户标识,分值(score)是入队时间戳(毫秒精度),天然按时间有序。

取号逻辑:ZADD queue:{storeId} timestamp userId。叫号逻辑:ZRANGE queue:{storeId} 0 0 WITHSCORES,拿到第一个用户,然后ZREM掉。看似简单,但这里有个并发陷阱:如果直接用ZRANGE + ZREM两个命令,在并发叫号时,多个服务员可能叫到同一个号!

解决方法是Lua脚本。把取最低分元素和删除合并成一个原子操作:

local member = redis.call('ZRANGE', KEYS[1], 0, 1, 'WITHSCORES')[1]
if member then
    redis.call('ZREM', KEYS[1], member)
end
return member

这行Lua脚本,在Redis内部是单线程执行的,所以不会出现并发问题。而且效率极高——整个操作在毫秒级完成。

你可能说:Redis单点不是有瓶颈吗?对,但到店排队这种场景,单店QPS上限也就几千,Redis单实例能轻松扛住10万QPS,完全够用。而且即使挂了,我们还有降级方案:用本地队列兜底,等Redis恢复再同步。

这就是典型的内存计算思维:把排序这件事从数据库挪到内存,利用异步写入保证最终一致性。

到店服务排队系统Redis ZSET架构图
到店服务排队系统Redis ZSET架构图

三、压测数据对比:从850ms到15ms

三、压测数据对比:从850ms到15ms
三、压测数据对比:从850ms到15ms

做迁移之前,我们专门压测了一轮。同一台服务器(16核32G),模拟2000并发请求,分别压MySQL方案和Redis+Lua方案。

MySQL方案:P99延迟850ms,成功率98.2%——有1.8%的请求超时或获取连接失败。Redis方案:P99延迟15.3ms,成功率99.99%。你没看错,两个数量级的差距。

更关键的是,我们原本为了MySQL能扛住,开了6台应用服务器做负载均衡。换了Redis之后,应用服务器直接缩减到2台,加上Redis本身,总成本反而降低。你算算,这省下的不仅是运维成本,还有用户流失的隐性损失。

当然,压测不是万能的。我们后来发现,Redis方案在极端情况下会出现队头阻塞?不,那是网络问题。真正的坑在下面。

四、预测翻台率:一个被忽略的隐性问题

排队只是入口,真正影响顾客体验的是预估等待时间。很多系统直接用一个固定值(比如平均用餐时间30分钟)来算,这不科学。饭点前和饭点后的翻台率能一样吗?周五晚上和周一中午能一样吗?

我们一开始用线性回归,输入特征包括:当前排队人数、已入座人数、时段、天气、节假日。训练了个模型,结果预测偏差率高达40%。因为线性回归假设误差是均方差最小,但实际数据里,少数超长用餐时间的用户会严重拉偏模型。

后来改用分位数回归,预测中位数或90%分位数,而不是平均值。效果立竿见影——偏差率降到18%。具体做法是:用梯度提升树(GBDT)去拟合期望的等待时长分位数,损失函数用pinball loss。

这里不展开数学公式,但从工程角度看,有一个关键点:预测结果必须与队列状态一致性校验。如果预测用户还要等20分钟,但队列里实际有35个人,那么系统必须强制校准。否则顾客会骂你的算法是瞎猜。

到店服务分位数回归预测等待时间示意图
到店服务分位数回归预测等待时间示意图

五、落地时的三个坑,每个都让我头大

五、落地时的三个坑,每个都让我头大
五、落地时的三个坑,每个都让我头大

这套系统上线已经半年,踩过的坑可以凑一桌了。这里分享三个最典型的。

坑之一:Redis持久化配置不当导致丢号

为了性能,我们把Redis的save策略设成了’不要持久化’。结果一次机房断电,线上排队数据全没了。顾客排了半小时,瞬间清零,那场面……后来我们改用AOF + everysec,丢数据最多1秒,而且我们还做了Redis主从,秒级切换。但注意,主从切换时也要小心,必须用哨兵或Cluster,防止脑裂。

坑之二:ZSET成员过期管理

有的用户取号以后,可能不来了(或者过号了),这些僵尸成员会一直占着排序,导致后面的真实顾客被无端等待。我们的方案是:每个成员加一个有效期的score字段(时间戳),定时用ZREMRANGEBYSCORE清理超过N分钟未叫号的记录。但这里有个反直觉的点:如果你直接删除,顾客可能刚好赶到,发现号没了。所以我们做了二次确认,比如到号时通过小程序弹窗,用户点’延后’则保留5分钟,否则自动跳过。

坑之三:Lua脚本执行时间过长阻塞Redis

Lua虽然原子,但如果你在脚本里做了复杂的循环或带网络IO的操作,整个Redis会被卡住。我们曾写了个脚本,里面调用了TIME命令和大量字符串拼接,结果慢查询日志显示执行了200ms,直接导致同一Redis实例上的其他业务卡顿。教训是:Lua脚本只做最精简的原子操作,任何非Redis的操作都不要放进去。如果必须做复杂逻辑,先取数据到应用层,再用Lua交还。

这三个坑,每一个都能让系统一夜回到解放前。希望你看完能少走弯路。

最后,我想说:到店服务的技术深度,不在那些花哨的AI概念,而在这些看似底层的细节里。一个优秀的架构师,就是要能在毫秒之间,让顾客感受到你的用心。

免责声明:市场有风险,选择需谨慎!此文仅供参考,不作买卖依据。如有侵权请联系删除。
文章名称:到店服务的高并发排队系统:Redis ZSET + Lua 实战,以及那些我踩过的坑
文章链接:https://lfdjt.com/info_23_12876.html