一、背景
过去几年,携程技术保障部门在Redis治理方面做了很多工作,解决了运营上的问题,在私有云上也积累了丰富的经验。后又通过引入Kvrocks,在公有云上实现降本增效的目的,从而支撑了公司的国际化战略。
与此同时,国内业务部门存在降低基础建设成本的客观需要,有些业务方期望提供一种非传统关系数据库来解决某些高性能海量空间的业务需求,并在此基础上支持特殊定制化以面对后疫情时代的挑战。这些变化使我们开始思考,是不是可以参考公有云上的思路,在私有云上构建一种持久化数据库,来满足业务方对高性能、低成本、海量、持久化的需求。
二、面对的问题
回顾之前在公有云上的方案,目的明确。因为公有云的内存较贵,我们将Redis的数据存在SSD上来降低成本,选型了Kvrocks,并自研实现支持Redis的复制协议,将公有云上的成本降低了60%(图1)。
图1
随着业务发展和Redis集群的日益增长,需求更加多样化,需要在私有云上同样能有一种持久化的KV存储系统来提供服务,包括:
- KV存储和读写的场景,Redis能提供的存储上限过低,需要有大容量的KV存储系统;
- 数据持久化,而不是像Redis那样重启数据即丢失;
- 节约Redis的使用成本,毕竟私有云上的Redis集群非常庞大;
- 提供类似selectforudpate的语义来实现库存之类字段的扣减,而不是依赖外部的一些组件,比如分布式锁;
- 数据能提供相比Redis更高的一致性,比如支持同步复制。
我们仔细分析业务需求和业界可选的方案,以期望找到一种持久化的KV数据库,能兼容Redis满足大容量和成本降低的需求,而又不局限于Redis,能提供更多样化的能力来支撑业务的诉求。
三、调研和选择
我们调研了业界大部分的NoSQL/NewSQL数据库,主要考虑以下几个方面。
是否为业界主流
主流有两层含义:第一,是否流行,比如github上的star数,是否是顶级开源基金的项目,或是否有大厂背书;第二,其理念是否主流,如现在使用最广的关系型数据库MySQL,以及NewSQL TiDB,其相关概念如半同步复制,GTID,raft,计算存储分离等概念都比较深入人心。
是否有成熟的中间件
中间件成熟是非常重要的一种能力,一旦选择了一种不合适的数据库,中间件相关的路由,打点,监控,降级,熔断,DR切换等每一项都需要投入大量的人力物力来做,此外稳定的中间件也是需要长时间打磨才能被业务方信赖,如果能复用现有中间件的大部分能力,能节约大量人力物力。
集群运维治理配套是否完善
选择一种KV数据库,除了中间件外,治理相关的如集群扩容,缩容,实例的迁移,资源利用率等一样要考虑进来。无论哪种数据库,部署后的运维治理相关,能复用现有的能力最好,如果不能复用,需要考虑:扩容到10倍需要多久时间,是否可以缩容?是否好迁移,对业务透明?大规模部署后,资源利用率是否可以提升?
性能是否满足要求,是否支持10X的扩展
上面说的这几点,如果都满足,但性能不满足或者不支持10X扩展,那也将一票否决。性能也是重要考量的一块,希望找到一种性能优异的KV数据库。
是否可以二次开发,独立演进
对于携程这样体量或相似体量的公司来说,持久化KV的数据库大多有自研的或基于开源二次开发的数据库,比如美团的Cellar,饿了么的Tidis,360的pika等,我们同样需要选择一种易于二次开发或方便扩展的数据库,来开发自定义的特性支撑业务。
调研的过程受制于篇幅限制,不再一一展开,最终我们继续选择了Kvrocks来作为治理演进的对象,其他的NoSQL/NewSQL有各种不足,而Kvrocks受益于Redis运维治理的成熟,可以复用现有的大部分Redis中间件和运维治理的能力,在携程与Redis几乎无差异的部署/使用方式,当下无疑是最适合的一种持久化KV数据库。
四、从Kvrocks到TRocks
经过不断的开发迭代和使用,最终我们将新系统命名为TRocks(Trip+Kvrocks),作为携程自己的持久化KV数据库。相比于原来的Kvrocks,除了与Redis可以互通协议互为主从外,主要是基于以下几个方面的改进。
1、功能增强
独占锁
一些业务方存在着流程协调,执行顺序的限制,往往会需要使用分布式锁,比如扣减库存的逻辑。常见的方式是引入一个第三方的分布式系统,将锁标识存储在那里用于共享访问,以达到锁的目的。
这样做虽然常见,但也有一些问题,首先需要引入额外的系统,并单独考虑各种异常情况的处理,增加了整个应用的复杂度。其次标识位往往有一定含义或者能与当前业务数据做关联,这就相当于额外存储了一份业务数据,存在一定的安全隐患。同时多个应用可能共用一套外部分布式系统来处理锁,这就无形中增加了系统的访问压力,一旦出现问题将影响多个依赖方,缺乏隔离性。
为了解决此类问题,TRocks在内部实现了基于Key力度的锁功能,将其分布式部署并作为应用的业务数据库时,其本身就拥有了分布式锁的能力(图2)。对锁的处理和业务数据在一起,无需引入多余的系统,降低复杂度,帮助业务方专注于业务代码的开发。
图2
为了保证请求的唯一性和类似raft那样支持幂等重试的功能,每个请求需要带上标识唯一性的clientid和自增seq,这些metadata和本身的data会被当成一个writebatch写入到rocksdb中,后续还会同步到slave上,从而保证整条链路上请求的原子性。
复合命令
由于Redis命令本身的限制,有些业务方反馈实现一个功能,比如对hash key进行超时处理需要进行2次操作,一次设置值,一次设置超时。虽然中间件将这层逻辑封装之后对外只提供一个api,但内部执行仍然是2个命令,可能存在原子性问题。TRocks针对这种情况增加了一些复合功能的命令,调用这些命令可以实现相同的效果并保证原子性,同时这些功能对用户是透明的,直接调用客户端相应api即可使用。
2、可用性增强
可调一致性
Kvrocks本身的主从复制逻辑与Redis相似,都是通过异步方式进行的。在这种方式下,如果出现网络断开或者master宕机,数据还未来得及同步,就会出现数据丢失的情况。为了避免此类问题,TRocks加入了类似Mysql的半同步复制来提高数据的一致性。我们可以通过打开半同步方式并指定至少需要参与的半同步slave的数量来启用该功能,提高灾备能力。
例如一个1主4从的集群,设定需要等待任意2台Slave响应。
如图3所示,当满足响应的slave为2的时候,半同步即可认为完成,即使此时另外两台slave可能还未完成同步工作。
但这种方式在多机房部署的情况仍然可能存在问题。因为距离的关系,相同机房的数据传输速率会更高,所以master复制到和其在同一个机房的slave通常情况会更快(图4)。
这样就很容易发生同机房的slave数据复制的进度要快于异地机房的slave。如果发生机房级的故障,导致master所在集群的服务全都无法正常工作,这个时候就可能发生数据丢失。
为此我们在半同步复制的基础上增加了IDC模式,使得即使初始条件已经满足,也需要至少存在相关IDC的slave反馈才能完成整个复制流程。IDC模式有两种,本地复制和异地复制。
以异地模式为例,如果返回slave的数量满足条件,且包含至少一台来自于master所在机房不同的slave,则半同步复制完成。如果当前响应中未包含非master集群的slave,则继续等待,直到master接收到一台来自异地的slave的反馈,半同步才能完成(图5)。
尽管异地模式数据的安全性更高,但也会影响整个系统的性能,这个性能差正常情况下取决于不同机房之间的网络延迟。基于对性能和数据可用性的不同要求,使用方可以酌情选择全异步复制(即关闭半同步),半同步 & 半同步(本地)复制或者半同步(异地)复制。
全量同步复制抑制
上面说到异步复制在异常情况下可能存在数据缺失的情况,如果再加上运维系统对主从关系的调整,就会发生数据冲突。而我们目前TRocks的版本还在快速迭代中,希望每次升级版本能够对用户透明,然而事实并非如此。
假设存在master A和slave B,正常情况下A和B的数据是保持一致的(绿色部分),但当A发生宕机的时候,B可能还未同步到A的最新数据,这时B的数据不再增加。但随后哨兵发现master无法访问,就把B提升为master并开始处理写入数据(蓝色部分)。当一段时间后,A系统恢复,重新加入进集群,此时A会变为masterB的slave,并尝试从B中同步数据,这里就可能存在冲突区(图6)。
按照Kvrocks初始的复制逻辑,A会认为自身数据存在问题,并放弃全部数据然后从头开始进行全量同步B的数据。这个行为本身没有问题。然而实际生产环境下,如果数据量很大的话,全量同步的耗时会比较长,而硬盘相比内存的带宽至少小两个数量级,因为我们的实例都是容器化部署,这有可能导致灾难性的后果,A在同步数据的时候会产生大量的IO,从而可能会影响A/B所在的宿主机上的所有的实例。
在数据一致性要求没有那么高的场景中,仅仅因为可能的几条数据不一致就重新同全量同步,代价非常昂贵。所以我们希望在非强一致性条件下,系统可以容忍极少量的数据差异,尽可能避免全量同步以便充分利用资源。
我们的方案是当检测到数据不一致的时候,主从之间会进行交互协调,计算出冲突区的范围,并从冲突区之后第一条数据开始进行同步。为什么不是直接从冲突区后面开始同步?这里需要有个概念,TRocks/Kvrocks的数据都是追加形式的,增删改都会在log文件中追加一条记录,并提供起始位置(Sequence),对应不同的Redis类型的记录会有不同的长度(Count),比如一条SET指令对应的Sequence会累加1,而HSET指令会累加2。
从Sequence到Sequence+Count就是一条记录的数据范围。当重新同步的时候,冲突区的结束位置如果处于正常数据的中间,这样是没有办法取得完整数据的,所以需要从冲突区后第一条数据开始。
而冲突区与同步开始之间的区域是补足区(图7),我们通过插入空白数据来进行填补,所以对于A和B来说,他们之间不一致区域是冲突区和补足区的总和。而对于冲突的部分,我们会记录下两边的差异,真有差异发生时,参考git解决冲突的思路,将数据的选择权交给用户。
上线该feature后,版本的升级就变得比较轻松,大部分情况下版本升级只是一次实例的拉出重启拉入,实例也是秒级up,升级过程也基本上对业务做到了透明。
在解决此问题的同时,我们也注意到master/slave数据是对齐的某些情况下也会发生全量同步,检查下来发现是pub/sub命令的问题。
这个命令是哨兵用于订阅服务消息的,但Kvrocks的pub/sub是一个写操作,这样就会造成持续性的数据写入从而累加rocksdb的Sequence,这样如果一个slave宕机后恢复,还没来得及与master同步却被哨兵写入了一条无关紧要的pub消息,累加了Seq从而触发了不必要的全量同步,但实际上该功能并非必须,所以我们修改Kvrocks处理哨兵pubsub消息的规则,不去写之后这个命令只工作在内存中,自然不会累加rocksdb的Sequence,杜绝这种情况全量同步的可能性。
3、运维治理能力增强
水平扩缩容
在之前的Redis治理演进之路文章中,我们介绍了一种新的扩缩容方案来解决Redis集群版本升级和扩缩容的问题(图8),参考同样的思路,我们继续改造BinlogServer来实现TRocks的集群的水平扩缩容,这套方案实际上不仅解决了扩缩容的问题,同时也解决了Redis到Redis的数据迁移,TRocks到TRocks的数据迁移,Redis与TRocks之间的互相迁移,也可以帮助用户平滑的从Redis的访问过渡到TRocks的访问。
然而相比Redis扩缩容基本不需要考虑内存带宽,硬盘带宽太窄,而数据迁移的时候流量太大。由于所有数据最终都需要在新集群上刷盘,导致迁移过程中目标集群的磁盘读写会非常大,又由于我们都是容器化部署,大量的磁盘读写也可能会影响到统一宿主机上的其他无关的应用,所以我们调整了TRocks的写入限流设置,以避免大量写入影响磁盘性能,同时修改了BinlogServer加入了限流功能,平缓数据传输的速率。
哨兵多机房部署
为了保证TRocks集群可以跨机房容灾,哨兵需要部署在多个机房中,目前我们是三机房部署。如下图(图9):
在部署的时候,遇到了一个问题,我们发现哨兵之间经常无法选出leader,需要等下一个选举周期(6分钟)才能重新选出,导致长时间无法确定TRocks master。这个问题本身跟TRocks没有太大关系,只是实际使用中对我们故障处理带来了不小麻烦。
出现无法选出leader的原因是多个哨兵同时发起选举希望成为leader,导致最终每个哨兵都选择了自己,无法达成共识。查看源码发现官方已经为发起选举前设置了随机的间隔时间(50~100ms),但实际操作中发现这个随机间隔反而增加了发生选角失败的可能,考虑应该是随机时间太短导致,所以我们将随件间隔修改为100~200ms,同时在哨兵发现master宕机之后就立即发起选举来尽可能规避无法选主的问题。
五、一些数据
1、性能数据
TRocks在内网上线后,在各个业务线都得到了广泛的使用,排除公有云的部分,私有云上已经有将近2K的实例,10T+的数据量,下图(图10,图11)可以看到同样的数据写TRocks和Redis的性能对比。平均响应时间,99.9%在同一个水平,并且我们还可以看到,得益于自定义的命令,同样的功能相比Redis更加简洁。
根据我们跟业务方压测,一台40C和2块RAID0的SATA SSD在保证良好响应的前提下(99.9%<10ms)约能提供读写的QPS为8-10W,其中value<1k。而如果换成NVME SSD这个QPS可以提升3-5倍。
2、成本数据
假设TRocks都是容器化部署,并且一台40C的宿主机上可以部署20个实例,每个实例大小为40G,因为TRocks相比Redis有不小的压缩功能(约3-7倍的压缩率),如果将Redis的数据导过来可以平稳运行,那么TRocks相比Redis约可以节约90%的成本。
既然能省这么多成本,是否所有的Redis都可以用TRocks来代替,我们是否需要将私有云上所有的Redis都替换成为TRocks?
答案都是否定的,也不是我们推广TRocks的初衷,原因有以下两点:
- 如上文所提到的,我们希望TRocks能拥有Redis的大部分能力,而又不仅仅局限于Redis,希望它更是一个通用的KV数据库,能提供更多样化的能力来支撑业务的诉求。
- 硬盘的带宽与内存有2个数量级的差距,而这些先天不足也无法满足某些Redis场景的需求。比如大Key(>100K)响应和Redis还是有一定的差距,此外某些数据量小并且单个实例访问QPS较高的实例,用TRocks来替换也并不合适,因为规模化运维治理,我们需要考虑整个宿主机和每个实例是否能平稳运行,一般来说单个实例>10G,QPS<5K 是比较适合的。当然NVME SSD可以极大缩短大Key的响应时间和提升单个实例QPS的上限。
六、未来规划
1、复合命令增强
我们调研发现,业务经常为了获取一条数据,需要多次查询TRocks,类似二度人脉的取数据逻辑,多次的网络IO会导致耗时增加,而设计通用的命令来支持业务需求,减少网络IO变得非常重要,此外还有些用户询问TRocks的hash类型中的subkey是否也可以实现过期。由于hash功能目前仍然是遵照Redis的规则,所以现在是按照整个hash key一起过期而不能实现内部数据项的过期。这个需求是有一定价值的,未来我们会通过提供一个特殊的hash结构来实现此类功能。
2、引入checkpoint
Kvrocks1.X在进行全量复制时,master会生成硬的backup,会拷贝文件产生大量的IO,而官方2.0版本已经用Rocksdbcheckpoint解决了这个问题,我们也已经将2.0版本merge过来测试,准备适时升级上线。
3、使用NVME SSD
目前携程的大量TRocks还是跑在SATA接口的SSD上,而据我们的测试下来两块SATA raid0的带宽大约为800MB/S,导致硬盘非常容易跑满,相比之下,NVME SSD的带宽基本都是几G起步,并且我们测试下来NVME SSD在小的压力下,对于SATA SSD性能有3-5倍的提升,而对于大Key的情况(超过100K)和大的压力下,NVME SSD的性能提升可以高达10-100倍。因此我们已经计划将SATA SSD全换成NVME SSD,进一步提升TRocks的性能。