ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

PostgreSQL QuerySpan 迁移到 Omni:Bytebase 以语义分析替代 ANTLR 的列血缘提取重构实践

PostgreSQL QuerySpan 迁移到 Omni:Bytebase 以语义分析替代 ANTLR 的列血缘提取重构实践 PostgreSQL QuerySpan 迁移到 OmniBytebase 以语义分析替代 ANTLR 的列血缘提取重构实践【免费下载链接】bytebaseDatabase governance built for humans and agents — controlling changes and access across every major database.项目地址: https://gitcode.com/GitHub_Trending/by/bytebase导读本文基于 docs/plans/2026-03-24-pg-query-span-omni-migration.md 这一实施计划完整拆解 Bytebase 将 PostgreSQL QuerySpan查询跨度访问表集合 结果列血缘提取从基于 ANTLR 的语法树遍历迁移到 omni 语义分析基础设施的全过程。本次改造以约 670 行新增代码替换约 3,964 行遗留代码净减约 3,300 行同时保证既有 46 个 query_span 与 31 个 query_type YAML 用例全部通过。读完本文你将掌握 omni 的AnalyzeSelectStmt语义分析管线、基于Query结构的列血缘 walker 设计以及如何用逐步替换 全量 YAML 回归的策略安全完成一次高危核心模块迁移。一、背景为什么要把 ANTLR 换成 omni当前状态迁移前Bytebase 的 PostgreSQL 解析器在迁移前依赖 ANTLR 语法树做手工遍历相关代码分散在四个文件中文件职责体量query_span.go入口调用 ANTLR 提取器—query_span_extractor.goANTLR 树遍历提取约 70 个方法3,868 行access_tables_antlr.goANTLR listener 提取被访问的表96 行query_type.go查询类型分类已迁移到 omni AST无需改动access_tables.go基于 omni 的ExtractAccessTables()已迁移无需改动ANTLR 方案的核心问题SQL 是声明式语言语法树只描述写成了什么样不描述实际访问了什么。SELECT a FROM t1 JOIN t2与SELECT * FROM vv 是视图在语法上结构相同但列血缘完全不同。要用语法树还原语义只能在 extractor 里手工模拟解析器的名称解析、作用域、星号展开等逻辑——这正是 3,868 行代码的由来且每支持一种新语法CTE、集合运算、视图、窗口函数都要打补丁。目标状态迁移后文件职责体量query_span.go入口调用 omni 提取器—query_span_omni.go新建约 400–600 行使用AnalyzeSelectStmt() 血缘 walker新增access_tables_antlr.go删除由access_tables.go取代-96query_span_extractor.go迁移完成后删除-3,868从当前仓库源码结构看本次迁移已经落地backend/plugin/parser/pg目录下已不存在query_span_extractor.go与access_tables_antlr.go入口文件 query_span.go 已完全基于 omni 实现最终文件约 1,800 行除血缘 walker 外还包含函数体分析、fallback 列提取等健壮性逻辑。范围排除PL/pgSQL 函数体分析单独跟踪在 BYT-9082 工单中。迁移期间函数调用将回退到既有 ANTLR 函数分析逻辑直到 BYT-9082omni 内置 PL/pgSQL 解析器完成。二、核心架构omni 的语义分析管线技术栈语言Go核心依赖github.com/bytebase/omni见 go.mod当前锁定版本v0.0.0-20260912023254-4574e69bb9f1元数据来源Bytebase 现有的数据库元数据基础设施backend/store/model、backend/plugin/parser/base三步式分析流程解析omni 的ParsePg()把 SQL 解析成 AST入口封装见 omni.go 的func ParsePg(sql string) ([]omnipg.Statement, error)。语义分析调用catalog.Catalog.AnalyzeSelectStmt(selStmt)产出带解析信息的Query结构——列引用VarExpr已解析到具体的RangeTableEntry附带类型信息与来源追踪provenance。血缘提取在分析后的Query树上行走把每个TargetEntry结果列映射到一组ColumnResource{Database, Schema, Table, Column}。Schema 元数据如何进入 omni catalog计划中的原始方案复用了walk_through_omni.go中已经验证的模式schema.GetDatabaseDefinition(Engine_POSTGRES, ctx, metadataProto) → schemaDDL catalog.New() SetSearchPath() catalog.Exec(schemaDDL, ContinueOnError)即先把 Bytebase 的元数据 proto 序列化成 DDL再回放进 omni 的 catalog。实现演进当前仓库的 query_span.go 中的initCatalog()已不再走生成 DDL 再回放的路径而是直接调用e.cat.LoadMetadata(ctx, meta.GetProto(), catalog.LoadMetadataOptions{})将元数据 proto 直接载入 catalog并会对report.Degraded/report.Missing降级为 stand-in 或缺失的对象打slog.Debug日志。生成 DDL 的能力本身仍保留在 get_database_definition.go由 schema.go 统一暴露GetDatabaseDefinition供其他场景使用。关键数据结构omni/pg/catalog 包Query分析后的查询含TargetList结果列、RangeTable范围表、CTEList、SetOp/LArg/RArg集合运算、JoinTree等。TargetEntry一个结果列含Expr、ResName、ResJunk系统辅助列标记。VarExpr列引用表达式通过RangeIdx指向RangeTable的索引AttNum1 起始的列序号唯一定位。RangeTableEntryFROM 中的每个来源Kind区分物理表RTERelation、子查询RTESubquery、CTERTECTE、函数RTEFunction、JOINRTEJoin。三、任务拆解11 步完成迁移计划将整个迁移拆成 11 个可独立提交的任务每个任务都以实现 → 测试 → 提交闭环推进。下面按阶段分组展开。阶段一脚手架与入口接线Task 1新建query_span_omni.go定义提取器结构与构造函数package pg import ( context github.com/pkg/errors github.com/bytebase/omni/pg/ast github.com/bytebase/omni/pg/catalog storepb github.com/bytebase/bytebase/backend/generated-go/store github.com/bytebase/bytebase/backend/plugin/parser/base github.com/bytebase/bytebase/backend/plugin/schema github.com/bytebase/bytebase/backend/store/model ) // omniQuerySpanExtractor extracts query span using omnis semantic analysis. type omniQuerySpanExtractor struct { ctx context.Context gCtx base.GetQuerySpanContext defaultDatabase string searchPath []string metaCache map[string]*model.DatabaseMetadata cat *catalog.Catalog } func newOmniQuerySpanExtractor( defaultDatabase string, searchPath []string, gCtx base.GetQuerySpanContext, ) *omniQuerySpanExtractor { if len(searchPath) 0 { searchPath []string{public} } return omniQuerySpanExtractor{ defaultDatabase: defaultDatabase, searchPath: searchPath, gCtx: gCtx, metaCache: make(map[string]*model.DatabaseMetadata), } }要点说明searchPath为空时默认[public]与 PostgreSQL 的默认搜索路径一致。metaCache做数据库元数据的惰性缓存避免同一次分析重复拉取。入口GetQuerySpan重接线先通过gCtx.GetDatabaseMetadataFunc取元数据读取meta.GetSearchPath()作为搜索路径schema参数非空时以指定 schema 覆盖再构造 omni 提取器调用getQuerySpan(ctx, stmt.Text)。当前 query_span.go 中该入口对 PostgreSQL 与 CockroachDB 两个引擎统一注册base.RegisterGetQuerySpan(storepb.Engine_POSTGRES, GetQuerySpan)与storepb.Engine_COCKROACHDB。提交git add bytebase/backend/plugin/parser/pg/query_span_omni.go bytebase/backend/plugin/parser/pg/query_span.go git commit -m feat(pg): scaffold omni-based QuerySpan extractor阶段二catalog 元数据加载Task 2实现 catalog 初始化。计划版本使用GetDatabaseDefinition → Exec(schemaDDL)func (e *omniQuerySpanExtractor) getDatabaseMetadata(database string) (*model.DatabaseMetadata, error) { if meta, ok : e.metaCache[database]; ok { return meta, nil } _, meta, err : e.gCtx.GetDatabaseMetadataFunc(e.ctx, e.gCtx.InstanceID, database) if err ! nil { return nil, errors.Wrapf(err, failed to get database metadata for database: %s, database) } e.metaCache[database] meta return meta, nil } // initCatalog creates an omni catalog loaded with the database schema. func (e *omniQuerySpanExtractor) initCatalog() error { meta, err : e.getDatabaseMetadata(e.defaultDatabase) if err ! nil { return err } schemaDDL, err : schema.GetDatabaseDefinition( storepb.Engine_POSTGRES, schema.GetDefinitionContext{}, meta.GetProto(), ) if err ! nil { return errors.Wrap(err, failed to generate schema DDL) } e.cat catalog.New() e.cat.SetSearchPath(e.searchPath) if schemaDDL ! { if _, err : e.cat.Exec(schemaDDL, catalog.ExecOptions{ContinueOnError: true}); err ! nil { return errors.Wrap(err, failed to load schema into catalog) } } return nil }验证方式构造一份 metadata proto调用initCatalog()然后通过cat.GetRelation(public, t)确认表能查到。测试命令go test -v -count1 -run ^TestOmniCatalogLoading$ github.com/bytebase/bytebase/backend/plugin/parser/pg从当前源码看initCatalog的最终形态改用了LoadMetadata直接载入 proto见上文实现演进并额外做了两件事对report.Degraded/report.Missing记录调试日志把每个函数的原始定义美元引用函数体findDollarQuotedBody提取存入funcOrigDefs供后续 PL/pgSQL 函数体分析使用。阶段三核心 getQuerySpan 管线Task 3这是主流程解析 → 分类查询类型 → 分析 SELECT → 提取血缘。func (e *omniQuerySpanExtractor) getQuerySpan(ctx context.Context, stmt string) (*base.QuerySpan, error) { e.ctx ctx // Step 1: Parse with omni. omniStmts, err : ParsePg(stmt) if err ! nil { return nil, errors.Wrap(err, failed to parse statement) } if len(omniStmts) ! 1 { return nil, errors.Errorf(expected 1 statement, got %d, len(omniStmts)) } // Step 2: Extract accessed tables using omni (already migrated). accessTables, err : ExtractAccessTables(stmt) if err ! nil { return nil, err } accessesMap : make(base.SourceColumnSet) for _, resource : range accessTables { accessesMap[resource] true } // Step 3: Check for mixed system/user tables. allSystems, mixed : isMixedQuery(accessesMap) if mixed { return nil, base.MixUserSystemTablesError } // Step 4: Classify query type (already uses omni). queryType, isExplainAnalyze : classifyQueryType(omniStmts[0].AST, allSystems) if queryType ! base.Select { return base.QuerySpan{ Type: queryType, SourceColumns: base.SourceColumnSet{}, Results: []base.QuerySpanResult{}, }, nil } if isExplainAnalyze { return base.QuerySpan{ Type: queryType, SourceColumns: accessesMap, Results: []base.QuerySpanResult{}, }, nil } // Step 5: Initialize catalog and analyze SELECT. selStmt, ok : omniStmts[0].AST.(*ast.SelectStmt) if !ok { return base.QuerySpan{ Type: base.Select, SourceColumns: accessesMap, Results: []base.QuerySpanResult{}, }, nil } if err : e.initCatalog(); err ! nil { return nil, errors.Wrap(err, failed to init catalog) } query, err : e.cat.AnalyzeSelectStmt(selStmt) if err ! nil { // Graceful degradation: return what we have. return base.QuerySpan{ Type: base.Select, SourceColumns: accessesMap, Results: []base.QuerySpanResult{}, }, nil } // Step 6: Extract lineage from analyzed query. results : e.extractLineage(query) allSourceCols : e.extractAllSourceColumns(query) for col : range allSourceCols { accessesMap[col] true } return base.QuerySpan{ Type: base.Select, SourceColumns: accessesMap, Results: results, }, nil }管线中几个关键判定混合查询检测isMixedQuery同时命中系统表与用户表时返回base.MixUserSystemTablesError拒绝分析。该逻辑在 access_tables.go 中实现——用户表pg_database与系统表pg_database的区别在于是否带 schema 限定isSystemResource判断。非 SELECT 提前返回DML/DDL/EXPLAIN 等直接返回空结果集只保留类型信息。EXPLAIN ANALYZE返回访问表集合但无结果列。阶段四列血缘 walkerTask 4核心逻辑将omni/pg/catalog/query_span_test.go中的概念验证 walker 移植为生产代码输出从测试专用类型改为base.QuerySpanResult。核心映射规则表达式/来源解析方式VarExpr通过RangeTable[RangeIdx]解析出 schema/table/columnRTERelation物理表查Catalog.GetRelationByOID()→Relation.Schema.NameRelation.NameRTESubquery递归进入Subquery.TargetList[colIdx]RTECTE递归进入CTEList[CTEIndex].Query.TargetList[colIdx]RTERelationRelKindv视图递归进入Relation.AnalyzedQueryGap 1 修复与测试 walker 的关键差异输出base.QuerySpanResult含Name、SourceColumns、IsPlainField。ColumnResource.Database使用e.defaultDatabase。集合运算合并Query.LArg与Query.RArg的血缘。extractAllSourceColumns则行走整个QueryTargetListJoinTree.QualsJoinExprNode.QualsHavingQual收集所有被访问的列。当前实现的增量仓库中的extractLineage见 query_span.go在计划之上还做了三件事用buildPlainFieldMask依据语法树判断IsPlainField仅SELECT */t.*展开的列才算 plain fieldisUltimatelyPlainColumn沿 CTE/子查询递归校验列是否最终落到物理表当 catalog 把表达式折叠为常量如json_object(id: a)丢失列引用时回退到语法树ResTarget用figureResTargetName补名字、plpgsqlAnalyzer.extractColumnRefsFromExpr补血缘。阶段五集合运算Task 5UNION/INTERSECT/EXCEPT会在顶层产生SetOp ! SetOpNone的Query其TargetList中只有占位VarExpr没有真实来源。处理方式递归取LArg/RArg的血缘按输出列位置合并两分支的来源列列名取左分支PostgreSQL 约定EXCEPT 特例只保留左分支来源右分支仅作过滤不贡献输出。当前实现extractSetOpLineageWithVisited确认了这一约定includeRight : q.SetOp ! catalog.SetOpExcept q.SetOp ! catalog.SetOpExceptAll且集合运算结果列的IsPlainField恒为false。阶段六视图穿透血缘Task 6当resolveVar遇到RTERelation且底层Relation.RelKind v视图时递归进入视图定义case catalog.RTERelation: rel : e.cat.GetRelationByOID(rte.RelOID) if rel nil || rel.Schema nil { return } // View through-lineage: recurse into view definition. if rel.RelKind v rel.AnalyzedQuery ! nil { if colIdx 0 colIdx len(rel.AnalyzedQuery.TargetList) { te : rel.AnalyzedQuery.TargetList[colIdx] e.walkExpr(rel.AnalyzedQuery, te.Expr, seen, result) } return } // Physical table: terminal case.物化视图RelKind m按同样方式处理。这样SELECT ssn FROM v的血缘能一路穿透到基表t.ssn这正是数据脱敏masking所需要的到底读了哪张表的哪一列。测试用例TestQuerySpanResolvesAViewOverABrokenTable验证了视图上叠破损表缺枚举类型时血缘仍能解析到records表的列。阶段七系统函数作为表源Task 7omni 分析器通过RTEFunction处理 FROM 子句中的系统函数。各函数的列名约定函数输出列generate_series单列generate_seriesgenerate_subscripts单列generate_subscriptsunnestN 列unnest每个数组参数一列jsonb_each/json_eachkey,valuejsonb_array_elements/json_array_elementsvaluejson_to_record/jsonb_to_recordalias 子句声明的列json_to_recordset/jsonb_to_recordsetalias 子句声明的列计划策略先验证 omni 是否正确设置rte.ColNames若已正确则无需任何代码——RTEFunction的血缘 walker 天然不返回来源列函数结果没有基表来源列名直接取自rte.ColNames。当前实现的resolveVar中RTEFunction分支进一步区分用户自定义函数则穿透其函数体血缘内置函数则穿透其参数的血缘如jsonb_each(a)依赖列a。阶段八用户自定义函数桥接Task 8复杂桥接BYT-9082 完成前UDF 调用需要桥接回 ANTLRwalkExpr遇到FuncCallExpr时通过 catalog 的UserProc注册表判断是否为用户自定义函数SQL 语言函数用pg.Parse()解析函数体AnalyzeSelectStmt()分析提取血缘PL/pgSQL 函数回退到既有querySpanExtractor.findFunctionDefine()逻辑。当前实现在此基础上扩展为完整的函数体分析子系统funcBodyCache按函数 OID 缓存分析结果、funcSourceColumns函数体内发现的表级访问合并进顶层SourceColumns、funcPredicateColumns函数体内 WHERE/JOIN 谓词列独立暴露给调用方决定是否进入顶层PredicateColumns。仓库中还有一组专门测试防止递归分析死循环自引用函数、循环子查询、循环 CTE、循环集合运算通过 1MB 栈的子进程验证见 query_span_test.go 的mustNotOverflow。阶段九sourceColumns 收集Task 9既有 QuerySpan 的SourceColumns是一组Column字段为空的ColumnResource表级访问用于数据脱敏判断访问了哪些表。实现行走Query.RangeTable对每个RTERelation输出ColumnResource{Database, Schema, Table, Column: }函数体分析产生的额外来源列合并进来。从当前源码看表级访问已前移到管线 Step 2 的ExtractAccessTables()非致命失败——SET等语句没有表引用函数体列随后合并入accessesMap因此血缘提取阶段无需重复收集。阶段十边界情况与错误恢复Task 10ResourceNotFoundError表/列不存在导致AnalyzeSelectStmt失败时返回部分QuerySpan并设置NotFoundError。FunctionNotSupportedError函数无法分析时同样返回部分结果。EXPLAINEXPLAIN SELECT ...应提取内部 SELECTEXPLAIN ANALYZE只返回访问表集合。classifyQueryType已覆盖见 query_type.goisExplainAnalyzeOmni通过检查 Options 中的analyzeDefElem 判断。当前实现的错误恢复比计划更细解析失败时把 omni 的ParseError转成带行列位置的base.SyntaxErrorByteOffsetToRunePositionAnalyzeSelectStmt失败时先尝试tryUserFuncTableSource处理RETURNS TABLE函数作为表源的场景再 fail-open 返回extractFallbackColumns的尽力而为结果并附带UnresolvedColumnsError通过relationHasNoSyncedColumns独立于血缘检查元数据中该表同步了但零列的异常。TestGetQuerySpanNilMetadata保证从未同步过的数据库返回明确错误而非 panic。阶段十一清理遗留 ANTLR 代码Task 11# 删除遗留提取器3,868 行与 ANTLR 访问表 listener96 行 # 检查包内是否还有 ANTLR 引用 grep -r antlr4-go/antlr bytebase/backend/plugin/parser/pg/query_span*.go # 全量回归 go test -v -count1 -run ^TestGetQuerySpan$ github.com/bytebase/bytebase/backend/plugin/parser/pg # Lint 与构建 golangci-lint run --allow-parallel-runners bytebase/backend/plugin/parser/pg/... go build -ldflags -w -s -p16 -o ./bytebase-build/bytebase ./backend/bin/server/main.go提交git commit -m refactor(pg): remove legacy ANTLR QuerySpan extractor。注意以上grep中使用rm删除文件的步骤属于迁移提交内容仓库为只读本文仅作计划还原说明不涉及对当前仓库的修改。四、任务汇总与风险评估Task描述预估行数变化风险1提取器脚手架 入口接线80低2catalog 元数据加载50低3核心 getQuerySpan 管线80中4列血缘 walker200高核心逻辑5集合运算40中6视图穿透血缘20低7系统函数20验证为主低8UDF 桥接回 ANTLR100高复杂桥接9sourceColumns 收集30低10边界情况 错误恢复50中11删除遗留代码-3,964低纯删除净结果约 670 行、-3,964 行净移除约3,300 行。五、测试策略77 个 YAML 用例作为验收标准所有既有 YAML 用例就是本次迁移的验收标准测试运行器 query_span_test.go 的TestGetQuerySpan本身不改动——它调用入口GetQuerySpan()遍历 test-data/query_span.yaml计划口径 46 个用例与 test-data/query_type.yaml31 个用例将结果序列化为 YAML 与 golden 数据比对result.ToYaml()与tc.QuerySpan逐字段相等共 77 个用例。每个用例的结构- description: 用例描述 statement: SELECT ... # 被测 SQL defaultDatabase: db # 默认数据库 metadata: ... # protojson 编码的 DatabaseSchemaMetadata querySpan: # 期望的 golden 结果 type: SELECT sourceColumns: [...] results: [...]测试运行命令每个任务完成后执行不允许累积失败go test -v -count1 -run ^TestGetQuerySpan$ github.com/bytebase/bytebase/backend/plugin/parser/pg此外测试套件还包含一批计划之外的健壮性用例集中体现了降级但不崩溃的工程取向坏引号标识符weirdtable不阻塞同库其他表查询引用未声明枚举类型的表以 stand-in 安装列名仍可解析同一 schema 中坏表不影响健康表的血缘爆炸半径控制分区表血缘解析到分区本身orders_2024供脱敏将分区解析回父表带WITH ORDINALITY的 LATERAL 函数、jsonb_path_query_array等复杂 JSONB 表达式在 CTE 链路中血缘保持完整。六、总结PostgreSQL QuerySpan 迁移到 omni 是一次教科书式的以语义分析替代语法遍历重构让解析器替我们完成名称解析、作用域与星号展开血缘提取器只负责行走已经懂语义的树。这不仅把 3,868 行的手工 ANTLR 遍历压缩到数百行更重要的是把正确性责任移交给了可复用的语义分析基础设施——后续 MySQL、MSSQL 的同类迁移仓库中已有 2026-04-23-mssql-query-span-omni-migration.md、2026-04-27-mysql-query-span-omni-migration.md 计划可以复用同一套模式。对希望复刻本次经验的团队核心可借鉴点有三先建概念验证测试再写生产代码把血缘 walker 先放在 omni 的测试里验证正确性再移植到生产侧适配base.QuerySpanResult以 golden YAML 全量回归作为安全网77 个既有用例不动、测试运行器不动任何一步的语义漂移都会立刻暴露fail-open 优于 fail-hard分析失败时返回部分结果访问表 尽力而为的列并设置明确的错误标志让上层脱敏、审批、SQL 审核自行决策而不是让一次无法分析打断整个功能链路。【免费下载链接】bytebaseDatabase governance built for humans and agents — controlling changes and access across every major database.项目地址: https://gitcode.com/GitHub_Trending/by/bytebase创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表