资讯动态

SpringBoot+Vue秒杀系统:高并发架构与防超卖实战

发布时间:2026/10/22 2:44:12 来源:尧图企业网站定制
我参与评审过的秒杀系统项目不在少数也陪练过好几轮答辩模拟这类题目在毕业设计里实在太高频了。Java、Vue、SpringBoot 三个关键词一列看上去就是一套标准的三层架构但每次拿到代码我都会先问一句你的系统里Redis 和消息队列是什么角色问完这一句十有八九能区分出谁是认真做过高并发设计的谁只是把增删改查外面套了个秒杀的壳。这篇博文想分享的是我基于这类完整实战项目程序、数据库、项目报告、部署、答辩指导反复打磨出来的全流程经验。我会从秒杀系统为什么容易翻车讲起接着拆数据库结构、双端核心实现、压测对比、服务器部署再到答辩时最容易被追问的高频问题通通掰开揉碎说一遍。适合正准备拿这个题目做毕业设计或面试项目的同学也适合已经在写代码、想搞清楚并发方案为什么这么设计的开发者。1. 秒杀系统难在哪超卖风险与瞬时流量冲击1.1 这个项目到底在模拟什么真实场景秒杀本质上是一个极端的限时限量促销几秒钟内涌入大量请求但真正能抢购成功的人数通常只有几十上百。某些电商平台的限量发售就是这种模式技术团队的核心任务不是多卖一点而是三件事系统别崩、库存不多卖、用户体感相对公平。很多同学不理解为什么秒杀系统会成为毕设热门觉得无非就是用户点按钮、库存减一、生成订单网上开源代码一抓一大把跑通不就完事了问题恰好出在这个看似简单上。你写单机 Demo 时MySQL 里一行update goods set stock stock - 1 where id ?够用可一旦用压测工具模拟几百人同时点击这一行 SQL 的表现会和单机版截然不同连接池被打满、锁等待超时、扣库存和生成订单之间出现错位超卖随之而来。秒杀项目的本质是训练你应对瞬时高并发写入的能力。这里的难点不在于某个单独的技术而在于多个环节叠加后的状态一致性。把这层理解透了项目答辩的时候讲出来的东西和别人完全不是一个层次。1.2 三个让新手翻车的高并发问题第一个是超卖。它的本质是并发上下文中的先查后写竞态程序先select stock查库存大于 0再执行update在同一瞬间多个请求都读到剩余 1 件库存然后一起把库存扣成负数。就算你加了一个if (stock 0)判断也不解决并发问题——两个线程可能同时通过这个判断。真正的兜底方案必须在数据库层面或原子性操作层面解决而不是靠业务代码里的 if。第二个是瞬间大流量把后端打垮。秒杀接口是整个系统最热的点如果不加任何缓冲直接打到 MySQL数据库的并发连接数会瞬间吃满。表现就是页面转圈、接口超时、服务重启。这也是为什么系统需要 Redis 做前置挡流需要消息队列做削峰。简单说不能让所有流量都直接砸向最脆弱、最底层的数据库。第三个是用户的重复点击和恶意刷单。用户在抢购按钮上连点前端组件如果不控制状态同一个用户会产生好几笔订单更严重的是如果秒杀接口没有做用户维度校验攻击者可以写脚本循环请求把库存洗劫一空。所以成熟系统里同一个用户同一场活动只能抢购一次是必须写进代码的硬约束。除此之外还有一个容易被答辩老师问住的点Redis 缓存击穿。抢购开始瞬间大量请求同时读取某个 key如果这个 key 正好在 Redis 里失效了请求就会穿透到数据库。这也是为什么预减库存的 key 要设置合理的过期策略同时要在数据库查询逻辑上做保护。后面我会专门说这块怎么处理。2. 技术栈搭配逻辑SpringBoot、Vue 与中间件各自的定位2.1 为什么前端选 Vue 而不是其他框架秒杀前端包含几个关键交互状态活动倒计时、抢购按钮置灰与点亮、抢购结果的弹窗提示、订单列表的轮询刷新。这些都属于典型的数据驱动视图场景正好是 Vue 的强项。用 Vue 的单文件组件实现倒计时逻辑很顺手状态一变化按钮文字和禁用状态跟着变用 Vue Router 管理首页商品列表 - 详情页 - 订单结果页的跳转也很自然。在项目实操里我建议前端只做三件事展示商品与活动信息、抢占用户操作入口、轮询后端接口。真正的抢购结果不要在前端本地判断一切以后端响应为准。前端拦截重复点击只是体验优化不是业务防线这一点答辩时如果被问到要能说清楚。很多同学把精力花在按钮动画、页面炫酷上结果后端接口连基本校验都没有这是典型的本末倒置。前端还有一个容易忽略的点同一用户不能在不同浏览器或设备上重复抢购。前端 localStorage 只能挡住本机的重复点击挡不住多设备登录。所以这个限制必须下沉到后端靠 Redis 记录用户 ID 集合和数据库唯一索引来约束。2.2 Redis 和消息队列在这个项目里的不可替代性这套项目里 Redis 至少承担四个任务缓存秒杀商品信息、预减库存、记录已抢购用户集合、存储临时订单状态。其中预减库存是核心。为什么不让所有请求都直接去数据库扣库存因为数据库的并发能力有限而且扣库存事务一旦失败还有锁的开销。Redis 单线程模型配合原子命令处理判断库存 - 减库存 - 记录用户这类操作非常快可以在一瞬间把大量请求挡在最前面只有真正通过预扣的请求才会进入后面的流程。不过 Redis 预减库存和实际数据库扣库存之间必须保持一致。我的做法是Redis 预减成功并不代表订单创建成功真正落库的扣减在消息队列消费者里执行那里做最后的数据库原子扣减兜底相当于双保险。如果消费者发现数据库扣减失败必须反向补偿 Redis把预减的库存加回来并记一条日志。消息队列在项目里的价值是削峰和异步化。抢购瞬间每秒可能有大量请求进入后端如果每个都同步去创建订单、扣库存、写数据库数据库会承受不住。正确的思路是接口只做校验和预减库存校验通过后把下单任务丢进队列立即返回正在排队然后由消费者线程池按可控速度处理创建订单、扣库存、生成秒杀结果。用户端再通过轮询拿到最终结果。3. 数据库设计从商品表到订单表库存扣减的原子性约束3.1 核心表结构设计与关系我见过很多秒杀系统的数据库设计喜欢把所有字段堆在一张商品表里库存、秒杀价格、活动起止时间全往里塞。这样确实省事但设计上有缺陷普通商品的库存和秒杀活动的限量库存是两套逻辑。秒杀活动结束后商品要恢复正常售卖如果共用一个库存字段活动期间扣掉的数量就没办法自然归位。我建议核心表分为五张user 用户表主键、用户名、密码加盐哈希、手机号、创建时间。goods 商品表主键、商品名称、商品图片、普通库存、原价、描述。seckill_goods 秒杀活动表主键、商品 ID、秒杀价、秒杀库存、开始时间、结束时间、乐观锁版本号。order_info 订单表主键、订单号、用户 ID、商品 ID、商品数量、订单金额、状态、创建时间。seckill_order 秒杀订单表主键、用户 ID、秒杀活动 ID、订单号、创建时间。这里重点解释秒杀活动表为什么要单独存在。秒杀是一个时间段 限量库存的活动实体而不是商品本身的属性。把活动独立出来以后你可以同时配置多个活动、给不同活动设置不同开始时间后续扩展同一商品参与多场秒杀也不用改表结构。将来商品从秒杀状态恢复正常售价时只需要按活动维度处理代码逻辑会清晰很多。秒杀订单表和订单表的分工也需要说清楚。order_info 是完整的业务订单记录用户买了什么、付了多少钱、物流状态怎么样seckill_order 更像一张活动参与凭证专门用来约束一个用户在一场活动中只能命中一次。两者通过订单号关联。这样拆开之后查询某用户抢到了哪些秒杀商品可以用秒杀订单表直接联表不需要去全量订单表里过滤。3.2 乐观锁与数据库层防超卖的实现数据库层面的防超卖最终由一条带条件的原子更新兜底update seckill_goods set stock stock - 1 where id ? and stock 0;执行这条语句时MySQL 会对命中行加锁并把判断库存 0和扣减库存合并成一个原子操作。两个并发事务同时执行时后执行的那个会因为stock 0条件不满足而影响行数为 0程序据此判断抢购失败。执行结果大于 0 才表示扣减成功这是一个非常重要的返回值判断很多初学者只执行 SQL 不检查影响行数等于没有兜底。有人会问要不要用乐观锁版本号字段update seckill_goods set stock stock - 1, version version 1 where id ? and version ?;这种方案在秒杀场景里并不比stock 0更优。因为秒杀请求并发太高所有线程都读取同一个版本号最终只有一个线程能更新成功其他大量请求都会失败重试反而增加了数据库压力。我的建议是数据库层用stock 0保证库存正确性Redis 预减库存负责拦截大部分流量两者职责清晰互相补充。订单表与秒杀订单表之间也要设计约束。秒杀订单表对 user_id 和 seckill_id 建立唯一索引从数据库层面硬性保证同一个用户同一场活动只能生成一条秒杀订单记录。这是应对刷单的底线哪怕上层校验被绕过数据库也会拒绝重复插入。4. 端到端实现用户点击按钮到订单落库的完整链路4.1 Vue 端倒计时与下单按钮状态管理页面核心状态有三种未开始、进行中、已结束或已抢完。倒计时要用服务器时间而不是本地时间否则用户改本地时间就能绕过限制这个设计在演示时要主动说出来。页面加载时先请求后端获取服务器当前时间戳再和活动开始时间做差用setInterval每秒更新倒计时文本。template div classcountdown距离开始还有{{ countdownText }}/div el-button :disabledstatus ! started :loadingsubmitting clickhandleSeckill {{ statusText }} /el-button /template script setup import { ref, onMounted, onUnmounted } from vue import axios from axios const status ref(loading) // loading / notStarted / started / ended / soldOut const countdownText ref() const submitting ref(false) let timer null function startTimer() { timer setInterval(() { // 根据服务器时间与后端返回的活动时间字段计算剩余秒数 }, 1000) } async function handleSeckill() { if (submitting.value) return submitting.value true try { const res await axios.post(/api/seckill/doSeckill, { seckillId: 1 }) // 返回 code 200 表示排队成功其余为失败或重复购买提示 } finally { // 根据后续轮询结果再恢复按钮可用状态 } } onMounted(() { // 获取服务器时间和活动状态 startTimer() }) onUnmounted(() clearInterval(timer)) /script按钮的 disabled 和 loading 只是体验层优化真正判断能不能抢购完全以后端为准前端不做任何本地成功的判断。抢购成功后建议开启一个轮询接口比如每两秒查一次订单状态直到返回成功或失败再结束轮询。轮询接口本身要做频率限制不能设计成无限制循环。4.2 SpringBoot 秒杀接口的分层实现后端核心接口我建议拆成三块拦截器和注解做登录校验与限流Service 层做业务规则校验是否已经抢过、活动是否开始最后通过 Redis 原子脚本执行预减库存通过后发送 MQ 消息。对应代码结构如下PostMapping(/doSeckill) public ResultString doSeckill(RequestParam Long seckillId, HttpServletRequest request) { Long userId UserContext.getUserId(); // 1. 活动时间校验未开始或已结束直接返回 // 2. 重复抢购校验seckill_order 唯一索引兜底 // 3. Redis 预减库存返回可购买后发送 MQ // 4. 返回排队成功前端轮询结果 }Redis 预减库存建议用一次性 Lua 脚本把检查库存 0、检查用户已存在、扣减库存、记录用户合在一条原子操作里。为什么用 Lua因为 Redis 多个命令组合在并发下有窗口期命令之间可能被其他请求插队。而 Lua 脚本在服务端是单线程串行执行的一旦开始执行就不会被其他命令插入天然具备原子性。if redis.call(exists, KEYS[1]) 0 then return -2; -- 活动不存在或未初始化 end if redis.call(get, KEYS[1]) 0 then return 0; -- 已售罄 end if redis.call(sismember, KEYS[2], ARGV[1]) 1 then return -1; -- 已抢购过 end redis.call(decr, KEYS[1]); redis.call(sadd, KEYS[2], ARGV[1]); return 1; -- 扣减成功脚本执行通过后把 userId 和 seckillId 封装成消息发送到消息队列。消费者收到消息后做三件事校验用户是否已存在秒杀订单、执行数据库原子扣减、创建秒杀订单并生成订单信息。如果数据库扣减影响行数为 0说明 Redis 与数据库状态不一致要补偿回滚 Redis 的预减并记录告警日志。这一步是保证缓存与数据最终一致的关键也是答辩时的高分点。这里还要注意一个细节RabbitMQ 消费者的幂等性。消息可能因为网络抖动被重复投递消费者必须通过订单表里是否已存在该用户的秒杀订单来判重而不是直接插入。否则重复消费会导致一个用户收到多笔订单。5. 压测实录从几十并发下的告警到几百并发下的稳定5.1 用 JMeter 搭建压测场景秒杀项目的测试不是点一下能不能跑通而是要模拟瞬时并发。我用 JMeter 建一个线程组线程数 300、Ramp-Up 设为 0、循环次数 10让所有线程在同一时刻冲向秒杀接口。添加聚合报告后重点看三个指标QPS、错误率、平均响应时间。压测前一定要把数据库里的秒杀活动时间改成当前时间附近不然请求会全部被时间校验拦截压出来的数据没有任何意义。我早期就犯过这个错压测报告一片绿结果是所有请求都被挡在活动时间外接口根本没被真实触发。后来我的习惯是先在压测前手动调用一次接口确认返回的是业务结果而不是活动未开始。5.2 优化节点与最终压测数据对比不同方案在不同机器上的数据会有差异但在我的环境里几版方案对比大致如下方案QPS 均值错误率平均响应时间超卖情况直接操作数据库扣库存约 120约 6%2 秒以上有超卖加 Redis 预减库存同步下单约 520约 1%400ms 以上无超卖Redis 预减 MQ 异步下单约 1300约 0.2%150ms 左右无超卖第一版直接操作 MySQL几百并发下大量请求阻塞在数据库连接池部分请求超时报错更麻烦的是库存出现了负值那就是典型的超卖现场。第二版加入 Redis 预减QPS 上去了但因为下单逻辑仍是同步的每个排队成功的请求要等数据库事务完成才返回吞吐量被拖住。第三版引入 MQ 后接口只做预校验和扣减真正耗时的数据库操作交给消费者异步处理接口响应时间大幅下降错误率也降到可接受范围。压测之外还要关注补偿逻辑。比如 MQ 消息处理失败后是否重新入队Redis 扣减成功但数据库扣减失败时是否回滚 Redis超时订单如何关闭并回补库存。这些点普通压测不容易暴露但答辩时经常被追问实现时一定要有代码落点。我的建议是加一个定时任务每隔一段时间扫描那些排队成功但长时间未生成订单的记录超时则回滚库存并标记失效。6. 部署全过程本地打包、服务器环境与 Nginx 反向代理6.1 服务器环境准备与项目打包部署到服务器的第一步是准备环境。服务器需要 JDK 17或项目对应版本、Maven、MySQL、Redis、消息队列、Nginx。如果是云服务器启动完记得确认安全组和防火墙规则放开 80、443、后端端口和中间件端口否则外部访问不通很多人排查半天发现是端口没放开。后端打可执行 jar 包mvn clean package -DskipTests java -jar target/seckill.jar --spring.profiles.activeprod建议把数据库、Redis、MQ 的连接配置放到单独的 application-prod.yml 里本地开发用 application-dev.yml避免部署时临时改代码。数据库初始化直接导入项目提供的 SQL 脚本注意 MySQL 时区设置。很多秒杀项目放在国内服务器上MySQL 默认时区和系统时区不一致会导致活动时间判断出错这个坑很隐蔽。前端构建npm install npm run build构建完成后把 dist 目录上传到 Nginx 指定的静态目录。前端构建时如果用了环境变量配置接口地址记得在构建前把VITE_API_BASE这类变量改成服务器的实际地址否则打包出来的 JS 里写的是本地地址上线后全部请求都会失败。6.2 Nginx 配置与前、后端联调Nginx 同时干两件事托管 Vue 构建出的静态文件、反向代理后端接口。静态文件直接访问 80 端口接口请求统一走/api/路径并代理到本机的后端服务端口。server { listen 80; server_name _; root /usr/share/nginx/html/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files ... /index.html是 Vue Router 的 history 模式必须的配置否则刷新子页面会 404。联调时还要注意跨域线上用 Nginx 同域反代后基本不存在跨域问题本地开发则靠后端 CORS 配置允许指定来源。部署中常见的坑还有很多Redis 密码未配置导致连接被拒、消息队列默认账号 guest 无法远程登录需要新建用户、后端 jar 启动时端口被占用、前端调接口时把/api前缀配错。定位问题的时候先用curl验证接口通不通再看 Nginx 和 Java 应用日志按链路一层一层找不要靠猜。我的习惯是后端应用单独写一个启动日志文件每次启动后先tail -f看有没有报错再手动调一次秒杀接口确认流程正常。7. 答辩环节5 个最能拉开分差的问题与应答思路7.1 项目亮点的表述方式答辩老师的注意力通常在两个地方项目是不是你自己写的、你对高并发方案是不是真懂。因此讲解项目时不要上来就念功能列表用我发现了什么问题 - 我用了什么方案 - 达到了什么效果的结构讲效果会好很多。比如我发现直接操作数据库在并发下会超卖于是把库存判断和扣减放入 Redis 的 Lua 脚本配合 MQ 异步下单最终在压测中把 QPS 提升数倍。这种表述既是过程展示也证明了你的思考和动手能力。还有一个小技巧提前准备一张自己画的架构图不用花哨但要能清楚标出请求从浏览器到前端、再到后端 Controller、Service、Redis、MQ、MySQL 的完整路径。答辩时往屏幕上一放讲起来会顺很多。架构图不建议现场临时画提前做好图PPT 里或者演示文档里都放一份。7.2 高频问题拆解Q1你的系统如何防止超卖三层兜底数据库层用update seckill_goods set stock stock - 1 where id ? and stock 0实现原子扣减Redis 层用 Lua 脚本做预减库存秒杀订单表对用户和活动建唯一索引。任何一层失效都有下一层兜住。Q2Redis 和数据库库存不一致怎么办消费者在数据库扣减失败时回滚 Redis 预减并记录日志同时消费者处理消息前先查订单是否存在避免重复下单。缓存与数据库采用最终一致性方案。如果回滚也失败还会有定时任务扫描补偿。Q3为什么要用 MQ直接同步下单不行吗同步下单会让接口等待数据库事务完成抢购瞬时高并发会把数据库连接耗尽。MQ 让接口先返回排队结果后端按可控速率消费消息削峰填谷提升系统吞吐。Q4如果有人恶意刷接口怎么办前后端做限流常用令牌桶或滑动窗口后端接口对非法参数做校验Redis 记录用户维度访问频率超过阈值直接拒绝秒杀订单唯一索引兜底重复购买。安全性设计要讲清楚不止有一层。Q5秒杀开始的一瞬间Redis 缓存没有数据怎么办避免缓存击穿的核心是热点 key 不设置固定过期时间或者设置逻辑过期同时可以为数据库查询加互斥锁让同一时刻只有一个请求去查库并重建缓存。这样即使缓存失效也不会把数据库打垮。8. 项目报告写法如何把代码实现转化为清晰的文字如果这个项目要同步产出完整的项目报告报告结构基本按选题背景、需求分析、系统设计、数据库设计、系统实现、系统测试、总结来走。写作时有三个容易出彩的点第一需求分析不要只列用户登录、商品列表、秒杀、订单这种功能清单要画出角色与流程。秒杀至少涉及游客、已登录用户、管理员三类角色外加活动创建、活动上线、抢购、下单、结果查询几条主流程。流程描述清楚后面的表结构设计会更好落地。第二系统设计部分要强调分层架构。表现层Vue、接入层SpringBoot Controller 加拦截器、业务层Service、数据层Mapper 加 Redis 加 MQ、基础设施层MySQL、消息队列。不用贴大段代码用核心类职责和关键流程图说明设计意图即可。第三系统测试部分放压测数据。我会附上 JMeter 截图和文章里那张优化对比表说明每加一层组件对并发表现的影响。评审人看到测试数据比看到十页代码更能确信这个项目的真实性。报告最忌讳的是代码贴满、文字空泛。每个核心实现要先用一两句话说明设计理由再配关键代码片段最后补一句测试效果。比如库存扣减这一节先写为什么用原子更新而非先查后改再贴 SQL 和 Lua 脚本最后写压测结果结构就立住了。最后再分享一个实操小技巧做这类项目时一定要建立自己的测试记录表。每做一次优化就把并发数、QPS、错误率、超卖数量记下来顺手截个图。这份记录既是压测分析的依据也是答辩时的叙事素材比临时翻代码回忆高效得多。秒杀系统项目代码量不算大但它是少数能把并发、缓存、消息队列、数据库事务全串起来的毕业设计题目。如果你正在做或者准备做把文章里这些思路吃透别停留在能跑的层面。会跑和跑得稳是两个完全不同的项目。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑