Một nghịch lý làm nhiều người bối rối: Git nói rằng mỗi commit lưu một snapshot đầy đủ của toàn bộ cây thư mục, không phải diff. Vậy nếu tôi sửa một dòng trong file 600 KB rồi commit 100 lần, thư mục .git phải nặng 60 MB? May thay, không. Câu trả lời nằm ở hai cơ chế tách bạch: object bất biến khử trùng theo SHA (bài đầu sê-ri), và packfile — nơi Git nén nhiều object lại và lưu chúng dưới dạng delta. Bài này mở nắp packfile ra đo tận mắt trong Git 2.39.
Loose object và packed object
Git có hai cách lưu một object trên đĩa:
- Loose object: mỗi object là một file riêng trong
.git/objects/xx/yyyy..., nội dung nén bằng zlib. Đây là dạng Git ghi ngay khi bạn commit — nhanh, đơn giản. Nhưng ở dạng này, mỗi phiên bản của file 600 KB là một blob riêng nén đầy đủ (~30 KB nén cho mỗi bản). Sửa một dòng vẫn ra một blob mới gần như to bằng. - Packed object:
git gc(chạy tự động định kỳ, hoặc khi push) dồn nhiều loose object vào một packfile duy nhất (.git/objects/pack/pack-*.pack), và ở đây Git áp dụng nén delta: các object gần giống nhau không lưu đầy đủ mà lưu như "khác biệt so với một object nền".
git gc # dồn loose object vào packfile, dọn rác
git count-objects -vH # đếm loose vs in-pack, xem dung lượng
git verify-pack -v .git/objects/pack/pack-*.idx # soi từng object trong pack

Hình 1: Git ghi loose object khi commit, rồi git gc gói chúng vào packfile với nén delta. git verify-pack -v cho thấy object nào là delta (có depth+base) và object nào là bản nền đầy đủ.
Đo thật: 6 phiên bản file 622 KB
Dựng một repo, tạo data.txt khoảng 622 KB (12.000 dòng), rồi commit 6 phiên bản — mỗi lần chỉ sửa khoảng 20 dòng rải rác. Trước khi gc, đếm loose object:
git config gc.auto 0 # tắt gc tự động để quan sát loose
# ... 6 commit ...
git count-objects -vH # count: 18, size: 248 KiB, in-pack: 0
18 loose object (6 blob + 6 tree + 6 commit), tổng 248 KiB. Mỗi blob data.txt nén zlib độc lập nên 6 bản chiếm phần lớn dung lượng đó. Giờ chạy git gc:

Hình 2: Sau git gc, 18 object vào một packfile 34.54 KiB. verify-pack cho thấy blob nền c84d302 (636.890 byte → 30.950 trong pack), còn 5 phiên bản sau là delta chuỗi depth 1→5, mỗi bản chỉ ~350 byte trong pack.
Kết quả rất rõ: 248 KiB loose → 34.54 KiB trong packfile. Nhìn vào verify-pack -v để hiểu vì sao. Cột của nó là: SHA, kiểu, size thực (sau giải nén), size trong packfile, offset, và với delta thì thêm depth + SHA của base.
- Blob nền
c84d302: size thực 636.890 byte (~622 KB), trong pack 30.950 byte — đây là bản đầy đủ, nén zlib bình thường. - Năm blob sau (
2ef78f1,0628443, ...): mỗi cái size thực ~600 byte nhưng đó là size của delta, và trong pack chỉ 347–364 byte. Chúng tạo thành một chuỗi:depth=1base là blob nền,depth=2base là blob depth 1, cứ thế tớidepth=5.
Nói cách khác: thay vì lưu 6 lần blob 30 KB, Git lưu một blob nền 30 KB + năm delta mỗi cái ~350 byte. Tổng size thực của 6 blob là 639.924 byte (~625 KB); trong packfile chúng chỉ chiếm 32.735 byte (~32 KB) — gộp gần 20 lần. Đây chính là câu trả lời cho nghịch lý ban đầu: snapshot là mô hình khái niệm (mỗi commit trỏ tới cây đầy đủ), còn cách lưu vật lý là delta nén.
Delta không đi theo thứ tự thời gian
Một chi tiết tinh tế, đáng biết: Git không bắt buộc lưu delta theo kiểu "bản mới là diff của bản cũ". Heuristic đóng gói của Git thường chọn bản lớn hơn làm base và diff bản nhỏ hơn từ nó, vì delta từ lớn sang nhỏ (xoá bớt) gọn hơn. Nó cũng ưu tiên các object có tên file và kích thước tương tự nằm gần nhau trong pack. Vì thế đừng ngạc nhiên nếu verify-pack cho thấy base là một commit mới hơn delta — thứ tự delta là quyết định về nén, không phải về lịch sử.
Độ sâu chuỗi delta bị chặn bởi pack.depth (mặc định 50): chuỗi càng dài càng tốn CPU khi đọc (phải áp nhiều delta liên tiếp để dựng lại object), nên Git đánh đổi giữa tỷ lệ nén và chi phí giải nén.
Đánh đổi cần cân nhắc
Packfile tối ưu cho văn bản, không cho binary đã nén. Delta hiệu quả khi các phiên bản giống nhau về byte. File văn bản (mã nguồn, JSON, HTML) delta rất tốt như đo ở trên. Nhưng ảnh JPEG, video, file zip — vốn đã nén và mỗi bản khác nhau hoàn toàn về byte — gần như không delta được; commit nhiều phiên bản của chúng khiến .git phình thật. Đây là lý do dữ liệu binary lớn nên để ngoài Git (hoặc dùng Git LFS).
gc tốn CPU và RAM. Đóng gói lại toàn bộ repo là việc nặng: Git phải so từng cặp object để tìm delta tốt. Với repo lớn, git gc (hay git repack -a -d) có thể chạy nhiều phút và ngốn RAM. Đó là lý do nó chạy nền định kỳ chứ không phải sau mỗi commit — commit chỉ ghi loose object cho nhanh.
Đừng xóa packfile bằng tay. Thấy .git/objects/pack to đôi khi có người muốn dọn thủ công — tuyệt đối không. Mọi lịch sử với tới được nằm trong đó. Muốn dọn thật thì git gc --prune=now (sau khi chắc chắn không cần commit mồ côi nào — nhớ bài reflog), để Git tự quyết định object nào bỏ được.
Ba ý mang về
- Snapshot là mô hình, delta là cách lưu. Mỗi commit khái niệm là ảnh chụp cây đầy đủ, nhưng packfile lưu vật lý bằng nén delta — đo thật: 6 phiên bản file 622 KB (loose 248 KiB) gói lại chỉ còn 34.54 KiB, phần blob chỉ 32 KB (gộp gần 20 lần).
verify-pack -vcho xem tận mắt chuỗi delta: một blob nền đầy đủ (30.950 byte) + các delta chuỗidepth1→5, mỗi bản sửa chỉ ~350 byte trong pack. Delta chọn base theo heuristic nén, không theo thứ tự thời gian.- Biết giới hạn: delta ăn với văn bản, gần như vô dụng với binary đã nén (để chúng ngoài Git/dùng LFS);
gcnặng CPU nên chạy nền định kỳ; và đừng bao giờ xóa packfile bằng tay — dùnggit gc --prune.
Nguồn
- Pro Git — Git Internals: Packfiles: https://git-scm.com/book/en/v2/Git-Internals-Packfiles
- Git Documentation — git-verify-pack và git-repack: https://git-scm.com/docs/git-verify-pack
Phần sau ta xem cách Git trả lời hai câu hỏi bạn gõ hàng chục lần mỗi ngày — git status và git diff: chúng so những gì với những gì (working tree, index, HEAD), và vì sao đôi khi chậm trên repo lớn.