Open mrdrivingduck opened 2 years ago
数据库系统不仅涉及到数据管理,还涉及应用、操作系统、网络服务。出于一些原因,数据库系统的架构在课本上显得比较笼统:
对于一个查询来说,其生命周期中流经的模块有:
在此过程中,catalog、memory manager 等子模块需要被全程使用。
处理模型决定了如何执行用户的并发请求,如何把请求映射到 OS 的进程或线程上。
几个概念:
把处理用户的实体称为 DBMS worker,那么可以有三种处理模型:
DBMS worker 之间完全的独立是不可能的,因为它们之间至少需要共享数据库。SQL 请求需要把数据从数据库中搬到服务进程中再搬到客户端。其中有着多级的缓存:
目前的大部分数据库都是在上世纪七十年代到上世纪八十年代期间经历了从学术论文到商业化的过程。在当时,OS 还不能很好地提供线程;就算提供,其实现也五花八门;并且没有很好的性能。直到上世纪 90 年代,OS 才逐渐实现了线程。因此很多 DBMS 要么使用进程模型,要么直接实现了自己的轻量级线程库——其中使用了异步非阻塞接口、调度程序等,从而在减少上下文切换开销的同时获得高性能。相当于把一部分 OS 需要实现的逻辑搬到了 DBMS 中。
为了防止过多的连接打进来,导致 DBMS 性能下降,需要做一些访问控制的工作。
几种不同的并行架构:
NUMA (Non-Uniform Memory Access):共享内存的编程模型,访问本地 RAM 较快,有能力访问远程 RAM 但访问远程 RAM 较慢。
SQL parser:
Parser 首先会将 FROM
子句中的表引用转换为全限定名 server.database.schema.table
,然后检查 catalog 中表是否存在。如果存在,则使用 catalog 中的表信息确认引用的属性存在,另外进行一些标准 SQL 的语法检查。最终确认用户有权限执行查询(当然这个操作也可以推迟到查询执行时进行,因为诸如行安全特性与具体的行数值相关)。结束后,将 SQL 查询的内部表示传递给 query rewriter module。
Rewriter 是一个逻辑组件,一些商业数据库会把 rewriter 实现在 parser 的末期阶段,或 optimizer 的早期阶段。其作用是在 不访问表数据 和不改变语义的前提下简化查询:
WHERE
子句中的谓词重写,甚至插入新的谓词使查询执行时可以过滤更多的数据将查询的内部表示转化为一个有效的查询计划。在大部分系统中,查询会被转换为 SELECT-FROM-WHERE
的 block,在每个 block 内分别做优化,最终在后处理阶段添加一些算子以支持 GROUP BY
、ORDER_BY
、HAVING
、DISTINCT
等特性。查询计划的形式可以是:
主流系统对 Selinger 论文的扩展:
查询编译和重编译:对 prepared statment 的支持。这类查询会通过 parser、rewriter 和 optimizer,然后查询计划将被保存到参数到达时才执行。另外一些系统会动态产生 SQL 语句并将类似语句的查询计划保存在 cache 中。随着时间推移,一个查询可能需要被重新优化,有两种处理方式:
上述两种差异是有历史原因的:对于高端系统,经验丰富的 DBA 不希望自己设计好的查询被 DBMS 弄得无法预测;对于低端系统,用户更希望 DBMS 有自动调优计划的能力。
查询计划是包含算子的数据流图,每个算子封装了查询执行算法。在一些系统中,算子已被编译为字节码,执行器充当运行时解释器;但更多系统中执行器得到的是数据流图,通过递归调用算子的函数完成查询。
现在查询系统主要使用 迭代器模型 实现执行器。每个算子的逻辑独立于其在图中的父节点和孩子节点。迭代器模型耦合了数据流和控制流,只需要一个 DBMS thread 就可以执行整个查询图,不需要复杂的网络协议,比如并发生产者和消费者之间的队列和反馈。另外,还提高了资源利用率。
此外,并行与网络通信也可以封装在算子中,从而维护完整的迭代器模型。
每一个算子会预先分配固定数量的 tuple descriptor,是一个列引用数组,每个列引用指向内存某处的元组及其列偏移。根据元组在内存中的实际位置,可以分为两类:
过度使用 M-tuple 可以防止一页数据被长时间 pin 在 buffer pool 中,也可以防止 pin 后忘记 unpin;但是拷贝数据将导致巨大的性能开销。对于一个会被长时间引用的元组,将其拷贝的做法是值得的。
用于访问不同磁盘数据结构的子函数:heaps、B+ 树索引、hash 索引、多维索引。其大致的 API 也被实现为迭代器模式:
init()
接收一个 search 参数用于过滤元组get_next()
用于返回一个符合条件的元组,直到没有任何元组可以返回其中 search 参数被传入 access method 层有两个原因:
DBMS 有很多设计是通过把要做的事放在一个集合中一次做完,通常是为了 disk I/O 性能。但这里是为了 CPU 性能。
AM 中对某一行的引用方式有着不同的设计:
数据仓库处理历史数据,数据库处理 当前数据。一些数据仓库中的重要技术:
几个 OLAP 概念:
上述概念形成了以 fact 表为中心,被 dimension 表包围的 星型模型:对 fact 表来说有一个主键 N 个外键。
而很多 dimension 表是天然具有层次的(地理上/时间上/...)。比如几个区域的 dimension 表可以组成一个城市的 dimension 表。一个多层次的星型模型,被称为 雪花模型。
大部分对数据仓库的查询包含:
对于这种查询模式,设计专用的优化器来选择最佳查询计划。
列式存储在数据仓库中有巨大优势,尤其适合表比较宽,但每次访问只需要访问很少几列的场合。列存能够进行高效的压缩。但列存需要保证每一行在列与列之间的一致性——但是对于通常为 append-only 的数据仓库来说,通常不是问题。
两种存储管理模型:
由于 DBMS 比 OS 更有能力知道查询的 I/O pattern,因此 DBMS 完全控制磁盘上的块在理论上具有最佳性能——绕开文件系统。缺点:
相比之下,在文件系统中划出一个大文件,同样可以起到线性数组的 page block 效果。研究显示,在 I/O 密集型负载(TPC-C)中,使用文件系统作为存储只有 6% 的性能下降;在普通负载中,性能下降可以忽略不计。
部分系统允许动态调整数据页大小,但需要是 OS 文件系统页大小的整数倍。
大部分文件系统提供了内置的 I/O 缓冲机制,但:
静态 / 动态分配。一个页框数组,每个页框都可以存放一个数据库磁盘块。一般来说从磁盘直接把数据搬到页框中,不作任何格式修改,反之亦然。
Buffer pool 最重要的信息 - hash 表:<逻辑页号 - 页框地址、物理页地址、metadata (dirty bit / pin)>
DBMS I/O pattern 的多样性使 OS 的页面淘汰机制 LRU、CLOCK 等性能较差。DBMS 一般使用改进型算法 LRU2 / 根据页面类型(B+ tree root page / heap page)处理。
DBMS 的查询处理器和事务存储引擎是独立的模块,通过接口耦合。在事务存储引擎中,以下几个模块紧密耦合在一起:
可序列化是并发事务正确性的衡量指标。以下三个著名的技术用于并发控制:
锁管理器通常用于实现 2PL,MVCC 和 OCC 通常作为 2PL 的附加。MVCC 减少了锁的使用,代价是无法提供完全的可序列化。
Lock 管理本质上提供了一个可以注册名称的地方,本质上是一个 hash table:
另外还提供一个事务表:
Lock 管理器提供两种操作(2PL):
lock(lockname, transactionId, lockmode)
remove_transaction(transactionId)
Lock 管理器需要周期性检测死锁。
而 latch 用于保护 DBMS 内存中的共享数据结构,不服从 2PL,不允许死锁(死锁说明有 bug),使用不会被追踪。
为了提升并发,ANSI SQL 标准对可序列化提供了更弱的语义:
READ_UNCOMMITTED
:任务可以读到提交或未被提交的任意数据READ_COMMITTED
:事务只能读到提交后版本的数据(但不保证是同一个版本)REPEATABLE_READ
:事务只能读到同一个版本的提交后数据(存在幻读问题,因为元组级别的 2PL 无法防止表中被插入新元组,而表级别的 2PL 将会降低吞吐量)SERIALIZABLE
:可序列化数据库厂商自行实现了一些隔离级别:
CURSOR_STABILITY
:解决 READ_COMMITTED
的丢失更改问题SNAPSHOT_ISOLATION
READ_CONSISTENCY
日志管理器负责维护已提交事务的持久性,并保证回滚或中止事务的原子性。为满足这些条件,日志管理器在磁盘上维护了一些列的日志记录,为了能够在数据库崩溃后恢复,内存中的数据结构需要能够从持久化的数据和日志中恢复。
标准的数据库恢复是由 Write-Ahead Logging (WAL) 协议实现的:
第一条规则保证了未完成的事务可以被撤销,从而保证原子性;第二条和第三条规则保证了持久性:已提交但丢失的事务可以通过 redo 日志恢复。
两种日志类型:
崩溃恢复时,从第一条日志开始恢复是低效的,正确的恢复起点应该是以下两个日志记录中更旧的那条:
该恢复起点的序列号被称为 recovery log sequence number (recovery LSN)。由于计算 LSN 有开销,而 LSN 又是单调递增的,因此不必实时保证 LSN 最新,可以周期性地计算,这个周期性间隔被称为 checkpoints。最简单的 checkpoint 方法是将所有脏页 flush 回磁盘然后保存 LSN。
对索引的查询需要保证返回事务一致性的元组。
对于 B+ 树来说,如果遵循 2PL,那么总会有一个事务锁定根节点。一些改进方法有...(没看懂)
一些索引的结构性变化并不需要 undo,因为不会有副作用。比如 B+ 树节点在事务插入的过程中分裂,当事务中止时,分裂的节点不合并也没有关系;再比如对于 heap file 的插入来说,插入了一些元组导致文件长度增长,而事务的回滚也并不需要把新分配的文件块回收,可以留作以后使用。
在使用索引和元组级别的锁时,将会产生幻读问题。由于事务不加表级锁,当一个事务通过索引查询一个范围的数据时(比如 BETWEEN 'Bob' AND 'Bobby'
),其它事务可以自由地插入新元组(比如 Bobbie
)。当新插入的元组落入了查询事务的条件范围内时,将会被查询事务访问到。因此需要做的是将查询事务的查询范围锁定。Next-key Locking 机制中,在索引中插入数据时,必须对表中比当前要插入数据大的第一个已存在元组加排它锁;同时要求查询事务对查询范围内的 next key tuple 加共享锁。这样保证了被插入的元组肯定不会出现在查询事务的查询范围中间。
保存系统中所有数据的 metadata:用户、schema、表、列、索引的名称和关系。其本身也作为一系列表保存在数据库中:
出于性能考虑,catalog 中的热点部分会被物化在内存中。
主流商业系统使用基于 context 的内存分配器。Memory context 是一个内存数据结构,维护了一个包含多段连续内存的列表,也就是一个内存池。其基本的 API 包含:
malloc()
),这块内存可以来自 memory context 已有的内存;也可以问 OS 要一块更大的内存(如果不够),并链接到 memory context 中free()
),用得比较少,一般是直接销毁整个 memory contextMemory context 类似于一个低级的、程序可控制的垃圾回收机制。它免去了需要遍历数据结构并释放内存以及潜在的内存泄露的风险。相比之下,Java 的 GC 是面向整个程序的内存,并且 JVM 自行决定何时 GC;而数据库可以只释放某个特定用途的 memory context,并且程序可以决定何时释放。
另外,OS 的 malloc()
和 free()
对小块内存来说较为昂贵,memory context 减小了 OS 级别的开销。
用途之一是管理数据库表和文件的映射关系。由于早期文件系统的局限,数据库表和文件并不是一一映射的,可能是多个表在一个文件中,也可能是一个表对应了多个文件。历史原因有:
另外,磁盘管理子系统还有部分用于处理设备相关细节的代码,比如 RAID 或 SAN。
几种复制方式:
fntdb07-architecture.pdf