設計高併發下的讀服務?一個電商老兵的10條經驗

本文做者是一個一線的電商老兵,任職於京東商城。在本文中,他將會分享他在構建以讀爲主的系統時總結的經驗和教訓,內容包括使用HTTP協議對外通信、使用短鏈接、數據異構、巧用緩存、流量控制、防刷、降級、多域名等,做者老馬不帶遮掩的,把本身總結的經驗,包括代碼都放到這裏了,歡迎各位檢閱!前端

幾乎全部的互聯網系統從開始都是一體化設計的,基本上全部的功能代碼都是耦合在一塊兒的。後續隨着用戶的不斷增多業務也愈來愈多樣化,系統須要的維護人員也會愈來愈多,相應的系統的複雜度、穩定性、可維護性也就愈來愈難控制,這時系統的拆分以及服務化就成了必然的選擇。git

系統被拆分後實現方式也就多樣化起來,各個系統能夠根據本身的業務需求、技術特性、方便程度甚至我的喜愛來選擇使用不一樣的語言。服務化後各類功能被拆分的愈來愈細,原來可能一次請求可以完成的事,如今就須要屢次請求並將結果進行融合。github

服務化的好處是系統的職責變得清晰,能夠突破單一資源限制等,好比突破數據庫鏈接資源的限制(包括關係型數據庫、非關係型數據庫);不太友好的地方如服務分化、治理複雜等,好比頁面要展現一個商品就須要調用庫存、商品、價格、促銷等各類服務。redis

像庫存、商品、價格等這些體量(訪問量+數據量)很是大的服務將他們拆分爲單一系統(一個系統只提供一種服務)是頗有必要的。對於體量不夠大的或者職責劃分不清的服務,爲了便於維護和使用,通常會將其融合在一個系統中(暫且稱它爲」非單一系統」)。這些服務一個共同的特色是讀大於寫,好比京東首頁的所有分類、熱搜索詞等, 能夠說是一個不折不扣的讀服務,這些信息數據量小並且不多改動,讀取量遠遠高於寫入(或更新)量,像單品頁要用到的延保、pop套裝等服務,雖然對於單個商品他們的讀寫不頻繁,但他們會涉及不少(億級別)sku,因此總體加起來他們的訪問量、數據量、更新頻率都不小。那麼針對這些五花八門的服務,怎麼才能在一個系統裏,既要保證高可用,又保證高性能,還要保證數據一致性等問題,下面咱們就來一一解答。算法

系統特色
  1. 提供的服務多數據庫

  2. 依賴的數據源多樣化,數據庫、HTTP接口、JSF(公司內部RPC框架)接口等後端

  3. 系統以讀爲主緩存

  4. 總體服務加起來體量大(訪問量+數據量)安全

  5. 須要快速響應服務器

  6. 服務之間相互影響性要小

基本原則

根據以上系統特色,咱們實現該系統時遵循如下幾個大的原則:

  1. 使用HTTP協議對外通訊

  2. 使用短鏈接

  3. 數據異構

  4. 巧用緩存

  5. 流量控制

  6. 異步、並行

  7. 數據託底

  8. 防刷

  9. 降級

  10. 多域名

使用HTTP協議對外通訊

前面提到服務化後各個系統使用的語言能夠不相同,對於使用同一種語言實現的不一樣系統,能夠指定語言相關的協議進行通訊,好比JSF(公司內部RPC框架),不一樣語言的系統之間就須要找一個通用的協議來通訊。SOAP簡單對象訪問協議是一種非語言相關的通訊協議, 以HTTP協議爲載體進行傳輸,雖然有各類輔助框架,但它仍是過重了,相比較HTTP從便捷和使用範圍上有絕對的優點,因此本系統以HTTP協議對外提供服務。

使用短鏈接

HTTP協議自己是工做在TCP協議上的,這裏說的長鏈接短鏈接本質上只的是TCP的長短鏈接。所謂的長鏈接顧名思義就是用完以後不當即斷開鏈接,什麼時候斷開取決於上層業務設置和底層協議是否發生異常,短鏈接就比較乾脆,幹完活立刻就將鏈接關閉,過完河就拆橋。

在HTTP中開啓長鏈接須要在協議頭中加上Connection:keep-alive,固然最終是否使用長鏈接通訊是須要雙方進行協商的,客戶端和服務端只要有一方不一樣意,則開啓失敗。長鏈接由於能夠複用鏈路,因此若是請求頻繁,能夠減小鏈接的創建和關閉時間,從而節省資源。

HTTP 1.0默認使用短鏈接,HTTP 1.1中開啓短鏈接須要在協議頭上加上Connection:close,如何單個客戶請求頻繁,TCP連接的創建和關閉多少會浪費點資源。

既然長鏈接這麼『好』,短鏈接這麼『很差』爲何還要使用短鏈接呢?咱們知道這個『鏈接』其實是TCP鏈接。TCP鏈接是有一個四元組表示的,如[源ip:源port—目標ip:目標port]。從這個四元組能夠看到理論上能夠有無數個鏈接, 可是操做系統可以承受的鏈接但是有限的,假設咱們設置了長鏈接,那麼無論這個時間有多短,在高併發下server端都會產生大量的TCP鏈接,操做系統維護每一個鏈接不但要消耗內存也會消耗CPU,在高併發下維護過多的活躍鏈接風險可想而知。

並且在長鏈接的狀況下若是有人搞惡意攻擊,建立完鏈接後什麼都不作,勢必會對Server產生不小的壓力。因此在互聯網這種高併發系統中,使用短鏈接是一個明智的選擇。對於服務端因短鏈接產生的大量的TIME_WAIT狀態的鏈接,能夠更改系統的一些內核參數來控制,好比net.ipv4.tcp_max_tw_buckets、net.ipv4.tcp_tw_recycle、net.ipv4.tcp_tw_reuse等參數(注:非專業人士調優內核參數要慎重)。

具體TIME_WAIT等TCP的各類狀態這裏再也不詳述,給出一個簡單狀態轉換圖供參考:

數據異構

一個大的原則,若是依賴的服務不可靠,那系統就可能隨時出問題。對於依賴服務的數據,能異構的就要拿過來,有了數據就能夠作任何你想作的事,有了數據,依賴服務再怎麼變着花的掛對你的影響也是有限的。

異構時能夠將數據打散,將數據原子化,這樣在向外提供服務時,能夠任意組裝拼合。

巧用緩存

應對高併發系統,緩存是必不可少的利器,巧妙的使用緩存會使系統的性能有質的飛躍,下面就介紹一下本系統使用緩存的幾種方式。

使用Redis緩存

首先看一下使用Redis緩存的簡單數據流向圖:

很典型的使用緩存的一種方式,這裏先重點介紹一下在緩存命中與不命中時都作了哪些事。

當用戶發起請求後,首先在Nginx這一層直接從Redis獲取數據, 這個過程當中Nginx使用lua-resty-redis操做Redis,該模塊支持網絡Socket和unix domain socket。若是命中緩存,則直接返回客戶端。若是沒有則回源請求數據,這裏要記住另外一個原則,不可『隨意回源』(爲了保護後端應用)。爲了解決高併發下緩存失效後引起的雪崩效應,咱們使用lua-resty-lock(異步非阻塞鎖)來解決這個問題。

不少人一談到鎖就心有忌憚,認爲一旦用上鎖必然會影響性能,這種想法的不妥的。咱們這裏使用的lua-resty-lock是一個基於Nginx共享內存(ngx.shared.DICT)的非阻塞鎖(基於Nginx的時間事件實現),說它是非阻塞的是由於它不會阻塞Nginx的worker進程,當某個key(請求)獲取到該鎖後,後續試圖對該key再一次獲取鎖時都會『阻塞』在這裏,但不會阻塞其它的key。當第一個獲取鎖的key將獲取到的數據更新到緩存後,後續的key就不會再回源後端應用了,從而能夠起到保護後端應用的做用。

下面貼一段從官網弄過來的簡化代碼,詳細使用請移步 https://github.com/openresty/lua-resty-lock 。使用Nginx共享緩存

上面使用到的Redis緩存,即便Redis部署在本地仍然會有進程間通訊、內核態和用戶態的數據拷貝,使用Nginx的共享緩存能夠將這些動做都省略掉。

Nginx共享緩存是worker共享的,也就是說它是一個全局的緩存,使用Nginx的lua_shared_dict配置指令定義。語法以下:

#指定一個100m的共享緩存 
lua_shared_dict cache 100m;

數據流向圖以下:

緩存分片

當緩存數據的總量大到必定程度後,單個Redis實例就會成爲瓶頸,這時候就要考慮分片了,具體如何分片能夠根據本身的系統特性來定,若是不是對性能有苛刻的要求,能夠直接使用一些Redis代理(如temproxy),由於代理對Redis性能有必定的損耗。

使用代理的另外一個好處是它支持多種分片算法,並且對用戶是透明的。咱們這裏沒有選擇代理,而是本身實現了一個簡單的分片算法。

該分片算法在Java端基於Jedis擴展出一套取摸算法,向Redis寫數據。Nginx這端使用lua+c實現一樣的算法,從Redis讀數據。

另外一種是對Nginx的共享緩存(dict)作分片,dict自己使用自旋鎖加紅黑樹實現的,它這個鎖是一個阻塞鎖。一樣當緩存在dict中的數據量和訪問併發量大到必定程度後,對其分片也是必須的了。

緩存數據切割

早前閱讀Redis代碼發現Redis在每一個事件循環中,一次最多向某個鏈接吐64K的數據,也就是說當緩存的數據大於64K時,至少須要兩個事件循環才能將數據吐完。固然,在網絡發生擁堵或者對端處理數據慢時,即便緩存數據小於64K,也不能保證在一個事件循環內吐完數據。基於這種狀況咱們能夠考慮,當數據大於某個閥值時,將數據切割成多個小塊,而後將其放到不一樣的Redis上。

簡單描述下實現方式:

  1. 存儲時先判斷數據大小(數據大小用n表示,閥值用a表示),若是n大於a則表明須要將數據切割存儲,切割的塊數用b表示,b是Redis的實例個數,用n整除b得出的數c是要切割的數據塊(前b-1塊)的大小,最後一塊數據的大小是n-c*2。存儲前生成一個版本號,將這個版本號放到被切割塊的第一個字節,而後按照順序異步將其存入各個Redis中,最後再爲表明該數據的key打上標記,標記該key的數據是被切割的。

  2. 取數據時先檢查該key是否被標記,若是被標記則使用ngx.thread.spawn(),按照順序異步並行向各個Redis發送get命令,而後對比獲取到的全部數據塊的第一個字節,若是比對一直,則拼裝輸出。

注:這種算法用在Nginx的共享緩存不會有性能的提高,由於共享緩存的操做都是阻塞操做,只有支持非阻塞操做的網絡通訊纔會對性能有提高。

緩存更新

根據業務的不一樣,緩存更新的方式也各有不一樣,一種容易帶來隱患的方式是被動更新,這種更新方式在緩存失效後,須要經過回源的方式來更新緩存,這時須要運用多種手段來控制回源量(好比前面說到的非阻塞鎖)。

另外一種咱們稱之爲主動更新,主動更新通常有消息、worker(定時器)等方式,使用消息方式能夠確保數據實時性比較高,worker方式實時性要少差一點。實際項目中使用哪一種方式更新緩存,能夠從可維護性、安全性、業務性、實時性等方便找一個平衡點以便選擇合適的更新方式。

數據一致性

爲了保證服務快速響應,咱們的Redis都是部署在本地的,這樣能夠減小網絡傳輸消耗的時間,也能夠避免緩存和應用之間網絡故障形成的風險。這個單機部署會形成相互之間數據不一致,爲了解決這個問題,咱們使用了Redis的主從功能,而且Redis以樹形結構進行部署,這時每一個集羣一個主Redis,同一個集羣中的服務都向主Redis寫數據, 由主Redis將數據逐個同步下去,每一個服務器只讀本身本機的Redis。

Redis的部署結構像這樣:

在描述緩存更新時,提到了worker更新,基於上述的Redis部署方式,咱們用worker更新緩存時會存在必定的問題。若是全部的機器都部署了worker,那麼當這些worker會在某個時刻同時執行,這顯然是不可行的。若是咱們每一個集羣部署一個worker,那麼勢必形成單點問題。基於以上問題咱們實現了一種分佈式worker,這種worker基於Redis以集羣爲單位,在一個時間段內(好比3分鐘)只會有一個worker被啓動。 這樣既能夠避免worker單點,又能夠保持代碼的統一。

流量控制

這一原則主要爲了不繫統過載,能夠採用多種方式達到此目的。流量控制能夠在前端作(Nginx),也能夠在後端作。

咱們知道servelt 2.x在處理請求時用的是多線程同步模型,每個請求都會建立一個線程,而後同步的執行該請求,這個模型受限於線程資源的限制,很難產生大的吞吐量,並且某個業務阻塞就會引發連鎖反應。基於servlet 2.x的容器咱們通常採用池化技術和同步並行操做,使用池化技術能夠將資源進行配額分配,好比數據庫鏈接池。同步並行須要業務特性的支持,好比一個請求依賴多個後端服務,若是這些後端服務在業務上沒有一個前後順序的依賴,那麼咱們徹底能夠將這些服務放到一個線程池中去並行執行,說它同步是由於咱們須要等到全部的服務都返回結果後才能繼續向下執行。

目前servlet 3支持異步請求,是一種多線程異步模型, 它的每一個請求仍然要使用一個線程,只不過能夠進行異步操做了。這種模型的一個優勢是能夠按業務來分配請求資源了,好比你的系統要向外提供10中服務,你能夠爲每種服務分配一個固定的線程池,這樣服務之間能夠相互隔離。

缺點是因爲是異步,因此就須要各類回調,開發和維護成本高。同事有一個項目用到了servlet 3,測試結果顯示這種方式不會得到更短的響應時間,反而會有稍微降低,可是吞吐量確實有提高。 因此最終是否使用這種方式,取決於你的系統更傾向於完成哪一種特性。

除了在後端進行流量控制,還能夠在Nginx層作控制。目前在Nginx層有多種模塊能夠支持流量控制,如ngx_http_limit_conn_module

、ngx_http_limit_req_module、lua-resty-limit-traffic(需安裝lua模塊)等,限於篇幅如何使用就不在詳述,感興趣可到官網查看。

數據託底

生產環境中有些服務可能很是重要,須要保證絕對可用,這時若是業務容許,咱們就能夠爲其作個數據託底。

託底方式很是多,這裏簡單介紹幾種,一種是在應用後端進行數據託底,這種方式比較靈活,能夠將數據存儲在內存、磁盤等各類設備上,當發生異常時能夠返回託底數據,缺點是和後端應用高度耦合,一旦應用容器掛掉託底也就不起做用了。

另外一種方式將託底功能跟應用剝離出來,可使用Lua的方式在Nginx作一層攔截,用每次請求回源返回的正確數據來更新託底數據(這個過程能夠作各類校驗),當服務或應用出問題時能夠直接從Nginx層返回數據。

還有一種是使用Nginx的error_page指令,簡單配置以下:

降級

降級的意義其實和流量控制的意義差很少,都是爲了確保系統負載穩定。當線上流量超過咱們預期時,爲了下降系統負載就能夠實施降級了。

降級的方式能夠是自動降級,好比咱們對一個依賴服務能夠設置一個超時,當超過這個時間時就能夠自動的返回一個默認值(前提是業務容許)。

手動降級,提早爲某些服務設置降級開關,出現問題是能夠將開關打開,好比前面咱們說到了有些服務是有託底數據的,當系統過載後咱們能夠將其降級到直接走託底數據。

防刷

對於一些有規律入參的請求,咱們能夠用嚴格檢驗入參的方式,來規避非法入參穿透緩存的行爲(好比一些爬蟲程序無限制的猜想商品價格),這種方式能夠作在前端(Nginx),也能夠作在後端(Tomcat),推薦在Nginx層作。 在Nginx層作入參校驗的例子:

使用計數器識別惡意用戶,好比在一段時間內爲每一個用戶或IP等記錄訪問次數,若是在規定的時間內超過規定的次數,則作一些對應策略。

對惡意用戶設置黑名單,每次訪問都檢查是否在黑名單中,存在就直接拒絕。

使用Cookie,若是用戶訪問是沒有帶指定的Cookie,或者和規定的Cookie規則不符,則作一些對應策略。

經過訪問日誌實時計算用戶的行爲,發現惡意行爲後對其作相應的對策。

多域名

除正常域名外,爲系統提供其它訪問域名。使用CDN域名縮短用戶請求鏈路,使用不帶Cookie的域名,下降用戶請求流量。

 

總結

以上大體介紹了開發以讀爲主系統的一些基本原則,用好這些原則,單臺機器天天抗幾十億流量不是問題。另外上面提到的好多原則,限於篇幅並無詳細展開描述,後續有時間再詳細展開。

做者介紹

馬順風,目前就任於京東商城,曾參與開發並設計過多個億級流量系統,擅長解決大併發、大流量問題。做爲一個不安分的Java碼農,懂點Lua會點C,業餘時間喜歡在Redis、Nginx、Nginx+Lua等方面瞎折騰。

http://mp.weixin.qq.com/s?__biz=MzA5Nzc4OTA1Mw==&mid=2659597710&idx=1&sn=e8801d7aba68485489cfcac9ac2fd2ba&scene=0#rd&utm_source=tuicool&utm_medium=referral

相關文章
相關標籤/搜索