無鎖緩存,每秒10萬併發,究竟如何實現?

有一類業務場景:

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

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


這類業務,有什麼實現技巧麼?


接下來,一塊兒聽我從案例入手,娓娓道來。數據庫


快狗打車場景舉例
(1)司機地理位置信息會隨時變化,可能 每幾秒鐘地理位置要修改一次
(2)用戶打車的時候查看某個司機的地理位置, 查詢地理位置的頻率相對較低

這裏要用到兩個接口
(1)大量修改司機信息:
void SetDriverInfo(long driver_id, DriverInfo info);
(2)相對少許查詢司機信息:
DriverInfo GetDriverInfo(long driver_id); 

這一類業務,通常怎麼實現呢?
具體到底層的實現,每每是 一個Map內存緩存
(1)查詢key定長,例如:司機ID;
(2)返回value也定長,例如:司機實體序列化後的二進制串;

即,相似這樣的一個kv緩存結構:
Map<driver_id, DriverInfo>

這個kv內存緩存是一個臨界資源,對它的併發訪問,有什麼注意事項麼?
臨界資源的訪問, 須要注意加讀寫鎖,實施互斥

如下,是加鎖寫入的僞代碼:

void SetDriverInfo(long driver_id, DriverInfo info){緩存

         WriteLock (m_lock);微信

         Map<driver_id>= info;多線程

         UnWriteLock(m_lock);架構

}併發

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

如下,是加鎖讀取的僞代碼:

DriverInfo GetDriverInfo(long driver_id){app

         DriverInfo t;運維

         ReadLock(m_lock);高併發

         t= Map<driver_id>;性能

         UnReadLock(m_lock);

         return t;

}


當吞吐量很高時,上述流程可能存在什麼問題?
假設快狗打車有100w司機同時在線,每一個司機每5秒更新一次經緯度狀態,那麼 每秒就有20w次寫併發操做

假設快狗打車日訂單1000w個,平均每秒大概也有300個下單,對應到查詢併發量,大概 每秒1000級別的併發讀操做

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

有什麼潛在的優化方法麼?

鎖衝突之因此嚴重,是由於整個Map共用一把鎖,鎖的粒度太粗。

畫外音:能夠認爲是一個數據庫的「庫級別鎖」。


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

答案是確定的。

畫外音:相似於數據庫裏的分庫,把一個庫鎖變成多個庫鎖,來提升併發,下降鎖衝突。


咱們能夠把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把鎖

}


如此優化,可否提升性能?
(1)一個Map變成了N個Map, 每一個Map的併發量,變成了1/N
(2)同時, 每一個Map的數據量,變成了1/N
因此理論上,鎖衝突會成平方指數下降,性能會提高。

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

答案也是確定的。

畫外音:能夠認爲是一個數據庫的「庫級別鎖」,優化爲「行級別鎖」。


不妨設driver_id是遞增生成的,而且假設內存比較大,此時能夠把Map優化成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]); // 解鎖行鎖

}


這個方案使得鎖衝突降到了最低,但鎖資源大增,在數據量很是大的狀況下,內存每每是裝不下的。
畫外音:數據量比較小的時候,能夠一個元素一把鎖,典型的是鏈接池,每一個鏈接用一把鎖表示鏈接是否可用。

尚未方法進一步下降鎖衝突,提高併發量呢?

寫多讀少的業務,有一種優化方案無鎖緩存,將鎖衝突下降到。


無鎖緩存,可能存在什麼問題?
若是緩存不加鎖,讀寫吞吐量能夠達到極限,可是多線程對緩存中同一塊定長數據進行寫操做時, 有可能出現不一致的髒數據

這個方案爲了提升性能,犧牲了一致性。

讀取時, 獲取到了錯誤的數據,是不能接受的
畫外音:做爲緩存,容許 cache miss ,卻不容許讀髒數據。

髒數據是如何產生的?
不加鎖,在多線程併發寫時,可能出現如下狀況:

(1)線程1對緩存進行操做,對 key 想要寫入 value1
2)線程2對緩存進行操做,對 key 想要寫入 value2
3)不加鎖,線程1和線程2對同一個定長區域進行一個併發的寫操做, 可能每一個線程寫成功一半,致使出現髒數據產生 ,最終的結果即不是 value1 也不是 value2 ,而是一個亂七八糟的不符合預期的值 value-unexpected

如何解決上述問題呢?
本質上,這是一個數據完整性問題。

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

一般如何保證數據的完整性呢?

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

又例如:即時通信系統中,如何保證接受方收到的消息,就是發送方發送的消息?
發送方除了發送消息自己,還要發送消息的簽名 ,接收方收到消息後要校驗簽名,以確保消息是完整的,未被篡改。

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

加入「簽名」保證數據的完整性以後,讀寫流程須要如何升級?

加上簽名以後, 不但緩存要寫入定長value自己,還要寫入定長簽名 (例如 16bitCRC 校驗):
(1)線程1對緩存進行操做,對 key 想要寫入 value1 ,寫入簽名 v1-sign
2)線程2對緩存進行操做,對 key 想要寫入 value2 ,寫入簽名 v2-sign
3)若是不加鎖,線程1和線程2對同一個定長區域進行一個併發的寫操做,可能每一個線程寫成功一半,致使出現髒數據產生,最終的結果即不是 value1 也不是 value2 ,而是一個亂七八糟的不符合預期的值 value-unexpected 但簽名,必定是v1-sign或者v2-sign中的任意一個
畫外音:16bit/32bit的寫能夠保證原子性。
4)數據讀取的時候,不但要取出 value ,還要像消息接收方收到消息同樣,校驗一下簽名,若是發現簽名不一致,緩存則返回 NULL ,即 cache miss

固然,對應到司機地理位置,除了內存緩存以前,確定須要timer對緩存中的數據按期落盤,寫入數據庫,若是cache miss,能夠從數據庫中讀取數據。

巧不巧秒?

總結

當業務知足:
(1)超高併發
(2)寫多讀少
(3)定長value
時,能夠用如下方法來提高吞吐量:
(1)水平拆分來下降鎖衝突;
思路 單庫變多庫。

2)Map轉Array的方式來最小化鎖衝突,一條記錄一個鎖;
思路 庫鎖變行鎖。

3)無鎖,最大化併發;
思路 行鎖變無鎖,完整性與性能的折衷。

4)經過簽名的方式保證數據的完整性,實現無鎖緩存;
思路 寫時寫簽名,讀時校驗簽名。

若是你喜歡本文,大機率會喜歡這個架構訓練營,歡迎一塊兒來玩。
掃碼,一塊兒玩架構,學別處沒有的知識

思路比結論重要 ,但願你們有收穫,謝
閱讀原文 ,更多幹貨。

本文分享自微信公衆號 - 架構師之路(road5858)。
若有侵權,請聯繫 support@oschina.cn 刪除。
本文參與「OSC源創計劃」,歡迎正在閱讀的你也加入,一塊兒分享。

相關文章
相關標籤/搜索