大型 Repository 用久了,常會出現一些很難描述的「鈍」:
git status還好,但git log -- path/to/file越來越慢;git fetch完成後,偶爾會卡在整理物件;.git/objects/pack/裡累積一大串 pack;- 同事說「跑一下
git gc --aggressive就好」,卻沒人先量過; - CI 快取整包
.git,每次只是把歷史包袱搬到下一台機器。
這些症狀不一定都靠 GC 解決。
Git 的效能不只取決於工作目錄大小,還跟 commit 拓樸、物件數量、pack 數量、refs 數量,以及維護工作何時執行有關。
這篇跟著拍拍君從「先量測」開始,拆解 git maintenance、commit-graph 與 multi-pack-index(MIDX),最後建立可驗證、可停用、不會半夜亂刪資料的背景維護流程。
本文聚焦本機 Repository 的資料結構維護。若問題是 Monorepo checkout 太大,請先看 Git sparse-checkout 實戰;兩者解的是不同瓶頸。
一. 先別急著 GC:你要修的是哪一層? #
Git Repository 大致可以從四層觀察:
| 層次 | 常見現象 | 對應工具 |
|---|---|---|
| Working tree / index | status、checkout 慢 |
sparse-checkout、fsmonitor、index 設定 |
| Commit 拓樸 | log、merge-base、可達性查詢慢 |
commit-graph |
| Object lookup | pack 很多、找物件成本增加 | multi-pack-index |
| 儲存與過期資料 | loose objects、多餘 pack、舊 reflog | maintenance task、審慎的 GC |
git gc 是一組整理工作的集合,不是萬用加速按鈕。
更重要的是,效能問題要有基準:
- 記錄 Git 版本與檔案系統環境;
- 記錄
.git大小、loose object 與 pack 數量; - 選三到五個真正常用的 Git 指令;
- 先跑暖機,再重複量測;
- 一次只改一個維護策略;
- 維護後做完整性驗證,再比較結果。
沒有 before / after 的「感覺變快」,只能算心情很好,不能算效能工程。
二. 建立 Repository 健康快照 #
2.1 先確認版本與 Repository 根目錄 #
git --version
git rev-parse --show-toplevel
git rev-parse --git-dir
不同版本的 Git 可能提供不同 maintenance task 或排程器。
如果你要把腳本交給整個團隊,先寫下最低支援版本,不要假設每台開發機的 Git 都一樣新。
2.2 看物件與 pack 的基本統計 #
git count-objects -vH
常見欄位包括:
count:目前 loose objects 數量;size:loose objects 佔用空間;in-pack:已放進 pack 的物件數量;packs:packfile 數量;size-pack:packfile 總大小;prune-packable:已有 pack 版本、可被整理的 loose objects;garbage:Git 不認得或需要人工檢查的檔案數量。
可以把快照留成純文字,方便日後比較:
mkdir -p .git/maintenance-metrics
git count-objects -vH > .git/maintenance-metrics/before.txt
這份檔案放在 .git 裡,不會被 commit;若 Repository 本身損壞,診斷資料也可能一起不見,所以正式監控應送到外部系統。
2.3 數 pack、commit-graph 與 MIDX #
find "$(git rev-parse --git-dir)/objects/pack" \
-name '*.pack' -type f | wc -l
find "$(git rev-parse --git-dir)/objects/info" \
-name 'commit-graph*' -o -name 'commit-graphs' 2>/dev/null
find "$(git rev-parse --git-dir)/objects/pack" \
-name 'multi-pack-index*' -o -name 'multi-pack-index.d' 2>/dev/null
這些命令只是盤點,不代表檔案越少一定越好。
大型 Repository 可能刻意保留多個 pack,再用 MIDX 提供統一索引;看到多個 pack 就立刻全量 repack,反而可能製造很長的停頓與大量暫存空間。
三. 量測真實操作,不要只量 git gc
#
3.1 用 /usr/bin/time 做最小量測
#
先挑團隊真的常用、而且輸出可丟棄的指令:
/usr/bin/time -p git rev-list --count --all >/dev/null
/usr/bin/time -p git log --all --oneline >/dev/null
/usr/bin/time -p git log --all -- src >/dev/null
/usr/bin/time -p git merge-base main HEAD >/dev/null
macOS 與 GNU time 的詳細輸出不同,-p 可以保留較可攜的 real、user、sys。
不要只跑一次。第一次可能包含磁碟 cache 暖機,後續執行也可能受到其他程式影響。
3.2 用 hyperfine 做重複比較 #
如果有安裝 hyperfine,可以固定暖機與次數:
hyperfine --warmup 3 --runs 10 \
'git rev-list --count --all' \
'git log --all -- src >/dev/null'
請在同一台機器、同一份 Repository、相近負載下比較。
維護前後若剛好有新的 fetch、branch 或大量檔案變更,兩組資料就不是同一個實驗。
3.3 需要拆解時使用 Trace2 #
Git Trace2 的 PERF target 可以把巢狀區段與計時事件寫成欄位化紀錄:
trace_file="$(mktemp)"
GIT_TRACE2_PERF="$trace_file" git log --all -- src >/dev/null
sed -n '1,30p' "$trace_file"
rm -f "$trace_file"
這適合回答「時間花在讀 commit-graph、走 revision graph,還是其他階段」,而不是只看到一個總秒數。
Trace 檔可能含路徑、參數與 Repository 資訊,不要直接貼進公開 issue。
四. Commit-Graph:替 Commit 拓樸建立捷徑 #
4.1 它加速的是什麼? #
Commit 本身會記錄 parent,但每次從大量 commit 物件追溯拓樸都很昂貴。
commit-graph 把常用的拓樸資訊與 generation number 序列化,讓 revision walk、可達性判斷、merge-base 等操作少讀很多散落物件。
它不會修改 commit ID,也不會重寫歷史。
刪除 commit-graph 後,Git 仍可回到直接讀取 object database;這是一份可重建的加速資料。
4.2 手動建立與驗證 #
git commit-graph write --reachable
git commit-graph verify
大型而且持續成長的 Repository,通常更適合 split chain:
git commit-graph write --reachable --split
git commit-graph verify
新 commit 可以先寫成較小的一層,之後再依大小策略合併,不必每次重寫整張圖。
日常使用不必自己反覆執行這些底層命令;後面會讓 git maintenance 管理 incremental write 與 verify。
4.3 Path history 慢時,再考慮 Bloom filters #
如果瓶頸是這種查詢:
git log --all -- path/to/component
可以評估 changed-path Bloom filters:
git commit-graph write --reachable --split --changed-paths
git commit-graph verify
它能幫 Git 快速排除「大概沒改到這條路徑」的 commit,但建立 filter 本身需要時間與空間。
因此先量測 path-limited history,確認這真的是常見瓶頸,再決定是否啟用:
git config commitGraph.changedPaths true
團隊若混用不同世代的 Git,還要測試相容性,別只在一台最新版本機器上宣告勝利。
五. Multi-Pack-Index:多個 Pack 共用一張索引 #
5.1 為什麼不永遠 repack 成一包? #
每個 pack 都有自己的 .idx。
當 pack 數量增加,物件查找可能需要檢查多份 index;可是把所有物件重打成單一巨型 pack,可能消耗大量 CPU、I/O、時間與額外磁碟空間。
MIDX 的想法是:
保留多個 packfile,但建立一份跨 pack 的物件位置索引。
Git 因此可以用同一份索引定位物件,不必先把整座倉庫重包一次。
5.2 建立與驗證 MIDX #
git multi-pack-index write
git multi-pack-index verify
底層檔案通常位於:
.git/objects/pack/multi-pack-index
MIDX 是衍生索引;各 pack 的 .idx 仍保留,所以它不是把唯一可讀資料鎖進一個神秘格式。
5.3 Incremental repack 的兩階段節奏 #
git maintenance 的 incremental-repack 會利用 MIDX:
expire清掉已不再被 MIDX 引用的舊 pack;repack挑數個較小 pack 合成較大的 pack;- 更新 MIDX,讓物件位置指向新 pack;
- 被取代的 pack 留到後續週期再安全清理。
這種漸進節奏,通常比大型 Repository 動不動就 full GC 更適合背景執行。
若要手動診斷,可以分開驗證:
git multi-pack-index verify
git fsck --connectivity-only
不要在不理解目前 object database 狀態時,直接把 expire、repack 和手動刪檔混成一串 shell 指令。
六. git maintenance:把小工作放到背景
#
6.1 先看預設,不要先改全域設定 #
在 Repository 內執行:
git config --show-origin --get maintenance.strategy || true
git config --show-origin --get-all maintenance.repo || true
git config --show-origin --get maintenance.auto || true
若只是想做一次 commit-graph 維護:
git maintenance run --task=commit-graph
若要依目前啟用的 task 與門檻判斷是否需要執行:
git maintenance run --auto
--task 明確指定工作,很適合先在測試 Repository 驗證;不要一開始就用最高權限把未知設定灑到所有專案。
6.2 註冊單一 Repository #
git maintenance register
這會把目前 Repository 加進使用者層級的 maintenance.repo 清單。
若原本沒有設定 strategy,Git 會採用適合背景工作的 incremental 策略,典型節奏包括:
- commit-graph:hourly;
- prefetch:hourly;
- loose-objects:daily;
- incremental-repack:daily;
- GC:不放進這組背景排程。
實際 task 與頻率仍要以你安裝版本的 git help maintenance 為準。
6.3 啟動平台排程器 #
git maintenance start
Git 會選擇平台排程器;也可以明確指定:
# macOS
git maintenance start --scheduler=launchctl
# Linux(可用時)
git maintenance start --scheduler=systemd-timer
# Windows
git maintenance start --scheduler=schtasks
背景排程會透過註冊清單逐一處理 Repository,不需要為每個專案手刻一份 cron。
6.4 確認真的註冊成功 #
git config --global --get-all maintenance.repo
git config --local --get maintenance.strategy
git config --local --get maintenance.auto
macOS 可再檢查 launchd:
launchctl list | grep -i git || true
Linux systemd user timer 可檢查:
systemctl --user list-timers | grep -i git || true
不要只看到 git maintenance start exit code 為 0 就收工;排程器、註冊清單與 Repository 設定三層都要確認。
七. GC 的安全邊界:哪些指令不要順手貼? #
7.1 git gc --aggressive 不是定期保養
#
--aggressive 會用更昂貴的方式重新計算 delta,可能花很久,卻不保證改善你的真實 workload。
把它排進每日 cron,通常只是穩定地浪費資源。
如果診斷後確實要執行 GC,優先透過 maintenance 的鎖定與工作模型:
git maintenance run --task=gc
仍應安排維護窗口,並避免跟其他寫入 object database 的作業重疊。
7.2 避免立即 prune #
這類指令風險很高:
# 不建議當成一般清理命令
git gc --prune=now
git prune --expire=now
不可達物件有時正是 reflog、未完成操作或救援流程的最後保險。
立即刪除也可能跟同時寫入 Repository 的程序產生競態;節省的那點空間,不一定值得拿復原能力交換。
如果你的目的其實是找回誤刪 commit,應先停止清理並閱讀 Git reflog 救援實戰。
7.3 不要手動刪 .git/objects/pack/*
#
pack、index、bitmap、MIDX 彼此有引用關係。
用檔名大小或修改時間猜「這包看起來很舊」,是讓 Repository 從慢變成壞的最快方式之一。
正確做法是:
git multi-pack-index verify
git fsck --connectivity-only
git maintenance run --task=incremental-repack
讓 Git 自己更新索引與選擇可清理的 pack。
八. 建立可重跑的維護檢查腳本 #
下面腳本只做量測、明確 task、驗證與再量測,不碰 --prune=now:
#!/usr/bin/env bash
set -euo pipefail
git rev-parse --is-inside-work-tree >/dev/null
metrics_dir="$(git rev-parse --git-dir)/maintenance-metrics"
mkdir -p "$metrics_dir"
timestamp="$(date +%Y%m%dT%H%M%S)"
before="$metrics_dir/${timestamp}-before.txt"
after="$metrics_dir/${timestamp}-after.txt"
{
git --version
git count-objects -vH
/usr/bin/time -p git rev-list --count --all >/dev/null
} >"$before" 2>&1
git maintenance run --task=commit-graph
git commit-graph verify
{
git --version
git count-objects -vH
/usr/bin/time -p git rev-list --count --all >/dev/null
} >"$after" 2>&1
printf 'before: %s\nafter: %s\n' "$before" "$after"
這份範例刻意只執行 commit-graph task,因為一次只改一個變因才容易判讀。
若你接著要測 incremental repack,另開一次實驗、保留新的 before / after,不要把兩種效果混在一起。
8.1 在 CI 中只做驗證,不一定做重維護 #
短命 CI runner 通常沒有必要排背景 maintenance。
比較實用的是在持久 cache 或 self-hosted runner 上監控,並讓一般 CI 做輕量檢查:
git fsck --connectivity-only
git commit-graph verify 2>/dev/null || true
git multi-pack-index verify 2>/dev/null || true
最後兩項可能因為尚未建立對應索引而失敗,所以範例把它們當成可選診斷;若你的環境宣告「一定存在」,就應移除 || true,讓缺失真的使 job 失敗。
九. 變更後如何驗收與回復? #
9.1 驗收清單 #
完成 maintenance 後,至少檢查:
git fsck --connectivity-only
git commit-graph verify
git multi-pack-index verify
git count-objects -vH
再重跑相同量測:
/usr/bin/time -p git rev-list --count --all >/dev/null
/usr/bin/time -p git log --all -- src >/dev/null
同時觀察:
- 常用指令的中位數是否改善;
- 最慢一次是否仍出現長尾;
.git空間是下降、持平,還是換取速度而略增;- maintenance 執行時間是否超過排程間隔;
- fetch / checkout 是否跟背景工作互相搶 I/O。
9.2 停止排程與取消註冊 #
停止整個使用者的 Git maintenance 排程:
git maintenance stop
只把目前 Repository 從清單移除:
git maintenance unregister
取消註冊不等於恢復所有 config;請重新查看設定來源:
git config --show-origin --get maintenance.auto || true
git config --show-origin --get maintenance.strategy || true
git config --global --get-all maintenance.repo || true
commit-graph 與 MIDX 是可重建的衍生資料,但別把「可重建」誤會成「應該直接用 rm 清掉」。先停用策略、驗證行為,再透過 Git 提供的命令處理。
十. 一套務實的導入順序 #
如果拍拍君要替團隊導入,會照這個順序:
- 盤點:Git 版本、Repository 大小、object / pack / ref 數量;
- 量測:挑出 revision walk、path history、fetch 等真實 workload;
- 低風險試跑:先做
commit-graphtask 與 verify; - 觀察 MIDX:pack 多時測試 incremental repack,不急著 full GC;
- 單一 Repo 註冊:確認 config 與排程器真的生效;
- 擴大範圍:一批一批加入,不要一次註冊所有巨型專案;
- 保留回復路徑:知道怎麼 stop、unregister、查看 config source;
- 定期檢討:若維護時間逼近排程間隔,就降低工作複雜度或頻率。
這套流程的核心不是「把 .git 壓到最小」,而是讓前景操作延遲穩定,並把昂貴整理拆成可控的小工作。
結語 #
大型 Repository 的維護,真正麻煩的不是缺少一條神奇指令,而是我們太容易把不同瓶頸都叫做「Git 很慢」。
記住三個角色就好:
- commit-graph 加速 commit 拓樸與 revision walk;
- MIDX 讓多個 pack 共用物件索引,支援漸進式 repack;
- git maintenance 把可背景執行的整理工作排程化、可驗證化。
先量測、再執行一個 task、接著 verify,最後用相同 workload 比較。
這比每週五下班前盲跑一次 git gc --aggressive 科學多了,也比較不會讓週末變成 Repository 救援實戰。🔧