作者:Rajiv Shringi,奥列克西·特卡丘克,卡蒂克·萨希亚纳兰 (查看领英个人资料)
介绍在我们之前的博客文章中,我们介绍了Netflix的时间序列抽象,这是一个设计用于存储和查询大量时间事件数据,并且具有毫秒级低延迟响应的分布式服务。今天,我们很高兴地向大家介绍这分布式计数抽象。这个计数服务基于时间序列抽象构建,支持大规模分布式计数,同时保持类似的低延迟性能。和其他抽象一样,我们利用数据网关控制平面用于分片、配置和全球部署此服务。
分布式计数是计算机科学中的一个棘手问题。在这篇博客文章中,我们将探讨奈飞(Netflix)的各种计数需求,在接近实时的情况下准确计数所面临的挑战,以及我们选择这种方法的理由,包括所需的权衡和取舍。
注意:在谈到分布式计数器时,诸如“准确”或“精确”之类的术语需要小心对待。在此上下文中,它们指的是非常接近准确的计数结果,并且在几乎无延迟的情况下呈现。
正用例和需求在 Netflix,我们的计数用例包括跟踪数百万次用户互动,监控特定功能或体验向用户展示的次数,以及在[A/B测试实验]中对多个数据维度进行计数,等等用途。
在 Netflix 上,这些情况可以分为两大类,
- 尽力而为:对于这种情况,计数不需要非常准确或持久。然而,这种情况几乎立即访问当前计数,同时保持低延迟,同时尽量减少基础设施成本。
- 最终一致性:这种情况需要准确且持久的计数,并且愿意容忍轻微的准确性延迟以及稍高的基础设施成本作为权衡。
这两个类别都有共同的要求,比如高吞吐量和高可用性。下表详细列出了这两个类别各自的需求。
分布式计数器概念(抽象概念)为了满足上述要求,计数器抽象设计为高度可配置。它允许用户在不同的计数模式之间进行选择,例如 尽力而为模式 或 最终一致性,同时考虑到每个选项的权衡。选择模式后,用户可以与 API 交互,而无需顾虑底层存储机制和计数方法。
我们来看看 API 的结构和功能。
API (应用程序编程接口)计数器被组织到独立的命名空间中,这些命名空间是用户根据具体使用场景设置的。每个命名空间都可以通过服务的控制平面配置不同的参数,如计数器的类型、生存时间(TTL,Time-To-Live)和计数器基数的设置。
反向抽象 API(Counter Abstraction API)类似于 Java 的 原子整数(AtomicInteger) 接口:
AddCount/AddAndGetCount :在数据集中调整指定计数器的计数值,将计数器的计数值增加给定的 delta 值。delta 值可以是正数也可以是负数。AddAndGetCount 对应的版本还会在执行加操作后返回计数器的计数值。
{
"命名空间名称": "my_dataset",
"计数器名称": "counter123",
"变化量": 2,
"幂等令牌信息": {
"令牌": "some_event_id",
"生成时间": "2024-10-05T14:48:00Z"
}
}
幂等令牌可以用于支持幂等性的计数器类型。客户端可以使用此令牌安全地重试或对请求进行安全处理。在分布式系统中,失败是常有的事情,安全地重试请求可以增强服务的可靠性。
GetCount :获取指定计数器在数据集中的计数值。
{
"namespace": "数据集命名空间",
"counter_name": "计数器123"
}
ClearCount:将指定计数器在数据集中的计数值清零。
{
"namespace": "数据集名称: my_dataset",
"counter_name": "计数器名称: counter456",
"idempotency_token": "重复提交标识符: {...}"
}
接下来,我们来看看抽象框架中支持的各种类型的计数器。
计数器种类
该服务主要支持两种类型的计数器:尽力型和一致型,以及一种实验性的类型:准确型。接下来的部分将介绍这些计数器类型的不同方法及其各自的权衡考量。
最佳区域对策这种计数器是由 EVCache,Netflix 的分布式缓存解决方案,基于广受欢迎的 Memcached 构建的。它适用于诸如 A/B 测试这样的场景,在这种场景中,会同时运行许多短期实验,并且近似计数就足够了。撇开配置和资源分配的复杂性不谈,该解决方案的核心其实非常简单:
// 计数缓存键
counterCacheKey = <namespace>:<counter_name>
// 增加操作如下
return delta > 0
? cache.incr(counterCacheKey, delta, TTL)
: cache.decr(counterCacheKey, Math.abs(delta), TTL);
// 获取计数
cache.get(counterCacheKey);
// 清除所有副本的计数
cache.delete(counterCacheKey, ReplicaPolicy.ALL);
EVCache 在单个区域中提供极高的吞吐量,延迟低至毫秒级或更低,支持在同一共享集群中设置多租户环境,这样一来,节省了基础设施成本。然而,它也有一些取舍:不支持跨区域的 增量 操作的复制,并且不提供 一致性保证,这对于准确计数来说可能是必要的。另外,它本身不支持幂等性,因此,重试或对冲请求可能不安全。
关于概率数据结构的说明如下:
概率性数据结构,如HyperLogLog (HLL),可用于估算唯一元素的数量,例如网站的独特浏览量或访问量,但不适合用于实现给定键的增减操作。Count-Min Sketch (CMS) 是一种可以用来调整键值的替代方法。数据存储系统,例如Redis,支持HLL和CMS。然而,出于几个原因我们并未选择这个方向:
- 我们选择在已经大规模运作的数据存储之上构建。
- 概率数据结构天生不支持我们的一些需求,比如为给定的键重置计数或为计数设置过期时间。需要额外的数据结构,包括更多的sketches,来支持这些需求。
- 另一方面,EVCache解决方案非常简单,代码行数很少,并且使用内置元素。不过,每个计数键会占用少量内存。
虽然一些用户可能接受尽力而为的计数器的限制,但其他用户则选择精确计数、持久性和全球可访问性。在接下来的内容中,我们将探讨实现持久且准确计数的各种方法。我们的目标是突出全球分布式计数的固有挑战,并解释我们选择这种方法的原因。
方法1:每个计数器只存一行数据
让我们从简单的做起,在全局复制的数据存储系统中的一个表中,为每个计数器键(counter key)单独使用一行。
我们来看看这种方法的一些缺点吧,例如:
- 缺乏幂等性:存储数据模型中没有内置的幂等性标识符,这使得用户无法安全地重试请求。实现幂等性可能需要使用外部系统来管理这些标识符,这可能会进一步降低性能或导致竞态条件。
- 高度竞争状况:为了可靠地更新计数,每个写入者都必须对给定的计数器执行比较和交换操作,使用锁或事务。根据吞吐量和并发性的不同,这可能会导致严重的竞争状况,严重影响性能。
次级键:减少争用的一种方法是使用次级键,例如 _bucketid ,这允许通过将计数器拆分成多个 buckets 来分散写操作,同时使读操作可以从多个桶汇总数据。挑战在于确定合适的桶数量。静态桶数量仍然可能导致 热键 争用,而对数百万计数器来说,动态分配每个计数器的桶数提出了一个更复杂的问题。
让我们看看我们能不能改进一下我们的方案来解决这些问题。
方法2:每个实例的聚合
为了解决热键问题以及实时写入同一行所引起的冲突,我们可以采用一种策略,即每个实例在内存中聚合计数,然后定期将它们刷新到磁盘上。在刷新间隔中引入足够的抖动可以进一步减少冲突。
然而,这个解决方案又带来了一组新的问题:
- 数据丢失的脆弱性:该解决方案在实例故障、重启或部署期间,内存中的所有数据容易丢失。
- 无法可靠地重置计数器:由于计数请求分布在多台机器上,因此很难就计数器重置的确切时间达成一致。
- 缺乏幂等性:与之前的方案类似,这种方案本身也无法保证幂等性。一种实现幂等性的方式是可以通过将同一组计数器始终路由到同一个实例。然而,这种方法可能会引入额外的复杂性,比如领导选举,以及写入路径中可能遇到的可用性和延迟问题。
话说,这种方法在这些取舍可以接受的情况下仍然是可行的。不过,我们看看能否通过不同的事件驱动方法来解决一些这些问题。
方法三:使用耐用队列
在这种方法中,我们将计数器事件记录到一个持久化的消息队列系统,如Apache Kafka,以防止数据丢失的可能性。通过创建多个主题分区,并将计数器键哈希到特定分区,我们确保相同的一组计数器由相同的消费者处理。这种设置简化了幂等性检查和计数重置的操作。此外,通过使用额外的流处理框架,如Kafka Streams或Apache Flink,我们可以实现窗口聚合功能。
不过,这种方法也有一些挑战,
- 潜在延迟:单个消费者处理某个分区的所有计数可能会导致堆积和延迟,从而导致计数过时。
- 重新平衡分区:这种方式需要随着计数器数量和吞吐量的增加自动调整并重新平衡主题分区。
此外,所有预先聚合计数的方法都使我们难以满足准确计数的两个要求。
- 计数审核:审核包括将数据提取到离线系统进行分析,以确保增量被正确应用以达到最终计数值。此过程也可以用来追踪增量的来源。然而,当计数被聚合而没有保存个别增量时,审核就变得不可行了。
- 可能的重新计数:与审核类似,如果需要调整增量并重新计数某个时间范围内的事件,预先聚合计数会使这种情况变得不可行。
除了这些少数要求之外,如果我们能够找到正确的方式去调整队列分区和消费者的规模并确保操作的幂等性,这种方法仍然可以有效。不过,让我们看看如何调整这种方法以满足审计和重新计数的需求。
方法四:单个增量的增量事件记录
在这种方法中,我们会记录每个单独的计数器递增情况以及它们各自的 event_time 和 event_id。event_id 可以包括递增的来源信息。event_time 和 event_id 的组合也可以作为写入操作的幂等键。
不过,最简单的情况下,这种方法有几个缺点。
- 读取延迟:每次读取请求都需要扫描给定计数器的所有增量值,这可能会降低读取性能。
- 重复工作:多个线程在读取操作期间可能会重复聚合同一组计数器的工作,导致资源浪费和资源利用率低下。
- 宽分区:如果使用像Apache Cassandra这样的数据存储,为相同的计数器存储许多增量可能会导致宽分区,影响读取的性能。
- 大数据量:单独存储每个增量也会随着时间的推移导致数据量显著增大。如果没有有效的数据保留策略,这种做法可能难以有效扩展规模。
这些问题的综合影响可能导致难以证明其合理性的增加的基础设施成本。然而,采用事件驱动的模式似乎是解决我们遇到的这些挑战并满足需求的重要一步。
我们怎样才能更好地优化这个解决方案呢?
Netflix的做法我们结合了之前的几种方法,即每次计数活动都记录为一个事件,并在后台通过队列以及滑动时间窗口持续聚合这些事件。此外,我们采用了一种分桶策略来避免出现过大的分区。在下面的部分中,我们将探讨这种方法如何解决之前提到的问题,并满足我们所有需求。
请注意:从现在开始,我们将交替使用这两个词“rollup”和“aggregate”。它们的意思差不多,即汇总每个计数器的增减,得出最终结果。
时序事件存储库:
我们选择了时序数据抽象层作为我们的事件存储系统,其中计数器的变化被记录为事件。时序数据存储事件的好处包括:
高性能的:TimeSeries 抽象概念已经满足了我们很多需求,包括高可用性和高吞吐量,以及可靠快速的性能等。
减少代码复杂性:我们通过减少计数抽象中的许多功能,并将这些功能委托给现有的服务,大大减少了代码的复杂性。
时间序列抽象功能使用Cassandra作为底层事件存储,但是可以配置为与任何持久存储系统一起工作。它看起来像这样:
处理宽分区:(_timebucket) 和 (_eventbucket) 列在分割宽分区中起着关键作用,防止给定分区被高吞吐量计数器事件压垮。更多相关信息,请参阅我们之前的博客文章《Netflix时间序列数据抽象层介绍》。
避免重复计数:对于一个计数器,事件的 _eventtime 、 _eventid 和 _event_itemkey 构成事件的幂等键值,让客户端可以安全重试而无需担心重复计数。
时间序列排序:TimeSeries 按时间逆序排列所有事件,从而我们可以利用这一特性处理类似计数重置的事件。
事件保留策略:时序抽象中包含了保留策略,确保事件不会被永久存储,从而节省磁盘空间并减少基础设施成本。一旦事件被聚合并移至更经济的存储进行审计,就没有必要再保留在主存储中。
现在,我们现在来看看这些事件是如何为特定计数器聚合的。
汇总计数统计:
如前所述,为每个读取请求收集所有单独增量将对读取性能造成过高成本,因此需要一个后台聚合过程来持续聚合计数,确保最优读取性能。
但我们如何在写操作进行中安全地汇总计数事件呢?
这里,最终一致性的概念变得至关重要。通过故意延迟一个安全的时间段,我们确保聚合总是发生在不可变的时间窗口中。
看看那是什么样
我们这样拆解一下看看:
- lastRollupTs :这表示计数器值最后一次聚合的时间。对于一个新计数器,此时间戳默认为一个合理的过去时间。
- 不可变窗口和滞后 :为了安全地进行聚合,只能在不再接收计数器事件的不可变窗口内进行。“TimeSeries 抽象的 'acceptLimit' 参数”在此处起关键作用,它会拒绝时间戳超出此限制的传入事件。聚合过程中,窗口会稍微向后推移以应对时钟偏差。
这意味着计数器的值会滞后于其最近一次更新一段时间(通常以几秒为单位)。这种方法确实可能导致由于跨区域复制问题而遗漏某些事件。请参阅文末的“未来工作”部分。
- 聚合过程:聚合过程是将自上一次聚合后聚合窗口内的所有事件聚合,以计算新的数值。
卷轴商店:
我们将聚合的结果保存到持久存储中。下一个聚合将从这里开始。
我们为每个数据集创建一个Rollup表,并使用Cassandra作为我们的持久化存储。然而,正如您将在控制平面部分看到的那样,Counter服务可以配置为与任何持久化存储协同工作。
LastWriteTs : 每当某个计数器收到一次写入请求时,我们也会记录一个 最后写入时间戳,作为列更新记录在该表中。这是通过Cassandra的USING TIMESTAMP特性来应用最后写入胜出(LWW)规则。该时间戳与事件的 事件时间 一致。在接下来的部分中,我们将看到如何使用此时间戳来保持某些计数器在活动汇总中更新,直到它们达到最新的值。
汇总缓存
为了优化读取性能,我们把这些值缓存到每个计数器的EVCache中。我们将lastRollupCount和lastRollupTs 组合成每个计数器的一个单一缓存值,以避免可能的不匹配,以防止计数及其相应的检查点时间戳之间的不匹配。
不过,我们如何知道触发汇总的计数器有哪些呢?让我们来看看我们的写路径和读路径,这样能更好地理解。
添加/清除计数器:
一个 增加 或 清除 计数的请求会持久写入时间序列抽象,并更新存储中的最后写入时间戳。如果确认失败,客户端可以使用相同的令牌重试请求,而不会导致重复计数。**** 在确认持久性后,我们会发送一个 一次性的 请求来触发汇总。
计数:
我们返回最后一个汇总的计数作为快速点读操作,接受传递可能略显过时计数的权衡。我们还在读取操作期间触发汇总以更新最后汇总时间戳,从而提升后续聚合的性能。此过程还会在之前的汇总失败时_自我修复_过时的计数。
采用这种方法,计数器的值会不断更新为最新的数值。现在,让我们看看如何通过我们的Rollup Pipeline将这种方法扩展到处理百万计数器和数千并发操作。
流水线:
每个Counter-Rollup服务器运行一个聚合管道流程,以高效地汇总数百万计数器的数据。这就是计数器抽象复杂性的主要来源。在以下各节中,我们将分享如何实现高效聚合的关键细节。
轻量级滚动事件: 如上所示,在写入和读取路径中,每个计数器的操作都会向汇总服务器发送一个轻量级事件。
rollupEvent: {
"namespace": "my_dataset",
"counter": "counter123"
}
请注意,此事件不包含增量部分。这只是告诉聚合服务器计数器已被访问,现在需要被聚合。这样我们就可以确切地知道哪些特定计数器需要聚合,从而避免为了聚合而扫描整个事件数据集。
内存汇总队列: 某个汇总服务器实例运行一组 内存中 的队列来接收汇总事件并并行聚合。在该服务的第一个版本中,我们决定使用内存队列来降低配置的复杂性,节省基础设施成本,并使调整队列数量的平衡变得相对简单。然而,这样做会带来权衡,在实例崩溃的情况下可能会丢失汇总事件。有关更多详情,请参阅“未来工作”部分中的“计数过时”。
减少重复劳动:我们使用如XXHash这样的快速非加密哈希算法来确保相同的计数器组最终落在同一个队列中。此外,我们通过运行更少但更强大的实例,尽量减少重复的汇总工作。
可用性和竞态条件问题: 单一的Rollup服务器实例可以减少重复聚合工作,但可能会给触发rollup带来可用性挑战。如果我们选择通过水平扩展来增加Rollup服务器的数量,允许线程覆盖rollup值的同时,这样可以避免使用任何形式的分布式锁机制,从而保持系统的高可用性和高性能。由于聚合在一个不可变的时间窗口内进行,这种做法是安全的。虽然不同线程中的_now()_时间点可能有所不同,这可能导致rollup值有时会有所波动,但最终计数会在每个不可变的聚合窗口内趋于准确值。
调整队列数量:如果我们需要调整队列的数量,只需简单更新控制平面配置并重新部署即可。
"eventual_counter_config": {
"queue_config": {
"num_queues" : 8, // 改为 16 并重新部署
...
处理部署:在部署期间,这些队列会优雅地停止运行,首先处理完所有现有事件,而新的Rollup服务器实例则根据新的队列配置启动。在短时间内,旧的和新的Rollup服务器可能会同时运行,但正如之前所述,由于聚合发生在不可变的时间窗口内,因此这种竞态情况是可以控制的。
减少汇总次数:收到针对同一个计数器的多个事件并不意味着要多次汇总。我们把这些汇总事件放入一个集合中,确保在一个汇总窗口内,每个计数器只被汇总一次。
高效聚合功能: 每个汇总消费者程序同时处理一批计数器数据。在每个批次中,它会并行查询底层的 TimeSeries 抽象,以聚合指定时间段内的事件。TimeSeries 抽象优化了这些范围扫描过程,以实现毫秒级的低延迟响应。
动态批量处理:Rollup服务器根据计数器基数动态调整需要扫描的时间分区数量,以避免底层存储因过多并行读取请求而过载。
自适应回压:每个消费者在完成一个批次后,才会发起下一批次的汇总。它根据前一批次的性能来调整批次间的等待时间。这种方法在汇总过程中提供了回压,以防止底层时序存储被压垮。
处理汇聚情况:
为了防止基数较低的计数器滞后太多,进而扫描过多的时间分区,会将它们保持在持续的汇总循环过程之中。对于基数较高的计数器,持续循环它们会消耗过多的内存,尤其是在汇总队列中。这里之前提到的最后写入时间戳在此扮演了关键角色。汇总服务器检查这个时间戳,以决定是否需要重新将给定的计数器入队,确保我们继续聚合,直到它完全追上写入。
现在我们来看看我们怎么用这种计数器来提供最新的当前计数,接近实时的。
实验性的:一个准确的全局计数器我们正在试验一种略有修改的最终一致性的计数器。请注意,这里的“准确”有些夸张。这种计数器与它的版本的关键区别在于,自上次汇总时间戳以来的计数增量(delta)是实时计算出来的。
实时聚合这个增量数据可能会根据需要扫描的事件和分区的数量来影响此操作的性能,这会影响操作性能,具体取决于需要扫描的事件和分区的数量。同样的原理适用于这里,为了防止并行扫描太多分区,这里也采用分批处理的方式来避免上述情况。
这样一来,这种方法获取当前计数就非常有效,即使数据集中的计数器访问次数较少,delta的间隔时间也依然很小。
现在让我们看看如何通过统一的控制平面配置来处理这一切复杂性。
控制面数据网关平台的控制平面( https://netflixtechblog.medium.com/data-gateway-a-platform-for-growing-and-protecting-the-data-tier-f1ed8db8f5c6 )管理所有抽象和命名空间的控制设置,包括例如计数器抽象。下面是一个支持低基数最终一致性的计数器命名空间的配置示例:
"persistence_configuration": [
{
"id": "CACHE", // 用于计数器缓存配置
"scope": "范围:dal=counter",
"physical_storage": {
"type": "EVCACHE", // 缓存存储的类型
"cluster": "evcache_dgw_counter_tier1" // 共享的EVCACHE集群
}
},
{
"id": "COUNTER_ROLLUP",
"scope": "范围:dal=counter", // 计数器抽象配置
"physical_storage": {
"type": "CASSANDRA", // Rollup存储的类型
"cluster": "cass_dgw_counter_uc1", // 物理集群名称
"dataset": "my_dataset_1" // 命名空间/数据集
},
"counter_cardinality": "LOW", // 支持的计数器基数
"config": {
"counter_type": "EVENTUAL", // 计数器类型
"eventual_counter_config": { // 最终计数器类型
"internal_config": {
"queue_config": { // 根据基数调整
"num_queues" : 8, // 每实例的Rollup队列数
"coalesce_ms": 10000, // 合并间隔
"capacity_bytes": 16777216 // 每个队列的内存分配
},
"rollup_batch_count": 32 // 并行化因子
}
}
}
},
{
"id": "EVENT_STORAGE",
"scope": "范围:dal=ts", // 时间序列事件存储
"physical_storage": {
"type": "CASSANDRA", // 持久化存储类型
"cluster": "cass_dgw_counter_uc1", // 物理集群名称
"dataset": "my_dataset_1", // keyspace名称
},
"config": {
"time_partition": { // 时间分区
"buckets_per_id": 4, // 事件桶数量
"seconds_per_bucket": "600", // 较低基数的间隔
"seconds_per_slice": "86400", // 时间切片表的宽度
},
"accept_limit": "5s", // 固定边界
},
"lifecycleConfigs": {
"lifecycleConfig": [
{
"type": "retention", // 事件保留
"config": {
"close_after": "518400s",
"delete_after": "604800s" // 事件计数保留7天
}
}
]
}
}
]
使用这样的控制平面设置,我们通过在同一个主机上运行的容器来组合多个抽象层次,每个容器获取特定于其自身的配置。
配置与时间序列抽象一样,我们的自动化使用了许多用户输入,包括他们的工作负载和基数,来自动选择合适的基础设施和相关的控制平面配置。你可以通过我们的一位同事Joey Lynch的演讲了解更多相关信息:观看视频《Netflix 如何在云中优化基础设施的配置过程》:How Netflix optimally provisions infrastructure in the cloud。
性能写这篇博客时,该服务在全球范围内每秒处理大约75K计数请求,在不同的API端点和数据集中:
实现所有端点的个位数毫秒延迟:
瑞典语翻译应为: 瑞典语中的未来工作但由于这里是从英语翻译到中文,正确的翻译应该是:
未来工作
尽管我们的系统很强大,但我们仍有许多工作要做,使其更加可靠,增强其功能,其中包括:
- 区域汇总: 跨区域复制问题可能导致来自其他区域的事件被忽略。一种替代策略是在每个区域建立一个汇总表,然后将它们汇总到一个全局汇总表中。此设计的关键挑战在于跨区域有效清除计数器的通知。
- 错误检测与陈旧计数: 如果汇总事件丢失或汇总过程失败且未重试,则可能会出现计数陈旧的情况。这对于频繁访问的计数器来说不是问题,因为它们会继续参与汇总。这类问题在不经常访问的计数器中更为明显。通常,这类计数器的首次读取会触发汇总,从而自我修复问题。然而,对于不能容忍初始读取可能过时的用例,我们计划实现改进的错误检测、汇总交接以及持久队列,以支持弹性重试。
分布式计数在计算机科学领域仍然是一个具有挑战性的问题。在这篇博客中,我们探讨了多种方法来实现并部署一个大规模的计数服务。虽然可能还有其他分布式计数的方法,我们的目标是提供超快的性能,同时保持低成本的基础设施,确保高可用性和幂等性保证。我们在过程中进行了一系列权衡,以满足奈飞多样化的计数需求。希望这篇博客文章给您带来了一些启发。
敬请关注“复合抽象”系列的第三篇,我们将介绍我们的“图数据抽象”,这是一个正在构建的新服务,基于键值抽象 和 时序抽象之上,旨在处理高吞吐量和低延迟的图数据。
感谢特别感谢这些同事们为反抽象成果的成功所做出的贡献:特别感谢Joey Lynch(乔伊·林奇),特别感谢Vinay Chella(维纳伊·切拉),特别感谢Kaidan Fullerton(凯登·富勒顿),特别感谢Tom DeVoe(汤姆·德沃),特别感谢Mengqing Wang(王梦清)。