Netflix单key存储的架构演进

记录一下看了Netflix实战指南:规模化时序数据存储后关于其中的单key存储的架构演进理解,


Overview

Netflix 的云原生存储架构使用了 Cassandra存储观看历史数据,考虑如下,

  • 支持对时序数据的建模
  • 在当前业务数据上,读写操作比是1:9。而Cassandra提供了高效写操作API,适用于当前的写密集型业务应用
  • CAP权衡,当前业务更偏向于C。而Cassandra支持可调整的一致性,有助于实现CAP上的权衡

演进路线总结,

v1:一个用户一个key,key后面跟一连串的用户观影记录信息data。当data少时,可以快速定位<userId, data[record1, …, recordN]>;水平扩展性好。但是当data很大,查询需要低效的O(N)来遍历整个data。LRU缓存可以改善查询低效,但是空间换时间。

v2:既然v1的问题是累积data的太大引起,那么可以根据自定义阈值T1将data切分为两部分,一部分是实时数据;一部分是历史数据。小的实时数据可以按照v1的方式一列一个record;大的历史数据都压缩在一起成为一列(多个历史列合并成一个列)。如果合并压缩后的历史数据还是太大,那么依照阈值T2对其切分。

个人感觉是分库分表/MapReduce/冷热数据分离的思想,将一个读操作或者一个写操作的数据模型,切开成多个小的数据模型,然后在其上实现并发读写。


单表数据模型(Version1)

数据结构

每位会员的所有观看记录存储为一行,使用customerId为主键,每一次观看记录为一列,即,

(`customerId`, record1, record2, record3, ..., recordN)
  • 优点,水平分区设计支持数据存储随会员数量的增长而有效扩展(会员越多,row越多,分库分表即可),并支持简单并高效地读取会员的完整观看历史数据(都在一行里)
  • 缺点,每位会员观看的视频流越来越多,存储的数据列数和整体数据量也日益膨胀。随着时间的推移,这将导致存储和操作的成本增大。而且对于观看了大量视频的会员而言,查询性能会严重降低(hashMap冲突多了查链表之后就是O(n))

image

单表数据模型/v1模型的读、写操作

写操作

当一位会员开始播放视频时,一条观看记录会以一个新列的方式插入。当会员暂停或停止观看视频流时,观看记录会做更新。在 Cassandra 中,对单一列值的写操作是快速和高效的。

读操作

为检索一位会员的所有观看记录,需要读取整行记录。如果每位会员的观看记录数量不大,这时读操作是高效的。如果一位会员观看了大量的视频,那么他的观看记录数量将会增加,即记录的列数增加。读取一个具有大量列的数据行,会对 Cassandra 造成了额外压力,进而对读操作延迟产生负面影响。

要读取一段时间内的会员数据,需要做一次时间范围查询。这同样会导致上面所说的性能不一致问题。因为查询性能依赖于给定时间范围内的观看记录数/列数。

如果要查看的历史数据规模很大,需要做分页才能进行整行读操作。分页对 Cassandra 更好,因为查询不需要等待所有数据都就绪,就能返回给用户。分页也避免了客户超时问题。但是,随着观看记录的增长,分页增加了读取整行的整体延迟。

读延迟

原因,只有最近的数据是维护在内存中的(LRU),因此在很多情况下,检索观看历史记录时需要同时读取内存表和 SSTable。这对于读取延迟具有负面影响。同样,随着数据的增长,合并(Compaction)操作将占用更多的 IO 和时间。此外,随着一行记录越来越宽,读修复(Read repair)和全列修复(Full column repair)也会变慢。

缓存层

为优化读操作延迟,考虑了以增加写路径上的工作为代价,在Cassandra存储前增加了一个内存中的分片缓存层(即EVCache)。缓存实现为一种基本的键-值存储,键是customerId,值是观看历史数据的二进制压缩表示。每次Cassandra的读操作,将额外生成一次缓存查找操作。一旦缓存命中,直接给出缓存中的已有值。对于观看历史记录的读操作,首先使用缓存提供的服务。一旦缓存没有命中,再从Cassandra读取条目,压缩后插入到缓存中。


实时数据与历史数据分离模型(Version2)

数据结构

为进一步实现存储的规模化,分析了数据的特征使用模式,重新定义了观看历史存储。给出了两个主要目标,

  • 更小的存储空间
  • 考虑每位会员观看视频的增长情况,提供一致的读写性能

最后决定将每位会员的观看历史数据划分为两个数据集,

  • 实时/近期观看历史记录(LiveVH,Live or Recent Viewing History):一小部分频繁更新的近期观看记录。LiveVH 数据以非压缩形式存储
  • 历史/归档观看历史记录(CompressedVH,Compressed or Archival Viewing History):大部分很少更新的历史观看记录。该部分数据将做压缩,以降低存储空间。压缩观看历史作为一列,按键值存储在一行中

为提供更好的性能,LiveVH 和 CompressedVH 存储在不同的数据库表中,并做了不同的优化。

冷数据太多,单机memory放不下,就将冷数据打包,然后切片分段,缓存到不同的单机memory上。在查找时再根据meta data的routing来查。

LiveVH

写操作

如果一位会员观看了大量的视频,那么他的观看记录数量将会增加,即记录的列数增加(跟version1的写操作一样)。

读操作

读取实时/近期观看历史:在大多数情况下,近期观看历史仅需从LiveVH读取。这限制了数据的规模,进而给出了更低的延迟。

CompressedVH

写操作

在从LiveVH读取观看历史记录时,如果记录数量超过了一个预设的阈值,那么最近观看记录将由后台任务打包(roll up)、压缩并存储在CompressedVH 中。

打包数据存储在一个行标识为 customerId 的新行中。新打包的数据在写入后会给出一个版本,用于读操作检查数据的一致性。只有验证了新版本的一致性后,才会删除旧版本的打包数据。

CompressedVH的打包行中还存储了元数据信息,其中包括最新版本信息对象规模分块信息

image

实时数据与历史数据/v2模型的读、写操作

新行记录中具有一个版本列,指向最新版本的打包数据。这样,读取 customerId 总是会返回最新打包的数据。 为降低存储的压力,只使用了一个列存储归档数据。 为最小化具有频繁观看模式的会员的打包频率,LiveVH中仅存储最近几天的观看历史记录。 打包后,其余的记录在打包期间会与 CompressedVH中已有的记录归并

读操作

读取完整观看历史:实现为对 LiveVH 和CompressVH的并行读操作(实时与历史同时读,与下文的分块的并行不一样)。考虑到数据是压缩的,并且CompressedVH 具有更少的列,因此读取操作涉及更少的数据,这显著地加速了读操作。

分块实现自动扩展

通常情况是,对于大部分的会员而言,全部的观看历史记录可存储在一行压缩数据中。

罕见情况是,对于一小部分具有大量观看历史的会员,与v1架构中的问题一样,单行记录太长。即从一行中读取CompressedVH的性能很低。

为解决这个问题,如果数据规模大于一个预先设定的阈值,就将打包的压缩数据切分为多个分块,并存储在不同的 Cassandra节点中。即使某一会员的观看记录非常大,对分块做并行读写也会将读写延迟控制在设定的上限内。

image

数据分块实现自动扩展(类似join倾斜的prefix)

写操作

打包压缩数据基于一个预先设定的分块大小切分为多个分块。各个分块使用标识CustomerId$Version$ChunkNumber并行写入到不同的行中。

在成功写入分块数据后,元数据会写入一个标识为 customerId 的单独行中。

对非常大的归档观看数据,这一做法将写延迟限制为两次写操作。这时,元数据行为一个不具有数据列的行,这种实现支持对元数据的快速读操作。

读操作

在读取时,首先会使用行标识customerId读取元数据行

  • 对于通常情况,分块数是1,元数据行中包括了打包压缩观看数据的最新版本
  • 对于罕见情况,存在多个压缩观看数据的分块。使用了元数据信息(例如版本和分块数)对不同分块生成不同的行标识即CustomerId$Version$ChunkNumber并行读取所有的分块。这将读延迟限制为两次读操作。

改进缓存层

对于有大量历史观看记录的会员,整个压缩的观看历史可能无法置于单个 EVCache条目中。因此,采用了类似于对CompressedVH模型的做法,将每个大型缓存条目分割为多个分块,并将元数据存储在首个分块中。


结果对比

image

v1 vs. v2