Log-Structured Database Storage ✸ SingleStore Database Talk
Buffer Pool Optimizations
Multiple Buffer Pools
可以为每个数据库、每张表、甚至每种页面类型(比如索引页 vs 数据页)分别设置一个缓冲池。这样做的好处是:我们可以根据系统如何使用这些数据,为每个缓冲池定制更合适的淘汰策略(eviction policy),从而做出更优的“何时淘汰哪个 Page ”的决策。此外,当多个工作线程并发访问时,多个缓冲池还能减少锁竞争(latch contention),因为数据被分散到了不同结构中。
实现方式通常有两种:
- 基于对象 ID 路由:当你需要访问某个记录(record)时,可以从记录 ID 中提取出对象 ID(比如表或索引的 ID),然后根据这个 ID 决定去哪个缓冲池找对应的页。关键点是:任何物理页在任意时刻只能存在于一个缓冲池中,否则会出现数据不一致的问题(比如两个副本被同时修改)。
- 哈希分区:直接对记录 ID 或页号做哈希,然后对缓冲池数量取模,决定该去哪个缓冲池查找。这种方式同样保证了“一页只在一个缓冲池中”。
注意,即使有多个缓冲池,底层仍然有一个磁盘调度器(disk scheduler),它可以汇总所有读写请求,进行排序和合并,避免大量随机 I/O,从而提升磁盘效率。
PRE-Fecthing
当只读取 Page 0 和 Page 1 时,可以对 Page 2 和 Page 3 进行预处理,一起从 Disk 中存入 Buffer Pool 中,操作系统的 mmap 就是这样实现的。
Scan Sharing
如果有一个正在运行的查询,它在扫描某一个 Table ,同时出现了另外一个查询,也在扫描这个 Table ,可以直接让第二个查询直接获取到第一个查询扫描过的内容。有时候也被叫做同步查询(synchronized scans)。它是在系统最底层实现的——比如当某个游标(cursor)正在从页目录(page directory)读取页面、顺序扫描表的时候。
注意:这不是结果缓存(result caching)。
这种功能其实很少有系统支持。不过一些高端数据库确实实现了它,比如 DB2、SQL Server、Teradata 和 PostgreSQL。PostgreSQL 是个例外(指开源系统中少有的支持者)。而 Oracle——尽管它的创始人 Larry Ellison 现在是世界首富——并没有真正实现这个功能。Oracle 有一个看起来类似的功能,但有个严重限制:只有当两个查询的 SQL 字符串完全相同时,才能共享扫描。SELECT * FROM employees SELECT * FROM Employees SELECT * FROM employees ,这三个查询在 Oracle 中都是不匹配的。
举个例子:
假设查询 Q1 是 SELECT SUM(x) FROM A,它从表头开始顺序扫描,逐页读取所需数据。
假设缓冲池只有 3 个帧(frames),当它读到第 3 页时,不得不把第 0 页淘汰出去。就在这时,另一个查询 Q2 到来了——它也要对表 A 做全表顺序扫描,只是计算不同的聚 合(比如 AVG(x))。
数据访问模式完全一样,只是计算逻辑不同。如果我们“傻乎乎地”让 Q2 从头开始:
它会先去读第 0 页——但那一页刚刚被 Q1 淘汰了!
于是我们又得从磁盘重新把它读回来,白白浪费 I/O。但如果我们聪明一点:
可以让 Q2 直接加入 Q1 的扫描过程。
当 Q1 的游标继续向前读取页面(page 3, 4, 5…)时,Q2 也同时处理这些页面。
等 Q1 扫完整张表退出后,Q2 会意识到:“我其实还没看到表开头的那些页(page 0, 1 , 2)!”
于是它再从头开始扫一遍剩下的部分,把之前错过的页面补上。这样做的好处是:最大化每次从磁盘加载页面后的计算利用率。
如果一次磁盘读取能同时服务两个(甚至多个)查询,那就是巨大的性能提升
这个优化只适用于顺序扫描(sequential scan),如果是索引扫描(index scan),或者查询带有 WHERE 条件、只访问部分数据,那两个查询可能读的页面完全不同,就无法共享了。这里讨论的是最简单、最典型的情况:全表顺序扫描。
Slotted Pages
在 Page 中,最主流的布局方式是 Slotted Pages ,Slotted Page 是一种在数据库系统中用于存储表记录(Tuples)的标准页面布局方案。它将一个固定大小的磁盘页(比如 4KB 或 8KB)划分为几个区域: Header 、 Slot Array 、 Tuple Data Area 。
Header 位于 Page 的顶部,存储一些 Page 的 metadata ,让 Database System 快速了解这个 Page。
Slot Array 有点类似于 Hash Table , Slot Array 会存储 Tuple 的偏移量,访问时直接遍历 Slot Array ,获得数据的偏移量,然后直接跳转到需要的 Tuple 地址。
Tuple Data Area 位于 Page 的底部,这些 Tuple 从 Page 底部向上增长(即从末尾开始,向中间扩展) ,这样的好处是当插入新的 Tuple 时, Slot 向下扩展, Tuple 向上扩展,最大化利用空间,删除时,也只需要在 Slot 里标记为空,然后删除 Tuple ;移动时,只需要修改 Slot 的偏移量,然后移动的 Tuple 滑动到空的位置。
Record ID
Record ID 通常是 File ID + Page ID + Slot Number 的组合,用于精确定位一条记录的物理位置。通常这个 ID 不是只用于一张表,而是整个数据库范围内的唯一标识。
Record ID 可以理解成是一行数据在 Disk 中的位置,它是唯一的,但是有可能会变动。
Tuple-Oriented Storage: Reads
当需要获取一个 Tuple 时,就会先获取 Tuple 的 Record ID ,然后去 Page Directory 中查找 Page 的位置,如果不在内存中,就会从 Disk 中加载,然后使用 Record ID 中的偏移量跳转到 Tuple 的位置。
Tuple-Oriented Storage: Writes
当需要插入一个 Tuple 时,只需要检查 Page Directory ,看看是否有空闲的 Page ,是否有足够的空间,如果该 Page 不在内存中,可以从 Disk 中加载,否则就创建一个新的 Page 进行插入。
当需要更新一个 Tuple 时,需要先像 Read 一样找到该 Tuple ,如果需要额外的空间,需要判断是否有足够的空间,如果有就直接写入,如果没有需要先从该 Page 中删除 Tuple ,然后重新插入该 Tuple 。
Index-Oriented Storage
Index-Oriented Storage 的基本思想是使用 Index 作为表本身的存储,通常是一个树状的结构,叶子节点直接存放 Tuple ,不再需要 Record ID 或单独的表堆。
相对来说,查询效率高,空间利用率高,局部性好,插入更新成本高,不适合无序主键插入
Log-Structured storage
Log-Structured storage 的基本思想是不再在 Page 内原地更新记录,而是只允许追加新记录(append-only)。
因为只做追加操作,所以删除、修改等操作也需要特殊处理。虽然这会让某些读取算法变复杂或低效,但写入性能会变得极快。因为所有写入都是顺序追加,磁盘效率极高。
这个存储系统主要有两个数据结构,一个叫做 Mem Table ,在这里进行所有的写入和更新,当它太大时,就会写入磁盘的另一个表中中,磁盘中的表叫做 Sorted-String Table (SSTable) ,SS Table 是一种紧凑的形式。
LSMTree(Log-Structured Merge trees) 可以说只有两个操作,添加和删除。
MemTable 通常是一种树状的数据结构,当记录 PUT(key101, a1) 时,这时内存中会存储 101->a1,就可以在内存中写入,再记录 PUT(key102, b1) ,这时内存中是 101->a1, 102->b1 ,再执行 PUT(key101, a2) 时,内存中就会是 101->a2, 102->b1 ,因为这是在内存中,因此可以更新,如果同一个 key 被多次更新,在写入 SSTable 时,只会保留最新版本。
在存储到 Disk 中时
首先会在 Level 0 按照时间戳从新到旧进行创建 SSTable ,当 Level 0 的 SSTable 有很多了的时候,就会将多个 SSTable 合并成一个大的 SSTable 放到 Level 1 然后将 Level 0 的 SSTable 删除,当 Level 1 的 SSTable 多的时候,还会合成一个更大的 SSTable 放到 Level 2 。
当需要读取的时候,首先会检查 MemTable ,看看是否有需要的的数据,如果有就获取到了,如果没有就需要去 Disk 中获取,这会非常麻烦,因此还需要维护一个 Summary Table ,这个表会提供一些 Metadata ,同时它会给出需要的 key 在哪个 Level 。
每个层级的布隆过滤器(Bloom Filter)—— 快速判断某个键是否可能存在于该 SSTable 中
每个 SSTable 的最小/最大键值范围(Min/Max Key)
在 LSM 中,写入的总是完整的 Tuple(整行数据),而不是只写变化的部分,即使只想改一个字段,系统也会把整条 Tuple 重新写一遍。
新写入 Tuple 后,旧的 Tuple ,不是立即删除,而是通过后台的 Compaction(合并)过程异步清理。
LSMTree 是用较慢的读取速度换取较快的写入速度,同时维护一个 SummaryTable ,让读取速度不要太慢。常用于 NoSQL