【業務場景】數據庫
有一類寫多讀少的業務場景:大部分請求是對數據進行修改,少部分請求對數據進行讀取。緩存
例子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會成爲潛在瓶頸,在這類高併發環境下寫多讀少的業務倉井,如何來進行優化,是本文將要討論的問題。
上文中之因此鎖衝突嚴重,是由於全部司機都公用一把鎖,鎖的粒度太粗(能夠認爲是一個數據庫的「庫級別鎖」),是否可能進行水平拆分(相似於數據庫裏的分庫),把一個庫鎖變成多個庫鎖,來提升併發,下降鎖衝突呢?顯然是能夠的,把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嗎,這顯然是不現實的。
假設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)能夠經過簽名的方式保證數據的完整性,實現無鎖緩存
以上內容均來自微信公衆號「架構師之路」胡劍老師的文章,歡迎關注。