Absolute_Database v7.93源码:嵌入式数据库引擎拆解与改造
简介面向 Delphi 411 开发者的 Absolute Database v7.93 完整源代码包尤其适配 Delphi 11 Alexandria内置可直接嵌入应用的关系型数据库引擎为需要在 Windows、macOS、Linux 等平台构建数据持久层的项目提供免安装、可定制的核心组件。包内共 756 个文件、约 18.9MB主要文件类型包括 Delphi 单元与包源码pas、dpk、dcu、C/C 接口头文件c、cpp、hpp、SQL 脚本、工程配置dproj、bdsproj、sln以及编译批处理脚本、资源文件与帮助文档能支撑从源码编译、IDE 安装到运行调试的完整流程。已有 443 人学习适合需要跨版本 Delphi 维护老项目或研究数据库底层的进阶开发人员。借助开放源代码开发者可以深入理解事务处理、内存缓存、并发访问、安全加密与备份恢复等机制并针对业务场景调整索引、存储或权限策略避免闭源组件带来的二次开发桎梏。同时资源保留了与 Delphi IDE 无缝集成的组件库设计期建表、建查询与窗体绑定均可直接完成能明显缩短企业级数据库应用的迭代周期。1. Absolute_Database v7.93源码级嵌入式数据库拆开就能当本地存储引擎用给桌面工具做数据落盘时最怕的是“先装一个数据库服务”。某次我接手一个老工具数据量过万条以后 CSV 明显顶不住查一条记录要遍历整个文件肉眼能等出一次眨眼的时间。换方案时没有选通用嵌入式库而是改用这份 Absolute_Database v7.93 源码包下文简称 ADB v7.93。它本质是一套源码即用的嵌入式数据库引擎不需要独立进程编译阶段直接合入宿主程序。它的应用场景很明确给桌面应用、工具链、边缘小服务加本地数据库能力同时保留底层控制权。适合两类人一类是想快速给项目加存储、又不想被外部服务绑架的开发者另一类是在旧工程里做存储替换、希望改动尽量小的维护者。这篇文章把模块边界、核心实现、编译方式和改造入口逐一拆开。2. 项目定位与技术选型嵌入式数据引擎为什么采用源码直供2.1 存储方案的取舍不是所有应用都该连一个大服务接手那个工具时最初想走 C/S 数据库单机客户端连本地服务进程端口一开管理工具一应俱全。但冷静一想单机工具并不需要监听端口多了服务进程反而带来开机自启、版本同步、崩溃自动恢复一系列运维问题这些对一个桌面小工具来说都是负担。传统文件存储CSV、JSON又缺了索引和事务数据一旦过万查起来就是全表扫描体验很尴尬。ADB v7.93 选的是折中路线嵌入式引擎、本地文件存储、源码形式分发。引擎以库的形式被宿主进程加载数据库文件就是普通磁盘文件备份等于拷贝迁移等于复制文件。相比通用的嵌入式方案它把源码摊开给你意味着字段类型、索引策略、日志行为都能在编译期调整而不是把一组黑匣子 API 塞给你。代价是你要承担一部分编译和集成的责任这也是它选择源码分发而不是二进制分发的直接原因。2.2 模块边界的判断源码包不是一个单一文件我拿到源码的第一反应是看目录它不是堆在一起的杂乱文件而是划分成存储层、表达层、查询层、事务层和工具层。这样的分层带来一个实际好处——你要换存储文件格式只需要改动存储层要加 SQL 语法只需要动表达层业务方看到的接口是一组公开函数私有实现全部藏在模块内部。v7.93 的对外接口风格很克制主要围绕五个能力展开初始化与关闭、打开与关闭数据库、执行语句、遍历结果集、事务控制。调用方拿到的不是对象是一组不透明句柄。这样做既避免全局状态冲突也方便多个数据库文件在同一个进程里同时打开。源码直供模式下API 稳定是维护者的核心压力v7.93 之前的不少小版本都在做同一件事就是保证接口不破、内部重构随意。2.3 v7.93 的文件清单压缩包里到底有什么解开源码包之后顶层目录划分比较规整大致如下表所示。模块目录名文件类型主要职责公共接口include/db_public.h头文件暴露数据库句柄、游标、事务等公开 API平台适配include/db_platform.h头文件隔离编译器差异、字节序、内存分配器存储层src/storage.c 文件数据页管理、空闲块回收、文件扩展查询层src/query.c 文件条件表达式解析、索引查找、结果集构造表达层src/parser.c 文件SQL 子集词法切分、语法分析、语句树生成事务层src/tx.c 文件事务日志、回滚段、提交点管理工具与示例samples工程文件控制台演示、压力测试用例、命令行查询工具include 目录里两个头文件值得先读db_public.h 告诉你能调用什么db_platform.h 告诉你哪些地方为兼容 D4~D11 做了隔离。绝大多数源码不直接调用操作系统 API而是通过 db_platform.h 里包装的接口去碰文件系统和内存这一点对后面二次开发很重要。2.4 兼容 D4~D11 的三个技术难点标题里的 D4~D11指的是宿主开发环境的版本跨度从第 4 版到第 11 版。跨这么大的范围不是简单定义几个宏就能搞定。常见难点有三个字符串的内存模型不一样早期是 ANSI 单字节后期默认走宽字符异常处理的机制也不统一编译器对结构体对齐、内联函数的支持力度不同。v7.93 的解法是提供一个平台映射头文件把所有容易变化的东西集中隔离。功能代码里看不到条件编译堆砌只在 db_platform.h 里做版本分支上层代码一律通过 db_malloc、db_strdup、db_file_open 这类带 db_ 前缀的包装函数工作。这意味着你要迁移到一个全新环境时需要改的地方是收敛的多数情况重写一个 db_platform.h 就能编译通过。对于想移植到自己团队基础库的开发者来说这个设计比在业务代码里到处写 ifdef 干净得多。3. 目录结构与核心实现从建表到拿回一行数据的完整链路3.1 先看目录结构再谈代码位置实际解压后的结构比文件清单更细核心路径大概长这样Absolute_Database_v7.93_sources_for_D4-11 ├── include │ ├── db_public.h │ └── db_platform.h ├── src │ ├── storage │ │ ├── page.c │ │ ├── page.h │ │ ├── file_io.c │ │ └── btree.c │ ├── parser │ │ ├── lexer.c │ │ ├── parser.c │ │ └── stmt_tree.h │ ├── query │ │ ├── expr_eval.c │ │ ├── select.c │ │ └── result.c │ └── tx │ ├── log.c │ ├── rollback.c │ └── commit.c ├── samples │ ├── demo_console.c │ ├── demo_transaction.c │ └── Makefile └── README.md我先说结论核心链路集中在 storage/page.c、storage/btree.c、parser/parser.c、query/expr_eval.c 这四个文件。page 管磁盘上的数据页btree 管索引parser 把 SQL 文本变成语句树expr_eval 在查询时算条件表达式。后面拆代码的时候我会沿着“建表 → 写入 → 查询 → 事务”的顺序走一遍这样你改代码的时候知道该进哪个目录。3.2 建表的数据结构字段元数据是整个存储的源头任何一张表落到磁盘之前先要在内存里描述清楚表名、字段列表、字段类型、主键标定。v7.93 用一组结构体来完成这个描述typedef struct db_field_s { char name[32]; /* 字段名最长 31 字符 */ int type; /* DB_TYPE_INT / DB_TYPE_DOUBLE / DB_TYPE_TEXT */ int length; /* TEXT 字段的长度上限数值型填 0 */ int flags; /* 0 表示普通字段DB_FIELD_PRIMARY 表示主键 */ struct db_field_s *next; } db_field_s; typedef struct db_table_s { char name[64]; /* 表名 */ db_field_s *fields; /* 字段链表头 */ int field_count; /* 字段总数 */ long first_page; /* 数据页链表首页号 */ long index_root; /* 索引根节点页号 */ } db_table_s; typedef struct db_header_s { char magic[8]; /* 文件魔数用于快速识别数据库文件 */ int version; /* 文件格式版本号 */ long table_count; /* 当前库内表数量 */ long free_start; /* 空闲页链表起点 */ unsigned char reserved[128]; } db_header_s;db_header_s 是数据库文件的头部打开文件时先校验 magic 和 version版本对不上直接拒绝打开避免旧代码写坏新文件。db_table_s 里不直接存字段数组而是存字段链表头好处是加字段时不需要整体搬移内存坏处是遍历链表比数组慢一点但这个量级对单机应用完全无所谓。建表语句执行时工作分成两步先在内存里构造出 db_table_s 和 db_field_s随后调用 storage 层的写页函数把表结构序列化到磁盘的 metadata 页。这里有一个值得注意的细节表结构本身也占用一个数据页而 first_page 指向的是第一行数据所在的页。表结构页和数据页分开管理重建索引时只需要遍历 index_root不需要重新读表结构。3.3 数据落盘一行记录如何写进文件写入一行数据时v7.93 不是直接把结构体往文件里倒而是先做字段级序列化。原因很直接结构体内存布局受编译器对齐影响不同版本的编译器在 D4~D11 之间对字节对齐的处理有细微差别直接按内存布局写文件换编译器后就读不出来了。int db_row_serialize(db_row_t *row, char *buf, int buf_size) { db_field_t *f; int offset 0, len; for (f row-table-fields; f ! NULL; f f-next) { if (offset 8 buf_size) { return DB_ERR_BUF_SMALL; /* 缓冲区不够属于调用方问题 */ } if (f-type DB_TYPE_INT) { memcpy(buf offset, row-values[f-index], sizeof(int)); offset sizeof(int); } else if (f-type DB_TYPE_DOUBLE) { memcpy(buf offset, row-values[f-index], sizeof(double)); offset sizeof(double); } else if (f-type DB_TYPE_TEXT) { /* 文本按 2 字节长度 内容字节的方式落盘 */ len (int)strlen(row-values[f-index].str); memcpy(buf offset, len, sizeof(short)); offset sizeof(short); memcpy(buf offset, row-values[f-index].str, len); offset len; } } return offset; }这段代码的逻辑不复杂但有三个设计点值得讲。第一序列化产物是按字段顺序排布的字节流字段类型不同占用空间不同读取时按同样的顺序反解即可。第二文本字段前面多放了一个 short 长度这样读取时先读长度再按长度读内容不会出现字符串截断或越界这是处理变长字段的常规做法。第三调用方需要自己保证 buf_size 够用函数只做边界检查不做动态扩容目的是让这个函数能用在页内偏移固定的场景。写完序列化字节流之后storage/page.c 负责把它塞进某个数据页。如果当前页剩余空间不够就申请新页并挂到链表尾部事务层再记录这次扩展。整个过程里我一般最关心的是页大小参数。页大小决定了单页能放多少行也影响了 B 树的扇出v7.93 默认页大小是 4096 字节如果你的行记录偏大可以把页大小调到 8192需要改的是 db_platform.h 里的一个配置宏不用碰存储代码。3.4 查询链路的索引实现B 树在源码里怎么工作条件查询能跑得快靠的是 btree.c 里面的索引结构。v7.93 的索引不是绕过存储层单独搞一套而是建立在数据页之上每个索引节点本身就是一个页页内保存若干键值和指向子页的页号。typedef struct btree_node_s { long page_no; /* 当前节点所在页号 */ int key_count; /* 节点内有效键数量 */ int keys[MAX_KEYS]; /* 键值数组这里假设是整型主键 */ long child[MAX_KEYS 1];/* 子节点页号比键多一个 */ bool is_leaf; /* 是否为叶子节点 */ } btree_node_t; long btree_find(db_btree_t *bt, int key) { btree_node_t node; long page_no bt-root_page; int i; while (!node.is_leaf) { db_page_read(bt-file, page_no, node); /* 找到第一个比 key 大的位置然后走入左侧子节点 */ for (i 0; i node.key_count; i) { if (key node.keys[i]) { page_no node.child[i]; break; } } if (i node.key_count) { page_no node.child[node.key_count]; } } /* 叶子节点里做线性查找 */ db_page_read(bt-file, page_no, node); for (i 0; i node.key_count; i) { if (node.keys[i] key) { return node.child[i]; } } return DB_NOT_FOUND; }这个实现是比较典型的 B 树查找非叶子节点只做路径选择真实数据落在叶子节点。代码里有个容易误解的地方node.child[i] 在非叶子节点里指向子页在叶子节点里却指向真正的数据页。同一个字段承担两种语义虽然让结构体省了一个字段但读代码时要格外注意。查找时每读一个节点都调用 db_page_read 从磁盘读取没有加缓存层。数据量几万行时够用但如果做高并发读取瓶颈会出现在磁盘 I/O 上改造时优先考虑给 btree 加节点缓存。3.5 事务与回滚不能只靠“写之前备份文件”这种笨办法单机嵌入式数据库的事务范围通常不像服务端那么严格但 v7.93 依然给了提交和回滚的骨架。实现思路是事务开始后所有修改先写进日志页日志页记录的是“修改前镜像”和“修改后镜像”数据页的落盘延迟到提交时统一做。typedef struct tx_log_entry_s { long log_seq; /* 日志序号单调递增 */ long page_no; /* 被修改的数据页 */ int offset; /* 页内偏移 */ int len; /* 修改长度 */ char old_data[16]; /* 修改前的字节通常 16 字节以内 */ char new_data[16]; /* 修改后的字节 */ } tx_log_entry_t; int db_tx_commit(db_conn_t *conn) { long seq conn-log_seq; /* 先确保日志落盘再动数据页 */ if (db_log_flush(conn-log_file, seq) ! DB_OK) { return DB_ERR_IO; } /* 按日志顺序把修改应用到数据页 */ if (db_log_replay(conn-log_file, conn-data_file) ! DB_OK) { return DB_ERR_IO; } db_log_truncate(conn-log_file, seq); return DB_OK; }提交过程的关键点是日志先落盘、数据页后落盘。如果只写了数据页、日志还没落盘就断电重启后无法判断哪页是新哪页是旧。反之先把日志写稳再回放数据页即使写到一半断电重启时也能按日志从头恢复。这套机制不算新但作为嵌入式引擎的默认事务基线已经能覆盖单进程、单线程环境下的大部分异常场景。回滚函数走的路径正好相反它从日志尾部向前读找到每个修改前的镜像并覆盖回数据页。实际使用中我建议你把事务的默认隔离级别理解成“串行即可”因为 v7.93 没有做多版本并发控制多线程共享同一个连接时事务修改在内存里是共享的担心数据错乱的话应用层加锁比依赖数据库事务更省心。4. 编译与运行把源码包变成可调用的数据库文件4.1 环境准备编译器、依赖和三个必须确认的事先拆环境要求。v7.93 的源码依赖很少核心代码只用标准 C 库和一套极薄的文件系统封装理论上大部分桌面环境都能编过。准备好编译器之后我建议先用控制台工程跑通最小链路再往自己的工程里集成。cd Absolute_Database_v7.93_sources_for_D4-11 make -C samples clean all在编译之前有三件事必须确认属于这个项目最常见的翻车前奏。第一确认编译器是 32 位还是 64 位模式db_platform.h 里对不同模式定义了不同的文件偏移量结构编译期写错会导致打开大文件时读写位置错乱。第二确认环境变量里有没有把 include 目录指到 headers 上public.h 和 platform.h 必须同时可见缺一个会报很隐晦的类型重定义错误。第三确认工程字符集和 db_platform.h 里的 DB_CHARSET 一致特别是传统工程还在用 ANSI 编码的情况混用宽字符会造成字符串读取乱码。4.2 编译参数与配置项build 脚本不多核心就一个 Makefile 和几个配置宏。常用的编译参数可以按下面这个表格来理解配置默认值作用改它会影响什么DB_PAGE_SIZE4096数据页字节数页越大单页行数越多B树扇出越大读取大记录时更快DB_MAX_FIELDS64单表字段上限超过后建表会被拒绝改大需要同步调整序列化缓冲区DB_CHARSETANSI字符集模型影响文本字段的存储与比较方式DB_LOG_MODEON事务日志开关关闭后写入更快但断电会丢数据DB_USE_MMAPOFF是否启用内存映射文件开启后大文件读取更快但跨平台行为差异更明显改这些宏不需要动业务代码只改 db_platform.h 里的宏定义后重新编译。有一点要提醒DB_MAX_FIELDS 不只是结构体上限parser 里还有一个静态数组用于解析时暂存字段名两个位置必须同步改只改一边会出现建表越界但语法检查不报错的怪问题。编译命令本身并不复杂但我习惯在 make 之后做一步静态检查看有没有出现函数指针类型不匹配的告警。v7.93 的接口层大量使用回调函数编译器对这块的检查特别严格如果看到 incompatible function pointer 字样的告警不要忽略这是平台适配层还没完全对上的信号。4.3 运行验证建库、写数据、条件查询一次跑通编译完成之后先用自带的命令行 demo 跑一遍验证引擎的基本功能。启动后直接在交互环境里输入 SQL。CREATE TABLE IF NOT EXISTS user_info ( id INT PRIMARY KEY, name TEXT(64), score DOUBLE ); INSERT INTO user_info VALUES (1, A同学, 87.5); INSERT INTO user_info VALUES (2, 某开发者, 92.0); SELECT * FROM user_info WHERE score 90;执行完这条 SELECT预期输出两列字段和一行数据。我建议验证时重点做三个操作连续执行两条 INSERT观察第二条是否追加而不是覆盖执行带 WHERE 的 SELECT确认条件过滤走的是索引不是全表扫最后执行一次 DELETE 再重新 SELECT确认结果集正确。全部通过后再打开数据库文件目录确认新增的文件尺寸大于一个页这能说明表结构和第一行数据确实落盘了。命令行 demo 只覆盖了基本语句下一步建议用 samples/demo_transaction.c 验证事务链路。这个示例会开启事务、写入几行、主动回滚、再开启新事务查询如果你看到回滚后数据行数为 0说明日志回滚逻辑工作正常。这一步骤对于后续做二次开发很重要后续改过事务相关代码后跑一遍 demo_transaction 就能很快定位问题。4.4 常见问题与避坑记录我踩过的四次坑第一坑打开已存在数据库时出现“格式不识别”报错。现象是一个之前能打开的库文件换了一台机器或换一个编译器重新编译后打不开。原因大概率是 db_platform.h 里字节序配置跟它原先写入时不一致了或者页大小配置变了。解决方法是确认新旧两个工程的 DB_PAGE_SIZE、字节序都一致数据库文件搬迁时尽量连配置宏一起带过去。第二坑写入大量小字段的数据时性能慢得离谱。现象是单条 INSERT 很快但连续插入一万行就开始越来越慢。原因是每写一行都要更新一次 B 树索引而索引根页在每次写后都落盘没有延迟合并。解决办法是把批量插入放进一个事务里让日志层把多次提交合并成一次落盘批量场景性能能提升一个数量级。第三坑SQL 里字段名带下划线时解析出错。现象是 CREATE TABLE 时字段名正常但 SELECT 带下划线字段时会报语法错误。原因是词法切分里把下划线算成了标识符分隔符而不是标识符的一部分。解决方法是调整 lexer.c 里的字符分类逻辑把下划线归入标识符字符集合再重新编译 parser 模块。第四坑同一进程打开两个数据库第二个总是打不开。现象是第一个库正常第二个库的 file open 返回拒绝。原因是默认配置限制了每进程最大打开文件数db_platform.h 里 DB_MAX_DB_OPEN 是 1。解决方式是把 DB_MAX_DB_OPEN 调大同时提醒自己把文件句柄的管理集中在一个模块里防止超过操作系统句柄限制。5. 二次开发改造指南把默认引擎改成贴近自己业务的形状5.1 加一种新的字段类型最少要动五个位置如果你想支持一个日期类型 DB_TYPE_DATE只改字段类型枚举是不够的。我在前面拆过的链路里按顺序梳理至少需要同步修改这五个位置db_public.h 里增加枚举值db_row_serialize 里增加对应的序列化分支查询层的 expr_eval.c 里增加比较逻辑parser 的类型检查里允许该类型出现在建表语句中最后在 demo 里补一条用例验证存取。这个改动链路正好验证了分层的价值每一层只碰一小块代码改完编译告警会帮你检查有没有漏网之鱼。我一般会再加一层保险在 samples 里写一个 round_trip 测试对新建的日期类型做“写入再读回再比较”的循环测试确保序列化没有精度损失。等这一轮测试跑过再考虑把新类型暴露给上层业务。5.2 给查询层加一个自定义聚合函数比较常见的需求是统计某列的平均值或中位数。v7.93 默认提供了 count、sum、avg 这几个基础聚合如果你想加一个 stddev 聚合改动集中在查询层在函数注册表里加入新的聚合函数指针在 result.c 里维护一个累计变量状态在 SELECT 解析时识别新的函数名。难点在于聚合是按行流式处理的中间状态要挂到每个结果集上不能用全局变量否则两个并发查询会互相污染。这段改造经验我每次都会跟人说尽量在 API 层做扩展不要直接改 parser 的语法分支。parser 一旦开始支持自定义函数表后续加新函数只是注册一行代码的事不需要重新调语法规则。v7.93 的函数注册表设计恰好留了这个口子算是这套源码里比较值得利用的架构红利。5.3 改完之后怎么验证源码级项目最怕改一处动全身所以我养成一个习惯每一次改完都强制走一遍当时跑通的最小用例集包括建表、批量写入、条件查询、事务回滚、重启后读数据。从那以后我每次迭代都强制走一遍这五步脚本化地记录输出结果不再依赖“改完编译通过就觉得没问题”。这个资源给你的空间比二进制库大得多但前提是你自己把验证路径看住。希望这篇拆解对你有帮助动手改的时候遇到具体报错回到模块边界那张表基本就能定位问题所在。本文还有配套的精品资源点击获取