无码系列-6 数据缓存设计经验谈

1.    缓存系统概述

1.1.  数据缓存的常识

为了解决什么问题

水平扩展:通常数据源站专注于数据存储, 对于负载能力、性能瓶颈不会做复杂的设计。缓存系统用于避免系统性能瓶颈,实现水平扩展。

降低成本: 存储介质置换,实现存储空间大小、IO能力、实时性等相关属性的优化, 整体上获得更低成本。

把缓存系统部署到廉价的机房、网络中, 实现多地低成本(低可靠性)、高可用性部署。CDN是这方面的典型应用。

多业务在cache上混合部署,达到性能的最佳平衡。 例如:IO次数多的业务+存储空间大的业务混合部署, 使得磁盘IO和存储空间都接近100%的利用率。 不同时间段达到峰值的业务混合部署, 实现资源在时间上的有效利用(云服务中的资源超卖)。

业务行为补偿:补偿业务行为导致的负载冷热不均, 尤其是负载毛刺。 使得系统承载更大负载,服务质量平稳。

对硬件工作模式的补偿: CPU cache、磁盘cache等。 此类cache和数据源是一体设计的, 不在本文讨论范围。

提升用户体验:利用分布式缓存实现多物理位置部署, 实现服务的低延时、就近供给。

缓存的基本要求

数据一致性: 缓存要求数据一致性, 但不是强一致性的。 根据业务体验容忍程度来确定缓存系统的数据一致性设计要求。

可用性: 缓存允许丢失, 但要求整体上提供高可用性。 避免对源数据系统造成性能冲击。

弹性、扩容: 大型缓存系统应对的业务场景较复杂。 文件size分布、IO读写size分布、存储空间、网络带宽、爆发性负载等, 都要求缓存系统具备良好的弹性来应对业务变化。 在业务快速增长时, 横向扩展性表现在快速扩容。 这里的扩容除了增加机器外, 还要实现缓存数据的有效建立。使得新增机器真正意义上分担业务压力。

备注:对于一个磁盘缓存的机器, 例如单机存储容量为100TB。 按照典型磁盘负载(小IO随机访问,读写IO对半),需要半个月时间才能写满缓存数据, 达到较好的缓存命中率。 缓存扩容的周期太长,可能无法应对快速的负载变化。

1.2.      与源数据的区别

可信度低于源数据:

缓存系统不是数据的“基准源”。 我们可以把缓存数据理解为“源”的副本。 因此缓存数据在“权威性”方面是低于“源”的。

不完整存储:

源数据系统存储了完整的数据。 缓存系统往往根据业务需要,仅存储部分数据。 从数据的角度来说, 缓存系统的数据是不完整的。

缓存数据从业务的角度看是完整的吗? 业务往往是复杂的,从这个角度看是完整的,从那个角度看又是不完整的。 缓存系统的设计,需要结合业务场景来确保“看到完整的数据”。

2.    数据集合与维度管理

2.1.  数据存入与淘汰

多维数据集和缓存系统的局限性

简单的缓存功能:缓存一个文件。 这个文件在缓存系统中只有两个状态: 缓存、没有缓存。

如果一个文件非常大, 例如文件size是1TB。 那么缓存一个“完整”的文件在技术上并不可行。 下载和存储1TB的数据花费太多网络带宽、时间、存储空间。

缓存碎片的一致性:缓存系统很可能选择存储这个文件的某些“碎片”数据。 当这个文件在源站被编辑修改的时候, 这些不同时间段先后存储的“碎片”数据,如何与源数据保持一致性? 当用户从缓存系统下载了这个文件的一部分数据后, 如何保证用户继续下载到“旧数据”? 因为用户不能接受下载到一个文档“一半是新的,一半是旧的”。 此类问题涉及缓存数据的“集合”应当如何管理。

多维度缓存:如果一份数据是多维度查询的。 例如一个“学生管理系统”的数据, 可以按照“学生考试分数 > 60分”获得一个数据集。 可以按照“班级”、“老师名称”等维度获取不同的数据集。 数据源包含了“全部数据”, 针对这些维度的数据集合是“实时生成”的。 但缓存系统不具备“全部数据”, 也不具备“实时生成”数据集合的能力。

共享数据:不同数据集之间存在“重复”的记录。 缓存系统出于节约成本考虑,往往要合并存储重复记录。 但是缓存系统并不理解这些千奇百怪的“维度”。 在某个维度中记录发生变更后,另一个维度的数据集合是否还继续成立?

数据集破坏:例如按照“班级”维度变更了学生的名称、成绩。 那么“学生考试分数 > 60分”维度选中的这个记录由于“数据共享”, 也被变更了。 这使得该数据集受到破坏。

缓存系统在处理多维度数据集时存在诸多限制。上述的数据集合“被破坏”是因为缓存系统“部分地”理解了数据结构,从而实现了“数据共享”特性来解决存储空间成本问题。 但不能充分理解“数据集合维度”的真实含义, 无法确保所有维度的数据集得到合理维护。 这正是我们在复杂业务环境下经常发生的问题。 缓存系统不可能“理解”所有的业务规则。

数据持有者角色

谁是数据的主人?

原则: 数据有且只有一个主人

多维度数据缓存下, 为了节省资源,往往把多个维度下看到的同一个“子资源”合并在一起(共享)。 谁才是这个资源的主人? 谁有权写入或改写这个资源?

在建立缓存系统模型前,我们应当先回答上述问题。 清晰地划分操作数据的角色。 出于简化实现的考虑, 数据只应该有一个“主人”。

数据集完整性保障策略

数据写入完整性: 最理想的方式是一次性把一个元素写入。 然而事情往往没有那么简单。 例如一个元素是一个文件,这个文件大小是 1TB。 无论对于哪个系统, 处理1TB的数据都不是一个轻松的事情。  

数据读取完整性: 写入与读取(使用)不是同一个角色。 角色之间存在一定程度的“耦合”。 因此数据读取时, 需要读取角色保证自身读取到的数据是完整的。

推荐: 对于合并存储的资源, 采用一主多从的方式组织。 认定数据归属于某个数据集合, 从属于其它数据集合。 当归属数据集合对数据进行编辑后, 其它“从属”的数据集合应当被认定存在“数据失效风险”。 这里提到的风险并不等同于错误。 大部分场景下,业务是能接受这种轻微的不一致的。 根据业务场景对“数据失效风险”的容忍度, 来确定是否继续使用该缓存。

备注: 如果存在“数据失效风险”就丢弃数据, 也是不对的。 这样做可能导致缓存没有真正发挥“降低成本”的作用。 缓存数据丢失通常也会导致服务质量下降(延时高、负载冲击故障等)。

使用者对数据的锁定问题: 当一份数据正在被读取的时候, 我们期望数据不会被改写。 这里称为数据锁定。 长时间锁定数据, 导致数据不能更新。 为了平衡数据读取、改写两个角色之间的冲突, 可能要求缓存系统创建使用者数据副本。 这也导致缓存系统设计更加复杂, 是一个不稳定因素。

缓存淘汰算法

数据淘汰的最小单位:按集合整体淘汰、集合内单元素淘汰、不确定界限淘汰(例如文件数据碎片淘汰)。

缓存淘汰算法:LRU, FIFO2…

缓存淘汰算法有很多种。 设计缓存系统时, 推荐把缓存淘汰算法独立成一个模块。 以便后续可以配置、替换不同的淘汰算法。 在云服务中, 甚至需要考虑根据不同的客户行为应用不同的缓存淘汰算法。

强制淘汰: 基于缓存一致性风险的兜底策略。 另外出于互联网信息安全管制的原因, 缓存系统也需要提供“清理数据”的能力, 否则你的互联网服务分分钟会因为违规被“封禁”。

2.2.  数据使用

数据不完整特性

缓存数据不同于源数据, 通常它是对源数据的局部进行缓存。 这意味着缓存系统中存储的数据是“碎片”。 缓存系统需要能够管理这些“碎片”之间的关系, 使得从缓存系统获取到的“完整数据”版本一致。

时差也导致缓存数据的完整性受到破坏。不同时间相同的请求可能得到不同的数据。http下载提供了Etag、If-Match等一致性校验机制。 如果你的缓存系统基于http接口, 应当使用这些校验机制。 如果是其它接口, 也应当有类似的机制。

需要特别提醒的是, 鉴于某些地方运营商频繁劫持网络数据的现状, 缓存系统从客户下载、回源下载等穿越公网的场景, 应当设计数据校验能力。

非稳态数据的使用

如果一个数据是频繁变更的, 缓存系统需要考虑有一套机制向客户端屏蔽这种数据变化。 使得客户端的多次数据下载总是能拿到“同一个快照”下的数据版本。 当这个规则不能实现时, 需要能指示客户端放弃旧数据,下载新数据。 典型应用:在http Range(断点续传)下载场景

2.3.  与源存储的一致性

缓存数据传播原则:

缓存数据在全网有很多副本。例如在视频缓存领域全国分布的副本数量是100量级。 某些游戏程序“镜像”可能会在几十万个节点上部署。

如果副本数量多,副本的size极大,短时间内copy成千上万个副本的性能损耗是源站无法承受的。 使得这些缓存节点不可能都从源站直接copy。

副本copy通常需要“逐级扩散”的方式完成。有几种典型的copy方式:固定路径传播、传染病模式。 在国内网络架构下, 网络的互通性并不好。可能需要部署一些中转站来协助copy。 同时也需要考虑传播路径上单点故障对传播路径的阻断。

缓存数据可信度:

数据可信度分级。 这里需要对不同数据节点建立一个“可信”排序。 使得缓存节点总是从比自己更“可信”的节点获取数据。 禁止逆可信度反写。 尤其是某个节点数据损坏时(软件bug破坏数据、被黑客攻击等), 如果这个节点的数被多份copy, 将会导致更严重的“污染”。

数据校验矢量:

如果数据集合很庞大,元素写入一般无先后顺序。缓存节点采用比较激进的策略, 得到什么数据就先缓存什么数据。 某些情况下数据具有“不可比较性”, 即数据写入后无法还原提取, 无法校验数据是否“已经写进去了”。  这些情况需要缓存系统设计数据缓存单元版本号,形成校验数据矢量。

环路读写的数据污染问题:

很多业务系统需要下载源数据,然后根据业务需要编辑数据,再回写到数据源。

从理论上说, 每一次读写数据都在两个(或多个)设备之间传输数据,并且被不同的软件、软件的不同版本代码对数据编辑。 这些过程都可能引入“错误”。 长此以往, 数据的“可信”程度会越来越低。

这些设备在编辑数据时,数据获得的路径中部署了缓存系统, 其cache数据本身的“数据一致性”较低。 客户端基于这样一份数据编辑再反写数据源。在这样一个环路中,反复引入低一致性保障的cache数据, 将会导致“数据源”的可靠性严重下降。

2.4.  集合的一致性

数据的自我校验

无论是存储系统还是缓存系统, 数据都应该能够一定程度上自证清白。 通过牺牲一点性能、存储空间, 添加必要的校验数据。

集合的部分元素缓存

   缓存系统应当具备一个能力来描述这个集合是否“完整”。 这个集合可以是一个文件, 或者按照某个条件提取的数据集。 在某些场景下, 集合的完整性无法确认, 需要评估业务的容忍度。

多维度的集合交叉

   如前文所述, 数据集合应当归属其中一个维度。

集合中元素间的版本兼容性

如果元素是大颗粒的, 无法做到集合中的元素“整体瞬间更新”。 那么必然存在一个集合中多个元素的版本不一致。 版本不一致是“不可避免”的情况。 需要有一套版本兼容性约束,使得缓存系统在任意时刻,都能满足元素之间的版本依赖关系。 此类缓存数据多见于软件包、配置等场景。

3.    分布式缓存系统

3.1.  特点:廉价、大容量

多个节点组成大容量缓存。 节点的部署可能是跨机房、跨地区的。 组建分布式的缓存系统,需要配套缓存的路由、请求分发等能力。 跨机房部署时,需要使用更复杂的服务发现、故障处理、负载调度等系统。

分布式系统的设计已经有很多典型案例, 在此不展开。需要注意的是: 目前的分布式系统典型设计, 都算不算“完美”。 很多系统治理的算法, 都有比较严格的“前提条件”。 在实际业务运营中难以保证总是能满足这些“前提条件”,“问题频发”仍然是常态。

大型缓存系统通常采用索引、数据分离的方式实现, 构建出廉价、高性能的缓存系统。 当然其研发时间更多、系统复杂度也会更高。 其“廉价”的核心因素在于索引访问的IO被内存访问替代, 节省了“存储介质”的IO消耗。 目前主流的存储介质, 如磁盘、SSD等, 索引查询的IO消耗可能占了成本的30%。

IO的极限优化可以榨干硬件性能。采用更复杂的缓存淘汰策略(根据用户行为优化), 最大程度提升缓存命中率。 结合业务应用场景, 可以对业务行为进行预测。 在缓存系统中可以采用“预读”的方式,实现空闲IO时间片的有效利用。

由于存储介质特性差异, 成本的优化需要依据存储介质量身定制。 例如磁盘顺序读写的性能比随机读写高10倍。 缓存系统的数据结构设计, 需要针对该特性进行设计。 SSD随机读写场景的性能比较高, 但写入寿命有限(频繁写时,两三年就达到生命终点了)。 不同存储介质特定的结合, 有助于把存储成本降到更低。

3.2.  负载热点处理

日常场景下的资源冷热分布,会导致资源的热点访问。 在分布式系统中表现为某个节点的负载高。 通常采用资源切片打散的方式,均衡节点间的负载。

负载热点除了日常场景下的资源冷热分布外, 更多的是来自业务场景的“突发事件”。 例如春节零点、世界杯等社会活动对网络应用的“极限挑战”。 此类热点负载的特点是突发、突破系统设计极限。 缓存系统需要在“超出系统极限”的情况下, 维持最佳服务能力。

根据业务场景,采取的措施也多种多样: 包括内存/SSD临时换磁盘IO性能、延时IO合并换性能、柔性打压(选择性调整服务规格)、热点数据多副本、磁盘数据连续存储、临时禁止写数据等。

在云服务领域, 还要同时考虑适当的物理隔离, 避免突发业务负载影响其它租户的服务质量。

3.3.  平滑扩容

缓存系统和其它分布式系统有一个显著差异, 在扩容时会出现缓存命中率下降的问题。 如果把缓存机器的数量突然增加一倍, 新机器没有任何缓存数据, 无法立即提供缓存命中服务。 老机器缓存的数据集由于有部分键值空间转移到新机器(但数据太大,无法瞬间迁移), 事实上会导致数据丢失。 因此突然大量扩容反而在一段时间内导致性能下降。 该问题可以通过选择适当的key路由算法+运维扩容策略来降低对业务的影响。

不同的key绑定算法在扩容时导致的缓存损失比例有较大差别。 如果采用 “简单哈希取余数”方式, 新增一个节点后会导致哈希分母改变, 缓存命中会大幅度下降。 扩容场景与节点故障退出(相当于缩容)导致的哈希重新分配效果类似。 如果有一个节点频繁地进行“故障/恢复”状态切换, 整个集群可用节点数就会一直变化, 缓存命中率也会下降。

分布式缓存系统通常采用一致性哈希的方式,把键值用环形哈希表映射到机器节点上。 该算法能够自动平衡新加入机器节点“承接”的缓存数据。 每个老节点都迁移一小部分key给新节点。 对整体缓存命中影响较小。

更复杂的扩容机制, 例如通过主从(master*1 + slave*n)的方式组件集群, 由master指派缓存的key集合。 扩容时由master进行平滑的数据迁移。 此类key路由机制通常需要客户端与master配合实现, 或者使用proxy代理路由。

缓存系统的扩容一般选择在业务负载较低的时间段。 在负载峰值到来时,新扩容节点已经缓存了较多数据, 可以有效提供缓存命中。 缓存建立所需要的时间与业务行为密切相关。 某些业务场景, 几分钟内即可在新节点上达到较高的命中率。 一些数据量大、无明显热点的业务,则需要几天甚至几个月的时间才能达到合理的命中率。 需要根据不同的业务形态, 采取合理的运维策略。

3.4.  节点故障对全局的影响

分布式系统中,为了负载均衡, 通常选择把热点负载打散到不同的节点中。 如果热点负载是一个较大的文件, 则可能会被切片分散存放在不同的节点中。 一个节点可能包含资源的一个片段。 单节点故障可能导致大量资源的片段丢失。 从而引发大量资源的完整性损失。

在对单个完备资源进行“切片”时,需要控制存储的节点分散程度。 资源切片在系统中分布越均衡,负载均衡表现越好,但单节点故障时的影响面就越大。 针对某些业务场景的热点资源, 也可以采用copy出多副本的冗余数据方式, 以空间换稳定性。

在进行分布式系统扩容、缩容时, 也有相似的问题。

4.    成本和复杂度权衡

4.1.  采用只读缓存

为了追求简单可靠,这些系统通常要求“数据只读”, 即数据不可编辑。 如果要修改数据, 则要求生成另一个文件名。 数据编辑的能力是通过业务包装实现, 缓存系统中不支持数据变更。

在大型磁盘缓存系统中, 数据编辑会带来大量的问题: 数据size变大,必须重新分配磁盘存储位置, 并且数据索引要联动修改。同时导致文件后半段数据移位。旧数据存储空间由于零散,回收利用价值太低。

4.2.  业务容忍度

在合理的业务使用场景下, 能容忍数据错误(仅限于某些类型的错误),并进行适当容错处理。 需要客户端配合,进行简单数据校验+重试。 缓存系统在业务能容忍的范围内降低成本。

4.3.  命中率和缓存淘汰算法

一个缓存系统总是期望用户要的数据,本地都有缓存。 即缓存命中率达到100%。 实际缓存系统根据业务不同,命中率差别很大。

按照业务请求次数、IO次数、数据量、带宽等维度, 命中率有很多计算方法。 命中率的真正意义仍然在于“成本”, 都是钱的问题。 缓存淘汰算法需要结合缓存成本设计。

如果系统的成本是按照“网络带宽峰值”计算的, 我们希望在高峰时间段、大部分数据访问尽可能命中。 注意两个维度:时间段、数据量。 对于空闲时间段、小数据的访问次数并不关心。 这使得缓存淘汰算法要跟“时间段”有关系。 在空闲时间段, 尽可能为“峰值时间段”准备数据。 空闲时间段可以认为是“免费的”。

4.4.  缓存系统服务质量

延时:

从请求到获得第一个字节数据的时间间隔。 通过把“第一个字节”的数据刻意部署到低延时的存储介质中,这个指标可以获得大幅度的优化。

速率:

持续一段时间内读取缓存数据的速度。 数据下载速率通常和网络、存储介质、数据在介质中分布有关。 对于磁盘存储介质, 数据碎片化会导致严重的读取速率下降,并且占用磁盘IO性能。因此缓存系统的设计也需要考虑存储空间的分布。

质量分布:

平均值是描述服务质量的一个重要指标。 但网友常讲一个笑话:“我和MaYun的平均收入是x亿”。 在评估一个系统质量时,我们还需要看质量的分布情况。 常见的有中位数、8分位等等。

服务质量分布与很多因素有关, 诸如客户的网络带宽分布、数据冷热程度分布、命中率分布、时间段、IO响应时间分布等。 质量分布的优化也是缓存系统运营的一个重要课题。 

posted @ 2019-08-23 11:16  华为云官方博客  阅读(306)  评论(0编辑  收藏  举报