简介一套基于JSPServletMySQL的游戏购买网站完整项目面向Java Web初学者、课程设计或毕业设计开发者完整实现管理员与用户双端业务。管理员端涵盖登录、订单管理、客户管理、游戏管理和类目管理用户端包含首页、登录、购买游戏、购物车、提交订单与查看订单业务链路清晰。资源共246个文件含55个Java源码、55个class编译文件、20个JSP页面、SQL脚本及XML配置另有JS/CSS、jpg/png图片素材等压缩包约14.85MB便于导入Eclipse/MyEclipse/IDEA配合Tomcat 8.x/9.x运行。目前已有96人学习使用。资料随附管理员与普通用户测试账号并采用ServletDAO分层设计能直观看到登录鉴权、数据访问、购物车与订单状态在经典Java Web架构中的落地写法适合直接参考、学习或二次开发。1. 基于 JSPServletMySQL 的游戏购买网站这个老技术组合为什么还能打前阵子 A 同学拿「基于 jspservletmysql 游戏购买网站设计」来问课程设计要求用这三件套做出游戏上架、注册登录、购物车、下单结算。这组合听着老但正因为它老Servlet 的请求流转、JSP 的渲染、手写 SQL 全没框架遮挡学完再看 Spring Boot 会通透很多。这个选题落地的是一整套典型 Java Web 业务浏览游戏清单、分类筛选、登录加购、提交订单扣库存。MySQL 存数据JSP 渲染页面Servlet 接请求再分发到 DAO 层一台装了 JDK、Tomcat、MySQL 的电脑就能跑完。适合两类人要交课程设计、想把功能跑通还能讲清原理的同学以及刚学完 Java 基础、想用真实业务串一遍这三件的自学者。下面按最常见方案一路拆到上线前优化新手里程碑明确熟手也能看到边界和坑。2. JSPServletMySQL 的三层架构表结构与工程目录怎么搭2.1 为什么这个组合还没过时很多同学一上来就问都什么年代了为什么课程设计还给 JSPServlet这问题得从两个层面看。工程选型层面新项目确实主流是前后端分离加 Spring BootJSP 在大型项目里已经很少出现但学习价值层面Servlet 是 Java Web 的根Spring MVC 的 DispatcherServlet 本质上还是一个 ServletJSP 的九大内置对象、作用域和标签语法在 Thymeleaf、Freemarker 里都能看到影子。把这一套跑通HTTP 请求从浏览器到服务器再到数据库的完整路径就有了体感后面学框架是在学「怎么少写重复代码」而不是在黑匣子里试错。这个选题的架构是最典型的 Model 2JSP 负责视图Servlet 负责控制器DAO 层负责数据访问MySQL 负责存储。JSP 里不写业务逻辑Servlet 里不写 SQL各层职责清楚。面试时被问三层架构能拿这个项目把每一层做什么、为什么这么拆讲明白比背概念有用得多。2.2 游戏购买网站的表结构设计先说表设计。做这种游戏商城六张表足够用户表 user、游戏分类表 category、游戏表 game、订单表 orders、订单明细表 order_item还有一张可以等做到购物车时再决定——是建 cart 表还是用 Session 存。我的建议是课程设计阶段直接用 Session原因到第 4 章再说。下面是建库脚本。CREATE DATABASE IF NOT EXISTS game_shop DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE game_shop; -- 用户表 CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, email VARCHAR(100), phone VARCHAR(20), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 游戏分类表 CREATE TABLE category ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 游戏表 CREATE TABLE game ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, category_id INT, price DECIMAL(10,2) NOT NULL, cover VARCHAR(200), description TEXT, stock INT DEFAULT 0, sales INT DEFAULT 0, status TINYINT DEFAULT 1, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_game_category FOREIGN KEY (category_id) REFERENCES category(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单表 CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL UNIQUE, user_id INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0 COMMENT 0待支付 1已支付 2已取消, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_order_user FOREIGN KEY (user_id) REFERENCES user(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单明细表 CREATE TABLE order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, game_id INT NOT NULL, price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL, CONSTRAINT fk_item_order FOREIGN KEY (order_id) REFERENCES orders(id), CONSTRAINT fk_item_game FOREIGN KEY (game_id) REFERENCES game(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;几个参数说明。数据库和所有表都用 utf8mb4因为游戏名称和描述里可能出现特殊符号utf8mb4 比 utf8 多覆盖了 emoji 和生僻字避免后期入库报错。DECIMAL(10,2) 存价格不能用 FLOAT浮点数算总价会出现 0.899999 这类精度问题这是做商城的一个老坑。订单表单独放 order_no 业务订单号不直接用自增 id 暴露给用户后面做订单查询和客服对账都靠它。status 字段用 TINYINT 存状态码比存字符串省空间也比用魔法数字裸写要好枚举值含义写在 COMMENT 里。订单为什么要拆 orders 和 order_item 两张表因为一个订单可能包含多款游戏订单表只管总金额和状态明细表管每个游戏下单时的快照价格。注意 order_item 里存的是下单那一刻的 price不是去关联 game 表现查——游戏改价后历史订单不能被影响这是表结构里最容易忽略的一点。2.3 工程目录与分层规划我一般用 Maven 建一个 war 包项目目录如下。game-shop/ ├─ src/main/java/ │ ├─ com.gameshop.entity User、Game、Category、Order、OrderItem、Cart │ ├─ com.gameshop.dao UserDao、GameDao、OrderDao │ ├─ com.gameshop.service UserService、GameService、OrderService │ ├─ com.gameshop.servlet LoginServlet、RegisterServlet、CartServlet、OrderServlet │ ├─ com.gameshop.filter EncodingFilter、LoginFilter │ └─ com.gameshop.util DBUtil ├─ src/main/webapp/ │ ├─ index.jsp 游戏列表页 │ ├─ login.jsp / register.jsp │ ├─ game_detail.jsp │ ├─ cart.jsp │ ├─ order_confirm.jsp │ ├─ order_success.jsp │ └─ WEB-INF/web.xml └─ pom.xml这里的核心约定JSP 只放 HTML 和 JSTL 标签偶尔用 EL 表达式取值Servlet 只做参数接收、调用 service、决定跳转SQL 全在 DAO 层。很多同学图省事把 SQL 写在 Servlet 里页面渲染也在 Servlet 里拼 HTML短期能跑但一旦要加功能会很痛苦——这就是 Model 1 的写法不推荐。实体类字段和表字段一一对应但 Java 驼峰和数据库下划线在查询时要对上。比如 user 表的 create_time 字段映射到 User 类的 createTime 属性SQL 里要么写别名create_time AS createTime要么用 MyBatis 那种下划线自动映射这里我们手写 JDBC所以别名要写清楚。顺序别搞反先定表再写实体最后写 DAO反向推会理不清。3. 数据库初始化与注册登录从 INSERT 脚本到第一个 Servlet3.1 初始化数据分类和游戏脚本表建好之后先塞几条数据不然列表页空白看不出效果。INSERT INTO category(name) VALUES (角色扮演), (动作冒险), (策略), (休闲); INSERT INTO game(name, category_id, price, cover, description, stock, sales) VALUES (暗影之刃, 2, 88.00, images/action01.jpg, 以连击为核心的硬核动作游戏支持手柄。, 200, 15), (星海旅人, 1, 128.00, images/rpg01.jpg, 科幻题材开放世界主线剧情约 40 小时。, 150, 28), (像素农场, 4, 28.00, images/casual01.jpg, 种田模拟经营适合碎片时间。, 500, 96), (帝国棋局, 3, 68.00, images/strategy01.jpg, 回合制策略战棋PVE 战役模式。, 300, 44);cover 字段存的是相对路径图片放在 webapp/images 下部署时不会因为域名变化丢图。分类先插四条后面列表页要按分类筛选category_id 直接关联。3.2 JDBC 连接工具类DBUtil 的连接串参数数据访问层先写一个统一的连接工具避免每个 DAO 重复写驱动加载。package com.gameshop.util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; public class DBUtil { private static final String URL jdbc:mysql://localhost:3306/game_shop ?useUnicodetruecharacterEncodingutf8 useSSLfalseserverTimezoneAsia/Shanghai; private static final String USER root; private static final String PASSWORD root; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, PreparedStatement ps, ResultSet rs) { if (rs ! null) { try { rs.close(); } catch (SQLException ignored) {} } if (ps ! null) { try { ps.close(); } catch (SQLException ignored) {} } if (conn ! null) { try { conn.close(); } catch (SQLException ignored) {} } } }代码逻辑静态块里注册 JDBC 驱动getConnection 每次拿新连接close 方法统一关资源。连接串四个参数缺一不可characterEncodingutf8 不写插入中文会变问号serverTimezone 不写MySQL 8 的驱动直接抛异常useSSLfalse 省掉开发时 SSL 握手警告数据库名 game_shop 要和建库脚本对上。USER 和 PASSWORD 改成自己本地的 MySQL 账号。每次 getConnection 这种方式在高并发下撑不住但课程设计阶段几十个并发没有问题而且学习阶段看得见连接打开和关闭反而更容易理解连接池到底在池化什么。想升级的话把 getConnection 换成连接池的 getConnection 方法即可DAO 层代码一行不用改。3.3 注册 Servlet后端校验与用户名查重package com.gameshop.servlet; import com.gameshop.dao.UserDao; import com.gameshop.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 java.io.IOException; WebServlet(/register) public class RegisterServlet extends HttpServlet { private final UserDao userDao new UserDao(); Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); String username req.getParameter(username); String password req.getParameter(password); String email req.getParameter(email); String phone req.getParameter(phone); if (username null || password null || username.trim().length() 3 || password.length() 6) { resp.sendRedirect(register.jsp?errorinvalid); return; } if (userDao.findByName(username.trim()) ! null) { resp.sendRedirect(register.jsp?errorexists); return; } User user new User(); user.setUsername(username.trim()); user.setPassword(password); user.setEmail(email); user.setPhone(phone); boolean ok userDao.add(user); resp.sendRedirect(ok ? login.jsp?registered1 : register.jsp?errorfailed); } }doPost 第一步设置请求编码然后从 request 取四个参数。校验分两层前端表单有 required但后端必须再校验一次前端校验可以通过改 HTML 绕过后端是最后一道防线。用户名查重通过 userDao.findByName 实现返回非空说明已注册跳回注册页带 error 参数。这里还缺一个细节用户名最好再 trim 一次再入库否则用户输入「abc 」和「abc」会被当成两个账号。密码明文存储这个点必须单独说。课程设计可以先用明文跑通我也理解交作业优先但如果你打算把项目写进简历或部署到公网密码必须做哈希。第 6 章会给出可直接替换的做法。WebServlet(/register) 是 Servlet 3.0 注解等价于在 web.xml 里配 servlet-mapping二选一即可同时配会冲突这个坑第 5 章展开。3.4 登录 ServletSession 与登录态管理package com.gameshop.servlet; import com.gameshop.dao.UserDao; import com.gameshop.entity.User; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.*; import java.io.IOException; WebServlet(/login) public class LoginServlet extends HttpServlet { private final UserDao userDao new UserDao(); Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); String username req.getParameter(username); String password req.getParameter(password); User user userDao.findByName(username); if (user ! null user.getPassword().equals(password)) { HttpSession session req.getSession(); session.setAttribute(loginUser, user); session.setMaxInactiveInterval(30 * 60); resp.sendRedirect(index.jsp); } else { resp.sendRedirect(login.jsp?errorbad); } } }登录成功把 user 对象放进 session后面 JSP 通过${loginUser.username}就能拿到当前用户名购物车、订单模块也从 session 里取用户。setMaxInactiveInterval(30 * 60) 意思是用户 30 分钟不操作自动登出。把整个 user 对象放进 session比只放 userId 更方便取值但 User 类要实现 Serializable因为 Tomcat 重启时可能对 session 做序列化持久化不实现会报 NotSerializableException虽然不致命但日志里很烦。注意sendRedirect 和 forward 的区别在这里就要建立起来。登录成功用 sendRedirect因为要清掉表单的 POST 数据防止刷新重复提交列表页带数据跳转必须用 forward因为 sendRedirect 会丢掉 request 里的属性。4. 核心业务实现游戏列表、Session 购物车与订单事务4.1 游戏列表与分类筛选Servlet 转发加 JSTL 渲染列表页是用户进站看到的第一个页面先写 GameDao 的查询方法。public ListGame findByCategory(int categoryId) { String sql SELECT id, name, category_id, price, cover, description, stock, sales FROM game WHERE status 1 AND category_id ?; ListGame list new ArrayList(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, categoryId); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { Game g new Game(); g.setId(rs.getInt(id)); g.setName(rs.getString(name)); g.setCategoryId(rs.getInt(category_id)); g.setPrice(rs.getBigDecimal(price)); g.setCover(rs.getString(cover)); g.setDescription(rs.getString(description)); g.setStock(rs.getInt(stock)); g.setSales(rs.getInt(sales)); list.add(g); } } } catch (SQLException e) { e.printStackTrace(); } return list; }sql 里只查 status 1把下架的游戏挡在列表外。preparedStatement 用 ? 占位setInt 绑定分类 id这里必须用 PreparedStatement 而不是字符串拼接SQL 注入的坑后面专门讲。rs.getBigDecimal 取 price对应表中的 DECIMAL 类型取出来是 BigDecimal 对象页面直接 ${g.price} 输出。对应的 Servlet 方法WebServlet(/index) public class IndexServlet extends HttpServlet { protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { GameDao gameDao new GameDao(); String cid req.getParameter(categoryId); ListGame list (cid null || cid.isEmpty()) ? gameDao.findAll() : gameDao.findByCategory(Integer.parseInt(cid)); req.setAttribute(gameList, list); req.getRequestDispatcher(index.jsp).forward(req, resp); } }index.jsp 里用 JSTL 遍历c:forEach varg items${gameList} div classgame-card img src${g.cover} alt${g.name} h3a hrefgame_detail.jsp?id${g.id}${g.name}/a/h3 p classprice${g.price}/p p classsales已售 ${g.sales} 份/p a hrefcart?actionaddgameId${g.id} classbtn加入购物车/a /div /c:forEachc:forEach 的 items 是 Servlet 转发过来的 Listvar 是循环变量。JSP 页面里没有 Java 代码块全是 EL 表达式和 JSTL 标签这是 JSP 的正确打开方式。URL 里的cart?actionaddgameId1是购物车 Servlet 的入口action 参数用来区分加购、删除、清空等操作。4.2 购物车用 Session 还是建表我建议 Session购物车是这个小项目里最值得想清楚的一个模块。建一张 cart 表用户加购一条记录、改数量一次 update、结算一次 delete逻辑上更持久关闭浏览器再打开购物车还在。但课程设计阶段我强烈建议用 Session不需要额外建表不需要处理游客购物车未登录用户也能加购代码量少一半。Session 购物车缺点是关闭浏览器就丢、换设备不共享但对演示项目完全够用而且这个取舍在答辩时反而是加分项——你能说出来为什么不用数据库表。package com.gameshop.entity; import java.math.BigDecimal; public class CartItem { private int gameId; private String name; private BigDecimal price; private int quantity; public CartItem(int gameId, String name, BigDecimal price, int quantity) { this.gameId gameId; this.name name; this.price price; this.quantity quantity; } // getter / setter 略 }package com.gameshop.entity; import java.util.LinkedHashMap; import java.util.Map; public class Cart { private final MapInteger, CartItem items new LinkedHashMap(); public void add(CartItem item) { CartItem old items.get(item.getGameId()); if (old ! null) { old.setQuantity(old.getQuantity() item.getQuantity()); } else { items.put(item.getGameId(), item); } } public void remove(int gameId) { items.remove(gameId); } public void clear() { items.clear(); } public MapInteger, CartItem getItems() { return items; } public boolean isEmpty() { return items.isEmpty(); } public BigDecimal getTotalPrice() { return items.values().stream() .map(i - i.getPrice().multiply(BigDecimal.valueOf(i.getQuantity()))) .reduce(BigDecimal.ZERO, BigDecimal::add); } }Cart 用 LinkedHashMap 而不是 HashMap保证加购顺序在页面上稳定显示。核心逻辑在 add 方法购物车已经有这个游戏时数量加一没有就放一条新记录。getTotalPrice 用 BigDecimal 计算乘法加一不可丢——如果这里用 double 累加页面金额会偶尔出现 644.99999999 这种怪数。加购的 ServletWebServlet(/cart) public class CartServlet extends HttpServlet { private final GameDao gameDao new GameDao(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { HttpSession session req.getSession(); Cart cart (Cart) session.getAttribute(cart); if (cart null) { cart new Cart(); session.setAttribute(cart, cart); } String action req.getParameter(action); if (add.equals(action)) { int gameId Integer.parseInt(req.getParameter(gameId)); Game game gameDao.findById(gameId); cart.add(new CartItem(game.getId(), game.getName(), game.getPrice(), 1)); resp.sendRedirect(cart.jsp); } else if (remove.equals(action)) { int gameId Integer.parseInt(req.getParameter(gameId)); cart.remove(gameId); resp.sendRedirect(cart.jsp); } } }第一次访问时 session 里没有 cart这里先 new 一个放进去。加购时通过 gameDao.findById 查一次数据库把名称和价格快照放进购物车后面结算时直接读购物车不再查库。这里注意 URL 是 GET 请求不要把修改购物车这类操作设计成 GET 以外的方式——准确说是删购物车这种幂等操作用 GET 可以下单这种非幂等操作必须用 POST下一节就是。4.3 订单结算事务与库存扣减下单是整个项目里唯一必须用事务的地方也是面试最爱问的点。package com.gameshop.servlet; import com.gameshop.entity.Cart; import com.gameshop.entity.CartItem; import com.gameshop.entity.User; import com.gameshop.util.DBUtil; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.*; import java.io.IOException; import java.sql.*; WebServlet(/order) public class OrderServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); HttpSession session req.getSession(); User user (User) session.getAttribute(loginUser); Cart cart (Cart) session.getAttribute(cart); if (user null) { resp.sendRedirect(login.jsp); return; } if (cart null || cart.isEmpty()) { resp.sendRedirect(cart.jsp); return; } Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); String orderNo G System.currentTimeMillis(); String sqlOrder INSERT INTO orders(order_no, user_id, total_amount) VALUES(?,?,?); int orderId; try (PreparedStatement ps conn.prepareStatement(sqlOrder, Statement.RETURN_GENERATED_KEYS)) { ps.setString(1, orderNo); ps.setInt(2, user.getId()); ps.setBigDecimal(3, cart.getTotalPrice()); ps.executeUpdate(); try (ResultSet keys ps.getGeneratedKeys()) { keys.next(); orderId keys.getInt(1); } } String sqlItem INSERT INTO order_item(order_id, game_id, price, quantity) VALUES(?,?,?,?); String sqlStock UPDATE game SET stock stock - ?, sales sales ? WHERE id ? AND stock ?; for (CartItem item : cart.getItems().values()) { try (PreparedStatement psItem conn.prepareStatement(sqlItem); PreparedStatement psStock conn.prepareStatement(sqlStock)) { psItem.setInt(1, orderId); psItem.setInt(2, item.getGameId()); psItem.setBigDecimal(3, item.getPrice()); psItem.setInt(4, item.getQuantity()); psItem.executeUpdate(); psStock.setInt(1, item.getQuantity()); psStock.setInt(2, item.getQuantity()); psStock.setInt(3, item.getGameId()); psStock.setInt(4, item.getQuantity()); int rows psStock.executeUpdate(); if (rows 0) { throw new SQLException(库存不足: gameId item.getGameId()); } } } conn.commit(); session.removeAttribute(cart); resp.sendRedirect(order_success.jsp?orderNo orderNo); } catch (Exception e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } e.printStackTrace(); resp.sendRedirect(error.jsp); } finally { if (conn ! null) { try { conn.close(); } catch (SQLException ignored) {} } } } }事务的关键是conn.setAutoCommit(false)和conn.commit()配对。订单插入、明细插入、库存扣减包在同一个事务里任何一个环节失败就 rollback数据库回到下单前状态。这里最值钱的一个技巧是Statement.RETURN_GENERATED_KEYSorders 表主键是自增 id插入后要拿到这个 id 才能写 order_item 的 order_id用getGeneratedKeys()取回主键避免再查一次数据库。很多同学不知道 PreparedStatement 有这个能力现场写代码时会卡住。库存扣减那句WHERE id ? AND stock ?是防超卖的关键。判断库存够不够和扣减必须是同一条 SQL如果分开「先查再扣」高并发下两个请求都查到 stock1然后都执行扣减结果变 -1。执行后影响行数为 0 说明库存不足直接抛异常回滚。课程设计答辩被问并发问题时这个答案很加分。5. 避坑指南JSPServlet 项目最常见的 5 个翻车点5.1 中文乱码页面、请求、数据库三层对不上现象注册用户名是中文插入数据库后变成 ??或者页面显示一堆乱码。原因编码问题有三个入口。JSP 页面自身编码不是 UTF-8POST 请求没设 request.setCharacterEncodingJDBC 连接串没带 characterEncodingutf8。三个入口只要一个没对齐就翻车。解决统一三处。JSP 头部写% page contentTypetext/html; charsetUTF-8 pageEncodingUTF-8 %写一个 CharacterEncodingFilter 过滤所有请求DBUtil 连接串带上 characterEncodingutf8建库时指定 utf8mb4。顺序别搞反页面编码错了改数据库没用先查 JSP 头部和响应编码。5.2 Servlet 访问 404注解和 web.xml 冲突现象明明写了 WebServlet(/login)访问 /login 却 404Tomcat 启动也没报错。原因web.xml 里如果声明的是 Servlet 3.0 以下规范web-app 版本写 2.5注解扫描不会生效反过来在 web.xml 里也配了一个同名 servlet-mapping两处冲突Tomcat 行为不确定。解决二选一。新项目用 Tomcat 8直接在 web.xml 顶部声明 Servlet 3.0 规范全用注解web.xml 留空或者干脆全在 web.xml 里配。我习惯全注解代码和映射在一起少跳文件。查这个问题最快的办法是看 Tomcat 的 localhost 日志启动时有没有扫描到 Servlet 类。5.3 刷新页面订单重复提交现象用户点「提交订单」后响应慢多点了两次结果生成两笔相同订单。原因经典表单重复提交。doPost 处理完直接 forward 到一个展示页浏览器地址栏还是 /order按 F5 刷新会再次提交同一个 POST订单逻辑又跑一遍。解决PRG 模式Post-Redirect-Get。OrderServlet 处理完业务后不要 forward而是 sendRedirect 到 order_success.jsp把订单号放在 URL 参数里。刷新 order_success.jsp 只是 GET 请求不会重新走订单逻辑。第 4 章代码里已经是这个写法这也是我坚持用 sendRedirect 的原因。5.4 库存扣成负数现象两个用户同时买最后一件游戏库存变成 -1。原因先查库存再扣减两个请求都查到 stock1都执行 UPDATE stock stock - 1结果 -1。解决把判断和扣减合并成一条带条件的 UPDATEUPDATE game SET stock stock - ? WHERE id ? AND stock ?。执行后影响行数为 0 说明库存不足抛异常回滚事务。这是数据库层的乐观控制思路不需要加锁就能防超卖。5.5 Session 登录状态莫名丢失现象登录后跳回首页正常但复制 URL 到新标签页打开又要重新登录或者过一会就掉线。原因两个可能。一是没显式设置 session 超时走 Tomcat 默认规则且 Tomcat 默认会话超时按分钟计算波动大二是部署时的 context 路径变了JSESSIONID 这个 cookie 的 path 没对上浏览器不带上这个 cookie服务器就认为你是新访客。解决登录时显式调用session.setMaxInactiveInterval(30 * 60)确保 User 类实现 Serializable清理 Tomcat work 目录后重启。本地测试关了浏览器再打开就掉线这是 Session 机制的正常行为不用慌如果开着浏览器也掉检查 cookie path 和服务器时间。6. 上线前必做的一轮分页索引与部署验证6.1 给游戏列表加分页SQL 和索引一起改第 4 章的列表查询是全表查出游戏几十条还能忍上百条就开始卡。分页 SQL 很简单SELECT id, name, price, cover, sales FROM game WHERE status 1 ORDER BY sales DESC LIMIT 0, 12;LIMIT 两个参数第一个是偏移量第二个是每页条数。第 2 页就是 LIMIT 12, 12偏移量 (页码-1) * 每页条数。Servlet 接收 page 参数算好偏移量传给 DAOJSP 底部放「上一页 / 下一页」链接。分页一上必须给索引否则数据量大了照样慢。WHERE status 1和ORDER BY sales DESC组合建一个(status, sales)联合索引查询直接走索引排序不用 filesort——filesort 在十万行数据时是性能杀手。6.2 Tomcat 部署与验证清单部署前把这张表过一遍能省去现场翻车的尴尬。检查项怎么验证常见结果数据库字符集SHOW VARIABLES LIKE character%;全 utf8mb4JDBC 驱动版本MySQL 8 必须用 com.mysql.cj.jdbc.DriverClassNotFoundException 是版本没对上web.xml 规范web-app 顶部 version 3.0注解不生效先查这里图片路径浏览器直接访问 /images/xx.jpg404 就检查相对路径session 超时登录后等待 30 分钟自动跳登录页是正常的war 包导出导出后放 webapps 重启 Tomcat注意别把源码打进 war最后一件事把数据库导出一份备用。mysqldump -u root -p game_shop game_shop_backup.sql万一改代码把表结构弄坏了一分钟还原。这个习惯我每次做项目都会执行一遍尤其是课程设计从开发机搬到另一台电脑演示之前备份是最便宜的后悔药。这个项目做下来我最深的一个感受是JSPServlet 这套东西不是让你背 API而是让你理解一个请求从浏览器穿到数据库、再穿回来的每一个环节。当年做类似项目时最浪费时间的就是在乱码和 404 上瞎猜后来养成一个习惯——任何诡异问题先看 Tomcat 日志再看数据库连接串最后检查编码基本能解决九成问题。希望这篇里的建表、购物车选型和事务写法能帮到你照着跑通之后再想想哪些地方能换成 Spring Boot 的等价写法你会比直接学框架的人扎实不少。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?