git bisect 两小时定位到一次提交:从 40 个候选里二分

结算回调偶发 NPE,六天四十多个提交没人认得出是哪次引入。先把偶发变必现,再把判定交给脚本做二分,两次跑偏后锁到一条「纯重构」提交。

结算回调接口从上周起偶发 500,一天十几次,报警群里没人认领,因为「本地复现不了,日志里那个空指针看起来不可能发生」。最先接手的人把那段代码读了四遍,order.getChannel() 明明在上一层做过判空,怎么会 NPE。

我接手的时候,这个接口已经断续报错六天。六天里仓库推了四十多次提交,动了八十多个文件,其中一半是「顺手整理」和「统一命名」。git log 一屏一屏往下翻,每一条提交信息都写着「优化」「调整」「修复小问题」,看完还是不知道哪一条有问题。

最后是 git bisect 把范围从四十多个提交压到九个,再压到一个,前后不到两小时。这件事之后我把二分定位写进了值班手册,因为它治的正是那种「读代码读不出来、复现又复现不了」的病。

一、先把「偶发」变成「必现」

二分的前提是每个候选提交都能给出稳定的是或否。原来是「一天十几次」,这种频率下你任何一个提交都得跑一天,四十个提交要跑四十天,二分毫无意义。

所以我第一件事是把它变成必现。翻了报错的十二条堆栈,发现全部落在同一个入口:渠道回调。再翻日志里能拿到的入参,十二条里有九条的 channel 字段是空的,另外三条 channel 有值、extra 是空的。

我去问渠道对接的同事,得到的答复是:「老渠道不走 extra,新渠道一定会带 channel。」这就解释得通了——本地测试环境只有老渠道的模拟器,所以我们永远测不到新渠道的那条路径。

于是我造了一个最小的必现用例:直接用一份新渠道的回调报文,channel 有值、extra 为 null,本地跑一次就 NPE。到这里,问题从一个「偶发」变成了「给定输入必现」,这才具备二分的条件。

顺便说一句,这一步比二分本身更值钱。很多次所谓「二分不出来」,其实是判定条件本身不成立。我现在的习惯是:开始二分之前,先写一个能稳定的最小复现命令,这条命令后面要直接交给 bisect run。

二、为什么不用 git log 逐个看

四十多个提交,看起来手动翻也就半小时。真实情况是翻不动。

原因有三个。第一,提交信息没有信息量,fix: 小调整 这种有十一条,你无法靠标题排除。第二,改动是散开的,一次「统一命名」动了三十个文件,你无法靠 git show --stat 判断它有没有碰到结算逻辑。第三,也是最要命的,出问题的不一定是「改错了」,很可能是「改对了别的地方,恰好让这里的假设不成立了」——比如上游把某个字段从必填改成可选,这边还在按必填写。

这三种情况有个共同点:靠读代码判断「这个提交有没有问题」,成本极高且不可靠;但靠跑一次判断「这个提交有没有问题」,成本很低。

二分的价值就在这儿。它不要求你理解代码,只要求你能给出一个可靠的判定。四十个提交,log2(40) 约等于六次,六次判定就能锁定;实际情况因为有 merge 和不可编译的中间提交,跑了九次。

定位手段 需要理解代码 单次成本 四十个提交的总成本 结论可靠性
逐个 git show 读 diff 高 3–10 分钟 2–6 小时 低,容易漏
按 git log 猜可疑提交 高 10 分钟 1 小时以上 很低,靠直觉
手动 checkout 逐个试 低 5–15 分钟 4 小时以上 高,但耗人
git bisect run 自动二分 低 一次脚本 10–20 分钟 高

三、bisect 的基本节奏

先确认边界。我知道六天前那个版本是好的,因为那天的回归用例全绿;当前 HEAD 是坏的。

cd /data/app/settle-service
git fetch --all
git log --oneline -1 v2026.10.28      # 好版本的锚点
git log --oneline -1 origin/master    # 当前坏版本

git bisect start
git bisect bad origin/master
git bisect good v2026.10.28
# Bisecting: 21 revisions left to test after this (roughly 5 steps)

Git 会把你切到中间某个提交。这时候你要做的是「编译 + 跑那个最小用例」,然后告诉它 good 或 bad。手工做一轮大概七八分钟,因为要等 Maven 编译。

第一轮跑完我标了 bad,第二轮标了 good,第三轮编译直接失败——那条提交里有个类被删了,依赖它的模块编不过。这种情况不能标 bad,也不能标 good,要标 skip:

git bisect skip
# 如果连续几个提交都编不过,可以跳过一段区间
git bisect skip v2026.10.28..v2026.11.02

skip 会让 Git 在剩余区间里换一个点,代价是最后可能给出「无法唯一确定」的结果,需要人工在几个候选里再挑。我这次只 skip 了一个提交,影响不大。

四、把判定写成脚本

手工九轮太慢,而且人容易在第三轮之后开始敷衍——看到编译过了、用例没报错,就顺手标 good,其实日志里那条 NPE 已经打出来了。所以我写了脚本,让机器判。

脚本要满足三个条件:能编译、能起服务、能断言。我用的是「跑单个测试类」这种最轻的方式,不起完整服务:

#!/usr/bin/env bash
# bisect.sh —— 返回 0 表示 good,1 表示 bad,125 表示无法判定(skip)
set -u
cd /data/app/settle-service || exit 125

# 编译失败不能算 bad,算 skip
mvn -q -o -pl settle-core -am compile -DskipTests >/tmp/bisect-build.log 2>&1 || exit 125

mvn -q -o -pl settle-core test \
    -Dtest=SettleCallbackNewChannelTest#extraIsNull \
    -DfailIfNoTests=false >/tmp/bisect-test.log 2>&1

rc=$?
if [ $rc -eq 0 ]; then
    exit 0
elif grep -q "NullPointerException" /tmp/bisect-test.log; then
    exit 1
else
    exit 125
fi

最后那个 else 分支很重要。测试失败的原因可能是断言挂了、可能是端口占用、可能是数据库连不上,这些都不该被当成 bad,否则二分会朝错误方向收敛。我只把「日志里确实出现这个 NPE」当成 bad,其余一律 skip。

然后:

git bisect start origin/master v2026.10.28
git bisect run ./bisect.sh

九轮跑完,Git 打印出唯一一条:

a3f9c21 is the first bad commit
commit a3f9c21e8b4d0f7c1a2e5b9d3c6f8a0e2d4b7c91
Author: ...
Date:   Thu Nov 5 14:22:07 2026 +0800

    refactor: 结算单模型改用 Lombok @Builder,去掉冗余 setter

五、第一次跑偏:环境差异让 good 也变 bad

第一次自动跑,跑到第四轮就出了个诡异结果:它指向一条只改了 README 的提交,git show 里一个字代码都没有。

原因是我在脚本里用了 -o(离线),而那条提交之前刚好有人往 pom.xml 里加了一个新依赖,本地仓库没有,编译失败。理论上编译失败会走 exit 125,但我当时写的是 mvn compile || exit 1,把编译失败当成了 bad。二分朝着「编译不过的更早提交」方向跑,最后停在第一个编不过的点上,和真正的 bug 完全无关。

这个坑值得单独记一笔:二分的判定函数必须区分「坏」和「不知道」。编译失败、超时、依赖拉不下来、数据库没起,全部是「不知道」,不是「坏」。一旦混了,二分不是找不到,而是会非常自信地给你一个错误答案。

修好之后我又踩了第二个小坑:测试类在某个提交里还不存在,-DfailIfNoTests=false 让 Maven 返回 0,脚本把这个提交判成了 good。这个错误方向更隐蔽,因为它会让区间收缩得比实际更快。后来我改成先检查文件是否存在:

[ -f settle-core/src/test/java/.../SettleCallbackNewChannelTest.java ] || exit 125

六、根因与修复

打开那个提交,改动是「结算单模型改用 @Builder,去掉冗余 setter」。看起来纯机械重构,但 @Builder 加在类上之后,Lombok 生成的是全参构造,原来那个手写的、只接收三个参数的构造器被删了。

而反序列化框架在某些配置下会优先用「唯一的有参构造」而不走无参构造 + setter。字段一多,extra 这种嵌套对象在构造时就被传成了 null,后面的 order.getExtra().getSubChannel() 自然就炸了。上一层判空判的是 channel,不是 extra——所以读代码的人怎么读都觉得「不可能」。

修复不是加判空,而是把协议契约补回来:

@Getter
@Builder
@NoArgsConstructor          // 反序列化需要一个无参构造
@AllArgsConstructor
public class SettleOrder {
    private String orderNo;
    private String channel;
    private ChannelExtra extra;   // 老渠道为 null,新渠道必填

    /** 订阅渠道标识:老渠道回落到主渠道,新渠道取 extra.subChannel */
    public String subscribeChannel() {
        if (extra == null || extra.getSubChannel() == null) {
            return channel;
        }
        return extra.getSubChannel();
    }
}

同时把调用点从 order.getExtra().getSubChannel() 改成 order.subscribeChannel(),把「空值回落到哪里」这件事收进模型里,而不是散在五个调用方。

七、防复发

单修一个 NPE 没什么可说的,值得说的是为什么这个重构能悄悄上线六天。

第一,测试环境只有老渠道模拟器,新渠道的报文从来没进过自动化。我加了一个参数化用例,把两种渠道的报文都喂一遍,成本不到二十行。

第二,重构类提交没有强制跑契约测试。我们后来在 CI 里加了一条规则:凡是 @Builder、@Data、构造器相关的改动,必须跑一遍 JSON 往返测试(序列化再反序列化,断言字段全等)。这条规则抓出过另外两个同类问题。

第三,也是我一开始最想改的:把二分脚本沉淀下来。现在仓库根目录有个 tools/bisect/ 目录,里面放几个常用判定脚本,模板三行就能改出一个新的。下次再遇到「不知道哪个提交引入的」,不用从零开始想「怎么判」。

小结

  • 二分之前先造最小必现用例。判定不稳定,二分只会更快地给你错误答案。
  • 四十个提交大概九轮,前提是判定交给脚本;手工跑三轮之后人就开始敷衍了。
  • bisect run 的脚本必须区分「坏」和「不知道」,编译失败、超时、缺依赖一律 exit 125,不是 exit 1。
  • 只把「日志里确实出现目标异常」当作 bad,其余失败都不算,否则区间会朝错误方向收缩。
  • 出问题的提交往往不是「改错了」,而是「改对了别处,让旧假设不成立了」——所以别指望读 diff 读出来。
  • 修完要补契约测试:序列化往返 + 多协议入参参数化,这类空值问题基本都能在 CI 拦下。
查看「工程效能」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。

git bisect 两小时定位到一次提交:从 40 个候选里二分 - 起航社区