京東商品詳情頁服務閉環實踐
該文章是根據OpenResty Con 2015技術大會的演講《 Nginx+Lua在京東商品詳情頁的大規模應用 》細化而來,希望對大家有用。
京東商品詳情頁技術方案在之前《 構建需求響應式億級商品詳情頁 》這篇文章已經為大家揭秘了,接下來為大家揭秘下雙十一抗下幾十億流量的商品詳情頁統一服務架構,這次雙十一整個商品詳情頁沒有出現不服務的情況,服務非 常穩定。統一服務提供了:促銷和廣告詞合并服務、庫存狀態/配送至服務、延保服務、試用服務、推薦服務、圖書相關服務、詳情頁優惠券服務、今日抄底服務等 服務支持;這些服務中有我們自己做的服務實現,而有些是簡單做下代理或者接口做了合并輸出到頁面,我們聚合這些服務到一個系統的目的是打造服務閉環,優化 現有服務,并為未來需求做準備,跟著自己的方向走,而不被別人亂了我們的方向。
大家在頁面中看到的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已經是我們應用的一部分,我們在實際使用中,也是把它做為項目開發,做為應用進行部署。
一些架構思路和總結
我們主要遵循如下幾個原則設計系統架構:
- 兩種讀服務架構模式
- 本地緩存
- 多級緩存
- 統一入口/服務閉環
- 引入接入層
- 前端業務邏輯后置
- 前端接口服務端聚合
- 服務隔離
兩種讀服務架構模式
1 、讀取分布式Redis 數據架構
可以看到Nginx應用和Redis單獨部署,這種方式是一般應用的部署模式,也是我們統一服務的部署模式,此處會存在跨機器、跨交換機或跨機柜 讀取Redis緩存的情況,但是不存在跨機房情況,因為通過主從把數據復制到各個機房。如果對性能要求不是非常苛刻,可以考慮這種架構,比較容易維護。
2 、讀取本地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不管怎么變都不要讓它成為緩 存不命中的因素。
多級緩存
對于讀服務,我們在設計時會使用多級緩存來盡量減少后端服務壓力,在統一服務系統中,我們設計了四級緩存,如下所示:
1.1、首先在接入層,會使用Nginx本地緩存,這種前端緩存主要目的是抗熱點;根據場景來設置緩存時間;
1.2、如果Nginx本地緩存不命中,接著會讀取各個機房的分布式從Redis緩存集群,該緩存主要是保存大量離散數據,抗大規模離散請求,比如使用一致性哈希來構建Redis集群,即使其中的某臺機器出問題,也不會出現雪崩的情況;
1.3、如果從Redis集群不命中,Nginx會回源到Tomcat;Tomcat首先讀取本地堆緩存,這個主要用來支持在一個請求中多次讀取 一個數據或者該數據相關的數據;而其他情況命中率是非常低的,或者緩存一些規模比較小但用的非常頻繁的數據,如分類,品牌數據;堆緩存時間我們設置為 Redis緩存時間的一半;
1.4、如果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信息,所有信息通過參數傳遞。
請求進入接入層后,會對參數進行校驗,如果參數校驗不合法直接拒絕這次請求;我們對每個請求的參數進行了最嚴格的數據校驗處理,保證數據的有效性。如下所示,我們對關鍵參數進行了過濾,如果這些參數不合法就直接拒絕請求。
另外我們還會對請求的參數進行過濾然后重新按照固定的模式重新拼裝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測試;不管是上線還是切換都非常容易。可以在Lua中根據請求的信息調用不同的服務或者upstream分組即可完成AB測試。
灰度發布/ 流量切換
對于一個靈活的系統來說,能隨時進行灰度發布和流量切換是非常重要的一件事情,比如驗證新服務器是否穩定,或者驗證新的架構是否比老架構更優秀, 有時候只有在線上跑著才能看出是否有問題;我們在接入層可以通過配置或者寫Lua代碼來完成這件事情,靈活性非常好。可以設置多個upstream分組, 然后根據需要切換分組即可。
監控服務質量
對于一個系統最重要的是要有雙眼睛能盯著系統來盡可能早的發現問題,我們在接入層會對請求進行代理,記錄status、request_time、response_time來監控服務質量,比如根據調用量、狀態碼是否是200、響應時間來告警。
限流
我們系統中存在的主要限流邏輯是:對于大多數請求按照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、主商品對應的套裝子商品庫存狀態、主商品附件庫存狀態及套裝子商品附件庫存狀 態;套裝商品是一個虛擬商品,是多個商品綁定在一起進行售賣的形式。如果這段邏輯放在前段完成,需要多次調用庫存服務,然后進行組合判斷,這樣前端代碼會 非常復雜,凡是涉及到調用庫存的服務都要進行這種判斷;因此我們把這些邏輯封裝到服務端完成;前端請求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}& amp;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& amp;_=1444305642364。
我們目前合并的主要有:促銷和廣告詞合并、配送至相關服務合并。而未來這些服務都會合并,會在前端進行一些特殊處理,比如設置超時,超時后自動調用原子接口;接口吐出的數據狀態碼不對,再請求一次原子接口獲取相關數據。
服務隔離
服務隔離的目的是防止因為某些服務抖動而造成整個應用內的所有服務不可用,可以分為:應用內線程池隔離、部署/分組隔離、拆應用隔離。
應用內線程池隔離,我們采用了Servlet3異步化,并為不同的請求按照重要級別分配線程池,這些線程池是相互隔離的,我們也提供了監控接口以便發現問題及時進行動態調整,該實踐可以參考《 商品詳情頁系統的Servlet3異步化實踐 》。
部署/分組隔離,意思是為不同的消費方提供不同的分組,不同的分組之間不相互影響,以免因為大家使用同一個分組導致有些人亂用導致整個分組服務不可用。
拆應用隔離,如果一個服務調用量巨大,那我們便可以把這個服務單獨拆出去,做成一個應用,減少因其他服務上線或者重啟導致影響本應用。