ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

校园线上订餐系统Java实战:Spring Boot+Redis高并发与订单状态机设计

校园线上订餐系统Java实战:Spring Boot+Redis高并发与订单状态机设计 简介这是一份面向高校软件工程、计算机相关专业学生的Java毕业设计论文文档主题为校园线上订餐系统的设计与实现适合正在准备毕设选题、需要参考完整项目方案与论文写作框架的读者。压缩包内共1个docx文件约788KB内容涵盖摘要、目录、绪论及各功能模块的设计论述涉及收货地址管理、菜品管理、菜品收藏与评价、订单管理、购物车管理、字典管理、用户与管理员管理等核心功能并以MySQL作为数据存储方案。文档从研究背景出发梳理了传统信息管理在时效性、安全性与可操作性上的不足进而引出系统需求分析与实现思路同时包含中英文摘要与关键词便于对照论文规范。目前已有72人学习下载可作为毕设选题参考、功能模块梳理与论文结构模仿的实用素材帮助读者快速理解一个完整订餐系统的设计脉络与写作要点。1. 校园线上订餐系统从课程设计到能扛住饭点并发的 Java 落地路线很多同学做「基于 Java 的校园线上订餐系统」第一反应是打开 IDE 建一个 Spring Boot 工程把用户、菜品、订单三张表一建Controller 写完就交差。结果答辩时老师问一句「中午 12 点 2000 人同时下单你怎么扛」当场卡壳。这个标题背后真正要解决的不是「能不能跑」而是「饭点高峰能不能稳、订单状态会不会乱、商家和骑手看到的数据一不一致」。它适合三类人正在做课程设计想拿高分的学生、想用真实业务练手 Java 后端的新人、以及需要一套可演示可扩展点餐流程的开发者。整套方案的技术底座是 Spring Boot MyBatis MySQL Redis前端可以是 Vue 或 Thymeleaf核心难点集中在订单状态机、库存扣减和并发控制上下面按落地顺序拆开讲。2. 技术选型与数据模型为什么是 Spring Boot MyBatis 而不是别的2.1 选型理由与常见替代方案的边界校园订餐系统的业务特征很明确读多写少、有明显的时段峰值、订单状态流转严格、需要和商家端实时同步。基于这些特征后端框架选 Spring Boot 几乎是默认答案原因是生态成熟、starter 依赖开箱即用、和 MyBatis 配合做复杂查询时 SQL 可控。这里要提醒一句很多教程一上来就推 JPA 或 MyBatis-Plus不是说不能用而是校园订餐的订单查询往往涉及多表关联加动态条件MyBatis 的 XML 映射在这种场景下更透明出问题能直接看到 SQL。数据库选 MySQL 8.x字符集用 utf8mb4因为菜品名称和备注里可能出现特殊字符。缓存层用 Redis主要解决两个问题一是菜单这种高频读取的数据没必要每次都打数据库二是饭点下单的分布式锁和库存预扣需要原子操作。消息队列在课程设计阶段可以不上但如果想模拟「下单后异步通知商家」RabbitMQ 或 RocketMQ 是常见做法我一般会先用 Redis 的 List 做个简易队列把流程跑通再考虑替换。前端如果时间紧Thymeleaf 服务端渲染最快出效果如果想让简历好看Vue3 Axios 前后端分离是主流。这里不展开前端重点在后端。2.2 核心表结构与字段设计数据模型是整个系统的地基表设计错了后面改起来非常痛苦。下面给出经过实际项目验证的核心表结构字段类型和索引都按校园场景调过。-- 用户表学生、商家、骑手、管理员共用用 role 区分 CREATE TABLE user ( id BIGINT NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL COMMENT 登录名, password VARCHAR(100) NOT NULL COMMENT BCrypt 加密后的密码, role TINYINT NOT NULL DEFAULT 0 COMMENT 0学生 1商家 2骑手 3管理员, phone VARCHAR(20) DEFAULT NULL, campus_card_no VARCHAR(30) DEFAULT NULL COMMENT 校园卡号用于核验学生身份, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 菜品表属于某个商家含库存和每日限量 CREATE TABLE dish ( id BIGINT NOT NULL AUTO_INCREMENT, shop_id BIGINT NOT NULL, name VARCHAR(80) NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0 COMMENT 当前可售数量, daily_limit INT NOT NULL DEFAULT 100 COMMENT 每日限量, status TINYINT NOT NULL DEFAULT 1 COMMENT 0下架 1上架, PRIMARY KEY (id), KEY idx_shop_status (shop_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单表状态机核心用 status 字段驱动流转 CREATE TABLE orders ( id BIGINT NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 业务订单号雪花算法生成, user_id BIGINT NOT NULL, shop_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已接单 3配送中 4已完成 5已取消, pickup_code VARCHAR(6) DEFAULT NULL COMMENT 取餐码, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_status (user_id, status), KEY idx_shop_status (shop_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单明细表一个订单对应多条菜品记录 CREATE TABLE order_item ( id BIGINT NOT NULL AUTO_INCREMENT, order_id BIGINT NOT NULL, dish_id BIGINT NOT NULL, dish_name VARCHAR(80) NOT NULL COMMENT 冗余存储防止菜品改名后历史订单显示错误, price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL, PRIMARY KEY (id), KEY idx_order_id (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这段建表语句有几个关键决策需要说明。order_item里冗余了dish_name和price这是血泪经验——如果只存dish_id商家改价或改名后历史订单金额和名称会跟着变对账时直接翻车。orders表的status用 TINYINT 而不是字符串是为了索引效率和状态机判断方便。order_no用雪花算法生成而不是自增 ID避免订单号被轻易遍历也方便分库分表时保持唯一。索引方面idx_user_status和idx_shop_status分别支撑学生查自己的订单和商家查待处理订单这两个查询在饭点频率最高。dish表的idx_shop_status支撑菜单加载。2.3 订单状态机的设计订单状态流转是这类系统最容易出 bug 的地方。常见错误是直接在 Service 里写if (status 0) status 1散落在各处后期加一个「退款中」状态就要改十几个地方。正确做法是把状态流转规则集中定义。public enum OrderStatus { PENDING_PAY(0, 待支付), PAID(1, 已支付), ACCEPTED(2, 已接单), DELIVERING(3, 配送中), COMPLETED(4, 已完成), CANCELLED(5, 已取消); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code code; this.desc desc; } // 定义合法流转key 是当前状态value 是允许的下一状态 private static final MapOrderStatus, SetOrderStatus TRANSITIONS Map.of( PENDING_PAY, Set.of(PAID, CANCELLED), PAID, Set.of(ACCEPTED, CANCELLED), ACCEPTED, Set.of(DELIVERING, CANCELLED), DELIVERING, Set.of(COMPLETED), COMPLETED, Set.of(), CANCELLED, Set.of() ); public static boolean canTransfer(OrderStatus from, OrderStatus to) { return TRANSITIONS.getOrDefault(from, Set.of()).contains(to); } }这个枚举把「哪些状态能转到哪些状态」变成数据而不是逻辑新增状态只需改TRANSITIONS。canTransfer在每次更新订单前调用非法流转直接抛业务异常。参数说明code存库desc给前端展示TRANSITIONS用Map.of初始化不可变集合避免运行时被误改。3. 下单与库存扣减把并发问题摁在数据库和 Redis 里3.1 为什么不能直接 update stock stock - 1饭点高峰最典型的翻车场景两个学生同时下单最后一份红烧肉代码里先select stock再update stock stock - 1结果两个请求都读到 stock1都判断通过最后库存变成 -1超卖发生。这不是理论问题是校园订餐系统答辩时老师最爱问的点。解决思路有三层从简单到复杂依次是数据库乐观锁、Redis 预扣减、分布式锁。课程设计阶段推荐乐观锁 Redis 预扣减组合既能讲清楚原理实现量也可控。3.2 数据库乐观锁扣减库存乐观锁的核心是给dish表加一个version字段每次更新带上版本号更新影响行数为 0 就说明被别人抢先了。ALTER TABLE dish ADD COLUMN version INT NOT NULL DEFAULT 0;// DishMapper.xml 中的扣减语句 // UPDATE dish SET stock stock - #{qty}, version version 1 // WHERE id #{dishId} AND stock #{qty} AND version #{version} public int deductStock(Param(dishId) Long dishId, Param(qty) int qty, Param(version) int version);Service public class OrderService { Autowired private DishMapper dishMapper; // 重试 3 次每次重新读取 version public boolean tryDeduct(Long dishId, int qty) { for (int i 0; i 3; i) { Dish dish dishMapper.selectById(dishId); if (dish.getStock() qty) return false; int affected dishMapper.deductStock(dishId, qty, dish.getVersion()); if (affected 0) return true; // 扣减成功 // affected 0 说明版本冲突循环重试 } return false; } }逻辑说明deductStock的 WHERE 条件同时校验stock qty和version两个条件任一不满足都返回 0 行。tryDeduct最多重试 3 次每次重新查最新 version。参数qty是购买数量version是读取时的版本号。这个方案在并发不极端每秒几百时足够用缺点是重试有开销高并发下失败率上升。3.3 Redis 预扣减与 Lua 脚本保证原子性当并发再上一个量级或者想减少数据库压力就在 Redis 里做预扣减。把菜品库存预热到 Redis下单时用 Lua 脚本原子判断并扣减扣成功再异步落库。Component public class RedisStockService { Autowired private StringRedisTemplate redisTemplate; // Lua 脚本KEYS[1] 是库存 keyARGV[1] 是扣减数量 private static final String DEDUCT_SCRIPT local stock tonumber(redis.call(get, KEYS[1])) if stock nil then return -1 end if stock tonumber(ARGV[1]) then return 0 end redis.call(decrby, KEYS[1], ARGV[1]) return 1; public boolean deduct(Long dishId, int qty) { String key dish:stock: dishId; Long result redisTemplate.execute( new DefaultRedisScript(DEDUCT_SCRIPT, Long.class), Collections.singletonList(key), String.valueOf(qty) ); return result ! null result 1L; } }逻辑说明Lua 脚本在 Redis 单线程里执行get和decrby之间不会被其他命令插入天然原子。返回值约定-1 表示 key 不存在库存未预热0 表示库存不足1 表示扣减成功。参数qty通过ARGV[1]传入避免脚本拼接。注意库存预热要在系统启动或商家改库存时同步否则会出现 Redis 和数据库不一致。提示Redis 预扣减成功后数据库扣减要用消息队列或定时任务补偿保证最终一致。课程设计阶段可以简化为扣减成功后同步更新数据库但要接受极端情况下短暂不一致。3.4 下单主流程串起来把上面的组件串成完整下单流程顺序很重要先校验参数和用户状态再 Redis 预扣再写订单最后数据库扣减。Transactional(rollbackFor Exception.class) public OrderVO createOrder(CreateOrderDTO dto, Long userId) { // 1. 校验菜品状态和数量 ListDish dishes dishMapper.selectByIds(dto.getDishIds()); // 2. Redis 预扣减任一失败则回滚已扣的 ListLong deducted new ArrayList(); try { for (OrderItemDTO item : dto.getItems()) { if (!redisStockService.deduct(item.getDishId(), item.getQuantity())) { throw new BizException(菜品库存不足); } deducted.add(item.getDishId()); } // 3. 生成订单号和订单记录 String orderNo SnowflakeIdGenerator.nextId(); Orders order buildOrder(orderNo, userId, dto); orderMapper.insert(order); // 4. 写明细 for (OrderItemDTO item : dto.getItems()) { orderItemMapper.insert(buildItem(order.getId(), item)); } return toVO(order); } catch (Exception e) { // 回滚 Redis 已扣库存 deducted.forEach(id - redisStockService.restore(id, 1)); throw e; } }逻辑说明Transactional保证数据库操作原子但 Redis 不在事务里所以用deducted列表记录已扣菜品异常时手动回滚。SnowflakeIdGenerator是雪花算法工具类保证订单号全局唯一且趋势递增。参数dto包含菜品列表和备注userId从登录态获取不信任前端传入。4. 避坑与排查那些答辩现场被问住的瞬间4.1 订单重复提交导致重复下单现象学生手抖连点两次「提交订单」生成两笔一模一样的订单商家做两份学生只想要一份。原因前端按钮没防抖后端也没做幂等两次请求都正常走完流程。解决前端按钮点击后置灰后端用「用户 ID 菜品组合 时间窗口」做幂等键存 Redis 设置 5 秒过期重复请求直接返回上一笔订单。更严谨的做法是下单前生成一个requestId前端携带后端用SETNX判断是否已处理。4.2 支付回调重复通知导致状态错乱现象模拟支付回调时第三方或模拟器发了两次通知订单状态从「已支付」被改成「已接单」又改回「已支付」或者重复加积分。原因回调接口没有做幂等每次通知都执行状态更新。解决回调入口先用order_no查订单如果当前状态已经是「已支付」或更后直接返回成功不处理。同时把回调记录写一张payment_log表用order_no 通知流水号做唯一索引插入失败说明重复。4.3 饭点数据库连接池被打满现象中午 12 点系统卡死日志里大量Connection is not available, request timed out。原因HikariCP 默认最大连接数 10饭点并发上来后连接不够请求排队直到超时。解决把spring.datasource.hikari.maximum-pool-size调到 20-30根据数据库承受能力同时检查有没有慢 SQL 占着连接不放。菜单查询加 Redis 缓存后数据库压力会明显下降。另外connection-timeout不要设太大3000ms 足够快速失败比拖死好。4.4 取餐码重复或可预测现象两个学生拿到同一个取餐码或者学生发现取餐码是 1001、1002 递增能猜到别人的。原因取餐码用自增 ID 取模或直接截取没有做唯一性校验。解决取餐码用「当日订单序号 随机数」生成比如 4 位随机数加 2 位校验位生成后查库确认当日唯一。不要用订单 ID 直接转换容易被遍历。4.5 商家端菜单缓存没失效现象商家下架了某个菜学生端还能看到并下单。原因菜单缓存在 Redis 里设了 30 分钟过期商家改状态后没主动删缓存。解决商家修改菜品状态或库存时同步删除menu:shop:{shopId}缓存下次查询重新加载。这就是缓存更新的经典问题课程设计阶段用「删除缓存」而不是「更新缓存」更稳妥。5. 压测验证与进阶用 JMeter 把饭点场景跑一遍系统能不能扛住饭点不能靠感觉要压测。我一般用 JMeter 模拟 500 个学生在 1 分钟内集中下单观察响应时间和错误率。测试计划里线程数设 500Ramp-up 设 10 秒循环 1 次请求指向下单接口带上登录 token 和随机菜品 ID。压测前先把 Redis 库存预热数据库清空订单表。跑完后看聚合报告如果 99% 响应时间在 500ms 以内、错误率低于 1%基本能应付大多数校园场景。如果错误率飙升先看数据库连接池和 Redis 连接数再看有没有锁竞争。进阶方向有两个。一是把下单后的通知改成异步用 Spring 的Async或消息队列让主流程更快返回。二是引入 Sentinel 做限流对下单接口按用户维度限流防止单个用户刷单拖垮系统。这两个点答辩时能讲清楚原理和接入方式就是加分项。// 异步通知商家主流程不阻塞 Async(notifyExecutor) public void notifyShop(Long shopId, String orderNo) { // 实际项目里这里可能是 WebSocket 推送或短信 log.info(通知商家 {} 有新订单 {}, shopId, orderNo); }配置线程池时注意队列容量和拒绝策略别让异步任务把内存撑爆。我自己的习惯是任何异步入口都加一行日志记录入参和耗时出问题时这是唯一的后悔药。这套系统从建表到压测跑通认真做大概两周但真正值钱的是过程中对并发和状态一致性的理解这些在面试里比 CRUD 更能拉开差距。希望帮到你。本文还有配套的精品资源点击获取
返回列表