跳转至

KV 模型

KV 模型是最简单的存储模型之一,可以理解为可以持久化的 std::map。其实文件系统也可以理解为一个 KV:Key 是文件名,Value 是文件内容。通用的 KV 一般保存的是 stringstring 的映射,其中的 string 不一定是需要纯文本,也可以是二进制的数据,可以理解为字节数组。

KV 模型强大之处就在于它非常简单,所以实现一个 KV 系统并不会花费很多时间,同时它的适应性非常强,开发者可以按需添加自己所需要的功能,定制自己的 KV 系统。同时,使用 KV 系统可以很方便地组合出其他计算存储系统,例如 MapReduce、BigTable、TiDB 等。因此,熟练掌握 KV 模型是编写服务器程序中必不可少的一项技能。尽管现在已经有各种各样的数据库和计算系统,但它们最底层往往都是基于 KV 模型的。当你掌握了 KV 模型,了解了常用的一些复杂的计算存储系统是如何实现之后,就可以尝试根据自己的需要编写自己的基于 KV 的新的计算存储系统了。

根据底层原理的不同,KV 提供的接口和性能也会有所不同。就像 std::mapstd::unordered_map 一样,有的 KV 是基于有序数据结构,例如 LSMTree、B+Tree 等,来保证其中的 Key 是有序存储的,这样我们可以很方便的进行有序的范围扫描,但是为了维护 Key 的有序性,在插入和对单独一个 Key 查询时的时间复杂度则会有所上升。而基于哈希表的 KV 存储,则牺牲了有序范围扫描的功能,换取更高性能的单点查询、插入操作。不过,现在成熟的 KV 存储引擎即使底层采用了哈希存储,也同样会在内存中维护一份有序的 Key 列表,来提供范围查询的能力,这种方式可能会牺牲启动速度,因为每次启动都需要扫描一次磁盘中的文件来建立索引,也有一些系统会单独存放索引文件。

根据不同的使用场景,可以选择最适合的存储引擎。一般而言,需要考虑的因素有:

  • 查询的方式,例如程序是针对某一个 Key 查询比较多(单点查询),还是会进行一定范围的扫描(范围查询),还是进行批量的 Key 查询(批量点查)
  • 读/写的比例,例如程序是不是 99% 的时候都只是读取数据,而不会进行修改,或者是写入后基本不需要读取
  • 读/写的延迟,例如注册用户的时候可能可以接受花好几秒来等待写入,但希望登录的时候几十毫秒就能读取数据
  • 数据的容量,如果数据量太大,例如朋友圈、微博这种可能上百亿的数量,单机则无法满足需求,但如果自己写的小网站,数据量可能小到可以直接放在内存中
  • 数据的热点分布,例如微博,很多访问到的微博都是最近一段时间发的,而发了一个星期或者更久的微博则基本很少被访问到

Key 的设计

使用 KV 存储的时候,最重要的就是怎样设计 Key。由于一般来说 KV 数据库是整个程序共享同一个数据库,所以需要在 Key 中编码较多的信息,比较简单的设计方法是类型、分隔符、ID。例如,我们想保存用户信息的话,可以使用 users:1users:2... 这种形式来编码 Key。在需要访问的时候手动拼接出 Key。

当然,我们也可以使用二进制作为 Key,例如,我们可以用九个字节来编码 Key,其中第一个字节可以设置为 0x1,用来表示这是用户,而剩余的字节则可以使用大端序来编码 64 位无符号整数作为 ID,这样则不需要使用分隔符。

有时候也可以模仿文件系统,进行层级化的 Key 设计,例如 users:admin:1,这样的好处是,假如我们需要按照一定属性(例如找到所有管理员)去遍历 KV,如果 Key 只有 ID 信息,那么我们必须扫描全部的 Key,取出 Value 解析再进行过滤。使用层级化的设计,我们可以只扫描以 users:admin 为前缀的 Key,就能找出所有符合条件的 KV。

ID 与索引

更多的时候,会混合使用到多种 Key 的编码方式。例如,我们既使用 users:1 来保存用户的基本信息,users:admin:1 来帮助我们快速查找特定条件的用户。像 users:admin:1 这种用来加速查询的 KV,可以理解为索引

那么,这会有一个问题,我们是要分别在两个 Key 中保存两份用户数据,还是 users:admin:1 这个 Key 不保存数据,只保存 ID,需要用到数据再查找 users:1。这可以理解为聚簇索引非聚簇索引

在聚簇索引中,我们选择每一个 Key 都保存完整的数据,这样的好处是,我们只需要一次 KV 查找,就能得到所有信息,代价是在更新数据的时候需要同时更新多个 Key 中的数据,同时还要保证他们的一致性。另外,也很浪费空间。可以理解为 std::map<string, User>

而使用非聚簇索引的话,一般而言只有一个 Key 会保存完整的数据,而其他的 Key 只保存指向数据的 Key,可以理解为一个指针,在需要完整信息的时候,需要根据这个指针去再次读取 KV,得到数据,这个过程可以叫做回表,显然,这种方式多了一次 KV 操作。而好处则是无需操心多个 Key 的完整性,也不需要浪费空间保存一模一样的数据。可以理解为 std::map<string, User*>

当然,以上两种方式也不是非黑即白,我们甚至可以采取一部分的 Key 使用聚簇索引的方式保存数据,一部分的 Key 则使用非聚簇索引。这就需要具体问题具体分析,看 KV 的存取频率等来决定使用怎样的方式。

KV 存储引擎的特点

KV 存储模型虽然简单,但是其实现和优化有数不清的细节纠缠在一起,到现在也仍然是研究的热点。一般而言,KV 会提供以下几种功能:

  • 基本的增删(点、范围)查改操作
    • 为了高性能,不同 KV 会采取不同的使用场景、运行硬件等进行针对性优化
  • 崩溃恢复
    • 在数据操作到一半的时候,程序意外崩溃不会造成数据损坏的问题
    • 在下一次数据库初始化的时候尝试修复
    • 一般是通过 WAL(Write-Ahead Logging)实现的
  • 有限的事务支持以及并发控制,例如
    • 希望两个 Key,比如 users:1users:admin:1 要么一起更新成功,要么一起更新失败
    • 在修改的过程中,不希望其他线程看到修改到一半的数据
    • 不希望一个 Key 同时被多个线程修改导致数据错乱

RocksDB

目前最流行的单机 KV 存储引擎是 RocksDB,其前身是 Google 开发的 LevelDB。RocksDB 针对 SSD、多核等进行了各种性能优化,基本能满足大部分的需求。

TiKV

TiKV 是基于 RocksDB 开发的分布式 KV 存储引擎,可以进行无限水平扩展,从而应付大量数据。


最后更新: 2021-09-05 20:53:56
本页作者: Howard Lau