
我处理过的SQL这类活儿十次里有八次不是写SQL本身而是“伺候SQL文本”。有段时间我要跑几十万条慢查询日志还要从一堆历史SQL里把涉及的表名捞出来画血缘关系图。第一版用正则硬拆遇到子查询、字符串里带逗号的SQL直接崩。后来换成sqlparse这类问题基本消停了。sqlparse是Python生态里最常用的SQL解析库核心能力是把SQL文本拆成结构化的token流并提供格式化、语句拆分、关键字大小写归一化这些开箱即用的功能。它不校验SQL语法也不生成执行计划但这种“不管对错只管拆”的定位反而让它特别适合做SQL美化、代码审计、表血缘分析、慢SQL日志处理、注入风险检测辅助这些上下游工具。本文就围绕sqlparse把它能解决什么问题、怎么上手、常见坑在哪一次讲清楚。我尽量用实际项目里验证过的代码和思路来写让你看完可以直接在工程里复刻。1. sqlparse是什么一个不做语法校验的SQL分词器1.1 为什么正则在这件事上靠不住SQL这个语言对正则特别不友好。带括号的子查询、字符串和注释里的逗号、嵌套的CASE WHEN、JOIN ON后面的条件都会让“按逗号切分”“找FROM后面第一个词”这类正则方案翻车。举个例子SELECT id, name FROM user WHERE remark a,b如果只按逗号切分会把字符串内部的内容也当成字段边界更别说SELECT (SELECT 1 FROM t WHERE x))这种括号与字符串里同时出现特殊字符的写法。正则很难优雅处理递归嵌套而SQL天然就是嵌套结构。换用真正的解析器把文本解析成树再在树上做遍历这才是处理SQL的合理姿势。1.2 非校验解析器的定位sqlparse官方称自己是non-validating parser也就是非校验解析器。说白了它负责把文本切词把一段SQL变成一棵token树但不做完整语法校验不会告诉你SELECT FROM WHERE到底缺了什么。打个比方它就像把一段英文拆成单词和标点但不管这句话是否符合语法。这种设计带来的好处是健壮和轻量什么样的SQL传进来都不会崩也没有庞大的语法规则引擎依赖少、上手快。坏处是如果你需要判断SQL是否合法、能不能被执行sqlparse并不能直接回答。1.3 核心能力与应用地图基于这个定位sqlparse常见的使用方向有这么几类SQL格式化美化、批量脚本拆分、关键字大小写归一化、提取SQL里的表名和列名做数据血缘、在安全链路中辅助检测SQL注入特征以及把慢查询日志转成结构化数据做统计分析。它适合写运维脚本、数据治理工具、内部SQL平台、安全审计系统的开发者。如果只是偶尔想把一条SQL整理好看也可以直接把它当命令行工具用装完即用不折腾。2. 十分钟上手四个高频API的正确打开方式2.1 安装与环境准备pip install sqlparsesqlparse不依赖任何第三方库装完就能用。目前最新的0.5.x版本要求Python 3.8以上建议在虚拟环境里安装免得跟系统Python环境产生冲突。生产环境如果离线部署直接把wheel包拷进内网用pip install安装就行体积小没什么依赖包袱。2.2 parse把文本变成Statement对象parse是最基础的入口返回一个Statement列表。列表里的每个元素对应一条SQL语句即使你只传一句它返回的也是一个长度为1的列表。import sqlparse raw SELECT id, name FROM user WHERE age 18 statements sqlparse.parse(raw) print(len(statements)) # 1 stmt statements[0] print(type(stmt)) # class sqlparse.sql.Statement这个Statement对象就是后续所有分析操作的根节点。你可以把它直接打印内容跟原SQL一样也可以遍历它的tokens看内部结构。2.3 format一键格式化format是大多数人入坑sqlparse的理由用法很简单import sqlparse raw select id,name from user where age18 and status1 order by id desc; pretty sqlparse.format(raw, reindentTrue, keyword_caseupper) print(pretty)输出SELECT id, name FROM user WHERE age 18 AND status 1 ORDER BY id DESCreindentTrue会重新缩进并换行keyword_caseupper把关键字统一大写。这种格式化能力很适合做代码风格统一工具比如在CI流程里检查SQL提交风格或者在SQL平台里给查询结果做美化展示。2.4 split按分号拆多条SQLsplit按分号把一段脚本拆成多条SQL文本返回字符串列表。sqlparse.split(select * from a; select * from b;) # [select * from a;, select * from b;]要注意split是语法感知的它知道分号在字符串里不算语句结束所以不会误拆。我在处理存储过程、初始化脚本、慢查询日志时经常先split再逐条处理这是最省心的一条API。2.5 tokenize流水线式处理超大文本tokenize是更底层的入口返回生成器逐条产出Token对象。for token in sqlparse.tokenize(select * from a): print(token.ttype, token.value)parse底层也会调用tokenize但parse会把结果全部装进Statement列表大文件下内存占用偏高tokenize是惰性生成器适合几十上百MB的日志文件按流处理。3. 拆解tokenSQL在内存里长什么样3.1 Token对象ttype与valueToken是sqlparse的基础数据单元你可以把它理解成一个namedtuple主要由ttype和value两个字段组成。ttype表示token类型比如关键字、标识符、数字、字符串、标点、空白value则是原始文本。类型本身是有层级的Token.Keyword下面还有Token.Keyword.DML和Token.Keyword.DDLToken.Name下面还有Token.Name.Builtin。做判断时可以用token.ttype in sqlparse.tokens.Keyword这种包含关系来匹配整个大类。3.2 一条SQL的token拆解示例拿SELECT id, name FROM user WHERE age 18来说sqlparse解析出的顶层token大概是这样Token.Keyword.DMLSELECTToken.Text.Whitespace空格IdentifierListid, name内部又含Identifier、Punctuation、WhitespaceToken.Text.Whitespace空格Token.KeywordFROMToken.Text.Whitespace空格IdentifieruserToken.Text.Whitespace空格Token.KeywordWHEREToken.Text.Whitespace空格Comparisonage 18内部含Identifier、Operator、Number你可以写个脚本把这些结构打印出来第一次看到会很有获得感——平时当成纯文本处理的SQL在解析器眼里其实是分层明确的树状结构。3.3 Statement、TokenList与树的层级关系Statement是一种TokenListTokenList可以包含子TokenList比如Identifier、Where、Comparison、Parenthesis一层层套下去形成树。这个树可以类比DOM树操作时既可以直接遍历顶层的tokens也可以用flatten往下递归拉平。理解了这个树结构后面做表名提取、条件语句分析才不容易迷路。3.4 flatten把树拉成一条线flatten是TokenList提供的方法把整棵树拉平成叶子token的生成器非常适合“我只想找某个类型的token不在乎它在哪一层”的场景。for token in stmt.flatten(): if token.ttype in sqlparse.tokens.Name.Builtin: print(函数:, token.value)拉平后层级信息会丢失如果你还需要知道某个token的父节点是谁就得自己用递归遍历常见写法是配合get_sublists()逐步下钻。3.5 normalized比较时别管大小写SQL关键字不区分大小写所以sqlparse为Token提供了normalized属性把关键字统一成规范形态。比如select、SELECT、Select的normalized都是SELECT。做规则匹配时直接拿token.normalized跟期望值比较就行比自己写token.value.upper() FROM干净得多也不容易漏匹配。4. 实战一写一个可用的SQL格式化服务4.1 format参数逐个说清format的参数不少这里列一份我在项目里实际验证过的速查表参数默认值作用keyword_caseNone关键字大小写upper/lower/capitalizeidentifier_caseNone标识符大小写upper/lower/capitalizestrip_commentsFalse是否删除注释reindentFalse是否重新缩进换行indent_tabsFalse是否用制表符缩进indent_width2缩进空格数wrap_after0超过多少字符换行0表示不限制use_space_around_operatorsFalse比较、算术运算符两侧是否加空格keyword_caseNone表示保持关键字原样不强制改写。我一般设置成upper做代码审查时关键字一眼就能扫出来。identifier_case我不会随便开因为有些数据库的字段大小写是有意义的改错了反而麻烦。4.2 推荐的一组格式化组合实际做代码风格统一时我常用的一套组合是formatted sqlparse.format( sql, keyword_caseupper, reindentTrue, use_space_around_operatorsTrue, strip_commentsFalse )这套配置对MySQL的单条DML语句效果很好简单、可预期。实测下来逗号分隔的多字段、JOIN条件、ORDER BY、GROUP BY这些常见场景缩进都比较符合人的阅读习惯。但要注意顺序多语句脚本最好先split再逐条format。把多个语句一次性交给format缩进偶尔会乱因为reindent是按整段脚本的上下文来处理的。4.3 遇到方言特性别慌如果你是PG用户经常写::类型转换如果是SQL ServerCTE和WITH ... AS很常见Hive里还有LATERAL VIEW。这些方言特性sqlparse的reindent偶尔会让缩进变得别扭但文本内容不会丢。我遇到这种情况会把格式化结果只当展示层不拿它当可执行语句直接下发。如果只需要统一关键字大小写可以只开keyword_case不开reindent这样最稳不会改动原有结构。5. 实战二提取表名和字段名硬啃表血缘5.1 思路从FROM、JOIN后面的标识符入手提取表名是表血缘分析的第一步。思路很直接找到FROM、JOIN、INTO、TABLE、UPDATE这些关键字它们后面的Identifier大概率就是表名。但光靠一个关键字不够还要处理多表逗号分隔、带别名、带库名前缀、子查询括号等各种情况。sqlparse的好处在于它已经把Identifier和IdentifierList识别出来了我们只需要设计一个上下文判断逻辑。5.2 一个能跑的提取表名版本下面这套代码我在内部数据治理项目里用过能覆盖绝大多数单层查询import sqlparse from sqlparse.sql import Identifier, IdentifierList from sqlparse.tokens import Keyword def extract_tables(sql): tables set() stmt sqlparse.parse(sql)[0] expect_table False for token in stmt.tokens: if token.ttype in Keyword and token.normalized in ( FROM, JOIN, INTO, TABLE, UPDATE ): expect_table True continue if expect_table: if token.is_whitespace: continue if isinstance(token, IdentifierList): for ident in token.get_identifiers(): if isinstance(ident, Identifier) and not ident.is_keyword: tables.add(ident.get_real_name()) elif isinstance(token, Identifier) and not token.is_keyword: tables.add(token.get_real_name()) expect_table False return tables核心点是两处一是用token.normalized做关键字匹配大小写都不用管二是用get_real_name()去掉库名前缀和别名比如db.user u最终返回user。执行一下看看效果sql select u.id, o.amount from orders o join users u on o.user_id u.id print(extract_tables(sql)) # {orders, users}这个版本覆盖了JOIN和FROM场景也能处理多表逗号分隔。但很有必要说清楚它的局限遇到FROM (SELECT ...) t这种子查询时括号结构会让期望表名的逻辑失效需要再往下递归一层或者改用完整的词法树遍历。做企业级血缘工具时我会在这个基础版本上叠加对Parenthesis分支的递归处理而不是靠一条路走到黑。5.3 从INSERT、UPDATE、DELETE里取表名上面的代码把INTO、UPDATE、FROM都纳入了触发关键字所以INSERT INTO table_a、UPDATE table_b、DELETE FROM table_c都能提取到对应表名。字段名提取同理在INSERT后的(a, b, c)括号内、SELECT后的IdentifierList、UPDATE后的SET字段位置收集Identifier再结合父节点类型做过滤。sqlparse没有现成的“给我表名”接口但这些信息都摆在token树里按需取用即可。5.4 真实场景生成表间依赖清单有了单条SQL的表名提取函数批量跑文件就能聚合出依赖清单。我在数据仓库治理时就是这么做的脚本自动扫描全量SQL目录提取每张SQL涉及的表再汇总成上游下游关系最后喂给可视化工具生成血缘图。这个流程里sqlparse基本是标配因为不用它的话单是处理各种别名、子查询、括号嵌套就够喝一壶。6. 实战三慢SQL日志拆分与注入风险审计辅助6.1 把慢查询日志切成一条条干净的SQL慢查询日志不是纯SQL里面有# Time、# UserHost这类元信息行。用sqlparse前先把以#开头的元信息行过滤掉再用split拆成单条SQL最后统一格式化就能把几十万行日志压缩成一张可统计的表。import sqlparse def split_slow_log(text): sql_lines [] for line in text.splitlines(): if line.startswith(#): continue sql_lines.append(line) return sqlparse.split(\n.join(sql_lines))拆分后可以配合上一节的extract_tables统计哪些表在慢SQL里出现频率最高也可以按格式化后的SQL做文本聚合看看哪些SQL形态反复出现帮DBA快速定位重灾区。6.2 用sqlparse做风险点初筛在安全审计场景里sqlparse能帮你快速定位可疑指纹。最直接的检查项是“多语句”正常业务接口一次只能执行一条SQL如果传入文本解析出来有多条语句就需要警惕。另一个检查项是扫token树里的注释和危险关键字。import sqlparse def audit_sql(sql): issues [] stmts sqlparse.parse(sql) if len(stmts) 1: issues.append(存在多条语句疑似注入载荷) for stmt in stmts: for token in stmt.flatten(): if token.ttype in sqlparse.tokens.Comment: issues.append(f包含注释需核对是否绕过过滤: {token.value[:30]}) if token.ttype in sqlparse.tokens.Keyword and token.normalized in ( SLEEP, BENCHMARK, LOAD_FILE, INTO, OUTFILE ): issues.append(f出现危险关键字:{token.normalized}) return issues这套东西的价值在于它可以在安全网的入口处加一道外部规则检查把明显异常的内容拦在前面。但要强调一点任何检测工具都替代不了预编译和参数化真正的防线永远是参数绑定。sqlparse在这里的作用只是辅助审计帮助你在海量SQL里筛选出值得人工确认的样本。6.3 工具是中性的用在哪一方向很重要写这类检查代码方向一定要放在防御和审计上给业务系统做体检帮助发现潜在风险而不是拿来生成攻击样本。安全工具的价值在于让系统更稳这一点从设计的第一天就该确定。7. 踩坑清单我在生产里踩过的几个坑7.1 “不能校验语法”不是bug有人拿sqlparse当语法校验器发现SLECT * FROM t也能正常解析就以为是库出了问题。这是期望错了。sqlparse的定位是non-validating parser它的职责是切词和结构化不是判断SQL是否合法。如果你需要语法校验应该用数据库自身的parser能力或者考虑sqlglot这类更偏解析器方向的项目。简单说选工具前先理清需求美化、拆分、抽取用sqlparse校验语法能不能执行别指望它。7.2 分号与注释的处理顺序split返回的单条SQL会保留末尾分号和首尾空格做字符串归一化前记得strip。另外如果两条SQL之间夹着注释split的结果可能跟直觉不一致有时注释会被归属到下一条SQL的开头。我处理这类文本时会先过滤掉--、#开头的独立注释行再做split这样归属关系更可控。7.3 重格式化的方言兼容性窗口函数、数组运算、JSON操作符这些高级语法在不同数据库里差异很大sqlparse的格式化输出偶尔会出现“该换行没换行”“不该换行多换行”的情况。到这一步别硬刚针对目标方言做一点后处理规则或者干脆只做关键字大小写统一不开reindent反而更省事。生产环境我一般会先跑一批代表性SQL做回归测试确认格式化结果不会打乱原有语义再上线。7.4 大文件性能与内存一次性把几十MB的SQL文本全部parse内存容易撑不住。更好的做法是用tokenize或split做流式处理配合生成器逐条消费。我在处理日志型数据时习惯按文本块分批比如先按行读入攒到一个阈值就切一段处理内存占用能控制在几百MB以内处理速度也足够。7.5 编码问题SQL文本尽量传str。从文件读的时候先确认源文件编码MySQL导出的SQL经常是utf8或gbk混着读进来之前统一decode否则中文注释会乱码关键字判断也可能因为编码问题出岔子。这个坑很基础但一旦踩到会浪费不少排查时间。我现在的习惯是任何SQL文件入口第一步都是强制转成utf-8再进sqlparse。sqlparse本身不复杂复杂的是你拿它解决什么问题的思路。我自己的习惯是遇到SQL文本处理第一件事先归类——是要美化、要拆分、还是要抽表名然后直接用sqlparse接住后面再补业务逻辑。这套打法在数据治理、慢日志统计、安全审计好几个项目里都验证过省下来的时间相当可观。如果你也经常被批量SQL搞得头大可以先把我上面给的几个例子跑一遍多数场景都能直接复刻到你的脚本里。