资讯动态

SpringBoot校园订餐系统:从CRUD到高并发架构的毕设全解

发布时间:2026/10/20 13:40:53 来源:尧图企业网站定制
校园食堂订餐这个题目算是SpringBoot毕设里的常青树了。每届都有大量同学选它代码网上也能找到一大堆但说句实话大部分项目都停留在“能跑”的层面用户选菜、下单、管理员后台改改状态一套CRUD走完答辩时老师一问高并发怎么处理、缓存怎么用的场面就很尴尬。我做过的模拟项目里恰好有一个“某高校食堂线上订餐系统”从需求调研到最终答辩全程跟进踩了不少坑也沉淀了不少经验。这篇文章就把这个项目完整拆开从技术选型的深层逻辑、核心模块的设计细节到真正让人眼前一亮的加分点全部讲透。1. 校园订餐系统的业务本质与设计思路饭堂订餐这需求看起来简单但深入想一层它其实是个“高并发读、低频写、强时效”的典型场景。1.1 高峰期的并发特征校园食堂的高峰时段非常集中中午11:30到12:30晚上17:30到18:30几千甚至上万名学生同时刷手机看菜品、加购物车、下单支付。这种场景下数据库扛不住的是读请求——所有人都在浏览菜单但真正下单的可能只有一小部分。所以在设计系统时我心里一直在提醒自己这是一套以查询为主、写操作为辅的系统。那么缓存、静态化、读写分离这些思想就必须要上否则一到饭点系统就该卡死。1.2 从用户角度拆需求站在学生用户的角度我要的是一个“打开就能用、下单快、不迷路”的工具按食堂、窗口、菜品分类浏览能看到图片和价格最好还有销量和评分帮我做选择加入购物车时能直观看到已选数量结算前能修改下单后能实时看到订单状态已支付、商家接单、制作中、可取餐如果错过饭点还能预约明天的餐定时来取站在食堂商家的角度他们要的是“收银效率”和“备餐计划”实时看到新订单能一键接单或拒单根据订单量预估备餐数量减少浪费简单粗暴的报表能看清哪些菜卖得好、哪些是凑数菜站在管理员的视角则是“全局掌控”食堂、窗口、菜品、用户、订单的全生命周期管理销量统计和营收趋势的可视化图表这三类角色的需求理清后整个系统的架构方向就基本定调了。技术选型从来不是为了炫技而是为了把事情办好。1.3 为什么用SpringBoot而不选其他框架很多同学纠结用SpringBoot还是用SSHStruts2SpringHibernate老框架或者用Python的Django。我的建议很直接如果不是对Python特别熟SpringBoot是当前最稳妥的选择。原因很简单SpringBoot把配置简化到了近乎“开箱即用”内嵌Tomcat不用额外部署WAR包到外部容器。对一个毕业设计来说省下来的时间应该花在业务实现和系统优化上而不是耗在配置文件的反复调试里。另一方面SpringBoot的生态非常成熟无论是集成MyBatis-Plus做数据访问还是集成Redis做缓存资料都极其丰富遇到问题一搜就能解决这对时间有限的毕设党来说是最宝贵的。2. 系统总体架构与技术选型详解这个系统的架构设计并不复杂但每一层的选型背后都有它的考量。2.1 前后端分离还是服务端渲染刚开始做的时候某个实验室的导师建议我直接用Thymeleaf模板引擎因为SpringBoot对它的支持很好后端渲染页面代码结构也简单。但后来我权衡了很久还是选择了前后端分离前端用Vue Element UI后端提供RESTful API。理由有两方面。第一校园订餐系统的交互不复杂但页面的状态变化不少——购物车角标、订单状态轮询、菜品销量实时刷新这些用前后端分离的方式做起来更顺手用户体感也更好。第二答辩的时候“前后端分离架构”本身就是个说得出内容的亮点比“我用模板引擎渲染的页面”听起来有含金量得多。当然如果你对前端不熟悉完全可以用Thymeleaf简化项目结构这也是可行的。判断标准是你自己的技术掌握程度而不是跟风。2.2 数据存储方案MySQL Redis组合拳核心数据用MySQL存储这是毫无悬念的选择。设计表结构时要注意的细节后面单独讲。这里重点说的是Redis的角色它在这个系统中承担了三项职责菜品缓存把菜品信息、窗口信息缓存到Redis避免高频查询直接打穿数据库购物车临时存储用户未登录时也能往购物车加菜这时用Redis的Hash结构存临时购物车数据登录后合并到MySQL分布式锁抢购限量菜品或处理订单状态变更时用Redis分布式锁防止超卖和状态错乱这一套组合拳下来数据库的压力能减轻一多半。但我后来发现一个实际问题如果不加缓存过期时间的随机性大量key同时过期会造成缓存雪崩瞬间把数据库打挂。这个问题在操作中非常值得注意后面我会详细展开。2.3 为什么引入消息队列很多毕设项目是没有消息队列的订单直接同步写库。但作为一个在校内真实运行过的系统我在订单模块引入了RabbitMQ核心目的一是削峰填谷二是异步处理。下单高峰期如果每个下单请求都同步等待数据库写入完成再返回用户等待时间会变长数据库的压力也是忽高忽低的。引入消息队列后用户发起下单请求后端先把订单消息丢到队列里立即返回“下单成功”的响应然后由消费者异步处理真正的库存扣减和订单入库。同时RabbitMQ的ACK机制能保证消息不丢失。这个点如果在答辩时被问到“你如何处理高并发环境下的订单写入”你就能言之有物地讲清楚整个消息流转过程这比干巴巴地说“我用了线程池”要有说服力得多。当然引入MQ也会带来消息重复消费的问题我的做法是用Redis Setnx实现幂等性校验保证同一订单号不会被重复入账。2.4 接口安全与鉴权设计校园系统虽然不如金融系统敏感但学生信息、支付订单数据同样是隐私。系统的接口不能裸奔必须做好鉴权。我采用JWTJSON Web Token方案用户登录后后端签发一个带过期时间的Token前端把它存在本地请求时放进Header里后端通过拦截器统一校验。这里有一个非常容易踩的坑把用户的角色信息直接编码在Token里而不加任何权限标识。比如管理员接口和学生接口如果共用同一个拦截器就可能导致越权问题。我的做法是在拦截器解析完Token后把用户ID和角色写入ThreadLocal然后在具体业务中做二次校验。关于密码存储一定不能明文存。用BCrypt加密每次校验时通过盐值比对这样做的好处是即使数据库泄露攻击者也很难还原原始密码。2.5 前后端接口联调的几个约定前后端分离项目里接口设计规范能省掉大量沟通成本。我在项目初期就定了几条硬性约定统一返回格式至少包含code、message、data三个字段前端根据code判断业务成功与否分页请求统一用pageNum和pageSize返回结果统一用total和list时间字段统一用时间戳或标准格式字符串传输避免不同时区导致的时间错乱涉及金额的字段用整数分存储和传输避免前端浮点精度问题这些约定可能看起来繁琐但真到了联调阶段你会发现它们几乎能消除所有低级的沟通误解。我同一时期看了不少同学的项目他们联调时经常因为返回字段命名不一致而反复改代码而我这边基本一遍过原因就在这套约定上。3. 数据库设计与核心模块实现数据库设计是整套系统的地基。地基不稳后面所有功能都会摇摇晃晃。3.1 核心表结构与关键字段说明这个系统的核心表我从一开始就确定了一共七张用户表user用户ID、昵称、手机号、密码BCrypt加密、角色学生/商家/管理员、学号、状态食堂表canteen食堂ID、名称、位置描述、营业开始时间、营业结束时间、状态窗口表stall窗口ID、所属食堂ID、窗口名、负责人ID关联商家用户、状态菜品表dish菜品ID、窗口ID、名称、描述、图片URL、价格用分存储、月销量、库存、状态购物车表cartID、用户ID、菜品ID、数量、加入时间订单表orders订单ID、订单号、用户ID、窗口ID、总金额、状态待支付/已支付/已接单/制作中/可取餐/已完成/已取消、创建时间、支付时间订单明细表order_item明细ID、订单ID、菜品ID、菜品名称冗余字段、单价、数量、小计这里我特别想强调两个字段设计的细节。第一个是“冗余菜品名称”这在订单明细表里看似多余但实际极其重要——因为菜品价格和名称后续可能调整如果订单明细去实时关联菜品表历史订单显示就会错乱。冗余一次换来的是查询时少一次关联数据一致性也更好。第二个是价格用分存储。这个在上面的接口约定里提到过但数据库字段层面也要贯彻比如价格字段用int存“分”而不是decimal存“元”好处是彻底避免浮点运算误差统计报表时也不会出现0.30000000000000004这种尴尬数字。3.2 防止库存超卖这是整个系统里技术上最核心的难点也是答辩时最能体现你思考深度的点。库存超卖本质上是并发场景下多个请求同时读到同一个剩余库存值然后都判断“还有货”一起扣减结果库存变成负数。我在这个项目里用Redis Lua脚本解决。核心思路是扣减库存的操作放到Redis里用Lua脚本原子执行脚本内部先判断库存是否充足充足才执行扣减整个过程不会被其他请求打断。看一段核心代码示例public class StockService { private StringRedisTemplate redisTemplate; /** * 使用Lua脚本扣减库存保证原子性 * KEYS[1] 库存key * ARGV[1] 扣减数量 */ private static final String STOCK_LUA if tonumber(redis.call(get, KEYS[1])) tonumber(ARGV[1]) then return redis.call(decrby, KEYS[1], ARGV[1]) else return -1 end; public boolean deductStock(String stockKey, int count) { Long result redisTemplate.execute( new DefaultRedisScript(STOCK_LUA, Long.class), Collections.singletonList(stockKey), String.valueOf(count) ); return result ! null result 0; } }用Lua脚本的好处是把“检查库存”和“扣减库存”合并成一个原子操作不存在竞态条件。数据库里的库存字段则用乐观锁作为兜底update dish set stock stock - #{count} where id #{id} and stock #{count}如果更新影响行数为0说明库存不足或已被并发扣完再回滚订单。这里有个细节值得注意Redis里的库存数据和数据库里的库存数据要保持最终一致。我用的方案是下单时先扣Redis库存异步消息里再扣数据库库存同时用定时任务对账校正两边数据防止极端情况下Redis崩溃造成的数据不一致。3.3 购物车设计的两种模式购物车在任何电商系统中都是高频操作的对象校园订餐也不例外。我这套系统里购物车分了两种情况处理。对未登录用户我用Redis的Hash结构暂存以用户设备的临时标识作为keyfield是菜品IDvalue是数量。前端每次操作购物车都同步到后端用户登录后调一次合并接口把Redis中的临时购物车数据迁移到MySQL购物车表。对已登录用户直接操作MySQL购物车表增删改查都走标准SQL。这里要提醒大家的是购物车的增删改查接口一定要加登录鉴权从Token里取用户ID去操作不能从前端传用户ID否则任何登录用户都能改别人的购物车。3.4 订单状态机的严格流转订单状态的变化如果不受控系统会出各种奇怪的bug。比如已经取消的订单被商家接单或者已完成的订单又被退款。所以我在设计之初就画好了状态机待支付 - 已支付 - 已接单 - 制作中 - 可取餐 - 已完成或者待支付 - 已取消已支付 - 申请退款 - 已退款。所有的状态变更都通过统一的状态流转方法完成方法内部校验当前状态和目标状态是否合法不合法直接抛异常。这样做的好处是逻辑清晰出bug的概率大幅降低代码可读性也更强。我强烈建议所有有状态机的模块都采用这种设计而不仅仅是订单模块。3.5 定时任务的两个经典应用场景校园订餐系统有两个场景特别适合定时任务一是订单超时未支付自动取消二是每日零点重置菜品月销量。订单超时关闭我用的是Spring自带的Scheduled注解加延时扫描每30秒扫描一次订单表把创建时间超过30分钟且状态仍为“待支付”的订单批量置为“已取消”并回补库存。有同学问为什么不用延迟队列精确到秒级我的回答是校园订餐的场景下30分钟的支付宽限期内晚几秒取消订单用户完全感知不到用数据库轮询完全够用且实现简单、不易出错。每日销量重置则是凌晨执行一次update语句把月销量清零同时把上个月的销量归档到历史统计表。这里特别注意凌晨零点到一点之间可能有少量订单在跨天时创建重置销量时务必要用条件过滤当天已产生的订单数据否则数据会不准确。4. 性能优化与线上部署的实战记录代码写完之后真正的考验才刚开始。我在自己电脑上跑得好好的系统部署到服务器模拟高并发时问题接二连三地冒出来。4.1 压测现场从几百并发到几千并发我用JMeter对几个核心接口做了压测。直接用初始版本无缓存、无队列的时候500个并发用户同时访问菜单接口接口响应时间飙升到3秒多错误率超过10%。这就是典型的数据库连接池被打满SQL执行排队。随后我调整了三个地方先给菜品查询接口加Redis缓存命中率到了80%以上给数据库连接池的参数做了调优最大连接数从10调到50、等待超时时间适当缩短把菜单接口的数据从数据库全量查询改成Redis缓存中直接读取。再压一次同样500并发响应时间降到200毫秒以内错误率归零。3000并发测试下虽然响应时间有所上升但系统整体稳定没有崩。这里有个体会性能优化不是盲目加缓存而是先找到瓶颈在哪。先看慢SQL日志再看连接池活跃连接数最后决定是加缓存、加索引还是调整线程池。针对性优化才能花最小成本换最大效果。4.2 缓存穿透、击穿与雪崩的应对这三个词在分布式缓存领域的概念我在这个项目里实实在在地实践了一遍。缓存穿透查询一个不存在的菜品ID缓存没有数据库也没有请求直接打到数据库。我的解决方案缓存空值并设置较短的过期时间比如3分钟同时用布隆过滤器在最前面拦截不存在的ID。布隆过滤器代码稍复杂但答辩讲出来非常加分。缓存击穿某个热点菜品突然过期大量请求瞬间打到数据库。我的解决方案互斥锁只允许一个请求去数据库查并回填缓存其他请求等待缓存重建完成。缓存雪崩大量缓存同时过期数据库压力瞬间暴涨。我的解决方案过期时间加随机值例如基础过期时间30分钟再加0到5分钟的随机数让过期时间分散开。这三个问题的解决方案我建议每个做高并发项目的同学都认真准备因为它们是面试和答辩环节问到的概率极高的考点。4.3 数据库索引优化的几个关键点索引优化在毕设阶段经常被忽略但它能让SQL性能产生数量级的差别。我在这个项目中认真分析过慢SQL然后加了下面几个索引订单表按用户ID创建时间建复合索引因为最常用的查询是查看某个用户的历史订单且按时间倒序订单表按状态创建时间建复合索引定时任务扫描超时订单时能快速定位数据订单明细表按订单ID建索引查询订单详情时避免全表扫描菜品表按窗口ID建索引一个窗口下有多少菜的查询很频繁这里有个很典型的误区索引不是越多越好每个索引都会拖慢写入速度并占用存储空间。只需要针对高频查询建索引低频查询保持全表扫描也问题不大。我还发现一个很有用的习惯在开发阶段开启MySQL慢查询日志把超过0.5秒的SQL全部记录下来逐一分析优化。这个习惯后来帮我在答辩时讲了三个真实可查的优化案例远比背概念有说服力。4.4 上传图片服务的实现细节菜品图片上传看起来小儿科但里面也藏着不少坑。我一开始用本地磁盘存储图片结果服务器磁盘空间很快被撑爆而且图片没法通过URL直接访问前端显示不了。后来我改为对象存储方案但改造逻辑并不复杂前端把图片文件传到后端后端生成唯一的文件名时间戳随机数然后把文件流上传到对象存储图片URL由存储服务返回并保存到数据库。这里有个很重要的细节一定要限制文件类型和大小否则会有人上传恶意脚本或超大文件。我的校验方案是只允许jpg、png、webp格式大小不超过2MB并且用白名单方式判断而不是黑名单。4.5 Docker一键部署的实践毕设项目的部署过程是我个人觉得最值得分享的经验之一。我没有直接用jar包在服务器上运行而是把项目打成Docker镜像用docker-compose把MySQL、Redis、RabbitMQ和后端服务一起编排起来一条命令就能在任意环境启动整个系统。这样做的好处有几个方面一是环境一致性有保障不会出现“在我电脑上跑得好好的到服务器上就崩了”的经典问题二是部署和回滚速度快更新版本只需要重新打镜像再启动容器三是答辩演示时能在评委面前展示工程化和容器化思维而不是简单的“双击jar包运行”。有一个细节值得提一下MySQL和Redis的容器数据目录要挂载到宿主机也就是配置volumes不然容器一删数据就全没了。这话听起来是常识但我见过不止一个同学在演示前一天发现数据库被清空原因是最后重新部署时没挂载数据卷。5. 常见问题排查与避坑指南这部分内容是我在整个开发周期里踩过的坑的汇总也是我最希望有人提前告诉我的东西。5.1 缓存与数据库数据不一致我在项目运行过程中发现菜品价格或库存修改后用户端看到的还是旧数据。原因很简单后台更新了MySQL但Redis里的缓存没更新。解决方案是先更新数据库再删除缓存而不是直接更新缓存。如果直接更新缓存在并发读写场景下很容易出现“线程A更新数据库为100线程B更新缓存为100线程A删除缓存线程B覆盖缓存”这类错乱。删除缓存的方案则简单很多下次读取时发现缓存没有就直接回源数据库并重建缓存最终一致性得以保证。删缓存还有一个好处天然带惰性加载不存在主动更新缓存带来的并发时序问题。5.2 定时任务重复执行导致数据错乱某天我打开后台销量统计发现某个菜品的月销量竟然翻了一倍。排查后发现原因是部署了多个实例每个实例上的定时任务都在跑“每日重置销量”的任务被执行了两次。解决办法很简单用分布式锁保证同一时刻只有一个实例执行定时任务。用Redis的Setnx命令加锁加锁成功的实例才执行业务逻辑执行完释放锁并且锁要设置过期时间防止宕机导致死锁。Spring的Scheduled搭配ShedLock框架也可以实现同样的效果但原理都是一样的。5.3 分页查询常见Bug分页查询的Bug看似低级但实际项目中发生的频率远超想象。最典型的问题是前端传了pageNum和pageSize后端却忘了处理总条数total的返回导致前端分页组件只有当前页数据无法计算总页数。另一个典型问题是pageNum从0开始还是从1开始每个前端组件约定不一样一旦前后端理解不一致就会出现首页数据为空或者只能看到第二页数据的奇怪现象。我的做法是在项目初期就规定前端传pageNum从1开始后端返回结果默认包含total和list两个字段。所有分页接口都用统一的PageResult对象封装。这样一套规范下来分页相关的沟通成本几乎降为零。5.4 跨域问题的终极解法前后端分离项目跨域问题是避不开的。一开始我用CorsFilter加允许跨域的配置但是发现携带Token的自定义Header无法通过预检请求前端一直报401。排查后发现是自定义Header没被允许。解决办法是在跨域配置中明确允许Authorization头并设置exposedHeaders让前端能读取后端的自定义响应头。更彻底的方式是用Nginx做反向代理把前端请求代理到后端同源地址从而在浏览器层面完全避免跨域。我最终采用的是Nginx代理方案因为生产环境本来就建议用Nginx做反向代理和静态资源服务器跨域问题顺带就能解决。5.5 并发环境下订单号重复这个坑比较隐蔽但一旦出现就是严重的资损问题。最开始我用时间戳加随机数生成订单号并发一高就出现重复数据库唯一索引直接报错。后来我改成Redis incr生成自增序列号再拼接业务前缀和日期比如当前日期加自增序列保证同一秒内也不会重复。对应代码逻辑public String generateOrderNo() { String dateStr LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); Long seq redisTemplate.opsForValue().increment(order:seq: dateStr); return dateStr String.format(%06d, seq); }这段代码的思路是以日期为key通过Redis的原子自增操作生成当天的流水号再把日期和流水号拼接成订单号。注意Redis的increment操作是原子的所以并发环境下不会重复。当然如果追求更强的唯一性UUID或雪花算法也可以但订单号要给人看、要排序所以可读性强的自增序列方案更合适。5.6 文件上传相关的一个典型失误这里再提一个和上传相关的失误我有一版上传图片时直接把图片塞进数据库的longblob字段导致数据库文件体积迅速膨胀查询变慢。后来全部改为上传到对象存储、数据库只存URL数据库的响应速度一下子提上来。这个经验对毕设项目很有参考价值不要用数据库存文件文件就该放文件系统或对象存储数据库只存元数据。6. 答辩加分项让评委眼前一亮的四个细节毕设答辩评委老师见过的项目太多了单纯说“我做了个订餐系统有登录、有下单、有后台”完全不够。但如果你能在答辩时讲出下面几个层面的内容效果会完全不同。6.1 接口幂等性设计用户在下单页面手抖点了两次“提交订单”或者前端网卡后自动重试都有可能产生两条一模一样的订单。我的做法是前端生成一个幂等键比如UUID放在请求参数里后端在Redis里以幂等键为key、以订单号为value第一次请求时写入并返回正常结果第二次请求时发现key已存在直接返回之前的订单结果不会重复创建订单。这个设计听起来并不复杂但它体现出来的思考深度是“并发安全”级别的而不是“CRUD”级别的非常加分。6.2 敏感操作日志记录我一个模拟项目里没有做操作日志后来出了个数据被误改但查不到是谁改的问题非常被动。之后我把登录、下单、修改菜品价格、删除菜品这类敏感操作全部做成AOP切面记录日志包括操作人、操作时间、操作内容、请求IP等。这个日志不仅能在出问题时追溯责任答辩时也能作为系统安全性的一个重要佐证。6.3 前端交互的人性化考虑同样是展示菜品列表我在每个菜品卡片上标出了“月销量”和“剩余份数”而且剩余份数低于一定阈值时卡片会显示“即将售罄”的红色标签。这一个细节比任何技术都更能打动评委因为这意味着你思考了用户的实际使用场景而不是仅仅完成功能。6.4 单元测试与接口文档很多毕设项目是完全不写测试的。但我花了一天时间给核心服务写了单元测试库存扣减的并发测试、订单状态的合法性校验测试、Token鉴权的测试等。这些测试在答辩时直接展示给评委看配上一句“我用并发100个线程同时下了20个订单验证库存没有超卖”效果远胜于口头描述“我的系统支持高并发”。接口文档方面我使用Swagger自动生成API文档不仅方便了自己调试和前后端联调也让答辩评委能直观看到全系统有多少个接口、每个接口的参数和返回结构。看到一页页结构清晰的接口文档老师对完成度的认可度会显著提升。7. 写在最后的一些体会这个系统从需求分析到测试上线前前后后大概用了三周多的时间。回想整个过程我最深的体会是毕业设计与其说是技术的比拼不如说是工程能力和取舍智慧的综合考量。同样是校园订餐系统有人写几千行代码做成一个可用的产品有人东拼西凑做成一堆碎片化的Demo核心差距就在于有没有完整的设计意识。如果你现在也正在做类似的系统给你几个比较实际的建议先画好系统架构图和数据表结构再动手写代码不要边写边想返工成本远超想象每个关键模块先写清楚“为什么这么做”答辩时就不会被问倒用日志记录每个请求的执行时间性能分析会轻松很多别只在你自己的电脑上运行尽早部署到服务器上测试环境差异带来的坑早踩早解决按这套思路走下来你的项目不仅能在答辩中拿到不错的评价更重要的是这套完整的设计、实现、优化方法论会让你后续做任何系统开发都更有底气。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑