Redis 核心篇:惟快不破的祕密

天下武功,無堅不摧,惟快不破!面試

學習一個技術,一般只接觸了零散的技術點,沒有在腦海裏創建一個完整的知識框架和架構體系,沒有系統觀。這樣會很吃力,並且會出現一看好像本身會,事後就忘記,一臉懵逼。redis

跟着「碼哥字節」一塊兒吃透 Redis,深層次的掌握 Redis 核心原理以及實戰技巧。一塊兒搭建一套完整的知識框架,學會全局觀去整理整個知識體系。數據庫

系統觀實際上是相當重要的,從某種程度上說,在解決問題時,擁有了系統觀,就意味着你能有依據、有章法地定位和解決問題。數組

Redis 全景圖

全景圖能夠圍繞兩個緯度展開,分別是:緩存

應用緯度:緩存使用、集羣運用、數據結構的巧妙使用安全

系統緯度:能夠歸類爲三高服務器

  1. 高性能:線程模型、網絡 IO 模型、數據結構、持久化機制;
  2. 高可用:主從複製、哨兵集羣、Cluster 分片集羣;
  3. 高拓展:負載均衡

Redis 系列篇章圍繞以下思惟導圖展開,此次從 《Redis 惟快不破的祕密》一塊兒探索 Redis 的核心知識點。微信

吃透Redis

惟快不破的祕密

65 哥前段時間去面試 996 大廠,被問到「Redis 爲何快?」網絡

65 哥:額,由於它是基於內存實現和單線程模型數據結構

面試官:還有呢?

65 哥:沒了呀。

不少人僅僅只是知道基於內存實現,其餘核心的緣由模凌兩可。今日跟着「碼哥字節」一塊兒探索真正快的緣由,作一個惟快不破的真男人!

Redis 爲了高性能,從各方各面都進行了優化,下次小夥伴們面試的時候,面試官問 Redis 性能爲何如此高,可不能傻傻的只說單線程和內存存儲了。

惟快不破的祕密

根據官方數據,Redis 的 QPS 能夠達到約 100000(每秒請求數),有興趣的能夠參考官方的基準程序測試《How fast is Redis?》,地址:https://redis.io/topics/benchmarks

基準測試

橫軸是鏈接數,縱軸是 QPS。此時,這張圖反映了一個數量級,但願你們在面試的時候能夠正確的描述出來,不要問你的時候,你回答的數量級相差甚遠!

徹底基於內存實現

65 哥:這個我知道,Redis 是基於內存的數據庫,跟磁盤數據庫相比,徹底吊打磁盤的速度,就像段譽的凌波微步。對於磁盤數據庫來講,首先要將數據經過 IO 操做讀取到內存裏。

沒錯,不論讀寫操做都是在內存上完成的,咱們分別對比下內存操做與磁盤操做的差別。

磁盤調用棧圖

Redis 核心篇:惟快不破的祕密

內存操做

內存直接由 CPU 控制,也就是 CPU 內部集成的內存控制器,因此說內存是直接與 CPU 對接,享受與 CPU 通訊的最優帶寬。

Redis 將數據存儲在內存中,讀寫操做不會由於磁盤的 IO 速度限制,因此速度飛通常的感受!

最後以一張圖量化系統的各類延時時間(部分數據引用 Brendan Gregg)

Redis 核心篇:惟快不破的祕密

高效的數據結構

65 哥:學習 MySQL 的時候我知道爲了提升檢索速度使用了 B+ Tree 數據結構,因此 Redis 速度快應該也跟數據結構有關。

回答正確,這裏所說的數據結構並非 Redis 提供給咱們使用的 5 種數據類型:String、List、Hash、Set、SortedSet。

在 Redis 中,經常使用的 5 種數據類型和應用場景以下:

  • String: 緩存、計數器、分佈式鎖等。
  • List: 鏈表、隊列、微博關注人時間軸列表等。
  • Hash: 用戶信息、Hash 表等。
  • Set: 去重、贊、踩、共同好友等。
  • Zset: 訪問量排行榜、點擊量排行榜等。

上面的應該叫作 Redis 支持的數據類型,也就是數據的保存形式。「碼哥字節」要說的是針對這 5 種數據類型,底層都運用了哪些高效的數據結構來支持。

65 哥:爲啥搞這麼多數據結構呢?

固然是爲了追求速度,不一樣數據類型使用不一樣的數據結構速度才得以提高。每種數據類型都有一種或者多種數據結構來支撐,底層數據結構有 6 種。

Redis 核心篇:惟快不破的祕密

Redis hash 字典

Redis 總體就是一個 哈希表來保存全部的鍵值對,不管數據類型是 5 種的任意一種。哈希表,本質就是一個數組,每一個元素被叫作哈希桶,無論什麼數據類型,每一個桶裏面的 entry 保存着實際具體值的指針。

Redis 全局哈希表

整個數據庫就是一個全局哈希表,而哈希表的時間複雜度是 O(1),只須要計算每一個鍵的哈希值,便知道對應的哈希桶位置,定位桶裏面的 entry 找到對應數據,這個也是 Redis 快的緣由之一。

那 Hash 衝突怎麼辦?

當寫入 Redis 的數據愈來愈多的時候,哈希衝突不可避免,會出現不一樣的 key 計算出同樣的哈希值。

Redis 經過鏈式哈希解決衝突:也就是同一個 桶裏面的元素使用鏈表保存。可是當鏈表過長就會致使查找性能變差可能,因此 Redis 爲了追求快,使用了兩個全局哈希表。用於 rehash 操做,增長現有的哈希桶數量,減小哈希衝突。

開始默認使用 hash 表 1 保存鍵值對數據,哈希表 2 此刻沒有分配空間。當數據越來多觸發 rehash 操做,則執行如下操做:

  1. 給 hash 表 2 分配更大的空間;
  2. 將 hash 表 1 的數據從新映射拷貝到 hash 表 2 中;
  3. 釋放 hash 表 1 的空間。

值得注意的是,將 hash 表 1 的數據從新映射到 hash 表 2 的過程當中並非一次性的,這樣會形成 Redis 阻塞,沒法提供服務。

而是採用了漸進式 rehash,每次處理客戶端請求的時候,先從 hash 表 1 中第一個索引開始,將這個位置的 全部數據拷貝到 hash 表 2 中,就這樣將 rehash 分散到屢次請求過程當中,避免耗時阻塞。

SDS 簡單動態字符

65 哥:Redis 是用 C 語言實現的,爲啥還從新搞一個 SDS 動態字符串呢?

字符串結構使用最普遍,一般咱們用於緩存登錄後的用戶信息,key = userId,value = 用戶信息 JSON 序列化成字符串。

C 語言中字符串的獲取 「MageByte」的長度,要從頭開始遍歷,直到 「\0」爲止,Redis 做爲惟快不破的男人是不能忍受的。

C 語言字符串結構與 SDS 字符串結構對比圖以下所示:

C 語言字符串與 SDS

SDS 與 C 字符串區別

O(1) 時間複雜度獲取字符串長度

C 語言字符串布吉路長度信息,須要遍歷整個字符串時間複雜度爲 O(n),C 字符串遍歷時遇到 '\0' 時結束。

SDS 中 len 保存這字符串的長度,O(1) 時間複雜度。

空間預分配

SDS 被修改後,程序不只會爲 SDS 分配所須要的必須空間,還會分配額外的未使用空間。

分配規則以下:若是對 SDS 修改後,len 的長度小於 1M,那麼程序將分配和 len 相同長度的未使用空間。舉個例子,若是 len=10,從新分配後,buf 的實際長度會變爲 10(已使用空間)+10(額外空間)+1(空字符)=21。若是對 SDS 修改後 len 長度大於 1M,那麼程序將分配 1M 的未使用空間。

惰性空間釋放

當對 SDS 進行縮短操做時,程序並不會回收多餘的內存空間,而是使用 free 字段將這些字節數量記錄下來不釋放,後面若是須要 append 操做,則直接使用 free 中未使用的空間,減小了內存的分配。

二進制安全

在 Redis 中不只能夠存儲 String 類型的數據,也可能存儲一些二進制數據。

二進制數據並非規則的字符串格式,其中會包含一些特殊的字符如 '\0',在 C 中遇到 '\0' 則表示字符串的結束,但在 SDS 中,標誌字符串結束的是 len 屬性。

zipList 壓縮列表

壓縮列表是 List 、hash、 sorted Set 三種數據類型底層實現之一。

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

ziplist 是由一系列特殊編碼的連續內存塊組成的順序型的數據結構,ziplist 中能夠包含多個 entry 節點,每一個節點能夠存放整數或者字符串。

ziplist 在表頭有三個字段 zlbytes、zltail 和 zllen,分別表示列表佔用字節數、列表尾的偏移量和列表中的 entry 個數;壓縮列表在表尾還有一個 zlend,表示列表結束。

struct ziplist<T> {
    int32 zlbytes; // 整個壓縮列表佔用字節數
    int32 zltail_offset; // 最後一個元素距離壓縮列表起始位置的偏移量,用於快速定位到最後一個節點
    int16 zllength; // 元素個數
    T[] entries; // 元素內容列表,挨個挨個緊湊存儲
    int8 zlend; // 標誌壓縮列表的結束,值恆爲 0xFF
}

ziplist

若是咱們要查找定位第一個元素和最後一個元素,能夠經過表頭三個字段的長度直接定位,複雜度是 O(1)。而查找其餘元素時,就沒有這麼高效了,只能逐個查找,此時的複雜度就是 O(N)

雙端列表

Redis List 數據類型一般被用於隊列、微博關注人時間軸列表等場景。無論是先進先出的隊列,仍是先進後出的棧,雙端列表都很好的支持這些特性。

Redis 核心篇:惟快不破的祕密

Redis 的鏈表實現的特性能夠總結以下:

  • 雙端:鏈表節點帶有 prev 和 next 指針,獲取某個節點的前置節點和後置節點的複雜度都是 O(1)。
  • 無環:表頭節點的 prev 指針和表尾節點的 next 指針都指向 NULL,對鏈表的訪問以 NULL 爲終點。
  • 帶表頭指針和表尾指針:經過 list 結構的 head 指針和 tail 指針,程序獲取鏈表的表頭節點和表尾節點的複雜度爲 O(1)。
  • 帶鏈表長度計數器:程序使用 list 結構的 len 屬性來對 list 持有的鏈表節點進行計數,程序獲取鏈表中節點數量的複雜度爲 O(1)。
  • 多態:鏈表節點使用 void* 指針來保存節點值,而且能夠經過 list 結構的 dup、free、match 三個屬性爲節點值設置類型特定函數,因此鏈表能夠用於保存各類不一樣類型的值。

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

quicklist 是 ziplist 和 linkedlist 的混合體,它將 linkedlist 按段切分,每一段使用 ziplist 來緊湊存儲,多個 ziplist 之間使用雙向指針串接起來。

Redis 核心篇:惟快不破的祕密

這也是爲什麼 Redis 快的緣由,不放過任何一個能夠提高性能的細節。

skipList 跳躍表

sorted set 類型的排序功能即是經過「跳躍列表」數據結構來實現。

跳躍表(skiplist)是一種有序數據結構,它經過在每一個節點中維持多個指向其餘節點的指針,從而達到快速訪問節點的目的。

跳躍表支持平均 O(logN)、最壞 O(N)複雜度的節點查找,還能夠經過順序性操做來批量處理節點。

跳錶在鏈表的基礎上,增長了多層級索引,經過索引位置的幾個跳轉,實現數據的快速定位,以下圖所示:

跳躍表

當須要查找 40 這個元素須要經歷 三次查找。

整數數組(intset)

當一個集合只包含整數值元素,而且這個集合的元素數量很少時,Redis 就會使用整數集合做爲集合鍵的底層實現。結構以下:

typedef struct intset{
     //編碼方式
     uint32_t encoding;
     //集合包含的元素數量
     uint32_t length;
     //保存元素的數組
     int8_t contents[];
}intset;

contents 數組是整數集合的底層實現:整數集合的每一個元素都是 contents 數組的一個數組項(item),各個項在數組中按值的大小從小到大有序地排列,而且數組中不包含任何重複項。length 屬性記錄了整數集合包含的元素數量,也便是 contents 數組的長度。

合理的數據編碼

Redis 使用對象(redisObject)來表示數據庫中的鍵值,當咱們在 Redis 中建立一個鍵值對時,至少建立兩個對象,一個對象是用作鍵值對的鍵對象,另外一個是鍵值對的值對象。

例如咱們執行 SET MSG XXX 時,鍵值對的鍵是一個包含了字符串「MSG「的對象,鍵值對的值對象是包含字符串"XXX"的對象。

redisObject

typedef struct redisObject{
    //類型
   unsigned type:4;
   //編碼
   unsigned encoding:4;
   //指向底層數據結構的指針
   void *ptr;
    //...
 }robj;

其中 type 字段記錄了對象的類型,包含字符串對象、列表對象、哈希對象、集合對象、有序集合對象。

對於每一種數據類型來講,底層的支持多是多種數據結構,何時使用哪一種數據結構,這就涉及到了編碼轉化的問題。

那咱們就來看看,不一樣的數據類型是如何進行編碼轉化的:

String:存儲數字的話,採用 int 類型的編碼,若是是非數字的話,採用 raw 編碼;

List:List 對象的編碼能夠是 ziplist 或 linkedlist,字符串長度 < 64 字節且元素個數 < 512 使用 ziplist 編碼,不然轉化爲 linkedlist 編碼;

注意:這兩個條件是能夠修改的,在 redis.conf 中:

list-max-ziplist-entries 512
list-max-ziplist-value 64

Hash:Hash 對象的編碼能夠是 ziplist 或 hashtable。

當 Hash 對象同時知足如下兩個條件時,Hash 對象採用 ziplist 編碼:

  • Hash 對象保存的全部鍵值對的鍵和值的字符串長度均小於 64 字節。
  • Hash 對象保存的鍵值對數量小於 512 個。

不然就是 hashtable 編碼。

Set:Set 對象的編碼能夠是 intset 或 hashtable,intset 編碼的對象使用整數集合做爲底層實現,把全部元素都保存在一個整數集合裏面。

保存元素爲整數且元素個數小於必定範圍使用 intset 編碼,任意條件不知足,則使用 hashtable 編碼;

Zset:Zset 對象的編碼能夠是 ziplist 或 zkiplist,當採用 ziplist 編碼存儲時,每一個集合元素使用兩個緊挨在一塊兒的壓縮列表來存儲。

Ziplist 壓縮列表第一個節點存儲元素的成員,第二個節點存儲元素的分值,而且按分值大小從小到大有序排列。

Redis 核心篇:惟快不破的祕密

當 Zset 對象同時知足一下兩個條件時,採用 ziplist 編碼:

  • Zset 保存的元素個數小於 128。
  • Zset 元素的成員長度都小於 64 字節。

若是不知足以上條件的任意一個,ziplist 就會轉化爲 zkiplist 編碼。注意:這兩個條件是能夠修改的,在 redis.conf 中:

zset-max-ziplist-entries 128
zset-max-ziplist-value 64

單線程模型

65 哥:爲何 Redis 是單線程的而不用多線程並行執行充分利用 CPU 呢?

咱們要明確的是:Redis 的單線程指的是 Redis 的網絡 IO 以及鍵值對指令讀寫是由一個線程來執行的。 對於 Redis 的持久化、集羣數據同步、異步刪除等都是其餘線程執行。

至於爲啥用單線程,咱們先了解多線程有什麼缺點。

多線程的弊端

使用多線程,一般能夠增長系統吞吐量,充分利用 CPU 資源。

可是,使用多線程後,沒有良好的系統設計,可能會出現以下圖所示的場景,增長了線程數量,前期吞吐量會增長,再進一步新增線程的時候,系統吞吐量幾乎再也不新增,甚至會降低!

線程數與吞吐量

在運行每一個任務以前,CPU 須要知道任務在何處加載並開始運行。也就是說,系統須要幫助它預先設置 CPU 寄存器和程序計數器,這稱爲 CPU 上下文。

這些保存的上下文存儲在系統內核中,並在從新計劃任務時再次加載。這樣,任務的原始狀態將不會受到影響,而且該任務將看起來正在連續運行。

切換上下文時,咱們須要完成一系列工做,這是很是消耗資源的操做。

另外,當多線程並行修改共享數據的時候,爲了保證數據正確,須要加鎖機制就會帶來額外的性能開銷,面臨的共享資源的併發訪問控制問題。

引入多線程開發,就須要使用同步原語來保護共享資源的併發讀寫,增長代碼複雜度和調試難度。

單線程又什麼好處?

  1. 不會由於線程建立致使的性能消耗;
  2. 避免上下文切換引發的 CPU 消耗,沒有多線程切換的開銷;
  3. 避免了線程之間的競爭問題,好比添加鎖、釋放鎖、死鎖等,不須要考慮各類鎖問題。
  4. 代碼更清晰,處理邏輯簡單。

單線程是否沒有充分利用 CPU 資源呢?

官方答案:由於 Redis 是基於內存的操做,CPU 不是 Redis 的瓶頸,Redis 的瓶頸最有多是機器內存的大小或者網絡帶寬。既然單線程容易實現,並且 CPU 不會成爲瓶頸,那就瓜熟蒂落地採用單線程的方案了。原文地址:https://redis.io/topics/faq

I/O 多路複用模型

Redis 採用 I/O 多路複用技術,併發處理鏈接。採用了 epoll + 本身實現的簡單的事件框架。epoll 中的讀、寫、關閉、鏈接都轉化成了事件,而後利用 epoll 的多路複用特性,毫不在 IO 上浪費一點時間。

65 哥:那什麼是 I/O 多路複用呢?

在解釋 IO 多慮複用以前咱們先了解下基本 IO 操做會經歷什麼。

基本 IO 模型

一個基本的網絡 IO 模型,當處理 get 請求,會經歷如下過程:

  1. 和客戶端創建創建 accept;
  2. 從 socket 種讀取請求 recv;
  3. 解析客戶端發送的請求 parse;
  4. 執行 get 指令;
  5. 響應客戶端數據,也就是 向 socket 寫回數據。

其中,bind/listen、accept、recv、parse 和 send 屬於網絡 IO 處理,而 get 屬於鍵值數據操做。既然 Redis 是單線程,那麼,最基本的一種實現是在一個線程中依次執行上面說的這些操做。

關鍵點就是 accept 和 recv 會出現阻塞,當 Redis 監聽到一個客戶端有鏈接請求,但一直未能成功創建起鏈接時,會阻塞在 accept() 函數這裏,致使其餘客戶端沒法和 Redis 創建鏈接。

相似的,當 Redis 經過 recv() 從一個客戶端讀取數據時,若是數據一直沒有到達,Redis 也會一直阻塞在 recv()。

Redis 核心篇:惟快不破的祕密

阻塞的緣由因爲使用傳統阻塞 IO ,也就是在執行 read、accept 、recv 等網絡操做會一直阻塞等待。以下圖所示:

阻塞IO

IO 多路複用

多路指的是多個 socket 鏈接,複用指的是複用一個線程。多路複用主要有三種技術:select,poll,epoll。epoll 是最新的也是目前最好的多路複用技術。

它的基本原理是,內核不是監視應用程序自己的鏈接,而是監視應用程序的文件描述符。

當客戶端運行時,它將生成具備不一樣事件類型的套接字。在服務器端,I / O 多路複用程序(I / O 多路複用模塊)會將消息放入隊列(也就是 下圖的 I/O 多路複用程序的 socket 隊列),而後經過文件事件分派器將其轉發到不一樣的事件處理器。

簡單來講:Redis 單線程狀況下,內核會一直監聽 socket 上的鏈接請求或者數據請求,一旦有請求到達就交給 Redis 線程處理,這就實現了一個 Redis 線程處理多個 IO 流的效果。

select/epoll 提供了基於事件的回調機制,即針對不一樣事件的發生,調用相應的事件處理器。因此 Redis 一直在處理事件,提高 Redis 的響應性能。

高性能 IO 多路複用

Redis 線程不會阻塞在某一個特定的監聽或已鏈接套接字上,也就是說,不會阻塞在某一個特定的客戶端請求處理上。正由於此,Redis 能夠同時和多個客戶端鏈接並處理請求,從而提高併發性。

惟快不破的原理總結

65 哥:學完以後我終於知道 Redis 爲什麼快的本質緣由了,「碼哥」你別說話,我來總結!一會我再點贊和分享這篇文章,讓更多人知道 Redis 快的核心原理。

  1. 純內存操做,通常都是簡單的存取操做,線程佔用的時間不少,時間的花費主要集中在 IO 上,因此讀取速度快。
  2. 整個 Redis 就是一個全局 哈希表,他的時間複雜度是 O(1),並且爲了防止哈希衝突致使鏈表過長,Redis 會執行 rehash 操做,擴充 哈希桶數量,減小哈希衝突。而且防止一次性 從新映射數據過大致使線程阻塞,採用 漸進式 rehash。巧妙的將一次性拷貝分攤到屢次請求過程後總,避免阻塞。
  3. Redis 使用的是非阻塞 IO:IO 多路複用,使用了單線程來輪詢描述符,將數據庫的開、關、讀、寫都轉換成了事件,Redis 採用本身實現的事件分離器,效率比較高。
  4. 採用單線程模型,保證了每一個操做的原子性,也減小了線程的上下文切換和競爭。
  5. Redis 全程使用 hash 結構,讀取速度快,還有一些特殊的數據結構,對數據存儲進行了優化,如壓縮表,對短數據進行壓縮存儲,再如,跳錶,使用有序的數據結構加快讀取的速度。
  6. 根據實際存儲的數據類型選擇不一樣編碼

下一篇「碼哥字節」將帶來 《Redis 日誌篇:無畏宕機快速恢復的殺手鐗》,關注我,獲取真正的硬核知識點。

另外技術讀者羣也開通了,後臺回覆「加羣」獲取「碼哥字節」做者微信,一塊兒成長交流。

以上就是 Redis 惟快不破的祕密詳解,以爲不錯請點贊、分享,「碼哥字節」感激涕零。

碼哥字節

相關文章
相關標籤/搜索