C 高併發場景下讀多寫少的優化方案

2022-09-08 16:27:20 字數 3370 閱讀 3064

一談到高併發的優化方案,往往能想到模組水平拆分、資料庫讀寫分離、分庫分表,加快取、加mq等,這些都是從系統架構上解決。單模組作為系統的組成單元,其效能好壞也能很大的影響整體效能,本文從單模組下讀多寫少的場景出發,**其解決方案,以其更好的實現高併發。

不同的業務場景,讀和寫的頻率各有側重,有兩種常見的業務場景:

讀多寫少:典型場景如廣告檢索端、白名單更新維護、loadbalancer

讀少寫多:典型場景如qps統計

本文針對讀多寫少(也稱一寫多讀)場景下遇到的問題進行分析,並**一種合適的解決方案。

讀多寫少的場景,服務大部分情況下都是處於讀,而且要求讀的耗時不能太長,一般是毫秒或者更低的級別;更新的頻率就不是那麼頻繁,如幾秒鐘更新一次。通過簡單的加互斥鎖,騰出一片臨界區,往往能到達預期的效果,保證資料更新正確。

但是,只要加了鎖,就會帶來競爭,即使加的是讀寫鎖,雖然讀之間不互斥,但寫一樣會影響讀,而且讀寫同時爭奪鎖的時候,鎖優先分配給寫(讀寫鎖的特性)。例如,寫的時候,要求所有的讀請求阻塞住,等到寫執行緒或協程釋放鎖之後才能讀。如果寫的臨界區耗時比較大,則所有的讀請求都會受影響,從監控圖上看,這時候會有一根很尖的耗時毛刺,所有的讀請求都在佇列中等待處理,如果在下個更新週期來之前,服務能處理完這些讀請求,可能情況沒那麼糟糕。但極端情況下,如果下個更新週期來了,讀請求還沒處理完,就會形成乙個惡性迴圈,不斷的有讀請求在佇列中等待,最終導致佇列被擠滿,服務出現假死,情況再惡劣一點的話,上游服務發現某個節點假死後,由於負載均衡策略,一般會重試請求其他節點,這時候其他節點的壓力跟著增加了,最終導致整個系統出現雪崩。

因此,加鎖在高併發場景下要盡量避免,如果避免不了,需要讓鎖的粒度盡量小,接近無鎖(lock-free)更好,簡單的對一大片臨界區加鎖,在高併發場景下不是一種合適的解決方案

有一種資料結構叫雙緩衝,其這種資料結構很常見,例如顯示屏的顯示原理,顯示屏顯示的當前幀,下一幀已經在後台的buffer準備好,等時間週期一到,就直接替換前台幀,這樣能做到無卡頓的重新整理,其實現的指導思想是空間換時間,這種資料結構的工作原理如下:

資料分為前台和後台

所有讀執行緒讀前台資料,不用加鎖,通過乙個指標來指向當前讀的前台資料

只有乙個執行緒負責更新,更新的時候,先準備好後台資料,接著直接切指標,這之後所有新進來的讀請求都看到了新的前台資料

有部分讀還落在老的前台那裡處理,因為更新還不算完成,也就不能退出寫執行緒,寫執行緒需要等待所有落在老前台的執行緒讀完成後,才能退出,在退出之前,順便再更新一遍老前台資料(也就當前的新後台),可以保證前後臺資料一致,這點在做增量更新的時候有用

工程實現上需要攻克的難點

寫執行緒要怎麼知道所有的讀執行緒在老前台中的讀完成了呢?

一種做法是讓各個讀執行緒都維護一把鎖,讀的時候鎖住,這時候不會影響其他執行緒的讀,但會影響寫,讀完後釋放鎖(某些時候可能會有通知寫執行緒的開銷,但由於寫的頻率很低,所以這點開銷還能接受),寫執行緒只需要確認鎖有沒有釋放了,確認完了後馬上釋放,確認這個動作非常快(小於25ns,1s=1000ms=1000000us=1000000000ns),讀執行緒幾乎不會感覺到鎖的存在。

每個執行緒都有一把自己的鎖,需要用全域性的map來做執行緒id和鎖的對映嗎?

不需要,而且這樣做全域性map就要加全域性鎖了,又回到了剛開始分析中遇到的問題了。其實,每個執行緒可以有私有儲存(thread local storage,簡稱tls),如果是協程,就對應這協程的tls(但對於go語言,官方是不支援tls的,想實現類似功能,要麼就想辦法獲取到tls,要麼就不要基於協程鎖,而是用全域性鎖,但盡量讓鎖粒度小,本文主要針對c++語言,暫時不深入討論其他語言的實現)。這樣每個讀執行緒鎖的是自己的鎖,不會影響到其他的讀執行緒,鎖的目的僅僅是為了保證讀優先。

對於執行緒私有儲存,可以使用pthread_key_create, pthread_setspecific,pthread_getspecific系列函式

核心**實現

}

template template size_t doublybuffereddata::modify(fn& fn) 

// 切指標

_index.store(bg_index, butil::memory_order_release);

bg_index = !bg_index;

// 等所有讀老前台的執行緒讀結束

}// 確認沒有讀了,直接修改新後台資料,對其新前台

const size_t ret2 = fn(_data[bg_index]);

return ret2;

}

完整實現請參考brpc的doublybufferdata

基於計數器的實現

基於計數器,用atomic,保證原子性,讀進入臨界區,計數器+1,退出-1,寫判斷計數器為0則切換(但在計數器為0和切換中間,不能保證沒有新的讀進來,這時候也要鎖),而且計數器是全域性鎖。這種方案c++也可以採取,只是計數器畢竟也是全域性鎖,效能會差那麼一丟丟。即使用智慧型指標shared_ptr,也會面臨切換之前計數器有變成非0的問題。之所以用計數器,而不用tls,是因為有些語言,如golang,不支援tls,對比tls版本和計數器版本,tls效能更優,因為沒有搶計數器的互斥問題,大概耗費700ns,很多嗎?搶計數器本身很快,效能沒測試過,可以試試。

golang中sync.map的實現

也是基於計數器,只是計數器是為了讓讀前台快取失效的概率不要太高,有抑制和收斂的作用,實現了讀的無鎖,少部分情況下,前台快取讀不到資料的時候,會去讀後台快取,這時候也要加鎖,同時計數器+1。計數器數值達到一定程度(超過後台快取的元素個數),就執行切換

不合適,雙緩衝優先保證讀的效能,寫多讀少的場景需要優先保證寫的效能。

brpc對於雙buffer的描述:

go實現的雙buffer(但讀是互斥的,效能先對較差):

雙buffer的三種實現方案:

一寫多讀:

高併發下的系統設計:

基於shared_ptr的實現,原理也是計數器,但感覺還是有缺陷:

高併發場景下的限流策略

目錄快取 降級 限流 漏桶演算法 令牌桶演算法 漏桶演算法與令牌桶演算法的區別 有效提公升熱點資料的訪問效率,在高併發 大流量的場景降低服務端壓力。當訪問量快速增長 服務可能會出現一些問題 響應超時 或者會存在非核心服務影響到核心流程的效能時,仍然需要保證服務的可用性,即便是有損服務。所以意味著我們...

高併發場景下的請求合併

一.在專案中,我們經常用到如下方式進行介面呼叫 有多少請求訪問,就會呼叫多少次第三方介面或資料庫,這樣的情況在高併發場景下很容易出現執行緒被打滿,返回結果慢。為了優化這個介面,後台可以將相同的請求進行合併,然後呼叫批量的查詢介面。請求合併 下面上 已查詢資料庫舉例 1.建立請求類 data buil...

高併發場景下ArrayList的執行緒安全問題

arraylist是我們常用的資料結構,在併發場景下是執行緒不安全的。在讀多寫少的場景下,我們一般會用讀寫鎖 readwritelock來保證共享物件的執行緒安全性。public object read public void write 這裡能解決部分問題。但是還是存在當有乙個執行緒在write的...