ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

SpringBoot餐厅等位叫号系统:Redis队列与WebSocket实时推送实战

SpringBoot餐厅等位叫号系统:Redis队列与WebSocket实时推送实战 如果你正在找Java后端的练手项目或者准备毕业设计SpringBoot的餐厅等位叫号系统大概率已经在你的候选清单里了。这个项目看着不起眼但里面塞满了面试高频考点——队列、并发控制、状态机、实时推送随便拎一个出来都能聊上十分钟。更关键的是它完整覆盖了一个真实业务从需求到落地的全过程比单纯刷八股文有意思得多。这篇文章我会从业务拆解、数据库设计、核心队列逻辑、实操搭建到踩坑排查把整个系统的设计和实现完整过一遍。适合正在准备毕设的在校学生、想丰富项目经验的Java初学者以及打算把这类场景搬到实际门店里的开发人员。不管你是哪一类照着这篇文章的思路走一遍至少能少踩一半的坑。1. 等位叫号系统到底在解决什么问题1.1 业务场景与三个核心痛点餐厅等位这件事看着简单实际运营起来全是细节。周末晚上五点半开始排队七点半还在翻台前台服务员一边接电话一边手写号牌顾客每隔十分钟就来问一次“还要等多久”后厨和传菜口又在催桌位。这种场景下人工叫号的问题不是“不智能”而是直接撑不住。往深了拆餐厅等位叫号系统要解决的核心痛点其实就三个。第一是取号与排队的有序性。纸质号牌最大的问题在于冲突和冒领同一桌客人和隔壁桌客人拿重号或者客人走了号还留在队列里导致叫号叫了个寂寞。系统必须保证号码的唯一性和顺序性同时能随时知道当前队列到底有多少人、前面还有几桌。第二是叫号触达的实时性。传统做法是服务员拿喇叭喊或者在大屏幕上滚字幕。问题是顾客可能在大厅外抽烟、去洗手间、或者在门口停车根本听不到喊号。系统需要把“叫号”这个动作实时推送到顾客端同时在大屏上同步展示最好是能通过语音播报把注意力拉回来。第三是桌位流转与过号处理的规则化。餐厅不是只有一个总队列而是按桌型二人桌、四人桌、六人桌、包厢分了多条队列每种桌型的排队进度不一样。顾客被叫号后多久算过号过号之后是顺延还是沉底这些规则如果靠服务员口头协调高峰期必然乱套系统必须用明确的状态机把规则固定下来。1.2 功能模块划分与用户角色这个系统的角色并不复杂三个端口对应三类使用场景。顾客端一般是H5或小程序取号、查看实时排队进度、接收叫号通知、过号后申请重新排队。这一端不需要做重核心就是把“我的号排到哪了”这件事讲清楚。服务员端/管理后台叫号操作按桌型叫下一桌、手动调整队列客人过号或取消、桌台状态管理空桌、已入座、已打扫、查看排队统计当前等位数、平均等位时长、翻台率。这里涉及权限控制一般分为前台操作员和店长两类角色。叫号大屏端挂在门店显眼位置展示各桌型的当前叫号号码、正在等待的号段、新取号的滚动提示。大屏端不需要交互数据单向推送即可。这三个端口加起来功能清单不算多但每一块都踩在与业务强相关的细节上。比如大屏端要显示“当前叫到A023号前面还有5桌”前端就得知道当前桌型的队列头部和长度又比如顾客在手机上看到的“预计等待时间”背后需要一套估算逻辑不是简单的排位序号乘以固定值。1.3 技术选型背后的取舍技术栈是SpringBoot Java这没什么好说的Java生态在这个场景下是最稳的选择。但往里细看几个关键选型值得掰扯一下。持久层用MyBatis-Plus而不是 JPA原因很简单这个系统查询模式固定排队列表、桌台状态、历史记录MyBatis-Plus 的分页插件和条件构造器能省掉大量样板代码而且对后期写复杂SQL更友好。JPA 在这种偏工具型的管理系统里反而显得笨重。缓存和队列用Redis这是整个系统的核心决策。排队队列本质上就是一个有序集合Redis 的 ZSet有序集合结构天生就适合干这个。score 存取号时间戳member 存排队单号按 score 排序就是排队顺序ZRANGE 取区间就是分页查询ZREM 移除就是叫号过号。这套操作在 MySQL 里要用事务和行锁才能实现在 Redis 里是一行命令的事。实时推送用WebSocket而不是轮询是为了让叫号消息的延迟控制在秒级以内。轮询的替代方案是前端每3秒请求一次接口高峰期几十个前端同时轮询对后端和数据库都是无谓的压力。WebSocket 是长连接服务端主动推送延迟低连接数也受控。2. 数据库设计与号牌规则2.1 核心表结构设计先看数据库设计这是整个系统能不能跑顺的基础。第一张核心表是排队单表queue_order。字段包括主键id、排队单号如A023、桌型编号table_type、手机号顾客联系方式、排队状态0等待中、1已叫号、2已入座、3已过号、4已取消、取号时间、叫号时间、入座时间、过号时间、预计桌号。这里要注意排队单号和数据库主键不是同一个概念——主键是自增id排队单号是对外展示的号码必须独立设计。第二张核心表是桌台表dining_table。字段包括主键id、桌台编号T01、T02、桌型编码TWO_PERSON、FOUR_PERSON等、状态0空闲、1已入座、2已打扫、当前排队单号关联字段标记这桌现在坐的是哪个号。这张表就是门店的“物理资产”清单所有入座和翻台操作都要落在这张表上。第三张是排队流转日志表queue_log。这张表很多人会忽略但实际运营中特别重要。它记录每个号码在每一个时间点的状态变化什么时间取的号、什么时间被叫号、什么时候开始等待、预计桌号是多少。高峰期出现纠纷时这张日志表就是唯一的仲裁依据。之外还需要一张桌型配置表table_type_config配置每种桌型对应的数量、可容纳人数、排队前缀字母。很多人会把桌型写死在代码里这是大忌——不同门店的桌型配置不一样后期调整桌面布局也要改代码代价太大。2.2 号牌生成规则与并发控制号牌规则看起来简单做起来有不少细节。我的设计是桌型前缀字母 四位顺序号比如二人桌叫A级号牌就是A0001到A9999四人桌则是B0001到B9999。为什么号段要封顶四位因为门店高峰期一天的单桌型排队量四位足够用了一旦超过9999就该考虑是不是系统出问题了。号牌生成最需要注意的是并发重复问题。新手最容易踩的坑是先从数据库查当前最大号再在代码里加一然后插入数据库。这个逻辑在并发量上来之后两个请求同时查到同一个最大号就产生了重复号牌而且很难复现排查。我的做法是用Redis 的 INCR 命令来生成顺序号。每个桌型的KEY比如queue:seq:A里存当前已发号的最大值每次取号执行 INCRRedis 是单线程模型INCR 原子性有保证绝不可能拿到重复值。每日零点把KEY重置为0或者直接用“当天日期序列号”生成号牌如 20240518A001连重置都不用考虑。为了简单我推荐直接用日期加序列的方式日志排查也方便。2.3 排队状态机设计等位叫号系统的业务流转本质上是一套严格的状态机。状态定义如下状态含义触达方式WAITING已取号正在等位顾客端显示排队中CALLED已叫号等待顾客确认大屏/语音播报SEATED顾客已入座服务员确认入座OVERTIME已过号顾客未在规定时间到达CANCELED已取消顾客自行取消/服务员操作状态流转方向是固定的WAITING 只能流向 CALLED 或 CANCELEDCALLED 只能流向 SEATED 或 OVERTIMEOVERTIME 可以转回 WAITING重新排队需插回队列尾部也可以保持过号状态。这套状态机必须在代码的 Service 层强制约束不能在 Controller 层直接改状态字段。我在实现时专门做了一层状态流转校验非法流转直接抛异常。3. 等位队列核心逻辑的实现3.1 为什么用 Redis ZSet 维护排队队列排队队列的实现是整个系统的核心中的核心。我在设计初期考虑过三条路数据库表排序、内存队列、Redis ZSet。最终选了 ZSet理由非常明确。数据库表排序的问题在于队列是动态的每分钟都有入队新取号、出队叫号入座、插队过号重新排队每条操作都要更新数据库而且需要行锁保证顺序一致。排队高峰期正好和数据库写入高峰期重叠数据库扛不住这个量级的频繁更新。内存队列的问题更致命——服务重启队列就丢了。你以为排队数据已经持久化到数据库如果在内存里维护队列、在数据库里存状态那么两边的数据一致性需要额外代码维护一旦服务器重启内存里的队列全部丢失顾客的排队进度全部错乱这种事故在现实中是不可接受的。Redis ZSet 的解法是队列的排序结构放在 Redis 里状态的持久化落在数据库里。ZSet 天然支持按 score 排序、支持范围查询、支持随机删除最关键的是 Redis 可以开启持久化RDB/AOF重启后队列还在或者可以通过数据库记录重建队列。具体结构是这样的每个桌型一条 ZSetKEY 为queue:{tableType}member 为排队单号score 为取号时间戳毫秒级。Redis ZSet 有一个特性member 相同则后插入的会覆盖 score所以用时间戳作为 score 时同一个 member 不会被重复插入。取号时 ZADD叫号时 ZRANGE(0,0) 取队列头部然后用 ZREM 移除。整个流程是 O(logN) 级别的操作高峰期几百个号毫无压力。3.2 叫号流程与 WebSocket 实时推送叫号是整个系统的关键动作我把它拆成四步从 Redis 队列取队头、更新数据库状态、推送大堂屏、推送顾客端。从 Redis 中取队头用 ZRANGE 命令取 score 最小的一个即最早取号的顾客。接下来要判断这个人是否仍然有效——比如在 WAITING 状态被手动取消了Redis 队列里可能还残留着这个 member。处理办法是取到队头后先查数据库确认状态如果发现已经 CANCELED就 ZREM 掉继续取下一个。更新数据库是第二步把这条排队记录的状态从 WAITING 改为 CALLED记录叫号时间。这里有个细节这一步要放在 Redis 操作之后还是之前我的经验是先更新数据库再更新 Redis。原因在于数据库是持久化事实来源如果先改 Redis 再改数据库时崩溃会出现 Redis 队列中号码消失但数据库还是 WAITING 状态的情况。反过来数据库改成 CALLED 后 Redis 操作失败由于状态机约束下次叫号时不会重复取到这个号队列顶多留一条脏数据可以靠定时任务清理。推送环节是顾客体验的关键。服务端实现一个 WebSocket 管理器维护当前所有在线连接。服务员点击“叫下一桌”后后端向指定桌型的频道广播消息包括被叫到的号码、当前排队总人数、最新队列快照。3.3 并发场景下的几个坑这个系统生产环境踩坑的重灾区就是并发。分享三个我实际踩过的第一个是重复叫号。服务员手快点了两下叫号按钮两个并发请求同时执行了取队头逻辑结果一个号被叫了两次。解决方式有两种一是在叫号接口加 Redis 分布式锁用 SETNX 命令确保同一桌型同一秒只有一个叫号请求在跑二是在数据库更新时加乐观锁用一个 version 字段更新时校验版本号。我实际用下来Redis 锁更简单直接。第二个是桌台超卖。两张空桌同时被两个叫号请求分配导致一个桌台对应两个顾客。解决办法是在入座操作时锁定桌台记录用SELECT ... FOR UPDATE或者 Redis 锁保证一个桌台同一时间只能被分配一次。第三个是过号重排的时序问题。顾客过号后重新排队按规定应该插到当前队列末尾。但如果在“查找队列末尾”和“插入 ZSet”之间恰好有新的取号请求插入新号就会排在这个顾客后面造成顺序错乱。解决方法是计算 score 时取 Redis ZSet 当前最大 score 再加1毫秒作为新的 score保证一定排在最后。4. 实操搭建从零跑通全流程4.1 项目初始化与环境依赖初始化项目我推荐直接用 Spring Initializrstart.spring.io版本选择 SpringBoot 2.7.xJDK 用 8 或 11 都行。为什么不推荐3.x因为3.x 要求 JDK17部分学校机房和老服务器可能没环境而且 2.7 已经足够稳没必要为了升级而升级。pom.xml 里需要引入的核心依赖如下dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-websocket/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId /dependencyapplication.yml 里的关键配置如下spring: datasource: url: jdbc:mysql://localhost:3306/queue_system?useUnicodetruecharacterEncodingutf8useSSLfalse username: root password: your_password redis: host: localhost port: 6379 password: database: 0 mybatis-plus: configuration: map-underscore-to-camel-case: true global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0这里有两个容易被忽略的点。一是 MySQL 连接串里的characterEncodingutf8一定不能省否则号牌前缀字母或者顾客昵称里带个生僻字入库就乱码。二是 MyBatis-Plus 的逻辑删除配置建议加上排队记录不能物理删除后面要算统计报表和翻台率。4.2 核心接口与关键代码实现项目结构我按业务模块分包controller、service、mapper、entity、config、websocket。不要用传统的三层包名混在一起一个模块一个包后面扩展和维护都省事。取号接口是这个系统最核心的入口。实现逻辑分三步生成号牌、插入数据库、加入 Redis 队列。PostMapping(/queue/take) public ResultString takeQueue(RequestBody TakeQueueRequest request) { // 1. 生成号牌日期 桌型前缀 4位序列号 String seq stringRedisTemplate.opsForValue() .increment(queue:seq: request.getTableType()) ; String queueNo DateUtil.format(new Date(), yyyyMMdd) request.getTableType().getPrefix() String.format(%04d, Integer.parseInt(seq)); // 2. 插入数据库 QueueOrder order new QueueOrder(); order.setQueueNo(queueNo); order.setTableType(request.getTableType().name()); order.setPhone(request.getPhone()); order.setStatus(QueueStatus.WAITING); queueOrderMapper.insert(order); // 3. 加入Redis ZSetscore为当前时间戳 stringRedisTemplate.opsForZSet().add( queue: request.getTableType().name(), queueNo, System.currentTimeMillis() ); return Result.success(queueNo); }这段代码看起来简单但有几处容易踩坑。INCR返回的是 Long 类型不能直接拼字符串要先转 String 再格式化。String.format(%04d, ...)保证了号牌是四位数不够四位前面补零避免 A1、A2 和 A10 排序混乱。取号时间戳作为 score能保证 ZSet 内按时间排序。叫号接口的实现更考验细节。要处理“队列为空”“取到的号已取消”“桌台分配失败”三种边界情况。PostMapping(/queue/call) public ResultCallResult callNext(RequestBody CallRequest request) { String queueKey queue: request.getTableType().name(); // 加锁防止重复叫号 String lockKey lock:call: request.getTableType().name(); Boolean lock stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, 1, Duration.ofSeconds(3)); if (!Boolean.TRUE.equals(lock)) { return Result.error(请勿重复操作); } try { // 1. 取队头 SetString members stringRedisTemplate.opsForZSet() .range(queueKey, 0, 0); if (members null || members.isEmpty()) { return Result.error(当前没有排队顾客); } String queueNo members.iterator().next(); // 2. 校验数据库状态 QueueOrder order queueOrderMapper.selectOne( new LambdaQueryWrapperQueueOrder() .eq(QueueOrder::getQueueNo, queueNo) ); if (order null || order.getStatus() ! QueueStatus.WAITING) { // 脏数据移除并继续取下一个 stringRedisTemplate.opsForZSet().remove(queueKey, queueNo); return callNext(request); } // 3. 更新数据库状态 order.setStatus(QueueStatus.CALLED); order.setCalledTime(new Date()); queueOrderMapper.updateById(order); // 4. 从Redis移除 stringRedisTemplate.opsForZSet().remove(queueKey, queueNo); // 5. 推送WebSocket消息 websocketService.broadcast(request.getTableType().name(), buildCallMessage(queueNo)); return Result.success(CallResult.builder() .queueNo(queueNo).build()); } finally { stringRedisTemplate.delete(lockKey); } }这里的递归调用callNext(request)有个隐患如果 Redis 队列里连续几十条脏数据递归深度可能过深最稳妥的写法是用 while 循环包起来。递归写法在数据量正常时没问题但高峰期队列里有几十条已取消的号时就可能栈溢出。实际项目中我用 while 循环重构了一遍这个心得建议照着改。入座与过号的实现相对简单。入座操作更新 QueueOrder 状态为 SEATED、分配桌台为已占用、更新 DiningTable 关联号牌。过号操作将状态改为 OVERTIME如果有“过号重排”的业务规则就重新计算 score 插入 ZSet 末尾score 取当前最大 score 加 1 毫秒。4.3 前端与叫号大屏的落地方式很多做毕设或项目练习的人都在前端技术上纠结。我的建议是这个项目的重点在后端前端用 Bootstrap Vue 3 CDN 的方式就够不需要单独搭前端工程。顾客扫码取号页面一个朴实无华的 HTML 页面选桌型、填手机号点击取号展示号牌和当前排队人数。这个页面可以通过 WebSocket 接收状态变更通知。叫号管理后台用 Vue 3 Element Plus 或者直接用 Thymeleaf 渲染一个表格页面列出当前所有排队中的顾客操作列提供“叫号”“取消”按钮。这个页面给服务员用交互要极简不要有学习成本。大屏展示页一个大号的 HTML 页面显示当前各桌型的叫号进度通过 WebSocket 接收广播消息后刷新页面上的号码配合 CSS 动画做号码滚动效果。如果有语音播报需求用 Web Speech API 的speechSynthesis接口就能实现不需要额外购买语音模块。5. 常见问题与排查经验5.1 高频问题速查表问题现象可能原因排查方法取号后排队列表看不到Redis ZSet 添加失败或 key 不一致检查 Redis key 拼写和登录密码叫号后顾客端收不到推送WebSocket 未连接或订阅了错误的频道检查前端连接地址和频道名号牌重复INCR 的 key 被删除或过期检查 Redis 过期策略排队顺序混乱score 精度不够改用毫秒时间戳避免用秒大屏页面不自动更新浏览器限制音频自动播放在用户交互后初始化语音过号后重新排队排到最前score 设置错误取当前 max score 再 1高峰期接口响应慢数据库连接池太小调整 HikariCP 配置5.2 典型BUG复盘一次诡异的队列错乱我记得很清楚测试阶段遇到过一个问题某个桌型的队列叫号叫到一半突然跳号跳过了中间几个号码。查了很久最后发现是 Redis 的 ZSet 里member 重名导致的覆盖问题。听我细说。Redis ZSet 的规则是member 相同时新的 score 会覆盖旧的 score。如果同一个号牌member被插入了两次而两次的 score时间戳不同那么 ZSet 里只会保留最后一次插入的 score。这在取号接口被重复调用时特别容易发生——比如某个请求超时重试同一个号牌被插了两次第二次的 score 比第一次大这个号码就不知不觉排到了队列更靠后的位置。解决方案是在取号逻辑里加一个防重判断插入 ZSet 之前先查数据库确认这个号是否已经存在如果存在就不再插入。同时在 Redis 侧对取号接口也加一个分布式锁防止同一个手机号在短时间内重复取号。5.3 扩展与进阶方向这个系统的地基打好了扩展方向非常多。等待时长预测目前队列只显示“前面还有几桌”如果加上“预计等待X分钟”顾客体验会提升一个档次。实现思路是统计每桌的平均就餐时长再乘以前面等待桌数就是个粗粒度的估算值。想要更精准可以按桌型、按时间段晚市比午市平均时长多15分钟、按星期周五周六比工作日周转慢分别建模。取号机对接如果门店想做自助取号机系统需要预留取号机接口实际上就是一个带扫码功能的客户端调用同一个取号接口逻辑完全复用。多门店架构单店场景下把所有数据放一张库里没问题多门店时就要做 tenant 隔离。最简单的方式是每张业务表加 store_id 字段查询时按租户条件过滤。我在实际迭代中最推荐的还是加一个简单的数据看板——把当天的取号量、叫号量、过号率、平均等位时长统计成图表。一方面门店管理确实需要另一方面写到简历和毕设说明书里项目的完整度和深度瞬间高一个档次。这个看板直接用 ECharts 就行后端提供一个聚合统计接口数据从 QueueOrder 表里按时间分组查询。回到最初的问题为什么说个子不算大的等位叫号系统值得认真做一个我的体会是它把很多知识点串成了一条完整的业务线——取号要解决并发和原子性排队要设计数据结构和状态机叫号要处理实时推送和网络可靠性统计又离不开数据聚合。每一个环节都不是孤立的技术炫技而是被真实业务需求推着走的。这种“被需求逼着思考”的过程才是做项目最有价值的部分。如果你正在选毕设课题或者找项目练手把这套系统吃透至少在面试聊项目时你能把边界情况、数据一致性和实时通信这几个话题讲出真正做过的人才会有的细节。
返回列表