Kvrocks 架构设计详解

介绍

今天偶然间看到了个用来代替 Redis 的数据库 Kvrocks ,感觉很巧妙,特此整理记录一下。

在各个网站发展的初期,硬盘的技术还不够完善的时候,读写数据库是会有很大的硬盘 IO 压力的。随着时代的发展,大家逐渐从数据库读写分离到 Memcached 再到 Redis,形成了完整的分布式数据库 + 分布式缓存的方案。

Redis 是现在最受欢迎的NoSQL数据库之一,Redis是一个使用ANSI C编写的开源、包含多种数据结构、支持网络、基于内存、可选持久性的键值对存储数据库,其具备如下特性:

  • 基于内存运行,性能高效
  • 支持分布式,理论上可以无限扩展
  • key-value存储系统
  • 开源的使用ANSI C语言编写、遵守BSD协议、支持网络、可基于内存亦可持久化的日志型、Key-Value数据库,并提供多种语言的API

但 Redis 是一个内存型的 KV 数据库,那么随着用量越来越大,内存的消耗也会越来越大。但我们知道在云服务或者是自主的服务器上,内存是一个很昂贵且难以不断增大的资源。而在 2010 年之后,硬盘的速度其实也在逐渐提升,甚至在傲腾的出现后,进一步缩短了与内存的性能差距。

开源社区是如何解决随着业务增长 Redis 的所需内存不断线性增长的问题的呢?

从存储设计来看大概分为三类:

  • 硬盘型 KV 存储 + Redis 协议兼容

是基于磁盘 KV 之上兼容 Redis 协议,绝大多数的本地磁盘 KV 只提供最简单的 Get/Set/Delete 方法,对于 Hash/Set/ZSet/List/Bitmap 等数据结构需要基于磁盘 KV 之上去实现。优点是可以规避下面方案 2 里提到的大 Key 问题,缺点是实现工作量大一些。

  • Redis 存储中冷数据移到硬盘上

基于 Redis 把冷数据交换磁盘是以 Key 作为最小单元,在大 Key 的场景下会有比较大的挑战。交换大 Key 到磁盘会有严重读写放大,如果是读可能会导致整个服务不可用,所以这种实现只能限制 Value 大小,优点在于实现简单且可按照 Key 维度来做冷热数据分离。

  • 基于分布式的 KV 存储 + Redis 协议兼容

是基于分布式 KV 之上实现 Redis 协议,最大的区别在于所以的操作都是通过网络。这种实现方式最大优点是只需要实现 Redis 协议的部分,服务本身是无状态的,无须考虑数据复制以及扩展性的问题。缺点也比较明显,因为所有的命令都是通过网络 IO,对于非 String 类型的读写一般都是需要多次网络 IO 且需要通过事务来保证原子,从而在延时和性能上都会比方案 1 和 2 差不少。

Kvrocks 就是基于第一种类型的方案,基于磁盘 KV 之上实现 Redis 协议以及复制。除了数据存储方式之外, Kvrocks 并没有淘汰策略,所以一般是作为存储服务而不是缓存,当写入的数据量达到实例最大容量或者磁盘容量不足时会写入失败。

通过硬盘存储大量数据的方式,Kvrocks 成为一种低成本、大容量的类 Redis 数据库。

据官方文档 Kvrocks 支持 Redis String、 List、 Hash、Set、 ZSet 五种基本数据类型, 以及 Bitmap、Geo 和自定义的 Sorted Int 类型。当前支持大多数命令,也支持 Pub/Sub、事务以及备份等功能。

架构设计

整体设计

Kvrocks 在内部设计上主要拆分成几个部分:

  • Redis 协议接收和解析模块,负责解析网络请求和解析 Redis 协议,相比 Redis 来说,Kvrocks 在 IO 处理以及命令执行都是多线程模型;
  • 数据结构转换模块,负责将 Redis 复杂类型转为 RocksDB 可处理的简单 KV,不同类型在设计上会有一些小差异;
  • 数据存储模块,Kvrocks 底层使用 RocksDB 并对其做了不少针对性的性能优化;
  • 主从复制模块,类似 Redis 的异步复制的方式,每个从库都会创建一个对应的复制线程。在实现方面,使用 RocksDB CheckPoint + WAL 来实现全量和增量同步;
  • 集群模块,包含 Redis 集群协议兼容以及在线迁移的功能。

协议兼容

Kvrocks 目前支持的还是 RESP 2 的协议,请求协议解析的相关代码都在 src/redis_request.cc 这个代码文件里面。相比于 Redis  的实现,Kvrocks 并没有自己实现接收和发送网络包逻辑,而直接使用比较成熟 Libevent 网络库,主要的原因多线程场景下,Libevent 的性能已经足够好,瓶颈主要在磁盘 IO, 没必要自己再造轮子。

数据编码

由于底层存储引擎是 RocksDB, 只提供简单的 Get/Set/Delete 以及 Scan 接口。在接收到请求之后,Kvrocks 需要对 Hash/List/Set/ZSet/Bitmap 等复杂数据结构的请求进行编码,转为简单 RocksDB KV 来进行读写。目前大部分数据都存储在以下两个 Column Family 里面:

  • Metadata Column Family,用来存储 Key 的元数据信息。以 Hash 为例,每个 Hash 都会在这个 Metadata Column Family 存储一个元数据的 KV,Key 就是用户请求的 Hash  Key,Value 包含:数据类型,版本号,过期时间以及 Hash 的子元素个数
  • Subkey Column Famliy, 用来存储 Hash 对应的子元素和对应的值,这个 Column Family 的 Key 组成是: Hash Key + 版本号 + 子字段的 Key,Value 是子元素对应的具体值
版本号是根据当前时间自动创建一个随机递增的数值,目的是为了实现快速删除,避免删除大 Hash 时产生慢请求。比如,第一次写入版本号为 V1, 包含 N 个元素,在删除或者过期之后再重新写入则会产生新的版本号 V2,由于查找时需要先找到当前活跃版本号,再拼接成子元素的 Key 再查找对应的值。相当于老版本的子元素都变成不可见,这些数据会在后台 Compaction 时自动回收,变相实现了异步删除。

Lua 和事务

Kvrocks 是目前开源磁盘 Redis 里面同时支持 Lua 和事务的选型,同时在命令支持上也是比较完善。为了简化实现复杂度,Lua 和事务相关命令执行时会限制为类似 Redis 的单线程执行。实现方式是在 Lua 和事务相关执行命令加上全局锁.

全局锁会导致 Lua 和事务的性能退化为单线程性能,但就如 「Spanner: Google’s Globally-Distributed Database」所说,业务解决性能问题会比解决功能缺失更加简单得多,性能问题业务总有办法去绕过而功能则很难。所以相比于功能完整性来说,少数命令的性能衰退是可接受的。

在 Lua 实现上,为了和 Redis 行为保持一致,Kvrocks 也是选择 Lua 5.1 版本。但实现上有一些差异,Redis 当前版本的 Lua 脚本做不会持久化,重启之后会丢失,而 Kvrocks 会持久化到磁盘且自动同步到从库,具体实现见: PR 363和 PR 369。此外,在后续计划中,我们会支持设置 Lua 脚本名字的功能并按名字进行调用,类似数据库的存储过程功能,具体讨论见: Issue 485

在事务方面,Kvrocks 目前支持 Multi/Exec 命令,实现也是跟 Redis 类型,对于 Multi 和 Exec 之间的命令先缓存在内存中,收到 Exec 命令之后才开始执行这行命令。目前实现上存在一个小问题是,虽然执行过程中可以保证单线程但写 Batch 不是原子,所以可能在极端场景下,写到一半服务挂了则可能部分 Batch 成功的情况。

存储

除了将复杂数据结构转为简单 KV 的设计之外,需要在存储层面也有很多优化细节需要去做。Kvrocks 底层的单机存储引擎使用的是 RocksDB,相比于 LevelDB 除了性能方面有比较大提升之外,在特性方面也是存储引擎里面最为丰富的,包含 Backup、CheckPoint 以及 Compact Filter 等功能。当然,RocksDB 除了丰富的特性之外,在配置方面也比 LevelDB 复杂不少,需要针对不同业务场景来提供最佳配置也是比较大的挑战。Kvrocks 针对不同的场景对 RocksDB 做了优化。

其他比较经常被提到的问题是: 「Kvrocks 过期或者删除数据如何回收?」,这个是通过 RocksDB 支持 Compact Filter 特性,在 Compaction 阶段对这些过期或者删除数据进行回收。

主从复制

在 2.0 版本之前 Kvrocks 使用 RocksDB Backup + WAL 来做全量和增量复制,创建 Backup 时需要拷贝全部的 DB 文件,导致全量同步时磁盘 IO 持续变高,从而影响服务的响应延时。在 2.0 开始使用 CheckPoint 替换 Backup,CheckPoint 在同步目录和 DB 目录在同一个文件系统时会使用硬连接而不是拷贝,所以全量同步创建 CheckPoint 对磁盘 IO 几乎没有影响,同时整个过程的耗时也比创建 Backup 低很多。

  1. 从库启动时,先检查 Auth 和 DB Name 是否正确,DB Name 主要是为了防止从库连错主库而导致数据被覆盖;
  2. 接着从库发送当前 DB 的 Sequence Number,主库根据 Sequence Number 确认是否可以进行增量同步;
  3. 如果 Sequence Number 在当前保留的 WAL 范围之内,则允许增量同步,使用 RocksDB 的  GetUpdateSince API 将 Sequence 之后的写入批量同步到从库。否则,进入全量同步 (Full Sync) 流程;
  4. 全量同步过程中,从库先发送 Fetch Meta 来获取 Meta 数据,主库会先创建 CheckPoint,并发送全量同步的 Meta 信息到从库(Meta 主要包含了需要拉取的文件列表)。
  5. 从库根据 Meta 信息主动批量拉取 CheckPoint 文件,如果已经在从库存在的文件则会跳过。同时,从库拉取文件可能占用比较多的带宽,可以通过配置 max-replication-mb 来限制拉取的带宽,默认是不限制;
  6. 全量同步成功之后回到 Step 2,重新尝试增量同步,以此循环直到成功为止。

集群模式

为了让 Kvrocks 支持更大的数据规模,大家通常采用 pre-sharding 方式,将 Kvrocks 划分为一主多从的多个复制组,然后使用 Twemproxy 进行数据路由,通过 Redis Sentinel 实现高可用。该方案虽然简单,但很难实现集群的在线扩缩容和高效的集群管理,所以 Kvrocks 迫切需要实现自身的集群方案。

业界常用 Redis 集群方案主要有两类:类似 Codis 中心化的集群架构和社区 Redis Cluster 去中心化的集群架构。Codis 中心化方案需要 Proxy 进行请求路由,增加了额外的网络通讯成本和延迟,并且 Proxy 本身也有资源开销和运维成本;Redis Cluster 方案最大的问题是集群规模受限于其 Gossip 通信的开销而不能过大,而且去中心化架构中集群拓扑的可控性和运维性相对较差。

在 Kvrocks 的设计中,我们希望能够提供一种兼容两者优点的方案,既可以有 Redis Cluster 方案中不需要额外 Proxy 即可让客户端或 SDK 直接请求 Server 的能力,又可以让集群规模无限扩展。

在 Kvrocks 的 Cluster 方案中,每个 Server 节点可以像 Redis 节点一样提供集群拓扑结构,让 Redis Cluster 的客户端和 SDK 可以直接工作在 Kvrocks 集群上;每个节点集群拓扑结构的管理并不是由节点本身完成的,而是由外部的管控组件进行设置的,同时还避免了基于 Gossip 协议构建集群的复杂度(Redis 社区经过多年的努力才让基于 Gossip 协议的 Cluster 方案比较完善)。

集群中的节点不会相互进行通信。

Kvrocks 集群拓扑结构的设置通过简单的几个命令即可完成配置,所以管控节点可以实现的非常简单,甚至一个脚本就可以实现。当节点发生变化时,对集群中所有节点再次更新一下拓扑结构即可。但 Kvrocks 自身的集群功能中不支持故障切换,需要有其他工具检查节点故障,然后更新拓扑。对于小规模集群,这些工作甚至可以由运维同学完成,将拓扑结构记录在数据库中,每次变更时更新拓扑。对于大多数运维 Redis 的公司,一般都会有自己的集群管理方案,Kvrocks 提供的命令也能其方便地接入到对应的管理系统中。

由于 Kvrocks 的集群访问能力兼容 Redis Cluster 方案,所以大家可以使用 Redis Cluster SDK 访问 Kvrocks 集群。具体实现上,Kvrocks 支持 CLUSTER NODES 和 CLUSTER SLOTS 命令,而 Redis Cluster SDK 正是通过这些命令获得集群拓扑,进行请求路由分发的。

如果在你有一款支持 Redis Cluster 的 Proxy,比如开源的 redis-cluster-proxy,Kvrocks 也是支持的。这样能够屏蔽后端集群架构,客户端可以直接访问一个入口。

当前,Kvrocks 集群拓扑结构的变更是基于全量状态进行转化的,也就是说每次拓扑结构的变更时,甚至只是一个节点的变更时,管控节点都需要会将全量的拓扑结构发送给每一个节点,这样的网络和 CPU 开销相对较大,但能够在不可靠的网络上尽可能保证拓扑结构的正确性,即使由于网络或管控自身原因导致中间部分更新没有被 Kvrocks 执行,也不会影响最终拓扑结构的正确性。因此设计了拓扑结构 Version 的概念,能保证拓扑结构在特定的基准上更新,这为设计基于操作的集群拓扑结构变更方案提供了基础,后续我们会提供添加节点、更新节点和删除节点等接口,方便运维。

Kvrocks 的数据迁移是基于 slot 维度的,支持将一个 slot 上所有数据迁移到另外一个实例的,这跟 Redis Cluster 中数据迁移是基于 Key 维度的不一样,Kvrocks 作为 Redis 在大容量上的一个互补服务,其存储的数据量也比 Redis 大的多,按 Key 迁移的时间周期不可控。

思考

Kvrocks 整体来看设计是比较有趣的而且是一个比较轻巧的方案。整体来看 Kvrocks 主要做了对 RocksDB 的优化、支持 Redis 协议、支持主从分布、支持集群模式、对 Redis 中复杂的类型进行转化。

整体来看是为了解决 Redis 内存不断增长的问题的巧妙构思。不过我想到了以下问题:

  • 在运维调整或者发生事故时,似乎复制的过程会出现数据缺失的问题,那么 Kvrocks 就会进行全量复制,这个过程看起来成本挺高的,不知道有没有具体的测试数据或者增量的方案?
  • 是否可以完全替换 Redis?
  • 社区的流行度看起来还是较小的,但是是刚需,说不定过一两年成熟后会大爆发,类似 TiDB。
  • 能否针对不同的硬盘有不同的优化呢?NVME SSD 的带宽一般是几 G,是 SATA SSD 的 3-5 倍。那么理论上似乎一定是 NVME SSD 比 SATA SSD 性能更好。那么如果用多个硬盘或者磁盘阵列做分片加速会怎么样。
  • 基于对象存储而不是硬盘会怎么样?
  • 看携程的测试数据,四线程的 Kvrocks 跑在傲腾上甚至比 Redis 的性能要更好。
  • 缺少比较好的适配云原生的方案,而且文档真的很少。