☰
校园线上订餐系统Java实战:Spring Boot+Redis高并发与订单状态机设计
2026/10/4 16:21:10 网站建设 项目流程

简介:这是一份面向高校软件工程、计算机相关专业学生的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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 菜品表:属于某个商家,含库存和每日限量 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单表:状态机核心,用 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单明细表:一个订单对应多条菜品记录 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

这段建表语句有几个关键决策需要说明。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 Map<OrderStatus, Set<OrderStatus>> 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,结果两个请求都读到 stock=1,都判断通过,最后库存变成 -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] 是库存 key,ARGV[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. 校验菜品状态和数量 List<Dish> dishes = dishMapper.selectByIds(dto.getDishIds()); // 2. Redis 预扣减,任一失败则回滚已扣的 List<Long> 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 分钟内集中下单,观察响应时间和错误率。测试计划里线程数设 500,Ramp-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 更能拉开差距。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询