一、前言
我们来看一个世界上最简单的数据库,它由两个Bash函数组成
#!/bin/bash
db_set () { echo "$1,$2" >> database }
db_get () { grep "^$1," database | sed -e "s/^$1,//" | tail -n 1 }
这两个函数实现了一个key-value存储。执行 db_set key value
会将 键(key) 和 值(value) 存储在数据库中。键和值(几乎)可以是你喜欢的任何东西,例如,值可以是 JSON 文档。然后调用 db_get key
会查找与该键关联的最新值并将其返回。
例如:
$ db_set 123456 '{"name":"London","attractions":["Big Ben","London Eye"]}'
$ db_set 42 '{"name":"San Francisco","attractions":["Golden Gate Bridge"]}'
$ db_get 42 {"name":"San Francisco","attractions":["Golden Gate Bridge"]}
它的底层存储格式其实非常简单:一个纯文本文件。 其中每行包含一个key-value对,用逗号分隔。
每次调用db_set就会追加新内容到文件末尾,因此,如果多次更新某个键,旧版本的值不会被覆盖,而是需要查看文件中最后一次出现的键来找到最新的值。
例如:数据库里面的数据如下
$ cat database
123456,{"name":"London","attractions":["Big Ben","London Eye"]}
42,{"name":"San Francisco","attractions":["Golden Gate Bridge"]}
42,{"name":"San Francisco","attractions":["Exploratorium"]}
执行命令db_get 42
$ db_get 42
{"name":"San Francisco","attractions":["Exploratorium"]}
对于这个简单数据库来说,它就像一个日志文件(许多数据库内部都是用日志log,日志是一个仅支持追加式更新的数据文件)
- 优势:简单,写入快。
- 缺点:如果日志文件保存了大量的记录,那么db_get函数的性能较差,需要扫描从头到尾扫描整个数据库文件来查找键出现的位置。开销是O(n)。数据库记录越多,越慢。
解决思路:
为了高效地查找数据库中特定key的value,需要新的数据结构:索引
。
索引
- 优势:提高查询效率
- 劣势:维护额外的结构势必会引入开销,使写入变慢。对于写入,它很难超过简单地追加文件方式的性能,因为这已经是最简单的操作了。
二、索引
2.1 HASH索引
首先我们以key-value数据的索引开始。key-value类型并不是唯一可以索引的数据,但它随处可见,而且是其他更复杂索引的基础构造模块。
key-value存储与大多数编程语言所内置的字典结构非常类似,通常采用hash map(或者hash table)实现。 如果我们要给刚刚的数据库加一个索引策略:
索引策略: 保持内存中的hash map,把每个键一一映射到数据文件中特定的字节偏移量,这样就可以找到每个值的位置。
图3-1:文件存储key-value,内存存储hashmap索引
事实上,这就是Bitcask(Riak中的默认存储引擎)所采用的核心做法。提供了高性能的读和写,只要所有的key都可以放进内存中
。这种非常时候每个键的值频繁更新的情况。而value的数据量则可以超过内存大小
,只需要一次磁盘寻址,就可以将value从磁盘加载到内存中。
像 Bitcask 这样的存储引擎·、非常适合每个键的值经常更新的情况
。例如,键可能是某个猫咪视频的网址(URL),而值可能是该视频被播放的次数(每次有人点击播放按钮时递增)。在这种类型的工作负载中,有很多写操作,但是没有太多不同的键 —— 每个键有很多的写操作,但是将所有键保存在内存中是可行的。
新的问题:如果只追加到一个文件,那么如何避免最终用尽磁盘空间?
解决方案:
- 把日志分解成一定大小的段,当文件达到一定大小时就关闭它,并将后续写入到新的段文件中。然后可以在这些段上执行压缩(压缩是指在日志中丢弃重复的键,并且只保留每个键最新的更新)。
图3-2:压缩key-value更新日志文件(计算每个猫视频播放的次数),仅保留每个键的最新值
- 由于压缩往往使得段更小,也可以在执行压缩的同时,将多个段合并在一起。如图3-3所示。由于段在写入后不会再进行修改,所以合并的段会被写入另一个新的文件。对于这些冻结段的合并和压缩过程可以再后台线程中完成,而且运行时,仍然可以用旧的段文件继续进行正常读取请求。当合并过程完成后,将读取请求切换到新的合并段上,而旧的段文件可以安全删除。
图3-3:同时执行段压缩和多个段合并
现在,每个段都有自己的hash map,将key映射到文件的偏移量。为了找到value,首先检查最新的段的hash map;如果键不存在,检查第二最新的段,以此类推。由于合并过程可以维持较少的段,因此查找通常不需要检查很多hash map。
其他问题:
-
删除记录:如果要删除一个键及其关联的值,则必须在数据文件中追加一个特殊的删除记录(逻辑删除,有时被称为墓碑,即 tombstone)。当日志段被合并时,合并过程会通过这个墓碑知道要将被删除键的所有历史值都丢弃掉。
-
崩溃恢复:如果数据库重新启动,则内存散列映射将丢失。原则上,你可以通过从头到尾读取整个段文件并记录下来每个键的最近值来恢复每个段的散列映射。但是,如果段文件很大,可能需要很长时间,这会使服务的重启比较痛苦。 Bitcask 通过将每个段的散列映射的快照存储在硬盘上来加速恢复,可以使散列映射更快地加载到内存中。
-
部分写入记录:数据库随时可能崩溃,包括在将记录追加到日志的过程中。 Bitcask 文件包含校验和,允许检测和忽略日志中的这些损坏部分。如果发现某条记录的校验失败或者读取时结构不完整(比如 key 长度写了但没写完 key),就说明这是一条 “写了一半的、坏掉的记录” ,会被自动忽略掉,不会加载进索引或影响后续数据。
-
并发控制:由于写操作是以严格的顺序追加到日志中的,所以常见的实现是只有一个写入线程。也因为数据文件段是仅追加的或者说是不可变的,所以它们可以被多个线程同时读取。
采用追加写入的方式而为什么不原地更新文件,用新值覆盖旧值?
-
追加和分段合并都是顺序写入操作,通常比随机写入快得多,尤其是在磁性机械硬盘上。在某种程度上,顺序写入在基于闪存的 固态硬盘(SSD) 上也是好的选择。
-
如果段文件是仅追加的或不可变的,并发和崩溃恢复就简单多了。例如,当一个数据值被更新的时候发生崩溃,你不用担心文件里将会同时包含旧值和新值各自的一部分。
-
合并旧段的处理也可以避免数据文件随着时间的推移而碎片化的问题。
Hash索引的局限性
-
散列表必须能放进内存。如果你有非常多的键,那真是倒霉。原则上可以在硬盘上维护一个散列映射,不幸的是硬盘散列映射很难表现优秀。它需要大量的随机访问 I/O,而后者耗尽时想要再扩充是很昂贵的,并且需要很烦琐的逻辑去解决散列冲突
-
范围查询效率不高。例如,你无法轻松扫描 kitty00000 和 kitty99999 之间的所有键 —— 你必须在散列映射中单独查找每个键。
接下来,我们看看如何解决这两个问题
2.2 SSTables和LSM-Tree
关于我们之前提到的Hash索引的局限性,我们用Sorted String Tables来解决。
Sorted String Tables顾名思义就是排序字符串表。它要求每个键在每个合并的段文件中只能出现一次,并且每个键都是排序的。
图 3-4 合并几个 SSTable 段,只保留每个键的最新值
图 3-5 SSTable及其内存中的索引
左边的是稀疏索引,右边是每个稀疏索引所关联的SSTable
2.2.1 如何构建和维护SSTable
- 当写入时,将其添加到内存中的平衡树数据结构中(例如红黑树或AVL树)。这个内存中的树有时候被称为内存表。
- 当内存表大于某个阈值(通常为几兆字节)时,将其作为SSTable文件写入磁盘。由于树已经维护了按键排序的key-value对,写磁盘可以比较高效。新的SSTable文件成为数据库的最新部分。当SSTable写入磁盘的同时,写入可以继续添加到一个新的内存表实例。
- 为了处理读请求,首先尝试在内存表中查找键,然后是最新的磁盘段文件,接下来是次新的磁盘段文件,以此类推,直到找到目标(或为空)
- 后台进程周期性地执行段合并与压缩过程,以合并多个段文件,并丢弃那些已被覆盖或删除的值。
2.2.2 工作流程
-
写操作 → MemTable(内存)
-
MemTable 满 → 刷盘生成 SSTable
-
多个 SSTable 并存 → 合并压缩优化查找
-
读操作:
- 查询 Bloom Filter → 判断是否可能存在
- (布隆过滤器是内存高效的数据结构,用于近似计算集合的内容。如果数据库中不存在某个键,它能够很快告诉你结果,从而节省了很多对于不存在的键的不必要的磁盘读取)
- 查索引文件 → 定位具体数据
- 查磁盘中的 SSTable 文件
- 查询 Bloom Filter → 判断是否可能存在
2.2.3 其他问题
问题一:如果数据库崩溃,最近的写入(在内存表中但尚未写入磁盘)将会丢失。
为了避免该问题,可以在磁盘上保留单独的日志,每个写入都会立即追加到该日志。可以通过日志文件来恢复内存表。每当将内存表写入SSTable时,相应的日志可以被丢弃。
问题二:在多个 SSTable 并存 → 合并压缩优化查找 的时候 如果此时有读请求会影响读请求吗?
✅ 不会阻塞读请求,但可能影响读性能
现代数据库在设计 SSTable 和 Compaction 的时候,会专门处理好读写并发的问题,具体如下:
🔄 1. Compaction 是在后台异步进行的
- Compaction 是一个异步后台任务,不会阻塞前台的读操作。
- 当读请求发生时,数据库可以继续从旧的 SSTable 中查找数据,即使某些 SSTable 正在被合并。
🧠 2. 读操作可以并发读取未被删除的旧 SSTable
- 在 Compaction 期间,旧的 SSTable 还在磁盘上保持只读可用状态,直到新合并的 SSTable 成功写入并替换旧的。
- 也就是说,在新的 SSTable 写入完成之前,读操作仍然访问旧的 SSTable,不会“找不到数据”。
📉 3. 可能影响性能但不影响正确性
-
虽然不会“阻塞”读,但读性能可能会略受影响:
- 磁盘 I/O 被后台 Compaction 占用
- 数据还没合并,读请求可能需要从多个 SSTable 里查找(更多 Bloom Filter 命中判断 + 索引文件查找)
🔧 4. 优化手段
为减少 Compaction 对读的影响,很多系统做了进一步优化:
- 优先级调度:限制 Compaction 的磁盘 I/O 占用率
- 多线程读写隔离:读线程与 Compaction 线程分开执行
- Compaction 策略:比如 Leveled Compaction(LevelDB/Cassandra)可以降低 SSTable 重叠数量,从而提升读性能
✅ 总结:
项目 | 影响读请求吗 |
---|---|
正确性 | ❌ 不会影响,数据永远能读到 |
性能 | ⚠️ 可能略受影响(I/O 增加、多 SSTable 查找) |
阻塞 | ❌ 不会阻塞,读写可以并发进行 |
问题三:多个 SSTable 并存 → 合并压缩优化查找是怎么合并的?
内存里面的SSTable是不是得知道这个SSTable里面的最大值和最小值,然后才能比较好的去磁盘上找其他SSTable合并?具体过程是怎样的?
❗并不需要遍历整个 SSTable 进内存,只需读取并维护每个 SSTable 的元信息(min/max key),这通常在写入 SSTable 时就记录了。
系统使用这个元信息来快速判断哪些 SSTable 的 key 范围有重叠,是否需要合并。
📌 总结:Compaction 的合并机制
步骤 | 描述 |
---|---|
Step 1 | 读取 SSTable 元数据(min/max key) |
Step 2 | 选出有 key 重叠的 SSTable 文件 |
Step 3 | 顺序读取这些文件,归并 key |
Step 4 | 选择最新值或删除 tombstone |
Step 5 | 写出新的 SSTable |
Step 6 | 更新元数据,删除旧文件 |
2.3 B-trees
像LSM-tree这样的日志结构索引将数据库分解为可变大小的段,通常大小为几兆字节或更大,并且始终按顺序写入段。
相比之下,B-tree将数据库分解为固定大小的块或页,传统上大小为4KB(有时更大)