资讯动态

JSP+MVC+MySQL图书购物系统实战:从建表到事务下单

发布时间:2026/10/5 3:54:07 来源:尧图企业网站定制
简介基于JSPMVC模式与MySQL构建的网上图书购物系统是一份面向Java Web初学者的课程设计与毕设参考资料。资源覆盖从数据库设计到前后端交互的完整流程包含用户登录注册、图书浏览搜索、购物车管理、订单提交等典型模块适合用于毕业设计、课程设计或工程实训但代码定位为学习参考需要读者具备一定基础并能自行调试扩展。压缩包共76个文件以14个JSP页面、12个Java类及对应class文件为主辅以16张图片素材、3个XML配置、3个CSS样式、数据库SQL脚本、项目展示视频和README说明文档等整体约47.8MB目录结构清晰便于按模块对照学习。其中SQL脚本可直接导入MySQL建立数据表展示视频可快速了解系统运行效果。目前已有136人浏览学习对于希望快速掌握JSPServletMySQL开发流程、理解MVC分层思想的学习者而言能够提供从代码阅读、环境配置到功能改写的完整参考路径。1. 为什么一个 JSP MVC MySQL 的图书购物系统至今还是值得搭一遍如果你正在为课程设计、毕业设计或一份 Java Web 简历项目选型JSP Servlet MySQL 这套组合并不时髦但它是一个能让你把“从前端页面到数据库落库”整条链路一次看懂的方案。JSP 负责渲染页面Servlet 按 MVC 模式充当控制器业务逻辑与数据库访问拆到独立的 JavaBean 和 DAO 层MySQL 负责把所有图书、用户、购物车和订单数据持久化。它的可贵之处在于链路短、依赖少、每一步出错都能在源码里直接定位。本文会沿着一个真实的下单流程走一遍建表、写 JDBC 连接层、实现登录与购物车、最后用事务把库存扣减和订单创建绑定在一起。你按章节操作能得到一个能注册登录、能加购、能下单、能查订单的网上图书购物系统同时避开 MySQL 连接和中文乱码这些最常见的坑。2. 技术选型与工程结构JSP MVC MySQL 怎么分工才能不翻车2.1 把 MVC 模式落到 Servlet、JSP、DAO 三层职责边界与两处取舍很多 JSP 课程设计写着“MVC 模式”实际代码却把所有东西堆在 JSP 页面里页面顶部连数据库、中部写业务判断、底部又拼 HTML。这种写法不是 MVC只是换了个扩展名的 JSP 脚本。真正的 MVC 在这套系统里应该这样切分视图层VJSP 页面只负责展示。页面里不出现% %脚本片段不写new UserDao()只使用 EL 表达式${loginUser.nickname}和 JSTL 标签库c:forEach遍历图书列表。控制器层CServlet 接收请求、解析参数、调用业务方法、把结果放进request或session然后转发或重定向。它不拼 HTML也不写 SQL。模型层M细分为 JavaBeanUser、Book、CartItem 这样的实体类、DAOUserDao、BookDao 这类只做单表增删改查的类和 ServiceCartService、OrderService 这类处理业务规则与事务的类。这里有一个很多人会纠结的取舍Service 层到底要不要单独建。对于一个图书购物系统直接把业务写在 Servlet 里也能跑通但一旦出现“加购时既要查库存、又要写购物车、还要校验是否登录”这种组合逻辑Servlet 里会堆出几十个 try-catch后面改任何一处都胆战心惊。我的建议是保留一个薄 Service 层它不做 SQL只做规则判断与事务控制DAO 层才碰 SQL。这样做的好处是后期如果你想换成 Spring MyBatisService 的接口基本不用动。第二个取舍发生在 DAO 层用原生的PreparedStatement还是直接用 DBUtils 这类工具。新手项目我建议先裸写 JDBC因为 DBUtils 会帮你把ResultSet转成 Bean反而让你漏掉“连接什么时候关闭”这个关键点。等你在裸 JDBC 下真正遇到过一次连接泄漏再引入 HikariCP 或 Druid 连接池你才知道连接池帮你解决了什么问题。这个项目规模下流量很小每次都新建连接不会有性能压力先保证逻辑正确、资源关闭正确再谈优化。2.2 推荐的工程目录与运行环境组合MVC 模式需要目录结构配合否则分层概念再清晰代码还是会被压进一个巨大的包。常见做法是先按“表现层 / 控制器 / 服务 / 数据访问”切包而不是按“用户模块 / 图书模块 / 订单模块”切因为小项目按模块切会让每个模块里重复出现 servlet、dao、service 三件套找文件反而困难。下面这个目录是这类系统里最常见、也能直接迁移到 Maven 的结构bookstore/ ├── src/ │ ├── com.bookstore.entity # User, Book, CartItem, Order │ ├── com.bookstore.dao # UserDao, BookDao, CartDao, OrderDao │ ├── com.bookstore.service # CartService, OrderService │ ├── com.bookstore.web # LoginServlet, IndexServlet, CartServlet, OrderServlet │ ├── com.bookstore.filter # LoginFilter, EncodingFilter │ └── com.bookstore.util # DBUtil ├── web/ │ ├── WEB-INF/ │ │ ├── web.xml │ │ └── lib/ # mysql-connector-j 8.x 的 jar 必须放在这里 │ ├── css/ │ ├── js/ │ ├── index.jsp # 图书列表首页 │ ├── login.jsp │ ├── register.jsp │ ├── cart.jsp # 购物车页面 │ ├── order.jsp # 下单确认页 │ ├── user.jsp # 个人信息展示页面 │ └── error.jsp └── db.properties # 放在 src 根目录由 DBUtil 读取这套结构里实体类com.bookstore.entity对应 MVC 的 Model 角色但它们不含任何数据访问逻辑只是数据的载体。web包是 C 层dao和service共同构成 M 层的数据与业务部分。web.xml在较新版本里可以只保留欢迎页配置Servlet 用WebServlet注解注册这样能少写很多 XML 映射。运行环境组合也需要提前定好这一步是网上图书购物系统最常见的开局失败点。JDK 用 1.8 或 11Tomcat 用 9.xMySQL 用 5.7 或 8.0JSP 用 2.3 JSTL 1.2。注意不要轻易用 Tomcat 10因为 Tomcat 10 把javax.servlet换成了jakarta.servlet老项目里的import javax.servlet.http.HttpServlet会直接编译失败如果你的教材或参考项目里全是javax.*Tomcat 9 是省事的选择。MySQL 版本对 Java 项目的影响主要在连接驱动和 URL 参数上后面第 5 章会详细说这里先记住 8.0 的驱动类名和 5.7 不一样就够了。3. 数据库与连接层设计从建表到 JDBC 工具类一次做扎实3.1 五张业务表的 SQL 设计与索引、排序、存储引擎选择网上图书购物系统需要的最小表集合是五张用户表、图书表、购物车表、订单主表、订单明细表。第一次做这个项目的人常犯两个错误一是把购物车做成单表大字段塞一堆逗号分隔的图书 ID二是订单只建一张表每个订单重复存储所有图书信息导致数据大量冗余。正确的做法是购物车表用“一个用户 一本图书”作为唯一键一条记录只表达“这个用户买了几本这本书”订单用主表存订单头谁买的、总价、状态、时间明细表存每一本书的标题、单价和数量这样订单主表字段少查询快明细又能完整回溯。下面这组 DDL 可以直接在 MySQL 8.0 里执行建库时务必指定utf8mb4不然中文标题会出现意料之外的乱码CREATE DATABASE IF NOT EXISTS bookstore DEFAULT CHARACTER SET utf8mb4; USE bookstore; CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL, password VARCHAR(64) NOT NULL, nickname VARCHAR(32), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_username (username) ) ENGINEInnoDB; CREATE TABLE t_book ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(128) NOT NULL, author VARCHAR(64), price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, cover_url VARCHAR(255), created_at DATETIME DEFAULT CURRENT_TIMESTAMP, KEY idx_stock (stock) ) ENGINEInnoDB; CREATE TABLE t_cart_item ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, book_id INT NOT NULL, quantity INT NOT NULL DEFAULT 1, UNIQUE KEY uk_user_book (user_id, book_id), KEY idx_user (user_id) ) ENGINEInnoDB; CREATE TABLE t_order ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL, user_id INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no), KEY idx_order_user (user_id) ) ENGINEInnoDB; CREATE TABLE t_order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, book_id INT NOT NULL, book_title VARCHAR(128) NOT NULL, price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL, KEY idx_order_id (order_id) ) ENGINEInnoDB;表结构中几个细节解释一下。图书价格用DECIMAL(10,2)而不是FLOAT因为浮点做金额计算会产生 0.1 0.2 0.30000000000000004 这类精度丢失账单合计时尤其明显。订单状态的TINYINT用数字 0 代表待付款、1 代表已付款、2 代表已发货、3 代表已完成比存字符串更省空间也方便扩展。存储引擎统一选InnoDB因为订单创建和库存扣减需要数据库事务支持MyISAM 不支持行级锁和事务回滚在这个系统里不能用。索引这里注意t_order_item上建的是KEY idx_order_id (order_id)而不是唯一键因为一个订单必然有多个明细重复值是正常的。t_book上我建了idx_stock如果你的首页有“按库存排序展示现货图书”的需求这个索引能让排序语句走索引而不是全表扫描但实际上这个库数据量很小索引的收益有限真正的收益在于让你养成“查询条件或排序字段尽量建索引”的习惯。外键我刻意没有建物理外键只保留了逻辑关联原因是外键约束会放大并发下单时的锁竞争而且图书购物系统这类小项目通常在应用层保证引用完整性删除用户或图书时会先清掉购物车与订单明细这样反而灵活。3.2 db.properties DBUtilMySQL 8 的连接参数与资源关闭顺序JDBC 连接层的标准做法是把数据库配置放进db.properties文件再用一个工具类统一读取和创建连接。配置文件放在src根目录这样它会被编译进WEB-INF/classesDBUtil用类路径加载即可不用关心项目部署在哪个绝对路径。这是我在多个项目里验证过的最稳妥方式比硬编码在 Java 类里好维护得多。jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://127.0.0.1:3306/bookstore?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue jdbc.usernameroot jdbc.passwordyour_password上面这几个参数是 MySQL 8.0 连接里最容易出问题的地方逐个说明参数作用踩坑点jdbc.driver驱动类名MySQL 8.0 必须用com.mysql.cj.jdbc.Driver旧教程里的com.mysql.jdbc.Driver会提示已废弃甚至直接报ClassNotFoundExceptionuseUnicodetruecharacterEncodingutf8连接层字符集漏掉后页面传进的中文参数和写入数据库的中文都可能变问号useSSLfalse关闭 SSL 握手MySQL 8 默认尝试 SSL本地开发不配证书会报SSL connection errorserverTimezoneAsia/Shanghai设置服务器时区不设置会报server time zone value Öйú±ê׼ʱ¼ä is unrecognized因为 MySQL 8 会尝试读取系统时区读不到就用英文碎片显示allowPublicKeyRetrievaltrue允许客户端获取公钥使用caching_sha2_password认证的用户第一次连接会报Public Key Retrieval is not allowed不开这参数每次重启服务后第一次访问就失败对应的 DBUtil 工具类写法如下package com.bookstore.util; import java.io.InputStream; import java.sql.Connection; import java.sql.DriverManager; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; import java.util.Properties; public final class DBUtil { private static final Properties props new Properties(); static { try (InputStream in DBUtil.class.getClassLoader() .getResourceAsStream(db.properties)) { props.load(in); Class.forName(props.getProperty(jdbc.driver)); } catch (Exception e) { throw new ExceptionInInitializerError(db.properties 加载或驱动注册失败请检查配置); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection( props.getProperty(jdbc.url), props.getProperty(jdbc.username), props.getProperty(jdbc.password)); } public static void close(Connection conn, Statement st, ResultSet rs) { // 关闭顺序必须与打开顺序相反先结果集后语句最后连接 if (rs ! null) { try { rs.close(); } catch (SQLException e) { /* 关闭失败不影响主流程 */ } } if (st ! null) { try { st.close(); } catch (SQLException e) { /* 关闭失败不影响主流程 */ } } if (conn ! null) { try { conn.close(); } catch (SQLException e) { /* 关闭失败不影响主流程 */ } } } }这段代码里有两个关键点。一是Class.forName在静态块里执行保证整个 Web 应用只注册一次驱动如果你把它放进getConnection()每次拿连接都会重复注册虽然 JDBC 规范会忽略重复注册但这属于无意义的开销。二是close方法用三个独立 if 判断而不是先判空再调用因为调用方可能在某个环节拿到 null。资源关闭顺序是有讲究的必须先关 ResultSet再关 Statement最后关 Connection。如果先关连接少数驱动实现会连带关闭语句和结果集但顺序反了在数据库连接池环境下很容易出现“结果集还在用连接却被回收”的诡异报错。这属于 JSP 项目里最典型的“玄学问题”其实只是关闭顺序不对。后面第 5 章会单独讲连接泄漏导致的 Too many connections。4. 核心流程实现登录保持、购物车校验与下单事务的完整链路4.1 登录与 session用过滤器拦住未登录访问登录是整个购物系统的入口很多图书购物网站“翻车”不是翻在登录判断上而是翻在登录后的状态保持上。登录成功只往 session 里放一个用户对象还不够你还需要保证“未登录用户不能访问购物车”“未登录用户不能点结算”否则会出现用户把商品加入购物车后点结算跳到空页面回头又怪系统坏了。这里用 Servlet 处理登录请求用 Filter 统一做登录校验是最清晰的分工。先看 LoginServletpackage com.bookstore.web; import com.bookstore.dao.UserDao; import com.bookstore.entity.User; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; WebServlet(/login) public class LoginServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 处理 POST 请求时第一行就设置请求编码防止中文用户名变成乱码 req.setCharacterEncoding(UTF-8); String username req.getParameter(username); String password req.getParameter(password); if (username null || password null || password.isEmpty()) { req.setAttribute(error, 用户名和密码不能为空); req.getRequestDispatcher(/login.jsp).forward(req, resp); return; } UserDao userDao new UserDao(); User user userDao.findByUsernameAndPassword(username, password); if (user null) { req.setAttribute(error, 用户名或密码错误); req.getRequestDispatcher(/login.jsp).forward(req, resp); return; } HttpSession session req.getSession(); session.setAttribute(loginUser, user); // 登录成功用重定向避免刷新页面重复提交表单 resp.sendRedirect(req.getContextPath() /index); } }逻辑说明这个 Servlet 只做三件事取参数、调 DAO 查用户、决定去哪个页面。失败时用forward转发回登录页并把错误信息放进request属性登录页用${error}显示成功时用sendRedirect重定向到首页。转发和重定向的区别在于转发是服务器内部跳转浏览器 URL 不变刷新时会再次 POST 重复登录重定向让浏览器发起新请求URL 变为/index刷新只刷新首页。登录成功必须重定向这是防止表单重复提交的最小成本方案。再看登录过滤器。订单、购物车这些业务 URL 统一挂在/user/*前缀下让过滤器一把拦住package com.bookstore.filter; import javax.servlet.*; import javax.servlet.annotation.WebFilter; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; WebFilter(/user/*) public class LoginFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; HttpSession session req.getSession(false); if (session null || session.getAttribute(loginUser) null) { resp.sendRedirect(req.getContextPath() /login.jsp); return; } chain.doFilter(request, response); } }这段代码注意req.getSession(false)这个参数。传 false 表示“如果当前没有 session 就直接返回 null”不会自动创建一个新 session。如果这里误写成req.getSession()那么每个未登录请求都会被迫创建新 session再被判空跳转白白增加无效 session 压力。过滤器拦截的是 URL 前缀/user/*所以购物车、结算、个人中心这些功能模块在 Servlet 的WebServlet里要统一设计成/user/cart、/user/order这样的路径而不是散乱地叫/cart.jsp或/doCartServlet。4.2 购物车加购数量上限检查和 INSERT ... ON DUPLICATE KEY UPDATE购物车在这个系统里有两种常见实现路线一种是纯 session 购物车把购物车对象放进HttpSession简单但用户清缓存或换浏览器车就没了另一种是数据库购物车只存user_id、book_id、quantity用户下次登录购物车还在。图书购物网站一般选数据库购物车因为下单流程需要事务性地同时操作购物车和订单表数据留在数据库里更容易保证一致。加购接口的 Servlet 只负责拿参数和跳转真正的规则判断放在CartServicepackage com.bookstore.service; import com.bookstore.dao.BookDao; import com.bookstore.dao.CartDao; import com.bookstore.entity.Book; public class CartService { public boolean addToCart(int userId, int bookId, int quantity) { if (quantity 0 || quantity 99) { throw new IllegalArgumentException(加购数量必须在 1-99 之间); } BookDao bookDao new BookDao(); Book book bookDao.findById(bookId); if (book null) { throw new IllegalArgumentException(图书不存在或已下架); } if (book.getStock() quantity) { throw new IllegalArgumentException(库存不足当前库存 book.getStock()); } CartDao cartDao new CartDao(); return cartDao.insertOrUpdate(userId, bookId, quantity); } }库存校验写在加购这一步而不是下单这一步能提前拦住大多数无效操作。但注意库存与真实下单之间有时间窗口所以下单时还需要再次校验库存购物车层和订单层各做一次前者是体验优化后者是数据正确性保障。CartDao.insertOrUpdate用 MySQL 的ON DUPLICATE KEY UPDATE合并插入和更新INSERT INTO t_cart_item (user_id, book_id, quantity) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE quantity quantity VALUES(quantity);这段 SQL 依赖建表时的UNIQUE KEY uk_user_book (user_id, book_id)触发“重复键”。用户第一次加购某本书时执行插入第二次再加唯一键冲突改为在原数量上加新数量。VALUES(quantity)是 MySQL 的写法含义是“引用 INSERT 语句里尝试写入的那个 quantity 值”注意这个语法在 MySQL 8.0.20 之后标记为废弃推荐改用别名语法AS new ON DUPLICATE KEY UPDATE quantity quantity new.quantity。如果你的 MySQL 版本比较新建议直接用别名写法行为完全一致。加购之后购物车页面需要根据user_id查询出所有条目并关联图书信息展示封面、书名、单价、数量、小计。这里最容易踩的坑是再次查出已经下架的图书所以在购物车查询 SQL 里要加上t_book.stock 0的判断或者引入 cart 和 book 的左连接过滤掉图书不存在的记录。4.3 下单事务先锁库存后写明细失败整单回滚下单是这个系统里唯一必须使用数据库事务的业务因为一个订单涉及三处数据变更插入订单主表、插入订单明细表、扣减图书库存。任何一个环节失败其他环节必须一起撤销否则会出现“订单建了但库存没扣”或“库存扣了但订单没了”的数据不一致。MySQL 的 InnoDB 引擎天然支持事务这里用setAutoCommit(false)手动控制package com.bookstore.service; import com.bookstore.dao.CartDao; import com.bookstore.entity.CartItem; import com.bookstore.util.DBUtil; import java.math.BigDecimal; import java.sql.*; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.List; import java.util.concurrent.ThreadLocalRandom; public class OrderService { public boolean createOrder(int userId, ListCartItem items) { Connection conn null; PreparedStatement psOrder null; PreparedStatement psItem null; PreparedStatement psStock null; PreparedStatement psClear null; try { conn DBUtil.getConnection(); // 关闭自动提交后续所有语句纳入同一个事务 conn.setAutoCommit(false); // 1. 插入订单主表拿到自增订单 ID String orderNo generateOrderNo(); psOrder conn.prepareStatement( INSERT INTO t_order (order_no, user_id, total_amount, status) VALUES (?, ?, ?, 0), Statement.RETURN_GENERATED_KEYS); psOrder.setString(1, orderNo); psOrder.setInt(2, userId); BigDecimal total BigDecimal.ZERO; for (CartItem item : items) { total total.add(item.getPrice().multiply(BigDecimal.valueOf(item.getQuantity()))); } psOrder.setBigDecimal(3, total); psOrder.executeUpdate(); ResultSet keys psOrder.getGeneratedKeys(); if (!keys.next()) { throw new SQLException(订单主表插入失败未获取到订单 ID); } int orderId keys.getInt(1); // 2. 写明细 扣库存两条 SQL 同时成功才算数 psItem conn.prepareStatement( INSERT INTO t_order_item (order_id, book_id, book_title, price, quantity) VALUES (?, ?, ?, ?, ?)); psStock conn.prepareStatement( UPDATE t_book SET stock stock - ? WHERE id ? AND stock ?); for (CartItem item : items) { // 先扣库存用 stock ? 条件确保不会扣成负数 psStock.setInt(1, item.getQuantity()); psStock.setInt(2, item.getBookId()); psStock.setInt(3, item.getQuantity()); int affected psStock.executeUpdate(); if (affected 0) { // 受影响的记录数为 0说明是库存不足而不是 SQL 报错 throw new SQLException(库存不足图书 ID: item.getBookId()); } // 库存扣减成功后再写明细 psItem.setInt(1, orderId); psItem.setInt(2, item.getBookId()); psItem.setString(3, item.getBookTitle()); psItem.setBigDecimal(4, item.getPrice()); psItem.setInt(5, item.getQuantity()); psItem.executeUpdate(); } // 3. 清空购物车与订单操作同事务 psClear conn.prepareStatement(DELETE FROM t_cart_item WHERE user_id ? AND book_id ?); for (CartItem item : items) { psClear.setInt(1, userId); psClear.setInt(2, item.getBookId()); psClear.addBatch(); } psClear.executeBatch(); conn.commit(); return true; } catch (SQLException e) { // 任何一条 SQL 失败回滚全部操作 if (conn ! null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } e.printStackTrace(); return false; } finally { // 复位自动提交不是必须的但连接归还给连接池时需要保证是干净状态 DBUtil.close(conn, null, null); } } private String generateOrderNo() { String time LocalDateTime.now().format(DateTimeFormatter.ofPattern(yyyyMMddHHmmss)); int random ThreadLocalRandom.current().nextInt(1000, 9999); return time random; } }这段代码把事务的骨架展示得很完整逻辑说明和参数说明如下setAutoCommit(false)是事务开启的开关。之后所有executeUpdate都不会立刻提交直到你显式调用commit()。如果程序在 commit 前抛异常rollback()会撤销本事务内的所有写入。订单总价用BigDecimal在整个流程传递避免double相加造成的精度误差。JSP 页面展示金额时也要保证单价是从数据库读取的BigDecimal而不是在 Servlet 里临时用字符串转 double。扣库存使用的UPDATE t_book SET stock stock - ? WHERE id ? AND stock ?是乐观的防超卖写法。它把库存判断和扣减合并成一条原子 SQL即使两个用户在极端并发下同时读到库存 1也只有一个用户的 UPDATE 影响行数为 1另一个用户影响行数为 0通过判断affected 0即可返回失败。为什么不先SELECT stock判断再UPDATE因为 SELECT 和 UPDATE 之间存在时间窗口做不到原子性。这个项目用它足够了如果是高并发电商系统通常还会引入分布式锁或扣减接口的幂等设计但 JSP 课程设计阶段掌握这个原子 UPDATE 就算过关。Statement.RETURN_GENERATED_KEYS配合getGeneratedKeys()是取自增主键的标准做法比单独再查一次SELECT LAST_INSERT_ID()更可靠因为后者如果连接被复用或中间插入别的事务可能拿到别的连接上的自增值。事务里使用executeBatch()批量清空购物车减少 SQL 往返次数如果不考虑性能也可以改成逐条 executeUpdate效果一样。下单完成后订单列表页和订单详情页从t_order和t_order_item两张表分别查询。订单列表按created_at DESC排序这个排序如果数据量大记得走index_order_user之后再做文件排序在t_order表上查某个用户订单时务必带WHERE user_id ?而不是全表扫。5. 避坑与排查MySQL 连接、中文乱码与 Tomcat 版本的 4 个翻车现场5.1 驱动、SSL 与 Public Key Retrieval 的连环报错现象项目启动后第一次访问数据库相关页面控制台直接抛出ClassNotFoundException: com.mysql.jdbc.Driver或者页面报SQLNonTransientConnectionException: Could not create connection to database server再细看还有SSL connection error和Public Key Retrieval is not allowed两条嵌套异常。原因这是三个问题串在一起但出身于同一个源头——MySQL 8.0 与老教程的配置不匹配。旧版驱动类名com.mysql.jdbc.Driver在 8.0 驱动里被移到com.mysql.cj.jdbc.DriverMySQL 8.0 默认开启 SSL 握手而本地开发环境没有证书默认认证插件caching_sha2_password在非 SSL 连接下第一次连接需要明文公钥驱动默认不允许自动获取。解决三步一起改。第一jar 确认是mysql-connector-j8.x驱动类名改成com.mysql.cj.jdbc.Driver旧名字虽然能打出警告但不该出现在新代码里。第二连接 URL 加上useSSLfalseallowPublicKeyRetrievaltrue本地开发直接关闭 SSL 握手生产环境如果在内网且要求安全再考虑开启 SSL 并配置证书。第三如果你的 MySQL 用户是root确认它使用的认证插件是caching_sha2_password还是mysql_native_password若是后者也要保留allowPublicKeyRetrievaltrue因为它同样走公钥流程。照着 3.2 节的db.properties全套参数核对一遍这三连坑基本一次清干净。5.2 中文乱码三处战场一个原则现象页面显示正常但用户名和图书标题入库后变成???或者数据库里存的中文正常JSP 页面刷新后变成一串乱码还有一种是 GET 请求的查询参数比如?keyword红楼梦在 Servlet 里取出来是乱的。原因JSP 项目的中文乱码从来不是单一原因而是编码链路上某个环节断层。涉及的位置至少有四处请求参数、响应输出、JSP 文件本身的存储编码、数据库连接与表的字符集。最常见的问题是只设了response.setContentType(text/html;charsetUTF-8)忘了request.setCharacterEncoding(UTF-8)导致 POST 表单里的中文在进入 Servlet 时就已经是乱码。GET 请求的乱码则更隐蔽因为request.setCharacterEncoding对 GET 参数不生效需要靠 Tomcat 的连接参数或对参数重新解码。解决按“全链路 UTF-8”原则过一遍顺序从外到内。第一JSP 文件头部写% page contentTypetext/html;charsetUTF-8 pageEncodingUTF-8 %并且文件另存为 UTF-8 编码IDE 默认可能是 GBK。第二Servlet 的doPost第一行写req.setCharacterEncoding(UTF-8)doGet里如果接收中文参数要么统一改用 POST 提交要么对参数重新new String(param.getBytes(ISO-8859-1), UTF-8)后一种属于 Tomcat 老版本的兼容写法。第三db.properties里连接 URL 带上characterEncodingutf8且建库建表用utf8mb4。第四web.xml里配置一个CharacterEncodingFilter过滤器把请求和响应编码统一打包处理这是最省心的一步。这四处都对齐乱码基本不会再来找茬。5.3 连接泄漏与 Too many connections现象系统运行半小时或一天后突然报java.sql.SQLException: Too many connections重启 Tomcat 恢复过一阵又复发。MySQL 的max_connections默认只有 151而每个 JDBCConnection在关闭前都会占住一个连接名额。原因代码里有连接没关闭。最常见的写法是在 try 块里获得连接后只写了业务逻辑漏了finally里的DBUtil.close或者抛出异常后提前 return连接没走关闭逻辑。另一个隐蔽点是ResultSet关闭了但Connection没关闭——很多人会直接依赖垃圾回收但垃圾回收只回收对象不保证驱动会立刻把连接归还给 MySQL。解决第一步回到每一个 DAO 方法确认finally块中调用了DBUtil.close(conn, st, rs)。第二步如果连接泄漏已经出现先不急着把max_connections调大那只是延缓问题先把所有 DAO 方法 grep 一遍找出没有 finally 的。第三步临时排查可以用SHOW PROCESSLIST;看哪些连接处于Sleep状态并结合connection_timeout字段判断是否泄漏。这一步做好后再考虑引入 HikariCP 连接池——连接池能复用连接、减少频繁建连的开销但前提是代码里的关闭逻辑正确否则连接池也救不了泄漏。5.4 Tomcat 10 的 jakarta 迁移与 JSP 编译失败现象把老教程的项目导入新版 IDE 配置的 Tomcat 10一启动就报InstantiationException: jakarta.servlet.Servlet或ClassNotFoundException: javax.servlet.http.HttpServlet项目里所有 Servlet 类全部红叉。原因Tomcat 10 是 Servlet API 5.0 的容器包名从javax.servlet改成了jakarta.servlet。网上大量 JSP 老项目、毕设源码都是javax.servlet写的直接跑在 Tomcat 10 上就会编译失败。这不是代码 bug是 API 迁移造成的兼容性断裂。解决最省事的做法是把 Tomcat 降回 9.x老代码一行不用改。如果你的机器上只有 Tomcat 10也可以全局替换导入把所有import javax.servlet.*替换成import jakarta.servlet.*同时web.xml的命名空间也要换成新的 schema但这样做只适合代码量小的项目而且后续参考老教程时又会对不上。所以我对这个项目的建议是用 Tomcat 9 JDK 8/11配套 JSTL 1.2这是 JSP 资料最多、兼容性最稳的组合。6. 验收清单与两个值得加的小细节封面图定位与订单号生成6.1 按功能点过一遍手工验收清单项目写完后不要急着交先按下面的功能点逐项手工验证一遍。这个清单是我自己在每次 JSP 项目交付前都会走一遍的能拦住 80% 的硬伤验证点操作预期结果用户注册注册一个含中文昵称的用户插入成功数据库中中文正常显示登录后可看到昵称登录拦截不登录直接访问/user/cart被过滤器重定向到login.jsp看不到购物车内容加购上限把库存只剩 2 的图书数量加到 3页面提示“库存不足”购物车中该图书数量不变并发扣库存两个浏览器同时下单买同一本只剩 1 本的图书只有一个下单成功另一个收到“库存不足”提示订单数据下单后分别查看t_order和t_order_item主表与明细表数量一致总价与各项单价乘数量之和相等购物车清空下单成功后回到购物车页购物车已清空订单列表里能查到新订单空值校验登录或注册时输入空密码页面给出错误提示不产生数据库异常页面乱码搜索“红楼梦”后刷新页面再查询搜索前后页面中文均正常URL 参数中无%E6%A2%81这类半截乱码6.2 两个低成本加分细节封面图坐标定位与更稳的订单号第一个细节是 JSP 页面里的图片定位。图书购物网站的首页经常要展示“热门图书推荐”这类横幅设计稿里会在图书封面左上角叠一个角标或价格标签。很多人问“jsp 图片如何对坐标定位”其实 JSP 本身不负责定位它是服务端渲染定位是纯 CSS 的事给图片包一层相对定位的 div让角标用绝对定位参照这个 div 对齐。div styleposition: relative; width: 180px; height: 240px; img src${book.coverUrl} width180 height240 alt${book.title} span styleposition: absolute; top: 8px; left: 8px; background: #e4393c; color: #fff; padding: 2px 6px; font-size: 12px; ${book.price} /span /divposition: relative是定位参照系absolute的top和left都以这个 div 的左上角为原点所以角标能稳定贴在封面左上角。这个技巧在图书横幅、套装书标签、限时折扣角标里非常常用核心就一句话relative给参照absolute给坐标。第二个细节是订单号生成。课程设计里最常见的做法是把t_order.id自增主键直接当成订单号展示给用户但这样做有两个问题一是用户能从订单号推测出今天的订单量二是多系统对接时自增 ID 不具备唯一性。我在 4.3 的代码里用的方案是“时间戳 随机数”yyyyMMddHHmmss加四位随机数生成如202506121430251234这样的订单号。同一秒内两个用户同时下单四位随机数撞上的概率极低而即使真的撞上t_order表上有uk_order_no唯一键兜底数据库会拒绝第二次插入并触发回滚。最后说一个我这几年踩出来的习惯每次大改代码后不是先跑 Tomcat而是先在 MySQL 里用EXPLAIN SELECT ... FROM t_order WHERE user_id 1 ORDER BY created_at DESC看一眼索引走没走。JSP 项目规模不大性能不是瓶颈但这个习惯能让你在面试被问“MySQL 性能调优”时说出真实案例而不是只背概念。希望这些配置、代码和血泪经验能帮你把项目稳稳落地少熬几个查异常的夜。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑