☰
JSP+MySQL网上图书购物系统:三层架构、事务与并发扣库存实战
2026/10/5 3:54:05 网站建设 项目流程

简介:一套基于JSP(MVC模式)与MySQL实现的网上图书购物系统,面向Java Web初学者、毕业设计及课程设计人群。项目完整覆盖图书展示、购物车、订单等核心流程,代码分层清晰,可作为实训或初期项目立项的参考资料。资源共76个文件,压缩包约47.8MB,含14个JSP页面、12个Java类、12个class文件及SQL脚本,另有jpg素材、CSS样式与演示录屏,便于从界面到数据库对照学习。目前已有136人学习下载。随包附带演示视频、README说明和数据库脚本(mobiledatabase.sql),可助理解前后端交互与建表逻辑。资源定位为参考项目而非定制成品,适合具备一定基础、能自行调试并扩展功能的学习者,借此梳理MVC分层、练习JSP+Servlet+MySQL整合开发。

1. 基于 JSP、(MVC 模式)和 MySQL 的网上图书购物系统:这套老组合为什么还能打

一套基于 JSP、(MVC 模式)和 MySQL 的网上图书购物系统,本质上还是教科书里那个经典三层:JSP 做视图、Servlet 当控制器、JavaBean/DAO 当模型,MySQL 负责落库。它的价值不在技术栈新,而在链路短、边界清楚:从商品列表到购物车再到下单减库存,一条请求链捋下来不用翻太多文件。这个方案适合三类人:急着交付毕设或课程设计的学生、需要给团队内部做图书资料库的后端工程师、以及想完整梳理一遍 JavaWeb 基础链路的新手。

这套做法最大的优点是“后悔药”好找。代码和数据库都在同一台机器上,出问题可以直接打印 SQL、翻 Tomcat 日志、用 MySQL 客户端查当前事务状态,定位起来比微服务那一堆组件直观得多。下面我从代码分层、表设计、核心链路、部署排错一路讲到收尾验证,把能直接抄作业的部分都放出来。

2. MVC 层先立住:代码目录、请求流转与两个关键类

2.1 三层职责和一条完整请求线

网上图书购物系统的 MVC 拆法,常见做法是让 Servlet 全面充当控制器,JSP 只做展示层,JavaBean 承担模型职责。模型再细分一层:实体类(Book、User、Cart)和 DAO(数据访问对象)分开,控制器不直接写 JDBC,否则三层很快就糊成一团。

一条典型请求线长这样:用户在前台点“加入购物车”,浏览器向/cart?action=add&bookId=3&qty=1发请求;Tomcat 根据@WebServlet("/cart")把请求交给 CartServlet;CartServlet 调用 BookDao 查书、把条目塞进购物车对象;随后控制器把请求转发到购物车 JSP 页面;JSP 从 request 域里取数据渲染成 HTML 返回浏览器。

这一条线里有几个容易被新手忽略的约束:JSP 不应该直接连数据库,Servlet 不应该在 doGet 里堆三四十行业务代码,DAO 里的 SQL 必须用 PreparedStatement 而不是拼字符串。遵不遵守这些约束,决定了你后期加一个“按分类筛书”功能时,是改一个方法还是把整段代码重写一遍。

2.2 实体 + DAO:最先落地的代码层

动手写页面之前,先把实体类和 DAO 敲定。实体类对应 book 表字段,DAO 提供列表查询、按 id 查询、扣库存这类原子操作。这段代码是整个项目的底座,后面所有功能都在它上面长。

// Book.java —— 对应 book 表的实体类 package com.example.bookstore.model; import java.math.BigDecimal; public class Book { private Integer id; private String title; // 书名 private String author; // 作者 private BigDecimal price; // 单价,用 BigDecimal 不用 double,避免金额精度问题 private Integer stock; // 库存 private String cover; // 封面图相对路径,如 /images/book1.jpg private String category; // 分类 // 生成 getter/setter,下面省略 }
// BookDao.java —— 图书表的数据访问层 package com.example.bookstore.dao; import com.example.bookstore.model.Book; import com.example.bookstore.util.DBUtil; import java.sql.*; import java.util.ArrayList; import java.util.List; public class BookDao { // 分页查询图书列表 public List<Book> listBooks(int page, int size) throws SQLException { String sql = "SELECT id, title, author, price, stock, cover FROM book LIMIT ? OFFSET ?"; List<Book> list = new ArrayList<>(); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, size); // 每页条数 ps.setInt(2, (page - 1) * size); // 偏移量 try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { Book b = new Book(); b.setId(rs.getInt("id")); b.setTitle(rs.getString("title")); b.setAuthor(rs.getString("author")); b.setPrice(rs.getBigDecimal("price")); b.setStock(rs.getInt("stock")); b.setCover(rs.getString("cover")); list.add(b); } } } return list; } }

这个 DAO 里有两个细节值得讲。第一,LIMIT ? OFFSET ?是 MySQL 最直接的分页写法,page 从 1 开始,那么 offset 就是(page-1)*size。第二,Connection 和 PreparedStatement 都放在 try-with-resources 里,方法结束自动关闭,否则连接池很快被耗尽——这个问题我在第 5 章还会专门讲。

实体类和 DAO 写完后,建议立刻做一件小事:写一个main方法或 JUnit 测试,把列表查出来打印。这一步能提前暴露 MySQL 驱动缺失、URL 配错、时区报错这些环境问题,别等页面写完再回头找。

2.3 Servlet 控制器:用 BaseServlet 收窄代码量

购物网站最常见的是“一个模块一个 Servlet”,每个 Servlet 里按 action 参数拆方法。如果不做封装,BookServlet、CartServlet、OrderServlet 里会出现大量重复的请求参数读取和转发代码。我的做法是先封装一个 BaseServlet,用反射把 action 对应的同名方法调起来。

// BaseServlet.java —— 所有控制器的父类 package com.example.bookstore.controller; import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.lang.reflect.Method; public abstract class BaseServlet extends HttpServlet { @Override protected void service(HttpServletRequest req, HttpServletResponse res) throws ServletException, IOException { String action = req.getParameter("action"); if (action == null || action.trim().isEmpty()) { action = "list"; // 默认动作:列表 } try { Method m = this.getClass().getMethod(action, HttpServletRequest.class, HttpServletResponse.class); String view = (String) m.invoke(this, req, res); if (view.startsWith("redirect:")) { res.sendRedirect(req.getContextPath() + "/" + view.substring("redirect:".length())); } else { req.getRequestDispatcher("/WEB-INF/" + view).forward(req, res); } } catch (NoSuchMethodException e) { res.sendError(404, "action not found: " + action); } catch (Exception e) { throw new ServletException("控制器调用失败:" + action, e); } } }
// CartServlet.java —— 购物车控制器 package com.example.bookstore.controller; import com.example.bookstore.model.Cart; import com.example.bookstore.service.CartService; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @WebServlet("/cart") public class CartServlet extends BaseServlet { private CartService cartService = new CartService(); // action=add 时由 BaseServlet 反射调用 public String add(HttpServletRequest req, HttpServletResponse res) throws Exception { Integer bookId = Integer.valueOf(req.getParameter("bookId")); int qty = Integer.parseInt(req.getParameter("qty")); Cart cart = (Cart) req.getSession().getAttribute("cart"); if (cart == null) { cart = new Cart(); req.getSession().setAttribute("cart", cart); } cart.addItem(bookId, qty); return "redirect:cart?action=list"; // 重定向到购物车列表,避免刷新二次提交 } // action=list 时展示购物车 public String list(HttpServletRequest req, HttpServletResponse res) throws Exception { Cart cart = (Cart) req.getSession().getAttribute("cart"); req.setAttribute("cart", cart == null ? new Cart() : cart); return "cart/list.jsp"; // 转发到 /WEB-INF/cart/list.jsp } }

BaseServlet 的动作分发约定很简单:URL 里传action=xxx,就反射调用本类里名为 xxx 的公有方法;方法返回字符串作为视图路径,以redirect:开头就重定向,否则转发到/WEB-INF/下的 JSP。把 JSP 放进 WEB-INF 目录,是为了防止用户绕过控制器直接访问页面文件。

这里提醒一句:BaseServlet 反射调用的方法签名必须是public String 方法名(HttpServletRequest, HttpServletResponse) throws Exception,参数顺序不能反。我第一次写的时候把参数写成了(HttpServletResponse, HttpServletRequest),页面 500 了半小时才反应过来。

3. MySQL 表设计与初始化:图书、用户、订单的建模要点

3.1 六张核心表的关系与建表语句

网上图书购物系统的数据模型,核心是用户、图书、购物车、订单四类对象。订单还要拆成主表和明细表,因为一个订单可能包含多本图书,明细表每行对应一本。购物车如果只给登录用户用,建议直接落库,这样用户换浏览器登录购物车还在;匿名购物车走 Session,下面一章细说。

表之间的关系一句话讲完:用户与购物车是一对一,购物车与图书是多对多,用户与订单是一对多,订单与图书通过订单明细表形成多对多。下面是完整的建表 SQL,直接复制到 MySQL 里执行即可。

-- 建库:务必指定 utf8mb4。书名和收货地址里可能输入生僻字,utf8 三个字节不够用 CREATE DATABASE bookstore DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE bookstore; -- 用户表 CREATE TABLE user ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL, password VARCHAR(64) NOT NULL, -- 存 SHA-256 摘要,不要存明文 email VARCHAR(100) NOT NULL, phone VARCHAR(20), address VARCHAR(200), reg_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_username (username) ) ENGINE=InnoDB; -- 图书表 CREATE TABLE book ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, title VARCHAR(100) NOT NULL, author VARCHAR(50), publisher VARCHAR(100), price DECIMAL(10,2) NOT NULL, -- 金额用 DECIMAL,double 会出精度偏差 stock INT UNSIGNED NOT NULL DEFAULT 0, cover VARCHAR(200), -- 封面图相对路径 category VARCHAR(20), INDEX idx_category (category), INDEX idx_title (title(20)) ) ENGINE=InnoDB; -- 购物车表(登录用户购物车持久化) CREATE TABLE cart_item ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id INT UNSIGNED NOT NULL, book_id INT UNSIGNED NOT NULL, quantity INT UNSIGNED NOT NULL DEFAULT 1, add_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_book (user_id, book_id), CONSTRAINT fk_cart_user FOREIGN KEY (user_id) REFERENCES user (id), CONSTRAINT fk_cart_book FOREIGN KEY (book_id) REFERENCES book (id) ) ENGINE=InnoDB; -- 订单主表 CREATE TABLE orders ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL, user_id INT UNSIGNED NOT NULL, total_price DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, -- 0待支付 1已支付 2已发货 3已完成 4已取消 create_time DATETIME DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME, UNIQUE KEY uk_order_no (order_no), INDEX idx_user (user_id), INDEX idx_status (status) ) ENGINE=InnoDB; -- 订单明细表 CREATE TABLE order_item ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, order_id INT UNSIGNED NOT NULL, book_id INT UNSIGNED NOT NULL, title VARCHAR(100) NOT NULL, -- 下单时的书名快照,图书改名不影响历史订单 price DECIMAL(10,2) NOT NULL, -- 下单时的单价快照 quantity INT UNSIGNED NOT NULL, CONSTRAINT fk_item_order FOREIGN KEY (order_id) REFERENCES orders (id) ) ENGINE=InnoDB;

几张表的用心之处都在注释里。特别说一下订单明细表的“快照”设计:历史订单里必须保留下单那一刻的书名和价格,否则图书管理员后来改了价格,老订单的金额就对不上了。这是不少学生项目会翻车的地方——订单明细里用外键关联 book_id,却不冗余留存当时的书名和价格,查老订单时看到的是现价,对不上账。

3.2 索引与外键:哪些必须加,哪些是白缴费

表结构建完,下一步就是考虑索引。购物网站的查询场景很固定:按 username 登录、按 title 搜书、按 user_id 查订单、按 order_id 查明细,再加一个分类列表页。上述索引覆盖这些场景就够了。

外键这块要克制。user_id、book_id、order_id 这些关联字段加外键约束,能防止业务代码把订单明细挂到一个不存在的订单上,对维护数据一致性是实打实的好处。但千万不要给一张表加十几个外键,尤其不要让购物车表通过外键级联删除图书——图书下架如果直接删行,购物车里引用了这本书的记录会被级联删掉,用户购物车莫名少东西,这是运维事故。

索引里有一个常见误用:给 category 这种区分度很低的字段加普通索引,或者给 order 表的 status 单独加索引,最后发现 MySQL 优化器根本不用它。status 只有 0 到 4 五个取值,区分度太差,单独建索引的收益接近零。在这个项目规模下,查询主要靠主键和唯一键,普通索引保持两三个就够。

3.3 MySQL 初始化配置:utf8mb4、时区与事务参数

表建好只算完成一半,MySQL 服务端的初始化配置直接影响后续开发和部署。不少人在本地装完 MySQL 就急着跑 JDBC,结果报错一个接一个。建议在安装配置阶段就把下面这几个参数一次性搞定。

配置项推荐值原因
character-set-serverutf8mb4默认字符集设为 utf8mb4,避免建表漏写字符集导致乱码
collation-serverutf8mb4_general_ci排序规则,大小写不敏感,图书搜索不区分大小写
default-time-zone+08:00让 NOW() 返回时间和业务时区一致,订单时间不会凭空差 8 小时
innodb_buffer_pool_size1G~2G(内存一半以内)表不多,1G 起步足够,调太大反而挤占 JVM 内存

MySQL 5.7 和 8.0 的安装包都建议从官网下载,5.7 系列到 5.7.44 已经基本定版。硬盘允许的话直接上 8.0,毕竟 8.0 默认字符集就是 utf8mb4,事务能力也更完整。装的时候记得选“Server Computer”类型,下面会以 5.7 的 my.ini 片段为例。

[mysqld] port=3306 character-set-server=utf8mb4 collation-server=utf8mb4_general_ci default-time-zone=+08:00

改完 my.ini 记得重启 MySQL 服务,再用SHOW VARIABLES LIKE 'character_set_server';确认改生效了。时区参数只对 JDBC 直连有意义,如果你用Connector/J 8.0,连接串里不写serverTimezone依然会报错,这个坑留到第 5 章集中排。

4. 把核心链路跑通:商品浏览、购物车、下单减库存

4.1 首页商品列表:从 DAO 到 JSP 的一条链

商品列表是整个系统的门面,链路非常直:BookServlet 调用 BookDao 查列表,把结果放进 request 域,转发到 JSP。JSP 端用 JSTL 的c:forEach循环渲染,注意从requestScope取数据,不要用 Scriptlet 写 Java 代码混在 HTML 里。

<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <!DOCTYPE html> <html> <head><title>图书列表</title></head> <body> <div class="book-grid"> <c:forEach var="b" items="${requestScope.books}"> <div class="book-card"> <c:if test="${not empty b.cover}"> <img class="cover" src="${pageContext.request.contextPath}${b.cover}" alt="${b.title}"/> </c:if> <h3><c:out value="${b.title}"/></h3> <p>作者:<c:out value="${b.author}"/></p> <p>价格:¥${b.price}</p> <a href="${pageContext.request.contextPath}/cart?action=add&bookId=${b.id}&qty=1"> 加入购物车 </a> </div> </c:forEach> </div> </body> </html>

页面里两个细节值得留意。第一,所有输出都用<c:out>,它能自动转义 HTML 特殊字符,防止书名里出现脚本标签时页面被注入。第二,图片路径前面拼了${pageContext.request.contextPath},这是为了解决部署时上下文路径不一致导致图片 404 的问题。“jsp 图片如何对坐标定位”这类问题,常见场景就是封面图在列表页和详情页显示大小不一致,我的做法是用 CSS 统一图片框尺寸,object-fit: cover让图片按比例裁剪对齐,而不是在 JSP 里手工算坐标。

4.2 购物车存储:登录用户的状态该放 Session 还是表

购物车是个两难选择。放 Session 实现简单、不用写数据库,但用户清缓存或换浏览器购物车就没了;放数据库 cart_item 表,换设备还在,但要多写几条 SQL。我的建议是分两步走:匿名用户购物车先放 Session,确保不登录也能加购;用户一旦登录,把 Session 里的购物车合并进 cart_item 表,后续操作都走数据库。

Session 购物车的数据结构用Map<Integer, Integer>最顺手,key 是书 id,value 是数量。下面这个 Cart 类负责维护这个 Map。

// Cart.java —— Session 购物车模型 package com.example.bookstore.model; import java.util.LinkedHashMap; import java.util.Map; public class Cart { // key: bookId, value: 数量。LinkedHashMap 保证遍历顺序和添加顺序一致 private final Map<Integer, Integer> items = new LinkedHashMap<>(); public void addItem(Integer bookId, int qty) { // 已存在的书累加数量,新书直接放入 items.merge(bookId, qty, Integer::sum); } public void updateItem(Integer bookId, int qty) { if (qty <= 0) { items.remove(bookId); } else { items.put(bookId, qty); } } public Map<Integer, Integer> getItems() { return items; } public int size() { return items.values().stream().mapToInt(Integer::intValue).sum(); } }

items.merge(bookId, qty, Integer::sum)是 Java 8 之后最简洁的累加写法:如果 key 不存在就放入 qty,存在就把旧值和新值相加。这个 Cart 类只负责内存状态,不碰数据库,所以它属于模型层的纯 JavaBean,后续换成数据库购物车时,Service 层只需要改数据读写部分,JSP 页面一行都不用动。

登录后把 Session 购物车同步到 cart_item 表时,注意用事务包住“查旧数据、对比、插入更新”三步,否则用户在同一台设备上重复登录登出,可能把购物车加出重复条目。cart_item 表上的UNIQUE KEY uk_user_book (user_id, book_id)已经做了最后一道防线,配合INSERT ... ON DUPLICATE KEY UPDATE quantity = quantity + VALUES(quantity)就能安全合并。

4.3 下单事务:扣库存和写订单必须同一事务

购物车有了,下单就是最危险的一步。下单动作包含三件事:写订单主表、写订单明细表、扣图书库存。这三件事必须在一个数据库事务里,任何一步失败都要整体回滚,否则会出现“订单生成了但库存没扣”这种对不上账的情况。

// OrderService.java —— 核心事务代码片段 public Long createOrder(Integer userId, Map<Integer, Integer> items) throws SQLException { Connection conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 关闭自动提交,手动控制事务 Long orderId = null; try { // 1. 插入订单主记录,status 先置 0(待支付) String sqlOrder = "INSERT INTO orders (order_no, user_id, total_price, status) VALUES (?, ?, ?, 0)"; // order_no 生成规则自己定:时间戳 + 用户 id + 随机数,保证唯一 // 这里省略计算 total_price 的循环,业务逻辑简单,但别忘加 // 2. 逐条扣库存。这条 UPDATE 是关键,见下文说明 for (Map.Entry<Integer, Integer> e : items.entrySet()) { String sqlStock = "UPDATE book SET stock = stock - ? WHERE id = ? AND stock >= ?"; PreparedStatement ps = conn.prepareStatement(sqlStock); ps.setInt(1, e.getValue()); // 扣减数量 ps.setInt(2, e.getKey()); // 图书 id ps.setInt(3, e.getValue()); // 库存必须大于等于本次购买数量 int affected = ps.executeUpdate(); if (affected == 0) { throw new SQLException("库存不足,bookId=" + e.getKey()); } // 3. 插入 order_item 明细,省略 } conn.commit(); // 全部成功才提交 orderId = ...; // 通过 SELECT LAST_INSERT_ID() 拿回自增主键 } catch (SQLException ex) { conn.rollback(); // 任一步失败,回滚所有操作 throw ex; } finally { conn.setAutoCommit(true); conn.close(); } return orderId; }

这段代码里最容易写错的就是那条 UPDATE 语句。很多人习惯先SELECT stock FROM book WHERE id=?查到库存量,在 Java 里判断够不够,再执行UPDATE book SET stock = stock - ? WHERE id=?。这个做法在单用户测试时没问题,但两个人同时下单就会冲出“库存变负数”。因为先查后改是两步操作,中间隔着的时间窗里另一个请求可能已经把最后一件买走了。

正确写法是上面代码里的UPDATE ... WHERE id = ? AND stock >= ?。这条 SQL 在数据库层面一次性完成“检查库存是否够”和“扣减库存”两个动作,而且 UPDATE 会对命中行加排他锁。两个并发请求同时执行时,后一个会等前一个提交后才执行,发现库存不够就返回 0 行,直接抛出库存不足异常。这就是事务加行锁解决并发问题的最小落地姿势。

5. 避坑与排错:JSP + MySQL 购物系统最常踩的 5 个坑

5.1 MySQL 装好了,JDBC 连接报 2003 或 1045

现象:跑 DAO 测试时报Communications link failure或Access denied for user,前者常带错误码 2003,后者是 1045。

原因:2003 是网络层面的问题,通常是 MySQL 服务没启动、端口改过、或者连接串里的 host 写错;1045 是认证问题,用户名密码不对,或者 root 账号只允许 localhost 登录,而代码里用了远程连接。

解决:2003 先在命令行执行mysql -u root -p验证本机能连,能连说明服务正常,问题在连接串;连不上就去服务列表启动 MySQL,Linux 下用systemctl start mysqld。1045 用 root 登录后执行ALTER USER 'root'@'localhost' IDENTIFIED BY '新密码';重置,如果代码要从别的机器连,还要建一个允许远程访问的账号并授权。

5.2 页面和数据库中文全部乱码

现象:图书详情页的书名变成问号或乱码,数据库里存的也是乱码。

原因:编码链路有一环断了。JSP 页面声明了大写 UTF-8,数据库表是 utf8mb4,但 JDBC 连接串里没加characterEncoding=utf8,或者 Tomcat 接收 GET 参数时用了默认的 ISO-8859-1 解码。

解决:把编码统一到 UTF-8 这一条链上。JDBC 连接串写成下面这样:

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

JSP 顶部保留contentType="text/html; charset=UTF-8" pageEncoding="UTF-8",HTML 的<meta charset="UTF-8">也别漏。Tomcat 8 及以后默认 URI 编码就是 UTF-8,不用额外配置;如果是老版本的 Tomcat 7,需要在 server.xml 的 Connector 上加URIEncoding="UTF-8"。排查时按“页面 → 后端 → 数据库”的顺序逐层打印,哪一层先变乱码就修哪一层。

5.3 并发下单把库存扣成负数

现象:两个用户同时下单,最后一件库存被卖了两次,book 表的 stock 变成 -1。

原因:使用了“先查库存,再判断,再更新”的流程,查和更新之间没有锁保护,两个请求交错执行导致超卖。

解决:把“检查并扣减”合成一条原子 UPDATE,再放进事务里。参考 4.3 节的写法,用UPDATE book SET stock = stock - ? WHERE id = ? AND stock >= ?代替先查后改。MySQL 默认隔离级别是 REPEATABLE READ,对这条 UPDATE 命中的行加锁直到事务结束,所以并发时第二个请求会阻塞等待,提交后再执行时就查不到足够的库存了。这也是回答“mysql 锁的分类”这类问题时最好的实战素材:共享锁、排他锁、行锁、间隙锁这些概念,在扣库存场景里全部能对上号。

5.4 部署后访问页面 404 或白屏

现象:Tomcat 启动没报错,但访问某个页面要么 404,要么整个区域白屏,控制台也不输出错误。

原因:404 多数是 URL 路径没带上下文路径,或者 Servlet 的 @WebServlet 映射和实际请求不一致;白屏通常是 JSP 编译异常被吞掉,或者转发路径写错。

解决:访问 URL 一定要带上下文名,比如部署包名是 bookstore,完整地址是http://localhost:8080/bookstore/cart?action=list。检查 @WebServlet 里的路径是否和表单 action 完全一致,包括大小写。白屏打开 Tomcat 的 catalina.out 日志,搜SEVERE或第一次出现的异常堆栈,十有八九是 JSP 里用了没引入的 JSTL 标签库,把两个 jar(jstl-api、jstl-impl)放进 WEB-INF/lib 就能解决。

5.5 数据库连接池耗尽导致系统卡死

现象:系统跑一段时间后,所有页面都转圈,Tomcat 日志大量输出Connection is not available, request timed out,重启 Tomcat 又恢复正常。

原因:连接没关。代码里有的 Connection 是从连接池拿的,但没有在 finally 块关闭,连接池的连接被慢慢耗尽。这是新手项目最普遍的性能杀。

解决:所有 JDBC 操作全部用 try-with-resources,或者保证 finally 里关闭 Connection、Statement、ResultSet 三个资源。使用连接池时,把参数调保守一点:Druid 的initialSize=5、maxActive=20、maxWait=60000起步,同时打开removeAbandoned=true让池子自动回收泄漏连接。配置好之后,可以用SHOW PROCESSLIST观察连接数是否在请求结束后回落,回落到初始值附近说明关闭逻辑没问题。

6. 验证与进阶:压测自检、分页与订单状态机

验证这套购物系统是不是真的能扛事,不要只靠鼠标点一遍。我习惯先用 SQL 直接造一批数据,然后跑一次下单并发测试。开两个终端同时执行同一条扣库存语句:

UPDATE book SET stock = stock - 1 WHERE id = 1 AND stock >= 1;

一个终端返回Query OK, 1 row affected,另一个返回0 rows affected,说明原子扣减生效了。再查一遍库存,确保结果非负,整个“下单一扣库存”的核心安全性就验证到位了。至于页面功能,按“注册登录 → 加购 → 改数量 → 下单 → 支付 → 查订单”这条路径走一遍,每步都去数据库对照一次金额和数量,能对上就说明业务闭环没问题。

项目验收之后,三个方向值得继续投入。第一个是分页优化,图书到上千本时首页一次渲染全部记录会明显变慢,把 BookDao 里的 LIMIT/OFFSET 参数化、加一个统计总条数的SELECT COUNT(*),就能做出标准的分页组件。第二个是订单状态机,把 status 字段的流转规则收敛到一个专门的处理类里,比如“待支付 → 已支付 → 已发货 → 已完成”,每个状态转移都写清楚,避免后来的人在 JSP 里随手改状态导致订单乱套。第三个是 Session 共享,如果系统要部署到两台 Tomcat 后面挂负载均衡,默认 Session 是各节点独立的,用户可能登录后一刷新就被踢回登录页,用 Redis 集中存 Session 是常见的解法。

最后说一个我自己的教训。早期做一个内部图书系统,上线的第一个版本没有做“库存必须大于等于购买量”的检查,结果运营同学测试时连点了三遍下单,库存直接变负数,订单倒是生成了三份。当时只能写脚本去对账补库存,折腾了一晚上。自那以后,凡涉及金额和数量变更的 SQL,我一定把条件写进 UPDATE 语句而不是先查再改,也一定在代码评审时盯着别人代码里的这条线。希望这篇笔记能帮你把该踩的坑提前绕过去,也希望你做完这套系统后,对 JSP、MVC、MySQL 三者如何配合这件事,有更踏实的手感。

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

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

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

立即咨询