ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

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

JSP+Servlet+MySQL实战:从零搭建游戏购买网站 简介一套基于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年了还选 JSPServletMySQL 做游戏购买网站如果你正在为毕设选题发愁想做“游戏购买网站”又不想一上来就被 SpringBoot 的自动配置和各种注解淹没那 JSPServletMySQL 这套组合反而是最能看清 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 groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency !-- MySQL 驱动8.0 系列必须要带 cj 的驱动类 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency !-- JSTLJSP 里做条件判断和循环 -- dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency /dependenciesscope 写 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 explodedApplication 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) ) ENGINEInnoDB 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) ) ENGINEInnoDB 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) ) ENGINEInnoDB COMMENT订单表;几个选型细节解释一下。表名用 t_user、t_game、t_order 而不是 user、game、orderorder 是 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 status1 ORDER BY id DESC这个组合索引能同时覆盖过滤和排序。t_order 的 user_id 和 game_id 都是外键InnoDB 在创建外键时会自动为外键列生成索引所以不写 KEY 也够用这里写出来是让表结构更明确。注意三张表全部用 InnoDB。MyISAM 查起来快一点但它不支持行锁和事务游戏购买里的“下单扣库存”离不开事务这一步选错后面全得返工。3. 核心功能落地注册登录、游戏列表与下单购买骨架有了这一章把三条链路做通注册登录、游戏列表与个人信息展示页面、购物车下单。JSPServlet 项目的请求链路是固定的页面表单发起请求 - 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?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8; 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 上useSSLfalse是本地开发关掉加密连接serverTimezoneAsia/Shanghai避免 8.0 驱动报时区错误characterEncodingutf8保证中文字符从连接就按 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?error1); 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?error2); 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?registered1); } catch (SQLException e) { e.printStackTrace(); resp.sendRedirect(register.jsp?error3); } } }这里用到的 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至少要加盐。但 JSPServlet 是教学向项目用 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?error1); } } catch (SQLException e) { e.printStackTrace(); resp.sendRedirect(login.jsp?error2); } } }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(); ListGame 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 contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % html headtitle游戏商城/title/head body h2游戏列表/h2 div classgame-grid c:forEach vargame items${gameList} div classgame-card img src${game.coverUrl} alt${game.gameName} width200 height200/ h3${game.gameName}/h3 p价格¥${game.price}/p p库存${game.stock}/p a hrefgame/detail?id${game.id}查看详情/a a hrefcart/add?gameId${game.id}加入购物车/a /div /c:forEach /div /body /htmlEL 表达式${game.gameName}等价于% game.getGameName() %但写法简洁得多而且属性不存在时输出空字符串而不是抛异常。JSTL 的 c:forEach 比 scriptlet 里写 for 循环干净太多。新手最容易犯的错误是页面里混用% %和 EL建议定一个规矩页面里只用 EL JSTLJava 代码全部留在 Servlet 里。个人信息展示页是选这个技术栈最常出现的需求页面本身不复杂难点在怎么把数据从 Session 和数据库里取出来。我一般用一个 ProfileServlet 在查库后把属性塞进 request再转发给 profile.jspWebServlet(/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 contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % html headtitle个人信息/title/head body c:if test${empty sessionScope.userId} p您还没有登录a hreflogin.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 /htmlempty sessionScope.userId是 EL 里的空值判断比% if (session.getAttribute(userId) ! null) %短一截。这里sessionScope.userId来自 Sessionusername、email来自 request 转发时塞的属性。如果用户没登录Session 里没有 userId页面就只显示登录提示不会抛空指针。3.3 购物车与下单MySQL 事务与库存条件更新游戏购买网站最核心的是购买流程。购物车常见做法有两种一种是全部存 Session另一种是落一张购物车表。中小型项目我推荐 Session 购物车用户关浏览器就清了结构简单。如果要跨设备同步那再上表但游戏购买网站的诉求没那么复杂。购物车操作我直接写一个 CartAddServletURL 设计成 /cart/addWebServlet(/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)); MapInteger, Integer cart (MapInteger, 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 购物车的实现就是一个MapInteger, Integerkey 是游戏IDvalue 是数量。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?errorstock); 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?errorsystem); } 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. 避坑与排查JSPServletMySQL 开发中的 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): Cant 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?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8useSSLfalse是本地开发关掉 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;charsetUTF-8);!-- JSP 文件第一行pageEncoding 决定 JSP 文件本身用啥编码解析 -- % page contentTypetext/html;charsetUTF-8 languagejava pageEncodingUTF-8 %!-- Tomcat 的 server.xml给 GET 请求的 URI 也设成 UTF-8 -- Connector port8080 protocolHTTP/1.1 URIEncodingUTF-8/-- 建库时指定字符集用 utf8mb4 而不是 utf8 CREATE DATABASE game_store DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;MySQL 连接 URL 里也要有characterEncodingutf8。这样下来前后端和数据库三层编码统一游戏描述里放 emoji 也不会变问号。4.3 传统 JSP 项目打包 WAR部署后静态资源 404现象IDEA 里 F5 跑得欢mvn clean package打出 WAR 丢到 Tomcat webapps登录后页面 CSS、图片全 404点跳转也找不着。原因IDEA 里 Application context 设成/访问路径是http://localhost:8080/index.jspTomcat 解压 WAR 后访问路径是http://localhost:8080/war包名/index.jsp。你在页面里写死/css/style.css它解析成http://localhost:8080/css/style.cssTomcat 在根路径下找不到这个资源404 没商量。解决页面里所有资源路径都拼上 contextPath。link relstylesheet 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 finalNamegame_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?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8); 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 status1 ORDER BY id DESC记得建idx_status_id(status, id)组合索引否则 MySQL 每次要 filesort游戏数据到几千条时排序耗时能被明显感知。5.3 用 JMeter 验证下单不超卖写完功能怎么证明它是对的我习惯用 JMeter 压一次下单接口验证三件事错误率是 0、订单总数正确、库存对得上。步骤很简单在 JMeter 里建线程组50 个线程Ramp-Up 1 秒循环 1 次。加 HTTP Cookie 管理器让每个线程带自己的 Session。加 HTTP 请求POST http://localhost:8080/order/create参数 gameId1quantity1。加响应断言判断响应码不是 302 重定向就报错。运行完看聚合报告然后查数据库-- 订单数 SELECT COUNT(*) FROM t_order WHERE game_id 1; -- 剩余库存 SELECT stock FROM t_game WHERE id 1; -- 校验初始库存 - 已支付订单数 当前剩余库存如果订单数加剩余库存不等于初始库存说明有超卖或丢单。跑一次压测再人工对账比盯着代码猜强得多。这个习惯我一直保留功能上线前用压测把“最坏情况”过一遍心里才有底。希望这个流程对你的游戏购买网站项目有帮助。本文还有配套的精品资源点击获取
返回列表