<?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/tags/%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/tags/%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>
        
    </channel>
</rss>
