天天看點

幹貨:每秒處理10萬高并發訂單的支付系統架構幹貨:每秒處理10萬高并發訂單的支付系統架構

幹貨:每秒處理10萬高并發訂單的支付系統架構

儒雅程式員 2019-03-15 07:21:00

随着各類搶購的不斷更新,支付面臨的請求壓力百倍乃至千倍的暴增。作為商品購買的最後一環,保證使用者快速穩定的完成支付尤為重要。我們對整個支付系統進行了全面的架構更新,使之具備了每秒穩定處理10萬訂單的能力。為各種形式的搶購秒殺活動提供了強有力的支撐。

幹貨:每秒處理10萬高并發訂單的支付系統架構幹貨:每秒處理10萬高并發訂單的支付系統架構

一、庫分表

在redis,memcached等緩存系統盛行的網際網路時代,建構一個支撐每秒十萬隻讀的系統并不複雜,無非是通過一緻性哈希擴充緩存節點,水準擴充web伺服器等。支付系統要處理每秒十萬筆訂單,需要的是每秒數十萬的資料庫更新操作(insert加update),這在任何一個獨立資料庫上都是不可能完成的任務,是以我們首先要做的是對訂單表(簡稱order)進行分庫與分表。

在進行資料庫操作時,一般都會有使用者ID(簡稱uid)字段,是以我們選擇以uid進行分庫分表。

分庫政策我們選擇了“二叉樹分庫”,所謂“二叉樹分庫”指的是:我們在進行資料庫擴容時,都是以2的倍數進行擴容。比如:1台擴容到2台,2台擴容到4台,4台擴容到8台,以此類推。這種分庫方式的好處是,我們在進行擴容時,隻需DBA進行表級的資料同步,而不需要自己寫腳本進行行級資料同步。

光是有分庫是不夠的,經過持續壓力測試我們發現,在同一資料庫中,對多個表進行并發更新的效率要遠遠大于對一個表進行并發更新,是以我們在每個分庫中都将order表拆分成10份:order_0,order_1,….,order_9。

最後我們把order表放在了8個分庫中(編号1到8,分别對應DB1到DB8),每個分庫中10個分表(編号0到9,分别對應order_0到order_9),部署結構如下圖所示:

幹貨:每秒處理10萬高并發訂單的支付系統架構幹貨:每秒處理10萬高并發訂單的支付系統架構

根據uid計算資料庫編号:

資料庫編号 = (uid / 10) % 8 + 1

根據uid計算表編号:

表編号 = uid % 10

當uid=9527時,根據上面的算法,其實是把uid分成了兩部分952和7,其中952模8加1等于1為資料庫編号,而7則為表編号。是以uid=9527的訂單資訊需要去DB1庫中的order_7表查找。具體算法流程也可參見下圖:

幹貨:每秒處理10萬高并發訂單的支付系統架構幹貨:每秒處理10萬高并發訂單的支付系統架構

有了分庫分表的結構與算法最後就是尋找分庫分表的實作工具,目前市面上約有兩種類型的分庫分表工具:

用戶端分庫分表,在用戶端完成分庫分表操作,直連資料庫使用分庫分表中間件,用戶端連分庫分表中間件,由中間件完成分庫分表操作

這兩種類型的工具市面上都有,這裡不一一列舉,總的來看這兩類工具各有利弊。用戶端分庫分表由于直連資料庫,是以性能比使用分庫分表中間件高15%到20%。而使用分庫分表中間件由于進行了統一的中間件管理,将分庫分表操作和用戶端隔離,子產品劃分更加清晰,便于DBA進行統一管理。

我們選擇的是在用戶端分庫分表,因為我們自己開發并開源了一套資料層通路架構,它的代号叫“芒果”,芒果架構原生支援分庫分表功能,并且配置起來非常簡單。

芒果首頁:mango.jfaster.org芒果源碼:github.com/jfaster/mango

二、訂單ID

訂單系統的ID必須具有全局唯一的特征,最簡單的方式是利用資料庫的序列,每操作一次就能獲得一個全局唯一的自增ID,如果要支援每秒處理10萬訂單,那每秒将至少需要生成10萬個訂單ID,通過資料庫生成自增ID顯然無法完成上述要求。是以我們隻能通過記憶體計算獲得全局唯一的訂單ID。

JAVA領域最著名的唯一ID應該算是UUID了,不過UUID太長而且包含字母,不适合作為訂單ID。通過反複比較與篩選,我們借鑒了Twitter的Snowflake算法,實作了全局唯一ID。下面是訂單ID的簡化結構圖:

幹貨:每秒處理10萬高并發訂單的支付系統架構幹貨:每秒處理10萬高并發訂單的支付系統架構

上圖分為3個部分:

時間戳

這裡時間戳的粒度是毫秒級,生成訂單ID時,使用System.currentTimeMillis()作為時間戳。

機器号

每個訂單伺服器都将被配置設定一個唯一的編号,生成訂單ID時,直接使用該唯一編号作為機器号即可。

自增序号

當在同一伺服器的同一毫秒中有多個生成訂單ID的請求時,會在目前毫秒下自增此序号,下一個毫秒此序号繼續從0開始。比如在同一伺服器同一毫秒有3個生成訂單ID的請求,這3個訂單ID的自增序号部分将分别是0,1,2。

上面3個部分組合,我們就能快速生成全局唯一的訂單ID。不過光全局唯一還不夠,很多時候我們會隻根據訂單ID直接查詢訂單資訊,這時由于沒有uid,我們不知道去哪個分庫的分表中查詢,周遊所有的庫的所有表?這顯然不行。是以我們需要将分庫分表的資訊添加到訂單ID上,下面是帶分庫分表資訊的訂單ID簡化結構圖:

幹貨:每秒處理10萬高并發訂單的支付系統架構幹貨:每秒處理10萬高并發訂單的支付系統架構

我們在生成的全局訂單ID頭部添加了分庫與分表的資訊,這樣隻根據訂單ID,我們也能快速的查詢到對應的訂單資訊。

分庫分表資訊具體包含哪些内容?第一部分有讨論到,我們将訂單表按uid次元拆分成了8個資料庫,每個資料庫10張表,最簡單的分庫分表資訊隻需一個長度為2的字元串即可存儲,第1位存資料庫編号,取值範圍1到8,第2位存表編号,取值範圍0到9。

還是按照第一部分根據uid計算資料庫編号和表編号的算法,當uid=9527時,分庫資訊=1,分表資訊=7,将他們進行組合,兩位的分庫分表資訊即為”17”。具體算法流程參見下圖:

幹貨:每秒處理10萬高并發訂單的支付系統架構幹貨:每秒處理10萬高并發訂單的支付系統架構

上述使用表編号作為分表資訊沒有任何問題,但使用資料庫編号作為分庫資訊卻存在隐患,考慮未來的擴容需求,我們需要将8庫擴容到16庫,這時取值範圍1到8的分庫資訊将無法支撐1到16的分庫場景,分庫路由将無法正确完成,我們将上訴問題簡稱為分庫資訊精度丢失。

為解決分庫資訊精度丢失問題,我們需要對分庫資訊精度進行備援,即我們現在儲存的分庫資訊要支援以後的擴容。這裡我們假設最終我們會擴容到64台資料庫,是以新的分庫資訊算法為:

分庫資訊 = (uid / 10) % 64 + 1

當uid=9527時,根據新的算法,分庫資訊=57,這裡的57并不是真正資料庫的編号,它備援了最後擴充到64台資料庫的分庫資訊精度。我們目前隻有8台資料庫,實際資料庫編号還需根據下面的公式進行計算:

實際資料庫編号 = (分庫資訊 - 1) % 8 + 1

當uid=9527時,分庫資訊=57,實際資料庫編号=1,分庫分表資訊=”577”。

由于我們選擇模64來儲存精度備援後的分庫資訊,儲存分庫資訊的長度由1變為了2,最後的分庫分表資訊的長度為3。具體算法流程也可參見下圖:

幹貨:每秒處理10萬高并發訂單的支付系統架構幹貨:每秒處理10萬高并發訂單的支付系統架構

如上圖所示,在計算分庫資訊的時候采用了模64的方式備援了分庫資訊精度,這樣當我們的系統以後需要擴容到16庫,32庫,64庫都不會再有問題。

上面的訂單ID結構已經能很好的滿足我們目前與之後的擴容需求,但考慮到業務的不确定性,我們在訂單ID的最前方加了1位用于辨別訂單ID的版本,這個版本号屬于備援資料,目前并沒有用到。下面是最終訂單ID簡化結構圖:

Snowflake算法:github.com/twitter/snowflake

三、最終一緻性

到目前為止,我們通過對order表uid次元的分庫分表,實作了order表的超高并發寫入與更新,并能通過uid和訂單ID查詢訂單資訊。但作為一個開放的集團支付系統,我們還需要通過業務線ID(又稱商戶ID,簡稱bid)來查詢訂單資訊,是以我們引入了bid次元的order表叢集,将uid次元的order表叢集備援一份到bid次元的order表叢集中,要根據bid查詢訂單資訊時,隻需查bid次元的order表叢集即可。

上面的方案雖然簡單,但保持兩個order表叢集的資料一緻性是一件很麻煩的事情。兩個表叢集顯然是在不同的資料庫叢集中,如果在寫入與更新中引入強一緻性的分布式事務,這無疑會大大降低系統效率,增長服務響應時間,這是我們所不能接受的,是以我們引入了消息隊列進行異步資料同步,來實作資料的最終一緻性。當然消息隊列的各種異常也會造成資料不一緻,是以我們又引入了實時監控服務,實時計算兩個叢集的資料差異,并進行一緻性同步。

下面是簡化的一緻性同步圖:

幹貨:每秒處理10萬高并發訂單的支付系統架構幹貨:每秒處理10萬高并發訂單的支付系統架構

四、資料庫高可用

沒有任何機器或服務能保證線上上穩定運作不出故障。比如某一時間,某一資料庫主庫當機,這時我們将不能對該庫進行讀寫操作,線上服務将受到影響。

所謂資料庫高可用指的是:當資料庫由于各種原因出現問題時,能實時或快速的恢複資料庫服務并修補資料,從整個叢集的角度看,就像沒有出任何問題一樣。需要注意的是,這裡的恢複資料庫服務并不一定是指修複原有資料庫,也包括将服務切換到另外備用的資料庫。

資料庫高可用的主要工作是資料庫恢複與資料修補,一般我們以完成這兩項工作的時間長短,作為衡量高可用好壞的标準。這裡有一個惡性循環的問題,資料庫恢複的時間越長,不一緻資料越多,資料修補的時間就會越長,整體修複的時間就會變得更長。是以資料庫的快速恢複成了資料庫高可用的重中之重,試想一下如果我們能在資料庫出故障的1秒之内完成資料庫恢複,修複不一緻的資料和成本也會大大降低。

下圖是一個最經典的主從結構:

幹貨:每秒處理10萬高并發訂單的支付系統架構幹貨:每秒處理10萬高并發訂單的支付系統架構

上圖中有1台web伺服器和3台資料庫,其中DB1是主庫,DB2和DB3是從庫。我們在這裡假設web伺服器由項目組維護,而資料庫伺服器由DBA維護。

當從庫DB2出現問題時,DBA會通知項目組,項目組将DB2從web服務的配置清單中删除,重新開機web伺服器,這樣出錯的節點DB2将不再被通路,整個資料庫服務得到恢複,等DBA修複DB2時,再由項目組将DB2添加到web服務。

當主庫DB1出現問題時,DBA會将DB2切換為主庫,并通知項目組,項目組使用DB2替換原有的主庫DB1,重新開機web伺服器,這樣web服務将使用新的主庫DB2,而DB1将不再被通路,整個資料庫服務得到恢複,等DBA修複DB1時,再将DB1作為DB2的從庫即可。

上面的經典結構有很大的弊病:不管主庫或從庫出現問題,都需要DBA和項目組協同完成資料庫服務恢複,這很難做到自動化,而且恢複工程也過于緩慢。

我們認為,資料庫運維應該和項目組分開,當資料庫出現問題時,應由DBA實作統一恢複,不需要項目組操作服務,這樣便于做到自動化,縮短服務恢複時間。

先來看從庫高可用結構圖:

幹貨:每秒處理10萬高并發訂單的支付系統架構幹貨:每秒處理10萬高并發訂單的支付系統架構

如上圖所示,web伺服器将不再直接連接配接從庫DB2和DB3,而是連接配接LVS負載均衡,由LVS連接配接從庫。這樣做的好處是LVS能自動感覺從庫是否可用,從庫DB2當機後,LVS将不會把讀資料請求再發向DB2。同時DBA需要增減從庫節點時,隻需獨立操作LVS即可,不再需要項目組更新配置檔案,重新開機伺服器來配合。

再來看主庫高可用結構圖:

幹貨:每秒處理10萬高并發訂單的支付系統架構幹貨:每秒處理10萬高并發訂單的支付系統架構

如上圖所示,web伺服器将不再直接連接配接主庫DB1,而是連接配接KeepAlive虛拟出的一個虛拟ip,再将此虛拟ip映射到主庫DB1上,同時添加DB_bak從庫,實時同步DB1中的資料。正常情況下web還是在DB1中讀寫資料,當DB1當機後,腳本會自動将DB_bak設定成主庫,并将虛拟ip映射到DB_bak上,web服務将使用健康的DB_bak作為主庫進行讀寫通路。這樣隻需幾秒的時間,就能完成主資料庫服務恢複。

組合上面的結構,得到主從高可用結構圖:

幹貨:每秒處理10萬高并發訂單的支付系統架構幹貨:每秒處理10萬高并發訂單的支付系統架構

資料庫高可用還包含資料修補,由于我們在操作核心資料時,都是先記錄日志再執行更新,加上實作了近乎實時的快速恢複資料庫服務,是以修補的資料量都不大,一個簡單的恢複腳本就能快速完成資料修複。

五、資料分級

支付系統除了最核心的支付訂單表與支付流水表外,還有一些配置資訊表和一些使用者相關資訊表。如果所有的讀操作都在資料庫上完成,系統性能将大打折扣,是以我們引入了資料分級機制。

我們簡單的将支付系統的資料劃分成了3級:

第1級:訂單資料和支付流水資料;這兩塊資料對實時性和精确性要求很高,是以不添加任何緩存,讀寫操作将直接操作資料庫。

第2級:使用者相關資料;這些資料和使用者相關,具有讀多寫少的特征,是以我們使用redis進行緩存。

第3級:支付配置資訊;這些資料和使用者無關,具有資料量小,頻繁讀,幾乎不修改的特征,是以我們使用本地記憶體進行緩存。

使用本地記憶體緩存有一個資料同步問題,因為配置資訊緩存在記憶體中,而本地記憶體無法感覺到配置資訊在資料庫的修改,這樣會造成資料庫中資料和本地記憶體中資料不一緻的問題。

為了解決此問題,我們開發了一個高可用的消息推送平台,當配置資訊被修改時,我們可以使用推送平台,給支付系統所有的伺服器推送配置檔案更新消息,伺服器收到消息會自動更新配置資訊,并給出成功回報。

六、粗細管道

黑客攻擊,前端重試等一些原因會造成請求量的暴漲,如果我們的服務被激增的請求給一波打死,想要重新恢複,就是一件非常痛苦和繁瑣的過程。

舉個簡單的例子,我們目前訂單的處理能力是平均10萬下單每秒,峰值14萬下單每秒,如果同一秒鐘有100萬個下單請求進入支付系統,毫無疑問我們的整個支付系統就會崩潰,後續源源不斷的請求會讓我們的服務叢集根本啟動不起來,唯一的辦法隻能是切斷所有流量,重新開機整個叢集,再慢慢導入流量。

我們在對外的web伺服器上加一層“粗細管道”,就能很好的解決上面的問題。

下面是粗細管道簡單的結構圖:

幹貨:每秒處理10萬高并發訂單的支付系統架構幹貨:每秒處理10萬高并發訂單的支付系統架構

請看上面的結構圖,http請求在進入web叢集前,會先經過一層粗細管道。入口端是粗口,我們設定最大能支援100萬請求每秒,多餘的請求會被直接抛棄掉。出口端是細口,我們設定給web叢集10萬請求每秒。剩餘的90萬請求會在粗細管道中排隊,等待web叢集處理完老的請求後,才會有新的請求從管道中出來,給web叢集處理。這樣web叢集處理的請求數每秒永遠不會超過10萬,在這個負載下,叢集中的各個服務都會高校運轉,整個叢集也不會因為暴增的請求而停止服務。