ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

社区团购系统Java实现:成团状态机与并发防超卖设计

2026/10/8 6:25:09 拓冰建站 浏览量
社区团购系统Java实现:成团状态机与并发防超卖设计 简介一份面向Java后端学习与毕业设计的社区团购系统完整资料包涵盖用户注册/登录、商品浏览、购物车、订单管理、团购详情以及管理员后台等核心模块适合用于课程设计、毕业设计答辩或入门级电商项目实战。包体共810个文件、约15.05MB以130个Java后端源码、48个Vue页面组件、153个JS逻辑脚本及162个SVG图标为主同时附带SQL初始化脚本与前后端工程配置说明便于直接导入IDE运行与二次开发。系统两种角色操作路径清晰前台用户可完成收藏、加购、下单后台管理员可维护商品与订单数据目录结构按前后端分层排列且提供bak备份与bat启动脚本辅助本地部署。目前已有84人学习下载体积虽小但业务覆盖完整适合希望快速理解社区团购从前端界面到后端业务实现全流程的初学者。1. 社区团购系统的设计与实现为什么四年Java学了那么多最后被一个成团状态机问倒如果你拿到的毕设题目是“基于Java的社区团购系统设计与实现”那它和普通商城类题目有个本质区别它不是让你把商品、购物车、订单的CRUD做完就收工而是要处理一个带状态的交易闭环——用户下单后凑够人数才成团没凑够要退款团长要按成团订单拿分佣。这套逻辑里最值钱的部分不是增删改查而是“成团”这个状态到底怎么设计、并发情况下库存为什么不超卖、分佣金额为什么不能算错。正好这几个点也是答辩现场老师最爱追问的“八股”。这篇文章想给你一条相对完整的落地路径从技术选型、工程骨架、数据库设计到成团链路的核心代码再到开题报告和论文怎么写最后用一个并发测试证明系统没坑。适合正在做这个题目的计算机专业学生也适合想用完整Java全栈项目练手但还没想清楚主线的开发者。2. Java技术栈与工程骨架Spring Boot MyBatis-Plus 的落地选型和环境配置拿到这种毕业设计题目第一反应通常是“我该用什么框架”。我的建议很简单别为选型纠结超过半天。社区团购系统的开发量集中在业务规则上不在基础设施上。选一套资料最多、自己能驾驭的技术栈比选一套最新最炫但没人踩过坑的框架重要得多。2.1 技术选型的三个硬约束答辩能讲、开发量可控、拿得出手常见的毕设技术方案是 Spring Boot MyBatis-Plus MySQL前端可以用 Vue 3也可以退回 Thymeleaf。这套组合的底气在于Spring Boot 是 Java 后端的事实标准MyBatis-Plus 把单表 CRUD 几乎消灭掉你没必要手写一堆重复的 XML Mapper 来证明自己很努力。答辩时老师问“为什么选 MyBatis-Plus”你可以答出“减少样板代码把精力集中在成团状态机和库存扣减这类核心业务上”这句话在答辩现场很加分。有些学校的老选题系统还在和 JSP 挂钩但我不建议新项目再用 JSP 搭页面。倒不是 JSP 不能做而是前后端不分离的写法在论文里很难把“系统设计”和“系统实现”两章写厚。你需要的不是更复杂的框架而是一个能让你把核心逻辑讲清楚的最小骨架。如果导师没有硬性指定Spring Boot 2.7 JDK 8 是最稳的组合如果导师要求新版再整体切到 Spring Boot 3 JDK 17不要混用。2.2 Java环境配置JDK、Maven与application.yml一次调对不返工环境问题听着基础但它确实是“java启动失败怎么解决”这类搜索里出现频率最高的源头。最常见的情况是 IDEA 里 Project SDK 是 17Maven 的 JDK 是 8Spring Boot 版本又不匹配启动直接抛 UnsupportedClassVersionError。统一版本这件事没捷径确认 IDEA 的 Project Structure 里 SDK 和 Language Level 一致确认 Maven 的 JDK for Importer 也指向同一个版本再在 pom.xml 里固定 spring-boot-starter-parent 版本。下面是一份可以直接抄的 application.yml 配置我一般会把这些参数写全server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/community_group?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 123456 jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0这段配置里有三个参数值得多说两句。第一个是 url 里的 serverTimezoneAsia/ShanghaiMySQL 8 的驱动默认对时区很敏感不写它启动时会直接报时区错误而且就算启动成功订单创建时间也可能比本地时间差 8 小时很影响后面的分佣结算对账。第二个是 map-underscore-to-camel-case它负责把数据库的 user_name 自动映射到实体类的 userName这个开关不开你写出来的实体查询结果会全是 null而 MyBatis-Plus 的 BaseMapper 又不会给你任何报错提示排查起来很痛苦。第三个是 logic-delete-field给所有表统一加一个 deleted 字段做逻辑删除比物理删除安全写论文时也能作为“系统设计考虑了数据安全”的一个细节。2.3 工程目录划分用一个最小可运行的项目撑起整篇论文工程结构不需要花哨但要有层次。我的习惯是把 controller、service、mapper、entity、config、common 六层分清楚其中 common 放统一的返回结果 Result 和异常类。不要为了省事把业务代码全塞在 controller 里那样论文的“系统实现”一章会很难写因为你在代码里根本分不出模块边界。一个最小可运行的目录结构类似这样community-group-buy/ ├── pom.xml └── src/main/java/com/example/community/ ├── CommunityApplication.java ├── common/ │ ├── Result.java │ └── BusinessException.java ├── config/ │ └── MybatisPlusConfig.java ├── controller/ │ └── UserController.java ├── entity/ │ └── User.java ├── mapper/ │ └── UserMapper.java └── service/ ├── UserService.java └── impl/ └── UserServiceImpl.java实体类是所有后续工作的起点它的写法直接影响 MyBatis-Plus 的行为。以一个用户实体为例Data TableName(user) public class User { TableId(type IdType.AUTO) private Long id; TableField(user_name) private String userName; /** 0-普通用户 1-团长 2-管理员 */ private Integer userType; private String phone; private String password; TableLogic private Integer deleted; }TableName 指定物理表名TableField(user_name) 显式声明列映射这样即使全局驼峰映射被关掉这一列也能正确对应。userType 用 Integer 而不是布尔值是为了后续扩展角色不用改表结构。TableLogic 配合刚才 yml 里的逻辑删除配置调用 deleteById 时 MyBatis-Plus 会自动把 deleted 置 1而不是真正删数据。这三个注解是 MyBatis-Plus 的常规用法但很多新手只写 TableName 不写 TableField导致列名对不上时一脸懵。对应的 Mapper 也简单Mapper public interface UserMapper extends BaseMapperUser { }继承 BaseMapper 后单表的 insert、selectById、updateById 都直接可用不需要任何 XML。这部分是 MyBatis-Plus 最省力的地方。真正需要手写 SQL 的是后面要说的库存扣减和成团计数那才是社区团购系统的业务核心。3. 数据库设计与建表SQL从实体类生成DDL订单与商品表怎么定才是对的数据库设计决定了论文里最能体现“设计能力”的一章也决定了代码写起来是顺畅还是别扭。社区团购系统的主线不复杂但它不是一张订单表就能解决的。订单主表和订单明细表必须拆开成团信息要单独一张表团长分佣需要一张结算表落账。下面按实际开发顺序讲清楚。3.1 最少能跑通业务的五张核心表用户、商品、订单、订单明细、成团记录社区团购的表设计有一个和普通电商不同的关键点订单不只属于用户还属于某个团。所以订单表里除了 user_id还要有 group_id 和 leader_id分别表示用户参加了哪个团、这个团由哪个团长发起。这是社区团购区别于普通商城最明显的表结构差异。表名核心字段设计要点userid, user_name, phone, password, user_type, deleteduser_type 区分普通用户和团长不单独建角色表goodsid, category_id, goods_name, price, stock, statusprice 用 DECIMAL(10,2)不用 doubleordersid, order_no, user_id, group_id, leader_id, total_amount, pay_amount, order_statusorder_no 唯一索引状态字段贯穿成团流程order_itemid, order_id, goods_id, goods_name, price, quantity, subtotal冗余 goods_name 和 price 快照防止商品改价影响历史订单group_buyid, goods_id, target_count, cur_count, status, start_time, end_time成团表和商品表一对多cur_count 记录当前参与人数订单主从表的设计不是小题大做。如果一个订单只买一件商品确实可以把商品名、价格直接塞进 orders 表但社区团购的团购活动往往允许用户一次下单多件同一商品后续团长的分佣也要按订单明细去汇总。把 order_item 独立出来好处是将来要出一个“哪个商品卖得最好”的报表时直接对明细表分组汇总就行不需要解析字符串。goods_name 和 price 在明细表里复制一份这是刻意冗余目的就是不让历史订单显示的商品名和价格随商品表变动。论文里如果被问到范式问题可以解释为“在第三范式与查询性能之间做了权衡”。3.2 用实体类生成建表SQL一个反射小工具解决实体和表不同步你有没有过这种经历实体类加了字段数据库表忘了加列启动后 SQL 一执行就报 Unknown column或者反过来表结构改好了实体类没同步查出来全是 null。这种实体和 DDL 不同步的问题在毕设开发阶段几乎每天都能遇到。网上经常搜“mybatisplus根据java实体类生成创建表的sql语句”其实 MyBatis-Plus 本身不带正向建表功能但我们可以自己写一个几十行的反射工具让实体类成为唯一的事实来源。思路是扫描实体类的字段读取 MyBatis-Plus 的表名和字段注解把 Java 类型映射成 MySQL 类型拼接成 CREATE TABLE 语句在应用启动时自动执行。核心代码可以写成这样public class DdlGenerator { public static String generate(Class? clazz) { TableName tableName clazz.getAnnotation(TableName.class); String table tableName null ? clazz.getSimpleName().toLowerCase() : tableName.value(); StringBuilder sb new StringBuilder(); sb.append(CREATE TABLE IF NOT EXISTS ).append(table).append( (\n); Field[] fields clazz.getDeclaredFields(); for (Field field : fields) { TableField tf field.getAnnotation(TableField.class); // TableField(exist false) 表示该字段不对应数据库列 if (tf ! null !tf.exist()) { continue; } String column field.getName(); if (tf ! null !tf.value().isEmpty()) { column tf.value(); } sb.append().append(column).append( ).append(mapType(field.getType())); if (field.isAnnotationPresent(TableId.class)) { sb.append( PRIMARY KEY AUTO_INCREMENT); } sb.append(,\n); } sb.append() ENGINEInnoDB DEFAULT CHARSETutf8mb4); return sb.toString(); } private static String mapType(Class? type) { if (type String.class) return VARCHAR(255); if (type Integer.class || type int.class) return INT; if (type Long.class || type long.class) return BIGINT; if (type BigDecimal.class) return DECIMAL(10,2); if (type LocalDateTime.class) return DATETIME; return VARCHAR(255); } }这个工具有几个明显的边界反射拿不到字段的注释所以数据库列的 COMMENT 需要手动补它也无法生成唯一索引和联合索引订单号的唯一约束还是得手工写。所以我不建议把这个工具当成万能方案它的真正价值是保证“表结构跟随实体走”避免你改了实体忘了改表。实际项目里我一般只在启动时执行一次表建好后自动跳过不影响快速迭代。答辩时如果老师问起也可以说这是“实体与表结构一致性保障机制”比单纯说“我手写SQL”听起来更完整。3.3 索引设计与唯一约束订单号必须有唯一索引这不是小题大做很多毕设项目只有几百条测试数据索引的作用根本体现不出来但数据库设计的规范性还是要从索引上体现。至少三处索引不能省orders 表的 user_id 和 group_idgoods 表的 category_id以及 orders 表的 order_no 唯一索引。-- 订单号唯一防止重复创建订单 ALTER TABLE orders ADD UNIQUE INDEX uk_order_no (order_no); -- 我的订单列表页按用户查 CREATE INDEX idx_order_user ON orders (user_id); -- 查看某团所有订单按团查 CREATE INDEX idx_order_group ON orders (group_id); -- 商品分类筛选 CREATE INDEX idx_goods_category ON goods (category_id);order_no 的唯一索引尤其重要。生成订单号时如果并发撞了数据库会直接拒绝插入应用层再捕获异常重试这是一种兜底机制。订单号我习惯用“年月日 时间戳 随机数”长度控制在 30 位以内避免索引过大。user_id 和 group_id 上的普通索引对应的是“我的订单”和“团购详情”两个高频查询就算现在数据量小索引的命中在 explain 里也能看得出来。写论文的数据库设计章节时把这三条索引 SQL 放进表格配上一句“通过索引设计保证订单查询和唯一性约束在数据量增长时仍然有效”这一小节的内容就充实了。注意别走入另一个极端给所有字段都加索引。写操作变慢不说答辩时老师可能反问你“这个索引的使用场景是什么”答不上来反而露怯。4. 下单、成团与分佣的核心链路事务和并发控制写在哪一步答辩才问不倒社区团购和普通电商系统最大的区别不在页面而在订单状态流转。普通电商是下单后直接支付、发货、完成社区团购多了一个“成团”的中间状态。这个状态是整篇论文的题眼也是你在答辩时可以深度展开的地方。本意核心的一句话是成团不是下单的附属动作而是一条独立的业务规则。4.1 成团状态机订单状态字段怎么定义待成团与已成团不能混为一谈订单状态字段建议按 0 到 4 定义每个数字对应一个明确的业务阶段状态值含义关键动作0待支付用户已下单未付款超时自动取消1已支付待成团支付成功等待该团人数凑满2已成团待自提团满进入备货和自提流程3已完成用户自提确认收货4已取消超时未支付或未成团自动退款这个状态机的好处在于支付和成团被拆成了两个独立事件支付成功只把状态从 0 改成 1成团成功才把状态从 1 改成 2。很多新手会把“用户付款后立刻成团”写在一起等于把成团这个核心业务规则给吞掉了——那这个系统就和普通商城没有区别了。核心的下单方法要同时处理库存扣减、订单创建、成团计数三件事并且这三件事必须在同一个事务里任何一个失败都要全部回滚Override Transactional(rollbackFor Exception.class) public Long createOrder(OrderCreateParam param) { // 1. 库存扣减stock #{count} 是防超卖的关键条件 int updated goodsMapper.deductStock(param.getGoodsId(), param.getQuantity()); if (updated 0) { throw new BusinessException(库存不足下单失败); } // 2. 创建订单初始状态为已支付待成团 Order order new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(param.getUserId()); order.setGroupId(param.getGroupId()); order.setLeaderId(param.getLeaderId()); order.setOrderStatus(1); order.setPayAmount(param.getPayAmount()); orderMapper.insert(order); // 3. 成团人数 1返回 0 说明该团已满或不存在 int row groupBuyMapper.increaseCount(param.getGroupId(), param.getTargetCount()); if (row 0) { throw new BusinessException(该团已满或不存在); } return order.getId(); }对应的 Mapper 里库存扣减的 SQL 长这样Mapper public interface GoodsMapper extends BaseMapperGoods { // stock #{count} 同时充当条件判断和原子扣减 Update(UPDATE goods SET stock stock - #{count} WHERE id #{goodsId} AND stock #{count}) int deductStock(Param(goodsId) Long goodsId, Param(count) Integer count); }这段代码有两个值得在答辩时展开的细节。第一Transactional 默认只回滚 RuntimeException 和 Error如果自定义的 BusinessException 继承的是 Exception事务不会回滚。所以必须写 rollbackFor Exception.class。第二deductStock 不是先查库存再更新而是把“库存是否足够”的判断交给数据库UPDATE 语句的 WHERE 条件里带上 stock #{count}数据库行锁保证同一商品的库存扣减是原子操作返回的影响行数为 0 就说明库存不够。这是解决并发超卖的正确姿势也是最容易在答辩中成为亮点的设计。4.2 模拟支付与成团后置逻辑别真接第三方支付也别把支付环节省掉毕设阶段接微信支付或支付宝不现实需要商户号资质、回调域名配置和证书处理光审核流程就能拖垮你的进度。但支付环节又不能不体现。常规做法是写一个模拟支付接口接收订单号把状态从未支付改成已支付待成团。这个“模拟”要在论文里明确写出来当作系统预留了第三方支付接口实际操作是模拟回调不要试图在论文里回避它。支付成功以后要触发一次成团判断。判断逻辑是查出该团当前的 cur_count如果加一后等于 target_count那就把该团所有状态为待成团的订单改成已成团待自提。这里要注意不是每个下单请求都自己去做成团判断而是把判断收敛到一个方法里避免多个线程同时更新成团状态时出现“团满了但订单状态没变”的问题。失败场景也要处理超过活动截止时间还没凑满的团要有定时任务扫描并把相关订单改为已取消、触发模拟退款。这个定时任务用 Spring 自带的 Scheduled 就能写不用引 Quartz。系统里还要有“超时未支付自动取消”的任务。如果不写你测试时会攒下一堆脏订单后续报表和分佣数据全对不上。定时任务的实现不复杂但它是“系统设计完整性”的体现论文的“系统实现”章可以单独用一个小节写它。4.3 团长分佣计算金额精度和结算状态一个都不能含糊社区团购里的团长本质是一个推广获客的角色。团长开团后该团下所有成团订单都要按比例给团长分佣。分佣计算有两个前提一是在订单已成团后触发未成团退款的订单不能进入分佣二是金额计算必须用 BigDecimal不能用 double。分佣逻辑单独抽一个 CommissionServiceService public class CommissionServiceImpl implements CommissionService { /** 佣金比例从配置表读取方便运营调整也方便论文里写成“可配置项” */ Value(${commission.rate:0.10}) private BigDecimal rate; Override Transactional(rollbackFor Exception.class) public void settleCommission(Long orderId) { Order order orderMapper.selectById(orderId); if (order null || order.getOrderStatus() ! 2) { // 只有已成团订单才允许结算佣金 throw new BusinessException(订单状态不允许结算佣金); } BigDecimal commission order.getPayAmount() .multiply(rate) .setScale(2, RoundingMode.HALF_UP); CommissionRecord record new CommissionRecord(); record.setOrderId(orderId); record.setLeaderId(order.getLeaderId()); record.setAmount(commission); record.setStatus(0); // 0-待结算 1-已结算 commissionRecordMapper.insert(record); } }这里有一个 Java 的经典精度坑BigDecimal 的构造器如果传的是 double例如 new BigDecimal(0.1)得到的不是一个精确的 0.1正确做法是传字符串或者用 BigDecimal.valueOf()。乘法结果再用 setScale(2, RoundingMode.HALF_UP) 保留两位小数否则分佣金额可能出现一串诡异的尾数把“0.30000000000000004”这种浮点数问题直接带进数据库。分佣记录里的 status 字段必须保留它把“该算的账”和“该发的钱”分开论文里可以说这是“财务结算流程的待结算与已结算状态设计”。5. 避坑清单从Java启动OOM到金额精度五个必踩的坑与排查思路毕设开发周期通常只有两三个月很多时间都花在排错上。下面五条是我做类似项目时真实的踩坑记录每一条都按“现象、原因、解决”给你理一遍。看完不能说你就不会踩了但至少踩到的时候能快速定位别在一个错误上耗掉两三天。5.1 IDEA 编译报 OOMJava heap space调了堆大小还是报错现象IDEA 里点击启动 Spring Boot编译阶段直接抛 java.lang.OutOfMemoryError: Java heap space或者在 mvn clean package 时 Maven 构建死在“Compiling 100 source files”这一步。原因IDEA 的构建进程默认堆内存很小对于包含几十个依赖的 Spring Boot 项目编译吞吐不足就会触发 OOM。还有一部分情况是电脑里存在多个 JDKIDEA 编译器和 Maven 用了不同版本的 JDK导致编译行为不一致间接放大了内存消耗。解决打开 IDEA 的 Help 菜单里的 Edit Custom VM Options把 -Xmx 调到 2048m 或更高再打开 Build Tools 的 Maven 配置把 Importer 的 VM options 也加上 -Xmx2048m。如果项目本身引了太多无用依赖顺手在 pom.xml 里清一遍也经常有奇效。5.2 MySQL 连接时报时区错误The server time zone value is unrecognized现象项目启动时数据库连接池初始化失败控制台报 The server time zone value Öйú±ê׼ʱ¼ä is unrecognized后面跟着一串乱码。原因MySQL 8 的驱动 com.mysql.cj.jdbc.Driver 默认要求连接串里显式指定时区否则会尝试读取系统时区一旦系统时区不是标准 UTC 格式就直接报错。解决在 jdbc url 末尾加上 serverTimezoneAsia/Shanghai 和 useSSLfalse。注意 useSSL 不是必须但如果你的 MySQL 没配 SSL 证书加上它能少一些连接阶段的握手警告。如果已经启动了还要确认 MySQL 服务端时区设置命令行里执行 show variables like %time_zone%把它和连接串统一起来。5.3 MyBatis-Plus 查出来的字段全是 null日志里明明是空的现象调用 selectList 查用户表数据库里 user_type 字段有值但实体对象的 userType 是 null。其他字段可能正常可能也异常表现不固定。原因数据库列名是 user_type实体属性是 userTypeMyBatis-Plus 的默认驼峰映射没生效或者写 SQL 的时候别名和实体属性对不上。更隐蔽的一种情况是实体里用了 Lombok 的 Data但字段名是 uType 这种不符合驼峰规则的写法映射直接错位。解决先在 application.yml 确认 map-underscore-to-camel-case: true 没被注释掉然后在实体字段上加 TableField(user_type) 双保险。排查顺序是先看 SQL 日志确认查询的列名再看实体注解这两个地方至少有一处能说明问题。5.4 并发下单库存变负数压测一跑超卖出现了现象用 JMeter 或自己写多线程脚本模拟 20 个用户同时抢同一商品跑完后 goods 表的 stock 字段变成负数订单却成功创建了。原因代码写成先 SELECT stock 判断库存是否充足再 UPDATE stock 减一。两个操作之间有时间窗口多个线程读到同一个库存值各自认为“库存够”然后一起执行扣减库存直接被扣穿。解决把扣减和判断合并成一条 UPDATE 语句例如 UPDATE goods SET stock stock - 1 WHERE id ? AND stock 1检查受影响行数为 0 就说明库存不足。这是并发扣库存的标准解法它把原子性问题交给了数据库的行锁。相关代码在第四章的 deductStock 里已经写过这里不再重复。5.5 分佣金额出现 0.30000000000000004Java数据类型选错了现象佣金率 0.03订单实付金额 10 元算出来的佣金不是 0.30而是一长串小数 0.30000000000000004数据库里存不下页面显示也诡异。原因double 和 float 是二进制浮点数二进制无法精确表示 0.1、0.03 这类十进制小数。用 double 做金额乘法误差就会在计算过程中累积。解决业务里所有金额一律用 BigDecimal数据库字段用 DECIMAL(10,2)并且创建 BigDecimal 时优先用字符串构造器例如 new BigDecimal(0.03)、BigDecimal.valueOf(0.03)。不要直接 new BigDecimal(0.03)那个构造器反而会用二进制浮点数初始化得不到精确值。这个坑不只存在于分佣场景订单金额、退款金额、补贴金额全都要按这个规则处理。6. 答辩前的最后一步用并发下单测试证明系统没有超卖系统写到能跑通页面只是第一步答辩时不光要演示功能还要拿出验证手段证明核心逻辑是可靠的。针对社区团购最容易被追问的并发超卖问题最好的办法是写一个集成测试模拟多个用户同时抢购同一商品跑完后校验库存余量和成功下单数是否对得上。我一般会用 Spring Boot Test 加 JUnit 写一段类似这样的代码SpringBootTest class ConcurrencyOrderTest { Autowired private GoodsMapper goodsMapper; Autowired private GroupBuyService groupBuyService; Test void concurrentOrderShouldNotOversell() throws InterruptedException { Long goodsId 1L; int threadCount 20; int stockBefore goodsMapper.selectById(goodsId).getStock(); ExecutorService pool Executors.newFixedThreadPool(threadCount); CountDownLatch ready new CountDownLatch(threadCount); CountDownLatch start new CountDownLatch(1); CountDownLatch done new CountDownLatch(threadCount); AtomicInteger successCount new AtomicInteger(); for (int i 0; i threadCount; i) { pool.submit(() - { ready.countDown(); start.await(); try { groupBuyService.createOrder(buildParam(goodsId, 1)); successCount.incrementAndGet(); } catch (BusinessException ignored) { } done.countDown(); }); } ready.await(); start.countDown(); done.await(); pool.shutdown(); int stockAfter goodsMapper.selectById(goodsId).getStock(); assertEquals(stockBefore - successCount.get(), stockAfter); } }这段测试的核心逻辑是让 20 个线程在 CountDownLatch 的控制下尽可能同时调用 createOrder成功数由 successCount 记录结束后校验“初始库存减成功下单数等于剩余库存”。如果库存扣减逻辑不是原子的stockAfter 会小于预期值测试直接失败。这个测试跑通后记得把结果截图放进论文的系统测试章比单纯写“经测试系统运行正常”有说服力得多。答辩现场如果老师追问“你怎么验证并发正确性”直接打开这段测试跑一遍就是最好的回答。这类测试跑之前有一个重要前提测试数据要干净。我习惯在测试方法里先清理指定商品的脏订单再重置库存避免上一次运行的失败数据干扰本次结果。为了省事也可以在 application-test.yml 里单独配置一个测试库和开发库分开这样不管怎么造数据都不影响正常开发。备份方面开发过程中我会定期用 mysqldump 导出一份 SQL 放项目根目录的 docs 文件夹改错表结构时随时可以原地恢复也算给自己留了后悔药。这个项目做完后我最深的体会是它的难点从来不在于“用 Java 写一个系统”而在于把成团这条业务链路拆开、想清楚、用对每一步的并发控制。当你把并发测试跑绿的那一刻心里会对整个系统踏实很多这种底气在答辩现场比背任何八股都有用。希望帮到你。本文还有配套的精品资源点击获取