ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

JavaEE网上商城实战:分层架构、下单事务与防超卖避坑指南

JavaEE网上商城实战:分层架构、下单事务与防超卖避坑指南 简介这份资源是面向计算机专业学生与Java Web开发初学者的毕业设计配套文档围绕基于JavaEE的网上商城管理系统展开适合需要完成课程设计、毕业设计或希望系统学习B/S架构电商项目开发的人群。压缩包内共1个doc文件整体约2.31MB文档完整呈现了从选题背景、需求分析到系统实现与测试的论文全流程涵盖前台用户注册登录、订单管理、售后维权、通知公告以及后台商品管理、客户管理、轮播管理与统计中心等模块的设计思路。目前已有1674人学习下载说明该选题在毕业设计场景中具有较高的参考价值。读者可借助文档中的系统架构说明、数据库表设计、模块划分与测试验证内容快速理解JavaEE电商系统的整体开发脉络为撰写论文或搭建类似项目提供可借鉴的框架与思路。1. 从一份课程设计到能跑通的 JavaEE 网上商城这套系统到底解决什么问题很多同学第一次接触 JavaEE 网上商城管理系统场景都差不多课程设计要交、毕设要开题、简历上想写一个「电商项目」。真正动手才发现难点从来不是「写一个商品列表」而是把用户、商品、订单、库存、支付状态这几条线串成一条能自洽的业务链。JavaEE 这套技术栈Servlet、JSP、JDBC、Spring、MyBatis之所以在商城类项目里被反复使用是因为它天然适合分层表现层管页面跳转业务层管下单扣库存持久层管数据落库每一层职责清晰出了问题能定位。这篇文章面向三类人正在做课程设计、需要一套能讲清楚也能跑起来方案的学生想用商城项目补齐 CRUD 之外业务经验的初级开发者以及用 VS Code 配置 JavaEE 语言环境、不想被 IDE 绑死的人。我会按「先讲清分层和数据模型 → 再落地环境与核心代码 → 最后讲避坑和进阶」的顺序展开中间给到能直接抄的建表语句、Servlet 和 Service 代码参数怎么设、报错怎么看都写明白。整套方案不依赖任何特定 IDEVS Code 加 Maven 就能跑。2. 分层架构与数据模型商城系统的地基怎么打2.1 为什么商城项目必须分层不分层会怎样先说不分层的后果。我见过太多把 JDBC 代码直接写进 JSP 的商城项目页面里% Class.forName(com.mysql.cj.jdbc.Driver) %一大段商品查询、下单、扣库存全糊在一个文件里。这种写法在演示时能跑但只要改一个字段名就要在十几个 JSP 里全局搜索替换改漏一处就是 500 错误。这就是典型的「能跑但没法维护」。JavaEE 的标准分层是四层表现层Servlet / Controller、业务层Service、持久层DAO / Mapper、实体层Entity。分层的核心价值是依赖方向单一——表现层调业务层业务层调持久层谁都不反向依赖。这样下单逻辑只写在OrderService里页面只管收参数和转发库存扣减规则改一次就够。具体到商城各层职责这样划分层典型类职责不该做的事表现层ProductServlet收参、校验、转发不写 SQL、不算业务业务层OrderService下单、扣库存、事务不碰 HttpServletRequest持久层ProductDao增删改查不做业务判断实体层Product、Order承载数据不含逻辑提示分层不是越多越好。课程设计规模下四层足够再拆 DTO、VO 只会增加理解成本答辩时反而讲不清。2.2 商城核心表结构五张表撑起整条业务链数据模型是商城的地基表设计错了后面代码怎么写都别扭。最小可用的商城需要五张表用户表、商品表、分类表、订单表、订单明细表。订单和订单明细必须拆开这是新手最容易合并错的地方——一个订单有多个商品合并成一张表就没法表达「一次买三件不同商品」。下面是可直接执行的建表语句MySQL 8 环境-- 用户表存登录信息密码必须存哈希而非明文 CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE COMMENT 登录名唯一, password VARCHAR(100) NOT NULL COMMENT 存 BCrypt 哈希不存明文, phone VARCHAR(20) COMMENT 收货联系电话, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 商品表price 用 DECIMAL绝不用 FLOAT CREATE TABLE product ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL COMMENT 金额必须 DECIMAL, stock INT NOT NULL DEFAULT 0 COMMENT 库存扣减要防超卖, category_id BIGINT COMMENT 所属分类, status TINYINT DEFAULT 1 COMMENT 1上架 0下架 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单表只存订单头不存具体商品 CREATE TABLE orders ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0 COMMENT 0待付款 1已付款 2已发货 3完成, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 订单明细表一个订单对应多行记录每件商品的下单快照 CREATE TABLE order_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, order_id BIGINT NOT NULL, product_id BIGINT NOT NULL, price DECIMAL(10,2) NOT NULL COMMENT 下单时价格快照, quantity INT NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;几个参数必须解释清楚。price用DECIMAL(10,2)而不是FLOAT因为浮点数在金额累加时会出现0.1 0.2 0.30000000000000004这种误差订单总额对不上账就是从这里来的。order_item里冗余存了一份price这是有意为之商品价格会变但历史订单必须保留下单那一刻的价格否则用户三个月后翻订单会发现金额和当初不一致。stock字段后面扣减时要用带条件的 UPDATE不能先查再改这是防超卖的关键第 4 章会展开。3. 用 VS Code 配好 JavaEE 环境并跑通第一个 Servlet3.1 VS Code 配置 JavaEE 语言环境的最小步骤很多人默认 JavaEE 就得用 Eclipse 或 IDEA其实 VS Code 配好了完全够用而且启动快。核心是装对扩展、配好 JDK 和 Maven。我一般按这个顺序来第一步装扩展。必装的是Extension Pack for Java微软官方含语言支持、调试、Maven、测试再补一个Community Server Connectors用来跑 Tomcat。装完重启 VS Code。第二步确认 JDK。JavaEE 项目建议用 JDK 8 或 11别一上来就 17很多老教程里的javax.servlet包在 JDK 17 加 Tomcat 10 之后会变成jakarta.servlet包名一变代码全红。用命令确认java -version # 期望输出类似openjdk version 11.0.20 mvn -v # 确认 Maven 已装且指向同一个 JDK第三步在 VS Code 里按CtrlShiftP输入Java: Configure Java Runtime把 JDK 路径指到上面那个版本。再打开设置搜索java.configuration.runtimes显式写死运行时避免 VS Code 自动挑到别的 JDK{ java.configuration.runtimes: [ { name: JavaSE-11, path: /usr/lib/jvm/java-11-openjdk, default: true } ] }path要换成你机器上的真实路径Windows 下形如C:\\Program Files\\Java\\jdk-11。这一步不做后面 Maven 编译和 VS Code 语言服务可能用两个不同 JDK出现「命令行能编译、编辑器报红」的玄学问题。3.2 Maven 依赖与 web.xml让项目能被 Tomcat 识别JavaEE 项目要跑在 Tomcat 上打包方式必须是war而不是jar。pom.xml里几个关键依赖和插件这样配project modelVersion4.0.0/modelVersion groupIdcom.example/groupId artifactIdmall/artifactId version1.0/version packagingwar/packaging !-- 必须是 warTomcat 才认 -- properties maven.compiler.source11/maven.compiler.source maven.compiler.target11/maven.compiler.target /properties dependencies !-- Servlet APIscope 必须是 providedTomcat 自带打进去会冲突 -- dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency !-- MySQL 驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency !-- JSTLJSP 页面循环遍历用 -- dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency /dependencies /projectservlet-api的scope写成provided是血泪经验。如果写成默认的compile这个 jar 会被打进 war 包部署到 Tomcat 时和容器自带的 Servlet API 冲突报ClassCastException或者类加载顺序错乱页面直接 404排查半天找不到原因。web.xml在src/main/webapp/WEB-INF/下Servlet 3.0 之后其实可以用注解WebServlet替代但保留一份web.xml设欢迎页更稳妥web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee version4.0 welcome-file-list welcome-fileindex.jsp/welcome-file /welcome-file-list /web-app3.3 第一个能返回商品列表的 Servlet环境配好后写一个最小可验证的 Servlet确认整条链路通了再往上堆业务。下面这个ProductServlet查商品列表并转发到 JSPWebServlet(/products) // 访问路径 /products public class ProductServlet extends HttpServlet { private final ProductDao productDao new ProductDao(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 1. 调持久层拿数据Servlet 本身不写 SQL ListProduct list productDao.findAllOnSale(); // 2. 放进 request 域供 JSP 读取 req.setAttribute(products, list); // 3. 转发到页面注意是 forward 不是 redirect req.getRequestDispatcher(/product_list.jsp).forward(req, resp); } }逻辑说明doGet只做三件事——取数据、塞域、转发。forward是服务器内部跳转浏览器地址栏不变request 域里的数据能带到 JSP如果写成sendRedirectrequest 域会丢页面拿不到products这是新手最常见的「页面空白」原因。参数上WebServlet(/products)决定了访问地址是http://localhost:8080/mall/products其中mall是 war 包名。对应的product_list.jsp用 JSTL 遍历% taglib prefixc urihttp://java.sun.com/jsp/jstl/core % table c:forEach items${products} varp tr td${p.name}/td td${p.price}/td td${p.stock}/td /tr /c:forEach /table跑起来的方式VS Code 里右键项目选Run on Server或者命令行mvn clean package后把target/mall.war丢进 Tomcat 的webapps目录。访问/products能看到商品表格说明环境、依赖、转发链路全通了可以进入下一章写业务。4. 下单与库存扣减商城最容易翻车的一段代码4.1 下单流程为什么要放在一个事务里下单不是「插一条订单记录」这么简单它至少包含四步校验库存、写订单头、写订单明细、扣减库存。这四步必须要么全成功要么全回滚。假设扣库存成功了但写订单明细失败用户钱没付、库存却少了这就是数据不一致。事务就是解决这个的。在 JavaEE 里事务边界应该放在 Service 层而不是 DAO 层。因为一个业务操作可能调多个 DAO事务要覆盖它们全部。用 JDBC 手动控制事务的写法public class OrderService { public void createOrder(Long userId, ListCartItem items) throws SQLException { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 关闭自动提交开启事务 BigDecimal total BigDecimal.ZERO; for (CartItem item : items) { // 扣库存带 stock ? 条件返回影响行数 int rows productDao.reduceStock(conn, item.getProductId(), item.getQuantity()); if (rows 0) { throw new RuntimeException(库存不足 item.getProductId()); } total total.add(item.getPrice().multiply(new BigDecimal(item.getQuantity()))); } Long orderId orderDao.insertOrder(conn, userId, total); orderItemDao.batchInsert(conn, orderId, items); conn.commit(); // 全部成功才提交 } catch (Exception e) { if (conn ! null) conn.rollback(); // 任何一步失败全回滚 throw e; } finally { if (conn ! null) conn.setAutoCommit(true); DBUtil.close(conn); } } }关键点所有 DAO 方法都接收同一个conn参数保证它们在同一事务里。如果每个 DAO 自己getConnection()那就是四个独立事务回滚管不到彼此事务就白开了。4.2 防超卖为什么不能先查库存再扣减这是商城项目最经典的坑。很多人这样写// 错误示范先查后改并发下必然超卖 int stock productDao.getStock(productId); if (stock quantity) { productDao.setStock(productId, stock - quantity); }单线程跑没问题但两个用户同时下单同一件只剩 1 件的商品时两人都查到stock 1都判断通过都执行扣减最后库存变成 -1卖出了 2 件。这就是超卖。正确做法是把判断和扣减合并成一条原子 SQL靠数据库的行锁保证UPDATE product SET stock stock - #{quantity} WHERE id #{productId} AND stock #{quantity}然后在 Java 里判断影响行数int rows productDao.reduceStock(conn, productId, quantity); if (rows 0) { throw new RuntimeException(库存不足); }WHERE stock #{quantity}是灵魂。数据库执行 UPDATE 时会对这一行加排他锁第二个请求必须等第一个提交后才能执行此时stock已经变小条件不满足影响行数为 0业务层抛异常回滚。这样并发再高也不会超卖。参数上quantity必须做正整数校验防止有人传负数把库存「加」回去。4.3 订单状态流转与金额校验订单状态不能随便改要有明确的流转规则0 待付款 → 1 已付款 → 2 已发货 → 3 已完成另外 4 已取消。状态只能单向推进不能从「已完成」退回「待付款」。实现时在 Service 里加判断public void pay(Long orderId) { Order order orderDao.findById(orderId); if (order.getStatus() ! 0) { throw new IllegalStateException(订单状态不允许支付); } orderDao.updateStatus(orderId, 1); }金额校验同样重要。订单总额不能信前端传来的值必须后端根据订单明细重新计算否则用户改一下请求参数就能一块钱买手机。做法是在createOrder里用BigDecimal累加每件商品的price * quantity和前端传的总额比对不一致就拒绝。注意BigDecimal的相等判断要用compareTo而不是equals。new BigDecimal(1.0).equals(new BigDecimal(1.00))返回 false因为精度不同用compareTo才返回 0。这个坑在金额比对时特别隐蔽。5. 避坑与排查JavaEE 商城项目最常见的五类翻车5.1 中文乱码POST 和 GET 处理方式不一样现象表单提交的商品名在数据库里变成????或者页面显示乱码。原因Tomcat 默认用 ISO-8859-1 解码请求体中文直接损坏。POST 和 GET 的乱码来源还不同——POST 是请求体编码GET 在旧版 Tomcat 里是 URL 参数编码。解决POST 在读取参数前设置req.setCharacterEncoding(UTF-8)响应统一设resp.setContentType(text/html;charsetUTF-8)。GET 乱码则要在 Tomcat 的server.xml里给 Connector 加URIEncodingUTF-8。数据库连接串也要带characterEncodingutf8三层编码一致才不出乱码。5.2 数据库连接泄漏连接池耗尽后整个系统卡死现象系统跑一段时间后所有请求都超时重启就好过一会又卡。原因DAO 里getConnection()之后没在finally里close()或者查询抛异常时跳过了关闭。连接被占满新请求拿不到连接就阻塞。解决所有连接释放必须放finally或者用 try-with-resources。生产环境别用DriverManager直连配 Druid 或 HikariCP 连接池池本身有泄漏检测。排查时看数据库的SHOW PROCESSLIST如果一堆 Sleep 状态的连接基本就是泄漏。5.3 事务不生效Service 加了注解但回滚没发生现象下单失败后订单没写进去但库存被扣了。原因常见有三种。一是异常被catch后没重新抛出事务管理器感知不到异常二是抛的是受检异常而默认只回滚RuntimeException三是同类内部方法直接调用代理失效。解决catch 后要么throw new RuntimeException(e)要么显式声明回滚异常。用 Spring 的话Transactional(rollbackFor Exception.class)覆盖受检异常。内部调用问题则要把方法拆到另一个 Bean或者注入自身代理。5.4 JSP 页面拿不到 request 域数据现象Servlet 里setAttribute了JSP 里${products}却是空。原因多半是用了sendRedirect而不是forward重定向是两次请求request 域不跨请求。也可能是 EL 表达式没开启或者 JSTL 的 taglib 引入路径写错。解决需要带数据就用forward确实要重定向就用 session 或 URL 参数传。检查 JSP 顶部 taglib 的 uri 是否为http://java.sun.com/jsp/jstl/core写错一个字符整个循环都不渲染。5.5 war 包部署后 404路径和包名对不上现象本地 IDE 里能跑打成 war 丢进 Tomcat 就 404。原因访问路径没算上 war 包名。mall.war部署后访问地址是http://localhost:8080/mall/products不是/products。另外web.xml版本和 Servlet 版本不匹配也会导致注解不生效。解决确认访问 URL 带上下文路径检查web.xml的version和xmlns是否对应 Servlet 4.0用WebServlet时确保类在WEB-INF/classes下被正确编译。6. 从能跑到能讲把商城项目做出差异化的三个技巧项目能跑通只是及格线答辩或面试时真正拉开差距的是「你有没有想过边界」。这里给三个我实际用过、成本不高但效果明显的技巧。第一个是给库存扣减加一个乐观锁版本号。前面用WHERE stock ?已经能防超卖但如果想记录「这次扣减是基于哪个版本」可以给商品表加version字段UPDATE 时带上AND version #{version}更新成功后version 1。这样在面试时你能讲清楚悲观锁和乐观锁的区别以及为什么高并发下乐观锁重试成本可能更高。加字段的语句ALTER TABLE product ADD COLUMN version INT NOT NULL DEFAULT 0;对应的扣减 SQL 改成SET stock stock - ?, version version 1 WHERE id ? AND stock ? AND version ?影响行数为 0 时业务层重试或直接失败。第二个是用统一响应封装替代到处 forward。前后端分离趋势下Servlet 直接返回 JSON 更通用。写一个ResultT类包含code、msg、data三个字段Servlet 里用 Jackson 序列化后resp.getWriter().write(json)。这样同一套 Service 既能给 JSP 用也能给前端 Ajax 用项目立刻显得「现代」了不少。参数上注意设置resp.setContentType(application/json;charsetUTF-8)否则前端拿到乱码。第三个是给关键操作加日志而不是到处 System.out。用 SLF4J Logback在 Service 的下单入口打一条log.info(用户 {} 下单商品数 {}, userId, items.size())异常处打log.error。排查线上问题时日志比断点有用得多。配置里把日志级别设成 INFO别用 DEBUG否则 SQL 日志刷屏。技巧成本收益适合场景乐观锁版本号加一字段一条件能讲清并发控制答辩、面试统一 JSON 响应一个工具类前后端通用想接前端框架SLF4J 日志加依赖改几行可排查可追溯任何规模最后说个我自己的习惯每次改完一段业务代码我都会故意把库存改成 0 再下一次单看它是不是按预期抛「库存不足」并回滚。这个「反向验证」比正向跑通更能暴露问题因为正向路径往往被各种默认值掩盖了。项目做完别急着交把这几条边界都手动触发一遍你讲起来会踏实很多。希望帮到你。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进