無鎖緩存,每秒 10 萬并發(fā),究竟如何實現(xiàn)?

> 原文地址 (https://mp.weixin.qq.com/s/BfuRWaB7RDjpGmQbZdmMZw)

有一類業(yè)務場景:

(1)超高吞吐量,每秒要處理海量請求;

(2)寫多讀少,大部分請求是對數據進行修改,少部分請求對數據進行讀取;

這類業(yè)務,有什么實現(xiàn)技巧么?

接下來,一起聽我從案例入手,娓娓道來。

快狗打車,場景舉例

(1)司機地理位置信息會隨時變化,可能每幾秒鐘地理位置要修改一次;

(2)用戶打車的時候查看某個司機的地理位置,查詢地理位置的頻率相對較低;

這里要用到兩個接口

(1)大量修改司機信息:

void SetDriverInfo(long driver_id, DriverInfo info);

(2)相對少量查詢司機信息:

DriverInfo GetDriverInfo(long driver_id);

這一類業(yè)務,一般怎么實現(xiàn)呢?

具體到底層的實現(xiàn),往往是一個 Map 內存緩存:

(1)查詢 key 定長,例如:司機 ID;

(2)返回 value 也定長,例如:司機實體序列化后的二進制串;

即,類似這樣的一個 kv 緩存結構:

Map<driver_id, DriverInfo>

這個 kv 內存緩存是一個臨界資源,對它的并發(fā)訪問,有什么注意事項么?

臨界資源的訪問,需要注意加讀寫鎖,實施互斥。

以下,是加鎖寫入的偽代碼:

void SetDriverInfo(long driver_id, DriverInfo info){

WriteLock (m_lock);

Map<driver_id>= info;

UnWriteLock(m_lock);

}

畫外音:假設 info 已經序列化。

以下,是加鎖讀取的偽代碼:

DriverInfo GetDriverInfo(long driver_id){

DriverInfo t;

ReadLock(m_lock);

t= Map<driver_id>;

UnReadLock(m_lock);

return t;

}

當吞吐量很高時,上述流程可能存在什么問題?

假設快狗打車有 100w 司機同時在線,每個司機每 5 秒更新一次經緯度狀態(tài),那么每秒就有 20w 次寫并發(fā)操作。

假設快狗打車日訂單 1000w 個,平均每秒大概也有 300 個下單,對應到查詢并發(fā)量,大概每秒 1000 級別的并發(fā)讀操作。

在這樣的吞吐量下(每秒 20w 寫,1k 讀),鎖 m_lock 會成為潛在瓶頸,導致 Map 訪問效率極低。

有什么潛在的優(yōu)化方法么?

鎖沖突之所以嚴重,是因為整個 Map 共用一把鎖,鎖的粒度太粗。

畫外音:可以認為是一個數據庫的 “庫級別鎖”。

是否可能進行水平拆分,來降低鎖沖突呢?

答案是肯定的。

畫外音:類似于數據庫里的分庫,把一個庫鎖變成多個庫鎖,來提高并發(fā),降低鎖沖突。

我們可以把 1 個 Map 水平切分成 N 個 Map:


void SetDriverInfo(long driver_id, DriverInfo info){

i = driver_id % N; // 水平拆分成 N 份,N 個 Map,N 個鎖

WriteLock (m_lock[i]); // 鎖第 i 把鎖

Map[i]<driver_id>= info; // 操作第 i 個 Map

UnWriteLock (m_lock[i]); // 解鎖第 i 把鎖

}

如此優(yōu)化,能否提高性能?

(1)一個 Map 變成了 N 個 Map,每個 Map 的并發(fā)量,變成了 1/N;

(2)同時,每個 Map 的數據量,變成了 1/N;

所以理論上,鎖沖突會成平方指數降低,性能會提升。

有沒有可能,進一步細化鎖粒度,一個元素一把鎖呢?

答案也是肯定的。

畫外音:可以認為是一個數據庫的 “庫級別鎖”,優(yōu)化為 “行級別鎖”。

不妨設 driver_id 是遞增生成的,并且假設內存比較大,此時可以把 Map 優(yōu)化成 Array,并把鎖的粒度細化到最細的,每個司機信息一個鎖:

void SetDriverInfo(long driver_id, DriverInfo info){

index = driver_id;

WriteLock (m_lock[index]); // 超級大內存,一條記錄一個鎖,鎖行鎖

Array[index]= info; //driver_id 就是 Array 下標

UnWriteLock (m_lock[index]); // 解鎖行鎖

}

image

這個方案使得鎖沖突降到了最低,但鎖資源大增,在數據量非常大的情況下,內存往往是裝不下的。

畫外音:數據量比較小的時候,可以一個元素一把鎖,典型的是連接池,每個連接用一把鎖表示連接是否可用。

還沒有方法進一步降低鎖沖突,提升并發(fā)量呢?

寫多讀少的業(yè)務,有一種優(yōu)化方案:無鎖緩存,將鎖沖突降低到。

無鎖緩存,可能存在什么問題?

如果緩存不加鎖,讀寫吞吐量可以達到極限,但是多線程對緩存中同一塊定長數據進行寫操作時,有可能出現(xiàn)不一致的臟數據。

這個方案為了提高性能,犧牲了一致性。

讀取時,獲取到了錯誤的數據,是不能接受的。

畫外音:作為緩存,允許 cache miss,卻不允許讀臟數據。

臟數據是如何產生的?

不加鎖,在多線程并發(fā)寫時,可能出現(xiàn)以下情況:

image

(1)線程 1 對緩存進行操作,對 key 想要寫入 value1;

(2)線程 2 對緩存進行操作,對 key 想要寫入 value2;

(3)不加鎖,線程 1 和線程 2 對同一個定長區(qū)域進行一個并發(fā)的寫操作,可能每個線程寫成功一半,導致出現(xiàn)臟數據產生,最終的結果即不是 value1 也不是 value2,而是一個亂七八糟的不符合預期的值 value-unexpected;

如何解決上述問題呢?

本質上,這是一個數據完整性問題。

并發(fā)寫入的數據分別是 value1 和 value2,讀出的數據是 value-unexpected,數據被篡改,這本質上是一個數據完整性的問題。

通常如何保證數據的完整性呢?

例如:運維如何保證,從中控機分發(fā)到上線機上的二進制沒有被篡改?

md5。

又例如:即時通訊系統(tǒng)中,如何保證接受方收到的消息,就是發(fā)送方發(fā)送的消息?

發(fā)送方除了發(fā)送消息本身,還要發(fā)送消息的簽名,接收方收到消息后要校驗簽名,以確保消息是完整的,未被篡改。

“簽名” 是一種常見的保證數據完整性的方案。

加入 “簽名” 保證數據的完整性之后,讀寫流程需要如何升級?

image

加上簽名之后,不但緩存要寫入定長 value 本身,還要寫入定長簽名(例如 16bitCRC 校驗):

(1)線程 1 對緩存進行操作,對 key 想要寫入 value1,寫入簽名 v1-sign;

(2)線程 2 對緩存進行操作,對 key 想要寫入 value2,寫入簽名 v2-sign;

(3)如果不加鎖,線程 1 和線程 2 對同一個定長區(qū)域進行一個并發(fā)的寫操作,可能每個線程寫成功一半,導致出現(xiàn)臟數據產生,最終的結果即不是 value1 也不是 value2,而是一個亂七八糟的不符合預期的值 value-unexpected,但簽名,一定是 v1-sign 或者 v2-sign 中的任意一個;

畫外音:16bit/32bit 的寫可以保證原子性。

(4)數據讀取的時候,不但要取出 value,還要像消息接收方收到消息一樣,校驗一下簽名,如果發(fā)現(xiàn)簽名不一致,緩存則返回 NULL,即 cache miss;

當然,對應到司機地理位置,除了內存緩存之前,肯定需要 timer 對緩存中的數據定期落盤,寫入數據庫,如果 cache miss,可以從數據庫中讀取數據。

巧不巧秒?

總結

當業(yè)務滿足:

(1)超高并發(fā);

(2)寫多讀少

(3)定長 value;

時,可以用以下方法來提升吞吐量:

(1)水平拆分來降低鎖沖突;

思路:單庫變多庫。

(2)Map 轉 Array 的方式來最小化鎖沖突,一條記錄一個鎖;

思路:庫鎖變行鎖。

(3)無鎖,最大化并發(fā);

思路:行鎖變無鎖,完整性與性能的折衷。

(4)通過簽名的方式保證數據的完整性,實現(xiàn)無鎖緩存;

思路:寫時寫簽名,讀時校驗簽名。

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
【社區(qū)內容提示】社區(qū)部分內容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

友情鏈接更多精彩內容