简介:这是一套基于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 和 Redis,RabbitMQ 如果没有可以用 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?useSSL=false&serverTimezone=Asia/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' doesn't exist,说明 SQL 脚本没跑全;如果报Connection refused,优先检查 Redis 和 RabbitMQ 有没有起来。我一般会先用redis-cli ping和curl localhost:15672确认中间件活着,再启动应用。
3.2 用 JMeter 做一次真实压测
项目能跑通不代表能扛住并发。验证秒杀系统是否真的做了限流和异步,最直接的办法是压测。用 JMeter 建一个线程组,模拟 500 个用户在 1 秒内同时请求秒杀接口。
关键配置:
| 参数 | 建议值 | 说明 |
|---|---|---|
| 线程数 | 500 | 模拟并发用户 |
| Ramp-up | 1 | 1 秒内启动完 |
| 循环次数 | 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 里加载并执行:
// 初始化脚本 DefaultRedisScript<Long> 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 脚本单独跑一遍并发测试,确认原子性没问题再往上叠业务逻辑。这个习惯帮我省掉了很多压测时才发现超卖的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取