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

<meta name="source" content="lake">

簡介: 主要內容: 一、IO 模型和問題 二、資源競爭與分佈式鎖 三、Redis 搶購系統實例

主要內容:

一、IO 模型和問題

二、資源競爭與分佈式鎖

三、Redis 搶購系統實例

一、IO 模型和問題

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 形式開源: https://github.com/alibaba/TairString

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 運算風暴中,須謹慎。

三、Redis 搶購系統實例

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 形式開源:https://github.com/alibaba/TairString

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。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章