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

四条结论:

  1. 写入是 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 倍)。
  2. 点查是 O(1)。 按主键查一行 0.2 ms(其中原始 IPC 索引查找 0.07 ms),1000 行时仍是 0.2 ms。 建在别的列上的索引也一样:WHERE code = k 从 1.8 ms(没有索引,退回扫描)降到 0.2 ms。
  3. 删除的代价和"删几行"无关了。 删 1 行 4.8 ms、删全部 1000 行 20.2 ms: 前者就贵在一次全表扫描 + 一次落盘,后者是同一笔扫描加上 1000 次 O(1) 的行级删除。 改之前,删 1 行也要把整张表重写一遍并重建整棵索引。
  4. 单表查询与那张表的行数成正比(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

S
Description
个人所写的数据库
Readme MIT
1.5 MiB
0 Stars 1 Watchers 0 Forks
Languages
Haskell 64.4%
Rust 22.4%
JavaScript 5.7%
PowerShell 2.7%
Shell 2.6%
Other 2.2%