碼雲 Gitee:高併發大存儲下的架構演進之路

碼雲 Gitee 自 2013 年推出以來,每年的數據量都是倍增的,截止到 2021 年 3 月份,Gitee 上已經有了 600 萬 + 的開發者,超 1500 萬的倉庫,成爲了國內首屈一指的研發協作平臺。在數據日益增長的過程中,Gitee 的架構也是經過了數個迭代,才能支撐起目前的數據量級。我曾在不少的大會上分享過 Gitee 的架構,也和很多有類似場景的同學一起討論過,偶然被問起有沒有專門的文章來介紹 Gitee 架構的,所以難得假期有時間,將此主題整理成文,以供大家參閱。

作爲國內發展最快的代碼託管平臺,Gitee 每天數據都在飛速的增長中,而且隨着 DevOps 概念的普及,持續構建也給平臺帶來更多的請求和更大的併發量,每天需要處理上千萬的 Git 操作,Gitee 架構也是在這個過程中逐步迭代發展起來的,回望 Gitee 架構的發展,主要分爲 5 個階段:

接下來就分享下 Gitee 整個架構的演進史。

單機架構

Gitee 上線於 2013 年 5 月份,上線之初就是一個單純的單體 Rails 應用,所有的請求都是通過這個 Rails 應用進行負載的。

除了把 Mysql 和 Redis 單獨一臺機器進行部署之外,跟絕大多數 Web 應用不一樣的是 Gitee 需要存儲大量的 Git 倉庫,無論是 Web 讀寫倉庫還是通過 Git 的方式操作倉庫,都是需要應用直接操作服務器上的裸倉庫的。這種單體架構在訪問量不大的時候還算可以,比如團隊或者企業內部使用,但是如果把他作爲一個公有云的 SaaS 服務提供出去的話,隨着訪問量和使用量的增長,壓力也會越來越明顯,主要就是以下兩個:

  1. 存儲空間的壓力

  2. 計算資源的壓力

由於開源中國社區的影響力,Gitee 在剛上線之處就湧入了大部分用戶,完全不需要擔心種子用戶的來源。相反,隨着社區用戶越來越多的使用,首先遭遇的問題就是存儲的壓力,由於當時使用的是阿里雲的雲主機,最大的磁盤只能選擇 2T,雖然後面通過一些渠道實現了擴容,但是雲主機後的物理機器也只是一個 1U 的機器,最多隻能有 4 塊硬盤,所以當存儲達到接近 8T 之後,除了外掛存儲設備,沒有什麼更好的直接擴容的方式了。

而且隨着使用量的增加,每到工作日的高峯期,比如早上 9 點左右,下午 5 點左右,是推拉代碼的高峯期,機器的 IO 幾乎是滿負載的,所以每到這個時候整個系統都會非常緩慢,所以系統擴容的事情刻不容緩。經過討論,團隊決定選擇使用分佈式存儲系統 Ceph,在經過了一系列不算特別嚴謹的「驗證」後(這也是後面出問題的根本原因),我們就採購機器開始進行系統的擴容了。

分佈式存儲架構

Ceph 是一個分佈式文件系統,它的主要目標是設計成基於 POSIX 的沒有單點故障的分佈式文件系統,能夠輕鬆的擴展到數 PB 級別的容量,所以當時的想法是藉助於 Ceph 的橫向擴容能力以及高可靠性,實現對存儲系統的擴容,並且在存儲系統上層提供多組無狀態的應用,使這些應用共享 Ceph 存儲,從而進一步實現了計算資源擴容的目的。

於是在 2014 年 7 月份的時候我們採購了一批機器,開始進行系統的搭建和驗證,然後挑選了一個週末開始進行系統的遷移與上線。遷移完成後的功能驗證一切正常,但是到了工作日,隨着訪問量的增加,一切開始往不好的方向發展了,整個系統開始變得非常緩慢,經過排查,發現系統的瓶頸在 Ceph 的 IO 上,於是緊急調用了一臺 ISCSI 存儲設備,將數據進行遷移進行壓力的分擔。本以爲一切穩定了下來,但是更可怕的事情發生了,Ceph RBD 設備突然間被卸載,所有的倉庫數據都沒了,瞬間整個羣和社區都炸開了鍋,經過 14 個小時的分析和研究,終於把設備重新掛載上,然後全速將數據遷往 ISCSI 存儲設備,才逐步平息了這場風波。

後來經過研究,才發現分佈式存儲系統並不適合用在 Git 這種海量小文件的場景下,因爲 Git 每一次的操作都需要遍歷大量的引用和對象,導致每一次操作整體耗時非常多,Github 之前發過一篇博客,也有提到分佈式存儲系統不適用於 Git 這種場景。而且在塊設備被卸載掉的時候,我們花費了長達 14 個小時的時間去進行恢復,這也是對工具沒有一個深入瞭解就去貿然使用的後果。經過這次血與淚的教訓,我們更加謹慎,更加細心的去做後續所有的調整。

NFS 架構

不過,存儲壓力和計算壓力依舊在,是迫在眉睫需要解決的問題,怎麼辦呢?於是爲了臨時解決問題,我們採用了相對原始的方案,也就是 2014 年 Gitlab 官方提供的方案

這個方案主要就是使用 NFS 來進行磁盤的共享,在上游搭建多臺應用實例來實現計算資源的擴展,但是由於存儲都是走網絡,必然會帶來性能的損耗,而且在實際應用的過程中,由於 Git 操作的場景比較複雜,會帶來一系列的問題

內網帶寬瓶頸

因爲存儲都是經過 NFS 進行掛載的,如果有比較大的比如超過 1G 的倉庫,它在執行 Clone 的時候將會消耗大量的內網帶寬,一般情況下我們的服務器的網口都是 1Gbps 的,所以很容易就會把網卡佔滿,佔滿導致的情況就是其它倉庫的操作速度被拖慢,進而導致大量的請求阻塞。這還不是最嚴重的,最嚴重的情況是內部服務網口被佔滿,導致 Mysql、Redis 等服務嚴重丟包,整個系統會非常緩慢,這種情況當時的解決方式就是把核心服務的調用走其它網口來解決,但是 NFS 網口的問題仍然沒法解決。

NFS 性能問題導致雪崩效應

這個就比較好理解了,如果某臺 NFS 存儲機器的 IO 性能過慢,同時所有的應用機器都有這個存儲機器的讀寫請求,那整個系統就會出問題,所以這個架構下的系統是非常脆弱的,經不起考驗。

NFS 緩衝文件導致刪除不徹底

這個問題是非常頭疼的問題,問題的原因是因爲爲了提升文件的讀寫性能,開啓了 NFS 內存緩存,所以會出現有些機器刪除了 NFS 存儲上的一些文件,但是在另外的機器上還存在於內存中,導致應用的一些邏輯判定出問題。

舉個例子,Git 在推送的過程中會產生.lock文件,爲的是防止在分支推送的過程中其它客戶端同時推送造成的問題,所以如果我們往master分支推送代碼的時候,服務端會產生master.lock文件,這樣其它客戶端就沒有辦法同時往master分支上推送代碼了。在推送完代碼後,Git 會自動的清除掉master.lock文件,但由於上面我們說的原因,有一些情況下我們在一臺應用機處理完推送請求後,明明已經刪除掉這個master.lock文件了,但是在另外一臺應用機器的內存裏還存在,就會導致無法推送。解決這個問題的方法就是關閉 NFS 內存級別的緩存,但是性能就會受損,還真是難以抉擇,好在出現這種問題的情況極少,所以爲了性能,只能忍受了。

維護性差

還是那句老話,由於歷史原因,應用的存儲目錄結構是固定的,所以我們不得不通過軟連接的方式對整個目錄進行擴容,而擴容的前提是要把 NFS 存儲的設備掛載在目錄呀,所以當時整個系統每個應用機器的掛載情況是非常複雜的

哇,看到這樣的目錄結構,運維要哭了,維護起來極其困難,如此下去,失控是早晚的事。

自研分片架構

NFS 這樣的方式可以抵擋一陣子,但是並不是長久之計,所以必須尋求改變,在架構上做改進。理想的方式當然是 Github 那種分片架構,通過 RPC 的方式將應用和倉庫調用拆離開來,這樣無論是擴展和維護都會比較方便

但是這種改造需要對應用進行改造,成本大,週期長,而且鑑於當時的情況,基本沒有太多的研發資源投入在架構上,那怎麼辦呢?當時在做這個架構討論的時候,我們有一位前端同事(暱稱:一隻大熊貓)提了一個想法,既然應用無法拆離,那爲什麼不再網上一層做分片路由呢?

題外話:團隊內部的「提問」是非常有必要的,而且激發了團隊討論的氛圍,我們能夠更好的做一些有價值的東西,所以每一個團隊成員,尤其是作爲一個開發者,永遠不要怕說,你的一個小小的想法,對於團隊可能是一次非常長遠的影響。比如這位熊貓先生的一句話,就直接決定了後續 Gitee 架構的發展方向,有空希望能夠再一起喫竹子 ;D

於是,第一版本的架構應運而生,我們不改變應用原有的結構,並允許應用是有狀態的,也就是應用與倉庫捆綁,一組應用對應一批倉庫,只要能夠在請求上進行辨識,並將其分發到對應的應用上進行處理即可。

從業務角度來講,Gitee 上的請求分爲 3 類:

  1. http(s) 請求,瀏覽倉庫以及 Git 的 http(s) 方式操作代碼

  2. SSH 請求,Git 的 SSH 方式操作代碼

  3. SVN 請求,Gitee 特性,使用 SVN 的方式操作 Git 倉庫

所以我們只需要對這三類請求進行分片路由,從請求中截取倉庫信息,根據倉庫信息找到對應的機器,然後進行請求的轉發即可。由此我們開發了 3 個組件,分別爲這三種請求做路由代理

Miracle http(s) 動態分發代理

組件基於 Nginx 進行二次開發,主要的功能就是通過對 URL 進行截取,獲取到倉庫的命名空間,然後根據這個命名空間進行 Proxy。比如上圖中我們請求了https://gitee.com/zoker/taskover這個倉庫,Miracle 或通過 URL 得知這個請求是請求zoker的倉庫,所以 Miracle 會先去路由 Redis 查找User.zoker的路由,如果不存在則去數據庫進行查找,並在路由 Redis 進行緩存,用來提升獲取路由 IP 地址的速度。拿到 IP 之後,Miracle 就會動態的將這個請求 Proxy 對應的後端 App1 上,那麼用戶就會正確的看到這個倉庫的內容。

對於路由的分發一定是要保證準確的,如果User.zoker取到的是一個錯誤的 IP,那麼用戶看到的現象就是空倉庫,這不是我們所期望的。另外,對於非倉庫的請求,也就是跟倉庫資源無關的請求,比如登陸,動態等,將會隨機分發到任一臺後端機器,因爲與倉庫無關,所以任意一臺後端機器均可處理。

SSH & SVN 動態分發代理

SSHD 組件主要是用來對 Git 的 SSH 請求進行分發代理,使用 LibSSH 進行二開;SVNSBZ 是針對 SVN 請求的動態分發代理。兩者實現的邏輯與 Miracle 類似,這裏不再贅述。

遺留問題

這種架構上線後,無論是從架構負載上,還是從運維維護成本上,都有了極大的改進。但是架構的演進總是無盡頭的,沒有萬金油,當前的架構還是存在一些問題:

因爲是以用戶或者組織爲原子單位進行分片,所以如果一個用戶下的倉庫過多,體積過大,可能一臺機器也處理不完,雖然我們在應用上限制了單個用戶可創建的倉庫數量以及體積,但是這種場景必定會出現,所以需要提前考慮。而且如果單倉庫訪問量過大,比如某些熱門的開源項目,極端情況下一臺機器也可能無法承受住這些請求,依舊是有缺陷的。

此外,Git 請求涉及到鑑權,所有的鑑權還是走的 GiteeWeb 的接口,並且 Git 的 https 操作依舊由 GiteeWeb 處理,並沒有像 SSH 那樣有單獨的組件進行處理,所以耦合性還是太強。

基於以上的一些問題,我們進一步對架構進行了改進,主要做了以下改動:

以倉庫分片使路由的原子單位更小,更容易進行管理和擴容,倉庫路由主要是以所屬空間/倉庫地址爲鍵,類似於zoker/taskover這種鍵進行路由

把 Git 的 http(s) 操作拆離出來的主要目的就是爲了不讓它影響到 Web 的訪問,因爲 Git 的操作是非常耗時的,場景不一樣,放在一起容易出現影響。而鑑權相關的 Api 的獨立也是爲了減少 GiteeWeb 的壓力,因爲推拉這種操作是非常非常多的,所以 Api 的訪問也會非常大,把它跟常規的用戶 Web 請求混在一起也是非常容易相互影響的。

在做完這些拆離之後,GiteeWeb 的穩定性提升了不少,由於 Api 和 Git 操作帶來的不穩定下降了 95% 左右。整個架構組件的構成類似於這樣

遺留問題

雖然提升了系統整體的穩定性,但是我們還是需要考慮一些極端的情況,比如如果單倉庫過大怎麼辦?單倉庫訪問量過大怎麼辦?好在系統能夠對單倉庫的容量進行限制,但是如果是一個非常熱非常火的倉庫呢?如果出現那種突然間大併發的訪問,該如何適應呢?

Rime 讀寫分離架構

Gitee 作爲國內最大的研發協作平臺,也作爲首屈一指的代碼託管平臺,衆多的開源項目在 Gitee 上建立了生態,其中不乏熱度非常高的倉庫,並且在高校、培訓機構、黑客馬拉松等場景也是作爲代碼託管平臺的首選,經常都可以遇到大併發的訪問。但是目前架構主要的問題是機器的備份都是冷備,沒有辦法有效的利用起來,並且單倉請求負載過大的問題也沒有解決。

爲什麼要做 Rime 架構?

自從華爲入駐 Gitee 之後,我們纔開始真正的重視這個問題。2020 年開始,華爲陸續在 Gitee 平臺上開源了 MindSpore、openEuler 等框架,單倉庫的壓力才逐漸顯現出來,爲了迎接 2020 年 9 月份舉世矚目的鴻蒙操作系統開源,我們在 2020 上半年繼續優化了我們的架構,使其能夠多機負載同一個倉庫的 IO 操作,這就是我們現在的 Rime 讀寫分離架構。

實現原理

想要實現機器的多讀的效果,就必須考慮到倉庫同步一致性的問題。試想,如果一個請求被分發到一臺備機,剛好主機又剛推送過代碼,那麼用戶在網頁上看到的倉庫將會是推送前的,這就是一個非常嚴重的問題,那麼該如何保證用戶訪問備機也是最新的代碼呢?或者說如何保證同步的及時性?這裏我們採用的如下的邏輯來進行保證

  1. 寫操作寫往主機

  2. 由主機主動發起同步到備機

  3. 主動維護同步狀態,根據同步狀態決定路由分發

如上圖所示,我們把倉庫的操作分爲讀和寫兩種,一般情況下,讀可以均等分發到各個的備機,這樣一來如果我們有一臺主機,兩臺備機,那麼在不考慮其它因素的情況下,理論上倉庫的讀取能力是增加了 3 倍的。但是考慮到倉庫會有寫的情況,那就會涉及到備機的同步,剛剛我們也說過,如果同步不及時,就會導致訪問到了老的代碼,這顯然是一個極大的缺陷。

爲了解決這個問題,我們利用 Git 的鉤子,在倉庫被寫入之後,同步觸發一個同步的隊列,這個隊列的主要任務有如下幾個:

  1. 同步倉庫到備機

  2. 驗證同步後的倉庫的一致性

  3. 管理變更同步狀態

當一個倉庫有推送之後,會由 Git 鉤子觸發一個同步任務,這個任務會主動的將增量同步到配置的備機,在同步完成後,會進行引用的一致性校驗,這個一致性校驗使用的是blake3哈希算法,通過對refs/中的內容進行編碼,來確認同步後的倉庫是否版本完全一致。

對於狀態管理,當觸發任務之後,會第一之間將兩臺備機的這個倉庫狀態設置爲未同步,我們的分發組件對於讀操作,只會分發到主機或者設置爲已同步狀態的備機,當同步完成並且完成一致性校驗之後,會將相關備機的同步狀態設置爲已同步,那麼讀操作就又會分發到備機上來了。但是如果同步失敗,比如上圖中同步到 App1bakA 的是成功的,那麼讀操作是可以正常的分發到備機的,但是 App1bakB 卻是失敗的,那麼讀操作就不會分發到未同步的機器,避免訪問上出現不一致的問題。

架構成果

通過對架構的讀寫分離的改造,系統對於單倉庫訪問過大的這種情況也能夠輕鬆應對了。2020 年 9 月 10 號,華爲鴻蒙操作系統正式在 Gitee 上開源,這個備受矚目的項目一經開放就給 Gitee 帶來了巨大的流量以及大量的倉庫下載操作,由於前期工作準備充分,並且讀寫分離架構極大提升了單倉庫負載的性能,所以算是完美的爲鴻蒙操作系統成功的保駕護航了。

後續優化

可能有細心的同學已經想到了,如果一個倉庫不同的在寫,並且同時伴隨着巨大的訪問量,那麼是不是就變成了單機器要去處理這些所有的請求?答案是 Yes,但是這種場景正常情況下是沒有的,一般情況下寫操作的頻率是遠遠低於讀操作的,如果真的出現了這種情況,只能說明被攻擊了,那麼我們在組件上也進行了單倉庫最大併發的限制,這也是我們維護 Gitee 以來得出的合理的限制條件,完全不會影響到正常用戶的使用。

但是架構的優化是無止境的,對於上面提到的情況,我們依舊是需要進行改良的,目前主要的做法主要是提交的時候同步更新,備機同步成功或者部分備機同步成功纔算本次推送成功,這種方式缺點是會加長用戶推送的時間,但是能夠很好的解決主機單讀的問題。目前的架構是多讀單寫,如果後面這個領域內出現了一些頻繁寫入的場景,可以考慮變更爲多讀多寫,做好狀態和衝突的維護即可。

未來展望

目前的架構最大的問題就是應用和倉庫的操作未拆離,這對於架構的擴展性是極爲不利的,所以目前或者後續我們正在做的就是對服務進行拆離和其他方面的優化:

  1. 倉庫的操作拆離,單獨以 RPC 的方式進行調用

  2. 應用的前後端分離

  3. 隊列、通知等服務的拆離

  4. 熱點倉庫的自動按需擴容

  5. 根據機器的指標進行新倉庫的分配

  6. ...

最後

Gitee 自 2013 年上線以來,直到 2017 年自研架構上線才真正解決了內憂外患,「內」是因爲架構無法撐起訪問量導致的各種不穩定,「外」是外部的一些 DDOS、CC 攻擊等難以招架,好在架構這項內功修煉得當,這些一直以來的問題才能夠輕鬆自如的應對。

有句老話說得好,脫離了一切場景談技術的行爲都是耍流氓,架構亦如是,脫離了背景去談架構是毫無意義的,很多時候我們做了非常多的工作,可能只是能夠解決當前或者未來幾年的問題,但是我們需要高瞻遠矚,對後續產品的發展、數據的增長、功能的增強做預估,這樣才能更好的改變架構來適應這個高速發展的領域,進而更好的去服務企業和賦能開發者。

轉載請保留出處:微信公衆號「Zoker 隨筆」(zokersay)

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/pisavMAF2u1-vxRMsL8V5A