大型系統儲存層遷移實踐

陶然陶然發表於2022-06-01

   背景

  作為一個以新聞、資訊為主的 App,今日頭條上的主要內容都是由文章組成,文章服務自然伴隨著今日頭條 App 的產生就已出現,之後又逐步擴充套件為目前的內容雲,為頭條、西瓜、小說、懂車帝等多個 App 服務的業務內容中臺。截止 2021 年底,內容雲接入子業務已經達到數百個,高峰期主要讀服務 QPS 數百萬,維護超過 2200 個屬性,存量資料達到百億條級別。然而由於歷史悠久,經手人眾多,加上歷史上一些環境或周邊系統的特殊性,業務模式發生轉變等,使得內容雲成為一個標準的大型遺留系統,早期的一些儲存、架構上的設計已經逐漸無法滿足當前的業務場景,並給維護者帶來了較大維護和迭代成本。

  因此我們啟動了內容雲端儲存層的遷移專案,隨著調研和與其他業務的討論的不斷深入,發現各業務對儲存層的痛點及需求基本一致,儲存模型和實現方案逐漸趨同,因此決定基於 ByteKV 開發一個寬表資料服務(本文主要聚焦在遺留系統儲存層遷移的過程,暫不涉及新儲存層的設計與實現細節),下沉儲存層通用邏輯,供其他業務接入,並替換內容雲原有的儲存層。最終歷時將近 1 年時間將線上流量切換至新的儲存層。

  遷移一個系統的儲存能有多複雜?無非是雙寫、遷移資料、切讀、停寫罷了,為何內容雲端儲存層的遷移竟花費將近一年時間?本文主要分享內容雲端儲存層遷移的血淚史,過程中的一些坑和經驗,望能給其他大型系統遷移儲存或做重構帶來一些流程上的參考。

   名詞解釋

  文章服務,內容雲:位元組跳動內部提供內容全生命週期解決方案的內容業務中臺。

  ByteKV:位元組跳動內部自研強一致 KV 模型儲存元件。

  ABase:位元組跳動內部自研高可用 KV 模型儲存元件。

  寬表資料服務:新的儲存層,通用的表格模型資料服務,透過下沉儲存層的通用能力,減少重複建設,降低維護成本,提升研發效率。

   難點

  領域邊界調整

  雖然大體目標是將原儲存層替換成新的儲存層,但預期本次遷移也需要解決原儲存層由來已久的多儲存不一致問題、容量瓶頸、主從延遲等問題,這要求在遷移過程中也需要對內容雲業務層進行大量改造,將原有業務層中包含的儲存層功能下沉到新的儲存層,使業務層和儲存層邊界明確,帶來了額外的工作量。

   資料模型變更

  由於原有主要儲存為 MySQL,本身資料模型為表格型,而新儲存使用 ByteKV,資料模型為 KV 模型,雖然在新儲存層建設過程中已經完成了基於 KV 模型提供表格模型能力的開發,但相關功能的能力與舊儲存層的能力仍有偏差,需要在遷移過程中不斷的完善和進一步改造。

   資料量、請求量大

  遷移時內容雲資料量已經達到數百億條,主要讀服務請求高峰期流量數百萬 QPS,大的資料量+大請求量使得在雙寫、做 diff、刷資料等每個階段都需要考慮效能問題,資源問題。本身雙寫雙讀期間就需要引入額外的資源消耗,使得過程中不得不抽出一些時間最佳化之前系統的效能,以釋放出一些資源進行雙寫、雙讀、消 diff 及壓測等驗證工作。後面會詳細介紹兩次大的效能最佳化過程。

   迭代中遷移

  唯一不變的是變化,在整個遷移過程中內容雲系統也在持續進行迭代,整個遷移的過程如同給正在奔跑的汽車換輪胎,給正在飛行的飛機換髮動機,需要做到業務無感。新 feture 的加入需要同時作用到兩套儲存上,否則就會產生 diff,時刻關注 diff 情況並追齊新加的 feature 同樣花費了不少時間。

   歷史包袱眾多

  由於業務經手人數較多,歷史悠久,遺留系統中都有眾多黑盒及不可解釋的邏輯,對這些邏輯的理解及相容是前期專案計劃之外的。此外歷史資料的混亂,已經無法用現有系統的標準去度量,為保證切換過程中透明,甚至需要去相容歷史上錯誤的資料。

   痛點分析

  內容雲本身對儲存層的依賴如下圖:

  此架構主要有以下幾個問題:

  儲存元件使用 2 個 MySQL+2 個 ABase 協同提供服務,但業務上操作四個儲存很難保證事務性,即使做一些補償也很難保證四個儲存同時成功或同時失敗,導致產生較多的多儲存不一致問題。

  對於一些儲存層的通用能力,如加密、版本、審計、快取等與業務層沒有明顯邊界,相應邏輯揉雜在業務程式碼中,對業務程式碼侵入較強。

  主要儲存為 MySQL,原生 MySQL 並非儲存、計算分離的架構,在大資料量的業務上儲存容量常常成為瓶頸,只能不斷進行拆庫"續命"。

  大多數上層業務對資料一致性要求較高,MySQL 的主從延遲的抖動會造成快取中存在髒資料,引發資料不一致。

  MySQL 中單列儲存容量存在上限,導致業務上對於一些"大"文章的儲存需求無法滿足。

   過程

  前置程式碼準備

  此階段主要進行資料雙寫程式碼準備,及寫 diff 流程、監控的搭建。

  從上述儲存架構可以看出,上層業務統一透過了抽象介面層(data_source)訪問底層的儲存,理論上在抽象介面層新增一個寬表資料服務的實現,並把舊儲存的實現直接替換為新儲存的實現即可完成儲存的替換,即基於新儲存的依賴如下圖所示:

  然而,理想很美好,現實總是很骨感,這樣雖然能做到替換儲存,但並沒有達到重構的目的,即解決多儲存不一致等問題,之前分別處理多個儲存的程式碼在業務層進行,透過 data_source 中的不同介面進行不同儲存資料的操作,因此需要進行 data_source 介面的改造和線上寫服務中操作多儲存部分的程式碼改造。同時把寫 diff 的流程搭建起來。

  此階段主要開發工作有:

  儲存抽象介面層(data_source)的介面改造,使得可以透過一次請求替代之前操作儲存的多個請求。

  線上寫服務操作多儲存的邏輯下沉,在業務層不再感知到儲存層相關的邏輯。

  開啟雙寫,把新儲存作為弱依賴雙寫資料。

  基於寫資料事件觸發 diff 服務,搭建寫 diff 流程(收到事件重新讀取兩儲存中的資料,並比較進行打點監控)。

  進行壓測。資源總是緊張的,需要預先申請,此時的新儲存叢集只能夠承擔雙寫的流量,此階段需要進行初步壓測並預估最終所需資源數量並提交申請。

   寫 diff 消除

  寫 diff 過程不管做的多細緻都不過分

  上階段程式碼準備完成後,開始無盡的消 diff 工作,由於內容雲欄位已經超過 2000 個,需要對有 diff 的欄位逐個進行排查,並不斷進行程式碼改造以消除這些 diff,是一個極度需要細緻和耐心的過程。

  最終寫 diff 消除用時 1 個月左右。後面也證明寫 diff 階段不管多細緻都不過分,因為寫 diff 消除完成證明了資料寫入已經沒有問題了,可以進行歷史資料遷移,如果歷史資料遷移完後又發現有寫 diff,很可能需要再次全量刷一遍資料,費時費力。然而雖然用時一個月後來發現仍有一些坑,導致大大小小最終刷了不下 10 遍資料,後面說。

  下面總結下比較有代表性的寫 diff:

  1.自身邏輯實現的 bug 及新流程未與舊流程完全對齊(這類導致的 diff 其實是最多的,具體要看本身的業務邏輯,沒什麼參考意義,只能不斷的去追平邏輯再驗證)。

  2.舊儲存特性導致的 diff,有預設值。即業務上沒有寫對應資料,但舊儲存 MySQL 每個列可以配置預設值。

  3.舊儲存本身配置不合理導致的 diff,如:

  字符集配置的 UTF-8,導致本身儲存中不支援 emoji 表情,而新儲存中支援導致的 diff。

  欄位型別配置為 tinyint,導致業務上如果寫一個較大的值時會發生溢位,而新儲存不會。

  4.兩個儲存一個成功、一個失敗導致的 diff,需要在一個儲存失敗時進行後續的補償重試,因此搭建了資料修復流程,期望兩儲存能夠達到最終一致的狀態。

  5.請求亂序,如下圖,可能會發生請求 2 比請求 1 先到的情況。需要在寫請求之前加鎖,並在兩儲存寫完後再釋放鎖,前提是能確保新儲存的效能不會對上游產生影響。

  6.時間戳問題

  由於兩儲存無法保證準確的同一時刻寫入,導致有些時間戳會出現 diff,這種解決方案分兩種情況,對於無法接受 diff 的時間戳需要在業務層統一時間戳,再指定使用統一時間戳寫入兩儲存。對於能夠接受 diff 的時間戳需要在 diff 時忽略掉。

  7.序列化問題

  一些反序列化方法會把 JSON 中的數字轉為 json.Number,這在業務中型別斷言或 diff 比較時都會留坑,應儘量在下層處理好這類問題。

  8.序列化的順序

  由於 map 結構的無序性,在序列化成字串時會導致順序不一致,可能在某些業務邏輯中有坑,較好的方法是在序列化時保證進行有序的序列化,已經有許多開源的 JSON 庫能夠做這樣的事情。

  9.服務本身的非同步寫入

  這種 diff 可能是內容雲獨有的,之前有較多邏輯直接在寫服務寫完主儲存後,起非同步協程再進行一些計算和資料操作,這使得這些寫入的請求順序無法得到保證。較好的做法是把操作儲存的邏輯收斂到統一的寫服務介面上。

  10.儲存一前一後寫入,或一前一後讀取導致的 diff

  由於無法保證在做 diff 時的事務隔離性(會影響線上服務,不太能接受),會存在在 diff 讀取時剛好有併發的資料寫入操作,導致的不一致,這種即使延遲一段時間再次進行 diff 也無法完全消除,因此最終 diff 的消除也無法達到 100%的一致率,最終在一致率達到 99.99%時經追查仍有 diff 的 case,發現都屬於這種情況,這時認為寫 diff 已經消除完成了。

   歷史資料遷移

  嘗試探索更高效的歷史資料遷移方案能提升儲存遷移的效率,除非能保證只刷一遍資料

  經過寫 diff 消除階段,此時理論上新增的資料寫入已經沒有問題了(只是理論上,後面讀 diff 時發現還是有一些邊緣 case 導致的寫 diff)。這個階段主要是把歷史存量資料從舊儲存匯入新儲存中。這個過程依然基於統一介面層 data_source 實現。

  這個階段同樣需要做完備 diff,需要驗證匯入的歷史資料是否符合預期,需要進行歷史資料的正確性校驗,但當時由於新儲存本身資源不足,離線資料也還不支援產出,此時進行歷史上 400 億條資料的對比是無法進行的,因此這個階段只進行了有明顯問題 diff 的修復,把歷史資料 diff 的校驗工作放到了切讀前的最後一步,但更合理的做法是在此時就校驗好歷史資料的正確性,否則之後可能會產生重複的刷資料工作。

  此階段主要會遇到的問題是如果一些資料是在真實資料寫入時生成的,可能有問題,需要新儲存支援這些資料可以指定寫入,如:

  create_time 類資料,是在新資料寫入時根據時間戳生成的,但歷史資料的 create_time 不能使用刷資料時的時間,因此需要新儲存支援上游指定寫入 create_time 的值,進行一些程式碼改造。

  刷資料的工作主要是依賴業務上層的實現進行,因此刷資料的過程需要進行大量的計算邏輯,是比較低效的,理論上把刷資料的工作越下沉越高效,比如參考 MySQL 遷移資料時的檔案級別複製等。由於當時考慮內容雲遷移本身 1. 資料匯入速度不會成為整個專案的瓶頸 2. 新舊儲存資料模型差別過大,透過離線資料匯入也需要大量適配、驗證工作,當時並沒有考慮更加高效的存量資料遷移的方案,後期刷全量資料約需要 5 天時間,但在儲存遷移的過程中如果能把資料遷移的時間壓縮到比較短,如半天能完成存量資料的全量遷移,對整個遷移工作是比較有利的,可以進行快速的驗證和試錯。

   快取最佳化

  效能最佳化初見成效

  在歷史資料遷移的過程中,我們也對新儲存層的效能進行了又一次壓測,發現在資料寫入 QPS 到達 3w 時,基本就會把 ByteKV 打掛,雖然此時只有部分機器資源到位,但也開始對效能產生深深的擔憂,因為此時壓測比四月份的壓測更接近真實業務場景。按照此時的壓測資料來看,即使到了開始預估的全量機器,也很可能無法承接所有流量。因此在七月份開啟了快取的最佳化改造,主要兩點考慮:

  期望透過快取的最佳化能夠提高快取命中率,減小到達儲存層的流量。

  之前新儲存的壓測是在沒有快取的情況下進行的,需要有額外的快取資源用來壓測得到更貼近真實的壓測資料。而如此大的流量的快取資源再部署一套是不被接受且浪費的。

  主要快取最佳化的思路是根據內容雲實際業務場景出發,發現之前使用快取的方式存在很大浪費,最佳化思路可能並不能直接複用於其他業務,這裡不詳細展開介紹。但值得注意的是對於類似大型遺留系統由於業務歷史上的轉變,總會發現一些系統中不合理的點,經過簡單最佳化後可能能得到意想不到的收穫。

  簡單說下此階段主要進行了兩點業務上的最佳化:

  線上讀服務的快取把一篇文章的資料分為四份來儲存,在早期來看這種設計的確合理,但由於業務的發展,在 18 年之後,四份快取中的資料就存在著大量的重複,造成快取空間的極大浪費。

  線上讀服務之前有兩層服務,兩層快取,上層的快取時間 6 分鐘,下層快取 30 分鐘,上下兩層快取中的資料也基本相同,這使得下層的快取資料比較浪費,因為快取的資料在 30 分鐘內不考慮併發的情況下只會有 5 次請求。

  因此,對線上讀服務的快取進行了改造,合併了多份快取的資料,並且把兩層快取改為一層,從而釋放出了 Redis 資源供新儲存使用,此次最佳化後快取命中率得到提升 90%->98%,且節省出的快取空間足夠新老兩套儲存同時使用。

  經過快取的最佳化,對新儲存加上快取再進行壓測,此時的壓測資料基本可以保證如果預期資源能如約到位,ByteKV 是基本能夠承擔內容雲的所有流量的。

   讀 diff 消除

  每個 diff 的消除都是在解決切換過程中的隱藏炸彈,diff 越仔細,切流時越安心

  與很多業務中臺一樣,內容雲的讀服務在讀取資料之後進行一些計算打包邏輯,此階段主要對內容雲業務層兩個出口服務的讀介面進行 diff 流程搭建和消除工作。對於讀服務來說進行了一些重構,預期把老的回源服務下掉以保持整體架構的簡潔。服務改造圖如下:

  主要改造點:

  快取適配新儲存模型,由於新的儲存是大寬表的模型,無法一次讀出一篇文章所有資訊,因此快取模式需要進行改造適配。

  老回源服務的業務邏輯上移到線上讀服務,業務和儲存層邊界更加清晰。

  計算邏輯適配新儲存。

  讀資料事件的解析和 diff 打點監控。

  產出快速檢視 diff 和快速修復資料的工具,提升消 diff 的效率。

  相比於寫 diff 階段,讀 diff 需要消除的 diff 並不算多,更多的 diff 是由於部分需要重構和適配的邏輯與原邏輯沒有對齊導致的,但由於讀介面流量較大,一般無法列印比較詳細的日誌,導致對於 diff 的排查工作較難進行,常常需要根據資料和程式碼的蛛絲馬跡在腦中進行編譯執行來定位具體產生 diff 的原因,這裡也是極度需要耐心和細緻的過程。

   效能最佳化終見曙光

  終於找到 ByteKV 的正確開啟方式!

  讀 diff 消除完成後,理論上已經可以進行逐步切流至新儲存,但意外總是不期而遇,最早預估的機器資源由於整體資源緊張並沒有如期到位,導致此時新儲存的資源不能承擔所有流量。因此需要進行進一步的效能最佳化。

  在一次小的效能最佳化上偶然發現,寫資料時把每次寫儲存的 Key 數量縮小一半,效能不止能翻一倍。基於儘量減少 Key 的個數這個思路開始進行程式碼的重構和調整(當然又需要全量刷一遍歷史資料),主要進行了兩點最佳化:

  儘量減少非必要的 Key 寫入,如之前會記錄每個欄位的建立、修改時間,但業務上並沒有實際使用,反而會使 Key 的數量膨脹為最初的三倍,因此暫時放棄了欄位維度時間的記錄。

  由於業務上歷史欄位眾多,且由於歷史原因需要全量返回,因此對歷史欄位進行了第二版合併,原則是除特殊情況,能合併的都合併。

  經過上次兩點最佳化,保證了對於大部分請求讀寫一篇文章的資料,能夠保證讀寫新儲存 4-5 個 Key 即可完成,這使得一切變得美好起來,介面的延遲能夠穩定保持在 10ms 以下,錯誤率也不會像之前那樣有突刺了。經過最佳化之後再壓測,當前的機器已經足以承擔所有流量,甚至還有富裕。

  做欄位合併是基於內容雲的歷史包袱和整體資源不足的無奈之舉,雖然提高了效能,但也會在其他場景引入坑,如非必要請勿作此妥協。

   歷史資料 diff

  對於歷史上的髒資料如果無法相容,嘗試把它改對吧

  你永遠無法想象一個歷史遺留系統中的資料能有多混亂,歷史資料的混亂總在不斷的顛覆對內容雲這個系統的認知。如:

  一個原則是草稿不會記錄到歷史庫中,但歷史資料中竟然發現好多草稿記錄到了歷史庫中。

  不需要記錄版本的欄位,卻在版本庫裡。

  一些不需要記錄版本的業務,版本號會有幾百甚至上千。

  底層的儲存層中竟然有對某一個歷史業務做的特殊邏輯,導致又花費了一些時間做邏輯的相容,並重新刷一遍資料。

  每次發現這種問題都彷彿是跟前人的一次對話,慢慢可以理解或者想象當時發生了什麼事情,如可能某幾天線上有 bug,造成髒資料,但並不影響整體使用,逐漸的這些髒資料也就留在了遺留系統中。

  前期為了保證切換儲存對上游完全透明,即對於這些髒資料我們也想辦法儘可能讓他繼續保持現狀,然後隨著相容的髒資料越來越多,發現我們新寫的邏輯逐漸不可解釋和維護,最終痛定思痛決定還是按照合理的方式把髒資料變成本來該有的樣子(又進行了一遍全量刷資料),最終結果發現把歷史上的髒資料改對可能確實是正確的,上游也沒有依賴髒資料做邏輯,切換無感知。

   切讀

  切流量是一個漫長、危險,如履薄冰的過程,需要保證每一步可回滾,可快速恢復

  經過前面的階段,已經基本保證了新儲存讀、寫的功能和效能滿足要求,在 12 月份終於迎來了切量到新儲存。由於此時一些舊儲存的調整導致此時舊儲存的主從延遲問題更加嚴重,導致業務上反饋較多,因此選擇優先把主要讀服務切換到新儲存上。

  此步驟主要就是把讀介面流量切換到新的鏈路來承接,本身開發工作不大,主要是需要觀察切量過程中是否有問題,切量前後的系統流量,穩定性等是否滿足需求,同時需要做好線上問題的處理預案。保證任何時候出現問題能夠快速回滾,及時止損。

  最終歷時三週時間,把線上讀服務的所有流量切換到新的鏈路上,徹底告別了主從延遲導致的資料不一致問題。

   切主儲存

  線上流量切換完成。需要做好切換過程中的資料補償

  切主儲存,主要是寫入相關介面,之前還是以舊的儲存作為主儲存,舊儲存成功即返回成功,舊儲存失敗介面返回失敗。需要切換到以新儲存返回為準。需要注意的是需要做好資料補償,如切之前,舊儲存成功,新儲存失敗,需要利用舊儲存的資料嘗試修復新儲存的資料,切完之後,新儲存成功,舊儲存失敗需要利用新儲存的資料嘗試修復舊儲存的資料,需要保證切換過程平滑可回滾,不會出現資料不一致的 badcase。如下圖,把新儲存切為主依賴,舊儲存切成弱依賴。

  最終又歷時兩週,切主儲存完成,線上流量全部切換到新儲存上,整個專案完成。

   收益分析

   解決多儲存不一致

  新的儲存層基於強一致的 ByteKV,不會產生一篇文章部分屬性寫成功,部分寫失敗的問題,切換後消除了不一致問題的反饋。

   歷史包袱清理

  遷移中附帶解決了業務中的一些歷史包袱,對歷史不一致髒資料嘗試修復,明確業務層和儲存層的邊界。使整體系統架構更加清晰。

   系統可用性提升

  儲存層可用性 99.8->大於 99.99%。

   更多業務特性支援

  新儲存支援了大 Key 的拆分,解決 MySQL 單列儲存上限問題,滿足部分業務對單列大容量儲存的需求。

   解決容量瓶頸

  將 MySQL 替換為計算、儲存分離的 ByteKV,使得儲存容量不再是儲存層的瓶頸。

   幹掉主從延遲

  同樣得益於 MySQL->ByteKV, 切換後無主從延遲導致的快取髒資料問題反饋。

   成本降低

  新儲存相比舊儲存成本節省超過 60%。

  最佳化快取使用方式,快取命中率 90%->98%,節省 XTRedis 資源。

  對 ByteKV 使用方式進行最佳化,完成遷移時只使用了啟動時預估資源的 50%。

  遷移中對服務日誌進行治理,框架、元件升級,節省計算資源若干。

   總結

  本文從儲存層遷移流程的角度詳細闡述了大型系統儲存遷移的過程,分析了其中的難點和過程中的一些坑,總結來說過程中也有一些不足和感悟:

  對於寫 diff 應儘量細緻和耐心的進行消除,後期再發現寫資料的問題會帶來較多重複的工作,再次強調寫 diff 不管做的多細緻都不過分。

  歷史資料的遷移,如果資料量過大應嘗試探索更加高效的遷移手段,遷移邏輯越下沉越高效。

  歷史資料的 diff 和一些業務流程上的改造應該儘量前置,後期再進行大的改造需要重新進行刷資料、diff 校驗等工作,費時費力。

  切流的過程要做好資料補償,保證出現任何問題可快速回滾和恢復。

  遺留系統中總能發現一些業務上使用不合理的點,與其想方設法去提升底層儲存元件的效能(當然也很重要),不如去嘗試進行一些業務使用方式上的改造,可能能達到意想不到的收穫。

  希望能給其他系統做資料或儲存層遷移重構帶來一些幫助或參考,能夠更加快速、安全的進行儲存或資料的遷移工作。

來自 “ 位元組跳動技術團隊 ”, 原文作者:化立志;原文連結:https://mp.weixin.qq.com/s/xrvzIQv2HoGSg5T2NceRAg,如有侵權,請聯絡管理員刪除。

相關文章