一个 CI 构建失败了,日志 3000 行。第一个人从头翻,找到第 1800 行的"编译错误",改了之后又失败——真正的原因是第 42 行的"依赖下载 403",而它导致了后续"一片红"。
构建日志的结构是"一个根因 + 一串连锁错误"。而"定位"的难点不在"找错误",在"分辨哪个是根因"——这个"更吓人"是人类注意力的弱点:红色一大片会"抓住眼睛",而"一行 403"躲在"下载阶段"里——于是"改错"成了默认结果。
因为"连锁错误"通常更长、更吓人、更靠后。
1. 构建日志的三段结构
| 段 | 内容 |
|---|---|
| 准备(下载/解压/生成) | 依赖、代码生成、配置 |
| 编译(多模块) | 类型错误、注解处理 |
| 打包/校验(测试、镜像) | 单测、静态检查、打包 |
第一段"最容易被跳过":它往往是"真正失败"的地方第一段的"欺骗性"在于它"看起来像准备工作":下载依赖、生成代码、读配置——而"准备工作失败"恰恰最像"环境问题",于是"人本能地想'重跑试试'"。
——而"日志看起来很正常"(只是下载慢),于是"人往后翻"。
2. "根因识别"的三个方法
| 方法 | 说明 |
|---|---|
| 找"第一个" ERROR/FAILURE | 通常最接近根因 |
| 找"第一个非零退出" | 比"ERROR 字样"更准 |
| 看"最后执行的阶段" | 上下文 |
第二项的"落点"很具体:set -e 的脚本会在"第一个非零命令"处停下——于是"那道命令"就是根因,而"后面的红色"都是"脚本继续输出"。
第二项最可靠:构建工具的"退出码"来自某一层——而"ERROR 字样"可能只是"警告被标红"。
3. "连锁错误"的三种形态
| 形态 | 表现 |
|---|---|
| 依赖缺失导致的"一片找不到符号" | 几十个编译错误 |
| 代码生成失败导致的"类型不存在" | 一堆类型错误 |
| 磁盘/内存不足导致的"随机失败" | 每次位置不同 |
第三项最迷惑:"随机位置失败"往往不是代码问题第三项的"排查顺序"是"先看资源(磁盘/内存/句柄),再看并发(同机多任务)"——因为"随机"意味着"某个共享资源被抢",而不是"某行代码错"。
——而是"环境资源"(磁盘满、OOM、并发写冲突)。
"第一个错误"要"连上下文一起看",因为原因常在"上一行":
// 一个真实片段(原因在"上一行")
[INFO] Downloading from central: https://repo/.../lib-1.2.3.jar
[ERROR] Could not resolve dependencies for project app
[ERROR] ... lib-1.2.3 was not found
// 读法:第 2 行的"解析失败"是结果,第 1 行的"下载地址"才是要查的(403/404/网络)
4. "三行定位法"的实操
| 步 | 动作 |
|---|---|
| 1 | 搜"第一个 BUILD FAILURE / error: / FAILED" |
| 2 | 看它"往上 30 行"(上下文常在前面) |
| 3 | 看"它属于哪个阶段/模块"(决定"该看哪份配置") |
第二步的"理由"是"日志的因果顺序":"因为无法解析依赖 X,所以找不到符号 Y"——原因在前,结果在后,而"人往下翻"恰好漏掉它。
第二步很关键:很多工具"把原因打在错误上面"——"无法解析依赖 X"在上,"编译错误"在下。
// 三个命令(比"从头翻"快得多)
grep -n -m1 -E "BUILD FAILURE|error:|FAILED" build.log # 第一个错误
awk 'NR>=1 && NR<=60' build.log # 准备阶段(前 60 行)
grep -n -E "^\[INFO\] Building |^--- " build.log | tail -3 # 最后在哪个模块
"日志被截断"是"定位失败"的隐形原因,而它有两个来源:
// 两种"截断"(都要提前防)
1 CI 平台的"输出上限"(如 4MB)——超出部分被丢,且"丢的是中间"
2 构建工具"缓冲输出"——失败时只打最后 N 行
// 对策:把完整日志"上传为 artifact",或在"关键阶段"分文件输出
5. "环境类失败"的三种特征
| 特征 | 说明 |
|---|---|
| 同一提交"重跑就好" | 资源/网络抖动 |
| 失败位置"每次不同" | 并发/磁盘 |
| 只在一个 runner 上失败 | 机器差异 |
第二项是"最值得怀疑环境"的:**代码问题"位置稳定",环境问题"位置随机"**这个"稳定性"判据很好用:同一提交跑三次,若"失败行号不同" → 环境;若"总在同一个文件" → 代码——三次跑,五分钟。
。
"重跑就好"的失败要"计数",否则"偶发"会变成"常态":
// 三个数(每周看)
1 失败总数
2 重跑后成功数(flaky 计数)
3 重跑后仍失败数(真问题)
// 判据:2 占比 > 20% → 先治"环境/并发",否则"真问题"会被噪声淹没
6. 排错:五种"构建失败定位问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 改了半天还在失败 | 盯了连锁错误 | 找第一个失败 |
| 重跑就好 | 环境抖动 | 加重试/隔离 |
| 本地能过 CI 不过 | 环境差异 | 记录规格 |
| 日志被截断 | 输出上限 | 分段输出/上传 artifact |
| 找不到"哪个模块" | 缺少 stage 标记 | 加 echo 标记 |
"静默失败"(无错误行的失败)要"加探针",否则"只能猜":
// 三种"探针"(成本都很低)
1 在"每个阶段"打一行"开始/结束 + 时间"
2 在"关键命令"前打"即将执行: <命令原文>"
3 结束时打"退出码 + 最后一条执行的命令"
// 判据:没有错误行时,能否从日志回答"最后执行到哪"
7. 边界:什么情况"从头读"是对的
| 场景 | 为什么 | 做法 |
|---|---|---|
| 日志很短(< 100 行) | 一眼能看完 | 从头读 |
| 首次接触该项目 | 需要了解结构 | 从头读一遍 |
| 失败"无明显错误行" | 需上下文 | 分段读 |
| 已知是"配置问题" | 目标明确 | 直接看配置 |
最后一条与"可维护"有关的边界:"给阶段加标记"是"投资一次、收益长期"的动作:每个阶段 echo "=== stage: compile ===",于是"日志能分段"、"AI 能定位"、"人也能扫"。
判断口径一句话:"日志里有没有'明确的第一个错误'"。有(BUILD FAILURE、error:),用"三行定位法";没有(静默退出、超时),才需要"分段读 + 加标记"。
再补一条"AI 辅助"的用法:把"前 100 行 + 第一个错误块 + 最后 20 行"喂给助手,而不是"整个 3000 行"。因为"整段"会让它"从最显眼的红字开始讲",而那通常是连锁错误;而"前 100 行 + 第一个错误"恰好覆盖"根因所在的上下文"。这条把"用 AI 看日志"从"抽卡"变成"有方法的检索"。
而"喂什么"这件事,本身就是"定位能力"的体现:你不知道该看哪一段时,AI 也只会"从最红的地方开始"。
最后一个"团队"建议:把"上次的根因"记进"构建故障手册"——"依赖 403 → 查私服白名单"、"找不到符号 → 先看代码生成阶段"、"随机失败 → 查磁盘与并发"。三条就能覆盖多数情况,而它的作用是"让下一个人不必从头翻 3000 行";否则每次故障都在"重新学习同一批经验"。
而这本手册,最好由"第一个遇到的人"写——因为那时他刚翻完三千行。
写一行,省三千行。
这句话也是"构建可维护性"的全部要义:把经验固化成"下次能直接用的一行",而不是"某个人脑子里的印象"。
8. 自测
- 构建日志的三段结构里,哪一段"最容易被跳过"?为什么它常是真正失败处?
- "找第一个 ERROR"与"找第一个非零退出",哪一个更可靠?为什么?
- "连锁错误"的三种形态里,哪一种最迷惑?为什么"随机位置失败"指向环境?
- "三行定位法"的第二步为什么要"往上 30 行"?
- "同一提交重跑就好"说明什么?它与"代码问题"的差别在哪?
- 判断"该不该从头读"的那个问题是什么?