Sản phẩm
Tại sao việc lưu trữ Git ở quy mô lớn lại là một thách thức?
(giờ Việt Nam)
Tóm tắt AI
Thiết kế phân tán của Git khiến việc lưu trữ quy mô lớn gặp khó khăn do packfile trở thành nút thắt về hiệu năng. Bài viết phân tích các giải pháp kỹ thuật và lý do tại sao việc tối ưu hóa lưu trữ đối tượng vẫn chưa thể giải quyết triệt để vấn đề tốc độ clone.
Bản dịch AI

Lưu trữ các Git repository ở quy mô lớn là một cơn ác mộng. Khi Linus Torvalds thiết kế phiên bản đầu tiên của "trình quản lý thông tin từ địa ngục" (đó thực chất là khẩu hiệu của Git, bạn có thể tra cứu), ông ấy đã nghĩ đến một trường hợp sử dụng rất cụ thể: chính nhu cầu của bản thân. Ông muốn thay thế BitKeeper, hệ thống kiểm soát phiên bản phân tán đang được sử dụng để phát triển Linux Kernel. Tất nhiên, sự thay thế này cũng phải có tính phân tán. Kernel là một dự án phần mềm khác thường; nó cực kỳ phi tập trung, với nhiều người bảo trì khác nhau cho các hệ thống con khác nhau. Một hệ thống kiểm soát phiên bản phân tán là sự lựa chọn tự nhiên cho quy trình làm việc này.
Hai mươi năm sau, Git đã trở thành tiêu chuẩn công nghiệp, nhưng sự thật là bản chất phân tán của nó lại là một trở ngại hơn là một lợi thế. Các dự án phần mềm mã nguồn mở thông thường không vận hành theo quy trình phi tập trung. Các công ty trung bình chắc chắn cũng không làm vậy. Họ tận dụng nhiều ưu điểm của mô hình phân tán (như khả năng làm việc ngoại tuyến, trì hoãn lệnh push, v.v.) nhưng họ lại phụ thuộc rất nhiều vào một máy chủ lưu trữ tập trung. Và hóa ra, việc lưu trữ một Git repository lại là một việc cực kỳ khó khăn.
Điều gì khiến Git trở nên khó khăn?
Thách thức trong việc lưu trữ các Git repository ở quy mô lớn nằm ngay trong thiết kế của chính Git: một hệ thống kiểm soát phiên bản phân tán có nghĩa là tất cả các bản sao của một repository đều giống hệt nhau. Không có gì đặc biệt về repository trên máy chủ Git mà không áp dụng cho repository trên máy tính xách tay của lập trình viên. Mặc dù thoạt nhìn có vẻ như điều này giúp việc lưu trữ các Git repository trở nên đơn giản (chỉ cần đặt một HTTP daemon phía trước bản sao repository trên ổ đĩa là bạn đã có một máy chủ Git!), nhưng có rất nhiều thách thức khó khăn về khả năng mở rộng và độ tin cậy khiến mọi thứ hoàn toàn ngược lại.
Trong một Git repository thông thường, mã nguồn và siêu dữ liệu (tệp tin, commit, tree) của bạn được nén và lưu trữ trong các packfile — một định dạng tuần tự hóa nhị phân đơn giản, thuận tiện để xử lý trên máy cục bộ nhưng không lý tưởng để quản lý ở quy mô lớn trên máy chủ. Packfile là khối xây dựng cơ bản của lưu trữ Git và mạng Git. Khi bạn push hoặc fetch dữ liệu từ một repository, nó được truyền tải dưới dạng packfile.
Đây là cách Git hoạt động theo thiết kế, nhưng cũng hợp lý khi nghĩ rằng không nhất thiết phải như vậy. Suy cho cùng, bạn không kiểm soát được Git client (ít nhất là không thể làm vậy mà không gây khó chịu cho người dùng và tạo ra nhiều rắc rối), nhưng trong phạm vi máy chủ của riêng mình, bạn có thể làm bất cứ điều gì bạn muốn. Không có gì bắt buộc bạn phải sử dụng packfile — Linus sẽ không đến kiểm tra đâu. Hạn chế duy nhất là bạn cần phải gửi và nhận packfile qua mạng cho tất cả các thao tác Git.
Qua nhiều năm, các công ty cố gắng lưu trữ Git repository ở quy mô lớn đã nhận thấy rằng thiết kế dựa trên packfile này là một hạn chế lớn đối với cả tính sẵn sàng và khả năng mở rộng. Packfile là các tệp nhị phân lớn phải tồn tại trên hệ thống tệp để Git có thể truy cập. Cách tiếp cận đơn giản là đặt một máy chủ HTTP phía trước repository trên ổ đĩa có giới hạn rất thấp. Lý tưởng nhất, bạn muốn repository tồn tại trên nhiều ổ đĩa và nhiều máy tính (điều này cho phép bạn chạy nhiều thao tác Git song song và giữ cho repository của bạn luôn sẵn sàng khi một máy chủ gặp sự cố). Nhưng làm thế nào để thực hiện điều đó?
Có ba cách tiếp cận chính để đạt được điều này, theo thứ tự độ phức tạp tăng dần: phân tán hệ thống tệp, phân tán các packfile, hoặc phân tán chính Git.
Git không dùng packfile
Git là một kho lưu trữ dữ liệu dựa trên nội dung (content-addressable). Tất cả các đối tượng trong một Git repository (blob, tree, commit, v.v.) đều được truy xuất bằng mã SHA-1 của nội dung đó. Đây là điều trực quan rất phù hợp với kho lưu trữ khóa-giá trị phân tán (khóa là SHA-1; giá trị là đối tượng thực tế), và có thể cung cấp một cách sạch sẽ để mở rộng quy mô lưu trữ của một repository. Nhưng thực tế điều này không hiệu quả.
Vấn đề là ở đây: cấu trúc thực tế của một Git repository là một đồ thị có hướng không chu trình (viết tắt là DAG). Bạn có thể tra cứu bất kỳ đối tượng nào thông qua mã SHA của nó, nhưng để thực hiện ngay cả thao tác đơn giản nhất trong repo, bạn thực sự phải duyệt qua DAG từng bước một.
[Bảng mô tả cấu trúc DAG]
Nếu bạn muốn thực hiện một thao tác như liệt kê các thay đổi gần đây trong một repository, bạn phải xử lý các commit của nó. Khi bạn xử lý một commit, bạn nhận được một con trỏ đến gốc của cây (tree) của nó. Từ cây đó, bạn nhận được các con trỏ đến từng tệp và từng cây con. Từ commit gốc, bạn nhận được một con trỏ đến cha của nó (commit đứng trước nó trong lịch sử). Quan trọng là, tại mỗi bước của quá trình duyệt này, bạn không biết giá trị của con trỏ tiếp theo cho đến khi bạn lấy được con trỏ trước đó. Nếu mỗi lần lấy dữ liệu đều yêu cầu một lượt truy vấn (round trip) đến kho lưu trữ phân tán, mọi thứ sẽ trở nên rất tốn kém và chậm chạp.
Cách tiếp cận phân tán Git ở cấp độ đối tượng này đã từng được thử nghiệm nhiều lần trước đây và thường thất bại ở quy mô lớn. Triển khai hứa hẹn nhất đã được thực hiện bởi người cố vấn cũ của tôi, Shawn Pearce, khi ông làm việc trong nhóm hệ thống kiểm soát phiên bản tại Google. Cách tiếp cận của ông là lưu trữ các đối tượng trong một bảng băm phân tán (DHT). Điều này chỉ có thể thực hiện được nhờ JGit, một bản triển khai Git tùy chỉnh bằng Java. Giống như bất kỳ thư viện Java cổ điển nào, JGit cung cấp đủ các giao diện, factory và factory giao diện để trừu tượng hóa tất cả các chi tiết của một Git repository thông thường, bao gồm cả việc thay thế các packfile trên ổ đĩa bằng một DHT. Mặc dù hệ thống hoạt động và kết quả đủ tốt cho các thao tác Git thông thường, nhưng những hạn chế của giao thức Git (vốn yêu cầu packfile phải được gửi qua mạng bất kể bạn lưu trữ dữ liệu trên máy chủ như thế nào) đã khiến hiệu suất git clone kém đến mức phải loại bỏ hoàn toàn thiết kế này.
GitHub và các hệ thống tệp
Vài năm sau khi Git bắt đầu thoát khỏi "bong bóng" Linux Kernel, một startup đầy nhiệt huyết đã ra đời tại San Francisco. GitHub được thành lập vào năm 2008 như một nền tảng lập trình xã hội với khẩu hiệu rất tiên tri: "Lưu trữ Git repository: không còn là nỗi đau đầu nữa." Tôi không đùa đâu, hãy tra cứu mà xem. Ngay từ năm 2008, đã có một sự đồng thuận rộng rãi rằng bất chấp (hoặc có lẽ vì) thiết kế phân tán của Git, bạn thực sự cần một cách tập trung để lưu trữ các Git repository nhằm làm cho chúng thân thiện với người dùng, và việc thực hiện điều này rất đau đớn. GitHub đã quyết tâm thay đổi điều đó.
Nền tảng của họ bắt đầu như (và phần lớn vẫn là) một ứng dụng Rails nguyên khối (monolith). Những phiên bản đầu tiên chạy trên một máy chủ duy nhất, dù khá mạnh, với một máy chủ Ruby và các bản sao của repository nằm trên ổ đĩa bên cạnh. Việc mở rộng quy mô một ứng dụng Rails rất dễ dàng: chỉ cần triển khai thêm các bản sao của nó. Nhưng trong trường hợp cụ thể này, vì có liên quan đến Git, họ nhanh chóng gặp phải câu hỏi lặp đi lặp lại mà chúng ta đang cố gắng giải quyết ở đây: Nếu ứng dụng Rails cần truy cập các Git repository trên ổ đĩa, làm thế nào để bạn triển khai thêm các bản sao của chúng?
Là một nhóm những người kỹ sư hệ thống tiết kiệm và khác biệt, các kỹ sư đời đầu tại GitHub đã thử cách tiếp cận đơn giản nhất có thể để giải quyết các vấn đề về quy mô. Suy nghĩ của họ là nếu tập trung vào việc phân tán hệ thống tệp (thay vì packfile hoặc chính Git), họ có thể giữ nguyên ứng dụng Rails và dành thời gian để phát hành thêm các tính năng cho cơ sở người dùng ngày càng tăng, thay vì làm những thứ kỳ quặc với Git. Rất thực dụng. Nhưng nó không hiệu quả.
Nhóm đã thử nhiều cách tiếp cận cho hệ thống tệp phân tán đối với dữ liệu Git: cách rõ ràng nhất, sử dụng NFS để lưu trữ tất cả các repository trên một máy chủ tập trung, đã nhanh chóng bị loại bỏ. Bản triển khai mặc định của Git đưa ra rất nhiều giả định về ngữ nghĩa hệ thống tệp (khóa, ghi đè, đọc, đồng bộ...) để đảm bảo hiệu suất ổn định trên hệ thống tệp cục bộ của một máy tính xách tay chậm chạp, nhưng lại không chú ý đến cách chúng hoạt động trên hệ thống tệp mạng. Nó chậm và đầy lỗi.
Các nỗ lực tiếp theo được thực hiện với những công nghệ (thành thật mà nói, khi nhìn lại, thật kinh khủng) sao chép hệ thống tệp ở cấp độ khối. Một lần triển khai ngắn hạn với GFS. Một lần triển khai dài hạn hơn dựa trên DRBD. Tất cả đều đi vào ngõ cụt. Chúng rất tệ để vận hành hàng ngày và không bù đắp được bằng hiệu suất tốt. Tất cả đều quy về thiết kế của các packfile trên ổ đĩa.
Chúng ta đã thấy cách các cấu trúc dữ liệu dạng đồ thị của Git khiến các lượt truy vấn (round-trip) trở nên đắt đỏ một cách vô lý. Thật không may, một nguyên tắc tương tự cũng áp dụng cho dữ liệu cơ bản trên ổ đĩa. Không có mối tương quan nào giữa bố cục của các đối tượng trong DAG và cách chúng được đặt trong một packfile. Phép toán heuristic đơn giản được sử dụng khi tạo packfile là giảm thiểu kích thước của chúng; các đối tượng được đặt ngẫu nhiên trong suốt packfile, chúng được nén lại, và quan trọng là chúng hiếm khi được lưu trữ toàn bộ. Hầu hết các đối tượng được lưu trữ dưới dạng delta (phần khác biệt) dựa trên một đối tượng khác trong cùng packfile. Việc đọc một đối tượng riêng lẻ, sau khi đi theo nhiều bước nhảy logic trong cấu trúc dữ liệu đồ thị, cũng bao gồm việc đi theo các bước nhảy vật lý trong định dạng trên ổ đĩa.
[Bảng mô tả cấu trúc dữ liệu]
Kiểu duyệt ngẫu nhiên trên hàng gigabyte dữ liệu này, vốn phải xảy ra cho mỗi thao tác Git thực hiện trên một repository, hoàn toàn không tương thích với hệ thống tệp mạng (dù nó sao chép ở cấp độ tệp hay cấp độ khối). Cách duy nhất để nó hoạt động mà không bị chậm như rùa bò là bạn có thể lưu trữ toàn bộ tệp cục bộ. Nhưng với hàng trăm nghìn repository trong cùng một hệ thống tệp, việc lưu trữ đệm (caching) không phải là một lựa chọn.
Cuối cùng, các kỹ sư hệ thống tại GitHub đã chấp nhận thực tế và từ bỏ việc phân tán hệ thống tệp. Họ bắt đầu phát triển một hệ thống RPC để các repository có thể nằm trên các máy chủ tệp chuyên dụng, và cập nhật ứng dụng Rails để thực hiện tất cả các thao tác từ xa. Điều này cung cấp một phần đáng kể khả năng mở rộng theo chiều ngang, nhưng không khắc phục được tính sẵn sàng, cũng như hiệu suất cho các repository bận rộn nhất. Suy cho cùng, mỗi repository vẫn chỉ được lưu trữ trên một máy duy nhất.
Spokes và tính nhất quán
Spokes ban đầu được phát triển tại GitHub vào khoảng năm 2013, và kể từ đó nó đã trở thành một tiêu chuẩn công nghiệp. Hầu hết các dịch vụ lưu trữ Git đều sử dụng một biến thể của cách tiếp cận Spokes (sao chép ở cấp độ ứng dụng cho các Git repository) trong kiến trúc của họ. Lý do chính khiến Spokes hoạt động tốt trong nhiều năm là nó đã đưa ra ba lựa chọn cơ bản mà theo thời gian, đã được chứng minh là tối ưu:
Do các mô hình đọc ngẫu nhiên trên các packfile mà chúng ta vừa thảo luận, việc lưu trữ các Git repository thuần túy trên ổ đĩa NVMe về cơ bản là một yêu cầu để đảm bảo tất cả các thao tác Git cơ bản vẫn nhanh chóng. Chúng cũng giúp các bản clone hiệu quả vì bạn không phải chuyển đổi dữ liệu sang định dạng mà Git client mong đợi. Chúng cũng cho phép bạn tập trung vào việc xây dựng sản phẩm trên nền tảng Git, thay vì tự duy trì một bản fork của Git có thể hoạt động trên các repository kỳ quặc của bạn.
Việc giữ cho tất cả các bản sao dữ liệu nhất quán và đồng bộ cũng cực kỳ quan trọng. Đây là điều bạn sẽ nhận ra theo cách khó khăn nhất, nhưng Git client thực sự không hoạt động tốt với tính nhất quán cuối cùng (eventual consistency). Nếu Git client cục bộ của bạn push một commit và sau đó không thể đọc được nó ngay lập tức sau một lệnh fetch, đó là tin xấu. Git thấy điều đó rất khó hiểu. Nếu bạn chạy quy trình CI của mình trên một trăm runner và ba trong số đó không tìm thấy commit mà chúng cần kiểm tra sau khi clone repository, đó là tin xấu. Đó cũng là trải nghiệm người dùng rất tệ.
Làm việc với một cái nhìn nhất quán cuối cùng về một Git repository có rất nhiều rủi ro, dù là trên client hay ở backend. Do đó, Spokes phải trả một cái giá rất cao về độ phức tạp để đảm bảo hệ thống luôn hoàn toàn nhất quán. Hãy xem chính xác điều này có nghĩa là gì.
Spokes là một hệ thống phân tán dựa trên sự đồng thuận. Nó hoạt động bằng cách lưu trữ nhiều bản sao của Git repository trên các máy chủ khác nhau. Bất cứ khi nào bạn push dữ liệu mới, một bộ điều phối (orchestrator) sẽ phân tán lệnh push của bạn để mọi bản sao của repository đều nhận được một bản sao. Việc "phân tán" này được đồng bộ hóa với một thuật toán đồng thuận cổ điển gọi là 3PC (three-phase commit - cam kết ba giai đoạn) để một lệnh push chỉ được chấp nhận nếu đa số các node xác nhận nó.
1 · BỎ PHIẾU 2 · TIỀN CAM KẾT 3 · THỰC HIỆN CAM KẾT
Trước khi có thể nói thêm về cách Spokes sử dụng 3PC, chúng ta cần hiểu cách một lệnh Git push hoạt động. Một lệnh Git push có hai thành phần: một packfile và một giao dịch tham chiếu (reference transaction). Packfile, thứ mà chúng ta đã thảo luận, chứa các đối tượng bạn đang push vào repository (blob, tree và commit với các thay đổi của bạn). Giao dịch là thứ thực sự công bố các thay đổi của bạn vào repository bằng cách cập nhật một hoặc nhiều tham chiếu (ví dụ: nhánh bạn đang làm việc) để trỏ đến các commit bạn vừa push.
Sự tách biệt này rất thuận tiện ở đây, vì một commit được push sẽ không hiển thị ("có thể truy cập" trong thuật ngữ của Git) cho đến khi tham chiếu trỏ đến nó đã được cập nhật. Điều này có nghĩa là chúng ta có thể thực hiện sự đồng thuận cho các lệnh push bằng cách phân tán các packfile đến tất cả các máy chủ cùng một lúc (chúng ta không cần đồng bộ hóa ở đây) và sau đó thực hiện cam kết ba giai đoạn với giao dịch tham chiếu, vốn nhỏ hơn và nhanh hơn nhiều để đồng bộ hóa so với packfile. Bản thân Git có hỗ trợ chuẩn bị các giao dịch tham chiếu: nó có thể khóa tham chiếu, xác minh rằng giá trị hiện tại là đúng như mong đợi, và sau đó giữ khóa cho đến khi nhận được lệnh commit hoặc hủy bỏ cho giao dịch đó.
Tốc độ phát lại 0.010x
Bản sao 5
Độ trễ một chiều 20ms
Với thiết kế này, chúng ta đảm bảo rằng mọi lệnh push đều được đồng bộ hóa hoàn toàn trên tất cả các bản sao. Các thao tác đọc (fetch, clone) sau đó có thể được định tuyến an toàn đến bất kỳ bản sao nào, vì mọi bản sao đều luôn được cập nhật.
Đây về cơ bản là cách Spokes hoạt động, và nó đã hoạt động khá tốt trong 13 năm qua. Tất nhiên, Spokes không hoàn hảo — không có hệ thống nào là hoàn hảo cả. Vào năm 2026, cách mọi người sử dụng Git repository đã thay đổi đáng kể, và chúng ta đã học được nhiều bài học quan trọng về việc xây dựng các hệ thống phân tán trong suốt quá trình đó. Thời gian và kinh nghiệm đã cho thấy lựa chọn nào của Spokes là tối ưu và lựa chọn nào thì không.
Một lỗ hổng đã trở nên nghiêm trọng là khả năng mở rộng theo chiều ngang bị hạn chế của 3PC. Khi Spokes lần đầu tiên được phát hành, ba bản sao cho mỗi repository là con số lý tưởng. Bạn có thể phục vụ repository trung bình của mình từ ba bản sao với công suất dự phòng, cùng với đủ sự dư thừa để tiếp tục chấp nhận các lệnh push ngay cả khi một máy bị hỏng.
Bài viết được AI dịch và tổng hợp tự động từ Cursor Blog. Liên kết bài gốc ở phía trên. Dữ liệu đồng bộ qua API công khai được ghi nguồn tại AI HOT (canonical) ↗. AIHOT.vn luôn dẫn nguồn đầy đủ — nếu bạn thấy điểm cần chỉnh sửa, hãy gửi ý kiến tại trang phản hồi.