閃購(gòu),或者秒殺,對(duì)于現(xiàn)代互聯(lián)網(wǎng)用戶來(lái)說(shuō)已經(jīng)是一件司空見(jiàn)慣的事情。雙十一、各大節(jié)慶日、限量供應(yīng)的緊俏產(chǎn)品上線,大量用戶在同一時(shí)間涌入網(wǎng)站搶購(gòu),突如其來(lái)的流量給網(wǎng)站帶來(lái)了很大壓力。那么各大電商網(wǎng)站是如何處理流量爆發(fā)的?
Shopify是加拿大著名的電子商務(wù)軟件解決方案提供商,來(lái)自Shopify性能工程團(tuán)隊(duì)的Emil Stolarsky分享了Shopify的閃購(gòu)限流解決方案。
第一波閃購(gòu)和來(lái)自卡戴珊家族的閃購(gòu)挑戰(zhàn)
Shopify成立于2006年,在它成立的第二年,也就是2007年,Shopify經(jīng)歷了第一波閃購(gòu)。Super Bowl在美國(guó)的收視率多年來(lái)一直名列前茅,在比賽結(jié)束之后,冠軍球隊(duì)的球迷們會(huì)涌入網(wǎng)站購(gòu)買冠軍球隊(duì)的紀(jì)念T恤,Shopify因此第一次經(jīng)歷了閃購(gòu)流量給網(wǎng)站帶來(lái)的沖擊。在那之后,應(yīng)對(duì)像Super Bowl那樣的閃購(gòu)流量便成為家常便飯。
來(lái)自卡戴珊家族的Kylie Jenner也是Shopify的用戶,她在Shopify上售賣自己設(shè)計(jì)的化妝品。因?yàn)樗姆劢z數(shù)量眾多,每次有新產(chǎn)品上線,粉絲們都會(huì)不約而同地沖入網(wǎng)站搶購(gòu)。黑色星期五的熱賣不過(guò)每年一次,但像Kylie這樣的閃購(gòu)幾乎每周都在發(fā)生。在2016年2月的一次閃購(gòu)中,瘋狂的流量沖垮了她的店鋪,同時(shí)也拖垮了整個(gè)數(shù)據(jù)庫(kù)。因?yàn)閿?shù)據(jù)庫(kù)是共享的,直接導(dǎo)致其它店鋪也跟著遭殃。這一事件引起了媒體的關(guān)注。Shopify需要找到更好的方案來(lái)避免同樣的事故再次發(fā)生。
秘密武器:Nginx+Lua
在Shopify架構(gòu)的最外層,是Nginx和OpenResty的Lua模塊。他們將這個(gè)模塊集成到Nginx的事件模型里,然后編寫Lua腳本來(lái)處理請(qǐng)求消息流和響應(yīng)消息流,有點(diǎn)類似Rack中間件。這樣做的好處在于,寥寥幾個(gè)工程師所做的修改就能夠影響到整個(gè)站點(diǎn),而且不需要對(duì)應(yīng)用做大手術(shù)。
下面給出一個(gè)代碼片段例子,通過(guò)Lua腳本在響應(yīng)消息頭里添加一個(gè)元數(shù)據(jù)。
events { worker_connections 42;}http { server { listen 8080; header_filter_by_lua_block { ngx.header['X-Epoch'] = ngx.time() } location / { return 200 'Hello, World!'; } }}類似的方法可以適用于很多場(chǎng)景,包括將HTTP路由到多個(gè)數(shù)據(jù)中心、對(duì)請(qǐng)求消息流進(jìn)行分片、防御DDoS攻擊。
結(jié)算限流
Shopify團(tuán)隊(duì)通過(guò)使用Lua腳本在Nginx層解決負(fù)載問(wèn)題,但他們?nèi)匀恍枰粋€(gè)回壓策略,因?yàn)樵購(gòu)?qiáng)大的服務(wù)器也無(wú)法滿足無(wú)限制的流量增長(zhǎng)。
回壓的概念其實(shí)很簡(jiǎn)單,試想一個(gè)具有第二個(gè)出口的容器,為了不讓倒入容器的液體溢出,可以通過(guò)第二個(gè)出口排出一部分液體。當(dāng)一個(gè)應(yīng)用無(wú)法處理更多的負(fù)載時(shí),那么就可以通過(guò)一種友好的方式通知客戶端暫緩請(qǐng)求的發(fā)送。速率限定就是一個(gè)很常見(jiàn)的回壓用例。為了避免API被過(guò)度調(diào)用,可以通過(guò)速率限定對(duì)其進(jìn)行限制。API的速率限定可以返回簡(jiǎn)單的錯(cuò)誤碼給客戶端,但出于用戶體驗(yàn)的考慮,對(duì)于網(wǎng)站的回壓來(lái)說(shuō),需要使用一些精心設(shè)計(jì)的頁(yè)面來(lái)代替簡(jiǎn)單的錯(cuò)誤碼。
回壓策略產(chǎn)生了一定的效果,不過(guò)結(jié)算流程涉及了大量的寫操作,仍然會(huì)拖垮部分店鋪。最開(kāi)始,他們?yōu)槊總€(gè)結(jié)算流程在MySQL數(shù)據(jù)庫(kù)里創(chuàng)建一條記錄,后續(xù)的每個(gè)步驟都會(huì)操作這條記錄。當(dāng)然,更為理想的方案是把整個(gè)結(jié)算流程的寫操作合并到一起,變成一個(gè)最終的操作。不過(guò)時(shí)間緊迫,他們無(wú)法在短時(shí)間內(nèi)實(shí)施這個(gè)方案,因?yàn)殚W購(gòu)活動(dòng)一個(gè)接著一個(gè),如果他們要實(shí)施這個(gè)方案,必然會(huì)引起服務(wù)中斷,對(duì)閃購(gòu)活動(dòng)造成影響。所以在完全使用新方案之前,他們需要一個(gè)能夠快速實(shí)施的臨時(shí)過(guò)渡方案。
漏桶算法
Shopify在系統(tǒng)的最外層使用了漏桶算法(leaky bucket algorithm)對(duì)用戶流量進(jìn)行限流。系統(tǒng)根據(jù)自身的處理能力設(shè)置了一個(gè)流量上限,在某一段時(shí)間內(nèi),最多只有這么多的請(qǐng)求會(huì)進(jìn)入到系統(tǒng)內(nèi)部,超出的請(qǐng)求將被拒絕服務(wù)。對(duì)于那些被拒絕的請(qǐng)求,系統(tǒng)需要通知客戶端何時(shí)再重試。
為了獲得良好的用戶體驗(yàn),被拒絕的請(qǐng)求被重定向到一個(gè)排隊(duì)頁(yè)面。排隊(duì)頁(yè)面是由Shopify的平臺(tái)用戶提供的,Shopify在頁(yè)面里注入了一段JavaScript腳本,這段腳本對(duì)“/checkout”端點(diǎn)發(fā)起輪詢,如果輪詢請(qǐng)求通過(guò)了漏桶,客戶端會(huì)分配到一個(gè)經(jīng)過(guò)安全簽名的cookie,用于識(shí)別后續(xù)的session。如果客戶端JavaScript被禁用,他們會(huì)使用meta refresh代替。
結(jié)算隊(duì)列
Shopify使用漏桶算法成功地對(duì)流量進(jìn)行了限流,但新的問(wèn)題又接踵而至。有的顧客需要在排隊(duì)頁(yè)面等上40分鐘,而整個(gè)閃購(gòu)過(guò)程可能也只有短短的40分鐘,甚至更短(在國(guó)內(nèi),緊俏的商品通常在幾分鐘內(nèi)就被搶購(gòu)一空,有的甚至是秒光),這對(duì)于顧客來(lái)說(shuō)是一種很糟糕的體驗(yàn)。
問(wèn)題是,真有那么多人在排隊(duì)嗎?竟然要排上40分鐘那么久?后來(lái)Shopify意識(shí)到一個(gè)問(wèn)題,他們讓用戶在排隊(duì)頁(yè)面等待,并通過(guò)向“/checkout”端點(diǎn)發(fā)起輪詢?cè)俅伟l(fā)出結(jié)算請(qǐng)求,但輪詢的時(shí)間間隔是隨機(jī)的,所以有可能出現(xiàn)一直被拒絕的情況。所以這種排隊(duì)是不平等的,如果有人結(jié)算成功就跟中了彩票一樣幸運(yùn),而那些等了40分鐘的人只能怪他們運(yùn)氣不好。
Shopify需要一個(gè)公平的排隊(duì)機(jī)制。
時(shí)間戳
為了讓排隊(duì)機(jī)制更加公平,Shopify為每一個(gè)結(jié)算請(qǐng)求啟用了時(shí)間戳。每個(gè)用戶在發(fā)起第一個(gè)結(jié)算請(qǐng)求時(shí)會(huì)獲得一個(gè)時(shí)間戳,如果請(qǐng)求沒(méi)有通過(guò)漏桶,那么時(shí)間戳就會(huì)被保存起來(lái)。當(dāng)用戶再次發(fā)起輪詢,之前保存的時(shí)間戳就會(huì)被用來(lái)做比對(duì),時(shí)間戳排名靠前的會(huì)優(yōu)先通過(guò)漏桶。這么一來(lái),對(duì)于獲得較早時(shí)間戳的用戶,當(dāng)他們的請(qǐng)求再次達(dá)到,可以確保不會(huì)落在時(shí)間戳靠后的請(qǐng)求后面。
這種方式雖然提高了公平性,但引入了數(shù)據(jù)存儲(chǔ),很容易成為新的故障點(diǎn)。而且如果系統(tǒng)需要跨多個(gè)數(shù)據(jù)中心,數(shù)據(jù)中心之間的數(shù)據(jù)復(fù)制會(huì)拖慢整個(gè)系統(tǒng)。所以他們需要找到一個(gè)不數(shù)據(jù)存儲(chǔ)的解決方案。
新的方案是使用閾值。閾值是什么?可以打個(gè)形象的比喻:有個(gè)很受歡迎的餐廳,每次去這家餐廳吃飯的顧客人數(shù)都超出了它的服務(wù)能力,所以顧客需要排隊(duì)。排隊(duì)的顧客人手一個(gè)號(hào)碼牌,每過(guò)一段時(shí)間,服務(wù)員就會(huì)出來(lái)喊道:“請(qǐng)第X號(hào)之前的顧客到里面用餐”。這樣,餐廳就不需要每次比對(duì)顧客之間的號(hào)碼牌,而是比較號(hào)碼牌和服務(wù)員嘴里所說(shuō)的那個(gè)號(hào)碼。服務(wù)員每次所喊的號(hào)就是閾值,這個(gè)值會(huì)根據(jù)系統(tǒng)的處理能力動(dòng)態(tài)變化,而顧客手里拿的號(hào)碼牌就是那個(gè)時(shí)間戳,這個(gè)時(shí)間戳?xí)环旁诮?jīng)過(guò)安全簽名的cookie里,所以就沒(méi)必要存儲(chǔ)時(shí)間戳了。那么接下來(lái)的問(wèn)題變成了該如何計(jì)算這個(gè)閾值。
PID控制器
為了計(jì)算出這個(gè)動(dòng)態(tài)的閾值,也就是服務(wù)員口中的那個(gè)號(hào)碼,Shopify在系統(tǒng)中使用了PID控制器。閾值需要根據(jù)實(shí)時(shí)的流量情況和系統(tǒng)處理能力進(jìn)行動(dòng)態(tài)調(diào)整,PID控制器是實(shí)現(xiàn)動(dòng)態(tài)調(diào)整的關(guān)鍵組件。PID控制器是一個(gè)無(wú)限循環(huán)的自反饋組件,假設(shè)給定了一個(gè)預(yù)期的系統(tǒng)狀態(tài),PID控制器會(huì)計(jì)算當(dāng)前狀態(tài)與預(yù)期狀態(tài)之間的差距(也就是系統(tǒng)錯(cuò)誤值),對(duì)當(dāng)前狀態(tài)進(jìn)行糾正,向預(yù)期狀態(tài)靠攏。系統(tǒng)狀態(tài)不斷地發(fā)生變化,PID控制器根據(jù)反饋計(jì)算錯(cuò)誤值,再次把系統(tǒng)帶向預(yù)期狀態(tài),不斷重復(fù)這樣的過(guò)程。
關(guān)于PID控制器的工作原理,也有一個(gè)形象的比喻。想象一下房間的溫控系統(tǒng),假設(shè)預(yù)期溫度為23度,當(dāng)房間實(shí)際溫度為22度時(shí),控制器發(fā)現(xiàn)此時(shí)的溫度與預(yù)期狀態(tài)相差1度(也就是說(shuō)錯(cuò)誤值為1度),加熱器就會(huì)被啟動(dòng),通過(guò)提升溫度來(lái)糾正錯(cuò)誤值。過(guò)了一會(huì)兒,溫度上升到24度,控制器發(fā)現(xiàn)此時(shí)的溫度超過(guò)了預(yù)期狀態(tài)(又出現(xiàn)了錯(cuò)誤值),于是加熱器被關(guān)閉,空調(diào)被打開(kāi),通過(guò)降溫來(lái)糾正錯(cuò)誤值??刂破鞑粩嗟貦z測(cè)房間溫度,讓溫度保持在預(yù)期的狀態(tài),這就是PID控制器的工作原理。
公平性比較
通過(guò)以下兩張圖片可以看出新的排隊(duì)方案與之前的排隊(duì)方案在公平性方面的差異。圖中黃色代表平均排隊(duì)時(shí)間,藍(lán)色代表P95排隊(duì)時(shí)間,紫色代表中值排隊(duì)時(shí)間。
從圖1可以看出,不公平的排隊(duì)方案各條線之間的間隔較大,這也意味著用戶的排隊(duì)等待時(shí)間相差較大。
圖2的各條線幾乎是重合的,也就是說(shuō)用戶的排隊(duì)等待時(shí)間幾乎是相等的。