Redis基礎
1.1 Redis 誕生歷程
1.1.1從一個故事開始
08 年的時候有一個意大利西西里島的小夥子,筆名 antirez(http://invece.org/),創建了一個訪客信息網站 LLOOGG.COM。有的時候我們需要知道網站的訪問情況,比如訪客的 IP、操作系統、瀏覽器、使用的搜索關鍵詞、所在地區、訪問的網頁地址等等。在國內,有很多網站提供了這個功能,比如 CNZZ,百度統計,國外也有谷歌的 GoogleAnalytics。我們不用自己寫代碼去實現這個功能,只需要在全局的 footer 裏面嵌入一段JS 代碼就行了,當頁面被訪問的時候,就會自動把訪客的信息發送到這些網站統計的服務器,然後我們登錄後臺就可以查看數據了。
LLOOGG.COM 提供的就是這種功能,它可以查看最多 10000 條的最新瀏覽記錄。這樣的話,它需要爲每一個網站創建一個列表(List),不同網站的訪問記錄進入到不同的列表。如果列表的長度超過了用戶指定的長度,它需要把最早的記錄刪除(先進先出)。
當 LLOOGG.COM 的用戶越來越多的時候,它需要維護的列表數量也越來越多,這種記錄最新的請求和刪除最早的請求的操作也越來越多。LLOOGG.COM 最初使用的數據庫是 MySQL,可想而知,因爲每一次記錄和刪除都要讀寫磁盤,因爲數據量和併發量太大,在這種情況下無論怎麼去優化數據庫都不管用了。
考慮到最終限制數據庫性能的瓶頸在於磁盤,所以 antirez 打算放棄磁盤,自己去實現一個具有列表結構的數據庫的原型,把數據放在內存而不是磁盤,這樣可以大大地提升列表的 push 和 pop 的效率。antirez 發現這種思路確實能解決這個問題,所以用 C 語言重寫了這個內存數據庫,並且加上了持久化的功能,09 年,Redis 橫空出世了。從最開始只支持列表的數據庫,到現在支持多種數據類型,並且提供了一系列的高級特性,Redis 已經成爲一個在全世界被廣泛使用的開源項目。爲什麼叫 REDIS 呢?它的全稱是 REmote DIctionary Service,直接翻譯過來是遠程字典服務。
從 Redis 的誕生歷史我們看到了,在某些場景中,關係型數據庫並不適合用來存儲
我們的 Web 應用的數據。那麼,關係型數據庫和非關係型數據庫,或者說 SQL 和 NoSQL,
到底有什麼不一樣呢?
1.2 Redis 定位與特性
1.2.1 SQL與NoSQL
在絕大部分時候,我們都會首先考慮用關係型數據庫來存儲我們的數據,比如SQLServer,Oracle,MySQL 等等。
關係型數據庫的特點:
1、它以表格的形式,基於行存儲數據,是一個二維的模式。
2、它存儲的是結構化的數據,數據存儲有固定的模式(schema),數據需要適應表結構。
3、表與表之間存在關聯(Relationship)。
4、大部分關係型數據庫都支持 SQL(結構化查詢語言)的操作,支持複雜的關聯查詢。
5、通過支持事務(ACID 酸)來提供嚴格或者實時的數據一致性。
但是使用關係型數據庫也存在一些限制,比如:
1、要實現擴容的話,只能向上(垂直)擴展,比如磁盤限制了數據的存儲,就要擴大磁盤容量,通過堆硬件的方式,不支持動態的擴縮容。水平擴容需要複雜的技術來實現,比如分庫分表。
2、表結構修改困難,因此存儲的數據格式也受到限制。
3、在高併發和高數據量的情況下,我們的關係型數據庫通常會把數據持久化到磁盤,基於磁盤的讀寫壓力比較大。
爲了規避關係型數據庫的一系列問題,我們就有了非關係型的數據庫,我們一般把它叫做“non-relational”或者“Not Only SQL”。NoSQL 最開始是不提供 SQL 的數據庫的意思,但是後來意思慢慢地發生了變化。
非關係型數據庫的特點:
1、存儲非結構化的數據,比如文本、圖片、音頻、視頻。
2、表與表之間沒有關聯,可擴展性強。
3、保證數據的最終一致性。遵循 BASE(鹼)理論。 Basically Available(基本可用); Soft-state(軟狀態); Eventually Consistent(最終一致性)。
4、支持海量數據的存儲和高併發的高效讀寫。
5、支持分佈式,能夠對數據進行分片存儲,擴縮容簡單。
對於不同的存儲類型,我們又有各種各樣的非關係型數據庫,比如有幾種常見的類型
1、KV 存儲,用 Key Value 的形式來存儲數據。比較常見的有 Redis 和MemcacheDB。
2、文檔存儲,MongoDB。
3、列存儲,HBase。
4、圖存儲,這個圖(Graph)是數據結構,不是文件格式。Neo4j。
5、對象存儲。
6、XML 存儲等等等等。
這個網頁列舉了各種各樣的 NoSQL 數據庫 http://nosql-database.org/ 。
NewSQL 結合了 SQL 和 NoSQL 的特性(例如 PingCAP 的 TiDB)。
1.2.2 Redis 特性
官網介紹:https://redis.io/topics/introduction
中文網站:http://www.redis.cn
硬件層面有 CPU 的緩存;瀏覽器也有緩存;手機的應用也有緩存。我們把數據緩存起來的原因就是從原始位置取數據的代價太大了,放在一個臨時位置存儲起來,取回就可以快一些。
Redis 的特性:
1)更豐富的數據類型
2)進程內與跨進程;單機與分佈式
3)功能豐富:持久化機制、過期策略
4)支持多種編程語言
5)高可用,集羣
1.3 Redis 安裝啓動 參考下面的鏈接
1.4 Redis基本操作
默認有 16 個庫(0-15),可以在配置文件中修改,默認使用第一個 db0。
databases 16
因爲Redis沒有完全把庫隔離,不像數據庫的 database,不適合把不同的庫分配給不同的業務使用。
切換數據庫
select 0
清空當前數據庫
flushdb
清空所有數據庫
flushall
Redis 是字典結構的存儲方式,採用 key-value 存儲。key 和 value 的最大長度限制是 512M(來自官網 https://redis.io/topics/data-types-intro/ )。
鍵的基本操作。
命令參考: http://redisdoc.com/index.html
密碼驗證
auth qazwsx
存值
set java 123
取值
get java
查看所有鍵
keys *
獲取鍵總數
dbsize
查看鍵是否存在 (存在返回1 不存在返回0)
exists java
刪除鍵
del java
重命名鍵
rename java php
查看類型
type php
Redis 一共有幾種數據類型?(注意是數據類型不是數據結構)
官網:https://redis.io/topics/data-types-intro
String、Hash、Set、List、Zset、Hyperloglog、Geo、Streams
1.5 Redis數據類型
最基本也是最常用的數據類型就是 String。set 和 get 命令就是 String 的操作命令。爲什麼叫 Binary-safe strings 呢?
1.5.1 String 字符串
存儲 類型
可以用來存儲字符串、整數、浮點數。
操作命令
設置多個值(批量操作,原子性)如果有重複的 會覆蓋
mset sunda 1314 Java 520
設置值,如果 key 存在,則不成功
setnx sunda
基於此可實現分佈式鎖。用 del key 釋放鎖。
但如果釋放鎖的操作失敗了,導致其他節點永遠獲取不到鎖,怎麼辦?
加過期時間。單獨用 expire 加過期,也失敗了,無法保證原子性,怎麼辦?多參數
set key value [expiration EX seconds|PX milliseconds][NX|XX]
使用參數的方式
set lock1 1 EX 10 NX
(整數)值遞增
incr java
incrby Java 100
(整數)值遞減
decr java
decrby java 100
浮點數增量
set f 2.6
incrbyfloat f 7.3
獲取多個值
mget java php
獲取值長度
strlen Java
字符串追加內容
append sunda good
獲取指定範圍的字符
getrange sunda 0 8
查看對外類型
type sunda
存儲(實現)原理
數據 模型
set hello word 爲例,因爲 Redis 是 KV 的數據庫,它是通過 hashtable 實現的(我們把這個叫做外層的哈希)。所以每個鍵值對都會有一個 dictEntry(源碼位置:dict.h),裏面指向了 key 和 value 的指針。next 指向下一個 dictEntry。
typedef struct dictEntry {
void *key; /* key 關鍵字定義 */
union {
void *val;
uint64_t u64; /* value 定義 */
int64_t s64;
double d;
} v;
struct dictEntry *next; /* 指向下一個鍵值對節點 */
} dictEntry;
key 是字符串,但是 Redis 沒有直接使用 C 的字符數組,而是存儲在自定義的 SDS中。
value 既不是直接作爲字符串存儲,也不是直接存儲在 SDS 中,而是存儲在redisObject 中。實際上五種常用的數據類型的任何一種,都是通過 redisObject 來存儲的。
redisObject
redisObject 定義在 src/server.h 文件中。
typedef struct redisObject {
unsigned type:4; /* 對象的類型,包括:OBJ_STRING、OBJ_LIST、OBJ_HASH、OBJ_SET、OBJ_ZSET */
unsigned encoding:4;/* 具體的數據結構 */ //實際使用的編碼
unsigned lru:LRU_BITS; /* 24 位,對象最後一次被命令程序訪問的時間,與內存回收有關 */
int refcount; /* 引用計數。當 refcount 爲 0 的時候,表示該對象已經不被任何對象引用,則可以進行垃圾回收了
*/
void *ptr; /* 指向對象實際的數據結構 */
} robj;
內部編碼
字符串類型的內部編碼有三種:
1、int,存儲 8 個字節的長整型(long,2^63-1)。
2、embstr, 代表 embstr 格式的 SDS(Simple Dynamic String 簡單動態字符串),存儲小於 44 個字節的字符串。
3、raw,存儲大於 44 個字節的字符串(3.2 版本之前是 39 字節)。爲什麼是 39?
在源代碼object.c裏面定義44個字節
問題1、什麼是SDS
Redis 中字符串的實現。
在 3.2 以後的版本中,SDS 又有多種結構(sds.h):sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64,用於存儲不同的長度的字符串,分別代表 2^5=32byte,2^8=256byte,2^16=65536byte=64KB,2^32byte=4GB
問題2、爲什麼 Redis 要用 SDS 實現字符串?
我們知道,C 語言本身沒有字符串類型(只能用字符數組 char[]實現)。
1、使用字符數組必須先給目標變量分配足夠的空間,否則可能會溢出。
2、如果要獲取字符長度,必須遍歷字符數組,時間複雜度是 O(n)。
3、C 字符串長度的變更會對字符數組做內存重分配。
4、通過從字符串開始到結尾碰到的第一個'\0'來標記字符串的結束,因此不能保存圖片、音頻、視頻、壓縮文件等二進制(bytes)保存的內容,二進制不安全。
SDS 的特點:
1、不用擔心內存溢出問題,如果需要會對 SDS 進行擴容。
2、獲取字符串長度時間複雜度爲 O(1),因爲定義了 len 屬性。
3、通過“空間預分配”( sdsMakeRoomFor)和“惰性空間釋放”,防止多次重分配內存。
4、判斷是否結束的標誌是 len 屬性(它同樣以'\0'結尾是因爲這樣就可以使用 C語言中函數庫操作字符串的函數了),可以包含'\0'
C字符串 | SDS |
---|---|
獲取字符串長度的複雜度爲 O(N) | 獲取字符串長度的複雜度爲 O(1) |
API 是不安全的,可能會造成緩衝區溢出 | API 是安全的,不會早晨個緩衝區溢出 |
修改字符串長度N次必然需要執行N次內存重分配 | 修改字符串長度N次最多需要執行N次內存重分配 |
只能保存文本數據 | 可以保存文本或者二進制數據 |
可以使用所有<string.h>庫中的函數 | 可以使用一部分<string.h>庫中的函數 |
問題三 embstr 和 raw 的區別?
embstr 的使用只分配一次內存空間(因爲 RedisObject 和 SDS 是連續的),而 raw需要分配兩次內存空間(分別爲 RedisObject 和 SDS 分配空間)。
因此與 raw 相比,embstr 的好處在於創建時少分配一次空間,刪除時少釋放一次空間,以及對象的所有數據連在一起,尋找方便。
而 embstr 的壞處也很明顯,如果字符串的長度增加需要重新分配內存時,整個RedisObject 和 SDS 都需要重新分配空間,因此 Redis 中的 embstr 實現爲只讀
問題 4:int 和 embstr 什麼時候轉化爲 raw?
當 int 數 據 不 再 是 整 數 , 或 大 小 超 過 了 long 的 範 圍(2^63-1=9223372036854775807)時,自動轉化爲 embstr。
192.168.2.171:6379> set k1 1
OK
192.168.2.171:6379> append k1 a
(integer) 2
192.168.2.171:6379> object encoding k1
"raw"
問題 5:明明沒有超過閾值,爲什麼變成 raw 了?
192.168.2.171:6379> set k2 a
OK
192.168.2.171:6379> object encoding k2
"embstr"
192.168.2.171:6379> append k2 b
(integer) 2
192.168.2.171:6379> object encoding k2
"raw"
對於 embstr,由於其實現是隻讀的,因此在對 embstr 對象進行修改時,都會先轉化爲 raw 再進行修改。
因此,只要是修改 embstr 對象,修改後的對象一定是 raw 的,無論是否達到了 44個字節。
問題 6:當長度小於閾值時,會還原嗎?
關於 Redis 內部編碼的轉換,都符合以下規律:編碼轉換在 Redis 寫入數據時完成,且轉換過程不可逆,只能從小內存編碼向大內存編碼轉換(但是不包括重新 set)
問題 7:爲什麼要對底層的數據結構進行一層包裝呢?
通過封裝,可以根據對象的類型動態地選擇存儲結構和可以使用的命令,實現節省
空間和優化查詢速度。
應用 場景
緩存
String 類型
例如:熱點數據緩存(例如報表,明星出軌),對象緩存,全頁緩存。
可以提升熱點數據的訪問速度。
數據 共享分佈式
STRING 類型,因爲 Redis 是分佈式的獨立服務,可以在多個應用之間共享
例如:分佈式 Session
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-data-redis</artifactId>
</dependency>
分佈式鎖
STRING 類型 setnx 方法,只有不存在時才能添加成功,返回 true。
http://redisdoc.com/string/set.html 建議用參數的形式
public Boolean getLock(Object lockObject){
jedisUtil = getJedisConnetion();
boolean flag = jedisUtil.setNX(lockObj, 1);
if(flag){
expire(locakObj,10);
}
return flag;
}
public void releaseLock(Object lockObject){
del(lockObj);
}
全局 ID
INT 類型,INCRBY,利用原子性
incrby userid 1000
(分庫分表的場景,一次性拿一段)
計數器
INT 類型,INCR 方法
例如:文章的閱讀量,微博點贊數,允許一定的延遲,先寫入 Redis 再定時同步到
數據庫。
限流
INT 類型,INCR 方法
以訪問者的 IP 和其他信息作爲 key,訪問一次增加一次計數,超過次數則返回 false。
位統計
String 類型的 BITCOUNT(1.6.6 的 bitmap 數據結構介紹)。
字符是以 8 位二進制存儲的。
set k1 a
setbit k1 6 1
setbit k1 7 0
get k1
a 對應的 ASCII 碼是 97,轉換爲二進制數據是 01100001
b 對應的 ASCII 碼是 98,轉換爲二進制數據是 01100010
因爲 bit 非常節省空間(1 MB=8388608 bit),可以用來做大數據量的統計。
例如:在線用戶統計,留存用戶統計
setbit onlineusers 0 1
setbit onlineusers 1 1
setbit onlineusers 2 0
支持按位與、按位或等等操作。
BITOP AND destkey key [key ...] ,對一個或多個 key 求邏輯並,並將結果保存到 destkey 。
BITOP OR destkey key [key ...] ,對一個或多個 key 求邏輯或,並將結果保存到 destkey 。
BITOP XOR destkey key [key ...] ,對一個或多個 key 求邏輯異或,並將結果保存到 destkey 。
BITOP NOT destkey key ,對給定 key 求邏輯非,並將結果保存到 destkey 。
計算出 7 天都在線的用戶
BITOP "AND" "7_days_both_online_users" "day_1_online_users" "day_2_online_users" ... "day_7_online_users"
如果一個對象的 value 有多個值的時候,怎麼存儲?
例如用一個 key 存儲一張表的數據。
序列化?例如 JSON/Protobuf/XML,會增加序列化和反序列化的開銷,並且不能
單獨獲取、修改一個值。
可以通過 key 分層的方式來實現,例如:
mset student:1:sno 16666 student:1:sname 孫達 student:1:company 騰訊
獲取值的時候一次獲取多個值:
mget student:1:sno student:1:sname student:1:company
缺點:key 太長,佔用的空間太多。有沒有更好的方式?
1.5.2 Hash
存儲 類型
包含鍵值對的無序散列表。value 只能是字符串,不能嵌套其他類型。
同樣是存儲字符串,Hash 與 String 的主要區別?
1、把所有相關的值聚集到一個 key 中,節省內存空間
2、只使用一個 key,減少 key 衝突
3、當需要批量獲取值的時候,只需要使用一個命令,減少內存/IO/CPU 的消耗
Hash 不適合的場景:
1、Field 不能單獨設置過期時間
2、沒有 bit 操作
3、需要考慮數據量分佈的問題(value 值非常大的時候,無法分佈到多個節點)
操作命令
hset h1 f 6
hset h1 e 5
hmset h1 a 1 b 2 c 3 d 4
hget h1 a
hmget h1 a b c d
hkeys h1
hvals h1
hgetall h1
key 操作
hget exists h1
hdel h1
hlen h1
存儲(實現)原理
Redis 的 Hash 本身也是一個 KV 的結構,類似於 Java 中的 HashMap。
外層的哈希(Redis KV 的實現)只用到了 hashtable。當存儲 hash 數據類型時,
我們把它叫做內層的哈希。內層的哈希底層可以使用兩種數據結構實現:
ziplist:OBJ_ENCODING_ZIPLIST(壓縮列表)
hashtable:OBJ_ENCODING_HT(哈希表)
ziplist 壓縮 列表
ziplist 壓縮列表是什麼?
/* ziplist.c 源碼頭部註釋 */
/* The ziplist is a specially encoded dually linked list that is designed
* to be very memory efficient. It stores both strings and integer values,
* where integers are encoded as actual integers instead of a series of
* characters. It allows push and pop operations on either side of the list
* in O(1) time. However, because every operation requires a reallocation of
* the memory used by the ziplist, the actual complexity is related to the
* amount of memory used by the ziplist.
*/
ziplist 是一個經過特殊編碼的雙向鏈表,它不存儲指向上一個鏈表節點和指向下一
個鏈表節點的指針,而是存儲上一個節點長度和當前節點長度,通過犧牲部分讀寫性能,
來換取高效的內存空間利用率,是一種時間換空間的思想。只用在字段個數少,字段值
小的場景裏面。
ziplist 的內部結構?
ziplist.c 源碼第 16 行的註釋:
<zlbytes> <zltail> <zllen> <entry> <entry> ... <entry> <zlend>
typedef struct zlentry {
unsigned int prevrawlensize; /* 上一個鏈表節點佔用的長度 */
unsigned int prevrawlen; /* 存儲上一個鏈表節點的長度數值所需要的字節數 */
unsigned int lensize; /* 存儲當前鏈表節點長度數值所需要的字節數 */
unsigned int len; /* 當前鏈表節點佔用的長度 */
unsigned int headersize; /* 當前鏈表節點的頭部大小(prevrawlensize + lensize),即非數據域的大小 */
unsigned char encoding; /* 編碼方式 */
unsigned char *p; /* 壓縮鏈表以字符串的形式保存,該指針指向當前節點起始位置 */
} zlentry;
編碼 encoding(ziplist.c 源碼第 204 行)
define ZIP_STR_06B (0 << 6) //長度小於等於 63 字節
define ZIP_STR_14B (1 << 6) //長度小於等於 16383 字節
define ZIP_STR_32B (2 << 6) //長度小於等於 4294967295 字節
問題:什麼時候使用 ziplist 存儲?
當 hash 對象同時滿足以下兩個條件的時候,使用 ziplist 編碼:
1)所有的鍵值對的健和值的字符串長度都小於等於 64byte(一個英文字母一個字節);
2)哈希對象保存的鍵值對數量小於 512 個。
在redis.conf配置文件
hash-max-ziplist-value 64 // ziplist 中最大能存放的值長度
hash-max-ziplist-entries 512 // ziplist 中最多能存放的 entry 節點數量
在源代碼t_hash.c
if (hashTypeLength(o) > server.hash_max_ziplist_entries)
hashTypeConvert(o, OBJ_ENCODING_HT);
/*源碼位置: t_hash.c,當字段值長度過大,轉爲 HT */
for (i = start; i <= end; i++) {
if (sdsEncodedObject(argv[i]) && sdslen(argv[i]->ptr) > server.hash_max_ziplist_value){
hashTypeConvert(o, OBJ_ENCODING_HT);
break;
}
}
一個哈希對象超過配置的閾值(鍵和值的長度有>64byte,鍵值對個數>512 個)時,會轉換成哈希表(hashtable)。
hashtable (dict)
在 Redis 中,hashtable 被稱爲字典(dictionary),它是一個數組+鏈表的結構。
源碼位置:dict.h
前面我們知道了,Redis 的 KV 結構是通過一個 dictEntry 來實現的。
Redis 又對 dictEntry 進行了多層的封裝。
typedef struct dictEntry {
void *key; /* key 關鍵字定義 */
union {
void *val;
uint64_t u64; /* value 定義 */
int64_t s64;
double d;
} v;
struct dictEntry *next; /* 指向下一個鍵值對節點 */
} dictEntry;
dictEntry 放到了 dictht(hashtable 裏面):
/* This is our hash table structure. Every dictionary has two of this as we
* implement incremental rehashing, for the old to the new table. */
typedef struct dictht {
dictEntry **table; /* 哈希表數組 */
unsigned long size; /* 哈希表大小 */
unsigned long sizemask; /* 掩碼大小,用於計算索引值。總是等於 size-1 */
unsigned long used; /* 已有節點數 */
} dictht;
ht 放到了 dict 裏面:
typedef struct dict {
dictType *type; /* 字典類型 */
void *privdata; /* 私有數據 */
dictht ht[2]; /* 一個字典有兩個哈希表 */
long rehashidx; /* rehash 索引 */
unsigned long iterators; /* 當前正在使用的迭代器數量 */
} dict;
從最底層到最高層 dictEntry——dictht——dict——OBJ_ENCODING_HT
總結:哈希的存儲結構
注意:dictht 後面是 NULL 說明第二個 ht 還沒用到。dictEntry*後面是 NULL 說明沒有 hash 到這個地址。dictEntry 後面是NULL 說明沒有發生哈希衝突。
應用 場景
String
String 可以做的事情,Hash 都可以做。
存儲對象類型的數據
比如對象或者一張表的數據,比 String 節省了更多 key 的空間,也更加便於集中管理。
購物車
key:用戶 id;field:商品 id;value:商品數量。
+1:hincr。-1:hdecr。刪除:hdel。全選:hgetall。商品數:hlen。
問題:爲什麼要定義兩個哈希表呢?ht[2]
redis 的 hash 默認使用的是 ht[0],ht[1]不會初始化和分配空間。
哈希表 dictht 是用鏈地址法來解決碰撞問題的。在這種情況下,哈希表的性能取決於它的大小(size 屬性)和它所保存的節點的數量(used 屬性)之間的比率:
比率在 1:1 時(一個哈希表 ht 只存儲一個節點 entry),哈希表的性能最好;
如果節點數量比哈希表的大小要大很多的話(這個比例用 ratio 表示,5 表示平均一個 ht 存儲 5 個 entry),那麼哈希表就會退化成多個鏈表,哈希表本身的性能優勢就不再存在。
在這種情況下需要擴容。Redis 裏面的這種操作叫做 rehash。
rehash 的步驟:
1、爲字符 ht[1]哈希表分配空間,這個哈希表的空間大小取決於要執行的操作,以及 ht[0]當前包含的鍵值對的數量。擴展:ht[1]的大小爲第一個大於等於 ht[0].used*2。
2、將所有的 ht[0]上的節點 rehash 到 ht[1]上,重新計算 hash 值和索引,然後放入指定的位置。
3、當 ht[0]全部遷移到了 ht[1]之後,釋放 ht[0]的空間,將 ht[1]設置爲 ht[0]表,並創建新的 ht[1],爲下次 rehash 做準備。
問題:什麼時候觸發擴容?
static int dict_can_resize = 1;
static unsigned int dict_force_resize_ratio = 5;
ratio = used / size,已使用節點與字典大小的比例
dict_can_resize 爲 1 並且 dict_force_resize_ratio 已使用節點數和字典大小之間的比率超過 1:5,觸發擴容
擴容判斷 _dictExpandIfNeeded(源碼 dict.c)
if (d->ht[0].used >= d->ht[0].size && (dict_can_resize ||d->ht[0].used/d->ht[0].size > dict_force_resize_ratio))
{
return dictExpand(d, d->ht[0].used*2);
}
return DICT_OK;
擴容方法 dictExpand(源碼 dict.c)
int dictExpand(dict *d, unsigned long size)
{
/* the size is invalid if it is smaller than the number of
* elements already inside the hash table */
if (dictIsRehashing(d) || d->ht[0].used > size)
return DICT_ERR;
dictht n; /* the new hash table */
unsigned long realsize = _dictNextPower(size);
/* Rehashing to the same table size is not useful. */
if (realsize == d->ht[0].size) return DICT_ERR;
/* Allocate the new hash table and initialize all pointers to NULL */
n.size = realsize;
n.sizemask = realsize-1;
n.table = zcalloc(realsize*sizeof(dictEntry*));
n.used = 0;
/* Is this the first initialization? If so it's not really a rehashing
* we just set the first hash table so that it can accept keys. */
if (d->ht[0].table == NULL) {
d->ht[0] = n;
return DICT_OK;
}
/* Prepare a second hash table for incremental rehashing */
d->ht[1] = n;
d->rehashidx = 0;
return DICT_OK;
}
縮容: server.c
int htNeedsResize(dict *dict) {
long long size, used;
size = dictSlots(dict);
used = dictSize(dict);
return (size > DICT_HT_INITIAL_SIZE &&
(used*100/size < HASHTABLE_MIN_FILL));
}
1.5.3 List 列表
存儲 類型
存儲有序的字符串(從左到右),元素可以重複。可以充當隊列和棧的角色。
操作命令
元素增減:
lpush queue a
lpush queue b c
rpush queue d e
lpop queue
rpop queue
blpop queue
brpop queue
取值
lindex queue 0
lrange queue 0 -1
存儲(實現)原理
在早期的版本中,數據量較小時用 ziplist 存儲,達到臨界值時轉換爲 linkedlist 進行存儲,分別對應 OBJ_ENCODING_ZIPLIST 和 OBJ_ENCODING_LINKEDLIST 。
3.2 版本之後,統一用 quicklist 來存儲。quicklist 存儲了一個雙向鏈表,每個節點都是一個 ziplist。
quicklist
quicklist(快速列表)是 ziplist 和 linkedlist 的結合體。
quicklist.h,head 和 tail 指向雙向列表的表頭和表尾
typedef struct quicklist {
quicklistNode *head; /* 指向雙向列表的表頭 */
quicklistNode *tail; /* 指向雙向列表的表尾 */
unsigned long count; /* 所有的 ziplist 中一共存了多少個元素 */
unsigned long len; /* 雙向鏈表的長度,node 的數量 */
int fill : 16; /* fill factor for individual nodes */
unsigned int compress : 16; /* 壓縮深度,0:不壓縮; */
} quicklist;
redis.conf 相關參數:
參數 | 含義 |
---|---|
list-max-ziplist-size(fill) | 正數表示單個 ziplist 最多所包含的 entry 個數。 負數代表單個 ziplist 的大小,默認 8k。 -1:4KB;-2:8KB;-3:16KB;-4:32KB;-5:64KB |
list-compress-depth(compress) | 壓縮深度,默認是 0。 1:首尾的 ziplist 不壓縮;2:首尾第一第二個 ziplist 不壓縮,以此類推 |
quicklistNode 中的*zl 指向一個 ziplist,一個 ziplist 可以存放多個元素
typedef struct quicklistNode {
struct quicklistNode *prev; /* 前一個節點 */
struct quicklistNode *next; /* 後一個節點 */
unsigned char *zl; /* 指向實際的 ziplist */
unsigned int sz; /* 當前 ziplist 佔用多少字節 */
unsigned int count : 16; /* 當前 ziplist 中存儲了多少個元素,佔 16bit(下同),最大 65536 個 */
unsigned int encoding : 2; /* 是否採用了 LZF 壓縮算法壓縮節點,1:RAW 2:LZF */
unsigned int container : 2; /* 2:ziplist,未來可能支持其他結構存儲 */
unsigned int recompress : 1; /* 當前 ziplist 是不是已經被解壓出來作臨時使用 */
unsigned int attempted_compress : 1; /* 測試用 */
unsigned int extra : 10; /* 預留給未來使用 */
} quicklistNode;
ziplist 的結構前面已經說過了,不再重複。
應用 場景
用戶 消息時間線 timeline
因爲 List 是有序的,可以用來做用戶時間線
消息 隊列
List 提供了兩個阻塞的彈出操作:BLPOP/BRPOP,可以設置超時時間。
BLPOP:BLPOP key1 timeout 移出並獲取列表的第一個元素, 如果列表沒有元素會阻塞列表直到等待超時或發現可彈出元素爲止。
BRPOP:BRPOP key1 timeout 移出並獲取列表的最後一個元素, 如果列表沒有元素會阻塞列表直到等待超時或發現可彈出元素爲止。
隊列:先進先出:rpush blpop,左頭右尾,右邊進入隊列,左邊出隊列。
棧:先進後出:rpush brpop
1.5.4 Set 集合
存儲類型
String 類型的無序集合,最大存儲數量 2^32-1(40 億左右)。
操作命令
添加一個或者多個元素
sadd myset a b c d e f g
獲取所有元素
smembers myset
統計元素個數
scard myset
隨機獲取一個元素
srandmember key
隨機彈出一個元素
spop myset
移除一個或者多個元素
srem myset d e f
查看元素是否存在
sismember myset a
存儲(實現)原理
Redis 用 intset 或 hashtable 存儲 set。如果元素都是整數類型,就用 inset 存儲。如果不是整數類型,就用 hashtable(數組+鏈表的存來儲結構)。
問題:KV 怎麼存儲 set 的元素?key 就是元素的值,value 爲 null。
如果元素個數超過 512 個,也會用 hashtable 存儲。
應用 場景
抽獎
隨機獲取元素
spop myset
點贊、簽到、打卡
這條微博的 ID 是 t1001,用戶 ID 是 u3001。
用 like:t1001 來維護 t1001 這條微博的所有點贊用戶。
點讚了這條微博:sadd like:t1001 u3001
取消點贊:srem like:t1001 u3001
是否點贊:sismember like:t1001 u3001
點讚的所有用戶:smembers like:t1001
點贊數:scard like:t1001
比關係型數據庫簡單許多。
商品 標籤
用 tags:i5001 來維護商品所有的標籤。
sadd tags:i5001 畫面清晰細膩
sadd tags:i5001 真彩清晰顯示屏
sadd tags:i5001 流暢至極
商品篩選
獲取差集
sdiff set1 set2
獲取交集( intersection )
sinter set1 set2
獲取並集
sunion set1 set2
Phone11 上市了。
sadd brand:apple iPhone11
sadd brand:ios iPhone11
sad screensize:6.0-6.24 iPhone11
sad screentype:lcd iPhone11
篩選商品,蘋果的,iOS 的,屏幕在 6.0-6.24 之間的,屏幕材質是 LCD 屏幕
sinter brand:apple brand:ios screensize:6.0-6.24 screentype:lcd
1.5.5 ZSet 有序集合
存儲 類型
sorted set,有序的 set,每個元素有個 score。
score 相同時,按照 key 的 ASCII 碼排序。
數據結構對比:
數據 結構 | 是否允許 重複元素 | 是否有序 | 有序實現方式 |
---|---|---|---|
列表 list | 是 | 是 | 索引下標 |
集合 set | 否 | 否 | 無 |
有序集合 zset | 否 | 是 | 分值 score |
操作命令
添加元素
zadd myzset 10 java 20 php 30 ruby 40 cpp 50 python
獲取全部元素
zrange myzset 0 -1 withscores
zrevrange myzset 0 -1 withscores
根據分值區間獲取元素
zrangebyscore myzset 20 30
移除元素
也可以根據 score rank 刪除
zrem myzset php cpp
統計元素個數
zcard myzset
分值遞增
zincrby myzset 5 python
根據分值統計個數
zcount myzset 20 60
獲取元素 rank
zrank myzset java
獲取元素 score
zsocre myzset java
也有倒序的 rev 操作(reverse)
存儲(實現)原理
同時滿足以下條件時使用 ziplist 編碼:
元素數量小於 128 個
所有 member 的長度都小於 64 字節
在 ziplist 的內部,按照 score 排序遞增來存儲。插入的時候要移動之後的數據。
在 redis.conf 參數
zset-max-ziplist-entries 128
zset-max-ziplist-value 64
超過閾值之後,使用 skiplist+dict 存儲
問題:什麼是 skiplist?
我們先來看一下有序鏈表:
在這樣一個鏈表中,如果我們要查找某個數據,那麼需要從頭開始逐個進行比較,直到找到包含數據的那個節點,或者找到第一個比給定數據大的節點爲止(沒找到)。也就是說,時間複雜度爲 O(n)。同樣,當我們要插入新數據的時候,也要經歷同樣的查找過程,從而確定插入位置。
而二分查找法只適用於有序數組,不適用於鏈表。
假如我們每相鄰兩個節點增加一個指針(或者理解爲有三個元素進入了第二層),讓指針指向下下個節點。
這樣所有新增加的指針連成了一個新的鏈表,但它包含的節點個數只有原來的一半(上圖中是 7, 19, 26)。在插入一個數據的時候,決定要放到那一層,取決於一個算法
(在 redis 中 t_zset.c 有一個 zslRandomLevel 這個方法)。
現在當我們想查找數據的時候,可以先沿着這個新鏈表進行查找。當碰到比待查數據大的節點時,再回到原來的鏈表中的下一層進行查找。比如,我們想查找 23,查找的路徑是沿着下圖中標紅的指針所指向的方向進行的:
- 23 首先和 7 比較,再和 19 比較,比它們都大,繼續向後比較。
- 但 23 和 26 比較的時候,比 26 要小,因此回到下面的鏈表(原鏈表),與 22比較。
- 23 比 22 要大,沿下面的指針繼續向後和 26 比較。23 比 26 小,說明待查數據 23 在原鏈表中不存在
在這個查找過程中,由於新增加的指針,我們不再需要與鏈表中每個節點逐個進行比較了。需要比較的節點數大概只有原來的一半。這就是跳躍表。
爲什麼不用 AVL 樹或者紅黑樹?因爲 skiplist 更加簡潔。
源碼:server.h
typedef struct zskiplistNode {
sds ele; /* zset 的元素 */
double score;/* 分值 */
struct zskiplistNode *backward; /* 後退指針 */
struct zskiplistLevel {
struct zskiplistNode *forward; /* 前進指針,對應 level 的下一個節點 */
unsigned long span; /* 從當前節點到下一個節點的跨度(跨越的節點數) */
} level[]; /* 層 */
} zskiplistNode;
typedef struct zskiplist {
struct zskiplistNode *header, *tail; /* 指向跳躍表的頭結點和尾節點 */
unsigned long length;/* 跳躍表的節點數 */
int level;/* 最大的層數 */
} zskiplist;
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
隨機獲取層數的函數:
源代碼 t_zset.c
/* Returns a random level for the new skiplist node we are going to create.
* The return value of this function is between 1 and ZSKIPLIST_MAXLEVEL
* (both inclusive), with a powerlaw-alike distribution where higher
* levels are less likely to be returned. */
int zslRandomLevel(void) {
int level = 1;
while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
level += 1;
return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}
應用 場景
排行榜
d 爲 6001 的新聞點擊數加 1:zincrby hotNews:20190926 1 n6001
獲取今天點擊最多的 15 條:zrevrange hotNews:20190926 0 15 withscores
1.5.6 其他 數據結構
https://redis.io/topics/data-types-intro
BitMaps
Bitmaps 是在字符串類型上面定義的位操作。一個字節由 8 個二進制位組成。
set k1 a
獲取 value 在 offset 處的值(a 對應的 ASCII 碼是 97,轉換爲二進制數據是 01100001)
getbit k1 0
修改二進制數據(b 對應的 ASCII 碼是 98,轉換爲二進制數據是 01100010)
setbit k1 6 1
setbit k1 7 0
get k1
統計二進制位中 1 的個數
bitcount k1
獲取第一個 1 或者 0 的位置
bitpos k1 1
bitpos k1 0
BITOP 命令支持 AND 、 OR 、 NOT 、 XOR 這四種操作中的任意一種參數:
BITOP AND destkey srckey1 … srckeyN ,對一個或多個 key 求邏輯與,並將結果保存到 destkey
BITOP OR destkey srckey1 … srckeyN,對一個或多個 key 求邏輯或,並將結果保存到 destkey
BITOP XOR destkey srckey1 … srckeyN,對一個或多個 key 求邏輯異或,並將結果保存到 destkey
BITOP NOT destkey srckey,對給定 key 求邏輯非,並將結果保存到 destkey
應用場景:
用戶訪問統計
在線用戶統計
Hyperloglogs
Hyperloglogs:提供了一種不太準確的基數統計方法,比如統計網站的 UV,存在
一定的誤差。HyperLogLogTest.java
Streams
5.0 推出的數據類型。支持多播的可持久化的消息隊列,用於實現發佈訂閱功能,借
鑑了 kafka 的設計。
1.5.7 總結
數據 結構總結
對象 | 對象 type 屬性 值 | type 命令輸出 | 底層可能的存儲結構 | object encoding |
---|---|---|---|---|
字符串對象 | OBJ_STRING | string | OBJ_ENCODING_INT OBJ_ENCODING_EMBSTR OBJ_ENCODING_RAW |
int embstr raw |
列表對象 | OBJ_LIST | list | OBJ_ENCODING_QUICKLIST | quicklist |
哈希對象 | OBJ_HASH | hash | OBJ_ENCODING_ZIPLIST OBJ_ENCODING_HT |
ziplist hashtable |
集合對象 | OBJ_SET | set | OBJ_ENCODING_INTSET OBJ_ENCODING_HT |
intset hashtable |
有序集合對象 | OBJ_ZSET | zset | OBJ_ENCODING_ZIPLIST OBJ_ENCODING_SKIPLIST |
ziplist skiplist(包含 ht) |
編碼 轉換總結
對象 | 原始編碼 | 升級編碼 | |
---|---|---|---|
字符串對象 | INT 整數並且小於 long 2^63-1 |
embstr 超過 44 字節,被修改 |
raw |
哈希對象 | ziplist 鍵和值的長度小於 64byte,鍵值對個數不 超過 512 個,同時滿足 |
hashtable |
|
列表對象 | quicklist | ||
集合對象 | intset 元素都是整數類型,元素個數小於 512 個, 同時滿足 |
hashtable |
|
有序集合對象 | ziplist 元素數量不超過 128 個,任何一個 member 的長度小於 64 字節,同時滿足。 |
skiplist |
2.0問答
1 Redis 爲什麼要自己實現一個 SDS ?
問題2、爲什麼 Redis 要用 SDS 實現字符串?
C 語言本身沒有字符串類型(只能用字符數組 char[]實現)。
1、使用字符數組必須先給目標變量分配足夠的空間,否則可能會溢出。
2、如果要獲取字符長度,必須遍歷字符數組,時間複雜度是 O(n)。
3、C 字符串長度的變更會對字符數組做內存重分配。
4、通過從字符串開始到結尾碰到的第一個'\0'來標記字符串的結束,因此不能保存圖片、音頻、視頻、壓縮文件等二進制(bytes)保存的內容,二進制不安全。
SDS 的特點:
1、不用擔心內存溢出問題,如果需要會對 SDS 進行擴容。
2、獲取字符串長度時間複雜度爲 O(1),因爲定義了 len 屬性。
3、通過“空間預分配”( sdsMakeRoomFor)和“惰性空間釋放”,防止多次重分配內存。
4、判斷是否結束的標誌是 len 屬性(它同樣以'\0'結尾是因爲這樣就可以使用 C語言中函數庫操作字符串的函數了),可以包含'\0'
C字符串 | SDS |
---|---|
獲取字符串長度的複雜度爲 O(N) | 獲取字符串長度的複雜度爲 O(1) |
API 是不安全的,可能會造成緩衝區溢出 | API 是安全的,不會早晨個緩衝區溢出 |
修改字符串長度N次必然需要執行N次內存重分配 | 修改字符串長度N次最多需要執行N次內存重分配 |
只能保存文本數據 | 可以保存文本或者二進制數據 |
可以使用所有<string.h>庫中的函數 | 可以使用一部分<string.h>庫中的函數 |
2、基於Set 如何實現用戶關注模型?
我:me
他:he
我關注的人:focus
關注我的人:attention
操作命令
sadd attention:me he u3 u4
sadd attention:me u3 he
sadd focus:me he u3
sadd focus:he u4
1)相互關注?
sinter attention:me focus:me
1) "he"
2) "u3"
2)我關注的人也關注了他?
sunion focus:me attention:he
2) "he"
3) "u3"
3)可能認識的人?
我關注的人 和他關注的人差集 但是要去除我和他
3、dict裏面爲什麼要定義兩個哈希表ht[0] ht[1]?hash擴容是怎麼實現的?
redis 的 hash 默認使用的是 ht[0],ht[1]不會初始化和分配空間。
哈希表 dictht 是用鏈地址法來解決碰撞問題的。在這種情況下,哈希表的性能取決於它的大小(size 屬性)和它所保存的節點的數量(used 屬性)之間的比率:
比率在 1:1 時(一個哈希表 ht 只存儲一個節點 entry),哈希表的性能最好;
如果節點數量比哈希表的大小要大很多的話(這個比例用 ratio 表示,5 表示平均一個 ht 存儲 5 個 entry),那麼哈希表就會退化成多個鏈表,哈希表本身的性能優勢就不再存在。
在這種情況下需要擴容。Redis 裏面的這種操作叫做 rehash。
hash擴容是怎麼實現的?
1、爲字符 ht[1]哈希表分配空間,這個哈希表的空間大小取決於要執行的操作,以及 ht[0]當前包含的鍵值對的數量。擴展:ht[1]的大小爲第一個大於等於 ht[0].used*2。
2、將所有的 ht[0]上的節點 rehash 到 ht[1]上,重新計算 hash 值和索引,然後放入指定的位置。
3、當 ht[0]全部遷移到了 ht[1]之後,釋放 ht[0]的空間,將 ht[1]設置爲 ht[0]表,並創建新的 ht[1],爲下次 rehash 做準備。
公衆號
如果大家想要實時關注我更新的文章以及分享的乾貨的話,可以關注我的公衆號。