Spring Boot秒杀系统实战:Redis+RabbitMQ高并发源码解析

📅 发布时间:2026/10/9 14:52:14
Spring Boot秒杀系统实战:Redis+RabbitMQ高并发源码解析
简介这是一套基于SpringBoot的电商秒杀系统完整项目源码面向计算机相关专业的在校学生、教师及企业开发者尤其适合作为毕业设计、课程设计或项目立项演示的参考方案。项目采用MySQL、SpringBoot、Redis与RabbitMQ技术栈重点解决高并发场景下的超卖与重复购买问题通过数据库行锁判断、唯一索引约束以及消息队列异步削峰三重手段保障库存安全。压缩包共147个文件约1.57MB其中67个Java文件承载核心业务逻辑22个HTML与12个JS、10个CSS文件构成前端页面另有SQL脚本、配置文件及少量图片资源结构完整便于二次开发。目前已有129人学习关注。读者可获得一套经过测试运行成功的秒杀实战代码理解防超卖设计思路与消息队列应用方式并在此基础上修改扩展功能用于毕设、课设或作业提交。1. 从一份秒杀源码说起为什么高并发场景总拿它练手电商秒杀系统是 Java 后端面试和课程设计里出现频率极高的题目但真正能跑通、能压测、能讲清楚瓶颈在哪的完整项目并不多。这份基于 Spring Boot 的秒杀系统源码把商品展示、库存扣减、订单创建、支付回调这条主链路完整串了起来同时附带了文档说明适合正在做毕业设计、想补一个高并发实战项目的 Java 开发者。它解决的核心问题不是“怎么写 CRUD”而是“当同一件商品被几千人同时抢购时怎么保证不超卖、不重复下单、数据库不被打崩”。如果你只会写单机增删改查这份资源能帮你把缓存、消息队列、分布式锁这些词从简历上落到代码里。下面我按实际拆包的顺序把技术选型、环境搭建、核心链路和踩坑点讲清楚。2. 秒杀系统的技术底座Spring Boot Redis RabbitMQ 怎么搭2.1 为什么是这套组合而不是纯 MySQL 硬扛秒杀场景的本质矛盾是读多写少、瞬时流量极大、库存是强一致资源。如果所有请求直接打到 MySQL行锁竞争会让数据库连接池瞬间耗尽表现就是接口大面积超时。常见做法是在应用层和数据库之间加两层缓冲Redis 扛读和预减库存RabbitMQ 削峰填谷异步下单。这份源码的技术栈大致是组件作用在秒杀链路中的位置Spring Boot应用框架自动装配整个 Web 层MySQL持久化商品、订单、用户最终落库Redis缓存商品信息、预减库存、分布式锁读多写少 原子扣减RabbitMQ异步下单、流量削峰秒杀请求入队Thymeleaf页面渲染商品列表和详情页Maven依赖管理构建选型理由很直接Redis 的DECR是原子操作适合做库存预减RabbitMQ 能把瞬时并发请求排队消费者按数据库能承受的速率慢慢处理。纯 MySQL 方案不是不能用但在课程设计或面试场景里没有缓存和队列的秒杀系统基本会被追问到哑口无言。2.2 环境搭建与依赖配置拿到源码后第一件事不是急着run而是把中间件跑起来。我一般会先确认本机有没有 MySQL 和 RedisRabbitMQ 如果没有可以用 Docker 起一个。# 启动 MySQL假设已安装 mysql -u root -p # 启动 Redis redis-server # 用 Docker 启动 RabbitMQ带管理界面 docker run -d --name rabbitmq \ -p 5672:5672 -p 15672:15672 \ rabbitmq:3-managementRabbitMQ 管理界面默认端口 15672账号密码都是guest。启动后访问http://localhost:15672能看到队列面板后面调试异步下单时会反复用到。接下来改application.yml把数据库、Redis、RabbitMQ 的连接信息换成自己的spring: datasource: url: jdbc:mysql://localhost:3306/seckill?useSSLfalseserverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 database: 0 rabbitmq: host: localhost port: 5672 username: guest password: guest virtual-host: /参数说明serverTimezone必须显式指定否则 MySQL 8 驱动会报时区错误database: 0表示用 Redis 第 0 号库如果你本机还有其他项目在用 Redis建议改成别的库号避免 key 冲突。RabbitMQ 的virtual-host默认是/如果自己建了 vhost 要对应改。数据库初始化脚本一般在src/main/resources/sql/目录下先建库再执行CREATE DATABASE seckill DEFAULT CHARACTER SET utf8mb4; USE seckill; -- 然后执行源码里附带的 init.sql提示导入 SQL 时注意看脚本里有没有DROP TABLE如果有别在已有数据的库上直接跑。2.3 核心链路从请求进来到订单落库秒杀的主流程可以拆成五步用户请求秒杀接口 → Redis 预减库存 → 库存不足直接返回失败 → 库存充足则发消息到 RabbitMQ → 消费者异步创建订单并扣减数据库库存。预减库存的代码通常长这样// 在秒杀 Service 中 public Result? doSeckill(Long userId, Long goodsId) { // 1. 先从 Redis 读库存 Integer stock (Integer) redisTemplate.opsForValue().get(seckill:stock: goodsId); if (stock null) { // 缓存未预热从数据库加载 stock goodsService.getStock(goodsId); redisTemplate.opsForValue().set(seckill:stock: goodsId, stock); } if (stock 0) { return Result.error(库存不足); } // 2. 原子递减 Long remain redisTemplate.opsForValue().decrement(seckill:stock: goodsId); if (remain 0) { // 防止减成负数回补一次 redisTemplate.opsForValue().increment(seckill:stock: goodsId); return Result.error(库存不足); } // 3. 发送消息异步下单 SeckillMessage message new SeckillMessage(userId, goodsId); rabbitTemplate.convertAndSend(seckill.exchange, seckill.route, message); return Result.success(排队中); }逻辑说明先查 Redis 库存没有就从数据库加载并写回缓存这叫缓存预热。decrement返回的是减完之后的值如果小于 0 说明减过头了需要increment回补否则库存会变成负数。发消息用convertAndSend交换机名和路由键要和消费者那边一致。消费者端负责真正的落库RabbitListener(queues seckill.queue) public void handleSeckill(SeckillMessage message) { Long userId message.getUserId(); Long goodsId message.getGoodsId(); // 1. 查是否重复下单 SeckillOrder exist orderService.getByUserAndGoods(userId, goodsId); if (exist ! null) { return; // 已下单直接丢弃 } // 2. 数据库扣库存带乐观锁或条件更新 int affected goodsService.reduceStock(goodsId); if (affected 0) { return; // 库存已被抢完 } // 3. 创建订单 orderService.createOrder(userId, goodsId); }这里的reduceStock一般写成UPDATE goods SET stock stock - 1 WHERE id ? AND stock 0靠数据库的行锁保证最终一致。affected 0说明库存已经没了直接返回不再创建订单。注意Redis 预减和数据库扣减是两套库存压测时如果发现 Redis 显示有库存但数据库扣不动多半是两边没对齐需要检查预热逻辑和回补逻辑。3. 把项目跑起来建库、改配置、压测三步走3.1 建库导入与配置检查清单环境搭好之后按这个顺序操作创建数据库seckill字符集用utf8mb4。执行源码sql目录下的初始化脚本确认商品表、订单表、用户表都有数据。修改application.yml中的数据库密码、Redis 地址、RabbitMQ 地址。检查pom.xml里的 Java 版本常见是 1.8 或 11和你本机 JDK 对齐。启动Application主类观察控制台有没有报连接超时。如果启动时报Table seckill.xxx doesnt exist说明 SQL 脚本没跑全如果报Connection refused优先检查 Redis 和 RabbitMQ 有没有起来。我一般会先用redis-cli ping和curl localhost:15672确认中间件活着再启动应用。3.2 用 JMeter 做一次真实压测项目能跑通不代表能扛住并发。验证秒杀系统是否真的做了限流和异步最直接的办法是压测。用 JMeter 建一个线程组模拟 500 个用户在 1 秒内同时请求秒杀接口。关键配置参数建议值说明线程数500模拟并发用户Ramp-up11 秒内启动完循环次数1每人抢一次HTTP 请求POST /seckill/{goodsId}带用户 token断言响应时间 2000ms超时算失败压测时重点看三个指标接口平均响应时间、RabbitMQ 队列堆积数、MySQL 的 CPU 占用。如果响应时间飙到几秒但队列没堆积说明瓶颈在 Redis 或应用层如果队列堆积严重但数据库 CPU 不高说明消费者处理太慢可以加消费者实例。# 压测期间用 redis-cli 观察库存变化 redis-cli get seckill:stock:1 # 查看 RabbitMQ 队列消息数 # 在管理界面 Queues 标签页看 Ready 和 Unacked提示压测前先把商品库存设小一点比如 10 件500 个请求抢 10 件能快速验证不超卖。3.3 接口安全别让脚本把库存刷光秒杀接口如果不做防刷一个脚本就能把库存全抢走。源码里通常会有几层防护登录拦截、接口限流、验证码、一人一单。登录拦截靠拦截器实现未登录直接返回 401接口限流可以用 Redis 做计数器比如同一用户 5 秒内只能请求一次。// 简单的 Redis 限流 String key limit: userId : goodsId; Boolean ok redisTemplate.opsForValue().setIfAbsent(key, 1, 5, TimeUnit.SECONDS); if (Boolean.FALSE.equals(ok)) { return Result.error(请求过于频繁); }setIfAbsent对应 Redis 的SETNX配合过期时间就是最简单的限流器。参数5是过期秒数按业务调整。一人一单则在消费者端用userId goodsId查重前面代码已经体现。验证防刷是否生效可以用同一个账号连续快速请求两次第二次应该返回“请求过于频繁”或“请勿重复下单”。4. 避坑与排查超卖、重复消费、缓存穿透的现场记录4.1 超卖Redis 减了但数据库没扣住现象压测后 Redis 库存显示 0但数据库里订单数比商品库存多。原因Redis 预减成功但消息丢失或者消费者端扣库存没有加stock 0条件。解决消费者端UPDATE必须带AND stock 0并且检查 RabbitMQ 是否开启了手动 ACK。如果消息在消费者处理前丢失需要开启持久化和确认机制。4.2 重复消费同一个消息被处理两次现象同一个用户出现两条订单记录。原因RabbitMQ 在网络抖动时会重发消息消费者没有做幂等。解决在消费者入口用userId goodsId查一次数据库已存在就直接返回。更稳妥的做法是给消息加唯一 ID用 Redis 记录已处理的消息 ID。4.3 缓存穿透查一个不存在的商品把数据库打满现象有人用不存在的goodsId疯狂请求Redis 没命中每次都打到 MySQL。原因缓存只存了存在的商品不存在的商品没有占位。解决对查不到的商品也写一个空值到 Redis过期时间设短一点比如 60 秒。或者用布隆过滤器提前拦截。4.4 库存回补Redis 减成负数才回补顺序不能反现象库存偶尔变成 -1。原因decrement之后判断remain 0再increment这两步之间如果有并发可能多个线程同时看到负数。解决更稳的做法是用 Lua 脚本把“判断 扣减”合成原子操作或者直接用decrement的返回值判断小于 0 就回补但回补也要考虑并发。课程设计里用 Lua 脚本是加分项。4.5 启动报错端口占用和版本冲突现象启动时提示Port 8080 was already in use或NoSuchMethodError。原因端口被其他进程占了或者 Spring Boot 版本和依赖版本不匹配。解决改server.port或者用lsof -i:8080找到进程杀掉。版本冲突优先看pom.xml里有没有重复引入不同版本的 starter用mvn dependency:tree排查。5. 进阶技巧用 Lua 脚本把库存扣减做成原子操作前面提到的 Redis 预减库存用decrement加回补的方式在低并发下没问题但压测一上来就可能出现负数。更可靠的做法是把判断和扣减写成一个 Lua 脚本让 Redis 单线程原子执行。-- seckill_stock.lua -- KEYS[1]: 库存 key -- ARGV[1]: 本次扣减数量 local stock redis.call(GET, KEYS[1]) if not stock then return -1 -- 库存未初始化 end if tonumber(stock) tonumber(ARGV[1]) then return 0 -- 库存不足 end return redis.call(DECRBY, KEYS[1], ARGV[1])在 Spring Boot 里加载并执行// 初始化脚本 DefaultRedisScriptLong redisScript new DefaultRedisScript(); redisScript.setScriptSource(new ResourceScriptSource( new ClassPathResource(lua/seckill_stock.lua))); redisScript.setResultType(Long.class); // 调用 Long result redisTemplate.execute(redisScript, Collections.singletonList(seckill:stock: goodsId), 1); if (result null || result 0) { return Result.error(库存未初始化); } if (result 0) { return Result.error(库存不足); } // 扣减成功发消息返回值约定-1表示 key 不存在0表示库存不足大于 0 表示扣减后剩余库存。这样应用层只需要判断返回值不用再回补也不会出现负数。验证方法用redis-cli --eval直接跑脚本或者写一个单元测试并发调用 100 次看最终库存是不是刚好减到 0 而不是负数。# 手动验证 Lua 脚本 redis-cli set seckill:stock:1 10 redis-cli --eval seckill_stock.lua seckill:stock:1 , 1 # 返回 9从那以后我每次做秒杀相关的项目都会先把库存扣减的 Lua 脚本单独跑一遍并发测试确认原子性没问题再往上叠业务逻辑。这个习惯帮我省掉了很多压测时才发现超卖的后悔药。希望帮到你。本文还有配套的精品资源点击获取