建設一個靠譜的火車票網上訂購系統 (續)
每到春運,買火車票就成為頭痛的事情。今年鐵道部開設了網上購票,本來是件惠民的好事兒,但是由于訂票網站 http://www.12306.cn,沒能快速地處理用戶的查詢和訂單,引起網友的冷嘲熱諷。
@王津 THU 在微博上替 12306 辯解了幾句 [1],立刻成為眾矢之的。王津有點冤,首先 12306 系統的確有技術難度,初次亮相,出點洋相,在所難免。其次,王津似乎沒有參與 12306 項目,大家罵錯了人。
即便王津是項目負責人,大家開罵也不解決問題。今年罵完了,明年是不是接著罵?不如討論一些有建設性的設計方案,但愿明年春運時,大家能夠輕松買到車票。
有評論說,“你們這些建議都是 YY,鐵道部不會聽你的”。
你說了,鐵道部不一定會聽。但是你不說,它想聽也聽不到。為自己,為親友,為老百姓,說總比不說好。
又有評論說,你們這些設計,“都是大路貨,沒技術含量”。
12306 網站不是研究項目,而是旨在解決實際問題。此類系統的設計原則,實效是首要目標,創新是次要目標。
@簡悅云風提了個建議,分時出票,均攤流量。“賣票這種事情,整個需求量(總出票數)擺在那里在。把峰值請求壓下來在時間軸上(前后要賣幾百小時呢)平攤,業務量就那么點。網站被峰值請求沖掛了,只能是因為簡單的問題都沒處理好”[2]。
這個辦法的確沒有什么技術含量,但是很明快很實用,所以是值得推崇的好辦法好思路。
說實話,像 12306 這樣受眾廣大的系統,能不創新,盡量別創新。因為創新是有風險的,在 12306 網站玩創新,你是不是把上億著急回家過年的老百姓,當成實驗小白鼠了?
創新主要是學界的活兒。學界強調另辟蹊徑,即便新路不如老路好走,但是或許在某某情況下,新路的辦法有一定優勢。如果是這樣,新路仍然有存在的價值。
務虛完畢,下面務實。
一、找到核心問題。
1月 12 日,拙作“建設一個靠譜的火車票網上訂購系統”發表后[3],收到不少同行的反饋。歸納一下,主要有兩類評論,
1. “真正的瓶頸,一般會出在數據庫上,怎么解決數據的問題,才是核心”。
2. “如果大量的黃牛阻塞隊列或者被 DDOS 攻擊的情況下,普通用戶會等到崩潰”。
也就是說,支付與登錄是 12306 系統的兩大短板。
@FireCoder 著文分析 12306 的用戶體驗和系統瓶頸,印證了上述兩個問題。
“最難的兩關是登陸和支付,這也是用戶體驗最糟糕的兩步。登陸是最難闖的一關,驗證碼驗證碼驗證碼…,每次嘗試等待若干時間,然后總是一個系統繁忙。這是令人著急和上火的一步。支付則是悲催的一步,訂單到手,接著在 45 分鐘內超時自動取消” [4]。
官方新聞報導,也證實了這兩個問題很突出。
“今年購買火車票最大亮點是,可以登錄 www.12306.cn 中國鐵路客戶服務中心,在網上訂票。最新統計顯示,7天內,12306網站訪問用戶已占全球互聯網用戶的 0.902%,每天點擊量高達 10 億人次。12306 網站的帶寬已經從最初的 400 兆擴充到了1.5G,但是每天 10 億次的點擊量,仍然彌補不了網上登錄和支付的短版。據了解,12306 網站正在進行后臺調試,爭取讓訂票和網上支付系統分開運行,互不交叉,避免擁堵,讓整個訂票支付流程更加順暢” [5]。
二、單機與分布式。
有人問,12306 訂票系統,為什么不用現成的 IBM z/TPF?
@周洪波-TSP 老師回復,“z/TPF 目前仍然是集中式交易處理量最大的,不過如果每張票都要經過 TPF 做唯一性 TP 確認,z/TPF 也是遠遠不能達到中國鐵路處理量要求,需要分布式處理和緩存(隊列)技術來分散壓力” [6]。
贊同周老師的觀點。好漢難敵四虎,再彪悍的武士,也抵擋不住千軍萬馬的圍攻。對于中國春運這樣的流量沖擊,再牛的單機終歸會有容量上限,所以單機基本不靠譜。
靠譜的辦法,是分布式。分布式需要解決的問題,是如何切割。切割流程,切割數據。
三、橫向切割流程。
拙文 [3] 討論了把 12306 系統,按登錄、查詢、訂票三類業務,切割成三種流程。其中查詢業務,又可以再切割成三種,查詢車次時間表、查詢某車次余票、查詢某用戶訂購了哪些車票。
為什么要不厭其煩地切割流程?因為不同流程的環節構成不同,不同流程用到的數據也不一樣,有些是靜態數據,例如車次時間表,有些是動態數據,例如余票和乘客訂購的車次座位。分而治之,有利于優化效率,也有利于讓系統更皮實,更容易維護。
靜態數據,更新少,盡可能存放在緩存(Cache)里,讀起來快,而且不給數據庫添麻煩。例如車次路線和時間表查詢,就應該這樣處理。
只有動態數據,才必須存放在數據庫中。動態數據在數據庫中,存放的方式是表。例如,查詢余票與訂票,就必須這樣處理。
四、切割數據。
在 12306 系統中,最關鍵的數據,是各個車次各個座位的訂購狀態。存放這些數據的數據格式,是訂票表。
最簡單的訂票的表設計,或許是設置若干列(車次,日期,座位,路段1,…路段N)。例如高鐵 G19,從北京始發,途徑濟南和南京,終點是上海,共三個路段。乘客甲,訂購某日 G19 某座位,從北京始發,途徑濟南,到南京下車。乘客乙也訂購了同日同車次同座位,但是從南京上車,到上海下車。那么這張表中,就會有一行,(G19,X 日,Y座位,乘客甲 ID,乘客甲 ID,乘客乙 ID)。
如果把全國所有日期的所有車次,全部集中在一個數據庫實例的同一張表中,那么勢必造成數據庫的擁塞。所以,必須對表做切割。
@李思 Samuel 建議橫向切,也就是按行切,“假定現在有 100 張北京到上海的車票可售,如果有 10 個衛星數據庫,那么在未來 1 秒內,每個衛星數據庫各有 10 張票可售。1 秒以后,各衛星數據庫向中心數據庫提交本地余票量,并由中心數據庫重新分配”[7]。
這個辦法的確可以達到減少中心數據庫負載的目的。但是顧慮是衛星數據庫,必須頻繁地與中心數據庫同步(李思建議每一秒同步一次)。同步不僅導致內網中的數據流量加大,另外,同步需要上鎖。分布式鎖機制相當復雜,也容易出故障。實際運行中,搞不好會出亂子。
我的辦法是縱向切,根據不同車次,以及同一個車次的不同日期,切成若干表,放進多個數據庫中去。這樣,每張表只有(座位,經停站1, … 經停站N)幾列。假如每趟火車的載客人數不超過 5000 人,那么每張表的行數也不會超過 5000 行。
同一個車次,不同日期,分別有一張表。這樣做的好處是,可以方便地實現分時出票。假如提前十天出票,今天是 1 月 16 日,那么在 G19 車次的數據庫中,存放著 1 月 16 日到 1 月 26 日的 10 張表,今晚打烊期間,數據庫清除今天的表,并轉移到備份數據庫中,作為歷史記錄。同時增添 1 月 27 日的表。明天一早開門營業時,乘客就可以預定 1 月 27 日的車票了。
把不同車次的表,分別存放在不同的數據庫中去,可以有效降低在每個數據庫外面,用戶排隊等待的時間,同時也避免了同步和上鎖的麻煩。
另外,假如每趟火車的座位不超過 5000 個,每趟火車沿線停靠的車站不超過 50 個,那么每個車次數據庫外面,排隊訂票的隊列長度,不必超過 50 x 5000 = 250,000。理由是,火車上每個座位,最多被 50 位乘客輪流坐,這種極端情況,出現在每位乘客只坐一站。
五、訂票流程。
圖一。訂票流程的異步的事件驅動的服務協作模式。
Courtesy http://pic004.cnblogs.com/news/201201/20120117_214459_3.jpg
圖一描述了訂票的內部流程。例如有乘客想訂兩張聯票,G11從北京到南京,然后 D3068 從南京到合肥。他從查詢頁面看到這兩趟列車有余票,于是他點擊訂票。
“訂票拆解”服務收到他的訂票請求后,先通知“下單調度”服務,跟蹤和處理該訂單的后續工作,參見圖中1.1和1.2。然后“訂票拆解”服務分別向 G11 和 D3068 兩個車次的預訂隊列,插入請求,分別預訂兩個座位,參見1.3。
G11和 D3068 兩個車次的訂票請求,在各自的預訂隊列中排隊等待。排隊結束后,G11和 D3068 的“預訂隊列”服務,分別查詢各自的數據庫,是否還剩余兩個座位,參見2.1。
G11車次數據庫收到指令后,查詢訂票表中,是否有兩行(對應兩個座位),從北京到南京途經的各個路段,對應的列的值,是否都是空。如果有,把這些值改寫為訂單中的乘客 ID。
如果預訂成功,G11車次“預訂隊列”服務,把訂單號以及預訂的座位號等等,發送給“下單調度”服務。如果沒有余票,預訂的座位號為空。參見2.2。
“下單調度”服務,會先后收到 G11 和 D3068 兩個“預訂隊列”服務,發來的預訂信息。只有 G11 和 D3068 都預訂成功,“下單調度”服務才會指揮網站前端,顯示網銀下單網頁,參見3.1和3.2。
彈出網銀下單網頁后,如果在 45 分鐘內,“下單調度”服務收到網銀的回執,匯款到賬,那么“下單調度”服務就通知用戶,訂票成功,以及座位號,參見4.1。如果沒有及時收到匯款,“下單 調度”服務就給車次數據庫發指令,讓它們把預訂座位相應的數據,逐一清零,參見4.2。
六、縱向切割流程。
前文中談到流程切割,主要是按照業務類型切割,是橫向切割。對于某一個業務流程,例如訂票流程,還可以根據不同環節,做縱向切割。
圖一描述了幾個服務,分別是“訂票拆解”、“下單調度”、“預訂隊列”、和“網銀下單”。之所以是“服務”,而不是模塊,是因為這些業務邏輯,各自運行在相互獨立的線程上,甚至不同機器上。
在沒有任務時,這些服務的線程處于等待狀態。一旦接收到任務,線程被激活。所以,訂票系統是異步的(Asynchronous)事件驅動的 (Event-driven)的系統架構[8]。這種系統架構,在當下被稱作,面向服務的系統架構(Service-Oriented Architecture,SOA)。
之所以采用面向服務的系統架構,最主要的動機是方便擴展吞吐量。
例如在圖一中,“下單調度”是一個樞紐,如果流量壓力太大,單個機器承受不住怎么辦?采用了上述設計,只要加機器就行了,方便,有效,皮實。
七、登錄流程。
除了支付是短板以外,登錄也是突出問題,尤其是大量用戶不斷刷屏,導致登錄請求虛高。
應對登錄洪峰的辦法,說來簡單,可以放置一大排 Web Servers。每個 Web Server 只做非常簡單的工作,讀用戶請求的前幾個 Bytes,根據請求的業務類型,迅速把用戶請求扔給下家,例如查詢隊列。
Web Server 不甄別用戶是否在刷屏,它來者不拒,把用戶請求(也許是刷屏的重復請求),扔給業務排隊隊列。隊列先查詢用戶 ID 是否已經出現在隊列中,如果是,那么就是刷屏,不予理睬。只有當用戶 ID 是新鮮的,隊列才把用戶請求,插入隊尾。
這個辦法不難,但是經受住了實踐考驗。
例如 2009 年 1 月 20 日,奧巴馬就任美國總統,并發表演說。奧巴馬就職典禮期間,推ter 網站每秒鐘收到 350 條新短信,這個流量洪峰維持了大約 5 分鐘。根據統計,平均每個 推ter 用戶被其他 120 人關注,也就是說,每秒 350 條短信,平均每條都要發送 120 次。這意味著,在這持續 5 分鐘的洪峰時刻,推ter 網站每秒鐘需要發送 350 x 120 = 42,000 條短信。
推ter 應對洪峰流量的辦法,與我們的設計相似,參見拙作“解剖 推ter,4”[9]。
有觀點質疑,“推ter 業務沒有交易, 2 Phase Commit, Rollback 等概念”,所以 推ter 的做法,未必能沿用到 12306 網站中來 [6]。
這個問題問得好,但是交易、二次確認、回放等等環節,都出現在 12306 系統的后續業務流程中,尤其是訂票流程中,而登錄發生在前端。
我們設計的出發點,是前端迅速接納,但是后端推遲服務,一言以蔽之,通過增加前端 Web Servers 機器數量來蓄洪。
又有觀點質疑,通過蓄洪的辦法,推ter 每秒能處理 42,000 條短信,但是 12306 面對的洪峰流量遠遠高過這個數量。增加更多前端 Web Servers 機器,是否能如愿地抵抗更大的洪峰呢?
每逢“超級碗 SuperBowl”橄欖球賽,推ter 的流量就大漲。根據統計,在 SuperBowl 比賽時段內,每分鐘 推ter 的流量,與當日平均流量相比,平均高出 40%。在比賽最激烈時,更高達 150% 以上。
面對排山倒海的洪峰流量,推ter 還是以不變應萬變,通過增加服務器的辦法來蓄洪抗洪。更確切地說,推ter 臨時借用第三方的服務器來蓄洪,而且根據實時流量,動態地調整借用服務器的數量 [10]。
值得注意的是,推ter 把借來的服務器,主要用于前端,增加 Apache Web Servers 的數量。而不是擴充后端,以便加快推送等等業務的處理速度。
這一細節,進一步證實 推ter 的抗洪措施,與我們的相似。強化蓄洪能力,而不必過份擔心泄洪能力。
Reference,
[1] “海量事務高速處理系統”是一種非常特別的系統,懇請大家不臆測不輕視類似 12306 系統的難度。
http://weibo.com/2484714107/y0i3b53dd
[2] @簡悅云風的微博
http://weibo.com/deepcold
[3] 建設一個靠譜的火車票網上訂購系統
http://blog.sina.com.cn/s/blog_46d0a3930100yc6x.html
[4] 12306 的問題
http://blog.csdn.net/firecoder/article/details/7197959
[5] 鐵道部訂票網站或分開運行訂票與支付系統
http://news.qq.com/a/20120116/000024.htm
[6] @周洪波-TSP 的微博
http://weibo.com/iotcloud
[7] @李思 Samuel 的微博
http://weibo.com/u/1400321871
[8] SEDA: An Architecture for Well-Conditioned,Scalable Internet Services
http://www.eecs.harvard.edu/~mdw/papers/seda-sosp01.pdf
[9] 解剖 推ter,4 抗洪需要隔離
http://blog.sina.com.cn/s/blog_46d0a3930100fd5c.html
[10] 解剖 推ter,6 流量洪峰與云計算
http://blog.sina.com.cn/s/blog_46d0a3930100fgin.html