如何實現超高併發的無鎖快取

2021-08-13 01:04:39 字數 3549 閱讀 8018

【業務場景】

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

例子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的快取結構)來儲存司機的資訊,或者某個型別的計數。

mapmap

【臨界資源】

這個map儲存了所有資訊,當併發讀寫訪問時,它作為臨界資源,在讀寫之前,一般要進行加鎖操作,以司機資訊儲存為例:

void setdriverinfo(long driver_id, driverinfoinfo){

writelock (m_lock);

map= info;

unwritelock(m_lock);

driverinfo getdriverinfo(long driver_id){

driverinfo t;

readlock(m_lock);

t= map;

unreadlock(m_lock);

return t;

【併發鎖瓶頸】

上述實現方案沒有任何問題,但在併發量很大的時候(每秒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]= info;  // 操作第i個map

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

每個map的併發量(變成了1/n)和資料量都降低(變成了1/n)了,所以理論上,鎖衝突會成平方指數降低。

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

假設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)可以通過簽名的方式保證資料的完整性,實現無鎖快取

如何實現超高併發的無鎖快取?

業務場景 有一類寫多讀少的業務場景 大部分請求是對資料進行修改,少部分請求對資料進行讀取。例子1 滴滴打車,某個司機地理位置資訊的變化 可能每幾秒鐘有乙個修改 以及司機地理位置的讀取 使用者打車的時候檢視某個司機的地理位置 void setdriverinfo long driver id,driv...

無鎖佇列的實現

可以用cas 以及fetch等原子操作來實現無鎖的佇列,說是無鎖其實感覺也是有鎖的,只是鎖的力度比較小,能提公升效能 bool cas t addr,t expected,t newvalue else return false 使用陣列來實現佇列是很常見的方法,因為沒有記憶體的分部和釋放,一切都會...

基於ThreadLocal的無鎖併發發號器實現

threadlocal是乙個執行緒級別的變數副本,它是對於執行緒隔離的,各個執行緒之間不能訪問非自己的threadlocal變數。我們先來分析一下乙個優秀的id應該具備哪些特點?為了保證id的全域性唯一,在生成的時候我們應該對其做一些併發安全的處理,不然很可能就會出現重複id,比如說id的序列號是遞...