Redis 高頻面試題:10w+QPS 的 Redis 真的只是由於單線程和基於內存?

你覺得 Redis 這麼快僅僅由於單線程和基於內存?面試

那麼你想得太少了,我我的認爲 Redis 的快是基於多方面的:不可是單線程和內存,還有底層的數據結構設計,網絡通訊的設計,主從、哨兵和集羣等等方面的設計~redis

下面,我將 360° 爲你揭開 Redis QPS達到10萬/秒的神祕面紗。算法

1、底層數據結構設計

一、底層架構:

首先值得稱讚的第一點:Redis 底層使用的數據結構不少,可是卻沒有直接使用這些數據結構來實現鍵值對數據庫,而是基於數據結構建立了一個對象(redisObject)系統。(是否是以爲有點面向對象編程的意思 ~)數據庫

對象系統裏面包括了字符串對象,列表對象,哈希對象、集合對象和有序集合對象。編程

使用對象的好處:數組

  • Redis 在執行命令以前,能夠根據對象的類型判斷這個對象是否能夠執行給定的命令。
  • 能夠針對不一樣的使用場景,爲對象設置多種不一樣的數據結構實現,從而優化對象在不一樣場景下的使用效率。

一個對象怎麼設置不一樣的數據結構實現?緩存

在講解前,咱們必需要了解 Redis 對象的結構。服務器

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

咱們用字符串對象爲例:數據結構

咱們都知道,Redis 的 SET 命令實際上是針對字符串的,可是它也能夠設置數值。那底層是怎麼作的呢?

它會將 String 對象的 encoding 屬性標識爲 REDIS_ENCODING_INT,表示這個鍵對應的值是 Long 類型的整數。

Redis 高頻面試題:10w+QPS 的 Redis 真的只是由於單線程和基於內存?

而當咱們利用 APPEND 命令往值後面添加字符串呢?

此時會將 String 對象的 encoding 屬性的標識爲 REDIS_ENCODING_RAW,表示這個值此時是簡單動態字符串。

Redis 高頻面試題:10w+QPS 的 Redis 真的只是由於單線程和基於內存?

正是由於使用對象,經過 type、encoding和prt 屬性,使得同一個對象能夠適應在不一樣的場景下,使得不一樣的改變不須要建立新的鍵值對,這樣使得 Redis 的對象使用效率很是的高。

二、靈活的字符串對象

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

int 編碼就不用說了,就是爲了兼容 SET 命令能夠設置數值。

而 embstr 和 raw 最大的區別就是內存分配操做次數:

  • embstr 編碼專門用於保存短字符串,因此它是經過調用一次內存分配函數來分配一塊連續的空間,空間包含 redisObject 和 sdsshdr 兩個結構,這樣能夠很好地利用緩存帶來的優點。
  • raw 編碼則是用於保存長字符串,它經過調用兩次內存分配函數來分別建立 redisObject 結構和 sdshdr 結構

三、絕妙的字符串優化策略

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 避免了縮短字符串時所需的內存重分配操做,併爲未來可能有的增加操做提供了優化。

四、字符串變量的共享和適配

對象中使用數字是很是常見的,例如設置用戶的年齡、學生的分數、博客中文章的排名等等。因此 Redis 爲了不重複建立數字對應的字符串對象,它會將一個範圍的整數對應的字符串對象用來共享。

目前來講,Redis 會在初始化服務器時,建立一萬個字符串對象,這些對象包含了從 0 到 9999 的全部整數值,當服務器須要用到值爲 0 到 9999 的字符串對象時,服務器就會使用這些共享對象,而不是新建立對象。

固然了,咱們還能夠經過修改 redis.h/REDIS_SHARED_INTEGERS 常量來修改建立共享字符串對象的數量。

咱們都知道 Redis 是使用 C 語言開發的,因此 SDS 同樣遵循 C 字符串以空字符結尾的慣例,因此 SDS 能夠重用不少 <string.h> 庫定義的函數。

五、強大的壓縮列表 ziplist

簡單介紹一下 ziplist 的結構:

Redis 高頻面試題:10w+QPS 的 Redis 真的只是由於單線程和基於內存?

  • zlbytes:記錄整個壓縮列表佔用的內存字節數;在對壓縮列表進行內存重分配時,或者計算 zlend 的位置時使用
  • zltail:記錄壓縮列表表尾節點距離壓縮列表的起始地址有多少字節;經過這個偏移量,程序無須遍歷真個壓縮列表就能夠肯定表尾節點的地址
  • zlen:記錄了壓縮列表包含的節點數量;當這個屬性的值大於 UINT16_MAX(65535)時,節點的真實數量須要遍歷整個壓縮列表才能計算出來。
  • entryX:壓縮列表的包含的各個節點,節點的長度由節點保存的內容決定。
  • zlend:特殊值0XFF(十進制255),用於標記壓縮列表的末端。

壓縮列表是一種爲節約內存而開發的順序型數據結構,因此在 Redis 裏面壓縮列表被用作列表鍵和哈希鍵的底層實現之一。

  • 當一個列表鍵只包含少許列表項,而且每一個列表項要麼就是小整數值,要麼就是長度比較短的字符串,那麼Redis就會使用壓縮列表來作列表鍵的底層實現。
  • 當一個哈希鍵只包含少許鍵值對,而且每一個鍵值對的鍵和值要麼就是小整數值,要麼就是長度比較短的字符串,那麼Redis就會使用壓縮列表來作哈希鍵的底層實現。

正是利用壓縮列表,不但使得數據很是緊湊而節約內存,並且還能夠利用它的結構來作到很是簡單的順序遍歷、逆序遍歷,O(1) 複雜度的獲取長度和所佔內存大小等等。

六、整數集合 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 的空間來保存。

2、單機數據庫實現設計

一、Reactor的I/O多路複用

每當別人問 Redis 爲啥這麼快?脫口而出的不是基於內存就是基於單線程。

Redis 使用基於 Reactor 模式實現的網絡通訊,它使用 I/O 多路複用(multiplexing)程序來同時監聽多個套接字,並根據套接字目前執行的任務來爲套接字關聯不一樣的事件處理器。

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

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

Redis 高頻面試題:10w+QPS 的 Redis 真的只是由於單線程和基於內存?

多種 I/O 複用機制:

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

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

二、同步處理的文件事件和時間事件

咱們都知道,文件事件的發生都是隨機的,由於 Redis 服務器永遠不可能知道客戶端下次發送命令是何時,因此程序也不可能一直阻塞着直到發生文件事件。

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

因此 Redis 有這麼一個策略,aeApiPoll 函數的最大阻塞時間由到達時間最接近當前時間的時間事件決定,這個方法既能夠避免服務器對時間事件進行頻繁的輪詢(忙等待),也能夠確保 aeApiPoll 函數不會阻塞過長時間。

對文件事件和時間事件的處理都是同步、有序、原子地執行的,服務器不會中途中斷事件處理,也不會對事件進行搶佔,所以,無論是文件事件的處理器,仍是時間事件的處理器,它們都會盡可地減小程序的阻塞時間,並在有須要時主動讓出執行權,從而下降形成事件飢餓的可能性。

三、當前時間緩存

Redis 服務器中很多功能是要使用系統的當前時間的,而獲取系統當前時間須要執行一次系統調用。

爲了減小系統調用,提高性能,服務器狀態(redisServer)中的 unixtime 屬性和 mstime 屬性分別保存了秒級精度的系統當前 UNIX 時間戳和毫秒級精度的系統當前 UNIX 時間戳;而後 serverCron 函數會每隔 100 毫秒更新一次這兩個屬性。

這兩個時間只會用在對時間精確度要求不高的功能上,例如打印日誌、計算服務器上線時間等等。
像設置鍵過時時間、添加慢查詢日誌這種須要時間精確度高的功能上,服務器仍是會每次都調用系統來獲取。

3、多機數據庫實現設計

一、主從模式 -> 複製算法優化

Redis 2.8 前的複製功能:

  • 從服務器向主服務器發送 SYNC 命令。
  • 主服務器收到 SYNC 命令後,後臺生成一個 RDB 文件(BGSAVE),並使用一個緩衝區記錄從如今開始執行的全部寫命令。
  • 當主服務器的 BGSAVE 命令執行完畢,將生成的 RDB 文件發送給從服務器;從服務器接收並載入這個 RDB 文件。
  • 主服務器將緩衝區裏的全部寫命令發送給從服務器;從服務器執行這些寫命令。
  • 至此,主從服務器二者的數據庫將達到一致狀態。

缺點:

假設主從服務器斷開鏈接,當從服務器從新鏈接上後,又要從新執行一遍同步(sync)操做;可是其實,從服務器從新鏈接時,數據庫狀態和主服務器大體是同樣的,缺乏的只是斷開鏈接過程當中,主服務器接收到的寫命令;每次斷線後都須要從新執行一遍完整的同步操做,這樣會很浪費主服務器的性能,畢竟 BGSAVE 命令要讀取此時主服務器完整的數據庫狀態。

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

利用 PSYNC 命令代替 SYNC 命令,將複製操做分爲完整重同步和部分重同步。只有當從服務器第一次複製或斷開時間過長時,纔會執行完整重同步,而從服務器短期斷開重連後,只須要將本身的 offset(複製偏移量)發送給主服務器,主服務器會根據從服務器的 offset 和本身的 offset,而後從複製積壓緩衝區裏將從服務器丟失的寫命令發送給從服務器,從服務器只要接收並執行這些寫命令,就能夠將數據庫更新至主服務器當前所處的狀態。

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

在命令傳播階段,從服務器默認會以每秒一次的頻率,向主服務器發送命令:REPLCONF ACK <replication_offset>,其中 replication_offet 是從服務器當前的複製偏移量。

心跳檢測的三大做用:

  • 心跳檢測不但能夠檢測主從服務器之間的網絡狀態。

  • 從服務器還會將它的複製偏移量發送給主服務器,讓主服務器檢查從服務器的命令是否丟失了。

  • 心跳檢測還能輔助實現 min-slaves 配置選項:
min-slaves-to-write 3
min-slaves-max-lag 10

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

三、哨兵模式的訂閱鏈接設計

Sentinel 不但會與主從服務器創建命令鏈接,還會創建訂閱鏈接。

在默認狀況下,Sentinel會以每兩秒一次的頻率,經過命令鏈接向全部被監視的主服務器和從服務器發送 PUBLISH 命令,命令附帶的是 Sentinel 自己的信息和所監聽的主服務器的信息;接着接收到此命令的主從服務器會向 sentinel:hello 頻道發送這些信息。

而其餘全部都是監聽此主從服務器的 Sentinel 能夠經過訂閱鏈接獲取到上面的信息。

這也就是說,對於每一個與 Sentinel 的服務器,Sentinel 既經過命令鏈接向服務器的 sentinel:hello 頻道發送信息(PUBLISH),又經過訂閱鏈接從服務器的 sentinel:hello 頻道接收信息(SUBSCRIBE)。

經過這種方式,監聽同一個主服務器的 Sentinel 們能夠互相知道彼此的存在,而且能夠根據頻道消息更新主服務器實例結構(sentinelRedisInstance)的 sentinels 字典,還可藉此與其餘 Sentinel 創建命令鏈接,方便以後關於主服務器下線檢查、選舉領頭 Sentinel 等等的通訊。

四、集羣模式中的 Gossip協議

Redis 集羣中的各個節點經過 Gossip 協議來交換各自關於不一樣節點的狀態信息,其中 Gossip 協議由 MEET、PING、PONG 三種消息實現,這三種消息的正文都由兩個 cluster.h/clusterMsgDataGossip 結構組成。

利用 Gossip 協議,可使得集羣中節點更新的信息像病毒同樣擴散,這樣不但擴散速度快,並且不須要每一個節點之間都發送一次消息才能同步集羣中最新的信息。

4、總結

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

你們都知道,使用 Redis 是很是簡單的,來來去去就幾個命令,可是當你深刻 Redis 底層的設計和實現,你會發現,這真的是一個很是值得你們深究的開源中間件!!!

相關文章
相關標籤/搜索