折騰Redis之字符串

      字符串是Redis五種基本數據類型中的基礎。同時也是我們在學習編程中接觸最多的一種數據類型。本文將從使用、源碼、編碼三個部分講解此數據類型在Redis中的使用。

字符串

      string是Redis中最簡單的數據結構。Redis中所有的數據結構都是以唯一的key字符串作爲名稱,根據此key獲取value,差異僅在於value的數據結構不同。string使用非常廣泛,最常見的就是存儲用戶信息(json串),再通過相關序列化工具轉爲對應的實體對象。
      在本文的第一部分不介紹string的底層實現,先來看看如何使用吧!完整string命令列表查看:string commands

設置字符串
格式:set <key> <value>。其中value的值可以爲字節串(byte string)、整型和浮點數。

> set name zhangsan
OK

獲取字符串
格式:get <key>

> get name
"zhangsan"

獲取字符串長度
格式:strlen <key>

> strlen name
(integer) 8

獲取子串
格式:getrange <key> start end
      獲取字符串的子串,在Redis2.0之前此命令爲substr,現使用getrange。返回位移爲start(從0開始)和end之間(都包括,而不是像其他語言中的包頭不包尾)的子串。可以使用負偏移量來提供從字符串末尾開始的偏移量。因此-1表示最後一個字符,-2表示倒數第二個,依此類推。該函數通過將結果範圍限制爲字符串的實際長度來處理超出範圍的請求(end設置非常大也是到字符串末尾就截止了)。

127.0.0.1:6379> set mykey "This is a string"
OK
127.0.0.1:6379> getrange mykey 0 3
"This"
127.0.0.1:6379> getrange mykey -3 -1
"ing"
127.0.0.1:6379> getrange mykey 0 -1
"This is a string"
127.0.0.1:6379> getrange mykey 10 10000
"string"

設置子串
格式:setrange <key> offset substr
返回值:修改後字符串的長度。

      從value的整個長度開始,從指定的偏移量覆蓋key處存儲的一部分字符串。如果偏移量大於key處字符串的當前長度,則該字符串將填充零字節以使偏移量適合。不存在的鍵被視爲空字符串,因此此命令將確保它包含足夠大的字符串以能夠將值設置爲offset。
      注意:您可以設置的最大偏移爲229 -1(536870911),因爲Redis字符串限制爲512 MB。如果您需要超出此大小,可以使用多個鍵。

127.0.0.1:6379> set key1 "hello world"
OK
127.0.0.1:6379> setrange key1 6 redis
(integer) 11
127.0.0.1:6379> get key1
"hello redis"
127.0.0.1:6379> setrange key2 6 redis
(integer) 11
127.0.0.1:6379> get key2
"\x00\x00\x00\x00\x00\x00redis"

追加子串
格式:append <key> substr
      如果key已經存在並且是字符串,則此命令將value在字符串末尾附加。如果key不存在,則會創建它並將其設置爲空字符串,因此APPEND在這種特殊情況下 將類似於SET。

127.0.0.1:6379> exists key4
(integer) 0
127.0.0.1:6379> append key4 hello
(integer) 5
127.0.0.1:6379> append key4 world
(integer) 10
127.0.0.1:6379> get key4
"helloworld"

計數
      在使用Redis中我們經常將字符串做爲計數器,使用incr命令進行加一。
格式:incr <key>
返回值:key遞增後的值。
      將存儲的數字key加1。如果key不存在,則在執行操作之前將其設置爲0。如果key包含錯誤類型的值或包含不能表示爲整數的字符串,則返回錯誤。此操作僅限於64位帶符號整數。計數是由範圍的,它不能超過Long.Max,不能低於Long.Min。
過期和刪除
      字符串可以使用del命令進行刪除,也可以使用expire命令設置過期時間,到期自動刪除。我們可以使用ttl命令獲取字符串的壽命(還有多少時間過期)。

格式:del <key1> <key2> ...
返回值:刪除key的個數

127.0.0.1:6379> SET key1 "Hello"
"OK"
127.0.0.1:6379> SET key2 "World"
"OK"
127.0.0.1:6379> DEL key1 key2 key3
(integer) 2 

格式:expire <key> time
返回值:如果設置了超時返回1。如果key不存在返回0。
如何將設置了過期的字符串設置爲永久的呢?
      生存時間可以通過使用DEL命令來刪除整個 key 來移除,或者被SETGETSET命令覆寫(overwrite),這意味着,如果一個命令只是修改一個帶生存時間的 key 的值而不是用一個新的 key 值來代替(replace)它的話,那麼生存時間不會被改變。比如說,對一個 key 執行INCR命令,對一個列表進行LPUSH命令,或者對一個哈希表執行HSET命令,這類操作都不會修改 key 本身的生存時間。
      如果使用RENAME對一個 key 進行改名,那麼改名後的 key 的生存時間和改名前一樣。RENAME 命令的另一種可能是,嘗試將一個帶生存時間的 key 改名成另一個帶生存時間的 another_key ,這時舊的 another_key (以及它的生存時間)會被刪除,然後舊的 key 會改名爲 another_key ,因此,新的 another_key 的生存時間也和原本的 key 一樣。
      使用PERSIST命令可以在不刪除 key 的情況下,移除 key 的生存時間,讓 key 重新成爲一個『持久的』(persistent) key 。

127.0.0.1:6379> expire age 100
(integer) 1
127.0.0.1:6379> ttl age
(integer) 97
127.0.0.1:6379> set age 20
OK
127.0.0.1:6379> ttl age
(integer) -1
127.0.0.1:6379> expire age 100
(integer) 1
127.0.0.1:6379> ttl age
(integer) 98
127.0.0.1:6379> rename age age2
OK
127.0.0.1:6379> ttl age2
(integer) 87
127.0.0.1:6379> expire age 100
(integer) 1
127.0.0.1:6379> ttl age
(integer) 96
127.0.0.1:6379> persist age
(integer) 1
127.0.0.1:6379> ttl age
(integer) -1

源碼分析

      string在Redis的底層實現中到底是什麼呢?
      Redis沒有直接使用C語言傳統的字符串表示(以空字符\0結尾的字符數組),而是自己構建了一種名爲簡單動態字符串(Simple Dynamic String,SDS)的類型,並將SDS作爲Redis的默認字符串表示。

SDS定義

      通過查看sds.h/sdshdr結構表示一個SDS:

struct sdshdr {
    //記錄buf數組中已使用字節的數量
    //等於SDS所保存字符串的長度
    unsigned int len;

    //記錄buf數組中未使用字節的數量
    unsigned int free;

    //字節數組,用於保存字符串
    char buf[];
};

請輸入圖片描述

如圖所示展示了一個SDS示例:

  • free屬性的值爲0,表示這個SDS沒有分配任何未使用空間(沒有空閒空間);
  • len屬性的值爲7,表示這個SDS保存了一個七字節長的字符串;
  • buf屬性是一個char類型的數組,數組的前7個字符分別保存了’C’,‘a’,‘t’,‘w’,‘i’,‘n’,‘g’這七個字符,最後一個字符則保存了空字符’\0’.

      SDS遵循C字符串以空字符結尾的慣例,保存空字符的1字節空間不計算在SDS的len屬性內,並且爲空字符分配額外的1字節空間以及添加空格字符到字符串末尾等操作都是SDS函數自動完成的,對用戶是透明的。

sds sdsnewlen(const void *init, size_t initlen) {
    struct sdshdr *sh;
    //在分配空間時多分配了1字節空間
    if (init) {
        sh = zmalloc(sizeof(struct sdshdr)+initlen+1);
    } else {
        sh = zcalloc(sizeof(struct sdshdr)+initlen+1);
    }
    if (sh == NULL) return NULL;
    sh->len = (int)initlen;                                                    
    sh->free = 0;
    if (initlen && init)
        memcpy(sh->buf, init, initlen);
    //將結尾字符設置爲\0
    sh->buf[initlen] = '\0';
    return (char*)sh->buf;
}

SDS優勢

      爲什麼Redis中需要使用SDS封裝字符串,直接一個char數組不香嗎?

O(1)獲取字符串長度

      由於C字符串不記錄自身的長度,所以爲了獲取一個字符串的長度程序必須遍歷這個字符串,直至遇到’\0’爲止,整個操作的時間複雜度爲O(N)。而我們使用SDS封裝字符串則直接獲取len屬性值即可,時間複雜度爲O(1)。

杜絕緩衝區溢出

      在C語言開發中使用char *strcat(char *dest,const char *src)將src字符串中的內容拼接到dest字符串的末尾。由於C字符串不記錄自身的長度,所有strcat假定用戶在執行此函數時已經爲dest分配了足夠多的內存,可以容納src字符串中的所有內容,而一旦這個假設不成立就會產生緩衝區溢出。
請輸入圖片描述

      與C字符串不同,**SDS的空間分配策略完全杜絕了發生緩衝區溢出的可能性:**當SDS API需要對SDS進行修改是,API會先檢查SDS的空間是否滿足修改所需的要求,如果不滿足,API會自動將SDS的空間擴展至執行修改所需的大小,然後才執行實際的修改操作,所以使用SDS既不需要手動修改SDS的空間大小,也不會出現緩衝區溢出問題。SDS中也有類似strcat的方法,對值爲"Redis"的SDS執行sdscat(s," good")後SDS如下所示:
請輸入圖片描述

      仔細觀察可以發現free屬性值由原來的0變成了12,這個值和len的值相等,這是巧合嗎?當然不是,這和擴容機制有關。查看源碼我們會發現最終分配空間使用sdsMakeRoomFor方法,如下所示:

sds sdsMakeRoomFor(sds s, size_t addlen) {
    struct sdshdr *sh, *newsh;
    size_t free = sdsavail(s);
    size_t len, newlen;
    //空閒空間大於待拼接字符串長度,空間足夠不需要擴容,直接返回
    if (free >= addlen) return s;
    len = sdslen(s);
    sh = (void*) (s-(sizeof(struct sdshdr)));
    newlen = (len+addlen);
    //SDS_MAX_PREALLOC (1024*1024)
    //當字符串長度小於1M時擴容都是加倍擴容(*2)
    if (newlen < SDS_MAX_PREALLOC)
        newlen *= 2;
    else
        //當字符串長度大於等於1M時,每次擴容只會擴容1M的空間
        newlen += SDS_MAX_PREALLOC;
    newsh = zrealloc(sh, sizeof(struct sdshdr)+newlen+1);
    if (newsh == NULL) return NULL;

    newsh->free = (int)(newlen - len);
    return newsh->buf;
}

減少修改字符串時的內存重分配次數

      藉助SDS的free空間SDS實現了空間預分配和惰性空間釋放兩種優化策略。

空間預分配

      藉助空間預分配策略SDS字符串在增長過程中不會頻繁的進行空間分配。是否需要擴容,擴容多大在上一小節中已經介紹了。通過這種分配策略,SDS將連續增長N次字符串所需的內存衝分配次數從必定N次降低爲最多N次。

惰性空間釋放

      空間預分配策略用於優化SDS字符串增長,而惰性空間釋放則用於優化SDS字符串縮短。**當SDS的API需要縮短SDS保存的字符串是,程序並不立即使用內存重分配來回收縮短後多出來的字節,而是使用free屬性將這些字節的數量記錄起來,並等待將來使用。**SDS中使用sdstrim縮短字符串。
請輸入圖片描述

      主要SDS並沒有釋放多出來的5字節空間,而是將這5字節空間作爲未使用空間保留在了SDS裏面,如果後續字符串增長則可以派上用場(可能不需要重分配)。也許你又會有疑問了,這沒真正釋放空間,是否會導致內存泄漏呢?放心SDS爲我們提供了真正釋放SDS未使用空間的方法sdsRemoveFreeSpace

二進制安全

什麼是二進制安全?通俗地講,C語言中,用’\0’表示字符串的結束,如果字符串本身就有’\0’字符,字符串就會被截斷,即非二進制安全;若通過某種機制,保證讀寫字符串時不損害其內容,則是二進制安全。

      C字符串中的字符除了末尾字符爲’\0’外其他字符不能爲空字符,否則會被認爲是字符串結尾(即使實際上不是)。這限制了C字符串只能保存文本數據,而不能保存二進制數據。而SDS使用len屬性的值判斷字符串是否結束,所以不會受’\0’的影響。

兼容部分C字符串函數

      SDS字符串一樣遵循C字符串以空字符結尾的慣例,這是爲了讓那些保存文本數據的SDS可以重用部分<string.h>庫定義的函數。

編碼

      Redis中的每個對象都由一個redisObject結構表示:

typedef struct redisObject {
    // 剛剛好32 bits
    // 對象的類型,字符串/列表/集合/哈希表
    unsigned type:4;
    // 未使用的兩個位
    unsigned notused:2; /* Not used */
    // 編碼的方式,Redis 爲了節省空間,提供多種方式來保存一個數據
    // 譬如:“123456789” 會被存儲爲整數123456789
    unsigned encoding:4;
    // 當內存緊張,淘汰數據的時候用到
    unsigned lru:22; /* lru time (relative to server.lruclock) */
    // 引用計數
    int refcount;
    // 數據指針,8bytes
    void *ptr;
} robj;

      對象的type屬性記錄了對象的類型,這個屬性的值可以是下表中的其中一個:

類型常量 對象名稱 TYPE命令輸出
字符串對象 REDIS_STRING string
列表對象 REDIS_LIST list
哈希對象 REDIS_HASH hash
集合對象 REDIS_SET set
有序集合對象 REDIS_ZSET 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 跳躍表和字典

      字符串對象的編碼可以是int、raw或者embstr。

      需要注意的是,在字符串長度小於等於44時使用embstr編碼存儲,超過44時使用raw編碼存儲。(Redis3.2之前是39)

127.0.0.1:6379> set str1 12345678901234567890123456789012345678901234
OK
127.0.0.1:6379> strlen str1
(integer) 44
127.0.0.1:6379> object encoding str1
"embstr"
127.0.0.1:6379> set str2 123456789012345678901234567890123456789012345
OK
127.0.0.1:6379> strlen str2
(integer) 45
127.0.0.1:6379> object encoding str2
"raw"

embstr vs raw

      embstr編碼是專門用於保存短字符串的一種優化編碼方式,這種編碼和raw編碼一樣,都使用redisObject結構和sdshdr結構來表示字符串對象,但raw對象會調用兩次內存分配函數來創建redisObject結構和sdshdr結構,而embstr編碼則通過調用一次內存分配函數來分配一塊連續的空間,空間中一次包含redisObject和sdshdr兩個結構:
請輸入圖片描述

      embstr編碼的字符串在執行命令時產生的效果和raw編碼的字符串對象執行命令時產生的效果是相同的,但使用embstr編碼的字符串對象來保存短字符串值由以下好處:

  • embstr編碼將創建字符串對象所需的內存分配次數從raw編碼的兩次降低爲一次;
  • 釋放embstr編碼的字符串對象只需調用一次內存釋放函數,而釋放raw編碼的字符串對象需要調用兩次內存釋放函數;
  • 因爲embstr編碼的字符串對象的所有數據都保存在一塊連續的內存裏面,所以這種編碼的字符串對象比起raw編碼的字符串對象能夠更好地利用緩存帶來的優勢。

44?39?

      也許你會疑惑爲什麼是長度超過44後編碼變爲raw?這就需要我們回到redisObject和sdshdr的結構上了。不難發現redisObject對象頭最少需要佔據16字節的空間,SDS對象頭的大小至少爲3(SDS結構在後續多了一個int8 flags)。意味着分配一個字符串的最小空間佔用爲19個字節。
      Redis內存分配器分配內存大小的單位都是2,4,8,16,32,64等等,爲了容納一個完整的embstr對象,jemalloc最少會分配32字節的空間,如果再長點就分配64字節空間。如果超出了64字節Redis則認爲這是一個大字符串,不再使用embstr編碼。所以,當內存分配器分配了64字節空間時字符串最長爲44(64-19-1=44)。
      刨根問底,爲什麼現在是44而原來是39呢?

Before:									 After:
最少佔用:2*4+1=9							2*1+1+1=4
struct sdshdr {                          struct __attribute__ ((__packed__)) sdshdr8 {
    unsigned int len;                    	uint8_t len; /* used */      
    unsigned int free; 						uint8_t gbb ; /* excluding the header and null terminator */
    char buf[];								unsigned char flags; /* 3 lsb of type, 5 unused bits */
};											char buf[];
										 }

      考慮最少佔用,所以只需要看sdshdr8。unsigned int變成了uint8_t。Redis內存優化硬是多出了5字節空間呀!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章