kafka事务
为什么写这篇文章
我在极客时间、B站上都没能搜索到系统讲解kafka事务的文章,倒是有一篇外网的文章需**讲得比较详细,于是花了两天时间看完这边文章,这里结合自己的理解聊一聊kafka对于事务的支持。
ps:这篇文章的最新更新时间是2017年,可能和最新版本kafka事务有出入,我先基于这篇文章聊完kafka对于事务的支持,而后再去查阅kafka最新的官方文档、源码进行交叉验证。
kafka事务的定义
事务最直观的解释,是进行多次操作,要么都成功,要么都失败(回滚)。比如转账,对于银行系统来说,从A账户扣款往B账户打款必须要么都成功,要么都失败,否则钱就对不上。
kafka对外提供的操作其实不多,而支持事务的操作,只有写数据和读数据。即在一个事务流程中,你可以多次读/多次写数据,kafka能够保证这些操作要么都成功,要么都失败。
事务实践
以下代码实现了同时对topic test的读写操作,要么都成功,要么都失败。已事先往topic中写入了一些消息
talk is easy, show me the code
private static int offset = 25;
private static String groupId = "xiaohaha";
public static void main(String[] args) {
KafkaProducer producer = createProducer();
KafkaConsumer<String, String> consumer = createConsumer();
try {
consumer.subscribe(Arrays.asList("test1"));
producer.initTransactions();
producer.beginTransaction();
//send msg
for (int i = 0; i < 5; i++) {
producer.send(new ProducerRecord("test1", "" + (i + offset)));
}
//poll msg
ConsumerRecords records = consumer.poll(Duration.ofSeconds(1));
printRecords(records);
//commit offset by producer
Map<TopicPartition, OffsetAndMetadata> offsets = new HashMap<>();
offsets.put(new TopicPartition("test1", 0), new OffsetAndMetadata(offset + records.count()));
producer.sendOffsetsToTransaction(offsets, new ConsumerGroupMetadata(groupId));
producer.flush();
producer.commitTransaction();
} catch (Exception e) {
e.printStackTrace();
producer.abortTransaction();
} finally {
consumer.close();
producer.close();
}
}
private static void printRecords(ConsumerRecords consumerRecords) {
if (!consumerRecords.isEmpty()) {
Iterator<ConsumerRecord<String, String>> iterator = consumerRecords.iterator();
List
while (iterator.hasNext()) {
ConsumerRecord<String, String> next = iterator.next();
data.add(next.value());
}
System.out.println(JSON.toJSONString(data));
}
}
private static KafkaProducer createProducer() {
Map<String, Object> config = new HashMap<>();
config.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
config.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
config.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
config.put("transactional.id", "first-transactional");
return new KafkaProducer<>(config, new StringSerializer(), new StringSerializer());
}
private static KafkaConsumer<String, String> createConsumer() {
Map<String, Object> consumerCfg = new HashMap<>();
consumerCfg.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");
consumerCfg.put(ConsumerConfig.ISOLATION_LEVEL_CONFIG, "read_committed");
consumerCfg.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 5);
consumerCfg.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
consumerCfg.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
consumerCfg.put(ConsumerConfig.GROUP_ID_CONFIG, groupId);
return new KafkaConsumer(consumerCfg, new StringDeserializer(), new StringDeserializer());
}
设计思路
分布式事务的实现很多使用了2pc的设计思路,这种思想里存在一个类似中介的角色,中介确定各方准备工作都准确无误时,才会敲定本次交易。
kafka对于读写消息的设计也采用了2pc的思想。它增加了一个叫transaction coordinator的角色,每个有leader副本的broker上都有一个这样的模块,这个角色的作用是对操作进行确认。
kafka读写消息,实际上还是客户端直接和broker交互,只是多了一些操作。
对于读操作,提交消费位移操作,由之前comsumer.commitSync提交改为由producer的sendOffsetsToTransaction提交(事务是由producer开启的),而sendOffsetsToTransaction是向transaction coordinator发送消息,也就是由transaction coordinator确认提交某个组某个分区消费的位移操作;
同样的,对于写操作,kafka的producer是直接和broker交互,不同之处在于事务状态下,写入broker中的消息对read_committed的消费者并不直接可见,而是需要等到事务提交后,由transaction coordinator向topic追加写入mask message后,确定该事物已经提交/回滚,消费者才能看到这条消息。
这事kafka实现事务的基本思路,但要完整地支持事务,需要考虑kafka的broker故障恢复、生产者消费者客户端假死等各种意外情况,kafka为了支持这些情况,引入了很多额外的设计,这些设计和kafka的架构设计息息相关,但不是事务的主要内容,本文只做简单描述。
数据流

为什么使用这个方案
2pc的设计思路,对于每个执行者提供的操作,都需要支持回滚,这样当知道兄弟执行者执行任务失败时,自己需要将已经成功执行的操作恢复到之前的状态,保持兄弟之间共进退。但针对不同的业务场景和业务操作,要具体事情具体分析,它们的回滚操作并不同。举个例子,你在淘宝买东西,收到东西后觉得不满意,选择无理由退货,你把收到的商品寄回去,商家退回你的购物款,这是一种回滚;但你在拼多多买东西,觉得不满意,可以选择仅退款,商家退回你的购物款,这是另一种回滚操作(拼多多打钱~)。
先聊kafka对于写操作的回滚,由于kafka保存消息是日志型的,删除操作成本较高,因此kafka对写操作的回滚,选择了再次确认这个方案。即生产者和非事务写操作一样往broker中写消息,但消息的可见性,需要由中间人二次确认才行。这有点类似银行账户间的大额转账,并非实时到账,而是由银行审计后次日到账。这个方案的实现成本不高,只需要对消息头做一些改动,标记该消息为事务产生的消息,再次提交的实现,则由coordinator给topic的同分区发送一条mask message。同时对读客户端进行改造,当读到由事务发送的消息时,会根据mask message中事务的状态判断是否给app展示这些消息。
如果不直接写消息,而是由coordinator暂时保存写消息,事务提交时再由coordinator往对应的broker写消息呢?这个方案对消息多了一层转发,会占用更多的带宽,同时对消息的实效性也有影响,毕竟事务提交时再写入消息之前,无法保证该topic有其它生产中写入消息。另一方面,coordinator为了保证消息不丢,势必需要将待转发的消息临时写入日志,这会大大增加日志量,也会提高事务结束后清理日志的复杂度。另外如果由coordinator在事务提交后写入消息,对于read_uncommitted级别的消费者,会造成消息丢失,无法读到已写入但未提交的消息。
当然,由coordinator在事务提交后写入消息较当前的实现方案,也有一些好处。最直接的,消息结构不需要变更,客户端也无需改造。所以你们看,任何方案都有优缺点,重在权衡。
再聊聊kafka对于读操作的回滚,kafka针对读操作,可以自动提交,也可以手动提交。手动提交的情况下,如果不提交,下次依旧从本次的offset开始提交。kafka对于回滚的方案,是把提交offset的操作从consumer改成了coordinator。当提交事务时,由coordinator向相关的broker提交消费位移,如果事务回滚,则不提交。从而实现回滚的语义。从这个层面上看,kafka对于读消息的回滚,是很轻量的,但从使用者角度看,需要调用producer的sendOffsetsToTransaction手动提交,这个方法的构造函数略微复杂,需要熟悉ConsumerRecord的数据结构。
相关数据
kafka为了支持事务,除了改造消息结构和消费者客户端,在broker上增加了coordinator模块,还需要存储一些数据,通过观察这些数据的定义和结构、跟踪这些数据的生命周期,对于kafka事务的实现,将有更加透彻的理解。
| 名称 | 含义/作用 |
|---|---|
| transactionId(简称txId) | 事务id,用于标记唯一的事务,由生产者提供,上报给transaction coordinator。注意,同一个事务id,最终一定会发送给同一个coordinator |
| pid | producerId,生产者id,由transaction coordinator使用producer提供的txId生成,和txId一一对应 |
| epoch | pid的版本号,由coordinator生成,对于同一个txId,递增,用于筛除僵尸producer发送的消息 |
| txStatus | 事务状态,分三大类,开始、准备提交/回滚、完成、回滚结束,由coordinator维护,当coordinator收到客户端发送commit消息时,事务状态由开始变更为准备提交,此后禁止提交读写操作,由coordinator完成后续的事务确认、清理工作,并将事务标记为完成 |
| txTime | 事务时间,包含事务最常执行时间、事务最新更新时间、事务开始时间等,coordinator用于强制结束事务 |
| mask message | 事务状态确认消息,由coordinator向对应的topic分区发送,客户端根据这个消息,决定有事务状态的消息是否展示给应用。客户端是否展示消息并没有根据事务状态,因此已提交成功的分区的消息可以被看到,哪怕事务时准备提交阶段 |
| topic_partitions | 生产者主题和分区,由生产者提供给coordinator,当生产者提交事务时,coordinator会给相关主题的分区发送mask message |
| topic_partition_commits | 消费者主题分区和偏移量,由生产者通过sendOffsetsToTransaction提交给coordinator,当生产者提交事务时,coordinator会给相关的broker发送消费偏移量 |
从数据存储介质看,coordinator维护了两类数据,一类是缓存数据,一类是写入transaction log中的落盘数据。缓存数据以key-value为主,包含txId-pid(epoch)的映射关系,tx log中维护了事务的状态,用于故障恢复。当coordinator发生崩溃试,故障恢复后,会根据tx log中事务的状态,如果是开始,将回滚事务,如果事务已经走到了准备提交的状态,将把事务执行完毕。因此,coordinator用于提交后确认事务的数据,都需要存储到log中,比如topic_partitions和topic_partition_commits。
和mysql对比
kafka事务更多是浅尝辄止。对于事务读数据的隔离级别,kafka只支持read committed 和read uncommitted两种,同时一个生产者客户端,只能开启一个事务,避免并发问题。(但多个生产者开启事务并向同一个topic中写数据,会发生什么样的情况呢?)
kfaka并没有为事务提供单独的client,而是和producer绑定在了一起,并且为消费者的提交操作,提供了一个构造复杂的方法。
kafaka这么做的目的,一方面是在实际生产中,利用kafka事务特性的场景非常少,即便有,大多会倾向选择更加成熟的mysql。kafka虽说能充当数据库,但消息队列才是它的主要属性。另一方面,kafka并不想改变它目前相对来说比较简单的架构设计(虽说有50万行源码),如果像mysql那样提供包含事务语义的上下文,自动绑定上下文中的生产和消费操作,kafka需要对目前的架构做较大的改动。
附:原创不易,转载请附原文链接

浙公网安备 33010602011771号