
一、背景
隨著機票訂單業務的不斷增長,當前訂單處理系統的架構已經不能滿足日益增長的業務需求,系統性能捉襟見肘,主要體現在以下方面:
-
資料庫CPU資源在業務高峰期經常達到50%以上,運行狀況亮起了黃燈;
-
磁盤存盤空間嚴重不足,需要經常清理磁盤資料騰挪可用空間;
-
系統擴容能力不足,如果需要提升處理能力只能更換配置更好的硬體資源,
因此我們迫切需要調整和優化機票訂單資料庫的架構,從而提升訂單系統的處理性能,通過建立良好的水平擴展能力,來滿足日益增長的業務需求,為后續系統優化和支撐10x訂單量的增長打下良好基礎,
1、存盤架構的演進
我們選擇一個新的系統架構,應該基于當下面臨的問題,綜合成本、風險、收益等多方面因素,選擇出的最合適的方案,機票訂單庫的架構演進也不例外,
我們最開始接觸機票訂單資料庫時,它是一個非常龐大的資料集合,所有的訂單業務全部都集中一個資料庫上,因此整體BR非常高,同時,我們的SQL陳述句也非常復雜,混雜著很多歷史遺留下來的存盤程序,可想而之,整個資料庫當時的壓力巨大,維護成本居高不下,DBA每天的作業也非常忙碌,想方設法降高頻,解決慢SQL等線上問題,生產偶爾也會因為某些沒有review的SQL導致資料庫短暫的停止服務,
初期,我們采用了最常見的幾種手段進行優化,包括:
-
索引優化
-
讀寫分離
-
降高頻
雖然手段比較常規,通過一段時間的治理,訂單庫的穩定性也得到了一定的增強,總體實施成本較低,效果也是立竿見影的,
隨著時間的推移和資料的積累,新的性能瓶頸逐漸顯露,我們再次對系統進行了升級,對資料庫架構做了改進,主要包括以下幾個方面:
1)垂直拆分

訂單庫根據業務屬性拆分成了多個資料庫
基于業務對資料庫進行垂直拆分在很大程度上提高了系統的可靠性和可維護性,一個上百人的團隊,同時對一套資料庫進行維護,對于發布變更來說是一種煎熬,同時也存在很大的風險,當一個非核心鏈路上的發布出現了問題,例如某些操作導致了鎖表或者占用過多的系統資源,其他關鍵鏈路的資料庫訪問都會因此受到影響,
我們根據不同的業務場景,例如:訂單管理系統、出票、退票、改簽等業務,將資料庫進行垂直拆分,使各自業務系統資料隔離,減少相互的影響,這些拆分的資料庫,可以根據不同性能要求,靈活調整資料庫的部署方式,來降低總體成本,
2)水平拆分(冷熱資料分離)

通常來說,當航班過了起飛時間并且用戶已經使用了當前機票,那么我們認為該訂單服務已經完成,后續訂單資料發生改變的可能性很小,于是會將該資料遷移到一個具有相同結構的冷資料庫中,該資料庫僅提供查詢功能,不提供修改功能,但是我們發現少數場景仍然需要對這些資料進行修改,于是我們開發了一套資料還原功能,將處于冷資料庫中的資料,還原到熱資料庫中,然后再進行操作,
我們當時采用的資料庫和資料結構是完全一致的,這樣做備份和還原、查詢會比較方便,其實也可以采用其他型別的資料庫,例如Mongo等,在讀取性能和使用成本等方面可能會更具優勢,
這次升級同樣解決了不少問題,使資料庫的穩定性得到了很大的增強,
2、基于冷熱資料分離的適用性
雖然基于冷熱資料的分庫方案,在目前來看遇到了瓶頸,但是我認為它是一個非常值得借鑒的方案,我們現在仍然有大量的業務系統資料庫采用這種方案對資料進行拆分,它不僅實施簡單,同時運維成本也相對較低,
1)優勢
-
功能簡單
-
實施成本低
2)局限性
-
資料冷處理的規則應該相對簡單,不應該經常發生變化;
-
熱資料的膨脹需要受到限制,否則熱資料的量一旦累積過多,性能瓶頸仍然會出現;
-
需要額外的查詢來找到訂單所處的位置(冷/熱資料庫);
-
因為冷資料量龐大,冷資料的查詢能力、表結構調整能力都收到了限制,不能進行復雜的業務查詢操作,
根據我們的系統規劃,在當下或者可預見的未來滿足以上提到原因中的多個,那么就得謹慎選擇采用此方案,或者在改方案的基礎上進行優化,
正是由于我們目前的業務場景恰好命中了上面列舉的所有問題,我們才需要對這個架構進行進一步調整,選擇一個更好的水平擴展的方式,解決當前系統面臨的問題,
3、當時面臨的主要問題
從2019年開始,我們就開始著手研究和規劃訂單資料庫sharding專案,當時主要面臨如下問題:
1)訂單的存盤要求
受制于當前訂單資料庫架構的限制以及機票業務的特殊性(通常不超過2年的處理生命周期),改造前的訂單資料庫僅能夠支持2年的訂單存盤,超過2年,我們會將資料進行歸檔,用戶和員工都無法通過在線查詢的方式獲取訂單資訊,
但是基于以下幾個方面原因,原本2年的存盤和處理周期已經不能滿足客戶和業務的需要:
-
從客戶的角度出發,仍然有查詢歷史訂單的需求;
-
業務場景的拓展導致機票訂單整個服務周期變長,
原先機票使用完成(出行)一段時間后就可以視為服務結束,大部分訂單3個月后就不會發生變化,但是由于新業務的推出,熱點資料查詢和處理周期明顯變長,
2)系統架構瓶頸
① 熱資料膨脹
熱資料原本僅千萬級別,由于業務的變化熱資料數量不斷膨脹,
② 冷資料量龐大
由于訂單存盤周期拉長和訂單量的增長,冷資料的數量也不斷攀升,冷資料庫查詢性能不斷下降;索引調整也變得非常困難,經常出現修改失敗的場景,
③ 資料庫高峰期BR達到了10w+
④ 系統存盤了20TB的資料,磁盤使用率達到80%以上,經常觸發使用容量告警
⑤ 主庫的CPU使用率高峰期接近50%
⑥ 由于采用了讀寫分離的架構,當主庫的服務器的性能受到影響的時候,AG延遲變得非常高,偶爾達到分鐘級,有的時候甚至更長,
主從同步的延遲,導致了資料新鮮度的降低,我們之前的ORM層封裝了一個新鮮容忍度的引數,當不能滿足新鮮度要求的時候,讀取會切換到主庫,從而進一步加重了主庫的負擔,
因此,訂單庫的整體性能壓力非常大,如果想快速解決性能問題,只能對機器進行擴容,但是由于資料庫本身就是消耗資源大戶,CPU和記憶體消耗非常高,只能通過進一步提高資料庫的硬體配置來解決問題,因此整體升級的成本居高不下,另外硬體升級完成后,SQLServer的授權成本可能也會進一步提升,
為了徹底解決以上問題,我們計劃通過優化架構來提升系統的水平擴展能力,從而進一步提升我們系統的性能和服務水平,
二、專案目標和實施方案
1、目標
基于上文提到的這些問題,為了確保系統能夠長久持續的穩定運行并且提升訂單系統的處理能力,我們計劃對資料庫的架構進行升級,總體實作以下目標:
-
訂單的存盤和處理周期至少達到5年;
-
提升訂單系統的處理能力,支撐訂單QPS10倍的規模增長;
-
在提升系統性能的前提下,降低總體成本;
-
提高系統的水平擴展能力,通過簡便的操作可以快速擴容以應對長期的業務增長,
我們希望通過1-2年的時間,實作對資料庫架構升級改造以及完成SQLServer遷移到MySQL的目標,
2、架構改造
1)新舊架構的對比
① 舊系統架構

架構說明:
-
訂單資料庫
為熱資料的主庫,提供讀寫功能
-
訂單資料庫 Slave
為熱資料的從庫,在保障新鮮度的前提下提供只讀功能,采用SQLServer的AlwaysOn技術
-
訂單備份資料庫
為冷資料庫(沒有主從之分),僅提供只讀功能
-
Archive
備份機制,根據業務的需要,為了緩解主資料庫的壓力,對于符合條件的訂單(通常時起飛后+已出行)定時遷移到冷資料庫的操作
-
Restore
還原機制,在一些特殊情況下,需要對已經備份的資料進行修改,我們需要將資料從冷資料庫中恢復到熱資料庫然后才能進行操作,這個操作叫做還原,
當前存在的問題:
-
資料量變多,業務場景變得復雜后,主庫的資料量從千萬級增長到億級別,對資料庫的性能產生明顯影響
-
冷資料庫的歷史累計資料量也在不斷膨脹,受到本地SSD磁盤容量的限制,磁盤空間使用率達到了80%以上
-
冷資料庫的訂單數量達到了十億級,資料庫索引調整,結構調整變得較為困難;查詢性能也受到了很大的影響
-
備份和還原邏輯需要根據業務要求不斷調整
② 新系統架構

架構說明:
-
訂單資料庫Shard Cluster
新資料庫基于訂單號將資料水平拆分成64個分片,目前部署在16臺物理機上
-
訂單聚合資料庫
針對熱點資料,通過Binglog和有序訊息佇列同步到訂單聚合資料庫,方便資料監控,并且用于提高資料聚合查詢的性能
2)新舊架構的差異
新舊系統的主要差別包括:
-
新資料庫的拆分維度從冷熱資料變更成了根據訂單號進行水平拆分
-
資料庫從1拆2,變成了1拆64解決了磁盤存盤空間不足的問題
-
新資料庫的部署方式更加靈活,如果16臺物理機器資源不足時,可以通過增加服務器的數量快速提高資料庫的處理性能
-
如果64個分片的數量不足時,可以通過調整分片計算的組件功能,擴展分片數量
-
原先的SQLServer采用的一主多從一DR的模式進行配置,當前系統每個分片物理服務器采用一主一從一DR的模式進行配置
-
通過增加訂單聚合資料庫將部分跨分片的資料通過Binglog+有序訊息的方式聚合到新的資料庫上,降低跨分片查詢帶來的性能損失
3、技術方案
在專案執行程序中有非常多的技術細節問題需要分析和解決,我們列舉一些在專案程序中可能會遇到的問題:
-
如何選擇分片鍵
-
如何解決跨分片查詢性能的損失
-
如何提高開發效率,降低專案風險
-
全鏈路的灰度切換方案
-
分片故障的處理方案
下面我們就選擇幾個典型的例子,來說明我們在專案程序中遇到的問題,以及解決這些問題的方案,
1)分片鍵選擇
分庫的第一步也是最重要的一步,就是選擇分片鍵,選擇的原則是:
-
分片鍵必須是不會被更新的欄位
-
各個分庫的資料量和讀寫壓力要均勻,避免熱點分庫
-
要盡量減少單次查詢涉及的分庫數量,降低DB壓力
分片鍵的選擇,是需要根據具體的業務場景來確定,對于訂單資料的拆分,常見的選擇是訂單ID和用戶ID兩個維度,這也是業內最常用的兩個分片鍵,我們最終采用的是主訂單ID,主要是基于四個因素:
-
90%的請求都是基于訂單ID進行查詢
-
主訂單ID是對應于用戶的一個訂單,包含多個行程和貴賓休息室等附加產品,后臺會可能將這些拆分為多個子訂單,而子訂單之間會做Join等關聯處理,所以不能選擇子訂單維度
-
一個主訂單可能關聯多個用戶ID,比如用戶A為用戶B購買機票,用戶B又可以自己為這個訂單添加值機的功能,一個訂單ID關聯了兩個用戶ID,從而使用用戶ID用作分片鍵會導致訂單分布在不同的分片
-
分銷商的訂單量非常大,按用戶ID分庫會導致資料不均衡
我們決定采用主訂單號作為分片鍵后,進行了下列改造,用于實作并且加速分片選擇的程序,
① 訂單ID索引表
-
如何獲取主子訂單對應的分片ID?
按主訂單ID分庫,首先產生的問題是子訂單ID如何計算分庫,需要查詢所有分庫么?我們是采用異構索引表的方式,即創建一個訂單ID到主訂單ID的索引表,并且索引表是按訂單ID進行分庫,每次查詢訂單ID查詢時,從索引表中獲取對應的主訂單ID,計算出分庫,再進行業務查詢,避免查詢所有分庫,
② 索引表多級快取
通過索引表查詢分片ID會增加了查詢的二次開銷,使查詢性能損失嚴重,如何減少資料庫二次查詢的開銷來提高查詢性能呢?
訂單ID的二次查詢,仍然會帶來資料庫的壓力明顯上升,實際上訂單ID是不會更新的,訂單ID和主訂單ID的映射關系也是不會發生變化的,完全可以把訂單ID索引表的資訊快取起來,每次查詢時從快取中就可以獲取主訂單ID,
我們設計了多級快取來實作查詢加速,所有的快取和分庫邏輯都封裝在組件中,提供給各個客戶端使用,三級快取結構如下:

圖下方的數字代表在當前快取和它的所有上級快取命中率的總和,例如Redis的99.5%代表1000個訂單有995個在本地快取或者是Redis快取中命中了,
-
客戶端本地快取
將最熱門的訂單ID索引存放在應用的本地記憶體中,只需要一次記憶體操作就能獲取主訂單號,不需要進行額外的網路IO
-
Redis分布式快取
將大量的索引資訊存放在Redis中,并且所有客戶端可以共用Redis快取,命中率超過99%,并且由于訂單的映射關系是不會發生變化的,因此可以在生成訂單號的階段對快取進行預填充
-
服務端本地快取
對DB索引表的讀取,都是在特定的應用中實作,未命中快取時客戶端是通過服務端獲取索引資訊,服務端也有本地快取,使用Guava實作用于級訓熱點key的流量尖刺避免快取擊穿
③ 本地快取的記憶體優化
使用本地快取可以減少索引表查詢開銷,如果需要提高快取命中率,就需要消耗更多的記憶體使用,那么如何減少記憶體占用的問題呢?
本地快取的效率是最高的,存盤在本地的索引資訊自然是越多越好,但本地記憶體是寶貴而有限的,我們需要盡量減少單個索引占用的記憶體,訂單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偏移量的覆寫面,
④ 主子訂單ID同余
我們對訂單ID索引做了各種改進,使它運行的越來越順暢,但三級快取的引入,也使得我們的系統結構變復雜,是否有辦法跳過索引表呢?
我們將未使用的訂單ID按余數分成多個桶,新增訂單在拆分訂單時,子訂單ID不再是隨機生成,而是按照主訂單ID的余數確定對應的桶,然后只允許使用這個桶內的訂單ID,即保證主訂單ID和子訂單ID的余數是相同的,在查詢時,子訂單ID直接取余數就能確定對應的分庫,不需要讀取訂單索引,
再進一步,生成主訂單ID時也不再是隨機選擇,而是基于用戶ID來分桶和選擇,做到一個UID下的訂單會盡量集中到單一分庫中,

用戶ID/主訂單ID/子訂單ID三者同余
2)跨分片查詢優化
資料分庫后,當查詢條件不是分片鍵時,例如使用用戶ID、更新時間等作為查詢條件,都需要對所有分片進行查詢,在DB上的執行次數會變為原來的64倍,消耗的CPU資源也會急劇放大,這是所有分庫分表都會遇到的問題,也是一個分庫專案最具有技術挑戰的環節,我們針對各種場景,采取多種方式來進行優化,
① UID索引表
UID是除了訂單號以外消耗資源最多的查詢之一,大約占用大約8%的資料庫使用資源,使用allShards查詢會消耗非常多的資源,嚴重降低查詢性能,那么我們如何對UID查詢進行優化,從而提升查詢效率呢?
索引表是一種常見的解決方案,需要滿足三個條件:
-
索引欄位不允許更新
訂單庫中用戶ID是不會被更新的
-
單個欄位值關聯的資料要少,或者關聯的分庫數量少
關聯的資料過多,最侄訓是到所有分庫中獲取資料,也就失去了索引表的意義,對于我們的業務場景來說,用戶購買機票是一種較為低頻的行為,因此,大部分用戶的訂單數量相對有限,平均每個用戶的訂單涉及的分庫數量遠小于所有分庫數量,
-
查詢頻率要足夠高
索引表本質上是一個“空間換時間”的思路,只有足夠高的查詢頻率,有足夠的收益,才值的實作索引表,
以用戶ID作為條件的查詢,是業務中非常重要的一類查詢,也是排除訂單ID查詢后,最多的一類查詢,基于業務和現有資料來分析,由于單個用戶購買機票的總數并不是很多,用戶ID分布在了有限的分庫上,我們增加一個用戶ID索引表,存盤用戶ID與訂單ID的映射資訊,并按照用戶ID進行分庫存盤,如下圖,每次用戶ID的查詢,會先查詢索引,獲取包含此用戶訂單的所有分庫串列,通過一次額外的查詢,能夠快速排除大量無關的分庫,再結合前面提及的用戶ID與訂單ID同余的策略,單個用戶ID的新增訂單會集中存盤在單一分庫中,隨著歷史資料的逐步歸檔,單個用戶查詢的分庫數量會越來越少,

UserIDIndex表結構
② 鏡像庫
并不是所有的查詢都可以像用戶ID一樣,通過建立一個二級索引表來優化查詢問題,而且建立二級索引表的代價比較大,我們需要一個更通用的方案解決這些查詢問題,
AllShards查詢中的另一類查詢就是時間戳的查詢,尤其是大量的監控查詢,大部分請求是可以接受一定的延遲,同時這些請求只是關注熱點資料,比如尚未被使用的訂單,
我們新建了一套MySQL資料庫,作為鏡像庫,將64個分庫中的熱點資料,集中存盤到單一資料庫中,相關的查詢直接在鏡像庫中執行,避免分庫的問題,
鏡像庫的資料同步,則是通過Canal+QMQ的方式來實作,并定時對比資料,業務應用上則是只讀不寫,嚴格保證雙邊資料一致性,
③ ES/MySQL對比
鏡像庫存在多種實作方案,很多系統采用了ES作為查詢引擎,我們該如何選擇?
ES也是解決復雜查詢場景的一種常見方案,我們曾經考慮采用ES來提升查詢性能,并且進行了詳細的評估和測驗,但最終放棄了ES方案,主要考慮到以下幾點原因:
-
專案前期對所有的查詢進行了充分簡化和規整,目前所有的查詢使用MySQL都可以很好的運行,
-
在已經正確建立索引和優化SQL陳述句的情況下,MySQL消耗的CPU可能遠小于ES,尤其是訂單ID、時間戳等數字型別的查詢,MySQL消耗的CPU只是ES消耗的20%甚至更低,
-
ES并不擅長數字查詢,而是更合適索引欄位多變的場景,
因此具體采用ES還是MySQL,或者是其他資料庫來建立鏡像資料庫,最重要的一點還是要基于現有的業務場景和實際生產上的需求進行綜合分析和驗證后,找出一個最適合自己當前情況的方案,
3)雙寫組件設計
因為技術堆疊的問題,目前我們的ORM采用的是公司的DAL組件,這個組件本身對公司的環境支持較好,而且該組件對于Sharding資料庫也提供了良好的支持,因此我們在該專案上仍然使用DAL作為我們資料庫的訪問組件,
但是原生的DAL并不支持雙寫的功能,不支持讀寫的切換,針對專案的特性,我們需要盡可能的讓開發少感知或者不感知底層資料庫的雙寫和讀寫切換的操作,一切對于用戶來說變得更簡單、更透明,另一方面,我們打算優化組件本身的使用介面,讓用戶使用起來更傻瓜化,
組件的升級改造需要符合以下原則:
-
對業務代碼侵入少
-
改造少,降低作業量
-
使用簡單
-
符合直覺
這些改造的意義是非常重大的,它是我們能夠高質量上線的關鍵,于是我們對組件進行了一些封裝和優化,
① 業務層物件和資料庫層物件進行隔離
為了統一維護方便我們將團隊內所有的資料庫物件(Pojo)都維護在了公共組件中,因此,在公共jar包中生成的物件通常是一個大而全的資料庫物體,這種大而全的物體資訊存在以下幾個問題:
-
單表查詢時直接只用pojo回傳了全量資訊,影響查詢性能
-
直接在代碼中使用pojo帶來了大量無用的欄位,不符合按需使用的原則
-
很難統計應用對于資料庫欄位的依賴的問題
-
資料庫欄位和代碼直接耦合,在代碼撰寫期間不能對欄位的命名等問題進行優化
為了解決以上問題,我們中間新增了Model層,實作資料庫pojo和業務代碼的隔離,例如我們的航班資訊表(Flight)有200多個欄位,但是實際在代碼中僅需要使用航班號和起飛時間,我們可以在業務代碼中定義一個新的FlightModel,如下圖所示:
@Builder
public class Flight implements DalDto {
/**
* 訂單號
*/
private Long orderId;
/**
* 航班號
*/
[DalField=”flight”]
private String flightNo;
}
擴展組件將該物件映射到資料庫的Pojo上,并且可以改變欄位的命名甚至型別從而優化代碼的可讀性,在資料庫查詢時也進行了優化,僅僅查詢必要欄位,減少了開銷,
② 雙寫功能
我們實作的雙寫方案是先寫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的雙邊映射,除此之外,開發人員不需要對業務代碼做其他調整,即可以通過配置實作雙寫、資料源切換等操作,
③ 雙寫例外處理模式
雙寫時,我們需要盡可能保證資料的一致性,對于MySQL資料寫入例外時,我們提供了多種例外處理模式,
-
AC
異步雙寫時,如果從庫發生例外進行資料捕獲,不拋出例外,僅輸出告警資訊
-
SC
同步雙寫時,如果從庫發生例外進行資料捕獲,不拋出例外,僅輸出告警資訊
-
ST
同步雙寫時,如果從庫發生例外,拋出例外,中斷處理流程
④ 雙讀功能
雙寫功能相對比較好理解,在灰度切換程序中,假如存在灰度控制的訂單A以SQLServer為主,訂單B以MySQL為主,但是我們查詢到結果中同時包含了訂單A和訂單B的場景,這個時候我們希望的是,同時查詢SQLServer和MySQL的資料源,并且從不同資料源中獲取相應的訂單數據,然后進行組合、排序、拼接,這些篩選邏輯由我們的組件來自動完成,從而實作了更加精細的灰度控制,
值得注意的是allShard查詢的結果在部分情況下(例如分頁查詢)和單庫查詢的結果存在較大的差異,也需要組件的支持,
⑤ 資料寫入例外的補償方案
我們需要在不同階段設計不同的補償方案,初期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地址
-
讀/寫
-
訂單區間
通過對以上維度的配置進行靈活調整,我們即可以實作單表,單機器的試驗性切換控制,也可以進行全鏈路的灰度切換,確保一個訂單在整個訂單處理生命周期使用相同的資料源,從而避免因為資料雙寫或者同步導致的資料讀取結果不一致的問題,整體的資料切換操作由配置中心統一托管,
4)分片故障處理
原先的資料庫如果發生了故障,會導致整個系統不可用,但是新的資料庫擴展成64個分片后,其實相對來說故障概率提高了64倍,因此,我們需要避免部分分片故障導致整個系統失效的情況,另外增加故障轉移和隔離功能,避免故障擴散,減少損失也是我們重點關注的功能,
當然,如果發生分片故障,首選的故障恢復方案是資料庫的主從切換,
① 回傳僅包含查詢成功分片的部分資料
針對跨分片查詢的場景,如果一個分片故障默認情況下會導致整個查詢失敗,那么如何提高查詢成功率呢?
我們調研了資料使用端,發現有很多場景,例如人工訂單處理的環節,是可以接受部分資料的回傳,也就是說有查詢出盡可能多符合條件的訂單,放入人工待處理串列中,我們增加了continueOnError引數來表示當前查詢可以接受部分分片失效的場景,并且,系統回傳了查詢結果后,如果存在分片查詢失敗的場景,系統會提供了錯誤分片的資訊,這樣業務上不僅能夠確保了很多業務環節處理不中斷,同時針對它提供的錯誤分片資訊可以讓我們快速感知失效的分片,以便系統自動或者人工對這些分片進行干預,
② 故障分片隔離
當故障分片出現大規模錯誤后,如果是因為回應時間長會導致大量執行緒block,從而拖累整個應用服務器,那么如何解決此類問題呢?
當分片發生故障時,有可能我們的資料庫請求被hang住,我們allShards查詢的底層實作是基于共享執行緒池,當部分分片的回應慢時,會拖累整個執行緒池,另外單表查詢時,也可能會因為資料庫回應時間的問題導致作業執行緒數量上漲的問題,
我們為此增加了分片屏蔽的引數,當我們啟用分片臨時屏蔽功能后,底層資料庫查詢時,發現該分片被屏蔽直接拋出例外,讓應用程式能夠得到快速回應,從而避免了網路和資料庫訪問時間消耗,提高了例外執行的效率,避免問題擴散到正常的分片的資料處理,
③ 故障訂單轉移
根據之前的介紹,用戶訂單號是根據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)資料范圍
① 劃定資料表范圍,先進行表結構優化的作業
我們需要在專案初期明確資料表的范圍,針對一些可以下線的表或者欄位,先完成合并和下線的作業,來縮小專案范圍,避免表結構的變化和該專案耦合在一起,造成不必要的困擾,
② 相關資料表中哪些資料需要被遷移
我們在處理這個問題上,有一些反復,
-
方案1:僅遷移熱資料
因為訂單資料分為冷熱資料,所以我們最開始考慮是不是只要遷移熱資料就好了,冷資料僅保留查詢功能,
但是,這個方案有兩個很大的問題:一是存在冷資料需要被還原到熱資料的場景,增加了系統實作的復雜度,二是冷資料保留時限的問題,無法在短時間內下線這個資料庫,
-
方案2:部分資料自然消亡的表和欄位不進行遷移
針對有一些表由于業務或者系統改造的原因,可能后續資料不會更新了,或者在新的訂單上這些欄位已經廢棄了,大家在設計新表的時候其實往往很不喜歡把這些已經廢棄了的資訊加到新設計的表中,但是,我們需要面臨的問題是,舊資料如何兼容是一個非常現實的問題,
因此,當我們開發到中間的程序中,還是將部分表和欄位重新加了回來,來確保舊資料庫盡快下線以及歷史邏輯保持兼容,
-
方案3:保留當前所有的表結構和資訊
我們最后采用了這個方案,哪怕這個資料表或者欄位未來不會做任何修改,
2)團隊范圍
確定好資料范圍后,我們需要根據這些資料,確定我們需要做的作業以及找到完成這些作業的相關團隊并提前安排好資源,整個專案的資源分為核心成員和相關配合改造的團隊,
核心成員需要做到組織分工明確,并且需要經常一起頭腦風暴,提出問題,解決問題,消除隱患,核心成員的另一個職責是幫助配合改造的團隊,協調并且解決技術問題、資源問題等等,特別是涉及到的改動點較多、改造難度較大的團隊,需要提前介入,在適當的時候提供更多的幫助,
1、規劃
確定了專案的目標和范圍后,我們為專案設計了6個里程碑,來幫助我們更好的完成這個專案,

階段1:通過API對讀取進行收口
這個階段雖然難度并不大,但是周期很長,溝通成本較高,在這個階段重點在于任務的協調和跟進,DBA幫助我們研發了生產Trace查詢的工具,能夠準實時的知道資料表的訪問情況,幫我們快速驗收并且圈定改造范圍,
我們建立了任務的看板,為每一個任務設定了負責人以及預期解決的時間,定期對任務進行進行跟蹤,專案的負責人也作為驗收人,確認每個任務的完成情況,
通過一段時間的努力,資料庫的訪問收口在極少數內部應用當中,實作了資料訪問的收口,
階段2:開發雙讀/雙寫功能來實作平滑的資料切換
這個階段需要將整個專案的技術點、難點都逐一的找到,并且給出解決方案,如何提高效率和質量也是這個階段重點關注的話題,我們盡量把這些雙寫、切換的功能進行封裝,讓業務邏輯層盡可能少感知,或者不感知這些底層邏輯,降低代碼開發量,不僅能提高效率,還能提升質量,
總的來說,這個階段需要提升開發效率,提高開發質量并且降低專案風險,
階段3:驗證資料一致性
這個是對階段2的驗證作業,需要注意的是在驗證中減少噪音,提高驗證的自動化率,能有效的提升專案的開發質量,
階段4:通過壓測,故障模擬等手段驗證系統性能,在資料庫故障時,提供可靠的系統的災備和故障隔離能力,
階段5:資料讀取從SQLServer切換到MySQL
這個階段可能不需要有太多的資源投入,但是風險卻是最大的,這個階段是對前面所有階段成果的驗收,做好資料監控、制定良好的切換方案、出現問題時能夠回退是這個階段順利實施的重點,
階段6:停止SQLServer寫入并且下線相關資料表
相比起階段5,階段6沒有后悔藥,一旦停止了SQLServer的寫入,就非常難進行回切的操作,所以得仔細做好白名單的驗證,并且及時回應和解決相關問題,
2、原則
整個專案周期較長,我們需要制定好每個階段的目標,每個任務的目標,由于資料庫承載了非常核心的業務,因此整個階段、所有任務以及技術方案其實圍繞著一個原則展開,就是降低風險,
所以我們在設計每個技術方案的時候,盡可能考慮這點,例如在資料源切換的開關雖然涉及較多的服務實體,但是我們通過一個集中控制的平臺,來實作全鏈路的切換和灰度控制,
四、經驗分享
該專案整體的周期較長,每個階段的挑戰不盡相同,為了確保專案的上線質量,后續在讀切換、寫切換兩個流程的灰度時間比較久,專案大約在2021年下半年順利完成,
實作了以下主要目標和功能:
-
系統的水平擴展能力得到大幅提升
系統分片數量為64,部署在16臺物理機上,后續根據業務需要機器的部署方式和分片數量可以進行靈活調整,
-
資料庫資源利用率大幅下降,可靠性提升
資料庫服務器的CPU利用率從高峰期40%下降到目前的3%-5%之間,
-
訂單處理能力提升和存盤能力提升
原先區分冷熱資料,熱資料大約僅能支持3個月的訂單,按照現在硬體資源推算,系統可以處理至少5年以上的訂單,
-
資料訪問收口
原先近200個應用直接訪問資料庫,給我們的改造帶來很大的不便,目前僅有限的內部應用允許直接訪問訂單庫,
-
整體成本下降
原先主從服務器的CPU為128核,記憶體256G;現在服務器縮減為40核心的標準配置,
在專案程序中也積累了不少的經驗,例如:
-
專案的規劃要清晰,任務要明確,跟蹤要及時
整個專案中大約建立了數百個子任務,每一個任務需要落實負責人以及上線時間,并對上線結果進行驗收,才能確保整個專案的周期不至于拉的非常長,減少后續的專案返工和風險,
-
減少例外情況的發生
當一個大型的專案存在非常多的例外情況,這些特殊情況就得特殊處理,那么到最后總會有一些沒有處理干凈的尾巴,這些問題都是專案的潛在隱患,
-
減少專案的依賴
這個和我們日常開發關系也非常密切,當一項任務有多個依賴方的時候,往往專案的進展會大幅超出我們的預期,因此減少一些前置依賴,在不是非常確定的情況下,我們得先做好最壞的打算,
-
一次干好一件事
很多時候我們往往會高估自己的能力,例如在這次的改造中,我們會順便優化一些表的結構,于是造成了MySQL和SQLServer的資料表差異過大的問題,那么這些差異其實為后面的開發造成了不小的困擾,所有的方案,包括資料補償、遷移、資料源的切換等等場景都得為這些特殊差異的表單獨考慮方案,單獨實作邏輯,一不留神或者沒有考慮的很周全的情況下,往往會漏掉這部分的差異,導致專案返工,甚至出現生產故障,
專案的成功上線離不開每一個成員的努力,在實施程序中,遇到的問題比這篇文章列舉的問題多得多,很多都是一些非常瑣碎的事情,特別是專案初期,我們往往是解決了一個,冒出了更多的問題,但是每次遇到問題后,團隊的成員都積極思考,集思廣益,攻破了一個又一個的技術問題和業務問題,通過一年多時間的鍛煉,團隊成員的專案能力、技術能力進步顯著;發現問題的角度更敏銳,思考的角度更全面;團隊的凝聚力也得到了明顯提升,
本文來自博客園,作者:古道輕風,轉載請注明原文鏈接:https://www.cnblogs.com/88223100/p/Support-10x-order-growth-Ctrip-database-architecture-upgrade-practice.html
轉載請註明出處,本文鏈接:https://www.uj5u.com/shujuku/548191.html
標籤:MySQL
上一篇:P2 MySQL三層結構
