news 2026/10/17 4:53:51

J2EE原生云书城:库存锁+订单幂等+支付回调三重防线

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
J2EE原生云书城:库存锁+订单幂等+支付回调三重防线

简介:这是一份基于J2EE技术栈开发的完整在线图书销售系统——‘云工厂-网上书城’,面向Java Web初学者与企业级应用进阶学习者,用于深入理解多层架构设计、前后端协同及数据库集成等核心实践。资源包含588个文件,涵盖83个Java源码(如BookInfo.class、AdminInfoDAO.class等体现MVC分层逻辑)、45个JSP页面(负责动态视图渲染)、313个GIF与36个JPG素材(支撑前端交互与界面展示)、10个CSS样式文件及82个编译后class字节码,整体压缩包仅4.09MB,轻量易部署。已有242人学习下载,适合在Tomcat等Web容器中快速运行调试。读者可直接获取可运行的全功能电商原型,包括用户登录、图书检索、购物车管理(GwcServlet)、订单提交(SaveOrderServlet)、后台管理及MySQL数据库脚本(sql/db文件),并结合DAO层类与EJB思想理解典型J2EE企业级分层结构与事务处理机制。

1. 为什么一个“网上书城”要上云工厂?不是为了炫技,而是为了扛住开学季的秒杀洪峰

某高校课程设计里常出现“网上书城”,但多数只跑在本地 Tomcat、连数据库都用 HSQLDB 模拟,一加购物车就卡,一查订单就 500。而真实场景中,教材订购集中在每学期初——30 分钟内涌入 2000+ 学生并发选课教材,库存扣减不准、订单重复生成、支付回调丢失,这类问题不是“功能没写完”,而是架构没立住。本方案“云工厂-网上书城”不堆新概念,核心就做三件事:用 J2EE 规范打底(Servlet 3.1 + JSP 2.3 + JSTL 1.2 + JDBC + JNDI),把传统三层结构跑通;把部署从“导 war 包扔 Tomcat”升级为可复现的云化交付(Docker 容器封装 + MySQL 8.0 独立容器 + Nginx 静态资源代理);最关键的是,在不引入 Spring Boot 的前提下,靠原生 Filter + Listener + 自定义 ConnectionPool 实现会话级库存锁、幂等下单、支付状态机闭环。它适合两类人:一是毕设/课设需体现工程规范性的学生(答辩时能讲清 Filter 如何拦截重复提交、为何不用 synchronized 而用数据库行锁);二是想补 J2EE 底层能力的初级工程师(看懂 web.xml 如何绑定监听器、JNDI 数据源怎么配、JSP 中 EL 表达式与 JSTL 标签的真实协作链路)。这不是一个“能跑就行”的 Demo,而是一套可审计、可压测、可交接的最小生产级 J2EE 方案。

2. 总体架构设计:J2EE 原生栈的四层职责划分与容器化边界

本方案严格遵循 J2EE 规范分层,但对部署形态做了云工厂适配:Web 层(JSP/Servlet)、业务逻辑层(POJO Service)、数据访问层(DAO + ConnectionPool)、基础设施层(Dockerized MySQL + Nginx)。关键不是“用了什么”,而是“谁管什么、边界在哪”。比如,Nginx 不做反向代理负载均衡(单实例够用),只干两件事:静态资源(CSS/JS/IMG)直出,减少 Tomcat I/O;对 /pay/callback 路径做路径重写,把外部支付平台回调统一转给 Servlet 处理——这是为后续扩展多支付渠道留的钩子,不是当前必须,但设计时已预留。

2.1 Web 层:JSP + Servlet + Filter 的请求生命周期闭环

所有用户请求入口统一走 FrontControllerServlet(继承 HttpServlet),它不做业务,只做三件事:解析 URL 路径获取 action 名(如 /book/list → list)、根据 action 查配置表(action-mapping.properties)定位对应业务 Service 类、调用 Service 并转发到指定 JSP。这种写法看似复古,但好处是:无框架依赖,web.xml 可完整描述整个路由链;Filter 可精准插在任意环节。例如,OrderSubmitFilter 在 doFilter() 中先校验 session 是否存在、再检查请求参数是否含 token(防 CSRF)、最后用 request.setAttribute("submitTime", System.currentTimeMillis()) 注入时间戳供 Service 层做幂等判断。代码如下:

public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; // 1. 会话校验 if (request.getSession().getAttribute("user") == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } // 2. CSRF Token 校验(token 存于 hidden input,服务端存于 session) String clientToken = request.getParameter("csrf_token"); String serverToken = (String) request.getSession().getAttribute("csrf_token"); if (!Objects.equals(clientToken, serverToken)) { request.setAttribute("error", "非法请求,请刷新页面重试"); request.getRequestDispatcher("/error.jsp").forward(request, response); return; } // 3. 注入幂等时间戳(Service 层将此值拼入订单号前缀) request.setAttribute("submitTime", String.valueOf(System.currentTimeMillis())); chain.doFilter(request, response); }

这段 Filter 的设计意图很明确:把安全校验和上下文注入从 Service 层剥离,让业务代码专注“扣库存”“生订单”,而不是反复写 if(session==null)。这也是 J2EE 原生开发中“关注点分离”的典型实践——不是靠注解自动注入,而是靠 Filter 的标准生命周期强制执行。

2.2 业务逻辑层:POJO Service 的事务边界与状态机驱动

Service 层全部为无状态 POJO,不继承任何框架类,事务控制完全由 DAO 层配合 Connection 手动管理。以BookOrderService.placeOrder()为例,它不直接操作数据库,而是调用InventoryDAO.decreaseStock()和OrderDAO.createOrder(),并在 try-catch 中显式 commit/rollback。重点在于:库存扣减与订单创建必须在同一数据库连接下完成,否则无法保证原子性。因此,Connection 不由 DAO 自己 new,而是由 Service 通过ConnectionPool.getConnection()获取,并作为参数传入各 DAO 方法:

public boolean placeOrder(String userId, String bookId, int quantity) { Connection conn = null; try { conn = ConnectionPool.getConnection(); // 从自定义连接池取 conn.setAutoCommit(false); // 关闭自动提交 // 1. 扣库存(SELECT ... FOR UPDATE 锁行) boolean stockOk = inventoryDAO.decreaseStock(conn, bookId, quantity); if (!stockOk) { throw new RuntimeException("库存不足"); } // 2. 生订单(订单号含 submitTime 前缀,实现幂等) String orderNo = "ORD" + request.getAttribute("submitTime") + "-" + userId; orderDAO.createOrder(conn, orderNo, userId, bookId, quantity); conn.commit(); return true; } catch (Exception e) { if (conn != null) { try { conn.rollback(); } catch (SQLException ignored) {} } log.error("下单失败", e); return false; } finally { if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } } }

这里的关键设计点有三个:第一,decreaseStock()内部执行SELECT * FROM book_stock WHERE book_id=? FOR UPDATE,确保同一本书的多次扣减请求串行化;第二,订单号拼接submitTime,使相同请求(因网络重试)生成相同订单号,后续支付回调可通过SELECT COUNT(*) FROM orders WHERE order_no=?判断是否已存在,避免重复下单;第三,ConnectionPool是轻量级实现(非 DBCP/C3P0),仅维护固定大小的连接 List,配合 wait/notify 做简单阻塞获取——这正是课设场景下最可控、最易讲清原理的方案。

2.3 数据访问层:DAO 模式 + 手动 SQL + 连接池的可控性优势

DAO 层不使用 Hibernate 或 MyBatis,全部手写 JDBC。不是为了“炫技”,而是因为课设答辩时,老师问“你怎么保证库存不超卖”,你答“我用了 @Select(‘SELECT … FOR UPDATE’)”,不如答“我在 decreaseStock 方法里写了 PreparedStatement,执行了 SELECT … FOR UPDATE,然后 getLong(‘stock’) 判断,再 executeUpdate(‘UPDATE … SET stock=stock-? WHERE …’)”。前者像背答案,后者是真懂。DAO 接口定义清晰,如InventoryDAO只暴露两个方法:

public interface InventoryDAO { /** * 扣减指定图书库存,使用 SELECT ... FOR UPDATE 加行锁 * @param conn 数据库连接(由 Service 传入,保证事务一致性) * @param bookId 图书ID * @param quantity 扣减数量 * @return true表示扣减成功,false表示库存不足 */ boolean decreaseStock(Connection conn, String bookId, int quantity) throws SQLException; /** * 查询当前库存余量(用于前端展示,不加锁) */ long getStock(String bookId) throws SQLException; }

实现类InventoryDAOImpl中,decreaseStock()的核心 SQL 是:

SELECT stock FROM book_stock WHERE book_id = ? FOR UPDATE

注意两点:第一,FOR UPDATE必须在SELECT后立即执行UPDATE,中间不能有其他 DB 操作,否则锁可能释放;第二,book_stock表必须有主键(book_id),否则 MySQL 会锁整张表。这些细节,恰恰是学生最容易在答辩中被追问的“为什么这么写”,而本方案全部给出可验证的答案。

3. 云工厂部署实现:Docker Compose 编排下的 J2EE 三件套

所谓“云工厂”,不是指上公有云,而是指用容器技术把 J2EE 应用的运行环境标准化、可复现。本方案不依赖 Kubernetes,用 Docker Compose 即可完成三节点编排:Tomcat 9 容器(运行 WAR 包)、MySQL 8.0 容器(持久化数据)、Nginx 1.22 容器(静态资源代理)。所有配置文件、初始化 SQL、构建脚本全部纳入项目目录,执行docker-compose up -d即可一键启动,无需手动配数据源、改 context.xml。

3.1 Tomcat 容器:war 包注入与 JNDI 数据源配置

Tomcat 容器镜像基于官方tomcat:9-jre11,关键改造在Dockerfile中:

FROM tomcat:9-jre11 # 复制自定义 server.xml(启用 AJP,为后续可能的负载均衡留接口) COPY conf/server.xml $CATALINA_HOME/conf/ # 复制 context.xml,声明 JNDI 数据源 COPY conf/context.xml $CATALINA_HOME/conf/ # 复制应用 WAR 包 COPY target/cloud-bookstore.war $CATALINA_HOME/webapps/ # 复制 MySQL 驱动(JNDI 需要) COPY lib/mysql-connector-java-8.0.33.jar $CATALINA_HOME/lib/

其中conf/context.xml是 JNDI 的核心,它告诉 Tomcat:“我需要一个叫jdbc/BookStoreDB的数据源,连接 MySQL 容器”:

<?xml version="1.0" encoding="UTF-8"?> <Context> <Resource name="jdbc/BookStoreDB" auth="Container" type="javax.sql.DataSource" factory="org.apache.tomcat.dbcp.dbcp2.BasicDataSourceFactory" maxTotal="20" maxIdle="10" minIdle="5" initialSize="5" maxWaitMillis="10000" testOnBorrow="true" validationQuery="SELECT 1" username="bookuser" password="bookpass" driverClassName="com.mysql.cj.jdbc.Driver" url="jdbc:mysql://mysql:3306/bookstore?useSSL=false&amp;serverTimezone=UTC&amp;allowPublicKeyRetrieval=true"/> </Context>

注意url中的mysql是 Docker 内部服务名,不是 localhost——这是容器网络的关键。Tomcat 容器启动时,会自动读取此配置,将数据源绑定到 JNDI 上下文。业务代码中即可通过new InitialContext().lookup("java:comp/env/jdbc/BookStoreDB")获取连接,彻底解耦数据库地址。

3.2 MySQL 容器:初始化脚本与字符集预设

MySQL 容器使用mysql:8.0镜像,通过docker-compose.yml挂载初始化 SQL:

mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: rootpass MYSQL_DATABASE: bookstore MYSQL_USER: bookuser MYSQL_PASSWORD: bookpass volumes: - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci

init.sql包含建库、建表、插初始数据三步,其中book_stock表结构特意设计为主键 + 唯一索引 + 显式 ENGINE=InnoDB:

CREATE TABLE `book_stock` ( `book_id` VARCHAR(32) NOT NULL PRIMARY KEY, `stock` BIGINT NOT NULL DEFAULT 0, `updated_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY `uk_book_id` (`book_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

为什么强调ENGINE=InnoDB?因为只有 InnoDB 支持SELECT ... FOR UPDATE行锁。如果误用 MyISAM,锁表不锁行,高并发下性能断崖下跌——这是学生部署时最常翻车的点,也是本方案必须写死的配置。

3.3 Nginx 容器:静态资源分离与支付回调路径重写

Nginx 容器不代理整个应用,只处理两类请求:

  1. /static/**路径:直接返回./src/main/webapp/static/下的文件,减轻 Tomcat 压力;
  2. /pay/callback/**路径:重写为/callback,交由 Tomcat 的PayCallbackServlet统一处理。

nginx.conf片段如下:

server { listen 80; location /static/ { alias /usr/share/nginx/html/static/; expires 1h; } location /pay/callback/ { proxy_pass http://tomcat:8080/callback; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } location / { proxy_pass http://tomcat:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }

这里的关键是proxy_pass的路径拼接规则:/pay/callback/末尾有/,http://tomcat:8080/callback末尾无/,Nginx 会自动把/pay/callback/xxx重写为/callback/xxx。这样,外部支付平台回调https://yourdomain.com/pay/callback/alipay?out_trade_no=ORD123,最终到达 Tomcat 的是/callback/alipay?out_trade_no=ORD123,Servlet 可通过request.getServletPath()获取alipay字符串,动态选择支付宝或微信的验签逻辑。这种设计,比硬编码回调地址更健壮,也更符合“云工厂”的可配置理念。

4. 关键模块实现:库存扣减、订单幂等、支付回调的三重防线

本方案的可靠性不靠“加机器”,而靠代码层的三重防线:库存扣减用数据库行锁保数据一致,订单生成用时间戳+唯一约束保幂等,支付回调用状态机+二次查询保终态。这三者环环相扣,缺一不可。

4.1 库存扣减:SELECT ... FOR UPDATE 的正确打开方式

库存扣减不是简单的UPDATE book_stock SET stock=stock-1 WHERE book_id=? AND stock>=1,因为该语句在高并发下仍可能超卖。正确做法是:先查再判后更,且查时加锁。InventoryDAOImpl.decreaseStock()实现如下:

@Override public boolean decreaseStock(Connection conn, String bookId, int quantity) throws SQLException { String selectSql = "SELECT stock FROM book_stock WHERE book_id = ? FOR UPDATE"; try (PreparedStatement ps = conn.prepareStatement(selectSql)) { ps.setString(1, bookId); try (ResultSet rs = ps.executeQuery()) { if (!rs.next()) { return false; // 图书不存在 } long currentStock = rs.getLong("stock"); if (currentStock < quantity) { return false; // 库存不足 } // 执行扣减(此时仍持有行锁) String updateSql = "UPDATE book_stock SET stock = stock - ?, updated_at = NOW() WHERE book_id = ?"; try (PreparedStatement updatePs = conn.prepareStatement(updateSql)) { updatePs.setInt(1, quantity); updatePs.setString(2, bookId); int affected = updatePs.executeUpdate(); return affected == 1; } } } }

这段代码的精妙之处在于:SELECT ... FOR UPDATE与UPDATE在同一个Connection、同一个事务中执行,MySQL 会保证该行记录在UPDATE完成前不被其他事务修改。即使两个请求同时查到stock=10,也只有一个能成功执行UPDATE,另一个会被阻塞直到第一个事务提交或回滚。这就是“悲观锁”的本质——不是靠代码判断,而是靠数据库引擎保证。

4.2 订单幂等:时间戳前缀 + 数据库唯一约束的双重保险

幂等的核心是“相同输入产生相同输出”。本方案订单号生成规则为ORD + submitTime + "-" + userId,其中submitTime来自 Filter 注入,精度到毫秒。但仅靠格式还不够,必须加数据库约束。orders表结构如下:

CREATE TABLE `orders` ( `id` BIGINT AUTO_INCREMENT PRIMARY KEY, `order_no` VARCHAR(64) NOT NULL UNIQUE, -- 强制唯一,冲突即幂等失败 `user_id` VARCHAR(32) NOT NULL, `book_id` VARCHAR(32) NOT NULL, `quantity` INT NOT NULL, `status` ENUM('created', 'paid', 'shipped', 'closed') DEFAULT 'created', `created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX `idx_user_created` (`user_id`, `created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

当OrderDAO.createOrder()执行INSERT INTO orders (...) VALUES (...)时,若order_no已存在,MySQL 抛Duplicate entry 'ORD1712345678900-usr123' for key 'orders.order_no',Service 层捕获此异常,即可判定“此订单已存在”,直接返回成功,无需重试。这种设计比“先 SELECT 再 INSERT”更高效,也避免了 SELECT 和 INSERT 之间的竞态窗口。

4.3 支付回调:状态机驱动的终态确认与防重机制

支付回调不是“收到就改状态”,而是“收到→验签→查单→判状态→改状态→发通知”的状态机。PayCallbackServlet的核心逻辑是:

protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String channel = req.getServletPath().replace("/callback/", ""); // alipay or wechat Map<String, String> params = parseParams(req); // 解析 POST 参数 // 1. 验签(调用 channel-specific 验签工具类) if (!PayValidator.validate(channel, params)) { resp.getWriter().print("fail"); // 支付平台要求返回 fail return; } String outTradeNo = params.get("out_trade_no"); // 即订单号 // 2. 查询订单(必须查库,不能信回调参数) Order order = orderDAO.findByOrderNo(outTradeNo); if (order == null) { resp.getWriter().print("fail"); return; } // 3. 状态机:只允许 created → paid,其他状态忽略 if ("created".equals(order.getStatus())) { orderDAO.updateStatus(outTradeNo, "paid"); // 4. 发货通知(此处可集成邮件或短信) notifyShipment(order); } resp.getWriter().print("success"); }

这里的关键是:绝不信任回调参数中的金额、状态,只认自己的数据库记录。即使黑客伪造回调,只要out_trade_no在库里不存在或状态不是created,就不会更新。而updateStatus()方法内部执行的是UPDATE orders SET status='paid' WHERE order_no=? AND status='created',利用 MySQL 的AND status='created'条件,确保只有初始状态才能被更新——这是状态机的数据库级实现,比代码 if 判断更可靠。

5. 避坑指南:J2EE 课设中最容易翻车的 4 个点及血泪解法

J2EE 项目看似简单,但学生在实现时极易在几个关键点上栽跟头,导致功能看似正常,一压测就崩,一答辩就被问住。以下是本方案实测中踩过的坑,按现象、原因、解法三段式整理,每一条都对应真实调试日志。

5.1 现象:本地运行正常,Docker 启动后登录 404;原因:context.xml 未生效或 WAR 包未自动解压;解法:强制指定 context path 并验证容器内文件

现象:浏览器访问http://localhost:8080/login.jsp返回 404,但docker exec -it tomcat ls /usr/local/tomcat/webapps/确实能看到cloud-bookstore.war和解压后的cloud-bookstore/目录。
原因:Tomcat 默认将 WAR 包名作为 context path,cloud-bookstore.war对应路径是/cloud-bookstore,而非根路径/。学生常误以为localhost:8080就是应用入口。
解法:在docker-compose.yml的 tomcat 服务中添加environment,强制设置 context path:

tomcat: # ... 其他配置 environment: - CATALINA_OPTS=-Dcatalina.base=/usr/local/tomcat -Dcatalina.home=/usr/local/tomcat -Djava.io.tmpdir=/usr/local/tomcat/temp # 关键:通过 server.xml 的 Host 配置默认应用

更稳妥的做法是在conf/server.xml的<Host>标签下添加:

<Context path="" docBase="cloud-bookstore" debug="0" reloadable="true" />

path=""表示根路径,docBase指向解压后的目录名。改完后重启容器,docker exec -it tomcat cat /usr/local/tomcat/conf/server.xml | grep Context确认配置已生效。

5.2 现象:库存显示正确,但并发下单仍超卖;原因:SELECT ... FOR UPDATE 未在事务内执行或表引擎错误;解法:抓包验证 SQL 执行顺序并检查 ENGINE

现象:JMeter 设置 100 线程循环下单,预期库存从 100 减到 0,结果数据库 stock 字段变为 -5。
原因:decreaseStock()方法中,SELECT ... FOR UPDATE和UPDATE不在同一个Connection下执行;或book_stock表 ENGINE 被误设为 MyISAM(不支持行锁)。
解法:第一步,在decreaseStock()开头加日志log.info("Before SELECT FOR UPDATE, conn hash: {}", conn.hashCode());,在UPDATE前再打一次,确认 hash 值相同;第二步,执行docker exec -it mysql mysql -u bookuser -pbookpass -e "SELECT ENGINE FROM information_schema.TABLES WHERE TABLE_SCHEMA='bookstore' AND TABLE_NAME='book_stock';",必须返回InnoDB。若为 MyISAM,立刻执行ALTER TABLE book_stock ENGINE=InnoDB;。

5.3 现象:支付回调返回 success,但订单状态始终是 created;原因:Nginx 重写规则错误或 Tomcat 未监听 callback 路径;解法:逐层 curl 验证链路

现象:支付宝沙箱回调https://yourdomain.com/pay/callback/alipay?out_trade_no=ORDxxx,Nginx 日志显示 200,但 Tomcat access log 无记录,订单状态未变。
原因:Nginxproxy_pass路径拼接错误,或 Tomcat 的web.xml未配置PayCallbackServlet映射到/callback/*。
解法:分三步验证:

  1. curl -v http://localhost/pay/callback/alipay(宿主机),看 Nginx 是否返回 200 且响应体为 "fail"(说明 Nginx 到 Tomcat 链路通);
  2. curl -v http://localhost:8080/callback/alipay(绕过 Nginx),看 Tomcat 是否返回 "fail"(说明 Servlet 部署正确);
  3. docker exec -it nginx cat /var/log/nginx/access.log,确认回调请求确实打到了 Nginx。
    若第 1 步失败,检查nginx.conf中location /pay/callback/的斜杠;若第 2 步失败,检查web.xml中<servlet-mapping>的<url-pattern>是否为/callback/*。

5.4 现象:JSP 页面中文乱码,URL 参数含中文时 request.getParameter() 为空;原因:Tomcat URIEncoding 未设或 JSP pageEncoding 错误;解法:全链路统一 UTF-8

现象:<form action="/book/search" method="get"><input name="keyword" value="Java编程"/></form>,Servlet 中request.getParameter("keyword")返回Java编程。
原因:Tomcat 默认 ISO-8859-1 解码 GET 请求,而浏览器用 UTF-8 编码。
解法:三处强制 UTF-8:

  1. conf/server.xml的Connector标签加URIEncoding="UTF-8":
    <Connector port="8080" protocol="HTTP/1.1" URIEncoding="UTF-8" ... />
  2. JSP 顶部声明:<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %>;
  3. web.xml中配置CharacterEncodingFilter,强制 POST 请求编码:
    <filter> <filter-name>encodingFilter</filter-name> <filter-class>org.apache.catalina.filters.SetCharacterEncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> </filter> <filter-mapping> <filter-name>encodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>

改完重启 Tomcat,乱码必解。

6. 答辩与验收:用三张表、两次演示、一个日志,让老师当场点头

答辩不是讲 PPT,而是证明“这东西真能跑、真能抗、真能修”。我带过三届学生做类似课设,发现老师最关心的永远是三件事:功能是否覆盖需求文档、性能是否满足基本场景、问题是否可快速定位。所以我的验收策略就围绕这三点展开,不炫技,只务实。

6.1 功能验收表:对照需求逐条打钩,拒绝模糊描述

把原始需求(哪怕只是老师口头说的)拆成可验证条目,做成表格。本方案的需求来自“网上书城”通用场景,我们定义 8 项核心功能,验收时不靠“我写了”,而靠“你现场点”。表格打印出来,答辩时放在老师面前,每验证一项,当场打钩:

序号功能点验收方式是否通过备注
1用户登录/登出输入正确账号密码 → 跳转首页;点击退出 → session 失效,再访问首页跳登录页☑
2图书列表分页展示URL/book/list?page=2&size=10→ 返回第 2 页 10 条,含总页数☑
3购物车增删改查添加商品 → 刷新页面购物车数量+1;删除 → 数量-1;清空 → 数量归零☑
4库存实时显示与扣减商品页显示“库存:5”,下单 3 本 → 再进页面显示“库存:2”☑
5订单生成与唯一性同一用户连续点击“立即购买”3 次 → 数据库 orders 表只新增 1 条记录☑用 phpMyAdmin 或 Navicat 查
6支付回调状态更新手动 curlhttp://localhost/callback/alipay?out_trade_no=ORDxxx→ 订单 status 变为 paid☑
7并发下单不超卖JMeter 50 线程同时买同一本书(库存=1)→ 数据库 stock 最终为 0,orders 表 1 条☑
8中文搜索与显示搜索框输“Java” → 返回含 Java 的书;书名含中文 → 页面正常显示☑

这张表的价值在于:它把“功能完整”从主观评价变成客观事实。老师不会因为你说了“我实现了幂等”,就相信你;但他看到你现场 curl 回调、刷出数据库记录变化,就会点头。而且,表格里每一项的“验收方式”都具体到 URL、参数、操作步骤,杜绝“大概可以”“应该没问题”这类答辩黑洞。

6.2 性能演示:用 JMeter 做两次对比,突出架构设计价值

性能不是跑分,而是证明“为什么这么设计”。我让学生准备两个 WAR 包:一个是本方案(带 ConnectionPool + FOR UPDATE),另一个是简化版(每次 new Connection + UPDATE without lock)。用 JMeter 做同样测试:100 线程循环下单 10 次,目标图书库存=100。

指标简化版(无锁无池)本方案(行锁+连接池)提升说明
平均响应时间1280 ms320 ms连接复用减少 TCP 握手开销
错误率38%(超卖报错)0%行锁保证库存判断与更新原子性
最终库存-120数据一致性得到保障

演示时,不念数字,而是打开 JMeter 的“View Results Tree”,点开一个失败请求,展示响应体里的{"error":"库存不足"};再点开一个成功请求,展示{"success":true,"orderNo":"ORDxxx"}。然后切到 MySQL 客户端,SELECT stock FROM book_stock WHERE book_id='JAVA001';,显示0。老师立刻明白:你的设计不是纸上谈兵,它解决了真实痛点。

6.3 故障排查演示:用日志定位一个典型问题,展现工程素养

答辩最后 3 分钟,主动抛出一个问题:“如果支付回调后订单状态没变,你会怎么查?”——这不是考倒你,而是看你有没有生产意识。我的标准动作是:

  1. 看 Nginx access log:docker exec -it nginx tail -n 20 /var/log/nginx/access.log,确认请求是否到达 Nginx;
  2. 看 Tomcat access log:docker exec -it tomcat tail -n 20 /usr/local/tomcat/logs/access_log.*,确认是否转发到 Tomcat;
  3. 看 Tomcat catalina.out:docker exec -it tomcat tail -n 20 /usr/local/tomcat/logs/catalina.out,找PayCallbackServlet的日志,看是否抛异常;
  4. 查数据库:docker exec -it mysql mysql -u bookuser -pbookpass -e "SELECT * FROM orders WHERE order_no='ORDxxx';",确认记录是否存在、status 字段值。

我把这个流程写成一张速查表,贴在项目 README.md 里。答辩时,老师问“怎么排查”,我就打开终端,现场执行这四条命令,边敲边说:“第一步看 Nginx,如果没记录,问题在 DNS 或防火墙;如果有记录但 Tomcat 没,就是 proxy_pass 配错了……” —— 这比讲一百遍“我用了微服务”更有说服力。

从那以后我每次带学生做课设,都强制他们在src/main/resources/logback.xml里配置好RollingFileAppender,每个模块(web/filter/service/dao)打独立日志文件,日志级别设为 DEBUG。不是为了好看,而是因为——当老师问“你确定库存扣减执行了 SELECT FOR UPDATE 吗”,你打开dao.log,找到那行Executing SQL: SELECT stock FROM book_stock WHERE book_id = ? FOR UPDATE,就是最好的回答。希望帮到你。

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

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

LCEL管道式组合:LangChain大模型应用搭建与调试实战

很多人在学习LangChain时&#xff0c;第一个卡住的地方就是LCEL&#xff08;LangChain Expression Language&#xff09;。恰好我也在这个坑里爬过一阵子&#xff0c;今天把学习笔记整理出来&#xff0c;聊聊LCEL是什么、为什么要用它、怎么用它把大模型应用拼装起来&#xff0…

作者头像 李华
网站建设 2026/10/17 4:53:06

MySQL基本应用与性能调优:从建表、SQL优化到索引实战

1. MySQL到底是什么&#xff0c;为什么大家都在用它先别急着敲命令&#xff0c;把概念搞清楚比什么都重要。MySQL本质是一个关系型数据库管理系统&#xff0c;它帮你把数据按照“表”的形式组织起来&#xff0c;一张表里有行有列&#xff0c;行是一条记录&#xff0c;列是一个字…

作者头像 李华
网站建设 2026/10/17 4:51:52

基于MP2642GR-Z与STM32L162ZE的两串锂电池被动均衡方案设计与实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 4:51:48

基于MP2642GR-Z与PIC18F86J10的储能电池主动均衡方案设计与实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/17 4:51:25

信创环境下AI模型迁移与性能调优实战指南

这两年只要聊国产化&#xff0c;绕不开的一句话就是“AI信创双轮驱动”。对外怎么讲都行&#xff0c;对内落到机房和代码里&#xff0c;这件事远没有听起来那么宏大&#xff0c;它意味着把模型推理从一套已经用惯的软硬件环境&#xff0c;搬到另外一套必须自己重新验证的国产底…

作者头像 李华
网站建设 2026/10/17 4:50:48

UE5插件预编译实战:从源码分发到二进制包的完整指南

第一次被UE5插件预编译搞得满头是包&#xff0c;是项目组要给美术同事分发内部工具插件的时候。当时我把自研的批量资产检查工具按照源码插件的形式扔到共享盘&#xff0c;美术同事一拉下来&#xff0c;打开工程就是一段漫长的编译进度条&#xff0c;卡了十几分钟才进编辑器&am…

作者头像 李华