Spring Boot单体电商系统设计:事务、库存与幂等性实战 简介本资源是一份面向计算机专业本科生与研究生的MySQL网上购物系统课程设计文档聚焦Spring Boot框架在B/S架构电商系统中的完整落地实践帮助学习者掌握需求分析、模块设计、数据库建模含E-R图与表结构、多角色权限管理用户/商家/管理员及系统测试功能、性能、安全三维度等核心开发能力。压缩包为单个3.8MB的Word文档.docx内容涵盖引言、技术选型Java、Spring Boot、MySQL、系统分析、详细设计、测试方案与总结并附中英文摘要及规范目录结构便于教学参考与项目复现。目前已有76人学习下载文档布局清晰、界面描述详实、测试分析到位既可作为课程设计报告范本也提供后续扩展思路——如智能推荐、图像识别与区块链集成等演进方向兼具教学性、工程性与前瞻性。1. 为什么一个“网上购物系统课程设计”能让学生在答辩现场被连续追问27分钟这不是在搭玩具网站而是用 Spring Boot MySQL 把电商最硬的几块骨头——用户身份核验、商品库存强一致性、订单状态机流转、支付结果异步对账——全塞进一个能跑通、能调试、能讲清每行代码意图的最小闭环里。很多同学交完代码才发现登录能跳转但 JWT 过期后没自动刷新加购能点但并发扣库存时出现负数订单生成了却查不到对应支付流水号。问题不在功能缺不缺而在事务边界划在哪、SQL 索引建没建对、Spring 的 Transactional 为什么在 service 层失效却在 controller 层“看起来生效”。这个课程设计本质是一次对 Java Web 工程能力的压测它不考你会不会写RestController而考你能不能在没有分布式锁、没有 RocketMQ、没有 Redis 缓存的纯单体架构下靠 MySQL 的行锁 Spring 的事务传播机制 合理的 SQL 写法守住「钱不能多扣、货不能超卖、状态不能错乱」这三条底线。适合刚学完 JDBC 和 Spring 基础、正卡在「知道概念但不敢写生产级代码」临界点的开发者——不是让你造轮子是逼你亲手把轮子的轴承、辐条、气门芯都拧紧。2. 从零初始化项目骨架Maven 依赖、数据库建模与实体映射的三重校准2.1 选型依据为什么不用 MyBatis-Plus 而坚持手写 Mapper XMLMyBatis-Plus 自动生成 CRUD 是快但课程设计的核心训练目标之一是理解「一条 update 语句执行时MySQL 怎么加锁、JDBC 怎么传参、Spring 怎么管理连接」。用Update(UPDATE order SET status #{status} WHERE id #{id})看似简洁可一旦出现WHERE id ? AND status UNPAID这类乐观锁条件你就得自己拼 SQL、自己处理空值、自己写if判断。而 XML 方式强制你直面 SQL 细节!-- src/main/resources/mapper/OrderMapper.xml -- update idupdateStatusIfUnpaid parameterTypemap UPDATE order SET status #{newStatus}, updated_at NOW() WHERE id #{orderId} AND status UNPAID AND deleted 0 /update提示parameterTypemap是关键。它让方法签名可以是int updateStatusIfUnpaid(MapString, Object params)避免为每个带条件更新新建 DTO 类又保留类型安全——参数名和 SQL 字段名一一对应IDE 能跳转、能校验。手写 XML 的血泪经验别省事用Param注解传两个参数。当方法要传orderId,newStatus,operatorId,remark四个字段时Param(a) Long a, Param(b) String b...会让 XML 里写成#{a},#{b}完全丢失语义。Map 是唯一能兼顾可读性、可扩展性、调试便利性的方案。2.2 MySQL 建库建表5 张核心表的字段设计逻辑与索引落点课程设计不是堆功能是练取舍。我们只建 5 张表user,product,cart,order,order_item。每张表字段控制在 10 个以内但每个字段都承担明确职责表名关键字段含注释必建索引为什么userphone VARCHAR(11) UNIQUE NOT NULL—— 登录凭证非主键但强唯一UNIQUE KEY uk_phone (phone)防止重复注册且登录查询走此索引O(1)productstock INT NOT NULL DEFAULT 0,lock_stock INT NOT NULL DEFAULT 0—— 库存双计数KEY idx_status_stock (status, stock)首页查「上架且有货」商品联合索引覆盖查询cartuser_id BIGINT NOT NULL,product_id BIGINT NOT NULL,quantity TINYINT NOT NULLUNIQUE KEY uk_user_product (user_id, product_id)防重复加购合并数量用orderuser_id,status ENUM(UNPAID,PAID,SHIPPED,COMPLETED,CANCELLED),pay_no VARCHAR(32)KEY idx_user_status (user_id, status)用户查「我的订单」按状态分页避免 filesortorder_itemorder_id,product_id,quantity,price DECIMAL(10,2)KEY idx_order (order_id)根据订单 ID 查所有子项必须有否则关联查询变全表扫描注意product.lock_stock不是冗余字段。它是「已加入购物车但未下单」的临时占用量。扣库存时先UPDATE product SET lock_stock lock_stock ? WHERE id ?下单成功再UPDATE product SET stock stock - ?, lock_stock lock_stock - ?。这是单体架构下规避超卖的低成本方案——比分布式锁轻比版本号乐观锁稳。2.3 实体类与 Lombok 的克制使用哪些字段必须手动 getter/setterLombok 的Data很香但会埋雷。比如Order实体中public class Order { private Long id; private Long userId; private String payNo; // 支付单号由系统生成外部不可设 private BigDecimal totalAmount; // 订单总金额由 item 汇总计算不应被外部修改 private String status; // 状态变更需走状态机禁止直接 set private LocalDateTime createdAt; // ✅ 正确payNo 只提供 getter构造时由 service 生成 public String getPayNo() { return payNo; } // ✅ 正确totalAmount 由 addOrderItem() 方法内部累加不暴露 setter public BigDecimal getTotalAmount() { return totalAmount; } // ❌ 错误status 不应有 public setter应提供 changeStatus(String from, String to) // public void setStatus(String status) { this.status status; } }提示Data会自动生成所有 getter/setter包括你不想要的。正确做法是Getter Setter(access AccessLevel.PRIVATE) 手动写需要的 public 方法。这样在单元测试里你能清晰看到谁在改状态、改之前校验了什么、失败时抛什么异常。3. 核心业务链路落地从加购到支付回调的四层事务控制3.1 加购为什么用 INSERT ON DUPLICATE KEY UPDATE 而不用先查后插加购接口/cart/add的常见翻车点用户快速连点两次导致 cart 表出现两条相同user_idproduct_id记录。有人写// ❌ 危险伪代码 Cart exist cartMapper.selectByUserAndProduct(userId, productId); if (exist null) { cartMapper.insert(new Cart(...)); } else { cartMapper.updateQuantity(exist.getId(), exist.getQuantity() 1); }问题在于两次请求同时执行selectByUserAndProduct都查不到然后都执行insert违反唯一索引。正确解法是把「查插/更」原子化-- src/main/resources/mapper/CartMapper.xml insert idmergeCart parameterTypemap INSERT INTO cart (user_id, product_id, quantity, created_at, updated_at) VALUES (#{userId}, #{productId}, #{quantity}, NOW(), NOW()) ON DUPLICATE KEY UPDATE quantity quantity #{quantity}, updated_at NOW() /insert逻辑说明ON DUPLICATE KEY UPDATE依赖uk_user_product唯一索引。当插入冲突时自动执行更新分支整个操作由 MySQL 行锁保证原子性。Java 层无需任何 synchronized 或数据库锁性能高、代码简。3.2 下单库存预占与订单生成的两阶段提交模拟下单/order/create是整个系统最脆弱环节。必须确保库存够 → 预占成功 → 订单生成 → 支付发起任一环节失败都要回滚前面所有操作。Spring 的Transactional在这里不是银弹——它只能管住本库事务管不了「调第三方支付接口」这种外部动作。所以我们拆成两阶段第一阶段DB 事务内校验product.stock cart.quantityUPDATE product SET lock_stock lock_stock ? WHERE id ? AND stock ?注意WHERE 条件包含库存判断失败则整条 SQL 影响行为 0插入order主表statusUNPAID插入order_item子表清空cart中对应商品第二阶段事务外调用模拟支付接口如http://localhost:8081/mock-pay若支付接口返回 success调用orderMapper.updateStatusIfUnpaid(orderId, PAID)若支付接口超时或失败启动定时任务扫描order.statusUNPAID AND created_at NOW()-30m自动取消订单并释放lock_stock// OrderService.java Transactional(rollbackFor Exception.class) public Order createOrder(Long userId) { // 1. 从 cart 读数据SELECT ... FOR UPDATE 保证读一致性 ListCart cartItems cartMapper.selectByUserIdForUpdate(userId); if (cartItems.isEmpty()) throw new BizException(购物车为空); // 2. 批量校验库存内存中校验不发 SQL for (Cart item : cartItems) { Product p productMapper.selectById(item.getProductId()); if (p.getStock() item.getQuantity()) { throw new BizException(商品[ p.getName() ]库存不足); } } // 3. 执行预占库存 创建订单全部在同一个事务 for (Cart item : cartItems) { int updated productMapper.lockStock(item.getProductId(), item.getQuantity()); if (updated 0) { throw new BizException(库存预占失败请重试); // 此时其他线程已占走 } } Order order buildOrder(userId, cartItems); orderMapper.insert(order); orderItemMapper.batchInsert(buildOrderItems(order.getId(), cartItems)); cartMapper.deleteByUserIdAndProducts(userId, cartItems.stream().map(Cart::getProductId).collect(Collectors.toList())); return order; }参数说明selectByUserIdForUpdate()的 XML 中必须有FOR UPDATE否则并发下单时读到的是旧库存。lockStock()方法对应的 SQL 是UPDATE product SET lock_stock lock_stock #{quantity} WHERE id #{id} AND stock #{quantity}—— 这个AND stock #{quantity}是关键它让 UPDATE 失败时返回影响行数 0而不是靠程序再去查一遍库存。3.3 支付回调幂等性设计与状态机驱动的最终一致性真实支付回调如微信/支付宝会因网络问题多次推送同一笔通知。你的/pay/callback接口必须做到同一pay_no的多次请求只处理一次且状态只能向前推进UNPAID→PAID不能 PAID→UNPAID。我们不用 Redis 记录已处理pay_no课程设计不引入新组件而是用 MySQL 的「状态机约束 唯一索引」-- 在 order 表加唯一索引防止重复插入回调记录 ALTER TABLE order ADD COLUMN callback_times TINYINT DEFAULT 0; ALTER TABLE order ADD UNIQUE KEY uk_pay_no_callback (pay_no, callback_times);回调处理逻辑PostMapping(/pay/callback) public String handleCallback(RequestBody PayCallbackDTO dto) { // 1. 校验签名略 // 2. 根据 pay_no 查订单 Order order orderMapper.selectByPayNo(dto.getPayNo()); if (order null) return FAIL; // 订单不存在 // 3. 状态机校验只允许 UNPAID → PAID if (!UNPAID.equals(order.getStatus())) { log.warn(订单{}状态为{}拒绝重复回调, order.getId(), order.getStatus()); return SUCCESS; } // 4. 尝试更新状态利用唯一索引实现幂等 try { int updated orderMapper.updateStatusIfUnpaid(order.getId(), PAID); if (updated 0) { // 更新成功触发发货准备等后续动作 afterPaid(order); } } catch (DuplicateKeyException e) { // uk_pay_no_callback 冲突说明已处理过忽略 } return SUCCESS; }关键点updateStatusIfUnpaid方法的 SQL 中WHERE status UNPAID是状态机守门员而uk_pay_no_callback索引是幂等性保险丝。两者结合不依赖外部组件纯 MySQL 就扛住重复回调。4. 避坑指南MySQL 与 Spring Boot 协同中的 4 个高频翻车现场4.1 现象Transactional失效订单创建后库存没扣原因事务方法被本类内其他方法直接调用如createOrder()调用了本类的deductStock()绕过了 Spring AOP 代理。Spring 的事务是基于代理的只有外部 Bean 调用才会触发。解决方案一推荐把deductStock()提到独立的StockService通过Autowired注入调用方案二在本类中通过ApplicationContext获取自身代理对象调用((OrderService) applicationContext.getBean(OrderService.class)).deductStock(...)方案三用TransactionTemplate手动控制事务边界适合复杂嵌套场景。4.2 现象并发加购时 cart 表出现重复记录唯一索引报错原因INSERT ON DUPLICATE KEY UPDATE的冲突检测依赖唯一索引但如果你的uk_user_product索引建在(user_id, product_id)上而user_id是BIGINT、product_id是BIGINTMySQL 默认排序规则是 B 树升序没问题但如果某次建表时手误写成(product_id, user_id)那么ON DUPLICATE就无法按user_idproduct_id组合去匹配导致冲突检测失效。解决执行SHOW CREATE TABLE cart确认索引列顺序与代码中mergeCart的参数顺序严格一致在单元测试中写并发加购用例用CountDownLatch模拟 100 线程断言cart表中COUNT(*)等于商品种类数而非线程数。4.3 现象订单详情页查order_item很慢EXPLAIN显示typeALL全表扫描原因order_item表数据增长后order_id字段没建索引而查询语句是SELECT * FROM order_item WHERE order_id ?。解决立即执行ALTER TABLE order_item ADD INDEX idx_order_id (order_id);更进一步如果经常按order_id查且只查部分字段如product_id,quantity建覆盖索引ADD INDEX idx_order_cover (order_id, product_id, quantity)让查询完全走索引不回表。4.4 现象LocalDateTime存入 MySQL 后时区错乱显示比实际早 8 小时原因MySQL 服务器时区是SYSTEM即系统时区而 JDBC 连接串没指定serverTimezoneGMT%2B8驱动默认按 UTC 解析LocalDateTime。解决修改application.yml中的 JDBC URLspring: datasource: url: jdbc:mysql://localhost:3306/shop?useUnicodetruecharacterEncodingUTF-8serverTimezoneGMT%2B8allowPublicKeyRetrievaltrueuseSSLfalse同时确保 MySQL 服务端时区正确SELECT global.time_zone, session.time_zone;若为SYSTEM检查系统timedatectl status是否为Asia/Shanghai。5. 数据验证与压力摸底用 3 个脚本看清系统真实承压能力5.1 用 Python 脚本模拟真实用户行为链路光跑单元测试不够得看服务在真实 HTTP 请求下的表现。写一个simulate_user.py模拟一个用户完成「登录→加购→下单→支付回调」全流程# simulate_user.py import requests import time import random BASE_URL http://localhost:8080 def login(): r requests.post(f{BASE_URL}/auth/login, json{phone: 13800138000, password: 123456}) return r.json()[data][token] def add_to_cart(token, product_id): headers {Authorization: fBearer {token}} r requests.post(f{BASE_URL}/cart/add, json{productId: product_id, quantity: 1}, headersheaders) return r.status_code 200 def create_order(token): headers {Authorization: fBearer {token}} r requests.post(f{BASE_URL}/order/create, headersheaders) return r.json().get(data, {}).get(id) def mock_pay_callback(order_id): # 模拟支付平台回调 r requests.post(f{BASE_URL}/pay/callback, json{ payNo: fPAY{order_id}TEST, orderId: order_id, amount: 99.00 }) return r.text SUCCESS if __name__ __main__: token login() print(✅ 登录成功) # 随机加购 3 个商品 for pid in random.sample([1, 2, 3, 4, 5], 3): if add_to_cart(token, pid): print(f✅ 加购商品 {pid}) time.sleep(0.1) order_id create_order(token) print(f✅ 下单成功订单ID: {order_id}) if mock_pay_callback(order_id): print(✅ 支付回调成功)作用这个脚本不是为了压测而是为了验证端到端链路是否真通。很多同学本地跑单元测试全绿但一调 HTTP 接口就 401JWT 解析失败、400JSON 字段名大小写不一致、500NPE。它帮你把「能编译」和「能跑通」之间的鸿沟显性化。5.2 用 sysbench 对 MySQL 进行基础压测定位慢 SQL 元凶Spring Boot 日志里QueryTimeoutException频发别急着加机器先用sysbench看看是 SQL 本身慢还是连接池不够# 1. 准备数据10万行商品 sysbench oltp_read_write --mysql-hostlocalhost --mysql-port3306 \ --mysql-userroot --mysql-password123456 --mysql-dbshop \ --tables1 --table-size100000 prepare # 2. 压测 select 查询模拟首页商品列表 sysbench oltp_read_only --mysql-hostlocalhost --mysql-port3306 \ --mysql-userroot --mysql-password123456 --mysql-dbshop \ --tables1 --table-size100000 --threads32 --time60 run # 3. 观察输出中的 queries performed 和 latency (ms,95%)若 latency 200ms说明 SQL 或索引有问题关键参数说明--threads32模拟 32 并发用户--time60压测 60 秒latency (95%)是核心指标——95% 的请求响应时间低于该值。若超过 200ms立刻SHOW PROCESSLIST看慢查询或打开 MySQL 慢日志SET GLOBAL slow_query_log ON; SET GLOBAL long_query_time 0.1;。5.3 用 JMeter 验证订单状态机确保 UNPAID→PAID 不可逆状态机错误是课程设计最隐蔽的 bug。写一个 JMeter 脚本专门测试「已支付订单能否被恶意回调改成未支付」Thread Group 设置 10 个线程循环 1 次HTTP Request调用/pay/callbackBody Data 为{payNo:PAY123TEST,orderId:123,amount:99.00}添加「BeanShell PostProcessor」提取响应文本若包含SUCCESS则继续否则报错添加「View Results Tree」人工检查每次响应关键一步在 MySQL 中手动将订单 123 的 status 改为PAID再运行脚本——预期结果是日志里看到订单123状态为PAID拒绝重复回调且数据库 status 仍为PAID。这个测试的价值在于它不测性能而测业务规则的鲁棒性。很多同学只测「正向流程」却忘了攻击者会伪造回调参数。用 JMeter 把「负向用例」变成可重复执行的自动化步骤是工程素养的分水岭。6. 答辩前最后一关如何用 3 分钟讲清你的技术决策与边界认知答辩不是复述代码而是展示你作为工程师的思考纵深。我一般会用「问题-解法-代价」三段式结构直击评委最关心的三个点6.1 为什么不用 Redis 缓存商品信息问题首页商品列表查询频繁MySQL 压力大。解法评估后决定暂不引入 Redis。原因有三一是课程设计目标是夯实单体架构基本功加缓存会模糊事务、一致性等核心矛盾二是当前商品数 1000MySQL 走idx_status_stock索引QPS 300 完全够用三是引入 Redis 带来新问题——缓存穿透查不存在商品、缓存雪崩大量 key 同时过期、缓存一致性更新 DB 后怎么删缓存这些超出课程范围。代价牺牲了理论上的高并发能力但换来了代码路径清晰、问题可追踪、调试不跨组件。6.2 为什么库存用stocklock_stock双字段而不是乐观锁 version问题防止超卖。解法version乐观锁在「读-改-写」链路长时容易失败重试而购物车场景中用户可能停留几分钟才下单重试成本高。lock_stock是空间换时间用一个字段记录「已被预占但未确认」的量下单时原子扣减stock并释放lock_stock失败时只需回滚lock_stock。代价多维护一个字段增加UPDATE语句复杂度但换来确定性——只要lock_stock stock就绝不会超卖。6.3 为什么支付回调用 MySQL 唯一索引做幂等而不是 Redis SETNX问题第三方支付可能重复推送。解法uk_pay_no_callback索引天然支持幂等且与订单主表强绑定无额外组件、无网络开销、无 TTL 过期风险。SETNX需要考虑 key 过期时间太短怕回调延迟太长怕占内存还要处理 Redis 故障降级逻辑。代价callback_times字段看似冗余但它让幂等性成为数据库 schema 的一部分而不是一段易出错的业务代码。最后我会说一句“这个系统不是生产可用的商城而是一份可验证、可推演、可质疑的技术契约——每一行 SQL 都有它的锁粒度每一个Transactional都有它的传播行为每一次状态变更都有它的前置条件。它不完美但它的缺陷是透明的它的边界是清晰的。”希望帮到你。本文还有配套的精品资源点击获取