Kafka原理详细介绍

1 Kafka

1.1 定义

Kafka 是一个分布式流媒体平台,kafka官网:http://kafka.apache.org/

Kafka 是一种高吞吐量、分布式、基于发布/订阅的消息系统,最初由 LinkedIn 公司开发,使用Scala 语言编写,目前是Apache 的开源项目。

流媒体平台有三个关键功能:

  • 发布和订阅记录流,类似于消息队列或企业消息传递系统。
  • 以容错的持久方式存储记录流。
  • 记录发生时处理流。

Kafka通常用于两大类应用:

  • 构建可在系统或应用程序之间可靠获取数据的实时流数据管道
  • 构建转换或响应数据流的实时流应用程序

1.1.1 Kafka名词

下面是Kafka中涉及到的相关概念:

  1. brokerKafka 服务器,负责消息存储和转发
  2. topic:消息类别,Kafka 按照topic 来分类消息(即使如此,kafka仍然有点对点和广播发布类型)
  3. partitiontopic 的分区,一个 topic 可以包含多个 partitiontopic 消息保存在各个partition
  4. offset:消息在日志中的位置,可以理解是消息在 partition 上的偏移量,也是代表该消息的唯一序号
  5. Producer:消息生产者
  6. Consumer:消息消费者
  7. Consumer Group:消费者分组,每个Consumer 必须属于一个 group
  8. Zookeeper:保存着集群 broker、topic、partitionmeta 数据;另外,还负责 broker 故障发现,partition leader 选举,负载均衡等功能

1.1.2 Kafka核心API

Kafka有四个核心API:

  • Producer API(生产者API)允许应用程序发布记录流至一个或多个kafkatopics(主题)

  • Consumer API(消费者API)允许应用程序订阅一个或多个topics(主题),并处理所产生的对他们记录的数据流。

  • Streams API(流API)允许应用程序充当流处理器,从一个或多个topics(主题)消耗的输入流,并产生一个输出流至一个或多个输出的topics(主题),有效地变换所述输入流,以输出流。

  • Connector API(连接器API)允许构建和运行kafka topics(主题)连接到现有的应用程序或数据系统中重用生产者或消费者。例如,关系数据库的连接器可能捕获对表的每个更改。

Kafka中,客户端和服务器之间的通信是通过简单,高性能,语言无关的TCP协议完成的。此协议已版本化并保持与旧版本的向后兼容性。Kafka提供Java客户端,但客户端有多种语言版本。

1.2 相关组件介绍

1.2.1 Topic

Topic 是生产者发送消息的目标地址,是消费者的监听目标

一个服务可以监听、发送多个 Topics

Kafka 中有一个consumer-group(消费者组)的概念。
这是一组服务,扮演一个消费者

如果是消费者组接收消息,Kafka 会把一条消息路由到组中的某一个服务

这样有助于消息的负载均衡,也方便扩展消费者。
Topic 扮演一个消息的队列。
首先,一条消息发送了

然后,这条消息被记录和存储在这个队列中,不允许被修改

接下来,消息会被发送给此 Topic 的消费者。
但是,这条消息并不会被删除,会继续保留在队列中

像之前一样,这条消息会发送给消费者、不允许被改动、一直呆在队列中。
(消息在队列中能呆多久,可以修改 Kafka 的配置)

1.2.2 Partitions分区

上面 Topic 的描述中,把 Topic 看做了一个队列,实际上,一个 Topic 是由多个队列组成的,被称为Partition(分区)
这样可以便于 Topic 的扩展

Kafka 的分区分配策略:

  • 默认使用 轮询分配,把消息均匀分布到每个分区
  • 如果设置了KeyKafka会用 哈希分配,保证相同 Key 的消息总是落到同一个分区。

生产者发送消息的时候,这条消息会被路由到此 Topic 中的某一个 Partition

消费者监听的是所有分区

生产者发送消息时,默认是面向 Topic 的,由 Topic 决定放在哪个 Partition,默认使用轮询策略

也可以配置 Topic,让同类型的消息都在同一个 Partition
例如,处理用户消息,可以让某一个用户所有消息都在一个 Partition
例如,用户1发送了3条消息:A、B、C,默认情况下,这3条消息是在不同的 Partition 中(如 P1、P2、P3)。
在配置之后,可以确保用户1的所有消息都发到同一个分区中(如 P1)

这个功能有什么用呢?
这是为了提供消息的【有序性】。
消息在不同的 Partition 是不能保证有序的,只有一个 Partition 内的消息是有序的

1.2.3 Topics主题 和 partitions分区

一个Topic可以认为是一类消息,每个topic将被分成多个partition(区),每个partition在存储层面是append log文件
主题是发布记录的类别或订阅源名称。Kafka的主题总是多用户; 也就是说,一个主题可以有零个,一个或多个消费者订阅写入它的数据。
对于每个主题,Kafka集群都维护一个如下所示的分区日志:

每个分区都是一个有序的,不可变的记录序列,不断附加到结构化的提交日志中。分区中的记录每个都分配了一个称为偏移的顺序ID号,它唯一地标识分区中的每个记录。

Kafka集群持久保存所有已发布的记录 - 无论是否已使用 - 使用可配置的保留期。例如,如果保留策略设置为两天,则在发布记录后的两天内,它可供使用,之后将被丢弃以释放空间。Kafka的性能在数据大小方面实际上是恒定的,因此长时间存储数据不是问题。

实际上,基于每个消费者保留的唯一元数据是该消费者在日志中的偏移或位置。这种偏移由消费者控制:通常消费者在读取记录时会线性地提高其偏移量,但事实上,由于该位置由消费者控制,因此它可以按照自己喜欢的任何顺序消费记录。例如,消费者可以重置为较旧的偏移量来重新处理过去的数据,或者跳到最近的记录并从“现在”开始消费。

这些功能组合意味着Kafka 消费者consumers 非常cheap - 他们可以来来往往对集群或其他消费者没有太大影响。例如,可以使用我们的命令行工具“tail”任何主题的内容,而无需更改任何现有使用者所消耗的内容。

日志中的分区有多种用途。首先,它们允许日志扩展到超出适合单个服务器的大小。每个单独的分区必须适合托管它的服务器,但主题可能有许多分区,因此它可以处理任意数量的数据。其次,它们充当了并行性的单位 - 更多的是它

1.2.4 Distribution分配

一个Topic的多个partitions,被分布在kafka集群中的多个server上;每个server(kafka实例)负责partitions中消息的读写操作;此外kafka还可以配置partitions需要备份的个数(replicas),每个partition将会被备份到多台机器上,以提高可用性.

基于replicated方案,那么就意味着需要对多个备份进行调度;每个partition都有一个serverleader;leader负责所有的读写操作,如果leader失效,那么将会有其他follower来接管(成为新的leader);follower只是单调的和leader跟进,同步消息即可..由此可见作为leaderserver承载了全部的请求压力,因此从集群的整体考虑,有多少个partitions就意味着有多少个"leader",kafka会将"leader"均衡的分散在每个实例上,来确保整体的性能稳定

1.2.5 Producers生产者 和 Consumers消费者

1.2.5.1 Producers生产者

Producers将数据发布到指定的topics 主题。同时Producer 也能决定将此消息归属于哪个partition;比如基于round-robin方式或者通过其他的一些算法等。

1.2.5.2 Consumers

本质上kafka只支持Topic.每个consumer属于一个consumer group;反过来说,每个group中可以有多个consumer.发送到Topic的消息,只会被订阅此Topic的每个group中的一个consumer消费。

如果所有使用者实例具有相同的使用者组,则记录将有效地在使用者实例上进行负载平衡。

如果所有消费者实例具有不同的消费者组,则每个记录将广播到所有消费者进程。

分析:两个服务器Kafka群集,托管四个分区(P0-P3),包含两个使用者组。消费者组A有两个消费者实例,B组有四个消费者实例。

Kafka中实现消费consumption 的方式是通过在消费者实例上划分日志中的分区,以便每个实例在任何时间点都是分配的“公平份额”的独占消费者。维护组中成员资格的过程由Kafka协议动态处理。如果新实例加入该组,他们将从该组的其他成员接管一些分区; 如果实例死亡,其分区将分发给其余实例。

Kafka仅提供分区内记录的总订单,而不是主题中不同分区之间的记录。对于大多数应用程序而言,按分区排序与按键分区数据的能力相结合就足够了。但是,如果您需要对记录进行总订单,则可以使用仅包含一个分区的主题来实现,但这将意味着每个使用者组只有一个使用者进程。

1.2.5.3 Consumers kafka确保

发送到partitions中的消息将会按照它接收的顺序追加到日志中。也就是说,如果记录M1由与记录M2相同的生成者发送,并且首先发送M1,则M1将具有比M2更低的偏移并且在日志中更早出现。

消费者实例按照它们存储在日志中的顺序查看记录。对于消费者而言,它们消费消息的顺序和日志中消息顺序一致。

如果Topicreplicationfactor为N,那么允许N-1个kafka实例失效,我们将容忍最多N-1个服务器故障,而不会丢失任何提交到日志的记录。

1.2.6 架构和zookeeper关系

Kafka 是集群架构的,ZooKeeper是重要组件。

ZooKeeper 管理者所有的 TopicPartition
TopicPartition 存储在 Node 物理节点中,ZooKeeper负责维护这些 Node

有2个 Topic,各自有2个 Partition

这是逻辑上的形式,但在 Kafka 集群中的实际存储可能是这样的

Topic APartition #1 有3份,分布在各个 Node 上。
这样可以增加 Kafka 的可靠性和系统弹性。
3个 Partition #1 中,ZooKeeper 会指定一个 Leader,负责接收生产者发来的消息

其他2个 Partition #1 会作为 Follower,Leader 接收到的消息会复制给 Follower

这样,每个 Partition 都含有了全量消息数据。

即使某个 Node 节点出现了故障,也不用担心消息的损坏。
Topic A 和 Topic B 的所有 Partition 分布可能就是这样的

转载于:https://mp.weixin.qq.com/s/k7DJJGmImcpnaSy9AhAmmQ

1.3 kafka 顺序消费

1.3.1 消息的顺序消费

Kafka 的消息顺序消费是指消费者按照消息的顺序逐条消费消息的过程。Kafka的分区(Partition)是消息的基本单位,每个分区中的消息按照顺序进行存储。在一个分区中,消息的顺序是有序的,这意味着先发送的消息会被存储在分区的前部,而后发送的消息会被追加到分区的末尾。

Kafka 通过分区的方式实现消息的顺序性,消费者可以订阅一个或多个分区来消费消息。当消费者从分区中拉取消息时,Kafka会按照消息在分区中的顺序返回给消费者。这样就保证了消费者将按照消息的顺序进行消费。
需要注意的是,Kafka的多个分区是并行处理的,每个分区的消息可以独立进行消费。因此,在多个分区并行消费的情况下,消费者之间的消息顺序可能无法保证。但是,对于单个分区的消息消费,Kafka会确保按照消息的顺序进行消费。
为了实现消息的顺序消费,可以根据业务需求将相关消息发送到同一个分区,并且使用单个消费者实例来消费该分区的消息。这样就可以保证消息在整个分区中按照顺序进行处理。同时,Kafka还提供了分区器(Partitioner)机制,可以根据消息的键(key)来决定消息被发送到哪个分区,从而进一步控制消息的顺序消费。

1.3.2 如何保证消息的顺序消费

使用Kafka的消费者API来实现消息的顺序消费。以下是几种可以考虑的方法:

  • 单个分区消费: 创建一个单独的消费者实例来消费一个分区的消息。这样可以确保在单个分区内的消息按顺序消费。但是需要注意,如果有多个分区,不同分区的消息仍可能以并发方式进行消费。
  • 指定分区消费: 通过指定消费者订阅的特定分区,可以确保只消费指定分区的消息。这样,可以通过将相关消息发送到同一个分区来保证消息的顺序消费。
  • 按键分区Kafka允许根据消息的键(key)来决定将消息发送到哪个分区。如果消息的键是相同的,Kafka 会将它们发送到同一个分区。因此,可以根据消息的键来保证消息的顺序消费。

无论选择哪种方法,都应该注意以下几点:

  • 设置消费者的 max.poll.records 参数,确保每次拉取的消息数量合适,以避免因一次拉取的消息过多而导致处理速度过慢。
  • 在消费者处理消息时,确保消息处理的逻辑是线程安全的。
  • 监听消费者的 onPartitionsRevoked 事件,以便在重新分配分区时进行必要的清理和准备工作。
  • 使用 auto.offset.reset 参数设置消费者的 offset 重置策略,以决定当消费者启动时从哪个offset开始消费

1.4 消息重复

1.4.1 引言

数据重复这个问题其实也是挺正常,全链路都有可能会导致数据重复

通常,消息消费时候都会设置一定重试次数来避免网络波动造成的影响,同时带来副作用是可能出现消息重复。

整理下消息重复的几个场景:

  • 生产端: 遇到异常,基本解决措施都是重试
    • 场景一:leader分区不可用了,抛 LeaderNotAvailableException 异常,等待选出新 leader 分区。
    • 场景二:Controller 所在 Broker 挂了,抛 NotControllerException 异常,等待 Controller 重新选举。
    • 场景三:网络异常、断网、网络分区、丢包等,抛 NetworkException 异常,等待网络恢复。
  • 消费端poll 一批数据,处理完毕还没提交 offset ,机子宕机重启了,又会 poll 上批数据,再度消费就造成了消息重复。

1.4.2 解决重复消息

了解下消息的三种投递语义:

  • 最多一次(at most once): 消息只发一次,消息可能会丢失,但绝不会被重复发送。例如:mqtt 中 QoS = 0。
  • 至少一次(at least once): 消息至少发一次,消息不会丢失,但有可能被重复发送。例如:mqtt 中 QoS = 1
  • 精确一次(exactly once): 消息精确发一次,消息不会丢失,也不会被重复发送。例如:mqtt 中 QoS = 2。

了解了这三种语义,再来看如何解决消息重复,即如何实现精准一次,可分为三种方法:

  • Kafka 幂等性 Producer: 保证生产端发送消息幂等。局限性,是只能保证单分区且单会话(重启后就算新会话)
  • Kafka 事务: 保证生产端发送消息幂等。解决幂等 Producer 的局限性。
  • 消费端幂等: 保证消费端接收消息幂等

1.4.2.1 Kafka 幂等性 Producer

幂等性指:无论执行多少次同样的运算,结果都是相同的。即一条命令,任意多次执行所产生的影响均与一次执行的影响相同

幂等性使用示例:在生产端添加对应配置即可

  1. 设置幂等,启动幂等。
  2. 配置 acks注意:一定要设置 acks=all,否则会抛异常。
  3. 配置 max.in.flight.requests.per.connection 需要 <= 5,否则会抛异常 OutOfOrderSequenceException
    • 0.11 >= Kafka < 1.1, max.in.flight.request.per.connection = 1
    • Kafka >= 1.1, max.in.flight.request.per.connection <= 5
Properties props = new Properties();
props.put("enable.idempotence", ture); // 1. 设置幂等
props.put("acks", "all"); // 2. 当 enable.idempotence 为 true,这里默认为 all
props.put("max.in.flight.requests.per.connection", 5); // 3. 注意

为了更好理解,需要了解下 Kafka 幂等机制:

  1. Producer 每次启动后,会向 Broker 申请一个全局唯一的 pid。(重启后 pid 会变化,这也是弊端之一)
  2. Sequence Numbe:针对每个 <Topic, Partition> 都对应一个从0开始单调递增的 Sequence,同时 Broker端会缓存这个 seq num
  3. 判断是否重复: 拿 <pid, seq num>Broker 里对应的队列 ProducerStateEntry.Queue(默认队列长度为 5)查询是否存在
    如果 nextSeq == lastSeq + 1,即 服务端seq + 1 == 生产传入seq,则接收。
    如果 nextSeq == 0 && lastSeq == Int.MaxValue,即刚初始化,也接收。
    反之,要么重复,要么丢消息,均拒绝。

这种设计针对解决了两个问题:

  • 消息重复: 场景 Broker 保存消息后还没发送 ack 就宕机了,这时候 Producer 就会重试,这就造成消息重复。
  • 消息乱序: 避免场景,前一条消息发送失败而其后一条发送成功,前一条消息重试后成功,造成的消息乱序。

那什么时候该使用幂等:

  • 如果已经使用 acks=all,使用幂等也可以。
  • 如果已经使用 acks=0 或者 acks=1,说明系统追求高性能,对数据一致性要求不高。不要使用幂等

使用例子
启动消息者:可以用 Kafka 提供的脚本

# 举个栗子:topic 需要自己去修改
$ cd ./kafka-2.7.1-src/bin
$ ./kafka-console-producer.sh --broker-list localhost:9092 --topic test_topic

创建 topic : 1副本,2 分区

$ ./kafka-topics.sh --bootstrap-server localhost:9092 --topic myTopic --create --replication-factor 1 --partitions 2

# 查看
$ ./kafka-topics.sh --bootstrap-server broker:9092 --topic myTopic --describe

生产者代码

public class KafkaProducerApplication {

    private final Producer<String, String> producer;
    final String outTopic;

    public KafkaProducerApplication(final Producer<String, String> producer,
                                    final String topic) {
        this.producer = producer;
        outTopic = topic;
    }

    public void produce(final String message) {
        final String[] parts = message.split("-");
        final String key, value;
        if (parts.length > 1) {
            key = parts[0];
            value = parts[1];
        } else {
            key = null;
            value = parts[0];
        }
        final ProducerRecord<String, String> producerRecord
            = new ProducerRecord<>(outTopic, key, value);
        producer.send(producerRecord,
                (recordMetadata, e) -> {
                    if(e != null) {
                        e.printStackTrace();
                    } else {
                        System.out.println("key/value " + key + "/" + value + "\twritten to topic[partition] " + recordMetadata.topic() + "[" + recordMetadata.partition() + "] at offset " + recordMetadata.offset());
                    }
                }
        );
    }

    public void shutdown() {
        producer.close();
    }

    public static void main(String[] args) {

        final Properties props = new Properties();

        props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, "true");
        props.put(ProducerConfig.ACKS_CONFIG, "all");

        props.put(ProducerConfig.CLIENT_ID_CONFIG, "myApp");
        props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
        props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);

        final String topic = "myTopic";
        final Producer<String, String> producer = new KafkaProducer<>(props);
        final KafkaProducerApplication producerApp = new KafkaProducerApplication(producer, topic);

        String filePath = "/home/donald/Documents/Code/Source/kafka-2.7.1-src/examples/src/main/java/kafka/examples/input.txt";
        try {
            List<String> linesToProduce = Files.readAllLines(Paths.get(filePath));
            linesToProduce.stream().filter(l -> !l.trim().isEmpty())
                    .forEach(producerApp::produce);
            System.out.println("Offsets and timestamps committed in batch from " + filePath);
        } catch (IOException e) {
            System.err.printf("Error reading file %s due to %s %n", filePath, e);
        } finally {
            producerApp.shutdown();
        }
    }
}

启动消费者:

$ ./kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic myTopic

1.4.2.2 Kafka 事务

使用 Kafka 事务解决幂等的弊端:单会话且单分区幂等。

事务使用示例:分为生产端 和 消费端

Properties props = new Properties();
props.put("enable.idempotence", ture); // 1. 设置幂等
props.put("acks", "all"); // 2. 当 enable.idempotence 为 true,这里默认为 all
props.put("max.in.flight.requests.per.connection", 5); // 3. 最大等待数
props.put("transactional.id", "my-transactional-id"); // 4. 设定事务 id

Producer<String, String> producer = new KafkaProducer<String, String>(props);

// 初始化事务
producer.initTransactions();

try{
    // 开始事务
    producer.beginTransaction();

    // 发送数据
    producer.send(new ProducerRecord<String, String>("Topic", "Key", "Value"));
 
    // 数据发送及 Offset 发送均成功的情况下,提交事务
    producer.commitTransaction();
} catch (ProducerFencedException | OutOfOrderSequenceException | AuthorizationException e) {
    // 数据发送或者 Offset 发送出现异常时,终止事务
    producer.abortTransaction();
} finally {
    // 关闭 Producer 和 Consumer
    producer.close();
    consumer.close();
}

这里消费端 Consumer 需要设置下配置:isolation.level 参数

  • read_uncommitted: 这是默认值,表明 Consumer 能够读取到 Kafka 写入的任何消息,不论事务型 Producer 提交事务还是终止事务,其写入的消息都可以读取。如果用了事务型 Producer,那么对应的 Consumer 就不要使用这个值。
  • read_committed: 表明 Consumer 只会读取事务型 Producer 成功提交事务写入的消息。当然了,它也能看到非事务型 Producer 写入的所有消息。

1.4.2.3 消费端幂等

只要消费端具备了幂等性,那么重复消费消息的问题也就解决了。
典型的方案是使用:消息表,来去重:

上述demo中,消费端拉取到一条消息后,开启事务,将消息Id 新增到本地消息表中,同时更新订单信息。
如果消息重复,则新增操作 insert 会异常,同时触发事务回滚。

参考链接:https://juejin.cn/post/7172897190627508237

1.5 消息丢失以及监控

一般情况下,Kafka 不会丢失消息,是通过ACK机制ISR(同步副本集)日志存储 条件决定的:

  • ACK机制Producer可以设置acks=all,只有所有副本都写成功才算成功。
  • ISR(同步副本集)Kafka 只会从 ISR 里的 FollowerLeader,保证选出来的Leader是最新的。
  • 日志存储:消息先写入磁盘,避免因内存问题导致数据丢失

但是,不能绝对保证不丢失消息

1.5.1 消息丢失场景

消息丢失的几种典型场景:

  • 生产者消息发送失败:这个比较简单,如果生产者发消息时,网络抖动、服务宕机或 Kafka broker 挂了,那消息就丢了。
    这时候生产者通常会重试,但是如果重试策略不当,还是可能丢消息。
  • 消费者消费消息失败:最常见的是消费者拉取了消息,但是业务处理失败,或者消费后没有提交 offset,导致消息“看似”消费了,实际根本没处理。
    这种情况不算真正的消息丢失,但业务数据不一致,这锅还是要 Kafka 来背。
  • 网络异常导致消息丢失:有时候消息发送成功了,但是因为网络问题,导致消费者没能拉到这些消息,这类情况更难排查。

1.5.2 消息丢失怎么发现

1.5.2.1 监控和告警系统

监控是最基础的保障手段。一般来说,Kafka 提供了很多指标可以监控,比如生产端和消费端的吞吐量、消息积压(lag)情况、消费者组的 offset 等等。
通过这些监控指标,一旦消费端的消息积压开始异常增长,或者 offset 停滞不前,就说明很可能有消息丢失了。
很多公司会用 Prometheus + Grafana 来做监控和可视化,再配合告警系统(如 Alertmanager)实时提醒。比如可以监控 kafka_consumer_lag 这个指标,一旦消息积压超过预设阈值,就触发告警。

1.5.2.2 消息追踪机制

消息追踪就像在每个消息上打个追踪码,确保每条消息都能被追踪到。
具体做法是:生产者在发送每条消息时,生成一个唯一的 message_id,消费者在消费时同样记录消费的 message_id。通过对比生产端和消费端的 ID,就可以发现有没有消息“掉队”了。
在实际应用中,通常会通过日志来记录这些 message_id,并定期检查对账,保证所有消息都正确处理了。

// 生产者发送消息时生成 
message_idString messageId = UUID.randomUUID().toString();
ProducerRecord<String, String> record = new ProducerRecord<>("your-topic", messageId, messageContent);
producer.send(record);
// 消费者消费消息时记录 message_id
public void consumeMessage(ConsumerRecord<String, String> record) {  
  String messageId = record.key();  // 获取 message_id 
     // 将 message_id 存储到日志或数据库中,用于后续追踪    
     log.info("Consumed message with ID: {}", messageId);}

1.5.2.3 消息确认机制

Kafka 本身有个很经典的机制,就是手动提交offset。消费者在处理完消息后,才提交消费位置的 offset
如果消费失败了,不提交 offset,Kafka 就会重新分配这条消息,避免消息丢失。
很多时候,消息丢失的“锅”其实是消费者自己在消费时出了问题,明明没处理完却偷偷提交了 offset,让 Kafka 以为消息已经处理完毕了。
手动提交 offset 就能很好地避免这种情况。

public void consumeMessages() {
	ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100)); 
	for (ConsumerRecord<String, String> record : records) {
	        try {            // 处理消息逻辑  
	            processMessage(record);
	                        // 成功处理后提交 offset      
	            consumer.commitSync();
	        } catch (Exception e) {
	        // 处理失败不提交 offset,Kafka 会重试 
           log.error("Failed to process message, will retry.", e);      
             }    
 }}

1.5.2.4 消息重试和补偿机制

为了解决偶发性的消费失败,很多公司会为 Kafka 消费端加一个重试机制。
当消息处理失败时,重新将消息放回队列,或者放到一个死信队列(Dead Letter Queue, DLQ)里,然后专门处理这些异常消息。

// 如果消息处理失败,将其放回死信队列
try {
	processMessage(record);
}catch (Exception e) {
	producer.send(new ProducerRecord<>("dlq-topic", record.key(), record.value()));
}

这个方式虽然不能彻底避免消息丢失,但能保证消息不会轻易丢失,特别是一些重要业务场景中,消息的可靠性至关重要。

1.5.2.5 多副本存储

Kafka 还有一个核心功能,就是多副本机制,即消息在多个 broker 上都有副本。这样即使某个 broker 挂了,其他副本也能提供消息。
通过设置 replication.factor 参数,我们可以指定 Kafka 每条消息的副本数,确保即使一台机器挂了,消息也不会丢失。

# Kafka Topic 多副本配置
replication.factor=3

1.5.3 消息丢失补救措施

最后,真正发现消息丢失了,怎么办呢?
这里有一些基本的补救措施:

  • 检查消费端日志
    首先要确定消息到底有没有消费。如果消费端日志显示消费失败,重新处理即可。
  • 重发消息
    如果消费端确实没处理成功,可以将消息重新发送到 Kafka,或者从备份中恢复并重放消息。
  • 处理丢失后的补偿
    业务上可能会涉及补偿措施,比如通知相关人员手动处理,或者对丢失的数据进行回补。

1.6 消息积压

1.6.1 积压原因

消息积压无非是消费者处理速度赶不上生产者。这里可能有几种原因:

  • 消费者数量不足:一个消费者处理不过来,瓶颈就在这。
  • 消费者代码逻辑复杂:比如在消费消息时还顺便做了“情感分析”或者“人脸识别”,自己 CPU 被掏空了,谁也救不了。
  • 网络带宽限制:消费消息的流量过大,网络成了瓶颈。
  • 分区(partition)数量不足Kafka 的消费是按分区并行的,分区少自然处理慢。
  • 磁盘 I/O 高负载:磁盘写入和读取速度达不到要求,导致积压。

1.6.2 定位问题

比如:

  • 积压从什么时候开始的? 找到积压的起点,看是哪一段时间的消息最集中。
  • 生产速度和消费速度对比? 如果消费速度很慢,重点优化消费者;如果生产速度爆炸,那可能得限流。
  • 消费者和分区数量匹配吗? Kafka 的消费是基于分区的,一个分区只能被一个消费者线程读取。
    Kafka 的设计决定了一个分区只能被一个消费者实例消费,且一个消费者实例只能属于一个消费组。因此,在同一个消费组内,多个线程是无法并发消费同一个分区的消息的。

注意:Kafka Consumer 实例不是线程安全的。即使多个线程被分配了不同的分区,也可能导致意想不到的错误。

可以用 Kafka 自带的工具,比如 kafka-consumer-groups.sh,查看消费组的 Lag(消息积压的数量)。命令如下:

kafka-consumer-groups.sh --bootstrap-server <kafka_broker> --group <consumer_group> --describe

这样你能看到哪个分区积压最严重,以及每个消费者的消费速度。

1.6.3 解决问题

1.6.3.1 紧急解决

如果需要紧急搞定积压,不用讲什么高深原理,直接说以下几招:
扩容消费者增加消费者实例数,用多线程或多实例提高消费能力。代码大概长这样:

Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("group.id", "consumer-group");
props.put("enable.auto.commit", "true");
props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");
props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");

for (int i = 0; i < 10; i++) { // 启动多个消费者线程
    new Thread(() -> {
        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
        consumer.subscribe(Arrays.asList("topic"));
        while (true) {
            ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));
            for (ConsumerRecord<String, String> record : records) {
                System.out.printf("offset = %d, key = %s, value = %s%n", record.offset(), record.key(), record.value());
            }
        }
    }).start();
}

这段代码启动了多个消费者线程,但要注意 Kafka 的分区数是有限的,消费者太多并没有意义。

限流生产者如果生产者写入过快,可以临时降低生产速度:

Properties props = new Properties();
props.put("acks", "all");
props.put("retries", 0);
props.put("batch.size", 16384); // 调小批次大小
props.put("linger.ms", 10); // 增大延迟时间
props.put("buffer.memory", 33554432);
KafkaProducer<String, String> producer = new KafkaProducer<>(props);

调整消费偏移量如果数据不需要全量消费,可以选择调整消费偏移量,直接从最新的消息开始消费:

consumer.seekToEnd(Collections.emptyList());

1.6.3.2 长期解决

应急搞定后,得解决根本问题,不然下次还得踩坑。
增加分区数量如果分区太少,可以通过增加分区提高并发能力。但注意:分区增加后,原有数据不会自动均衡,需要重新分配。

kafka-topics.sh --alter --zookeeper localhost:2181 --topic <topic_name> --partitions <new_partition_count>

优化消费者逻辑看看你的消费者代码,是不是做了不必要的复杂逻辑,比如调用慢速 API 或者处理大文件。可以考虑把耗时操作异步化,用线程池来优化性能。
监控和报警建立 Kafka 的 Lag 监控,实时报警积压情况。或者直接用 Kafka 自带的 JMX 指标。

posted @ 2022-03-30 09:52  上善若泪  阅读(931)  评论(0编辑  收藏  举报