购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

1.2 数据存储的核心:存储引擎

本节介绍的主要内容属于通用性内容,不仅适用于磁盘型存储引擎的设计,也适用于内存型存储引擎的设计。这部分内容是理解存储引擎内部工作原理的基础,认真学习这部分内容将会对理解B+树存储引擎和LSM Tree存储引擎有很大的帮助。读者在掌握了这部分内容后,完全可以尝试自己动手实现一个内存型的本地缓存组件或者简易版的磁盘型存储引擎组件。

1.2.1 存储引擎整体架构

广义上的存储引擎在现实中有哪些用处呢?日常工作中通常会用到本地缓存组件(如Go中的BigCache、FreeCache,Java中的Caffeine等)、远程缓存组件Redis、关系数据库MySQL中的InnoDB、嵌入式数据库BoltDB和RocksDB等。这些组件都支持数据的存储和读取,并且暴露的接口基本相似(如Set、Get、Delete等)。它们之间有一个差异是数据是否需要落盘及是否实时落盘。数据落盘这种能力被称为数据的持久化。上述共性能力就是一个最基本的存储引擎要具备的功能。

在开始本小节的内容前,先对后面要介绍的存储引擎做一个限定和抽象。本小节所讨论的存储引擎是指对上层应用程序提供最基本的增删改查操作并且支持数据持久化能力的通用数据存储组件。通常会根据不同的使用场景来选择将持久化能力作为一个可选项还是必选项。例如,本地缓存类的组件会缓存数据到内存以提升系统的读性能,但往往不考虑数据的持久化。又如,单机的Redis数据库主要缓存数据到内存,但实际上也有一项暴露给上层用户开启异步持久化的功能(RDB、AOF两种),只不过是异步非实时持久化的。本小节介绍的存储引擎指具备持久化能力,并且默认可实时同步(至少是WAL)存储一份数据到磁盘。

为了接口的通用性,假定存储引擎提供的接口中,接收参数key(下文简称k)和value(下文简称v)的数据类型全部为byte(二进制数据)。该存储引擎对于上层应用程序暴露的基本接口主要有以下几个。

❑ Set(k,v):将一组k-v键值对添加到该存储引擎中。如果k已经存在,则更新其值v。

❑ Get(k):从存储引擎中查询k对应的v。

❑ Del(k):从存储引擎中删除k对应的数据。

上述定义的存储引擎可以服务于任何单机节点下需要数据持久化的场景,包括关系数据库、NoSQL数据库、NewSQL数据库、消息队列等。一次用户请求进来后,按不同存储介质的存储阶段划分,存储引擎可以分为用户接口层、内存层和磁盘层。整体结构如图1-5所示。

图1-5 存储引擎处理用户请求的过程

(1)用户接口层

用户接口层为上层应用程序提供可以使用的各种接口,例如添加数据、查询数据、更新数据、删除数据等。这一层是离用户最近的地方,存储引擎的这些接口一般都是支持并发读/写访问的(排除个例)。对并发读/写访问而言,不同类型的存储引擎在设计实现上可能会有所差异。

以一些本地缓存类的存储引擎(如BigCache、FreeCache)为例,它们内部绝大部分是通过不同语言提供的读写锁(RWLock)来保证的,这类锁可以保证读读不互斥,读写、写读、写写互斥,主要用于读多写少的场景中。如果整个组件只有一个全局锁,则明显锁的粒度很大,导致锁很“重”,会成为系统访问的瓶颈。所以绝大部分组件一般会对存储的数据进行分片,每个分片维护一个读写锁,这样就减小了锁的粒度,以保证系统的访问性能。

数据分片策略最常用的就是哈希分片。常规的做法是:缓存组件内部会维护一个大小为 n (分片数)的分片数组shards;在初始化该组件时,会预先创建好 n 个数据分片对象存入shards中;后续在处理数据读/写时,首先根据传入的 k 值计算其对应的Hash值,然后用Hash值对分片数 n 取余( m =hash( k )% n ),取余后的数值 m 即该数据对应的分片在分片数组shards中的下标,也就是说,shards[ m ]为存储数据 k 对应的分片;接着在该分片内部加锁,然后执行对应的操作即可。

再比如,一些存储引擎(如InnoDB、LevelDB、RocksDB等)则是通过保留一份数据的多个版本,不同请求访问不同版本的内容,来解决读/写并发问题的,即采用MVCC(Multi-version Concurrency Control,多版本并发控制)技术。多版本并发控制内部比较复杂,后面章节会介绍这部分内容。

(2)内存层

由用户接口层接收的内容会首先传递到内存中,也就是到达内存层,那内存层一般会做什么工作呢?

在回答这个问题前,读者先将保存数据的内存空间想象成一个非常大的连续区域(假设为buffer,buffer为空间无限大的byte数组)。通过接口传递进来的k和v数据最终会存储到buffer中,后面查询时会从buffer中获取对应的内容。而k和v都是byte类型,而且长度是不固定的,所以首先面临的一个问题就是数据以何种格式进行组织,也就是常说的数据编码。数据编码可以抽象成一个方法,它的入参是要存储的k和v,而返回值则是编码好的byte(二进制)数据。和编码对应的过程是解码,解码的工作是将传入的byte数据解析还原成原先写入的k和v。数据存储时需要编码,而数据读取时则需要解码。当k和v数据编码完成后就可以写入buffer中了。

接着来看写入buffer后的数据后面应该如何读取。先来思考一下如何从数组buffer中读取数据。数组中访问元素都是通过下标来实现的,因此要读取之前存入的数据,就必须知道两个要素:待读取数据的起始位置(start)、待读取数据的结束位置(end)。有了这两个要素就知道buffer中的[start,end]区域对应的就是之前写入的内容了,而end-start就是写入数据的长度(length)。仔细思考可以发现,<start,end,length>这三个要素中只要知道了其中两个要素就可完成数据读取的工作了。简单来说就是, 用户将长度为length的数据写入数组buffer中,起始位置为start 。那问题来了,这两个要素何时可以知道呢?自然是在写入数据的时候就知道了。因此在数据写入buffer时只要记录下<start,length>,后面读取buffer中[start,start+length]的数据即可。类似<start,length>这种前置信息称为 索引

(3)磁盘层

内存层要做的主要工作是数据编码和索引存储。磁盘层主要负责数据的持久化,即将内存中存储的数据写入磁盘文件中,当节点宕机或者系统重启后,可以从磁盘中恢复数据,以保证数据的持久性。此处的磁盘是一种泛指,具体的磁盘介质可能有很多,例如HDD(Hard Disk Drive,机械磁盘)、SSD(Solid State Drive,固态硬盘)、HHD(Hybrid Hard Drive,混合硬盘)等。

数据有落盘的存储引擎,在写入时会实时写入一份数据到磁盘,部分系统通过WAL日志数据落盘来保证,而在数据读取时一般都会先从内存已缓存的数据中检索待查找的数据是否存在,如果缓存中不存在则从磁盘加载数据,然后返回给上层应用程序。返回后需根据具体的缓存策略将该数据缓存一份到内存中。

1.2.2 存储引擎的共性问题

通过前面的介绍可以发现,数据如何编码、索引如何存储这两个问题是共性问题,不管是何种存储引擎还是何种存储组件都无法避免。

数据的编码经常采用TLV格式。而索引的存储主要是选择恰当的数据结构来维护索引信息。关于数据结构的详细内容将在第2章介绍。

存储介质可以分为两类:易失性存储和非易失性存储。寄存器、CPU缓存、内存都属于易失性存储,而PMEM、SSD、HDD属于非易失性存储。非易失性存储PMEM既具备和内存访问差不多的速度,同时容量比内存大且支持持久化存储。关于存储介质将在第3章进行详细介绍。 cCzs74Ue9NxXAejUbbmurDTy9SLfKG+qbHf9Yyad6R4eiA5llsufAH4P+riB5NnJ

点击中间区域
呼出菜单
上一章
目录
下一章
×