業(yè)務場景
達達是全國領先的最后三公里物流配送平臺。 達達的業(yè)務模式與滴滴以及Uber很相似,以眾包的方式利用社會閑散人力資源,解決O2O最后三公里即時性配送難題。 達達業(yè)務主要包含兩部分:商家發(fā)單,配送員接單配送,如下圖所示。
達達的業(yè)務規(guī)模增長極大,在1年左右的時間從零增長到每天近百萬單,給后端帶來極大的訪問壓力。壓力主要分為兩類:讀壓力、寫壓力。讀壓力來源于配送員在APP中搶單,高頻刷新查詢周圍的訂單,每天訪問量幾億次,高峰期QPS高達數(shù)千次/秒。寫壓力來源于商家發(fā)單、達達接單、取貨、完成等操作。達達業(yè)務讀的壓力遠大于寫壓力,讀請求量約是寫請求量的30倍以上。
下圖是達達過去6個月,每天的訪問量及QPS變化趨勢圖變化趨圖,可見增長極快
極速增長的業(yè)務,對技術(shù)的要求越來越高,我們必須在架構(gòu)上做好充分的準備,才能迎接業(yè)務的挑戰(zhàn)。接下來,我們一起看看達達的后臺架構(gòu)是如何演化的。
最初的技術(shù)選型
作為創(chuàng)業(yè)公司,最重要的一點是敏捷,快速實現(xiàn)產(chǎn)品,對外提供服務,于是我們選擇了公有云服務,保證快速實施和可擴展性,節(jié)省了自建機房等時間。在技術(shù)選型上,為快速的響應業(yè)務需求,業(yè)務系統(tǒng)使用python做為開發(fā)語言,數(shù)據(jù)庫使用Mysql。如下圖所示,應用層的幾大系統(tǒng)都訪問一個數(shù)據(jù)庫。
讀寫分離
隨著業(yè)務的發(fā)展,訪問量的極速增長,上述的方案很快不能滿足性能需求。每次請求的響應時間越來越長,比如配送員在app中刷新周圍訂單,響應時間從最初的500毫秒增加到了2秒以上。業(yè)務高峰期,系統(tǒng)甚至出現(xiàn)過宕機,一些商家和配送員甚至因此而懷疑我們的服務質(zhì)量。在這生死存亡的關(guān)鍵時刻,通過監(jiān)控,我們發(fā)現(xiàn)高期峰Mysql CPU使用率已接近80%,磁盤IO使用率接近90%,Slow query從每天1百條上升到1萬條,而且一天比一天嚴重。數(shù)據(jù)庫儼然已成為瓶頸,我們必須得快速做架構(gòu)升級。
如下是數(shù)據(jù)庫一周的qps變化圖,可見數(shù)據(jù)庫壓力的增長極快。
當Web應用服務出現(xiàn)性能瓶頸的時候,由于服務本身無狀態(tài)(stateless),我們可以通過加機器的水平擴展方式來解決。 而數(shù)據(jù)庫顯然無法通過簡單的添加機器來實現(xiàn)擴展,因此我們采取了Mysql主從同步和應用服務端讀寫分離的方案。
Mysql支持主從同步,實時將主庫的數(shù)據(jù)增量復制到從庫,而且一個主庫可以連接多個從庫同步(細節(jié)參考Replication)。利用此特性,我們在應用服務端對每次請求做讀寫判斷,若是寫請求,則把這次請求內(nèi)的所有DB操作發(fā)向主庫;若是讀請求,則把這次請求內(nèi)的所有DB操作發(fā)向從庫,如下圖所示。
實現(xiàn)讀寫分離后,數(shù)據(jù)庫的壓力減少了許多,CPU使用率和IO使用率都降到了5%內(nèi),Slow Query也趨近于0。主從同步、讀寫分離給我們主要帶來如下兩個好處:
減輕了主庫(寫)壓力:達達的業(yè)務主要來源于讀操作,做讀寫分離后,讀壓力轉(zhuǎn)移到了從庫,主庫的壓力減小了數(shù)十倍。
從庫(讀)可水平擴展(加從庫機器):因系統(tǒng)壓力主要是讀請求,而從庫又可水平擴展,當從庫壓力太時,可直接添加從庫機器,緩解讀請求壓力
如下是優(yōu)化后數(shù)據(jù)庫qps的變化圖:
讀寫分離前主庫的select qps
讀寫分離后主庫的select qps
當然,沒有一個方案是萬能的。讀寫分離,暫時解決了Mysql壓力問題,同時也帶來了新的挑戰(zhàn)。業(yè)務高峰期,商家發(fā)完訂單,在我的訂單列表中卻看不到當發(fā)的訂單(典型的read after write);系統(tǒng)內(nèi)部偶爾也會出現(xiàn)一些查詢不到數(shù)據(jù)的異常。通過監(jiān)控,我們發(fā)現(xiàn),業(yè)務高峰期Mysql可能會出現(xiàn)主從延遲,極端情況,主從延遲高達10秒。
那如何監(jiān)控主從同步狀態(tài)?在從庫機器上,執(zhí)行show slave status,查看Seconds_Behind_Master值,代表主從同步從庫落后主庫的時間,單位為秒,若主從同步無延遲,這個值為0。Mysql主從延遲一個重要的原因之一是主從復制是單線程串行執(zhí)行。
那如何為避免或解決主從延遲?我們做了如下一些優(yōu)化:
優(yōu)化Mysql參數(shù),比如增大innodb_buffer_pool_size,讓更多操作在Mysql內(nèi)存中完成,減少磁盤操作。
使用高性能CPU主機
數(shù)據(jù)庫使用物理主機,避免使用虛擬云主機,提升IO性能
使用SSD磁盤,提升IO性能。SSD的隨機IO性能約是SATA硬盤的10倍。
業(yè)務代碼優(yōu)化,將實時性要求高的某些操作,使用主庫做讀操作
垂直分庫
讀寫分離很好的解決讀壓力問題,每次讀壓力增加,可以通過加從庫的方式水平擴展。但是寫操作的壓力隨著業(yè)務爆發(fā)式的增長沒有很有效的緩解辦法,比如商家發(fā)單起來越慢,嚴重影響了商家的使用體驗。我們監(jiān)控發(fā)現(xiàn),數(shù)據(jù)庫寫操作越來越慢,一次普通的insert操作,甚至可能會執(zhí)行1秒以上。
下圖是數(shù)據(jù)庫主庫的壓力, 可見磁盤IO使用率已經(jīng)非常高,高峰期IO響應時間最大達到636毫秒,IO使用率最高達到100%。
同時,業(yè)務越來越復雜,多個應用系統(tǒng)使用同一個數(shù)據(jù)庫,其中一個很小的非核心功能出現(xiàn)Slow query,常常影響主庫上的其它核心業(yè)務功能。我們有一個應用系統(tǒng)在MySql中記錄日志,日志量非常大,近1億行記錄,而這張表的ID是UUID,某一天高峰期,整個系統(tǒng)突然變慢,進而引發(fā)了宕機。監(jiān)控發(fā)現(xiàn),這張表insert極慢,拖慢了整個MySql Master,進而拖跨了整個系統(tǒng)。(當然在mysql中記日志不是一種好的設計,因此我們開發(fā)了大數(shù)據(jù)日志系統(tǒng),敬請關(guān)注本博客后續(xù)文章。另一方面,UUID做主鍵是個糟糕的選擇,在下文的水平分庫中,針對ID的生成,有更深入的講述)。
這時,主庫成為了性能瓶頸,我們意識到,必需得再一次做架構(gòu)升級,將主庫做拆分,一方面以提升性能,另一方面減少系統(tǒng)間的相互影響,以提升系統(tǒng)穩(wěn)定性。這一次,我們將系統(tǒng)按業(yè)務進行了垂直拆分。如下圖所示,將最初龐大的數(shù)據(jù)庫按業(yè)務拆分成不同的業(yè)務數(shù)據(jù)庫,每個系統(tǒng)僅訪問對應業(yè)務的數(shù)據(jù)庫,避免或減少跨庫訪問。
下圖是垂直拆分后,數(shù)據(jù)庫主庫的壓力,可見磁盤IO使用率已降低了許多,高峰期IO響應時間在2.33毫秒內(nèi),IO使用率最高只到22.8%。
未來是美好的,道路是曲折的。垂直分庫過程,我們也遇到不少挑戰(zhàn),最大的挑戰(zhàn)是:不能跨庫join,同時需要對現(xiàn)有代碼重構(gòu)。單庫時,可以簡單的使用join關(guān)聯(lián)表查詢;拆庫后,拆分后的數(shù)據(jù)庫在不同的實例上,就不能跨庫使用join了。比如在CRM系統(tǒng)中,需要通過商家名查詢某個商家的所有訂單,在垂直分庫前,可以join商家和訂單表做查詢,如下如示:
select * from tborder where supplierid in (select id from supplier where name=‘上海海底撈’);
分庫后,則要重構(gòu)代碼,先通過商家名查詢商家id,再通過商家Id查詢訂單表,如下所示:
supplierids = select id from supplier where name=‘上海海底撈’
select * from tb_order where supplierid in (supplierids )
垂直分庫過程中的經(jīng)驗教訓,使我們制定了SQL最佳實踐,其中一條便是程序中禁用或少用join,而應該在程序中組裝數(shù)據(jù),讓SQL更簡單。一方面為以后進一步垂直拆分業(yè)務做準備,另一方面也避免了Mysql中join的性能較低的問題。
經(jīng)過一個星期緊鑼密鼓的底層架構(gòu)調(diào)整,以及業(yè)務代碼重構(gòu),終于完成了數(shù)據(jù)庫的垂直拆分。拆分之后,每個應用程序只訪問對應的數(shù)據(jù)庫,一方面將單點數(shù)據(jù)庫拆分成了多個,分攤了主庫寫壓力;另一方面,拆分后的數(shù)據(jù)庫各自獨立,實現(xiàn)了業(yè)務隔離,不再互相影響。
水平分庫(sharding)
讀寫分離,通過從庫水平擴展,解決了讀壓力;垂直分庫通過按業(yè)務拆分主庫,緩存了寫壓力,但系統(tǒng)依然存在以下隱患:
單表數(shù)據(jù)量越來越大。如訂單表,單表記錄數(shù)很快將過億,超出MySql的極限,影響讀寫性能。
核心業(yè)務庫的寫壓力越來越大,已不能再進一次垂直拆分,Mysql 主庫不具備水平擴展的能力
以前,系統(tǒng)壓力逼迫我們架構(gòu)升級,這一次,我們需提前做好架構(gòu)升級,實現(xiàn)數(shù)據(jù)庫的水平擴展(sharding)。業(yè)務類似于我們的Uber在公司成立的5年后(2014)年才實施了水平分庫(mezzanine-migration),但我們的業(yè)務發(fā)展要求我們在成立18月就要開始實施水平分庫。邏輯架構(gòu)圖如下圖所示:
水平分庫面臨的第一個問題是,按什么邏輯進行拆分。一種方案是按城市拆分,一個城市的所有數(shù)據(jù)在一個數(shù)據(jù)庫中;另一種方案是按訂單ID平均拆分數(shù)據(jù)。按城市拆分的優(yōu)點是數(shù)據(jù)聚合度比較高,做聚合查詢比較簡單,實現(xiàn)也相對簡單,缺點是數(shù)據(jù)分布不均勻,某些城市的數(shù)據(jù)量極大,產(chǎn)生熱點,而這些熱點以后可能還要被迫再次拆分。按訂單ID拆分則正相反,優(yōu)點是數(shù)據(jù)分布均勻,不會出現(xiàn)一個數(shù)據(jù)庫數(shù)據(jù)極大或極小的情況,缺點是數(shù)據(jù)太分散,不利于做聚合查詢。比如,按訂單ID拆分后,一個商家的訂單可能分布在不同的數(shù)據(jù)庫中,查詢一個商家的所有訂單,可能需要查詢多個數(shù)據(jù)庫。針對這種情況,一種解決方案是將需要聚合查詢的數(shù)據(jù)做冗余表,冗余的表不做拆分,同時在業(yè)務開發(fā)過程中,減少聚合查詢。
反復權(quán)衡利弊,并參考了Uber等公司的分庫方案后,我們最后決定按訂單ID做水平分庫。從架構(gòu)上,我們將系統(tǒng)分為三層:
應用層:即各類業(yè)務應用系統(tǒng)
數(shù)據(jù)訪問層:統(tǒng)一的數(shù)據(jù)訪問接口,對上層應用層屏蔽讀寫分庫、分庫、緩存等技術(shù)細節(jié)。
數(shù)據(jù)層:對DB數(shù)據(jù)進行分片,并可動態(tài)的添加shard分片。
水平分庫的技術(shù)關(guān)鍵點在于數(shù)據(jù)訪問層的設計,數(shù)據(jù)訪問層主要包含三部分:
ID生成器:生成每張表的主鍵
數(shù)據(jù)源路由:將每次DB操作路由到不同的shard數(shù)據(jù)源上
緩存: 采用Redis實現(xiàn)數(shù)據(jù)的緩存,提升性能(以后會有詳細文章)
ID生成器是整個水平分庫的核心,它決定了如何拆分數(shù)據(jù),以及查詢存儲-檢索數(shù)據(jù)。ID需要跨庫全局唯一,否則會引發(fā)業(yè)務層的沖突。此外,ID必須是數(shù)字且升序,這主要是考慮到升序的ID能保證Mysql的性能(若是UUID等隨機字符串,在高并發(fā)和大數(shù)據(jù)量情況下,性能極差。對比性能測試數(shù)據(jù)可供參考uuid-vs-int-insert-performance)。同時,ID生成器必須非常穩(wěn)定,因為任何故障都會影響所有的數(shù)據(jù)庫操作。
我們的ID的生成策略借鑒了Instagram的ID生成算法(sharding-ids-at-instagram)。具體方案如下:
整個ID的二進制長度為64位
前36位使用時間戳,以保證ID是升序增加
中間13位是分庫標識,用來標識當前這個ID對應的記錄在哪個數(shù)據(jù)庫中
后15位為自增序列,以保證在同一秒內(nèi)并發(fā)時,ID不會重復。每個shard庫都有一個自增序列表,生成自增序列時,從自增序列表中獲取當前自增序列值,并加1,做為當前ID的后15位。