天天看點

Redis 的高并發實戰:搶購系統 --淺奕

主要内容:

一、IO 模型和問題

二、資源競争與分布式鎖

三、Redis 搶購系統執行個體

1)Run-to-Completion in a solo thread

Redis社群版的IO模型比較簡單,通常是由一個 IO線程實作所有指令的解析與處理。

問題是如果有一條慢查詢指令,其他的查詢都要排隊。即當一個用戶端執行一個指令執行很慢的時候,後面的指令都會被阻塞。使用 Sentinel 判活,會導緻ping 指令也被延遲,ping 指令同樣受到慢查詢影響,如果引擎被卡住,則 ping 失敗,導緻無法判斷服務此時是不是可用,因為這是一種誤判。

如果此時發現服務沒有響應,我們從Master切換到Slave,結果又發現慢查詢拖慢了Slave,這樣的話,ping又會去誤判,導緻很難監聽服務是不是可靠。

問題總結:

1. 使用者所有的來自不同client的請求,實際上在每個事件到來後,都是單線程執行。等每個事件處理完成後,才處理下一個;

2. 單線程run-to-completion 就是沒有dispatcher,沒有後端的multi-worker;

如果慢查詢諸如 keys、lrange、hgetall等拖慢了一次查詢,那麼後面的請求就會被拖慢。

Redis 的高并發實戰:搶購系統 --淺奕

使用 Sentinel 判活的缺陷:

• ping 指令判活:ping 指令同樣受到慢查詢影響,如果引擎被卡住,則 ping 失敗;

• duplex Failure:sentinel 由于慢查詢切備(備變主)再遇到慢查詢則無法繼續工作。

2)Make it a cluster

用多個分片組成一個cluster的時候,也是同樣的問題。如果其中的某一個分片被慢查詢拖慢,比如使用者調用了跨分片的指令,如mget,通路到出問題的分片,仍會卡住,會導緻後續所有指令被阻塞。

1. 同樣的,叢集版解決不了單個 DB 被卡住的問題;

2. 查詢空洞:如果使用者調用了跨分片的指令,如mget,通路到出問題的分片,仍會卡住。

Redis 的高并發實戰:搶購系統 --淺奕

3)“Could not get a resource from the pool”

常見的 Redis用戶端如Jedis,會配連接配接池。業務線程去通路Redis的時候,每一個查詢會去裡面取一個長連接配接進行通路。如果該查詢比較慢,連接配接沒有傳回,那麼會等待很久,因為請求在傳回之前這個連接配接不能被其他線程使用。

如果查詢都比較慢,會使得每一個業務線程都拿一個新的長連接配接,這樣的話,會逐漸耗光所有的長連接配接,導緻最終抛出異常——連接配接池裡面沒有新的資源。因為Redis服務端是一個單線程,當用戶端的一個長連接配接被一個慢查詢阻塞時,後續連接配接上的請求也無法被及時處理,因為目前連接配接無法釋放給連接配接池。

之是以使用連接配接池,是因為 Redis 協定不支援連接配接收斂,Message 沒有 ID,是以 Request 和Response 關聯不起來。如果要實作異步的話,可以每一個請求發送的時候,把回調放入一個隊列裡面(每個連接配接一個隊列),在請求傳回之後從隊列取出來回調執行,即FIFO模型。但是服務端連接配接無法讓服務端亂序傳回,因為亂序在用戶端沒有辦法對應起來。一般用戶端的實作,用 BIO比較簡單,拿一個連接配接阻塞住,等其傳回之後,再讓給其他線程使用。

但實際上異步也不能提升效率,因為服務端實際上還是隻有一個線程,即便用戶端對通路方式進行修改,使得很多個連接配接去發請求,但在服務端一樣需要排隊,因為是單線程,是以慢查詢依然會阻塞别的長連接配接。

另外一個很嚴重的問題是,Redis的線程模型,當IO線程到萬以上的時候,性能比較差,如果有2萬到3萬長連接配接,性能将會慢到業務難以承受的程度。而業務機器,比如有300~500台,每一台配50個長連接配接,很容易達到瓶頸。

Redis 的高并發實戰:搶購系統 --淺奕

總結:

之是以使用連接配接池,是因為 Redis 協定不支援連接配接收斂

• Message 沒有 ID,是以 Request 和Response 關聯不起來;

• 非常類似 HTTP 1.x消息。

    當Engine層出現慢查詢,就會讓請求傳回的慢

• 很容易讓使用者把連接配接池用光;

• 當應用機器特别多的情況,按每個 client 連接配接池50個max_conn 來算,很容易打到 10K 連結的限制,導緻回調速度慢;

1. 每次查詢,都要先從連接配接池拿出一個連接配接,當請求傳回後,再放回連接配接池;

2. 如果使用者傳回的及時,那麼連接配接池一直保有的連接配接數并不高

• 但是一旦傳回不了,又有新的請求,就隻能再checkout一根連接配接;

• 當連接配接池被checkout完,就會爆沒有連接配接的異常:"Could not get a resource from the pool"。

補充一點在當下的Redis協定上實作異步接口的方法:

1. 類似上面提到的,一個連接配接配置設定一個回調隊列,在異步請求發出去前,将處理回調放入隊列中,等到響應回來後取出回調執行。這個方法比較常見,主流的支援異步請求的用戶端一般都這麼實作。

2. 有一些取巧的做法,比如使用Multi-Exec以及ping指令包裝請求,比如要調用set k v這個指令,包裝為下面的形式:

multi

ping {id}

set k v

exec

服務端的傳回是:

{id}

OK

這是利用Multi-Exec的原子執行以及ping的參數原樣傳回的特性來實作在協定中“夾帶”消息的ID的方式,比較取巧,也沒見用戶端這麼實作過。4)Redis 2.x/4.x/5.x 版本的線程模型

Redis5.X之前比較知名的版本,模型沒有變化過,所有的指令處理都是單線程,所有的讀、處理、寫都在一個主IO裡運作。背景有幾個BIO線程,任務主要是關閉檔案、刷檔案等等。

4.0之後,添加了LAZY_FREE,有些大KEY可以異步的釋放,以避免阻塞同步任務處理。而在2.8上會經常會遇到淘汰或過期删除比較大的key時服務會卡頓,是以建議使用者使用4.0以上的服務端,避免此類大key删除問題導緻的卡頓。

Redis 的高并發實戰:搶購系統 --淺奕

5)Redis 5.x 版本的火焰圖

性能分析,如下圖所示:前兩部分是指令處理、中間是“讀取”、最右側“寫”占比61.16%,由此可以看出,性能占比基本上都消耗在網絡IO上。

Redis 的高并發實戰:搶購系統 --淺奕

6)Redis 6.x 版本的線程模型

Redis 6.x 版本改進的模型,可以在主線程,可讀事件觸發之後,把“讀”任務委托在IO線程處理,全讀完之後,傳回結果,再一次處理,然後“寫”也可以分發給IO線程寫,顯而易見可以提升性能。

這種性能提升,運作線程還隻有一個,如果有一些O(1)指令,比如簡單的“讀”、“寫”指令,提升效果非常高。但如果指令本身很複雜,因為DB還是隻有一個運作線程,提升效果會比較差。

還有個問題,把“讀”任務委托之後,需要等傳回,“寫”也需要等傳回,是以主線程有很長時間在等,且這段時間無法提供服務,是以Redis 6.x模型還有提升的空間。

Redis 的高并發實戰:搶購系統 --淺奕

7) 阿裡雲 Redis 企業版(Tair 增強性能)的線程模型

阿裡雲 Redis 企業版模型更進一步,把整個事件拆分開,主線程隻負責指令處理,所有的讀、寫處理由IO線程全權負責,不再是連接配接永遠都屬于主線程。事件出發之後,讀一下而已當用戶端連進來之後,直接交給其他IO線程,從此用戶端可讀、可寫的所有事件,主線程不再關心。

當有指令到達,IO線程會把指令轉發給主線程處理,處理完之後,通過通知方式把處理結果轉給IO線程,由IO線程去寫,最大程度把主線程的等待時間去掉,使性能有更進一步提升。

缺點還是隻有一個線程在處理指令,對于O(1)指令提升效果非常理想,但對于本身比較耗CPU的指令,效果不是很理想。

Redis 的高并發實戰:搶購系統 --淺奕

8)性能對比測試

如下圖所示,左邊灰色是:redis社群5.0.7,右邊橙色是:redis增強型性能,redis6.X的多線程性能在這兩個之間。下圖指令測試的是“讀”指令,本身不是耗CPU,瓶頸在IO上,是以效果非常理想。如果最壞情況下,假設指令本身特别耗CPU,兩個版本會無限逼近,直到齊平。

值得一提的是,redis社群版7的計劃已經出來了,按目前的計劃,redis社群版7會采用類似阿裡雲當下采用的的修改方案,逐漸逼近單個主線程的性能瓶頸。

Redis 的高并發實戰:搶購系統 --淺奕

這裡補充一點,性能隻是一個方面,把連接配接全權交給别的IO的另一個好處是獲得了連接配接數的線性提升能力,可以通過增加IO線程數的方式不斷的提升更大連接配接數的處理能力。阿裡雲的企業版Redis預設就提供數萬的連接配接數能力,更高的比如五六萬的長連接配接也能提工單來支援,以解決使用者業務層機器大量擴容時,連接配接數不夠用的問題。

1)CAS/CAD 高性能分布式鎖

Redis字元串的寫指令有個參數叫NX,意思是字元串不存在時可以寫,是天然的加鎖場景。這樣的特性,加鎖非常容易,value取一個随機值,set的時候帶上NX參數就可以保證原子性。

帶EX是為了業務機器加上鎖之後,如果因為某個原因被下線掉了(或者假死之類),導緻這個鎖沒有正常釋放,就會使得這個鎖永遠無法被解掉。是以需要一個過期時間,保證業務機器故障之後,鎖會被釋放掉。

Redis 的高并發實戰:搶購系統 --淺奕

這裡的參數“5”隻是一個例子,并不一定得是5秒鐘,要看業務機器具體要做的事情來定。

分布式鎖删除的時候比較麻煩,比如機器加上鎖後,突然遇到情況,卡頓或者某種原因失聯了。失聯之後,已經過了5秒,這個鎖已經失效掉了,其他的機器加上鎖了,然後之前那個機器又可用了,但是處理完之後,比如把 Key删掉了,使得删掉了本來并不屬于它的鎖。是以删除需要一個判斷,當 value等于之前寫的value時,才可以删掉。Redis 目前沒有這樣的指令,一般通過Lua來實作。

當 value 和引擎中 value 相等時候删除 Key,可以使用“Compare And Delete”的CAD指令。CAS/CAD 指令以及後續提到的 TairString 以 Module形式開源:

https://github.com/alibaba/TairString

。無論使用者使用哪個Redis版本(需要支援Module機制),都可以直接把Module載入,使用這些API。

續約CAS,當加鎖時我們給過一個過期時間,比如“5秒”,如果業務在這個時間内沒處理完需要有一個機制續約。比如事務沒有執行完,已經過了3秒,那需要把及時把運作時間延長。續約跟删除是一樣的道理,我們不能直接續約,必須當value 和引擎中 value 相等時候續約 ,隻有證明這個鎖被當下線程持有,才能續約,是以這是一個CAS操作。同理,如果沒有 API,需要寫一段Lua,實作對鎖的續約。

其實分布式并不是特别可靠,比如上面講的,盡管加上鎖之後失聯了,鎖被别人持有了,但是突然又可用了,這時代碼上不會判斷這個鎖是不是被當下線程持有,可能會重入。是以Redis分布式鎖,包括其他的分布式鎖并不是100%可靠。

本節總結:

• CAS/CAD 是對 Redis String 的擴充;

• 分布式鎖實作的問題;

• 續約(使用CAS)

• 詳細文檔:

https://help.aliyun.com/document_detail/146758.html

CAS/CAD 以及後續提到的 TairString 以 module 形式開源:

2)CAS/CAD 的 Lua 實作

如果說沒有CAS/CAD指令,需要去寫一段Lua,第一是讀 Key,如果value等于我的value,那麼可以删掉;第二是需續約,value等于我的value,更新一下時間。

需要注意的是,腳本中每次調用會改變的值一定要通過參數傳遞,因為隻要腳本不相同,Redis 就會緩存這個腳本,截止目前社群 6.2 版本仍然沒有限制這個緩存大小的配置,也沒有逐出政策,執行 script flush 指令清理緩存時也是 同步 操作,一定要避免腳本緩存過大(異步删除緩存的能力已經由阿裡雲的工程師添加到社群版本,Redis 6.2開始支援 script flush async)。

使用方式也是先執行 script load 指令加載 Lua 到Redis 中,後續使用 evalsha 指令攜帶參數調用腳本,一來減少網絡帶寬,二來避免每次載入不同的腳本。需要注意的是 evalsha 可能傳回腳本不存在,需要處理這個錯誤,重新 script load 解決。

Redis 的高并發實戰:搶購系統 --淺奕

CAS/CAD 的 Lua 實作還需要注意:

• 其實由于 Redis 本身的資料一緻性保證以及當機恢複能力上看,分布式鎖并不是特别可靠的;

• Redis 作者提出來 Redlock 這個算法,但是争議也頗多:

參考資料1

參考資料2 參考資料3

• 如果對可靠性要求更高的話,可以考慮 Zookeeper 等其他方案(可靠性++, 性能--);

• 或者,使用消息隊列串行化這個需要互斥的操作,當然這個要根據業務系統去設計。

3)Redis LUA

一般來說,不建議在Redis裡面使用LUA,LUA執行需要先解析、翻譯,然後執行整個過程。

第一:因為 Redis LUA,等于是在C裡面調LUA,然後LUA裡面再去調 C,傳回值會有兩次的轉換,先從Redis協定傳回值轉成LUA對象,再由LUA對象轉成 C的資料傳回。

第二:有很多LUA解析,VM處理,包括lua.vm記憶體占用,會比一般的指令時間慢。建議用LUA最好隻寫比較簡單的,比如if判斷。盡量避免循環,盡量避免重的操作,盡量避免大資料通路、擷取。因為引擎隻有一個線程,當CPU被耗在LUA的時候,隻有更少的CPU處理業務指令,是以要慎用。

Redis 的高并發實戰:搶購系統 --淺奕

• “The LUA Iceberg inside Redis”

       腳本的 compile-load-run-unload 非常耗費 CPU,整個 Lua 相當于把複雜事務推送到 Redis 中執行,如果稍有不慎記憶體會爆,引擎算力耗光後挂住Redis。

• “Script + EVALSHA”

可以先把腳本在 Redis 中預編譯和加載(不會 unload 和 clean),使用EVALSHA 執行,會比純 EVAL 省 CPU,但是 Redis重新開機/切換/變配 code cache 會失效,需要reload,仍是缺陷方案。建議使用複雜資料結構,或者 module 來取代 Lua。

• 對于 JIT 技術在存儲引擎中而言,“EVAL is evil”,盡量避免使用 Lua 耗費記憶體和計算資源(省事不省心);

• 某些SDK(如 Redisson)很多進階實作都内置使用 Lua,開發者可能莫名走入 CPU 運算風暴中,須謹慎。

1)搶購/秒殺場景的特點

• 秒殺活動對稀缺或者特價的商品進行定時定量售賣,吸引成大量的消費者進行搶購,但又隻有少部分消費者可以下單成功。是以,秒殺活動将在較短時間内産生比平時大數十倍,上百倍的頁面通路流量和下單請求流量。

• 秒殺活動可以分為 3 個階段:

• 秒殺前:使用者不斷重新整理商品詳情頁,頁面請求達到瞬時峰值;

• 秒殺開始:使用者點選秒殺按鈕,下單請求達到瞬時峰值;

• 秒殺後:少部分成功下單的使用者不斷重新整理訂單或者退單,大部分使用者繼續重新整理商品詳情頁等待機會。

2)搶購/秒殺場景的一般方法

• 搶購/秒殺其實主要解決的就是熱點資料高并發讀寫的問題。

• 搶購/秒殺的過程就是一個不斷對請求 “剪枝” 的過程:

1.盡可能減少使用者到應用服務端的讀寫請求(用戶端攔截一部分);

2.應用到達服務端的請求要減少對後端存儲系統的通路(服務端 LocalCache 攔截一部分);

3.需要請求存儲系統的請求盡可能減少對資料庫的通路(使用 Redis 攔截絕大多數);

4.最終的請求到達資料庫(也可以消息隊列再排個隊兜底,萬一後端存儲系統無響應,應用服務端要有兜底方案)。

• 基本原則

1. 資料少(靜态化、CDN、前端資源合并,頁面動靜分離,LocalCache)盡一切的可能降低頁面對于動态部分的需求,如果前端的整個頁面大部分都是靜态,通過 CDN或者其他機制可以全部擋掉,服務端的請求無論是量,還是位元組數都會少很多。

2. 路徑短(前端到末端的路徑盡可能短、盡量減少對不同系統的依賴,支援限流降級);從使用者這邊發起之後,到最終秒殺的路徑中,依賴的業務系統要少,旁路系統也要競争的少,每一層都要支援限流降級,當被限流被降級之後,對于前端的提示做優化。

3. 禁單點(應用服務無狀态化水準擴充、存儲服務避免熱點)。服務的任何地方都要支援無狀态化水準擴充,對于存儲有那個狀态,避免熱點,一般都是避免一些讀、寫熱點。

• 扣減庫存的時機

1.下單減庫存( 避免惡意下單不付款、保證大并發請求時庫存資料不能為負數 );

2. 付款減庫存( 下單成功付不了款影響體驗 );

3. 預扣庫存逾時釋放( 可以結合 Quartz 等架構做,還要做好安全和反作弊 )。

一般都選擇第三種,多前兩種都有缺陷,第一種很難避免惡意下單不付款,第二種成功的下單了,但是沒法付款,因為沒有庫存。兩個體驗都非常不好,一般都是先預扣庫存,這個單子逾時會把庫存釋放掉。結合電視架構做,同時會做好安全與反作弊機制。

• Redis 的一般實作方案

1. String 結構

• 直接使用incr/decr/incrby/decrby,注意 Redis 目前不支援上下界的限制;

• 如果要避免負數或者有關聯關系的庫存 sku 扣減隻能使用 Lua。

2. List 結構

• 每個商品是一個 List,每個 Node 是一個庫存機關;

• 扣減庫存使用lpop/rpop 指令,直到傳回 nil (key not exist)。

List缺點比較明顯,如:占用的記憶體變大,還有如果一次扣減多個,lpop就要調很多次,對性能非常不好。

3. Set/Hash 結構

• 一般用來去重,限制使用者隻能購買指定個數(hincrby 計數,hget 判斷已購買數量);

• 注意要把使用者 UID 映射到多個 key 來讀寫,一定不能都放到某一個 key 裡(熱點);因為典型的熱點key的讀寫瓶頸,會直接造成業務瓶頸。

4.業務場景允許的情況下,熱點商品可以使用多個 key:key_1,key_2,key_3 ...

• 随機選擇;

• 使用者 UID 做映射(不同的使用者等級也可以設定不同的庫存量)。

3)TairString:支援高并發 CAS 的 String

module裡另一個結構TairString,對 Redis String進行修改,支援高并發 CAS 的 String,攜帶Version 的 String,有Version值,在讀、寫時帶上Version值實作樂觀所,注意這個String對應的資料結構是另一種,不能與 Redis 的普通 String 混用。

Redis 的高并發實戰:搶購系統 --淺奕

TairString的作用,如上圖所示,先給一個exGet值,會傳回(value,version),然後基于 value操作,更新時帶上之前 version,如果一緻,那麼更新,否則重新讀,然後去改再更新,實作CAS操作,在服務端就是樂觀鎖。

對于上述場景進一步優化,提供了exCAS接口,exCAS跟exSet一樣,但遇到version沖突之後,不光傳回version不一緻的錯誤,并且順帶傳回新的value跟新的version。這樣的話,API調用又減少一次,先exSet之後用exCAS進行操作,如果失敗了再“exSet -> exCAS” 減少網絡互動,降低對Redis的通路量。

TairString:支援高并發 CAS 的 String。

• 攜帶 Version 的 String

• 保證并發更新的原子性;

• 通過 Version 來實作更新,樂觀鎖;

• 不能與 Redis 的普通 String 混用。

• 更多的語義

•exIncr/exIncrBy:搶購/秒(有上下界);

• exSet -> exCAS:減少網絡互動。

https://help.aliyun.com/document_detail/147094.html

• 以 Module 形式開源:

4)String 和 exString 原子計數的對比

String方式INCRBY,沒有上下界;exString 方式是EXINCRBY,提供了各種各樣的參數跟上下界,比如直接指定最小是0,當等于0時就不能再減了。另外還支援過期,比如某個商品隻能在某個時間段搶購,過了這個時間點之後讓它失效。業務系統也會做一些限制,緩存可以做限制,過了時間點把這個緩存清理掉。如果庫存數量有限,比如如果沒人購買,商品過10秒鐘消掉;如果有人一直在買,這個緩存一直續期,可以在EXINCRBY裡面帶一個參數,每調用一次 INCRBY或者API就會給它續期,提升命中率。

Redis 的高并發實戰:搶購系統 --淺奕
Redis 的高并發實戰:搶購系統 --淺奕

計數器過期時間可以做什麼?

1 某件商品指定在某個時間段搶購,需要在某個時間後庫存失效。

2. 緩存的庫存如果有限,沒人購買的商品就過期删除,有人購買過就自動再續期一段時間(提升緩存命中率)。

如下圖所示,用Redis String,可以寫上面這段Lua,“get”KEY[1]大于“0”的時候“decrby”減“1”,否則傳回“overflow”錯誤,已經減到“0”不能再減。下面是執行的例子,ex_item設為“3”,然後減、減、減,當比“0”時傳回“overflow”錯誤。

用exString非常簡單,直接exset一個值,然後”exincrby k -1”。 注意 String 和TairString 類型不同,不能混用 API。

Redis 的高并發實戰:搶購系統 --淺奕

繼續閱讀