简介:这是基于Java技术设计与实现的二手车交易拍卖管理系统,面向计算机相关专业的毕业设计场景。系统围绕车辆信息发布、拍卖竞价、订单管理及后台审核等核心业务模块展开,功能边界清晰,适合需要完成类似课设或毕设的在校学生参考借鉴。开发过程涵盖需求分析、总体设计、详细设计与功能测试等完整环节,总体设计覆盖功能、结构、数据结构与安全设计,详细设计则深入数据库访问实现、核心模块代码逻辑及关键代码说明,可帮助读者理解从业务建模到编码落地的完整路径。资源以zip压缩包发布,大小约10.96MB,内含可运行的Java源代码与数据库脚本,并随附配置环境说明,部署后即可验证各模块功能是否符合预期。目前已有2281人浏览学习,项目结构清晰、注释完整,适合作为毕业答辩、课程设计及项目复现的参照模板。
1. 二手车交易拍卖系统到底在做什么:一个时序系统,而不是 CRUD 堆砌
如果你把“二手车交易拍卖系统”当成又一个增删改查的毕设题目,那就低估它了。普通的商品管理系统,核心是数据的写入和读取;而拍卖系统多了一个“时序”维度——车辆在什么时间上架、用户什么时候出价、倒计时还剩几秒、成交后订单怎么闭环。多出来的这一层时序,才是面试官在问“你这个项目有什么难点”时真正想听的东西。这个方向特别适合 Java 方向的毕业生、想补全项目经验的转行者,以及需要在简历上写出“并发与状态流转”的求职者。整套系统里既有传统的 CRUD 模块,又有高并发竞价、定时关拍、支付回调等真实业务场景,做完之后你对 Spring Boot、Redis、状态机的理解会完全不一样。
我见过太多人把毕设做成“用户表 + 商品表 + 订单表”的三张表堆砌,答辩时老师问一句“同时出价怎么办”就卡住了。二手车拍卖系统恰好能把这些黑匣子打开:出价要防超卖、倒计时要防作弊、成交后要应付毁单。下面这套方案是我基于 Spring Boot + Redis + MySQL 的落地做法,新手能按步骤抄,熟手可以跳过环境搭建直接看并发和补偿的部分。
2. 技术选型和数据建模:为什么 Spring Boot + Redis + WebSocket 能打穿一场拍卖
2.1 技术栈选型:毕设项目最稳的组合不是最新,而是最不容易翻车
二手车拍卖系统这类项目,技术选型的核心矛盾是“答辩要有亮点”和“你一个人写得完”。我一般会把技术栈拆成四层,各自承担不同的职责:
| 层级 | 技术选型 | 承担职责 |
|---|---|---|
| 接口层 | Spring Boot 2.7 / 3.x + MyBatis-Plus | 提供 REST API,处理业务逻辑 |
| 实时层 | Redis + WebSocket | 存拍卖中的实时状态、广播最新出价 |
| 存储层 | MySQL 8.x | 存用户、车辆、订单、成交记录等最终数据 |
| 前端 | Vue 3 + Element Plus | 管理后台 + 用户端竞价页 |
这里有个经验:不要把拍卖的“当前价”“剩余时间”放在 MySQL 里频繁更新。MySQL 的每一行更新都要走事务、锁、Redo Log,几十个人同时出价时,数据库的锁等待会让你在答辩现场表演卡死。我常用的做法是拍卖进行中把当前价、出价次数、结束时间放在 Redis 里,等落锤或者流拍之后再一次性写回 MySQL。这个“热数据走缓存、冷数据落库”的思路,既是毕业设计的加分项,也是面试八股文里反复提到的 java 怎么保证数据一致性 问题的实际应用。
另一个看起来不重要但实际很关键的选型是 JDK 版本。不要一上来就装 JDK 21,很多学校的答辩环境还是 JDK 8,而 Spring Boot 3.x 要求 JDK 17 起步。我建议要么用 JDK 8 + Spring Boot 2.7,要么用 JDK 17 + Spring Boot 3.x,别混搭。环境变量配置的问题通常不在 JDK 本身,而在于你同时装了多个版本,JAVA_HOME 指向了旧版。
2.2 数据库表设计:九张表还是十张表,取决于你有没有设计状态机
二手车拍卖系统的表结构比普通商城多一点,但也不至于失控。我按自己的项目经验列一份最常用的清单,一共九张表:
| 表名 | 作用 | 关键字段 |
|---|---|---|
| user | 用户(买家、卖家、管理员) | id, username, password, role, balance |
| car | 二手车源 | id, seller_id, brand, model, year, price, status |
| car_image | 车源图片 | id, car_id, url, sort |
| auction | 拍卖场次 | id, car_id, start_price, current_price, start_time, end_time, status |
| bid_record | 出价记录 | id, auction_id, user_id, bid_price, bid_time |
| order_ | 成交订单 | id, auction_id, buyer_id, seller_id, deal_price, status |
| payment | 支付流水 | id, order_id, user_id, amount, status, callback_time |
| car_inspection | 车检报告 | id, car_id, inspector, score, report_url |
| message_log | 站内消息/出价广播日志 | id, auction_id, content, create_time |
这些表的字段不需要做到生产级那么复杂,毕设评审关注的是你有没有“状态”的概念。几乎所有表都应该有一个 status 字段,而且 status 的取值要能用状态机描述清楚。不要在一张表里既存车辆信息又存拍卖信息,那会让你的 SQL 越写越长,最后自己都看不懂。我的经验是:car 表只管车本身的信息和上架状态,auction 表专门管拍卖过程,order 和 payment 只管成交后的流程。
2.3 拍卖状态机设计:状态流转是整套系统的骨架
拍卖系统最容易写乱的部分,就是状态流转。一个车源从卖家发布到最终成交(或流拍),要经过下面这几步:
WAIT(待开始) -> RUNNING(竞拍中) -> TIME_OVER(时间到,未有成交) -> FINISHED(已成交/流拍)竞拍中如果有人出价且倒计时结束,进入成交确认;如果倒计时结束且没人出价(或者没人出到保留价),进入流拍。这里有一个很容易被忽略的设计:拍卖结束后不是立刻成交,而是先进入人工确认或自动确认的中间态,防止出现最后两秒一个高价单没有支付能力的情况。订单状态同样要独立:
CREATED(已创建) -> UNPAID(待支付) -> PAID(已支付) -> DEALED(完成过户) -> CLOSED(超时关闭)写代码时强烈建议用枚举类把这些状态定义出来,而不是用散落的 int 常量。用 int 常量写到最后就是满屏的if (status == 1),你在答辩时很难说清楚为什么 1 是待开始而不是已成交。用枚举类的好处是两个:第一,代码可读性高,写if (auctionStatus == AuctionStatus.RUNNING)谁都能看懂;第二,枚举可以携带状态机的流转边界,比如只允许 WAIT -> RUNNING 这个方向,禁止跳变。
public enum AuctionStatus { WAIT(0, "待开始"), RUNNING(1, "竞拍中"), TIME_OVER(2, "时间到"), FINISHED(3, "已结束"), FLOWED(4, "已流拍"); private final int code; private final String desc; AuctionStatus(int code, String desc) { this.code = code; this.desc = desc; } // getter... }这个枚举类的代码本身不难,难的是你要在 Service 层所有的业务方法里用同一套枚举去判断。发布车源时把拍卖状态初始化为 WAIT,到了预设的开始时间切换为 RUNNING,结束时间到了统一调度去判断是成交还是流拍。状态机一旦定义清楚,后面的竞价逻辑、订单生成逻辑都会顺着这条主线走,不会出现“订单生成了但拍卖还是 RUNNING”这种前后台对不上的问题。
数据建模做完,到了真正写代码的阶段,你会发现最大的挑战不再是建表,而是出价这个动作本身。下一章我会把从发布车源到出价落锤的整个链路拆开,每一步都给出可以直接抄的 Java 代码。
3. 核心流程的实现:从发布车源到竞价落锤的最小闭环
3.1 发布车源和创建拍卖场次:一个事务完成两件事
业务流程是这样:卖家填写车辆信息(品牌、型号、里程、车检报告),后台管理员审核通过后,发布车源挂到拍卖列表。发布车源这个动作,如果只往 car 表插一行,拍卖列表怎么知道这辆车要不要展示?所以发布车源一定要和创建拍卖场次绑定在同一个事务里。
@Transactional(rollbackFor = Exception.class) public Long publishCarWithAuction(Car car, BigDecimal startPrice, BigDecimal floorPrice, LocalDateTime startTime, LocalDateTime endTime) { // 1. 插入车辆基本信息,status 为 PENDING car.setStatus(CarStatus.PENDING); carMapper.insert(car); // 2. 创建拍卖场次,初始状态为 WAIT Auction auction = new Auction(); auction.setCarId(car.getId()); auction.setStartPrice(startPrice); auction.setCurrentPrice(startPrice); auction.setFloorPrice(floorPrice); // 保留价,低于此价不成交 auction.setStartTime(startTime); auction.setEndTime(endTime); auction.setStatus(AuctionStatus.WAIT); auctionMapper.insert(auction); // 3. 预写入 Redis 拍卖缓存 String key = "auction:" + auction.getId(); redisTemplate.opsForHash().putAll(key, Map.of( "currentPrice", startPrice.toString(), "floorPrice", floorPrice.toString(), "status", String.valueOf(AuctionStatus.WAIT.getCode()), "endTime", endTime.toString() )); redisTemplate.expire(key, Duration.ofDays(3)); return auction.getId(); }这段代码的逻辑是:先插车辆,再建场次,最后预热 Redis 缓存。三个动作放在一个事务里,前面任何一步失败,整个事务回滚,不会出现“车发布了但拍卖没建”的脏数据。参数说明:startPrice是起拍价,floorPrice是保留价,注意两者有区别——出价可以低于保留价,但最终成交时如果最高出价没到保留价,系统判定流拍。这是二手车拍卖和普通商品竞价的典型差异。
这里我用了 Redis Hash 结构而不是 String,因为一场拍卖有多个属性(价格、状态、结束时间),Hash 能单独更新其中某个字段,而且后面出价时可以直接用HINCRBY或 Lua 脚本做原子操作。你可能觉得预热 Redis 不是必须的,但实际我遇到过一个情况:拍卖开始前用户刷新列表,所有请求都穿透到 MySQL 查最新价格,DB 瞬间扛不住。提前把拍卖状态放到 Redis,列表页只查缓存,压力小很多。
3.2 出价竞价的并发控制:别让两条出价把数据改丢
出价是拍卖系统的灵魂,也是并发问题最集中的地方。每个用户点击出价时,系统要做三件事:检查当前出价是否高于最高价、扣减用户保证金额度、把出价记录写入 bid_record。这三件事如果不加控制,两个用户同时出相同价格,就可能出现“覆盖写”导致一人出价成功但另一人也被扣了钱。这个问题对应 java 并发面试题里经典的“并发原子性”问题,也是 java 怎么保证数据一致性 的核心考点。
常见的方案有两种:一种是synchronized或Lock,简单粗暴但只对单机有效;另一种是利用 Redis 的 Lua 脚本原子执行,这也是我推荐的做法,因为拍卖系统必然要上 Redis,用 Lua 顺手解决并发问题,是性价比最高的选择。
// 出价接口:先走Redis Lua脚本保证原子性,再异步落库 public BidResult bid(BidRequest request) { Long auctionId = request.getAuctionId(); BigDecimal price = request.getPrice(); // Lua脚本:检查出价是否高于当前价,是则更新Redis中的currentPrice String luaScript = "local current = redis.call('HGET', KEYS[1], 'currentPrice') " + "if not current then return -1 end " + "if tonumber(ARGV[1]) <= tonumber(current) then return 0 end " + "redis.call('HSET', KEYS[1], 'currentPrice', ARGV[1]) " + "return 1"; Long result = redisTemplate.execute( new DefaultRedisScript<>(luaScript, Long.class), Arrays.asList("auction:" + auctionId), price.toString() ); if (result == -1) { throw new BizException("拍卖不存在或已结束"); } if (result == 0) { throw new BizException("出价必须高于当前价"); } // 异步写MySQL,记录完整出价流水 bidAsyncService.saveBidRecord(auctionId, request.getUserId(), price); return new BidResult(price); }这段代码的关键在那个 Lua 脚本。tonumber(ARGV[1])比较的是出价和当前价,如果小于等于当前价直接返回 0,表示出价无效;只有高于当前价才执行HSET更新。整个比较和更新在 Redis 单线程模型下是原子性的,两个用户同时出价时,Redis 会逐个执行,不会出现覆盖写。异步落库是为了减少出价接口的响应时间,但要注意异步任务失败后要有补偿机制(后面避坑章节会讲)。
参数说明:KEYS[1]是拍卖的 Redis key,ARGV[1]是本次出价价格。使用 Lua 脚本时还有一个细节——脚本不能太长,Redis 的 EVAL 命令每次都要传整个脚本,如果脚本体积大,网络传输也会成为瓶颈。我一般在几百字节以内,只做核心原子判断,其余逻辑放到 Java 里。
有了 Redis 那层原子控制,MySQL 的出价记录写入就不太容易出现主键冲突或覆盖了。但还有一个问题没解决:出价成功后,前端的剩余时间显示怎么办?如果每个用户都从自己电脑的本地时间算剩余时长,有人把系统时间改慢,理论上可以一直等到最后几秒再出价。这里就轮到倒计时机制登场了。
3.3 拍卖倒计时的设计:把时间判定权放在服务端,而不是浏览器
倒计时是拍卖系统里最容易“翻车”的部分,原因有两个:一是浏览器本地时间不可信,二是 Redis 过期事件并不保证实时。我见过有人用redisTemplate.expire(key, 600, TimeUnit.SECONDS)监听KeyExpirationEventMessageListener来决定拍卖结束,但这个方案在 Redis 的过期淘汰策略下有一定延迟,而且如果 Redis 重启,未触发的过期键会丢失,拍卖就永远结束不了。
我的做法是双保险:服务端统一用数据库记录的end_time判定,同时用@Scheduled定时任务每分钟扫一次库,把到时间但状态还是 RUNNING 的拍卖改成 TIME_OVER。
@Component public class AuctionScheduleTask { @Resource private AuctionMapper auctionMapper; @Resource private RedisTemplate<String, String> redisTemplate; // 每30秒执行一次,兜底将超时拍卖置为TIME_OVER @Scheduled(fixedDelay = 30_000) public void closeExpiredAuctions() { List<Auction> overdueList = auctionMapper.selectList( new LambdaQueryWrapper<Auction>() .eq(Auction::getStatus, AuctionStatus.RUNNING) .lt(Auction::getEndTime, LocalDateTime.now()) .last("LIMIT 100") ); for (Auction auction : overdueList) { // 判断是否有人出价到保留价,决定成交还是流拍 auctionMapper.closeAuction(auction.getId(), AuctionStatus.TIME_OVER); redisTemplate.opsForHash().put("auction:" + auction.getId(), "status", "2"); } } }这段代码是 java 定时任务框架的典型用法,@Scheduled(fixedDelay = 30_000)表示上一次任务执行完 30 秒后再执行下一次。注意我用的是fixedDelay而不是fixedRate:fixedRate从上一次开始时间计时,如果任务执行超过 30 秒就会重叠;fixedDelay是完成后间隔 30 秒,不会重叠。虽然看起来差不多,但在数据库慢查询的时候,fixedDelay更安全,不会堆任务。
真正实操时还有个容易忽略的参数:last("LIMIT 100")。如果不加这个限制,一次性扫出几千条超时拍卖,每条都去更新数据库,会让 GC 压力陡增。分页批量处理是稳定性的一个细节。
倒计时结束后的状态切换不复杂,核心是要有兜底逻辑。我见过很多项目只在 WebSocket 连接断开时判断结束时间,一断网拍卖就卡住。服务端定时任务的好处是:即使前端全部断连,拍卖的状态也在按计划推进,用户重新连上后拿到的数据依然是准确的。很多同学在毕设答辩时被问“如果用户关闭了浏览器页面,拍卖还会继续吗”,答案就在这里。
4. 订单结算与状态恢复正常化:成交后的一串后悔药机制
4.1 成交确认与订单生成:别让买家拍到车却下不了单
当拍卖状态变为 TIME_OVER,系统需要判断是否有人出价达到保留价。这里有一个关键的边界:TIME_OVER 不等于成交,它只是一个中间态。代码里我用closeAuction方法同时做了两件事——更新拍卖状态、返回值用来判断是生成订单还是标记流拍。
@Transactional(rollbackFor = Exception.class) public void settleAuction(Long auctionId) { // 查询当前最高出价记录 BidRecord highestBid = bidRecordMapper.findHighestByAuctionId(auctionId); Auction auction = auctionMapper.selectById(auctionId); // 如果无人出价,或最高价低于保留价,则流拍 boolean isDeal = (highestBid != null) && highestBid.getBidPrice().compareTo(auction.getFloorPrice()) >= 0; if (isDeal) { // 创建订单:CREATED状态 Order order = new Order(); order.setAuctionId(auctionId); order.setBuyerId(highestBid.getUserId()); order.setSellerId(auction.getSellerId()); order.setDealPrice(highestBid.getBidPrice()); order.setStatus(OrderStatus.CREATED); orderMapper.insert(order); // 拍卖最终状态置为FINISHED auctionMapper.updateStatus(auctionId, AuctionStatus.FINISHED); } else { auctionMapper.updateStatus(auctionId, AuctionStatus.FLOWED); } }compareTo在这里是 java 比较大小的标准写法,用>= 0判断是否达到保留价。这个写法比highestBid.getBidPrice() >= auction.getFloorPrice()更严谨,因为BigDecimal的>运算符是不存在的,只有compareTo方法。很多刚开始写 java 的同学在这行代码上栽过跟头——把BigDecimal用==比较,结果两个价格明明一样大却是 false。
订单生成后,买家需要在规定时间内(比如 24 小时)支付定金或全款。这个 24 小时如果完全依赖人工,系统很快会出现“拍到车不付款”的脏数据。所以必须有定时任务把超时未支付的订单改成 CLOSED。这里就用到所谓的“后悔药机制”——拍卖成交后允许买家毁单,但要承受保证金扣减等代价。我通常的做法是把保证金字段加在 user 表上,出价时先冻结(冻结金额 = 起拍价的 5%),成交后解冻成功部分,毁单则扣除。
// 买家申请毁单(在支付前) @Transactional(rollbackFor = Exception.class) public PayResult cancelOrder(Long orderId, Long userId) { Order order = orderMapper.selectById(orderId); if (!order.getBuyerId().equals(userId)) { throw new BizException("只能操作自己的订单"); } if (!OrderStatus.CREATED.equals(order.getStatus()) && !OrderStatus.UNPAID.equals(order.getStatus())) { throw new BizException("当前订单状态不可取消"); } // 扣除保证金(或打入卖家账户),订单关闭 userService.deductBail(order.getBuyerId(), order.getDealPrice().multiply(new BigDecimal("0.05"))); orderMapper.updateStatus(orderId, OrderStatus.CLOSED); return PayResult.success("订单已关闭,保证金扣除5%"); }注意毁单操作是有状态限制的:只有 CREATED 和 UNPAID 才能取消,一旦进入 PAID 就必须走过户流程。这个限制也是状态机的价值所在——你不必在代码里到处写复杂的 if 判断,状态机本身就把合法路径限定好了。
4.2 定时状态补偿:所有异步操作都得有后悔药
异步是提高系统吞吐量的手段,但也带来了一个问题:异步任务可能失败,可能丢失,可能重复执行。出价记录是异步写库的,如果写失败了,Redis 里的当前价和 MySQL 里的最高出价就不一致。订单支付回调也可能因为网络超时没拿到结果。这些不一致不能靠人工去修,必须有一套状态补偿机制。
我常用的补偿方式就是增加一张task_retry表,记录所有异步任务的执行状态和重试次数:
@Component public class AsyncTaskCompensator { // 每60秒扫描一次所有失败或未确认的任务 @Scheduled(fixedDelay = 60_000) public void retryFailedTasks() { List<TaskRetry> pendingList = taskRetryMapper.selectList( new LambdaQueryWrapper<TaskRetry>() .in(TaskRetry::getStatus, 0, 2) // 0=待执行,2=执行失败 .lt(TaskRetry::getRetryCount, 5) // 最多重试5次 ); for (TaskRetry task : pendingList) { try { // 根据taskType做不同的补偿业务 taskService.executeCompensation(task); taskRetryMapper.updateStatus(task.getId(), 1); // 成功 } catch (Exception e) { taskRetryMapper.incrementRetry(task.getId()); log.error("补偿任务执行失败, taskId={}", task.getId(), e); } } } }这个补偿器的设计思路是:每个异步任务在执行前先落一条 task_retry 记录,如果异步执行成功则把记录状态置为成功;失败则保持待重试状态,由定时任务捞起重试。重试次数上限设为 5,超过 5 次就进入人工处理清单,用一个manual_review字段标记。这套机制写起来不复杂,但答辩时讲出来,比“我用 Redis 缓存了所有数据”要有说服力得多。
补偿任务最容易踩的坑是“重复执行导致副作用”。比如支付回调补偿,如果订单已经 PAID 了,补偿逻辑就不能再扣一次款。所以补偿业务里第一步永远是检查当前状态是否已经是终态,是终态直接跳过。这就是 java 面试题里常说的“幂等性”的落地——所有补偿操作都要做成幂等的。
到这里,系统的主链路(发布车源 → 出价 → 倒计时 → 成交/流拍 → 订单 → 毁单/支付)已经闭环了。但闭环不意味着没有坑,恰恰相反,这套系统里全是隐蔽的坑。下一章我把自己踩过的五个大坑原原本本写出来,每一条都是“现象 → 原因 → 解决”的格式,照着排查能少走很多弯路。
5. 毕业设计最容易翻车的 5 个坑:并发、时区、数据库锁、集合迭代、环境
5.1 并发出价导致价格错乱:Redis 没有“扣减”操作逻辑
现象:模拟 20 个用户同时出价时,最后成交价反而比某个中间出价更低,数据看起来像“穿越”了。 原因:我一开始是在 Java 代码里先读 Redis 当前价,和出价比较,再写回。两个线程同时读到同一个 currentPrice,后写的一方覆盖了先写的一方的结果。这是典型的“读改写”非原子操作。 解决:换用 3.2 节里的 Lua 脚本,把“比较价格 + 更新价格”放到 Redis 内部原子执行。改完后 100 个并发线程测试,价格始终是严格递增的。这个坑对应 java 并发题里最常问的“如何保证原子性”,Lua 脚本就是 Redis 场景下的标准答案。
5.2 倒计时作弊:改本地系统时间就能拉长拍卖
现象:前端页面显示剩余 2 秒时,用户把电脑系统时间改慢 5 分钟,倒计时停住还能继续出价。 原因:倒计时计算用的是endTime.getTime() - System.currentTimeMillis(),这里的System.currentTimeMillis()是客户端系统的本地时间。 解决:改用服务端时间。前端通过 WebSocket 连接建立时,从服务端拿一个时间戳,之后的倒计时都用这个服务端毫秒数减去剩余的本地漂移量,同时每次出价都由服务端校验endTime,超过时间直接拒绝出价。双保险的意思是:即使前端显示有误差,出价接口也会拦一次,数据不会脏。
5.3 Redis 过期事件不可靠:拍卖到点却不结束
现象:拍卖时间到了,Redis 的过期事件监听器触发了,但没人接,拍卖一直处于 RUNNING。 原因:Redis 的 key 过期事件不是准实时触发的,它依赖 Redis 的过期淘汰策略,有一定延迟;另外如果 Redis 进程重启,所有 TTL 数据丢失,监听器自然不会再触发。 解决:放弃“依赖 Redis 过期事件”作为唯一出口。改用第 3.3 节的@Scheduled定时扫表兜底,每 30 秒把超时拍卖全部置为 TIME_OVER。Redis 过期事件可以作为加速器,但绝不能作为唯一依赖。
5.4 数据库更新丢失:BidRecord 表出现重复主键
现象:两条出价记录同时写 MySQL,报Duplicate entry的异常。 原因:我用了数据库自增主键,但出价接口是异步写库的。异步线程先执行时,BidRecord 的主键没有冲突,但另一个同步线程也插入了同一条业务主键(比如auction_id + user_id的联合唯一索引),导致重复。 解决:给bid_record表增加一个bid_no字段(业务流水号),在插入前用 Redis 的INCR生成,同时建立(auction_id, bid_no)的联合唯一索引。异步落库时如果遇到重复主键,捕获异常后重试一次,重试仍失败则丢弃这条记录并打日志。
5.5 Spring Boot 启动失败:端口占用和 JDK 版本不对最坑人
现象:java -jar启动项目,控制台报Port 8080 was already in use,或者直接报UnsupportedClassVersionError。 原因:一是被其他程序占用了 8080 端口,二是 JDK 版本和项目编译版本不一致(比如项目用 JDK 17 编译,运行环境却装的是 JDK 8)。 解决:端口占用用netstat -ano | findstr :8080(Windows)或lsof -i :8080(Mac/Linux)找到进程后杀掉;版本不一致则检查JAVA_HOME环境变量指向的路径,或者直接在 IDE 里把 Project Structure 的 SDK 和 Maven 的Java Compiler都统一。这个坑别看简单,我在答辩前夜遇到过三次,每一次都是因为环境变量配置多装了一个 JDK 版本导致的。
6. 从毕业设计到可上线系统:验证手段和三个加分改造
毕设做完不是终点,答辩前的验证才是重中之重。我推荐一个最直接的压测方法:用 JMeter 模拟 30 个并发用户同时出价,持续跑 5 分钟,观察两个指标——Redis 里的最终价格是否为严格递增序列,MySQL 里的bid_record总数是否和 Redis 的出价次数一致。这两个指标能在一分钟内暴露并发控制的漏洞。压测命令不用太复杂,JMeter 里加一个 HTTP 采样器,POST 到出价接口,参数用 CSV 文件给每个线程不同的价格即可。跑完后如果发现不一致,赶紧回到第 3.2 节看 Lua 脚本是不是有逻辑遗漏。
在这套系统里,我最后还做了三个加分改造,你也完全可以照做。第一,车辆的图片和车辆检测报告单独放在一张表里,不做物理外键,页面加载时把 car_id 作为查询条件批量查出来,避免 N+1 查询导致列表页卡顿。第二,拍卖列表页的所有车辆数据先读 Redis 缓存,缓存未命中时再由一个请求拉取 MySQL 并回填缓存,这个拆分让列表接口的延迟从 300ms 降到了 40ms 左右。第三,支付模块不真正接第三方支付,而是做一个模拟支付页面——点击支付后直接把订单置为 PAID,然后在支付流水里打一条模拟回调记录。这样既能闭环流程,又不用去申请商户号和密钥。
最后分享一个我的个人习惯:每次改完并发相关代码,不要只看功能跑不跑得通,一定要主动加上压力测试——用 JMeter 或 Postman 的 Runner 去并发点击出价。凡是涉及“当前价”“库存”“状态更新”的逻辑,都不该只在单线程下验证通过就算完成。我自己的第一个拍卖项目就是漏了并发测试,答辩当天前端页面演示一切正常,评委突然让两个学生同时抢拍,成交价直接算错,当场翻车。后来我把上面这套 Lua 脚本和幂等补偿逻辑补齐之后,才真正敢说这个系统“能跑”。
毕业设计的目的不是把系统写出来给老师看一眼,而是通过这个系统把 Java 里那些看不见摸不着的并发、一致性、状态流转问题变成自己能讲清楚的东西。这套二手车交易拍卖系统从数据建模到拍卖落锤,再到订单闭合,中间的每个环节都有可以深挖的细节,只要你肯花时间把每一块的边界条件测一遍,答辩时被问得越细,你反而越有话讲。希望这套方案和这些踩坑记录能帮到你,让你少走一点我当时走过的弯路。
本文还有配套的精品资源,点击获取