【架构篇】双遍历 + 多栈协同:手撸 SQL 血缘引擎的架构思考

0 阅读7分钟

前言

在上一篇文章中,我们展示了 Hermes-SQL 最终的解析成果。但你可能不知道,刚接手 ANTLR4 的第一个星期,我差点因为一个极其反直觉的机制而放弃。

今天这篇文章,我将毫无保留地分享我在构建这套引擎时的核心架构思考:它是如何从最初一塌糊涂的状态机,一步步演进为双遍历 + 多栈协同的精密架构的。

💡开源项目地址::Hermes-SQL

ANTLR4 解析原理

在我们接着往下讲之前,我们先简单科普一下,ANTLR4的解析原理。

其本质就是根据对应的.g4文件即语法文件和词法文件,将输入的SQL语句一步步拆解、搭建为一棵语法树(parser tree)。

因为真实的语法文件和词法文件都相当的复杂,为了方便演示,我自己简单手写了一个语法-词法文件:

其可以对简单的SQL语句进行解析,比如SQL语句:select id,name,sex from student;,它最终对应的语法树,如下图所示:

image.png

拿到语法树后,ANTLR4 提供了两种深度遍历机制:Listener 和 Visitor。我们可以在遍历进入(enter)或退出(exit)对应节点时,书写业务代码来实现解析目的。

AST 遍历的第一个“下马威”

image.png

ANTLR4 的遍历默认是从左到右、深度优先的

在 SQL 语法中,SELECT 通常写在 FROM 前面。因此,ANTLR4 会先触发 SELECT 的解析,后触发 FROM 的解析。

这就带来一个致命的业务逻辑冲突:如果还没解析到 FROM 里的表,我怎么知道 SELECT 里的 id 是哪张表的字段呢?

按照传统思维,我开始设置状态标识(状态机),通过状态标识来确定解析到哪一步了,从而采用不同的处理策略,比如用一个全局变量 enterFrom,解析 FROM 时置为 true,解析 SELECT 时置为 false。

image.png

// 曾经的错误写法:靠全局状态机硬抗
int enterFrom = 0; // 0:未进入, 1:已进入
public void enterSelectItem(ctx) {
    if (enterFrom == 1) {
        // 如果是子查询嵌套,这里的 enterFrom 到底该重置还是保持?
        // 一旦嵌套超过2层,这里直接乱套!
    }
}

这种策略对于简单的单表 SQL 而言没问题。但是一旦遇到复杂 SQL,嵌套子查询一多,全局变量直接乱套,状态标识的控制复杂度呈指数级上升,稍不注意就会全线报错。

而在真实的金融生产环境下,嵌套 5-6 层子查询的 SQL 十分普遍。因此,这种靠“状态机”硬扛的策略,应对企业级真实场景时,往往是心有余而力不足。

破局之道:双遍历架构 (Dual-pass Architecture)

架构总览

既然一条路走不通,那就换一条。既然一次遍历处理不完,那我们就遍历两次!

因此,我找到了破局之道——双遍历架构。将“建表”和“查血缘”彻底分开:

  • 第一阶段(Pass 1):ScopeBuilderListener。不解析任何 SELECT 字段,只盯着 FROM、JOIN、子查询、UDTF 等数据源节点,把所有的表、别名、层级关系全部建好,存入 Scope(作用域)中。
  • 第二阶段(Pass 2):SqlAnalyzerListener。开始解析 SELECT 列。此时直接利用第一阶段建好的 Scope,查表如探囊取物,顺藤摸瓜。
graph TD
A[输入SQL] --> B[Parser生成AST抽象语法树] 
B --> C[Pass 1: ScopeBuilderListener 构建 Scope作用域]
C --> D[Pass 2: SqlAnalyzerListener 提取字段血缘]          

架构的桥梁:为什么是 IdentityHashMap

前后两次遍历,本质上遍历的是同一棵 AST 树。那么中间怎么传数据呢?即怎么将第一次遍历建好的 Scope,精准传递给第二次遍历对应的节点?

这里引出了架构的桥梁——IdentityHashMap。

我们通过将SQL信息与对应的AST节点(ParserRuleContext)进行一对一绑定。

为什么不用普通的 HashMap?* 因为 ParserRuleContext 并没有重写 equals 和 hashCode 方法,它默认使用对象的内存地址进行比对。IdentityHashMap 恰好是基于内存地址(==)来判断 Key 的唯一性的。

这样,即使两次遍历的时机不同,只要指向的是同一个 AST 节点内存地址,就能安全、高效地取出第一阶段存进去的数据。

// 核心桥梁代码示例
Map<ParserRuleContext, SqlInfo> sqlInfoMap = new IdentityHashMap<>();

// Pass 1: 存入
sqlInfoMap.put(ctx, sqlInfo);

// Pass 2: 取出
SqlInfo targetInfo = sqlInfoMap.get(ctx);

神兵利器:多栈协同管理 (Multi-stack)

结合上面的信息,我们知道要处理的有:子查询嵌套,函数嵌套,每个语句的select 查询列,数据源,scope作用域等。

对于这些内容,最早我是采用list列表+状态标识(状态机) 来处理的,但是随着SQL复杂度的增加,这些开始变得越来越力不从心了。

list并不能严格控制数据的读取顺序,而状态标识(状态机) 在复杂场景下显得捉襟见肘,无法进行统一的管理。

对此,我的选择是 栈(Deque),通过多个栈来协调管理,利用栈先进后出的特性来严格保证数据读取顺序,应对复杂的嵌套场景:

  • sqlInfoStack:管理 SQL 嵌套层级。遇到一个子查询,入栈,子查询解析完,出栈。
  • scopeStack:作用域栈。内层子查询的 id 和外层表的 id 互不干扰。
  • selectItemStack:查询列栈。处理单个查询列,特别是列级子查询(比如 SELECT (SELECT MAX(x)))。
  • functionStack:函数栈。专门处理嵌套函数,比如UPPER(SUBSTR(name)),精准关联父子函数。
时序步骤sqlInfoStackscopeStackselectItemStackfunctionStack
1. 初始化主查询主查询scope_t1空空
2. push (id 列)主查询scope_t1item_id空
3. pop (id 列)主查询scope_t1空空
4. push (复合列)主查询scope_t1item_func_col空
5. push (UPPER)主查询scope_t1item_func_colUPPER
6. push (SUBSTR)主查询scope_t1item_func_colUPPER,SUBSTR
7. push (子查询 t2)主查询,子查询_t2scope_t1,scope_t2item_func_colUPPER,SUBSTR
8. pop (子查询 t2)主查询scope_t1item_func_colUPPER,SUBSTR
9. pop (SUBSTR)主查询scope_t1item_func_colUPPER
10. pop (UPPER)主查询scope_t1item_func_col空
11. pop (复合列)主查询scope_t1空空
12. 解析结束空空空空

利用栈的这种 LIFO(后进先出)特性,我们彻底告别了混乱的全局状态标识,代码的鲁棒性提升了不止一个量级。

架构演进:从“单打独斗”到“门面模式”

我就在想:如果只支持一种引擎(Hive),是不足以解决我所面对的多方言迁移痛点的。我必须要对代码进行进一步封装,使其能够兼容其他引擎,形成支持多引擎的态势。

于是,我抽离出 SqlParser 接口,面向接口编程,其中parse()方法用来对不同引擎所对应的ANTLR4解析代码进行封装,getDialectName()方法是一个辅助方法,用来获取当前引擎的名字,如下面代码所示:

package org.example;

import org.example.entity.datamodel.SqlInfo;

public interface SqlParser {

    //解析 SQL 字符串,返回完整的血缘与结构信息
    SqlInfo parse(String sql);


    //获取当前引擎的方言名称(如 Hive, Spark)
    String getDialectName();
}

外部调用变得极其简单,遵循“引擎名+Sql”的类名格式,这就是设计模式(门面模式 + 策略模式)在真实业务中的完美落地:

SqlParser engine = new HiveSql(); 

SqlInfo info = engine.parse(sql);

结语与预告

本篇文章主要是陈述了我在编码过程中,一步一步地心路历程以及自己的一些思考,尽管早期踩过不少坑,但我认为这是工程落地必须经历的过程。

有了这样健壮的架构骨架之后,接下来就是往里面填充血肉。下一篇,我将深入代码细节,带大家看看 Hive SQL 当中 FROM 后面的 5 种奇葩数据源到底该怎么建作用域,敬请期待!😁