
最近踩了个坑,让我认真想了一件事:开发者工具到底在跟什么竞争?
2026年5月20日,AWS停用了DMS Fleet Advisor。
这是个什么东西?它是AWS出的免费数据库迁移评估工具,帮你回答每个迁移团队最开始都会问的问题:我的数据库资产里到底有什么?迁过去难度有多大?免费、全托管、靠山是全球最大的云厂商。
然后它就被停用了。
AWS的官方公告就一句话:"经过慎重考虑,我们决定停止对AWS DMS Fleet Advisor的支持。"没有给原因。但你不需要它给原因——文档里写得清清楚楚。在Fleet Advisor能告诉你任何关于数据库的信息之前,它要求你完成这些前置工作:
然后你还会碰到天花板:一次最多评估100个数据库、一对一的目标映射、不支持多租户服务器。
现在想象你在一家银行里跑这个流程。你是一个业务解决方案架构师(BSA),领导让你评估一个迁移项目。你还没有拿到迁移的批准——而评估结果本身就是用来申请批准的。为了做出评估,你必须先申请生产数据库的访问权限、把一个代理程序通过软件审批、配置OSS bucket、让一套IAM资源通过安全审查。
这是一个需要六周才能完成的采购对话,而你本来是希望这周就能回答这个问题的。
AWS给的替代方案是Migration Evaluator——一个需要咨询介入的项目。说白了就是:AWS看了自助式迁移评估这件事,认为人工加服务比做一个产品效果更好。
我觉得他们只对了一半。而那错的一半,恰恰是最有意思的部分。
我们谈开发者工具的时候,总觉得它们是在能力上竞争。Fleet Advisor的能力比大多数团队实际使用的替代方案强多了。你知道那个替代方案是什么吗?
在SSMS里打开存储过程,一个一个读。
手动读一千个存储过程,客观来说烂透了。要花几周时间,容易出错,不能扩展。但它打败了AWS这个免费、全托管的服务——因为它不需要任何审批、不需要凭证、不需要代理、不需要跟任何人沟通。
真正的竞争对手从来不是数据库迁移工具(SCT)或者第三方厂商。竞争对手是一个会用Ctrl+F的人,而这个人的设置时间是零。
当你的工具"从第一次使用到产生价值"的时间是以周为单位的采购流程时,你不是在跟其他工具竞争。你是在跟"人们就这么手动干,凑合干,一直干下去"竞争。在这场较量中,能力没有你想象的那么值钱。
这完全重新定义了设计问题。不是问"我能做出最完整的评估吗",而是问"在一个BSA电脑上已有的东西里,不用任何审批,我能做出最有用的东西是什么?"
这个问题的答案是:相当多。
他们没有凭证。没有云账号。没有安装代理的权限。
他们有一个满是.sql文件的Git仓库。
所以我做了SPXray——一个开源静态分析器,只需要这些,不需要别的。丢.sql文件进浏览器,大约30秒返回每个存储过程涉及的所有物理表、Schema、列和CRUD操作。不用安装。不用代理。不用IAM角色。不用连接数据库——这不是限制,是设计约束。
它所处的工具生态:
| 工具 | 前置要求 | 回答的问题 |
|---|---|---|
| 阿里云数据传输服务(DTS) | 实时数据库连接 + 云账号 | 这个能转成阿里云数据库吗? |
| AWS DMS Fleet Advisor | 收集器、OSS、IAM、数据库凭证 | 我的数据库集群里有什么? — 2026年5月已停用 |
| SQLFlow / GSP | 商业许可证 | 这些数据怎么流转? |
| sqlglot | 无 | 解析这个查询 |
| SPXray | 一堆.sql文件 | 这次迁移涉及哪些内容? |
这是四个完全不同的问题。这不是"更好的DTS"——DTS免费且在数据转换方面做得很好,我会告诉你用它做那个。它是一个不同的工种,在不同的时间点,给有不同权限的人用的。
这里是做解析器的人会感兴趣的部分。
你可能会理所当然地认为正确做法是:别用正则表达式,用AST解析器。sqlglot很棒、免费、无依赖、支持31种方言。用它就行了。
问题是,2026年一个独立的解析器对比测评发现,给一段T-SQL存储过程,sqlglot返回"包含不支持的语法,回退为按'Command'解析"。它没有抛异常——但结果丢失了过程体的结构理解。TRY/CATCH块、变量声明、控制流在语法树里根本没有表示。他们的结论是:"如果你需要从存储过程中提取血缘关系,Command回退模式无法提供。"
General SQL Parser处理了所有情况,包括棘手的Oracle和存储过程模式。但用他们的话说,这是"商业产品,有授权费用。"
所以解析T-SQL存储过程体内部的市场是:免费的会回退,能用的要花钱。这个空白就是这个项目的全部技术依据。手动构建提取引擎通常都是错误答案——但在这里,它是唯一免费的选项。
值得大声声明的警告:SQLFlow的解析器在将近二十年里针对13000+真实SQL用例的回归语料库进行商业化开发。我只有几十个。如果你需要跨所有方言的解析精度,而且能付得起钱,那就付。这不是假谦虚——这是诚实的边界,我宁可你在这里读到,也不要在生产环境里发现它。
玩具SQL能过。生产SQL才是解析器挂掉的地方。每一个都上线了,每一个现在都成了永久的回归测试。
SSMS默认以Windows-1252编码保存.sql文件。注释里的破折号或者项目符号是字节0x96或0x95——无效的UTF-8。
# 这个bug:读到文件中间抛异常,而取决于你怎么处理它,
# 坏字节之后的所有内容静默消失了
text = Path(f).read_text(encoding='utf-8')
# UnicodeDecodeError: 'utf-8' codec can't decode byte 0x95 in position 1247
灾难性的失败不是那个异常。是你捕获异常、回退、然后丢失了坏字节之后的内容——因为现在你的报告是"自信地错了"。文件中存在的表在报告里不存在,而没有任何东西告诉用户这一点。
def read_bytes_safe(raw: bytes) -> str: """先读整个buffer,再解码。永远不要分段。""" if raw.startswith(b'\xef\xbb\xbf'): # 去除BOM raw = raw[3:] for enc in ['utf-8', 'windows-1252', 'cp1252', 'latin-1']: try: return raw.decode(enc) except (UnicodeDecodeError, LookupError): continue # latin-1接受所有256个字节值,所以我们永远不会到这里—— # 但如果到了,用替换而不是丢弃。 return raw.decode('latin-1', errors='replace')
测试断言的是不变式,而不是机制:
def test_bad_bytes_never_truncate_file(): raw = (b"SELECT a.Id FROM dbo.TableAlpha a;\n" b"-- smart quote \x92 en-dash \x96 bullet \x95\n" b"SELECT b.Id FROM dbo.TableBravo b;\n") physical, _ = parse_sp(read_bytes_safe(raw)) assert "DBO.TABLEBRAVO" in physical # 坏字节之后的内容存活了
SELECT o.OrderID FROM sales.Orders o; -- o = Orders
SELECT o.OfficeName FROM hr.Offices o; -- o = Offices
为整个存储过程构建一个别名映射,"OfficeName"就会落在sales.Orders上。修复方法是作用域隔离:别名映射按DML语句构建,永远不要全局构建。事后看很明显;直到真实SQL在同一个过程里把o、p、c复用十五次之前都看不出来。它总是会这么干的。
SELECT LE.[Party ID], LE.[Scheduled Review Date] FROM ...
任何基于token的解析器都会把[Party ID]拆成PARTY和ID两个token。现在你的报告说有两个根本不存在的列,而遗漏了真正存在的那一个。
修复方法是在整个提取流程前后做一次normalize/restore:
def normalize_bracketed(sql: str): """[Party ID] -> PARTY_ID,同时保留到显示形式的映射。""" mapping = {} def replacer(m): inner = m.group(1).strip() normed = re.sub(r'\s+', '_', inner).upper() mapping[normed] = inner return normed return re.sub(r'\[([^\]]+)\]', replacer, sql), mapping
在normalize空间里解析,输出时恢复显示名称。
这是我最不引以为豪、也学到最多的一个bug。
SELECT c.Id FROM dbo.Customers c
WHERE c.Notes = 'migrated FROM dbo.PhantomTable last year'
FROM模式在字符串字面量内部匹配了。报告里列出了dbo.PhantomTable——一个在任何地方都不存在的表,是从某人在数据字段里写的一句注释里生造出来的。
更糟的是。同一个bug还从动态SQL里刮出了表名:
DECLARE @s NVARCHAR(MAX) = N'SELECT * FROM dbo.SecretTable';
EXEC sp_executesql @s;
工具把这段存储过程标记为"包含动态SQL——无法静态分析,需人工审查"同时在结果里报告了dbo.SecretTable。两句话不可能同时为真。你要么能分析,要么不能。工具在同一个屏幕上自相矛盾。
修复——在注释之外同时遮蔽字面量内容:
def mask_string_literals(sql: str) -> str: """清空带引号字面量的内容,保留长度。""" def blank(m): return "'" + ("\u0000" * (len(m.group(0)) - 2)) + "'" return re.sub(r"'(?:[^']|'')*'", blank, sql) def clean_sql(sql: str) -> str: sql = re.sub(r'/\*.*?\*/', ' ', sql, flags=re.DOTALL) # 块注释 sql = re.sub(r'--[^\n]*', ' ', sql) # 行注释 sql = mask_string_literals(sql) # 字面量内容 return re.sub(r'\s+', ' ', sql).strip()
真正的教训不是那个正则表达式。是这个:对于一个人们会围绕它规划迁移的工具来说,错误答案比缺失答案糟糕得多。漏一个表会在审查时被发现。生造一个表会让人花一个小时在schema里翻找,然后悄悄地摧毁他对报告中每一行数据的信任。
如果一个BSA围绕你的输出规划了一次迁移,而你静默地漏掉了一个表,他们会在周六凌晨两点发现。风险模型决定了特定的测试策略——不是"追覆盖率"。覆盖率衡量的是执行的代码行数,不是兑现的承诺。
我测试六个合同——如果被打破,就意味着工具在说谎的承诺:
| 合同 | 断言 |
|---|---|
| 确定性 | 同一输入的五次运行产生字节级相同的输出 |
| 仅物理表 | 临时表和CTE名称永远不会作为表出现 |
| 无静默丢弃 | 坏字节永远不会截断文件 |
| 绝不捏造 | 有歧义的列不猜测,标记为未解析 |
| 动态SQL诚实 | EXEC/sp_executesql始终被标记 |
| 离线运行 | 解析器不发起任何网络请求 |
最后一条是真正的测试,不是政策声明:
def test_parsing_makes_no_network_calls(monkeypatch): import socket def boom(*a, **k): raise AssertionError("解析器尝试了网络连接") monkeypatch.setattr(socket.socket, "connect", boom) physical, _ = parse_sp(sql_fixture("multi_cte_report.sql")) assert physical
企业会问"它会不会偷偷联网?"README里写一句话是断言。CI里写一个测试是证据。
每个fixture都有一份committed的JSON快照,记录解析器的精确输出。任何改变现有fixture输出的修改都会大声失败,显示结构化diff:
Parser output changed for multi_cte_report.sql TABLE LOST: RISK.RATINGDETAILS DBO.PARTY.columns ADDED: ['ACTIVE']
这个失败是一个问题,不是裁决:我有意这样改的吗?新输出更好吗?如果是,执行pytest --update-golden然后commit这个diff——reviewer读它来理解真正的行为变化。如果不是,你刚刚在发布前抓到了一个回归。
这就是"我的改动破坏已经工作的东西了吗"如何被机械地回答,而不是凭运气地回答。没有任何golden diff变化的parser PR什么都没改。有golden diff的PR必须解释它。
这是我所采用的最有用的模式。每个已知缺陷都是一个标记了xfail(strict=True)的测试:
@pytest.mark.xfail(strict=True, reason="KL-1: 输出别名被报告为物理列")
def test_cte_output_alias_not_reported_as_physical_column(): sql = """ ;WITH PartyDetails AS (SELECT Id as 'Party ID' FROM dbo.Party) SELECT PD.[Party ID] FROM PartyDetails PD """ physical, _ = parse_sp(sql) cols = physical["DBO.PARTY"]["columns"] assert "ID" in cols # 真正的源列 assert "Party ID" not in cols # 输出别名不是物理列
当bug存在时,CI是绿的——它按预期失败。当有人修了它,测试XPASSes,CI变红,强制他们把它提升为真正的断言并更新公开的 limitations 表格。
已知限制不会静默腐烂成谎言。这比我多十分覆盖率更有价值。
上面KL-1是真实存在的未修复问题:给定SELECT Id AS 'Party ID',工具目前会把'Party ID'报告为dbo.Party的一个列。它不是——这是一个输出别名。修复它需要别名→源绑定,而这恰恰是正则表达式无法可靠做到、而AST可以做到的事。这是路线图上混合后端方案最有说服力的论据。
我宁可在这里失去你,也不要浪费你一下午:
代码仓库:github.com/AutoShiftOps/spxray — Apache-2.0协议,68个测试,4个公开记录的限制。
在线演示:spxray.vercel.app
最有用的贡献不是功能——是一个导致失败的SQL片段。如果它漏掉了你SQL里的一个表,开一个issue,附上最小复现(仅结构,假名字),它就成为一个永久回归测试。这是从几十个fixture变成几百个fixture的唯一路径,而这才是通往任何人都应该信任的解析器的唯一道路。