排个队而已,怎么还扯上了系统设计?

810 阅读15分钟

前言

前段时间,我的好基友决定裸辞,奔赴他所谓的“新生活”。

本以为他会 gap 一段时间、好好休息,结果不到一周就开始海投简历了。
身为牛马,我很抱歉。还是建议没有存款的情况下,真的别轻易裸辞。能苟着,就先苟着。 现在的大环境,一言难尽,他面了一个月才勉强拿到两个面试机会。

昨天他终于迎来了其中一个面试,是一家做餐饮的小中型公司。技术面聊得还行,接着就到了业务场景题的环节。

面试官很直接,甩过来一个听起来简单、但其实非常能卡人的问题:

“我们小程序支持用户下单排队,商家那边会按号出餐。如果是你来负责该怎么设计这个排队叫号系统?”

可能是太久没面试了,也可能是真的没准备好,我这位基友也没多想,第一反应就是:

“那就 Redis List 搞一搞呗。”

听起来似乎也没毛病,用 RPUSH 入队、LPOP 出队,顺序管理非常简单。但面试官显然并不满意这样的回答,紧接着抛出几个实际场景问题:

  • “那如果有一杯奶茶先做好了,你能先叫它吗?”
  • “如果用户想查看自己前面还有几个人,该怎么查?”
  • “非顺序叫号你这个支持吗?”

这几个问题抛出来后,我朋友也没仔细去想,就随口回答了几句,最后也只是礼貌地等通知了。。。


排队叫号的两种经典场景

我们在生活中遇到的排队叫号系统,其实大致可以分成两种典型场景:

  • 一种是严格顺序叫号:比如火锅店,顾客到店后按顺序排队。每叫走一桌,下一个自然就上,中途不可跳号,只能等前面的都完成;
  • 一种是非顺序叫号:比如奶茶店、炸鸡店,虽然下单顺序有编号,但备餐进度不同。有的简单饮品先做好,就可以先叫,叫号顺序并不等于下单顺序

这两种场景虽然表面上都叫“排队叫号”,但系统设计上完全不是一个路数。

那我们结合这两类场景,逐步来看看:这个排队叫号系统到底该怎么设计?

顺序叫号场景设计:从 Redis List 到 ZSet 的演进

场景复现:银行排队系统

举个例子,我们在建行小程序里预约办理业务,点击“取号”,就会跳出如下页面:

image.png

  • 我们就拿到了一个号码,比如 A014
  • 提示我们前面还有 4 人
  • 显示我们的业务类型、办理网点、排队时间
  • 同时提供“查看我的排号”与“在线填单”入口

这是一个典型的顺序叫号系统:按照我们取号的顺序排队、叫号,不可跳过,必须一人办完或者无人应答才能叫下一位。

初始方案设计:后端使用 Redis List

在最初版本的设计中,这类排队需求我们通常可以使用 Redis 的 List 结构实现,因为它天然是个先进先出队列(FIFO)。

用户取号流程:
  1. 小程序点击“取号” → 调用接口
  2. 后端用 Redis INCR 生成一个编号,例如 A014
  3. 将编号加入队列:
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 是因为刚加入进去
}

  1. 返回用户当前号码和排队信息(例如前方还有多少人)
商家叫号流程:
  1. 调用 LPOP store:{id}:queue,获取队头(下一个要叫的号)
  2. 将叫号信息推送到前台设备 / 小程序 / 短信
  3. 标记叫号已处理
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
}

执行逻辑说明

  1. LPOP 拿出当前号(处理完成)
  2. 可将该记录落库(如写入 MySQL 排队日志表)
  3. LINDEX 查看下一个号(不出队)
  4. 可推送通知给下一个用户(通过短信、小程序订阅消息、WebSocket 等)

在实际业务中,一般会将“叫号 → 状态记录 → 通知”这套逻辑封装为事务性处理,避免中间部分失败后状态错乱。

用 Redis ZSet 实现更强排队能力

我们在上面用几个简单的 Go 伪代码,结合 Redis 的 List 结构,实现了一个最基础的“顺序叫号系统”——用户取号、排队、叫号、查看排位都可以跑起来。

虽然功能能跑,但实际业务中,这种架构存在非常明显的缺陷:

List 架构的问题

  1. 排位查询效率低
    Redis List 不支持排名查询,只能 LRANGE 整个列表后手动遍历,性能差、并发下易出问题。
  2. 不支持分页展示
    想在前端展示“前10人排队列表”?List 也不支持,必须手动截取处理。
  3. 中间删除麻烦
    用户想取消排队?商家想跳过某个人?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 ListRedis ZSet
顺序入队支持支持
查询我排第几位不支持(需遍历)ZRANK 直接查
分页展示队列列表不支持ZRANGE 支持分页
中间删除(取消排队、跳过等)非常麻烦ZREM 可直接删除任意成员
性能稳定,支持高并发操作一般,队列越长越卡高效,支持上千人实时查询
支持更灵活的业务场景(如权重)不支持支持多维排序 / 优先级扩展

如果我们的排队系统只需要“顺序进、顺序出”,Redis List 勉强够用;
但只要业务稍微复杂,比如用户想知道自己排第几、展示分页列表、支持取消排队 —— 那么 ZSet 就是更合适的选择。


非顺序叫号场景设计:状态驱动的灵活叫号机制

前面我们讲的是严格顺序的排队场景,比如火锅店、政务大厅等,讲究一个“排队有序,先来先上”。

但现实生活中,很多时候排队压根不是按顺序来的。

比如我们去肯德基、或是线下奶茶店点单,虽然我们是第 5 个下单的,但可能我们点的是个复杂的手打三重芝士玛奇朵,而第 6 个只要了一杯美式,结果美式先做好了,就先叫号了。

这类场景的本质是:

叫号顺序 ≠ 下单顺序,而是“谁先准备好谁先叫”

也就是说,它是一个非顺序叫号场景

那问题来了:

像这种逻辑,我们还继续用 Redis ZSet 来 hold 吗?
又该怎么管理“叫号”和“准备完成”之间的状态变化?

接下来我们就来设计一下:在“状态驱动叫号”的场景下,该怎么设计排队系统?

状态驱动型叫号系统

我们前面说了,顺序叫号的本质是:先进先服务,排队顺序就是叫号顺序

但像奶茶店、炸鸡店、肯德基这类“备餐型业务”,实际运作逻辑往往不是这样。
在这些场景里:

  • 同样是排队,叫号却并不按顺序来
  • 谁的单子先准备好,谁就先被叫号取餐
  • 排队号只是用于标识用户顺序,但不代表服务先后顺序

这就是典型的:状态驱动型叫号系统

业务流程变化

我们把它和顺序模式做个对比:

项目顺序叫号场景(火锅)非顺序叫号场景(奶茶)
排队顺序严格 FIFOFIFO 只作为参考
叫号依据排队顺序商家标记“完成”的状态
可否跳过/打乱顺序不可跳过完全允许
用户体验重点我排第几位,预计还有多久订单准备好了吗,能不能早点叫

技术设计方案:ZSet + Ready Set 双结构模型

ZSet 仍然可以保留用于存储“用户排队顺序”,但是真正的叫号行为,依赖于“已准备完成”的状态标记,而不是 ZSet 的顺序。

我们引入一个新的结构:Ready Set

Redis 结构设计:

Key说明
store:{id}:queueZSet:排队顺序队列(按 score 排序)
store:{id}:ready_setSet:已准备完成、可叫号的编号集合
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)
}

实现策略:

场景类型策略实现特点
顺序叫号FifoQueueStrategyZSet 排队,按顺序叫号
非顺序叫号ReadyDrivenQueueStrategyZSet 排队 + ReadySet 控制

系统架构示例

+----------------------+       +----------------------+       +----------------------+
|   小程序端(用户)   | <---> |   排队服务(接口层)  | <---> |   策略实现(顺序/非顺序)|
+----------------------+       +----------------------+       +----------------------+
                                               |
                                               |
                            +------------------+------------------+
                            |                                     |
            +-----------------------------+       +-----------------------------+
            |   Redis: queue (ZSet)       |       |   Redis: ready_set (Set)    |
            +-----------------------------+       +-----------------------------+

后台可配置策略

我们的后台系统可以支持门店维度的配置:

{
  "store_id": "store_123",
  "queue_strategy": "ready_driven" // 或 "fifo"
}

服务端通过读取配置,选择对应的策略实现,系统就具备了多门店、多逻辑场景的兼容能力。


总结对比:两种排队策略适用场景

特性顺序叫号(火锅)非顺序叫号(奶茶)
排队顺序严格按照先来先服务仅用于参考
叫号机制队首即叫完成状态 + 顺序结合
查询排位ZSet + ZRANKZSet + 状态判断
是否支持跳号不支持支持
使用数据结构Redis ZSetRedis ZSet + Set + 状态
技术实现策略名示例FifoQueueStrategyReadyDrivenQueueStrategy

将排队叫号逻辑抽象成策略,不仅能应对火锅店和奶茶店这类常见场景,还能为未来的多门店、多业态排队系统提供更强的灵活性和可维护性。

写在最后

现在找工作,面试好像都不怎么考八股文了。什么“Java 有几种引用”、“MySQL 三大范式”之类的问题,大家早都背得滚瓜烂熟,反倒越来越少有人问。

取而代之的,是一堆“场景题”。

比如上面说的:“怎么设计一个排队叫号系统?”

一听这题,有些人会觉得:“这种东西真的用得上吗?你们公司连用户都排不满还排队?”

但我们会发现,这类场景题不光是在考会不会用 Redis、能不能写个接口,更是在考我们是否具备抽象问题和构建系统的能力

需要得理解顺序一致性是怎么来的,
需要要知道并发下如何避免重复叫号,
还得能用状态建模去控制流程走向,
最后最好再来个策略模式解耦逻辑,方便未来多业务复用。

是的,这听起来很系统设计味儿。

所以说,别小看这些看似“有点无聊”的题。真正能脱颖而出的,往往不是会答八股,而是能把“叫个号”这点小事,讲得又清晰又合理又能落地。

希望我们下次在面试场景题里,也能从容不迫地问一句:

“这个叫号系统,是奶茶店那种,还是火锅店那种?”

最后,希望我好基友面试顺利,永远不用排队等 Offer 😄

更多架构实战、工程化经验和踩坑复盘,我会在公众号 「洛卡卡了」 持续更新。
如果内容对你有帮助,欢迎关注我,我们一起每天学一点,一起进步。