ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot秒杀系统实战:Redis+Lua+消息队列解决高并发超卖

SpringBoot秒杀系统实战:Redis+Lua+消息队列解决高并发超卖 简介这是一套基于SpringBoot的电商基础秒杀项目完整工程资源面向计算机相关专业的毕业设计、课程设计、大作业、工程实训及学科竞赛参赛者也适合希望练手全栈开发的学习者。资源包共2000个文件约53.91MB以1240个JavaScript、357个CSS、148个HTML等前端资源为主配合40个Java后端源码、113个Markdown说明文档及JSON、XML、YAML等配置文件构成前后端分离的完整工程结构可支撑秒杀场景下的商品展示、下单与库存控制等核心流程。已有43人学习关注。项目代码经过测试运行功能完整可实现复现复刻答辩评审平均分达96分设计报告亦可借鉴。读者可据此快速搭建可运行环境理解秒杀业务的分层设计与接口组织并在此基础上扩展新功能适合作为项目立项与学习参考。1. 秒杀系统到底难在哪从 SpringBoot 电商项目说起很多人第一次接触「基于 SpringBoot 的电商基础秒杀项目」第一反应是不就是个商品下单吗加个库存字段减一减就完事了。真动手才发现平时安安静静的商品接口一旦挂到秒杀场景下几百上千个请求同时打进来库存能减成负数订单能重复生成数据库连接池直接被打满。秒杀这个场景本质上是把电商系统里最尖锐的并发问题压缩到几秒钟内集中爆发它考验的不是你会不会写 CRUD而是你懂不懂限流、缓存、异步和锁。这个项目适合两类人一类是正在做毕设、课设、实训大作业的学生需要一个结构完整、技术点密集、能讲清楚设计思路的电商项目另一类是刚工作不久、想补齐高并发实战经验的开发者平时写的业务太温和没机会碰真正的流量冲击。下面我按一个能跑起来、能讲清楚、能扛住压测的最小闭环把 SpringBoot 秒杀项目的选型、实现和踩坑讲透你照着做就能复现一套属于自己的秒杀系统。2. 技术选型与项目骨架为什么是 SpringBoot 加 Redis 加消息队列2.1 秒杀场景下 SpringBoot 的定位与边界SpringBoot 在这个项目里扮演的是「业务编排层」的角色它负责把 HTTP 请求接进来、做参数校验、调用库存服务、生成订单、返回结果。它的自动装配和 starter 机制让整合 Redis、MySQL、消息队列变得非常省事这也是为什么「springboot项目结构」和「springboot自动装配原理」一直是热搜词——大家真正关心的是怎么把一堆中间件干净地塞进一个工程里。但 SpringBoot 本身不解决并发问题。它内嵌的 Tomcat 默认最大线程数是 200意味着同一时刻最多 200 个请求在业务线程里跑超出的请求排队。秒杀场景下这个数字远远不够所以常见做法是在 SpringBoot 前面加一层限流或者用 Nginx 做负载均衡把流量分散到多个实例。我一般会先把 Tomcat 的max-threads调到 500 左右配合 Redis 做库存预减把真正落到数据库的请求压到个位数。选型上MySQL 存商品和订单Redis 扛库存和限流RabbitMQ 或 RocketMQ 做异步下单。这个组合是经过大量项目验证的资料多、坑也透明。如果你用「springboot整合activemq」思路一样只是消息中间件换了个实现核心的异步削峰逻辑不变。2.2 用 Spring Initializr 搭出可运行的最小骨架第一步不是写业务代码而是把工程结构定下来。我习惯用 Spring Initializr 生成依赖勾选 Spring Web、Spring Data Redis、MyBatis Framework、MySQL Driver、Spring for RabbitMQ。生成后目录结构如下seckill-demo ├── src/main/java/com/example/seckill │ ├── SeckillApplication.java │ ├── controller │ ├── service │ ├── mapper │ ├── entity │ └── config ├── src/main/resources │ ├── application.yml │ └── mapper └── pom.xmlapplication.yml里先把数据源和 Redis 配好spring: datasource: url: jdbc:mysql://localhost:3306/seckill?useSSLfalseserverTimezoneAsia/Shanghai username: root password: your_password hikari: maximum-pool-size: 50 minimum-idle: 10 redis: host: localhost port: 6379 lettuce: pool: max-active: 100 max-idle: 20 rabbitmq: host: localhost port: 5672 username: guest password: guest server: tomcat: threads: max: 500 min-spare: 50这里有几个参数值得说清楚。hikari.maximum-pool-size设成 50 是因为秒杀请求大部分被 Redis 挡掉了真正到数据库的并发不高连接池开太大反而浪费。lettuce.pool.max-active设 100 是为了让 Redis 操作有足够的并发通道。Tomcat 的max设 500 是给限流后的请求留余量具体数值要结合压测结果调不是拍脑袋定的。提示如果你用的 SpringBoot 版本较高比如 3.xRedis 客户端默认还是 Lettuce但配置项前缀可能从spring.redis变成spring.data.redis启动报错先检查这里。这也是「springboot版本太高」这个热搜词背后的真实痛点。2.3 数据库表设计三张表撑起整个秒杀流程秒杀项目不需要复杂的电商表结构三张表足够商品表、秒杀商品表、订单表。CREATE TABLE goods ( id BIGINT NOT NULL AUTO_INCREMENT, name VARCHAR(128) NOT NULL, price DECIMAL(10,2) NOT NULL, PRIMARY KEY (id) ); CREATE TABLE seckill_goods ( id BIGINT NOT NULL AUTO_INCREMENT, goods_id BIGINT NOT NULL, seckill_price DECIMAL(10,2) NOT NULL, stock INT NOT NULL, start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, PRIMARY KEY (id), KEY idx_goods_id (goods_id) ); CREATE TABLE seckill_order ( id BIGINT NOT NULL AUTO_INCREMENT, user_id BIGINT NOT NULL, goods_id BIGINT NOT NULL, seckill_price DECIMAL(10,2) NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_user_goods (user_id, goods_id) );seckill_order上的唯一索引uk_user_goods是防重复下单的最后一道防线。即使前面的 Redis 判断失效数据库层面也会拦住同一个用户对同一商品的重复插入。这个索引不是可选项是必须项。3. 库存扣减与限流把并发挡在数据库之外3.1 Redis 预减库存的原子操作与 Lua 脚本秒杀最核心的一步是扣库存。如果直接UPDATE seckill_goods SET stock stock - 1 WHERE stock 0在并发下会出现超卖因为多个事务可能同时读到stock 1。常见做法是把库存预热到 Redis用 Lua 脚本保证判断和扣减的原子性。// 库存预热项目启动后把秒杀商品库存写入 Redis PostConstruct public void initStock() { ListSeckillGoods goodsList seckillGoodsMapper.selectList(null); for (SeckillGoods goods : goodsList) { redisTemplate.opsForValue().set(seckill:stock: goods.getId(), goods.getStock()); } }// Lua 脚本原子性判断库存并扣减 private static final String STOCK_LUA local stock redis.call(get, KEYS[1]) if stock and tonumber(stock) 0 then redis.call(decr, KEYS[1]) return 1 else return 0 end; public boolean deductStock(Long seckillGoodsId) { DefaultRedisScriptLong script new DefaultRedisScript(STOCK_LUA, Long.class); Long result redisTemplate.execute(script, Collections.singletonList(seckill:stock: seckillGoodsId)); return result ! null result 1; }这段 Lua 脚本在 Redis 单线程模型下执行get和decr之间不会被其他命令插入所以不会出现两个请求同时读到stock 1的情况。KEYS[1]是库存键名返回值 1 表示扣减成功0 表示库存不足。注意脚本里用了tonumber做类型转换因为 Redis 返回的是字符串直接比较会出错。注意库存预热要在秒杀开始前完成如果秒杀过程中重启服务Redis 里的库存会丢失。我一般会加一个定时任务每隔几分钟把数据库库存同步到 Redis作为兜底。3.2 用 RabbitMQ 异步下单削峰Redis 扣减成功后不要直接在接口里写订单到数据库而是发一条消息到 RabbitMQ让消费者慢慢处理。这样接口的响应时间从几百毫秒降到几毫秒用户体验好数据库压力也小。// 生产者扣减库存成功后发送消息 Autowired private RabbitTemplate rabbitTemplate; public void sendOrderMessage(Long userId, Long seckillGoodsId) { SeckillMessage message new SeckillMessage(userId, seckillGoodsId); rabbitTemplate.convertAndSend(seckill.exchange, seckill.order, message); }// 消费者异步创建订单 RabbitListener(queues seckill.order.queue) public void handleOrder(SeckillMessage message) { // 幂等性检查先查订单是否已存在 SeckillOrder exist orderMapper.selectByUserAndGoods( message.getUserId(), message.getSeckillGoodsId()); if (exist ! null) { return; } // 扣减数据库库存 int affected seckillGoodsMapper.deductStock(message.getSeckillGoodsId()); if (affected 0) { // 库存不足回滚 Redis 库存 redisTemplate.opsForValue().increment( seckill:stock: message.getSeckillGoodsId()); return; } // 创建订单 SeckillOrder order new SeckillOrder(); order.setUserId(message.getUserId()); order.setGoodsId(message.getSeckillGoodsId()); orderMapper.insert(order); }消费者里的幂等性检查很关键。RabbitMQ 可能重复投递消息如果没有这个检查同一个用户会生成多笔订单。数据库的唯一索引uk_user_goods也能兜底但提前查一次可以减少异常日志。deductStock对应的 SQL 是UPDATE seckill_goods SET stock stock - 1 WHERE id ? AND stock 0返回受影响行数0 表示库存已经被其他消费者扣完了。3.3 接口限流用 Redis 计数器挡住脚本请求秒杀接口最怕的是有人用脚本刷。常见做法是在接口入口加一层限流基于 Redis 的INCR和过期时间实现。public boolean tryAcquire(Long userId, Long seckillGoodsId) { String key seckill:limit: seckillGoodsId : userId; Long count redisTemplate.opsForValue().increment(key); if (count ! null count 1) { redisTemplate.expire(key, 5, TimeUnit.SECONDS); } return count ! null count 1; }这段逻辑的意思是同一个用户对同一个秒杀商品5 秒内只允许请求一次。第一次increment返回 1设置过期时间第二次返回 2直接拒绝。这个限流粒度比较粗但能挡住大部分重复点击和简单脚本。如果要更精细可以用令牌桶算法但实现复杂度会上升毕设和课设场景下这个方案足够。4. 避坑与排查秒杀项目最容易翻车的五个地方4.1 库存超卖Redis 扣减成功但数据库扣成负数现象压测时发现数据库里stock字段出现负数或者订单数量超过库存。原因Redis 扣减和数据库扣减之间没有做好一致性。如果 Redis 扣减成功但消息消费失败数据库库存没动Redis 库存却少了反过来如果 Redis 扣减失败但数据库扣减成功就会超卖。解决数据库扣减 SQL 必须带AND stock 0条件靠数据库行锁保证不会扣成负数。同时消费者里如果数据库扣减失败要把 Redis 库存加回去。这个回滚逻辑不能省否则 Redis 库存会越来越少。4.2 重复下单同一个用户生成多笔订单现象用户点了一次秒杀订单表里出现两条相同记录。原因RabbitMQ 消息重复投递或者用户快速点击两次两次请求都通过了限流。解决订单表加UNIQUE KEY (user_id, goods_id)这是最后一道防线。消费者里先查再插减少异常。限流那层用INCR保证 5 秒内只放一次请求进来。4.3 Redis 连接超时压测时大量请求报连接池耗尽现象压测到 500 并发时日志里出现Could not get a resource from the pool。原因Lettuce 连接池max-active设得太小或者 Redis 操作里有慢查询阻塞了连接。解决把max-active调到 200 以上同时检查 Lua 脚本里有没有KEYS *这种全量扫描操作。另外redisTemplate的序列化方式要设成StringRedisSerializer默认的 JDK 序列化会产生乱码且体积大。4.4 消息堆积RabbitMQ 队列里消息越来越多现象秒杀结束后RabbitMQ 管理页面显示队列里有几万条未消费消息。原因消费者处理速度跟不上生产速度或者消费者线程数太少。解决在application.yml里配置spring.rabbitmq.listener.simple.concurrency: 5和max-concurrency: 10增加消费者并发数。同时检查消费者里的数据库操作有没有慢 SQL加索引优化。4.5 秒杀时间判断错误活动没开始就能下单现象秒杀开始时间还没到接口就返回下单成功。原因时间判断只在前端做了后端没校验或者服务器时间不同步。解决后端接口里必须校验start_time和end_time用数据库时间或 Redis 时间不要用应用服务器本地时间。如果部署多台机器时间同步是必须的。5. 压测验证与进阶技巧怎么证明你的秒杀系统真的扛住了5.1 用 JMeter 做一轮可复现的压测写完代码不算完得用数据证明系统能扛。我一般用 JMeter 做压测线程组设 1000 个线程Ramp-up 设 1 秒循环 1 次模拟瞬间涌入。HTTP 请求指向秒杀接口带上用户 ID 参数。跑完后看三个指标TPS、平均响应时间、错误率。一个健康的秒杀系统TPS 应该在 1000 以上平均响应时间在 50ms 以内错误率低于 1%。如果 TPS 只有几十说明请求都堵在数据库或者 Redis 连接池上了回去检查连接池配置和慢查询。如果错误率很高看日志里是限流拒绝还是超时限流拒绝是正常的超时就要排查。压测时记得把 Redis 和 MySQL 的监控打开看 CPU 和内存曲线。Redis 的INFO stats里instantaneous_ops_per_sec能看出实际 QPSMySQL 的SHOW PROCESSLIST能看出有没有慢查询堆积。5.2 用本地标记减少 Redis 访问进阶优化里有一个很实用的技巧在应用内存里维护一个本地库存标记当 Redis 库存扣到 0 时直接在本地拦截后续请求不再访问 Redis。private final MapLong, Boolean localStockFlag new ConcurrentHashMap(); public boolean deductStockWithLocalFlag(Long seckillGoodsId) { // 本地标记为已售罄直接返回 if (Boolean.TRUE.equals(localStockFlag.get(seckillGoodsId))) { return false; } boolean success deductStock(seckillGoodsId); if (!success) { localStockFlag.put(seckillGoodsId, true); } return success; }这个标记的过期时间要和秒杀活动结束时间对齐活动结束后清掉。它的好处是当库存为 0 后后续请求在 JVM 层面就被挡住了Redis 的 QPS 会大幅下降。代价是如果 Redis 库存被回滚比如消费者处理失败本地标记不会自动恢复需要额外的同步机制。我一般只在压测环境用这个技巧生产环境要谨慎。5.3 一个我踩过的坑序列化方式选错导致库存键乱码最后说一个血泪教训。刚开始用redisTemplate.opsForValue().set(seckill:stock: id, stock)时没配序列化器结果 Redis 里存的键是\xac\xed\x00\x05t\x00\x14seckill:stock:1这种乱码。Lua 脚本里用KEYS[1]去取根本取不到库存扣减永远返回 0。排查了半天才反应过来RedisTemplate默认用JdkSerializationRedisSerializer键和值都会被序列化成二进制。解决办法是加一个配置类Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(new StringRedisSerializer()); template.setHashKeySerializer(new StringRedisSerializer()); template.setHashValueSerializer(new StringRedisSerializer()); template.afterPropertiesSet(); return template; } }配完之后Redis 里的键就是可读的字符串Lua 脚本也能正常取到值。这个坑不复杂但第一次遇到很容易懵因为代码逻辑看起来完全正确问题出在看不见的序列化层。希望帮到你。本文还有配套的精品资源点击获取
返回列表