搞过一个完整的企业级秒杀系统,SpringBoot + Vue + MyBatis + MySQL这套组合,从功能设计到最终上线压测,算是把整个链路摸了一遍。这篇文章就直接把项目的架构思路、核心实现、数据库优化、前端交互以及部署测试时踩过的坑一次说清楚,给正在做秒杀类需求、或者准备做高并发毕业设计/项目经验的同学一份可直接参考的完整清单。
先声明一下,文章里的所有代码片段、配置和压测数据都来自我这个模拟项目X的实测记录,不是什么虚构场景。秒杀系统听起来很唬人,把业务拆开其实就两件事:活动期间集中放量卖少量高热度商品,以及保证这个过程不出超卖、不出错账、不被脚本薅秃。看清楚这两个核心诉求,后面所有架构动作就都有了解释。
1. 秒杀真正的难度在哪:先看这个系统的设计出发点
很多人一上来就谈Redis、MQ、集群,其实脱离了业务场景去堆技术栈完全没意义。我在设计这套系统之前,先把秒杀场景的难点列成了三个层面,每个层面都有对应的技术考量。
1.1 毫秒级瞬时流量是表面难点,数据一致性才是内伤
秒杀场景的流量峰值有个很典型的特征:流量在一两秒内集中涌入,之后迅速回落。一个普通商品平时每秒几十个请求,秒杀开始那一刻可能直接冲到每秒几千甚至上万。流量曲线像心电图里的一个尖峰,和常规业务的平缓曲线完全不同。
但这个瞬时流量本身反而好办——Nginx做负载均衡、网关层限流、Redis挡在前面,能扛住大头。真正麻烦的是流量打到数据库之后,如何保证库存扣减不出错。
我举个例子:某商品库存只剩1件,1000个请求同时进来,在数据库层面全部读到库存为1,然后逐一执行扣减。如果只是简单的 update stock = stock - 1,第一批请求里可能好几百个都能执行成功,库存变成负数,这就是典型的超卖。所以秒杀系统的数据库层设计,核心就是要在并发写的情况下,保证库存数据的强一致性,而不是简单处理读请求的快慢。
1.2 超卖、重复下单、恶意刷单,本质都是并发写冲突
超卖是最致命的问题,但还有三个和超卖并列的难点,同样不能忽略:
- 重复下单:用户秒杀成功,但前端连续点击提交,后端收到多个创建订单的请求,结果生成了多笔相同商品的订单。如果不做幂等处理,订单表的记录会翻倍。
- 恶意刷单:脚本绕过前端倒计时,用大量请求直接打后端接口。服务端如果没有校验用户维度、设备维度、频率维度,活动库存会被同一个用户刷走一大半。
- 订单超时未支付:用户抢到了,但一直不付钱,库存被占用。需要有一个完整的订单超时取消机制,把库存释放回池子里。
这三个都和“并发写”有关。解决思路也不是靠某一个组件单独完成,而是要把限流、幂等、状态机、异步处理结合起来,在不同节点各挡一层。
2. 技术选型逻辑:为什么是SpringBoot+Vue+MyBatis+MySQL
标题里的这套技术栈,放在业界看属于非常主流的组合。不是说它性能天花板最高,而是它在开发效率、协作成本、维护便利性上足够均衡。这里把选型理由展开讲讲,这部分也是面试里经常被追问的。
2.1 后端框架选型的现实考量
SpringBoot在Java后端领域几乎成了项目的基础设施。它对配置做了大量的自动化处理,内嵌Tomcat让部署变得简单,生态里配套的Spring Security、Spring Validation、Spring Data Redis等组件,可以直接拿来接入到秒杀的各种能力模块中。我的项目里后端采用分层结构:Controller层只做参数接收和响应包装,Service层处理业务逻辑,DAO层通过MyBatis操作MySQL,各司其职。
SpringBoot本身解决的是“快速串联”的问题。秒杀系统要接Redis、接消息队列、接MySQL,如果不用SpringBoot,光是配置各类客户端连接和依赖管理就要费很大劲。用它之后,这些连接池、序列化、异常处理大多可以通过配置类和注解搞定。
MyBatis的选择也很直接:它让SQL保持可控。秒杀场景对SQL的性能极其敏感,MyBatis可以手写精细的SQL,包括库存扣减的原子条件更新、乐观锁版本号控制、复杂统计查询等,比全自动ORM更好排查SQL性能问题。另一个好处是SQL和Java代码分离在XML文件里,后续DBA评审SQL时可以直接翻阅XML,非常直观。
2.2 标题里没写Redis和MQ,但实战中必须有
严格说,企业级的秒杀系统,只有SpringBoot + Vue + MyBatis + MySQL是不够的。你仔细看标题里“MySQL架构”的表述,实际上这个系统在MySQL前面还叠加了两层中间件:Redis和消息队列(MQ)。
Redis承担的角色很明确:把热门的秒杀商品库存提前从MySQL加载到内存,用Lua脚本实现预减库存。因为Redis单线程执行命令,多个并发请求落在这个脚本上时会排队执行,天然避免了“同时读到相同库存值”的问题。MySQL只承接真正秒杀成功的那部分请求下单,数量会少几个数量级。
消息队列负责削峰和异步化。Redis预减成功后,把“用户ID + 商品ID + 秒杀令牌”的信息存入MQ,立即给前端返回“正在排队中”的状态。后端的消费者服务从MQ拉取消息,入库创建订单。这样DB承受的峰值写入速率等于MQ消费速率,而不是等于前端请求爆发速率。我在实际里用的是RabbitMQ,按商品维度建队列、按用户维度做消费幂等,跑得比较稳定。
所以这套系统的完整数据链路应该是:
前端Vue -> Nginx -> SpringBoot网关 -> Redis预减库存 -> MQ异步队列 -> MyBatis操作MySQL -> 订单落库标题没写Redis和MQ,但技术债躲不过。如果要让秒杀系统真正达到“企业级”,这两个中间件几乎是必备品,后面每一步优化都绕不开它们。
3. 核心业务流程拆解:秒杀链路每一步都在做什么
秒杀的完整流程不是只有“用户点按钮”那一刻,而是分成秒杀前、秒杀中、秒杀后三个时间段。每一步都有需要防的坑,下面按我的实际实现来拆。
3.1 秒杀前:热点数据预热与页面静态化
秒杀开始前,系统要做两件关键的事:数据预热和前端静态资源下沉。
数据预热是指把秒杀商品的库存、活动时间等热点数据提前写入Redis缓存。我在项目里做了一个专门的启动任务,扫描数据库里所有处于“待开启”状态的秒杀活动,把商品ID、总库存、活动开始时间、活动结束时间这些字段构造成一个Hash结构存进Redis,并为每个商品设置一个独立的库存键。
为什么不能等用户请求到了再查数据库加载缓存?因为秒杀开始的瞬间,如果Redis里没有缓存,大量请求全部打到MySQL查库存表,数据库连接池会直接被占满,其他正常业务跟着挂掉——这就是缓存击穿的风险。提前预热之后,秒杀期间MySQL只有在MQ消费时才可能被访问,读写冲突大大降低。
页面静态化指的是把商品详情页、秒杀倒计时页尽可能从后端模板渲染中剥离,改用CDN或者Nginx直接返回静态HTML。前端Vue打包后的静态资源本身就是静态文件,通过Nginx直接托管即可,这一层基本不耗费Java后端资源,提升非常明显。
3.2 秒杀中:Redis预减库存与MQ异步下单
用户点击“立即秒杀”后,请求会先经过几个前置校验:
- 接口限流:基于Sentinel或自定义拦截器,限制同一用户对同一秒杀接口的每秒请求数,超过阈值直接拒绝并返回“操作过于频繁”。
- 时间校验:比较当前时间与活动开始时间、结束时间,不在窗口内的请求一律打回,避免提前刷接口。
- 参数校验:商品ID、活动ID的合法性校验,防止遍历接口试探活动规则。
通过前置校验后,进入核心的Redis预减库存步骤,我用的是Lua脚本。这个脚本做了两个操作:一是检查当前时间是否在活动窗口内,二是执行库存扣减并返回值。因为Lua脚本是原子执行的,所以并发请求进来后,Redis逐个执行,不会出现库存扣成负数的情况。
Redis扣减成功的请求,会拿到一个seckillToken(用户维度的唯一凭证),同时将消息发送到RabbitMQ。消息内容只包含秒杀单号、用户ID、商品ID、活动ID,不包含全量商品信息,降低序列化开销。
MQ消费者在收到消息后,先校验秒杀Token是否有效,再用MyBatis写入秒杀订单表。订单表的主键设计为雪花ID(或者带业务前缀的单号),方便后续分库分表和业务追溯。
这里有个很关键的细节:RabbitMQ消费端的并发线程数和数据库连接池大小需要匹配。我在配置里把消费者的prefetchCount设置为10,SpringBoot的数据库连接池最大连接数设置为20,这样消费速度不会瞬间打满数据库连接,给正常业务留出余量。
3.3 秒杀后:订单状态校验与未支付回滚
秒杀成功后,前端跳到订单详情或“等待支付”页面。此时订单状态是待支付,系统在MQ消费者落库时会记录订单创建时间和过期时间。我的方案是:订单创建后15分钟内未支付,自动取消,把库存数量回补到Redis和MySQL。
订单超时处理有几种常见方式:
| 方案 | 优点 | 我的实测结论 |
|---|---|---|
| 定时任务批量扫描订单表 | 实现简单,无额外组件 | 订单量大时SQL扫描成本高,延迟严重 |
| RabbitMQ延迟消息 | 精准延迟,对代码侵入小 | 需要单独配置延迟插件,运维成本略高 |
| Redis过期键监听 | 无需额外组件 | 过期消息可能延迟或丢失,不建议强依赖 |
| 定时任务 + 延迟消息混合 | 兼顾精度和兜底 | 我最终用的是这个方案 |
我最后选择的是RabbitMQ延迟消息作为主通道,同时保留一个5分钟扫描一次的兜底定时任务。这能保证即使延迟消息偶尔丢失,也不会导致库存长期被无效订单占用。
释放库存的操作同样涉及原子性。我的做法是:在MySQL里更新订单状态为“已取消”,同时更新商品库存表加回库存;随后再刷新Redis中的库存键。这两步由一个事务方法包裹,先改库再加缓存,保证最终一致性。
4. 数据库层的深坑与优化:MyBatis里那几条SQL怎么写得稳
数据库是秒杀系统最容易出问题的地方,也是性能瓶颈的终点。这一章把我实际反复改了几版的SQL和事务设计详细列出来。
4.1 库存扣减SQL的正确写法
对比两种库存扣减SQL的写法:
-- 错误的写法:读-判断-写,非原子操作 SELECT stock FROM seckill_goods WHERE id = #{goodsId}; -- 判断如果 stock > 0 UPDATE seckill_goods SET stock = stock - 1 WHERE id = #{goodsId};第一步查询得到库存值,第二步更新扣减。两个请求同时执行第一步时,看到的结果都是剩余1件,两步并发后库存可能变成-1。虽然可以在Java代码里加synchronized锁,但分布式环境下多个应用实例之间锁不互通,依然会出问题。
-- 正确的写法:条件更新,原子扣减 UPDATE seckill_goods SET stock = stock - 1 WHERE id = #{goodsId} AND stock > 0 AND seckill_activity_status = 1;这里的关键是WHERE stock > 0。MySQL执行update时,会对满足条件的行加锁,同一时间只有一个事务能更新这一行的库存。更新后返回受影响行数,Java层判断:
- 返回1,说明扣减成功
- 返回0,说明库存已经扣完或者活动未开启
mybatis的Mapper接口返回值可以直接是int,不需要额外查询再判断。这条SQL是秒杀系统不超卖的最后一道防线,Redis预减可能会出现极端情况下的不一致(比如缓存被外部修改),但这条SQL兜底保证了数据库层的最终正确性。
4.2 事务边界设计:别把Redis和MySQL混在一个事务里
要让秒杀系统的性能和一致性兼得,事务边界的划分至关重要。很多初学者会把Redis预减库存和MySQL订单创建写在同一个@Transactional方法里,这样不仅让事务放大到包含远程调用,还会导致两个问题。
第一,事务时间不可控。Redis操作虽然快,但在高并发下网络往返需要时间,MySQL写库存又要获取锁,整个事务耗时一长,数据库连接很快被占满。
第二,Redis和MySQL不在同一个事务管理器里。Spring的@Transactional只能管DataSource事务,Redis的操作无法回滚。如果MySQL下单失败,Redis里的库存已经扣了,数据就产生不一致。
我的实现是把秒杀链路拆成两个独立事务:
流程拆解:
- 无事务的Controller层代码先执行Redis预减库存
- 预减成功的请求发送到MQ后立即返回
- MQ消费者在独立事务中创建订单
- 如果创建订单失败,通过补偿机制把库存回补到Redis
这样做的好处是每个环节的事务都很短,Redis操作不占用数据库连接。真正往数据库写数据的操作只有下单事务和回滚库存事务,数量少且可控。
4.3 索引与慢查询的排查经验
秒杀系统的订单表会快速增长,索引设计必须从建表开始规划清楚。我的订单表核心索引包括:
- 订单号字段唯一索引:用于幂等判断和按单号查询
- 用户ID + 创建时间联合索引:用于用户查询自己的秒杀记录
- 活动ID + 商品ID联合索引:用于统计活动销量
- 订单状态 + 创建时间联合索引:用于超时扫描和未支付订单查询
初期我没建“订单状态 + 创建时间”这个联合索引,结果超时扫描任务每5分钟执行一次全表扫描,订单量到几十万的时候,一次扫描要一二十秒,严重拖垮正常查询。后来补上联合索引,扫描耗时降到几百毫秒以内。
在开发环境开启MySQL的慢查询日志,线上保持合理阈值:
slow_query_log = 1 slow_query_log_file = /var/log/mysql/slow.log long_query_time = 1通过mysqldumpslow定期分析慢日志,能快速找出没走索引的SQL。这个习惯在秒杀项目里尤其重要,因为秒杀场景SQL模式相对固定(就那几条高频语句),只要这几条SQL都能走对索引、保持浅查询,数据库基本稳得住。
5. 前端Vue实战:倒计时、防重复提交与结果轮询
前端在秒杀系统里不是摆设。体验做不好,用户一样会骂;防刷做不好,后端再极限的架构也会被打穿。这章分享Vue端的核心设计。
5.1 秒杀按钮的状态机设计
秒杀按钮不能只有“可点”和“不可点”两种状态,至少要是五种状态:
| 状态 | 按钮文案 | 用户操作 |
|---|---|---|
| 活动未开始 | “即将开始” | 禁止点击,显示倒计时 |
| 活动进行中 | “立即秒杀” | 可点击 |
| 秒杀排队中 | “排队中…” | 禁止重复点击 |
| 秒杀成功 | “查看订单” | 跳转订单页 |
| 秒杀失败/已结束 | “已抢光” | 禁止点击 |
我用Vue的computed属性根据当前时间、活动时间、用户状态动态计算按钮样式和点击事件。倒计时是秒杀前使用的核心体验,这里有个大坑:不能直接用本地系统时间计算倒计时。因为用户本地时间和服务器时间可能差几分钟甚至更久,倒计时结束时机不一致。
我的做法:从后端接口请求当前服务器时间,算出和活动开始时间的差值,然后在前端用setInterval每秒更新剩余时间,同时记录一个基准差值。每次执行setInterval时用Date.now() - 基准差值计算相对时间,避免系统时间被修改导致倒计时跑偏。
订单状态的流转,前端也会通过路由守卫拦截:未登录用户不允许进入秒杀提交页。
5.2 结果查询:异步轮询比WebSocket更务实
用户点击秒杀后、MQ异步处理完成前,前端不能干等着。如果同步调用接口,MQ消费和下单的耗时会直接占住HTTP连接,体验很差。我的选择是提交后立即返回“排队中”,然后前端每隔2秒轮询一次订单查询接口。
轮询接口返回三种结果:
- 已创建订单:跳转订单详情页
- 还在排队:继续轮询,最多轮询30秒
- 秒杀失败:提示活动太火爆,请重新尝试
轮询接口本身要轻,不要全表查订单。我的实现是先查Redis里的用户秒杀状态Key,如果状态为“成功”再查MySQL订单详情;如果Redis没有状态,再查一下MySQL兜底。这样绝大多数请求都在Redis层面就返回了,不会压到数据库。
有人建议用WebSocket推送下单结果,更“高大上”,但实际落地时连接管理、断线重连、集群广播都是额外成本。对秒杀这个场景,轮询几十秒钟完全可接受,实现和维护都简单得多。
5.3 前端防刷的核心:服务端才说了算
前端可以做很多限制:按钮置灰、禁止复制口令、验证码等。但这些只是辅助,不要把防刷希望全寄托在前端脚本上。前端做如下限制:
- 秒杀按钮点击后加锁,
disabled状态持续到本次请求结束 - 同一用户每个商品每日最多提交一次,用localStorage记录标记
- 对请求做签名:后端下发token,前端带上签名提交
但这些很容易被绕过——脚本可以直接调接口,根本不走页面逻辑。所以真正的防刷在后端:
- 用户维度限流(同一个user_id每秒最多一次)
- IP维度限流
- 设备指纹(前端生成UUID存本地,作为辅助识别)
- 验证码兜底(秒杀失败一次后强制弹验证码)
我的项目里把这些逻辑统一放在Gateway层或SpringBoot拦截器中,不管前端发什么请求,后端统一校验,才能防住脚本刷单。
6. 压测结果与性能数据:这些数字是我实测出来的
没用数据说话的项目分享都是空谈。我基于JMeter做了一轮完整压测,记录了几组关键数据,也暴露了三个需要优化的点。这章贴出我的测试方式和结论。
6.1 压测工具和压测场景怎么搭
压测用的JMeter,模拟场景设计为:1000个线程同时发起秒杀请求,覆盖一个总库存500件的限时活动。测试环境配置为一台应用服务器(4核8G)、一台Redis、一台MySQL(8.0),所有服务跑在同一台机器里,测量的是系统整体极限,而不是单一组件。
主要的压测断言包括:
- 接口错误率低于0.1%
- P99响应时间小于500ms
- 数据库库存数量与订单记录一致
- 无超卖记录,即订单数不超过库存数
测试命令举例,如果走命令行模式可以用:
jmeter -n -t seckill.jmx -l seckill_result.jtl -e -o report6.2 从数据看到的问题与调优
第一轮压测的原始数据并不理想,主要瓶颈暴露在:
- MySQL连接池被占满:下单事务里查了太多次数据库,每次秒杀请求在MySQL端产生4次查询
- Redis连接耗尽:Lua脚本和状态查询共用一个连接池,默认配置太小
- MQ消费速度跟不上生产速度:导致队列积压,订单状态迟迟不更新
针对这些点做了三轮调整:
第一轮:将商品详情、活动信息等查询全部Redis化,MySQL只承担订单写入和最终库存扣减,数据库查询次数从4次降到1次。
第二轮:给Redis连接池和MySQL连接池分别独立配置,避免不同业务的连接相互抢占。
第三轮:给MQ消费者增加合理线程数,并启用批量消费模式,让消费速率与水位的读写能力匹配。
调优后的压测数据:
| 指标 | 调优前 | 调优后 |
|---|---|---|
| QPS(聚合报告) | 780 | 2100 |
| P99响应时间 | 1.8s | 380ms |
| 错误率 | 2.1% | 0.03% |
| 超卖记录 | 0 | 0 |
| 数据库平均事务耗时 | 46ms | 8ms |
数据库事务耗时下降尤其明显,因为事务里只剩一条条件更新SQL和一条订单插入SQL,且都走了主键索引和唯一索引,锁竞争很少。
6.3 极限容量估算与降级方案
基于压测数据,我用一个简单的换算公式估算极限容量:
系统最大QPS ≈ 单机QPS × 节点数
按单机性能2100 QPS估算,部署3台应用节点,理论上能达到6300 QPS。不过这只在流量均匀分布的情况下成立。秒杀流量瞬时爆发,高峰期如果是6300 QPS同时进来,Redis和MySQL依然有压力。
在线上环境我配置了三层降级方案:
- 限流降级:接口超过设定的QPS阈值时,直接返回“排队人数过多”,由Gateway层拦截处理
- 缓存降级:如果Redis不可用,秒杀接口直接熔断,不继续往MySQL打,避免拖垮数据库
- 静态兜底页:前端检测到接口异常时,展示活动火爆的统一页面,保证用户有反馈而不是白屏
降级不是“系统挂了才做的事”,而是在设计时就预设好的策略。秒杀活动开始前,运维应该提前确认各组件运行状态,必要时提前扩容节点。
7. 上线前后踩过的坑:比功能更值得记录的细节
最后这部分,是整篇分享里我认为最值钱的部分。很多坑都是在模拟环境的“成功跑通”中完全发现不了的,只有放到接近真实的场景里才会爆发。
7.1 库存预热时的缓存雪崩
我的商品预热任务最初把所有活动的库存写在同一个时间点,统一设置了活动当天零点过期。结果活动结束后大量Key同时过期,导致新活动开始前,系统频繁回源数据库,数据库连接数飙升。后来我把Key的过期时间设计成“活动开始后随机20-60秒过期”的错峰过期策略,同时为秒杀商品设置了兜底永不过期的库存Key(由定时任务主动更新),尽量降低缓存雪崩概率。
7.2 MQ消费积压导致订单超时
有次压测时我故意调大了单条消息的处理耗时,结果消息生产速率远大于消费速率,RabbitMQ队列积压了几万条消息。用户端看到的结果是:明明抢到了,订单查询接口却一直提示“排队中”。而等待超过15分钟后,部分订单被超时扫描任务标记为取消,用户彻底失去了购买机会。
顺序问题非常微妙:超时扫描任务扫描的是“订单创建时间”,但这个时间是在MQ消费后才落库的,用户抢购成功到订单落库之间有延迟,这个延迟完全可能超过15分钟,导致订单被误判为超时。我把超时窗口从15分钟调成了30分钟,同时给MQ消费者增加线程数、开启批量消费,让消息从“生产”到“落库”的延迟降到秒级,问题才消失。
7.3 接口防重的重复请求陷阱
前后端分离模式下,前端点击“立即秒杀”后如果网络抖动,用户会刷新页面再点一次。如果不做幂等,同一个用户同一秒可能会提交两到三次请求。我在服务端做了一层基于Redis SETNX的请求去重,以seckill:user_goods_activity:{userId}:{goodsId}为幂等键,请求到达时尝试写入,写入成功才继续,否则直接返回“重复提交”。幂等键设置了活动结束后的一小段额外保留时间,防止活动结束后新请求误判。
这个处理在功能测试阶段基本测不出来,因为手工点击的间隔太长,直到我用脚本模拟两次500ms内的连续请求时才暴露出重复下单问题。秒杀系统只要沾上“高并发”三个字,就要把所有看似“人不会这么操作”的场景都当作会发生的场景去设计。
写在最后
开发这套秒杀系统最大的感受是:难点从来不是写一个能用的功能,而是让它在极限流量下依然能稳定、正确、有反馈地运行。SpringBoot + Vue + MyBatis + MySQL这套技术栈,配合Redis和MQ做缓冲削峰,是目前投入产出比很高的一类方案。整个项目从数据库建表到前端页面再到压测调优,过程非常锻炼对并发和事务的理解。
如果你准备拿这个项目做面试谈资,重点不是背住多少类名和方法名,而是能讲清楚四个关键决策:库存扣减SQL为什么那样写、Redis和MySQL的数据一致性怎么保持、MQ消费积压怎么处理、订单超时怎么回滚。把这四条链路讲透,技术含量自然就出来了。顺着这个思路走,你的企业级秒杀系统,才真正算得上“完整版”。