0 简介
Kafka 最初的设计目的是用于处理海量的日志。
在早期的版本中,为了获得极致的性能,在设计方面做了很多的牺牲,比如不保证消息的可靠性,可能会丢失消息,也不支持集群,功能上也比较简陋,这些牺牲对于处理海量日志这个特定的场景都是可以接受的。
随后的几年 Kafka 逐步补齐了这些短板 ,当下的 Kafka 已经发展为一个非常成熟的消息队列产品 ,无论在数据可靠性、稳定性和功能特性等方面都可以满足绝大多数场景的需求。
但是,如果使用不当 ,还是有可能发生丢消息 、重复消费等情况。
Kafka 为了实现超高的并发性能,大量使用了批量和异步 的设计,也因此,反而导致它同步收发消息的响应时延比较高,因为当客户端发送一条消息的时候,Kafka 并不会立即发送出去,而是要等一会儿攒一批再发送,在它的 Broker 中,很多地方都会使用这种"先攒一波再一起处理"的设计。所以,Kafka 其实不太适合实时的在线业务场景。
golang 一般使用 c.Consumer.Offsets.AutoCommit.Interval的库连接 Kafka,默认配置可见:github.com/Shopify/sar...
框架配参数时是通过:
ini
cli, err = skafka.NewClient(cfg.KafkaAddr, func(config *sarama.Config) {
config.Version = sarama.V2_3_0_0
//config.Producer.Flush.Frequency = time.Second
})
1 生产者阶段
1.1 生产者丢消息
生产者丢消息一般分为:
-
producer 把消息发送给 broker,因为网络抖动,消息没有到达 broker;
-
producer 把消息发送给 broker-leader,leader 接收到消息,在未将消息同步给 follower 之前,挂掉了;
-
producer 把消息发送给 broker-leader,leader 接收到消息,leader 未成功将消息同步给每个 follower,有消息丢失风险;
-
某个 broker 消息尚未从内存缓冲区持久化到磁盘,就挂掉了,这种情况无法通过ack机制感知。
其中,前三种都是可以通过 ack 配置来规避的,生产者客户端可配置:
arduino
config := sarama.NewConfig()
config.Producer.RequiredAcks = sarama.WaitForAll
ack参数取值见:github.com/Shopify/sar...
ini
const (
// 不等待ack
NoResponse RequiredAcks = 0
// 本地提交成功
WaitForLocal RequiredAcks = 1
// 等待所有同步副本提交成功,这副本的数量看服务端的`min.insync.replicas`配置
WaitForAll RequiredAcks = -1
)
遇到提交消息失败时,生产者可以进行重试。
此时要关注 c.Producer.Retry.Max 最大重试次数、c.Producer.Retry.Backoff 重试时间间隔、c.Producer.Flush.Frequency flush发送频率、c.Producer.Flush.Messages 触发flush的消息量 等等。
不过并不是所有的异常都是可以通过重试来解决的,比如消息太大,超过 max.request.size 参数配置的值时,这种方式就不可行了。
Kafka 为了提升性能,使用页缓存机制,将消息写入页缓存而非直接持久化至磁盘,采用了异步批量刷盘机制,也就是说,按照一定的消息量和时间间隔去刷盘,刷盘的动作由操作系统来调度的,如果刷盘之前,Broker 宕机了,重启后在页缓存的这部分消息则会丢失。
1.2 消息重复生产
先来看一个定义:
类型 | 消息是否会重复 | 消息是否会丢失 | 优势 | 劣势 | 适用场景 |
---|---|---|---|---|---|
最多一次 | 否 | 是 | 生产端发送消息后不用等待和处理服务端响应,消息发送速度会很快。 | 网络或服务端有问题会造成消息的丢失。 | 消息系统吞吐量大且对消息的丢失不敏感。例如:日志收集、用户行为等场景。 |
最少一次 | 是 | 否 | 生产端发送消息后需要等待和处理服务端响应,如果失败会重试。 | 吞吐量较低,有重复发送的消息。 | 消息系统吞吐量一般,但是绝不能丢消息,对于重复消息不敏感。 |
有且仅有一次 | 否 | 否 | 消息不重复,消息不丢失,消息可靠性很好。 | 吞吐量较低。 | 对消息的可靠性要求很高,同时可以容忍较小的吞吐量。 |
生产者阶段消息重复根本原因其实就是:生产发送的消息没有收到正确的 ack 响应,导致 producer 重试。
比如 broker 落盘成功,但 ack 响应由于网络原因丢失了,生产者进行重试,broker 再次落盘成功。
实际上,就是为了降低消息丢失的概率,反而 增加了重试概率,从而导致消息重复生产。
解决方案是启用 Kafka 的幂等性:enable.idempotence=true 同时要求 ack=all 且 retries>1。
原理:每个 producer 有一个 producer id,服务端会通过这个id关联记录每个producer的状态,每个producer的每条消息会带上一个递增的sequence,服务端会记录每个producer对应的当前最大sequence(producerId + sequence),如果新的消息带上的sequence不大于当前的最大sequence就拒绝这条消息,消息落盘会同时更新最大sequence,这个时候重发的消息会被服务端拒掉从而避免消息重复。
2 消费者阶段
2.1 原理
消费者阶段的异常本质上是对位移(offset) 的不恰当提交。
参考上图,当前一次 poll() 操作所拉取的消息集为 [x+2, x+7],x+2 代表上一次提交的消费位移,说明已经完成了 x+1 之前(包括 x+1 在内)的所有消息的消费,x+5 表示当前正在处理的位置。如果拉取到消息之后就进行了位移提交,即提交了 x+8,那么当前消费 x+5 的时候遇到了异常,在故障恢复之后,我们重新拉取的消息是从 x+8 开始的。也就是说,x+5 至 x+7 之间的消息并未能被消费,如此便发生了消息丢失的现象。
再考虑另外一种情形,位移提交的动作是在消费完所有拉取到的消息之后才执行的,那么当消费 x+5 的时候遇到了异常,在故障恢复之后,我们重新拉取的消息是从 x+2 开始的。也就是说,x+2 至 x+4 之间的消息又重新消费了一遍,故而又发生了重复消费的现象。
2.2 自动提交位移
在 Kafka 中默认的消费位移的提交方式是自动提交,这个由消费者客户端参数 c.Consumer.Offsets.AutoCommit.Enable 配置,sarama 默认值为 true。当然这个默认的自动提交不是每消费一条消息就提交一次,而是定期提交,这个定期的周期时间由客户端参数 c.Consumer.Offsets.AutoCommit.Interval 配置,sarama 默认值为1秒。
假设刚刚提交完一次消费位移,然后拉取一批消息进行消费,在下一次自动提交消费位移之前,消费者崩溃了,那么又得从上一次位移提交的地方重新开始消费,这样便发生了重复消费的现象。
我们可以通过减小位移提交的时间间隔来减小重复消息的窗口大小,但这样并不能避免重复消费的发生,而且也会使位移提交更加频繁。
而当消息拉取和业务处理是分开线程的,则可能会导致消息丢失:
在 sarama 库中的自动提交,是基于被标记过的消息 session.MarkMessage(msg, ""),见:github.com/Shopify/sar...
如果不调用 session.MarkMessage(msg, ""),即使启用了自动提交也没有效果,下次启动消费者会从上一次的 offset 重新消费。
这样的处理,我们可以在业务处理完成后再调用 session.MarkMessage(msg, "") 来最大限度保证不会出现消息丢失场景。
2.3 手动提交位移
Kafka 当然也提供了手动提交位移的方法,并且分为同步提交和异步提交。但这里不打算细讲,理由很简单,会大大增加代码复杂度(对提交失败的重试处理),还无法完全避免重复消费。
异步提交不用多说,当异步提交操作未完成时,程序崩溃,自然导致重复消费;
异步提交失败时,也可能导致重复消费:
- 异步提交 x+1 时失败,走进重试逻辑;
- 消费者继续处理 x+2,异步提交成功;
- x+1的提交重试成功,覆盖掉 x+2 的位移,下次将重复拉取 x+2。
而同步提交,会大大降低消费者处理效率,基本可以说是放弃了队列的所有优势。
3 总结
消息丢失的场景,基本可以通过 Kafka服务端配置、生产者配置、消费者消息标记 来最大限度 的保证绝大多数情况 下不丢失消息。但建议设计之初考虑补消息的逻辑。
而消息重复场景,实际上可以说很多是为了对抗消息丢失而导致的,可以说很难完全避免 ,强烈建议 消费者做好幂等性处理。
题外话,Kafka 的这些 broker、topic、partition 设计,在高可用之余,也会导致消息时序无法保证,消费者要注意不能依赖消息时序进行业务处理。