STRING
我們會經常打交道的string類型,在redis中擁有廣泛的使用。也是開啟redis數據類型的基礎。
在我最最開始接觸的redis的時候,總是以為字符串類型就是值的類型是字符串。
比如:SET key value
我的理解是value數據類型是stirng類型,現在來看呢,這句話說得不夠具體全面。
-
所有的鍵都是字符串類型
-
字符串類型的值可以是字符串、數字、二進制
這里也就引出了,另一個概念:外部類型和內部類型
外部類型 vs 內部類型
這里的外部類型,就是我們所熟知的:字符串(string)、哈希(hash)、列表(list)、集合(set)、有序結合(zset)等
Q1:那么什么是內部類型呢?
Q2:外部類型和內部類型是什么時候出現的?
Q3:為什么要這樣設計?
我們先來看問題1,可以這樣理解,對外數據結構就像是我們的API,對外提供着一定組織結構的數據。
對內來說,我們可以更換里面的邏輯算法,甚至更換數據存儲方式,比如將Mysql換成Redis.
內部類型其實就是數據存儲的形式。舉現在我們所討論的stirng來說。
string的外部類型就是string,而它對應的數據內部存儲結構分為三種。
int:8個字節的長整形
embstr:<=39個字節的字符串(3.2 版本變成了44)
raw:>39個字節的字符串(3.2 版本變成了44)
所以,string類型會根據當前字符串的長度來決定到底使用哪種內部數據結構。
現在我們再回到問題上:什么是內部類型?
就是數據真正存儲在內存上的數據結構。
其實第二個問題:外部類型和內部類型是什么時候出現的?
這里也算是有答案了,外部類型就是對外公開的數據類型也可以說是API,內部類型根據長度判斷哪種內部結構。
第三個問題:為什么這樣設計?
前后分離,如果有更好地內部數據類型,我們可以替換后面的數據類型,但不影響前面的Api.
還有一點也是根據不同情況,選擇更好地數據結構,節省內存。畢竟是內存數據庫,資源珍貴。
如何查看外部類型和內部類型
查看外部類型:type
127.0.0.1:6999[1]> SET sc sunchong // 對外類型:string
OK
127.0.0.1:6999[1]> type sc
string
127.0.0.1:6999[1]> HSET hsc sun chong // 對外類型:hash
(integer) 1
127.0.0.1:6999[1]> type hsc
hash
127.0.0.1:6999> RPUSH rsc s un ch hong
(integer) 4
127.0.0.1:6999> TYPE rsc
list
查看內部類型:object
int
127.0.0.1:6999[1]> set sc 1234567890123456789 // 對內類型:int
OK
127.0.0.1:6999[1]> STRLEN sc
(integer) 19
127.0.0.1:6999[1]> OBJECT encoding sc
"int"
int -> embstr
(int 8位的長整形,最大存儲十進制位數為19位)
127.0.0.1:6999[1]> set sc 12345678901234567890 // 對內類型:embstr
OK
127.0.0.1:6999[1]> STRLEN sc
(integer) 20
127.0.0.1:6999[1]> OBJECT encoding sc
"embstr"
embstr -> raw
127.0.0.1:6999[1]> set sc 123456789012345678901234567890123456789
OK
127.0.0.1:6999[1]> STRLEN sc
(integer) 39
127.0.0.1:6999[1]> OBJECT encoding sc
"embstr"
127.0.0.1:6999[1]> set sc 12345678901234567890123456789012345678901
OK
127.0.0.1:6999[1]> STRLEN sc
(integer) 41
127.0.0.1:6999[1]> OBJECT encoding sc
"embstr"
額,這里我看《Redis 開發與運維》一書
39字節,embstr 轉raw。寫錯了?
我的本機redis版本是5.0+,這本書是3.0,中間肯定是有了版本更新。
試試看看源碼和提交記錄 (https://github.com/antirez/redis/commit/f15df8ba5db09bdf4be58c53930799d82120cc34#diff-43278b647ec38f9faf284496e22a97d5)
繼續嘗試 embstr -> raw
127.0.0.1:6999[1]> set sc 12345678901234567890123456789012345678901234
OK
127.0.0.1:6999[1]> STRLEN sc
(integer) 44
127.0.0.1:6999[1]> OBJECT encoding sc
"embstr"
127.0.0.1:6999[1]> set sc 123456789012345678901234567890123456789012345 // 對內類型:raw
OK
127.0.0.1:6999[1]> STRLEN sc
(integer) 45
127.0.0.1:6999[1]> OBJECT encoding sc
"raw"
常用命令
set key value [EX seconds] [PX milliseconds] [NX|XX]
-- ex 秒級過期時間
-- px 毫秒級過期時間
-- nx 不存在才能執行成功,類似添加
-- xx 必須存在才能執行成功,類似修改
nx
127.0.0.1:6999[1]> EXISTS bus
(integer) 0
127.0.0.1:6999[1]> SET bus Q xx
(nil)
127.0.0.1:6999[1]> SET bus Q nx
OK
xx
127.0.0.1:6999[1]> EXISTS car
(integer) 0
127.0.0.1:6999[1]> SET car B
OK
127.0.0.1:6999[1]> SET car C nx
(nil)
127.0.0.1:6999[1]> SET car C xx
OK
127.0.0.1:6999[1]> GET car
"C"
setnx / setxx
這兩個命令會逐步棄用
String類型源碼分析
SDS 數據結構
為什么Redis要自己實現一套簡單的動態字符串?
1. 效率
2. 安全(二進制安全:C語言中的字符串已 “\0” 為結束標志。)
3. 擴容
如果說,有一輛車,到站前提前告知車站乘客,本次列車還有多少余座。
此時,如果有個計數器可以計算一下當前坐了多少乘客,同時還有多少空位就好了。
這樣司機師傅就不必每次停車上客前,數數還有多少座位可以坐。可以專心開車。
同樣,Redis SDS 也使用了這樣一些小小的記錄,
使用時候獲取這個記錄,時間復雜度是O(1),效率是很高的。不用每次都去統計。
redis做了這樣的設計:
struct sdshdr {
unsigned int len;
unsigned int free;
char buf[];
};
len 已用字節數
free 未用字節數
buf[] 字符數組
這樣設計有什么好處?
1. 方便統計當前長度等,時間復雜度是O(1)
2. 有了長度這些關鍵屬性,可以不依賴“\0” 終止符。二進制安全。
3. 指針返回的是buf[],這樣可以復用C字符串相關的函數。避免重復造輪子,兼容C字符串操作
4. 前面的len和free以及數組指針buf,內存分配上地址是連續的。所以很容易使用buf地址找到len和free.
我們先來看看,這個數據結構:
問題來了,是否還有優化的空間呢?
這樣問比較籠統。我們思考一種場景:是不是所有的字符串存儲都需要這樣的結構?
到這里,有經驗的你已經想到,所有的情況用沒問題,但是Redis是內存數據庫,
內存是資源,如何在資源上斤斤計較是Redis必須權衡的問題。
現在我們坐下來仔細分析一下:
unsigned int len 可以存的數據范圍是:0 ~ 4294967295 (4 Bytes)
Redis中的字符串長度往往不需要這么大,多大合適呢?
1字節(Byte)? 這樣?
struct sdshdr {
char len;
char free;
char buf[];
};
呀, 1字節是0~255,一般長度的字符串足夠用。
如果真的存儲了1個字節的字符串,len和free加起來也占了兩個字節。
本來數據就1字節大,我為了存數據,額外信息都占2字節。
再優化,只能使用位來存儲長度
假設,我們從全局來看,將字符串長度(小於1KB,1KB,2KB,4KB,8KB)來表示。
對於1字節,至少要拿出3個位,才能覆蓋這5種情況( 2^3=8),那么剩下的5位才能存儲長度。
現在我們已經進入到了Redis5.0 數據結構時代:
struct __attribute__ ((__packed__)) sdshdr5 {
unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
char buf[];
};
3個低位標識類型,5個高位存長度(2^5=32)
說到這,長度大於31('\0'結束符)的字符串,1個字節是存不下的。
我們還是按照之前的邏輯 len和free再結合剛才的按位看長度類型,來看看大於1字節的數據結構:
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* used */
uint8_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
使用了多少(len)、分配了多大(alloc)、長度類型標識(flags) ---- 這些表頭= 1字節+1字節+1字節 ,共3字節
所以Redis對:字符串大小的界限就有了對應的宏定義
#define SDS_TYPE_5 0 // 小於1KB
#define SDS_TYPE_8 1
#define SDS_TYPE_16 2
#define SDS_TYPE_32 3
#define SDS_TYPE_64 4
對應的數據結構就是:
struct __attribute__ ((__packed__)) sdshdr5 {
unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* used */
uint8_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len; /* used */
uint16_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
uint32_t len; /* used */
uint32_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
uint64_t len; /* used */
uint64_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
創建字符串
sds sdsnewlen(const void *init, size_t initlen);
看看注釋,非常明白:
/* Create a new sds string with the content specified by the 'init' pointer
* and 'initlen'.
* If NULL is used for 'init' the string is initialized with zero bytes.
* If SDS_NOINIT is used, the buffer is left uninitialized;
*
* The string is always null-termined (all the sds strings are, always) so
* even if you create an sds string with:
*
* mystring = sdsnewlen("abc",3);
*
* You can print the string with printf() as there is an implicit \0 at the
* end of the string. However the string is binary safe and can contain
* \0 characters in the middle, as the length is stored in the sds header. */
獲取SDS類型
char type = sdsReqType(initlen);
SDS_TYPE_5 一般用於字符串追加,所以還是用8這個。
if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8;
獲取頭長度
int hdrlen = sdsHdrSize(type);
申請內存(頭+數據體+終止符)
sh = s_malloc(hdrlen+initlen+1);
s=數據體buf[]指針
s = (char*)sh+hdrlen;
buf[]指針-1,就找到了長度類型flag
fp = ((unsigned char*)s)-1;
最后綴上結束符,然后返回的是buf[]指針,兼容C語言字符串
s[initlen] = '\0';
return s;
追加字符串
sds sdscatlen(sds s, const void *t, size_t len) {
size_t curlen = sdslen(s); // 原字符串長度
s = sdsMakeRoomFor(s,len); // 調整空間
if (s == NULL) return NULL; // 內存不足
memcpy(s+curlen, t, len); // 追加
sdssetlen(s, curlen+len); // 設置新的長度
s[curlen+len] = '\0'; // 結束標志,兼容C字符串
return s;
}
釋放字符串
void sdsfree(sds s)
直接釋放內存
if (s == NULL) return;
s_free((char*)s-sdsHdrSize(s[-1]));
}
為了避免頻繁申請關釋放內存, 把使用量len重置為0,同時清空數據
void sdsclear(sds s) {
sdssetlen(s, 0);
s[0] = '\0';
}
好處數據可以復用,避免重新申請內存
應用場景
用戶信息
最近用戶中心的訪問壓力極大,數據庫已經扛不住。
我們使用比家里快而且成熟的技術,就是再加一層緩存。
比如:
uid:ui01
username: sunchong
nickname:二中
roletype:01
level:0
需求是:用戶中心的用戶數據,可以用uid拿到,也可以根據username拿到(uid和username 都是唯一不重復的)
我根據uid可以獲取查詢到用戶,也可以根據username獲取到用戶。
首先,使用哈希進行數據的緩存 — HSET user:ui01 key1 value1 key2 value2 key3 value3 ...
127.0.0.1:6999> HSET user:ui01 username sunchong nickname 二中 roletype 01 level 0
(integer) 4
127.0.0.1:6999> HKEYS user:ui01
1) "username"
2) "nickname"
3) "roletype"
4) "level"
然后創建映射關系:
127.0.0.1:6999> SET user:sunchong ui01
OK
127.0.0.1:6999> GET user:sunchong
"ui01"
通過 username 找到主鍵uid,然后根據主鍵獲取用戶信息。
數據量較多時,過期時間設置為一定區間內的隨機數。避免緩存穿透。
接口請求次數
當前我們有對用戶開放的API,用戶充值后使用,使用次數累加,剩余次數遞減。
127.0.0.1:6999> SET user-ui01:times 1000
OK
127.0.0.1:6999> INCR user-ui01:times
(integer) 1001
127.0.0.1:6999> GET user-ui01:times
"1001"
127.0.0.1:6999> DECR user-ui01:times
(integer) 1000
短信驗證碼
就在前幾天,我們剛剛對接了阿里雲短信碼服務。
起初,我自己認為短信驗證碼為了實時性不需要進行實際的緩存處理。
但是完全可以根據實際情況進行設計魂村策略。
為了防止接口的頻繁調用,我們可以像網關一樣進行設置。
現在就有這樣一個需求:1個手機號,1分鍾最多獲取10次驗證碼
SET Catch:Limit:13355222226 1 ex 60 nx
初始化手機號,起始次數是1,默認過期時間60秒
再剩下的就是代碼判斷次數即可。
string redisConn = "127.0.0.1:6378,defaultDatabase=0,prefix=";
using (var redis = new CSRedis.CSRedisClient(redisConn))
{
string key = "Catch:13355222222";
bool inserted = redis.SetAsync(key, 1, 60, CSRedis.RedisExistence.Nx).Result;
if (inserted || redis.IncrByAsync(key).Result <= 10)
{
// 新增或未達上限--發送驗證碼
}
else
{
// 受限
}
}
總結
字符串類型結合命令有很多的應用場景,這個有待去收集和發現。
Redis 比較容易上手,文檔全,代碼整潔高效。
當然更需要我們去深入其運行原理,來更好使用這個工具來服務我們的業務。