天天看點

10w+QPS 的 Redis 真的隻是因為單線程和記憶體?360° 深入底層設計為你揭開 Redis 神秘面紗!

10w+QPS 的 Redis 真的隻是因為單線程和記憶體?360° 深入底層設計為你揭開 Redis 神秘面紗!

你以為 Redis 這麼快僅僅因為單線程和基于記憶體?

那麼你想得太少了,我個人認為 Redis 的快是基于多方面的:不但是單線程和記憶體,還有底層的資料結構設計,網絡通信的設計,主從、哨兵和叢集等等方面的設計~

下面,我将 360° 為你揭開 Redis QPS達到10萬/秒的神秘面紗。

一、底層資料結構設計

1、底層架構:

首先值得稱贊的第一點:Redis 底層使用的資料結構很多,但是卻沒有直接使用這些資料結構來實作鍵值對資料庫,而是基于資料結建構立了一個對象(redisObject)系統。(是不是覺得有點面向對象程式設計的意思 😂 ~)

對象系統裡面包括了字元串對象,清單對象,哈希對象、集合對象和有序集合對象。

使用對象的好處:

Redis 在執行指令之前,可以根據對象的類型判斷這個對象是否可以執行給定的指令。

可以針對不用的使用場景,為對象設定多種不同的資料結構實作,進而優化對象在不同場景下的使用效率。

一個對象怎麼設定不同的資料結構實作?

在講解前,我們必須要了解 Redis 對象的結構。

它三個重要的部分:type 屬性、encoding 屬性,和 ptr 屬性。

我們用字元串對象為例:

我們都知道,Redis 的 SET 指令其實是針對字元串的,但是它也可以設定數值。那底層是怎麼做的呢?

它會将 String 對象的 encoding 屬性辨別為 REDIS_ENCODING_INT,表示這個鍵對應的值是 Long 類型的整數。

而當我們利用 APPEND 指令往值後面添加字元串呢?

此時會将 String 對象的 encoding 屬性的辨別為 REDIS_ENCODING_RAW,表示這個值此時是簡單動态字元串。

正是因為使用對象,通過 type、encoding和prt 屬性,使得同一個對象可以适應在不同的場景下,使得不同的改變不需要建立新的鍵值對,這樣使得 Redis 的對象使用效率非常的高。

2、靈活的字元串對象

Redis 的字元串對象采用三種編碼:int、embstr 和 raw。

int 編碼就不用說了,就是為了相容 SET 指令可以設定數值。

而 embstr 和 raw 最大的差別就是記憶體配置設定操作次數:

embstr 編碼專門用于儲存短字元串,是以它是通過調用一次記憶體配置設定函數來配置設定一塊連續的空間,空間包含 redisObject 和 sdsshdr 兩個結構,這樣可以很好地利用緩存帶來的優勢。

raw 編碼則是用于儲存長字元串,它通過調用兩次記憶體配置設定函數來分别建立 redisObject 結構和 sdshdr 結構

3、絕妙的字元串優化政策

Redis 中字元串對象的底層是使用 SDS (Simple Dynamic String)實作的。

SDS 有三部分:

len:記錄 buf 數組中已使用位元組的數量,等于 SDS 鎖儲存字元串的長度

free:記錄 buf 數組中未使用位元組的數量

buf[]:位元組數組,用于儲存字元串

首先介紹一下使用 len 屬性和 free 屬性的好處:

得益于 SDS 有 len 屬性,擷取字元串長度的複雜度為 O(1);

得益于 SDS 有 free 屬性,可以杜絕緩沖區溢出,字元串擴充前可以根據 free 屬性來判斷是否滿足直接擴充,不滿足則需要先執行記憶體重配置設定操作,然後再擴充字元串。

我們都知道修改字元串長度很有可能導緻觸發記憶體重配置設定操作,但是 Redis 對于記憶體重配置設定有兩個優化政策:

空間預配置設定:

空間預配置設定用于優化 SDS 的字元串增長操作:當 SDS 的API對一個 SDS 進行修改,并且需要對 SDS 進行空間擴充的時候,程式不僅會為 SDS 配置設定修改所必須要的空間,還會為 SDS 配置設定額外的未使用空間,并使用 free 屬性來記錄這些額外配置設定的位元組的數量。

通過空間預配置設定政策,下次字元串擴充時,可以充分利用上次預配置設定的未使用空間,而不用再觸發記憶體重配置設定操作了。

惰性空間釋放:

惰性空間釋放用于優化 SDS 的字元串縮短操作:當 SDS 的API需要縮短 SDS 儲存的字元串時,程式并不立即使用記憶體重配置設定來回收縮短後多出來的位元組,而是使用上面提到的 free 屬性将這些位元組的數量記錄起來,并等待将來使用。

通過惰性空間釋放政策,SDS 避免了縮短字元串時所需的記憶體重配置設定操作,并為将來可能有的增長操作提供了優化。

4、字元串變量的共享和适配

對象中使用數字是非常常見的,例如設定使用者的年齡、學生的分數、部落格中文章的排名等等。是以 Redis 為了避免重複建立數字對應的字元串對象,它會将一個範圍的整數對應的字元串對象用來共享。

目前來說,Redis 會在初始化伺服器時,建立一萬個字元串對象,這些對象包含了從 0 到 9999 的所有整數值,當伺服器需要用到值為 0 到 9999 的字元串對象時,伺服器就會使用這些共享對象,而不是新建立對象。

當然了,我們還可以通過修改 redis.h/REDIS_SHARED_INTEGERS 常量來修改建立共享字元串對象的數量。

我們都知道 Redis 是使用 C 語言開發的,是以 SDS 一樣遵循 C 字元串以空字元結尾的慣例,是以 SDS 可以重用很多 庫定義的函數。

5、強大的壓縮清單 ziplist

簡單介紹一下 ziplist 的結構:

zlbytes:記錄整個壓縮清單占用的記憶體位元組數;在對壓縮清單進行記憶體重配置設定時,或者計算 zlend 的位置時使用

zltail:記錄壓縮清單表尾節點距離壓縮清單的起始位址有多少位元組;通過這個偏移量,程式無須周遊真個壓縮清單就可以确定表尾節點的位址

zlen:記錄了壓縮清單包含的節點數量;當這個屬性的值大于 UINT16_MAX(65535)時,節點的真實數量需要周遊整個壓縮清單才能計算出來。

entryX:壓縮清單的包含的各個節點,節點的長度由節點儲存的内容決定。

zlend:特殊值0XFF(十進制255),用于标記壓縮清單的末端。

壓縮清單是一種為節約記憶體而開發的順序型資料結構,是以在 Redis 裡面壓縮清單被用做清單鍵和哈希鍵的底層實作之一。

當一個清單鍵隻包含少量清單項,并且每個清單項要麼就是小整數值,要麼就是長度比較短的字元串,那麼Redis就會使用壓縮清單來做清單鍵的底層實作。

當一個哈希鍵隻包含少量鍵值對,并且每個鍵值對的鍵和值要麼就是小整數值,要麼就是長度比較短的字元串,那麼Redis就會使用壓縮清單來做哈希鍵的底層實作。

正是利用壓縮清單,不但使得資料非常緊湊而節約記憶體,而且還可以利用它的結構來做到非常簡單的順序周遊、逆序周遊,O(1) 複雜度的擷取長度和所占記憶體大小等等。

6、整數集合 intset 的更新政策

整數集合(intset)是 Redis 用于儲存整數值的集合抽象資料結構,它可以儲存類型為 int16_t、int32_t 或者 int64_t 的整數值,并且保證集合中不會出現重複元素。

我們先看看整數集合的結構:

typeof struct intset{

uint32_t encoding;
uint32_t length;
int8_t contents[];           

} intset;

雖然 intset 結構将 contents 屬性聲明為 int8_t類型的數組,但實際上 contents 數組并不儲存任何 int8_t 類型的值,contents 數組的真正類型取決于 encoding 屬性的值。

intset 一開始不會直接使用最大類型來定義數組,而是利用更新操作,當元素的值達到一定長度時,會重新為數組配置設定記憶體空間,并将數組裡的舊元素的類型進行更新。

這樣做好處:

避免錯誤類型,能自适應新添加的新元素的長度。隻要是更新了,那麼小于對應的長度的數值都可以存進來,而如果長度不足,大不了再更新一次即可。而且,intset 最多就更新兩次,不用擔心更新次數多而導緻性能降低。

節約記憶體,隻要當需要時才會進行更新操作,這樣可以很好地節省記憶體。

因為整數集合沒有降級操作,是以從另外一個角度看,更新操作其實也會浪費記憶體:如果整數集合裡隻有一個數值是 int64_t ,而其他數值都是小于它的,但是整數集合的編碼将還是保持 INTSET_ENC_INT64,就是說,小于 int64_t 的整數還是會用 int64_t 的空間來儲存。

二、單機資料庫實作設計

1、Reactor的I/O多路複用

每當别人問 Redis 為啥這麼快?吐口而出的不是基于記憶體就是基于單線程。

Redis 使用基于 Reactor 模式實作的網絡通信,它使用 I/O 多路複用(multiplexing)程式來同時監聽多個套接字,并根據套接字目前執行的任務來為套接字關聯不同的事件處理器。

當被監聽的套接字準備好執行連接配接應答(accept)、讀取(read)、寫入(write)、關閉(close)等操作時,與操作相對應的檔案事件就會産生,這時檔案事件分派器就會調用套接字之前關聯好的事件處理器來處理這些事件。

因為 Redis 是單線程的,是以I/O多路複用程式會利用隊列來控制産生事件的套接字的并發;隊列中的套接字以有序、同步、每次一個的方式分派給檔案事件分派器。

多種 I/O 複用機制:

常見的 I/O 複用機制有很多種,例如 select、epoll、evport 和 kqueue 等等。

Redis 對上面的多種 I/O 複用機制都進行了各自的封裝,在程式編譯時會自動選擇系統中性能最高的 I/O 多路複用函數庫來作為 Redis 的 I/O 多路複用程式的底層實作。

2、同步處理的檔案事件和時間事件

我們都知道,檔案事件的發生都是随機的,因為 Redis 伺服器永遠不可能知道用戶端下次發送指令是什麼時候,是以程式也不可能一直阻塞着直到發生檔案事件。

畢竟 Redis 是單線程的,檔案事件的處理和時間事件的處理都在同一個線程裡,如果線程被 aeApiPoll 函數一直阻塞着,那麼即使時間事件的時間到了,也得不到資源來執行。

是以 Redis 有這麼一個政策,aeApiPoll 函數的最大阻塞時間由到達時間最接近目前時間的時間事件決定,這個方法既可以避免伺服器對時間事件進行頻繁的輪詢(忙等待),也可以確定 aeApiPoll 函數不會阻塞過長時間。

對檔案事件和時間事件的處理都是同步、有序、原子地執行的,伺服器不會中途中斷事件處理,也不會對事件進行搶占,是以,不管是檔案事件的處理器,還是時間事件的處理器,它們都會盡可地減少程式的阻塞時間,并在有需要時主動讓出執行權,進而降低造成事件饑餓的可能性。

3、目前時間緩存

Redis 伺服器中不少功能是要使用系統的目前時間的,而擷取系統目前時間需要執行一次系統調用。

為了減少系統調用,提升性能,伺服器狀态(redisServer)中的 unixtime 屬性和 mstime 屬性分别儲存了秒級精度的系統目前 UNIX 時間戳和毫秒級精度的系統目前 UNIX 時間戳;然後 serverCron 函數會每隔 100 毫秒更新一次這兩個屬性。

這兩個時間隻會用在對時間精确度要求不高的功能上,例如列印日志、計算伺服器上線時間等等。

像設定鍵過期時間、添加慢查詢日志這種需要時間精确度高的功能上,伺服器還是會每次都調用系統來擷取。

三、多機資料庫實作設計

1、主從模式 -> 複制算法優化

Redis 2.8 前的複制功能:

從伺服器向主伺服器發送 SYNC 指令。

主伺服器收到 SYNC 指令後,背景生成一個 RDB 檔案(BGSAVE),并使用一個緩沖區記錄從現在開始執行的所有寫指令。

當主伺服器的 BGSAVE 指令執行完畢,将生成的 RDB 檔案發送給從伺服器;從伺服器接收并載入這個 RDB 檔案。

主伺服器将緩沖區裡的所有寫指令發送給從伺服器;從伺服器執行這些寫指令。

至此,主從伺服器兩者的資料庫将達到一緻狀态。

缺點:

假設主從伺服器斷開連接配接,當從伺服器重新連接配接上後,又要重新執行一遍同步(sync)操作;但是其實,從伺服器重新連接配接時,資料庫狀态和主伺服器大緻是一樣的,缺少的隻是斷開連接配接過程中,主伺服器接收到的寫指令;每次斷線後都需要重新執行一遍完整的同步操作,這樣會很浪費主伺服器的性能,畢竟 BGSAVE 指令要讀取此時主伺服器完整的資料庫狀态。

Redis 2.8 後對複制算法進行了很大的優化:

利用 PSYNC 指令代替 SYNC 指令,将複制操作分為完整重同步和部分重同步。隻有當從伺服器第一次複制或斷開時間過長時,才會執行完整重同步,而從伺服器短時間斷開重連後,隻需要将自己的 offset(複制偏移量)發送給主伺服器,主伺服器會根據從伺服器的 offset 和自己的 offset,然後從複制積壓緩沖區裡将從伺服器丢失的寫指令發送給從伺服器,從伺服器隻要接收并執行這些寫指令,就可以将資料庫更新至主伺服器目前所處的狀态。

2、主從模式 -> 心跳檢測

在指令傳播階段,從伺服器預設會以每秒一次的頻率,向主伺服器發送指令:REPLCONF ACK ,其中 replication_offet 是從伺服器目前的複制偏移量。

心跳檢測的三大作用:

心跳檢測不但可以檢測主從伺服器之間的網絡狀态。

從伺服器還會将它的複制偏移量發送給主伺服器,讓主伺服器檢查從伺服器的指令是否丢失了。

心跳檢測還能輔助實作 min-slaves 配置選項:

min-slaves-to-write 3

min-slaves-max-lag 10

解釋:那麼在從伺服器的數量少于3個,或者三個從伺服器的延遲(lag)值都大于或等于10秒時,主伺服器将拒絕執行寫指令,這裡的延遲值就是上面提到的INFO replication指令的lag值。

3、哨兵模式的訂閱連接配接設計

Sentinel 不但會與主從伺服器建立指令連接配接,還會建立訂閱連接配接。

在預設情況下,Sentinel會以每兩秒一次的頻率,通過指令連接配接向所有被監視的主伺服器和從伺服器發送 PUBLISH 指令,指令附帶的是 Sentinel 本身的資訊和所監聽的主伺服器的資訊;接着接收到此指令的主從伺服器會向 _sentinel_:hello 頻道發送這些資訊。

而其他所有都是監聽此主從伺服器的 Sentinel 可以通過訂閱連接配接擷取到上面的資訊。

這也就是說,對于每個與 Sentinel 的伺服器,Sentinel 既通過指令連接配接向伺服器的 __sentinel__:hello 頻道發送資訊(PUBLISH),又通過訂閱連接配接從伺服器的 __sentinel__:hello 頻道接收資訊(SUBSCRIBE)。

通過這種方式,監聽同一個主伺服器的 Sentinel 們可以互相知道彼此的存在,并且可以根據頻道消息更新主伺服器執行個體結構(sentinelRedisInstance)的 sentinels 字典,還可借此與其他 Sentinel 建立指令連接配接,友善之後關于主伺服器下線檢查、選舉領頭 Sentinel 等等的通信。

4、叢集模式中的 Gossip協定

Redis 叢集中的各個節點通過 Gossip 協定來交換各自關于不同節點的狀态資訊,其中 Gossip 協定由 MEET、PING、PONG 三種消息實作,這三種消息的正文都由兩個 cluster.h/clusterMsgDataGossip 結構組成。

利用 Gossip 協定,可以使得叢集中節點更新的資訊像病毒一樣擴散,這樣不但擴散速度快,而且不需要每個節點之間都發送一次消息才能同步叢集中最新的資訊。

四、總結

至此,我自己能想到的使得 Redis 性能優越的設計都在這裡了。當然了,它的厲害之處遠遠不止這些~

大家都知道,使用 Redis 是非常簡單的,來來去去就幾個指令,但是當你深入 Redis 底層的設計和實作,你會發現,這真的是一個非常值得大家深究的開源中間件!!!

參考資料:《Redis 設計與實作》,這是一本寫得非常好的書,通俗易懂~

原文連結:10w+QPS 的 Redis 真的隻是因為單線程和記憶體?360° 深入底層設計為你揭開 Redis 神秘面紗!