简介:一套基于Java Servlet/JSP与MySQL构建的网上购物商城项目源码,定位为Java Web课程设计或入门实践项目,适合在校学生和自学者通过真实案例掌握MVC架构、JDBC数据库操作、用户会话管理以及商品与订单核心流程。压缩包共57个文件,包含17个Java源码、22个class编译结果、6个XML配置、2个jar依赖包,以及数据库脚本和运行说明txt,整体约1.93MB,目录组织清晰,便于按模块阅读与调试。配套的数据库设计说明与从下载到运行的详细步骤,能有效降低环境搭建门槛,帮助理解前后端交互、事务处理、文件上传、Cookie/Session和SQL注入防护等典型Web开发要点。它也可作为课堂作业、毕业设计或第一份企业级Web项目的参考模板,帮助学习者在动手过程中建立完整的开发思维。目前已有1387人学习下载,是轻量而完整的实战资料。
1. 为什么网上购物商城还是该从Java+MySQL单体干起
在微服务和新式数据库满天飞的今天,听到“用Java+MySQL写网上购物商城”,第一反应往往是课程设计。可仔细拆开看,电商的项目规模不论多大,落到最后都要回答四个问题:用户是谁、卖什么、订单怎么生成、库存怎么保证不变错。Java+MySQL这对组合,恰好把前三个问题的业务编排和最后一个问题的数据一致性都包圆了。Java负责线程模型和业务规则,MySQL负责持久化和隔离级别,两者之间用JDBC或ORM接起来。这个方案可以小到单机启动,也可以大到按业务模块拆服务后底层仍然不换。这套组合适合两类人:一类是准备走Java后端方向、想完整走通一次商城系统的初中级开发;另一类是技术栈不在Java体系里、但需要快速了解Java和MySQL怎么协作的老手。
2. 商城数据模型与Java实体映射:六张表把业务锁死
做一个能跑的购物商城,我从来不先写Service,而是先把MySQL的表结构和Java实体定下来。数据模型一旦错,后面的SQL越写越痛苦。这里假设本地MySQL和Java环境变量已经按常规流程配置完毕,后面所有SQL都能直接复用。
2.1 从零建库建表:用户、商品、购物车、订单要分开
先建库,生产环境直接用utf8mb4。然后建六张表,以下是最小可用版本。
CREATE DATABASE IF NOT EXISTS shop DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE shop; CREATE TABLE `user` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE, `password` VARCHAR(64) NOT NULL COMMENT 'BCrypt哈希', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB; CREATE TABLE `category` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `name` VARCHAR(50) NOT NULL ) ENGINE=InnoDB; CREATE TABLE `product` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `category_id` BIGINT NOT NULL, `title` VARCHAR(200) NOT NULL, `price` DECIMAL(10,2) NOT NULL, `stock` INT NOT NULL DEFAULT 0, `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1上架 0下架', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY `idx_category_id` (`category_id`) ) ENGINE=InnoDB; CREATE TABLE `cart_item` ( `user_id` BIGINT NOT NULL, `product_id` BIGINT NOT NULL, `quantity` INT NOT NULL DEFAULT 1, PRIMARY KEY (`user_id`, `product_id`) ) ENGINE=InnoDB; CREATE TABLE `orders` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL UNIQUE, `user_id` BIGINT NOT NULL, `total` DECIMAL(10,2) NOT NULL, `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0待付款 1已付款 2已发货 3已完成 4已取消', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY `idx_user_created` (`user_id`, `created_at`) ) ENGINE=InnoDB; CREATE TABLE `order_item` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL, `product_id` BIGINT NOT NULL, `title_snapshot` VARCHAR(200) NOT NULL, `price_snapshot` DECIMAL(10,2) NOT NULL, `quantity` INT NOT NULL, KEY `idx_order_no` (`order_no`) ) ENGINE=InnoDB;这段DDL里有几个点值得说。order_no不设成整数自增,而用业务字符串,是为了避免在分表或异步消息里依赖数据库自增。order_item里保留了title_snapshot和price_snapshot,这是订单快照,商品标题和价格改了就导致历史订单不可复现,这是没做过商城的人最容易漏的。订单表里放冗余查询索引idx_user_created,因为用户查自己的订单列表是最常见的请求,按用户ID加时间倒序走索引就行,不必回表太多。
另外注意cart_item的主键是(user_id, product_id),这表示同一用户对同一商品的历史行为会被合并,如果需求是允许同一商品存在多个勾选记录,那这个主键就要改成自增ID。我把购物车设计成一张关系表而不是大宽表,原因是购物车属性确实只有“谁、什么商品、多少件”,再加别的字段就变成订单项了,职责不清。
我刻意没有建任何物理外键。商城核心表在高并发写入时,物理外键会让MySQL在插入order_item时去持有orders的共享锁,锁竞争比应用层做一致性校验高得多。外键约束删掉,由Java事务来保证引用关系成立。
2.2 Java实体与MySQL字段的类型对应关系
MySQL表结构定完之后,再去写Java实体就是机械工作,但仍有三类对应容易错:DATETIME对应LocalDateTime,不要用java.util.Date;DECIMAL(10,2)对应BigDecimal,不要用double,金额用浮点会在打折和税费计算时出现精度漂移;TINYINT可以映射成Integer或Boolean,但状态字段我建议用Integer,不要用Boolean,因为状态很可能从0/1扩展成更多值。
以商品为例:
@Data public class Product { private Long id; private Long categoryId; private String title; private BigDecimal price; private Integer stock; private Integer status; private LocalDateTime createdAt; }这段代码最值得留意的不是注解,而是BigDecimal和Integer。数据库里的DECIMAL用BigDecimal接收后,计算价格时直接调用add、multiply,不能走+和*;stock用Integer接收后,扣库存时要注意旧值和新值的更新时间,否则读到脏数据。
对应关系可以整理成一张常用表,我一般贴在团队Wiki里。
| MySQL | Java | 备注 |
|---|---|---|
| BIGINT | Long | ID、user_id |
| VARCHAR | String | 用户名、订单号 |
| DECIMAL(10,2) | BigDecimal | 金额字段 |
| INT | Integer | 数量、状态 |
| DATETIME | LocalDateTime | 创建时间、更新时间 |
| TINYINT(1) | Boolean | 仅当只有0/1两种取值 |
实体不是写出来就完事了。商城里的用户实体不能原样返回到前端,得有UserVO。实体类只承载数据库字段,user表里的password字段在JSON序列化时必须标记忽略,否则用户列表接口会把哈希密码带出去。我见过不止一次因为实体直接返回导致的密码哈希泄露,这个坑在Java面试八股文里不会讲,但线上一定会找你。
2.3 商品类目和订单明细的层级关系怎么建模
商品和类目是一对多,一张product表通过category_id指向category表。这里没必要把类目做成无限级表,用parent_id自关联确实很通用,但对于一个中小型商城,两级类目用parent_id=0表示根类目就够了,再多级对查询没有好处,只会让category表成为递归查询的重灾区。
订单和商品是多对多,但多对多不能直接用中间表了事,因为订单需要保存下单那一刻的商品快照。所以在order_item中记录order_no和product_id,并提供标题、价格、数量字段。这样在渲染订单详情时,不需要再去product表实时join,历史价格也不会被商品改价污染。
到这里,MySQL侧的准备已经完成。接着要解决的问题是:Java代码怎么和MySQL高效地对话,而不是每条请求都去getConnection。
3. 用JDBC与连接池让Java和MySQL跑通商品查询
直接上ORM框架是现在的主流,但把原生JDBC先跑通,后面看ORM的SQL日志才不至于心里没底。这一章用一套可复制的商品查询代码说明Java与MySQL的最短链路。
3.1 从JDBC开始:PreparedStatement才是正确姿势
我一般先写一个最小可用的JDBC查询,确认驱动和MySQL的连接串没问题。
public Product findById(Long id) { String url = "jdbc:mysql://127.0.0.1:3306/shop?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai"; String user = "root"; String password = "your_password"; String sql = "SELECT id, category_id, title, price, stock, status, created_at " + "FROM product WHERE id = ?"; try (Connection conn = DriverManager.getConnection(url, user, password); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setLong(1, id); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { Product p = new Product(); p.setId(rs.getLong("id")); p.setCategoryId(rs.getLong("category_id")); p.setTitle(rs.getString("title")); p.setPrice(rs.getBigDecimal("price")); p.setStock(rs.getInt("stock")); p.setStatus(rs.getInt("status")); return p; } } } catch (SQLException e) { throw new RuntimeException("商品查询失败", e); } return null; }这段代码里连接串上的三个参数是长期踩坑总结:useUnicode=true&characterEncoding=utf8保证中文不乱码,serverTimezone指定时区,否则Java 8以后的LocalDateTime会报时区异常。PreparedStatement的?占位符不只是写法问题,参数会被MySQL服务端当纯数据而不是SQL片段,等于从根上断掉拼接式注入。
try-with-resources用了两层,外层是连接和Statement,内层是ResultSet,这样无论哪一层出现异常,资源都会按逆序自动关闭。注意别在这里提前调rs.close(),上面的查询结果还没拿完就关,会报“Operation not allowed after ResultSet closed”。
3.2 连接池参数怎么配置:HikariCP和MySQL的兼容细节
只要请求量超过个位数,DriverManager.getConnection就会成为瓶颈。每次新建连接都包含TCP三次握手和MySQL认证握手,这一步在不走连接池时能占到查询耗时的30%以上。我现在的做法是直接用HikariCP,Spring Boot默认就带它,但裸JDBC项目里也可以单独使用。
HikariConfig config = new HikariConfig(); config.setJdbcUrl("jdbc:mysql://127.0.0.1:3306/shop?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai"); config.setUsername("root"); config.setPassword("your_password"); config.setMaximumPoolSize(10); config.setMinimumIdle(2); config.setConnectionTimeout(3000); config.setIdleTimeout(600000); config.setMaxLifetime(1800000); HikariDataSource dataSource = new HikariDataSource(config);从MySQL架构看,每个数据库连接在后端就是一个线程,连接池如果设置得过大,MySQL的线程调度成本反而会拖慢查询。这里maximumPoolSize=10的参考公式是CPU核心数×2+1,如果我的机器是4核,取值10基本合适。各参数并不是越大越好,常用取值范围可以看下面这张表。
| 参数 | 推荐值 | 说明 |
|---|---|---|
| connectionTimeout | 3000 | 等连接超过3秒直接失败,避免请求堆积 |
| validationTimeout | 1000 | 连接池检测连接是否可用的最大等待时间 |
| idleTimeout | 600000 | 空闲10分钟后回收连接 |
| maxLifetime | 1800000 | 必须小于MySQL wait_timeout,默认8小时 |
| maximumPoolSize | CPU核数×2+1 | 连接数不盲目追求大 |
连接池就位后,之前的findById只需要把DriverManager.getConnection换成dataSource.getConnection,其余代码不变。这带来的收益是:一个连接重复使用,省掉了握手开销,同时HikariCP会检测MySQL断开的连接,不会再出现凌晨跑批时拿到一个死连接。
3.3 商品列表怎么分页:LIMIT的代价和参数边界
商城首页的商品列表和商品搜索都不能全量返回,最常见的SQL是:
SELECT id, title, price, stock FROM product WHERE status = 1 ORDER BY id DESC LIMIT 20, 20;LIMIT 20, 20的含义是跳过20条取20条,偏移量越大,MySQL需要扫描并丢弃的行数越多。当你翻到第10000条时,即使有status索引,执行计划也会先扫过10000个主键再丢掉前9980个,这是深分页问题的起点。Java侧接收分页参数时,我还会强制校验page_size <= 100,避免有人用LIMIT 0, 999999把MySQL内存打满。
在这里先不深入优化,先把这一版跑通。真正大量数据下的优化方案,我放到最后一章用EXPLAIN再演示。
4. 订单扣库存:MySQL事务、锁与Java重试的配合
商城项目最不能出错的链路,归纳起来是三件事:扣库存、写订单、改订单状态。这三件事必须在一个数据库事务里完成,或者用同一把锁约束。下面用一条商品抢购的简化版来说明边界。
4.1 先锁库存还是先建订单
常见的错误写法是先INSERT订单,再UPDATE库存。如果库存不足,订单已经插进去了,虽然可以回滚,但在高并发下会出现大量无效的插入和回滚日志膨胀。我一般会先把库存锁住,拿到真实库存后再决定是否生成订单。
过程如下:
- 开启事务。
SELECT ... FOR UPDATE锁定商品行。- 判断当前库存是否大于等于购买数量,不满足则回滚。
- 执行
UPDATE product SET stock = stock - ? WHERE id = ?。 - 插入
orders表,插入order_item表。 - 提交事务。
对应SQL脚本可以直接在MySQL客户端里跑一遍验证:
START TRANSACTION; SELECT id, stock FROM product WHERE id = 1001 FOR UPDATE; UPDATE product SET stock = stock - 1 WHERE id = 1001 AND stock > 0; INSERT INTO orders (order_no, user_id, total, status) VALUES ('202506010001', 10001, 1999.00, 0); INSERT INTO order_item (order_no, product_id, title_snapshot, price_snapshot, quantity) VALUES ('202506010001', 1001, '机械键盘', 1999.00, 1); COMMIT;SELECT ... FOR UPDATE用的是当前读,它读到的一定是最新已提交的数据,并且在事务提交前阻止其他事务对这个商品行加写锁。这行SELECT没有全表扫描,因为product.id是主键,锁会落在具体的聚簇索引记录上。如果id不是主键且没有索引,InnoDB会退化成锁全表,下单接口就会变成所有商品的串行瓶颈。
注意:
SELECT ... FOR UPDATE必须命中索引,否则 InnoDB 会把锁升级为表锁。
这里还有一个容易被忽略的细节:步骤4的UPDATE要带上AND stock > 0,让数据库再做一次原子校验。否则单靠Java判断库存再UPDATE,中间还是存在并发窗口。加了stock > 0条件后,影响行数为0就说明库存已经被抢空,可以直接回滚。
订单号不要在Java里用UUID直接拼,太长还无序,我一般用雪花算法生成,或者用“日期+Redis自增”拼成20位左右的可读字符串,插入时走unique索引也能更快。
4.2 Java侧怎么处理锁超时和死锁
悲观锁不是无限制等待的,MySQL默认innodb_lock_wait_timeout是50秒,一个下单请求等锁50秒肯定不现实。我会先在数据库连接上把它改小,再用Java捕获异常。
SET SESSION innodb_lock_wait_timeout = 5;然后Java侧捕获JDBC里的死锁异常:
public void createOrder(OrderRequest request) { for (int attempt = 0; attempt < 3; attempt++) { try (Connection conn = dataSource.getConnection()) { conn.setAutoCommit(false); // 此处放入4.1的SQL序列 conn.commit(); return; } catch (SQLException e) { if (isDeadlockOrLockTimeout(e) && attempt < 2) { try { conn.rollback(); } catch (SQLException ignored) {} } else { throw new RuntimeException("下单失败", e); } } } }这里的循环重试上限是3次,每次失败都先回滚,把之前的行锁释放掉,然后再重新获取连接。判断isDeadlockOrLockTimeout时,我看的是MySQL错误码:1213表示死锁,1205表示锁等待超时。这两个错误不一定是数据出错,更像是并发请求之间的锁碰撞,所以值得重试。
4.3 用乐观锁做库存校验:version列和更新行数
悲观锁适合单机、热点少的场景。如果不想让事务期间一直持锁,可以给product表加一列version INT NOT NULL DEFAULT 0,改成乐观锁。
UPDATE product SET stock = stock - 1, version = version + 1 WHERE id = 1001 AND stock > 0 AND version = 3;如果更新行数是0,说明版本号已经变化或者库存不足,Java抛异常后可以让用户重新选条件下单。乐观锁不需要SELECT ... FOR UPDATE,读的仍是快照数据,写时校验版本号。它比悲观锁更轻,但同一商品超卖时只能靠重试,热点商品的更新成功率会偏低。
这两种锁在Java并发压测下的差异很明显。我压测时会用CountDownLatch让所有线程同时起跑,然后await等待全部执行完成,最后统计库存总量。如果库存总量不为负数,说明没有超卖;如果为负,再回头看是版本号没判断还是FOR UPDATE没生效。
int threadCount = 20; CountDownLatch ready = new CountDownLatch(threadCount); CountDownLatch done = new CountDownLatch(threadCount); for (int i = 0; i < threadCount; i++) { executor.execute(() -> { ready.countDown(); try { ready.await(); orderService.createOrder(createRequest(1L, 1)); } finally { done.countDown(); } }); } done.await();这段代码模拟20个用户同时抢一件商品。ready.await()让所有工作线程先站在同一起跑线,等ready归零后同时执行下单;done.await()则让主线程等待所有下单线程都完成后再统计库存。这个过程能筛掉很多只有并发时才出现的错误,比如扣库存SQL没有stock > 0条件,一次就会压成负数。
4.4 事务隔离级别和锁参数怎么定
MySQL InnoDB默认隔离级别是REPEATABLE READ,这个对商城下单是够用的。用READ COMMITTED能降低间隙锁的影响,但需要注意订单表里可能出现的幻读场景:同一个用户两次查待付款订单,第一次2条第二次3条,在REPEATABLE READ下不会发生,在READ COMMITTED下可能发生。
所以我的原则很简单:订单核心链路保持默认隔离级别,不为了微小的并发收益去改全局设置。需要改的只有innodb_lock_wait_timeout、innodb_deadlock_detect这两个参数。事务里的SQL只访问必要行,不要在一个事务里先查category又查product又查user,行锁范围越大,死锁概率越高。
5. 慢查询定位:从MySQL日志到Java线程栈的一次排查
最后给一个商城上线后最常见的排障动作:用户说首页加载慢,但不知道是Java在等锁还是MySQL在扫表。先用MySQL日志圈出慢SQL,再用Java线程栈确认调用方。
5.1 打开MySQL慢查询日志
在MySQL客户端执行下面三条语句,能让超过1秒的SQL被记录下来。
SET GLOBAL slow_query_log = ON; SET GLOBAL long_query_time = 1; SET GLOBAL log_output = 'TABLE';log_output=TABLE表示慢SQL会落到mysql.slow_log表里,排查时直接SELECT,省去登录服务器看日志文件。记录几条后,把slow_query_log关掉,因为日志表本身也会产生写放大。
5.2 用EXPLAIN查看SQL是否走了索引
如果首页商品列表慢,先把这条SQL单独拿出来:
SELECT p.id, p.title, p.price FROM product p WHERE p.status = 1 ORDER BY p.sales_count DESC LIMIT 20, 20;执行EXPLAIN后,重点看type列。如果出现ALL,说明在扫全表;出现index,说明在扫整棵索引树;出现ref或range,说明查询走了索引的某一个取值范围,这个方向基本合理。再看rows列,它估算扫描行数,如果显示100000而实际页面只有20条,就要考虑索引优化。
EXPLAIN SELECT p.id, p.title, p.price FROM product p WHERE p.status = 1 ORDER BY p.sales_count DESC LIMIT 20, 20;常见做法是给product建一个KEY idx_status_sales (status, sales_count),让WHERE过滤和排序都在同一个索引里完成。注意索引字段顺序,先等值条件status,再排序列sales_count。顺序反过来时,MySQL可能只能用到status前缀,排序依然要filesort。
5.3 深分页改用延迟关联
第3章提到的深分页问题,在商城后台订单导出时最明显。翻到第100页时的写法不能再用大LIMIT,我一般把SQL改造成延迟关联:
SELECT p.id, p.title, p.price, p.stock FROM product p INNER JOIN ( SELECT id FROM product WHERE status = 1 ORDER BY sales_count DESC LIMIT 1980, 20 ) t ON p.id = t.id;子查询只查id,不查大字段,扫描的宽度变小,分页偏移大部分发生在索引页上。MySQL先快速定位到第1980条到第2000条的主键,再回表取完整字段。这条SQL改完,同样的接口在数据量从十万涨到百万时,响应时间不会从100毫秒翻到10秒。
排查到这里还没完,如果SQL已经走索引但接口还是慢,就要在Java侧用jstack抓线程栈:
jstack -l 12345 > thread_dump.txt在thread_dump.txt里搜索java.sql和连接池相关的类名,看线程是卡在socketRead还是lock。卡在socketRead说明Java在等MySQL返回,多半是SQL长时间占用行锁;卡在UNPARK说明线程在等待HikariCP发连接,这时不用查MySQL,直接调大maximumPoolSize。抓到线程卡点后,按“连接池是否耗尽、SQL是否跑全表、事务是否持锁太久”这个顺序往下查,商城慢请求基本都能定位到具体一行。
本文还有配套的精品资源,点击获取