當前位置: 妍妍網 > 碼農

美團大規模KV儲存挑戰與架構實踐

2024-03-25碼農

KV 儲存作為美團一項重要的線上儲存服務,承載了線上服務每天萬億級的請求量,並且保持著 99.995% 的服務可用性。在 DataFunSummit 2023 數據基礎架構峰會上,我們分享了【美團大規模 KV 儲存挑戰與架構實踐】,本文為演講內容的整理。文章主要分為四個部份:第一部份介紹了美團 KV 儲存發展歷程;第二部份分享了記憶體 KV Squirrel 挑戰和架構實踐;第三部份闡述了持久化 KV Cellar 挑戰和架構實踐;最後一部份介紹了未來的發展規劃。希望這些內容能對大家有所幫助或啟發。

  • 1 美團 KV 儲存發展歷程

  • 2 大規模 KV 儲存的挑戰

  • 3 記憶體 KV Squirrel 挑戰和架構實踐

  • 3.1 Squirrel水平擴充套件的挑戰

  • 3.2 Gossip最佳化

  • 3.3 Squirrel 垂直擴充套件的挑戰

  • 3.4 forkless RDB

  • 3.5 工作多執行緒

  • 3.6 Squirrel可用性的挑戰

  • 3.7 兩機房容災

  • 3.8 跨地域容災

  • 3.9 雙向同步沖突自動解決

  • 4 持久化 KV Cellar 挑戰和架構實踐

  • 4.1 Cellar垂直擴充套件的挑戰

  • 4.2 Bulkload 數據匯入

  • 4.3 執行緒排程模型最佳化

  • 4.4 執行緒RTC模型改造

  • 4.5 記憶體引擎無鎖化

  • 4.6 Cellar可用性的挑戰

  • 4.7 雙向同步沖突自動解決

  • 5 發展規劃和業界趨勢

  • 1 美團 KV 儲存發展歷程

    上圖就是美團第一代的分布式 KV 儲存的架構,可能很多公司都經歷過這個階段。在客戶端內做一致性哈希,然後在後端部署上很多 Memcached 例項,這樣就實作了最基本的 KV 儲存分布式設計。但這樣的設計存在很明顯的問題:比如在宕機摘除節點會時遺失數據;此外,在緩存空間不夠需要擴容時,一致性哈希也會遺失一些數據,這樣會給業務的開發帶來很大的困擾。

    隨著 Redis 計畫的成熟,美團也引入了 Redis 來解決我們上面提到的問題,進而演進出來上圖這樣一個架構。可以看到,客戶端還是一樣,使用一致性哈希演算法,在伺服器端變成了 Redis 組成的主從結構。當任何一個節點宕機,我們可以透過 Redis 哨兵完成 failover,實作高可用。但有,還一個問題還是沒有解決,如果擴縮容的話,一致性哈希仍然會遺失數據。

    這時我們發現業界有一個比較成熟的開源 KV 儲存:也就是阿裏巴巴的 Tair 。2014年,我們把 Tair 引入到技術內部,去滿足業務 KV 儲存方面的需求。Tair 開源版本的架構主要是三部份:最下邊的是儲存節點,儲存節點會上報心跳到它的中心節點,中心節點內部設有兩個配置管理節點,會監控所有的儲存節點。如果有任何儲存節點宕機或者擴容之類的行為,它會做集群拓撲的重新構建。客戶端啟動的時候,它會直接從中心節點引入一個路由表,這個路由表簡單來說就是一個集群的數據分布圖,客戶端根據路由表直接去儲存節點讀寫。之前我們 KV 遇到的擴容丟數據問題,它也有數據遷移機制來保證數據的完整性。

    但是在使用的過程中,我們還遇到了一些其他問題,比如:它的中心節點雖然是主備高可用的,但它沒有分布式仲裁之類的機制,所以在網路分割的情況下,它是有可能發生「腦裂」的,這種情況也給我們的業務造成過比較大的影響。在容災擴容的時候,遇到過數據遷移影響業務可用性的問題。

    另外,我們之前用過 Redis ,業務會發現 Redis 的數據結構特別豐富,而 Tair 還不支持這些數據結構。雖然我們用 Tair 解決了一些問題,但是 Tair 同樣也無法完全滿足我們的業務需求。於是,我們認識到在美團這樣一個業務規模大、復雜度高的場景下,很難有開源系統能很好滿足我們的需求。所以,我們決定在已套用的開源系統之上進行自研。

    時值 2015 年, Redis 社群正式釋出了它的集群版本 Redis Cluster。所以,我們緊跟社群步伐,並結合內部需求做了很多自研功能,進而演進出本文要介紹的全記憶體、高吞吐、低延遲的 KV 儲存 Squirrel。另外,我們基於 Tair,加入了很多美團自研的功能,演進出本文要介紹的持久化、大容量、數據高可靠的 KV 儲存 Cellar 。

    Redis 社群一直都很活躍,所以,Squirrel 的叠代是自研和社群並重,自研功能設計上也會盡量與社群架構相容。Tair 開源版本已經多年沒有更新,所以,Cellar 的叠代完全靠自研。後續內容上大家也能看到,因為這方面的不同,Cellar 和 Squirrel 在解決同樣問題時可能會選取不同的方案。

    這兩個儲存其實都是 KV 儲存領域的解決方案。實際套用上,如果業務的數據量小,對延遲敏感,建議用 Squirrel ;如果數據量大,對延遲不是特別敏感,我們建議用成本更低的 Cellar 。

    2 大規模 KV 儲存的挑戰大規模

    KV 儲存的業務挑戰主要有兩點:

    一個是擴充套件性。隨著業務規模持續變大,業務會要求使用容量更大的集群。這個容量包括兩方面,一方面是數據量,還有一方面是呼叫量。擴充套件容量,最常見的方法就是把集群水平擴充套件到更多的節點,但是當集群節點數達到一定規模後,再想擴充套件新節點也會遇到很多困難,這是擴充套件性上的第一個挑戰。

    還有一個問題是有些業務場景的呼叫容量是無法隨著集群水平擴充套件而擴充套件的。比如,很多業務會使用 mget 進行批次讀取。但隨著集群節點數的增加,由於「木桶效應」,整個 mget 請求的長尾延遲會越來越高,進而導致服務的請求超時率持續上升。等集群達到一定規模之後,長尾延遲造成的可用性降低就超出業務的承受能力了。所以在水平擴充套件之外,我們還需要解決好節點垂直擴充套件上的挑戰,來支持這種批次操作的業務場景。

    另一個是可用性。隨著集群規模變大,要保證可用性維持在與小規模集群同等的水平,其實是很困難的。但業務服務卻不會因為集群規模變大而能接受可用性有所降低。所以,美團的挑戰是如何保證集群可用性不會隨著規模的變大而有所降低。

    3 記憶體 KV Squirrel 挑戰和架構實踐

    上圖是美團的 Squirrel 架構。中間部份跟 Redis 社群集群是一致的。它有主從的結構,Redis 例項之間透過 Gossip 協定去通訊。我們在右邊添加了一個集群排程平台,包含排程服務、擴縮容服務和高可用服務等,它會去管理整個集群,把管理結果作為後設資料更新到 ZooKeeper。我們的客戶端會訂閱 ZooKeeper 上的後設資料變更,即時獲取到集群的拓撲狀態,直接對 Redis 集群節點進行讀寫操作。

    | 3.1 Squirrel水平擴充套件的挑戰

    但是基於 Redis Cluster 架構的水平擴充套件,會有如下問題:

    一個是 Gossip 的訊息通訊量是節點數的平方,隨著集群節點數的增加,Gossip 通訊的訊息量會急劇膨脹。比如,我們實測對於一個 900 節點的集群,Gossip 訊息的 CPU 消耗會高達12%,遠高於小集群的 Gossip 資源消耗,這樣會造成極大的資源浪費。

    除了資源的浪費以外,Gossip 訊息過多,也會更多搶占使用者請求處理執行緒的資源,進而會導致使用者請求經常被 Gossip 訊息的處理所阻塞,再導致使用者請求產生更多的超時,影響服務可用性。

    | 3.2 Gossip最佳化

    為了解決上述的擴充套件性問題,我們對社群的 Gossip 方案進行了最佳化。首先針對 Gossip 傳輸的訊息,我們透過 Merkle Tree 對其做了一個摘要,把集群 Gossip 通訊的數據量減少了90%以上。伺服端節點僅需要對比 Hash 值即可判斷後設資料是否有更新,對於存在更新的情況也能快速判斷出更新的部份,並僅對此部份後設資料進行獲取、更新,大幅降低了 Gossip 訊息處理的資源消耗。同時,我們還增加了一個周期性的後設資料全量同步功能,來解決可能因 Hash 沖突導致後設資料無法更新的問題。

    針對上述提到的 Gossip 訊息處理影響業務請求的問題,我們把 Gossip 訊息處理功能剝離到一個單獨的心跳執行緒裏,並且由心跳執行緒來更新集群拓撲的後設資料。對於處理使用者請求的工作執行緒,僅需要對後設資料進行讀操作,可以做到無鎖讀。這樣的話,Gossip 請求處理就對業務請求完全沒有影響了。

    | 3.3 Squirrel 垂直擴充套件的挑戰

    對基於 Redis 研發的 Squirrel 來說,垂直擴充套件會存在如下問題:

    首先是數據容量的問題。對一個記憶體儲存來說,節點容量過大的話,很容易影響服務的可用性。例如,在主從節點要做數據同步時,Redis 節點需要透過 fork 產生子行程來生成全量數據的 RDB 快照。當一個 8GB 的節點做 fork 呼叫時,會由於頁表項過多,造成行程出現 500 毫秒的阻塞。對於平均耗時只有幾毫秒的 KV 請求來說,這 500 毫秒的阻塞會造成大量的超時。

    還有就是處理量的擴充套件問題。雖然我們可以透過加從庫去擴充套件集群的讀能力上限,但主庫的寫處理能力卻還是無力擴充套件的。而且,受限於主庫的處理能力和機器頻寬限制,加從庫來擴充套件讀能力也是有上限的。

    | 3.4 forkless RDB

    針對上述節點過大,fork 生成 RDB 會導致可用性降低的問題。我們實作了 forkless RDB 方案,這是一個不基於 fork,且不會中斷服務的生成數據快照 RDB 的方案。

    如上圖所示,forkless RDB 的生成期間,它首先會停止哈希表的 rehash 過程,避免數據在哈希表之間的搬遷影響快照的一致性。然後,它會從頭開始對整個哈希表的 key 做叠代,每叠代一個 key 就會把它 dump 一份出來放到復制佇列裏邊。在叠代 key 的同時,它會對叠代的位置記錄一個遊標。

    如果在叠代哈希表的過程中,裏面的 KV 有變更的話,在這個遊標之前的 KV 變更,也會把它放到復制佇列裏邊,確保已經復制的 KV 能夠持續獲得後續的變更。如圖所示,RDB 遊標在 key 3,它會把之前已經叠代過的 key 1 更新、key 2 刪除操作也插入到復制佇列裏邊。在遊標之後的 key,因為還沒有做數據復制,所以等後續叠代到這個 key 時,把其最新值 dump 到復制佇列就好。透過這樣的方式,就實作了一個不需要 fork 就能獲得一個一致性數據快照 RDB 的過程。

    這個方案的優點很明顯,生成 RDB 的過程不會阻塞服務請求處理,並且因為是即時的發送一個個 KV 數據,所以就不需要等 RDB 生成好就可以向從庫復制數據了,大幅提升了數據同步的速度。但因為全量數據叠代、復制是在工作執行緒去做的,而不是在子行程內。所以,該方案會占用一部份工作執行緒的資源。另外,因為是以 KV 為粒度做復制的,所以,如果哈希表裏面有大 KV 的話,可能會因為工作執行緒復制大 KV 耗時過長,造成使用者請求等待耗時的上升。

    | 3.5 工作多執行緒

    對於處理量的擴充套件,社群有一個 IO 多執行緒的解決方案。但這個 IO 多執行緒只是把網路收發部份做了多執行緒處理,所以,其擴充套件能力是比較有限的。比如 4個 IO 執行緒下,它只能把整體的吞吐提升一倍,就到極限了。而且因為此時工作執行緒已經到瓶頸了,再往上去加 IO 執行緒,不僅無法提升效能,反而會消耗更多的 CPU 資源。對此,我們的解決方案是工作多執行緒,也就是說把請求處理的過程也多執行緒化。

    如上圖所示,在工作多執行緒方案下,每個執行緒都會去處理請求,並且每個執行緒會完成從收包到請求處理,然後到發包的整個過程,是一個 Run-to-Completion 執行緒模型。相比 IO 多執行緒,它會減少很多執行緒切換,節省很多的 CPU 資源。同時對於請求處理的過程,我們也透過細致的梳理,盡量縮小了臨界區的範圍,以保證大部份的請求處理過程是在臨界區之外的,來提升處理並行度。

    如果一個工作執行緒需要加鎖的話,它會先 try lock。如果加鎖成功就繼續執行了,但如果加鎖失敗的話,這個工作執行緒也不會阻塞等鎖。它會先去註冊一個管道的通知訊息,然後就繼續處理網路的收發包,還有非臨界區的請求了。等到鎖被釋放的時候,這個工作執行緒會透過 epoll 獲得管道裏面的鎖釋放通知,然後去拿到這把鎖。這個時候它就可以去處理臨界區的請求操作了。

    這樣的話,在整個加鎖、解鎖的過程中,工作執行緒沒有任何阻塞,仍然可以繼續做網路收發、非臨界區請求的處理,獲得最大限度的處理能力。另外,對於新建 socket、數據復制等工作,跟工作執行緒的耦合很低,我們將其放到了單獨的執行緒去執行,以盡量降低工作執行緒的負載。

    透過實測,工作多執行緒方案的吞吐比社群 IO 多執行緒提升了 70%,相對於社群單執行緒提升 3 倍多。

    | 3.6 Squirrel可用性的挑戰

    基於 Redis Cluster 的大規模集群可用性挑戰主要是維持機房容災部署很困難。如上圖所示,由於 Redis Cluster 是去中心化的架構,所以部署上要求至少是三機房分布,以此來保證任何一個機房掛掉的時候,剩余的兩個機房仍然能有過半的節點來選出新的主節點。比如一個上千節點的集群要擴容的話,可能需要幾百個分布在三個機房的節點,一時之間其實很難湊齊這麽多機房的資源。而當業務大促容量需求很急時,我們有時候只能犧牲機房容災能力來滿足業務的容量需求。

    還有在成本方面,對於一些數據可靠性要求較低的業務,只需要兩副本冗余就夠了,極端情況下丟一點數據也是可以接受的。但受限於容災要求,這些業務也只能使用三機房三副本部署,從成本角度考量很不劃算。

    | 3.7 兩機房容災

    受 Google Spanner 的見證者節點啟發,我們在 Squirrel 集群也引入了見證者節點角色。 同 Spanner 一樣,Squirrel 見證者節點也不會儲存數據,所以,它無法作為正常的主從庫提供請求處理能力,也不能發起選主投票。 但見證者節點可以在集群選主時參與投票,幫助存活的機房節點完成過半選主過程。

    見證者節點還可以設定權重,這樣只需要一個或幾個高權重見證者節點,就能滿足一個大規模集群的容災部署需求了。由於見證者節點不儲存數據,且節點數很少,雖然集群還是三機房部署,但實際幾乎只需要兩機房的資源就能滿足機房容災部署需求了,這樣就大幅降低了集群維持容災部署的難度,從而節省大量的機器成本。

    | 3.8 跨地域容災

    Squirrel 跨地域容災的架構如上圖所示,它透過一個集群間同步服務在兩個不同地域的集群之間做數據同步。 這個同步服務首先偽裝為上遊集群節點的 slave 把它的 RDB 和增量 log 拉取過來,然後再把拉取到的數據轉化成寫請求發到下遊的集群,從而實作了一個集群間的數據同步。 透過這樣的架構,我們解決了服務的跨地域容災問題。 並且,透過在集群間搭建正反兩個方向的兩個同步任務,就能實作集群間的雙向同步。 這樣的話,使用者服務就可以只在本地域寫,但同時能讀到兩個地域分別寫入的數據,解決了單向同步需要跨地域寫的問題。

    雙向同步有兩個經典問題需要解決:

    一個是迴圈復制問題。我們為每個 Squirrel 集群標記了不同的 cluster id,並且記錄了每個 KV 的初始寫入 cluster id,同步服務會過濾掉與目標集群 cluster id 相同的數據,以避免發生迴圈復制。

    還有一個是數據沖突問題。我們一開始是透過業務層面保證在每個地域寫不同的 Key 來解決的。但是在雙向同步的執行過程中,還是會有一些極端場景可能會出現兩個地域並行寫同一個 Key。比如像機房網路故障場景,業務會把故障機房的所有寫入都切到正常機房。

    但由於我們的集群間復制是異步的,可能故障機房有一些最新的 Key 變更還沒有復制到正常機房的集群。而如果在業務將寫切換到正常機房後,又寫入了相同 Key 的不同變更,就會產生兩個同步集群的數據沖突。在機房網路恢復之後,業務還是要把一部份流量切回到之前故障的集群上,恢復到跨地域容災的架構。

    但由於兩個集群可能已經有數據沖突了,所以,在業務切回之前,就需要對數據做沖突校驗和修復。但是對大數據量集群來說,數據校驗和修復的耗時可能會長達數天。在這樣長的時間內,只有一個單地域集群來支撐業務,無論是從容災還是容量的角度來看,都是有較大風險的。

    | 3.9 雙向同步沖突自動解決

    為了解決上述的雙向同步數據沖突問題,我們實作了一個基於數據寫入本地時間的 last write win 沖突自動解決功能。

    如上圖所示,在 T1 時刻 Key money 的值在 A、B 兩個集群都是 100。T2 時刻,money 的值在 A 集群更新成了 120。但是在 A 集群的新值還沒復制到 B 集群的時候,B 集群在 T3 時刻把 money 的值更新成了 130。這時候 A、B 集群會互相向對方復制各自寫入的新值,A 集群收到 B 集群的值 130 後,會發現 B 集群 money 的更新時間大於自己( T3 > T2 ),它就會更新自己的 money 值為 130;B 集群也會收到 A 集群復制過來的 money 值 120,但它會發現這個值的更新時間小於自己本地值的更新時間( T2 < T3 ),就會忽略這個復制請求。透過這樣一個基於更新時間的 last write win 策略,就可以達到最終一致性。

    上述方案看起來簡單,但是在復雜、大規模的業務場景下,還有很多問題要處理,所以,我們還做了以下的工作:

  • 保存最近更新的時間戳 :當發生時鐘回退時,我們會繼續使用自己保存的時間戳,避免使用本地回退的時間導致數據也跟著發生了回退。( PS:對於時鐘回退問題,我們調研過最新的 NTP 時鐘同步不會像以前一樣造成本地時鐘的回退或跳變,現在它透過把時鐘 tick 調快或調慢來完成類似的調整,所以,前述關於時鐘回退的解決方案在最新的 NTP 同步機制下就不是必要的了。不過,為了保證我們的服務在任何系統下都能正常執行,我們最終還是實作了這個功能。

  • 記錄寫入數據的集群 id :我們會為所有寫入的 Key 保存寫入的集群 id。當兩個值的更新時間相同時,我們會比較集群 id,如果也相同,我們就知道是同一個集群先後寫入但獲取到相同本地時間的數據,會允許其寫入;如果不同,我們僅會讓集群 id 更大的值寫入,來保證數據最終一致性。

  • 由復制操作改為復制變更後的數據 :像 INCR 類介面,A 集群的 money T1 時刻透過 INCRBY money 20 變成了 120,然後 B 集群 T2 時刻透過 INCRBY money 30 變成了 130。A 集群收到 B 集群的復制時,因為時間戳比自己的本地值大,它會執行 INCRBY money 30 變成 150;然後 B 集群收到 A 集群的復制時,因為時間戳比自己的本地值小,它會把這個復制請求給忽略掉,就造成了數據沖突。針對這個問題,我們將所有操作的數據復制都改成了復制操作後的數據,而不是這個操作本身,來解決類似 INCRBY 這種介面的數據沖突問題。

  • 保存最近刪除的 Key :像刪除類介面,A 集群 T2 時刻寫入了 money:120,然後 B 集群在 T3 時刻刪除了 money 這個 Key。A 集群收到 B 集群的復制時,由於其時間戳比本地值大,A 會把數據刪了;但 B 集群收到 A 集群的復制時,由於本地已經不存在 money 這個 Key 了,它就會把 money 當做一個新 Key 進行寫入,就造成了數據最終不一致。針對這個問題,我們透過保存最近一段時間刪除掉的 Key 及刪除時間戳,以便在刪除集群收到對端復制過來的舊 Key 時進行甄別。

  • 4 持久化 KV Cellar 挑戰和架構實踐

    上圖是我們最新的 Cellar 架構圖,它跟阿裏開源的 Tair 主要有兩個層面的不同。

    第一個是 OB,第二個是 ZooKeeper。我們的 OB 跟 ZooKeeper 的 Observer 是類似的作用,提供 Cellar 中心節點後設資料的查詢服務。它即時的與中心節點的 Master 同步最新的路由表,客戶端的路由表都是從 OB 去拿。這樣做的好處主要有兩點:第一,把大量的業務客戶端跟集群的大腦 Master 做了隔離,防止路由表請求影響集群的管理;第二,因為 OB 只提供路由表查詢服務,不參與集群的管理,所以它可以水平擴充套件,極大地提升了路由表的查詢能力。

    第二個是我們引入了 ZooKeeper 做分布式仲裁,解決了上述提到的 Master、Slave 在網路分割情況下的「腦裂」問題。並且透過把集群的後設資料儲存到 ZooKeeper,從而提升了後設資料的可靠性。

    | 4.1 Cellar垂直擴充套件的挑戰

    在 Cellar 架構下,不存在水平擴充套件的問題,但與 Squirrel 一樣,它也有垂直擴充套件方面的挑戰。而由於 Cellar 是持久儲存,它也很少遇到單機數據容量的問題,而要解決的問題主要是處理容量的垂直擴充套件。而且,由於 Cellar 是持久化引擎、多執行緒模型,它要解決的處理容量擴充套件問題也是不一樣的,具體如下:

  • 引擎讀寫能力的不均衡性 :Cellar 是基於 LSM-Tree 引擎模型的持久化儲存,這種引擎的多 Level compaction 會導致寫放大問題,進而會造成其寫處理能力比讀低很多。所以,在一些寫相對較多的場景,機器資源雖然還有空閑,但寫處理能力卻已經到瓶頸了。

  • 執行緒間同步的開銷 :想要提升處理容量,就需要增加執行緒數。而隨著執行緒數的增加,執行緒間同步的開銷在整個服務的 CPU 使用占比也會越來越高。所以,如果解決不好執行緒間同步的問題,想單純地增加執行緒數來提升處理容量行不通。

  • | 4.2 Bulkload 數據匯入

    對於上述提到引擎寫壓力達到瓶頸的集群,我們調研後發現其線上的即時寫入一般都是比較少的,高寫入量主要是使用者從離線批次寫數據到線上 Cellar 集群帶來的。基於此,我們開發了 Bulkload 數據匯入能力來解決這個問題。

    Bulkload 整體架構如上圖所示,它在普通寫入流涉及的客戶端和儲存節點之外,還引入了 S3 物件儲存來做匯入數據的中轉。 下面我們看下 Bulkload 具體的寫入流程: Bulkload 首先會在客戶端行程內生成分片內有序的數據檔並寫到本地硬碟上。 等客戶端的數據檔寫好之後,它會上傳到物件儲存,利用物件儲存做數據檔的中轉,解決了客戶端與伺服端之間直傳大檔容易失敗的問題。

    分片 1 的數據檔寫入到物件儲存之後,客戶端會將數據檔的儲存地址告訴分片 1 的主所在的儲存節點 DS1。然後 DS1 就會從物件儲存下載分片 1 的數據檔,並把它直接插入到 LSM-Tree 引擎裏面。因為這是一個完整的檔插入,所以,它可以消除引擎在普通寫入時的記憶體排序和刷盤壓力。同時,因為這個檔的數據是分片內有序的,所以,它在參與 Level 間 Compaction 時會與其他的引擎檔交叉很少,可以大幅減少多 Level compaction 的壓力。

    然後 DS1 會把分片 1 數據檔的物件儲存地址復制發送到分片 1 的從所在的儲存節點 DS2 。因為儲存節點的復制只是傳輸數據檔的地址,所以復制速度是特別快的,也節省了很多傳輸的頻寬。DS2 收到了分片 1 的地址後同樣會從物件儲存下載數據檔,並插入到引擎裏面。

    透過 Bulkload 解決方案,我們整體把數據離線匯入的效能提升到舊版的 5 倍。比如我們的一個儲存廣告特征的客戶使用 KV 方式從離線導數據到線上需要 14 小時,受限於線上高峰期無法導數據,如果需要繼續增加特征數據,就需要擴容集群了。而擴容集群一方面會因為「木桶效應」導致請求長尾延遲問題,另一方面 Cellar 成本的上升也會抵消一部份廣告收益。而在 Bulkload 功能加持下,該客戶匯入相同規模數據僅需不到 3 小時,它可以在不增加 Cellar 資源的情況下,將廣告特征規模增加數倍,大幅提升了廣告的效果。

    | 4.3 執行緒排程模型最佳化

    我們最初的執行緒模型與開源版 Tair 一樣,網路執行緒池做收發包,收到的包經過一個佇列轉出到一個大的工作執行緒池做請求處理。這樣的執行緒模型,很容易發生請求間的互相影響。比如使用者有離線數據匯入到 Cellar 的時候,就很容易導致線上讀請求的超時。又比如當有大 Value 讀寫的時候,工作執行緒處理會比較慢、占用執行緒的時間會很長,導致正常 Value 讀寫的快請求只能在佇列等待,進而導致大量超時。

    所以,為了隔離在離線請求、快慢請求的處理,讓服務資源優先保證核心流量的處理,我們後來把執行緒模型改造成如上圖所示的 4 個佇列 + 4 個執行緒池的結構,將請求分成 4 類( 讀快、讀慢、寫快、寫慢 )分別放到不同的佇列和執行緒池去處理,進而來提升服務核心流量的可用性。

    但是,工作執行緒池按照請求型別分離之後帶來一個問題,就是不同業務場景、甚至同一業務的不同時段,不同型別請求量的占比是不一樣的。所以,給每個執行緒池分配多少執行緒是一個很棘手的問題。

    針對這個問題,我們增加了一個執行緒動態排程的邏輯:每個執行緒池都有一部份執行緒被設定為可共享執行緒,如果執行緒池比較空閑,共享執行緒就會去輪詢其他的佇列,處理一些繁忙執行緒池的請求,這樣就達到了自適應調整各執行緒池資源的效果。但是在這樣的架構下,雖然解決好了請求隔離性和不同請求型別執行緒資源的動態分配問題,但我們發現隨著節點流量的上漲,共享執行緒對於其他佇列的輪詢會消耗越來越多的 CPU 資源,而且集群業務的負載分布與預設的執行緒數設定差異越大,這個消耗的占比也會越高。

    為了解決上述執行緒池資源自適應排程帶來的 CPU 消耗問題,我們對分離後的執行緒、佇列模型做出了如上圖的改造。 改進後的執行緒模型最主要的特點是引入了一個排程執行緒和一個空閑執行緒池,這個排程執行緒會即時統計每個執行緒池的負載,來評估每個執行緒池是否需要增加或減少執行緒並做出排程動作,空閑執行緒池用來存放當前空閑的可用於調配的執行緒資源。

    當排程執行緒評估後決定做執行緒資源調配時,它就會發送排程指令到相應佇列中,當執行緒池裏的執行緒獲取並執行了這個指令後,就實作了執行緒資源的調配。比如,它想給讀快執行緒池增加執行緒,就會給空閑執行緒池的佇列發送一個排程指令,空閑執行緒池的執行緒取到這個指令後,就會將自己加入到讀快佇列的執行緒池裏面,去處理讀快佇列的請求。

    當排程執行緒想對讀慢執行緒池調減執行緒時,它會向讀慢佇列發送一個排程指令,讀慢佇列的執行緒獲取到這個指令後,就會離開讀慢執行緒池加入到空閑執行緒池。透過排程執行緒準即時的毫秒級負載統計、排程,我們實作了執行緒池資源的快速動態分配。對於每一個執行緒池的共享執行緒,也不再需要去輪詢其他執行緒池的佇列了,只需要專心處理自己佇列的請求即可,大幅降低了執行緒池資源排程的 CPU 消耗。

    透過上述的執行緒佇列模型最佳化,服務在高負載場景下可以提高 30% 以上的吞吐量。

    | 4.4 執行緒RTC模型改造

    上圖左側畫的是我們服務請求的 IO 處理路徑: 一個請求的處理流程會經過網路執行緒、請求佇列、工作執行緒、記憶體和硬碟引擎。 這個設計的問題是,請求在不同執行緒之間流轉會造成大量的 CPU 切換以及 CPU 快取的 Cache Miss,進而造成大量的 CPU 資源消耗。 在大流量場景下,這樣的 CPU 消耗也是很可觀的一筆資源。

    針對這個問題,我們對執行緒佇列模型又做了如上圖右側所示的改造。新的模型下,我們讓網路執行緒直接去做讀請求的處理,對於能夠命中記憶體引擎的讀請求,其處理模型就是一個 RTC( Run-to-Completion )模型。

    具體來講,當網路執行緒收到一個請求之後,會先判斷是否為一個讀請求,如果是,就會直接去讀記憶體引擎。我們服務的記憶體引擎會緩存硬碟引擎上的熱點數據,如果記憶體引擎命中的話,網路執行緒就可以直接返回結果給客戶端。這樣在網路執行緒內就實作了請求的閉環處理,相比原來的模型可以去除所有因請求流轉造成的 CPU 資源消耗。而對於寫和讀未命中記憶體引擎的請求,仍然需要經過原來的請求處理路徑,去硬碟引擎讀或者寫數據。

    新的執行緒模型,經實測在 80% 記憶體引擎命中率場景下,服務讀吞吐可以提升 30%+。雖然新的執行緒佇列模型只實作了讀緩存命中請求的 RTC,但其實線上流量大多都是讀多寫少且熱點數據明顯、記憶體引擎命中率比較高的場景,所以,新模型上線後在大多數的業務集群都取得了明顯的效能提升。

    | 4.5 記憶體引擎無鎖化

    當單機請求量達到了一定規模之後,我們發現服務內的鎖操作會占用很多的 CPU 資源。經分析發現,大多數的鎖操作都發生在上節內容提到的記憶體緩存引擎上。如上節所述,所有請求都會經過記憶體引擎,且大部份請求都會在記憶體引擎命中並返回結果給客戶端。所以,大部份請求都是純記憶體處理,這個過程中的鎖操作就很容易成為瓶頸。針對這個問題,我們對記憶體引擎做了無鎖化改造,其改造後的結構如下圖所示:

    整體改造主要跟上圖的 HashMap 和 SlabManager 兩個數據結構有關( 其他數據結構在圖中已略掉 )。HashMap 是儲存 KV 數據的核心結構,它把 Key 透過 Hash 演算法雜湊到不同的 Slot 槽位上,並利用連結串列處理 Hash 沖突;SlabManager管理不同尺寸記憶體頁的申請和釋放,它利用連結串列把相同尺寸的記憶體頁放到一起管理。

    對於 HashMap,我們做了單寫多讀的無鎖連結串列改造。同時,透過引入 RCU 機制實作了異步的記憶體回收,解決了讀請求與寫請求記憶體釋放操作的沖突,實作了讀請求處理全程的無鎖化。寫請求雖仍需要加鎖,但我們對寫做了鎖粒度的最佳化,可以大幅提升並行度。比如我們把 SlabManager 的存取由一把大鎖改成每個記憶體尺寸的管理連結串列單獨一把鎖,這樣在分配和釋放不同尺寸記憶體頁的時候就可以實作並行。同時 RCU 機制下的記憶體異步回收,也解決了寫執行緒回收記憶體時可能被阻塞的問題,進一步提升了寫效能。

    記憶體引擎透過無鎖化加 RCU 技術的改造,讀處理能力提升了 30% 以上。

    | 4.6 Cellar可用性的挑戰

    同 Squirrel 一樣,Cellar 也透過建設集群間數據同步能力,實作了跨地域的容災架構。 不同的是,Cellar 因為是自研,無需考慮與社群版本的相容性,同時為了簡化部署結構、降低運維成本,它把集群間數據同步功能做到了儲存節點內部。 如上圖範例的北京集群 A 節點、上海集群 H 節點,在接收到寫入之後,除了要做集群內的數據同步以外,還需要把寫入數據同步到跨地域的另一個集群上。

    Cellar 也可以透過配置兩個方向的跨集群數據同步鏈路,實作完全的本地域讀寫。Cellar 由於采用了儲存節點內建的方案,它的集群間復制透過使用客製的復制包來甄別客戶寫入和復制寫入,並只為客戶寫入生成復制 log 來避免迴圈復制,相對Squirrel 會簡單一點。但同樣的,這種架構也會遇到極端情況下,雙向同步導致的數據沖突問題。

    | 4.7 雙向同步沖突自動解決

    如上圖所示,Cellar 也實作了類似 Squirrel 的基於數據寫入本地時間的 last write win 沖突自動解決方案。 但 Cellar 的方案有一點區別是,它沒有透過在每條數據記錄 cluster id 的方式解決時鐘回退、兩次變更寫入的本地時間相同的問題,而是引入了 HLC( Hybrid Logic Clock )時鐘來解決這個問題。

    因為 HLC 可以保證每個集群寫入數據的時鐘是單調遞增的。所以,接收端是不用擔心對端復制過來的數據有時間戳相同的問題。而對於兩個集群分別寫入,時間戳相同且 HLC 的邏輯時鐘剛好也相同的情況,可以透過比較集群配置的 cluster id( 不會儲存到每條 KV 數據內 )來決定最終哪個數據可以寫入。

    5 發展規劃和業界趨勢

    未來,根據技術棧自上而下來看,我們的規劃主要覆蓋服務、系統、硬體三個層次。

    首先,在服務層主要包括三點:

  • 第一,Squirrel && Cellar 去 ZK 依賴。如前所述,Squirrel 集群變更到客戶端的通知是依賴 ZK 來實作的,Cellar 的中心節點選主和後設資料儲存也是依賴 ZK 實作的。但 ZK 在大規模變更、通知場景下,它的處理能力是無法滿足我們的需求的,很容易引發故障。所以,Squirrel 會去掉對 ZK 的依賴,改為使用公司內的配置管理、通知元件來實作集群變更到客戶端的通知。Cellar 會透過在中心節點間使用 Raft 協定組成 Raft 組,來實作選主和後設資料多副本強一致儲存( 註:本文整理自 DatafunSummit 2023 演講,此工作當前已完成開發,處於灰度落地階段 )。

  • 第二,向量引擎。大模型訓練、推理場景有很多向量數據儲存和檢索需求,業界很多 NoSQL、SQL 資料庫都支持了向量引擎能力。KV 儲存作為高效能的儲存服務,如果支持了向量引擎,可大幅提升大模型訓練、推理的效率。

  • 第三,雲原生。當前美團的 KV 服務規模很大,相應的運維成本也比較高。所以,我們計劃做一些服務雲原生部署、排程方面的探索,向更高運維自動化水平邁進。

  • 其次是系統層,計劃對 Kernel Bypass 技術做一些探索和研發落地,比如新版內核支持的 io_uring、英特爾的 DPDK、SPDK 技術等。由於 KV 儲存是典型的高吞吐服務,它的網路 IO、硬碟 IO 壓力都很大,Kernel Bypass 技術可以大幅提升服務的 IO 能力,降低存取延遲和成本。

    最後是硬體層,計劃對計算型硬體的套用做一些探索,比如配備了壓縮卡的 SSD,可以將服務引擎層使用 CPU 做的資料壓縮工作解除安裝到壓縮卡上,釋放出 CPU 資源做更高價值的計算工作。KV 服務是典型的低延遲、高網路負載的服務。所以,我們也計劃對 RDMA 網路做一些探索,以期進一步降低服務存取延遲、提升網路處理能力。

    6 本文作者

    澤斌,來自美團基礎研發平台/基礎技術部。