本篇结构:
在分布式系统中,构成系统的任何节点都是被定义为可以彼此独立失败的。比如在 Kafka 中,broker 可能会 crash,在 producer 推送数据至 topic 的过程中也可能会遇到网络问题。根据 producer 处理此类故障所采取的提交策略类型,有如下三种:
at-least-once:如果 producer 收到来自 Kafka broker 的确认(ack)或者 acks = all,则表示该消息已经写入到 Kafka。但如果 producer ack 超时或收到错误,则可能会重试发送消息,客户端会认为该消息未写入 Kafka。如果 broker 在发送 Ack 之前失败,但在消息成功写入 Kafka 之后,此重试将导致该消息被写入两次,因此消息会被不止一次地传递给最终 consumer,这种策略可能导致重复的工作和不正确的结果。
at-most-once:如果在 ack 超时或返回错误时 producer 不重试,则该消息可能最终不会写入 Kafka,因此不会传递给 consumer。在大多数情况下,这样做是为了避免重复的可能性,业务上必须接收数据传递可能的丢失。
exactly-once:即使 producer 重试发送消息,消息也会保证最多一次地传递给最终consumer。该语义是最理想的,但也难以实现,因为它需要消息系统本身与生产和消费消息的应用程序进行协作。
理想状况,网络良好,代码没有错误,则 Kafka 可以保证 exactly-once,但生产环境错综复杂,故障几乎无法避免,主要有:
对生产者:
对消费者:
Kafka 在0.11.0.0之前的版本中只支持 At Least Once 和 At Most Once 语义,尚不支持 Exactly Once 语义。
Kafka 0.11.0.0版本引入了幂等语义。 一个幂等性的操作就是一种被执行多次造成的影响和只执行一次造成的影响一样的操作。
如果出现导致生产者重试的错误,同样的消息,仍由同样的生产者发送多次,将只被写到 Kafka broker 的日志中一次。
对于单个分区,幂等生产者不会因为生产者或 broker 故障而产生多条重复消息。
想要开启这个特性,获得每个分区内的精确一次语义,也就是说没有重复,没有丢失,并且有序的语义,只需要 producer 配置 enable.idempotence=true。
这个特性是怎么实现的呢?每个新的 Producer 在初始化的时候会被分配一个唯一的 PID,该PID对用户完全透明而不会暴露给用户。在底层,它和 TCP 的工作原理有点像,每一批发送到 Kafka 的消息都将包含 PID 和一个从 0 开始单调递增序列号。
Broker 将使用这个序列号来删除重复的发送。和只能在瞬态内存中的连接中保证不重复的 TCP 不同,这个序列号被持久化到副本日志,所以,即使分区的 leader 挂了,其他的 broker 接管了leader,新 leader 仍可以判断重新发送的是否重复了。这种机制的开销非常低:每批消息只有几个额外的字段。这种特性比非幂等的生产者只增加了可忽略的性能开销。
上述幂等设计只能保证单个 Producer 对于同一个
Kafka 现在通过新的事务 API 支持跨分区原子写入。这将允许一个生产者发送一批到不同分区的消息,这些消息要么全部对任何一个消费者可见,要么对任何一个消费者都不可见。这个特性也允许在一个事务中处理消费数据和提交消费偏移量,从而实现端到端的精确一次语义。
为了实现这种效果,应用程序必须提供一个稳定的(重启后不变)唯一的 ID,也即Transaction ID 。 Transactin ID 与 PID 可能一一对应。区别在于 Transaction ID 由用户提供,将生产者的 transactional.id 配置项设置为某个唯一ID。而 PID 是内部的实现对用户透明。
另外,为了保证新的 Producer 启动后,旧的具有相同 Transaction ID 的 Producer 失效,每次 Producer 通过 Transaction ID 拿到 PID 的同时,还会获取一个单调递增的 epoch。由于旧的 Producer 的 epoch 比新 Producer 的 epoch 小,Kafka 可以很容易识别出该 Producer 是老的 Producer 并拒绝其请求。
下面是的代码片段演示了事务 API 的使用:
Producer<String, String> producer = new KafkaProducer<String, String>(props);
// 初始化事务,包括结束该Transaction ID对应的未完成的事务(如果有)
// 保证新的事务在一个正确的状态下启动
producer.initTransactions();
// 开始事务
producer.beginTransaction();
// 消费数据
ConsumerRecords<String, String> records = consumer.poll(100);
try{
// 发送数据
producer.send(new ProducerRecord<String, String>("Topic", "Key", "Value"));
// 发送消费数据的Offset,将上述数据消费与数据发送纳入同一个Transaction内
producer.sendOffsetsToTransaction(offsets, "group1");
// 数据发送及Offset发送均成功的情况下,提交事务
producer.commitTransaction();
} catch (ProducerFencedException | OutOfOrderSequenceException | AuthorizationException e) {
// 数据发送或者Offset发送出现异常时,终止事务
producer.abortTransaction();
} finally {
// 关闭Producer和Consumer
producer.close();
consumer.close();
}
需要注意的是,上述的事务保证是从 Producer 的角度去考虑的。从 Consumer 的角度来看,该保证会相对弱一些。尤其是不能保证所有被某事务 Commit 过的所有消息都被一起消费,因为:
Kafka设计解析(八)- Kafka事务机制与Exactly Once语义实现原理
Apache kafka是如何实现消息的精确一次(Exactly-once-semantics)语义的?