京東商品詳情頁服務(wù)閉環(huán)實踐
京東商品詳情頁技術(shù)方案在之前《構(gòu)建需求響應(yīng)式億級商品詳情頁》這篇文章已經(jīng)為大家揭秘了,接下來為大家揭秘下雙十一抗下幾十億流量的商品詳情頁統(tǒng)一服務(wù)架構(gòu),這次雙十一整個商品詳情頁沒有出現(xiàn)不服務(wù)的情況,服務(wù)非常穩(wěn)定。統(tǒng)一服務(wù)提供了:促銷和廣告詞合并服務(wù)、庫存狀態(tài)/配送至服務(wù)、延保服務(wù)、試用服務(wù)、推薦服務(wù)、圖書相關(guān)服務(wù)、詳情頁優(yōu)惠券服務(wù)、今日抄底服務(wù)等服務(wù)支持;這些服務(wù)中有我們自己做的服務(wù)實現(xiàn),而有些是簡單做下代理或者接口做了合并輸出到頁面,我們聚合這些服務(wù)到一個系統(tǒng)的目的是打造服務(wù)閉環(huán),優(yōu)化現(xiàn)有服務(wù),并為未來需求做準備,跟著自己的方向走,而不被別人亂了我們的方向。
大家在頁面中看到的c.3.cn/c0.3.cn/c1.3.cn/cd.jd.com請求都是統(tǒng)一服務(wù)的入口。
為什么需要統(tǒng)一服務(wù)
商品詳情頁雖然只有一個頁面,但是依賴的服務(wù)眾多,我們需要把控好入口,一統(tǒng)化管理。這樣的好處:統(tǒng)一管理和監(jiān)控,出問題可以統(tǒng)一降級;可以把一些相關(guān)接口合并輸出,減少頁面的異步加載請求;一些前端邏輯后移到服務(wù)端,前端只做展示,不進行邏輯處理。
有了它,所有入口都在我們服務(wù)中,我們可以更好的監(jiān)控和思考我們頁面的服務(wù),讓我們能運籌于帷幄之中,決勝于千里之外。在設(shè)計一個高度靈活的系統(tǒng)時,要想著當(dāng)出現(xiàn)問題時怎么辦:是否可降級、不可降級怎么處理、是否會發(fā)送滾雪球問題、如何快速響應(yīng)異常;完成了系統(tǒng)核心邏輯只是保證服務(wù)能工作,服務(wù)如何更好更有效或者在異常情況下能正常工作也是我們要深入思考和解決的問題。
整體架構(gòu)
整體流程:
1、請求首先進入Nginx,Nginx調(diào)用Lua進行一些前置邏輯處理,如果前置邏輯不合法直接返回;然后查詢本地緩存,如果***直接返回數(shù)據(jù);
2、如果本地緩存不***數(shù)據(jù),則查詢分布式Redis集群,如果***數(shù)據(jù),則直接返回;
3、如果分布式Redis集群不***,則會調(diào)用Tomcat進行回源處理;然后把結(jié)果異步寫入Redis集群,并返回。
如上是整個邏輯流程,可以看到我們在Nginx這一層做了很多前置邏輯處理,以此來減少后端壓力,另外我們Redis集群分機房部署,如下所示:
即數(shù)據(jù)會寫一個主集群,然后通過主從方式把數(shù)據(jù)復(fù)制到其他機房,而各個機房讀自己的集群;此處沒有在各個機房做一套獨立的集群來保證機房之間沒有交叉訪問,這樣做的目的是保證數(shù)據(jù)一致性。
在這套新架構(gòu)中,我們可以看到Nginx+Lua已經(jīng)是我們應(yīng)用的一部分,我們在實際使用中,也是把它做為項目開發(fā),做為應(yīng)用進行部署。
一些架構(gòu)思路和總結(jié)
我們主要遵循如下幾個原則設(shè)計系統(tǒng)架構(gòu):
- 兩種讀服務(wù)架構(gòu)模式
- 本地緩存
- 多級緩存
- 統(tǒng)一入口/服務(wù)閉環(huán)
- 引入接入層
- 前端業(yè)務(wù)邏輯后置
- 前端接口服務(wù)端聚合
- 服務(wù)隔離
兩種讀服務(wù)架構(gòu)模式
1、讀取分布式Redis數(shù)據(jù)架構(gòu)
可以看到Nginx應(yīng)用和Redis單獨部署,這種方式是一般應(yīng)用的部署模式,也是我們統(tǒng)一服務(wù)的部署模式,此處會存在跨機器、跨交換機或跨機柜讀取Redis緩存的情況,但是不存在跨機房情況,因為通過主從把數(shù)據(jù)復(fù)制到各個機房。如果對性能要求不是非常苛刻,可以考慮這種架構(gòu),比較容易維護。
2、讀取本地Redis數(shù)據(jù)架構(gòu)
可以看到Nginx應(yīng)用和Redis集群部署在同一臺機器,這樣好處可以消除跨機器、跨交換機或跨機柜,甚至跨機房調(diào)用。如果本地Redis集群不***, 還是回源到Tomcat集群進行取數(shù)據(jù)。此種方式可能受限于TCP連接數(shù),可以考慮使用unix domain socket套接字減少本機TCP連接數(shù)。如果單機內(nèi)存成為瓶頸(比如單機內(nèi)存***256GB),就需要路由機制來進行Sharding,比如按照商品尾號Sharding,Redis集群一般采用樹狀結(jié)構(gòu)掛主從部署。
本地緩存
我們把Nginx作為應(yīng)用部署,因此我們大量使用Nginx共享字典作為本地緩存,Nginx+Lua架構(gòu)中,使用HttpLuaModule模塊的shared dict做本地緩存( reload不丟失)或內(nèi)存級Proxy Cache,提升緩存帶來的性能并減少帶寬消耗;另外我們使用一致性哈希(如商品編號/分類)做負載均衡內(nèi)部對URL重寫提升***率。
我們在緩存數(shù)據(jù)時采用了維度化存儲緩存數(shù)據(jù),增量獲取失效緩存數(shù)據(jù)(比如10個數(shù)據(jù),3個沒***本地緩存,只需要取這3個即可);維度如商家信息、店鋪信息、商家評分、店鋪頭、品牌信息、分類信息等;比如我們本地緩存30分鐘,調(diào)用量減少差不多3倍。
另外我們使用一致性哈希+本地緩存,如庫存數(shù)據(jù)緩存5秒,平常***率:本地緩存25%;分布式Redis28%;回源47%;一次普通秒殺活動***率:本地緩存 58%;分布式Redis 15%;回源27%;而某個服務(wù)使用一致哈希后***率提升10%;對URL按照規(guī)則重寫作為緩存KEY,去隨機,即頁面URL不管怎么變都不要讓它成為緩存不***的因素。
多級緩存
對于讀服務(wù),我們在設(shè)計時會使用多級緩存來盡量減少后端服務(wù)壓力,在統(tǒng)一服務(wù)系統(tǒng)中,我們設(shè)計了四級緩存,如下所示:
1.1、首先在接入層,會使用Nginx本地緩存,這種前端緩存主要目的是抗熱點;根據(jù)場景來設(shè)置緩存時間;
1.2、如果Nginx本地緩存不***,接著會讀取各個機房的分布式從Redis緩存集群,該緩存主要是保存大量離散數(shù)據(jù),抗大規(guī)模離散請求,比如使用一致性哈希來構(gòu)建Redis集群,即使其中的某臺機器出問題,也不會出現(xiàn)雪崩的情況;
1.3、如果從Redis集群不***,Nginx會回源到Tomcat;Tomcat首先讀取本地堆緩存,這個主要用來支持在一個請求中多次讀取一個數(shù)據(jù)或者該數(shù)據(jù)相關(guān)的數(shù)據(jù);而其他情況***率是非常低的,或者緩存一些規(guī)模比較小但用的非常頻繁的數(shù)據(jù),如分類,品牌數(shù)據(jù);堆緩存時間我們設(shè)置為Redis緩存時間的一半;
1.4、如果Java堆緩存不***,會讀取主Redis集群,正常情況該緩存***率非常低,不到5%;讀取該緩存的目的是防止前端緩存失效之后的大量請求的涌入,導(dǎo)致我們后端服務(wù)壓力太大而雪崩;我們默認開啟了該緩存,雖然增加了幾毫秒的響應(yīng)時間,但是加厚了我們的防護盾,使服務(wù)更穩(wěn)當(dāng)可靠。此處可以做下改善,比如我們設(shè)置一個閥值,超過這個閥值我們才讀取主Redis集群,比如Guava就有RateLimiter API來實現(xiàn)。
統(tǒng)一入口/服務(wù)閉環(huán)
在《構(gòu)建需求響應(yīng)式億級商品詳情頁》中已經(jīng)講過了數(shù)據(jù)異構(gòu)閉環(huán)的收益,在統(tǒng)一服務(wù)中我們也遵循這個設(shè)計原則,此處我們主要做了兩件事情:
1、數(shù)據(jù)異構(gòu),如判斷庫存狀態(tài)依賴的套裝、配件關(guān)系我們進行了異構(gòu),未來可以對商家運費等數(shù)據(jù)進行異構(gòu),減少接口依賴;
2、服務(wù)閉環(huán),所有單品頁上用到的核心接口都接入統(tǒng)一服務(wù);有些是查庫/緩存然后做一些業(yè)務(wù)邏輯,有些是http接口調(diào)用然后進行簡單的數(shù)據(jù)邏輯處理;還有一些就是做了下簡單的代理,并監(jiān)控接口服務(wù)質(zhì)量。
引入Nginx接入層
我們在設(shè)計系統(tǒng)時需要把一些邏輯盡可能前置以此來減輕后端核心邏輯的壓力,另外如服務(wù)升級/服務(wù)降級能非常方便的進行切換,在接入層我們做了如下事情:
- 數(shù)據(jù)校驗/過濾邏輯前置、緩存前置、業(yè)務(wù)邏輯前置
- 降級開關(guān)前置
- AB測試
- 灰度發(fā)布/流量切換
- 監(jiān)控服務(wù)質(zhì)量
- 限流
數(shù)據(jù)校驗/過濾邏輯前置
我們服務(wù)有兩種類型的接口:一種是用戶無關(guān)的接口,另一種則是用戶相關(guān)的接口;因此我們使用了兩種類型的域名c.3.cn/c0.3.cn/c1.3.cn和cd.jd.com;當(dāng)我們請求cd.jd.com會帶著用戶cookie信息到服務(wù)端;在我們服務(wù)器上會進行請求頭的處理,用戶無關(guān)的所有數(shù)據(jù)通過參數(shù)傳遞,在接入層會丟棄所有的請求頭(保留gzip相關(guān)的頭);而用戶相關(guān)的會從cookie中解出用戶信息然后通過參數(shù)傳遞到后端;也就是后端應(yīng)用從來就不關(guān)心請求頭及Cookie信息,所有信息通過參數(shù)傳遞。
請求進入接入層后,會對參數(shù)進行校驗,如果參數(shù)校驗不合法直接拒絕這次請求;我們對每個請求的參數(shù)進行了最嚴格的數(shù)據(jù)校驗處理,保證數(shù)據(jù)的有效性。如下所示,我們對關(guān)鍵參數(shù)進行了過濾,如果這些參數(shù)不合法就直接拒絕請求。
另外我們還會對請求的參數(shù)進行過濾然后重新按照固定的模式重新拼裝URL調(diào)度到后端應(yīng)用,此時URL上的參數(shù)是固定的而且是有序的,可以按照URL進行緩存。
緩存前置
我們把很多緩存前置到了接入層,來進行熱點數(shù)據(jù)的削峰,而且配合一致性哈希可能提升緩存的***率。在緩存時我們按照業(yè)務(wù)來設(shè)置緩存池,減少相互之間的影響和提升并發(fā)。我們使用Lua讀取共享字典來實現(xiàn)本地緩存。
業(yè)務(wù)邏輯前置
我們在接入層直接實現(xiàn)了一些業(yè)務(wù)邏輯,原因是當(dāng)在高峰時出問題,可以在這一層做一些邏輯升級;我們后端是Java應(yīng)用,當(dāng)修復(fù)邏輯時需要上線,而一次上線可能花費數(shù)十秒時間啟動應(yīng)用,重啟應(yīng)用后Java應(yīng)用JIT的問題會存在性能抖動的問題,可能因為重啟造成服務(wù)一直啟動不起來的問題;而在Nginx中做這件事情,改完代碼推送到服務(wù)器,重啟只需要秒級,而且不存在抖動的問題。這些邏輯都是在Lua中完成。
降級開關(guān)前置
我們降級開關(guān)分為這么幾種:接入層開關(guān)和后端應(yīng)用開關(guān)、總開關(guān)和原子開關(guān);我們在接入層設(shè)置開關(guān)的目的是防止降級后流量還無謂的打到后端應(yīng)用;總開關(guān)是對整個服務(wù)降級,比如庫存服務(wù)默認有貨;而原子開關(guān)時整個服務(wù)中的其中一個小服務(wù)降級,比如庫存服務(wù)中需要調(diào)用商家運費服務(wù),如果只是商家運費服務(wù)出問題了,此時可以只降級商家運費服務(wù)。另外我們還可以根據(jù)服務(wù)重要程度來使用超時自動降級機制。
我們使用init_by_lua_file初始化開關(guān)數(shù)據(jù),共享字典存儲開關(guān)數(shù)據(jù),提供API進行開關(guān)切換(switch_get(“stock.api.not.call”) ~= “1”)??梢詫崿F(xiàn):秒級切換開關(guān)、增量式切換開關(guān)(可以按照機器組開啟,而不是所有都開啟)、功能切換開關(guān)、細粒度服務(wù)降級開關(guān)、非核心服務(wù)可以超時自動降級。
比如雙十一期間我們有些服務(wù)出問題了,我們進行過大服務(wù)和小服務(wù)的降級操作,這些操作對用戶來說都是無感知的。
AB測試
對于服務(wù)升級,最重要的就是能做AB測試,然后根據(jù)AB測試的結(jié)果來看是否切新服務(wù);而有了接入層非常容易進行這種AB測試;不管是上線還是切換都非常容易??梢栽贚ua中根據(jù)請求的信息調(diào)用不同的服務(wù)或者upstream分組即可完成AB測試。
灰度發(fā)布/流量切換
對于一個靈活的系統(tǒng)來說,能隨時進行灰度發(fā)布和流量切換是非常重要的一件事情,比如驗證新服務(wù)器是否穩(wěn)定,或者驗證新的架構(gòu)是否比老架構(gòu)更優(yōu)秀,有時候只有在線上跑著才能看出是否有問題;我們在接入層可以通過配置或者寫Lua代碼來完成這件事情,靈活性非常好??梢栽O(shè)置多個upstream分組,然后根據(jù)需要切換分組即可。
監(jiān)控服務(wù)質(zhì)量
對于一個系統(tǒng)最重要的是要有雙眼睛能盯著系統(tǒng)來盡可能早的發(fā)現(xiàn)問題,我們在接入層會對請求進行代理,記錄status、request_time、response_time來監(jiān)控服務(wù)質(zhì)量,比如根據(jù)調(diào)用量、狀態(tài)碼是否是200、響應(yīng)時間來告警。
限流
我們系統(tǒng)中存在的主要限流邏輯是:對于大多數(shù)請求按照IP請求數(shù)限流,對于登陸用戶按照用戶限流;對于讀取緩存的請求不進行限流,只對打到后端系統(tǒng)的請求進行限流。還可以限制用戶訪問頻率,比如使用ngx_lua中的ngx.sleep對請求進行休眠處理,讓刷接口的速度降下來;或者種植cookie token之類的,必須按照流程訪問。當(dāng)然還可以對爬蟲/刷數(shù)據(jù)的請求返回假數(shù)據(jù)來減少影響。
前端業(yè)務(wù)邏輯后置
前端JS應(yīng)該盡可能少的業(yè)務(wù)邏輯和一些切換邏輯,因為前端JS一般推送到CDN,假設(shè)邏輯出問題了,需要更新代碼上線,推送到CDN然后失效各個邊緣CDN節(jié)點;或者通過版本號機制在服務(wù)端模板中修改版本號上線,這兩種方式都存在效率問題,假設(shè)處理一個緊急故障用這種方式處理完了可能故障也恢復(fù)了。因此我們的觀點是前端JS只拿數(shù)據(jù)展示,所有或大部分邏輯交給后端去完成,即靜態(tài)資源CSS/JS CDN,動態(tài)資源JSONP;前端JS瘦身,業(yè)務(wù)邏輯后置。
在雙十一期間我們的某些服務(wù)出問題了,不能更新商品信息,此時秒殺商品需要打標(biāo)處理,因此我們在服務(wù)端完成了這件事情,整個處理過程只需要幾十秒就能搞定,避免了商品不能被秒殺的問題。而如果在JS中完成需要耗費非常長的時間,因為JS在客戶端還有緩存時間,而且一般緩存時間非常長。
前端接口服務(wù)端聚合
商品詳情頁上依賴的服務(wù)眾多,一個類似的服務(wù)需要請求多個不相關(guān)的服務(wù)接口,造成前端代碼臃腫,判斷邏輯眾多;而我無法忍受這種現(xiàn)狀,我想要的結(jié)果就是前端異步請求我的一個API,我把相關(guān)數(shù)據(jù)準備好發(fā)過去,前端直接拿到數(shù)據(jù)展示即可;所有或大部分邏輯在服務(wù)端完成而不是在客戶端完成;因此我們在接入層使用Lua協(xié)程機制并發(fā)調(diào)用多個相關(guān)服務(wù)然后***把這些服務(wù)進行了合并。
比如推薦服務(wù):***組合、推薦配件、優(yōu)惠套裝;通過
http://c.3.cn/recommend?methods=accessories,suit,combination&sku=1159330&cat=6728,6740,12408&lid=1&lim=6進行請求獲取聚合的數(shù)據(jù),這樣原來前端需要調(diào)用三次的接口只需要一次就能吐出所有數(shù)據(jù)。
我們對這種請求進行了API封裝,如下所示:
比如庫存服務(wù),判斷商品是否有貨需要判斷:1、主商品庫存狀態(tài)、2、主商品對應(yīng)的套裝子商品庫存狀態(tài)、主商品附件庫存狀態(tài)及套裝子商品附件庫存狀態(tài);套裝商品是一個虛擬商品,是多個商品綁定在一起進行售賣的形式。如果這段邏輯放在前段完成,需要多次調(diào)用庫存服務(wù),然后進行組合判斷,這樣前端代碼會非常復(fù)雜,凡是涉及到調(diào)用庫存的服務(wù)都要進行這種判斷;因此我們把這些邏輯封裝到服務(wù)端完成;前端請求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,然后服務(wù)端計算整個庫存狀態(tài),而前端不需要做任何調(diào)整。在服務(wù)端使用Lua協(xié)程并發(fā)的進行庫存調(diào)用,如下圖所示:
比如今日抄底服務(wù),調(diào)用接口太多,如庫存、價格、促銷等都需要調(diào)用,因此我們也使用這種機制把這幾個服務(wù)在接入層合并為一個大服務(wù),對外暴露:http://c.3.cn/today?skuId=1264537&area=1_72_2840_0&promotionId=182369342&cat=737,752,760&callback=jQuery9364459&_=1444305642364。
我們目前合并的主要有:促銷和廣告詞合并、配送至相關(guān)服務(wù)合并。而未來這些服務(wù)都會合并,會在前端進行一些特殊處理,比如設(shè)置超時,超時后自動調(diào)用原子接口;接口吐出的數(shù)據(jù)狀態(tài)碼不對,再請求一次原子接口獲取相關(guān)數(shù)據(jù)。
服務(wù)隔離
服務(wù)隔離的目的是防止因為某些服務(wù)抖動而造成整個應(yīng)用內(nèi)的所有服務(wù)不可用,可以分為:應(yīng)用內(nèi)線程池隔離、部署/分組隔離、拆應(yīng)用隔離。
應(yīng)用內(nèi)線程池隔離,我們采用了Servlet3異步化,并為不同的請求按照重要級別分配線程池,這些線程池是相互隔離的,我們也提供了監(jiān)控接口以便發(fā)現(xiàn)問題及時進行動態(tài)調(diào)整,該實踐可以參考《商品詳情頁系統(tǒng)的Servlet3異步化實踐》。
部署/分組隔離,意思是為不同的消費方提供不同的分組,不同的分組之間不相互影響,以免因為大家使用同一個分組導(dǎo)致有些人亂用導(dǎo)致整個分組服務(wù)不可用。
拆應(yīng)用隔離,如果一個服務(wù)調(diào)用量巨大,那我們便可以把這個服務(wù)單獨拆出去,做成一個應(yīng)用,減少因其他服務(wù)上線或者重啟導(dǎo)致影響本應(yīng)用。
【本文是51CTO專欄作者張開濤的原創(chuàng)文章,作者微信公眾號:開濤的博客,id:kaitao-1234567】