淺談12306核心模型設計思路和架構設計

摘要:我發現也許是不是由於目前12306的核心領域模型設計的不夠好,致使用戶購票時要處理的業務邏輯異常複雜,維護數據一致性的難度也幾百倍的上升,同時面對高併發的訂票也難以支持很高的TPS。我以爲,越是複雜的業務,就越要重視業務分析,重視領域模型的抽象和設計。若是不假思索,憑以往經驗行事,則極可能會被以往的設計經驗先入爲主,陷入死衚衕。我發現技術人員每每更注重技術層面的解決方案,好比一上來就分析如何集羣、如何負載均衡、如何排隊、如何分庫分表、如何用鎖,如何用緩存等技術問題,而忽略了最根本的業務層面的思考,如分析業務、領域建模。我認爲越是複雜的業務系統,則越要設計一個健壯的領域模型。若是一個系統的架構咱們設計錯了,還有補救的餘地,由於架構最終沉澱的只是代碼,調整架構便可(一個系統的架構自己就是不斷演進的);而若是領域模型設計錯了,那要補救的代價是很是大的,由於領域模型沉澱的是數據結構及其對應的大量數據,對任何一個大型系統,要改核心領域模型都是成本很是高的數據庫

需求簡述

12306這個系統,核心要解決的問題是網上售票。涉及到2個角色使用該系統:用戶、鐵道部。用戶的核心訴求是查詢餘票、購票;鐵道部的核心訴求是售票。購票和售票實際上是一個場景,對用戶來講是購票,對鐵道部來講是售票。所以,咱們要設計一個在線的網站系統,解決用戶的查詢餘票、購票,以及鐵道部的售票這3個核心訴求。看起來,這3個場景都是圍繞火車票展開的。緩存

 

查詢餘票:用戶輸入出發地、目的地、出發日三個條件,查詢可能存在的車次,用戶能夠看到每一個車次通過的站點名稱,以及每種座位的餘票數量。數據結構

 

購票:購票分爲訂票和付款兩個階段,本文重點分析訂票的模型設計和實現思路。架構

 

其實還有不少其餘的需求,好比給不一樣的車次設定銷售座位數配額,以及不一樣的區段設置不一樣的限額。我以爲這個需求不是核心最重要的訴求,因此,本文針對這個需求不作具體討論,也不是本文分析設計的重點。併發

 

需求分析

確實,12306也是一個電商系統,並且看起來商品就是票了。由於若是把一張票當作是一個商品,那購票就相似於購買商品,而後每張票都有庫存,商品也有庫存的概念。可是若是咱們仔細想一想,會發現12306要複雜不少,由於咱們沒法預先肯定好全部的票,若是非要肯定,那隻能經過窮舉法了。負載均衡

咱們以北京西到深圳北的G71車次高鐵爲例(這裏只考慮南下的方向,不考慮深圳北到北京西的,那是另一個車次,叫G72),它有17個站(北京西是01號站,深圳北是17號站),3種座位(商務、一等、二等)。表面看起來,這不就是3個商品嗎?G71商務座、G71一等座、G71二等座。大部分輕易噴12306的技術人員(包括某些中等規模公司的專家、CTO)就是在這裏栽第一個跟頭的。框架

 

實際上,這個車次能夠賣的票是很是多的。爲了方便後面的討論,咱們先明確一下票是什麼?分佈式

 

一張票的核心信息包括:出發時間、出發地、目的地、車次、座位號。持有票的人就擁有了一個憑證,該憑證表示持有它的人能夠坐某個車次的某個座位號,從某地到某地。因此,一張票,對用戶來講是一個憑證,對鐵道部來講是一個承諾;那對系統來講是什麼呢?不知道。這就是咱們要分析業務,領域建模的緣由,咱們再繼續思考吧。高併發

 

明白了票的核心信息後,咱們再看看G71這個車次的高鐵,能夠賣多少張票?性能

討論前先說明一下,一輛火車的物理座位數(站票也能夠當作是一種座位,由於站票也有數量配額)不等於可用的最大配合。全部的物理座位不可能都經過12306網站來銷售,而是隻會銷售一部分,好比40%。其他的仍是會經過線下的方式銷售。不只如此,可能有些站點上車的人會比較多,有些比較少,因此咱們還會給不一樣的區間配置不一樣的限額。好比D31北京南至上海共有765張,北京南有260張,楊柳青有80張,泰安有76張。若是楊柳青的80張票售完就會顯示無票,就算其餘站有票也會顯示無票的。無論如何配置限制區段的配額和限額,咱們老是針對車次進行配置,這點只是車次內部售票時的一些額外的判斷條件(業務規則),不影響車次模型的核心地位。因此,爲了本文討論的清楚起見,我後續的討論都不涉及配額和限額的問題,而是認爲任何區段均可以享受火車最大的物理座位數。

 

爲了討論問題方便,咱們減小一些站點來討論。假設某個車次有A,B,C,D四個站點。那001這我的購買了A,B這個區間,系統會分配給001一個座位x;可是由於001坐到B站點後會下車,因此至關於x這個座位又空出來了,也就是說,從B站點開始,系統又能夠認爲x這個座位是可用的。因此,咱們得出結論:同一個座位,其實能夠同時出售AB,BC這兩張票。經過這個簡單的分析,咱們知道,一列火車雖然只有有限的座位數,好比1000個座位。但能夠賣出的票遠遠不止1000個。仍是以A,B,C,D四個站點爲例,假如火車總共有1000個座位,那AB能夠賣1000張,BC也能夠賣1000張,一樣,CD也能夠賣1000張。也就是說,理論上最多能夠賣出3000張票。可是若是換一種賣法,全部人都是買ABCD的票,也就是說全部的票都是通過全部站點的,那就是最多隻能賣出1000張票了。而實際的場景,必定是介於1000到3000之間。而後實際的G71這個車次,有17個站,那到底能夠賣出多少個票,你們應該能夠算了吧。理論上這17個站中的任意兩個站點之間所造成的線段,均可以出售爲一張票。我數學很差,算不太清楚,麻煩有數學好的人幫我算算,呵呵。

 

經過上面的分析,咱們知道一張票的本質是某個車次的某一段區間(一條線段),這個區間包含了若干個站點。而後咱們還發現,只要區間不重疊,那座位就不會發生競爭,能夠被回收利用,也就是說,能夠同時預先出售。

另外,通過更深刻的分析,咱們還發現區間有4種關係:1)不重疊;2)部分重疊;3)徹底重疊;4)覆蓋;不重疊的狀況咱們已經討論過了,而覆蓋也是重疊的一種。因此咱們發現若是重疊,好比有兩個區間發生重疊,那重疊部分的區間(可能誇一個或多個站點)是在爭搶座位的。由於假設一列火車有100個座位,那每一個原子區間(兩個相鄰站點的連線),最多容許重疊99次。

 

因此,通過上面的分析,咱們知道了一個車次可以出售一張車票的核心業務規則是什麼?就是:這張車票所包含的每一個原子區間的重疊次數加1都不能超過車次的總座位數,實際上重疊次數+1也能夠理解爲線段的厚度。

 

模型設計

上面我分析了一下票的本質是什麼。那接下來咱們再來看看怎麼設計模型,來快速實現購票的需求,重點是怎麼設計商品聚合以及減庫存的邏輯。

 

傳統電商的思路

若是按照普通電商的思路,把票(站點區間)設計爲商品(聚合根),而後爲票設計庫存數量。我我的以爲是很糟糕的。由於一方面這種聚合根很是多,另外一方面,即使枚舉出來了,一次購票也必定會影響很是多其餘聚合根的庫存數量(只要被部分或所有重疊的區間都受影響)。這樣的一次訂單處理的複雜度是難以評估的。並且這麼多聚合根的更新要在一個事務裏,這不是爲難數據庫嗎?並且,這種設計必然帶來大量的事務的併發衝突,極可能致使數據庫死鎖。總之,我認爲這種是典型的因爲領域模型的設計錯誤,致使併發衝突高、數據持久化落地困難。或者若是要解決併發問題,只能排隊單線程處理,可是仍然解決不了要在一個事務裏修改大量聚合根的尷尬局面。據說12306是採用了Pivotal Gemfire這種高大上的內存數據庫,我對這個不太瞭解。我不可想象要是不使用內存數據庫,他們要怎麼實現車次內的票之間的數據強一致性(就是保證全部出售的票都是符合上面討論的業務規則的)?

 

個人思路

經過上面的分析咱們知道,其實任何一次購票都是針對某個車次的。咱們看看一個車次包含了哪些信息?一個車次包括了:1)車次名稱,如G71;2)座位數,實際座位數會分類型,好比商務座20個,一等座200個;二等座500個;咱們這裏爲了簡化問題,能夠暫時忽略類型,我認爲這個類型不影響核心的模型的設計決策。須要格外注意的是:這裏的座位數不要理解爲真實的物理座位數,頗有可能比真實的座位數要少。由於咱們不可能把一個車次的全部座位都在網上經過12306來出售,而是隻出售一部分,具體出售多少,要由工做人員人工指定。3)通過的站點信息(包括站點的ID、站點名稱等),注意:車次還會記錄這些站點之間的順序關係;4)出發時間;看過GRASP九大模式中的信息專家模式的同窗應該知道,將職責分配給擁有執行該職責所需信息的類。咱們這個場景,車次具備一次出票的全部信息,因此咱們應該把出票的職責交給車次。另外學過DDD的同窗應該知道,聚合設計有一個原則,就是:聚合內強一致性,聚合之間最終一致性。通過上面的分析,咱們知道要產生一張票,其實要影響不少和這個票對應的直線相交的其餘票的可用數量。由於全部的站點信息都在車次聚合內部,因此車次聚合內部天然能夠維護全部的原子區間,以及每一個原子區間的可用票數(至關因而庫存數)。當一個原子區間的可用票數爲0的時候,意味着火車針對這個區間的票已經賣完了。因此,咱們徹底可讓車次這個聚合根來保證出票時對全部原子區間的可用票數的更新的強一致性。對於車次聚合根來講,這很簡單,由於只是幾回簡單的內存操做而已,耗時能夠忽略。一列火車假若有ABCD四個站點,那原子區間就是3個。對於G71,則是16個。

 

而後基於上面的聚合設計,出票時扣減庫存的邏輯是:

根據訂單信息,拿到出發地和目的地,而後獲取這段區間裏的全部的原子區間。而後嘗試將每一個原子區間的可用票數減1,若是全部的原子區間都夠減,則購票成功;不然購票失敗,提示用戶該票已經賣完了。是否是很簡單呢?知道了出票的邏輯,那退票的邏輯也就很簡單了,就是把這個票的全部原子區間的可用票數加1就OK了。若是咱們從線段的厚度的角度去考慮,那出票時,每一個原子區間的厚度就是+1,退票時就是減一。就是相反的操做,但本質是同樣的。

 

因此,經過這樣的思路,咱們將一次訂票的處理控制在了一個聚合根裏,用聚合根內的強一致性的特性保證了訂票處理的強一致性,同時也保證了性能,免去了併發衝突的可能性。傳統電商那種把票單作相似商品的核心聚合根的設計,我當時第一眼看到就以爲不妥。由於這違背了DDD強調的強一致性應該由聚合根來保證、聚合根之間的最終一致性經過Saga來保證的原則。

 

還有一個很重要的概念我想說一下個人見解,就是座位和區間的關係。由於有些朋友和我講,考慮座位號的問題,雖然都能減1,座位號也必須是同一個。我以爲座位是全局共享的,和區段無關(也許個人理解徹底有誤,請你們指正)。座位是一個物理概念,一個用戶成功購買了一張票後,座位就會少一個,一張票惟一對應一個座位,可是一個座位有可能會對應多張票;而區間是一個邏輯上的概念,區間的做用有兩個:1)表示票的出發地和目的地;2)記錄票的可用數額。若是區間能連通(即該區間內的每一個原子區間的可用數額都大於0),則表示容許擁有一個座位。因此,我以爲座位和票(區間)是兩個維度的概念。

 

模型分析總結:我認爲票不是核心聚合根,票只是一個計算的結果,一個憑證而已,票自己沒有什麼邏輯;12306真正的核心模型應該是車次,車次具備出票的職責,並以強一致性的方式維護一次出票(或退票)時全部原子區間的可用票數。

 

架構設計

我以爲12306這樣的業務場景,很是適合使用CQRS架構;由於首先它是一個查多寫少、可是寫的業務邏輯很是複雜的系統。因此,很是適合作架構層面的讀寫分離,即採用CQRS架構。並且應該使用數據存儲也分離的CQRS。這樣CQ兩端才能夠徹底不須要顧及對方的問題,各自優化本身的問題便可。咱們能夠在C端使用DDD領域模型的思路,用良好設計的領域模型實現複雜的業務規則和業務邏輯。而Q端則使用分佈式緩存方案,實現可伸縮的查詢能力。

 

訂票的實現思路

同時藉助像ENode這樣的框架,咱們能夠實現in-memory + Event Sourcing的架構。Event Sourcing技術,可讓領域模型的全部狀態修改的持久化統一塊兒來,原本要用ORM的方式保存聚合根最新狀態的,如今只須要簡單的通用的方式保存一個事件便可(一次訂票只涉及一個車次聚合根的修改,修改只產生一個事件,只須要持久化一個事件(一個JSON串)便可,保證了高性能,無須依賴事務,並且經過ENode能夠解決併發問題)。咱們只要保存了聚合根每次變化的事件(事件的結構怎麼設計,本文不作多的介紹了,你們能夠思考下),就至關於保存了聚合根的最新狀態。而正是因爲Event Sourcing技術的引入,讓咱們的模型能夠一直存活在內存中,便可以使用in-memory技術。不要小看in-memory技術,in-memory技術在某些方面對提升命令的處理性能很是有幫助。好比就以咱們車次聚合根處理出票的邏輯,假設某個車次有大量的命令發送到分佈式消息隊列,而後有一臺機器訂閱了這個隊列的消息,而後這臺機器處理這個車次的訂票命令時,因爲這個車次聚合根一直在內存,因此就省去了每次要去數據庫取出聚合根的步驟,至關於少了一次數據庫IO。這樣的好處是,由於一個車次可以真正出售的票是有限的,由於座位就那麼幾個,好比就1000個座位,估計通常正常狀況也就出個2000個左右的座位吧(具體能出多少張票要取決於區間的相交程度,上面分析過)。也就是說,這個聚合根只會產生2000個事件,也就是說只會有2000個訂票命令的處理是會產生事件,並持久化事件;而其他的大量命令,由於車次在內存計算後發現沒有餘票了,就不會作任何修改,也不會產生領域事件,這樣就能夠直接處理下一個訂票命令了。這樣就能夠大大提升處理訂票命令的性能。

 

另一個問題我以爲還須要提一下,由於用戶訂票成功後,還須要付款。但用戶有可能不去付款或者沒有在規定的時間內完成付款。那這種狀況下,系統會自動釋放該用戶以前訂購的票。因此基於這樣的需求,咱們在業務上須要支持業務級別的2pc。即先預扣庫存,也就是先佔住這張票必定時間(好比15分鐘),而後付款成功後再真實給你這張票,系統作真正的庫存修改。經過這樣的預扣處理,能夠保證不會出現超賣的狀況。這個思路其實和傳統電商好比淘寶這樣的系統相似,我就很少展開了,我以前寫的Conference案例也是這樣的思路,你們有興趣的能夠去看一下我以前錄製的視頻。

 

查詢餘票的實現思路

我以爲餘票的查詢的實現相對簡單。雖然對於12306來講,查詢的請求佔了80%,提交訂單的請求只佔20%。但查詢因爲對數據沒有修改,因此咱們徹底可使用分佈式緩存來實現。咱們只須要精心設計好緩存的key便可;緩存key的多少要當作本,若是全部可能的查詢都設計對應的key,那時間複雜度爲1,查詢性能天然高;但代價也大,由於key多了。若是想key少一點,那查詢的複雜度天然要上去一點。因此緩存設計無非就是空間換時間的思路。而後,緩存的更新無非就是:自動失效、定時更新、主動通知3種。經過CQRS架構,因爲CQ兩端是事件驅動的,當C端有任何狀態變化,都會產生對應的事件去通知Q端,因此咱們幾乎能夠作到Q端的準實時更新。

 

同時因爲CQ兩端的徹底解耦,Q端咱們能夠設計多種存儲,如數據庫和緩存(Redis等);數據庫用於線下維護關係型數據,緩存用戶實時查詢。數據庫和緩存的更新速度相互不受影響,由於是並行的。對同一個事件,能夠10臺機器負責更新緩存,100臺機器負責更新數據庫。即使數據庫的更新很慢,也不會影響緩存的更新進度。這就是CQRS架構的好處,CQ的架構徹底不一樣,且咱們隨時能夠重建一種新的Q端存儲。不知道你們體會到了沒有?

 

關於緩存key的設計,我以爲主要從查詢餘票時傳遞的信息來考慮。12306的關鍵查詢是:出發地、目的地、出發日期三個信息。我以爲有兩種key的設計思路:1)直接設計了該查詢條件的key,而後快速拿到車次信息,直接返回;這種方式就是要求咱們系統已經枚舉了全部車次的全部可能出現的票(區間)的緩存key,相信你必定知道這樣的key是很是多的。2)不是枚舉全部區間,而是把每一個車次的每一個原子區間(相鄰的兩個站點所連成的直線)的可用票數做爲key。這樣,key就很是少了,由於車次假若有10000個,而後每一個車次平均15個區間,那也就15W個key而已。當咱們要查詢時,只須要把用戶輸入的出發地和目的地之間的全部原子區間的可用票數都查出來,而後比較出最小可用票數的那個原子區間。則這個原子區間的可用票數就是用戶輸入的區間的可用票數了。固然,到這裏我提到考慮出發日期。我認爲出發日期是用來決定具體是哪一個車次聚合根的。同一個車次,不一樣的日期,對應的聚合根實例是不一樣的,即使是同一天,也可能有多個車次聚合根,由於有些車次一天有幾班的,好比上午9點發車的一班,下午3點發車的通常。因此,咱們也只要把日期也做爲緩存key的一部分便可。

 

總結

本文徹底是憑本身對12306這個網站的核心業務的簡單思考而獲得的一些設計結果。若是真正的DDD領域建模,更多的是要和業務一線的工做人員、領域專家進行深刻溝通,才能更深刻的瞭解該領域內的業務知識,從而才能設計出更靠譜的領域模型和架構設計。我本人很是慚愧由於沒有上12306買過火車票,家離的比較近,就算要買也是家人給我買:)因此,本文所分享的內容不免是紙上談兵。但我以爲12306這個系統的業務確實比傳統的電商系統要複雜,且併發又這麼高。因此,我以爲這個系統真的很值得你們重視模型的設計,而不僅是隻關注技術層面的實現。

相關文章
相關標籤/搜索