前言
在事务消息未出现前,Pulsar中支持的最高等级的消息传递保证,是通过Broker的消息去重机制,来保证Producer在单个分区上的消息只精确保存一次。当Producer发送消息失败后,即使重试发送消息,Broker也能确保消息只被持久化一次。但在Partitioned Topic的场景下,Producer没有办法保证多个分区的消息原子性。
当Broker 宕机时,Producer可能会发送消息失败,如果Producer没有重试或已用尽重试次数,则消息不会写入 Pulsar。在消费者方面,目前的消息确认是尽力而为的操作,并不能确保消息一定被确认成功,如果消息确认失败,这将导致消息重新投递,消费者将收到重复的消息, Pulsar 只能保证消费者至少消费一次。
类似地,Pulsar Functions 仅保证对幂等函数上的单个消息处理一次,即需要业务保证幂等。它不能保证处理多个消息或输出多个结果只发生一次。
举个例子,某个Function的执行步骤是:从Topic-A1、Topic-A2中消费消息,然后Function中对消息进行聚合处理(如:时间窗口聚合计算),结果存储到Topic-B,最后分别确认(ACK) Topic-A1和Topic-A2中的消息。该Function可能会在“输出结果到Topic-B”和“确认消息”之间失败,甚至在确认单个消息时失败。这将导致所有(或部分)Topic-A1、Topic-A2的消息被重新传递和重新处理,并生成新的结果,进而导致整个时间窗口的计算结果错误。
因此,Pulsar需要事务机制来保证精确一次的语义(Exactly-once),生产和消费都能保证精确一次,不会重复,也不会丢失数据,即使在Broker宕机或Function处理失败的情况下。
一、Exactly once 语义
Apache Pulsar 社区在发布的 Pulsar 2.8.0 版本中实现了一个里程碑式功能:Exactly-once(精确一次)语义。在这之前,我们只能通过在 Broker 端开启消息去重来保证单个 Topic 上的Exactly-once 语义。随着Pulsar 2.8.0 的发布,利用事务 API 可以在跨 Topic 的场景下保证消息生产和确认的原子性操作
在使用 Pulsar 过程中任何节点都有可能出现异常甚至宕机,当 Producer 生产消息时,pulsar 集群可能会发生 Broker 或 Bookie 异常不可用,或者网络突然中断等异常情况。根据在发生异常时 Producer 处理消息的方式,系统可以具备以下三种消息语义。
1.1 At-least-once (至少一次)语义
Producer 通过接收 Broker 的 ACK (消息确认)通知来确保消息成功写入 Pulsar Topic。然而,当 Producer 接收 ACK 通知超时,或者收到 Broker 出错信息时,会尝试重新发送消息。如果 Broker 正好在成功把消息写入到 Topic,但还没有给 Producer 发送 ACK 时宕机,Producer 重新发送的消息会被再次写入到 Topic,最终导致消息被重复分发至 Consumer。
1.2 At-most-once (最多一次)语义
当 Producer 在接收 ACK 超时,或者收到 Broker 出错信息时不重发消息,那就有可能导致这条消息丢失,没有写入到 Topic 中,也不会被 Consumer 消费到。在某些场景下,为了避免发生重复消费,我们可以容许消息丢失的发生。
1.3 Exactly-once (精确一次)语义
Exactly-once 语义保证了即使 Producer 多次发送同一条消息到服务端,服务端也仅仅会记录一次。
pulsar 中 Exactly-once 语义并不包含 consumer 端只消费一次的场景。因为真正意义上的Exactly-Once依赖消息系统的服务端、消息系统的客户端和用户消费逻辑这三者状态的协调。消息系统不可能保证 consumer 只会接收一次消息,在 consumer 由于网络等原因未及时 ack 消息时,pulsar broker 就会重复投递消息给 consumer,这时候需要做的是保证 consumer 消费幂等。可能使用 “有效一次” 来描述更恰当些。
1.4 单个 Topic 的 Exactly-once 语义
从 Pulsar 1.20.0-incubating 版本开始可以通过幂等性 Producer 和 pulsar server 端消息去重来保证单个 Topic 上的 Exactly-once 语义。
什么是幂等性 Producer ?幂等性就是指对于同一操作发起的一次或者多次请求的结果是一致的,不会因为多次操作而产生不同的结果。当出现由于异常导致 Producer 重发消息时,重复的消息只会在 Broker 中写入一次。
可以通过以下方式来开启消息去重和设置幂等性 producer:
- 在 Cluster 级别(针对所有 Namespace 下的 Topic 有效),Namespace 级别(针对该 Namespace下的 Topic 有效)或者 Topic 级别 (针对单个 Topic 有效)开启消息去重:
bin/pulsar-admin namespaces set-deduplication \
public/default \
--enable # or just -e
- 为 Producer 设置任意的名称并且设置消息超时时间为 0
PulsarClient pulsarClient = PulsarClient.builder()
.serviceUrl("pulsar://localhost:6650")
.build();
Producer producer = pulsarClient.newProducer()
.producerName("producer-1")
.topic("persistent://public/default/topic-1")
.sendTimeout(0, TimeUnit.SECONDS)
.create();
实现原理:每条发送给 Pulsar 的消息都会带有一个唯一的序列号,Pulsar Broker 利用这个序列号来判断和去除重复的消息,当接收的消息的 sequenceId 小于等于 pulsar 记录的最大 sequenceId,即为重复消息。 Pulsar 会把消息体中的序列号保存到 Topic 中,并且记录最新接收到的序列号。所以哪怕 Broker 节点出现异常宕机了,另一个重新接管处理该 Topic 的 Broker 节点也可以判断消息是否重复。
1.5 多个Topic 的 Exactly-once 语义
幂等性 Producer 只能保证单个 topic 上的 exactly-once 语义,当一条消息要发送到多个 topic 时,就不能保证多个操作的原子性。
Pulsar 2.8.0 引入事务消息,我们可以通过事务 API 来实现多个 topic 发送消息的原子操作,要么都成功要么都失败。也可以在一个事务中对多个 topic 上的消息进行 ACK 确认。
在流处理系统中,常见的操作是 read-process-write。即从一个或多个 topic 中读取消息,进过程序加工处理后得出结果,最后把结果写入另一个 topic。在这个过程中如果不使用事务消息,就有可能出现结果重复或者消息丢失的情况。
如果执行流程是 producer 先发送消息,然后 consumer 再 ACK 消息,程序在 ACK 前发生异常,consumer 未 ACK 成功,程序恢复后会再次消费消息,发送新计算的结果给 topic,这就会导致消息重复。
如果执行流程是 consumer 先 ACK 消息,然后 producer 再发送消息,程序在 producer 发送前发生异常,程序恢复后由于消息已经 ACK ,消息将不会再次消费,这就会导致消息丢失问题。
事务 API 确保消息 A 的确认和消息 B 的写入以原子操作发生,此时才认为“消费-处理-生产”整个操作为一个原子操作。
二、Pulsar的Transaction事务
Pulsar在2.8.0版本中,通过事务API 实现跨topic消息生产和确认的原子性操作,通过这个功能,Producer可以确保一条消息同时发送到多个 Topic,要么这些消息都发送成功,在所有 Topic 上都可以被消费,要么所有消息都不能被消费。这个功能也允许在一个事务操作中对多个 Topic 上的消息进行 ACK 确认,从而实现端到端的Exactly-once 语义。
事务语义允许事件流应用将消费,处理,生产消息整个过程定义为一个原子操作。 在 Pulsar 中,生产者或消费者能够处理跨多个主题和分区的消息,允许一个原子操作写入多个主题和分区,同时事务中的批量消息可以被以多分区接收、生产和确认,一个事务涉及的所有操作都作为整体成功或失败。事务中几个概念需要大家了解:
2.1 Transaction的各种概念
- 事务调度器:事务提交后,事务调度器与相关联的topic所在的broker交互完成事务。所有事务元数据都持久化在pulsar的一个topic中。
- 事务ID:pulsar中用于标记一条事务。长度为128位字节,前16位表示事务协调器的ID,其余位用于代表事务协调器中的一个个事务,是递增的。
- 事务缓存:事务中产生的消息存储在事务缓冲区中。在事务提交之前,事务缓存中的消息对消费者是不可见的。当事务中止时,事务缓冲区中的消息将被丢弃。
- 待确认状态:挂起确认状态在事务完成之前维护事务中的消息确认。 如果消息处于挂起确认状态,则在该消息从挂起确认状态中移除之前,其他事务无法确认该消息。挂起的确认状态被保留到挂起的确认日志中(cursor ledger)。 新启动的broker可以从挂起的确认日志中恢复状态,以确保状态确认不会丢失。
2.2 开启Transaction
- 1、修改Pulsar集群所有服务器的conf/broker.conf文件,修改内容如下:
transactionCoordinatorEnabled=true
acknowledgmentAtBatchIndexLevelEnabled=true
- 2、在Pulsar集群的一台服务器上,初始化事务协调器的元数据
[root@bigdata001 apache-pulsar-2.9.1]#
[root@bigdata001 apache-pulsar-2.9.1]# bin/pulsar initialize-transaction-coordinator-metadata -cs bigdata002:2181 -c pulsar-cluster
......省略部分......
2022-04-11T18:03:11,386+0800 [main-EventThread] INFO org.apache.zookeeper.ClientCnxn - EventThread shut down for session: 0x1007d52d99e691d
Transaction coordinator metadata setup success
[root@bigdata001 apache-pulsar-2.9.1]#
然后重启Pulsar集群的所有Bookie和Broker。
2.3 使用事务
2.3.1 流处理系统中,常见的 read-process-write 操作的 Exactly-once 语义
import com.yibo.pulsar.pojo.User;
import org.apache.pulsar.client.api.*;
import org.apache.pulsar.client.api.transaction.Transaction;
import org.apache.pulsar.client.impl.schema.AvroSchema;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
/**
* @Author: huangyibo
* @Date: 2022/6/1 22:20
* @Description: Pulsar 事务的操作
* 流处理系统中,常见的 read-process-write 操作的 Exactly-once 语义
*/
public class PulsarTransaction {
public static void main(String[] args) throws PulsarClientException, ExecutionException, InterruptedException {
//1. 创建pulsar支持事务的客户端对象
String serviceUrl = "pulsar://192.168.23.111:6650,192.168.23.112:6650,192.168.23.113:6650";
PulsarClient pulsarClient = PulsarClient.builder().serviceUrl(serviceUrl)
.enableTransaction(true)
.build();
//2. 开启事务的支持
Transaction transaction = pulsarClient.newTransaction()
.withTransactionTimeout(5, TimeUnit.SECONDS)
.build().get();
String txnTopic1 = "persistent://my-tenant/my-ns/my-txn-topic1";
String txnTopic2 = "persistent://my-tenant/my-ns/my-txn-topic2";
try {
//3. 执行相关的操作
//3.1 接收相关消息
Consumer consumer = pulsarClient.newConsumer(AvroSchema.of(User.class))
.topic(txnTopic1)
.subscriptionName("consume-txn")
.subscriptionType(SubscriptionType.Shared)
.subscribe();
Message message = consumer.receive();
User user = message.getValue();
//3.2 处理操作
System.out.println(user);
//3.3 将处理后的数据发送到另一个topic中
Producer producer = pulsarClient.newProducer(AvroSchema.of(User.class))
.topic(txnTopic2)
.sendTimeout(0, TimeUnit.SECONDS)
.create();
producer.newMessage(transaction).value(user).send();
//4. 确认消息
consumer.acknowledge(message);
//5. 提交事务
transaction.commit();
} catch (PulsarClientException e) {
e.printStackTrace();
//回滚事务
transaction.abort();
}
}
}
2.3.2 实现一条消息发送到多个topic的原子性操作
import com.yibo.pulsar.pojo.User;
import org.apache.pulsar.client.api.Producer;
import org.apache.pulsar.client.api.PulsarClient;
import org.apache.pulsar.client.api.PulsarClientException;
import org.apache.pulsar.client.api.transaction.Transaction;
import org.apache.pulsar.client.impl.schema.AvroSchema;
import java.util.concurrent.TimeUnit;
/**
* @Author: huangyibo
* @Date: 2022/6/1 23:32
* @Description: Pulsar 事务的操作
* 实现一条消息发送到多个topic的原子性操作
*/
public class PulsarProducerTransaction {
public static void main(String[] args) throws Exception {
// 1 创建pulsar支持事务的客户端对象
String serviceUrl = "pulsar://192.168.23.111:6650,192.168.23.112:6650,192.168.23.113:6650";
PulsarClient pulsarClient = PulsarClient.builder().serviceUrl(serviceUrl)
.enableTransaction(true)
.build();
String txnTopic1 = "persistent://my-tenant/my-ns/my-txn-topic1";
String txnTopic2 = "persistent://my-tenant/my-ns/my-txn-topic2";
//2. 开启事务的支持
Transaction transaction = pulsarClient.newTransaction()
.withTransactionTimeout(5, TimeUnit.SECONDS)
.build().get();
Producer producer1 = null;
Producer producer2 = null;
try {
//3. 基于客户端对象进行构建生产者对象
producer1 = pulsarClient.newProducer(AvroSchema.of(User.class))
.topic(txnTopic1)
.sendTimeout(1, TimeUnit.SECONDS)
.create();
producer2 = pulsarClient.newProducer(AvroSchema.of(User.class))
.topic(txnTopic2)
.sendTimeout(1, TimeUnit.SECONDS)
.create();
//4. 发送数据生产
User user = new User();
user.setName("张无忌");
user.setAge(20);
producer1.newMessage(transaction).value(user).send();
producer2.newMessage(transaction).value(user).send();
//5. 提交事务
transaction.commit();
} catch (PulsarClientException e) {
e.printStackTrace();
//回滚事务
transaction.abort();
}finally {
//6. 释放资源
producer1.close();
producer2.close();
pulsarClient.close();
}
}
}
2.3.3 实现多条消息发送到单个topic的原子性操作
- 事务消息不能与批量同时使用,事务有超时控制机制。
- 事务消息可以混杂立即发送和延迟发送消息,完整的发送事务代码如下:
import com.yibo.pulsar.pojo.User;
import org.apache.pulsar.client.api.Producer;
import org.apache.pulsar.client.api.PulsarClient;
import org.apache.pulsar.client.api.PulsarClientException;
import org.apache.pulsar.client.api.TypedMessageBuilder;
import org.apache.pulsar.client.api.transaction.Transaction;
import org.apache.pulsar.client.impl.schema.AvroSchema;
import java.util.concurrent.TimeUnit;
/**
* @Author: huangyibo
* @Date: 2022/6/1 23:32
* @Description: Pulsar 事务的操作
* 实现多条消息发送到单个topic的原子性操作
*/
public class PulsarProducerTransaction1 {
public static void main(String[] args) throws Exception {
// 1 创建pulsar支持事务的客户端对象
String serviceUrl = "pulsar://192.168.23.111:6650,192.168.23.112:6650,192.168.23.113:6650";
PulsarClient pulsarClient = PulsarClient.builder().serviceUrl(serviceUrl)
.enableTransaction(true)
.build();
String txnTopic1 = "persistent://my-tenant/my-ns/my-txn-topic1";
//2. 开启事务的支持
Transaction transaction = pulsarClient.newTransaction()
.withTransactionTimeout(1, TimeUnit.SECONDS)
.build().get();
Producer producer = null;
try {
//3. 基于客户端对象进行构建生产者对象
producer = pulsarClient.newProducer(AvroSchema.of(User.class))
.topic(txnTopic1)
.sendTimeout(1, TimeUnit.SECONDS)
.create();
//4. 发送数据生产
for (int i = 0; i < 25; i++) {
User user = new User();
user.setName("张无忌");
user.setAge(20 + i);
// BatchProducer.newMessage().value();
TypedMessageBuilder messageBuilder ;
if (i %2 ==0) {
messageBuilder = producer.newMessage(transaction).key("key" + i).value(user)
.property("user-defined-property", "value");
}else {
messageBuilder = producer.newMessage(transaction).deliverAfter(100, TimeUnit.SECONDS).key("key" + i).value(user)
.property("user-defined-property", "value");
}
messageBuilder.sendAsync().thenAccept(messageId -> {
System.out.println("Published batch message: " + messageId);
}).exceptionally(ex -> {
System.err.println("Failed to publish: " + ex);
return null;
});
}
//5. 提交事务
transaction.commit();
} catch (PulsarClientException e) {
e.printStackTrace();
//回滚事务
transaction.abort();
}finally {
//6. 释放资源
producer.close();
pulsarClient.close();
}
}
}
2.3.4 消费侧事务
import com.yibo.pulsar.pojo.User;
import org.apache.pulsar.client.api.Consumer;
import org.apache.pulsar.client.api.Message;
import org.apache.pulsar.client.api.PulsarClient;
import org.apache.pulsar.client.api.SubscriptionType;
import org.apache.pulsar.client.api.transaction.Transaction;
import org.apache.pulsar.client.impl.schema.AvroSchema;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* @Author: huangyibo
* @Date: 2022/6/1 23:39
* @Description: Pulsar 事务的操作 消费侧事务
*/
public class PulsarConsumerTransaction {
public static void main(String[] args) throws Exception {
//1. 创建pulsar支持事务的客户端对象
String serviceUrl = "pulsar://192.168.23.111:6650,192.168.23.112:6650,192.168.23.113:6650";
PulsarClient pulsarClient = PulsarClient.builder().serviceUrl(serviceUrl)
.enableTransaction(true)
.build();
String txnTopic1 = "persistent://my-tenant/my-ns/my-txn-topic1";
String txnTopic2 = "persistent://my-tenant/my-ns/my-txn-topic2";
List topicList = new ArrayList<>();
topicList.add(txnTopic1);
topicList.add(txnTopic2);
//2. 开启事务的支持
Transaction transaction = pulsarClient.newTransaction()
.withTransactionTimeout(1, TimeUnit.SECONDS)
.build().get();
//3. 基于客户端构建消费者对象
Consumer consumer = pulsarClient.newConsumer(AvroSchema.of(User.class))
// 可以传入多个topic
.topics(topicList)
.subscriptionName("consume-txn")
.subscriptionType(SubscriptionType.Shared)
.subscribe();
//Messages m = consumer.batchReceive();
int i=0;
while (true) {
Message message = consumer.receive();
try {
// Do something with the message
System.out.println("Message received: " + message.getValue());
// Acknowledge the message so that it can be deleted by the message broker
consumer.acknowledgeCumulativeAsync(message.getMessageId(), transaction);
} catch (Exception e) {
// Message failed to process, redeliver later
consumer.negativeAcknowledge(message);
}
i++;
if (i >= 5) {
transaction.commit();
transaction = pulsarClient.newTransaction().withTransactionTimeout(1, TimeUnit.SECONDS).build().get();
i = 0;
}
}
}
}
流处理系统中,常见的 read-process-write 操作的 Exactly-once 语义的事务流程
当未开启事务时,如果Function先把结果写入SinkTopic,但是消息确认失败(下图Step-4失败),这会导致消息被重新被投递(下图Step-1),Function会重新计算一个结果再发送到SinkTopic,这样就会出现一条数据被重复计算并投递了两次。
如果没有开启事务,Function会先确认消息,再把数据写入SinkTopic(先执行Step-4 再执行Step-3),此时如果写入SinkTopic失败,而SourceTopic的消息又已经被确认,则会造成数据丢失,最终的计算结果也不准确。
如果开启了事务,只要最后没有commit,前面所有的步骤都会被回滚,生产的消息、确认过的消息都被回滚,从而让整个流程可以重新再来一遍,不会重复计算,也不会丢失数据。整个时序图如下所示:
2.4 事务流程
在了解整个事务流程之前,我们先介绍Pulsar中事务的组件,常见的分布式事务中都会有TC、TM、RM等组件:
TM:事务发起者。定义事务的边界,负责告知 TC,分布式事务的开始,提交,回滚。在Pulsar事务中,由每个PulsarClient来扮演这个角色。
RM:每个节点的资源管理者。管理每个分支事务的资源,每一个 RM 都会作为一个分支事务注册在 TC。在Pulsar中定义了一个TopicTransactionBuffer和PendingAckHandle来分别管理生产、消费的资源。
TC :事务协调者。TC用于处理来自Pulsar Client的事务请求以跟踪其事务状态的模块。每个TC都有一个 唯一id (TCID) 标识,TC之间独立维护自己的事务元数据存储,TCID 用于生成事务 ID,广播通知不同节点提交、回滚事务。
下面,我们以一个Producer来介绍整个事务的流程,图中灰色部分代表存储,现有内存和Bookkeeper两种存储实现:
1、选择TC:一个Pulsar集群中可能存在多个TC(默认16个),PulsarClient在创建事务时需要先选择用哪个TC,后续所有事务的创建、提交、回滚等操作都会发往这个TC。选择的规则很简单,由于TC的Topic是固定的,首先Lookup查看所有分区所在的Broker(每个分区就是一个TC),然后每次Client创建新事务,轮询选择一个TC即可。
2、开启事务:代码中通过pulsarClient.newTransaction()开启一个事务,Client会往对应的TC中发送一个newTxn命令,TC生成并返回一个新事务的ID对象,对象里保存了TC的ID(用于后续请求找节点)和事务的ID,事务ID是递增的,同一个TC生成ID不会重复。
3、注册分区:Topic有可能是分区主题,消息会被发往不同的Broker节点,为了让TC知道消息会发送到哪些节点(后续事务提交、回滚时TC需要通知这些节点),Producer在发送消息之前,会先往TC上注册分区信息。这样一来,后续TC就知道要通知哪些节点的RM来提交、回滚事务了。
4、发送消息:这一步和普通的消息发送没有太大的区别,不过消息需要先经过每个Broker上的RM,Pulsar中RM被定义为TopicTransactionBuffer,RM里面会记录一些元数据,最后消息还是会被写入原始的Topic中。此时虽然消息已经被写入了原始Topic,但消费者是不可见的,Pulsar中的事务隔离级别是Read Commit。
5、提交事务:Producer发送完所有的消息后,提交事务,TC会收到提交请求后,会广播通知RM节点提交事务,更新对应的元数据,让消息可以被消费者消费。
Setp-4中的消息是如何保证持久化到Topic中又不可见的呢?
每个Topic中都会保存一个maxReadPosition属性,用来标识当前消费者可以读取的最大位置,当事务还未提交之前,虽然数据已经持久化到Topic中,但是maxReadPosition是不会改变的。因此Consumer无法消费到未提交的数据。
消息已经持久化了,最后事务要回滚,这部分数据如何处理?
如果事务要回滚,RM中会记录这个事务为Aborted状态。每条消息的元数据中都会保存事务的ID等信息,Dispatcher中会根据事务ID判断这条消息是否需要投递给Consumer。如果发现事务已经结束,则直接过滤掉(内部确认掉消息)。
最后提交事务时如果部分成功、部分失败,如何处理?
TC中有一个名为TransactionOpRetryTimer的定时对象,所有未全部成功广播的事务都会交给它来重试,直到所有节点最终全部成功或超过重试次数。那这个过程不会出现一致性问题吗?首先我们想想,出现这种情况的场景是什么。通常是某些Broker节点宕机导致这些节点不可用,或是网络抖动导致暂时不可达。在Pulsar中如果出现Broker宕机,Topic的归属是会转移的,除非整个集群不可用,否则总是可以找到一个新的Broker,通过重试来解决。在Topic归属转移过程中,maxReadPosition没有改变,消费者也消费不到消息。即使整个集群不可用,后续等到集群恢复后,Timer还是会通过重试让事务提交。
如果事务未完成,会阻塞普通消息的消费吗?
会。假设我们开启事务,发送了几条事务消息,但是并未提交或回滚事务。此时继续往Topic中发送普通消息,由于事务消息一直没有提交,maxReadPosition不会变化,消费者会消费不到新的消息,会阻塞普通消息的消费。这是符合预期的行为,为了保证消息的顺序。而不同Topic之间不会相互影响,因为每个Topic都有自己的maxReadPosition。
三、事务的实现
我们可以把事务的实现分为五部分:环境、TC、生产者RM、消费者RM、客户端。由于生产和消费资源的管理是分开的,因此我们会分别介绍。
3.1 环境设定
事务协调者的设置,需要从Pulsar集群的初始化时开始,我们在第一章中有介绍如何搭建集群,第一次需要执行一段命令,初始化ZooKeeper中的集群元数据。此时,Pulsar会自动创建一个SystemNamespace,并在里面创建一个Topic,完整的Topic如下所示:
persistent://pulsar/system/transaction_coordinator_assign
这是一个PartitionedTopic,默认有16个分区,每个分区就是一个独立的TC。我们可以通过--initial-num-transaction-coordinators参数来设置TC的数量。
3.2 TC与RM
接下来,我们看看服务端的事务组件,如下图所示:
- TransactionMetadataStoreService:是Broker上事务的总体协调者,我们可以认为它是TC。
- TransactionMetadataStore:被TC用来保存事务的元数据,如:新创建的事务,Producer注册上来的分区。这个接口有两个实现类,一个是把数据保存到Bookkeeper的实现,另外一个则直接把数据保存在内存中。
- TransactionTimeoutTracker:服务端用于追踪超时的事务。
- 各种Provider:它们都属于工厂类,无需特别关注。
- TopicTransactionBuffer:生产者的RM,当事务消息被发送到Broker,RM作为代理会记录一些元数据,然后把消息存入原始Topic。内部包含了TopicTransactionBufferRecover和TransactionBufferSnapshotService,RM的元数据会被结构化为快照并定时刷盘,这两个对象分别负责快照的恢复和快照的保存。由于生产消息是以Topic为单位,因此每个Topic/Partition都会有一个。
- PendingAckHandle:消费者的RM,由于消费是以订阅为单位的,因此每个订阅都有一个。
由于线上环境通常会使用持久化的事务,因此下面的原理都基于持久化实现。
所有事务相关的服务,在BrokerService启动时会初始化。TC主题中,每个Partition都是一个Topic,TransactionMetadataStoreService在初始化时,会根据当前Broker纳管的TC Partition,从Bookkeeper中恢复之前持久化的元数据。每个TC会保存以下元数据:
- newTransaction:新建一个事务,返回一个唯一的事务ID对象。
- addProducedPartitionToTxn:注册生产者要发送消息的Partition信息,用于后续TC通知对应节点的RM提交/回滚事务。
- addAckedPartitionToTxn:注册消费者要消费消息的Partition信息,用于后续TC通知对应节点的RM提交/回滚事务。
- endTransaction:结束一个事务,可以是提交、回滚或者超时等。
我们在初始化PulsarClient时,如果设置了enableTransaction=true,则Client初始化时,还会额外初始化一个TransactionCoordinatorClient。由于TC的Tenant、Namespace以及Topic名称都是固定的,因此TC客户端可以通过Lookup发现所有的Partition信息并缓存到本地,后续Client创建事务时,会轮询从这个缓存列表中选取下一个事务要使用的TC。
3.3 Producer事务管理
接下来我们会开启一个事务:
// 创建事务
Transaction txn = pulsarClient
.newTransaction()
.withTransactionTimeout(1, TimeUnit.MINUTES)
.build()
.get();
上面这段代码中,会发送一个newTxn给某个TC,并得到一个Transaction对象。
开启事务时,TransactionCoordinatorClient会从缓存中选取一个TC,然后往选定的TC所在的Broker发送一个newTxn命令,命令的结构定义如下所示:
message CommandNewTxn {
required uint64 request_id = 1;
optional uint64 txn_ttl_seconds = 2 [default = 0];
optional uint64 tc_id = 3 [default = 0];
}
由于命令中包含了TCID,因此即使多个TC被同一个Broker纳管也没有问题。Broker会根据TCID找到对应的TC并处理请求。
Producer发送消息之前,会先发送一个AddPartitionToTxn命令给Broker,只有成功后,才会继续发送真实的消息。事务消息到达Broker后,传递给TransactionBuffer进行处理。期间Broker必定会对消息进行去重校验,通过校验后,数据会保存到TransactionBuffer里,而TransactionBuffer只是一个代理(会保存一些元数据),它最终会调用原始Topic保存消息,TransactionBuffer在初始化时,构造方法需要传入原始Topic对象。我们可以把TransactionBuffer看作是Producer端的RM。
TransactionBuffer中会保存两种信息,一种是原始消息,直接使用Topic保存。另外一种是快照,快照中保存了Topic名称,最大可读位置信息(避免Consumer读到未提交的数据)、该Topic中已经中断(aborted)的事务列表。
其中,中断的事务,是由TC广播告知其他Broker节点的,TransactionBuffer接到信息后,会直接在原始Topic中写入一个abortMarker,标记事务已经中断,然后更新内存中的列表。abortMarker也是一条普通的消息,但是消息头中的元数据和普通消息不一样。这些数据保存在快照中,主要是为了Broker重启后数据能快速恢复。如果快照数据丢失,TopicTransactionBufferRecover会从尾到头读取Topic中的所有数据,每遇到一个abortMarker都会更新内存中的中断列表。如果有了快照,我们只需要从快照处的起点开始读即可恢复数据。
3.4 Consumer事务管理
消费者需要在消息确认时带上事务对象,标识使用事务Ack:
consumer.acknowledge(message, txn);
服务端每个订阅都有一个PendingAckHandle对象用于管理事务Ack信息,我们可以认为它是管理消费者数据的RM。当Broker发现消息确认请求中带有事务信息,则会把这个请求转交给对应的PendingAckHandle处理。
所有开启了事务的消息确认,不会直接修改游标上的MarkDeleted位置,而是先持久化到一个额外的Ledger中,Broker内存中也会缓存一份。这个Ledger由pendingAckStore管理,我们可以认为是Consumer RM的日志。
事务提交时,RM会调用消费者对应的Subscription,执行刚才所有的消息确认操作。同时,也会在日志Ledger中写入一个特殊的Marker,标识事务需要提交。在事务回滚时,也会先在日志中记录一个AbortMarker,然后触发Message重新投递。
pendingAckStore中保存的日志是redo log,该组件在初始化时,会先从日志Ledger中读取所有redo log,从而在内存中重建先前的消息确认信息。因为消息确认是幂等操作,如果Broker不慎宕机,只需要把redo log中的操作重新执行一遍。当订阅中的消息被真正确认掉后,pendingAckStore中对应的redo log也可以被清理了。清理方式很简单,只需要移动pendingAckStore中Ledger的MarkDelete位置即可。
3.5 再谈TC
所有的事务提交、回滚,由于Client端告知TC,或者由于超时TC自动感知。TC的日志中保存了Producer的消息要发往哪些Partition,也保存了Consumer会Ack哪些Partition。RM分散在每个Broker上,记录了整个事务中发送的消息和要确认的消息。当事务结束时,TC则以TCID为key,找到所有的元数据,通过元数据得知需要通知哪些Broker上的RM,最后发起广播,通知这些Broker上的RM,事务需要提交/回滚。
参考:
https://pulsar.apache.org/docs/zh-CN/txn-how/
https://blog.csdn.net/yy8623977/article/details/124097585
https://blog.csdn.net/weixin_40455124/article/details/121501511
https://blog.csdn.net/u010657094/article/details/124053071
https://baijiahao.baidu.com/s?id=1710928488479266873
https://segmentfault.com/a/1190000041490199
https://zhuanlan.zhihu.com/p/475356136
https://baijiahao.baidu.com/s?id=1710928488479266873
https://zhuanlan.zhihu.com/p/437384528