实战复盘: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)通常用什么方案?临时扩容消费者还是丢弃非核心消息?欢迎在评论区留言讨论。

相关推荐
启山智软18 小时前
【中大企业选择源码部署商城系统】
java·spring·商城开发
我真的是大笨蛋18 小时前
深度解析InnoDB如何保障Buffer与磁盘数据一致性
java·数据库·sql·mysql·性能优化
怪兽源码19 小时前
基于SpringBoot的选课调查系统
java·spring boot·后端·选课调查系统
恒悦sunsite19 小时前
Redis之配置只读账号
java·redis·bootstrap
梦里小白龙19 小时前
java 通过Minio上传文件
java·开发语言
人道领域19 小时前
javaWeb从入门到进阶(SpringBoot事务管理及AOP)
java·数据库·mysql
sheji526119 小时前
JSP基于信息安全的读书网站79f9s--程序+源码+数据库+调试部署+开发环境
java·开发语言·数据库·算法
毕设源码-邱学长19 小时前
【开题答辩全过程】以 基于Java Web的电子商务网站的用户行为分析与个性化推荐系统为例,包含答辩的问题和答案
java·开发语言
Jing_jing_X19 小时前
CPU 架构:x86、x64、ARM 到底是什么?为什么程序不能通用?
arm开发·架构·cpu
摇滚侠20 小时前
Java项目教程《尚庭公寓》java项目从开发到部署,技术储备,MybatisPlus、MybatisX
java·开发语言