本文作者:李博文 - CODING 后端開發工程師
前言
六七年前,我機緣巧合進入了代碼托管行業,做過基於 Git 支持 SVN 客戶端接入、Git 代碼托管平台分布式、Git 代碼托管讀寫分離、Git 代碼托管高可用等工作,所幸學到了一些知識,積累了一些經驗,本次分享我的一點經驗之談,希望對即將進入或者已在代碼托管行業的朋友有所幫助。
Git 的發展歷史
版本控制系統的發展歷史
版本控制系統歷史悠久,最早的開源的版本控制系統可以追溯到幾乎與 C 語言同時誕生的 Source Code Control System (SCCS),作者Marc J. Rochkind來自著名的貝爾實驗室,他於 1973 年發布了 SCCS 的初始版本。SCCS 的壽命悠久,直到 2007 年再沒有人維護而終結。SCCS 本質上是一種 Local Only 版本控制系統,如今網絡快速發展,無法跟上時代的腳步只能消亡,同類型的 RCS 雖然維護至今,也鮮有人問津。
1986 年誕生的 CVS 是一款真正的自由軟件,使用 GPL 協議發布。一個有趣的事實是:CVS 是 RCS 的前端,也就是說 CVS 將 RCS 從 Local Only 變成了 Client-Server 版本控制系統。隨着 2000 年 Apache Subversion 誕生,CVS 的市場快速萎縮,到了 2008 年 CVS 不再維護,集中式版本控制系統漸漸也只剩 Subversion 在維護了。
最早的分布式版本控制系統是 1992 年誕生的 Sun WorkShop TeamWare,但它並沒有發展的很好。從 2000 年到 2007 年,分布式版本控制系統如雨后春筍一樣冒了出來,2005 年誕生的 Git 和 Mercurial 幸運流傳開來,時至今日,Git 終於在版本控制領域獨占鰲頭。
Git 的發展史
2005 年,開發 BitKeeper 的商業公司結束與 Linux 內核開源社區的合作關系,他們收回了 Linux 內核社區免費使用 BitKeeper 的權利。Linus Torvalds 花了十天時間編寫了 Git 的第一個版本,Git 的故事由此展開。
Git 原本只能在 Linux 上運行,隨着開源社區的參與,逐漸能在各個平台上運行。在 Windows 上,最初有兩個方案,一個是讓 Git 在 Cygwin 的環境下編譯,Cygwin 是 Windows 上的 POSIX 兼容層,但缺陷是需要帶一大堆 DLL。另一個方案是 msysgit,基於 MSYS 運行時,MYSY 是更小的 POSIX 兼容環境。到了 2015 年,msysgit 不再維護,主要開發者基於 MSYS2 環境推出了 Git for Windows,而 MSYS2 的核心運行時基於 Cygwin 進行了定制。值得一提的是,在 Git for Windows 中,Git 命令並不是基於 MSYS2 運行時,而是原生的 Windows 程序,到今天我們已經可以使用 Visual C++ 編譯 Git 源碼了,Git for Windows 的維護者 Johannes Schindelin 加入微軟后,在 Windows 上使用 Git 的體驗也越來越好。
2008 年 11 月 Shawn O. Pearce 寫下了 libgit2 的第一個提交;2009 年 9 月,Shawn 寫下了 JGit 的第一個提交。Libgit2/jgit 被代碼托管平台,Git 客戶端廣泛使用,比如 GitHub 使用 libgit2 的 Ruby 綁定 rugged 提供頁面讀寫存儲庫能力。遺憾的是 Shawn 已經離開這個世界兩年多了。
再來回顧 Git 的一些大事件:2008 年 GitHub 誕生,是最成功的代碼托管平台,幾乎以一己之力帶來了 Git 的繁榮;2008 年 BitBucket 誕生,最初 BitBucket 還支持 Mercurial,到了 2020 年已不再支持;2011 年 GitLab 誕生,而國內的 Gitee 也是基於 GitLab 發展而來的;2014 年 CODING 成立,國內國外代碼托管平台百花齊放;2018 年,微軟花費 75 億美元收購 GitHub,大家才猛然發現,基於 Git 的代碼托管平台已經有了這樣大的價值。
Git 是一個充滿活力的版本控制系統,每一年,Git 的開發者們都在將他們新的知識、經驗實踐到 Git 中。2018 年 5 月,在谷歌工作的 Git 開發者們發布了 Git Wire Protocol,這解決了 Git 協議中最低效的部分;到了 2020 年 10 月,Git 實驗性地支持 SHA256 哈希算法,在 SHA1 被破解幾年后,我們終於可以在 Git 中嘗試淘汰 SHA1 了。
Git 的發展必然會擠占其他版本控制系統份額,隨着 Git 越來越流行,更多的項目也從其他的版本控制系統遷移到 Git 上來:
- 編譯器基礎設施 LLVM 從 SVN 遷移到 Git
- FreeBSD 從 SVN 遷移到 Git
- GCC(仍處於遷移過程中)從 SVN 遷移到 Git
- Windows 源碼(已經遷移到 Git,使用 VFS for Git 技術)
- VIM 遷移到 GitHub
- OpenJDK 從 Mercurial 遷移到 Git
2016 年,Git 誕生11年之后,BitKeeper 宣布采用 Apache 2.0 許可協議開源,如果再回到 2005 年,BitKeeper 又會做出怎樣的抉擇呢?
Git 的存儲原理
對於代碼托管從業人員來說,只了解 Git 的使用並不足以參與代碼托管平台服務開發和架構優化等工作,所以了解 Git 的一些原理非常必要。
Git 的目錄結構
首先需要了解 Git 存儲庫的目錄結構,Git 存儲庫分為常規存儲庫和 Bare (裸)存儲庫,普通用戶從遠程克隆下來的存儲庫,或者本地初始化的存儲庫大多是常規存儲庫,這類存儲庫和特定的工作區相關聯;另一類是沒有工作區的存儲庫,就是裸存儲庫,在代碼托管平台的服務器上,存儲庫幾乎都是以裸存儲庫的方式存儲的。對於常規存儲庫而言,其存儲庫真正的路徑是工作區根目錄下的 .git
文件夾,或者 .git
文件指向的目錄,后者通常用於 Git 子模塊。
知道了 Git 存儲庫的位置,就可以查看存儲庫的目錄結構,下面是一個查看存儲庫的截圖。
不同的目錄具備不同的作用,大致如下:
路徑 | 屬性 | 作用 | 備注 |
---|---|---|---|
HEAD | R |
存儲當前檢出的引用或者提交 ID | 在遠程服務器上用於展示默認分支 |
config | R |
存儲庫配置 | 存儲庫配置優先級高於用戶配置,用戶配置優先級高於系統配置 |
branches | D |
deprecated |
|
description | R |
depracated |
|
hooks | D |
Git 鈎子目錄,包括服務端鈎子和客戶端鈎子 | 當設置了 core.hooksPath 時,則會從設置的鈎子目錄查找鈎子 |
info | D |
存儲庫信息 | dump 協議依賴,但目前 dump 協議已無人問津 |
objects | D |
存儲庫對象存儲目錄 | |
refs | D |
存儲庫引用存儲目錄 | |
packed-refs | R |
存儲庫打包引用存儲文件 | 該文件可能不存在,運行 git pack-refs 或者 git gc 后出現 |
在這些目錄或者文件中,最重要的是 objects
和 refs
,只需要兩個目錄的數據就可以重建存儲庫了。在 objects
目錄下,Git 對象可能以松散對象也可能以打包對象的形式存儲:
路徑 | 描述 |
---|---|
objects/[0-9a-f][0-9a-f] |
松散對象存儲目錄,最多有 256 個這樣的子目錄 |
objects/pack |
打包對象目錄,除了打包對象,還有打包對象索引,多包索引等 |
objects/info |
存儲存儲庫擴展信息 |
objects/info/packs |
啞協議依賴 |
objects/info/alternates |
存儲庫對象借用技術 |
objects/info/http-alternates |
存儲庫對象借用,用於 HTTP fetch |
Git 在實現其復雜功能的時候還會創建一些其他目錄,更詳細的細節可以查閱:Git Repository Layout。
Git 對象的存儲
Git 的對象可以按照松散對象的格式存儲,也可以按照打包對象的格式存儲,用戶將文件納入版本控制時,Git 會將文件的類型標記為 blob
,將文件長度和 \x00
以及文件內容合並在一起計算 SHA1 哈希值后,使用 Deflate 壓縮,存儲到存儲庫的 objects 目錄下,路徑匹配正則為 objects\/[0-9a-f]{2}\/[0-9a-f]{38}$
,當然如果使用 SHA256 則應該匹配 objects\/[0-9a-f]{2}\/[0-9a-f]{62}$
,松散對象的空間布局如下:
Git 使用的 Deflate 是 Phil Katz 為 PKZIP 創建的壓縮算法,也是使用最廣泛的壓縮算法之一,其變體 GZIP 也被廣泛用於 POSIX 文件壓縮和 HTTP 壓縮。Git 命令行,libgit2 目前依賴 zlib 提供 deflate 算法,jgit 則使用 Java 提供的 deflate 實現,Golang 則在 compress/zlib
包中提供 deflate
支持,但算法實現在 compress/flate
,嚴格來說 Git 使用的是 deflate 的 zlib 包裝,比如我們使用 zlib 創建 zip 壓縮包時會使用 -15
作為 WindowBits
,而在創建 GZIP 時會使用 31
作為 WindowBits
,在 Git 中,則會使用 15
作為 WindowBits
。
在 Git 中,除了有 blob
對象,還有 commit
,tag
,以及 tree
,commit
對象存儲了用戶的提交信息,tree
顧名思義,存儲的是目錄結構。下面是一個 commit 對象的內容:
tree d8329fc1cc938780ffdd9f94e0d364e0ea74f579
author Scott Chacon <schacon@gmail.com> 1243040974 -0700
committer Scott Chacon <schacon@gmail.com> 1243040974 -0700
First commit
下面是 tree 對象的內容:
100644 blob a906cb2a4a904a152e80877d4088654daad0c859 README
100644 blob 8f94139338f9404f26296befa88755fc2598c289 Rakefile
040000 tree 99f1a6d12cb4b6f19c8655fca46c3ecf317074e0 lib
解析松散對象非常容易,我們只需要使用能夠解析 zlib
的庫就可以完成這一操作,這里有一個例子可以參考 https://gist.github.com/fcharlie/2dde5a491d08dbc45c866cb370b9fa07。
想要了解更多的 Git 對象的細節可以參考: Git Internals - Git Objects。
站在文件系統的角度上看,數量巨大的小文件性能通常會急劇下降,而松散對象就是這樣的小文件,Git 的解決方案是引入了打包文件,打包文件就是將多個松散對象依次存儲到打包文件的存儲空間之中,相關的布局如下:
Pack 文件的路徑正則為 objects\/pack\/pack-[0-9a-f]{40}.pack$
,當存儲庫使用 SHA256 哈希算法時,相應的路徑正則為objects\/pack\/pack-[0-9a-f]{64}.pack$
,Pack 文件的魔數是 'P','A','C','K'
,隨后的 4 字節是版本信息,版本可以為 2,也可以為 3,后者是 SHA256 支持的前提。我們在讀取 Pack 文件版本的時候需要注意,Git 使用網絡字節序存儲數據,也就是常說的大端,目前 Windows 全部使用小端字節序,macOS/iOS 等也是這樣,Linux x86/AMD64 也是小端,ARM/ARM64 事實上也使用小端,使用大端的平台非常少。版本后緊接着是 4 字節的數字,用於表示這個包中有多少個 Git 對象,4 字節意味着單個 Pack 中最多只能有 232-1 個 Git 對象。接下來的事情就稍微復雜一些,Git 存儲對象時使用 3-bit
表示對象類型,(n-1)*7+4
bit 表示文件長度,這種機制主要是支持大於 4G 的文件和支持 OBJ_OFS_DELTA
,也就是說,盡管 Git 是基於快照的,但是在 pack 文件中,我們依然可以看到一些對象使用差異存儲,這樣的好處是節省空間,壞處就是查看對象復雜度上升,因此,Git 會傾向於將歷史久遠的用 OBJ_OFS_DELTA
存儲,以降低影響,不管怎么說,都是權衡利弊,保證存儲和讀取的平衡。最后是 20 字節的 checksum SHA1
,當然如果是 SHA-256 存儲庫,則需要使用 SHA-256 計算 checksum
。
上圖一目了然,如果沒有其他措施,我們要在 Pack 文件中查找某個對象是非常難的,所幸這個問題一開始就被重視了,在 Pack 文件的同級目錄下存在文件后綴名為 .idx
的文件,就是 Pack Index,其布局如下:
版本 1 的 Pack-Index 現在已經很難見到,原因很簡單,不支持 Pack 文件大於 4 GB,版本 2 格式非常有趣,魔數為 '\377','t','O','c'
,第二個 4 字節就是版本信息,隨后是 256 * 4
的扇區表,0~254
分別表示前綴從 0x00
~0xFE
的對象數量,而 fanout[255]
則表示所有對象的數量,隨后對象 ID 按字典排序到 sha listing
,緊接着是相應的 crc checksums
,然后是 packfile offsets
,packfile offsets
是 4 字節的,這並不能支持 Pack 大於 4 GB。而后續的 large packfile offsets
則支持了 Pack 大於 4 GB。當 4byte offset 最高位是 1 時說明需要從 large packfile offsets
讀取長度。
Pack Index 文件很好的解決了 Pack 文件的隨機讀取的問題,按照其特性,我們在查找 Git 對象時,使用二分法查找,最多 8 次就可以在找到對象在 Pack 中的偏移,進一步讀取文件。
但如果 Pack 文件數量特別多時,還是會遇到查找對象性能較多,微軟在將 Windows 源碼遷移到 Git 后也遇到了這個問題,后來在微軟工程師的努力下,multi-pack-index(MIDX)出現了,存在多個 Pack 文件時,MIDX 便可以加快 Git 對象的查找。
既然我們已經對 Git 的存儲有了個簡單的認識,那么要找到某個文件也不在話下,分支對應了一個提交,提交有一個 ID,我們可以在松散對象或者打包對象中找到該 ID,然后獲得提交的內容,找到 tree 后,按照路徑一級級往下找,找到路徑匹配的 blob,該 blob 解壓后的內容就是文件的原始內容,一個簡單的流程如下:
對於引用而言,通常存儲在 refs
目錄下,和松散對象一樣,這種機制可能存在性能問題,因此,在運行 git gc
后,引用會被打包到 packed-refs
文件中集中管理,為了加快引用的查詢,引用名會使用字典排序,Git 同樣會使用二分法查找在 packed-refs 中查找引用。盡管查找引用的速度非常快,但面對 Android 這樣引用數量巨大的項目,Git 依然會顯得心有余而力不足,這就需要設計一個好的方案解決其性能問題。
Git 存儲原理的運用
了解到 Git 的存儲原理后,我們可以基於其原理做一些有趣的事情,比如要快速找到存儲庫中存在哪些大文件,我們可以通過分析 Pack Index,將文件的偏移按照遞減的順序排列,依次相減就可以知道某一對象在 Pack 中占據的大致大小,這樣就可以實現大文件的檢測。這種機制要比從 Pack 中依次讀取文件大小高效的多,同時對於平台而言,盡管存在一些誤差,但這種方案卻是十分經濟有效的。
另外,在實現代碼托管平台存儲庫快照的功能時,可以通過研究存儲庫引用的存儲機制,利用引用名稱空間實現存儲庫的快照,相對於直接克隆快照的方案,該方案節省了非常大的存儲空間。
Git 的傳輸協議
對於現代版本控制系統而言,傳輸協議與代碼托管平台的關系更為密切,只要支持了該版本控制系統的傳輸協議才意味着平台支持這個版本控制系統,要支持 Git,代碼托管平台也就需要了解 Git 的傳輸協議。
傳輸協議的發展
和版本控制系統的不斷發展類似,Git 的傳輸協議也是在不斷發展以適應新的情況。談到 Git 傳輸協議,我們最常用的是智能協議,除了智能協議,Git 還有本地協議,啞協議(Dump Protocol),以及有線協議(Wire Protocol/v2 Protocol)。本地協議通常指通過文件系統路徑或者 file://
協議路徑訪問本機上的存儲庫的協議,該協議本質上是通過命令調用將其他目錄的存儲庫拷貝到指定目錄,這類協議的用處較少,其中有一個細節需要講清楚,基於文件系統路徑的克隆,也就是非 file://
協議克隆,會將源存儲庫的對象,這里通常是 .pack
文件通過硬鏈接的方式共享,這實際上是利用了 Git 對象的只讀特性,也就是只能刪除和新增而不能修改,另外,兩個目錄並不在同一個分區則不支持硬鏈接,也就不能使用硬鏈接共享對象。
啞協議旨在為服務端沒有 Git 服務時提供只讀的 Git Over HTTP 訪問支持,正因為不支持寫操作,目前幾乎所有的公共代碼托管平台均已經不在支持啞協議了。
既然啞協議不堪重任,那么也只能另起爐灶設計一個好的協議了,這就有了智能協議,但隨着 Git 被廣泛使用,智能協議也有一些先天性缺陷,於是就產生了有線傳輸協議。
智能傳輸協議
Git 目前主要支持的網絡協議有三種,分別是 http(s)://
,ssh://
,git://
無論哪種協議,拉取實質上都是 git-fetch-pack/git-upload-pack
的數據交換,推送都是 git-send-pack/git-receive-pack
的數據交換,在 2018 年以前,均是采用智能傳輸協議,我們可以使用 Wireshark 這樣的工具抓包分析其傳輸流程,也可以使用 GIT_CURL_VERBOSE=2
GIT_TRACE_PACKET=2
這樣設置環境變量后運行相關命令調試 Git,在 Windows 中可以使用我編寫的包管理器 baulk 中的命令運行器 baulk-exec
運行相關命令,如:
baulk-exec GIT_CURL_VERBOSE=1 GIT_TRACE_PACKET=2 git ls-remote https://github.com/baulk/baulk.git
分析協議的方法已經有了,我們就可以輕易的知道智能協議的流程,以 http(s)://
為例,我們把傳輸的第一個步驟叫做引用發現,客戶端根據存儲庫的 URL 使用 GET
請求到 /repo.git/info/refs?service=git-upload-pack
這樣的地址,服務端則以 --advertise-refs
--stateless-rpc
這樣的參數啟動 git-upload-pack
,該命令啟動后將存儲庫目前的 HEAD
commitID,存儲庫支持的 capabilities
,以及 HEAD
對應的 symref
以及所有的引用名及其 commitID 返回給客戶端,客戶端根據這些信息,以及本地的存儲庫已經存在的對象清點出需要的 want
和存在的 have
commitID,然后通過 POST /repo,git/git-upload-pack
發送給服務端,服務端通過執行 git-upload-pack --stateless-rpc /path/to/repo.git
將打包好的對象返回給客戶端,待客戶端清點好對象,傳輸就結束了,對於 git pull
請求還需要將更新的文件檢出到工作目錄。
這里需要注意,實施 Git Over HTTP 服務器時,Git 客戶端需要在 POST 請求響應最開始添加 001e# service=git-upload-pack\n0000
,另外我們還需要正確的設置 Content-Type
,服務端處理POST
請求時,請求體可能使用 gzip
編碼,需要解壓縮處理。
推送的傳輸協議流程類似,但服務變為 git-receive-pack
,相關的流程如下:
在推送時,Git 協議本身的權限驗證機制極其有限,一些分支權限控制等安全功能基本上只能通過鈎子實現,而鈎子的標准錯誤實際上也會被 Git 命令行捕獲作為響應返回給客戶端,如果客戶端的 Git 恰好運行在 Windows Terminal、Mintty、iTerm 等等終端中,那么我們就可以將一些信息以彩色的形式輸出給用戶,這些信息使用 ANSI 轉義的。
ssh://
協議和 git://
協議同樣支持智能傳輸協議,實現起來只需要把為客戶端連接和 git-upload-pack/git-receive-pack
的標准輸入和輸出建立數據交換的通道即可。在實施 Git Over SSH SSH 服務器時,像 GitLab 會直接使用 OpenSSH,但 OpenSSH 可定制性有限,在分布式 Git 平台上需要實現模擬的 git-upload-pack/git-receive-pack 這樣的命令,效率較低。像 GitHub 早期使用了 libssh 實現了 Git Over SSH 服務,BitBucket 使用了 Apache Mina SSHD,還有一些平台使用了 Golang crypto/ssh,無論采用什么樣的技術,都應該經過慎重考慮,是否契合平台的架構,維護成本是否合適等等。在實施 Git Over TCP (git://
) 服務器時,只需要解析第一個 pktline
數據包即可,git://
協議簡單,表達能力有限,沒有足夠的權限驗證,公有雲除了 GitHub 其他平台使用的較少,但我在設計讀寫分離和高可用時,會優先考慮使用 git://
協議作為內部傳輸協議以降低內部負載。
ssh://
協議和 git://
協議可以支持數據的多次往返,而 http(s)://
協議只能是 Request-->Response
這樣的一個來回,不同的來回實際上狀態已經丟失,所以需要指定為 State Less
也就是無狀態。
智能協議雖然非常簡單,但我們在 Git Over HTTP 上支持 shallow clone
時卻不得不注意一些細節,在協商 commit deepin
時,客戶端和服務端都在等待對方的響應,這時我們只能通過提前關閉服務端的標准輸入中斷一方的等待,這就是智能傳輸協議的大問題,HTTP 傳輸實現復雜,不支持擴展。另外隨着 VFS for Git 這樣技術的誕生,使得一個問題浮現在公眾面前:“巨型存儲庫如何優化克隆”。VFS for Git 重新設計了傳輸協議更顯得智能傳輸協議在這上面尤為不足。
有線傳輸協議
Google 開發者的思路是,通過一個特殊的環境變量開關控制協議的切換。從外表看,傳輸協議仍然是幾組命令的輸入輸出交換,但從內在看,新的傳輸協議更像是利用低級別的命令實現功能的擴展。我們依然可以使用上面的調試方法分析 Git 有線協議的傳輸流程,在新的協議中,服務端先返回了版本信息,支持的命令,過濾器,對象格式等等,客戶端再次發送請求需要使用 ls-refs
發現引用,然后是 fetch
命令(以下截圖中沒有這一操作)獲得數據。
實施 Git 有線傳輸協議非常簡單,只需要升級 Git 命令,檢測客戶端請求是否為 GIT_PROTOCOL=2
,然后以環境變量 GIT_PROTOCOL=2
啟動上述命令即可,在我們的博客《Git Wire 協議雜談》 中也有介紹。
Git Wire 協議是 Git 的一次大的改變,在協議中添加了命令、filter 等機制,有效解決了傳輸協議中最低效的部分,增強了可擴展性,比如我們使用部分克隆時,需要添加 blob filter,即我不需要我就可以不下載文件;支持 SHA256 時,告訴服務端,我需要 object-format=sha256
,這為 Git 增加了無限可能。目前 Git 的部分克隆,SHA256 存儲庫都依賴有線傳輸協議。
實際上集中式版本控制系統 SVN 早就利用子命令擴展了協議能力,SVN 協議使用 ABNF 描述協議,要比 Git 的有線協議解析起來復雜一些。
Git 數據的交換
了解了 Git 的存儲結構和傳輸協議后,再建立宏觀上的 Git 數據交換映像就容易得多,對 Git 的操作實際上是發生在三個區域,工作區是我們實質上修改,添加,刪除文件的地方,通過 git add/commit/checkout 等命令,我們就將工作區的文件納入版本管理了,通過 git push/fetch 等命令,就將本地存儲庫和遠程建立了關聯。這里需要注意,git pull 實際 上是 git fetch+ git checkout(沒有 merge 的情況下),大致如下圖:
大型 Git 代碼托管平台的關鍵問題
隨着平台規模的增長,代碼托管從業人員也會遇到一些問題難以解決,在我職業生涯中同樣如此,解決問題的過程是艱辛的,去年年底,我曾經寫過一篇文章:《性能,可擴展性和高可用 - 大型 Git 代碼托管平台的關鍵問題》,文章的內容與本節內容相似,這里帶領讀者重新回顧一下。
大型存儲庫的優化
目前國內 IT 行業版本控制系統都在往 Git 遷移,一些大型企業,軟件源碼歷史悠久,存儲的文件各種各樣,在遷移到 Git 時,體積巨大的存儲庫給代碼托管平台帶來了壓力,首當其沖的問題就是從其他版本控制系統遷移到 Git 耗時太長。
Git 在安裝了 SVN 的前提下,支持 git svn
命令訪問 SVN 倉庫,從 SVN 倉庫遷移到 Git 的邏輯很簡單,就是從 Rev0 開始,遞歸的創建 Git 提交,如果這個存儲庫歷史悠久,提交特別多,文件特別多,那么轉換耗時將非常長。網絡上也有一種優化方案,直接在 SVN 中央存儲庫,通過解析存儲庫元數據,直接在上面創建 Git 提交,這種方案的耗時可能是原本的數十分之一。KDE 團隊維護的 svn-all-fast-export aka svn2git 就是其中一款。
轉移到 Git 后,如果存儲庫包含很多的二進制文件,存儲庫體積巨大,那么用戶拉取的時間還是會很長,一種解決方案是將不同的數據分離,也就是將體積大的二進制文件,通過 Git 擴展 git lfs
追蹤,從源碼中排除,通過這種措施存儲庫的體積減小,平台的壓力降低,而這些大文件可以存儲到其他的設備上,比如對象存儲,利用 CDN 優化,就能提升用戶的體驗.實現 Git LFS 服務器可以參考我之前的博客《Git LFS 服務器實現雜談》。
如果存儲庫小文件特別多,這個時候 Git LFS 的作用反而沒有那么大了,Git LFS 並不存在打包機制,也沒有壓縮,如果大量文件使用 Git LFS 跟蹤,那么 HTTP 請求數會變得非常多,傳輸時間也會特別長。微軟在將 Windows 源碼遷移到 Git 做技術選型便遇到了問題,Windows 源碼數百 GB,引用數量數十萬,這些傳統方案和 Git LFS 完全不能解決。於是微軟的開發者推出了 VFS for Git 用來解決這個問題,簡單來說,VFS for Git 的手段是只獲得淺表 commit 以及相應的 tree 對象,然后在文件系統建立虛擬文件,也就是用戶空間文件系統 Filesystem in Userspace (FUSE) 創建占位符文件,但向這種文件發起 IO 操作時,驅動會觸發 VFS for Git 客戶端取請求遠程服務器,獲得這些文件,在 Windows 上 FUSE 使用了 NTFS 重解析點,其 TAG 為 IO_REPARSE_TAG_PROJFS
,微軟前員工 Saeed Noursalehi(現已加入 Facebook)曾寫過一些 VFS for Git 的文章,比如 《Git at Scale》以及《Git Virtual File System Design History》,大家有興趣可以看一下。VFS for Git 驚艷的架構也吸引了 GitHub 的注意,當時 GitHub 還未被 Microsoft 收購,GitHub 創建了 Linux projected filesystem library 項目試圖在 Linux 上創建類似 Windows 平台的 projfs,以支持 VFS for Git 在 Linux 上運行,但該項目一直沒有被完成。
VFS for Git 的設計是獨樹一幟的,也很難推廣開來,目前除了 Microsoft 的 Azure,其他平台幾乎都沒有支持,核心就是 Git 客戶端支持難度高。后來 Git 的一些開發者提議在 Git 中實現部分克隆,經過幾年的努力,終於支持部分克隆,該方案和 VFS for Git 類似,使用有線傳輸協議的 filter 機制,實現一個 blob filter
過濾掉 blob
,與 VFS for Git 存在差異的是,沒有 FUSE 加成,最終使用有限,是否能夠有其他手段提升部分克隆的實用性,還得 Git 貢獻者們進一步的努力了。
最近,Git 貢獻者還增加了 Packfile URIs 設計,該方案旨在將對象通過 CDN 存儲,然后客戶端根據返回的地址請求到合適的 CDN 下載存儲庫對象,該方案仍處於早期,還有許多細節要處理,最終能做到什么程度有待觀察。
代碼托管平台伸縮性
大型代碼托管平台面臨的另一個問題則是系統的伸縮性,在架構上具備良好的伸縮性則意味着平台能做到多大的規模,比如 Gitea/Gogs 這種傾向於單節點的開源代碼托管平台要做到大型分布式代碼托管平台就麻煩得多,而 GitLab 則更容易搭建分布式可擴展的代碼托管平台。
在討論伸縮性之前,我們要解釋一下分布式文件系統為什么不適合大型代碼托管平台。
- Git 的計算壓力並沒有隨着分布式文件系統的擴展性而分攤。
- 分布式文件系統很難解決 Git 小文件的問題,特別是小文件帶來的系統調用,IO 問題。
- 分布式文件系統反而會帶來平台內部網絡數據的消耗,比如文件的元數據,以及文件的數據。
- 國內外廠商的生產事故歷歷在目。
當了解到分布式文件系統不合適之后,我們也就只能采用笨辦法,分片,將存儲庫分布在不同的存儲節點,Git 命令也在這個節點上運行,這樣無論是計算還是 I/O 都能夠通過存儲節點的擴展實現擴容,這就是 Git 目前最主要的分布式解決方案。
通過這樣的方案實現平台的伸縮性時,還需要解決一些分布式環境常見的問題,比如存儲庫的分布,存儲庫隊列等等,當然這些都有可用的方案,在本文就不展開細說。
主從同步,讀寫分離和多寫高可用架構探討
無論是公共代碼托管平台還是私有化部署的代碼托管服務,當代平台發展到一定程度,高可用這個問題就會被反復提及,分布式系統的架構設計難度較高,與傳統的單機服務有很大的差別,而 Git 代碼托管平台分布式系統與普通的分布式系統有更大的差異,高可用的設計不僅要吸納主流的分布式系統的架構經驗,還需要迎合 Git 的特性,另外還需要考慮到架構的經濟性。
首先我們看一下分布式大型代碼托管平台的簡易架構(下圖的架構是精簡版本,與實際架構存在差距),從下圖我們可以看到,用戶的 Git 請求實際上並不是直接請求到存儲節點上的 Git 服務,而是通過代理服務轉發過去,這些代理服務通過路由模塊獲得存儲庫位於那個存儲節點,從架構上講,這些代理服務都可以做到無狀態,通過部署多個服務副本再在前端入口添加負載均衡健康檢查,可以很好地做到這些代理服務的高可用,但這個架構也意味着存儲節點上的存儲庫並不能支持高可用。
存儲庫要支持高可用,應該在不同的存儲節點上都存在副本,在一個副本所在的節點無法正常提供服務時,需要其他副本所在的節點能夠頂上來提供服務,這些副本要始終保持一致,如果不一致,在切換的時候就會出現數據紊亂,這顯然是不符合用戶期望的。高可用可分為主從同步高可用,以及讀寫分離高可用,還有同時多寫高可用(多寫高可用),設計一個簡單的主從同步高可用系統,我們首先需要保證存儲庫的一致性,這里可以通過 git hooks 觸發存儲庫實時同步,存儲庫副本分布在不同的節點,在用戶推送代碼后,被更新的存儲庫副本及時將數據通過內部傳輸協議同步到其他副本。早期 GitHub 使用 DRDB 實現同步,目前大多使用 Git 傳輸協議實現同步,我個人更偏好於實現自定義的 git://
提供存儲庫同步功能。
存儲庫實現了實時同步,還需要有一種機制保證存儲庫數據一致,GitHub 的方案是循環哈希校驗和,而我的方案是使用 BLAKE3 計算引用哈希,原理很簡單,就是將存儲庫的引用按字典排序計算哈希值,哈希值一致意味着兩個存儲庫的引用一致,引用一致存儲庫克隆獲得的數據也就是一致的,兩個存儲庫肯定一致。
這里主從同步高可用如果支持將讀取請求轉發到其他副本而不僅是主副本,那么這種情況就叫讀寫分離高可用(簡稱讀寫分離),讀寫分離的好處就是對於特別活躍的存儲庫能夠提供更高的並發。當然無論是看似簡單的主從同步,還是復雜的讀寫分離,內里考慮的細節並不少,環環相扣,需要對整個代碼托管架構有一個清晰的認識。
實施類似 Github Spokes (DGit is now Spokes) 一樣的多寫高可用要復雜一些,主要難點是要支持同時寫入到多個副本,要做到這一點需要實現一些約束性條件:
- 寫入到多個副本的前提是多個副本的數據是一致的,GitHub 使用了三階段提交協議先判斷是否可以寫入,寫入的前提就是服務正常,存儲庫一致。
- 存儲庫的引用更新應該是事務的,也就是說可以回滾事務,這樣在寫入到其中一個節點失敗后,其他的節點上實時回滾。這一點可以考慮使用原子更新引用,可以修改 git receive-pack 源碼增強實現該功能。
- 代碼托管平台常常使用 Git 鈎子實現一些功能,這些鈎子的操作是否等冪,也就是說,鈎子的執行結果在不同的副本上退出碼必須一致,如果不同副本中執行鈎子不做區別,我們要保證鈎子中請求 API 授權的結果一致,避免內部服務故障照成影響,執行 post-receive 鈎子產生動態或者觸發 WebHook 時需要進行消息去重,避免多次執行。當然還有一種方案就是只執行一次鈎子,然后使用協調機制將鈎子的結果廣播到其他副本,共同進退。
- 存儲庫在不一致,或者從停機中恢復后,多寫高可用依然需要考慮存儲庫的同步,以保證不同節點的一致性。
要設計好高可用,應該實現一套良好的故障檢測機制,合理的方案有多種,可以用專門的服務檢測磁盤是否可用,服務是否聯通,出現故障時標記不可用,恢復后直接標記為正常即可;還可以通過學習,將前端服務與存儲節點通信的錯誤采集分離,進行健康評估,在節點故障時將其下線。兩者都需要不斷的汲取經驗,故障的錯誤標記往往是災難性的,GitHub 就出現過這樣的事故,給其聲譽帶來了一定的影響。
無論是主從同步還是讀寫分離以及實時多寫架構,都需要給存儲庫創建多個副本,這就意味着存儲空間的消耗加倍,每個存儲庫有一個副本,存儲空間的消耗就要增加一倍,兩個副本就增加兩倍,所以在設計高可用系統的時候還需要考慮到經濟因素對架構的影響,這也是國內代碼托管行業高可用架構發展並不順利的原因之一。
多寫系統如果能修改 Git 源碼實現一些細節的優化,這在架構上有更好的設計余地,比如我們可以修改 Git 源碼支持主動非侵入數據流的原子更新,我們也可以在 receive-pack
中修改執行鈎子的邏輯,使其更符合讀寫系統的設計。而現實並不令人滿意,沒有足夠的人手能夠參與 Git 的研究,這阻礙了國內代碼托管行業的創新,很容易陷入只能苦苦追隨前人的困境。
思考
代碼托管早期有 SourceForge,我剛剛工作時,構建的 Clang On Windows
便是發布在 SourceForge 上分發的,現在已經好幾年沒登錄 SourceForge 了,Git 的發展不快不慢,但終歸是流行起來了,GitHub 把其他平台徹底碾壓,有點所向披靡的樣子。不過國內得益於政策環境,GitHub 想進來並不容易,國內也就有了另一番天地。但是做到 GitHub 那樣的規模並不容易,做到 GitHub 那樣的技術更不容易,羅馬不是一天建成的,這仍需要同行們的持續努力。