1. 程式人生 > >曹工說JDK原始碼(4)--抄了一小段ConcurrentHashMap的程式碼,我解決了部分場景下的Redis快取雪崩問題

曹工說JDK原始碼(4)--抄了一小段ConcurrentHashMap的程式碼,我解決了部分場景下的Redis快取雪崩問題

[曹工說JDK原始碼(1)--ConcurrentHashMap,擴容前大家同在一個雜湊桶,為啥擴容後,你去新陣列的高位,我只能去低位?](https://www.cnblogs.com/grey-wolf/p/13057567.html) [曹工說JDK原始碼(2)--ConcurrentHashMap的多執行緒擴容,說白了,就是分段取任務](https://www.cnblogs.com/grey-wolf/p/13062751.html) [曹工說JDK原始碼(3)--ConcurrentHashMap,Hash演算法優化、位運算揭祕](https://www.cnblogs.com/grey-wolf/p/13069173.html) # 什麼是快取雪崩 ##基本概念梳理 這個基本也是redis 面試的經典題目了,然而,網上不少部落格對這個詞的定義都含糊不清,各執一詞。 主要有兩類說法: * 大量快取key,由於設定了相同的過期時間,在某個時刻同時失效,導致此刻的查詢請求,全部湧向db,本來db的tps大概是幾千左右,結果湧入了幾十萬的請求,那db肯定直接就扛不住了 這種說法下面,解決方案一般是,把過期時間增加一個隨機值,這樣,也就不會大批量的key同時失效了 * 另外一種說法是,本來redis扛下了大部分的請求,但是,由於快取所在的機器,發生了宕機。此時,快取這臺機器之間就連不上了,redis服務也掛了,此時,你的服務裡,發現redis取不到,然後全都跑去查資料庫,那,就發生和前面一樣的情況了,請求全部湧向db,db無響應。 兩類說法,也不用覺得,這個對,那個不對,不過是一個技術名詞,當初發明這個詞的人,估計也沒想那麼多,結果傳播開來之後,就變成了現在這個樣子。 我們這裡主要採用下面那一種說法,因為下面這種說法,其實是已經包含了上面的情景。但是,下面這種場景,要複雜的多,因為redis此時就是一個完全不可信的東西了,你得想好,怎麼不讓它掛掉,那是不是應該部署sentinel、cluster叢集?同時,持久化必須要開啟。 這樣呢,掛掉後,短暫的不可用之後,大概幾十s吧,快取叢集就恢復了,就又可用了。 同時,我們還得考慮,假設,現在redis掛了,我們程式碼的降級策略是什麼? 大家發現redis掛了,首先,估計是會拋異常了,連線超時;拋了異常後,要直接拋到前端嗎?作為一個穩健的後端程式,那肯定是不行的,你redis掛了,資料庫又沒掛;好吧,那我們就大家一起去查資料庫。 結果,大量的查詢請求,就烏泱泱地跑去查庫了,然後,db卒。這個肯定不行。 所以,我們必須要控制的一點是,當發現某個key失效了,不是大家都去查庫,而是要進行 **併發控制**。 什麼是併發控制?就是不能全部放過去查庫,只能放少部分,免得把脆弱的db打死。 併發控制,基本就是要爭奪去查庫的權利了,這一步,基本就是一個選舉的過程,可以通過搶鎖的方式,比如Reentrentlock,synchronized,cas也可以。 1. 搶到鎖的執行緒,有資格去查庫,其他執行緒要麼被阻塞,要麼自旋 2. 搶到鎖的執行緒,去查庫,查到資料後,將資料存放在某個地方,通知其他執行緒去取(如果其他執行緒被阻塞的話);或者,如果其他執行緒沒被阻塞,比如sleep 50ms,再去指定的地方拿資料那種,這種就不需要通知 總之,如果其他執行緒要我們通知,我們就通知;不要我們通知,我們就不通知。 ## 搶到鎖的執行緒,在構建快取時,其他執行緒應該幹什麼? 1. 在while(true)裡,sleep 50ms,然後再去取資料 這種類似於忙等待,但是每次sleep一會,所以還不錯 2. 將自己阻塞,等待搶到鎖的執行緒,構建完快取後,來喚醒 3. 在while(true)裡,一直忙迴圈,期間一直檢查資料是否已經ok了,這種方案呢,要看裡面:檢查資料的操作,是否耗時;如果只是檢查jvm記憶體裡的資料,那還好;否則的話,假設要去檢查redis的話,這種io比較耗時的操作的話,就不合適了,cpu會一直空轉。 # 本文采用的方案 主執行緒構建快取時,其他執行緒,在while(true)裡,sleep 一定時間,然後再檢查資料是否ready。 說了這麼多,好像和題目裡的concurrenthashmap沒啥關係,不,是有關係的,因為,這個思路,其實就是來自於concurrentHashMap。 # ConcurrentHashMap中,是怎麼去初始化底層陣列的 在我們用無參建構函式,去new一個ConcurrentHashMap時,此時還不會去建立底層陣列,這個是一個小優化。什麼時候建立陣列呢,是在我們第一次去put的時候。 put的時候,會呼叫putVal。 其中,putVal程式碼如下: ```java transient volatile Node[] table; final V putVal(K key, V value, boolean onlyIfAbsent) { if (key == null || value == null) throw new NullPointerException(); int hash = spread(key.hashCode()); int binCount = 0; // 1 for (Node[] tab = table;;) { Node f; int n, i, fh; // 2 if (tab == null || (n = tab.length) == 0) tab = initTable(); ``` * 1處,把field table,賦值給區域性變數tab * 2處,如果tab為null,則進行initTable初始化 這個2處,在多執行緒put的時候,是可能多個執行緒同時進來的。有併發問題。 我們接下來,看看initTable是怎麼解決這個問題的,畢竟,我們new陣列,只new一次即可,new那麼多次,沒用,對效能有損耗。所以,這裡面肯定會多執行緒爭奪初始化權利的程式碼。 ```java private transient volatile int sizeCtl; transient volatile Node[] table; /** * Initializes table, using the size recorded in sizeCtl. */ private final Node[] initTable() { Node[] tab; int sc; // 0 while ((tab = table) == null || tab.length == 0) { // 1 if ((sc = sizeCtl) < 0) Thread.yield(); // lost initialization race; just spin // 2 else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { try { // 3 if ((tab = table) == null || tab.length == 0) { // 4 int n = (sc >
0) ? sc : DEFAULT_CAPACITY; @SuppressWarnings("unchecked") Node[] nt = (Node[])new Node[n]; table = tab = nt; sc = n - (n >>> 2); } } finally { // 5 sizeCtl = sc; } break; }// end if }// end while return tab; } ``` * 1處,這裡把sizeCtl,賦值給區域性變數sc。這裡的sizeCtl是一個很重要的field,當我們new完之後,預設這個欄位,要麼為0,要麼為準備建立的底層陣列的長度。 這裡去判斷是否小於0,那肯定不滿足,小於0,會是什麼意思?當某個執行緒,搶到了這個initTable中的底層陣列的建立權利時,就會把sizeCtl改為 -1。 所以,這裡的意思是,看看是否已經有其他執行緒在初始化了,如果已經有了,則直接呼叫: Thread.yield(); 這個方法的意思是,暗示作業系統,自己準備放棄cpu;但作業系統,自有它自己的執行緒排程規則,所以,這個方法可能沒什麼效果;我們業務程式碼,這裡一般可以修改為Thread.sleep。 這個方法呼叫完成後,後續也沒有其他程式碼,所以會直接跳轉到迴圈開始處(0處程式碼),判斷table是否初始化ok了,如果沒有ok,則會繼續進來。 * 2處,使用cas,如果此時,sizeCtl的值等於sc的值,就修改sizeCtl為 -1;如果成功,則返回true,進入3處 否則,會跳轉到0處,繼續迴圈。 * 3處,雖然搶到了控制權,但是這裡還是要再判斷一下,不然可能出現重複初始化,即,不加這一行,4處的程式碼,會被重複執行 * 4處開始,這裡去執行真正的初始化邏輯。 ```java // int n = (sc > 0) ? sc : DEFAULT_CAPACITY; @SuppressWarnings("unchecked") // 1 Node[] nt = (Node[])new Node[n]; // 2 table = tab = nt; sc = n - (n >>> 2); ``` 這裡的1處,new陣列;2處,賦值給field:table;此時,因為table 這個field是volatile修飾的,所以其他執行緒會馬上感知到。0處程式碼就不會為true了,就不會繼續迴圈了。 * 5處,修改sizeCtl為正數。 這裡說下,為啥要加3處的那個判斷。 現在,假設執行緒A,在初始化完成後,走到了5處,修改了sizeCtl為正數;而執行緒B,剛好執行1處程式碼: ```java // 1 if ((sc = sizeCtl) < 0) ``` 那肯定,1處就不滿足了;然後就會進到2處,cas修改成功,進行初始化。沒有3處判斷的話,就會重複初始化。 # 基於concurrentHashmap,實現我們的快取雪崩方案 我這裡的方案,還是比較簡單那種,就是,n個執行緒同時爭奪構建快取的權利;winner執行緒,構建快取後,會把快取設定到redis;其他執行緒則是一直在while(true)裡sleep一段時間,然後檢查redis裡的資料是否不為空。 這個方案中,redis掛了這種情況,是沒在考慮中的,但是一個方案,沒辦法立馬各方面全部到位,後續我再完善一下。 ## 不考慮快取雪崩的程式碼 ```java @Override public Users getUser(long userId) { ValueOperations ops = redisTemplate.opsForValue(); // 1 Users s = ops.get(String.valueOf(userId)); if (s == null) { /** * 2 這裡要去查庫獲取值 */ Users users = getUsersFromDB(userId); // 3 ops.set(String.valueOf(users.getUserId()),users); return users; } return s; } private Users getUsersFromDB(long userId) { Users users = new Users(); try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println("spent 1s to get user from db"); users.setUserId(userId); users.setUserName("zhangsan"); return users; } ``` 直接看上面的1,2,3處。就是檢查、構建快取,設定到快取的過程。 ## 考慮快取雪崩的程式碼 ```java // 1 private volatile int initControl; @Override public Users getUser(long userId) { ValueOperations ops = redisTemplate.opsForValue(); Users users; while (true) { // 2 users = ops.get(String.valueOf(userId)); if (users != null) { // 3 break; } // 4 int initControlLocal = initControl; /** * 5 如果已經有執行緒在進行獲取了,則直接放棄cpu */ if (initControlLocal < 0) { // log.info("initControlLocal < 0,just yield and wait"); // Thread.yield(); try { Thread.sleep(50); } catch (InterruptedException e) { log.warn("e:{}", e); } continue; } /** * 6 爭奪控制權 */ boolean bGotChanceToInit = U.compareAndSwapInt(this, INIT_CONTROL, initControlLocal, -1); // 7 if (bGotChanceToInit) { try { // 8 users = ops.get(String.valueOf(userId)); if (users == null) { log.info("got change to init"); /** * 9 這裡要去查庫獲取值 */ users = getUsersFromDB(userId); ops.set(String.valueOf(users.getUserId()), users); log.info("init over"); } } finally { // 10 initControl = 0; } break; }// end if (bGotChanceToInit) }// end while return users; } ``` * 1處,定義了一個field,initControl;預設為0.執行緒們會去使用cas,修改為-1,成功的執行緒,即獲得初始化快取的權利。 注意,要定義為volatile,保證執行緒間的可見性 * 2處,去redis獲取快取,如果不為null,直接返回 * 4處,如果沒取到快取,則進入此處;此處,將field:initControl賦值給區域性變數 * 5處,判斷區域性變數initControlLocal,是否小於0;小於0,說明已經有執行緒在進行初始化了,直接contine,繼續下一次迴圈 * 6處,如果當前還沒有執行緒在初始化,則開始競爭初始化的權利,誰成功地用cas,修改field:initControl為-1,誰就獲得這個權利 * 7處,如果當前執行緒獲得了權利,則進入8處,否則,會繼續下一次迴圈 * 8處,再次去redis,獲取快取,如果不為空,則進入9處 * 9處,查庫,設定快取 * 10處,修改field:initControl為0,表示退出初始化 這裡的程式碼,整體和hashmap中的initTable是一模一樣的。 ## 如何測試 上面的方案,怎麼測試沒問題呢?我寫了一段測試程式碼。 ```java ThreadPoolExecutor executor = new ThreadPoolExecutor(100, 100, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1000), new RejectedExecutionHandler() { @Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { log.info("discard:{}",r); } }); @RequestMapping("/test.do") public void test() { // 0 iUsersService.deleteUser(111L); CyclicBarrier barrier = new CyclicBarrier(100); for (int i = 0; i < 100; i++) { executor.submit(new Runnable() { @Override public void run() { try { barrier.await(); } catch (InterruptedException | BrokenBarrierException e) { e.printStackTrace(); } long start = System.currentTimeMillis(); // 1 Users users = iUsersService.getUser(111L); log.info("result:{},spent {} ms", users, System.currentTimeMillis() - start); } }); } } ``` 上面模擬100併發下,獲取快取。 0處,把快取刪了,模擬快取失效 1處,呼叫方法,獲取快取。 效果如下: ![](https://img2020.cnblogs.com/blog/519126/202006/519126-20200611150600273-1455535837.png) 可以看到,只有一個執行緒拿到了初始化權利。 # 原始碼位置
# 總結 jdk的併發包,寫得真是有水平,大家仔細研究的話,必有