
这次我们来看一个非常硬核的技术实践项目从零开始编写自己的数据库。这不是一个简单的玩具项目而是一个旨在深入理解数据库内核原理的工程实践。对于后端开发者、系统工程师以及对数据库底层机制充满好奇的技术爱好者来说这是一个绝佳的学习和挑战机会。本文将带你从零开始一步步拆解数据库的核心组件包括存储引擎、索引结构、事务处理和查询解析并提供一个可运行的、最小化的数据库实现方案。我们将重点关注其设计思路、关键实现细节、如何启动和测试以及在实际开发中可能遇到的性能瓶颈和排查方法。1. 核心能力速览能力项说明项目类型教学/实践型数据库内核实现核心目标深入理解数据库存储、索引、事务、SQL解析等核心机制编程语言通常使用 C/C、Go、Rust 或 Java 等系统级语言存储引擎实现基于 BTree 或 LSM-Tree 的键值存储索引支持主键索引BTree可能扩展二级索引事务支持实现 ACID 特性如基于 WALWrite-Ahead Logging的原子性和持久性查询语言支持简化版的 SQLDDL、DML或自定义命令并发控制可能实现简单的锁机制或 MVCC多版本并发控制启动方式命令行启动作为独立进程运行提供交互式 Shell 或网络 API适合场景学习数据库原理、课程设计、技术验证、嵌入式轻量存储不适合场景生产环境高并发、海量数据、企业级高可用需求2. 适用场景与使用边界这个“手写数据库”项目主要面向几类开发者一是希望突破 CRUD 层面深入理解数据库内部工作原理的后端工程师二是计算机专业的学生用于完成数据库原理相关的课程设计或毕业设计三是系统架构师通过拆解核心组件来评估不同存储引擎的优劣。它能帮助你彻底搞清 BTree 如何组织数据、WAL 如何保证数据不丢失、SQL 语句如何被解析和执行。它能解决的核心问题是“知其然知其所以然”。通过亲手实现你将不再对数据库的黑盒感到畏惧能够更精准地进行 SQL 优化、故障排查和存储选型。例如当你理解了索引的物理结构就能更好地设计表结构当你明白了事务日志的写入过程就能更合理地配置存储 I/O。然而必须明确其使用边界。这是一个教学项目绝对不适合直接用于生产环境。它缺乏企业级数据库的查询优化器、复杂的执行计划、分布式事务、备份恢复、监控告警等高级功能。在性能、稳定性、安全性和功能完整性上与 MySQL、PostgreSQL 等成熟产品有数量级的差距。请仅将其用于学习、研究和测试验证。3. 环境准备与前置条件开始编码前需要准备好开发环境。由于数据库是系统软件对开发工具和基础知识有一定要求。操作系统推荐 Linux如 Ubuntu 20.04或 macOS。Windows 用户可通过 WSL2 获得接近 Linux 的开发体验。编程语言与工具链C/C需要 GCC/Clang 编译器、Make/CMake 构建工具、GDB 调试器。这是最经典的选择能让你接触到底层内存和文件管理。Go需要安装 Go (1.18)。其并发原语和标准库对实现网络层和并发控制比较友好。Rust需要安装 Rust 工具链。其所有权模型能帮助避免很多内存安全错误适合构建可靠的系统软件。Java需要 JDK 11。优势在于生态丰富可以更专注于逻辑而非底层细节。必备知识数据结构必须熟练掌握链表、哈希表、树尤其是 B-Tree/BTree的结构和操作。文件 I/O理解如何以二进制形式读写文件管理文件指针处理缓冲。内存管理理解堆栈内存、指针/引用、序列化与反序列化。并发基础了解锁、信号量等概念为后续实现事务隔离打基础。网络基础可选如果计划提供网络访问接口需要了解 Socket 编程基础。磁盘空间项目本身代码不大但需要预留空间存放测试数据文件和日志建议至少 1GB 可用空间。4. 设计与实现存储引擎Storage Engine存储引擎是数据库的基石负责数据的持久化存储和检索。我们首先实现一个基于文件页Page和 BTree 的简单存储引擎。4.1 文件与页管理数据库将持久化数据存放在磁盘文件中。为了高效管理我们把文件逻辑上划分为固定大小的“页”Page例如 4KB 或 8KB。这是磁盘 I/O 的基本单位。首先定义页的结构// page.h - 页结构定义C语言示例 #define PAGE_SIZE 4096 // 4KB typedef struct { uint32_t page_id; // 页编号 uint16_t free_space; // 页内剩余空间 uint16_t record_count; // 当前页存储的记录数 char data[PAGE_SIZE - 8]; // 实际存储数据的区域 } Page;我们需要一个Pager模块来管理页的读写和缓存// pager.h typedef struct { int file_descriptor; uint32_t file_length; uint32_t num_pages; Page* pages_cache[MAX_CACHED_PAGES]; // 简单的页缓存 } Pager; Pager* pager_open(const char* filename); Page* pager_get_page(Pager* pager, uint32_t page_num); void pager_flush_page(Pager* pager, uint32_t page_num); void pager_close(Pager* pager);pager_get_page是核心函数它首先检查缓存如果缺失则从磁盘读取。一个简单的 LRU 缓存策略可以提升性能。4.2 BTree 索引实现BTree 是关系型数据库最常用的索引结构。它保持树平衡所有数据都存储在叶子节点并且叶子节点之间通过指针链接支持高效的范围查询。节点设计 我们设计两种节点内部节点存储键和指向子节点的指针和叶子节点存储键和实际的数据记录或记录指针。// btree.h typedef enum { NODE_INTERNAL, NODE_LEAF } NodeType; typedef struct { NodeType node_type; bool is_root; uint32_t parent_page_num; uint32_t num_keys; // 后续是变长部分存储键和指针/数据 } Node;关键操作查找从根节点开始根据键值比较递归向下查找直到叶子节点。插入找到应插入的叶子节点。如果节点未满直接插入。如果节点已满需要进行分裂将原节点一半的键提升到父节点创建新节点存放另一半键。分裂可能递归向上传播直到根节点。如果根节点分裂则树的高度增加。删除更为复杂涉及节点合并或向兄弟节点借键以保持树的平衡。以下是一个简化的插入流程代码框架// btree.c ExecuteResult insert_into_leaf(Pager* pager, Table* table, uint32_t leaf_page_num, uint32_t key, Row* value) { Node* leaf_node get_page(pager, leaf_page_num); uint32_t num_cells *leaf_node_num_cells(leaf_node); if (num_cells LEAF_NODE_MAX_CELLS) { // 叶子节点已满需要分裂 return insert_into_leaf_after_splitting(pager, table, leaf_page_num, key, value); } // 找到插入位置移动后续单元格插入新键值 uint32_t insertion_index find_key_index_in_leaf(leaf_node, key); for (uint32_t i num_cells; i insertion_index; i--) { memcpy(leaf_node_cell(leaf_node, i), leaf_node_cell(leaf_node, i-1), LEAF_NODE_CELL_SIZE); } *leaf_node_num_cells(leaf_node) 1; *leaf_node_key(leaf_node, insertion_index) key; serialize_row(value, leaf_node_value(leaf_node, insertion_index)); return EXECUTE_SUCCESS; }5. 设计与实现SQL前端与命令执行有了存储引擎我们需要一个前端来接收用户命令如 SQL并驱动后端执行。5.1 词法分析与语法分析Parser首先我们需要将输入的 SQL 字符串如INSERT INTO users VALUES (1, ‘alice’)转换成内部可处理的结构。这个过程分为两步词法分析Lexer将字符串拆分成一个个“词法单元”Token如关键字INSERT, INTO、标识符users、值1, ‘alice’、运算符、括号等。语法分析Parser根据预定义的语法规则通常使用上下文无关文法描述将 Token 序列组合成一颗“抽象语法树”AST。这颗树代表了 SQL 语句的结构。我们可以使用工具如 Flex/Bison, ANTLR或手写递归下降解析器。以下是一个极简的手动解析INSERT语句的示例// parser.c PrepareResult prepare_insert(InputBuffer* input_buffer, Statement* statement) { statement-type STATEMENT_INSERT; char* keyword strtok(input_buffer-buffer, ); char* table_name strtok(NULL, ); if (table_name NULL) return PREPARE_SYNTAX_ERROR; char* values_keyword strtok(NULL, ); if (values_keyword NULL || strcmp(values_keyword, VALUES) ! 0) { return PREPARE_SYNTAX_ERROR; } char* id_str strtok(NULL, ); char* username_str strtok(NULL, ); // 简单处理假设用户名用单引号包裹 if (id_str NULL || username_str NULL) { return PREPARE_SYNTAX_ERROR; } int id atoi(id_str); if (id 0) return PREPARE_NEGATIVE_ID; if (strlen(username_str) COLUMN_USERNAME_SIZE) return PREPARE_STRING_TOO_LONG; statement-row_to_insert.id id; strcpy(statement-row_to_insert.username, username_str); return PREPARE_SUCCESS; }5.2 虚拟机VM与执行器Executor解析得到的 AST 或预处理后的Statement需要被转换成对存储引擎的一系列操作。这部分可以看作一个简单的“虚拟机”。// vm.c ExecuteResult execute_statement(Statement* statement, Table* table) { switch (statement-type) { case (STATEMENT_INSERT): return execute_insert(statement, table); case (STATEMENT_SELECT): return execute_select(statement, table); default: return EXECUTE_FAILURE; } } ExecuteResult execute_insert(Statement* statement, Table* table) { Row* row_to_insert (statement-row_to_insert); Cursor* cursor table_find(table, row_to_insert-id); // 先查找是否已存在 if (cursor-page_num table-root_page_num) { // 找到了相同的键说明主键冲突 free(cursor); return EXECUTE_DUPLICATE_KEY; } // 执行插入 leaf_node_insert(cursor, row_to_insert-id, row_to_insert); free(cursor); return EXECUTE_SUCCESS; } ExecuteResult execute_select(Statement* statement, Table* table) { Cursor* cursor table_start(table); // 获取指向第一个元素的游标 Row row; while (!(cursor-end_of_table)) { deserialize_row(cursor_value(cursor), row); // 从存储中反序列化数据 print_row(row); // 打印行数据 cursor_advance(cursor); } free(cursor); return EXECUTE_SUCCESS; }Cursor是一个抽象它表示在表中的某个位置用于迭代遍历数据。6. 设计与实现事务与持久化WAL要保证数据的持久性Durability和原子性Atomicity我们需要实现预写式日志Write-Ahead Logging, WAL。核心思想在数据页被实际修改并刷回磁盘之前先将修改操作描述重做日志 Redo Log记录到一个独立的日志文件中。这样即使系统在写数据页时崩溃重启后也可以通过重放日志来恢复未完成的事务。WAL 实现步骤日志记录格式定义每条日志的结构通常包含事务ID、操作类型INSERT/UPDATE/DELETE、涉及的表、页、偏移量以及旧值/新值。typedef struct { uint32_t trx_id; uint8_t operation; // INSERT, UPDATE, DELETE uint32_t table_id; uint32_t page_num; uint16_t offset; uint16_t data_len; char old_data[VALUE_MAX_LEN]; // 用于UNDO如果实现 char new_data[VALUE_MAX_LEN]; // 用于REDO } LogRecord;日志写入在执行任何会修改数据页的操作前先调用log_write(record)将日志记录同步fsync到磁盘的日志文件。数据页修改日志成功落盘后才在内存中修改数据页。检查点Checkpoint定期将内存中所有脏页刷回数据文件并在日志中记录一个检查点标记。检查点之前的日志可以被安全清理。恢复Recovery数据库启动时检查是否存在未应用的日志。从最近的检查点开始重放Redo之后的所有日志记录将数据恢复到崩溃前的状态。7. 功能测试与效果验证完成核心模块后我们需要进行系统性的测试。7.1 单元测试为每个核心模块编写单元测试。BTree 测试测试节点的插入、分裂、查找、删除、合并是否正确。Pager 测试测试页的读取、写入、缓存替换策略。Parser 测试测试各种正确和错误的 SQL 语句能否被正确解析或报错。WAL 测试模拟崩溃验证重启后数据能否通过日志恢复。可以使用如 Google Test (C)、testing (Go) 等框架。7.2 集成测试启动数据库并执行操作编写一个简单的测试脚本或直接在交互式 Shell 中操作。1. 启动数据库服务假设我们的数据库编译后的可执行文件叫mydb。# 启动一个全新的数据库实例数据文件为 test.db ./mydb test.db启动后应进入一个简单的命令行提示符如mydb 。2. 创建表与插入数据mydb CREATE TABLE users (id INT PRIMARY KEY, name TEXT); -- 执行成功应返回提示 mydb INSERT INTO users VALUES (1, Alice); Executed. mydb INSERT INTO users VALUES (2, Bob); Executed.3. 查询数据mydb SELECT * FROM users; (1, Alice) (2, Bob) Executed.4. 测试主键约束mydb INSERT INTO users VALUES (1, Charlie); Error: Duplicate key.这验证了 BTree 索引防止了重复主键的插入。5. 测试持久化插入一些数据。退出数据库进程 (CtrlD或.exit命令)。重新启动数据库./mydb test.db。执行SELECT * FROM users;检查之前插入的数据是否仍然存在。这验证了 WAL 或数据刷盘机制是否正常工作。7.3 性能与正确性验证批量插入编写脚本插入 1万/10万 条数据观察插入速度和内存占用。使用time命令计时。范围查询测试SELECT * FROM users WHERE id 100 AND id 200;验证 BTree 叶子节点链表遍历的效率。并发简单测试如果已实现尝试在两个客户端连接中同时读写观察是否有基本的锁保护避免数据错乱。8. 常见问题与排查方法在开发和使用自研数据库过程中你会遇到各种问题。以下是一些常见问题及排查思路问题现象可能原因排查方式解决方案数据库文件损坏无法启动1. 程序异常崩溃导致数据页未完整写入。2. WAL 日志与数据文件不一致。3. 磁盘故障。1. 检查程序最后退出的日志。2. 使用hexdump或自定义工具查看文件头魔数、页校验和。3. 尝试从备份或 WAL 恢复。1. 加强 WAL 机制确保原子性。2. 定期备份数据文件。3. 实现文件完整性校验如 checksum。插入或查询速度极慢1. 未使用索引全表扫描。2. 缓存太小频繁磁盘 I/O。3. 每次插入都同步刷盘fsync。4. BTree 节点分裂频繁。1. 使用EXPLAIN类命令查看执行路径如果实现了。2. 监控系统 I/O 使用率iostat。3. 检查代码中 fsync 的调用频率。4. 打印 BTree 高度变化。1. 确保对查询条件列建立索引。2. 增加页缓存大小。3. 实现 Group Commit批量处理 fsync。4. 优化 BTree 的填充因子。并发操作时数据错乱或丢失1. 未实现任何并发控制。2. 锁粒度太粗或太细导致死锁或性能问题。3. 事务隔离级别实现有误。1. 编写并发测试用例复现问题。2. 打印锁获取和释放的日志。3. 检查 MVCC 中版本链的维护。1. 实现最基本的行级锁或表级锁。2. 使用锁超时机制预防死锁。3. 仔细阅读并实现 MVCC 算法。解析复杂的 SQL 语句失败1. 语法分析器Parser文法定义不完整。2. 词法分析器无法识别某些 Token。1. 输入出错的 SQL 语句观察 Parser 报错信息。2. 使用调试工具跟踪 Parser 的解析过程。1. 扩展语法规则支持更多 SQL 特性。2. 使用更强大的解析器生成工具如 ANTLR。内存泄漏进程占用内存持续增长1. 分配的内存页缓存、游标、语句结构未释放。2. 缓存无淘汰策略。1. 使用 Valgrind (C/C) 或 pprof (Go) 等工具检测内存泄漏。2. 检查所有malloc和free的配对。1. 确保每个create或open函数都有对应的destroy或close。2. 为页缓存实现 LRU 等淘汰算法。9. 最佳实践与扩展方向开发最佳实践测试驱动每实现一个核心功能如 BTree 插入立即编写单元测试。这能极大提升代码质量和调试效率。版本控制与备份使用 Git 管理代码。在实现破坏性更改如修改文件格式前务必打标签备份。模块化设计清晰划分 Pager、BTree、Parser、VM、WAL 等模块降低耦合度。日志系统实现一个简单的日志模块输出不同级别DEBUG, INFO, ERROR的信息这是调试复杂系统问题的生命线。使用现有轮子对于复杂部分如 SQL 解析可以考虑集成轻量级的现有库如 SQLite 的 parser将精力集中在核心存储和事务上。下一步扩展方向支持更多 SQL从简单的 INSERT/SELECT 扩展到 UPDATE、DELETE、WHERE 条件过滤、多表 JOIN非常复杂。实现 MVCC用多版本并发控制替代简单的锁大幅提升读并发性能。这是理解 PostgreSQL、MySQL(InnoDB) 事务隔离级别的关键。增加网络层实现一个简单的 TCP 服务器让数据库可以通过网络协议如模仿 MySQL 的协议被远程客户端访问。实现简单的优化器对于带 WHERE 条件的查询决定是全表扫描还是使用索引扫描。支持多种存储引擎尝试实现一个基于 LSM-Tree如 LevelDB/RocksDB 风格的存储引擎并与 BTree 引擎进行性能对比。从零编写数据库是一个庞大的工程但通过将其分解为存储引擎、SQL 前端、事务处理等相对独立的模块并逐个攻破是完全可行的。这个过程带给你的不仅是数据库知识的融会贯通更是系统设计能力、复杂问题分解能力和工程实现能力的巨大提升。建议从 SQLite 的官方文档和代码特别是btree.c、pager.c、vdbe.c中汲取灵感它是最佳的学习范本。当你看到自己编写的程序能够持久化存储并正确检索数据时那种成就感是无与伦比的。