1 kafka 是啥
Kafka 是一款开源的消息引擎系统,用来实现解耦的异步式数据传递。即系统 A 发消息给到 消息引擎系统,系统 B 通过消息引擎系统读取 A 发送的消息,在大数据场景下,能达到削峰填谷的效果。
2 Kafka 术语
Kafka 中的分区机制指的是将每个主题(Topic)划分成多个分区(Partition),每个分区是一组有序的消息日志。生产者生产的每条消息只会被发送到一个分区中,也就是说如果向一个双分区的主题发送一条消息,这条消息要么在分区 0 中,要么在分区 1 中。Kafka 的分区编号是从 0 开始的,如果 Topic 有 100 个分区,那么它们的分区号就是从 0 到 99。每个分区下可以配置若干个副本,其中只能有 1 个领导者副本和 N-1 个追随者副本。
Kafka 的三层消息架构:
1)主题层,每个主题可以配置 M 个分区,而每个分区又可以配置 N 个副本。
2)分区层,每个分区的 N 个副本中只能有一个充当领导者角色,对外提供服务;其他 N-1 个副本是追随者副本,只是提供数据冗余之用。
3)消息层,分区中包含若干条消息,每条消息的位移从 0 开始,依次递增。最后,客户端程序只能与分区的领导者副本进行交互。
Broker 如何持久化数据?
Kafka 使用消息日志(Log)来保存数据,一个日志就是磁盘上一个只能追加写(Append-only)消息的物理文件。因为只能追加写入,故避免了缓慢的随机 I/O 操作,改为性能较好的顺序 I/O 写操作,这也是实现 Kafka 高吞吐量特性的一个重要手段。如果不停地向一个日志写入消息,最终也会耗尽所有的磁盘空间,因此 Kafka 必然要定期地删除消息以回收磁盘。怎么删除呢?简单来说就是通过日志段(Log Segment)机制。在 Kafka 底层,一个日志又进一步细分成多个日志段,消息被追加写到当前最新的日志段中,当写满了一个日志段后,Kafka 会自动切分出一个新的日志段,并将老的日志段封存起来。Kafka 在后台还有定时任务会定期地检查老的日志段是否能够被删除,从而实现回收磁盘空间的目的。
3 生产者
3.1 消息发送
- Producer创建时,会创建一个Sender线程并设置为守护线程;
- 生产消息时,内部是异步流程。生产的消息先经过拦截器->序列化器->分区器,然后将消息缓存在缓冲区(该缓冲区也是在Producer创建时创建);
- 批次发送的条件为:缓冲区数据大小达到 batch.size 或者 linger.ms 达到上限,哪个先达到就算哪个;
- 批次发送后,发往指定分区,然后落盘到broker;如果生产者配置了 retrires 参数大于 0 并且失败原因允许重试,那么客户端内部会对该消息进行重试;
- 落盘到broker成功,返回生产元数据给生产者;
- 元数据返回有两种方式:一种是通过阻塞直接返回,另一种是通过回调返回。
3.2 重试机制
retries
默认值为 0,当设置为大于零的值,客户端会重新发送任何发送失败的消息。注意,此重试与客户端收到错误时重新发送消息是没有区别的。在配置 max.in.flight.requests.per.connection 不等于1的情况下,允许重试可能会改变消息的顺序,因为如果两个批次的消息被发送到同一个分区,第一批消息发送失败但第二批成功,而第一批消息会被重新发送,则第二批消息会先被写入。注意此参数可能会改变消息的顺序性。
max.in.flight.requests.per.connection
此配置设置客户端在单个连接上能够发送的未确认请求的最大数量,默认为 5,超过此数量会造成阻塞。设置大的值可以提高吞吐量但会增加内存使用,但是需要注意的是,当设置值大于 1 而且发送失败时,如果启用了重试配置,有可能会改变消息的顺序。设置为 1 时,即使重新发送消息,也可以保证发送的顺序和写入的顺序一致。
3.3 原理剖析
3.4 分区机制
主题是承载真实数据的逻辑容器,而在主题之下还分为若干个分区,主题下的每条消息只会保存在某一个分区中,而不会在多个分区中被保存多份。
为什么使用分区的概念而不是直接使用多个主题呢?
对数据进行分区的主要原因是为了实现系统的高伸缩性(Scalability)。不同的分区能够被放置到不同节点的机器上,而数据的读写操作也都是针对分区这个粒度而进行的,这样每个节点的机器都能独立地执行各自分区的读写请求处理。并且,还可以通过添加新的节点机器来增加整体系统的吞吐量。
3.4.1 分区策略
所谓分区策略是决定生产者将消息发送到哪个分区的算法。
轮询策略
顺序分配。比如一个主题下有 3 个分区,那么第一条消息被发送到分区 0,第二条被发送到分区 1,第三条被发送到分区 2,以此类推。当生产第 4 条消息时又会重新开始,即将其分配到分区 0。
轮询策略有非常优秀的负载均衡表现,它总是能保证消息最大限度地被平均分配到所有分区上,故默认情况下它是最合理的分区策略,也是最常用的分区策略之一。
随机策略
随意地将消息放置到任意一个分区上
Key-ordering 策略
Kafka 允许为每条消息定义消息键,简称为 Key。这个 Key 可以是一个有着明确业务含义的字符串,比如客户代码、部门编号或是业务 ID 等。一旦消息被定义了 Key,就可以保证同一个 Key 的所有消息都进入到相同的分区里面,由于每个分区下的消息处理都是有顺序的。
假设有一个服务需要监听某个公众号用户关注取关的事件,发送的消息必须要保证有序性,不然会导致结果混乱。如果给 Kafka 主题只设置 1 个分区,这样所有的消息都只在这一个分区内读写,因此保证了全局的顺序性。
这样做虽然实现了因果关系的顺序性,但也丧失了 Kafka 多分区带来的高吞吐量和负载均衡的优势。
可以在消息体中封装了固定的标志位,并对此标志位设定专门的分区策略,保证同一标志位的所有消息都发送到同一分区,这样既可以保证分区内的消息顺序,也可以享受到多分区带来的性能红利。
4 消费者
4.1 消费组(Consumer Group)
消费组是 kafka 提供的可扩展且具有容错性的消费者机制,是 Kafka 实现单播和广播两种消息模型的手段。
多个从同一个主题消费的消费者可以加入到一个消费组中,消费组中的消费者共享 Group Id。组内的所有消费者协调在一起来消费订阅主题的所有分区,每个分区只能由同一个消费者组内的一个 Consumer 实例来消费。
Consumer 采用 pull 模式从 broker 中读取数据,可以自主控制消费方式,逐条消费或批量消费。
4.2 位移提交
Consumer 需要向 Kafka 记录自己的位移数据,这个汇报过程称为 提交位移(Committing Offsets)。这个过程非常灵活,可以提交任何位移值,但也会由此产生系列不好的结果。假设 Consumer 消费了 10 条消息,提交的位移值却是 20,那么位移介于 11~19 之间的消息是有可能丢失的;相反地,如果提交的位移值是 5,那么位移介于 5~9 之间的消息就有可能被重复消费。
自动提交
1)开启自动提交: enable.auto.commit=true,默认为 true
2)配置自动提交间隔: auto.commit.interval.ms ,默认 5s
自动提交会导致消息被重复消费
- Consumer 每 5s 提交 offset
- 假设提交 offset 后的 3s 发生了 Rebalance
- Rebalance 之后的所有 Consumer 从上一次提交的 offset 处继续消费
- 因此 Rebalance 发生前 3s 的消息会被重复消费
虽然能通过减少 auto.commit.interval.ms 的值来提高提交频率,但这么做只能缩小重复消费的时间窗口,不可能完全消除它。
手动同步提交
使用 KafkaConsumer#commitSync(),会提交 KafkaConsumer#poll() 返回的最新 offset。
该方法为同步操作,等待直到 offset 被成功提交才返回。
while (true) {
ConsumerRecords<String, String> records =
consumer.poll(Duration.ofSeconds(1)); process(records); // 处理消息
try {
// Consumer 程序会处于阻塞状态,直到远端的 Broker 返回提交结果
consumer.commitSync();
} catch (CommitFailedException e) {
// 处理提交失败异常
handle(e);
}
}
手动异步提交
while (true) {
ConsumerRecords<String, String> records =
consumer.poll(Duration.ofSeconds(1));
// 处理消息
process(records);
// 会立即返回结果,不会阻塞
consumer.commitAsync((offsets, exception) -> {
if (exception != null)
handle(exception);
});
}
但 commitAsync 不能替代 commitSync,因为出现问题时它不会自动重试。由于是异步操作,倘若提交失败后自动重试,那么它重试时提交的位移值可能早已经“过期”或不是最新值了。因此,异步提交的重试其实没有意义,所以 commitAsync 是不会重试的。
手动同步提交与异步提交结合
try {
while (true) {
ConsumerRecords<String, String> records =
consumer.poll(Duration.ofSeconds(1));
// 处理消息
process(records);
// 使用异步提交规避阻塞
commitAysnc();
}
} catch (Exception e) {
// 处理异常
handle(e);
} finally {
try {
// Consumer 要关闭前使用同步阻塞式提交,以确保 Consumer 关闭前能够保存正确的位移数据
consumer.commitSync();
} finally {
consumer.close();
}
}
4.3 位移管理
Kafka默认定期自动提交位移( enable.auto.commit = true ),也手动提交位移。另外kafka会定期把group消费情况保存起来,做成一个offset map
位移管理机制将 Consumer 的位移数据作为一条条普通的 Kafka 消息,提交到 __consumer_offsets 主题中。
4.4 重平衡
重平衡其实就是⼀个协议,它规定了如何让消费者组下的所有消费者来分配 topic 中的每⼀个分区。⽐如⼀个 topic 有 100 个分区,⼀个消费者组内有 20 个消费者,在协调者的控制下让组内每⼀个消费者分配到 5 个分区,这个分配的过程就是重平衡。
重平衡的触发条件主要有三个:
- 消费者组内成员发⽣变更,这个变更包括了增加和减少消费者,⽐如消费者宕机退出消费组
- 主题的分区数发⽣变更, kafka ⽬前只⽀持增加分区,当增加的时候就会触发重平衡
- 订阅的主题发⽣变化,当消费者组使⽤正则表达式订阅主题,⽽恰好⼜新建了对应的主题,就会触发重平衡
1)消费者宕机,退出消费组,触发再平衡,重新给消费组中的消费者分配分区
2)由于 broker 宕机,主题 X 的分区 3 宕机,此时分区 3 没有 Leader 副本,触发再平衡,消费者 4 没有对应的主题分区,则消费者 4 闲置
3) 主题增加分区,需要主题分区和消费组进⾏再均衡
4)由于使⽤正则表达式订阅主题,当增加的主题匹配正则表达式的时候,也要进⾏再均衡
因为重平衡过程中,消费者⽆法从kafka消费消息,这对 kafka 的 TPS 影响极⼤,⽽如果 kafka 集内节点较多,⽐如数百个,那重平衡可能会耗时极多。数分钟到数⼩时都有可能,⽽这段时间kafka基 本处于不可⽤状态。 所以在实际环境中,应该尽量避免重平衡发⽣。
避免重平衡
要说完全避免重平衡,是不可能,因为⽆法完全保证消费者不会故障。⽽消费者故障其实也是最常⻅的引发重平衡的地⽅,所以我们需要保证尽⼒避免消费者故障。
⽽其他⼏种触发重平衡的⽅式,增加分区,或是增加订阅的主题,抑或是增加消费者,更多的是主动控制。
如果消费者真正挂掉了,就没办法了,但实际中,会有⼀些情况, kafka错误地认为⼀个正常的消费者已经挂掉了,我们要的就是避免这样的情况出现。⾸先要知道哪些情况会出现错误判断挂掉的情况。
在分布式系统中,通常是通过⼼跳来维持分布式系统的, kafka也不例外。在分布式系统中,由于⽹络问题你不清楚没接收到⼼跳,是因为对⽅真正挂了还是只是因为负载过重没来得及发⽣⼼跳或是⽹络堵塞。所以⼀般会约定⼀个时间,超时即判定对⽅挂了。
⽽在kafka消费者场景中,session.timout.ms 参数就是规定这个超时时间是多少。
还有⼀个参数, heartbeat.interval.ms,这个参数控制发送⼼跳的频率,频率越⾼越不容易被误判,但也会消耗更多资源。
此外,还有最后⼀个参数, max.poll.interval.ms,消费者poll数据后,需要⼀些处理,再进⾏拉取。如果两次拉取时间间隔超过这个参数设置的值,那么消费者就会被踢出消费者组。也就是说,拉取,然后处理,这个处理的时间不能超过 max.poll.interval.ms 这个参数的值。这个参数的默认值是5分钟,⽽如果消费者接收到数据后会执⾏耗时的操作,则应该将其设置得⼤⼀些。
2 类非必要 Rebalance
- 因为 Consumer 没能及时发送心跳请求,导致被踢出消费组
- Consumer 消费时间过长导致
用于减少 Rebalance de 的 3 个参数:
- session.timout.ms 控制⼼跳超时时间,建议设置为 6s
- heartbeat.interval.ms 控制⼼跳发送频率,建议设置为 2s
- max.poll.interval.ms 控制 poll 的间隔,推荐为消费者处理消息最⻓耗时再加 1 分钟
如果按照上面的推荐数值恰当地设置了这几个参数,却发现还是出现了 Rebalance,可以排查一下 Consumer 端的 GC 表现,比如是否出现了频繁的 Full GC 导致的长时间停顿,从而引发了 Rebalance。在实际场景中,很多是因为 GC 设置不合理导致程序频发 Full GC 而引发的非预期 Rebalance 了。
5 异常处理
5.1 如何保证消息不丢失
Kafka 只对“已提交”的消息(committed message)做有限度的持久化保证。
什么是已提交的消息?
当 Kafka 的若干个 Broker 成功地接收到一条消息并写入到日志文件后,它们会告诉生产者程序这条消息已成功提交。此时,这条消息在 Kafka 看来就正式变为“已提交”消息了。
有限度的持久化保证
假如你的消息保存在 N 个 Kafka Broker 上,那么这个前提条件就是这 N 个 Broker 中至少有 1 个存活。只要这个条件成立,Kafka 就能保证你的这条消息永远不会丢失。
案例 1:生产者程序丢失数据
目前 Kafka Producer 是异步发送消息的,如果调用 producer.send(msg) 会立即返回结果,但无法判断是否发送成功。
可能由于网络抖动,导致消息压根就没有发送到 Broker 端;或者消息本身不合格导致 Broker 拒绝接收(比如消息太大了,超过了 Broker 的承受能力)等。
解决方法:Producer 永远要使用带有回调通知的发送 API,也就是说不要使用 producer.send(msg),而要使用 producer.send(msg, callback)。callback 能准确地告诉你消息是否真的提交成功了,一旦出现消息提交失败的情况,就可以有针对性地进行处理。
如果是因为那些瞬时错误,那么仅仅让 Producer 重试就可以了,但是重试会导致乱序问题;如果是消息不合格造成的,那么可以调整消息格式后再次发送。
案例 2:消费者程序丢失数据
没消费成功,但消费位移已经提交
解决方法:采用手动提交位移,保证消费成功后再提交唯一
5.2 如何保证消息不被重复消费?
见 4.2.1
手动提交替代自动提交,并且手动同步提交与异步提交相结合
5.3 如何保证消息消费的幂等性?
在业务层针对不同的业务场景处理
- 如果数据需要写入数据库,先判断一下,这个数据在数据库中存不存在,如果已经有了,update一下即可;
- 或者生产者在发送每条数据的时候,添加一个全局唯一的id,然后消费的时候,现根据这个id去查有没有这个消息,再做逻辑处理。
5.4 如何处理消息积压?
思考
- 是什么导致了消息积压?是consumer程序bug?是consumer消费的速度落后于消息生产的速度?
- 积压了多长时间,积压了多少量?
- 对业务的影响?
解决思路
1)如果仅仅是 consumer 消费的速度落后于消息生产的速度的话,可以考虑采用扩容消费者群组的方式。
2)如果积压比较严重,积压了上百万、上千万的消息。
- 修复现有 consumer 的问题,并将其停掉;
- 重新创建一个容量更大的 topic,比如 patition 是原来的 10 倍,同时提升消费者组的消费者数量(@KafkaListener里的concurrency可以设置消费者数,默认为3,一般设置数量与partitions一样);
- 编写一个临时 consumer 程序,消费原来积压的队列;该 consumer 不做任何耗时的操作,将消息均匀写入新创建的队列里;
- 将修复好的 consumer 部署到原来 10 倍的机器上消费新队列;
- 消息积压解决后,恢复原有架构。
3)如果消息已经丢失
由于有的消息队列有过期失效的机制,造成了大量的消息丢失。这种情况只能将丢失的那批数据,写个临时程序,一点一点的查出来,然后重新灌入mq里面去。
生产环境遇到的消息积压问题
促销活动开始后发现消费用户消息堆积导致发券延时问题
【20:00】活动开始
【20:05】发现促销中台消费用户注册事件kafka堆积,检查只有4个partition,消费速度慢跟不上生产,导致发券延迟
【20:11】扩容该topic partition从4个扩容到10个partition
【20:15】发现mysql数据库CPU升高90%,业务没有不影响,担心持续增长临时扩容到16核
【20:20】kafka无堆积,消费正常
5.5 如何保证消息的顺序消费
每条发布到 kafka 集群的消息都有一个主题,物理上不同主题的消息是分开存储的。主题下还会分成多个分区,消息以追加的方式写入分区,然后以先入先出的顺序读取,且每条消息只会存在某一个分区中。
如果需要严格保证消息的消费顺序,需要将分区数目设为 1,但是这样做就丢失了 kafka 多分区带来的高吞吐量和负载均衡的优势。
还有一种方式
发消息的时候,在消息体里根据不同的业务封装不同的标记位,并针对标记位设定专门的分区策略,保证同一标记位的所有消息都发送到同一分区。这样既可以保证分区内的消息顺序,也可以享受到多分区带来的性能优势。