ChuSQL
本项目是一个简单的数据库项目,服务端采用Haskell和Rust联合编写,采取B/S模式,管理客户端运行在浏览器网页上。
仓库布局
| 目录 | 语言 | 组件 |
|---|---|---|
chusql-engine/ |
Haskell | 执行引擎:词法分析与AST、表达式求值、关系代数、内存表、服务端接口 |
chusql-storage/ |
Rust | 存储引擎:磁盘IO、B+树、缓冲池、WAL、崩溃恢复、并发控制 |
chusql-web/ |
前端 | 浏览器端管理客户端 |
架构分层
Haskell部分
作为函数式编程语言,从语言层面契合SQL语句解析和代数数学运算,同时有强大的类型安全系统。
- SQL词法分析、AST
- 语义分析:表/列存在性与类型检查(执行前的关卡)
- 事务处理
- 查询优化、内存表管理
- 关系代数运算
- 服务端接口与请求相应
Rust部分
高性能、内存安全且具有强大并行异步编程能力的现代编程语言。
- 磁盘IO
- B+树、页面管理
- 缓冲池
- WAL
- 事务日志和崩溃恢复
- 并发控制
前端部分
- 前端部分目前还没想好选型架构,暂且使用简单前端三件套做一个简单的仿CLI WebUI即可。后期可能会加上Rust编写的CLI前端
Rust和Haskell部分的通信通过管道实现,后期跨平台到Linux后使用Unix Domain Socket
如果找到了较好的Rust+Haskell集成方法也会及时替换
性能基准
三个基准,各管一件事,主基准是全链路(Haskell 引擎和 Rust 存储进程一起启动,跑真实情景):
| 基准 | 位置 | 测什么 |
|---|---|---|
| 全链路 | benchmark/fullchain/ |
SQL 文本 → 引擎 → 命名管道 → Rust → WAL/堆表/索引 → 落盘,整条路 |
| 查询层 | benchmark/chusql-engine/ |
只用内存数据,隔离出优化器与连接算法的效果 |
| 存储层 | benchmark/chusql-storage/ |
Rust 侧页 IO / B+ 树 / 堆表 / WAL 的微基准 |
全链路:Haskell + Rust 一起跑
cd chusql-storage && cargo build --release # 先编译存储进程(找不到 release 会用 debug 并提醒你)
cd benchmark/fullchain
stack run chusql-fullchain # 默认 users = orders = 200
stack run chusql-fullchain -- 1000 1000 # 换规模
# 改过 chusql-engine 源码之后,先 stack build --force-dirty 再跑(原因见"计时怎么做"的坑四)
它做的事:造一个临时数据目录和独立管道名 → 起 Rust 存储进程 → 用引擎的 IPC 存储连上去 → 按真实情景跑一遍(建表、逐条 INSERT、全表 SELECT、过滤、JOIN、按主键点查)→ 校验行数 → 收摊。
每条语句都是整条链路:SQL 文本真的要过词法/语法分析、语义检查、关系代数,再走管道到 Rust, 经 WAL(fsync)、堆表、B+ 树、页缓冲池落到磁盘,结果再原路返回。 计时用墙上时钟(单调时钟),不是 CPU 时间——这条链路大部分工夫花在等 IO 和跨进程往返上。
实测(每条语句平均耗时)
| 指标 | 200 × 200 | 500 × 500 | 1000 × 1000 |
|---|---|---|---|
单条 INSERT(整条语句) |
2.66 ms | 2.86 ms | 2.88 ms |
同一条 INSERT(原始 IPC,不经引擎) |
2.50 ms | 2.85 ms | 2.77 ms |
多行 INSERT(100 行/条) |
— | — | 0.059 ms/行 |
SELECT * FROM users |
0.4 ms | 1.0 ms | 1.9 ms |
SELECT name FROM users WHERE age > 90 |
0.3 ms | 0.8 ms | 2.7 ms |
SELECT ... JOIN ... WHERE |
0.9 ms | 2.4 ms | 5.3 ms |
SELECT name FROM users WHERE id = k |
0.2 ms | 0.2 ms | 0.2 ms |
DELETE FROM users WHERE id = k(删 1 行) |
— | — | 4.8 ms |
DELETE FROM users(删全部 1000 行) |
— | — | 20.2 ms |
SELECT ... WHERE code = k(无索引 → 建索引后) |
— | — | 1.8 ms → 0.2 ms |
四条结论:
- 写入是 fsync 说了算,和数据量无关。 单条 INSERT 约 2.7 ms。能单独量出来的落盘成本约 1.0 ms
(写日志 0.43 + 清日志 0.42 + 表数据 flush 0.15),其余是这几次同步落盘之间的等待和跨进程往返。
表从 200 行长到 1000 行,单条耗时基本不动——插一行和插一千行的落盘代价几乎一样。
所以想快就得多行一起提交:
VALUES (...), (...), ...一条语句 100 行, 每行 2.88 ms → 0.059 ms(约 49 倍)。 - 点查是 O(1)。 按主键查一行 0.2 ms(其中原始 IPC 索引查找 0.07 ms),1000 行时仍是 0.2 ms。
建在别的列上的索引也一样:
WHERE code = k从 1.8 ms(没有索引,退回扫描)降到 0.2 ms。 - 删除的代价和"删几行"无关了。 删 1 行 4.8 ms、删全部 1000 行 20.2 ms: 前者就贵在一次全表扫描 + 一次落盘,后者是同一笔扫描加上 1000 次 O(1) 的行级删除。 改之前,删 1 行也要把整张表重写一遍并重建整棵索引。
- 单表查询与那张表的行数成正比(1000 行 1.9 ms,约 2 µs/行)。查询层现在的设计是把表拉进内存算, 这个数字就是"拉 1000 行过管道 + 在内存里过一遍"的价钱。
P2 这一版改了什么
四件事,都对应"把整表操作换成行级/批量操作":
- ① 行级 DELETE。 B+ 树加
delete(惰性删除:只摘叶子里的键,不合并节点); 堆表把槽位长度置 0(读侧本来就把"长度 0"当作没有这一行);查询层的DELETE改成 "扫一遍算出要删哪些 id →delete_keys一次删掉",不再replaceAll重写整表。 - ② 多索引 +
CREATE INDEX+ 规划器选路。 优化器把"单列等值条件"标成点查节点Lookup 别名 表 列 值;到底有没有索引由存储层回答——没有就回no_index,查询层退回全表扫描。 这样索引元数据只存在数据字典一处,加索引不用改优化器。 边界写在下面:只支持单列整数索引,而且索引列必须唯一。 - ③ 批量插入。 语法支持
INSERT INTO t (...) VALUES (...), (...), ...:一条语句走一次 WAL、一次 fsync。 - ④ 列统计。 数据字典里每列记"不同值个数"(内存里按值哈希精确维护,上限 4096 之后转下界),
随
catalog.json落盘,启动时扫一遍表重建,所以重启后数字仍然准;插入/删除实时更新。
一个必须讲清楚的取舍:索引列必须唯一
原来的索引只服务 id,"唯一"是默认成立的前提。要做通用索引就必须回答一个问题:
一个列上有重复值时,"走索引取一行"和"全表扫一遍再筛"还能得到同一个答案吗?
现在不能——索引的每个键只放得下一个行位置。所以这一版的规则是:
- 建索引时查重,有重复值就报错、不建(不留半成品);
- 索引列上插入重复值会被拒绝(老代码是"覆盖索引条目",那会让老行再也查不到)。
真正的解法是让 B+ 树支持重复键(键后面带上行号,再做叶子链上的范围扫描),
那要把 B+ 树的 i64 定长键换成内存可比较的变长字节键——和"复合键"是同一块地基,
留到单独一轮做。这条限制记在根 README 和 projectplan.txt 里,不是忘了。
顺带修掉一个旧 bug:点查节点原来会把别名前缀丢掉, 于是
SELECT u.name FROM users u WHERE u.id = 1取不到列(投影按u.name找, 回来的行却是name)。现在Lookup也带着别名,回来的一行和Scan 别名 表长得一样, 并且补了对照测试。
全链路这一版改了什么:别再为语义检查搬整个库
原来的 runStatementM 每条语句都先取一次整库快照(把所有表的所有行都拉过管道),
只为了给语义检查看列名。可是语义检查(Semantic.check)和查询优化(relOpCols)只看列,一行数据都不碰,
行数据是白搬的——而且每条语句都搬一次,于是单条语句的开销随库变大而线性增长。
改法:给 MonadStorage 加一个 schema(只要表结构,不要行),默认实现退化成"快照 + 抹掉行",
IPCStorage 覆盖成"只问数据字典要结构"(一次 list_catalog 往返,约 0.07 ms),Engine.runStatementM 改用它。
1000 × 1000 下的效果:
| 指标 | 改前 | 改后 | 倍率 |
|---|---|---|---|
INSERT INTO users |
3.65 ms | 2.67 ms | 1.4x |
INSERT INTO orders |
5.61 ms | 2.91 ms | 1.9x |
SELECT * FROM users |
6.7 ms | 2.0 ms | 3.4x |
SELECT ... WHERE age > 90 |
6.6 ms | 1.7 ms | 3.9x |
SELECT ... JOIN ... WHERE |
11.2 ms | 4.0 ms | 2.8x |
SELECT ... WHERE id = k |
3.3 ms | 0.2 ms | 16x |
改完之后单条 INSERT 的耗时和"原始 IPC 插入"持平(2.67 vs 2.68 ms)—— 引擎侧的每条语句开销已经小到量不出来,剩下全是存储层的落盘成本。
查询层:等值连接改成哈希表
Join 原来是"两边两两配对再筛":25 万对,每对拼一次行、按列名查两次值,单对约 0.66 µs,
乘起来就是 165 ms 上下——这正是下表"未优化"那一列的全部耗时。
现在条件是 a = b 这种两列等值时,先拿右表建哈希表、再拿左表逐行探测,配对次数降到 1000 次哈希查找;
认不出等值键时自动退回嵌套循环,输出顺序与原来完全一致。
数据规模 users / orders 各 500 行(配对 25 万对),中位数,单位 ms:
| 查询 | 结果行数 | 改前 未优化 | 改后 未优化 | 改前 优化后 | 改后 优化后 |
|---|---|---|---|---|---|
JOIN + WHERE u.age > 90 |
45 | 110.4 ms | 0.267 ms | 8.9 ms | 0.30 ms |
JOIN(无 WHERE,只有投影) |
500 | 135.7 ms | 0.397 ms | 111.3 ms | 0.549 ms |
JOIN + WHERE + ORDER BY + LIMIT 10 |
10 | 130.9 ms | 0.336 ms | 9.2 ms | 0.320 ms |
单表 WHERE age > 90(对照组) |
45 | 0.095 ms | 0.084 ms | 0.149 ms | 0.13 ms |
连接类查询约 300 倍;换成 2000 行 / 400 万对时是 500~900 倍(嵌套循环是 O(左 × 右),哈希连接是线性,规模越大省得越多)。
需要注意:表里"优化器开/关"的比值从原来的 12~17 倍降到了 1.0 倍左右—— 不是优化器变差了,是分母变了:原来那十几倍几乎全来自"条件把配对次数从 25 万筛到 2.25 万", 而连接本身只要 0.4 ms 之后,下推省下的时间已不占主导。看性能要看绝对耗时,不是比值。
存储层:写入路径
单条 INSERT 的完整流程(清 WAL → 写 WAL → 改数据 → 落盘 → 清 WAL)原来约 3.8 ms,现在约 2.5 ms:
- WAL 文件句柄常驻:原来每条操作要 open/close 三次,而 append 模式下开完再 fsync 比看起来的 open 更贵; 实测单次追加 990 µs → 507 µs。
- 写前那次清空只截断、不单独 fsync:紧跟其后的
append会 fsync,那一次会把截断一起落下去。 这一项从 522~600 µs 降到 11.6 µs。
写入路径的瓶颈是 fsync 次数,不是数据量。剩下的时间基本都是"真落盘",而这是 WAL 语义要求的: 再往下压就要动"每条操作都截断 WAL"这个设计本身(真正的数据库是顺序追加 + 检查点,而不是写一条清一次)。
其余微基准(n = 2000):页顺序写 1517 µs/页(含 flush)、页顺序读 4.14.5 µs/页、
B+ 树插入 17.9 µs/次、点查 14.8~16.1 µs/次、堆表扫描 0.5 µs/行。
计时怎么做(四个坑都踩过了)
坑一:Haskell 是惰性的,不强制求值就等于没测。
只写 let r = evalRelOp db plan,r 根本没被算出来,测到的是"定义一个变量"的时间。
所以每次必须把结果算到底(项目里是逐行把列数累加起来当校验和,或者把行数 evaluate 出来)。
坑二:Windows 上 CPU 时钟的精度只有约 15.6 毫秒。
毫秒级的用例直接测会得到 0.0 ms 这种没意义的数字。
查询层基准用自适应批量(同一用例连跑 k 次,k 翻倍到一批超过 0.3 秒再折算成单次),
把时钟误差摊薄;全链路基准因为是 IO 密集、单次就有毫秒级,直接用墙上单调时钟。
坑三:机器会漂移。 同一份代码、同一天、前后隔半小时,指标能整体差 15~25%(温度、后台进程都算)。 所以:改前改后必须同一时段交叉测;小于 2 倍的差异,没交叉验证之前不要当结论。
坑四(本项目的专属坑):小心链到旧引擎。 benchmark/fullchain 和 chusql-engine 在同一个 stack 项目里,
改完引擎源码后 stack 有时不会把依赖库重新拷进基准项目的安装目录——于是你测的是旧引擎,
而且数字看起来"合理"。改过引擎就先 stack build --force-dirty。
(这一条是实测踩出来的:一次"改动没效果"的排查,最后发现是引擎库的时间戳比源码还旧。)
试过但没留下的(别重复踩)
| 想法 | 结果 | 结论 | 为什么 |
|---|---|---|---|
给 Haskell 库加 -O2 |
加完 8 项全部慢 15~25%;去掉后重建再测,一样慢 | 已回退 | 这次"变慢"是机器漂移,不是 -O2 造成的——但回退后也没测出 -O2 的好处。结论:没有证据,要么同轮交叉测再下判断,要么别加 |
换掉缓冲池的 min_by_key 淘汰 |
池 1024 / 工作集超池:随机读 7.9 µs vs 池 16:5.1 µs | 未做 | 差距是真的(缺页时多扫 1024 个候选页),但只影响"工作集大于池"的随机读,绝对量 ~2.8 µs/次;而写入路径是 2500 µs/次,不在一个量级 |
把投影下推进 Scan(Scan 自带列清单) |
未做 | 待定 | 能消掉"无 WHERE 的 JOIN"多出来的那趟投影(约 25%,0.1 ms 级),但要改 RelOp 类型、relOpCols、Eval 和一堆测试 |
| 非唯一索引(一个键多条记录) | 未做 | 待定 | 要 B+ 树允许重复键(键后面带行号)+ 叶子链上的范围扫描;和"复合键"共用同一块地基:键从 i64 定长槽位改成内存可比较的变长字节键 |
| 只给索引加"唯一"检查、不改插入语义 | 未做 | 已否 | 那样建索引时唯一、之后插重复值仍会把老行"挤"出索引,查询结果就和全表扫不一致了——宁可插入时报错 |
怎么加用例
- 全链路:在
benchmark/fullchain/src/Main.hs的runScenarios里加benchSql label sql就行; 要验证结果就照着[校验]那一段写断言(现在的两个校验是age > 90的行数和 JOIN 的行数)。 - 查询层:在
benchmark/chusql-engine/src/Main.hs的queries列表里加一条 SQL 字符串,其余全自动 (自动翻译、优化、跑两遍、比结果、算加速比)。 - 存储层:在
benchmark/chusql-storage/benches/storage.rs里加一段bench(...)/bench_once(...)。
想在文档里引用数字,记得把数据规模、机器、时间一起写上——脱离这些谈"快了几倍"没有意义。
最近更新:2026-09-25