site logo

Marico's space

AWS停用了免费的数据库迁移评估工具。原因应当改变开发者工具的构建方式。

编程技术 2026-07-30 11:29:15 7

最近踩了个坑,让我认真想了一件事:开发者工具到底在跟什么竞争?

2026年5月20日,AWS停用了DMS Fleet Advisor。

这是个什么东西?它是AWS出的免费数据库迁移评估工具,帮你回答每个迁移团队最开始都会问的问题:我的数据库资产里到底有什么?迁过去难度有多大?免费、全托管、靠山是全球最大的云厂商。

然后它就被停用了。

AWS的官方公告就一句话:"经过慎重考虑,我们决定停止对AWS DMS Fleet Advisor的支持。"没有给原因。但你不需要它给原因——文档里写得清清楚楚。在Fleet Advisor能告诉你任何关于数据库的信息之前,它要求你完成这些前置工作:

  1. 在本地环境安装一个独立的数据收集器
  2. 创建一个阿里云OSS(对象存储服务) bucket
  3. 创建IAM(身份和访问管理)策略、角色和用户——官方推荐通过资源编排(类似CloudFormation)来做
  4. 在每个源数据库上创建具有最低权限要求的数据库用户
  5. 确保收集器能访问每台数据库服务器的网络

然后你还会碰到天花板:一次最多评估100个数据库、一对一的目标映射、不支持多租户服务器。

现在想象你在一家银行里跑这个流程。你是一个业务解决方案架构师(BSA),领导让你评估一个迁移项目。你还没有拿到迁移的批准——而评估结果本身就是用来申请批准的。为了做出评估,你必须先申请生产数据库的访问权限、把一个代理程序通过软件审批、配置OSS bucket、让一套IAM资源通过安全审查。

这是一个需要六周才能完成的采购对话,而你本来是希望这周就能回答这个问题的。

AWS给的替代方案是Migration Evaluator——一个需要咨询介入的项目。说白了就是:AWS看了自助式迁移评估这件事,认为人工加服务比做一个产品效果更好。

我觉得他们只对了一半。而那错的一半,恰恰是最有意思的部分。

The lesson: friction is a competitor, and it usually wins

我们谈开发者工具的时候,总觉得它们是在能力上竞争。Fleet Advisor的能力比大多数团队实际使用的替代方案强多了。你知道那个替代方案是什么吗?

在SSMS里打开存储过程,一个一个读。

手动读一千个存储过程,客观来说烂透了。要花几周时间,容易出错,不能扩展。但它打败了AWS这个免费、全托管的服务——因为它不需要任何审批、不需要凭证、不需要代理、不需要跟任何人沟通。

真正的竞争对手从来不是数据库迁移工具(SCT)或者第三方厂商。竞争对手是一个会用Ctrl+F的人,而这个人的设置时间是零。

当你的工具"从第一次使用到产生价值"的时间是以周为单位的采购流程时,你不是在跟其他工具竞争。你是在跟"人们就这么手动干,凑合干,一直干下去"竞争。在这场较量中,能力没有你想象的那么值钱。

这完全重新定义了设计问题。不是问"我能做出最完整的评估吗",而是问"在一个BSA电脑上已有的东西里,不用任何审批,我能做出最有用的东西是什么?"

这个问题的答案是:相当多。

What a BSA actually has

他们没有凭证。没有云账号。没有安装代理的权限。

他们有一个满是.sql文件的Git仓库。

所以我做了SPXray——一个开源静态分析器,只需要这些,不需要别的。丢.sql文件进浏览器,大约30秒返回每个存储过程涉及的所有物理表、Schema、列和CRUD操作。不用安装。不用代理。不用IAM角色。不用连接数据库——这不是限制,是设计约束。

它所处的工具生态:

工具 前置要求 回答的问题
阿里云数据传输服务(DTS) 实时数据库连接 + 云账号 这个能转成阿里云数据库吗?
AWS DMS Fleet Advisor 收集器、OSS、IAM、数据库凭证 我的数据库集群里有什么? — 2026年5月已停用
SQLFlow / GSP 商业许可证 这些数据怎么流转?
sqlglot 解析这个查询
SPXray 一堆.sql文件 这次迁移涉及哪些内容?

这是四个完全不同的问题。这不是"更好的DTS"——DTS免费且在数据转换方面做得很好,我会告诉你用它做那个。它是一个不同的工种,在不同的时间点,给有不同权限的人用的。

The technical problem: nobody parses procedure bodies for free

这里是做解析器的人会感兴趣的部分。

你可能会理所当然地认为正确做法是:别用正则表达式,用AST解析器。sqlglot很棒、免费、无依赖、支持31种方言。用它就行了。

问题是,2026年一个独立的解析器对比测评发现,给一段T-SQL存储过程,sqlglot返回"包含不支持的语法,回退为按'Command'解析"。它没有抛异常——但结果丢失了过程体的结构理解。TRY/CATCH块、变量声明、控制流在语法树里根本没有表示。他们的结论是:"如果你需要从存储过程中提取血缘关系,Command回退模式无法提供。"

General SQL Parser处理了所有情况,包括棘手的Oracle和存储过程模式。但用他们的话说,这是"商业产品,有授权费用。"

所以解析T-SQL存储过程体内部的市场是:免费的会回退,能用的要花钱。这个空白就是这个项目的全部技术依据。手动构建提取引擎通常都是错误答案——但在这里,它是唯一免费的选项。

值得大声声明的警告:SQLFlow的解析器在将近二十年里针对13000+真实SQL用例的回归语料库进行商业化开发。我只有几十个。如果你需要跨所有方言的解析精度,而且能付得起钱,那就付。这不是假谦虚——这是诚实的边界,我宁可你在这里读到,也不要在生产环境里发现它。

Four bugs that only real SQL finds

玩具SQL能过。生产SQL才是解析器挂掉的地方。每一个都上线了,每一个现在都成了永久的回归测试。

1. The encoding bug that ate half a file

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 # 坏字节之后的内容存活了

2. Alias collision across statements

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复用十五次之前都看不出来。它总是会这么干的。

3. Bracketed multi-word identifiers

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空间里解析,输出时恢复显示名称。

4. The one that mattered: inventing tables from string literals

这是我最不引以为豪、也学到最多的一个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里翻找,然后悄悄地摧毁他对报告中每一行数据的信任。

Testing a tool whose product is trust

如果一个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里写一个测试是证据。

Golden files: making behaviour changes reviewable

每个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必须解释它。

Strict xfail: known bugs that cannot rot

这是我所采用的最有用的模式。每个已知缺陷都是一个标记了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可以做到的事。这是路线图上混合后端方案最有说服力的论据。

What this is not

我宁可在这里失去你,也不要浪费你一下午:

  • 不是转换器。它不会把T-SQL重写成PL/pgSQL。阿里云数据传输服务免费且做那个做得很好。
  • 不是血缘平台。没有跨你整个数据库资产的源→目标列级血缘追踪。用SQLFlow或Dataedo。
  • 永远不连接数据库。设计如此,永久不变。文件进,报告出。
  • 永不执行你的SQL。这就是为什么动态SQL是硬边界,不是bug。如果你付得起二十年解析器的钱,那就付。如果你需要转换,用DTS。这个工具只存在于一个特定时刻:你有SQL文件,没有凭证,有个问题周五就要答案。

Takeaways

  1. 从第一次使用到产生价值的时间是一个特性,而且往往是唯一重要的特性。一个免费的AWS服务输给了Ctrl+F,因为Ctrl+F不需要任何审批。算算你工具的初始设置步骤有几个。那个数字直接跟你的能力竞争。
  2. 为你的用户实际拥有的权限做设计,而不是架构图假设的权限。一个BSA有Git仓库,没有生产凭证。
  3. 对于分析工具,错误比缺失更糟糕。捏造的输出摧毁正确输出的信任。让"绝不猜测"成为一个测试,不是愿望。
  4. 确定性是一个可以卖的功能。"相同输入,相同输出,每次运行都一样"是任何基于LLM的工具都无法写进合同的——而这正是"一份你能在审查中捍卫的报告"和"一份你无法捍卫的报告"之间的区别。
  5. 发布你的限制,让CI强制执行它们。strict-xfail把你诚实的空白变成机器可检查的事实,一旦它们变化就中断构建。

代码仓库:github.com/AutoShiftOps/spxray — Apache-2.0协议,68个测试,4个公开记录的限制。

在线演示:spxray.vercel.app

最有用的贡献不是功能——是一个导致失败的SQL片段。如果它漏掉了你SQL里的一个表,开一个issue,附上最小复现(仅结构,假名字),它就成为一个永久回归测试。这是从几十个fixture变成几百个fixture的唯一路径,而这才是通往任何人都应该信任的解析器的唯一道路。