大型系統存儲層遷移實踐

‍背景

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

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

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

名詞解釋

難點

領域邊界調整

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

數據模型變更

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

數據量、請求量大

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

迭代中遷移

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

歷史包袱衆多

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

痛點分析

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

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

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

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

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

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

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

過程

前置代碼準備

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

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

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

此階段主要開發工作有:

  1. 存儲抽象接口層(data_source)的接口改造,使得可以通過一次請求替代之前操作存儲的多個請求。

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

  3. 開啓雙寫,把新存儲作爲弱依賴雙寫數據。

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

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

寫 diff 消除

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

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

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

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

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

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

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

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

    b. 字段類型配置爲 tinyint, 導致業務上如果寫一個較大的值時會發生溢出,而新存儲不會。

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

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

  1. 時間戳問題

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

  1. 序列化問題

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

  1. 序列化的順序

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

  1. 服務本身的異步寫入

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

  1. 存儲一前一後寫入,或一前一後讀取導致的 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 打掛,雖然此時只有部分機器資源到位,但也開始對性能產生深深的擔憂,因爲此時壓測比四月份的壓測更接近真實業務場景。按照此時的壓測數據來看,即使到了開始預估的全量機器,也很可能無法承接所有流量。因此在七月份開啓了緩存的優化改造,主要兩點考慮:

  1. 期望通過緩存的優化能夠提高緩存命中率,減小到達存儲層的流量。

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

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

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

  1. 在線讀服務的緩存把一篇文章的數據分爲四份來存儲,在早期來看這種設計的確合理,但由於業務的發展,在 18 年之後,四份緩存中的數據就存在着大量的重複,造成緩存空間的極大浪費。

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

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

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

讀 diff 消除

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

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

主要改造點:

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

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

  3. 計算邏輯適配新存儲。

  4. 讀數據事件的解析和 diff 打點監控。

  5. 產出快速查看 diff 和快速修復數據的工具,提升消 diff 的效率。

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

性能優化終見曙光

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

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

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

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

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

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

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

歷史數據 diff

對於歷史上的髒數據如果無法兼容,嘗試把它改對吧

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

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

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

切讀

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

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

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

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

切主存儲

在線流量切換完成。需要做好切換過程中的數據補償

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

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

收益分析

解決多存儲不一致

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

歷史包袱清理

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

系統可用性提升

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

更多業務特性支持

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

解決容量瓶頸

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

幹掉主從延遲

同樣得益於 MySQL->ByteKV, 切換後無主從延遲導致的緩存髒數據問題反饋。

成本降低

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

  2. 優化緩存使用方式,緩存命中率 90%->98%,節省 XTRedis 資源。

  3. 對 ByteKV 使用方式進行優化,完成遷移時只使用了啓動時預估資源的 50%。

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

總結

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

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

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

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

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

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

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

加入我們

我們是字節跳動內容平臺團隊,負責提供內容相關全生命週期解決方案和相關服務,包括但不限於:內容發佈、存儲、審覈、管控、內容協議制定、內容素材解析與理解,與內容推薦和搜索的聯動等。同時我們也在積極探索下一代大型應用架構演進思路,負責今日頭條系列產品服務端的架構設計和系統優化,致力於打造穩定高效的服務億級 DAU 的在線系統。

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