消息队列如Kafka、Pulsar利用事务特性所提供的exactly once语义,只能在特定使用场景 consume-transform-produce 下保证,即一个事务同时包含了生产和消费,利用事务的原子性,事务中的操作包含sink端的生产和source端的offset提交,这两个操作要么同时完成,要么同时不完成。它不用关心事务是否commit成功,因为无论是否成功,端对端的状态前后都是一致的。因此,kafka、pulsar实现的事务,都只支持commit或者abort一次,后面重复提交commit、abort请求是非法的,即不支持Commit操作的幂等性。
Flink提交的exactly once语义,是基于Flink自身实现的两阶段提交协议来保证的,当接入一个外部系统时,为了保证exactly once语义,Flink对外部系统是有要求的:1. 提供事务功能 2. 事务commit操作要保证幂等性。详细原理参考上一小节:https://blog.csdn.net/m0_43406494/article/details/130294452
因此,当前Kafka、Pulsar接入Flink都是不完全满足条件的。
当Flink打checkpoint完成时,会调用到notifyCheckpointCompelete方法,发送commit请求给外部系统。然而notifyCheckpointCompelete方法只是best effort的,并不保证一定会执行。而且notifyCheckpointCompelete方法执行失败,也不会让已经打好的Checkpoint给删除掉,因此Flink侧要求commit请求必须保证最终成功,否则可能导致丢数。因此,commit请求如果失败,flink会立刻重启任务,执行recoverAndCommit方法,重新执行commit请求,一直到commit请求成功。
那Kafka、Pulsar不支持多次Commit的,岂不是会无限重启?理论上是这样的。
下面举些具体的例子,触发这个问题:
有一个点这里需要提示的,虽然在broker侧Kafka是能查到最后一个事务的状态,但是Kafka原生的客户端逻辑会使得尽管没开启新事务,第二次Commit仍然会失败,但是Flink Kafka Connector处理了这个问题,从而支持了:只要不开启新的事务,Kafka是支持对最后一个事务进行重复commit的。
下面进行详细地介绍这个事情,如果是简单地重复commit两次,会报错
@Test
public void givenMessage_whenProduceWithTransaction_thenShouldSucceed() {
try {
kafkaProducer.beginTransaction();
String message = "Hello, Kafka";
ProducerRecord record = new ProducerRecord<>(TOPIC_NAME, message);
kafkaProducer.send(record);
kafkaProducer.commitTransaction();
kafkaProducer.commitTransaction();
} catch (Exception ex) {
kafkaProducer.abortTransaction();
Assertions.fail("Failed to produce message with transaction, reason: " + ex.getMessage());
}
}
报错如下:
org.apache.kafka.common.KafkaException: TransactionalId kafka_producer_id: Invalid transition attempted from state READY to state COMMITTING_TRANSACTION
这是因为,客户端第一次commit,收到broker的成功响应后会把事务的状态转换为Ready状态,而Commit请求会尝试把事务状态切换为COMMITTING状态,只能从IN_TRANSACTION状态切换为COMMITTING状态,从Ready状态切换为COMMITTING状态是非法的,因此第二次commit报错了。
客户端侧的事务状态机如下:
org.apache.kafka.clients.producer.internals.TransactionManager.State#isTransitionValid
而Flink Kafka Connector对这种情况进行了fix,在恢复事务时,通过反射的手段把事务的状态强行设置为IN_TRANSACTION状态,从而支持重复commit了。
org.apache.flink.streaming.connectors.kafka.internals.FlinkKafkaInternalProducer#resumeTransaction
为了更好地展示这个事情,使用下面的测试代码模拟Flink Kafka Connector的逻辑。
/**
* we can see that, given specified transaction ID, flink kafka connector support for
* committing to the last transaction multiple times.
*/
@Test
public void testCommitMultipleTimes() {
try {
kafkaProducer.beginTransaction();
String message = "Hello, Kafka";
ProducerRecord record = new ProducerRecord<>(TOPIC_NAME, message);
kafkaProducer.send(record);
kafkaProducer.commitTransaction();
// try to commit multiple time directly will fail, because current state
// of transaction is Ready.
// kafkaProducer.commitTransaction();
// we need to roll back the transaction state to IN_TRANSACTION to prepare for
// second commit.
Object transactionManager = getField(kafkaProducer, "transactionManager");
assert getField(transactionManager, "currentState").equals(getEnum(
"org.apache.kafka.clients.producer.internals.TransactionManager$State.READY"));
setField(transactionManager, "currentState", getEnum(
"org.apache.kafka.clients.producer.internals.TransactionManager$State.IN_TRANSACTION"));
assert getField(transactionManager, "currentState").equals(getEnum(
"org.apache.kafka.clients.producer.internals.TransactionManager$State.IN_TRANSACTION"));
kafkaProducer.commitTransaction();
} catch (Exception ex) {
kafkaProducer.abortTransaction();
Assertions.fail("Failed to produce message with transaction, reason: " + ex.getMessage());
}
}
还有一个问题,前面也说了,当前支持:只要不开启新的事务,Kafka是支持对最后一个事务进行重复commit的。
那如果开启了新事务,那最后一个事务的状态不就被覆盖了?根据Flink两段式协议的流程,如下图:
在执行snapshotState方法,即pre commit阶段就调用了beginTransaction开启了一个新的事务T2,这不就覆盖了当前checkpoint对应的事务T1的状态了吗?
如果T1跟T2的TransactionID是相同的话,是会覆盖的。
而且,Flink是可能并发打checkpoint的! MaxConcurrentCheckpoints配置就控制了最大的并发checkpoint个数,即可能有如下执行序列:snapshotState -> snapshotState -> notifyCheckpointComplete -> notifyCheckpointComplete。
TwoPhaseCommitSinkFunction.notifyCheckpointComplete方法的注释中就介绍了各种可能的情况。
因此,为了解决上面的问题,Flink Kafka Connector里面维护了一个TransactionID池,当notifyCheckpointComplete中事务commit成功时才会把T1对应的TransactionID给放回池子里,供后面的新事务使用。TransactionID池是一个阻塞队列,按添加的顺序进行排序,出队列也是按照顺序来。
每个TransactionID会创建一个对应的Producer,一个checkpoint只用一个事务,对应一个Producer,相邻的checkpoint使用不同的TransactionID、Producer。因此,不用担心新的事务会覆盖最后一个事务的状态。
默认TransactionID池的大小为5,也就是说连续5个checkpoint会分别对应5个不同TransactionID。
最后提示一点,虽然Kafka保证了维护最后一个事务的状态,但也不是永远维护的。为了避免一些TransactionID不再使用了,导致对应无用元数据一直存储在broker内存里,kafka有一个机制:当一个TransactionID的事务状态长时间不更新,则自动把它从内存中删除掉。由transactional.id.expiration.ms来配置这个时间,默认为7天。因此如果停止flink任务长达7天以上,那么重启时也有可能commit失败。
相同点:
不同点:
kafka在broker侧还有一个配置transaction.max.timeout.ms,用来限制用户设置的超时时间的最大上限,超过了则会事务初始化失败。
逻辑如下,kafka.coordinator.transaction.TransactionCoordinator#handleInitProducerId
pulsar对事务超时的控制比kafka更精确,pulsar使用了一个时间轮来安排超时事务的自动abort任务,时间精度为100ms。
kafka对事务超时的控制是粗糙的,安排一个周期任务,定期将所有超时的事务abort掉,默认的时间周期是10s,也就是说,会有最大10s的误差。
kafka.coordinator.transaction.TransactionCoordinator#startup
pulsar对事务超时的操作是向TB、TP发送abort指令,将事务状态转换为ABORTED,删除元数据就完了。
kafka还多了一个epoch机制:
看如下测试代码:
/** will throw org.apache.kafka.common.errors.ProducerFencedException:
* There is a newer producer with the same transactionalId which fences the current one.
* Broker will abort the timeout txn and increment epoch to fence current producer, so
* ProducerFencedException will be thrown.
*/
@Test
public void testCommitTimeoutTxn() {
try {
printProducerIdAndEpoch();
kafkaProducer.beginTransaction();
String message = "Hello, Kafka";
ProducerRecord record = new ProducerRecord<>(TOPIC_NAME, message);
kafkaProducer.send(record);
// check timeout txn in broker every DefaultAbortTimedOutTransactionsIntervalMs, default 10s.
Thread.sleep(TRANSACTION_TIMEOUT_IN_MS + 25000);
kafkaProducer.commitTransaction();
} catch (Exception ex) {
System.out.println(ex);
Assertions.fail("Failed to produce message with transaction");
}
}
连续执行两次上面的测试代码,commit一个超时的事务,都抛出了ProducerFencedException报错,观察两次执行时打印的epoch值,发现递增了2,而不是1。这是因为事务超时会递增epoch值一次,第二次执行时初始化事务递增epoch值一次,因此是递增2。
因为上面的关系,Flink Kafka Connector在重启任务执行recoverAndCommit方法时,不会调用kafkaProducer.initTransactions()来初始化事务(避免递增epoch),而是读取checkpoint里的transactionalId、producerId、epoch三个信息,然后直接通过反射来设置好,直接发送commit请求。测试代码testCommitOldTxnSuccess模拟了这个过程。
static void printProducerIdAndEpoch() {
Object transactionManager = getField(kafkaProducer, "transactionManager");
System.out.println(getField(transactionManager, "producerIdAndEpoch"));
}
public static void initWithoutInit() {
Properties props = new Properties();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, KAFKA_ADDRESS);
props.put(ProducerConfig.CLIENT_ID_CONFIG, CLIENT_ID);
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
props.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, "kafka_producer_id");
props.put(ProducerConfig.TRANSACTION_TIMEOUT_CONFIG, TRANSACTION_TIMEOUT_IN_MS);
kafkaProducer = new KafkaProducer<>(props);
// kafkaProducer.initTransactions();
}
public void commitUnit() {
printProducerIdAndEpoch();
kafkaProducer.beginTransaction();
String message = "Hello, Kafka";
ProducerRecord record = new ProducerRecord<>(TOPIC_NAME, message);
kafkaProducer.send(record);
kafkaProducer.commitTransaction();
printProducerIdAndEpoch();
}
/**
* commit old transaction to simulate roll back to older checkpoint.
* As epoch do not increment,commit an older transaction will succeed.
*/
@Test
public void testCommitOldTxnSuccess() {
try {
commitUnit();
// do a checkpoint to save producerId,transactionalId,epoch
Object transactionManager = getField(kafkaProducer, "transactionManager");
Object producerIdAndEpoch = getField(transactionManager, "producerIdAndEpoch");
commitUnit();
commitUnit();
// restart instances.
cleanup();
// do not init transaction to avoid epoch incrementation.
// init();
initWithoutInit();
// configure producerIdAndEpoch from checkpoint with refection instead of
// calling kafkaProducer.initTransactions()
transactionManager = getField(kafkaProducer, "transactionManager");
setField(transactionManager, "producerIdAndEpoch", producerIdAndEpoch);
// set txn state
setField(transactionManager, "currentState", getEnum(
"org.apache.kafka.clients.producer.internals.TransactionManager$State.IN_TRANSACTION"));
setField(transactionManager, "transactionStarted", true);
// commit old transaction.
printProducerIdAndEpoch();
kafkaProducer.commitTransaction();
printProducerIdAndEpoch();
} catch (Exception ex) {
System.out.println(ex);
Assertions.fail("Failed to produce message with transaction");
}
}
org.apache.flink.streaming.connectors.kafka.internals.FlinkKafkaInternalProducer#resumeTransaction
根据前面的分析,Flink+Kafka是支持对最后一个事务进行重复commit的,这算是不完全的commit操作幂等性。实际上,Flink在大多数情况下都只需要commit最后一个事务,因此这种方案能hold住大多数情况。
下面分析一些极端情况:
Kafka的fix方案是:当recoverAndCommit方法报错,某些报错类型会直接忽略commit请求的失败,这样就会接着执行Flink任务了,避免无限重启。
FlinkKafkaProducer#recoverAndCommit
这里忽略了InvalidTxnStateException 、ProducerFencedException两种报错。
下面再分析会不会丢数:
如果是事务超时导致的ProducerFencedException,由前面分析知事务超时时间已经设成了1h了,除非宕机时间超过1h才有一点点概率会aborted,否则1h内肯定足够发送commit请求到达broker。就算宕机时间超过1h,要发生丢数问题也是很微弱的,因为得要宕机的时间点刚好在:前面发送消息都没报错,pre commit阶段完成进入commit阶段之后,并且在commit阶段发送commit请求给broker之前。这个时间空隙是很小很小的,因为进入commit阶段就会立马发送commit请求了,没有耗时操作。
如果是回滚到一个旧的checkpoint,那么它对应的事务在broker侧肯定是committed的,如果是aborted那么根本不会开启新的事务,而是直接重启flink任务了。这一点根据数学归纳法也可以证明出来。
综上所述:几乎不会发生丢数。
但是,回滚到到一个比较旧的checkpoint,会导致数据重复。比如说当前是checkpoint N,回滚到checkpoint N-5,回滚checkpoint可以把source端的offset给重置回去,但是不能把sink端已经生产的消息给删除掉,因此checkpoint N-5到checkpoint N之间生产的消息会发生重复。
但Flink侧回滚到到一个比较旧的checkpoint是极端极端的情况了,就算真发生了,根据这里的分析可知也影响不大,不至于导致丢数。
根据前面的分析,Flink Kafka Connector当前提供的保证是相当强的,Pulsar侧提供的保障则相当地弱了。
而看回Flink对外部系统的要求:1. 提供事务功能 2. 事务commit操作要保证幂等性。因此要对Pulsar进行改造,方向也是让Pulsar的commit支持幂等性。
因此,接下来的各个方案之间,区别仅在于如何实现事务commit的幂等性。
这个方案已经实现完成,并经过测试了,但是有缺陷,见下面分析。
https://github.com/apache/pulsar/pull/19662
当前pulsar是事务一完成(committed或aborted)就立刻删除元数据了,因此无法支持事务commit的幂等性。因此,很自然的想法就是不立马删除元数据,继续在内存里保存一段时间,因为客户端只会在transaction.timeout.ms时间内才能发送请求到broker,因此在内存中保存transaction.timeout.ms这么长时间就足够了。
缺点:
增加broker配置:
#每个TC为每个客户端维护的N个已结束的事务状态
TransactionMetaPersistCount=10
# 为每个客户端维护已结束的事务状态的最长时间,避免客户端长时间不用导致数据不清理。
TransactionMetaPersistTimeInHour=72
客户端增加配置ClientName,类似于ProducerName,要用户保证唯一性。如果client只使用一个Producer,则可以让ClientName=ProducerName。
方案:
分析:
注记:可以进一步优化,可以只保留aborted的事务状态,committed状态的事务不用保留,那么如果找不到事务元数据时,默认是committed的事务。但是这样的话会隐藏掉可能丢数的风险,因此暂不采用。