高并发秒杀系统实战:Redis预减库存+RabbitMQ异步削峰架构解析
2026/9/12 23:37:28 网站建设 项目流程

简介:基于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. 基于 springboot+springcloud 的秒杀微服务拆分与调用链设计

2.1 秒杀域的服务划分:网关、商品、秒杀与订单四层

秒杀项目不要一上来就搞十几个微服务,服务拆得越细,链路越长,排查起来越痛苦。我一般把秒杀拆成 4 个可独立部署的服务,外加 Nacos 做注册中心和配置中心:

服务名端口职责核心依赖
flash-gateway8080统一入口、限流、JWT 鉴权、放行秒杀请求springcloud gateway
flash-goods8081商品列表、秒杀活动配置、存库信息查询nacos config + mybatis
flash-sale8082预减库存、发送 MQ 订单消息、返回“秒杀中/成功/已售罄”redis + rabbitmq
flash-order8083消费订单消息、生成订单、扣减数据库库存rabbitmq + mybatis

四个服务的调用关系是:客户端只打网关,网关按路径路由到具体服务,flash-sale 通过 openfeign 拉取 flash-goods 的商品信息,flash-sale 不直接写订单库,它只把成功扣减库存的请求投递到 MQ,由 flash-order 消费并完成落库。

这样做的好处是:秒杀服务本身是无状态的,压力再大也只需要横向扩容 flash-sale 节点,而订单服务慢一点没关系,因为流量已经变成了平滑的消息流。

2.2 一次秒杀请求的完整调用链:从网关到 MQ

用文字把链路画清楚:客户端 POST /api/seckill/{skuId} 到网关;网关鉴权后转发到 flash-sale;flash-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: 1800000

maximum-pool-size 不是越大越好,MySQL 默认连接数上限通常在 151 左右,4 个订单服务节点各自开到 60,加起来就有 240 个连接,会直接打满数据库连接数导致其他服务拿不到连接。建议用maximum-pool-size = 核数 * 2 + 磁盘数的公式估算初始值,再通过压测逐步上调。connection-timeout 设 3 秒,是为了快速失败而不是让请求堆积在线程池里等连接。

3. redis 预减库存与分布式锁——扛住流量峰值的关键

3.1 库存预减:先用 redis 的 string 记账

秒杀接口被网关放行后,第一件要做的事是查 Redis,而不是查 MySQL。秒杀活动开始前,将商品总库存量直接写入 Redis 的 string 类型 key,value 用十进制整数表示剩余库存;同时用另一个 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 脚本保证“判断+扣减”原子性

上面那段“查库存再判断”的代码有一个经典问题:他不是原子操作。两个并发请求同时读到 stock=1,同时判断大于 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 DefaultRedisScript<Long> 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 还是 redisson

Lua 解决的是库存扣减的原子性,但秒杀场景还有另一个并发问题:同一个用户疯狂点击按钮,会产生多个重复下单请求。这个问题需要“按用户加锁”。业界有两种做法。

第一种是 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 > 0

update 语句执行时会对命中行加写锁,两个并发事务同时执行这条 SQL,后到的那一个会因为 stock > 0 条件不满足而影响行数为 0。这是防止超卖的兜底方案,即便前面的 Redis、MQ 都失效了,数据库也不会把库存扣成负数。

4.4 消息不丢失的三个环节

RabbitMQ 丢消息可能发生在三个环节:生产者发送失败、broker 持久化失败、消费者没消费完就 ack。项目里这三个环节一个都不能省。

生产者侧开 confirm 回调,异步确认 broker 已收到消息:

spring: rabbitmq: publisher-confirm-type: correlated publisher-returns: true template: mandatory: true
rabbitTemplate.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() { Map<String, 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 \ -Jthreads=2000 \ -Jrampup=20 \ -Jduration=120

这里 -J 参数会覆盖 jmx 脚本里的用户自定义变量:线程数 2000,20 秒内全部拉满,持续压 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 幂等表三层的正确性,跑过这一关,这个秒杀项目才算真的能接生产流量。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询