手写C++迷你数据库引擎:在Visual Studio中实现可交互的SQL解析与执行
发布时间:2026/10/14 15:34:17 锦皓数字建站

先讲个我自己的真实感受。有段时间我需要在一个C写的桌面工具里持续保存一批配置数据和运行日志第一反应就是引入SQLite把源码拖进工程就能用。但用着用着总觉得有一块黑盒一条INSERT INTO下去数据到底进了什么地方一条WHERE条件又是怎么把行找出来的这个好奇心最后驱动我直接在Visual Studio里手写了一个用C实现的内存交互数据库Demo我管它叫MiniDB。编译完在控制台里敲CREATE TABLE、INSERT INTO、SELECT回车后能看到格式化好的结果表那一刻确实有点“SQL是我写出来的”的感觉。这篇文章就是MiniDB从零到跑通的完整记录适合有C基础、想弄懂数据库原理或者想做个小而美的项目练手的朋友。1. 为什么我要在C工程里亲手写一个能跑SQL的引擎1.1 标题背后真正的需求不是再造轮子是理解轮子先说清楚“C交互数据库用VS也能写SQL”这句话到底在说什么。这里不是指在Visual Studio里装一个数据库插件也不是调MySQL API而是指你自己用C写一个带SQL解析能力的小型数据库引擎编译成控制台程序后用户可以直接输入SQL语句并实时看到执行结果。这种“敲一句、执行一句、看结果”的交互模式在数据库领域有专门叫法REPLRead-Eval-Print Loop读入-求值-输出循环。SQLite的命令行工具sqlite3、MySQL的mysql客户端、PostgreSQL的psql本质都是REPL壳子包着一个SQL引擎。对大多数C开发者来说日常工作中直接拼装SQL字符串交给数据库去跑是常态真正自己实现SQL解析的少之又少。但恰恰是这项“少有人走的路”能带来极高的收益理解了词法分析、语法分析、数据组织、条件匹配之后你才算真正看懂索引为什么要用B树、为什么SELECT *在宽表上是反面教材、以及数据库连接池到底在池化什么东西。而且从工程角度说一个内嵌在C程序里的迷你数据库并非没有实用价值。程序需要本地缓存一批结构化数据、需要按条件过滤、又不想引入重量级数据库时一个内存表引擎就是很顺手的方案。MiniDB虽然不追求支撑千万级数据量但足以作为解读数据库底层逻辑的活教材。1.2 常规选型对比SQLite、外部进程、自研引擎在做这个项目之前我先把市面上常见的三种路线放在一起做了对比这样才能想明白自研到底值得不值得。方案优点缺点适合场景引入SQLite嵌入式库功能完整、稳定、有事务和索引源码数万行黑盒感强生产级别的局部持久化启动外部数据库进程功能最强大生态成熟部署复杂C程序需要网络通信独立服务端/多端协同自研内存交互引擎零依赖、源码可控、学习收益高功能有限、无持久化、需自己踩坑教学、轻量Demo、原理验证我选择第三个方案的原因很清楚MiniDB的目标不是“替代SQLite”而是“用最少的代码让人看懂SQL执行的全过程”。既然是学习用途特性清单就可以做极端裁剪只留四条最核心的SQL命令CREATE TABLE、INSERT INTO、SELECT、DELETE。别小看这四条它们恰好覆盖了一个数据库引擎最关键的三个模块表结构管理、数据写入、条件查询。WHERE子句一旦跑通你就已经掌握了执行器设计的一半精髓。这里也想给后来者一句劝做类似项目千万不要一上来就想着支持JOIN、子查询、事务、B树索引那会把项目拖入无底洞。先做一个单表、无索引的极简版本再逐步加特性才是可持续的节奏。MiniDB第一版两天就能跑通这个范围控制很关键。2. 先拆架构一条SQL语句拿到手后要经过哪些关卡2.1 整体管线从终端文本到结果集在写第一行代码前我先在纸上画了一条流水线MiniDB的架构其实就是这五道关卡的有序组合读入SQL文本处理末尾分号。词法分析把字符串拆成一串Token比如关键字SELECT、标识符users、符号*、数字18。语法分发看一眼第一个Token是什么决定调用创建表、插入、查询还是删除处理器。执行计算操作内存中的表对象完成建表、插行、匹配条件等具体工作。格式化输出把查询结果以对齐表格的形式打印到控制台。这一步很像餐厅点餐客人喊出一串话当作“点餐文本”服务员先要能拆分出“菜品名”“数量”“口味”判断这是一道加菜还是退菜请求再让后厨动手做最后把菜端上桌。SQL引擎里的“服务员”就是解析器“后厨”就是执行器。这种分层最大的好处是每一层只做一件事排查问题的时候也只需要盯住对应的代码段不用满文件翻。2.2 第一关词法分析把字符串拆成Token词法分析是SQL引擎的第一个大门。它的任务看似简单——把字符串切成词组但实际设计时要想清楚Token分类。MiniDB只用了五类Token标识符IDENT表名、列名比如users、age。字符串常量STRING单引号包裹的内容比如Tom。数字常量NUMBER整数或小数比如25。符号SYMBOL*、、(、)、,、;这些结构化字符。关键字KEYWORDCREATE、TABLE、INSERT、INTO、VALUES、SELECT、FROM、WHERE、DELETE、AND、OR等。有个设计细节值得注意关键字和标识符在词法层面长相完全一样都是连续的字母下划线组合。区别方式很简单——先拆出来再把单词拿去和关键字集合比对命中就是KEYWORD没命中就是IDENT。这也是绝大多数编程语言处理保留字的通用做法。MiniDB选择把关键字统一转成大写后再比对这样用户在输入select、SELECT、Select时都能正常解析。词法分析按字符挨个扫描碰到字母就继续收集连续字母直到遇到分隔符形成标识符或关键字碰到数字就收集连续数字碰到单引号就一直读到下一个单引号为止。扫描器本身不判断“这句话语义对不对”它只负责忠实切词。语义是否合法是后续语法分发和执行器要管的。2.3 第二关语法分发识别命令类型很多初学者以为“解析SQL”是特别高深的事实际上MiniDB的语法层可以做得极其简单把Token流前几个Token拿来做个模式匹配就行。比如CREATE TABLE users (id INT, name TEXT);这句Token序列开头的模式是CREATE TABLE IDENT ( ...看到CREATE后跟一个TABLE就可以直接认定这是建表命令。INSERT INTO users VALUES (1, Tom);的模式是INSERT INTO IDENT VALUES ...同理SELECT ... FROM ...和DELETE FROM ... WHERE ...也都是固定开头。这种模式匹配式解析能覆盖全部计划内的语法写起来就是几个if分支的事。真正复杂的SQL解析器还需要考虑优先级、复合表达式、子句顺序那是在这个基础版本之上慢慢叠起来的。但作为理解原理的第一版模式匹配完全够用而且好处是代码读起来一目了然每一行都在干明确的事。我需要强调的是语法分发这里必须做好一件事参数个数校验。比如用户写了INSERT INTO users VALUES (1)但表有3列这个错误一定要在执行前拦截否则会出现内部数据结构和SQL意图对不上的问题。MiniDB在执行插入前会比较值列表长度和列定义数量不一致直接打印错误信息并中止当前命令不会破坏已有数据。2.4 第三关存储和执行表结构、行数据、条件过滤执行器是整个引擎的心脏。MiniDB的存储层一开始就确定了两条原则全部数据保存在内存中程序退出即清空不做磁盘持久化。表的结构元数据列名、列类型和行数据分开存储列定义放数组行数据放二维数组。这样的二维行存储模型是所有关系数据库的通用基础。真实数据库可能在页面、段、索引结构上花大量心思但最终落到用户层的依然是一张逻辑上的二维表第一行是列名每一行是一条记录每一列是记录的某个属性。条件过滤是WHERE子句要做的事MiniDB支持最简单的等值比较WHERE 列名 值。执行SELECT时遍历表的每一行取出指定列做字符串比较匹配就加入结果集。因为没有索引这是标准的全表扫描复杂度O(n)。虽然慢但它把执行器最核心的“扫描-过滤-投影”三个动作完整呈现出来了。理解了这一点以后再看索引优化就会格外亲切——索引本质上就是让“找到匹配行”这一步不用遍历每一行MiniDB这一步的朴素实现反而是最好的对照组。2.5 为什么交互式要做成REPL最后说下REPL。MiniDB主程序结构是一个无限循环打印提示符、读取一行输入、累积到分号结束、交给解析器执行、输出结果、回到提示符等待下一条。这种交互模式和C标准输入循环很像但它有明确的形态——用户是在“对话”中完成数据库操作的每敲一条SQL都能立刻看到反馈。REPL的意义不只是“方便”。它把引擎的输入输出边界定义得非常清晰前端是一个人机交互壳后端是纯逻辑的SQL引擎。这种分离让引擎部分可以独立复用比如将来你完全可以把解析执行部分包成一个类在另一个窗口程序里调用这正好对应标题里说的“在VS工程里写SQL”——把SQL能力嵌进自己的C程序而非在某个数据库客户端里敲命令。3. 核心代码实现分词、解析、执行一锅端3.1 数据结构先行Token、列定义、表、结果集代码实现之前四个核心数据结构先摆出来它们是整个MiniDB的地基。#include iostream #include string #include vector #include sstream #include cctype #include unordered_set #include iomanip enum class TokenType { IDENT, STRING, NUMBER, SYMBOL, KEYWORD, END }; struct Token { TokenType type; std::string text; double number 0; bool isSymbol(const std::string s) const { return type TokenType::SYMBOL text s; } }; struct ColumnDef { std::string name; std::string type; // INT 或 TEXT }; using Row std::vectorstd::string; struct Table { std::string name; std::vectorColumnDef columns; std::vectorRow rows; };Token里比较特殊的是number字段它是给数字常量用的。这里有个C细节std::string转数字如果失败会怎样我们规定词法分析阶段只要按数字规则扫出来的Token就一定转成功如果转不出来就说明是输入格式问题后续逻辑里要做保护。Row直接定义成std::vectorstd::string列值统一按字符串保存这里我踩过一个小坑后面专门提。3.2 词法分析器的实现词法分析器是MiniDB第一个有含金量的模块。它的核心逻辑是循环扫描字符按字符类别触发不同的“吞字”逻辑。class Tokenizer { public: explicit Tokenizer(const std::string sql) : sql(sql), pos(0) {} std::vectorToken tokenize() { std::vectorToken tokens; while (pos sql.size()) { char c sql[pos]; if (std::isspace(static_castunsigned char(c))) { pos; } else if (std::isalpha(static_castunsigned char(c)) || c _) { tokens.push_back(readIdentOrKeyword()); } else if (std::isdigit(static_castunsigned char(c))) { tokens.push_back(readNumber()); } else if (c \) { tokens.push_back(readString()); } else { tokens.push_back(readSymbol()); } } tokens.push_back({TokenType::END, , 0}); return tokens; } private: const std::string sql; size_t pos; Token readIdentOrKeyword() { std::string word; while (pos sql.size()) { char c sql[pos]; if (std::isalnum(static_castunsigned char(c)) || c _) { word c; pos; } else { break; } } std::string upper; for (char ch : word) { upper static_castchar(std::toupper(static_castunsigned char(ch))); } static const std::unordered_setstd::string keywords { CREATE, TABLE, INSERT, INTO, VALUES, SELECT, FROM, WHERE, DELETE, AND, OR }; if (keywords.count(upper)) { return {TokenType::KEYWORD, upper, 0}; } return {TokenType::IDENT, word, 0}; } Token readNumber() { std::string num; while (pos sql.size() (std::isdigit(static_castunsigned char(sql[pos])) || sql[pos] .)) { num sql[pos]; pos; } return {TokenType::NUMBER, num, std::stod(num)}; } Token readString() { pos; // 跳过开头的单引号 std::string result; while (pos sql.size() sql[pos] ! \) { result sql[pos]; pos; } if (pos sql.size()) pos; // 跳过闭口的单引号 return {TokenType::STRING, result, 0}; } Token readSymbol() { char c sql[pos]; return {TokenType::SYMBOL, std::string(1, c), 0}; } };这段词法器有几个我实际用下来觉得特别顺手的设计。第一个是把关键字集合做成静态局部变量每次调用时不会反复构造集合。第二个是把std::toupper显式转换成unsigned char再调用避免中文字符或扩展ASCII字符在部分编译器下触发断言。第三个是读字符串时不做转义处理直接把两个单引号之间的内容原样带走——对Demo来说够用但如果要支持Its这种SQL标准转义写法后续需要额外处理。3.3 命令分发器和执行器雏形有了Token列表下一步就是看第一个Token是不是关键字然后分派给对应的处理函数。执行器的骨架如下class MiniDB { public: void execute(const std::string sql) { Tokenizer tokenizer(sql); std::vectorToken tokens tokenizer.tokenize(); if (tokens.empty()) return; Token first tokens[0]; if (first.type ! TokenType::KEYWORD) { std::cout Error: SQL must start with a keyword.\n; return; } if (first.text CREATE) { executeCreate(tokens); } else if (first.text INSERT) { executeInsert(tokens); } else if (first.text SELECT) { executeSelect(tokens); } else if (first.text DELETE) { executeDelete(tokens); } else { std::cout Error: unsupported statement.\n; } } private: std::vectorTable tables; // 后面依次实现四个执行函数 };executeCreate处理建表逻辑是解析括号内的列定义并创建空表。它的实现需要仔细处理符号Token因为列定义片段由IDENT IDENT , IDENT IDENT , ...组成必须按逗号切分。void executeCreate(const std::vectorToken tokens) { // 期望序列: CREATE TABLE IDENT ( IDENT TYPE , IDENT TYPE ... ) if (tokens.size() 7 || !tokens[1].isKeyword(TABLE)) if (tokens[2].type ! TokenType::IDENT) { std::cout Error: missing table name.\n; return; } std::string tableName tokens[2].text; if (!tokens[3].isSymbol(()) { std::cout Error: expected (.\n; return; } std::vectorColumnDef cols; size_t i 4; while (i tokens.size() !tokens[i].isSymbol())) { if (tokens[i].type ! TokenType::IDENT || tokens[i 1].type ! TokenType::IDENT || tokens.size() i 2) { std::cout Error: invalid column definition.\n; return; } std::string colName tokens[i].text; std::string colType tokens[i 1].text; if (colType ! INT colType ! TEXT) { std::cout Error: unsupported type colType .\n; return; } cols.push_back({colName, colType}); i 2; if (i tokens.size() tokens[i].isSymbol(,)) { i; } else if (i tokens.size() !tokens[i].isSymbol())) { std::cout Error: expected , or ).\n; return; } } if (cols.empty()) { std::cout Error: no columns defined.\n; return; } tables.push_back({tableName, cols, {}}); std::cout Table created.\n; }这里有个分割逻辑上的常见陷阱列定义之间需要逗号但最后一个列定义后面什么都不能有直接是右括号。我一开始偷懒统一“读两列然后期望逗号”结果最后一个列定义后面必然报错。所以上面的代码特意在跳过逗号之后判断当前符号是不是)把这两种边界分开处理。executeInsert相对简单它负责解析出表名、值列表的长度和每个值的字符串形式然后追加到对应的表里。void executeInsert(const std::vectorToken tokens) { // 期望序列: INSERT INTO IDENT VALUES ( value , value ... ) if (tokens.size() 6) { std::cout Error: malformed INSERT.\n; return; } std::string tableName tokens[2].text; Table* table findTable(tableName); if (!table) { std::cout Error: table not found.\n; return; } size_t i 5; // 跳过 VALUES if (!tokens[i].isSymbol(()) { std::cout Error: expected ( after VALUES.\n; return; } i; Row newRow; while (i tokens.size() !tokens[i].isSymbol())) { if (tokens[i].type TokenType::STRING || tokens[i].type TokenType::NUMBER) { newRow.push_back(tokens[i].text); } else { std::cout Error: invalid value.\n; return; } i; if (i tokens.size() tokens[i].isSymbol(,)) { i; } } if (newRow.size() ! table-columns.size()) { std::cout Error: column count mismatch.\n; return; } table-rows.push_back(newRow); std::cout 1 row inserted.\n; }查询和删除的执行逻辑很像它们都要做表查找、条件定位、逐行匹配核心差异只在匹配之后是“收集进结果”还是“从表里移除”。void executeSelect(const std::vectorToken tokens) { // 最小支持: SELECT * FROM IDENT WHERE IDENT value if (tokens.size() 4) { std::cout Error: malformed SELECT.\n; return; } std::string tableName tokens[3].text; Table* table findTable(tableName); if (!table) { std::cout Error: table not found.\n; return; } std::vectorRow result; if (tokens.size() 4 tokens[4].isKeyword(WHERE)) { if (tokens.size() 8) { std::cout Error: incomplete WHERE clause.\n; return; } std::string colName tokens[5].text; Token right tokens[7]; if (right.type ! TokenType::STRING right.type ! TokenType::NUMBER) { std::cout Error: WHERE comparison value must be a literal.\n; return; } int colIndex findColumn(table, colName); if (colIndex 0) { std::cout Error: unknown column colName .\n; return; } for (const auto row : table-rows) { if (row[colIndex] right.text) { result.push_back(row); } } } else { result table-rows; } printResult(*table, result); } void executeDelete(const std::vectorToken tokens) { // 最小支持: DELETE FROM IDENT WHERE IDENT value if (tokens.size() 8) { std::cout Error: malformed DELETE.\n; return; } std::string tableName tokens[2].text; Table* table findTable(tableName); if (!table) { std::cout Error: table not found.\n; return; } std::string colName tokens[5].text; Token right tokens[7]; int colIndex findColumn(table, colName); if (colIndex 0) { std::cout Error: unknown column.\n; return; } auto rows table-rows; rows.erase( std::remove_if(rows.begin(), rows.end(), [](const Row row) { return row[colIndex] right.text; }), rows.end() ); std::cout Delete complete.\n; }3.4 输出格式化让结果表像SQLite那样打印结果集会打印成对齐的文本表格包含表头分隔线和行数据这一步看似无关紧要但它在REPL体验里的重要性超过想象。没有格式化输出的时候控制台里挤成一团的值根本无法快速核对。void printResult(const Table table, const std::vectorRow rows) { if (rows.empty()) { std::cout (empty set)\n; return; } std::vectorsize_t widths; for (const auto col : table.columns) { widths.push_back(col.name.size()); } for (const auto row : rows) { for (size_t i 0; i row.size(); i) { widths[i] std::max(widths[i], row[i].size()); } } auto printSep []() { for (size_t w : widths) { std::cout std::string(w 2, -); } std::cout \n; }; printSep(); for (size_t i 0; i table.columns.size(); i) { std::cout | std::left std::setw(static_castint(widths[i])) table.columns[i].name ; } std::cout |\n; printSep(); for (const auto row : rows) { for (size_t i 0; i row.size(); i) { std::cout | std::left std::setw(static_castint(widths[i])) row[i] ; } std::cout |\n; } printSep(); std::cout rows.size() row(s) returned.\n; }4. 在Visual Studio里跑起来从新建工程到跑通第一次SQL4.1 工程配置零依赖的心安之处MiniDB最大的好处是零外部依赖全程只用C标准库所以在Visual Studio里新建工程只需要三个步骤新建一个“控制台应用”空项目语言选C。把上述代码按Tokenizer、MiniDB、main三个文件组织添加进工程。在项目属性的C/C项中把C语言标准设为C17或更高。这里特别提醒一句Visual Studio默认的“控制台应用”模板可能会生成预编译头pch.h如果你不习惯可以直接建“空项目”再从源文件起步。那些头文件依赖关系并不复杂一个文件也能堆完但我建议至少拆成三个文件后面扩展索引或者JOIN的时候能省很多事。main函数是REPL的核心骨架它从标准输入读行遇到分号就执行int main() { MiniDB db; std::string line; std::string accumulated; std::cout MiniDB v0.1 — type SQL and press Enter.\n; while (true) { if (accumulated.empty()) { std::cout sql ; } else { std::cout - ; } if (!std::getline(std::cin, line)) { break; } accumulated line; if (accumulated.find(;) std::string::npos) { continue; } db.execute(accumulated); accumulated.clear(); } return 0; }这段REPL代码有个重要细节支持一条SQL跨多行输入。如果用户输入SELECT *后忘了写FROM他会看到第二行提示符-而不是立即报错。只有遇到分号才把整段累积文本一次性交给执行器。这个交互策略和真实数据库客户端是一致的能极大提升输入体验。4.2 控制台代码页与中文显示实际跑起来以后我遇到的第一件怪事是中文列名或值在控制台里显示成乱码。这不是MiniDB的问题而是Visual Studio控制台默认使用GBK代码页而C源文件里的中文字符串在保存为UTF-8后会在运行时被打印成错误编码。解决办法有两种任选其一在main函数开头调用SetConsoleOutputCP(CP_UTF8)让控制台输出切到UTF-8。或者直接把源文件编码保存为GBK/ANSI让字符串按本地代码页解释。我建议用第一种因为它能保证编写的源文件统一UTF-8后续如果要把控制台程序改成GUI字符串编码不会埋雷。另外如果控制台里打印中文仍然有异常可以再顺手执行chcp 65001切换当前会话的代码页Windows Terminal里一般没有这个问题老式控制台窗口才需要。4.3 完整会话演示下面是一段MiniDB实际运行时的会话记录读者可以对照着验证自己的实现MiniDB v0.1 — type SQL and press Enter. sql CREATE TABLE users (id INT, name TEXT); Table created. sql INSERT INTO users VALUES (1, Tom); 1 row inserted. sql INSERT INTO users VALUES (2, Jerry); 1 row inserted. sql INSERT INTO users VALUES (3, Alice); 1 row inserted. sql SELECT * FROM users; ----------- | id | name | ----------- | 1 | Tom | | 2 | Jerry | | 3 | Alice | ----------- 3 row(s) returned. sql SELECT * FROM users WHERE id 2; ----------- | id | name | ----------- | 2 | Jerry | ----------- 1 row(s) returned. sql DELETE FROM users WHERE name Alice; Delete complete. sql SELECT * FROM users; ----------- | id | name | ----------- | 1 | Tom | | 2 | Jerry | ----------- 2 row(s) returned.第一次看到这个输出的时候我确实起了一身鸡皮疙瘩。不是因为它功能有多强而是因为从CREATE TABLE到最后的查询结果整条链路每一行代码都是我写的每一块逻辑的输入输出都在掌握之中。这种感觉和调第三方库完全不一样。4.4 从Demo到可复用引擎的小改造如果只是想玩玩到上面这一步项目就算完成了。但如果你想把这个引擎嵌到别的VS工程里我建议在收尾前做几处重构把MiniDB类的execute方法改成返回一个结构化的结果对象而不是直接向控制台cout。结果对象至少包含错误码、错误消息、列名列表、行数据列表四样东西。把输出格式化逻辑从MiniDB类里抽出来做成独立的ResultPrinter这样窗口程序可以用表格控件展示无需复用控制台打印代码。给execute加一个异常捕获的包裹层确保任何一条SQL出错都不会把整个宿主程序带崩。这三处改造大约多花一个小时但它让MiniDB从“一个控制台演示”变成了“一个可嵌入的SQL引擎组件”这正是标题里“用VS也能写SQL”的工程化含义——你的C程序可以把SQL当一种内置语言来用。5. 实测踩过的坑和下一步扩展方向5.1 字符串里的逗号、空格、单引号词法容易翻车我在写INSERT INTO users VALUES (1, Tom, Jr.)这种带逗号字符串的时候MiniDB直接解析失败。原因是在值列表解析的while循环里遇到逗号就当作字段分隔符可这个逗号明明在单引号内部应该是字符串的一部分。这个问题的根子在词法分析单引号字符串在tokenize阶段已经被合并成一个完整的STRING Token解析器看到的是一个整体不该再拆。所以如果你的解析器仍然从“原始字符串”而不是“Token列表”里找逗号那一定会踩坑。MiniDB最终能正确处理的秘诀就是所有字段分隔都基于Token列表不能回头碰原始SQL文本。后来又考虑到SQL标准里的单引号转义Its我在readString里补了一段逻辑读到连续两个单引号时把其中一个加入结果、跳过另一个这样转义后的内容就不会提前终止字符串。边界条件复杂但每一步都值得想清楚。5.2 多行SQL与分号拼接REPL那种“累积到分号才执行”的策略有一个小坑如果SQL文本内部出现了分号字符比如字符串a;b里包含分号find(;)就会提前触发执行把语句拦腰截断。正确做法是让REPL也走一遍词法分析只有当顶层出现SYMBOL类型的分号Token时才认为语句结束。MiniDB第一版用了简单的字符串查找后来看到字符串里的分号翻车后才改的。这里面其实有个更大的原则SQL语句的边界不应该用“字符长什么样”来判断而应该用“词法结构里它是什么角色”来判断。凡是要判断一个角色就得让分词器先跑一遍。这属于典型的经验之谈不实际踩一次不会长记性。5.3 关键字大小写与列名区分刚才提到过关键字统一转大写但这带来一个附带问题如果用户把列名或表名定义成小写users而查询时输入USERSMiniDB会认为它们是不同的标识符。真实SQL标准里未加引号的表名列名通常不区分大小写。MiniDB在findTable和findColumn里做了同样的处理查找时统一转大写后再比对。这样用户随便怎么输入都能命中。这个细节说明凡是需要“比较名字”的地方都要考虑大小写策略。实现时建议在表名和列名的存储环节就统一成大写而不是在查询时临时转换这样可以减少多处重复代码。5.4 内存与拷贝自己管理内存表之后我对std::vectorRow的拷贝开销变得特别敏感。在executeSelect里如果把所有匹配行都复制进一个新vector数据量小的时候无感数据量一大就明显拖慢速度。MiniDB第一版就是直接复制行后来改成只复制匹配行的指针或索引查询性能立刻提升了一截。这个优化在真实数据库里对应的是“物化结果集 vs 游标延迟加载”理解这个差异后你再看数据库客户端的网络传输开销会更有体感。另外还有个小细节Row的每一列统一用std::string存储这让数值比较变成了字符串比较WHERE id 10和WHERE id 10.0会被判定为不同值因为字符串10和10.0不相等。真要支持数值语义比较就需要根据列类型选择比较器这是扩展方向里值得排第一的功能点。5.5 下一步从单表玩具到最小可用数据库跑通第一版之后我列了一个扩展优先级清单分享出来供后来者参考优先级功能说明P0更多比较运算符、、!、、WHERE条件会立刻实用十倍P0类型感知比较INT列按数值比TEXT列按字符串比P1多列WHERE组合支持AND、OR连接多个条件P1排序输出ORDER BY列名加ASC/DESCP2文件持久化退出前把表数据序列化成文本文件启动时重新加载P2简单事务用命令日志模拟回滚P3B树索引给WHERE等值查询加速这是数据库进阶的真正大门P3JOIN至少支持两表等值连接理解嵌套循环连接我自己接下来最想做的是B树索引因为MiniDB的全表扫描已经让我切身体会到“没有索引时查询有多笨重”。将来给某个列建了索引再跑同样的SQL对比全表扫描和索引查找的时间差那种体会比看十篇博客都深刻。把MiniDB收尾的那一刻我坐在电脑前发了会儿呆。从词法分析的一行行字符判断到执行器里逐行匹配的朴素循环再到控制台打印出那张对齐的表格整个过程下来最大的收获不是“我会写SQL引擎了”而是我终于能看懂数据库文档里那些抽象概念到底对应代码里的什么东西。你如果也想感受一下这种“从空白到完整”的掌控感建议别犹豫打开Visual Studio照着这篇文章把MiniDB敲出来。遇到问题就在本地断点调试一行行看Token是怎么被切出来的数据是怎么被塞进内存表的那种连错误都在自己掌控范围里的体验是任何教程都给不了的。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。