diff --git a/notes/消息队列.md b/notes/消息队列.md index a8cbf167..0dc4bdbf 100644 --- a/notes/消息队列.md +++ b/notes/消息队列.md @@ -23,6 +23,10 @@ * [为什么吞吐量高](#为什么吞吐量高) * [副本与可靠性](#副本与可靠性) * [附录:实测](#附录实测) + * [A1 批量对吞吐量的影响](#a1-批量对吞吐量的影响) + * [A2 重复消费](#a2-重复消费) + * [A3 消费积压](#a3-消费积压) + * [A4 Redis Stream 的确认](#a4-redis-stream-的确认) * [参考资料](#参考资料) @@ -78,7 +82,17 @@ Kafka、RocketMQ 等通过消费者组把两种模型结合起来:一条消息 ## 三、可靠性 -一条消息要经过发送、存储、消费三个环节,每个环节都可能丢失消息。 +一条消息要经过发送、存储、消费三个环节,每个环节都可能丢失消息。三种常用的消息队列在这三个环节上的做法: + +| 环节 | Kafka | RabbitMQ | RocketMQ(4.x) | +| --- | --- | --- | --- | +| 发送 | acks=all,失败自动重试 | 开启发布确认(Publisher Confirm),收到 ack 才算成功,并设置 mandatory | 同步发送,返回 SEND_OK 才算成功 | +| 存储 | 多副本,配合 min.insync.replicas,见 [副本与可靠性](#副本与可靠性) | 持久化的队列加持久化的消息,或者使用多副本的仲裁队列(Quorum Queue) | 同步刷盘(SYNC_FLUSH)加同步复制(SYNC_MASTER),默认都是异步的 | +| 消费 | 处理完再提交偏移量 | 关闭自动确认,处理完再发送 ack | 处理完再返回 CONSUME_SUCCESS,失败返回 RECONSUME_LATER 稍后重试 | + +需要注意的是,RabbitMQ 对无法路由到任何队列的消息同样返回 ack,只开启确认而不设置 mandatory 时,发错路由键的消息会被直接丢弃;设置了 mandatory,这类消息会先退回给生产者。 + +即使这些配置都打开,消息仍可能因为程序缺陷、误操作等原因丢失或者处理失败,关键业务还要定期核对上下游的数据(对账),发现缺失时补发。 ### 发送端的可靠性 @@ -101,17 +115,53 @@ Kafka、RocketMQ 等通过消费者组把两种模型结合起来:一条消息 - 保证接收端处理消息的业务逻辑具有幂等性:只要具有幂等性,那么消费多少次消息,最后处理的结果都是一样的; - 保证消息具有唯一编号,并使用一张日志表来记录已经消费的消息编号。 +例如积分服务消费“订单 1024 支付成功”的消息,在同一个本地事务中先写去重表、再加积分。重复的消息插入去重表时违反主键约束,回滚后直接确认这条消息: + +```sql +BEGIN; +INSERT INTO consumed_msg (msg_key) VALUES ('order:1024:paid'); -- 重复时报错 1062 +UPDATE points SET balance = balance + 100 WHERE user_id = 1024; +COMMIT; +``` + +去重的键要用业务上的唯一标识,例如订单号加事件类型,而不是消息队列生成的消息 ID。RocketMQ 文档指出,同一条消息被生产者重发后可能有两个不同的消息 ID。 + +去重记录也可以放在 Redis 中,但如果用 SETNX 标记之后、处理完成之前消费者崩溃,重新投递的消息会被当作已经处理而跳过,这条消息就丢了。因此 Redis 中要区分“处理中”和“已完成”两种状态,“处理中”设置过期时间,过期后允许重新处理。通用的幂等方法见 [分布式中的幂等](分布式.md#九幂等)。 + ## 四、顺序性 消息队列通常只保证单个队列(Kafka 中的分区)内的消息有序,多个队列之间没有顺序保证。并且在点对点模式下,一个消费者处理失败的消息会被重新投递给另一个消费者,顺序也会被打乱。 要保证同一业务的消息有序,需要让发送端把这些消息按业务 Key(例如订单号)路由到同一个队列,并且接收端对同一个队列只用一个线程顺序消费。 +RabbitMQ 中一个队列内的消息是先进先出的,但一个队列有多个消费者时,消息被拒绝后重新入队会打乱顺序。需要顺序时按业务 Key 把消息路由到多个队列,每个队列只有一个消费者;或者开启单活跃消费者(Single Active Consumer),同一时刻只有一个消费者接收消息,它断开后才由另一个消费者接替。Kafka 中这两件事由分区和消费者组完成:同一个 key 进入同一个分区,一个分区只分配给组内的一个消费者。 + ## 五、消息堆积 -消费速度长期低于生产速度时,消息会在队列中堆积。 +消费速度长期低于生产速度时,消息会在队列中堆积。堆积的程度用积压量(Lag)衡量,Kafka 中每个分区的积压量等于日志末尾的偏移量减去消费者组已提交的偏移量。例如积分服务只消费了 3000 条就停止,用 kafka-consumer-groups.sh 查看(Kafka 4.4 实测,省略了后三列,见[附录 A3](#a3-消费积压)): -解决方法是增加消费者数量来提高消费能力,但是消费者数量超过队列(分区)数量之后就不再有效,需要同时增加队列数量。 +```text +GROUP TOPIC PARTITION CURRENT-OFFSET LOG-END-OFFSET LAG +points points-events 0 0 9237 9237 +points points-events 1 0 9577 9577 +points points-events 2 3000 11186 8186 +``` + +排查时先看积压是生产突增造成的,还是消费变慢造成的: + +- 生产突增:例如大促、批量任务,生产速率的监控会明显上升,峰值过后通常能自然消化; +- 消费变慢:最常见的原因是消费逻辑依赖的数据库或外部接口变慢,其次是某些消息反复处理失败、不断重试,或者处理超时导致消费者被踢出、频繁重平衡; +- 只有个别分区积压:通常是 key 分布不均,大量消息集中到了同一个分区。 + +处理方法: + +- 先解决消费变慢的原因,例如修复下游、把反复失败的消息转入死信队列; +- 增加消费者数量,但消费者数量超过分区(队列)数量之后不再有效。上面的主题有 3 个分区,组内启动 4 个消费者后,其中一个分到 0 个分区; +- 提高单个消费者的处理能力:批量写库,或者在消费者内部用线程池并行处理,同一个 key 的消息交给同一个线程以保持顺序; +- 积压量很大、分区数又不够时,临时新建一个分区更多的主题,原来的消费者只把消息原样转发过去,再用更多的消费者消费新主题; +- 不重要的消息可以降级,例如先跳过,之后再补偿。 + +需要注意的是,Kafka 按保留时间删除旧数据,不管消息有没有被消费,积压超过 retention.ms(默认 7 天)的消息会被删除;RocketMQ 默认保留 72 小时。积压的时间接近保留时间时,要先把消息转存出来。 ## 六、延迟消息与死信队列 @@ -152,6 +202,26 @@ JDK 自带的 DelayQueue 也能实现延迟,但数据只在单个进程的内 ## 七、Kafka +Kafka 把消息保存为按分区追加的日志,消息被消费后不会删除,这是它与 RabbitMQ 最大的区别。三种常用的消息队列对比如下: + +| | Kafka | RabbitMQ | RocketMQ | +| --- | --- | --- | --- | +| 存储 | 分区日志,按保留时间删除,与是否被消费无关 | 队列,消息被确认后删除 | 所有主题的消息顺序写入同一个 CommitLog,每个队列另有消费索引,按保留时间删除 | +| 消费 | 消费者拉取,提交偏移量,可以把偏移量调回去重新消费 | Broker 主动推送给消费者,用 prefetch 限制未确认的消息数 | 消费者按队列拉取(Push 模式用长轮询实现),可以按时间重置消费位置 | +| 路由 | 生产者按 key 选择分区 | 交换机按路由键把消息投递到一个或多个队列,规则灵活 | 主题加 Tag 过滤 | +| 业务功能 | 本身不支持延迟消息和死信 | 死信交换机、TTL、优先级队列,延迟消息需要插件 | 延迟消息、事务消息、消费重试和死信队列 | +| 适用场景 | 日志、埋点、数据同步、流处理等吞吐量大的场景 | 消息量不大、路由规则复杂的业务消息 | 订单、支付等需要事务消息、延迟消息和重试的业务消息 | + +RabbitMQ 也提供了只追加的 Stream 类型,消息确认后不删除,可以重复读取。 + +Redis 也常被用作轻量的消息队列,但可靠性不如专门的消息队列: + +- Pub/Sub 不保存消息,发布时没有订阅者或者订阅者断线,消息就丢了,实测没有订阅者时 PUBLISH 返回 0; +- List 用 LPUSH 和 BRPOP 实现队列,消息取出后就从 List 中删除,消费者处理时崩溃就丢了,也不支持多个消费者组; +- Stream(Redis 5.0 起)支持消费者组和确认:XREADGROUP 读出的消息进入待确认列表,XACK 之后才移除,消费者崩溃时其它消费者可以用 XAUTOCLAIM 接管它没有确认的消息(见[附录 A4](#a4-redis-stream-的确认))。 + +即使使用 Stream,数据也保存在内存中,持久化和主从复制都是异步的,宕机时可能丢失最近写入的消息,能保存的消息量也受内存限制。消息量不大、能接受少量丢失时可以用 Redis,否则用专门的消息队列。 + ### 分区与消费者组 Kafka 中的消息按主题(Topic)分类,每个主题分成多个分区(Partition)。分区是一个只能在末尾追加的日志,每条消息在分区内有一个递增的偏移量(Offset),消费者记录自己读到了哪个偏移量。 @@ -193,12 +263,16 @@ Kafka 中的消息按主题(Topic)分类,每个主题分成多个分区( 顺序写和页缓存:消息只追加到日志的末尾,是顺序写,比随机写快得多。写入时只写到操作系统的页缓存,由操作系统决定何时刷盘,消费者读取刚写入不久的数据时也通常直接命中页缓存。可靠性不依靠每条消息都刷盘,而是依靠多个副本。 -批量:生产者把发往同一个分区的消息攒成批次再发送,批次在网络传输、写入日志和副本复制时都作为一个整体处理,压缩也以批次为单位。例如在 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 实测,见[附录:实测](#附录实测))。 +批量:生产者把发往同一个分区的消息攒成批次再发送,批次在网络传输、写入日志和副本复制时都作为一个整体处理,压缩也以批次为单位。例如在 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 实测,见[附录 A1](#a1-批量对吞吐量的影响))。 -零拷贝:broker 把日志发给消费者时使用 sendfile 系统调用,数据不需要先读到用户空间再写回内核(启用 SSL 时数据要在用户空间加密,不能使用 sendfile): +零拷贝:零拷贝(Zero-copy)指的是传输数据时,CPU 不在内核空间和用户空间之间来回拷贝数据。用 read() 和 write() 把文件发到网络时,数据从页缓存拷贝到应用的缓冲区,再拷贝到 Socket 缓冲区,共 4 次拷贝、2 次系统调用。broker 把日志发给消费者时使用 sendfile 系统调用,数据只在内核中传递(启用 SSL 时数据要在用户空间加密,不能使用 sendfile):