数据库如何用 WAL 保证事务一致性?

数据库如何用 WAL 保证事务一致性?

学数据库事务的时候都有这么一个例子,A 转账 100 块钱给 B,A 账户减 100 和 B 账户加 100 要么都完成要么一个都不完成,不能出现 A 账户减了 100 而 B 账户没加 100 的情况。如果从原子性的角度考虑可以通过加锁来解决,但即使是加锁这两步执行也是有顺序的,万一这两步之间系统掉电了怎么办?我们通过一个真实的数据库例子来看一下数据库是怎么处理这种事情的。

微软旗下的 Office Word 作为一款数据类型最丰富,操作方式最多样,图像化界面最好的业界领先数据库(大雾)自然也会处理这种事务执行过程中出现掉电的情况。可以把每一次的开始编辑作为一次事务的 Begin,每一次的保存作为事务的 Commit,不保存退出作为事务的 Rollback(Word 还支持事务内和跨事务的单步 undo/redo,简直逆天)。如果编辑 Word 的过程中还没保存就断电或者 Word 崩溃退出,再次打开文档的时候回发现左侧有个自动恢复的选项可以选择某个自动保存的版本,这样我们即使断电也可选择将这次编辑 Commit 或者 Rollback。

实现的方式也很简单,就是每当你编辑一段时间 Word 就自动将当前编辑的变更记录下来写到一个 log 文件里,如果你主动 Commit 就把这些变更保存到原先的 Word 文档里把 log 清掉,如果不保存的话直接把 log 清掉就可以。这样正常关闭的文档是不会有 log 文件的,如果下次打开文档时发现没有 log 就正常继续编辑,如果有 log 那么就需要人来手动干预一下到底是将最后没有保存的变更 Commit 还是 Rollback。

这种在数据真正持久化之前先把变更写入 log 的方式就叫做 WAL(Write Ahead Logging)既然实现的方式是 write ahead,那么它的作用自然就是 read after 了。抛开 Word 这种先进的数据库,抽象来看 log 的格式可以有很多,一种格式是记录操作前后的值 <TransactionID, Entry, OldValue, NewValue>。那么最上来的那个转账的例子我们假设 A 一开始有 100 块钱,B 有 0 一个完整的 log 就是:

1. Begin

2. <1, A, 100, 0>

3. <1, B, 0, 100>

4. Commit

由于 WAL 是 write ahead 的,也就意味着即使 Commit 日志已经写成功了,但数据库还没真正把事务提交。而且一般数据库掉电后也不会让人和 Word 一样手工上去判断每个事务是否提交,这就需要数据库能根据 log 来自动的 commit 和 rollback 事务。

这个事情也简单只要看一下 WAL 中有哪些事务是数据库还没做完的,把这些事务的 log 找出来。如果这个事务最后一条记录是 Commit,那么由于 Write ahead 的特性可能还没写进去我们只需要根据 log 里的 Entry 和 NewValue 把值不管三七二十一覆盖一下就好了,这样就达到了和 WAL 一致的状态。如果事务最后一条记录不是 Commit,那么这个事务肯定没有执行完,我们根据 OldValue 的值进行覆盖就相当于 rollback 到了事务开始前的值。

还是刚才那个 log,如果在 4 之后发生断电,那么恢复后数据库要执行的就是把 A set 成 0 把 B set 成 100。如果 3 和 4 之间断电那么就要把 A set 成 100,B set 成 0。如果 2 和 3 之间断电需要把 A set 成 100。1 和 2 之间,由于没记录也就不用操作了。这样无论哪种情况事务最终的一致性都是得到保证的。

除了带来事务一致性的保证,由于只需要把操作写到 WAL 里就可以认为操作完成而无需等待持久化真正的数据库变更完成就可以返回,数据库操作的效率也得到了一些提升。你可能要问了写 log 也要持久化呀那里性能提升了?窍门在于 WAL 是顺序写入的一直在文件末尾 append,而持久化数据库的数据是一个随机写入操作,顺序写会节省大量的磁盘悬臂来回寻址的过程,效率要高好几个量级。你可能又要问了,谁现在还用机械盘啊,都是 SSD 了。那么效率的提升在哪里呢?


哪这么多问题?咳咳…… 一般来说 SSD 的顺序写还是要比随机写好点,此外还有对 GC 友好,防止写放大之类我也说不太清楚的好处。

此外 WAL 还有很多的变种,比如我觉得这个 log 体积太大了想缩减一下体积,就把 OldValue 这个字段给去掉了,那么 Commit 的事务还好说和之前一样,对于需要 Rollback 的事务该咋办呢?也简单,换个思路把前一次的事务中涉及这个 Entry 的 NewValue 再 set 一下就好了。此外也有不记录值变化而是记录数据库操作记录的 log 那么恢复机制就又是另一个样子了。

换个角度想一想,如果 WAL 中记录了一个 Entry 所有的变化历史,那么我们其实不需要查询真正的数据库文件,只要查 WAL 就可以了,这就和我们用 git 一样,即使没有当前最新的代码,但是给我每次的提交记录我也可以算出一份最新的代码。数据库的恢复也变得容易了,只要有 Commit 的肯定都已经写入了,只需要把没有 Commit 的 log 删掉就可以了。

如果整个数据库的后端存储就是 WAL 的话一个显而易见的优点就是写入会极快,都是 append 操作。问题就是读操作会很慢,每个条目都需要扫一遍 log 获取所有的变化才能推算出最终结果,所以真正用起来肯定还是要做大量的优化。两个常见的优化方法一个是 snapshot,当 log 增长到一定长度后就做一次聚合,这样查找就不需要从头开始遍历,只需要从最近的一次 snapshot 里面读到一个值再和后面的 log 进行比较就可以了。另一个就是做缓存了把尽量多的最新值放在内存里来避免遍历 log。

对 WAL 感兴趣的可以看下 etcd 的源码,专门有个目录是用来实现 WAL 的,还算比较清晰有许多工程方面的细节,snapshot 的优化也在另外一个目录里有实现。顺便提一下 etcd 中还有 MVCC 的实现,数据库原理方面的课程 sql 之外的很多主题都可以拿 etcd 来作参考实现了。

写到最后我想说的是,作为一个软狗 Word 才是世界上最好的数据库(逃

编辑于 2017-01-16

文章被以下专栏收录