当前位置:首页 > 问答 > 正文

数据库系统日志模块怎么从零开始一步步写起来的那些事儿

(根据知乎专栏“写数据库的人”系列文章、CSDN博客“自己动手写数据库”部分章节、以及一些开源数据库如SQLite的文档和邮件列表讨论整理)

这事儿得从一个最根本的问题开始:数据库为啥非得有个日志?想象一下,你正在玩一个不能存档的游戏,突然断电了,再来的时候你得从头开始,数据库也怕这个,它的核心任务就是保证数据“稳”,答应存进去的东西就不能丢,哪怕机器突然宕机,日志,就是这个游戏的“存档点”和“操作记录本”。

最开始,想法特别简单,就是想在修改数据库里真正存储数据(比如硬盘上那个大文件)之前,先把“我打算干什么”这个动作用文字记下来,单独存到另一个更安全的地方(比如另一块硬盘,或者至少是文件系统能保证顺序写入的地方),这个记录,就是最早的“日志条目”或者叫“日志记录”。

数据库系统日志模块怎么从零开始一步步写起来的那些事儿

那记点什么呢?最早期的日志,可能就记两样最基本的东西,一个事务开始了(记一条“BEGIN TRANSACTION”),然后对于事务里要做的每一个修改,记下“在哪个表的哪一页,从什么值改成什么值”,这叫“物理日志”,因为它记录的是非常底层的物理变化。“把文件block 1234号块,从偏移量56开始的8个字节,从原来的值‘Alice’改成‘Bob’”,这么记的好处是,恢复的时候非常直接,照着日志把数据文件再改一遍就行,但缺点也很明显,太琐碎了,如果改了一大片数据,日志会非常大。

所以后来就有了更聪明点的办法,记“逻辑日志”,不记录底层字节的变化,而是记录执行的SQL命令本身,UPDATE users SET name = 'Bob' WHERE id = 1;”,这样日志会小很多,但这就带来了新麻烦:恢复的时候再执行一遍这个SQL,能保证得到一模一样的结果吗?比如UPDATE语句里的WHERE条件可能依赖当时的数据状态,恢复时数据状态可能变了,再执行可能就不是那个效果了,为了解决这个问题,人们又搞出了折中的“生理日志”,它记录的是“在某个数据页上,执行某个特定操作”,这个操作是预先定义好的、原子的,向这个B树页面插入一条特定记录”,这样既比物理日志简洁,又比逻辑日志确定。

数据库系统日志模块怎么从零开始一步步写起来的那些事儿

光有记录还不够,还得保证记录的顺序和持久化,这里就遇到了一个关键挑战:性能,每一条修改都要写一次硬盘(日志),如果每次都等硬盘慢吞吞地写完再继续,数据库就快不起来了,于是引入了“日志缓冲区”的概念,就是先在内存里划出一块地方,像个小黑板,修改操作先把日志记录写在这个小黑板上,就算完成了,然后有一个后台线程,负责不停地把小黑板上新写的内容“刷”到硬盘的日志文件里,这个刷新的时机很有讲究,太频繁了性能差,太慢了万一宕机丢的数据又多,通常会在事务提交的时候,强制要求把跟这个事务相关的所有日志记录都刷到硬盘上,这叫“Write-Ahead Logging”(WAL)协议,也就是“预写式日志”,它的核心规矩就是:任何对数据文件本身的修改,其对应的日志记录必须已经老老实实地躺在硬盘日志文件里了,这样才能保证,即使数据修改到一半宕机,恢复时也能从日志里找到完整的操作依据。

接下来是恢复过程,这就像是宕机重启后,拿着“操作记录本”来收拾残局,恢复程序首先会“重放”所有已经记在日志里、但可能还没来得及应用到数据文件上的修改(这叫REDO,重做),确保答应要存的数据都存了,它还要找出那些日志里记录了“事务开始”但没记录“事务提交”的事务,把这些事务已经对数据文件做的修改再给撤销掉(这叫UNDO,回滚),确保只有完整的事务才生效,这个过程就像是把宕机时正在进行的操作,要么做完,要么就当没发生过。

写着写着,问题又来了,日志文件不能无限增长啊,不然硬盘迟早被塞满,所以得有个“检查点”机制,隔一段时间,数据库会做一个检查点:它把当前内存里所有脏数据(修改过但还没写回数据文件的数据)都刷到硬盘上,它在日志文件里打一个标记,说“到此为止,数据文件的状态已经是包含了这个点之前所有已提交事务的结果了”,这样,在检查点之前产生的日志记录,基本上就可以被安全地截断和删除了,因为数据文件已经追上了进度,恢复的时候,也从最新的检查点开始往前找就行,不用从头扫描整个巨大的日志文件。

这还只是单机的情况,等后来要做主从复制、分布式数据库的时候,日志又扮演了新的角色,比如作为数据同步的依据(二进制日志Binlog),那又是另一段更复杂的故事了,但最核心的,就是从那个简单的“先记日志后改数据”的想法,一步步应对性能、可靠性、空间管理等挑战,慢慢演化出来的。 整理自多位数据库内核开发者在网络上的技术分享和讨论,具体细节可参考SQLite、PostgreSQL等开源项目的文档中关于WAL和恢复机制的描述)