Apache Kafka 事务机制
为什么需要事务呢?
在 Kafka 中设计事务主要针对表现出 “读取-处理-写入”(read-process-write) 模式的应用程序,其中读取和写入来自异步数据流(例如 Kafka 主题),即事务中同时包含读取消息、处理消息和写入消息过程,此类应用程序更普遍地称为流处理应用程序。
第一代流处理应用程序可以容忍不准确的处理,例如,消耗网页印象流并生成每个网页的总浏览次数的应用程序可以容忍计数中的一些错误。
然而,随着流处理应用程序的流行,对具有更强语义的流处理应用程序的需求也随之增长。
例如,一些金融机构使用流处理应用程序来处理用户帐户的借记和贷记,在这些情况下,处理过程中不能容忍错误:我们需要每条消息都被 精确处理一次。
更正式地说,如果流处理应用程序消费消息 \(A\) 并生成消息 \(B\), 使得 \(B = F(A)\),则一次性处理意味着当且仅当成功生成 \(B\) 时才将 \(A\) 视为已消费,反之亦然。
如果我们使用配置为 至少处理一次(at-least-once) 投递语义的普通 Kafka 生产者和消费者,流处理应用程序可能会在如下场景下,违反 精确处理一次(exactly-once)处理语义:
-
由于内部重试机制,
Producer.send()
可能会导致消息 \(B\) 的重复写入。这是可以通过配置 幂等生产者 解决。
-
我们可能会重复处理输入消息 \(A\),导致重复的 \(B\) 消息被写入输出,从而违反了一次性处理语义。
如果流处理应用程序,在写入消息 \(B\) 之后,同时,在将消息 \(A\) 标记为已使用之前,发生崩溃,则可能会发生重新处理。
因此,当流处理应用程序恢复时,它将再次消费消息 \(A\),并再次写入消息 \(B\),从而导致消息重复。
-
在分布式环境中,应用程序将崩溃,或者更糟糕的是,暂时失去与系统其余部分的连接,新实例会自动启动以替换被视为丢失的实例。
通常通过这个过程,我们可能有多个实例处理相同的输入主题并写入相同的输出主题,导致重复输出并违反一次性处理语义,这种称之为“僵尸实例”问题。
我们在Kafka中设计了事务API来解决第二个问题和第三个问题。事务通过使这些周期原子化,并促进僵尸防护,在 读取-处理-写入 周期中实现 精确一次性处理。
幂等生产者与事务型生产者
幂等生产者(Idempotent Producer)与事务型生产者(Transactional Producer)的差异:
Delivery Semantic | Guarantee | 特点 | 影响 |
---|---|---|---|
Idempotent Producer | 通过引入 PID 和 Sequence Number,保证了单分区内的单会话消息幂等性。 | 只能保证单分区上的幂等,并且只能实现单个会话上的幂等,不能实现跨会话的幂等。 | 进程不能重启,重启之后幂等性保证就丧失了。 |
Transactional Producer | 通过引入 transactionalId 和 Epoch,实现了多分区、多会话的幂等性。 | 事务型 Producer 能够保证将消息原子性地写入到多个分区中,即使Producer重启也依然能保证它们发送的消息精确一次投递。 | 相比幂等性 Producer,事务型 Producer 的性能要更差。 |
恰好一次语义
Kafka 的一次性消息传递语义意味着多个步骤的组合结果将只发生一次。消息将被消费、处理并生成结果消息,并且只发生一次。
这里的关键是,任何轮询出站主题的下游 Kafka 消费者只会收到这些结果消息一次 —— 保证不会有重复,即使生产者必须重试发送。
失败场景可能意味着原始消息被多次使用和处理(或部分处理),但这永远不会导致发布重复的出站事件。
事务语义
原子多分区写入
事务支持对多个 Kafka 主题和分区进行原子写入。事务中包含的所有消息都将被成功写入,或者都不会成功写入。例如,处理期间的错误可能会导致事务中止,在这种情况下,消费者将无法读取来自事务的任何消息。现在我们将了解如何实现原子读取-处理-写入循环。
首先,让我们考虑一下原子读取-处理-写入周期的含义。简而言之,这意味着如果应用程序在某个主题分区tp0的偏移量X处消费消息A ,并在对消息A进行一些处理后将消息B写入主题分区tp1,使得B = F(A),则仅当消息 A 和 B 被视为已成功消费并一起发布(或者根本没有)时,读取-处理-写入周期才是原子的。
现在,只有当消息A的偏移量X被标记为已消费时,才会认为该消息 A 是从主题分区tp0中消费的。将偏移量标记为已消耗称为提交偏移量。在 Kafka 中,我们通过写入称为offsets topic的内部 Kafka 主题来记录偏移量提交。仅当消息的偏移量提交到 offsets topic 时,消息才被视为已消耗。
因此,由于偏移量提交只是对 Kafka 主题的另一次写入,并且由于只有在提交其偏移量时才将消息视为已消耗,因此跨多个主题和分区的原子写入也启用原子读取-处理-写入循环:偏移量的提交X到偏移主题以及将消息B写入tp1将成为单个事务的一部分,因此是原子的。
僵尸防护
我们通过要求为每个事务生产者分配一个名为transactional.id的唯一标识符来解决僵尸实例的问题。这用于在进程重新启动时识别相同的生产者实例。
API 要求事务生产者的第一个操作应该是向 Kafka 集群显式注册其 transactional.id。执行此操作时,Kafka 代理会检查具有给定 transactional.id 的未完成事务并完成它们。它还会增加与 transactional.id 关联的纪元。纪元是为每个 transactional.id 存储的内部元数据。
一旦纪元被碰撞,任何具有相同 transactional.id 和较旧纪元的生产者都被视为僵尸并被隔离,即。来自这些生产者的未来事务写入将被拒绝。
读取事务消息
现在,让我们将注意力转向读取作为事务一部分写入的消息时提供的保证。
仅当事务实际提交时,Kafka 消费者才会将事务消息传递给应用程序。换句话说,消费者不会传递属于开放事务一部分的事务消息,也不会传递属于已中止事务的消息。
值得注意的是,上述保证不足以实现原子读取。特别是,当使用 Kafka 消费者消费来自某个主题的消息时,应用程序将不知道这些消息是否是作为事务的一部分编写的,因此它们不知道事务何时开始或结束。此外,不能保证给定的消费者订阅属于事务一部分的所有分区,并且它无法发现这一点,因此很难保证属于单个事务的所有消息最终都会被消费由单个消费者。
简而言之:Kafka 保证消费者最终只会传递非事务性消息或已提交的事务性消息。它将保留来自打开事务的消息并过滤掉来自中止事务的消息。
Java 中的事务 API
事务功能主要是服务器端和协议级别的功能,可供任何支持它的客户端库使用。使用 Java 编写的使用 Kafka 事务 API 的“读取-处理-写入”应用程序将附录中的示例 1 所示,其中有几个地方需要注意:
-
在生产者中指定 transactional.id 配置;
-
生产者开启事务:
Producer.initTransactions()
;这个步骤,主要有两个作用:
-
确保具有相同
transactional.id
的先前其他的生产者实例发起的任何事务均已完成;如果前一个生产者实例在事务中执行失败,它将被中止。如果最后一个事务已开始,但尚未完成,则这个方法将会等待其完成。
-
获取内部 Producer Id(生产者 ID,PID)和 Epocd (纪元),用于生产者发出的所有未来事务消息;
-
-
消费者隔离级别:
isolation.level=read_committed
确保 Consumer 应该只读取 非事务性消息 或者 已经提交的消息。即主题对应的 Consumer 如果未开启事务,可以直接读取,开启了事务,必须读取已经提交的消息。
流处理应用程序通常在多个读取-处理-写入阶段处理数据,每个阶段都使用前一个阶段的输出作为当前阶段的输入,通过指定 read_committed 隔离级别,我们可以在所有的阶段获得一次性处理。
-
流处理的核心流程:读取-处理-写入 流程
-
首先消费者拉取一些消息(Record);
-
启动事务;
-
处理消费的消息(Record);
-
将处理后的消息(Record)写入输出主题;
-
将消耗的偏移量发送到偏移量主题;
-
最后提交事务。
通过上述流程保证,偏移量提交和输出记录 将会作为原子单元提交。
-
事务运行机制
在本节中,我们将简要概述上面介绍的事务 API 引入的新组件和新数据流。
实现事务,即确保以原子方式生成和消费一组消息,我们引入了几个新概念:
事务协调器
它是每个 Kafka broker 内部运行的模块。
与消费者组协调器类似,每个生产者都分配一个事务协调器,所有分配 PID 和管理事务的逻辑都由事务协调器完成。
事务日志
事务日志是一个的内部 Topic(__transaction_state
),每个协调器都拥有事务日志中分区的一些子集,也就是说,事务日志的所在的分区就是 broker 的领导者分区。
事务日志是事务协调器的状态存储,最新版本日志的快照封装了每个活动事务的当前状态。事务日志中,会记录事务的最新状态和它关联的元数据。
在用户日志中,除了通常的数据记录批次之外,事务协调器还会使分区领导者附加控制记录(commit 或 abort 标记),以跟踪哪些批次已实际提交以及哪些批次已回滚。
控制记录不会被 Kafka 消费者暴露给应用程序,因为它们是事务支持的内部实现细节。
注意,事务日志只存储事务的最新状态,而不存储事务中的实际消息,消息仅存储在实际的主题分区中。
事务状态:
一个事务会经历以下状态:
-
Undecided: 正在进行 (Ongoing)
-
Decided and unreplicated: 准备提交或终止 (PrepareCommit | PrepareAbort)
-
Decided and replicated:已完成提交或者终止 (CompleteCommit | CompleteAbort)
控制消息的概念。
这些是写入用户主题的特殊消息,由客户端处理,但从未暴露给用户。
例如,它们用于让代理向消费者指示先前获取的消息是否已被原子提交。控制消息之前已经在这里提出过。
TransactionalId
使用户能够以持久的方式唯一地标识生产者。具有相同 TransactionalId 的生产者的不同实例将能够恢复(或中止)前一个实例实例化的任何事务。
每个 transactional.id
会 通过简单的哈希函数映射到事务日志的特定分区,因此,只会有一个协调者拥有给定的 transactional.id
。
这样,我们利用 Kafka 坚如磐石的复制协议和领导者选举流程来确保事务协调器始终可用,并且,保证所有的事务状态都得到持久存储。
生产者 epoch
它能够确保同一时刻,只有一个特定的 TransactionalId 的生产者的合法活动实例,从而使我们能够在发生故障时维持事务保证。
除了上述新概念之外,我们还引入了新的请求类型、现有请求的新版本以及核心消息格式的新版本以支持事务。
事务数据流(Data Flow)
在上图中,方形边框代表不同的机器,底部的圆形框代表 Kafka TopicPartition,对角圆形框代表在代理内部运行的逻辑实体。
每个箭头代表一个 RPC,或对 Kafka 主题的写入,这些操作按照每个箭头旁边的数字指示的顺序发生。以下部分的编号与上图中的操作相匹配,并描述了相关操作。
1. FindCoordinatorRequest
由于事务协调器处于分配 PID 和管理事务的中心,因此生产者要做的第一件事就是向任意一个 broker 发送一个 FindCoordinatorRequest
请求以发现其协调器的位置。
FindCoordinatorRequest
以前称为GroupCoordinatorRequest
,但为了更通用的用途而被重命名了。
2. InitPidRequest
发现事务协调器的位置后,生产者会调用 initTransactions()
API 向事务协调器,发送 InitPidRequest
请求注册 transactional.id
,此时,事务协调器将会关闭该 transactional.id
的所有的待处理事务,并更改纪元(Epoch)以排除僵尸实例,每个生产者会话仅执行一次。
这个过程,除了会返回 PID 之外,InitPidRequest
还执行以下步骤:
-
增加 PID 的纪元(Epoch):确保生产者的任何先前的僵尸实例都会被隔离,并且无法继续其事务;
-
恢复(前滚或回滚)生产者的前一个实例留下的任何未完成的事务。
InitPidRequest
的处理是同步的,一旦返回,生产者就可以发送数据,并开始新的事务。
2.1. 指定 TransactionalId
如果生产者配置中指定了 transactional.id
配置,则 TransactionalId 会通过 InitPidRequest 请求传递给协调者,并且会将 <transactionalId, PID> 一对一映射,同时将映射关系与最新的 Epoch 记录到事务日志中。
This enables us to return the same PID for the TransactionalId to future instances of the producer, and hence enables recovering or aborting previously incomplete transactions.
因为事务可能会因为生产者异常下线而终止,通过事务日志,就能保证拥有该 TransactionalId 生产者的新实例在初始化化时,返回相同的 PID,同时会把 Epoch 自增加 1 返回给生产者实例,从而恢复或中止先前不完整的事务。
这样,即时当异常的生产者实例在恢复(如机器重启、网络恢复)后,再次请求操作该事务时,就会因为 Epoch 较小,而被认为无效的实例而被拒绝。
2.2. 未指定TransactionalId
如果生产者配置中未指定 transactional.id
配置,则每次初始化都会分配新的 PID,因此,这种情况下,生产者仅会在单个会话中享有幂等语义和事务语义。
3. beginTransaction()
生产者必须调用 beginTransaction()
方法来开启一个事务。
生产者记录本地状态,表明事务已经开始,但从协调者的角度来看,事务在发送第一个记录之前不会开始。
4. consume-transform-produce loop
在此阶段,生产者开始消费、转换、生产构成事务的消息,这是一个漫长的阶段,可能由多个请求组成。
4.1. AddPartitionsToTxnRequest
当生产者在事务中第一次向分区发送数据时,该分区首先会向事务协调器注册。
The producer sends this request to the transaction coordinator the first time a new TopicPartition is written to as part of a transaction. The addition of this TopicPartition to the transaction is logged by the coordinator in step 4.1a. We need this information so that we can write the commit or abort markers to each TopicPartition (see section 5.2 for details). If this is the first partition added to the transaction, the coordinator will also start the transaction timer.
当一个新的 TopicPartition 第一次被作为事务写入时,生产者将会发送 AddPartitionsToTxnRequest
请求到事务协调器,协调器会将此 TopicPartition 的 offset 的增加记录 写入到事务日志,这样,我们可以给每个 TopicPartition 写入提交或者中止的标记。
如果这是添加到事务中的第一个分区,协调器还将启动事务计时器。
4.2. ProduceRequest
生产者通过一个或多个 ProduceRequest(从生产者的 send 方法触发)将一堆消息写入用户的 TopicPartition。这些请求包括:PID、纪元(epoch)和序列号( sequence number),如图中 4.2a 所示。
4.3. AddOffsetCommitsToTxnRequest
事务协调器会将此主题分区的 offset 的增加记录 写入到事务日志中,如步骤 4.3a 所示。
public void sendOffsetsToTransaction(Map<TopicPartition,OffsetAndMetadata> offsets,
ConsumerGroupMetadata groupMetadata) throws ProducerFencedException
生产者可以调用 KafkaProducer.sendOffsetsToTransaction()
方法,它可以批量地消费和生产消息,此方法采用 Map<TopicPartitions, OffsetAndMetadata> 和 groupId 参数。
sendOffsetsToTransaction()
方法将会构造一个带有 groupId
的 AddOffsetCommitsToTxnRequests
请求发送到事务协调器,从中,可以推断出内部主题 __consumer-offsets
中该消费者组的 TopicPartition。
4.4. TxnOffsetCommitRequest
同样作为 sendOffsets 的一部分,生产者将向消费者协调器发送 TxnOffsetCommitRequest
,以将偏移量保留在 __consumer-offsets
主题中。
消费者协调器通过使用作为此请求的一部分发送的 PID 和生产者纪元来验证生产者是否被允许发出此请求(并且不是僵尸)。在提交事务之前,消耗的偏移量在外部不可见,我们现在将讨论该过程。
5. Committing or Aborting a Transaction
数据写入主题后,用户必须调用 KafkaProducer 的提交或者终止事务的方法:
-
commitTransaction()
:提交一个进行中的事务它会将步骤 4 中产生的数据提供给下游消费者。
-
abortTransaction()
:终止一个进行中的事务它可以有效地从日志中删除生成的数据:用户永远无法访问它,即下游消费者将读取并丢弃中止的消息。
当应用程序调用 commitTransaction 或 abortTransaction 时,会向事务协调器发送请求以开始两阶段提交协议。
5.1. EndTxnRequest
无论调用生产者的提交事务方法或者终止事务方法,生产者都会向事务协调器发送 EndTxnRequest
请求,并附加该事务是要提交还是中止的数据。
收到此请求后,事务协调器:
-
将
PREPARE_COMMIT
或PREPARE_ABORT
消息写入事务日志,参考步骤 5.1a -
开始通过
WriteTxnMarkerRequest
将称为COMMIT
(或ABORT
)标记的命令消息写入用户日志的过程,参考 5.2 -
最后将
COMMITTED
或ABORTED
消息写入事务日志,参考 5.3
5.2. WriteTxnMarkerRequest
该请求由事务协调器向作为事务一部分的每个 TopicPartition 的领导者发出。收到此请求后,每个 Broker 都会将 COMMIT(PID)
或 ABORT(PID)
控制消息写入日志,如步骤 5.2a 所示。
此消息向消费者指示是否必须将具有给定 PID 的消息传递给用户或丢弃。
因此,消费者将缓冲具有 PID 的消息,直到它读取相应的 COMMIT
或 ABORT
消息,此时,它将分别传递或删除消息。
请注意,如果 __consumer-offsets
主题是事务中的 TopicPartition 之一,则提交(或中止)的标记也会被写入日志中,并且通知消费者协调器在以下情况下需要具体化这些偏移量:在中止的情况下提交或忽略它们(左侧步骤 5.2a)。
5.3. Writing the final Commit or Abort Message
当所有提交或中止标记写入数据日志后,事务协调器将最终的 COMMITTED
或 ABORTED
消息写入事务日志,表明事务已完成,如图中的步骤 5.3所示。
此时,事务日志中与该事务相关的大部分消息都可以被删除。
我们只需要保留已完成事务的 PID 和时间戳,因此,我们最终可以删除生产者的 TransactionalId->PID
映射。
交互过程
生产者和事务协调者交互
执行事务时,生产者在以下几点向事务协调者发出请求:
initTransactions()
API 向协调器注册 transactional.id
。此时,协调器将关闭具有该 transactional.id
的所有待处理事务,并更改纪元以排除僵尸。每个生产者会话仅发生一次。
当生产者在事务中第一次向分区发送数据时,该分区首先向协调器注册。
当应用程序调用 commitTransaction()
或 abortTransaction()
时,会向协调器发送请求以开始两阶段提交协议。
协调器和事务日志交互
随着事务的进行,生产者发送上述请求以更新协调器上的事务状态。事务协调器将其拥有的每个事务的状态保存在内存中,并将该状态写入事务日志(以三种方式复制,因此是持久的)。
事务协调器是唯一从事务日志中读取和写入的组件。
如果给定的代理发生故障,则会选举一个新的协调器作为死代理所拥有的事务日志分区的领导者,并从传入分区读取消息以重建这些分区中事务的内存中状态。
生产者将数据写入目标主题分区
在向协调器注册事务中的新分区后,生产者照常将数据发送到实际分区。这与 Producer.send()
流程完全相同,但进行了一些额外的验证以确保生产者不受隔离。
主题分区交互的协调者
生产者发起提交(或中止)后,协调者开始两阶段提交协议。
-
第一阶段,协调器将其内部状态更新为
prepare_commit
,并在事务日志中更新此状态。一旦完成,无论如何都保证事务会被提交。 -
第二阶段,将事务提交标记(transaction commit markers)作为事务的一部分写入主题分区。
这些事务标记不会暴露给应用程序,但会被配置了 read_committed
隔离级别的消费者使用,以过滤掉来已经中止事务和未完成事务的(open transaction)消息,也就是说,这些消息会被记录到消息日志中,但是不会有事务标记关联它们。
一旦标记被写入,事务协调器将事务标记为“完成”,生产者可以开始下一个事务。
实践中的事务
现在我们已经了解了事务的语义及其工作原理,我们将注意力转向编写利用事务的应用程序的实际方面。
如何选择 transactional.id
transactional.id
在抵御僵尸实例方面发挥着重要作用,但是,维护一个在生产者会话之间保持一致的标识符并正确地隔离僵尸是有点棘手的。
正确隔离僵尸实例的关键是确保对于给定的 transactional.id
,读-进程-写 周期中的输入主题和分区始终相同。如果情况并非如此,则某些消息可能会通过事务提供的防护而泄漏。
例如,在分布式流处理应用程序中,假设主题分区 tp0 最初由
transactional.id=T0
处理。如果在稍后的某个时刻,它可以映射到另一个具有transactional.id=T1
的生产者,那么 T0 和 T1 之间就不会存在隔离。因此,来自 tp0 的消息可能会被重新处理,从而违反了一次性处理保证。
实际上,要么必须将输入分区和 transactional.ids 之间的映射存储在外部存储中,要么对其进行某种静态编码。而 Kafka Streams 选择后一种方法来解决这个问题。
事务如何执行以及如何调整它们
事务生产者的性能
让我们将注意力转向事务的执行方式。
首先,事务仅导致适度的写入放大,额外的写入是由于:
-
对于每个事务,我们都有额外的 RPC 来向协调器注册分区。这些是批处理的,因此我们的 RPC 数量少于事务中的分区数量;
-
当完成一笔事务时,必须将一个事务标记写入参与该事务的每个分区。同样,事务协调器在单个 RPC 中对绑定到同一代理的所有标记进行批处理,因此我们可以在那里节省 RPC 开销。但我们无法避免对事务中的每个分区进行一次额外的写入;
-
最后,我们将状态更改写入事务日志。这包括添加到事务中的每批分区的写入、
prepare_commit
状态和complete_commit
状态;
正如我们所看到的,开销与作为事务一部分写入的消息数量无关。因此,获得更高吞吐量的关键是每个事务包含更多数量的消息。
实际上,对于以最大吞吐量生成 1KB 记录的生产者来说,每 100 毫秒提交一次消息只会导致吞吐量下降 3%。较小的消息或较短的事务提交间隔将导致更严重的性能下降。
增加事务持续时间时的主要权衡是它增加了端到端延迟。回想一下,读取事务消息的消费者不会传递属于开放事务一部分的消息。因此,提交之间的间隔越长,消耗应用程序必须等待的时间就越长,从而增加了端到端延迟。
事务中消费者的表现
事务消费者比生产者简单得多,因为它需要做的就是:
-
过滤掉属于已中止的事务消息;
-
不返回 未完成事务(open transaction)的事务消息。
因此,事务使用者在 read_committed
模式下读取事务消息时,吞吐量不会下降,主要的原因是我们在读取事务消息时保留零拷贝读取。
此外,消费者不需要任何缓冲来等待事务完成。相反,broker 不允许移动 offset 到未完成的事务记录,因此,消费者极其轻便且高效。
附录
流处理程序示例
下面我们以一个具体的示例,来介绍 Kafka 事务的机制。
下面的流处理应用中,会首先从 input-topic
消费消息,同时处理其中的消息,并将消息处理完成后,将结果发送到 output-topic
,事务中所有的操作都是原子操作,包括消费偏移量的提交。
【流处理程序代码示例】:
public class AppReadProcessWrite {
private static final String TRANSACTION_ID = "transaction-001";
private static final String CONSUMER_GROUP_ID = "consumer-group-transact-05";
public static void main(String[] args) throws ExecutionException, InterruptedException {
long init = System.currentTimeMillis();
process();
long end = System.currentTimeMillis();
System.out.println("Total time (ms) = " + (end - init));
}
private static void process() throws ExecutionException, InterruptedException {
KafkaProducer < String, String > kafkaProducer = createProducer();
// 初始化事务
kafkaProducer.initTransactions();
KafkaConsumer < String, String > kafkaConsumer = createConsumer();
kafkaConsumer.subscribe(Collections.singleton(Configuration.INPUT_TOPIC));
int recordCount = 0;
do {
Map <TopicPartition, OffsetAndMetadata> offsets = new HashMap <> ();
// 拉取消息
ConsumerRecords < String, String > records = kafkaConsumer.poll(Duration.ofSeconds(10));
recordCount = records.count();
// 开启事务
kafkaProducer.beginTransaction();
// 发送消息
boolean aborted = false;
for (ConsumerRecord <String, String> record: records) {
// 处理消息
String message = transformMessage(record.value());
// 发送消息
kafkaProducer.send(new ProducerRecord < > (Configuration.OUTPUT_TOPIC, message));
if (message.contains("55")) {
// 终止事务
kafkaProducer.abortTransaction();
aborted = true;
break;
}
// 更新消息偏移量
offsets.put(new TopicPartition(Configuration.INPUT_TOPIC, record.partition()), new OffsetAndMetadata(record.offset() + 1));
}
if (!aborted) {
// 向 consumer group coordinator 发送消息偏移量
kafkaProducer.sendOffsetsToTransaction(offsets, CONSUMER_GROUP_ID);
// 提交事务
kafkaProducer.commitTransaction();
}
} while (recordCount > 0);
}
private static String transformMessage(String message) {
return message.concat("-processed");
}
private static KafkaProducer < String, String > createProducer() {
Map < String, Object > props = new HashMap < > ();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, Configuration.BROKER_URL);
props.put(ProducerConfig.CLIENT_ID_CONFIG, "PRODUCER_OUTPUT_TOPIC");
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
props.put(ProducerConfig.TRANSACTIONAL_ID_CONFIG, TRANSACTION_ID);
props.put(ProducerConfig.ACKS_CONFIG, "all");
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, "true");
props.put("ssl.endpoint.identification.algorithm", "https");
props.put("security.protocol", "SASL_SSL");
props.put("sasl.mechanism", "PLAIN");
props.put("sasl.jaas.config", String.format("org.apache.kafka.common.security.plain.PlainLoginModule required username=\"%s\" password=\"%s\";", Configuration.APIKEY, Configuration.SECRET));
return new KafkaProducer < > (props);
}
private static KafkaConsumer < String, String > createConsumer() {
Map < String, Object > props = new HashMap < > ();
props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, Configuration.BROKER_URL);
props.put(ConsumerConfig.GROUP_ID_CONFIG, CONSUMER_GROUP_ID);
props.put(ConsumerConfig.CLIENT_ID_CONFIG, "CONSUMER_INPUT_TOPIC-02");
props.put(ConsumerConfig.ISOLATION_LEVEL_CONFIG, "read_committed");
props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false); // 自动提交需要设置为False:enable.auto.commit=false
props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 10000);
props.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
props.put("ssl.endpoint.identification.algorithm", "https");
props.put("security.protocol", "SASL_SSL");
props.put("sasl.mechanism", "PLAIN");
props.put("sasl.jaas.config", String.format("org.apache.kafka.common.security.plain.PlainLoginModule required username=\"%s\" password=\"%s\";", Configuration.APIKEY, Configuration.SECRET));
return new KafkaConsumer < > (props);
}
}
参考: