Git 与调试
Git 保存历史,调试寻找原因。两者看起来无关,却共享同一个基础能力:把当前状态变成可观察、可回退、可比较的状态。没有版本控制,很难知道“哪次修改引入了问题”;没有可复现步骤,也很难判断问题是否真的被修好。
1. Git 保存的是快照图
跳转到“1. 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 statusgit diffgit add src/request.tsgit diff --stagedgit commit -m "fix: handle request timeout"一条好的提交信息说明改了什么、为什么改。个人项目不必模仿大型团队全部规范,但应避免 update、fix bug 这类以后无法检索的描述。
3. 分支、合并与变基
跳转到“3. 分支、合并与变基”创建分支只是创建一个新的引用:
git switch -c feature/login完成修改后,常见整合方式有:
- 合并(merge):保留两条历史,生成合并提交。历史真实,但分支多时图会比较复杂。
- 变基(rebase):把当前分支的提交重新应用到另一个基线上,历史更线性,但会改写提交哈希。
- 压缩合并(squash):把多个提交合成一个,适合一个功能需要保留为单一变更。
已经共享给别人的提交不要随意变基或强制推送。改写历史会改变提交身份,协作者基于旧历史的工作会变复杂。个人分支可以整理历史,公共主干要优先保证可追溯。
3.1 冲突不是 Git 坏了
跳转到“3.1 冲突不是 Git 坏了”冲突发生在两边都修改了同一区域,Git 无法自动判断正确语义。解决步骤不是简单选择“我的版本”,而是:
- 看两边的意图和上下文。
- 保留两者都需要的改动。
- 删除冲突标记。
- 运行格式、类型检查和测试。
git add标记已解决,再完成合并或变基。
冲突标记如下:
<<<<<<< HEADcurrent version=======incoming version>>>>>>> feature最终文件里不应该残留这些标记。机器能自动合并,不代表语义一定正确;冲突解决后必须重新验证。
4. 查看历史与恢复
跳转到“4. 查看历史与恢复”git log --oneline --graph --decorate --allgit show <commit>git diff <old>..<new> -- path/to/filegit blame -L 20,40 path/to/filelog 看提交图,show 看某次快照,blame 看每一行最后由哪次提交引入。blame 是定位上下文,不是追责工具;一行代码可能只是格式调整,真正语义变化在更早的提交。
4.1 reset、revert 与 reflog
跳转到“4.1 reset、revert 与 reflog”| 命令 | 作用 | 适合场景 |
|---|---|---|
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 startgit bisect bad HEADgit bisect good v1.4.0# 每次测试后标记git bisect goodgit bisect badgit bisect resetGit 每次把范围减半,最终指向第一个可疑提交。前提是问题可稳定判断,并且“好版本”和“坏版本”之间没有无法运行的环境断层。
5. 远端不是自动同步
跳转到“5. 远端不是自动同步”origin/main 是本地保存的“上次看到的远端 main”,不是实时状态。只有执行 fetch 或 pull 时才可能更新。
git fetch origingit log --oneline HEAD..origin/maingit log --oneline origin/main..HEADpull 大致等于 fetch 加合并或变基,具体取决于配置。先 fetch 再查看差异,通常比盲目 pull 更容易理解将要发生什么。
推送被拒绝时,先判断原因:
- 远端有新提交:先整合再推。
- 权限不足:检查账号、密钥和仓库权限。
- 分支受保护:走代码评审或调整分支策略。
- 历史不兼容:确认自己是否在基于旧历史强制推送。
不要因为推送失败就习惯性使用 --force。需要覆盖远端时优先使用 --force-with-lease,它至少能发现远端在你上次 fetch 后又被别人修改。
6. 不该进入仓库的东西
跳转到“6. 不该进入仓库的东西”.gitignore 是边界,不是清理工具。已经提交的敏感信息即使后来删除,也仍然存在于历史中,需要轮换密钥并清理历史。
应忽略的常见内容:
node_modules/.venv/dist/coverage/.env*.log*.pem*.keymodels/*.bin大文件、构建产物和依赖目录会使仓库膨胀,并制造无意义差异。二进制资产应明确来源、许可和存储方式。提交前至少看一次 git status 和 git diff --staged。
7. 调试是一条证据链
跳转到“7. 调试是一条证据链”调试不是“凭经验猜一个地方改掉”。更可靠的过程是:
复现问题 ↓提出可被证伪的假设 ↓选择能验证假设的最小观察 ↓缩小范围 ↓修复根因 ↓补测试并验证没有回归每一步都应留下证据:最小输入、环境版本、日志、请求、trace、文件或提交差异。没有证据的判断只是猜测。
7.1 先复现
跳转到“7.1 先复现”复现记录至少包含:
- 实际操作和输入。
- 预期结果与实际结果。
- 操作系统、运行时、依赖和配置版本。
- 错误发生时间、频率和影响范围。
- 是否能稳定重现;是否与账号、数据、网络或并发有关。
偶发问题不要因为一次没重现就标记为“已解决”。记录失败概率、发生窗口和触发条件,继续积累样本。
7.2 按层缩小范围
跳转到“7.2 按层缩小范围”一个常见 Web 请求可以按以下层排查:
| 层 | 要问的问题 | 典型证据 |
|---|---|---|
| 浏览器 | 有没有 JS 错误、请求失败、渲染卡顿 | Console、Network、Performance |
| 网络 | DNS、TCP、TLS、代理是否正常 | curl -v、DNS 记录、证书 |
| 入口 | 网关是否转发、限流或拒绝 | 状态码、访问日志、路由配置 |
| 应用 | 是哪段代码、哪个依赖慢或失败 | 结构化日志、trace、断点 |
| 数据 | 查询、锁、索引、连接池是否异常 | 慢查询、执行计划、指标 |
| 系统 | CPU、内存、磁盘、文件描述符是否耗尽 | 系统指标、进程状态、日志 |
不要同时改五个变量。一次只改变一个条件,才能知道结果由什么导致。
8. 浏览器调试
跳转到“8. 浏览器调试”浏览器开发者工具主要看五个区域:
- Console:JavaScript 错误、警告和临时输出;大量输出本身会拖慢页面。
- Network:请求 URL、方法、状态码、请求头、响应、耗时和缓存。
- Sources:源码、断点、调用栈和作用域;Source Map 可帮助定位压缩代码。
- Performance:长任务、布局、绘制、主线程和帧率。
- Application:Cookie、Local Storage、Session Storage、IndexedDB、Service Worker 与缓存。
排查请求问题时,要区分:
- 请求根本没有发出:前端逻辑、表单、权限或事件绑定。
- 请求发出但失败:DNS、网络、CORS、TLS、服务端错误。
- 返回 200 但页面不对:响应结构、状态更新、渲染或缓存。
- 浏览器缓存旧资源:缓存头、Service Worker、构建哈希或强刷。
9. 服务端调试
跳转到“9. 服务端调试”服务端日志应结构化,至少包含时间、级别、服务、环境、请求 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 高可能是死循环,也可能只是正常加密;延迟高可能是代码慢,也可能是在排队等数据库连接。
10. 断点、日志与二分
跳转到“10. 断点、日志与二分”10.1 断点
跳转到“10.1 断点”断点适合看某条路径的变量和调用栈。条件断点可以只在特定用户或输入上暂停;日志断点可以不修改代码而输出局部信息。
但断点会暂停进程,不适合观察长时间运行、并发竞争或生产环境中的高频请求。远程调试端口也必须严格保护,不能暴露到公网。
10.2 日志
跳转到“10.2 日志”日志应回答具体问题,而不是把每个变量都打印出来。增加日志前先问:这条记录能证实或推翻哪一个假设?验证完成后是否应该降级或删除?
10.3 二分
跳转到“10.3 二分”二分不只适用于提交,也适用于配置、输入和服务链路:
- 禁用一半功能开关。
- 替换一半依赖。
- 对请求按用户、地区或数据规模分组。
- 在请求路径中间增加计时点。
前提是各组之间有明确边界,且能判断结果是否正常。
11. 并发与偶发故障
跳转到“11. 并发与偶发故障”偶发问题常来自共享状态。检查:
- 两个线程是否同时读写同一对象。
- 锁的获取顺序是否可能形成循环。
- 超时后是否仍有后台任务继续修改状态。
- 重试是否造成同一操作执行多次。
- 连接池或队列是否有上限,等待是否会无限增长。
- 时间、随机数和外部服务响应是否让测试难以重现。
不要用“加个 sleep”掩盖竞态。sleep 只是改变时序概率,不是同步机制。
12. 修复之后
跳转到“12. 修复之后”修复需要同时完成三件事:
- 改变根因,而不是只在报错处吞掉异常。
- 增加能稳定暴露该问题的测试或监控。
- 验证正常路径、边界路径和失败路径。
如果无法自动测试,至少留下手工复现步骤和可查询指标。问题关闭不等于知识关闭:把关键排查路径写进 runbook,下一次才能更快。
13. 最小问题记录
跳转到“13. 最小问题记录”标题:现象:最小输入:预期:实际:环境与版本:发生时间与频率:最近变化:已有证据(日志 / trace / 请求 / commit):已排除的假设:下一步:这份记录的价值在于让结论可复核。好的调试不是“我猜中了”,而是别人根据证据也能得到同一个结论。
检查清单
跳转到“检查清单”- 是否能解释工作区、暂存区、提交和远端引用的区别。
- 是否能在不破坏公共历史的情况下撤销修改。
- 是否会在合并前查看远端新提交和潜在冲突。
- 是否知道
reset、revert和reflog的风险边界。 - 是否能写出最小复现,而不是只描述“打不开”。
- 是否能按浏览器、网络、服务、数据和系统层缩小范围。
- 是否有 request ID、结构化日志和可关联的 trace。
- 是否一次只改变一个变量,并为结论保留证据。
- 修复后是否补了测试或监控,防止同类问题再次出现。