尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

高并发秒杀系统实战:Redis预减库存+RabbitMQ异步削峰架构解析

发布时间:2026/9/12 23:37:28

资讯中心
01
ARTICLE

高并发秒杀系统实战:Redis预减库存+RabbitMQ异步削峰架构解析

高并发秒杀系统实战:Redis预减库存+RabbitMQ异步削峰架构解析
简介基于SpringBoot与SpringCloud构建的高并发商品秒杀项目核心运用Redis缓存热点数据、RabbitMQ异步削峰解决秒杀场景下的瞬时流量冲击、超卖和订单积压问题。这套资料适合计算机相关专业学生、教师及企业开发者既可用于课程设计、毕业设计也能作为微服务与高并发架构的系统学习案例。压缩包共192个文件大小5.78MB内容涵盖Java源码与编译类文件、YAML多环境配置、HTML/CSS/JS前端页面、SQL数据库脚本、Markdown说明文档、PNG架构图等文件组织清晰从控制层、服务层到数据层均有对应实现便于分模块阅读和二次开发。资料附有详细文档与测试运行说明源码已经过验证可支撑完成秒杀下单、库存扣减、订单生成等核心流程。读者可从中学习Redis缓存与分布式锁、RabbitMQ消息延迟与削峰、服务注册发现等关键实践也可直接修改扩展用于毕业设计或项目答辩目前已有36人学习下载。1. 秒杀项目要解决的根本问题把瞬时流量挡在数据库之前商品秒杀的难点不在于“卖东西”而在于那一两秒内几十万请求同时打到一台服务上。数据库的连接数通常只有几百Redis 单实例也能到十万级 QPS两者差了两个数量级。所以高并发秒杀项目的设计主线从来只有一个能用缓存挡住流量就不用数据库能异步消化请求就不同步等待结果。本项目的技术组合是 springboot springcloud 搭微服务骨架redis 承担库存预减与热点数据访问rabbitmq 把下单动作从请求链路里摘出去做异步削峰数据库只处理真正落到交易环节的数据。这个方案适合两类人一类是把秒杀当毕业设计或简历项目、需要完整跑通“高并发”技术链路的同学另一类是业务系统有大促场景、想把秒杀模块从单体接口重构为独立服务的一线工程师。下文按架构拆分、Redis 预减、MQ 异步、压测排错四个层次展开所有代码片段来自我平时搭建秒杀工程时的实际写法你可以直接抄进自己的项目里改。2. 基于 springbootspringcloud 的秒杀微服务拆分与调用链设计2.1 秒杀域的服务划分网关、商品、秒杀与订单四层秒杀项目不要一上来就搞十几个微服务服务拆得越细链路越长排查起来越痛苦。我一般把秒杀拆成 4 个可独立部署的服务外加 Nacos 做注册中心和配置中心服务名端口职责核心依赖flash-gateway8080统一入口、限流、JWT 鉴权、放行秒杀请求springcloud gatewayflash-goods8081商品列表、秒杀活动配置、存库信息查询nacos config mybatisflash-sale8082预减库存、发送 MQ 订单消息、返回“秒杀中/成功/已售罄”redis rabbitmqflash-order8083消费订单消息、生成订单、扣减数据库库存rabbitmq mybatis四个服务的调用关系是客户端只打网关网关按路径路由到具体服务flash-sale 通过 openfeign 拉取 flash-goods 的商品信息flash-sale 不直接写订单库它只把成功扣减库存的请求投递到 MQ由 flash-order 消费并完成落库。这样做的好处是秒杀服务本身是无状态的压力再大也只需要横向扩容 flash-sale 节点而订单服务慢一点没关系因为流量已经变成了平滑的消息流。2.2 一次秒杀请求的完整调用链从网关到 MQ用文字把链路画清楚客户端 POST /api/seckill/{skuId} 到网关网关鉴权后转发到 flash-saleflash-sale 先查 Redis 里的活动开关与用户黑名单再执行 Lua 预减库存预减成功后发送订单消息到 RabbitMQ接口立刻返回“已进入排队”flash-order 消费消息用乐观锁 update 数据库库存插入订单表通过 WebSocket 或主动查询推送结果。这条链路里有两个最容易写错的地方。第一接口返回的“成功”不等于买到了前端要做轮询或长连接查订单结果不能把接口的响应码当最终结果。第二预减库存成功之后MQ 消息必须保证到达消费者后面第四章会展开说 confirm 机制和手动 ack。2.3 服务发现与配置中心nacos 的接入方式Spring Cloud Alibaba 生态下服务注册与配置中心最常用的是 nacos。flash-sale 服务的 bootstrap.yml 通常是这样的spring: application: name: flash-sale cloud: nacos: server-addr: 192.168.10.20:8848 username: nacos password: nacos discovery: namespace: flash-sale-prod config: namespace: flash-sale-prod group: FLASH_GROUP file-extension: yaml shared-configs: ->spring: datasource: hikari: maximum-pool-size: 60 minimum-idle: 20 connection-timeout: 3000 idle-timeout: 600000 max-lifetime: 1800000maximum-pool-size 不是越大越好MySQL 默认连接数上限通常在 151 左右4 个订单服务节点各自开到 60加起来就有 240 个连接会直接打满数据库连接数导致其他服务拿不到连接。建议用maximum-pool-size 核数 * 2 磁盘数的公式估算初始值再通过压测逐步上调。connection-timeout 设 3 秒是为了快速失败而不是让请求堆积在线程池里等连接。3. redis 预减库存与分布式锁——扛住流量峰值的关键3.1 库存预减先用 redis 的 string 记账秒杀接口被网关放行后第一件要做的事是查 Redis而不是查 MySQL。秒杀活动开始前将商品总库存量直接写入 Redis 的 string 类型 keyvalue 用十进制整数表示剩余库存同时用另一个 key 记录已售数量用于后续对账。// 活动开始前初始化库存 stringRedisTemplate.opsForValue().set(seckill:stock: skuId, 10); stringRedisTemplate.opsForValue().set(seckill:sold: skuId, 0); // 秒杀接口里查询 String stock stringRedisTemplate.opsForValue().get(seckill:stock: skuId); if (stock null || Integer.parseInt(stock) 0) { return Result.error(已售罄); }Redis 的读取速度在单机可达十万级 QPS把库存放在这里意味着大多数请求根本不会触达数据库。这里要说一个常见误区有人把“剩余库存”放到 hash 结构里跟别的字段混着存完全没有必要。秒杀库存就一个整数string 最合适遇到需要展示多人已抢时再用 hash 存 userId 到下单状态的映射不要把所有信息都塞进一个 key。3.2 lua 脚本保证“判断扣减”原子性上面那段“查库存再判断”的代码有一个经典问题他不是原子操作。两个并发请求同时读到 stock1同时判断大于 0同时扣减库存就变成了 -1。必须把“判断库存是否足够 扣减”这两步合并成一个原子操作。Redis 的 lua 脚本执行是原子的这是秒杀扣库存的标配-- KEYS[1]: seckill:stock:{skuId} -- KEYS[2]: seckill:sold:{skuId} -- ARGV[1]: 本次购买数量秒杀场景通常为 1 local stock redis.call(get, KEYS[1]) if not stock then return -1 -- 库存 key 不存在活动未开始或已结束 end if tonumber(stock) tonumber(ARGV[1]) then return 0 -- 库存不足 end redis.call(decrby, KEYS[1], ARGV[1]) redis.call(incrby, KEYS[2], ARGV[1]) return 1 -- 扣减成功Java 侧调用这段脚本时用 DefaultRedisScript 把它加载成可复用对象private static final DefaultRedisScriptLong SECKILL_SCRIPT new DefaultRedisScript(); static { SECKILL_SCRIPT.setScriptText( local stock redis.call(get, KEYS[1]) ... // 上面的 lua 内容 ); SECKILL_SCRIPT.setResultType(Long.class); } Long result stringRedisTemplate.execute( SECKILL_SCRIPT, Arrays.asList(seckill:stock: skuId, seckill:sold: skuId), 1 ); if (result ! null result 1) { // 扣减成功发送 MQ 消息 } else if (result ! null result 0) { return Result.error(已售罄); } else { return Result.error(活动未开始); }这段代码的关键点有两个。其一是每次执行都传完整 lua 源码Redis 内部会做脚本缓存不用担心编译开销其二是 execute 方法的第一个集合参数里的 key必须跟脚本里 KEYS 的顺序严格对应顺序错了 Redis 会返回 nil你会在生产环境里看到“莫名其妙扣不到库存”的诡异问题。3.3 分布式锁选型setnx 还是 redissonLua 解决的是库存扣减的原子性但秒杀场景还有另一个并发问题同一个用户疯狂点击按钮会产生多个重复下单请求。这个问题需要“按用户加锁”。业界有两种做法。第一种是 Redis 原生 setnx命令本身原子但要注意设置过期时间时必须用一条命令完成不要分两步执行Boolean locked stringRedisTemplate.opsForValue() .setIfAbsent(seckill:user: userId : skuId, 1, Duration.ofSeconds(3));如果用 setnx 单独执行再 expire 设置过期时间中间进程挂掉的话锁就永远不会释放后面的请求全部卡死。第二种更推荐的做法是引入 Redisson它把看门狗续期机制做进了客户端内部锁快过期时如果业务还没执行完会自动续期不需要你手动管理过期时间RLock lock redissonClient.getLock(lock:user: userId : skuId); boolean tryLock lock.tryLock(2, 30, TimeUnit.SECONDS); if (!tryLock) { return Result.error(操作太频繁); } try { // 预减库存 发送 MQ 消息 } finally { lock.unlock(); }tryLock 的第一个参数是等待锁的时间第二个是锁自动释放时间。注意这里不要签名写反先等锁后释锁。很多人把两个时间参数搞混导致锁提前释放重复请求又进来了。3.4 缓存穿透、击穿、雪崩的兜底手段Redis 挡在前面但缓存层本身也可能出问题。最常见的三个穿透恶意用户用不存在的 skuId 反复请求Redis 查不到请求漏到数据库。解决办法是查不到就把空值也缓存起来并设置 60 秒左右的短过期时间。注意空值的 value 不能写成 null要写一个约定好的占位符比如空字符串或 “N”否则缓存系统会认为数据未加载而继续放行。击穿某个 sku 是热点缓存过期的一瞬间大量请求集中打到数据库。这正好是上一节分布式锁的另一处应用在查询数据库重建缓存之前加一把“缓存重建锁”只让一个线程去查库其他线程短暂自旋重试。雪崩大量 key 同时过期应对办法是给过期时间加随机数int expireSeconds 60 * 60 * 6 RandomUtil.randomInt(0, 600);把过期时间打散避免同一时间点集体失效。对于秒杀这种场景库存 key 根本不需要设置过期时间活动结束由后台任务删除即可。3.5 redis 里的数据最后怎么和数据库对齐预减库存只是“预占”真正的扣减发生在 flash-order 消费消息时。如果 MQ 消息丢了、消费者挂了Redis 显示卖了 10 件但数据库只扣了 9 件两边就对不上。解决这个问题必须靠对账任务每天定时扫 Redis 的 sold 计数和数据库订单表做对比差值超过阈值就告警。对账脚本我通常用 xxl-job 或 spring task 写一个定时任务SQL 是这样的select sku_id, count(*) as order_count from t_seckill_order where create_time date_sub(now(), interval 1 day) group by sku_id然后把这条 SQL 的结果跟 Redis 里 sold 计数做比对。差 1~2 笔可能是消息还没消费完先等 5 分钟再查差得多就说明消费链路出了问题需要去翻死信队列。4. rabbitmq 异步下单——削峰填谷与消息可靠性4.1 为什么秒杀下单必须走消息队列如果不在中间插入消息队列flash-order 的下单接口被 flash-sale 直接调用会带来两个致命问题一是 flash-order 的接口吞吐由数据库写入能力决定通常只有每秒几百到一千秒杀流量一来直接把它打爆二是 flash-sale 在等待订单接口返回的过程中连接被白白占住服务本身的吞吐也会跟着下降。走 rabbitmq 之后flash-sale 只做“把消息投递到队列”这个操作写内存队列的速度比写数据库高两个数量级接口响应时间也从几百毫秒降到几十毫秒。秒杀请求里真正能抢到的比例很低绝大多数流量在 Redis 预减阶段就被拦截掉了剩下的“有效请求”才转为消息这就是削峰。4.2 生产者把“扣库存成功”的请求转成消息Lua 扣减成功之后紧接着构造一个消息体里面至少要包含 userId、skuId、秒杀活动 ID以及一个全局唯一的消息 ID。这个消息 ID 后面会用来做幂等判断所以不能漏也不能用随机数临时生成就算最好是订单号生成器一类的全局序列。JSONObject msg new JSONObject(); msg.put(msgId, IdUtils.generateId()); msg.put(userId, userId); msg.put(skuId, skuId); msg.put(activityId, activityId); rabbitTemplate.convertAndSend( SeckillMqConstants.SECKILL_EXCHANGE, SeckillMqConstants.ORDER_ROUTING_KEY, msg.toJSONString(), message - { message.getMessageProperties().setMessageId(msg.getString(msgId)); message.getMessageProperties().setDeliveryMode(MessageDeliveryMode.PERSISTENT); return message; } );convertAndSend 的重载参数分别是交换机名称、路由键、消息正文、消息增强回调。回调里设置了两件重要的事messageId 用于消费者幂等PERSISTENT 持久化模式保证消息写入磁盘这样即使 broker 重启也不丢消息。不写 deliveryMode 的话默认是临时消息服务重启就没了。交换机和队列我不会在代码里用 RabbitAdmin 动态声明而是直接写一个配置类一次性声明交换机、队列和绑定关系Configuration public class SeckillMqConfig { Bean public DirectExchange seckillExchange() { return ExchangeBuilder.directExchange(flash.sale.exchange) .durable(true).build(); } Bean public Queue seckillOrderQueue() { return QueueBuilder.durable(flash.sale.order.queue).build(); } Bean public Binding seckillBinding() { return BindingBuilder.bind(seckillOrderQueue()) .to(seckillExchange()).with(seckill.order); } }注意交换机使用的类型。秒杀只有一种订单消息用 direct 交换机就够了如果你在同一个交换机上还发“取消订单”“支付回调”这几种消息就要考虑 topic 交换机路由键写成 seckill.order.cancel 这种多点匹配的形式。4.3 消费者幂等建单数据库行锁兜底flash-order 消费消息时必须处理“同一条消息被投递两次”的情况。RabbitMQ 的 at-least-once 语义决定了消费者可能在处理完业务、但还没来得及 ack 时宕机此时队列会重新投递这条消息。所以消费者第一件事是查重RabbitListener(queues flash.sale.order.queue) public void createOrder(String body, Channel channel, Message message) throws IOException { JSONObject msg JSON.parseObject(body); String msgId msg.getString(msgId); // 1. 幂等查重订单表里有没有这个 msgId Integer count seckillOrderMapper.selectUserOrderCount( msg.getLong(userId), msg.getLong(skuId)); if (count ! null count 0) { // 重复消息直接 ack不处理 channel.basicAck(message.getMessageProperties().getDeliveryTag(), false); return; } // 2. 数据库扣减库存用行锁防止超卖 int rows stockMapper.deductStock(msg.getLong(skuId)); if (rows 0) { // 库存不足把消息转存到失败队列或记录日志ack 掉避免无限重投 channel.basicAck(message.getMessageProperties().getDeliveryTag(), false); return; } // 3. 插入订单 seckillOrderMapper.insert(...); // 4. 手动 ack channel.basicAck(message.getMessageProperties().getDeliveryTag(), false); }deductStock 的 SQL 是整个链路中最后一道防线它用了“乐观扣减 条件判断”的行锁写法update t_seckill_stock set stock stock - 1, version version 1 where sku_id #{skuId} and stock 0update 语句执行时会对命中行加写锁两个并发事务同时执行这条 SQL后到的那一个会因为 stock 0 条件不满足而影响行数为 0。这是防止超卖的兜底方案即便前面的 Redis、MQ 都失效了数据库也不会把库存扣成负数。4.4 消息不丢失的三个环节RabbitMQ 丢消息可能发生在三个环节生产者发送失败、broker 持久化失败、消费者没消费完就 ack。项目里这三个环节一个都不能省。生产者侧开 confirm 回调异步确认 broker 已收到消息spring: rabbitmq: publisher-confirm-type: correlated publisher-returns: true template: mandatory: truerabbitTemplate.setConfirmCallback((correlationData, ack, cause) - { if (!ack) { log.error(消息投递失败cause: {}, cause); // 落库标记失败定时任务重新投递 } });broker 侧要注意镜像队列。单节点 rabbitmq 在消息持久化到磁盘之前宕机消息一样会丢。生产环境至少要搭 3 节点镜像集群或者用 quorum queue仲裁队列替代经典队列quorum 队列基于 Raft 协议主从切换不丢消息。我现在的新项目已不推荐再用镜像队列后者的脑裂处理要比前者优雅得多。消费者侧用手动 ack不要用自动 ack 模式。自动 ack 是消费者收到消息就立刻返回确认业务还没执行就标记成功一旦处理中途抛出异常消息就永远消失了。4.5 死信队列处理下单失败的订单flash-order 消费消息时如果数据库扣减库存失败直接 ack 掉不做处理用户那边会一直处于“排队中”状态体验很差。更好的方案是把这类消息投递到死信队列由另一个服务去通知用户“手速慢了活动已结束”。在声明订单队列时加上死信交换机的参数Bean public Queue seckillOrderQueue() { MapString, Object args new HashMap(); // 设置死信交换器和死信路由键 args.put(x-dead-letter-exchange, flash.sale.dlx.exchange); args.put(x-dead-letter-routing-key, seckill.fail); return QueueBuilder.durable(flash.sale.order.queue) .withArguments(args).build(); }消费者里显式抛出 AmqpRejectAndDontRequeueException消息就会被自动路由到死信队列而不是无限重投try { // 业务处理 } catch (Exception e) { // 拒绝消息且不重新入队 throw new AmqpRejectAndDontRequeueException(下单失败进入死信队列, e); }这里要注意死信和重试的区别只有确定“再试也会失败”的消息才应该进死信队列比如库存确实没了对于数据库临时抖动导致的失败应该用basicNack(deliveryTag, false, true)让消息重新入队再试几次而不是直接打死信。按业务性质区分这两种策略是消息队列使用水平的分水岭。5. 秒杀接口的压测验证与三个高频排查点5.1 用 jmeter 发起集群压测的命令与参数项目跑起来之后先做单机压测再上集群。jmeter 的无界面压测命令参数很固定jmeter -n -t seckill.jmx -l result.jtl -e -o report \ -Jthreads2000 \ -Jrampup20 \ -Jduration120这里 -J 参数会覆盖 jmx 脚本里的用户自定义变量线程数 200020 秒内全部拉满持续压 120 秒。观察两个指标聚合报告里的 error% 超过 1% 就要回头查服务日志吞吐量曲线在压测中段出现平台期说明已经到达系统峰值继续加线程只是增加排队。我一般把监听器里的响应时间阈值设为 200ms。秒杀接口如果你发现平均响应时间超过 300ms先看是不是被限流了再看 Redis 的 connect 数是不是打满。压测机和服务不要部署在同一台机器否则压测结果会被 CPU 竞争干扰。5.2 三个高频排查点连接池溢出、消息堆积、超卖疑点第一个是 Redis 连接池溢出。Lua 脚本执行是一次网络往返但高并发下每个线程从连接池 loan 一个 connection池子默认 8 个很容易打满。把 lettuce 换成 jedis或调大spring.redis.lettuce.pool.max-active到 200并观察 Redis 侧 connections 数量是否异常升高。第二个是 RabbitMQ 消息堆积。消费者消费速度跟不上生产速度时队列 depth 会持续上涨。不要盲目加消费者实例先看 flash-order 慢在哪里数据库锁等待时间高还是 MyBatis 批量插入效率低。如果队列堆积超过 10 万且 delta 在扩大优先考虑把订单表做分表而不是无限扩容消费者。第三个是“超卖疑点”。如果对账发现数据库扣减数大于 Redis 预减数大概率是 Lua 脚本里 KEYS 传错或过期时间设置不当。去 Redis 里查seckill:sold:{skuId}和数据库 count 对比差多少、差的哪些 sku一次性就能定位。所有排查日志里必须打印 skuId、userId 和 msgId 三个维度缺一个你就得逐条翻 MQ trace。最后说一个压测时要主动做的小验证在压测流量里混入 2% 的重复 userId 请求确认这些请求被 Redis 分布式锁拦掉而不是进入 MQ 排队。这个混入测试能同时验证 Lua 脚本、Redisson 锁和 Mongo 幂等表三层的正确性跑过这一关这个秒杀项目才算真的能接生产流量。本文还有配套的精品资源点击获取
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。