简介:面向 Delphi 4~11 开发者的 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_SIZE | 4096 | 数据页字节数 | 页越大单页行数越多,B+树扇出越大,读取大记录时更快 |
| DB_MAX_FIELDS | 64 | 单表字段上限 | 超过后建表会被拒绝,改大需要同步调整序列化缓冲区 |
| DB_CHARSET | ANSI | 字符集模型 | 影响文本字段的存储与比较方式 |
| DB_LOG_MODE | ON | 事务日志开关 | 关闭后写入更快,但断电会丢数据 |
| DB_USE_MMAP | OFF | 是否启用内存映射文件 | 开启后大文件读取更快,但跨平台行为差异更明显 |
改这些宏不需要动业务代码,只改 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 改完之后怎么验证
源码级项目最怕改一处动全身,所以我养成一个习惯:每一次改完都强制走一遍当时跑通的最小用例集,包括建表、批量写入、条件查询、事务回滚、重启后读数据。从那以后我每次迭代都强制走一遍这五步,脚本化地记录输出结果,不再依赖“改完编译通过就觉得没问题”。这个资源给你的空间比二进制库大得多,但前提是你自己把验证路径看住。希望这篇拆解对你有帮助,动手改的时候遇到具体报错,回到模块边界那张表,基本就能定位问题所在。
本文还有配套的精品资源,点击获取