SQL解析器完整代码实战:从词法分析到AST构建
发布时间:2026/9/26 14:20:16来源:尧图网络
简介一份基于Flex与Bison构建的SQL解析器完整源代码面向数据库内核开发、编译原理学习及自定义查询引擎研究场景可帮助读者理清词法分析与语法分析的协作关系并掌握完整的解析流程。压缩包约54KB共11个文件涵盖C源文件、头文件、Flex词法规则、Bison语法规则、AST结构定义及SQL测试脚本各类文件职责清晰便于对照阅读。目前已有690人学习下载具备较好的参考价值。借助这份代码既能观察SQL关键字、标识符与操作符如何被逐字识别也能看到上下文无关文法如何驱动语法树构建并完整理解解析链路同时错误处理、中间表示等关键环节均以简洁代码呈现适合作为课程设计或小型数据库项目的解析模块。整体实现紧凑对理解编译器前端的实际落地非常有帮助。1. SQL解析器完整代码到底解决什么问题写代码时最容易被忽略的一个事实是数据库在真正执行SQL之前必须先“读懂”你写的这条语句。所谓SQL解析器完整代码就是把这串文本变成程序可以操作的结构化对象AST的整套实现。做慢SQL优化、SQL审核、数据血缘、甚至SQL注入检测第一步都是它。后端开发、大数据工程师、DBA三拨人最需要这个东西——前者做ORM和查询改写中间人做引擎集成后者做日志分析时经常要拿历史慢查询SQL做特征提取。我见过不少团队把慢SQL优化做成“拿正则匹配表名和关键条件”结果一遇到子查询和括号嵌套就崩。原因很简单正则没有层次结构而SQL是一个有递归嵌套的语言。真正能落地的方案是写一个只覆盖你业务SQL子集的解析器而不是去追平数据库官方的完整语法。下面这套代码就是按这个思路来的。2. 解析器核心拆解从一条SELECT到AST的完整链路2.1 词法分析把SQL字符串切成Token流词法分析是解析器的“眼睛”。它把原始SQL字符串切成一个个Token。Token不是单词而是带类型的语言单元。比如SELECT id FROM user WHERE age 18会被切成KEYWORD(SELECT)、IDENT(id)、KEYWORD(FROM)、IDENT(user)、KEYWORD(WHERE)、IDENT(age)、OP()、NUMBER(18)。类型区分很重要否则后续语法分析无法判断某个标识符是关键字还是列名。常见做法是先定义Token类型表再按字符逐字扫描。我一般会这样区分连续字母或下划线开头的查关键字表匹配上就是关键字否则是标识符数字开头按数字解析遇到小数点继续往后读单引号开头按字符串解析直到下一个单引号结束运算符单独处理。这里有个容易踩的位置和不同必须做最长匹配即先看两个字符是否构成双字符运算符再看单字符。很多人把拆成了和后面语法分析时怎么都匹配不上。词法层的设计目标是把“切得准”和“报错早”结合起来。如果遇到不认识字符不要静默跳过直接抛错并带出字符串位置方便后续定位。许多新手在词法层就把引号里的内容切碎了原因后面我会在避坑章详说。一个可靠的词法层应该输出不依赖空格、大小写不敏感的Token流——关键字统一大写标识符保留原样。比如SeLeCt也要被识别成SELECT但列名userName不能被改成大写。词法层还有一个容易被忽略的点Token必须保留位置信息。我在线上项目里遇到过只存Token不存位置的解析器SQL一报错只能看到“第11个Token附近语法错误”对于几万字符的动态SQL来说这等于没报。所以即使是原型我也建议在Token的数据结构里加上line和col两个字段后面所有报错信息都从这里取。2.2 语法分析递归下降与优先级处理语法分析把Token流变成树。最容易被接受的手写方案是递归下降它实际上就是把文法规则直接写成函数调用。比如 SELECT 语句的简化文法select_stmt : SELECT [DISTINCT] column_list [FROM table_ref] [WHERE expr] [GROUP BY expr_list] [ORDER BY col_ref [ASC|DESC]] [LIMIT number]每个非终结符对应一个函数函数内部按产生式顺序读取Token。递归下降的天然优势是代码结构清晰、错误定位准确、容易扩展缺点是必须处理左递归文法。SQL表达式文法天然是左递归的比如加减乘除。解决办法是用循环代替递归先解析一个因子然后while循环看下一个Token是不是运算符是则继续解析右侧因子生成左结合树。这个写法在解析a b c时得到的树是(a b) c符合SQL语义。优先级处理靠“分层下推”。表达式文法从低优先级到高优先级分层ORANDNOT比较, !, , , , 加减乘除一元负号原子数字、字符串、列名、括号表达式每一层调用更低一层。WHERE a 1 AND b 2会先按AND拆开两侧分别是比较表达式最终生成AND((a,1), (b,2))的树。这里容易翻车的是把比较优先级放得太高或太低导致NOT a 1被解析成NOT(a1)还是(NOT a)1。SQL标准规定NOT优先级低于比较所以是前者。实现时注意顺序。递归下降的另一个关键点是“一个Token的归属只能有一个函数”。比如SELECT a, b FROM tFROM关键字到底在列解析里结束还是在表解析里开始取决于列解析函数是否只消费到FROM前一个Token。我一般会让列解析函数遇到非COMMA就停止把FROM的判断留给上层parse_select。这样每个函数职责清晰不会出现两个函数都试图消费同一个关键字。2.3 AST设计与语义校验解析器与执行引擎的分界线AST抽象语法树是解析器的输出也是执行引擎的输入。设计AST节点时不要照搬Token而是按语义抽象。例如 SELECT 子句里的a.id在词法层是三个Token但在AST里是一个ColumnRef节点包含表名和列名两个字段。这样执行引擎或者优化器拿到的是一个“列引用”而不是Token流。反例是有些解析器把AST节点保留成Token列表导致优化器每次判断都要重新拼接字符串。AST节点通常用不可变对象表示方便后续遍历。我一般用Python的dataclass定义SelectStmt、ColumnRef、BinaryOp、Literal等。每个节点只保存自己的直接信息子节点通过字段引用。例如BinaryOp有op、left、right三个字段不保存位置信息位置信息单独维护在token流里需要报错时再查。语义校验是“SQL解析器完整代码”里容易被忽略的部分。语法分析只保证SQL符合文法不保证语义正确。比如SELECT * FROM a JOIN b ON a.id c.id里c.id在FROM中没有来源语法分析不会报错语义校验会查表。小型解析器可以省略完整语义校验但至少要检查列引用是否有表名前缀、LIMIT是否为正整数、GROUP BY的列是否都在SELECT中。这些检查放在AST构建之后作为单独遍历不要混入语法分析函数里否则维护难度会指数上升。还要注意AST的可序列化。我在做慢SQL日志分析时需要把AST存成JSON做规则匹配。所以每个AST节点我都会加一个to_dict()方法或者用dataclasses.asdict直接转换。这样解析器不仅能在线运行还能离线分析一批SQL文本。3. 一份可复现的SQL解析器完整代码Python递归下降实现3.1 结构与Token定义下面给出一份可以独立运行的简化SQL解析器完整代码只依赖Python标准库。它支持SELECT、DISTINCT、FROM、WHERE、GROUP BY、ORDER BY、LIMIT以及带括号的布尔表达式和四则运算。不支持JOIN和子查询但扩展点已经留好。# sql_parser.py from dataclasses import dataclass, field from typing import List, Optional, Any # ---------- Token 类型 ---------- dataclass class Token: kind: str # NUMBER / STRING / IDENT / KEYWORD / OP / LPAREN / RPAREN / COMMA / DOT / EOF value: str KEYWORDS { SELECT, FROM, WHERE, GROUP, BY, ORDER, LIMIT, DISTINCT, AS, AND, OR, NOT, ASC, DESC } # ---------- AST 节点 ---------- dataclass class ColumnRef: name: str table: Optional[str] None alias: Optional[str] None dataclass class TableRef: name: str alias: Optional[str] None dataclass class Literal: value: Any dataclass class Column: name: str table: Optional[str] None dataclass class UnaryOp: op: str operand: Any dataclass class BinaryOp: op: str left: Any right: Any dataclass class OrderItem: col: ColumnRef direction: str ASC dataclass class SelectStmt: distinct: bool False columns: List[ColumnRef] field(default_factorylist) table: Optional[TableRef] None where: Optional[Any] None group_by: List[Any] field(default_factorylist) order_by: List[OrderItem] field(default_factorylist) limit: Optional[int] None这段代码把Token和AST分开了。Token定义是按词法输出设计的AST节点则只存语义信息。SelectStmt里用field(default_factorylist)是为了避免可变默认参数的坑。如果你看到解析结果里columns莫名共享同一个列表多半就是这里写成了[]。这里的ColumnRef和Column是两个不同的东西ColumnRef用于SELECT、ORDER BY子句描述“用户引用了哪一列”Column用于表达式内部例如WHERE u.age 18里的u.age。两者结构相似但语义不同分开定义能让AST遍历代码更清晰。3.2 核心解析逻辑词法扫描与递归下降下面的代码是解析器主体。词法部分我用tokenize()函数实现语法部分用Parser类实现。注意tokenize()返回的Token流末尾有个EOF标记这样Parser不需要反复判断是否越界。def tokenize(sql: str) - List[Token]: tokens [] i 0 n len(sql) while i n: ch sql[i] if ch.isspace(): i 1 continue if ch.isdigit(): j i while j n and (sql[j].isdigit() or sql[j] .): j 1 tokens.append(Token(NUMBER, sql[i:j])) i j continue if ch : j i 1 while j n and sql[j] ! : j 1 if j n: raise SyntaxError(f未闭合字符串位置 {i}) tokens.append(Token(STRING, sql[i:j1])) i j 1 continue if ch.isalpha() or ch _: j i while j n and (sql[j].isalnum() or sql[j] _): j 1 word sql[i:j] tokens.append(Token(KEYWORD, word.upper()) if word.upper() in KEYWORDS else Token(IDENT, word)) i j continue if ch in -*/: tokens.append(Token(OP, ch)) i 1 continue if ch in !: j i if i 1 n and sql[i1] : j i 2 elif ch in and i 1 n and sql[i1] in (, ): j i 2 tokens.append(Token(OP, sql[i:j])) i j continue if ch ,: tokens.append(Token(COMMA, ,)) i 1 continue if ch .: tokens.append(Token(DOT, .)) i 1 continue if ch (: tokens.append(Token(LPAREN, ()) i 1 continue if ch ): tokens.append(Token(RPAREN, ))) i 1 continue raise SyntaxError(f无法识别字符 {ch}位置 {i}) tokens.append(Token(EOF, )) return tokens class Parser: def __init__(self, tokens: List[Token]): self.tokens tokens self.pos 0 def peek(self) - Token: return self.tokens[self.pos] def advance(self) - Token: tok self.tokens[self.pos] self.pos 1 return tok def expect(self, kind: str, value: Optional[str] None) - Token: tok self.advance() if tok.kind ! kind or (value is not None and tok.value ! value): raise SyntaxError(f位置 {self.pos}: 期望 {kind} {value}实际 {tok.kind} {tok.value}) return tok def parse_select(self) - SelectStmt: stmt SelectStmt() self.expect(KEYWORD, SELECT) if self.peek().kind KEYWORD and self.peek().value DISTINCT: self.advance() stmt.distinct True stmt.columns self.parse_column_list() if self.peek().kind KEYWORD and self.peek().value FROM: self.advance() stmt.table self.parse_table() if self.peek().kind KEYWORD and self.peek().value WHERE: self.advance() stmt.where self.parse_expr() if self.peek().kind KEYWORD and self.peek().value GROUP: self.advance() self.expect(KEYWORD, BY) stmt.group_by self.parse_expr_list() if self.peek().kind KEYWORD and self.peek().value ORDER: self.advance() self.expect(KEYWORD, BY) stmt.order_by self.parse_order_list() if self.peek().kind KEYWORD and self.peek().value LIMIT: self.advance() stmt.limit self.parse_limit() self.expect(EOF) return stmt def parse_column_list(self): cols [self.parse_column_ref()] while self.peek().kind COMMA: self.advance() cols.append(self.parse_column_ref()) return cols def parse_column_ref(self) - ColumnRef: tok self.advance() if tok.kind OP and tok.value *: return ColumnRef(*) if tok.kind ! IDENT: raise SyntaxError(f位置 {self.pos}: 列名必须是标识符或 *实际 {tok.kind} {tok.value}) name tok.value alias None table None if self.peek().kind DOT: self.advance() col_tok self.advance() if col_tok.kind not in (IDENT,): raise SyntaxError(DOT 后必须是列名) table, name name, col_tok.value if self.peek().kind KEYWORD and self.peek().value AS: self.advance() alias_tok self.advance() if alias_tok.kind ! IDENT: raise SyntaxError(AS 后必须是别名) alias alias_tok.value return ColumnRef(namename, tabletable, aliasalias) def parse_table(self) - TableRef: tok self.advance() if tok.kind ! IDENT: raise SyntaxError(表名必须是标识符) alias None if self.peek().kind KEYWORD and self.peek().value AS: self.advance() alias_tok self.advance() if alias_tok.kind ! IDENT: raise SyntaxError(AS 后必须是表别名) alias alias_tok.value elif self.peek().kind IDENT: alias self.advance().value return TableRef(tok.value, alias) def parse_expr(self): return self.parse_or() def parse_or(self): left self.parse_and() while self.peek().kind KEYWORD and self.peek().value OR: self.advance() right self.parse_and() left BinaryOp(OR, left, right) return left def parse_and(self): left self.parse_not() while self.peek().kind KEYWORD and self.peek().value AND: self.advance() right self.parse_not() left BinaryOp(AND, left, right) return left def parse_not(self): if self.peek().kind KEYWORD and self.peek().value NOT: self.advance() return UnaryOp(NOT, self.parse_not()) return self.parse_comparison() def parse_comparison(self): left self.parse_additive() if self.peek().kind OP and self.peek().value in (, !, , , , , ): op self.advance().value right self.parse_additive() return BinaryOp(op, left, right) return left def parse_additive(self): left self.parse_multiplicative() while self.peek().kind OP and self.peek().value in (, -): op self.advance().value right self.parse_multiplicative() left BinaryOp(op, left, right) return left def parse_multiplicative(self): left self.parse_primary() while self.peek().kind OP and self.peek().value in (*, /): op self.advance().value right self.parse_primary() left BinaryOp(op, left, right) return left def parse_primary(self): tok self.advance() if tok.kind NUMBER: return Literal(float(tok.value) if . in tok.value else int(tok.value)) if tok.kind STRING: return Literal(tok.value[1:-1]) if tok.kind IDENT: name tok.value table None if self.peek().kind DOT: self.advance() col_tok self.advance() if col_tok.kind ! IDENT: raise SyntaxError(列引用格式错误) table, name name, col_tok.value return Column(name, table) if tok.kind LPAREN: expr self.parse_expr() self.expect(RPAREN) return expr raise SyntaxError(f位置 {self.pos}: 无法识别的表达式起始 {tok.kind} {tok.value}) def parse_expr_list(self): exprs [self.parse_expr()] while self.peek().kind COMMA: self.advance() exprs.append(self.parse_expr()) return exprs def parse_order_list(self): items [self.parse_order_item()] while self.peek().kind COMMA: self.advance() items.append(self.parse_order_item()) return items def parse_order_item(self) - OrderItem: col self.parse_column_ref() direction ASC if self.peek().kind KEYWORD and self.peek().value in (ASC, DESC): direction self.advance().value return OrderItem(col, direction) def parse_limit(self) - int: tok self.advance() if tok.kind ! NUMBER or . in tok.value: raise SyntaxError(LIMIT 必须是整数) return int(tok.value) def parse(sql: str) - SelectStmt: tokens tokenize(sql) parser Parser(tokens) return parser.parse_select()这一大段代码里有几个参数值得说明parse_select()里对每个子句的判断用的是self.peek()的两层条件而不是先expect这样能保证子句顺序可省略。比如没有WHERE的SQL不会报错。parse_column_ref()里把*当成OP的特殊取值因为SELECT *不是乘法但在表达式里*又是乘法运算符靠上下文区分。parse_limit()里我特意加了. in tok.value判断因为LIMIT 1.0在SQL里不合法但词法层会把它当数字。parse_primary里LPAREN分支是递归下降的关键点它调用parse_expr期待一个RPAREN结束。这个分支天然支持(a b) * c的括号处理。如果将来要支持子查询也是在这个分支里识别SELECT关键字并调用parse_select而不是另起炉灶。如果你只需要读SQL不需要执行这里输出是SelectStmt就够了。不要在这里混入任何连接数据库的代码保持解析器纯净后续做测试和扩展都轻松。3.3 运行示例从SQL到AST的打印输出为了验证解析器能用加一个入口函数把AST递归打印出来。这里我用递归缩进展示树结构方便肉眼对照。def dump_ast(node, indent0): pad * indent if isinstance(node, SelectStmt): print(f{pad}SelectStmt distinct{node.distinct}) for col in node.columns: dump_ast(col, indent 1) if node.table: dump_ast(node.table, indent 1) if node.where: print(f{pad}WHERE) dump_ast(node.where, indent 1) if node.group_by: print(f{pad}GROUP BY) for g in node.group_by: dump_ast(g, indent 1) if node.order_by: print(f{pad}ORDER BY) for o in node.order_by: dump_ast(o, indent 1) if node.limit is not None: print(f{pad}LIMIT {node.limit}) elif isinstance(node, ColumnRef): print(f{pad}ColumnRef {node.table . if node.table else }{node.name} alias{node.alias}) elif isinstance(node, TableRef): print(f{pad}TableRef {node.name} alias{node.alias}) elif isinstance(node, BinaryOp): print(f{pad}BinaryOp {node.op}) dump_ast(node.left, indent 1) dump_ast(node.right, indent 1) elif isinstance(node, UnaryOp): print(f{pad}UnaryOp {node.op}) dump_ast(node.operand, indent 1) elif isinstance(node, Literal): print(f{pad}Literal {node.value!r}) elif isinstance(node, Column): print(f{pad}Column {node.table . if node.table else }{node.name}) elif isinstance(node, OrderItem): print(f{pad}OrderItem {node.direction}) dump_ast(node.col, indent 1) if __name__ __main__: sql SELECT DISTINCT u.id, u.name AS username FROM user u WHERE u.age 18 AND u.level 2 ORDER BY u.id DESC LIMIT 10 ast parse(sql) dump_ast(ast)运行这段代码会看到树形输出。dump_ast不是解析器的一部分但强烈建议保留因为调试AST时没有可视化工具会很痛苦。如果某个SQL解析结果和你预期不同先看这棵树再决定是词法还是语法函数的问题。这个完整代码本身不执行SQL但对“解析SQL”这件事来说已经闭环。后面要加JOIN、子查询、窗口函数都是在这棵树上做加法。4. 把解析器用起来语法扩展与参数调优4.1 支持窗口函数ROW_NUMBER、PARTITION BY的解析扩展标题里“SQL解析器完整代码”如果只到SELECT就收工在真实业务里肯定不够。现在SQL里到处是ROW_NUMBER() OVER(PARTITION BY dept_id ORDER BY salary DESC)。要在现有解析器上加窗口函数我一般分三步先扩展关键字表再加AST节点再在SELECT列解析里识别函数() OVER结构。关键字表里增加OVER、PARTITION保留字里不要动ROW_NUMBER这类函数名因为它们在绝大多数数据库里不是保留字。AST节点可以这样加dataclass class WindowSpec: partition_by: List[Any] field(default_factorylist) order_by: List[OrderItem] field(default_factorylist) dataclass class FuncCall: name: str args: List[Any] field(default_factorylist) over: Optional[WindowSpec] None然后在parse_column_ref里如果当前Token是IDENT且下一个Token是LPAREN就进入函数解析分支。函数参数可以复用parse_expr但要额外处理COUNT(*)这种带星号的参数。OVER之后的括号由新的parse_window_spec解析逻辑和GROUP BY、ORDER BY共用子程序。这里有一个经验窗口函数的PARTITION BY和ORDER BY解析完成后别忘了检查OVER是否紧跟)。很多SQL写成了ROW_NUMBER() OVER (...) OVER (...)这在标准语法里不合法解析器要在第二次遇到OVER时报错而不是静默丢弃。4.2 支持DISTINCT与COUNT等去重的AST设计基础代码已经支持了SELECT DISTINCT但真实的去重需求还有COUNT(DISTINCT user_id)和SELECT COUNT(*) FROM t。这两类去重语义不同AST设计也要区分SELECT DISTINCT是作用于结果集的行去重COUNT(DISTINCT col)是作用于聚合函数的输入去重。我给AST增加两个字段而不是复用一个distinct布尔dataclass class AggCall: name: str # COUNT / SUM / AVG / MIN / MAX arg: Any distinct: bool False在解析函数参数时如果参数第一个Token是KEYWORD DISTINCT就把AggCall.distinct置为True并把参数表达式解析在后面。对于COUNT(*)我让arg直接存一个Literal(None)避免造一个无意义的ColumnRef(*)。这样后面执行引擎做聚合时只需要看distinct标志决定是否先构建HashSet。要注意COUNT(DISTINCT a, b)在部分数据库里支持在MySQL里不支持。如果解析器要做到跨数据库就得设计一个distinct_args: List如果只服务一个库建议从解析层就拦截这种写法给出明确错误信息。我在做SQL审核工具时就是在这里挂了一个规则检测到COUNT(DISTINCT a, b)直接报warning。4.3 解析错误恢复与定位三个必调参数解析器在真实环境里会遇到各种非法SQL报错信息直接决定用户能不能快速修复。我一般在Parser里加三个参数是默认推荐值max_error_positions默认1。语法错误抛错时允许继续尝试解析后面的恢复点把多个错误一次列出来。但是恢复机制复杂初版不要做先保证第一个错误准确。max_recursion_depth默认100。递归下降遇到深层括号嵌套时会撑爆栈限制深度并在接近时抛“括号嵌套过深”比Python默认的RecursionError友好。source_line_offset默认0。报错时把Token位置换算成原始SQL的行列号标准做法是在tokenize时同时记录每个Token的开始行和列而不是只记offset。这样报错信息能写成第2行第5列。这些参数不参与SQL语义但参与了解析器的健壮性。我建议把tokenize返回的Token增加line和col字段改造成本很低。后面做慢SQL日志分析时行号对定位超长SQL非常有价值。我实际使用中发现max_recursion_depth这个参数最值得调。默认100在普通业务SQL上完全够用但如果你解析的是ORM自动生成的大SQL嵌套深度可能超过50。调到200不会明显影响性能但如果超过300基本可以确认是用户的SQL写法有问题不是解析器不够强。5. SQL解析器避坑指南5个最容易翻车的现场5.1 字符串与引号处理单引号里的逗号和括号现象WHERE name Smith, John (sr.)被切成了多个Token逗号被当成列分隔符括号被当成子查询解析直接报错。原因词法阶段遇到单引号时必须进入“字符串模式”直到下一个单引号结束。如果只在字符层判断逗号和括号就会把字符串内部的内容泄露到语法层。解决在tokenize里看到单引号后用while跳到匹配的闭合引号中间不再做任何Token切分。如果SQL里还有转义引号\需要额外处理。大多数解析器选择在字符串模式里支持双单引号表示转义这是SQL标准做法。我建议初版至少支持未闭合字符串抛错不然错误信息会很诡异。我之前就遇到过生产环境里一条SQL漏了右引号解析器把后面几百个字符全当字符串吞掉报错位置直接指到SQL结尾排查了很久。5.2 大小写与关键字冲突列名命名为order怎么办现象用户表里有一列叫order写SELECT order FROM t直接被解析成ORDER关键字然后报语法错误。原因词法阶段把order大写成ORDER后查到了关键字表。SQL的关键字不区分大小写但列名和表名在很多数据库里是大小写敏感的两者语义必须分开。解决词法层只负责输出KEYWORD和IDENT不负责区分“这里应该是列名”。关键字判断属于语法层要依据上下文。比如ORDER只有在ORDER BY里才是关键字单独出现时可以当列名。我一般会把ORDER从全局关键字表里拿掉在parse_select里用peek()判断当前是否是ORDER且下一个Token是BY是才当关键字。同理GROUP要配合BY。这也是为什么parse_select里用了两层peek而不是expect。除了ORDER还有几个常见冲突词ASC、DESC、LIMIT。如果一张表真的有desc列在SELECT desc FROM t里也会被误判。我建议把关键字表尽量精简只保留多词组合中不可省略的词比如SELECT、FROM、WHERE、BY本身。ASC、DESC只在ORDER BY后面才有意义如果不在那也应允许当列名。5.3 子查询与括号嵌套的递归陷阱现象一条SQL里写了三层子查询解析到第四层时直接RecursionError或者括号匹配错位。原因递归下降解析器对每个括号调用一次parse_primary - parse_expr子查询再嵌套一次parse_select递归深度和SQL嵌套深度成正比。Python 默认递归深度约1000三层子查询远远到不了但如果每层表达式里还有多个括号深度会被放大而且某些写法会形成“伪左递归”导致死循环。解决在parse_primary的LPAREN分支里进入parse_select前加一个深度计数器超过阈值抛自定义异常。另外括号匹配必须放在parse_primary的同一层完成不能在parse_select里跳跃太多。如果遇到(SELECT ...) UNION (SELECT ...)要先让parse_primary返回子查询AST节点再由外层的SQL子句继续解析不要把UNION处理堆进parse_primary。我曾遇到过((((a))))这种纯括号表达式解析器每次都递归调用parse_primarytoken数量只有5个但递归深度也有5层。如果是运行时动态生成SQL括号层数可能到几十层。所以深度计数器必须加在表达式解析的入口而不是parse_select入口否则子查询和括号叠加时计数器不准。5.4 类型推断与隐式转换为什么“1”和1是同一个值现象WHERE age 18被解析成string类型的字面量执行引擎拿去和整数列比较时出现类型不匹配或者反过来把整数列转成字符串导致索引失效。原因词法层只负责把Token标成STRING或NUMBER不负责语义类型。AST里的Literal节点如果直接用Python原生的str和int执行引擎就必须在类型层面做隐式转换。很多SQL解析器原型的坑就藏在Literal节点的类型标注上。解决在AST节点里增加data_type字段标注int、float、string、null由语义校验统一处理隐式转换规则。解析器不转换只标记执行引擎根据列元数据决定是否需要转换。比如遇到age 18如果age是整数列就把字符串稍后转成整数。这个设计把转换规则集中到一处避免每个执行函数各做各的。在慢SQL优化场景里这个坑会放大成索引失效。WHERE create_time 2024-01-01如果列是datetime类型字符串转换规则要由数据库决定解析器如果预先把字符串转成时间对象反而会失去灵活性。所以我的建议是解析器只标记不转换把类型决策留给执行引擎。5.5 大SQL性能问题回溯导致的解析超时现象一条线上慢SQL有几百个字段、几十个JOIN条件解析器直接卡住CPU跑满像死循环一样。原因递归下降本身是线性复杂度但如果文法有公共前缀且没有提前分流就会产生回溯。比如SELECT a, b FROM t WHERE和SELECT a, b FROM t在SELECT之后先走列解析列解析每次都要试到FROM才知道结束这个还好真正严重的是表达式解析里parse_or不断尝试parse_andparse_and又尝试parse_not如果同一层对相同Token产生多条分支会指数级放大。解决每个解析函数进入时要先看下一个Token能否唯一确定分支。parse_select已经用peek检查FROM、WHERE等关键字表达式分层本身没有回溯但要注意不要写成像“先尝试按WHERE解析失败再按GROUP解析”这样的模式。如果SQL太深导致Python解析栈压力大可以把parse_or、parse_and的while循环保留但把parse_not的递归改成迭代。还有一个通用技巧在Token流里缓存函数结果遇到同样Token位置和函数组合直接返回之前的AST这个叫记忆化能把最坏情况拉回线性。我实际压测过一个4000字的SQL普通递归下降解析耗时约2毫秒如果错误地用了回溯式解析能到几百毫秒。这还没到超时但如果每天解析几十万条慢SQL差异就到小时级了。所以性能优化不是等出问题再做而是在写解析函数时就保证每个分支只看一个前瞻Token。6. 从能跑到跑好为慢SQL优化做AST改写6.1 用AST做谓词下推一条WHERE改写的完整例子很多慢SQL优化场景第一步不是调数据库参数而是改写SQL。AST改写比正则可靠得多。比如SELECT * FROM orders o JOIN users u ON o.user_id u.id WHERE u.level 3标准优化是先把谓词u.level 3下推到子查询(SELECT * FROM users WHERE level 3) u让数据库提前过滤。用解析器做这件事只需要遍历AST里的WHERE表达式找到引用某个表的BinaryOp把它复制到对应的TableRef后面。我这里只讲思路完整改写器需要处理别名、AND表达式切片。6.2 慢SQL识别解析器里加一个预估代价解析器能顺手解决“这条SQL值不值得优化”的问题。我在AST上做一次遍历给每个节点加权重全表扫描权值100等值比较权值1范围比较权值10ORDER BY权值20DISTINCT权值30LIMIT权值-50。最终总分超过阈值就标记为疑似慢SQL。这个模型不精确但它让慢SQL分析系统不需要连数据库就能给每条SQL打分筛选出需要人工看的TopN。真正的执行计划还要以数据库的EXPLAIN为准解析器只做粗筛。6.3 回归测试给你的解析器戴上“后悔药”解析器改文法时最容易改坏旧功能。我给自己写的解析器维护了一张测试用例表每条SQL配一个期望的AST快照JSON格式。每次改动后跑全量测试比对AST是否一致。这样能防止新增窗口函数支持时把原本的SELECT *解析弄坏。这里的关键是快照要精确到字段而不是只比对SQL字符串。我的习惯是先在测试里写“坏SQL”清单比如SELECT FROM、WHERE后面跟ORDER确保解析器抛出可读错误。慢SQL优化系统上线前我会用真实保存的慢查询日志回放几百条SQL看解析成功率。这个回放脚本很简单但对信心提升很大。用好这套方法解析器就不再是黑匣子而是可以持续打磨的底座。希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网