故障排查
先看日志
大多数问题的答案都在日志里,先确认看的是哪一份:
| 文件 | 什么时候看 |
|---|---|
logs/code-association.log |
主日志(INFO 及以上),一次运行的完整时间线 |
logs/code-association-error.log |
只看失败,不用在几万行 INFO 里翻 |
logs/console.log |
服务根本没起来:logback 初始化前的报错、JVM 崩溃输出只在这里 |
logs/archive/*.log.gz |
今天之前的(保留 7 天),用 zgrep 查 |
目录默认 ./logs,由 code-association.log-dir 决定(LOG_DIR 可覆盖)。
重要节点是 event=... 的结构化行,事件目录与现成的 grep 命令见 日志规范。
下文所有命令都假定在仓库根目录执行。
分析结果为空
按顺序检查:
1. 是否选了其他程序?
POST /api/analyses {"sourceId": 1, "peerSourceIds": []}
→ 400 {"error":"请至少选择一个要扫描的其他程序"}
只选被改动的程序本身不算——自身影响不在扫描范围内。
2. 本次是否真的有契约变更?
如果只改了内部实现(变量重命名、提取私有方法),没有触及任何契约, 那么对其他程序确实没有影响。看报告里的「变更清单」确认识别到了什么。
3. 对方是否真的在用这个契约?
看该 peer 条目的 matches:
{ "matches": [], "impacts": [], "note": null }
matches 为空说明没有找到使用点。常见原因:
- 对方调用的是动态拼接的 URL(
baseUrl + path),静态扫不到 - 对方通过反射或脚本调用,不是 Java 代码
- 表名 / 字段名 / 配置 key 拼写不同(大小写、下划线 vs 驼峰)
- 对方用
SELECT *查这张表:列清单静态确定不了,不会命中字段级契约, 而是进入报告的字段盲区清单。这类情形要在盲区里找,不要看matches。 - 两侧声明的数据库不同(JDBC URL 解析出的库名不同):候选会被排除,报告会给出「因数据库不同被排除」计数
- 改动属于兼容变更(加列、类型放宽):不扩散,报告会给出「因类型兼容被排除」计数
对方程序读取失败
{ "peerSourceName": "order-service", "note": "无法读取该程序:目录不存在:/path" }
只有该条目失败,其他 peer 不受影响。检查位置是否可达、Git 凭据是否配置。
GitLab 私有仓库认证失败
现象:添加 GITLAB 代码源时分支下拉为空或提示认证失败;分析时克隆 / fetch 报
not authorized、Authentication failed 或 401。
按顺序检查:
| 检查项 | 说明与处理 |
|---|---|
| 来源类型 | 必须是 GITLAB。GITHUB / GIT / LOCAL_* 不会附加 GitLab 令牌,改成 GITLAB 后重试 |
| 令牌 scope | 至少 read_repository;改过 scope 后要重新生成或刷新令牌 |
| 令牌本身 | 过期、被撤销,或复制时带上了空格 / 引号。在设置页重新粘贴保存(输入框是空的,占位符里的掩码不是明文) |
| 环境变量遮蔽 | 代码源「凭据环境变量」指向的变量有值时会优先于设置页令牌;旧值会让设置页的令牌不生效。清空该字段或改对变量 |
| 自建实例地址 | 用 https://gitlab.example.com/group/repo.git 或 http://...;git@... 这类 SSH 地址不支持(这个 JGit 构建没有 SSH 传输),换成 HTTPS 克隆地址 |
| 令牌类型 | 个人访问令牌(PAT)。部署令牌(deploy token)需要用户名配对,本功能不适用 |
| 实际生效来源 | 看 GET /api/gitlab 的 configuredBy(file / application.yml)与 tokenSet |
curl -s http://127.0.0.1:9996/api/gitlab
# {"tokenSet":true,"tokenMasked":"glpa…cdef","configuredBy":"file","settingsFile":"/.../gitlab-settings.json"}
排查时不要指望从日志里读到令牌:接口与日志都只有 set / none 与掩码。设置页保存的令牌对
同一 data-dir 上的 CLI 同样生效;流水线请用 GITLAB_TOKEN 环境变量或 --token-env
(见 命令行与 CI)。
定时扫描不触发
确认配置生效
curl http://127.0.0.1:9996/api/schedule | python3 -m json.tool
enabled 必须是 true。注意 cron 是六段式(秒 分 时 日 月 周),
五段式会被解析成错误时间。
确认程序被正确跳过还是漏掉了
看 last.details:
| outcome | reason | 含义 |
|---|---|---|
SCANNED |
有新提交 abc123 → def456 | 正常 |
SKIPPED |
没有新提交 | 正常 |
SKIPPED |
已有分析在进行中 | 上一次还没跑完 |
SKIPPED |
位置不可访问:/path | 目录被删或移动 |
SKIPPED |
没有其他程序可比较 | 只注册了一个程序 |
FAILED |
… | 见对应错误 |
手动触发验证
curl -X POST http://127.0.0.1:9996/api/schedule/run
# 然后轮询
watch -n5 'curl -s http://127.0.0.1:9996/api/schedule | python3 -c "import json,sys; d=json.load(sys.stdin); print(d[\"running\"], d[\"last\"][\"status\"])"'
接口很慢或超时
/api/analyses 慢
历史报告可能有几 MB(完整 JSON)。列表查询不应该读取报告内容。
如果这一项变慢,检查 Store.analyses() 是否用了 SELECT *——那会把所有 report_json 都读出来。
扫描一直 RUNNING
完整扫描要 fetch 远程仓库(每个可能几十秒)。4 个程序 2~3 分钟是正常的。 超过 10 分钟仍然 RUNNING,可能是网络卡住:
# 看是否卡在 fetch
tail -50 logs/code-association.log | grep -i fetch
# 或按阶段看耗时:准备(含 fetch)与差异识别各花了多久
grep 'event=analysis.prepare' logs/code-association.log | tail -5
强制恢复:重启服务,启动时会把孤儿任务标记为失败。
分析一直停在 96%
96% 只可能是 AI 评审段。 全仓库只有 AnalysisService 在进入 AI 段时写 96%(阶段 REPORT,
detail「正在生成 AI 评审说明」);静态分析的各阶段在它之前就已经结束。
先确认卡在哪一段
curl -s 'http://127.0.0.1:9996/api/analyses?limit=1' | python3 -m json.tool | grep -A6 progress
progressStage 是 REPORT、detail 以 AI 评审 开头,就是 AI 段。刚进这一段时 detail 会先出现一次旧文案
「正在生成 AI 评审说明」(仍是 96%),随后才变成「AI 评审:…」——这是同一段的起始写入,不是漏了一行。
之后它在 96..99 之间按阶段推进,detail 依次是 AI 评审:生成评审要点、AI 评审:逐条评审意见、AI 评审:分块 i/n、
AI 评审:重定位意见 i/n、AI 评审:反思意见、AI 评审:整理意见(中间四行只在开启逐条评审时出现)。
一直不动的 detail 说明当前那次调用还没回来——用日志区分「慢」与「半死」:
grep 'event=analysis.ai' logs/code-association.log | tail -5
grep 'event=ai.review' logs/code-association.log | tail -5
event=analysis.ai在整段 AI 结束时写,带generated、budgetSeconds、durationMs;失败时带error。 没有这一行说明整段还没结束。event=ai.review在逐条评审结束时写,带评论数、错误,预算耗尽时带expired=true。
常见原因
| 原因 | 表现 |
|---|---|
| 上游模型慢或「半死」 | 响应头回来了、body 不动;单次 HttpRequest.timeout 不覆盖 body 读取,以前会一直阻塞 |
| 开着逐条评审 | comments: true 时一次分析最多 14 次串行模型调用(摘要 + ≤6 个分块 + ≤6 次重定位 + 1 次反思) |
timeout-seconds 配得过大 |
单次上限大,整段就更容易长时间停在最后一个进度上 |
处置
- 等预算耗尽自愈。 预算到了会停止剩余调用,已生成的部分落进报告。
reviewError是否带预算句, 看预算有没有截断这一段,有两种形态:真的跳过了调用(budgetExpired=true);只缩短了最后一次调用的超时、 而那次调用失败(budgetExpired=false,评论缺失的原因仍是预算)。两种形态都拼成 「第一条具体错误;AI 评审超出时间预算(N 秒),已保留已生成的部分」,例如第 1 个分块评审失败:模型调用超时(5 秒),请检查接口地址、网络或模型响应速度;AI 评审超出时间预算(8 秒),已保留已生成的部分; 没有具体错误时只有后半句,没被预算截断时保留原错误。逐段明细(剩余几个分块未评审、几条意见未重定位、 跳过反思)在reviewStats.notes。budgetExpired只表示真的跳过了调用,用于区分是否还有未送审的 分块,不等于评论是否完整;摘要本身失败时report.ai.error是 「AI 评审超出时间预算(N 秒),已保留已生成的部分」;分析随后照常完成。 - 调小总预算。 在
application.yml设code-association.ai.total-budget-seconds(重启生效); 不写时按timeout-seconds × 3推导并夹在 90..900 秒。 - 关掉逐条评审。
comments: false把十几次调用降回一次摘要,最坏耗时从一刻钟降到一次请求。 - 换端点或模型。 用设置页「测试连接」确认
endpoint与延迟,再重新生成。
静态分析结果不受影响,AI 只是可选的最后一段。 预算耗尽、模型报错、上游永久挂住,都不会改变 影响清单、路径与风险结论;最坏情况只是这次解释没有生成,可以从分析页重新生成。
分析在最后一步失败:serialize failed / TextBuffer overrun
现象:分析跑到最后(进度接近 100%、阶段是 REPORT)后变成失败,历史里只留下一条失败记录,
没有可查看的报告。logs/code-association-error.log 里是:
java.lang.IllegalStateException: serialize failed
... at com.geek.association.util.Json.write(Json.java:...)
Caused by: com.fasterxml.jackson.core.exc.StreamConstraintsException:
String length (...) exceeds the maximum length (...)
或者日志里出现 TextBuffer overrun。
原因:旧版本保存报告时把整份报告序列化成单个 String 再写进 report_json(SQLite TEXT)。
跨程序影响条目极多时(一次大变更产生数十万条并不罕见),这份 JSON 会超过 Jackson 文本缓冲的
2 GiB 上限,序列化直接抛错。失败发生在最后一步,所以前面的索引、传播、评分全部作废。
新版行为:报告里的影响清单按风险截断到 code-association.max-report-impacts 条
(默认 5000,名额按对比程序公平分配),其余条目只计数、不逐条写入 JSON:
| 字段 | 含义 |
|---|---|
report.impactsTotal |
本次发现的影响总处数(全量) |
report.impactsShown |
清单里实际保留的条数 |
report.impactsTruncated |
未列入清单的条数(0 表示没截断) |
report.impactsNote |
一句中文说明;未截断时为 null |
影响审核页会在发现列表上方显示这句说明,页头的「N 项影响」用全量计数;
summary 里的 impactedSymbols / highRisk / mediumRisk / lowRisk 也仍是全量计数。
截断只影响列表能逐条读多少,不改变风险结论。
处理:
- 升级到包含该修复的版本,重新运行一次分析。
-
确实需要逐条看更多影响时,在
application.yml调大上限(重启生效):code-association: max-report-impacts: 20000上限越大,报告 JSON 越接近 2 GiB,落库失败的风险也越高——不要一路开到几十万。 命令行模式同样读取这个配置。
服务无响应但进程还在
典型症状:/api/health 正常,但静态资源请求挂住,logs/code-association.log(或
logs/code-association-error.log)里有 ClassNotFoundException: ch.qos.logback...。
原因:运行中的 JVM 打开了 target/code-association.jar,而这个 jar 被
mvn package 就地覆盖了。重启即可恢复。
预防:不要对着正在运行的实例执行 mvn package。构建到别的目录,或先停服务。
端口被占用
lsof -nP -iTCP:9996 -sTCP:LISTEN # Linux 无 lsof 时:ss -ltnp | grep 9996
./stop.sh # 会等到端口真正释放才退出
./start.sh
Windows:
netstat -ano | findstr :9996
stop.bat :: 按 pid 文件 → 端口 → 命令行逐级兜底
start.bat
stop.sh / stop.ps1 都会等到端口真正释放才退出(最多 10 秒),避免紧接着启动撞端口。
两者的”停”并不相同:stop.sh 发 SIGTERM,JVM 会跑 shutdown hook;Windows 没有等价的信号,
stop.ps1 只能结束进程(TerminateProcess),JVM 的 shutdown hook 不会执行。
这对本服务几乎没有影响:它没有注册任何 shutdown hook,SQLite 用 rollback journal 崩溃安全,
被中断的分析下次启动时会被标记为失败。
两处例外值得知道:
{data-dir}/repos下的 JGit 克隆缓存没有自愈能力。克隆 / 拉取中途被杀可能留下*.lock或半个对象库,下次分析会失败——删掉对应目录让它重新克隆即可。- 看到「已停止」才是真的停了。结束失败时脚本会打印「无法结束进程」并以退出码 1 结束,
所以
stop.bat && start.bat不会在服务还活着的时候继续启动。
启停脚本报「缺少命令」或行为异常
start.sh / stop.sh 不硬依赖单一工具,但不同机器上走的分支不同:
| 症状 | 原因 | 处理 |
|---|---|---|
| 提示「没有 setsid 也没有 python3,退化为 nohup」 | Linux 极简镜像 / macOS 无 python3 | 关掉终端可能带走服务;装 python3 或 util-linux(提供 setsid)可恢复 |
找不到监听 PID(PID 显示 ?) |
没有 lsof / ss / netstat -tlnp |
装任意一个即可;不影响启动本身,只影响 PID 文件准确性 |
| 健康检查一直超时但进程活着 | 没有 curl,走了 /dev/tcp 探测 |
装 curl;或直接用浏览器打开 http://127.0.0.1:9996/api/health |
| Windows 脚本中文乱码 / 语法报错 | .ps1 被存成了无 BOM 的 UTF-8 |
用发布包里的原文件,不要用编辑器另存;release.sh 已做 BOM 校验 |
stop.bat 提示「进程无窗口,无法发送关闭信号」 |
旧版 stop.ps1 把 CloseMainWindow() 当主路径,而 java 是控制台进程,永远拿不到主窗口 |
升级即可:当前版本停止那一步只输出「已停止 (PID N)」,并省掉白等的 3 秒。旧包没有 -Verbose 输出,只能忽略这条提示——它不影响停止结果 |
stop.bat 说「已停止」但端口仍被占用 |
旧版没复核结束结果;或进程以管理员启动、当前 shell 权限不足 | 用管理员身份重开终端再执行 stop.bat;netstat -ano 查到 PID 后用任务管理器结束 |
深色模式下图表颜色不对
依赖图(Cytoscape)把颜色烘进画布,无法继承 CSS 变量。
主题切换时 app.js 会读取令牌计算值并重建图实例。如果颜色没更新,硬刷新一次页面。
AI 审核失败
AI 审核是可选层,它的失败不影响静态分析结论与 --fail-on 退出码。以下三种最常见。
401 / 403
现象:AI 卡片显示 HTTP 401: ... 或 HTTP 403: ...;「测试连接」返回 ok=false。
原因:
| 状态码 | 常见原因 |
|---|---|
| 401 | Key 未填、已失效、被截断,或复制时带上了多余的空格与引号 |
| 403 | Key 有效但无该模型的权限;组织策略或区域限制;余额 / 额度耗尽时部分服务也返回 403 |
处理:
- 在设置页「API Key」输入框重新粘贴一次完整 Key 并保存(输入框本身是空的,占位符里的 「已保存 sk-a…3f9c」才是掩码;留空保存表示不修改);
- 点「测试连接」,看返回的
endpoint是不是预期的地址; - 仍失败则用同一把 Key 直接 curl 一次,确认不是服务端账号问题:
curl -sS https://api.deepseek.com/v1/chat/completions \
-H "Authorization: Bearer $AI_API_KEY" -H 'Content-Type: application/json' \
-d '{"model":"deepseek-chat","messages":[{"role":"user","content":"ping"}],"max_tokens":8}'
日志里不会出现 Key,所以排查时不要指望从日志里读到它——能读到的只有 enabled / baseUrl /
model / maxImpacts。
超时
现象:AI 卡片显示超时原因;「测试连接」返回 ok=false 且 message 含「连接超时」。
原因:
- 端点不可达(内网服务没起、代理没配、DNS 解析不了);
- 模型本身慢:本地 Ollama 首次加载模型、大模型冷启动、长上下文都会超过默认 60 秒;
- 影响条目太多导致提示词过长,
max-impacts偏大时更容易撞上。
处理:
| 情况 | 处理 |
|---|---|
| 本地模型冷启动慢 | 先在终端跑一次让模型驻留;或在设置页把「请求超时(秒)」调大(上限 300,默认 60) |
| 网络不可达 | 确认容器 / 服务能访问该端点;容器里的 localhost / 127.0.0.1 指容器自己,Ollama 在宿主机时要换地址 |
| 提示词太长 | 调小「每条分析最多送审影响」(默认 12) |
超时只影响这一次解释,报告其它部分照常可用,可以稍后从分析页重试。
返回空内容
现象:AI 卡片显示「AI 评审说明生成失败」,原因是「模型返回了空内容」或「无法解析响应:…」;
「测试连接」却返回 ok=true。
原因:
- 端点返回的
choices为空数组(模型拒答、触发内容策略、被网关改写); choices[0].message.content为null(部分兼容实现把正文放在reasoning_content等非标准字段,标准字段留空);- 推理型模型把
max_tokens全花在推理上,正文被截断成空。
处理:
- 用「测试连接」确认端点确实是 OpenAI 兼容的
/chat/completions——只实现了部分字段的 网关最容易出现这种”能连通但拿不到正文”; - 换一个模型名再试(推理型模型与
deepseek-chat这类对话模型的输出形态不同); - 调小「每条分析最多送审影响」后重新生成,缩短提示词;
- 直接 curl 同一端点,看原始响应里
choices与message.content到底是什么。
空内容被当成失败而不是成功:报告里记的是 generated: false 加原因,界面上是可重试的红色提示,
不会存下一张没有正文的卡片。「测试连接」只验证连通性(要求模型回 pong),所以它通过并不代表
完整提示词能拿到正文——两者用的不是同一个请求。
还想定位更细
# 分析进度(阶段 + 百分比 + 当前文件)
curl -s 'http://127.0.0.1:9996/api/analyses?limit=1' | python3 -m json.tool | grep -A3 progress
# 完整报告
curl -s 'http://127.0.0.1:9996/api/analyses/42/report' | python3 -m json.tool | head -80
报告里的 blindSpots 列出所有解析失败的调用点——”为什么没找到影响”的答案常在这里。
日志侧更细的查询(事件目录与字段含义见 日志规范):
# 一次分析的全部节点,按时间顺序
grep 'analysisId=42' logs/code-association.log
# 最近几次失败的阶段与原因
grep 'event=analysis.failed' logs/code-association.log | tail -10
# 最近几轮定时扫描:为什么有的程序没扫
grep 'event=scan.done' logs/code-association.log | tail -7
# 历史(今天之前,保留 7 天)
zgrep 'event=analysis.failed' logs/archive/*.log.gz