news 2026/9/8 6:53:16

从关系模型到事务:手把手实现一个迷你数据库内核

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从关系模型到事务:手把手实现一个迷你数据库内核

做数据库课程设计时,很多同学会选择“从零实现一个迷你数据库”这类项目,但在动手之后往往会发现:网上的案例要么只讲 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 解析器还要处理ANDOR、子查询、函数、连接等,复杂度会高很多。

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)

这里需要注意几个关键点:

  1. id字段不是由用户传入的,而是自动生成,这样 undo log 可以通过主键精确删除记录。
  2. 只有在事务开启时才记录 undo log,普通模式下直接提交。
  3. 回滚时从后向前撤销操作,保证执行顺序相反,这正是 Undo Log 的基本思想。
  4. 每次修改都立刻持久化到 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 还能怎么扩展

这个迷你数据库虽然简单,但已经具备了一个数据库内核的雏形。如果数据库课程设计需要进一步扩展,我建议按以下顺序迭代:

  1. 支持删除和更新,并设计对应的 undo log 操作。
  2. 支持带条件的WHERE查询。
  3. 实现简单的 B+ 树索引,优化查询。
  4. 引入 Redo Log,模拟宕机后的恢复。
  5. 实现两种隔离级别,比如读未提交和读已提交。
  6. 用 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 从课程设计到工程实现

在完成基础功能后,可以从以下几个方面提升代码质量:

  • 引入面向接口的设计:把存储引擎、解析器、执行器拆成不同模块,避免把所有逻辑堆在同一个类里。
  • 统一异常处理:自定义DatabaseErrorTableNotExistsErrorTransactionError等异常,便于上层捕获。
  • 增加日志输出:用logging模块替代print,方便后续排查问题。
  • 使用配置文件:数据目录、日志级别、页面大小等都放入配置,提高灵活性。

8.2 事务与并发设计建议

事务设计时,要先想清楚“哪些操作需要处于同一个事务中”。在业务系统中,事务边界过大会导致锁持有时间过长,事务边界过小又可能破坏原子性。一个常见经验是:只把“不可分割的一组写操作”放进事务,查询读取尽量走读副本。

在数据库内核层面,如果要做并发控制,我建议优先理解锁和 MVCC 的差别。课程设计中可以先实现简单的全局锁,也就是把所有操作串行化;在此基础上再尝试按行加锁;最后再研究 MVCC。层层推进会更容易理解。

8.3 持久化与恢复建议

持久化不能只靠“每次修改后立刻全量写文件”,这个做法在数据量变大后非常低效。更合理的做法是:

  • 先写日志,再异步把数据页刷盘。
  • 日志文件按大小或时间轮转,避免无限增长。
  • 定期做 Checkpoint,记录哪些页面已经落盘,缩短恢复时间。

MiniDB 可以把这部分当成进阶任务,模拟一次“程序中途退出后,通过日志恢复未提交数据”的场景,非常能提升对 WAL 的理解。

8.4 数据库内核学习的推荐路线

如果在完成本文内容后,想继续深入学习数据库内核,可以参考下面的路线:

  1. 先读完《数据库系统概念》前几章,把关系代数、SQL、事务概念打牢。
  2. 学习一门经典公开课,例如卡内基梅隆大学的 15-445 Database Systems。
  3. 阅读 PostgreSQL 或 SQLite 的源码片段,不需要全部看懂,重点看存储和事务模块。
  4. 实现一个支持 B+ 树索引、MVCC 和 WAL 的 MiniDB。
  5. 最后再接触分布式数据库组件,比如 TiKV、Doris、OceanBase 等开源项目的设计文档。

如果你经常在搜索引擎看到“数据库课程设计”“分布式事务一致性”“Seata 分布式事务原理”等关键词,说明你正在经历从理论到实战、从单机到分布式的成长过程。建议把每一步的基础打扎实,不要急于求成。

9. 小结与动手建议

本文完整走了一遍数据库内核的主线:从关系模型理解数据的二维表结构,从存储引擎理解数据的落盘方式,从 SQL 解析理解查询的执行过程,从事务理解并发场景下的安全性与一致性。最后的 MiniDB 代码虽然只有不到两百行,却把数据字典、文件持久化、事务提交和回滚这些核心概念串在了一起。

如果你正在准备数据库课程设计,我的建议是:先把今天这份代码在自己的电脑上跑一遍,确认输出符合预期;然后把后面提到的扩展点列成计划表,每周完成一个功能模块。比如第一周加 DELETE 和 UPDATE,第二周加 WHERE 条件,第三周加简单索引。这样一来,你的课程设计既有理论深度,又有完整实现,答辩时也能讲得非常清楚。

数据库内核的学习没有捷径,但也没有想象中那么难。当你亲手实现了一次事务回滚,看到数据真的恢复到事务开始前的状态时,那些曾经在教科书里显得抽象的概念,就会变成你真正掌握的工程能力。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/8 6:53:00

Python单元测试实战:用unittest为代码构建可靠安全网

很多人一听到“单元测试”这四个字&#xff0c;第一反应往往是&#xff1a;“这不归我管&#xff0c;功能能跑就行。”我以前也这么想&#xff0c;直到有一次改一个订单金额计算函数&#xff0c;把向下取整改成了四舍五入&#xff0c;结果线上所有低于 0.5 的分位金额都多了一分…

作者头像 李华
网站建设 2026/9/8 6:52:49

Forge 1.20.1模组安装与开发环境搭建全攻略

简介&#xff1a;面向Minecraft模组开发者的Forge 1.20.1开发工具包&#xff0c;对应Minecraft 1.20.1版本&#xff0c;适合希望扩展游戏内容、学习模组开发或搭建专属体验环境的玩家与开发者使用。压缩包体积仅111KB&#xff0c;共17个文件&#xff0c;以txt说明文档、gradle构…

作者头像 李华
网站建设 2026/9/8 6:52:25

Spring AI 2.0实战:Java团队零Python接入大模型

最近一次代码评审&#xff0c;我终于把维护了一整年的Python大模型中转服务下线了。上一轮项目刚开始接大模型的时候&#xff0c;公司技术栈全是Java&#xff0c;团队里没有任何人有Python实战经验&#xff0c;最后只能临时找外包搭了一个Flask服务&#xff0c;专门负责把群里的…

作者头像 李华
网站建设 2026/9/8 6:52:16

三甲医院大模型本地部署全指南:硬件选型、显存计算与HIS对接实战

医院信息科这几年的日子确实不好过。一边是HIS、EMR、PACS这些老系统维护不完的工单&#xff0c;一边是领导从外面开会回来就拍桌子问&#xff1a;AI大模型到底什么时候能用上&#xff1f;你要是直接说买云服务&#xff0c;后面患者隐私、数据合规那一关就够你喝一壶的。所以找…

作者头像 李华
网站建设 2026/9/8 6:52:08

原神抽卡模拟器zip:概率算法、保底机制与前端实现全拆解

简介&#xff1a;一份基于C开发的原神抽卡模拟器工程&#xff0c;面向原神玩家与C学习者。该程序复刻游戏内祈愿逻辑&#xff0c;通过随机数生成、类与计数器实现常驻池、角色池概率分配及保底机制&#xff0c;并用Qt/SFML等方式提供简易交互界面。资源包共38个文件&#xff0c…

作者头像 李华
网站建设 2026/9/8 6:52:05

Boost 1.78 MinGW 7.30 64位动态库预编译包与工程配置详解

简介&#xff1a;面向64位Windows平台的Boost 1.78库预编译包&#xff0c;采用MinGW 7.3.0工具链构建&#xff0c;提供动态链接版本&#xff0c;同时包含调试版与发布版。特别适合使用Qt Creator进行C开发的工程师&#xff0c;可跳过繁重的源码编译过程&#xff0c;直接获得与M…

作者头像 李华