作者:肥朝
出處:公眾號【肥朝】
前言
本篇主要講解的是前陣子的一個壓測問題.那麼就直接開門見山

可能有的朋友不並不知道 forceTransactionTemplate這個是幹嘛的,首先這裡先普及一下,在Java中,我們一般開啟事務就有三種方式
- XML中根據service及方法名配置切麵,來開啟事務(前幾年用的頻率較高,現在基本很少用)
- @Transactional註解開啟事務(使用頻率最高)
- 採用spring的事務模板(截圖中的方式,幾乎沒什麼人用)
我們先不糾結為什麼使用第三種,後面在講 事務傳播機制的時候我會專門介紹,我們聚焦一下主題,你現在只要知道,那個是開啟事務的意思就行了.我特意用紅色和藍色把日誌程式碼圈起來,意思就是,進入方法的時候列印日誌,然後開啟事務後,再列印一個日誌.一波壓測之後,發現介面頻繁超時,資料一致壓不上去.我們檢視日誌如下:

我們發現.這兩個日誌輸出時間間隔,竟然用了接近5秒!開個事務為何用了5秒? 事出反常必有妖!
如何切入解決問題
線上遇到高併發的問題,由於一般高併發問題重現難度比較大,所以一般肥朝都是採用眼神編譯,九淺一深靜態看原始碼的方式來分析.具體可以參考本地可跑,上線就崩?慌了!.但是考慮到肥朝公眾號仍然有小部分新關註的粉絲尚未掌握分析問題的技巧,本篇就再講一些遇到此類問題的一些常見分析方式,不至於遇到問題時, 慌得一比!

好在這個併發問題的難度並不大,本篇案例排查非常適合小白入門,我們可以透過本地模擬場景重現,將問題範圍縮小,從而逐步定位問題.
本地重現
首先我們可以準備一個併發工具類,透過這個工具類,可以在本地環境模擬併發場景.手機檢視程式碼並不友好,但是沒關係,以下程式碼均是給你複製貼上進專案重現問題用的, 並不是給你手機上看的.至於這個工具類為什麼能模擬併發場景,由於這個工具類的程式碼 全是JDK中的程式碼,核心就是 CountDownLatch類,這個原理你根據我提供的關鍵字對著你喜歡的搜尋引擎搜尋即可.
CountDownLatchUtil.java
1 public class CountDownLatchUtil {23 private CountDownLatch start;4 private CountDownLatch end;5 private int pollSize = 10;67 public CountDownLatchUtil() {8 this(10);9 }1011 public CountDownLatchUtil(int pollSize) {12 this.pollSize = pollSize;13 start = new CountDownLatch(1);14 end = new CountDownLatch(pollSize);15 }1617 public void latch(MyFunctionalInterface functionalInterface) throws InterruptedException {18 ExecutorService executorService = Executors.newFixedThreadPool(pollSize);19 for (int i = 0; i < pollSize; i++) {20 Runnable run = new Runnable() {21 @Override22 public void run() {23 try {24 start.await();25 functionalInterface.run();26 } catch (InterruptedException e) {27 e.printStackTrace();28 } finally {29 end.countDown();30 }31 }32 };33 executorService.submit(run);34 }3536 start.countDown();37 end.await();38 executorService.shutdown();39 }4041 @FunctionalInterface42 public interface MyFunctionalInterface {43 void run();44 }45}
HelloService.java
1 public interface HelloService {23 void sayHello(long timeMillis);45}
HelloServiceImpl.java
1 @Service2 public class HelloServiceImpl implements HelloService {34 private final Logger log = LoggerFactory.getLogger(HelloServiceImpl.class);56 @Transactional7 @Override8 public void sayHello(long timeMillis) {9 long time = System.currentTimeMillis() - timeMillis;10 if (time > 5000) {11 //超過5秒的列印日誌輸出12 log.warn("time : {}", time);13 }14 try {15 //模擬業務執行時間為1s16 Thread.sleep(1000);17 } catch (Exception e) {18 e.printStackTrace();19 }20 }21}
HelloServiceTest.java
1 @RunWith(SpringRunner.class)2 @SpringBootTest3 public class HelloServiceTest {45 @Autowired6 private HelloService helloService;78 @Test9 public void testSayHello() throws Exception {10 long currentTimeMillis = System.currentTimeMillis();11 //模擬1000個執行緒併發12 CountDownLatchUtil countDownLatchUtil = new CountDownLatchUtil(1000);13 countDownLatchUtil.latch(() -> {14 helloService.sayHello(currentTimeMillis);15 });16 }1718 }
我們從本地除錯的日誌中,發現了大量超過5s的介面,並且還有一些規律,肥朝特地用不同顏色的框框給大家框起來

為什麼這些時間,都是5個為一組,且每組資料相差是1s左右呢?
真相大白
@Transactional的核心程式碼如下(後續我會專門一個系列分析這部分原始碼,關註肥朝以免錯過核心內容).這裡簡單說就是 retVal=invocation.proceedWithInvocation()方法會去獲取資料庫連線.
1 if (txAttr == null || !(tm instanceof CallbackPreferringPlatformTransactionManager)) {2 // Standard transaction demarcation with getTransaction and commit/rollback calls.3 TransactionInfo txInfo = createTransactionIfNecessary(tm, txAttr, joinpointIdentification);4 Object retVal = null;5 try {6 // This is an around advice: Invoke the next interceptor in the chain.7 // This will normally result in a target object being invoked.8 retVal = invocation.proceedWithInvocation();9 }10 catch (Throwable ex) {11 // target invocation exception12 completeTransactionAfterThrowing(txInfo, ex);13 throw ex;14 }15 finally {16 cleanupTransactionInfo(txInfo);17 }18 commitTransactionAfterReturning(txInfo);19 return retVal;20}
然後肥朝為了更好的演示這個問題,將資料庫連線池(本篇用的是Druid)的引數做了以下設定
1 //初始連線數2 spring.datasource.initialSize=13 //最大連線數4 spring.datasource.maxActive=5
由於最大連線數是5.所以當1000個執行緒併發進來的時候,你可以想象是一個隊伍有1000個人排隊,最前面的5個,拿到了連線,並且執行業務時間為1秒.那麼隊伍中剩下的995個人,就在門外等候.等這5個執行完的時候.釋放了5個連線,依次向後的5個人又進來,又執行1秒的業務操作.透過簡單的小學數學,都可以計算出最後5個執行完,需要多長時間.透過這裡分析,你就知道,為什麼上面的日誌輸出,是5秒為一組了,並且每組間隔為1s了.
怎麼解決
看過肥朝原始碼實戰的粉絲都知道,肥朝從來不耍流氓,凡是丟擲問題,都會相應給出 其中一種解決方案.當然方案 沒有最優只有更優!
比如看到這裡有的朋友可能會說,你最大連線數設定得 就像平時贊賞肥朝的金額一樣小,如果設定大一點,自然就不會有問題了.當然這裡為了方便向大家演示問題,設定了最大連線數是5.正常生產的連線數是要根據業務特點和不斷壓測才能得出合理的值,當然肥朝也瞭解到,部分同學公司機器的配置,竟然比不過市面上的 千元手機!!!
但是其實當時壓測的時候,資料庫的最大連線數設定的是200,並且當時的壓測壓力並不大.那為什麼還會有這個問題呢?那麼仔細看前面的程式碼

其中這個 校驗的程式碼是RPC呼叫,該介面的同事並沒有像肥朝一樣 值得託付終身般的高度可靠,導致耗時時間較長,從而導致後續執行緒獲取資料庫連線等待的時間過長.你再根據前面說的小學數學來算一下就很容易明白該壓測問題出現的原因.
敲黑板劃重點
之前肥朝就反覆說過,遇到問題,要經過深度思考.比如這個問題,我們能得到什麼拓展性的思考呢?我們來看一下之前一位粉絲的面試經歷

其實他面試遇到的這個問題,和我們這個壓測問題基本是同一個問題,只不過面試官的結論其實並不夠準確.我們來一起看一下阿裡巴巴的開發手冊

那麼什麼樣叫做濫用呢?其實肥朝認為,即使這個方法經常呼叫,但是都是單表insert、update操作,執行時間非常短,那麼承受較大併發問題也不大.關鍵是,這個事務中的所有方法呼叫,是否是有意義的,或者說,事務中的方法是否是真的要事務保證,才是關鍵.因為部分同學,在一些比較傳統的公司,做的多是 能用就行的CRUD工作,很容易一個service方法,就直接打上事務註解開始事務,然後在一個事務中,進行大量和事務一毛錢關係都沒有的無關耗時操作,比如檔案IO操作,比如查詢校驗操作等.例如本文中的 業務校驗就完全沒必要放在事務中.平時工作中沒有相應的實戰場景,加上並沒有關註肥朝的公眾號,對原理原始碼真實實戰場景一無所知.面試稍微一問原理就喊痛,面試官也只好換個方向再繼續深入!
透過這個經歷我們又有什麼拓展性的思考呢?因為問題是永遠解決不完的,但是我們可以透過不斷的思考,把這個問題壓榨出更多的價值!我們再來看一下阿裡規範手冊

用大白話概括就是,儘量減少鎖的粒度.並且儘量避免在鎖中呼叫RPC方法,因為RPC方法涉及網路因素,他的呼叫時間存在很大的不可控,很容易就造成了佔用鎖的時間過長.
其實這個和我們這個壓測問題是一樣的.首先你本地事務中呼叫RPC既不能起到事務作用(RPC需要分散式事務保證),但是又會因為RPC不可控因素導致資料庫連線佔用時間過長.從而引起介面超時.當然我們也可以透過 APM工具來梳理介面的耗時拓撲,將此類問題在壓測前就暴露.
知識星球