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

前言

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

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

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

ANTLR4 解析原理

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

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

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

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

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

AST 遍历的第一个"下马威"

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

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

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

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

java 复制代码
// 曾经的错误写法:靠全局状态机硬抗
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,查表如探囊取物,顺藤摸瓜。

#mermaid-svg-g6qk5kUcPzRvtsaH{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-g6qk5kUcPzRvtsaH .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-g6qk5kUcPzRvtsaH .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-g6qk5kUcPzRvtsaH .error-icon{fill:#552222;}#mermaid-svg-g6qk5kUcPzRvtsaH .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-g6qk5kUcPzRvtsaH .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-g6qk5kUcPzRvtsaH .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-g6qk5kUcPzRvtsaH .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-g6qk5kUcPzRvtsaH .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-g6qk5kUcPzRvtsaH .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-g6qk5kUcPzRvtsaH .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-g6qk5kUcPzRvtsaH .marker{fill:#333333;stroke:#333333;}#mermaid-svg-g6qk5kUcPzRvtsaH .marker.cross{stroke:#333333;}#mermaid-svg-g6qk5kUcPzRvtsaH svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-g6qk5kUcPzRvtsaH p{margin:0;}#mermaid-svg-g6qk5kUcPzRvtsaH .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-g6qk5kUcPzRvtsaH .cluster-label text{fill:#333;}#mermaid-svg-g6qk5kUcPzRvtsaH .cluster-label span{color:#333;}#mermaid-svg-g6qk5kUcPzRvtsaH .cluster-label span p{background-color:transparent;}#mermaid-svg-g6qk5kUcPzRvtsaH .label text,#mermaid-svg-g6qk5kUcPzRvtsaH span{fill:#333;color:#333;}#mermaid-svg-g6qk5kUcPzRvtsaH .node rect,#mermaid-svg-g6qk5kUcPzRvtsaH .node circle,#mermaid-svg-g6qk5kUcPzRvtsaH .node ellipse,#mermaid-svg-g6qk5kUcPzRvtsaH .node polygon,#mermaid-svg-g6qk5kUcPzRvtsaH .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-g6qk5kUcPzRvtsaH .rough-node .label text,#mermaid-svg-g6qk5kUcPzRvtsaH .node .label text,#mermaid-svg-g6qk5kUcPzRvtsaH .image-shape .label,#mermaid-svg-g6qk5kUcPzRvtsaH .icon-shape .label{text-anchor:middle;}#mermaid-svg-g6qk5kUcPzRvtsaH .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-g6qk5kUcPzRvtsaH .rough-node .label,#mermaid-svg-g6qk5kUcPzRvtsaH .node .label,#mermaid-svg-g6qk5kUcPzRvtsaH .image-shape .label,#mermaid-svg-g6qk5kUcPzRvtsaH .icon-shape .label{text-align:center;}#mermaid-svg-g6qk5kUcPzRvtsaH .node.clickable{cursor:pointer;}#mermaid-svg-g6qk5kUcPzRvtsaH .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-g6qk5kUcPzRvtsaH .arrowheadPath{fill:#333333;}#mermaid-svg-g6qk5kUcPzRvtsaH .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-g6qk5kUcPzRvtsaH .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-g6qk5kUcPzRvtsaH .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-g6qk5kUcPzRvtsaH .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-g6qk5kUcPzRvtsaH .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-g6qk5kUcPzRvtsaH .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-g6qk5kUcPzRvtsaH .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-g6qk5kUcPzRvtsaH .cluster text{fill:#333;}#mermaid-svg-g6qk5kUcPzRvtsaH .cluster span{color:#333;}#mermaid-svg-g6qk5kUcPzRvtsaH div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-g6qk5kUcPzRvtsaH .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-g6qk5kUcPzRvtsaH rect.text{fill:none;stroke-width:0;}#mermaid-svg-g6qk5kUcPzRvtsaH .icon-shape,#mermaid-svg-g6qk5kUcPzRvtsaH .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-g6qk5kUcPzRvtsaH .icon-shape p,#mermaid-svg-g6qk5kUcPzRvtsaH .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-g6qk5kUcPzRvtsaH .icon-shape .label rect,#mermaid-svg-g6qk5kUcPzRvtsaH .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-g6qk5kUcPzRvtsaH .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-g6qk5kUcPzRvtsaH .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-g6qk5kUcPzRvtsaH :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 输入SQL
Parser生成AST抽象语法树
Pass 1: ScopeBuilderListener 构建 Scope作用域
Pass 2: SqlAnalyzerListener 提取字段血缘

架构的桥梁:为什么是 IdentityHashMap

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

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

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

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

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

java 复制代码
// 核心桥梁代码示例
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)),精准关联父子函数。
时序步骤 sqlInfoStack scopeStack selectItemStack functionStack
1. 初始化主查询 主查询 scope_t1 空 空
2. push (id 列) 主查询 scope_t1 item_id 空
3. pop (id 列) 主查询 scope_t1 空 空
4. push (复合列) 主查询 scope_t1 item_func_col 空
5. push (UPPER) 主查询 scope_t1 item_func_col UPPER
6. push (SUBSTR) 主查询 scope_t1 item_func_col UPPER,SUBSTR
7. push (子查询 t2) 主查询,子查询_t2 scope_t1,scope_t2 item_func_col UPPER,SUBSTR
8. pop (子查询 t2) 主查询 scope_t1 item_func_col UPPER,SUBSTR
9. pop (SUBSTR) 主查询 scope_t1 item_func_col UPPER
10. pop (UPPER) 主查询 scope_t1 item_func_col 空
11. pop (复合列) 主查询 scope_t1 空 空
12. 解析结束 空 空 空 空

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

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

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

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

java 复制代码
package org.example;

import org.example.entity.datamodel.SqlInfo;

public interface SqlParser {

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


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

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

java 复制代码
SqlParser engine = new HiveSql(); 

SqlInfo info = engine.parse(sql);

结语与预告

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

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

相关推荐
杨云龙UP1 小时前
TDengine 社区版磁盘空间不足故障处理 SOP:历史日志清理、日志保留策略及数据保留 200 天
数据库·时序数据库·tdengine·tsdb·历史日志清理·trim database·logkeepdays
企业数字化笔记1 小时前
业务单据怎样接入流程中心?启动条件、实例关联与失败回退
java·spring boot·mysql
betazhou1 小时前
MySQL Oracle官方备份工具
数据库·mysql·oracle
挖掘狂人1 小时前
同一个端口能不能被两个程序同时监听?
java·后端·程序员
Teable任意门互动1 小时前
破解多平台数据不通!江浙沪跨境电商外贸业务私有化部署方案|任意门互动科技
大数据·人工智能·科技·信息可视化·信息与通信
codigger1 小时前
同一个端口能不能被两个程序同时监听?
java·后端·程序员·编程
西索斯coding1 小时前
qwen3.8-max-0902 和 minimax-m3 改真实仓库差多少?2026 年 50 个缺陷复现实测框架
java·服务器·网络·ai
云-缩影1 小时前
拆解微信小程序后端架构:ECS、RDS、SSL 和 VPC 怎么配合
微信小程序·架构·ssl
2501_933923251 小时前
SpringCloud:OpenFeign快速实现
java·后端·spring cloud·openfeign