kafka学习之事务
前言
为了实现EOS(exactly once semantics,精确一次处理语义)karka从0.11.0.0版本开始引入了幂等性和事务两个特性来支撑。
场景
- 最简单的需求是producer发的多条消息组成一个事务这些消息需要对consumer同时可见或者同时不可见 。
- producer可能会给多个topic,多个partition发消息,这些消息也需要能放在一个事务里面,这就形成了一个典型的分布式事务。
- kafka的应用场景经常是应用先消费一个topic,然后做处理再发到另一个topic,这个consume-transform-produce过程需要放到一个事务里面,比如在消息处理或者发送的过程中如果失败了,消费位点也不能提交。
- producer或者producer所在的应用可能会挂掉,新的producer启动以后需要知道怎么处理之前未完成的事务 。
- 流式处理的拓扑可能会比较深,如果下游只有等上游消息事务提交以后才能读到,可能会导致rt非常长吞吐量也随之下降很多,所以需要实现read committed和read uncommitted两种事务隔离级别。
幂等性
幂等这个概念,我们再接口那里就了解过。它简单得说就是对接口得多次调用所产生的结果和调用一次是一致的。再kafka中,生产者再进行重试的时候有可能会重复写入相同的消息(比如,第一次写消息给broker, broker写入日志之后,发送ack失败。生产者误以为broker没收到改消息,所以根据retry 进行重发,这就导致了消息的重复),而使用kafka的幂等性功能之后就可以避免这种情况。
开启幂等性功能的方式很简单,只需要显示地将生产者客户端参数enable.idempotence设置为true就可以(默认位false。
不过如果要确保幂等性功能正常,还需要确保生产者客户端的retries、acks、max.in.filght.request.per.connection这几个参数不被配置错。如果用户没用自定义过上面参数,那么幂等性功能可以完全被保证。
-
如果用户自定了retries参数,那么这个参数的值必须大于0,否则会报ConfigException,如果用户没定义这个参数,那么改参数的值就是Integer.MAX_VALUE;(这里可以理解为,一旦事务中出现异常,要确保有能力重试保证消息再次发送出去)
-
如果用户自定了max.in.flight.request.per.connection, 要保证不能大于5.默认值是5。如果大于5则会同样会报错。
max.in.flight.request.per.connection 表示生产者向指定broker发送的消息中,还未到达的个数。类似于tcp协议中滑动窗口里面未到达窗口的概念。它可以用来衡量该客户端一定时间段内到指定broker的网络通信情况。
-
如果用户自定了acks参数,那么就需要保证这个参数为-1,如果不为-1,同样会报错。(也就是说必须保证消息一定要在broker端落地,保证消息只要到了broker端就不能丢失)
为了实现生产者的幂等性,kafka为此引入了**producer id(PID)和序列号(sequence number)**两个概念。 每个新的生产者实例再初始化的时候都会被分配一个PID, 这个PID对用户而言完全透明的。 对于每个PID, 消息发送到的每一个分区都有对应的序列号,这些序列号从0开始单调递增。 生产者每发送一条消息就会将<PID,分区>对应的序列号的值加1;
broker里面可能采用Map<Map<PiD,分区>,seq>来维护每一个客户端和每一个分区消息的序列号;
broker端会再内存中为每一对<PID,分区>维护一个序列号。对于收到的每一条消息,只有当它的序列号的值(SN_new)比broker端中维护的对应序列号的值(SN_old)大1(即SN_new = SN_old+1)时,broker才会接收它。如果SN_new < SN_old+1, 那么说明消息被重复写入, broker可以直接将其丢弃。如果SN_new > SN_old+1, 那么说明中间有数据尚未写入,出现了乱序,暗示可能有消息丢失,对应的生产者会抛出OutOfOrderSequenceException, 这个异常是一个严重的异常,后续的诸如 **send()、beginTransaction()、commitTransaction()**等方法的调用都会抛出IIIegalStateException的异常。
引入序列号来实现幂等也只是针对每一对<PID,分区>而言的,也就是说,kafka幂等只保证单个生产者会话(session)中单分区的幂等。
事务性
幂等性并不能跨多个分区运行,而事务可以弥补这个缺陷。事务可以保证对多个分区写入操作的原子性。操作的原子性是指多个操作要么全部成功,要么全部失败,不存在不一致的情况。
对流式应用而言,一个典型的应用模式为“consumer-transform-produce", 这种模式下消费和生产并存: 应用程序从某个主题中消费消息,然后经过一系列操作写入另一个主题,消费者可能再提交消费位移的过程中出现问题而导致重复消费,也有可能生产者重复生产消息。 kafka中的事务可以使应用程序将消费消息,生产消息、提交消费位移当作原子操作来处理,同时成功或者失败,即使该生产或消费跨越多个分区。
up有个业务场景是:1、从一个主题中获取人员位置变化,计算位置变化是否满足条件。2、如果满足条件发送到另一个主题。供消费线程去消费。就是这种consumer-transform-produce的模式。
为了实现事务,应用程序必须提供唯一的transactionalId,这个transactionalId通过客户端参数显示设置。
事务要求生产者开启幂等特性,因此通过将transactional.id 参数设置为非空从而开启事务特性的同时需要将enable.idempotence设置为true。
transactionalId与PID一一对应,两者之间所不同的是transactionalId由用户显示设置,而PID是由kafka内部分配的。 为了保证新的生产者启动后,具有相同transactionalId的旧生产者能够立即失效,每个生产者通过transactionalId获取PID的同时,还会获取一个单调递增的producer epoch(对应下面要讲述的kafkaProducer.initTransactions()方法)。 如果使用同一个transactionalId开启两个生产者,那么前一个生产者会报提示有一个新的生产者利用同一个事务id申请了producer epoch。提示老的生产者它再broker里面已经过期了。
从生产者的角度分析,通过事务,Kafka 可以保证跨生产者会话的消息幂等发送,以及跨生产者会话的事务恢复。前者表示具有相同 transactionalId 的新生产者实例被创建且工作的时候,旧的且拥有相同transactionalId的生产者实例将不再工作。后者指当某个生产者实例宕机后,新的生产者实例可以保证任何未完成的旧事务要么被提交(Commit),要么被中止(Abort),如此可以使新的生产者实例从一个正常的状态开始工作。
而从消费者的角度分析,事务能保证的语义相对偏弱。出于以下原因,Kafka 并不能保证已提交的事务中的所有消息都能够被消费:
- 对采用日志压缩策略的主题而言,事务中的某些消息有可能被清理(相同key的消息,后写入的消息会覆盖前面写入的消息)。
- 事务中消息可能分布在同一个分区的多个日志分段(LogSegment)中,当老的日志分段被删除时,对应的消息可能会丢失。
- 消费者可以通过seek()方法访问任意offset的消息,从而可能遗漏事务中的部分消息。
- 消费者在消费时可能没有分配到事务内的所有分区,如此它也就不能读取事务中的所有消息。
KafkaProducer提供了5个与事务相关的方法,详细如下:
- **initTransactions()**方法用来初始化事务,这个方法能够执行的前提是配置了transactionalId,如果没有则会报出IllegalStateException:
- **beginTransaction()**方法用来开启事务;
- **sendOffsetsToTransaction()**方法为消费者提供在事务内的位移提交的操作;
- **commitTransaction()**方法用来提交事务;
- **abortTransaction()**方法用来中止事务,类似于事务回滚。
生产段的事务也要消费端的适配,在消费端存在一个isolation.level的配置。它存在两个配置分别是read_uncommitted和read_committed分别代表消费端可以看到未提交的消息(包括已经提交)和只能看到已经提交的消息。一个事务的闭环存在两个操作,分别是上面方法中的commitTransaction()和abortTransaction(),那么这两个动作是通过什么实现的呢?
日志文件中除了普通的消息,还有一种消息专门用来标志一个事务的结束,它就是控制消息