主题
SQL 跑成功、数字静默虚高:1:N join 的 fan-out 与双 1:N chasm trap 怎么治
做问数(NL2SQL)和指标平台,最怕的从来不是 SQL 报错。报错会被看见,数字错不会。
一个加性指标(SUM(金额))沿着 1:N 的 join 聚合,行数被明细放大,金额就跟着被重复计数——SQL 跑成功、结果集长得很正常、数量级也不离谱,只是比真值高。这是 BI 里最毁信任的一类 bug,业界管它叫 fan-out;它还有个更难的变体,叫 chasm trap。
「我的数据空间」(NL2SQL 能力页)在语义层上把这一族错误做成了系统性消除,而不是逐条 case 修。下面是机制、选型判据,和一组 A/B 实测数字。
一、两个静默错,一张图看懂
假设三张表:订单(事实)、订单明细(1:N,一单多件)、支付流水(1:N,一单可多次支付)。
- fan-out(单个 1:N):订单 join 明细之后,一张订单的金额在结果里出现了 N 行。此时
SUM(订单金额)变成 N 倍。只有一个 1:N 时还算好防——对订单金额先去重再聚合就行。 - chasm trap(两个 1:N):订单同时 join 明细和支付流水,两个 1:N 在扁平 join 里相乘。此时「商品总件数」被支付笔数放大、「支付总额」被明细件数放大,两个指标同时虚高,而且倍数各不相同——任何一处「先去重」的补丁都救不了另一处。
实测同一个问题「商品总件数和支付总额」:正确答案 (12, 1150),扁平三表 join 得到 (21, 2050)。
正确解法是对称聚合:每条 1:N 分支各自在独立的子查询里聚到订单粒度,再按订单键 join 回来。

二、为什么「自己写对」是个陷阱
平台原先有一个确定性的 SQL 编译器——按规则拼 SQL,不让大模型写终态 SQL。这个方向是对的,而且在能表达的形态上很扎实:单表聚合、简单 join、过滤分组、口径 FILTER、比率指标。
问题出在覆盖面:单个 1:N 的去重不难,但要把这一族搞全,要处理多事实表、对称聚合、派生与比率指标、半可加指标、多方言差异、窗口函数……每加一种形态 = 一段新 codegen + 一批新 bug,而且它们会组合。
判据不是「现在够不够用」,是「把它搞全等于重造什么」。答案是:等于重造一台成熟的语义引擎。那就该 adopt,不该自造。
三、接法:让语义引擎「只编译不执行」
选型只看四个硬条件——宽松许可 / 可自托管 / 引擎无关 / 有干净的 compile-only 端点:
| 引擎 | 形态 | 结论 |
|---|---|---|
| Cube | 独立服务 + 编译端点 | ✅ 同时满足四条:能只出 SQL 不连库,可自托管 |
| dbt MetricFlow | 库,与 dbt 建模强耦合 | 绑定太深 |
| Malloy | 语言/库 | 偏语言实验,要自己写运行壳 |
| LookML | 闭源专有 | 不可自托管 |
关键是接在哪一层。有两种接法,只有一种对:
- ❌ 让大模型直接产出引擎的查询 JSON:等于把「自然语言 → 查询意图」这一层连带交出去,已经建好的多轮澄清、few-shot 检索、取值消歧全部作废。
- ✅ 保留平台自己的查询意图契约,只替换「意图 → SQL」的编译后端:上游一行不动,新增的只是一个薄翻译器 + 一个模型适配器。
于是数据流是:自然语言 → 查询意图 → 语义引擎只编译 → SQL → 平台执行。
安全边界一点没动:语义引擎只是个编译器,鉴权、配额、引擎选路、结果落盘全部留在平台后端。这条不是设计洁癖——把它配到一个不可达的数据库上,编译端点照样出 SQL,证明它确实不碰数据;而越权查询依旧被平台的鉴权闸拒掉。


四、A/B 实测:差异恰好就是 fan-out 那几例
方法:同一套评测集,分别用外部语义引擎与原内置编译器各跑一次;生成的 SQL 与标准答案 SQL 都真跑引擎比对结果集(denotation accuracy),并确认响应里的「编译后端」标记没有静默回退。数据是真 Iceberg 表。
| 评测集 | 语义引擎 | 原内置编译器 | 差异 |
|---|---|---|---|
| fan-out 专项(3 例) | 3/3 | 1/3 | 2 例静默虚高 |
| 生产常用场景全量(22 例) | 22/22 | 18/22 | 差异恰为 4 个 fan-out 用例 |
| chasm trap(双 1:N) | (12,1150) ✓ | (21,2050) ✗ | 自动拆两个独立子查询 vs 扁平三表 join |
生产全量那 22 例覆盖:聚合(SUM / COUNT / 去重计数 / 比率 / 多指标)、分组(单维 / 多维 / 维表维)、过滤(=、IN、>、口径 filter)、时间范围、top-N、子表指标、单双 join fan-out。
两个结论同样重要:
- 引入外部引擎只在 fan-out 一族上赢;
- 但在其余 18 例上结果完全一致——零回归。
第二条才是敢默认开启的前提。一个只在最危险场景上更强、在其他场景上不改变行为的组件,才是可以设成默认的。

五、把外部编译器接成生产件,要守住五条
adopt 一个组件的成本不在接通,在接成生产件。这五条都是端到端立体测试才暴露出来的,单元测试与内存库全覆盖不到:
- 参数化 SQL 必须内联。编译端点返回的是带占位符的 SQL 加参数数组,直接拿去执行必然失败。内联时要正确转义(
O'Brien→'O''Brien'),并且跳过字符串字面量里的问号。 - 模型变更要能热加载。生产模式下改语义模型如果必须重启编译服务,这条链路在多租户平台上根本不可运营。做法是给模型集合一个版本哨兵,变更即 bump,几秒内传播;传播窗口内安全回退。
- 编译服务挂了不能让查询挂。副本缩到 0 时自动回退到内置编译器,查询照样出结果,响应里标记真实后端——降级要么保住能力,要么明确拒绝,不能静默变成别的语义。
- 非 ASCII 名字必须保唯一。中文维度名做标识符净化时,等长的中文名会塌缩成同一串下划线、互相撞名。拼原名哈希解决。
- 比率类指标别用正则切。
SUM(x) / NULLIF(y, 0)这种表达式按括号配平拆分,正则一切就切出坏 SQL。
再加一条部署纪律:钉死版本,不用 :latest。语义层是正确性组件,漂移一个小版本就可能改变生成的 SQL。
六、什么时候不需要它
这是个产品判断,不是技术判断:你的语义模型里,会不会出现「一个事实表同时按多个 1:N 子表聚合」?
- 会(多明细、订单 + 支付 + 发货、多值属性)→ 需要,它系统性消除这类静默错;
- 几乎只有单事实星型 → 给自己的编译器补上单 1:N 的去重也够用,可以不引。
别拿「当前数据量小、还没出过错」当理由。fan-out 的机制缺陷与表数量、数据量完全无关——它只跟模型里有没有 1:N 有关。而这类错误一旦发生,没有报错、没有告警、只有一个偏高的数字,通常是业务方拿着报表来对账时才被发现。
七、四句话总结
- 语义层最危险的失败模式不是 SQL 报错,是加性指标沿 1:N join 静默重复计数;两个 1:N 时(chasm trap)两个指标同时以不同倍数虚高,单点补丁救不了;
- 判断自造还是 adopt,看的是「把它搞全等于重造什么」——对称聚合手写起来是组合爆炸;
- 外部语义引擎要接在「意图 → SQL」这一层,并且只编译不执行:鉴权、配额、执行全留在平台侧,安全边界不动;
- 敢把它设成默认的前提是零回归:只在最危险的那一族上更强,其余场景结果完全一致,且挂了能自动回退。
NL2SQL 与语义建模是「我的数据空间」的一部分——一套可私有化部署的数据平台(湖仓 + 调度 + 数据治理 + 智能诊断)。看NL2SQL 能力页与核心能力总览,交流合作 QQ:1559851993。