Springboot+Vue秒杀系统设计与实现:Redis预减库存与RabbitMQ异步下单
2026/9/13 14:45:06 网站建设 项目流程

简介:这是一份基于Spring Boot和Vue的秒杀系统毕业设计项目包,面向Java学习者、毕业设计学生以及需要前后端分离实战案例的开发者。项目源码已经过导师指导与答辩评审,定位在高并发抢购场景的完整教学与演示,覆盖后端接口、前端页面、数据库表结构和部署配置,适合毕业设计复用或期末作业参考。资源共810个文件,压缩包约20MB,包含123个Java后端类、47个Vue组件、164个JavaScript脚本、53个CSS样式,同时还有HTML页面、SVG图标、GIF演示,以及SQL数据库脚本、论文文档、答辩PPT等,目录清晰,类型完整。目前已有50人学习下载。除可运行源码外,还提供数据库脚本、论文、PPT、使用说明文档和一键安装打包脚本,可从项目规划、数据库建模、接口联调到底层部署逐步查阅,既能帮助理解秒杀系统的并发处理与缓存策略,也可按照文档快速完成本地部署与答辩演示。

1. 秒杀系统难在哪:瞬时流量、超卖与接口幂等

秒杀系统是Java面试和毕业设计里最常见的“看起来简单、做起来全是坑”的项目。用户点击“立即秒杀”只是前端一秒钟的动作,后端要在同一时刻面对几千个请求打向同一个商品ID、同一行库存,还要保证不超卖、不重复下单。基于Springboot+Vue的秒杀系统设计与实现,核心就是拆解这股瞬时流量。

这套方案不像普通CRUD那样直接操作数据库,而是通过数据库建模、Redis预减库存、消息队列异步下单,把瓶颈逐个击破。无论做毕业设计还是写进简历,都能讲清楚“为什么这么设计”。

读者包括正在做Java毕设的学生、需要落地秒杀业务的后端开发,以及准备Springboot和Vue面试题的候选人。下面给出可直接复现的表结构、Lua脚本和队列参数。

2. Springboot+Vue秒杀系统的数据库建模与库存扣减方案

2.1 秒杀商品表与订单表:字段设计和唯一索引

秒杀系统的第一版代码往往是两张表:商品表和订单表。商品表记录秒杀商品的基本信息,订单表记录谁在什么时间秒杀了哪个商品。在建表时,有几处是专门为秒杀场景设计的。

CREATE TABLE seckill_goods ( id BIGINT AUTO_INCREMENT PRIMARY KEY, goods_name VARCHAR(120) NOT NULL COMMENT '商品名称', stock INT NOT NULL DEFAULT 0 COMMENT '剩余库存', seckill_price DECIMAL(10,2) NOT NULL COMMENT '秒杀价', start_time DATETIME NOT NULL COMMENT '秒杀开始时间', end_time DATETIME NOT NULL COMMENT '秒杀结束时间', version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='秒杀商品表';

订单表需要建立用户和商品的联合唯一索引,这是防止“同一个人重复秒杀”的数据库级防线:

CREATE TABLE seckill_order ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, goods_id BIGINT NOT NULL, order_sn VARCHAR(64) NOT NULL COMMENT '全局唯一订单号', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付 2已取消', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME NULL, UNIQUE KEY uk_user_goods (user_id, goods_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='秒杀订单表';

表字段说明:stock采用INT而非BIGINT,因为秒杀商品的库存很少会超过百万,能节省索引空间;version是为乐观锁预留的字段,如果用Redis预减或悲观锁,可以不用它;order_sn使用VARCHAR(64),由“时间戳+用户ID+随机数”生成,避免使用数据库自增主键直接暴露订单号。uk_user_goods联合唯一索引不是简单的加速查询,它能让插入订单时由数据库直接拒绝重复记录。

有人会在订单表上额外加goods_id普通索引,方便按商品维度查询订单,但要注意索引数量和写入性能的权衡。秒杀场景下订单写入是主要压力,索引过多会明显拉低INSERT吞吐。常见做法是只保留uk_user_goods唯一索引,商品维度的统计交给离线报表。

2.2 为什么不能直接“查库存再减库存”,以及SQL原子扣减

很多第一版代码是:先SELECT stock,在Java里判断if (stock > 0),然后执行UPDATE seckill_goods SET stock = stock - 1 WHERE id = ?。这个流程在并发量一上来立刻超卖,因为两个线程可能同时读到库存为1,都通过判断,然后各自执行UPDATE,最终库存变成 -1,订单却有两个。

正确的数据库写法是让“判断库存是否充足”和“扣减库存”在同一条原子SQL中完成:

UPDATE seckill_goods SET stock = stock - 1 WHERE id = #{goodsId} AND stock > 0

对应的MyBatis Mapper配置:

<update id="deductStock"> UPDATE seckill_goods SET stock = stock - 1 WHERE id = #{goodsId} AND stock > 0 </update>

这条SQL执行后,返回受影响行数。若为1表示扣减成功,若为0表示库存不足。原子性由InnoDB的行锁保证:同一时刻只有一个事务能修改这一行,其他请求会等待行锁释放。这样不会超卖,但问题是所有请求都串行等待同一行锁,数据库行锁等待时间会随着并发上升急剧拉长,甚至出现死锁。所以业界不把数据库扣减作为唯一防线,而是作为最终兜底。

下表对比了三种常见库存扣减方案:

方案防超卖能力并发吞吐实现复杂度适用场景
纯数据库原子扣减低(行锁瓶颈)低并发秒杀、兜底补偿
Redis预减 + 数据库最终扣减中小型秒杀、毕业设计
Redis预减 + 消息队列异步下单高并发秒杀、生产级

需要明确,Redis预减不是不扣数据库,而是把“谁的请求能进入下单流程”提前到Redis这一层过滤掉,最终库存的准确性仍然由数据库的stock > 0条件兜底。毕业设计建议选择第二种方案,代码量适中,又能清晰说明缓存和数据库的一致性处理。

2.3 预扣减的一致性问题:DB失败后的回补

一旦引入Redis预减,就必须处理“预减成功、数据库扣减失败”的情况。比如请求通过了Redis,进入@Transactional方法,但插入订单时异常,或者数据库UPDATE返回0,这时需要把Redis中减掉的库存加回去。

@Transactional(rollbackFor = Exception.class) public SeckillResult doSeckill(Long goodsId, Long userId) { Long remain = redisStockService.deduct(goodsId); if (remain == null || remain < 0) { return SeckillResult.fail(Code.SOLD_OUT); } try { int rows = seckillGoodsMapper.deductStock(goodsId); if (rows == 0) { redisStockService.rollback(goodsId); return SeckillResult.fail(Code.SOLD_OUT); } String orderSn = generateOrderSn(); seckillOrderMapper.insert(buildOrder(userId, goodsId, orderSn)); return SeckillResult.success(orderSn); } catch (Exception e) { redisStockService.rollback(goodsId); throw e; } }

代码逻辑:deduct内部使用Redis的DECRBY扣减,返回剩余库存;如果剩余为负,直接返回售罄。数据库扣减失败或插入订单失败时,调用rollback执行INCR加回。INCR本身是原子的,多个失败请求同时回补时不会错乱。

这个写法有一个边界问题:如果数据库扣减成功、订单插入成功,但事务提交前服务宕机,事务会回滚,可Redis已经减掉库存,此时不会执行回补,Redis和数据库会暂时不一致。生产系统会引入定时对账任务,扫描订单量和Redis剩余库存做校准;毕业设计可以在答辩时说明这个缺陷,并给出定时对账的解决思路,反而会成为加分项。

3. Springboot秒杀后端:Controller、Redis预减与RabbitMQ异步下单

3.1 Controller层参数校验与接口限流

秒杀接口必须使用POST,不能用GET。GET 会被浏览器预加载,也会被日志系统记录,用户多点几次就会产生大量无效请求。Controller 只做参数绑定和结果封装,业务逻辑放在Service层。

@RestController @RequestMapping("/api/seckill") public class SeckillController { @PostMapping("/{goodsId}") public Result<SeckillResp> seckill(@PathVariable Long goodsId, @RequestParam Long userId) { if (goodsId == null || goodsId <= 0 || userId == null) { return Result.fail(Code.PARAM_ERROR); } return seckillService.doSeckill(goodsId, userId); } }

参数说明:goodsId从路径获取,userId在真实项目中应该从登录态获取,而不是由前端传入;毕业设计为了方便演示,常由前端通过请求参数携带。接口限流的常见做法是针对每个商品ID做令牌桶限流,可使用 Guava RateLimiter 或 Redis 计数器。简单实现是在Service入口添加拦截器,检查用户ID是否在“允许参秒名单”中,或对单个用户做SETNX一分钟一次的粗粒度限流。

3.2 用Redis+Lua实现原子预减库存

Redis 的DECRBY虽然原子,但“查询剩余库存是否大于0再扣减”这个组合操作不是原子的。要保证“判断+扣减”一次完成,使用Lua脚本。

-- KEYS[1] seckill:stock:{goodsId} -- ARGV[1] 扣减数量 local stock = tonumber(redis.call('get', KEYS[1]) or '-1') if stock > 0 then redis.call('decrby', KEYS[1], ARGV[1]) return stock - 1 end return -1

Java调用:

public Long deduct(Long goodsId) { String key = "seckill:stock:" + goodsId; DefaultRedisScript<Long> script = new DefaultRedisScript<>(); script.setLocation(new ClassPathResource("lua/deductStock.lua")); script.setResultType(Long.class); return stringRedisTemplate.execute(script, Collections.singletonList(key), "1"); }

说明:脚本返回扣减后的剩余库存,返回 -1 表示已售罄。Lua 脚本在Redis服务端执行,整个过程不会被打断,因此高并发下不会出现超减。每次执行脚本需要传输脚本内容,Spring Data Redis 内部会缓存脚本SHA,不会造成明显性能损耗。

秒杀开始前,需要将商品库存预热到Redis:

redis-cli SET seckill:stock:1001 10

预热应在秒杀开始前由管理员操作或定时任务完成,不要等到第一个用户请求时才初始化,否则第一波高并发会全部穿透到数据库。

3.3 RabbitMQ异步下单与死信队列处理超时订单

如果秒杀接口里同步执行“扣库存+插入订单”,数据库压力仍然很大。更稳妥的做法是:Redis预减成功后,把userId + goodsId封装成消息发送到RabbitMQ,接口立即返回“排队中”,消费者再执行真实下单事务。这样削峰后,数据库每秒处理的请求可以降低一个数量级。

public SeckillResult seckill(Long goodsId, Long userId) { // 检查秒杀时间段、幂等 Long remain = stockService.deduct(goodsId); if (remain < 0) return Result.fail(SOLD_OUT); try { SecKillMessage msg = new SecKillMessage(userId, goodsId); rabbitTemplate.convertAndSend( "seckill.exchange", "seckill.routing", msg, message -> { message.getMessageProperties().setExpiration("5000"); return message; } ); return Result.success("排队中", 1); } catch (Exception e) { stockService.rollback(goodsId); return Result.fail(SYSTEM_BUSY); } }

消息属性setExpiration("5000")表示消息在订单队列中等待5秒后变为死信,配合死信队列实现订单超时未支付自动取消。生产上更可靠的做法是使用RabbitMQ延迟交换机插件,但毕业设计用TTL+死信队列足够。RabbitMQ核心配置如下:

配置项说明
exchangeseckill.exchange交换机名称,类型为topic
queueseckill.order.queue订单队列,绑定秒杀消费者
routing keyseckill.routing路由键,发送消息时指定
queue TTL5000ms消息未消费时过期进入死信队列
dead letter exchangeseckill.dlx死信交换机
dead letter queueseckill.cancel.queue取消订单队列,消费者执行支付超时取消

消费者使用@RabbitListener监听队列,并在@Transactional中执行“数据库扣减库存 + 插入订单”。注意消费者要处理消息幂等,因为RabbitMQ的重试或网络抖动可能导致同一条消息重复消费。幂等方案是依赖订单表uk_user_goods唯一索引,插入时捕获DuplicateKeyException后直接确认消息。

3.4 订单超时未支付的自动取消

下单后用户一直不支付,库存需要释放。做法是:秒杀订单消息进入订单队列时携带TTL,如果5秒内未完成支付,消息转入死信队列;死信消费者读取消息后执行“取消订单 + 回补Redis库存 + 回补数据库库存”。回补库存时仍然要保证“更新订单状态”和“增加库存”的先后顺序:

UPDATE seckill_order SET status = 2 WHERE order_sn = #{orderSn} AND status = 0;

如果受影响行数为1,再执行UPDATE seckill_goods SET stock = stock + 1 WHERE id = #{goodsId}。这个条件更新相当于状态机上的乐观锁,避免用户恰好支付成功时库存被重复加回。

4. Vue3秒杀页面:倒计时、按钮状态与订单结果轮询

4.1 Vue3 + Vite 初始化与秒杀商品列表渲染

前端部分推荐Vue3 + Vite,而不是Vue2 + vue-cli。Vite启动更快,模板更简洁。初始化命令为npm create vite@latest seckill-ui -- --template vue,进入目录后安装axios:npm install axios。在vite.config.js中配置开发代理,将/api转发到Springboot的8080端口:

export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })

代理配置说明:前端页面运行在5173,后端在8080。如果不在Vite里配置代理,直接请求/api会被浏览器拦截跨域。changeOrigin: true会把请求头里的Host改为目标地址,避免后端校验域名。

商品列表渲染在onMounted中调用后端秒杀商品列表接口,模板用v-for渲染卡片。需要注意后端返回的时间字段建议使用时间戳毫秒数(Long),不要直接传字符串,否则前端计算倒计时还要解析,且容易踩时区坑。

4.2 倒计时组件与按钮禁用逻辑

秒杀页面的核心是“不同时间段显示不同文字、按钮处于不同状态”。用Vue3的组合式API实现可复用倒计时组件:

<template> <div class="seckill-card"> <img :src="goods.pic" :alt="goods.name"> <h4>{{ goods.name }}</h4> <p>秒杀价:{{ goods.seckillPrice }} 元</p> <p>{{ remainText }}</p> <button :disabled="!canSeckill" @click="handleSeckill"> {{ buttonText }} </button> </div> </template> <script setup> import { ref, computed, onMounted, onUnmounted } from 'vue' import axios from 'axios' const props = defineProps({ goods: { type: Object, required: true } }) const now = ref(Date.now()) const submitting = ref(false) let timer = null onMounted(() => { timer = setInterval(() => { now.value = Date.now() }, 200) }) onUnmounted(() => { clearInterval(timer) }) const status = computed(() => { if (now.value < props.goods.startTime) return 'notStart' if (now.value > props.goods.endTime) return 'end' return 'start' }) const remainText = computed(() => { const diff = props.goods.startTime - now.value if (status.value === 'notStart') { const seconds = Math.ceil(diff / 1000) return `距开始 ${seconds} 秒` } if (status.value === 'end') return '已结束' return '火热秒杀中' }) const canSeckill = computed(() => { return status.value === 'start' && !submitting.value }) const buttonText = computed(() => { if (submitting.value) return '排队中...' if (status.value === 'notStart') return '看预告' if (status.value === 'end') return '看详情' return '立即秒杀' }) async function handleSeckill() { if (!canSeckill.value) return submitting.value = true try { const { data } = await axios.post(`/api/seckill/${props.goods.id}`, null, { params: { userId: currentUserId() } }) if (data.code === 0) { pollOrder(data.data.orderSn) } else { // data.message 展示给用户 } } finally { submitting.value = false } } </script>

逻辑说明:now每200毫秒更新一次,倒计时不会出现秒级跳动;按钮状态由status计算属性驱动。点击按钮后submitting立即为truefinally中恢复,这是防止用户重复点击的关键。canSeckill同时判断是否在秒杀时间段内、是否正在提交,避免按钮文字和实际状态不一致。

这里要注意,如果页面商品数量特别多,每个商品都挂一个定时器会浪费CPU。常见做法是只对“距离开始时间小于1小时”的商品启动定时器,或者统一使用一个全局倒计时服务,把时间同步到所有组件。

4.3 订单结果轮询与错误提示

秒杀接口返回“排队中”后,前端不能干等,需要用轮询查询最终结果。后端订单状态码定义如下:

状态值含义前端动作
0待支付继续轮询
1已支付提示秒杀成功
2已取消/超时提示支付超时

轮询函数实现:

function pollOrder(orderSn, times = 0) { if (times > 20) return // 超过20次停止,提示稍后到订单中心查看 setTimeout(async () => { const { data } = await axios.get(`/api/order/${orderSn}`) if (data.data.status === 1) { message.success('秒杀成功,请支付') } else if (data.data.status === 0) { pollOrder(orderSn, times + 1) } else { message.error('支付超时,订单已取消') } }, 1000) }

参数说明:首次调用pollOrder(orderSn),轮询间隔1秒,每次次数加1,最多20次;状态为0继续轮询;状态为1提示成功;状态为2提示失败。轮询的终止条件还可以增加“当前时间超过秒杀结束时间3秒”,避免结束后继续浪费请求。

另一个隐藏点是:前端禁用按钮只能提升用户体验,不能防止脚本刷量。真正防刷要依赖后端限流和风控,前端disabled不是一个安全边界,这一观点在答辩时被问到可以直接讲清楚。

5. 压测与调优:JMeter模拟高并发、JVM和数据库连接池参数

5.1 JMeter线程组设置与聚合报告指标分析

秒杀系统上线前必须压测。JMeter足够完成这个任务。在JMeter中新建线程组,设置线程数500、Ramp-up时间1秒、循环次数1,HTTP请求填写POST http://localhost:8080/api/seckill/1001?userId=1001。为了让每次请求的userId不同,可以在CSV数据文件里准备500个用户ID,这样能真实触发订单表唯一索引的约束。

命令行运行测试计划并生成报告:

jmeter -n -t seckill.jmx -l result.jtl -e -o report

参数说明:-n非GUI模式,-t指定测试计划文件,-l输出采样日志,-e生成HTML聚合报告,-o输出目录。运行结束后重点看聚合报告中的吞吐量、错误率和响应时间百分位(p90/p99)。

如果错误率超过1%,优先检查后端日志中是否有数据库连接池满、Redis连接超时、Tomcat线程耗尽三类异常。很多项目在本地压测500并发时,第一个崩溃点不是业务代码,而是Springboot内嵌Tomcat默认的200线程被全部占满,后续请求直接拒绝。此时可以调大server.tomcat.max-threads,但不要无限调大,线程数增加会带来上下文切换开销。

5.2 数据库连接池与Redis连接池参数调优

数据库连接池是另一个瓶颈。Springboot默认使用HikariCP,如果引入Druid则以Druid为准。下面是Druid常用配置及推荐值:

参数推荐值说明
initial-size10启动时创建的核心连接数
min-idle20最小空闲连接数,避免突发流量时频繁建连
max-active100最大活动连接数,超过则排队等待
max-wait60000获取连接最大等待毫秒数,超时抛异常
validation-querySELECT 1空闲连接校验SQL
test-while-idletrue空闲时校验,防止被断掉的连接被业务使用

配置示例:

spring: datasource: druid: initial-size: 10 min-idle: 20 max-active: 100 max-wait: 60000 validation-query: SELECT 1 test-while-idle: true redis: lettuce: pool: max-active: 200 max-idle: 50 min-idle: 10

参数调优的原则不是“越大越好”。max-active设为500而MySQL本身最大连接数是200,超出的请求一样要等待;MySQL单库推荐连接数一般不超过CPU核数的3倍。Redis的max-active要结合Lettuce的线程模型来设置,Lettuce本身是异步非阻塞的,连接池过大会浪费内存,200在当前场景通常够用。

注意:连接池参数不是越大越好,压测时观察连接活跃曲线再调整,否则会产生大量空闲线程,反而降低整体吞吐。

5.3 JVM参数和Springboot内嵌Tomcat的坑

启动秒杀服务时,建议显式设置JVM内存参数:

java -Xms512m -Xmx512m -XX:+UseG1GC -jar seckill-server.jar

-Xms-Xmx相等可以避免堆扩容抖动;UseG1GC适合对停顿时间敏感的服务。在容器里运行时要结合容器内存上限,比如容器限1G,堆不要设为512m以上,否则会触发OOM杀容器。对于毕业设计,不用过度调GC,把-Xmx设置合理即可。

内嵌Tomcat还有几个容易踩的坑:server.tomcat.accept-count默认100,是等待队列长度;max-connections默认8192,但受线程数和文件描述符限制。如果压测时出现大量Connection refused,先看accept-count是否被短连接打满,再看Linux文件描述符ulimit -n是否需要提高。

6. 秒杀系统答辩技巧:ER图、压测数据与docker-compose一次性演示

最后一步不是继续写代码,而是把系统变成能被评审老师快速理解的演示环境。一个可靠的做法是把整个环境用docker-compose编排起来,现场一条命令启动,不依赖人工先启动MySQL再启动Redis的繁琐顺序。

services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: root MYSQL_DATABASE: seckill_db ports: ["3306:3306"] redis: image: redis:7-alpine ports: ["6379:6379"] rabbitmq: image: rabbitmq:3.13-management ports: ["5672:5672", "15672:15672"] seckill-app: build: ./server depends_on: [mysql, redis, rabbitmq] ports: ["8080:8080"]

启动命令是docker-compose up -d,稍等几十秒后访问http://localhost:8080/api/seckill/1001即可验证。image指定镜像版本,ports是宿主和容器的端口映射,environment设置初始账号和数据库名,depends_on只控制容器启动顺序,并不能保证服务已就绪,所以需要在Springboot的application.yml中配置较长的连接重试时间,或者加上healthcheck让应用等数据库可用后再启动。

使用说明文档不需要写成长篇操作手册,把docker-compose up -d、默认密码 root、端口对应关系,以及“Redis未启动时秒杀接口会返回系统繁忙”这类现象写清楚,就能覆盖大多数现场运行问题。

答辩PPT的演示顺序建议固定为“三连”:先演示正常秒杀成功;再演示库存只剩1件时两个账号同时点击,只有一个人成功、另一个人看到“已售罄”;最后打开JMeter报告,展示压测前后的吞吐量对比。这样能一次性覆盖高并发、防超卖、异步削峰三个核心卖点。如果被问到“Redis和数据库不一致怎么办”,直接打开日志确认回补调用,再补充定时对账方案作为生产兜底。最后把ER图、流程图、时序图三张图画清楚,评审老师不会再看多余代码。

本文还有配套的精品资源,点击获取

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

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

立即咨询