Kafka原理篇
Kafka工作原理
消息写入算法
消息发送者将消息发送给 broker,并形成最终的可供消费者消费的 log,是一个比较复杂的过程。
- producer向broker集群提交连接请求,其所连接上的任意broker都会向其发送broker controller的通信URL,即broker controller主机配置文件中的listeners地址。
- 当producer指定了要生产消息的topic后,其会向broker controller发送请求,请求当前topic中所有partition的leader列表地址。
- broker controller在接收到请求后,会从zk中查找到指定topic的所有partition的leader,并返回给producer。
- producer在接收到leader列表地址后,根据消息路由策略找到当前要发送消息所要发送的partition leader,然后将消息发送给该leader。
- leader将消息写入本地log,并通知ISR中的followers。
- ISR中的followers从leader中同步消息后向leader发送ACK。
- leader收到所有ISR中的followers的ACK后,增加HW,表示消费者已经可以消费到该位置了。
- 若leader在等待的followers的ACK超时了,发现还有follower没有发送ACK,则会将该follower从ISR中清除,然后增加HW。
HW机制
高水位的作用
- HW,HighWatermark,高水位,表示Consumer可以消费到的最高partition偏移量。其作用:
- 定义消息可⻅性,即用来标识分区下的哪些消息是可以被消费者消费的。
- 帮助 Kafka 完成副本同步,保证leader和follower之间的数据一致性。
- 假设下图是某个分区 Leader 副本的高水位图。首先,请你注意图中的“已提交消息”和“未提交消息”。在分区高水位以下的消息被认为是已提交消息,反之就是未提交消息。消费者只能消费已提交消息,即图中位移小于 8 的所有消息。
- 位移值等于高水位的消息也属于未提交消息。也就是说,高水位上的消息是不能被消费者消费的。
- LEO,日志末端位移,即 Log End Offset。它表示副本写入下一条消息的位移值。
数字 15 所在的方框是虚线,这就说明,这个副本当前只有 15 条消息,位移值是从 0 到 14,下一条新消息的位移是 15。显然,介于高水位和 LEO 之间的消息就属于未提交消息。这也从侧面告诉了我们一个重要的事实,那就是同一个副本对象,其高水位值不会大于 LEO 值。
- Kafka 所有副本都有对应的高水位和 LEO 值,而不仅仅是 Leader 副本。Kafka 使用 Leader 副本的高水位来定义所在分区的高水位。换句话说,分区的高水位就是其 Leader 副本的高水位。
- LEO:Log End Offset 指的是每个副本最大的 offset;日志最后消息的偏移量。消息是被写入到Kafka的日志文件中的,这是当前最后一个写入的消息在Partition中的偏移量。
- HW:指的是消费者能⻅到的最大的 offset,ISR 队列中最小的 LEO。
- 该机制要求,对于 partition leader 新写入的消息,consumer 不能立刻消费。leader 会等待该消息被所有 ISR 中的 partition follower 同步后才会更新 HW,此时该消息才能被 consumer 消费。
HW 并不是 Kafka 特有的概念,HW 通常被用在流式处理领域(比如 Apache Flink、Apache Spark 等),以表征元素或事件在时间层面上的进度。在 Kafka 中,对于 Leader 新写入的消息,Leader 会等待该消息被 ISR 中所有的 Replicas 同步后再更新 HW,之后该消息才能被提交从而被 Consumer 消费。 这种机制有一个好处,确保 HW 及其之前的消息(Committed 状态)都是已备份的,即便 Leader 所在的 Broker 因故障下线,那么 Committed 状态的消息仍然可以从新选举出的 Leader 中获取。
高水位更新机制
- 远程副本(Remote副本)的作用:帮助 Leader 副本确定其高水位,也就是分区高水位。
- Leader 副本的HW更新原则:取当前leader副本的LEO和所有remote副本的LEO的最小值。
- Follower副本的HW更新原则:取leader副本发送的HW和自身的LEO中的最小值。
- 下图演示了生产者写入数据到Kafka的leader副本,然后同步到follower副本的流程:
- 副本同步机制流程:
- 生产者写入消息到leader副本。
- leader副本LEO值更新。
- follower副本尝试拉取消息,发现有消息可以拉取,更新自身LEO。
- follower副本继续尝试拉取消息,这时会更新remote副本LEO,同时会更新leader副本的HW。
- 完成4步骤后,leader副本会将已更新过的HW发送给所有follower副本。
- follower副本接收leader副本HW,更新自身的HW。
图解HW备份过程
- Step 1:leader 和 follower 副本处于初始化值,follower 副本发送 fetch 请求,由于 leader 副本没有数据,因此不会进行同步操作;
- Step 2:生产者发送了消息 m1 到分区 leader 副本,写入该条消息后 leader 更新 LEO = 1;
- Step 3:follower 发送 fetch 请求,携带当前最新的 offset = 0,leader 处理 fetch 请求时,更新 remote LEO = 0,对比 LEO 值最小为 0,所以 HW = 0,leader 副本响应消息数据及 leader HW = 0 给 follower,follower 写入消息后,更新 LEO 值,同时对比 leader HW 值,取最小的作为新的 HW 值,此时 follower HW = 0,这也意味着,follower HW 是不会超过 leader HW 值的。
- Step 4:follower 发送第二轮 fetch 请求,携带当前最新的 offset = 1,leader 处理 fetch 请求时,更新 remote LEO = 1,对比 LEO 值最小为 1,所以 HW = 1,此时 leader 没有新的消息数据,所以直接返回 leader HW = 1 给 follower,follower 对比当前最新的 LEO 值 与 leader HW 值,取最小的作为新的 HW 值,此时 follower HW = 1。
HW截断机制
HW机制的缺陷
数据丢失
- 前面也说过,leader 中的 HW 值是在 follower 下一轮 fetch RPC 请求中完成更新的,如上图所示,有副本 A 和 B,其中 B 为 leader 副本,A 为 follower 副本,在 A 进行第二段 fetch 请求,并接收到响应之后,此时 B 已经将 HW 更新为 2,如果这是 A 还没处理完响应就崩溃了,即 follower 没有及时更新 HW 值,A 重启时,会自动将 LEO 值调整到之前的 HW 值,即会进行日志截断,接着会向 B 发送 fetch 请求,但很不幸的是此时 B 也发生宕机了,Kafka 会将 A 选举为新的分区 Leader。当 B 重启后,会从 向 A 发送 fetch 请求,收到 fetch 响应后,拿到 HW 值,并更新本地 HW 值,此时 HW 被调整为 1(之前是 2),这时 B 会做日志截断,因此,offsets = 1 的消息被永久地删除了。
- 可能你会问,follower 副本为什么要进行日志截断?
- 这是由于消息会先记录到 leader,follower 再从 leader 中拉取消息进行同步,这就导致 leader LEO 会比 follower 的要大(follower 之间的 offset 也不尽相同,虽然最终会一致,但过程中会有差异),假设此时出现 leader 切换,有可能选举了一个 LEO 较小的 follower 成为新的 leader,这时该副本的 LEO 就会成为新的标准,这就会导致 follower LEO 值有可能会比 leader LEO 值要大的情况,因此 follower 在进行同步之前,需要从 leader 获取 LastOffset 的值(该值后面会有解释),如果 LastOffset 小于 当前 LEO,则需要进行日志截断,然后再从 leader 拉取数据实现同步。
- 可能你还会问,日志截断会不会造成数据丢失?
- 前面也说过,HW 值以上的消息是没有“已提交”或“已备份”的,因此消息也是对消费者不可见,即这些消息不对用户作承诺,也即是说从 HW 值截断日志,并不会导致数据丢失(承诺用户范围内)。
数据不一致/离散
- 以上情况,需要满足以下其中一个条件才会发生
- 宕机之前,B 已不在 ISR 列表中,unclean.leader.election.enable=true,即允许非 ISR 中副本成为 leader;
- B 消息写入到 pagecache,但尚未 flush 到磁盘。
- 分区有两个副本,其中 A 为 Leader 副本,B 为 follower 副本,A 已经写入两条消息,且 HW 更新到 2,B 只写了 1 条消息,HW 为 1,此时 A 和 B 同时宕机,B 先重启,B 成为了 leader 副本,这时生产者发送了一条消息,保存到 B 中,由于此时分区只有 B,B 在写入消息时把 HW 更新到 2,就在这时候 A 重新启动,发现 leader HW 为 2,跟自己的 HW 一样,因此没有执行日志截断,这就造成了 A 的 offset=1 的日志与 B 的 offset=1 的日志不一样的现象。
HW截断机制
- follower 故障:follower 发生故障后会被临时踢出 ISR,待该 follower 恢复后,follower 会读取本地磁盘记录的上次的 HW,并将 log 文件高于 HW 的部分截取掉,从 HW 开始向 leader 进行同步。 等该 follower 的 LEO 大于等于该 Partition 的 HW,即 follower 追上 leader 之后,就可以重 新加入 ISR 了。
- leader 故障:该机制在leader出现宕机情况然后又恢复时,可以防 止 partition leader 与 follower 间出现数据不一致。当原 Leader 宕机后又恢复时,将其 LEO 回退到其宕机时的 HW,然后再与新的 Leader 进行数据同步,这种机制称为 HW 截断机制。
消息发送的可靠性机制
- 生产者向 kafka 发送消息时,可以选择需要的可靠性级别。通过 request.required.acks 参数的值进行设置。
0值
异步发送。生产者向 kafka 发送消息而不需要 kafka 反馈成功 ack。该方式效率最高,但可靠性最低。其可能会存在消息丢失的情况。
- 在传输途中丢失:由网络原因,生产者发送的消息kafka并未收到,但由于生产者无需等待 kafka 的 ack,所以会发生消息丢失的情况。
- 在broker中丢失:消息到达 broker 后并不是直接写到 partition 中的,而是首先写入到 broker 的 buffer 中。当 broker 的 buffer 满足将消息写入到 partition 时(容量到,或时间到,或数量到),在 buffer 正要写入到 partition 但还未写入时(buffer还未腾出空间时) 新的消息又来了,此时的消息可能会丢失。
- 顺序与生产顺序可能不一致:由于网络原因。
1值
同步发送。生产者发送消息给 kafka,broker 的 partition leader 在收到消息后马上发送成功 ack(无需等 ISR 中的 Follower 同步),生产者收到后知道消息发送成功,然后会再发送消息。如果一直未收到 kafka 的 ack,则生产者会认为消息发送失败,会重发消息。
该方式能否使生产者知道消息发送成功?不能
该方式能否使生产者知道消息发送失败?能
- 当生产者在超时时限内没有收到 kafka 的 ack,则可以确定,该消息发送失败。但在有效时间内收到了 ack,也不能确定消息发送成功。例如,当 leader 收到消息后马上向生产者发送了 ack,在 ISR 中的 follower 还未做同步时,leader 宕机。原来老 leader 收到的那条消息, 对于新的 leader 来说,根本就不存在。所以,原来的那条消息丢失了(即原来的那条消息发送并未成功)。
-1值
同步发送。生产者发送消息给 kafka,kafka 收到消息后要等到 ISR 列表中的所有副本都同步消息完成后,才向生产者发送成功 ack。如果一直未收到 kafka 的 ack,则生产者会认为消息发送失败,会重发消息。
- 该模式几乎不会出现消息丢失,但可能会出现消息重复接收的情况。
消费过程解析
生产者将消息发送到 topic 中,消费者即可对其进行消费,其消费过程如下:
- 消费者订阅指定的 topic 的消息;
- broker controller 会为消费者分配 partition,并将该 partition 的当前 offset 发送给消费者;
- 当 broker 接收到生产者发送的消息时,broker 会将消息推送给消费者;
- 消费者消费完该条消息后,消费者会向 broker 发送一个该消息已被消费的反馈;
- 当 broker 接收消费者的反馈后,broker 会更新 partition 中的 offset(从这两步分析可知,消息的消费与 offset 的更新是一个同步过程);
- 以上过程一直重复,直到消费者停止请求消息;
- 消费者可以重置 offset,从而可以灵活消费存储在 broker 上的消息。
消息投递语义
- Kafka支持三种消息投递语义
- At most once:至多一次,消息可能会丢,但不会重复。
- At least once:至少一次,消息肯定不会丢失,但可能重复。
- Exactly once:有且只有一次,消息不丢失不重复,且只消费一次。
At most once
- enable.auto.commit为ture
- 设置 auto.commit.interval.ms为一个较小的时间间隔。
- 不要调用commitSync()
At least once
- 方法一:
- enable.auto.commit为false
- commitSync(),增加消息偏移;
- 方法二:
- enable.auto.commit为ture
- auto.commit.interval.ms为一个较大的时间间隔。
- 调用commitSync(),增加消息偏移;
Exactly once
- 思路:如果要实现这种方式,必须自己控制消息的offset,自己记录一下当前的offset,对消息的处理和 offset的移动必须保持在同一个事务中,例如在同一个事务中,把消息处理的结果存到mysql数据库同时更新此时的消息的偏移。
- 实现:
- 设置enable.auto.commit为false
- 保存ConsumerRecord中的offset到数据库
无消息丢失参考配置
Producer端:
- 不要使用producer.send(msg),而要使用producer.send(msg, callback)。记住,一定要使用带有回调通知的send方法。
- 设置acks = all。acks是Producer的一个参数,代表了你对“已提交”消息的定义。如果设置成all,则表明所有副本Broker都要接收到消息,该消息才算是“已提交”。这是最高等级的“已提交”定义。
request.required.acks
此配置是表明当一次produce请求被认为完成时的确认值。特别是,多少个其他brokers必须已经提交了数据到他们的log并且向他们的leader确认了这些信息。典型的值包括:
0: 表示producer从来不等待来自broker的确认信息(和0.7一样的行为)。这个选择提供了最小的时延但同时风险最大(因为当server宕机时,数据将会丢失)。
1:表示获得leader replica已经接收了数据的确认信息。这个选择时延较小同时确保了server确认接收成功。
-1:producer会获得所有同步replicas都收到数据的确认。同时时延最大,然而,这种方式并没有完全消除丢失消息的风险,因为同步replicas的数量可能是1.如果你想确保某些replicas接收到数据,那么你应该在topic-level设置中选项min.insync.replicas设置一下。 - 设置retries为一个较大的值。这里的retries同样是Producer的参数,对应前面提到的Producer自动重试。当出现网络的瞬时抖动时,消息发送可能会失败,此时配置了retries > 0的Producer能够自动重试消息发送,避免消息丢失。
Broker端:
- 设置unclean.leader.election.enable = false。这是Broker端的参数,它控制的是哪些Broker有资格竞选分区的Leader。如果一个Broker落后原先的Leader太多,那么它一旦成为新的Leader,必然会造成消息的丢失。故一般都要将该参数设置成false,即不允许这种情况的发生。
- 设置replication.factor >= 3。这也是Broker端的参数。其实这里想表述的是,最好将消息多保存几份,毕竟目前防止消息丢失的主要机制就是冗余。
- 设置min.insync.replicas > 1。这依然是Broker端参数,控制的是消息至少要被写入到多少个副本才算是“已提交”。设置成大于1可以提升消息持久性。在实际环境中千万不要使用默认值1。
- 确保replication.factor > min.insync.replicas。如果两者相等,那么只要有一个副本挂机,整个分区就无法正常工作了。我们不仅要改善消息的持久性,防止数据丢失,还要在不降低可用性的基础上完成。推荐设置成replication.factor = min.insync.replicas + 1。
ps:该属性规定了最小的ISR数。当producer设置request.required.acks为-1时,min.insync.replicas指定replicas的最小数目(必须确认每一个repica的写数据都是成功的),如果这个数目没有达到,producer会产生异常。
Consumer端
- 确保消息消费完成再提交。Consumer端有个参数enable.auto.commit,最好把它设置成false,并采用手动提交位移的方式。就像前面说的,这对于单Consumer多线程处理的场景而言是至关重要的。
重复消费问题
同一个consumer重复消费
- consumer 在 auto.commit.interval.ms 会话时间内消费完一批消息后会自动提交 offset 给 partition 以备后续的消息。但当项目中的 consumer 消费能力比较低时,其取出的一批消息在会话时间内没有消费完毕,此时 consumer 会向 broker 提交一个异常。但 broker 并不认为该 consumer 宕机,因为当前 consumer 向 broker 提交了信息。
- 对于 consumer 来说,由于本次消费过程在时限内没有完成,即没有成功,所以该 consumer 会再从该 partition 中拉取消息。而对于 partition 来说,由于前面没有提交 offset,所以这次拉取的消息与上次的是相同的。该 consumer 又重新消费之前的那一批消息,然后就又出现了消费超时,所以会造成死循环,一直消费相同的消息。
不同的consumer重复消费
- 当 consumer 消费了某批消息后自动提交了 offset,但此时由于网络等原因在 session.timeout.ms 时间范围内 broker 没有接收到其发送的 offset,这个 consumer 被认为宕机,然后发生 rebalance。此时,那个曾被消费过的 partition 又被分配给了其它 consumer,而这个 partition 中曾被消费过的 offset 没有被记录,故这部分消息会被重复消费。
解决方案
- 提高消费能力
- 增加自动提交超时时限 auto.commit.interval.ms 的值,延长 offset 提交时间。
- 设置 enable.auto.commit 为 false,将 kafka 自动提交 offset 改为手动提交。
- 引入消费者单独去重机制
位移重放
- kafka重放即重设消费者组位移
为什么
- 基于⽇志结构(log-based)的消息引擎,消费者在消费消息时,仅仅是从磁盘⽂件上读取数据⽽已,是只读的操作,因此消费者不会删除消息数据。同时,由于位移数据是由消费者控制的,因此它能够很容易地修改位移的值,实现重复消费历史数据的功能。
auto.offset.reset
- 官方文档解释:
auto.offset.reset: What to do when there is no initial offset in Kafka or if the current offset does not exist any more on the server (e.g. because that data has been deleted):
earliest: automatically reset the offset to the earliest offset
latest: automatically reset the offset to the latest offset
none: throw exception to the consumer if no previous offset is found for the consumer’s group
anything else: throw exception to the consumer.
- earliest:当各分区下有已提交的offset时,从提交的offset开始消费;⽆提交的offset时,从头开始消费。
- latest:当各分区下有已提交的offset时,从提交的offset开始消费;⽆提交的offset时,消费新产⽣的该分区下的数据。
- none:topic各分区都存在已提交的offset时,从offset后开始消费;只要有⼀个分区不存在已提交的offset,则抛出异常。
- 默认建议⽤earliest。设置该参数后 kafka出错后重启,找到未消费的offset可以继续消费。
⽽latest 这个设置容易丢失消息,假如kafka出现问题,还有数据往topic中写,这个时候重启kafka,这个设置会从最新的offset开始消费,中间出问题的哪些就不管了。
更新offset
- Topic的作⽤域:
--all-topics :为consumer group下所有topic的所有分区调整位移
--topic t1 --topic t2 :为指定的若⼲个topic的所有分区调整位移
--topic t1:0,1,2 :为指定的topic分区调整位移
- 重置策略:
--to-earliest :把位移调整到分区当前最⼩位移
--to-latest :把位移调整到分区当前最新位移
--to-current :把位移调整到分区当前位移
--to-offset <offset> : 把位移调整到指定位移处
--shift-by N : 把位移调整到当前位移 + N处,注意N可以是负数,表示向前移动
--to-datetime <datetime> :把位移调整到⼤于给定时间的最早位移处,datetime格式是 yyyy-MM-ddTHH:mm:ss.xxx,⽐如2017-08-04T00:00:00.000
--by-duration <duration> :把位移调整到距离当前时间指定间隔的位移处,duration格式是 PnDTnHnMnS,⽐如PT0H5M0S
--from-file <file> :从CSV⽂件中读取调整策略
- 确定执⾏⽅案:
什么参数都不加:只是打印出位移调整⽅案,不具体执⾏
--execute :执⾏真正的位移调整
--export :把位移调整⽅案按照CSV格式打印,⽅便⽤户成csv⽂件,供后续直接使⽤
重置策略
- 位移维度
- 时间维度
1、Earliest 策略表示将位移调整到主题当前最早位移处。这个最早位移不⼀定就是 0,因为在⽣产环境中,很久远的消息会被 Kafka ⾃动删除,所以当前最早位移很可能是⼀个⼤于 0 的值。如果你想要重新消费主题的所有消息,那么可以使⽤ Earliest 策略。
2、Latest 策略表示把位移重设成最新末端位移。如果你总共向某个主题发送了 15 条消息,那么最新末端位移就是 15。如果你想跳过所有历史消息,打算从最新的消息处开始消费的话,可以使⽤ Latest 策略。
3、Current 策略表示将位移调整成消费者当前提交的最新位移。有时候你可能会碰到这样的场景:你修改了消费者程序代码,并重启了消费者,结果发现代码有问题,你需要回滚之前的代码变更,同时也要把位移重设到消费者重启时的位置,那么,Current 策略就可以帮你实现这个功能。
4、Specified-Offset 策略则是⽐较通⽤的策略,表示消费者把位移值调整到你指定的位移处。这个策略的典型使⽤场景是,消费者程序在处理某条错误消息时,你可以⼿动地“跳过”此消息的处理。在实际使⽤过程中,可能会出现 corrupted 消息⽆法被消费的情形,此时消费者程序会抛出异常,⽆法继续⼯作。⼀旦碰到这个问题,你就可以尝试使⽤ Specified-Offset 策略来规避。
5、如果说 Specified-Offset 策略要求你指定位移的绝对数值的话,那么 Shift-By-N 策略指定的就是位移的相对数值,即你给出要跳过的⼀段消息的距离即可。这⾥的“跳”是双向的,你既可以向前“跳”,也可以向后“跳”。⽐如,你想把位移重设成当前位移的前 100 条位移处,此时你需要指定 N 为 -100
6、DateTime 允许你指定⼀个时间,然后将位移重置到该时间之后的最早位移处。常⻅的使⽤场景是,你想重新消费昨天的数据,那么你可以使⽤该策略重设位移到昨天 0 点。
7、Duration 策略则是指给定相对的时间间隔,然后将位移调整到距离当前给定时间间隔的位移处,具体格式是 PnDTnHnMnS。如果你熟悉 Java 8 引⼊的 Duration 类的话,你应该不会对这个格式感到陌⽣。它就是⼀个符合 ISO-8601 规范的 Duration 格式,以字⺟ P 开头,后⾯由 4 部分组成,即 D、H、M 和 S,分别表示天、⼩时、分钟和秒。举个例⼦,如果你想将位移调回到 15 分钟前,那么你就可以指定PT0H15M0S。
注意事项
- 如果kafka 开启了认证、授权的操作,需要配置赋予了相应权限的⽤户。
- 需要制定对应的Consumer Group 的id,重置的是Consumer Group 的位移。
- consumer group状态必须是inactive的,即不能是处于正在⼯作中的状态。
- 要关闭kakfa的⾃动位移提交功能
API
- KafkaConsumer 的 seek ⽅法,或者是它的变种⽅法 seekToBeginning 和 seekToEnd。
Earliest实现
Properties properties = new Properties();
// 要重设位移的 Kafka 主题
String topic = "test";
try (final KafkaConsumer<String, String> consumer = new KafkaConsumer<>(properties)) {
consumer.subscribe(Collections.singleton(topic));
consumer.poll(0);
consumer.seekToBeginning(consumer.partitionsFor(topic).stream().map(partitionInfo ->
new TopicPartition(topic, partitionInfo.partition())).collect(Collectors.toList()));
}
- 你要创建的消费者程序,要禁⽌⾃动提交位移。
- 组 ID 要设置成你要重设的消费者组的组 ID。
- 调⽤ seekToBeginning ⽅法时,需要⼀次性构造主题的所有分区对象。
- 最重要的是,⼀定要调⽤带⻓整型的 poll ⽅法,⽽不要调⽤ consumer.poll(Duration.ofSecond(0))。
Latest实现
consumer.seekToEnd(consumer.partitionsFor(topic).stream().map(partitionInfo ->
new TopicPartition(topic, partitionInfo.partition())).collect(Collectors.toList()));
Current实现
- 实现 Current 策略的⽅法很简单,需要借助 KafkaConsumer 的 committed ⽅法来获取当前提交的最新位移。
consumer.partitionsFor(topic).stream().map(partitionInfo ->
new TopicPartition(topic, partitionInfo.partition()))
.forEach(topicPartition -> {
long offset = consumer.committed(topicPartition).offset();
consumer.seek(topicPartition, offset);
});
Specified-Offset实现
long targetOffset = 1234L;
for (PartitionInfo info : consumer.partitionsFor(topic)) {
TopicPartition topicPartition = new TopicPartition(topic, info.partition());
consumer.seek(topicPartition, targetOffset);
}
Shift-By-N实现
for (PartitionInfo info : consumer.partitionsFor(topic)) {
TopicPartition topicPartition = new TopicPartition(topic, info.partition());
long offset = consumer.committed(topicPartition).offset() + 123L;
consumer.seek(topicPartition, offset);
}
datetime实现
- 如果要实现 DateTime 策略,需要借助另⼀个⽅法:KafkaConsumer. offsetsForTimes ⽅法。假设要重设位移到 2020 年 11 ⽉ 19 ⽇上午 10 点 16 分,那么具体代码如下:
long ts = LocalDateTime.of(2020, 11, 19, 10, 16)
.toInstant(ZoneOffset.ofHours(8)).toEpochMilli();
Map<TopicPartition, Long> timeToSearch = consumer.partitionsFor(topic).stream().map(info ->
new TopicPartition(topic, info.partition())).collect(Collectors.toMap(Function.identity(), tp -> ts));
for (Map.Entry<TopicPartition, OffsetAndTimestamp> entry : consumer.offsetsForTimes(timeToSearch).entrySet()) {
consumer.seek(entry.getKey(), entry.getValue().offset());
}
Duration 实现
- 位移回调30分钟前
Map<TopicPartition, Long> timeToSearch = consumer.partitionsFor(topic).stream().map(info ->
new TopicPartition(topic, info.partition())).collect(Collectors.toMap(Function.identity(), tp ->
System.currentTimeMillis() - 30 * 1000 * 60L));
for (Map.Entry<TopicPartition, OffsetAndTimestamp> entry : consumer.offsetsForTimes(timeToSearch).entrySet()) {
consumer.seek(entry.getKey(), entry.getValue().offset());
}
- 总之,使⽤ Java API 的⽅式来实现重设策略的主要⼊⼝⽅法,就是 seek ⽅法。
kafka-consumer-groups
- 位移重设还有另⼀个重要的途径:通过 kafka-consumer-groups 脚本。需要注意的是,这个功能是在 Kafka 0.11 版本中新引⼊的。这就是说,如果你使⽤的 Kafka 是 0.11 版本之前的,那么你只能使⽤API 的⽅式来重设位移。
# Earliest实现
bin/kafka-consumer-groups.sh --bootstrap-server kafka-host:port --group test-group --reset-offsets --all-topics --to-earliest --execute
# Latest实现
bin/kafka-consumer-groups.sh --bootstrap-server kafka-host:port --group test-group --reset-offsets --all-topics --to-latest --execute
# Current实现
bin/kafka-consumer-groups.sh --bootstrap-server kafka-host:port --group test-group --reset-offsets --all-topics --to-current --execute
# Specified-Offset实现
bin/kafka-consumer-groups.sh --bootstrap-server kafka-host:port --group test-group --reset-offsets --all-topics --to-offset <offset> --execute
# Shift-By-N 实现
bin/kafka-consumer-groups.sh --bootstrap-server kafka-host:port --group test-group --reset-offsets --all-topics --to-shift-by <offset_N> --execute
# datetime实现
bin/kafka-consumer-groups.sh --bootstrap-server kafka-host:port --group test-group --reset-offsets --all-topics --to-datetime 2019-06-20T20:00:00.000 --execute
# Duration 实现
bin/kafka-consumer-groups.sh --bootstrap-server kafka-host:port --group test-group --reset-offsets --all-topics --by-duration PT0H30M0S --execute
消息压缩
- 压缩就是⽤时间去换空间的经典 trade-off 思想,具体来说就是⽤ CPU 时间去换磁盘空间或⽹络I/O 传输量,希望以较⼩的 CPU 开销带来更少的磁盘占⽤或更少的⽹络 I/O 传输。
消息格式
- 有两⼤类消息格式,分别称之为V1版本和V2版本。V2版本是Kafka 0.11.0.0中正式引⼊的。
- V2版本都⽐V1版本节省磁盘空间,当启⽤压缩时,这种节省空间的效果更加明显。
什么时候压缩
- ⽣产者端和Broker端:⽣产者程序中配置 compression.type 参数即表示启⽤指定类型的压缩算法。
// 开启Gzip压缩
properties.put("compression.type", "gzip");
在⽣产者端启⽤压缩是很⾃然的想法,那为什么说在Broker端也可能进⾏压缩呢?
- 两种例外情况就可能让Broker重新压缩消息
- Broker端指定了和Producer端不同的压缩算法
- broker端发⽣了消息格式转换
什么时候解压缩
- Producer 端压缩、Broker 端保持、Consumer 端解压缩。
各种压缩算法对比
- 在吞吐量⽅⾯:LZ4 > Snappy > zstd / GZIP
- 在压缩⽐⽅⾯:zstd > LZ4 > GZIP > Snappy
文件存储机制
- 消息在磁盘上都是以⽇志的形式保存的,我们这⾥说的⽇志是存放在(config/server.properties:log.dirs=/tmp/kafka-logs) ⽬录中的消息⽇志,即partition与segment。
partition
# 创建 topic 为 one,3个分区、3个备份
bin/kafka-topics.sh --create --bootstrap-server 192.168.254.128:9092 --replication-factor 3 --partitions 3 --topic one
查看
# /brokers/ids⽬录
[zk: 192.168.254.120:2181(CONNECTED) 0] ls /brokers
[ids, topics, seqid]
[zk: 192.168.254.120:2181(CONNECTED) 1] ls /brokers/ids
[0, 1, 2]
# 每个id的数据内容为当前主机的信息
[zk: 192.168.254.120:2181(CONNECTED) 2] get /brokers/ids/0
{"listener_security_protocol_map":{"PLAINTEXT":"PLAINTEXT"},"endpoints":["PLAINTEXT://192.168.254.128:9092"],"jmx_port":-1,"host":"192.168.254.128","timestamp":"1605622157512","port":9092,"version":4}
# ...
# /brokers/topics
[zk: 192.168.254.120:2181(CONNECTED) 3] ls /brokers/topics
[test, __consumer_offsets, one]
[zk: 192.168.254.120:2181(CONNECTED) 4] ls /brokers/topics/one/partitions
[0, 1, 2]
- /brokers/topics/one/partitions中存放的是one主题下所包含的partition。这⾥的0、1、2,在~/kafka-logs⽬录中即为one-0,one-1,one-2。
[zk: 192.168.254.120:2181(CONNECTED) 5] get /brokers/topics/one
{"version":1,"partitions":{"2":[2,1,0],"1":[0,2,1],"0":[1,0,2]}}
[zk: 192.168.254.120:2181(CONNECTED) 6] get /brokers/topics/one/partitions/0/state
{"controller_epoch":117,"leader":1,"version":1,"leader_epoch":0,"isr":[1,0,2]}
存储结构
segment
- log⽂件⼤⼩可以通过log.segment.bytes参数设定,默认值是1073741824。
00000000000000000000.index
00000000000000000000.log
00000000000000000133.index
00000000000000000133.log
00000000000000000251.index
00000000000000000251.log
00000000000000000378.index
00000000000000000378.log
- index⽂件的的格式:每个索引项占用 8 个字节,分为两个部分:
- relativeOffset:相对偏移量,表示消息相对于baseOffset的偏移量,占⽤4个字节,当前索引⽂件的⽂件名即为baseOffset的值。
- position:物理地址,也就是消息在⽇志分段⽂件中的物理位置,占⽤4个字节。
- 示例:查找偏移量为23的信息
- 以上是最简单的⼀种情况。如果要查找偏移量为268的消息,那么应该怎么办呢?⾸先是定位到baseOffset为251的⽇志分段,然后计算相对偏移量relavtiveOffset=268-251=17,之后再在对应的索引⽂件中知道不⼤于17的索引项,最后根据索引项中的postition定位到具体的⽇志分段⽂件位置开始查找消息。
- 跳跃表的结构:如何查找baseOffset为251的⽇志分段呢?这⾥不是顺序查找,⽽是⽤了跳跃表的结构。
Kafka的每个⽇志对象中使⽤了ConcurrentSkipListMap来保存各个⽇志分段,每个⽇志分段的baseOffset为key,这样可以根据指定偏移量俩快速定位到消息所在的⽇志分段。
为什么在index⽂件中这些编号不是连续的呢?
- 这是因为index⽂件中并没有为数据⽂件中的每条消息都建⽴索引,⽽是采⽤了稀疏存储的⽅式,每隔⼀定字节的数据建⽴⼀条索引。 这样避免了索引⽂件占⽤过多的空间,从⽽可以将索引⽂件保留在内存中。
message
- 这个就需要涉及到消息的物理结构了,消息都具有固定的物理结构,包括:offset(8 Bytes)、消息体的大小(4 Bytes)、crc32(4 Bytes)、magic(1 Byte)、attributes(1 Byte)、key length(4 Bytes)、key(K Bytes)、payload(N Bytes)等等字段,可以确定一条消息的大小,即读取到哪里截止。
高效文件存储设计特点
- Kafka把topic中⼀个parition⼤⽂件分成多个⼩⽂件段,通过多个⼩⽂件段,就容易定期清除或删除已经消费完⽂件,减少磁盘占⽤。
- 通过索引信息可以快速定位message
- 通过index元数据全部映射到memory,可以避免segment file的IO磁盘操作。
- 通过索引⽂件稀疏存储,可以⼤幅降低index⽂件元数据占⽤空间⼤⼩。
- 顺序写:操作系统每次从磁盘读写数据的时候,需要先寻址,也就是先要找到数据在磁盘上的物理位置,然后再进⾏数据读写,如果是机械硬盘,寻址就需要较⻓的时间。kafka的设计中,数据其实是存储在磁盘上⾯,⼀般来说,会把数据存储在内存上⾯性能才会好。但是kafka⽤的是顺序写,追加数据是追加到末尾,磁盘顺序写的性能极⾼,在磁盘个数⼀定,转数达到⼀定的情况下,基本和内存速度⼀致。随机写的话是在⽂件的某个位置修改数据,性能会较低。
- 零拷⻉
日志清理策略
清理策略
- kafka log的清理策略有两种:delete、compact,默认是delete,这个对应了kafka中每个topic对于 record的管理模式
bin/kafka-topics.sh --create \
--bootstrap-server localhost:9092 \
--replication-factor 1 \
--partitions 1 \
--topic streams-wordcount-output \
--config cleanup.policy=compact
Created topic "streams-wordcount-output"
- delete:一般是使用按照时间保留的策略,当不活跃的segment的时间戳是大于设置的时间的时候,当前segment就会被删除
- compact:日志不会被删除,会被去重清理,这种模式要求每个record都必须有key,然后kafka会按照一定的时机清理segment中的key,对于同一个key只保留最新的那个key。同样的,compact也只针对不活跃的segment。
cleanup.policy: delete
cleanup.policy: compact
delete 相关配置
- 假如对某个topic(假设为user_topic)设置了 cleanup.policy: delete。那么当前topic使用的log删除策略就是 delete,这个策略会周期性的检查partion中的不活跃的segment,根据配置采用两种方式删除一些旧的segment。
retention.bytes: 总的segment的大小限制,达到这个限制后会删除旧的segment,默认值为-1,就是不会删除
retention.ms: segment的最后写入record的时间-当前时间 > retention.ms 的segment会被删除,默认是168h, 7天
- 一些其他的辅助性配置
log.retention.check.interval.ms: 每隔多久检查一次是否有可以删除的log,默认是300s,5分钟 这个是broker级别的设置
file.delete.delay.ms: 在彻底删除文件前保留的时间,默认为1分钟 这个是broker级别的设置
日志清理compact策略
使用场景
- 日志清理的compact策略,对于那种需要留存一份全量数据的需求比较有用,什么意思呢,比如,我用flink计算了所有用户的粉丝数,而且每5分钟更新一次,结果都存储到kafka当中。这个时候kafka相当于是一个数据总线,任何需要用户粉丝数的业务部门都可以从kafka中拿到这个数据。这个时候如果数据的保存使用delete策略,为了保存所有用户的粉丝数,只能设置不删除,也就是
retention.bytes: -1
retention.ms: Long.MAX #这个值需要自己去设置实际的数值值
- 这样的话,数据会无限膨胀,而且,很多数据是无意义的,因为业务方从kafka中消费数据的时候,实际上只是想知道用户的当前粉丝数是多少,不关注一个月前这个用户有多少粉丝数,但是这些数据都在kafka中存储,会造成无意义的消费。
- kafka提供了一种叫做compact的清理策略,这个策略可以很好的帮助我们应对这种情况。
- kafka的compact 策略要求每个record都要有key,kafka是根据key来进行去重合并的。每个key至少保留一个最新的值。
compact的工作模式
- 对于每一个kafka partition的日志,以segment为单位,都会被分为两部分,已清理和未清理的部分。同时,未清理的那部分又分为可以清理的和不可清理的。对于可以清理的segment大致是下面的一个清理思路。
- 同时对于清理过后的segment如果太小,kafka也会有一定的策略去合并这些segemnt,防止segment碎片化。
我们通过配置cleanup.policy: compact
来开启compact的日志清理策略。配套的配置还有:- min.cleanable.dirty.ratio: 可以进行compact的脏数据的比例,dirtyRatio = dirtyBytes / (cleanBytes + dirtyBytes) 其中dirtyBytes表示可清理部分的日志大小,cleanBytes表示已清理部分的日志大小。这个配置也是为了提升清理的性价比设置的,因为清理数据需要对磁盘进行读写,开销并不小,如果你的数据只有很小的重复比例,实际上是没有清理的必要的。这个值默认是0.5 也就是脏了的数据达到了总数据的50%才会清理,一般情况下我如果开启了compact策略,都会将这个值设置为0.1,感觉这样对于想要消费当前topic的业务方更加友好。
- min.compaction.lag.ms: 这个设置了在一条消息在被produer发送到kafka当中之后,多久时间以内不会被compact,为了满足有些想要获取一定时间内的历史快照的业务,默认是0,就是不会根据消息投递的时间来决定消息是否应该被compacted
tombstone 消息
- 在compact下,还有一类比较特殊的消息,只有key,value值为null的消息,这一类消息如果合并了实际上也是没有意义的,因为没有值,所以kafka在compact的时候会删除value为null的消息,但是并不是在第一次去重的时候立刻删除,而是允许存储的更久一些。有一个特殊的配置来处理。
- delete.retention.ms: 这个配置就是专门针对tombstone类型的消息进行设置的。默认为24小时,也就是这个tombstone在当次compact完成后并不会被清理,在下次compact的时候,他的最后修改时间+delete.retention.ms>当前时间,才会被删掉。
简单总结compact的配置
- kafka启用delete的清理策略的时候需要注意配置
cleanup.policy: compact
segment.bytes: 每个segment的大小,达到这个大小会产生新的segment, 默认是1G
segment.ms: 配置每隔n ms产生一个新的segment,默认是168h,也就是7天
retention.bytes: 总的segment的大小限制,达到这个限制后会删除旧的segment,默认值为-1,就是不会删除
retention.ms: segment的最后写入record的时间-当前时间 > retention.ms 的segment会被删除,默认是168h, 7天
min.cleanable.dirty.ratio: 脏数据可以容忍的比例,如果你的机器性能可以,而且数据量较大的话,建议这个值设置更小一些,对consumer更友好
min.compaction.lag.ms: 看业务有需要的话可以设置