硬核项目手写 KV 存储—轻松拿捏面试官!

roseduan777 · · 2420 次点击 · · 开始浏览    
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

> 本文是《从零实现 KV 存储》课程的面试要点总结,相当于只要你学习了课程,以下提到的内容都是你自己完成的。对课程感兴趣的同学可以进这个链接查看详情:[https://w02agegxg3.feishu.cn/docx/Ktp3dBGl9oHdbOxbjUWcGdSnn3g](https://link.zhihu.com/?target=https%3A//w02agegxg3.feishu.cn/docx/Ktp3dBGl9oHdbOxbjUWcGdSnn3g) ## **在简历上如何写这个项目?** **项目概述** 基于 Bitcask 模型,兼容 Redis 数据结构和协议的高性能 KV 存储引擎 **设计细节** - 采用 Key/Value 的数据模型,实现数据存储和检索的快速、稳定、高效 - 存储模型:采用 Bitcask 存储模型,具备高吞吐量和低读写放大的特征 - 持久化:实现了数据的持久化,确保数据的可靠性和可恢复性 - 索引:多种内存索引结构,高效、快速数据访问 - 并发控制:使用锁机制,确保数据的一致性和并发访问的正确性 - 编程语言:采用 Go/Rust(根据实际情况写 Go 或者 Rust) 编写,兼顾高性能以及编码简洁性 **结果** - 性能方面:相较于其他同类型的存储引擎,读写性能稳定快速,相较于 redis,能够基本在一个数量级,但是节省了大量的内存空间 - 例如 leveldb、bolt、badger、sled 等等,做个简单的对比 - 可靠性:依赖数据文件的持久化特性,确保了数据的可靠存储和恢复,降低数据丢失的风险 - 简洁直观的用户 API,支持内嵌式的基础 Put/Get/Delete 等接口,也可以通过 HTTP 接口进行数据访问,也可以通过 redis client 进行直接访问 ## **可能的面试问题&回答** ### **你做的这个项目能简单介绍一下吗** 我开发的项目是一个基于 Bitcask 存储模型的 KV 数据库。bitcask 是一种高性能的持久化存储引擎,其基本原理是采用了预写日志的数据存储方式,每一条数据在写入时首先会追加写入到数据文件中,然后更新内存索引,内存索引存储了 Key 和 Value 在磁盘上的位置,读取数据时,会先从内存中根据 key 找到对应 Value 的位置,然后再从磁盘中取出实际的 Value。 基于这种模型,其读写性能都非常高效快速,因为每次写入实际上都是一次顺序 IO 操作,然后更新内存,每次读取也是直接从内存中找到对应数据在磁盘上的位置。 ### **为什么会做这个项目** 这个问题的答案因人而异,可以根据自己的情况来回答,例如: > 对数据库存储系统实现的好奇心,看到了对应的 Bitcask 的论文,想要自己去实现 > 弥补 redis 的缺陷,因为 redis 是一种基于内存的数据库,在数据量较大的情况下,对内存的压力会非常大,而 Bitcask 可以规避这个缺点,显著降低内存使用量 > 参加数据库比赛,针对性的设计了一种存储引擎 > 现有的存储引擎例如基于 B+ 树,读性能稳定,但是写数据是随机 IO,性能较差,LSM Tree 写性能优秀,但是读性能不稳定,读放大、写放大、空间放大问题严重;而 Bitcask 存储模型的读写性能则非常的稳定快速。 ### **有哪些适用场景** **缓存系统** KV 数据库可用作缓存系统的后端存储,以提供快速的数据访问和响应能力。由于 Bitcask 存储模型具有高性能和低读写放大的特性,它适合存储频繁访问的热数据,提供快速的缓存读取操作。 **日志存储** KV 数据库可以作为日志存储系统使用,将日志数据持久化到磁盘上的日志文件中。Bitcask 存储模型的追加写入方式使得日志的写入操作非常高效,确保了日志的可靠存储和后续分析。 **Key 小 Value 大的 KV 数据存储** Bitcask 将 key 和对应的索引都维护在了内存当中,这样如果 key 较小的话,那么内存当中能够维护的数据量就更多,并且 Value 是在磁盘上存储的,因此可利用磁盘更大的空间来存储 Value。 ### **优缺点是什么** **优点** - **读写低延迟** 这是由于 Bitcask 存储模型文件的追加写入特性,充分利用顺序 IO 的优势。 - **高吞吐量,即使数据完全无序** 写入的数据不需要在磁盘上排序,Bitcask 的日志结构文件设计在写入过程中减少了磁盘磁头的移动。 - **能够处理大于内存的数据集,性能稳定** 数据访问涉及对内存中的索引数据结构进行直接查找,这使得即使数据集非常大,查找数据也非常高效。 - **一次磁盘 IO 可以获取任意键值对** 内存索引数据结构直接指向数据所在的磁盘位置,不需要多次磁盘寻址来读取一个值,有时甚至不需要寻址,这归功于操作系统的文件系统缓存以及 WAL 的 block 缓存。 - **性能快速稳定** 写入操作最多需要一次对当前打开文件的尾部的寻址,然后进行追加写入,写入后会更新内存。这个流程不会受到数据库数据量大小的影响,因此性能稳定。 - **备份简单** 在大多数系统中,备份可能非常复杂。Bitcask 通过其只追加写入一次的磁盘格式简化了此过程。任何按磁盘块顺序存档或复制文件的工具都将正确备份或复制 Bitcask 数据库。 - **批处理操作可以保证原子性、一致性和持久性** 支持批处理操作,这些操作是原子、一致和持久的。批处理中的新写入操作在提交之前被缓存在内存中。如果批处理成功提交,批处理中的所有写入操作将持久保存到磁盘。如果批处理失败,批处理中的所有写入操作将被丢弃。 即一个批处理操作中的所有写入操作要么全部成功,要么全部失败。 **缺点** - **所有的 key 必须在内存中维护** 始终将所有 key 保留在内存中,这意味着您的系统必须具有足够的内存来容纳所有的 key。 - **启动速度受数据量的影响** 数据库启动时,会加载所有的数据,并且会重放所有的操作,以此来构建内存索引,如果数据量较大,这个过程可能会非常漫长 ### **磁盘上产生了无效的数据,如何清理** 实现了 Bitcask 论文中提到的 Merge 方案,Merge 实际上就是对磁盘数据空间进行清理的操作,其基本执行流程是遍历所有的数据,并将有效的数据进行重写,然后使用新的文件替换旧的文件,以此达到回收空间的效果。 **追问:** - **Merge 的过程会阻塞读写操作吗** - 不会,Merge 实际上是在新的目录打开了新的 Bitcask 进程实例,这个实例和原目录上运行的实例互不冲突,Merge 的时候,只会读取原 Bitcask 实例的索引数据结构,判断数据是否有效,并不会对原来的实例产生任何影响,并且原实例的写入会写到新的文件中,不会参与到 Merge 过程中,所以对写入也没有影响。 - **Merge 过程万一很漫长,中途挂了怎么办** - 在具体实现中,会在 Merge 结束之后,在磁盘文件中写入一个 Merge 完成的标识,只有当有这个标识的时候,我们才认为一次 Merge 是完整的,否则 Merge 都是不完整的,直接删除掉 Merge 的数据目录即可。 ### **写操作是如何保证原子性的** 采用了预写日志的方式,和其他大多数系统一样,WAL 通常是保证事务原子性和持久性的关键,在 Bitcask 存储模型中,比较特殊的是 WAL 文件本身就是数据文件,所以天然可以保证原子性,我们在写入的时候加上了一个完成的标识,并且给每一批次的数据都附了一个全局递增的 id,只有全部提交完成了,这个批次的数据才算完成,否则都不会进行加载。 ### **和 LevelDB、BoltDB、Redis 的区别** LevelDB 是经典的 LSM Tree 存储模型,其基本架构大致分为了 WAL、memtable、SSTable,数据写入首先会到 wal 中保证持久化,然后更新到内存的 memtable 中,如果 memtable 满了,则 flush 到磁盘的 sstable 中。 读数据会从 memtable 查找,如果没找到,则从磁盘上的多级 sstable 中查找,读性能不稳定。 BoltDB 是 B+ 树存储模型,读性能稳定,但是写入是随机 IO,性能较差。 Redis 是一种纯内存的数据结构服务,也可以持久化到磁盘中,但其实际上是一种面向内存的 KV 存储,数据量受到内存容量的影响。 而 Bitcask 存储模型,写性能和 LSM 模型相当,读性能也很稳定,读写都是一次磁盘 IO 操作即完成,并且相较于 Redis,Value 是不会存储到内存中,节省了内存空间,并且性能能够和 Redis 维持在一个数量级。 ### **做了哪些改进** - **内存索引限制** - 前面说到,Bitcask 的一大缺点就是所有的 key 都必须在内存中维护,这样数据库中能存储的数据量就受到了内存容量的限制,而我的项目中,创造性的使用了持久化的 B+ 树来作为索引数据结构,这样就可以将索引存储到磁盘上,突破了内存容量的限制。但是这样带来的一个副作用便是读性能会下降,因为原来是直接从内存中就能到获取到 Value 的位置,但是使用 B+ 树存储索引的话,还需要从磁盘 B+ 树中获取索引,然后再到磁盘中获取 Value,相当于多了几次磁盘 IO 操作 - **内存索引锁粒度优化** - 在我的最初的项目中,内存索引是单个数据结构,并且为了保证并发安全,这个结构的读写都需要加锁,如果在大数据量下,所有的数据都会竞争这把锁,所以我将索引进行了分区,并通过哈希函数将 key 映射到不同的索引结构当中,大大减少了并发冲突 - **启动速度优化** - 为了避免在重启的时候全量加载所有的数据来构建内存索引,我的项目中实现了 Bitcask 论文中提到的 Hint 文件,Hint 文件实际上就是一个 key+索引的文件,它不存储 Value,相较于原始文件容量会小很多,这样重启加载的时候,直接加载这个 Hint 文件 - 追问 1:Hint 文件是在什么时候生成的呢 - Merge 的时候生成的,Merge 时实际上所有的数据都是有效的,这个时候只需要依次存储对应的 key 和索引数据 - 追问 2:Hint 文件的格式是什么 - 和数据文件是一样的,都采用了日志追加的方式 - **持久化策略优化** - 在最开始的设计中,默认的刷盘策略是交给了操作系统来调度,这样的好处是性能很好,但是存在丢失数据的风险,在实际环境中,我们可以再提供另一个选项,用户可以设置积累到多少个字节之后,再进行一次持久化。这相当于提供了一个折中的方案,相较于之前的要么每条都持久化,要么完全交给操作系统,这样能够让用户自己灵活配置。 - **数据文件布局优化** - 我还参考了 LevelDB 和 RocksDB 的 WAL 文件的格式,将数据文件的组织形式改为 block(32KB) 的方式,加速数据读写的效率。 - **兼容 HTTP 协议** - 单纯的 KV 接口大多只能在嵌入式的场景中使用,但无法作为远程调用服务使用,所以我在存储引擎的基础之上,加上了 HTTP 的访问接口,这样便可以将存储引擎作为 HTTP 服务使用 - **兼容 Redis 数据结构和协议** - 原生的 KV 接口能够满足的需求比较有限,而 Redis 支持了多种数据结构,比如 String、List、Hash、Set、ZSet,满足了多样化的需求。于是我在 KV 的接口之上,兼容了 Redis 的数据结构,并且兼容了 Redis 的通信协议 RESP,这样一是可以满足多样需求,二是可以让用户无缝切换到我的存储项目中 > 注:以上是我个人能够想到的一些东西,但在实际场景中,可能问的问题会更多,大家如果有相关的经验,可以在这里留言分享!

有疑问加站长微信联系(非本文作者))

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

2420 次点击  
加入收藏 微博
1 回复  |  直到 2023-07-07 11:43:42
暂无回复
添加一条新回复 (您需要 登录 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传