在 Netflix,我们为数百万用户提供无缝、高质量的流媒体体验的能力取决于强大的全球后端基础设施。该基础设施的核心是我们使用多个在线分布式数据库,例如Apache Cassandra,这是一种以高可用性和可扩展性而闻名的 NoSQL 数据库。Cassandra 是 Netflix 内各种用例的支柱,从用户注册和存储观看历史记录到支持实时分析和直播。
随着新键值数据库的引入和服务所有者推出新用例,我们遇到了许多数据存储误用方面的挑战。首先,开发人员很难在这种跨多个商店的复杂全球部署中推断一致性、耐用性和性能。其次,开发人员必须不断重新学习新的数据建模实践和常见但关键的数据访问模式。这些挑战包括尾部延迟和幂等性、管理具有多行的“宽”分区、处理单个大型“胖”列以及响应分页缓慢。此外,与多个本机数据库 API 的紧密耦合(这些 API 不断发展,有时会引入向后不兼容的更改)导致整个组织都在进行工程工作以维护和优化我们的微服务的数据访问。
为了克服这些挑战,我们开发了一种基于数据网关平台的整体方法。这种方法促成了几种基础抽象服务的创建,其中最成熟的是我们的键值 (KV) 数据抽象层 (DAL)。这种抽象简化了数据访问,增强了我们基础设施的可靠性,并使我们能够以最少的开发人员工作量支持 Netflix 要求的广泛用例。
在这篇文章中,我们深入探讨了 Netflix 的 KV 抽象的工作原理、指导其设计的架构原则、我们在扩展不同用例时面临的挑战,以及使我们能够实现 Netflix 全球运营所需的性能和可靠性的技术创新。
键值服务
引入 KV 数据抽象服务是为了解决我们在分布式数据库中面临的数据访问模式的持续挑战。我们的目标是构建一个多功能且高效的数据存储解决方案,可以处理各种各样的用例,从最简单的哈希图到更复杂的数据结构,同时确保高可用性、可调一致性和低延迟。
数据模型
KV 抽象的核心是两级映射 架构。第一级是散列字符串ID(主键),第二级是字节键值对的有序映射。此模型支持简单和复杂的数据模型,在灵活性和效率之间取得平衡。
HashMap <String, SortedMap <Bytes, Bytes>>
Records对于结构化或按时间顺序排列的复杂数据模型Events,这种两级方法可以有效地处理分层结构,从而允许一起检索相关数据。对于更简单的用例,它还表示平面键值Maps(例如id → {"" → value})或命名Sets(例如id → {key → ""})。这种适应性使 KV 抽象可用于数百种不同的用例,使其成为在 Netflix 等大型基础设施中管理简单和复杂数据模型的多功能解决方案。
KV 数据可以在高层次上进行可视化,如下图所示,其中显示了三条记录。
图片
message Item (
Bytes key,
Bytes value,
Metadata metadata,
Integer chunk
)
数据库无关的抽象
KV 抽象旨在隐藏底层数据库的实现细节,为应用程序开发人员提供一致的接口,而不管该用例的最佳存储系统是什么。虽然 Cassandra 就是一个例子,但该抽象适用于多种数据存储,如EVCache、DynamoDB、RocksDB等……
例如,当使用 Cassandra 实现时,抽象利用了 Cassandra 的分区和聚类功能。记录ID充当分区键,项目键充当聚类列:
图片
Cassandra 中此结构对应的数据定义语言 (DDL) 是:
CREATE TABLE IF NOT EXISTS <ns>.<table> (
id text,
key blob,
value blob,
value_metadata blob,
PRIMARY KEY (id, key))
WITH CLUSTERING ORDER BY (key <ASC|DESC>)
命名空间:逻辑和物理配置
命名空间定义了数据的存储位置和存储方式,在抽象底层存储系统的同时提供逻辑和物理分离。它还充当访问模式(例如一致性或延迟目标)的中央配置。每个命名空间可以使用不同的后端:Cassandra、EVCache 或多个后端的组合。这种灵活性使我们的数据平台能够根据性能、耐用性和一致性需求将不同的用例路由到最合适的存储系统。开发人员只需提供他们的数据问题,而不是数据库解决方案!
在此示例配置中,ngsegment命名空间由 Cassandra 集群和 EVCache 缓存层支持,从而实现高度耐用的持久存储和低延迟点读取。
"persistence_configuration":[
{
"id":"PRIMARY_STORAGE",
"physical_storage": {
"type":"CASSANDRA",
"cluster":"cassandra_kv_ngsegment",
"dataset":"ngsegment",
"table":"ngsegment",
"regions": ["us-east-1"],
"config": {
"consistency_scope": "LOCAL",
"consistency_target": "READ_YOUR_WRITES"
}
}
},
{
"id":"CACHE",
"physical_storage": {
"type":"CACHE",
"cluster":"evcache_kv_ngsegment"
},
"config": {
"default_cache_ttl": 180s
}
}
]
KV 抽象的关键 API
为了支持不同的用例,KV 抽象提供了四个基本的 CRUD API:
PutItems — 将一个或多个项目写入记录
该PutItemsAPI是一个upsert操作,它可以在两级map结构中插入新数据或者更新现有数据。
message PutItemRequest (
IdempotencyToken idempotency_token,
string namespace,
string id,
List<Item> items
)
如您所见,请求包括命名空间、记录 ID、一个或多个项目以及幂等性令牌,以确保重试相同的写入是安全的。可以通过暂存块然后使用适当的元数据(例如块数)提交它们来写入分块数据。
**GetItems **— 从记录中读取一个或多个项目
该GetItemsAPI 提供了一种结构化且自适应的方法,可使用 ID、谓词和选择机制来获取数据。这种方法既能满足检索大量数据的需求,又能满足严格的性能和可靠性服务级别目标 (SLO)。
message GetItemsRequest (
String namespace,
String id,
Predicate predicate,
Selection selection,
Map<String, Struct> signals
)
其中GetItemsRequest包括几个关键参数:
- 命名空间:指定逻辑数据集或表
- Id:标识顶级 HashMap 中的条目
- 谓词:过滤匹配的项目,可以检索所有项目 ( match_all)、特定项目 ( match_keys) 或某个范围 ( match_range)
- 选择:缩小返回的响应范围,例如page_size_bytes分页、item_limit限制页面中的项目总数和include/exclude或从响应中包含或排除较大的值
- **信号:**提供带内信令来指示客户端功能,例如支持客户端压缩或分块。
该GetItemResponse消息包含匹配的数据:
message GetItemResponse (
List<Item> items,
Optional<String> next_page_token
)
- 项目:根据请求中定义Predicate检索到的项目列表。Selection
- 下一页标记:可选标记,用于指示后续读取的位置(如果需要),这对于处理跨多个请求的大型数据集至关重要。分页是有效管理数据检索的关键组件,尤其是在处理可能超出典型响应大小限制的大型数据集时。
DeleteItems — 从记录中删除一个或多个项目
该DeleteItemsAPI 提供了灵活的数据删除选项,包括记录级、项目级和范围删除——同时支持幂等性。
message DeleteItemsRequest (
IdempotencyToken idempotency_token,
String namespace,
String id,
Predicate predicate
)
就像在 API 中一样GetItems,Predicate允许一次处理一个或多个项目:
- 记录级删除(match_all):无论记录中的项目数有多少,都会以恒定的延迟删除整个记录。
- 项目范围删除(match_range):这将删除记录中的一系列项目。对于保留“n 个最新”或前缀路径删除很有用。
- 项目级删除(match_keys):删除一个或多个单独的项目。
某些存储引擎(任何推迟真正删除的存储)如 Cassandra 因墓碑和压缩开销而难以处理大量删除。键值优化记录和范围删除,以便为操作生成单个墓碑 — 您可以在关于删除和墓碑中了解有关墓碑的更多信息。
项目级删除会创建许多墓碑,但 KV 通过基于TTL 的抖动删除隐藏了存储引擎的复杂性。项目元数据不会立即删除,而是更新为已过期,并使用随机抖动的 TTL 来错开删除。此技术可维护读取分页保护。虽然这不能完全解决问题,但它可以减少负载峰值并有助于在压缩赶上时保持一致的性能。这些策略有助于保持系统性能、减少读取开销并通过最大限度地减少删除的影响来满足 SLO。
复杂的 Mutate 和 Scan API
除了对单个记录进行简单的 CRUD 之外,KV 还支持通过MutateItems和ScanItemsAPI 进行复杂的多项目和多记录变更和扫描。PutItems还支持通过分块协议在单个记录中对大型 blob 数据进行原子写入Item。这些复杂的 API 需要仔细考虑以确保可预测的线性低延迟,我们将在以后的文章中分享有关其实现的详细信息。
可靠且可预测的性能设计理念
幂等性可以解决尾部延迟问题
为了确保数据完整性PutItems,DeleteItemsAPI 使用幂等性令牌,它可以唯一地标识每个可变操作,并保证操作按逻辑顺序执行,即使由于延迟原因而进行对冲或重试也是如此。这在 Cassandra 等最后写入获胜的数据库中尤其重要,因为确保请求的正确顺序和重复数据删除至关重要。
在 Key-Value 抽象中,幂等性 token 包含生成时间戳和随机 nonce token。后端存储引擎可能需要其中一个或两个来删除重复的突变。
message IdempotencyToken (
Timestamp generation_time,
String token
)
在 Netflix,客户端生成的单调令牌因其可靠性而受到青睐,尤其是在网络延迟可能影响服务器端令牌生成的环境中。这将客户端提供的单调generation_time时间戳与 128 位随机 UUID相结合token。虽然基于时钟的令牌生成可能会受到时钟偏差的影响,但我们在 EC2 Nitro 实例上的测试表明偏差很小(不到 1 毫秒)。在某些需要更强排序的情况下,可以使用 Zookeeper 等工具生成区域唯一令牌,或者可以使用交易 ID 等全局唯一令牌。
下图展示了我们在 Cassandra 集群上观察到的时钟偏差,表明该技术在可直接访问高质量时钟的现代云虚拟机上是安全的。为了进一步保持安全性,KV 服务器拒绝带有较大偏移的令牌的写入,这既可以防止易受这些攻击的存储引擎出现静默写入丢弃(写入的时间戳远在过去)和不可变的末日石(写入的时间戳远在未来)。
图片
通过分块处理大数据
键值对还旨在高效处理大型数据块,这是传统键值对存储的常见挑战。数据库通常面临每个键或分区可存储的数据量限制。为了解决这些限制,KV 使用透明分块来高效管理大数据。
对于小于 1 MiB 的项目,数据直接存储在主后备存储(例如 Cassandra)中,以确保快速高效的访问。但是,对于较大的项目,只有 id 、 key和元数据存储在主存储中,而实际数据被分成较小的块并单独存储在块存储中。此块存储也可以是 Cassandra,但具有针对处理大值优化的不同分区方案。幂等性令牌将所有这些写入绑定到一个原子操作中。
通过将大型项目拆分成块,我们确保延迟与数据大小成线性比例,从而使系统既可预测又高效。未来的博客文章将更详细地描述分块架构,包括其复杂性和优化策略。
客户端压缩
KV 抽象利用客户端有效负载压缩来优化性能,尤其是对于大数据传输。虽然许多数据库都提供服务器端压缩,但在客户端处理压缩可以减少昂贵的服务器 CPU 使用率、网络带宽和磁盘 I/O。在我们的一个部署中,这有助于支持 Netflix 的搜索,启用客户端压缩可将有效负载大小减少 75%,从而显著提高成本效率。
更智能的分页
我们选择以字节为单位的有效负载大小作为每个响应页面的限制,而不是项目数量,因为这使我们能够提供可预测的操作 SLO。例如,我们可以在 2 MiB 页面读取上提供个位数毫秒的 SLO。相反,使用每页项目数作为限制会导致不可预测的延迟,因为项目大小存在很大差异。如果每页 10 个项目的请求是 1 KiB 而不是 1 MiB,则延迟可能会有很大差异。
使用字节作为限制会带来挑战,因为很少有后备存储支持基于字节的分页;大多数数据存储使用结果数(例如 DynamoDB 和 Cassandra 按项目数或行数进行限制)。为了解决这个问题,我们对后备存储的初始查询使用静态限制,使用此限制进行查询,然后处理结果。如果需要更多数据来满足字节限制,则将执行其他查询,直到满足限制,丢弃多余的结果并生成页面令牌。
这种静态限制可能会导致效率低下,结果中的一个大项可能会导致我们丢弃许多结果,而小项可能需要多次迭代才能填满一页,从而导致读取放大。为了缓解这些问题,我们实现了自适应分页,可根据观察到的数据动态调整限制。
自适应分页
当发出初始请求时,将在存储引擎中执行查询并检索结果。当消费者处理这些结果时,系统会跟踪消费的项目数量和使用的总大小。这些数据有助于计算近似的项目大小,该大小存储在页面令牌中。对于后续的页面请求,这些存储的信息允许服务器对底层存储应用适当的限制,从而减少不必要的工作并最大限度地减少读取放大。
虽然此方法对于后续页面请求有效,但对于初始请求会发生什么情况?除了将项目大小信息存储在页面令牌中之外,服务器还会估算给定命名空间的平均项目大小并将其缓存在本地。此缓存估算值可帮助服务器为初始请求在后备存储上设置更优化的限制,从而提高效率。服务器会根据最近的查询模式或其他因素不断调整此限制以保持其准确性。对于后续页面,服务器会同时使用缓存数据和页面令牌中的信息来微调限制。
图片
除了自适应分页之外,如果服务器检测到处理请求有可能超出请求的延迟 SLO,则还有一种机制可以提前发送响应。
例如,假设客户端提交的GetItems请求每页限制为 2 MiB,最大端到端延迟限制为 500 毫秒。在处理此请求时,服务器会从后备存储中检索数据。此特定记录包含数千个小项目,因此收集整页数据通常需要的时间超过 500 毫秒的 SLO。如果发生这种情况,客户端将收到 SLO 违规错误,导致请求失败,即使没有任何异常。为了防止这种情况,服务器会在获取数据时跟踪已用时间。如果它确定继续检索更多数据可能会违反 SLO,则服务器将停止处理进一步的结果并返回带有分页令牌的响应。
图片
这种方法可确保请求在 SLO 内得到处理,即使未满足整个页面大小,也能为客户端提供可预测的进度。此外,如果客户端是具有适当截止期限的 gRPC 服务器,则客户端足够智能,不会发出进一步的请求,从而减少无用的工作。
如果您想了解更多信息,Netflix 如何确保高可靠性的在线状态系统一文将更详细地讨论这些技术以及许多其他技术。
信号
KV 使用带内消息传递(我们称之为信令),允许动态配置客户端,并使其能够将其功能传达给服务器。这确保了配置设置和调整参数可以在客户端和服务器之间无缝交换。如果没有信令,客户端将需要静态配置(每次更改都需要重新部署),或者,如果使用动态配置,则需要与客户端团队进行协调。
对于服务器端信号,当客户端初始化时,它会向服务器发送握手。服务器会以信号(例如目标或最大延迟 SLO)进行响应,从而允许客户端动态调整超时和对冲策略。然后会在后台定期进行握手以保持配置最新。对于客户端通信信号,客户端会随每个请求一起传达其功能,例如它是否可以处理压缩、分块和其他功能。
图片
Netflix 的 KV 使用情况
KV 抽象支持 Netflix 的几个关键用例,包括:
- 流元数据:高吞吐量、低延迟访问流元数据,确保实时个性化内容交付。
- 用户资料:高效存储和检索用户偏好和历史记录,实现跨设备的无缝、个性化体验。
- 消息传递:存储和检索消息传递需求的推送注册表,使数百万个请求能够流经。
- 实时分析:这可以持久产生大规模印象并提供对用户行为和系统性能的洞察,将数据从离线移动到在线,反之亦然。
未来的增强功能
展望未来,我们计划通过以下方式增强 KV 抽象:
- 生命周期管理:对数据保留和删除的细粒度控制。
- 汇总:通过将包含多项的记录汇总为较少的后备行来提高检索效率的技术。
- 新的存储引擎:与更多存储系统集成以支持新的用例。
- 字典压缩:在保持性能的同时进一步减少数据大小。
结论
Netflix 的键值服务是一种灵活、经济高效的解决方案,支持从低流量到高流量场景的各种数据模式和用例,包括关键的 Netflix 流媒体用例。简单而强大的设计使其能够处理各种数据模型,如 HashMap、集合、事件存储、列表和图形。它从我们的开发人员那里抽象了底层数据库的复杂性,使我们的应用程序工程师能够专注于解决业务问题,而不是成为每个存储引擎及其分布式一致性模型的专家。随着 Netflix 不断在在线数据存储方面进行创新,KV 抽象仍然是高效、可靠地大规模管理数据的核心组件,为未来的增长奠定了坚实的基础。