在前文《客户案例分析:Netflix 全球多区域多活架构演进》中,我们提到 Netflix 技术栈中 EVCache 组件是基于 memcached 的低延迟高可用的缓存服务;随着该服务在团队中的普遍应用(注册,推荐,搜索,回看,等等),它成为一个存储 Petabytes 数据,百亿数据条目,每天承载万亿次操作的 “一等公民” 服务,在 AWS 上有数千台 EC2 虚机的集群。随着多区域多活架构的发展,缓存数据进一步在多区域冗余复制,在追求性能的同时,Netflix 的团队还在优化整个集群成本,因此,Netflix 团队从两个方向继续优化缓存服务的架构:(1)结合 SSD 保存缓存数据(2)更好的预热机制来支撑缓存服务的扩展和提升可用性。
SSD(固态磁盘)用在缓存中是否合适?
从成本上来看,固态磁盘(SSD)比内存具有更高的性价比,两者之间的主要差异是数据读取延迟,通常,内存的数据读取延迟很少情况会高于 1微秒,而固态磁盘(SSD)的随机数据读取延迟在 100~500 微秒之间;那对于EVCahce服务而言,Netflix 定义的服务水平(SLA)是延迟在 1毫秒上下,超时时间 20毫秒,每秒请求数量(RPS)在100K;Netflix 团队经过测试发现,AWS 存储优化实例 I3.2xlarge,使用本地 NVMe 实例存储的情况下,在 1KB 大小条目请求压测中可以达到 200K 的 IOPS,并且延迟极少超过 1毫秒;该测试结果表明,Netflix 可以利用本地 NVMe 实例存储满足缓存的 SLA 的前提下大规模降低成本。
L1内存+L2本地实例存储二级缓存架构
在原本的 EVcache 组件的基础上,2016 年发表的博客透露,Netflix 团队引入了一个新的 EVCache Moneta 组件,该组件利用 RocksDB 引擎将所有数据存储在本机实例存储上,而只有活跃或热点数据依旧保存在内存(memcached)中,该组件的引入,与纯内存缓存相比,降低了超过 60% 的内存需求,比如在个性化和推荐场景中,后端推荐引擎定期为每个用户计算推荐结果,并利用 Moneta 来存储数据;另一个组件是 Rend,一个用 Go实现的高性能代理,主要用在开发测试场景;无论 Moneta,Rend 还是 Memcached 对客户端而言,都支持同样的 Memcached 的文本和二进制协议;对比以前的架构而言,客户端从原来直连 Memcached 到现在通过 Rend 代理访问,而由 Rend 代理负责 L1/L2 的数据交互。
容错性:Rend/Memcached/Moneta三者在一个 EC2 实例上是完全独立的进程,处理不同的任务,这样的设计提升了单机的缓存数据可用性;如果 Rend 挂了,数据完整无损的存放在内存和磁盘中,一旦 Rend 恢复,客户端即可重连恢复功能;如果 L1 的Memcached 挂了,我们损失掉部分热点数据,但完整的数据还在 L2 中,后续 Memcached 进程恢复,一旦有请求过来,数据会从 L2 智能缓存在 L1 中;假如 Moneta 挂了,最坏的情况,我们损失的全部数据,但热点数据 L1 依然可用;容错性评估的选择权内置在 EVCache 客户端;
数据批处理优化:有些场景有定期的数据写入,比如每天夜间的批量数据导入,其他AWS 区域的同步过来的数据写入,这些场景 Rend 组件提供了单独的批量操作入口,满足数据直接写到 L2 的 SSD 磁盘,而不要经过 L1 内存;
性能:一个 i2.xlarge 实例,包含 L1 和 L2 缓存组件,测试下来性能可以到 22K/S 插入性能,21K/S 的读性能,差不多并发读写 10K/S插入,10K/S 读的性能;测试性能要低于真实生产环境,因为生产环境,更多的请求会命中L1缓存。
进一步优化:Memcached External Storage
Memcached 提供了一个新的特性 extstore,支持将数据存储在 SSD(i2)或NVMe(i3)磁盘上;extstore 在保证性能和吞吐的前提下,充分利用了不同的存储介质特性,所有的元数据包括键值和其他元数据保存在内存中,而真正的“数据”存储在磁盘上;
在 Moneta 的架构中,我们最多使用 50% 的存储容量,因为我们必须确保旧的数据条目在再次写入后才能被删除(FIFO 压缩)这种机制最坏情况下,每个数据条目都会存在一个新值和旧值,因此我们要预留 50% 的磁盘容量;在 extstore 下,我们不需要保存冗余的数据条目,这样所有的磁盘容量都可以被利用,因此基于 extstore 的 EVCache 集群相对Moneta 集群而言极大降低了存储成本;
2018年 Netflix 团队将 Moneta 迁移到 extstore 的架构,并充分利用Memcached 的异步元数据 dump 命令(lru_crawler),允许在某台实例上遍历所有的键值,从而利用该特性,进行新的缓存实例的预热,以及定期的数据快照;从而使得存储在 EVCache 的数据可以在灾难的情况下优雅的恢复。
性能提升:采用 extstore 实现 EVCache 的磁盘设备的读写,也极大提升的性能,下图是Netflix的一个推荐系统缓存服务生产集群的磁盘读取(通过 iosnoop 命令)的延迟情况,对应的读操作延迟大多数在 100微秒上下:
在类似的压力和同一种实例类型下,extstore(红色)比Moneta(蓝色)的平均读延迟持续稳定而且延迟要低:
如何扩展 EVCache 缓存服务?
缓存服务的扩展通常是由于应用端需要更多的存储或者更高的网络性能,Netflix 原本的扩展流程是:(1)启动一个新的缓存集群,通常实例性能更强大数量更多,但没有数据(2)打开双写,客户端同时写原来的缓存集群和新的缓存集群(3)等旧的缓存集群数据TTL 过期;这个流程工作的很好,但每次扩展活动,在新旧集群同时存在的时候,需要支付额外的费用;另外,不适合没有过期时间的数据条目或没有差异的数据条目的集群,以及在一个或多个副本中替换节点上的数据条目方式的“自然“预热会导致缓存未命中问题。
为了解决这个挑战,我们需要实现一套新的预热方案:(1)副本预热,一个机制从已经存在的数据副本复制到新的副本,越快越好,而且不影响连接到现有副本的EVCache客户端应用(2)实例预热,由于一台实例被替换或者被终止,需要初始化一个新的缓存实例节点,数据需要从其他缓存实例节点的数据副本进行同步。
架构要满足如下要求:
- 对已有的EVCache客户端影响尽量小
- 最小化EVCache节点的内存和磁盘使用量
- 预热时间尽量短
- 对预热时间没有限制(无论访问高峰还是平时)
基于这样的要求,Netflix 团队尝试了如下方法:(1)复制时加载,Netflix 团队已经实现了基于 Kafka 队列的多区域数据同步,可以保障 EVCache 集群在多个 AWS 区域进行数据同步;很自然的一个想法就是利用 Kafka 队列的消息来预热新的数据副本,但这种方法的挑战是我们需要在TTL期间(最长可能是数周)持久化存储键值,从而增加不少成本;还遇到了键值重复问题;(2)通过键值转存,该方法中,我们把每个缓存节点的键值(keys)及元数据导出并存储在 Amazon S3 中,元数据的导出是利用了 Memcached 的LRU Crawler 实用程序;该元数据随后会被缓存填充程序(populator)消费并遍历数据键值,从现有的副本中读取每个键的数据来填充到新的缓存副本;但我们注意到,当预热程序读取现有副本数据时,现有的应用客户端程序会受到影响,这将限制我们在访问高峰的时候进行缓存预热操作,因此为了控制预热对于网络性能的影响,我们引入了限速器组件。
缓存预热架构设计和实践
整个缓存预热系统有三个组件(1)控制器 Controller,负责系统资源创建和销毁及构建 Dumper和 Populator 之间的通信通道(2)元数据转存器 Dumper,功能内置在 EVCache 服务的 Sidecar 中,是一个Tomcat服务负责导出 Memcached 中的元数据信息(3)数据填充器Populator,消费 Dumper 出来的元数据信息,并负责将现有的缓存数据复制到新的副本。
控制器 Controller:数据源(即从何处复制数据的副本)可以由用户指定,或者由控制器Controller选择数据条目最多的副本。或者由控制器 Controller 将创建一个专用的 SQS 队列,用作元数据转存器 Dumper 和数据填充器 Populator 之间的通信链接。然后,它会在源副本节点上启动缓存转储(dump);转储在进行的同时,控制器Controller将创建一个新的填充器Populator群集,填充器Populator将获取配置,如 SQS 队列名称和其他设置;控制器 Controller 将等待,直到填充器 Populator 成功读取 SQS 队列消息;一旦 SQS 队列为空,它将销毁填充者 Populator 群集、SQS 队列和任何其他资源。
元数据转存器 Dumper:为了限制对正在访问 EVCache 副本的现有客户端应用的影响,我们采取了在每个EVCache 节点上转储数据的方法,每个节点分两个阶段执行数据转储。(1)使用 Memcached LRU 爬虫实用程序枚举密钥,并将密钥转储到许多密钥块文件中;(2)对于密钥块中的每个密钥,Dumper 将检索其值和辅助数据,并将它们转储到本地数据块文件中;达到最大块大小后,数据块将上传到 S3,并将包含 S3 URI 的消息写入 SQS 队列。有关数据块的元数据(如预热ID、主机名、S3 URI、转储格式和密钥计数)与 S3 中的数据块一起保留;这允许程序独立使用数据块;数据块的可配置大小允许它们在可用时被消耗,因此无需等待整个转储完成;由于每个EVCache 节点中将有多个密钥块,因此数据转储可以并行完成;并行线程的数量取决于可用磁盘空间,Sidecar 的JVM 堆大小和 CPU 内核。
数据填充器 Populator:填充器是负责填充目标副本的工作进程;它通过控制器设置的Archaius 动态属性获取有关 SQS 队列和目标副本(可选)的信息。填充器将从 SQS 队列中提取消息;该消息包含 S3 URI,然后下载数据块并开始填充目标副本上的数据。 Pppulator 执行添加操作(即仅在关键密钥-key不存在时插入数据),以防止覆盖在预热过程中发生变异的键值对。只要数据转储可用,Populator 就开始工作;填充器群集根据 SQS 队列中的可用数据块自动弹性扩展。
缓存服务节点的快速预热
在一个大型的 EVCache 集群中,一个非常常见的挑战是由于硬件故障或其它不可抗力的因素,缓存服务节点会被终止。这种情况会导致应用侧的延迟加大,因为 EVCache 客户端会从其他的数据副本获取数据,当多个数据副本同时受到故障影响时,应用侧可以观察到明显的缓存命中率下降;
如果我们能够非常快速地预热替换或重新启动的 EC2 实例,我们可以最大限度地减少节点替换或重新启动的影响。我们需要扩展缓存预热系统以实现实例预热,只需对缓存转储器进行少量更改,并从 EVCache 节点添加一个信号以通知重新启动事件。
该图说明了改进的预热的系统架构,这里我们有三个 EVCache 副本,副本中的一个节点(显示在中间)会重新启动用红色表示,并且需要对其进行预热。
当控制器 Controller 在启动时收到来自 EVCache 节点的重启信号时,它将检查报告副本中的任何节点是否少于其应得的条目数量,如果是,它将触发预热过程;控制器Controller 确保不使用上报事件的数据副本作为源副本。EVCache 对虚拟节点使用一致的哈希算法进行分布,重新启动/替换节点上的数据分布在其他副本中的所有节点上,因此我们需要在所有节点上转储(dump)数据;但转储器 Dumper 将仅为将散列到特定节点的密钥转储数据。然后,填充器 Populator 将使用数据块来将数据填充到特定副本,如前所述。
实例预热过程比副本预热轻得多,因为只处理每个节点上的一小部分数据。缓存预热系统正被广泛用于扩展缓存服务集群,尤其是缓存的数据 TTL 大于几个小时的集群;当我们扩展缓存以处理假日突发高峰流量时,这一点非常有用。下图显示了两个现有副本之一的新副本的预热情况;现有副本有大约 5 亿数据条目和 12 TB的数据,预热大约在 2 小时内完成。
我们已经预热的最大的缓存集群大约 700 TB 数据,总计 460 亿个数据条目;该缓存集群有 380 个副本,缓存副本预热消耗了 570 个填充实例,大约需要 24 小时。实例预热系统正在用于生产,它每天都在预热几个实例。下面的图表是一个实例预热例子,例如一个实例在 5.27分左右被替换,它在不到 15 分钟内被预热,大约 2.2 GB 的数据量和 1500 万个数据条目;副本的平均数据大小和项目计数也显示在图表中。
总结和展望
缓存服务在现代化应用程序架构中是一个非常重要的数据存储类型,从 Netflix 团队优化 EVCache 的过程来看,大规模数据缓存服务,需要结合多种存储类型,比如 SSD 和内存等;实践生产中的扩展要求,数据需要智能分区,存储能够自动扩展;如果客户也想实现类似的二级缓存策略,兼顾成本和性能,可以尝试使用 AWS 托管的 NoSQL 存储服务DynamoDB 结合它自带的内存加速 DAX 组件,避免了重复造轮子的同时,享受云服务进步带来的便利。
参考资料:
- Netflix 的官方技术博客