第27讲 · 数据库系统

2026 南京大学《操作系统原理》第二十七讲(Hacking Day)· 课堂实录重构

主讲 蒋炎岩(Yanyan Jiang)
视频 https://www.bilibili.com/video/BV1eqEA6JEZA/
课程主页 https://jyywiki.cn/OS/2026/
本讲讲义 本讲为 Hacking Day 补充选讲,讲义见课程主页 https://jyywiki.cn/OS/2026/
讲义 / 幻灯片作者 蒋炎岩,https://jyywiki.cn/
讲义 / 幻灯片许可 CC BY-NC 4.0
图片来源 书中插图为课堂演示与讲义渲染图;演示类截图为视频画面

对应讲次:第27讲(南京大学 操作系统原理)

制作模型:deepseek-flash(coding-agent / pi;据 ~/.pi/agent/settings.json)

编者说明:本书由课堂录像的语音字幕重构而成。本讲是 Hacking Day 补充选讲(数据库系统专题),课堂内容作为主线保留;编辑性的补充一律以「编者说明」或「编者拓展」标出。


引子:从文件系统再往前一步

(参考时间: 00:00)

上一讲讲了文件系统的实现:文件系统是磁盘上的一个数据结构——如果你的数据结构课学得好,就很容易理解它「其实就是在做一棵目录树(图)」,只不过访问方式从「按字节」换成了「按块」。

上一讲还讲了两个额外话题:文件系统的 crash consistency 与应用程序的 crash consistency——你要保证数据结构在「随时可能断电、线程随时可能消失」的情况下,重启后仍能恢复到一个正确的状态(可能丢掉最近几次操作,但整体不能 corrupt)。

今天顺着文件系统继续往前:有了一个好的文件系统,我们能用它来做什么?

编者拓展|一个容易被忽略的观点:真正访问文件的不是人,而是应用程序。你与计算机的交互只有键盘、鼠标和屏幕,屏幕上的内容都是应用程序在读写文件。哪怕你只是 cat 一个文件,那也是「一个最简单的应用程序」在调用 open、read。正因如此,讲师上一讲强调——应用程序的 crash consistency 同样重要:文件系统一致、但应用丢了数据,对用户来说照样不可接受。


一、一切应用数据,都是数据结构的增删改查

(参考时间: 00:02)

软件是物理世界的过程在信息世界里的投影(而且通常是有损的)。任何应用都有把数据持久化的需求:个人信息、订单、支付记录……而这些数据,本质上都是数据结构的增删改查(CRUD)。

例子 1:教务系统。 打开教务系统你会发现,看到的界面全是「数据结构的操作」:编辑教学班、编辑周历、查看选课列表……「就好像文件系统一样,增删改查」。

例子 2:PowerPoint / Word 文档。 一个「看起来不像数据结构」的东西,其实也是数据结构——把 .pptx 当 ZIP 解开:

$ unzip 离散数学-slides.pptx        # 一个 pptx 同时也是一个合法的 zip
$ ls ppt/media/                     # 里面的图片都是独立文件

于是你可以把文档里所有原始图片一次性导出来。pptx 本质上是一套 XML(Office Open XML,遵循 ISO/IEC 29500)——「页、每页里每个东西在哪里、怎么保存」,全都是一棵可读、可改的树。讲师甚至现场让 coding agent 把一个中文离散数学 slides 翻译成英文(标题、每页标题都变成英文),「完全是文本的修改」。

把 .pptx 当 ZIP 解开:里面是 XML 与独立的图片文件

📷 把 .pptx 当 ZIP 解开:里面是 XML 与独立的图片文件
回到原视频 (00:07:05)

编者拓展|为什么会有 OpenXML:早期 .doc 是不开放的二进制格式,欧盟等对微软施加压力(「标准不公开,就在政府层面禁用你」),最终促成了微软自己做一个开放标准、让大家来遵循它——这就是 Office Open XML。它带来的一个重要后果是:文档从此是「可编程的」——能用代码解析、批量修改、做自动化(也正因如此,今天 AI agent 能直接操作 docx/pptx)。

编者说明:讲师指出 Anthropic 官方的 docx/pptx skill 就是去解析这个文件。但他并不认为这是最好的做法——「格式并不规整,也许我们需要一个更好的查询语言」。

结论:实现一切应用数据持久化的本质,就是实现数据结构的持久化。 实现它有两种典型方法:

  1. 使用文件和目录(借力 UNIX 的 Everything is a file);
  2. 在文件之上构建一个数据结构(自己设计布局)。

二、方法一:用文件和目录

(参考时间: 00:09)

如果让你用文件系统实现一个教务系统,一个很自然的做法是:用目录树来组织。

教务app/
└── 操作系统课-2026/
    ├── metadata.json          # 开课信息
    ├── syllabus.md            # 教学大纲
    └── enrollment/
        ├── 2312001.md         # 每个选课同学一个文件
        ├── 2312002.md
        └── ...

好处:所有的东西都是平的——UNIX 世界的所有工具(find、grep、sed……)都能用,agent 也能用。「目录结构是非常 agent-friendly 的方式。」

用符号链接做「引用」:你不必复制学生的个人信息,只要在选课列表里放一个符号链接指向那个学生的目录——学生更新了 email 或手机号,你这里自动同步。「完全可以用目录来存储任何数据结构,就像 docx/pptx 一样。」

用目录树实现教务系统:每个实体一个文件,用符号链接做引用

📷 用目录树实现教务系统:每个实体一个文件,用符号链接做引用
回到原视频 (00:12:00)

2.1 真实案例:课程网站与 Online Judge

(参考时间: 00:14)

这门课自己的网站就是这个风格:所有提交都在一个目录树底下。

(提交根目录)/COURSE/MODULE/学号/xxxx.result

「这一切我都不需要知道任何(数据库)系统,而且可以让 agent 来帮我做。」——这是文件系统的好处:它是一个公共接口,agent 用得很溜。

课程网站:提交目录 + 监听程序 + OJ,全部建立在文件系统上

📷 课程网站:提交目录 + 监听程序 + OJ,全部建立在文件系统上
回到原视频 (00:15:00)

编者说明:这个架构的可靠性是「够用」的——阿里云的机器还算可靠,偶尔丢一两个 .result,同学刷新五分钟发现没进 pending 状态会自己再交一次,影响也不大。「所以这是一个完全可以接受的方案。」

2.2 代价:多文件写的原子性

(参考时间: 00:17)

用文件/目录实现数据库不是没有代价:

「对我这种小规模系统、或者一次 OJ 评测的开销远大于文件系统开销时,这个代价完全可以接受」——尤其是在要hot fix 的时候,这种架构非常顺手。


三、方法二:在文件之上构建数据结构

(参考时间: 00:19)

如果你一点额外开销都不想付,那就自己在文件上实现一个数据结构。因为——文件就是一个 byte array,而磁盘也是 byte array:

于是实现一个教务数据库,就是在文件头上放一个 superblock:

struct superblock {
    uint32_t magic;              // "教务DB"
    struct student *head;        // 学生链表
    struct student *free_list;   // 空闲链表(allocator)
    uint32_t count;
    char     wal[...];           // write-ahead log
};

关键的 hack:把文件 mmap 到固定地址。只要每次都映射到同一个虚拟地址,文件里的「指针」就直接有效——head 的值就是 0x10020,指向的正是文件里那个 student 的位置。于是:

// add:完全是数据结构课上学的链表插入
struct student *s = allocate(sizeof(struct student));
s->next = sb->head;
sb->head = s;
sb->count++;
把文件 mmap 到固定地址,直接在文件里存「指针」

📷 把文件 mmap 到固定地址,直接在文件里存「指针」
回到原视频 (00:22:00)

编者说明:这套东西「理论上可以」,但生产环境里坑非常多。第一个坑:mmap 一个文件后,你对内存的修改不会立刻反映到磁盘(先写到 cache,甚至 L1 cache 里)。如果你频繁按数据结构的方式读写,而操作系统又要保持内存与磁盘同步,就会造成无限大的写放大。默认情况下,mmap 的读写不做 writeback——「数据丢了就丢了」。

要让写入真正落盘,需要一串系统调用:

「操作系统既想提供透明的抽象(memory-mapped file,随便读写),又要在写回时让你控制策略——这时就引入了很多复杂性。所以 mmap 可能并不是实现数据库的好 API。」

3.1 Write-Ahead Log(按需引入)

(参考时间: 00:29)

既然要 crash consistency,就按上一讲的思路,在 superblock 里塞一个 write-ahead log(这里只放一个 slot,是偷懒的简化版):

graph LR
    A["执行 add/delete"] --> B["把命令字符串写入 WAL"]
    B --> C["msync + fdatasync(确保日志落盘)"]
    C --> D["更新真正的数据结构"]
    D --> E["再次落盘后清空 WAL"]
    E --> F["事务提交完成"]
Write-Ahead Log:命令先落盘,再更新数据结构,最后清空日志

📷 Write-Ahead Log:命令先落盘,再更新数据结构,最后清空日志
回到原视频 (00:30:00)

讲师现场演示:直接 hack 数据库文件、在 WAL: 后面手写一条 add 99 XX 命令,再打开系统——replay 后 99 号同学就出现了,日志随即被清空。

手工写入 WAL,重启后被 replay:99 号同学出现

📷 手工写入 WAL,重启后被 replay:99 号同学出现
回到原视频 (00:32:00)

编者拓展|Single-Level Store(单级存储):顺着这个思路想到极致——既然「数据结构可以直接映射到磁盘」,那整个进程的内存能不能都 mmap 到持久内存上?这样「拔电重启后程序状态还在」,就得到了一个 single-level store 的操作系统(内存与持久存储不再分层)。这在「有非易失内存(persistent memory)」时理论上是可做的(讲师提到有一篇相关研究论文)。「你有这种疯狂的小 idea,不断往实际里做,就可以做出真正有用的东西。」


四、关系数据库:把「关系」做成表格

(参考时间: 00:35)

前面是在解决「把应用数据存下来」。现在野心更大:做一个能处理任意类型数据、又好用、性能又极致、还永不丢数据的「通用数据结构」——这就是关系数据库。

编者拓展|图灵奖与 Codd 的关系模型:关系数据库的理论基础是 E.F. Codd 1970 年的论文 A Relational Model of Data for Large Shared Data Banks,他因此获得图灵奖。论文的核心主张恰是本讲的出发点:

Future users of large data banks must be protected from having to know how the data is organized in the machine.

——用户只需在「概念层」描述数据与查询,不必关心数据在机器里怎么组织。这是数据库最重要的抽象。

4.1 关系模型:一切皆「二维表」

(参考时间: 00:39)

在内存里实现数据结构,我们的本能是指针(struct node { node *left, *right; })。但指针不是一个好的数据库抽象——因为你想表达的是「实体与实体之间的关系」,而世界上无非就是「一堆实体 + 一堆关系」。

答案简单得不能再简单:二维表(就像 Excel 的一张张表):

表 列
students 学号(主键)、姓名……
courses 课号(主键)、课名……
enrollment 学号、课号

「选课」这件多对多的事,就变成第三张表——用学号、课号把两个实体关联起来。「世界上只有三种最小的事实:学生、课程、选课。理论上你应该能用这三种最小的事实,推出任何其他导出的事实。」

关系模型:一切皆二维表;多对多关系用「关联表」表达

📷 关系模型:一切皆二维表;多对多关系用「关联表」表达
回到原视频 (00:40:30)

4.2 Join:笛卡尔积 + 过滤

(参考时间: 00:42)

那么「一门课的选课列表」怎么算?数学上非常有趣的答案:对三张表做笛卡尔积(所有组合),再按条件过滤——这就是 join:

for s in students:
    for c in courses:
        for e in enrollment:
            if e.student_id == s.student_id and e.course_id == c.course_id:
                输出 (s, c, e)

「看起来非常低效,但如果你把它做得非常高效,无论你写多少层循环嵌套,它都能给你算出来——这就是能得图灵奖的想法。」用"学号/课号"这两个 primary key(主键),就能把「遍历整表」优化成「查索引」。

join = 笛卡尔积 + 过滤:用三重循环描述,用索引高效执行

📷 join = 笛卡尔积 + 过滤:用三重循环描述,用索引高效执行
回到原视频 (00:43:00)

于是就有了 SQL(Structured Query Language,结构化查询语言)——一个专门管理数据的领域特定语言:

SELECT course.title
FROM   students
JOIN   enrollment ON students.id = enrollment.student_id
JOIN   courses    ON enrollment.course_id = courses.id
WHERE  students.name LIKE '张%';

「update 也是一个查询;INSERT INTO 就是往表里插数据。你可以随便写查询——想要什么就要什么。」

SQL:把「事实」拆到最小的表,用查询导出任何想要的东西

📷 SQL:把「事实」拆到最小的表,用查询导出任何想要的东西
回到原视频 (00:47:00)

4.3 ACID 与「事务」

(参考时间: 00:48)

数据库系统给出的保证是 ACID:

全称 含义
A Atomicity 原子性:要么全做,要么全不做
C Consistency 一致性
I Isolation 隔离性
D Durability 持久性

编者说明:讲师从操作系统视角把它归纳为两点:Serializability(可串行化)——无论多少并发的事务(读的、写的),最终都能排成一个全局顺序,像是「1 执行完再执行 2……」,而不是 weak memory model 那种乱序;以及 strong crash consistency——系统随时 crash 都不会造成数据丢失,还支持任意的多写(multi-write)。

核心抽象:transaction(事务):

BEGIN TRANSACTION;
  UPDATE accounts SET balance = balance - 100 WHERE id = 'A';
  UPDATE accounts SET balance = balance + 100 WHERE id = 'B';
  -- ……可以在这里做任意图灵完备的计算、甚至 sleep
  -- 若余额不足 / 发现异常:ROLLBACK
COMMIT;

「在一个 transaction 里,你可以写任何代码。只要 abort,前面执行的操作全部都没有了;只有 commit,所有操作才立即生效,而且是 all-or-nothing。」

这解决了「转账」这类多写问题:它既要抵抗并发(两个线程同时判断余额),又要抵抗崩溃(扣了 100 但还没加 100 时断电)。

编者拓展|为什么这比「一把大锁」更难:如果只是并发,你上一把锁就行;但数据库还要保证「线程随时可能消失」——即 transaction 中途 crash,已执行的部分必须整体回滚。这需要 WAL + 恢复(rollback/replay) 的配合,是数据库最核心的工程之一。

ACID 与事务:begin / commit / abort 之间可以写任意代码,all-or-nothing

📷 ACID 与事务:begin / commit / abort 之间可以写任意代码,all-or-nothing
回到原视频 (00:50:00)

4.4 并发控制:2PL、死锁与 MVCC

(参考时间: 00:56)

数据库里每个请求都会读写不同的部分(可能几百张表),并发控制非常复杂。两种经典方法:

① Two-Phase Locking(2PL,两阶段锁):

所以数据库有死锁检测(deadlock detector):像维护一张「谁等谁」的图,一旦成环就「破圈」——选一个 transaction abort,让它回滚、从头再来,另一个继续 commit。「回滚到什么程度、怎么回滚,都是无穷的复杂性。」

② MVCC(Multi-Version Concurrency Control,多版本并发控制):用一个很好的类比——

每个 transaction BEGIN 时,都 git checkout 出一个新分支:读永远读自己这个分支(别人怎么改都与我无关),写也只写进自己的分支;做完时,尝试向 main 分支 merge。 - 两个事务改了不同的行 → 不冲突,都能 commit(本质是 replay 各自的 write-ahead log); - 两个事务改了同一行 → 冲突,其中一个分支被丢弃(abort)。

「和 git 不同的是,git 允许你 resolve merge conflict;数据库不能——因为 transaction 里可以有任意计算,值是程序算出来的。」

MVCC:像 git 分支一样,每个事务在自己分支上读写,提交时 merge

📷 MVCC:像 git 分支一样,每个事务在自己分支上读写,提交时 merge
回到原视频 (01:00:00)

编者说明:「这是一个超级复杂的并发程序,但也是一个了不起的工程奇迹。」上节课讲过的 serializability 与 crash consistency,在这里被实现成了「你只管写一把大锁,性能却优于你自己加细粒度锁,而且完全自动的崩溃恢复」——后台用的是 append-only + LSM-tree 之类的高性能并发结构。

4.5 查询优化:数据库里的「编译器」

(参考时间: 00:53)

一段 SQL 就是一段程序,所以可以等价改写(rewriting):

「你们的编译器 gcc -O0/-O1/-O2,本质上也是一大堆 rewriting rules;数据库里的查询优化器是同一件事——只是这两批人走了完全不同的路线。」

查询优化器 = 一大堆 rewriting rules:把「三重循环」改写成查 index

📷 查询优化器 = 一大堆 rewriting rules:把「三重循环」改写成查 index
回到原视频 (00:53:00)

4.6 SQLite:无处不在的小数据库

(参考时间: 01:02)

数据库不只服务互联网大厂,它也服务我们每个人。看看手机设置里那些小拨片(settings)——它们是持久化的。你会想「是不是每个 preference 一个文件、写个 0/1、再 fsync」?实际上是一个很小的 SQLite 数据库在管理:

SQLite: a C library that implements a small, fast, self-contained, high-reliability, full-featured SQL database engine. ——the most used database engine in the world

「系统里所有涉及 persistence 的地方,第一反应就是求助于 SQLite。」——想做一个动态构建的设置列表?一个 SQL 查询查出来就行;想改?一个 SQL 查询搞定,而且数据安稳地存到磁盘上,不用你操心 fsync。

SQLite:一个文件 + 一个库,世界上最广泛使用的数据库引擎

📷 SQLite:一个文件 + 一个库,世界上最广泛使用的数据库引擎
回到原视频 (01:04:00)


五、NoSQL 与 Key-Value:为了「更大」

(参考时间: 01:07)

微信的聊天记录,本质上一张表就能装下(一个气泡就是一条记录,内容是一段 XML)。「最近十条聊天」就是一句 SQL:

SELECT * FROM chat_records
WHERE sender_id = :me AND receiver_id = :peer
ORDER BY time DESC LIMIT 10;

「到百万、千万用户时,一台机器的 Postgres 都能扛。但到了微信的量级,压力就给到数据库引擎:分库分表、读写分离、索引、缓存……」而且——

你永远无法预知程序员会写出多么糟糕的 query。

「企业级系统里,下个单背后可能是 50 张表 join 在一起。SQL 给了太多能力,很难避免被滥用;而滥用就意味着 performance bug。用户量小的时候不致命,但数据一增长,它就会越来越慢。」

于是有了另一种思路:「SQL 给得太多了,也许在某些应用里,我可以给得少一点。」 给得少 → 系统实现更容易、性能更高、更容易分布式。

5.1 Key-Value:最简单又够用

(参考时间: 01:10)

最常用的数据结构是什么?——key-value(一个 map / dict):

user:2312001        -> '{"name":"张三","phone":"..."}'    # 用户信息(JSON)
enrollment:2312001  -> ["OS2026", ...]                    # 选课记录(append-only list)

编者拓展|LSM-Tree 与 Redis:像这样的「只追加写 + 随机读」的键值存储,通常用 LSM-Tree(Log-Structured Merge Tree)实现——写入先进内存表 + 日志,写满后合并到磁盘上层层不可变的树,靠均摊分析得到好性能。而在它前面,还常放一层 Redis(in-memory key-value,支持 string/list/set/hash)——把「全世界几百万人同时点赞」先缓冲在内存里(比如一个 counter 或一个 list append),每隔一段时间再合并写回底层数据库,从而大幅减轻数据库压力。「这就已经很接近今天的高性能架构了。」

5.2 从 KV 到 NewSQL

(参考时间: 01:17)

但「key-value 用起来终究不如 SQL 好用」——因为 SQL 可以「随便写 50 层循环,你自动帮我搞定」。于是人们开始在简单模型上扩展:

类型 例子 特点
Document DB MongoDB 以「类 JSON 文档」为单位(结构化存储,比纯 list 更好用)
Column DB Cassandra(查询语言 CQL) 按列组织;有表模型,但不提供 Full SQL
NewSQL TiDB(国产)、Google Spanner、CockroachDB 几乎完整的 SQL + 分布式 scale out

「今天也不是版本答案,这是一个巨大的 industry。」


六、向量数据库:为 AI 时代准备的数据结构

(参考时间: 01:19)

前面所有数据库,本质上都是「某个数据结构的增删改查」。还有一种新的、和它们都不一样的向量数据库:

讲师现场做了个漂亮的演示:把他每一讲的幻灯片 embedding 成向量,算出两两距离,然后可视化——

把 27 讲 embedding 成向量再可视化:并发部分聚成一团,第 4 讲是孤岛

📷 把 27 讲 embedding 成向量再可视化:并发部分聚成一团,第 4 讲是孤岛
回到原视频 (01:20:00)

编者拓展|向量检索与 RAG:把文档 embedding 后存进向量库、用「最近邻」把相关片段找出来塞进大模型上下文,这就是 RAG(Retrieval-Augmented Generation,检索增强生成) 的检索部分。传统数据库靠精确匹配(等于、范围),向量库靠语义相似——所以它天然适合「找相关论文」「找出可能给我启发的东西」这类模糊需求。「构建一个论文数据库,把和我在讨论的东西相关的、甚至只是弱相关但能启发我的论文找出来」——「我不知道未来会变成什么样,但我相信世界会变成这样。」

总结:无论是 SQL、NoSQL 还是向量数据库,「本质上它们都是某一个数据结构的增删改查」——回到教务系统,它的功能就是增删改查;一个 PowerPoint 也是增删改查。「任何一个产品在设计的时候,背后都是那些数据管理的道理。」


总结


附:官方参考与延伸阅读

本讲为 Hacking Day 补充选讲,以下链接按书中出现顺序整理:

主题 链接
课程主页 https://jyywiki.cn/OS/2026/
Codd, A Relational Model of Data for Large Shared Data Banks(1970) https://dl.acm.org/doi/10.1145/362384.362685
Office Open XML(ISO/IEC 29500) https://en.wikipedia.org/wiki/Office_Open_XML
SQLite https://www.sqlite.org/
LSM-Tree(O'Neil et al., 1996) https://www.cs.umb.edu/~poneil/lsmtree.pdf
Redis https://redis.io/
MongoDB https://www.mongodb.com/
Apache Cassandra https://cassandra.apache.org/
TiDB https://www.pingcap.com/tidb/
Google Spanner 论文 https://research.google/pubs/pub39966/
CockroachDB https://www.cockroachlabs.com/
Andy Pavlo, CMU 15-445 Database Systems https://15445.courses.cs.cmu.edu/
OSTEP(第 43 章:Log-structured File Systems;第 44 章:Flash-based SSDs) https://pages.cs.wisc.edu/~remzi/OSTEP/
msync(2) / fdatasync(2) / madvise(2) 手册 https://man7.org/linux/man-pages/man2/msync.2.html

阅读材料:读 OSTEP 相关章节;有兴趣的同学可以看 Andy Pavlo 的 CMU 15-445《Database Systems》公开课,理解「数据库里的编译器」;动手体验 SQLite(一个文件、一个库)。

版权说明:本讲讲义与幻灯片作者为蒋炎岩,采用 Creative Commons BY-NC 4.0 许可,来源于 https://jyywiki.cn/OS/2026/。视频画面与字幕版权归原作者所有。本书为课堂内容的学习性重构。