Kafka+SparkStreaming的精准一次性消费

  • 0、准备知识
  • 0.1、kafka基础架构
  • 0.2 一次性语义
  • 0.2.1 At least once
  • 0.2.2 At most once
  • 0.2.3 Exactly once
  • 1、Kafka的精准一次性
  • 1.1、生产者生产数据发送给kafka的精准一次性(幂等性)
  • 1.2、kafka broker接受数据的精准一次性
  • 1.2.1、ack
  • ack=0
  • ack=1
  • ack=-1\all
  • 1.2.2、副本同步策略(过半、全量->kafka)
  • 1.2.3、ISR( in-sync replica set)
  • 1.2.4、HW(High Watermark)、LEO(Log End Offset)
  • 1.3、 kafka的精准一次性
  • 1.4、kafka事务
  • 2、SparkStreaming消费的精准一次性
  • 2.1、数据丢失的情况
  • 2.2、数据重复的情况
  • 2.3、方法1:数据处理完后再手动提交偏移量+幂等性去重处理
  • 2.4、方法2:(数据处理+修改偏移量)组成事务
  • 2.5、kafka偏移量存放
  • 3、扩展:Storm\Spark Streaming\Flink的消费语义



本文从Kafka本身的精准一次性保障机制说起,谈一谈Kafka+SparkStreaming的精准一次性消费是如何组合起来保障的。

0、准备知识

0.1、kafka基础架构

在介绍Kafka的精准一次性前,先简单过一下kafka的架构,以下是尚硅谷的课件图:

seatunnel spark消费kafka sparkstreaming消费kafka精准一次_数据


还需要强调以下概念:

1)Broker :一台 kafka 服务器就是一个 broker。一个集群由多个 broker 组成。一个 broker可以容纳多个 topic。

2)Partition):为了实现扩展性,一个非常大的 topic 可以分布到多个 broker(即服务器)上,一个 topic 可以分为多个 partition,每个 partition 是一个有序的队列;

3)Replica:副本,为保证集群中的某个节点发生故障时,该节点上的 partition 数据不丢失,且 kafka 仍然能够继续工作,kafka 提供了副本机制。一个 topic 的每个分区都有若干个副本=>(一个 leader 和若干个 follower。)

4)leader:每个分区多个副本的“主”,生产者发送数据的对象,以及消费者消费数据的对象都是 leader。

5)follower:每个分区多个副本中的“从”,实时从 leader 中同步数据,保持和 leader 数据的同步。leader 发生故障时,某个 follower 会成为新的 follower。

6)Consumer Group (CG):消费者组,由多个 consumer 组成。消费者组内每个消费者负责消费不同分区的数据,一个分区只能由一个组内消费者消费;消费者组之间互不影响。所有的消费者都属于某个消费者组,即消费者组是逻辑上的一个订阅者。

0.2 一次性语义

0.2.1 At least once

消息至少被处理一次
可以保证 数据不丢失, 但有可能存在数据重复问题

0.2.2 At most once

消息最多被处理一次
可以保证数据不重复, 但有可能存在数据丢失问题.

0.2.3 Exactly once

消息刚好被处理一次
实际上并不是真的做到只对消息处理一次, 而是能够实现消息的可靠性和消息的幂等性, 即对于上下游系统来说不存在数据重复和数据丢失的问题
实际上是通过 At Least Once + 幂等性处理 去实现Exactly Once 语义

1、Kafka的精准一次性

1.1、生产者生产数据发送给kafka的精准一次性(幂等性)

我们的精准一次性探索从生产者发送数据到kafka开始说起,首先如若需要实现整个链路的精准一次性,生产者所生产数据的精准一次性需要保证,这里需要所产生数据的幂等性:

幂等性:就是用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。
举个最简单的例子,那就是支付,用户购买商品后支付,支付扣款成功,但是返回结果的时候网络异常,此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结果成功,用户查询余额发现多扣钱了,流水记录也变成了两条。在以前的单应用系统中,我们只需要把数据操作放入事务中即可,发生错误立即回滚,但是再响应客户端的时候也有可能出现网络中断或者异常等等。

在增删改查4个操作中,尤为注意就是增加或者修改,查询对于结果是不会有改变的,删除只会进行一次,用户多次点击产生的结果一样,修改在大多场景下结果一样,增加在重复提交的场景下会出现。

数据的幂等性往往通过加入唯一确定的流水号uuid、seqNo等保证。

1.2、kafka broker接受数据的精准一次性

kafka broker接受数据的精准一次性:即,ack通知机制,leader与follower的副本同步机制,二者的组合实现。

1.2.1、ack

为保证 producer 发送的数据,能可靠的发送到指定的 topic,topic 的每个 partition 收到producer 发送的数据后,都需要向 producer 发送 ack(acknowledgement 确认收到),如果producer 收到 ack,就会进行下一轮的发送,否则重新发送数据。

seatunnel spark消费kafka sparkstreaming消费kafka精准一次_数据_02

ack=0

producer 不等待 broker 的 ack,这一操作提供了一个最低的延迟,broker 一接收到还没有写入磁盘就已经返回,当 broker 故障时有可能丢失数据

ack=1

producer 等待 broker 的 ack,partition 的 leader 落盘成功后返回 ack,如果在 follower同步成功之前 leader 故障,那么将会丢失数据

数据丢失场景见下图:当follower还没同步成功,leader返回了ack之后挂掉。此时kafka会从其他follower中选举一个新的leader,继续发送下一条数据。而此时被选举为leader的follower之前并没有被同步到这条数据,因此存在了该数据丢失场景。

seatunnel spark消费kafka sparkstreaming消费kafka精准一次_偏移量_03

ack=-1\all

-1(all):producer 等待 broker 的 ack,partition 的 leader 和 follower 全部落盘成功后才返回 ack。但是如果在 follower 同步完成后,broker 发送 ack 之前,leader 发生故障,那么会造成数据重复

数据重复场景见下图:当数据发送后,leader与follower落盘成功,但是未来得及发送ack后leader挂掉,此时新的leader从follower中选取。但此时producer并未收到ack,因此会重发该条数据,造成数据的重复。

seatunnel spark消费kafka sparkstreaming消费kafka精准一次_偏移量_04

1.2.2、副本同步策略(过半、全量->kafka)

seatunnel spark消费kafka sparkstreaming消费kafka精准一次_数据_02


上图提出了两种follower的同步方案:等待多少follower同步完成后发送ack?

kafka目前是第二种方案:等全部的follower同步完成后,才发送ack。那么此时由引入一个问题采用第二种方案之后,设想以下情景:leader 收到数据,所有 follower 都开始同步数据,但有一个 follower,因为某种故障,迟迟不能与 leader 进行同步,那 leader 就要一直等下去,直到它完成同步,才能发送 ack。这个问题怎么解决呢?

这种情况kafka引入了ISR队列策略:

1.2.3、ISR( in-sync replica set)

Leader 维护了一个动态的 in-sync replica set (ISR),意为和 leader 保持同步的 follower 集合。当 ISR 中的 follower 完成数据的同步之后,leader 就会给 follower 发送 ack。如果 follower长时间 未 向 leader 同 步 数 据 , 则 该 follower 将 被 踢 出 ISR , 该 时 间 阈 值 由replica.lag.time.max.ms 参数设定。

Leader 发生故障之后,就会从 ISR 中选举新的 leader。follower长时间同步不了,会被剔除出ISR队列。那么Kafka leader与follower的故障处理机制如何呢,又引出了以下HW与LEO概念:

1.2.4、HW(High Watermark)、LEO(Log End Offset)

如下图所示:

LEO:指的是每个副本最大的 offset;

HW:指的是消费者能见到的最大的 offset,ISR 队列中最小的 LEO。

seatunnel spark消费kafka sparkstreaming消费kafka精准一次_偏移量_06

如果同步的时候follower挂了?
follower 发生故障后会被临时踢出 ISR,待该 follower 恢复后,follower 会读取本地磁盘记录的上次的 HW,并将 log 文件高于 HW 的部分截取掉,从 HW 开始向 leader 进行同步。
等该 follower 的 LEO 大于等于该 Partition 的 HW,即 follower 追上 leader 之后,就可以重新加入 ISR 了。

如果leader挂了?
leader 发生故障之后,会从 ISR 中选出一个新的 leader。之后,为保证多个副本之间的数据一致性,其余的 follower 会先将各自的 log 文件高于 HW 的部分截掉,然后从新的 leader同步数据。

1.3、 kafka的精准一次性

将服务器的 ACK设置为-1,可以保证 Producer 到 Server 之间不会丢失数据,即 At Least Once 语义。相对的,将服务器 ACK 级别设置为 0,可以保证生产者每条消息只会被发送一次,即 At Most Once 语义。

At Least Once 可以保证数据不丢失,但是不能保证数据不重复;相对的,At Least Once可以保证数据不重复,但是不能保证数据不丢失。但是,对于一些非常重要的信息,比如说交易数据,下游数据消费者要求数据既不重复也不丢失,即 Exactly Once 语义。在 0.11 版本以前的 Kafka,对此是无能为力的,只能保证数据不丢失,再在下游消费者对数据做全局去重。对于多个下游应用的情况,每个都需要单独做全局去重,这就对性能造成了很大影响。0.11 版本的 Kafka,引入了一项重大特性:幂等性。所谓的幂等性就是指 Producer 不论向 Server 发送多少次重复数据,Server 端都只会持久化一条。幂等性结合 At Least Once 语义,就构成了 Kafka 的 Exactly Once 语义。即:

At Least Once(ACK=-1) + 幂等性 = Exactly Once

要启用幂等性,只需要将 Producer 的参数中 enable.idompotence 设置为 true 即可。Kafka的幂等性实现其实就是将原来下游需要做的去重放在了数据上游。开启幂等性的 Producer 在初始化的时候会被分配一个 PID,发往同一 Partition 的消息会附带 Sequence Number。而Broker 端会对<PID, Partition, SeqNumber>做缓存,当具有相同主键的消息提交时,Broker 只会持久化一条。

但是 PID 重启就会变化,同时不同的 Partition 也具有不同主键,所以幂等性无法保证跨分区跨会话的 Exactly Once。那么跨分区跨会话的精准一次性如何实现呢,可以引入kafka事务概念。

1.4、kafka事务

Kafka 从 0.11 版本开始引入了事务支持。事务可以保证 Kafka 在 Exactly Once 语义的基础上,生产和消费可以跨分区和会话,要么全部成功,要么全部失败。

Producer事务:

为了实现跨分区跨会话的事务,需要引入一个全局唯一的 Transaction ID,并将 Producer获得的PID 和Transaction ID 绑定。这样当Producer 重启后就可以通过正在进行的 Transaction ID 获得原来的 PID。

为了管理 Transaction,Kafka 引入了一个新的组件 Transaction Coordinator。Producer 就是通过和 Transaction Coordinator 交互获得 Transaction ID 对应的任务状态。Transaction Coordinator 还负责将事务所有写入 Kafka 的一个内部 Topic,这样即使整个服务重启,由于事务状态得到保存,进行中的事务状态可以得到恢复,从而继续进行。

Consumer事务:

上述事务机制主要是从 Producer 方面考虑,对于 Consumer 而言,事务的保证就会相对较弱,尤其时无法保证 Commit 的信息被精确消费。这是由于 Consumer 可以通过 offset 访问任意信息,而且不同的 Segment File 生命周期不同,同一事务的消息可能会出现重启后被删除的情况。

而由于性能等原因,在当前笔者项目使用中kafka本身不会开启事务,事务由下游消费者开启:例如本文提到的Spark streaming

2、SparkStreaming消费的精准一次性

2.1、数据丢失的情况

比如实时计算任务进行计算,到数据结果存盘之前,进程崩溃,假设在进程崩溃前 kafka调整了偏移量,那么 kafka 就会认为数据已经被处理过,即使进程重启,kafka 也会从新的偏移量开始,所以之前没有保存的数据就被丢失掉了。

即先修改偏移量,但数据没处理:

seatunnel spark消费kafka sparkstreaming消费kafka精准一次_数据_07

2.2、数据重复的情况

如果数据计算结果已经存盘了,在 kafka 调整偏移量之前,进程崩溃,那么 kafka 会认为数据没有被消费,进程重启,会重新从旧的偏移量开始,那么数据就会被 2 次消费,又会被存盘,数据就被存了 2 遍,造成数据重复。

即先处理数据,但未修改偏移量:

seatunnel spark消费kafka sparkstreaming消费kafka精准一次_数据_08

如果同时解决了数据丢失和数据重复的问题,那么就实现了精确一次消费的语义了。

目前 Kafka 默认每 5 秒钟做一次自动提交偏移量,这样并不能保证精准一次消费。

enable.auto.commit 的默认值是 true;就是默认采用自动提交的机制。

auto.commit.interval.ms 的默认值是 5000,单位是毫秒。

2.3、方法1:数据处理完后再手动提交偏移量+幂等性去重处理

我们知道如果能够同时解决数据丢失和数据重复问题,就等于做到了精确一次消费。那就各个击破。

首先解决数据丢失问题,办法就是要等数据保存成功后再提交偏移量,所以就必须手工来控制偏移量的提交时机。

但是如果数据保存了,没等偏移量提交进程挂了,数据会被重复消费。怎么办?那就要把数据的保存做成幂等性保存。即同一批数据反复保存多次,数据不会翻倍,保存一次和保存一百次的效果是一样的。如果能做到这个,就达到了幂等性保存,就不用担心数据会重复了。

seatunnel spark消费kafka sparkstreaming消费kafka精准一次_kafka_09


➢ 难点

话虽如此,在实际的开发中手动提交偏移量其实不难,难的是幂等性的保存,有的时候并不一定能保证,这个需要看使用的数据库,如果数据库本身不支持幂等性操作,那只能优先保证的数据不丢失,数据重复难以避免,即只保证了至少一次消费的语义。一般有主键的数据库都支持幂等性操作 upsert。

➢ 使用场景
处理数据较多,或者数据保存在不支持事务的数据库上

2.4、方法2:(数据处理+修改偏移量)组成事务

出现丢失或者重复的问题,核心就是偏移量的提交与数据的保存,不是原子性的。如果能做成要么数据保存和偏移量都成功,要么两个失败,那么就不会出现丢失或者重复了。这样的话可以把存数据和修改偏移量放到一个事务里。这样就做到前面的成功,如果后面做失败了,就回滚前面那么就达成了原子性,这种情况先存数据还是先修改偏移量没影响。

seatunnel spark消费kafka sparkstreaming消费kafka精准一次_偏移量_10


➢ 好处

事务方式能够保证精准一次性消费

➢ 问题与限制
◼ 数据必须都要放在某一个关系型数据库中,无法使用其他功能强大的 nosql 数据

◼ 事务本身性能不好
◼ 如果保存的数据量较大一个数据库节点不够,多个节点的话,还要考虑分布式事务的问题。分布式事务会带来管理的复杂性,一般企业不选择使用,有的企业会把分布式事务变成本地事务,例如把 Executor 上的数据通过 rdd.collect 算子提取到Driver 端,由 Driver 端统一写入数据库,这样会将分布式事务变成本地事务的单线程操作,降低了写入的吞吐量。

➢ 使用场景
数据足够少(通常经过聚合后的数据量都比较小,明细数据一般数据量都比较大),并且支持事务的数据库

2.5、kafka偏移量存放

本身 kafka 0.9 版本以后 consumer 的偏移量是保存在 kafka 的__consumer_offsets主题中。(之前是在zookeeper中放的)但是如果用这种方式管理偏移量,有一个限制就是在提交偏移量时,数据流的元素结构不能发生转变 , 即提交偏移量时数据流必须是InputDStream[ConsumerRecord[String, String]] 这种结构。

但是在实际计算中,数据难免发生转变,或聚合,或关联,一旦发生转变,就无法在利用以下语句进行偏移量的提交:
xxDstream.asInstanceOf[CanCommitOffsets].commitAsync(offsetRanges)

因为 offset 的存储于 HasOffsetRanges,只有 kafkaRDD 继承了他,所以假如我们对KafkaRDD 进行了转化之后,其它 RDD 没有继承 HasOffsetRanges,所以就无法再获取offset 了。

所以实际生产中通常会利用 ZooKeeper,Redis,Mysql 等工具手动对偏移量进行保存,流程如下

seatunnel spark消费kafka sparkstreaming消费kafka精准一次_数据_11


1、SparkStreaming从Redis中读取偏移量起始点

2、如果第一步读到偏移量,则通过Redis中存储的偏移量起始点去加载Kafka数据

getKafkaStream(topic, ssc,kafkaOffsetMap,groupId)

否则: Redis 中没有保存偏移量,Kafka 默认从最新读取。

3、得到本批次中处理数据的分区对应的偏移量起始及结束位置(注意:这里我们从 Kafka 中读取数据之后,直接就获取了偏移量的位置,因为 KafkaRDD 可以转换为HasOffsetRanges,会自动记录位置)

4、SparkStreaming进行过滤清洗计算

5、SparkStreaming进行数据存储

6、待完成存储落盘后,将偏移量结束点提交Redis

3、扩展:Storm\Spark Streaming\Flink的消费语义

seatunnel spark消费kafka sparkstreaming消费kafka精准一次_偏移量_12


Flink的精准一次有两种情况,一个是Flink内部的精准一次,一个是端对端的精准一次