Redis物件系統

况况况發表於2024-03-14

我們之前介紹了Redis的各種基本資料型別.
比如SDS字串,連結串列,壓縮連結串列,字典,跳躍表,整數集合等.
但是Redis並不是直接使用它們來構建一個資料庫的,而是又包裝了一層,使用它們構建了物件系統,然後使用這些物件系統來建立資料庫系統.
Redis中主要的物件型別有:
字串物件
列表物件
雜湊物件
集合物件
有序集合物件

相對於上一篇文章寫的那些基礎資料型別,這些物件型別是不是看起來更親近了?這些是我們在使用中能夠直觀感受的資料型別.
文中有編碼方式與基礎資料型別倆種說法,要注意一個編碼方式可能不止使用一個基本資料型別來實現,就比如有序集合物件的skiplist編碼方式就使用了dictzskiplist倆種基本資料型別來實現

並且Redis在這個物件系統的基礎之上還實現了一些額外的功能:

  • 型別檢查和命令多型: 一個物件型別在不同的使用場景下可以使用不同的編碼方式,十分靈活,而這對於客戶端來說是透明的,無感知的.當執行命令之前會.並且對於客戶端的相同命令,服務端可能會因為其物件的使用的編碼方式的不同呼叫不同的函式.
  • 記憶體自動回收: Redis實現了基於引用計數的物件回收系統,當服務端內一個物件沒有人使用之後,會進行記憶體釋放,避免空間的浪費.
  • 共享物件: 我們上面說了有引用計數,那麼同樣,在某些情況下,Redis中的多個鍵可以共享一個物件來節約記憶體
  • 記錄訪問時間: Redis會對物件的上一次的訪問時間進行記錄,如果一個物件長時間沒有被使用,如果服務端啟用了maxmemory,在回收的時候會優先回收這些鍵.

我們接下來就來介紹一下Redis的物件系統.

物件的型別與編碼

我們前面說過,Redis有不同的物件型別,而同一個物件型別在不同的使用場景下能夠使用不同的編碼方式.
這個時候我們可以來看一下Redis物件的定義:


typedef struct redisObject{
    //型別,對應我們客戶端能夠直觀感受到的物件型別
    unsigned type:4;
    //編碼,對應物件所使用的編碼方式
    unsigned encoding:4;
    //指向基本資料型別的物件的指標
    void* ptr;
    //引用計數
    int refCount;
    //最後一次被訪問的時間
    unsigned lru:22
}

//屬性名:整數 (意思是該欄位佔用的bit位數,該數要小於該欄位資料型別佔用的位數,就比如int amount:n 這個n的值就需要小於32)

type屬性,即一個物件的型別的取值有:

型別常量 描述
REDIS_STRING 字串物件
REDIS_LIST 列表物件
REDIS_HASH 雜湊物件
REDIS_SET 集合物件
REDIS_ZSET 有序集合物件

encoding屬性,即一個物件採用的編碼方式有:

編碼常量 描述
REDIS_ENCODING_INT long型別的整數
REDIS_ENCODING_EMBSTR embstr編碼的簡單動態字串
REDIS_ENCODING_RAW 簡單動態字串
REDIS_ENCODING_HT 字典
REDIS_ENCODING_LINKEDLIST 連結串列
REDIS_ENCODING_ZIPLIST 壓縮連結串列
REDIS_ENCODING_INTSET 整數集合
REDIS_ENCODING_SKIPLIST 跳躍表

一個物件型別至少擁有倆個基礎編碼方式的實現,其對應關係有:

那麼我們如果想要知道某個鍵的值對應的資料型別該如何獲取呢?

  • 可以使用OBJECT ENCODING key命令來獲取.

物件型別與對應的編碼

我們前面說過一個物件型別至少有倆個編碼方式的實現,在不同場景下會使用不同的編碼方式.
那麼在什麼場景下會使用什麼樣的編碼方式呢?在何種情況下物件會進行編碼方式的切換呢?這就是我們接下來要討論的問題

字串物件

字串物件的實現有三種: int,embrstr,raw
當存入的字串值是整數,並且可以使用long來表示,字串物件就會使用int
如果存入的字串值長度大於32,並且可以用一個字串來表示,那麼就會使用raw來儲存
如果存入的字串值長度小於32,並且可以用一個字串來表示,那麼就會使用embstr型別來進行儲存.

那麼這就帶來了一個問題,embstr之前我們介紹基本資料型別的時候並沒有說過啊?這又是哪來的?

  • embstrraw同樣都採用redisObject結構和sdshdr結構來作為字串的實現,而embstr是專門對於儲存短字串進行的最佳化,區別就在於:
    當建立儲存embstr的物件時,服務端只會呼叫一次記憶體分配函式來分配一個連續的記憶體空間,也就是redisObject結構體物件與sdshdr結構體物件在記憶體中相鄰,這樣我們在回收它的記憶體空間的時候也只需要呼叫一次記憶體釋放函式
    也因為embstr在記憶體中的位置是一片連續的記憶體空間,也就更好能利用快取的優勢.
    而raw會呼叫倆次記憶體分配函式來分別分配空間,回收空間的時候也會呼叫倆次記憶體釋放函式.

在Redis中的浮點數實際上也是採用embstr和raw來儲存的,程式會先將浮點數轉換成字串再存入資料庫,然後再取出使用的時候再將字串轉換成浮點數.

那麼在何種情況下,物件的編碼方式會進行轉換呢?

  • 如果儲存的值可以用long來表示,則會使用int編碼方式來儲存,如果對該value更新為整數無法表示(需要使用字串來表示)的操作,又或是隻有對字串才能做的操作,比如append,就會先將該值轉換成raw型別,然後再進行操作.
    如果embstr編碼方式的值被更改,那麼就會建立一個raw編碼方式的物件,再進行操作.(因為實際上embstr的函式庫是沒有編寫任何對字串的修改操作的)

列表物件

列表物件的編碼方式有ziplist,linkedlist

前面在介紹壓縮連結串列ziplist的時候可能有人會想:壓縮連結串列的結構不是也挺複雜的,為什麼說它能節省了空間呢?

  • 因為之前在講解linkedlist連結串列的時候,只說了listNode結構體使用value屬性來儲存節點的值,並沒有說節點是如何儲存的,但是實際上,像是一個儲存字串列表的linkedlist連結串列,其節點的value是指向一個字串的redisObject的:

    我們可以想象一下:最外層redisObject的ptr指標指向這個adlist.h/list連結串列,這個連結串列中又有多個listNode連結串列節點,每一個節點中又包括一個redisObject,每一個redisObject又指向一個int或是embstr又或是raw字串,在這其中每一層結構體都有自己的屬性需要儲存,這樣花費空間是不是就多了很多?
  • 但如果是使用ziplist,其結構就會是這樣:

    看起來是不是比linkedlist結構節省許多空間了.

那麼在何種情況下,物件的編碼方式會進行轉換呢?

  • 當列表元素儲存的字串長度都小於64位元組,並且列表的元素數量小於512,就會採用ziplist,否則採用linkedlist,當然這些數值都是可以進行配置更改的.

雜湊物件

雜湊物件的編碼方式有ziplist,hashtable

當使用ziplist作為實現的時候,雜湊的鍵與值緊緊的挨在一起,當要對該雜湊物件新增鍵值對時,會先將該鍵值對的鍵新增到隊尾,然後再將該鍵值對的值也給新增到隊尾.
我們可以想像到,當雜湊物件的編碼方式為ziplist時,其redisObject的ptr指標指向的物件會是這樣:

當使用hashtable作為實現的時,使用dict字典來作為雜湊物件的實現.
這就和我們印象裡的雜湊物件使用一樣了,當要對hashtable編碼方式的雜湊物件進行新增鍵值對時,會將該鍵值對的鍵的字串物件作為鍵,該鍵值對的值的字串物件作為鍵來存入到dict之中:

那麼在何種情況下,物件的編碼方式會進行轉換呢?

  • 當雜湊物件儲存的鍵和值所對應的字串長度都小於64位元組,並且雜湊物件中的元素數量小於512,就會採用ziplist,否則採用hashtable,當然這些數值都是可以進行配置更改的.

集合物件

集合物件的編碼方式有intset,hashtable

當使用intset作為集合物件的編碼方式時,集合物件中的整數都會被存放到整數集合中,就像這樣:

當使用hashtable作為集合物件的編碼方式時,會將集合中的元素作為hashtable中的鍵來儲存,而其鍵對應的值則設定為NULL:

那麼在何種情況下,物件的編碼方式會進行轉換呢?

  • 當集合物件中的元素都是整數並且集合中的元素個數小於512的時候就會採用intset,否則採用hashtable

有序集合

有序集合的編碼方式有ziplist,skiplist

當使用ziplist作為有序集合物件的編碼方式的時候,程式會使用倆個緊挨著的位置存放有序集合的member(成員)和score(分值).當要在有序集合中新增一個新的成員時,會根據這個成員的分值的大小尋找到對應的位置,使用倆個位置來存放這個成員與其分值.

當使用skiplist作為有序集合的編碼方式的時候,這個編碼方式的實現使用zset結構體:

typeof struct zset{
    zskiplist *zsl;
    dict *dict; 
}

我們可以看到zset結構體是由一個跳躍表和一個字典組成的
其中zsl中存放了以分值排序從小到大的成員.skiplist編碼方式使用zskiplist的函式來實現ZRANGE,ZRANK等命令
而dict中存放了鍵為成員,值為分值的鍵值對.這樣ZSCORE命令根據某個成員查詢對應的分值就可以只使用O(1)的時間複雜度了.

看著是不是覺得使用倆個結構體更加複雜了?會不會更浪費記憶體空間?
但其實zskiplist和dict中指向成員和分值的指標都是指向相同的物件,並不會重複建立物件,只額外消耗了儲存指標的記憶體空間,所以問題不大.
並且透過使用使用zskiplist能夠便捷的實現ZRANGE,ZRANK等命令,不需要再花費至少O(NlogN)的時間複雜度和額外的記憶體空間O(N)來暫時存放排序後的成員,而使用dict又可以讓獲取成員分值的時間複雜度降到O(1).我們可以肯定的說這是利大於弊的.

那麼在何種情況下,物件的編碼方式會進行轉換呢?

  • 當有序集合物件中的成員長度都小於64位元組並且序集合物件中的元素個數小於128的時候就會採用ziplist,否則採用skiplist

型別檢查和命令多型

型別檢查:Redis有許多命令鍵對應的值物件型別是有要求的,比如APPEND,SET,STRLEN這些命令就要求值物件的型別是字串.Redis在執行這些有值物件型別限制的命令時,會先對對應鍵的值物件type所記錄的型別進行檢查,如果不符合則向客戶端丟擲錯誤.
這是透過redisObject結構體的type欄位來實現的.

命令多型:為了實現在不同場景下可以使用不同的編碼方式來進行最佳化,在Redis中物件的型別會有不同編碼方式的實現,一個相同的命令,如果物件的編碼方式不同,可能會選擇使用不同的函式來執行.
比如LLEN命令,獲取列表的長度,如果該值物件的編碼方式是ziplist,則會返回壓縮列表的長度,如果是linkedlist,則會返回連結串列的節點數量,這也就是多型,會根據實際編碼方式選擇不同的實現.

記憶體回收.

記憶體回收: 我們知道c和c++都是不具備記憶體回收功能的語言,需要使用者來手動進行回收記憶體空間,但是Redis實現了記憶體回收.
Redis中基於引用計數方式實現了記憶體回收,這是透過在redisObject結構體中refCount屬性來記錄該物件的引用次數來實現的,程式根據一個物件的refCount屬性來判斷這個物件所佔用的記憶體空間是否應該被回收.

當一個物件剛建立的時候,這個物件的refCount欄位的屬性為1,每當有一個新的程式引用它的時候,它的refCount欄位就會增加1
與此相反,每當有一個程式不再引用它的時候,他的refCount就會減1
當這個物件的refCount為0的時候,redis就會去回收這個物件的記憶體空間

我們可以透過OBJECT REFCOUNT key命令來檢視該值物件被引用的次數

物件共享

我們上面也說了,Redis透過引用計數來進行記憶體回收,這也就說明一個物件是可以被多個程式來引用的,這樣服務端就不需要去重複的建立一個相同的物件,也就能夠節省空間.
Redis在啟動的時候就會初始化0~9999的字串物件,用於服務端共享.

要注意的是Redis只對整數值做物件共享最佳化,這主要是因為要進行共享前,我們肯定要判斷新值是否存在於共享物件池中,如果是一個整數字符串物件,比較倆個物件是否相同就只需要O(1)的時間複雜度,但是是字串就會是O(N),如果是一個列表的物件,就會是O(N²),如果要進行整數字符串以外的物件共享對於CPU時間是不友好的.

記錄物件的空轉時間

有時候我們進行Redis記憶體回收的時候,希望那些最近沒有用到的物件佔用的記憶體空間優先被回收.
我們就可以透過redisObject中記錄的lru欄位來判斷哪些物件的空轉時間最長,來優先回收這些物件.
我們可以使用OBJECT IDLETIME key命令來檢視這個值物件的空轉時間.

伺服器需要開啟maxmemory選項,並且設定記憶體回收策略為volatile-lru或是allkeys-lru,在佔用記憶體超過maxmemory值之後,就會優先回收這些空轉時間長的物件了.

相關文章