实战复盘:10W+ QPS 秒杀架构演进(Redis Lua + 分片 + RabbitMQ)

前言

面试中经常被问到:"你做过最高并发的系统是什么?当面对 10W+ QPS 的瞬时流量,普通的"查库 -> 判断 -> 扣减 -> 更新"逻辑会让数据库瞬间熔断。"扛住高并发"从来不是某一项技术的单打独斗,而是一场蓄谋已久的分层防御战。

本文将基于 Redis Lua + 库存分片 + RabbitMQ 的核心架构,深度拆解其中的设计细节与那些差点踩翻的"坑"。


一、 全局视角:流量漏斗架构

如果你直接问我:"Redis 怎么扛 10W QPS?",我会先制止你。因为在请求到达 Redis 之前,必须先经过层层削减。我们将架构设计为一个漏斗:

  1. CDN/静态资源 :拦截 90% 的页面流量(图片、CSS、JS)。

  2. 本地缓存 (Caffeine) :拦截 60% 的读请求(活动配置、规则)。

    • 架构思考:如何保证一致性?我们利用 MQ 广播机制(类似 Spring Cloud Bus),当后台修改配置时,通知所有节点失效本地缓存。
  3. Redis 集群:处理核心的"写"请求(扣库存、发令牌)。

  4. RabbitMQ:流量削峰,异步解耦。

  5. MySQL 数据库:最终落单,保证账目兜底。

本文核心聚焦于最凶险的环节:Redis 高并发扣减与数据一致性。


二、 核心攻坚:Redis Lua 脚本与原子性

在秒杀场景下,传统的 Java 代码(即使使用 Redisson 分布式锁)存在两个致命问题:

  1. 性能损耗:获取锁、判断、扣减、释放锁,至少 4 次网络 RTT。
  2. 锁竞争:高并发下线程阻塞严重,CPU 上下文切换频繁。

1. 战术核武器:Lua 脚本

为了极致性能,我将"查询库存"与"扣减库存"的逻辑下沉到 Redis 服务端,封装为一个 Lua 脚本。

收益

  • 原子性:Redis 单线程模型保证了脚本执行期间,不会有任何其他命令插入,天然防止超卖。
  • 零 RTT :将多次网络通信压缩为 1 次

核心 Lua 逻辑演示:

lua 复制代码
-- keys[1]: 库存Key, keys[2]: 用户历史记录Key
-- argv[1]: 用户ID, argv[2]: 活动ID
local stock_key = KEYS[1]
local user_history_key = KEYS[2]
local user_id = ARGV[1]
​
-- 1. 校验是否重复领取
if redis.call("SISMEMBER", user_history_key, user_id) == 1 then
    return -1 -- 重复领取
end
​
-- 2. 校验库存
local stock = tonumber(redis.call("GET", stock_key))
if stock <= 0 then
    return -2 -- 库存不足
end
​
-- 3. 核心扣减逻辑
redis.call("DECR", stock_key)
redis.call("SADD", user_history_key, user_id)
return 1 -- 抢购成功

2. 致命陷阱:集群下的 CROSSSLOT

当你自信满满地上线,可能会遇到一个报错:

CROSSSLOT Keys in request don't hash to the same slot

原因 :Redis Cluster 模式下,Key 根据 CRC16 算法分配槽位。若 Lua 脚本同时操作 activity_stock(A节点)和 user_history(B节点),Redis 为了保证事务安全性,会直接拒绝跨 Slot 执行。

解法:使用Hash Tag {}

我们在 Key 的设计上做了妥协,使用 Hash Tag 强制路由:

  • 库存 Key:activity:{1001}:stock
  • 用户 Key:user:{1001}:8888

Redis 只会计算 {} 内部的字符串(即 1001)的 Hash,确保它们必定落在同一个redis节点。


三、 极限挑战:热点 Key 分片 (Sharding)

使用 Hash Tag 虽然解决了原子性,但引入了新问题:数据倾斜。

如果某个活动极其火爆,所有请求都打向同一个 Redis 节点,该节点网卡可能被打满(通常单节点极限在 8W-10W QPS),成为系统瓶颈。

架构升级:库存分片方案

为了突破单节点性能极限,我们采用了 库存分片 (Stock Sharding) 策略。

设计思路:

将 10,000 个库存拆分为 10 份,分散到 stock:0 到 stock:9 这 10 个 Key 中,且去掉 Hash Tag,让它们自然散落在集群的不同节点上。

Java 侧逻辑:

  1. 随机路由 :请求进来,随机生成 index = Random(0-9)
  2. 扣减 :去操作对应的子 Key stock:{index}
  3. 兜底重试:如果该分片库存不足,脚本返回特定错误码。

⚠️ 避坑提醒:

这里有一个巨大的风险点。如果采用无限轮询重试(尝试下一个分片),在库存快耗尽时,客户端会发起指数级增长的请求,引发"惊群效应"。

建议策略:

  • 限制重试次数:最多重试 3 次,失败则直接返回"哎呀,太火爆了"。
  • Fail Fast:在超高并发下,宁可误杀(告诉用户没货了),也不要为了卖完最后几个库存把系统搞挂。

四、 稳定性保障:分布式锁的"红与黑"

Lua 虽然好用,但复杂的业务逻辑(如涉及第三方调用)依然需要分布式锁(Redisson)。

1. Watchdog (看门狗) 的真相

很多同学只知道它能续期,但不知道生效条件。

  • 机制:Redisson 基于 Netty 的 HashedWheelTimer(时间轮),默认每 10s 检查一次,将 30s 的锁续期。
  • 避坑 :如果你手动调用 lock.lock(10, TimeUnit.SECONDS) 指定了时间,看门狗会自动失效! 业务跑不完锁就会丢。

2. 拒绝 RedLock

关于"主从切换导致锁丢失"的问题,面试中常问 RedLock。

我的观点:在千万级流量下,不推荐使用 RedLock。

  • 理由:性能开销太大,且依赖 NTP 时间同步,并不绝对安全(Martin Kleppmann 与 Redis 作者有过著名辩论)。
  • 替代方案 :利用MySQL 唯一索引做最终兜底。Redis 偶尔丢锁,让请求穿透到 DB,DB 会利用 Unique Key 报错,保证数据绝不脏写。

五、 数据一致性:RabbitMQ 的"三角维稳"策略

扣减了 Redis 库存,怎么同步给 MySQL?

原则:Redis 是高并发的"令牌桶",MySQL 是最终的"账本"。我们采用 RabbitMQ 做异步削峰。但是,RabbitMQ 并不是把消息丢进去就万事大吉了。

在 10W QPS 下,任何网络抖动都可能导致消息丢失。我们构建了 "发送端 - 路由层 - 消费端" 的三角维稳策略。

1. 发送端可靠性:Confirm 与 Return

Spring Boot 中默认的 rabbitTemplate 是"发后即忘"的。为了知道消息到底有没有到达 Broker,我们需要开启两个开关:

  • Publisher-Confirm:Broker 收到消息后的回调(无论成功失败)。
  • Publisher-Return :消息到达 Exchange 但未路由到 Queue 时的回调(常用于配置错误排查)。

配置开启 (application.yml):

yaml 复制代码
spring:
  rabbitmq:
    publisher-confirm-type: correlated # 开启发送确认
    publisher-returns: true            # 开启路由失败回调
    template:
      mandatory: true                  # 消息路由失败时,返回给发送者而不是直接丢弃

实战代码封装:

我们需要自定义 RabbitTemplate,注入回调逻辑。注意:这里必须利用 CorrelationData 来绑定业务 ID,否则报错了你都不知道是哪一个订单错了。

java 复制代码
@PostConstruct
public void initRabbitTemplate() {
    // 1. 确认回调:消息是否到达 Exchange
    rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> {
        if (ack) {
            // log.info("消息发送成功: {}", correlationData.getId());
        } else {
            log.error("消息发送失败: {}, 原因: {}", correlationData.getId(), cause);
            // 🚨 核心补救:根据 correlationData.getId() (订单号) 进行 Redis 库存回滚或重试
            rollbackStock(correlationData.getId());
        }
    });
​
    // 2. 回退回调:消息是否到达 Queue (通常因路由键写错导致)
    rabbitTemplate.setReturnsCallback(returned -> {
        log.error("消息丢失: exchange={}, route={}, replyText={}",
                returned.getExchange(), returned.getRoutingKey(), returned.getReplyText());
        // 🚨 报警 + 人工介入,此类通常是代码配置错误
    });
}

2. 消费端可靠性:ACK 与 QoS

消费者端最大的坑在于默认的自动确认 (AutoACK) 。一旦消费者接收到消息(还没处理业务),RabbitMQ 就自动删除了。如果此时 JVM 宕机或数据库事务回滚,消息就永久丢失了(订单消失)。

黄金法则:

  1. 开启手动 ACK (AcknowledgeMode.MANUAL)
  2. 设置 QoS (Prefetch Count) :防止 RabbitMQ 一次性把几万条消息推给消费者,导致消费者内存溢出 (OOM)。

消费者实战代码:

java 复制代码
@RabbitListener(queues = "order.queue")
public void handleOrder(Message message, Channel channel) {
    long deliveryTag = message.getMessageProperties().getDeliveryTag();
    String msgBody = new String(message.getBody());
    OrderDTO order = JSON.parseObject(msgBody, OrderDTO.class);
​
    try {
        // 1. 幂等性校验 (防止网络抖动导致的重复消费)
        if (orderService.exists(order.getOrderId())) {
            // 直接确认,不再处理
            channel.basicAck(deliveryTag, false);
            return;
        }
​
        // 2. 执行核心业务:落库、生成订单
        orderService.createOrder(order);
​
        // 3. 业务执行成功,手动 ACK
        // multiple=false: 仅确认当前这一条
        channel.basicAck(deliveryTag, false);
​
    } catch (Exception e) {
        log.error("消费失败, orderId: {}", order.getOrderId(), e);
        
        // 4. 异常处理策略
        // requeue=true: 重新放回队列尾部(慎用!如果一直报错会死循环)
        // requeue=false: 丢弃或进入死信队列 (DLQ)
        try {
            // 建议:重试次数耗尽后,转发到死信队列,人工处理
            channel.basicNack(deliveryTag, false, false); 
        } catch (IOException ex) {
            ex.printStackTrace();
        }
    }
}

3. 避坑指南:死信队列 (DLQ) 的必要性

catch 代码块中,如果直接调用 basicNack(requeue=true),一旦遇到代码逻辑 bug(比如空指针),这条消息会无限循环:消费 -> 报错 -> 回队列 -> 消费 -> 报错...,瞬间把 CPU 打满,且阻塞后续正常消息。

架构建议:

一定要配置 Dead Letter Exchange (DLQ)。当消息重试 N 次依然失败,或者显式拒绝 (requeue=false) 时,自动投递到死信队列。开发人员编写一个专门的 Consumer 监听死信队列,进行报警和人工修复。


六、 终极兜底:对账脚本

即使做了上述所有工作,依然存在极端情况:Redis 扣了,JVM 瞬间宕机,MQ 没发出去。这就造成了"Redis 少了,DB 没单"的库存泄露

解法:T+N 分钟的对账脚本。

  • 逻辑:定期比对 Redis 剩余库存 与 DB 订单数。
  • 修复:如果发现差额,自动将 Redis 库存加回去(或者回收到回收站),确保"肉烂在锅里"。

七、 总结与思考

扛住 10W+ QPS,本质上是在做 Trade-off (权衡)

  1. Lua + Redis 的原子性,换取了数据库的安全。
  2. 分片策略 的复杂度,换取了集群的水平扩展能力。
  3. MQ 异步 + 对账 的延迟,换取了系统的整体吞吐量。

架构没有银弹,只有针对业务场景的最优解。希望这篇复盘能给你在应对高并发设计时带来一些灵感。

最后的提问:

大家在生产环境中,处理 RabbitMQ 消息积压(Lag)通常用什么方案?临时扩容消费者还是丢弃非核心消息?欢迎在评论区留言讨论。

相关推荐
b***9101 小时前
idea创建springBoot的五种方式
java·spring boot·intellij-idea
t***82111 小时前
MySQL的底层原理与架构
数据库·mysql·架构
BD_Marathon1 小时前
【IDEA】常用快捷键【上】
java·ide·intellij-idea
BD_Marathon1 小时前
【IDEA】工程与模块的管理
java·ide·intellij-idea
tgethe1 小时前
MybatisPlus基础部分详解(中篇)
java·spring boot·mybatisplus
core5121 小时前
【Java AI 新纪元】Spring AI 深度解析:让 Java 开发者无缝接入大模型
java·人工智能·spring·ai
Y***89081 小时前
Spring Boot的项目结构
java·spring boot·后端
好好研究1 小时前
MyBatis框架 - 注解形式
java·数据库·mysql·maven·mybatis
i***71951 小时前
Partition架构
架构