redisObject 以及 對抽象的理解

給新觀衆老爺的開場

大家好,我是弟弟!
最近讀了一遍 黃健宏大佬的 <<Redis 設計與實現>>,對Redis 3.0版本有了一些認識
該書作者有一版添加了註釋的 redis 3.0源碼
👉官方redis的github傳送門
👉黃健宏大佬添加了註釋的 redis 3.0源碼傳送門

網上說Redis代碼寫得很好,爲了加深印象和學習redis大佬的代碼寫作藝術,瞭解工作中使用的redis 命令背後的源碼邏輯,便有了寫博客記錄學習redis源碼過程的想法。

redisObject

看過前幾期的觀衆老爺,都會常常見到一個叫 robj 的結構體
比如在k/v空間中查詢k/v, key是robj類型的指針,返回的value 也是robj類型的指針👇

/*
 * 從數據庫 db 中取出鍵 key 的值(對象)
 *
 * 如果 key 的值存在,那麼返回該值;否則,返回 NULL 。
 */
robj *lookupKey(redisDb *db, robj *key)
{
    // 查找鍵空間
    dictEntry *de = dictFind(db->dict, key->ptr);
    // 節點存在
    if (de)
    {
        // 取出值
        robj *val = dictGetVal(de);
		...
        // 返回值
        return val;
    }
    // 節點不存在
    return NULL;
}

redis的k/v空間裏不是有好多種類型的數據結構嗎?
如果說key都是字符串可以用一個類型來表示,
那鏈表,集合,有序集合,hash表呢?
我們是不是應該對不同的數據結構都寫一套 存取k/v的邏輯呢?

顯然是隻寫一套是最簡單省事的哈 🐶

如果每種數據結構都來一套對應的 存取k/v邏輯,
不僅增加了開發量,而且引入了非常多的冗餘邏輯,
不僅不方便人閱讀,而且系統的混亂程度也會升高,
做新功能擴展一類的開發,代碼會越寫越屎💩

抽象出 redisObject 的好處,拿存取k/v來舉例

  1. 抽象層面相同邏輯的代碼一套就夠了。 比如存取k/v的代碼

    針對不同數據結構來一套特定的crud代碼,這個是沒辦法避免的事情。

    但是對於 k/v 在redis中的存取操作,這是可以抽象出一套代碼的,
    因爲不同數據類型的對象都可以用一個指針來指向他們,
    對於k/v的存取最需要關心的是 key的指針是哪個,value的指針是哪個,
    k/v 的指針指向的結構類型是什麼(類型變量,只是一個數字),編碼方式是什麼。
    而對於 k/v指針指向的具體結構到底長啥樣,對於存取k/v的指針這個操作來說 實際上是不關心的。
    誰要讀 就 根據讀出來的 指針、類型字段和實現方式 自己解析就好了。🙃️

  2. 代碼邏輯精簡,清晰明瞭
    便於開發者自己閱讀,也便於後來的其他開發者閱讀,賞心悅目。

  3. 降低系統混亂程度(墒減),系統各個部分都簡單明瞭時,系統會比較健壯,容易擴展
    因爲簡單嘛,所以相對複雜來說不容易出錯,就比較健壯。

    是否在工作中有這樣一種體驗? 修了一個bug,結果多了好幾個bug😂
    這種問題一部分是因爲系統太混亂,對於修改造成的影響,人在短時間之內沒辦法快速評估/發現所有被影響的地方。只有等上線、搞出一口大鍋之後才發現原來沒改對😂

    人腦有智慧,但接受/處理/輸出 信息的速度,相比於電腦來說差太遠了。
    金字塔原理一書中提到 人大概同一時間接收超過7個信息時,就不太能記全所有信息了。
    如果系統太過複雜,謹慎修bug😂

  4. 因結構簡單帶來了 可以組合/嵌套出複雜結構的能力。
    這種能力要是不通過抽象獲得,無腦寫代碼將是一場噩夢。

    一個hash表裏,可以存放各種類型的value, 甚至可以hash表裏嵌套一個hash表。
    沒錯redis的哈希數據結構就是放在全局的k/v字典裏的,這個全局k/v字典實際上就是一個hash表

  5. 對象引用計數,方便對象的複用與銷燬,節省空間

  6. 記錄對象的訪問信息,以便於內存不足時進行內存淘汰

redisObject的結構定義

redisObject一共有5個字段

  1. 指向實際對象的指針
  2. 實際對象的數據結構類型
  3. 實際對象在一個具體數據結構類型下的編碼類型
  4. 引用計數
  5. lru字段

結構定義如下👇

typedef struct redisObject
{

    // 類型
    unsigned type : 4;

    // 編碼
    unsigned encoding : 4;

    // 對象最後一次被訪問的時間
    unsigned lru : REDIS_LRU_BITS; /* lru time (relative to server.lruclock) */

    // 引用計數
    int refcount;

    // 指向實際值的指針
    void *ptr;

} robj;

redisObject 涵蓋的數據類型有哪些

在redis3.0裏redisObject一共有5中數據類型

/* Object types */
// 對象類型
#define REDIS_STRING 0
#define REDIS_LIST 1
#define REDIS_SET 2
#define REDIS_ZSET 3
#define REDIS_HASH 4

在較新一些的redis6.0裏,redisObject一共有7種類型。

每一種數據類型,在redis內部都有2種及以上的 編碼方式,也可以理解爲實現方式。
爲什麼要這麼搞呢?

主要是考慮到redis作爲一個內存數據庫,在key對應的value比較小時,使用更節省內存的方式存儲。
當key對應的value元素個數或單個元素過大時,會轉換成更爲通用的實現方式。

在之前學習各個數據結構時,會經常發現這樣的情況,再創建各個數據類型時,默認會先以節省內存的編碼方式創建,當滿足一定條件時,會轉換爲更爲通用的編碼方式。

所有的編碼類型如下👇

/* Objects encoding. Some kind of objects like Strings and Hashes can be
 * internally represented in multiple ways. The 'encoding' field of the object
 * is set to one of this fields for this object. */
// 對象編碼
#define REDIS_ENCODING_RAW 0REDIS_STRING)
#define REDIS_ENCODING_INT 1REDIS_STRING)
#define REDIS_ENCODING_HT 2REDIS_SETREDIS_HASH)
#define REDIS_ENCODING_ZIPMAP 3     /* Encoded as zipmap */
#define REDIS_ENCODING_LINKEDLIST 4REDIS_LIST)
#define REDIS_ENCODING_ZIPLIST 5REDIS_LISTREDIS_HASH,REDIS_ZSET)
#define REDIS_ENCODING_INTSET 6REDIS_SET)
#define REDIS_ENCODING_SKIPLIST 7REDIS_ZSET)
#define REDIS_ENCODING_EMBSTR 8REDIS_STRING

數據結構類型判斷

前面說,當從redis的db中存取k/v時,使用了redisObject結構,沒有關心value的具體結構。
那麼當value從redis的db中取出來後,再做相應邏輯之前還是要判斷一下value的數據結構類型,
否則是會有問題的。

比如hget 命令對應的 hgetCommand命令,通過key從db中取出value後,判斷了value是否是hash對象
源碼邏輯如下👇

void hgetCommand(redisClient *c) {
    robj *o;

    if ((o = lookupKeyReadOrReply(c,c->argv[1],shared.nullbulk)) == NULL ||
        checkType(c,o,REDIS_HASH)) return;

    // 取出並返回域的值
    addHashFieldToReply(c, o, c->argv[2]);
}
/*
 * 檢查對象 o 的類型是否和 type 相同:
 *
 *  - 相同返回 0 
 *
 *  - 不相同返回 1 ,並向客戶端回覆一個錯誤
 */
int checkType(redisClient *c, robj *o, int type) {

    if (o->type != type) {
        addReply(c,shared.wrongtypeerr);
        return 1;
    }

    return 0;
}
/*
 * 爲執行讀取操作而從數據庫中查找返回 key 的值。
 *
 * 如果 key 存在,那麼返回 key 的值對象。
 *
 * 如果 key 不存在,那麼向客戶端發送 reply 參數中的信息,並返回 NULL 。
 */
robj *lookupKeyReadOrReply(redisClient *c, robj *key, robj *reply) {

    // 查找
    robj *o = lookupKeyRead(c->db, key);

    // 決定是否發送信息
    if (!o) addReply(c,reply);

    return o;
}

實際上這個 用key在redis的db中取value並判斷類型的操作,在各種數據類型上都是有的

比如 取string

void getCommand(redisClient *c) {
    getGenericCommand(c);
}
int getGenericCommand(redisClient *c) {
    robj *o;
    // 嘗試從數據庫中取出鍵 c->argv[1] 對應的值對象
    // 如果鍵不存在時,向客戶端發送回覆信息,並返回 NULL
    if ((o = lookupKeyReadOrReply(c, c->argv[1], shared.nullbulk)) == NULL) {
        return REDIS_OK;
    }
    // 值對象存在,檢查它的類型
    if (o->type != REDIS_STRING) {
        // 類型錯誤
        addReply(c, shared.wrongtypeerr);
        return REDIS_ERR;
    }
    ...
 }

取list

void lindexCommand(redisClient *c) {

    robj *o = lookupKeyReadOrReply(c,c->argv[1],shared.nullbulk);

    if (o == NULL || checkType(c,o,REDIS_LIST)) return;
    ...
}

其餘類型…省略

將公有邏輯抽象出來,只寫一套代碼。不同的實現方式的具體邏輯再寫不同的代碼。
似乎還不錯🤔

對象引用計數

C語言是沒有GC的,自己申請的內存還得自己釋放。

比如B函數通過A函數得到了一個robj*類型的變量C ,B函數用完之後理所應當的要釋放掉C。
但此時問題就來了,這個C別人有沒有在用呢?如果只是B自己在用那釋放掉就釋放了。
如果有別人在用,這個C還不能馬上釋放。

redis作者搞了一個簡單的對象引用計數,來解決內存複用與銷燬的問題。

  1. 誰要"拷貝"這個redisObject,引用計數就+1
  2. 誰用完了這個redisObject,引用計數就-1
    引用計數爲0時,free掉redisObject佔用的內存空間

舉個源碼例子
比如判斷hash表中的key是否與輸入的key相等
因爲字符串類型底層有多種不同的實現方式,在比較的時候需要統一的一種形式。
getDecodedObject 會返回給定 robj的 REDIS_ENCODING_RAW 編碼形式。

  1. 如果robj 的編碼就是 REDIS_ENCODING_RAW 那麼可以複用該內存,僅增加引用計數即可
  2. 否則,創建一個新的 REDIS_ENCODING_RAW編碼的robj 字符串,引用計數初始爲1
  3. 比對完之後對 getDecodedObject 返回的結構體 減1個引用計數,若引用計數減爲0,則釋放該結構體

非字符串類型的robj會崩潰

int dictEncObjKeyCompare(void *privdata, const void *key1,
        const void *key2)
{
    robj *o1 = (robj*) key1, *o2 = (robj*) key2;
    int cmp;

    if (o1->encoding == REDIS_ENCODING_INT &&
        o2->encoding == REDIS_ENCODING_INT)
            return o1->ptr == o2->ptr;

    o1 = getDecodedObject(o1);
    o2 = getDecodedObject(o2);
    cmp = dictSdsKeyCompare(privdata,o1->ptr,o2->ptr);
    decrRefCount(o1);
    decrRefCount(o2);
    return cmp;
}


/* Get a decoded version of an encoded object (returned as a new object).
 *
 * 以新對象的形式,返回一個輸入對象的解碼版本(RAW 編碼)。
 *
 * If the object is already raw-encoded just increment the ref count. 
 *
 * 如果對象已經是 RAW 編碼的,那麼對輸入對象的引用計數增一,
 * 然後返回輸入對象。
 */
robj *getDecodedObject(robj *o) {
    robj *dec;

    if (sdsEncodedObject(o)) {
        incrRefCount(o);
        return o;
    }

    // 解碼對象,將對象的值從整數轉換爲字符串
    if (o->type == REDIS_STRING && o->encoding == REDIS_ENCODING_INT) {
        char buf[32];

        ll2string(buf,32,(long)o->ptr);
        dec = createStringObject(buf,strlen(buf));
        return dec;

    } else {
        redisPanic("Unknown encoding type");
    }
}

/*
 * 爲對象的引用計數增一
 */
void incrRefCount(robj *o) {
    o->refcount++;
}
/*
 * 爲對象的引用計數減一
 *
 * 當對象的引用計數降爲 0 時,釋放對象。
 */
void decrRefCount(robj *o) {

    if (o->refcount <= 0) redisPanic("decrRefCount against refcount <= 0");

    // 釋放對象
    if (o->refcount == 1) {
        switch(o->type) {
        case REDIS_STRING: freeStringObject(o); break;
        case REDIS_LIST: freeListObject(o); break;
        case REDIS_SET: freeSetObject(o); break;
        case REDIS_ZSET: freeZsetObject(o); break;
        case REDIS_HASH: freeHashObject(o); break;
        default: redisPanic("Unknown object type"); break;
        }
        zfree(o);

    // 減少計數
    } else {
        o->refcount--;
    }
}

lru字段 與 內存淘汰

redisObject中的lru字段記錄了k/v被訪問的 時間 or 頻率 相關信息。
這個lru字段 只佔了24位空間

在內存不足時作爲參考數據,根據淘汰策略進行數據淘汰,以釋放空間。
這個redis的內存淘汰策略有多種方式,可以單獨寫一篇博客了,在這裏先不細說了。🐶

小結

  1. 寫代碼的時候最先想到的就是具體如何實現,這個想法並沒有什麼問題。
    但這是局部視角,並不完整,缺少站在系統整體層面的思考。
    所寫的代碼模塊,在整個系統中是一個什麼定位,與系統中其他模塊有什麼關係。
    所寫代碼模塊本身是否有更好的組織方式。
    系統層面的整體思考 跟 具體功能如何實現 一樣重要。(跑題了🐶)

往期博客回顧

  1. redis服務器的部分啓動過程
  2. GET命令背後的源碼邏輯
  3. redis的基礎數據結構之 sds
  4. redis的基礎數據結構之 list
  5. redis的基礎數據結構 之 ziplist
  6. redis 基礎數據結構之 hash表
  7. redis不穩定字典的遍歷
  8. redis 基礎數據結構 之 集合
  9. redis 基礎數據結構 之 有序集合
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章