Redis學習筆記(三)redis 的鍵管理

Ethan_Wong發表於2022-02-06

Redis 的鍵管理

一、Redis 資料庫管理

Redis 是一個鍵值對(key-value pair)的資料庫伺服器,其資料儲存在 src/server.h/redisDb 中(網上很多帖子說在 redis.h 檔案中,但是 redis 6.x版本目錄中都沒有這個檔案。redisDb 結構應該在 server.h檔案中)

typedef redisServer {
    ....
    
    // Redis資料庫
    redisDb *db;
    
    ....
}

Redis 預設會建立 16 個資料庫,每個資料庫是獨立互不影響。其預設的目標資料庫是 0 號資料庫,可以通過 select 命令來切換目標資料庫。在 redisClient 結構中記錄客戶端當前的目標資料庫:

typedef struct redisClient {

    // 套接字描述符
    int fd;

    // 當前正在使用的資料庫
    redisDb *db;

    // 當前正在使用的資料庫的 id (號碼)
    int dictid;

    // 客戶端的名字
    robj *name;             /* As set by CLIENT SETNAME */

} redisClient;

下面是客戶端和伺服器狀態之間的關係例項,客戶端的目標資料庫目前為 1 號資料庫:

通過修改 redisClient.db 的指標來指向不同資料庫,這也就是 select 命令的實現原理。但是,到目前為止,Redis 仍然沒有可以返回客戶端目標資料庫的命令。雖然在 redis-cli 客戶端中輸入時會顯示:

redis> SELECT 1
Ok
redis[1]> 

但是在其他語言客戶端沒有顯示目標資料庫的號端,所以在頻繁切換資料庫後,會導致忘記目前使用的是哪一個資料庫,也容易產生誤操作。因此要謹慎處理多資料庫程式,必須要執行時,可以先顯示切換指定資料庫,然後再執行別的命令。

二、Redis 資料庫鍵

2.1 資料庫鍵空間

Redis 伺服器中的每一個資料庫是由一個 server.h/redisDb 結構來表示的,其具體結構如下:

typedef struct redisDb {
    //資料庫鍵空間
    dict *dict;                 /* The keyspace for this DB */
    //鍵的過期時間,字典的值為過期事件 UNIX 時間戳
    dict *expires;              /* Timeout of keys with a timeout set */
    //正處於阻塞狀態的鍵
    dict *blocking_keys;        /* Keys with clients waiting for data (BLPOP)*/
    //可以解除阻塞的鍵
    dict *ready_keys;           /* Blocked keys that received a PUSH */
    //正在被 WATCH 命令監視的鍵
    dict *watched_keys;         /* WATCHED keys for MULTI/EXEC CAS */
    //資料庫號端
    int id;                     /* Database ID */
    //資料庫鍵的平均 TTL,統計資訊
    long long avg_ttl;          /* Average TTL, just for stats */
    //
    unsigned long expires_cursor; /* Cursor of the active expire cycle. */
    list *defrag_later;         /* List of key names to attempt to defrag one by one, gradually. */
} redisDb;

鍵空間和使用者所見的資料庫是直接對應:

  • 鍵空間的 key 就是資料庫的 key, 每個 key 都是一個字串物件
  • 鍵空間的 value 是資料庫的 value, 每個 value 可以是字串物件、列表物件和集合物件等等任意一種 Redis 物件

舉個例項,若在空白資料庫中執行一下命令:插入字串物件、列表物件和雜湊物件

# 插入一個字串物件
redis> SET message "hello world"
OK
# 插入包含三個元素的列表物件
redis> RPUSH alphabet "a" "b" "c"
(integer)3
# 插入包含三個元素的雜湊表物件
redis> HSET book name "Redis in Action"
(integer) 1
redis> HSET book author "Josiah L. Carlson"
(integer) 1
redis> HSET book publisher "Manning"
(integer) 1

所以說 redis 對資料的增刪改查是通過操作 dict 來操作 Redis 中的資料

2.2 資料庫鍵的過期

我們可以通過兩種方式設定鍵的生命週期:

  • 通過 EXPIRE 或者 PEXPIRE 命令來為資料庫中的某個鍵設定生存時間(TTL,Time To Live)。在經過 TTL 個生存時間後,伺服器會自動刪除生存時間為0 的鍵。比如:

    redis> set key value
    OK
    # 設定鍵的 TTL 為 5
    redis> EXPIRE key 5
    (integer)1
    
  • 此外,客戶端也可以通過 EXPIREAT 或者PEXPIREAT 命令,為資料庫中的某個鍵設定過期時間(expire time)。過期時間是一個 UNIX 時間戳,當過期時間來臨時,伺服器就會自動從資料庫中刪除這個鍵。比如

    redis> SET key value
    OK
    redis> EXPIREAT key 1377257300
    (integer) 1
    # 當前系統時間
    redis> TIME
    1)"1377257296"
    # 過一段時間後,再查詢key 
    redis> GET key // 1377257300
    (nil)
    

2.2.1 過期時間

redisDb 中的dict *dictdict *expires 字典 分別儲存了資料庫中的鍵和鍵的過期時間,分別叫做鍵空間過期字典

  • 過期字典的鍵是一個指向鍵空間中的某個鍵物件
  • 過期字典的值是一個 long long 型別的整數,這個整數儲存了鍵所指向的資料庫鍵的過期時間

2.3 過期鍵的刪除策略

對於已經過期的資料是如何刪除這些過期鍵的呢?主要有兩種方式:惰性刪除和定期刪除:

1.惰性刪除

是指 Redis 伺服器不主動刪除過期的鍵值,而是通過訪問鍵值時,檢查當前的鍵值是否過期

  • 如果過期則執行刪除並返回 null
  • 沒有過期則正常訪問值資訊給客戶端

惰性刪除的原始碼在 src/db.c/expireIfNeeded 方法中

int expireIfNeeded(redisDb *db, robj *key) {

    // 判斷鍵是否過期

    if (!keyIsExpired(db,key)) return 0;

    if (server.masterhost != NULL) return 1;

    /* 刪除過期鍵 */

    // 增加過期鍵個數

    server.stat_expiredkeys++;

    // 傳播鍵過期的訊息

    propagateExpire(db,key,server.lazyfree_lazy_expire);

    notifyKeyspaceEvent(NOTIFY_EXPIRED,

        "expired",key,db->id);

    // server.lazyfree_lazy_expire 為 1 表示非同步刪除,否則則為同步刪除

    return server.lazyfree_lazy_expire ? dbAsyncDelete(db,key) :

                                         dbSyncDelete(db,key);

}

// 判斷鍵是否過期

int keyIsExpired(redisDb *db, robj *key) {

    mstime_t when = getExpire(db,key);

    if (when < 0) return 0; 

    if (server.loading) return 0;

    mstime_t now = server.lua_caller ? server.lua_time_start : mstime();

    return now > when;

}

// 獲取鍵的過期時間

long long getExpire(redisDb *db, robj *key) {

    dictEntry *de;

    if (dictSize(db->expires) == 0 ||

       (de = dictFind(db->expires,key->ptr)) == NULL) return -1;

    serverAssertWithInfo(NULL,key,dictFind(db->dict,key->ptr) != NULL);

    return dictGetSignedIntegerVal(de);

}

2.定期刪除

與惰性刪除不同,定期刪除是指 Redis 伺服器會每隔一段時間就會檢查一下資料庫,看看是否有過期鍵可以清除,預設情況下,Redis 定期檢查的頻率是每秒掃描 10 次,這個值在 redis.conf 中的 "hz" , 預設是 10 ,可以進行修改。

定期刪除的掃描並不是遍歷所有的鍵值對,這樣的話比較費時且太消耗系統資源。Redis 伺服器採用的是隨機抽取形式,每次從過期字典中,取出 20 個鍵進行過期檢測,過期字典中儲存的是所有設定了過期時間的鍵值對。如果這批隨機檢查的資料中有 25% 的比例過期,那麼會再抽取 20 個隨機鍵值進行檢測和刪除,並且會迴圈執行這個流程,直到抽取的這批資料中過期鍵值小於 25%,此次檢測才算完成。

定期刪除的原始碼在 expire.c/activeExpireCycle 方法中:

void activeExpireCycle(int type) {

    static unsigned int current_db = 0; /* 上次定期刪除遍歷到的資料庫ID */

    static int timelimit_exit = 0;      

    static long long last_fast_cycle = 0; /* 上次執行定期刪除的時間點 */

    int j, iteration = 0;

    int dbs_per_call = CRON_DBS_PER_CALL; // 需要遍歷資料庫的數量

    long long start = ustime(), timelimit, elapsed;

    if (clientsArePaused()) return;

    if (type == ACTIVE_EXPIRE_CYCLE_FAST) {

        if (!timelimit_exit) return;

        // ACTIVE_EXPIRE_CYCLE_FAST_DURATION 快速定期刪除的執行時長

        if (start < last_fast_cycle + ACTIVE_EXPIRE_CYCLE_FAST_DURATION*2) return;

        last_fast_cycle = start;

    }

    if (dbs_per_call > server.dbnum || timelimit_exit)

        dbs_per_call = server.dbnum;

    // 慢速定期刪除的執行時長

    timelimit = 1000000*ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC/server.hz/100;

    timelimit_exit = 0;

    if (timelimit <= 0) timelimit = 1;

    if (type == ACTIVE_EXPIRE_CYCLE_FAST)

        timelimit = ACTIVE_EXPIRE_CYCLE_FAST_DURATION; /* 刪除操作花費的時間 */

    long total_sampled = 0;

    long total_expired = 0;

    for (j = 0; j < dbs_per_call && timelimit_exit == 0; j++) {

        int expired;

        redisDb *db = server.db+(current_db % server.dbnum);

        current_db++;

        do {

            // .......

            expired = 0;

            ttl_sum = 0;

            ttl_samples = 0;

            // 每個資料庫中檢查的鍵的數量

            if (num > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP)

                num = ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP;

            // 從資料庫中隨機選取 num 個鍵進行檢查

            while (num--) {

                dictEntry *de;

                long long ttl;

                if ((de = dictGetRandomKey(db->expires)) == NULL) break;

                ttl = dictGetSignedInteger

                // 過期檢查,並對過期鍵進行刪除

                if (activeExpireCycleTryExpire(db,de,now)) expired++;

                if (ttl > 0) {

                    ttl_sum += ttl;

                    ttl_samples++;

                }

                total_sampled++;

            }

            total_expired += expired;

            if (ttl_samples) {

                long long avg_ttl = ttl_sum/ttl_samples;

                if (db->avg_ttl == 0) db->avg_ttl = avg_ttl;

                db->avg_ttl = (db->avg_ttl/50)*49 + (avg_ttl/50);

            }

            if ((iteration & 0xf) == 0) { /* check once every 16 iterations. */

                elapsed = ustime()-start;

                if (elapsed > timelimit) {

                    timelimit_exit = 1;

                    server.stat_expired_time_cap_reached_count++;

                    break;

                }

            }

            /* 判斷過期鍵刪除數量是否超過 25% */

        } while (expired > ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP/4);

    }

    // .......

}

以上就是Redis 的刪除策略。下面來看一個面試題:

面試題:你知道 Redis 記憶體淘汰策略和鍵的刪除策略的區別嗎?

Redis 記憶體淘汰策略

我們可以通過 config get maxmemory-policy 命令來檢視當前 Redis 的記憶體淘汰策略:

127.0.0.1:6379> config get maxmemory-policy

1) "maxmemory-policy"

2) "noeviction"

當前伺服器設定的是 noeviction 型別的,對於 redis 6.x版本,主要有以下幾種記憶體淘汰策略

  • noeviction:不淘汰任何資料,當記憶體不足時,執行快取新增操作會報錯,它是 Redis 預設記憶體淘汰策略。
  • allkeys-lru:淘汰整個鍵值中最久未使用的鍵值。
  • allkeys-random:隨機淘汰任意鍵值。
  • volatile-lru:淘汰所有設定了過期時間的鍵值中最久未使用的鍵值。
  • volatile-random:隨機淘汰設定了過期時間的任意鍵值。
  • volatile-ttl:優先淘汰更早過期的鍵值。
  • volatile-lfu: 淘汰所有設定了過期時間的鍵值中最少使用的鍵值。
  • alkeys-lfu: 淘汰整個鍵值中最少使用的鍵值

也就是 alkeys 開頭的表示從所有鍵值中淘汰相關資料,而 volatile 表示從設定了過期鍵的鍵值中淘汰資料。

Redis 記憶體淘汰演算法

記憶體淘汰演算法主要分為 LRU 和 LFU 淘汰演算法

LRU(Least Recently Used) 淘汰演算法

是一種常用的頁面置換演算法,LRU 是基於連結串列結構實現,連結串列中的元素按照操作順序從前往後排列。最新操作的鍵會被移動到表頭,當需要進行記憶體淘汰時,只需要刪除連結串列尾部的元素。

Redis 使用的是一種近似 LRU 演算法,目的是為了更好的節約記憶體,給現有的資料結構新增一個額外的欄位,用於記錄此鍵值的最後一次訪問時間。Redis 記憶體淘汰時,會使用隨機取樣的方式來淘汰資料,隨機取5個值,然後淘汰最久沒有使用的資料。

LFU(Least Frequently Used)淘汰演算法

根據總訪問次數來淘汰資料,核心思想是如果資料過去被訪問多次,那麼將來被訪問的頻率也更高

參考資料

《Redis 設計與實現》
https://kaiwu.lagou.com/course/courseInfo.htm?courseId=59#/detail/pc?id=1779

相關文章