我们如何为 DynamoDB 构建一个本地 SQL 引擎(一个没有 JOIN 的数据库)
DynamoDB 能导出一张 100 GB 的表,却无法对它做聚合。没有 JOIN、没有 GROUP BY、没有 COUNT(*) —— 不是它们的慢速版本,而是_压根没有_ —— 就连 ,AWS 自己那个类 SQL 的表面,也没有把它们加进来。于是每个团队最终都会写下同样一个用完即弃的脚本:对一次 分页、把行折叠进一个映射、打印出答案、删掉脚本。
DynoTable 的 工作台就是我们对那个脚本的回应:一个标签页,你在其中写下一条真正的 SELECT —— 多表 JOIN … ON、WHERE、GROUP BY、HAVING、DISTINCT、CASE、聚合 —— 针对的却是一堆完全不支持这些的表。这篇文章讲的是它在底层如何运作、那个悄无声息地欺骗了我们的解析器,以及我们后来为何拆掉了引擎的内存模型。
不要构建一个查询引擎
核心的决定是拒绝编写一个关系型执行器。DynamoDB 的分页流式送入一个内嵌的 SQLite 数据库,而 SQLite —— 地球上受测试最充分的查询引擎之一 —— 来完成关系型的工作:
每一个 DynamoDB 项都以其原始的带类型信封形式({platform: {S: "ios"}})落入 SQLite 的单个 item 列中。一个小小的用户定义函数会在查询时解开属性,因此你的 SELECT platform 会编译成一个 attr(item, 'platform', …) 调用。那个函数里有一个承重的细节:它把自己的 JSON 解析包在一个 catch 里,因为 SQLite 绑定会把一行格式错误的行的解析错误变成对_整条_预备语句的中止。没有这个 catch,一行损坏的行就会让整条查询崩掉。
我们保留的那个诚实的约束:DynamoDB 的访问模式物理法则依然适用。 一次 JOIN 的目标侧必须是一个主键或一个 分区键 —— 引擎通过对数据流做键查找来解析连接,绝不通过笛卡尔积扫描。如果你需要在非键属性上做任意连接,那是一场数据建模的对话,而不是查询引擎的一项功能。

那个欺骗了我们的解析器
第一个版本使用了一个流行的现成 SQL 解析器。在几周之内,有四个各自独立的正确性 bug 都追溯到了它身上:
SELECT DISTINCT被悄无声息地丢弃了。 解析器接受了它,而发射器忽略了它 —— 每一行重复的行都回来了。一位用户的 bug 报告写道:“显示的是一张列出所有值的表,而不是计数。”- 任何未被处理的东西都变成了字面的 SQL
NULL。CASE、CAST、标量子查询 —— 发射器对一个未知语法节点的兜底做法是return 'NULL'。查询照样运行、自信地返回错误答案,且不抛出任何错误。 - 标识符大小写的行为不像 SQL。 当属性是
platform时,SELECT PLATFORM FROM t返回的是 null —— 与每一个 SQL 数据库训练你去预期的恰好相反。 - 自动补全与解析器意见相左,会建议一些编译器随后无法解析的名称。
那个替代者,sql-parser-cst,凭借一项我们在调研中别处再未找到的基础能力胜出:它的语法树同时保留了每个标识符的原始文本和规范化名称 —— 因此编译器能分辨 platform 与 "PLATFORM"。这一个区分就让一套语法得以承载正确的 ANSI 语义:不带引号的标识符按大小写不敏感来解析,带引号的则是大小写敏感的逃生出口,与 Postgres 或 SQLite 完全一样。它还在每个节点上携带一个源码范围,因此诊断会在出问题的构造下画线,而不是整条语句。
而我们把那个 return 'NULL' 兜底替换成了一条如今被我们当作政策来对待的规则:永远不要有悄无声息的 NULL。 发射器的默认分支是一个带精确范围的错误。窗口函数会得到一条说明_为什么_、而不只是说“不行”的消息:
Window functions are not supported in Workbench.
They have undefined semantics on the joined-row materialization.我们接受的取舍:一个 1.0 之前的依赖,被钉死在确切的版本上,只由人手工升级,并以测试套件作为关卡。我们评估过给旧解析器打补丁(它需要一个并行的预分词器 —— 把每个输入解析两遍)、使用编辑器的语法树(token 级别,没有子句结构),以及手工自造(相当于一个 SQLite 解析器的大部分,维护成本无界)。一次四重的正确性失败,压倒了上述所有这些顾虑。
知道什么不该拦截
有一条诊断刻意_不会_阻拦你。当一个过滤器引用了一个 schema 无法确认其大小写的属性时,我们无从知晓其规范的大小写 —— DynamoDB 的服务端过滤器是大小写敏感的,而它的 schema 只声明键属性。这会发出一条警告,而警告绝不会禁用运行。我们在自己的校验器工作中吃过苦头才学到了这一类教训:一个会拒绝最常见的合法查询形态的关卡,比没有关卡还糟。
那个上限的形状是错的
第一个聚合引擎把扫描到的行缓冲进一个内存中的 SQLite,带有一个大小上限 —— 这里 64 MB、那里 250 MB,还有一条完全没有上限、会在大表上把应用 OOM 掉的路径。撞上上限,你就会得到一个带溢出标志的部分答案。
这次重新设计始于一次重新定义:竞争对手是一个开发者本会转而写下的用完即弃的脚本。 而一个称职的脚本不会先缓冲再设上限 —— 它会_流式处理_,用有界的内存把每一页折叠进一个累计总和。那个内存上限并不是太小;它的形状就是错的。更糟的是,一个部分聚合结果不是“不完整” —— 它是错的。让这一点变得具体的那个瞬间:我们自己的 AI 演示曾自信地把一份部分的“消费最高者”排名当作事实娓娓道来。
重建后的 Compute 引擎以脚本的方式运作,外加脚本从不费心去做的一切:
- 一个规划器会把每一条查询分类到三条通道之一:流式折叠(stream-fold)(朴素的代数聚合 ——
COUNT/SUM/AVG/MIN/MAX—— 在主进程之外逐页折叠;大小从不成为可折叠查询的关卡,只是会更耗时)、物化(materialize)(排序、有键约束的连接 —— 溢写到基于磁盘的 SQLite,没有内存上限),或者一次诚实的_拒绝_,为我们无法服务的那部分长尾推荐正确的重量级工具(DynamoDB 的 S3 导出加上 Athena)。 - 折叠分类是刻意收窄的。 一个
HAVING、一个ORDER BY、聚合旁边一个裸列 —— 其中任何一个都会强制走物化通道。一个忽略了某个子句却仍报告“精确”的折叠,恰恰就是这个规划器存在的意义所在——它要防的那种既错又自信的 bug。 - 精确性是结果的一部分。 在分页仍在流式传输时,聚合列会显示一个_部分(partial)_徽章,只有当扫描抽干后才会撤下它。就连算术上的诚实也被追踪着:整数求和在超过 2⁵³ 之后仍保持精确(以 64 位整数存储),而一个超出浮点尾数的小数求和会把自己标记为
partial: precision,而不是悄悄地四舍五入。 - 对机器而言,用保险丝取代弹窗。 交互式应用会在一次五百万项的扫描之前征询;而 AI 助手和 MCP 调用方得到的则是扫描预算和分页边界中止。而且模型所看到的结果信封是刻意精简过的 —— 成本等级和大小估算都被扣下不给,因为把一个陈旧的猜测摆出来,会诱使模型把它当作事实娓娓道来。
有一条成本方面的说明在内部让人意外:对于一次性的聚合,一次实时扫描比生成一次 DynamoDB→S3 导出大约便宜 6 倍 —— 这正是为什么脚本形态的通道是默认选项,而数据仓库是针对反复进行的重度分析的推荐做法,而非条件反射。(定价计算器会为你自己那张表的一次完整遍历计价。)
把 SQL 指向生产环境安全吗?
工作台在结构上是只读的。抵达本地引擎的最终 SQL 会通过一道纵深防御关卡,它会分词并拒绝任何不是单条 SELECT 的东西 —— 作为一次_契约违背_,而非用户错误,因为编译器本就绝不应该产生它。SQLite 底座在运行时禁用了危险的内置函数,而对 DynamoDB 的写入在整个应用里只有唯一一条路径:那个可审查的暂存区,SQL 无法触及它。
如果你正在构建一个这样的引擎,有哪些经验可以迁移
- 不要编写一个查询引擎;要编写一个忠实地对接某个已经存在的引擎的_编译器_。你的 bug 会藏在接缝处(标识符大小写、类型信封、损坏的行),而不是连接算法里。
- 向你的解析器索要引号出处。如果它无法分辨
name与"NAME",你就无法实现 SQL 的大小写语义,而你的用户会比你更早发现这一点。 - 让“未被处理的语法”成为一个大声的错误,绝不要成为一个默认值。悄无声息的
NULL正是自信的错误答案得以交付的途径。 - 拿你的设计去对标你的用户本会写下的那个用完即弃的脚本。如果脚本是流式的而你是缓冲的,那你就构建成了错误的形状。
- 把部分聚合当作需要贴标签的错误答案,而不是给一半分数。
工作台文档介绍了日常的功能表面,而 DynamoDB 的 SQL 用法则梳理了在整个生态系统中哪些是行得通的。或者干脆下载 DynoTable,用 ⌘⌥Q 打开一个工作台标签页,然后针对那个本身并不具备 JOIN 的数据库运行一次 JOIN。


