支持 10X 增長,攜程機票訂單庫 Sharding 實踐
作者簡介
初八,攜程資深研發經理,專注於訂單後臺系統架構優化工作;JefferyXin,攜程高級後端開發專家,專注系統性能、業務架構等領域。
一、背景
隨着機票訂單業務的不斷增長,當前訂單處理系統的架構已經不能滿足日益增長的業務需求,系統性能捉襟見肘,主要體現在以下方面:
-
數據庫 CPU 資源在業務高峯期經常達到 50% 以上,運行狀況亮起了黃燈
-
磁盤存儲空間嚴重不足,需要經常清理磁盤數據騰挪可用空間
-
系統擴容能力不足,如果需要提升處理能力只能更換配置更好的硬件資源
因此我們迫切需要調整和優化機票訂單數據庫的架構,從而提升訂單系統的處理性能。通過建立良好的水平擴展能力,來滿足日益增長的業務需求,爲後續系統優化和支撐 10x 訂單量的增長打下良好基礎。
1.1 存儲架構的演進
我們選擇一個新的系統架構,應該基於當下面臨的問題,綜合成本、風險、收益等多方面因素,選擇出的最合適的方案。機票訂單庫的架構演進也不例外。
我們最開始接觸機票訂單數據庫時,它是一個非常龐大的數據集合,所有的訂單業務全部都集中一個數據庫上,因此整體 BR 非常高。同時,我們的 SQL 語句也非常複雜,混雜着很多歷史遺留下來的存儲過程。可想而之,整個數據庫當時的壓力巨大,維護成本居高不下。DBA 每天的工作也非常忙碌,想方設法降高頻,解決慢 SQL 等線上問題。生產偶爾也會因爲某些沒有 review 的 SQL 導致數據庫短暫的停止服務。
初期,我們採用了最常見的幾種手段進行優化,包括:
-
索引優化
-
讀寫分離
-
降高頻
雖然手段比較常規,通過一段時間的治理,訂單庫的穩定性也得到了一定的增強。總體實施成本較低,效果也是立竿見影的。
隨着時間的推移和數據的積累,新的性能瓶頸逐漸顯露。我們再次對系統進行了升級,對數據庫架構做了改進。主要包括以下幾個方面:
- 垂直拆分
訂單庫根據業務屬性拆分成了多個數據庫
基於業務對數據庫進行垂直拆分在很大程度上提高了系統的可靠性和可維護性。一個上百人的團隊,同時對一套數據庫進行維護,對於發佈變更來說是一種煎熬,同時也存在很大的風險。當一個非核心鏈路上的發佈出現了問題,例如某些操作導致了鎖表或者佔用過多的系統資源,其他關鍵鏈路的數據庫訪問都會因此受到影響。
我們根據不同的業務場景,例如:訂單管理系統、出票、退票、改簽等業務,將數據庫進行垂直拆分。使各自業務系統數據隔離,減少相互的影響。這些拆分的數據庫,可以根據不同性能要求,靈活調整數據庫的部署方式,來降低總體成本。
- 水平拆分(冷熱數據分離)
通常來說,當航班過了起飛時間並且用戶已經使用了當前機票,那麼我們認爲該訂單服務已經完成,後續訂單數據發生改變的可能性很小,於是會將該數據遷移到一個具有相同結構的冷數據庫中。該數據庫僅提供查詢功能,不提供修改功能。但是我們發現少數場景仍然需要對這些數據進行修改。於是我們開發了一套數據還原功能,將處於冷數據庫中的數據,還原到熱數據庫中,然後再進行操作。
注:我們當時採用的數據庫和數據結構是完全一致的,這樣做備份和還原、查詢會比較方便。其實也可以採用其他類型的數據庫,例如 Mongo 等。在讀取性能和使用成本等方面可能會更具優勢。
這次升級同樣解決了不少問題,使數據庫的穩定性得到了很大的增強。
1.2 基於冷熱數據分離的適用性
雖然基於冷熱數據的分庫方案,在目前來看遇到了瓶頸。但是我認爲它是一個非常值得借鑑的方案。我們現在仍然有大量的業務系統數據庫採用這種方案對數據進行拆分。它不僅實施簡單,同時運維成本也相對較低。
優勢:
-
功能簡單
-
實施成本低
侷限性:
-
數據冷處理的規則應該相對簡單,不應該經常發生變化
-
熱數據的膨脹需要受到限制,否則熱數據的量一旦累積過多,性能瓶頸仍然會出現
-
需要額外的查詢來找到訂單所處的位置(冷 / 熱數據庫)
-
因爲冷數據量龐大,冷數據的查詢能力、表結構調整能力都收到了限制,不能進行復雜的業務查詢操作
根據我們的系統規劃,在當下或者可預見的未來滿足以上提到原因中的多個,那麼就得謹慎選擇採用此方案。或者在改方案的基礎上進行優化。
正是由於我們目前的業務場景恰好命中了上面列舉的所有問題,我們才需要對這個架構進行進一步調整,選擇一個更好的水平擴展的方式,解決當前系統面臨的問題。
1.3 當時面臨的主要問題
從 2019 年開始,我們就開始着手研究和規劃訂單數據庫 sharding 項目。當時主要面臨如下問題:
1.3.1 訂單的存儲要求
受制於當前訂單數據庫架構的限制以及機票業務的特殊性(通常不超過 2 年的處理生命週期),改造前的訂單數據庫僅能夠支持 2 年的訂單存儲。超過 2 年,我們會將數據進行歸檔。用戶和員工都無法通過在線查詢的方式獲取訂單信息。
但是基於以下幾個方面原因,原本 2 年的存儲和處理週期已經不能滿足客戶和業務的需要:
-
從客戶的角度出發,仍然有查詢歷史訂單的需求;
-
業務場景的拓展導致機票訂單整個服務週期變長;
原先機票使用完成(出行)一段時間後就可以視爲服務結束,大部分訂單 3 個月後就不會發生變化,但是由於新業務的推出,熱點數據查詢和處理週期明顯變長。
1.3.2 系統架構瓶頸
1)熱數據膨脹
熱數據原本僅千萬級別,由於業務的變化熱數據數量不斷膨脹。
2)冷數據量龐大
由於訂單存儲週期拉長和訂單量的增長,冷數據的數量也不斷攀升。冷數據庫查詢性能不斷下降;索引調整也變得非常困難,經常出現修改失敗的場景。
3)數據庫高峯期 BR 達到了 10w+;
4)系統存儲了 20TB 的數據,磁盤使用率達到 80% 以上,經常觸發使用容量告警;
5)主庫的 CPU 使用率高峯期接近 50%;
6)由於採用了讀寫分離的架構,當主庫的服務器的性能受到影響的時候,AG 延遲變得非常高,偶爾達到分鐘級,有的時候甚至更長。
主從同步的延遲,導致了數據新鮮度的降低。我們之前的 ORM 層封裝了一個新鮮容忍度的參數。當不能滿足新鮮度要求的時候,讀取會切換到主庫,從而進一步加重了主庫的負擔。
因此,訂單庫的整體性能壓力非常大,如果想快速解決性能問題,只能對機器進行擴容。但是由於數據庫本身就是消耗資源大戶,CPU 和內存消耗非常高,只能通過進一步提高數據庫的硬件配置來解決問題,因此整體升級的成本居高不下。另外硬件升級完成後,SQLServer 的授權成本可能也會進一步提升。
爲了徹底解決以上問題,我們計劃通過優化架構來提升系統的水平擴展能力,從而進一步提升我們系統的性能和服務水平。
二、項目目標和實施方案
2.1 目標
基於上文提到的這些問題,爲了確保系統能夠長久持續的穩定運行並且提升訂單系統的處理能力,我們計劃對數據庫的架構進行升級,總體實現以下目標:
-
訂單的存儲和處理週期至少達到 5 年
-
提升訂單系統的處理能力,支撐訂單 QPS10 倍的規模增長
-
在提升系統性能的前提下,降低總體成本
-
提高系統的水平擴展能力,通過簡便的操作可以快速擴容以應對長期的業務增長
我們希望通過 1-2 年的時間,實現對數據庫架構升級改造以及完成 SQLServer 遷移到 MySQL 的目標。
2.2 架構改造
2.2.1 新舊架構的對比
舊系統架構:
架構說明:
1)訂單數據庫
爲熱數據的主庫,提供讀寫功能
2)訂單數據庫 Slave
爲熱數據的從庫,在保障新鮮度的前提下提供只讀功能,採用 SQLServer 的 AlwaysOn 技術
3)訂單備份數據庫
爲冷數據庫(沒有主從之分),僅提供只讀功能
4)Archive
備份機制,根據業務的需要,爲了緩解主數據庫的壓力,對於符合條件的訂單(通常時起飛後 + 已出行)定時遷移到冷數據庫的操作
5)Restore
還原機制。在一些特殊情況下,需要對已經備份的數據進行修改,我們需要將數據從冷數據庫中恢復到熱數據庫然後才能進行操作。這個操作叫做還原。
當前存在的問題:
1)數據量變多,業務場景變得複雜後,主庫的數據量從千萬級增長到億級別,對數據庫的性能產生明顯影響
2)冷數據庫的歷史累計數據量也在不斷膨脹,受到本地 SSD 磁盤容量的限制,磁盤空間使用率達到了 80% 以上
3)冷數據庫的訂單數量達到了十億級,數據庫索引調整,結構調整變得較爲困難;查詢性能也受到了很大的影響
4)備份和還原邏輯需要根據業務要求不斷調整
新系統架構:
架構說明:
1)訂單數據庫 Shard Cluster
新數據庫基於訂單號將數據水平拆分成 64 個分片,目前部署在 16 臺物理機上
2)訂單聚合數據庫
針對熱點數據,通過 Binglog 和有序消息隊列同步到訂單聚合數據庫,方便數據監控,並且用於提高數據聚合查詢的性能
2.2.2 新舊架構的差異
新舊系統的主要差別包括:
-
新數據庫的拆分維度從冷熱數據變更成了根據訂單號進行水平拆分
-
數據庫從 1 拆 2,變成了 1 拆 64 解決了磁盤存儲空間不足的問題
-
新數據庫的部署方式更加靈活,如果 16 臺物理機器資源不足時,可以通過增加服務器的數量快速提高數據庫的處理性能
-
如果 64 個分片的數量不足時,可以通過調整分片計算的組件功能,擴展分片數量
-
原先的 SQLServer 採用的一主多從一 DR 的模式進行配置。當前系統每個分片物理服務器採用一主一從一 DR 的模式進行配置
-
通過增加訂單聚合數據庫將部分跨分片的數據通過 Binglog + 有序消息的方式聚合到新的數據庫上,降低跨分片查詢帶來的性能損失
2.3 技術方案
在項目執行過程中有非常多的技術細節問題需要分析和解決。我們列舉一些在項目過程中可能會遇到的問題:
-
如何選擇分片鍵
-
如何解決跨分片查詢性能的損失
-
如何提高開發效率,降低項目風險
-
全鏈路的灰度切換方案
-
分片故障的處理方案
下面我們就選擇幾個典型的例子,來說明我們在項目過程中遇到的問題,以及解決這些問題的方案。
2.3.1 分片鍵選擇
分庫的第一步也是最重要的一步,就是選擇分片鍵。選擇的原則是:
-
分片鍵必須是不會被更新的字段
-
各個分庫的數據量和讀寫壓力要均勻,避免熱點分庫
-
要儘量減少單次查詢涉及的分庫數量,降低 DB 壓力
分片鍵的選擇,是需要根據具體的業務場景來確定。對於訂單數據的拆分,常見的選擇是訂單 ID 和用戶 ID 兩個維度,這也是業內最常用的兩個分片鍵。我們最終採用的是主訂單 ID,主要是基於四個因素:
-
90% 的請求都是基於訂單 ID 進行查詢
-
主訂單 ID 是對應於用戶的一個訂單,包含多個行程和貴賓休息室等附加產品,後臺會可能將這些拆分爲多個子訂單,而子訂單之間會做 Join 等關聯處理,所以不能選擇子訂單維度
-
一個主訂單可能關聯多個用戶 ID,比如用戶 A 爲用戶 B 購買機票,用戶 B 又可以自己爲這個訂單添加值機的功能。一個訂單 ID 關聯了兩個用戶 ID,從而使用用戶 ID 用作分片鍵會導致訂單分佈在不同的分片
-
分銷商的訂單量非常大,按用戶 ID 分庫會導致數據不均衡
我們決定採用主訂單號作爲分片鍵後,進行了下列改造,用於實現並且加速分片選擇的過程。
1)訂單 ID 索引表
【問題】:如何獲取主子訂單對應的分片 ID?
按主訂單 ID 分庫,首先產生的問題是子訂單 ID 如何計算分庫,需要查詢所有分庫麼?我們是採用異構索引表的方式,即創建一個訂單 ID 到主訂單 ID 的索引表,並且索引表是按訂單 ID 進行分庫。每次查詢訂單 ID 查詢時,從索引表中獲取對應的主訂單 ID,計算出分庫,再進行業務查詢,避免查詢所有分庫。
2)索引表多級緩存
【問題】:通過索引表查詢分片 ID 會增加了查詢的二次開銷,使查詢性能損失嚴重,如何減少數據庫二次查詢的開銷來提高查詢性能呢?
訂單 ID 的二次查詢,仍然會帶來數據庫的壓力明顯上升,實際上訂單 ID 是不會更新的,訂單 ID 和主訂單 ID 的映射關係也是不會發生變化的,完全可以把訂單 ID 索引表的信息緩存起來,每次查詢時從緩存中就可以獲取主訂單 ID。
我們設計了多級緩存來實現查詢加速,所有的緩存和分庫邏輯都封裝在組件中,提供給各個客戶端使用。三級緩存結構如下:
注:圖下方的數字代表在當前緩存和它的所有上級緩存命中率的總和。例如 Redis 的 99.5% 代表 1000 個訂單有 995 個在本地緩存或者是 Redis 緩存中命中了。
客戶端本地緩存:將最熱門的訂單 ID 索引存放在應用的本地內存中,只需要一次內存操作就能獲取主訂單號,不需要進行額外的網絡 IO
Redis 分佈式緩存:將大量的索引信息存放在 Redis 中,並且所有客戶端可以共用 Redis 緩存,命中率超過 99%,並且由於訂單的映射關係是不會發生變化的,因此可以在生成訂單號的階段對緩存進行預填充
服務端本地緩存:對 DB 索引表的讀取,都是在特定的應用中實現,未命中緩存時客戶端是通過服務端獲取索引信息。服務端也有本地緩存,使用 Guava 實現用於減緩熱點 key 的流量尖刺避免緩存擊穿
3)本地緩存的內存優化
【問題】:使用本地緩存可以減少索引表查詢開銷,如果需要提高緩存命中率,就需要消耗更多的內存使用,那麼如何減少內存佔用的問題呢?
本地緩存的效率是最高的,存儲在本地的索引信息自然是越多越好。但本地內存是寶貴而有限的,我們需要儘量減少單個索引佔用的內存。訂單 ID 都是 Long 類型,每個 Long 類型佔用 24 個字節,通常情況下,單個索引中包含兩個 Long 類型, 還需要緩存內部的多層 Node 節點,最終單個索引大約需要 100 個字節。
我們主要是結合業務場景來改進內存的使用。訂單 ID 是有序的,而且主子訂單 ID 的生成時間是非常接近的,大部分情況下,主訂單 ID 和子訂單 ID 的數值差異是很小的。對於連續的數字,數組的方式是非常節省空間的,100 個 Long 類型佔用 2400 個字節,而一個長度爲 100 的 long 數組,則只佔用 824 個字節。同時不直接存儲主訂單 ID,而是隻存儲主子訂單 ID 的差值,從 long 類型縮減爲 short 類型,可以進一步減少內存佔用。
最終的緩存結構爲:Map<Long, short[]>。從而使整體的內存佔用減少了大約 93% 的存儲空間。也就意味着我們可以適當增加本地緩存的容量,同時減少內存的消耗。
改造後:
【Key】表示訂單 ID 所在的桶,計算方式爲訂單 ID 對 64(數組長度)取模
【下標】表示訂單 ID 的具體位置,計算方式爲訂單 ID 對 64(數組長度)取餘數,即【KEY】和【下標】合計起來表示訂單 ID
【偏移量】表示主訂單 ID 的信息,計算方式是主訂單 ID 減去訂單 ID
最優情況下,存儲 64 個索引只需要一個 Long 類型、一個長度 64 的 short 數組和約 50 個字節的輔助空間,總計 200 個字節,平均每個索引 3 個字節,佔用的內存縮減到原來的 100 個字節的 3%。
值得注意的是對於偏移量的設計仍然有一定的講究。我們需要分析主子訂單的差異區間範圍。Short 的取值範圍是 - 32768 ~ 32767,首先將 - 32768 定義爲非法值。我們還發現大部分的訂單分佈區間其實並沒有和這個取值範圍重疊,因此需要額外再給偏移量增加二次偏移量來優化這個問題,實際的取值範圍是:-10000 ~ 55534,進一步提高了 short 偏移量的覆蓋面。
4)主子訂單 ID 同餘
【問題】我們對訂單 ID 索引做了各種改進,使它運行的越來越順暢,但三級緩存的引入,也使得我們的系統結構變複雜,是否有辦法跳過索引表呢?
我們將未使用的訂單 ID 按餘數分成多個桶,新增訂單在拆分訂單時,子訂單 ID 不再是隨機生成,而是按照主訂單 ID 的餘數確定對應的桶,然後只允許使用這個桶內的訂單 ID,即保證主訂單 ID 和子訂單 ID 的餘數是相同的。在查詢時,子訂單 ID 直接取餘數就能確定對應的分庫,不需要讀取訂單索引。
再進一步,生成主訂單 ID 時也不再是隨機選擇,而是基於用戶 ID 來分桶和選擇,做到一個 UID 下的訂單會盡量集中到單一分庫中。
用戶 ID / 主訂單 ID / 子訂單 ID 三者同餘
2.3.2 跨分片查詢優化
數據分庫後,當查詢條件不是分片鍵時,例如使用用戶 ID、更新時間等作爲查詢條件,都需要對所有分片進行查詢,在 DB 上的執行次數會變爲原來的 64 倍,消耗的 CPU 資源也會急劇放大。這是所有分庫分表都會遇到的問題,也是一個分庫項目最具有技術挑戰的環節。我們針對各種場景,採取多種方式來進行優化。
1)UID 索引表
【問題】UID 是除了訂單號以外消耗資源最多的查詢之一,大約佔用大約 8% 的數據庫使用資源。使用 allShards 查詢會消耗非常多的資源,嚴重降低查詢性能。那麼我們如何對 UID 查詢進行優化,從而提升查詢效率呢?
索引表是一種常見的解決方案,需要滿足三個條件:
-
索引字段不允許更新
訂單庫中用戶 ID 是不會被更新的
-
單個字段值關聯的數據要少,或者關聯的分庫數量少
關聯的數據過多,最終還是到所有分庫中獲取數據,也就失去了索引表的意義。對於我們的業務場景來說,用戶購買機票是一種較爲低頻的行爲。因此,大部分用戶的訂單數量相對有限,平均每個用戶的訂單涉及的分庫數量遠小於所有分庫數量。
-
查詢頻率要足夠高
索引表本質上是一個 “空間換時間” 的思路,只有足夠高的查詢頻率,有足夠的收益,才值的實現索引表。
以用戶 ID 作爲條件的查詢,是業務中非常重要的一類查詢,也是排除訂單 ID 查詢後,最多的一類查詢。基於業務和現有數據來分析,由於單個用戶購買機票的總數並不是很多,用戶 ID 分佈在了有限的分庫上。我們增加一個用戶 ID 索引表,存儲用戶 ID 與訂單 ID 的映射信息,並按照用戶 ID 進行分庫存儲。如下圖,每次用戶 ID 的查詢,會先查詢索引,獲取包含此用戶訂單的所有分庫列表,通過一次額外的查詢,能夠快速排除大量無關的分庫。再結合前面提及的用戶 ID 與訂單 ID 同餘的策略,單個用戶 ID 的新增訂單會集中存儲在單一分庫中,隨着歷史數據的逐步歸檔,單個用戶查詢的分庫數量會越來越少。
UserIDIndex 表結構
2)鏡像庫
【問題】:並不是所有的查詢都可以像用戶 ID 一樣,通過建立一個二級索引表來優化查詢問題,而且建立二級索引表的代價比較大,我們需要一個更通用的方案解決這些查詢問題。
AllShards 查詢中的另一類查詢就是時間戳的查詢,尤其是大量的監控查詢,大部分請求是可以接受一定的延遲,同時這些請求只是關注熱點數據,比如尚未被使用的訂單。
我們新建了一套 MySQL 數據庫,作爲鏡像庫,將 64 個分庫中的熱點數據,集中存儲到單一數據庫中,相關的查詢直接在鏡像庫中執行,避免分庫的問題。
鏡像庫的數據同步,則是通過 Canal+QMQ 的方式來實現,並定時對比數據,業務應用上則是隻讀不寫,嚴格保證雙邊數據一致性。
3)ES/MySQL 對比
【問題】:鏡像庫存在多種實現方案,很多系統採用了 ES 作爲查詢引擎,我們該如何選擇?
ES 也是解決複雜查詢場景的一種常見方案,我們曾經考慮採用 ES 來提升查詢性能,並且進行了詳細的評估和測試,但最終放棄了 ES 方案,主要考慮到以下幾點原因:
-
項目前期對所有的查詢進行了充分簡化和規整,目前所有的查詢使用 MySQL 都可以很好的運行。
-
在已經正確建立索引和優化 SQL 語句的情況下,MySQL 消耗的 CPU 可能遠小於 ES,尤其是訂單 ID、時間戳等數字類型的查詢,MySQL 消耗的 CPU 只是 ES 消耗的 20% 甚至更低。
-
ES 並不擅長數字查詢,而是更合適索引字段多變的場景。
因此具體採用 ES 還是 MySQL,或者是其他數據庫來建立鏡像數據庫,最重要的一點還是要基於現有的業務場景和實際生產上的需求進行綜合分析和驗證後,找出一個最適合自己當前情況的方案。
2.3.3 雙寫組件設計
因爲技術棧的問題,目前我們的 ORM 採用的是公司的 DAL 組件。這個組件本身對公司的環境支持較好,而且該組件對於 Sharding 數據庫也提供了良好的支持。因此我們在該項目上仍然使用 DAL 作爲我們數據庫的訪問組件。
但是原生的 DAL 並不支持雙寫的功能,不支持讀寫的切換。針對項目的特性,我們需要儘可能的讓開發少感知或者不感知底層數據庫的雙寫和讀寫切換的操作。一切對於用戶來說變得更簡單、更透明。另一方面,我們打算優化組件本身的使用接口,讓用戶使用起來更傻瓜化。
組件的升級改造需要符合以下原則:
-
對業務代碼侵入少
-
改造少,降低工作量
-
使用簡單
-
符合直覺
這些改造的意義是非常重大的,它是我們能夠高質量上線的關鍵。於是我們對組件進行了一些封裝和優化。
1)業務層對象和數據庫層對象進行隔離
爲了統一維護方便我們將團隊內所有的數據庫對象(Pojo)都維護在了公共組件中。因此,在公共 jar 包中生成的對象通常是一個大而全的數據庫實體。這種大而全的實體信息存在以下幾個問題:
-
單表查詢時直接只用 pojo 返回了全量信息,影響查詢性能
-
直接在代碼中使用 pojo 帶來了大量無用的字段,不符合按需使用的原則
-
很難統計應用對於數據庫字段的依賴的問題
-
數據庫字段和代碼直接耦合,在代碼編寫期間不能對字段的命名等問題進行優化
爲了解決以上問題,我們中間新增了 Model 層,實現數據庫 pojo 和業務代碼的隔離。例如我們的航班信息表(Flight)有 200 多個字段,但是實際在代碼中僅需要使用航班號和起飛時間。我們可以在業務代碼中定義一個新的 FlightModel,如下圖所示:
@Builder
public class Flight implements DalDto {
/**
* 訂單號
*/
private Long orderId;
/**
* 航班號
*/
[DalField=”flight”]
private String flightNo;
}
擴展組件將該對象映射到數據庫的 Pojo 上,並且可以改變字段的命名甚至類型從而優化代碼的可讀性。在數據庫查詢時也進行了優化,僅僅查詢必要字段,減少了開銷。
2)雙寫功能
我們實現的雙寫方案是先寫 SQLServer 再寫 MySQL,同時也實現了失敗處理相關的策略。
雙寫模式包括:
- 異步雙寫
這個主要是在雙寫功能實現的初期,我們會使用隊列 + 異步線程的方式將數據寫入到 MySQL。採取這種方式的數據一致性是比較差的,之所以採用這種方式也是在初期我們對數據庫處於探索階段,避免 MySQL 數據庫故障對當前系統產生影響。
- 同步雙寫
當 SQLServer 寫入成功後,在相同的線程中對 MySQL 進行寫入。這種模式相對來說數據一致性會比較好,但是在極端情況下仍然可能存在數據不一致的情況。
如下圖所示。當任務 1 更新 MySQL 數據庫之前,如果有別的任務搶先更新了相同的數據字段就有可能產生髒寫的問題。
我們可以通過以下手段減少數據不一致的問題:
-
數據表的讀寫儘可能收口
-
訪問收口以後,通過對業務系統增加分佈式鎖等手段緩解此類問題的產生
-
可以增加數據比對的工具,主動發現數據的不一致並進行修復,通過一個異步的掃描時間戳的工具來主動進行數據對比注和修復
-
寫入失敗需要根據當前的模式觸發自動補償的策略,這個可以參考下文提到的數據雙寫異常的補償方案
注:數據對比和補償需要注意熱點數據頻繁更新和由於讀取時間差導致的不一致的問題
剛纔提到我們抽象了 Model 層的數據,在此基礎上,我們的雙寫改造對用戶來說非常的容易。
@DalEntity(primaryTypeName = "com.ctrip.xxx.dal.sqlserver.entity.FlightPojo",
secondaryTypeName = "com.ctrip.xxx.dal.mysql.entity.FlightPojo")
@Builder
public class Flight implements DalDto {
我們僅需在 Model 對象上增加 DalEntity 註解實現數據庫 Pojo 的雙邊映射。除此之外,開發人員不需要對業務代碼做其他調整,即可以通過配置實現雙寫、數據源切換等操作。
3)雙寫異常處理模式
雙寫時,我們需要儘可能保證數據的一致性,對於 MySQL 數據寫入異常時,我們提供了多種異常處理模式。
- AC
異步雙寫時,如果從庫發生異常進行數據捕獲,不拋出異常,僅輸出告警信息
- SC
同步雙寫時,如果從庫發生異常進行數據捕獲,不拋出異常,僅輸出告警信息
- ST
同步雙寫時,如果從庫發生異常,拋出異常,中斷處理流程
4)雙讀功能
雙寫功能相對比較好理解。在灰度切換過程中,假如存在灰度控制的訂單 A 以 SQLServer 爲主,訂單 B 以 MySQL 爲主。但是我們查詢到結果中同時包含了訂單 A 和訂單 B 的場景。這個時候我們希望的是,同時查詢 SQLServer 和 MySQL 的數據源,並且從不同數據源中獲取相應的訂單數據,然後進行組合、排序、拼接。這些篩選邏輯由我們的組件來自動完成,從而實現了更加精細的灰度控制。
值得注意的是 allShard 查詢的結果在部分情況下(例如分頁查詢)和單庫查詢的結果存在較大的差異,也需要組件的支持。
5)數據寫入異常的補償方案
我們需要在不同階段設計不同的補償方案。初期 MySQL 的數據並不會對外提供服務,即使數據寫入失敗,也不能影響系統流程的正常運行,同時也要保證數據寫入的準確性。因此,我們採用了 SC 的異常處理模式,並且增加了主動和被動的數據補償。
但是我們的目標是使用 MySQL 的數據。因此,當主數據源需要 SQLServer 切換到 MySQL 後,雖然數據庫寫入的順序仍然保持先寫 SQLServer 再寫 MySQL,但是數據寫入失敗的處理模式需要發生變化。
這裏先插播一個問題,就是爲什麼不能先寫 MySQL 然後同步更新 SQLServer。主要考慮到以下兩個因素:
- 數據庫主鍵生成的歷史遺留原因
由於 MySQL 是 Sharding 數據庫,如果先插入該數據庫,默認情況下會通過雪花算法生成主鍵。寫入完成後,我們將該主鍵同步給 SQLServer。
但是受到公司 ORM 框架和歷史遺留的技術限制,SQLServer 不會使用該數據,仍然採用自增的方式生成主鍵。導致數據嚴重不一致。
- 數據雙向同步的複雜度問題
當我們以 SQLServer 作爲主數據庫時,如果數據不一致需要同步給 MySQL(異步存在延時);當以 MySQL 作爲主數據庫時,如果發生數據不一致,需要進行反向同步。
一來,數據補償程序複雜度很高。二來,如果我們如果在 MySQL 和 SQLServer 數據庫誰作爲主庫之間切換頻繁,數據同步程序就會變得非常迷茫,到底誰該同步給誰?
那麼如何提高在以 MySQL 爲主的情況下,雙邊數據庫的一致性呢?
首先,我們得關閉自動補償功能,異常處理模式需要從 SC 切換到 ST,遇到 MySQL 失敗直接拋出數據庫異常,然後基於系統的業務場景進行如下操作:
- 依賴業務系統的自動補償
對於訂單處理系統,大部分的流程其實具備了自動補償的能力,因此哪怕 SQLServer 更新成功,MySQL 未成功。下次補償程序仍然讀取 MySQL,SQLServer 會被二次更新,從而達到最終一致性。
這個時候,需要考慮的 SQLServer 的可重入性。
- 無法自動補償的場景,提供手工數據補償的功能
因爲此時 MySQL 已經作爲主要數據源,如果 SQLServer 存在不一致的場景可以提供手工的方式將數據補償回 SQLServer。這邊沒有實現自動補償,因爲理論上只有在數據不一致的場景,並且發生了回切纔會產生影響。
- 數據的比對功能仍然正常開啓,及時發現數據的不一致
- 組件設計的功能和策略分離
我們整體的功能都整合在名爲 Dal-Extension 的系統組件裏,主要分爲功能實現和策略兩大部分。
功能就是前面提到的例如雙寫,讀切換,異常處理模式切換等。策略就是引擎,它實現了功能和功能間的聯動。例如上文提到的,如果以 SQLServer 作爲主數據源,那麼系統自動採用 SC 的異常處理模式,並且主動調用數據補償功能。如果是以 MySQL 作爲主數據源,那麼系統自動切換到 ST 的異常處理模式。
相較於基於應用、表維度的切換策略。我們提供了維度更豐富的切換組合策略。
-
表
-
應用 / IP 地址
-
讀 / 寫
-
訂單區間
通過對以上維度的配置進行靈活調整,我們即可以實現單表,單機器的試驗性切換控制,也可以進行全鏈路的灰度切換,確保一個訂單在整個訂單處理生命週期使用相同的數據源,從而避免因爲數據雙寫或者同步導致的數據讀取結果不一致的問題。整體的數據切換操作由配置中心統一託管。
2.3.4 分片故障處理
原先的數據庫如果發生了故障,會導致整個系統不可用。但是新的數據庫擴展成 64 個分片後,其實相對來說故障概率提高了 64 倍。因此,我們需要避免部分分片故障導致整個系統失效的情況。另外增加故障轉移和隔離功能,避免故障擴散,減少損失也是我們重點關注的功能。
(當然,如果發生分片故障,首選的故障恢復方案是數據庫的主從切換)。
1)返回僅包含查詢成功分片的部分數據
【問題】針對跨分片查詢的場景,如果一個分片故障默認情況下會導致整個查詢失敗,那麼如何提高查詢成功率呢?
我們調研了數據使用端,發現有很多場景,例如人工訂單處理的環節,是可以接受部分數據的返回。也就是說有查詢出盡可能多符合條件的訂單,放入人工待處理列表中。我們增加了 continueOnError 參數來表示當前查詢可以接受部分分片失效的場景。並且,系統返回了查詢結果後,如果存在分片查詢失敗的場景,系統會提供了錯誤分片的信息。這樣業務上不僅能夠確保了很多業務環節處理不中斷,同時針對它提供的錯誤分片信息可以讓我們快速感知失效的分片,以便系統自動或者人工對這些分片進行干預。
2)故障分片隔離
【問題】當故障分片出現大規模錯誤後,如果是因爲響應時間長會導致大量線程 block,從而拖累整個應用服務器。那麼如何解決此類問題呢?
當分片發生故障時,有可能我們的數據庫請求被 hang 住。我們 allShards 查詢的底層實現是基於共享線程池。當部分分片的響應慢時,會拖累整個線程池。另外單表查詢時,也可能會因爲數據庫響應時間的問題導致工作線程數量上漲的問題。
我們爲此增加了分片屏蔽的參數。當我們啓用分片臨時屏蔽功能後,底層數據庫查詢時,發現該分片被屏蔽直接拋出異常,讓應用程序能夠得到快速響應。從而避免了網絡和數據庫訪問時間消耗,提高了異常執行的效率,避免問題擴散到正常的分片的數據處理。
3)故障訂單轉移
【問題】根據之前的介紹,用戶訂單號是根據 UID 的哈希值進行分配的。也就是說同一個用戶分配的分片是固定的。如果該分片故障時,用戶就無法提交訂單。那麼如何避免或者減少此類問題呢?
如上圖所示,用戶 ID_1 和用戶 ID_2 根據哈希算法,原先會在分片 1 上生成訂單。但是如果發生了分片 1 故障時,我們的 UID 分片計算組件會將分片 1 標記爲不可用,然後通過新的 Hash 算法計算出新的分片。
這裏需要注意的是,新 hash 算法的選擇。
方法 1:
使用同樣的哈希算法,但是生成結果後取模的值爲 63(64-1),但是這個存在的問題是用戶 ID_1 和用戶 ID_2 計算出來的分片結果是一致的。假如新的分片號爲 2 的話,如果發生分片 1、分片 2 同時失效的情況下。那麼仍然有 1/64 的訂單出現問題。
方法 2:
採用新的哈希算法,儘量使訂單分佈在出了分片 1 以外的其他分片上。那麼這種方法,即使分片 1、分片 2 同時失效。那麼僅僅會影響到 1/64 * 1/63 的訂單。受影響的訂單量大幅降低。
三、項目規劃
除了以上提到的技術問題以外,我們再談談項目的管理和規劃問題。首先,圈定合理的項目範圍,劃清項目邊界是項目順利實施的重要前提。這個項目的範圍包括兩個重要的屬性:數據和團隊。
數據範圍
1)劃定數據表範圍,先進行表結構優化的工作
我們需要在項目初期明確數據表的範圍,針對一些可以下線的表或者字段,先完成合並和下線的工作,來縮小項目範圍。避免表結構的變化和該項目耦合在一起,造成不必要的困擾。
2)相關數據表中哪些數據需要被遷移
我們在處理這個問題上,有一些反覆。
方案 1:僅遷移熱數據
因爲訂單數據分爲冷熱數據,所以我們最開始考慮是不是隻要遷移熱數據就好了,冷數據僅保留查詢功能。
但是,這個方案有兩個很大的問題:一是存在冷數據需要被還原到熱數據的場景,增加了系統實現的複雜度。二是冷數據保留時限的問題,無法在短時間內下線這個數據庫。
方案 2:部分數據自然消亡的表和字段不進行遷移
針對有一些表由於業務或者系統改造的原因,可能後續數據不會更新了,或者在新的訂單上這些字段已經廢棄了。大家在設計新表的時候其實往往很不喜歡把這些已經廢棄了的信息加到新設計的表中。但是,我們需要面臨的問題是,舊數據如何兼容是一個非常現實的問題。
因此,當我們開發到中間的過程中,還是將部分表和字段重新加了回來。來確保舊數據庫儘快下線以及歷史邏輯保持兼容。
方案 3:保留當前所有的表結構和信息
我們最後採用了這個方案,哪怕這個數據表或者字段未來不會做任何修改。
團隊範圍
確定好數據範圍後,我們需要根據這些數據,確定我們需要做的工作以及找到完成這些工作的相關團隊並提前安排好資源。整個項目的資源分爲核心成員和相關配合改造的團隊。
核心成員需要做到組織分工明確,並且需要經常一起頭腦風暴,提出問題,解決問題,消除隱患。核心成員的另一個職責是幫助配合改造的團隊,協調並且解決技術問題、資源問題等等。特別是涉及到的改動點較多、改造難度較大的團隊,需要提前介入,在適當的時候提供更多的幫助。
3.1 規劃
確定了項目的目標和範圍後,我們爲項目設計了 6 個里程碑,來幫助我們更好的完成這個項目。
階段 1:通過 API 對讀取進行收口
這個階段雖然難度並不大,但是週期很長,溝通成本較高。在這個階段重點在於任務的協調和跟進。DBA 幫助我們研發了生產 Trace 查詢的工具,能夠準實時的知道數據表的訪問情況,幫我們快速驗收並且圈定改造範圍。
我們建立了任務的看板,爲每一個任務設定了負責人以及預期解決的時間,定期對任務進行進行跟蹤。項目的負責人也作爲驗收人,確認每個任務的完成情況。
通過一段時間的努力,數據庫的訪問收口在極少數內部應用當中。實現了數據訪問的收口。
階段 2:開發雙讀 / 雙寫功能來實現平滑的數據切換
這個階段需要將整個項目的技術點、難點都逐一的找到,並且給出解決方案。如何提高效率和質量也是這個階段重點關注的話題,我們儘量把這些雙寫、切換的功能進行封裝,讓業務邏輯層儘可能少感知,或者不感知這些底層邏輯。降低代碼開發量,不僅能提高效率,還能提升質量。
總的來說,這個階段需要提升開發效率,提高開發質量並且降低項目風險。
階段 3:驗證數據一致性
這個是對階段 2 的驗證工作,需要注意的是在驗證中減少噪音,提高驗證的自動化率,能有效的提升項目的開發質量。
**階段 4:通過壓測,故障模擬等手段驗證系統性能。**在數據庫故障時,提供可靠的系統的災備和故障隔離能力。
階段 5:數據讀取從 SQLServer 切換到 MySQL
這個階段可能不需要有太多的資源投入,但是風險卻是最大的。這個階段是對前面所有階段成果的驗收。做好數據監控、制定良好的切換方案、出現問題時能夠回退是這個階段順利實施的重點。
階段 6:停止 SQLServer 寫入並且下線相關數據表
相比起階段 5,階段 6 沒有後悔藥。一旦停止了 SQLServer 的寫入,就非常難進行回切的操作。所以得仔細做好白名單的驗證,並且及時響應和解決相關問題。
3.2 原則
整個項目週期較長,我們需要制定好每個階段的目標,每個任務的目標。由於數據庫承載了非常核心的業務,因此整個階段、所有任務以及技術方案其實圍繞着一個原則展開,就是降低風險。
所以我們在設計每個技術方案的時候,儘可能考慮這點。例如在數據源切換的開關雖然涉及較多的服務實例,但是我們通過一個集中控制的平臺,來實現全鏈路的切換和灰度控制。
四、經驗分享
該項目整體的週期較長,每個階段的挑戰不盡相同。爲了確保項目的上線質量,後續在讀切換、寫切換兩個流程的灰度時間比較久。項目大約在 2021 年下半年順利完成。
實現了以下主要目標和功能:
- 系統的水平擴展能力得到大幅提升
系統分片數量爲 64,部署在 16 臺物理機上。後續根據業務需要機器的部署方式和分片數量可以進行靈活調整。
- 數據庫資源利用率大幅下降,可靠性提升
數據庫服務器的 CPU 利用率從高峯期 40% 下降到目前的 3%-5% 之間。
- 訂單處理能力提升和存儲能力提升
原先區分冷熱數據,熱數據大約僅能支持 3 個月的訂單,按照現在硬件資源推算,系統可以處理至少 5 年以上的訂單。
- 數據訪問收口
原先近 200 個應用直接訪問數據庫,給我們的改造帶來很大的不便。目前僅有限的內部應用允許直接訪問訂單庫。
- 整體成本下降
原先主從服務器的 CPU 爲 128 核,內存 256G;現在服務器縮減爲 40 核心的標準配置。
在項目過程中也積累了不少的經驗,例如:
- 項目的規劃要清晰,任務要明確,跟蹤要及時
整個項目中大約建立了數百個子任務,每一個任務需要落實負責人以及上線時間,並對上線結果進行驗收。才能確保整個項目的週期不至於拉的非常長,減少後續的項目返工和風險。
- 減少例外情況的發生
當一個大型的項目存在非常多的例外情況,這些特殊情況就得特殊處理,那麼到最後總會有一些沒有處理乾淨的尾巴。這些問題都是項目的潛在隱患。
- 減少項目的依賴
這個和我們日常開發關係也非常密切,當一項任務有多個依賴方的時候,往往項目的進展會大幅超出我們的預期。因此減少一些前置依賴,在不是非常確定的情況下。我們得先做好最壞的打算。
- 一次幹好一件事
很多時候我們往往會高估自己的能力,例如在這次的改造中,我們會順便優化一些表的結構。於是造成了 MySQL 和 SQLServer 的數據表差異過大的問題。那麼這些差異其實爲後面的開發造成了不小的困擾。所有的方案,包括數據補償、遷移、數據源的切換等等場景都得爲這些特殊差異的表單獨考慮方案,單獨實現邏輯。一不留神或者沒有考慮的很周全的情況下,往往會漏掉這部分的差異。導致項目返工,甚至出現生產故障。
項目的成功上線離不開每一個成員的努力。在實施過程中,遇到的問題比這篇文章列舉的問題多得多,很多都是一些非常瑣碎的事情。特別是項目初期,我們往往是解決了一個,冒出了更多的問題。但是每次遇到問題後,團隊的成員都積極思考,集思廣益,攻破了一個又一個的技術問題和業務問題。通過一年多時間的鍛鍊,團隊成員的項目能力、技術能力進步顯著;發現問題的角度更敏銳,思考的角度更全面;團隊的凝聚力也得到了明顯提升。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/Bwwf-brPCmtDMBtdsfWmdA