前言
前段时间,我的好基友决定裸辞,奔赴他所谓的“新生活”。
本以为他会 gap 一段时间、好好休息,结果不到一周就开始海投简历了。
身为牛马,我很抱歉。还是建议没有存款的情况下,真的别轻易裸辞。能苟着,就先苟着。 现在的大环境,一言难尽,他面了一个月才勉强拿到两个面试机会。
昨天他终于迎来了其中一个面试,是一家做餐饮的小中型公司。技术面聊得还行,接着就到了业务场景题的环节。
面试官很直接,甩过来一个听起来简单、但其实非常能卡人的问题:
“我们小程序支持用户下单排队,商家那边会按号出餐。如果是你来负责该怎么设计这个排队叫号系统?”
可能是太久没面试了,也可能是真的没准备好,我这位基友也没多想,第一反应就是:
“那就 Redis List 搞一搞呗。”
听起来似乎也没毛病,用 RPUSH 入队、LPOP 出队,顺序管理非常简单。但面试官显然并不满意这样的回答,紧接着抛出几个实际场景问题:
- “那如果有一杯奶茶先做好了,你能先叫它吗?”
- “如果用户想查看自己前面还有几个人,该怎么查?”
- “非顺序叫号你这个支持吗?”
这几个问题抛出来后,我朋友也没仔细去想,就随口回答了几句,最后也只是礼貌地等通知了。。。
排队叫号的两种经典场景
我们在生活中遇到的排队叫号系统,其实大致可以分成两种典型场景:
- 一种是严格顺序叫号:比如火锅店,顾客到店后按顺序排队。每叫走一桌,下一个自然就上,中途不可跳号,只能等前面的都完成;
- 一种是非顺序叫号:比如奶茶店、炸鸡店,虽然下单顺序有编号,但备餐进度不同。有的简单饮品先做好,就可以先叫,叫号顺序并不等于下单顺序。
这两种场景虽然表面上都叫“排队叫号”,但系统设计上完全不是一个路数。
那我们结合这两类场景,逐步来看看:这个排队叫号系统到底该怎么设计?
顺序叫号场景设计:从 Redis List 到 ZSet 的演进
场景复现:银行排队系统
举个例子,我们在建行小程序里预约办理业务,点击“取号”,就会跳出如下页面:
- 我们就拿到了一个号码,比如
A014 - 提示我们前面还有 4 人
- 显示我们的业务类型、办理网点、排队时间
- 同时提供“查看我的排号”与“在线填单”入口
这是一个典型的顺序叫号系统:按照我们取号的顺序排队、叫号,不可跳过,必须一人办完或者无人应答才能叫下一位。
初始方案设计:后端使用 Redis List
在最初版本的设计中,这类排队需求我们通常可以使用 Redis 的 List 结构实现,因为它天然是个先进先出队列(FIFO)。
用户取号流程:
- 小程序点击“取号” → 调用接口
- 后端用 Redis
INCR生成一个编号,例如A014 - 将编号加入队列:
func TakeNumber(storeID string, redisClient *redis.Client) (string, int, error) {
// 生成排队编号(例如 A014)
nextNum, err := redisClient.Incr(ctx, fmt.Sprintf("store:%s:next_number", storeID)).Result()
if err != nil {
return "", 0, err
}
queueNo := fmt.Sprintf("A%03d", nextNum)
// 加入队列
_, err = redisClient.RPush(ctx, fmt.Sprintf("store:%s:queue", storeID), queueNo).Result()
if err != nil {
return "", 0, err
}
// 获取当前队列长度,估算排在第几
queueLen, err := redisClient.LLen(ctx, fmt.Sprintf("store:%s:queue", storeID)).Result()
if err != nil {
return "", 0, err
}
return queueNo, int(queueLen) - 1, nil // -1 是因为刚加入进去
}
- 返回用户当前号码和排队信息(例如前方还有多少人)
商家叫号流程:
- 调用
LPOP store:{id}:queue,获取队头(下一个要叫的号) - 将叫号信息推送到前台设备 / 小程序 / 短信
- 标记叫号已处理
func CallNext(storeID string, redisClient *redis.Client) (string, error) {
// 从队头取出第一个排队号
queueNo, err := redisClient.LPop(ctx, fmt.Sprintf("store:%s:queue", storeID)).Result()
if err != nil {
return "", err
}
// TODO: 通知前端/推送小程序/短信等
fmt.Printf("叫号通知:请 %s 到前台办理\n", queueNo)
return queueNo, nil
}
查询“我前面有几人”:
这个环节是个痛点,因为 Redis List 不支持“查排名”,只能 LRANGE 拉整条数据再遍历
常见做法是:
func GetQueuePosition(storeID, queueNo string, redisClient *redis.Client) (int, error) {
listKey := fmt.Sprintf("store:%s:queue", storeID)
// 获取整个队列
queue, err := redisClient.LRange(ctx, listKey, 0, -1).Result()
if err != nil {
return -1, err
}
// 遍历找排在第几
for i, v := range queue {
if v == queueNo {
return i, nil
}
}
return -1, fmt.Errorf("not found")
}
应用层拉全量遍历 → 查询用户在 List 中的位置,来估算前面还有几人。对于几十、上百个排队用户,这个方法非常低效
叫号完成:
- 用户被叫到后业务完成
- 后端移除该号(其实 LPOP 时已移除)
- 系统通知下一个人
假设我们维护一个简单的状态记录,以及通知下一位用户
func FinishCurrentAndNotifyNext(storeID string, redisClient *redis.Client) error {
// 模拟叫号:从队列中取出当前排队号
currentNo, err := redisClient.LPop(ctx, fmt.Sprintf("store:%s:queue", storeID)).Result()
if err == redis.Nil {
fmt.Println("队列为空,暂无人排队")
return nil
} else if err != nil {
return err
}
fmt.Printf("当前号码 %s 已完成业务处理\n", currentNo)
// TODO:记录处理日志、写入 MySQL 等(伪代码)
// db.Insert("queue_log", {queue_no: currentNo, finished_at: time.Now()})
// 查看下一个排队号(用于前端展示或通知)
nextNo, err := redisClient.LIndex(ctx, fmt.Sprintf("store:%s:queue", storeID), 0).Result()
if err == redis.Nil {
fmt.Println("暂无下一位用户")
} else if err != nil {
return err
} else {
// TODO: 推送消息到小程序 / 短信通知 / WebSocket 等
fmt.Printf("📣 请 %s 到前台办理业务\n", nextNo)
}
return nil
}
执行逻辑说明
- 用
LPOP拿出当前号(处理完成) - 可将该记录落库(如写入 MySQL 排队日志表)
- 用
LINDEX查看下一个号(不出队) - 可推送通知给下一个用户(通过短信、小程序订阅消息、WebSocket 等)
在实际业务中,一般会将“叫号 → 状态记录 → 通知”这套逻辑封装为事务性处理,避免中间部分失败后状态错乱。
用 Redis ZSet 实现更强排队能力
我们在上面用几个简单的 Go 伪代码,结合 Redis 的 List 结构,实现了一个最基础的“顺序叫号系统”——用户取号、排队、叫号、查看排位都可以跑起来。
虽然功能能跑,但实际业务中,这种架构存在非常明显的缺陷:
List 架构的问题
- 排位查询效率低
Redis List 不支持排名查询,只能LRANGE整个列表后手动遍历,性能差、并发下易出问题。 - 不支持分页展示
想在前端展示“前10人排队列表”?List 也不支持,必须手动截取处理。 - 中间删除麻烦
用户想取消排队?商家想跳过某个人?List 只能重新构建整个列表,操作极不优雅。
于是我们换个思路:使用 Redis Sorted Set(ZSet)
Redis 的 ZSet(有序集合)非常适合需要“排名”、“分页”、“灵活操作”的排队场景。它的底层是跳表结构,支持按 score 排序、快速查找 rank 和 value。
用 ZSet 重写取号逻辑
func TakeNumberZSet(storeID string, redisClient *redis.Client) (string, int64, error) {
// 编号自增
num, err := redisClient.Incr(ctx, fmt.Sprintf("store:%s:next_number", storeID)).Result()
if err != nil {
return "", 0, err
}
queueNo := fmt.Sprintf("A%03d", num)
score := time.Now().UnixNano() // 或直接用 num 做 score 也可
// 加入 ZSet
_, err = redisClient.ZAdd(ctx, fmt.Sprintf("store:%s:queue", storeID), redis.Z{
Score: float64(score),
Member: queueNo,
}).Result()
if err != nil {
return "", 0, err
}
// 查询我排第几(ZRANK)
rank, err := redisClient.ZRank(ctx, fmt.Sprintf("store:%s:queue", storeID), queueNo).Result()
if err != nil {
return "", 0, err
}
return queueNo, rank, nil
}
叫号逻辑(取第一个人并移除)
func CallNextZSet(storeID string, redisClient *redis.Client) (string, error) {
key := fmt.Sprintf("store:%s:queue", storeID)
// 获取第一个人
vals, err := redisClient.ZRange(ctx, key, 0, 0).Result()
if err != nil || len(vals) == 0 {
return "", fmt.Errorf("队列为空")
}
queueNo := vals[0]
// 移除该用户
_, err = redisClient.ZRem(ctx, key, queueNo).Result()
if err != nil {
return "", err
}
fmt.Printf("请 %s 到前台办理业务\n", queueNo)
return queueNo, nil
}
查询我前面有几人
func GetQueuePositionZSet(storeID, queueNo string, redisClient *redis.Client) (int64, error) {
rank, err := redisClient.ZRank(ctx, fmt.Sprintf("store:%s:queue", storeID), queueNo).Result()
if err == redis.Nil {
return -1, fmt.Errorf("未找到该排号")
} else if err != nil {
return -1, err
}
return rank, nil
}
其他业务能力也可以实现的:
| 功能 | Redis ZSet 命令 |
|---|---|
| 获取当前排队人数 | ZCARD key |
| 分页获取排队列表 | ZRANGE key 0 9 |
| 删除某个号(用户取消排队) | ZREM key A014 |
| 查询我在队列的第几 | ZRANK key A014 |
我们来对比下:使用 Redis ZSet 相比 Redis List 有哪些优点?
在顺序叫号的场景下,虽然 Redis List 实现简单、操作直观,但在实际业务中会遇到不少扩展性问题。而 Redis Sorted Set(ZSet)在功能性和性能方面都有明显优势:
| 能力项 | Redis List | Redis ZSet |
|---|---|---|
| 顺序入队 | 支持 | 支持 |
| 查询我排第几位 | 不支持(需遍历) | ZRANK 直接查 |
| 分页展示队列列表 | 不支持 | ZRANGE 支持分页 |
| 中间删除(取消排队、跳过等) | 非常麻烦 | ZREM 可直接删除任意成员 |
| 性能稳定,支持高并发操作 | 一般,队列越长越卡 | 高效,支持上千人实时查询 |
| 支持更灵活的业务场景(如权重) | 不支持 | 支持多维排序 / 优先级扩展 |
如果我们的排队系统只需要“顺序进、顺序出”,Redis List 勉强够用;
但只要业务稍微复杂,比如用户想知道自己排第几、展示分页列表、支持取消排队 —— 那么 ZSet 就是更合适的选择。
非顺序叫号场景设计:状态驱动的灵活叫号机制
前面我们讲的是严格顺序的排队场景,比如火锅店、政务大厅等,讲究一个“排队有序,先来先上”。
但现实生活中,很多时候排队压根不是按顺序来的。
比如我们去肯德基、或是线下奶茶店点单,虽然我们是第 5 个下单的,但可能我们点的是个复杂的手打三重芝士玛奇朵,而第 6 个只要了一杯美式,结果美式先做好了,就先叫号了。
这类场景的本质是:
叫号顺序 ≠ 下单顺序,而是“谁先准备好谁先叫”
也就是说,它是一个非顺序叫号场景。
那问题来了:
像这种逻辑,我们还继续用 Redis ZSet 来 hold 吗?
又该怎么管理“叫号”和“准备完成”之间的状态变化?
接下来我们就来设计一下:在“状态驱动叫号”的场景下,该怎么设计排队系统?
状态驱动型叫号系统
我们前面说了,顺序叫号的本质是:先进先服务,排队顺序就是叫号顺序。
但像奶茶店、炸鸡店、肯德基这类“备餐型业务”,实际运作逻辑往往不是这样。
在这些场景里:
- 同样是排队,叫号却并不按顺序来
- 谁的单子先准备好,谁就先被叫号取餐
- 排队号只是用于标识用户顺序,但不代表服务先后顺序
这就是典型的:状态驱动型叫号系统
业务流程变化
我们把它和顺序模式做个对比:
| 项目 | 顺序叫号场景(火锅) | 非顺序叫号场景(奶茶) |
|---|---|---|
| 排队顺序 | 严格 FIFO | FIFO 只作为参考 |
| 叫号依据 | 排队顺序 | 商家标记“完成”的状态 |
| 可否跳过/打乱顺序 | 不可跳过 | 完全允许 |
| 用户体验重点 | 我排第几位,预计还有多久 | 订单准备好了吗,能不能早点叫 |
技术设计方案:ZSet + Ready Set 双结构模型
ZSet 仍然可以保留用于存储“用户排队顺序”,但是真正的叫号行为,依赖于“已准备完成”的状态标记,而不是 ZSet 的顺序。
我们引入一个新的结构:Ready Set。
Redis 结构设计:
| Key | 说明 |
|---|---|
store:{id}:queue | ZSet:排队顺序队列(按 score 排序) |
store:{id}:ready_set | Set:已准备完成、可叫号的编号集合 |
store:{id}:status:{queueNo} | Hash/String:可记录状态(waiting/ready/called) |
业务流程伪代码
用户取号
func TakeNumberFlexible(storeID string, redisClient *redis.Client) (string, error) {
num, err := redisClient.Incr(ctx, fmt.Sprintf("store:%s:next_number", storeID)).Result()
if err != nil {
return "", err
}
queueNo := fmt.Sprintf("A%03d", num)
score := time.Now().UnixNano()
// 加入顺序队列
_, err = redisClient.ZAdd(ctx, fmt.Sprintf("store:%s:queue", storeID), redis.Z{
Score: float64(score),
Member: queueNo,
}).Result()
if err != nil {
return "", err
}
// 设置初始状态
redisClient.Set(ctx, fmt.Sprintf("store:%s:status:%s", storeID, queueNo), "waiting", 0)
return queueNo, nil
}
商家备好后,标记订单为 ready
func MarkReady(storeID, queueNo string, redisClient *redis.Client) error {
// 更新状态
redisClient.Set(ctx, fmt.Sprintf("store:%s:status:%s", storeID, queueNo), "ready", 0)
// 加入 ready_set
_, err := redisClient.SAdd(ctx, fmt.Sprintf("store:%s:ready_set", storeID), queueNo).Result()
return err
}
系统叫号:从 ready_set 中选排得最靠前的
func CallNextFlexible(storeID string, redisClient *redis.Client) (string, error) {
queueKey := fmt.Sprintf("store:%s:queue", storeID)
readyKey := fmt.Sprintf("store:%s:ready_set", storeID)
readyList, err := redisClient.SMembers(ctx, readyKey).Result()
if err != nil || len(readyList) == 0 {
return "", fmt.Errorf("没有可叫号用户")
}
// 获取所有 ready 用户在 ZSet 中的排名,选最靠前的
var minRank int64 = math.MaxInt64
var selected string
for _, q := range readyList {
rank, err := redisClient.ZRank(ctx, queueKey, q).Result()
if err == nil && rank < minRank {
minRank = rank
selected = q
}
}
if selected == "" {
return "", fmt.Errorf("找不到合适的叫号对象")
}
// 移除
redisClient.ZRem(ctx, queueKey, selected)
redisClient.SRem(ctx, readyKey, selected)
redisClient.Set(ctx, fmt.Sprintf("store:%s:status:%s", storeID, selected), "called", 0)
fmt.Printf("请 %s 到前台取餐\n", selected)
return selected, nil
}
查询我还排在第几位(逻辑保留不变)
即便非顺序叫号,ZSet 仍然可以用于展示“理论排位”:
ZRank store:{id}:queue A015
但这个信息只是作为“排队顺序参考”,最终是否被叫号,还是要看商家是否标记为 ready。
拓展能力
-
如果我们希望叫号更灵活,还可以扩展:
ZSet的 score 用订单复杂度加权(复杂优先靠后)ReadySet可拆成不同窗口/任务池
-
支持取消订单:直接
ZREM+SREM+ 删除状态 key 即可
总的来说:
在非顺序叫号的业务场景中,叫号是由状态驱动的,而不是简单的“谁先排谁先上”。
我们可以借助 Redis 的 ZSet + Set 组合,优雅地实现这套逻辑,并保持系统的可观测性、灵活性与可扩展性。
统一设计思路:策略驱动的排队叫号系统架构
前面我们拆解了两类典型场景:
- 火锅店:严格按顺序叫号 → Redis ZSet 排队
- 奶茶店:按状态叫号 → Redis ZSet + ReadySet + 状态控制
虽然它们的业务流程不同,但核心需求其实是相同的:
“用户取号 → 加入排队 → 商家叫号 → 用户完成”
区别只在于:叫号顺序的决定逻辑不一样。
于是我们就可以提炼出一个统一设计思路:
用“策略模式”统一不同叫号逻辑
我们可以把不同的叫号规则抽象成一个策略接口(策略模式),不同的门店、不同的业务线,可以配置不同的叫号策略。
策略接口(伪代码):
type QueueStrategy interface {
Join(userID string) error
GetMyPosition(userID string) (int, error)
CallNext() (string, error)
}
实现策略:
| 场景类型 | 策略实现 | 特点 |
|---|---|---|
| 顺序叫号 | FifoQueueStrategy | ZSet 排队,按顺序叫号 |
| 非顺序叫号 | ReadyDrivenQueueStrategy | ZSet 排队 + ReadySet 控制 |
系统架构示例
+----------------------+ +----------------------+ +----------------------+
| 小程序端(用户) | <---> | 排队服务(接口层) | <---> | 策略实现(顺序/非顺序)|
+----------------------+ +----------------------+ +----------------------+
|
|
+------------------+------------------+
| |
+-----------------------------+ +-----------------------------+
| Redis: queue (ZSet) | | Redis: ready_set (Set) |
+-----------------------------+ +-----------------------------+
后台可配置策略
我们的后台系统可以支持门店维度的配置:
{
"store_id": "store_123",
"queue_strategy": "ready_driven" // 或 "fifo"
}
服务端通过读取配置,选择对应的策略实现,系统就具备了多门店、多逻辑场景的兼容能力。
总结对比:两种排队策略适用场景
| 特性 | 顺序叫号(火锅) | 非顺序叫号(奶茶) |
|---|---|---|
| 排队顺序 | 严格按照先来先服务 | 仅用于参考 |
| 叫号机制 | 队首即叫 | 完成状态 + 顺序结合 |
| 查询排位 | ZSet + ZRANK | ZSet + 状态判断 |
| 是否支持跳号 | 不支持 | 支持 |
| 使用数据结构 | Redis ZSet | Redis ZSet + Set + 状态 |
| 技术实现策略名示例 | FifoQueueStrategy | ReadyDrivenQueueStrategy |
将排队叫号逻辑抽象成策略,不仅能应对火锅店和奶茶店这类常见场景,还能为未来的多门店、多业态排队系统提供更强的灵活性和可维护性。
写在最后
现在找工作,面试好像都不怎么考八股文了。什么“Java 有几种引用”、“MySQL 三大范式”之类的问题,大家早都背得滚瓜烂熟,反倒越来越少有人问。
取而代之的,是一堆“场景题”。
比如上面说的:“怎么设计一个排队叫号系统?”
一听这题,有些人会觉得:“这种东西真的用得上吗?你们公司连用户都排不满还排队?”
但我们会发现,这类场景题不光是在考会不会用 Redis、能不能写个接口,更是在考我们是否具备抽象问题和构建系统的能力。
需要得理解顺序一致性是怎么来的,
需要要知道并发下如何避免重复叫号,
还得能用状态建模去控制流程走向,
最后最好再来个策略模式解耦逻辑,方便未来多业务复用。
是的,这听起来很系统设计味儿。
所以说,别小看这些看似“有点无聊”的题。真正能脱颖而出的,往往不是会答八股,而是能把“叫个号”这点小事,讲得又清晰又合理又能落地。
希望我们下次在面试场景题里,也能从容不迫地问一句:
“这个叫号系统,是奶茶店那种,还是火锅店那种?”
最后,希望我好基友面试顺利,永远不用排队等 Offer 😄
更多架构实战、工程化经验和踩坑复盘,我会在公众号 「洛卡卡了」 持续更新。
如果内容对你有帮助,欢迎关注我,我们一起每天学一点,一起进步。