本文作者是一個一線的電商老兵,任職於京東商城。在本文中,他將會分享他在構建以讀為主的系統時總結的經驗和教訓,內容包括使用HTTP協議對外通訊、使用短連接、數據異構、巧用緩存、流量控制、防刷、降級、多域名等,作者老馬不帶遮掩的,把自己總結的經驗,包括代碼都放到這里了,歡迎各位檢閱!
幾乎所有的互聯網系統從開始都是一體化設計的,基本上所有的功能代碼都是耦合在一起的。后續隨着用戶的不斷增多業務也越來越多樣化,系統需要的維護人員也會越來越多,相應的系統的復雜度、穩定性、可維護性也就越來越難控制,這時系統的拆分以及服務化就成了必然的選擇。
系統被拆分后實現方式也就多樣化起來,各個系統可以根據自己的業務需求、技術特性、方便程度甚至個人喜好來選擇使用不同的語言。服務化后各種功能被拆分的越來越細,原來可能一次請求能夠完成的事,現在就需要多次請求並將結果進行融合。
服務化的好處是系統的職責變得清晰,可以突破單一資源限制等,比如突破數據庫連接資源的限制(包括關系型數據庫、非關系型數據庫);不太友好的地方如服務分化、治理復雜等,比如頁面要展示一個商品就需要調用庫存、商品、價格、促銷等各種服務。
像庫存、商品、價格等這些體量(訪問量+數據量)非常大的服務將他們拆分為單一系統(一個系統只提供一種服務)是很有必要的。對於體量不夠大的或者職責划分不清的服務,為了便於維護和使用,一般會將其融合在一個系統中(暫且稱它為”非單一系統”)。這些服務一個共同的特點是讀大於寫,比如京東首頁的全部分類、熱搜索詞等, 可以說是一個徹徹底底的讀服務,這些信息數據量小而且很少改動,讀取量遠遠高於寫入(或更新)量,像單品頁要用到的延保、pop套裝等服務,雖然對於單個商品他們的讀寫不頻繁,但他們會涉及很多(億級別)sku,所以整體加起來他們的訪問量、數據量、更新頻率都不小。那么針對這些五花八門的服務,怎么才能在一個系統里,既要保證高可用,又保證高性能,還要保證數據一致性等問題,下面我們就來一一解答。
-
提供的服務多
-
依賴的數據源多樣化,數據庫、HTTP接口、JSF(公司內部RPC框架)接口等
-
系統以讀為主
-
整體服務加起來體量大(訪問量+數據量)
-
需要快速響應
-
服務之間相互影響性要小
根據以上系統特點,我們實現該系統時遵循以下幾個大的原則:
-
使用HTTP協議對外通信
-
使用短連接
-
數據異構
-
巧用緩存
-
流量控制
-
異步、並行
-
數據托底
-
防刷
-
降級
-
多域名
使用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上。
簡單描述下實現方式:
-
存儲時先判斷數據大小(數據大小用n表示,閥值用a表示),如果n大於a則代表需要將數據切割存儲,切割的塊數用b表示,b是Redis的實例個數,用n整除b得出的數c是要切割的數據塊(前b-1塊)的大小,最后一塊數據的大小是n-c*2。存儲前生成一個版本號,將這個版本號放到被切割塊的第一個字節,然后按照順序異步將其存入各個Redis中,最后再為代表該數據的key打上標記,標記該key的數據是被切割的。
-
取數據時先檢查該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
