基于kafka 2.12-2.0.0版本
kafka-clients 2.0.0
本文是《深入理解Kafka核心设计与实践原理》的读书笔记、参考了https://www.infoq.cn/article/kafka-analysis-part-8。
at most once:最多一次。消息可能丢失,但对不会重复
at least once:最少一次。消息绝不会丢失,但可能重复
exactly once:恰好一次。每条消息肯定会被传输一次且仅传输一次
对于Kafka的生产者而言,ACK机制保证了at least once语义。
对于Kafka的消费者而言,消费者处理消息和提交消费位移的顺序决定了消费者是哪种消息传输保障。
Kafka从0.11.0.0版本开始引入了幂等性和事务这两个特性,以此来实现EOS(exactly once semantics),精确一次处理的语义
所谓幂等操作,就是对接口的多次调用所产生的结果和调用一次是一致的。
生产者在进行重试的时候有可能会重复下入消息,而使用Kafka的幂等性功能之后就可以避免这种情况。
把生产者客户端参数enable.idempotence设置为true
确保幂等性功能正常,需要确保生产者客户端的其他几个参数不配置错
retries大于0
max.in.flight.requests.per.connection参数的值不能大于5,默认是5
acks要为-1
生产者客户端
为了实现 Producer 的幂等语义,Kafka 引入了Producer ID(即PID)和Sequence Number (即序列号)这两个概念。
每个新的 Producer 在初始化的时候会被分配一个唯一的 PID,该 PID 对用户完全透明而不会暴露给用户。
对于每个 PID,消息发送到的每一个分区都有对应的序列号,这些序列号从0开始单调递增。
生产者每发送一条消息就会将
服务端Broker
Broker 端也会为每一对
维护一个序列号,并且每次 Commit 一条消息时将其对应序号递增。
对于收到的每一条消息,只有当它的序列号的值(SN_new)比broker端中维护的对应的序列号的值(SN_old)大1(即SN_new=SN_old+1)时,broker才会接收它。
如果SN_new < SN_old + 1 ,说明消息被重复写入,broker可以直接将其丢弃。
如果SN_new > SN_old + 1 ,说明中间有数据尚未写入,出现了乱序,暗示可能有消息丢失,对应的生产者会抛出OutOfOrderSequenceException异常,这是一个严重的异常,后续的诸如send() beginTransaction() commitTransaction等方法的调用都会抛出IllegalStateException异常
注意:引入序列号来实现幂等也只是针对每一对
幂等性并不能跨多个分区运作,而事务可以弥补这个缺陷。
事务可以保证对多个分区写入操作的原子性。
操作的原子性是指:多个操作要么全部成功,要么全部失败,不存在部分成功、部分失败的可能。
Kafka中的事务可以使应用程序将消息消费、生产消费、提交消费位移当作原子操作来处理,同时成功或失败,即使生产或者消费会跨多个分区
1.客户端配置需要提供位移的transactionalId,这个transactionId通过客户端参数transactional.id来显示设置
2.事务要求生产者开启幂等性
1. transactionId与PID一一对应,两者之间所不同的是transactionId由用户显示设置,而PID是由Kafka内部分配的。
2.为了保证新的生产者启动后具有相同transactionId的旧生产者能够立即失效,每个生产者通过transactionId获取PID的同时,还会获取一个单调递增的producer epoch
从生产者角度分析:
Kafka通过事务可以保证跨生产者会话的消息幂等发送,以及跨生产者会话的事务恢复。
前者表示具有相同transactionId的新生产者实例被创建且工作的时候,旧的且拥有相同transactionId的生产者实例将不再工作。
后者指当某个生产者实例宕机后,新的生产者实例可以保证任何未完成的旧事务要么被提交Commit,要么被中止Abort。如此可以使新的生产者实例从一个正常的状态开始工作。
从消费者的角度分析:
事务能保证的语义相对偏弱。处于一下原因,Kafka并不能保证已提交的事务中所有消息都能够被消费:
1.对采用日志压缩策略的主题而言,事务中的某些消息有可能被清理(相同key的消息,后写入的消息会覆盖前写入的消息)
2.事务中消息可能分布在同一个分区的多个日志分段LogSegment中,当老的日志分段被删除时,对应的消息可能会丢失
3.消费者可以通过seek()方法访问任意offset的消息,从而可能遗漏事务中的部分消息。
4.消费者在消费时可能没有分配到事务内所有分区,如此它就不能读取事务中的所有消息。
事务的隔离级别:
在消费端有一个参数isolation.level,用来配置事务的隔离级别的。
默认是 read_uncommitted,读未提交,也就是消费端应用可以消费到未提交的事务。
这个参数还可以设置为 read_committed ,只有已经提交的事务,消费端才能消费到。
例如,生产者开启事务并向某个分区发送3条消息msg1 msg2 msg3 ,在执行commitTransaction()或者abortTransaction()方法前,设置为read_committed的话,消费端应用是消费不到这些消息的。
不过在KafkaConsumer内部会缓存这些消息,知道生产者执行commitTransaction()方法之后它才能将这些消息推送给消费端应用。
反之,如果生产者执行了abortTransaction()方法,那么KafkaConsumer会将这些缓存的消息丢弃而不推送给消费端应用。
为了区分写入 Partition 的消息被 Commit 还是 Abort,Kafka 引入了一种特殊类型的消息,即Control Batch 控制消息。
控制消息一共有两种:COMMIT和ABORT,分别用来表征事务已经成功提交或者已经被成功中止。
KafkaConsumer可以通过这个控制消息来判断应对的事务时被提交了还是被中止了,然后结合isolation.levl配置的隔离界别来决定是否将对应的消息返回给消费端应用。
注意:Control Batch对消费端应用不可见。
Kafka还引入了事务协调器TransactionCoordinator来负责处理事务,这点可以类比一下组协调器GroupCoordinator。
每个生产者都会被指派一个特定的TransactionCoordinator,所有的事务逻辑包括分派PID等都是由TransactionCoordinator来负责实施的。
TransactionCoordinator会将事务状态持久化到内部主题__transaction_state中
示例是,消费了一个数据后做点逻辑加工,然后发到另外一个主题里面。俗称消费----生产模型
package com.sid;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.clients.consumer.OffsetAndMetadata;
import org.apache.kafka.clients.producer.KafkaProducer;
import org.apache.kafka.clients.producer.ProducerRecord;
import org.apache.kafka.clients.producer.RecordMetadata;
import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.common.errors.ProducerFencedException;
import java.time.Duration;
import java.util.*;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
public class ConsumerTranProducerTest {
public static void main(String[] args) {
Properties properties = new Properties();
properties.put("bootstrap.servers", KafkaProperties.BROKER_LIST);
properties.put("request.required.acks", "1");
properties.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
properties.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
//初始化生产者和消费者
KafkaProducer producer = new KafkaProducer<>(properties);
KafkaConsumer consumer= new KafkaConsumer(properties);
consumer.subscribe(Collections.singletonList(KafkaProperties.TOPIC2));
//初始化事务
producer.initTransactions();
while (true){
ConsumerRecords consumerRecords = consumer.poll(Duration.ofMillis(1000));
if(!consumerRecords.isEmpty()){
Map offsets = new HashMap<>();
//开启事务
producer.beginTransaction();
try {
for(TopicPartition partition : consumerRecords.partitions()){
List> partitionRecords = consumerRecords.records(partition);
for(ConsumerRecord record : partitionRecords){
//do some logical processing.
ProducerRecord producerRecord = new ProducerRecord<>("topic-sink",record.key(),record.value());
//消费--生产模型
producer.send(producerRecord);
}
long lastConsumedOffset = partitionRecords.get(partitionRecords.size()-1).offset();
offsets.put(partition,new OffsetAndMetadata(lastConsumedOffset+1));
}
//提交消费位移
producer.sendOffsetsToTransaction(offsets,"groupId");
//提交事务
producer.commitTransaction();
}catch (ProducerFencedException e){
//中止事务
producer.abortTransaction();
}
}
}
}
}
注意:在使用KafkaConsumer的时候要将enable.auto.commit参数设置为false,代码里也不能手动提交消费偏移量,而是使用producer.sendOffsetsToTransaction()方法来提交消费偏移量,这样才能保存消费偏移量的提交和生产者发送消息到某个主题是在一个事务中的。
以最复杂的consume-transform-produce的流程为例来分析Kafka事务的实现原理
1.生产者找出对应的TransactionCoordinator所在的broker节点。
与查找GroupCoordinator节点一样,也是通过FindCoorinatorRequest请求来实施的,只不过FindCoorinatorRequest中的入参coordinator_type由原来的0变成了1.
2.Kafka服务端收到FindCoorinatorRequest请求之后,会根据coorinator_key也就是trsactionalId查找对应的TransactionCoordinator节点。
如果找到会返回其对应的Node、id、host和port信息。
具体查找方式根据transactionalId的哈希值计算主题__transaction_state中的分区编号
Utils.abs(transactionalId.hashCode) % transactionTopicPartitionCount
其中transactionTopicPartitionCount为主题__transaction_state中的分区个数,这个可以通过broker端参数transaction.state.log.num.partitions来配置,默认是50
找到对应的分区之后,再寻找此分区leader副本所在的Broker节点,该broker节点,即为这个TransactionalId对应的TransactionCoordinator节点。
再找到TransactionCoordinator节点之后,就需要为当前生产者分配一个PID了,但凡是开启了幂等性功能的生产者都必须执行这个操作。
1.生产者获取PID的操作时通过InitProducerIdRequest请求来实现的
InitProducerIdRequest请求中包含的transactional_id表示事务的transactionalId,transaction_timeout_ms参数表示TransactionCoordinator等待事务状态更新的超时时间,通过生产者客户端的配置可配置transaction.timeout.ms,默认为60000
2.TransactionCoordinator对InitProducerIdRequest请求的处理
当TransactionCoordinator第一次收到包含transactionalId的InitProducerIdRequest请求时,它会把transactionalId和对应的PID以消息(我们把这类消息称为事务日志消息)的形式保存到主题__transaction_state中。
其中transaction_status包含Empty(0)、Ongoing(1)、PrepareCommit(2)、PrepareAbort(3)、CompleteCommit(4)、CompleteAbort(5)、Dead(6)这几种状态。
在存入主题__transaction_state之前,事务日志消息同样会根据单独的transactionalId来计算要发送的分区。
除此以外,InitProducerIdRequest请求还会触发以下任务:
增加该PID对应的producer_epoch。具有相同PID但producer_epoch小于该producer_epoch的其他生产者新开启的事务将被拒绝。
恢复Commit或者中止Abort之前的生产者未完成的事务。
注意:InitPidRequest的处理过程是同步阻塞的。一旦该调用正确返回,Producer 即可开始新的事务。
如果事务特性未开启,InitPidRequest可发送至任意 Broker,并且会得到一个全新的唯一的 PID。该 Producer 将只能使用幂等特性以及单一 Session 内的事务特性,而不能使用跨 Session 的事务特性。
Kafka 从 0.11.0.0 版本开始,通过KafkaProducer的beginTransaction()方法可以开启一个事务。
调用该方法后,生产者本地会标记已经开启了一个新事务,只有在生产者发送第一条消息之后TransactionCoordinator才会认为该事务已经开启。
这一阶段,包含了整个事务的数据处理过程,并且包含了多种请求。
1)AddPartitionsToTxnRequest
当生产者给一个新的分区(TopicPartition)发送数据前,它需要向TransactionCoordinator发送AddPartitionsToTxnRequest请求。
这个请求会让TransactionCoordinator将
有了这个对照关系后,就可以在后续的步骤中为每个分区设置COMMIT或ABORT标记。
如果该分区是对应事务中的第一个分区,那么此时TransactionCoordinator还会启动对该事务的计时。(每个事务都有自己的超时时间)
2) ProduceRequest
生产者通过ProduceRequest请求发送消息(ProducerBatch)到用户自定义主题中,这一点和发送普通消息时相同。
和普通消息不同的是,ProducerBatch中会包含实质的PID、producer_epoch和sequence number
3) AddOffsetsToTxnRequest
通过KafkaProducer的sendOffsetsToTransaction()方法可以在一个事务批次里面处理消息的消费和发送。
这个方法会向TransactionCoordinator节点发送AddOffsetsToTxnRequest请求,
TransactionCoordinator收到这个请求之后会通过groupId来推导出在__consumer_offsets中的分区,
之后TransactionCoordinator会将这个分区保存在__transaction_state中,并将其状态记为BEGIN
该方法会阻塞直到收到响应。
4) TxnOffsetCommitRequest
这个请求也是sendOffsetsToTransaction()方法中的一部分,在处理完AddOffsetsToTxnRequest之后,生产者还会发送TxnOffsetCommitRequest请求给GroupCoordinator,从而将被刺事务中包含的消费位移信息offsets存储到主题__consumer_offsets中
在此过程中,Consumer Coordinator会通过 PID 和对应的 epoch 来验证是否应该允许该 Producer 的该请求。
注意:
1.写入__consumer_offsets的 Offset 信息在当前事务 Commit 前对外是不可见的。也即在当前事务被 Commit 前,可认为该 Offset 尚未 Commit,也即对应的消息尚未被完成处理。
2.Consumer Coordinator并不会立即更新缓存中相应
一旦上述数据写入操作完成,我们就可以调用KafkaProducer的commitTransaction()方法或者abortTransaction()方法以结束当前事务。
1)EndTxnRequest
无论调用CommitTransaction()方法还是abortTransaction()方法,生产者都会向TransactionCoordinator发送EndTxnRequest请求,一次来通知它提交(Commit)事务还是中止(Abort)事务
TransactionCoordinator收到EndTxnRequest请求后,会进行如下操作:
1.将PREPARE_COMMIT或PREPARE_ABORT消息写入主题__transaction_state,如上图中步骤 5.1 所示
2.通过WriteTxnMarkersRequest请求将COMMIT或ABORT信息写入用户所使用的普通主题和__consumer_offsets,如上图中步骤 5.2 所示
3.将COMPLETE_COMMIT或COMPLETE_ABORT信息写入内部主题__transaction_state,如上图中步骤 5.3 所示
补充说明:
对于commitTransaction
方法,它会在发送EndTxnRequest
之前先调用 flush 方法以确保所有发送出去的数据都得到相应的 ACK。
对于abortTransaction
方法,在发送EndTxnRequest
之前直接将当前 Buffer 中的事务性消息(如果有)全部丢弃,但必须等待所有被发送但尚未收到 ACK 的消息发送完成。
上述第二步是实现将一组读操作与写操作作为一个事务处理的关键。
因为 Producer 写入的数据 Topic 以及记录 Comsumer Offset 的 Topic 会被写入相同的Transactin Marker
,所以这一组读操作与写操作要么全部 COMMIT 要么全部 ABORT。
2)WriteTxnMarkerRequest
WriteTxnMarkerRequest由TransactionCoordinator发送给当前事务涉及到的各个分区的 Leader。
当节点收到该请求后,对应的 Leader 会将对应的COMMIT(PID)或者ABORT(PID)控制信息写入日志。
3)写入最终的COMPLETE_COMMIT
或COMPLETE_ABORT
消息
Transaction Coordinator会将最终的COMPLETE_COMMIT或COMPLETE_ABORT消息写入主题__transaction_state中以标明该事务结束,如上图中步骤 5.3 所示。
此时,Transaction Log中所有关于该事务的消息全部可以移除。
当然,由于 Kafka 内数据是 Append Only 的,不可直接更新和删除,这里说的移除只是将其标记为 null 从而在 Log Compact 时不再保留。
另外,COMPLETE_COMMIT或COMPLETE_ABORT的写入并不需要得到所有 Rreplica 的 ACK,因为如果该消息丢失,可以根据事务协议重发。
补充说明,如果参与该事务的某些
在该
InvalidProducerEpoch
这是一种 Fatal Error,它说明当前 Producer 是一个过期的实例,有Transaction ID相同但 epoch 更新的 Producer 实例被创建并使用。此时 Producer 会停止并抛出 Exception。
InvalidPidMapping
Transaction Coordinator没有与该Transaction ID对应的 PID。此时 Producer 会通过包含有Transaction ID的InitPidRequest请求创建一个新的 PID。
NotCorrdinatorForGTransactionalId
该Transaction Coordinator不负责该当前事务。Producer 会通过FindCoordinatorRequest请求重新寻找对应的Transaction Coordinator。
InvalidTxnRequest
违反了事务协议。正确的 Client 实现不应该出现这种 Exception。如果该异常发生了,用户需要检查自己的客户端实现是否有问题。
CoordinatorNotAvailable
Transaction Coordinator仍在初始化中。Producer 只需要重试即可。
DuplicateSequenceNumber
发送的消息的序号低于 Broker 预期。该异常说明该消息已经被成功处理过,Producer 可以直接忽略该异常并处理下一条消息
InvalidSequenceNumber
这是一个 Fatal Error,它说明发送的消息中的序号大于 Broker 预期。此时有两种可能
InvalidTransactionTimeout
InitPidRequest调用出现的 Fatal Error。它表明 Producer 传入的 timeout 时间不在可接受范围内,应该停止 Producer 并报告给用户。
TransactionCoordinator
失败PREPARE_COMMIT/PREPARE_ABORT
前失败Producer 通过FindCoordinatorRequest
找到新的Transaction Coordinator
,并通过EndTxnRequest
请求发起COMMIT
或ABORT
流程,新的Transaction Coordinator
继续处理EndTxnRequest
请求——写PREPARE_COMMIT
或PREPARE_ABORT
,写Transaction Marker
,写COMPLETE_COMMIT
或COMPLETE_ABORT
。
PREPARE_COMMIT/PREPARE_ABORT
后失败此时旧的Transaction Coordinator
可能已经成功写入部分Transaction Marker
。新的Transaction Coordinator
会重复这些操作,所以部分 Partition 中可能会存在重复的COMMIT
或ABORT
,但只要该 Producer 在此期间没有发起新的事务,这些重复的Transaction Marker
就不是问题。
COMPLETE_COMMIT/ABORT
后失败旧的Transaction Coordinator
可能已经写完了COMPLETE_COMMIT
或COMPLETE_ABORT
但在返回EndTxnRequest
之前失败。该场景下,新的Transaction Coordinator
会直接给 Producer 返回成功。
transaction.timeout.ms
当 Producer 失败时,Transaction Coordinator必须能够主动的让某些进行中的事务过期。否则没有 Producer 的参与,Transaction Coordinator无法判断这些事务应该如何处理,这会造成:
为了避免上述问题,Transaction Coordinator会周期性遍历内存中的事务状态 Map,并执行如下操作
TransactionID
某Transaction ID的 Producer 可能很长时间不再发送数据,Transaction Coordinator没必要再保存该Transaction ID与PID等的映射,否则可能会造成大量的资源浪费。
因此需要有一个机制探测不再活跃的Transaction ID并将其信息删除。
Transaction Coordinator会周期性遍历内存中的Transaction ID与PID映射,如果某Transaction ID没有对应的正在进行中的事务并且它对应的最后一个事务的结束时间与当前时间差大于transactional.id.expiration.ms(默认值是 7 天),则将其从内存中删除并在Transaction Log中将其对应的日志的值设置为 null 从而使得 Log Compact 可将其记录删除。
Kafka 的事务机制与《 MVCC PostgreSQL 实现事务和多版本并发控制的精华》一文中介绍的 PostgreSQL 通过 MVCC 实现事务的机制非常类似,对于事务的回滚,并不需要删除已写入的数据,都是将写入数据的事务标记为 Rollback/Abort 从而在读数据时过滤该数据。
Kafka 的事务机制与《分布式事务(一)两阶段提交及 JTA 》一文中所介绍的两阶段提交机制看似相似,都分 PREPARE 阶段和最终 COMMIT 阶段,但又有很大不同:
1.Kafka 事务机制中,PREPARE 时即要指明是PREPARE_COMMIT还是PREPARE_ABORT,并且只须在Transaction Log中标记即可,无须其它组件参与。
而两阶段提交的 PREPARE 需要发送给所有的分布式事务参与方,并且事务参与方需要尽可能准备好,并根据准备情况返回Prepared或Non-Prepared状态给事务管理器。
2.Kafka 事务中,一但发起PREPARE_COMMIT或PREPARE_ABORT,则确定该事务最终的结果应该是被COMMIT或ABORT。
而分布式事务中,PREPARE 后由各事务参与方返回状态,只有所有参与方均返回Prepared状态才会真正执行 COMMIT,否则执行 ROLLBACK
3.Kafka 事务机制中,某几个 Partition 在 COMMIT 或 ABORT 过程中变为不可用,只影响该 Partition 不影响其它 Partition。
两阶段提交中,若唯一收到 COMMIT 命令参与者 Crash,其它事务参与方无法判断事务状态从而使得整个事务阻塞
4.Kafka 事务机制引入事务超时机制,有效避免了挂起的事务影响其它事务的问题
5.Kafka 事务机制中存在多个Transaction Coordinator实例,而分布式事务中只有一个事务管理器
Zookeeper 的原子广播协议与两阶段提交以及 Kafka 事务机制有相似之处,但又有各自的特点
1.Kafka 事务可 COMMIT 也可 ABORT。
而 Zookeeper 原子广播协议只有 COMMIT 没有 ABORT。当然,Zookeeper 不 COMMIT 某消息也即等效于 ABORT 该消息的更新。
2.Kafka 存在多个Transaction Coordinator实例,扩展性较好。
而 Zookeeper 写操作只能在 Leader 节点进行,所以其写性能远低于读性能。
3.Kafka 事务是 COMMIT 还是 ABORT 完全取决于 Producer 即客户端。
而 Zookeeper 原子广播协议中某条消息是否被 COMMIT 取决于是否有一大半 FOLLOWER ACK 该消息。