我的静态扫描连错三次:为什么能用运行环境当裁判就别做静态分析
1. 任务
一套系统因为表名大小写问题在 Linux 上部分功能报错(背景见另一篇复盘)。修复前需要先回答一个看似简单的问题:
源码里到底有多少处用了大写表名?
这个数字决定了修复方案的选型:如果只有两三处,直接改代码;如果有几十处,就得换思路。
这是一个典型的"用正则扫一遍就能出结果"的任务。我扫了三次,错了三次,而且每一次的结果都"看起来很合理"。
这篇文章记录这三次翻车,以及从中提炼出的一条原则。
2. 第一次:跨行 SQL 漏匹配
第一版正则:
python
READ = re.compile(r'\b(FROM|JOIN|INTO|UPDATE)\s+([A-Z_]{4,})\b')
思路很直接:找 SQL 关键字后面跟着的全大写标识符。
结果:5 张表。
看起来数量可控,我一度准备按"改这 5 处代码"来推进。
错在哪 :mapper XML 里的 SQL 是格式化过的,关键字和表名不在同一行:
xml
<select id="selectLinkTemplateName" resultType="...">
SELECT
TEMPLATE_CONTRACT.ID,
TEMPLATE_CONTRACT.TEMPLATE_NAME
FROM
TEMPLATE_CONTRACT <!-- FROM 和表名分行 -->
WHERE
TEMPLATE_CONTRACT.DEL_FLAG = 0
</select>
\s+ 虽然能匹配换行符,但我在测试时用的样本恰好都是单行的,没意识到问题。真正漏掉的原因是另一个细节:这类多行 SQL 里 FROM 后面紧跟的是换行 + 大量缩进,而我在某个版本里把 \s+ 写成了 [ \t]+,直接把跨行情况排除了。
这次错误的隐蔽之处:它不报错,只是少给你几个结果。而 5 这个数字看起来完全合理,没有任何"不对劲"的信号。
3. 第二次:把 Java 枚举常量当成了表名
修好跨行问题,扩大扫描范围到所有 .java 和 .xml:
python
for t in all_tables:
if re.search(r'(?<![A-Za-z0-9_])' + t.upper() + r'(?![A-Za-z0-9_])', content):
hits[t].add(path)
思路变了:不再找 SQL 关键字,而是拿建表脚本里的所有表名,逐个去源码里搜它的大写形式。
结果:37 张表,散落在约 40 个文件里。
这个数字把我吓到了,我据此告诉用户"改代码是打地鼠,建议改数据库配置"。
错在哪 :这个正则匹配的是任意位置的大写标识符,不区分它是不是表名。命中的东西包括:
java
// 枚举值
public enum UserType {
SYS_USER("sys_user", "系统用户"),
APP_USER("app_user", "APP用户");
}
// 常量
public class UserConstants {
public static final String SYS_USER = "sys_user";
}
// 多租户配置里列出的表名清单(这是配置,不是查询)
public class TenantProperties {
private List<String> excludes = Arrays.asList("ACT_RU_TASK", "ACT_HI_TASKINST", ...);
}
这些全被算成"用大写表名查询"了。
戳破它的不是更好的正则,是一个常识判断 :SYS_USER 被报告为"在 SysUserMapper.java 里被大写引用"。如果这是真的,登录功能应该整个挂掉------但系统能正常登录。
矛盾出现了,说明扫描结果有假阳性。
这里值得停一下:如果没有"登录是好的"这个外部事实,我不会发现第二次扫描是错的。
验证静态分析结果的,往往是运行时的事实。
4. 第三次:忘了 re.IGNORECASE
第三版,改成只在 SQL 上下文里找,并且区分读写:
python
READ = re.compile(r'(?:FROM|JOIN)\s+([A-Z][A-Z0-9_]{3,})(?![A-Za-z0-9_])')
WRITE = re.compile(r'(?:INSERT\s+INTO|UPDATE|DELETE\s+FROM)\s+([A-Z][A-Z0-9_]{3,})(?![A-Za-z0-9_])')
结果:14 张表,其中写操作 0 张。
"0 张写操作"这个结论很关键------它意味着可以用只读视图解决,方案一下子简单了。我把这个结论告诉了用户。
错在哪 :两个正则都没加 re.IGNORECASE。
它们只能匹配大写的 FROM / JOIN / UPDATE。而源码里的 SQL 关键字大小写是混着写的:
java
@Select("SELECT a.ID, a.CONTRACT_NAME "
+ "from CONTRACT_DRAFT a INNER JOIN CONF_ARCHIVES b on a.ID = b.id ")
// ↑ 小写 from,漏掉 CONTRACT_DRAFT
// ↑ 大写 JOIN,命中 CONF_ARCHIVES
同一行里,INNER JOIN CONF_ARCHIVES 被匹配到了,from CONTRACT_DRAFT 被漏掉了。所以结果不是"全漏"而是"漏一半"------这种半对半错的结果最难察觉。
更糟的是"0 张写操作"这个结论:小写的 insert into / update 全部没被扫到,这个结论根本不可靠。而它恰恰是方案选型的关键依据。
加上 re.IGNORECASE 重扫:22 张表,其中 3 张有写操作。
5. 三次错误的共同点
| 次数 | 结果 | 错因 | 为什么没被立刻发现 |
|---|---|---|---|
| 1 | 5 张 | 跨行 SQL 漏匹配 | 数字小,看着合理 |
| 2 | 37 张 | 把枚举/常量当表名 | 数字大,符合"祖传代码很乱"的预期 |
| 3 | 14 张 / 0 写 | 忘了 IGNORECASE | 半对半错,无异常信号 |
共同点很清楚:
每一次的输出都是一个语法正确、格式整齐、看起来可信的清单。没有任何一次会抛异常、报警告、或者返回明显荒谬的东西。
静态分析的失败模式是静默的。它不会告诉你"我可能漏了",它只会给你一个数字,而你会相信它。
而且这三个错误分别属于三个不同的类别:
- 第一次是模式覆盖不全(没考虑跨行)
- 第二次是语义理解缺失(分不清标识符的用途)
- 第三次是低级实现疏忽(忘加 flag)
也就是说,即使前两类想清楚了,第三类还是会咬你一口。这不是"再仔细一点"能解决的问题。
6. 转折:让数据库来裁判
第四版彻底换思路。不再问"源码里有多少大写表名引用",而是问:
哪些大写引用,在真实数据库里找不到对应的对象?
这个问题有一个权威答案源:information_schema。
脚本改成三步:
python
# 1. 本地扫源码,找出全大写的表名引用(仍然用正则,但只作为候选集)
refs = scan_source()
# 2. 拉服务器真实表名,BINARY 区分大小写
rows, _ = mysql(ssh,
"SELECT BINARY table_name, table_type FROM information_schema.tables "
f"WHERE table_schema='{DB_NAME}';")
base_tables = {n for n, t in real_tables.items() if t == "BASE TABLE"}
views = {n for n, t in real_tables.items() if t == "VIEW"}
# 3. 三方比对,逐个判定
for upper in sorted(refs):
if upper in base_tables: # 大写名本来就是真实表 -> 不动
protected.append(upper)
elif upper in views: # 已处理过 -> 幂等跳过
already_ok.append(upper)
elif upper.lower() in base_tables: # 小写同名表存在 -> 需要建视图
to_create.append((upper, upper.lower()))
else: # 两者都不存在 -> 扫描误报
unresolved.append(upper)
关键变化:正则的角色从"给出答案"降级成"给出候选"。
正则依然可能漏、可能多报,但:
- 多报会在第 3 步被过滤掉(库里没这个表 → 归为误报)
- 误伤真实大写表 会被
upper in base_tables拦住 - 唯一还会漏的是"正则完全没扫到的引用",但由于候选集只需要宁滥勿缺,我可以把正则写得更宽松,把精确性交给数据库
运行结果:
需要建视图 : 17
已是视图 : 0 (幂等跳过)
真实表/受保护: 4 (ACT_* 引擎自建表)
扫描误报 : 5 (库里没有对应表)
7. 意外收获:误报里藏着另一个真问题
那 5 个"误报",我顺手去建表脚本里查了一下------五个全都不在建表脚本里。
它们对应四个业务模块:代码在,表不在。这是开源版本身裁掉的功能,点进那些菜单会直接报表不存在。
如果第 3 步只是简单地 if not found: skip,这个信息就丢了。因为脚本把它单独归为一类并打印出来,才顺藤摸瓜发现了另一个待处理的问题。
**分类输出比布尔判断有信息量。**不要把"不处理"和"没发现"混在一起。
8. 原则
从这次经历里提炼出一条我准备长期执行的原则:
凡是能拿运行环境当裁判的,就不要用静态分析下结论。
展开说:
① 静态分析适合"生成候选",不适合"给出答案"
正则、AST 扫描这些手段的价值在于快速缩小范围。一旦你打算基于它的输出做决策(选方案、报数字、写进交付文档),就必须找一个权威源交叉验证。
② 运行环境里有大量现成的权威源
- 数据库:
information_schema(表、列、索引、约束的真实状态) - HTTP 接口:直接 curl,看真实状态码
- 编译器 / 类型检查器:比正则懂语义
- 运行时日志:真实发生了什么
这些的共同特点是它们不猜。
③ 用外部事实反证你的结论
第二次扫描的错误,是被"系统能正常登录"这个事实戳破的。养成习惯:拿到一个静态分析结论后,主动找一个"如果这个结论成立,那么 X 应该发生"的可验证推论。
④ 报告结论时说清楚它的来源
我犯的一个附带错误是:把静态扫描的中间结果当成事实汇报了三次,每次都要推翻重来。正确做法是------在还没交叉验证时,明确标注"这是静态扫描的推测值,待验证"。
9. 边界
这条原则不是无限适用的。运行环境当裁判有前提:
- 得有一个可访问的、状态正确的运行环境。全新项目、还没建库的阶段,只能靠静态分析
- 运行环境本身可能不是"正确"的基准。比如生产库缺表,那它反映的是现状而非应然状态
- 有些问题运行时查不到。比如"这段代码永远不会被执行",得靠静态分析
- 成本。跑一次真实环境比跑一次正则慢得多,快速迭代阶段可以先用静态分析探路
所以更准确的表述是:在有权威运行时数据可用、且结论会影响决策的场景下,优先用运行时数据校准静态分析结果。
10. 小结
一次任务,同一个问题,扫了四遍才对。前三遍分别栽在模式覆盖、语义理解、和一个 re.IGNORECASE。
真正的收获不是"正则要写仔细点"------第三次的错误证明了仔细是不够的,人总会漏。收获是换一个不依赖我写对正则的架构:让正则只负责宁滥勿缺地圈出候选,让数据库负责判定。
这样即使正则再错一次,错误也会被下游的比对步骤拦住,而不是直接变成一个"看起来很合理"的错误结论。