news 2026/10/14 1:58:27

Spring Boot单体电商系统设计:事务、库存与幂等性实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 XML?

MyBatis-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 id="updateStatusIfUnpaid" parameterType="map"> UPDATE `order` SET status = #{newStatus}, updated_at = NOW() WHERE id = #{orderId} AND status = 'UNPAID' AND deleted = 0 </update>

提示:parameterType="map"是关键。它让方法签名可以是int updateStatusIfUnpaid(Map<String, 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):用户查「我的订单」按状态分页,避免 filesort
order_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/setter?

Lombok 的@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_id+product_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 id="mergeCart" parameterType="map"> 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.quantity
  • UPDATE product SET lock_stock = lock_stock + ? WHERE id = ? AND stock >= ?(注意:WHERE 条件包含库存判断,失败则整条 SQL 影响行为 0)
  • 插入order主表(status='UNPAID')
  • 插入order_item子表
  • 清空cart中对应商品

第二阶段(事务外):

  • 调用模拟支付接口(如http://localhost:8081/mock-pay)
  • 若支付接口返回 success,调用orderMapper.updateStatusIfUnpaid(orderId, "PAID")
  • 若支付接口超时或失败,启动定时任务扫描order.status='UNPAID' AND created_at < NOW()-30m,自动取消订单并释放lock_stock
// OrderService.java @Transactional(rollbackFor = Exception.class) public Order createOrder(Long userId) { // 1. 从 cart 读数据(SELECT ... FOR UPDATE 保证读一致性) List<Cart> 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是BIGINT,MySQL 默认排序规则是 B+ 树升序,没问题;但如果某次建表时手误写成(product_id, user_id),那么ON DUPLICATE就无法按user_id+product_id组合去匹配,导致冲突检测失效。
解决:

  • 执行SHOW CREATE TABLE cart,确认索引列顺序与代码中mergeCart的参数顺序严格一致;
  • 在单元测试中写并发加购用例(用CountDownLatch模拟 100 线程),断言cart表中COUNT(*)等于商品种类数,而非线程数。

4.3 现象:订单详情页查order_item很慢,EXPLAIN显示type=ALL(全表扫描)

原因: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 连接串没指定serverTimezone=GMT%2B8,驱动默认按 UTC 解析LocalDateTime。
解决:

  • 修改application.yml中的 JDBC URL:
    spring: datasource: url: jdbc:mysql://localhost:3306/shop?useUnicode=true&characterEncoding=UTF-8&serverTimezone=GMT%2B8&allowPublicKeyRetrieval=true&useSSL=false
  • 同时确保 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": f"Bearer {token}"} r = requests.post(f"{BASE_URL}/cart/add", json={"productId": product_id, "quantity": 1}, headers=headers) return r.status_code == 200 def create_order(token): headers = {"Authorization": f"Bearer {token}"} r = requests.post(f"{BASE_URL}/order/create", headers=headers) return r.json().get("data", {}).get("id") def mock_pay_callback(order_id): # 模拟支付平台回调 r = requests.post(f"{BASE_URL}/pay/callback", json={ "payNo": f"PAY{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 接口就 401(JWT 解析失败)、400(JSON 字段名大小写不一致)、500(NPE)。它帮你把「能编译」和「能跑通」之间的鸿沟显性化。

5.2 用 sysbench 对 MySQL 进行基础压测:定位慢 SQL 元凶

Spring Boot 日志里QueryTimeoutException频发?别急着加机器,先用sysbench看看是 SQL 本身慢,还是连接池不够:

# 1. 准备数据(10万行商品) sysbench oltp_read_write --mysql-host=localhost --mysql-port=3306 \ --mysql-user=root --mysql-password=123456 --mysql-db=shop \ --tables=1 --table-size=100000 prepare # 2. 压测 select 查询(模拟首页商品列表) sysbench oltp_read_only --mysql-host=localhost --mysql-port=3306 \ --mysql-user=root --mysql-password=123456 --mysql-db=shop \ --tables=1 --table-size=100000 --threads=32 --time=60 run # 3. 观察输出中的 "queries performed" 和 "latency (ms,95%)",若 latency > 200ms,说明 SQL 或索引有问题

关键参数说明:--threads=32模拟 32 并发用户;--time=60压测 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/callback,Body 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。原因有三:一是课程设计目标是夯实单体架构基本功,加缓存会模糊事务、一致性等核心矛盾;二是当前商品数 < 1000,MySQL 走idx_status_stock索引,QPS 300+ 完全够用;三是引入 Redis 带来新问题——缓存穿透(查不存在商品)、缓存雪崩(大量 key 同时过期)、缓存一致性(更新 DB 后怎么删缓存),这些超出课程范围。
代价:牺牲了理论上的高并发能力,但换来了代码路径清晰、问题可追踪、调试不跨组件。

6.2 为什么库存用stock+lock_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都有它的传播行为,每一次状态变更都有它的前置条件。它不完美,但它的缺陷是透明的,它的边界是清晰的。”

希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 1:57:44

Microsoft Edge 无法翻译/翻译失败的一种解决方法:修复 Edge

最近在使用 Microsoft Edge 浏览网页时遇到了一个问题&#xff1a; Edge 自带的网页翻译功能突然无法正常使用&#xff0c;一直提示“无法翻译”或者“翻译失败”。 浏览器本身可以正常使用&#xff0c;网页也能够正常打开&#xff0c;但翻译功能就是无法工作。 在尝试解决之…

作者头像 李华
网站建设 2026/10/14 1:57:18

Ghost误操作恢复全攻略:抢在二次写入前拯救数据

简介&#xff1a;当使用Ghost还原系统时误选目标分区&#xff0c;很可能导致整个硬盘的分区表损坏、数据“凭空消失”&#xff0c;这时掌握正确的恢复方法至关重要。这份图文教程以一位用户的真实误操作案例为切入点&#xff0c;面向遇到类似分区丢失问题的普通电脑用户与维护人…

作者头像 李华
网站建设 2026/10/14 1:55:46

SearXNG 搜索引擎配置优化实录

SearXNG 是自托管的元搜索引擎&#xff0c;聚合多个搜索源的结果。默认配置下启用约 350 个引擎&#xff0c;但大部分在当前网络环境下不可用。本文记录一次从 350 个引擎精简到 19 个的过程。 测试环境&#xff1a;ARMv7l&#xff0c;Debian trixie&#xff0c;SearXNG 4.0.0…

作者头像 李华