简介这是一套基于SpringBoot的电商秒杀系统完整项目源码面向计算机相关专业的在校学生、教师及企业开发者尤其适合作为毕业设计、课程设计或项目立项演示的参考方案。项目采用MySQL、SpringBoot、Redis与RabbitMQ技术栈重点解决高并发场景下的超卖与重复购买问题通过SQL行锁判断、数据库唯一索引以及消息队列三重手段保障数据一致性是理解秒杀业务与分布式中间件协作的典型实战案例。资源包共147个文件约1.57MB以67个Java源文件为核心业务实现辅以22个HTML页面、12个JavaScript脚本与10个CSS样式文件构成前端交互另含XML配置、SQL脚本及图片资源结构完整便于二次开发。目前已有129人学习下载代码均经过测试运行成功读者可据此掌握秒杀流程设计、缓存与消息队列的落地方式并在此基础上修改扩展其他功能。1. 秒杀系统到底难在哪从“超卖一单”说起电商秒杀系统表面看是“把商品挂上去、到点开抢”实际做起来最扎心的不是功能多而是同一毫秒里几千个请求同时扣同一件库存。我见过一个模拟项目库存 100 件活动结束卖出 137 单财务对账时才发现——这就是典型的超卖。基于 Spring Boot 的秒杀系统核心要解决三件事库存不能超卖、请求不能打垮数据库、下单结果要能追溯。它适合已经会写增删改查、但没处理过高并发场景的后端开发者也适合想拿一个完整项目练手的学生。标题里的“源代码文档说明”意味着这套东西不是纸上谈兵而是能跑起来、能改参数、能压测的工程骨架。接下来我按“先立住理论、再动手复现”的顺序把库存扣减、限流、异步下单、对账补偿这几块拆开讲中间会给出可直接抄的代码和参数也会说清楚哪些地方我翻过车。2. 库存扣减与超卖防护从数据库行锁到 Redis 预扣秒杀系统的第一道命门是库存。很多人第一版会写成“先查库存再判断大于 0再更新”这在单线程下没问题一旦并发上来两个线程同时查到库存 1都判断通过最后卖出 2 件。要解决它得先理解数据库层面的锁行为再决定要不要把扣减挪到 Redis。2.1 数据库悲观锁与乐观锁的选型理由在 MySQL InnoDB 里SELECT ... FOR UPDATE会对行加排他锁直到事务提交才释放。把扣减逻辑放进一个事务先锁行再判断库存就能避免超卖。但秒杀场景下大量请求会在锁上排队数据库连接池很快被占满响应时间飙升。乐观锁用版本号或库存字段做条件更新UPDATE stock SET count count - 1 WHERE id ? AND count 0靠数据库的行锁保证原子性不需要显式加锁吞吐比悲观锁好一些。我一般会先上乐观锁因为实现简单、不依赖额外组件等压测发现数据库 CPU 打满再考虑 Redis。-- 乐观锁扣减一条 SQL 完成判断与扣减 UPDATE seckill_stock SET stock_count stock_count - 1, update_time NOW() WHERE goods_id #{goodsId} AND stock_count 0;这条 SQL 的关键在于stock_count 0放在 WHERE 里数据库执行更新时会加行锁判断和扣减在同一个原子操作内完成。返回值是受影响行数如果为 0说明库存已空或商品不存在直接返回“已售罄”。参数上要注意goods_id必须有唯一索引否则行锁会升级为表锁并发直接崩掉。另外事务隔离级别用默认的 REPEATABLE READ 即可不需要改成 READ COMMITTED因为这里不涉及范围查询。2.2 Redis 预扣库存的 Lua 脚本与回补逻辑当数据库扛不住时常见做法是把库存预热到 Redis用 Lua 脚本保证“判断扣减”的原子性。Lua 脚本在 Redis 里单线程执行天然不会并发穿插。下面这段脚本先检查库存是否大于 0再扣减最后返回剩余值。-- seckill_stock.lua -- KEYS[1]: 库存 key如 seckill:stock:1001 -- ARGV[1]: 本次扣减数量通常为 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 里用RedisTemplate执行脚本需要把脚本加载成DefaultRedisScript并指定返回类型为Long。参数说明KEYS[1]是商品维度的库存键建议加业务前缀避免冲突ARGV[1]固定为 1如果支持一次买多件再传具体数量。返回值 -1 表示没预热0 表示售罄大于 0 表示扣减成功并返回剩余库存。注意 Redis 预扣成功后数据库扣减可能失败所以必须有一个“回补”机制下单失败时把 Redis 库存加回去或者用消息队列异步补偿。我一般会在业务层记录一条扣减流水定时任务扫描“Redis 已扣、DB 未扣”的记录做回补避免少卖。2.3 库存预热与缓存一致性检查预热通常在活动开始前 5 到 10 分钟执行把数据库库存读出来写入 Redis。这里有个坑预热后如果运营在后台改了库存Redis 和数据库就不一致了。常见做法是后台改库存时直接删 Redis 键下次请求重新预热或者用定时任务每 30 秒同步一次。我一般会加一个校验接口活动开始前手动触发一次“Redis 库存 vs 数据库库存”比对差值超过阈值就告警。参数上预热脚本的批量大小建议 100 到 200 个商品一批避免单次写入过多导致 Redis 阻塞。3. 请求削峰与异步下单把同步链路拆成两段库存扣减解决的是“不超卖”但秒杀瞬间的流量可能是日常的几百倍如果每个请求都同步走完“查商品、扣库存、写订单、发通知”数据库和线程池都会被拖垮。这一章讲怎么把请求先接住、再慢慢处理。3.1 接口限流令牌桶与漏桶在秒杀入口的取舍限流是秒杀系统的第一道闸门。常见算法有令牌桶和漏桶令牌桶允许一定程度的突发流量漏桶则强制匀速。秒杀场景下我一般用令牌桶因为活动开始那一秒的突发是预期内的只要不超过桶容量就行。Spring Boot 里可以用 Guava 的RateLimiter做单机限流分布式环境则用 Redis Lua 实现。下面是一个基于 Redis 的简单令牌桶脚本。-- rate_limiter.lua -- KEYS[1]: 限流 key如 seckill:limit:1001 -- ARGV[1]: 桶容量 -- ARGV[2]: 令牌生成速率个/秒 -- ARGV[3]: 当前时间戳秒 -- ARGV[4]: 本次请求令牌数 local capacity tonumber(ARGV[1]) local rate tonumber(ARGV[2]) local now tonumber(ARGV[3]) local requested tonumber(ARGV[4]) local bucket redis.call(HMGET, KEYS[1], tokens, last_time) local tokens tonumber(bucket[1]) or capacity local last_time tonumber(bucket[2]) or now local delta math.max(0, now - last_time) local filled math.min(capacity, tokens delta * rate) if filled requested then return 0 -- 限流 end redis.call(HMSET, KEYS[1], tokens, filled - requested, last_time, now) redis.call(EXPIRE, KEYS[1], 2) return 1参数说明桶容量建议设为“库存数 × 2 到 3 倍”因为不是每个拿到令牌的请求都会最终下单速率按压测得出的数据库承受能力来设比如数据库每秒能处理 500 次扣减速率就设 500。返回 0 表示被限流直接给前端返回“当前人数过多请稍后再试”。注意EXPIRE设 2 秒是为了让冷 key 自动清理避免 Redis 内存被大量限流键占满。3.2 RabbitMQ 异步下单消息体设计与消费幂等限流之后通过的请求不应该同步写订单而是发一条消息到 MQ由消费者慢慢处理。消息体里至少要带用户 ID、商品 ID、活动 ID、请求时间戳、一个全局唯一的请求 ID。请求 ID 用来做幂等防止消息重复消费导致同一用户下两单。下面是一个消息生产者的核心代码。// 发送秒杀消息请求 ID 用于消费端幂等 public void sendSeckillMessage(Long userId, Long goodsId) { String requestId UUID.randomUUID().toString(); SeckillMessage msg new SeckillMessage(); msg.setUserId(userId); msg.setGoodsId(goodsId); msg.setRequestId(requestId); msg.setTimestamp(System.currentTimeMillis()); rabbitTemplate.convertAndSend(seckill.exchange, seckill.order, msg); }消费端收到消息后先拿requestId去 Redis 查是否已处理用SETNX设置一个 5 分钟过期的键设置成功才继续走数据库扣减和订单写入。参数上requestId建议用 UUID不要用“用户 ID商品 ID”因为同一用户可能重复点击。消费端的并发数不要设太大一般 5 到 10 个消费者线程就够设多了反而会把数据库打满。我踩过的坑是消息体用了默认的 Java 序列化结果消费端反序列化失败后来统一改成 JSON 序列化才稳定。3.3 订单落库与状态机从“待支付”到“已取消”异步下单后订单状态不能只有“已创建”。一个可靠的秒杀订单至少有这几个状态待支付、已支付、已取消、已超时。用户下单后进入待支付超过 15 分钟未支付就自动取消并回补库存。状态流转要用条件更新比如UPDATE orders SET status CANCELLED WHERE order_no ? AND status PENDING避免并发操作把已支付的订单改成取消。参数上超时时间建议 10 到 15 分钟太短用户来不及支付太长库存被占用。回补库存时要注意先改订单状态成功后再回补 Redis 和数据库库存顺序反了会导致库存多出来。4. 避坑与排查那些压测时才暴露的问题秒杀系统的坑大多不在功能而在边界和并发。下面几条是我和同行交流时反复听到的翻车记录每条按“现象→原因→解决”写方便对照排查。4.1 库存扣成负数Redis 与数据库双写不一致现象压测时 Redis 库存显示还有 10数据库库存已经是 -3。原因Redis 预扣成功后数据库扣减失败比如连接超时但没有回补 Redis导致 Redis 库存虚高后续请求继续扣。解决在业务层记录每次 Redis 扣减的流水数据库扣减失败时立即回补 Redis并发送告警同时加一个定时对账任务每 5 分钟比对一次 Redis 和数据库库存差值超过 0 就触发人工检查。4.2 消息重复消费同一用户下了两单现象用户反馈自己只点了一次却生成了两个订单。原因RabbitMQ 在网络抖动时会重发消息消费端没有做幂等。解决消费端用requestId做SETNX设置成功才处理处理完再删除或等过期数据库订单表对user_id goods_id 活动 ID加唯一索引作为最后一道防线。4.3 限流误伤正常用户被挡在门外现象活动开始后很多用户看到“人数过多”但库存还有剩余。原因限流速率设得太低或者令牌桶的 key 粒度太粗把不同商品的请求算到一起。解决限流 key 按商品维度隔离速率根据压测结果动态调整同时加一个白名单机制对已登录且信用良好的用户放宽限制。4.4 数据库连接池耗尽请求全部超时现象压测到 2000 并发时接口响应时间从 50ms 涨到 5s最后全部超时。原因同步链路里每个请求都占一个数据库连接连接池最大连接数只有 20。解决把下单链路改成异步数据库操作只在消费端执行连接池参数按“消费者线程数 × 2”设置比如 10 个消费者就设 20 到 30同时给数据库扣减加超时时间避免慢查询拖死连接。4.5 缓存击穿热点商品 key 过期瞬间打垮数据库现象活动开始前 1 秒大量请求同时查同一个商品Redis 里 key 刚好过期全部打到数据库。解决热点商品的缓存不设过期时间或者用逻辑过期加互斥锁预热时把商品信息、库存、活动状态一起写入避免请求时再查库。5. 压测验证与对账补偿怎么确认系统真的扛住了写完代码只是开始秒杀系统必须压测。我一般用 JMeter 或 wrk 模拟 1000 到 5000 并发观察三个指标接口响应时间、数据库 QPS、Redis 命中率。压测前先把库存预热好压测后检查“Redis 剩余库存 已生成订单数”是否等于初始库存如果不等说明有漏扣或多扣。对账补偿是最后一道保险每天凌晨跑一次全量对账把 Redis 流水、数据库订单、库存变更记录三方比对差异记录写入异常表人工介入。参数上压测并发从 500 开始每轮增加 500直到响应时间超过 1 秒或错误率超过 1%那个点就是当前架构的容量上限。我自己的习惯是每次改完库存扣减逻辑先跑 100 并发验证功能再跑 1000 并发看瓶颈最后跑一次全链路对账。这套流程帮我省了很多后悔药。希望帮到你。本文还有配套的精品资源点击获取