你好呀,我是歪歪。
這次來盤個小夥伴分享給我的一個面試題,他說面試的過程中面試官的問了一個比較開放的問題:
請談談你對於請求合併和分治的看法。
他覺得自己沒有答的特別好,主要是沒找到合適的角度來答題,跑來問我怎麼看。
我能怎麼看?
我也不知道面試官想問啥角度啊。但是這種開放題,只要回答的不太離譜,應該都能混得過去。
比如,如果你回答一個:我認為合併和分治,這二者之間是辯證的關係,得用辯證的眼光看問題,它們是你中有我,我中有你~
那涼了,拿著簡歷回家吧。
我也想了一下,如果讓我來回答這個問題,我就用這篇文章來回答一下。
有廣義上的實際應用場景,也有狹義上的原始碼體現對應的思想。
讓面試官自己挑吧。
首先回答之前肯定不能幹聊,所以我們鋪墊一下,先帶入一個場景:熱點賬戶。
什麼是熱點賬戶呢?
在第三方支付系統或者銀行這類交易機構中,每產生一筆轉入或者轉出的交易,就需要對交易涉及的賬戶進行記賬操作。
記賬粗略的來說涉及到兩個部分。
如果對於某個賬戶操作非常的頻繁,那麼當我們對賬戶餘額進行操作的時候,肯定就會涉及到並行處理的問題。
並行了怎麼辦?
我們可以對賬戶進行加鎖處理嘛。但是這樣一來,這個賬戶就涉及到頻繁的加鎖解鎖操作。
雖然這樣我們可以保證資料不出問題,但是隨之帶來的問題是隨著並行的提高,賬戶系統效能下降。
極少數的賬戶在短時間內出現了極大量的餘額更新請求,這類賬戶就是熱點賬戶,就是效能瓶頸點。
熱點賬戶是業界的一個非常常見的問題。
而且根據熱點賬戶的特性,也可以分為不同的型別。
如果餘額的變動是在頻繁的增加,比如頭部主播帶貨,只要一喊 321,上連結,那訂單就排山倒海的來了,錢就一筆筆的打到賬戶裡面去了。這種賬戶,就是非常多的人在給這個賬戶打款,頻率非常高,賬戶餘額一直在增加。
這種賬戶叫做「加餘額頻繁的熱點賬戶」。
如果餘額的變動是在頻繁的減少,比如常見的某流量平臺廣告位曝光,這種屬於扣費場景。
商家先充值一筆錢到平臺上,然後平臺給商家一頓咔咔曝光,接著賬戶上的錢就像是流水一樣嘩啦啦啦的就沒了。
這種預充值,然後再扣減頻繁的賬戶,這種賬戶叫做「減餘額頻繁的熱點賬戶」。
還有一種,是加餘額,減餘額都很頻繁的賬戶。
你細細的嗦一下,什麼賬戶一邊加一遍減呢,怎麼感覺像是個二手販子在左手倒右手呢?
這種賬戶一般不能細琢磨,琢磨多了,就有點灰色地帶了,點到為止。
針對「加餘額頻繁的熱點賬戶」我們就可以採取請求合併的思路。
假設有個歪師傅是個正經的帶貨主播,在直播間穿著女裝賣女裝,我只要喊「321,上連結」姐妹們就開始衝了。
隨著歪師傅生意越來越好,有的姐妹們就反饋下單越來越慢。
後來一分析,哦,原來是更新賬戶餘額那個地方是個效能瓶頸,大量的單子都在這裡排著隊,等著更新餘額。
怎麼辦呢?
針對這種情況,我們就可以把多筆調整賬務餘額的請求合併成一筆處理。
當記錄進入緩衝流水記錄表之後,我就可以告知姐妹下單成功了,雖然錢還沒有真的到我的賬戶中來,但是你放心,有定時任務保證,錢一會就到賬。
所以當姐妹們下單之後,我們只是先記錄資料,並不去實際動賬戶。等著定時任務去觸發記賬,進行多筆合併一筆的操作。
比如下面的這個示意圖:
對於歪師傅來說,實際有 6 個姐妹的支付記錄,但是隻有一次餘額調整。
而我拿著這一次餘額調整的賬戶流水,也是可以追溯到這 6 筆交易記錄的詳情。
這樣的好處是吞吐量上來了,使用者體驗也好了。但是帶來的弊端是餘額並不是一個準確的值。
假設我們的定時任務是一小時彙總一次,那麼歪師傅在後端看到的交易金額可能是一小時之前的資料。
但是歪師傅覺得沒關係,總比姐妹們下不了單強。
如果我們把緩衝流水記錄表看作是一個佇列。那麼這個方案抽象出來就是佇列加上定時任務。
所以,請求合併的關鍵點也是佇列加上定時任務。
除了我上面的例子外,比如還有 redis 裡面的 mget,資料庫裡面的批次插入,這玩意不就是一個請求合併的真實場景嗎?
比如 redis 把多個 get 合併起來,然後呼叫 mget。多次請求合併成一次請求,節約的是網路傳輸時間。
還有真實的案例是轉賬的場景,有的轉賬渠道是按次收費的,那麼作為第三方公司,我們就可以把使用者的請求先放到表裡記錄著,等一小時之後,一起彙總發起,假設這一小時內發生了 10 次轉賬,那麼 10 次收費就變成了 1 次收費,雖然讓客戶等的稍微久了點,但還是在可以接受的範圍內,這操作節約的就是真金白銀了。
請求合併,說穿了,就這麼一點事兒,一點也不復雜。
那麼如果我在請求合併的前面,加上「高並行」這三個字...
首先不論是在請求合併的前面加上多麼狂拽炫酷吊炸天的形容詞,說的多麼的天花亂墜,它也還是一個請求合併。
那麼佇列和定時任務的這個基礎結構肯定是不會變的。
高並行的情況下,就是請求量非常的大嘛,那我們把定時任務的頻率調高一點不就行了?
以前 100ms 內就會過來 50 筆請求,我每收到一筆就是立即處理了。
現在我們把請求先放到佇列裡面快取著,然後每 100ms 就執行一次定時任務。
100ms 到了之後,就會有定時任務把這 100ms 內的所有請求取走,統一處理。
同時,我們還可以控制佇列的長度,比如只要 50ms 佇列的長度就達到了 50,這個時候我也進行合併處理。不需要等待到 100ms 之後。
其實寫到這裡,高並行的請求合併的答案已經出來了。
關鍵點就三個:
方案都想到了,把程式碼寫出來豈不是很容易的事情。而且對於這種面試的場景圖,一般都是討論技術方案,而不太會去討論具體的程式碼。
當討論到具體的程式碼的時候,要麼是對你的方案存疑,想具體的探討一下落地的可行性。要麼就是你答對了,他要準備從程式碼的交易開始衍生另外的面試題了。
總之,大部分情況下,不會在你給了一個面試官覺得錯誤的方案之後,他還和你討論程式碼細節。你們都不在一個頻道了,趕緊換題吧,還聊啥啊。
實在要往程式碼實現上聊,那麼大概率他是在等著你說出一個框架:Hystrix。
其實這題,你要是知道 Hystrix,很容易就能給出一個比較完美的回答。
因為 Hystrix 就有請求合併的功能。
通過一個實際的例子,給大家演示一下。
假設我們有一個學生資訊查詢介面,呼叫頻率非常的高。對於這個介面我們需要做請求合併處理。
做請求合併,我們至少對應著兩個介面,一個是接收單個請求的介面,一個處理把單個請求彙總之後的請求介面。
所以我們需要先提供兩個 service:
其中根據指定 id 查詢的介面,對應的 Controller 是這樣的:
服務啟動起來後,我們用執行緒池結合 CountDownLatch 模擬 20 個並行請求:
從控制檯可以看到,瞬間接受到了 20 個請求,執行了 20 次查詢 sql:
很明顯,這個時候我們就可以做請求合併。每收到 10 次請求,合併為一次處理,結合 Hystrix 程式碼就是這樣的,為了程式碼的簡潔性,我採用的是註解方式:
在上面的圖片中,有兩個方法,一個是 getUserId,直接返回的是null,因為這個方法體不重要,根本就不會執行。
在 @HystrixCollapser 裡面可以看到有一個 batchMethod 的屬性,其值是 getUserBatchById。
也就是說這個方法對應的批次處理方法就是 getUserBatchById。當我們請求 getUserById 方法的時候,Hystrix 會通過一定的邏輯,幫我們轉發到 getUserBatchById 上。
所以我們呼叫的還是 getUserById 方法:
同樣,我們用執行緒池結合 CountDownLatch 模擬 20 個並行請求,只是變換了請求地址:
呼叫之後,神奇的事情就出現了,我們看看紀錄檔:
同樣是接受到了 20 個請求,但是每 10 個一批,只執行了兩個sql語句。
從 20 個 sql 到 2 個 sql,這就是請求合併的威力。請求合併的處理速度甚至比單個處理還快,這也是效能的提升。
那假設我們只有 5 個請求過來,不滿足 10 個這個條件呢?
別忘了,我們還有定時任務呢。
在 Hystrix 中,定時任務預設是每 10ms 執行一次:
同時我們可以看到,如果不設定 maxRequestsInBatch,那麼預設是 Integer.MAX_VALUE。
也就是說,在 Hystrix 中做請求合併,它更加側重的是時間方面。
功能演示,其實就這麼簡單,程式碼量也不多,有興趣的朋友可以直接搭個 Demo 跑跑看。看看 Hystrix 的原始碼。
我這裡只是給大家指幾個關鍵點吧。
第一個肯定是我們需要找到方法入口。
你想,我們的 getUserById 方法的方法體裡面直接是 return null,也就是說這個方法體是什麼根本就不重要,因為不會去執行方法體中的程式碼。它只需要攔截到方法入參,並快取起來,然後轉發到批次方法中去即可。
然後方法體上面有一個 @HystrixCollapser 註解。
那麼其對應的實現方式你能想到什麼?
肯定是 AOP 了嘛。
所以,我們拿著這個註解的全路徑,進行搜尋,啪的一下,很快啊,就能找到方法的入口:
com.netflix.hystrix.contrib.javanica.aop.aspectj.HystrixCommandAspect#methodsAnnotatedWithHystrixCommand
在入口處打上斷點,就可以開始偵錯了:
第二個我們看看定時任務是在哪兒進行註冊的。
這個就很好找了。我們已經知道預設引數是 10ms 了,只需要順著鏈路看一下,哪裡的程式碼呼叫了其對應的 get 方法即可:
同時,我們可以看到,其定時功能是基於 scheduleAtFixedRate 實現的。
第三個我們看看是怎麼控制超過指定數量後,就不等待定時任務執行,而是直接發起彙總操作的:
可以看到,在com.netflix.hystrix.collapser.RequestBatch#offer方法中,當 argumentMap 的 size 大於我們指定的 maxBatchSize 的時候返回了 null。
如果,返回為 null ,那麼說明已經不能接受請求了,需要立即處理,程式碼裡面的註釋也說的很清楚了:
以上就是三個關鍵的地方,Hystrix 的原始碼讀起來,需要下點功夫,大家自己研究的時候需要做好心理準備。
最後再貼一個官方的請求合併工作流程圖:
還是回到最開始我們提出的熱點賬戶問題中的「減餘額頻繁的熱點賬戶」。
請求分治和請求合併,就是針對「熱點賬戶」這同一個問題的完全不同方向的兩個回答。
分治,它的思想是拆分。
再說拆分之前,我們先聊一個更熟悉的東西:AtomicLong。
AtomicLong,這玩意是可以實現原子性的增減操作,但是當競爭非常大的時候,被操作的「值」就是一個熱點資料,所有執行緒都要去對其進行爭搶,導致並行修改時衝突很大。
那麼 AtomicLong 是靠什麼解決這個衝突的呢?
看一下它的 getAndAdd 方法:
可以看到這裡面還是有一個 do-while 的迴圈:
裡面呼叫了 compareAndSwapLong 方法。
do-while,就是自旋。
compareAndSwapLong,就是 CAS。
所以 AtomicLong 靠的是自旋 CAS 來解決競爭大的時候的這個衝突。
你看這個場景,是不是和我們開篇提到的熱點賬戶有點類似?
熱點賬戶,在並行大的時候我們可以對賬戶進行加鎖操作,讓其他請求進行排隊。
而它這裡用的是 CAS,一種樂觀鎖的機制。
但是也還是要排隊,不夠優雅。
什麼是優雅的?
LongAdder 是優雅的。
有點小夥伴就有點疑問了:歪師傅,不是要講熱點賬戶嗎,怎麼扯到 LongAdder 上了呢?
閉嘴,往下看就行了。
首先,我們先看一下官網上的介紹:
上面的截圖一共兩段話,是對 LongAdder 的簡介,我給大家翻譯並解讀一下。
首先第一段:當有多執行緒競爭的情況下,有個叫做變數集合(set of variables)的東西會動態的增加,以減少競爭。
sum 方法返回的是某個時刻的這些變數的總和。
所以,我們知道了它的返回值,不論是 sum 方法還是 longValue 方法,都是那個時刻的,不是一個準確的值。
意思就是你拿到這個值的那一刻,這個值其實已經變了。
這點是非常重要的,為什麼會是這樣呢?
我們對比一下 AtomicLong 和 LongAdder 的自增方法就可以知道了:
AtomicLong 的自增是有返回值的,就是一個這次呼叫之後的準確的值,這是一個原子性的操作。
LongAdder 的自增是沒有返回值的,你要獲取當前值的時候,只能呼叫 sum 方法。
你想這個操作:先自增,再獲取值,這就不是原子操作了。
所以,當多執行緒並行呼叫的時候,sum 方法返回的值必定不是一個準確的值。除非你加鎖。
該方法上的說明也是這樣的:
至於為什麼不能返回一個準確的值,這就是和它的設計相關了,這點放在後面去說。
然後第二段:當在多執行緒的情況下對一個共用資料進行更新(寫)操作,比如實現一些統計資訊類的需求,LongAdder 的表現比它的老大哥 AtomicLong 表現的更好。在並行不高的時候,兩個類都差不多。但是高並行時 LongAdder 的吞吐量明顯高一點,它也佔用更多的空間。這是一種空間換時間的思想。
這段話其實是接著第一段話在進行描述的。
因為它在多執行緒並行情況下,沒有一個準確的返回值,所以當你需要根據返回值去搞事情的時候,你就要仔細思考思考,這個返回值你是要精準的,還是大概的統計類的資料就行。
比如說,如果你是用來做序號生成器,所以你需要一個準確的返回值,那麼還是用 AtomicLong 更加合適。
如果你是用來做計數器,這種寫多讀少的場景。比如介面存取次數的統計類需求,不需要時時刻刻的返回一個準確的值,那就上 LongAdder 吧。
總之,AtomicLong 是可以保證每次都有準確值,而 LongAdder 是可以保證最終資料是準確的。高並行的場景下 LongAdder 的寫效能比 AtomicLong 高。
接下來探討三個問題:
先帶你上個圖片,看不懂沒有關係,先有個大概的印象:
接下來我們就去探索原始碼,原始碼之下無祕密。
從原始碼我們可以看到 add 方法是關鍵:
裡面有 cells 、base 這樣的變數,所以在解釋 add 方法之前,我們先看一下 這幾個成員變數。
這幾個變數是 Striped64 裡面的。
LongAdder 是 Striped64 的子類:
其中的四個變數如下:
之前,檔案裡面說的 set of variables 就是這裡的 cells。
好了,我們再回到 add 方法裡面:
cells 沒有被初始化過,說明是第一次呼叫或者競爭不大,導致 CAS 操作每次都是成功的。
casBase 方法就是進行 CAS 操作。
當由於競爭激烈導致 casBase 方法返回了 false 後,進入 if 分支判斷。
這個 if 分子判斷有 4 個條件,做了 3 種情況的判斷
這三個操作的含義為:當 cells 陣列裡面有東西,並且通過 getProbe() & m算出來的值,在 cells 陣列裡面能取到東西(cell)時,就再次對取到的 cell 物件進行 CAS 操作。
如果不滿足上面的條件,則進入 longAccumulate 函數。
這個方法主要是對 cells 陣列進行操作,你想一個陣列它可以有三個狀態:未初始化、初始化中、已初始化,所以下面就是對這三種狀態的分別處理:
上面三步是在一個死迴圈裡面的。
所以如果 cells 還沒有進行初始化,由於有鎖的標誌位,所以就算並行非常大的時候一定只有一個執行緒去做初始化 cells 的操作,然後對 cells 進行初始化或者擴容的時候,其他執行緒的值就在 base 上進行累加操作。
上面就是 sum 方法的工作過程。
感受到了嗎,其實這就是一個分段操作的思想,不知道你有沒有想到 ConcurrentHashMap,也不奇怪,畢竟這兩個東西都是 Doug Lea 寫的。
總的來說,就是當沒有衝突的時候 LongAdder 表現的和 AtomicLong 一樣。當有衝突的時候,才是 LongAdder 表現的時候,然後我們再回去看這個圖,就能明白怎麼回事了:
好了,現在我們回到前面提出的三個問題:
它們其實是一個問題。
因為 LongAdder 把熱點 value 拆分了,放到了各個 cell 裡面去操作。這樣就相當於把衝突分散到了 cell 裡面。所以解決了並行修改衝突很大這個問題。
當發生衝突時 sum= base+cells。高並行的情況下當你獲取 sum 的時候,cells 極有可能正在被其他的執行緒改變。一個在高並行場景下實時變化的值,你要它怎麼給你個準確值?
當然,你也可以通過加鎖操作拿到當前的一個準確值,但是這種場景你還用啥 LongAdder,是 AtomicLong 不香了嗎?
為什麼高並行場景下 LongAdder 的寫效能比 AtomicLong 高?
你發動你的小腦殼想一想,朋友。
AtomicLong 不管有沒有衝突,它寫的都是一個共用的 value,有衝突的時候它就在自旋。
LongAdder 沒有衝突的時候表現的和 AtomicLong 一樣,有衝突的時候就把衝突分散到各個 cell 裡面了,衝突分散了,寫的當然更快了。
我強調一次:有衝突的時候就把衝突分散到各個 cell 裡面了,衝突分散了,寫的當然更快了。
你注意這句話裡面的「各個 cell」。
這是什麼?
這個東西本質上就是 sum 值的一部分。
如果用熱點賬戶去套的話,那麼「各個 cell」就是熱點賬戶下的影子賬戶。
熱點賬戶說到底還是一個單點問題,那麼對於單點問題,我們用微服務的思想去解決的話是什麼方案?
就是拆分。
假設這個熱點賬戶上有 100w,我設立 10 個影子賬戶,每個賬戶 10w ,那麼是不是我們的流量就分散了?
從一個賬戶變成了 10 個賬戶,壓力也就進行了分攤。
但是同時帶來的問題也很明顯。
比如,獲取賬戶餘額的時候需要把所有的影子賬戶進行彙總操作。但是每個影子賬戶上的餘額是時刻在變化的,所以我們不能保證餘額是一個實時準確的值。
但是相比於下單的量來說,大部分商戶並不關心「賬上的實時餘額」這個點。
他只關心上日餘額是準確的,每日對賬都能對上就行了。
這就是分治。
其實我淺顯的覺得分散式、高並行都是基於分治,或者拆分思想的。
本文的 LongAdder 就不說了。
微服務化、分庫分表、讀寫分離......這些東西都是在分治,在拆分,把集中的壓力分散開來。
這就算是我對於「請求合併和分治」的理解、
好了,到這裡本文就算是結束了。
針對"熱點賬戶"這同一個問題,細化了問題方向,定義了加餘額頻繁和減餘額頻繁的兩種熱點賬戶,然後給出了兩個完全不同方向的回答。
這個時候,我就可以把文章開頭的那句話拿出來說了:
綜上,我認為合併和分治,這二者之間是辯證的關係,得用辯證的眼光看問題,它們是你中有我,我中有你~