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

发布时间:2026/10/11 2:44:48
SpringBoot+Vue秒杀系统实战:高并发架构与防超卖设计
1. 秒杀系统到底难在哪先搞懂瓶颈再动手写代码很多人一看到秒杀系统这四个字第一反应就是这不就是个下单功能吗确实从业务逻辑上看秒杀就是一次库存扣减加一个订单生成单看接口本身并不复杂。但真正把它放到高并发场景里事情就完全变了。我经常打一个比方普通电商下单就像小区门口的便利店顾客稀稀拉拉收银员慢悠悠扫码就行。秒杀系统则是春运期间的火车站售票窗口几千人同时扑向同一个窗口这时候比拼的已经不是你会不会卖票而是你怎么让队伍不乱、窗口不崩、票不超卖。拆分一下秒杀系统的核心痛点其实集中在四个层面第一流量洪峰。秒杀开始那一瞬间大量请求会在几秒内集中涌入。如果所有请求都直接打到数据库数据库的连接池很快就会被耗尽响应时间急剧上升最终表现为系统假死甚至宕机。第二库存超卖。这是最致命的问题。数据库层面的原子扣减如果写得不对两个请求同时读到库存还剩1件各自执行扣减结果卖出去2件。这在真实业务里就是事故。第三重复下单。一个用户疯狂点击按钮或者用脚本批量提交同一秒杀活动重复下单可能会导致一人抢到多件或者生成大量无效订单冲垮下游的订单系统。第四系统雪崩。秒杀接口挂了之后如果上游网关没有做降级和限流请求还会持续压进来拖垮其他正常的业务接口最后整个应用都跟着遭殃。所以一个可直接运行的秒杀系统绝不只是一堆CRUD代码堆在一起而是要在架构上把上述四个问题都考虑到。下面这份SpringBoot后端Vue前端MySQL的源码项目走的正是这套思路接下来我把它拆开讲透。2. 项目整体结构与技术选型为什么是SpringBootVueMySQL这个组合2.1 项目分层与目录结构拿到源码之后先把项目结构看清楚。这个项目是典型的前后端分离工程后端一个目录前端一个目录数据库脚本单独放。seckill-system/ ├── backend/ # SpringBoot后端工程 │ ├── src/main/java/ │ │ ├── controller/ # 控制层秒杀接口、订单接口、商品接口 │ │ ├── service/ # 业务层秒杀核心逻辑、订单处理 │ │ ├── dao/ # 数据访问层MyBatis映射 │ │ ├── entity/ # 实体类用户、商品、秒杀商品、订单 │ │ ├── config/ # 配置类Redis、拦截器、跨域配置 │ │ ├── interceptor/ # 拦截器登录校验、限流拦截 │ │ └── common/ # 公共类统一返回、全局异常 │ └── src/main/resources/ │ ├── mapper/ # MyBatis XML映射文件 │ └── application.yml # 数据源、Redis等配置 ├── frontend/ # Vue前端工程 │ ├── src/ │ │ ├── views/ # 页面商品列表、秒杀详情、订单列表 │ │ ├── api/ # axios接口封装 │ │ ├── router/ # 路由配置 │ │ └── store/ # 状态管理Vuex │ └── package.json ├── sql/ │ └── seckill.sql # 建库建表脚本含测试数据 └── README.md # 启动说明这套分层没有搞花里胡哨的设计Controller只管接收参数和返回结果Service层承载秒杀核心逻辑DAO层通过MyBatis操作MySQL。对于想拿它做毕业设计、课程设计或者作为入职前的练手项目来说这种结构是最容易读懂的。2.2 技术选型的理由SpringBoot就不用多解释了它把Spring家族的配置工作大幅简化内嵌Tomcat打一个jar包就能跑。后端不需要额外安装Tomcat对新手极其友好。Vue作为前端框架优势在于组件化开发和响应式数据绑定。秒杀详情页里的倒计时、商品信息展示、下单按钮状态切换用Vue写起来比原生JS清爽太多。而且Vue生态里的Element UI组件库可以直接拿来搭后台管理界面效率很高。MySQL负责持久化存储。商品信息、用户信息、订单信息都落到MySQL秒杀场景中库存扣减这个关键操作也依赖MySQL的事务和行锁来保证正确性。Redis在这个项目里的角色也很关键。虽然标题里没写Redis但源码中实际上用Redis做了几个重要的事情缓存秒杀商品的库存、预减库存、存储用户是否已购买过。Redis的高并发读写能力配合MySQL的持久化形成一个典型的Redis挡流量 MySQL落数据组合。2.3 数据库表设计的门道打开sql/seckill.sql核心表一共四张用户表user存用户ID、手机号、密码MD5加密后、昵称等。秒杀场景下用户表本身不复杂但要注意ID不要用自增而是用雪花算法生成的分布式ID。原因很简单如果表的数据量上亿自增ID既不安全容易被爬虫遍历也不好做分库分表。商品表goods存储普通商品信息包括商品名、图片、价格、库存等。这里的库存字段代表的是商品总库存。秒杀商品表seckill_goods这是整个系统的核心表。它和商品表是一对一关系除了关联商品ID之外还多了秒杀价格、秒杀库存、秒杀开始时间、秒杀结束时间。把秒杀库存单独拆出来而不是直接用商品库存字段是因为同一件商品可以有多个秒杀场次每个场次的秒杀价和库存都不同。订单表seckill_order用户ID、商品ID、秒杀商品ID、订单状态、下单时间。这张表数据量会非常大真实生产环境里通常还要做水平分表比如按用户ID分1024张表。这个项目作为演示单表即可。设计上的一个小细节值得注意秒杀订单表对(用户ID, 秒杀商品ID)建了唯一索引。这招是防重复下单的关键手段之一后面讲秒杀逻辑时会展开说。3. 秒杀核心流程拆解从用户点击按钮到库存扣减中间发生了什么3.1 完整的请求链路用户在Vue页面上点击立即秒杀按钮整个流程大致如下前端把用户ID、秒杀商品ID作为参数调用后端接口/api/seckill/doSeckill。请求先经过登录拦截器校验用户是否已登录。进入秒杀接口后先查Redis判断该用户是否已经购买过这场秒杀如果买过直接返回请勿重复下单。用Redis的decr命令对秒杀库存做预扣减。如果扣减后的值小于0说明库存已抢完直接返回商品已售罄并且把库存回补。库存预扣减成功后创建订单写入MySQL的秒杀订单表同时把用户购买记录写入Redis。返回下单成功前端跳转到订单详情页。可能有人会问为什么不在第5步直接同步返回因为数据库写入是有瓶颈的。真实秒杀场景中如果所有抢购成功的人都同步等在接口上等数据库把订单写完接口RT会非常长用户体验极差而且数据库压力也扛不住。这个项目的处理方式是在校验和预减库存之后先快速返回排队中或下单成功然后通过异步操作把订单真正落库。异步可以用的方案有线程池、消息队列、本地异步任务。最简单的做法就是Spring的Async注解配合线程池把插入订单表的操作丢到独立线程去执行。3.2 缓存预热秒杀开始之前把库存搬进Redis这里有个很多新手容易忽略的环节缓存预热。如果秒杀商品的库存只在MySQL里而Redis里没有那么秒杀一开始所有请求还是会穿透到数据库Redis就形同虚设。正确做法是在秒杀开始之前通过一个管理接口或者定时任务把秒杀商品的剩余库存从数据库加载到Redis。项目里通常在商品上架秒杀活动时执行类似下面的操作// 秒杀活动开始时把库存放入Redis String seckillStockKey seckill:stock: seckillGoodsId; redisTemplate.opsForValue().set(seckillStockKey, String.valueOf(stock));这样用户请求进来时第一步的库存查询和扣减都在内存中完成完全不碰数据库。预热时机的选择也很重要——过早加载可能库存不准过晚加载会导致前几秒的请求全部打到数据库。通常的做法是活动开始前1分钟由定时任务执行预热并在日志里打印预热结果。3.3 防重复下单Redis标记 数据库唯一索引双保险防重复下单有两个层面。第一层是应用层用户点击按钮后前端立刻禁用按钮并显示排队中避免手抖重复提交。后端也要做同样的事情不能把信任交给前端。第二层是数据库层在秒杀订单表上对(用户ID, 商品ID)建唯一索引。即便应用层校验漏掉了数据库的唯一索引也能兜底。这个项目里Redis侧的判断代码大致是这样的// 判断用户是否已经秒杀过该商品 String seckillUserKey seckill:user: userId : seckillGoodsId; Boolean hasKey redisTemplate.hasKey(seckillUserKey); if (Boolean.TRUE.equals(hasKey)) { return Result.error(您已经参与过该商品的秒杀请勿重复下单); }有一类场景需要额外考虑用户抢单成功但支付超时订单取消后重新抢购。这种场景下Redis里的用户购买标记要跟随订单状态一起清理否则用户永远无法再买。这个项目作为基础版没有引入订单超时自动取消和库存回补的逻辑如果要投入真实生产这两块是必须补上的。3.4 库存扣减的原子性为什么不能用先查再改很多第一次接触秒杀系统的同学会写出下面这样的代码SeckillGoods goods seckillGoodsDao.selectById(goodsId); if (goods.getStockCount() 0) { // 库存大于0执行扣减 }这段逻辑有严重的并发问题。两个请求同时读到stockCount 1都通过了if判断然后都去执行update最终库存变成 -1超卖了。正确的做法是直接用一条UPDATE语句完成条件扣减UPDATE seckill_goods SET stock_count stock_count - 1 WHERE id #{seckillGoodsId} AND stock_count 0这条SQL利用数据库行锁来保证原子性。多个请求同时执行时只有一个请求能成功把stock_count从正数减为0其他请求因为stock_count 0的条件不满足更新影响行数为0由此判断库存已卖完。配合前面的Redis预减库存整体逻辑就变成双保险了Redis先挡住绝大多数请求MySQL的原子扣减保证最终不超卖。即使Redis挂了MySQL兜底逻辑依然能保证数据正确。4. 高并发防护三板斧限流、削峰、异步4.1 接口限流Google Guava RateLimiter的使用秒杀接口是全系统压力最大的一个接口如果没有限流恶意脚本可以瞬间发起成千上万个请求。这个项目中使用Google Guava的RateLimiter做简单的接口限流。Guava RateLimiter是一个令牌桶算法的实现。它的核心思想是系统以固定的速率往桶里放令牌每个请求必须拿到一个令牌才能通过。如果桶里的令牌被取完了后来的请求要么等待要么直接拒绝。// 创建限流器每秒放行100个请求 private RateLimiter rateLimiter RateLimiter.create(100); // 在秒杀接口入口处进行限流 if (!rateLimiter.tryAcquire()) { return Result.error(系统繁忙请稍后再试); }有人看到RateLimiter.create(100)可能觉得100太少了。要知道这100是真正进入业务逻辑的请求数秒杀系统最怕的就是脚本把所有请求都打进业务层。真实项目中还会结合Nginx层的limit_req模块、网关层的Sentinel或Gateway来做多级限流Guava属于应用层兜底。注意Guava RateLimiter是单机版的只在当前进程内生效。如果后端部署了多个实例每个实例各自有独立的令牌桶整体放行量会乘以实例数。分布式限流需要借助Redis实现比如用INCR 过期时间做一个固定窗口计数器。这个项目定位是单机演示用Guava足够但如果部署集群这一点要改。4.2 秒杀地址隐藏防止脚本提前请求再往外多讲一层很多秒杀项目还有一个最常见的漏洞秒杀接口暴露脚本可以提前请求。常规做法是秒杀开始前后端生成一个动态的秒杀路径MD5加盐后的随机字符串用户每次点击秒杀按钮时先请求一个获取秒杀路径的接口拿到路径后才用这个路径去请求真正的秒杀接口。public String createSeckillPath(long userId, long seckillGoodsId) { String md5 DigestUtils.md5Hex(userId # seckillGoodsId #salt); redisTemplate.opsForValue().set(seckill:path: seckillGoodsId : userId, md5, 60, TimeUnit.SECONDS); return md5; }这样做的好处是脚本无法在秒杀开始前就构造出合法的秒杀请求URL只有用户真正点击了按钮前端才会向后端索取动态路径。虽然不能完全杜绝脚本但可以把攻击成本大幅抬高。基础版的源码可能没有实现这一步但作为改进建议这是新手最容易做出的亮点升级之一。4.3 消息队列削峰异步落单的进阶之路上面提到的Async是异步的入门玩法真实项目更推荐用消息队列RocketMQ、RabbitMQ、Kafka来做削峰。区别在于Async只是把任务丢进JVM线程池如果服务重启线程池里还没执行完的任务直接丢失。消息队列则多了一层持久化保障消息发到Broker之后即使消费者服务宕机消息也不会丢等消费者恢复后再继续消费。用消息队列之后秒杀接口的流程变成这样Redis预减库存判断是否卖完。Redis判断用户是否已购买。都通过后把下单消息发送到MQ接口立刻返回正在排队中。MQ消费者接收到消息执行数据库建单和真实库存扣减。用户在页面上轮询订单状态看到支付待确认。这个项目的可直接运行定位决定了它没有引入MQ但我建议拿到源码后自己加上这条链路因为消息队列才是秒杀架构里削峰填谷的灵魂。面试问秒杀系统时如果只答得上Async答不上MQ削峰会明显少一个层次。5. 前端设计与Vue页面交互倒计时、秒杀按钮、订单轮询5.1 商品列表与详情页前端Vue页面整体走的是SPA单页应用风格路由如下/goods 商品列表页 /goods/:id 商品详情页含秒杀信息 /order 订单列表页 /order/:id 订单详情页商品列表页用Element UI的卡片组件展示商品每一张卡片上显示商品主图、名称、秒杀价、原价、秒杀倒计时和立即秒杀按钮。这里有一个交互小细节秒杀开始之前按钮是禁用状态倒计时结束后才变为可点击。很多前端新手会直接用前端本地时间做倒计时这有个大坑——用户电脑的系统时间可以随便改把时间往后调一个小时倒计时瞬间结束用户就能提前点按钮。正确做法是进入页面时先请求后端接口拿到服务器时间然后以前端时间为基准倒计时同时每次向服务器发送秒杀请求时后端校验当前时间是否处于秒杀时段内。// 获取服务器时间的接口 api.getServerTime().then(res { const serverTime res.data.time; // 用服务器时间启动倒计时 startCountdown(serverTime, seckillEndTime); });5.2 axios拦截器与登录状态管理项目里的axios封装做了两件事统一在请求头携带token统一处理后端返回的特定状态码。// axios请求拦截器 service.interceptors.request.use(config { const token localStorage.getItem(token); if (token) { config.headers[Authorization] token; } return config; }); // 响应拦截器处理业务错误码和登录失效 service.interceptors.response.use(response { const res response.data; if (res.code 401) { // 登录过期跳回登录页 router.push(/login); } return res; });后端相应地用拦截器在每次请求中校验token校验不过直接返回401。这个项目没有引入Spring Security或Shiro而是用了自定义拦截器加简单的token校验逻辑简单、体量轻适合作为演示项目。5.3 订单状态的轮询处理秒杀接口返回排队中之后前端不能一直傻等而是需要轮询订单状态。这个项目里采用的方案是下单后前端启动一个setInterval定时器每2秒请求一次订单状态接口。const timer setInterval(() { api.getOrderStatus(orderId).then(res { if (res.data.status 1) { clearInterval(timer); this.$message.success(秒杀成功请尽快支付); this.$router.push(/order/ orderId); } else if (res.data.status -1) { clearInterval(timer); this.$message.error(秒杀失败库存已售罄); } }); }, 2000);轮询虽然简单但要注意一点轮询间隔不能太短否则大量订单查询请求也会给后端带来压力。2秒到5秒是一个比较合理的区间。更优雅的方案是前端用WebSocket或SSE接收后端推送但轮询对于秒杀场景来说已经够用而且实现成本低很多。6. 项目的部署运行与常见问题排查6.1 从零启动这个项目拿到源码后启动步骤如下初始化数据库本地启动MySQL用Navicat或命令行执行sql/seckill.sql脚本生成数据库和表结构脚本里自带测试数据。修改后端配置打开application.yml把数据源的用户名和密码改成你自己的。注意spring.redis配置同样要检查一遍。启动Redis建议用Docker一行命令启动docker run -d --name redis -p 6379:6379 redis:6启动后端在backend目录下执行mvn spring-boot:run或者运行主类SeckillApplication。看到Started SeckillApplication就说明OK了。启动前端在frontend目录下依次执行npm install和npm run dev浏览器访问http://localhost:8080。登录测试脚本里预置了测试用户登录后进入商品列表页点击秒杀按钮即可体验完整流程。6.2 能跑和跑得好之间的差距这个项目能直接跑通不代表它已经达到生产级别。拿到源码后我建议从以下几个方向继续打磨优化1引入秒杀令牌机制。每次秒杀请求都要先获取动态URL同时可以加一个接口防刷的逻辑——同一个用户ID在1秒内只能获取一次秒杀路径超出次数限制直接返回操作过于频繁。优化2完善订单状态机。目前订单只有待支付和已关闭两种基础状态真实秒杀业务还需要已支付已发货已完成退款中等状态以及订单超时未支付自动取消的定时任务。优化3加一个简单的压力测试。用好压、JMeter或者Locust对着秒杀接口压一把看看在200并发下系统的表现然后针对性地优化。你大概率会发现MySQL连接池默认配置不够需要调整spring.datasource.hikari.maximum-pool-size还有Tomcat的server.tomcat.max-threads。压测时我自己遇到过这样的场景200并发点进去数据库层没崩但前端大量请求出现连接超时。排查后发现是Tomcat默认工作线程数200一旦跑满所有新请求都会排队等待前端自然超时。把server.tomcat.max-threads调到500同时server.tomcat.accept-count设置一个合理排队上限情况立刻好转。优化4增加日志和监控。利用Spring Boot Actuator暴露健康检查接口配合一个简单的定时脚本记录接口耗时。秒杀场景下接口RT波动2~3倍都算正常但如果超过5秒大概率是数据库连接或Redis连接出了问题。6.3 项目运行中的常见报错与解决方法整理几个实操中最常遇到的报错场景报错现象可能原因解决办法Access denied for user rootlocalhostapplication.yml中数据库密码错误检查用户名密码与本地MySQL一致Unable to connect to Redis本地Redis没有启动或端口不对启动Redis确认application.yml中的redis配置前端页面空白 / 请求404前端代理未配置跨域失效检查vue.config.js中的proxy目标地址是否为后端端口秒杀提示库存不足但数据库库存还有Redis缓存没有预热重启后端调用管理接口重新加载库存PacketTooBigExceptionMySQL缓冲区过小调整MySQL的max_allowed_packet参数秒杀时下单接口偶尔超时MySQL连接池过小调大Hikari连接池的maximum-pool-size如果启动后前端一直报跨域错误优先检查vue.config.js里的proxy配置。这个项目前端开发服务器通常跑在8080后端跑在8081两者端口不同必然产生跨域要么在后端配全局CORS要么用proxy把前端的/api请求转发到后端地址。源码里两种方式都做了属于双保险所以一旦改了端口两边的配置都要同步改。7. 扩展思考从课程设计到生产级秒杀中间还差什么如果你打算拿这个项目去做答辩或者作为简历项目面试官大概率会追问一个问题你觉得这个项目距离线上秒杀系统还差什么这里给出几个方向性思路。第一分布式环境下的一致性。单机版的Redis预减库存和MySQL扣减库存在集群部署时会遇到新的问题多台后端实例同时操作同一把库存锁需要用Redisson等分布式锁来保证互斥。另外本地缓存的一致性、分布式session、分布式ID生成策略都需要重新设计。第二优雅降级与熔断。秒杀开始瞬间流量超预期怎么办把秒杀模块单独拆成独立服务用Hystrix或Sentinel做熔断一旦秒杀服务不可用只影响秒杀功能不影响商品浏览、购物车、下单等主流程业务。同时要设计排队等待页作为兜底页面让用户不至于面对404或白屏。第三数据库的最终一致性处理。消息队列削峰之后订单表的写入是异步的但用户需要知道自己的抢购结果。这时候有两种做法一是用户主动轮询二是后端通过WebSocket推送。前者简单但体验一般后者体验好但增加了连接管理复杂度。即时通讯推送这块做得好项目整体质感会明显提升。第四安全防护。秒杀接口做好了库存控制还要防恶意刷单。同一个IP在短时间内高频率请求要触发验证码或IP限流。同一用户多台设备同时抢需要通过用户行为分析来识别。这些都加进去系统才真正能扛住黑产脚本的冲击。我个人在实际操作中的感受是拿到一份能跑的源码最容易犯的错误就是想一步到位把它改造成生产级系统结果改到一半项目启动不了了。我的建议是先原封不动跑通一遍理解每个模块在做什么然后每次只选一个点去优化比如先加秒杀路径隐藏跑通后再加MQ再加分布式锁。每加一个点就做一轮压测对比数据这样你的理解才是扎实的。秒杀系统这个题目能做出来的项目很多但能讲清楚每个设计决策为什么这么做的人才是真正值钱的。