分布式系统之缓存的微观应用经验谈(三)【数据分片和集群篇】
前言
近几个月一直在忙些琐事,几乎年后都没怎么闲过。忙忙碌碌中就进入了2018年的秋天了,不得不感叹时间总是如白驹过隙,也不知道收获了什么和失去了什么。最近稍微休息,买了两本与技术无关的书,其一是 Yann Martel 写的《The High Mountains of Portugal》(葡萄牙的高山),发现阅读此书是需要一些耐心的,对人生暗喻很深,也有足够的留白,有兴趣的朋友可以细品下。好了,下面回归正题,尝试写写工作中缓存技术相关的一些实战经验和思考。
正文
在分布式Web程序设计中,解决高并发以及内部解耦的关键技术离不开缓存和队列,而缓存角色类似计算机硬件中CPU的各级缓存。如今的业务规模稍大的互联网项目,即使在最初beta版的开发上,都会进行预留设计。但是在诸多应用场景里,也带来了某些高成本的技术问题,需要细致权衡。本系列主要围绕分布式系统中服务端缓存相关技术,也会结合朋友间的探讨提及自己的思考细节。文中若有不妥之处,恳请指正。
为了方便独立成文,原谅在内容排版上的一点点个人强迫症。
第三篇这里尝试谈谈缓存的数据分片(Sharding)以及集群(Cluster)相关方案(具体应用依然以Redis 举例)
一、先分析缓存数据的分片(Sharding)
(注:由于目前个人工作中大多数情况应用的是Redis 3.x,以下若有特性关联,均是以此作为参照说明。)
缓存在很多时候同 RDBMS类似,解决数据的分布式存储的基础理念就是把整个数据集按照一定的规则(切分算法)映射到多个节点(node)中,每个 node负责处理整体数据的一个子集。给缓存作 Sharding 设计,围绕基础数据的存储、通信、数据复制和整合查询等,很多时候比较类似 RDBMS中的水平分区(Horizontal Partitioning),事实上很多点在底层原理上是保持一致的。在缓存的分区策略中,最常见的是基于哈希的各种算法。
1.1 基于 Round Robin
实现思路是以缓存条目的标识进行哈希取余,如对缓存中的 Key 进行 Hash计算,然后将结果 R 与 node 个数 N 进行取余,即 R%N 用来指定数据归属到的 node索引。
个人认为在数据量比较固定并有一定规律的场景下,则可以考虑基于这种方式的设计。在落地实践前需要注意,这种方案看起来简洁高效,但却无法良好的解决 node的弹性伸缩问题,比如数量 N发生变化时均需要重新覆盖计算,存储的数据几乎是重新迁移甚至重置,对数据的支撑本身会有些勉强和局限。另外,早期使用手动进行预分区,后来增加了一些相应的路由策略可进行翻倍扩容等,都可考虑作为实践场景中的某些细小优化和辅助。
1.2 基于 Consistent Hashing
实现思路是将 node 进行串联组合形成一个 Hash环,每个 node 均被分配一个 token作为 sign,sign取值范围对应 Hash结果区间,即通过Hash计算时,每个 node都会在环上拥有一个独一无二的位置,此时将缓存中的Key做基本Hash计算,根据计算结果放置在就近的 node 上,且 node 的 sign 大于等于该计算结果。
这种策略的优势体现在,当数据更迭较大,动态调整node(增加/删除)只影响整个 Hash环中个别相邻的 node,而对其他 node则无需作任何操作。也就是说,当数据发生大量变动时,可以有效将影响控制在局部区间内,避免了不必要的过多数据迁移,这在缓存的条目非常多、部署的 node也较多的时候,可以有效形成一个真正意义上的分布式均衡。但在架构落地之前,这种方案除了必要的对 node 调整时间的额外控制,还需要权衡下缓存数据的体量与 node 的数量进行比较后的密集度,当这个比值过大时,数据影响也自然过大,这个就是不符合设计初衷的,不仅没有得到应有的优化,反而增加了一定的技术成本。
1.3 基于 Range Partitioning
实现思路实际是一种增加类似中间件的包装思想,首先将所有的缓存数据统一划分到多个自定义区间(Range),然后将这些 Range逐一绑定到关联服务的各个 node中,每个 Range将在数据变化时进行相应调整以达到均衡负载。
这其实并非是一个完全新颖的策略,但针对大数据的划分和交互做了更多的考虑。以 Redis的 Sharding算法类比,截止目前的集群方案(Redis Cluster,以3.x举例)中,其策略同样包含了 Range这一元素概念。Redis采用虚拟槽(slot)来标记,数据合计 16384个 slot。缓存数据根据 key进行 Hash归类到各个 node绑定的 slot。当动态伸缩 node时,针对 slot做相应的分配,即间接对数据作迁移。
这种上层的包装,虽然极大地方便了集群的关注点和线性扩展,在目前落地方案里 Redis也已经尽力扩展完善,但依然还是处于半自动状态。集群是分布的 N个 node,如需要伸展为 N+2个node,那么可以手动或者结合其他辅助框架给每个 node进行划分和调整,一般均衡数约为 16384 /(N+2) slot。同样的,Cluster 的Sharding 在实际落地之前,也需要注意到其不适合的场景,并根据实际数据体量和 QPS瓶颈来合理扩展node,同时处理事务型的应用、统计查询等,对比单机自然是效率较低,这时候可能需要权衡规避过多的扩展,越多从来都不代表越稳定或者说性能越高。
二、谈谈缓存的集群实践与相关细节
2.1 提下集群的流程
我在之前一篇文章里,主要围绕主从和高可用进行了一些讨论(主从和主备高可用篇:https://www.cnblogs.com/bsfz/p/9769503.html),要提出的是 Master-Slave 结构上来说同样算是一种集群的表现形式,而在 Redis Cluster 方案里,则更侧重分布式数据分片集群,性能上能规避一些冗余数据的内存浪费以及木桶效应,并同时具备类似 Sentinel机制的 HA和 Failover等特性(但要注意并非完全替换)。
这里同样涉及到运维、架构 、开发等相关,但个人依然侧重于针对架构和开发来做一些讨论。 当然,涉及架构中对网络I/O、CPU的负载,以及某些场景下的磁盘I/O的代价等问题的权衡,其实大体都是相通的,部分可参见之前文章中的具体阐述。
简单说在集群模式 Redis Cluster 下, node 的角色分为 Master node 和 Slave node,明面上不存在第三角色。 Master node 将被分配一定范围的 slots 作为数据 Sharding 的承载,而 Slave node 则主要负责数据的复制(相关交互细节可参照上一篇) 并进行出现故障时半自动完成故障转移(HA的实现)。node之间的通信依赖一个相对完善的去中心化的高容错协议Gossip, 当扩展node、node不可达、node升级、 slots修改等时, 内部需要经过一段较短时间的反复ping/pong消息通信, 并最终达到集群状态同步一致。
以 Redis 3.x 举例,假定一共 10 个 node,Master / Slave = 5 / 5,执行基础握手指令 " cluster meet [ip port] "后,就能很快在 cluster nodes里看到相应会话日志信息,在这个基础上,再给每个node 添加指定 Range 的 lots( addslots指令),就基本完成了 Redis Cluster的基础构建。(当然,如果是侧重运维,一般你可以手动自定义配置,也可以使用 redis-trib.rb来辅助操作,这里不讨论)。
2.2 Redis Cluster的部分限制
Redis node的拓扑结构设计,目前只能采用单层拓扑,即不可直接进行树状延伸扩展node,注意这里是不同于 Redis Mater-Slave 基本模式的,然后记得在上一篇也提到了本人迄今为止也并未有机会在项目中使用,也是作为备用。
对于原有 DB空间的划分基本等同取消,这个有在第一篇设计细节话题中提到过,并且 Redis Cluster 模式下只能默认使用第一个DB, 即索引为0 标识的库。
假如存在大数据表 “Table”,例如 hash、list 等,是不可以直接采用更细粒度的操作来 Sharding 的,即使强制分散到不同 node 中,也会造成 slot 的覆盖错误。
缓存数据的批量操作无法充分支持,如 mset / mget 并不能直接操作到所有对应 key中,除非是具有相同 slot。 这是由于 Sharding机制原理决定的,举一反三,若是存在事务操作,也存在相关的限制(另外稍微注意,截止目前,不同node 本身也是无法事务关联的)。
2.3 Redis Cluster的相关细节考虑
对于 Redis node 的数量 N 理论上需要保持偶数台,一般不少于6个才能保证组成一个闭环的高可用的集群,这也意味着理想状态至少需要6台服务器来承载,但这里个人在架构设计中,往往场景不是很敏感,那么将设计为 N/2台服务器分布,目的是兼顾成本以及折中照顾到主从之间的 HA机制(HA相关可以参照上一篇里提到的部分延伸,这里尽量避免重复性讨论)。 这里要稍微注意的是,对于机器的配对,尽量保证不要在同一台机器上配置过多的Master,否则会严重影响选举,甚至failover被直接拒绝,无法重建。
对于 node 之间的消息交互,每次发送的数据均包含 slot数据和集群基础状态,node越多分发的数据也几近是倍增,这个在上面 Sharding 算法里也表明,那么一方面可以针对 node数量进行控制,另一个则是设置合理的消息发送频率,比如在主要配置 cluster-node-timeout上,适当由默认15秒递增 5/10 秒。但是过度调大 cluster_node_timeout相关设置一定会影响到消息交换的实时性,所以我认为这里可以尝试微调,在大多数本身比较均匀分布数据的场景下适当放宽,这样不会对node检测和选举产生较大影响,同时也间接节约了一定网络IO。
对于数据增长粒度较大的场景,优先控制集群的 node数量,否则同样避免不了一个比较大头的用户指令消耗 和 Gossip维护消息开销(集群内所有 node的ping/pong消息),官方早前就建议控制集群规模不是没有道理的。个人认为真到了需要的场景,必须主动作减法,缩减node数,取而代之使用小的集群来分散业务,而且这也有利于更精确的控制风险和针对性优化。
对于集群的伸缩,如项目中应用较多操作的一般是扩容场景,增加新的node 建议跟集群内的已有 node 配置保持一致,并且在完成 cluster meet 后,需要合理控制划分出的 slot 数,一般没有特殊要求,应该都是均匀化。额外要稍微注意的是,新的 node 务必保证是一个干净的node,否则会造成不必要的拓扑错误(这种是可能会导致数据分布复制严重错乱的),当然新增 node这里也可以借助 redis-trib.rb 或者其他第三方包装的方案来辅助操作。
对于不在当前 node 的键指令查询,默认是只回复重定向转移响应(redirect / moved)给到调用的客户端(这里特指应用程序端),并不负责转发。这是跟单机是完全不同的,所以即使是使用相关的第三方驱动库(比如JAVA的Jedis、和.Net的 StackExchange.Redis)完成程序端的封闭式控制,也仍旧需要权衡数据的热点分散是否足够集中在各自的node中等细节。当然,假如是 hashset等结构,由于Cluster本身的Sharding机制涉及到不可分散负载,倒是无需过多编码实现,也不用担心性能在这里的损耗。
结语
本篇先写到这里,下一篇会继续围绕相关主题尝试扩展阐述。
PS:由于个人能力和经验均有限,自己也在持续学习和实践,文中若有不妥之处,恳请指正。
个人目前备用地址:
【预留占位:分布式系统之缓存的微观应用经验谈(四)【交互场景篇】
End.