ARTICLE DETAIL

资讯详情

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

在线拍卖系统全栈实战:Spring Boot+Redis高并发出价架构详解

在线拍卖系统全栈实战:Spring Boot+Redis高并发出价架构详解 做在线拍卖系统是很多Java全栈开发者绕不开的练手项目。原因很简单它麻雀虽小五脏俱全涉及用户认证、商品管理、高并发出价、定时任务、订单支付、实时推送几乎把Web开发里最常踩的坑都过了一遍。我这两年带团队做过两版拍卖平台一版是传统单体架构一版是带Redis和消息队列的优化版本踩过的坑比写过的代码还多。这篇博客就把完整方案拆开讲清楚从需求分析到数据库设计从后端核心接口到前端竞拍页再到线上常见问题排查适合有一定Java基础、想系统做全栈项目的人参考。文章里所有表结构、接口设计和代码片段都来自真实实践你可以直接拿去改。1. 项目整体设计与技术选型1.1 需求分析与角色模型动手写代码前先把需求理清楚。在线拍卖系统看起来简单实际角色和流程比普通商城复杂不少。系统一共三类角色普通用户、卖家、管理员。普通用户能注册登录、浏览拍品、缴纳保证金、参与出价、竞拍成功后支付下单卖家能发布拍品、设置起拍价和加价幅度、查看自己商品的拍卖进度管理员负责审核商品、处理违规、查看平台运营数据。核心业务流程是一条链卖家发布商品 → 管理员审核通过 → 商品进入拍卖状态 → 用户浏览并出价 → 拍卖倒计时结束 → 最高出价者胜出 → 生成订单 → 买家支付 → 卖家发货。这条链路里最容易出问题的环节是“拍卖倒计时结束”和“最高出价者胜出”这两个点涉及并发和时间边界后面会单独讲。角色模型定了技术方案才有依据。例如“普通用户竞拍时需要缴纳保证金”这个需求就决定了必须引入支付和对账逻辑而不是简单的增删改查。1.2 技术选型为什么是这套组合技术选型没有标准答案但要有明确理由。我这套方案选用的是后端Spring Boot 2.7 MyBatis-Plus MySQL 8.0 Redis RabbitMQ前端Vue 3 Element Plus鉴权用JWT实时推送用WebSocket。Spring Boot不用多说生态成熟、上手快适合快速搭建业务接口。MyBatis-Plus比原生MyBatis省很多事分页、条件构造器、逻辑删除都是开箱即用但复杂SQL还是要手写所以不能完全依赖它。Redis在本项目里干了三件事缓存商品详情、存储出价令牌桶、做竞拍倒计时的分布式锁。为什么不让数据库扛出价压力因为出价是典型的读多写少场景一个热门拍品在最后几分钟的QPS可以冲到几百上千直接打MySQL的竞拍记录表行锁竞争会把数据库拖死。Redis的原子自增和过期机制天然适合处理这种短时高频操作。RabbitMQ主要用于处理拍卖结束后的一系列动作比如通知胜出者、生成订单、释放未胜出用户的保证金。为什么不直接用定时任务扫表因为扫表有延迟而且拍卖结束瞬间可能会有几十个商品同时到期定时任务处理不及时就会积压。用延迟队列或者死信队列处理是最省心的方案。前端选Vue 3 Element Plus主要是开发效率高组件全表格、表单、弹窗这些后台管理页面几乎不用自己写样式。竞拍页面的倒计时用JavaScript的setInterval驱动但服务端会实时返回服务器时间避免用户本地时间不准导致显示误差。1.3 系统架构与模块划分整体架构按功能拆成六个模块用户模块、商品模块、拍卖模块、订单模块、支付模块、消息模块。用户模块负责注册登录、个人信息、保证金管理。商品模块负责商品CRUD、图片上传、审核流。拍卖模块是核心负责出价、价高者得、自动延时、竞拍结束处理。订单模块负责胜出后生成订单、订单状态流转。支付模块对接支付网关这里会用沙箱环境模拟真实支付。消息模块负责站内信、WebSocket实时推送、邮件通知。模块之间通过接口交互不直接互相调用数据库保证后期可以独立拆分服务。虽然单体架构不需要像微服务那样严格隔离但在代码层面保持模块边界清晰对后续维护和扩展非常重要。我见过太多项目写着写着所有业务逻辑都堆在Controller里改一个出价逻辑要动三四张表那种代码基本没法维护。2. 数据库设计先把表结构想清楚2.1 核心表结构与字段设计在线拍卖系统的数据库设计核心是四张表用户表、商品表、竞拍记录表、订单表。再加两张辅助表保证金流水表和站内信表。用户表字段不复杂注意几个关键点密码用BCrypt加密存储不能明文存余额字段用decimal(10,2)而不是float避免精度丢失status字段标记用户状态比如正常、封禁。商品表是设计的重头戏核心字段包括字段名类型说明idbigint主键seller_idbigint卖家IDtitlevarchar(100)商品标题descriptiontext商品描述start_pricedecimal(10,2)起拍价increment_pricedecimal(10,2)加价幅度current_pricedecimal(10,2)当前最高价current_bidder_idbigint当前最高出价人start_timedatetime开拍时间默认创建后立即开始end_timedatetime拍卖结束时间statustinyint状态0草稿 1待审核 2拍卖中 3已结束 4流拍versionint乐观锁版本号status字段是整个商品状态机的核心后面所有业务流程都是围绕状态流转设计的。version字段很关键是处理并发出价时防止覆盖的关键。竞拍记录表记录每一次出价明细字段包括id、auction_id、user_id、bid_price、created_time。这张表的数据量会很大所以索引设计尤为重要必须要建(auction_id, bid_price)联合索引因为查当前最高价和出价历史都是走这个索引。订单表字段包括id、auction_id、winner_id、final_price、status、created_time、pay_time、shipping_time。订单状态机0待支付 1已支付 2已发货 3已完成 4已取消 5退款中。2.2 状态机设计与关键索引状态机设计是数据库设计里最容易被忽略但最重要的部分。商品状态流转草稿(0) → 待审核(1) → 拍卖中(2) → 已结束(3) 拍卖中(2) → 已结束(3) → 流拍(4) 或者 → 待支付订单订单状态流转待支付(0) → 已支付(1) → 已发货(2) → 已完成(3) 待支付(0) → 已取消(4)设计状态机时我踩过一个坑一开始没有把“流拍”和“已结束”分清楚。事实上拍卖结束有两种结果有人出价就是已结束没人出价就是流拍。这两个状态处理逻辑完全不同前者要生成订单后者直接标记商品可重新上架。如果合成一个状态代码里就全是if-else很恶心。索引设计方面除了竞拍记录表的联合索引商品表的时间字段也要建索引。因为拍卖中列表页的查询条件通常是“状态2 AND start_time 当前时间 AND end_time 当前时间”走索引后查询性能能提升一个量级。我见过一个没有任何索引的生产库商品到十万条之后列表页接口直接超时加了复合索引之后压测QPS从不到100打到800多。2.3 数据库并发控制方案数据库并发控制在拍卖场景里主要解决两个问题防止超卖同一商品同时被多人出价但最终只能有一个有效出价和防止金额覆盖高并发下两个用户都读到相同的最新价都往上加价导致先加价的人被覆盖。第一层方案用MySQL行锁在出价操作前先执行SELECT ... FOR UPDATE锁定商品行拿到最新价格后再插入竞拍记录。这个方案能解决问题但锁竞争太严重高并发下性能不佳。第二层方案用乐观锁在商品表加version字段更新时带版本号判断UPDATE auction SET current_price #{newPrice}, version version 1 WHERE id #{id} AND version #{oldVersion}。如果影响行数为0说明版本冲突用户需要重试或直接提示出价失败。我最后选择的是Redis 数据库双层校验出价请求先打Redis利用Redis的单线程特性原子操作记录当前最高出价和出价人然后异步把出价明细写入MySQL。数据库层的乐观锁仍然保留作为最终的兜底防止Redis数据丢失后出现金额错乱。3. 后端核心功能实现3.1 登录认证与JWT权限控制用户模块第一步是登录认证。使用JWT做无状态登录登录成功后服务端签发Token前端后每次请求带着Token后端通过拦截器解析Token获取用户身份。JWT的优点是后端不用存session天然支持跨域客户端拿Token就能鉴权。但有一个坑JWT无法主动失效用户改密码后旧Token仍然有效。解决方式是引入token_version机制用户表加一个token_version字段JWT生成时把版本号带进去校验时对比版本不一致就拒绝。权限控制有三种角色拦截器里判断角色后再放行对应接口。注意拦截器只能做粗粒度校验细粒度权限要放在Service层。比如用户只能修改自己的商品这个判断不能只靠拦截器必须在Service层查库后再校验。实际代码片段PostMapping(/login) public Result login(RequestBody LoginRequest req) { User user userService.login(req.getUsername(), req.getPassword()); String token JwtUtil.createToken(user.getId(), user.getRole(), user.getTokenVersion()); return Result.success(new LoginResponse(token, user)); }3.2 商品发布与拍卖流程卖家发布商品走一个提交审核的流程商品审核通过后自动进入拍卖中状态。这里要注意一个问题卖家提交上架时如果设置了start_time需要校验start_time不能晚于end_time且end_time不能小于当前时间加最少拍卖时长。拍卖流程的核心逻辑是出价接口。出价接口的需求不复杂但有很多边界条件要考虑用户不能对自己的商品出价出价金额必须大于当前最高价且加价幅度不能低于设定值商品状态必须为“拍卖中”当前时间必须在拍卖有效期内这个接口的伪代码Transactional public Result bid(BidRequest req) { // 1. 查商品并加乐观锁 Auction auction auctionMapper.selectById(req.getAuctionId()); if (auction.getStatus() ! 2) return Result.error(拍卖已结束); if (auction.getSellerId().equals(req.getUserId())) return Result.error(不能出价自己的商品); BigDecimal bidPrice new BigDecimal(req.getBidPrice()); if (bidPrice.compareTo(auction.getCurrentPrice()) 0) return Result.error(出价必须高于当前价); if (bidPrice.subtract(auction.getCurrentPrice()).compareTo(auction.getIncrementPrice()) 0) { return Result.error(加价幅度不足); } // 2. 乐观锁更新商品 int count auctionMapper.updatePriceWithVersion(auction.getId(), bidPrice, req.getUserId(), auction.getVersion()); if (count 0) return Result.error(出价失败请重试); // 3. 插入出价记录 bidRecordMapper.insert(...); // 4. 推送WebSocket消息 webSocketService.pushBidMessage(auction.getId(), req.getUserId(), bidPrice); return Result.success(); }这里要特别说明为什么出价逻辑要放在同一个事务里。因为更新商品价格和插入出价记录要么同时成功要么同时失败否则会出现价格变了但没人出价记录的脏数据。事务隔离级别用默认的REPEATABLE_READ就好不需要调成SERIALIZABLE那个性能太差。3.3 高并发出价的Redis优化上面那版逻辑虽然正确但在高并发下性能不够。一个热门的拍卖商品最后几分钟每秒可能有几百上千次出价请求直接走数据库事务行锁竞争会非常激烈接口响应时间飙升。优化思路是引入Redis作为出价入口的缓存层。核心原理是Redis的单线程模型让所有操作原子执行天然不会有并发问题。出价请求先打Redis用Redis的Lua脚本或者事务操作来记录最高出价数据库的写入改成异步。我用的方案是Redis存当前最高价和出价人key设计为auction:price:{auctionId}value是JSON字符串包含userId和price。出价时用Redis的WATCH MULTI EXEC事务或者更推荐用Lua脚本因为Lua脚本在Redis里是原子执行的。Lua脚本逻辑local current redis.call(GET, KEYS[1]) if current false then return 0 end local currentUser cjson.decode(current).userId if currentUser ARGV[1] then return -1 end local currentPrice cjson.decode(current).price if tonumber(ARGV[2]) currentPrice then return -2 end if tonumber(ARGV[2]) - currentPrice tonumber(ARGV[3]) then return -3 end redis.call(SET, KEYS[1], cjson.encode({userId ARGV[1], price ARGV[2]})) return 1然后异步把出价记录写入MySQL通过MQ发消息消费者插入竞拍记录表同时更新商品表的价格字段。有一个细节必须注意Redis缓存和数据库的一致性。如果Redis写入成功了异步写库失败Redis里的价格会比MySQL高用户看到的价格和数据库不一致。我的做法是写库失败时用定时任务做对账定期把Redis里的数据同步到MySQL同时记录日志人工排查。3.4 拍卖结束的延时处理拍卖结束后要做的事很多判断是否有人出价、生成订单、通知胜出者、释放未胜出用户的保证金。这些操作如果全部在出价请求线程里同步执行会拖慢出价接口的响应。我用的是RabbitMQ延迟队列来实现。商品创建时设定一个延迟消息在end_time到达时触发。为什么不用定时任务因为扫表逻辑对时间精度要求很高定时任务每隔几分钟扫一次表拍卖结束到实际处理之间会有几分钟延迟。对于拍卖这种对时间敏感的业务用户如果看到拍卖明明结束了但订单好几分钟后才生成体验会很差。RabbitMQ延迟队列的实现方式是创建一个死信队列发送消息时设置expirationTTL消息过期后自动路由到死信交换机死信交换机再路由到真正的业务队列消费者订阅业务队列处理竞拍结束逻辑。处理逻辑public void handleAuctionEnd(AuctionEndMessage msg) { Auction auction auctionMapper.selectById(msg.getAuctionId()); if (auction.getStatus() ! 2) return; // 幂等处理防止重复消息 if (auction.getCurrentBidderId() null) { // 无人出价拍卖流拍 auctionMapper.updateStatus(auction.getId(), 4); } else { // 有人出价生成订单 auctionMapper.updateStatus(auction.getId(), 3); orderMapper.insert(...); // 释放未胜出用户保证金 refundService.refundDeposit(auction.getId(), auction.getCurrentBidderId()); // 推送WebSocket通知胜出者 webSocketService.pushAuctionResult(auction.getId(), auction.getCurrentBidderId()); } }这里有个很重要的坑消息队列的消息可能重复投递消费者必须做幂等处理。上面的代码先判断商品状态如果不是拍卖中就直接返回这就是幂等控制。还有一个坑是消息丢失如果消费者处理失败消息会被重投但重投次数有限超过限制就会进入死信队列。所以必须加定时任务兜底扫描超过end_time半小时但仍处于拍卖中状态的商品强制结束拍卖。3.5 订单支付与自动取消拍卖胜出后生成待支付订单买家需要在限定时间内完成支付通常24小时。如果超时未支付订单自动取消商品状态变成流拍保证金作废。超时取消的实现方案如果不知道有延迟队列很可能会用定时任务每分钟扫一次订单表找超时未支付的订单批量取消。这个方案在数据量小的时候没问题数据量大以后每次扫描的SQL都慢得不行。我的做法是继续用延迟队列生成订单时同时发送一个延迟消息24小时后触发消费者收到消息后判断订单状态如果是待支付就取消订单。支付模块对接沙箱网关流程是下单 → 支付网关创建支付单 → 用户扫码或跳转支付 → 网关回调通知 → 后端修改订单状态 → 通知卖家发货。支付回调处理有个非常经典的坑回调通知是异步的可能重复发送必须用订单号支付状态做幂等先查数据库状态已更新就直接返回成功。3.6 WebSocket实时竞价消息推送竞拍页面需要实时显示最新价格和出价记录这个不能用轮询轮询会造成大量无效请求而且延迟高。用WebSocket做实时推送是标准方案。后端用Spring封装的WebSocket模块建立连接后按auctionId订阅主题。前端的出价请求成功后服务端通过WebSocket把最新价格推送给所有订阅了该商品的用户这样竞拍页面的价格和出价记录能实时刷新。还有一个细节当用户进入竞拍页面时服务端需要推送当前的最高出价和最近几条出价记录WebSocket只负责增量推送初始数据还是走HTTP接口获取。WebSocket连接管理有个坑用户断线重连后服务端的session对象可能会失效消息推不出去。我在实际项目中遇到过一次用户出价成功但页面价格没刷新排查后发现是session已经过期但服务器不知道。解决方式是定期发送心跳消息ping/pong服务端发现心跳超时就主动移除失效session。4. 前端页面与交互实现4.1 前端页面结构与路由设计前端页面按角色划分普通用户能看到首页拍卖列表、商品详情页竞拍页、个人中心我的竞拍、我的订单、保证金记录。卖家和管理员是后台管理页面用权限控制路由。页面结构设计遵循一个原则竞拍相关页面简单直接不要在首屏放太多信息。首页就是商品卡片列表每张卡片显示商品主图、当前价格、剩余时间。点击进入竞拍详情页上半部分是商品大图和描述下半部分是出价区域和出价记录列表。路由设计用Vue Router的懒加载按模块拆分。权限控制用路由守卫登录后从后端获取用户信息和角色存入Pinia路由跳转时判断角色是否有权限访问。4.2 竞拍页倒计时与出价交互竞拍页面的倒计时是最重要的交互元素。倒计时不能只用前端本地时间计算因为用户本机时间不准会导致显示混乱。正确做法是前后端时间校准前端启动时调一个接口拿服务器时间计算出和本地时间的差值之后每秒根据差值更新倒计时。倒计时显示逻辑const serverTimeDiff ref(0) const getServerTime async () { const res await getServerTimeAPI() serverTimeDiff.value res.data.serverTime - Date.now() } const countdown computed(() { const remain auction.value.endTime - (Date.now() serverTimeDiff.value) if (remain 0) return 已结束 const hours Math.floor(remain / 3600000) const minutes Math.floor((remain % 3600000) / 60000) const seconds Math.floor((remain % 60000) / 1000) return ${hours}:${minutes.toString().padStart(2, 0)}:${seconds.toString().padStart(2, 0)} })出价交互的流程是用户输入出价金额 → 前端校验金额合法 → 调后端出价接口 → 后端返回成功 → WebSocket推送最新价 → 页面刷新出价记录。如果出价失败根据错误码显示具体原因比如“出价低于当前价”“加价幅度不足”“拍卖已结束”。这里有一个交互细节出价按钮在竞拍即将结束时最好是禁用状态并提示用户“即将结束正在提交...”。因为最后一两秒的激烈出价会带来大量并发请求服务端压力很大前端要控制请求频率。我在项目里做了简单的防重复提交按钮点击后进入loading状态3秒内不能重复点击。4.3 管理后台数据看板管理后台包括商品审核、用户管理、订单管理、平台数据看板。商品审核是卖家发布商品后管理员需要操作的模块核心是审核通过/驳回驳回时可以填原因。数据看板展示平台的运营数据总商品数、拍卖中商品数、今日成交金额、用户总数、竞拍参与人数。用ECharts做图表展示比如近一周成交金额趋势图、热门商品排行榜。看板数据实时性要求不高接口可以直接查MySQL做聚合统计。数据量大以后考虑加一层Redis缓存比如每小时刷新一次统计数据避免每次打开管理页都做全表聚合。5. 常见问题与排查技巧实录5.1 出价并发导致的价格错乱这是拍卖系统最经典的bug。现象是两个人同时出价都读到了当前价100元A出价120B出价130但最终数据库里价格变成了120而不是130B的出价记录存在但价格被A覆盖了。原因分析两个并发请求同时读到商品价格100A先更新为120B也用读到的100作为基准算出130后更新此时SQL如果是UPDATE ... SET current_price 130 WHERE id ...不带条件判断就会把120覆盖掉。解决方案就是前面提到的乐观锁SQL改成UPDATE ... SET current_price 130, version version 1 WHERE id ... AND version 旧版本号。B更新时版本号已经变了影响行数为0B会收到“出价失败请重试”的提示需要重新读取最新价格再出价。这个教训说明凡是有“读-改-写”流程的操作都必须在数据库层面考虑并发控制不能只靠代码里加锁因为多个实例部署时本地锁是无效的。5.2 定时任务与延迟消息的边界问题在已有定时任务扫表兜底的情况下如果消息队列处理得慢定时任务也会扫描到同一批数据并处理导致同一订单被创建两次。解决方案是业务幂等 状态判断。处理前先查商品状态只有状态为“拍卖中”才执行处理逻辑处理完成后状态变成“已结束”或“流拍”后续消息或定时任务再触发时会发现状态不匹配直接退出。还有一个坑是数据库时间与服务器时间不一致。如果数据库部署在不同的时区或者服务器时间偏差拍卖的结束时间判断会出现误差。我的做法是统一用服务端时间数据库时间字段用datetime类型写入时以服务端时间为准避免依赖MySQL的NOW()函数。5.3 Redis内存不断增加竞拍结束后auction:price:{auctionId}这个key不会自动删除长期积累会导致Redis内存涨到告警线。解决方式是给Redis key设置过期时间常规做法是拍卖结束后延迟一段时间删除。更优雅的方案是竞拍结束处理逻辑中主动删除相关key。如果消息队列处理失败导致没有删掉就用过期时间兜底。我设置的是end_time 24小时过期足够覆盖所有对账场景。5.4 常见问题速查表现象可能原因解决方案出价后价格不变Redis未更新或更新失败检查Redis连接和Lua脚本返回码拍卖结束后订单没生成延迟消息积压或消费失败查看MQ消费者日志检查延时消息是否超时支付回调收到但订单还是待支付回调幂等判断出错检查订单号解析逻辑打印详细日志用户收到多个出价成功通知WebSocket重复推送检查session管理添加消息去重竞拍页面倒计时显示不准前端时间校准逻辑错误确认接口返回的是服务器时间而非客户端时间高并发下接口超时数据库锁竞争严重引入Redis缓存出价异步写库退款重复执行未做幂等处理增加退款流水表退款前检查流水号5.5 部署与线上环境注意事项线上部署推荐用Docker Compose编排Spring Boot应用、MySQL、Redis、RabbitMQ、Nginx各起一个容器。数据库数据目录挂载到宿主机防止容器重建后数据丢失。MySQL连接池配置需要重点注意默认的HikariCP不太适合高并发场景建议连接池大小设为CPU核数乘以2加1最大连接数不要超过500。如果数据库连接不够接口会大量报“Connection is not available”的错误。Java应用启动参数里有一个很实用的配置-Xms和-Xmx设为相同值避免JVM频繁扩容堆内存导致停顿。线上机器8G内存的话给应用分配4G堆内存是比较合理的。还有一个经常遇到的问题是时区设置。Docker镜像默认是UTC时区如果不设置Asia/Shanghai日志时间和数据库时间对不上排查问题会非常痛苦。启动容器时用-e TZAsia/Shanghai解决。6. 写在最后的实战体会做这个项目最深的体会是在线拍卖系统的难点从来不是某个技术点本身而是多个技术点串联时的边界问题。出价涉及Redis和数据库的一致性拍卖结束涉及消息队列和定时任务的幂等支付回调涉及状态机的严谨性每一个环节单独跑都没问题连起来就全是坑。我个人在实际操作中建议做这类全栈项目时一定要先画清楚状态机把商品和订单的所有状态迁移列出来再开始写代码。状态机画清楚了剩下的事情就是往里面填具体实现。另外一个小技巧调试拍卖结束时可以写一个测试脚本把商品的end_time设置为当前时间加上一分钟然后反复测试结束流程看消息是否重复处理、订单是否重复生成。这个脚本我到现在还在用每次改完代码都先跑一遍比人工点页面省事得多。后续做分布式部署时你可以把用户模块和拍卖模块拆成独立服务接口用Feign调用Redis和MQ复用现有这套扩展成本很低。
返回列表