☰
JSP+Servlet+MySQL游戏购买网站:订单事务、库存防超卖与激活码设计
2026/11/4 8:07:24 网站建设 项目流程

简介:面向Java Web入门、课程设计与毕业设计参考场景的游戏购买网站完整项目,适合具备基础语法、希望打通页面到数据库完整链路的学习者;围绕商品浏览、购物车、下单与后台运营等典型电商流程,帮助读者理解JSP+Servlet+DAO+MySQL的分层协作方式,并可作为同类商城题目的改造基础。压缩包共246个文件,约14.85MB,包含55个Java源文件、20个JSP页面、55个编译类、9个依赖JAR以及SQL脚本、CSS/JS、图片和XML配置等,基本覆盖前端页面、后端控制、数据访问与数据库初始化所需内容。已有99人学习,适合需要一套可运行、结构清晰的Java Web案例来对照练习和二次开发。资源内置管理员与普通用户两类角色:后台可进行订单、客户、游戏和类目管理,前台可完成登录、选购、购物车、提交订单与订单查询,配套admin/admin、user1/123456演示账号,便于快速梳理业务闭环与权限划分。

1. 一套游戏购买网站设计,卡住人的往往不是页面

某同学接了个毕业设计课题,题目是基于 jsp+servlet+mysql 的游戏购买网站,他一开始以为难点在首页轮播和商品详情页好不好看。真做起来才发现,页面两天就能搭出个样子,卡住他的是三件事:用户付了钱没拿到激活码、两个人同时抢最后一份库存、后台管理员能随手改价把钱改丢。这套 java mysql 游戏购买 的组合之所以年年有人选,是因为它把「请求进来—业务处理—数据落库」这条链路完整摊在你面前,没有框架替你兜底,写错了立刻体现在数据上。下面按一份能直接交出去的方案讲:需求怎么划边界、8 张表怎么落、下单事务怎么写、验收那天怎么演示。

2. 需求拆解与技术选型:为什么这套组合反而最稳

动手写代码之前先把范围固定下来,这一步决定后面三个月的返工量。游戏购买网站听起来简单,实际牵扯商品、库存、订单、激活码、权限五摊子事,每一摊都能无限扩写。多数人翻车不是因为技术难,是因为在开题阶段没有明确划定功能范围,做到一半发现要加退款、要加优惠券、要加评论,数据库直接推翻重来。

2.1 三类角色与用例边界

角色划分不需要多,三个足够撑起一份完整的方案。游客只读:浏览首页、按分类筛、搜索游戏名、看详情页的图文和价格,能看到的库存只显示「有货/无货」,不给具体数字。登录用户可以加购物车、下单、模拟支付、查看自己的订单列表和订单里的激活码。管理员是运营侧,管分类、管游戏上下架、补库存、查订单、处理退款申请、封禁异常账号。

边界比功能更重要,这也是答辩时最容易被追问的地方。真实支付不接,用「模拟支付」按钮把订单状态从待支付改到已支付,答辩时主动说明这是范围控制,比被问出来强。游戏本体不做下载分发,卖的是激活码,用户拿到码自己去对应平台兑换,这样既贴合真实业务,又避开了文件存储的大坑。推荐算法、评论社区、优惠券体系一律不做,理由写进文档:与核心交易链路无关。

提示:开题文档里专门写一小节「本期不做」,比写十页功能列表更能体现工程判断力。

用例边界定清楚之后,表结构和接口数量基本就确定了:用户表、管理员表、分类表、游戏表、激活码表、购物车表、订单表、订单明细表,八张表刚好覆盖全部用例,不多不少。

2.2 技术栈选型与版本组合

这套方案的技术选型核心原则是「每一层都能在答辩时讲清原理」。用 Spring 当然省事,但 IOC 容器怎么扫描、事务代理怎么织入,讲不透反而扣分。手写 Servlet 至少能说清 init 到 destroy 的生命周期,DAO 层手写 JDBC 至少能说清 PreparedStatement 怎么防注入。

层次选型版本选它的理由
运行环境JDK8 或 11与 Tomcat 9 兼容性最好,教程资料最多
Web 容器Tomcat9.0.x命名空间仍是 javax.servlet,绝大多数现成代码可直接用
Web 层Servlet + JSP + JSTLServlet 4.0 / JSTL 1.2课设要求手写,生命周期与请求转发链路清晰
数据库MySQL5.7 或 8.0事务、行锁、索引都支持,安装包小
驱动mysql-connector-java8.0.x与 MySQL 8 匹配,注意驱动类名带 cj
连接池Druid1.1.x自带监控页,演示连接池复用效果很直观
前端Bootstrap 5 + jQuery—不写原生 CSS 也能出效果,省时间给后端
构建Maven3.6+依赖版本锁在 pom 里,换机器不炸

版本组合里有一个必须提前确认的点:Tomcat 10 之后包名从 javax.servlet 换成了 jakarta.servlet,网上抄来的代码十有八九编译不过,报「程序包 javax.servlet 不存在」。要么锁死 Tomcat 9,要么全量改包名,两头选一个,别中途换。数据库连接串里三个参数别省:useUnicode=true 和 characterEncoding=utf8 决定中文不乱码,serverTimezone=Asia/Shanghai 决定时间不偏移八小时,这两个坑在第 5 章还会细说。

前端选 Bootstrap 不是为了好看,是为了让列表页、表单页、弹窗在一周内全部成型,把时间留给订单和库存这两块真正有技术含量的地方。JSTL 的 c:forEach 和 c:if 足以覆盖所有页面渲染需求,不需要引入模板引擎。

2.3 工程结构与分层约定

包结构一旦定了就别中途改,否则 import 改到你怀疑人生。按职责切四个包:controller 只负责收参数、调 service、转发页面;service 承载业务规则和事务边界;dao 只写 SQL;entity 是纯数据载体,一个字段对应一列,不塞业务方法。util 放数据库工具、订单号生成、MD5 加密这类横切的东西。

game-shop/ ├── src/main/java/com/shop/ │ ├── controller/ # Servlet:/game/list、/cart/add、/order/create … │ ├── service/ # 业务规则 + 事务边界,一个方法一个事务 │ ├── dao/ # 纯 JDBC,SQL 只出现在这一层 │ ├── entity/ # User、Game、Orders、OrderItem … │ ├── filter/ # LoginFilter、AdminFilter、EncodingFilter │ └── util/ # DBUtil、OrderNoGen、MD5Util、PageResult ├── src/main/webapp/ │ ├── WEB-INF/jsp/ # 全部 JSP 放这里,禁止浏览器直接访问 │ ├── static/ # bootstrap、jquery、上传的封面图 │ └── WEB-INF/web.xml # Servlet 3.0+ 可用注解替代,留作兜底 └── pom.xml

JSP 全部塞进 WEB-INF 下是个小习惯但收益很大,浏览器无法直接敲 URL 访问到视图层,所有请求必须经过 Servlet,参数校验和权限检查就没法被绕过。连接池用静态块初始化一次,全局复用,不要在每次请求里 new 一个 DataSource。

public final class DBUtil { private static final DataSource DS; static { DruidDataSource ds = new DruidDataSource(); // 三个必带参数:编码、时区、SSL,少一个就等着排查乱码和时间偏移 ds.setUrl("jdbc:mysql://localhost:3306/game_shop" + "?useUnicode=true&characterEncoding=utf8" + "&serverTimezone=Asia/Shanghai&useSSL=false"); ds.setUsername("shop"); ds.setPassword("shop_2024"); ds.setInitialSize(5); // 启动即建 5 条连接,避免首屏卡顿 ds.setMaxActive(20); // 上限按并发估,课设场景 20 足够 ds.setMaxWait(3000); // 拿不到连接最多等 3 秒,超时抛异常而不是无限挂起 ds.setValidationQuery("SELECT 1"); DS = ds; } public static Connection getConnection() throws SQLException { return DS.getConnection(); } }

maxActive 不是越大越好,MySQL 默认最大连接数是 151,池子开到 200 只会让数据库端报 too many connections。initialSize 设成 5 能让第一次访问不至于因为建连接而卡顿。maxWait 一定要设,否则连接泄漏时请求会一直挂着不返回,前端表现就是页面转圈到超时,日志里什么都看不到,非常难查。

3. 数据库设计:8 张表撑起商品、订单、库存三条主线

表设计是整个方案的地基,改表比改代码贵十倍。这里的主线只有三条:商品从哪来(分类 + 游戏)、库存怎么算(激活码)、钱怎么记(订单 + 明细)。购物车是临时的,用户表是支撑的,这四条线交叉出来的就是全部业务。

3.1 八张表的分工与关键字段

先说结论:game 表存商品信息和一个冗余的 stock 字段,game_key 表存每一条激活码,orders 只存订单头和总金额,order_item 存明细快照。冗余 stock 是为了列表页不用每次 count 激活码,代价是发码时必须同步扣减,这个一致性在第 4 章的事务里保证。

表名作用关键字段
user买家账号id、username、password(MD5)、status、create_time
admin管理员id、username、password、role
category游戏分类id、name、sort
game游戏商品id、name、category_id、price、stock、cover、status
game_key激活码明细id、game_id、key_code、status、order_item_id
cart购物车id、user_id、game_id、num
orders订单头id、order_no、user_id、total_amount、status、create_time
order_item订单明细id、order_id、game_id、game_name、price、num、key_code

order_item 里的 game_name 和 price 是快照字段,不是冗余设计失误。商品改价、改名、下架之后,历史订单必须显示下单当时的信息,否则用户翻旧订单会发现金额对不上,这种投诉没法解释。同理 key_code 直接写在明细里,一个明细行对应一个激活码,数量大于 1 就拆成多行,逻辑最简单。

CREATE TABLE `game` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `name` VARCHAR(120) NOT NULL, `category_id` INT NOT NULL, `price` DECIMAL(10,2) NOT NULL, -- 金额一律 DECIMAL,禁用 FLOAT/DOUBLE `stock` INT NOT NULL DEFAULT 0, `cover` VARCHAR(255) DEFAULT NULL, `status` TINYINT NOT NULL DEFAULT 1, -- 1 上架 0 下架 `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY `idx_cate_status` (`category_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `game_key` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `game_id` INT NOT NULL, `key_code` VARCHAR(64) NOT NULL, `status` TINYINT NOT NULL DEFAULT 0, -- 0 未售 1 已售 `order_item_id` BIGINT DEFAULT NULL, UNIQUE KEY `uk_key` (`key_code`), KEY `idx_game_status` (`game_id`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `orders` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `order_no` VARCHAR(32) NOT NULL, `user_id` INT NOT NULL, `total_amount` DECIMAL(10,2) NOT NULL, `status` TINYINT NOT NULL DEFAULT 0, -- 0 待支付 1 已支付 2 已发码 3 已取消 `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `pay_time` DATETIME DEFAULT NULL, UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_time` (`user_id`, `create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `order_item` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `order_id` BIGINT NOT NULL, `game_id` INT NOT NULL, `game_name` VARCHAR(120) NOT NULL, -- 下单时的名称快照 `price` DECIMAL(10,2) NOT NULL, -- 下单时的价格快照 `num` INT NOT NULL, `key_code` VARCHAR(64) DEFAULT NULL, KEY `idx_order` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

字符集统一用 utf8mb4 而不是 utf8,游戏名里出现生僻字或者玩家昵称带表情符号时,utf8 三字节存不下会直接报错。金额字段用 DECIMAL(10,2),如果用 DOUBLE 存 19.9,累加几次之后可能出现 19.899999999,订单总额和明细对不上,对账时非常难受。

3.2 下单落库的时序与事务边界

一次下单要碰四张表:写 orders、写 order_item、更新 game_key 状态、扣 game.stock。这四步必须在一个事务里,中途任何一步失败都要整体回滚,否则会出现「订单在但没码」或者「码发了但库存没减」这种脏数据。

正确的顺序是:先开事务,按游戏 id 从小到大排序(避免多商品下单时的死锁),逐个SELECT ... FOR UPDATE锁住 game 行并校验库存,全部通过后再写订单头,然后逐条写明细、占激活码、扣库存,最后 commit。排序这一步很多人忽略,两个用户同时买 A 和 B,一个先 A 后 B,一个先 B 后 A,就会互相等锁直到超时。

取消订单是反方向操作:状态改为已取消,把 order_item 里的 key_code 对应的 game_key 状态重置为未售、order_item_id 置空,再把 stock 加回去。这一套也必须在一个事务里,且要判断当前状态是否为待支付,已支付的订单不能直接取消,只能走退款流程。

3.3 索引设计与列表查询

索引只建在真正会用到的条件上。game 表按分类筛列表是最频繁的查询,(category_id, status)联合索引能覆盖「某分类下所有上架游戏」;orders 表用户查自己的订单,(user_id, create_time)让订单列表按时间倒序走索引,不用额外排序;game_key 表发码时要找「某游戏第一个未售的码」,(game_id, status)正好命中。key_code 上的唯一索引是数据安全网,程序出错重复插入时数据库直接拦下来,比事后查数据强。

不要给每个字段都加索引,写多读少的表加索引纯属自找麻烦,而且 MySQL 选错索引时查询反而更慢。课设规模的数据量下,两三张核心表各一两个索引就够了,剩下的靠 SQL 写对。

4. 核心模块实现:登录拦截、商品分页、下单扣库存与后台状态机

实现阶段最忌讳的是「一个 Servlet 干完所有事」。见过不少课程设计,一个 GameServlet 里 if 判断 action 参数,然后几百行代码从上写到下,登录、列表、详情、加购全在里面,改一个功能要读半小时。按 URL 拆 Servlet,一个动作一个类,虽然类多了,但每个类都短得能一眼看完。

4.1 登录会话与两个过滤器

登录流程本身不难:表单提交到 LoginServlet,查库校验密码(存的是 MD5,比对也用 MD5),成功后把 User 对象放进 HttpSession,重定向回首页。真正的设计点在于「哪些路径必须登录」,这个判断不应该散落在每个 Servlet 里,交给过滤器统一做。

@WebFilter(urlPatterns = {"/cart/*", "/order/*", "/user/*"}) public class LoginFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; // 关键:false 表示不主动创建 session,避免给爬虫和游客凭空建会话 HttpSession session = req.getSession(false); Object user = (session == null) ? null : session.getAttribute("loginUser"); if (user == null) { // 把原始地址带上,登录后能跳回去,体验差别很大 String back = URLEncoder.encode(req.getRequestURI(), "UTF-8"); resp.sendRedirect(req.getContextPath() + "/login.jsp?back=" + back); return; } chain.doFilter(request, response); // 放行,请求继续走到目标 Servlet } }

getSession(false) 是个容易被忽略的细节,写成 getSession(true) 的话每个未登录请求都会创建一个空会话,Tomcat 内存里堆一堆没人用的 session,压测时表现就是内存缓慢上涨。urlPatterns 只挂需要保护的三组路径,商品浏览、搜索、详情页不拦,游客能正常逛。

管理员侧单独一个 AdminFilter,挂在/admin/*上,判断 session 里的 admin 对象。两个过滤器不要合并成一个用角色分支判断,职责分开后面改权限规则时不用动另一条逻辑。密码用 MD5 加固定盐就行,课设层面够了,答辩时主动说明 MD5 不可逆但可撞库、生产环境应换 BCrypt,比假装安全更得分。

4.2 商品列表的分页与条件检索

分页不要用 JSP 里全查出来再截取那种写法,数据一多必卡。标准做法是两条 SQL:一条SELECT COUNT(*)拿总数,一条带LIMIT offset, size拿当前页数据。两条 SQL 的条件必须完全一致,这一点下面第 5 章会作为踩坑点展开。

public PageResult<Game> page(Integer categoryId, String keyword, int page, int size) throws SQLException { StringBuilder where = new StringBuilder(" WHERE status = 1"); List<Object> params = new ArrayList<>(); if (categoryId != null && categoryId > 0) { where.append(" AND category_id = ?"); params.add(categoryId); } if (keyword != null && !keyword.trim().isEmpty()) { where.append(" AND name LIKE ?"); params.add("%" + keyword.trim() + "%"); // 前后模糊,注意这里走不了索引 } int total; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement("SELECT COUNT(*) FROM game" + where)) { bind(ps, params); try (ResultSet rs = ps.executeQuery()) { rs.next(); total = rs.getInt(1); } } List<Game> list = new ArrayList<>(); String sql = "SELECT id, name, cover, price, stock FROM game" + where + " ORDER BY id DESC LIMIT ?, ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { int i = bind(ps, params); // 复用同一组条件参数,返回下一个占位符下标 ps.setInt(i, (page - 1) * size); // offset:从 0 开始 ps.setInt(i + 1, size); // size:每页条数 try (ResultSet rs = ps.executeQuery()) { while (rs.next()) list.add(mapRow(rs)); } } return new PageResult<>(list, total, page, size); }

bind 方法负责把条件参数按顺序塞进 PreparedStatement 并返回下一个可用下标,这样 count 和 list 两条 SQL 共用一份条件,永远不会出现「总数说有 37 条、翻到第 4 页是空的」这种诡异现象。size 一般固定 12 或 20,page 从 1 开始,前端传非法值(0、负数、超过总页数)时在 Servlet 里校正到合法区间,别指望数据库替你兜。

搜索结果里LIKE '%关键词%'前后都带通配符,索引失效走全表扫描,数据量小的时候完全无感,几万行之后能明显看到慢。课设规模不必上全文索引,但答辩时被问到要能说出这个限制和优化方向,这是加分项。

4.3 下单与库存扣减的完整事务

这是整个项目最值得写进论文的一节。核心矛盾是:库存判断和库存扣减必须原子,否则并发下必然超卖。做法是把校验和扣减放进同一个数据库事务,用FOR UPDATE行锁把该商品的行锁住,第二个请求进来会阻塞等待,第一个提交后它读到的就是扣减后的库存。

public String createOrder(int userId, List<CartItem> items) throws Exception { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 事务开始,后续所有写操作同生共死 // 1. 按 gameId 升序,防止两个用户交叉下单造成死锁 items.sort(Comparator.comparingInt(CartItem::getGameId)); BigDecimal total = BigDecimal.ZERO; for (CartItem it : items) { // 2. 行锁:这条 SELECT 会锁住该游戏行,直到事务结束 Game g = gameDao.lockById(conn, it.getGameId()); if (g == null || g.getStatus() != 1) { throw new BizException("商品已下架:" + it.getGameId()); } if (g.getStock() < it.getNum()) { throw new BizException("《" + g.getName() + "》库存不足,仅剩 " + g.getStock() + " 份"); } it.setPrice(g.getPrice()); // 价格以数据库为准,不信前端传的 total = total.add(g.getPrice().multiply(BigDecimal.valueOf(it.getNum()))); } // 3. 写订单头,状态 0 = 待支付 String orderNo = OrderNoGen.next(); // 时间戳 + 用户尾号 + 4 位随机 long orderId = orderDao.insert(conn, orderNo, userId, total, 0); // 4. 写明细、占激活码、扣库存,三步绑在一起 for (CartItem it : items) { for (int n = 0; n < it.getNum(); n++) { String key = gameKeyDao.takeOne(conn, it.getGameId()); // 取一条未售码并置为已占 if (key == null) throw new BizException("激活码库存异常,请联系客服"); orderItemDao.insert(conn, orderId, it, key); } gameDao.decreaseStock(conn, it.getGameId(), it.getNum()); // UPDATE game SET stock = stock - ? } conn.commit(); // 全部成功才落盘 return orderNo; } catch (Exception e) { if (conn != null) conn.rollback(); // 任何一步失败,订单、明细、码、库存全部还原 throw e; } finally { if (conn != null) { conn.setAutoCommit(true); // 还回连接池前必须恢复默认值 conn.close(); } } }

几个细节值得单独说。价格必须从数据库重新读,不能信任前端表单传来的 price,否则改一下请求参数就能一块钱买走游戏。items 排序是为了死锁预防,这点在前面已经解释过。finally 里把 autoCommit 恢复成 true 是纪律,连接池复用连接时如果带着 autoCommit=false 状态回去,下一个借到它的请求会莫名其妙地不提交,这个 bug 排查起来极其痛苦。

支付环节做模拟:用户点「去支付」,PayServlet 把 orders.status 从 0 改成 1,同时写 pay_time;紧接着调发码逻辑,把 order_item 里的 key_code 展示到订单详情页,orders.status 再推进到 2。发码确认可以合并到支付动作里一次事务完成,也可以拆成两步演示状态流转,看答辩时想强调什么。

4.4 后台管理的订单状态机

订单状态不能随便改,必须定义成有限状态机,每个流转写清楚触发条件和允许的源状态。这张表建议直接放进论文,评审看的是你有没有想清楚业务规则。

当前状态允许流转到触发动作校验条件
0 待支付1 已支付用户支付订单属于当前用户、未超时
0 待支付3 已取消用户取消 / 30 分钟超时无
1 已支付2 已发码系统发码 / 管理员手动激活码分配成功
1 已支付3 已取消管理员退款需填写退款原因
2 已发码—终态激活码已交付,不可回退

后台的状态修改接口只暴露「取消」和「发码」两个动作,不提供「直接改成任意状态」的下拉框。数据库层面在 update 语句里带上源状态条件:UPDATE orders SET status = 2 WHERE id = ? AND status = 1,返回影响行数为 0 就说明状态已经被别处改过,直接提示失败。这种乐观校验成本几乎为零,却能挡住重复点击和并发操作导致的脏数据。

后台商品管理还要注意一点:删商品用逻辑删除(status 置 0)而不是物理 DELETE。订单明细里存的是 game_id 快照,商品行真删了,历史订单点进去查不到商品信息,页面直接报空指针。这个坑在演示环节特别容易暴露,因为演示时为了展示「下架」效果往往现场删一个。

5. 避坑清单:这个项目里最容易翻车的五个点

下面这五条基本覆盖了同类项目 80% 的求助帖。每一条我都按「现象—原因—解决」写,遇到问题可以直接对号入座。

5.1 中文乱码:表单提交全是问号

现象:JSP 页面输入中文游戏名,保存到数据库变成???,或者列表页显示成乱码方块。原因是字符集在三个环节各有一套:请求体编码、响应体编码、数据库连接编码,任何一环没设对都会乱。解决办法是三层一起治:EncodingFilter 里无条件设置request.setCharacterEncoding("UTF-8")和response.setContentType("text/html;charset=UTF-8"),且必须在所有 Servlet 之前执行;JDBC URL 带上useUnicode=true&characterEncoding=utf8;建库建表统一CHARSET=utf8mb4。

还有一个容易漏的点:GET 请求的参数编码由 Tomcat 的 server.xml 决定,光设 request 的编码不管用,需要在 Connector 上加URIEncoding="UTF-8"。如果发现 POST 正常、GET 乱码,问题一定在这里。另外过滤器里的 setCharacterEncoding 必须写在 chain.doFilter 之前,写在后面等于没写。

5.2 连接泄漏:跑一会儿就卡死,日志里什么都没有

现象:本地测试正常,连续点几十次之后页面开始转圈,最后抛GetConnectionTimeoutException,重启 Tomcat 又好了。原因基本都是 Connection 用完没关,或者中途抛异常跳过了 close。JDBC 的 Connection 是稀缺资源,池子里就 20 条,泄漏 20 次就彻底堵死。解决办法是全部改成 try-with-resources,让编译器保证关闭:

// 正确写法:无论是否抛异常,conn / ps / rs 都会按逆序关闭 try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, gameId); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { /* 处理结果 */ } } }

注意 try-with-resources 只对实现了 AutoCloseable 的对象生效,Connection、PreparedStatement、ResultSet 都支持。还有一种隐蔽泄漏是忘记归还连接池里的连接时恢复 autoCommit,这个在第 4 章提过。开着 Druid 的监控页(/druid/index.html)能实时看到活跃连接数和等待线程数,答辩演示时打开这个页面,比嘴上说「用了连接池」有说服力得多。

5.3 重复提交与超卖:一次点击生成两笔订单

现象:用户手快连点两下「提交订单」,订单列表里出现两条一模一样的记录,库存被扣了两次;或者两个人同时买最后一份,都下单成功,激活码只有一条,第二个人拿不到码。原因有两个层面:前端没有防重复提交,后端没有对库存加锁。解决分三步走。前端在提交按钮上绑定一次性的 disable,点击后立即置灰。后端用订单号的唯一索引兜底,同一用户短时间内重复提交相同商品,生成相同 order_no 时数据库直接拒绝,捕获DuplicateKeyException后提示「订单已提交,请勿重复操作」。库存层面用 4.3 里的SELECT ... FOR UPDATE行锁,保证判断和扣减之间没有窗口期。

这三道防线里,唯一索引是最可靠的一道,因为它不依赖应用层逻辑。但要注意 order_no 生成规则必须能区分正常业务,用「时间戳到秒 + 用户 id 后两位 + 4 位随机」已经足够,两个用户在同一秒下单也不会撞。

5.4 金额用错类型:订单总额和明细对不上

现象:购物车里 19.9 + 29.9 + 9.9,页面显示 59.7,订单详情里三个明细加起来是 59.699999999。原因是用 double 或 float 存金额,二进制浮点表示不了 0.1 这类十进制小数。解决办法是数据库用 DECIMAL(10,2),Java 侧统一用 BigDecimal,且必须用字符串构造:new BigDecimal("19.90")而不是new BigDecimal(19.90),后者会把 double 的误差原样带进来。BigDecimal 做除法时记得指定精度和舍入模式,divide(x, 2, RoundingMode.HALF_UP),不然遇到除不尽的小数直接抛 ArithmeticException。金额的加减乘全部走 BigDecimal 的方法,别用+运算符。

5.5 分页的总数和列表用了两套条件

现象:搜索某个关键词,页面显示「共 37 条」,但翻到第 3 页只有 2 条,第 4 页是空白。原因是 count 查询和 list 查询分别拼了 WHERE 条件,某次改动只改了一边。解决办法是把条件拼装抽成一个方法,返回 where 子句和参数列表,两条 SQL 共用;参数绑定也用同一个 bind 方法按序填充。另外 LIMIT 的 offset 必须是(page-1)*size,写成page*size会永远跳过第一页,这个 off-by-one 错误调试时很容易看走眼,因为「数据能出来」所以不觉得有问题。

还有一个细节:查询结果为空时,前端翻页组件要隐藏或者禁用,别让用户点到第 100 页还是空白,体验和答辩观感都不好。

6. 验收与答辩:一份能照着跑的演示脚本

到了这一步,代码写没写完其实已经定了,能不能拿分主要看怎么演示。我一般会提前写一份 8 分钟的演示脚本,每个步骤配一句解说,卡壳就跳过,绝不现场调 bug。脚本的核心逻辑是「先展示主链路,再展示防错能力」,前者证明功能完整,后者证明你想过边界。

第一步,游客浏览首页,按分类筛一次,搜一个关键词,说明分页和检索的实现方式。第二步,注册一个新账号,加两个游戏进购物车,把其中一个数量改成 3,展示金额随数量实时重算。第三步,提交订单,故意点两次提交按钮,展示「请勿重复操作」提示,然后打开订单详情,这一下把防重复提交讲完了。第四步,模拟支付,页面刷新出 4 个激活码,逐条对应订单明细。第五步,切到后台,把其中一个商品的库存改成 0,回前台用另一个账号下单,展示「库存不足,仅剩 0 份」。第六步,打开 Druid 监控页,展示连接池活跃数在几个并发请求下的变化。

答辩提问集中在六个方向,提前把答案写成一句话版本背下来:

高频问题回答要点
为什么不用 Spring课设目标是掌握请求链路和 JDBC 底层,手写能完整说清 Servlet 生命周期与事务边界
超卖怎么解决行锁 + 事务,校验和扣减在同一事务内,SELECT ... FOR UPDATE阻塞并发请求
密码为什么不加密存实际存的是加盐 MD5,说明 MD5 可被彩虹表攻击,生产应换 BCrypt
事务在哪一层控制Service 层,一个业务方法一个事务,DAO 只接收外部传入的 Connection
激活码发完了怎么办支付前预占,取不到码直接回滚并提示,不会出现钱付了没码
表为什么这么设计商品、库存、订单三条主线,快照字段保证历史订单不受改价影响

有一次我把演示脚本里的顺序写反了,先演示后台改库存再去前台下单,结果前后两个浏览器 session 没切换干净,台上看到的库存是缓存里的旧值,当场愣了几秒才反应过来。从那以后我每次上台前都强制走一遍「关全部标签页、重开无痕窗口、按脚本顺序点一遍」的流程,宁可多花五分钟,也不在演示环节赌运气。这套方案里的表结构、事务代码和演示脚本是一个整体,缺哪块都跑不通,需要完整工程文件的话可以按标题去找对应的下载包。希望帮到你。

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

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

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

立即咨询