這個面試官真煩,問完合并又問拆分。
你好呀,我是歪歪。
這次來盤個小伙伴分享給我的一個面試題,他說面試的過程中面試官的問了一個比較開放的問題:
請談談你對于請求合并和分治的看法。
他覺得自己沒有答的特別好,主要是沒找到合適的角度來答題,跑來問我怎么看。
我能怎么看?
我也不知道面試官想問啥角度啊。但是這種開放題,只要回答的不太離譜,應該都能混得過去。
比如,如果你回答一個:我認為合并和分治,這二者之間是辯證的關系,得用辯證的眼光看問題,它們是你中有我,我中有你~
那涼了,拿著簡歷回家吧。

我也想了一下,如果讓我來回答這個問題,我就用這篇文章來回答一下。
有廣義上的實際應用場景,也有狹義上的源代碼體現對應的思想。
讓面試官自己挑吧。

鋪墊一下
首先回答之前肯定不能干聊,所以我們鋪墊一下,先帶入一個場景:熱點賬戶。
什么是熱點賬戶呢?
在第三方支付系統或者銀行這類交易機構中,每產生一筆轉入或者轉出的交易,就需要對交易涉及的賬戶進行記賬操作。
記賬粗略的來說涉及到兩個部分。
交易系統記錄這一筆交易的信息。 賬戶系統需要增加或減少對應的賬戶余額。
如果對于某個賬戶操作非常的頻繁,那么當我們對賬戶余額進行操作的時候,肯定就會涉及到并發處理的問題。
并發了怎么辦?
我們可以對賬戶進行加鎖處理嘛。但是這樣一來,這個賬戶就涉及到頻繁的加鎖解鎖操作。
雖然這樣我們可以保證數據不出問題,但是隨之帶來的問題是隨著并發的提高,賬戶系統性能下降。
極少數的賬戶在短時間內出現了極大量的余額更新請求,這類賬戶就是熱點賬戶,就是性能瓶頸點。
熱點賬戶是業界的一個非常常見的問題。
而且根據熱點賬戶的特性,也可以分為不同的類型。
如果余額的變動是在頻繁的增加,比如頭部主播帶貨,只要一喊 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 高。
接下來探討三個問題:
LongAdder 是怎么解決多線程操作熱點 value 導致并發修改沖突很大這個問題的? 為什么高并發場景下 LongAdder 的 sum 方法不能返回一個準確的值? 為什么高并發場景下 LongAdder 的寫性能比 AtomicLong 高?
先帶你上個圖片,看不懂沒有關系,先有個大概的印象:

接下來我們就去探索源碼,源碼之下無秘密。
從源碼我們可以看到 add 方法是關鍵:

里面有 cells 、base 這樣的變量,所以在解釋 add 方法之前,我們先看一下 這幾個成員變量。
這幾個變量是 Striped64 里面的。
LongAdder 是 Striped64 的子類:

其中的四個變量如下:

NCPU:cpu 的個數,用來決定 cells 數組的大小。 cells:一個數組,當不為 null 的時候大小是 2 的次冪。里面放的是 cell 對象。 base : 基數值,當沒有競爭的時候直接把值累加到 base 里面。還有一個作用就是在 cells 初始化時,由于 cells 只能初始化一次,所以其他競爭初始化操作失敗線程會把值累加到 base 里面。 cellsBusy:當 cells 在擴容或者初始化的時候的鎖標識。
之前,文檔里面說的 set of variables 就是這里的 cells。

好了,我們再回到 add 方法里面:

cells 沒有被初始化過,說明是第一次調用或者競爭不大,導致 CAS 操作每次都是成功的。
casBase 方法就是進行 CAS 操作。
當由于競爭激烈導致 casBase 方法返回了 false 后,進入 if 分支判斷。
這個 if 分子判斷有 4 個條件,做了 3 種情況的判斷

標號為 ① 的地方是再次判斷 cells 數組是否為 null 或者 size 為 0 。as 就是 cells 數組。 標號為 ② 的地方是判斷當前線程對 cells 數組大小取模后的值,在 cells 數組里面是否能取到 cell 對象。 標號為 ③ 的地方是對取到的 cell 對象進行 CAS 操作是否能成功。
這三個操作的含義為:當 cells 數組里面有東西,并且通過 getProbe() & m算出來的值,在 cells 數組里面能取到東西(cell)時,就再次對取到的 cell 對象進行 CAS 操作。
如果不滿足上面的條件,則進入 longAccumulate 函數。
這個方法主要是對 cells 數組進行操作,你想一個數組它可以有三個狀態:未初始化、初始化中、已初始化,所以下面就是對這三種狀態的分別處理:

標號為 ① 的地方是 cells 已經初始化過了,那么這個里面可以進行在 cell 里面累加的操作,或者擴容的操作。 標號為 ② 的地方是 cells 沒有初始化,也還沒有被加鎖,那就對 cellsBusy 標識進行 CAS 操作,嘗試加鎖。加鎖成功了就可以在這里面進行一些初始化的事情。 標號為 ③ 的地方是 cells 正在進行初始化,這個時候就在 base 基數上進行 CAS 的累加操作。
上面三步是在一個死循環里面的。
所以如果 cells 還沒有進行初始化,由于有鎖的標志位,所以就算并發非常大的時候一定只有一個線程去做初始化 cells 的操作,然后對 cells 進行初始化或者擴容的時候,其他線程的值就在 base 上進行累加操作。
上面就是 sum 方法的工作過程。
感受到了嗎,其實這就是一個分段操作的思想,不知道你有沒有想到 ConcurrentHashMap,也不奇怪,畢竟這兩個東西都是 Doug Lea 寫的。
總的來說,就是當沒有沖突的時候 LongAdder 表現的和 AtomicLong 一樣。當有沖突的時候,才是 LongAdder 表現的時候,然后我們再回去看這個圖,就能明白怎么回事了:

好了,現在我們回到前面提出的三個問題:
LongAdder 是怎么解決多線程操作熱點 value 導致并發修改沖突很大這個問題的? 為什么高并發場景下 LongAdder 的 sum 方法不能返回一個準確的值? 為什么高并發場景下 LongAdder 的寫性能比 AtomicLong 高?
它們其實是一個問題。
因為 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 就不說了。
微服務化、分庫分表、讀寫分離......這些東西都是在分治,在拆分,把集中的壓力分散開來。
這就算是我對于“請求合并和分治”的理解、
好了,到這里本文就算是結束了。
針對"熱點賬戶"這同一個問題,細化了問題方向,定義了加余額頻繁和減余額頻繁的兩種熱點賬戶,然后給出了兩個完全不同方向的回答。
這個時候,我就可以把文章開頭的那句話拿出來說了:
綜上,我認為合并和分治,這二者之間是辯證的關系,得用辯證的眼光看問題,它們是你中有我,我中有你~
