如何實現超高併發的無鎖緩存?

1、需求緣起

【業務場景】數據庫

有一類寫多讀少的業務場景:大部分請求是對數據進行修改,少部分請求對數據進行讀取。緩存

例子1:滴滴打車,某個司機地理位置信息的變化(可能每幾秒鐘有一個修改),以及司機地理位置的讀取(用戶打車的時候查看某個司機的地理位置)。微信

void SetDriverInfo(long driver_id, DriverInfoi); // 大量請求調用修改司機信息,可能主要是GPS位置的修改多線程

DriverInfo GetDriverInfo(long driver_id);  // 少許請求查詢司機信息架構

 

例子2:統計計數的變化,某個url的訪問次數,用戶某個行爲的反做弊計數(計數值在不停的變)以及讀取(只有少數時刻會讀取這類數據)。併發

void AddCountByType(long type); // 大量增長某個類型的計數,修改比較頻繁運維

long GetCountByType(long type); // 少許返回某個類型的計數高併發

 

【底層實現】性能

具體到底層的實現,每每是一個Map(本質是一個定長key,定長value的緩存結構)來存儲司機的信息,或者某個類型的計數。優化

Map<driver_id, DriverInfo>

Map<type, count>

 

【臨界資源】

這個Map存儲了全部信息,當併發讀寫訪問時,它做爲臨界資源,在讀寫以前,通常要進行加鎖操做,以司機信息存儲爲例:

void SetDriverInfo(long driver_id, DriverInfoinfo){

         WriteLock (m_lock);

         Map<driver_id>= info;

         UnWriteLock(m_lock);

}

 

DriverInfo GetDriverInfo(long driver_id){

         DriverInfo t;

         ReadLock(m_lock);

         t= Map<driver_id>;

         UnReadLock(m_lock);

         return t;

}

 

【併發鎖瓶頸】

假設滴滴有100w司機同時在線,每一個司機沒5秒更新一次經緯度狀態,那麼每秒就有20w次寫併發操做。假設滴滴日訂單1000w個,平均每秒大概也有300個下單,對應到查詢併發量,多是1000級別的併發讀操做。

上述實現方案沒有任何問題,但在併發量很大的時候(每秒20w寫,1k讀),鎖m_lock會成爲潛在瓶頸,在這類高併發環境下寫多讀少的業務倉井,如何來進行優化,是本文將要討論的問題。

 

2、水平切分+鎖粒度優化

上文中之因此鎖衝突嚴重,是由於全部司機都公用一把鎖,鎖的粒度太粗(能夠認爲是一個數據庫的「庫級別鎖」),是否可能進行水平拆分(相似於數據庫裏的分庫),把一個庫鎖變成多個庫鎖,來提升併發,下降鎖衝突呢?顯然是能夠的,把1個Map水平切分紅多個Map便可:

void SetDriverInfo(long driver_id, DriverInfoinfo){

         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把鎖

}

 

每一個Map的併發量(變成了1/N)和數據量都下降(變成了1/N)了,因此理論上,鎖衝突會成平方指數下降。

分庫以後,仍然是庫鎖,有沒有辦法變成數據庫層面所謂的「行級鎖」呢,難道要把x條記錄變成x個Map嗎,這顯然是不現實的。

 

3、MAP變Array+最細鎖粒度優化

假設driver_id是遞增生成的,而且緩存的內存比較大,是能夠把Map優化成Array,而不是拆分紅N個Map,是有可能把鎖的粒度細化到最細的(每一個記錄一個鎖)。

void SetDriverInfo(long driver_id, DriverInfoinfo){

         index= driver_id;

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

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

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

}


和上一個方案相比,這個方案使得鎖衝突降到了最低,但鎖資源大增,在數據量很是大的狀況下,通常不這麼搞。數據量比較小的時候,能夠一個元素一個鎖的(典型的是鏈接池,每一個鏈接有一個鎖表示鏈接是否可用)。

 

上文中提到的另外一個例子,用戶操做類型計數,操做類型是有限的,即便一個type一個鎖,鎖的衝突也多是很高的,尚未方法進一步提升併發呢?

 

4、把鎖去掉,變成無鎖緩存

【無鎖的結果】

void AddCountByType(long type /*, int count*/){

         //不加鎖

         Array[type]++; // 計數++

         //Array[type] += count; // 計數增長count

}


若是這個緩存不加鎖,固然能夠達到最高的併發,可是多線程對緩存中同一塊定長數據進行操做時,有可能出現不一致的數據塊,這個方案爲了提升性能,犧牲了一致性。在讀取計數時,獲取到了錯誤的數據,是不能接受的(做爲緩存,容許cache miss,卻不容許讀髒數據)。

 

【髒數據是如何產生的】

這個併發寫的髒數據是如何產生的呢,詳見下圖:


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

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

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

 

【數據完整性問題】

併發寫入的數據分別是value1和value2,讀出的數據是value-unexpected,數據的篡改,這本質上是一個數據完整性的問題。一般如何保證數據的完整性呢?

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

回答:md5

 

例子2:即時通信系統中,如何保證接受方收到的消息,就是發送方發送的消息?

回答:發送方除了發送消息自己,還要發送消息的簽名,接收方收到消息後要校驗簽名,以確保消息是完整的,未被篡改。

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

 

【加上簽名以後的流程】

加上簽名以後,不但緩存要寫入定長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中的任意一個 

4)數據讀取的時候,不但要取出value,還要像消息接收方收到消息同樣,校驗一下簽名,若是發現簽名不一致,緩存則返回NULL,即cache miss

 

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

 

5、總結

在【超高併發】,【寫多讀少】,【定長value】的【業務緩存】場景下:

1)能夠經過水平拆分來下降鎖衝突

2)能夠經過Map轉Array的方式來最小化鎖衝突,一條記錄一個鎖

3)能夠把鎖去掉,最大化併發,但帶來的數據完整性的破壞

4)能夠經過簽名的方式保證數據的完整性,實現無鎖緩存

 

以上內容均來自微信公衆號「架構師之路」胡劍老師的文章,歡迎關注。

相關文章
相關標籤/搜索