技术派面试拷打①:RabbitMQ 异步解耦与消息可靠性

技术派面试拷打①:RabbitMQ 异步解耦与消息可靠性

简历原文:「将点赞通知等事件通过 RabbitMQ 异步解耦,消费端手动 Ack 保障消息至少处理一次;针对消息可靠性设计了生产端 Confirm 与死信兜底方案。」

面试口径:“点赞通知用 RabbitMQ 做异步解耦:用户点赞立即返回,通知消息异步处理,核心链路和非核心链路解耦。消费端用 manual ack,业务处理完成后才 basicAck,保证 at-least-once;重复消费靠 notify 表唯一索引做幂等。生产端开启 Confirm 回调,Broker 持久化失败后 nack 触发重发;消费失败进入死信队列(DLQ)人工兜底。”


第一层:为什么需要消息队列?(架构设计题)

Q1:你们项目里 RabbitMQ 是必须的吗?不用行不行?

答: 不是必须的,但用了之后架构更清晰。

不用 MQ 的问题(同步通知):

1
2
3
4
5
6
用户点赞
→ 写 user_foot 表
→ 更新文章点赞数
→ 更新作者积分
→ 生成通知消息(notify 表)
→ 返回给用户

这一套下来,用户要等 所有操作都完成 才能看到”点赞成功”,如果通知生成很慢,用户就卡住了。

用了 MQ 之后(异步通知):

1
2
3
4
5
用户点赞
→ 写 user_foot 表
→ 返回"点赞成功"给用户(快!)
→ 发送消息到 RabbitMQ(很快,只是写内存)
→ 消费者异步处理:更新积分 + 生成通知

用户完全不用等通知生成,核心链路(点赞)和辅助链路(通知)解耦了

项目里的实际代码UserFootServiceImpl.java):

1
2
3
4
5
6
7
8
// 点赞消息走 RabbitMQ,其它走 Java 内置消息机制
if (notifyType.equals(NotifyTypeEnum.PRAISE) && rabbitmqService.enabled()) {
rabbitmqService.publishMsg(
CommonConstants.EXCHANGE_NAME_DIRECT,
CommonConstants.QUERE_KEY_PRAISE,
JsonUtil.toStr(foot)); // 发送消息到 MQ
}
// 不管 MQ 是否成功,主流程都已经完成,用户已经看到点赞成功

不用 MQ 会怎样? 如果通知生成要 200ms,用户点赞后就要等 200ms 才能继续操作。用 MQ 后,用户 5ms 内就能继续刷下一篇文章。


Q2:RabbitMQ、Kafka、RocketMQ 怎么选?(字节必考)

答: 三个的定位不一样,选型要看场景。

维度 RabbitMQ Kafka RocketMQ
定位 传统消息队列,功能丰富 分布式流平台,吞吐量极高 阿里开源,电商场景打磨
吞吐量 万级 ~ 十万级 QPS 百万级 QPS 十万级 ~ 百万级 QPS
消息可靠性 支持事务、手动 Ack,很可靠 靠副本机制,可能丢消息 支持事务消息、顺序消息
延迟 微秒 ~ 毫秒级(最低) 毫秒级 毫秒级
顺序消息 不支持 分区内有序 支持(电商订单场景)
死信队列 原生支持(DLX) 不支持(要自己实现) 支持
延迟消息 需装插件(rabbitmq-delayed-message-exchange) 不支持 原生支持(很方便)

本项目选 RabbitMQ 的理由:

  1. 消息量不大(论坛点赞/评论频率),不需要 Kafka 的百万级吞吐
  2. 需要消息可靠性(通知消息不能丢),RabbitMQ 的手动 Ack + Confirm 机制很成熟
  3. 需要死信队列兜底,RabbitMQ 原生支持,配置简单
  4. 运维简单,RabbitMQ Management UI 很好用,适合个人项目

面试回答模板:

“日志采集/大数据管道用 Kafka,电商订单/事务消息用 RocketMQ,业务解耦/通知类用 RabbitMQ。我们项目是论坛场景,消息量不大但要求可靠性,所以选了 RabbitMQ。”


第二层:生产端消息可靠性

Q3:你说生产端用了 Confirm 机制,具体是怎么工作的?

答: Confirm 机制是 RabbitMQ 的生产端确认机制,保证消息成功到达 Broker。

不用 Confirm 的问题:

1
2
生产者 → 发送消息 → RabbitMQ(没收到!)
↑ 生产者不知道消息丢了,也不重发

消息在网络传输中丢了,生产者完全不知道。

Confirm 机制的工作原理:

  1. 开启 Confirmchannel.confirmSelect()
  2. 发送消息:正常发送,RabbitMQ 收到后会异步回调
  3. ack 回调:RabbitMQ 成功持久化后,调用 ConfirmListenerhandleAck(告诉生产者:消息安全了)
  4. nack 回调:RabbitMQ 内部错误(比如磁盘满了),调用 handleNack(告诉生产者:消息丢了,重发吧)

项目里的实际代码RabbitmqServiceImpl.java):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 1. 开启 Confirm 机制
channel.confirmSelect();

// 2. 注册 Confirm 回调
channel.addConfirmListener(
// ack 回调:Broker 已持久化,消息安全
(deliveryTag, multiple) ->
log.info("消息已到达 Broker,deliveryTag={}, exchange={}", deliveryTag, exchange),
// nack 回调:Broker 内部错误,需要重发
(deliveryTag, multiple) ->
log.warn("消息被 Broker 拒绝(nack),deliveryTag={}, msg={}", deliveryTag, message)
);

// 3. 发送消息
channel.basicPublish(exchange, routingKey, null, message.getBytes());

// 4. 同步等待确认(最多等 3 秒)
channel.waitForConfirmsOrDie(3000);

waitForConfirmsOrDie(3000) 是什么意思?

  • 同步等待 Broker 的 Confirm 回调,最多等 3 秒
  • 如果 3 秒内没收到 ack → 抛异常,捕获后记录日志/重发
  • 如果收到 nack → 直接抛异常

Confirm 和事务(tx)的区别?

Confirm(推荐) 事务(tx)
性能 高(异步回调) 低(同步阻塞,性能降 250 倍)
可靠性 同样可靠 同样可靠
用法 confirmSelect() + 监听回调 txSelect() + txCommit()

面试必说: “Confirm 是异步确认,性能远高于事务消息,生产环境都用 Confirm。”


Q4:Confirm 机制是异步的,项目里为什么还要用 waitForConfirmsOrDie 同步等待?

答: 这是一个取舍问题,项目里用的是”同步等待 Confirm”,适合消息量不大的场景。

纯异步 Confirm 的问题:

1
2
3
4
5
6
7
// 纯异步:发送后立即返回,Confirm 回调在另一个线程执行
channel.addConfirmListener((deliveryTag, multiple) -> {
// 这里可能是 5 秒后才被调用(Broker 异步回调)
log.info("消息已确认");
});
channel.basicPublish(...);
// 发送完立即返回,不知道消息是否成功

如果消息量很大,纯异步更好(不阻塞发送线程)。但如果消息量不大,用 waitForConfirmsOrDie 同步等待更简单:

  • 发送 → 阻塞等待 Broker 确认 → 确认成功才继续
  • 如果超时或 nack → 立即知道,可以重发

更好的方案(生产环境):

1
2
3
4
5
6
7
8
9
10
11
12
13
// 批量发送 + 异步 Confirm + 失败重试队列
channel.addConfirmListener((deliveryTag, multiple) -> {
// ack:从"待确认集合"里删除
}, (deliveryTag, multiple) -> {
// nack:把消息重新放入发送队列
});

// 批量发送
for (int i = 0; i < 1000; i++) {
channel.basicPublish(...);
}
// 等待所有消息都被确认
channel.waitForConfirmsOrDie(5000);

面试回答:

“我们项目里消息量不大,用 waitForConfirmsOrDie 同步等待比较简单。如果消息量很大,应该用纯异步 Confirm + 失败重试队列,避免阻塞发送线程。”


Q5:如果 RabbitMQ 宕机了,生产端发送的消息会丢失吗?

答: 会丢失,除非你做了消息持久化

RabbitMQ 宕机消息丢失的三个阶段:

阶段一:消息在 JVM 内存里,还没发给 RabbitMQ

  • 解决:消息先存本地 DB(状态=待发送),发送成功再改状态

阶段二:消息已发给 RabbitMQ,但 RabbitMQ 还没持久化(在内存里)就宕机了

  • 解决:① 开启 Confirm 机制,确保 Broker 持久化后才算成功;② 队列和消息都设置持久化

阶段三:RabbitMQ 持久化了,但磁盘坏了

  • 解决:RabbitMQ 镜像队列(高可用),消息存多台机器

项目里的持久化配置RabbitmqServiceImpl.java):

1
2
3
4
5
6
7
8
9
10
// 1. Exchange 持久化:durable=true
channel.exchangeDeclare(exchange, exchangeType, true, false, null);
// ↑ 持久化

// 2. Queue 持久化:durable=true
channel.queueDeclare(queueName, true, false, false, args);
// ↑ 持久化

// 3. 消息持久化:MessageProperties.PERSISTENT_TEXT_PLAIN
// (项目里没显式设置,这是个可以优化的点)

面试加分: “我们项目里 Exchange 和 Queue 都设置了持久化,但消息本身的 deliveryMode 没显式设置,这是个可以优化的点,应该设置为 MessageProperties.PERSISTENT_TEXT_PLAIN(即 deliveryMode=2)。”


第三层:消费端手动 Ack 与消息可靠性

Q6:消费端手动 Ack 是怎么工作的?为什么不用自动 Ack?

答: 自动 Ack 是”发给消费者就立即确认”,如果消费者拿到消息后处理到一半宕机了,这条消息就永久丢失了。

自动 Ack 的问题:

1
2
3
4
RabbitMQ → 发送消息给消费者 → 立即 basicAck(消息从队列删除)

消费者拿到消息,但处理到一半宕机了
→ 这条消息永远丢了!

手动 Ack 的工作原理:

1
2
3
4
5
6
7
8
RabbitMQ → 发送消息给消费者 → 不确认(消息还在队列里,状态=Unacked)

消费者处理完成

basicAck() → RabbitMQ 才删除消息

如果消费者宕机了:Unacked 消息自动回到 Ready 状态,
被其他消费者重新消费(at-least-once

项目里的实际代码RabbitmqServiceImpl.java):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 1. 关闭自动 Ack(autoAck=false)
channel.basicConsume(queueName, false, consumer);
// ↑ 关键!false = 手动 Ack

// 2. 消费者处理完成后,才手动 Ack
Consumer consumer = new DefaultConsumer(channel) {
@Override
public void handleDelivery(...) {
String message = new String(body, "UTF-8");
// 处理业务逻辑:保存通知到 DB
notifyService.saveArticleNotify(...);

// 处理完成,才 Ack
channel.basicAck(envelope.getDeliveryTag(), false);
// ↑ multiple=false:只确认当前这条
}
};

basicAck 的第二个参数 multiple 是什么意思?

  • false:只确认当前这条消息(deliveryTag)
  • true:确认当前这条 + 所有比它 deliveryTag 小的未确认消息(批量确认,提升性能)

面试回答:

“自动 Ack 是发出去就确认,消费者拿到消息后宕机了消息就丢了。手动 Ack 是等业务处理完成后才确认,如果处理中途宕机,RabbitMQ 会把消息重新分发给其他消费者,保证 at-least-once。”


Q7:手动 Ack 保证了 at-least-once,那重复消费怎么办?你们项目里怎么处理幂等性?

答: at-least-once 必然导致重复消费,要解决幂等性问题。

重复消费的场景:

1
2
3
消息被消费者 A 拿到 → 处理完成,但网络抖动,basicAck() 超时
→ RabbitMQ 认为没确认,把消息重新入队
→ 消费者 B 又拿到同一条消息 → 重复处理!

幂等性设计:同样的消息,消费 1 次和消费 10 次,效果一样。

方案 1:数据库唯一索引(最常用)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 项目里的 notify 表,应该有这些字段:
CREATE TABLE notify (
id BIGINT PRIMARY KEY,
user_id BIGINT,
article_id BIGINT,
type INT, -- 点赞通知/评论通知/收藏通知
create_time DATETIME,
UNIQUE KEY uk_user_article_type (user_id, article_id, type)
-- 同一个人对同一篇文章的同一类通知,只能有一条
);

// 消费时:
try {
INSERT INTO notify(user_id, article_id, type) VALUES(..., ..., ...);
} catch (DuplicateKeyException e) {
// 唯一索引冲突 = 重复消费,直接忽略
log.warn("重复消费,忽略");
channel.basicAck(deliveryTag, false);
}

方案 2:Redis SetNX(分布式锁)

1
2
3
4
5
6
7
8
9
10
// 用消息的唯一 ID 做幂等
String msgId = envelope.getDeliveryTag() + "_" + queueName;
Boolean first = redisTemplate.opsForValue()
.setIfAbsent("msg:processed:" + msgId, "1", Duration.ofHours(24));
if (Boolean.FALSE.equals(first)) {
// 已经处理过,直接 Ack 跳过
channel.basicAck(deliveryTag, false);
return;
}
// 第一次处理,执行业务逻辑

项目里的幂等处理: 从代码看,项目用 notifyService.saveArticleNotify() 保存通知,应该在 notify 表有唯一索引,防止重复插入。

面试回答:

“我们项目里用 notify 表的唯一索引(user_id + article_id + type)做幂等,重复消费时数据库报 DuplicateKeyException,捕获后直接 Ack 跳过。也可以用 Redis SetNX 做幂等,但数据库唯一索引更简单可靠。”


Q8:如果消费者处理消息时一直卡住(比如远程调用超时),会怎么样?

答: RabbitMQ 的 消费超时机制 会触发,但如果没设置,消息会一直卡在 Unacked 状态。

问题场景:

1
2
3
4
消费者拿到消息 → 调用远程 HTTP 接口(超时 30 秒)→ 一直等...
→ 这条消息一直是 Unacked 状态
→ RabbitMQ 认为消费者还活着,不会重新分发这条消息
→ 如果所有消费者都卡住了 → 队列消息堆积,无法消费

解决方案:

1. 设置 basicQos(限制每个消费者同时处理的最大消息数)

1
2
3
// 每个消费者最多同时处理 10 条消息,超过 10 条时不再推送新消息
// 防止消费者堆积太多 Unacked 消息,导致内存溢出
channel.basicQos(10);

2. 设置消费超时(Spring AMQP 的 @RabbitListener

1
2
3
4
5
6
@RabbitListener(queues = "queue.praise", 
containerFactory = "rabbitListenerContainerFactory")
public void consume(Message message) {
// Spring 的默认超时是 30 分钟
// 可以自定义 ContainerFactory 设置超时
}

3. 业务代码里设置超时

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
public void handleDelivery(...) {
try {
// 设置 HTTP 超时
httpClient.setConnectTimeout(3000);
httpClient.setReadTimeout(5000);
// 处理业务...
channel.basicAck(...);
} catch (TimeoutException e) {
// 超时了,拒绝消息并不重新入队(进入死信队列)
channel.basicNack(deliveryTag, false, false);
}
}

面试回答:

“如果消费者一直卡住,消息会一直处于 Unacked 状态,RabbitMQ 不会重新分发。解决方法是:① basicQos 限制每个消费者同时处理的最大消息数;② 业务代码里设置远程调用超时;③ 超时时用 basicNack(requeue=false) 让消息进入死信队列,不让它一直卡住。”


第四层:死信队列(DLQ)兜底方案

Q9:死信队列(Dead Letter Queue)是什么?你们项目里怎么用的?

答: 死信队列是装”无法正常消费的消息”的队列,相当于”垃圾回收站”。

消息变成”死信”的三种情况:

  1. 消费者拒绝消息basicReject / basicNack)且 requeue=false(不重新入队)
  2. 消息 TTL 过期(设置了消息的存活时间,超时没人消费)
  3. 队列达到最大长度(队列满了,最早进来的消息被挤掉)

项目里的死信队列配置RabbitmqServiceImpl.java):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 1. 声明死信交换机(DLX = Dead Letter Exchange)
channel.exchangeDeclare(CommonConstants.EXCHANGE_NAME_DLX,
BuiltinExchangeType.DIRECT, true, false, null);

// 2. 声明死信队列(DLQ = Dead Letter Queue)
// 持久化,消息存磁盘,重启不丢
channel.queueDeclare(CommonConstants.DLX_QUEUE_NAME_PRAISE,
true, false, false, null);

// 3. 死信队列绑定到死信交换机
channel.queueBind(CommonConstants.DLX_QUEUE_NAME_PRAISE,
CommonConstants.EXCHANGE_NAME_DLX,
CommonConstants.DLX_QUEUE_KEY_PRAISE);

// 4. 主队列绑定死信交换机(关键配置!)
Map<String, Object> args = new HashMap<>();
args.put("x-dead-letter-exchange", CommonConstants.EXCHANGE_NAME_DLX);
args.put("x-dead-letter-routing-key", CommonConstants.DLX_QUEUE_KEY_PRAISE);
args.put("x-message-ttl", 30000); // 消息 30 秒过期后进入死信队列

channel.queueDeclare(queueName, true, false, false, args);

死信队列的工作流程:

1
2
3
4
5
6
主队列 queue.praise
→ 消费者处理失败(basicNack, requeue=false
→ 消息被发送到死信交换机 EXCHANGE_NAME_DLX
→ 根据 routing-key 路由到死信队列 DLX_QUEUE_NAME_PRAISE
→ 人工排查:为什么这条消息处理失败?
→ 修复 Bug 后,从死信队列重新消费

面试回答:

“死信队列是消息处理失败后的兜底方案。我们项目里,消费者处理失败时 basicNack(requeue=false),消息自动进入死信队列。后续人工排查失败原因(可能是代码 Bug 或数据问题),修复后从死信队列重新消费。相当于给消息一个’复活’的机会。”


Q10:死信队列里的消息应该怎么处理?能不能自动重试?

答: 死信队列里的消息通常不能自动重试(因为上次消费失败了,重试大概率还会失败),需要人工介入

处理死信队列的三种策略:

策略 1:人工排查 + 手动重发(最常用)

1
2
3
4
5
6
7
8
9
10
// 写一个管理接口,运营人员可以查看死信队列
GET /admin/dlq/messages?queue=dlx.queue.praise

// 人工判断:这条消息是不是因为代码 Bug 导致的失败?
// 如果是 Bug → 修复代码 → 手动重发到主队列
POST /admin/dlq/republish
{
"messageId": "xxx",
"targetQueue": "queue.praise"
}

策略 2:延迟自动重试(谨慎使用)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 死信队列的消费者:等待一段时间后,重新发送到主队列
@RabbitListener(queues = "dlx.queue.praise")
public void consumeDlq(Message message) {
// 解析消息
String body = new String(message.getBody());
int retryCount = message.getMessageProperties().getHeader("x-retry-count");

if (retryCount >= 3) {
// 重试 3 次还失败 → 记录到数据库,人工处理
log.error("消息重试 3 次仍失败,需人工处理: {}", body);
return;
}

// 等待 1 分钟后再重试(给系统恢复的时间)
Thread.sleep(60000);

// 重试次数 +1,重新发送到主队列
message.getMessageProperties().setHeader("x-retry-count", retryCount + 1);
rabbitTemplate.send("exchange.direct", "queue.praise", message);
}

策略 3:报警 + 日志记录

1
2
3
4
5
6
7
8
9
// 死信队列有消息进入时,立即发报警(钉钉/邮件)
@RabbitListener(queues = "dlx.queue.praise")
public void consumeDlq(Message message) {
// 发钉钉报警
dingTalkService.send("⚠️ 死信队列有新消息进入,请排查!\n消息内容:" + new String(message.getBody()));

// 记录到数据库
dlqMessageService.save(message);
}

面试回答:

“死信队列里的消息一般不能自动重试,因为上次已经失败了,重试大概率还会失败。我们的处理方式是:① 死信队列有消息进入时发报警(钉钉/邮件);② 人工排查失败原因(代码 Bug 还是数据问题);③ 修复后手动重发到主队列。如果非要自动重试,要限制重试次数(比如 3 次),超过后转人工处理。”


第五层:RabbitMQ 高可用与运维

Q11:RabbitMQ 怎么保证高可用?如果单台 RabbitMQ 宕机了怎么办?

答: 单台 RabbitMQ 是单点故障,要用镜像队列(Mirrored Queue)Quorum Queue 实现高可用。

镜像队列(RabbitMQ 3.8 之前的方式):

1
2
3
4
5
6
7
3 台 RabbitMQ 组成集群:
- Node A(主队列):处理所有读写请求
- Node B(镜像):主队列的副本,只读
- Node C(镜像):主队列的副本,只读

如果 Node A 宕机 → 从 Node BNode C 中选举新的主队列
→ 消息不丢失(因为镜像里有完整数据)

Quorum Queue(RabbitMQ 3.8+ 推荐):

1
2
3
基于 Raft 一致性协议,数据强一致
- 写消息:大多数节点确认才算成功(比如 3 个节点,至少 2 个确认)
- 宕机:自动选举新 Leader,数据不丢

项目里的配置: 从代码看,项目用的是单台 RabbitMQ(switchFlag=false 默认关闭),生产环境应该开启镜像队列或 Quorum Queue。

面试回答:

“单台 RabbitMQ 是单点故障,生产环境要用镜像队列或 Quorum Queue。我们项目是个人学习项目,用的是单台 RabbitMQ;如果上生产,会配置镜像队列(所有节点的队列数据一致,主节点宕机后从节点自动接管)。新版本的 RabbitMQ 推荐用 Quorum Queue,基于 Raft 协议,数据强一致。”


Q12:RabbitMQ 的消息积压(堆积)了怎么办?

答: 消息积压说明消费者处理速度 < 生产者发送速度,要扩容消费者或优化消费逻辑。

导致积压的原因:

  1. 消费者处理能力不足(比如消费者只有 1 个,但消息量是 1 万/秒)
  2. 消费者挂了(所有消费者都宕机了,消息只进不出)
  3. 消费逻辑太慢(比如每条消息要处理 5 秒,但每秒来 100 条)

解决方案:

1. 扩容消费者(最直接)

1
2
// 启动多个消费者实例(水平扩容)
// RabbitMQ 会自动把消息分发给所有消费者(轮询)

2. 优化消费逻辑(提升单消费者处理能力)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 慢:逐条处理
for (Message msg : messages) {
handle(msg); // 每条处理 100ms
}

// 快:批量处理
List<Message> batch = new ArrayList<>(100);
for (Message msg : messages) {
batch.add(msg);
if (batch.size() >= 100) {
handleBatch(batch); // 批量处理 100 条,只需 500ms
batch.clear();
}
}

3. 紧急预案:新建一个”加速队列”

1
2
3
正常情况:生产者 → 主队列(queue.praise)→ 消费者(慢)
紧急情况:生产者 → 加速队列(queue.praise.fast)→ 更多消费者(20 个)
→ 消费完后,把积压消息也导到加速队列

面试回答:

“消息积压说明消费者速度跟不上生产者。解决方案:① 扩容消费者(启动多个消费者实例,RabbitMQ 会自动负载均衡);② 优化消费逻辑(比如批量处理代替逐条处理);③ 紧急情况可以新建一个’加速队列’,分配更多消费者去处理积压消息。”


第六层:项目代码细节与开放题

Q13:你们项目里 RabbitMQ 的 switchFlagfalse,默认是关闭的,为什么?

答: 因为本地开发不一定安装 RabbitMQ,关闭时走 Spring 内部的事件总线ApplicationEventPublisher),功能等价,但不需要依赖外部中间件。

代码里的实现UserFootServiceImpl.java):

1
2
3
4
5
6
7
if (notifyType.equals(NotifyTypeEnum.PRAISE) && rabbitmqService.enabled()) {
// switchFlag=true:走 RabbitMQ
rabbitmqService.publishMsg(...);
} else {
// switchFlag=false:走 Spring 内部事件总线
applicationEventPublisher.publishEvent(new NotifyEvent(...));
}

Spring 事件总线的原理:

1
2
3
4
5
6
7
8
// 发布事件
applicationEventPublisher.publishEvent(new NotifyEvent(...));

// 监听事件(同步执行,等价于直接调用方法)
@EventListener
public void handleNotifyEvent(NotifyEvent event) {
notifyService.saveArticleNotify(...);
}

为什么本地开发不用 RabbitMQ?

  1. 安装 RabbitMQ 麻烦(要装 Erlang,要配置)
  2. 本地调试时,直接用 Spring 事件总线更简单(不用启动 RabbitMQ)
  3. 部署到服务器时,再把 switchFlag 改为 true

面试回答:

“我们项目 switchFlag 默认是 false,因为本地开发不一定安装 RabbitMQ。关闭时走 Spring 内部的 ApplicationEventPublisher(事件总线),功能和 MQ 等价,但不需要依赖外部中间件。部署到测试/生产环境时,再把 switchFlag 改为 true,走真正的 RabbitMQ。”


Q14:如果让你设计一个”点赞通知系统”,日活 100 万,你会怎么设计?

答: 这是一道系统设计题,要综合考虑性能、可靠性、成本。

V1(最简单):RabbitMQ 直接异步处理

1
2
用户点赞 → 写 DB → 发消息到 RabbitMQ
→ 消费者异步生成通知

问题:如果日活 100 万,平均每人点赞 2 次 → 每天 200 万条通知消息,RabbitMQ 能扛住,但通知表会很大。

V2(引入批量处理):消费者每积攒 100 条通知,再批量 INSERT

1
2
3
4
5
6
7
8
9
10
11
// 消费者里用内存队列积攒消息
List<NotifyDO> batch = new ArrayList<>(100);

public void handleDelivery(...) {
batch.add(parse(message));
if (batch.size() >= 100) {
notifyService.batchInsert(batch); // 批量 INSERT
batch.clear();
}
channel.basicAck(...);
}

问题:如果消费者宕机,内存里的通知就丢了。

V3(最终方案):RabbitMQ + 批量 + 定时任务兜底

1
2
3
4
5
6
7
用户点赞 → 写 DB(点赞记录)
→ 发消息到 RabbitMQ(通知任务)

消费者:积攒 100 条或每隔 5 秒,批量 INSERTnotify
如果消费者宕机 → 消息重新入队,其他消费者接手

兜底:定时任务每天凌晨 2 点扫描"点赞了但没生成通知"的记录,补生成

面试回答模板:

“日活 100 万的点赞通知系统,我会这样设计:① 用户点赞立即写 DB 并返回,同时发消息到 RabbitMQ;② 消费者积攒 100 条或每隔 5 秒批量 INSERT 通知表(减少 DB 压力);③ 消费者宕机后,RabbitMQ 的手动 Ack 保证消息不丢,其他消费者接手;④ 定时任务兜底,每天凌晨扫描’有点赞记录但没通知’的数据,补生成。这样就兼顾了性能(批量 INSERT)和可靠性(RabbitMQ 手动 Ack + 定时任务兜底)。”


Q15(附加):RabbitMQ 的消息是有序的吗?怎么保证顺序消费?

答: RabbitMQ 不保证全局有序,但可以保证同一个队列里的消息按 FIFO 顺序被同一个消费者消费

问题场景:

1
2
3
4
5
6
生产者发送:msg1(订单创建)→ msg2(订单支付)→ msg3(订单完成)

RabbitMQ 队列:msg1 → msg2 → msg3(队列里是有序的)

消费者 A 拿到 msg1 和 msg3(msg2 被消费者 B 拿走了!)
→ 消费顺序乱了!msg3 比 msg2 先处理

保证顺序消费的方案:

方案 1:同一个订单的消息,始终路由到同一个队列(推荐)

1
2
3
4
// 根据订单 ID 的 hash 值,路由到固定的队列
int queueIndex = orderId.hashCode() % QUEUE_COUNT;
String queueName = "queue.order." + queueIndex;
channel.basicPublish(exchange, queueName, ...);

这样,同一个订单的所有消息都会到同一个队列,被同一个消费者顺序处理。

方案 2:用 RabbitMQ 的单个消费者 + 单线程处理**

1
2
3
// 不扩容消费者,只有 1 个消费者
// 这样队列里的消息一定是顺序处理的
// 缺点:性能差,不适合高并发

面试回答:

“RabbitMQ 不保证全局有序。要保证顺序消费,可以用’相同业务 ID 路由到同一个队列’的方案,比如根据订单 ID 的 hash 值路由,这样同一个订单的消息始终被同一个消费者顺序处理。或者简单粗暴地只用 1 个消费者,但性能差。我们项目里点赞通知不需要保证顺序,所以没有做这个优化。”


总结:简历上这句话的面试回答模板

面试官:”你的简历上写了’RabbitMQ 异步解耦,手动 Ack 保证消息至少处理一次,Confirm 和死信队列兜底’,你能详细讲一下吗?”

回答模板(背下来!):

“好的。我们项目里点赞操作后需要生成通知消息,如果同步执行,用户要等通知生成完才能继续操作,体验不好。所以用 RabbitMQ 做异步解耦:用户点赞后立即返回,通知消息异步处理。

生产端可靠性:我们开启了 Confirm 机制,channel.confirmSelect() 后,RabbitMQ 成功持久化消息才会异步回调 handleAck,如果 Broker 内部错误会回调 handleNack,我们在 nack 回调里记录日志并准备重发。同时用 waitForConfirmsOrDie(3000) 同步等待确认,3 秒超时算发送失败。

消费端可靠性:我们关闭了自动 Ack(autoAck=false),等业务逻辑处理完成后才调用 basicAck。这样如果消费者处理中途宕机,RabbitMQ 会把消息重新分发给其他消费者,保证 at-least-once。重复消费问题,我们用 notify 表的唯一索引(user_id + article_id + type)做幂等,重复插入时捕获 DuplicateKeyException 直接忽略。

死信队列兜底:消费者处理失败时,我们用 basicNack(requeue=false) 让消息进入死信队列(DLQ)。后续人工排查失败原因,修复后手动重发到主队列。相当于给消息一个’复活’的机会,不会丢失。

整体来说,这套方案保证了点赞通知的高性能(异步解耦)和高可靠(Confirm + 手动 Ack + 死信队列兜底)。”


下一篇预告: 策略模式 + 工厂模式实现 LLM 调用链路统一抽象,以及 WebSocket 推送 Stream 流实现毫秒级响应 🔜


技术派面试拷打①:RabbitMQ 异步解耦与消息可靠性
https://whyalwaysme.lol/2026/06/08/2026-06-08-devlink-rabbitmq-qa/
作者
Cassiur
发布于
2026年6月8日
许可协议