楠木軒

10萬+QPS 真的只是因為單線程和基於內存?

由 南門語山 發佈於 科技

你以為 Redis 這麼快僅僅因為單線程和基於內存?

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

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

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

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

使用對象的好處:

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

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

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

我們用字符串對象為例:

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

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

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

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

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

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

int 編碼就不用説了,就是為了兼容 SET 命令可以設置數值。

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

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

SDS 有三部分:

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

得益於 SDS 有 len 屬性,獲取字符串長度的複雜度為 O(1);

得益於 SDS 有 free 屬性,可以杜絕緩衝區溢出,字符串擴展前可以根據 free 屬性來判斷是否滿足直接擴展,不滿足則需要先執行內存重分配操作,然後再擴展字符串。

我們都知道修改字符串長度很有可能導致觸發內存重分配操作,但是 Redis 對於內存重分配有兩個優化策略:

空間預分配:

惰性空間釋放:

對象中使用數字是非常常見的,例如設置用户的年齡、學生的分數、博客中文章的排名等等。所以 Redis 為了避免重複創建數字對應的字符串對象,它會將一個範圍的整數對應的字符串對象用來共享。

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

當然了,我們還可以通過修改

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

簡單介紹一下 ziplist 的結構:

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

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

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

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

typeof structintset{

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

intset 一開始不會直接使用最大類型來定義數組,而是利用升級操作,當元素的值達到一定長度時,會重新為數組分配內存空間,並將數組裏的舊元素的類型進行升級。

這樣做好處:

因為整數集合沒有降級操作,所以從另外一個角度看,升級操作其實也會浪費內存:如果整數集合裏只有一個數值是 int64_t ,而其他數值都是小於它的,但是整數集合的編碼將還是保持 INTSET_ENC_INT64,就是説,小於 int64_t 的整數還是會用 int64_t 的空間來保存。

每當別人問 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 多路複用程序的底層實現。

我們都知道,文件事件的發生都是隨機的,因為 Redis 服務器永遠不可能知道客户端下次發送命令是什麼時候,所以程序也不可能一直阻塞着直到發生文件事件。

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

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

對文件事件和時間事件的處理都是同步、有序、原子地執行的,服務器不會中途中斷事件處理,也不會對事件進行搶佔,因此,不管是文件事件的處理器,還是時間事件的處理器,它們都會盡可地減少程序的阻塞時間,並在有需要時主動讓出執行權,從而降低造成事件飢餓的可能性。

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

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

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

Redis 2.8 前的複製功能:

缺點:

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

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

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

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

心跳檢測的三大作用:

min-slaves-to-write3

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

Sentinel 不但會與主從服務器建立命令連接,還會建立訂閲連接。

在默認情況下,Sentinel會以每兩秒一次的頻率,通過命令連接向所有被監視的主服務器和從服務器發送 PUBLISH 命令,命令附帶的是 Sentinel 本身的信息和所監聽的主服務器的信息;接着接收到此命令的主從服務器會向 __sentinel__:hello 頻道發送這些信息。

而其他所有都是監聽此主從服務器的 Sentinel 可以通過訂閲連接獲取到上面的信息。

這也就是説,對於每個與 Sentinel 的服務器,Sentinel 既通過命令連接向服務器的 __sentinel__:hello 頻道發送信息(PUBLISH),又通過訂閲連接從服務器的 __sentinel__:hello 頻道接收信息(SUBSCRIBE)。

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

Redis 集羣中的各個節點通過 Gossip 協議來交換各自關於不同節點的狀態信息,其中 Gossip 協議由 MEET、PING、PONG 三種消息實現,這三種消息的正文都由兩個

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

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

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