一、背景
携程Redis集群规模和数据规模在过去几年里快速增长,我们通过容器化解决了Redis集群快速部署的问题,并根据实际业务进行的一系列尝试,比如二次调度、自动化漂移等,在内存超分的情况下保证了宿主机的可靠性。
扩缩容方面,我们主要通过垂直扩缩容的方式解决Redis集群容量的问题,但随着集群规模扩大,这种方式逐渐遇到了瓶颈。一方面,单个Redis实例过大,会带来较大的运维风险和困难;另一方面,宿主机容量有上限,不能无止境的扩容。考虑到运维便利性和资源利用率的平衡,我们希望单个Redis实例的上限为15GB。
但实际操作中却很难做到:某些业务发展很快,经常性需要给Redis进行扩容,导致单个实例大小远超15GB;一些业务萎缩,实际使用量远低于初始申请的量,造成资源的浪费。
如何有效控制Redis实例大小呢?接下来本文将带着这个问题,逐步讲解携程Redis治理和扩缩容方面的演进历程。
二、Redis水平扩分拆
在携程开始使用Redis很长一段时间里,一直只有垂直扩缩容,原因有两点:
第一,一开始业务规模比较小,垂直扩缩容可以满足需求。垂直扩缩容对于Redis来说只是Maxmemory的配置更改,对业务透明。
第二,水平拆分/扩缩容的实现难度和成本较高。
之前文章《携程Redis治理演进之路》中已经提到,携程访问所有的Redis集群使用的是自主研发的CRedis,而部署在应用端的CRedis通过一致性hash来访问实际承载数据的Redis实例。但一致性hash是无法支持直接水平扩缩容的。因为无论增加一个节点或者删除一个节点,都会导致整个hash环的调整。
图1
如图所示,假设原始有4个分片(图1)。当添加一个节点后,它会导致某一部分的key本来是写到nodeC上而现在会被写到nodeE上,也就是无法命中之前的节点。从客户端的角度来看,key就像是丢失了。而变动的节点越多,key丢失的也越多,假设某个集群从10分片直接添加到20分片,它直接会导致50%的key丢失。删除一个节点同理,就不再赘述。
因此尽管一致性hash是个比较简单优秀的集群方案,但无法直接水平扩容一直困扰着运维和架构团队。为此,CRedis团队在2019年提出了水平拆分的方案。
CRedis水平分拆的思路比较朴素,因为在一致性hash同一个水平位置增加节点会导致数据丢失,那么不改变原来层次节点的hash规则,以某个节点为hash的起点,再来进行一次一致性hash,演变成树的结构(图2)。
图2
如上图所示,将树形结构从一层拓展成二层,如果继续拆分新的叶子Group,则可以将树形结构拓展到三层,拆分方案可以支持到十层。叶子 Group是物理分片,直接对应的 Redis 实例,分支 Group 是虚拟分片,当Hash 命中到分支 Group 后,并没有找不到对应的Redis实例,需要再继续向下寻找,直到找到叶子 Group 为止。
图3
CRedis水平分拆上线后,DBA将现存的绝大部分超过15G的实例都拆分成更小的实例,在一段时间内缓解了大内存实例的运维治理压力。但随着Redis规模的快速增长,不断有大的实例集群出现,此外CRedis水平分拆的缺点也逐渐暴露出来:
- 持续的周期很长,对多个 Group 进行拆分的话,每个Group的数据需要同时复制几份同样的实例。比如60G的某个实例(图3),如果想拆到5G一个,那么下级的Group必须有12个,而拆分要先将该实例的数据先同步为12个60G的实例,再根据key的命中规则清理该12个60G的实例中不会命中的key,最终演变成12个5G的实例。一般60G的group实例拆分需要3个小时-6个小时,如果一个集群的分片非常多,加上观察对业务影响的时间,可能要持续上几天或一两周,并且只能是有人值守的串行操作。
- 拆分过程中需要2次迁移,如上面所说的,拆分中中间态实例对于内存的要求是非常大的,拆分完成后对内存的需求会急剧下降,因此每次拆分都涉及到2次迁移,尽管迁移不会影响业务,但对于执行操作拆分的运维人员来说,心智负担比较大,而且一不小心也会导致线上事故。
- 拆分后无法还原回去,也就是说假设业务分拆后收缩,对Redis的需求变小了,但它实际拆分后的分片还在那边,所申请的空间还并没有释放掉,客观上浪费了资源,降低了Redis总体的利用率。
- 只支持扩容,不支持缩容,这点上面也提到了,除了一些集群过大需要分拆外,还有一些申请远超需求的实例需要缩容,而水平分拆对于这点无能为力。
- 拆分一次,就多一次的性能损耗,因为需要多计算一次hash,虽然耗时不大,但是对于性能敏感的业务还是有影响。
由此可见,水平分拆的方案虽然解决了实例过大的问题,但不能缩容的弊端也逐渐凸现了出来。尤其是在今年因疫情影响需要降本增效的背景下,一方面资源比充足,一方面宿主机上跑的都是无法缩容的实例。那么是否有更好的解决方案呢?答案是有的。
三、Redis水平扩缩容
1、设计思路
图4
既然缩分片比较困难,我们首先想到的是业务双写集群的方法,也就是业务同时双写2个新老集群,新老集群的分片数是不一样的,并且大小配置也不一样。比如之前申请4个分片现在发现资源过剩,让业务创新申请一个新的2个分片的集群,由业务来控制灰度写哪个集群(图4)。最终会迁移到新集群上,而新集群大小是满足当前业务需求的,从而达到了缩容的目的。
双写集群的方案虽然解决我们部分的问题,但对于业务的侵入比较深,此外由于双写集群引入了业务配合观察的时间,整体流程也比较长。所以,我们需要寻找更好的解决方案。
既然业务双写集群可以达到要求,基础设施如果代替业务做完这部分岂不是更好?借鉴业务双写集群的思路和云原生的不可变基础设施的理念,我们首先想到的是通过新集群替换老集群而不是原地修改集群;另外,为了在公有云上节省Redis成本,我们积累了kvrocks的实践经验,两者相结合,设计了一种高效的水平扩缩容的方案。
本方案的核心是引入了一个基于kvrocks改造的中间态binlogserver,它既是一个老集群的Slave节点,又充当了新集群的客户端。一方面,它会从Redis Master复制全量和增量数据;另一方面,它又充当客户端的角色,将复制来的数据按照新集群的一致性HASH规则写往新的集群。大致的步骤如下,具体的步骤流程可以参考下面的图所示(图5)。
- 根据当前V1集群的分片启动对应个数binlogserver,并获取V2集群的一致性HASH规则和group。
- 每个binlogserver成为V1集群单个分片中Master的Slave,执行salveof后保存V1中Master传过来的RDB文件并解析,对于每个RDB文件,解析还原成Redis命令,并按CRedis的一致性hash规则写入到V2中,对于后续V1集群传播过来的命令,同样同步到V2中。
- 当这个过程都完成并且binlog追的差不多的时候,为了数据一致性,可以停止V1的写(客户端报错)后由CRedis推送V2的配置或直接推送V2的配置(客户端不报错但数据可能会丢或不一致),APP端将会顺序切换到 V2上来;此过程对用户完全透明,应用端无需做任何操作。
图5
通过Redis的水平扩缩容方案,我们解决了之前的几个痛点问题:
- 持续时间大大缩短,基本上跟V1集群最大实例的大小正相关,因为是并发执行,跟集群分片数无关。根据实际的运维数据来看,集群单个实例为20G,集群扩缩容在10分钟之内完成,而低于10G的,5分钟即可完成,大大缩短了扩缩容的周期,并且业务在毫无感知的情况下即可完成扩缩容。由于可以做到秒级切换集群,即使扩缩容后对业务有影响也可以快速回退,因为回退也只是更改了集群的路由指向。
- 扩缩容过程只需要1次切换集群指向,0次迁移,没有中间态,也无需通过大内存宿主机来实现分拆。
- 对于扩容的集群,很方便再来一次缩容还原回去,缩容同理。对于那些已经水平拆分过的集群,也可以通过这种方式还原回去。
- 既可以扩容也可以缩容,甚至还可以不扩容也不缩容按集群来迁移,比如《携程Cilium+BGP云原生网络实践》一文中提到的云原生网络安全控制试点项目。由于原来Redis集群下面的实例可能同时部署在openstack网络和cilium网络,但云原生安全只能控制cilium网络下的实例,这种情况下就需要迁移Redis实例。如果按之前的运维方式,要按分片来一组组迁移,整个工程可能持续较长时间,并且耗费较多人力,而水平扩缩容可以将一个集群一次性快速迁移到cilium网络,省时省力。
- 扩缩容后无性能损耗。
2、运维数据
水平扩缩容方案上线4个月来,已经成功完成了200多次的扩容和缩容。今年某个业务突然请求量暴增十几倍,相关集群经历了多次扩容,每次扩容大多在10分钟内完成,有效地支撑了业务发展。
另一方面,针对申请分片非常多而大但实际使用量非常小的集群,我们也借助水平扩缩容的能力快速地缩小了分片数和申请量。通过这些缩容,有效地提升了整体的资源利用率。
3、一些坑
(1)单个key过大导致key被驱逐
在实际水平扩缩容过程中,我们发现有些集群,单个实例中可能会有巨大的key(大于3G),由于V2集群的大小是根据V1大小实时算出来的平均值,一旦V1中某个实例过大,可能会导致写到V2中的某个实例大小大于预期的平均值,从而引起某些key被驱逐。因此,针对这种情况:
- 加强大key的检测逻辑,对于超过512M的key会有告警邮件告知所有者。
- V2中所有实例的maxmemory在分拆之前不设置限制,统一都调到60G,防止V2中key分配不均导致key驱逐。
- 水平扩缩容后,在V1和V2切换过程中,检测V2中的实例是否发生过驱逐,如果有则默认分拆失败,不进行切换。
(2)mget扩容后会导致性能下降
对于极个别的场景,我们还发现,mget请求耗时会有明显上升,主要原因还是在于,扩容之前mget需要访问的实例数少,而分拆后访问的实例数变多。一般这种情况,我们建议业务控制单次mget的key的数量,或者将string类型改造为hash类型,通过hmget来访问数据,保证每次只会访问到一个实例,这样扩容后其吞吐量是随着分片数量线性增加,而延迟不会有增加。
四、总结和未来规划
1、Xpipe支持
目前水平扩缩容和漂移以及二次调度等一系列治理工具和策略组成了一个比较完善的闭环,有效地支撑了生产几千台宿主机,几万带超分能力Redis实例的运维治理。
但目前受制于xpipe的架构,对于接入了xpipe的集群,必须先扩缩容后再将DR端的xpipe人工补齐,自动化程度还不足,而补齐xpipe的时间比较长,比如之前是就近读本机房的Redis集群的APP,在扩缩容后可能一段时间里只能跨机房读取,必然导致延迟上升。而这种延迟上升又会影响我们对于水平扩缩容逻辑是否正确,是否需要回退的判断。因此后续我们会针对xpipe集群,也做到和普通集群一样,也就是V2集群在扩缩容写流量之前就是带DR架构的集群。
2、持久化KV存储的支持
除了Redis本身受业务欢迎使用广泛外,我们还发现有些业务需要相比Redis 更可靠的KV存储方式,比如数据保存在磁盘上而不是保存在内存里,再比如业务需要支持一些增减库存逻辑,对某个key的独占访问,实现语义近似INCRBY操作,但实际上是对于一些字符串进行merge操作。此外数据可靠性要求更高,master宕机不能丢失数据等。针对这些需求目前我们已经也有一些实践经验,将在后续文章中分享。