ARTICLE DETAIL

资讯详情

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

JavaEE网上商城管理系统实战:从建表到并发扣库存全解析

JavaEE网上商城管理系统实战:从建表到并发扣库存全解析 简介一份基于JavaEE网上商城管理系统的毕业设计完整文档面向计算机相关专业毕业生与Java Web开发者解决毕业设计选题、系统分析设计与论文撰写等痛点。系统采用Java语言、B/S架构、IDEA开发工具与MySQL数据库涵盖前台用户模块登录注册、订单、消息、地址、售后维权、公告和后台管理模块个人中心、轮播管理、客户管理、商品管理、售后维权、统计中心功能结构清晰契合高校毕业设计常见要求。资源包内仅有1个doc文件大小2.31MB即完整毕业论文文档包含摘要、目录、前言、需求分析、系统设计、数据库设计及实现测试等内容。数据库设计涉及用户、商品、订单、消息等核心数据表后台支持商品上下架、库存管理、销售统计与售后审核从需求到实现完整覆盖尤其适合需要快速搭建毕业设计框架或学习JavaEE商城项目设计的读者。目前已有1674人学习下载。1. javaEE 网上商城管理系统这个标题到底在做什么现在做还值不值2025 年再提 javaEE 网上商城管理系统很多人第一反应是“这都什么年代了”。但翻一翻招聘市场和课设题库就会发现传统行业、国企项目以及大量毕业设计里Servlet/JSP/Tomcat 这套东西的存量依旧巨大这个标题的命中率高得吓人。这个系统解决的核心问题不是“怎么把商城上线卖货”而是把用户登录、商品展示、购物车、下单扣库存、订单状态流转这条电商主链路用最不藏私的方式讲清楚分层是硬约束事务要自己管会话要自己想明白连一个中文乱码都能逼你把编码原理吃透。这篇笔记按我实际搭过的一套 javaEE 网上商城管理系统的路线来写从选型、建表、跑通最小闭环讲到订单事务和并发扣库存的坑最后落到部署验证和进阶优化。适合正在做课设的人、要接手老系统的维护者以及想真正入门 Java 服务端开发的转行者。2. 技术栈与工程骨架为什么是 Servlet/JSP 分层而不是一把梭 Spring Boot2.1 javaEE 不是框架是规范集合2025 年做商城怎么选先厘清一个最容易被忽略的概念javaEE 不是某个框架而是一组企业级 Java 规范的统称包括 Servlet、JSP、JSTL、JPA、EJB、JMS 等。Tomcat 只是 Servlet/JSP 容器并不是完整的 javaEE 应用服务器真正完整实现 javaEE 规范的是 WildFly、OpenLiberty 这类重型服务器。现实里 90% 标着“基于 javaEE”的商城项目用的是 Servlet JSP JPA/Hibernate MySQL Tomcat 这套组合少数项目会用到 EJB 和消息队列。这个选型是有道理的Servlet 容器轻、资料多、约束少适合教学和中小型后台而 JSP 虽然被前端工程化淘汰了但作为服务端渲染模板它继承了 javaEE 时代“分层强制清晰”的基因。那为什么不用 Spring Boot我在对比表里把两边的差异列清楚方便你按自己的目标选。对比项传统 javaEEServlet/JSP JPASpring Boot学习曲线先讲容器和协议曲线陡起步快屏蔽底层细节配置方式XML 注解混合自动配置 application.yml事务与会话手动管理或靠容器声明式事务默认行为成熟适用阶段课设、毕设、老系统维护商业新产品、快速迭代部署方式WAR 包部署到 Tomcat内嵌 jar 或 WAR 均可对商城主链路每层自己写练基本功一步到位但黑盒较多我的建议很直接如果目标是快速上线用 Spring Boot如果目标是吃透一个请求从浏览器到数据库再回来的全过程或者你正在做的就是课设、毕设那 javaEE 这套传统组合是性价比最高的教材。Spring Boot 不是 javaEE它只是 Spring 生态很多老商城的演进路径就是从 Servlet/JSP 迁到 Spring MVC再迁到 Boot搞清楚这条链路后面看什么都快。2.2 Maven 工程与依赖清单一套能直接编译的 pom.xml用 Maven 管理依赖是 javaEE 项目最常见的做法避免手动往 WEB-INF/lib 塞 jar。下面是我常用的最小依赖集合包类型必须是 war因为要部署到 Tomcat。packagingwar/packaging properties maven.compiler.source1.8/maven.compiler.source maven.compiler.target1.8/maven.compiler.target project.build.sourceEncodingUTF-8/project.build.sourceEncoding /properties dependencies dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency dependency groupIdjavax.servlet.jsp/groupId artifactIdjavax.servlet.jsp-api/artifactId version2.3.3/version scopeprovided/scope /dependency dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency dependency groupIdorg.hibernate/groupId artifactIdhibernate-core/artifactId version5.6.15.Final/version /dependency dependency groupIdorg.mindrot/groupId artifactIdjbcrypt/artifactId version0.4/version /dependency /dependenciesservlet-api 和 jsp-api 的 scope 必须配成 provided因为 Tomcat 自带这两个实现如果打成 compile 打包进 WAR启动会报类冲突。Hibernate 5.6 系列还在用 javax 包名适配 Tomcat 9 和 JDK 8如果你用的是 Tomcat 10整套依赖要切到 jakarta.servlet:jakarta.servlet-api代码里的 import 也要从 javax.* 全局替换成 jakarta.*这是新手最容易翻车的一个版本坑。2.3 分层与请求流转controller、service、dao 各管哪一段一个请求从点下“登录”按钮到页面渲染出来链路是固定的浏览器发起 POST 请求过滤器先处理编码和登录校验然后请求进入 Controller 层的 ServletServlet 调用 Service 层Service 层处理业务逻辑并管理事务最后通过 DAO 层操作数据库结果再逐层返回最后由 JSP 渲染响应。这个分层是 javaEE 的硬约束每一层职责清晰出了问题看日志定位也快。WebFilter(/*) public class EncodingFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding(UTF-8); resp.setCharacterEncoding(UTF-8); resp.setContentType(text/html; charsetUTF-8); chain.doFilter(req, resp); } }这个过滤器解决了最常见的 POST 请求中文乱码。参数说明req.setCharacterEncoding必须在读取任何请求参数之前调用否则对已解析的请求体无效resp.setContentType(text/html; charsetUTF-8)是告诉浏览器用 UTF-8 解码响应内容。如果你有静态资源可以在这层判断 URL 后缀放行 .css、.js、.jpg不过商城系统里大多数请求都是动态的直接/*也不会出问题。事务放在 Service 层而不是 DAO 层是因为一次下单操作要拆成多个 DAO 方法扣库存、写订单、写订单项、清购物车只有 Service 层方法足够大才能把这几步包进一个事务边界里。2.4 web.xml 里的三个必配项session 超时、欢迎页、Cookie 属性javaEE 工程即使用了注解web.xml 也不能缺Maven 打包时failOnMissingWebXml要设为 false但保留这个文件对配置 session 和欢迎页最直观。web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee version3.1 session-config session-timeout30/session-timeout cookie-config http-onlytrue/http-only /cookie-config /session-config welcome-file-list welcome-fileindex.jsp/welcome-file /welcome-file-list /web-app参数说明session-timeout单位是分钟商城后台建议 20 到 30 分钟太短用户逛着逛着就要重新登录太长又容易留会话风险。http-only打开后JS 脚本读不到 JSESSIONID Cookie能挡掉一部分 XSS 窃取会话的攻击。欢迎页建议放一个 index.jsp里面根据 Session 里有没有用户做跳转已登录就重定向到商品列表未登录就跳到登录页这个页写得很薄不给用户看到空白首页的机会。3. 从建表到跑通最小闭环商品列表、登录会话与购物车的可复现代码3.1 建库建表商城 6 张表的字段设计与两个关键约束网上商城管理系统的核心数据模型围绕用户、商品、购物车、订单来建。下面这套 SQL 我实际改过几版字段不多不少够跑通主链路也预留了支付和收货信息的位置。CREATE DATABASE shop DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; CREATE TABLE user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL, password VARCHAR(100) NOT NULL COMMENT BCrypt 哈希绝不存明文, nickname VARCHAR(50), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE category ( id BIGINT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50) NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE product ( id BIGINT AUTO_INCREMENT PRIMARY KEY, category_id BIGINT NOT NULL, title VARCHAR(100) NOT NULL, subtitle VARCHAR(200), price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 1 COMMENT 1 上架 0 下架, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_category (category_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE cart_item ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, product_id BIGINT NOT NULL, quantity INT NOT NULL DEFAULT 1, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_user_product (user_id, product_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE orders ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0 未支付 1 已支付 2 已发货 3 已完成 4 已取消, receiver_name VARCHAR(50), receiver_phone VARCHAR(20), receiver_addr VARCHAR(200), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, paid_at DATETIME, UNIQUE KEY uk_order_no (order_no), KEY idx_user (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE order_item ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_id BIGINT NOT NULL, product_id BIGINT NOT NULL, title VARCHAR(100) NOT NULL COMMENT 冗余商品标题快照, price DECIMAL(10,2) NOT NULL COMMENT 成交价快照, quantity INT NOT NULL, KEY idx_order (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有几个容易被新手忽略的设计点我用表格列一下。字段类型设计理由product.priceDECIMAL(10,2)金额必须用定点数float/double 会有精度漂移账会对不上orders.order_noVARCHAR(32) 唯一索引业务订单号要在 Java 层生成唯一索引防重复下单orders.receiver_*VARCHAR 快照收货信息直接冗余在订单表不 join 用户地址表历史订单不受地址修改影响cart_item 唯一键(user_id, product_id)同一个用户对同一个商品只能有一行记录加购走更新而非新增order_item 冗余标题/价格快照字段订单生成后商品改价改名不影响历史订单外键我故意没建物理外键。课设为了画 ER 图可以建但生产环境物理外键在并发写和分表时会成为噩梦用普通索引加应用层校验就够了。注意表名用了 orders 而不是 order因为 order 是 SQL 关键字直接叫 order 在很多查询里要加反引号非常麻烦。3.2 商品实体与 DAOJPA 注解、分页查询和金额字段的写法实体类用 JPA 注解映射数据库表这是 javaEE 规范的一部分Hibernate 作为实现。商品实体里最需要注意的是金额和库存的类型。Entity Table(name product) public class Product { Id GeneratedValue(strategy GenerationType.IDENTITY) private Long id; Column(name category_id, nullable false) private Long categoryId; Column(nullable false, length 100) private String title; Column(length 200) private String subtitle; Column(nullable false, precision 10, scale 2) private BigDecimal price; Column(nullable false) private Integer stock; Column(nullable false) private Integer status; Column(name created_at, insertable false, updatable false) private LocalDateTime createdAt; // getter / setter 省略 }price字段用BigDecimal对应数据库的 DECIMAL精度和小数位在注解里写死这是商城系统的红线。stock用 Integer库存只有整数减库存时靠数据库条件判断防超卖。created_at设成 insertable/updatable 都为 false让数据库默认值生效Java 端不需要关心这个字段。商品列表的分页查询用 JPA 的 JPQL 加 setFirstResult/setMaxResults 实现Repository public class ProductDao { PersistenceContext private EntityManager em; public ListProduct listOnSale(int page, int size) { return em.createQuery( SELECT p FROM Product p WHERE p.status 1 ORDER BY p.createdAt DESC, Product.class) .setFirstResult((page - 1) * size) .setMaxResults(size) .getResultList(); } public long countOnSale() { return em.createQuery(SELECT COUNT(p) FROM Product p WHERE p.status 1, Long.class) .getSingleResult(); } }参数说明page 从 1 开始setFirstResult((page - 1) * size)是第一页从第 0 条开始取。这个写法对应 SQL 的LIMIT (page-1)*size, size。数据量小没问题但商品超过十万条时 offset 越大越慢因为 MySQL 要跳过前面所有行后续优化方向是改成 keyset 分页用上一页最后一条记录的 created_at 或 id 做游标。另外查询只返回了上架商品下架的、库存为 0 的商品要做成不可见还是标记售罄属于产品决策代码里留好 status 字段就行。3.3 登录与 Session写一个带 BCrypt 校验的最小 Servlet登录是商城所有个人操作的前置条件。密码绝不能存明文我用 BCrypt 加盐哈希校验时用BCrypt.checkpw。下面是登录接口的核心代码WebServlet(/login) public class LoginServlet extends HttpServlet { Inject private UserService userService; Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); String username req.getParameter(username); String password req.getParameter(password); User user userService.findByUsername(username); if (user ! null BCrypt.checkpw(password, user.getPassword())) { HttpSession session req.getSession(); session.setAttribute(loginUser, user); resp.sendRedirect(req.getContextPath() /product/list); } else { req.setAttribute(error, 用户名或密码错误); req.getRequestDispatcher(/login.jsp).forward(req, resp); } } }逻辑说明先查用户再比对 BCrypt 哈希用户名不存在和密码错误走同一个提示避免暴露哪些用户名已注册。登录成功后把用户对象塞进 Session后续所有需要登录的接口靠过滤器判断session.getAttribute(loginUser)是否为 null。这里登录成功后用的是sendRedirect而不是forward因为转发会保留浏览器地址栏为 /login用户刷新页面会重复提交表单。重定向是 302 跳转地址栏变为商品列表刷新也不会再触发 POST。Session 本身的超时时间已经在 web.xml 里配成 30 分钟。要注意的是一个浏览器标签页共享同一个 Session后面 3.5 节购物车落库时会再提到这个特性带来的坑。另外User实体里别序列化整个对象到 JSONpassword 字段会漏出去查询时要手动脱敏或者用 VO 对象。3.4 商品列表页用 JSTL 渲染不在 JSP 里写 Java 脚本JSP 渲染商品列表正确姿势是用 EL 表达式和 JSTL 标签不要用% %Java 脚本。% taglib prefixc urihttp://java.sun.com/jsp/jstl/core % % taglib prefixfmt urihttp://java.sun.com/jsp/jstl/fmt % div classproduct-grid c:forEach items${page.list} varp div classcard h3${p.title}/h3 p classpricefmt:formatNumber value${p.price} pattern#,##0.00/ 元/p p剩余 ${p.stock} 件/p a href${pageContext.request.contextPath}/cart/add?productId${p.id}加入购物车/a /div /c:forEach /div参数说明${page.list}是 Servlet 里request.setAttribute(page, pageBean)的结果pageBean 里包含当前页的 List 和总页数。${pageContext.request.contextPath}会自动带上应用上下文路径部署时不管 WAR 包叫什么名字都不会写死绝对路径。金额展示用fmt:formatNumber的 pattern 指定千分位和两位小数这样 1999.00 会显示成 1,999.00比直接在 Java 里拼字符串规范得多。压测和实际体验中JSP 渲染的商品页比纯静态页慢不少但商城的主路径瓶颈一般不在页面渲染而在数据库的列表查询和订单写入所以前期不用过分优化这块。3.5 购物车落库为什么我不再把购物车塞进 Session很多课设例子把购物车对象存 Session代码写起来爽但有两个致命问题刷新丢数据、多标签页互相覆盖。我踩过这个坑之后购物车一律落库用 cart_item 表存储加购接口如下。Transactional public void addToCart(Long userId, Long productId, int quantity) { CartItem item cartDao.findByUserAndProduct(userId, productId); if (item null) { item new CartItem(); item.setUserId(userId); item.setProductId(productId); item.setQuantity(quantity); cartDao.insert(item); } else { item.setQuantity(item.getQuantity() quantity); cartDao.update(item); } }逻辑说明利用 cart_item 表的唯一键 (user_id, product_id)先查后改。第一次加购走 insert之后每次加购都走 update 把 quantity 累加两条路径不会同时执行到。这个方法的调用方是购物车 Servlet它先从 Session 里拿 loginUser 的 userId再调 service。Transactional保证查询和写入在同一个事务里虽然这里只有一条写操作事务收益不明显但保持统一习惯后到了第 4 章下单场景不会漏加注解。Session 存购物车的方案也不是完全不能用如果你做的是五分钟演示不关浏览器、不开多标签页Session 方案能少写一张表。但只要你打算让这个商城有点“产品”的样子购物车落库是投入产出比最高的决定而且后续做“已登录设备同步购物车”也只有落库方案才做得到。4. 订单与库存是商城的命门状态机、事务边界与并发扣减4.1 订单状态机用枚举把状态流转关进笼子订单状态直接用数字裸奔一定会出问题最典型的是已经取消的订单被再次支付或者已完成的订单被发货。正确做法是用枚举定义状态并让状态流转逻辑只出现在一个地方。public enum OrderStatus { UNPAID(0, 未支付), PAID(1, 已支付), SHIPPED(2, 已发货), COMPLETED(3, 已完成), CANCELED(4, 已取消); private final int code; private final String desc; OrderStatus(int code, String desc) { this.code code; this.desc desc; } public boolean canTransit(OrderStatus target) { if (this CANCELED || this COMPLETED) { return false; } if (this UNPAID) { return target PAID || target CANCELED; } if (this PAID) { return target SHIPPED || target CANCELED; } if (this SHIPPED) { return target COMPLETED; } return false; } }状态流转规则我列在下面代码里每次更新订单状态前都调canTransit非法流转直接抛异常。当前状态允许流转到不允许的典型操作未支付已支付、已取消直接发货、直接完成已支付已发货、已取消退回未支付已发货已完成退回已支付、再次发货已完成无任何修改已取消无重新支付、重新发货这个枚举的业务含义是硬规则已支付订单在发货前可以取消发货后只能等完成。实际项目里可能还有退款状态、售后状态这套“枚举 canTransit”的骨架可以直接扩展。订单状态更新时记得一并更新 paid_at 或 shipped_at 时间戳不然以后查“支付成功耗时”没有任何依据。4.2 下单事务扣库存、写订单、清购物车必须同生共死下单是最考验事务边界的地方。一次下单至少涉及四步写操作扣减商品库存、插入订单主表、插入订单商品明细、清空购物车。任何一步失败前面成功的数据都必须回滚否则就会出现库存扣了订单没生成或者订单生成了库存没扣的脏数据。用 Spring 的声明式事务加一个注解就够了Transactional public Order createOrder(Long userId, ListCartItem items) { // 1. 先扣库存所有商品依次占用库存 for (CartItem item : items) { int changed productDao.deductStock(item.getProductId(), item.getQuantity()); if (changed 0) { throw new StockNotEnoughException(item.getProductId()); } } // 2. 创建订单主表 Order order new Order(); order.setOrderNo(generateOrderNo(userId)); order.setUserId(userId); order.setStatus(OrderStatus.UNPAID.getCode()); order.setTotalAmount(calcTotalAmount(items)); orderDao.insert(order); // 3. 创建订单明细快照商品标题和成交价 for (CartItem item : items) { orderItemDao.insert(buildOrderItem(order.getId(), item)); } // 4. 清空购物车 cartDao.clearByUserId(userId); return order; }参数说明Transactional默认遇到 RuntimeException 才回滚StockNotEnoughException继承 RuntimeException库存不足抛异常后前面扣掉的库存会一起回滚这个设计是故意的。步骤顺序我也调整过先扣库存再写订单是因为所有并发下单都要抢同一行商品的库存锁先集中抢同一把锁比每个事务穿插着申请多把锁更容易避免死锁。如果先插入订单再扣库存两个事务可能各自持有一行订单的插入锁再去抢对方的商品锁直接死锁。还有一个新手常犯的错事务方法内部调用另一个加了Transactional的方法会失效因为 Spring 的 AOP 代理只拦外部调用。下面避坑章节会单独展开。这里需要强调createOrder是整个下单链路的唯一入口调用方绝不能自己分步去调 dao 层否则等于拆掉了事务边界。4.3 并发扣库存乐观锁条件 UPDATE 和重试的正确姿势商城超卖是并发场景最经典的故障。“先 SELECT 库存再 UPDATE”在并发下必超卖因为两个线程同时读到库存 5各自扣 3都认为够了最后库存变成 2 甚至负数。正确做法是把扣减条件的判断放进一条 UPDATE 语句里Modifying Query(UPDATE Product p SET p.stock p.stock - :quantity WHERE p.id :id AND p.status 1 AND p.stock :quantity) int deductStock(Param(id) Long id, Param(quantity) int quantity);这条 UPDATE 的返回值是关键affected rows 为 1 表示扣减成功为 0 表示库存不足或商品已下架应用层拿返回值判断即可。我用一个 service 方法包住它public boolean tryDeduct(Long productId, int quantity, int retryTimes) { for (int i 1; i retryTimes; i) { int changed productDao.deductStock(productId, quantity); if (changed 0) { return true; } if (i retryTimes) { try { Thread.sleep(50L * i); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return false; } } } return false; }注意一个边界这个方法如果放在 createOrder 的事务内重试是无效的。原因在于同一事务里第一次 UPDATE 已经对那一行加了行锁第二次 UPDATE 会阻塞在自己持有的锁上永远等不到结果。所以重试要放在事务外或者干脆把 retryTimes 设为 1扣减失败就抛异常让整个下单事务回滚由客户端重新提交订单。提示高并发商城更常见的做法是“预占库存”模式下单时只冻结库存支付成功才真正扣减超时未支付自动释放冻结量。这种方案能显著缩短库存行锁的持有时间但表结构和状态机都要加东西课设阶段用上面的条件 UPDATE 已经足够。另外悲观锁写法SELECT ... FOR UPDATE也能防超卖它把一行库存锁到事务结束并发低的时候简单可靠但并发一高就变成排队还容易死锁。我的经验是优先用乐观锁的条件 UPDATE只有当业务明确要求“强一致 低并发”才上悲观锁。4.4 支付回调的幂等设计留好这个接口后续接支付不返工商城系统做完订单下一步几乎必然要接支付。支付回调是外部系统调用你的接口网络重试会导致同一个支付结果被推送多次所以回调接口必须幂等。常见做法是加一张支付流水表用支付平台返回的交易号做唯一索引。Transactional public void handlePayCallback(String tradeNo, String orderNo) { try { payLogDao.insert(tradeNo, orderNo); } catch (DuplicateKeyException e) { return; // 已经处理过这次回调直接返回避免重复改订单 } orderDao.updateStatusByOrderNo(orderNo, OrderStatus.PAID.getCode()); }逻辑说明先插入流水利用唯一索引挡住重复回调插入失败说明这个 tradeNo 已经处理过直接返回。这里有个顺序问题必须先插流水再改订单状态如果先改订单再插流水两个请求同时进来订单状态被改两次第二次的 paid_at 会覆盖第一次的。这张 pay_log 表的核心字段就三个自增 id、trade_no 唯一键、order_no 普通索引金额和支付渠道可以作为扩展字段加上。课设阶段不做真实支付的话可以在后台加一个“模拟支付成功”的按钮直接调用这个方法接口留好以后接微信支付宝不用改业务代码。5. 避坑记录javaEE 商城从开发到部署的 5 个典型翻车现场这一章写我实际踩过、以及帮别人排查过的五个高频故障每条按现象、原因、解决三个步骤写可以直接对号入座。5.1 JSP、MySQL 同时乱码问题往往不在单独某层现象商品标题、用户名在页面上显示成问号或者菱形乱码改 JSP 的 pageEncoding 也没用。原因乱码是整条链路的问题浏览器请求编码、Servlet 响应编码、MySQL 连接编码、表字符集任何一层不一致都会乱。最常见的是三层同时出问题JSP 文件本身是 UTF-8 但没设置 pageEncodingMySQL 连接串没带 characterEncoding表结构用了默认的 latin1。解决三层分别处理。JSP 文件头加% page contentTypetext/html; charsetUTF-8 pageEncodingUTF-8 %MySQL 连接串加上characterEncodingutf8建表统一用 utf8mb4。数据库已经建成 latin1 的话要先用ALTER TABLE product CONVERT TO CHARACTER SET utf8mb4;转码再确认连接串参数。最后通过 2.3 节那个过滤器统一设置请求和响应编码。这套做完基本不会再出中文问题乱码是最像玄学的坑但其实每一层都是确定性的。5.2 Session 购物车多标签页互相覆盖现象开两个标签页逛商城A 页加入商品 AB 页加入商品 B回到 A 刷新购物车里只剩 B。原因Session 是跟着浏览器走的同一浏览器所有标签页共享同一个 Session 对象。如果把整个购物车对象存成一个 Session 属性后写入的购物车对象会整体覆盖先写入的不存在“合并”这回事。解决按 3.5 节的方案把购物车落库用 (user_id, product_id) 唯一键做累加加购操作是单条记录的更新天然没有覆盖问题。如果坚持用 Session至少要把购物车实现为 MapLong, Integerkey 是 productId加购时按商品维度更新数量而不是新建 List 替换但这也只能缓解同个 session 内的合并无法解决刷新丢数据的问题。5.3 Transactional 自调用导致事务悄悄失效现象OrderService 里createOrder方法调用本类另一个加了Transactional的方法数据库操作失败后数据没回滚日志里也没有任何事务异常。原因Spring 的声明式事务基于 AOP 代理只有外部调用才会经过代理对象类内部this.xxx()是直接调用原始对象注解被绕过。解决三种常见改法。把需要事务的方法拆到另一个 Service bean 里由外部调用或者注入自己的代理对象Autowired Lazy private OrderService self;然后用self.otherMethod()调用或者直接用TransactionTemplate手动包事务。我的习惯是第一种拆 bean 最直观也方便以后复用。排查这个坑有个简单办法在事务方法里故意抛一个 RuntimeException如果数据没回滚基本就是自调用了。5.4 MySQL 8 首次连接报 Public Key Retrieval is not allowed现象项目从 MySQL 5.7 换到 MySQL 8.0启动时 JDBC 连接报Public Key Retrieval is not allowed有时还伴随 SSL 连接告警。原因MySQL 8 默认认证插件是 caching_sha2_password客户端第一次连接需要从服务器获取 RSA 公钥来加密密码传输而 JDBC 驱动默认不允许自动获取公钥。解决在 JDBC 连接串上加两个参数allowPublicKeyRetrievaltrue和useSSLfalse同时指定时区serverTimezoneAsia/Shanghai。完整连接串是jdbc:mysql://localhost:3306/shop?useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltruecharacterEncodingutf8。驱动类名用com.mysql.cj.jdbc.Driver老驱动com.mysql.jdbc.Driver在 MySQL 8 下已经不推荐。这个坑不会影响本地开发因为 IDE 里可能自动处理了但命令行启动 Tomcat 时就会暴露部署阶段遇到优先检查连接串。5.5 金额用 double 导致订单总额对不上账现象订单明细加总和订单总额偶尔差 0.01或者 19.9 0.1 显示出来是 20.0但换一组价格变成 19.999999。原因float 和 double 是二进制浮点数无法精确表示十进制小数计算和存储都会产生误差。商城金额一旦出现精度漂移对账就是灾难。解决Java 端所有金额字段用 BigDecimal数据库用 DECIMAL(10,2)JSON 序列化时 BigDecimal 转字符串而不是数字。BigDecimal 构造参数必须传字符串new BigDecimal(19.9)传 doublenew BigDecimal(19.9)会把二进制误差带进来。比较金额大小用compareTo而不是equals因为equals还比较精度位数new BigDecimal(1.0).equals(new BigDecimal(1.00))返回 false。JSP 展示用fmt:formatNumber统一格式化。这套规则要在项目一开始就定成代码规范不然后期账不平的时候根本不知道是哪笔订单出的问题。6. 验证与进阶在 VSCode 里配好 javaEE 环境把单体商城部署到 Tomcat 并继续优化6.1 把 WAR 部署到 Tomcat 并验证项目编译、打包、部署这条命令链我重复了无数遍写下来可以直接照做。mvn clean package -DskipTests cp target/shop-1.0.0-SNAPSHOT.war $CATALINA_HOME/webapps/shop.war cd $CATALINA_HOME/bin ./startup.sh tail -f $CATALINA_HOME/logs/catalina.out参数说明-DskipTests跳过测试编译课设项目没有写单测就省时间。WAR 包复制到 webapps 目录后Tomcat 启动时会自动解压部署访问路径是http://localhost:8080/shop/这里的 shop 是 WAR 包文件名。tail -f盯日志看到Deployment of web application archive [shop.war] has finished才算部署成功。启动报错优先看logs/localhost-当天日期.log那里面是应用级异常的堆栈比 catalina.out 更具体。6.2 在 VSCode 里配好 javaEE 开发环境现在很多新同事不再用 Eclipse直接用 VSCode 做 javaEE 项目也完全可行。装三个扩展Extension Pack for Java、Maven for Java、Tomcat for Java。打开项目后 Maven 会自动导入依赖右下角会有构建进度提示。需要设置环境变量 JAVA_HOME 指向 JDK 8 或 11Tomcat 插件里配置好本地 Tomcat 路径右键就能启动和 Debug。调试时断点可以打到 Servlet、Service、DAO 任意一层比System.out.println好用太多。一个小技巧VSCode 里按 CtrlShiftP 输入 Java: Clean Java Language Server Workspace可以解决依赖更新后代码飘红的问题这个操作不会动任何项目文件。6.3 压测与优化方向最小闭环跑通后先用 JMeter 建一个 50 线程并发调用cart/add和order/create的测试计划观察错误率和 TPS。如果错误率高优先看库存扣减那一步是不是出现了超卖或锁等待如果单接口 TPS 低但没报错先查数据库的慢查询日志再考虑给 product 表加缓存。这个单体商城后续值得做的优化方向有三块商品热数据用 Redis 做缓存列表接口不再每次查 MySQL图片和静态资源交给独立 Web 服务器不给 Tomcat 增加压力订单表量大后按月分表order_no 里带上年月前缀。每一步都有现成方案但前提是当前的 javaEE 分层足够清晰不然改造时每动一处都要连锁返工。我做第一个 javaEE 商城时最蠢的一步是把购物车完整塞进 Session上线演示时开个新标签页就“丢单”被现场围观了半小时。后来我养成的习惯是凡是用户维度有状态的数据先问一句“刷新页面能不能丢”再决定放内存还是落库。这句提醒在我后来做订单、做支付回调、做购物车同步时都救过我希望也能帮到你。本文还有配套的精品资源点击获取
返回列表