上一节中我们讲到,目前页布局的两种主要方法,分别是槽位页和日志结构。
槽位页设计存在的问题:
1.碎片化:删除元组会在页面中留下空隙,导致空间利用率不全。
2.无用的磁盘I/O:由于非易失性存储(磁盘存储)是按块操作的特性,即使只更新一个元组,也需要读取整个块。
3.随机磁盘I/O:更新20个不同的元组可能需要磁盘磁头跳转到20个不同的位置,速度很慢。
日志结构存储概述:此结构下,内存中我们有一张跳表mtable,mtable中存储的是日志记录,日志记录中的日志条目记录操作而非对应元组的完整状态。当mtable积累一定量后,会将其压缩为SSTable文件并提交到磁盘上。
如何理解SSTable文件:比如有甲乙丙丁四人,数据库存储它们的登录时间。
第一次提交:假如第一次提交之前,有甲在时间1登录,乙在0登录,丙在2登录,它们三人登录时间记录被压缩为一个SSTable并提交到磁盘中。
第二次提交:假如第一次提交之后,第二次提交之前,有甲在4登录,乙在5登录,丁在7登录,它们三人的登录时间又被压缩为一个SSTable并提交到磁盘中。
可以看到SSTable是在特定的某一段时间内,数据库被修改元组的集合。最新的SSTable和之前的所有SSTable压缩可以成为一个完整的数据库。
注:SSTable之间是有时间顺序的,假如最新的SSTable和一个旧的SStable之间都有关于元组A的信息,那么显然应该使用最新SSTable的记录。
查询元组:现在mtable中查找相关元组的日志记录,假如刚好有相关元组的PUT记录,那么直接在内存中查找到元组。如果内存中没有找到,那么需要按照时间顺序将磁盘中的SSTable文件加载到内存中再进行查找。
压缩:注意区分,日志记录转换为SSTable并非压缩,SSTable之间的压缩才叫压缩。假如SSTable之间永远不压缩,那么会出现无数个SSTable。这在时间和空间上都是不可接收的。因此DBMS会定期进行压缩。
压缩策略:
1.通用压缩:一种懒惰的SSTable合并方式,等到SSTable文件实在太多才进行压缩。但文件重叠严重,好处是写放大低。
2.分层压缩:Level0为乱序区,Level1及以上为整齐区,每一层内的所有文件都必须排好序,且互不重叠,只有当上一层达到上限时才会合并到下一层。比如Level1的文件1存1到100,文件2存101到200。这种方式空间利用率更高,但是写放大极大,写一条数据,可能因为层层合并被翻来覆去的反复写。
不论哪种策略,压缩的开销都很大,且会导致写放大,即一次逻辑写入可能导致多次物理写入。
记录内容:包含元组的唯一标识符、操作类型(PUT/DELETE),对于 PUT 操作还包含元组的具体内容。
优缺点:写速度极快,读速度可能较慢。磁盘写入是顺序的,且现有页面不可变,这减少了随机磁盘 I/O。非常适合"仅追加"的存储介质。
索引加速:为了避免漫长的读取过程,DBMS 可以建立索引(簿记)来跳转到日志中的特定位置。
索引组织存储
无论是面向页的存储,还是日志结构存储,由于表本身是无序的,它们都依赖于额外的索引来查找单个元组。
在索引组织存储的方案中,DBMS直接将表的元组作为索引数据结构中的值进行存储,在这种模式下,DBMS会使用类似于插槽页的页面布局,并且元组在页面内通常是按照键进行排序存储的。
数据表示
元组中的数据本质上只是字节数组 ,它本身并不记录属性所属的数值类型。DBMS 必须负责记录如何追踪并解析这些字节。数据表示方案指的就是 DBMS 如何为某个值存储字节。
DBMS必须确保元组是字对齐的,以便CPU能够直接访问而不会出现意外行为或产生额外的处理开销,通常采用两种方案:
-
填充 (Padding):在属性后添加空位,以确保元组实现字对齐。
-
重排序 (Reordering):在物理布局中交换属性的顺序,以确保它们是对齐的。
元组中可以存五种高层数据类型:整数,变精度数字,定点精度数字,变长值以及日期/时间。
整数:大多数DBMS使用IEEE-754 标准指定的原生C/C++ 类型来存储整数。这些值是定长 的。 示例:INTEGER, BIGINT, SMALLINT, TINYINT。
变精度数字:这些是不精确的、变精度的数值类型,使用 IEEE-754 标准指定的"原生"C/C++ 类型。这些值也是定长 的。 对变精度数字的操作计算速度比任意精度数字更快,因为 CPU 可以直接对它们执行指令。然而,由于某些数字无法精确表示,在进行计算时可能会出现舍入误差 。 示例:FLOAT, REAL。
定点精度数字:这些是具有任意精度和标度的数值类型。它们通常以精确的、变长 的二进制形式存储(几乎像字符串一样),并带有额外的元数据,用于告知系统数据的长度以及小数点的位置。 当舍入误差不可接受时,会使用这些数据类型,但 DBMS 为了获得这种准确性会付出性能代价 。 示例:NUMERIC, DECIMAL。
变长数据:这些代表任意长度的数据类型。它们通常存储有一个头部 (Header),用于追踪字符串的长度,以便轻松跳转到下一个值。头部还可能包含数据的校验和 (Checksum)。
大多数 DBMS 不允许单个元组的大小超过单个页面的大小。允许超过的系统会将数据存储在特殊的溢出页中,并在元组中包含对该页面的引用。这些溢出页可以包含指向额外溢出页的指针,直到存储完所有数据。
某些系统允许将这些大值存储在外部文件 中,此时元组将包含指向该文件的指针。例如,如果数据库存储照片信息,DBMS 可以将照片存储在外部文件中,而不是让它们占用 DBMS 中的大量空间。这种做法的一个缺点是 DBMS 无法操作该文件的内容。因此,不具备持久性 (Durability) 或事务保护 。 示例:VARCHAR, VARBINARY, TEXT, BLOB。
日期和时间:不同系统对日期/时间的表示方式各不相同。通常,这些被表示为自 Unix 纪元以来的某种单位时间(微秒/毫秒)。 示例:TIME, DATE, TIMESTAMP。
空值类型:DBMS 中有三种常见的表示空值 (Null) 的方法:
-
空列位图头部 (Null Column Bitmap Header):在集中式的头部中存储一个位图 (Bitmap),指明哪些属性为空。这是最常用的方法。
-
特殊值 (Special Values):为某种数据类型指定一个特定的值来代表 NULL(例如,使用 INT32 的最小值)。
-
按属性设置 Null 标记 (Per Attribute Null Flag) :存储一个标记来指明某个值是否为空。不推荐这种方法,因为它的内存效率不高。为了避免破坏字对齐,DBMS 必须为每个值使用超过一个位 (Bit) 的空间。
系统目录:为了让DBMS能够解析元组的内容,它维护了一个内部目录,用于记录关于数据库的元数据。
元数据内容:
-
数据库拥有的表 和列 ,以及这些表上的任何索引。
-
数据库的用户 及其拥有的权限。
-
关于表的统计信息以及其中包含的内容(例如,某个属性的最大值)。
大多数DBMS将它们的目录以自身存储表时所使用的格式存储在数据库内部,它们使用特殊的代码来引导这些目录表。
既然目录也是表,那么读取目录表也需要目录信息,因此DBMS会在代码中硬编码最核心目录表的结构(如pg_class或pg_attribute),从而开启整个系统。