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

Ảnh chụp đoạn mã shell nền tối giải thích packfile vì sao thư mục git không phình to dù mỗi commit là snapshot, hai dạng lưu object của Git dạng loose mỗi object một file trong git objects nén zlib nên snapshot sửa một dòng của file 600KB vẫn lưu cả 600KB nén, dạng packed gom nhiều object vào một packfile và nén delta, lệnh git gc dồn loose vào packfile git count-objects -vH đếm loose và in-pack, delta object sau lưu như khác biệt so với object nền trong packfile các phiên bản gần giống nhau không lưu đầy đủ mà lưu base cộng vá chuỗi delta có độ sâu depth, git verify-pack -v xem cột SHA type size-thực size-trong-pack offset depth base dòng có depth và base là object delta không có là base nguyên bản, điều hiểu lầm sai snapshot nghĩa là mỗi commit nhân đôi dung lượng đúng object bất biến khử trùng theo SHA cộng packfile nén delta nên 6 phiên bản file 622KB gói lại chỉ còn 34KB

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:

Ảnh chụp bảng kết quả đo thật nền tối chạy git 2.39, sáu phiên bản của data.txt khoảng 622KB mỗi bản mỗi lần chỉ sửa 20 dòng, trước và sau git gc dùng git count-objects -vH trước gc count 18 loose size 248 KiB in-pack 0 sau gc count 0 in-pack 18 packs 1 size-pack 34.54 KiB, git verify-pack -v chuỗi delta của 6 blob data.txt cột SHA size-thực trong-pack ghi chú, c84d302 size thực 636890 trong pack 30950 là BASE nguyên bản nén zlib, 2ef78f1 size 803 trong pack 364 DELTA depth 1 base c84d302, 0628443 size 568 trong pack 360 DELTA depth 2 base 2ef78f1, 447c3a5 size 564 trong pack 359 DELTA depth 3 base 0628443, 68d7c51 size 565 trong pack 355 DELTA depth 4 base 447c3a5, aabcd93 size 534 trong pack 347 DELTA depth 5 base 68d7c51, kết mỗi bản sửa chỉ tốn khoảng 350 byte thay vì 30KB tổng size thực 6 blob 639924 byte khoảng 625KB tổng trong packfile 32735 byte khoảng 32KB gộp gần 20 lần cả packfile gồm commit tree blob 36KB

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=1 base là blob nền, depth=2 base là blob depth 1, cứ thế tới depth=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ề

  1. 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).
  2. verify-pack -v cho xem tận mắt chuỗi delta: một blob nền đầy đủ (30.950 byte) + các delta chuỗi depth 1→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.
  3. 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); gc nặng CPU nên chạy nền định kỳ; và đừng bao giờ xóa packfile bằng tay — dùng git gc --prune.

Nguồn

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.