Java+MySQL商城开发实战:从数据模型到并发扣库存
2026/9/16 6:13:28 网站建设 项目流程

简介:Java+MySQL实现的网上购物商城是一份Web开发学习型项目资源,面向正在学习Java后端与数据库原理的开发者,可用于毕业设计、课程实训或框架入门练习。项目涵盖Servlet/JSP请求处理、MVC分层设计、JavaBeans数据封装,以及MySQL表结构设计、SQL联表查询与事务管理等关键环节,适合有基本Java语法基础、想理解完整开发链路的读者。压缩包共57个文件,大小仅1.93MB,主要包括17个Java源码与22个编译后的class文件,另有XML配置、TXT说明及JAR依赖;其中的文本说明对环境搭建和数据库初始化有直接帮助。附带指导文件与清晰目录结构,能帮助读者从代码层面拆解购物车、订单、用户登录等模块,快速走通项目部署与二次开发。当前已有1387人学习下载,可作为系统性复习和动手实践的参考资料。

1. 从商品到订单,Java+MySQL 商城仍然是个硬需求

一套 Java+MySQL 实现的网上购物商城,不是只出现在课程设计和毕业设计里的老古董。中小型电商、企业内购、区域生鲜平台在起步阶段,比起一上来就拆微服务、上 Redis 和消息队列,用一个单体 Java 应用加 MySQL 就能把商品、购物车、订单、库存、用户这五条主链路完整撑起来。这个标题的关键不在于「能不能做出来」,而在于「正确地把电商的核心约束表达在数据库层面」,比如库存不能被超卖、订单一旦生成就不能丢、金额计算不能浮点误差。新手可以照着把项目跑通,有经验的开发也能从数据模型、事务边界、锁的选择里看出这套方案的取舍。后续迁移拆分时,业务边界也已经清晰,不会因为早期图省事而返工。

2. 核心数据模型:SKU/SPU、订单状态与 MySQL 索引设计

网上购物商城最怕的不是功能多,而是表结构一上来就画成一张大宽表。商品、规格、库存、订单混在一起,后半程每个需求都要动表结构。常见做法是把「商品」拆成 SPU 和 SKU 两层,订单与商品之间再加订单明细表做快照,这一节把建表 SQL 直接给出来,并按电商场景聊索引怎么建。

2.1 为什么商品模型必须拆 SPU 和 SKU

SPU(Standard Product Unit)是商品聚合,比如「iPhone 15 Pro」;SKU(Stock Keeping Unit)是具体可售卖单元,比如「iPhone 15 Pro 原色钛金属 256G」。库存、价格、图片必须挂在 SKU 上而不是 SPU 上,否则不同规格无法独立设库存和定价。购物车、订单明细、扣库存操作全部指向 SKU ID,这也是后续所有并发的关键粒度。

很多刚接触商城项目的人把商品规格直接塞进一个goods表的spec字段,查询和统计倒是简单了,但一到「按颜色过滤」「按尺寸过滤」「某个规格单独补货」就束手无策。ERP、WMS、财务对账也都按 SKU 展开,模型不拆,后续根本接不住。这个分层不是过度设计,是电商的基础共识。

2.2 核心表结构 DDL:金额用整数分,订单要有独立订单号

下面这套 SQL 覆盖用户、SKU、订单、订单明细、购物车五张核心表,直接复制到 MySQL 8.0 即可运行。

CREATE TABLE IF NOT EXISTS `user` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `username` VARCHAR(64) NOT NULL, `password_hash` VARCHAR(128) NOT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS `sku` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `spu_id` BIGINT NOT NULL, `title` VARCHAR(128) NOT NULL, `attrs` VARCHAR(255) NOT NULL DEFAULT '', -- 规格属性,如 {"color":"原色","storage":"256G"} `price_cents` INT NOT NULL, -- 单价,单位:分 `stock_count` INT NOT NULL DEFAULT 0, `version` INT NOT NULL DEFAULT 0, -- 乐观锁版本号,扣库存时使用 PRIMARY KEY (`id`), KEY `idx_spu` (`spu_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS `orders` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL, -- 对外业务订单号,用于幂等和客服查单 `user_id` BIGINT NOT NULL, `status` TINYINT NOT NULL DEFAULT 0, -- 0待支付 1已支付 2已发货 3已完成 4已取消 `total_cents` INT NOT NULL, `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_status` (`user_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS `order_item` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `order_id` BIGINT NOT NULL, `sku_id` BIGINT NOT NULL, `buy_count` INT NOT NULL, `price_cents` INT NOT NULL, -- 下单时的价格快照,商品改价不影响历史订单 PRIMARY KEY (`id`), KEY `idx_order` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE IF NOT EXISTS `cart_item` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `user_id` BIGINT NOT NULL, `sku_id` BIGINT NOT NULL, `count` INT NOT NULL DEFAULT 1, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_user_sku` (`user_id`, `sku_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

两个关键决定需要展开说明。第一,金额用INT类型存「分」,而不是用DECIMAL之外的浮点类型,更不能用DOUBLE,Java 侧用intlong与之对应,彻底避开二进制浮点的 0.1 精度问题。第二,order_item里的price_cents是下单时刻的商品价快照,不是实时读sku.price_cents,这样后续改价不影响历史订单的对账和统计,这也是电商表结构的基本功。

2.3 订单状态机与订单号生成

订单状态不要用自由字符串,用TINYINT配合枚举类统一管理。状态机设计决定了后续退款、取消、超时关闭的落点。

状态码状态含义允许流转到
0待支付1 已支付、4 已取消
1已支付2 已发货
2已发货3 已完成
3已完成无(或进入售后流程)
4已取消

表里没有给订单创建时间之外的时间字段(支付时间、发货时间),生产环境建议补上pay_timeship_timefinish_time三个DATETIME,便于统计履约时效。订单号生成建议单独做一个IdGenerator,用「时间戳 + 用户ID后几位 + 随机数」的结构,保证 32 个字符内唯一;不要依赖数据库自增主键对客展示,自增主键会暴露单量,也不适合跨库迁移。

2.4 索引设计:先按查询需求反推,再走 EXPLAIN 验证

user.username建唯一索引是防重复注册的兜底。orders表上除了订单号唯一索引,还要建(user_id, status)复合索引,因为「我的订单列表」是商城最高频的查询。cart_item上的(user_id, sku_id)唯一索引承担双重职责:一是防止同一用户重复加同一 SKU 产生多行脏数据,二是给「加购时数量累加」提供ON DUPLICATE KEY UPDATE的判定条件。

MySQL 索引这里有一个常见误用:习惯性给每个字段单独建索引,然后以为查询能自动组合。实际上查询只有一个字段条件过滤时单索引有用,多字段条件要建复合索引,且最左前缀原则决定字段顺序——把等值查询字段放前面,范围查询字段放后面。比如WHERE user_id = ? AND status = ? ORDER BY created_at DESC,复合索引可以按(user_id, status, created_at)建,让排序也能利用索引,避免Using filesort

3. 用 Java 实现下单与扣库存:事务边界和并发控制是核心

商城最容易被问进java面试八股文里的环节就是下单。加购物车只是写库,下单要同时完成校验库存、扣减库存、生成订单、写入明细四件事,任何一步失败都必须整体回滚。更麻烦的是多个用户同时买同一个 SKU,如何在 MySQL 层面保证不超卖。这一节给出完整实现和三种方案对比。

3.1 下单服务的最小可运行实现

以 Spring Boot + MyBatis 为例,事务注解加在 Service 方法上,Controller 只负责参数接收与响应封装。

@Service public class OrderService { @Resource private SkuMapper skuMapper; @Resource private OrderMapper orderMapper; @Resource private OrderItemMapper orderItemMapper; @Transactional(rollbackFor = Exception.class) public OrderResult createOrder(Long userId, Long skuId, Integer buyCount) { // 1. 当前读,锁定该 SKU 行,防止其他事务同时扣减 Sku sku = skuMapper.selectForUpdate(skuId); if (sku == null) { throw new BizException("商品不存在"); } if (sku.getStockCount() < buyCount) { throw new BizException("库存不足"); } // 2. 扣减库存,行锁已持有,这里用普通 update 即可 int rows = skuMapper.decreaseStock(skuId, buyCount); if (rows == 0) { throw new BizException("库存不足"); } // 3. 生成业务订单号并插入订单主表 Order order = new Order(); order.setOrderNo(generateOrderNo(userId)); order.setUserId(userId); order.setStatus(OrderStatus.PENDING_PAY.getCode()); order.setTotalCents(sku.getPriceCents() * buyCount); orderMapper.insert(order); // 4. 插入订单明细,价格取当前 SKU 快照 orderItemMapper.insert(order.getId(), skuId, buyCount, sku.getPriceCents()); return OrderResult.of(order.getId(), order.getOrderNo()); } }

对应 Mapper 里的两条关键 SQL:

<select id="selectForUpdate" resultType="Sku"> SELECT id, title, price_cents, stock_count FROM sku WHERE id = #{id} FOR UPDATE </select> <update id="decreaseStock"> UPDATE sku SET stock_count = stock_count - #{count} WHERE id = #{skuId} </update>

SELECT ... FOR UPDATE是当前读,会在这条 SKU 记录上加行级排他锁,直到事务提交或回滚才释放。selectForUpdate之后的事务内任何读都拿到同一份最新数据,因此第 2 步的普通UPDATE是安全的。注意这个锁必须在事务内才生效,单独执行一条FOR UPDATE而事务不提交,锁不会释放,会让所有购买该商品的请求全部阻塞,直到连接超时。

3.2 防超卖的三种扣库存方案对比

不少项目直接把扣库存写成UPDATE sku SET stock_count = stock_count - 1 WHERE id = ?,不管影响行数,也不加锁。这在并发下会出问题:两个事务同时读到库存为 1,各自判断充足后都执行扣减,最终库存变成 -1。MySQL 默认隔离级别是REPEATABLE READ,普通SELECT是快照读,天然看到的是事务开始时的旧快照,所以「先查后扣」在并发下必然失效。

方案关键 SQL冲突时表现适用场景
无锁扣减UPDATE ... SET stock_count = stock_count - 1 WHERE id = ?可能超卖仅演示,不可上线
乐观锁UPDATE ... SET stock_count = stock_count - #{count} WHERE id = ? AND version = #{version}影响行数为 0,需重试或提示失败并发冲突低、读多写少
悲观锁SELECT ... FOR UPDATE后普通UPDATE事务排队等待秒杀、结算高峰期写冲突严重

乐观锁的 Java 侧重试逻辑通常这样写:循环最多 3 次,每次执行UPDATE,如果返回 0 就重新查询版本号再试;超过次数直接抛出「系统繁忙」。悲观锁的优点是冲突直接排队,缺点是持锁期间占用数据库连接,长事务会拖垮连接池,所以FOR UPDATE后面的业务逻辑要尽量短,只做必要的校验和插入,不要在锁内调用远程接口或等待第三方响应。

3.3 @Transactional 的边界与常见失效场景

@Transactional(rollbackFor = Exception.class)里的rollbackFor必须显式指定,默认配置只对RuntimeExceptionError回滚,受检异常不触发回滚。业务层自定义的BizException如果继承自Exception而不是RuntimeException,不写rollbackFor就会验库存失败后照样提交订单。

事务自调用是最隐蔽的坑。同类里this.createOrder()this.addCart()直接调用带事务注解的方法,代理不生效,整个执行链路没有事务边界。解决方式是拆成两个类,通过 Spring 注入的代理对象调用;或者把事务注解放在 Controller 调用入口的 Service 方法上,内部私有方法不标注。

另外,事务中查到的实体不要直接返回给前端,转成 DTO 或 VO 后再输出。否则事务提交后 JSON 序列化期间访问懒加载字段,或者把包含versionpassword_hash的内部字段暴露出去,都会成为线上事故。

3.4 重复下单的幂等兜底

用户双击「提交订单」或前端重试,会导致同一请求被执行两次,库存被扣两遍。常见做法是在进入 Service 前用requestId判重,更可靠的兜底是在数据库层加唯一约束。订单号生成时带上用户 ID 和时间戳,并在orders.order_no上建唯一索引,插入时捕获DuplicateKeyException,命中则回滚事务并返回「请勿重复提交」。

4. 购物车与会话保持:DB 持久化方案和 REST 接口设计

购物车是商城体验的中间层,既不能丢数据也不能太笨重。网上购物商城的购物车有两种主流实现:存 MySQL 或存 Redis,这个标题给定的技术栈下,默认用 MySQL 持久化即可,本文第 2 章的cart_item表就是为此设计的。Redis 方案的优势是读写快、天然带过期时间,但需要额外引入组件,还要解决「未登录购物车合并到已登录购物车」的数据迁移问题。

4.1 购物车选型:为什么 MySQL 持久化够用

cart_item以用户 ID 和 SKU ID 作为唯一键,加购时执行INSERT ... ON DUPLICATE KEY UPDATE count = count + VALUES(count),天然完成「加过就累加数量」的逻辑,不需要先查再更新,避免一次加购产生两条以上记录。用户清空购物车、下单后移除已购买项,都是针对这张表的标准操作。

MySQL 存储购物车的真正瓶颈不在读写量,而在会话未登录的场景。未登录用户的购物车只能依靠 Cookie 或临时标识,此时购物车内容存在浏览器本地更合理,登录后再一次性合并到服务端。中小商城如果强制登录才能加购,体验上损失不小,所以要按产品决策选型:允许游客加购,则购物车表增加session_key字段并在合并时清洗;仅限登录用户加购,直接依赖user_id即可。

4.2 购物车 REST 接口与分页查询

前端通过接口操作购物车,Controller 层只做参数与状态码转换,业务逻辑全部下沉到 Service。

@RestController @RequestMapping("/api/cart") public class CartController { @PostMapping("/{skuId}") public Result<Void> add(@RequestAttribute Long userId, @PathVariable Long skuId, @RequestParam(defaultValue = "1") Integer count) { cartService.add(userId, skuId, count); return Result.ok(); } @DeleteMapping("/{skuId}") public Result<Void> remove(@RequestAttribute Long userId, @PathVariable Long skuId) { cartService.remove(userId, skuId); return Result.ok(); } @GetMapping public Result<List<CartView>> list(@RequestAttribute Long userId, @RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "20") Integer pageSize) { return Result.ok(cartService.list(userId, page, pageSize)); } }

@RequestAttribute Long userId由登录过滤器在进入 Controller 前写入请求上下文,避免每个方法都从 Session 里手动取。列表中需要展示 SKU 的标题、价格和图片,涉及cart_itemsku的连接查询,注意分页与排序下推的写法:

SELECT c.sku_id, s.title, s.price_cents, c.count FROM cart_item c JOIN sku s ON s.id = c.sku_id WHERE c.user_id = #{userId} ORDER BY c.updated_at DESC LIMIT #{offset}, #{pageSize};

其中offset = (page - 1) * pageSize。这个查询有几点要说清楚:MySQL 排序默认不区分大小写且中文按字符集排序,如果前端需要按添加时间倒序,updated_at索引要作为二级索引存在;数据量到达百万行后,LIMIT深分页会产生回表,常见做法是先按主键排好序再分页,也就是 keyset 分页,把LIMIT offset, size换成WHERE updated_at < #{lastUpdatedAt} ORDER BY updated_at DESC LIMIT #{pageSize}

4.3 Session 与 JWT 的选择边界

登录态维护决定了购物车接口里userId从哪来。传统服务端渲染用HttpSession+ Cookie,Session 存在服务器内存或 MySQL 会话表里,结构简单,但集群部署时要引入spring-session共享。前后端分离项目则大量使用 JWT,无状态签发,userId从 Token 中解析,但注销与封禁变得被动,需要维护黑名单。

商城场景下用户对「退出登录后 Token 立即失效」的预期很强,我一般会倾向服务端 Session,即便前后端分离也可以通过Authorization头装 Session ID。JWT 适合读多写少的开放接口,不适合需要立即吊销凭证的购物与结算链路。这个判断和框架无关,是产品需求决定的。

5. 上线前验证:EXPLAIN、慢查询日志与并发压测技巧

Java+MySQL 商城的代码写完只是第一步,数据库层面的验证才决定能否扛住真实流量。这一章给出一套上线前最值得做的三项验证方式,并用一个并发脚本直接检验防超卖是否生效。

5.1 用 EXPLAIN 检查订单列表查询是否走索引

拿用户订单列表举例,MySQL 连接查询的性能问题通常出在驱动表和索引使用上。

EXPLAIN SELECT o.id, o.order_no, o.status, o.total_cents, i.sku_id, i.buy_count FROM orders o JOIN order_item i ON i.order_id = o.id WHERE o.user_id = 123 AND o.status = 0 ORDER BY o.id DESC LIMIT 20;

重点看两列:type应当达到refrange,如果出现ALL说明在扫全表;Extra出现Using filesort说明排序没有走索引。优化手段是把idx_user_status调整为(user_id, status, id),让ORDER BY o.id DESC也能从索引里取顺序,避免临时排序。这张表数据量小的时候看不出差异,压测数据灌到几十万行后再跑EXPLAIN才有意义。

5.2 开启 MySQL 慢查询日志定位业务慢 SQL

在新装 MySQL 实例上,先确认安装配置是否支持慢日志,再动态开启。

mysql -uroot -p SET GLOBAL slow_query_log = ON; SET GLOBAL long_query_time = 1; SHOW VARIABLES LIKE 'slow_query_log_file';

long_query_time单位是秒,线上建议从 1 秒开始压测观察,全部 SQL 都超过阈值就把阈值调高到 2 或 3,找相对慢的语句持续优化。注意SET GLOBAL在 MySQL 8.0 中不会持久化到配置文件,重启后失效,要永久开启需写入my.cnf。用 Navicat 或 MySQL Workbench 查slow_query_log_file指向的文件即可看到具体 SQL 与执行时长,不必额外搭监控平台。

5.3 HikariCP 连接池参数与长事务控制

Java 侧与 MySQL 的连接池参数常被忽略,但它直接决定高并发下数据库连接是否耗尽。HikariCP 是 Spring Boot 默认连接池,给出常用参数基准。

参数建议值说明
maximumPoolSize10~20按并发峰值与单请求平均耗时估算,不要盲目调大
minimumIdlemaximumPoolSize避免冷启动时连接池重建抖动
connectionTimeout30000 ms获取连接的超时时间,超过则抛异常
socketTimeout300000 ms防止 MySQLwait_timeout断开后客户端卡住
maxLifetime1800000 ms小于服务端wait_timeout,触发回收后重建

连接池数量不是越多越好,每个连接背后都是一个线程和一个 MySQL 线程。下单事务里使用FOR UPDATE时尤其要注意:锁等待占用的是连接池连接,长事务会让池子很快枯竭。所以锁内的代码要极简,不允许在锁内调用第三方 HTTP 接口。

5.4 用并发脚本验证防超卖是否真的生效

写完代码后,用一个 shell 脚本模拟 100 个并发扣减同一库存,验证最终库存余量与预期一致。

#!/bin/bash for i in $(seq 1 100); do mysql -umall -p123456 mall \ -e "START TRANSACTION; SELECT stock_count FROM sku WHERE id = 1 FOR UPDATE; UPDATE sku SET stock_count = stock_count - 1 WHERE id = 1 AND stock_count > 0; COMMIT;" & done wait echo "remaining_stock=$(mysql -umall -p123456 mall -N -e 'SELECT stock_count FROM sku WHERE id = 1')"

这个脚本的实际作用是验证FOR UPDATE的串行化效应:100 个并发事务对同一行加锁后,最终库存一定大于等于初始库存减 100,不会出现负数;如果去掉FOR UPDATE,并发热身下大概率出现超卖负值。生产环境的压测还要模拟完整下单链路,只测一条 SQL 不够,但这个最小脚本能在不引入压测工具的前提下快速验证数据库层的并发约束。

Excel 之外,一个值得养成的习惯是把所有UPDATE语句的WHERE条件写成能够走唯一索引的形式,比如WHERE id = ?而不是WHERE sku_id = ? AND title = ?。行锁的粒度在 MySQL InnoDB 里是索引记录锁,条件能用到唯一索引就是行锁,条件走不上索引就会退化为锁表,下单接口直接变成全站串行。这个细节比任何大招都更能保护你的商城系统。

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

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

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

立即咨询