雙十一大型電商統一服務架構實戰

weedw 9年前發布 | 45K 次閱讀 架構 軟件架構
 

張開濤

2014年加入京東,主要負責商品詳情頁、詳情頁統一服務架構與開發工作,設計并開發了多個億級訪問量系統。工作之余喜歡寫技術博客,有《跟我 學Spring》、《跟我學Spring MVC》、《跟我學Shiro》、《跟我學Nginx+Lua開發》等系列教程,博客訪問量500萬。開濤之前在高可用架構群分享《億級商品詳情頁架構演 進技術解密》,受到高度關注,文章閱讀數超過1.8萬,關注本群公眾號,回復29可查看。

京東商品詳情頁技術方案在之前《億級商品詳情頁架構演進技術解密》這篇文章已經為大家揭秘了,接下來為大家揭秘下雙十一抗下幾十億流量的商品詳情頁統一服務架構。

這次雙十一整個商品詳情頁在流量增長數倍的情況下,沒有出現不可用的情況,服務非常穩定。

統一服務提供了:促銷和廣告詞合并服務、庫存狀態/配送至服務、延保服務、試用服務、推薦服務、圖書相關服務、詳情頁優惠券服務、今日抄底服務等服務支持。

其實就是一個大的網關,提供各種服務。但區別于一般網關,它還提供業務邏輯的處理。這些服務中有我們自己做的服務實現,而有些是簡單做下代理或者 接口做了合并輸出到頁面,我們聚合這些服務到一個系統的目的是打造服務閉環,優化現有服務。并為未來需求做準備,跟著自己的方向走,而不被別人亂了我們的 方向。這樣一起盡在自己的掌控之中,想怎么玩就看心情了。

大家在頁面中看到的c.3.cn/c0.3.cn/c1.3.cn/cd.jd.com請求都是統一服務的入口。我們分別為有狀態服務和無狀態服務提供了不同的域名,而且域名還做了分區。

為什么需要統一服務?

商品詳情頁雖然只有一個頁面,但是依賴的服務眾多,我們需要把控好入口,一統化管理。

這樣的好處:

  • 統一管理和監控,出問題可以統一降級
  • 可以把一些相關接口合并輸出,減少頁面的異步加載請求
  • 一些前端邏輯后移到服務端,前端只做展示,不進行邏輯處理。

有了它,所有入口都在我們服務中,我們可以更好的監控和思考我們頁面的服務,讓我們能運籌于帷幄之中,決勝于千里之外。

在設計一個高度靈活的系統時,要想著當出現問題時怎么辦:是否可降級、不可降級怎么處理、是否會發送滾雪球問題、如何快速響應異常。完成了系統核心邏輯只是保證服務能工作,服務如何更好更有效或者在異常情況下能正常工作也是我們要深入思考和解決的問題。

整體架構

整體流程

  1. 請求首先進入Nginx,Nginx調用Lua進行一些前置邏輯處理,如果前置邏輯不合法直接返回;然后查詢本地緩存,如果命中直接返回數據。
  2. 如果本地緩存不命中數據,則查詢分布式Redis集群,如果命中數據,則直接返回。
  3. 如果分布式Redis集群不命中,則會調用Tomcat進行回源處理;然后把結果異步寫入Redis集群,并返回。

如上是整個邏輯流程,可以看到我們在Nginx這一層做了很多前置邏輯處理,以此來減少后端壓力,另外我們Redis集群分機房部署,如下圖所示:

即數據會寫一個主集群,然后通過主從方式把數據復制到其他機房,而各個機房讀自己的集群;此處沒有在各個機房做一套獨立的集群來保證機房之間沒有交叉訪問,這樣做的目的是保證數據一致性。

在這套新架構中,我們可以看到Nginx+Lua已經是我們應用的一部分,我們在實際使用中,也是把它做為項目開發,做為應用進行部署。

我們主要遵循如下幾個原則設計系統架構:

  • 兩種讀服務架構模式
  • 本地緩存
  • 多級緩存
  • 統一入口/服務閉環
  • 引入接入層
  • 前端業務邏輯后置
  • 前端接口服務端聚合
  • 服務隔離

兩種讀服務架構模式

  • 讀取分布式Redis數據架構

可以看到Nginx應用和Redis單獨部署,這種方式是一般應用的部署模式,也是我們統一服務的部署模式,此處會存在跨機器、跨交換機或跨機柜 讀取Redis緩存的情況,但是不存在跨機房情況,因為使用容器化,不太好做不跨機柜的應用了。通過主從把數據復制到各個機房。如果對性能要求不是非常苛 刻,可以考慮這種架構,比較容易維護。

  • 讀取本地Redis數據架構

可以看到Nginx應用和Redis集群部署在同一臺機器,這樣好處可以消除跨機器、跨交換機或跨機柜,甚至跨機房調用。如果本地Redis集群 不命中, 還是回源到Tomcat集群進行取數據。此種方式可能受限于TCP連接數,可以考慮使用unix domain socket套接字減少本機TCP連接數。如果單機內存成為瓶頸(比如單機內存最大256GB),就需要路由機制來進行Sharding,比如按照商品尾 號Sharding,Redis集群一般采用樹狀結構掛主從部署。

本地緩存

我們把Nginx作為應用部署,因此大量使用Nginx共享字典作為本地緩存,Nginx+Lua架構中,使用HttpLuaModule模塊的 shared dict做本地緩存( reload不丟失)或內存級Proxy Cache,提升緩存帶來的性能并減少帶寬消耗。

之前的詳情頁架構也是采用這種緩存。另外使用一致性哈希(如商品編號/分類)做負載均衡內部對URL重寫提升命中率。

在緩存數據時采用了維度化存儲緩存數據,增量獲取失效緩存數據(比如10個數據,3個沒命中本地緩存,只需要取這3個即可)。維度如商家信息、店鋪信息、商家評分、店鋪頭、品牌信息、分類信息等;比如本地緩存30分鐘,調用量減少差不多3倍。

另外使用一致性哈希+本地緩存,如庫存數據緩存5秒,平常命中率:本地緩存25%;分布式Redis28%;回源47%。一次普通秒殺活動命中 率:本地緩存 58%;分布式Redis 15%;回源27%。而某個服務使用一致哈希后命中率提升10%。對URL按照規則重寫作為緩存KEY,去隨機,即頁面URL不管怎么變都不要讓它成為緩 存不命中的因素。

多級緩存

對于讀服務,在設計時會使用多級緩存來盡量減少后端服務壓力,在統一服務系統中,設計了四級緩存,如下圖所示:

  • 首先在接入層,會使用Nginx本地緩存,這種前端緩存主要目的是抗熱點;根據場景來設置緩存時間。
  • 如果Nginx本地緩存不命中,接著會讀取各個機房的分布式從Redis緩存集群,該緩存主要是保存大量離散數據,抗大規模離散請求,比如使用一致性哈希來構建Redis集群,即使其中的某臺機器出問題,也不會出現雪崩的情況。
  • 如果從Redis集群不命中,Nginx會回源到Tomcat;Tomcat首先讀取本地堆緩存,這個主要用來支持在一個請求中多次讀取一個 數據或者該數據相關的數據。而其他情況命中率是非常低的,或者緩存一些規模比較小但用的非常頻繁的數據,如分類,品牌數據;堆緩存時間設置為Redis緩 存時間的一半。
  • 如果Java堆緩存不命中,會讀取主Redis集群,正常情況該緩存命中率非常低,不到5%。讀取該緩存的目的是防止前端緩存失效之后的大量 請求的涌入,導致后端服務壓力太大而雪崩。默認開啟了該緩存,雖然增加了幾毫秒的響應時間,但是加厚了防護盾,使服務更穩當可靠。此處可以做下改善,比如 設置一個閥值,超過這個閥值才讀取主Redis集群,比如Guava就有RateLimiter API來實現。

統一入口/服務閉環

在《億級商品詳情頁架構演進技術解密》中已經講過了數據異構閉環的收益,在統一服務中也遵循這個設計原則,此處主要做了兩件事情

  1. 數據異構,如判斷庫存狀態依賴的套裝、配件關系進行了異構,未來可以對商家運費等數據進行異構,減少接口依賴。
  2. 服務閉環,所有單品頁上用到的核心接口都接入統一服務。

有些是查庫/緩存然后做一些業務邏輯,有些是http接口調用然后進行簡單的數據邏輯處理;還有一些就是做了下簡單的代理,并監控接口服務質量。

引入Nginx接入層

在設計系統時需要把一些邏輯盡可能前置以此來減輕后端核心邏輯的壓力。另外如服務升級/服務降級能非常方便的進行切換,在接入層做了如下事情:

  • 數據校驗/過濾邏輯前置、緩存前置、業務邏輯前置
  • 降級開關前置
  • AB測試
  • 灰度發布/流量切換
  • 監控服務質量
  • 限流

服務有兩種類型的接口:一種是用戶無關的接口,另一種則是用戶相關的接口。因此使用了兩種類型的域名c.3.cn/c0.3.cn/c1.3.cn和cd.jd.com。

當請求cd.jd.com會帶著用戶cookie信息到服務端。在服務器上會進行請求頭的處理,用戶無關的所有數據通過參數傳遞,在接入層會丟棄所有的請求頭(保留gzip相關的頭)。

而用戶相關的會從cookie中解出用戶信息然后通過參數傳遞到后端;也就是后端應用從來就不關心請求頭及Cookie信息,所有信息通過參數傳遞。

有些是查庫/緩存然后做一些業務邏輯,有些是http接口調用然后進行簡單的數據邏輯處理;還有一些就是做了下簡單的代理,并監控接口服務質量。

請求進入接入層后,會對參數進行校驗,如果參數校驗不合法直接拒絕這次請求。對每個請求的參數進行了最嚴格的數據校驗處理,保證數據的有效性。

如圖所示,我們對關鍵參數進行了過濾,如果這些參數不合法就直接拒絕請求。另外還會對請求的參數進行過濾然后重新按照固定的模式重新拼裝URL調度到后端應用。此時URL上的參數是固定的而且是有序的,可以按照URL進行緩存

緩存前置

很多緩存都前置到了接入層,來進行熱點數據的削峰,而且配合一致性哈希可能提升緩存的命中率。在緩存時按照業務來設置緩存池,減少相互之間的影響和提升并發,使用Lua讀取共享字典來實現本地緩存。

業務邏輯前置

在接入層直接實現了一些業務邏輯,原因是當在高峰時出問題,可以在這一層做一些邏輯升級。

后端是Java應用,當修復邏輯時需要上線,而一次上線可能花費數十秒時間啟動應用。重啟應用后Java應用JIT的問題會存在性能抖動的問題。 可能因為重啟造成服務一直啟動不起來的問題。而在Nginx中做這件事情,改完代碼推送到服務器,重啟只需要秒級,而且不存在抖動的問題。這些邏輯都是在 Lua中完成。

降級開關前置

降級開關分為這么幾種:

  • 接入層開關和后端應用開關。在接入層設置開關的目的是防止降級后流量還無謂的打到后端應用。
  • 總開關是對整個服務降級,比如庫存服務默認有貨。
  • 原子開關是整個服務中的其中一個小服務降級,比如庫存服務中需要調用商家運費服務,如果只是商家運費服務出問題了,此時可以只降級商家運費服務。

另外還可以根據服務重要程度來使用超時自動降級機制。使用init_by_lua_file初始化開關數據,共享字典存儲開關數據。提供API進 行開關切換(switch_get(“stock.api.not.call”) ~= “1”)。可以實現:秒級切換開關、增量式切換開關(可以按照機器組開啟,而不是所有都開啟)、功能切換開關、細粒度服務降級開關、非核心服務可以超時自 動降級。

比如雙十一期間有些服務出問題了,進行過大服務和小服務的降級操作,這些操作對用戶來說都是無感知的。有一個后臺可以直接控制開關,切換非常方便。

AB測試

對于服務升級,最重要的就是能做AB測試,然后根據AB測試的結果來看是否切新服務。這一塊算是家常便飯。有了接入層非常容易進行這種AB測試;不管是上線還是切換都非常容易。

可以動態改Nginx配置、推送然后reload。也可以在Lua中根據請求的信息調用不同的服務或者upstream分組即可完成AB測試。因為有了Lua,可以實現復雜邏輯的編程,比如根據商品屬性進行切換。

灰度發布/流量切換

對于一個靈活的系統來說,能隨時進行灰度發布和流量切換是非常重要的一件事情。比如驗證新服務器是否穩定,或者驗證新的架構是否比老架構更優秀,有時候只有在線上跑著才能看出是否有問題。

在接入層也是通過配置或Lua代碼來完成這些事情。靈活性非常好,可以設置多個upstream分組,然后根據需要切換分組即可。

監控服務質量

對于一個系統最重要的是要有雙眼睛能盯著系統來盡可能早的發現問題。在接入層會對請求進行代理,記錄status、request_time、 response_time來監控服務質量,比如根據調用量、狀態碼是否是200、響應時間來告警。 這些都是通過Nginx子請求記錄的。

限流

系統中存在的主要限流邏輯是:

  • 對于大多數請求按照IP請求數限流,對于登陸用戶按照用戶限流
  • 對于讀取緩存的請求不進行限流,只對打到后端系統的請求進行限流
  • 還可以限制用戶訪問頻率,比如使用ngx_lua中的ngx.sleep對請求進行休眠處理,讓刷接口的速度降下來或者種植cookie token之類的,必須按照流程訪問。當然還可以對爬蟲/刷數據的請求返回假數據來減少影響。

前端業務邏輯后置

前端JS應該盡可能少的業務邏輯和一些切換邏輯,因為前端JS一般推送到CDN。假設邏輯出問題了,需要更新代碼上線,推送到CDN然后失效各個 邊緣CDN節點或者通過版本號機制在服務端模板中修改版本號上線。這兩種方式都存在效率問題,假設處理一個緊急故障用這種方式處理完了可能故障也恢復了。

因此我們的觀點是前端JS只拿數據展示,所有或大部分邏輯交給后端去完成,即靜態資源CSS/JS CDN,動態資源JSONP。前端JS瘦身,業務邏輯后置。這種方案可能在我們的場景下更適用。

在雙十一期間某些服務出問題了,不能更新商品信息。此時秒殺商品如果不打標就不能購買。因此我們在服務端完成了這件事情,整個處理過程只需要幾十 秒就能搞定,避免了商品不能被秒殺的問題。而如果在JS中完成需要耗費非常長的時間,因為JS在客戶端還有緩存時間,而且一般緩存時間非常長。這也是詳情 頁動態化后能靈活的應對各種問題。

前端接口服務端聚合

商品詳情頁上依賴的服務眾多,一個類似的服務需要請求多個不相關的服務接口,造成前端代碼臃腫,判斷邏輯眾多。而我無法忍受這種現狀,我想要的結果就是前端異步請求我的一個API,我把相關數據準備好發過去,前端直接拿到數據展示即可。

所有或大部分邏輯在服務端完成而不是在客戶端完成。因此在接入層使用Lua協程機制并發調用多個相關服務然后最后把這些服務進行了合并。比如推薦服務:最佳組合、推薦配件、優惠套裝。

通過

http://c.3.cn/recommend?methods=accessories,suit,combination&sku=1159330&cat=6728,6740,12408&lid=1&lim=6

進行請求獲取聚合的數據。這樣原來前端需要調用三次的接口只需要一次就能吐出所有數據。我們對這種請求進行了API封裝,如下圖所示 :

比如庫存服務,判斷商品是否有貨需要判斷:1、主商品庫存狀態、2、主商品對應的套裝子商品庫存狀態、主商品附件庫存狀態及套裝子商品附件庫存狀態。

套裝商品是一個虛擬商品,是多個商品綁定在一起進行售賣的形式。如果這段邏輯放在前段完成,需要多次調用庫存服務,然后進行組合判斷,這樣前端代 碼會非常復雜,凡是涉及到調用庫存的服務都要進行這種判斷。因此我們把這些邏輯封裝到服務端完成。另外對這些關系數據進行了異構存儲,減少與其他系統進行 交互帶來的未知性能開銷。這樣只需要讀取自己的Redis就能拿到關系。

前端請求

http://c0.3.cn/stock?skuId=1856581&venderId=0&cat=9987,653,655&area=1_72_2840_0&buyNum=1&extraParam={%22originid%22:%221%22}&ch=1&callback=getStockCallback

然后服務端計算整個庫存狀態,而前端不需要做任何調整。

在服務端使用Lua協程并發的進行庫存調用,如下圖所示 :

另外接口也會吐出相關的附件/套裝的庫存狀態,方便前臺萬一需要時使用。

另外比如今日抄底服務,調用接口太多,如庫存、價格、促銷等都需要調用,因此也使用這種機制把這幾個服務在接入層合并為一個大服務,對外暴露。

http://c.3.cn/today?skuId=1264537&area=1_72_2840_0&promotionId=182369342&cat=737,752,760&callback=jQuery9364459&_=1444305642364

整個詳情頁的未來就是首屏盡量一個大服務提供所有數據,各子接口設置超時時間,在超時后,單獨發送一次請求查詢相關數據。

目前合并的主要有:促銷和廣告詞合并、配送至相關服務合并。

服務隔離

服務隔離的目的是防止因為某些服務抖動而造成整個應用內的所有服務不可用。

可總結為:

  • 應用內線程池隔離
  • 部署/分組隔離
  • 拆應用隔離

應用內線程池隔離,采用了Servlet3異步化,并為不同的請求按照重要級別分配線程池,這些線程池是相互隔離的。也提供了監控接口以便發現問 題及時進行動態調整,該實踐可以參考我博客的《商品詳情頁系統的Servlet3異步化實踐》。目前Java系統也全面升級為 JDK8+Servlet3。

部署/分組隔離,意思是為不同的消費方提供不同的分組,不同的分組之間不相互影響,以免因為大家使用同一個分組導致有些人亂用導致整個分組服務不可用。

拆應用隔離,如果一個服務調用量巨大,那便可以把這個服務單獨拆出去,做成一個應用,減少因其他服務上線或者重啟導致影響本應用。

其他

對于http 304一些服務都設置了,如延保,設置了一個容忍時間,如果在這段時間內重復請求,會直接返回304,而不查緩存或回源處理。

超時時間/重試,對于一個系統來說,超時時間是必須設置的,主要有如TCP連接/讀/寫超時時間、連接池超時時間,還有相關的重試時機/次數。對 于一個網絡服務,需要根據實際情況設置TCP連接/讀/寫超時時間,如果不設置很可能會在網絡出問題時服務直接掛斷,比如連接Redis都設置在 150ms以內。

還有如果使用Nginx,還需要考慮Nginx超時時間、upstream超時時間和業務超時時間。假設Nginx超時時間是5s、 upstream超時時間是6s,而業務的超時時間是8s,那么假設業務處理了7s,那么upstream超時時間到了需要進行下一個upstream的 重試,但是Nginx總的超時時間是5s,所以就無法重試了

連接池也需要設置獲取連接的等待時間,如果不設置會有很多線程一直在等待,之前自己實現連接池時,會去發現是否網絡錯誤,如果網絡錯誤就沒必要等待連接了,而立即失敗即可。

還需要考慮服務失敗重試時機,比如是立即重試,還是指數式重試;還有重試次數,是一次性重試三次還是指數式等待時間后進行一次次的重試。比如HttpClient(DefaultHttpRequestRetryHandler)默認會立即進行三次重試。

CDN緩存評價使用了版本化。對于版本化的意思是對于一些內容可以通過版本化機制設置更長的超時時間,而且固定URL順序,這樣相同的URL請求只要版本不變就會更有效的命中CDN數據。

版本可以通過消息推送機制通知相關系統。比如評價數據的版本化,可以每隔幾分鐘推送變更了版本的商品更新版本號,還要注意消息排重。另外為了發現 是哪臺服務器出現了問題,都要在響應響應頭記錄服務器真實IP。這樣出問題后,直接定位到哪臺服務器出問題了。還有如對于整個系統無法保證整個系統不出現 臟數據,所以需要提供刷臟數據的接口,以便出現問題時進行數據的更新或刪除。

Q&A

Q1 : 邏輯由前段向后端遷移后,后端壓力對比上升了多少?還是降低了?

我個人從來不認為后端會有壓力,我們的某服務響應時間在10-100ms之間,8CPU容器壓測在10000~20000qps。另外后端是比較容易水平擴展的。

Q2 : 獲取多維度數據時,部分維度需要回源,這時候邏輯上會阻塞。是如何做到大并發下的高吞吐?基于事件驅動網絡IO?協程?

Nginx的非阻塞IO+Lua協程;另外我們使用Nginx共享字典,按照不同的維度開辟共享字典,對于大流量的可以做sharding,目前沒有做sharding,只是按照維度分離共享字典,防止LRU和鎖競爭。

Q3 : 多級緩存如何保證一致性?

1、緩存數據時間都非常短;2、有些可以持久化的數據是通過消息通知變更推送;3、不能保證100%沒問題,也提供了清理接口進行清理。

Q4 : 接入層的本地緩存用啥存的也是redis么?

ngx_lua提供的共享字典,目前使用中沒有遇到任何問題,reload不丟,減少了部署Redis的繁瑣。也考慮過Java堆外緩存使用Local Redis或Local Nginx。

Q5 : “另外對這些關系數據進行了異構存儲,減少與其他系統進行交互帶來的未知性能開銷 這樣只需要讀取自己的Redis就能拿到關系”這個異構存儲是怎么維護和更新的?

數據都是KV存儲,復雜的數據通過Redis Lua腳本減少交互。異構數據都是通過消息對接的。

Q6 : 請問商品數量這種變化相對較快的數據如何緩存的,如何防止超賣,特別對于熱門商品?

超賣通過庫存系統的服務進行控制,詳情頁是前端展示庫存,主要是展示商品、價格、庫存都是通過消息通知變更的。

Q7 :秒殺預熱,開始,結束后:幾個階段的庫存,價格相關信息如果管理?如何防止超賣和缺貨?

秒殺是單獨的服務實現,跟詳情頁是分開實現;另外據我知道的 1、秒殺是和主交易流程隔離的,防止相互影響;2、庫存扣減通過Redis。

Q8 :聚合服務的超時時間如何設置?根據所有依賴服務的超時進行設置?

幾個點:1、Nginx proxy timeout;2、客戶端timeout,如HttpClient;3、每個服務都是單獨設置超時時間。

Q9 :服務的超時時間統一管理么,某個業務超時時間變化可能影響很大,怎么控制?

根據業務去控制,不同的業務要求不一樣;比如庫存/配送至依賴的商家配送時效,正常100ms以內,如果超過這個時間,排除網絡問題,相關系統肯定出問題了,可以進行優雅的降級。

Q10 :用戶鑒權也是用ngx_lua共享字典做的么?用戶信息這種敏感信息如果在接入暴露應該會產生安全隱患,但不在接入做放到后面核心域又會拖慢返回速度?

我們封裝了一個API,專門在接入層對一些服務進行鑒權,不對用戶信息的輸出和暴露,這樣能更好的控制而且可以盡早的發現非法請求;拖慢速度的情況,因為在接入層驗證,這個就不是問題了。

Q11 :秒殺的商品庫存 跟正常商品庫存是分開的嗎?

對,包括整個交易流程也是分離的,可以走下流程就看到了,而且必須按照正常用戶步驟去訪問才可以。

 本文由用戶 weedw 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!