diff --git a/notes/pics/mq-observer-vs-pubsub.png b/notes/pics/mq-observer-vs-pubsub.png
new file mode 100644
index 00000000..68b811a0
Binary files /dev/null and b/notes/pics/mq-observer-vs-pubsub.png differ
diff --git a/notes/pics/mq-p2p.png b/notes/pics/mq-p2p.png
new file mode 100644
index 00000000..0e50b9c3
Binary files /dev/null and b/notes/pics/mq-p2p.png differ
diff --git a/notes/pics/mq-pubsub.png b/notes/pics/mq-pubsub.png
new file mode 100644
index 00000000..bb208927
Binary files /dev/null and b/notes/pics/mq-pubsub.png differ
diff --git a/notes/消息队列.md b/notes/消息队列.md
index a2b2122a..a8cbf167 100644
--- a/notes/消息队列.md
+++ b/notes/消息队列.md
@@ -22,6 +22,7 @@
* [存储](#存储)
* [为什么吞吐量高](#为什么吞吐量高)
* [副本与可靠性](#副本与可靠性)
+ * [附录:实测](#附录实测)
* [参考资料](#参考资料)
@@ -32,13 +33,13 @@
消息生产者向消息队列中发送了一个消息之后,只能被一个消费者消费一次。
-
+
### 发布/订阅
消息生产者向频道发送一个消息之后,多个消费者可以从该频道订阅到这条消息并消费。
-
+
Kafka、RocketMQ 等通过消费者组把两种模型结合起来:一条消息会发送给每个订阅它的消费者组,但在一个消费者组内只会被一个消费者消费。
@@ -46,10 +47,10 @@ Kafka、RocketMQ 等通过消费者组把两种模型结合起来:一条消息
发布与订阅模式和观察者模式有以下不同:
-- 观察者模式中,观察者和主题都知道对方的存在;而在发布与订阅模式中,生产者与消费者不知道对方的存在,它们之间通过频道进行通信。
+- 观察者模式中,观察者和主题都知道对方的存在;而在发布与订阅模式中,生产者与消费者不知道对方的存在,它们之间通过频道进行通信;
- 观察者模式是同步的,当事件触发时,主题会调用观察者的方法,然后等待方法返回;而发布与订阅模式是异步的,生产者向频道发送一个消息之后,就不需要关心消费者何时去订阅这个消息,可以立即返回。
-
+
## 二、使用场景
@@ -85,7 +86,7 @@ Kafka、RocketMQ 等通过消费者组把两种模型结合起来:一条消息
实现方法:在本地数据库建一张消息表,将消息数据与业务数据保存在同一数据库实例里,这样就可以利用本地数据库的事务机制。事务提交成功后,将消息表中的消息转移到消息队列中,若转移消息成功则删除消息表中的数据,否则继续重传。
-消息队列在持久化消息之后才向发送端返回确认,发送端没有收到确认就进行重传。
+消息队列把消息可靠地保存之后才向发送端返回确认,发送端没有收到确认就进行重传。这里的“保存”不一定是刷盘,例如 Kafka 中是写入了足够多的副本,见下文的[副本与可靠性](#副本与可靠性)。
### 消息队列的可靠性
@@ -97,7 +98,7 @@ Kafka、RocketMQ 等通过消费者组把两种模型结合起来:一条消息
接收端在处理完成之后才向消息队列确认,处理过程中崩溃的话消息会被重新投递,因此一条消息可能被重复消费,需要做到:
-- 保证接收端处理消息的业务逻辑具有幂等性:只要具有幂等性,那么消费多少次消息,最后处理的结果都是一样的。
+- 保证接收端处理消息的业务逻辑具有幂等性:只要具有幂等性,那么消费多少次消息,最后处理的结果都是一样的;
- 保证消息具有唯一编号,并使用一张日志表来记录已经消费的消息编号。
## 四、顺序性
@@ -165,7 +166,7 @@ Kafka 中的消息按主题(Topic)分类,每个主题分成多个分区(
### 存储
-每个分区在磁盘上是一个目录,日志按大小切分成多个段(Segment),文件名是段中第一条消息的偏移量。本机把段的大小设为 1 MB(默认 1 GB),向一个只有一个分区的主题写入 3 万条 100 字节的消息后,目录中的文件和大小如下(省略了 .timeindex 等文件):
+每个分区在磁盘上是一个目录,日志按大小切分成多个段(Segment),文件名是段中第一条消息的偏移量。例如把段的大小设为 1 MB(默认 1 GB),向一个只有一个分区的主题写入 3 万条 100 字节的消息后,目录中的文件和大小如下(Kafka 4.4 实测,省略了 .timeindex 等文件):
```text
00000000000000000000.log 1041728
@@ -192,9 +193,9 @@ Kafka 中的消息按主题(Topic)分类,每个主题分成多个分区(
顺序写和页缓存:消息只追加到日志的末尾,是顺序写,比随机写快得多。写入时只写到操作系统的页缓存,由操作系统决定何时刷盘,消费者读取刚写入不久的数据时也通常直接命中页缓存。可靠性不依靠每条消息都刷盘,而是依靠多个副本。
-批量:生产者把发往同一个分区的消息攒成批次再发送,批次在网络传输、写入日志和副本复制时都作为一个整体处理,压缩也以批次为单位。在本机 3 个节点的集群上用 kafka-producer-perf-test.sh 发送 2 万条 100 字节的消息(acks=all),各运行 3 次:默认配置每秒约 5.7 万~6.8 万条,平均延迟 21~54 ms;设置 batch.size=0 关闭批量后,每秒只有约 6500~6800 条,发送的请求大量排队,平均延迟约 1.4 秒。
+批量:生产者把发往同一个分区的消息攒成批次再发送,批次在网络传输、写入日志和副本复制时都作为一个整体处理,压缩也以批次为单位。例如在 3 个节点的集群上发送 2 万条 100 字节的消息(acks=all),默认配置每秒约 5.7 万~6.8 万条,平均延迟 21~54 ms;设置 batch.size=0(linger.ms=0)关闭批量后,每秒只有约 6500~6800 条,发送的请求大量排队,平均延迟约 1.4 秒(Kafka 4.4 实测,见[附录:实测](#附录实测))。
-零拷贝:broker 把日志发给消费者时使用 sendfile 系统调用,数据不需要先读到用户空间再写回内核:
+零拷贝:broker 把日志发给消费者时使用 sendfile 系统调用,数据不需要先读到用户空间再写回内核(启用 SSL 时数据要在用户空间加密,不能使用 sendfile):
@@ -204,7 +205,7 @@ Kafka 中的消息按主题(Topic)分类,每个主题分成多个分区(
每个分区有多个副本,分布在不同的 broker 上,其中一个是 Leader,负责处理读写,其余的 Follower 不断从 Leader 拉取数据。与 Leader 保持同步的副本集合称为 ISR(In-Sync Replicas),Follower 超过 replica.lag.time.max.ms(默认 30 秒)没有追上 Leader 就会被移出 ISR。Kafka 4.0 起完全移除了 ZooKeeper,Leader 选举等元数据由内置的 KRaft 控制器管理,它基于 Raft 协议实现。
-本机创建 3 个分区、3 个副本的主题,然后用 kill -9 停掉 broker 3:
+例如创建 3 个分区、3 个副本的主题,然后用 kill -9 停掉 broker 3(3 个节点的 KRaft 集群实测):
```text
停止前
@@ -229,11 +230,45 @@ acks=all 要配合 min.insync.replicas 使用,否则 ISR 只剩 Leader 一个
生产者没有收到确认就会重试,这可能让同一条消息写入两次。Kafka 3.0 起默认开启幂等生产者:broker 给每个生产者分配一个 ID,生产者给发往每个分区的消息编上序号,broker 发现序号重复就丢弃。前面查看 .log 文件时,每个批次都带有 producerId: 1001 和 baseSequence 字段。幂等只保证单个分区内不重复;要让写入多个分区的消息同时成功或失败,需要使用事务,消费者设置 isolation.level=read_committed 后只读取已提交事务中的消息。
-消费者处理完消息后提交偏移量,下次从提交的位置继续读取。默认每 5 秒自动提交一次,已经处理但还没提交时崩溃,重启后就会重复消费。本机实验中,消费者读到 3 条扣款消息后被 kill -9,这时组的偏移量仍然为空;它在 session.timeout.ms(默认 45 秒)之后被移出消费者组,重新启动的消费者又收到了这 3 条消息。因此接收端必须做到幂等,方法见 [接收端的可靠性](#接收端的可靠性)。反过来,如果先提交偏移量再处理,处理失败的消息就丢失了。
+消费者处理完消息后提交偏移量,下次从提交的位置继续读取。默认每 5 秒自动提交一次,已经处理但还没提交时崩溃,重启后就会重复消费。例如消费者读到 3 条扣款消息后被 kill -9,这时组的偏移量仍然为空;它在 session.timeout.ms(默认 45 秒)之后被移出消费者组,重新启动的消费者又收到了这 3 条消息(见[附录:实测](#附录实测))。因此接收端必须做到幂等,方法见 [接收端的可靠性](#接收端的可靠性)。反过来,如果先提交偏移量再处理,处理失败的消息就丢失了。
+
+## 附录:实测
+
+环境为 Apple M1 Pro、macOS,Kafka 4.4.0 的 3 个节点的 KRaft 集群运行在同一台机器上(端口 9091~9093,命令中的 $B 为这三个地址)。
+
+批量对吞吐量的影响:主题有 3 个分区、3 个副本,每种配置运行 3 次:
+
+```text
+$ kafka-topics.sh --bootstrap-server $B --create --topic perf --partitions 3 --replication-factor 3
+$ kafka-producer-perf-test.sh --topic perf --num-records 20000 --record-size 100 --throughput -1 \
+ --command-property bootstrap.servers=$B acks=all
+20000 records sent, 64724.919094 records/sec (6.17 MB/sec), 35.14 ms avg latency, 186.00 ms max latency
+20000 records sent, 56980.056980 records/sec (5.43 MB/sec), 53.59 ms avg latency, 184.00 ms max latency
+20000 records sent, 68493.150685 records/sec (6.53 MB/sec), 21.34 ms avg latency, 190.00 ms max latency
+
+$ ...(同上) acks=all batch.size=0 linger.ms=0
+20000 records sent, 6459.948320 records/sec (0.62 MB/sec), 1446.62 ms avg latency, 2773.00 ms max latency
+20000 records sent, 6704.659739 records/sec (0.64 MB/sec), 1397.86 ms avg latency, 2674.00 ms max latency
+20000 records sent, 6795.786612 records/sec (0.65 MB/sec), 1410.16 ms avg latency, 2648.00 ms max latency
+```
+
+重复消费:消费者组 billing 使用默认的自动提交,读到 3 条消息后立即 kill -9,45 秒后再启动一个同组的消费者:
+
+```text
+第一个消费者:
+扣款 order:1024
+扣款 order:1025
+扣款 order:1026
+(kill -9)
+重新启动的消费者:
+扣款 order:1024
+扣款 order:1025
+扣款 order:1026
+Processed a total of 3 messages
+```
## 参考资料
-- [Observer vs Pub-Sub](http://developers-club.com/posts/270339/)
- [Designing Data-Intensive Applications 中文版](https://github.com/vonng/ddia)
- [消息队列中点对点与发布订阅区别](https://web.archive.org/web/20250522024511/https://blog.csdn.net/lizhitao/article/details/47723105)
- [定时/延时消息 | RocketMQ](https://rocketmq.apache.org/zh/docs/featureBehavior/02delaymessage/)