
简介这是一套基于SpringBoot的电商秒杀系统完整项目源码面向计算机相关专业的在校学生、教师及企业开发者尤其适合作为毕业设计、课程设计或项目立项演示的参考方案。项目采用MySQL、SpringBoot、Redis与RabbitMQ技术栈重点解决高并发场景下的超卖与重复购买问题通过数据库行锁判断、唯一索引约束以及消息队列削峰三种手段保障数据一致性。压缩包共147个文件约1.57MB其中67个Java文件承载核心业务逻辑22个HTML与12个JavaScript、10个CSS文件构成前端页面另有XML配置、SQL脚本及图片资源等结构完整便于二次开发。目前已有129人学习关注。读者可获得一套经过测试运行成功的秒杀实战代码理解防超卖、限流与异步下单的落地思路并在此基础上修改扩展功能用于毕设、课设或作业场景。1. 秒杀系统到底难在哪从 SpringBoot 电商场景说起很多人第一次接触「基于 SpringBoot 的电商秒杀系统 源代码 文档说明」这类项目第一反应是去翻源代码里有多少个 Controller、几张表然后本地mvn spring-boot:run跑起来看到页面能下单就觉得搞定了。但真正上线过一次秒杀活动的人都知道能跑起来和扛得住是两码事。秒杀的本质不是「电商」而是「高并发下的库存一致性」——同一件商品一万个人同时点最后只能卖出十件多卖一件就是超卖事故少卖一件就是资损。这个标题背后真正要解决的是怎么用 SpringBoot 这套生态把限流、缓存、异步下单、库存扣减这几件事串成一条不会崩的链路同时把源代码和文档说明整理成别人能接手、能复现的形态。适合谁看正在做毕设或课程设计、需要一套完整可讲清的项目也适合工作一两年、想从 CRUD 往高并发方向补课的工程师。下面我按自己搭过几套类似系统的经验把选型、代码、参数和踩过的坑讲透。2. 秒杀链路的技术选型为什么是 Redis MQ SpringBoot2.1 秒杀系统的四层防线与 SpringBoot 的定位秒杀请求的流量曲线非常极端活动开始那一秒的 QPS 可能是平时的几百倍几秒后又断崖式回落。如果所有请求都打到数据库MySQL 的行锁会瞬间把连接池打满后面正常业务也跟着挂。所以业界常见的做法是分层拦截我一般会把它拆成四层。第一层是前端和网关层做按钮置灰、验证码、单用户限流把重复点击和脚本请求挡在门外。第二层是应用层用 SpringBoot 写的服务里做令牌桶或漏桶限流配合本地缓存扛住热点读。第三层是缓存层用 Redis 预减库存把「判断有没有库存」这个动作从数据库挪到内存。第四层才是数据库只处理真正扣减成功的订单落库而且尽量异步化。SpringBoot 在这里的定位是「胶水 编排」它不负责扛并发但它把 Redis、消息队列、数据库、定时任务这些组件用 starter 的方式整合进来让整套链路的代码组织清晰、配置集中、启动即用。这也是为什么这个标题里 SpringBoot 是核心词——它决定了项目结构长什么样而不是决定性能上限。需要说清楚的是SpringBoot 版本不要盲目追新。热搜里常出现「springboot版本太高」的抱怨原因多是 JDK 版本、依赖冲突或某些 starter 行为变化。做这类项目我一般锁在 2.7.x 或 3.2.x 这种长期维护线上JDK 用 8 或 17别用刚发布的版本否则光是排查自动装配问题就能耗掉两天。2.2 库存扣减的三种方案对比库存扣减是整个系统的心脏选错方案后面全是坑。常见有三种做法我列个表对比。方案实现方式优点缺点适用场景数据库乐观锁update stock set numnum-1 where id? and num0简单、强一致高并发下大量失败重试DB 压力大并发量低的活动Redis 预减 MQ 异步落库Lua 脚本扣 Redis发消息消费者写 DB扛并发、响应快最终一致需处理消息丢失主流秒杀方案分布式锁串行化Redisson 加锁后扣减逻辑直观锁竞争严重吞吐低库存极少、并发不高的场景我一般选第二种。核心思路是Redis 里存一份库存用 Lua 脚本保证「判断 扣减」的原子性扣成功就发一条消息到 MQ消费者慢慢写数据库。这样请求在 Redis 这一层就被快速裁决数据库只承担最终落库压力小一个数量级。Lua 脚本长这样放在resources/scripts/seckill.lua-- KEYS[1]: 库存 key, ARGV[1]: 用户 id -- 返回 1 表示扣减成功, 0 表示库存不足, -1 表示重复下单 local stock redis.call(GET, KEYS[1]) if not stock then return 0 end if tonumber(stock) 0 then return 0 end -- 用 set 记录已下单用户, 防止同一用户重复抢 local userKey KEYS[1] .. :users if redis.call(SISMEMBER, userKey, ARGV[1]) 1 then return -1 end redis.call(DECR, KEYS[1]) redis.call(SADD, userKey, ARGV[1]) return 1逻辑说明先读库存没有或小于等于 0 直接返回失败再用一个 Set 记录已下单用户防止同一账号刷单最后 DECR 扣减并记录用户。整个过程在 Redis 单线程里执行天然原子不需要额外加锁。参数上KEYS[1]是商品维度的库存键建议命名成seckill:stock:{goodsId}ARGV[1]是用户 id。注意 Set 要设过期时间否则活动结束后内存不释放可以在脚本外用EXPIRE补一刀。2.3 消息队列选型与 SpringBoot 整合方式MQ 的作用是削峰和异步落库。选型上RabbitMQ 和 RocketMQ 都常见。RabbitMQ 轻量、和 SpringBoot 整合成熟适合毕设和中小项目RocketMQ 吞吐更高、支持事务消息适合真实生产。热搜里出现过「springboot整合activemq」ActiveMQ 现在用得少了新项目不建议选。整合 RabbitMQ 的依赖和配置dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-amqp/artifactId /dependencyspring: rabbitmq: host: 127.0.0.1 port: 5672 username: guest password: guest publisher-confirm-type: correlated # 开启发送确认 publisher-returns: true # 开启路由失败回调 listener: simple: acknowledge-mode: manual # 手动 ack, 防止消息丢失 prefetch: 1 # 每次只取一条, 保证公平分发参数说明publisher-confirm-type设成 correlated 后消息到达交换机会回调 ConfirmCallback能知道发没发出去publisher-returns处理路由不到队列的情况消费端acknowledge-mode: manual是关键自动 ack 在消费者处理到一半宕机时会丢消息手动 ack 才能保证「处理成功才确认」。prefetch: 1避免一个消费者囤积大量消息而其他消费者空闲。发送端代码Autowired private RabbitTemplate rabbitTemplate; public void sendSeckillMessage(Long userId, Long goodsId) { SeckillMessage msg new SeckillMessage(userId, goodsId); // 用 CorrelationData 关联消息, 便于 confirm 回调定位 CorrelationData cd new CorrelationData(UUID.randomUUID().toString()); rabbitTemplate.convertAndSend(seckill.exchange, seckill.order, msg, cd); }逻辑说明把用户和商品封装成消息体通过指定 exchange 和 routingKey 投递。CorrelationData 用来在确认回调里识别是哪条消息。消费端拿到消息后做真正的数据库扣减和订单创建成功后channel.basicAck失败则basicNack并视情况重入队或进死信队列。3. 从源代码到能跑本地复现的完整步骤3.1 项目结构与依赖梳理拿到一套「源代码 文档说明」第一步不是急着跑而是先看结构。典型的 SpringBoot 秒杀项目目录大致是这样seckill/ ├── src/main/java/com/example/seckill/ │ ├── controller/ # 接口层, 秒杀、商品、订单 │ ├── service/ # 业务逻辑, 库存、订单 │ ├── mapper/ # MyBatis 或 JPA 数据访问 │ ├── config/ # Redis、RabbitMQ、线程池配置 │ ├── entity/ # 实体类 │ └── common/ # 统一返回、异常处理 ├── src/main/resources/ │ ├── application.yml │ ├── scripts/seckill.lua │ └── mapper/*.xml └── pom.xml看结构时重点确认三件事配置类里 Redis 和 MQ 的连接信息是不是写死的、Lua 脚本有没有被正确加载、数据库脚本建表和初始库存在不在文档里。很多源代码包跑不起来就是缺了初始化 SQL 或者配置里连的是作者本机的地址。依赖方面核心就几个spring-boot-starter-web、spring-boot-starter-data-redis、spring-boot-starter-amqp、mybatis-spring-boot-starter、mysql-connector-java。如果文档里提到用 Redisson 做分布式锁再加redisson-spring-boot-starter。注意 Redis 客户端SpringBoot 2.x 默认 Lettuce要确认 Lua 脚本执行用的是RedisTemplate.execute(RedisScript, keys, args)这套 API。3.2 数据库与 Redis 初始化建表 SQL 一般文档里会给核心两张表商品表和订单表。CREATE TABLE goods ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(128) NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0 ) ENGINEInnoDB; CREATE TABLE seckill_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, goods_id BIGINT NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_goods (user_id, goods_id) ) ENGINEInnoDB;逻辑说明goods表存库存version字段给乐观锁备用seckill_order表上的唯一索引uk_user_goods是最后一道防线即使前面 Redis 判重失效数据库层也能挡住重复下单。这一步很多人会忽略等出了重复订单才回头加索引属于典型的后悔药没处买。Redis 初始化在应用启动时做把数据库库存预热进去Component public class StockPreheatRunner implements ApplicationRunner { Autowired private StringRedisTemplate redisTemplate; Autowired private GoodsMapper goodsMapper; Override public void run(ApplicationArguments args) { ListGoods list goodsMapper.selectAll(); for (Goods g : list) { String key seckill:stock: g.getId(); redisTemplate.opsForValue().set(key, String.valueOf(g.getStock())); // 用户去重集合设 24 小时过期, 避免内存泄漏 redisTemplate.expire(key :users, 24, TimeUnit.HOURS); } } }参数说明预热放在ApplicationRunner里保证容器启动完成后执行。库存键和用户集合键的命名要和 Lua 脚本里保持一致否则脚本读不到。过期时间按活动周期设一般比活动结束时间多留几小时。3.3 秒杀接口的完整实现接口层要做的事限流、调用 Lua 扣库存、发消息、返回结果。下面是一个精简但完整的实现。RestController RequestMapping(/seckill) public class SeckillController { Autowired private StringRedisTemplate redisTemplate; Autowired private RabbitTemplate rabbitTemplate; Autowired private RedisScriptLong seckillScript; // 本地令牌桶, 单机限流, 集群需换 Redis 或网关限流 private final RateLimiter rateLimiter RateLimiter.create(500); PostMapping(/do/{goodsId}) public Result doSeckill(PathVariable Long goodsId, RequestParam Long userId) { // 1. 本地限流, 拿不到令牌直接拒绝 if (!rateLimiter.tryAcquire()) { return Result.fail(当前人数过多, 请稍后再试); } // 2. 执行 Lua 脚本, 原子扣减 String stockKey seckill:stock: goodsId; Long r redisTemplate.execute(seckillScript, Collections.singletonList(stockKey), String.valueOf(userId)); if (r null || r 0) { return Result.fail(库存不足); } if (r -1) { return Result.fail(请勿重复下单); } // 3. 扣减成功, 发消息异步落库 SeckillMessage msg new SeckillMessage(userId, goodsId); rabbitTemplate.convertAndSend(seckill.exchange, seckill.order, msg); return Result.ok(排队中, 请稍后查看订单); } }逻辑说明先限流再执行 Lua根据返回值区分库存不足和重复下单成功则发消息并立即返回「排队中」。这里返回的不是「下单成功」因为真正的落库是异步的用户需要轮询订单状态。参数上RateLimiter.create(500)表示每秒放行 500 个请求这个值要按压测结果调设太小会误杀正常用户设太大起不到保护作用。消费端Component public class SeckillConsumer { Autowired private OrderService orderService; RabbitListener(queues seckill.queue) public void onMessage(SeckillMessage msg, Channel channel, Header(AmqpHeaders.DELIVERY_TAG) long tag) throws IOException { try { orderService.createOrder(msg.getUserId(), msg.getGoodsId()); channel.basicAck(tag, false); } catch (Exception e) { // 落库失败, 拒绝并进死信队列人工处理 channel.basicNack(tag, false, false); } } }逻辑说明消费成功手动 ack失败 nack 且不重入队避免死循环。真正生产里还要配死信队列和告警把失败消息捞出来补偿。createOrder里做数据库扣减和订单插入用事务包起来扣减用update goods set stockstock-1 where id? and stock0影响行数为 0 就抛异常回滚。4. 避坑与排查秒杀系统最容易翻车的五个点4.1 超卖库存扣成负数现象活动结束后对账发现订单数大于实际库存库存字段出现负数。原因最常见的是没用 Lua 原子扣减而是先 GET 再 DECR 两步走高并发下两个请求都读到 1都扣成 0卖出两件。另一种是数据库扣减没加stock0条件。解决库存判断和扣减必须在 Redis 单命令或 Lua 脚本里完成数据库层update ... where stock0作为兜底订单表唯一索引防重。三层都加上基本不会超卖。4.2 消息丢失导致少卖现象Redis 扣了库存但数据库里没有对应订单用户投诉下单成功却没订单。原因发送端没开 confirm消息没到交换机或者消费端自动 ack处理到一半宕机。解决发送端开publisher-confirm-type: correlated并实现 ConfirmCallback 记录失败消息消费端手动 ack配死信队列兜底。我一般还会加一个定时对账任务比对 Redis 扣减数和数据库订单数差值超过阈值就告警。4.3 重复下单现象同一用户出现两条订单。原因Redis 判重的 Set 过期了或者用户快速点两次第一次请求还在处理时第二次已经进来。解决Set 过期时间要覆盖整个活动周期数据库唯一索引uk_user_goods必须建接口层可以再加一层基于用户 id 的分布式锁但别滥用锁粒度太大会拖垮吞吐。4.4 本地限流在集群下失效现象单机测试限流正常部署多台后总流量还是把 Redis 打满。原因RateLimiter是 JVM 级别的每台机器各限各的集群总量是单机乘以机器数。解决集群环境把限流挪到网关层如 Nginx 限流、Sentinel或者用 Redis Lua 做分布式令牌桶。本地限流只作为最后一道保险。4.5 热点 key 打爆单个 Redis 节点现象某个爆款商品的请求全压在一个 Redis 分片上该节点 CPU 飙满其他节点空闲。原因库存 key 按商品 id 分片单个爆款天然是热点。解决本地缓存 Redis 多级缓存本地缓存扛住绝大部分读或者把库存拆成多个子 key如stock:1001:0到stock:1001:9请求分散到不同 key 再汇总。拆分方案复杂一般项目用本地缓存就够了。5. 压测验证与文档说明怎么写才有人接手5.1 用 JMeter 验证秒杀接口的真实吞吐代码写完不压测等于没写。我一般用 JMeter 做两轮一轮测限流是否生效一轮测库存是否准确。线程组设 2000 个线程、 ramp-up 2 秒、循环 1 次模拟瞬时涌入。压测前先确认几个观察点Redis 的INFO stats里instantaneous_ops_per_sec有没有异常飙升MySQL 的Threads_running是否被打满RabbitMQ 队列有没有堆积。压测后核对三组数字请求总数、Redis 扣减总数、数据库订单总数。理想情况下Redis 扣减数等于订单数且都小于等于初始库存。如果发现订单数少于 Redis 扣减数去查死信队列如果 Redis 扣减数大于库存说明 Lua 脚本有问题。这一步是验证整套链路一致性的关键别跳过。5.2 文档说明的四个必备章节「文档说明」不是把代码注释复制一遍而是要让人不看代码也能跑起来。我写这类文档一般固定四块。第一块是环境要求JDK 版本、Maven 版本、MySQL 版本、Redis 版本、RabbitMQ 版本写清楚每个的安装要点。第二块是初始化步骤建库 SQL、Redis 预热方式、MQ 队列和交换机的声明。第三块是配置说明application.yml里每个需要改的字段尤其是数据库密码、Redis 地址、MQ 地址。第四块是验证方法怎么发起一次秒杀、怎么查订单、怎么确认没超卖。表格形式最省事配置项默认值说明spring.datasource.urljdbc:mysql://127.0.0.1:3306/seckill改成自己的库地址spring.redis.host127.0.0.1Redis 地址spring.rabbitmq.host127.0.0.1MQ 地址seckill.rate.limit500单机每秒放行数5.3 一个容易被忽略的收尾技巧最后说个我踩过的坑秒杀结束后Redis 里的库存 key 和用户 Set 如果不清理下次活动复用同一个商品 id 时会读到旧数据导致「明明补了库存却卖不出去」。我的习惯是在活动结束的定时任务里按商品 id 前缀批量删除相关 key同时把数据库库存和 Redis 做一次对账同步。这个动作写进文档的「运维说明」里接手的人才知道活动后要做什么。做这类系统代码能跑只是起点真正值钱的是那条从限流到对账的完整闭环以及文档里写清楚的每一个「为什么这么设」。希望帮到你。本文还有配套的精品资源点击获取