从0开始打造一个高性能kv数据库

1,411 阅读8分钟

前言

Redis性能出色,设计巧妙,是各大公司常用的高性能kv数据库,非常值得大家学习。学习的最好方法就是实践,本文将使用go语言,从0开始打造一个简单但是可用的高性能kv数据库,我们就叫它simple_cache(github.com/ccfarm/simp…)吧。

整体架构

截屏2023-04-03 16.36.17.png 一个请求从客户端打到kv数据库,依次会经过网络模块、IO缓存、通信协议模块、和存储引擎,当然划分不是唯一的,不同模块间的边界也不一定很清晰。这四个模块大致的功能是:

  • 网络模块
    • 接受客户端的连接请求,收发字节流
  • IO缓存
    • 使得IO动作可以批量读写大块数据,提升IO效率
  • 通信协议模块
    • 收到请求时将字节流翻译成具体的命令,处理完后再把结果翻译成字节流
  • 存储引擎
    • 做好KV数据的索引,高效率存取数据

网络模块

先来看看6.0版本以前Redis的网络模型。Redis网络模型跟线程模型很难分开,6.0版本以前,Redis采用的是单线程Reactor模型,使用IO多路复用和非阻塞IO在单线程处理多个连接的请求,依次处理。 截屏2023-03-15 20.52.10.png simple_cache采用的网络模块设计较为简单,直接使用go语言的net库,每接到一个请求,就开启一个新的go routine去处理请求。这么做除了方案简单外还出于两点考虑:

  • go 语言net库底层其实也用到了epoll,性能不错;
  • go routine是轻量级的线程,成本较低。 截屏2023-03-15 20.58.16.png

数据通信协议

网络传输的是字节流,拿到程序中处理之前需要先翻译成命令。字节流和命令之间的转换就需要数据通信协议。本文沿用Redis协议, Redis协议几乎是最简单的网络传输协议。使用Redis协议的另一个好处是,可以使用Redis客户端直接连上smple_cache。

完整的Redis协议详见参考资料,这里根据举一个简单的例子做个介绍。假设有这样一次网络请求:

Client: GET key

Server: value

那么使用Redis协议序列化之后是这样的:

Client: *2\r\n$3\r\nGET\r\n$3\r\nkey\r\n

Server: $5\r\nvalue\r\n

我们先来看客户端的请求,首先要说明的是,Redis协议可以根据\r\n符号分段。

先看第一部分*2\r\n,第一个符号*号代表接下来是一个数组(Arrays),有2个元素,分别对应的是$3\r\nGET\r\n$3\r\nkey\r\n

再看$3\r\nGET\r\n,第一个符号$号代表接下来是一个多行字符串(Bulk Strings),长度3个字节,内容是GET。

同理$3\r\nkey\r\n同样代表一个多行字符串,内容是key。

最后再来看看服务端的返回值$5\r\nvalue\r\n,也是一个多行字符串,值为value。

Redis协议就是那么简单,用一个符号代表数据类型,然后紧接着传递数据,一共可表示以下五种数据类型:

  • 单行字符串(Simple Strings): 响应的首字节是 +
  • 错误(Errors): 响应的首字节是 -
  • 整型(Integers): 响应的首字节是 :
  • 多行字符串(Bulk Strings): 响应的首字节是$
  • 数组(Arrays): 响应的首字节是 *

IO缓存

如果没有缓存,直接从网络连接中读取数据,好像勉强也能用,但是性能会非常差。

继续引用上一节的例子,客户端发出请求GET key

Cleint: *2\r\n$3\r\nGET\r\n$3\r\nkey\r\n

请求以字节流的形式发给服务端,服务端需要首先尝试从连接中读取一个字符*,了解接下来是一个数组。然后需要连续读取字符,直到读到\r\n,这样才能解析出数组的长度是2。整个过程需要多次从连接读取数据,每一次读取都产生一次系统调用,从用户态切换到内核态,开销巨大。

此时如果我们有缓存,服务端虽然一次只需要读取一个字符,但是缓存模块在第一读取时已经把整个字节流全读取下来了,整个过程只需要产生一次系统调用。

bufio的设计

go的bufio库其实已经实现了简单的IO缓存功能,用一块固定大小的内存把数据给缓存下来。但是这么设计存在一个问题,当buffer写满的时候,需要进行GC,把旧数据清理,然后把新数据复制到buffer头部,这个过程有一定开销。 截屏2023-03-16 16.01.42.png

NoCopyBuffer

相对而言,simple_cache采用NoCopyBuffer方案理论上来讲将会有更好的性能。buffer被分割成一个个的小块,使用链表的形式连接在一起,当buffer node被写满时,无需进行GC,只需要取一个新的buffer node放在最后即可。当最前面的node被使用后,则可直接回收。

让我们稍稍深入NoCopyBuffer的实现细节。NoCopyBuffer有两个指针,read node指向链表中的读取节点,上层应用读取数据从这个节点开始;write node指向链表中的写入节点,从TCP连接读取新数据后将写入这个节点。每个节点本身都需要记录read offset和write offset,分别代表数据的读取起点和写入起点。一个新节点的read offset和write offset都为0;当缓存了一部分数据后,read offset仍然为0,write offset则增长到数据长度;当读取一部分数据后,read offset等于读取数据长度;当read offset和write offset相等时,代表缓存的数据都已经被读取,需要从TCP连接读取新数据。

node本身的生产和销毁需要一定开销,针对这点,simple_cache使用了池化技术,利用go语言的sync.pool回收复用node。

截屏2023-03-20 20.10.00.png

除此之外,buffer node还采用了一个零拷贝技术,上层应用无需把数据从buffer node中拷贝出来,直接引用即可。

截屏2023-03-16 16.12.08.png

存储引擎

存储引擎提供数据的存储和提取,是kv数据库的核心组件。simple_cache暂时只提供字符串key-value对的增删改查,存储引擎需要实现以下几个功能。go语言实现类似功能最简单的方式就是用自带的map[string]string,但是当存储的数据量很大时,map会产生极大GC开销,当map元素超过千万时,单次gc造成的停顿达百毫秒以上(Large maps cause significant GC pauses)。

截屏2023-03-16 16.20.59.png

但是Go 1.5引入了一个新特性:当map中的key和value都是基础类型时,GC就不会扫到 map 里的 key 和 value。simple_cache利用该特性,使用map[uint64]int作为索引,然后再申请一块巨大的[]byte存储真正的数据。索引map[uint64]int中存储的key和value都是假的。索引key是真实key的哈希值,uint64类型;索引value存储的是真实value在[]byte数组中的位置,int类型。说起来有点拗口,可以对着图片再来看一遍。这么做有一个风险,就是hash碰撞时,相同hash值的key只能存在一个。不过我们使用了uint64表示hash值,碰撞概率很低。

截屏2023-03-16 16.43.29.png

索引中不存真实的key和value,所以数据块中需要存这些信息。具体的存储格式如下,从头到尾依次存储key的长度,value的长度,过期时间,key的值和value的值。

截屏2023-03-16 16.45.39.png

细心的朋友可能会意识到一个问题,用来存储数据的byte数组,大小是有限的,当byte数组写满时,需要逐出部分数据。其实可以把byte数组当成一个环形队列,写到队尾时把指针拨回头部。这样数据的逐出有点类似先进先出策略。 simple_cache处理请求时同时会存在多个并发routine,当存取数据时需要加锁。如果采用全局锁,性能损失会比较大,simple_cache将数据库分成多个block,每个block有单独的索引、数据块和分片锁,减少全局范围内锁冲突的概率。比如key的hash值是257,共有256个block,hash值%256=1,这个key就存在1号block中。

截屏2023-03-16 16.56.23.png

我们再来看看存储引擎的set、get、del操作。

set(key, value, expire)的流程如下

  • 计算key的hash值
  • 根据hash值算出key属于哪个block
  • 将key、value和过期时间存入byte数组,同时记录储存的offset
  • 写入前要检查空间是否足够,不够就得先逐出部分数据,不仅要从byte数组删除数据,还得从map删除索引
  • 将hash值和offset写入索引map
  • 完成写入操作

get(key)的流程如下

  • 计算key的hash值
  • 根据hash值算出key属于哪个block
  • 尝试从索引map以hash值为key读取offset
  • hash值不在map中,表示key不在数据库中
  • 根据offset从byte数组中获取key,value、过期时间
  • 检查取出的key是不是我们想要的key,避免hash碰撞
  • 如果不是,说明读空
  • 根据过期时间检查数据是否过期
  • 如果过期,读空
  • 返回value

delete(key)的流程如下

  • 计算key的hash值
  • 根据hash值算出key属于哪个block
  • 尝试从索引map以hash值为key读取offset
  • hash值不在map中,表示key不在数据库中
  • 根据offset从byte数组中获取key
  • 检查取出的key是不是我们想要的key,避免hash碰撞
  • 如果不是,说明key不存在
  • 从map删除索引
  • 完成删除操作

参考资料: