ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Java+MySQL商城开发实战:从数据模型到并发扣库存

Java+MySQL商城开发实战:从数据模型到并发扣库存 简介JavaMySQL实现的网上购物商城是一份Web开发学习型项目资源面向正在学习Java后端与数据库原理的开发者可用于毕业设计、课程实训或框架入门练习。项目涵盖Servlet/JSP请求处理、MVC分层设计、JavaBeans数据封装以及MySQL表结构设计、SQL联表查询与事务管理等关键环节适合有基本Java语法基础、想理解完整开发链路的读者。压缩包共57个文件大小仅1.93MB主要包括17个Java源码与22个编译后的class文件另有XML配置、TXT说明及JAR依赖其中的文本说明对环境搭建和数据库初始化有直接帮助。附带指导文件与清晰目录结构能帮助读者从代码层面拆解购物车、订单、用户登录等模块快速走通项目部署与二次开发。当前已有1387人学习下载可作为系统性复习和动手实践的参考资料。1. 从商品到订单JavaMySQL 商城仍然是个硬需求一套 JavaMySQL 实现的网上购物商城不是只出现在课程设计和毕业设计里的老古董。中小型电商、企业内购、区域生鲜平台在起步阶段比起一上来就拆微服务、上 Redis 和消息队列用一个单体 Java 应用加 MySQL 就能把商品、购物车、订单、库存、用户这五条主链路完整撑起来。这个标题的关键不在于「能不能做出来」而在于「正确地把电商的核心约束表达在数据库层面」比如库存不能被超卖、订单一旦生成就不能丢、金额计算不能浮点误差。新手可以照着把项目跑通有经验的开发也能从数据模型、事务边界、锁的选择里看出这套方案的取舍。后续迁移拆分时业务边界也已经清晰不会因为早期图省事而返工。2. 核心数据模型SKU/SPU、订单状态与 MySQL 索引设计网上购物商城最怕的不是功能多而是表结构一上来就画成一张大宽表。商品、规格、库存、订单混在一起后半程每个需求都要动表结构。常见做法是把「商品」拆成 SPU 和 SKU 两层订单与商品之间再加订单明细表做快照这一节把建表 SQL 直接给出来并按电商场景聊索引怎么建。2.1 为什么商品模型必须拆 SPU 和 SKUSPUStandard Product Unit是商品聚合比如「iPhone 15 Pro」SKUStock 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;两个关键决定需要展开说明。第一金额用INT类型存「分」而不是用DECIMAL之外的浮点类型更不能用DOUBLEJava 侧用int或long与之对应彻底避开二进制浮点的 0.1 精度问题。第二order_item里的price_cents是下单时刻的商品价快照不是实时读sku.price_cents这样后续改价不影响历史订单的对账和统计这也是电商表结构的基本功。2.3 订单状态机与订单号生成订单状态不要用自由字符串用TINYINT配合枚举类统一管理。状态机设计决定了后续退款、取消、超时关闭的落点。状态码状态含义允许流转到0待支付1 已支付、4 已取消1已支付2 已发货2已发货3 已完成3已完成无或进入售后流程4已取消无表里没有给订单创建时间之外的时间字段支付时间、发货时间生产环境建议补上pay_time、ship_time、finish_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 里的两条关键 SQLselect idselectForUpdate resultTypeSku SELECT id, title, price_cents, stock_count FROM sku WHERE id #{id} FOR UPDATE /select update iddecreaseStock UPDATE sku SET stock_count stock_count - #{count} WHERE id #{skuId} /updateSELECT ... 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必须显式指定默认配置只对RuntimeException和Error回滚受检异常不触发回滚。业务层自定义的BizException如果继承自Exception而不是RuntimeException不写rollbackFor就会验库存失败后照样提交订单。事务自调用是最隐蔽的坑。同类里this.createOrder()或this.addCart()直接调用带事务注解的方法代理不生效整个执行链路没有事务边界。解决方式是拆成两个类通过 Spring 注入的代理对象调用或者把事务注解放在 Controller 调用入口的 Service 方法上内部私有方法不标注。另外事务中查到的实体不要直接返回给前端转成 DTO 或 VO 后再输出。否则事务提交后 JSON 序列化期间访问懒加载字段或者把包含version、password_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 ResultVoid add(RequestAttribute Long userId, PathVariable Long skuId, RequestParam(defaultValue 1) Integer count) { cartService.add(userId, skuId, count); return Result.ok(); } DeleteMapping(/{skuId}) public ResultVoid remove(RequestAttribute Long userId, PathVariable Long skuId) { cartService.remove(userId, skuId); return Result.ok(); } GetMapping public ResultListCartView 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_item与sku的连接查询注意分页与排序下推的写法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 CookieSession 存在服务器内存或 MySQL 会话表里结构简单但集群部署时要引入spring-session共享。前后端分离项目则大量使用 JWT无状态签发userId从 Token 中解析但注销与封禁变得被动需要维护黑名单。商城场景下用户对「退出登录后 Token 立即失效」的预期很强我一般会倾向服务端 Session即便前后端分离也可以通过Authorization头装 Session ID。JWT 适合读多写少的开放接口不适合需要立即吊销凭证的购物与结算链路。这个判断和框架无关是产品需求决定的。5. 上线前验证EXPLAIN、慢查询日志与并发压测技巧JavaMySQL 商城的代码写完只是第一步数据库层面的验证才决定能否扛住真实流量。这一章给出一套上线前最值得做的三项验证方式并用一个并发脚本直接检验防超卖是否生效。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应当达到ref或range如果出现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按并发峰值与单请求平均耗时估算不要盲目调大minimumIdle同maximumPoolSize避免冷启动时连接池重建抖动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 里是索引记录锁条件能用到唯一索引就是行锁条件走不上索引就会退化为锁表下单接口直接变成全站串行。这个细节比任何大招都更能保护你的商城系统。本文还有配套的精品资源点击获取
返回列表