1.消息的语义
1、At most once:最多一次消费,数据可能会丢失,但是不会产生数据被重复消费的情况,所以,该层语义的可靠性最低。
2、At least once:最少一次消费,数据肯定不会丢失,但是可能会导致数据被重复的处理。
3、Exactly once:精确性一次消费,这是我们有时候期望的结果,数据不仅不会丢失,而且数据也不会被重复的处理。该层语义的基础是 At least once。
2.RabbitMQ是怎么保证消息的语义的?
RabbitMQ 的消息语义分析是站着生产者的角度进行分析的。详细的消息语义参考 Kafka 的消息语义说实现。
(1)最多一次消费
生产者发送消息到 Broker,不管 Broker 有没有接收到数据。
(2)最少一次消费
利用RabbitMQ发送方的 发 送 方 确 认 机 制 \color{#FF3030}{发送方确认机制} 发送方确认机制或者 事 物 机 制 \color{#FF3030}{事物机制} 事物机制来保证消息到达了 Broker。当RabbitMQ的发送方确认机制没有收到响应ACK的时候,将重新发送该消息,直到Broker响应ACK。
(3)精确性一次消费
可利用最少一次消费和幂等性来实现消息的精确一次消费,这需要额外的保证。RabbitMQ本身暂没有提供对应的机制。
3.kafka是怎么保证消息的语义的?
1.Producer -> Kafka的过程
(1)由于通过网络发送数据,而网络并不总是可靠的,生成者发送出去的数据 Kafla 可能没有收到,在这种情况就意味着
数
据
可
能
会
丢
失
\color{#FF3030}{数据可能会丢失}
数据可能会丢失。
对应的消息语义就是 at most once(至多一次),即:数据最多就发送一次,下游的接收者是否收到不再关注。
(2)如果要想保证数据不丢失,可以
引
入
A
C
K
确
认
机
制
\color{#FF3030}{引入 ACK 确认机制}
引入ACK确认机制。即下游的接收方收到数据之后,反馈 ACK,上游收到对应的 ACK 则认为接收成功,如果过了超时时间(假设为20s),还没有收到 ACK,则认为接收失败,然后就会再次发送数据。
对应的 at least once(至少一次),即:同一条数据至少发送一次,可能发送多次,确保下游接收方一定能够收到数据。
at least once潜在的问题是?
当下游接收方收到数据并反馈了ack,但是反馈时由于网络故障,发送者没有及时收到接收方的响应 ACK,导致的最终的结果是:同一数据重复发送并被接收方重复处理。
常见处理方式
- 通过数据的唯一 id,下游的接收方可以实现幂等性操作。
- 幂等性操作指的是:代码执行多次和执行1次的结果是一样的。
- 通过数据的 id 递增性,可以确保数据处理的顺序性。
代码实现:
(1)producer 最多一次消费
/**
* 最多一次消费
*/
public void producer_atMostOnce() throws ExecutionException {
Properties props = new Properties();
props.put("key.serializer", "org.apache.kafka.common.serialization.IntegerSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "192.168.56.101:9092,192.168.56.102:9092");
props.put("acks", 0);
Producer<Integer, String> kafkaProducer = new KafkaProducer<>(props);
for (int i = 0; i < 100; i++) {
ProducerRecord<Integer, String> message
= new ProducerRecord<>("messageTest", "" + i);
kafkaProducer.send(message);
}
while (true) ;
}
(2)producer 最少一次消费
/**
* 最少一次消费
*/
public void producer_atLeastOnce() throws ExecutionException {
Properties props = new Properties();
props.put("key.serializer", "org.apache.kafka.common.serialization.IntegerSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "192.168.56.101:9092,192.168.56.102:9092");
// 生产者向主题发送教据时, 最终是向主题的分区发送,而分区可能有多个副本
// 如果有多个副本, 就会有Leader和follower
// - acks, all -> 表示当生产者接收到leader和所有的follower的ack, 才认为是成功
props.put("acks", "all");
// --acks, 1, 接收到Leader的ack就认为是成功了, 建议用1这个参数。因为all的等待时间可能会较长
props.put("acks", 1);
Producer<Integer, String> kafkaProducer = new KafkaProducer<>(props);
for (int i = 0; i < 100; i++) {
ProducerRecord<Integer, String> message
= new ProducerRecord<>("messageTest", "" + i);
kafkaProducer.send(message);
}
while (true) ;
}
(3)producer 精确性一次消费
/**
* 精确性一次消费
*/
public void producer_exactlyOnce() throws ExecutionException {
Properties props = new Properties();
props.put("key.serializer", "org.apache.kafka.common.serialization.IntegerSerializer");
props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "192.168.56.101:9092,192.168.56.102:9092");
props.put("acks", "all");
props.put("enable.idempotence", "true");
Producer<Integer, String> kafkaProducer = new KafkaProducer<>(props);
for (int i = 0; i < 100; i++) {
ProducerRecord<Integer, String> message = new ProducerRecord<>("messageTest", "" + i);
kafkaProducer.send(message);
}
while (true) ;
}
2.Kafka -> Consumer的过程
at most once(最多一次消费)语义:
①消费到5位置的数据。
处理数据…
②Kafka 底层会触发 commit,commit 之后,offset + 1。
服
务
器
宕
机
,
处
理
失
败
了
!
\color{#FF3030}{服务器宕机,处理失败了!}
服务器宕机,处理失败了!
③消费下一次消费就从位置 6 开始消费,位置 5 的数据将不再处理上述的场景,这也就意味着位置 5 的数据处理丢失,对应的语义也就是:at most once
at least once(最少一次消费)语义:
①消费到位置 5 的数据。
处理数据…
②处理完数据之后,用户自己来执行 commit。
执
行
c
o
m
m
i
t
失
败
了
,
宕
机
了
!
\color{#FF3030}{执行 commit 失败了,宕机了!}
执行commit失败了,宕机了!
③消费者下一次消费就从位置 5 开始消费了,此时位置 5 的数据被重复处理。
注 意 : 客 户 端 的 再 均 衡 , 也 会 导 致 消 费 消 息 出 现 最 多 一 次 消 费 和 最 少 一 次 消 费 的 语 义 。 \color{#FF3030}{注意:客户端的再均衡,也会导致消费消息出现最多一次消费和最少一次消费的语义。} 注意:客户端的再均衡,也会导致消费消息出现最多一次消费和最少一次消费的语义。
代码实现:
(1)consumer 最多一次消费
/**
* 最多一次消费
* kafka consumer是默认至多一次, consumer的配置是:
* 1、设置enable.auto.commit为 true。
* 2、设置auto.commit.interval.ms为一个较小的值。
* 3、consumer不去执行consumer.commitSync(),这样,Kafka会每隔一段时间自动提交offset
*/
public void consumer_atMostOnce() {
Properties props = new Properties();
props.put("bootstrap.servers", "192.168.56.101:9092,192.168.56.102:9092");
props.put("group.id", "g1");
props.put("key.deserializer", StringDeserializer.class.getName());
props.put("value.deserializer", StringDeserializer.class.getName());
// - true表示消费到数据后, kafka自动commit, 默认就是true
props.put("enable.auto.commit", "true");
// - 此参数表示消费到数据后, 间隔105ms自动commit
props.put("auto.commit.interval.ms", "105");
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Arrays.asList("book", "t2"));
try {
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Long.MAX_VALUE);
for (ConsumerRecord<String, String> record : records) {
System.out.println("g1组c2消费者,分区编号:" + record.partition() + " offset:" + record.offset() +
"value:" + record.value());
}
}
} catch (Exception e) {
} finally {
consumer.close();
}
}
(2)consumer 最少一次消费
/**
* 最少一次消费
* 设置enable.auto.commit为 false或者
* 设置enable.auto.commit为 true并设置auto.commit.inteval.ms为一个较大的值.
* 处理完后consumer调用consumer.commitSync()
*/
@Test
public void consumer_atLeastOnce() {
Properties props = new Properties();
props.put("bootstrap.servers", "192.168.56.101:9092,192.168.56.102:9092");
props.put("group.id", "g1");
props.put("key.deserializer", StringDeserializer.class.getName());
props.put("value.deserializer", StringDeserializer.class.getName());
// - 关闭kafka的自动commit
props.put("enable.auto.commit", "false");
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Arrays.asList("book", "t2"));
try {
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Long.MAX_VALUE);
for (ConsumerRecord<String, String> record : records) {
// - Process....
// - 处理完成后, 用户自己手动提交offset
System.out.println("value:" + record.value());
consumer.commitAsync();
}
}
} catch (Exception e) {
} finally {
consumer.close();
}
}
(3)consumer 精确性一次消费
/**
* 精确性一次消费
*/
public void consumer_exactlyOnce() {
Properties props = new Properties();
props.put("bootstrap.servers", "192.168.56.101:9092,192.168.56.102:9092");
props.put("group.id", "g1");
props.put("key.deserializer", StringDeserializer.class.getName());
props.put("value.deserializer", StringDeserializer.class.getName());
props.put("enable.auto.commit", "false");
// -底层就是通过数据的id实现幕等性操作, 即数据处理不丢失, 而且不重复处理
props.put("processing.guarantee", "exact_once");
KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Arrays.asList("book", "t2"));
try {
while (true) {
ConsumerRecords<String, String> records = consumer.poll(Long.MAX_VALUE);
for (ConsumerRecord<String, String> record : records) {
// - Process..
// - 处理完成后, 用户自己手动提交offset
System.out.println("value:" + record.value());
consumer.commitAsync();
}
}
} catch (Exception e) {
} finally {
consumer.close();
}
}
3.Kafka 精确性一次消费的实现
Kafka在 0.11.0.0 之前的版本中只支持 At Least Once 和 At Most Once 语义,尚不支持 Exactly Once 语义。但是在很多要求严格的场景下,如使用 Kafka 处理交易数据,Exactly Once 语义是必须的。我们可以通过让下游系统具有幂等性来配合 Kafka 的 At Least Once 语义来间接实现 Exactly Once。但是:该方案要求下游系统支持幂等操作,限制了Kafka的适用场景。
- 实现门槛相对较高,需要用户对Kafka的工作机制非常了解。
- 对于Kafka Stream而言,Kafka本身即是自己的下游系统,但Kafka在 0.11.0.0 版本之前不具有幂等发送能力。
(1)幂等性发送:单个 producer 的 EOS 语义
实现Exactly Once的一种方法是让下游系统具有幂等处理特性,而在Kafka Stream中,Kafka Producer本身就是“下游”系统,因此如果能让Producer具有幂等处理特性,那就可以让Kafka Stream在一定程度上支持Exactly once语义。
为了实现Producer的幂等语义,Kafka引入了Producer ID(即PID)和Sequence Number。每个新的Producer在初始化的时候会被分配一个唯一的PID,该PID对用户完全透明而不会暴露给用户。
对于每个PID,该Producer发送数据的每个<Topic, Partition>都对应一个从0开始单调递增的Sequence Number。
类似地,Broker端也会为每个<PID, Topic, Partition>维护一个序号,并且每次Commit一条消息时将其对应序号递增。对于接收的每条消息,如果其序号比Broker维护的序号(即最后一次 Commit 的消息的序号)大一,则 Broker 会接受它,否则将其丢弃:
如果消息序号比 Broker 维护的序号大一以上,说明中间有数据尚未写入,也即乱序,此时 Broker 拒绝该消息,Producer 抛出
I
n
v
a
l
i
d
S
e
q
u
e
n
c
e
N
u
m
b
e
r
\color{#FF3030}{InvalidSequenceNumber}
InvalidSequenceNumber。
如果消息序号小于等于 Broker 维护的序号,说明该消息已被保存,即为重复消息,Broker 直接丢弃该消息,Producer 抛出
D
u
p
l
i
c
a
t
e
S
e
q
u
e
n
c
e
N
u
m
b
e
r
\color{#FF3030}{DuplicateSequenceNumber}
DuplicateSequenceNumber。
示意图:
源码分析:
在Sender.run(long now)方法中,maybeWaitForProducerId()方法会生成一个producerID。
/**
* Run a single iteration of sending
*
* @param now The current POSIX time in milliseconds
*/
void run(long now) {
if (transactionManager != null) {
try {
if (transactionManager.shouldResetProducerStateAfterResolvingSequences())
// Check if the previous run expired batches which requires a reset of the producer state.
transactionManager.resetProducerId();
if (!transactionManager.isTransactional()) {
// this is an idempotent producer, so make sure we have a producer id
maybeWaitForProducerId();
}
// 省略其他代码......
}
long pollTimeout = sendProducerData(now);
client.poll(pollTimeout, now);
}
上述设计解决了 0.11.0.0 之前版本中的两个问题:
- Broker保存消息后,发送ACK前宕机,Producer认为消息未发送成功并重试,造成数据重复。
- 前一条消息发送失败,后一条消息发送成功,前一条消息重试后成功,造成数据乱序。
(2)事务性保证
幂等设计只能保证单个 Producer 对于同一个<Topic, Partition>的Exactly Once语义。另外,它并不能保证写操作的原子性----即多个写操作,要么全部被 Commit 要么全部不被 Commit。更不能保证多个读写操作的的原子性。尤其对于 Kafka Stream 应用而言,典型的操作即是从某个 Topic 消费数据,经过一系列转换后写回另一个 Topic,保证从源 Topic 的读取与向目标 Topic 的写入的原子性有助于从故障中恢复。
事务保证可使得应用程序将生产数据和消费数据当作一个原子单元来处理,要么全部成功,要么全部失败,即使该生产或消费跨多个<Topic, Partition>。另外,有状态的应用也可以保证重启后从断点处继续处理,也即事务恢复。
为了实现这种效果,应用程序必须提供一个稳定的(重启后不变)唯一的 ID,也即Transaction ID。Transactin ID与PID可能一一对应。区别在于
T
r
a
n
s
a
c
t
i
o
n
I
D
由
用
户
提
供
,
而
P
I
D
是
内
部
的
实
现
对
用
户
透
明
\color{#FF3030}{Transaction ID由用户提供,而PID是内部的实现对用户透明}
TransactionID由用户提供,而PID是内部的实现对用户透明。
另外,为了保证新的 Producer 启动后,旧的具有相同Transaction ID的 Producer 即失效,每次 Producer 通过Transaction ID拿到 PID 的同时,还会获取一个单调递增的 epoch(年号)。由于旧的 Producer 的 epoch 比新 Producer 的 epoch 小,Kafka 可以很容易识别出该 Producer 是老的 Producer 并拒绝其请求。
有了Transaction ID后,Kafka 可保证(从 Producer 的角度考虑):
- 跨 Session 的数据幂等发送。当具有相同Transaction ID的新的 Producer 实例被创建且工作时,旧的且拥有相同Transaction ID的 Producer 将不再工作。
- 跨 Session 的事务恢复。如果某个应用实例宕机,新的实例可以保证任何未完成的旧的事务要么 Commit 要么 Abort,使得新实例从一个正常状态开始工作。
示例代码
kafka事务属性是指一系列的生产者生产消息和消费者提交偏移量的操作在一个事务,或者说是是一个原子操作),同时成功或者失败。
事务操作的API
producer提供了initTransactions, beginTransaction, sendOffsets, commitTransaction, abortTransaction 五个事务方法。
/**
* 初始化事务。需要注意的有:
* 1、前提
* 需要保证transation.id属性被配置。
* 2、这个方法执行逻辑是:
* (1)Ensures any transactions initiated by previous instances of the producer with the same
* transactional.id are completed. If the previous instance had failed with a transaction in
* progress, it will be aborted. If the last transaction had begun completion,
* but not yet finished, this method awaits its completion.
* (2)Gets the internal producer id and epoch, used in all future transactional
* messages issued by the producer.
*
*/
public void initTransactions();
/**
* 开启事务
*/
public void beginTransaction() throws ProducerFencedException ;
/**
* 为消费者提供的在事务内提交偏移量的操作
*/
public void sendOffsetsToTransaction(Map<TopicPartition, OffsetAndMetadata> offsets,
String consumerGroupId) throws ProducerFencedException ;
/**
* 提交事务
*/
public void commitTransaction() throws ProducerFencedException;
/**
* 放弃事务,类似回滚事务的操作
*/
public void abortTransaction() throws ProducerFencedException ;
事务属性的应用实例
在一个原子操作中,根据包含的操作类型,可以分为三种情况,前两种情况是事务引入的场景,最后一种情况没有使用价值。
- 只有Producer生产消息。
- 消费消息和生产消息并存,这个是事务场景中最常用的情况,就是我们常说的“consume-transform-produce ”模式。
- 只有consumer消费消息,这种操作其实没有什么意义,跟使用手动提交效果一样,而且也 不 是 事 务 属 性 引 入 的 目 的 \color{#FF3030}{不是事务属性引入的目的} 不是事务属性引入的目的,所以一般不会使用这种情况。
相关属性配置
使用kafka的事务api时的一些注意事项:
- 需要消费者的自动模式设置为 false,并且不能再手动的进行执行 consumer#commitSync() 或者 consumer#commitAsync()。
- 生产者配置 transaction.id 属性。
- 生产者不需要再配置 enable.idempotence,因为如果配置了 transaction.id,则此时 enable.idempotence 会被设置为true
- 消费者需要配置Isolation.level。在consume-trnasform-produce模式下使用事务时,必须设置为READ_COMMITTED。
只有写
创建一个事务,在这个事务操作中,只有生成消息操作。代码如下:
/**
* 在一个事务只有生产消息操作
*/
public void onlyProduceInTransaction() {
// 创建生成者,代码如下,需要:
// 配置transactional.id属性
// 配置enable.idempotence属性
Producer producer = buildProducer();
// 1.初始化事务
producer.initTransactions();
// 2.开启事务
producer.beginTransaction();
try {
// 3.kafka写操作集合
// 3.1 do业务逻辑
// 3.2 发送消息
producer.send(new ProducerRecord<String, String>("test", "transaction-data-1"));
producer.send(new ProducerRecord<String, String>("test", "transaction-data-2"));
// 3.3 do其他业务逻辑,还可以发送其他topic的消息。
// 4.事务提交
producer.commitTransaction();
} catch (Exception e) {
// 5.放弃事务
producer.abortTransaction();
}
}
消费-生产并存(consume-transform-produce)
/**
* 在一个事务内,即有生产消息又有消费消息
*/
public void consumeTransferProduce() {
// 1.构建上产者
Producer producer = buildProducer();
// 2.初始化事务(生成productId),对于一个生产者,只能执行一次初始化事务操作
producer.initTransactions();
// 3.构建消费者和订阅主题
// 创建消费者代码,需要:
// 将配置中的自动提交属性(auto.commit)进行关闭
// 而且在代码里面也不能使用手动提交commitSync( )或者commitAsync( )
// 设置isolation.level
Consumer consumer = buildConsumer();
consumer.subscribe(Arrays.asList("test"));
while (true) {
// 4.开启事务
producer.beginTransaction();
// 5.1 接受消息
ConsumerRecords<String, String> records = consumer.poll(500);
try {
// 5.2 do业务逻辑;
System.out.println("customer Message---");
Map<TopicPartition, OffsetAndMetadata> commits = Maps.newHashMap();
for (ConsumerRecord<String, String> record : records) {
// 5.2.1 读取消息,并处理消息。print the offset,key and value for the consumer records.
System.out.printf("offset = %d, key = %s, value = %s\n",
record.offset(), record.key(), record.value());
// 5.2.2 记录提交的偏移量
commits.put(new TopicPartition(record.topic(), record.partition()),
new OffsetAndMetadata(record.offset()));
// 6.生产新的消息。比如外卖订单状态的消息,如果订单成功,则需要发送跟商家结转消息或者派送员的提成消息
producer.send(new ProducerRecord<String, String>("test", "data2"));
}
// 7.提交偏移量
producer.sendOffsetsToTransaction(commits, "group0323");
// 8.事务提交
producer.commitTransaction();
} catch (Exception e) {
// 7.放弃事务
producer.abortTransaction();
}
}
}
原理说明
(1)查找 Tranaction Corordinator
由于Transaction Coordinator是分配 PID 和管理事务
的核心,因此 Producer 要做的第一件事情就是通过向任意一个 Broker 发送FindCoordinator
请求找到 Transaction Coordinator 的位置。
注意:只有应用程序为 Producer 配置了 Transaction ID 时才可使用事务特性,也才需要这一步。另外,由于事务性要求 Producer 开启幂等特性,因此通过将transactional.id
设置为非空从而开启事务特性,同时也需要通过将enable.idempotence
设置为 true 来开启幂等特性。
(2)初始化事务 initTransaction
Producer 发送InitpidRequest
给Transaction Coordinator,获取到 PID(上图中第二步的Get PID)。然后 Transaction Coordinator 会在 Transaciton Log 中记录这<TransactionId,pid>
的映射关系。此外,它还会做两件事:
PID 是幂等性特征;TransactionId 是全局唯一的事物ID。
只要开启了幂等特性即必须执行InitpidRequest
,而无须考虑该Producer是否开启了事务特性。
- 恢复(Commit或Abort)之前的Producer未完成的事务。
- 对 PID 对应的 epoch 进行递增,这样可以保证同一个 app 的不同实例对应的 PID 是一样,而 epoch 是不同的。
注意:InitPidRequest的处理过程是同步阻塞的。一旦该调用正确返回,Producer即可开始新的事务。
另外,如果事务特性未开启,InitPidRequest可发送至任意Broker,并且会得到一个全新的唯一的PID。该Producer将只能使用幂等特性以及单一Session内的事务特性,而不能使用跨Session的事务特性。
(3)开始事务 beginTransaction
Kafka从 0.11.0.0 版本开始,提供 beginTransaction() 方法用于开启一个事务。调用该方法后, Producer 本地
会记录已经开启了事务,这个操作并没有通知 Transaction Coordinator
,因为 Transaction Coordinator 只有在 Producer 发送第一条消息后才认为事务已经开启
。
(4)Consume-Transform-Produce
这一阶段,包含了整个事务的数据处理过程,并且包含了多种请求。
AddPartitionsToTxnRequest
一个Producer可能会给多个<Topic, Partition>发送数据,给一个新的<Topic, Partition>发送数据前,它需要先向Transaction Coordinator发送AddPartitionsToTxnRequest
。Transaction Coordinator会将该<Transaction, Topic, Partition>
存于Transaction Log内,并将其状态置为BEGIN,如上图中步骤4.1所示。有了该信息后,我们才可以在后续步骤中为每个<Topic, Partition>设置COMMIT或者ABORT标记(如上图中步骤5.2所示)。
另外,如果该<Topic, Partition>为该事务中第一个<Topic, Partition>,Transaction Coordinator还会启动对该事务的计时(每个事务都有自己的超时时间)。
在注册<Transaction, Topic, Partition>到Transaction Log后,生产者发送数据,虽然没有还没有执行commit或者abort,但是此时消息已经保存到Broker上了。即使后面执行abort,消息也不会删除,只是更改状态字段标识消息为abort状态。
-
ProduceRequest
Producer通过一个或多个ProduceRequest发送一系列消息。除了应用数据外,该请求还包含了PID,epoch,和Sequence Number。该过程如上图中步骤4.2所示。 -
AddOffsetsToTxnRequest
为了提供事务性,Producer新增了sendOffsetsToTransaction
方法,该方法将多组消息的发送和消费放入同一批处理内。
该方法先判断在当前事务中该方法是否已经被调用并传入了相同的Group ID。若是,直接跳到下一步;若不是,则向Transaction Coordinator发送AddOffsetsToTxnRequests请求,Transaction Coordinator将对应的所有<Topic, Partition>存于Transaction Log中,并将其状态记为BEGIN,如上图中步骤4.3所示。该方法会阻塞直到收到响应。 -
TxnOffsetCommitRequest
作为sendOffsetsToTransaction方法的一部分,在处理完AddOffsetsToTxnRequest后,Producer也会发送TxnOffsetCommit请求给Consumer Coordinator,从而将本事务包含的与读操作相关的各<Topic, Partition>的Offset持久化到内部的__consumer_offsets中,如上图步骤4.4所示。
在此过程中,Consumer Coordinator会通过PID和对应的epoch来验证是否应该允许该Producer的该请求。
注意:写入__consumer_offsets的Offset信息在当前事务Commit前对外是不可见的。即:在当前事务被Commit前,可认为该Offset尚未Commit,也就是对应的消息尚未被完全处理。
Consumer Coordinator并不会立即更新缓存中相应<Topic, Partition>的Offset,因为此时这些更新操作尚未被COMMIT或ABORT。
(5)事务提交或终结 commitTransaction/abortTransaction
在Producer执行commitTransaction/abortTransaction时,Transaction Coordinator会执行一个两阶段提交:
一旦上述数据写入操作完成,应用程序必须调用KafkaProducer的commitTransaction方法或者abortTransaction方法以结束当前事务。
- 第一阶段,将
Transaction Log
内的该事务状态设置为PREPARE_COMMIT
或PREPARE_ABORT
- 第二阶段,将
Transaction Marker
写入该事务涉及到的所有消息(即将消息标记为committed或aborted)。这一步骤Transaction Coordinator
会发送给当前事务涉及到的每个<Topic, Partition>
的Leader,Broker收到该请求后,会将对应的Transaction Marker控制信息写入日志。
一旦Transaction Marker写入完成,Transaction Coordinator会将最终的COMPLETE_COMMIT或COMPLETE_ABORT状态写入Transaction Log中以标明该事务结束。
EndTxnRequest
commitTransaction方法使得Producer写入的数据对下游Consumer可见。abortTransaction方法通过Transaction Marker将Producer写入的数据标记为Aborted状态。下游的Consumer如果将isolation.level
设置为READ_COMMITTED
,则它读到被Abort的消息后直接将其丢弃而不会返回给客户程序,也即被Abort的消息对应用程序不可见。
无论是Commit还是Abort,Producer都会发送EndTxnRequest请求给Transaction Coordinator,并通过标志位标识是应该Commit还是Abort。
收到该请求后,Transaction Coordinator会进行如下操作:
- 将
PREPARE_COMMIT
或PREPARE_ABORT
消息写入Transaction Log,如上图中步骤5.1所示。 - 通过
WriteTxnMarker
请求以Transaction Marker的形式将COMMIT或ABORT信息写入用户数据日志以及Offset Log中,如上图中步骤5.2所示。 - 最后将
COMPLETE_COMMIT
或COMPLETE_ABORT
信息写入Transaction Log中,如上图中步骤5.3所示。
补充说明:对于commitTransaction方法,它会在发送EndTxnRequest之前先调用flush方法以确保所有发送出去的数据都得到相应的ACK。对于abortTransaction方法,在发送EndTxnRequest之前直接将当前Buffer中的事务性消息(如果有)全部丢弃,但必须等待所有被发送但尚未收到ACK的消息发送完成。
上述第二步是实现将一组读操作与写操作作为一个事务处理的关键。因为Producer写入的数据Topic以及记录Comsumer Offset的Topic会被写入相同的Transactin Marker
,所以这一组读操作与写操作要么全部COMMIT要么全部ABORT。
-
WriteTxnMarkerRequest
上面提到的WriteTxnMarkerRequest由Transaction Coordinator发送给当前事务涉及到的每个<Topic, Partition>的Leader。收到该请求后,对应的Leader会将对应的COMMIT(PID)或者ABORT(PID)控制信息写入日志,如上图中步骤5.2所示。该控制消息向Broker以及Consumer表明对应PID的消息被Commit了还是被Abort了。
这里要注意,如果事务也涉及到__consumer_offsets,即该事务中有消费数据的操作且将该消费的Offset存于__consumer_offsets中,Transaction Coordinator也需要向该内部Topic的各Partition的Leader发送WriteTxnMarkerRequest从而写入COMMIT(PID)或COMMIT(PID)控制信息。
-
写入最终的COMPLETE_COMMIT或COMPLETE_ABORT消息
写完所有的Transaction Marker后,Transaction Coordinator会将最终的COMPLETE_COMMIT或COMPLETE_ABORT消息写入Transaction Log中以标明该事务结束,如上图中步骤5.3所示。
此时,Transaction Log中所有关于该事务的消息全部可以移除。当然,由于Kafka内数据是Append Only的,不可直接更新和删除,这里说的移除只是将其标记为null从而在Log Compact时不再保留。
另外,COMPLETE_COMMIT或COMPLETE_ABORT的写入并不需要得到所有Rreplica的ACK
,因为如果该消息丢失,可以根据事务协议重发。
补充说明:如果参与该事务的某些<Topic, Partition>在被写入Transaction Marker前不可用,它对READ_COMMITTED的Consumer不可见,但不影响其它可用<Topic, Partition>的COMMIT或ABORT。在该<Topic, Partition>恢复可用后,Transaction Coordinator会重新根据PREPARE_COMMIT或PREPARE_ABORT向该<Topic, Partition>发送Transaction Marker。
总结
PID
与Sequence Number
的引入实现了写操作的幂等性。- 写操作的幂等性结合
At Least Once
语义实现了单一Session内的Exactly Once
语义。 - Transaction Marker与PID提供了识别消息是否应该被读取的能力,从而实现了事务的隔离性。
- Offset的更新标记了消息是否被读取,从而将对读操作的事务处理转换成了对写(Offset)操作的事务处理。
- Kafka事务的本质是,将一组写操作(如果有)对应的消息与一组读操作(如果有)对应的Offset的更新进行同样的标记(即Transaction Marker)来实现事务中涉及的所有读写操作同时对外可见或同时对外不可见。
- Kafka只提供对Kafka本身的读写操作的事务性,不提供包含外部系统的事务性。