redis實戰最佳化二

圣辉發表於2024-05-04

參考:

圖靈課堂

快取穿透之布隆過濾器

對於惡意攻擊,向伺服器請求大量不存在的資料造成的快取穿透,還可以用布隆過濾器先做一次過濾,對於不存在的資料布隆過濾器一般都能夠過濾掉,不讓請求再往後端傳送。
當布隆過濾器說某個值存在時,這個值可能不存在;當它說不存在時,那就肯定不存在。

布隆過濾器就是一個大型的位陣列和幾個不一樣的無偏 hash 函式。所謂無偏就是能夠把元素的 hash 值算得比較均勻。
向布隆過濾器中新增 key 時,會使用多個 hash 函式對 key 進行 hash 算得一個整數索引值然後對位陣列長度進行取模運算得到一個位置,每個 hash 函式都會算得一個不同的位置。再把位陣列的這幾個位置都置為 1 就完成了 add 操作。
向布隆過濾器詢問 key 是否存在時,跟 add 一樣,也會把 hash 的幾個位置都算出來,看看位陣列中這幾個位置是否都為 1,只要有一個位為 0,那麼說明布隆過濾器中這個key 不存在。如果都是 1,這並不能說明這個 key 就一定存在,只是極有可能存在,因為這些位被置為 1 可能是因為其它的 key 存在所致。如果這個位陣列長度比較大,存在機率就會很大,如果這個位陣列長度比較小,存在機率就會降低。
這種方法適用於資料命中不高、 資料相對固定、 實時性低(通常是資料集較大) 的應用場景, 程式碼維護較為複雜, 但是快取空間佔用很少。

布隆過濾器,底層是一個大的bitmap陣列,值是0或者1,經過多個hash函式進行計算後,針對布隆過濾器的長度取模,針對取模得到的值的位置賦值為1,因為hash函式存在計算衝突,所以會有一定的誤差,但是這個是可以接受的。同時布隆過濾器初始化的時候要指定儲存的元素大概個數,然後指定誤差率,這樣可以根據這兩個引數進行初始化布隆過濾器的長度;誤差率當然是越小越好,但是越小會導致陣列長度增加,並且hash函式增加,每次運算的效率就會下降,這個是要綜合考慮的。並且布隆過濾器是不能刪除無效資料的,這個是要注意的。

布穀鳥過濾器據說是可以進行資料的刪除,但是生產使用並不多。

可以用redisson實現布隆過濾器,引入依賴:
<dependency>
  <groupId>org.redisson</groupId>

  <artifactId>redisson</artifactId>
   <version>3.6.5</version>
</dependency>

示例程式碼

package com.redisson;

import org.redisson.Redisson;
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;

public class RedissonBloomFilter {

    public static void main(String[] args) {
        Config config = new Config();
        config.useSingleServer().setAddress("redis://localhost:6379");
        //構造Redisson
        RedissonClient redisson = Redisson.create(config);

        RBloomFilter<String> bloomFilter = redisson.getBloomFilter("nameList");
        //初始化布隆過濾器:預計元素為100000000L,誤差率為3%,根據這兩個引數會計算出底層的bit陣列大小
        bloomFilter.tryInit(100000000L,0.03);
        //將zhuge插入到布隆過濾器中
        bloomFilter.add("hh");

        //判斷下面號碼是否在布隆過濾器中
        System.out.println(bloomFilter.contains("dd"));//false
        System.out.println(bloomFilter.contains("gg"));//false
        System.out.println(bloomFilter.contains("hh"));//true
    }
}

使用布隆過濾器需要把所有資料提前放入布隆過濾器,並且在增加資料時也要往布隆過濾器裡放,布隆過濾器快取過濾虛擬碼:
//初始化布隆過濾器
RBloomFilter<String> bloomFilter = redisson.getBloomFilter("nameList");
//初始化布隆過濾器:預計元素為100000000L,誤差率為3%
bloomFilter.tryInit(100000000L,0.03);
        
//把所有資料存入布隆過濾器
void init(){
    for (String key: keys) {
        bloomFilter.put(key);
    }
}

String get(String key) {
    // 從布隆過濾器這一級快取判斷下key是否存在
    Boolean exist = bloomFilter.contains(key);
    if(!exist){
        return "";
    }
    // 從快取中獲取資料
    String cacheValue = cache.get(key);
    // 快取為空
    if (StringUtils.isBlank(cacheValue)) {
        // 從儲存中獲取
        String storageValue = storage.get(key);
        cache.set(key, storageValue);
        // 如果儲存資料為空, 需要設定一個過期時間(300秒)
        if (storageValue == null) {
            cache.expire(key, 60 * 5);
        }
        return storageValue;
    } else {
        // 快取非空
        return cacheValue;
    }
}

快取雪崩

由於快取層承載著大量請求, 有效地保護了儲存層, 但是如果快取層由於某些原因不能提供服務(比如超大併發過來,快取層支撐不住,或者由於快取設計不好,類似大量請求訪問bigkey,導致快取能支撐的併發急劇下降), 於是大量請求都會打到儲存層, 儲存層的呼叫量會暴增, 造成儲存層也會級聯當機的情況。
預防和解決快取雪崩問題, 可以從以下幾個方面進行著手。
1) 保證快取層服務高可用性,比如使用Redis Sentinel或Redis Cluster。
2) 依賴隔離元件為後端限流熔斷並降級。比如使用Sentinel或Hystrix限流降級元件。
比如服務降級,我們可以針對不同的資料採取不同的處理方式。當業務應用訪問的是非核心資料(例如電商商品屬性,使用者資訊等)時,暫時停止從快取中查詢這些資料,而是直接返回預定義的預設降級資訊、空值或是錯誤提示資訊;當業務應用訪問的是核心資料(例如電商商品庫存)時,仍然允許查詢快取,如果快取缺失,也可以繼續透過資料庫讀取。
3) 提前演練。 在專案上線前, 演練快取層宕掉後, 應用以及後端的負載情況以及可能出現的問題, 在此基礎上做一些預案設定。

4)快取過期時間不在同一時間過期,在基礎上加上一個隨機值。

熱點快取key重建最佳化

熱點快取key失效就類似於快取擊穿。

開發人員使用“快取+過期時間”的策略既可以加速資料讀寫, 又保證資料的定期更新, 這種模式基本能夠滿足絕大部分需求。 但是有兩個問題如果同時出現, 可能就會對應用造成致命的危害:
  • 當前key是一個熱點key(例如一個熱門的娛樂新聞),併發量非常大。
  • 重建快取不能在短時間完成, 可能是一個複雜計算, 例如複雜的SQL、 多次IO、 多個依賴等。
在快取失效的瞬間, 有大量執行緒來重建快取, 造成後端負載加大, 甚至可能會讓應用崩潰。
要解決這個問題主要就是要避免大量執行緒同時重建快取。
我們可以利用互斥鎖來解決,此方法只允許一個執行緒重建快取, 其他執行緒等待重建快取的執行緒執行完, 重新從快取獲取資料即可。
示例虛擬碼:
String get(String key) {
    // 從Redis中獲取資料
    String value = redis.get(key);
    // 如果value為空, 則開始重構快取
    if (value == null) {
        // 只允許一個執行緒重建快取, 使用nx, 並設定過期時間ex
        String mutexKey = "mutext:key:" + key;
        if (redis.set(mutexKey, "1", "ex 180", "nx")) {
             // 從資料來源獲取資料
            value = db.get(key);
            // 回寫Redis, 並設定過期時間
            redis.setex(key, timeout, value);
            // 刪除key_mutex
            redis.delete(mutexKey);
        }// 其他執行緒休息50毫秒後重試
        else {
            Thread.sleep(50);
            get(key);
        }
    }
    return value;
}

快取與資料庫雙寫不一致

在大併發下,同時運算元據庫與快取會存在資料不一致性問題
1、雙寫不一致情況

2、讀寫併發不一致

解決方案:
1、對於併發機率很小的資料(如個人維度的訂單資料、使用者資料等),這種幾乎不用考慮這個問題,很少會發生快取不一致,可以給快取資料加上過期時間,每隔一段時間觸發讀的主動更新即可。
2、就算併發很高,如果業務上能容忍短時間的快取資料不一致(如商品名稱,商品分類選單等),快取加上過期時間依然可以解決大部分業務對於快取的要求。
3、如果不能容忍快取資料不一致,可以透過加分散式讀寫鎖保證併發讀寫或寫寫的時候按順序排好隊,讀讀的時候相當於無鎖。
4、也可以用阿里開源的canal透過監聽資料庫的binlog日誌及時的去修改快取,但是引入了新的中介軟體,增加了系統的複雜度。

總結:
以上我們針對的都是讀多寫少的情況加入快取提高效能,如果寫多讀多的情況又不能容忍快取資料不一致,那就沒必要加快取了,可以直接運算元據庫。當然,如果資料庫抗不住壓力,還可以把快取作為資料讀寫的主儲存,非同步將資料同步到資料庫,資料庫只是作為資料的備份。
放入快取的資料應該是對實時性、一致性要求不是很高的資料。
切記不要為了用快取,同時又要保證絕對的一致性做大量的過度設計和控制,增加系統複雜性!

開發規範與效能最佳化

一、鍵值設計

1. key名設計

  • (1)【建議】: 可讀性和可管理性
    以業務名(或資料庫名)為字首(防止key衝突),用冒號分隔,比如業務名:表名:id
    trade:order:1
  • (2)【建議】:簡潔性
    保證語義的前提下,控制key的長度,當key較多時,記憶體佔用也不容忽視,例如:
    user:{uid}:friends:messages:{mid} 簡化為 u:{uid}:fr:m:{mid}
  • (3)【強制】:不要包含特殊字元
    反例:包含空格、換行、單雙引號以及其他跳脫字元

2. value設計

  • (1)【強制】:拒絕bigkey(防止網路卡流量、慢查詢)
    在Redis中,一個字串最大512MB,一個二級資料結構(例如hash、list、set、zset)可以儲存大約40億個(2^32-1)個元素,但實際中如果下面兩種情況,我就會認為它是bigkey。
  1. 字串型別:它的big體現在單個value值很大,一般認為超過10KB就是bigkey。
  2. 非字串型別:雜湊、列表、集合、有序集合,它們的big體現在元素個數太多。
一般來說,string型別控制在10KB以內,hash、list、set、zset元素個數不要超過5000。
反例:一個包含200萬個元素的list。
非字串的bigkey,不要使用del刪除,使用hscan、sscan、zscan方式漸進式刪除,同時要注意防止bigkey過期時間自動刪除問題(例如一個200萬的zset設定1小時過期,會觸發del操作,造成阻塞)

bigkey的危害:

1.導致redis阻塞
2.網路擁塞
bigkey也就意味著每次獲取要產生的網路流量較大,假[[設一個bigkey為1MB,客戶端每秒訪問量為1000,那麼每秒產生1000MB的流量,對於普通的千兆網路卡(按照位元組算是128MB/s)的伺服器來說簡直是滅頂之災,而且一般伺服器會採用單機多例項的方式來部署,也就是說一個bigkey可能會對其他例項也造成影響,其後果不堪設想。
3. 過期刪除
有個bigkey,它安分守己(只執行簡單的命令,例如hget、lpop、zscore等),但它設定了過期時間,當它過期後,會被刪除,如果沒有使用Redis 4.0的過期非同步刪除(lazyfree-lazy-expire yes),就會存在阻塞Redis的可能性。

bigkey的產生:

一般來說,bigkey的產生都是由於程式設計不當,或者對於資料規模預料不清楚造成的,來看幾個例子:
(1) 社交類:粉絲列表,如果某些明星或者大v不精心設計下,必是bigkey。
(2) 統計類:例如按天儲存某項功能或者網站的使用者集合,除非沒幾個人用,否則必是bigkey。
(3) 快取類:將資料從資料庫load出來序列化放到Redis裡,這個方式非常常用,但有兩個地方需要注意,第一,是不是有必要把所有欄位都快取;第二,有沒有相關關聯的資料,有的同學為了圖方便把相關資料都存一個key下,產生bigkey。

如何最佳化bigkey

1. 拆
big list: list1、list2、...listN
big hash:可以講資料分段儲存,比如一個大的key,假設存了1百萬的使用者資料,可以拆分成200個key,每個key下面存放5000個使用者資料
2. 如果bigkey不可避免,也要思考一下要不要每次把所有元素都取出來(例如有時候僅僅需要hmget,而不是hgetall),刪除也是一樣,儘量使用優雅的方式來處理。
  • (2)【推薦】:選擇適合的資料型別。
  例如:實體型別(要合理控制和使用資料結構,但也要注意節省記憶體和效能之間的平衡)
3.【推薦】:控制key的生命週期,redis不是垃圾桶。
建議使用expire設定過期時間(條件允許可以打散過期時間,防止集中過期)。

二、命令使用

1.【推薦】 O(N)命令關注N的數量

例如hgetall、lrange、smembers、zrange、sinter等並非不能使用,但是需要明確N的值。有遍歷的需求可以使用hscan、sscan、zscan代替。

2.【推薦】:禁用命令

禁止線上使用keys、flushall、flushdb等,透過redis的rename機制禁掉命令,或者使用scan的方式漸進式處理。save這些也要禁用。

3.【推薦】合理使用select

redis的多資料庫較弱,使用數字進行區分,很多客戶端支援較差,同時多業務用多資料庫實際還是單執行緒處理,會有干擾。redis支援0-15一共16個庫,看著是分開了,但是其查詢執行緒還是同一個,效率並沒有提升,相反還要去遍歷資料庫會進一步降低效率。如果要拆分,可以將其拆分為不同的redis叢集。

4.【推薦】使用批次操作提高效率

1 原生命令:例如mget、mset。
2 非原生命令:可以使用pipeline提高效率。
但要注意控制一次批次操作的元素個數(例如500以內,實際也和元素位元組數有關)。

注意兩者不同:
1 1. 原生命令是原子操作,pipeline是非原子操作。
2 2. pipeline可以打包不同的命令,原生命令做不到
3 3. pipeline需要客戶端和服務端同時支援。

5.【建議】

Redis事務功能較弱,不建議過多使用,可以用lua替代

三、客戶端使用

1.【推薦】

避免多個應用使用一個Redis例項
正例:不相干的業務拆分,公共資料做服務化。

2.【推薦】

使用帶有連線池的資料庫,可以有效控制連線,同時提高效率,標準使用方式:
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
jedisPoolConfig.setMaxTotal(5);
jedisPoolConfig.setMaxIdle(2);
jedisPoolConfig.setTestOnBorrow(true);

JedisPool jedisPool = new JedisPool(jedisPoolConfig, "192.168.0.60", 6379, 3000, null);

Jedis jedis = null;
try {
    jedis = jedisPool.getResource();
    //具體的命令
    jedis.executeCommand()
} catch (Exception e) {
    logger.error("op key {} error: " + e.getMessage(), key, e);
} finally {
    //注意這裡不是關閉連線,在JedisPool模式下,Jedis會被歸還給資源池。
    if (jedis != null) 
        jedis.close();
}

連線池引數含義:

序號
引數名 含義 預設值 使用建議
1
maxTotal
資源池中最大連線數
8  如下
2
maxIdle
資源池允許最大空閒
的連線數
8 如下
3
minIdle
資源池確保最少空閒
的連線數
0 如下
4 blockWhenExhausted
當資源池用盡後,呼叫者是否要等待。只有當為true時,下
的maxWaitMillis才會生效
true 建議使用預設值
5
maxWaitMillis
當資源池連線用盡後,呼叫者的最大等待時間(單位為毫秒)
-1:表示永不超時
不建議使用預設值
6
testOnBorrow
向資源池借用連線時是否做連線有效性檢測(ping),無效連線會被移除
false
業務量很大時候建議設定為false(多一次ping的開銷)。
7
testOnReturn
向資源池歸還連線時是否做連線有效性檢測(ping),無效連線會被移除
false
業務量很大時候建議設定為false(多一次ping的開銷)。
8
jmxEnabled
是否開啟jmx監控,可用於監控
true
建議開啟,但應用本身也要開啟

最佳化建議:

1)maxTotal:最大連線數,早期的版本叫maxActive 實際上這個是一個很難回答的問題,考慮的因素比較多:

業務希望Redis併發量

客戶端執行命令時間

Redis資源:例如 nodes(例如應用個數) * maxTotal 是不能超過redis的最大連線數 maxclients。

資源開銷:例如雖然希望控制空閒連線(連線池此刻可馬上使用的連線),但是不希望因 為連線池的頻繁釋放建立連線造成不必靠開銷。

以一個例子說明。

假設: 一次命令時間(borrow|return resource + Jedis執行命令(含網路) )的平均耗時約為 1ms,一個連線的QPS大約是1000

業務期望的QPS是50000

那麼理論上需要的資源池大小是50000 / 1000 = 50個。

但事實上這是個理論值,還要考慮到要 比理論值預留一些資源,通常來講maxTotal可以比理論值大一些。

但這個值不是越大越好,一方面連線太多佔用客戶端和服務端資源,另一方面對於Redis這種高 QPS的伺服器,一個大命令的阻塞即使設定再大資源池仍然會無濟於事。

2)maxIdle和minIdle

maxIdle實際上才是業務需要的最大連線數,maxTotal是為了給出餘量,所以maxIdle不要設定 過小,否則會有new Jedis(新連線)開銷。

連線池的最佳效能是maxTotal = maxIdle,這樣就避免連線池伸縮帶來的效能干擾。但是如果 併發量不大或者maxTotal設定過高,會導致不必要的連線資源浪費。一般推薦maxIdle可以設定 為按上面的業務期望QPS計算出來的理論連線數,maxTotal可以再放大一倍。

minIdle(最小空閒連線數),與其說是最小空閒連線數,不如說是"至少需要保持的空閒連線 數",在使用連線的過程中,如果連線數超過了minIdle,那麼繼續建立連線,如果超過了 maxIdle,當超過的連線執行完業務後會慢慢被移出連線池釋放掉。

如果系統啟動完馬上就會有很多的請求過來,那麼可以給redis連線池做預熱,比如快速的建立一 些redis連線,執行簡單命令,類似ping(),快速的將連線池裡的空閒連線提升到minIdle的數量。、

連線池預熱示例程式碼:
List<Jedis> minIdleJedisList = new ArrayList<Jedis>(jedisPoolConfig.getMinIdle());

for (int i = 0; i < jedisPoolConfig.getMinIdle(); i++) {
    Jedis jedis = null;
    try {
        jedis = pool.getResource();
        minIdleJedisList.add(jedis);
        jedis.ping();
    } catch (Exception e) {
        logger.error(e.getMessage(), e);
    } finally {
        //注意,這裡不能馬上close將連線還回連線池,否則最後連線池裡只會建立1個連線。。
     // 這裡是因為每次初始化一個連線之後,如果close,就會將這個連線放入到連線池,然後下次迴圈去連線池那連線還是這一個連線,因為這個for迴圈是序列的。
//jedis.close(); } } //統一將預熱的連線還回連線池 for (int i = 0; i < jedisPoolConfig.getMinIdle(); i++) { Jedis jedis = null; try { jedis = minIdleJedisList.get(i); //將連線歸還回連線池 jedis.close(); } catch (Exception e) { logger.error(e.getMessage(), e); } finally { } }
總之,要根據實際系統的QPS和呼叫redis客戶端的規模整體評估每個節點所使用的連線池大小。

3.【建議】

高併發下建議客戶端新增熔斷功能(例如sentinel、hystrix)

4.【推薦】

設定合理的密碼,如有必要可以使用SSL加密訪問

5.【建議】

Redis對於過期鍵有三種清除策略:

被動刪除:當讀/寫一個已經過期的key時,會觸發惰性刪除策略,直接刪除掉這個過期key

主動刪除:由於惰性刪除策略無法保證冷資料被及時刪掉,所以Redis會定期(預設每100ms)主動淘汰一批已過期的key,這裡的一批只是部分過期key,所以可能會出現部分key已經過期但還沒有被清理掉的情況,導致記憶體並沒有被釋放

當前已用記憶體超過maxmemory限定時,觸發主動清理策略

主動清理策略在Redis 4.0 之前一共實現了 6 種記憶體淘汰策略,在 4.0 之後,又增加了 2 種策略,總共8種:

a) 針對設定了過期時間的key做處理:

  volatile-ttl:在篩選時,會針對設定了過期時間的鍵值對,根據過期時間的先後進行刪除,越早過期的越先被刪除。

  volatile-random:就像它的名稱一樣,在設定了過期時間的鍵值對中,進行隨機刪除。

  volatile-lru:會使用 LRU 演算法篩選設定了過期時間的鍵值對刪除。

  volatile-lfu:會使用 LFU 演算法篩選設定了過期時間的鍵值對刪除。

b) 針對所有的key做處理:

  allkeys-random:從所有鍵值對中隨機選擇並刪除資料。

  allkeys-lru:使用 LRU 演算法在所有資料中進行篩選刪除。

  allkeys-lfu:使用 LFU 演算法在所有資料中進行篩選刪除。

c) 不處理:

  noeviction:不會剔除任何資料,拒絕所有寫入操作並返回客戶端錯誤資訊"(error) OOM command not allowed when used memory",此時Redis只響應讀操作。

LRU 演算法(Least Recently Used,最近最少使用) 淘汰很久沒被訪問過的資料,以最近一次訪問時間作為參考。

LFU 演算法(Least Frequently Used,最不經常使用) 淘汰最近一段時間被訪問次數最少的資料,以次數作為參考。

當存在熱點資料時,LRU的效率很好,但偶發性的、週期性的批次操作會導致LRU命中率急劇下降,快取汙染情況比較嚴重。這時使用LFU可能更好點。 根據自身業務型別,配置好maxmemory-policy(預設是noeviction),推薦使用volatile-lru。如果不設定最大記憶體,當 Redis 記憶體超出實體記憶體限制時,記憶體的資料會開始和磁碟產生頻繁的交換 (swap),會讓 Redis 的效能急劇下降。 當Redis執行在主從模式時,只有主結點才會執行過期刪除策略,然後把刪除操作”del key”同步到從結點刪除資料。

相關文章