天天看點

無鎖緩存,每秒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){
         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]); // 解鎖行鎖
}           
無鎖緩存,每秒10萬并發,究竟如何實作?

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

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

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

寫多讀少的業務,有一種優化方案:無鎖緩存,将鎖沖突降低到。

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

如果緩存不加鎖,讀寫吞吐量可以達到極限,但是多線程對緩存中同一塊定長資料進行寫操作時,有可能出現不一緻的髒資料。

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

讀取時,擷取到了錯誤的資料,是不能接受的。

畫外音:作為緩存,允許cache miss,卻不允許讀髒資料。

髒資料是如何産生的?

不加鎖,在多線程并發寫時,可能出現以下情況:

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

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

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

(3)不加鎖,線程1和線程2對同一個定長區域進行一個并發的寫操作,可能每個線程寫成功一半,導緻出現髒資料産生,最終的結果即不是value1也不是value2,而是一個亂七八糟的不符合預期的值value-unexpected;

如何解決上述問題呢?

本質上,這是一個資料完整性問題。

并發寫入的資料分别是value1和value2,讀出的資料是value-unexpected,資料被篡改,這本質上是一個資料完整性的問題。

通常如何保證資料的完整性呢?

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

md5。

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

發送方除了發送消息本身,還要發送消息的簽名,接收方收到消息後要校驗簽名,以確定消息是完整的,未被篡改。

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

加入“簽名”保證資料的完整性之後,讀寫流程需要如何更新?

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

加上簽名之後,不但緩存要寫入定長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)無鎖,最大化并發;

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