news 2026/9/26 14:20:10

SQL解析器完整代码实战:从词法分析到AST构建

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SQL解析器完整代码实战:从词法分析到AST构建

简介:一份基于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语义。

优先级处理靠“分层下推”。表达式文法从低优先级到高优先级分层:

  • OR
  • AND
  • NOT
  • 比较(=, !=, <, <=, >, >=)
  • 加减
  • 乘除
  • 一元负号
  • 原子(数字、字符串、列名、括号表达式)

每一层调用更低一层。WHERE a = 1 AND b > 2会先按AND拆开,两侧分别是比较表达式,最终生成AND(=(a,1), >(b,2))的树。这里容易翻车的是把比较优先级放得太高或太低,导致NOT a = 1被解析成NOT(a=1)还是(NOT a)=1。SQL标准规定NOT优先级低于比较,所以是前者。实现时注意顺序。

递归下降的另一个关键点是“一个Token的归属只能有一个函数”。比如SELECT a, b FROM t,FROM关键字到底在列解析里结束还是在表解析里开始,取决于列解析函数是否只消费到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_factory=list) table: Optional[TableRef] = None where: Optional[Any] = None group_by: List[Any] = field(default_factory=list) order_by: List[OrderItem] = field(default_factory=list) limit: Optional[int] = None

这段代码把Token和AST分开了。Token定义是按词法输出设计的;AST节点则只存语义信息。SelectStmt里用field(default_factory=list)是为了避免可变默认参数的坑。如果你看到解析结果里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:j+1])) 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[i+1] == '=': j = i + 2 elif ch in '<>' and i + 1 < n and sql[i+1] 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(name=name, table=table, alias=alias) 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, indent=0): 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_factory=list) order_by: List[OrderItem] = field(default_factory=list) @dataclass class FuncCall: name: str args: List[Any] = field(default_factory=list) 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_primary,token数量只有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_and,parse_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,范围比较权值10,ORDER BY权值20,DISTINCT权值30,LIMIT权值-50。最终总分超过阈值就标记为疑似慢SQL。这个模型不精确,但它让慢SQL分析系统不需要连数据库就能给每条SQL打分,筛选出需要人工看的TopN。真正的执行计划还要以数据库的EXPLAIN为准,解析器只做粗筛。

6.3 回归测试:给你的解析器戴上“后悔药”

解析器改文法时最容易改坏旧功能。我给自己写的解析器维护了一张测试用例表,每条SQL配一个期望的AST快照(JSON格式)。每次改动后跑全量测试,比对AST是否一致。这样能防止新增窗口函数支持时把原本的SELECT *解析弄坏。这里的关键是快照要精确到字段,而不是只比对SQL字符串。

我的习惯是先在测试里写“坏SQL”清单,比如SELECT FROM、WHERE后面跟ORDER,确保解析器抛出可读错误。慢SQL优化系统上线前,我会用真实保存的慢查询日志回放几百条SQL,看解析成功率。这个回放脚本很简单,但对信心提升很大。用好这套方法,解析器就不再是黑匣子,而是可以持续打磨的底座。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 14:19:24

数据采集总线选型指南:从SPI、CAN到PXI、AXI的六个关键问题

做数据采集系统这些年&#xff0c;被问得频率最高的一个问题就是&#xff1a;总线到底怎么选。SPI、CAN、RS485、PXI、AXI&#xff0c;每个都有人推荐&#xff0c;每个都有自己的死忠用户&#xff0c;但很多板卡到手一跑&#xff0c;不是丢帧就是抖成心电图。其实选总线不是选一…

作者头像 李华
网站建设 2026/9/26 14:18:27

UC网盘下载提速全攻略:在线解析工具与直链下载实战

网盘下载这件事&#xff0c;说简单也简单&#xff0c;说折腾也真能折腾死人。我平时因为工作关系&#xff0c;经常要从各种网盘里拉素材、拉安装包、拉别人分享的资料&#xff0c;UC网盘是近两年用得比较多的一个。倒不是说它有多完美&#xff0c;而是分享链接的生态慢慢往这边…

作者头像 李华
网站建设 2026/9/26 14:16:30

AI编程工具实战指南:本地化夯基与工程化拉起

1. 项目概述&#xff1a;这不是榜单&#xff0c;是一份AI编程工具的实战生存指南“从夯到拉”——这四个字不是修辞&#xff0c;是我在过去三年里用掉七台开发机、重装过四十七次IDE、被API限流踢出过二十三次会话后&#xff0c;总结出来的AI编程工具演进真实节奏。夯&#xff…

作者头像 李华
网站建设 2026/9/26 14:15:14

AI编程风潮下,嵌入式开发如何正确拥抱Vibe Coding?

最近这半年&#xff0c;身边做 Web 的朋友经常在群里晒 AI 编程的战绩&#xff1a;丢一句需求描述过去&#xff0c;代码自动生成&#xff0c;编译、测试、重构都在一个会话里完成。那是他们的 Vibe Coding 时代。回到嵌入式这边&#xff0c;气氛完全不一样——底层要跟寄存器、…

作者头像 李华
网站建设 2026/9/26 14:14:53

驾驶员安全带检测数据集:YOLO格式开箱即用与训练避坑指南

简介&#xff1a;本资源为驾驶员佩戴安全带检测的YOLO格式数据集&#xff0c;面向从事目标检测学习与车辆安全场景开发的学生、算法工程师及竞赛参与者&#xff0c;可直接用于YOLOv5等框架的训练与验证。数据按YOLOv5标准目录组织&#xff0c;标注采用classes、x_centre、y_cen…

作者头像 李华
网站建设 2026/9/26 14:14:48

MVVM架构详解:从核心机制到Qt框架落地实践

做客户端开发这些年&#xff0c;我见过太多把业务逻辑直接揉进界面代码里的项目。很多时候&#xff0c;一个页面还没写几百行&#xff0c;就已经出现“改一个按钮就要翻遍整个文件”的情况。越来越多的团队开始把设计模式引入GUI开发&#xff0c;而“MVVM是什么”这个看似入门的…

作者头像 李华