Files
ChuSQL/README.md
T
2026-09-25 14:21:55 +08:00

235 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 一起跑
```bash
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):页顺序写 15~17 µs/页(含 flush)、页顺序读 4.1~4.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