一、事务
1. 事务简介
1.1 事务场景
- producer发的多条消息组成⼀个事务这些消息需要对consumer同时可⻅或者同时不可⻅
- producer可能会给多个topic,多个partition发消息,这些消息也需要能放在⼀个事务⾥⾯,这就形成了⼀个典型的分布式事务
- kafka的应⽤场景经常是应⽤先消费⼀个topic,然后做处理再发到另⼀个topic,这个consume-transform-produce过程需要放到⼀个事务⾥⾯,⽐如在消息处理或者发送的过程中如果失败了,消费偏移量也不能提交
- producer或者producer所在的应⽤可能会挂掉,新的producer启动以后需要知道怎么处理之前未完成的事务
1.2 关键概念和推导
- 因为producer发送消息可能是分布式事务,所以引⼊了常⽤的2PC,所以有事务协调者(Transaction Coordinator)。Transaction Coordinator和之前为了解决脑裂和惊群问题引⼊的Group Coordinator在选举和failover上⾯类似
- 事务管理中事务⽇志是必不可少的,kafka使⽤⼀个内部topic来保存事务⽇志,这个设计和之前使⽤内部topic保存偏移量的设计保持⼀致。事务⽇志使Transaction Coordinator管理的状态的持久化,因为不需要回溯事务的历史状态,所以事务⽇志只⽤保存最近的事务状态
- 因为事务存在commit和abort两种操作,⽽客户端⼜有read committed和read uncommitted两种隔离级别,所以消息队列必须能标识事务状态,这个被称作Control Message
- producer挂掉重启或者漂移到其它机器,需要能关联到之前的未完成事务,所以需要有⼀个唯⼀标识符来进⾏关联,这个就是TransactionalId,⼀个producer挂了,另⼀个有相同TransactionalId的producer能够接着处理这个事务未完成的状态。kafka⽬前没有引⼊全局序,所以也没有transaction id,这个TransactionalId是⽤户提前配置的
- TransactionalId能关联producer,也需要避免两个使⽤相同TransactionalId的producer同时存在,所以引⼊了producer epoch来保证对应⼀个TransactionalId只有⼀个活跃的producer epoch
1.3 事务语义
多分区原子写入:
事务能够保证Kafka topic下每个分区的原⼦写⼊。事务中所有的消息都将被成功写⼊或者丢弃。
⾸先,我们来考虑⼀下原⼦读取-处理-写⼊周期是什么意思。简⽽⾔之,这意味着如果某个应⽤程序在某个topic tp0的偏移量X处读取到了消息A,并且在对消息A进⾏了⼀些处理(如B = F(A)),之后将消息B写⼊topic tp1,则只有当消息A和B被认为被成功地消费并⼀起发布,或者完全不发布时,整个读取过程写⼊操作是原⼦的。
现在,只有当消息A的偏移量X被标记为已消费,消息A才从topic tp0消费,消费到的数据偏移量(record offset)将被标记为提交偏移量(Committing offset)。在Kafka中,我们通过写⼊⼀个名为offsets topic的内部Kafka topic来记录offset commit。消息仅在其offset被提交给offsets topic时才被认为成功消费。
由于offset commit只是对Kafka topic的另⼀次写⼊,并且由于消息仅在提交偏移量时被视为成功消费,所以跨多个主题和分区的原⼦写⼊也启⽤原⼦读取-处理-写⼊循环:提交偏移量X到offset topic和消息B到tp1的写⼊将是单个事务的⼀部分,所以整个步骤都是原⼦的。
粉碎“僵尸实例”:
我们通过为每个事务Producer分配⼀个称为transactional.id
的唯⼀标识符来解决僵⼫实例的问题。在进程重新启动时能够识别相同的Producer实例。
API要求事务性Producer的第⼀个操作应该是在Kafka集群中显示注册transactional.id
。 当注册的时候,Kafka broker⽤给定的transactional.id
检查打开的事务并且完成处理。 Kafka也增加了⼀个与transactional.id
相关的epoch。Epoch存储每个transactional.id
内部元数据。
⼀旦epoch被触发,任何具有相同的transactional.id
和旧的epoch的⽣产者被视为僵⼫,Kafka拒绝来⾃这些⽣产者的后续事务性写⼊。
简⽽⾔之:Kafka可以保证Consumer最终只能消费⾮事务性消息或已提交事务性消息。它将保留来⾃未完成事务的消息,并过滤掉已中⽌事务的消息。
1.4 事务的使用场景
在⼀个原⼦操作中,根据包含的操作类型,可以分为三种情况,前两种情况是事务引⼊的场景,最后⼀种没⽤:
- 只有Producer⽣产消息;
- 消费消息和⽣产消息并存,这个是事务场景中最常⽤的情况,就是我们常说的
consume-transform-produce
模式 - 只有consumer消费消息,这种操作其实没有什么意义,跟使⽤⼿动提交效果⼀样,⽽且也不是事务属性引⼊的⽬的,所以⼀般不会使⽤这种情况
1.5 事务配置
创建消费者代码,需要:
- 将配置中的⾃动提交属性(
auto.commit
)进⾏关闭 - ⽽且在代码⾥⾯也不能使⽤⼿动提交
commitSync()
或者commitAsync()
- 设置
isolation.level
创建生产者,代码如下,需要:
- 配置
transactional.id
属性 - 配置
enable.idempotence
属性
事务相关配置
Broker configs:
配置项 | 说明 |
---|---|
transactional.id.timeout.ms | 在ms中,事务协调器在⽣产者TransactionalId提前过期之前等待的最⻓时间,并且没有从该⽣产者TransactionalId接收到任何事务状态更新。默认是604800000(7天)。这允许每周⼀次的⽣产者作业维护它们的id |
max.transaction.timeout.ms | 事务允许的最⼤超时。如果客户端请求的事务时间超过此时间,broke将在InitPidRequest中返回InvalidTransactionTimeout错误。这可以防⽌客户机超时过⼤,从⽽导致⽤户⽆法从事务中包含的主题读取内容。 默认值为900000(15分钟)。这是消息事务需要发送的时间的保守上限。 |
transaction.state.log.replication.factor | 事务状态topic的副本数量。默认值:3 |
transaction.state.log.num.partitions | 事务状态主题的分区数。默认值:50 |
transaction.state.log.min.isr | 事务状态主题的每个分区ISR最⼩数量。默认值:2 |
transaction.state.log.segment.bytes | 事务状态主题的segment⼤⼩。默认值:104857600字节 |
Producer configs:
配置项 | 说明 |
---|---|
enable.idempotence | 开启幂等 |
transaction.timeout.ms | 事务超时时间 事务协调器在主动中⽌正在进⾏的事务之前等待⽣产者更新事务状态的最⻓时间。这个配置值将与InitPidRequest⼀起发送到事务协调器。如果该值⼤于max.transaction.timeout。在broke中设置ms时,请求将失败,并出现InvalidTransactionTimeout错误。 默认是60000。这使得交易不会阻塞下游消费超过⼀分钟,这在实时应⽤程序中通常是允许的。 |
transactional.id | ⽤于事务性交付的TransactionalId。这⽀持跨多个⽣产者会话的可靠性语义,因为它允许客户端确保使⽤相同TransactionalId的事务在启动任何新事务之前已经完成。如果没有提供TransactionalId,则⽣产者仅限于幂等交付。 |
Consumer configs:
配置项 | 说明 |
---|---|
isolation.level | - read_uncommitted:以偏移顺序使⽤已提交和未提交的消息。 - read_committed:仅以偏移量顺序使⽤⾮事务性消息或已提交事务性消息。为了维护偏移排序,这个设置意味着我们必须在使⽤者中缓冲消息,直到看到给定事务中的所有消息。 |
1.6 事务工作原理
-
事务协调器和事务⽇志
事务协调器是每个Kafka内部运⾏的⼀个模块。事务⽇志是⼀个内部的主题。每个协调器拥有事务⽇志所在分区的⼦集,即这些 borker 中的分区都是Leader。
每个transactional.id
都通过⼀个简单的哈希函数映射到事务⽇志的特定分区,事务⽇志⽂件__transaction_state-0
。这意味着只有⼀个Broker拥有给定的transactional.id
。
通过这种⽅式,我们利⽤Kafka可靠的复制协议和Leader选举流程来确保事务协调器始终可⽤,并且所有事务状态都能够持久化。
值得注意的是,事务⽇志只保存事务的最新状态⽽不是事务中的实际消息。消息只存储在实际的Topic的分区中。事务可以处于诸如“Ongoing”,“prepare commit”和“Completed”之类的各种状态中。正是这种状态和关联的元数据存储在事务⽇志中。 -
事务数据流
数据流在抽象层⾯上有四种不同的类型- producer和事务coordinator的交互
执⾏事务时,Producer向事务协调员发出如下请求:initTransactions API
向coordinator
注册⼀个transactional.id
。 此时,coordinator
使⽤该transactional.id
关闭所有待处理的事务,并且会避免遇到僵⼫实例,由具有相同的transactional.id
的Producer的另⼀个实例启动的任何事务将被关闭和隔离。每个Producer会话只发⽣⼀次。- 当Producer在事务中第⼀次将数据发送到分区时,⾸先向
coordinator
注册分区 - 当应⽤程序调⽤
commitTransaction
或abortTransaction
时,会向coordinator
发送⼀个请求以开始两阶段提交协议。
- Coordinator和事务⽇志交互
随着事务的进⾏,Producer发送上⾯的请求来更新Coordinator上事务的状态。事务Coordinator会在内存中保存每个事务的状态,并且把这个状态写到事务⽇志中(这是以三种⽅式复制的,因此是持久保存的)。
事务Coordinator是读写事务⽇志的唯⼀组件。如果⼀个给定的Borker故障了,⼀个新的Coordinator会被选为新的事务⽇志的Leader,这个事务⽇志分割了这个失效的代理,它从传⼊的分区中读取消息并在内存中重建状态。 - Producer将数据写⼊⽬标Topic所在分区
在Coordinator的事务中注册新的分区后,Producer将数据正常地发送到真实数据所在分区。这与producer.send流程完全相同,但有⼀些额外的验证,以确保Producer不被隔离。 - Topic分区和Coordinator的交互
- 在Producer发起提交(或中⽌)之后,协调器开始两阶段提交协议。
- 在第⼀阶段,Coordinator将其内部状态更新为“prepare_commit”并在事务⽇志中更新此状态。⼀旦完成了这个事务,⽆论发⽣什么事,都能保证事务完成。
- Coordinator然后开始阶段2,在那⾥它将事务提交标记写⼊作为事务⼀部分的Topic分区。
- 这些事务标记不会暴露给应⽤程序,但是在read_committed模式下被Consumer使⽤来过滤掉被中⽌事务的消息,并且不返回属于开放事务的消息(即那些在⽇志中但没有事务标记与他们相关联)
- ⼀旦标记被写⼊,事务协调器将事务标记为“完成”,并且Producer可以开始下⼀个事务。
- producer和事务coordinator的交互
2. 幂等性
Kafka在引⼊幂等性之前,Producer向Broker发送消息,然后Broker将消息追加到消息流中后给Producer返回Ack信号值。实现流程如下:
⽣产中,会出现各种不确定的因素,⽐如在Producer在发送给Broker的时候出现⽹络异常。⽐如以下这种异常情况的出现:
上图这种情况,当Producer第⼀次发送消息给Broker时,Broker将消息(x2,y2)追加到了消息流中,但是在返回Ack信号给Producer时失败了(⽐如⽹络异常) 。此时,Producer端触发重试机制,将消息(x2,y2)重新发送给Broker,Broker接收到消息后,再次将该消息追加到消息流中,然后成功返回Ack信号给Producer。这样下来,消息流中就被重复追加了两条相同的(x2,y2)的消息。
幂等性
保证在消息重发的时候,消费者不会重复处理。即使在消费者收到重复消息的时候,重复处理,也要保证最终结果的⼀致性。
所谓幂等性,数学概念就是:f(f(x)) = f(x)
。f函数表示对消息的处理。
⽐如,银⾏转账,如果失败,需要重试。不管重试多少次,都要保证最终结果⼀定是⼀致的。
幂等性实现
添加唯⼀ID,类似于数据库的主键,⽤于唯⼀标记⼀个消息。
Kafka为了实现幂等性,它在底层设计架构中引⼊了ProducerID
和SequenceNumber
。
- ProducerID:在每个新的Producer初始化时,会被分配⼀个唯⼀的ProducerID,这个ProducerID对客户端使⽤者是不可⻅的。
- SequenceNumber:对于每个ProducerID,Producer发送数据的每个Topic和Partition都对应⼀个从0开始单调递增的SequenceNumber值。
同样,这是⼀种理想状态下的发送流程。实际情况下,会有很多不确定的因素,⽐如Broker在发送Ack信号给Producer时出现⽹络异常,导致发送失败。异常情况如下图所示:
当Producer发送消息(x2,y2)给Broker时,Broker接收到消息并将其追加到消息流中。此时,Broker返回Ack信号给Producer时,发⽣异常导致Producer接收Ack信号失败。对于Producer来说,会触发重试机制,将消息(x2,y2)再次发送,但是,由于引⼊了幂等性,在每条消息中附带了PID(ProducerID)和SequenceNumber。相同的PID和SequenceNumber发送给Broker,⽽之前Broker缓存过之前发送的相同的消息,那么在消息流中的消息就只有⼀条(x2,y2),不会出现重复发送的情况。
客户端在⽣成Producer时,会实例化如下代码:
// 实例化⼀个Producer对象 |
|
Producer<String, String> producer = new KafkaProducer<>(props); |
在org.apache.kafka.clients.producer.internals.Sender
类中,在run()中有⼀个maybeWaitForPid()
⽅法,⽤来⽣成⼀个ProducerID,实现代码如下:
private void maybeWaitForPid() {
|
|
if (transactionState == null) |
|
return; |
|
while (!transactionState.hasPid()) {
|
|
try {
|
|
Node node = awaitLeastLoadedNodeReady(requestTimeout); |
|
if (node != null) {
|
|
ClientResponse response = sendAndAwaitInitPidRequest(node); |
|
if (response.hasResponse() && (response.responseBody() instanceof InitPidResponse)) {
|
|
InitPidResponse initPidResponse = (InitPidResponse) response.responseBody(); |
|
transactionState.setPidAndEpoch(initPidResponse.producerId(), initPidResponse.epoch()); |
|
} else {
|
|
log.error("Received an unexpected response type for an InitPidRequest from {}. " + "We will back off and try again.", node); |
|
} |
|
} else {
|
|
log.debug("Could not find an available broker to send InitPidRequest to. " + "We will back off and try again."); |
|
} |
|
} catch (Exception e) {
|
|
log.warn("Received an exception while trying to get a pid. Will back off and retry.", e); |
|
} |
|
log.trace("Retry InitPidRequest in {}ms.", retryBackoffMs); |
|
time.sleep(retryBackoffMs); |
|
metadata.requestUpdate(); |
|
} |
|
} |
3. 事务操作
在Kafka事务中,⼀个原⼦性操作,根据操作类型可以分为3种情况。情况如下:
- 只有Producer⽣产消息,这种场景需要事务的介⼊;
- 消费消息和⽣产消息并存,⽐如Consumer&Producer模式,这种场景是⼀般Kafka项⽬中⽐较常⻅的模式,需要事务介⼊;
- 只有Consumer消费消息,这种操作在实际项⽬中意义不⼤,和⼿动Commit Offsets的结果⼀样,⽽且这种场景不是事务的引⼊⽬的。
// 初始化事务,需要注意确保transation.id属性被分配 |
|
void initTransactions(); |
|
// 开启事务 |
|
void beginTransaction() throws ProducerFencedException; |
|
// 为Consumer提供的在事务内Commit Offsets的操作 |
|
void sendOffsetsToTransaction(Map<TopicPartition, OffsetAndMetadata> offsets, String consumerGroupId) throws ProducerFencedException; |
|
// 提交事务 |
|
void commitTransaction() throws ProducerFencedException; |
|
// 放弃事务,类似于回滚事务的操作 |
|
void abortTransaction() throws ProducerFencedException; |
案例1:单个Producer,使⽤事务保证消息的仅⼀次发送: