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