news 2026/10/14 12:56:27

JSP+Servlet+MySQL三层架构实战:从零搭建游戏购买网站

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JSP+Servlet+MySQL三层架构实战:从零搭建游戏购买网站

简介:一套基于JSP+Servlet+MySQL的游戏购买网站完整项目,面向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人学习使用。资料随附管理员与普通用户测试账号,并采用Servlet+DAO分层设计,能直观看到登录鉴权、数据访问、购物车与订单状态在经典Java Web架构中的落地写法,适合直接参考、学习或二次开发。

1. 基于 JSP+Servlet+MySQL 的游戏购买网站:这个老技术组合为什么还能打

前阵子 A 同学拿「基于 jsp+servlet+mysql 游戏购买网站设计」来问课程设计,要求用这三件套做出游戏上架、注册登录、购物车、下单结算。这组合听着老,但正因为它老,Servlet 的请求流转、JSP 的渲染、手写 SQL 全没框架遮挡,学完再看 Spring Boot 会通透很多。

这个选题落地的是一整套典型 Java Web 业务:浏览游戏清单、分类筛选、登录加购、提交订单扣库存。MySQL 存数据,JSP 渲染页面,Servlet 接请求再分发到 DAO 层,一台装了 JDK、Tomcat、MySQL 的电脑就能跑完。

适合两类人:要交课程设计、想把功能跑通还能讲清原理的同学,以及刚学完 Java 基础、想用真实业务串一遍这三件的自学者。下面按最常见方案一路拆到上线前优化,新手里程碑明确,熟手也能看到边界和坑。

2. JSP+Servlet+MySQL 的三层架构:表结构与工程目录怎么搭

2.1 为什么这个组合还没过时

很多同学一上来就问,都什么年代了,为什么课程设计还给 JSP+Servlet?这问题得从两个层面看。工程选型层面,新项目确实主流是前后端分离加 Spring Boot,JSP 在大型项目里已经很少出现;但学习价值层面,Servlet 是 Java Web 的根,Spring MVC 的 DispatcherServlet 本质上还是一个 Servlet,JSP 的九大内置对象、作用域和标签语法,在 Thymeleaf、Freemarker 里都能看到影子。把这一套跑通,HTTP 请求从浏览器到服务器再到数据库的完整路径就有了体感,后面学框架是在学「怎么少写重复代码」,而不是在黑匣子里试错。

这个选题的架构是最典型的 Model 2:JSP 负责视图,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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 游戏分类表 CREATE TABLE category ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 游戏表 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单表 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 订单明细表 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

几个参数说明。数据库和所有表都用 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 脚本到第一个 Servlet

3.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" + "?useUnicode=true&characterEncoding=utf8" + "&useSSL=false&serverTimezone=Asia/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 方法统一关资源。连接串四个参数缺一不可:characterEncoding=utf8 不写,插入中文会变问号;serverTimezone 不写,MySQL 8 的驱动直接抛异常;useSSL=false 省掉开发时 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?error=invalid"); return; } if (userDao.findByName(username.trim()) != null) { resp.sendRedirect("register.jsp?error=exists"); 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?registered=1" : "register.jsp?error=failed"); } }

doPost 第一步设置请求编码,然后从 request 取四个参数。校验分两层:前端表单有 required,但后端必须再校验一次,前端校验可以通过改 HTML 绕过,后端是最后一道防线。用户名查重通过 userDao.findByName 实现,返回非空说明已注册,跳回注册页带 error 参数。这里还缺一个细节:用户名最好再 trim 一次再入库,否则用户输入「abc 」和「abc」会被当成两个账号。

密码明文存储这个点必须单独说。课程设计可以先用明文跑通,我也理解交作业优先,但如果你打算把项目写进简历或部署到公网,密码必须做哈希。第 6 章会给出可直接替换的做法。@WebServlet("/register") 是 Servlet 3.0 注解,等价于在 web.xml 里配 servlet-mapping,二选一即可,同时配会冲突,这个坑第 5 章展开。

3.4 登录 Servlet:Session 与登录态管理

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?error=bad"); } } }

登录成功把 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 List<Game> findByCategory(int categoryId) { String sql = "SELECT id, name, category_id, price, cover, description, stock, sales " + "FROM game WHERE status = 1 AND category_id = ?"; List<Game> 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"); List<Game> 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 var="g" items="${gameList}"> <div class="game-card"> <img src="${g.cover}" alt="${g.name}"> <h3><a href="game_detail.jsp?id=${g.id}">${g.name}</a></h3> <p class="price">¥${g.price}</p> <p class="sales">已售 ${g.sales} 份</p> <a href="cart?action=add&gameId=${g.id}" class="btn">加入购物车</a> </div> </c:forEach>

c:forEach 的 items 是 Servlet 转发过来的 List,var 是循环变量。JSP 页面里没有 Java 代码块,全是 EL 表达式和 JSTL 标签,这是 JSP 的正确打开方式。URL 里的cart?action=add&gameId=1是购物车 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 Map<Integer, 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 Map<Integer, 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 这种怪数。

加购的 Servlet:

@WebServlet("/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_KEYS:orders 表主键是自增 id,插入后要拿到这个 id 才能写 order_item 的 order_id,用getGeneratedKeys()取回主键,避免再查一次数据库。很多同学不知道 PreparedStatement 有这个能力,现场写代码时会卡住。

库存扣减那句WHERE id = ? AND stock >= ?是防超卖的关键。判断库存够不够和扣减必须是同一条 SQL,如果分开「先查再扣」,高并发下两个请求都查到 stock=1,然后都执行扣减,结果变 -1。执行后影响行数为 0 说明库存不足,直接抛异常回滚。课程设计答辩被问并发问题时,这个答案很加分。

5. 避坑指南:JSP+Servlet 项目最常见的 5 个翻车点

5.1 中文乱码:页面、请求、数据库三层对不上

现象:注册用户名是中文,插入数据库后变成 ??,或者页面显示一堆乱码。

原因:编码问题有三个入口。JSP 页面自身编码不是 UTF-8;POST 请求没设 request.setCharacterEncoding;JDBC 连接串没带 characterEncoding=utf8。三个入口只要一个没对齐就翻车。

解决:统一三处。JSP 头部写<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>;写一个 CharacterEncodingFilter 过滤所有请求;DBUtil 连接串带上 characterEncoding=utf8,建库时指定 utf8mb4。顺序别搞反,页面编码错了改数据库没用,先查 JSP 头部和响应编码。

5.2 Servlet 访问 404:注解和 web.xml 冲突

现象:明明写了 @WebServlet("/login"),访问 /login 却 404,Tomcat 启动也没报错。

原因: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。

原因:先查库存再扣减,两个请求都查到 stock=1,都执行 UPDATE stock = stock - 1,结果 -1。

解决:把判断和扣减合并成一条带条件的 UPDATE:UPDATE 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 参数,算好偏移量传给 DAO,JSP 底部放「上一页 / 下一页」链接。分页一上,必须给索引,否则数据量大了照样慢。WHERE status = 1和ORDER BY sales DESC组合,建一个(status, sales)联合索引,查询直接走索引排序,不用 filesort——filesort 在十万行数据时是性能杀手。

6.2 Tomcat 部署与验证清单

部署前把这张表过一遍,能省去现场翻车的尴尬。

检查项怎么验证常见结果
数据库字符集SHOW VARIABLES LIKE 'character%';全 utf8mb4
JDBC 驱动版本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,万一改代码把表结构弄坏了,一分钟还原。这个习惯我每次做项目都会执行一遍,尤其是课程设计从开发机搬到另一台电脑演示之前,备份是最便宜的后悔药。

这个项目做下来,我最深的一个感受是:JSP+Servlet 这套东西不是让你背 API,而是让你理解一个请求从浏览器穿到数据库、再穿回来的每一个环节。当年做类似项目时,最浪费时间的就是在乱码和 404 上瞎猜,后来养成一个习惯——任何诡异问题先看 Tomcat 日志,再看数据库连接串,最后检查编码,基本能解决九成问题。希望这篇里的建表、购物车选型和事务写法能帮到你,照着跑通之后再想想哪些地方能换成 Spring Boot 的等价写法,你会比直接学框架的人扎实不少。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 12:56:17

SpringBoot多数据源实战:从事务失效到线程安全的完整避坑指南

1. 为什么多数据源不是“加个配置就行”的事刚接手一个老系统迁移项目时&#xff0c;我看到需求文档里写着“支持对接新旧两套数据库”&#xff0c;心里还觉得挺简单——不就是Spring Boot里配两个DataSource&#xff0c;再让MyBatis-Plus自动切库嘛。结果第一天就卡在事务回滚…

作者头像 李华
网站建设 2026/10/14 12:55:41

JavaScript核心概念与工程实践全解析

1. JavaScript编程核心概念全景解析 作为现代Web开发的基石语言&#xff0c;JavaScript的掌握程度直接决定了前端工程师的能力天花板。从表面看&#xff0c;JS的语法确实比许多语言更简单&#xff0c;但真正理解其核心机制需要穿透语法糖衣&#xff0c;深入到执行上下文、事件循…

作者头像 李华
网站建设 2026/10/14 12:55:41

知识工作插件组合:从信息捕获到检索输出的效率方案

你每天的工作流里是不是也有这样的场景&#xff1a;写方案时在编辑器、浏览器、笔记软件之间来回切换&#xff0c;临时记下的想法散落在三四个工具里&#xff0c;等真要用的时候却怎么也搜不到&#xff1b;重复性的文本处理明明可以一键完成&#xff0c;你却花了半小时手工折腾…

作者头像 李华
网站建设 2026/10/14 12:55:14

微信小程序实现高校实验室智能排课系统设计与实践

1. 项目背景与需求分析 实验室排课一直是高校教学管理中的痛点问题。传统的人工排课方式需要教务人员反复核对实验室设备、课程需求、教师时间等多维因素&#xff0c;一个中型院校每学期至少需要投入200-300个工时进行排课协调。更棘手的是&#xff0c;临时调课申请往往需要层层…

作者头像 李华
网站建设 2026/10/14 12:55:13

知识工作插件体系:从捕获到检索的本地知识管理流水线

如果你和我一样&#xff0c;浏览器收藏夹里躺着几百个“稍后读”&#xff0c;剪贴板里反复粘贴同一段文献却找不到最初出处&#xff0c;笔记软件里堆着半年前随手拍的书页照片——那你大概率需要一套顺手的工作流。这套我命名为knowledge-work-plugins的插件体系&#xff0c;就…

作者头像 李华
网站建设 2026/10/14 12:55:11

智能问数Agent设计:数据集与语义层路线对比

1. 智能问数Agent的设计路线之争 "智能问数Agent"这个概念最近在企业数据分析领域越来越火。简单来说&#xff0c;它就是能让你用自然语言提问&#xff0c;自动生成数据报表和分析结果的AI助手。想象一下&#xff0c;以前需要写SQL、跑Python脚本才能完成的数据分析工…

作者头像 李华