Kafka核心技术与实战 <二>

532 阅读15分钟

其他更多java基础文章:
java基础学习(目录)


这系列是根据极客时间《Kafka核心技术与实战》这个课程做的笔记

本篇目录

  • Kafka拦截器
  • 生产者消息分区机制
  • 生产者压缩算法面面观
  • 幂等生产者和事务生产者
  • 生产者无消息丢失配置怎么实现?
  • Java生产者管理TCP连接
  • Java消费者管理TCP链接

Kafka拦截器

Kafka 拦截器分为生产者拦截器消费者拦截器

  • 生产者拦截器允许你在发送消息前以及消息提交成功后植入你的拦截器逻辑;
  • 消费者拦截器支持在消费消息前以及提交位移后编写特定逻辑。 值得一提的是,这两种拦截器都支持链的方式,即你可以将一组拦截器串连成一个大的拦截器,Kafka 会按照添加顺序依次执行拦截器逻辑。
Properties props = new Properties(); 
List<String> interceptors = new ArrayList<>(); 
interceptors.add("com.yourcompany.kafkaproject.interceptors.AddTimestampInterceptor"); 
interceptors.add("com.yourcompany.kafkaproject.interceptors.UpdateCounterInterceptor"); 
props.put(ProducerConfig.INTERCEPTOR_CLASSES_CONFIG, interceptor);

你自己编写的所有 Producer 端拦截器实现类都要继承 org.apache.kafka.clients.producer.ProducerInterceptor 接口。该接口是 Kafka 提供 的,里面有两个核心的方法。

  1. onSend:该方法会在消息发送之前被调用。如果你想在发送之前对消息“美美容”,这 个方法是你唯一的机会。
  2. onAcknowledgement:该方法会在消息成功提交或发送失败之后被调用。还记得我在 上一期中提到的发送回调通知 callback 吗?onAcknowledgement 的调用要早于 callback 的调用。值得注意的是,这个方法和 onSend 不是在同一个线程中被调用的, 因此如果你在这两个方法中调用了某个共享可变对象,一定要保证线程安全哦。还有一 点很重要,这个方法处在 Producer 发送的主路径中,所以最好别放一些太重的逻辑进 去,否则你会发现你的 Producer TPS 直线下降。

同理,指定消费者拦截器也是同样的方法,只是具体的实现类要实现 org.apache.kafka.clients.consumer.ConsumerInterceptor 接口,这里面也有两个核心 方法。

  1. onConsume:该方法在消息返回给 Consumer 程序之前调用。也就是说在开始正式处 理消息之前,拦截器会先拦一道,搞一些事情,之后再返回给你。
  2. onCommit:Consumer 在提交位移之后调用该方法。通常你可以在该方法中做一些记 账类的动作,比如打日志等。

一定要注意的是,指定拦截器类时要指定它们的全限定名

生产者消息分区机制

为什么使用分区的概念而不是直接 使用多个主题呢?

其实分区的作用就是提供负载均衡的能力,或者说对数据进 行分区的主要原因,就是为了实现系统的高伸缩性 (Scalability)。不同的分区能够被放置到不同节点的机器 上,而数据的读写操作也都是针对分区这个粒度而进行的, 这样每个节点的机器都能独立地执行各自分区的读写请求处 理。并且,我们还可以通过添加新的节点机器来增加整体系 统的吞吐量。

都有哪些分区策略?

如果要自定义分区策略,你需要显式地配置生产者端的参数partitioner.class

  • 轮询策略,也称 Round-robin 策略,即顺序分配。

  • 随机策略,也称 Randomness 策略。

  • 按消息键保序策略,也称 Key-ordering 策略。Kafka 允许为每条消息定义消息键,简称为 Key。这个 Key 的作用非常大,它可以是一个有着明确业务含义的字符串, 比如客户代码、部门编号或是业务 ID 等;也可以用来表征 消息元数据。。一旦消息被定义了 Key,那么你就可以保证同一个 Key 的所有消息都进入到相同的分区里面,由于每个分区下 的消息处理都是有顺序的,故这个策略被称为按消息键保序策略。

前面提到的 Kafka 默认分区策略实际上同时实现了两种策略:如果指定了 Key,那么默认实现按消息键保序策略;如 果没有指定 Key,则使用轮询策略。

生产者压缩算法面面观

怎么压缩

  • 新版本改进将每个消息公共部分取出放在外层消息集合,例如消息的 CRC 值
  • 新老版本的保存压缩消息的方法变化, V1版本多条消息进行压缩然后保存到外层消息的消息体字段中;而V2 版本的做法是对整个消息集合进行压缩。

何时压缩:

  • 正常情况下都是producer压缩,节省带宽,磁盘存储
  • 例外情况,broker端也会参与压缩
    • broker端和producer端使用的压缩方法不同
    • broker与client交互,消息版本不同

何时解压缩:

  • consumer端解压缩
  • broker端解压缩,用来对消息执行验证

总结

优化:选择适合自己的压缩算法,是更看重吞吐量还是压缩率。其次尽量server和client保持一致,这样不会损失kafka的zero copy优势

在实际使用中,GZIP、Snappy、LZ4 甚至是 zstd 的表现各有千秋。但对于 Kafka 而言, 它们的性能测试结果却出奇得一致,即在吞吐量方面:LZ4 > Snappy > zstd 和 GZIP;而 在压缩比方面,zstd > LZ4 > GZIP > Snappy。具体到物理资源,使用 Snappy 算法占用 的网络带宽最多,zstd 最少,这是合理的,毕竟 zstd 就是要提供超高的压缩比;在 CPU 使用率方面,各个算法表现得差不多,只是在压缩时 Snappy 算法使用的 CPU 较多一些, 而在解压缩时 GZIP 算法则可能使用更多的 CPU。

幂等生产者和事务生产者

Kafka 对 Producer 和 Consumer 要处理的消息提供什么样的承诺。常见的承诺有以下三种:

  • 最多一次(at most once):消息可能会丢失,但绝不会 被重复发送。
  • 至少一次(at least once):消息不会丢失,但有可能被 重复发送。
  • 精确一次(exactly once):消息不会丢失,也不会被重 复发送

目前,Kafka 默认提供的交付可靠性保障是第二种,即至少一次。 我们说过消息“已提交”的含义,即只有 Broker 成功“提交”消息且 Producer 接到 Broker 的应答才会认为该消息成功发送。不过倘若消息成 功“提交”,但 Broker 的应答没有成功发送回 Producer 端(比如网络出现瞬时抖动),那么 Producer 就无法确定消息是否真的提交成功了。因此,它只能选择重试,也就是再次发送相同的消息。这就是 Kafka 默认提供至少一次可靠性保障的原因,不过这会导致消息重复发送

Kafka 也可以提供最多一次交付保障,只需要让 Producer 禁止重试即可。这样一来,消息要么写入成功,要么写入失败,但绝不会重复发送。我们通常不会希望出现消息丢失的情况,但一些场景里偶发的消息丢失其实是被允许的,相反,消息重复是绝对要避免的。此时,使用最多一次交付保障就是最恰当的

幂等性 Producer

指定 Producer 幂等性的方法很简单,仅需要设置一个参数即可,即 props.put(“enable.idempotence”, ture),或props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CO NFIG, true)

enable.idempotence 被设置成 true 后,Producer 自动升级成幂等性 Producer,其他所有的代码逻辑都不需要改变。Kafka 自动帮你做消息的重复去重。底层具体的原理很简单,就是经典的用空间去换时间的优化思路,即在 Broker 端多保存一些字段。当 Producer 发送了具有相同 字段值的消息后,Broker 能够自动知晓这些消息已经重复 了,于是可以在后台默默地把它们“丢弃”掉。当然,实际的实现原理并没有这么简单,但你大致可以这么理解。

幂等性 Producer 的作用范围

首先,它只能保证单分区上的幂等性,即一个幂等性 Producer 能够保证某个主题的一个分区上不出现重复消息,它无法实现多个分区的幂等性。其次,它只能实现单会话上的幂等性,不能实现跨会话的幂等性。 这里的会话,你可以理解为 Producer 进程的一次运行。当你重启了 Producer 进程之后,这种幂等性保证就丧失了。

事务型 Producer

事务型 Producer 能够保证将消息原子性地写入到多个分区 中。这批消息要么全部写入成功,要么全部失败。另外,事 务型 Producer 也不惧进程的重启。Producer 重启回来 后,Kafka 依然保证它们发送消息的精确一次处理。
设置事务型 Producer 的方法也很简单,满足两个要求即可:

  • 和幂等性 Producer 一样,开启 enable.idempotence = true
  • 设置 Producer 端参数 transctional. id。最好为其设置一个有意义的名字。

此外,你还需要在 Producer 代码中做一些调整,如这段代 码所示:

producer.initTransactions(); 
try{
            producer.beginTransaction();
            producer.send(record1);
            producer.send(record2);
            producer.commitTransaction();
} catch (KafkaException e) {
            producer.abortTransaction();
}

在 Consumer 端,读取事务型 Producer 发送的消息也是需要一些变更的。修改起来也很简单,设置 isolation.level 参数的值即可。当前这个参数有两个取值:

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

生产者无消息丢失配置

ack机制详解

生产者客户端要求kafka集群中选举的领导者在确认请求完成之前已收到的确认数。可能的值是:0,1,all及-1(其等同于all)。

  • 如果将此值设置为“ 0”,则表示“发完消息即忘记”。生产者客户端只管闷头继续发送消息,无视kafka是否能够保存它发送的消息。当每个消息都很重要时,这绝对不是一个好主意。
  • 将其设置为“ 1” 意味着kafka集群能够保留消息时,生产者客户端才继续发送消息。听起来好多了,但可以想象一下当Kafka集群选举的领导者服务器节点崩溃了,并且尚未将消息复制到集群中其他备份节点时,这些消息也会丢失。
  • 属性值“ all”表明它可以确保消息在所有备份节点上都持久存在,但是事情更加复杂,实际上,这意味着它是在同步的所有备份节点上写入操作。无论如何,“全部”(或“ -1”)是最安全的设置。

如前所述,生产者acks属性确定Kafka集群何时应确认该消息,并且all设置更加安全。在这种情况下,“all”一词是什么意思?这意味着all in-sync replicas。

min.insync.replicas当生产者将acks设置为“ all”(或“ -1”)时,min.insync.replicas指定必须确认写入才能使成功写入的最小备份副本数量。
如果无法满足此最小值,则生产者将引发异常(NotEnoughReplicas或NotEnoughReplicasAfterAppend)。
考虑场景:我们设置acks=all和min.insync.replicas=1(这是默认设置!)。网络不稳定,只有领导者处于同步状态(例如,其他kafka节点失去了与Zookeeper的连接)。生产者将写入消息,并根据min.insync.replicas配置确认了。因为网络不稳定,领导者节点无法与其他备份节点正常通讯,这条消息只保存在领导者节点上,这意味着该消息将永远不会复制到其他备份节点并丢失。

min.insync.replicas最小安全设置为2
默认值是1,会很危险,很容易忘记对其进行更改。在min.insync.replicas上配置的代理将成为所有新的主题(你可以每个主题进行配置)的默认。
同样,事务主题不使用此设置,它有自己的:transaction.state.log.min.isr

无消息丢失的配置

  • 不要使用 producer.send(msg),而要使用 producer.send(msg, callback)。记住,一 定要使用带有回调通知的 send 方法。
  • 设置 acks = all。 acks 是 Producer 的一个参数,代表了你对“已提交”消息的定义。 如果设置成 all,则表明所有副本 Broker 都要接收到消息,该消息才算是“已提交”。 这是最高等级的“已提交”定义。
  • 设置 retries 为一个较大的值。 这里的 retries 同样是 Producer 的参数,对应前面提到 的 Producer 自动重试。当出现网络的瞬时抖动时,消息发送可能会失败,此时配置了retries > 0 的 Producer 能够自动重试消息发送,避免消息丢失。
  • 设置 unclean.leader.election.enable = false。 这是 Broker 端的参数,它控制的是哪 些 Broker 有资格竞选分区的 Leader。如果一个 Broker 落后原先的 Leader 太多,那么 它一旦成为新的 Leader,必然会造成消息的丢失。故一般都要将该参数设置成 false,即不允许这种情况的发生。
  • 设置 replication.factor >= 3。 这也是 Broker 端的参数。其实这里想表述的是,最好将 消息多保存几份,毕竟目前防止消息丢失的主要机制就是冗余。
  • 设置 min.insync.replicas > 1。 这依然是 Broker 端参数,控制的是消息至少要被写入 到多少个副本才算是“已提交”。设置成大于 1 可以提升消息持久性。在实际环境中千万不要使用默认值 1。
  • 确保 replication.factor > min.insync.replicas。 如果两者相等,那么只要有一个副本挂 机,整个分区就无法正常工作了。我们不仅要改善消息的持久性,防止数据丢失,还要 在不降低可用性的基础上完成。推荐设置成 replication.factor = min.insync.replicas + 1。
  • 确保消息消费完成再提交。Consumer 端有个参数 enable.auto.commit,最好把它设 置成 false,并采用手动提交位移的方式。 就像前面说的,这对于单 Consumer 多线程 处理的场景而言是至关重要的。

各场景测试总结:

  1. 当acks=-1时,Kafka发送端的TPS受限于topic的副本数量(ISR中),副本越多TPS越低;
  2. acks=0时,TPS最高,其次为1,最差为-1,即TPS:acks_0 > acks_1 > ack_-1;
  3. min.insync.replicas参数不影响TPS;
  4. partition的不同会影响TPS,随着partition的个数的增长TPS会有所增长,但并不是一直成正比关系,到达一定临界值时,partition数量的增加反而会使TPS略微降低;
  5. Kafka在acks=-1,min.insync.replicas>=1时,具有高可靠性,所有成功返回的消息都可以落盘。

具体案例分析

如果上图所示,假设某个partition中的副本数为3,replica-0, replica-1, replica-2分别存放在broker0, broker1和broker2中。AR=(0,1,2),ISR=(0,1)。 设置request.required.acks=-1, min.insync.replicas=2,unclean.leader.election.enable=false。这里将broker0中的副本也称之为broker0起初broker0为leader,broker1为follower。

  • 当ISR中的replica-0出现crash的情况时,broker1选举为新的leader[ISR=(1)],因为受min.insync.replicas=2影响,write不能服务,但是read能继续正常服务。此种情况恢复方案:
    1. 尝试恢复(重启)replica-0,如果能起来,系统正常;
    2. 如果replica-0不能恢复,需要将min.insync.replicas设置为1,恢复write功能。
  • 当ISR中的replica-0出现crash,紧接着replica-1也出现了crash, 此时[ISR=(1),leader=-1],不能对外提供服务,此种情况恢复方案:
    1. 尝试恢复replica-0和replica-1,如果都能起来,则系统恢复正常;
    2. 如果replica-0起来,而replica-1不能起来,这时候仍然不能选出leader,因为当设置unclean.leader.election.enable=false时,leader只能从ISR中选举,当ISR中所有副本都失效之后,需要ISR中最后失效的那个副本能恢复之后才能选举leader, 即replica-0先失效,replica-1后失效,需要replica-1恢复后才能选举leader。保守的方案建议把unclean.leader.election.enable设置为true,但是这样会有丢失数据的情况发生,这样可以恢复read服务。同样需要将min.insync.replicas设置为1,恢复write功能;
    3. replica-1恢复,replica-0不能恢复,这个情况上面遇到过,read服务可用,需要将min.insync.replicas设置为1,恢复write功能;
    4. replica-0和replica-1都不能恢复,这种情况可以参考情形2.
  • 当ISR中的replica-0, replica-1同时宕机,此时[ISR=(0,1)],不能对外提供服务,此种情况恢复方案:尝试恢复replica-0和replica-1,当其中任意一个副本恢复正常时,对外可以提供read服务。直到2个副本恢复正常,write功能才能恢复,或者将将min.insync.replicas设置为1。

Java生产者管理TCP连接

Kafka(2.1.0)而言,Java Producer 端管理 TCP 连接的方式是:

  1. KafkaProducer 实例创建时启动 Sender 线程,从而创建与 bootstrap.servers 中所有 Broker 的 TCP 连接。
  2. KafkaProducer 实例首次更新元数据信息之后,还会再次创建与集群中所有 Broker 的 TCP 连接。
  3. 如果 Producer 端发送消息到某台 Broker 时发现没有与该 Broker 的 TCP 连接,那么也会立即创建连接。
  4. 如果设置 Producer 端 connections.max.idle.ms 参数大于 0,则步骤 1 中创建的 TCP 连接会被自动关闭;如果设置该参数 =-1,那么步骤 1 中创建的 TCP 连接将无 法被关闭,从而成为“僵尸”连接。
  5. Producer 通过 metadata.max.age.ms 参数定期地去更新元数据信息。该参数的默认值是 300000,即 5 分 钟,也就是说不管集群那边是否有变化,Producer 每 5 分 钟都会强制刷新一次元数据以保证它是最及时的数据。

我在这里稍微解释一下 bootstrap.servers 参数。它是 Producer 的核心参数之一,指定了这个 Producer 启动时 要连接的 Broker 地址。请注意,这里的“启动时”,代表 的是 Producer 启动时会发起与这些 Broker 的连接。因 此,如果你为这个参数指定了 1000 个 Broker 连接信息, 那么很遗憾,你的 Producer 启动时会首先创建与这 1000 个 Broker 的 TCP 连接。

在实际使用过程中,我并不建议把集群中所有的 Broker 信息都配置到 bootstrap.servers 中,通常你指定 3~4 台就足以了。因为 Producer 一旦连接到集群中的任一台 Broker,就能拿到整个集群的 Broker 信息,故没必要为 bootstrap.servers 指定所有的Broker。

Java消费者如何管理TCP链接的

何时创建

消费者和生产者不同,在创建KafkaConsumer实例时不会创建任何TCP连接。 原因是因为生产者入口类KafkaProducer在构建实例时,会在后台启动一个Sender线程,这个线程是负责Socket连接创建的。

TCP连接是在调用KafkaConsumer.poll方法时被创建。 在poll方法内部有3个时机创建TCP连接:

  • 发起findCoordinator请求时创建
    Coordinator(协调者)消费者端主键,驻留在Broker端的内存中,负责消费者组的组成员管理和各个消费者的位移提交管理。当消费者程序首次启动调用poll方法时,它需要向Kafka集群发送一个名为FindCoordinator的请求,确认哪个Broker是管理它的协调者。
  • 连接协调者时
    Broker处理了消费者发来的FindCoordinator请求后,返回响应显式的告诉消费者哪个Broker是真正的协调者。当消费者知晓真正的协调者后,会创建连向该Broker的socket连接。只有成功连入协调者,协调者才能开启正常的组协调操作。
  • 消费数据时
    消费者会为每个要消费的分区创建与该分区领导者副本所在的Broker连接的TCP.

创建多少

消费者程序会创建3类TCP连接:

  • 确定协调者和获取集群元数据
  • 连接协调者,令其执行组成员管理操作
  • 执行实际的消息获取

何时关闭TCP连接

  • 和生产者相似,消费者关闭Socket也分为主动关闭和Kafka自动关闭。
  • 主动关闭指通过KafkaConsumer.close()方法,或者执行kill命令,显示地调用消费者API的方法去关闭消费者。
  • 自动关闭指消费者端参数connection.max.idle.ms控制的,默认为9分钟,即如果某个socket连接上连续9分钟都没有任何请求通过,那么消费者会强行杀死这个连接。
  • 若消费者程序中使用了循环的方式来调用poll方法消息消息,以上的请求都会被定期的发送到Broker,所以这些socket连接上总是能保证有请求在发送,从而实现“长连接”的效果。
  • 当第三类TCP连接成功创建后,消费者程序就会废弃第一类TCP连接,之后在定期请求元数据时,会改为使用第三类TCP连接。对于一个运行了一段时间的消费者程序来讲,只会有后面两种的TCP连接。