当 Netflix 推出 Tudum 作为其幕后故事、粉丝采访和互动体验的官方主页时,工程挑战很明确:以高速向数百万观众提供新鲜、格式丰富的内容,同时为编辑提供无缝方式实时预览更新。
初始架构遵循经典的 CQRS(命令查询责任分离)模式,将编辑工具的”写路径”与访问者的”读路径”分离。Kafka 连接这些路径,将读取优化数据推送到后端服务用于页面构建。
该方法可扩展且可靠,但并非没有权衡。
随着 Tudum 增长,编辑注意到保存更新和在现场预览中看到它之间令人沮丧的延迟。罪魁祸首是一系列顺序过程和缓存刷新周期,虽然适合生产访问者,但减慢了创意工作流程。
为解决这个问题,Netflix 工程师用 RAW Hollow 替换了读路径的外部键值存储和每请求 I/O:一个压缩的、分布式的、内存中的对象存储直接嵌入到应用中。
结果是近乎即时的编辑预览,更简单的基础设施,以及终端用户页面构建时间的大幅下降。在本文中,我们将看看这个设计决策的演变以及 Netflix 如何实现它。
CQRS 架构
Netflix 的 Tudum 平台必须支持两个根本不同的工作流程:
- 写路径:内容编辑在内容管理系统(CMS)中创建和更新丰富的、媒体重的故事。
- 读路径:数百万全球访问者以优化快速渲染和交付的格式消费这些故事。
为了使这些工作流程独立并允许每个根据其需要扩展,Netflix 采用了 CQRS(命令查询责任分离)架构。
写存储包含原始编辑数据(带 ID、元数据和引用的内部 CMS 对象),而读存储包含相同数据的完全”渲染就绪”版本,如解析的电影标题而不是 ID,CDN 就绪图像 URL 而不是内部资产引用,以及预计算的布局元素。
如前所述,Kafka 作为两条路径之间的桥梁。当编辑进行更改时,CMS 向 Tudum 的摄入层发出事件。这个摄入管道执行以下步骤:
- 从 CMS 拉取内容
- 应用模板和业务规则以确保格式一致性
- 验证数据和约束
- 将占位符转换为生产就绪资产(例如,电影标题查找、CDN URL 解析)
处理后的内容发布到 Kafka 主题。
数据服务消费者订阅这个主题,读取每个新的或更新的页面元素。它将这个数据写入 Cassandra 支持的读存储,结构化为快速检索。最后,API 层将这些读取优化的实体暴露给下游消费者,如页面构建服务(为渲染组装完整页面)、个性化服务和其他内部工具。
这个事件驱动设计确保编辑更改最终出现在 Tudum 网站上,而不影响写端性能,同时还允许 Netflix 独立扩展读和写路径。
问题
虽然带 Kafka 的 CQRS 设计稳健且可扩展,但它引入了一个工作流瓶颈,随着 Tudum 的编辑输出增长而变得越来越明显。
每次编辑在 CMS 中进行更改时,该更改必须经过长链才能出现在预览环境或现场网站上。以下是涉及的各个步骤:
- 在 CMS 中保存:更新的内容存储在写数据库中。
- Webhook 到 Tudum 摄入:CMS 通知 Tudum 的摄入层更改。
- 重新读取和处理:摄入获取相关部分,应用模板,验证数据,并执行资产解析。
- 发布到 Kafka:处理后的内容发送到指定的 Kafka 主题。
- 消费和存储:数据服务消费者摄入消息,将其作为读取优化记录写入 Cassandra。
- 缓存刷新:页面数据服务,位于 Cassandra 前面,维护近缓存,必须刷新才能使新内容可见。
这个近缓存是延迟的关键贡献者。从技术上讲,近缓存是小的、每实例的、内存层,位于读存储前面。然而,与其为每个更新立即刷新,它在计划的每键刷新策略上运行。每个键有一个计时器。当计时器触发时,实例从 backing store 刷新该键。虽然这种方法是为生产流量效率设计的,但它意味着新鲜编辑经常等待下一个计划的刷新周期才出现。
随着内容量和页面元素数量增加,这些刷新周期延长。页面由多个片段组装,每个都有自己的键和计时器。它们不一起刷新。这意味着页面元素越多,刷新完成越交错,导致不一致的预览状态。换句话说,一些元素更新了,但其他保持陈旧。
结果是编辑有时必须等待几分钟才能看到他们的更改反映在预览中,即使系统已经处理和存储了更新。
对于像 Tudum 这样的平台,时间与发布 tied 到新发布和活动的故事的时间至关重要,这种延迟破坏了编辑流程并使作家、编辑和设计师之间的协作复杂化。
RAW Hollow 解决方案
为消除 Tudum 读路径中的瓶颈,Netflix 工程师转向 RAW Hollow:一个压缩的、分布式的、内存中的对象存储,设计用于数据集小到中等大小、不频繁更改、必须以极低延迟服务的场景。
与早期设置不同,读服务从外部 Cassandra 支持的键值存储获取数据(带网络调用、缓存层和刷新周期),RAW Hollow 将整个数据集直接加载到每个需要它的应用实例的内存中。这意味着所有查找都在进程中发生,避免了旧方法的 I/O 和缓存失效复杂性。
RAW Hollow 在 Tudum 上下文中的关键特征如下:
- 分布式和共置:每个服务实例在内存中保存完整数据集。更新传播以便所有实例保持同步,而无需按需查询外部存储。
- 压缩可扩展性:数据以压缩二进制形式存储,显著减少 RAM 使用。在 Tudum 的案例中,三年的未水合数据适合约 130 MB,大约是 Apache Iceberg 中相同数据大小的四分之一。
- 每请求一致性控制:默认情况下,RAW Hollow favor 高可用性的最终一致性,但服务可以选择每请求强读后写一致性。这对编辑预览流程特别有用,确保刚发布的更新立即可见,而无需等待标准同步间隔。
- 为内存中计算构建:因为数据集总是在 RAM 中,如页面构建、搜索和个性化等服务可以以 O(1) 时间检索数据,消除网络往返。
对于 Tudum,采用 RAW Hollow 意味着移除页面数据服务、其近缓存、外部键值存储,甚至读路径中的 Kafka。相反,Hollow 客户端直接嵌入到每个需要内容的微服务中。这减少了顺序操作的数量,收紧了编辑的反馈循环,并通过移除多个移动部件简化了架构。
结果是重大转变:系统现在操作在”一次加载到内存→立即服务→传播更改”,而不是”存储到获取到缓存到刷新”。
新架构
采用 RAW Hollow 后,Netflix 重建了 Tudum 的读路径,以移除减慢编辑预览并添加不必要复杂性的层。
新设计仍然遵循 CQRS 原则(分离编辑内容创建和访问者面对的内容),但数据如何通过读侧移动现在从根本上简化了。
以下是更改的内容:
- 不再页面数据服务和近缓存:旧的外观覆盖键值存储,管理自己的计划缓存刷新周期,被完全移除。服务不再等待缓存更新:它们需要的数据已经在内存中。
- 读路径不再外部键值存储:Cassandra(及其所需的 I/O)不再在页面渲染或个性化期间查询。相反,整个读取优化数据集通过 RAW Hollow 存储在每个服务实例的 RAM 中。
- 读路径不再 Kafka:虽然 Kafka 在其他地方仍有用途,但读侧不再依赖 Kafka 为服务实时流量传播更新。RAW Hollow 内部处理数据分发和同步。
- 每个微服务嵌入 Hollow 客户端:如页面构建、搜索和个性化等服务现在运行自己的嵌入 Hollow 客户端。这给它们直接、O(1) 时间访问最新读取优化内容,而无需网络调用。
新流程工作如下:
- 当内容在写路径中更新时,它被处理成读取优化格式
- RAW Hollow 将该更新分发到所有服务实例的 Hollow 客户端。这称为 Hollow 状态,是每个服务进程本地保存的内存中数据集。
- 因为每个实例在内存中有完整数据集,任何请求(无论是来自编辑预览还是现场用户)都立即服务,无需缓存检查或数据存储查询。
- 对于编辑预览,服务可以请求强读后写一致性,确保最新更新在保存后立即显示在 CMS 中。
这个重新架构将 Tudum 的读路径从多跳网络绑定管道转变为内存本地查找模型。本质上,Netflix 采用了 CQRS 的可扩展性和分离,但剥离了读路径的 I/O 重管道,用内存优先、嵌入数据模型替换它。
结果
从带 Cassandra 和缓存的 Kafka 到带 RAW Hollow 的内存模型的读路径转变,为 Tudum 产生了即时和可衡量的改进。一些关键好处如下:
- 大幅延迟减少:在受控测试中(排除 TLS、认证、WAF 和日志开销),一旦所有读路径服务消费 Hollow 内存状态,主页构建时间从约 1.4 秒下降到约 0.4 秒。这个速度增益直接影响编辑预览和现场访问者请求。
- 近乎即时编辑预览:编辑现在在几秒内看到更新反映,而不是等待几分钟的缓存刷新周期。预览请求上的强读后写一致性确保更改在 CMS 中保存后立即可见。
- 小内存占用:压缩允许整个读取数据集舒适地适合 RAM。三年的未水合内容重约 130 MB,大约是之前 Apache Iceberg 格式的 25%。这使得将完整数据集与每个服务实例共置可行,而无需昂贵扩展。
- 操作简单性:从读路径移除 Kafka、外部键值存储和近缓存层减少了移动部件和故障点,同时消除了缓存失效头痛。
本文为学习目的的个人翻译,译文仅供参考。
原文链接:How Netflix Tudum Supports 20 Million Users With CQRS。
版权归原作者或原刊登方所有。本文为非官方译本;如有不妥,请联系删除。