天天看點

如何實作超高并發的無鎖緩存?

一、需求緣起

【業務場景】

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

例子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

【臨界資源】

這個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會成為潛在瓶頸,在這類高并發環境下寫多讀少的業務倉井,如何來進行優化,是本文将要讨論的問題。

二、水準切分+鎖粒度優化

上文中之是以鎖沖突嚴重,是因為所有司機都公用一把鎖,鎖的粒度太粗(可以認為是一個資料庫的“庫級别鎖”),是否可能進行水準拆分(類似于資料庫裡的分庫),把一個庫鎖變成多個庫鎖,來提高并發,降低鎖沖突呢?顯然是可以的,把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嗎,這顯然是不現實的。

三、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一個鎖,鎖的沖突也可能是很高的,還沒有方法進一步提高并發呢?

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

【無鎖的結果】

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,可以從資料庫中讀取資料。

五、總結

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

1)可以通過水準拆分來降低鎖沖突

2)可以通過Map轉Array的方式來最小化鎖沖突,一條記錄一個鎖

3)可以把鎖去掉,最大化并發,但帶來的資料完整性的破壞

4)可以通過簽名的方式保證資料的完整性,實作無鎖緩存