深入理解redis原理!


原理篇

redis 時單線程的為什么還能那么快?

數據都在內存中,運算都是內存級別的運算。

redis既然是單線程的為什么能處理那么多的並發數?

多路復用,操作系統時間輪訓epoll 函數作為選擇器,維護了指令隊列,和響應隊列,java的nio。

select ,poll, epoll

rset ,fds(文件描述符的集合)。而select模型存儲fds的方式是采取的bitmap,默認最大1024個。

2.3、執行流程

1.select模型每次都直接將rset(也就是fds)全部拷貝到內核態,因為內核態速度比用戶空間態快很多。
2.如果沒數據的話,select函數會阻塞,如果有數據的話會執行兩步
(1)將有數據的那個fd置位(也就是標記一下,代表這個fd有數據)
(2)select函數不在阻塞,將繼續往下執行。也就是整體遍歷fds,找到有數據的那個fd讀取數據做處理。他的fd不能重用,每一次都需要重新創建新的fds且將用戶空間態的fds拷貝到內核態

3、缺點
fds最大支持1024個(可以更改,但是意義不大)
fd不可重用,每次內核態都給置位了,導致為了標記fd,必須創建一個新的rset從而導致fds在用戶態內存態間多次拷貝(也就是fds)

用戶控件態拷貝rset到內核態也需要時間,雖然內核態執行比用戶態快,但是copy也需要開銷
O(n)再次遍歷問題。因為rset里的fd被置位后,select函數並不知道哪個被置位了,需要從頭遍歷到尾,逐個對比。

poll

poll的結構體是為了fd重復利用,不需要每次都拷貝到內核態用的。

1、解決了select哪些問題
采取的鏈表存儲,而不是bitmap,解決了1024長度限制問題
采取結構體每次置位結構體內的revents字段,而不破壞fd本身,所以可重用,不需要每次都創建新的fd。
2、缺點
用戶控件態拷貝rset到內核態也需要時間,雖然內核態執行比用戶態快,但是copy也需要開銷
O(n)再次遍歷問題。因為rset里的fd被置位后,select函數並不知道哪個被置位了,需要從頭遍歷到尾,逐個對比。

epoll

2.2、執行流程
epoll將fd放到了紅黑樹里,且不需要拷貝到內核態,因為他采取了“共享內存”的概念。(其實還是復制,只是復制采取了其他技術可以使開銷極其的小)
epoll的置位是重排,比如五個fd, 1 2 3 4 5,1 3 5這三個fd有數據了,那么他會重排序,排成如下1 3 5 2 4。(也有的說是單獨放到新的數組里)
每一次置位nfds的值都+1。且會回調epoll_wait
所以epoll_wait執行完會返回有幾個fd有數據,那么下面的for直接遍歷nfds次即可。解決了前面的兩種O(n)。變成了O1

 

 

總結如下:

 

 

 

 

比如三個redis-cli,假設2個redis-cli寫入命令,
select:那么select模型是輪詢這三個redis-cli的fd,看哪個fd有消息,有的話讀取處理消息。當他下次再寫命令的時候還需要重新創建fd,然后復制到內核態然后再遍歷全部。
poll:那么poll模型是輪詢這三個redis-cli的fd,看哪個fd有消息,有的話讀取處理消息。下次再寫入的時候還是遍歷全局fd,看哪個fd有消息進行處理。省去了每次都創建新的fd且復制的過程。
epoll:epoll就不輪詢了,有消息進來后你通知我,我去處理你的消息,那些沒消息的fd我不管。而且復制到內核態的過程我采取牛逼的技術讓開銷達到最小的極致。


原文鏈接:https://blog.csdn.net/ctwctw/java/article/details/105024324

 

Redis 服務器與客戶端通過 RESPREdis Serialization Protocol) 協議通信。
主要以下特點: 容易實現,解析快,人類可讀.
RESP 底層采用的是 TCP 的連接方式, 通過 tcp 進行數據傳輸, 然后根據解析規則解析相
應信息, 完成交互。

 

 持久化

(原文鏈接:https://blog.csdn.net/ctwctw/java/article/details/105147277)

一、為什么需要持久化
redis里有10gb數據,突然停電或者意外宕機了,再啟動的時候10gb都沒了?!所以需要持久化,宕機后再通過持久化文件將數據恢復。

二、優缺點
1、rdb文件
rdb文件都是二進制,很小。比如內存數據有10gb,rdb文件可能就1gb,只是舉例。

2、優點
由於rdb文件都是二進制文件,所以很小,在災難恢復的時候會快些。
他的效率(主進程處理命令的效率,而不是持久化的效率)相對於aof要高(bgsave而不是save),因為每來個請求他都不會處理任何事,只是bgsave的時候他會fork()子進程且可能copyonwrite,但copyonwrite只是一個尋址的過程,納秒級別的。而aof每次都是寫盤操作,毫米級別。沒法比。
3、缺點
數據可靠性比aof低,也就是會丟失的多。因為aof可以配置每秒都持久化或者每個命令處理完就持久化一次這種高頻率的操作,而rdb的話雖然也是靠配置進行bgsave,但是沒有aof配置那么靈活,也沒aof持久化快,因為rdb每次全量,aof每次只追加。

三、RDB持久化的兩種方法
配置文件也可以配置觸發rdb的規則。配置文件配置的規則采取的是bgsave的原理。

1、save
1.1、描述 aof 處理增量數據
同步、阻塞

1.2、缺點
致命的問題,持久化的時候redis服務阻塞(准確的說會阻塞當前執行save命令的線程,但是redis是單線程的,所以整個服務會阻塞),不能繼對外提供請求,GG!數據量小的話肯定影響不大,數據量大呢?每次復制需要1小時,那就相當於停機一小時。

2、bgsave
2.1、描述  rdb 處理全量數據
異步、非阻塞

2.2、原理
fork() + copyonwrite

2.3、優點
他可以一邊進行持久化,一邊對外提供讀寫服務,互不影響,新寫的數據對我持久化不會造成數據影響,你持久化的過程中報錯或者耗時太久都對我當前對外提供請求的服務不會產生任何影響。持久化完會將新的rdb文件覆蓋之前的。
四 、fork()

bgsave原理是fork() + copyonwrite,那么現在來聊一下fork()

1、fork()是什么
fork()是unix和linux這種操作系統的一個api,而不是Redis的api。

2、fork()有什么用
fork()用於創建一個子進程,注意是子進程,不是子線程。fork()出來的進程共享其父類的內存數據。僅僅是共享fork()出子進程的那一刻的內存數據,后期主進程修改數據對子進程不可見,同理,子進程修改的數據對主進程也不可見。比如:A進程fork()了一個子進程B,那么A進程就稱之為主進程,這時候主進程子進程所指向的內存空間是同一個,所以他們的數據一致。但是A修改了內存上的一條數據,這時候B是看不到的,A新增一條數據,刪除一條數據,B都是看不到的。而且子進程B出問題了,對我主進程A完全沒影響,我依然可以對外提供服務,但是主進程掛了,子進程也必須跟隨一起掛。這一點有點像守護線程的概念。Redis正是巧妙的運用了fork()這個牛逼的api來完成RDB的持久化操作。

五、Redis中的fork()
Redis巧妙的運用了fork()。當bgsave執行時,Redis主進程會判斷當前是否有fork()出來的子進程,若有則忽略,若沒有則會fork()出一個子進程來執行rdb文件持久化的工作,子進程與Redis主進程共享同一份內存空間,所以子進程可以搞他的rdb文件持久化工作,主進程又能繼續他的對外提供服務,二者互不影響。我們說了他們之后的修改內存數據對彼此不可見,但是明明指向的都是同一塊內存空間,這是咋搞得?肯定不可能是fork()出來子進程后順帶復制了一份數據出來,如果是這樣的話比如我有4g內存,那么其實最大有限空間是2g,我要給rdb留出一半空間來,扯淡一樣!那他咋做的?采取了copyonwrite技術。

六、copyonwrite
很簡單,現在不就是主進程和子進程共享了一塊內存空間,怎么做到的彼此更改互不影響嗎?

1、原理
主進程fork()子進程之后,內核把主進程中所有的內存頁的權限都設為read-only,然后子進程的地址空間指向主進程。這也就是共享了主進程的內存,當其中某個進程寫內存時(這里肯定是主進程寫,因為子進程只負責rdb文件持久化工作,不參與客戶端的請求),CPU硬件檢測到內存頁是read-only的於是觸發頁異常中斷(page-fault),陷入內核的一個中斷例程。中斷例程中,內核就會把觸發的異常的頁復制一份(這里僅僅復制異常頁,也就是所修改的那個數據頁,而不是內存中的全部數據),於是主子進程各自持有獨立的一份。
數據修改之前的樣子

 

 

 

數據修改之后的樣子

 

 

2、回到原問題
其實就是更改數據的之前進行copy一份更改數據的數據頁出來,比如主進程收到了set k 1請求(之前k的值是2),然后這同時又有子進程在rdb持久化,那么主進程就會把k這個key的數據頁拷貝一份,並且主進程中k這個指針指向新拷貝出來的數據頁地址上,然后進行更改值為1的操作,這個主進程k元素地址引用的新拷貝出來的地址,而子進程引用的內存數據k還是修改之前的。

3、一段話總結
copyonwritefork()出來的子進程共享主進程的物理空間,當主子進程有內存寫入操作時,read-only內存頁發生中斷,將觸發的異常的內存頁復制一份(其余的頁還是共享主進程的)。

4、額外補充
在 Redis 服務中,子進程只會讀取共享內存中的數據,它並不會執行任何寫操作,只有主進程會在寫入時才會觸發這一機制,而對於大多數的 Redis 服務或者數據庫,寫請求往往都是遠小於讀請求的,所以使用fork()加上寫時拷貝這一機制能夠帶來非常好的性能,也讓BGSAVE這一操作的實現變得很簡單。

七、疑問
0、調用fork()也會阻塞啊
我只能說沒毛病,但是這個阻塞真的可以忽略不計。尤其是相對於阻塞主線程的save。

1、會同時存在多個子進程嗎?
不會,主進程每次收到bgsave命令需要fork()子進程之前都會判斷是否存在子進程了,若存在也會忽略掉這次bgsave請求。若不存在我會fork()出子進程進行工作。
為什么這么搞?
我猜測原因如下:
1.如果支持並行存在多個子進程,那么不僅會拉低服務器性能,還會造成數據問題,比如八點的bgsave在工作,九點又來個bgsave命令。這時候九點的先執行完了,八點的后執行完了,那九點的不白執行了嗎?這是我所謂的數據問題。再比如,都沒執行完,十點又開一個bgsave,越積越多,服務器性能被拉低。
2.那為什么不阻塞?判斷有子進程在工作,就等待,等他執行完我在上場,那一樣,越積越多,文件過大,只會造成堆積。

2、如果沒有copyonwrite這種技術是什么效果?
1.假設是全量復制,那么內存空間直接減半,浪費資源不說,數據量10g,全量復制這10g的時間也夠長的。這誰頂得住?2.如果不全量復制,會是怎樣?相當於我一邊復制,你一邊寫數據,看着貌似問題不大,其實不然。比如現在Redis里有k1的值是1,k2的值是
2,比如bgsave了,這時候rdb寫入了k1的值,在寫k2的值之前時,有個客戶端請求

set k1 11
set k2 22
1
2
那么持久化進去的是k2 22,但是k1的值還是1,而不是最新的11,所以會造成數據問題,所以采取了copyonwrite技術來保證觸發bgsave請求的時候無論你怎么更改,都對我rdb文件的數據持久化不會造成任何影響。

 

redis cluster集群

redis cluster

  redis cluster是Redis的分布式解決方案,在3.0版本推出后有效地解決了redis分布式方面的需求

  自動將數據進行分片,每個master上放一部分數據

  提供內置的高可用支持,部分master不可用時,還是可以繼續工作的

 

  支撐N個redis master node,每個master node都可以掛載多個slave node

  高可用,因為每個master都有salve節點,那么如果mater掛掉,redis cluster這套機制,就會自動將某個slave切換成master

 

redis cluster vs. replication + sentinal

  如果你的數據量很少,主要是承載高並發高性能的場景,比如你的緩存一般就幾個G,單機足夠了

  replication,一個mater,多個slave,要幾個slave跟你的要求的讀吞吐量有關系,然后自己搭建一個sentinal集群,去保證redis主從架構的高可用性,就可以了

  redis cluster,主要是針對海量數據+高並發+高可用的場景,海量數據,如果你的數據量很大,那么建議就用redis cluster

 

數據分布算法

hash算法

  比如你有 N 個 redis實例,那么如何將一個key映射到redis上呢,你很可能會采用類似下面的通用方法計算 key的 hash 值,然后均勻的映射到到 N 個 redis上:

  hash(key)%N

  如果增加一個redis,映射公式變成了 hash(key)%(N+1)

  如果一個redis宕機了,映射公式變成了 hash(key)%(N-1)

  在這兩種情況下,幾乎所有的緩存都失效了。會導致數據庫訪問的壓力陡增,嚴重情況,還可能導致數據庫宕機。

 

一致性hash算法

  一個master宕機不會導致大部分緩存失效,可能存在緩存熱點問題

 

用虛擬節點改進

redis cluster的hash slot算法

  redis cluster有固定的16384個hash slot,對每個key計算CRC16值,然后對16384取模,可以獲取key對應的hash slot

  redis cluster中每個master都會持有部分slot,比如有3個master,那么可能每個master持有5000多個hash slot

  hash slot讓node的增加和移除很簡單,增加一個master,就將其他master的hash slot移動部分過去,減少一個master,就將它的hash slot移動到其他master上去

  移動hash slot的成本是非常低的

  客戶端的api,可以對指定的數據,讓他們走同一個hash slot,通過hash tag來實現

 

  127.0.0.1:7000>CLUSTER ADDSLOTS 0 1 2 3 4 ... 5000  可以將槽0-5000指派給節點7000負責。

  每個節點都會記錄哪些槽指派給了自己,哪些槽指派給了其他節點。

  客戶端向節點發送鍵命令,節點要計算這個鍵屬於哪個槽。

  如果是自己負責這個槽,那么直接執行命令,如果不是,向客戶端返回一個MOVED錯誤,指引客戶端轉向正確的節點。

 

 

 

節點間的內部通信機制

1、基礎通信原理

(1)redis cluster節點間采取gossip協議進行通信

跟集中式不同,不是將集群元數據(節點信息,故障,等等)集中存儲在某個節點上,而是互相之間不斷通信,保持整個集群所有節點的數據是完整的

維護集群的元數據用得,集中式,一種叫做gossip

集中式:好處在於,元數據的更新和讀取,時效性非常好,一旦元數據出現了變更,立即就更新到集中式的存儲中,其他節點讀取的時候立即就可以感知到; 不好在於,所有的元數據的跟新壓力全部集中在一個地方,可能會導致元數據的存儲有壓力

gossip:好處在於,元數據的更新比較分散,不是集中在一個地方,更新請求會陸陸續續,打到所有節點上去更新,有一定的延時,降低了壓力; 缺點,元數據更新有延時,可能導致集群的一些操作會有一些滯后

我們剛才做reshard,去做另外一個操作,會發現說,configuration error,達成一致

(2)10000端口

每個節點都有一個專門用於節點間通信的端口,就是自己提供服務的端口號+10000,比如7001,那么用於節點間通信的就是17001端口

每隔節點每隔一段時間都會往另外幾個節點發送ping消息,同時其他幾點接收到ping之后返回pong

(3)交換的信息

故障信息,節點的增加和移除,hash slot信息,等等

gossip協議

 

gossip協議包含多種消息,包括ping,pong,meet,fail,等等

meet: 某個節點發送meet給新加入的節點,讓新節點加入集群中,然后新節點就會開始與其他節點進行通信

redis-trib.rb add-node

其實內部就是發送了一個gossip meet消息,給新加入的節點,通知那個節點去加入我們的集群

ping: 每個節點都會頻繁給其他節點發送ping,其中包含自己的狀態還有自己維護的集群元數據,互相通過ping交換元數據

每個節點每秒都會頻繁發送ping給其他的集群,ping,頻繁的互相之間交換數據,互相進行元數據的更新

pong: 返回ping和meet,包含自己的狀態和其他信息,也可以用於信息廣播和更新

fail: 某個節點判斷另一個節點fail之后,就發送fail給其他節點,通知其他節點,指定的節點宕機了

 

3、ping消息深入

ping很頻繁,而且要攜帶一些元數據,所以可能會加重網絡負擔

每個節點每秒會執行10次ping,每次會選擇5個最久沒有通信的其他節點

當然如果發現某個節點通信延時達到了cluster_node_timeout / 2,那么立即發送ping,避免數據交換延時過長,落后的時間太長了

比如說,兩個節點之間都10分鍾沒有交換數據了,那么整個集群處於嚴重的元數據不一致的情況,就會有問題

所以cluster_node_timeout可以調節,如果調節比較大,那么會降低發送的頻率

每次ping,一個是帶上自己節點的信息,還有就是帶上1/10其他節點的信息,發送出去,進行數據交換

至少包含3個其他節點的信息,最多包含總節點-2個其他節點的信息

-------------------------------------------------------------------------------------------------------

面向集群的jedis內部實現原理

開發,jedis,redis的java client客戶端,redis cluster,jedis cluster api

jedis cluster api與redis cluster集群交互的一些基本原理

1、基於重定向的客戶端

redis-cli -c,自動重定向

(1)請求重定向

客戶端可能會挑選任意一個redis實例去發送命令,每個redis實例接收到命令,都會計算key對應的hash slot

如果在本地就在本地處理,否則返回moved給客戶端,讓客戶端進行重定向

cluster keyslot mykey,可以查看一個key對應的hash slot是什么

用redis-cli的時候,可以加入-c參數,支持自動的請求重定向,redis-cli接收到moved之后,會自動重定向到對應的節點執行命令

(2)計算hash slot

計算hash slot的算法,就是根據key計算CRC16值,然后對16384取模,拿到對應的hash slot

用hash tag可以手動指定key對應的slot,同一個hash tag下的key,都會在一個hash slot中,比如set mykey1:{100}和set mykey2:{100}

(3)hash slot查找

節點間通過gossip協議進行數據交換,就知道每個hash slot在哪個節點上

(4)JedisCluster的工作原理

在JedisCluster初始化的時候,就會隨機選擇一個node,初始化hashslot -> node映射表,同時為每個節點創建一個JedisPool連接池

每次基於JedisCluster執行操作,首先JedisCluster都會在本地計算key的hashslot,然后在本地映射表找到對應的節點

如果那個node正好還是持有那個hashslot,那么就ok; 如果說進行了reshard這樣的操作,可能hashslot已經不在那個node上了,就會返回moved

如果JedisCluter API發現對應的節點返回moved,那么利用該節點的元數據,更新本地的hashslot -> node映射表緩存

重復上面幾個步驟,直到找到對應的節點,如果重試超過5次,那么就報錯,JedisClusterMaxRedirectionException

jedis老版本,可能會出現在集群某個節點故障還沒完成自動切換恢復時,頻繁更新hash slot,頻繁ping節點檢查活躍,導致大量網絡IO開銷

jedis最新版本,對於這些過度的hash slot更新和ping,都進行了優化,避免了類似問題

(5)hashslot遷移和ask重定向

如果hash slot正在遷移,那么會返回ask重定向給jedis

jedis接收到ask重定向之后,會重新定位到目標節點去執行,但是因為ask發生在hash slot遷移過程中,所以JedisCluster API收到ask是不會更新hashslot本地緩存

已經可以確定說,hashslot已經遷移完了,moved是會更新本地hashslot->node映射表緩存的

-------------------------------------------------------------------------------------------------------

高可用性與主備切換原理

redis cluster的高可用的原理,幾乎跟哨兵是類似的

1、判斷節點宕機

如果一個節點認為另外一個節點宕機,那么就是pfail,主觀宕機

如果多個節點都認為另外一個節點宕機了,那么就是fail,客觀宕機,跟哨兵的原理幾乎一樣,sdown,odown

在cluster-node-timeout內,某個節點一直沒有返回pong,那么就被認為pfail

如果一個節點認為某個節點pfail了,那么會在gossip ping消息中,ping給其他節點,如果超過半數的節點都認為pfail了,那么就會變成fail

2、從節點過濾

對宕機的master node,從其所有的slave node中,選擇一個切換成master node

檢查每個slave node與master node斷開連接的時間,如果超過了cluster-node-timeout * cluster-slave-validity-factor,那么就沒有資格切換成master

這個也是跟哨兵是一樣的,從節點超時過濾的步驟

3、從節點選舉

哨兵:對所有從節點進行排序,slave priority,offset,run id

每個從節點,都根據自己對master復制數據的offset,來設置一個選舉時間,offset越大(復制數據越多)的從節點,選舉時間越靠前,優先進行選舉

所有的master node開始slave選舉投票,給要進行選舉的slave進行投票,如果大部分master node(N/2 + 1)都投票給了某個從節點,那么選舉通過,那個從節點可以切換成master

從節點執行主備切換,從節點切換為主節點

4、與哨兵比較

整個流程跟哨兵相比,非常類似,所以說,redis cluster功能強大,直接集成了replication和sentinal的功能

 

redis源碼篇

 

SDS——動態字符串(原文鏈接:https://blog.csdn.net/qq193423571/java/article/details/81637075)
Redis中簡單動態字符串sds數據結構與API相關文件是:sds.h, sds.c。

SDS本質上就是char *,因為有了表頭sdshdr結構的存在,所以SDS比傳統C字符串在某些方面更加優秀,並且能夠兼容傳統C字符串。

sds在Redis中是實現字符串對象的工具,並且完全取代char*..sds是二進制安全的,它可以存儲任意二進制數據,不像C語言字符串那樣以‘\0’來標識字符串結束,

因為傳統C字符串符合ASCII編碼,這種編碼的操作的特點就是:遇零則止 。即,當讀一個字符串時,只要遇到’\0’結尾,就認為到達末尾,就忽略’\0’結尾以后的所有字符。因此,如果傳統字符串保存圖片,視頻等二進制文件,操作文件時就被截斷了。

SDS表頭的buf被定義為字節數組,因為判斷是否到達字符串結尾的依據則是表頭的len成員,這意味着它可以存放任何二進制的數據和文本數據,包括’\0’

SDS 和傳統的 C 字符串獲得的做法不同,傳統的C字符串遍歷字符串的長度,遇零則止,復雜度為O(n)。而SDS表頭的len成員就保存着字符串長度,所以獲得字符串長度的操作復雜度為O(1)。

總結下sds的特點是:帶着長度信息的字節數組,可動態擴展內存、二進制安全、快速遍歷字符串 和與傳統的C語言字符串類型兼容。

 

 

 

 


下面是一個不同 SDS 結構體下的不同字符串的例子:

 

 

 

 

上圖是sds的一個內部結構的例子。圖中展示了兩個sds字符串s1和s2的內存結構,一個使用sdshdr8類型的header,另一個使用sdshdr16類型的header。但它們都表達了同樣的一個長度為6的字符串的值:”tielei”。下面我們結合代碼,來解釋每一部分的組成。

sds結構一共有五種Header定義,其目的是為了滿足不同長度的字符串可以使用不同大小的Header,從而節省內存。 Header部分主要包含以下幾個部分: + len:表示字符串真正的長度,不包含空終止字符 + alloc:表示字符串的最大容量,不包含Header和最后的空終止字符 + flags:表示header的類型。

 

2.2 在RedisObject中,SDS的兩種存儲形式
詳情:

> set codehole abcdefghijklmnopqrstuvwxyz012345678912345678
OK
> debug object codehole
Value at:0x7fec2de00370 refcount:1 encoding:embstr serializedlength:45 lru:5958906 lru_seconds_idle:1
> set codehole abcdefghijklmnopqrstuvwxyz0123456789123456789
OK
> debug object codehole
Value at:0x7fec2dd0b750 refcount:1 encoding:raw serializedlength:46 lru:5958911 lru_seconds_idle:1...

一個字符的差別,存儲形式 encoding 就發生了變化。一個是 embstr,一個是 row。

在了解存儲格式的區別之前,首先了解下RedisObject結構體。

所有的 Redis 對象都有一個 Redis 對象頭結構體

struct RedisObject {
int4 type; // 4bits 類型
int4 encoding; // 4bits 存儲格式
int24 lru; // 24bits 記錄LRU信息
int32 refcount; // 4bytes
void *ptr; // 8bytes,64-bit system
} robj;

不同的對象具有不同的類型 type(4bit),同一個類型的 type 會有不同的存儲形式 encoding(4bit)。

為了記錄對象的 LRU 信息,使用了 24 個 bit 的 lru 來記錄 LRU 信息。

每個對象都有個引用計數 refcount,當引用計數為零時,對象就會被銷毀,內存被回收。ptr 指針將指向對象內容 (body) 的具體存儲位置


而Redis 的字符串共有兩種存儲方式,在長度特別短時,使用 emb 形式存儲 (embedded),當長度超過 44 時,使用 raw 形式存儲。

embstr 存儲形式是這樣一種存儲形式,它將 RedisObject 對象頭和 SDS 對象連續存在一起,使用 malloc 方法一次分配。而 raw 存儲形式不一樣,它需要兩次 malloc,兩個對象頭在內存地址上一般是不連續的。

在字符串比較小時,SDS 對象頭的大小是capacity+3——SDS結構體的內存大小至少是 3,一個 RedisObject 對象頭共需要占據 16 字節的存儲空間,字符串已\0結尾。意味着分配一個字符串的最小空間占用為 19 字節 (16+3)。

如果總體超出了 64 字節,Redis 認為它是一個大字符串,不再使用 emdstr 形式存儲,而該用 raw 形式。而64-19-結尾的\0,所以empstr只能容納44字節。

 

 

 

embstr 存儲形式是這樣一種存儲形式,它將 RedisObject 對象頭和 SDS 對象連續存在一起,使用 malloc 方法一次分配。而 raw 存儲形式不一樣,它需要兩次 malloc,兩個對象頭在內存地址上一般是不連續的。

在字符串比較小時,SDS 對象頭的大小是capacity+3——SDS結構體的內存大小至少是 3。意味着分配一個字符串的最小空間占用為 19 字節 (16+3)。

如果總體超出了 64 字節,Redis 認為它是一個大字符串,不再使用 emdstr 形式存儲,而該用 raw 形式。而64-19-結尾的\0,所以empstr只能容納44字節。

2.3 擴容策略

當字符串長度小於 1M 時,擴容都是加倍現有的空間,如果超過 1M,擴容時一次只會多擴 1M 的空間

 

dict——字典

類似java中的hashmap結構,但是擴容是有所不同;

漸進式哈希的精髓在於:數據的遷移不是一次性完成的,而是可以通過dictRehash()這個函數分步規划的,並且調用方可以及時知道是否需要繼續進行漸進式哈希操作。如果dict數據結構中存儲了海量的數據,那么一次性遷移勢必帶來redis性能的下降,別忘了redis是單線程模型,在實時性要求高的場景下這可能是致命的。而漸進式哈希則將這種代價可控地分攤了,調用方可以在dict做插入,刪除,更新的時候執行dictRehash(),最小化數據遷移的代價。
在遷移的過程中,數據是在新表還是舊表中並不是一個非常急迫的需求,遷移的過程並不會丟失數據,在舊表中找不到再到新表中尋找就是了。

dict的結構大致如上,接下來分析一下其中最重要的幾個數據成員:

dictht::table:哈希表內部的table結構使用了鏈地址法來解決哈希沖突,剛開始看的時候我很奇怪,這怎么是個二維數組?這其實是一個指向數組的指針,數組中的每一項都是entry鏈表的頭結點。
dictht ht[2]:在dict的內部,維護了兩張哈希表,作用等同於是一對滾動數組,一張表是舊表,一張表是新表,當hashtable的大小需要動態改變的時候,舊表中的元素就往新開辟的新表中遷移,當下一次變動大小,當前的新表又變成了舊表,以此達到資源的復用和效率的提升。
rehashidx:因為是漸進式的哈希,數據的遷移並不是一步完成的,所以需要有一個索引來指示當前的rehash進度。當rehashidx為-1時,代表沒有哈希操作。
————————————————

rehash是以bucket(桶)為基本單位進行漸進式的數據遷移的,每步完成一個bucket的遷移,直至所有數據遷移完畢。一個bucket對應哈希表數組中的一條entry鏈表。新版本的dictRehash()還加入了一個最大訪問空桶數(empty_visits)的限制來進一步減小可能引起阻塞的時間。
————————————————
最后是從《Redis設計與實現》中copy來的圖解,可以幫助大家更形象地理解整個incremental rehash的過程:

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

set——實現方式也是字典,只不過所有的value都是null,其他特性和字典一摸一樣

 

zipList壓縮列表

簡介

壓縮列表是 Redis 為了節約內存而開發的, 由一系列特殊編碼的連續內存塊組成的,

增加元素

因為ziplist緊湊,意味着每插入一個元素都要調用realloc擴展內存,依據待擴展內存的大小決定是,一次性拷貝新地址還是,在原地址擴展,如果ziplist內存過大重新分配內存可拷貝內存代價過高,故不適合存儲大型字符串字符串

級聯更新

因為ziplist每個元素entry都會有一個prevlen存儲前一個entry的長度,如果內容小於254,prevlen=1bit 否則prevlen=5bit。這意味着如果某個entry經過修改從253到254直接,那么他的下一個entry的prevlen字段就要更新,從1->5,如果后面這個entry的長度也是253,便產生二樓級聯更新

 

inset小整數集合

簡介

集合元素都是整數並且元素個數較少時使用,當set里面放入非整數是,存儲形式立即從inset轉變成hash

 

quicklist快速列表

概述
考慮到鏈表的附加空間相對太高,prev 和 next 指針就要占去 16 個字節 (64bit 系統的指針是 8 個字節),另外每個節點的內存都是單獨分配,會加劇內存的碎片化,影響內存管理效率。

后續版本對列表數據結構進行了改造,使用 quicklist 代替了 ziplist 和 linkedlist.

基本結構
quickList 是 zipList 和 linkedList 的混合體,它將 linkedList 按段切分,每一段使用 zipList 來緊湊存儲,多個 zipList 之間使用雙向指針串接起來。

 

 

 

壓縮深度
quicklist 默認的壓縮深度是 0,也就是不壓縮。壓縮的實際深度由配置參數lis搞t-compress-depth決定。

為了支持快速的 push/pop 操作,quicklist 的首尾兩個 ziplist 不壓縮,此時深度就是 1。

如果深度為 2,就表示 quicklist 的首尾第一個 ziplist 以及首尾第二個 ziplist 都不壓縮。

zipList 長度
quicklist 內部默認單個 ziplist 長度為 8k 字節,超出了這個字節數,就會新起一個 ziplist。

ziplist 的長度由配置參數 list-max-ziplist-size 決定。

 

skiplist數據結構簡介

skiplist本質上也是一種查找結構,用於解決算法中的查找問題(Searching),即根據給定的key,快速查到它所在的位置(或者對應的value)。

我們在《Redis內部數據結構詳解》系列的第一篇中介紹dict的時候,曾經討論過:一般查找問題的解法分為兩個大類:一個是基於各種平衡樹,一個是基於哈希表。但skiplist卻比較特殊,它沒法歸屬到這兩大類里面。

這種數據結構是由William Pugh發明的,最早出現於他在1990年發表的論文《Skip Lists: A Probabilistic Alternative to Balanced Trees》。對細節感興趣的同學可以下載論文原文來閱讀。

skiplist,顧名思義,首先它是一個list。實際上,它是在有序鏈表的基礎上發展起來的。

我們先來看一個有序鏈表,如下圖(最左側的灰色節點表示一個空的頭結點):

有序鏈表結構圖

在這樣一個鏈表中,如果我們要查找某個數據,那么需要從頭開始逐個進行比較,直到找到包含數據的那個節點,或者找到第一個比給定數據大的節點為止(沒找到)。也就是說,時間復雜度為O(n)。同樣,當我們要插入新數據的時候,也要經歷同樣的查找過程,從而確定插入位置。

假如我們每相鄰兩個節點增加一個指針,讓指針指向下下個節點,如下圖:

每兩個節點增加一個跳躍指針的有序鏈表

這樣所有新增加的指針連成了一個新的鏈表,但它包含的節點個數只有原來的一半(上圖中是7, 19, 26)。現在當我們想查找數據的時候,可以先沿着這個新鏈表進行查找。當碰到比待查數據大的節點時,再回到原來的鏈表中進行查找。比如,我們想查找23,查找的路徑是沿着下圖中標紅的指針所指向的方向進行的:

一個搜索路徑的例子

  • 23首先和7比較,再和19比較,比它們都大,繼續向后比較。
  • 但23和26比較的時候,比26要小,因此回到下面的鏈表(原鏈表),與22比較。
  • 23比22要大,沿下面的指針繼續向后和26比較。23比26小,說明待查數據23在原鏈表中不存在,而且它的插入位置應該在22和26之間。

在這個查找過程中,由於新增加的指針,我們不再需要與鏈表中每個節點逐個進行比較了。需要比較的節點數大概只有原來的一半。

利用同樣的方式,我們可以在上層新產生的鏈表上,繼續為每相鄰的兩個節點增加一個指針,從而產生第三層鏈表。如下圖:

兩層跳躍指針

在這個新的三層鏈表結構上,如果我們還是查找23,那么沿着最上層鏈表首先要比較的是19,發現23比19大,接下來我們就知道只需要到19的后面去繼續查找,從而一下子跳過了19前面的所有節點。可以想象,當鏈表足夠長的時候,這種多層鏈表的查找方式能讓我們跳過很多下層節點,大大加快查找的速度。

skiplist正是受這種多層鏈表的想法的啟發而設計出來的。實際上,按照上面生成鏈表的方式,上面每一層鏈表的節點個數,是下面一層的節點個數的一半,這樣查找過程就非常類似於一個二分查找,使得查找的時間復雜度可以降低到O(log n)。但是,這種方法在插入數據的時候有很大的問題。新插入一個節點之后,就會打亂上下相鄰兩層鏈表上節點個數嚴格的2:1的對應關系。如果要維持這種對應關系,就必須把新插入的節點后面的所有節點(也包括新插入的節點)重新進行調整,這會讓時間復雜度重新蛻化成O(n)。刪除數據也有同樣的問題。

skiplist為了避免這一問題,它不要求上下相鄰兩層鏈表之間的節點個數有嚴格的對應關系,而是為每個節點隨機出一個層數(level)。比如,一個節點隨機出的層數是3,那么就把它鏈入到第1層到第3層這三層鏈表中。為了表達清楚,下圖展示了如何通過一步步的插入操作從而形成一個skiplist的過程:

skiplist插入形成過程

從上面skiplist的創建和插入過程可以看出,每一個節點的層數(level)是隨機出來的,而且新插入一個節點不會影響其它節點的層數。因此,插入操作只需要修改插入節點前后的指針,而不需要對很多節點都進行調整。這就降低了插入操作的復雜度。實際上,這是skiplist的一個很重要的特性,這讓它在插入性能上明顯優於平衡樹的方案。這在后面我們還會提到。

根據上圖中的skiplist結構,我們很容易理解這種數據結構的名字的由來。skiplist,翻譯成中文,可以翻譯成“跳表”或“跳躍表”,指的就是除了最下面第1層鏈表之外,它會產生若干層稀疏的鏈表,這些鏈表里面的指針故意跳過了一些節點(而且越高層的鏈表跳過的節點越多)。這就使得我們在查找數據的時候能夠先在高層的鏈表中進行查找,然后逐層降低,最終降到第1層鏈表來精確地確定數據位置。在這個過程中,我們跳過了一些節點,從而也就加快了查找速度。

剛剛創建的這個skiplist總共包含4層鏈表,現在假設我們在它里面依然查找23,下圖給出了查找路徑:

skiplist上的查找路徑展示


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM