几个月前,我在重构一个数据分析服务的时候,被一堆手写 SQL 折磨得够呛。二三十个筛选条件、七八个 JOIN、还有各种嵌套子查询,代码里散落着字符串拼接,团队里每个人的写法风格都不统一,review 起来非常痛苦。那时我就在想一个问题:能不能用更高级、更抽象的方式来描述查询意图,然后让工具自动把它编译成可执行的 SQL?
这个想法最终变成了一个实验项目——一门编译为 SQL 查询的范畴论编程语言。简单说,就是用范畴论(Category Theory)里的抽象概念去建模数据库查询过程,用户写的是纯函数式的表达式,编译器负责把表达式转换成干净的 SQL。这篇文章我想完整复盘一下这个项目的设计思路、编译链路、实现细节,以及我在实操中踩过的坑,希望能给对函数式编程、DSL 设计和 SQL 优化感兴趣的朋友一些参考。
1. 项目概述:从"写 SQL"到"描述查询意图"
1.1 为什么会想到用范畴论来建模 SQL
先说说最直接的问题:SQL 本身已经很成熟了,为什么还要绕一大圈用范畴论来搞一套新东西?
我的答案是:SQL 缺少可组合性。你写一个查询,经常会遇到"这段逻辑我要在另一个查询里复用它"的需求,但 SQL 并不能优美地做到这一点——你只能复制粘贴,或者借助视图、CTE、临时表来变通。而且手写 SQL 非常容易出现类型错误:把字符串列当成数字去聚合、在 WHERE 里对 NULL 做判断、JOIN 条件写错造成笛卡尔积爆炸……这些问题在编译期是无法被发现的,只能在运行时让你看到一堆莫名其妙的结果。
范畴论恰好提供了一个优雅的组合模型。它研究的是"对象"和"对象之间的箭头"(态射),而数据库查询本质上就是"关系"到"关系"的变换。如果把表看作对象、查询看作态射,那么查询的组装就是态射的复合,天然支持模块化和复用。更进一步,范畴论中的函子(Functor)、自然变换(Natural Transformation)、单子(Monad)这些概念,恰好可以一一对应到 SQL 中的投影、连接、子查询等核心操作。
本质上,这个项目的目标不是取代 SQL,而是把 SQL 隐藏起来,让开发者用一套更安全、更可组合的抽象来表达查询意图,最终交给编译器去生成"人写得出来的那种好 SQL"。
1.2 这个项目能解决什么实际场景
从实际应用角度,这个编译器的使用场景非常明确:
场景一:复杂报表查询。 一张报表往往要聚合多个维度的数据,还要做各种筛选、排位、同比环比。手写这种 SQL 很痛苦,而且后续改动需求一多就熵增爆炸。用范畴论语言写,逻辑被拆成一个个小的态射组合,每个组合单元可以单独测试。
场景二:数据权限控制。 系统里不同角色能看的数据范围不一样,最常见的手段就是在查询末尾拼接 WHERE org_id IN (...)。但这种拼接特别容易出错,经常拼出语法错误。在这门语言里,权限过滤可以被建模为一个固定的"过滤器函子",编译器在生成 SQL 时自动把过滤条件追加到最终语句上,杜绝了注入和拼接错误。
场景三:多数据库方言适配。 靠字符串拼 SQL 的应用,换数据库等于重写一遍。而编译器的中间表示(IR)是关系代数级别的,和具体数据库无关,需要支持 PostgreSQL、MySQL、SQLite 时,只需要改后端翻译模块。
我在项目早期定了一个目标:任何"范畴论程序"最终必须编译成一条(或一组)SQL 语句,并且生成的 SQL 要保证语义等价、性能不差于手写。这个目标听起来简单,实际上非常难,后面会详细展开。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心原理:范畴论概念如何映射到查询
2.1 把数据库表看作一个范畴
在展开设计前,先花点篇幅讲清楚范畴论在这里到底怎么用。别被"范畴论"这三个字吓到,它的核心思想其实就一句话:研究事物以及事物之间的联系。
一个范畴由三部分组成:对象(Objects)、态射(Morphisms)、态射复合(Composition)。通俗地理解,对象是"东西",态射是"东西之间的箭头",复合表示"两个箭头首尾相连变成一个新箭头"。
对应到数据库领域:
- 表(Table) 是一个对象。更严谨地说,表的结构(Schema)才是对象,表里的数据行是这个对象的实例。
- 查询(Query) 是从一个表到另一个表(或者多个表到一张结果表)的态射。
- 查询的组合 就是先把 A 表查出一个结果,再把这个结果作为输入去查 B 表——这就是态射的复合。
举个例子,你有两张表:customers 和 orders。从 customers 出发,通过外键 customer_id 去关联订单,这个关联动作就是一个态射。多个这样的态射串联起来,就能实现"查客户->查客户的所有订单->查每笔订单的商品明细"。这就是一个典型的查询管道。
为什么这个视角有用?因为在范畴论中,态射可以自由地复合,并且复合满足结合律。这意味着我们可以把一个大查询拆分成无数个小函数,然后像搭积木一样组合起来,而组合得到的语义是确定的、唯一的。这是"可组合性"的数学保证。
2.2 函子、自然变换与 JOIN 的关系
有了对象和态射,下一步是引入"函子"。函子是范畴之间的映射,它把对象映射到对象、态射映射到态射,并保持复合关系。这个概念听起来抽象,但在 SQL 场景里非常具体。
函子对应 SQL 的投影(Projection)。假设你有一个查询 SELECT name, email FROM customers,这个查询把"客户表"映射到"客户姓名和邮箱的结果集",同时保留了表的结构特性。这就是一个典型的函子映射:它把每一行转换成新的形状,这个过程是"受结构约束的"。
自然变换对应 SQL 的 JOIN。自然变换是两个函子之间的函数,它在不改变已有结构的情况下,把一种"结构容器"转换成另一种。在关系型数据库里,JOIN 本质上是把两张表"合并"成一个更宽的关系,而且它不破坏每张表原有的列结构。这个性质非常像自然变换的"自然性条件"。
实际编码中,我定义一个 Query 类型时,对用户暴露的接口就刻意模仿了函子的 map 操作:
typescript复制query<Customer>(customers)
.map(c => ({ name: c.name, email: c.email }))
// 这里 map 就是函子的 fmap
接着是 JOIN 的抽象。在实现中,我把 JOIN 定义为一个函数,它接收两张表的查询对象,以及一个连接条件表达式,返回一个新的查询对象:
typescript复制query<Order>(orders)
.join(query<Customer>(customers), (o, c) => o.customerId === c.id)
// 返回的查询对象包含两张表的字段
从用户视角看,join 就像一条通道,把两个独立的查询管线接通了。正是这种"通道感"让我意识到,JOIN 的抽象应该向自然变换靠拢,而不是像 SQL 那样线性地把表拼在一起。
2.3 单子(Monad)与嵌套子查询的表达
前面这些抽象在关系型数据库里都不难找到对应物,真正难的是嵌套子查询。
SQL 中的 WHERE EXISTS (SELECT 1 FROM ...)、NOT IN (SELECT ...)、FROM (SELECT ...) AS t 都涉及嵌套查询。嵌套查询的核心特征是:内层查询依赖外层查询的值。这个特征在函数式编程里有一个非常成熟的概念——Monad。
Monad 的核心操作是 bind(也叫 flatMap),它的作用是:把一个返回"容器"的函数应用到容器上,并把结果压平。放到查询场景里:
typescript复制query<Customer>(customers)
.flatMap(c =>
query<Order>(orders)
.filter(o => o.customerId === c.id)
// 内层查询依赖外层 c 的值
)
这个 flatMap 编译后可能变成:
sql复制SELECT c.*, o.*
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id;
也有可能变成相关子查询,取决于优化器怎么选择。但关键在于,用户不需要关心"应该用 JOIN 还是子查询",编译器会自动完成这个决策。这正是 Monad 抽象的巨大价值:它把"依赖上下文"的查询统一打包,让编译器可以自由地做等价变换。
我在实现时发现,用 Monad 建模子查询还有一个额外的好处——可以彻底消灭动态 SQL 拼接。很多报表系统会根据用户输入动态生成 SQL,比如"如果选了地区就加一个地区过滤"。用 Monad 的 flatMap 结构,每一步都在构建一个新的查询对象,最终一次性编译成一条完整 SQL,中间没有任何字符串拼接,自然就没有 SQL 注入的问题。
2.4 范畴论不是银弹:哪些查询模型不适合这样建模
当然,必须承认范畴论建模也有局限。我在项目推进过程中碰到几个明显不适用的场景:
窗口函数(Window Function)。ROW_NUMBER() OVER (PARTITION BY ...) 这类查询涉及"行编号""分组排序"等操作,它们依赖行的物理顺序和分组边界,在纯关系代数里不好表达,在范畴论语义里也难以找到一个自然的结构对应。后来我被迫为窗口函数做了一些特化,但设计上多多少少显得不那么优雅。
递归查询(WITH RECURSIVE)。递归查询的本质是迭代不动点,这超出了普通态射复合的范畴。我最初没有设计递归支持,后来为了兼容组织架构树查询才补充了一个非标准的原语。
更新和删除操作。范畴论建模查询很容易,但建模副作用(UPDATE、DELETE)很难。因为副作用打破了纯函数的模型。所以这门语言最终定位成了只读查询语言,写操作仍然回到手写 SQL。
这也给了我一个很重要的教训:任何抽象都有边界,硬套反而会更复杂。范畴论适合描述"结构变换"和"组合",但不适合描述"状态变更"。把握好这个边界,项目才能真正落地。
3. 编译器设计与实现:从表达式到 SQL 的完整链路
3.1 整体架构:前端、中间表示、后端三层编译
整个编译器采用经典的三阶段架构。这个结构是所有编译器的基础,尤其适合处理"源语言 -> 中间表示 -> 目标语言"的翻译过程。
- 前端(Frontend):负责解析源代码、做类型检查,生成抽象语法树(AST)。
- 中间表示(IR):将 AST 转换为关系代数表达式树,然后做等价变换和优化。
- 后端(Backend):将优化后的 IR 翻译成目标数据库的 SQL 方言。
选三阶段而不是直接"AST 翻译成 SQL",原因是中间表示的抽象层次很关键:关系代数表达式树既能表达 SQL 的所有语义,又不受具体 SQL 方言的影响。这样后面新增一个数据库后端时,工作量就只是写 SQL 生成器,而不是重写整个编译器。
我这边的技术栈选了 TypeScript,理由有三:一是开发速度快,类型系统能帮我约束 AST 和 IR 的正确性;二是不需要额外搭建构建工具链;三是社区里有成熟的 visitor 模式库,方便遍历 AST 做优化。如果你打算用 Haskell 或 OCaml 写,类型安全会更强,但开发效率会低一些,看你自己的取舍。
3.2 前端实现:类型推导与 AST 生成
前端的基本流程是:词法分析 -> 语法分析 -> 类型检查 -> AST 生成。对于一个内嵌 DSL 来说,词法和语法分析相对简单,因为源语言看起来就是一行行链式调用的函数式表达式。
我设计了一个 GADT(广义代数数据类型)风格的 AST:
typescript复制type QueryExpr =
| { kind: 'table'; name: string; alias: string }
| { kind: 'map'; source: QueryExpr; fn: (input: any) => any }
| { kind: 'filter'; source: QueryExpr; predicate: (input: any) => boolean }
| { kind: 'join'; left: QueryExpr; right: QueryExpr; on: (l: any, r: any) => boolean }
| { kind: 'flatMap'; source: QueryExpr; fn: (input: any) => QueryExpr }
| { kind: 'aggregate'; source: QueryExpr; groupBy: string[]; measures: Measure[] };
这里每个 kind 对应一种范畴论结构,map 对应函子映射,flatMap 对应单子绑定,join 对应自然变换。解析器把用户写的链式表达式直接转换成这样的树。
类型检查是前端最关键的一步。每个表都有自己的 Schema 定义(字段名 + 类型),编译器必须确保:
- 字段引用是存在的,比如
c.name只有在customers表有name字段时才合法; - 筛选条件返回的是布尔类型,不能是数字或字符串;
- JOIN 条件两边的字段类型必须兼容,比如
customers.id不能和orders.amount比较。
类型检查的实现方式很直接:在遍历 AST 时维护一个"环境"(Environment),环境里记录了当前查询上下文中的所有表及其字段类型。每遇到一个字段访问表达式,就到环境里去查这个字段是否存在、类型对不对。整个过程类似编译器课上的符号表管理。
为了表达复杂嵌套查询,后端还用了一个别名系统:每次出现新表引用时自动分配别名(t1, t2, t3...),这样后面的 SQL 生成器可以把表达式树展开成正确的、有明确别名的 SQL 语句。
3.3 中间表示设计:把 AST 规约为关系代数
AST 是"面向用户"的,它含着用户写代码时的结构。但要做优化,就必须把它转换成编译器真正用来推理的形式——关系代数表达式树。
关系代数的经典算子有:选择(σ,对应 WHERE)、投影(π,对应 SELECT 列)、连接(⋈,对应 JOIN)、分组(γ,对应 GROUP BY)、排序(τ,对应 ORDER BY)、去重(δ,对应 DISTINCT)、重命名(ρ,对应别名)。
我在这个项目里定义了一个自己的 IR 节点集合,和关系代数一一对应:
typescript复制type RelExpr =
| { op: 'Scan'; table: string; alias: string }
| { op: 'Project'; exprs: string[]; input: RelExpr } // 对应 π
| { op: 'Select'; cond: string; input: RelExpr } // 对应 σ
| { op: 'Join'; left: RelExpr; right: RelExpr; on: string }
| { op: 'GroupBy'; keys: string[]; aggs: AggSpec[]; input: RelExpr }
| { op: 'OrderBy'; keys: string[]; input: RelExpr }
| { op: 'Distinct'; input: RelExpr }
| { op: 'Limit'; count: number; input: RelExpr };
从 AST 到 IR 的转换规则比较机械:
map编译成Project;filter编译成Select;join编译成Join;flatMap要根据具体结构决定——内层查询不依赖外层就编译成Join,依赖外层就编译成Select加 EXISTS 子查询,或保留为相关子查询结构;aggregate编译成GroupBy。
这个步骤中最关键的设计决策是:表达式必须保持"字符串化"或"结构化"的表达形式。因为字段表达式 o.totalPrice > 100 在 AST 里是一棵表达式树,在翻译成 SQL 时需要把这棵树序列化成 "o.total_price > 100"。我在 IR 阶段选择保持表达式为结构化数据(一个操作符树),这样在做优化时(比如谓词合并)还有得改,如果直接转成字符串就失去优化机会了。
3.4 优化器实现:谓词下推与投影消除
关系代数表达式树建好后,一个好处是可以做各种等价变换优化。我实现了两个最基础、也最有效的优化规则:谓词下推和投影消除。
谓词下推(Predicate Pushdown) 是最重要的一条规则。SQL 执行引擎处理 WHERE 的最佳时机是尽早过滤数据,越早过滤,进入后面算子的数据量就越小。但用户在写查询时,经常是在多表 JOIN 之后才写 WHERE 条件,比如:
sql复制SELECT c.name, o.total
FROM customers c
JOIN orders o ON c.id = o.customer_id
WHERE c.region = '华东';
执行引擎当然会优化,但手工把 WHERE c.region = '华东' 下推到 JOIN 前面(先过滤 customers 再 JOIN),对于大多数数据库来说是有性能收益的,尤其是当 customers 表很大时。
在我的 IR 上实现谓词下推非常简单:遍历关系代数树,遇到 Join 节点时,检查它的父节点 Select 的条件表达式,如果条件只涉及左子树或右子树的列,就把这个条件挪到对应子树上。这个规则可以递归应用,直到所有能在下层执行的过滤都在最底层执行。
投影消除(Projection Elimination) 是第二个优化。用户经常写出"SELECT *"或者选中了所有列,这会导致数据库需要读取大量数据。优化器会分析每个算子的输出列,删除那些没有被上层引用的列。比如内层 Project 返回了 id, name, email,但外层只用了 name,那优化器会尝试把内层 Project 的 id 和 email 删掉。
优化器实现时最容易出错的是保持"表达式正确性"——当你下推了一个谓词,或者合并了两个 Project,必须重新检测字段别名是否冲突。我在调试中就遇到过多次由字段掩码出错导致的 "column not found" 错误,后来给 IR 里的每个算子都维护了一个"输出字段集合",优化前先做字段依赖分析,才彻底解决了这个问题。
3.5 后端代码生成:把 IR 翻译成 SQL 的具体规则
IR 优化完成后,就到了后端翻译环节。这部分非常繁琐,因为各种数据库方言差异很大。我的实现是定义了一个统一的 SQL 文本生成接口,然后为不同数据库实现不同的生成策略。
翻译规则的核心是:
Scan生成FROM table AS aliasSelect生成WHERE condProject生成SELECT exprsJoin生成JOIN right ON condGroupBy生成GROUP BY keys和SELECT中的聚合表达式OrderBy生成ORDER BY keysLimit生成LIMIT n(MySQL、PostgreSQL)或SELECT TOP n(SQL Server)
看起来是一一映射,但真正写起来有很多细节坑。举个最常见的例子:字段名转义。PostgreSQL 里大小写敏感的列名必须加双引号,MySQL 里用反引号,SQL Server 用方括号。你不可能让用户记住这些差异,所以我写了一个 quoteIdent 函数,根据目标方言输出不同的引号。还要处理关键字冲突——如果用户的列叫 order 或 group,不加引号直接生成 SQL 就是语法错误。
还有一个更麻烦的问题:参数化查询。很多人写 SQL 生成器时直接字符串拼接值,比如 WHERE total_price > 100,这非常危险。我的实现是,任何来自用户输入的常量值,编译器会生成一个占位符($1、$2 或 ?),并把实际值放入参数列表,最终输出的结果是一条参数化 SQL,配合驱动调用可以彻底防住 SQL 注入。
生成 SQL 之后,还有一个可选的格式化阶段。直接生成的 SQL 往往非常拥挤、缩进混乱,不利于 DBA 审阅。我写了一个简单的 SQL pretty-printer,按关键字分行、缩进,这样生成的 SQL 至少能拿给 DBA 去 review。
4. 实操过程:从零搭建一个最小可运行版本
4.1 核心数据结构:Query 类的设计
为方便复现,我把核心抽象简化成了一个 Query<T> 类,整个编译过程就发生在这个类的方法调用链上。T 是泛型参数,表示"查询结果的每一行的类型"。
Query 类的核心字段和构造方式如下:
typescript复制type Query<T> = {
// 内部 IR 节点,由 toIR 方法构造
_ir: RelExpr;
// 泛型信息,用于类型推导时获取列名和类型
_typeMap: Record<string, string>;
};
TS 的类型系统在这里会做一层类型体操,但实际运行时的核心就是 _ir 这个关系代数表达式树。每个 DSL 方法(filter, map, join)都会返回一个新的 Query 对象,内部构造一个新的 IR 节点,同时更新 _typeMap。
这个设计跟函数式编程里 Immutable 数据结构的思路一致:每次操作不修改原对象,而是返回一个新对象。这样整个查询构建过程天然线程安全,也可以自由地缓存中间查询对象。
4.2 从过滤查询到 SQL:一个最小可用示例
我们来跑通一个最简单的场景:从 customers 表中查询所有来自"华东"地区的客户姓名。
用语言来描述是这样的:
typescript复制const customers = table('customers', {
id: 'number',
name: 'string',
region: 'string',
});
const result = customers
.filter(c => c.region === '华东')
.map(c => ({ customerName: c.name }));
const sql = compileToSql(result, 'postgres');
console.log(sql);
// 期望输出:
// SELECT c.name AS customer_name
// FROM customers AS c
// WHERE c.region = $1
这里的 compileToSql 是核心入口,它接收一个 Query 对象和目标方言,经过"IR 归约 -> 优化 -> SQL 生成"三步,返回一条参数化 SQL。
第一次实现时,我直接把用户提供的回调函数(比如 c => c.region === '华东')在运行时执行,拿到一个布尔值,再把值嵌入 SQL——这样是行不通的。原因很简单:你拿到的只是结果(true 或 false),而不是"怎么算出这个结果"的过程。所以必须把回调函数转换成"表达式描述",也就是在函数体内访问 c.region 时,不是真的取数据库的值,而是返回一个 AST 节点。
这里用到了一个非常经典的技巧:在 Query 对象的属性访问器里做手脚。TS 的 Proxy 对象可以拦截属性访问,当我执行 c.region 时,Proxy 的 get 陷阱会返回一个占位符对象,它记录了"我正在访问 region 字段"。同样,运算符 === 可以通过重写 valueOf 或 Symbol.toPrimitive 来拦截,从而构造出 "c.region = '华东'" 这样的表达式树。
听起来很绕,但用起来效果惊艳——用户完全感觉不到自己写的是"表达式构造",就像在写普通 JS 条件一样。
4.3 关联查询与聚合查询的实际编译过程
下面展示一个更完整的示例,涉及 JOIN 和 GROUP BY。假设我们要查询"每个地区的客户订单总额",并按地区排序。
typescript复制const customers = table('customers', { id: 'number', name: 'string', region: 'string' });
const orders = table('orders', { id: 'number', customerId: 'number', total: 'number' });
const result = customers
.join(orders, (c, o) => c.id === o.customerId)
.groupBy(row => [row.region], {
totalAmount: rows => rows.sum(row => row.total),
})
.orderBy(row => row.region);
const sql = compileToSql(result, 'mysql');
这条查询的期望 SQL 是:
sql复制SELECT c.region, SUM(o.total) AS total_amount
FROM customers AS c
JOIN orders AS o ON c.id = o.customer_id
GROUP BY c.region
ORDER BY c.region;
实际编译过程中,会发生几件事:
join方法构造了一个JoinIR 节点,记录左表和右表及其连接条件;groupBy方法检查region字段在当前的字段集合中存在,并且聚合函数sum(row => row.total)的输入total是数字类型;orderBy在 IR 上追加排序节点,并做一次合法性检查——排序字段必须在 SELECT 列表中或者是分组键(MySQL 如果不满足这个条件,会把报错延迟到执行期);- 后端翻译为 MySQL 方言,字段名使用反引号转义,别名的命名采用 snake_case 风格。
我在调试这个流程时发现,groupBy 的类型约束特别容易绕晕人——row.region 和 row.name 在类型层面都是字符串,但只有 region 能做分组键。所以我在类型层面加了额外标记:当用户执行 flatMap 或 map 返回对象时,编译器会区分"普通列"和"分组列",分组列的取值来自 GroupBy 的 keys 数组。做了这个区分之后,很多奇怪的错误就能在编译期被拦截。
4.4 单测设计与对照验证:如何确保生成的 SQL 正确
编译器的正确性验证,靠人肉看 SQL 是不行。我采用了大名鼎鼎的 Golden Test 方案:为每个测试用例准备两份文件——输入 DSL 代码、期望生成的 SQL。运行测试时,编译器的输出必须和期望 SQL 完全一致,否则测试失败。
这种测试方式最大的优点是非常直观:一眼就能看出生成的 SQL 是否符合预期。比如这个测试用例:
typescript复制it('should push filter before join', () => {
const sql = compileToSql(
customers
.join(orders, (c, o) => c.id === o.customerId)
.filter((c, o) => c.region === '华东' && o.total > 100),
'postgres'
);
expect(sql.sql).toBe(
`SELECT c.id, c.name, c.region, o.id, o.customer_id, o.total\n` +
`FROM customers AS c\n` +
`JOIN orders AS o ON c.id = o.customer_id\n` +
`WHERE (c.region = $1) AND (o.total > $2)`
);
});
写测试时你会发现,很多错误是"优化规则引起的边界情况"。比如谓词下推时,如果 JOIN 条件是 c.id = o.customerId,过滤条件只涉及 c.region,优化器把它下推到左子树后,生成的 SQL 应该变成:
sql复制SELECT ...
FROM (SELECT * FROM customers AS c WHERE c.region = $1) AS c
JOIN orders AS o ON c.id = o.customer_id
注意这里发生了一个子查询包裹。如果不加这个包裹,而直接将 WHERE c.region = ... 放到 JOIN 后面,那么它也能达到同样的效果,但执行计划可能不一样。我最后实现时选择"下推到 FROM 子查询",因为这样可以保证谓词一定是在 JOIN 之前执行的,不会依赖数据库执行器的优化能力。
这类测试案例积累了大约 300 个,覆盖了基本的映射、过滤、连接、聚合、排序、分页、子查询,以及各种优化规则触发后的 SQL 形态。可以说,项目能走到稳定运行,很大程度上归功于这些回归测试。
5. 常见问题与排查技巧实录
5.1 生成的 SQL 正确但执行很慢,问题出在哪
这是我在项目分享中被人问得最多的问题之一。用户写了一个查询,生成的 SQL 语法正确、逻辑正确,但放到 MySQL 里一跑就是十几秒,比手写 SQL 慢了一个数量级。
分析下来,最常见的原因是优化器只做了谓词下推,没有做投影消除。我们来看一个场景:用户先 flatMap 了多张表,然后又选了某些列。在 IR 层面,每个中间算子可能都保留了表的所有列。翻译成 SQL 后就是 SELECT c.id, c.name, c.region, o.id, o.customer_id, o.total ...,但实际只用了其中两列。这种"全列扫描"会让数据库读取大量不必要的列,一旦表的列很多、行数很大,性能就会急剧下降。
解决方式就是我在前面提到的投影消除优化。做法是:从查询的最上端开始,反向遍历 IR 树,为每一个算子计算"哪些列是上层需要的"。然后删除那些既没有被上层引用、也不是算子自身必须保留的列。这个优化完成后,生成的 SQL 里每个 SELECT 列表都只会出现真正需要的列。
另一个常见性能问题是子查询嵌套太深。用户为了表达方便,把多个 flatMap 串联起来,每一个都生成了一层子查询:
sql复制SELECT t1.*
FROM (
SELECT t2.*
FROM (
SELECT ...
FROM customers AS c
JOIN orders AS o ON ...
) AS t2
...
) AS t1
嵌套子查询如果不去关联,数据库优化器一般在执行计划层也能把它展平。但也有些数据库优化器比较弱,比如某些 MySQL 版本,子查询嵌套层级深了会直接产生额外临时表开销。我这边做了一个"子查询展开"优化,当两层子查询之间没有相关性限制(即内层不依赖外层)时,就把内层子查询的内容合并到外层,消除一层嵌套。这个优化在 MySQL 低版本上实测有显著收益。
5.2 类型推导失败:为什么编译期总是提示字段不存在
类型检查是最容易让使用者感到困惑的部分。最典型的报错是"字段不存在",但用户明明看到表里是有这个字段的。
我排查了多次后发现,大部分情况是因为用户把两个表的列名搞混了。比如:
typescript复制const result = customers
.join(orders, (c, o) => c.id === o.customerId)
.filter((c, o) => o.customerName === '张三'); // 错误:customerName 在 customers 表
这个错误在手写 SQL 时同样会犯,但手写 SQL 的报错要到运行期才能发现——如果数据库恰好没有 customerName 列,就会抛错;如果同名字段恰好存在(比如两个表都有 name),那结果就完全错了,而且很难排查。
我这边为了尽早暴露这种错误,有一个"字段来源追踪表":每个字段在编译期都记录它来自哪张表。当用户写了一个字段比较表达式时,编译器会检查左右两边的字段是不是来自同一类型兼容的源,如果不兼容就报错。这个特性在很多商业数据库工具里都不一定有,反而是我们这套编译器最大的卖点之一。
但这也带来一个新问题:字段名冲突。如果两张表都有 id 字段,用户在 map 里写 row.id 时,编译器不知道该取哪张表的 id。我的处理方式是:在 map 和 filter 里,默认取第一个(左表)的同名字段,如果需要取右表的字段,必须显式写 row.orders.id 这样的全限定名。这在文档里写得很清楚,但用户经常记不住,遇到"字段不存在"的第一反应就是骂编译器不智能。所以我在报错信息里做了特殊优化:当检测到用户访问的字段名存在但不在当前作用域时,会提示"是否拼写错误?或者该字段属于右表,请使用全限定名"。
5.3 处理 NULL 语义差异:同一个查询在不同数据库结果不一致
NULL 是 SQL 世界里最著名的坑,而在我们这种"把查询意图翻译成 SQL"的场景里,NULL 语义的差异会被放大。同一个 DSL 查询,在 PostgreSQL 和 MySQL 里跑出的结果可能完全不同。
最经典的案例是 NOT IN 和 NOT EXISTS。SQL 标准规定 NOT IN (SELECT ...) 当子查询返回 NULL 时,整个表达式的结果是"未知",即不会返回任何行。而 NOT EXISTS 没有这个问题。
我的编译器在把 DSL 中的"不在这个集合里"翻译成 SQL 时,如果直接翻译成 NOT IN,就会踩到 NULL 的坑。所以编译器会做一步语义修正:当检测到 NOT IN 子查询的 SELECT 列可能是 NULL 时,自动改写为 NOT EXISTS。这个改写逻辑跟数据库无关,是完全在 IR 层面实现的,因此可以保证在不同数据库上得到一致的语义。
类似的问题还有聚合函数对 NULL 的处理:SUM 会忽略 NULL,COUNT(*) 不会忽略 NULL 行,而 COUNT(col) 会忽略 NULL。这些细节在用户写聚合表达式时未必意识到,编译器最好帮用户处理好。我的方案是为聚合函数定义精确的语义规则,翻译成 SQL 时根据目标数据库的特性补充 COALESCE 或调整表达式结构,确保语义统一。
5.4 调试技巧:如何把编译中间产物打印出来
最后分享一个实用技巧:中间产物可视化。编译器是个黑盒时,出了问题你只能对着源代码和输出 SQL 猜。但大部分问题都出在 IR 优化阶段——优化规则改动了树的结构,输出 SQL 不符合预期,到底是哪条规则搞错了吗?
我写了一组调试工具:
dumpIR(ir):以缩进树的形式打印关系代数表达式树,每个节点显示算子类型和涉及的字段;dumpOptimizedIR(ir):打印优化后的 IR,方便和原始 IR 做 diff;explain(sql):把生成的 SQL 发送到数据库执行EXPLAIN,把执行计划拉回来。
使用这个工具链的典型流程是:
- 用户写一段 DSL;
- 编译后生成的 SQL 不符合预期;
- 打开
dumpIR看最初的 IR 是否正确; - 如果原始 IR 正确,再开
dumpOptimizedIR看是哪条优化规则改坏了; - 定位到具体的优化规则后,单测输入和输出,修复规则实现。
有了这套工具,调试效率能提升一个数量级。特别是面对复杂嵌套查询时,肉眼是不可能"心算"出优化前后的 IR 差异的,只有机器能帮你看清。
5.5 实测总结:哪些优化在真实数据库上有效,哪些无效
项目做到后期,我把每一个优化规则都拿到不同数据库上做了实际测试,结论很有意思:
谓词下推:在 PostgreSQL 和 MySQL 上都有收益,尤其是多表 JOIN 场景,收益非常明显。
投影消除:在列数很多、宽度很大的表上收益显著;在列少、行数大的表上收益有限。
子查询展平:在 MySQL 8.0 以下收益比较大,因为老版本对子查询的优化很差;PostgreSQL 几乎不需要手动展平,它自己的优化器已经能处理得很好。
半连接改写(把 IN 改成 EXISTS):在 PostgreSQL 和 MySQL 上都有效,因为这两种数据库执行器对 EXISTS 的优化更成熟。但在某些列式存储数据库上,IN 和 EXISTS 的执行计划几乎没区别,改写反而增加了复杂度,收益不大。
这让我意识到一个非常重要的原则:优化规则不能"一刀切",每一种优化都应该和目标数据库的特性绑定。我的优化器里有一个"优化器配置"模块,根据目标数据库类型动态决定启用哪些优化规则。这样做的好处是:换了数据库,不需要改业务代码,只需要调整优化器配置,就能获得最佳的 SQL 形态。
6. 个人体会与下一步扩展
这个项目从最初的一个实验性想法,到最终能够稳定地处理几百个真实查询场景,过程相当有收获。我自己最大的感受是:范畴论的抽象能力在数据库查询领域确实有实际价值,但它最大的价值不在于让你写代码变得更"数学化",而在于给你一个强大的组合框架,让复杂的查询可以被拆成小模块自由组装。同时,编译器可以在组装过程中做各种安全检查和等价变换,这是手写 SQL 完全做不到的。
如果你也想复刻一个类似的项目,我给的建议是:不要一开始就想做全。先做最简单的 filter 和 map,跑通整个编译链路——从表达式识别到 IR 生成到 SQL 输出。先把这条链路打通,再逐步加入 join、groupBy、flatMap 和优化规则。每次加一个特性,就配套写两三个 golden test,防止回归。这样迭代下来,项目的复杂度虽然会不断上升,但始终是可控的。
此外,有一个非常有价值的扩展方向是查询自动缓存。因为编译器是结构化的 IR,完全可以在 IR 层面计算查询的哈希,然后对相同的查询复用结果集,省掉重复查询数据库的开销。这个特性对报表系统尤其友好。
最后再分享一个小技巧:在我整个实现过程中,最让我头疼的坑是 TS 运行时无法准确推断"表达式结构"。一开始我尝试用函数重载来让类型系统自动推导字段类型,但总是卡在复杂泛型上。后来我干脆退了一步,在运行时通过 Proxy 来记录字段访问,再用 TS 的类型断言配合辅助类型来约束。这个"退一步"的决定,让整个项目从"卡着不动"变成了"顺利推进"。所以在做编译器这类项目时,不要追求类型层面 100% 的完美,先把功能跑通,再逐步完善类型体验,这样的迭代策略更现实,也更高效。
