1. 程式人生 > >Redis原理與實踐總結

Redis原理與實踐總結

quick zookeepe 優勢 raw 網絡 完整 主從 zha redis命令

Redis原理與實踐總結

本文主要對Redis的設計和實現原理做了一個介紹很總結,有些東西我也介紹的不是很詳細準確,盡量在自己的理解範圍內把一些知識點和關鍵性技術做一個描述。如有錯誤,還望見諒,歡迎指出。 這篇文章主要還是參考我之前的技術專欄總結而來的。歡迎查看:

重新學習Redis

https://blog.csdn.net/column/details/21877.html

使用和基礎數據結構(外觀)

redis的基本使用方式是建立在redis提供的數據結構上的。

字符串 REDIS_STRING (字符串)是 Redis 使用得最為廣泛的數據類型,它除了是 SET 、GET 等命令 的操作對象之外,數據庫中的所有鍵,以及執行命令時提供給 Redis 的參數,都是用這種類型 保存的。

字符串類型分別使用 REDIS_ENCODING_INT 和 REDIS_ENCODING_RAW 兩種編碼

只有能表示為 long 類型的值,才會以整數的形式保存,其他類型 的整數、小數和字符串,都是用 sdshdr 結構來保存

哈希表 REDIS_HASH (哈希表)是HSET 、HLEN 等命令的操作對象

它使用 REDIS_ENCODING_ZIPLIST和REDIS_ENCODING_HT 兩種編碼方式

Redis 中每個hash可以存儲232-1鍵值對(40多億)

列表 REDIS_LIST(列表)是LPUSH 、LRANGE等命令的操作對象

它使用 REDIS_ENCODING_ZIPLIST和REDIS_ENCODING_LINKEDLIST 這兩種方式編碼

一個列表最多可以包含232-1 個元素(4294967295, 每個列表超過40億個元素)。

集合 REDIS_SET (集合) 是 SADD 、 SRANDMEMBER 等命令的操作對象

它使用 REDIS_ENCODING_INTSET 和 REDIS_ENCODING_HT 兩種方式編碼

Redis 中集合是通過哈希表實現的,所以添加,刪除,查找的復雜度都是O(1)。

集合中最大的成員數為 232 - 1 (4294967295, 每個集合可存儲40多億個成員)

有序集 REDIS_ZSET (有序集)是ZADD 、ZCOUNT 等命令的操作對象

它使用 REDIS_ENCODING_ZIPLIST和REDIS_ENCODING_SKIPLIST 兩種方式編碼

不同的是每個元素都會關聯一個double類型的分數。redis正是通過分數來為集合中的成員進行從小到大的排序。

有序集合的成員是唯一的,但分數(score)卻可以重復。

集合是通過哈希表實現的,所以添加,刪除,查找的復雜度都是O(1)。 集合中最大的成員數為 232 - 1 (4294967295, 每個集合可存儲40多億個成員)

下圖說明了,外部數據結構和底層實際數據結構是通過redisObject來連接的。一個外觀類型裏面必然存著一個redisObject,通過它來訪問底層數據結構。

技術分享圖片

底層數據結構

下面討論redis底層數據結構

1 SDS動態字符串

sds字符串是字符串的實現

動態字符串是一個結構體,內部有一個buf數組,以及字符串長度,剩余長度等字段,優點是通過長度限制寫入,避免緩沖區溢出,另外剩余長度不足時會自動擴容,擴展性較好,不需要頻繁分配內存。

並且sds支持寫入二進制數據,而不一定是字符。

2 dict字典

dict字典是哈希表的實現。

dict字典與Java中的哈希表實現簡直如出一轍,首先都是數組+鏈表組成的結構,通過dictentry保存節點。

其中dict同時保存兩個entry數組,當需要擴容時,把節點轉移到第二個數組即可,平時只使用一個數組。

技術分享圖片

3 壓縮鏈表ziplist

3.1 ziplist是一個經過特殊編碼的雙向鏈表,它的設計目標就是為了提高存儲效率。ziplist可以用於存儲字符串或整數,其中整數是按真正的二進制表示進行編碼的,而不是編碼成字符串序列。它能以O(1)的時間復雜度在表的兩端提供push和pop操作。

3.2 實際上,ziplist充分體現了Redis對於存儲效率的追求。一個普通的雙向鏈表,鏈表中每一項都占用獨立的一塊內存,各項之間用地址指針(或引用)連接起來。這種方式會帶來大量的內存碎片,而且地址指針也會占用額外的內存。

3.3 而ziplist卻是將表中每一項存放在前後連續的地址空間內,一個ziplist整體占用一大塊內存。它是一個表(list),但其實不是一個鏈表(linked list)。

3.4 另外,ziplist為了在細節上節省內存,對於值的存儲采用了變長的編碼方式,大概意思是說,對於大的整數,就多用一些字節來存儲,而對於小的整數,就少用一些字節來存儲。

實際上。redis的字典一開始的數據比較少時,會使用ziplist的方式來存儲,也就是key1,value1,key2,value2這樣的順序存儲,對於小數據量來說,這樣存儲既省空間,查詢的效率也不低。

當數據量超過閾值時,哈希表自動膨脹為之前我們討論的dict。

4 quicklist

quicklist是結合ziplist存儲優勢和鏈表靈活性於一身的雙端鏈表。

quicklist的結構為什麽這樣設計呢?總結起來,大概又是一個空間和時間的折中:

4.1 雙向鏈表便於在表的兩端進行push和pop操作,但是它的內存開銷比較大。

首先,它在每個節點上除了要保存數據之外,還要額外保存兩個指針;其次,雙向鏈表的各個節點是單獨的內存塊,地址不連續,節點多了容易產生內存碎片。

4.2 ziplist由於是一整塊連續內存,所以存儲效率很高。

但是,它不利於修改操作,每次數據變動都會引發一次內存的realloc。特別是當ziplist長度很長的時候,一次realloc可能會導致大批量的數據拷貝,進一步降低性能。

技術分享圖片

5 zset zset其實是兩種結構的合並。也就是dict和skiplist結合而成的。dict負責保存數據對分數的映射,而skiplist用於根據分數進行數據的查詢(相輔相成)

6 skiplist

sortset數據結構使用了ziplist+zset兩種數據結構。

Redis裏面使用skiplist是為了實現sorted set這種對外的數據結構。sorted set提供的操作非常豐富,可以滿足非常多的應用場景。這也意味著,sorted set相對來說實現比較復雜。

sortedset是由skiplist,dict和ziplist組成的。

當數據較少時,sorted set是由一個ziplist來實現的。 當數據多的時候,sorted

set是由一個叫zset的數據結構來實現的,這個zset包含一個dict + 一個skiplist。dict用來查詢數據到分數(score)的對應關系,而skiplist用來根據分數查詢數據(可能是範圍查找)。

在本系列前面關於ziplist的文章裏,我們介紹過,ziplist就是由很多數據項組成的一大塊連續內存。由於sorted set的每一項元素都由數據和score組成,因此,當使用zadd命令插入一個(數據, score)對的時候,底層在相應的ziplist上就插入兩個數據項:數據在前,score在後。

技術分享圖片

skiplist的節點中存著節點值和分數。並且跳表是根據節點的分數進行排序的,所以可以根據節點分數進行範圍查找。

7inset

inset是一個數字結合,他使用靈活的數據類型來保持數字。

技術分享圖片

新創建的intset只有一個header,總共8個字節。其中encoding = 2, length = 0。 添加13, 5兩個元素之後,因為它們是比較小的整數,都能使用2個字節表示,所以encoding不變,值還是2。 當添加32768的時候,它不再能用2個字節來表示了(2個字節能表達的數據範圍是-215~215-1,而32768等於215,超出範圍了),因此encoding必須升級到INTSET_ENC_INT32(值為4),即用4個字節表示一個元素。

8總結

sds是一個靈活的字符串數組,並且支持直接存儲二進制數據,同時提供長度和剩余空間的字段來保證伸縮性和防止溢出。

dict是一個字典結構,實現方式就是Java中的hashmap實現,同時持有兩個節點數組,但只使用其中一個,擴容時換成另外一個。

ziplist是一個壓縮鏈表,他放棄內存不連續的連接方式,而是直接分配連續內存進行存儲,減少內存碎片。提高利用率,並且也支持存儲二進制數據。

quicklist是ziplist和傳統鏈表的中和形成的鏈表結果,每個鏈表節點都是一個ziplist。

skiplist一般有ziplist和zset兩種實現方法,根據數據量來決定。zset本身是由skiplist和dict實現的。

inset是一個數字集合,他根據插入元素的數據類型來決定數組元素的長度。並自動進行擴容。

9 他們實現了哪些結構

字符串由sds實現

list由ziplist和quicklist實現

sortset由ziplist和zset實現

hash表由dict實現

集合由inset實現。

技術分享圖片

redis server結構和數據庫redisDb

1 redis服務器中維護著一個數據庫名為redisdb,實際上他是一個dict結構。

Redis的數據庫使用字典作為底層實現,數據庫的增、刪、查、改都是構建在字典的操作之上的。

2 redis服務器將所有數據庫都保存在服務器狀態結構redisServer(redis.h/redisServer)的db數組(應該是一個鏈表)裏:

同理也有一個redis client結構,通過指針可以選擇redis client訪問的server是哪一個。

3 redisdb的鍵空間

typedef struct redisDb {
// 數據庫鍵空間,保存著數據庫中的所有鍵值對
dict *dict; /* The keyspace for this DB */
// 鍵的過期時間,字典的鍵為鍵,字典的值為過期事件 UNIX 時間戳
dict *expires; /* Timeout of keys with a timeout set */
// 數據庫號碼
int id; /* Database ID */
// 數據庫的鍵的平均 TTL ,統計信息
long long avg_ttl; /* Average TTL, just for stats */
//..
} redisDb

這部分的代碼說明了,redisdb除了維護一個dict組以外,還需要對應地維護一個expire的字典數組。

大的dict數組中有多個小的dict字典,他們共同負責存儲redisdb的所有鍵值對。

同時,對應的expire字典則負責存儲這些鍵的過期時間 技術分享圖片

4 過期鍵的刪除策略

2、過期鍵刪除策略 通過前面的介紹,大家應該都知道數據庫鍵的過期時間都保存在過期字典裏,那假如一個鍵過期了,那麽這個過期鍵是什麽時候被刪除的呢?現在來看看redis的過期鍵的刪除策略:

a、定時刪除:在設置鍵的過期時間的同時,創建一個定時器,在定時結束的時候,將該鍵刪除;

b、惰性刪除:放任鍵過期不管,在訪問該鍵的時候,判斷該鍵的過期時間是否已經到了,如果過期時間已經到了,就執行刪除操作;

c、定期刪除:每隔一段時間,對數據庫中的鍵進行一次遍歷,刪除過期的鍵。

redis的事件模型

redis處理請求的方式基於reactor線程模型,即一個線程處理連接,並且註冊事件到IO多路復用器,復用器觸發事件以後根據不同的處理器去執行不同的操作。總結以下客戶端到服務端的請求過程

總結

遠程客戶端連接到 redis 後,redis服務端會為遠程客戶端創建一個 redisClient 作為代理。
?
redis 會讀取嵌套字中的數據,寫入 querybuf 中。
?
解析 querybuf 中的命令,記錄到 argc 和 argv 中。
?
根據 argv[0] 查找對應的 recommand。
?
執行 recommend 對應的執行函數。
?
執行以後將結果存入 buf & bufpos & reply 中。
?
返回給調用方。返回數據的時候,會控制寫入數據量的大小,如果過大會分成若幹次。保證 redis 的相應時間。
?
Redis 作為單線程應用,一直貫徹的思想就是,每個步驟的執行都有一個上限(包括執行時間的上限或者文件尺寸的上限)一旦達到上限,就會記錄下當前的執行進度,下次再執行。保證了 Redis 能夠及時響應不發生阻塞。

備份方式

快照(RDB):就是我們俗稱的備份,他可以在定期內對數據進行備份,將Redis服務器中的數據持久化到硬盤中;

只追加文件(AOF):他會在執行寫命令的時候,將執行的寫命令復制到硬盤裏面,後期恢復的時候,只需要重新執行一下這個寫命令就可以了。類似於我們的MySQL數據庫在進行主從復制的時候,使用的是binlog二進制文件,同樣的是執行一遍寫命令;

appendfsync同步頻率的區別如下圖:

技術分享圖片

redis主從復制

Redis復制工作過程:

slave向master發送sync命令。

master開啟子進程來將dataset寫入rdb文件,同時將子進程完成之前接收到的寫命令緩存起來。

子進程寫完,父進程得知,開始將RDB文件發送給slave。

master發送完RDB文件,將緩存的命令也發給slave。

master增量的把寫命令發給slave。

註意有兩步操作,一個是寫入rdb的時候要緩存寫命令,防止數據不一致。發完rdb後還要發寫命令給salve,以後增量發命令就可以了

分布式鎖實現

使用setnx加expire實現加鎖和時限

加鎖時使用setnx設置key為1並設置超時時間,解鎖時刪除鍵

tryLock(){  
SETNX Key 1
EXPIRE Key Seconds
}
release(){
DELETE Key
}

這個方案的一個問題在於每次提交一個Redis請求,如果執行完第一條命令後應用異常或者重啟,鎖將無法過期,一種改善方案就是使用Lua腳本(包含SETNX和EXPIRE兩條命令),但是如果Redis僅執行了一條命令後crash或者發生主從切換,依然會出現鎖沒有過期時間,最終導致無法釋放。

使用getset加鎖和獲取過期時間

針對鎖無法釋放問題的一個解決方案基於GETSET命令來實現

思路:
?
SETNX(Key,ExpireTime)獲取鎖
?
如果獲取鎖失敗,通過GET(Key)返回的時間戳檢查鎖是否已經過期
?
GETSET(Key,ExpireTime)修改Value為NewExpireTime
?
檢查GETSET返回的舊值,如果等於GET返回的值,則認為獲取鎖成功
?
註意:這個版本去掉了EXPIRE命令,改為通過Value時間戳值來判斷過期

2.0的setnx可以配置過期時間。

V2.0 基於SETNX
?
tryLock(){
SETNX Key 1 Seconds
}
release(){
DELETE Key
}

Redis 2.6.12版本後SETNX增加過期時間參數,這樣就解決了兩條命令無法保證原子性的問題。但是設想下面一個場景:

  1. C1成功獲取到了鎖,之後C1因為GC進入等待或者未知原因導致任務執行過長,最後在鎖失效前C1沒有主動釋放鎖 2. C2在C1的鎖超時後獲取到鎖,並且開始執行,這個時候C1和C2都同時在執行,會因重復執行造成數據不一致等未知情況 3. C1如果先執行完畢,則會釋放C2的鎖,此時可能導致另外一個C3進程獲取到了鎖

流程圖如下 技術分享圖片

使用sentx將值設為時間戳,通過lua腳本進行cas比較和刪除操作

V3.0
tryLock(){
SETNX Key UnixTimestamp Seconds
}
release(){
EVAL(
//LuaScript
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
)
}

這個方案通過指定Value為時間戳,並在釋放鎖的時候檢查鎖的Value是否為獲取鎖的Value,避免了V2.0版本中提到的C1釋放了C2持有的鎖的問題;另外在釋放鎖的時候因為涉及到多個Redis操作,並且考慮到Check And Set 模型的並發問題,所以使用Lua腳本來避免並發問題。

如果在並發極高的場景下,比如搶紅包場景,可能存在UnixTimestamp重復問題,另外由於不能保證分布式環境下的物理時鐘一致性,也可能存在UnixTimestamp重復問題,只不過極少情況下會遇到。

分布式Redis鎖:Redlock

redlock的思想就是要求一個節點獲取集群中N/2 + 1個節點 上的鎖才算加鎖成功。

總結

不論是基於SETNX版本的Redis單實例分布式鎖,還是Redlock分布式鎖,都是為了保證下特性

  1. 安全性:在同一時間不允許多個Client同時持有鎖

  2. 活性

    死鎖:鎖最終應該能夠被釋放,即使Client端crash或者出現網絡分區(通常基於超時機制)

    容錯性:只要超過半數Redis節點可用,鎖都能被正確獲取和釋放

    分布式方案

1 主從復制,優點是備份簡易使用。缺點是不能故障切換,並且不易擴展。

2 使用sentinel哨兵工具監控和實現自動切換。

3 codis集群方案

首先codis使用代理的方式隱藏底層redis,這樣可以完美融合以前的代碼,不需要更改redis訪問操作。

然後codis使用了zookeeper進行監控和自動切換。同時使用了redis-group的概念,保證一個group裏是一主多從的主從模型,基於此來進行切換。

4 redis cluster集群

該集群是一個p2p方式部署的集群

Redis cluster是一個去中心化、多實例Redis間進行數據共享的集群。

每個節點上都保存著其他節點的信息,通過任一節點可以訪問正常工作的節點數據,因為每臺機器上的保留著完整的分片信息,某些機器不正常工作不影響整體集群的工作。並且每一臺redis主機都會配備slave,通過sentinel自動切換。

redis事務

事務 MULTI 、 EXEC 、 DISCARD 和 WATCH 是 Redis 事務相關的命令。事務可以一次執行多個命令, 並且帶有以下兩個重要的保證:

事務是一個單獨的隔離操作:事務中的所有命令都會序列化、按順序地執行。事務在執行的過程中,不會被其他客戶端發送來的命令請求所打斷。

事務是一個原子操作:事務中的命令要麽全部被執行,要麽全部都不執行。

redis事務有一個特點,那就是在2.6以前,事務的一系列操作,如果有的成功有的失敗,仍然會提交成功的那部分,後來改為全部不提交了。

但是Redis事務不支持回滾,提交以後不能執行回滾操作。

為什麽 Redis 不支持回滾(roll back)
如果你有使用關系式數據庫的經驗, 那麽 “Redis 在事務失敗時不進行回滾,而是繼續執行余下的命令”這種做法可能會讓你覺得有點奇怪。
?
以下是這種做法的優點:
?
Redis 命令只會因為錯誤的語法而失敗(並且這些問題不能在入隊時發現),或是命令用在了錯誤類型的鍵上面:這也就是說,從實用性的角度來說,失敗的命令是由編程錯誤造成的,而這些錯誤應該在開發的過程中被發現,而不應該出現在生產環境中。
因為不需要對回滾進行支持,所以 Redis 的內部可以保持簡單且快速。

redis腳本事務

Redis 腳本和事務 從定義上來說, Redis 中的腳本本身就是一種事務, 所以任何在事務裏可以完成的事, 在腳本裏面也能完成。 並且一般來說, 使用腳本要來得更簡單,並且速度更快。

因為腳本功能是 Redis 2.6 才引入的, 而事務功能則更早之前就存在了, 所以 Redis 才會同時存在兩種處理事務的方法。

redis事務的ACID特性 在傳統的關系型數據庫中,嘗嘗用ACID特質來檢測事務功能的可靠性和安全性。 在redis中事務總是具有原子性(Atomicity),一致性(Consistency)和隔離性(Isolation),並且當redis運行在某種特定的持久化 模式下,事務也具有耐久性(Durability).

①原子性

事務具有原子性指的是,數據庫將事務中的多個操作當作一個整體來執行,服務器要麽就執行事務中的所有操作,要麽就一個操作也不執行。 但是對於redis的事務功能來說,事務隊列中的命令要麽就全部執行,要麽就一個都不執行,因此redis的事務是具有原子性的。

②一致性

事務具有一致性指的是,如果數據庫在執行事務之前是一致的,那麽在事務執行之後,無論事務是否執行成功,數據庫也應該仍然一致的。
”一致“指的是數據符合數據庫本身的定義和要求,沒有包含非法或者無效的錯誤數據。redis通過謹慎的錯誤檢測和簡單的設計來保證事務一致性。

③隔離性

事務的隔離性指的是,即使數據庫中有多個事務並發在執行,各個事務之間也不會互相影響,並且在並發狀態下執行的事務和串行執行的事務產生的結果完全
相同。
因為redis使用單線程的方式來執行事務(以及事務隊列中的命令),並且服務器保證,在執行事務期間不會對事物進行中斷,因此,redis的事務總是以串行
的方式運行的,並且事務也總是具有隔離性的

④持久性

事務的耐久性指的是,當一個事務執行完畢時,執行這個事務所得的結果已經被保持到永久存儲介質裏面。
因為redis事務不過是簡單的用隊列包裹起來一組redis命令,redis並沒有為事務提供任何額外的持久化功能,所以redis事務的耐久性由redis使用的模式
決定

Redis原理與實踐總結