☰
JSP+Servlet+MySQL实战:从零搭建游戏购买网站
2026/9/26 16:43:44 网站建设 项目流程

简介:一套基于JavaWeb技术的游戏购买商城项目,后端使用JSP动态页面和Servlet控制器,配合MySQL数据库持久化数据,整体面向JavaWeb课程设计、毕业设计以及希望快速上手电商系统的初学者。项目中用户端提供注册登录、游戏列表浏览、加入购物车、提交订单与订单查询等流程,管理端提供游戏信息维护、类目管理、订单处理和客户管理功能,并内置两套测试账号,方便开箱即用。资源包共246个文件,压缩包大小约14.85MB,包含55个Java源码、55个class字节码、20个JSP页面、SQL初始化脚本、9个JAR依赖库,以及CSS、JS、JPG图片等前端展示素材,可满足直接运行和二次修改的需要。代码按DAO、Service、Servlet分层编写,用户、商品、订单等核心实体均有独立封装,配套运行环境为JDK1.8及以上和Tomcat8.x以上,也兼容Eclipse、MyEclipse、IDEA。目前已有95人学习下载,是一份完整度较高的JavaWeb实战案例,既能用来研究前后台交互与购物车会话机制,也能作为课程设计或毕业论文的实现蓝本。

1. 为什么2024年了还选 JSP+Servlet+MySQL 做游戏购买网站

如果你正在为毕设选题发愁,想做“游戏购买网站”又不想一上来就被 SpringBoot 的自动配置和各种注解淹没,那 JSP+Servlet+MySQL 这套组合反而是最能看清 Web 请求全流程的起点:浏览器发请求,Servlet 接住,JDBC 操作 MySQL,结果交给 JSP 渲染回页面,每一步都透明。三年前我用这个组合写过一个游戏商城,后来一直没重构,它在单台 Tomcat 上稳定跑了一年多,日常两三百并发没有掉过链子。这套技术适合作课程设计、毕业设计,也适合中小型内部系统。下面会把从建表到下单的完整路径拆开讲清楚,包括那些最容易让人翻车的坑。

2. 从零搭项目骨架:IDEA新建JSP项目与MySQL表设计

这一章先把能跑起来的空壳搭好。常见做法是两条路:纯 Web Application 手动拷 jar 包,或者用 Maven 管理的 Java Web 项目。我一般选 Maven,依赖版本在 pom.xml 里锁死,后面换连接池、加 JSTL 都不用再往 WEB-INF/lib 里手动丢 jar。

2.1 用 IDEA 新建 JSP 项目:Tomcat 配置与 Servlet 依赖

打开 IDEA 后,File -> New -> Project,选 Java Enterprise,左边选 Web Application,然后在 Application Server 里选你已经装好的 Tomcat。如果列表里没有,点 New 手动指定 Tomcat 根目录。IDEA 会自动把 Tomcat 的 servlet-api.jar 加进依赖,但为了不依赖 IDE,我习惯在 pom.xml 里显式声明。

<dependencies> <!-- Servlet API:编译期需要,运行期由 Tomcat 提供 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <!-- MySQL 驱动:8.0 系列必须要带 cj 的驱动类 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <!-- JSTL:JSP 里做条件判断和循环 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> </dependencies>

scope 写 provided 是关键:Tomcat 自带 servlet-api.jar,要让运行时用容器里的,而不是把这份 jar 打包进 WAR,否则不同版本容器可能冲突。mysql-connector-java 8.0.33 是 8.x 系列的最后一个版本,功能稳定;如果只跟 MySQL 5.7 打交道,用 5.1.49 也行,但 Druid 等连接池对 8.x 驱动兼容得更好。

依赖配好后,点 Maven 刷新。然后配置运行环境:Run -> Edit Configurations -> 左上角 + -> Tomcat Server -> Local。Server 页选 Tomcat 安装目录,Deployment 页里点 + 添加 Artifact,选 war exploded,Application context 建议改成/,这样访问地址就是 http://localhost:8080。如果本机 8080 被占了,在 Server 页的 HTTP port 改成 8081,这比去改 server.xml 省事。

注意:IDEA 的 Tomcat 集成只是“把 Web 应用通过 Tomcat 跑起来”,并不是把 Tomcat 装进 IDEA。发布到服务器时仍需自己下载 Tomcat 解压部署。

2.2 MySQL 三张核心表:用户、游戏、订单的字段与索引

游戏购买网站的数据库设计,核心就三张表:用户、游戏、订单。先把建表 SQL 放出来,这一段直接可以抄:

CREATE DATABASE IF NOT EXISTS game_store DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE game_store; CREATE TABLE t_user ( id INT AUTO_INCREMENT PRIMARY KEY COMMENT '用户ID', username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名', password VARCHAR(64) NOT NULL COMMENT 'MD5 摘要,长度固定 64', email VARCHAR(100) DEFAULT '' COMMENT '邮箱', nickname VARCHAR(50) DEFAULT '' COMMENT '昵称,个人中心展示用', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间', KEY idx_create_time (create_time) ) ENGINE=InnoDB COMMENT='用户表'; CREATE TABLE t_game ( id INT AUTO_INCREMENT PRIMARY KEY COMMENT '游戏ID', game_name VARCHAR(100) NOT NULL COMMENT '游戏名称', price DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '售价,保留两位小数', stock INT NOT NULL DEFAULT 0 COMMENT '可卖库存', cover_url VARCHAR(255) NOT NULL DEFAULT '' COMMENT '封面图路径', description TEXT COMMENT '游戏简介', status TINYINT NOT NULL DEFAULT 1 COMMENT '1 上架 0 下架', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '上架时间', KEY idx_status_id (status, id) ) ENGINE=InnoDB COMMENT='游戏表'; CREATE TABLE t_order ( id INT AUTO_INCREMENT PRIMARY KEY COMMENT '订单ID', order_no VARCHAR(32) NOT NULL UNIQUE COMMENT '业务订单号,展示给用户看', user_id INT NOT NULL COMMENT '下单用户ID', game_id INT NOT NULL COMMENT '游戏ID', quantity INT NOT NULL DEFAULT 1 COMMENT '购买数量', total_amount DECIMAL(10,2) NOT NULL COMMENT '实付总金额', status TINYINT NOT NULL DEFAULT 0 COMMENT '0 待支付 1 已支付 2 已取消', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '下单时间', KEY idx_user_id (user_id), KEY idx_game_id (game_id), CONSTRAINT fk_order_user FOREIGN KEY (user_id) REFERENCES t_user(id), CONSTRAINT fk_order_game FOREIGN KEY (game_id) REFERENCES t_game(id) ) ENGINE=InnoDB COMMENT='订单表';

几个选型细节解释一下。表名用 t_user、t_game、t_order 而不是 user、game、order:order 是 SQL 的保留字,直接用order表,每条 SQL 都得写反引号,纯给自己添堵;加前缀 t_ 是顺手习惯,避免和某些报表工具里同名表冲突。

价格字段用 DECIMAL(10,2) 而不用 FLOAT 或 DOUBLE。游戏价格这种东西,9.99 在浮点里可能是 9.9900000000000002,订单总额一旦算错,对账对到怀疑人生。DECIMAL 是定点数,存的就是精确十进制。status 字段默认值设为 0 和 1,对应订单待支付、游戏上架,这种枚举值建表时就定好,比代码里到处写魔法数字清晰得多。

索引上,t_game 建了idx_status_id(status, id),因为游戏列表最常见的查询是WHERE status=1 ORDER BY id DESC,这个组合索引能同时覆盖过滤和排序。t_order 的 user_id 和 game_id 都是外键,InnoDB 在创建外键时会自动为外键列生成索引,所以不写 KEY 也够用;这里写出来是让表结构更明确。

注意:三张表全部用 InnoDB。MyISAM 查起来快一点,但它不支持行锁和事务,游戏购买里的“下单扣库存”离不开事务,这一步选错后面全得返工。

3. 核心功能落地:注册登录、游戏列表与下单购买

骨架有了,这一章把三条链路做通:注册登录、游戏列表与个人信息展示页面、购物车下单。JSP+Servlet 项目的请求链路是固定的:页面表单发起请求 -> web.xml 或注解把 URL 映射到 Servlet -> Servlet 调 JDBC 访问 MySQL -> 转发回 JSP 渲染。把这一条链走通,后面加功能都是同样的套路。

3.1 用户注册与登录:Servlet 生命周期与 Session 会话

Servlet 的生命周期就三段:init() 在首次请求时执行一次,用来初始化资源;service() 每次请求都执行,doGet/doPost 在这里分发;destroy() 在容器关闭时执行,做清理。理解这个就够了,写代码时知道“连接池”这类资源该放 init 里,“处理参数”该放 doPost 里。

先写注册。我把数据库连接封装成一个 DBUtil,后续所有 Servlet 共用:

public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/game_store?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"; private static final String USER = "root"; private static final String PASSWORD = "123456"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }

URL 上useSSL=false是本地开发关掉加密连接,serverTimezone=Asia/Shanghai避免 8.0 驱动报时区错误,characterEncoding=utf8保证中文字符从连接就按 UTF-8 传输。后面换成 Druid 连接池时,这套参数原样照搬。

注册逻辑:

@WebServlet("/register") public class RegisterServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { req.setCharacterEncoding("UTF-8"); String username = req.getParameter("username"); String password = req.getParameter("password"); if (username == null || username.isEmpty() || password == null || password.isEmpty()) { resp.sendRedirect("register.jsp?error=1"); return; } try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement("SELECT id FROM t_user WHERE username = ?")) { ps.setString(1, username); ResultSet rs = ps.executeQuery(); if (rs.next()) { resp.sendRedirect("register.jsp?error=2"); return; } String md5Password = MD5Util.md5(password); try (PreparedStatement insert = conn.prepareStatement("INSERT INTO t_user(username, password) VALUES(?, ?)")) { insert.setString(1, username); insert.setString(2, md5Password); insert.executeUpdate(); } resp.sendRedirect("login.jsp?registered=1"); } catch (SQLException e) { e.printStackTrace(); resp.sendRedirect("register.jsp?error=3"); } } }

这里用到的 MD5 工具类,是密码入库前做摘要的常规做法:

public class MD5Util { public static String md5(String input) { try { MessageDigest md = MessageDigest.getInstance("MD5"); byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8)); StringBuilder sb = new StringBuilder(); for (byte b : digest) { sb.append(String.format("%02x", b)); } return sb.toString(); } catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } } }

注意,真正的生产环境密码不能直接 MD5,至少要加盐。但 JSP+Servlet 是教学向项目,用 MD5 是常见演示;如果你敢上线,建议换成 Spring Security 的 BCrypt。密码字段设计成 VARCHAR(64) 就是因为 MD5 摘要固定是 32 个十六进制字符,现在这个长度还有富余。

登录逻辑才有 Session 参与:

@WebServlet("/login") public class LoginServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { req.setCharacterEncoding("UTF-8"); String username = req.getParameter("username"); String password = req.getParameter("password"); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement("SELECT id, username, nickname FROM t_user WHERE username = ? AND password = ?")) { ps.setString(1, username); ps.setString(2, MD5Util.md5(password)); ResultSet rs = ps.executeQuery(); if (rs.next()) { HttpSession session = req.getSession(); session.setAttribute("userId", rs.getInt("id")); session.setAttribute("username", rs.getString("username")); session.setAttribute("nickname", rs.getString("nickname")); resp.sendRedirect("index.jsp"); } else { resp.sendRedirect("login.jsp?error=1"); } } catch (SQLException e) { e.printStackTrace(); resp.sendRedirect("login.jsp?error=2"); } } }

req.getSession()在没有 Session 时会自动新建一个,并把 JSESSIONID 写进 Cookie。后面每次请求浏览器都会带上这个 Cookie,所以只要往 Session 里 setAttribute("userId"),其他 Servlet 就能通过 session.getAttribute 知道当前是谁。Session 默认 30 分钟过期,可以在 web.xml 里改,游戏商城这种场景 30 分钟够了。

还需要一个过滤器把未登录用户挡在购买入口外:

@WebFilter("/*") public class LoginFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpSession session = req.getSession(false); String uri = req.getRequestURI(); String ctxPath = req.getContextPath(); String path = uri.substring(ctxPath.length()); // 静态资源和登录、注册相关接口放行 if (path.startsWith("/css/") || path.startsWith("/js/") || path.startsWith("/images/") || path.endsWith("login.jsp") || path.endsWith("register.jsp") || path.equals("/login") || path.equals("/register") || path.equals("/index.jsp")) { chain.doFilter(request, response); return; } if (session == null || session.getAttribute("userId") == null) { ((HttpServletResponse) response).sendRedirect(ctxPath + "/login.jsp"); } else { chain.doFilter(request, response); } } }

Filter 的写法比在每个 Servlet 里查 Session 省事得多。白名单里放行登录、注册、首页和静态资源,其余全拦。

3.2 游戏列表与个人信息展示页面:EL 表达式与 JSTL 渲染

游戏列表是老套路:查 t_game 表上架状态的记录,转发给 JSP 循环渲染。我用 Servlet 返回列表:

@WebServlet("/game/list") public class GameListServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { int page = 1; int pageSize = 12; String pageStr = req.getParameter("page"); if (pageStr != null && !pageStr.isEmpty()) { page = Integer.parseInt(pageStr); } try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement("SELECT id, game_name, price, stock, cover_url, description FROM t_game WHERE status = 1 ORDER BY id DESC LIMIT ? OFFSET ?")) { ps.setInt(1, pageSize); ps.setInt(2, (page - 1) * pageSize); ResultSet rs = ps.executeQuery(); List<Game> gameList = new ArrayList<>(); while (rs.next()) { Game g = new Game(); g.setId(rs.getInt("id")); g.setGameName(rs.getString("game_name")); g.setPrice(rs.getBigDecimal("price")); g.setStock(rs.getInt("stock")); g.setCoverUrl(rs.getString("cover_url")); g.setDescription(rs.getString("description")); gameList.add(g); } req.setAttribute("gameList", gameList); req.getRequestDispatcher("/game_list.jsp").forward(req, resp); } catch (SQLException | NumberFormatException e) { e.printStackTrace(); resp.sendError(500); } } }

LIMIT ? OFFSET ? 是用 PreparedStatement 做分页的标准写法,相比直接拼接字符串,既防注入又不用关心参数类型。forward到 JSP 时 URL 不变,浏览器地址栏还是 /game/list,所以刷新页面不会重复提交表单,这是 forward 和 sendRedirect 的本质区别:forward 是一次请求内转发,sendRedirect 是浏览器又发起一次新请求。

game_list.jsp 用 JSTL 的 c:forEach 渲染:

<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <html> <head><title>游戏商城</title></head> <body> <h2>游戏列表</h2> <div class="game-grid"> <c:forEach var="game" items="${gameList}"> <div class="game-card"> <img src="${game.coverUrl}" alt="${game.gameName}" width="200" height="200"/> <h3>${game.gameName}</h3> <p>价格:¥${game.price}</p> <p>库存:${game.stock}</p> <a href="game/detail?id=${game.id}">查看详情</a> <a href="cart/add?gameId=${game.id}">加入购物车</a> </div> </c:forEach> </div> </body> </html>

EL 表达式${game.gameName}等价于<%= game.getGameName() %>,但写法简洁得多,而且属性不存在时输出空字符串而不是抛异常。JSTL 的 c:forEach 比 scriptlet 里写 for 循环干净太多。新手最容易犯的错误是页面里混用<%= %>和 EL,建议定一个规矩:页面里只用 EL + JSTL,Java 代码全部留在 Servlet 里。

个人信息展示页是选这个技术栈最常出现的需求,页面本身不复杂,难点在怎么把数据从 Session 和数据库里取出来。我一般用一个 ProfileServlet 在查库后把属性塞进 request,再转发给 profile.jsp:

@WebServlet("/profile") public class ProfileServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { HttpSession session = req.getSession(false); if (session == null || session.getAttribute("userId") == null) { resp.sendRedirect("login.jsp"); return; } int userId = (Integer) session.getAttribute("userId"); try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement("SELECT username, email, nickname, create_time FROM t_user WHERE id = ?")) { ps.setInt(1, userId); ResultSet rs = ps.executeQuery(); if (rs.next()) { req.setAttribute("email", rs.getString("email")); req.setAttribute("createTime", rs.getTimestamp("create_time")); } req.setAttribute("username", session.getAttribute("username")); req.setAttribute("nickname", session.getAttribute("nickname")); req.getRequestDispatcher("/profile.jsp").forward(req, resp); } catch (SQLException e) { e.printStackTrace(); resp.sendError(500); } } }

profile.jsp 里直接用 EL 取值:

<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <html> <head><title>个人信息</title></head> <body> <c:if test="${empty sessionScope.userId}"> <p>您还没有登录,<a href="login.jsp">去登录</a></p> </c:if> <c:if test="${not empty sessionScope.userId}"> <h2>个人信息</h2> <p>用户名:${username}</p> <p>昵称:${nickname}</p> <p>邮箱:${email}</p> <p>注册时间:${createTime}</p> </c:if> </body> </html>

empty sessionScope.userId是 EL 里的空值判断,比<% if (session.getAttribute("userId") != null) %>短一截。这里sessionScope.userId来自 Session,username、email来自 request 转发时塞的属性。如果用户没登录,Session 里没有 userId,页面就只显示登录提示,不会抛空指针。

3.3 购物车与下单:MySQL 事务与库存条件更新

游戏购买网站最核心的是购买流程。购物车常见做法有两种:一种是全部存 Session,另一种是落一张购物车表。中小型项目我推荐 Session 购物车,用户关浏览器就清了,结构简单。如果要跨设备同步,那再上表,但游戏购买网站的诉求没那么复杂。

购物车操作我直接写一个 CartAddServlet,URL 设计成 /cart/add:

@WebServlet("/cart/add") public class CartAddServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { HttpSession session = req.getSession(false); if (session == null || session.getAttribute("userId") == null) { resp.sendRedirect("login.jsp"); return; } int gameId = Integer.parseInt(req.getParameter("gameId")); Map<Integer, Integer> cart = (Map<Integer, Integer>) session.getAttribute("cart"); if (cart == null) { cart = new HashMap<>(); } cart.put(gameId, cart.getOrDefault(gameId, 0) + 1); session.setAttribute("cart", cart); resp.sendRedirect("cart.jsp"); } }

Session 购物车的实现就是一个Map<Integer, Integer>,key 是游戏ID,value 是数量。getOrDefault是 JDK 8 的默认方法,省掉先判断再累加的三行 if。

下单就要碰 MySQL 事务了。这一步的硬性要求是:要么订单表和库存更新一起成功,要么一起失败。我把事务和条件更新写在一起:

@WebServlet("/order/create") public class OrderCreateServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { HttpSession session = req.getSession(false); Integer userId = (Integer) session.getAttribute("userId"); int gameId = Integer.parseInt(req.getParameter("gameId")); int quantity = Integer.parseInt(req.getParameter("quantity")); Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务,两条SQL必须同时成功 // 条件更新:库存足够才扣,受影响行数为0说明库存不足 try (PreparedStatement update = conn.prepareStatement( "UPDATE t_game SET stock = stock - ? WHERE id = ? AND stock >= ? AND status = 1")) { update.setInt(1, quantity); update.setInt(2, gameId); update.setInt(3, quantity); int rows = update.executeUpdate(); if (rows == 0) { conn.rollback(); resp.sendRedirect("cart.jsp?error=stock"); return; } } try (PreparedStatement insert = conn.prepareStatement( "INSERT INTO t_order(order_no, user_id, game_id, quantity, total_amount) VALUES(?, ?, ?, ?, ?)")) { insert.setString(1, generateOrderNo()); insert.setInt(2, userId); insert.setInt(3, gameId); insert.setInt(4, quantity); insert.setBigDecimal(5, getGamePrice(conn, gameId).multiply(BigDecimal.valueOf(quantity))); insert.executeUpdate(); } conn.commit(); session.removeAttribute("cart"); resp.sendRedirect("order_success.jsp"); } catch (SQLException | NullPointerException e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) {} } e.printStackTrace(); resp.sendRedirect("cart.jsp?error=system"); } finally { if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } } } }

这段代码有三处容易看懂但也容易抄错的地方。第一,setAutoCommit(false)必须在拿到连接后立刻执行,如果中间任何一条 SQL 失败,conn.rollback()把已执行的 UPDATE 也撤销。第二,扣库存用WHERE stock >= ?而不是先 SELECT 再 UPDATE,这样并发请求不会读到同一份旧库存。第三,finally里的conn.close()是必须的,用 DriverManager 拿连接时,没关闭会泄漏连接,直到 MySQL 报 Too many connections。

getGamePrice是一个在事务里查价格的辅助方法,逻辑就是SELECT price FROM t_game WHERE id = ?,返回 BigDecimal。下单金额必须在服务端算,绝不能信前端传过来的 totalAmount,这是下单接口的基本素养。

4. 避坑与排查:JSP+Servlet+MySQL 开发中的 6 个常见问题

这一章整理开发中最高频的 6 个坑,每条都按现象、原因、解决的顺序写。有的是我早期踩过,有的在帮别人排查时见过。

4.1 MySQL 连接失败:error 2002 (HY000) 与驱动类名

现象:项目启动后日志里报java.sql.SQLException: No suitable driver found for jdbc:mysql://localhost:3306/game_store,或者在命令行直接输mysql -uroot -p报ERROR 2002 (HY000): Can't connect to local MySQL server through socket '/tmp/mysql.sock'。

原因:No suitable driver 通常是驱动 jar 没进 WEB-INF/lib,或者数据库连接 URL 拼错;error 2002 则是 MySQL 服务没启动、socket 路径不对。如果 MySQL 装的是 5.7,连接串还是jdbc:mysql://localhost:3306能跑,换成 MySQL 8 就必须要带 serverTimezone,不然报时区错误。

解决:先看服务状态再动代码。

# 确认 MySQL 是否在运行 systemctl status mysqld # 没启动就启动 systemctl start mysqld # 本机连一次验证权限 mysql -uroot -p

代码里统一用 8.x 的驱动类名com.mysql.cj.jdbc.Driver,连接串写成:

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

useSSL=false是本地开发关掉 SSL 握手,serverTimezone是避开 8.0 驱动默认 UTC 与本地时区不一致。如果还报 Access denied,那就是 root 密码或账号权限问题,用 root 登录 MySQL 重新 GRANT 一下。

4.2 JSP 中文乱码:请求、响应、页面三层都要处理

现象:页面显示 “æ¸¸æ” 这样的乱码,或者数据库里存的中文变成问号。

原因:乱码分两个方向。请求方向:浏览器把表单内容用 UTF-8 编码发过来,Servlet 没调用req.setCharacterEncoding("UTF-8"),Tomcat 默认用 ISO-8859-1 解码,汉字就乱了。响应方向:Servlet 往页面输出文字时,如果不设置 ContentType 的 charset,浏览器可能按系统默认编码去解。数据库方向:连接 URL 没带 characterEncoding,或者库表字符集不是 utf8mb4。

解决:三层全处理,少一层都可能出问题。

// Servlet 里,读取参数前先设置编码 req.setCharacterEncoding("UTF-8"); resp.setContentType("text/html;charset=UTF-8");
<!-- JSP 文件第一行,pageEncoding 决定 JSP 文件本身用啥编码解析 --> <%@ page contentType="text/html;charset=UTF-8" language="java" pageEncoding="UTF-8" %>
<!-- Tomcat 的 server.xml,给 GET 请求的 URI 也设成 UTF-8 --> <Connector port="8080" protocol="HTTP/1.1" URIEncoding="UTF-8"/>
-- 建库时指定字符集,用 utf8mb4 而不是 utf8 CREATE DATABASE game_store DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;

MySQL 连接 URL 里也要有characterEncoding=utf8。这样下来前后端和数据库三层编码统一,游戏描述里放 emoji 也不会变问号。

4.3 传统 JSP 项目打包 WAR:部署后静态资源 404

现象:IDEA 里 F5 跑得欢,mvn clean package打出 WAR 丢到 Tomcat webapps,登录后页面 CSS、图片全 404,点跳转也找不着。

原因:IDEA 里 Application context 设成/,访问路径是http://localhost:8080/index.jsp;Tomcat 解压 WAR 后,访问路径是http://localhost:8080/war包名/index.jsp。你在页面里写死/css/style.css,它解析成http://localhost:8080/css/style.css,Tomcat 在根路径下找不到这个资源,404 没商量。

解决:页面里所有资源路径都拼上 contextPath。

<link rel="stylesheet" href="${pageContext.request.contextPath}/css/style.css"/> <img src="${pageContext.request.contextPath}/images/cover.jpg"/>
// Servlet 里重定向方向也必须带 contextPath resp.sendRedirect(req.getContextPath() + "/index.jsp");

另外,WAR 文件名如果想短一点,可以在 pom.xml 里配:

<build> <finalName>game_store</finalName> </build>

部署完访问地址就变成http://localhost:8080/game_store/index.jsp。web.xml 里把 welcome-file 配成 index.jsp,根路径直接访问也能找到首页。

4.4 连接没关闭:Too many connections 把 MySQL 拖死

现象:Tomcat 跑一两个小时开始报Data source rejected establishment of connection, ... Too many connections,重启 Tomcat 恢复,过一阵又犯。

原因:业务代码里写了conn = DBUtil.getConnection(),但 try-catch-finally 里漏了conn.close(),或者只关了 Statement 没关 Connection。每个请求建一条新物理连接,用完不还,MySQL 的 max_connections 默认 151,被一个 Tomcat 实例占满毫不意外。

解决:用 try-with-resources 语法,让 Connection、Statement、ResultSet 出作用域自动关。

try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement("SELECT ...")) { ... } catch (SQLException e) { e.printStackTrace(); }

排查时先看当前到底有多少连接:

SHOW STATUS LIKE 'Threads_connected'; SHOW PROCESSLIST;

SHOW PROCESSLIST能列出每个连接来自哪个 IP、在跑什么 SQL。看到大量来自 Tomcat 所在 IP 的 Sleep 连接,基本就是代码漏关连接。这个坑属于定时炸弹,业务量上去才炸,排查时最容易被忽略。

4.5 库存扣成负数:并发下单没做条件更新

现象:某游戏库存 5,活动期间用户疯狂下单,最后订单总量 20,库存变 -15,后台看呆。

原因:下单逻辑是“先 SELECT 查库存,再 UPDATE 减库存”。两条 SQL 之间有间隙,两个请求同时读到库存 5,各自减一,UPDATE 结果都执行成功,库存变 3,但订单多了两笔。SELECT 拿到的是“快照”,不是“锁”。

解决:把校验和扣减合成一条 UPDATE 条件更新:

UPDATE t_game SET stock = stock - 1 WHERE id = ? AND stock >= 1 AND status = 1;

Java 里用executeUpdate()返回的受影响行数判断:返回 0 说明条件不满足,即库存不足或已下架,直接回滚事务、提示用户库存不足。这样即使 100 个并发同时执行,InnoDB 的行锁也会让它们排队,只有前一个 UPDATE 提交后,下一个才能拿到锁,库存不会被扣穿。

4.6 Servlet 单实例多线程:全局可变状态害人

现象:用户 A 下单后,用户 B 偶尔看到 A 的购物车内容;或者两个用户同时操作,某个全局变量相互覆盖。

原因:Servlet 在 Tomcat 里是单实例的,所有请求共用同一个 Servlet 对象。如果 Servlet 里定义了成员变量,比如private Connection conn;或private Map cart;,那就是多个线程在读写同一份数据,没有同步就是数据错乱。

解决:Servlet 里不写可变成员变量,连接、参数全部在方法内局部创建。

// 错误写法:成员变量被多线程共享 private Connection conn; // 正确写法:每个请求方法内部自己拿连接 @Override protected void doPost(...) { try (Connection conn = DBUtil.getConnection()) { ... } }

Session 的购物车变量没问题,因为 Session 是每个用户一份,但 Servlet 的成员变量是全局一份。记住这条经验:把 Servlet 当无状态的服务类,所有状态都放 request、session 或数据库里。

5. 进阶:连接池、分页优化与一次 JMeter 压测验证

这一章把项目从“能跑”推到“能用、能验证”。三个方向:连接池替代手动管理 JDBC 连接、分页索引优化、用 JMeter 验证下单不超卖。

5.1 从 DriverManager 到 Druid 连接池

手动用 DriverManager 管理连接,高并发时 MySQL 握手开销不可忽略。换 Druid 连接池是国内 Java 项目最常见做法,配置集中在 DBUtil 里:

public class DBUtil { private static DruidDataSource dataSource = new DruidDataSource(); static { dataSource.setDriverClassName("com.mysql.cj.jdbc.Driver"); dataSource.setUrl("jdbc:mysql://localhost:3306/game_store?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"); dataSource.setUsername("root"); dataSource.setPassword("123456"); dataSource.setInitialSize(5); dataSource.setMaxActive(20); dataSource.setMinIdle(5); dataSource.setMaxWait(10000); dataSource.setTestWhileIdle(true); dataSource.setValidationQuery("SELECT 1"); } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }

maxActive 是最重要的参数,它决定连接池最多持有多少物理连接。Tomcat 默认线程池 200,如果 maxActive 也是 200,极端情况线程全在抢连接,建议配合压测调到 20 到 50。maxWait 是拿不到连接时的等待毫秒数,超过就抛异常,总比无限卡死好。

5.2 分页查询:LIMIT/OFFSET 与组合索引

游戏列表页要分页,典型 SQL 是:

SELECT id, game_name, price, stock, cover_url FROM t_game WHERE status = 1 ORDER BY id DESC LIMIT 12 OFFSET 12;

OFFSET 翻页越深越慢,因为 MySQL 要先扫过前面所有行再丢弃。列表页控制在几十页内没问题,但如果有“查看第 1000 页”这种需求,就换游标分页:

SELECT id, game_name, price, stock, cover_url FROM t_game WHERE status = 1 AND id < 1000 ORDER BY id DESC LIMIT 12;

前台游戏列表商品量不会上万,OFFSET 就够用。但WHERE status=1 ORDER BY id DESC记得建idx_status_id(status, id)组合索引,否则 MySQL 每次要 filesort,游戏数据到几千条时排序耗时能被明显感知。

5.3 用 JMeter 验证下单不超卖

写完功能怎么证明它是对的?我习惯用 JMeter 压一次下单接口,验证三件事:错误率是 0、订单总数正确、库存对得上。步骤很简单:

  1. 在 JMeter 里建线程组:50 个线程,Ramp-Up 1 秒,循环 1 次。
  2. 加 HTTP Cookie 管理器,让每个线程带自己的 Session。
  3. 加 HTTP 请求:POST http://localhost:8080/order/create,参数 gameId=1&quantity=1。
  4. 加响应断言,判断响应码不是 302 重定向就报错。
  5. 运行完看聚合报告,然后查数据库:
-- 订单数 SELECT COUNT(*) FROM t_order WHERE game_id = 1; -- 剩余库存 SELECT stock FROM t_game WHERE id = 1; -- 校验:初始库存 - 已支付订单数 = 当前剩余库存

如果订单数加剩余库存不等于初始库存,说明有超卖或丢单。跑一次压测再人工对账,比盯着代码猜强得多。这个习惯我一直保留:功能上线前用压测把“最坏情况”过一遍,心里才有底。希望这个流程对你的游戏购买网站项目有帮助。

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

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

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

立即咨询