Table of Contents
一、概述
Redis是速度非常快的非關係型(NoSQL)內存鍵值數據庫,可以存儲鍵和五種不同類型的值之間的映射。
鍵的類型只能爲字符串,值支持五種數據類型:字符串、列表、集合、散列表、有序集合。
Redis支持很多特性,例如將內存中的數據持久化到硬盤中,使用複製來擴展讀性能,使用分片來擴展寫性能。
二、數據類型
數據類型 | 可以存儲的值 | 操作 |
---|---|---|
STRING | 字符串、整數或浮點數 |
對整個字符串或字符串的其中一部分執行操作 對整數和浮點數執行自增或者自減操作 |
LIST | 列表 |
從兩端壓入或者彈出元素 對單個或者多個元素進行修剪,只保留一個範圍內的元素 |
SET | 無序集合 |
添加、獲取、移除單個元素 檢查一個元素是否在集合中 計算交集、並集、差集 從集合裏面隨機獲取元素 |
HASH | 包含鍵值對的無序散列表 |
添加、獲取、移除單個鍵值對 獲取所有鍵值對 檢查某個鍵是否存在 |
ZSET | 有序集合 |
添加、獲取、刪除元素 根據分值範圍或者成員來獲取元素 計算一個鍵的排名 |
STRING
> set hello world
OK
> get hello
"world"
> del hello
(integer) 1
> get hello
(nil)
LIST
> rpush list-key item
(integer) 1
> rpush list-key item2
(integer) 2
> rpush list-key item
(integer) 3
> lrange list-key 0 -1
1) "item"
2) "item2"
3) "item"
> lindex list-key 1
"item2"
> lpop list-key
"item"
> lrange list-key 0 -1
1) "item2"
2) "item"
SET
> sadd set-key item
(integer) 1
> sadd set-key item2
(integer) 1
> sadd set-key item3
(integer) 1
> sadd set-key item
(integer) 0
> smembers set-key
1) "item"
2) "item2"
3) "item3"
> sismember set-key item4
(integer) 0
> sismember set-key item
(integer) 1
> srem set-key item2
(integer) 1
> srem set-key item2
(integer) 0
> smembers set-key
1) "item"
2) "item3"
HASH
> hset hash-key sub-key1 value1
(integer) 1
> hset hash-key sub-key2 value2
(integer) 1
> hset hash-key sub-key1 value1
(integer) 0
> hgetall hash-key
1) "sub-key1"
2) "value1"
3) "sub-key2"
4) "value2"
> hdel hash-key sub-key2
(integer) 1
> hdel hash-key sub-key2
(integer) 0
> hget hash-key sub-key1
"value1"
> hgetall hash-key
1) "sub-key1"
2) "value1"
ZSET
> zadd zset-key 728 member1
(integer) 1
> zadd zset-key 982 member0
(integer) 1
> zadd zset-key 982 member0
(integer) 0
> zrange zset-key 0 -1 withscores
1) "member1"
2) "728"
3) "member0"
4) "982"
> zrangebyscore zset-key 0 800 withscores
1) "member1"
2) "728"
> zrem zset-key member1
(integer) 1
> zrem zset-key member1
(integer) 0
> zrange zset-key 0 -1 withscores
1) "member0"
2) "982"
三、數據結構
字典
dictht是一個散列表結構,使用拉鍊發解決哈希衝突。
/* 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;
unsigned long used;
} dictht;
typedef struct dictEntry {
void *key;
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next;
} dictEntry;
Redis的字典dict中包含兩個哈希表dictht,這是爲了方便進行rehash操作。在擴容時,將其中一個dictht上的鍵值對rehash到另一個hashht上面,完成之後釋放空間並交換兩個dictht的角色。
typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2];
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
unsigned long iterators; /* number of iterators currently running */
} dict;
rehash操作不是一次性完成,而是採用漸進式方式,這是爲了避免一次性執行過多的rehash操作給服務器帶來過大的負擔。
漸進式rehash通過記錄dict的rehashidx完成,它從0開始,然後每執行一次rehash都會遞增。例如在一次rehash中,要把dict[0] rehash到dict[1],這一次會把dict[0]上table[rehashidx]的鍵值對rehash到dict[1]上,dict[0]的table[rehashidx]指向null,並令rehashidx++。
在rehash期間,每次對字典進行添加、刪除、查找或者更新操作時,都會執行一次漸進式rehash。
採用漸進式rehash會導致字典中的數據分散在兩個dictht上,因此對字典的查找操作也要到對應的dictht去執行。
/* Performs N steps of incremental rehashing. Returns 1 if there are still
* keys to move from the old to the new hash table, otherwise 0 is returned.
*
* Note that a rehashing step consists in moving a bucket (that may have more
* than one key as we use chaining) from the old to the new hash table, however
* since part of the hash table may be composed of empty spaces, it is not
* guaranteed that this function will rehash even a single bucket, since it
* will visit at max N*10 empty buckets in total, otherwise the amount of
* work it does would be unbound and the function may block for a long time. */
int dictRehash(dict *d, int n) {
int empty_visits = n * 10; /* Max number of empty buckets to visit. */
if (!dictIsRehashing(d)) return 0;
while (n-- && d->ht[0].used != 0) {
dictEntry *de, *nextde;
/* Note that rehashidx can't overflow as we are sure there are more
* elements because ht[0].used != 0 */
assert(d->ht[0].size > (unsigned long) d->rehashidx);
while (d->ht[0].table[d->rehashidx] == NULL) {
d->rehashidx++;
if (--empty_visits == 0) return 1;
}
de = d->ht[0].table[d->rehashidx];
/* Move all the keys in this bucket from the old to the new hash HT */
while (de) {
uint64_t h;
nextde = de->next;
/* Get the index in the new hash table */
h = dictHashKey(d, de->key) & d->ht[1].sizemask;
de->next = d->ht[1].table[h];
d->ht[1].table[h] = de;
d->ht[0].used--;
d->ht[1].used++;
de = nextde;
}
d->ht[0].table[d->rehashidx] = NULL;
d->rehashidx++;
}
/* Check if we already rehashed the whole table... */
if (d->ht[0].used == 0) {
zfree(d->ht[0].table);
d->ht[0] = d->ht[1];
_dictReset(&d->ht[1]);
d->rehashidx = -1;
return 0;
}
/* More to rehash... */
return 1;
}
跳躍表
跳躍表是有序集合的底層實現之一。
跳躍表是基於多指針有序鏈表實現的,可以看成多個有序鏈表。
在查找時,從上層指針開始查找,找到對應的區間之後再到下一層去查找。下圖演示了查找22的過程。
與紅黑樹等平衡二叉樹相比,跳躍表具有以下優點:
- 插入速度非常快,因爲不需要進行旋轉等操作來維護平衡性;
- 更容易實現;
- 支持無鎖操作。
四、使用場景
計數器
可以對String進行自增自減運算,從而實現計數器功能。
Redis這種內存型數據庫的讀寫性能非常高,很適合存儲頻繁讀寫的計數量。
緩存
將熱點數據放在內存中,設置內存的最大使用量以及淘汰策略來保證緩存的命中率。
查找表
例如DNS記錄就很適合使用Redis進行存儲。
查找表和緩存類似,也是利用了Redis快速的查找特性。但是查找表的內容不能失效,而緩存的內容可以失效,因爲緩存不作爲可靠的數據來源。
消息隊列
List是一個雙向鏈表,可以通過lpush和rpop寫入和讀取消息。
不過最好使用Kafka、RabbitMQ等消息中間件。
會話緩存
可以使用Redis來統一存儲多臺應用服務器的會話信息。
當應用服務器不再存儲用戶的會話信息,也就不再具有狀態,一個用戶可以請求任意一個應用服務器,從而更容易實現高可用性以及可伸縮性。
分佈式鎖實現
在分佈式場景下,無法使用單機環境下的鎖來對多個節點上的進程進行同步。
可以使用Redis自帶的SETNX命令實現分佈式鎖,除此之外,還可以使用官方提供的RedLock分佈式所實現。
其它
Set可以實現交基、並集等操作,從而實現共同好友等功能。
ZSet可以實現有序性操作,從而實現排行榜等功能。
五、Redis與Memcached
兩者都是非關係型內存鍵值數據庫,主要有以下不同:
數據類型
Memcached僅支持字符串類型,而Redis支持五種不同的數據類型,可以更靈活地解決問題。
數據持久化
Redis支持兩種持久化策略:RDB快照和AOF日誌,而Memcached不支持持久化。
分佈式
Memcached不支持分佈式,只能通過在客戶端使用一致性哈希來實現分佈式存儲,這種方式在存儲和查詢時都需要現在客戶端計算一次數據所在的節點。
Redis Cluster實現了分佈式的支持。
內存管理機制
在Redis中,並不是所有數據都一直存儲在內存中,可以將一些很久沒有使用的value交換到磁盤,而Memcached得數據則一直在內存中。
Memcached將內存分割成特定長度的塊來存儲數據,以完全解決內存碎片的問題。但是這種方式會使得內存的利用率不高,例如塊的大小爲128bytes,只存儲100bytes的數據,那麼剩下的28bytes就浪費掉了。
六、鍵的過期時間
Redis可以爲每個鍵設置過期事件,當鍵過期時,會自動刪除該鍵。
對於散列表這種容器,只能爲整個鍵設置過期時間(整個散列表),而不能爲鍵裏面的單個元素設置過期時間。
七、數據淘汰策略
可以設置內存最大使用量,當內存使用量超出時,會施行數據淘汰策略。
Redis具體有6中淘汰策略:
策略 | 描述 |
---|---|
volatile-lru | 從已設置過期時間的數據集中挑選最近最少使用的數據淘汰 |
volatile-ttl | 從已設置過期時間的數據集中挑選將要過期的數據淘汰 |
volatile-random | 從以設置過期時間的數據集中任意選擇數據淘汰 |
allkeys-lru | 從所有數據集中挑選最近最少使用的數據集淘汰 |
allkeys-random | 從所有數據集中任意選擇數據進行淘汰 |
noeviction | 精緻驅逐數據 |
作爲內存數據庫,出於對性能和內存消耗的考慮,Redis的淘汰算法實際上並非針對所有key,而是抽樣一小部分並且從中選出被淘汰的key。
使用Redis緩存數據時,爲了提高緩存命中率,需要保證緩存數據都是熱點數據。可以將內存最大使用量設置爲熱點數據佔用的內存量,然後啓動allkeys-lru淘汰策略,將最近最少使用的數據淘汰。
Redis 4.0引入了volatile-lfu和allkeys-lfu淘汰策略,LFU策略通過統計訪問頻率,將訪問頻率最少的鍵值對淘汰。
八、持久化
Redis是內存型數據庫,爲了保證數據在斷電後不會丟失,需要將內存中的數據持久化到硬盤上。
RDB持久化
將某個時間點的所有數據都存放到硬盤上。
可以將快照複製到其它服務器從而創建具有相同數據的服務器副本。
如果系統發生故障,將會丟失最後一次創建快照之後的數據。
如果數據量很大,保存快照的時間會很長。
AOF持久化
將寫命令添加到AOF文件(Append Only File)的末尾。
使用AOF持久化需要設置同步選項,從而確保寫命令同步到磁盤文件上的時機。這是因爲對文件進行寫入並不會馬上將內容同步到磁盤上,而是先存儲到緩衝區,然後由操作系統決定什麼時候同步到磁盤。有以下同步選項:
選項 | 同步頻率 |
---|---|
always | 每個寫命令都同步 |
everysec | 每秒同步一次 |
no | 讓操作系統來決定何時同步 |
- always選項會嚴重降低服務器的性能
- everysec選項比較適合,可以保證系統崩潰時只會丟失一秒左右的數據,並且Redis每秒只從一次同步對服務器性能幾乎沒有任何影響;
- no選項並不能給服務器性能帶來多大的提升,而且也會增加系統崩潰時數據丟失的數量。
隨着服務器讀寫請求的增多,AOF文件會越來越大。Redis提供了一種將AOF重寫的特性,能夠去除AOF文件中的冗餘寫命令。
九、事務
一個事務包含了多個命令,服務器在執行事務期間,不會該去執行其它客戶端的命令請求。
事務中的多個命令被一次性發送給服務器,而不是一條一條發送,這種方式被稱爲流水線,它可以減少客戶端與服務器之間的網絡通信次數從而提升性能。
Redis最簡單的事務實現方式是使用MULTI和EXEC命令將事務操作包圍起來。
十、事件
Redis服務器是一個事件驅動程序。
文件事件
服務器通過套接字與客戶端或者其它服務器進行通信,文件事件就是對套接字操作的抽象。Redis基於Reactor模式開發了自己的網絡事件處理器,使用I/O多路複用程序來同時監聽多個套接字,並將到達的事件傳送給文件文件分派器,分派器會根據套接字產生的事件類型條用響應的事件處理器。
時間事件
服務器有一些操作需要在給定的時間點執行,時間事件是對這類定時操作的抽象。時間事件又分爲:
- 定時事件:是一段讓程序在指定時間之內執行一次;
- 週期性事件:是讓一段程序每隔指定事件就執行一次。
Redis將所有時間事件都放在一個無序鏈表中,通過遍歷整個鏈表查找出已到達的時間事件,並調用相應的事件處理器。
事件的調度與執行
服務器需要不斷監聽文件事件的套接字才能得到待處理的文件事件,但是不能一直監聽,否則時間事件無法在規定的時間內執行,因此監聽時間應該根據距離現在最近的的時間事件來決定。
事件調度與執行由aeProcessEvents函數負責,僞代碼如下:
def aeProcessEvents():
# 獲取到達時間離當前時間最接近的時間事件
time_event = aeSearchNearestTimer()
# 計算最接近的時間事件距離到達還有多少毫秒
remaind_ms = time_event.when - unix_ts_now()
# 如果事件已到達,那麼 remaind_ms 的值可能爲負數,將它設爲 0
if remaind_ms < 0:
remaind_ms = 0
# 根據 remaind_ms 的值,創建 timeval
timeval = create_timeval_with_ms(remaind_ms)
# 阻塞並等待文件事件產生,最大阻塞時間由傳入的 timeval 決定
aeApiPoll(timeval)
# 處理所有已產生的文件事件
procesFileEvents()
# 處理所有已到達的時間事件
processTimeEvents()
將asProcessEvents函數置於一個循環裏面,加上初始化和清理函數,就構成了Redis服務器的主函數,僞代碼如下:
def main():
# 初始化服務器
init_server()
# 一直處理事件,直到服務器關閉爲止
while server_is_not_shutdown():
aeProcessEvents()
# 服務器關閉,執行清理操作
clean_server()
從事件處理的角度來看,服務器運行流程如下:
十一、複製
通過使用slaveof host port命令來讓一個服務器稱爲另一個服務器的從服務器。
一個從服務器只能有一個主服務器,並且不支持主主複製。
連接過程
- 主服務器創建快照文件,發送給從服務器,並在發送期間使用緩衝區記錄執行的寫命令。快照文件發送完畢之後,開始向從服務器發送存儲在緩衝區中的寫命令;
- 從服務器丟棄所有的舊數據,載入主服務器發來的快照文件,之後從服務器開始接受主服務器發來的寫命令;
- 主服務器沒執行一次寫命令,就像從服務器發送相同的寫命令。
主從鏈
隨着負載不斷上升,主服務器可能無法很快地更新所有的從服務器,或者重新連接和重新同步從服務器將導致系統超載。爲了解決這個問題,可以創建一箇中間層來分擔主服務器的複製工作。中間層的服務器是最上層服務器的從服務器,又是最下層服務器的主服務器。
十二、Sentinel
Sentinel(哨兵)可以監聽集羣中的服務器,並在主服務器進入下線狀態時,自動從從服務器中選舉出新的主服務器。
十三、分片
分片是將數據劃分爲多個部分的方法,可以將數據存儲到多臺機器裏面,這種方法在解決某些問題時可以獲得線性級別的性能提升。
假設有四個Redis實例R0, R1, R2, R3,還有很多表示用戶的鍵user:1, user:2, ... 有不同的方式來選擇一個指定的鍵存儲在哪個實例中。
- 最簡單的方式是範圍分片,例如用戶id從0~1000的存儲到實例R0中,用戶id從1001到2000的存儲到實例R1中等。但是這樣需要維護一張映射範圍表,維護操作代價很高。
- 還以一種方式是哈希分片,使用CRC32哈希函數將鍵轉換爲一個數字,再對實例數量求模就能知道應該存儲的實例。
根據執行分片的位置,可以分爲三種分片方式:
- 客戶端分片:客戶端使用一致性哈希等算法決定鍵應當分佈到哪個節點。
- 代理分片:將客戶端請求發送到代理上,由代理轉發請求到正確的節點上。
- 服務器分片:Redis Cluster
十四、一個簡單的論壇系統分析
該論壇系統功能如下:
- 可以發佈文章;
- 可以對文章進行點贊;
- 在首頁可以按文章的發佈時間或者文章的點贊數進行排序顯示。
文章信息
文章包括標題、作者、贊數等信息,在關係型數據庫中很容易構建一張表來存儲這些信息,在Redis中可以使用HASH來存儲每種信息以及其對應的值的映射。
Redis沒有關係型數據庫中的表這一概念來將同種類型的數據存放在一起,而是使用命名空間的方式來實現這一功能。鍵名的前面部分存儲命名空間,後面部分的內存存儲ID,通常使用: 來進行分隔。例如下面的HASH的鍵名爲article:982617,其中article爲命名空間,ID爲982617。
點贊功能
當有用戶爲一篇文章點贊時,除了要對該文章的votes字段進行加1操作,還必須記錄該用戶已經對文章進行了點贊,防止用戶點贊次數超過1。可以建立文章的已投票用戶集合來進行記錄。
爲了節約內存,規定一篇文章發佈滿一週之後,就不能再對它進行投票,而文章的已投票集合也會被刪除,可以爲文章的已投票集合設置一個一週的過期時間就能實現這個功能。
對文章進行排序
爲了按發佈時間和點贊數進行排序,可以建立一個文章發佈時間的有序集合和一個文章點贊數的有序集合。
下圖中的score就是點贊數,有序集合中的分值並不直接是時間和點贊數,而是根據時間和點贊數間接計算出來的)