2026-09-10-静态扫描连错三次-用运行环境当裁判

我的静态扫描连错三次:为什么能用运行环境当裁判就别做静态分析

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

真正的收获不是"正则要写仔细点"------第三次的错误证明了仔细是不够的,人总会漏。收获是换一个不依赖我写对正则的架构:让正则只负责宁滥勿缺地圈出候选,让数据库负责判定。

这样即使正则再错一次,错误也会被下游的比对步骤拦住,而不是直接变成一个"看起来很合理"的错误结论。

相关推荐
天涯浪客6 小时前
静态 → LLM → 动态:一条 6 模块流水线,把 Python 漏洞告警噪声压到 4.3%
人工智能
蜗牛互联网6 小时前
语音AI开始边听边说,改变的不只是响应速度
java·人工智能·后端·语音识别
一切皆是因缘际会6 小时前
轻量化端侧部署
人工智能
Maxkim6 小时前
我把一个"会自己查资料"的 AI 助手塞进了浏览器侧边栏,开源了
前端·javascript
FL16238631296 小时前
苹果果梗枝条识别分割数据集labelme格式712张3类别
人工智能
齐齐大魔王6 小时前
机器学习(七)
人工智能·机器学习
跨境卫士—小依6 小时前
2026跨境电商数据分析入门:用指标判断选品与投放是否有效
大数据·人工智能·数据分析·跨境电商·营销策略
mONESY6 小时前
LangChain 结构化输出进阶:with_structured_output 做了什么,以及它为什么流不起来
javascript
m4Rk_7 小时前
【论文阅读】Agent 记忆机制(68):Memp——把历史轨迹沉淀为可检索、可纠错的程序性记忆
论文阅读·人工智能·学习·开源·github