Java二手车拍卖系统实战:并发出价、乐观锁与延时竞拍

发布时间:2026/10/24 0:48:24
Java二手车拍卖系统实战:并发出价、乐观锁与延时竞拍

简介:这是一套基于Java实现的二手车交易拍卖系统毕业设计程序,面向计算机相关专业需要完成毕设或课程设计的学生,也适合希望参考完整管理系统开发流程的Java学习者。资源压缩包约10.96MB,内含可运行的程序源代码一份与配套数据库文件,目录围绕系统源码与数据脚本组织,具体配置环境在包内说明。已有2281人学习下载,说明其在同类毕设资料中具有一定参考热度。内容覆盖需求分析、系统功能与总体结构设计、数据结构及安全设计,并深入数据库访问实现、主要功能模块的具体实现与关键代码,最后给出功能测试及结果分析。读者可据此梳理二手车拍卖业务的模块划分、数据库表关系、数据库访问与测试思路,获得一套结构完整、可运行验证的毕设实现方案,便于对照学习与二次调整。

1. 从二手车拍卖的实际流程切入:这套 Java 系统能解决什么

二手车拍卖跟普通电商最大的区别不在商品,在“时间”和“唯一性”:一辆车只有一个成交名额,同一秒可能有十几个人点出价,谁的钱先落库、谁的出价被顺延、谁的保证金该退,全是硬约束。这套基于 Java 的二手车交易拍卖系统,就是把这些约束落成可运行代码的一套完整程序,覆盖车辆发布、场次管理、实时出价、延时竞拍、保证金冻结、订单结算和管理端审核。它适合三类人:拿它做课程设计或毕设答辩的演示系统,需要一份能跑通全链路的骨架;想练手 Spring Boot 业务分层的中级开发者;以及要快速验证拍卖类业务模型的产品侧同学。读完你应该能判断它值不值得下载、自己改哪几个点就能跑起来。

2. 技术栈与工程分层:Spring Boot + MyBatis-Plus 的骨架怎么搭

选型这部分先说结论:拍卖系统是典型的“读多写少但写极敏感”的业务,写操作集中在出价和结算两个点上,读写比例大致 20:1。这意味着框架不需要多花哨,但事务边界和锁的粒度必须清楚。纯 Servlet + JDBC 能跑,但你要自己写连接池管理、参数绑定、异常统一处理,改一个字段要动五个文件,维护成本会把调试时间吃掉。Spring Boot 的价值在这里不是“流行”,而是把事务注解、参数校验、全局异常这三件事收敛成声明式配置,让你把注意力放回竞价逻辑本身。

2.1 依赖清单与版本口径

下面这份pom.xml片段是我一般会用的最小集合,去掉了一切非必要组件。注意 MyBatis-Plus 的版本要和 Spring Boot 主版本对齐,3.5.x 配 Boot 2.7.x 是经过大量项目验证的组合,跨大版本容易在分页插件上翻车。

<dependencies> <!-- Web 层:REST 接口 + Thymeleaf 管理端页面 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> <!-- 持久层:MyBatis-Plus 省掉 80% 单表 CRUD --> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <version>8.0.33</version> </dependency> <!-- 参数校验:@NotNull / @Min 直接作用在 DTO 上 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <!-- JWT:管理端和用户端分开鉴权 --> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <!-- 工具类:金额计算、日期格式化、MD5 --> <dependency> <groupId>cn.hutool</groupId> <artifactId>hutool-all</artifactId> <version>5.8.20</version> </dependency> </dependencies>

这份清单背后的取舍是:不引入 Redis 做分布式锁。毕设级和个人项目基本都是单机部署,MySQL 的行锁加乐观锁足够撑住几十并发的出价场景,多引一个中间件只会让部署环节多一个失败点。等你真到了多实例部署,再把出价入口换成 Redis 的SETNX加 Lua 脚本,改动面也就一个 Service 方法。

2.2 配置文件与启动顺序

配置文件里最容易被忽略的是时区和连接池。时间字段一旦跨时区,拍卖结束时间会莫名其妙提前 8 小时,这种问题排查起来极其耗时。

server: port: 8080 servlet: context-path: / spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://127.0.0.1:3306/auction_car?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: 123456 hikari: maximum-pool-size: 20 # 出价接口是短事务,20 足够 minimum-idle: 5 connection-timeout: 30000 servlet: multipart: max-file-size: 10MB # 车辆图片单张上限 max-request-size: 50MB mybatis-plus: configuration: map-underscore-to-camel-case: true # car_id -> carId log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 调试期开,上线关 global-config: db-config: id-type: auto logic-delete-field: deleted # 全局逻辑删除字段 logic-delete-value: 1 logic-not-delete-value: 0

连接池上限设 20 而不是默认的 10,是因为出价接口在压测时会出现“等连接”而非“等锁”的假死,现象是接口超时但数据库 CPU 很低,血泪经验是先把池子调大再怀疑 SQL。启动顺序上,先跑建表 SQL,再mvn spring-boot:run,最后访问/admin/login。如果启动报Table 'auction_car.sys_user' doesn't exist,说明建表脚本没执行或库名对不上,别急着改代码。

2.3 六张核心表与字段口径

拍卖系统的数据模型,核心是“场次”和“出价”两张表的关系。场次存当前最高价和领先者,出价表存全量流水,两者是冗余关系——场次表是为了读快,出价表是为了可追溯和争议处理。

表名作用关键字段说明
sys_user用户/管理员id,username,role,real_name_statusrole取值 0 普通、1 管理员;未实名不能出价
car_vehicle车辆档案id,brand,model,reg_date,mileage,seller_id,audit_status里程数用 int 存公里,reg_date用 date
auction_item拍卖场次id,car_id,start_price,current_price,step_price,start_time,end_time,status,versionstatus:0 待开始、1 竞价中、2 已结束、3 已流拍
bid_record出价流水id,auction_id,user_id,bid_price,bid_time,request_idrequest_id做幂等,唯一索引
au_deposit保证金id,user_id,auction_id,amount,statusstatus:0 冻结、1 已退、2 已抵扣
au_order成交订单id,auction_id,buyer_id,deal_price,pay_statusauction_id唯一索引,防重复落单

金额一律用decimal(12,2),理由在第 5 章展开。auction_item.version是乐观锁字段,MyBatis-Plus 的@Version注解能自动接管它,但前提是你用updateById而不是自己写 XML 的 update 语句,这一点后面会展开。

3. 拍卖核心链路:出价校验、延时竞拍与订单落库

这一章是整个系统最关键的部分。出价接口写得好不好,直接决定系统在并发下会不会出现“一辆车两个人成交”或者“价格被覆盖”的事故。我见过不少类似项目把出价简化成一条UPDATE auction_item SET current_price = ?,单机演示没问题,一旦两个人同时点,后写的直接覆盖前面的,而且不留痕迹。

3.1 出价接口的六道校验与乐观锁

先看校验顺序。顺序不是随便排的,便宜的判断放前面,能避免无谓的数据库往返;涉及金额和身份的放后面,因为要查表。

@Service public class BidService { @Resource private AuctionItemMapper auctionMapper; @Resource private BidRecordMapper bidMapper; @Resource private DepositMapper depositMapper; /** * 出价核心方法 * @param auctionId 场次 ID * @param userId 出价人 * @param bidPrice 出价金额(前端传入,服务端必须重校验) * @param requestId 前端生成的 UUID,用于幂等 */ @Transactional(rollbackFor = Exception.class) public BidResult placeBid(Long auctionId, Long userId, BigDecimal bidPrice, String requestId) { // 1. 幂等拦截:同一 requestId 只允许落一条流水 Long dup = bidMapper.selectCount( new LambdaQueryWrapper<BidRecord>().eq(BidRecord::getRequestId, requestId)); if (dup > 0) { throw new BizException("请勿重复提交"); } // 2. 场次必须存在且处于竞价中 AuctionItem item = auctionMapper.selectById(auctionId); if (item == null || item.getStatus() != 1) { throw new BizException("该场次不在竞价中"); } // 3. 时间窗校验:服务端时间为准,不信任前端 LocalDateTime now = LocalDateTime.now(); if (now.isBefore(item.getStartTime()) || now.isAfter(item.getEndTime())) { throw new BizException("不在竞价时间段内"); } // 4. 卖家不能拍自己的车 CarVehicle car = carMapper.selectById(item.getCarId()); if (car.getSellerId().equals(userId)) { throw new BizException("不能竞拍自己发布的车辆"); } // 5. 保证金必须已冻结 Long depositOk = depositMapper.selectCount(new LambdaQueryWrapper<AuDeposit>() .eq(AuDeposit::getUserId, userId) .eq(AuDeposit::getAuctionId, auctionId) .eq(AuDeposit::getStatus, 0)); if (depositOk == 0) { throw new BizException("请先缴纳保证金"); } // 6. 出价必须高于 当前价 + 最小加价幅度 BigDecimal minAccept = item.getCurrentPrice().add(item.getStepPrice()); if (bidPrice.compareTo(minAccept) < 0) { throw new BizException("出价不得低于 " + minAccept); } // 7. 乐观锁更新:version 不匹配则说明被别人抢先 int rows = auctionMapper.update(null, new LambdaUpdateWrapper<AuctionItem>() .set(AuctionItem::getCurrentPrice, bidPrice) .set(AuctionItem::getCurrentWinnerId, userId) .set(AuctionItem::getVersion, item.getVersion() + 1) .set(AuctionItem::getEndTime, extendIfNeeded(item, now)) // 延时逻辑见 3.3 .eq(AuctionItem::getId, auctionId) .eq(AuctionItem::getVersion, item.getVersion()) .eq(AuctionItem::getStatus, 1)); if (rows == 0) { throw new BizException("出价已被他人抢先,请刷新后重试"); } // 8. 落流水 BidRecord record = new BidRecord(); record.setAuctionId(auctionId); record.setUserId(userId); record.setBidPrice(bidPrice); record.setBidTime(now); record.setRequestId(requestId); bidMapper.insert(record); return BidResult.ok(bidPrice); } }

这段代码里有三个地方值得单独说。第一,幂等用request_id唯一索引兜底,代码里查一次只是为了让用户看到友好提示,真正的防线是数据库唯一索引,即使并发穿透了应用层的查询,插入也会失败回滚。第二,eq(AuctionItem::getStatus, 1)这个条件是防御性的,防止在查和更新之间场次被定时任务结算掉,属于“多写一个条件少一次事故”的典型。第三,update语句里同时改了价格、领先者、版本号和结束时间,是一条语句完成的,不存在中间态被别人读到的情况。

参数方面,step_price建议按车价分档设:10 万以下的车设 500,10 到 30 万设 1000,30 万以上设 2000。这个字段放在场次表而不是全局配置,是因为同一批次的车可能加价规则不同,写到全局配置里后期改起来要动代码。

3.2 拍卖结束的调度与幂等结算

结算最容易出的问题是重复落单。定时任务每 10 秒扫一次,如果某次执行超过 10 秒,下一次会并发跑起来,同一场次被结算两次,生成两张订单。解决办法是把“改状态”作为结算的前置条件,用条件更新抢锁。

@Component public class AuctionSettleTask { @Resource private AuctionItemMapper auctionMapper; @Resource private OrderService orderService; /** 每 10 秒扫描一次到期场次,fixedDelay 保证上一轮跑完才开下一轮 */ @Scheduled(fixedDelay = 10_000L, initialDelay = 15_000L) public void settleExpired() { List<AuctionItem> list = auctionMapper.selectList(new LambdaQueryWrapper<AuctionItem>() .eq(AuctionItem::getStatus, 1) .le(AuctionItem::getEndTime, LocalDateTime.now()) .last("limit 100")); // 分批,避免一次拉太多 for (AuctionItem item : list) { // 抢占式改状态:只有把 1 改成 2 成功的那个线程才继续结算 int locked = auctionMapper.update(null, new LambdaUpdateWrapper<AuctionItem>() .set(AuctionItem::getStatus, 2) .eq(AuctionItem::getId, item.getId()) .eq(AuctionItem::getStatus, 1)); if (locked == 0) { continue; // 已被其他线程结算,直接跳过 } if (item.getCurrentWinnerId() == null) { auctionMapper.update(null, new LambdaUpdateWrapper<AuctionItem>() .set(AuctionItem::getStatus, 3) .eq(AuctionItem::getId, item.getId())); continue; // 无人出价,置为流拍 } orderService.createOrder(item); } } }

fixedDelay和fixedRate的区别这里必须分清:fixedRate按固定频率触发,任务执行时间超过间隔时会堆积并发;fixedDelay是上一轮结束后再等 10 秒,天然串行。毕设项目里用fixedDelay就能规避掉大部分重复结算,再叠加状态条件更新,基本万无一失。limit 100是防止某次积压了几千场次导致单轮执行过久,分批次处理更稳。

订单表上还要加一个uk_order_auction唯一索引(auction_id单列),这是最后一道防线。应用层逻辑再严密也可能因为将来加了新的结算入口而出错,唯一索引能在数据库层面直接拒绝重复插入,报错信息也比脏数据好排查。

3.3 延时竞拍:最后三分钟出价顺延的边界

延时竞拍是拍卖类系统的标配,作用是防止“最后一秒狙击”。规则一般写成:结束前 N 分钟内有新出价,结束时间往后顺延 M 分钟。N 和 M 我一般取 3 和 5,也见过取 5 和 5 的,看业务习惯。

/** * 判断是否需要顺延结束时间 * @param item 当前场次 * @param bidTime 本次出价时间 * @return 顺延后的结束时间;不需要顺延时返回原值 */ private LocalDateTime extendIfNeeded(AuctionItem item, LocalDateTime bidTime) { long secondsLeft = Duration.between(bidTime, item.getEndTime()).getSeconds(); if (secondsLeft > 0 && secondsLeft <= 180) { // 剩余 3 分钟内 return item.getEndTime().plusMinutes(5); // 顺延 5 分钟 } return item.getEndTime(); }

这里有个容易被忽略的点:顺延要设上限。如果一直有人出价,理论上可以无限延长,实际业务里通常会限制最多顺延 3 次或总时长不超过 2 小时。实现方式是在场次表加一个extend_count字段,每次顺延加一,超过阈值就不再顺延。不加这个限制,测试阶段会出现“怎么也结束不了”的诡异现象,第一次遇到会以为是定时任务挂了,其实是自己的延时逻辑在无限触发。

另一个细节是secondsLeft > 0这个判断。如果不加,场次已经过期但还没被结算的那几秒内有人出价,secondsLeft是负数,plusMinutes会算出一个更早的时间,等于把结束时间往前推,越推越乱。

4. 交易侧功能落地:车辆发布、保证金与后台审核

核心竞价跑通之后,剩下的功能看着琐碎但直接影响系统能不能演示完整。这一章按“卖家发布 → 平台审核 → 买家缴保证金 → 参与竞拍”的实际使用顺序展开,每一步给出可运行的接口和需要注意的参数。

4.1 车辆发布与图片上传的路径处理

车辆信息包含品牌、型号、上牌日期、里程、排量、变速箱、车况描述和一组图片。图片存储是这类项目最常见的翻车点:本地开发用D:/upload/写死绝对路径,换台机器或部署到服务器就 404。正确的做法是路径配置化,通过资源映射对外暴露。

@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Value("${app.upload.path}") private String uploadPath; // 例如 /data/upload/ 或 D:/upload/ /** 把磁盘目录映射成可访问的 URL 前缀 */ @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadPath); } }
@PostMapping("/car/publish") public Result<Long> publish(@Valid @RequestBody CarPublishDTO dto, HttpServletRequest req) { Long userId = (Long) req.getAttribute("userId"); CarVehicle car = new CarVehicle(); car.setBrand(dto.getBrand()); car.setModel(dto.getModel()); car.setRegDate(dto.getRegDate()); // LocalDate,格式 yyyy-MM-dd car.setMileage(dto.getMileage()); // 单位公里,int car.setSellerId(userId); car.setAuditStatus(0); // 0 待审核,只有 1 才能上架 car.setImages(String.join(",", dto.getImageUrls())); carMapper.insert(car); return Result.ok(car.getId()); }

图片上传接口本身要注意两件事:一是文件名用 UUID 重命名,别用原始文件名,中文名和特殊字符在部分容器里会出问题;二是限制扩展名白名单(jpg、jpeg、png、webp),不要只靠前端accept属性,那个随手就能绕过。上传成功后返回相对路径/upload/2024/06/xxx.jpg,前端拼接域名展示,这样换域名不用改数据库。

audit_status字段控制车辆能否上架,值为 0 时管理端能看到,用户端列表查不到。这个字段别省,很多同类项目直接发布即上架,答辩时被问“虚假车源怎么处理”就答不上来。

4.2 保证金冻结与退回的状态流转

保证金是拍卖系统的信用基础。设计上分三个状态:0 冻结、1 已退、2 已抵扣。缴纳时插入一条冻结记录,竞拍失败退回到 1,成交后从冻结转抵扣或在支付尾款时一并处理。

@Transactional(rollbackFor = Exception.class) public void payDeposit(Long userId, Long auctionId, BigDecimal amount) { // 同一场次同一用户只能有一笔有效保证金 Long exist = depositMapper.selectCount(new LambdaQueryWrapper<AuDeposit>() .eq(AuDeposit::getUserId, userId) .eq(AuDeposit::getAuctionId, auctionId) .ne(AuDeposit::getStatus, 1)); if (exist > 0) { throw new BizException("该场次已缴纳保证金"); } AuctionItem item = auctionMapper.selectById(auctionId); if (item.getStatus() != 0 && item.getStatus() != 1) { throw new BizException("该场次不接受保证金"); } AuDeposit d = new AuDeposit(); d.setUserId(userId); d.setAuctionId(auctionId); d.setAmount(amount); d.setStatus(0); d.setCreateTime(LocalDateTime.now()); depositMapper.insert(d); // 冻结逻辑:从用户账户余额扣减,这里用账户表减余额 + 冻结额加值 accountMapper.freeze(userId, amount); }

ne(AuDeposit::getStatus, 1)这个条件的意思是把“已退回”的记录排除在外,允许用户退款后再次缴纳。如果写成eq(status, 0),逻辑上也对,但将来加“已过期”状态时会漏掉。状态判断尽量用“排除法”而不是“枚举法”,扩展性更好。

保证金金额一般设起拍价的 5% 到 10%,最低不低于 500 元。这个值放在场次表里跟着车走,不要全局写死,因为高档车的保证金要求明显更高。

4.3 管理端审核与接口鉴权

管理端负责三件事:车辆审核、场次创建、异常订单处理。鉴权用 JWT 加拦截器,普通用户和管理员走同一套 token 机制,靠role字段区分。

public class AuthInterceptor implements HandlerInterceptor { @Resource private JwtUtil jwtUtil; @Override public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) throws Exception { String token = req.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { resp.setStatus(401); return false; } try { Claims claims = jwtUtil.parse(token.substring(7)); req.setAttribute("userId", Long.valueOf(claims.getSubject())); req.setAttribute("role", claims.get("role", Integer.class)); return true; } catch (Exception e) { resp.setStatus(401); // token 过期或签名不符统一 401 return false; } } }

管理员专属接口再加一层判断,比如在AdminController的类级别拦截里校验role == 1。不要只靠前端隐藏菜单,接口必须独立鉴权,这是安全底线。审核动作本身很简单:update car_vehicle set audit_status = 1 where id = ? and audit_status = 0,加上audit_status = 0的条件是为了防止重复审核覆盖掉拒绝原因。

5. 避坑与排查:这套系统最容易出问题的五处

这一章全是实际调试中撞出来的,每条按现象、原因、解决三段写,对照排查能省掉很多时间。

5.1 出价成功但价格不涨

现象:接口返回成功,bid_record也插入了,但刷新页面价格还是旧的。

原因:auction_item的更新语句里漏了version的自增,或者用了updateById(item)但item是从缓存里取的旧对象,@Version注解虽然生效但比对的是旧版本号,更新影响行数为 0 却没人检查。

解决:所有涉及乐观锁的更新,必须判断rows,为 0 时抛异常回滚。同时确认 XML 里写的是version = version + 1,而不是set version = #{version}。

5.2 金额出现 0.1 + 0.2 = 0.30000000000000004

现象:加价后价格出现一长串小数,或者两个用户出价 1000.10 和 1000.20,比较时判定后者不高于前者。

原因:实体类用了double或float,数据库字段用了float。浮点数二进制表示无法精确表达十进制小数。

解决:Java 侧一律BigDecimal,数据库一律decimal(12,2),比较用compareTo不用equals(equals会比 scale,1000.0和1000.00不相等)。这个坑我在三个项目里都见过,属于必踩。

5.3 定时任务重复生成订单

现象:同一场次出现两条au_order记录,或者日志里同一场次结算了两次。

原因:多实例部署或fixedRate导致任务重叠,状态更新和订单插入之间没有原子性。

解决:fixedRate换fixedDelay;改状态用条件更新抢锁;订单表加auction_id唯一索引兜底。

5.4 时间字段整体偏移 8 小时

现象:设定的结束时间是 20:00,实际 12:00 就结束了。

原因:JDBC URL 没写serverTimezone,或者实体用了java.util.Date配@JsonFormat时区没指定。

解决:URL 加serverTimezone=Asia/Shanghai,实体统一用LocalDateTime,Jackson 全局配置spring.jackson.time-zone=GMT+8和date-format=yyyy-MM-dd HH:mm:ss。

5.5 分页查询返回全表数据

现象:前端传page=1&size=10,返回了 500 条。

原因:MyBatis-Plus 的分页插件没注册,Page对象被当成普通参数忽略,SQL 里没有limit。

解决:加一个MybatisPlusInterceptor配置类,注册PaginationInnerInterceptor(DbType.MYSQL)。这个配置不写编译不报错,运行时才出问题,属于典型的静默失败。

6. 并发验证:用一个脚本把出价链路压到边界

功能跑通不代表正确,拍卖系统的正确性只有在并发下才暴露。我的习惯是上线前用脚本模拟 20 个用户同时抢一辆车,然后核对三件事:最终价格是否等于最高出价、bid_record条数是否等于成功请求数、au_order是否只有一条。下面这段 Python 脚本不依赖 JMeter,装个requests就能跑,改两个参数就能复用到不同场次。

import threading, uuid, requests, time BASE = "http://127.0.0.1:8080" AUCTION_ID = 1001 TOKENS = [f"token_{i}" for i in range(20)] # 预先登录拿到的 20 个用户 token results = [] lock = threading.Lock() def bid(token, price): headers = {"Authorization": f"Bearer {token}"} body = { "auctionId": AUCTION_ID, "bidPrice": price, "requestId": str(uuid.uuid4()) # 每次请求唯一,模拟正常用户 } r = requests.post(f"{BASE}/api/bid/place", json=body, headers=headers, timeout=5) with lock: results.append((token, price, r.status_code, r.text[:60])) # 用 Barrier 让 20 个线程尽可能同时发出 barrier = threading.Barrier(20) def worker(i): barrier.wait() bid(TOKENS[i], 50000 + i * 1000) threads = [threading.Thread(target=worker, args=(i,)) for i in range(20)] [t.start() for t in threads] [t.join() for t in threads] ok = [r for r in results if r[2] == 200] print(f"成功 {len(ok)} / 总 {len(results)}") print("最高成功价:", max([r[1] for r in ok], default=0))

脚本的关键在threading.Barrier,它让 20 个线程在同一个瞬间释放,比顺序发请求更容易撞出并发问题。如果只是for循环发请求,服务端处理太快,根本压不出竞争。requestId每次都用新 UUID,是模拟正常用户;如果想验证幂等,把uuid.uuid4()换成固定字符串,重复发 20 次,正确结果是只有 1 次成功。

跑完脚本后执行这三条 SQL 核对:

-- 1. 最终价格应等于最高成功出价 SELECT current_price, current_winner_id, version FROM auction_item WHERE id = 1001; -- 2. 流水条数应等于成功请求数 SELECT COUNT(*) FROM bid_record WHERE auction_id = 1001; -- 3. 订单只能有一条 SELECT COUNT(*) FROM au_order WHERE auction_id = 1001;

我第一次做这类验证时,20 个并发里有 3 个请求返回成功但价格被覆盖,version字段只涨了 17。原因就是当时更新语句里漏了eq(version)条件,直接updateById,MyBatis-Plus 的乐观锁插件只在updateById生效,而我当时用的是自定义 XML 的 update,注解等于没生效。后来养成的习惯是:凡是带乐观锁的表,更新方法统一走LambdaUpdateWrapper并显式写eq(getVersion, oldVersion),不依赖注解的隐式行为,也不写裸的updateById。这行代码多写 20 个字符,能省掉一整晚的排查。

压测的另一个价值是暴露连接池配置问题。20 并发下如果出现大量Connection is not available, request timed out,先看 Hikari 的maximum-pool-size,再看每个事务里有没有在事务内调外部接口(比如发短信、调图片处理),有的话把外部调用挪到事务外,事务只包数据库操作。这个调整通常能把接口 P99 从 800ms 压到 100ms 以内。

最后提醒一句,压测数据和真实数据要分开,脚本跑完记得清掉测试场次和流水,不然演示时列表里全是 50000 起的假车源。希望帮到你。

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