消息队列 MQ 完全指南

从零手撸一个 MQ,逐步遭遇所有经典难题,最终理解 Kafka 背后每一个设计决策

从零手撸 持久化 消息顺序 Kafka 原理 高可用 分区 Partition 消费者组
为什么需要消息队列?
从一个最简单的业务场景出发,感受没有 MQ 时的痛苦

🛒 场景:电商下单

用户点击"下单",系统需要同步完成:扣库存 → 生成订单 → 发送短信 → 推送积分 → 更新用户行为日志 → 触发风控...

用户下单
扣库存
生成订单
发短信
推积分
风控检测
返回成功
⚠️

同步链路总耗时 = 每个步骤耗时相加。短信服务超时 2s?整个下单就要等 2s!任何一环故障,整条链路崩溃。

🐢
性能瓶颈
调用链越来越长,响应时间线性增加。非核心业务(短信、积分)拖慢了核心业务(下单)。
高严重性
💥
耦合爆炸
订单服务需要知道短信、积分、风控的接口地址和协议。新增一个下游 = 修改订单服务代码。
高严重性
🌊
流量突刺
双十一订单量 10 倍暴增,所有下游系统同时被打爆。数据库扛不住瞬时写入压力。
中严重性
🔗
级联故障
积分服务宕机 → 下单失败。下游故障不应该影响上游核心链路的可用性。
高严重性

✅ 引入 MQ 之后

订单服务只负责核心步骤(扣库存+生成订单),其余非核心操作异步化,发一条消息就完事。

用户下单
核心步骤
发消息到MQ
立即返回
MQ 异步分发给:短信服务、积分服务、风控服务、行为日志...互不干扰,各自消费

三大核心价值:① 解耦(生产者不关心谁在消费)② 削峰填谷(MQ 作为缓冲)③ 异步(非核心逻辑不阻塞主流程)

📊 MQ 解决的核心问题汇总

问题没有MQ有了MQ
系统耦合A调B,B调C,牵一发动全身只依赖MQ,无需知道下游
响应时间所有步骤串行,越来越慢核心链路极短,异步处理剩余
流量突刺瞬时高峰直接打爆下游MQ 蓄洪,下游按自己节奏消费
故障隔离下游故障影响上游下游挂了,消息在 MQ 中等待重试
扩展性新增下游要改上游代码新服务订阅 Topic 即可,零侵入
从零手撸一个 MQ
一步步构建,感受每个设计决策背后的理由

第一版:一个队列 + 两个线程

最朴素的思路:一个 BlockingQueue 放消息,一个线程生产,一个线程消费。

// 极简版 MQ(Java伪代码) class SimpleMQ { private BlockingQueue<String> queue = new LinkedBlockingQueue(1000); // 生产者调用 public void send(String msg) { queue.put(msg); // 满了会阻塞 } // 消费者调用 public String consume() { return queue.take(); // 空了会阻塞 } }
💡

这就是一个"内存 MQ"。但立刻暴露第一个问题:进程一重启,消息全丢了

🟦 v0.1 — 内存队列

一个 BlockingQueue,生产者投递,消费者取走。简单但不可靠,重启即丢失。

🟩 v0.2 — 加持久化

消息写入磁盘文件,启动时重放。但随机写磁盘极慢,需要改成 顺序追加写(Append-Only Log)

🟪 v0.3 — 多消费者

多个消费者同时消费,需要记录每个消费者消费到哪了(Offset),否则同一条消息会被消费多次。

🟨 v0.4 — 分布式部署

单机存不下,要分布式。消息需要分散到多台机器(分区 Partition),但顺序问题随之而来。

🟥 v0.5 — 高可用

某台机器宕机,那台机器上的消息怎么办?需要副本(Replica)机制,数据多份存储。

🟫 v1.0 — 你发明了 Kafka

以上所有问题的解决方案组合起来,就是 Kafka 的架构设计。每一个决策都有它存在的理由。

⚡ 持久化的关键:顺序写 vs 随机写

为什么要用 Append-Only Log?磁盘随机写很慢,顺序追加写极快,接近内存速度。

❌ 随机写
每次写入需要磁头寻道 → 旋转等待 → 写入。HDD 机械硬盘约 100~200 IOPS,极慢。
✅ 顺序追加写
始终在文件末尾写,磁头不需要移动。HDD 可达 600MB/s,SSD 更快,接近内存。
🚀

Kafka 官方实测:顺序磁盘写比随机内存读还快。这就是为什么 Kafka 不用内存缓存,直接写磁盘也能跑出 100万+/s 的吞吐量。

消息可靠性:消息丢失问题
三个地方都可能丢消息:生产者 → Broker → 消费者

📍 消息丢失的三大战场

Producer
生产者
① 发送失败未重试
Broker
MQ服务器
② 内存未持久化就宕机
Consumer
消费者
③ 提前Ack后处理失败

① 生产者侧丢失

网络抖动、Broker 压力大、短暂超时都会导致发送失败。如果不重试,消息就丢了。

// 错误做法:发完不管 producer.send(message); // fire and forget,丢了没人知道 // 正确做法:同步确认 / 异步回调 RecordMetadata meta = producer.send(message).get(); // 同步等待Broker确认 // Kafka 配置:等待所有副本写入才算成功 props.put("acks", "all"); // all = -1,最安全 props.put("retries", 3); // 失败自动重试3次 props.put("retry.backoff.ms", 1000); // 每次重试间隔1秒
acks 配置含义丢消息风险性能
acks=0不等Broker确认,发了就算最高最快
acks=1Leader 写入就确认中等(Leader宕机会丢)
acks=all所有副本都写入才确认最低最慢

② Broker 侧丢失

消息只写了内存(PageCache),还没 flush 到磁盘,服务器断电,消息丢失。

⚠️

Kafka 的默认刷盘策略是异步刷盘(靠 OS 调度),追求极致安全要开同步刷盘,但代价是性能下降。实际生产更推荐靠副本机制(多副本)来保可靠性,而非强制同步刷盘。

# Kafka broker 刷盘配置(kafka server.properties) # 每写入 N 条消息强制刷盘(0 = 依赖 OS 调度) log.flush.interval.messages=10000 # 每隔 N 毫秒强制刷盘 log.flush.interval.ms=1000 # 副本数(推荐生产环境设3) default.replication.factor=3 # 最小ISR副本数(至少2个副本写入才算成功) min.insync.replicas=2

③ 消费者侧丢失(最隐蔽!)

消费者取到消息,先提交 Ack/Offset,再处理业务逻辑。处理到一半崩溃了 → 消息已确认但业务没完成 → 消息丢失

// ❌ 错误顺序:先Ack后处理 msg = consumer.poll(); consumer.commitOffset(msg.offset); // 先提交 processBusiness(msg); // 再处理 — 如果这里崩了,消息丢了! // ✅ 正确顺序:先处理后Ack msg = consumer.poll(); processBusiness(msg); // 先处理完 consumer.commitOffset(msg.offset); // 再确认 — 崩了会重新消费(可能重复,但不会丢)
💡

先处理后 Ack 可能导致重复消费(处理成功但 Ack 前崩溃)。这是一个经典的"消息丢失 vs 重复消费"的权衡,大多数系统选择容忍重复、保证不丢(At Least Once)。

消息顺序性
为什么顺序难以保证?Kafka 如何在有限范围内保证顺序?

🤔 为什么消息顺序会乱?

看似简单,实则暗藏多个打乱顺序的节点:

  • 1

    生产者多线程并发发送

    线程A发消息1,线程B发消息2,但消息2可能先到 Broker(网络延迟不同)。

  • 2

    消息分散到多个分区

    Kafka 默认按 Round Robin 分发消息,消息1在分区0,消息2在分区1,消费时无法保证跨分区顺序。

  • 3

    消费者多线程并行处理

    消费者取到消息后用线程池处理,消息1的处理可能比消息2更慢,导致消息2先完成。

  • 4

    重试机制打乱顺序

    消息1发送失败触发重试,消息2已经发出去了,消息1重试成功后排在了消息2后面。

✅ Kafka 的顺序保证粒度:分区内有序

Kafka 只保证同一个分区内的消息是有序的。跨分区不保证。

Topic: order-events(3个分区)
Partition 0
msg1
msg3
msg7
msg9
Partition 1
msg2
msg5
msg8
Partition 2
msg4
msg6
✅ 每个分区内部:msg1 → msg3 → msg7 → msg9,严格有序
❌ 跨分区:msg1 和 msg2 的顺序无法保证

🔑 实际方案:同一业务 Key 路由到同一分区

电商订单场景:同一个 orderId 的所有消息,永远路由到同一个分区 → 该订单的消息天然有序。

// 发送时指定 Key(同一 orderId 会路由到同一分区) producer.send(new ProducerRecord( "order-events", // Topic orderId, // Key —— Kafka用Key的hash决定分区 orderEventJson // Value )); // Kafka 分区选择逻辑(默认) int partition = Math.abs(key.hashCode()) % numPartitions;
⚠️

注意:如果某个 orderId 消息特别多(热点 Key),会导致该分区数据量远超其他分区,引发分区不均衡(数据倾斜)问题,需要特殊处理。

🔧 生产者重试导致乱序的解决方案

// 开启幂等性生产者(Kafka 0.11+) props.put("enable.idempotence", "true"); // 限制单个连接的在途请求数 // max.in.flight.requests.per.connection=1 绝对保序,但降低吞吐 // 配合幂等性,可以设置为5(Kafka自动处理乱序+去重) props.put("max.in.flight.requests.per.connection", "5");
💡

开启幂等性后,Kafka 给每条消息分配全局唯一 ProducerID + SequenceNumber,Broker 端去重并重排序,重试不再导致乱序。

重复消费与幂等性
消息至少一次 vs 恰好一次:这是 MQ 领域最经典的权衡

📦 三种消息投递语义

语义英文含义丢消息重复消费适用场景
至多一次 At Most Once 发完不管,Broker 不确认 可能 不会 日志、监控(允许丢)
至少一次 At Least Once 失败重试,保证投递成功 不会 可能 大多数业务(推荐)
恰好一次 Exactly Once 既不丢也不重 不会 不会 金融交易(代价高)

😱 重复消费长什么样?

以下是最常见的重复消费场景:

  • 1

    消费者处理完,提交 Offset 前崩溃

    重启后从上次的 Offset 重新消费,之前已处理的消息被再次消费。

  • 2

    消费者心跳超时,Kafka 发起 Rebalance

    消费者 GC 暂停太久 → Kafka 认为它挂了 → 把分区分配给别人 → 同一批消息被两个消费者都消费了。

  • 3

    生产者重试导致消息重复发送

    消息发送成功但网络超时没收到 ACK → 生产者以为失败 → 重发 → Broker 收到两条相同消息。

✅ 核心解法:消费者侧幂等性

与其在 MQ 层面保证恰好一次(极难),不如让业务消费逻辑变成幂等的——多次执行和一次执行结果一样。

🔑
唯一消息 ID 去重
每条消息携带全局唯一 ID,消费前先查 Redis/DB 是否已处理过,处理后记录。
推荐
🗄️
数据库唯一约束
INSERT IGNORE 或 ON DUPLICATE KEY UPDATE。重复消息触发冲突时直接忽略,不影响数据正确性。
简单高效
📊
乐观锁版本号
UPDATE table SET status=B, version=version+1 WHERE id=? AND version=old_version。不满足条件则忽略。
适合状态机
📝
状态机校验
业务有状态流转,先校验当前状态是否符合预期,不符合则跳过,防止重复驱动状态流转。
有状态场景
// 方案1:Redis 去重(推荐,高性能) public void consume(Message msg) { String key = "msg:processed:" + msg.getId(); // SET NX(Not Exist)— 只有不存在才设置,原子操作 boolean isFirst = redis.setNX(key, "1", 24, TimeUnit.HOURS); if (!isFirst) { log.warn("重复消息,忽略: {}", msg.getId()); return; } processBusiness(msg); // 真正执行业务 }

🏆 Kafka 的 Exactly Once(事务机制)

Kafka 0.11+ 引入事务 Producer,可以实现真正的 Exactly Once(跨分区原子写入)。

// 开启事务(性能有代价,谨慎使用) props.put("enable.idempotence", "true"); props.put("transactional.id", "my-producer-001"); producer.initTransactions(); try { producer.beginTransaction(); producer.send(record1); producer.send(record2); producer.commitTransaction(); // 原子提交 } catch (Exception e) { producer.abortTransaction(); // 原子回滚 }
Kafka 核心架构全景
搞懂 Broker、Topic、Partition、Producer、Consumer Group 之间的关系

🏗️ Kafka 整体架构图

Producer A
Producer B
Producer C
↓↓↓
KAFKA CLUSTER
Broker 1 (Leader)
Partition 0
Topic-A
Partition 2
Topic-A
Broker 2
Partition 1
Topic-A
Partition 0
副本
Broker 3
Partition 1
副本
Partition 2
副本
↓↓↓
Consumer Group A
C-1
C-2
Consumer Group B
C-3
📝
Topic(主题)
消息的逻辑分类,类似数据库的"表"。生产者向 Topic 发消息,消费者从 Topic 订阅消息。一个 Topic 可有多个分区。
🗂️
Partition(分区)
Topic 的物理分片。每个分区是一个有序的消息日志,分散在不同 Broker 上,实现水平扩展和并发消费。
🏢
Broker(节点)
Kafka 集群中的一个服务器节点,负责存储分区数据、处理读写请求。一个集群由多个 Broker 组成。
📍
Offset(偏移量)
分区内每条消息的唯一递增编号(从0开始)。消费者通过提交 Offset 来记录消费进度,重启后从该位置继续。
👥
Consumer Group
一组消费者的集合,共同消费一个 Topic。同组内每个分区只能被一个消费者消费。不同组之间互相独立,都能收到全量消息。
👑
Leader / Follower
每个分区有一个 Leader 副本(处理读写)和多个 Follower 副本(同步数据备份)。Leader 挂了,Follower 自动选举接班。
分区设计与磁盘存储
Kafka 为什么这么快?磁盘上的数据到底长什么样?

📁 分区的磁盘存储结构

每个分区对应磁盘上的一个目录,目录下是若干 Segment(段)文件。Kafka 不在一个文件写到底,而是按大小/时间切割。

# 磁盘目录结构(Topic: orders,Partition 0) /kafka-logs/orders-0/ ├── 00000000000000000000.log # 消息数据文件(追加写) ├── 00000000000000000000.index # 稀疏索引(Offset → 文件位置) ├── 00000000000000000000.timeindex # 时间索引(时间戳 → Offset) ├── 00000000000005000000.log # 第2个Segment(从Offset 5000000开始) ├── 00000000000005000000.index └── ... # 文件名 = 该Segment起始Offset,方便二分查找定位

⚡ 三大性能法宝

📋
① 顺序追加写
消息只在文件末尾追加,不修改已有数据。磁头不用跳转,顺序写速度远超随机写,即使 HDD 也能跑出极高吞吐。
最核心
🗺️
② 稀疏索引
不是每条消息都建索引,而是每隔 N 条建一条。查找时先定位到最近的索引点,再顺序扫描。索引文件极小,可全量加载进内存。
O(log n) 查找
🔄
③ 零拷贝 sendfile
消费者拉取消息时,数据从磁盘 → 内核缓冲区 → 网卡,不经过用户态,省去了两次内存拷贝和两次上下文切换。
网络传输极快
💡

零拷贝(Zero-Copy)是 Kafka 消费吞吐高的关键。传统读文件:磁盘→内核缓存→用户态→Socket缓冲→网卡(4次拷贝)。零拷贝:磁盘→内核缓存→网卡(2次拷贝,Linux sendfile 系统调用)。

🗑️ 消息保留策略(数据不会永久存)

Kafka 不像传统 MQ 消费后就删消息,而是按策略定期清理。

策略配置说明
按时间保留 log.retention.hours=168 默认保留7天,超期的 Segment 文件直接删除
按大小保留 log.retention.bytes=1073741824 超过指定大小(如1GB)则删除最早的 Segment
日志压缩 cleanup.policy=compact 只保留每个 Key 的最新消息,适合状态快照场景

📏 分区数量怎么定?

分区数决定了并发消费上限。实践经验:

# 推荐公式(仅供参考) 目标消费吞吐 = 单个消费者吞吐 × 分区数 # 例如:目标100MB/s,单消费者50MB/s → 需要2个分区 # 分区数不宜过多的原因: # 1. 每个分区对应一个文件句柄,占用 FD # 2. ZooKeeper/KRaft 存储每个分区元数据,过多增加元数据压力 # 3. Leader 选举时间与分区数成正比 # 4. 分区数不能减少,只能增加(增加后Key路由会变化!) # 经验值:单 Topic 分区数建议 6~20,单集群分区总数 < 200,000
⚠️

分区数设好之后不能减少,只能增加。增加分区后,同一个 Key 的消息可能路由到不同分区,破坏顺序性!生产环境要提前规划好。

消费者组与 Rebalance
消费者组是 Kafka 实现水平扩展的核心机制,但 Rebalance 是它的阿喀琉斯之踵

👥 消费者组的核心规则

Topic(4个分区) + 消费者组(3个消费者)
分区分配:
Partition 0
Consumer 1
Partition 1
Consumer 1
Partition 2
Consumer 2
Partition 3
Consumer 3
关键规则:
📌

同组内,一个分区只能被一个消费者消费(防止重复)

📌

不同组之间互相独立,都能消费全量消息(广播模式)

📌

消费者数 > 分区数时,多余的消费者空闲,浪费资源

🔀 Rebalance:分区重新分配

当以下事件发生时,Kafka 会触发 Rebalance,重新分配分区给消费者:

新消费者加入
有新成员加入消费者组,需要重新均衡分配分区。
💀
消费者宕机/退出
消费者崩溃或主动退出,其持有的分区要被重新分配给其他成员。
⏱️
心跳超时
消费者长时间 GC(Stop The World)或处理慢,心跳超时被踢出组,触发 Rebalance。
📊
分区数变化
Topic 增加了分区,需要重新分配。
⚠️

Rebalance 期间,整个消费者组停止消费(Stop The World),直到重新分配完成。频繁 Rebalance 会导致消费延迟飙升!这是 Kafka 早期版本(<2.4)的重大性能痛点。

# 减少 Rebalance 的关键配置 # 增大心跳间隔和超时时间,避免因GC触发Rebalance session.timeout.ms=30000 # 消费者超时判定:30秒 heartbeat.interval.ms=10000 # 心跳间隔:10秒 max.poll.interval.ms=300000 # 单次poll最长处理时间:5分钟 # Kafka 2.4+ 静态成员(Static Membership) # 给每个消费者分配固定 group.instance.id # 短暂下线后重连不触发 Rebalance,直接恢复原分区 group.instance.id=consumer-instance-1

📍 Offset 管理

Kafka 的 Offset 存储经历了两个阶段:

时期存储位置问题
Kafka < 0.9 ZooKeeper(zk的 /consumers 节点) ZK 不适合频繁写,高并发下 ZK 压力巨大
Kafka >= 0.9 内部 Topic:__consumer_offsets Kafka 自己存自己管,吞吐高,运维简单
// Kafka 消费者 Offset 提交方式 // 1. 自动提交(简单但可能丢消息/重复) props.put("enable.auto.commit", "true"); props.put("auto.commit.interval.ms", "5000"); // 每5秒自动提交 // 2. 手动同步提交(处理完再提交,更安全) props.put("enable.auto.commit", "false"); consumer.poll(Duration.ofMillis(1000)).forEach(record -> { process(record); consumer.commitSync(); // 处理完同步提交 }); // 3. 手动异步提交(性能更好,但失败不重试) consumer.commitAsync((offsets, e) -> { if (e != null) log.error("Commit failed", e); });
高可用:副本机制与 Leader 选举
机器总会宕机,Kafka 如何让数据不丢、服务不停?

🔄 ISR:核心高可用概念

ISR(In-Sync Replicas) = 与 Leader 保持同步的副本集合。

Partition 0 副本状态
Broker 1
👑 Leader
Offset: 10000
✅ ISR 成员
← 同步 →
Broker 2
Follower
Offset: 9998
✅ ISR 成员(滞后<阈值)
Broker 3
Follower
Offset: 8500
❌ 已踢出 ISR(滞后太多)
ISR 内的副本才有资格参与 Leader 选举。Broker 3 落后太多,被踢出 ISR,即使 Leader 宕机也不会选它,避免数据丢失。

👑 Leader 选举流程

  • 1

    Leader 宕机,Controller 检测到

    Kafka Controller(集群中选举出来的一个特殊 Broker)通过 ZooKeeper 或 KRaft 监听到 Leader 下线。

  • 2

    从 ISR 中选取新 Leader

    Controller 从当前 ISR 列表中选第一个(或按一定策略)作为新 Leader,保证新 Leader 的数据与旧 Leader 基本一致。

  • 3

    广播新的元数据

    Controller 更新 ZooKeeper/KRaft 中的分区元数据,通知所有 Broker 和 Producer/Consumer 新的 Leader 位置。

  • 4

    Producer/Consumer 自动切换

    Producer 和 Consumer 重新获取元数据,自动将请求路由到新 Leader,整个过程对业务透明。

🆕 KRaft:告别 ZooKeeper

Kafka 2.8+ 引入 KRaft 模式,Kafka 3.x 中 ZooKeeper 已被废弃。

传统 ZooKeeper 模式
• 需要单独部署维护 ZK 集群
• 元数据全量加载,启动慢
• 分区数受限于 ZK 性能
• Controller 故障恢复慢
KRaft 模式(新)
• Kafka 自己用 Raft 管理元数据
• 支持数百万分区
• 启动时间大幅缩短
• 架构更简单,运维成本低
主流 MQ 横向对比
Kafka、RabbitMQ、RocketMQ、Pulsar — 选哪个?什么场景用什么?

📊 全面对比表

维度 Kafka RabbitMQ RocketMQ Pulsar
诞生背景 LinkedIn 日志收集 金融系统消息 阿里电商 Yahoo 云原生
吞吐量 极高 100万+/s 中等 万级/s 高 十万级/s 极高
延迟 毫秒级(非最低) 微秒级(最低) 毫秒级 毫秒级
消息模型 Pull(消费者主动拉) Push(推送给消费者) Push + Pull Push + Pull
消息顺序 分区内有序 队列内有序 分区内有序(支持全局) 分区内有序
延迟消息 不原生支持 插件支持 原生支持 原生支持
事务消息 支持(0.11+) 不支持 支持(强事务) 支持
死信队列 需手动实现 原生支持 原生支持 原生支持
消息回溯 支持(按时间/Offset) 消费即删除 支持 支持
存储 磁盘持久化 内存为主(可持久化) 磁盘持久化 BookKeeper(计算存储分离)
生态 极丰富(大数据) 丰富(企业集成) 丰富(阿里系) 新兴,快速增长
适用场景 日志、大数据流、事件溯源 任务队列、RPC、复杂路由 电商、金融事务消息 云原生、多租户

🎯 选型决策树

  • Q1

    你的场景是大数据/日志收集/事件流处理吗?

    是 → 选 Kafka。与 Flink/Spark/Hadoop 生态无缝集成,吞吐量无敌。

  • Q2

    你需要复杂路由规则(按类型分发、优先级队列)?

    是 → 选 RabbitMQ。Exchange + Binding 路由灵活,AMQP 协议标准化。

  • Q3

    你的场景是电商(延迟消息+事务消息+顺序消息都要)?

    是 → 选 RocketMQ。阿里双十一实战打磨,电商场景完美契合。

  • Q4

    你是云原生架构,需要多租户/存算分离?

    是 → 考虑 Pulsar。计算与存储完全分离,Kubernetes 友好。

🧠 终极总结:所有 MQ 都要解决的共同问题

💾
持久化
消息怎么存?存多久?内存 or 磁盘?顺序写日志是最佳实践。
全部 MQ 必须解决
🔢
顺序性
全局有序 or 局部有序?代价是并发度下降。按 Key 路由到固定分区是主流方案。
按需选择
📬
投递语义
At Most Once / At Least Once / Exactly Once。越严格代价越高,大多数选 At Least Once + 消费幂等。
全部 MQ 必须解决
🔄
高可用
副本机制,Leader 选举,故障自动切换。副本数越多越安全,但写入延迟越高。
全部 MQ 必须解决
📈
水平扩展
分区/队列分散到多节点,消费者水平扩容。分区是 Kafka 扩展的核心单元。
全部 MQ 必须解决
积压处理
消费者处理不过来怎么办?告警、临时扩容消费者、消息降级/过期策略。
生产必须考虑
💬 一句话总结
消息队列的所有设计,本质上都是在以下几个维度做权衡(Trade-off)
可靠性(不丢消息)vs 性能(高吞吐低延迟)vs 一致性(顺序/Exactly Once)vs 运维复杂度
没有完美答案,只有符合你场景的最优解。理解每个设计决策背后的 Trade-off,比记住结论更重要。