ARTICLE DETAIL

资讯详情

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

基于SpringBoot的拍卖网站设计与并发控制实战解析

基于SpringBoot的拍卖网站设计与并发控制实战解析 做毕业设计选型那会儿我和很多人聊起过十个里面有八个都在做电商系统但真正让我把SpringBoot吃透的反而是这个看起来有点小众的拍卖网站项目。拍卖网站不是普通商城“商品加个价格字段”那么简单它和传统电商最大的区别在于交易价格不固定参与的人实时竞争系统必须在同一瞬间处理大量并发出价而且所有状态变化都有严格的时间约束。这套基于SpringBoot的拍卖网站解决的就是把线下“举牌竞价”这套规则完整搬到互联网上。它既适合拿来做毕设课题也适合当成练手项目帮自己把SpringBoot、数据库设计和前后端联调这条链路彻底跑通。1. 项目概览与核心场景拆解1.1 拍卖网站和普通电商的本质差异先聊清楚概念再动手。这个项目叫“基于SpringBoot拍卖网站”名字听着直白但真正落地的时候它的业务模型和最常见的商城系统完全不同。商城里的商品一般是稳定库存加固定价格用户买不买只影响库存数字和销量排行而拍卖网站的核心资产是“正在进行中的拍卖场次”每件拍品只有一个起拍价价格的走势由所有出价者共同决定。系统最核心要回答的问题是在任意时间点上谁能买、价格是多少并且这个结论在所有用户看起来都是公平且无争议的。这一条差异直接决定了系统设计的重心电商系统关注“下单-扣库存-支付”的事务一致性拍卖系统关注“竞价-锁价-成交”的并发一致性外加严格的时间窗口。我做第一版的时候就栽过一个大跟头把拍品表设计得跟商品表一样再塞一个current_price字段就以为完事了。结果第一次并发压测就露馅了两个用户同时出价后提交的那个把前一个的报价直接覆盖了数据库里的最高价一会儿变成这个人的一会儿变成那个人的完全不可控。后来才反应过来拍卖系统的每一次出价都必须是一个“读-比较-写”的原子操作不能指望客户端自己刷新页面来兜底。而且拍卖系统还有一条电商系统没有的硬约束时间。拍品必须在固定的结束时间前完成竞价到点后立即锁定结果系统自动判定成交还是流拍并且在极短的时间内生成后续订单。这意味着系统里必须有一个可靠的状态机能把“待开始-进行中-已结束”这几个状态串起来还要处理各种边界情况比如最后几秒有人出价、定时任务还没来得及扫描、请求恰好卡在截止时刻这种尴尬局面。1.2 用户角色划分与功能清单拍卖网站的规模虽然不大但角色划分比一般商城更清晰不同用户的动机也完全不同。买家进入系统是为了竞拍心仪的物品卖家是为了把自己的闲置或者商品卖个好价钱管理员则是保证整个拍卖过程合规、稳定地跑下来。买家竞拍者浏览在拍和预告拍品参与出价查看自己的出价记录和竞得结果成交后走订单支付流程。卖家拍品发布者发布拍品、设置起拍价和结束时间、查看竞价进度、管理成交结果。管理员负责拍品审核、用户管理、场次管理、违规出价处理、异常订单关闭。对应到功能模块一个完整可演示的拍卖网站至少包含用户认证与权限控制、拍品信息管理发布、编辑、上下架、竞拍大厅列表、详情、倒计时、出价竞价模块加价阶梯、保证金逻辑、拍卖结算模块成交订单、支付回调、后台运营模块审核、数据统计。当时我给自己定的目标是所有模块都做到可跑通而不是只搭一个空架子。实际做下来最花时间的不是页面UI而是出价模块和倒计时逻辑这两个地方基本决定了整个项目能不能真正扛住演示。后面写着写着我发现所谓“基于SpringBoot的拍卖网站”本质上做的是两件事一件是管好数据另一件是管好并发和时间页面和按钮反倒是最简单的部分。2. 技术选型与关键决策2.1 为什么选SpringBoot而不是SSH或纯Servlet这个项目选SpringBoot几乎是必然的选择。要是放到十年前这种项目得用SSH框架慢慢折腾一堆XML配置现在SpringBoot靠自动装配把SpringMVC、内嵌Tomcat、数据源这些东西全部整合好一个spring-boot-starter-web依赖就能把整套Web环境跑起来。做毕设也好、做个人项目也罢最怕的就是环境配置消耗大量精力SpringBoot这种开箱即用的特性刚好把人力和时间省出来投入业务逻辑。还有一个现实原因SpringBoot的生态太成熟了。要接数据库有mybatis-spring-boot-starter要接Redis有spring-boot-starter-data-redis要做定时任务原生支持Scheduled要做参数校验有spring-boot-starter-validation。每个环节都有官方或社区的标准做法遇到问题搜一下就能找到一堆参考这对个人开发者非常友好。版本选择上我得提醒一句如果追求稳定SpringBoot 2.7.x配合JDK8是最省心的组合。SpringBoot 3.x确实新潮但JDK版本要求是17及以上一些老教程里的配置和依赖坐标对不上排查起来会比较折腾。我最初图新鲜直接上了3.0结果发现一些第三方组件还没完全适配后来又老老实实退回2.7。这个坑不算大但它会平白消耗你的时间尤其是当你的核心目标是搞清楚拍卖业务本身而不想折腾环境。2.2 数据存储方案与数据库表设计数据存储上最核心的是MySQL承担所有业务数据的持久化。Redis在这个项目里的角色很明确一是缓存热门拍品列表和详情减轻数据库压力二是在出价高峰期保存实时最高价和出价流水做数据前置三是配合分布式锁防止并发出价时临界区失控。如果只是单机演示项目Redis也不是非用不可但用了之后对后续压测和扩展的帮助非常明显。数据库表设计是这类系统最见功力的地方。我的核心表最终设计成这样user用户表id、username、password、role、balance、create_time。balance在拍卖系统里很重要关系到成交后能否自动扣款。auction_item拍品表id、seller_id、title、description、start_price、current_price、bid_increment、status、start_time、end_time、version。其中status表示当前状态version是乐观锁字段。bid_record出价记录表id、item_id、user_id、bid_price、bid_time。每次出价都落一条记录便于追溯和展示出价历史。auction_order成交订单表id、item_id、buyer_id、seller_id、final_price、status、pay_time。拍卖结束自动生成订单买家确认支付后完成闭环。这套设计里每个字段都有明确的使用场景尤其是version几乎是给拍卖这种“高并发更新同一行记录”的典型场景量身定做的。早先我试着把出价记录塞到拍品表里做一个冗余字段后来发现既不好查询也无法统计果断拆成了独立表。竞拍历史单独建表还有一个好处后续做数据报表时直接按item_id查bid_record做价格走势、出价次数分析都很顺手。2.3 并发控制方案从数据库锁到Redis分布式锁拍卖系统的灵魂是并发控制。同一拍品在同一时刻被两个用户出价系统必须保证只有一个人的出价生效而且最高价始终唯一且递增。我实际对比过三种方案下面按我的使用体验展开。方案实现方式适用场景优缺点数据库乐观锁拍品表加version字段更新时比较旧版本号大部分常规场景实现简单性能好冲突时抛错重试数据库悲观锁select for update 锁定拍品行竞价人数少、一致性要求极高严格串行但锁等待有延迟Redis分布式锁setnx对拍品ID加锁高并发集群环境适用面广但多一次网络IO复杂一点实际写代码时我把乐观锁作为核心方案。理由很实在拍卖出价本质上不是长时间占用的写操作单个用户一次出价从请求到更新耗时不过几十毫秒乐观锁冲突的概率并没有想象中那么高。真到了千人同时抢一件拍品的场景再引入Redis锁也不迟。提前把复杂的锁机制引入项目只会让代码难读难调还容易出现“锁没释放”“锁超时”这些新问题。还有一个容易被忽略的细节乐观锁失败后用户界面不能只显示一句“出价失败”就完事。我处理的方式是失败时返回当前的最新价格前端自动把用户输入框的价格刷新到“当前价加价幅度”并提示用户“价格已更新请重新出价”。这样既保证了数据一致产品体验上也顺畅很多不至于让人一脸懵。3. 核心业务逻辑实现3.1 拍卖状态机的设计与流转状态设计是整个拍卖系统最容易被忽略、又最影响全局逻辑的部分。我梳理出来的状态链是PENDING待开始 - RUNNING进行中 - SUCCESS已成交 / FAILED已流拍 RUNNING / SUCCESS / FAILED - CANCELED已取消 SUCCESS - PAID已支付 / CLOSED已关闭状态之间靠事件驱动管理员上架拍品进入PENDING到达start_time就变成RUNNING到达end_time后系统根据当前出价情况决定进入SUCCESS还是FAILED。买家只有在RUNNING状态下才能出价管理员可以把有问题的拍品直接置为CANCELED。这个状态机看起来简单实际开发却有个坑状态不能只靠update_time在代码里硬判断必须显式维护state字段。否则在并发和定时任务的双重触发下很容易出现状态回退。举个例子定时任务刚把拍品从RUNNING改成SUCCESS用户下一秒的滞后请求又进来了如果代码里只有“RUNNING状态就允许出价”的判断又恰好读到旧状态就会出现成交后还能出价的逻辑漏洞。我最后的处理是所有修改状态的操作都走一个统一的状态流转服务使用compareAndSet的方式先比较当前状态是否符合预期再执行更新。更新失败就返回错误信息不让状态出现跳变。这就像我们平时改数据时会先确认版本号一样状态机也要有这种“先确认再修改”的机制才能保证任何入口进来的操作都沿着预设路径走。3.2 出价竞拍的事务处理与代码实现出价接口是拍卖系统的绝对核心。完整流程包含四个动作判断拍品状态、校验出价金额、更新当前价、写入出价记录。这四个动作必须在同一个事务里完成否则就会出现“价格更新了但记录没写入”或者反过来“记录写了但价格没更新”的数据不一致问题。我贴一段基于MyBatis-Plus实现的核心出价代码省略了部分非关键信息Service public class AuctionServiceImpl implements AuctionService { Autowired private AuctionItemMapper auctionItemMapper; Autowired private BidRecordMapper bidRecordMapper; Transactional(rollbackFor Exception.class) public BidResult placeBid(BidRequest request) { // 1. 查询拍品确认状态和价格 AuctionItem item auctionItemMapper.selectById(request.getItemId()); if (item null) { throw new BizException(拍品不存在); } if (item.getStatus() ! AuctionStatus.RUNNING.getCode()) { throw new BizException(当前不在竞拍时间内); } // 2. 校验出价必须高于当前价格 if (request.getBidPrice() item.getCurrentPrice()) { throw new BizException(出价必须高于当前最高价); } // 3. 使用乐观锁更新当前价同时校验version int rows auctionItemMapper.compareAndSetPrice(item.getId(), item.getVersion(), request.getBidPrice()); if (rows 0) { throw new BizException(手速慢了当前价格已变化请重新出价); } // 4. 记录出价流水 BidRecord record new BidRecord(); record.setItemId(request.getItemId()); record.setUserId(request.getUserId()); record.setBidPrice(request.getBidPrice()); record.setBidTime(LocalDateTime.now()); bidRecordMapper.insert(record); return new BidResult(request.getBidPrice()); } }对应的compareAndSetPrice的SQL如下UPDATE auction_item SET current_price #{bidPrice}, version version 1 WHERE id #{id} AND version #{expectedVersion}这段逻辑的精髓就在update语句里的version判断。当两个用户同时出价数据库层面只会让一个update成功另一个影响行数为0直接抛异常告诉用户“价格已变更”。配合事务出价失败时出价流水也不会落库数据不会出现幽灵记录。这一步想明白之后我对事务和锁的理解一下就落地了。3.3 拍卖结束的定时任务处理拍卖结束时间的处理我踩过不少坑。最简单的思路是用户端页面用JavaScript倒计时时间到了前端禁止出价。但前端只能管住本地页面服务器上还有大量没在前端活动的用户还有迟到几毫秒的请求所以真正的截止判断必须放在后端。我的做法是用SpringBoot自带的Scheduled写一个定时任务扫描所有状态为RUNNING且end_time早于当前时间的拍品把它们改为SUCCESS或FAILED。这里有个业务决策只要拍品有人出过价就进入SUCCESS并自动生成订单反之进入FAILED。定时任务的核心代码大概是下面这样Component public class AuctionScheduler { Autowired private AuctionItemMapper auctionItemMapper; Autowired private AuctionOrderService auctionOrderService; Scheduled(cron 0 * * * * ?) public void closeExpiredAuctions() { ListAuctionItem expiredItems auctionItemMapper.selectRunningExpired(); for (AuctionItem item : expiredItems) { auctionOrderService.settleAuction(item); } } }这里必须提醒一个容易踩的坑定时任务每60秒跑一次意味着理论上拍品的实际结束时间会延迟最多60秒。演示的时候如果有人较真就会看到倒计时已经归零拍品却还在“进行中”。解决这个问题有两个办法一是把扫描频率提高比如每5秒一次二是在出价接口里再补一道时间校验请求进来先比较服务器当前时间和end_time超时就拒绝出价。稳妥的做法是两者都做。我写的时候把定时任务频率调成5秒出价接口里也加了一道时间门禁这样不管定时任务有没有扫到逻辑上都不会出现截止后还能出价的情况。3.4 订单生成与支付闭环拍卖结束进入SUCCESS后系统自动创建一个成交订单把买家、卖家、成交价、拍品信息都落进auction_order表。买家端出现一条“待支付”订单完成支付后订单变为PAID。订单生成环节有个业务细节值得注意成交后不能立刻扣买家的保证金或者余额必须给一个缓冲时间让买家确认信息。我的方案是订单生成后等待支付超过24小时未支付就自动关闭订单并把对应拍品标记为CANCELED。这个超时关闭逻辑同样可以交给定时任务处理本质上是扫描“待支付且创建时间早于24小时”的订单然后更新状态。支付对接部分个人项目不建议真的申请商户号。直接用支付宝沙箱或者微信支付沙箱就能模拟完整支付回调流程和真实环境一模一样。回调接口就是普通的Controller接收支付平台的异步通知校验签名后再更新订单状态。这里我建议把回调逻辑单独拆到一个Service里不要在Controller里堆代码否则后期维护会很痛苦。另外一个容易忽略的点支付回调可能被平台重复调用所以处理回调的逻辑必须保证幂等。我的做法是先查订单状态如果已经是PAID就直接返回成功不再重复更新数据。这个细节虽然简单但很多人在联调支付时都会遇到重复通知导致的数据错乱。4. 项目从0到1的实操记录4.1 项目搭建与目录分层这个项目我用Maven管理依赖标准的分层结构如下springboot-auction ├─ src/main/java │ ├─ com/example/auction │ │ ├─ controller # 接口层 │ │ ├─ service # 业务层 │ │ ├─ mapper # 数据访问层 │ │ ├─ entity # 实体类 │ │ ├─ dto # 入参出参对象 │ │ ├─ config # 配置类Redis、拦截器等 │ │ ├─ common # 公共组件异常、结果封装 │ │ └─ task # 定时任务 ├─ src/main/resources │ ├─ application.yml │ ├─ mapper # MyBatis XML文件 │ └─ static # 前端打包产物 └─ pom.xml分层这块我坚持的原则是Controller只做参数接收和结果封装不做任何业务判断Service里写业务逻辑和事务Mapper走数据访问。这样做的好处是后期写测试、加功能都很顺手不会出现一个3000行的Controller把人看晕的情况。4.2 前端页面与后端接口的联调前端我用的Vue2模板从网上找了个基础框架改的。整个页面分三大块卖家中心的发布拍品页、买家中心的竞拍大厅、管理员的后台审核页。竞拍大厅的核心交互就是“倒计时竞拍按钮出价记录”数据通过接口定时轮询刷新。其实用WebSocket效果会更好但考虑到这个项目规模轮询完全够用复杂度还低所以我没上WebSocket。接口设计遵守RESTful风格比如GET /api/items获取拍品列表GET /api/items/{id}获取详情POST /api/items/{id}/bid提交出价POST /api/orders/{id}/pay触发支付。所有接口返回统一封装成Result对象包含code、message、data前端只需要针对code做判断联调起来非常省事。联调时有一个教训必须说一下前端开发时用vue.config.js代理到SpringBoot的8080但后端打包时要把前端dist目录拷到SpringBoot的static目录下同时前端调用接口要用相对路径否则部署后接口地址会找不到。我第一版部署时就是忘了改接口前缀结果打包上线全是404排查了半天才发现是路径写死了。4.3 部署环境的搭建与演示准备部署我给的建议是演示前一定要自己完整跑一遍主链路从发布拍品、出价竞拍、成交生成订单到最后支付回调每一步都要亲眼看到数据变化。我遇到过的问题包括前端打包后接口404、本地MySQL和服务器MySQL时区不一致导致倒计时偏差、依赖版本冲突导致启动失败等等。如果是给老师或者面试官演示建议把数据库连好、Redis也启动整个项目打成jar包用java -jar命令启动浏览器直接访问。也可以录一段操作录像作为备案防止现场环境出错时手足无措。演示场景准备一个已经设置好倒计时的拍品很重要让对方打开页面就能看到倒计时跳动的效果这个比任何讲解都有说服力。5. 常见问题与排查指南5.1 并发出价价格覆盖这是拍卖系统最容易出的bug。症状是两个人同时出价数据库里的current_price最终变成后来者的出价但出价记录里最高价对应的人却不是他。根因就是出价操作没有加锁两个线程同时读了旧价格然后各自覆盖写入。排查方法很直接去看更新current_price的update语句在哪里有没有带上version或者行锁条件。如果没有立刻按上面讲的乐观锁方式修改。压测时可以写一个简单的并发脚本模拟20个线程同时出价最后检查出价记录里的最高价和拍品的current_price是否一致一致才算通过。5.2 时间边界导致倒计时混乱这个坑很多人都会遇到前端倒计时显示还有30秒用户出价竟然成功了。排查后发现是后端的end_time存的是北京时间但页面用的是本地电脑时间和浏览器时间只要两边时钟差几秒就会出现这种不一致。处理办法是统一前后端的时间源。后端所有时间判断都基于服务器当前时间前端倒计时基于服务器返回的剩余毫秒数做本地递减不要用客户端自己new Date()来计算。演示前还要确认MySQL的time_zone配置最好在数据库连接串上显式指定serverTimezoneAsia/Shanghai避免时区默认成UTC导致8小时偏差。这种问题很难通过看代码发现只能靠实际操作踩出来。5.3 Transactional写了却不生效这是个经典问题。表现是方法报异常后出价记录还是写进库了。原因大概率是同一个类内部自调用。比如A方法里直接调用同类B方法B方法上有Transactional此时Spring调用的是代理对象只有外部调用才会触发事务增强内部自调用不会走代理。解决办法是注入自身代理或者干脆把带事务的方法放到另一个Service里。另外要确认事务默认只对RuntimeException回滚如果业务异常继承的是Exception而不是RuntimeExceptionrollbackFor必须显式声明否则事务照样不会回滚。我当时用自定义BizException继承RuntimeException少踩了很多不必要的麻烦。5.4 出价记录表设计易犯的错出价记录最容易出现的两个错误一是把出价历史塞到拍品表的大字段里二是用一张JSON表存所有历史数据。前者后期没法查询“某个人最近出过哪些价”后者的数据检索性能惨不忍睹。正确做法是出价记录单独建表拍品表只保存当前最高价和version每次出价只写一条记录。同时给bid_record的item_id和bid_time建联合索引历史记录查询几乎是秒回。这样既保证竞拍历史完整可追溯又能保持拍品表足够轻统计拍卖数据时直接查bid_record做价格走势和出价频率分析都有现成的数据基础。5.5 性能优化与扩展思路做完基础功能后可以从这几个方向提升系统专业度。拍品列表接入Redis缓存热门场次靠缓存顶住压力出价接口加一个简单的防重复提交校验防止用户双击按钮产生重复记录有精力的话把拍品状态变化通过WebSocket推给前端减少轮询频率。另外还可以加上保证金逻辑买家出价前冻结一部分余额成交后自动扣减这个功能会让系统更贴近真实拍卖网站的业务形态。这几步优化难度不大但对系统观感的提升非常明显。尤其是被问到“并发高怎么办”的时候能脱口说出乐观锁、Redis缓存、防重校验这些具体方案比空谈概念有说服力得多。最后说点个人体会。做完这个SpringBoot拍卖网站我最大的收获不是掌握了某个框架的API而是真正理解了一个事务在真实业务里意味着什么每一次出价背后都是一次并发竞态每一次成交后面都跟着一个订单闭环。以前总觉得SpringBoot自动装配、乐观锁这些东西是课本上的概念直到自己写的系统在压测下价格错乱、在演示现场被指出时间不准才一点点把原理和场景对上。拍卖网站这个选题说难不难说简单也不简单。如果你正在准备自己的项目我建议亲手把出价并发这块跑通哪怕只是加一个version字段的乐观锁也会让你对这个系统的理解提升一个台阶。这套代码我至今还留着偶尔翻出来改一改总能找出新的优化点。
返回列表