跳转到内容

Git 与调试

Git 保存历史,调试寻找原因。两者看起来无关,却共享同一个基础能力:把当前状态变成可观察、可回退、可比较的状态。没有版本控制,很难知道“哪次修改引入了问题”;没有可复现步骤,也很难判断问题是否真的被修好。

Git 不是“给文件做备份”的同步盘。它的核心对象大致分为四类:

对象 表示什么
blob 某个文件内容
tree 目录结构,记录文件名、权限和对应的 blob/tree
commit 一个完整快照,带父提交、作者、时间和提交信息
tag 对某个对象的命名引用,常用于发布版本

提交通过父指针连成有向无环图。分支和 HEAD 只是指向提交的引用,不是复制出来的文件夹。

A ← B ← C ← D main
↑
└── E feature

当 main 和 feature 分别前进后再合并,会产生新的合并提交,或通过变基重新排列提交。理解这张图,才能知道“分支删了会不会丢代码”“为什么提交不在主干上”“合并和变基差别是什么”。

2. 工作区、暂存区、本地仓库与远端

跳转到“2. 工作区、暂存区、本地仓库与远端”
位置 含义 常用命令
工作区 磁盘上当前文件 git diff
暂存区 下一次提交准备的内容 git add、git diff --staged
本地仓库 已提交的提交图 git commit、git log
远端跟踪引用 上次从远端获取到的状态 git fetch、git branch -vv

git add 不是“把文件上传”,而是把当前内容放进暂存区。一个文件可以同时有已暂存和未暂存修改。提交时只记录暂存区中的快照,所以提交前要检查 git diff --staged。

终端窗口
git status
git diff
git add src/request.ts
git diff --staged
git commit -m "fix: handle request timeout"

一条好的提交信息说明改了什么、为什么改。个人项目不必模仿大型团队全部规范,但应避免 update、fix bug 这类以后无法检索的描述。

创建分支只是创建一个新的引用:

终端窗口
git switch -c feature/login

完成修改后,常见整合方式有:

  • 合并(merge):保留两条历史,生成合并提交。历史真实,但分支多时图会比较复杂。
  • 变基(rebase):把当前分支的提交重新应用到另一个基线上,历史更线性,但会改写提交哈希。
  • 压缩合并(squash):把多个提交合成一个,适合一个功能需要保留为单一变更。

已经共享给别人的提交不要随意变基或强制推送。改写历史会改变提交身份,协作者基于旧历史的工作会变复杂。个人分支可以整理历史,公共主干要优先保证可追溯。

冲突发生在两边都修改了同一区域,Git 无法自动判断正确语义。解决步骤不是简单选择“我的版本”,而是:

  1. 看两边的意图和上下文。
  2. 保留两者都需要的改动。
  3. 删除冲突标记。
  4. 运行格式、类型检查和测试。
  5. git add 标记已解决,再完成合并或变基。

冲突标记如下:

<<<<<<< HEAD
current version
=======
incoming version
>>>>>>> feature

最终文件里不应该残留这些标记。机器能自动合并,不代表语义一定正确;冲突解决后必须重新验证。

终端窗口
git log --oneline --graph --decorate --all
git show <commit>
git diff <old>..<new> -- path/to/file
git blame -L 20,40 path/to/file

log 看提交图,show 看某次快照,blame 看每一行最后由哪次提交引入。blame 是定位上下文,不是追责工具;一行代码可能只是格式调整,真正语义变化在更早的提交。

命令 作用 适合场景
git revert <commit> 新增一个反向提交 已经共享的历史
git reset --soft 移动分支指针,保留暂存区 本地整理提交
git reset --mixed 移动指针,保留工作区 重新选择提交内容
git reset --hard 指针、暂存区、工作区一起重置 确认不保留本地修改时
git restore 恢复工作区或暂存区文件 撤销未提交修改

revert 不删除历史,因此在公共分支更安全。reset --hard 会丢失未提交内容,执行前必须确认。

git reflog 记录 HEAD 和分支引用最近移动到哪里。误删分支或回退错提交时,经常能通过 reflog 找回旧哈希。它是本地恢复工具,不等于永久备份;对象是否还在还取决于垃圾回收和是否推送到远端。

4.2 bisect:让历史帮助定位

跳转到“4.2 bisect:让历史帮助定位”

当问题在很久之前的版本引入,可以二分提交:

终端窗口
git bisect start
git bisect bad HEAD
git bisect good v1.4.0
# 每次测试后标记
git bisect good
git bisect bad
git bisect reset

Git 每次把范围减半,最终指向第一个可疑提交。前提是问题可稳定判断,并且“好版本”和“坏版本”之间没有无法运行的环境断层。

origin/main 是本地保存的“上次看到的远端 main”,不是实时状态。只有执行 fetch 或 pull 时才可能更新。

终端窗口
git fetch origin
git log --oneline HEAD..origin/main
git log --oneline origin/main..HEAD

pull 大致等于 fetch 加合并或变基,具体取决于配置。先 fetch 再查看差异,通常比盲目 pull 更容易理解将要发生什么。

推送被拒绝时,先判断原因:

  • 远端有新提交:先整合再推。
  • 权限不足:检查账号、密钥和仓库权限。
  • 分支受保护:走代码评审或调整分支策略。
  • 历史不兼容:确认自己是否在基于旧历史强制推送。

不要因为推送失败就习惯性使用 --force。需要覆盖远端时优先使用 --force-with-lease,它至少能发现远端在你上次 fetch 后又被别人修改。

6. 不该进入仓库的东西

跳转到“6. 不该进入仓库的东西”

.gitignore 是边界,不是清理工具。已经提交的敏感信息即使后来删除,也仍然存在于历史中,需要轮换密钥并清理历史。

应忽略的常见内容:

node_modules/
.venv/
dist/
coverage/
.env
*.log
*.pem
*.key
models/*.bin

大文件、构建产物和依赖目录会使仓库膨胀,并制造无意义差异。二进制资产应明确来源、许可和存储方式。提交前至少看一次 git status 和 git diff --staged。

调试不是“凭经验猜一个地方改掉”。更可靠的过程是:

复现问题
↓
提出可被证伪的假设
↓
选择能验证假设的最小观察
↓
缩小范围
↓
修复根因
↓
补测试并验证没有回归

每一步都应留下证据:最小输入、环境版本、日志、请求、trace、文件或提交差异。没有证据的判断只是猜测。

复现记录至少包含:

  • 实际操作和输入。
  • 预期结果与实际结果。
  • 操作系统、运行时、依赖和配置版本。
  • 错误发生时间、频率和影响范围。
  • 是否能稳定重现;是否与账号、数据、网络或并发有关。

偶发问题不要因为一次没重现就标记为“已解决”。记录失败概率、发生窗口和触发条件,继续积累样本。

一个常见 Web 请求可以按以下层排查:

层 要问的问题 典型证据
浏览器 有没有 JS 错误、请求失败、渲染卡顿 Console、Network、Performance
网络 DNS、TCP、TLS、代理是否正常 curl -v、DNS 记录、证书
入口 网关是否转发、限流或拒绝 状态码、访问日志、路由配置
应用 是哪段代码、哪个依赖慢或失败 结构化日志、trace、断点
数据 查询、锁、索引、连接池是否异常 慢查询、执行计划、指标
系统 CPU、内存、磁盘、文件描述符是否耗尽 系统指标、进程状态、日志

不要同时改五个变量。一次只改变一个条件,才能知道结果由什么导致。

浏览器开发者工具主要看五个区域:

  • Console:JavaScript 错误、警告和临时输出;大量输出本身会拖慢页面。
  • Network:请求 URL、方法、状态码、请求头、响应、耗时和缓存。
  • Sources:源码、断点、调用栈和作用域;Source Map 可帮助定位压缩代码。
  • Performance:长任务、布局、绘制、主线程和帧率。
  • Application:Cookie、Local Storage、Session Storage、IndexedDB、Service Worker 与缓存。

排查请求问题时,要区分:

  • 请求根本没有发出:前端逻辑、表单、权限或事件绑定。
  • 请求发出但失败:DNS、网络、CORS、TLS、服务端错误。
  • 返回 200 但页面不对:响应结构、状态更新、渲染或缓存。
  • 浏览器缓存旧资源:缓存头、Service Worker、构建哈希或强刷。

服务端日志应结构化,至少包含时间、级别、服务、环境、请求 ID、错误码和关键耗时。日志中不要直接输出密码、令牌、完整 Cookie 或敏感个人数据。

{
"time": "2026-10-31T12:00:00Z",
"level": "error",
"service": "orders",
"requestId": "req_123",
"code": "DB_TIMEOUT",
"durationMs": 5032
}

常见排查工具:

目标 方法
请求经过哪些服务 分布式 trace、request ID
哪段代码慢 profiler、火焰图、采样
内存为何上涨 堆快照、GC 指标、对象引用
为什么阻塞 线程/协程状态、锁等待、连接池
是否被系统限制 CPU/内存/文件描述符/磁盘配额
是否依赖故障 外部调用耗时、错误率、熔断状态

先看症状,再选工具。CPU 高可能是死循环,也可能只是正常加密;延迟高可能是代码慢,也可能是在排队等数据库连接。

断点适合看某条路径的变量和调用栈。条件断点可以只在特定用户或输入上暂停;日志断点可以不修改代码而输出局部信息。

但断点会暂停进程,不适合观察长时间运行、并发竞争或生产环境中的高频请求。远程调试端口也必须严格保护,不能暴露到公网。

日志应回答具体问题,而不是把每个变量都打印出来。增加日志前先问:这条记录能证实或推翻哪一个假设?验证完成后是否应该降级或删除?

二分不只适用于提交,也适用于配置、输入和服务链路:

  • 禁用一半功能开关。
  • 替换一半依赖。
  • 对请求按用户、地区或数据规模分组。
  • 在请求路径中间增加计时点。

前提是各组之间有明确边界,且能判断结果是否正常。

偶发问题常来自共享状态。检查:

  • 两个线程是否同时读写同一对象。
  • 锁的获取顺序是否可能形成循环。
  • 超时后是否仍有后台任务继续修改状态。
  • 重试是否造成同一操作执行多次。
  • 连接池或队列是否有上限,等待是否会无限增长。
  • 时间、随机数和外部服务响应是否让测试难以重现。

不要用“加个 sleep”掩盖竞态。sleep 只是改变时序概率,不是同步机制。

修复需要同时完成三件事:

  1. 改变根因,而不是只在报错处吞掉异常。
  2. 增加能稳定暴露该问题的测试或监控。
  3. 验证正常路径、边界路径和失败路径。

如果无法自动测试,至少留下手工复现步骤和可查询指标。问题关闭不等于知识关闭:把关键排查路径写进 runbook,下一次才能更快。

标题:
现象:
最小输入:
预期:
实际:
环境与版本:
发生时间与频率:
最近变化:
已有证据(日志 / trace / 请求 / commit):
已排除的假设:
下一步:

这份记录的价值在于让结论可复核。好的调试不是“我猜中了”,而是别人根据证据也能得到同一个结论。

  • 是否能解释工作区、暂存区、提交和远端引用的区别。
  • 是否能在不破坏公共历史的情况下撤销修改。
  • 是否会在合并前查看远端新提交和潜在冲突。
  • 是否知道 reset、revert 和 reflog 的风险边界。
  • 是否能写出最小复现,而不是只描述“打不开”。
  • 是否能按浏览器、网络、服务、数据和系统层缩小范围。
  • 是否有 request ID、结构化日志和可关联的 trace。
  • 是否一次只改变一个变量,并为结论保留证据。
  • 修复后是否补了测试或监控,防止同类问题再次出现。