低成本亿级流量分布式本地缓存一致性方案(设计篇)
声明:为力求内容的准确性,为大家提供更优质的技术内容。如果您发现文章内容中任何不准确或遗漏的部分。非常希望您能评论指正,我将尽快修正疏漏。
此前我们发布的两篇文章缓存与主副本数据一致性系统设计方案(上篇)与缓存与主副本数据一致性系统设计方案(下篇)。讲解了如何在系统设计中选择缓存与主副本数据一致性处理模式以及如何正确的实现相应模式。其中提到的问题与解决方案基本可以覆盖各规模研发团队 90% 的业务场景。
采用缓存提升系统性能,提升系统并发度,是我们在系统设计角度下通常会想到的应用场景。在普遍降本增效的大环境下,服务器成本也逐步成为了我们选择为系统添加缓存的参考因素。
缓存用的好,降本又增效
我们以云厂商两类产品的定价做为成本参考,如下面两张图是国内头部云厂商的产品基础版本的价格截图(截取自 24 年 12 月 03 日)。第一张图为关系型数据库 MySQL 版本价格,选择高可用系列标准版 4C8G 以及 50GB 存储容量,付费方式为包年包月制一年固定费用为 7358.40 元。第二张图为缓存产品 Redis 开源版本价格,选择标准版 4GB 主从双副本,付费方式依然为包年包月制一年固定费用为 2448.00 元。
以上配置对于系统设计来讲,属于提供基本高可用保障的规格,其中关系型数据库的固定成本约为缓存产品的 3 倍。你可能会觉得这个价格表对比不公平,缓存的容量与数据库差距过大,所以成本才如此显著。当然如果想达到与数据库一致的存储容量,使用缓存的成本也将会是数倍于数据库。但是在实际业务场景下,会被高频访问与使用的数据可能不足总数据量的 20% 。
如果你所负责的系统属于基础系统,也是存在绝大部分数据存在高频高并发的访问场景。通常为了防止缓存穿透等问题,我们在部署架构上也会增加数据库节点,来承载这些以外流量,防止节点负载过高引发的系统问题。如果仅仅依靠扩展 MySQL 节点来承载系统负载,那么成本开销无疑也是巨大的。
此前本人整理的Shopee 百亿级商品数据如何平稳实现千万级服务器成本缩减一文,便介绍了 Shopee 研发团队如何通过提升缓存命中率进而提高缓存利用率,促使数据库节点数量下降进而降低成本的案例。所以,如果你所在的团队面临控制硬件成本资源的挑战时,不妨考虑一下采用缓存的来缩减数据库的方案。同时还会带来系统性能的提升,可谓降本增效的典范。
当然缓存所能够承载的系统负载也是有限的,随着流量的增加缓存服务器的成本也会随之增加。为了应对此类情况,我们又可以采取应用本地缓存的方案,来降低缓存服务的负载。因为应用服务的成本通常价格相对较低,所以也是进一步控制成本的选择。
如下图所示为国内头部云厂商的轻量应用服务器的产品价格(截取自 24 年 12 月 03 日),即便按照官方折扣价一台 2 核 4G 的应用服务器一年的固定成本也仅需 816 元,上图同等价位的缓存服务可以组一个 3 台应用服务的集群。同样在真实环境下,50% 以上的应用服务其内存利用率都是不高的。长期为数据分配几十,甚至上百 MB 的空间是可能的,这不仅可以降低缓存系统的负载,还可以进一步带来系统性能的提升。
本地缓存的数据一致性问题
为系统引入本地缓存,又会带来颇具挑战的数据一致性问题。而为了解决数据一致性问题所带来的工程复杂度,常常让我们忽视掉其经济价值。其实在真实场景下,绝大多数系统中短时数据一致性问题都是可以容忍的。
在Shopee 百亿级商品数据如何平稳实现千万级服务器成本缩减一文中“多级缓存压缩缓存成本”章节中提到 Shopee 研发团队结合自身实际场景,采用了一个颇具性价比的方案来实现本地缓存。大致思路为通过指定时间窗口内数据访问阈值以及本地缓存短 TTL 的方式实现本地缓存的使用。
可参考下图辅助理解,应用服务针对每个缓存记录维护一个计数器,起初请求穿过应用服务查询缓存,当单位时间内请求数量达到阈值(如:100QPS),便从缓存中加载数据至本地缓存,此后单位时间内(如:1S,S - 秒)请求查询本地缓存,本地缓存失效后查询缓存系统并重启计数。当然也可以在查询本地缓存时重启计数,当本地缓存失效时,如果请求依然达到阈值,再次加载数据至本地缓存。
上述方案在极端情况下,本地缓存与缓存系统的数据不一致的持续时间也不超过 TTL ,如上面的案例将会小于 1S。考虑到应用服务的数量,以及每个应用服务的时钟不同,数据不一致的情况,在总流量中占比会被进一步降低。
如果依赖该服务的应用,其后续逻辑均为查询类,不会产生对已经发生业务逻辑的变更或影响,那么上述方案的短时数据不一致便是可接受的。同时考虑到大访问量的数据同时修改的几率并不高。所以,在绝大多数场景下,采取上述方案实现本地缓存是经济高效的,工程负载度不高且易于理解。
假设我们的服务中数据不一致,会对依赖服务中已发生的业务逻辑产生影响,那么这种情况下显然无法接受这种数据不一致。尤其是当我们的服务处于流量的下游,系统的底层。那么这种短时数据不一致,可能会被逐级放大,进而造成不良影响。
坦诚地讲,上面描述的业务场景并不多见,但也确实存在。假设该系统负责业务属性配置,如业务规则的开关等。当采用上述本地缓存方案时可能存在影响放大所带来的问题。那么针对此类场景,我们是否有相对合适且具备一定性价比的解决方案呢?
解决方案
如果你是一名具有一定经验的研发工程师,此时可能会想到引入 ZooKeeper 一类的配置或注册中心,通过其 Watch 机制来实现较强的一致性。
但是,伴随着解决问题而引入的外部基础设施,除了可以方便的解决技术问题以外,都会为系统带来额外的复杂度与服务器成本,以及运维挑战。在过去业务高速发展,企业未来预期较好的情况下,都是可以忽略的因素,我们会将考量的比重更多的倾斜到方便高效快捷上。但是在逐渐成本至上的当下,我们在系统设计阶段的 trade off 可能也要随之改变。
那么我们是否有一个简单易行,且无需额外硬件成本的设计方案呢?接下来我们提供的方案以满足通用普惠为主,尽可能做到技术中立,不依赖于特定的中间件等基础设施。
主从架构模式
我们此前在主从架构(Coordinator-Worker Architecture)综述一文中,提到主从架构是分布式系统中最简单的架构模式。系统可以通过主节点对从节点的管控形成外部视角下一个实体的行为表现。
主从架构(Coordinator-Worker Architecture)是一种分布式系统的计算模型,其中至少有一个节点充当主节点(Coordinator),其余节点充当从节点(Worker)。主节点对从节点实施管控,从节点负责任务的计算与执行,并存储结果等。
我们的设计方案便基于主从架构,以一个主节点来控制所有应用服务本地缓存变更,进而实现更好的数据一致性保障。结合下方图示,以及面临的主要问题,我们将详细讲解一下设计方案。讲解会围绕着图中表示数字的三类动线展开,在开始之前,我们先为系统确认一下主从关系。
在整个系统中,首先我们需要对应用服务进行角色划分。如果你的应用系统采取 CQRS 模式,并且在部署架构上也做到了分离,那么筛选出主节点(Coordinator)并不困难。如果你的系统方案中对于缓存系统与数据库的更新,采取了我们在缓存与主副本数据一致性系统设计方案(下篇)一文中介绍的提取 Batch 组件模式,那么可以用 Batch 组件节点担任主节点。否则的话,建议独立出一个扮演主节点角色的实例与组件,这会显著降低系统设计与实现复杂度。
实现通知机制
一旦主节点与从节点确立后,因为需要主节点通知所有从节点,更新并生效本地缓存。所以,接下来我们需要考虑的便是主从节点之间的通讯。对应上图中标识为 1 的动线。
谈及这个背景,多数人会想到利用消息队列等 Pub/Sub 组件,让充当从节点的应用服务订阅主节点的广播消息来实现通知。但是基于此类中间件基础设施的消息通常是单向的,且可能不支持广播消息(毕竟每个研发团队所面临的技术环境是不同的,我们的设计不能假设基础设施的能力)。所以这显然无法满足一个通用方案下的决策选择。
相对简单的实现可以基于 HTTP 协议建立从节点到主节点的 Heartbeat 连接。这里选择由从节点向主节点发送 Heartbeat 请求,主要考量到网络拓扑结构简单,面对需要开通节点间网络权限的运维环境,开通固定 IP 权限可以降低运维复杂度,同时对于从节点的横向扩展也更容易。此外,实现相对简单,如果从节点数量较大(如过百台节点)也可以通过异步机制进行应对。
识别热点数据
针对热点数据的识别,可以尝试依赖缓存系统提供的能力(如 Redis 的 --hotkeys),由主节点定时轮询缓存系统捕获热点数据。因为我们通过从节点的 Heartbeat 与主节点建立连接,可以在 Heartbeat 过程中上送从节点对各项数据记录的访问监控,由主节点汇总运算确定热点数据。
但是我们要讨论的问题并非是对热点数据的识别,而是,是否有必要由主节点识别热点数据,确保所有从节点均加载热点数据至本地缓存。
如果你的应用在部署结构上,存在不同分组面向不同下游应用服务(针对依赖维度)。那么所有从节点持有一致的本地缓存则是没有必要,甚至是错误的。如果一条数据记录成为热点,服务负载均衡正常的情况下,所有从节点应用服务应该有相对均等的请求量。那么最简便的方式还是由从节点根据单位时间访问阈值判断是否加载到本地缓存。
主节点需要控制的并不是热点数据加载的时机,而是当数据变更时,通知各从服务器将自身本地缓存中过期数据替换掉。那么你可能会有疑问,按照从节点自身判断热点数据,是否存在主节点通知变更的数据不在从节点本地缓存时要如何处理?这部分内容我们在下面 “应对节点恢复” 章节统一阐述。
二阶段提交协议
该方案的算法基础依赖于二阶段提交协议,二阶段提交协议不但是一种原子提交协议,同时做为一种分布式算法,可以协调所有参与分布式原子事务的进程,以决定是否提交或终止(回滚)该事务。该协议即使在许多临时系统故障的情况下也能实现其目标,因此被广泛使用。即便是在我们的应用系统中使用的各类柔性事物也好,还是提供强一致性共识算法也好,二阶段提交协议都是其基础组成部分。
结合下图我们将该方案中对二阶段提交协议的使用做详尽说明。
当主节点识别热点数据后,通过向所有从节点发送 Prepare 类请求,要求各从节点加载缓存系统中的指定数据到本地缓存中;
各从节点完成数据加载以及加工操作后,此时本地缓存中指定记录包含两条数据,其中新数据为 ready 状态,如图中数据记录 record_1 所示,不可以被访问。各节点完成操作后响应主节点的 Prepare 请求;
当主节点收到所有从节点对于 Prepare 的成功响应后,发送 Commit 请求,触发从节点切换数据,将原有记录删除,并去除新数据的 ready 状态;
当存在从节点未成功响应 Prepare 请求时,在我们这个场景下,不需要执行终止回滚操作,因为数据的变更已是既定事实。我们只能尝试针对从节点进行重试、告警或主动下线等方式使得系统达成一致的状态。具体内容将在后面应对节点异常部分进行详细讨论。
应对节点异常
在上文中,我们提到这套设计方案中并不需要回滚类操作,因为数据的变更已是既定事实,我们需要做的就是促使系统达成一致。因各种原因导致的节点异常,使得主节点未能成功发送 Commit 请求,或者从节点未收到 Commit 请求。都属于节点异常情况,接下来我们将分开讨论。
主节点未发送 Commit 请求
主节点未发生 Commit 请求的状态,存在以下两种情况,均会导致主节点停止后续操作。
向从节点发送 Prepare 请求存在未响应的节点;
向从节点发送 Prepare 请求存在响应失败的节点;
对于未能收到或正确处理 Prepare 请求的从节点,无需执行其他操作。此时主节点需要在短时间内对故障从节点进行发起重试,在指定时间内未能重试成功,可以通过触发系统告警,运维介入处理故障节点。如果团队中基础实施能力完备,拥有良好的服务治理平台,可以在报警的基础上依托服务治理平台能力对故障节点进行下线处理。在故障节点处理完毕后可向其余从节点继续发送 Commit 请求,完成后续操作。
对于收到 Prepare 请求并成功执行的从节点,属于未收到 Commit 请求的状态。我们合并到下方的问题一起解决。
从节点未收到 Commit 请求
除了上文中提到的情况外,还有一种情况是从节点在成功响应 Prepare 操作后由于网络原因,导致未能收到主节点的 Commit 请求。而系统内其他从节点已经收到 Commit 请求。针对这种情况主节点需要通过系统告警等手段,通知运维介入排除。
面对这两种情况,一种是所有从节点均未收到 Commit 请求,另一种是存在从节点未收到 Commit 请求,而部分从节目收到 Commit 请求。对于收到 Commit 请求的从节点执行后续逻辑,进行本地缓存数据的替换。
而未收到 Commit 请求的从节点,自身无法判断属于那种情况。除了上面提到的告警运维介入以外,系统可以自恢复以达成数据一致性吗?
此时从节点在收到针对本地缓存记录的查询请求时,可以借助已集成的 RPC 组件,向其他从节点发起问讯。询问其他从节点该条数据的状态,并与本地进行比较。如果其他从节点中存在已切换数据的情况,则执行本地缓存数据替换。如果其他从节点均未进行切换,则保持现状返回历史值,下次收到查询请求时继续询问其他从节点。
这里可以基于系统性能要求与数据一致性要求,并结合 RPC 组件的能力,确定询问其他从节点的数量。==询问其他从节点数量超过半数的一致性效果最好,但需要 RPC 组件能力的支持以及对响应性能的妥协==。但至少要成功询问一个其他从节点。
如果 RPC 组件能力不足的情况下,又有较强的数据一致性要求,那么我们可以基于 HTTP 实现对半数以上从节点的问询。如果服务治理平台能力充足,我们可以从服务治理平台获取到所有从节点信息,否则可以利用与主节点的 Heartbeat 获取从节点 IP 信息。
如果我们的应用服务集群数量庞大,为了满足数据一致性的要求,又不希望过分影响请求响应时间。可以尝试为节点分组,询问不同分组下的一个从节点的数据状态,只要请求分组数量达到半数即可。分组可以基于部署的集群的机架、网络分区来划分,可以更好的规避网络抖动等异常造成的访问问题。
应对节点恢复
节点恢复包含多种情况,一种是从节点重启,或者集群中追加新的节点。因为我们上文中提到数据是否加载到本地缓存的判断由从节点自主决定,那么就存在本地缓存没有指定数据记录的情况。以上三者因为本地缓存中均没有指定数据记录,所以可以视为同一种场景,可采用同一种处理方式。针对本地缓存因淘汰机制而清除数据的情况,亦可视为同一场景。
我们针对二阶段提交协议的中间态进行讲解,所谓的中间态也就是 Prepare 完成的阶段。如下图所示,其中存在一个本地缓存没有 record_1 的节点,无论其是新加入集群、亦或重启节点,都会在与主节点通信的过程中,收到集群内 Prepare 的数据记录。
当该节点收到对 record_1 的查询请求时,因为 record_1 在集群中处于 Prepare 阶段,那么便存在可能存储旧数据的从节点,如果要保障一致性,该节点需要向其他从节点问询 record_1 的值,并返回其他从节点的数据;
此时主节点向集群内从节点 “广播” Commit 操作后,因数据访问未达阈值,该节点本地缓存中依然没有对应数据,接收 Commit 操作后只需清除 Prepare 数据记录,直接向缓存系统发起查询请求即可。
对于未能收到或正确处理 Prepare 请求的从节点的恢复,如果不是系统重启(网络短时故障),仅做节点 “摘除” 的逻辑动作。那么本地缓存中可能留有旧数据,同时主节点已经完成 Commit 操作的话,这部分旧数据便会使系统整体的数据一致性无法保障。最容易的处理办法是采取物理意义上的 “摘除” 并重启节点,重新加入集群。将这一系列操作变成一项固定的运维操作指令。
总结
最后,采用本地缓存并不一定要有缓存系统,如果我们所负责的系统整体流量不大的情况下,可以通过增加本地缓存,而非缓存系统的方式,即降低了数据库负载的同时也节省部分缓存系统的成本。
文章至此,我们完成了该方案的设计阶段,实际上落地上述方案的复杂度并不低,编码工作也较多。需要应用到实际中的场景非常少,更像是面试中的 follow up 环节。所以具体是否采用该方案还需要结合实际,以及投入产出比来决策。如无必要,还是建议通过指定时间窗口内数据访问阈值以及本地缓存短 TTL 的方式实现本地缓存的使用。
感谢你的阅读和时间,如果你对实现细节感兴趣,欢迎留言告诉我。根据大家的需求反应,我们会考虑是否再出一个实现篇,并提供基于 Java 技术栈的示例工程,并结合代码对落地细节与问题进行分析与讨论。
关联阅读
你好,我是 HAibiiin,一名探索独立开发的 Product Engineer,你可以关注【公众号:单核生悟】与我交流,一起探索技术之外的更多可能。
本文为架构与系统设计系列文章。系统设计能力对于许多从业人员来讲相对薄弱,一方面缺乏实际经验的锻炼,另一方面简中互联网缺乏同类型内容。当你背了很多八股,刷了许多算法,但是面对系统设计却无从下手。当你迈过了中高级工程师的门槛,却苦于简中互联网技术内容在深度与实用性上的匮乏,而迷失了成长方向时。希望我的系列文章能给你帮助。
版权声明: 本文为 InfoQ 作者【HAibiiin】的原创文章。
原文链接:【http://xie.infoq.cn/article/dd85b4824acf79acdc5076b4a】。文章转载请联系作者。
评论