做数据库课程设计时,很多同学会选择“从零实现一个迷你数据库”这类项目,但在动手之后往往会发现:网上的案例要么只讲 SQL 建表,要么只给 CRUD 代码,很少有资料把“关系模型、存储引擎、SQL 解析、事务与并发控制”这条完整链路讲清楚。受宾州州立等欧美高校数据库系统课程的启发,这类项目不仅要求你能跑通功能,更要求你理解一个数据库“内核”是如何工作的。
这篇文章将从数据库内核的视角出发,带着大家完成一条从关系模型到事务的完整学习路径,并给出一个可以运行的极简数据库实现(MiniDB)。无论你是正在准备数据库课程设计的在校学生,还是想补数据库底层知识的后端开发,都能在文中找到能落地、能复现、能扩展的内容。
本文会涉及一部分中英双语术语,因为在学习数据库内核时,很多经典概念和论文名称都是英文,保留英文术语反而更容易查阅资料。下面我们正式开始。
1. 为什么应该手写一个数据库内核
很多开发者每天都在用 MySQL、Oracle、达梦等数据库,但对数据库“内核”的理解往往停留在“它是一个能存数据、能执行 SQL 的软件”。实际上,数据库内核是一个典型的系统软件,它由多个精巧的模块组成,理解这些模块比单纯背 SQL 重要得多。
1.1 数据库内核是什么
数据库内核(Database Kernel)指的是数据库管理系统中最核心的部分,它负责存储数据、解释 SQL、组织执行计划、控制并发与事务,并提供崩溃恢复能力。可以把数据库内核理解为操作系统的“内核”:用户通常接触的是外壳,比如命令行接口、可视化工具、ORM 框架,而真正支撑这些工具运行的,是内核中的一组底层机制。
一个完整的数据库内核通常包含以下模块:
- 存储引擎(Storage Engine):负责数据在磁盘或内存中的组织方式,包括表数据、索引数据、日志数据。
- 解析器(Parser):把 SQL 文本转换为抽象语法树(AST)。
- 优化器(Optimizer):把一个 SQL 表示成多种执行计划,并选择代价最低的那一个。
- 执行引擎(Executor):按照执行计划逐行处理数据,完成扫描、连接、聚合、排序等操作。
- 事务管理器(Transaction Manager):负责事务的开启、提交、回滚以及并发控制,通常基于日志和锁实现。
- 日志与恢复模块(Logging & Recovery):记录数据库的变更历史,在宕机后恢复到一致性状态。
实际的产品级数据库内核远比这复杂,比如 MySQL 的 InnoDB、PostgreSQL、Oracle、达梦等都有自己独特的实现,但这六大模块是理解数据库内核的最小框架。
1.2 数据库内核的宏观架构
如果把一次 SQL 查询执行过程画成一条数据流,大致是这样:
客户端 SQL -> 解析器:SQL -> 抽象语法树(AST) -> 优化器:AST -> 逻辑计划 -> 物理计划 -> 执行引擎:物理计划 -> 调用存储引擎接口 -> 存储引擎:磁盘页、索引、日志实际工程中还会插入权限校验、查询缓存、并发控制等环节。对于一门数据库课程设计来说,并不需要真正做到分布式或高并发,但至少要能打通“SQL 解析—执行—存储—事务”这条链路。这也是为什么我建议你用一门静态语言或脚本语言去实现一个 MiniDB:这个过程能帮你把抽象概念全部落到具体代码上。
1.3 本文适合谁,学完能收获什么
本文适合以下几类读者:
- 计算机相关专业学生,正在准备数据库课程设计或毕业设计。
- 后端开发工程师,对数据库底层实现感兴趣,想理解事务、锁、日志的真实原理。
- 准备数据库相关面试,需要系统梳理关系模型、事务、隔离级别等知识点的同学。
学完本文,你将能够:
- 说清楚关系模型为什么是现代数据库的基础。
- 理解存储引擎中的“数据字典、页面、记录、日志”等概念。
- 写出一个极简 SQL 词法/语法解析器的核心代码。
- 用一个可运行的 Python 示例,亲手实现事务的“提交”与“回滚”。
- 理解事务模式有哪些,以及从单机事务到分布式事务的演进逻辑。
2. 从关系模型开始:数据库的“世界观”
关系模型(Relational Model)由 Edgar F. Codd 在 1970 年提出,它奠定了现代关系型数据库的理论基础。很多教材一上来就讲 SQL,但其实 SQL 只是关系模型的一种具体语言实现。理解关系模型,才能理解为什么数据库中到处都是“表”。
2.1 关系模型为何能统治数据库
在关系模型出现之前,数据库主要采用层次模型和网状模型。这两种模型把数据之间的“指针”作为核心设计,查询时往往需要沿着指针遍历,开发和维护成本很高。关系模型的核心思想是把数据组织成二维表(Relation),表与表之间通过公共字段(外键)关联,而不是通过物理指针关联。
这种设计有几个明显优势:
- 数据独立性高:应用层不关心数据在磁盘上的物理位置。
- 集合操作自然:可以用选择、投影、连接等关系运算处理数据。
- 理论基础扎实:关系代数、关系演算提供了数学层面的保证。
所以,关系模型的“世界观”就是:一切数据都是表,一切操作都是对表的集合运算。
2.2 表、行、列与约束
在关系模型中,有一些基础术语需要先统一:
- 关系(Relation):对应一张表(Table)。
- 元组(Tuple):对应表中的一行(Row)。
- 属性(Attribute):对应表中的一列(Column)。
- 域(Domain):属性值的取值范围,对应字段类型。
- 基数(Cardinality):表中行的数量。
表还需要满足一些约束,其中最常遇到的是:
- 主键(Primary Key):唯一标识一行的列或列组合。
- 外键(Foreign Key):引用其他表主键的列,用于维护引用完整性。
- 唯一约束(Unique):保证列值不重复。
- 非空约束(Not Null):保证列值不能为 NULL。
- Check 约束:自定义条件表达式。
在数据库课程设计中,很多人只把表当成“二维数组”来用,实际上约束是关系模型完整性的关键。例如订单表引用用户表主键时,如果没有外键约束,就可能出现“孤儿订单”。
2.3 关系运算与 SQL
关系模型提供了若干基础运算,SQL 是这些运算的具体语法体现:
| 关系运算 | 含义 | SQL 示例 |
|---|---|---|
| 选择(Select) | 按条件筛选行 | WHERE |
| 投影(Projection) | 按列筛选字段 | SELECT col1, col2 |
| 连接(Join) | 按条件组合多张表 | JOIN |
| 并(Union) | 合并两个结果集 | UNION |
| 差(Difference) | 取属于一个结果集但不属于另一个的行 | EXCEPT |
| 笛卡尔积(Product) | 所有行两两组合 | CROSS JOIN |
| 聚合(Aggregation) | 对一组行计算统计值 | GROUP BY+COUNT/SUM/MAX/MIN/AVG |
理解关系运算的价值在于:当你在写 SQL 时,其实是在描述“我要什么样的集合结果”,而不是“我要怎么一步步遍历数据”。优化器的作用,就是把这句声明式的描述翻译成高效的执行步骤。
2.4 数据字典:元数据的存储
数据库自身也需要存储“关于数据的数据”,也就是元数据(Metadata)。例如一张表有哪些列,每列是什么类型,哪个字段是主键,这些信息统称为数据字典(Data Dictionary / Catalog)。
在我们后续实现的 MiniDB 中,数据字典是最先要设计的数据结构。我通常会用一个 JSON 文件保存表名到列定义的映射,结构类似:
{ "users": { "columns": [ {"name": "id", "type": "int"}, {"name": "name", "type": "string"}, {"name": "age", "type": "int"} ] } }有了数据字典,插入数据时才能知道“第 0 列对应 id,第 1 列对应 name”,查询时也才能把 SQL 里的列名映射到实际存储中的字段。
3. 存储引擎:数据文件与页面管理
如果说关系模型定义了数据库的逻辑结构,那么存储引擎就负责把逻辑结构落到磁盘上。这一部分对于很多人来说比较陌生,因为平时写业务代码时很少直接接触“页面”“记录”这类底层术语。
3.1 存储引擎要解决什么问题
存储引擎需要回答几个问题:
- 表数据如何组织?是顺序追加到文件末尾,还是按主键排序,还是按哈希分布?
- 索引如何组织?是 B+ 树,还是 LSM 树,还是哈希索引?
- 如何利用磁盘页?磁盘读写的最小单位通常是页(Page),数据库也需要按页管理数据。
- 如何保证写入不丢失?需要借助日志。
常见的存储引擎组织结构包括:
- 堆表(Heap Table):新记录追加到数据文件末尾,适合全表扫描。
- 顺序文件(Sorted File):按某个字段排序存储,适合范围查询,但插入成本高。
- B+ 树索引组织表(Index-Organized Table):表本身按主键的 B+ 树结构存储。
- LSM 树(Log-Structured Merge Tree):先写内存缓冲,再异步合并落盘,适合写多读少的场景。
3.2 页面、记录与插槽结构
数据库一般不会让每条记录单独占用一个文件,而是把文件划分成固定大小的页(Page),页内再存放多条记录。常见的页大小在 4KB 到 32KB 之间。
一个简单的堆表页面结构可以这样理解:
+----------------------------------------------------+ | 页头(页号、空闲空间偏移、记录数量) | +----------------------------------------------------+ | 记录1 | 记录2 | ... | 空闲空间 | +----------------------------------------------------+ | 插槽数组(指向记录偏移量) | +----------------------------------------------------+记录明明已经在页里了,为什么还要插槽数组(Slot Array)?因为删除或更新记录后,物理偏移量可能会变化,插槽数组相当于给每条记录一个稳定的“逻辑指针”。这样可以减少移动大量数据。在我们简化版本的 MiniDB 中,可以先用list[dict]表示一张表,重点先打通整体流程,不必过度纠结页结构。
3.3 日志:先写日志,再写数据
数据库保证事务持久性的关键在于日志。最简单的日志体系是 Write-Ahead Logging(WAL),核心规则是:数据页落盘之前,必须先把描述这次修改的日志落盘。
为什么需要先写日志?因为如果系统在数据写了一半时宕机,启动时可以通过日志进行重做(Redo)或撤销(Undo)。这比直接保证原子写入磁盘要可靠得多,也成为主流数据库的一致性与恢复基础。
MiniDB 课程设计不一定需要完整实现 WAL,但最好能体现“日志先行”思想。比如事务提交时,先把事务操作追加到日志文件,再真正修改内存表数据。这样即使程序中途崩溃,也能根据日志恢复。
3.4 基于 JSON 的最小持久化示例
这里先给一个最基础的存储层示例。我们使用 Python 标准库中的json模块,把数据字典和表数据分别保存到 JSON 文件。虽然性能无法和真实数据库相比,但思路清楚,适合作为课程设计的第一版。
import json import os DATA_DIR = "minidb_data" def load_json(path, default): if os.path.exists(path): with open(path, encoding="utf-8") as f: return json.load(f) return default def save_json(path, obj): with open(path, "w", encoding="utf-8") as f: json.dump(obj, f, ensure_ascii=False, indent=2) def catalog_path(): return os.path.join(DATA_DIR, "catalog.json") def table_path(table_name): return os.path.join(DATA_DIR, f"{table_name}.json")这段代码做的事情很简单:把数据字典和每张表单独拆成文件,读写都封装成函数。实际项目中建议加上异常处理、目录自动创建、文件锁等能力,这里只展示核心思路。
4. SQL 解析与执行引擎:让机器理解查询
存储层解决的是“数据怎么存”的问题,而 SQL 解析与执行引擎解决的是“用户怎么查”的问题。一个数据库支持 SQL,本质上就是把文本翻译成一系列可执行操作。
4.1 词法分析与语法分析
SQL 处理的第一步是词法分析(Lexical Analysis),把 SQL 字符串拆成一个个 Token。比如下面这条语句:
SELECT id, name FROM users WHERE age > 18;会被拆成这些 Token:
SELECT id , name FROM users WHERE age > 18 ;词法分析并不理解语句含义,它只负责识别单词、数字、运算符、标点符号。接下来是语法分析(Syntax Analysis),根据语法规则判断这些 Token 是否能组成合法的 SQL 语句,并生成抽象语法树(AST)。
使用 Python 写一个极简 Tokenizer 其实不难。下面是一个简单的分词函数,用于处理只包含标识符、数字、括号和逗号的 SQL:
import re TOKEN_PATTERN = re.compile(r"\s*(?P<token>[A-Za-z_][A-Za-z0-9_]*|\d+|[(),;=<>!]+)") def tokenize(sql): tokens = [] pos = 0 while pos < len(sql): m = TOKEN_PATTERN.match(sql, pos) if not m: raise SyntaxError(f"无法解析的字符: {sql[pos]}") token = m.group("token") if token: tokens.append(token) pos = m.end() return tokens print(tokenize("SELECT id, name FROM users WHERE age > 18;"))运行后会输出:
['SELECT', 'id', ',', 'name', 'FROM', 'users', 'WHERE', 'age', '>', '18', ';']这个分词器还没有处理字符串字面量,比如WHERE name = 'Alice'中的单引号内容,你需要额外扩展。真实数据库的词法分析器会用类似 Flex 的工具生成,但理解原理后,手写一个简单的也完全可行。
4.2 递归下降解析器
语法分析常用的写法之一是递归下降(Recursive Descent)解析器,它的思路是给每一种语法规则写一个解析函数,函数之间互相调用。例如 SQL 的顶层规则可以是:
statement := SELECT select_list FROM table_name [WHERE condition]对应到递归下降解析器,可以定义parse_select()、parse_select_list()、parse_condition()等方法。下面是一个极简例子,演示如何把SELECT语句解析成结构化对象:
class SelectStatement: def __init__(self, column_names, table_name, condition): self.column_names = column_names self.table_name = table_name self.condition = condition def __repr__(self): return f"SelectStatement({self.column_names}, {self.table_name}, {self.condition})" def parse_select(tokens): index = 0 if tokens[index] != "SELECT": raise SyntaxError("只支持 SELECT 语句") index += 1 column_names = [] while index < len(tokens) and tokens[index] != "FROM": if tokens[index] != ",": column_names.append(tokens[index]) index += 1 if index >= len(tokens) or tokens[index] != "FROM": raise SyntaxError("缺少 FROM 关键字") index += 1 table_name = tokens[index] index += 1 condition = None if index < len(tokens) and tokens[index] == "WHERE": index += 1 condition = { "column": tokens[index], "op": tokens[index + 1], "value": tokens[index + 2], } index += 3 return SelectStatement(column_names, table_name, condition) tokens = tokenize("SELECT id, name FROM users WHERE age > 18;") parse_select(tokens)这里把条件拆成{column, op, value}三种要素,已经足够覆盖数值型简单过滤。真实 SQL 解析器还要处理AND、OR、子查询、函数、连接等,复杂度会高很多。
4.3 执行模型:火山模型
SQL 解析后得到 AST,再经过优化器生成物理计划后,就到了执行器。传统数据库教材中最为经典的执行模型是“火山模型”(Volcano Model),也叫迭代模型。
火山模型的核心思想:每个执行算子都是一个Iterator,对外暴露next()接口。上一次算子从子算子获取一行,经过自己的处理后再向上返回一行。整个查询计划形成一棵“算子树”,最上层不断调用next(),就像火山喷发一样把数据一行行“喷”出来。
例如SELECT id, name FROM users WHERE age > 18的执行计划可以抽象为:
Projection(id, name) -> Filter(age > 18) -> TableScan(users)在实际代码中,可以给每个算子定义一个next()。但在 MiniDB 中,如果你想先跑通功能,最简单的方式是直接用 Python 列表推导式。例如:
def execute_select(statement, tables): table = tables[statement.table_name] rows = table if statement.condition is not None: column = statement.condition["column"] op = statement.condition["op"] value = int(statement.condition["value"]) if op == ">": rows = [r for r in rows if r[column] > value] elif op == "<": rows = [r for r in rows if r[column] < value] else: raise ValueError(f"不支持的操作符: {op}") if "*" in statement.column_names: return rows return [{col: r[col] for col in statement.column_names} for r in rows]这种写法虽然不够工程化,但非常直观,适合用来理解执行器的职责。
4.4 解析并执行一条 INSERT
只支持 SELECT 的数据库还不能写入数据。我们需要继续扩展解析器和执行器,支持 INSERT 语句。假设 SQL 格式是:
INSERT INTO users (id, name, age) VALUES (1, 'Alice', 20);这句话的 Token 可能长这样:
['INSERT', 'INTO', 'users', '(', 'id', ',', 'name', ',', 'age', ')', 'VALUES', '(', '1', ',', "'Alice'", ',', '20', ')', ';']你可以先写一个parse_insert(),把表名、列名、值分别提取出来,然后在存储层调用insert()方法。这种“解析—执行”分离的思路,能让你后续不断扩展 DELETE、UPDATE 等语法,而不会把代码变成一坨。
5. 事务:ACID 的完整落地
关系模型和存储引擎能让数据库“存得住、查得出”,但要让多个用户并发安全地读写数据,还需要事务(Transaction)。事务可能是数据库内核中最值得深挖的部分,也是面试中问题最多的地方。
5.1 事务为什么存在
假设一个银行转账场景:账户 A 扣 100 元,账户 B 加 100 元。如果 A 扣款后系统崩了,B 没有到账,用户就会投诉。数据库引入事务就是为了解决这类问题:把多个操作打包成一个不可分割的执行单元,要么全部成功,要么全部失败。
事务的经典定义是:事务是数据库执行逻辑的一个工作单元,由一系列操作组成,并且具备 ACID 特性。
5.2 ACID 与事务状态机
ACID 是四个特性的缩写:
- 原子性(Atomicity):事务中的操作要么全部生效,要么全部不生效。
- 一致性(Consistency):事务执行前后,数据库的完整性约束不被破坏。
- 隔离性(Isolation):多个事务并发执行时,彼此不会产生干扰。
- 持久性(Durability):事务一旦提交,修改结果就不会丢失。
在实现层面,原子性通常靠 Undo Log 或回滚段实现;持久性通常靠 Redo Log / WAL 实现;隔离性通常靠锁或 MVCC 实现;一致性则是对前面三个特性的综合保障。
事务状态机描述了事务从开始到结束的迁移过程:
Active(活动) -> Partially Committed(部分提交) -> Committed(提交) -> Failed(失败) -> Aborted(中止) -> Terminated(终止)实现事务管理器时,通常需要给每个事务分配一个事务 ID,并跟踪它当前的状态。
5.3 事务模式有哪些
在看数据库文档或框架时,经常会遇到“事务模式”这个说法。常见的事务模式包括:
- 自动提交事务(Auto-Commit):每条 SQL 自动提交,MySQL 默认就是这种模式。
- 显式事务(Explicit Transaction):由开发者显式执行
BEGIN/COMMIT/ROLLBACK。 - 隐式事务(Implicit Transaction):上一事务提交后自动开启新事务,SQL Server 早期支持类似模式。
- 批处理事务:把一组操作作为一个批次提交,减少提交次数。
在编程框架中,还有编程式事务(Programmatic Transaction)和声明式事务(Declarative Transaction),例如 Spring 的@Transactional注解就属于声明式事务。理解这些模式,有助于在实际项目中合理选择事务边界。
5.4 隔离级别:从读未提交到串行化
并发事务如果完全不加控制,会出现四种经典问题:
- 脏读(Dirty Read):读到另一个事务未提交的数据。
- 不可重复读(Non-Repeatable Read):同一事务中两次读取同一行,结果不同,因为中间有其他事务提交了修改。
- 幻读(Phantom Read):同一事务中两次范围查询,结果集不同,因为中间有其他事务插入了新行。
- 丢失更新(Lost Update):两个事务同时修改同一行,后提交的覆盖了先提交的。
SQL 标准定义了四种隔离级别,每一种隔离级别能规避的问题不同:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| 读未提交(Read Uncommitted) | 可能 | 可能 | 可能 |
| 读已提交(Read Committed) | 不会 | 可能 | 可能 |
| 可重复读(Repeatable Read) | 不会 | 不会 | 可能 |
| 串行化(Serializable) | 不会 | 不会 | 不会 |
不同数据库对隔离级别的实现差异很大。比如 MySQL InnoDB 的可重复读通过 MVCC 和间隙锁(Gap Lock)基本避免了幻读,而 PostgreSQL 的默认隔离级别是读已提交,但也支持串行化快照隔离(SSI)。在课程设计中,不需要把每种隔离级别都实现出来,但至少要能在代码里看出“当前事务读取的是哪个快照”。
5.5 并发控制:锁与两阶段锁
数据库实现隔离性最常见的手段是锁(Lock)。锁有两种基本模式:
- 共享锁(Shared Lock, S Lock):允许多个事务同时读同一资源。
- 排他锁(Exclusive Lock, X Lock):只允许一个事务写资源,其他读写都阻塞。
为了让多个事务并发调度后得到的结果与某种串行执行结果等价,数据库普遍采用两阶段锁(Two-Phase Locking, 2PL):事务分为“加锁阶段”和“解锁阶段”,一旦进入解锁阶段就不能再加新锁。严格的 2PL 能保证冲突可串行化。
不过真实数据库中直接使用 2PL 的并不多,因为锁竞争会导致性能下降。更多数据库采用 MVCC(多版本并发控制),写操作创建新版本,读操作读取旧版本,从而做到读写互不阻塞。这也是为什么很多数据库在高并发下仍然表现不错的原因。
5.6 从单机事务到分布式事务
面对微服务和分库分表场景,分布式事务成为必须考虑的问题。分布式事务会涉及多个数据库节点或消息中间件,无法只靠单一数据库的本地事务解决。
分布式事务常见解决方案包括:
- 两阶段提交(2PC):协调者先询问所有参与者能否提交,再统一提交或回滚。
- 三阶段提交(3PC):在 2PC 基础上增加准备阶段,减少阻塞时间。
- TCC(Try-Confirm-Cancel):业务层补偿方案,适合跨服务调用。
- 最终一致性:基于本地消息表、MQ 事务消息等方式,保证短时间内数据最终一致。
- Seata 等分布式事务框架:把 AT、TCC、Saga 等模式封装成易用的中间件。
如果搜索引擎中看到“分布式事务一致性”“Seata 分布式事务原理”等热词,实际上都是围绕这一类问题展开的。对于数据库内核初学者,建议先掌握单机事务,再逐步理解分布式事务的协调与补偿机制。
6. 手把手实现一个迷你数据库
接下来我们进入实战环节。这一节将实现一个简单的 MiniDB,它支持:
create_table:创建表。insert:插入记录。select:查询记录。begin:开启事务。commit:提交事务。rollback:回滚事务。
运行时只需要 Python 3.8+ 和标准库,不需要安装任何第三方依赖。我会给出完整代码,并解释每一部分的设计原因。
6.1 课程设计中的 MiniDB 目标
在设计 MiniDB 时,我建议先明确范围:不追求性能,不追求完整的 SQL 标准,重点是展示数据库内核的模块划分和事务回滚原理。MiniDB 需要满足以下目标:
- 所有数据持久化到本地文件,重启后数据不丢失。
- 数据字典独立保存,支持多表。
- 支持事务的回滚,体现原子性。
- 支持事务提交,体现持久性。
- 使用全局锁实现串行化,避免并发问题。
至于并发性能、MVCC、崩溃恢复等,可以作为扩展方向。
6.2 项目结构与数据模型
项目的目录结构可以很简单:
minidb/ ├── minidb.py └── minidb_data/ ├── catalog.json ├── users.json └── ...所有代码都放在一个minidb.py文件中,方便课堂演示和理解。minidb_data目录会在第一次运行时自动创建。
数据模型方面,我们用 JSON 对象表示表和列:
catalog.json保存数据字典。users.json保存 users 表的全部行,每一行是一个 dict。
6.3 存储与数据字典实现
下面实现 MiniDB 的存储部分。为了演示方便,我使用一个类来统一管理数据和日志。
import json import os import threading class MiniDB: def __init__(self, data_dir="minidb_data"): self.data_dir = data_dir os.makedirs(data_dir, exist_ok=True) self.lock = threading.RLock() self.catalog = self._load_json("catalog.json", {}) self.tables = {} for table_name in self.catalog: table_path = os.path.join(data_dir, f"{table_name}.json") if os.path.exists(table_path): self.tables[table_name] = self._load_json( f"{table_name}.json", [] ) else: self.tables[table_name] = [] self._txn_active = False self._undo_log = [] def _path(self, filename): return os.path.join(self.data_dir, filename) def _load_json(self, filename, default): path = self._path(filename) if os.path.exists(path): with open(path, encoding="utf-8") as f: return json.load(f) return default def _save_json(self, filename, obj): path = self._path(filename) with open(path, "w", encoding="utf-8") as f: json.dump(obj, f, ensure_ascii=False, indent=2) def create_table(self, table_name, columns): with self.lock: if table_name in self.catalog: raise ValueError(f"表 {table_name} 已存在") self.catalog[table_name] = {"columns": columns} self.tables[table_name] = [] self._save_json("catalog.json", self.catalog) self._save_json(f"{table_name}.json", self.tables[table_name])这段代码实现了最基础的建表和持久化。每次create_table都会同步更新数据字典和表文件。之所以用threading.RLock()加锁,是因为 MiniDB 内部操作要保持原子性,防止多线程同时修改文件。
6.4 事务与回滚实现
接下来实现插入和事务控制。为了演示回滚,我采用 Undo Log 的思路:每次在事务内执行insert时,把“这条记录的主键”记录到 undo log;回滚时,按照相反顺序删除这些记录。
def insert(self, table_name, values): with self.lock: self._check_table(table_name) columns = self.catalog[table_name]["columns"] if len(columns) != len(values): raise ValueError(f"列数量不匹配: 需要 {len(columns)},实际 {len(values)}") row = {} for i, col in enumerate(columns): row[col["name"]] = self._parse_value(values[i], col["type"]) row["id"] = len(self.tables[table_name]) + 1 if self._txn_active: self._undo_log.append(("insert", table_name, row["id"])) self.tables[table_name].append(row) self._save_json(f"{table_name}.json", self.tables[table_name]) return row["id"] def begin(self): with self.lock: if self._txn_active: raise RuntimeError("当前已有未提交事务") self._txn_active = True self._undo_log = [] print("事务已开始") def commit(self): with self.lock: if not self._txn_active: raise RuntimeError("没有活跃事务") self._txn_active = False self._undo_log = [] print("事务已提交") def rollback(self): with self.lock: if not self._txn_active: raise RuntimeError("没有活跃事务") for operation, table_name, row_id in reversed(self._undo_log): if operation == "insert": self.tables[table_name] = [ row for row in self.tables[table_name] if row["id"] != row_id ] self._save_json(f"{table_name}.json", self.tables[table_name]) self._txn_active = False self._undo_log = [] print("事务已回滚") def select(self, table_name): with self.lock: self._check_table(table_name) return list(self.tables[table_name]) def _check_table(self, table_name): if table_name not in self.catalog: raise ValueError(f"表 {table_name} 不存在") def _parse_value(self, value, value_type): if value_type == "int": return int(value) if value_type == "float": return float(value) return str(value)这里需要注意几个关键点:
id字段不是由用户传入的,而是自动生成,这样 undo log 可以通过主键精确删除记录。- 只有在事务开启时才记录 undo log,普通模式下直接提交。
- 回滚时从后向前撤销操作,保证执行顺序相反,这正是 Undo Log 的基本思想。
- 每次修改都立刻持久化到 JSON 文件,虽然性能差,但保证了重启后数据仍然存在。
6.5 运行测试与预期输出
把上面的代码整合到同一个类中后,我们在文件末尾添加测试代码:
if __name__ == "__main__": db = MiniDB() db.create_table("users", [ {"name": "name", "type": "string"}, {"name": "age", "type": "int"}, ]) # 测试回滚 db.begin() db.insert("users", ["Alice", 20]) db.insert("users", ["Bob", 25]) print("回滚前数据:", db.select("users")) db.rollback() print("回滚后数据:", db.select("users")) # 测试提交 db.begin() db.insert("users", ["Charlie", 30]) db.commit() print("提交后数据:", db.select("users"))预期输出类似:
事务已开始 回滚前数据: [{'name': 'Alice', 'age': 20, 'id': 1}, {'name': 'Bob', 'age': 25, 'id': 2}] 事务已回滚 回滚后数据: [] 事务已开始 事务已提交 提交后数据: [{'name': 'Charlie', 'age': 30, 'id': 1}]看到这个结果,说明事务的原子性已经具备了:未提交事务被回滚后,数据恢复到事务开始前的状态。
6.6 还能怎么扩展
这个迷你数据库虽然简单,但已经具备了一个数据库内核的雏形。如果数据库课程设计需要进一步扩展,我建议按以下顺序迭代:
- 支持删除和更新,并设计对应的 undo log 操作。
- 支持带条件的
WHERE查询。 - 实现简单的 B+ 树索引,优化查询。
- 引入 Redo Log,模拟宕机后的恢复。
- 实现两种隔离级别,比如读未提交和读已提交。
- 用 Flask/FastAPI 写一个 HTTP 接口,让 MiniDB 可以被外部程序调用。
只要有耐心,这个项目完全可以发展成一个中等规模的课程设计项目。
7. 常见问题与排查思路
在实现迷你数据库时,初学者最容易遇到下面几类问题。这里整理成表格,方便你快速排查。
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 建表后插入数据报“表不存在” | 数据字典与表文件加载顺序不对 | 检查__init__中是否先加载 catalog,再加载 tables |
| 回滚后发现数据没有恢复 | undo log 记录的主键定位不到记录 | 打印 undo log 和表中数据,确认主键是否一致 |
| 重复运行程序后数据“消失” | 工作目录不对,文件写到了其他路径 | 在代码中打印os.getcwd()和data_dir的绝对路径 |
| 多线程同时写入时报错 | 没有加锁或锁粒度不够 | 在修改表和文件的方法上统一使用with self.lock |
| 修改了表结构后旧数据无法读取 | 数据字典变更没有做兼容设计 | 增加表结构版本号,或在加载时做字段映射 |
| 插入中文时 JSON 文件乱码 | 写入文件时没有指定ensure_ascii=False | 统一使用json.dump(obj, f, ensure_ascii=False) |
| 事务内插入大量数据后回滚很慢 | 回滚需要全表遍历删除记录 | 用row_id建立内存索引,或记录行号而非主键 |
如果你在运行过程中遇到其他问题,我建议先用最小化场景复现:只创建一个空数据库,执行一次插入,再执行一次回滚,逐步增加复杂度。
8. 最佳实践与工程建议
课程设计项目和生产级数据库之间还有很大距离,但我们可以从工程角度优化这个 MiniDB 的代码和设计。
8.1 从课程设计到工程实现
在完成基础功能后,可以从以下几个方面提升代码质量:
- 引入面向接口的设计:把存储引擎、解析器、执行器拆成不同模块,避免把所有逻辑堆在同一个类里。
- 统一异常处理:自定义
DatabaseError、TableNotExistsError、TransactionError等异常,便于上层捕获。 - 增加日志输出:用
logging模块替代print,方便后续排查问题。 - 使用配置文件:数据目录、日志级别、页面大小等都放入配置,提高灵活性。
8.2 事务与并发设计建议
事务设计时,要先想清楚“哪些操作需要处于同一个事务中”。在业务系统中,事务边界过大会导致锁持有时间过长,事务边界过小又可能破坏原子性。一个常见经验是:只把“不可分割的一组写操作”放进事务,查询读取尽量走读副本。
在数据库内核层面,如果要做并发控制,我建议优先理解锁和 MVCC 的差别。课程设计中可以先实现简单的全局锁,也就是把所有操作串行化;在此基础上再尝试按行加锁;最后再研究 MVCC。层层推进会更容易理解。
8.3 持久化与恢复建议
持久化不能只靠“每次修改后立刻全量写文件”,这个做法在数据量变大后非常低效。更合理的做法是:
- 先写日志,再异步把数据页刷盘。
- 日志文件按大小或时间轮转,避免无限增长。
- 定期做 Checkpoint,记录哪些页面已经落盘,缩短恢复时间。
MiniDB 可以把这部分当成进阶任务,模拟一次“程序中途退出后,通过日志恢复未提交数据”的场景,非常能提升对 WAL 的理解。
8.4 数据库内核学习的推荐路线
如果在完成本文内容后,想继续深入学习数据库内核,可以参考下面的路线:
- 先读完《数据库系统概念》前几章,把关系代数、SQL、事务概念打牢。
- 学习一门经典公开课,例如卡内基梅隆大学的 15-445 Database Systems。
- 阅读 PostgreSQL 或 SQLite 的源码片段,不需要全部看懂,重点看存储和事务模块。
- 实现一个支持 B+ 树索引、MVCC 和 WAL 的 MiniDB。
- 最后再接触分布式数据库组件,比如 TiKV、Doris、OceanBase 等开源项目的设计文档。
如果你经常在搜索引擎看到“数据库课程设计”“分布式事务一致性”“Seata 分布式事务原理”等关键词,说明你正在经历从理论到实战、从单机到分布式的成长过程。建议把每一步的基础打扎实,不要急于求成。
9. 小结与动手建议
本文完整走了一遍数据库内核的主线:从关系模型理解数据的二维表结构,从存储引擎理解数据的落盘方式,从 SQL 解析理解查询的执行过程,从事务理解并发场景下的安全性与一致性。最后的 MiniDB 代码虽然只有不到两百行,却把数据字典、文件持久化、事务提交和回滚这些核心概念串在了一起。
如果你正在准备数据库课程设计,我的建议是:先把今天这份代码在自己的电脑上跑一遍,确认输出符合预期;然后把后面提到的扩展点列成计划表,每周完成一个功能模块。比如第一周加 DELETE 和 UPDATE,第二周加 WHERE 条件,第三周加简单索引。这样一来,你的课程设计既有理论深度,又有完整实现,答辩时也能讲得非常清楚。
数据库内核的学习没有捷径,但也没有想象中那么难。当你亲手实现了一次事务回滚,看到数据真的恢复到事务开始前的状态时,那些曾经在教科书里显得抽象的概念,就会变成你真正掌握的工程能力。