☰
JavaWeb商城下单事务与库存防超卖:Eclipse+Tomcat+MySQL实战方案
2026/10/9 3:41:39 网站建设 项目流程

简介:面向JavaWeb初学者的在线购物商城项目,基于Eclipse环境开发,涵盖商品浏览、购物车管理、用户登录注册等基础电商流程,适合学习Servlet、JSP、JSTL及MVC分层结构的读者作为实践与二次开发起点。资源打包为zip压缩包,大小8.52MB,共1122个文件,其中包含41个Java源文件、18个JSP页面、42个class文件,以及大量HTML、CSS、JavaScript和图片素材,另有SQL脚本可辅助搭建数据库,目录结构清晰,便于按功能模块查阅。目前已有2078人学习下载,适合刚接触JavaWeb或希望快速上手Eclipse商城项目的开发者参考。通过阅读源码可理解购物车基于Session或数据库的实现方式、JDBC数据交互流程,以及前端页面与后台Servlet的协作机制;同时项目预留了功能扩展空间,可在此基础上加入订单处理、支付接口或用户评论,是巩固JavaWeb基础与提升实战能力的实用素材。

1. Eclipse 里的 JavaWeb 商城购买:难点不在页面,在事务和库存

不少人在 Eclipse 里打开一个 JavaWeb 商城项目,商品列表能显示、注册能通、登录能进,偏偏栽在“购买”这一步:点完下单按钮页面白屏,刷新一下订单重复了,或者两个人同时抢最后一件商品,库存直接变负数。这不是页面没写好的问题,而是购买链路里的数据一致性和并发控制没有落地。这篇文章讲的是一套可以照着做的完整方案:怎么用 Eclipse 把网上商城项目跑起来,最小需要哪几张表,下单代码怎么分层,事务和库存锁怎么加,以及跑老项目时 Eclipse 和 Tomcat 的常见坑。适合两类人:一是做 JavaWeb 课设或毕设、手里有现成源码但打不开的人;二是刚接手老商城项目、被下单和库存问题卡住的新人。这篇文章不聊商品图怎么选,只把“买得到、买不错、买不超”这条链路上的关键步骤讲清楚。

2. 搭一套能跑通购买的 JavaWeb 环境:Eclipse、Tomcat 与 MySQL 的选型和导入步骤

2.1 为什么用 Eclipse 跑 JavaWeb 商城:适配老源码的选型理由

网上能找到的 JavaWeb 商城完整案例,绝大多数是从 Eclipse 导出的 Dynamic Web Project,目录结构是 WebContent/WEB-INF,而不是 Maven 的 src/main/webapp。用 IDEA 导入这种老项目,常见问题是 WebContent 被当成普通资源目录,部署后 JSP 永远 404;改用 Eclipse 的 File → Import → Existing Projects into Workspace,它能直接识别 .project 和 .classpath,一步到位。所以如果你的目标是把“Eclipse 商城、网上购物”这类老源码跑起来,最省事的做法就是回到 Eclipse 本身,别在这个环节纠结 IDE 新旧。

版本选型上,我一般会选 JDK 8 + Tomcat 8.5 + MySQL 5.7:老商城源码的 Servlet API 几乎都是 javax.servlet 命名空间,Tomcat 8.5 对应 Servlet 3.1,兼容性最稳。如果你电脑上已经装了 Eclipse Temurin JDK 21,也不是不能跑,但要注意两条线:老源码只能在 Tomcat 9 及以下跑,因为 Tomcat 10 把 javax.servlet 换成了 jakarta.servlet,老项目的 import 全部会编译失败。与其花半天把源码里的包名批量替换,不如按老组合来配。实在想用新 JDK,就把 Tomcat 升到 10.1,同时改 import,但这属于额外工作量,不建议在第一步就挑战。

这里给一张老源码迁移的选型对比表:

组合Servlet API老源码迁移成本适合场景
JDK 8 + Tomcat 8.5javax.servlet零成本,直接跑课设、毕设、老项目维护
JDK 11 + Tomcat 9.0javax.servlet零成本,需确认依赖 JAR 兼容老项目升级运行环境
JDK 21 + Tomcat 10.1jakarta.servlet要批量改 import,部分 JAR 要换新写项目,不推荐接手老源码

2.2 导入商城项目并配好 Tomcat:三步让页面在浏览器里出来

先在 Eclipse 里配置服务器运行时:Window → Preferences → Server → Runtime Environments,点 Add,选 Apache Tomcat v8.5,然后指向 Tomcat 解压目录。这里有个容易踩的细节:Tomcat 解压路径最好不要带中文和空格,比如不要放在“D:\新建文件夹\tomcat”,否则启动时找不到 Bootstrap 类的概率很高。配完后在 Server 视图新建一个 Server,右键项目选择 Add and Remove 把商城项目加进去,然后右键 Server 点 Start。

导入项目本身很简单:File → Import → General → Existing Projects into Workspace,选择项目根目录,记得勾选 Copy projects into workspace。勾选复制的意思是把源码拷贝到工作空间副本,之后你删掉原目录也不影响 Eclipse 里的工程;不勾选则是引用原目录,源码包在下载目录里被清理,项目就跟着废了。导入后先检查一件事:打开 WebContent/WEB-INF/web.xml 是否存在。很多老商城项目的过滤器、字符编码处理都写在 web.xml 里,缺了它页面还能显示,但登录态、中文编码会出各种怪问题。

web.xml 的最小骨架一般是这样的:

<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd" version="3.1"> <display-name>shop-cart</display-name> <welcome-file-list> <welcome-file>index.jsp</welcome-file> </welcome-file-list> </web-app>

这个文件里最关键的是 version="3.1",它对应 Tomcat 8.5 的 Servlet 3.1 规范。如果你把 version 写成 2.5,项目会被当成老规范运行,部分注解(@WebServlet、@WebFilter)会不生效。所以导入老项目后,先看 web.xml 头部的 version,再决定要不要改。最后在浏览器访问 http://localhost:8080/项目名/index.jsp,能出页面,说明环境就通了一半。

老项目里数据库驱动 JAR 一般放在 WebContent/WEB-INF/lib 下。如果导入后发现项目没有这个 lib 目录,或者里面是空的,就要手动加 mysql-connector-java 的 JAR;放到 lib 后 Eclipse 会自动加进 Build Path。这一步不做,后面连接数据库必然报 ClassNotFoundException,别在这里省时间。

2.3 最小可用的商城购买表结构:建表 SQL 与字段设计

购买链路最小需要四张表:用户表、商品表、订单表、订单明细表。购物车可以不建表,先放在 Session 里,这是多数 JavaWeb 商城案例的做法,单机演示完全够用;如果要做多实例部署,再考虑把购物车搬进 Redis 或数据库。很多完整案例的 SQL 脚本里表很多,但购买这个动作真正依赖的就这四张,先把它建对,再去补积分、优惠券都不迟。

CREATE DATABASE shop CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE shop; CREATE TABLE t_user ( user_id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_product ( product_id INT PRIMARY KEY AUTO_INCREMENT, product_name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, KEY idx_stock (product_id, stock) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_order ( order_id BIGINT PRIMARY KEY, user_id INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_order_user (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE t_order_item ( item_id INT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, product_id INT NOT NULL, product_name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL, KEY idx_item_order (order_id), CONSTRAINT fk_item_order FOREIGN KEY (order_id) REFERENCES t_order(order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

几个字段设计里的关键选择说下原因。金额一律用 DECIMAL(10,2),不要用 FLOAT 或 DOUBLE:浮点累计会有精度误差,订单金额差 0.01 元在财务对账时非常难查。order_id 用 BIGINT 且不依赖数据库自增,因为下单时要先拿到订单号再去插明细表,用自增还得额外查询一次。status 用 TINYINT 表示订单状态,约定 0 待支付、1 已支付、2 已取消,比存字符串“PAID”省空间、也更好写判断。t_product 里的 version 字段是给乐观锁准备的,第 4 章会用到;如果你导入的老源码建表脚本里没有这列,用 ALTER TABLE t_product ADD COLUMN version INT NOT NULL DEFAULT 0; 补上即可,不影响其他代码。

所有表引擎必须用 InnoDB,不要用 MyISAM。购买这个场景的底线是事务和行锁,MyISAM 不支持,实战中并发一上来就超卖。表结构建好后,往 t_user 插一条 id=1 的测试用户,往 t_product 插两三件商品,先不写注册登录,直接拿 userId=1 测购买流程,能省掉一大半调试时间。

3. 把购买链路写通:CartItem、OrderServlet 到 OrderDAO 的三层代码

3.1 从商品页到购物车:CartItem 与 CartServlet 的数据流

商品列表页上每个商品放一个“加入购物车”按钮,提交时只带两个参数:productId 和 quantity。页面不要传价格,价格必须到后端从数据库重新取,这是防止篡改价格的第一道闸。购物车这层可以先做一个 CartItem 模型,把一个商品和购买数量绑在一起:

public class CartItem { private Product product; private int quantity; public CartItem(Product product, int quantity) { this.product = product; this.quantity = quantity; } public BigDecimal getSubtotal() { return product.getPrice().multiply(BigDecimal.valueOf(quantity)); } // getter / setter 略 }

Product 类里就是最普通的四个字段:productId、productName、price、stock,对应 t_product 表。购物车用什么结构存?我一般会用 Map<Integer, CartItem> 而不是 List ,key 是 productId。原因是同一个商品加入两次购物车时,Map 直接找到已有对象把数量加起来,不用每次遍历;JSP 上渲染时也方便,直接循环 map.values() 就能得到一行一个商品的列表。

加入购物车的 Servlet 接收参数并写 Session:

@WebServlet("/cart/add") public class CartServlet extends HttpServlet { protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { int productId = Integer.parseInt(req.getParameter("productId")); int quantity = Integer.parseInt(req.getParameter("quantity")); HttpSession session = req.getSession(); Map<Integer, CartItem> cart = (Map<Integer, CartItem>) session.getAttribute("cart"); if (cart == null) { cart = new HashMap<>(); session.setAttribute("cart", cart); } CartItem item = cart.get(productId); if (item == null) { ProductDAO dao = new ProductDAO(); Product product = dao.findById(productId); if (product == null) { resp.sendError(404, "商品不存在"); return; } item = new CartItem(product, quantity); } else { item.setQuantity(item.getQuantity() + quantity); } cart.put(productId, item); resp.sendRedirect(req.getContextPath() + "/cart/list.jsp"); } }

这里有个容易被忽略的细节:第二次加购时没有重新查数据库,商品价格用的是 Session 里第一次放进去的值。商品如果中途调价,购物车会显示旧价格,但真实下单时第 3.3 节的 Service 会再次从数据库取价覆盖。购物车显示价格只是参考,订单金额以结算那一刻的数据库价格为准,这个原则在做商城时一定要守住。Session 默认超时 30 分钟,用户挑了半天再去结账发现购物车空了,这是演示项目的常见尴尬,可以在 web.xml 里把 session-timeout 调到 60。

3.2 下单入口:OrderServlet 编排参数与页面跳转

购物车页点“去结算”后,请求进 OrderServlet。这个 Servlet 做的事很薄:从 Session 取购物车,调 Service,成功清空购物车并跳成功页,失败把错误带回购物车页。

@WebServlet("/order/create") public class OrderServlet extends HttpServlet { protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { HttpSession session = req.getSession(); Map<Integer, CartItem> cart = (Map<Integer, CartItem>) session.getAttribute("cart"); if (cart == null || cart.isEmpty()) { resp.sendRedirect(req.getContextPath() + "/cart/list.jsp"); return; } int userId = (Integer) session.getAttribute("userId"); try { OrderService service = new OrderService(); long orderId = service.createOrder(userId, cart); session.removeAttribute("cart"); resp.sendRedirect(req.getContextPath() + "/order/success.jsp?orderId=" + orderId); } catch (Exception e) { req.setAttribute("error", e.getMessage()); req.getRequestDispatcher("/cart/list.jsp").forward(req, resp); } } }

成功时用 sendRedirect 而不是 forward,是为了防止用户在成功页按 F5 刷新导致重复提交订单。失败时用 forward 把 error 带回购物车页,因为错误信息放在 request 域,重定向会丢失。另外注意 userId 是从 Session 取的,下单接口绝不能相信前端传的 userId,否则改个参数就能代客下单。

3.3 OrderService 与 OrderDAO:下单的五步编排

OrderService 是购买链路的核心,它负责把“校验库存、算总价、写订单、写明细、扣库存”五步串起来。先给一个能把流程跑通的版本,事务问题留到第 4 章处理:

public class OrderService { private ProductDAO productDAO = new ProductDAO(); private OrderDAO orderDAO = new OrderDAO(); public long createOrder(int userId, Map<Integer, CartItem> cart) { BigDecimal total = BigDecimal.ZERO; for (CartItem item : cart.values()) { Product product = productDAO.findById(item.getProduct().getProductId()); if (product == null || product.getStock() < item.getQuantity()) { throw new RuntimeException("库存不足:" + item.getProduct().getProductName()); } item.setProduct(product); total = total.add(product.getPrice() .multiply(BigDecimal.valueOf(item.getQuantity()))); } long orderId = OrderIdGenerator.next(); orderDAO.insertOrder(orderId, userId, total); for (CartItem item : cart.values()) { orderDAO.insertOrderItem(orderId, item.getProduct().getProductId(), item.getProduct().getProductName(), item.getProduct().getPrice(), item.getQuantity()); } for (CartItem item : cart.values()) { productDAO.deductStock(item.getProduct().getProductId(), item.getQuantity()); } return orderId; } }

逻辑上这个版本是对的:先校验再写主表,再写明细,最后扣库存。但注意每次 DAO 方法内部都是各连各的数据库连接,每个操作独立自动提交。也就是说,如果扣库存成功、写明细失败,这一单就变成“库存少了但订单不完整”。这就是第 4 章要重点解决的问题。

再看 DAO 的写法。插入订单主表时,order_id 由应用生成,DAO 不需要查自增值:

public class OrderDAO { private static final String INSERT_ORDER = "INSERT INTO t_order (order_id, user_id, total_amount, status) VALUES (?, ?, ?, 0)"; private static final String INSERT_ORDER_ITEM = "INSERT INTO t_order_item (order_id, product_id, product_name, price, quantity) VALUES (?, ?, ?, ?, ?)"; private static final String DEDUCT_STOCK = "UPDATE t_product SET stock = stock - ? WHERE product_id = ? AND stock >= ?"; public void insertOrder(long orderId, int userId, BigDecimal totalAmount) throws SQLException { try (Connection conn = DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD); PreparedStatement ps = conn.prepareStatement(INSERT_ORDER)) { ps.setLong(1, orderId); ps.setInt(2, userId); ps.setBigDecimal(3, totalAmount); ps.executeUpdate(); } } public void deductStock(int productId, int quantity) throws SQLException { try (Connection conn = DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD); PreparedStatement ps = conn.prepareStatement(DEDUCT_STOCK)) { ps.setInt(1, quantity); ps.setInt(2, productId); ps.setInt(3, quantity); int rows = ps.executeUpdate(); if (rows == 0) { throw new RuntimeException("库存不足或商品已下架"); } } } }

insertOrderItem 方法同理,参数顺序对应表字段:order_id、product_id、product_name、price、quantity。注意 DEDUCT_STOCK 这个 UPDATE 带了 AND stock >= ? 条件,这是扣库存的第一道保险:即使前面校验慢了,并发下库存不够时受影响行数为 0,直接抛异常,不会把库存扣成负数。这个细节在很多老项目里是没有的,拿到别人源码时建议先看扣库存 SQL 有没有这个条件。

4. 购买最容易翻车的地方:事务边界与库存防超卖的三种写法

4.1 为什么下单必须手动控事务,而不是靠自动提交

第 3.3 节那个能跑通的版本有一个致命问题:五个操作五个独立连接,自动提交。假设订单表插入成功、明细表插入失败,异常抛到 Servlet 后整个请求失败,但订单主表和已扣减的库存已经各自提交,库存在没有订单的情况下少了。这就是网上商城项目里最常见的一类脏数据。

正确的原则是:下单这个业务动作必须是一个数据库事务,要么全部成功提交,要么全部回滚。JDBC 层面的做法是让所有 DAO 方法接收同一个 Connection 对象,在 Service 层统一控制提交与回滚,而不是让每个 DAO 自己去 DriverManager.getConnection。这个连接由调用方创建并关闭,Service 只负责事务边界。

4.2 悲观锁 FOR UPDATE 的下单实现

把 3.3 的 Service 改成事务版本,连接由 Service 层创建并传给所有 DAO:

public long createOrderWithLock(Connection conn, int userId, Map<Integer, CartItem> cart) throws Exception { conn.setAutoCommit(false); try { BigDecimal total = BigDecimal.ZERO; for (CartItem item : cart.values()) { Product product = productDAO.findByProductIdForUpdate(conn, item.getProduct().getProductId()); if (product == null || product.getStock() < item.getQuantity()) { throw new RuntimeException("库存不足:" + item.getProduct().getProductName()); } item.setProduct(product); total = total.add(product.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); } long orderId = OrderIdGenerator.next(); orderDAO.insertOrder(conn, orderId, userId, total); for (CartItem item : cart.values()) { orderDAO.insertOrderItem(conn, orderId, item.getProduct().getProductId(), item.getProduct().getProductName(), item.getProduct().getPrice(), item.getQuantity()); } for (CartItem item : cart.values()) { productDAO.deductStock(conn, item.getProduct().getProductId(), item.getQuantity()); } conn.commit(); return orderId; } catch (Exception e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); } }

关键改动有两点。第一,所有 DAO 方法都多了一个 Connection 参数,内部不再自己建连接;第二,校验商品库存时用的不是普通 select,而是 select ... for update:

public Product findByProductIdForUpdate(Connection conn, int productId) throws SQLException { String sql = "SELECT product_id, product_name, price, stock FROM t_product " + "WHERE product_id = ? FOR UPDATE"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, productId); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { return new Product(rs.getInt("product_id"), rs.getString("product_name"), rs.getBigDecimal("price"), rs.getInt("stock")); } } } return null; }

FOR UPDATE 的作用是:事务里查这一行时,直接给商品记录加行级排他锁,其他事务再对同一行做 FOR UPDATE 或 UPDATE 会阻塞,直到当前事务提交或回滚才继续。所以并发买同一件商品时,第二个请求会在查询阶段卡住,等第一个请求把库存扣完、事务提交,它读到的 stock 已经是扣减后的,库存不足就抛异常回滚。这样从“校验库存”到“扣库存”中间不会有其他人插入。

悲观锁最典型的坑是死锁。如果一个订单里有多个商品,两个用户同时买 A 和 B,第一个请求锁定顺序是 A 再 B,第二个是 B 再 A,两边都等对方释放锁,MySQL 会检测到死锁并把其中一个事务回滚。解决办法是在循环里先按 productId 排序再逐件加锁,全站统一加锁顺序,死锁概率基本归零。

4.3 乐观锁与悲观锁的取舍:小商城项目用哪一把锁

如果你不想在 Service 层手动维护 Connection,或者担心 FOR UPDATE 长时间持锁,可以用乐观锁。乐观锁依赖 t_product 表的 version 字段:

UPDATE t_product SET stock = stock - ?, version = version + 1 WHERE product_id = ? AND stock >= ? AND version = ?

UPDATE 返回受影响行数:stock 不足或 version 被改动,行数为 0,说明这次扣减失败,整个事务回滚让用户重新下单。乐观锁不需要 select for update,并发压力不大时性能和体验都好。但注意一个隐藏问题:MySQL 默认 update 返回的是“匹配行数”而不是“变更行数”,只有 JDBC 连接串或驱动层面开启 useAffectedRows=true 时,返回的才是真正被改动的行数。用 MyBatis 时也要确认数据库连接的 useAffectedRows 配置,否则两个并发请求同时更新同一行,第二个版本号不匹配但受影响行数仍可能返回 1。

方案核心机制适用场景主要坑
悲观锁select ... for update,事务提交前锁行单体部署、库存紧张的商品持锁时间长,多商品要按序加锁
乐观锁version 字段控制更新条件并发竞争低、重试成本低依赖受影响行数返回值
Redis 分布式锁外部锁服务多实例部署、微服务要处理锁过期和释放问题

小商城单体部署,我建议直接上悲观锁。逻辑直观,不用处理乐观锁重试和 useAffectedRows 的配置差异;只要有 InnoDB 事务托底,FOR UPDATE 的阻塞时间在这个量级的并发下完全可接受。Redis 分布式锁是给集群部署准备的,单机项目引入 Redis 反而把链路变长,出了锁超时问题更难排查。

5. Eclipse 跑商城避坑与排查:从 Tomcat 启动到 MySQL 连接的 4 个高频坑

5.1 找不到或无法加载主类 org.apache.catalina.startup.Bootstrap

现象:Eclipse 里启动 Tomcat 报错,控制台直接打出 java.lang.ClassNotFoundException: org.apache.catalina.startup.Bootstrap,或者弹出“Server Tomcat v8.5 Server at localhost failed to start”的对话框,看不到具体日志。

原因按出现频率排:第一,Server Runtime 里配置的 Tomcat 路径不对,比如指向了安装程序的解压目录、目录里没有 bin/bootstrap.jar;第二,Tomcat 路径带中文或空格,导致类路径解析失败;第三,项目上右键运行时用的 JRE 和 Tomcat 要求的 JRE 不一致,Eclipse 找不到 Tomcat 类自然加载不了主类。

解决步骤:Window → Preferences → Server → Runtime Environments,删掉现有配置,重新 Add 一个 Apache Tomcat v8.5,选择干净的 Tomcat 解压根目录,不要选 bin 目录。确认该目录下有 bin/bootstrap.jar 和 bin/tomcat-juli.jar。然后在 Server 视图停掉所有 Server,右键 Server → Delete,重新新建一个 Server 并把项目加进去再看。

5.2 JDK 版本引发的一连串玄学问题:Temurin 21 跑老商城该不该换

现象:你装了 Eclipse Temurin JDK 21,项目编译时报 javax.servlet 包不存在,或者 Tomcat 能启动但某些 JSP 编译失败。这两类问题看起来是环境坏了,实际是版本选错了。

原因:绝大多数 JavaWeb 商城源码用的是 javax.servlet 命名空间,而 Tomcat 10 开始已经切到 jakarta.servlet。JDK 21 本身不背锅,是 Tomcat 10.1 配 JDK 21 后,老源码里的 import javax.servlet.* 全部失效。

解决:要么把运行组合调回 JDK 8 或 11 + Tomcat 8.5/9.0,老源码不用改任何 import;要么坚持 JDK 21,把 Tomcat 升到 10.1 并把所有 javax.servlet 改成 jakarta.servlet。注意 javax 到 jakarta 的替换不只是 servlet:很多老项目用了 JSTL,JSTL 依赖的 JAR 也要换对应版本。如果只是想把这个商城项目跑起来,选前者。改 JDK 时要在 Eclipse 里检查三个位置保持一致:Installed JREs 里当前项目用的 JRE、Project Properties → Java Compiler 的 compliance level、Project Properties → Targeted Runtimes 选中的 Tomcat。三个位置不一致就会出现那种“配置文件都对但就是启动不了”的玄学问题。

5.3 MySQL 驱动类找不到与时区错误:连接串与 lib 位置

现象:运行项目点击登录或下单,页面报 ClassNotFoundException: com.mysql.jdbc.Driver;换了新版驱动后报 The server time zone value 'XXX' is unrecognized。

原因:第一,mysql-connector-java 的 JAR 没有放进 WebContent/WEB-INF/lib,或者放进去后没刷新 Build Path;第二,驱动版本和数据库版本不匹配,MySQL 5.7 用老驱动没问题,MySQL 8.0 用 5.1 的老驱动会连不上,因为 MySQL 8 默认认证插件是 caching_sha2_password,老驱动不支持;第三,MySQL 8 的驱动类名已经变成 com.mysql.cj.jdbc.Driver,还要在连接串里带 serverTimezone。

解决:统一用 5.1.49 的 mysql-connector 连 MySQL 5.7,或用 8.x 驱动连 MySQL 8.0,连接串写成:

jdbc:mysql://localhost:3306/shop?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai

JAR 放到 WebContent/WEB-INF/lib 后再确认一下项目 Properties → Deployment Assembly 里有没有带上这个目录。很多时候本地编译不报错是因为项目在开发环境里能找到 JAR,但部署到 Tomcat 时没有打进 WebContent/WEB-INF/lib,运行起来才炸。

5.4 中文乱码:JSP 页面、请求参数和 Tomcat 三处一起查

现象:商品名、收货地址显示成问号,搜索框输入中文提交后变成乱码。乱码问题最坑的地方在于它不是一处错的,而是多处编码不一致叠加的结果。

排查顺序固定:先看 JSP 页面文件本身编码,文件属性 Text file encoding 设为 UTF-8,页面头部的 pageEncoding 也必须是 UTF-8;再看请求参数编码,POST 提交的情况下,在第一个 getParameter 调用之前执行一次 request.setCharacterEncoding("UTF-8"),最干净的做法是写一个 CharacterEncodingFilter,在 web.xml 里配置成对所有 URL 生效,顺序放在最前面:

public class CharacterEncodingFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding("UTF-8"); chain.doFilter(req, resp); } }

最后看 Tomcat 的连接器编码。Tomcat 8 及以上版本 GET 请求默认就是 UTF-8,但老项目迁移过来的 Tomcat 配置里可能改过,稳妥起见在 server.xml 的 Connector 上加上 URIEncoding:

<Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" URIEncoding="UTF-8" />

数据库侧也要一起查:连接串带 characterEncoding=utf8,表结构用 utf8mb4。四层全部统一成 UTF-8 后,中文乱码基本绝迹。如果还有问题,用浏览器开发者工具看响应头 Content-Type 有没有 charset=UTF-8,别只怀疑 Java 代码。

6. 从能下单到买得稳:订单号生成、幂等与压测的小技巧

6.1 订单号生成与下单幂等

前面代码里 OrderIdGenerator.next() 是个占位,这里给一个在单体商城够用的生成器:时间到秒的 14 位数字加 4 位自增序列,拼成一个 18 位的 long 型订单号。

public class OrderIdGenerator { private static final AtomicLong SEQ = new AtomicLong(0); public static long next() { String timestamp = DateTimeFormatter.ofPattern("yyyyMMddHHmmss") .format(LocalDateTime.now()); long seq = SEQ.incrementAndGet() % 10000; return Long.parseLong(timestamp + String.format("%04d", seq)); } }

同一秒内最多生成 10000 个订单号不重复,单机商城完全够用。订单号有了之后,再给下单接口加一层幂等处理:前端提交订单时带一个唯一的请求 token,t_order 表加一列 source_token,对 user_id + source_token 做唯一索引,重复提交时数据库会拒绝第二条订单。这个防重复的效果比在后端判断“当前用户是否已有未支付订单”要可靠得多。

6.2 压测前先看三个参数

下单代码改完事务和锁之后,别急着上生产,先用 JMeter 或干脆写个多线程脚本把库存顶穿测一轮。压测前把这三个参数调好。

MySQL 的 innodb_lock_wait_timeout 默认 50 秒,压测场景下调到 5 秒,死锁或锁等待能立刻暴露出来,不用等半天;Tomcat 连接数默认 200,对这个量级的商城够用,不用追求调大;压测结束后检查三样东西:t_product 里库存是否等于 0 且没有负数,t_order 订单数和 t_order_item 明细数是否对得上,有没有订单存在但明细缺失。这三样查完,事务和行锁到底有没有生效就一目了然。

我自己第一次压商城库存时翻过一次车:库存设置 100,并发压了 500 个请求,最后库存变成 -80,订单表却只有 300 多条。后来定位就是扣库存 SQL 没写 AND stock >= ?,校验和扣减之间也隔着独立连接,并发一上来就穿透了。从那以后我每次写下单代码,都会固定检查三件事:是不是同一个 Connection、有没有锁住库存行、扣库存是否判断受影响行数。这个检查顺序比任何架构图都好用,希望帮到你。

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

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

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

立即咨询