十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Java电商系统毕设实战:从CRUD到高并发库存扣减

Java电商系统毕设实战:从CRUD到高并发库存扣减 简介一份基于Java的电商系统毕业设计项目面向计算机相关专业学生或初级Java开发者用于课程设计、毕设参考与项目实战练习。系统覆盖商品管理、用户管理、订单管理、支付结算、购物车、物流跟踪等核心电商业务模块包含前后端页面与后台逻辑可帮助理解完整电商平台的开发流程与模块划分。资源压缩包共1237个文件约3.98MB以Java源码、JSP/HTML页面、CSS样式、JavaScript脚本为主另含XML配置、SQL数据库脚本、JSON数据及图片等素材便于直接导入开发工具运行和学习。其中136个Java类与34个JSP页面体现后台逻辑与动态页面交互175个CSS和137个JS构建前端展示效果230个HTML可作为静态页面参考。项目结构清晰代码注释与资源分类便于按模块研读适合作为毕业设计选题的落地参考或日常练手的完整案例。目前已有134人学习下载对想要快速上手电商项目开发的读者具有较高参考价值。1. 为什么毕设要选 Java 电商系统如果只打算在答辩现场放几张界面截图那 Java 电商系统这个毕设做的其实只是一个网站外壳。和我认识的大多数做这个选题的人一样前期大部分时间会花在注册登录、商品列表、购物车、订单这些 CRUD 上真正拉开差距的反而是那些不容易一眼看出来的部分库存怎么扣、订单号怎么生成、支付回调怎么验签。这套系统适合两类人一类是需要把 Java 技术栈完整走一遍的应届生另一类是已经在写接口、想借一个完整项目把 Spring Boot、MyBatis、Redis、MySQL 串起来的在职工程师。毕设项目这个词不代表它必须停留在玩具水平只要正确处理并发和事务这两个点它就能直接出现在简历的项目列表里。2. Java 电商系统的技术选型与环境搭建2.1 为什么是 Spring Boot MyBatis MySQL Redis先谈分工再谈选型。Java 电商系统的毕业设计绝大多数不是从零写 Servlet面试接受度和实际开发里最常用的组合是 Spring Boot 管 Web 层和对象装配、MyBatis 管 SQL 映射、MySQL 管业务数据落地、Redis 管缓存和热点计数。层次常用选择在这个项目里的作用Web 框架Spring Boot自动配置、内嵌 Tomcat减少部署心智负担ORMMyBatis / MyBatis-Plus单表 CRUD 少写代码复杂查询仍然手写 SQL数据库MySQL 8.0存储用户、商品、订单、支付单等核心数据缓存Redis商品缓存、库存预扣、防重幂等标记构建Maven依赖管理与最终打包成可执行 jar这里不会选 JPA不是因为它不好而是订单这类联动场景需要精确控制 SQL。MyBatis 的 update 语句把「库存充足」和「扣减」放进同一个 where 条件这个能力在 JPA 里要绕好几层才能表达。面试时如果被问两者差异你至少能说出一个具体业务场景来支撑判断。2.2 JDK 与 Maven 环境变量配置“java 环境变量配置”是搭建阶段最常见的拦路虎。现象通常是java -version能执行但 Maven 或 IDE 内部启动时报JAVA_HOME is not defined correctly。这是因为多数启动脚本都要用$JAVA_HOME/bin/java拼路径只配 PATH 不配 JAVA_HOME一半的工具会找不到 JDK。# 先确认当前 java 实际来自哪里 which java java -version # Linux/macOS 临时设置 export JAVA_HOME/path/to/jdk-17 export PATH$JAVA_HOME/bin:$PATH # Windows PowerShell 等价写法 # $env:JAVA_HOME C:\Program Files\Java\jdk-17 # $env:Path $env:JAVA_HOME\bin;$env:PathJAVA_HOME要指向 JDK 根目录而不是bin目录Maven、Gradle、Tomcat 都会基于根目录再去拼bin/java。PATH 里也要把$JAVA_HOME/bin放到其他 JDK 路径前面否则系统里残留的旧版本会被优先执行。从官网 JDK 下载页安装之后仍然不识别多半是路径里有空格的问题把 JDK 挪到纯英文无空格的目录比反复改配置文件省心得多。2.3 生成 Spring Boot 工程骨架常见做法是在 Spring Initializr 上选 Maven、Java 版本、Spring Boot 版本下载压缩包后导入 IDE。项目依赖至少包含这些spring-boot-starter-web接口、JSON 序列化、内嵌 Tomcatspring-boot-starter-validation下单参数校验spring-boot-starter-data-redis缓存与库存预扣mysql-connector-jMySQL 驱动mybatis-plus-boot-starterORMlombok减少样板代码如果想用命令行直接拉骨架可以这样curl -G https://start.spring.io/starter.tgz \ -d dependenciesweb,validation,data-redis \ -d languagejava \ -d typemaven-project \ -d baseDirdemo | tar -xzvf -这个命令生成的工程里没有 MyBatis因为 Initializr 官方依赖列表不含它需要手动往pom.xml里补一条。版本选择上JDK 8 配 Spring Boot 2.7.x 比较稳妥JDK 17 以上可以直接用 Spring Boot 3.x毕业设计不追求新追求的是在答辩环境里能一次跑起来。3. 核心业务域的表结构与交易链路实现3.1 从 ER 图出发商品、库存、订单三张表答辩时老师几乎一定会问 ER 图。别只画 user 和 order 两张表电商订单必须在一张 ER 图里表达用户、商品、库存、订单、订单明细五类实体。库存要单独拆出来因为商品字段变动少且查询频繁库存字段会被高并发 update拆开后行锁的范围更小一次 update 只锁库存这一行。CREATE TABLE product ( id bigint NOT NULL, name varchar(128) NOT NULL, price decimal(10,2) NOT NULL, status tinyint NOT NULL DEFAULT 1 COMMENT 1上架 0下架, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE stock ( product_id bigint NOT NULL, quantity int NOT NULL DEFAULT 0, version int NOT NULL DEFAULT 0, PRIMARY KEY (product_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE orders ( id bigint NOT NULL, order_no varchar(32) NOT NULL, user_id bigint NOT NULL, total_amount decimal(12,2) NOT NULL, status tinyint NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;order_no的唯一索引是实现幂等的底线服务端重复处理同一请求时不至于插入两条订单但不要把唯一索引报错当正常流程因为并发下重复插入会互相等待锁拖慢接口。订单明细表这里没有展开它的核心字段是order_id, product_id, price, quantity其中 price 必须是下单时刻的商品价格快照否则商品改价后历史订单金额会跟着变。3.2 下单链路校验、乐观锁扣库存、落订单下单是电商系统里最值得写清楚的一段代码我一般会组织成下面这样Transactional(rollbackFor Exception.class) public Long createOrder(Long userId, Long productId, Integer quantity) { // 1. 校验商品是否存在并且已上架 Product product productMapper.selectById(productId); if (product null || product.getStatus() ! 1) { throw new BizException(商品不存在或已下架); } // 2. 乐观锁扣减库存受影响行数为 0 代表库存不足 int rows stockMapper.deductStock(productId, quantity); if (rows 0) { throw new BizException(库存不足); } // 3. 落订单状态为待支付 Order order new Order(); order.setOrderNo(orderNoGenerator.next()); order.setUserId(userId); order.setTotalAmount(product.getPrice() .multiply(BigDecimal.valueOf(quantity))); order.setStatus(0); orderMapper.insert(order); return order.getId(); }deductStock对应的 Mapper 方法和 SQL 要写成这样// Mapper 方法签名 int deductStock(Param(productId) Long productId, Param(quantity) Integer quantity);update iddeductStock UPDATE stock SET quantity quantity - #{quantity}, version version 1 WHERE product_id #{productId} AND quantity #{quantity} /update先说乐观锁。version 递增只是标记这行数据发生过变化真正拦截超卖的是AND quantity #{quantity}。两个请求同时进入时InnoDB 行锁会让它们的 update 串行执行第一个成功后第二个的 where 条件已经不成立更新行数为 0于是抛业务异常。这种做法比「先 select 再 update」少一次查询也不用额外处理版本号比较的返回结果。再说事务。Transactional必须写rollbackFor Exception.class不写的话业务代码抛受检异常时 Spring 默认不回滚库存扣了但订单没生成这是最典型的数据不一致事故。这个点也是 Java 面试题里高频追问的细节在电商项目里它直接对应「钱货一致」。3.3 事务边界与自调用失效上面的下单事务范围只有查商品、扣库存、插订单三步。下单完成后的短信通知、发优惠券、更新搜索索引都不应该放进这个事务里否则数据库连接会被长时间占用。如果一定要在事务提交后做后续动作可以用事务同步器TransactionSynchronizationManager.registerSynchronization( new TransactionSynchronization() { Override public void afterCommit() { // 事务提交后发送通知或写日志 } });比这更隐蔽的是自调用失效。同一个类里createOrder调用this.sendNotify()Spring AOP 代理机制决定了this不是代理对象sendNotify上的Transactional不会生效。解决办法是把被调方法放到另一个 bean或者直接用TransactionTemplate手动控制事务边界。这类问题在毕设里不容易被发现但答辩时讲出来能看出你真的理解 Spring 事务的生效条件。异常现象可能原因修复方向库存扣了但订单缺失受检异常没有触发回滚补充 rollbackFor同 bean 方法事务不生效this 调用绕过代理拆分 bean 或使用 TransactionTemplate接口响应很慢事务里做了远程调用或等待把耗时操作移到 afterCommit4. 订单号生成、Redis 缓存与库存并发扣减4.1 订单号生成时间戳加自增序列很多教程上来就甩雪花算法但订单号生成的本质是「全局唯一 顺序可读」。毕设项目单机部署用时间戳加自增序列更直观也方便在日志里按订单号排查问题。public class OrderNoGenerator { private static final DateTimeFormatter FMT DateTimeFormatter.ofPattern(yyyyMMddHHmmssSSS); private final AtomicLong sequence new AtomicLong(0); public String next() { String time LocalDateTime.now().format(FMT); long seq sequence.incrementAndGet() % 1000; return time String.format(%03d, seq); } }时间部分精确到毫秒序列号用 AtomicLong 保证同一毫秒内多线程拿到不同值取模 1000 是给生成器加一层保护上限。单机部署没问题但多实例部署时不同 JVM 各自维护 sequence仍然存在重复可能。所以更稳妥的做法是把生成器抽象成接口单机用这个实现以后接雪花算法或发号器时只替换实现类不动业务代码。并发创建订单的场景下订单号生成器必须是线程安全的这也是面试里追问「你的订单号并发下会不会重复」的回答支点。4.2 用 Redis 缓存商品数据与空值缓存商品详情是典型的读多写少接口。缓存如果只设置过期时间热门商品缓存一旦失效请求会全部打到 MySQL这就是缓存穿透。我常用的做法是空值也缓存并且给空值设置很短的过期时间public Product getProduct(Long id) { String key product:detail: id; String json stringRedisTemplate.opsForValue().get(key); if (json ! null) { return JSON.parseObject(json, Product.class); } Product product productMapper.selectById(id); if (product ! null) { stringRedisTemplate.opsForValue().set( key, JSON.toJSONString(product), 30, TimeUnit.MINUTES); } else { stringRedisTemplate.opsForValue().set( key, , 5, TimeUnit.MINUTES); } return product; }缓存穿透说的是查询一个根本不存在的 id每次都会落到数据库。空值缓存把不存在的 key 也存下来5 分钟内同样请求不会再次穿透。实现上建议用 StringRedisTemplate不要用默认的 GenericRedisTemplate后者的 JDK 序列化会在 Redis 里存出\xac\xed开头的一串乱码排查问题时非常痛苦。4.3 三种库存扣减方案对比与 Lua 落地库存扣减是这个选题里最值得展开的部分。常见方案有三种放在一起对比更清楚方案并发能力主要问题synchronized 锁单机有效集群部署失效且 JVM 内全部线程排队数据库条件更新可靠吞吐一般行锁会让 update 排队Redis Lua 脚本吞吐最高需要处理 Redis 与 MySQL 的一致性数据库条件更新就是上章写的UPDATE stock ... WHERE quantity #{quantity}对毕设来说已经够用。想往上再走一步可以再加 Redis 预扣。先把库存预热到 Redis扣减在 Redis 里用 Lua 脚本原子完成-- KEYS[1] 库存 keyARGV[1] 扣减数量 local left tonumber(redis.call(get, KEYS[1]) or 0) local need tonumber(ARGV[1]) if left need then redis.call(decrby, KEYS[1], need) return 1 end return 0Spring Boot 端调用private static final DefaultRedisScriptLong DEDUCT_SCRIPT new DefaultRedisScript(LUA, Long.class); public boolean tryDeduct(String stockKey, int quantity) { Long result stringRedisTemplate.execute( DEDUCT_SCRIPT, List.of(stockKey), String.valueOf(quantity)); return Long.valueOf(1).equals(result); }Redis 执行 Lua 脚本是原子的整个脚本运行期间不会有其他命令插入因此这里的判断和扣减不会被并发打断。要注意的是Redis 扣减属于预扣不是最终扣减支付超时或取消后要把数量加回 Redis下单成功后还要真正去 MySQL 扣库存。如果 Redis 扣了但 MySQL 扣减失败需要一个对账任务用“Redis 剩余量 未支付订单预扣量”反推数据库库存是否被多扣。这个补偿逻辑是项目里最值得写进论文的亮点。4.4 并发加载商品信息CompletableFuture.allOf下单页往往要展示购物车里的多个商品顺序查询每个商品详情会累加接口耗时。这些查询互不依赖适合并行。「java 线程等待都完成」在项目里的真实写法就是allOf().join()ListCompletableFutureProduct futures productIds.stream() .map(id - CompletableFuture.supplyAsync( () - productService.getProduct(id), executor)) .collect(Collectors.toList()); CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) .join(); ListProduct products futures.stream() .map(CompletableFuture::join) .collect(Collectors.toList());allOf等待所有子任务完成join再逐个拿结果。这里必须用自定义线程池executor不要用默认的 ForkJoinPool否则并行任务会和其他异步逻辑抢公共线程。压测时调整线程池核心数和队列容量对比接口 RT 的变化这是答辩时能直接展示的数据。5. 支付单设计、AOP 审计与安全加固5.1 为什么还要一张 payment 表很多毕设把支付做成「点一下按钮订单状态直接变成已支付」的模拟。但如果接支付沙箱或者想认真做回调流程需要一张独立的支付单。一张订单可能发起多次支付、发生部分退款只有 payment 表能记录每一次支付行为。CREATE TABLE payment ( id bigint NOT NULL, payment_no varchar(32) NOT NULL, order_no varchar(32) NOT NULL, amount decimal(12,2) NOT NULL, status tinyint NOT NULL DEFAULT 0 COMMENT 0创建 1成功 2失败 3关闭, channel varchar(16) NOT NULL, callback_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_payment_no (payment_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;payment_no是我方在发起支付时生成的唯一标识支付网关回调时也带着它。状态流转只允许0创建 → 1成功或0创建 → 2失败不允许从成功状态再流转回创建状态。当前状态允许流转状态回调处理方式0 创建1 成功、2 失败正常更新1 成功不流转直接返回已处理2 失败可重试为 0重新发起支付重复回调到达时先按 payment_no 查询状态已经是成功就直接返回「已处理」。这个幂等判断配合唯一索引双重保证重复回调不会把订单金额改错。5.2 用 AOP 统一接口审计与耗时统计登录校验应该在拦截器里做它是 Web 层的访问控制而操作审计、耗时统计适合用 AOP 切面侵入性更小。AOP 的底层就是 JDK 动态代理Spring 容器返回给调用方的是代理对象而不是原始 bean这个机制也是常被追问的 Java 动态代理概念的落地场景。Aspect Component public class OperationLogAspect { Around(annotation(operationLog)) public Object around(ProceedingJoinPoint pjp, OperationLog operationLog) throws Throwable { long start System.currentTimeMillis(); try { return pjp.proceed(); } finally { long cost System.currentTimeMillis() - start; log.info(op{}, userId{}, cost{}ms, operationLog.value(), getUserClaim(), cost); } } }annotation(operationLog)这个切入点只拦截标注了OperationLog的方法finally 块保证方法抛异常也会记录耗时。getUserClaim()从 ThreadLocal 取登录态里解析出的用户 ID。有了这个切面后续看日志能直接回答「哪个接口慢、哪个用户操作了什么」这类问题。需要特别注意的是自调用同样会让切面失效同类里this调用带注解方法代理对象不会介入这也是动态代理面试题在真实工程里的体现。5.3 支付回调验签与防重放支付网关回调时正文里有一串签名。验签的作用是确认请求确实来自支付平台而不是攻击者伪造的「支付成功」通知。RSA 验签的核心代码很短Signature signature Signature.getInstance(SHA256withRSA); signature.initVerify(publicKey); signature.update(content.getBytes(StandardCharsets.UTF_8)); boolean ok signature.verify(Base64.getDecoder().decode(signStr));验签通过后还要检查回调里的时间戳。当前时间和回调时间差超过 5 分钟直接拒绝这是防重放的第一道关。紧接着再查 payment_no 判断这笔支付是否已经被处理过。伪造回调和重放是答辩时最容易演示的攻击点把验签失败的原因和具体报文打到日志里整套逻辑就足够支撑「你怎样验证回调一定是支付平台发来的」这个问题。6. 部署上线、JVM 参数与答辩前自检6.1 mvn package 与一条命令启动部署前打包用 Maven 跳过测试保留生产环境配置mvn clean package -DskipTests nohup java -Xms256m -Xmx512m \ -jar target/edu-mall-0.0.1-SNAPSHOT.jar \ --spring.profiles.activeprod logs/app.log 21 -Xms和-Xmx设置为相同值避免 JVM 运行过程中不断扩容堆内存。1C1G 的云服务器上 256m 到 512m 是比较合理的起点。6.2 慢接口与 OOM 时的排查命令接口变慢先看 GC 活跃程度再看线程栈里 Tomcat 工作线程卡在什么位置tail -f logs/app.log | grep ERROR jstack pid | grep -A 20 http-nio-8080 jstat -gcutil pid 1000 5如果出现内存溢出启动参数加上-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/dump事后用 MAT 打开 dump 文件定位大对象比盲目调大-Xmx更有说服力。6.3 答辩现场的三步自检最后把三个最容易翻车的场景过一遍连续快速点击两次下单按钮看订单表是否出现两条相同 order_no把库存改成 1 后用两个线程同时下单看是否只有一个成功用错误的签名伪造支付回调看系统是否把订单状态改成已支付。这三个场景在答辩现场当众跑一次比任何 PPT 都有说服力。本文还有配套的精品资源点击获取
返回列表