Springboot电商秒杀系统实战:Redis+Lua防超卖与RabbitMQ异步下单
简介这是一套基于SpringBoot的电商秒杀系统完整项目源码面向计算机相关专业的在校学生、教师及企业开发者尤其适合作为毕业设计、课程设计或项目立项演示的参考方案。项目采用MySQL、SpringBoot、Redis与RabbitMQ技术栈重点解决高并发场景下的超卖与重复购买问题通过数据库行锁判断、唯一索引约束以及消息队列异步削峰三重手段保障数据一致性。压缩包共147个文件约1.57MB其中67个Java文件承载核心业务逻辑22个HTML与12个JS、10个CSS文件构成前端页面另有XML配置、SQL脚本及图片资源等结构完整便于二次开发。目前已有129人学习关注。读者可获得一套经过测试运行成功的秒杀实战代码理解防超卖设计思路与消息队列应用方式并在此基础上修改扩展功能用于毕设、课设或作业等学习场景。1. 秒杀系统到底难在哪从超卖到限流的真实战场电商秒杀系统听起来像是「把商品放到页面上用户点一下购买」这么简单但真正做过的人都知道这是一场关于并发、库存和公平性的硬仗。基于 Springboot 的电商秒杀系统核心要解决三个问题第一瞬时高并发下如何保证库存不超卖第二如何把请求挡在数据库之前避免系统被流量打穿第三如何让真正想买的用户有机会下单而不是被脚本和机器抢光。这套系统适合有一定 Java 基础、想从 CRUD 项目进阶到高并发场景的开发者也适合正在准备毕设或面试、需要一套完整可运行案例的从业者。源代码和文档说明的价值不在于代码行数多少而在于它把「限流、缓存、异步下单、分布式锁」这些零散知识点串成了一条能跑通的链路。接下来我会按实际落地顺序把每个环节的参数、坑点和验证方法讲清楚。2. 秒杀系统的技术选型与核心链路拆解2.1 为什么是 Springboot Redis RabbitMQ 这套组合秒杀场景的本质是「读多写少、瞬时爆发」。如果所有请求直接打到 MySQL即使加了索引几千 QPS 就能让连接池耗尽。常见做法是在 Springboot 和数据库之间加两层缓冲Redis 扛读和库存预扣RabbitMQ 做异步下单削峰。选 Springboot 的理由很直接自动装配让 Redis、RabbitMQ、MyBatis 的集成成本降到最低spring-boot-starter-data-redis和spring-boot-starter-amqp引入后基本只需要写配置。但要注意Springboot 版本太高有时反而带来兼容问题比如 Springboot 3.x 默认要求 Java 17部分老教程里的WebMvcConfigurerAdapter已经废弃。我一般会选 Springboot 2.7.x 配合 Java 8 或 11生态最稳。Redis 在这里承担三个角色缓存秒杀商品信息、原子递减库存、记录用户购买标记。RabbitMQ 则负责把「扣减库存成功」的请求异步落库让用户先拿到「排队中」的响应而不是同步等待数据库写入。2.2 数据库表结构与库存字段设计秒杀系统的表不用多但每个字段都要经得起推敲。核心两张表seckill_goods和seckill_order。CREATE TABLE seckill_goods ( id BIGINT NOT NULL AUTO_INCREMENT, goods_name VARCHAR(128) NOT NULL, price DECIMAL(10,2) NOT NULL, stock_count INT NOT NULL COMMENT 库存数量, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, version INT DEFAULT 0 COMMENT 乐观锁版本号, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE seckill_order ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, goods_id BIGINT NOT NULL, order_no VARCHAR(64) NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_goods (user_id,goods_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;stock_count是库存字段version用于乐观锁更新。seckill_order上的唯一索引uk_user_goods是防重复下单的最后一道防线——即使 Redis 标记失效数据库层面也会拒绝同一用户对同一商品的第二条订单。注意不要把库存扣减放在应用层用if (stock 0)判断那个判断在并发下没有任何意义。2.3 从请求到下单的完整链路一次秒杀请求的完整路径是这样的用户点击秒杀 → 网关或拦截器做限流 → 校验活动时间 → Redis 判断用户是否已购买 → Redis 原子递减库存 → 发送 MQ 消息 → 返回排队中 → 消费者异步创建订单 → 数据库扣减库存。这条链路里Redis 的DECR操作是原子性的返回值小于 0 说明库存不足直接拒绝。但这里有个细节如果DECR之后发现库存不足需要把库存加回去否则会出现「库存被扣光但没人下单」的假象。常见做法是用 Lua 脚本把「判断 扣减」合并成一个原子操作。-- seckill.lua local stock redis.call(GET, KEYS[1]) if not stock then return -1 end if tonumber(stock) 0 then return 0 end redis.call(DECR, KEYS[1]) return 1这段 Lua 脚本在 Redis 中执行时是原子的KEYS[1]是库存键返回-1表示键不存在0表示库存不足1表示扣减成功。Springboot 中通过RedisTemplate.execute(RedisScript, keys, args)调用。参数说明keys传入库存键名args可以传用户 ID 用于后续扩展。逻辑上先读再判断再扣减避免了先扣后补的补偿逻辑。3. 用 Springboot 把秒杀接口跑起来的最小步骤3.1 项目结构与依赖配置一个能跑的最小秒杀项目目录结构不需要太复杂。我一般会这样组织seckill-demo ├── src/main/java/com/example/seckill │ ├── controller │ ├── service │ ├── mapper │ ├── config │ └── mq ├── src/main/resources │ ├── application.yml │ └── lua/seckill.lua └── pom.xmlpom.xml里关键依赖包括spring-boot-starter-web、spring-boot-starter-data-redis、spring-boot-starter-amqp、mybatis-plus-boot-starter和mysql-connector-java。如果用的是 Springboot 2.7.xMyBatis-Plus 选 3.5.x 版本兼容性最好。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-amqp/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3/version /dependencyapplication.yml中需要配置 Redis 和 RabbitMQ 的连接信息以及秒杀商品的缓存过期时间。注意 Redis 的序列化方式默认的 JDK 序列化在 Redis 里存的是二进制调试时不方便建议换成GenericJackson2JsonRedisSerializer。3.2 Redis 库存预热与 Lua 脚本调用秒杀开始前需要把商品库存从数据库加载到 Redis。这一步叫「库存预热」通常在活动开始前几分钟执行。Service public class StockPreheatService { Autowired private RedisTemplateString, Object redisTemplate; Autowired private SeckillGoodsMapper goodsMapper; public void preheat(Long goodsId) { SeckillGoods goods goodsMapper.selectById(goodsId); if (goods null || goods.getStockCount() 0) { return; } String stockKey seckill:stock: goodsId; redisTemplate.opsForValue().set(stockKey, goods.getStockCount()); // 设置过期时间避免活动结束后库存键长期占用内存 redisTemplate.expire(stockKey, goods.getEndTime().getTime() - System.currentTimeMillis(), TimeUnit.MILLISECONDS); } }preheat方法把数据库库存写入 Redis键名格式为seckill:stock:{goodsId}。过期时间设为活动结束时间减去当前时间这样活动结束后键自动清理。参数说明goodsId是商品 IDstockCount是数据库中的库存数量。注意预热操作要在活动开始前完成否则用户请求进来时 Redis 里没有库存键Lua 脚本会返回-1。调用 Lua 脚本的代码public Long trySeckill(Long goodsId, Long userId) { String stockKey seckill:stock: goodsId; String userKey seckill:user: goodsId : userId; // 先判断用户是否已购买 Boolean hasBought redisTemplate.hasKey(userKey); if (Boolean.TRUE.equals(hasBought)) { return 0L; // 重复购买 } DefaultRedisScriptLong script new DefaultRedisScript(); script.setScriptSource(new ResourceScriptSource(new ClassPathResource(lua/seckill.lua))); script.setResultType(Long.class); Long result redisTemplate.execute(script, Collections.singletonList(stockKey)); if (result ! null result 1L) { // 扣减成功标记用户已购买 redisTemplate.opsForValue().set(userKey, 1, 10, TimeUnit.MINUTES); } return result; }这段代码先检查用户是否已经购买过再执行 Lua 脚本扣减库存。userKey设置了 10 分钟过期时间防止用户标记永久占用内存。返回值1表示扣减成功0表示库存不足或重复购买-1表示库存键不存在。注意hasKey和execute之间不是原子的极端情况下同一用户的两个请求可能同时通过检查但后续的数据库唯一索引会兜底。3.3 RabbitMQ 异步下单与消费者幂等扣减库存成功后发送消息到 RabbitMQ让消费者异步创建订单。Autowired private RabbitTemplate rabbitTemplate; public void sendOrderMessage(Long goodsId, Long userId) { SeckillMessage message new SeckillMessage(goodsId, userId); rabbitTemplate.convertAndSend(seckill.exchange, seckill.order, message); }消费者端RabbitListener(queues seckill.order.queue) public void handleOrder(SeckillMessage message) { Long goodsId message.getGoodsId(); Long userId message.getUserId(); // 幂等检查数据库唯一索引兜底 SeckillOrder exist orderMapper.selectByUserAndGoods(userId, goodsId); if (exist ! null) { return; } // 数据库扣减库存 int affected goodsMapper.decrStock(goodsId); if (affected 0) { // 库存不足记录日志并返回 return; } SeckillOrder order new SeckillOrder(); order.setUserId(userId); order.setGoodsId(goodsId); order.setOrderNo(UUID.randomUUID().toString()); orderMapper.insert(order); }消费者先查订单是否已存在再执行数据库扣减库存最后插入订单。decrStock对应的 SQL 是UPDATE seckill_goods SET stock_count stock_count - 1 WHERE id #{goodsId} AND stock_count 0利用数据库行锁保证最终一致性。参数说明goodsId和userId从消息中获取orderNo用 UUID 生成。注意消费者必须做幂等因为 RabbitMQ 可能重复投递消息。4. 避坑与排查秒杀系统上线前必须过的五道关4.1 库存超卖Redis 扣减成功但数据库为负现象活动结束后对账发现数据库库存字段变成负数或者订单数大于库存数。原因Redis 扣减和数据库扣减是两步操作如果消费者处理失败或重复消费数据库扣减可能多执行。更常见的是 Redis 库存预热时数量不对比如数据库库存 100Redis 里写成了 1000。解决数据库扣减 SQL 必须带AND stock_count 0条件利用UPDATE的原子性兜底。同时消费者端做幂等插入订单前先查唯一索引。上线前用 JMeter 模拟 1000 并发检查最终订单数是否等于库存数。4.2 重复下单同一用户抢到两件现象同一用户 ID 在订单表里有两条记录商品 ID 相同。原因Redis 的用户购买标记设置了过期时间如果用户在标记过期后再次请求会被当成新请求放行。或者hasKey和set之间不是原子操作两个请求同时通过检查。解决数据库seckill_order表加UNIQUE KEY uk_user_goods (user_id, goods_id)这是最后一道防线。Redis 标记的过期时间不要设太短建议覆盖整个活动周期。如果追求严格可以用SETNX代替hasKey set。4.3 RabbitMQ 消息丢失扣了库存没生成订单现象Redis 库存扣减成功但用户迟迟收不到订单数据库里也没有记录。原因发送消息时 RabbitMQ 连接断开或者消费者处理异常后消息被丢弃。解决开启 RabbitMQ 的持久化队列和消息都设为 durable。消费者端加 try-catch处理失败时手动 nack 并重新入队或者记录到死信队列后续补偿。生产环境建议开启 publisher confirms确保消息到达 Broker。4.4 限流配置不当正常用户被误伤现象活动开始瞬间大量用户看到「请求过于频繁」但实际流量并不高。原因限流阈值设得太低或者限流维度是 IP 而不是用户 ID导致同一公司出口 IP 的用户互相影响。解决限流用 Redis Lua 做滑动窗口维度优先选用户 ID。阈值根据压测结果设定一般单机 QPS 的 60% 作为限流上限。注意限流要返回友好提示而不是直接 500。4.5 缓存击穿秒杀开始瞬间 Redis 压力过大现象活动开始前 Redis 里没有库存键大量请求同时查数据库。原因库存预热没做或者预热后键过期时间设错了。解决活动开始前 5 分钟执行预热键的过期时间设为活动结束时间加 10 分钟缓冲。如果担心 Redis 宕机可以加本地缓存做二级兜底但要注意本地缓存和 Redis 的一致性。5. 压测验证与生产环境参数调优5.1 用 JMeter 做一次真实的秒杀压测代码跑通不代表能扛住流量。我一般会用 JMeter 做两轮压测第一轮 500 并发观察响应时间和错误率第二轮 2000 并发看系统瓶颈在哪里。压测脚本配置线程组 2000 个线程Ramp-up 时间 1 秒循环 1 次。HTTP 请求指向秒杀接口带上用户 ID 参数。监听器用「聚合报告」看 TPS 和 99% 响应时间。第一轮压测后如果 TPS 低于 500检查 Redis 连接池配置。spring.redis.lettuce.pool.max-active默认是 8秒杀场景建议调到 50 以上。RabbitMQ 的spring.rabbitmq.listener.simple.concurrency默认是 1可以调到 5 到 10增加消费者并发。第二轮压测如果出现大量超时检查 Tomcat 线程数。server.tomcat.threads.max默认 200可以调到 500但不要超过系统文件句柄限制。同时观察 MySQL 的SHOW PROCESSLIST如果大量连接处于Waiting for lock说明数据库扣减库存的行锁竞争激烈可以考虑把库存拆成多个分片比如stock_count_1到stock_count_10请求按用户 ID 哈希到不同分片。5.2 生产环境必须调整的五个参数参数默认值建议值说明spring.redis.lettuce.pool.max-active850Redis 连接池最大连接数spring.rabbitmq.listener.simple.concurrency15消费者最小并发数server.tomcat.threads.max200500Tomcat 最大工作线程数spring.datasource.hikari.maximum-pool-size1030数据库连接池最大连接数seckill.rate.limit无单机 QPS 60%自定义限流阈值这些参数没有万能值需要根据压测结果逐步调整。我习惯先调 Redis 和 RabbitMQ再调 Tomcat最后调数据库。每次只改一个参数观察 TPS 变化。5.3 一个容易被忽略的细节JVM 预热秒杀系统上线后第一次请求往往很慢因为 JVM 还没预热类加载和 JIT 编译都在进行。我一般会在活动开始前 10 分钟用脚本跑一遍核心接口让 JVM 进入稳定状态。如果用的是容器部署注意容器的 CPU 限制不要太低否则 JIT 编译线程抢不到 CPU预热时间会拉长。另外日志级别在生产环境要调到INFO或WARNDEBUG级别在秒杀场景下会产生大量 IO拖慢响应。如果用了 Springboot 的 Actuator记得关闭不必要的端点减少暴露面。这套基于 Springboot 的秒杀系统源代码和文档说明能帮你省去搭框架的时间但真正决定成败的是参数调优和边界处理。我踩过最深的坑是 Redis 库存预热时忘了设过期时间活动结束后库存键一直留在 Redis 里下次活动开始前没清理导致库存对不上。后来养成了习惯每次活动前先跑一遍清理脚本再执行预热脚本最后用redis-cli确认键值和过期时间。希望帮到你。本文还有配套的精品资源点击获取