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

三國夢迴發表於2020-06-11

曹工說JDK原始碼(1)--ConcurrentHashMap,擴容前大家同在一個雜湊桶,為啥擴容後,你去新陣列的高位,我只能去低位?

曹工說JDK原始碼(2)--ConcurrentHashMap的多執行緒擴容,說白了,就是分段取任務

曹工說JDK原始碼(3)--ConcurrentHashMap,Hash演算法優化、位運算揭祕

什麼是快取雪崩

基本概念梳理

這個基本也是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程式碼如下:

    transient volatile Node<K,V>[] 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<K,V>[] tab = table;;) {
            Node<K,V> 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那麼多次,沒用,對效能有損耗。所以,這裡面肯定會多執行緒爭奪初始化權利的程式碼。

	private transient volatile int sizeCtl;
	transient volatile Node<K,V>[] table;

	/**
     * Initializes table, using the size recorded in sizeCtl.
     */
    private final Node<K,V>[] initTable() {
        Node<K,V>[] 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<K,V>[] nt = (Node<K,V>[])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處開始,這裡去執行真正的初始化邏輯。

    // 
    int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
    @SuppressWarnings("unchecked")
    // 1
    Node<K,V>[] nt = (Node<K,V>[])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處程式碼:

// 1
if ((sc = sizeCtl) < 0)

那肯定,1處就不滿足了;然後就會進到2處,cas修改成功,進行初始化。沒有3處判斷的話,就會重複初始化。

基於concurrentHashmap,實現我們的快取雪崩方案

我這裡的方案,還是比較簡單那種,就是,n個執行緒同時爭奪構建快取的權利;winner執行緒,構建快取後,會把快取設定到redis;其他執行緒則是一直在while(true)裡sleep一段時間,然後檢查redis裡的資料是否不為空。

這個方案中,redis掛了這種情況,是沒在考慮中的,但是一個方案,沒辦法立馬各方面全部到位,後續我再完善一下。

不考慮快取雪崩的程式碼

@Override
public Users getUser(long userId) {
    ValueOperations<String, Users> 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處。就是檢查、構建快取,設定到快取的過程。

考慮快取雪崩的程式碼

	// 1
	private volatile int initControl;

	@Override
    public Users getUser(long userId) {
        ValueOperations<String, Users> 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是一模一樣的。

如何測試

上面的方案,怎麼測試沒問題呢?我寫了一段測試程式碼。

    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://gitee.com/ckl111/all-simple-demo-in-work-1/tree/master/redis-cache-avalanche

總結

jdk的併發包,寫得真是有水平,大家仔細研究的話,必有收穫。

相關文章