校园订餐管理系统实战:从表结构到高并发防超卖的JavaWeb设计源码 📅 发布时间:2026/9/14 4:43:26 👁 浏览次数: 简介基于Java和MySQL的校园订餐管理系统设计源码完整覆盖前端页面与后端业务逻辑适合高校计算机专业学生作为课程设计参考也可用于快速搭建小型订餐服务平台。整包共40个文件约145KB主要由20个Java源文件、13个XML配置文件、2个SQL脚本、2个属性文件以及JSP、Markdown、txt等组成分别承担业务逻辑、Spring、Struts等框架配置、数据库建表与初始化、环境参数设置及项目说明。Java源文件实现用户登录、菜品管理、订单处理等核心业务XML文件负责Spring及Struts的Bean与动作配置SQL脚本预设数据表结构JSP文件动态生成订餐页面Markdown文档则提供安装部署与使用说明。这套源码目录结构清晰配置文件齐全从数据库搭建到项目部署均有文档指引方便学习者快速跑通系统并进行二次开发。目前已有408人浏览学习适合具备一定Java基础、希望借助完整案例巩固开发技能的读者。1. 校园订餐管理系统为什么需要一份能落地的设计源码校园订餐管理系统是典型的 JavaWeb 课程设计与毕业设计题目但能下单和能上线之间隔着真实工程的那道鸿沟高峰期同一菜品被瞬间抢光、订单状态被后提交的请求覆盖、支付金额与订单明细对不上。这个标题的核心价值在设计源码四个字它暗示的是一份从需求拆解到数据表结构、从 JDBC 调用到事务边界都摊开给你看的工程参考而不是只有三个页面的 demo。它解决的是照着抄能跑换台机器也能跑的问题适合正在做课设的在校生、想找一套简洁可扩展架构的初级后端以及需要快速交付校园场景小程序后端的小团队。Java 负责业务编排与人机交互MySQL 负责数据规则、事务承载与查询加速下文就按这条主线把设计思路和可复现代码讲透。2. 从订单模型到 MySQL 表结构先把数据地基立住2.1 五张核心表与字段约束的取舍校园订餐的业务实体并不复杂真正决定后期好不好改的是表结构设计。常见做法是拆成五张核心表用户表、菜品表、购物车表、订单主表、订单明细表。这里给出一个可以直接执行的建库脚本注释里标明了每个字段存在的原因。CREATE DATABASE IF NOT EXISTS campus_order DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE campus_order; CREATE TABLE user ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, phone VARCHAR(20) NOT NULL COMMENT 手机号/登录账号, password_hash VARCHAR(64) NOT NULL COMMENT 加盐哈希后的密码, role TINYINT NOT NULL DEFAULT 1 COMMENT 1学生 2商家 3管理员, nickname VARCHAR(50) NOT NULL DEFAULT , create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_phone (phone) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户表; CREATE TABLE dish ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, shop_id BIGINT UNSIGNED NOT NULL COMMENT 所属商家关联user.id, name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL COMMENT 菜品单价禁止用FLOAT, stock INT NOT NULL DEFAULT 0 COMMENT 当前可用库存, status TINYINT NOT NULL DEFAULT 1 COMMENT 1上架 0下架, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_shop_status (shop_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT菜品表; CREATE TABLE order ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL COMMENT 业务订单号展示给用户, user_id BIGINT UNSIGNED NOT NULL, shop_id BIGINT UNSIGNED NOT NULL, total_amount DECIMAL(10,2) NOT NULL COMMENT 下单时快照总金额, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2备餐中 3配送中 4已完成 5已取消, receive_address VARCHAR(255) NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME NULL, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_user_time (user_id, create_time), KEY idx_shop_status (shop_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单主表; CREATE TABLE order_item ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, order_id BIGINT UNSIGNED NOT NULL, dish_id BIGINT UNSIGNED NOT NULL, dish_name VARCHAR(100) NOT NULL COMMENT 下单时菜品名快照, price DECIMAL(10,2) NOT NULL COMMENT 下单时单价快照, quantity INT NOT NULL DEFAULT 1, subtotal DECIMAL(10,2) NOT NULL, KEY idx_order_id (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单明细表;这段 DDL 里有几个值得注意的决策。金额字段统一用 DECIMAL(10,2) 而不是 FLOAT原因是二进制浮点数在累计求和时会产生精度漂移账单对不上是最难排查的线上事故。订单明细冗余了dish_name和price这属于典型的空间换时间设计防止商家改价或下架后历史订单无法还原。订单主的total_amount也是快照值它不来自前端传参而是后端用明细表重算得出前端传多少都只作参考。2.2 订单状态机与金额冗余设计订单状态不要散落在 Java 代码里用魔法数字判断先把它定义成一张状态流转表写代码和写接口文档都照着它来。状态值含义可流转到触发动作0待支付1、5用户支付 / 超时取消或手动取消1已支付2、5商家接单 / 商家拒单2备餐中3出餐完成3配送中4用户确认收货或超时自动完成4已完成-终态5已取消-终态库存已回滚金额冗余设计的核心原则是下单即定格。order表存总价order_item存单价和数量两边的差值应该永远为零。我在实际排查数据问题时第一件事永远是执行下面这条校验 SQLSELECT o.id, o.order_no, o.total_amount, SUM(i.subtotal) AS calc_amount FROM order o JOIN order_item i ON i.order_id o.id GROUP BY o.id, o.total_amount HAVING o.total_amount calc_amount;如果查询结果有记录说明写入事务里主表和明细表没有保持原子性。这条 SQL 应该作为每次版本上线前的例行体检脚本而不是出了问题才想起来跑。2.3 索引建立顺序与初始化数据索引不是越多越好校园订餐系统的查询特征很集中用户查自己的历史订单、商家按状态查订单、用户按店铺浏览在售菜品。针对这三个高频路径上面的 DDL 里已经建了idx_user_time(user_id, create_time)、idx_shop_status(shop_id, status)和idx_shop_status(shop_id, status)菜品索引。联合索引的列顺序是有讲究的。idx_user_time把user_id放前面是因为它承担等值过滤create_time承担排序最左匹配原则下这个顺序能让排序也直接走索引避免 Using filesort。如果倒过来建查询WHERE user_id ? ORDER BY create_time DESC就无法利用索引完成排序。初始化数据时不要用 INSERT 一条条写可以用一条带UNION ALL的语句批量插入商家和菜品样本或者直接用存储过程循环生成。这里给出一个生成 30 家商家、每家 10 个菜品的初始化脚本INSERT INTO user (phone, password_hash, role, nickname) SELECT CONCAT(1380000, LPAD(n, 4, 0)), SHA2(123456, 256), 2, CONCAT(商家, n) FROM (SELECT 1 n UNION ALL SELECT 2 UNION ALL SELECT 3) t WHERE n 30;这个写法的意义在于让测试数据具备可重复性。脚本里的LPAD生成定长序号SHA2模拟真实环境中的密码哈希存储。实际生产环境密码要用 BCrypt 一类带盐的算法但测试数据用 SHA2 足够验证登录链路。3. Java 持久层实现连接池、DAO 与事务边界的常见写法3.1 HikariCP 连接池参数与 MySQL 超时时间的匹配Java 项目操作 MySQL第一步是拿到一个可靠的数据库连接。现在主流做法是用 HikariCP 连接池Spring Boot 2.x 之后它也是默认选项不需要额外引入依赖只做配置。下面是一份与 MySQL 默认配置匹配度较高的 HikariCP 配置spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver jdbc-url: jdbc:mysql://localhost:3306/campus_order?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue username: root password: 123456 hikari: minimum-idle: 5 maximum-pool-size: 20 connection-timeout: 30000 idle-timeout: 600000 max-lifetime: 1800000参数推荐值说明maximum-pool-size20受 MySQL 端 max_connections 限制不要盲目加大minimum-idle5与 maximum-pool-size 相差过大时会产生频繁建连开销max-lifetime1800000要略小于 MySQL 的 wait_timeout默认 8 小时connection-timeout30000连接获取超时超过后抛出 SQLTransientConnectionException关键点是max-lifetime与 MySQLwait_timeout的关系。MySQL 默认会在连接空闲 8 小时后断开如果连接池里的连接寿命比 8 小时还长应用拿到的是一个已经被服务端关闭的半死连接第一次查询会报Communications link failure。HikariCP 会主动丢弃超过 max-lifetime 的连接因此这个值必须小于数据库的 wait_timeout。3.2 DAO 层代码骨架与 PreparedStatement 防注入我不建议在这个体量的项目里引入 MyBatis 或 JPAJDBC 加一个轻量 DAO 封装已经能把逻辑表达得很清楚也方便课程设计答辩时讲原理。下面是一个订单写入的 DAO 骨架重点看事务和连接释放的处理public class OrderDao { private final DataSource dataSource; public OrderDao(DataSource dataSource) { this.dataSource dataSource; } public Long createOrder(Order order, ListOrderItem items) throws SQLException { try (Connection conn dataSource.getConnection()) { conn.setAutoCommit(false); conn.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED); long orderId insertOrder(conn, order); for (OrderItem item : items) { insertItem(conn, orderId, item); } conn.commit(); return orderId; } catch (SQLException e) { conn.rollback(); throw e; } } private long insertOrder(Connection conn, Order order) throws SQLException { String sql INSERT INTO order (order_no, user_id, shop_id, total_amount, status, receive_address, create_time) VALUES (?, ?, ?, ?, ?, ?, NOW()); try (PreparedStatement ps conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, order.getOrderNo()); ps.setLong(2, order.getUserId()); ps.setLong(3, order.getShopId()); ps.setBigDecimal(4, order.getTotalAmount()); ps.setInt(5, order.getStatus()); ps.setString(6, order.getReceiveAddress()); ps.executeUpdate(); try (ResultSet keys ps.getGeneratedKeys()) { keys.next(); return keys.getLong(1); } } } }这段代码用 try-with-resources 管理 Connection 和 PreparedStatement但注意关闭连接并不代表物理断开连接池的close()只是把连接归还给池子。所有 SQL 参数都通过setXxx方法绑定绝不拼接字符串这是防 SQL 注入的最基本防线。PreparedStatement 的预编译也能让 MySQL 复用执行计划在高频下单场景下能减少一部分 CPU 开销。3.3 下单事务的控制范围与回滚策略下单是一个典型的多表写操作插入订单主表、插入明细表、扣减菜品库存、清空购物车。这四步必须在同一个事务里任何一个失败都要整体回滚。事务范围的控制原则是无状态操作尽量做短锁资源尽快释放。事务里绝对不能做的事包括发 HTTP 回调通知、调用短信接口、推送 WebSocket 消息、执行耗时超过百毫秒的外部 IO。这些动作放在事务里不会参与回滚只会让数据库连接被长时间占用。常见做法是事务提交成功后再把事件放入内存队列或直接触发异步执行。如果使用 Spring 的Transactional要注意它默认只对RuntimeException回滚受检异常不会触发回滚。在纯 JDBC 实现里需要手动在 catch 中调用rollback()并且在 finally 里把autoCommit恢复为 true 之后再归还连接否则连接池中的连接会带着错误状态污染下一次使用。4. 高峰并发下单乐观锁、隔离级别与 SQL 优化的实战组合4.1 库存超卖与乐观锁版本号方案校园订餐流量峰值集中在饭点前后十分钟最常见的故障是同一道菜库存只有 5 份却有 20 个人同时下单成功最后仓库实际超卖。避免超卖有两种思路悲观锁SELECT ... FOR UPDATE和乐观锁版本号 CAS。校园项目并发量没有大到必须用 Redis 扣减库存的程度MySQL 的乐观锁足够且实现成本最低。UPDATE dish SET stock stock - 1, version version 1 WHERE id ? AND version ? AND stock 1;public boolean deductStock(Connection conn, long dishId, int oldVersion) throws SQLException { String sql UPDATE dish SET stock stock - 1, version version 1 WHERE id ? AND version ? AND stock 1; try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setLong(1, dishId); ps.setInt(2, oldVersion); return ps.executeUpdate() 1; } }这条 SQL 的关键在AND stock 1它让数据库在行锁层面直接拒绝了负库存。AND version ?则是乐观锁的 CAS 判断如果更新影响行数为 0说明版本号已被别人改过业务层应该回滚整个事务并向用户返回手慢了库存不足。重试策略一般控制在 3 次以内超过就放弃避免在锁竞争上浪费太多事务时间。4.2 事务隔离级别和锁等待超时的取舍MySQL InnoDB 默认隔离级别是 REPEATABLE READ很多人纠结要不要改成 READ COMMITTED。在这个系统里默认 RR 配合索引访问已经完全够用不用为了性能更好盲目调整。真正要处理的是死锁和锁等待。死锁发生的高频场景是两个会话以不同顺序更新同一个商家下面的菜品。比如事务 A 先更新菜品 1 再更新菜品 2事务 B 先更新菜品 2 再更新菜品 1就容易互相等锁形成环。解决方式是强制所有事务按菜品 id 升序执行更新顺序一致就不会出现循环等待。锁等待超时参数innodb_lock_wait_timeout默认是 50 秒在下单场景这个时间太长。我一般会在 my.cnf 里把它调到 5 到 10 秒让被阻塞的事务快速失败并返回可读错误信息而不是让用户在 App 上转圈半分钟。调整语句如下SET GLOBAL innodb_lock_wait_timeout 10; SET SESSION innodb_lock_wait_timeout 10;需要注意的是SET GLOBAL在 MySQL 8.0 中不会影响已经建立的连接需要重启应用或重新获取连接才会生效因此正式环境应该直接改配置文件。4.3 EXPLAIN 分析与索引优化一例当用户订单列表查询变慢第一步不是加索引而是先用 EXPLAIN 看清 SQL 的执行路径。下面这条查询是用户端的常见请求使用idx_user_time索引时的执行计划如下EXPLAIN SELECT order_no, total_amount, status, create_time FROM order WHERE user_id 1001 ORDER BY create_time DESC LIMIT 10;---------------------------------------------------------------------------------------------- | id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra | ---------------------------------------------------------------------------------------------- | 1 | SIMPLE | order | ref | idx_user_time | idx_user_time | 8 | const | 37 | Using index | ----------------------------------------------------------------------------------------------typeref说明使用了非唯一索引等值匹配rows37表示在索引层面扫描了 37 行。如果发现typeALL且Extra里出现Using filesort说明user_id上没有可用索引或者索引列的顺序没匹配最左前缀。这是排查慢查询时最直观的信号先看type再看key最后看Extra是否出现Using filesort或Using temporary。对于订单这类持续增长的表深分页也是一个隐患。LIMIT 100000, 10会让数据库扫描前 100000 行再丢弃正确做法是改成游标分页SELECT order_no, total_amount, status, create_time FROM order WHERE user_id 1001 AND create_time 2025-06-01 12:00:00 ORDER BY create_time DESC LIMIT 10;这种方式利用联合索引直接定位到上次翻页的位置扫描行数稳定在 10 行左右不随页数增长。5. 上线前的验证数据一致性核查与压测方法系统上线前数据一致性验证比功能自测更容易发现隐藏问题。先把三组核查 SQL 存成一个check_consistency.sql脚本每次发布前跑一遍。第一组查订单主表与明细表的缺失关联第二组查金额不匹配第三组查库存字段的异常负数SELECT 订单缺少明细 AS check_name, COUNT(*) AS cnt FROM order o LEFT JOIN order_item i ON i.order_id o.id WHERE i.id IS NULL; SELECT 库存异常负数 AS check_name, COUNT(*) AS cnt FROM dish WHERE stock 0;压测时不一定要上 JMeter 这类重型工具一个小型 Java 并发程序就能模拟二十个线程同时抢购同一份菜品重点关注两个指标最终创建成功的订单数和最终库存数之和是否等于初始库存以及日志中是否出现死锁异常。断言逻辑写在测试代码里让构建工具在 CI 阶段就能拦住回归问题CountDownLatch latch new CountDownLatch(20); ExecutorService pool Executors.newFixedThreadPool(20); // 每个线程执行同一道菜的下单逻辑 // 统计成功的订单数和最终剩余库存 // 断言成功订单数 剩余库存 初始库存执行完并发压测后立刻查询dish表里该菜的version字段值。如果版本号和初始版本号加上成功下单数相等说明乐观锁链路全程生效。如果你用的是 HikariCP压测结束后再观察连接池监控指标如果active连接数长期逼近maximum-pool-size说明连接获取耗时增加需要考虑把部分无状态查询移到 Redis 缓存或拆分到读库。最后再分享一个容易被忽略的细节业务订单号order_no不要用数据库自增主键直接展示。常见做法是yyyyMMddHHmmss user_id 后四位 四位随机数拼成一个 32 位以内的字符串并加上唯一索引兜底。这样既方便用户报单号给客服排查也能避免订单号被遍历抓取。把这套校验脚本和压测方法固化到项目的scripts目录下每次迭代跑一遍比任何代码评审都更能守住数据这条底线。本文还有配套的精品资源点击获取