☰
SpringBoot+Vue秒杀系统实战:高并发架构与防超卖设计
2026/10/11 2:44:43 网站建设 项目流程

1. 秒杀系统到底难在哪:先搞懂瓶颈,再动手写代码

很多人一看到"秒杀系统"这四个字,第一反应就是"这不就是个下单功能吗?"确实,从业务逻辑上看,秒杀就是一次库存扣减加一个订单生成,单看接口本身并不复杂。但真正把它放到高并发场景里,事情就完全变了。

我经常打一个比方:普通电商下单就像小区门口的便利店,顾客稀稀拉拉,收银员慢悠悠扫码就行。秒杀系统则是春运期间的火车站售票窗口,几千人同时扑向同一个窗口,这时候比拼的已经不是"你会不会卖票",而是"你怎么让队伍不乱、窗口不崩、票不超卖"。

拆分一下,秒杀系统的核心痛点其实集中在四个层面:

第一,流量洪峰。秒杀开始那一瞬间,大量请求会在几秒内集中涌入。如果所有请求都直接打到数据库,数据库的连接池很快就会被耗尽,响应时间急剧上升,最终表现为系统假死甚至宕机。

第二,库存超卖。这是最致命的问题。数据库层面的原子扣减如果写得不对,两个请求同时读到库存还剩1件,各自执行扣减,结果卖出去2件。这在真实业务里就是事故。

第三,重复下单。一个用户疯狂点击按钮,或者用脚本批量提交,同一秒杀活动重复下单,可能会导致一人抢到多件,或者生成大量无效订单,冲垮下游的订单系统。

第四,系统雪崩。秒杀接口挂了之后,如果上游网关没有做降级和限流,请求还会持续压进来,拖垮其他正常的业务接口,最后整个应用都跟着遭殃。

所以,一个"可直接运行"的秒杀系统,绝不只是一堆CRUD代码堆在一起,而是要在架构上把上述四个问题都考虑到。下面这份SpringBoot后端+Vue前端+MySQL的源码项目,走的正是这套思路,接下来我把它拆开讲透。

2. 项目整体结构与技术选型:为什么是SpringBoot+Vue+MySQL这个组合

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页面上点击"立即秒杀"按钮,整个流程大致如下:

  1. 前端把用户ID、秒杀商品ID作为参数,调用后端接口/api/seckill/doSeckill。
  2. 请求先经过登录拦截器,校验用户是否已登录。
  3. 进入秒杀接口后,先查Redis,判断该用户是否已经购买过这场秒杀,如果买过直接返回"请勿重复下单"。
  4. 用Redis的decr命令对秒杀库存做预扣减。如果扣减后的值小于0,说明库存已抢完,直接返回"商品已售罄",并且把库存回补。
  5. 库存预扣减成功后,创建订单写入MySQL的秒杀订单表,同时把用户购买记录写入Redis。
  6. 返回下单成功,前端跳转到订单详情页。

可能有人会问:为什么不在第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之后,即使消费者服务宕机,消息也不会丢,等消费者恢复后再继续消费。

用消息队列之后,秒杀接口的流程变成这样:

  1. Redis预减库存,判断是否卖完。
  2. Redis判断用户是否已购买。
  3. 都通过后,把"下单消息"发送到MQ,接口立刻返回"正在排队中"。
  4. MQ消费者接收到消息,执行数据库建单和真实库存扣减。
  5. 用户在页面上轮询订单状态,看到"支付待确认"。

这个项目的"可直接运行"定位决定了它没有引入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 从零启动这个项目

拿到源码后,启动步骤如下:

  1. 初始化数据库:本地启动MySQL,用Navicat或命令行执行sql/seckill.sql脚本,生成数据库和表结构,脚本里自带测试数据。
  2. 修改后端配置:打开application.yml,把数据源的用户名和密码改成你自己的。注意spring.redis配置同样要检查一遍。
  3. 启动Redis:建议用Docker一行命令启动:
    docker run -d --name redis -p 6379:6379 redis:6
  4. 启动后端:在backend目录下执行mvn spring-boot:run,或者运行主类SeckillApplication。看到"Started SeckillApplication"就说明OK了。
  5. 启动前端:在frontend目录下依次执行npm install和npm run dev,浏览器访问http://localhost:8080。
  6. 登录测试:脚本里预置了测试用户,登录后进入商品列表页,点击秒杀按钮,即可体验完整流程。

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 'root'@'localhost'application.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,再加分布式锁。每加一个点,就做一轮压测对比数据,这样你的理解才是扎实的。秒杀系统这个题目,能做出来的项目很多,但能讲清楚"每个设计决策为什么这么做"的人,才是真正值钱的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询