<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>消息队列 on Ther 的博客</title>
        <link>https://blog.ther.cool/categories/%E6%B6%88%E6%81%AF%E9%98%9F%E5%88%97/</link>
        <description>Recent content in 消息队列 on Ther 的博客</description>
        <generator>Hugo -- gohugo.io</generator>
        <language>en-us</language>
        <lastBuildDate>Sat, 29 Jul 2023 22:43:16 +0800</lastBuildDate><atom:link href="https://blog.ther.cool/categories/%E6%B6%88%E6%81%AF%E9%98%9F%E5%88%97/index.xml" rel="self" type="application/rss+xml" /><item>
        <title>Kafka高吞吐设计</title>
        <link>https://blog.ther.cool/posts/kafka%E9%AB%98%E5%90%9E%E5%90%90%E8%AE%BE%E8%AE%A1/</link>
        <pubDate>Sat, 29 Jul 2023 22:43:16 +0800</pubDate>
        
        <guid>https://blog.ther.cool/posts/kafka%E9%AB%98%E5%90%9E%E5%90%90%E8%AE%BE%E8%AE%A1/</guid>
        <description>&lt;p&gt;Kafka 采用了一系列的技术优化来保证高吞吐，这其中包括批量处理、压缩、零拷贝、磁盘顺序读写、页面缓存技术、Reactor 网络架构设计模式等。接下来主要从生产端、服务端和消费端三个方面来剖析和讨论。同时讨论一些高性能的设计方法，以及操作系统底层的工作模式，这些都有利于高效地设计出一个高吞吐的系统。&lt;/p&gt;
&lt;h2 id=&#34;生产端&#34;&gt;生产端&lt;/h2&gt;
&lt;p&gt;Kafka 高吞吐量的特性在生产端这里是怎么体现的呢？要想回答这个问题，首先得了解下生产端是如何发送消息的，这属于铺垫知识。下图详细描述了生产端发送消息的全部流程。&lt;/p&gt;
&lt;p&gt;&lt;img src=&#34;https://s0.lgstatic.com/i/image6/M00/51/01/CioPOWEDbJeAZ9G6AAhIbEVyoo0362.png&#34;
	
	
	
	loading=&#34;lazy&#34;
	
	
&gt;&lt;/p&gt;
&lt;!-- raw HTML omitted --&gt;
&lt;p&gt;结合该图，我们可以看到发送一条消息需要经历 7 个步骤，这些步骤可以分为三大块，分别是 KafkaProducer 主线程、RecordAccumulator 缓存和 Sender 子线程。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;KafkaProducer 主线程&lt;/strong&gt;，主要负责创建信息，并调用拦截器、序列化器、分区器分别对消息进行拦截、序列化和路由分区，然后对消息进行压缩，把压缩过的消息放入 RecordAccumulator 缓存中。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;RecordAccumulator 缓存&lt;/strong&gt;，为每个分区创建了一个队列，这个队列是要发送到某个分区的消息集合。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Sender 子线程&lt;/strong&gt;，是真正发送消息的线程。满足一定条件时，KafkaProducer 主线程会激活 Sender 子线程。Sender 子线程从 RecordAccumulator 缓存中拿到要发送的消息，并把消息交给底层网络组件来发送。对于网络接收和网络发送的数据，网络组件会通过两个缓存集合来维护：completedReceives 是负责保存完成的网络接收的集合，completedSends 是负责保存完成的网络发送的集合。服务端成功响应返回给 Sender 子线程后，Sender 子线程就会删除 RecordAccumulator 缓存内已经发送成功的消息。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;介绍完生产端的这个架构设计后，接下来就从以下三点解释一下这个架构从哪些方面提升了消息的吞吐量。&lt;/p&gt;
&lt;h3 id=&#34;1-多线程异步的设计&#34;&gt;1. 多线程异步的设计&lt;/h3&gt;
&lt;p&gt;生产端在异步的设计上体现到了两个方面。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;第一个方面，KafkaProducer 主线程和 Sender 子线程各司其职，通过 RecordAccumulator 缓存交互数据。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;KafkaProducer 主线程有同步和异步两种发送方式，但是这两者底层的实现是相同的，都是通过 Sender 子线程异步发送消息实现的。不同的地方是同步场景下主线程会等待 Sender 子线程发送完消息再返回，而异步是不等待 Sender 子线程发送完消息就返回了。&lt;/p&gt;
&lt;p&gt;KafkaProducer 主线程发送消息时并不是真正的网络发送，而是将消息放入 RecordAccumulator 中缓存，然后主线程就从 send() 方法返回，之后 KafkaProducer 主线程会不断调用 send() 方法把消息缓存到 RecordAccumulator 中，而不去在意消息是否发送出去了。真正发送消息的是 Sender 子线程，Sender 子线程从 RecordAccumulator 缓存中取出消息，然后调用底层网络组件完成消息的发送。&lt;/p&gt;
&lt;p&gt;有的同学可能会有疑问：为什么不能把主线程和 Sender 子线程放到一个线程呢？一个线程里会有什么问题呢？&lt;/p&gt;
&lt;p&gt;生产端发送消息有两个过程：创建消息和网络发送消息。这两个过程都有可能出现阻塞，比如，消息的创建依赖远程数据库或缓存，如果网络不好，线程就会阻塞在消息创建上；而生产端和服务端的通信不好时，也会导致出现阻塞的问题。如果这两个过程放到一个线程里的话，那么其中有一个发送阻塞，就会影响另一个过程的执行。&lt;/p&gt;
&lt;p&gt;但是如果我们把创建消息交给主线程负责，发送消息交给子线程负责，这样这两个过程相互不影响，同时有缓存作为缓冲，很好地起到“削峰填谷”的作用。&lt;/p&gt;
&lt;p&gt;第&lt;strong&gt;二个方面，Sender 子线程和 Kafka 底层通信模块解耦。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Sender 子线程最终是调用 Kafka 底层通信模块实现消息的发送和接收的。&lt;/p&gt;
&lt;p&gt;我们知道 Java NIO 本质上是调用了 Linux 通信模块，Kafka 底层封装了 Java NIO 组件，特别是 org.apache.kafka.common.network.Selector（简称 KSelector）封装了 Java NIO 的 Selector 类，KSelector 在 Selector 的基础上增加了两个集合做缓冲，分别是 completedReceives 集合和 completedSends 集合，KSelector 发送成功和接收成功的消息都会放到这两个集合里。而 Sender 子线程通过 while(true) 循环不断地尝试从这两个集合获取消息，从而实现了这两个组件的解耦，道理也是一样，也是起到“削峰填谷”的作用，进而有利于高吞吐。&lt;/p&gt;
&lt;h3 id=&#34;2-在缓存中批量地获取数据并做到高效的空间利用&#34;&gt;2. 在缓存中批量地获取数据，并做到高效的空间利用&lt;/h3&gt;
&lt;p&gt;这一点与 RecordAccumulator 类的设计关系很大，RecordAccumulator 类的设计图如下：&lt;/p&gt;
&lt;p&gt;&lt;img src=&#34;https://s0.lgstatic.com/i/image6/M00/50/F9/Cgp9HWEDbK6AMBwCAAPPZPimeXE261.png&#34;
	
	
	
	loading=&#34;lazy&#34;
	
	
&gt;&lt;/p&gt;
&lt;!-- raw HTML omitted --&gt;
&lt;p&gt;由图可以看到，在 RecordAccumulator 中有一个 CopyOnWriteMap 集合 batches。key 是主题分区，value 是 ProducerBatch 队列，每个分区都对应一个队列。队列中的元素是批次 ProducerBatch，消息就是封装在这些批次里进行缓存的。而消息发送的最小单位是 batch，也就是说一次消息发送可能不止一条消息，这样的设计大大减少了网络请求的次数，从而提升了网络读写的效率，进而提高了吞吐量。&lt;/p&gt;
&lt;p&gt;接下来我们再来分析下消息的发送时机和逻辑。代码在 RecordAccumulator.drain() 方法内，其源码和源码注释如下：&lt;/p&gt;
&lt;div class=&#34;highlight&#34;&gt;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-java&#34; data-lang=&#34;java&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;//五个判断条件决定是否是能发送的node
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;&lt;/span&gt;&lt;span class=&#34;kt&#34;&gt;boolean&lt;/span&gt; &lt;span class=&#34;n&#34;&gt;sendable&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;n&#34;&gt;full&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;||&lt;/span&gt; &lt;span class=&#34;n&#34;&gt;expired&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;||&lt;/span&gt; &lt;span class=&#34;n&#34;&gt;exhausted&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;||&lt;/span&gt; &lt;span class=&#34;n&#34;&gt;closed&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;||&lt;/span&gt; &lt;span class=&#34;n&#34;&gt;flushInProgress&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;();&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;//能发送且没有正在退避
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;&lt;/span&gt;&lt;span class=&#34;k&#34;&gt;if&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;sendable&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;!&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;backingOff&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;)&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;    &lt;span class=&#34;c1&#34;&gt;//如果是能发送就加入readyNodes集合
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;&lt;/span&gt;    &lt;span class=&#34;n&#34;&gt;readyNodes&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;na&#34;&gt;add&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;leader&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;o&#34;&gt;}&lt;/span&gt; &lt;span class=&#34;k&#34;&gt;else&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;    &lt;span class=&#34;kt&#34;&gt;long&lt;/span&gt; &lt;span class=&#34;n&#34;&gt;timeLeftMs&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;n&#34;&gt;Math&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;na&#34;&gt;max&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;timeToWaitMs&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;-&lt;/span&gt; &lt;span class=&#34;n&#34;&gt;waitedTimeMs&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;,&lt;/span&gt; &lt;span class=&#34;mi&#34;&gt;0&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;    &lt;span class=&#34;c1&#34;&gt;//还剩多久：需要等待的时间-已经等待的时间
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;&lt;/span&gt;    &lt;span class=&#34;n&#34;&gt;nextReadyCheckDelayMs&lt;/span&gt; &lt;span class=&#34;o&#34;&gt;=&lt;/span&gt; &lt;span class=&#34;n&#34;&gt;Math&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;na&#34;&gt;min&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;timeLeftMs&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;,&lt;/span&gt; &lt;span class=&#34;n&#34;&gt;nextReadyCheckDelayMs&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;);&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;o&#34;&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;这里我重点解释下决定是否发送的布尔型变量 sendable 的判断逻辑：五个判断条件只要有一个满足就能发送消息。这五个条件可总结为如下。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;full：deque 是否大于 1，或 deque 的第一个 ProducerBatch 是否满了。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;expired：ProducerBatch 在 deque 里是否超时。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;exhausted：BufferPool 是否正在释放空间。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;closed：生产者是否准备正常关闭了。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;flushInProgress：是否在 flush 操作，这个 flush 是把暂存消息立即发送的标记。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;第一个判断条件是 deque 是否大于 1，或 deque 的第一个 ProducerBatch 是否满了，在 Broker 负载没满的情况下，deque 的第一个 ProducerBatch 是否满了是大部分情况下发送消息的时机。所以说，生产者发送消息并不是一条条发送的，而是一个一个 batch 发送的。&lt;/p&gt;
&lt;p&gt;接下来我们再分析下&lt;strong&gt;生产端高效的空间利用特性&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;缓存的空间分配是由 BufferPool 组件完成的，下面是其工作原理图：&lt;/p&gt;
&lt;p&gt;&lt;img src=&#34;https://s0.lgstatic.com/i/image6/M00/51/02/CioPOWEDbMmAcpiVAALwOMYqWmQ225.png&#34;
	
	
	
	loading=&#34;lazy&#34;
	
	
&gt;&lt;/p&gt;
&lt;!-- raw HTML omitted --&gt;
&lt;p&gt;整个 BufferPool 的大小默认为 32M，内部内存区域分为两块：固定大小内存块集合 free 和非池化缓存 nonPooledAvailableMemory。固定大小内存块默认大小为 16K。当 ProducerBatch 向 BufferPool 申请一个大小为 size 的内存块时，BufferPool 会根据 size 的大小判断由哪个内存区域分配内存块。&lt;/p&gt;
&lt;p&gt;当 ProducerBatch 的数据发送成功后，ProducerBatch 并不会销毁，而是继续留在集合 free 中，这样需要 ProducerBatch 的时候就直接从集合中拿出，就不用频繁地销毁和重建了。其实 ProducerBatch 的底层是 Java NIO ByteBuffer，ByteBuffer 的创建和销毁是很消耗 CPU 资源的，这样的设计实现了 ByteBuffer 的重用，从而大大减少了对资源的消耗。&lt;/p&gt;
&lt;h3 id=&#34;3-消息的压缩&#34;&gt;3. 消息的压缩&lt;/h3&gt;
&lt;p&gt;消息压缩是在业务主线程 KafkaProducer 完成的，消息的压缩大大减少了本地内存、网络通信和服务端存储的压力。&lt;/p&gt;
&lt;p&gt;目前主要有 4 种压缩算法，分别是 gzip、snappy、lz4 和 zstd。你可以根据生产环境实际情况来配置适合自己的压缩算法，评估一个压缩算法一般是从压缩解压的速度和压缩率两方面去权衡的。当机器 CPU 配置比较高而带宽比较低的时候，可以考虑压缩率高而压缩速度低的算法；相反，当机器 CPU 配置比较低而带宽比较高的时候，可以考虑压缩率低而解压速度比较高的算法。&lt;/p&gt;
&lt;p&gt;Kafka 生产端对于高吞吐的设计我就介绍到这里，接下来我继续介绍 Kafka 服务端针对高吞吐的设计特点。&lt;/p&gt;
&lt;h2 id=&#34;服务端&#34;&gt;服务端&lt;/h2&gt;
&lt;p&gt;服务端针对高吞吐有几个设计特点：网络层的 Reactor 设计模式、顺序写、页缓存和零拷贝。接下来我会按照顺序分别为你详细讲解。&lt;/p&gt;
&lt;h3 id=&#34;1-网络层的-reactor-设计模式&#34;&gt;1. 网络层的 Reactor 设计模式&lt;/h3&gt;
&lt;p&gt;网络层的设计图如下：&lt;/p&gt;
&lt;p&gt;&lt;img src=&#34;https://s0.lgstatic.com/i/image6/M00/51/02/CioPOWEDbNqARKWBAAJy6zlp8aI724.png&#34;
	
	
	
	loading=&#34;lazy&#34;
	
	
&gt;&lt;/p&gt;
&lt;!-- raw HTML omitted --&gt;
&lt;p&gt;这里我就结合该图解释一下网络层的架构设计。&lt;/p&gt;
&lt;p&gt;整个服务端的网络架构分为 4 个层次：①Acceptor 线程构成的&lt;strong&gt;连接创建层&lt;/strong&gt;，负责创建和客户端的连接；②Processor 线程类构成的&lt;strong&gt;网络事件处理层&lt;/strong&gt;；③由 RequestChannel 构成的请求和响应的&lt;strong&gt;缓冲层&lt;/strong&gt;；④由 KafkaRequestHandler 和 KafkaApis 构成的真正的&lt;strong&gt;业务处理层&lt;/strong&gt;。&lt;/p&gt;
&lt;p&gt;这样的设计有什么优势呢？&lt;/p&gt;
&lt;p&gt;第一，我们先思考为什么要把 Acceptor 线程和 Processor 线程分开。如果不分开，网络读写的量很大势必造成大量线程阻塞，导致服务端对 OP_ACCEPT 事件响应不及时，进而连接失败。同时，如果服务端刚启动瞬时来了很多连接，大量的线程都去建立新的连接了，那么网络读写事件的处理就会慢下来，也会引起读写超时等问题。&lt;/p&gt;
&lt;p&gt;Acceptor 线程和 Processor 线程分为两层这样的设计让连接的创建和网络读写事件的处理分开，同时还可以配置 Processor 线程的数量，这样做不会被极端场景影响到整体的响应时间，同时也是符合 Reactor 设计模式的。（Reactor 模式又被称为反应器模式或应答者模式，是基于事件驱动的设计模式，如有需要你可以查阅相关的资料来学习，这里就不过多赘述了。）&lt;/p&gt;
&lt;p&gt;第二，Processor 线程解析完请求后并不是直接交给业务线程处理，而是放到 RequestChannel 的请求队列里，这样做避免在高并发场景下业务线程（即调用底层组件的线程）工作过于饱和而造成超时的情况出现。&lt;/p&gt;
&lt;p&gt;第三，KafkaRequestHandlerPool 线程池先消费 RequestChannel 类里的请求队列，然后通过调用 KafkaApis 实现对底层组件的调用。这样做既可以实现网络处理和调用底层组件的解耦，也可以根据实际请求，随时调整 KafkaRequestHandlerPool 线程池的线程数，调整调用底层组件的能力。&lt;/p&gt;
&lt;p&gt;第四，KafkaApis 类会把响应放入对应的 Processor 线程里的响应集合里，而不是直接让 Processor 把响应发送给客户端，这样做实现了业务线程和网络操作线程的解耦，避免了高并发时线程工作过于饱和而造成的延迟问题。&lt;/p&gt;
&lt;h3 id=&#34;2-顺序写&#34;&gt;2. 顺序写&lt;/h3&gt;
&lt;p&gt;Kafka 写日志文件的时候用的是追加消息的形式，只在文件尾部顺序写消息，同时在文件头部顺序读取消息。消息队列不涉及修改消息，所以不需要随机写。这样的设计即使用的是传统的磁盘，吞吐量也会很大。主要原因是操作系统对于顺序写和顺序读有优化，具体采用的是后写（对于写消息优化）和预读（对于读消息优化）。生产环境上经过测试，顺序写比随机写快 3 个数量级。&lt;/p&gt;
&lt;h3 id=&#34;3-页缓存&#34;&gt;3. 页缓存&lt;/h3&gt;
&lt;p&gt;页缓存简单说就是把缓存当磁盘用，这样就避免了频繁地读写磁盘。&lt;/p&gt;
&lt;p&gt;当一个进程要读取或写入磁盘文件的时候，系统会判断数据是否在内存中，如果在，就直接把内存中的数据返回给进程；如果不在，就读取磁盘文件，同时会多读一些连续的磁盘页放到内存中。这样下次再读取或写入时，系统会判断数据是否在内存中，只要是顺序地读写消息，命中率会很高的，大大减少了磁盘访问的次数，提高了服务端的吞吐量。&lt;/p&gt;
&lt;h3 id=&#34;4-零拷贝&#34;&gt;4. 零拷贝&lt;/h3&gt;
&lt;p&gt;这里我们以消费者读取消息为例，服务端要从磁盘拷贝数据然后网络发送，如果不采用零拷贝的话，会发生什么样的事情呢？如下图所示：&lt;/p&gt;
&lt;p&gt;&lt;img src=&#34;https://s0.lgstatic.com/i/image6/M00/51/02/CioPOWEDbOuAD4z_AAE6h6G4b-o112.png&#34;
	
	
	
	loading=&#34;lazy&#34;
	
	
&gt;&lt;/p&gt;
&lt;!-- raw HTML omitted --&gt;
&lt;p&gt;首先，应用程序调用 read() 方法时需要从用户态切换到内核态，将数据从磁盘上取出来保存到内核缓冲区中；然后，内核缓冲区中的数据传输到应用程序，此时 read() 方法调用结束，从内核态切换到用户态。之后，应用程序执行 send() 方法，需要从用户态切换到内核态，将数据传输给 Socket Buffer；最后，内核会将 Socket Buffer 中的数据发送到网卡，再发送到远程节点，此时 send() 方法结束，从内核态切换到用户态。&lt;/p&gt;
&lt;p&gt;可以看到，这个过程共涉及四次 CPU 上下文切换和四次数据复制，并且有两次复制操作是由 CPU 完成的，另外两次由 DMA 完成。在这个过程中，数据本身没有任何修改，仅仅是从磁盘复制到了网卡缓冲区中，于是会浪费大量的 CPU 周期。&lt;/p&gt;
&lt;p&gt;那采用零拷贝又会发生什么呢？如下过程图：&lt;/p&gt;
&lt;p&gt;&lt;img src=&#34;https://s0.lgstatic.com/i/image6/M00/50/F9/Cgp9HWEDbPiAUHBIAAEMXenJgF4404.png&#34;
	
	
	
	loading=&#34;lazy&#34;
	
	
&gt;&lt;/p&gt;
&lt;!-- raw HTML omitted --&gt;
&lt;p&gt;首先，应用程序调用 transferTo() 方法，从用户态转换为内核态，DMA 会将文件数据发送到内核缓冲区；然后，Socket Buffer 追加数据的描述信息；最后，DMA 将内核缓冲区的数据发送到网卡缓冲区，这样就完全解放了 CPU，实现了零拷贝。&lt;/p&gt;
&lt;p&gt;也就是说，所谓“零拷贝”是 CPU 不参与拷贝数据的工作，可以节省大量的 CPU 周期，同时减少了两次 CPU 在用户态和内核态的切换。这样大大减少了 CPU 的负载，从而提升了吞吐量。&lt;/p&gt;
&lt;h2 id=&#34;消费端&#34;&gt;消费端&lt;/h2&gt;
&lt;p&gt;相较生产端和服务端，消费端提升吞吐量的策略就没那么复杂了。&lt;/p&gt;
&lt;p&gt;一般来讲，消费端提升吞吐量的方式主要就是通过解耦，消费者在消费消息的时候，也是有两个线程分别来拉取消息任务线程和网络 IO 任务线程。下图描述了拉取消息的过程：&lt;/p&gt;
&lt;p&gt;&lt;img src=&#34;https://s0.lgstatic.com/i/image6/M00/50/F9/Cgp9HWEDbQeAGhdjAAsVGWUzvKw810.png&#34;
	
	
	
	loading=&#34;lazy&#34;
	
	
&gt;&lt;/p&gt;
&lt;!-- raw HTML omitted --&gt;
&lt;p&gt;通过该图可以看到，消费者拉取完消息后并不是直接处理，而是放到一个缓存里，等待其他任务处理。&lt;/p&gt;
&lt;p&gt;那消费者为什么不直接从 Broker 拉取消息，而是先把消息拉取过来放入缓存再等着获取呢？可以看下面的关系图：&lt;/p&gt;
&lt;p&gt;&lt;img src=&#34;https://s0.lgstatic.com/i/image6/M00/51/02/CioPOWEDbRmAbW47AAxkOLj86Is054.png&#34;
	
	
	
	loading=&#34;lazy&#34;
	
	
&gt;&lt;/p&gt;
&lt;!-- raw HTML omitted --&gt;
&lt;p&gt;如图所示，拉取消息任务和网络 IO 任务是解耦的，网络 IO 任务会事先把消息拉取到消费者缓存里，然后等待拉取消息任务读取缓存里的消息。这样做的好处是拉取消息任务拉取消息的时候不会造成 IO 阻塞，可以提高拉取消息任务的效率，并最终提升整体的吞吐量。&lt;/p&gt;
&lt;h2 id=&#34;总结&#34;&gt;总结&lt;/h2&gt;
&lt;p&gt;今天我主要从生产端、服务端和消费端三个方面给你介绍了 Kafka 为提高吞吐量而做的一些设计。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;生产端主要是通过消息压缩、消息缓存批量发送、异步解耦等方面提升吞吐量的。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;服务端采用的优化技术比较多，比如，网络层的 Reactor 设计提升了网络层的吞吐，顺序写、页缓存、零拷贝这些是利用操作系统的优化点来实现存储层读写的吞吐量。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;消费端主要是通过线程异步解耦的方式提升了拉取消息的效率，进而提升消费者的吞吐量。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;结合我多年的工作经验来看，搞清楚 Kafka 对于高吞吐的设计思路是有很多好处的。&lt;/p&gt;
&lt;p&gt;首先，了解这个设计原理后能更好地调优 Kafka 的性能，比如服务端网络层有 Acceptor 和 Processor 两种线程，当网络读写比较频繁的时候，你可以通过增加 Processor 线程数来提升网络吞吐。&lt;/p&gt;
&lt;p&gt;另外，这些设计原理对你设计其他系统的时候也有很大的借鉴意义。比如，你在设计高吞吐系统的时候，完全可以借鉴生产者用不同的线程完成不同的任务，实现任务的解耦，防止某个任务延迟造成整体变慢。还有，利用操作系统本身的特性优化吞吐量也是值得学习的，比如，页缓存、顺序读写、零拷贝等，大大提升了系统的吞吐量。在工作中，你都可以好好利用这些优秀的设计来实现高吞吐、高性能的系统。&lt;/p&gt;
</description>
        </item>
        <item>
        <title>消息队列模型</title>
        <link>https://blog.ther.cool/posts/%E6%B6%88%E6%81%AF%E9%98%9F%E5%88%97%E6%A8%A1%E5%9E%8B/</link>
        <pubDate>Mon, 12 Jun 2023 09:46:30 +0800</pubDate>
        
        <guid>https://blog.ther.cool/posts/%E6%B6%88%E6%81%AF%E9%98%9F%E5%88%97%E6%A8%A1%E5%9E%8B/</guid>
        <description>&lt;h2 id=&#34;主题和队列&#34;&gt;主题和队列&lt;/h2&gt;
&lt;p&gt;最初的消息队列，就是一个严格意义上的队列。在计算机领域，“队列（Queue）”是一种数据结构，有完整而严格的定义。在维基百科中，队列的定义是这样的：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;队列是先进先出（FIFO, First-In-First-Out）的线性表（Linear List）。在具体应用中通常用链表或者数组来实现。队列只允许在后端（称为 rear）进行插入操作，在前端（称为 front）进行删除操作。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这个定义里面包含几个关键点，第一个是先进先出，这里面隐含着的一个要求是，在消息入队出队过程中，需要保证这些消息&lt;strong&gt;严格有序&lt;/strong&gt;，按照什么顺序写进队列，必须按照同样的顺序从队列中读出来。不过，队列是没有“读”这个操作的，“读”就是出队，也就是从队列中“删除”这条消息。&lt;/p&gt;
&lt;p&gt;**早期的消息队列，就是按照“队列”的数据结构来设计的。**生产者（Producer）发消息就是入队操作，消费者（Consumer）收消息就是出队也就是删除操作，服务端存放消息的容器自然就称为“队列”。&lt;/p&gt;
&lt;p&gt;这就是最初的一种消息模型：队列模型。&lt;/p&gt;
&lt;p&gt;&lt;img src=&#34;https://s2.loli.net/2023/06/14/pH5PDXOdf6mMV1n.jpg&#34;
	
	
	
	loading=&#34;lazy&#34;
	
	
&gt;&lt;/p&gt;
&lt;p&gt;如果有多个生产者往同一个队列里面发送消息，这个队列中可以消费到的消息，就是这些生产者生产的所有消息的合集。消息的顺序就是这些生产者发送消息的自然顺序。如果有多个消费者接收同一个队列的消息，这些消费者之间实际上是竞争的关系，每个消费者只能收到队列中的一部分消息，也就是说任何一条消息只能被其中的一个消费者收到。&lt;/p&gt;
&lt;p&gt;如果需要将一份消息数据分发给多个消费者，要求每个消费者都能收到全量的消息，例如，对于一份订单数据，风控系统、分析系统、支付系统等都需要接收消息。这个时候，单个队列就满足不了需求，一个可行的解决方式是，为每个消费者创建一个单独的队列，让生产者发送多份。这显然这是个比较蠢的做法，同样的一份消息数据被复制到多个队列中会浪费资源，更重要的是，生产者必须知道有多少个消费者。为每个消费者单独发送一份消息，这实际上违背了消息队列“解耦”这个设计初衷。&lt;/p&gt;
&lt;p&gt;为了解决这个问题，演化出了另外一种消息模型：“&lt;strong&gt;发布 - 订阅模型（Publish-Subscribe Pattern）&lt;/strong&gt;”。&lt;/p&gt;
&lt;p&gt;&lt;img src=&#34;https://s2.loli.net/2023/06/14/lfibw3hr9yTdqB2.jpg&#34;
	
	
	
	loading=&#34;lazy&#34;
	
	
&gt;&lt;/p&gt;
&lt;p&gt;在发布 - 订阅模型中，消息的发送方称为发布者（Publisher），消息的接收方称为订阅者（Subscriber），服务端存放消息的容器称为主题（Topic）。发布者将消息发送到主题中，订阅者在接收消息之前需要先“订阅主题”。“订阅”在这里既是一个动作，同时还可以认为是主题在消费时的一个逻辑副本，每份订阅中，订阅者都可以接收到主题的所有消息。&lt;/p&gt;
&lt;p&gt;在消息领域的历史上很长的一段时间，队列模式和发布 - 订阅模式是并存的，有些消息队列同时支持这两种消息模型，比如 ActiveMQ。我们仔细对比一下这两种模型，生产者就是发布者，消费者就是订阅者，队列就是主题，并没有本质的区别。&lt;strong&gt;它们最大的区别其实就是，一份消息数据能不能被消费多次的问题。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;实际上，在这种发布 - 订阅模型中，如果只有一个订阅者，那它和队列模型就基本是一样的了。也就是说，发布 - 订阅模型在功能层面上是可以兼容队列模型的。&lt;/p&gt;
&lt;p&gt;现代的消息队列产品使用的消息模型大多是这种发布 - 订阅模型，当然也有例外，RabbitMQ 是少数依然坚持使用队列模型的产品之一。&lt;/p&gt;
&lt;h3 id=&#34;rocketmq-的消息模型&#34;&gt;RocketMQ 的消息模型&lt;/h3&gt;
&lt;p&gt;RocketMQ 使用的消息模型是标准的发布 - 订阅模型，在 RocketMQ 的术语表中，生产者、消费者和主题与上述发布 - 订阅模型中的概念是完全一样的。但是在 RocketMQ 还有队列（Queue）这个概念，队列在 RocketMQ 中的作用是什么呢？这就要从消息队列的消费机制说起。&lt;/p&gt;
&lt;p&gt;几乎所有的消息队列产品都使用一种非常朴素的“请求 - 确认”机制，确保消息不会在传递过程中由于网络或服务器故障丢失。具体的做法也非常简单。在生产端，生产者先将消息发送给服务端，也就是 Broker，服务端在收到消息并将消息写入主题或者队列中后，会给生产者发送确认的响应。&lt;/p&gt;
&lt;p&gt;如果生产者没有收到服务端的确认或者收到失败的响应，则会重新发送消息；在消费端，消费者在收到消息并完成自己的消费业务逻辑（比如，将数据保存到数据库中）后，也会给服务端发送消费成功的确认，服务端只有收到消费确认后，才认为一条消息被成功消费，否则它会给消费者重新发送这条消息，直到收到对应的消费成功确认。&lt;/p&gt;
&lt;p&gt;这个确认机制很好地保证了消息传递过程中的可靠性，但是，引入这个机制在消费端带来了一个问题。为了确保消息的有序性，在某一条消息被成功消费之前，下一条消息是不能被消费的，否则就会出现消息空洞，违背了有序性这个原则。&lt;/p&gt;
&lt;p&gt;也就是说，每个主题在任意时刻，至多只能有一个消费者实例在进行消费，那就没法通过水平扩展消费者的数量来提升消费端总体的消费性能。为了解决这个问题，RocketMQ 在主题下面增加了队列的概念。&lt;/p&gt;
&lt;p&gt;**每个主题包含多个队列，通过多个队列来实现多实例并行生产和消费。**需要注意的是，&lt;strong&gt;RocketMQ 只在队列上保证消息的有序性&lt;/strong&gt;，主题层面是无法保证消息的严格顺序的。&lt;/p&gt;
&lt;p&gt;RocketMQ 中，订阅者的概念是通过消费组（Consumer Group）来体现的。每个消费组都消费主题中一份完整的消息，不同消费组之间消费进度彼此不受影响，也就是说，一条消息被 Consumer Group1 消费过，也会再给 Consumer Group2 消费。&lt;/p&gt;
&lt;p&gt;消费组中包含多个消费者，同一个组内的消费者是竞争消费的关系，每个消费者负责消费组内的一部分消息。如果一条消息被消费者 Consumer1 消费了，那同组的其他消费者就不会再收到这条消息。&lt;/p&gt;
&lt;p&gt;在 Topic 的消费过程中，由于消息需要被不同的组进行多次消费，所以消费完的消息并不会立即被删除，这就需要 RocketMQ 为每个消费组在每个队列上维护一个消费位置（Consumer Offset），这个位置之前的消息都被消费过，之后的消息都没有被消费过，每成功消费一条消息，消费位置就加一。这个消费位置是非常重要的概念，我们在使用消息队列的时候，丢消息的原因大多是由于消费位置处理不当导致的。&lt;/p&gt;
&lt;p&gt;RocketMQ 的消息模型如下：&lt;/p&gt;
&lt;p&gt;&lt;img src=&#34;https://s2.loli.net/2023/06/14/3EUwqMuVBp6bncg.jpg&#34;
	
	
	
	loading=&#34;lazy&#34;
	
	
&gt;&lt;/p&gt;
&lt;h3 id=&#34;kafka-的消息模型&#34;&gt;Kafka 的消息模型&lt;/h3&gt;
&lt;p&gt;Kafka 的消息模型和 RocketMQ 是完全一致，所有 RocketMQ 中对应的概念，和生产消费过程中的确认机制，都完全适用于 Kafka。唯一的区别是，在 Kafka 中，队列这个概念的名称不一样，Kafka 中对应的名称是“分区（Partition）”，含义和功能是没有任何区别的。&lt;/p&gt;
&lt;h3 id=&#34;小结&#34;&gt;小结&lt;/h3&gt;
&lt;p&gt;首先我们讲了队列和主题的区别，这两个概念的背后实际上对应着两种不同的消息模型：队列模型和发布 - 订阅模型。然后你需要理解，这两种消息模型其实并没有本质上的区别，都可以通过一些扩展或者变化来互相替代。&lt;/p&gt;
&lt;p&gt;常用的消息队列中，RabbitMQ 采用的是队列模型，但是它一样可以实现发布 - 订阅的功能。RocketMQ 和 Kafka 采用的是发布 - 订阅模型，并且二者的消息模型是基本一致的。&lt;/p&gt;
&lt;p&gt;最后提醒你一点，我这节课讲的消息模型和相关的概念是业务层面的模型，深刻理解业务模型有助于你用最佳的姿势去使用消息队列。&lt;/p&gt;
&lt;p&gt;但业务模型不等于就是实现层面的模型。比如说 MySQL 和 Hbase 同样是支持 SQL 的数据库，它们的业务模型中，存放数据的单元都是“表”，但是在实现层面，没有哪个数据库是以二维表的方式去存储数据的，MySQL 使用 B+ 树来存储数据，而 HBase 使用的是 KV 的结构来存储。同样，像 Kafka 和 RocketMQ 的业务模型基本是一样的，并不是说他们的实现就是一样的，实际上这两个消息队列的实现是完全不同的。&lt;/p&gt;
&lt;h3 id=&#34;思考题&#34;&gt;思考题&lt;/h3&gt;
&lt;p&gt;最后给大家留一个思考题。刚刚我在介绍 RocketMQ 的消息模型时讲过，在消费的时候，为了保证消息的不丢失和严格顺序，每个队列只能串行消费，无法做到并发，否则会出现消费空洞的问题。那如果放宽一下限制，不要求严格顺序，能否做到单个队列的并行消费呢？如果可以，该如何实现？欢迎在留言区与我分享讨论。&lt;/p&gt;
&lt;p&gt;评论回答：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;把消息队列的先进先出，改成数组的随机访问，用offset来控制消息组具体要消费哪条消息，mq不主动删除消息，消息有过期时间，如果到了过期时间，只能确认不能重新该消费，只保留最大可设置天数的消息。超过该天数则删除，还要维护客户端确认信息，如果有客户端没确认，需要有重发机制。&lt;/li&gt;
&lt;/ul&gt;
</description>
        </item>
        <item>
        <title>消息队列知识笔记</title>
        <link>https://blog.ther.cool/posts/%E6%B6%88%E6%81%AF%E9%98%9F%E5%88%97%E7%9F%A5%E8%AF%86%E7%AC%94%E8%AE%B0/</link>
        <pubDate>Tue, 06 Jun 2023 09:25:03 +0800</pubDate>
        
        <guid>https://blog.ther.cool/posts/%E6%B6%88%E6%81%AF%E9%98%9F%E5%88%97%E7%9F%A5%E8%AF%86%E7%AC%94%E8%AE%B0/</guid>
        <description>&lt;h2 id=&#34;如何确保消息不会丢失&#34;&gt;如何确保消息不会丢失?&lt;/h2&gt;
&lt;p&gt;现在主流的消息队列产品都提供了非常完善的消息可靠性保证机制，完全可以做到在消息传递过程中，即使发生网络中断或者硬件故障，也能确保消息的可靠传递，不丢消息。&lt;/p&gt;
&lt;p&gt;绝大部分丢消息的原因都是由于开发者不熟悉消息队列，没有正确使用和配置消息队列导致的。&lt;/p&gt;
&lt;h3 id=&#34;检测消息丢失的方法&#34;&gt;检测消息丢失的方法&lt;/h3&gt;
&lt;p&gt;如果是 IT 基础设施比较完善的公司，一般都有分布式链路追踪系统，使用类似的追踪系统可以很方便地追踪每一条消息。如果没有这样的追踪系统，这里我提供一个比较简单的方法，来检查是否有消息丢失的情况。&lt;/p&gt;
&lt;p&gt;**我们可以利用消息队列的有序性来验证是否有消息丢失。**原理非常简单，在 Producer 端，我们给每个发出的消息附加一个连续递增的序号，然后在 Consumer 端来检查这个序号的连续性。&lt;/p&gt;
&lt;p&gt;如果没有消息丢失，Consumer 收到消息的序号必然是连续递增的，或者说收到的消息，其中的序号必然是上一条消息的序号 +1。如果检测到序号不连续，那就是丢消息了。还可以通过缺失的序号来确定丢失的是哪条消息，方便进一步排查原因。&lt;/p&gt;
&lt;p&gt;大多数消息队列的客户端都支持拦截器机制，你可以利用这个拦截器机制，在 Producer 发送消息之前的拦截器中将序号注入到消息中，在 Consumer 收到消息的拦截器中检测序号的连续性，这样实现的好处是消息检测的代码不会侵入到你的业务代码中，待你的系统稳定后，也方便将这部分检测的逻辑关闭或者删除。&lt;/p&gt;
&lt;p&gt;如果是在一个分布式系统中实现这个检测方法，有几个问题需要你注意。&lt;/p&gt;
&lt;p&gt;首先，像 Kafka 和 RocketMQ 这样的消息队列，它是不保证在 Topic 上的严格顺序的，只能保证分区上的消息是有序的，所以我们在发消息的时候必须要指定分区，并且，在每个分区单独检测消息序号的连续性。&lt;/p&gt;
&lt;p&gt;如果你的系统中 Producer 是多实例的，由于并不好协调多个 Producer 之间的发送顺序，所以也需要每个 Producer 分别生成各自的消息序号，并且需要附加上 Producer 的标识，在 Consumer 端按照每个 Producer 分别来检测序号的连续性。&lt;/p&gt;
&lt;p&gt;Consumer 实例的数量最好和分区数量一致，做到 Consumer 和分区一一对应，这样会比较方便地在 Consumer 内检测消息序号的连续性。&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;疑问：怎么保存上一条消息的序号？&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h3 id=&#34;确保消息可靠传递&#34;&gt;确保消息可靠传递&lt;/h3&gt;
&lt;p&gt;，整个消息从生产到消费的过程中，哪些地方可能会导致丢消息，以及应该如何避免消息丢失。&lt;/p&gt;
&lt;p&gt;你可以看下这个图，一条消息从生产到消费完成这个过程，可以划分三个阶段，为了方便描述，我给每个阶段分别起了个名字。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;生产阶段&lt;/strong&gt;: 在这个阶段，从消息在 Producer 创建出来，经过网络传输发送到 Broker 端。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;存储阶段&lt;/strong&gt;: 在这个阶段，消息在 Broker 端存储，如果是集群，消息会在这个阶段被复制到其他的副本上。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;消费阶段&lt;/strong&gt;: 在这个阶段，Consumer 从 Broker 上拉取消息，经过网络传输发送到 Consumer 上。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;strong&gt;1. 生产阶段&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;在生产阶段，消息队列通过最常用的请求确认机制，来保证消息的可靠传递：当你的代码调用发消息方法时，消息队列的客户端会把消息发送到 Broker，Broker 收到消息后，会给客户端返回一个确认响应，表明消息已经收到了。客户端收到响应后，完成了一次正常消息的发送。&lt;/p&gt;
&lt;p&gt;只要 Producer 收到了 Broker 的确认响应，就可以保证消息在生产阶段不会丢失。有些消息队列在长时间没收到发送确认响应后，会自动重试，如果重试再失败，就会以返回值或者异常的方式告知用户。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;你在编写发送消息代码时，需要注意，正确处理返回值或者捕获异常，就可以保证这个阶段的消息不会丢失。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. 存储阶段&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;在存储阶段正常情况下，只要 Broker 在正常运行，就不会出现丢失消息的问题，但是如果 Broker 出现了故障，比如进程死掉了或者服务器宕机了，还是可能会丢失消息的。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;如果对消息的可靠性要求非常高，可以通过配置 Broker 参数来避免因为宕机丢消息。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;对于单个节点的 Broker，需要配置 Broker 参数，在收到消息后，将消息写入磁盘后再给 Producer 返回确认响应，这样即使发生宕机，由于消息已经被写入磁盘，就不会丢失消息，恢复后还可以继续消费。例如，在 RocketMQ 中，需要将刷盘方式 flushDiskType 配置为 SYNC_FLUSH 同步刷盘。&lt;/p&gt;
&lt;p&gt;如果是 Broker 是由多个节点组成的集群，需要将 Broker 集群配置成：至少将消息发送到 2 个以上的节点，再给客户端回复发送确认响应。这样当某个 Broker 宕机时，其他的 Broker 可以替代宕机的 Broker，也不会发生消息丢失。后面我会专门安排一节课，来讲解在集群模式下，消息队列是如何通过消息复制来确保消息的可靠性的。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. 消费阶段&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;消费阶段采用和生产阶段类似的确认机制来保证消息的可靠传递，客户端从 Broker 拉取消息后，执行用户的消费业务逻辑，成功后，才会给 Broker 发送消费确认响应。如果 Broker 没有收到消费确认响应，下次拉消息的时候还会返回同一条消息，确保消息不会在网络传输过程中丢失，也不会因为客户端在执行消费逻辑中出错导致丢失。&lt;/p&gt;
&lt;p&gt;你在编写消费代码时需要注意的是，&lt;strong&gt;不要在收到消息后就立即发送消费确认，而是应该在执行完所有消费业务逻辑之后，再发送消费确认。&lt;/strong&gt;&lt;/p&gt;
&lt;h3 id=&#34;小结&#34;&gt;小结&lt;/h3&gt;
&lt;p&gt;这节课我带大家分析了一条消息从发送到消费整个流程中，消息队列是如何确保消息的可靠性，不会丢失的。这个过程可以分为分三个阶段，每个阶段都需要正确的编写代码并且设置正确的配置项，才能配合消息队列的可靠性机制，确保消息不会丢失。&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;在生产阶段，你需要捕获消息发送的错误，并重发消息。&lt;/li&gt;
&lt;li&gt;在存储阶段，你可以通过配置刷盘和复制相关的参数，让消息写入到多个副本的磁盘上，来确保消息不会因为某个 Broker 宕机或者磁盘损坏而丢失。&lt;/li&gt;
&lt;li&gt;在消费阶段，你需要在处理完全部消费业务逻辑之后，再发送消费确认。&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id=&#34;思考题&#34;&gt;思考题&lt;/h3&gt;
&lt;p&gt;如果消息在网络传输过程中发送错误，由于发送方收不到确认，会通过重发来保证消息不丢失。但是，如果确认响应在网络传输时丢失，也会导致重发消息。也就是说，&lt;strong&gt;无论是 Broker 还是 Consumer 都是有可能收到重复消息的。&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id=&#34;如何处理消费过程中的重复消息&#34;&gt;如何处理消费过程中的重复消息？&lt;/h2&gt;
&lt;h3 id=&#34;消息重复的情况必然存在&#34;&gt;消息重复的情况必然存在&lt;/h3&gt;
&lt;p&gt;在 MQTT 协议中，给出了三种传递消息时能够提供的服务质量标准，这三种服务质量从低到高依次是：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;At most once&lt;/strong&gt;: 至多一次。消息在传递时，最多会被送达一次。换一个说法就是，没什么消息可靠性保证，允许丢消息。一般都是一些对消息可靠性要求不太高的监控场景使用，比如每分钟上报一次机房温度数据，可以接受数据少量丢失。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;At least once&lt;/strong&gt;: 至少一次。消息在传递时，至少会被送达一次。也就是说，不允许丢消息，但是允许有少量重复消息出现。&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Exactly once&lt;/strong&gt;：恰好一次。消息在传递时，只会被送达一次，不允许丢失也不允许重复，这个是最高的等级。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这个服务质量标准不仅适用于 MQTT，对所有的消息队列都是适用的。现在常用的绝大部分消息队列提供的服务质量都是 At least once，包括 RocketMQ、RabbitMQ 和 Kafka 都是这样。也就是说，消息队列很难保证消息不重复。&lt;/p&gt;
&lt;h3 id=&#34;用幂等性解决重复消息问题&#34;&gt;用幂等性解决重复消息问题&lt;/h3&gt;
&lt;p&gt;一般解决重复消息的办法是，在消费端，让消费消息的操作具备幂等性。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;幂等（Idempotence）&lt;/strong&gt; 本来是一个数学上的概念，它是这样定义的：&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;如果一个函数 f(x) 满足：f(f(x)) = f(x)，则函数 f(x) 满足幂等性。&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p&gt;这个概念被拓展到计算机领域，被用来描述一个操作、方法或者服务。一个幂等操作的特点是，&lt;strong&gt;其任意多次执行所产生的影响均与一次执行的影响相同。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;从对系统的影响结果来说：&lt;strong&gt;At least once + 幂等消费 = Exactly once。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;那么如何实现幂等操作呢？最好的方式就是，**从业务逻辑设计上入手，将消费的业务逻辑设计成具备幂等性的操作。**但是，不是所有的业务都能设计成天然幂等的，这里就需要一些方法和技巧来实现幂等。&lt;/p&gt;
&lt;p&gt;下面介绍几种常用的设计幂等操作的方法：&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. 利用数据库的唯一约束实现幂等&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;例如这个不具备幂等特性的转账的例子：将账户 X 的余额加 100 元。在这个例子中，可以通过改造业务逻辑来具备幂等性。&lt;/p&gt;
&lt;p&gt;首先限定对于每个转账单每个账户只可以执行一次变更操作，在分布式系统中，这个限制实现的方法非常多，最简单的是在数据库中建一张转账流水表，这个表有三个字段：转账单 ID、账户 ID 和变更金额，然后给转账单 ID 和账户 ID 这两个字段联合起来创建一个唯一约束，这样对于相同的转账单 ID 和账户 ID，表里至多只能存在一条记录。&lt;/p&gt;
&lt;p&gt;这样消费消息的逻辑可以变为：“在转账流水表中增加一条转账记录，然后再根据转账记录，异步操作更新用户余额即可。”在转账流水表增加一条转账记录这个操作中，由于我们在这个表中预先定义了“账户 ID 转账单 ID”的唯一约束，对于同一个转账单同一个账户只能插入一条记录，后续重复的插入操作都会失败，这样就实现了一个幂等的操作。我们只要写一个 SQL，正确地实现它就可以了。&lt;/p&gt;
&lt;p&gt;基于这个思路，不光是可以使用关系型数据库，只要是支持类似“INSERT IF NOT EXIST”语义的存储类系统都可以用于实现幂等，比如，你可以用 Redis 的 SETNX 命令来替代数据库中的唯一约束，来实现幂等消费。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. 为更新的数据设置前置条件&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;另外一种实现幂等的思路是，给数据变更设置一个前置条件，如果满足条件就更新数据，否则拒绝更新数据，在更新数据的时候，同时变更前置条件中需要判断的数据。这样，重复执行这个操作时，由于第一次更新数据的时候已经变更了前置条件中需要判断的数据，不满足前置条件，则不会重复执行更新数据操作。&lt;/p&gt;
&lt;p&gt;比如“将账户 X 的余额增加 100 元”这个操作并不满足幂等性，但是将这个操作加上一个前置条件，变为：“如果账户 X 当前的余额为 500 元，将余额加 100 元”，这个操作就具备了幂等性。对应到消息队列中的使用时，可以在发消息时在消息体中带上当前的余额，在消费的时候进行判断数据库中，当前余额是否与消息中的余额相等，只有相等才执行变更操作。&lt;/p&gt;
&lt;p&gt;但是，如果我们要更新的数据不是数值，或者我们要做一个比较复杂的更新操作怎么办？用什么作为前置判断条件呢？更加通用的方法是，给你的数据增加一个版本号属性，每次更数据前，比较当前数据的版本号是否和消息中的版本号一致，如果不一致就拒绝更新数据，更新数据的同时将版本号 +1，一样可以实现幂等更新。&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. 记录并检查操作&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;如果上面提到的两种实现幂等方法都不能适用于你的场景，还有一种通用性最强，适用范围最广的实现幂等性方法：记录并检查操作，也称为“Token 机制或者 GUID（全局唯一 ID）机制”，实现的思路特别简单：在执行数据更新操作之前，先检查一下是否执行过这个更新操作。&lt;/p&gt;
&lt;p&gt;具体的实现方法是，在发送消息时，给每条消息指定一个全局唯一的 ID。消费时，先根据这个 ID 检查这条消息是否有被消费过，如果没有消费过，才更新数据，然后将消费状态置为已消费。&lt;/p&gt;
&lt;p&gt;原理和实现是不是很简单？其实一点儿都不简单，在分布式系统中，这个方法其实是非常难实现的。首先，给每个消息指定一个全局唯一的 ID 就是一件不那么简单的事儿，方法有很多，但都不太好同时满足简单、高可用和高性能，或多或少都要有些牺牲。更加麻烦的是，在“检查消费状态，然后更新数据并且设置消费状态”中，三个操作必须作为一组操作保证原子性，才能真正实现幂等，否则就会出现 Bug。&lt;/p&gt;
&lt;p&gt;比如说，对于同一条消息：“全局 ID 为 8，操作为：给 ID 为 666 账户增加 100 元”，有可能出现这样的情况：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;t0 时刻：Consumer A 收到条消息，检查消息执行状态，发现消息未处理过，开始执行“账户增加 100 元”；&lt;/li&gt;
&lt;li&gt;t1 时刻：Consumer B 收到条消息，检查消息执行状态，发现消息未处理过，因为这个时刻，Consumer A 还未来得及更新消息执行状态。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;这样就会导致账户被错误地增加了两次 100 元，这是一个在分布式系统中非常容易犯的错误，一定要引以为戒。&lt;/p&gt;
&lt;p&gt;对于这个问题，当然我们可以用事务来实现，也可以用锁来实现，但是在分布式系统中，无论是分布式事务还是分布式锁都是比较难解决的问题。&lt;/p&gt;
&lt;h3 id=&#34;小结-1&#34;&gt;小结&lt;/h3&gt;
&lt;p&gt;这节课我们主要介绍了通过幂等消费来解决消息重复的问题，然后我重点讲了几种实现幂等操作的方法，你可以利用数据库的约束来防止重复更新数据，也可以为数据更新设置一次性的前置条件，来防止重复消息，如果这两种方法都不适用于你的场景，还可以用“记录并检查操作”的方式来保证幂等，这种方法适用范围最广，但是实现难度和复杂度也比较高，一般不推荐使用。&lt;/p&gt;
&lt;p&gt;这些实现幂等的方法，不仅可以用于解决重复消息的问题，也同样适用于，在其他场景中来解决重复请求或者重复调用的问题。比如，可以将 HTTP 服务设计成幂等的，解决前端或者 APP 重复提交表单数据的问题；也可以将一个微服务设计成幂等的，解决 RPC 框架自动重试导致的重复调用问题。&lt;/p&gt;
&lt;h3 id=&#34;思考题-1&#34;&gt;思考题&lt;/h3&gt;
&lt;p&gt;为什么大部分消息队列都选择只提供 At least once 的服务质量，而不是级别更高的 Exactly once 呢？&lt;/p&gt;
&lt;p&gt;评论回答：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;我觉得最重要的原因是消息队列即使做到了Exactly once级别，consumer也还是要做幂等。因为在consumer从消息队列取消息这里，如果consumer消费成功，但是ack失败，consumer还是会取到重复的消息，所以消息队列花大力气做成Exactly once并不能解决业务侧消息重复的问题。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;我感觉是消息队列没有办法做到exactly once吧。原因是网络环境太复杂，底层的tcp都做不到exactly once，上层的应用更加做不到了。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;exactly once的要想实现，一种选择是效仿mqtt qos2，做多次确认。这种方法理论上并不能100%避免消息重复，却使得性能大幅下降，得不偿失。另一种是mq的consumer实现框架在内部对消息id做记录，并做重复性检查，但这又引来了新问题，框架的实现无法知道一条长时间没ack的消息发生了什么，没有进行去重的依据。看来这些是还是交给应用层更合适。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id=&#34;如何处理消息积压&#34;&gt;如何处理消息积压？&lt;/h2&gt;
&lt;p&gt;消息积压的直接原因，一定是系统中的某个部分出现了性能问题，来不及处理上游发送的消息，才会导致消息积压。所以先分析下在使用消息队列时，如何优化代码的性能，避免出现消息积压。然后再看如果线上系统出现了消息积压该如何进行紧急处理，最大程度地避免消息积压对业务的影响。&lt;/p&gt;
&lt;h3 id=&#34;优化性能来避免消息积压&#34;&gt;优化性能来避免消息积压&lt;/h3&gt;
&lt;p&gt;在使用消息队列的系统中，对于性能的优化，主要体现在生产者和消费者这一收一发两部分的业务逻辑中。对于消息队列本身的性能，你作为使用者，不需要太关注。为什么这么说呢？&lt;/p&gt;
&lt;p&gt;主要原因是，对于绝大多数使用消息队列的业务来说，消息队列本身的处理能力要远大于业务系统的处理能力。主流消息队列的单个节点，消息收发的性能可以达到每秒钟处理几万至几十万条消息的水平，还可以通过水平扩展 Broker 的实例数成倍地提升处理能力。&lt;/p&gt;
&lt;p&gt;而一般的业务系统需要处理的业务逻辑远比消息队列要复杂，单个节点每秒钟可以处理几百到几千次请求，已经可以算是性能非常好的了。所以，对于消息队列的性能优化，我们更关注的是，&lt;strong&gt;在消息的收发两端，我们的业务代码怎么和消息队列配合，达到一个最佳的性能。&lt;/strong&gt;&lt;/p&gt;
&lt;h4 id=&#34;1-发送端性能优化&#34;&gt;1. 发送端性能优化&lt;/h4&gt;
&lt;p&gt;发送端业务代码的处理性能，实际上和消息队列的关系不大，因为一般发送端都是先执行自己的业务逻辑，最后再发送消息。&lt;strong&gt;如果说，你的代码发送消息的性能上不去，你需要优先检查一下，是不是发消息之前的业务逻辑耗时太多导致的。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;对于发送消息的业务逻辑，只需要注意设置合适的并发和批量大小，就可以达到很好的发送性能。为什么这么说呢？&lt;/p&gt;
&lt;p&gt;我们之前的课程中讲过 Producer 发送消息的过程，Producer 发消息给 Broker，Broker 收到消息后返回确认响应，这是一次完整的交互。假设这一次交互的平均时延是 1ms，我们把这 1ms 的时间分解开，它包括了下面这些步骤的耗时：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;发送端准备数据、序列化消息、构造请求等逻辑的时间，也就是发送端在发送网络请求之前的耗时；&lt;/li&gt;
&lt;li&gt;发送消息和返回响应在网络传输中的耗时；&lt;/li&gt;
&lt;li&gt;Broker 处理消息的时延。&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;如果是单线程发送，每次只发送 1 条消息，那么每秒只能发送 1000ms / 1ms * 1 条 /ms = 1000 条 消息，这种情况下并不能发挥出消息队列的全部实力。&lt;/p&gt;
&lt;p&gt;无论是增加每次发送消息的批量大小，还是增加并发，都能成倍地提升发送性能。至于到底是选择批量发送还是增加并发，主要取决于发送端程序的业务性质。简单来说，只要能够满足你的性能要求，怎么实现方便就怎么实现。&lt;/p&gt;
&lt;p&gt;比如说，你的消息发送端是一个微服务，主要接受 RPC 请求处理在线业务。很自然的，微服务在处理每次请求的时候，就在当前线程直接发送消息就可以了，因为所有 RPC 框架都是多线程支持多并发的，自然也就实现了并行发送消息。并且在线业务比较在意的是请求响应时延，选择批量发送必然会影响 RPC 服务的时延。这种情况，比较明智的方式就是通过并发来提升发送性能。&lt;/p&gt;
&lt;p&gt;如果你的系统是一个离线分析系统，离线系统在性能上的需求是什么呢？它不关心时延，更注重整个系统的吞吐量。发送端的数据都是来自于数据库，这种情况就更适合批量发送，你可以批量从数据库读取数据，然后批量来发送消息，同样用少量的并发就可以获得非常高的吞吐量。&lt;/p&gt;
&lt;h4 id=&#34;2-消费端性能优化&#34;&gt;2. 消费端性能优化&lt;/h4&gt;
&lt;p&gt;使用消息队列的时候，大部分的性能问题都出现在消费端，如果消费的速度跟不上发送端生产消息的速度，就会造成消息积压。如果这种性能倒挂的问题只是暂时的，那问题不大，只要消费端的性能恢复之后，超过发送端的性能，那积压的消息是可以逐渐被消化掉的。&lt;/p&gt;
&lt;p&gt;要是消费速度一直比生产速度慢，时间长了，整个系统就会出现问题，要么，消息队列的存储被填满无法提供服务，要么消息丢失，这对于整个系统来说都是严重故障。&lt;/p&gt;
&lt;p&gt;所以，我们在设计系统的时候，&lt;strong&gt;一定要保证消费端的消费性能要高于生产端的发送性能，这样的系统才能健康的持续运行。&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;消费端的性能优化除了优化消费业务逻辑以外，也可以通过水平扩容，增加消费端的并发数来提升总体的消费性能。特别需要注意的一点是，**在扩容 Consumer 的实例数量的同时，必须同步扩容主题中的分区（也叫队列）数量，确保 Consumer 的实例数和分区数量是相等的。**如果 Consumer 的实例数量超过分区数量，这样的扩容实际上是没有效果的。原因我们之前讲过，因为对于消费者来说，在每个分区上实际上只能支持单线程消费。&lt;/p&gt;
&lt;p&gt;我见到过很多消费程序，他们是这样来解决消费慢的问题的：&lt;/p&gt;
&lt;p&gt;&lt;img src=&#34;https://s2.loli.net/2023/06/12/sUeGAqKcj4Y2OZo.jpg&#34;
	
	
	
	loading=&#34;lazy&#34;
	
	
&gt;&lt;/p&gt;
&lt;p&gt;它收消息处理的业务逻辑可能比较慢，也很难再优化了，为了避免消息积压，在收到消息的 OnMessage 方法中，不处理任何业务逻辑，把这个消息放到一个内存队列里面就返回了。然后它可以启动很多的业务线程，这些业务线程里面是真正处理消息的业务逻辑，这些线程从内存队列里取消息处理，这样它就解决了单个 Consumer 不能并行消费的问题。&lt;/p&gt;
&lt;p&gt;这个方法是不是很完美地实现了并发消费？请注意，这是一个非常常见的错误方法！ 为什么错误？因为会丢消息。如果收消息的节点发生宕机，在内存队列中还没来及处理的这些消息就会丢失。&lt;/p&gt;
&lt;h3 id=&#34;消息积压了该如何处理&#34;&gt;消息积压了该如何处理？&lt;/h3&gt;
&lt;p&gt;能导致积压突然增加，最粗粒度的原因，只有两种：要么是发送变快了，要么是消费变慢了。&lt;/p&gt;
&lt;p&gt;大部分消息队列都内置了监控的功能，只要通过监控数据，很容易确定是哪种原因。如果是单位时间发送的消息增多，比如说是赶上大促或者抢购，短时间内不太可能优化消费端的代码来提升消费性能，唯一的方法是通过扩容消费端的实例数来提升总体的消费能力。&lt;/p&gt;
&lt;p&gt;如果短时间内没有足够的服务器资源进行扩容，没办法的办法是，将系统降级，通过关闭一些不重要的业务，减少发送方发送的数据量，最低限度让系统还能正常运转，服务一些重要业务。&lt;/p&gt;
&lt;p&gt;还有一种不太常见的情况，你通过监控发现，无论是发送消息的速度还是消费消息的速度和原来都没什么变化，这时候你需要检查一下你的消费端，是不是&lt;strong&gt;消费失败导致的一条消息反复消费&lt;/strong&gt;这种情况比较多，这种情况也会拖慢整个系统的消费速度。&lt;/p&gt;
&lt;p&gt;如果监控到消费变慢了，你需要检查你的消费实例，分析一下是什么原因导致消费变慢。优先检查一下日志是否有大量的消费错误，如果没有错误的话，可以通过打印堆栈信息，看一下你的&lt;strong&gt;消费线程&lt;/strong&gt;是不是卡在什么地方不动了，比如&lt;strong&gt;触发了死锁或者卡在等待某些资源&lt;/strong&gt;上了。&lt;/p&gt;
&lt;h3 id=&#34;小结-2&#34;&gt;小结&lt;/h3&gt;
&lt;p&gt;优化消息收发性能，预防消息积压的方法有两种，增加批量或者是增加并发，在发送端这两种方法都可以使用，在消费端需要注意的是，增加并发需要同步扩容分区数量，否则是起不到效果的。&lt;/p&gt;
&lt;p&gt;对于系统发生消息积压的情况，需要先解决积压，再分析原因，毕竟保证系统的可用性是首先要解决的问题。快速解决积压的方法就是通过水平扩容增加 Consumer 的实例数量。&lt;/p&gt;
&lt;h3 id=&#34;思考题-2&#34;&gt;思考题&lt;/h3&gt;
&lt;p&gt;在消费端是否可以通过批量消费的方式来提升消费性能？在什么样场景下适合使用这种方法？有什么局限性？&lt;/p&gt;
&lt;p&gt;评论解答：&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;
&lt;p&gt;批量消费有意义的场景要求：1.要么消费端对消息的处理支持批量处理，比如批量入库 2. 要么消费端支持多线程/协程并发处理，业务上也允许消息无序。3. 或者网络带宽在考虑因素内，需要减少消息的overhead。
批量消费的局限性：1. 需要一个整体ack的机制，一旦一条靠前的消息消费失败，可能会引起很多消息重试。2. 多线程下批量消费速度受限于最慢的那个线程。&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;1、要求消费端能够批量处理或者开启多线程进行单条处理
2、批量消费一旦某一条数据消费失败会导致整批数据重复消费
3、对实时性要求不能太高，批量消费需要Broker积累到一定消费数据才会发送到Consumer&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;1.无法提升消费业务效率（仅受消费业务自身逻辑影响），但可以提高mq中堆积消息消费的整体吞吐量（批推比单推mq耗时较短）。
2.数据增量同步，监控信息采集。（非核心业务的稳定大数据流操作）。
3.批处理意味数据积累和大数据传输，这会让单次消费的最长时延变长。同时批量操作为了保证当前批量操作一致性，在个别失败的情况下会引发批量操作重试。&lt;/p&gt;
&lt;/li&gt;
&lt;/ul&gt;
</description>
        </item>
        
    </channel>
</rss>
