在现代 SaaS 架构中,为租户提供数据分析能力是一个常见需求。Trigger.dev 最近推出的 Query & Dashboards 功能允许用户直接编写 SQL 查询来分析任务运行数据,但其底层架构面临一个核心挑战:如何在共享的 ClickHouse 集群上安全地给予所有用户直接 SQL 访问权限,同时保证租户之间的数据隔离。Trigger.dev 的解决方案是构建一套名为 TRQL(Trigger Query Language)的领域特定语言,通过编译时强制执行安全策略来从根本上消除数据泄露风险。
为什么需要自定义查询语言
直接向用户暴露原生 ClickHouse SQL 看似是最简单的方案,但实际上存在三个关键问题无法通过简单的验证层来解决。首先,语言本身就是一个安全边界。如果允许用户执行原始 SQL,那么 INSERT、UPDATE、DELETE、DROP 等危险操作即使被验证规则拦截,也只是被动防御。TRQL 的做法是从语法层面直接排除这些操作 —— 如果删除命令根本不存在于语言定义中,那么解析器物理上就无法产生对应的抽象语法树(AST)节点,这是一种本质性的安全设计而非事后防御。
其次,租户隔离必须由编译器强制执行,不能依赖用户的自觉性。在多租户系统中,每个查询都必须在组织级别受限。如果依赖用户在查询中手动添加 WHERE organization_id = '...' 条件,那么一个疏忽就可能导致跨租户数据泄露。TRQL 在编译阶段自动注入这些过滤条件,用户无法选择跳过。这种机制确保了租户边界由系统保证而非用户承诺。
第三,内部数据库细节应该对用户透明。ClickHouse 中的表名可能是 trigger_dev.task_runs_v2,列名可能是 cost_in_cents 或 base_cost_in_cents,这些实现细节不应该暴露给终端用户。TRQL 允许用户编写 SELECT total_cost FROM runs,而编译器负责将其转换为底层的 ClickHouse 查询。此外,TRQL 还实现了 ClickHouse 原生不支持的功能特性,如虚拟列、自动时间分桶和值转换等。
编译管道的完整流程
TRQL 的编译管道包含七个明确的阶段,每个阶段都对查询进行特定的转换和验证。解析阶段使用 ANTLR4 将用户输入的 TRQL 查询文本转换为抽象语法树。由于语法定义是 SQL 的严格子集,只有 SELECT、FROM、WHERE、GROUP BY、ORDER BY、LIMIT 等标准操作可以被解析,任何不在语法定义中的命令都会在解析阶段直接失败。
第二个阶段是模式验证。编译器遍历 AST 并对照表模式进行检查:引用的表是否存在、所有列名是否在该表中有效、函数调用是否被允许、参数类型是否匹配。例如,如果用户写 WHERE status = 123 但 status 列是字符串类型且有预定义允许值,此阶段就会捕获错误。这种验证确保了查询在语义层面也是合法的。
租户隔离是第三阶段的核心。编译器自动向 WHERE 子句注入租户特定的过滤条件,至少会添加 organization_id 过滤,根据查询范围还可能添加 project_id 和 environment_id 过滤。这些条件被直接注入到 AST 结构中,在生成 SQL 之前就已经成为查询的一部分。这一步消除了跨租户数据访问的任何可能性。
第四个阶段添加时间限制以防止无界限的全表扫描。没有时间限制的情况下,SELECT * FROM runs 会尝试扫描整个表的历史记录,这不仅性能低下,还可能返回过期的历史数据。根据不同的订阅计划,Trigger.dev Cloud 允许不同的最大时间查询范围。
第五阶段是参数化处理。所有字面量值(字符串、数字、日期)都从 AST 中提取出来,替换为命名参数如 {tsql_val_0: String},实际值作为独立参数传递给 ClickHouse,而不是拼接到 SQL 字符串中。结合解析阶段的语法限制,这意味着生成的 ClickHouse SQL 在结构上永远是安全的,完全消除了 SQL 注入的可能性。
第六阶段是生成最终的 ClickHouse SQL。转换后的 AST 被打印为 ClickHouse 兼容的 SQL,在这个过程中虚拟列被展开为真实表达式、表名被翻译、TRQL 特定函数被转换为 ClickHouse 等价物。最后,生成的 SQL 以只读模式在只读副本上执行,避免影响写入性能。
租户隔离的实现细节
TRQL 的租户隔离机制是其安全设计的核心。以一个具体查询为例,用户编写的 TRQL 是 SELECT task_identifier, SUM(total_cost) AS cost FROM runs GROUP BY task_identifier,而生成的 ClickHouse SQL 包含了自动注入的租户过滤条件。在生成的 SQL 中可以看到 equals(runs.organization_id, {tsql_val_0: String}) 这样的条件被直接编译进查询结构中,用户根本无法绕过这个过滤。
这种隔离方式的优势在于它不依赖用户行为。即使查询中已经存在其他 WHERE 条件,租户过滤条件也会通过 AST 转换被安全地合并进去,确保永远只有一个结果集 —— 当前租户的数据。没有办法通过任何查询技巧访问其他组织的数据,因为过滤是在编译时强制注入的。
除了租户隔离,Trigger.dev 还对查询资源设置了多重限制。每个组织的并发查询数量受到限制,防止单个租户运行昂贵查询时独占集群资源影响其他用户。结果集上限为 10,000 行,实际请求 10,001 行以便向用户提示是否存在更多结果。每个查询都有内存使用限制、Wall-clock 时间限制和 AST 复杂度限制,其中 AST 复杂度限制可以防止深度嵌套的子查询产生指数级执行计划。
模式系统的设计哲学
TRQL 的模式定义是其强大能力的来源。每个表都被定义为 TypeScript 对象,不仅描述列,还定义了如何翻译、验证和渲染数据。虚拟列是最有价值的特性之一 —— 某些最有用的列在 ClickHouse 中根本不存在的,它们是编译器在生成查询时展开的表达式。
以 total_cost 为例,它在 ClickHouse 中实际存储为两个独立的整数列:cost_in_cents(计算成本)和 base_cost_in_cents(调用成本)。模式定义 total_cost 为表达式 (cost_in_cents + base_cost_in_cents) / 100.0,当用户编写 SELECT total_cost FROM runs 时,TRQL 会自动展开为对应的计算表达式。用户看到的是清晰的美元金额,无需知道内部的存储格式。
值转换是另一个关键特性。运行 ID 在 ClickHouse 中存储时没有前缀,但用户期望编写 WHERE run_id = 'run_cm1a2b3c4d5e6f7g8h9i'。模式中的 whereTransform 函数在值进入 ClickHouse 查询前剥离 run_ 前缀,而 expression 则在读取时添加前缀。这种双向转换对用户完全透明。
自定义函数 timeBucket() 根据查询的时间范围自动选择合适的间隔大小。查询最近一小时会获得 10 秒的桶,而同一查询跨度 30 天会自动切换到每小时桶。用户无需考虑粒度问题,系统会自动平衡细节与性能,同时防止返回数百万行数据。
工程实践与运维考量
在实际部署中,Trigger.dev 将查询路由到专用的只读副本执行,避免读取操作影响写入性能。编辑器采用双解析器架构:Lezer 用于即时语法高亮,ANTLR4 用于完整的 linting 和验证,两者结合提供流畅的交互体验同时保证正确性。自动补全完全由模式驱动,在 FROM 后显示表名,在 SELECT 后显示列名,在比较操作符后显示允许值。
这种设计代表了一种构建多租户分析平台的范式:通过自定义查询语言在编译器层面强制执行安全策略,而不是依赖运行时验证。TRQL 的方法本质上是将安全内嵌到查询处理管道中,使得不安全查询在技术上根本无法产生。
参考资料:How we give every user SQL access to a shared ClickHouse cluster(Trigger.dev 官方博客)