J2EE教学电商项目:Servlet+JSP+Filter+JDBC实战解析
简介这是一套基于J2EE技术栈开发的完整在线图书销售系统——“云工厂-网上书城”面向Java Web初学者与企业级应用进阶学习者聚焦多层架构设计、前后端协同与数据库集成等核心实践问题。资源包含588个文件涵盖83个Java源码如BookInfo、AdminInfoDAO等业务类、45个JSP页面实现用户购书、订单管理等交互视图、82个编译后class文件、313个GIF动图含界面操作示意或图标资源以及SQL建表脚本、CSS样式、JAR依赖库和数据库文件.db等整体压缩包仅4.09MB轻量易部署。已有242人学习下载适合在Tomcat等J2EE容器中快速运行调试。读者可直接获取从Servlet请求分发、JSP动态渲染、JavaBeans数据封装、JDBC数据库操作到EJB事务管理的全链路代码实现尤其利于理解MVC分层结构与典型电商模块如购物车GwcServlet、收藏夹FavoritesInfoDAO、订单保存SaveOrderServlet的设计逻辑。1. 一个被低估的 J2EE 教学级电商骨架它不跑在云上却真能让你把 Servlet、JSP、Filter、JDBC 这套“古早但硬核”的 Web 开发链路从黑匣子抠成白盒你可能刚在某高校《Web 应用开发》课上被要求交一个“网上书城”大作业或者正卡在 Spring Boot 配置里怀疑人生想回溯下最原始的请求怎么从浏览器进到数据库再吐回页面——这时候“云工厂-网上书城”不是个噱头项目它是一份带完整部署路径的 J2EE 实战切片。它没用 Spring、没碰 Maven 多模块、不依赖任何云服务控制台纯靠 Tomcat 原生 Servlet JSP JDBC 自定义 Filter MySQL 构建出用户注册、登录、图书浏览、购物车、订单提交、后台管理等闭环功能。所有代码都在一个标准 Java Web 工程目录里WEB-INF 下 web.xml 清晰可见每个 .java 文件都能双击打开调试。它不适合上线但特别适合你亲手打断点看 session 是怎么跨请求存的、看 request.getParameter() 背后到底做了什么、看 Filter 怎么拦截 /admin/* 路径。如果你需要的不是“跑起来就行”而是“改一行代码就明白为什么”这个项目就是那把锈迹斑斑但齿纹清晰的螺丝刀。2. 工程结构与技术栈解剖一个标准 J2EE Web 应用该长什么样2.1 目录结构即设计文档从 /src 到 /WEB-INF/web.xml 的每一层都藏着决策逻辑这个项目采用最经典的 Java Web 工程布局没有隐藏文件夹没有混淆命名所有路径都遵循 Servlet 规范。整个工程解压后是标准的 Eclipse Dynamic Web Project 结构cloud-factory-bookstore/ ├── src/ # Java 源码根目录 │ ├── dao/ # 数据访问层BookDao.java、UserDao.java 等封装 JDBC 操作 │ ├── entity/ # 实体类Book.java、User.java、Order.java字段与数据库表严格对应 │ ├── filter/ # 过滤器LoginFilter.java拦截未登录访问、AdminFilter.java校验管理员权限 │ ├── servlet/ # 核心控制器LoginServlet.java、AddToCartServlet.java、SubmitOrderServlet.java 等 │ └── util/ # 工具类DBUtil.java单例 DataSource 获取、StringUtil.java空值处理 ├── WebContent/ # Web 资源根目录Eclipse 旧称等同于标准的 webapp/ │ ├── css/ # 静态资源 │ ├── images/ │ ├── js/ │ ├── WEB-INF/ │ │ ├── web.xml # 全局配置中枢servlet-mapping、filter-mapping、welcome-file-list 全在这里 │ │ ├── lib/ # 依赖 JARmysql-connector-java-5.1.47.jar、jstl-1.2.jar、standard-1.1.2.jar │ │ └── views/ # JSP 页面集合非标准但实用的组织方式index.jsp、login.jsp、cart.jsp、admin/book_list.jsp │ ├── index.jsp # 默认首页含图书轮播分类导航热销榜 │ └── error.jsp # 统一错误页通过 web.xml 中的 error-page 关联 └── build.xml # Ant 构建脚本非必须但项目附带说明作者考虑了可重复构建提示WEB-INF/views/这个子目录不是 Servlet 规范强制要求的但它是一种成熟实践——把所有业务 JSP 放进views/再配合RequestDispatcher转发能有效避免用户直接 URL 访问未登录页面如http://localhost:8080/cart.jsp因为WEB-INF下资源默认禁止外部直连。这是比单纯用 Filter 拦截更底层的安全加固。2.2 web.xml整个应用的“宪法”37 行配置决定所有请求走向web.xml是这个项目的灵魂文件它不写一行 Java 逻辑却定义了所有组件的生命周期和协作关系。我们来逐段拆解关键配置?xml version1.0 encodingUTF-8? web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd version4.0 !-- 1. 应用名与欢迎页 -- display-nameCloudFactory BookStore/display-name welcome-file-list welcome-fileindex.jsp/welcome-file /welcome-file-list !-- 2. Servlet 注册核心业务入口 -- servlet servlet-nameLoginServlet/servlet-name servlet-classservlet.LoginServlet/servlet-class /servlet servlet-mapping servlet-nameLoginServlet/servlet-name url-pattern/login/url-pattern /servlet-mapping servlet servlet-nameAddToCartServlet/servlet-name servlet-classservlet.AddToCartServlet/servlet-class /servlet servlet-mapping servlet-nameAddToCartServlet/servlet-name url-pattern/addtocart/url-pattern /servlet-mapping !-- 3. Filter 注册横切关注点 -- filter filter-nameLoginFilter/filter-name filter-classfilter.LoginFilter/filter-class /filter filter-mapping filter-nameLoginFilter/filter-name url-pattern/cart.jsp/url-pattern url-pattern/order.jsp/url-pattern url-pattern/user/*/url-pattern /filter-mapping filter filter-nameAdminFilter/filter-name filter-classfilter.AdminFilter/filter-class /filter filter-mapping filter-nameAdminFilter/filter-name url-pattern/admin/*/url-pattern /filter-mapping !-- 4. 错误页映射 -- error-page error-code404/error-code location/error.jsp?code404/location /error-page error-page exception-typejava.lang.Exception/exception-type location/error.jsp?code500/location /error-page /web-app这段 XML 的价值远超配置本身。它告诉你请求路由是显式声明的/login→LoginServlet不是注解扫描没有魔法Filter 的作用域是精确控制的LoginFilter只管/cart.jsp、/order.jsp和/user/*而AdminFilter单独守/admin/*职责分离清晰错误处理是统一兜底的所有未捕获异常都会跳转到error.jsp且带code参数方便前端做差异化提示版本明确为 4.0意味着它兼容 Tomcat 8.5不依赖 Java EE 8 的新特性如 CDI降低环境门槛。2.3 数据库设计5 张表撑起完整业务字段命名直白无抽象项目附带bookstore.sql初始化脚本共 5 张表无外键约束简化教学但主键、索引、字段类型均符合生产常识表名主要字段说明usersid,username,password,email,role(user/admin)用户表role字段用于权限分流booksid,title,author,price,stock,category,cover_url图书主表cover_url存相对路径如/images/books/java.jpgcartsid,user_id,book_id,quantity购物车表user_idbook_id联合唯一避免重复添加ordersid,user_id,total_amount,status(pending/paid/shipped),create_time订单主表状态机雏形order_itemsid,order_id,book_id,quantity,price_at_order订单明细记录下单时价格快照防止图书调价影响历史订单注意carts表没有session_id字段而是用user_id关联。这意味着它默认只支持登录用户加购未登录用户购物车需另存 Session该项目未实现。这是个刻意的设计取舍——聚焦已登录场景降低复杂度。若你要扩展游客购物车只需在AddToCartServlet中增加HttpSession判断分支并将cart对象存入 session而非查库。3. 核心模块代码精读从 LoginServlet 到 AdminFilter看请求如何穿越层层关卡3.1 LoginServlet一次登录背后的三次关键判断LoginServlet是用户进入系统的第一个 Java 类它浓缩了 J2EE Web 开发最基础也最关键的三重校验逻辑。我们来看它的doPost方法核心片段protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 1. 获取参数注意getParameter() 自动处理 URL 编码无需手动 decode String username request.getParameter(username); String password request.getParameter(password); // 2. 基础校验空值防御教学项目常忽略但生产必须有 if (username null || username.trim().isEmpty() || password null || password.trim().isEmpty()) { request.setAttribute(errorMsg, 用户名或密码不能为空); request.getRequestDispatcher(/login.jsp).forward(request, response); return; // 提前退出避免后续执行 } // 3. 业务校验查库验证DAO 层封装了 Connection 获取、PreparedStatement 设置、ResultSet 解析 User user new UserDao().findByUsername(username); if (user null || !user.getPassword().equals(password)) { request.setAttribute(errorMsg, 用户名或密码错误); request.getRequestDispatcher(/login.jsp).forward(request, response); return; } // 4. 状态维护登录成功写入 session关键session 是跨请求状态载体 HttpSession session request.getSession(true); // true 表示不存在则创建 session.setAttribute(currentUser, user); // 将用户对象存入 session session.setMaxInactiveInterval(1800); // 设置 session 30 分钟无操作过期秒 // 5. 重定向避免 F5 刷新重复提交Post-Redirect-Get 模式 if (admin.equals(user.getRole())) { response.sendRedirect(request.getContextPath() /admin/book_list.jsp); } else { response.sendRedirect(request.getContextPath() /index.jsp); } }这段代码的“教学价值”在于它暴露了三个容易被框架掩盖的细节request.getParameter()的安全性它自动解码 URL 编码如%E4%B8%AD%E6%96%87→中文但对 SQL 注入毫无防护——这正是 DAO 层必须用PreparedStatement而非字符串拼接的原因request.getRequestDispatcher().forward()vsresponse.sendRedirect()前者是服务器端内部跳转URL 不变request 属性如errorMsg可传递后者是客户端重定向URL 变request 属性丢失但能防止表单重复提交HttpSession的生命周期管理setMaxInactiveInterval()是硬性设置但实际过期还受容器Tomcat全局配置影响。教学中常忽略这点导致学生调试时发现 session “莫名消失”。3.2 LoginFilter如何用 Filter 实现“未登录用户只能看首页和登录页”LoginFilter是权限控制的第一道闸门它不处理业务只做放行/拦截决策。其doFilter方法逻辑极简却体现了 Filter 的典型模式public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; // 1. 获取当前请求 URI不带上下文路径如 /cart.jsp String uri request.getRequestURI(); String contextPath request.getContextPath(); // 2. 白名单首页、登录页、注册页、静态资源一律放行 if (uri.equals(contextPath /index.jsp) || uri.equals(contextPath /login.jsp) || uri.equals(contextPath /register.jsp) || uri.startsWith(contextPath /css/) || uri.startsWith(contextPath /js/) || uri.startsWith(contextPath /images/)) { chain.doFilter(request, response); // 放行给下一个 Filter 或 Servlet return; } // 3. 核心判断检查 session 中是否存在 currentUser HttpSession session request.getSession(false); // false 表示不创建新 session User user (User) session.getAttribute(currentUser); if (user null) { // 未登录重定向到登录页并携带原请求地址便于登录后跳回 String targetUrl uri ? request.getQueryString(); response.sendRedirect(contextPath /login.jsp?redirect URLEncoder.encode(targetUrl, UTF-8)); return; } // 4. 已登录放行 chain.doFilter(request, response); }这个 Filter 的精妙之处在于第 2 步的白名单设计。它没有用正则匹配而是用equals()和startsWith()做精确字符串判断性能高、无歧义。更重要的是它把/login.jsp放在白名单里形成闭环——否则用户永远无法到达登录页。而redirect参数的传递第 3 步末尾则是用户体验的关键用户从/cart.jsp被拦下登录后应自动跳回/cart.jsp而不是首页。这个参数在LoginServlet登录成功后会被读取并用于重定向构成完整的“拦截-跳转-登录-返回”链路。3.3 AdminFilter基于角色的细粒度拦截一行代码区分 admin/user 权限AdminFilter的逻辑比LoginFilter更进一步它假设用户已登录由LoginFilter保证只专注角色校验。其核心就一句话// 在 AdminFilter.doFilter() 中 User user (User) session.getAttribute(currentUser); if (user null || !admin.equals(user.getRole())) { response.sendError(HttpServletResponse.SC_FORBIDDEN, Access Denied: Admin Role Required); return; }这里用sendError()而非sendRedirect()是因为/admin/*下的所有资源如book_list.jsp、add_book.jsp都属于敏感后台不应提供任何跳转入口直接返回 403 Forbidden 状态码是最符合 REST 语义的做法。web.xml中已配置error-page捕获 403会自动展示error.jsp并显示友好提示。提示admin.equals(user.getRole())的写法是经典防御式编程。如果user.getRole()返回nulladmin.equals(null)安全返回false而user.getRole().equals(admin)会抛NullPointerException。这个细节在教学项目中常被忽略却是生产环境血泪经验。4. 运行与部署从零开始在本地 Tomcat 上跑通避开 90% 的新手翻车点4.1 环境准备JDK 8 Tomcat 8.5 MySQL 5.7版本锁死是稳定前提这个项目对环境版本有明确要求不是“理论上兼容”而是实测通过。强行升级会导致不可预知问题JDK必须是 JDK 81.8不能是 JDK 11。原因web.xml声明version4.0对应 Servlet 4.0 规范而 Tomcat 8.5 是首个完全支持 Servlet 4.0 的版本它要求 JDK 8。JDK 11 的模块化JPMS会干扰传统WEB-INF/lib下 JAR 的类加载。Tomcat推荐 Tomcat 8.5.99最新 8.5.x 版本避免使用 Tomcat 9。Tomcat 9 默认启用 HTTP/2 和更严格的 MIME 类型检查可能报404或406 Not Acceptable而项目中的web.xml和 JSP 没有适配这些新规则。MySQL5.7 是黄金版本。项目 SQL 脚本使用utf8mb4字符集支持 emoji而 MySQL 5.7 默认支持MySQL 8.0 的caching_sha2_password认证插件与mysql-connector-java-5.1.47.jar不兼容会导致Access denied for user错误。提示下载 Tomcat 8.5 后务必修改conf/tomcat-users.xml添加管理员账号否则无法访问 Manager App虽本项目不用但调试时可能需要role rolenamemanager-gui/ role rolenamemanager-script/ user usernameadmin passwordadmin123 rolesmanager-gui,manager-script/4.2 数据库初始化执行 bookstore.sql 的三个关键步骤项目根目录下的bookstore.sql是唯一数据源执行它需三步缺一不可创建数据库并指定字符集在 MySQL 命令行或 Workbench 中执行CREATE DATABASE bookstore DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; USE bookstore;注意必须用utf8mb4不是utf8。MySQL 的utf8实际是utf8mb3不支持 4 字节 Unicode如某些 emoji、生僻汉字而项目books.title字段可能含中文utf8mb4才是真正完整的 UTF-8。执行建表与初始数据继续在同一会话中-- 粘贴 bookstore.sql 全部内容执行 -- 它会创建 5 张表并插入 3 条测试用户含 1 名 admin和 10 本测试图书验证连接在src/util/DBUtil.java中确认数据库连接 URL、用户名、密码与你本地一致private static final String URL jdbc:mysql://localhost:3306/bookstore?useSSLfalseserverTimezoneUTC; private static final String USER root; // 你的 MySQL 用户名 private static final String PASSWORD 123456; // 你的 MySQL 密码如果 MySQL root 密码不是123456请在此处修改。useSSLfalse是为简化本地开发生产环境必须开启 SSL。4.3 Eclipse 导入与部署标准 Dynamic Web Project 流程项目是标准 Eclipse 工程无需额外转换解压项目到工作空间目录如D:\workspace\cloud-factory-bookstoreEclipse → File → Import → Web → Existing Dynamic Web ProjectBrowse选择解压后的文件夹确保Project name显示正确如cloud-factory-bookstore勾选Copy project into workspace可选但推荐FinishEclipse 会自动识别WebContent/为 Web Rootsrc/为 Source Folder右键项目 → Properties → Targeted Runtimes勾选你已配置好的Apache Tomcat v8.5右键项目 → Run As → Run on Server选择 Tomcat 8.5点击 Finish。注意首次运行时Eclipse 可能提示The project was not published这是正常现象。等待几秒Tomcat 启动日志中出现INFO [main] org.apache.catalina.startup.Catalina.start Server startup in [xxx] milliseconds即表示成功。此时访问http://localhost:8080/cloud-factory-bookstore/即可看到首页。5. 避坑指南那些让开发者抓狂的常见问题与血泪解决方案5.1 现象启动 Tomcat 后访问首页报 404控制台无明显错误原因Eclipse 未将项目正确发布到 Tomcat 的webapps/目录或项目名称与访问路径不匹配。解决检查 Eclipse 的Servers视图双击Tomcat v8.5 Server在Modules标签页中确认cloud-factory-bookstore的Path是/cloud-factory-bookstore即上下文路径。若Path是/则访问http://localhost:8080/即可若是/cloud-factory-bookstore则必须访问http://localhost:8080/cloud-factory-bookstore/。强制重新发布右键Servers中的 Tomcat →Clean...→Clean Tomcat work directory再右键项目 →Refresh最后重启 Tomcat。5.2 现象登录时输入正确账号密码却始终提示“用户名或密码错误”原因DBUtil.java中的数据库连接失败UserDao.findByUsername()返回null但控制台未打印异常因异常被try-catch吞掉且未e.printStackTrace()。解决打开src/dao/UserDao.java找到findByUsername方法在catch (SQLException e)块中添加e.printStackTrace();。重启 Tomcat尝试登录观察控制台输出。常见错误是Communications link failureMySQL 未启动或Access denied用户名密码错误。若控制台无输出说明异常未被捕获检查DBUtil.getConnection()是否在static块中初始化DataSource时就失败了此时需在DBUtil的static块中加System.out.println输出初始化状态。5.3 现象添加图书到购物车后刷新页面购物车为空原因AddToCartServlet中未正确获取HttpSession或carts表中user_id与当前登录用户不匹配。解决在AddToCartServlet.doPost()开头添加日志System.out.println(Current user ID from session: ((User)session.getAttribute(currentUser)).getId());在AddToCartServlet的数据库插入逻辑中确认INSERT INTO carts (user_id, book_id, quantity) VALUES (?, ?, ?)的第一个?是从 session 中取出的user.getId()而非request.getParameter(user_id)后者不可信。检查LoginFilter是否真的生效在LoginFilter.doFilter()中加System.out.println(LoginFilter triggered for: uri);访问/cart.jsp看是否输出若无输出说明web.xml中filter-mapping的url-pattern配置错误如多写了/。5.4 现象JSP 页面中文显示为乱码如“????”原因JSP 文件本身编码、Tomcat 的 URI 编码、JSP 的 pageEncoding 三者不一致。解决确保所有.jsp文件用 UTF-8 编码保存Eclipse 中右键 JSP → Properties → Resource → Text file encoding → Other → UTF-8。在每个 JSP 顶部添加% page pageEncodingUTF-8 contentTypetext/html;charsetUTF-8 %。修改 Tomcat 的conf/server.xml在Connector标签中添加URIEncodingUTF-8属性例如Connector port8080 protocolHTTP/1.1 connectionTimeout20000 redirectPort8443 URIEncodingUTF-8 /重启 Tomcat。5.5 现象后台管理页/admin/book_list.jsp报 403 Forbidden原因AdminFilter生效但当前登录用户role字段值不是admin或session.getAttribute(currentUser)为null。解决使用初始数据中的管理员账号登录用户名admin密码admin123bookstore.sql中已插入。若忘记初始账号可直接在 MySQL 中执行UPDATE users SET role admin WHERE username testuser;在AdminFilter.doFilter()中添加日志System.out.println(AdminFilter: user role user.getRole());确认值为admin。检查web.xml中AdminFilter的filter-mapping确保url-pattern/admin/*/url-pattern没有拼写错误如写成/admi/*。6. 二次开发实战从“能跑”到“能改”三个可立即上手的改造技巧6.1 技巧一为图书列表页增加搜索功能GET 参数驱动5 分钟完成当前index.jsp只显示全部图书缺乏搜索。我们利用 Servlet 的doGet方法和 JSP 的 EL 表达式快速添加一个按书名模糊搜索的功能。步骤 1修改servlet.BookListServlet新增doGet方法protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String keyword request.getParameter(keyword); // 获取搜索关键词 ListBook books; if (keyword ! null !keyword.trim().isEmpty()) { // 调用 DAO 的新方法执行 LIKE 查询 books new BookDao().searchByTitle(keyword.trim()); } else { // 无关键词查全部 books new BookDao().findAll(); } request.setAttribute(books, books); request.setAttribute(keyword, keyword); // 传回关键词用于搜索框回显 request.getRequestDispatcher(/WEB-INF/views/index.jsp).forward(request, response); }步骤 2在BookDao.java中添加searchByTitle方法public ListBook searchByTitle(String keyword) { String sql SELECT * FROM books WHERE title LIKE ?; ListBook books new ArrayList(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, % keyword %); // 注意前后加 % 实现模糊匹配 try (ResultSet rs ps.executeQuery()) { while (rs.next()) { books.add(new Book(rs)); } } } catch (SQLException e) { e.printStackTrace(); } return books; }步骤 3修改index.jsp在图书列表上方添加搜索表单!-- 在 h2图书列表/h2 上方插入 -- form action${pageContext.request.contextPath}/booklist methodget classmb-4 div classinput-group input typetext namekeyword classform-control placeholder请输入书名关键词... value${param.keyword} !-- EL 表达式回显关键词 -- div classinput-group-append button classbtn btn-outline-secondary typesubmit搜索/button /div /div /form验证启动项目访问http://localhost:8080/cloud-factory-bookstore/booklist?keywordJava即可看到只显示书名含 “Java” 的图书。这个改造没有动任何配置文件纯粹在 Servlet-JSP-DAO 三层间加了一条 GET 请求链路是理解 MVC 模式最直观的练习。6.2 技巧二将购物车从数据库持久化改为 Session 内存存储解耦数据库提升并发当前购物车数据存在carts表中每次增删都要查库对教学演示足够但若想模拟高并发场景内存购物车更合适。我们用HttpSession存储一个MapInteger, IntegerbookId - quantity彻底绕过数据库。步骤 1在servlet.AddToCartServlet中替换数据库操作为 Session 操作// 删除原有的数据库插入逻辑 // 新增 HttpSession session request.getSession(); MapInteger, Integer cart (MapInteger, Integer) session.getAttribute(cart); if (cart null) { cart new HashMap(); session.setAttribute(cart, cart); } int bookId Integer.parseInt(request.getParameter(bookId)); int quantity Integer.parseInt(request.getParameter(quantity)); // 累加数量非覆盖 cart.put(bookId, cart.getOrDefault(bookId, 0) quantity); // 重定向回来源页通常为图书详情页或列表页 String referer request.getHeader(Referer); if (referer ! null referer.contains(request.getContextPath())) { response.sendRedirect(referer); } else { response.sendRedirect(request.getContextPath() /index.jsp); }步骤 2修改servlet.ShowCartServlet负责显示购物车从 Session 读取protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { HttpSession session request.getSession(); MapInteger, Integer cart (MapInteger, Integer) session.getAttribute(cart); if (cart null || cart.isEmpty()) { request.setAttribute(cartItems, Collections.emptyList()); } else { // 根据 bookId 查数据库获取图书详情组装成 CartItem 对象列表 ListCartItem cartItems new ArrayList(); for (Map.EntryInteger, Integer entry : cart.entrySet()) { Book book new BookDao().findById(entry.getKey()); if (book ! null) { cartItems.add(new CartItem(book, entry.getValue())); } } request.setAttribute(cartItems, cartItems); } request.getRequestDispatcher(/WEB-INF/views/cart.jsp).forward(request, response); }步骤 3为CartItem创建一个简单 POJOsrc/entity/CartItem.javapublic class CartItem { private Book book; private int quantity; public CartItem(Book book, int quantity) { this.book book; this.quantity quantity; } // getter/setter 略 }这个改造的价值在于它让你亲手体验了“状态存储位置”的权衡。数据库持久化保证数据不丢失但 IO 成本高Session 内存存储响应快但服务器重启或 Session 过期即丢失。没有银弹只有根据场景选择。从那以后我每次设计状态存储都强制走一遍“数据重要性 vs 性能要求 vs 实现成本”三问再决定放哪。6.3 技巧三为订单提交增加库存校验事务 乐观锁避免超卖当前SubmitOrderServlet直接扣减库存若两个用户同时下单同一本只剩 1 库存的书会变成负数超卖。我们用数据库事务 UPDATE ... WHERE stock ?实现乐观锁校验。步骤 1修改SubmitOrderServlet的核心逻辑// 在创建订单前先校验并扣减库存 int bookId ...; // 从购物车获取 int quantity ...; Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 关键UPDATE 语句带库存条件返回影响行数 String updateSql UPDATE books SET stock stock - ? WHERE id ? AND stock ?; try (PreparedStatement ps conn.prepareStatement(updateSql)) { ps.setInt(1, quantity); ps.setInt(2, bookId); ps.setInt(3, quantity); int rows ps.executeUpdate(); if (rows 0) { // 库存不足回滚事务 conn.rollback(); request.setAttribute(errorMsg, 库存不足无法提交订单); request.getRequestDispatcher(/cart.jsp).forward(request, response); return; } } // 库存扣减成功继续创建订单和订单明细... // 原有 insert orders 和 order_items 的逻辑 conn.commit(); // 提交事务 } catch (SQLException e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ignored) {} } e.printStackTrace(); request.setAttribute(errorMsg, 订单提交失败请重试); request.getRequestDispatcher(/cart.jsp).forward(request, response); } finally { if (conn ! null) { try { conn.close(); } catch (SQLException ignored) {} } }步骤 2在bookstore.sql中为books.stock字段添加 CHECK 约束可选双重保险ALTER TABLE books ADD CONSTRAINT chk_stock_nonnegative CHECK (stock 0);这个改造引入了真正的生产级思维用数据库原生能力做并发安全校验而非在 Java 层用synchronized无效因为多实例部署或SELECT ... FOR UPDATE悲观锁性能差。UPDATE ... WHERE stock ?是典型的乐观锁实践它假设冲突少用一次原子更新完成校验与扣减本文还有配套的精品资源点击获取