Redis(開發與運維):55---緩存設計之(無底洞問題及優化)

一、無底洞問題

  • 2010年,Facebook的Memcache節點已經達到了3000個,承載着TB級別的緩存數據。但開發和運維人員發現了一個問題,爲了滿足業務要求添加了大量新Memcache節點,但是發現性能不但沒有好轉反而下降了,當時將這 種現象稱爲緩存的“無底洞”現象。
  • 那麼爲什麼會產生這種現象呢,通常來說添加節點使得Memcache集羣性能應該更強了,但事實並非如此。鍵值數據庫由於通常採用哈希函數將 key映射到各個節點上,造成key的分佈與業務無關,但是由於數據量和訪問量的持續增長,造成需要添加大量節點做水平擴容,導致鍵值分佈到更多的節點上,所以無論是Memcache還是Redis的分佈式,批量操作通常需要從不同節點上獲取,相比於單機批量操作只涉及一次網絡操作,分佈式批量操作會涉及多次網絡時間
  • 下圖展示了在分佈式條件下,一次mget操作需要訪問多個Redis節點, 需要多次網絡時間

  • 而下圖由於所有鍵值都集中在一個節點上,所以一次批量操作只需要 一次網絡時間

  • 無底洞問題分析:
    • 客戶端一次批量操作會涉及多次網絡操作,也就意味着批量操作會隨 着節點的增多,耗時會不斷增大
    • 網絡連接數變多,對節點的性能也有一定影響
  • 用一句通俗的話總結就是,更多的節點不代表更高的性能,所謂“無底 洞”就是說投入越多不一定產出越多。但是分佈式又是不可以避免的,因爲 訪問量和數據量越來越大,一個節點根本抗不住,所以如何高效地在分佈式緩存中批量操作是一個難點

優化思路

  • 下面介紹如何在分佈式條件下優化批量操作。在介紹具體的方法之前, 我們來看一下常見的IO優化思路:
    • 命令本身的優化,例如優化SQL語句等
    • 減少網絡通信次數
    • 降低接入成本,例如客戶端使用長連/連接池、NIO等
  • 這裏我們假設命令、客戶端連接已經爲最優,重點討論減少網絡操作次數
  • 以Redis批量獲取n個字符串爲例,有三種實現方法,如下圖所示:
    • 客戶端n次get:n次網絡+n次get命令本身
    • 客戶端1次pipeline get:1次網絡+n次get命令本身
    • 客戶端1次mget:1次網絡+1次mget命令本身

  • 上面已經給出了IO的優化思路以及單個節點的批量操作優化方式,下面我們將結合Redis Cluster的一些特性對四種分佈式的批量操作方式進行說明

二、串行命令

  • 由於n個key是比較均勻地分佈在Redis Cluster的各個節點上,因此無法使用mget命令一次性獲取,所以通常來講要獲取n個key的值,最簡單的方法就是逐次執行n個get命令,這種操作時間複雜度較高,它的操作時間=n次網絡時間+n次命令時間,網絡次數是n
  • 很顯然這種方案不是最優的,但是實現起來比較簡單,如下圖所示

  • Jedis客戶端示例代碼如下:
List<String> serialMGet(List<String> keys) {
    // 結果集
    List<String> values = new ArrayList<String>();
    // n次串行get
    for (String key : keys) {
        String value = jedisCluster.get(key);
        values.add(value);
    }
    return values;
}

三、串行IO

  • Redis Cluster使用CRC16算法計算出散列值,再取對16383的餘數就可以算出slot值,同時前面文章https://blog.csdn.net/qq_41453285/article/details/106463895我們提到過Smart客戶端會保存slot和節點的對應關係,有了這兩個數據就可以將屬於同一個節點的key進行歸檔,得到每個節點的key子列表,之後對每個節點執行mget或者Pipeline操作
  • 它的操作時間=node次網絡時間+n次命令時間,網絡次數是node的個數
  • 整個過程如下圖所示,很明顯這種方案比第一種要好很多,但是如果節點數太多,還是有一定的性能問題

  • Jedis客戶端示例代碼如下:
Map<String, String> serialIOMget(List<String> keys) {
    // 結果集
    Map<String, String> keyValueMap = new HashMap<String, String>();
    // 屬於各個節點的key列表,JedisPool要提供基於ip和port的hashcode方法
    Map<JedisPool, List<String>> nodeKeyListMap = new HashMap<JedisPool, List<String>>();
    // 遍歷所有的key
    for (String key : keys) {
        // 使用CRC16本地計算每個key的slot
        int slot = JedisClusterCRC16.getSlot(key);
        // 通過jedisCluster本地slot->node映射獲取slot對應的node
        JedisPool jedisPool = jedisCluster.getConnectionHandler().getJedisPoolFromSlot(slot);
        // 歸檔
        if (nodeKeyListMap.containsKey(jedisPool)) {
            nodeKeyListMap.get(jedisPool).add(key);
        } else {
            List<String> list = new ArrayList<String>();
            list.add(key);
        nodeKeyListMap.put(jedisPool, list);
        }
    }
    // 從每個節點上批量獲取,這裏使用mget也可以使用pipeline
    for (Entry<JedisPool, List<String>> entry : nodeKeyListMap.entrySet()) {
        JedisPool jedisPool = entry.getKey();
        List<String> nodeKeyList = entry.getValue();
        // 列表變爲數組
        String[] nodeKeyArray = nodeKeyList.toArray(new String[nodeKeyList.size()]);
        // 批量獲取,可以使用mget或者Pipeline
        List<String> nodeValueList = jedisPool.getResource().mget(nodeKeyArray);
        // 歸檔
        for (int i = 0; i < nodeKeyList.size(); i++) {
            keyValueMap.put(nodeKeyList.get(i), nodeValueList.get(i));
        }
    }
    return keyValueMap;
}

四、並行IO

  • 此方案是將方案2中的最後一步改爲多線程執行,網絡次數雖然還是節點個數,但由於使用多線程網絡時間變爲O(1),這種方案會增加編程的複雜度
  • 它的操作時間爲:
max_slow(node網絡時間)+n次命令時間
  • 整個過程如下圖所示:

五、hash_tag實現

  • 如下圖所示,所有key屬於node2節點

  • Jedis客戶端示例代碼如下:
List<String> hashTagMget(String[] hashTagKeys) {
    return jedisCluster.mget(hashTagKeys);
}

六、總結

  • 上面已經對批量操作的四種方案進行了介紹,最後通過下圖來對四種方案的優缺點、網絡IO次數進行一個總結

  • 實際開發中可以根據上圖給出的優缺點進行分析,沒有最好的方案只有最合適的方案
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章