
简介这是一份面向计算机专业本科生与数据库系统初学者的C数据库开发实践资源聚焦关系型数据库核心原理实现适用于数据库课程设计、系统编程实训及MiniSQL实验教学。资源包含377个文件以127个头文件h和100个C源文件cc/cpp构成主体框架辅以34个Python脚本用于测试与工具支持、9个CMake构建配置及多种工程元文件如CMakeLists.txt、.yml、.gitignore等完整覆盖编译构建、语法解析、执行引擎与持久化模块压缩包仅1.05MB轻量易部署。已有80人学习下载适合通过源码级研读深入理解缓冲池管理、B树索引、记录管理器及序列化持久化等关键机制。读者可直接运行并调试完整MiniSQL系统观察语法树生成、SQL语句执行流程掌握从Parser到Executor的全链路实现细节是少有的兼顾教学规范性与工程可运行性的轻量级DBMS参考实现。1. 这不是玩具一个用纯C手撸的MiniSQL为什么能跑通CREATE/INSERT/SELECT还扛住百条并发你可能在课程设计、毕设选题或自学数据库原理时见过“MiniSQL”这个词——它不像SQLite那样开箱即用也不像PostgreSQL那样功能庞杂而是一个完全由C从零实现的关系型数据库最小可行系统没有第三方SQL解析器没有现成的B树库连内存管理都自己写allocator但它能真正执行CREATE TABLE users(id INT, name CHAR(20));、INSERT INTO users VALUES(1, Alice);、SELECT * FROM users WHERE id 0;甚至支持基本的事务隔离与磁盘持久化。这不是教学演示脚本而是某高校数据库系统实验课的硬核结题项目源码包里共127个.cpp/.h文件核心模块词法分析、语法树构建、查询优化器骨架、页式存储管理器、日志恢复模块全部可调试、可打断点、可单步跟踪。它不追求性能但每行代码都在回答一个问题“SQL语句到底怎么变成磁盘读写”如果你正卡在“学完《数据库系统概念》却写不出一行存储引擎代码”或者想用真实可运行的C工程反向吃透ACID、WAL、缓冲区替换策略这些黑匣子概念——这个MiniSQL就是你该打开的第一个生产级玩具。它适合两类人刚学完操作系统和C基础、想落地验证理论的学生以及需要给新人讲清“数据库内核不是魔法”的一线工程师。2. 从零启动编译、目录结构与第一个可运行的SQL命令MiniSQL不是靠make install就能塞进系统PATH的成熟工具它的构建逻辑紧贴教学目标——暴露所有底层依赖。你拿到的.zip解压后典型结构如下路径已脱敏MiniSQL/ ├── build/ # 编译输出目录空需手动创建 ├── src/ │ ├── parser/ # 手写LL(1)语法分析器含.y和.l文件 │ ├── optimizer/ # 基于规则的简单优化器如谓词下推 │ ├── storage/ # 核心BufferPool PageManager FileManager │ ├── transaction/ # 两阶段锁协议 WAL日志格式定义 │ └── main.cpp # 入口REPL循环 SQL命令分发 ├── data/ # 运行时生成的数据库文件.db, .log ├── test/ # 内置测试SQL脚本create_test.sql等 └── CMakeLists.txt # 关键仅依赖C17标准库无Boost/STL以外任何外部库提示该工程明确拒绝使用std::filesystem因部分旧版GCC不支持所有路径操作均用string拼接fopen()裸调这是为强制理解“文件抽象层”而做的刻意降级。2.1 用CMake在Linux/macOS上完成首次编译Windows需额外处理确保你有GCC 9.4 或 Clang 12C17完整支持。进入项目根目录执行mkdir build cd build cmake -DCMAKE_BUILD_TYPEDebug .. # 必须加Debug否则断点失效 make -j$(nproc)若报错fatal error: bits/cconfig.h: No such file说明未安装g-multilibUbuntu/Debian系sudo apt install g-multilib # 或对应发行版的等效包编译成功后build/下生成可执行文件minisql。此时不要急着运行——先确认它是否真能解析SQLecho SELECT 11; | ./minisql预期输出[INFO] Parser: Successfully parsed 1 statement(s) [RESULT] 2这行命令验证了词法分析器lexer→ 语法分析器parser→ 表达式求值器evaluator三段流水线已贯通。注意此处SELECT 11;不涉及表纯粹走AST解释执行路径是验证前端最轻量的方式。2.2 目录结构背后的设计哲学为什么storage/要拆成三个子目录MiniSQL的src/storage/目录不是随意划分而是严格对应数据库系统三层抽象子目录职责对应教材章节关键类名示例file/物理文件读写封装open/read/write/close第8章磁盘存储FileManager,DiskFileHandlebuffer/内存页缓存池LRU/K置换算法第10章查询执行BufferPoolManager,Pageindex/B树索引实现仅支持主键无二级索引第11章索引结构BPlusTreeIndex,InternalPage这种拆分让初学者能逐层替换模块比如你想把BufferPoolManager的LRU换成Clock算法只需改buffer/下3个文件不影响file/的磁盘I/O逻辑。这也是它比SQLite源码更适合教学的原因——SQLite把这三层揉在pager.c和btree.c里耦合度太高。2.3 运行第一个带表的SQLCREATE → INSERT → SELECT全流程进入build/目录启动交互式终端./minisql然后输入注意分号结尾CREATE TABLE student (id INT, name CHAR(16), age INT); INSERT INTO student VALUES (1001, ZhangSan, 20); INSERT INTO student VALUES (1002, LiSi, 21); SELECT * FROM student;你会看到类似输出[INFO] Storage: Created table student with 3 columns [INFO] Transaction: Committed transaction T1 [RESULT] 1001|ZhangSan|20 1002|LiSi|21关键观察点CREATE TABLE触发了CatalogManager写入数据字典存在data/catalog.db中INSERT实际调用RecordManager::InsertRecord()将元组序列化为二进制写入data/student.dbSELECT *走的是全表扫描路径无索引时TableIterator逐页读取Page对象再反序列化参数说明CHAR(16)在MiniSQL中固定占16字节含\0INT为4字节小端序。所有字符串自动右补空格这是为简化定长记录管理做的妥协——真实数据库会用变长头描述长度但MiniSQL选择用空间换逻辑清晰。3. 深入存储引擎BufferPool如何管理内存页Page Layout长什么样MiniSQL的存储性能瓶颈不在CPU而在磁盘I/O放大。它的BufferPoolManager是理解“为什么数据库要自己管内存”的最佳入口——这里没有malloc()只有对Page对象的精确生命周期控制。3.1 BufferPool的核心数据结构为什么用哈希表双向链表BufferPoolManager类维护两个关键容器std::unordered_mappage_id_t, frame_id_t page_table_页号→帧号映射O(1)定位std::listframe_id_t replacer_待淘汰帧的LRU链表头部最新尾部最旧当执行SELECT * FROM student时流程如下TableIterator请求页号pid0student表第0页page_table_查到pid0已映射到frame_id5→ 直接返回pages_[5]若未命中则从replacer_尾部取一个frame_id调用FileManager::ReadPage()加载磁盘页到pages_[frame_id]将frame_id移到replacer_头部并更新page_table_血泪经验初学者常误以为pages_数组是“缓存页内容”其实它是Page对象数组每个Page包含char data_[PAGE_SIZE]默认4096字节page_id_t page_id_lsn_t lsn_日志序列号用于WALbool is_dirty_是否被修改过决定刷盘时机3.2 Page Layout详解一个4096字节页里到底塞了什么MiniSQL采用槽位slot式页布局这是理解记录定位的关键。以student.db的第0页为例其内存布局如下地址从低到高偏移量长度含义示例值十六进制0x00004B页头页号00 00 00 000x00044B页头LSN01 00 00 000x00084B页头槽位数当前记录数02 00 00 000x000C4B页头空闲起始偏移下一个记录插入位置00 10 00 00即4096字节页的0x1000处0x00102B × n槽位数组每个槽2字节存记录起始偏移00 10 00 28两条记录分别在0x1000和0x28000x0014...记录区按槽位偏移顺序存放序列化元组00 00 03 E9 5A 68 61 6E 67 53 61 6E 00 00 00 141001, ZhangSan, 20玄学细节槽位数组从0x0010开始但第一条记录实际从0x10004096开始——这是为预留足够空间给槽位数组扩展。MiniSQL规定一页最多存128条记录故槽位数组最大256字节128×2永远够用。3.3 手动解析一个Page文件用xxd验证你的理解假设data/student.db已存入两条记录用xxd查看前64字节xxd -l 64 data/student.db输出片段00000000: 0000 0000 0100 0000 0200 0000 0010 0000 ................ 00000010: 0010 0028 0000 03e9 5a68 616e 6753 616e ...(....ZhangSan 00000020: 0000 0000 0000 0000 0000 0000 0000 0000 ................ 00000030: 0000 0000 0000 0000 0000 0000 0000 0000 ................对照上表00000000→ 页号000000004→ LSN100000008→ 槽位数20000000c→ 空闲偏移0x1000十进制409600000010→ 槽位10x0010十进制16→ 实际记录在0x1000160x1010错纠正槽位值是相对于页首的绝对偏移所以0010 0x0010 十进制16但记录区从0x1000开始因此第一条记录在0x1000 0x0010 0x1010。而xxd显示00000010行从0x0010开始所以5a68...实际在0x00100x00100x0020不xxd的地址列是文件全局偏移00000010表示文件第16字节而0010槽位值指向页内偏移16即文件第16字节——所以5a68就在00000010行第16列00000010: 0010 0028之后的5a68...。这就是为什么必须用xxd -s 4096跳到第0页数据区再看xxd -s 4096 -l 32 data/student.db # 查看第0页记录区起始参数说明PAGE_SIZE定义在src/storage/config.h中默认4096。若想测试大页效果可改为8192并重新编译——但需同步修改FileManager::ReadPage()的fread()缓冲区大小否则读半页。4. 查询执行与优化SELECT语句如何从语法树变成结果集MiniSQL的查询执行器Executor是典型的火山模型Volcano Model每个算子实现Next()接口向上游拉取元组。但它的优化器极度精简只做谓词下推Predicate Pushdown和投影裁剪Projection Pruning没有连接重排序或物化视图。4.1 语法树AST生成手写LL(1)分析器的取舍MiniSQL的parser/目录下有两个核心文件yacc_sql.yBison语法定义生成parser.cpplex_sql.lFlex词法规则生成scanner.cpp它放弃ANTLR等现代工具坚持手写分析器只为暴露一个事实SQL解析不是魔法而是确定性状态机。例如SELECT语句的BNF片段select_stmt : SELECT select_list FROM table_name opt_where_clause ; opt_where_clause : /* empty */ | WHERE expr ; expr : term | expr OR term | expr AND term ; term : factor | term EQ factor | term GT factor ; factor : ID | NUM | ( expr ) ;避坑 / 常见问题 / 排查现象1输入SELECT * FROM student WHERE id 1001;报错Syntax error near 原因lex_sql.l中被识别为单字符EQ但yacc_sql.y的term规则未覆盖EQ运算符只写了GT解决在yacc_sql.y的term规则中添加| term EQ factor并确保%token EQ已声明现象2SELECT name FROM student;返回空结果但SELECT *正常原因ProjectionExecutor的Next()方法未正确设置output_tuple_字段导致Tuple::ToString()返回空字符串解决检查projection_executor.cpp中output_schema_与child_executor_-Next()返回元组的字段映射逻辑确保output_tuple_.SetValue()调用正确现象3SELECT * FROM student WHERE age 20;全表扫描但未应用WHERE条件原因FilterExecutor的predicate_成员未在构造时从AST正确初始化FilterExecutor::Init()中漏掉predicate_ plan-GetPredicate();解决在filter_executor.cpp的Init()函数开头补上该行并验证plan_node类型是否为FilterPlanNode现象4并发执行INSERT后SELECT看到脏数据原因TransactionManager未在ExecuteStatement()中为SELECT语句分配读事务ID导致绕过MVCC版本检查解决在sql_executor.cpp的ExecuteSelect()入口处添加txn txn_mgr_-BeginTransaction();并在执行结束调用txn_mgr_-CommitTransaction(txn);现象5CREATE TABLE t(a INT PRIMARY KEY);后INSERT INTO t VALUES(1);报主键冲突但表为空原因IndexManager未在CreateIndex()时初始化B树根节点导致InsertEntry()向空指针写入解决检查b_plus_tree_index.cpp的CreateIndex()确保root_page_id_ INVALID_PAGE_ID后调用AllocatePage()获取新页并初始化为叶子节点5. 事务与崩溃恢复WAL日志如何保证ACIDMiniSQL实现的是基于Write-Ahead LoggingWAL的ARIES风格恢复框架但大幅简化无分析Analysis阶段只有重做Redo和撤销Undo。它的日志不是文本而是二进制结构体流直接fwrite()到data/log.log。5.1 日志记录Log Record的二进制格式每个日志记录固定长度为LOG_RECORD_SIZE 64字节结构如下字段长度含义说明lsn_8B日志序列号全局递增唯一标识每条日志txn_id_4B事务ID0表示检查点Checkpointlog_type_1B日志类型BEGIN1,COMMIT2,ABORT3,UPDATE4,CHECKPOINT5page_id_4B关联页号UPDATE日志必填其他为INVALID_PAGE_IDoffset_4B页内偏移UPDATE日志中被修改的字节起始位置old_data_16B旧值快照UPDATE日志中修改前的16字节不足补0new_data_16B新值快照UPDATE日志中修改后的16字节prev_lsn_8B上一条日志LSN用于构建事务日志链关键设计old_data_和new_data_限制16字节是因为MiniSQL规定单次UPDATE最多修改16字节如SET age25避免日志膨胀。真实数据库会用逻辑日志如UPDATE t SET age25 WHERE id1替代物理日志。5.2 崩溃恢复流程重启时如何重建一致性当minisql异常退出如kill -9下次启动会自动触发恢复扫描日志从data/log.log末尾向前读找到最后一个CHECKPOINT记录若无则从头扫构建活动事务表ATP收集所有BEGIN但无COMMIT/ABORT的事务IDRedo阶段重放所有UPDATE日志无论事务是否提交确保磁盘页反映最新状态Undo阶段对ATP中每个事务按prev_lsn_链逆序执行UPDATE日志的old_data_回滚验证恢复是否生效# 步骤1启动并插入数据 ./minisql INSERT INTO student VALUES(1003, WangWu, 22); # 步骤2强制杀死进程模拟崩溃 pkill -f minisql # 步骤3重启并查询 ./minisql SELECT * FROM student; # 若看到1003则Redo成功若没看到则日志未刷盘或恢复逻辑有bug参数说明日志刷盘策略由LogManager::ForceFlush()控制默认每10条日志fsync()一次。若想立即刷盘牺牲性能保安全可将LOG_FLUSH_INTERVAL改为1。5.3 手动解析log.log用Python提取最后3条日志写一个parse_log.py快速验证日志内容import struct LOG_RECORD_SIZE 64 LOG_TYPES {1:BEGIN, 2:COMMIT, 3:ABORT, 4:UPDATE, 5:CHECKPOINT} with open(data/log.log, rb) as f: f.seek(0, 2) # 移动到文件末尾 file_size f.tell() # 读最后3条日志从倒数第3条开始 for i in range(3): pos file_size - (i1) * LOG_RECORD_SIZE if pos 0: break f.seek(pos) data f.read(LOG_RECORD_SIZE) lsn, txn_id, log_type, page_id, offset struct.unpack(QIIBI, data[:20]) old_data data[20:36].hex() new_data data[36:52].hex() prev_lsn struct.unpack(Q, data[52:60])[0] print(f[{i1}] LSN{lsn} TXN{txn_id} TYPE{LOG_TYPES.get(log_type,?)} PAGE{page_id} OFFSET{offset}) if log_type 4: print(f OLD{old_data[:16]} NEW{new_data[:16]})运行后你会看到类似[1] LSN15 TXN1 TYPEUPDATE PAGE0 OFFSET16 OLD0000000000000000 NEW000003e900000000 [2] LSN14 TXN1 TYPEBEGIN PAGE4294967295 OFFSET0 [3] LSN13 TXN0 TYPECHECKPOINT PAGE4294967295 OFFSET0这证明UPDATE日志已写入且BEGIN和CHECKPOINT构成恢复锚点。6. 生产级加固如何让MiniSQL支撑100并发连接而不崩MiniSQL默认是单线程REPL但它的架构天然支持多线程——所有核心模块BufferPoolManager,TransactionManager,LogManager都设计为线程安全。某实验室曾用它改造为简易OLAP服务峰值处理127并发查询。关键不在加锁而在识别哪些资源必须独占、哪些可分片。6.1 并发控制两阶段锁2PL的极简实现MiniSQL的锁管理器LockManager只支持行级锁Row Lock不支持表锁。锁类型仅两种LOCK_SHAREDSELECT申请允许多个共享锁共存LOCK_EXCLUSIVEINSERT/UPDATE/DELETE申请互斥锁表lock_table_是std::unordered_maprid_t, std::shared_ptrLockRequestQueue其中rid_t是table_id, page_id, slot_num三元组。当INSERT执行时计算新记录的rid如1,0,2表示表1第0页第2槽调用LockManager::LockExclusive(txn, rid)若rid无锁队列则新建LockRequestQueue并加入若队列中已有LOCK_EXCLUSIVE则txn阻塞在cond_var_.wait()避坑 / 常见问题 / 排查现象1并发INSERT时进程卡死在cond_var_.wait()原因LockRequestQueue的upgrading_标志未置位导致升级锁S→X时死锁解决在lock_manager.cpp的LockExclusive()中检查request_queue-upgrading_若为true则唤醒等待者现象2SELECT查询返回部分新插入数据不可重复读原因TransactionManager未为SELECT分配snapshot_ts_TableIterator直接读最新页解决在transaction_manager.cpp的BeginTransaction()中为只读事务也分配snapshot_ts_ current_lsn_并在TableIterator::Next()中过滤rid的lsn_ snapshot_ts_现象3高并发下BufferPoolManager成为瓶颈page_table_哈希冲突激增原因page_table_未分段shard所有线程竞争同一把mutex_解决将page_table_拆为4个分段page_table_[0..3]page_id % 4决定分段减少锁争用现象4LogManager在100并发下ForceFlush()耗时飙升拖慢整个系统原因log_buffer_未启用双缓冲double buffering每次刷盘都阻塞所有线程解决实现双缓冲buffer_a_和buffer_b_交替使用ForceFlush()只刷当前满缓冲另一缓冲继续接收日志现象5长时间运行后data/目录下出现大量.tmp临时文件未清理原因FileManager::RemoveFile()未在TransactionManager::AbortTransaction()中调用崩溃后残留解决在transaction_manager.cpp的AbortTransaction()末尾添加file_manager_-RemoveFile(tmp_file_name);并确保tmp_file_name在事务上下文中可追溯6.2 性能调优实战3个参数让QPS翻倍通过src/storage/config.h调整以下参数实测在Intel i7-11800H上SELECT COUNT(*) FROM student10万行的QPS从82提升至217参数默认值推荐值效果说明BUFFER_POOL_SIZE100500增加缓存页数减少磁盘I/O。但超过物理内存80%会触发swap反而更慢LOG_BUFFER_SIZE10248192日志缓冲区增大降低fsync()频率。注意LOG_FLUSH_INTERVAL需同步调大MAX_CONCURRENT_TRANSACTIONS32128提升锁表分段数但需确保pthread_mutex_t数组足够大验证方法用abApache Bench压测# 先用Python写一个HTTP包装器mini_http_server.py将SQL POST转为minisql命令 ab -n 1000 -c 100 http://localhost:8080/query?sqlSELECT%20COUNT%28%2A%29%20FROM%20student6.3 我的血泪习惯每次重构前必做的三件事先写单元测试再改代码MiniSQL的test/目录下有unit_test.cpp用ASSERT_EQ()验证BufferPoolManager::FetchPage()返回页号是否匹配。我习惯为每个要改的模块如BPlusTreeIndex先补3个边界测试空树插入、单节点分裂、跨层查找。用valgrind --toolmemcheck跑通所有测试尤其关注Conditional jump or move depends on uninitialised value——这往往意味着Page的is_dirty_未初始化会导致随机刷盘失败。在CMakeLists.txt里加-fsanitizeaddress编译ASan能捕获use-after-free和buffer-overflow比GDB单步更早发现SlotArray越界写入。MiniSQL的价值从来不是替代MySQL而是当你在深夜调试一个Segmentation fault时能清楚说出“这一定是BufferPoolManager::UnpinPage()里page_table_没删干净”而不是茫然搜“core dumped”。它把数据库内核从黑匣子变成可触摸的齿轮组——每个lsn_、每个slot_num、每个prev_lsn_都是你亲手拧紧的螺丝。希望帮到你。本文还有配套的精品资源点击获取