主要内容:
一、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等拖慢了一次查詢,那麼後面的請求就會被拖慢。

使用 Sentinel 判活的缺陷:
• ping 指令判活:ping 指令同樣受到慢查詢影響,如果引擎被卡住,則 ping 失敗;
• duplex Failure:sentinel 由于慢查詢切備(備變主)再遇到慢查詢則無法繼續工作。
2)Make it a cluster
用多個分片組成一個cluster的時候,也是同樣的問題。如果其中的某一個分片被慢查詢拖慢,比如使用者調用了跨分片的指令,如mget,通路到出問題的分片,仍會卡住,會導緻後續所有指令被阻塞。
1. 同樣的,叢集版解決不了單個 DB 被卡住的問題;
2. 查詢空洞:如果使用者調用了跨分片的指令,如mget,通路到出問題的分片,仍會卡住。
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 協定不支援連接配接收斂
• 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删除問題導緻的卡頓。
5)Redis 5.x 版本的火焰圖
性能分析,如下圖所示:前兩部分是指令處理、中間是“讀取”、最右側“寫”占比61.16%,由此可以看出,性能占比基本上都消耗在網絡IO上。
6)Redis 6.x 版本的線程模型
Redis 6.x 版本改進的模型,可以在主線程,可讀事件觸發之後,把“讀”任務委托在IO線程處理,全讀完之後,傳回結果,再一次處理,然後“寫”也可以分發給IO線程寫,顯而易見可以提升性能。
這種性能提升,運作線程還隻有一個,如果有一些O(1)指令,比如簡單的“讀”、“寫”指令,提升效果非常高。但如果指令本身很複雜,因為DB還是隻有一個運作線程,提升效果會比較差。
還有個問題,把“讀”任務委托之後,需要等傳回,“寫”也需要等傳回,是以主線程有很長時間在等,且這段時間無法提供服務,是以Redis 6.x模型還有提升的空間。
7) 阿裡雲 Redis 企業版(Tair 增強性能)的線程模型
阿裡雲 Redis 企業版模型更進一步,把整個事件拆分開,主線程隻負責指令處理,所有的讀、寫處理由IO線程全權負責,不再是連接配接永遠都屬于主線程。事件出發之後,讀一下而已當用戶端連進來之後,直接交給其他IO線程,從此用戶端可讀、可寫的所有事件,主線程不再關心。
當有指令到達,IO線程會把指令轉發給主線程處理,處理完之後,通過通知方式把處理結果轉給IO線程,由IO線程去寫,最大程度把主線程的等待時間去掉,使性能有更進一步提升。
缺點還是隻有一個線程在處理指令,對于O(1)指令提升效果非常理想,但對于本身比較耗CPU的指令,效果不是很理想。
8)性能對比測試
如下圖所示,左邊灰色是:redis社群5.0.7,右邊橙色是:redis增強型性能,redis6.X的多線程性能在這兩個之間。下圖指令測試的是“讀”指令,本身不是耗CPU,瓶頸在IO上,是以效果非常理想。如果最壞情況下,假設指令本身特别耗CPU,兩個版本會無限逼近,直到齊平。
值得一提的是,redis社群版7的計劃已經出來了,按目前的計劃,redis社群版7會采用類似阿裡雲當下采用的的修改方案,逐漸逼近單個主線程的性能瓶頸。
這裡補充一點,性能隻是一個方面,把連接配接全權交給别的IO的另一個好處是獲得了連接配接數的線性提升能力,可以通過增加IO線程數的方式不斷的提升更大連接配接數的處理能力。阿裡雲的企業版Redis預設就提供數萬的連接配接數能力,更高的比如五六萬的長連接配接也能提工單來支援,以解決使用者業務層機器大量擴容時,連接配接數不夠用的問題。
1)CAS/CAD 高性能分布式鎖
Redis字元串的寫指令有個參數叫NX,意思是字元串不存在時可以寫,是天然的加鎖場景。這樣的特性,加鎖非常容易,value取一個随機值,set的時候帶上NX參數就可以保證原子性。
帶EX是為了業務機器加上鎖之後,如果因為某個原因被下線掉了(或者假死之類),導緻這個鎖沒有正常釋放,就會使得這個鎖永遠無法被解掉。是以需要一個過期時間,保證業務機器故障之後,鎖會被釋放掉。
這裡的參數“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 解決。
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處理業務指令,是以要慎用。
• “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 混用。
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就會給它續期,提升命中率。
計數器過期時間可以做什麼?
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。