拓冰建站拓冰建站
首页 / 资讯中心 / 正文

SpringBoot游乐园预约排队系统:Redis并发控制与WebSocket实时推送实战

做毕设选管理系统没毛病但选一个“听起来没技术含量做起来全是坑”的题目才是真正考验人的地方。游乐园运营管理平台这类题表面上是普通CRUD实际上把预约、排队、叫号、并发控制、消息推送全串起来了。我见过太多人拿到这个题目后直接开一个SpringBoot项目建几张表写增删改查最后答辩被问一句“如果同一秒有三百人预约同一个过山车时段你怎么保证不超卖”就愣住了。这篇文章我想把自己做完这个“基于SpringBoot的游玩预约与排队叫号一体化系统”的思路完整写出来包括技术选型、核心模块设计、Redis与WebSocket的落地细节、以及答辩时被追问的Spring原理。如果你正在做Java毕设或者想在简历上写一个“高并发业务场景”的项目这个拆解应该能帮你省不少事。1. 题目拆解预约与排队为什么必须“一体化”而不是两个模块1.1 系统到底要管哪些人和哪些事先别急着写代码把业务方画出来。游乐园管理系统常规涉及三类角色游客、运营人员管理员、系统本身。游客要能注册登录、浏览游乐项目、查看某个项目的今日场次和剩余名额、提交预约、到现场取号排队、实时看到自己前面还有多少人管理员要能维护游乐项目、设置每天开放哪些时段、每个时段放多少个名额、操作叫号、查看实时排队人数、统计每日入园数据。这些需求初看都不难但难点在于“游客侧”和“管理员侧”是相互咬合的。游客预约了一个时段不代表他就排在队列头部游客到了现场取了号又要跟预约时段关联起来。如果按普通管理系统那样分成“预约管理模块”和“排队叫号模块”两套独立功能预约表跟队列记录表各管各的就会出现一个典型的逻辑漏洞预约成功的人来现场取号时系统发现该时段已经排了别的人无法确认取号优先级。所以“一体化”不是标题噱头而是业务强约束。我在设计时把预约和排队放在同一条核心流程链上选择项目时段 → 扣减容量 → 生成预约单 → 入园核销 → 领取排队号 → 进入该时段队列 → 叫号消费。1.2 核心流程里的“状态机”思维任何一个订单都会经历状态流转预约单也一样。这个系统里预约单至少有这些状态已预约、已入园、已取号、已完成、已取消、已过期。取完号的游客再有独立的排队状态排队中、正在游玩、已过号、已结束。我建议把状态明确枚举出来并写在代码里而不是用乱七八糟的字符串散落在业务逻辑中。用枚举加状态机校验的好处是你很难写出“预约状态还能更新成已结束”这种逻辑漏洞。答辩时面试官如果问到“订单状态如何管理”你直接抛出状态机设计就已经超过了大多数只写CRUD的毕设。1.3 非功能性需求为什么不能只做单机版毕设演示时你可以只用一台电脑跑但题目既然叫“智能游乐园运营管理平台”并发就是绕不开的点。五一假期一个热门过山车项目一个时段内可能同时涌入几百个预约请求。如果预约扣减写成“先select容量再update”在秒杀场景下就是典型的超卖故障。所以我在设计时将系统拆成两个层次MySQL负责持久化与事务Redis负责预约扣减和排队队列。这也是这个项目区别于普通管理系统的关键技术点让数据一致性从数据库层提升到分布式协调层。2. 技术栈定档SpringBoot、Redis、MySQL、Vue是怎么拍板的2.1 为什么是SpringBoot而不是裸的SpringMVC这个标题已经帮你把框架定死了但面试官大概率会问为什么SpringBoot适合做这类系统我的理解是项目里要同时整合MyBatis-Plus、Redis、WebSocket、定时任务、参数校验、统一异常处理如果全部手动配置至少要多写几十个配置类和xml文件。SpringBoot的自动配置解决的核心问题是“让常用组件开箱即用”比如内嵌Tomcat打包后一个jar就能跑这对毕业设计部署和答辩演示都极其友好。另外要有意识地区分Spring和SpringBoot。Spring是框架核心IOC与AOP都在这一层SpringBoot是它的快速开发脚手架。这层区分一定要在文章和答辩中讲清楚否则会出现“Spring三级缓存是SpringBoot的”这种硬伤。2.2 Redis在项目里的三个角色缓存、分布式锁、队列为什么说这个题目是SpringBoot实战好题因为它不是单表CRUD它需要你用Redis做三件不同类型的事情。第一预约时段剩余数量需要缓存。每次查询都走MySQL会让数据库压力很大尤其是热门项目页面。Redis String类型存“时段ID:剩余容量”用的时候读取扣减的时候更新。第二扣减动作必须原子。多个请求同时扣减会竞争后面专门讲Lua脚本。第三排队叫号需要一个队列。Redis List天然支持左进右出我用LPUSH取号入队RPOP或LRANGE模拟叫号比用数据库表轮询要高效得多。如果你愿意还能用Redis做分布式会话或接口幂等但这些对毕设来说有点超纲我建议把核心亮点放在扣减原子性和排队队列上。2.3 ORM选型MyBatis-Plus压过JPA的几点理由同级别的毕设项目里MyBatis-Plus的出镜率远高于Spring Data JPA原因很现实一是国内面试和招聘普遍更认MyBatis体系二是MyBatis-Plus提供了内置分页插件、逻辑删除、乐观锁插件这几个功能在这个项目中正好全都能用上。分页插件用来做游客预约记录和管理员项目列表逻辑删除用来标记某个时段被取消而不暴露给游客乐观锁插件用数据库version字段做最终一致性兜底。如果你用JPA这些东西也能实现但要手写不少JPQL和Specification调试成本高。有面试官问“为什么不用JPA”理由就是这八个字学习成本低排查直观。3. 预约模块并发控制从synchronized翻车到RedisLua原子扣减3.1 第一版用synchronized为什么一压测就翻车我先说一个真实踩坑。最初图省事我对“扣减容量”方法加了一个synchronized想当然觉得Java内置锁就能解决并发。单机演示确实没问题可一旦用JMeter模拟一百个并发请求同时预约同一个时段超卖现象就出来了。原因要理解透synchronized锁的是当前JVM实例内的对象监视器而SpringBoot默认单实例部署时所有请求确实会经过同一个JVM理论上单机并发是能串行化的。但我犯了一个更隐蔽的错误——把锁放在Service方法上用一个没加Transactional的方法把“查询→判断→扣减→插入”包起来锁释放后事务还没有提交另一个线程进入查询时读到的还是旧值。而且我中间还插入了Redis预扣减看完缓存再锁数据库又造成了更大的不一致。所以不要迷信锁。锁只是手段关键是锁的范围要覆盖“读→判→写”这个完整链路且要保证共享资源在锁内被原子修改。毕设里用synchronized能糊弄演示但无法回答问题“放入生产环境会怎样”。3.2 最终方案Redis Lua 原子扣减我把扣减逻辑改成了Redis Lua脚本这是本项目的核心亮点。Lua脚本在Redis中是原子执行的也就是说脚本执行期间不会插入其他命令这就等价于给“判断剩余容量并扣减”加了一把分布式锁而且没有死锁风险。local key KEYS[1] local capacity tonumber(ARGV[1]) local current tonumber(redis.call(GET, key) or 0) if current 1 capacity then return 0 end redis.call(INCR, key) return 1这段脚本的思路是KEYS[1]是某个时段预约人数的累计值ARGV[1]是时段容量。先读取当前累计值超过容量就返回0否则INCR并返回1。这里有两个细节需要注意初始值要由管理员建时段时写入0容量从MySQL配置读取并缓存到Redis修改容量时要同步更新。扣减成功后异步写预约单到MySQL。为什么要异步因为预约单本身在MySQL有唯一约束和事务高峰期直接同步插入会把压力传导到数据库。用Spring的Async把“扣减Redis成功后的落库”抛到线程池执行配合CompletableFuture返回结果实际压测下来吞吐比同步写库高很多。注意Lua脚本返回0后不能反复重试去“撞库”一定要记录失败原因区分“时段已满”和“参数错误”给游客返回对应的中文提示。3.3 号源回流与过期释放定时任务很关键预约成功后如果游客取消或者预约后没有在开始前入园时段容量必须释放否则系统会越积越多。我用Spring的Scheduled写了一个定时任务每分钟执行一次扫描状态为“已预约”且开始时间小于当前时间减去30分钟的预约单统一改为“已过期”同时对每个过期单执行Redis INCR回补容量。这里还要注意“取消预约”这个动作也不能只改数据库。我封装了一个统一方法状态变更 释放Redis容量 记录操作流水三个动作放在同一个事务方法里Redis操作放在事务提交后的TransactionalEventListener中避免事务回滚但Redis已经被扣减的脏状态。这层的价值在于它体现了你对“分布式环境下数据一致性”有意识而不只是一个会写CRUD的学生。4. 排队叫号模块队列结构、过号策略与WebSocket推送4.1 叫号队列的数据结构为什么用Redis ZSet而不是List最开始我用Redis List模拟队列取号LPUSH叫号RPOP。直白且够用但很快发现两个问题一是无法直接查看“某个人前面还有多少人”总要遍历整个List二是不能做按时间优先的排队只能按照进队列顺序而实际业务可能还要按预约时间排序。交付版本我改成了Redis ZSet。游客取号时执行ZADDscore设为当前时间戳叫号时ZRANGEBYSCORE取分数最小的一批成员查询排队人数直接ZCARD或ZCOUNT。这样能同时支持FIFO相当于score单调递增又能快速算“前面还有多少人”。ZSet每个成员的key我用“队列前缀:项目ID:预约单号”value设为游客ID或排队号这样叫号时能从Redis里直接拿到必要信息不用反复查数据库。4.2 叫号规则放几个、过号怎么办、如何重返队列叫号不能简单理解为“挨个取一个”。过山车一次放行一辆车假设一车20人叫号就是一次取20个4D影院一场50人叫号一次取50个。项目表要存一个字段叫“每批放号人数”管理员点击叫号时按这个数批量取出。过号策略是容易被忽略的需求。真实游乐园里广播叫号三遍没人来就会标记为过号。我的设计是在ZSet里给每个成员存一个连续过号次数游客被叫到后5分钟内未确认系统自动将其移出ZSet记一次过号同一时间段内超过两次过号取消当次排队资格。Redis可以在成员score上做文章被叫到的人临时放到另一个Field中用Hash结构维护“呼叫中”状态。管理员点击“完成消费”或“过号”再决定从ZSet删除还是重新插回队尾。这块逻辑用数据库表也能模拟但用Redis实现后整个队列的响应速度都是在毫秒级。4.3 WebSocket推送叫号别让前端轮询浪费服务器资源叫号通知有两类方案前端定时轮询接口或者后端WebSocket主动推送。毕设里轮询最简单几行setInterval就能搞定但每三秒把所有排队游客都轮询一遍数据库和Redis压力都不小。这个题目既然叫“一体化系统”WebSocket是值得写进亮点里的。我的实现是Spring Boot原生WebSocket STOMP协议。游客登录后按“项目ID”订阅一个主题管理员执行叫号时服务端向该主题发送一条JSON消息当前叫到的起始排队号、批次人数、预计等待时间。前端收到消息后立刻刷新排队状态不需要前端主动发请求。前端用WebSocket连接后端用SimpMessagingTemplate广播这一整套代码量不算大但架构展示力很强。可以看一下核心流程public void callQueue(Long projectId) { // 从ZSet中批量取出本批游客 SetObject batch jedis.zrange(queue: projectId, 0, batchSize - 1); // 更新呼叫中状态 // 向订阅主题推送 messagingTemplate.convertAndSend(/topic/queue/ projectId, new CallNotice(batch, LocalTime.now().toString())); }注意本地联调时记得把页面部署到和WebSocket同一个域下否则跨域握手会被浏览器拦截。开发环境最好直接让前端项目代理后端地址。5. 数据库设计与接口清单用最少的表撑起整个业务5.1 核心表结构六张主表和两张辅助表我在设计表时坚持一个原则不要让一张表承担过多语义。以下六张是核心表member游客用户表字段包括id、username、password、real_name、phone、create_time。admin运营人员表也使用Spring Security做角色区分。attraction游乐项目表存储项目名称、描述、单批容量、单批放号数量、状态。time_slot项目时段表一个项目每天有多个时段每个时段存容量、开始时间、结束时间、状态。appointment预约订单表核心字段包括member_id、attraction_id、time_slot_id、appointment_no、status、visit_date。queue_record排队记录表记录游客取号、叫号、完成、过号的全过程存储queue_no、call_time、finish_time、over_time。辅助表方面我加了一张operation_log记录后台管理员的放号、停运操作一张message_record记录WebSocket推送流水。前者方便做审计后者方便排查“游客明明收到叫号却刷新不出来”的临场问题。5.2 关键接口清单与Spring Security权限控制接口设计要能体现“权限分离”和“资源导向”直接列一张表接口路径方法说明权限/api/auth/registerPOST游客注册公开/api/auth/loginPOST登录返回JWT公开/api/attraction/listGET游乐项目分页列表游客/管理员/api/appointment/createPOST预约某个时段项目游客/api/appointment/cancelPOST取消预约游客/api/appointment/pageGET当前游客的预约记录游客/api/queue/takePOST现场取号入队游客/api/queue/statusGET查看排队人数与等待时间游客/api/queue/callPOST管理员叫号一批管理员/api/queue/finishPOST核销完成排队管理员/api/stats/dailyGET每日预约和入园统计管理员权限控制我用了Spring Security JWT服务端只保留无状态鉴权。特别提醒不要把密码明文存在数据库用BCryptPasswordEncoder加密这个细节论文写“采用BCrypt加密存储”就能体现安全意识。5.3 接口联调时的“隐性地雷”这几个接口很容易翻车我实际联调时踩过这类坑一是预约创建和取号入队之间没有校验“是否已经入园”——我在Queue接口前统一校验预约单状态状态不是“已入园”就拒绝取号二是WebSocket连接后没有心跳检测游客长时间挂着没操作会被网关断开三是跨天时段处理time_slot必须额外存一个visit_date字段不能只依赖开始时间否则过了零点系统直接混乱。6. 从报错到答辩现场那些直接影响演示效果的经验6.1 我实际遇过的三个典型问题第一个问题预约扣减完成但数据库订单没同步出现。排查后发现是Async线程池默认丢弃了任务因为我在没配置线程池时用了默认Executor。解决方法是自定义ThreadPoolTaskExecutor并且把落库失败消息打到告警日志。第二个问题Redis的key缓存了过期容量管理员改完项目容量后游客端永远显示旧值。后来加了“管理员修改容量时同步清理相关缓存”的逻辑并统一走一个CacheService封装。第三个问题页面端收到WebSocket消息但Queue组件没有刷新。原因是浏览器环境没有使用同一份JWT身份WebSocket握手时没把token传进去。实际开发时前端需要在连接URL上拼接参数或使用stompClient的connectHeaders传递token服务端拦截器再从参数中解析身份。6.2 面试官顺着这个项目会追问的SpringBoot考点这个题目很容易成为提问素材我把高频追问和可用的回答方向整理一下。问“Spring三级缓存是什么”的时候你应结合实际说出来Spring在创建单例Bean时用三级缓存提前暴露早期引用解决循环依赖。一级缓存是单例池二级缓存存早期暴露的对象三级缓存存ObjectFactory。你的Controller依赖Service、Service依赖Mapper如果双方循环引用三级缓存在实例化阶段就能把代理对象暴露出去。别只背定义要在项目里举实例我的项目里如果RedisTemplate和某个Service循环依赖框架启动时就是靠三级缓存解开的。问“SpringBoot自动配置原理”就顺着SpringBootApplication拆先讲EnableAutoConfiguration再讲META-INF/spring.factories或新的AutoConfiguration.imports机制然后说你项目里的MyBatis-Plus和Redis都是通过自动配置类完成初始化的。问“Redis为什么快”就落到内存操作、单线程避免竞争、IO多路复用三点。问“Lua脚本原子性原理”要答Redis是单线程执行命令脚本作为整体执行不会被其他请求插队。6.3 让答辩演示更稳的几条经验演示前一定准备好一套真实感强的假数据包括五个项目、每个项目三到四个时段、容量大小不等。最好写一个DataInitializer应用启动时自动初始化管理员账号、游客账号和未来三天的时段。这样无论评委从哪个入口点都不会出现空数据尴尬。还要准备一个“并发演示脚本”我通常在Session里开两个浏览器一个游客一个管理员先让游客预约再让管理员叫号展示WebSocket实时推送。如果评委问“这个项目高并发体现在哪”直接切到JMeter压测截图展示一百并发预约任意时段不超卖。这就把纯概念问题转化成了数据结论远比背“我们用了缓存”更有说服力。提示不要把压力测试数据写得太离谱。毕设项目说“每秒两千并发”很难让人信服压测能证明在30个并发用户同时抢一个10人名额时唯一成功人数为10就足够体现你的并发控制了。最后分享一点个人体会做完这个系统我最大的感受是毕设题目的价值不在于名字有多宏亮而在于题目能不能推动你去解决几个真实问题。预约并发、排队队列、实时通知这三件事每一个都对应一个可展示的成果。建议你动工之前先把预约和排队的状态流转图画清楚再决定先写哪个模块。如果时间紧优先把“预约扣减”和“叫号推送”做扎实其余的增删改查页面可以后补。答辩看的是你的深度不是页面数量。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门