多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

高校教材订购系统毕业设计实战:并发下单与库存扣减的工程化方案

高校教材订购系统毕业设计实战:并发下单与库存扣减的工程化方案 简介本资源为高校教材订购管理系统毕业设计完整源码包面向计算机相关专业毕业生及需要信息管理系统实战参考的开发者。系统采用B/S架构涵盖用户登录、教材信息管理、订单处理、库存管理、报表统计与系统维护等核心模块可帮助读者理解教材采购、分配与库存监控的完整业务闭环适合作为毕业设计选题或课程设计参考。压缩包共398个文件约5.62MB其中118个vue文件与31个js文件构成前端界面与交互逻辑87个java文件实现后端业务处理另有35个html、15个xml及若干png、jpg等图片资源整体结构清晰、便于按模块查阅。目前已有124人学习下载。读者可从中获取完整的前后端分离项目骨架、数据库交互示例、订单与库存管理实现思路以及报表统计模块的编码参考对掌握Java Web开发流程与信息系统设计方法具有实际帮助。1. 教材订购系统为什么总在开学前两周崩掉每年开学前两周高校教材科的订购系统都会迎来一年里最猛的一波流量。学生端集中下单、班委代订、教师端补报教材、库管端核对库存四个角色同时在线一个基于单体架构、数据库连接池只配了 10 个连接的毕业设计级系统很容易在这时候直接卡死。高校教材订购管理系统这个题目表面看是个 CRUD 练手项目真正做进去会发现它同时踩中了并发下单、库存扣减、多角色权限、订单状态机四个工程难点非常适合拿来当毕业设计也足够让面试官追问十分钟。这篇笔记面向三类人正在做这个题目的同学、想把它从能跑改到能扛的开发者、以及需要一套可复现落地路径的指导者。我会按需求拆解 → 数据建模 → 核心链路实现 → 并发与库存 → 避坑 → 进阶验证的顺序讲代码用 Java Spring Boot MyBatis-Plus MySQL 的技术栈这是目前高校毕设里最常见、资料最全、答辩最好讲的一套组合。整套方案不依赖任何特定云服务本地一台 16G 内存的机器就能跑通全流程。2. 需求拆解与角色建模别一上来就画 ER 图2.1 四个角色到底各自要什么教材订购系统的角色不是拍脑袋定的是从真实业务流里切出来的。学生要的是我这学期要买哪些书、多少钱、什么时候到班委要的是我们班一共订多少本、能不能合并成一单省运费教师要的是我这门课指定哪本教材、需要多少本样书教材科管理员要的是全校这学期总共要订多少、哪些书库存不够、什么时候向出版社下单。把这四类诉求翻译成功能点就是下面这张表。做毕设时最忌讳的是把四个角色的功能混在一个菜单里答辩时老师一问权限怎么隔离的就答不上来。角色核心功能数据可见范围典型操作频率学生浏览教材、加购物车、下单、查订单仅本人订单开学前集中日均 3-5 次班委代班级下单、合并订单、导出名单本班订单开学前 1-2 次教师申报教材、申请样书、确认班级用量本人所授课程学期初 1 次管理员教材维护、库存管理、订单审核、统计报表全校数据持续日均 20 次2.2 数据库表设计六张核心表就够很多同学一上来画 ER 图能画二十张表最后自己都理不清。我的经验是教材订购系统的核心表控制在六张用户表、教材表、库存表、订单主表、订单明细表、课程教材关联表。下面给出建表 SQL字段类型和索引都按实际查询场景调过。-- 用户表用 role 字段区分四种角色避免建四张表 CREATE TABLE sys_user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE COMMENT 学号/工号, password VARCHAR(100) NOT NULL COMMENT BCrypt 加密存储, real_name VARCHAR(50) NOT NULL, role TINYINT NOT NULL COMMENT 1学生 2班委 3教师 4管理员, class_id BIGINT DEFAULT NULL COMMENT 班委和学生所属班级, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_role_class (role, class_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 教材表isbn 唯一避免同一本书重复录入 CREATE TABLE textbook ( id BIGINT PRIMARY KEY AUTO_INCREMENT, isbn VARCHAR(20) NOT NULL UNIQUE, title VARCHAR(200) NOT NULL, author VARCHAR(100), publisher VARCHAR(100), price DECIMAL(10,2) NOT NULL, cover_url VARCHAR(255), KEY idx_title (title) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 库存表和教材表拆开方便后续做多仓库扩展 CREATE TABLE stock ( textbook_id BIGINT PRIMARY KEY, total INT NOT NULL DEFAULT 0 COMMENT 总库存, locked INT NOT NULL DEFAULT 0 COMMENT 下单锁定未出库, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, CONSTRAINT chk_stock CHECK (total locked) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单主表status 用状态机管理不要用布尔字段 CREATE TABLE order_main ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE COMMENT 业务单号, user_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已出库 3已完成 4已取消, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_user_status (user_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单明细表记录下单时的价格快照教材调价不影响历史订单 CREATE TABLE order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, textbook_id BIGINT NOT NULL, quantity INT NOT NULL, price_snapshot DECIMAL(10,2) NOT NULL, KEY idx_order (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 课程教材关联教师申报教材走这张表 CREATE TABLE course_textbook ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_name VARCHAR(100) NOT NULL, teacher_id BIGINT NOT NULL, textbook_id BIGINT NOT NULL, semester VARCHAR(20) NOT NULL COMMENT 如 2024-2025-1, UNIQUE KEY uk_course_teacher (course_name, teacher_id, semester) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;建表时有三个决策点值得说清楚。第一用户表用单表加 role 字段而不是四张表因为四种角色的公共字段用户名、密码、姓名完全一致拆表只会让登录逻辑变复杂。第二库存表独立出来并加了locked字段这是后面做并发扣减的关键total - locked才是真正可售数量。第三订单明细里存price_snapshot教材价格调整后历史订单金额不会变这是财务类系统的铁律答辩时能讲出这一点很加分。2.3 技术选型为什么不用微服务经常有同学问要不要上 Spring Cloud。我的建议是毕业设计阶段坚决不上微服务。教材订购系统的业务边界清晰但流量不大单体应用配合合理的分层Controller → Service → Mapper完全够用而且调试、部署、答辩演示都简单。微服务带来的注册中心、配置中心、链路追踪在毕设场景里只会增加你讲不清楚的模块。真要用把精力花在 Service 层的并发控制和缓存上收益高得多。3. 核心链路实现从下单到库存扣减的完整代码3.1 下单接口的分层写法下单是整个系统最核心也最容易出问题的链路。我一般把它拆成四步参数校验 → 库存预占 → 生成订单 → 返回结果。下面给出 Service 层的核心代码用 Spring 的声明式事务包住前三步。Service public class OrderService { Autowired private StockMapper stockMapper; Autowired private OrderMainMapper orderMainMapper; Autowired private OrderItemMapper orderItemMapper; /** * 创建订单 * param userId 下单用户 * param items 教材ID与数量的列表 */ Transactional(rollbackFor Exception.class) public String createOrder(Long userId, ListOrderItemDTO items) { // 1. 参数校验数量必须为正列表不能为空 if (items null || items.isEmpty()) { throw new BizException(订单不能为空); } BigDecimal total BigDecimal.ZERO; // 2. 逐个扣减库存用乐观锁防止超卖 for (OrderItemDTO item : items) { if (item.getQuantity() 0) { throw new BizException(数量非法); } // 关键带 version 条件的更新返回影响行数为 0 说明被并发抢走了 int affected stockMapper.lockStock(item.getTextbookId(), item.getQuantity()); if (affected 0) { throw new BizException(教材 item.getTextbookId() 库存不足); } // 累加金额价格从教材表实时读取 total total.add(item.getPrice().multiply(new BigDecimal(item.getQuantity()))); } // 3. 生成订单主表和明细 String orderNo generateOrderNo(userId); OrderMain order new OrderMain(); order.setOrderNo(orderNo); order.setUserId(userId); order.setTotalAmount(total); order.setStatus(0); orderMainMapper.insert(order); for (OrderItemDTO item : items) { OrderItem oi new OrderItem(); oi.setOrderId(order.getId()); oi.setTextbookId(item.getTextbookId()); oi.setQuantity(item.getQuantity()); oi.setPriceSnapshot(item.getPrice()); orderItemMapper.insert(oi); } return orderNo; } private String generateOrderNo(Long userId) { // 时间戳 用户ID后四位保证单号可读且不易冲突 return System.currentTimeMillis() String.format(%04d, userId % 10000); } }这段代码有三个关键点。第一Transactional的rollbackFor必须显式写Exception.class否则遇到受检异常事务不回滚这是血泪经验。第二库存扣减放在生成订单之前先占库存再建单避免订单建了库存没了。第三金额累加用BigDecimal而不是double浮点误差在财务场景里是致命的。3.2 库存扣减的 SQL 与乐观锁上面调用的lockStock方法对应的 SQL 是这样的update idlockStock UPDATE stock SET locked locked #{quantity}, version version 1 WHERE textbook_id #{textbookId} AND total - locked #{quantity} AND version #{version} /update这里有个细节version条件其实在单条 UPDATE 里可以省略因为total - locked quantity这个条件本身在 InnoDB 行锁下就是原子的。但保留 version 字段有两个好处一是后续做分布式锁或缓存一致性时用得上二是答辩时能讲清楚乐观锁的原理。真正防止超卖的是total - locked quantity这个 WHERE 条件它保证了扣减后locked不会超过total。3.3 订单状态机别用 if-else 堆状态流转订单状态从待支付到已完成中间有支付、出库、取消等多个流转。很多同学写成一堆 if-else最后自己都理不清哪些状态能跳到哪些状态。正确做法是用状态机枚举管理。public enum OrderStatus { PENDING(0, 待支付), PAID(1, 已支付), SHIPPED(2, 已出库), FINISHED(3, 已完成), CANCELLED(4, 已取消); 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, Set.of(PAID, CANCELLED), PAID, Set.of(SHIPPED, CANCELLED), SHIPPED, Set.of(FINISHED), FINISHED, Set.of(), CANCELLED, Set.of() ); public boolean canTransferTo(OrderStatus target) { return TRANSITIONS.getOrDefault(this, Set.of()).contains(target); } }用状态机的好处是任何状态变更前先调canTransferTo校验非法流转直接抛异常。这样即使前端传了错误的状态参数后端也能兜住。取消订单时还要记得把locked库存释放回去这一步经常被漏掉导致库存越用越少。4. 并发与性能开学季流量下的三个必调参数4.1 数据库连接池HikariCP 的四个核心参数Spring Boot 默认用 HikariCP但默认配置是给小型应用用的。开学季并发上来后连接池不够会直接导致请求排队超时。下面是我在压测后调过的一组参数写在application.yml里。spring: datasource: hikari: maximum-pool-size: 20 # 核心数 * 2 磁盘数16核机器给20够用 minimum-idle: 5 # 常驻连接避免冷启动抖动 connection-timeout: 3000 # 获取连接超时3秒快速失败 max-lifetime: 1800000 # 连接最长存活30分钟小于MySQL的wait_timeout idle-timeout: 600000 # 空闲10分钟回收maximum-pool-size不是越大越好。连接数超过数据库 CPU 核数太多反而会因为上下文切换导致性能下降。connection-timeout设成 3 秒是为了快速失败让前端能及时提示系统繁忙而不是让用户干等 30 秒。4.2 热点教材的缓存策略每学期总有一两本教材是全校必修课指定用书比如某本公共英语教材可能几千人同时下单。这种热点数据每次都查库数据库扛不住。做法是用 Redis 缓存教材详情和库存但库存缓存要特别小心一致性。public StockVO getStock(Long textbookId) { String key stock: textbookId; String cached redisTemplate.opsForValue().get(key); if (cached ! null) { return JSON.parseObject(cached, StockVO.class); } // 缓存未命中查库并回填过期时间设短一点 StockVO vo stockMapper.selectById(textbookId); redisTemplate.opsForValue().set(key, JSON.toJSONString(vo), 30, TimeUnit.SECONDS); return vo; }注意缓存过期时间只给了 30 秒。库存这种强一致性要求的数据缓存时间越长用户看到有货实际下单却失败的概率越高。30 秒是个折中既能挡住瞬时热点又不至于让数据太旧。下单扣减库存时直接操作数据库不走缓存扣减成功后主动删除缓存 key让下次查询回源。4.3 接口限流用 Guava RateLimiter 挡住刷单学生端下单接口是最容易被脚本刷的。加一层简单的限流每个用户每秒最多 2 次下单请求。private final LoadingCacheLong, RateLimiter limiterCache CacheBuilder.newBuilder() .expireAfterAccess(10, TimeUnit.MINUTES) .build(new CacheLoaderLong, RateLimiter() { Override public RateLimiter load(Long userId) { // 每个用户独立限流器每秒2个令牌 return RateLimiter.create(2.0); } }); public void checkLimit(Long userId) { RateLimiter limiter limiterCache.getUnchecked(userId); if (!limiter.tryAcquire()) { throw new BizException(操作过于频繁请稍后再试); } }用LoadingCache给每个用户维护独立限流器10 分钟不活跃自动清理避免内存泄漏。这个方案在单机部署下够用如果要多机部署得换成 Redis 的令牌桶。5. 避坑指南五个让答辩翻车的常见问题5.1 现象库存扣成负数超卖了原因库存扣减写成了先查后改SELECT查出来 10 本两个线程都看到 10各自扣 1最后变成 -1。这是最经典的并发问题。解决把判断和扣减合并到一条 UPDATE 里用WHERE total - locked quantity做原子判断。或者用SELECT ... FOR UPDATE加行锁但性能差不推荐。5.2 现象订单金额和明细对不上原因订单主表的total_amount是前端传过来的后端没重新计算。前端被篡改或者计算精度丢失就会导致主表金额和明细累加不一致。解决金额永远在后端根据教材实时价格重新计算前端传的金额只做展示参考不参与入库。这条规则在任何涉及钱的系统里都适用。5.3 现象取消订单后库存没回来原因取消订单只改了order_main.status忘了把stock.locked减回去。库存被永久占用越用越少。解决取消订单和释放库存必须在同一个事务里。写一个releaseStock方法locked locked - quantity和状态更新一起提交。5.4 现象教师申报教材时重复插入原因course_textbook表没加唯一约束教师手抖点两次提交同一门课同一学期插了两条记录。解决建表时加UNIQUE KEY uk_course_teacher (course_name, teacher_id, semester)数据库层面兜底。前端按钮置灰只是辅助不能依赖。5.5 现象分页查询越翻越慢原因用了LIMIT offset, sizeoffset 到了几万条时MySQL 要扫描并丢弃前面所有行速度断崖式下降。解决改成游标分页用WHERE id last_id ORDER BY id LIMIT size。订单列表这种按时间倒序的场景用create_time加id做游标性能稳定。6. 进阶验证用 JMeter 压出系统的真实上限6.1 压测脚本的关键配置系统做完不能只靠我点了没问题就交差。用 JMeter 做一轮压测能拿到真实数据答辩时也有的讲。核心配置是线程组和 CSV 参数化。# 用命令行模式跑压测避免 GUI 消耗资源 jmeter -n -t order_test.jmx -l result.jtl -e -o report/ # 线程组配置在 jmx 里设置 # 线程数 200Ramp-up 10 秒循环 10 次 # 即 10 秒内逐步加压到 200 并发总共 2000 次请求压测时重点看三个指标TPS每秒事务数、响应时间 P99、错误率。教材订购系统在 200 并发下TPS 能到 300 以上、P99 在 500ms 以内、错误率低于 1%就算达标。6.2 用 EXPLAIN 验证索引是否生效压测发现慢查询后用EXPLAIN看执行计划。下面这条 SQL 是订单列表查询重点看type和key两列。EXPLAIN SELECT * FROM order_main WHERE user_id 1001 AND status 1 ORDER BY create_time DESC LIMIT 20;如果type是ALL全表扫描或者key是NULL说明索引没走对。idx_user_status这个联合索引user_id在前status在后正好匹配这个查询。如果查询条件只有status没有user_id索引就用不上这时候要考虑是不是查询设计有问题。6.3 一个我踩过的坑压测数据要贴近真实第一次压测时我用 100 个用户压 1000 本教材结果库存充足根本没触发并发扣减的竞争TPS 虚高。后来改成 500 个用户抢 50 本热门教材才真正压出问题——大量请求在库存扣减那一步失败重试TPS 直接掉到 80。压测数据的热点分布比并发数更重要这是很多人忽略的一点。做这个系统最大的体会是毕业设计题目看着简单但只要你愿意往并发、事务、状态机这些方向深挖它能撑起的技术深度远超预期。我一般会建议同学先把单机版跑通再逐步加缓存、加限流、做压测每一步都有可验证的产出。别一上来就追求大而全先把下单这条主链路做扎实比堆十个花哨功能都管用。希望帮到你。本文还有配套的精品资源点击获取
返回列表