【為了方便獨立成文,原諒在內容排版上的一點點個人強迫症】 【本文內容由上一篇擴展論述(詳見:商城系統下單庫存管控系列雜記(一) http://www.cnblogs.com/bsfz/p/7801980.html)】 四、闡述關於併發環境中庫存管控的一些案例問題,以及涉及到的相關技術實現細節 ... ...
商城系統下單庫存管控系列雜記(二)(併發安全和性能部分延伸) 前言 參與過幾個中小型商城系統的開發,隨著時間的增長,以及對系統的深入研究和測試,發現確實有很多值得推敲和商榷的地方(總有很多重要細節存在缺陷)。基於商城系統,無論規模大小,或者本身是否分佈架構,個人覺得最核心的一環就是下單模塊,而這裡面更相關和棘手的一些設計和問題,大多時候都涉及庫存系統。想想之前跟某人的交流,他一句“庫存管控做得好,系統設計就成功了一半”,自己頗有認同。圍繞這個點,結合目前經驗和朋友間的交流(包括近來參閱其他文章提到的點),閑來做些整理記錄,也許不太完整,但總歸希望能有更多啟發,自己往後也會重新揣摩。當然,文中若有不妥,歡迎指正。 正文 談及”下單“,就立刻想起前年參與的一個基於微信的小型商城系統,裡面下單這塊本身談不上複雜,大概可以這樣描述提交過程:用戶提交商品訂單,系統核對用戶提交的訂單,校驗商品(商品價格、優惠折扣、積分等),檢測附屬信息(地址運費等),一切Pass,操作庫存(記錄/預扣),生成訂單及相關聯的明細數據。此時下單Ok,那麼後續則是等待用戶的及時付款了。 然而,看似如此簡單的一個流程,放在併發環境下,就暴露了足夠多的問題。深入進去,首當其衝的就是庫存管控。包括但不限於庫存的扣減方式,如何安全操作,以及減少性能損耗等等。 【為了方便獨立成文,原諒在內容排版上的一點點個人強迫症】 【本文內容由上一篇擴展論述(詳見:商城系統下單庫存管控系列雜記(一) http://www.cnblogs.com/bsfz/p/7801980.html)】 四、闡述關於併發環境中庫存管控的一些案例問題,以及涉及到的相關技術實現細節 庫存扣減,簡單來說,就是在對應的存儲器中(資料庫或者持久緩存)將對應商品的數量減少。 資料庫設計時,一般包含但不限於 商品主表,商品規格表,商品庫存表,商品庫存流水日誌表等等。但這裡為了方便後續闡述,將其簡化為一張表——商品表(PT),該表僅包含兩個欄位——商品主鍵(id)和商品庫存(qty )。 依然以商品P舉例,其主鍵為pid,那麼就是在下單時,將歷史庫存S修改為 S -N。具體到SQL里,原始操作大概是這樣(以SQL SERVER 舉例): update PT set qty = (S - N) where id = pid ; 這是以前的最原始的操作方式,單粒度的看,也沒什麼大礙。然而,放在一個併發環境中,則立馬暴露出諸多問題。 假定在同一時刻,有兩個用戶提交了訂單,一樣的操作,一樣的商品,一樣的數量。那麼最終商品P的庫存數量應該為 S - N - N。而執行上面的SQL,因為併發,導致兩次查詢到歷史庫存均是S(應該至少有一次qty為S - N),則更新完畢後,商品數量最終是 S - N。這種致命性的Bug,也屬於超賣(雖然不會扣為負數),如果放線上上,簡直是一個定時炸彈。 圍繞解決這樣的問題,考慮到併發安全以及併發性能,產生了各種解決方案。大體基於兩種機制:悲觀鎖和樂觀鎖。在諸多場景里,基於每種鎖,都有配套的輔助手段,以及各自不同的側重取捨和相關實現。 4.1 使用悲觀鎖的理念,實際就是在併發的關鍵地方,強制將“類似並行”改為串列,相關的一些處理方式: 4.1.1 資料庫鎖,利用資料庫的自身的事務隔離機制(Isolation),進行排他操作。 4.1.1.1 極端的在查詢時,直接開啟事務設置行鎖(rowlock)。串列目的是達到了,但即時在單機系統中,也無法承受巨大的性能損耗。並且最終的超賣問題也沒有解決,非常不推薦。 4.1.1.2 僅利用資料庫在update時造成的排他鎖,使真實更新時串列,並增加庫存判斷,若庫存發生變動,則更新無效,超賣問題也不會發生。譬如(以SQL SERVER 舉例): update PT set qty = qty - N where id = pid and qty >= N; 嚴格來講,這依然是一個較粗的粒度,但不得不說,在單機環境下有一定的可行性。同時,需要考慮高併發情況下(例如商戶舉辦活動,同時參與用戶過多)存在一定性能瓶頸,資料庫IO負載過大。此時需要結合其他方案,包括增加上層緩存層等。甚至部分場景需要單獨設計一套流程(例如秒殺搶購場景,首先就是應用到隊列,否則網站可能沒崩潰在併發請求數上,而是直接掛在了DB上,後面會有相關闡述) 4.1.2 使用程式鎖(單機線程鎖和分散式調度鎖),使部分關鍵代碼串列。 4.1.2.1 極端的直接使用程式自帶的全局線程鎖,以.NET Framewok 舉例,裡面有各級粒度的鎖,常用的輕量鎖有lock(Mointor語法糖)、SpinLock(自旋鎖)。使用它們,最早大概是應用在“單例模式”的構建,原理本身不複雜,使用也方便,並且也達到了串列的目的。 然而,放在下單庫存管控這裡,串列的卻是所有用戶進行任意商品下單操作,打擊面太大(甚至直接上升到全面打擊),對性能造成極大影響,不可行,不過多延伸,也不推薦。(曾經優化一個舊項目里的模塊,初步Review代碼時就發現了幾處不經意的地方竟直接使用了這種寫法,而開發人員還是兩名老員工)。 4.1.2.2 構建一個本地的線程鎖管理器(這裡稱為LockerManage),統一分配鎖對象(等待對象)。其本質是針對上面4.1.2.1方式的包裝處理,實現類似“工廠模式”的機制。主要是通過它來生產具有唯一特征的Object對象,這個對象將會作為鎖對象資源返回給Monitor等調用,並具有一定的使用時效,每次生成後保存在內部的線程安全的集合里,同時具有自動銷毀機制(運行一個獨立線程,定時檢查清理)。其中有個小細節,為了優化管理器內部的併發問題,開始使用的是.NET Framewok 里自帶的線程安全的字典集合(ConcurrentDictionary),後來經測試,發現併發處理並不理想,後面便換了其他方案(讀寫分離)。回歸到下單這裡,這裡依然以商品P為例,首先調用LockerManage,獲取一個以當前商品主鍵為標識的Object對象,然後在庫存的預扣核對時,使用Mointor加鎖處理。(當然,這裡是本機鎖,後續有說明)。這種方式對比資料庫鎖,則是降低資料庫的操作,而將壓力大部分轉移到了程式上,但相對可以更靈活的去操控。 4.1.2.3 使用分散式鎖。上面的普通程式鎖作為單機的存在,決定了其在分散式架構上的不可控性,而這時就有了分散式調度鎖。它主要是為了方便解決分散式情況下,在多個Web程式內實現併發線程的一個管控。值得一提的是,這個“輪子”並不需要手動重新創造,目前市面上已經有相對成熟的解決方案,如利用Zookeeper和Redis。在AutumnBing項目中,當時選擇的是Redis,使用的驅動庫是StackExchange.Redis。(後續聽到朋友提到Zookeeper更適合充當這樣的角色,但由於目前自己還沒有太多涉獵研究,暫時持保留態度)。當然,純粹採用分散式鎖,自然調用性能會有更多損耗。而相對更合理的做法,是結合單機鎖搭配應用(試討論,分散式鎖放置外層,單機鎖放置內部,每個站點各自維護)。 4.2 遵循樂觀鎖的理念,則是默許不會有太大的併發問題(聚焦在小粒度的商品P上,則是認為大多數情況下P不會被同時消費),“放任”線程的執行,不做管控。但是會在關鍵地方進行版本核對,假如失敗,則內部重試或拋出失敗信號。 4.2.1 資料庫層面上,增加顯式的版本號欄位(ver)。 購買商品P,下單這裡需要獲取到當前時刻對應的庫存qty01,當前記錄是版本ver01,然後在真實更新時,再次查詢商品P的庫存,以及對應的當前的版本ver02,如果 ver01 == ver02,那麼可以更新。否則,當前數據已因併發被修改,無法更新。這更像是資料庫的“不可重覆讀”,而出現這種情況後(高併發情況下,出現概率直線上升),必須附有關聯的內部嘗試機制(註意保證冪等性)。 這是一種實現併發管控的方案,但只適合存在併發,但併發量不太大的情況,否則,一是違背樂觀鎖的理念初衷,二是整體性能以及體驗會大打折扣。 4.2.2 程式控制上,採取隊列(queue)方式,進行相對集中化預受理,然後分發逐個處理。 需要聲明,這裡本身執行原理,其實質依然離不開類似悲觀鎖的管控性質,一是入隊時需要有個小粒度的鎖機制保證串列(當然也可以是其他方式,這是隊列內部的管控機制之一),二是出隊,例如分發到不同服務上去處理,最終也是一個一個在操作更新(依然是某種程度上的串列)。但是,作為用戶下單的提交,本身是保證了樂觀的態度,一股腦“同時”或者“快速”接收,然後再考慮如何告知處理。 由於單機隊列的應用,會出現更多類似上面單機鎖的一些額外問題,這裡不推薦(當然你可以結合),也不做擴展說明。下麵僅就分散式隊列在大方向上舉例闡述。 如何採用分散式隊列來實現下單以及庫存管控呢?依然以商品P為例,用戶同時購買商品P,本身是一個併發操作,但是我們可以將一系列的請求商品扣減數據Push到一個隊列中(生產者開始生產),然後由專門的線程進行訂閱消費(消費者開始消費)。暫且假定為一個線程在消費,那麼該線程具體消費時,逐個將商品數據出隊,進行庫存扣減,這裡必然不會出現併發。消費完畢,無論扣除庫存邏輯上是成功還是失敗,均給出一個應答(ACK)。註意這裡並沒有過多的拆分邏輯,而是將下單的一些操作扔進一個隊列中,使用專門的程式去逐個或者逐幾個(分批)處理。實際使用往往是根據業務,做更小粒度的拆分和調整。另外,關於技術框架選型,目前各類開源成熟的MQ項目比比皆是,個人圈子裡瞭解到最多的還是 RabbitMQ,對於多個生產者以及與之配合的多個消費者,還有應答處理機制,包括本身的性能和高可用性,均極其出色。額外的,關於web前端,很多時候則是需要配合一些輪詢機制來檢查訂單狀態(當然,輪詢這裡也有一些具體細節,比如非同步體驗、輪詢時長和狀態重置等考慮) 五、涉及到分散式SOA架構體系(包括如今基於SOA開始流行的微服務架構)情況下的一些額外考慮。 首先聲明,個人認為SOA只是一種架構上的抽離設計,本身與論述的庫存管控沒有直接關係。但這裡以庫存管控為例,也有需要額外考慮的地方。 我們假定在一個下單API中,包含了3個獨立的API介面:A-積分扣減API,B-優惠券扣減API,C-庫存扣減API。考慮一種情況:假定庫存本身可以被合法扣除,並且執行C成功了,但是發生了其他問題,A或者B執行失敗了,那庫存該如何回滾。 必須糾正的是,在這樣一個耦合性系統場景里(而上例僅是其中一種案例),需要解決的問題本質和庫存如何扣減沒有絲毫直接關係,其暴露的實質問題是如何實現一個分散式事務機制。這是一個比較大的專題,實現相對複雜,開發成本也足夠高。基於單一RPC介面,到如今流行的更小粒度的微服務,都足夠寫一本書了。截止目前個人的瞭解,如早期的2PC (兩階段)、3PC(三階段)、TCC(補償事務),以及後來的純消息列表式方案等等,均是一些無法達到完美的理論(性能、時效、複雜度等)。至於實踐上,自然就沒有絕對OK的方案,只能根據項目規模和實際業務做些取捨,最終得到一個儘量滿足的“高可用”方案。以後待到經驗足夠,有機會嘗試一下單獨開篇討論。(對於分散式事務,寫過一些demo,卻應用不深,以後會考慮抽個專門的時間在續篇中嘗試撰寫探討)。 六、結合高併發場景(如:秒殺活動),簡單聊聊如何關聯各類技術手段,進行下單及庫存管控的應用。 在電商系統里,併發簡直無處不在,目前較為突出的一個場景,則是秒殺活動。所謂秒殺,最簡單直觀的場景如下:在某個時刻,商品P開放購買(P的實際庫存僅為1個或者幾個),大批量的用戶同時進行下單搶購。 秒殺時併發量之大遠遠超過一般情況下的併發(你要考慮到不止一個商品),甚至還會影響到商城裡現有其他業務(這裡討論非獨立部署)。需要考慮諸多細節,以及大量技術手段來進行有效管控。以下簡單聊聊後臺下單相關問題,不討論其他前端處理技術,包括定時查詢,頁面靜態化,網路帶寬優化等。 6.1 明確業務本質需求,脫離業務,當然談不了任何技術架構和實現方案。 秒殺的業務場景,巨集觀上來說,就是一個典型的排序模型。誰先來,誰先得到。這裡我們儘量簡化舉例:假定商品P庫存為10,同時參與下單的用戶數為100000。那麼,最終只有開始的(理論上的)10個用戶購買成功,其餘99990個用戶購買失敗。商品庫存被成功消費為0。 6.2 防作弊等安全監測,從RPC的第一個介面開始,就進行過濾。 例如,在雜記上一篇中提到的(見第一篇主題三),做好基礎的安全監測機制。如相同IP的僵屍賬號,做限制IP的訪問,並增加驗證碼等。同時,包括但不限於一些額外的業務輔助手段,如限制僅滿足一定註冊時間的用戶可下單等。 6.3 限流機制,在外層計數,達到一個下單閾值,直接拋棄。 從6.1中就可以發現,秒殺業務本身就註定了大部分人是搶不到的,那麼針對大部分人的下單請求,完全就可以不做處理(直接拋棄)。在進行真正的下單操作之前,可在具體操作介面上,增加一個攔截計數器來統計,比如當計數超過3000時,後續下單直接返回搶購失敗的信息。這樣就將數據處理由大化小了,實現了限流(僅針對下單)。當然,具體實現時,這個3000名額推薦是篩選後的。比如,先過濾8000,從中隨機抽取3000(這裡不擴展)。 6.4 從資料庫角度,首先就是要增加單獨的臨時緩存層。 即使是3000的量,在這個環節也肯定是不能直接操作資料庫的(你要明白,實際秒殺的商品,不只一個),直接讀庫寫庫對資料庫壓力太大,甚至直接負載過大導致資料庫掛掉。那麼,針對這種情況,推薦的一種方案就是結合緩存來操作。譬如:把商品P * 10 這條數據提前Push到專門的緩存中,然後每次讀取和更新,均是走的該緩存。這裡額外提到一點,如果用戶下單成功,預扣庫存 -1,但又未進行安全時間內的支付,那麼系統將自動回滾商品P的庫存,進行 +1(當然,回滾同樣需要協調處理併發)。 6.5 從程式角度,修改庫存依然需要保證一定串列。 首先,如果保證DAL的串列,可以是資料庫上鎖,也可以是程式上鎖(或者隊列)。但如果直接資料庫上鎖,諸多併發請求(依然考慮到,單時間內的多個商品被多用戶搶購),即使前面削減了部分下單處理,資料庫的I/O負載依然會很嚴重。那麼,首先就是推薦樂觀進隊列,然後悲觀進分散式程式鎖,混合處理(即是對主題四的結合應用)。 結語 電商項目里,幾乎處處是併發,無論是單機還是分散式架構。結合下單庫存管控相關,我們可以深刻理解解決這些併發性能問題和併發安全顧慮,即使是同一類型的業務,也有諸多方案,每種方案都有一些細粒度的問題需要嘗試剋服,更需結合實際項目(具體業務性質和規模),做一些實現上的各種優化與權衡等。 [不知不覺又是凌晨兩點多了,本文作為系列第二篇雜記(部分延伸篇),暫告一段落吧。第三篇,待續。該睡了,晚安。] End.