一致性在分布式系统中的重要性

在软件开发中,一致性是一个经常被忽视,但至关重要的概念。如果曾经参与过大型遗留系统的开发,或者在一个规模较大的团队中工作过,就会明白一致性对于提升团队协作效率和减少错误是多么重要。当所有成员都遵循相同的编码规范和风格时,代码看起来一致,感觉也一致,这非常有助于提高生产力。此外,一致性还能让那些不一致的地方更加显眼,从而迅速被发现和纠正。

风格和命名上的不一致通常不会影响大局,编译器通常会忽略它们,让它们消失在无尽的代码海洋中。但是,当这些不一致扩展到代码本身和实现细节时,问题就变得严重了。本文要讲述的就是一个看似无害的不一致问题,最终却演变成了一个难以追踪的严重问题。

为了更好地理解这个问题的复杂性,需要先设定一下背景。这个问题涉及到两个主要组件:

.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的家伙们也想到了一个更好的分区策略:

至少这会确保所有的键最终都出现在它们各自的分区上。

沪ICP备2024098111号-1
上海秋旦网络科技中心:上海市奉贤区金大公路8218号1幢 联系电话:17898875485