前言
面试中经常被问到:"你做过最高并发的系统是什么?当面对 10W+ QPS 的瞬时流量,普通的"查库 -> 判断 -> 扣减 -> 更新"逻辑会让数据库瞬间熔断。"扛住高并发"从来不是某一项技术的单打独斗,而是一场蓄谋已久的分层防御战。
本文将基于 Redis Lua + 库存分片 + RabbitMQ 的核心架构,深度拆解其中的设计细节与那些差点踩翻的"坑"。
一、 全局视角:流量漏斗架构
如果你直接问我:"Redis 怎么扛 10W QPS?",我会先制止你。因为在请求到达 Redis 之前,必须先经过层层削减。我们将架构设计为一个漏斗:
-
CDN/静态资源 :拦截 90% 的页面流量(图片、CSS、JS)。
-
本地缓存 (Caffeine) :拦截 60% 的读请求(活动配置、规则)。
- 架构思考:如何保证一致性?我们利用 MQ 广播机制(类似 Spring Cloud Bus),当后台修改配置时,通知所有节点失效本地缓存。
-
Redis 集群:处理核心的"写"请求(扣库存、发令牌)。
-
RabbitMQ:流量削峰,异步解耦。
-
MySQL 数据库:最终落单,保证账目兜底。
本文核心聚焦于最凶险的环节:Redis 高并发扣减与数据一致性。
二、 核心攻坚:Redis Lua 脚本与原子性
在秒杀场景下,传统的 Java 代码(即使使用 Redisson 分布式锁)存在两个致命问题:
- 性能损耗:获取锁、判断、扣减、释放锁,至少 4 次网络 RTT。
- 锁竞争:高并发下线程阻塞严重,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 侧逻辑:
- 随机路由 :请求进来,随机生成
index = Random(0-9)。 - 扣减 :去操作对应的子 Key
stock:{index}。 - 兜底重试:如果该分片库存不足,脚本返回特定错误码。
⚠️ 避坑提醒:
这里有一个巨大的风险点。如果采用无限轮询重试(尝试下一个分片),在库存快耗尽时,客户端会发起指数级增长的请求,引发"惊群效应"。
建议策略:
- 限制重试次数:最多重试 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 宕机或数据库事务回滚,消息就永久丢失了(订单消失)。
黄金法则:
- 开启手动 ACK (
AcknowledgeMode.MANUAL) 。 - 设置 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 (权衡) :
- 用 Lua + Redis 的原子性,换取了数据库的安全。
- 用 分片策略 的复杂度,换取了集群的水平扩展能力。
- 用 MQ 异步 + 对账 的延迟,换取了系统的整体吞吐量。
架构没有银弹,只有针对业务场景的最优解。希望这篇复盘能给你在应对高并发设计时带来一些灵感。
最后的提问:
大家在生产环境中,处理 RabbitMQ 消息积压(Lag)通常用什么方案?临时扩容消费者还是丢弃非核心消息?欢迎在评论区留言讨论。