ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Springboot+Vue秒杀系统设计与实现:Redis预减库存与RabbitMQ异步下单

2026/9/13 14:45:17 拓冰建站 浏览量
Springboot+Vue秒杀系统设计与实现:Redis预减库存与RabbitMQ异步下单 简介这是一份基于Spring Boot和Vue的秒杀系统毕业设计项目包面向Java学习者、毕业设计学生以及需要前后端分离实战案例的开发者。项目源码已经过导师指导与答辩评审定位在高并发抢购场景的完整教学与演示覆盖后端接口、前端页面、数据库表结构和部署配置适合毕业设计复用或期末作业参考。资源共810个文件压缩包约20MB包含123个Java后端类、47个Vue组件、164个JavaScript脚本、53个CSS样式同时还有HTML页面、SVG图标、GIF演示以及SQL数据库脚本、论文文档、答辩PPT等目录清晰类型完整。目前已有50人学习下载。除可运行源码外还提供数据库脚本、论文、PPT、使用说明文档和一键安装打包脚本可从项目规划、数据库建模、接口联调到底层部署逐步查阅既能帮助理解秒杀系统的并发处理与缓存策略也可按照文档快速完成本地部署与答辩演示。1. 秒杀系统难在哪瞬时流量、超卖与接口幂等秒杀系统是Java面试和毕业设计里最常见的“看起来简单、做起来全是坑”的项目。用户点击“立即秒杀”只是前端一秒钟的动作后端要在同一时刻面对几千个请求打向同一个商品ID、同一行库存还要保证不超卖、不重复下单。基于SpringbootVue的秒杀系统设计与实现核心就是拆解这股瞬时流量。这套方案不像普通CRUD那样直接操作数据库而是通过数据库建模、Redis预减库存、消息队列异步下单把瓶颈逐个击破。无论做毕业设计还是写进简历都能讲清楚“为什么这么设计”。读者包括正在做Java毕设的学生、需要落地秒杀业务的后端开发以及准备Springboot和Vue面试题的候选人。下面给出可直接复现的表结构、Lua脚本和队列参数。2. SpringbootVue秒杀系统的数据库建模与库存扣减方案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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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 iddeductStock 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 ResultSeckillResp 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 用RedisLua实现原子预减库存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 -1Java调用public Long deduct(Long goodsId) { String key seckill:stock: goodsId; DefaultRedisScriptLong 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不会造成明显性能损耗。秒杀开始前需要将商品库存预热到Redisredis-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交换机名称类型为topicqueueseckill.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 vitelatest seckill-ui -- --template vue进入目录后安装axiosnpm 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 classseckill-card img :srcgoods.pic :altgoods.name h4{{ goods.name }}/h4 p秒杀价{{ goods.seckillPrice }} 元/p p{{ remainText }}/p button :disabled!canSeckill clickhandleSeckill {{ 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秒、循环次数1HTTP请求填写POST http://localhost:8080/api/seckill/1001?userId1001。为了让每次请求的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空闲连接校验SQLtest-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图、流程图、时序图三张图画清楚评审老师不会再看多余代码。本文还有配套的精品资源点击获取