Hầu hết chúng ta dùng Git như một hộp đen: git add, git commit, git push, và cầu cho nó chạy. Nhưng khi có sự cố (merge rối, mất commit, .git phình to), hiểu bên trong Git cứu bạn. Tin tốt: lõi Git đơn giản đến bất ngờ — nó chỉ là một key-value store nội dung địa chỉ hoá (content-addressed). Bài đầu của loạt "Git nội bộ" này mổ xẻ ba loại object nền tảng bằng lệnh git thật, và tự tay tính ra khoá SHA của Git.

Content-addressed: khoá chính là nội dung

Trong Git, "địa chỉ" của một dữ liệu chính là hàm băm SHA-1 của nội dung nó. Lệnh git hash-object cho thấy điều đó:

$ echo -n "xin chao git" > f.txt
$ git hash-object -w f.txt          # -w = ghi object vào .git
c8c3736359f89d7fd2a7a0f3f80eb4712012c7e1

Điểm mấu chốt: SHA băm nội dung, không phải tên file. Cùng nội dung luôn cho cùng SHA, dù ở file nào:

$ echo -n "xin chao git" | git hash-object --stdin
c8c3736359f89d7fd2a7a0f3f80eb4712012c7e1     # KHỚP

Đọc lại object bằng git cat-file:

$ git cat-file -t c8c3736    # -t = loại  -> blob
$ git cat-file -p c8c3736    # -p = nội dung -> xin chao git

Ảnh chụp đoạn mã nền tối minh hoạ Git là CSDL nội dung địa chỉ hoá blob tree commit git key-value store khoá SHA của nội dung 3 loại object, một git hash-object nội dung tới khoá SHA địa chỉ bằng nội dung echo -n xin chao git lớn hơn f.txt git hash-object -w f.txt -w ghi object vào .git c8c3736 SHA băm nội dung không phải tên file cùng nội dung bằng cùng SHA dedup tự nhiên git cat-file -t c8c3736 loại blob git cat-file -p in nội dung xin chao git, hai SHA bằng sha1 blob độ dài null nội dung rồi zlib nén lưu vào .git objects printf blob 12 null xin chao git sha1sum c8c3736 khớp SHA của git object thô trên đĩa .git objects c8 c3736 2 ký tự đầu thư mục nén zlib byte đầu 78, ba một commit sinh đủ 3 loại object trỏ nhau thành cây commit trỏ tree trỏ blob commit ai khi nào message parent tree thư mục tên tới SHA blob blob nội dung file commit bằng ảnh chụp snapshot toàn bộ cây tại một thời điểm không phải diff

Hình 1: git hash-object băm nội dung thành khoá SHA; git cat-file đọc lại object; SHA tính từ header blob <len>\0<nội dung> rồi nén zlib; một commit trỏ tree, tree trỏ blob.

Tự tính khoá SHA của Git (không phải phép màu)

Object của Git không chỉ là nội dung thô — nó có một header nhỏ: <loại> <độ dài>\0 đứng trước nội dung, rồi cả cụm được băm SHA-1 và nén zlib. Ta tự tính ra được đúng khoá của Git:

$ printf "blob 12\0xin chao git" | sha1sum
c8c3736359f89d7fd2a7a0f3f80eb4712012c7e1     # khớp SHA của git!

Object thô trên đĩa nằm ở .git/objects/c8/c3736... (2 ký tự đầu SHA làm tên thư mục, phần còn lại làm tên file), và được nén zlib — byte đầu tiên là 78, magic byte của zlib. Không có gì huyền bí: Git = SHA + zlib + một quy ước header.

Ba loại object trỏ nhau thành cây

Khi bạn commit, Git sinh đủ ba loại object:

$ git add f.txt && git commit -m "bai dau tien"
$ git cat-file -p HEAD
tree ee36608525da4eec8c770bd03f3d0b2ff3fe2542
author demo <a@b.c> 1790740945 +0000
...
    bai dau tien
$ git cat-file -p ee36608     # tree = một thư mục
100644 blob c8c3736359f89d7fd2a7a0f3f80eb4712012c7e1	f.txt
  • blob: nội dung một file (không có tên, không có thời gian — chỉ nội dung).
  • tree: một thư mục — ánh xạ tên → SHA (trỏ tới blob hoặc tree con). Tên file sống ở đây, không ở blob.
  • commit: một ảnh chụp (snapshot) — trỏ tới một tree gốc, kèm tác giả, thời gian, message, và (các) commit cha.

Chuỗi trỏ: commit → tree → blob. Quan trọng: commit lưu snapshot toàn bộ cây, không lưu "diff". Diff mà bạn thấy khi git show là Git tính ra lúc đó bằng cách so hai snapshot — không phải thứ được lưu.

Ảnh chụp bảng kết quả chạy thật git 2.39 output thật, content-addressed cùng nội dung cùng SHA echo -n xin chao git git hash-object -w c8c3736 012c7e1 echo -n git hash-object --stdin c8c3736 012c7e1 khớp git cat-file -t c8c3736 blob git cat-file -p c8c3736 xin chao git, chứng minh công thức SHA không phải phép màu printf blob 12 null xin chao git sha1sum c8c3736359f89d7fd2a7a0f3f80eb4712012c7e1 git tính c8c3736359f89d7fd2a7a0f3f80eb4712012c7e1 SHA bằng sha1 blob len null nội dung tự tính ra đúng khoá của git object thô trên đĩa 78 01 4b ca c9 4f byte đầu 78 zlib nén, một commit 3 object trỏ nhau git cat-file -p commit 98511a7 tree ee36608 tree ee36608 100644 blob c8c3736 f.txt blob c8c3736 xin chao git .git objects 98 511a7 ee 36608 c8 c3736 2 ký tự đầu thư mục, vì sao quan trọng nguồn Pro Git Git Internals toàn vẹn đổi 1 byte nội dung đổi SHA git phát hiện hỏng dữ liệu ngay dedup cùng nội dung chỉ lưu 1 object dù ở nhiều file commit bất biến object không sửa được thay đổi bằng tạo object mới trỏ lại nền tảng mọi thứ branch merge history xây trên 3 loại object này

Hình 2: Chạy thật trên git 2.39 — cùng nội dung cho cùng SHA, công thức sha1("blob <len>\0...") tự tính ra đúng khoá của git, object nén zlib (byte đầu 78), và một commit trỏ tree trỏ blob.

Vì sao thiết kế này lại hay đến vậy

Content-addressing không phải chi tiết vụn — nó là nền của mọi tính năng Git (theo Pro Git — Git Internals):

  • Toàn vẹn dữ liệu: đổi một byte nội dung là đổi SHA. Git so SHA khi đọc object nên phát hiện hỏng dữ liệu (đĩa lỗi, sửa lén) ngay lập tức.
  • Khử trùng (dedup) tự nhiên: cùng một nội dung chỉ lưu một object, dù nó xuất hiện ở nhiều file hay nhiều commit. Đây là lý do .git không phình theo kiểu ngây thơ (bài packfile sau còn nén thêm nữa).
  • Bất biến (immutable): object không sửa được — "thay đổi một file" thực chất là tạo object mới rồi tạo tree/commit mới trỏ tới nó. Lịch sử vì thế là một chuỗi snapshot bất biến, không phải một file bị ghi đè.

Đánh đổi cần cân nhắc

SHA-1 và va chạm. Git dùng SHA-1 (đang chuyển dần sang SHA-256). SHA-1 về lý thuyết có thể va chạm, nhưng Git thêm phòng vệ (collision detection) và trong thực tế nguy cơ cực nhỏ với mã nguồn. Đừng lo va chạm SHA cho công việc thường ngày; nhưng biết Git đang di trú sang SHA-256.

Snapshot, không phải diff — đánh đổi chỗ và tốc độ. Lưu snapshot mỗi commit nghe như tốn chỗ, nhưng nhờ dedup (blob không đổi được chia sẻ) và packfile (nén delta sau), Git rất tiết kiệm. Đổi lại, git show/git diff phải tính diff lúc chạy — nhanh cho file thường, chậm hơn cho file khổng lồ.

Đừng lưu file nhị phân lớn vào Git. Vì mỗi phiên bản một file lớn tạo một blob mới toàn phần (nội dung khác = SHA khác = object mới), Git phình nhanh với binary hay đổi. Dùng Git LFS cho tài nguyên lớn — đó là hệ quả trực tiếp của mô hình content-addressed.

Ba ý mang về

  1. Git là key-value store nội dung địa chỉ hoá: khoá = SHA của nội dung, không phải tên file — đo thật cùng nội dung cho cùng SHA, và ta tự tính ra đúng khoá bằng sha1("blob <len>\0<nội dung>").
  2. Ba loại object trỏ nhau thành cây: commit → tree → blob; tên file sống ở tree, nội dung ở blob, và commit là snapshot toàn bộ cây (không phải diff — diff được tính lúc chạy).
  3. Content-addressing là nền của mọi thứ: cho toàn vẹn dữ liệu (đổi 1 byte = đổi SHA), dedup tự nhiên, và tính bất biến — mọi tính năng Git (branch, merge, history) đều xây trên ba loại object này.

Nguồn

Phần sau ta mổ xẻ ba vùng của Git — working directory, index (staging) và repository — và làm rõ index thực chất là gì, thứ mà git add thao tác vào.