日訂單量達到100萬單后,我們做了訂單中心重構(gòu)
作者簡介:曾任職于阿里巴巴,每日優(yōu)鮮等互聯(lián)網(wǎng)公司,任技術(shù)總監(jiān)。
最近很多讀者朋友留言,希望“二馬”多寫一些實際工作經(jīng)歷以及工作中遇到的問題和技術(shù)解決方案。應(yīng)大家要求,本文介紹一次訂單中心重構(gòu)的經(jīng)歷。
背景
幾年前我曾經(jīng)服務(wù)過的一家電商公司,隨著業(yè)務(wù)增長我們每天的訂單量很快從30萬單增長到了100萬單,訂單總量也突破了一億。當時用的Mysql數(shù)據(jù)庫。根據(jù)監(jiān)控,我們的每秒最高訂單量已經(jīng)達到了2000筆(不包括秒殺,秒殺TPS已經(jīng)上萬了。秒殺我們有一套專門的解決方案,詳見《秒殺系統(tǒng)設(shè)計~億級用戶》)。不過,直到此時,訂單系統(tǒng)還是單庫單表,幸好當時數(shù)據(jù)庫服務(wù)器配置不錯,我們的系統(tǒng)才能撐住這么大的壓力。
業(yè)務(wù)量還在快速增長,再不重構(gòu)系統(tǒng)早晚出大事,我們花了一天時間快速制定了重構(gòu)方案。
重構(gòu)?說這么高大上,不就是分庫分表嗎?的確,就是分庫分表。不過除了分庫分表,還包括管理端的解決方案,比如運營,客服和商務(wù)需要從多維度查詢訂單數(shù)據(jù),分庫分表后,怎么滿足大家的需求?分庫分表后,上線方案和數(shù)據(jù)不停機遷移方案都需要慎重考慮。為了保證系統(tǒng)穩(wěn)定,還需要考慮相應(yīng)的降級方案。
為什么要分庫分表?
當數(shù)據(jù)庫產(chǎn)生性能瓶頸:IO瓶頸或CPU瓶頸。兩種瓶頸最終都會導(dǎo)致數(shù)據(jù)庫的活躍連接數(shù)增加,進而達到數(shù)據(jù)庫可承受的最大活躍連接數(shù)閾值。終會導(dǎo)致應(yīng)用服務(wù)無連接可用,造成災(zāi)難性后果??梢韵葟拇a,sql,索引幾方面進行優(yōu)化。如果這幾方面已經(jīng)沒有太多優(yōu)化的余地,就該考慮分庫分表了。
1、IO瓶頸
-
磁盤讀IO瓶頸。由于熱點數(shù)據(jù)太多,數(shù)據(jù)庫緩存完全放不下,查詢時會產(chǎn)生大量的磁盤IO,查詢速度會比較慢,這樣會導(dǎo)致產(chǎn)生大量活躍連接,最終可能會發(fā)展成無連接可用的后果??梢圆捎靡恢鞫鄰模x寫分離的方案,用多個從庫分攤查詢流量?;蛘卟捎梅謳?水平分表(把一張表的數(shù)據(jù)拆成多張表來存放,比如訂單表可以按user_id來拆分)的方案。
-
第二種:磁盤寫IO瓶頸。由于數(shù)據(jù)庫寫入頻繁,會產(chǎn)生頻繁的磁盤寫入IO操作,頻繁的磁盤IO操作導(dǎo)致產(chǎn)生大量活躍連接,最終同樣會發(fā)展成無連接可用的后果。這時只能采用分庫方案,用多個庫來分攤寫入壓力。再加上水平分表的策略,分表后,單表存儲的數(shù)據(jù)量會更小,插入數(shù)據(jù)時索引查找和更新的成本會更低,插入速度自然會更快。
2、CPU瓶頸
-
SQL問題。如果SQL中包含join,group by,order by,非索引字段條件查詢等增加CPU運算的操作,會對CPU產(chǎn)生明顯的壓力。 這時可以考慮SQL優(yōu)化,創(chuàng)建適當?shù)乃饕?,也可以把一些計算量大的SQL邏輯放到應(yīng)用中處理。
-
單表數(shù)據(jù)量太大。由于單張表數(shù)據(jù)量過大,比如超過一億,查詢時遍歷樹的層次太深或者掃描的行太多,SQL效率會很低,也會非常消耗CPU。這時可以根據(jù)業(yè)務(wù)場景水平分表。
分庫分表方案
分庫分表主要有兩種方案:
-
利用MyCat,KingShard這種代理中間件分庫分表。好處是和業(yè)務(wù)代碼耦合度很低,只需做一些配置即可,接入成本低。缺點是這種代理中間件需要單獨部署,所以從調(diào)用連路上又多了一層。而且分庫分表邏輯完全由代理中間件管理,對于程序員完全是黑盒,一旦代理本身出問題(比如出錯或宕機),會導(dǎo)致無法查詢和存儲相關(guān)業(yè)務(wù)數(shù)據(jù),引發(fā)災(zāi)難性的后果。如果不熟悉代理中間件源碼,排查問題會非常困難。曾經(jīng)有公司使用MyCat,線上發(fā)生故障后,被迫修改方案,三天三夜才恢復(fù)系統(tǒng)。CTO也廢了!
-
利用Sharding-Jdbc,TSharding等以Jar包形式呈現(xiàn)的輕量級組件分庫分表。缺點是,會有一定的代碼開發(fā)工作量,對業(yè)務(wù)有一些侵入性。好處是對程序員透明,程序員對分庫分表邏輯的把控會更強,一旦發(fā)生故障,排查問題會比較容易。
穩(wěn)妥起見,我們選用了第二種方案,使用更輕量級的Sharding-Jdbc。
做系統(tǒng)重構(gòu)前,我們首先要確定重構(gòu)的目標,其次要對未來業(yè)務(wù)的發(fā)展有一個預(yù)期,這個可以找相關(guān)業(yè)務(wù)負責(zé)人了解。根據(jù)目標和業(yè)務(wù)預(yù)期來確定重構(gòu)方案。例如,我們希望經(jīng)過本次重構(gòu),系統(tǒng)能支撐兩年,兩年內(nèi)不再大改。業(yè)務(wù)方預(yù)期兩年內(nèi)日單量達到1000萬。相當于兩年后日訂單量要翻10倍。
根據(jù)上面的數(shù)據(jù),我們分成了16個數(shù)據(jù)庫。按日訂單量1000萬來算,每個庫平均的日訂單量就是62.5萬(1000萬/16),每秒最高訂單量理論上在1250左右( 2000*(62.5/100) )。這樣數(shù)據(jù)庫的壓力基本上是可控的,而且基本不會浪費服務(wù)器資源。
每個庫分了16張表,即便按照每天1000萬的訂單量,兩年總單量是73億(73億=1000萬*365*2),每個庫的數(shù)據(jù)量平均是4.56億(4.56億=73億/16),每張表的數(shù)據(jù)量平均是2850萬(2850萬=4.56億/16)。可以看到未來兩到三年每張表的數(shù)據(jù)量也不算多,完全在可控范圍。
分庫分表主要是為了用戶端下單和查詢使用,按user_id的查詢頻率最高,其次是order_id。所以我們選擇user_id做為sharding column,按user_id做hash,將相同用戶的訂單數(shù)據(jù)存儲到同一個數(shù)據(jù)庫的同一張表中。這樣用戶在網(wǎng)頁或者App上查詢訂單時只需要路由到一張表就可以獲取用戶的所有訂單了,這樣就保證了查詢性能。
另外我們在訂單ID(order_id)里摻雜了用戶ID(user_id)信息。簡單來說,order_id的設(shè)計思路就是,將order_id分為前后兩部分,前面的部分是user_id,后面的部分是具體的訂單編號,兩部分組合在一起就構(gòu)成了order_id。這樣我們很容易從order_id解析出user_id。通過order_id查詢訂單時,先從order_id中解析出user_id,然后就可以根據(jù)user_id路由到具體的庫表了。
另外,數(shù)據(jù)庫分成16個,每個庫分16張表還有一個好處。16是2的N次冪,所以hash值對16取模的結(jié)果與hash值和16按位“與運算”的結(jié)果是一樣的。我們知道位運算基于二進制,跨過各種編譯和轉(zhuǎn)化直接到最底層的機器語言,效率自然遠高于取模運算。
有讀者可能會問,查詢直接查數(shù)據(jù)庫,會不會有性能問題?是的。所以我們在上層加了Redis,Redis做了分片集群,用于存儲活躍用戶最近50條訂單。這樣一來,只有少部分在Redis查不到訂單的用戶請求才會到數(shù)據(jù)庫查詢訂單,這樣就減小了數(shù)據(jù)庫查詢壓力,而且每個分庫還有兩個從庫,查詢操作只走從庫,進一步分攤了每個分庫的壓力。
有讀者可能會問,為什么沒采用一致性hash方案?用戶查詢最近50條之前的訂單怎么辦?請繼續(xù)往后看!
管理端技術(shù)方案
分庫分表后,不同用戶的訂單數(shù)據(jù)散落在不同的庫和表中,如果需要根據(jù)用戶ID之外的其他條件查詢訂單。例如,運營同學(xué)想從后臺查出某天iphone7的訂單量,就需要從所有數(shù)據(jù)庫的表中查出數(shù)據(jù)然后在聚合到一起。這樣代碼實現(xiàn)非常復(fù)雜,而且查詢性能也會很差。所以我們需要一種更好的方案來解決這個問題。
我們采用了ES(Elastic Search)+HBase組合的方案,將索引與數(shù)據(jù)存儲隔離。可能參與條件檢索的字段都會在ES中建一份索引,例如商家,商品名稱,訂單日期等。所有訂單數(shù)據(jù)全量保存到HBase中。我們知道HBase支持海量存儲,而且根據(jù)rowkey查詢速度超快。而ES的多條件檢索能力非常強大??梢哉f,這個方案把ES和HBase的優(yōu)點發(fā)揮地淋漓盡致。
看一下該方案的查詢過程:先根據(jù)輸入條件去ES相應(yīng)的索引上查詢符合條件的rowkey值,然后用rowkey值去HBase查詢,后面這一步查詢速度極快,查詢時間幾乎可以忽略不計。如下圖:
該方案,解決了管理端通過各種字段條件查詢訂單的業(yè)務(wù)需求,同時也解決了商家端按商家ID和其他條件查詢訂單的需求。如果用戶希望查詢最近50條訂單之前的歷史訂單,也同樣可以用這個方案。
每天產(chǎn)生數(shù)百萬的訂單數(shù)據(jù),如果管理后臺想查到最新的訂單數(shù)據(jù),就需要頻繁更新ES索引。在海量訂單數(shù)據(jù)的場景下,索引頻繁更新會不會對ES產(chǎn)生太大壓力?
ES索引有一個segment(片段)的概念。ES把每個索引分成若干個較小的 segment 片段。每一個 segement 都是一個完整的倒排索引,在搜索查詢時會依次掃描相關(guān)索引的所有 segment。每次 refresh(刷新索引) 的時候,都會生成一個新的 segement,因此 segment 實際上記錄了索引的一組變化值。由于每次索引刷新只涉及個別segement片段,更新索引的成本就很低了。所以,即便默認的索引刷新(refresh)間隔只有1秒鐘,ES也能從容應(yīng)對。不過,由于每個 segement 的存儲和掃描都需要占用一定的內(nèi)存和CPU等資源,因此ES后臺進程需要不斷的進行segement合并來減少 segement 的數(shù)量,從而提升掃描效率以及降低資源消耗。
Mysql中的訂單數(shù)據(jù)需要實時同步到Hbase和ES中。同步方案是什么?
我們利用Canal實時獲取Mysql庫表中的增量訂單數(shù)據(jù),然后把訂單數(shù)據(jù)推到消息隊列RocketMQ中,消費端獲取消息后把數(shù)據(jù)寫到Hbase,并在ES更新索引。
圖片來源于網(wǎng)絡(luò)
上面是Canal的原理圖,
1,Canal模擬mysql slave的交互協(xié)議,把自己偽裝成mysql的從庫
2,向mysql master發(fā)送dump協(xié)議
3. mysql master收到dump協(xié)議,發(fā)送binary log給slave(Canal)
4. Canal解析binary log字節(jié)流對象,根據(jù)應(yīng)用場景對binary log字節(jié)流做相應(yīng)的處理
為了保證數(shù)據(jù)一致性,不丟失數(shù)據(jù)。我們使用了RocketMQ的事務(wù)型消息,保證消息一定能成功發(fā)送。另外,在Hbase和ES都操作成功后才做ack操作,保證消息正常消費。
不停機數(shù)據(jù)遷移
在互聯(lián)網(wǎng)行業(yè),很多系統(tǒng)的訪問量很高,即便在凌晨兩三點也有一定的訪問量。由于數(shù)據(jù)遷移導(dǎo)致服務(wù)暫停,是很難被業(yè)務(wù)方接受的!下面就聊一下在用戶無感知的前提下,我們的不停機數(shù)據(jù)遷移方案!
數(shù)據(jù)遷移過程我們要注意哪些關(guān)鍵點呢?第一,保證遷移后數(shù)據(jù)準確不丟失,即每條記錄準確而且不丟失記錄;第二,不影響用戶體驗,尤其是訪問量高的C端業(yè)務(wù)需要不停機平滑遷移;第三,保證遷移后的系統(tǒng)性能和穩(wěn)定性。
常用的數(shù)據(jù)遷移方案主要包括:掛從庫,雙寫以及利用數(shù)據(jù)同步工具三種方案。下面分別做一下介紹。
掛從庫
在主庫上建一個從庫。從庫數(shù)據(jù)同步完成后,將從庫升級成主庫(新庫),再將流量切到新庫。
這種方式適合表結(jié)構(gòu)不變,而且空閑時間段流量很低,允許停機遷移的場景。一般發(fā)生在平臺遷移的場景,如從機房遷移到云平臺,從一個云平臺遷移到另一個云平臺。大部分中小型互聯(lián)網(wǎng)系統(tǒng),空閑時段訪問量很低。在空閑時段,幾分鐘的停機時間,對用戶影響很小,業(yè)務(wù)方是可以接受的。所以我們可以采用停機遷移的方案。步驟如下:
1,新建從庫(新數(shù)據(jù)庫),數(shù)據(jù)開始從主庫向從庫同步。
2,數(shù)據(jù)同步完成后,找一個空閑時間段。為了保證主從數(shù)據(jù)庫數(shù)據(jù)一致,需要先停掉服務(wù),然后再把從庫升級為主庫。如果訪問數(shù)據(jù)庫用的是域名,直接解析域名到新數(shù)據(jù)庫(從庫升級成的主庫),如果訪問數(shù)據(jù)庫用的是IP,將IP改成新數(shù)據(jù)庫IP。
3,最后啟動服務(wù),整個遷移過程完成。
這種遷移方案的優(yōu)勢是遷移成本低,遷移周期短。缺點是,切換數(shù)據(jù)庫過程需要停止服務(wù)。我們的并發(fā)量比較高,而且又做了分庫分表,表結(jié)構(gòu)也變了,所以不能采取這種方案!
雙寫
老庫和新庫同時寫入,然后將老數(shù)據(jù)批量遷移到新庫,最后流量切換到新庫并關(guān)閉老庫讀寫。
這種方式適合數(shù)據(jù)結(jié)構(gòu)發(fā)生變化,不允許停機遷移的場景。一般發(fā)生在系統(tǒng)重構(gòu)時,表結(jié)構(gòu)發(fā)生變化,如表結(jié)構(gòu)改變或者分庫分表等場景。有些大型互聯(lián)網(wǎng)系統(tǒng),平常并發(fā)量很高,即便是空閑時段也有相當?shù)脑L問量。幾分鐘的停機時間,對用戶也會有明顯的影響,甚至導(dǎo)致一定的用戶流失,這對業(yè)務(wù)方來說是無法接受的。所以我們需要考慮一種用戶無感知的不停機遷移方案。
聊一下我們的具體遷移方案,步驟如下:
-
代碼準備。在服務(wù)層對訂單表進行增刪改的地方,要同時操作新庫(分庫分表后的數(shù)據(jù)庫表)和老庫,需要修改相應(yīng)的代碼(同時寫新庫和老庫)。準備遷移程序腳本,用于做老數(shù)據(jù)遷移。準備校驗程序腳本,用于校驗新庫和老庫的數(shù)據(jù)是否一致。
-
開啟雙寫,老庫和新庫同時寫入。注意:任何對數(shù)據(jù)庫的增刪改都要雙寫;對于更新操作,如果新庫沒有相關(guān)記錄,需要先從老庫查出記錄,將更新后的記錄寫入新庫;為了保證寫入性能,老庫寫完后,可以采用消息隊列異步寫入新庫。
-
利用腳本程序,將某一時間戳之前的老數(shù)據(jù)遷移到新庫。注意:1,時間戳一定要選擇開啟雙寫后的時間點,比如開啟雙寫后10分鐘的時間點,避免部分老數(shù)據(jù)被漏掉;2,遷移過程遇到記錄沖突直接忽略,因為第2步的更新操作,已經(jīng)把記錄拉到了新庫;3,遷移過程一定要記錄日志,尤其是錯誤日志,如果有雙寫失敗的情況,我們可以通過日志恢復(fù)數(shù)據(jù),以此來保證新老庫的數(shù)據(jù)一致。
-
第3步完成后,我們還需要通過腳本程序檢驗數(shù)據(jù),看新庫數(shù)據(jù)是否準確以及有沒有漏掉的數(shù)據(jù)
-
數(shù)據(jù)校驗沒問題后,開啟雙讀,起初給新庫放少部分流量,新庫和老庫同時讀取。由于延時問題,新庫和老庫可能會有少量數(shù)據(jù)記錄不一致的情況,所以新庫讀不到時需要再讀一遍老庫。然后再逐步將讀流量切到新庫,相當于灰度上線的過程。遇到問題可以及時把流量切回老庫
-
讀流量全部切到新庫后,關(guān)閉老庫寫入(可以在代碼里加上熱配置開關(guān)),只寫新庫
-
遷移完成,后續(xù)可以去掉雙寫雙讀相關(guān)無用代碼。
利用數(shù)據(jù)同步工具
我們可以看到上面雙寫的方案比較麻煩,很多數(shù)據(jù)庫寫入的地方都需要修改代碼。有沒有更好的方案呢?
我們還可以利用Canal,DataBus等工具做數(shù)據(jù)同步。以阿里開源的Canal為例。
利用同步工具,就不需要開啟雙寫了,服務(wù)層也不需要編寫雙寫的代碼,直接用Canal做增量數(shù)據(jù)同步即可。相應(yīng)的步驟就變成了:
-
代碼準備。準備Canal代碼,解析binary log字節(jié)流對象,并把解析好的訂單數(shù)據(jù)寫入新庫。準備遷移程序腳本,用于做老數(shù)據(jù)遷移。準備校驗程序腳本,用于校驗新庫和老庫的數(shù)據(jù)是否一致。
-
運行Canal代碼,開始增量數(shù)據(jù)(線上產(chǎn)生的新數(shù)據(jù))從老庫到新庫的同步。
-
利用腳本程序,將某一時間戳之前的老數(shù)據(jù)遷移到新庫。注意:1,時間戳一定要選擇開始運行Canal程序后的時間點(比如運行Canal代碼后10分鐘的時間點),避免部分老數(shù)據(jù)被漏掉;3,遷移過程一定要記錄日志,尤其是錯誤日志,如果有些記錄寫入失敗,我們可以通過日志恢復(fù)數(shù)據(jù),以此來保證新老庫的數(shù)據(jù)一致。
-
第3步完成后,我們還需要通過腳本程序檢驗數(shù)據(jù),看新庫數(shù)據(jù)是否準確以及有沒有漏掉的數(shù)據(jù)
-
數(shù)據(jù)校驗沒問題后,開啟雙讀,起初給新庫放少部分流量,新庫和老庫同時讀取。由于延時問題,新庫和老庫可能會有少量數(shù)據(jù)記錄不一致的情況,所以新庫讀不到時需要再讀一遍老庫。逐步將讀流量切到新庫,相當于灰度上線的過程。遇到問題可以及時把流量切回老庫
-
讀流量全部切到新庫后,將寫入流量切到新庫(可以在代碼里加上熱配置開關(guān)。注:由于切換過程Canal程序還在運行,仍然能夠獲取老庫的數(shù)據(jù)變化并同步到新庫,所以切換過程不會導(dǎo)致部分老庫數(shù)據(jù)無法同步新庫的情況)
-
關(guān)閉Canal程序
-
遷移完成。
擴容縮容方案
需要對數(shù)據(jù)重新hash取模,再將原來多個庫表的數(shù)據(jù)寫入擴容后的庫表中。整體擴容方案和上面的不停機遷移方案基本一致。采用雙寫或者Canal等數(shù)據(jù)同步方案都可以。
更好的分庫分表方案
通過前面的描述,不難看出我們的分庫分表方案有一些缺陷,比如采用hash取模的方式會產(chǎn)生數(shù)據(jù)分布不均勻的情況,擴容縮容也非常麻煩。
這些問題可以用一致性hash方案解決?;谔摂M節(jié)點設(shè)計原理的一致性hash可以讓數(shù)據(jù)分布更均勻。
而且一致性hash采用環(huán)形設(shè)計思路,在增減節(jié)點時,使得數(shù)據(jù)遷移的成本會更低,只需要遷移臨近節(jié)點的數(shù)據(jù)。不過需要擴容時基本上要成倍擴容,在hash環(huán)上每個節(jié)點間隙都增加新的節(jié)點,這樣才能分攤所有原有節(jié)點的訪問和存儲壓力。
由于篇幅原因,這里不詳細介紹一致性hash了,網(wǎng)上有很多相關(guān)資料,大家有興趣可以仔細研究一下。
降級方案
在大促期間訂單服務(wù)壓力過大時,可以將同步調(diào)用改為異步消息隊列方式,來減小訂單服務(wù)壓力并提高吞吐量。
大促時某些時間點瞬間生成訂單量很高。我們采取異步批量寫數(shù)據(jù)庫的方式,來減少數(shù)據(jù)庫訪問頻次,進而降低數(shù)據(jù)庫的寫入壓力。詳細步驟:后端服務(wù)接到下單請求,直接放進消息隊列,訂單服務(wù)取出消息后,先將訂單信息寫入Redis,每隔100ms或者積攢10條訂單,批量寫入數(shù)據(jù)庫一次。前端頁面下單后定時向后端拉取訂單信息,獲取到訂單信息后跳轉(zhuǎn)到支付頁面。用這種異步批量寫入數(shù)據(jù)庫的方式大幅減少了數(shù)據(jù)庫寫入頻次,從而明顯降低了訂單數(shù)據(jù)庫寫入壓力。不過,因為訂單是異步寫入數(shù)據(jù)庫的,就會存在數(shù)據(jù)庫訂單和相應(yīng)庫存數(shù)據(jù)暫時不一致的情況,以及用戶下單后不能及時查到訂單的情況。因為畢竟是降級方案,可以適當降低用戶體驗,我們保證數(shù)據(jù)最終一致即可。根據(jù)系統(tǒng)壓力情況,可以在大促開始時開啟異步批量寫的降級開關(guān),大促結(jié)束后再關(guān)閉降級開關(guān)。流程如下圖:
免責(zé)聲明:本文內(nèi)容由21ic獲得授權(quán)后發(fā)布,版權(quán)歸原作者所有,本平臺僅提供信息存儲服務(wù)。文章僅代表作者個人觀點,不代表本平臺立場,如有問題,請聯(lián)系我們,謝謝!