# 1 入门 ## 1.1 介绍 ### 什么是事件流? 事件流是人体中枢神经系统的数字等价物。它是“永远在线”世界的技术基础,在这个世界上,企业越来越多地由软件定义和自动化,软件的用户越来越多。 从技术上讲,事件流是以事件流的形式从数据库、传感器、移动设备、云服务和软件应用程序等事件源实时捕获数据的做法;持久地存储这些事件流以供以后检索;实时地以及回顾性地操纵、处理和响应事件流;以及根据需要将事件流路由到不同的目的地技术。因此,事件流可确保数据的连续流动和解释,从而使正确的信息在正确的时间、正确的地点出现。 ### 我能用事件流做什么? 事件流应用于众多行业和组织中的各种用例。它的许多例子包括: - 实时处理支付和金融交易,例如在证券交易所、银行和保险中。 - 实时跟踪和监控汽车、卡车、车队和货运,如物流和汽车行业。 - 持续捕获和分析来自物联网设备或其他设备(如工厂和风电场)的传感器数据。 - 收集客户互动和订单并立即做出反应,例如在零售、酒店和旅游业以及移动应用程序中。 - 监测医院护理中的患者并预测病情变化,以确保在紧急情况下及时治疗。 - 连接、存储并提供由公司不同部门生成的数据。 - 作为数据平台、事件驱动架构和微服务的基础。 ### Apache Kafka® 是一个事件流平台,这意味着什么? Kafka 结合了三个关键功能,因此您可以使用一个经过实战测试的解决方案来端到端地实现事件流的用例: - **发布**(写入)和**订阅**(读取)事件流,包括从其他系统连续导入/导出数据。 - 按您想要的时间长度将事件流持久可靠地**存储**。 - 在事件发生时或回顾性地**处理**事件流。 所有这些功能都是以分布式、高度可扩展、弹性、容错和安全的方式提供的。Kafka 可以部署在裸机硬件、虚拟机和容器上,也可以部署在本地和云中。您可以在自我管理 Kafka 环境和使用各种供应商提供的完全管理的服务之间进行选择。 ### 简而言之 Kafka 怎么工作? Kafka 是一个分布式系统,由**服务器**和**客户端**组成,通过高性能 TCP 网络协议进行通信。它可以部署在本地和云环境中的裸机硬件、虚拟机和容器上。 **服务器**:Kafka 作为一个或多个服务器的集群运行,这些服务器可以跨越多个数据中心或云区域。其中一些服务器形成了存储层,称为代理。其他服务器运行 Kafka Connect,将数据作为事件流不断导入和导出,以将 Kafka 与现有系统(如关系数据库和其他 Kafka 集群)集成。为了让您实现任务关键型用例,Kafka 集群具有高度可扩展性和容错性:如果它的任何服务器出现故障,其他服务器将接管它们的工作,以确保连续操作而不会丢失任何数据。 **客户端**:它们允许您编写分布式应用程序和微服务,即使在网络问题或机器故障的情况下,也可以并行、大规模、容错地读取、写入和处理事件流。Kafka 附带了一些这样的客户端,Kafka 社区提供的数十个客户端对这些客户端进行了扩展:对 Java 和 Scala (包括更高级别的 Kafka Streams 库)提供了客户端,以及对 Go、Python、C/C++ 和许多其他编程语言以及 REST API。 ### 主要概念和术语 一个**事件**记录了世界上或你的企业中“发生了一些事情”的事实。它在文档中也称为记录或消息。当你读或写数据到 Kafka 时,你是以事件的形式来完成的。从概念上讲,事件有一个键、值、时间戳和可选的元数据头。以下是一个示例事件: - 事件键:“Alice” - 事件值:“向 Bob 支付了 200 美元” - 事件时间戳:“2020 年 6 月 25 日下午 2:06” **生产者**(producers)是那些向 Kafka 发布(写入)事件的客户端应用程序,**消费者**(consumers)是那些订阅(读取和处理)这些事件的人。在 Kafka 中,生产者和消费者是完全解耦的,彼此不可知,这是实现 Kafka 众所周知的高可扩展性的关键设计元素。例如,生产商永远不需要等待消费者。Kafka 提供了各种保证,例如可以只处理一次事件。 事件按**主题**(topics)进行组织和持久存储。非常简单,主题类似于文件系统中的文件夹,事件是该文件夹中的文件。例如,主题名称可以是“支付”。Kafka 中的主题总是多生产者和多订阅者:一个主题可以有零个、一个或多个向其写入事件的生产者,也可以有零、一个、或多个订阅这些事件的消费者。可以根据需要随时读取主题中的事件。与传统的消息传递系统不同,使用后不会删除事件。相反,您可以通过每个主题的配置设置来定义 Kafka 应该保留事件多长时间,之后旧事件将被丢弃。Kafka 的性能在数据大小方面实际上是恒定的,因此长时间存储数据是非常好的。 主题是**分区**的,这意味着一个主题分布在位于不同 Kafka 代理上的多个“桶”上。这种数据的分布式放置对于可扩展性非常重要,因为它允许客户端应用程序同时从多个代理读取数据和向多个代理写入数据。当一个新事件发布到一个主题时,它实际上被附加到该主题的一个分区。具有相同事件密钥(例如,客户或车辆 ID)的事件被写入同一分区,Kafka 保证给定主题分区的任何消费者将始终以与写入时完全相同的顺序读取该分区的事件。 为了使您的数据具有容错性和高可用性,每个主题都可以**复制**,甚至可以跨地理区域或数据中心复制,这样总是有多个代理拥有一份数据副本,以防出现问题,您需要对代理进行维护,等等。常见的生产设置是复制因子为 3,即始终有三份数据副本。此复制是在主题分区级别执行的。 这些基础应该足够入门了。如果你感兴趣的话,文档的设计部分会详细解释 Kafka 的各种概念。 ### Kafka APIs 除了用于管理和管理任务的命令行工具外,Kafka 还有五个用于 Java 和 Scala 的核心 API: - **Admin API** 用于管理和检查主题、代理和其他 Kafka 对象的。 - **Producer API**,用于向一个或多个 Kafka 主题发布(编写)事件流。 - **Consumer API**,用于订阅(读取)一个或多个主题并处理为其生成的事件流。 - **Kafka Streams API**,用于实现流处理应用程序和微服务。它提供了更高级别的功能来处理事件流,包括转换、聚合和联接等有状态操作、窗口化、基于事件时间的处理等等。从一个或多个主题读取输入,以便生成到一个或更多主题的输出,从而有效地将输入流转换为输出流。 - **Kafka Connect API** 用于构建和运行可重复使用的数据导入/导出连接器,这些连接器从外部系统和应用程序消耗(读取)或生成(写入)事件流,以便与 Kafka 集成。例如,像 PostgreSQL 这样的关系数据库的连接器可能会捕获对一组表的每一次更改。然而,在实践中,您通常不需要实现自己的连接器,因为 Kafka 社区已经提供了数百个现成的连接器。 ### 下一步 - 要获得使用 Kafka 的亲身体验,请遵循快速入门。 - 要更详细地理解 Kafka,请阅读文档。你也可以选择 Kafka 的书籍和学术论文。 - 浏览用例,了解我们全球社区中的其他用户是如何从 Kafka 中获得价值的。 - 加入当地的 Kafka meetup 小组,观看 Kafka 社区的主要会议 Kafka Summit 的演讲。 ## 1.2 使用示例 以下是 Apache Kafka® 的一些流行用例的描述。有关这些领域的概况,请参阅这篇博客文章。 ### 消息 Kafka 可以很好地替代更传统的消息代理。消息代理的使用有多种原因(将处理与数据生产者解耦,缓冲未处理的消息等)。与大多数消息系统相比,Kafka 具有更好的吞吐量、内置分区、复制和容错能力,这使其成为大规模消息处理应用程序的良好解决方案。 根据我们的经验,消息传递的使用通常吞吐量相对较低,但可能需要较低的端到端延迟,并且通常依赖于 Kafka 提供的强大持久性保证。 在这个领域,Kafka 可以与 ActiveMQ 或 RabbitMQ 等传统消息传递系统相媲美。 ### 网站活动跟踪 Kafka 最初的用例是能够将用户活动跟踪管道重建为一组实时发布订阅提要。这意味着网站活动(页面视图、搜索或用户可能采取的其他操作)发布到中心主题,每个活动类型有一个主题。这些提要可用于订阅一系列用例,包括实时处理、实时监控,以及加载到 Hadoop 或离线数据仓库系统中进行离线处理和报告。 由于每个用户页面视图都会生成许多活动消息,因此活动跟踪的量通常非常大。 ### 测量指标 Kafka 通常用于操作监控数据。这涉及到聚合来自分布式应用程序的统计数据,以生成操作数据的集中式提要。 ### 日志聚合 许多人使用 Kafka 作为日志聚合解决方案的替代品。日志聚合通常从服务器上收集物理日志文件,并将它们放在中心位置(可能是文件服务器或 HDFS)进行处理。Kafka 抽象掉了文件的细节,并将日志或事件数据作为消息流进行了更干净的抽象。这允许更低的延迟处理,更容易支持多个数据源和分布式数据消耗。与 Scribe 或 Flume 等以日志为中心的系统相比,Kafka 提供了同样好的性能、更强的复制耐用性保证,以及更低的端到端延迟。 ### 流处理 许多 Kafka 用户在由多个阶段组成的处理管道中处理数据,在这些管道中,原始输入数据从 Kafka 主题中消耗,然后聚合、丰富或以其他方式转换为新主题,以供进一步消耗或后续处理。例如,推荐新闻文章的处理管道可能会从 RSS 提要中抓取文章内容,并将其发布到“文章”主题;进一步的处理可以对该内容进行标准化或消除重复,并将净化后的文章内容发布到新的主题;最后的处理阶段可能会尝试向用户推荐此内容。这样的处理管道基于各个主题创建实时数据流的图。从 0.10.0.0 开始,Apache Kafka 中提供了一个轻量级但功能强大的流处理库 Kafka Streams,用于执行如上所述的数据处理。除了 Kafka Streams 之外,其他开源流处理工具还包括 Apache Storm 和 Apache Samza。 ### 事件溯源 事件源是一种应用程序设计风格,其中状态更改记录为按时间顺序排列的记录序列。Kafka 对非常大的存储日志数据的支持使其成为以这种风格构建的应用程序的优秀后端。 ### 提交日志 Kafka 可以作为分布式系统的一种外部提交日志。日志有助于在节点之间复制数据,并作为故障节点恢复数据的重新同步机制。Kafka 中的日志压缩功能有助于支持这种使用。在这个用法中,Kafka 类似于 Apache BookKeeper 项目。 ## 1.3 快速开始 ### 第一步:获取 Kafka 下载最新 Kafka 发行版并解压: ```shell $ tar -xzf kafka_2.13-3.4.0.tgz $ cd kafka_2.13-3.4.0 ``` ### 第二步:启动 Kafka 环境 > 注意:您的本地环境必须安装Java 8+。 Apache Kafka 可以使用 ZooKeeper 或 KRaft 启动。要开始使用任一配置,请按照下面的一节进行操作,但不能同时使用这两节。 #### 使用 ZooKeeper 的 Kafka 运行以下命令以按正确顺序启动所有服务: ```shell # Start the ZooKeeper service $ bin/zookeeper-server-start.sh config/zookeeper.properties ``` 打开另一个终端会话并运行: ```shell # Start the Kafka broker service $ bin/kafka-server-start.sh config/server.properties ``` 一旦所有服务都成功启动,您将有一个基本的 Kafka 环境在运行并准备使用。 #### 使用 KRaft 的 Kafka 生成集群 UUID ```shell $ KAFKA_CLUSTER_ID="$(bin/kafka-storage.sh random-uuid)" ``` 格式化日志目录 ```shell $ bin/kafka-storage.sh format -t $KAFKA_CLUSTER_ID -c config/kraft/server.properties ``` 启动 Kafka 服务器 ```shell $ bin/kafka-server-start.sh config/kraft/server.properties ``` 一旦 Kafka 服务器成功启动,您将有一个基本的 Kafka 环境在运行并准备使用。 ### 第三步:创建一个存储你的事件的主题 Kafka 是一个分布式事件流平台,允许您在许多机器上读取、写入、存储和处理事件(在文档中也称为记录或消息)。 示例事件包括支付交易、手机的地理位置更新、运输订单、物联网设备或医疗设备的传感器测量等。这些事件按主题进行组织和存储。非常简单,主题类似于文件系统中的文件夹,事件是该文件夹中的文件。 因此,在编写第一个事件之前,您必须创建一个主题。打开另一个终端会话并运行: ```shell $ bin/kafka-topics.sh --create --topic quickstart-events --bootstrap-server localhost:9092 ``` Kafka 的所有命令行工具都有额外的选项:在没有任何参数的情况下运行 `kafka-topics.sh` 命令来显示使用信息。例如,它还可以向您显示新主题的分区计数等详细信息: ```shell $ bin/kafka-topics.sh --describe --topic quickstart-events --bootstrap-server localhost:9092 Topic: quickstart-events TopicId: NPmZHyhbR9y00wMglMH2sg PartitionCount: 1 ReplicationFactor: 1 Configs: Topic: quickstart-events Partition: 0 Leader: 0 Replicas: 0 Isr: 0 ``` ### 第四步:将一些事件写进主题 Kafka 客户端通过网络与 Kafka 代理进行通信,以编写(或读取)事件。一旦收到,代理将以持久和容错的方式存储事件,直到您需要,甚至永远。 运行控制台生成器客户端,在您的主题中写入一些事件。默认情况下,您输入的每一行都将导致一个单独的事件写入主题。 ```shell $ bin/kafka-console-producer.sh --topic quickstart-events --bootstrap-server localhost:9092 This is my first event This is my second event ``` 您可以随时使用 `Ctrl-C` 停止生产者客户端。 ### 第五步:读事件 打开另一个终端会话并运行控制台使用者客户端以读取您刚刚创建的事件: ```shell $ bin/kafka-console-consumer.sh --topic quickstart-events --from-beginning --bootstrap-server localhost:9092 This is my first event This is my second event ``` 您可以随时使用 `Ctrl-C` 停止消费者客户端。 请随意尝试:例如,切换回生产者终端(前一步)以编写其他事件,并查看事件如何立即显示在消费者终端中。 因为事件是持久存储在 Kafka 中的,所以它们可以被任意多次读取,也可以被任意多的消费者读取。您可以通过打开另一个终端会话并再次运行上一个命令来轻松验证这一点。 ### 第六步:使用 Kafka Connect 将数据导入/导出为事件流 您可能在现有的系统(如关系数据库或传统消息传递系统)中拥有大量数据,以及许多已经使用这些系统的应用程序。Kafka Connect 允许您不断地将数据从外部系统摄取到 Kafka 中,反之亦然。它是一个可扩展的工具,运行连接器,实现与外部系统交互的自定义逻辑。因此,将现有系统与 Kafka 集成是非常容易的。为了使这一过程更加容易,有数百个这样的连接器可供选择。 在这个快速入门中,我们将看到如何使用简单的连接器运行 Kafka Connect,这些连接器将数据从文件导入到 Kafka 主题,并将数据从 Kafka 话题导出到文件。 首先,确保将 `connect-file-3.4.0.jar` 添加到 Connect 工作程序配置中的 `plugin.path` 属性中。为了实现这个快速启动,我们将使用一个相对路径,并将连接器的包视为一个 uber jar,当从安装目录运行快速启动命令时,它就可以工作。然而,值得注意的是,对于生产部署,使用绝对路径总是更可取的。有关如何设置此配置的详细描述,请参见 plugin.path。 编辑 `config/connect-standalone.properties` 文件,添加或更改与以下内容匹配的 `plugin.path` 配置属性,然后保存该文件: ```shell > echo "plugin.path=libs/connect-file-3.4.0.jar" ``` 然后,首先创建一些种子数据进行测试: ```shell > echo -e "foo\nbar" > test.txt ``` 或者在 Windows 上: ```shell > echo foo> test.txt > echo bar>> test.txt ``` 接下来,我们将启动两个以独立模式运行的连接器,这意味着它们在单个本地专用进程中运行。我们提供三个配置文件作为参数。第一个始终是 Kafka Connect 进程的配置,包含常见的配置,如要连接的 Kafka 代理和数据的序列化格式。其余的配置文件分别指定要创建的连接器。这些文件包括唯一的连接器名称、要实例化的连接器类以及连接器所需的任何其他配置。 ```shell > bin/connect-standalone.sh config/connect-standalone.properties config/connect-file-source.properties config/connect-file-sink.properties ``` Kafka 中包含的这些示例配置文件使用您之前启动的默认本地集群配置,并创建了两个连接器:第一个是源连接器,它从输入文件中读取行,并将每个行生成到 Kafka 主题;第二个是接收连接器,它读取 Kafka 话题中的消息,并将每一行生成为输出文件中的行。 在启动过程中,您将看到许多日志消息,其中包括一些指示连接器正在实例化的消息。一旦 Kafka Connect 进程启动,源连接器应该开始从 `test.txt` 中读取行并将其生成到主题 `connect-test`,而接收器连接器应该开始读取主题 `connect-test` 中的消息并将其写入文件 `test.sink.txt`。我们可以通过检查输出文件的内容来验证数据是否已通过整个管道传递: ```shell > more test.sink.txt foo bar ``` 请注意,数据存储在 Kafka 主题 `connect-test` 中,因此我们也可以运行控制台消费者来查看主题中的数据(或使用自定义消费者代码来处理它): ```shell > bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic connect-test --from-beginning {"schema":{"type":"string","optional":false},"payload":"foo"} {"schema":{"type":"string","optional":false},"payload":"bar"} ... ``` 连接器继续处理数据,因此我们可以将数据添加到文件中,并看到它在管道中移动: ```shell > echo Another line>> test.txt ``` 您应该在控制台使用者输出和接收器文件中看到这一行。 ### 第七步:使用 Kafka Streams 处理您的事件 一旦您的数据作为事件存储在 Kafka 中,您就可以使用 Java / Scala 的 Kafka Streams 客户端库来处理数据。它允许您实现关键任务实时应用程序和微服务,其中输入和/或输出数据存储在 Kafka 主题中。Kafka Streams 将在客户端编写和部署标准 Java 和 Scala 应用程序的简单性与 Kafka 服务器端集群技术的优势相结合,使这些应用程序具有高度可扩展性、弹性、容错性和分布式。该库支持一次处理、有状态操作和聚合、窗口化、联接、基于事件时间的处理等等。 为了让您第一次体验,以下是如何实现流行的 `WordCount` 算法: ```java KStream textLines = builder.stream("quickstart-events"); KTable wordCounts = textLines .flatMapValues(line -> Arrays.asList(line.toLowerCase().split(" "))) .groupBy((keyIgnored, word) -> word) .count(); wordCounts.toStream().to("output-topic", Produced.with(Serdes.String(), Serdes.Long())); ``` Kafka Streams 演示和应用程序开发教程演示了如何从头到尾编写和运行这样一个流应用程序。 ### 第八步:关闭 Kafka 环境 现在你已经到了快速启动的终点,可以随意拆除 Kafka 环境或继续玩。 - 使用 `Ctrl-C` 停止生产者和消费者客户端,如果您还没有这样做的话。 - 使用 `Ctrl-C` 停止 Kafka 代理(broker)。 - 最后,如果遵循用 ZooKeeper 的 Kafka 部分,则使用 `Ctrl-C` 停止 ZooKeeper 服务器。 如果您还想删除本地 Kafka 环境的任何数据,包括在此过程中创建的任何事件,请运行以下命令: ```shell $ rm -rf /tmp/kafka-logs /tmp/zookeeper /tmp/kraft-combined-logs ``` ### 恭喜! 您已成功完成 Apache Kafka 快速启动。 要了解更多信息,我们建议采取以下步骤: - 阅读简介,了解 Kafka 是如何在高水平上工作的,它的主要概念,以及它与其他技术的比较。要更详细地理解 Kafka,请参阅文档。 - 浏览用例,了解我们全球社区中的其他用户是如何从 Kafka 中获得价值的。 - 加入当地的 Kafka meetup 小组,观看 Kafka 社区的主要会议 Kafka Summit 的演讲。 ## 1.4 生态系统 在主要发行版之外,有很多工具可以与 Kafka 集成。生态系统页面列出了其中的许多,包括流处理系统、Hadoop 集成、监控和部署工具。 ## 1.5 从以前的版本升级 # 2 API Kafka 包括五个核心 api: - Producer API 允许应用程序向 Kafka 集群中的主题发送数据流。 - Consumer API 允许应用程序从 Kafka 集群中的主题读取数据流。 - Streams API 允许将数据流从输入主题转换为输出主题。 - Connect API 允许实现连接器,这些连接器可以不断地从某个源系统或应用程序拉入 Kafka,或从 Kafka 推送到某个接收系统或应用。 - Admin API 允许管理和检查主题、代理和其他 Kafka 对象。 Kafka 通过一个独立于语言的协议公开了它的所有功能,该协议有许多编程语言的客户端。然而,只有 Java 客户端作为主要 Kafka 项目的一部分进行维护,其他客户端则作为独立的开源项目提供。此处提供了非 Java 客户端的列表。 ## 2.1 Producer API Producer API 允许应用程序向 Kafka 集群中的主题发送数据流。 javadocs 中给出了显示如何使用生产者的示例。 要使用生产者,可以使用以下 maven 依赖项: ```xml org.apache.kafka kafka-clients 3.4.0 ``` ## 2.2 Consumer API Consumer API 允许应用程序从 Kafka 集群中的主题读取数据流。 javadocs 中给出了显示如何使用消费者的示例。 要使用使用者,可以使用以下 maven 依赖项: ```xml org.apache.kafka kafka-clients 3.4.0 ``` ## 2.3 Streams API Streams API 允许将数据流从输入主题转换为输出主题。 javadocs 中给出了显示如何使用此库的示例 此处提供了有关使用 Streams API 的其他文档。 要使用 Kafka Streams,您可以使用以下 maven 依赖项: ```xml org.apache.kafka kafka-streams 3.4.0 ``` 当使用 Scala 时,您可以选择包含 `kafka-streams-scala` 库。关于使用 Kafka Streams DSL for Scala 的其他文档可在开发人员指南中找到。 对于 Scala 2.13 要使用 Kafka Streams DSL for Scala,您可以使用以下 maven 依赖项: ```xml org.apache.kafka kafka-streams-scala_2.13 3.4.0 ``` ## 2.4 Connect API Connect API 允许实现连接器,这些连接器可以不断地从某些源数据系统拉入 Kafka,或从 Kafka 推送到某些汇点数据系统。 Connect 的许多用户不需要直接使用此 API,但他们可以使用预构建的连接器,而无需编写任何代码。此处提供了有关使用 Connect 的其他信息。 那些想要实现自定义连接器的人可以看到 javadoc。 ## 2.5 Admin API Admin API 支持管理和检查主题、代理、acl 和其他 Kafka 对象。 要使用 Admin API,请添加以下 Maven 依赖项: ```xml org.apache.kafka kafka-clients 3.4.0 ``` 有关 Admin API 的更多信息,请参阅 javadoc。 # 3 配置 Kafka 使用属性文件格式中的键值对进行配置。这些值可以从文件中提供,也可以通过编程方式提供。 ## 3.1 Broker 配置 基本配置如下: - `broker.id` - `log.dirs` - `zookeeper.connect` 下面将更详细地讨论主题级别的配置和默认设置。 # 4 设计 ## 4.1 动机 我们将 Kafka 设计为能够作为一个统一的平台来处理大型公司可能拥有的所有实时数据源。要做到这一点,我们必须考虑一组相当广泛的用例。 它必须具有高吞吐量才能支持大容量事件流,例如实时日志聚合。 它需要优雅地处理大量的数据积压,才能支持离线系统的定期数据加载。 这也意味着系统必须处理低延迟交付,以处理更传统的消息传递用例。 我们希望支持对这些提要进行分区、分布式、实时处理,以创建新的派生提要。这激发了我们的分区和消费者模型。 最后,在流被馈送到其他数据系统中进行服务的情况下,我们知道该系统必须能够保证在存在机器故障的情况下的容错性。 支持这些用途使我们获得了一种具有许多独特元素的设计,与传统的消息传递系统相比,它更类似于数据库日志。我们将在以下章节中概述设计的一些元素。 ## 4.2 持久化 ### 不要害怕文件系统! Kafka 在很大程度上依赖于文件系统来存储和缓存消息。人们普遍认为“磁盘很慢”,这让人们对持久结构能否提供有竞争力的性能持怀疑态度。事实上,磁盘的速度比人们预期的要慢得多,也快得多,这取决于它们的使用方式;并且适当设计的磁盘结构通常可以与网络一样快。 关于磁盘性能的关键事实是,在过去十年中,硬盘驱动器的吞吐量一直与磁盘查找的延迟不同。因此,在具有六个 7200rpm SATA RAID-5 阵列的 JBOD 配置上,线性写入的性能约为 600MB/秒,但随机写入的性能仅约为 100k/秒,相差超过 6000X。这些线性读写是所有使用模式中最可预测的,并且由操作系统进行了大量优化。现代操作系统提供了预读和写后技术,以大的块倍数预取数据,并将较小的逻辑写入分组为较大的物理写入。关于这个问题的进一步讨论可以在这篇 ACM 队列文章中找到;他们实际上发现,在某些情况下,顺序磁盘访问可能比随机内存访问更快! 为了弥补这种性能差异,现代操作系统在使用主存进行磁盘缓存方面变得越来越激进。现代操作系统很乐意将所有可用内存转移到磁盘缓存,而在回收内存时几乎不会对性能造成影响。所有的磁盘读取和写入都将通过这个统一的缓存。如果不使用直接I/O,就无法轻松关闭此功能,因此即使进程维护数据的进程内缓存,这些数据也可能在操作系统页面缓存中重复,从而有效地将所有内容存储两次。 此外,我们是在 JVM 之上构建的,任何花过 Java 内存使用时间的人都知道两件事: 1. 对象的内存开销非常高,通常会使存储的数据大小增加一倍(甚至更糟)。 2. 随着堆内数据的增加,Java 垃圾收集变得越来越繁琐和缓慢。 由于这些因素,使用文件系统和依赖页面缓存优于维护内存中的缓存或其他结构,我们通过自动访问所有空闲内存,至少将可用缓存增加了一倍,并且通过存储紧凑的字节结构而不是单个对象,可能会再次增加一倍。这样做将在 32GB 的机器上产生高达 28-30GB 的缓存,而不会受到 GC 惩罚。此外,即使重新启动服务,该缓存也会保持温暖,而进程中的缓存将需要在内存中重建(对于 10GB 的缓存来说,这可能需要 10 分钟),否则它将需要从完全冷的缓存开始(这可能意味着糟糕的初始性能)。这也极大地简化了代码,因为用于维护缓存和文件系统之间一致性的所有逻辑现在都在操作系统中,这往往比一次性的进程内尝试更有效、更正确。如果您的磁盘使用倾向于线性读取,那么预读实际上是在每次读取磁盘时用有用的数据预先填充这个缓存。 这表明了一种非常简单的设计:当我们的空间用完时,我们不是在内存中维护尽可能多的内存,并在恐慌中将其全部清空到文件系统,而是将其颠倒过来。所有数据都会立即写入文件系统上的持久日志,而不必刷新到磁盘。实际上,这只是意味着它被转移到内核的页面缓存中。 在一篇关于 Varnish 设计的文章中描述了这种以页面缓存为中心的设计风格(同时也有一种健康程度的傲慢)。 ### 恒定时间条件 消息传递系统中使用的持久数据结构通常是具有相关联的 BTree 或其他通用随机访问数据结构的每个消费者队列,以维护关于消息的元数据。BTrees 是可用的最通用的数据结构,可以在消息传递系统中支持各种各样的事务性和非事务性语义。不过,它们的成本确实相当高:Btree 操作是 O(log N)。通常,O(log N) 被认为本质上等同于恒定时间,但对于磁盘操作来说却不是这样。磁盘寻道速度为 10ms,每个磁盘一次只能进行一次寻道,因此并行性有限。因此,即使是少量的磁盘寻道也会导致非常高的开销。由于存储系统将非常快的缓存操作与非常慢的物理磁盘操作混合在一起,因此随着数据随着固定缓存的增加,观察到的树结构的性能通常是超线性的——即,将数据翻倍会使速度慢一倍以上。 直观地说,持久队列可以建立在简单的读取和附加到文件上,就像日志记录解决方案通常的情况一样。这种结构的优点是所有操作都是 O(1),并且读取不阻塞写入或彼此阻塞。这具有明显的性能优势,因为性能与数据大小完全脱钩——一台服务器现在可以充分利用许多廉价、低转速的 1+TB SATA 驱动器。尽管它们的寻道性能很差,但这些驱动器对于大型读写具有可接受的性能,而且价格是其三分之一,容量是其三倍。 可以访问几乎无限的磁盘空间而不会受到任何性能损失,这意味着我们可以提供一些消息系统中通常找不到的功能。例如,在 Kafka 中,我们可以将消息保留相对较长的时间(比如一周),而不是试图在消息被消费后立即删除。正如我们将要描述的那样,这为消费者带来了很大的灵活性。 ## 4.3 效率 我们在效率方面付出了巨大努力。我们的主要用例之一是处理 web 活动数据,这是一个非常高的量:每个页面视图可能会生成几十个写入。此外,我们假设发布的每条消息至少有一个消费者(通常是多个)阅读,因此我们努力使消费尽可能便宜。 我们还发现,从构建和运行许多类似系统的经验来看,效率是有效的多租户运营的关键。如果下游基础设施服务很容易因应用程序使用量的小幅增加而成为瓶颈,那么这种微小的更改往往会带来问题。通过非常快速,我们有助于确保应用程序在基础架构之前在负载不足的情况下翻转。当试图在集中式集群上运行支持数十或数百个应用程序的集中式服务时,这一点尤为重要,因为使用模式的变化几乎每天都会发生。 我们在上一节中讨论了磁盘效率。一旦消除了糟糕的磁盘访问模式,就有两个常见的原因导致这种类型的系统效率低下:太多的小 I/O 操作和过多的字节复制。 小的 I/O 问题既发生在客户端和服务器之间,也发生在服务器自己的持久操作中。 为了避免这种情况,我们的协议是围绕“消息集”抽象构建的,该抽象将消息自然地分组在一起。这允许网络请求将消息分组在一起,并分摊网络往返的开销,而不是一次发送单个消息。反过来,服务器一次性将消息块附加到其日志中,消费者一次获取大型线性块。 这种简单的优化产生了数量级的加速。批处理会导致更大的网络数据包、更大的顺序磁盘操作、连续的内存块等等,所有这些都允许 Kafka 将突发的随机消息写入流转换为流向消费者的线性写入。 另一个低效率是字节复制。在低消息速率下,这不是一个问题,但在负载下,影响是显著的。为了避免这种情况,我们采用了一种标准化的二进制消息格式,由生产者、经纪人和消费者共享(因此数据块可以在它们之间进行无需修改的传输)。 代理维护的消息日志本身只是一个文件目录,每个文件由一系列消息集填充,这些消息集以生产者和消费者使用的相同格式写入磁盘。维护这种通用格式可以优化最重要的操作:持久日志块的网络传输。现代 unix 操作系统提供了一个高度优化的代码路径,用于将数据从页面缓存传输到套接字;在 Linux 中,这是通过 sendfile 系统调用完成的。 为了理解 sendfile 的影响,了解从文件到套接字传输数据的通用数据路径很重要: 1. 操作系统将数据从磁盘读取到内核空间中的页面缓存中 2. 应用程序将数据从内核空间读取到用户空间缓冲区 3. 应用程序将数据写回内核空间中的套接字缓冲区 4. 操作系统将数据从套接字缓冲区复制到 NIC 缓冲区,并在其中通过网络发送 这显然效率低下,有四个副本和两个系统调用。使用 sendfile,通过允许操作系统将数据从页面缓存直接发送到网络,可以避免这种重新复制。因此,在这个优化的路径中,只需要 NIC 缓冲区的最终副本。 我们期望一个常见的用例是一个主题上的多个消费者。使用上面的零拷贝优化,数据被精确地拷贝到页面缓存中一次,并在每次消耗时重复使用,而不是存储在内存中,每次读取时都被拷贝到用户空间。这允许以接近网络连接限制的速率消耗消息。 pagecache 和 sendfile 的这种组合意味着,在消费者主要被占用的 Kafka 集群上,您将不会看到磁盘上的任何读取活动,因为它们将完全从缓存中提供数据。 TLS/SSL 库在用户空间操作(在内核中,`SSL_sendfile` 当前不受 Kafka 支持)。由于此限制,启用 SSL 时不使用 `sendfile`。有关启用 SSL 配置的信息,请参阅 `security.procol` 和 `security.inter.broker.procol` 有关 Java 中 sendfile 和零拷贝支持的更多背景信息,请参阅本文。 ### 端到端批压缩 在某些情况下,瓶颈实际上不是 CPU 或磁盘,而是网络带宽。对于需要通过广域网在数据中心之间发送消息的数据管道来说尤其如此。当然,用户总是可以一次压缩一条消息,而不需要 Kafka 的任何支持,但这可能会导致压缩率非常低,因为大部分冗余是由于相同类型的消息之间的重复(例如 JSON 中的字段名或 web 日志中的用户代理或公共字符串值)。高效的压缩需要将多条消息压缩在一起,而不是单独压缩每条消息。 Kafka 通过一种高效的批处理格式支持这一点。可以将一批消息压缩在一起,并以此形式发送到服务器。这批消息将以压缩形式写入,并将在日志中保持压缩状态,并且仅由使用者解压缩。 Kafka 支持 GZIP、Snappy、LZ4 和 ZStandard 压缩协议。有关压缩的更多详细信息,请点击此处。 ## 4.4 生产者 ### 负载均衡 生产者将数据直接发送到作为分区领导者的代理,而不需要任何中间的路由层。为了帮助生产者做到这一点,所有 Kafka 节点都可以回答对元数据的请求,即在任何给定时间,哪些服务器是活动的,以及主题分区的领导者在哪里,以允许生产者适当地引导其请求。 客户端控制向哪个分区发布消息。这可以随机完成,实现一种随机负载平衡,也可以通过一些语义分区功能来完成。我们通过允许用户指定分区键并使用该键对分区进行哈希来公开语义分区接口(如果需要,还可以选择覆盖分区函数)。例如,如果选择的密钥是用户 id,那么给定用户的所有数据都将发送到同一分区。这反过来将允许消费者对他们的消费做出地区性假设。这种类型的分区被明确地设计为允许在消费者中进行位置敏感的处理。 ### 异步发送 批处理是效率的主要驱动因素之一,为了实现批处理,Kafka 生产者将尝试在内存中积累数据,并在单个请求中发送更大的批处理。批处理可以被配置为累积不超过固定数量的消息,并且等待不超过某个固定延迟界限(例如 64k 或 10ms)。这允许累积更多要发送的字节,并且在服务器上很少有更大的 I/O 操作。这种缓冲是可配置的,并提供了一种机制来权衡少量额外的延迟以获得更好的吞吐量。 关于生产者的配置和 api 的详细信息可以在文档的其他地方找到。 ## 4.5 消费者 Kafka 使用者通过向引导其想要消费的分区的代理发出“fetch”请求来工作。使用者通过每个请求指定其在日志中的偏移量,并从该位置开始接收日志的一块。因此,消费者对这个位置有很大的控制权,如果需要,可以将其倒带以重新消费数据。 ### 推 vs. 拉 我们最初考虑的一个问题是,消费者是应该从经纪人那里获取数据,还是经纪人应该将数据推送给消费者。在这方面,Kafka 遵循了一种更传统的设计,大多数消息系统都共享这种设计,即数据从生产者推送到代理,然后由消费者从代理中提取。一些以日志记录为中心的系统,如 Scribe 和 Apache Flume,采用了一种非常不同的基于推送的路径,将数据推送到下游。这两种方法都有利弊。然而,基于推送的系统很难处理不同的消费者,因为代理控制数据传输的速率。目标通常是消费者能够以最大可能的速率进行消费;不幸的是,在推送系统中,这意味着当消费者的消费率低于生产率时,消费者往往会不堪重负(本质上是一种拒绝服务攻击)。基于拉动的系统具有更好的特性,即消费者只是落后,并在可能的时候赶上。这可以通过某种退避协议来缓解,消费者可以通过这种退避协议指示自己不堪重负,但要让传输速率充分利用(但永远不要过度利用)消费者比看起来更难。以前尝试以这种方式构建系统,导致我们采用了更传统的拉取模式。 基于拉取的系统的另一个优点是,它有助于对发送给消费者的数据进行积极的批处理。基于推送的系统必须选择立即发送请求,或者积累更多数据,然后再发送,而不知道下游消费者是否能够立即处理它。如果调整为低延迟,这将导致一次发送一条消息,但最终传输会被缓冲,这是浪费。基于拉取的设计解决了这一问题,因为消费者总是在其在日志中的当前位置之后拉取所有可用消息(或最大可配置大小)。因此,可以在不引入不必要的延迟的情况下获得最佳批处理。 天真的基于拉取的系统的不足之处在于,如果代理没有数据,消费者可能会在一个紧密的循环中进行轮询,实际上忙于等待数据到达。为了避免这种情况,我们在拉取请求中有一些参数,允许消费者请求在“长轮询”中进行阻塞,等待数据到达(也可以选择等待,直到有给定数量的字节可用,以确保大的传输大小)。 你可以想象其他可能的设计,它们只能是拉式的,端到端的。生产者会在本地写入本地日志,经纪人会从中提取,消费者也会从中取出。通常会提出类似类型的“存储转发”生产者。这很有趣,但我们觉得不太适合我们有数千个生产商的目标用例。我们大规模运行持久性数据系统的经验让我们觉得,在许多应用程序中使用系统中的数千个磁盘实际上不会使事情变得更可靠,而且操作起来会是一场噩梦。在实践中,我们发现我们可以大规模运行具有强大 SLA 的管道,而不需要生产者持久性。 ### 消费者位置 令人惊讶的是,跟踪所消耗的内容是消息传递系统的关键性能点之一。 大多数消息传递系统都保存有关代理上使用了哪些消息的元数据。也就是说,当消息被分发给消费者时,代理要么立即在本地记录这一事实,要么等待消费者的确认。这是一个相当直观的选择,事实上,对于单机服务器来说,还不清楚这种状态还能去哪里。由于许多消息传递系统中用于存储的数据结构扩展性较差,这也是一个实用的选择——因为代理知道消耗了什么,所以可以立即删除它,从而保持数据大小较小。 也许不明显的是,让经纪人和消费者就消费达成一致并不是一个微不足道的问题。如果代理每次通过网络发送消息时都会立即将消息记录为**已消费**,那么如果消费者未能处理该消息(比如因为消息崩溃或请求超时或其他原因),则该消息将丢失。为了解决这个问题,许多消息传递系统添加了一个确认功能,这意味着消息在发送时只标记为**已发送**,而不是**已消费**;代理等待来自消费者的特定确认,以将消息记录为**已消费**。此策略解决了丢失消息的问题,但也产生了新的问题。首先,如果消费者处理了消息,但在发送确认之前失败了,那么消息将被消费两次。第二个问题是性能问题,现在代理必须为每条消息保留多个状态(首先锁定它,这样它就不会第二次发出,然后将它标记为永久消耗,这样它才能被删除)。必须处理一些棘手的问题,比如如何处理发送但从未被确认的消息。 Kafka 对此的处理方式有所不同。我们的主题被划分为一组完全有序的分区,每个分区在任何给定时间都由每个订阅消费者组中的一个消费者使用。这意味着消费者在每个分区中的位置只是一个整数,即下一个要消费的消息的偏移量。这使得消耗的状态非常小,每个分区只有一个数字。此状态可以定期进行检查点检查。这使得消息确认的等价物非常便宜。 这一决定有一个附带的好处。使用者可以故意倒回旧的偏移量并重新使用数据。这违反了队列的通用约定,但事实证明这是许多消费者的一个基本功能。例如,如果使用者代码有一个错误,并且是在消费了一些消息后发现的,那么一旦修复了错误,使用者就可以重新消费这些消息。 ### 离线数据负载 可扩展的持久性允许消费者只定期消费,例如批量数据加载,定期将数据批量加载到 Hadoop 或关系数据仓库等离线系统中。 在 Hadoop 的情况下,我们通过将负载拆分到各个映射任务上来并行化数据负载,每个映射任务对应一个节点/主题/分区组合,从而实现负载的完全并行。Hadoop 提供了任务管理,失败的任务可以重新启动,而不会有重复数据的危险——它们只需从原始位置重新启动。 ### 静态成员 静态成员资格旨在提高流应用程序、消费者组和其他基于组再平衡协议构建的应用程序的可用性。再平衡协议依赖于组协调器为组成员分配实体 ID。这些生成的 id 是短暂的,并且在成员重新启动和重新加入时会发生更改。对于基于消费者的应用程序,这种“动态成员身份”可能会导致在代码部署、配置更新和定期重新启动等管理操作期间,将很大比例的任务重新分配给不同的实例。对于大型状态应用程序,打乱的任务需要很长时间才能在处理之前恢复其本地状态,并导致应用程序部分或完全不可用。受此启发,Kafka 的组管理协议允许组成员提供持久的实体 ID。基于这些 id,组成员身份保持不变,因此不会触发重新平衡。 如果您想使用静态成员身份, - 将代理集群和客户端应用程序升级到 2.3 或更高版本,并确保升级后的代理程序也使用 `inter.broker.procol.version` 2.3 或更高的版本。 - 对于一个组下的每个使用者实例,将配置 `ConsumerConfig#GROUP_INSTANCE_ID_CONFIG` 设置为唯一值。 - 对于 Kafka Streams 应用程序,为每个 KafkaStreams 实例设置一个唯一的 `ConsumerConfig#GROUP_INSTANCE_ID_CONFIG` 就足够了,与实例使用的线程数无关。 如果您的 broker 版本早于 2.3,但您选择在客户端设置 `ConsumerConfig#GROUP_INSTANCE_ID_CONFIG`,则应用程序将检测到 broker 版本,然后抛出 UnsupportedException。如果您不小心为不同的实例配置了重复的 ID,代理端的围栏机制将通过触发 `org.apache.kafka.commun.errors.FencedInstanceIdException` 来通知您的重复客户端立即关闭。有关更多详细信息,请参阅 KIP-345 ## 4.6 消息传递语义 既然我们对生产者和消费者是如何工作的有了一些了解,那么让我们讨论一下 Kafka 在生产者和消费者之间提供的语义保证。显然,可以提供多种可能的消息传递保证: - 最多一次——消息可能会丢失,但永远不会重新发送。 - 至少有一次——消息不会丢失,但可能会重新发送。 - 精确一次——一旦这正是人们真正想要的,每条信息就会传递一次,而且只有一次。 值得注意的是,这分为两个问题:发布消息的持久性保证和使用消息时的保证。 许多系统声称提供“精确一次”的交付语义,但阅读细节很重要,这些说法大多具有误导性(即,它们不能转化为消费者或生产商可能失败的情况、存在多个消费者进程的情况或写入磁盘的数据可能丢失的情况)。 Kafka 的语义是直截了当的。当发布消息时,我们有一个消息被“提交”到日志的概念。一旦提交了已发布的消息,只要复制该消息写入的分区的一个代理保持“活动”状态,它就不会丢失。提交消息、活动分区的定义以及我们试图处理的故障类型的描述将在下一节中进行更详细的描述。现在,让我们假设一个完美、无损的 broker,并尝试理解对生产者和消费者的保证。如果生产者试图发布消息并遇到网络错误,则无法确定该错误是在提交消息之前还是之后发生的。这类似于使用自动生成的键插入数据库表的语义。 在 0.11.0.0 之前,如果生产者未能接收到指示消息已提交的响应,那么它别无选择,只能重新发送消息。这提供了至少一次传递语义,因为如果原始请求实际上已经成功,则消息可以在重新发送期间再次写入日志。自 0.11.0.0 以来,Kafka 生产者还支持幂等传递选项,该选项确保重新发送不会导致日志中的重复条目。为了实现这一点,代理为每个生产者分配一个ID,并使用生产者与每条消息一起发送的序列号来消除重复消息。同样从 0.11.0.0 开始,生产者支持使用类似事务的语义将消息发送到多个主题分区的能力:即要么所有消息都成功写入,要么没有成功写入。这方面的主要用例是在 Kafka 主题之间进行一次处理(如下所述)。 并不是所有的用例都需要如此强大的保证。对于延迟敏感的用途,我们允许生产商指定其所需的耐久性级别。如果生产者指定它想等待正在提交的消息,这可能需要 10 毫秒的数量级。然而,生产者也可以指定它想完全异步地执行发送,或者它只想等到领导者(但不一定是追随者)拥有消息。 现在让我们从消费者的角度来描述语义。所有复制副本都有完全相同的日志,具有相同的偏移量。使用者控制其在该日志中的位置。如果使用者从未崩溃,它可以将这个位置存储在内存中,但如果使用者失败,并且我们希望这个主题分区被另一个进程接管,则新进程需要选择一个适当的位置来开始处理。假设消费者读取了一些消息——它有几个处理消息和更新位置的选项。 1. 它可以读取消息,然后保存它在日志中的位置,最后处理消息。在这种情况下,使用者进程可能在保存其位置之后但在保存其消息处理的输出之前崩溃。在这种情况下,接管处理的过程将从保存的位置开始,即使该位置之前的一些消息尚未处理。这对应于“最多一次”语义,因为在消费者失败的情况下,消息可能不会被处理。 2. 它可以读取消息,处理消息,并最终保存其位置。在这种情况下,使用者进程可能在处理消息之后但在保存其位置之前崩溃。在这种情况下,当新进程接管时,它接收到的前几条消息将已经被处理。这对应于消费者失败情况下的“至少一次”语义。在许多情况下,消息有一个主键,因此更新是幂等的(两次接收同一消息只是用另一个自身副本覆盖一个记录)。 那么,究竟一次性语义(即你真正想要的东西)是什么呢?当从一个 Kafka 主题消费并生产到另一个主题时(如在 Kafka Streams 应用程序中),我们可以利用前面提到的 0.11.0.0 中的新事务生成器功能。消费者的位置以消息的形式存储在主题中,因此我们可以在接收处理数据的输出主题的同一事务中将偏移量写入 Kafka。如果事务被中止,消费者的位置将恢复到其旧值,并且输出主题上生成的数据对其他消费者将不可见,这取决于他们的“隔离级别”。在默认的“read_uncommitted”隔离级别中,所有消息对消费者都可见,即使它们是中止事务的一部分,但在“read_committed”中,使用者将只返回已提交事务的消息(以及不属于事务的任何消息)。 当写入外部系统时,限制在于需要将消费者的位置与实际存储的输出相协调。实现这一点的经典方法是在消费者位置的存储和消费者输出的存储之间引入两阶段提交。但这可以通过让消费者将其偏移量存储在与其输出相同的位置来更简单、更普遍地处理。这样做更好,因为消费者可能想要写入的许多输出系统都不支持两阶段提交。作为一个例子,考虑一个 Kafka Connect 连接器,它在 HDFS 中填充数据以及它读取的数据的偏移量,这样就可以保证数据和偏移量都更新或都不更新。我们对许多其他数据系统采用了类似的模式,这些系统需要这些更强的语义,并且消息没有允许重复数据消除的主键。 因此,Kafka 有效地支持 Kafka Streams 中的一次交付,并且在 Kafka 主题之间传输和处理数据时,通常可以使用事务生产者/消费者来提供一次交付。其他目的地系统的一次交付通常需要与此类系统合作,但 Kafka 提供了偏移量,这使得实现这一点是可行的(另请参阅 Kafka Connect)。否则,默认情况下,Kafka 保证至少一次传递,并允许用户在处理一批消息之前通过禁用生产者上的重试和在消费者中提交偏移来实现最多一次传递。 ## 4.7 复制 Kafka 在可配置数量的服务器上复制每个主题分区的日志(您可以逐个主题设置此复制因子)。这允许在群集中的服务器出现故障时自动故障切换到这些复制副本,以便在出现故障时消息仍然可用。 其他消息传递系统提供了一些与复制相关的功能,但在我们(完全有偏见)看来,这似乎是一个附加功能,没有大量使用,而且有很大的缺点:副本处于非活动状态,吞吐量受到严重影响,需要繁琐的手动配置,等等。默认情况下,Kafka 使用复制——事实上,我们将未复制的主题实现为复制主题,其中复制因子为 1。 复制的单位是主题分区。在非故障条件下,Kafka 中的每个分区都有一个前导(leader)和零个或多个跟随(followers)。包括前导在内的复制副本总数构成了复制因子。所有写操作都会转到分区的引导程序,读操作可以转到分区的跟随程序或引导程序。通常情况下,分区比代理多得多,并且领导者在代理之间均匀分布。追随者上的日志与领导者的日志相同,都有相同的偏移量和相同顺序的消息(当然,在任何给定的时间,领导者的日志末尾都可能有一些尚未复制的消息)。 追随者像普通的卡夫卡消费者一样消费来自领导者的消息,并将其应用到自己的日志中。让追随者从领导者那里拉出来有一个很好的特性,那就是允许追随者自然地将他们应用到日志的日志条目批处理在一起。 与大多数分布式系统一样,自动处理故障需要精确定义节点“活动(alive)”的含义。在Kafka中,一个被称为“控制器(controller)”的特殊节点负责管理集群中代理的注册。broker 活跃度有两个条件: 1. broker 必须维护与控制器的活动会话,以便定期接收元数据更新。 2. 作为追随者的 broker 必须复制领导者的写入,不能“落后太多”。 “活动会话”的含义取决于集群配置。对于 KRaft 集群,通过向控制器发送周期性心跳来维持活动会话。如果控制器在 `broker.session.timeout.ms` 配置的超时到期之前未能接收到心跳,则认为节点处于脱机状态。 对于使用 Zookeeper 的集群,活跃度是通过临时节点的存在间接确定的,该节点是由代理(broker)在初始化其 Zookeepper 会话时创建的。如果代理在 `zookeeper.session.timeout.ms` 到期前未能向 Zookeeper 发送检测信号,从而丢失了会话,则该节点将被删除。然后,控制器会通过 Zookeeper 监控注意到节点删除,并将代理标记为离线。 我们将满足这两个条件的节点称为“同步(in sync)”,以避免“活动”或“失败”的模糊性。领导者跟踪一组“同步”复制副本,即所谓的 ISR。如果这些条件中的任何一个都不能满足,那么该代理(broker)将被从 ISR 中删除。例如,如果一个跟随者死亡,那么控制器将通过丢失其会话来注意到故障,并将代理从 ISR 中删除。另一方面,如果跟随者落后于引导者太远,但仍有活动会话,则引导者也可以将其从 ISR 中删除。滞后副本的确定通过 `replica.lag.time.max.ms` 配置进行控制。无法在该配置设置的最长时间内赶上前导上日志末尾的副本将从 ISR 中删除。 在分布式系统术语中,我们只试图处理“故障/恢复”故障模型,即节点突然停止工作,然后恢复(可能不知道它们已经死亡)。Kafka 不处理所谓的“拜占庭式”故障,在这种故障中,节点会产生任意或恶意的响应(可能是由于错误或恶意行为)。 我们现在可以更精确地定义,当该分区的 ISR 中的所有副本都已将消息应用于其日志时,该消息被视为已提交。只有已提交的消息才会发送给消费者。这意味着,如果领导者失败,消费者不必担心可能会看到可能丢失的信息。另一方面,生产者可以选择等待消息提交或不提交,这取决于他们对延迟和持久性之间权衡的偏好。此首选项由生产者使用的 acks 设置控制。请注意,主题中有一个同步副本“最小数量”的设置,当生产者请求确认消息已写入完整的同步副本集时,会检查该设置。如果生产者请求了不太严格的确认,那么即使同步复制副本的数量低于最小值(例如,它可以低到只有前导),也可以提交和消费消息。 Kafka 提供的保证是,只要始终有至少一个同步副本处于活动状态,提交的消息就不会丢失。 在短暂的故障转移期后,Kafka 在出现节点故障时仍然可用,但在出现网络分区时可能不可用。 ### 复制日志:法定人数、ISR 和状态机(天哪!) Kafka 分区的核心是一个复制的日志。复制日志是分布式数据系统中最基本的原语之一,有多种方法可以实现。复制的日志可以被其他系统用作以状态机方式实现其他分布式系统的基元。 复制日志模拟了就一系列值的顺序达成共识的过程(通常将日志条目编号为 0、1、2…)。有很多方法可以实现这一点,但最简单、最快的方法是由领导者选择提供给它的值的顺序。只要领导者还活着,所有追随者只需要复制值并对领导者选择的顺序进行排序。 当然,如果领导者没有失败,我们就不需要追随者了!当领导者挂了的时候,我们需要从追随者中选择一位新的领导者。但追随者本身可能会落后或崩溃,所以我们必须确保选择最新的追随者。日志复制算法必须提供的基本保证是,如果我们告诉客户端消息已提交,而领导者失败,那么我们选择的新领导者也必须有该消息。这就产生了一种权衡:如果领导人在宣布承诺之前等待更多的追随者确认消息,那么就会有更多潜在的可选举领导人。 如果你选择所需的确认数量和必须比较的日志数量来选举领导人,从而保证有重叠,那么这被称为法定人数(Quorum)。 这种权衡的一种常见方法是在承诺决定和领导人选举中使用多数票。这不是卡夫卡所做的,但无论如何,让我们探索一下,以了解其中的权衡。假设我们有 2f+1 个副本。如果 f+1 个副本必须在领导者声明提交之前接收消息,并且如果我们通过从至少 f+1 个复制副本中选择具有最完整日志的跟随者来选择新的领导者,那么,在不超过 f 次失败的情况下,领导者保证拥有所有提交的消息。这是因为在任何 f+1 复制副本中,必须至少有一个复制副本包含所有提交的消息。该复制副本的日志将是最完整的,因此将被选为新的领导者。每个算法都必须处理许多剩余的细节(例如,精确定义是什么使日志更完整,在领导者失败或更改副本集中的服务器集时确保日志一致性),但我们现在将忽略这些。 这种多数投票方法有一个非常好的特性:延迟只取决于最快的服务器。也就是说,如果复制因子为三,则延迟由速度较快的跟随者而不是速度较慢的跟随者决定。 这个家族中有各种各样的算法,包括 ZooKeeper 的 Zab、Raft 和 Viewstamp Replication。据我们所知,与 Kafka 的实际实现最相似的学术出版物是微软的 PacificA。 多数票的不利之处在于,不需要多次失败就可以让你失去可选举的领导人。容忍一次故障需要三个数据副本,容忍两次故障需要五个数据副本。根据我们的经验,对于一个实用的系统来说,只有足够的冗余来容忍一次故障是不够的,但对于大容量数据问题来说,每次写五次,磁盘空间要求是原来的 5 倍,吞吐量是原来的 1/5,这不是很实用。这可能就是为什么仲裁算法更常见于共享集群配置(如 ZooKeeper),而不太常见于主数据存储的原因。例如,在 HDFS 中,namenode 的高可用性功能是建立在基于多数投票的日志上的,但这种更昂贵的方法不用于数据本身。 Kafka 在选择法定人数集时采取了一种稍微不同的方法。Kafka 动态地维护一组同步副本(ISR),而不是多数投票,这些副本会被领先者捕获。只有这一组成员才有资格当选为领导人。对 Kafka 分区的写入在所有同步副本都收到写入之前不会被视为已提交。无论何时更改,此 ISR 集都会保留在群集元数据中。因此,ISR 中的任何复制副本都有资格当选为领导者。这是 Kafka 的使用模型的一个重要因素,因为 Kafka 有很多分区,确保领导层的平衡很重要。有了这个 ISR 模型和 f+1 副本,Kafka 主题可以容忍 f 次失败,而不会丢失提交的消息。 对于我们希望处理的大多数用例,我们认为这种权衡是合理的。在实践中,为了容忍 f 次故障,多数投票和 ISR 方法都将等待相同数量的副本进行确认,然后再提交消息(例如,为了在一次故障中幸存下来,多数法定人数需要三个副本和一个确认,ISR 方法需要两个副本和两个确认)。在没有最慢服务器的情况下提交的能力是多数投票方法的一个优势。然而,我们认为,通过允许客户端选择是否阻止消息提交,它得到了改进,并且由于所需复制因子较低而带来的额外吞吐量和磁盘空间是值得的。 另一个重要的设计区别是,Kafka 不要求崩溃的节点在恢复时所有数据都完好无损。这一领域中的复制算法依赖于“稳定存储”的存在,这种存储在任何故障恢复场景中都不会丢失,而不会违反潜在的一致性。这个假设有两个主要问题。首先,磁盘错误是我们在持久性数据系统的实际操作中观察到的最常见的问题,它们通常不会使数据保持完整。其次,即使这不是问题,我们也不想要求在每次写入时使用 fsync 来保证一致性,因为这会将性能降低两到三个数量级。我们允许复制副本重新加入 ISR 的协议确保在重新加入之前,它必须再次完全重新同步,即使它在崩溃中丢失了未刷新的数据。 ### 不干净的领导人选举:如果他们都死了怎么办? 注意,Kafka 对数据丢失的保证是基于至少一个保持同步的副本。如果复制分区的所有节点都死了,那么这种保证就不再有效。 然而,当所有复制副本(replicas)都失效时,一个实用的系统需要做一些合理的事情。如果你不幸发生了这种情况,那么考虑一下会发生什么是很重要的。有两种行为可以实现: 1. 等待 ISR 中的一个复制副本复活,然后选择这个复制副本作为领导者(希望它仍然拥有所有数据)。 2. 选择第一个复活的复制副本(不一定在 ISR 中)作为领导者。 这是可用性和一致性之间的一个简单权衡。如果我们在 ISR 中等待复制副本,那么只要这些复制副本关闭,我们就会一直不可用。如果这样的复制副本被销毁或数据丢失,那么我们将永久停机。另一方面,如果一个不同步的复制副本复活了,并且我们允许它成为领导者,那么它的日志就会成为真相的来源,尽管它不能保证拥有所有提交的消息。默认情况下,从 0.11.0.0 版本开始,Kafka 选择第一种策略,并倾向于等待一致的副本。可以使用配置属性 unclean.leader.election.enable 更改此行为,以支持正常运行时间优于一致性的用例。 这种困境并不是 Kafka 特有的。它存在于任何基于法定人数的方案中。例如,在多数投票方案中,如果大多数服务器出现永久性故障,那么您必须选择丢失 100% 的数据,或者将现有服务器上的剩余数据作为新的真相来源,从而违反一致性。 ### 可用性和持久性保证 在向 Kafka 写入时,生产者可以选择是等待消息被 0,1 还是所有(-1)副本确认。请注意,“所有副本确认”并不保证已分配的完整副本集已收到消息。默认情况下,当 acks=all 时,一旦所有当前同步副本都收到消息,就会立即进行确认。例如,如果一个主题只配置了两个副本,其中一个失败(即,只剩下一个同步副本),那么指定 acks=all 的写入将成功。但是,如果剩余的复制副本也出现故障,这些写入操作可能会丢失。尽管这确保了分区的最大可用性,但对于一些喜欢持久性而不是可用性的用户来说,这种行为可能是不可取的。因此,我们提供了两种主题级配置,可用于偏好消息持久性而非可用性: 1. 禁用不干净的引导程序选择 - 如果所有副本都不可用,则分区将一直不可用,直到最近的引导程序再次可用。这实际上更倾向于不可用性,而不是消息丢失的风险。请参阅上一节关于不干净的领导人选举的说明。 2. 指定最小 ISR 大小 - 只有当 ISR 的大小高于某个最小值时,分区才会接受写入,以防止只写入单个复制副本的消息丢失,而该复制副本随后变得不可用。只有当生产者使用 acks=all 并保证消息将至少由这么多同步副本确认时,此设置才会生效。此设置在一致性和可用性之间进行权衡。最小 ISR 大小的设置越高,保证了更好的一致性,因为可以保证将消息写入更多的副本,从而降低消息丢失的概率。但是,它会降低可用性,因为如果同步副本的数量降至最低阈值以下,则分区将无法进行写入。 ### 副本管理 上面关于复制日志的讨论实际上只涉及单个日志,即一个主题分区。然而,一个 Kafka 集群将管理成百上千个这样的分区。我们试图以循环方式平衡集群中的分区,以避免在少数节点上为高容量主题集群所有分区。同样,我们试图平衡领导力,使每个节点都是其分区的比例份额的领导者。 优化领导层选举过程也很重要,因为这是不可用的关键窗口。领导者选举的天真实现最终会为节点失败时托管的所有分区按分区运行选举。正如上面关于复制的部分所讨论的,Kafka 集群有一个特殊的角色,称为“控制器”,负责管理代理的注册。如果控制者检测到代理失败,则负责选择 ISR 的剩余成员之一担任新的领导者。结果是,我们能够将许多所需的领导层变更通知批量处理在一起,这使得大量分区的选举过程更加便宜和快速。如果控制器本身发生故障,则将选择另一个控制器。 ## 4.8 日志压缩 日志压缩确保 Kafka 始终为单个主题分区的数据日志中的每个消息键保留至少最后一个已知值。它解决了一些用例和场景,例如在应用程序崩溃或系统故障后恢复状态,或者在操作维护期间在应用程序重新启动后重新加载缓存。让我们更详细地了解这些用例,然后描述压缩是如何工作的。 到目前为止,我们只描述了一种更简单的数据保留方法,即在固定时间段后或日志达到某个预定大小时丢弃旧的日志数据。这对于时间事件数据(如日志记录)非常有效,因为每个记录都是独立的。然而,一类重要的数据流是对键的、可变的数据的更改日志(例如,对数据库表的更改)。 让我们讨论这样一个流的具体例子。假设我们有一个包含用户电子邮件地址的主题;每次用户更新他们的电子邮件地址时,我们都会使用他们的用户 id 作为主键向该主题发送消息。现在假设我们在一段时间内为 id 为 123 的用户发送以下消息,每条消息都对应于电子邮件地址的更改(省略了其他 id 的消息): ``` 123 => bill@microsoft.com . . . 123 => bill@gatesfoundation.org . . . 123 => bill@gmail.com ``` 日志压缩为我们提供了一种更细粒度的保留机制,这样我们就可以保证至少保留每个主键的最后一次更新(例如。bill@gmail.com). 通过这样做,我们保证日志包含每个键的最终值的完整快照,而不仅仅是最近更改的键。这意味着下游消费者可以恢复他们自己的状态,而无需我们保留所有更改的完整日志。 让我们从一些有用的用例开始,然后看看如何使用它。 1. 数据库更改订阅。在多个数据系统中通常需要有一个数据集,其中一个系统通常是某种数据库(RDBMS 或新的键值存储)。例如,您可能有一个数据库、一个缓存、一个搜索集群和一个 Hadoop 集群。对数据库的每一次更改都需要反映在缓存、搜索集群中,并最终反映在 Hadoop 中。在只处理实时更新的情况下,您只需要最近的日志。但是,如果您希望能够重新加载缓存或恢复失败的搜索节点,则可能需要一个完整的数据集。 2. 事件来源。这是一种应用程序设计风格,它将查询处理与应用程序设计放在一起,并使用更改日志作为应用程序的主存储。 3. 记录以获得高可用性。进行本地计算的进程可以通过注销对其本地状态所做的更改来实现容错,这样另一个进程就可以重新加载这些更改,并在失败时继续执行。这方面的一个具体例子是在流查询系统中处理计数、聚合和其他类似“分组”的处理。Samza,一个实时流处理框架,正是出于这个目的使用了这个功能。 在每种情况下,主要需要处理更改的实时馈送,但偶尔,当机器崩溃或需要重新加载或处理数据时,需要进行全加载。日志压缩允许将这两个用例从同一个后台主题中提供出来。日志的这种使用方式在这篇博客文章中有更详细的描述。 总的想法很简单。如果我们有无限的日志保留,并且我们记录了上述情况下的每一个更改,那么我们就会捕获系统从刚开始时开始的每一次状态。使用这个完整的日志,我们可以通过回放日志中的前N条记录来恢复到任何时间点。这种假设的完整日志对于多次更新单个记录的系统来说不是很实用,因为即使对于稳定的数据集,日志也会在没有绑定的情况下增长。丢弃旧更新的简单日志保留机制将绑定空间,但日志不再是恢复当前状态的一种方式,现在从日志开始恢复不再重新创建当前状态,因为可能根本无法捕获旧更新。 日志压缩是一种提供细粒度的每记录保留的机制,而不是基于时间的粗粒度保留。我们的想法是有选择地删除具有相同主键的最近更新的记录。通过这种方式,可以保证日志至少具有每个密钥的最后一个状态。 可以按主题设置此保留策略,因此单个集群可以具有某些主题,其中保留是通过大小或时间强制执行的,而其他主题则通过压缩强制执行的。 该功能的灵感来自领英最古老、最成功的基础设施之一,即名为 Databus 的数据库更改日志缓存服务。与大多数日志结构存储系统不同,Kafka 是为订阅而构建的,它组织数据以实现快速线性读写。与 Databus 不同,Kafka 充当了真相来源的存储,因此即使在上游数据源无法重放的情况下,它也很有用。 ### 日志压缩基础 这是一张高级图片,显示了 Kafka 日志的逻辑结构以及每条消息的偏移量。 ![img](https://kafka.apache.org/34/images/log_cleaner_anatomy.png) 日志的头部与传统的 Kafka 日志完全相同。它具有密集的顺序偏移,并保留所有消息。日志压缩添加了一个用于处理日志尾部的选项。上图显示的是一条尾部被压缩的日志。请注意,日志尾部的消息保留了首次写入时分配的原始偏移量,该偏移量从未更改。还要注意,即使具有该偏移量的消息已被压缩掉,所有偏移量在日志中仍保持有效位置;在这种情况下,该位置与日志中出现的下一个最高偏移量无法区分。例如,在上面的图片中,偏移 36、37 和 38 都是等效位置,并且从这些偏移中的任何偏移开始的读取将返回以 38 开始的消息集。 压缩还允许删除。带有密钥和 null 有效负载的消息将被视为从日志中删除。这样的记录有时被称为墓碑(tombstone)。此删除标记将导致删除任何以前带有该键的消息(就像删除任何新消息一样),但删除标记的特殊之处在于,它们将在一段时间后从日志中清除,以释放空间。不再保留删除的时间点在上图中标记为“删除保留点”(delete retention point)。 压缩是在后台通过定期重新复制日志段来完成的。清洁不会阻止读取,并且可以进行调节,以使用不超过可配置数量的 I/O 吞吐量,从而避免影响生产商和消费者。压缩日志段的实际过程如下所示: ![img](https://kafka.apache.org/34/images/log_compaction.png) ### 日志压缩提供什么保证? 日志压缩保证以下内容: 1. 任何停留在日志头部的消费者都会看到所写的每一条消息;这些消息将具有顺序偏移。主题的 `min.compaction.lag.ms` 可用于保证在写入消息后必须经过最短时间才能压缩消息。也就是说,它提供了每个消息在(未压缩的)头中保留多长时间的下限。主题的 `max.compaction.lag.ms` 可用于保证从写入消息到消息符合压缩条件之间的最大延迟。 2. 始终保持消息的顺序。压缩永远不会重新排序消息,只需删除一些即可。 3. 消息的偏移量永远不会改变。它是日志中某个位置的永久标识符。 4. 任何从日志开始进行的使用者都将至少看到所有记录的最终状态(按写入顺序)。此外,如果使用者在小于主题的 `delete.retension.ms` 设置(默认为 24 小时)的时间段内到达日志的头部,则会看到已删除记录的所有删除标记。换言之:由于删除标记的删除与读取同时发生,因此如果延迟超过 `delete.retension.ms`,则使用者可能会错过删除标记。 ### 日志压缩细节 日志压缩由日志清理器处理,日志清理器是一个后台线程池,用于重新复制日志段文件,删除其关键字出现在日志头中的记录。每个压缩者线程的工作原理如下: 1. 它选择具有最高日志头与日志尾比率的日志 2. 它为日志头中的每个键创建了最后一个偏移量的简洁摘要 3. 它从头到尾重新复制日志,删除稍后在日志中出现的键。新的干净段会立即交换到日志中,因此所需的额外磁盘空间仅为一个额外的日志段(而不是日志的完整副本)。 4. 日志头的摘要本质上只是一个空间紧凑的哈希表。它每个条目正好使用 24 个字节。因此,使用 8GB 的更干净的缓冲区,一次更干净的迭代可以清理大约 366GB 的日志头(假设有1k条消息)。 ### 配置日志清理器 默认情况下会启用日志清理器。这将启动清理线程池。要对特定主题启用日志清理,请添加特定于日志的属性 ```properties log.cleanup.policy=compact ``` `log.cleanup.policy` 属性是在代理的 `server.properties` 文件中定义的代理配置设置;它会影响集群中没有配置覆盖的所有主题,如本文所述。日志清理器可以配置为保留最小数量的未压缩的日志“头”。这是通过设置压缩时间滞后来启用的。 ``` log.cleaner.min.compaction.lag.ms ``` 这可以用来防止更新到最短消息期限的消息被压缩。如果未设置,则除最后一个段(即当前正在写入的段)外,所有日志段都有资格压缩。即使活动段的所有消息都早于最小压缩时间滞后,也不会压缩活动段。日志清理器可以配置为确保最大延迟,在此之后,未压缩的日志“头”有资格进行日志压缩。 ``` log.cleaner.max.compaction.lag.ms ``` 这可用于防止低生产率的日志在无限制的持续时间内不符合压缩条件。如果未设置,则不会压缩不超过 `min.cleanable.dirtyRatio` 的日志。请注意,这个压缩截止日期并不是一个硬性保证,因为它仍然受到日志清理线程的可用性和实际压缩时间的影响。您将需要监控不可清理分区计数、最大清理时间秒和最大压缩延迟秒指标。 此处介绍了进一步的清洁器配置。 ## 4.9 配额 Kafka 集群能够对请求强制执行配额,以控制客户端使用的代理资源。Kafka 代理可以为共享配额的每组客户端强制执行两种类型的客户端配额: 1. 网络带宽配额定义字节率阈值(自 0.9 起) 2. 请求速率配额将 CPU 利用率阈值定义为网络和 I/O 线程的百分比(自 0.11 起) ### 为什么配额是必须的? 生产者和消费者有可能产生/消耗非常高的数据量或以非常高的速率生成请求,从而垄断代理资源,导致网络饱和,并且通常 DOS 其他客户端和代理本身。配额可以防止这些问题的发生,在大型多租户集群中更为重要,在大型集群中,一小部分表现不佳的客户端可能会降低表现良好的客户端的用户体验。事实上,当将 Kafka 作为服务运行时,这甚至可以根据约定的合同强制执行 API 限制。 ### 客户端组 Kafka 客户端的身份是用户主体,代表安全集群中经过身份验证的用户。在支持未经验证的客户端的集群中,用户主体是由代理使用可配置的 `PrincipalBuilder` 选择的未经验证用户的分组。客户端 id 是客户端应用程序选择的具有有意义名称的客户端的逻辑分组。元组(user,client-id)定义了一个安全的客户端逻辑组,该组客户端共享用户主体和客户端 id。 配额可以应用于(用户、客户端 id)、用户或客户端 id 组。对于给定的连接,将应用与该连接匹配的最特定配额。配额组的所有连接共享为该组配置的配额。例如,如果(user=“test-user”,client id=“test-client”)的生产配额为 10MB/秒,则该配额将在具有 client-id “test-client” 的用户 “test-user” 的所有生产者实例中共享。 ### 配额配置 可以为(用户、客户端 id)、用户和客户端 id 组定义配额配置。可以在任何需要更高(甚至更低)配额的配额级别覆盖默认配额。该机制类似于每个主题的日志配置覆盖。用户和(用户,客户端 id)配额覆盖在 **/config/users** 下写入 ZooKeeper,客户端 id 配额覆盖在 **/config/clients** 下写入。所有代理都会读取这些覆盖并立即生效。这使我们可以更改配额,而不必对整个集群进行滚动重新启动。请参阅此处了解详细信息。每个组的默认配额也可以使用相同的机制动态更新。 配额配置的优先顺序为: 1. `/config/users//clients/` 2. `/config/users//clients/` 3. `/config/users/` 4. `/config/users//clients/` 5. `/config/users//clients/` 6. `/config/users/` 7. `/config/clients/` 8. `/config/clients/` ### 网络带宽配额 网络带宽配额被定义为共享配额的每组客户端的字节率阈值。默认情况下,每个唯一的客户端组接收由集群配置的以字节/秒为单位的固定配额。此配额是在每个经纪人的基础上定义的。在客户端被抑制之前,每组客户端可以发布/获取每个代理最多 X 字节/秒的数据。 ### 请求速率配额 请求速率配额被定义为客户端在配额窗口内可以在每个代理的请求处理程序 I/O 线程和网络线程上使用的时间百分比。n% 的配额表示一个线程的 n%,因此该配额超出了 `((num.io.threads + num.network.threads) * 100)%` 的总容量。在被限制之前,每组客户端可以在配额窗口中的所有 I/O 和网络线程中使用高达 n% 的总百分比。由于分配给 I/O 和网络线程的线程数通常基于代理主机上可用的内核数,因此请求率配额表示共享配额的每组客户端可能使用的 CPU 的总百分比。 ### 执行 默认情况下,每个唯一的客户端组都会接收由集群配置的固定配额。此配额是在每个代理(per-broker)的基础上定义的。每个客户端都可以在每个代理被限制之前使用这个配额。我们决定,为每个代理定义这些配额比为每个客户端提供固定的集群带宽要好得多,因为这需要一种在所有代理之间共享客户端配额使用情况的机制。这可能比配额实施本身更难做到! 代理在检测到配额违规时会如何反应?在我们的解决方案中,代理首先计算将违规客户端置于其配额之下所需的延迟量,并立即返回带有延迟的响应。在获取请求的情况下,响应将不包含任何数据。然后,代理使通往客户端的通道静音,不再处理来自客户端的请求,直到延迟结束。在接收到具有非零延迟持续时间的响应时,Kafka 客户端也将在延迟期间避免向代理发送进一步的请求。因此,来自被抑制的客户端的请求被有效地从双方阻止。即使使用不尊重来自代理的延迟响应的旧客户端实现,代理通过屏蔽其套接字通道施加的背压仍然可以处理性能不良的客户端的节流(throttling)。那些向节流通道发送进一步请求的客户端只有在延迟结束后才会收到响应。 字节率和线程利用率是在多个小窗口(例如,每个窗口为 1 秒的 30 个窗口)上测量的,以便快速检测和纠正配额违规。通常,具有大的测量窗口(例如,每个 30 秒的 10 个窗口)会导致大的流量爆发,随后是长的延迟,这在用户体验方面不是很好。 # 5 实现 ## 5.1 网络层