在软件开发中,一致性是一个经常被忽视,但至关重要的概念。如果曾经参与过大型遗留系统的开发,或者在一个规模较大的团队中工作过,就会明白一致性对于提升团队协作效率和减少错误是多么重要。当所有成员都遵循相同的编码规范和风格时,代码看起来一致,感觉也一致,这非常有助于提高生产力。此外,一致性还能让那些不一致的地方更加显眼,从而迅速被发现和纠正。
风格和命名上的不一致通常不会影响大局,编译器通常会忽略它们,让它们消失在无尽的代码海洋中。但是,当这些不一致扩展到代码本身和实现细节时,问题就变得严重了。本文要讲述的就是一个看似无害的不一致问题,最终却演变成了一个难以追踪的严重问题。
为了更好地理解这个问题的复杂性,需要先设定一下背景。这个问题涉及到两个主要组件:
.NET生产者 - 这是一个基本的.NET控制台应用程序,它从源读取数据,并将消息发送到Kafka,Kafka会在下游执行各种魔法操作。
Kafka Streams 消费者 - 这是一个处理从生产者接收消息的应用程序,用于执行一些下游的丰富处理(例如,将消息与另一个数据源进行连接)。
不需要深入了解细节,只需要知道,当消息被产生时,它们会与一个键关联。这些键用于唯一标识每条消息,并且Kafka在确定分布式环境中给定键应该位于哪个分区时会使用这些键。
分区对于这个故事也很重要,因为Kafka本质上是分布式的,所以给定的键应该只存在于整个环境中的单个分区上。
在Kafka世界中,这种用例非常常见。没有发生任何魔法。一切都是使用开箱即用/推荐的设置进行的非常普通的设置。运行后不久,它似乎按预期工作。每秒有成千上万的消息流过,数据流入最终的丰富着陆点。
这个过程在一夜之间运行,但当醒来检查数据时,很明显有些地方出了问题。所有数据都从生产者流向消费者,日志显示在需要的地方有适当的键,但看起来连接失败了。
让考虑一个类比,可能会让数据库(而不是流)背景的人更容易理解:
有一个想象中的数据库,里面有两个完全相同的表。尝试在它们的键上连接这两个表,这些键在每个表中都是完全相同的。连接成功并返回...什么也没有...好吧...有时候。
知道连接失败后,有点困惑。一些记录通过了连接操作的流水线,但这没有意义。键在那里,确信。所以,决定取一个数据子集,更仔细地看看,以确保没有发疯:
源 A(生产者)
源 B(消费者)
mawjuG0B9k3AiALz0_2S
0q0juG0B9k3AiALz8ApP
xEEcv20B9k3AiALzEN0m
m60juG0B9k3AiALz5gU5
ua0juG0B9k3AiALz7wqa
ua0juG0B9k3AiALz7wqa
m60juG0B9k3AiALz5gU5
xEEcv20B9k3AiALzEN0m
0q0juG0B9k3AiALz8ApP
mawjuG0B9k3AiALz0_2S
...
...
在这个非常小的子集中,它反映了整体数据,经过验证,在超过一百万对记录中,每对键在两个要连接的源中都存在。接下来,尝试了一个实验,用一个非常小的25条记录的子集来看看有多少条记录通过了流水线并成功连接:5。
现在,为什么只有这么小的一部分记录通过了整个处理流水线,而其他的却没有?这没有意义。这几乎就像是随机的。
在几个小时的敲打和熬夜之后,一个同事提到这个问题看起来是多么随机,这让意识到:
它是随机的,但不是要找的那种随机。
使用Kafka的一个挑战是,它旨在在分布式环境中使用。将消息分配给多个节点的能力提供了惊人的性能、弹性,并能够轻松地扩展以满足需求,而不会错过任何节拍。但是Kafka是如何管理得这么好的呢?答案是:分区。
Kafka默认使用一个算法来处理跨多个分区和/或节点的工作分配,这个算法会查看给定记录的键,然后将其委托给一个分区:
// Kafka如何处理跨分区的消息分配
return DefaultPartitioner.toPositive(Utils.murmur2(keyBytes)) % numPartitions;
如所见,它取消息键,对它执行一些操作,然后取总和模拥有的分区数量,神奇地为记录分配一个分区。
由于这个过程是确定性的,并且依赖于键,它将确保给定的键总是被分配到同一个分区。
所以,必须进一步调查这个问题,而不是看那些失败的连接,而是专注于那些成功的连接。
宾果!在分析了之前子集中的所有数据后,发现所有五个成功的连接都有相同的键出现在同一个分区上:
键 分区 A 分区 B
mawjuG0B9k3AiALz0_2S 8 8
xEEcv20B9k3AiALzEN0m 8 8
ua0juG0B9k3AiALz7wqa 6 6
m60juG0B9k3AiALz5gU5 1 1
0q0juG0B9k3AiALz8ApP 3 3
那么为什么有些键出现在同一个分区上,而其他的却没有呢?似乎没有任何理由或原因说明给定的记录落在哪个分区上。
它是随机的,这就是问题所在。
经过一轮又一轮的数据分析,得到了以下结果:
所有数据都按预期从生产者应用程序发出(带有适当的键)
所有数据都进入了流/Kafka生态系统。
一些连接操作失败了,看起来似乎是随机的,尽管键在连接的两边都存在。
随机这个词在整个帖子中反复出现,这很重要,因为它是整个问题的关键。在从数据本身抽身而出,专注于分区之后,一个突破出现了。
深入研究源代码,详细说明了Kafka使用的默认分区策略是murmur2_random哈希算法。然而,在查看.NET生产者的默认设置后,它使用的是consistent_random算法!
这两种技术,旨在相互交互,对特定键的分区方式存在不一致。由于Kafka依赖于给定的键只存在于一个特定的分区,所以之前失败的连接永远不会成功,因为键虽然相同,但并不在同一个分区上。
对.NET生产者应用程序进行了一个简单的调整,解决了这个问题:
// 将.NET生产者设置为使用与下游Kafka分区一致的分区策略
producerConfiguration.Partitioner = Partitioner.Murmur2Random;
在设置了这个单一属性并重新处理了所有的数据后:立即有了巨大的变化。每个连接都成功了,整个流水线都在按预期运行。生活又变得美好了。回顾过去,很容易对问题的解决方案如此简单而微笑,甚至XKCD的家伙们也想到了一个更好的分区策略:
至少这会确保所有的键最终都出现在它们各自的分区上。