今天转载一篇文章,是关于数据库原理的文章,写的非常好,自己从中摘抄一些对自己有用的东西。点击打开链接
这篇文章大约分为3个部分:
- 底层和上层数据库组件概况
- 查询优化过程概况
- 事务和缓冲池管理概况
数据量低时,O(1) 和 O(n^2)的区别可以忽略不计。比如,你有个算法要处理2000条元素。
- O(1) 算法会消耗 1 次运算
- O(log(n)) 算法会消耗 7 次运算
- O(n) 算法会消耗 2000 次运算
- O(n*log(n)) 算法会消耗 14,000 次运算
- O(n^2) 算法会消耗 4,000,000 次运算
这里我只探讨时间复杂度,但复杂度还包括:
- 算法的内存消耗
- 算法的磁盘 I/O 消耗
合并排序的强大之处
为什么这个算法如此强大?
因为:
- 你可以更改算法,以便于节省内存空间,方法是不创建新的序列而是直接修改输入序列。
注:这种算法叫『原地算法』(in-place algorithm)
- 你可以更改算法,以便于同时使用磁盘空间和少量内存而避免巨量磁盘 I/O。方法是只向内存中加载当前处理的部分。在仅仅100MB的内存缓冲区内排序一个几个GB的表时,这是个很重要的技巧。
注:这种算法叫『外部排序』(external sorting)。
- 你可以更改算法,以便于在 多处理器/多线程/多服务器 上运行。
比如,分布式合并排序是Hadoop(那个著名的大数据框架)的关键组件之一。
- 这个算法可以点石成金(事实如此!)
阵列,树和哈希表
阵列
树和数据库索引
- 比保存在左子树的任何键值都要大
- 比保存在右子树的任何键值都要小
现在比方说我要找40
- 我从键值为136的根开始,因为 136>40,所以我去找节点136的左子树。
- 80>40,所以我去找节点 80 的左子树
- 40=40,节点存在。我抽取出节点内部行的ID(图中没有画)再去表中查找对应的 ROW ID。
- 知道 ROW ID我就知道了数据在表中对精确位置,就可以立即获取数据。
上文说的很抽象,我们回来看看我们的问题。这次不用傻傻的数字了,想象一下前表中代表某人的国家的字符串。假设你有个树包含表中的列『country』:
- 如果你想知道谁在 UK 工作
- 你在树中查找代表 UK 的节点
- 在『UK 节点』你会找到 UK 员工那些行的位置
B+树索引
- 只有最底层的节点(叶子节点)才保存信息(相关表的行位置)
- 其它节点只是在搜索中用来指引到正确节点的。
你可以看到,节点更多了(多了两倍)。确实,你有了额外的节点,它们就是帮助你找到正确节点的『决策节点』(正确节点保存着相关表中行的位置)。但是搜索复杂度还是在 O(log(N))(只多了一层)。一个重要的不同点是,最底层的节点是跟后续节点相连接的。
用这个 B+树,假设你要找40到100间的值:
- 你只需要找 40(若40不存在则找40之后最贴近的值),就像你在上一个树中所做的那样。
- 然后用那些连接来收集40的后续节点,直到找到100。
然而还有新的问题(又来了!)。如果你在数据库中增加或删除一行(从而在相关的 B+树索引里):
- 你必须在B+树中的节点之间保持顺序,否则节点会变得一团糟,你无法从中找到想要的节点。
- 你必须尽可能降低B+树的层数,否则 O(log(N)) 复杂度会变成 O(N)。
比方说你要找元素 78:
- 哈希表计算 78 的哈希码,等于 8。
- 查找哈希桶 8,找到的第一个元素是 78。
- 返回元素 78。
- 查询仅耗费了 2 次运算(1次计算哈希值,另一次在哈希桶中查找元素)。
现在,比方说你要找元素 59:
- 哈希表计算 59 的哈希码,等于9。
- 查找哈希桶 9,第一个找到的元素是 99。因为 99 不等于 59, 那么 99 不是正确的元素。
- 用同样的逻辑,查找第二个元素(9),第三个(79),……,最后一个(29)。
- 元素不存在。
- 搜索耗费了 7 次运算。
为什么不用阵列呢?
嗯,你问得好。
- 一个哈希表可以只装载一半到内存,剩下的哈希桶可以留在硬盘上。
- 用阵列的话,你需要一个连续内存空间。如果你加载一个大表,很难分配足够的连续内存空间。
- 用哈希表的话,你可以选择你要的关键字(比如,一个人的国家和姓氏)。
数据库是一个易于访问和修改的信息集合。不过简单的一堆文件也能达到这个效果。事实上,像SQLite这样最简单的数据库也只是一堆文件而已,但SQLite是精心设计的一堆文件,因为它允许你:
- 使用事务来确保数据的安全和一致性
- 快速处理百万条以上的数据
为了理解成本优化器的原理,我觉得最好用个例子来『感受』一下这个任务背后的复杂性。这里我将给出联接 2 个表的 3 个方法,我们很快就能看到即便一个简单的联接查询对于优化器来说都是个噩梦。之后,我们会了解真正的优化器是怎么做的。
对于这些联接操作,我会专注于它们的时间复杂度,但是,数据库优化器计算的是它们的 CPU 成本、磁盘 I/O 成本、和内存需求。时间复杂度和 CPU 成本的区别是,时间成本是个近似值(给我这样的懒家伙准备的)。而 CPU 成本,我这里包括了所有的运算,比如:加法、条件判断、乘法、迭代……还有呢:
- 每一个高级代码运算都要特定数量的低级 CPU 运算。
- 对于 Intel Core i7、Intel Pentium 4、AMD Opteron…等,(就 CPU 周期而言)CPU 的运算成本是不同的,也就是说它取决于 CPU 的架构。
缓存管理器在缓冲池里保存所有的这些数据。为了确定一条数据是否有用,缓存管理器给缓存的数据添加了额外的信息(叫闩锁)。
有时查询执行器不知道它需要什么数据,有的数据库也不提供这个功能。相反,它们使用一种推测预读法(比如:如果查询执行器想要数据1、3、5,它不久后很可能会要 7、9、11),或者顺序预读法(这时候缓存管理器只是读取一批数据后简单地从磁盘加载下一批连续数据)。
事务管理器
最后但同样重要的,是事务管理器,我们将看到这个进程是如何保证每个查询在自己的事务内执行的。但开始之前,我们需要理解ACID事务的概念。
“I’m on acid”
一个ACID事务是一个工作单元,它要保证4个属性:
- 原子性(Atomicity): 事务『要么全部完成,要么全部取消』,即使它持续运行10个小时。如果事务崩溃,状态回到事务之前(事务回滚)。
- 隔离性(Isolation): 如果2个事务 A 和 B 同时运行,事务 A 和 B 最终的结果是相同的,不管 A 是结束于 B 之前/之后/运行期间。
- 持久性(Durability): 一旦事务提交(也就是成功执行),不管发生什么(崩溃或者出错),数据要保存在数据库中。
- 一致性(Consistency): 只有合法的数据(依照关系约束和函数约束)能写入数据库,一致性与原子性和隔离性有关。
在同一个事务内,你可以运行多个SQL查询来读取、创建、更新和删除数据。当两个事务使用相同的数据,麻烦就来了。经典的例子是从账户A到账户B的汇款。假设有2个事务:
- 事务1(T1)从账户A取出100美元给账户B
- 事务2(T2)从账户A取出50美元给账户B
我们回来看看ACID属性:
- 原子性确保不管 T1 期间发生什么(服务器崩溃、网络中断…),你不能出现账户A 取走了100美元但没有给账户B 的现象(这就是数据不一致状态)。
- 隔离性确保如果 T1 和 T2 同时发生,最终A将减少150美元,B将得到150美元,而不是其他结果,比如因为 T2 部分抹除了 T1 的行为,A减少150美元而B只得到50美元(这也是不一致状态)。
- 持久性确保如果 T1 刚刚提交,数据库就发生崩溃,T1 不会消失得无影无踪。
- 一致性确保钱不会在系统内生成或灭失。
悲观锁
原理是:
- 如果一个事务需要一条数据
- 它就把数据锁住
- 如果另一个事务也需要这条数据
- 它就必须要等第一个事务释放这条数据
这个锁叫排他锁。
共享锁是这样的:
- 如果一个事务只需要读取数据A
- 它会给数据A加上『共享锁』并读取
- 如果第二个事务也需要仅仅读取数据A
- 它会给数据A加上『共享锁』并读取
- 如果第三个事务需要修改数据A
- 它会给数据A加上『排他锁』,但是必须等待另外两个事务释放它们的共享锁。
锁管理器是添加和释放锁的进程,在内部用一个哈希表保存锁信息(关键字是被锁的数据),并且了解每一块数据是:
- 被哪个事务加的锁
- 哪个事务在等待数据解锁
在本图中:
- 事务A 给 数据1 加上排他锁并且等待获取数据2
- 事务B 给 数据2 加上排他锁并且等待获取数据1
在死锁发生时,锁管理器要选择取消(回滚)一个事务,以便消除死锁。这可是个艰难的决定:
- 杀死数据修改量最少的事务(这样能减少回滚的成本)?
- 杀死持续时间最短的事务,因为其它事务的用户等的时间更长?
- 杀死能用更少时间结束的事务(避免可能的资源饥荒)?
- 一旦发生回滚,有多少事务会受到回滚的影响?