Redis 如何保證高效的查詢效率
為什麼 Redis 比較快
Redis 中的查詢速度為什麼那麼快呢?
1、因為它是記憶體資料庫;
2、歸功於它的資料結構;
3、Redis 中是單執行緒;
4、Redis 中使用了多路複用。
Redis 中的資料結構
這裡借用一張來自[Redis核心技術與實戰] Redis 中資料結構和底層結構的對應圖片
1、簡單動態字串
Redis 中並沒有使用 C 中 char 來表示字串,而是引入了 簡單動態字串(Simple Dynamic Strings,SDS)來儲存字串和整型資料。那麼 SDS 對比傳統的字串有什麼優點呢?
先來看下 SDS 的結構
struct sdshdr {
// 記錄 buf 陣列中已使用位元組的數量
// 等於 SDS 儲存字串的長度,不包含'\0'
long len;
// 記錄buf陣列中未使用位元組的數量
long free;
// 位元組陣列,用於儲存字串
char buf[];
};
舉個例子:
使用 SDS 儲存了一個字串 hello,對應的 len 就是5,同時也申請了5個為未使用的空間,所以 free 就是5。
在 3.2 版本後,sds 會根據字串實際的長度,選擇不同的資料結構,以更好的提升記憶體效率。當前 sdshdr 結構分為 5 種子型別,分別為 sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64
。其中 sdshdr5 只有 flags 和 buf 欄位,其他幾種型別的 len 和 alloc 採用從 uint8_t 到 uint64_t 的不同型別,以節省記憶體空間。
SDS 對比 c 字串的優勢
SDS可以常數級別獲取字串的長度
因為結構裡面已經記錄了字串的長度,所以獲取字串的長度複雜度為O(1),c 中字串沒記錄長度,需要遍歷整個長度,複雜度為O(N)。
杜絕緩衝區溢位
如果在修改字元的時候,沒有分配足夠的記憶體大小,就很容易造成快取溢位,記憶體越界。
strcat 函式常見的錯誤就是陣列越界,即兩個字串連線後,長度超過第一個字串陣列定義的長度,導致越界。
SDS 中的空間分配策略可以杜絕這種情況,當對 SDS 進行修改時,API 會檢查 SDS 的空間是否滿足修改所需的要求,如果不滿足的話,API 會自動將 SDS 的空間擴充套件至執行修改所需的大小,然後才執行實際的修改操作。空間的申請是自動完成的,所以就避免了快取溢位。
減少修改字串時帶來的記憶體分配次數
對於 C 字串來說,如果修改字串的長度,都需要重新執行記憶體分配操作;但是對於 Redis 資料庫來說,如果頻繁執行記憶體分配/釋放操作,必然會對效能產生一定影響。為了避免 C 字串的缺陷,SDS 採用了空間預分配和惰性空間釋放兩種優化策略。
空間預分配
空間預分配用於優化 SDS 的字串增長操作,當 SDS 的 api 對 SDS 進行修改,同時需要進行空間擴充套件的時候,除了會給 SDS 分配修改需要的空間,同時還會給 SDS 分配額外的未使用空間。
1、如果對 SDS 修改之後,SDS 的長度小於1MB
,那麼程式分配和 len 同樣大小的未使用空間,也就是這時候 SDS 中的 len 和 free 長度相同;
2、如果對 SDS 修改之後,SDS 的長度大於等於1MB
,那麼程式分配1MB
的未使用空間。
在對 SDS 空間進行擴充套件的時候,首先會判斷未使用空間的大小是否能滿足要求,如果足夠,就不用在進行記憶體分配了,這樣能夠減少記憶體的重新分配的次數。
惰性空間釋放
惰性空間釋放用於優化 SDS 字串的縮短操作,當 SDS 的 API 需要縮短 SDS 保護的字串時,程式並不會立即使用記憶體重分配來回收縮短後多出來的記憶體,而是使用 free 屬性將這些位元組的數量記錄起來,等待之後的重新使用。
二進位制安全
對於 C 字串來說,字串中不能包含空字元,否則最先被程式讀入的空字串被誤認為是字串結尾,這使得 C 字串只能儲存文字資料,而不能儲存圖片、音視訊等二進位制檔案。對於 SDS 來說,所有 SDS 都會以處理二進位制的方式來處理 SDS 儲存在 buf 陣列中的內容,程式不會對裡面的內容做任何限制。
相容部分C字串函式
SDS 末尾設定空字元的操作使得它可以和部分 C 字串函式相容。
2、連結串列
連結串列是一種很常見的資料結構,在很多高階語言中都能看到,Redis 中的 list 就用到了雙向連結串列,當一個列表鍵包含了數量比較多的元素,或者是列表中包含的元素都是比較長的字串的時,Redis 就會使用連結串列作為 list 的底層實現。
這裡雙向連結串列不展開討論了。
3、字典
redisDb 主要包括 2 個核心 dict 字典、3 個非核心 dict 字典、dbID 和其他輔助屬性。2 個核心 dict 包括一個 dict 主字典和一個 expires 過期字典。主 dict 字典用來儲存當前 DB 中的所有資料,它將 key 和各種資料型別的 value 關聯起來,該 dict 也稱 key space。過期字典用來儲存過期時間 key,存的是 key 與過期時間的對映。日常的資料儲存和訪問基本都會訪問到 redisDb 中的這兩個 dict。
所以對於任何型別的資料查詢都需要經過一次 dict 的查詢,也就是 hash 的過程。通過這個 dict 將 key 對映到對應的資料型別的 value 中。
3 個非核心 dict 包括一個欄位名叫 blocking_keys 的阻塞 dict,一個欄位名叫 ready_keys 的解除阻塞 dict,還有一個是欄位名叫 watched_keys 的 watch 監控 dict。
Redis 的字典使用雜湊表作為底層實現,一個雜湊表裡面可以有多個雜湊節點,每個雜湊表節點就儲存了字典中的一個鍵值對。
使用雜湊表就難免遇到雜湊碰撞,兩個key的雜湊值和雜湊桶計算對應關係時,正好落在了同一個雜湊桶中。
Redis 解決雜湊衝突的方式,就是鏈式雜湊。鏈式雜湊也很容易理解,就是指同一個雜湊桶中的多個元素用一個連結串列來儲存,它們之間依次用指標連線。
隨著寫入的訊息越來越多,雜湊衝突的機率也隨之升高,這時候就需要對雜湊表進行擴容,Redis 中的擴容使用的是漸進式rehash。
其實,為了使 rehash 操作更高效,Redis 預設使用了兩個全域性雜湊表:雜湊表1和雜湊表2。一開始,當你剛插入資料時,預設使用雜湊表1,此時的雜湊表2並沒有被分配空間。隨著資料逐步增多,Redis 開始執行 rehash,這個過程分為三步:
1、給雜湊表2分配更大的空間,例如是當前雜湊表1大小的兩倍;
2、把雜湊表1中的資料重新對映並拷貝到雜湊表2中;
3、釋放雜湊表1的空間。
當然資料很大的話,一次遷移所有的資料,顯然是不合理的,會造成Redis執行緒阻塞,無法服務其他請求。這裡 Redis 使用的是漸進式 rehash。
在 rehash 期間,每次執行新增,刪除,查詢或者更新操作時,除了對命令本身的處理,還會順帶將雜湊表1中的資料拷貝到雜湊表2中。從索引0開始,每執行一次操作命令,拷貝一個索引位置的資料。
在進行 rehash 期間,刪除,查詢或者更新操作都會在兩個雜湊表中執行,新增操作就直接新增到雜湊表2中了。查詢會把兩個雜湊表都找一遍,直到找到或者兩個都找不到。
4、跳錶
其中 Redis 中的有序集合就是使用跳錶來實現的
對於一個單連結串列來講,即便連結串列中儲存的資料是有序的,如果我們要想在其中查詢某個資料,也只能從頭到尾遍歷連結串列。這樣查詢效率就會很低,時間複雜度會很高,是O(n)。
連結串列加多級索引的結構,就是跳錶,跳錶查詢的時間複雜度是O(logn)
。通過在每個節點中維持多個指向其他節點的指標,從而實現快速訪問的節點的目的。
5、整數陣列
整數集合(intset)是集合鍵的底層實現之一,當一個集合只包含整數值元素,並且這個集合的元素數量不多時,Redis 就會使用整數集合作為集合鍵的底層實現。是 Redis 使用者儲存整數值的集合抽象資料結構,可以儲存的型別是int16_t,int32_t,int64_t
的整數值,並且保證集合中不會出現重複的元素。
typedef struct intset{
// 編碼方法,指定當前儲存的是 16 位,32 位,還是 64 位的整數
int32 encoding;
// 集合中的元素數量
int32 length;
// 儲存元素的陣列
int<T> contents;
}
這樣看,這個集合倒也沒有什麼特殊的點。這時候需要來看下整數集合的升級。
每當一個整數被新增到整數集合時,首先需要判斷下 新元素的型別和集合中現有元素型別的長短,如果新元素是一個32位的數字,現有集合型別是16位的,那麼就需要將整數集合進行升級,然後才能將新的元素加入進來。
這樣做的優點:
1、提升整數集合的靈活性,可以隨意的新增整數,而不用關心整數的型別;
2、可以儘可能的節約記憶體。
缺點:
不支援降級,一旦對陣列進行了升級,就會一直保持升級後的狀態。
6、壓縮列表
壓縮列表(ziplist)是列表鍵和雜湊鍵的底層實現之一。當一個元素只包含少量的列表項,並且每個列表項是小整數值或者是長度比較短的字串,redis 就會使用壓縮列表來作為列表鍵的底層實現。
壓縮列表(ziplist)的目的是為了節約記憶體,通過一片連續的記憶體空間來儲存資料。這樣看起來好像和陣列很像,陣列中每個節點的記憶體大小是一樣的,對於壓縮列表,每個節點的記憶體大小是不同的,每個節點可以儲存位元組陣列或一個整數值。通過可變的節點記憶體大小,來節約記憶體的使用。
ziplist 的結構:
1、zlbytes : 是壓縮列表所佔用的總記憶體位元組數;
2、Zltail : 尾節點到起始位置的位元組數,(目的是為了直接定位到尾節點,方便反向查詢);
3、Zllen : 總共包含的節點/記憶體塊數,也就是壓縮列表的節點數量;
4、Entry : 是 ziplist 儲存的各個資料節點,這些資料點長度隨意;
5、Zlend : 是一個魔數 255,用來標記壓縮列表的結束。
由於 ziplist 是連續緊湊儲存,沒有冗餘空間,所以插入新的元素需要 realloc 擴充套件記憶體,所以如果 ziplist 佔用空間太大,realloc 重新分配記憶體和拷貝的開銷就會很大,所以 ziplist 不適合儲存過多元素,也不適合儲存過大的字串。
因此只有在元素數和 value 數都不大的時候,ziplist 才作為 hash 和 zset 的內部資料結構。其中 hash 使用 ziplist 作為內部資料結構的限制時,元素數預設不超過 512 個,value 值預設不超過 64 位元組。可以通過修改配置來調整 hash_max_ziplist_entries 、hash_max_ziplist_value
這兩個閥值的大小。
zset 有序集合,使用 ziplist 作為內部資料結構的限制元素數預設不超過 128 個,value 值預設不超過 64 位元組。可以通過修改配置來調整 zset_max_ziplist_entries 和 zset_max_ziplist_value 這兩個閥值的大小。
在壓縮列表中,如果我們要查詢定位第一個元素和最後一個元素,可以通過表頭三個欄位的長度直接定位,複雜度是O(1)。而查詢其他元素時,就沒有這麼高效了,只能逐個查詢,此時的複雜度就是O(N)了。
連鎖更新
壓縮列表 ziplist 的儲存節點 Entry 資料節點的結構:
1、previous_entry_length : 記錄了前一個節點的長度
-
如果前一節點的長度小於 254 位元組, 那麼 previous_entry_length 屬性的長度為 1 位元組: 前一節點的長度就儲存在這一個位元組裡面;
-
如果前一節點的長度大於等於 254 位元組, 那麼 previous_entry_length 屬性的長度為 5 位元組: 其中屬性的第一位元組會被設定為 0xFE (十進位制值 254), 而之後的四個位元組則用於儲存前一節點的長度。
2、encoding : 記錄了節點的 content 屬性所儲存資料的型別以及長度
3、content : 節點的 content 屬性負責儲存節點的值, 節點值可以是一個位元組陣列或者整數, 值的型別和長度由節點的 encoding 屬性決定。
舉個例子:
如果壓縮列表中每個節點的長度都是250,因為是小於254,所以每個節點中的 previous_entry_length 長度1位元組就能夠儲存了。
這時候,在頭部節點插入了一個新的元素 entryNew,然後長度是大於254,那麼後面的節點中 entry1 的 previous_entry_length 長度為1位元組,就不能儲存了,需要擴容成5位元組,然後 entry1 節點進行擴容了,變成了254,所以後面的節點也就需要一次擴容,這就引發一連串的擴容。也就是連鎖更新。
為什麼單執行緒還能很快
Redis 是單執行緒,主要是指 Redis 的網路IO和鍵值對讀寫是由一個執行緒來完成的,這也是 Redis 對外提供鍵值儲存服務的主要流程。
多執行緒必然會面臨對於共享資源的訪問,這時候通常的做法就是加鎖,雖然是多執行緒,這時候就會變成序列的訪問。也就是多執行緒程式設計模式會面臨的共享資源的併發訪問控制問題。
同時多執行緒也會引入同步原語來保護共享資源的併發訪問,程式碼的可維護性和易讀性將會下降。
基於多路複用的高效能I/O模型
Linux 中的IO多路複用機制是指一個執行緒處理多個IO流。簡單來說,在 Redis 只執行單執行緒的情況下,該機制允許核心中,同時存在多個監聽套接字和已連線套接字。核心會一直監聽這些套接字上的連線請求或資料請求。一旦有請求到達,就會交給 Redis 執行緒處理,這就實現了一個 Redis 執行緒處理多個IO流的效果。
檔案事件是對連線 socket 操作的一個抽象。當埠監聽 socket 準備 accept 新連線,或者連線 socket 準備好讀取請求、寫入響應、關閉時,就會產生一個檔案事件。IO 多路複用程式負責同時監聽多個 socket,當這些 socket 產生檔案事件時,就會觸發事件通知,檔案分派器就會感知並獲取到這些事件。
雖然多個檔案事件可能會併發出現,但 IO 多路複用程式總會將所有產生事件的 socket 放入一個佇列中,通過這個佇列,有序的把這些檔案事件通知給檔案分派器。
檔案事件分派器接收 I/O 多路複用程式傳來的套接字,並根據套接字產生的事件型別,呼叫相應的事件處理器。
伺服器會為執行不同任務的套接字關聯不同的事件處理器,這些處理器是一個個的函式,他們定義了這個事件發生時,伺服器應該執行的動作。
Redis 封裝了 4 種多路複用程式,每種封裝實現都提供了相同的 API 實現。編譯時,會按照效能和系統平臺,選擇最佳的 IO 多路複用函式作為底層實現,選擇順序是,首先嚐試選擇 Solaries 中的 evport,如果沒有,就嘗試選擇 Linux 中的 epoll,否則就選擇大多 UNIX 系統都支援的 kqueue,這 3 個多路複用函式都直接使用系統核心內部的結構,可以服務數十萬的檔案描述符。
如果當前編譯環境沒有上述函式,就會選擇 select 作為底層實現方案。select 方案的效能較差,事件發生時,會掃描全部監聽的描述符,事件複雜度是 O(n),並且只能同時服務有限個檔案描述符,32 位機預設是 1024 個,64 位機預設是 2048 個,所以一般情況下,並不會選擇 select 作為線上執行方案。
單執行緒處理IO請求效能瓶頸
1、後臺 Redis 通過監聽處理事件佇列中的訊息,來單執行緒的處理命令,如果一個命令的執行時間很久,就會影響整個 server 的效能;
耗時的操作命令有下面幾種:
-
1、操作 bigkey:bigkey 在寫入和刪除的時候,需要的時間都會很長;
-
2、使用複雜度過高的命令;
-
3、大量 key 集中過期:Redis 的過期機制也是在主執行緒中執行的,大量 key 集中過期會導致處理一個請求時,耗時都在刪除過期 key,耗時變長;
-
4、淘汰策略:淘汰策略也是在主執行緒執行的,當記憶體超過 Redis 記憶體上限後,每次寫入都需要淘汰一些 key,也會造成耗時變長;
-
5、AO F刷盤開啟 always 機制:每次寫入都需要把這個操作刷到磁碟,寫磁碟的速度遠比寫記憶體慢,會拖慢 Redis 的效能;
-
6、主從全量同步生成 RDB:雖然採用 fork 子程式生成資料快照,但 fork 這一瞬間也是會阻塞整個執行緒的,例項越大,阻塞時間越久;
上面的這幾種問題,我們在寫業務的時候需要去避免,對於 bigkey,Redis 在4.0推出了 lazy-free 機制,把 bigkey 釋放記憶體的耗時操作放在了非同步執行緒中執行,降低對主執行緒的影響。
2、併發量非常大時,單執行緒讀寫客戶端 IO 資料存在效能瓶頸
使用 Redis 時,幾乎不存在 CPU 成為瓶頸的情況, Redis 主要受限於記憶體和網路。隨著硬體水平的提升,Redis 中的效能慢慢主要出現在網路 IO 的讀寫上。雖然採用 IO 多路複用機制,但是讀寫客戶端資料依舊是同步IO,只能單執行緒依次讀取客戶端的資料,無法利用到CPU多核。
為了提升網路 IO 的讀寫效能,Redis 在6.0推出了多執行緒,同過多執行緒的 IO 來處理網路請求。不過需要注意的是這裡的多執行緒僅僅是針對客戶端的讀寫是並行的,Redis 處理事件佇列中的命令,還是單執行緒處理的。
總結
Redis 使用單執行緒,來避免共享資源的競爭,使用多路複用實現高效能的I/O,它是記憶體資料庫,所有操作都在記憶體上完成,使用雜湊表,跳錶等一系列高效的資料結構,這些特性保證了 Redis 的高效能。
參考
【Redis核心技術與實戰】https://time.geekbang.org/column/intro/100056701
【Redis6.0為什麼引入多執行緒?】https://juejin.cn/post/7004683161695158309
【Redis設計與實現】https://book.douban.com/subject/25900156/
【redis---sds(簡單動態字串)詳解】https://blog.csdn.net/u010765526/article/details/89065607
【為什麼 Redis 的查詢很快】https://boilingfrog.github.io/2022/01/07/為什麼redis的查詢比較快/