38 張圖詳解 Redis:核心架構、發(fā)布訂閱機制、九大數(shù)據(jù)類型底層原理、RDB和AOF 持久化、高可用架構、性能問題排查和調(diào)優(yōu)
現(xiàn)在就讓我們從Redis 的視角去了解她的核心知識點與架構設計.......
核心架構
當你熟悉我的整體架構和每個模塊,遇到問題才能直擊本源,直搗黃龍,一笑破蒼穹。
我的核心模塊如圖 1-10。
圖1-10
- Client 客戶端,官方提供了 C 語言開發(fā)的客戶端,可以發(fā)送命令,性能分析和測試等。
- 網(wǎng)絡層事件驅動模型,基于 I/O 多路復用,封裝了一個短小精悍的高性能 ae 庫,全稱是 a simple event-driven programming library。
a.在 ae 這個庫里面,我通過 aeApiState 結構體對 epoll、select、kqueue、evport四種 I/O 多路復用的實現(xiàn)進行適配,讓上層調(diào)用方感知不到在不同操作系統(tǒng)實現(xiàn) I/O 多路復用的差異。
b.Redis 中的事件可以分兩大類:一類是網(wǎng)絡連接、讀、寫事件;另一類是時間事件,比如定時執(zhí)行 rehash 、RDB 內(nèi)存快照生成,過期鍵值對清理操作。
- 命令解析和執(zhí)行層,負責執(zhí)行客戶端的各種命令,比如 SET、DEL、GET等。
- 內(nèi)存分配和回收,為數(shù)據(jù)分配內(nèi)存,提供不同的數(shù)據(jù)結構保存數(shù)據(jù)。
- 持久化層,提供了 RDB 內(nèi)存快照文件 和 AOF 兩種持久化策略,實現(xiàn)數(shù)據(jù)可靠性。
- 高可用模塊,提供了副本、哨兵、集群實現(xiàn)高可用。
- 監(jiān)控與統(tǒng)計,提供了一些監(jiān)控工具和性能分析工具,比如監(jiān)控內(nèi)存使用、基準測試、內(nèi)存碎片、bigkey 統(tǒng)計、慢指令查詢等。
數(shù)據(jù)存儲原理
在掌握存儲原理之前,先看一下全局架構圖,后邊慢慢分析他們的作用。
如圖 1-11 是由 redisDb、dict、dictEntry、redisObejct 關系圖:
圖1-11
redisServer
每個被啟動的服務我都會抽象成一個 redisServer,源碼定在server.h 的redisServer 結構體。結構體字段很多,不再一一列舉,部分核心字段如下。
truct redisServer {
pid_t pid; /* 主進程 pid. */
pthread_t main_thread_id; /* 主線程 id */
char *configfile; /*redis.conf 文件絕對路徑*/
redisDb *db; /* 存儲鍵值對數(shù)據(jù)的 redisDb 實例 */
int dbnum; /* DB 個數(shù) */
dict *commands; /* 當前實例能處理的命令表,key 是命令名,value 是執(zhí)行命令的入口 */
aeEventLoop *el;/* 事件循環(huán)處理 */
int sentinel_mode; /* true 則表示作為哨兵實例啟動 */
/* 網(wǎng)絡相關 */
int port;/* TCP 監(jiān)聽端口 */
list *clients; /* 連接當前實例的客戶端列表 */
list *clients_to_close; /* 待關閉的客戶端列表 */
client *current_client; /* 當前執(zhí)行命令的客戶端*/
};
這個結構體包含了存儲鍵值對的數(shù)據(jù)庫實例、redis.conf 文件路徑、命令列表、加載的 Modules、網(wǎng)絡監(jiān)聽、客戶端列表、RDB AOF 加載信息、配置信息、RDB 持久化、主從復制、客戶端緩存、數(shù)據(jù)結構壓縮、pub/sub、Cluster、哨兵等一系列 Redis 實例運行的必要信息。
接下來我們分別看下他們之間的關系和作用。
redisDb
其中redisDb *db指針非常重要,它指向了一個長度為 dbnum(默認 16)的 redisDb 數(shù)組,它是整個存儲的核心,我就是用這玩意來存儲鍵值對。
typedef struct redisDb {
dict *dict;
dict *expires;
dict *blocking_keys;
dict *ready_keys;
dict *watched_keys;
int id;
long long avg_ttl;
unsigned long expires_cursor;
list *defrag_later;
clusterSlotToKeyMapping *slots_to_keys;
} redisDb;
dict 和 expires
- dict 和 expires 是最重要的兩個屬性,底層數(shù)據(jù)結構是字典,分別用于存儲鍵值對數(shù)據(jù)和 key 的過期時間。
- expires,底層數(shù)據(jù)結構是 dict 字典,存儲每個 key 的過期時間。
dict
Redis 使用 dict 結構來保存所有的鍵值對(key-value)數(shù)據(jù),這是一個散列表,所以 key 查詢時間復雜度是 O(1) 。
struct dict {
dictType *type;
dictEntry **ht_table[2];
unsigned long ht_used[2];
long rehashidx;
int16_t pauserehash;
signed char ht_size_exp[2];
};
dict 的結構體里,有 dictType *type,**ht_table[2],long rehashidx 三個很重要的結構。
- type 存儲了 hash 函數(shù),key 和 value 的復制等函數(shù);
- ht_table[2],長度為 2 的數(shù)組,默認使用 ht_table[0] 存儲鍵值對數(shù)據(jù)。我會使用 ht_table[1] 來配合實現(xiàn)漸進式 reahsh 操作。
- rehashidx 是一個整數(shù)值,用于標記是否正在執(zhí)行 rehash 操作,-1 表示沒有進行 rehash。如果正在執(zhí)行 rehash,那么其值表示當前 rehash 操作執(zhí)行的 ht_table[1] 中的 dictEntry 數(shù)組的索引。
重點關注 ht_table 數(shù)組,數(shù)組每個位置叫做哈希桶,就是這玩意保存了所有鍵值對,每個哈希桶的類型是 dictEntry。
MySQL:“Redis 支持那么多的數(shù)據(jù)類型,哈希桶咋保存?”
他的玄機就在 dictEntry 中,每個 dict 有兩個 ht_table,用于存儲鍵值對數(shù)據(jù)和實現(xiàn)漸進式 rehash。
dictEntry 結構如下。
typedef struct dictEntry {
void *key;
union {
// 指向實際 value 的指針
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
// 散列表沖突生成的鏈表
struct dictEntry *next;
void *metadata[];
} dictEntry;
- *key 指向鍵值對的鍵的指針,指向一個 sds 對象,key 都是 string 類型。
- v 是鍵值對的 value 值,是個 union(聯(lián)合體),當它的值是 uint64_t、int64_t 或 double 數(shù)字類型時,就不再需要額外的存儲,這有利于減少內(nèi)存碎片。(為了節(jié)省內(nèi)存操碎了心)當值為非數(shù)字類型,就是用 val 指針存儲。
- *next指向另一個 dictEntry 結構, 多個 dictEntry 可以通過 next 指針串連成鏈表, 從這里可以看出, ht_table 使用鏈地址法來處理鍵碰撞:當多個不同的鍵擁有相同的哈希值時,哈希表用一個鏈表將這些鍵連接起來。
redisObject
dictEntry 的 *val 指針指向的值實際上是一個 redisObject 結構體,這是一個非常重要的結構體。
我的 key 是字符串類型,而 value 可以是 String、Lists、Set、Sorted Set、Hashes 等數(shù)據(jù)類型。
鍵值對的值都被包裝成 redisObject 對象, redisObject 在 server.h 中定義。
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS;
int refcount;
void *ptr;
} robj;
- type:記錄了對象的類型,string、set、hash 、Lis、Sorted Set 等,根據(jù)該類型來確定是哪種數(shù)據(jù)類型,這樣我才知道該使用什么指令執(zhí)行嘛。
- encoding:編碼方式,表示 ptr 指向的數(shù)據(jù)類型具體數(shù)據(jù)結構,即這個對象使用了什么數(shù)據(jù)結構作為底層實現(xiàn)保存數(shù)據(jù)。同一個對象使用不同編碼內(nèi)存占用存在明顯差異,節(jié)省內(nèi)存,這玩意功不可沒。
- lru:LRU_BITS:LRU 策略下對象最后一次被訪問的時間,如果是 LFU 策略,那么低 8 位表示訪問頻率,高 16 位表示訪問時間。
- refcount :表示引用計數(shù),由于 C 語言并不具備內(nèi)存回收功能,所以 Redis 在自己的對象系統(tǒng)中添加了這個屬性,當一個對象的引用計數(shù)為 0 時,則表示該對象已經(jīng)不被任何對象引用,則可以進行垃圾回收了。
- ptr 指針:指向值的指針,對象的底層實現(xiàn)數(shù)據(jù)結構。
數(shù)據(jù)類型底層數(shù)據(jù)結構
我是 Redis,給開發(fā)者提供了 String(字符串)、Hashes(散列表)、Lists(列表)、Sets(無序集合)、Sorted Sets(可根據(jù)范圍查詢的排序集合)、Bitmap(位圖)、HyperLogLog、Geospatial (地理空間)和 Stream(流)等數(shù)據(jù)類型。
數(shù)據(jù)類型的使用技法和以及每種數(shù)據(jù)類型底層實現(xiàn)原理是你核心筑基必經(jīng)之路,好好修煉。
五種基本數(shù)據(jù)類型 String、List、Set、Zset、Hash。數(shù)據(jù)類型與底層數(shù)據(jù)結構的關系如下所示。
2-55
String 字符串
我并沒有直接使用 C 語言的字符串,而是自己搞了一個 SDS 結構體來表示字符串。SDS 的全稱是 Simple Dynamic String,中文叫做“簡單動態(tài)字符串”。
圖2-2
O(1) 時間復雜度獲取字符串長度
SDS 中 len 保存了字符串的長度,實現(xiàn)了**O(1) 時間復雜度獲取字符串長度。
二進制安全
SDS 不僅可以存儲 String 類型數(shù)據(jù),還能存儲二進制數(shù)據(jù)。SDS 并不是通過“\0” 來判斷字符串結束,用的是 len 標志結束,所以可以直接將二進制數(shù)據(jù)存儲。
空間預分配
在需要對 SDS 的空間進行擴容時,不僅僅分配所需的空間,還會分配額外的未使用空間。
通過預分配策略,減少了執(zhí)行字符串增長所需的內(nèi)存重新分配次數(shù),降低由于字符串增加操作的性能損耗。
惰性空間釋放
當對 SDS 進行縮短操作時,程序并不會回收多余的內(nèi)存空間,如果后面需要 append 追加操作,則直接使用 buf 數(shù)組 alloc - len中未使用的空間。
通過惰性空間釋放策略,避免了減小字符串所需的內(nèi)存重新分配操作,為未來增長操作提供了優(yōu)化。
Lists(列表)
在 C 語言中,并沒有現(xiàn)成的鏈表結構,所以 antirez 為我專門設計了一套實現(xiàn)方式。
關于 List 類型的底層數(shù)據(jù)結構,可謂英雄輩出,antirez 大佬一直在優(yōu)化,創(chuàng)造了多種數(shù)據(jù)結構來保存。
從一開始早期版本使用 linkedlist(雙端列表)和 ziplist(壓縮列表)作為 List 的底層實現(xiàn),到 Redis 3.2 引入了由 linkedlist + ziplist 組成的 quicklist,再到 7.0 版本的時候使用 listpack 取代 ziplist。
linkedlist
在 Redis 3.2 版本之前,List 的底層數(shù)據(jù)結構由 linkedlist 或者 ziplist 實現(xiàn),優(yōu)先使用 ziplist 存儲。
當列表對象滿足以下兩個條件的時候,List 將使用 ziplist 存儲,否則使用 linkedlist。
- List 的每個元素的占用的字節(jié)小于 64 字節(jié)。
- List 的元素數(shù)量小于 512 個。
linkedlist 的結構如圖 2-5 所示。
圖 2-5
Redis 的鏈表實現(xiàn)的特性總結如下。
- 雙端:鏈表節(jié)點帶有 prev 和 next 指針,獲取某個節(jié)點的前置節(jié)點和后繼節(jié)點的復雜度都是 O(1)。
- 無環(huán):表頭節(jié)點的 prev 指針和尾節(jié)點的 next 指針都指向 NULL,對鏈表的訪問以 NULL 為結束。
- 帶表頭指針和表尾指針:通過 list 結構的 head 指針和 tail 指針,程序獲取鏈表的頭節(jié)點和尾節(jié)點的復雜度為 O(1)。
- 使用 list 結構的 len 屬性來對記錄節(jié)點數(shù)量,獲取鏈表中節(jié)點數(shù)量的復雜度為 O(1)。
ziplist(壓縮列表)
MySQL:為啥還設計了 ziplist 呢?
- 普通的 linkedlist 有 prev、next 兩個指針,當存儲數(shù)據(jù)很小的情況下,指針占用的空間會超過數(shù)據(jù)占用的空間,這就離譜了,是可忍孰不可忍。
- linkedlist 是鏈表結構,在內(nèi)存中不是連續(xù)的,遍歷的效率低下。
為了解決上面兩個問題,antirez 創(chuàng)造了 ziplist 壓縮列表,是一種內(nèi)存緊湊的數(shù)據(jù)結構,占用一塊連續(xù)的內(nèi)存空間,提升內(nèi)存使用率。
當一個列表只有少量數(shù)據(jù)的時候,并且每個列表項要么是小整數(shù)值,要么就是長度比較短的字符串,那么我就會使用 ziplist 來做 List 的底層實現(xiàn)。
ziplist 中可以包含多個 entry 節(jié)點,每個節(jié)點可以存放整數(shù)或者字符串,結構如圖 2-6 所示。
圖 2-6
- zlbytes,占用 4 個字節(jié),記錄了整個 ziplist 占用的總字節(jié)數(shù)。
- zltail,占用 4 個字節(jié),指向最后一個 entry 偏移量,用于快速定位最后一個 entry。
- zllen,占用 2 字節(jié),記錄 entry 總數(shù)。
- entry,列表元素。
- zlend,ziplist 結束標志,占用 1 字節(jié),值等于 255。
連鎖更新
每個 entry 都用 prevlen 記錄了上一個 entry 的長度,從當前 entry B 前面插入一個新的 entry A 時,會導致 B 的 prevlen 改變,也會導致 entry B 大小發(fā)生變化。entry B 后一個 entry C 的 prevlen 也需要改變。以此類推,就可能造成了連鎖更新。
圖 2-8
連鎖更新會導致 ziplist 的內(nèi)存空間需要多次重新分配,直接影響 ziplist 的查詢性能。于是乎在 Redis 3.2 版本引入了 quicklist。
quicklist
quicklist 是綜合考慮了時間效率與空間效率引入的新型數(shù)據(jù)結構。結合了原先 linkedlist 與 ziplist 各自的優(yōu)勢,本質還是一個鏈表,只不過鏈表的每個節(jié)點是一個 ziplist。
結合 quicklist 和 quicklistNode定義,quicklist 鏈表結構如下圖所示。
圖 2-9
MySQL:“搞了半天還是沒能解決連鎖更新的問題嘛”
別急,飯要一口口吃,路要一步步走,步子邁大了容易扯著蛋。
畢竟還是使用了 ziplist,本質上無法避免連鎖更新的問題,于是乎在 5.0 版本設計出另一個內(nèi)存緊湊型數(shù)據(jù)結構 listpack,于 7.0 版本替換掉 ziplist。
listpack
MySQL:“l(fā)istpack 是啥?”
listpack 也是一種緊湊型數(shù)據(jù)結構,用一塊連續(xù)的內(nèi)存空間來保存數(shù)據(jù),并且使用多種編碼方式來表示不同長度的數(shù)據(jù)來節(jié)省內(nèi)存空間。
先看 listpack 的整體結構。
圖 2-10
一共四部分組成,tot-bytes、num-elements、elements、listpack-end-byte。
- tot-bytes,也就是 total bytes,占用 4 字節(jié),記錄 listpack 占用的總字節(jié)數(shù)。
- num-elements,占用 2 字節(jié),記錄 listpack elements 元素個數(shù)。
- elements,listpack 元素,保存數(shù)據(jù)的部分。
- listpack-end-byte,結束標志,占用 1 字節(jié),值固定為 255。
MySQL:“好家伙,這跟 ziplist 有啥區(qū)別?別以為換了個名字,換個馬甲我就不認識了”
聽我說完!確實有點像,listpack 也是由元數(shù)據(jù)和數(shù)據(jù)自身組成。最大的區(qū)別是 elements 部分,為了解決 ziplist 連鎖更新的問題,element 不再像 ziplist 的 entry 保存前一項的長度。
圖 2-11
Sets(無序集合)
Sets 是 String 類型的無序集合,集合中的元素是唯一的,集合中不會出現(xiàn)重復的數(shù)據(jù)。
Java 的 HashSet 底層是用 HashMap 實現(xiàn),Sets 的底層數(shù)據(jù)結構也是用 Hashtable(散列表)實現(xiàn),散列表的 key 存的是 Sets 集合元素的 value,散列表的 value 則指向 NULL。。
不同的是,當元素內(nèi)容都是 64 位以內(nèi)的十進制整數(shù)的時候,并且元素個數(shù)不超過 set-max-intset-entries 配置的值(默認 512)的時候,會使用更加省內(nèi)存的 intset(整形數(shù)組)來存儲。
圖2-15
關于散列表結構我會在專門的章節(jié)介紹,先看 intset 結構,結構體定義在源碼 intset.h中。
typedef struct intset {
uint32_t encoding;
uint32_t length;
int8_t contents[];
} intset;
- length,記錄整數(shù)集合存儲的元素個數(shù),其實就是 contents 數(shù)組的長度。
- contents,真正存儲整數(shù)集合的數(shù)組,是一塊連續(xù)內(nèi)存區(qū)域。每個元素都是數(shù)組的一個數(shù)組元素,數(shù)組中的元素會按照值的大小從小到大有序排列存儲,并且不會有重復元素。
- encoding,編碼格式,決定數(shù)組類型,一共有三種不同的值。
圖2-16
Hash(散列表)
Redis Hash(散列表)是一種 field-value pairs(鍵值對)集合類型,類似于 Python 中的字典、Java 中的 HashMap。
Redis 的散列表 dict 由數(shù)組 + 鏈表構成,數(shù)組的每個元素占用的槽位叫做哈希桶,當出現(xiàn)散列沖突的時候就會在這個桶下掛一個鏈表,用“拉鏈法”解決散列沖突的問題。
簡單地說就是將一個 key 經(jīng)過散列計算均勻的映射到散列表上。
圖 2-18
Hashes 數(shù)據(jù)類型底層存儲數(shù)據(jù)結構實際上有兩種。
- dict 結構。
- 在 7.0 版本之前使用 ziplist,之后被 listpack 代替。
listpack 數(shù)據(jù)結構在之前的已經(jīng)介紹過, 接下來帶你揭秘 dict 到底長啥樣。
Redis 數(shù)據(jù)庫就是一個全局散列表。正常情況下,我只會使用 ht_table[0]散列表,圖 2-20 是一個沒有進行 rehash 狀態(tài)下的字典。
圖 2-20
- dictType *type,存放函數(shù)的結構體,定義了一些函數(shù)指針,可以通過設置自定義函數(shù),實現(xiàn) dict 的 key 和 value 存放任何類型的數(shù)據(jù)。
- 重點看 dictEntry **ht_table[2],存放了兩個 dictEntry 的二級指針,指針分別指向了一個 dictEntry 指針的數(shù)組。
- ht_used[2],記錄每個散列表使用了多少槽位(比如數(shù)組長度 32,使用了 12)。
- rehashidx,用于標記是否正在執(zhí)行 rehash 操作,-1 表示沒有進行 rehash。如果正在執(zhí)行 rehash,那么其值表示當前 rehash 操作執(zhí)行的 ht_table[0] 散列表 dictEntry 數(shù)組的索引。
- pauserehash 表示 rehash 的狀態(tài),大于 0 時表示 rehash 暫停了,小于 0 表示出錯了。
Sorted Sets(有序集合)
Sorted Sets 與 Sets 類似,是一種集合類型,集合中不會出現(xiàn)重復的數(shù)據(jù)(member)。區(qū)別在于 Sorted Sets 元素由兩部分組成,分別是 member 和 score。member 會關聯(lián)一個 double 類型的分數(shù)(score),sorted sets 默認會根據(jù)這個 score 對 member 進行從小到大的排序,如果 member 關聯(lián)的分數(shù) score 相同,則按照字符串的字典順序排序。
2-24
常見的使用場景:
- 排行榜,比如維護大型在線游戲中根據(jù)分數(shù)排名的 Top 10 有序列表。
- 速率限流器,根據(jù)排序集合構建滑動窗口速率限制器。
- 延遲隊列,score 存儲過期時間,從小到大排序,最靠前的就是最先到期的數(shù)據(jù)。
Sorted Sets 底層有兩種方式來存儲數(shù)據(jù)。
- 在 7.0 版本之前是 ziplist,之后被 listpack 代替,使用條件是集合元素個數(shù)小于等于 zset-max-listpack-entries 配置值(默認 128),且 member 占用字節(jié)大小小于 zset-max-listpack-value 配置值(默認 64)時使用 listpack 存儲,member 和 score 緊湊排列作為 listpack 的一個元素進行存儲。
- 不滿足上述條件,使用 skiplist + dict(散列表) 組合方式存儲,數(shù)據(jù)會插入 skiplist 的同時也會向 dict(散列表)中插入數(shù)據(jù) ,是一種用空間換時間的思路。散列表的 key 存放的是元素的 member,value 存儲的是 member 關聯(lián)的 score。
skiplist + dict
MySQL:“說說什么是跳表吧”
實質就是一種可以進行二分查找的有序鏈表。跳表在原有的有序鏈表上面增加了多級索引,通過索引來實現(xiàn)快速查找。
查找數(shù)據(jù)總是從最高層開始比較,如果節(jié)點保存的值比待查數(shù)據(jù)小,跳表就繼續(xù)訪問該層的下一個節(jié)點;
如果碰到比待查數(shù)據(jù)值大的節(jié)點時,那就跳到當前節(jié)點的下一層的鏈表繼續(xù)查找。
比如現(xiàn)在想查找 17,查找的路徑如下圖紅色指向的方向進行。
圖 2-27
- 從 level 1 開始,17 與 6 比較,值大于節(jié)點,繼續(xù)與下一個節(jié)點比較。
- 與 26 比較,17 < 26,回到原節(jié)點,跳到當前節(jié)點的 level 0 層鏈表,與下一個節(jié)點比較,找到目標 17。
MySQL:采用 listpack 存儲數(shù)據(jù)的 Sorted Sets 怎么實現(xiàn)呢?
我們知道,listpack 是一塊由多個數(shù)據(jù)項組成的連續(xù)內(nèi)存。而 sorted set 每一項元素是由 member 和 score 兩部分組成。
采用 listpack 存儲插入一個(member、score)數(shù)據(jù)對的時候,每個 member/score 數(shù)據(jù)對緊湊排列存儲。
listpack 最大的優(yōu)勢就是節(jié)省內(nèi)存,查找元素的話只能按順序查找,時間復雜度是 O(n)。
正是如此,在少量數(shù)據(jù)的情況下,才能做到既能節(jié)省內(nèi)存,又不會影響性能。
每一步查找前進兩個數(shù)據(jù)項,也就是跨越一個 member/score 數(shù)據(jù)對。
圖 2-30
streams(流)
Stream 是 Redis 5.0 版本專門為消息隊列設計的數(shù)據(jù)類型,借鑒了 Kafka 的 Consume Group 設計思路,提供了消費組概念。
同時提供了消息的持久化和主從復制機制,客戶端可以訪問任何時刻的數(shù)據(jù),并且能記住每一個客戶端的訪問位置,從而保證消息不丟失。
以下幾個是 Stream 類型的主要特性。
- 使用 Radix Tree 和 listpack 結構來存儲消息。
- 消息 ID 序列化生成。
- 借鑒 Kafka Consume Group 的概念,多個消費者劃分到不同的 Consume Group 中,消費同一個 Streams,同一個 Consume Group 的多個消費者可以一起并行但不重復消費,提升消費能力。
- 支持多播(多對多),阻塞和非阻塞讀取。
- ACK 確認機制,保證了消息至少被消費一次。
- 可設置消息保存上限閾值,我會把歷史消息丟棄,防止內(nèi)存占用過大。
Stream 流就像是一個僅追加內(nèi)容的消息鏈表,把消息一個個串起來,每個消息都有一個唯一的 ID 和消息內(nèi)容,消息內(nèi)容則由多個 field/value 鍵值對組成。底層使用 Radix Tree 和 listpack 數(shù)據(jù)結構存儲數(shù)據(jù)。
為了便于理解,我畫了一張圖,并對 Radix Tree 的存儲數(shù)據(jù)做了下變形,使用列表來體現(xiàn) Stream 中消息的邏輯有序性。
圖 2-31
Geo(地理空間)
Redis 老兄,產(chǎn)品經(jīng)理跟我說,他有一個 idea,想為廣大少男少女提供一個連接彼此的機會。
所謂花有重開日,人無再少年,為了讓處于這美好年齡的少男少女,能在以每一個十二時辰里邂逅那個 ta”。想開發(fā)一款 APP,用戶登錄登錄后,基于地理位置發(fā)現(xiàn)附近的那個 Ta 鏈接彼此。
Sorted Sets 集合中,每個元素由兩部分組成,分別是 member 和 score。可以根據(jù)權重分數(shù)對 member 排序,這樣看起來就滿足我的需求了。比如,member 存儲 “女神 ID”,score 是該女神的經(jīng)緯度信息。
圖 2-40
還有一個問題,Sorted Set 元素的權重值是一個浮點數(shù),經(jīng)緯度是經(jīng)度、緯度兩個值,咋辦呢?如何將經(jīng)緯度轉換成一個浮點數(shù)呢?
思路對了,為了實現(xiàn)對經(jīng)緯度比較,Redis 采用業(yè)界廣泛使用的 GeoHash 編碼,分別對經(jīng)度和緯度編碼,最后再把經(jīng)緯度各自的編碼組合成一個最終編碼。
這樣就實現(xiàn)了將經(jīng)緯度轉換成一個值,而 Redis 的 GEO 類型的底層數(shù)據(jù)結構用的就是 Sorted Set來實現(xiàn)。
Geohash 算法就是將經(jīng)緯度編碼,將二維變一維,給地址位置分區(qū)的一種算法,核心思想是區(qū)間二分:將地球編碼看成一個二維平面,然后將這個平面遞歸均分為更小的子塊。
一共可以分為三步。
- 將經(jīng)緯度變成一個 N 位二進制。
- 將經(jīng)緯度的二進制合并。
- 按照 Base32 進行編碼。
Bitmap(位圖)
Redis Bitmap(位圖)是 Redis 提供的一種特殊的數(shù)據(jù)結構,用于處理位級別的數(shù)據(jù)。
實際上是在 String 類型上定義的面向 bit 位的操作,將位圖存儲在字符串中,每個字符代表 8 位二進制,是一個由二進制位(bit)組成的數(shù)組,其中的每一位只能是 0 或 1。
String 數(shù)據(jù)類型最大容量是 512MB,所以一個 Bitmap 最多可設置 2^32 個不同位。
Bitmap 的底層數(shù)據(jù)結構用的是 String 類型的 SDS 數(shù)據(jù)結構來保存位數(shù)組,Redis 把每個字節(jié)數(shù)組的 8 個 bit 位利用起來,每個 bit 位 表示一個元素的二值狀態(tài)(不是 0 就是 1)。
可以將 Bitmap 看成是一個 bit 為單位的數(shù)組,數(shù)組的每個單元只能存儲 0 或者 1,數(shù)組的每個 bit 位下標在 Bitmap 中叫做 offset 偏移量。
為了直觀展示,我們可以理解成 buf 數(shù)組的每個槽位中的字節(jié)用一行表示,每一行有 8 個 bit 位,8 個格子分別表示這個字節(jié)中的 8 個 bit 位,如下圖所示:
2-44
8 個 bit 組成一個 Byte,所以 Bitmap 會極大地節(jié)省存儲空間。 這就是 Bitmap 的優(yōu)勢。
HyperlogLogs(基數(shù)統(tǒng)計)
在移動互聯(lián)網(wǎng)的業(yè)務場景中,數(shù)據(jù)量很大,系統(tǒng)需要保存這樣的信息:一個 key 關聯(lián)了一個數(shù)據(jù)集合,同時對這個數(shù)據(jù)集合做統(tǒng)計做一個報表給運營人員看。
比如:
- 統(tǒng)計一個 APP 的日活、月活數(shù)。
- 統(tǒng)計一個頁面的每天被多少個不同賬戶訪問量(Unique Visitor,UV)。
- 統(tǒng)計用戶每天搜索不同詞條的個數(shù)。
- 統(tǒng)計注冊 IP 數(shù)。
通常情況下,系統(tǒng)面臨的用戶數(shù)量以及訪問量都是巨大的,比如百萬、千萬級別的用戶數(shù)量,或者千萬級別、甚至億級別的訪問信息,咋辦呢?
Redis:“這些就是典型的基數(shù)統(tǒng)計應用場景,基數(shù)統(tǒng)計:統(tǒng)計一個集合中不重復元素,這被稱為基數(shù)?!?/p>
HyperLogLog 是一種概率數(shù)據(jù)結構,用于估計集合的基數(shù)。每個 HyperLogLog 最多只需要花費 12KB 內(nèi)存,在標準誤差 0.81%的前提下,就可以計算 2 的 64 次方個元素的基數(shù)。
HyperLogLog 的優(yōu)點在于它所需的內(nèi)存并不會因為集合的大小而改變,無論集合包含的元素有多少個,HyperLogLog 進行計算所需的內(nèi)存總是固定的,并且是非常少的。
Redis 內(nèi)部使用字符串位圖來存儲 HyperLogLog 所有桶的計數(shù)值,一共分了 2^14 個桶,也就是 16384 個桶。每個桶中是一個 6 bit 的數(shù)組。
這段代碼描述了 Redis HyperLogLog 數(shù)據(jù)結構的頭部定義(hyperLogLog.c 中的 hllhdr 結構體)。以下是關于這個數(shù)據(jù)結構的各個字段的解釋。
struct hllhdr {
char magic[4];
uint8_t encoding;
uint8_t notused[3];
uint8_t card[8];
uint8_t registers[];
};
- **magic[4]**:這個字段是一個 4 字節(jié)的字符數(shù)組,用來表示數(shù)據(jù)結構的標識符。在 HyperLogLog 中,它的值始終為"HYLL",用來標識這是一個 HyperLogLog 數(shù)據(jù)結構。
- encoding:這是一個 1 字節(jié)的字段,用來表示 HyperLogLog 的編碼方式。它可以取兩個值之一:
a.HLL_DENSE:表示使用稠密表示方式。
b.HLL_SPARSE:表示使用稀疏表示方式。
- **notused[3]**:這是一個 3 字節(jié)的字段,目前保留用于未來的擴展,要求這些字節(jié)的值必須為零。
- **card[8]**:這是一個 8 字節(jié)的字段,用來存儲緩存的基數(shù)(基數(shù)估計的值)。
- **egisters[]**:這個字段是一個可變長度的字節(jié)數(shù)組,用來存儲 HyperLogLog 的數(shù)據(jù)。
4-45
Redis 對 HyperLogLog 的存儲進行了優(yōu)化,在計數(shù)比較小的時候,存儲空間采用系數(shù)矩陣,占用空間很小。
只有在計數(shù)很大,稀疏矩陣占用的空間超過了閾值才會轉變成稠密矩陣,占用 12KB 空間。
Bloom Filter(布隆過濾器)
當你遇到數(shù)據(jù)量大,又需要去重的時候就可以考慮布隆過濾器,如下場景:
- 解決 Redis 緩存穿透問題。
- 郵件過濾,使用布隆過濾器實現(xiàn)郵件黑名單過濾。
- 爬蟲爬過的網(wǎng)站過濾,爬過的網(wǎng)站不再爬取。
- 推薦過的新聞不再推薦。
布隆過濾器 (Bloom Filter)是由 Burton Howard Bloom 于 1970 年提出,它是一種 space efficient 的概率型數(shù)據(jù)結構,用于判斷一個元素是否在集合中。
是一種空間效率高、時間復雜度低的數(shù)據(jù)結構,用于檢查一個元素是否存在于一個集合中。它通常用于快速判斷某個元素是否可能存在于一個大型數(shù)據(jù)集中,而無需實際存儲整個數(shù)據(jù)集。
布隆過濾器客戶以保證某個數(shù)據(jù)不存在時,那么這個數(shù)據(jù)一定不存在;當給出的響應是存在,這個數(shù)可能不存在。
Redis 的 Bloom Filter 實現(xiàn)基于一個位數(shù)組(bit array)和一組不同的哈希函數(shù)。
- 首先分配一塊內(nèi)存空間做 bit 數(shù)組,這個位數(shù)組的長度是固定的,通常由用戶指定,決定了 Bloom Filter 的容量。每個位都初始為 0。
- 添加元素時,采用 k 個相互獨立的 Hash 函數(shù)對這個 key 計算,這些哈希函數(shù)應該是獨立的,均勻分布的,以減小沖突的可能性,然后將元素 Hash 值所映射的 K 個位置全部設置為 1。
- 檢測 key 是否存在,仍然用這 k 個 Hash 函數(shù)對 key 計算出 k 哈希值,哈希值映射的 k 個 位置,如果位置全部為 1,則表明 key 可能存在,否則不存在。
2-46
高可用架構
我是一個基于內(nèi)存的數(shù)據(jù)庫,名字叫 Redis。我對數(shù)據(jù)讀寫操作的速度快到令人發(fā)指,很多程序員把我當做緩存使用系統(tǒng),用于提高系統(tǒng)讀取響應性能。
然而,快是需要付出代價的:內(nèi)存無法持久化,一旦斷電或者宕機,我保存在內(nèi)存中的數(shù)據(jù)將全部丟失。
我有兩大殺手锏,實現(xiàn)了數(shù)據(jù)持久化,做到宕機快速恢復,不丟數(shù)據(jù)穩(wěn)如狗,避免從數(shù)據(jù)庫中慢慢恢復數(shù)據(jù),他們分別是 RDB 快照和 AOF(Append Only File)。
RDB 快照
RDB 內(nèi)存快照,指的就是 Redis 內(nèi)存中的某一刻的數(shù)據(jù)。
好比時間定格在某一刻,當我們拍照時,把某一刻的瞬間畫面定格記錄下來。
我跟這個類似,就是把某一刻的數(shù)據(jù)以文件的形式“拍”下來,寫到磁盤上。這個文件叫做 RDB 文件,是 Redis Database 的縮寫。
我只需要定時執(zhí)行 RDB 內(nèi)存快照,就不必每次執(zhí)行寫指令都寫磁盤,既實現(xiàn)了快,還實現(xiàn)了持久化。
RDB內(nèi)存快照
當在進行宕機后重啟數(shù)據(jù)恢復時,直接將磁盤的 RDB 文件讀入內(nèi)存即可。
MySQL:“實際生產(chǎn)環(huán)境中,程序員通常給你配置 6GB 的內(nèi)存,將這么大的內(nèi)存數(shù)據(jù)生成 RDB 快照文件落到磁盤的過程會持續(xù)比較長的時間。
你如何做到繼續(xù)處理寫指令請求,又保證 RDB 與內(nèi)存中的數(shù)據(jù)的一致性呢?”
作為唯快不破的 NoSQL 數(shù)據(jù)庫扛把子,我在對內(nèi)存數(shù)據(jù)做快照的時候,并不會暫停寫操作(讀操作不會造成數(shù)據(jù)的不一致)。
我使用了操作系統(tǒng)的多進程寫時復制技術 COW(Copy On Write) 來實現(xiàn)快照持久化。
在持久化時我會調(diào)用操作系統(tǒng) glibc 函數(shù)fork產(chǎn)生一個子進程,快照持久化完全交給子進程來處理,主進程繼續(xù)處理客戶端請求。
子進程剛剛產(chǎn)生時,它和父進程共享內(nèi)存里面的代碼段和數(shù)據(jù)段,你可以將父子進程想象成成一個連體嬰兒,共享身體。
這是 Linux 操作系統(tǒng)的機制,為了節(jié)約內(nèi)存資源,所以盡可能讓它們共享起來。在進程分離的一瞬間,內(nèi)存的增長幾乎沒有明顯變化。
bgsave 子進程可以共享主線程的所有內(nèi)存數(shù)據(jù),所以能讀取主線程的數(shù)據(jù)并寫入 RDB 文件。
如果主線程對這些數(shù)據(jù)是讀操作,那么主線程和 bgsave子進程互不影響。
當主線程要修改某個鍵值對時,這個數(shù)據(jù)會把發(fā)生變化的數(shù)據(jù)復制一份,生成副本。
接著,bgsave 子進程會把這個副本數(shù)據(jù)寫到 RDB 文件,從而保證了數(shù)據(jù)一致性。
圖 3-2 寫時復制技術保證快照期間數(shù)據(jù)客修改
AOF
針對 RDB 不適合實時持久化等問題,我提供 AOF 持久化方式來破解。
AOF (Append Only File)持久化記錄的是服務器接收的每個寫操作,在服務器啟動執(zhí)行重放還原數(shù)據(jù)集。
AOF 采用的是寫后日志模式,即先寫內(nèi)存,后寫日志。
AOF寫后日志
當我接收到 set key MageByte 命令將數(shù)據(jù)寫到內(nèi)存后, 會按照如下格式寫入 AOF 文件。
- *3:表示當前指令分為三個部分,每個部分都是 $ + 數(shù)字開頭,緊跟后面是該部分具體的指令、鍵、值。
- 數(shù)字:表示這部分的命令、鍵、值多占用的字節(jié)大小。比如 $3表示這部分包含 3 個字節(jié),也就是 SET 指令。
AOF 日志格式
為了解決 AOF 文件體積膨脹的問題,創(chuàng)造我的 antirez 老哥設計了一個殺手锏——AOF 重寫機制,對文件進行瘦身。
例如,使用 INCR counter 實現(xiàn)一個自增計數(shù)器,初始值 1,遞增 1000 次的最終目標是 1000,在 AOF 中保存著 1000 次指令。
在重寫的時候并不需要其中的 999 個寫操作,重寫機制有多變一功能,將舊日志中的多條指令,重寫后就變成了一條指令。
其原理就是開辟一個子進程將內(nèi)存中的數(shù)據(jù)轉換成一系列 Redis 的寫操作指令,寫到一個新的 AOF 日志文件中。再將操作期間發(fā)生的增量 AOF 日志追加到這個新的 AOF 日志文件中,追加完畢后就立即替代舊的 AOF 日志文件了,瘦身工作就完成了。
AOF重寫機制(糾錯:3條變一條)
每次 AOF 重寫時,Redis 會先執(zhí)行一個內(nèi)存拷貝,讓 bgrewriteaof 子進程擁有此時的 Redis 內(nèi)存快照,子進程遍歷 Redis 中的全部鍵值對,生成重寫記錄。
使用兩個日志保證在重寫過程中,新寫入的數(shù)據(jù)不會丟失,并且保持數(shù)據(jù)一致性。
AOF 重寫過程
antirez 在 4.0 版本中給我提供了一個混合使用 AOF 日志和 RDB 內(nèi)存快照的方法。簡單來說,RDB 內(nèi)存快照以一定的頻率執(zhí)行,在兩次快照之間,使用 AOF 日志記錄這期間的所有寫操作。
如此一來,快照就不需要頻繁執(zhí)行,避免了 fork 對主線程的性能影響,AOF 不再是全量日志,而是生成 RDB 快照時間的增量 AOF 日志,這個日志就會很小,都不需要重寫了。
等到,第二次做 RDB 全量快照,就可以清空舊的 AOF 日志,恢復數(shù)據(jù)的時候就不需要使用 AOF 日志了。
主從復制
Chaya:“李老師,有了 RDB 內(nèi)存快照和 AOF 再也不怕宕機丟失數(shù)據(jù)了,但是 Redis 實例宕機了辦?如何實現(xiàn)高可用呢?“
李老師愣了一會兒,又趕緊補充道:“依然記得那晚我和我的戀人 Chaya 鴛語輕傳,香風急促,朱唇緊貼。香肌如雪,羅裳慢解春光泄。含香玉體說溫存,多少風和月。今宵魚水和諧,抖顫顫,春潮難歇。千聲呢喃,百聲喘吁,數(shù)番愉悅。”
可是這時候 Redis 忽然宕機了,無法對外提供服務,電話連環(huán) call,豈不是折煞人也。
Redis:“你還念上詩歌了,莫怕,為了你們的幸福。我提供了主從模式,通過主從復制,將數(shù)據(jù)冗余一份復制到其他 Redis 服務器,實現(xiàn)高可用。你們放心的說溫存,說風月?!?/p>
既然一臺宕機了無法提供服務,那多臺呢?是不是就可以解決了。
前者稱為 mater (master),后者稱為 slave (slave),數(shù)據(jù)的復制是單向的,只能由 mater 到 slave。
默認情況下,每臺 Redis 服務器都是 mater;且一個 mater 可以有多個 slave (或沒有 slave),但一個 slave 只能有一個 mater。
3-1
主從庫第一次復制過程大體可以分為 3 個階段:連接建立階段(即準備階段)、mater 同步數(shù)據(jù)到 slave 階段、發(fā)送同步期間接收到的新寫命令到 slave 階段。
直接上圖,從整體上有一個全局觀的感知,后面具體介紹。
Redis全量同步
哨兵集群
主從復制架構面臨一個嚴峻問題,master 掛了,無法執(zhí)行寫操作,無法自動選擇一個 Slave 切換為 Master,也就是無法故障自動切換。
李老師:“還記得那晚與我女友 Chaya 約會,眼前是橡樹的綠葉,白色的竹籬笆。好想告訴我的她,這里像幅畫。一起手牽手么么噠……(此處省略 10000 字)。
Redis 忽然宕機,我總不能推開 Chaya,停止甜蜜,然后打開電腦手工進行主從切換,再通知其他程序員把地址重新改成新 Master 信息上線?”。
Redis:“如此一折騰恐怕李老師已被 Chaya 切換成前男友了,心里的雨傾盆地下,萬萬使不得。所以必須有一個高可用的方案,為此,我提供一個高可用方案——哨兵(Sentinel)“。
先來看看哨兵是什么?搭建哨兵集群的方法我就不細說了,假設三個哨兵組成一個哨兵集群,三個數(shù)據(jù)節(jié)點構成一個一主兩從的 Redis 主從架構。
3-17
Redis 哨兵集群高可用方法,有三種角色,分別是 master,slave,sentinel。
- setinel 節(jié)點之間互相通信,組成一個集群視線哨兵高可用,選舉出一個 leader 執(zhí)行故障轉移。
- master 與 slave 之間通信,組成主從復制架構。
- sentinel 與 master/ slave 通信,是為了對該主從復制架構進行管理:監(jiān)視(Monitoring)、通知(Notification)、自動故障切換(Automatic Failover)、配置提供者(Configuration Provider)。
Redis Cluster
使用 Redis Cluster 集群,主要解決了大數(shù)據(jù)量存儲導致的各種慢問題,同時也便于橫向拓展。
兩種方案對應著 Redis 數(shù)據(jù)增多的兩種拓展方案:垂直擴展(scale up)、水平擴展(scale out)。
- 垂直拓展:升級單個 Redis 的硬件配置,比如增加內(nèi)存容量、磁盤容量、使用更強大的 CPU。
- 水平拓展:橫向增加 Redis 實例個數(shù),每個節(jié)點負責一部分數(shù)據(jù)。
比如需要一個內(nèi)存 24 GB 磁盤 150 GB 的服務器資源,有以下兩種方案。
3-24
在面向百萬、千萬級別的用戶規(guī)模時,橫向擴展的 Redis 切片集群會是一個非常好的選擇。
Redis Cluster 在 Redis 3.0 及以上版本提供,是一種分布式數(shù)據(jù)庫方案,通過分片(sharding)來進行數(shù)據(jù)管理(分治思想的一種實踐),并提供復制和故障轉移功能。
Redis Cluster 并沒有使用一致性哈希算法,而是將數(shù)據(jù)劃分為 16384 的 slots ,每個節(jié)點負責一部分 slots,slot 的信息存儲在每個節(jié)點中。
它是去中心化的,如圖 3-25 所示,該集群有三個 Redis mater 節(jié)點組成(省略每個 master 對應的的 slave 節(jié)點),每個節(jié)點負責整個集群的一部分數(shù)據(jù),每個節(jié)點負責的數(shù)據(jù)多少可以不一樣。
圖 3-25
三個節(jié)點相互連接組成一個對等的集群,它們之間通過 Gossip協(xié)議相互交互集群信息,最后每個節(jié)點都保存著其他節(jié)點的 slots 分配情況。
Chaya:“Redis Cluster 如何實現(xiàn)自動故障轉移呢?”
簡而概之的說,Redis Cluster 會經(jīng)歷以下三個步驟實現(xiàn)自動故障轉移實現(xiàn)高可用。
- 故障檢測:集群中每個節(jié)點都會定期通過 Gossip 協(xié)議向其他節(jié)點發(fā)送 PING 消息,檢測各個節(jié)點的狀態(tài)(在線狀態(tài)、疑似下線狀態(tài) PFAIL、已下線狀態(tài) FAIL)。并通過 Gossip 協(xié)議來廣播自己的狀態(tài)以及自己對整個集群認知的改變。
- master 選舉:使用從當前故障 master 的所有 slave 選舉一個提升為 master。
- 故障轉移:取消與舊 master 的主從復制關系,將舊 master 負責的槽位信息指派到當前 master,更新 Cluster 狀態(tài)并寫入數(shù)據(jù)文件,通過 gossip 協(xié)議向集群廣播發(fā)送 CLUSTERMSG_TYPE_PONG消息,把最新的信息傳播給其他節(jié)點,其他節(jié)點收到該消息后更新自身的狀態(tài)信息或與新 master 建立主從復制關系。
發(fā)布訂閱
Redis 發(fā)布訂閱(Pus/Sub)是一種消息通信模式:發(fā)送者通過 PUBLISH發(fā)布消息,訂閱者通過 SUBSCRIBE 訂閱或通過UNSUBSCRIBE 取消訂閱。
發(fā)布到訂閱模式主要包含三個部分組成。
- 發(fā)布者(Publisher),發(fā)送消息到頻道中,每次只能往一個頻道發(fā)送一條消息。
- 訂閱者(Subscriber),可以同時訂閱多個頻道。
- 頻道(Channel),將發(fā)布者發(fā)布的消息轉發(fā)給當前訂閱此頻道的訂閱者。
碼哥寫好了一篇技術文章則通過 “ChannelA” 發(fā)布消息,消息的訂閱者就會收到“關注碼哥字節(jié),提升技術”的消息。
圖4-13
Chaya:“說了這么多,Redis 發(fā)布訂閱能在什么場景發(fā)揮作用呢?”
哨兵間通信
哨兵集群中,每個哨兵節(jié)點利用 Pub/Sub 發(fā)布訂閱實現(xiàn)哨兵之間的相互發(fā)現(xiàn)彼此和找到 Slave。
哨兵與 Master 建立通信后,利用 master 提供發(fā)布/訂閱機制在__sentinel__:hello發(fā)布自己的信息,比如 IP、端口……,同時訂閱這個頻道來獲取其他哨兵的信息,就這樣實現(xiàn)哨兵間通信。
消息隊列
之前碼哥跟大家分享過如何利用 Redis List 與 Stream 實現(xiàn)消息隊列。我們也可以利用 Redis 發(fā)布/訂閱機制實現(xiàn)輕量級簡單的 MQ 功能,實現(xiàn)上下游解耦,需要注意點是 Redis 發(fā)布訂閱的消息不會被持久化,所以新訂閱的客戶端將收不到歷史消息。
Redis I/O 多線程模型
Redis 官方在 2020 年 5 月正式推出 6.0 版本,引入了 I/O 多線程模型。
謝霸哥:“為什么之前是單線程模型?為什么 6.0 引入了 I/O 多線程模型?主要解決了什么問題?”
今天,咱們就詳細的聊下 I/O 多線程模型帶來的效果到底是黛玉騎鬼火,該強強,該弱弱;還是猶如光明頂身懷絕技的的張無忌,招招都是必殺技。
命令執(zhí)行階段,每一條命令并不會立馬被執(zhí)行,而是進入一個一個 socket 隊列,當 socket 事件就緒則交給事件分發(fā)器分發(fā)到對應的事件處理器處理,單線程模型的命令處理如下圖所示。
圖 4-23
謝霸哥:“為什么 Redis6.0 之前是單線程模型?”
以下是官方關于為什么 6.0 之前一直使用單線程模型的回答。
- Redis 的性能瓶頸主要在于內(nèi)存和網(wǎng)絡 I/O,CPU 不會是性能瓶頸所在。
- Redis 通過使用 pipelining 每秒可以處理 100 萬個請求,應用程序的所時候用的大多數(shù)命令時間復雜度主要使用 O(N) 或 O(log(N)) 的,它幾乎不會占用太多 CPU。
- 單線程模型的代碼可維護性高。多線程模型雖然在某些方面表現(xiàn)優(yōu)異,但是它卻引入了程序執(zhí)行順序的不確定性,帶來了并發(fā)讀寫的一系列問題,增加了系統(tǒng)復雜度、同時可能存在線程切換、甚至加鎖解鎖、死鎖造成的性能損耗。
需要注意的是,Redis 多 IO 線程模型只用來處理網(wǎng)絡讀寫請求,對于 Redis 的讀寫命令,依然是單線程處理。
這是因為,網(wǎng)絡 I/O 讀寫是瓶頸,可通過多線程并行處理可提高性能。而繼續(xù)使用單線程執(zhí)行讀寫命令,不需要為了保證 Lua 腳本、事務、等開發(fā)多線程安全機制,實現(xiàn)更簡單。
謝霸哥:“碼哥,你真是斑馬的腦袋,說的頭頭是道?!?/p>
我謝謝您嘞,主線程與 I/O 多線程共同協(xié)作處理命令的架構圖如下所示。
圖 4-24
性能排查和優(yōu)化
Redis 通常是我們業(yè)務系統(tǒng)中一個重要的組件,比如:緩存、賬號登錄信息、排行榜等。一旦 Redis 請求延遲增加,可能就會導致業(yè)務系統(tǒng)“雪崩”。
今天碼哥跟大家一起來分析下 Redis 突然變慢了我們該怎么辦?如何確定 Redis 性能出問題了,出現(xiàn)問題要如何調(diào)優(yōu)解決。
性能基線測量
張無劍:“那么,我們?nèi)绾闻卸?Redis 真的變慢呢?”
因此,我們需要對當前環(huán)境下的Redis 基線性能進行測量,即在系統(tǒng)低壓力、無干擾的條件下,獲取其基本性能水平。
當你觀察到 Redis 運行時延遲超過基線性能的兩倍以上時,可以明確判定 Redis 性能已經(jīng)下降。
redis-cli 可執(zhí)行腳本提供了 –intrinsic-latency 選項,用來監(jiān)測和統(tǒng)計測試期間內(nèi)的最大延遲(以毫秒為單位),這個延遲可以作為 Redis 的基線性能。
需要注意的是,你需要在運行 Redis 的服務器上執(zhí)行,而不是在客戶端中執(zhí)行。
./redis-cli --intrinsic-latency 100
Max latency so far: 4 microseconds.
Max latency so far: 18 microseconds.
Max latency so far: 41 microseconds.
Max latency so far: 57 microseconds.
Max latency so far: 78 microseconds.
Max latency so far: 170 microseconds.
Max latency so far: 342 microseconds.
Max latency so far: 3079 microseconds.
45026981 total runs (avg latency: 2.2209 microseconds / 2220.89 nanoseconds per run).
Worst run took 1386x longer than the average latency.
注意:參數(shù)100是測試將執(zhí)行的秒數(shù)。我們運行測試的時間越長,我們就越有可能發(fā)現(xiàn)延遲峰值。
通常運行 100 秒通常是合適的,足以發(fā)現(xiàn)延遲問題了,當然我們可以選擇不同時間運行幾次,避免誤差。
我運行的最大延遲是 3079 微秒,所以基線性能是 3079 (3 毫秒)微秒。
需要注意的是,我們要在 Redis 的服務端運行,而不是客戶端。這樣,可以避免網(wǎng)絡對基線性能的影響。
慢指令監(jiān)控
Chaya:“知道了性能基線后,有什么監(jiān)控手段知道有慢指令呢?”
我們要避免使用時間復雜度為 O(n)的指令,盡可能使用O(1)和O(logN)的指令。
涉及到集合操作的復雜度一般為O(N),比如集合全量查詢HGETALL、SMEMBERS,以及集合的聚合操作 SORT、LREM、 SUNION 等。
Chaya:“代碼不是我寫的,不知道有沒有人用了慢指令,有沒有監(jiān)控呢?”
有兩種方式可以排查到。
- 使用 Redis 慢日志功能查出慢命令。
- latency-monitor(延遲監(jiān)控)工具。
性能問題排查清單
Redis 的指令由單線程執(zhí)行,如果主線程執(zhí)行的操作時間太長,就會導致主線程阻塞。一起分析下都有哪些情況會導致 Redis 性能問題,我們又該如何解決。
1. 網(wǎng)絡通信導致的延遲
客戶端使用 TCP/IP 連接或 Unix 域連接連接到 Redis。1 Gbit/s 網(wǎng)絡的典型延遲約為 200 us。
redis 客戶端執(zhí)行一條命令分 4 個過程:
發(fā)送命令-〉 命令排隊 -〉 命令執(zhí)行-〉 返回結果
這個過程稱為 Round trip time(簡稱 RTT, 往返時間),mget mset 有效節(jié)約了 RTT,但大部分命令(如 hgetall,并沒有 mhgetall)不支持批量操作,需要消耗 N 次 RTT ,這個時候需要 pipeline 來解決這個問題。
解決方案
Redis pipeline 將多個命令連接在一起來減少網(wǎng)絡響應往返次數(shù)。
圖 5-1
2. 慢指令
根據(jù)上文的慢指令監(jiān)控到慢查詢的指令。可以通過以下兩種方式解決。
- 在 Cluster 集群中,將聚合運算等 O(N) 時間復雜度操作放到 slave 上運行或者在客戶端完成。
- 使用更高效的命令代替。比如使用增量迭代的方式,避免一次查詢大量數(shù)據(jù),具體請查看 SCAN、SSCAN、HSCAN、ZSCAN命令。
除此之外,生產(chǎn)中禁用 KEYS 命令,因為它會遍歷所有的鍵值對,所以操作延時高,只適用于調(diào)試。
3. 開啟透明大頁(Transparent HugePages)
常規(guī)的內(nèi)存頁是按照 4 KB 來分配,Linux 內(nèi)核從 2.6.38 開始支持內(nèi)存大頁機制,該機制支持 2MB 大小的內(nèi)存頁分配。
Redis 使用 fork 生成 RDB 快照的過程中,Redis 采用寫時復制技術使得主線程依然可以接收客戶端的寫請求。
也就是當數(shù)據(jù)被修改的時候,Redis 會復制一份這個數(shù)據(jù),再進行修改。
采用了內(nèi)存大頁,生成 RDB 期間即使客戶端修改的數(shù)據(jù)只有 50B 的數(shù)據(jù),Redis 可能需要復制 2MB 的大頁。當寫的指令比較多的時候就會導致大量的拷貝,導致性能變慢。
使用以下指令禁用 Linux 內(nèi)存大頁即可解決。
echo never > /sys/kernel/mm/transparent_hugepage/enabled
4. swap 交換區(qū)
謝霸哥:“什么是 swap 交換區(qū)?”
當物理內(nèi)存不夠用的時候,操作系統(tǒng)會將部分內(nèi)存上的數(shù)據(jù)交換到 swap 空間上,防止程序因為內(nèi)存不夠用而導致 oom 或者更致命的情況出現(xiàn)。
當應用進程向操作系統(tǒng)請求內(nèi)存發(fā)現(xiàn)不足時,操作系統(tǒng)會把內(nèi)存中暫時不用的數(shù)據(jù)交換放在 SWAP 分區(qū)中,這個過程稱為 SWAP OUT。
當該進程又需要這些數(shù)據(jù)且操作系統(tǒng)發(fā)現(xiàn)還有空閑物理內(nèi)存時,就會把 SWAP 分區(qū)中的數(shù)據(jù)交換回物理內(nèi)存中,這個過程稱為 SWAP IN。
內(nèi)存 swap 是操作系統(tǒng)里將內(nèi)存數(shù)據(jù)在內(nèi)存和磁盤間來回換入和換出的機制,涉及到磁盤的讀寫。
謝霸哥:“觸發(fā) swap 的情況有哪些呢?”
對于 Redis 而言,有兩種常見的情況。
- Redis 使用了比可用內(nèi)存更多的內(nèi)存。
- 與 Redis 在同一機器運行的其他進程在執(zhí)行大量的文件讀寫 I/O 操作(包括生成大文件的 RDB 文件和 AOF 后臺線程),文件讀寫占用內(nèi)存,導致 Redis 獲得的內(nèi)存減少,觸發(fā)了 swap。
謝霸哥:“我要如何排查因為 swap 導致的性能變慢呢?”
Linux 提供了很好的工具來排查這個問題,當你懷疑由于交換導致的延遲時,只需按照以下步驟排查。
獲取 Redis pid
我省略部分指令響應的信息,重點關注 process_id。
127.0.0.1:6379> INFO Server
# Server
redis_version:7.0.14
process_id:2847
process_supervised:no
run_id:8923cc83412b223823a1dcf00251eb025acab271
tcp_port:6379
查找內(nèi)存布局
進入 Redis 所在的服務器的 /proc 文件系統(tǒng)目錄。
cd /proc/2847
在這里有一個 smaps 的文件,該文件描述了 Redis 進程的內(nèi)存布局,用 grep 查找所有文件中的 Swap 字段。
$ cat smaps | egrep '^(Swap|Size)'
Size: 316 kB
Swap: 0 kB
Size: 4 kB
Swap: 0 kB
Size: 8 kB
Swap: 0 kB
Size: 40 kB
Swap: 0 kB
Size: 132 kB
Swap: 0 kB
Size: 720896 kB
Swap: 12 kB
每行 Size 表示 Redis 實例所用的一塊內(nèi)存大小,和 Size 下方的 Swap 對應這塊 Size 大小的內(nèi)存區(qū)域有多少數(shù)據(jù)已經(jīng)被換出到磁盤上了,如果 Size == Swap 則說明數(shù)據(jù)被完全換出了。
可以看到有一個 720896 kB 的內(nèi)存大小有 12 kb 被換出到了磁盤上(僅交換了 12 kB),這就沒什么問題。
Redis 本身會使用很多大小不一的內(nèi)存塊,所以,你可以看到有很多 Size 行,有的很小,就是 4KB,而有的很大,例如 720896KB。不同內(nèi)存塊被換出到磁盤上的大小也不一樣。
敲重點了
如果 Swap 一切都是 0 kb,或者零星的 4k ,那么一切正常。
當出現(xiàn)百 MB,甚至 GB 級別的 swap 大小時,就表明,此時,Redis 實例的內(nèi)存壓力很大,很有可能會變慢。
解決方案
- 增加機器內(nèi)存。
- 將 Redis 放在單獨的機器上運行,避免在同一機器上運行需要大量內(nèi)存的進程,從而滿足 Redis 的內(nèi)存需求。
- 增加 Cluster 集群的數(shù)量分擔數(shù)據(jù)量,減少每個實例所需的內(nèi)存。
5. AOF 和磁盤 I/O 導致的延遲
在不死之身高可用章節(jié)我們知道 Redis 為了保證數(shù)據(jù)可靠性,你可以使用 AOF 和 RDB 內(nèi)存快照實現(xiàn)宕機快速恢復和持久化。
**可以使用 appendfsync **配置將 AOF 配置為以三種不同的方式在磁盤上執(zhí)行 write 或者 fsync (可以在運行時使用 CONFIG SET命令修改此設置,比如:redis-cli CONFIG SET appendfsync no)。
- no:Redis 不執(zhí)行 fsync,唯一的延遲來自于 write 調(diào)用,write 只需要把日志記錄寫到內(nèi)核緩沖區(qū)就可以返回。
- everysec:Redis 每秒執(zhí)行一次 fsync,使用后臺子線程異步完成 fsync 操作。最多丟失 1s 的數(shù)據(jù)。
- always:每次寫入操作都會執(zhí)行 fsync,然后用 OK 代碼回復客戶端(實際上 Redis 會嘗試將同時執(zhí)行的許多命令聚集到單個 fsync 中),沒有數(shù)據(jù)丟失。在這種模式下,性能通常非常低,強烈建議使用 SSD 和可以在短時間內(nèi)執(zhí)行 fsync 的文件系統(tǒng)實現(xiàn)。
我們通常只是將 Redis 用于緩存,數(shù)據(jù)未命中從數(shù)據(jù)獲取,并不需要很高的數(shù)據(jù)可靠性,建議設置成 no 或者 everysec。
除此之外,避免 AOF 文件過大 Redis 會進行 AOF 重寫縮小的 AOF 文件大小。
你可以把配置項 no-appendfsync-on-rewrite設置為 yes,表示在 AOF 重寫時不進行 fsync 操作。
也就是說,Redis 實例把寫命令寫到內(nèi)存后,不調(diào)用后臺線程進行 fsync 操作,就直接向客戶端返回了。
6. fork 生成 RDB 導致的延遲
Redis 必須 fork 后臺進程才能生成 RDB 內(nèi)存快照文件,fork 操作(在主線程中運行)本身會導致延遲。
Redis 使用操作系統(tǒng)的多進程寫時復制技術 COW(Copy On Write) 來實現(xiàn)快照持久化,減少內(nèi)存占用。
圖 5-2
但 fork 會涉及到復制大量鏈接對象,一個 24 GB 的大型 Redis 實例執(zhí)行 bgsave生成 RDB 內(nèi)存快照文件 需要復制 24 GB / 4 kB * 8 = 48 MB 的頁表。
此外,slave 在加載 RDB 期間無法提供讀寫服務,所以主庫的數(shù)據(jù)量大小控制在 2~4G 左右,讓從庫快速的加載完成。
7. 鍵值對數(shù)據(jù)集中過期淘汰
Redis 有兩種方式淘汰過期數(shù)據(jù)。
- 惰性刪除:當接收請求的時候檢測 key 已經(jīng)過期,才執(zhí)行刪除。
- 定時刪除:按照每 100 毫秒的頻率刪除一些過期的 key。
定時刪除的算法如下。
- 隨機采樣 CTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP(默認設置為 20)`個數(shù)的 key,刪除所有過期的 key。
- 執(zhí)行之后,如果發(fā)現(xiàn)還有超過 25% 的 key 已過期未被刪除,則繼續(xù)執(zhí)行步驟一。
每秒執(zhí)行 10 次,一次刪除 200 個 key 沒啥性能影響。如果觸發(fā)了第二條,就會導致 Redis 一致在刪除過期數(shù)據(jù)取釋放內(nèi)存。
謝霸哥:“碼哥,觸發(fā)條件是什么呀?”
大量的 key 設置了相同的時間參數(shù),同一秒內(nèi)大量 key 過期,需要重復刪除多次才能降低到 25% 以下。
簡而言之:大量同時到期的 key 可能會導致性能波動。
解決方案
如果一批 key 的確是同時過期,可以在 EXPIREAT 和 EXPIRE 的過期時間參數(shù)上,加上一個一定大小范圍內(nèi)的隨機數(shù),這樣,既保證了 key 在一個鄰近時間范圍內(nèi)被刪除,又避免了同時過期造成的壓力。
8. bigkey
謝霸哥:“什么是 Bigkey?key 很大么?”
“大”確實是關鍵字,但是這里的“大”指的是 Redis 中那些存有較大量元素的集合或列表、大對象的字符串占用較大內(nèi)存空間的鍵值對數(shù)據(jù)稱為 Bigkey。用幾個實際例子來說。
- 一個 String 類型的 Key,它的 value 為 5MB(數(shù)據(jù)過大)。
- 一個 List 類型的 Key,它的列表數(shù)量為 10000 個(列表數(shù)量過多)。
- 一個 Zset 類型的 Key,它的成員數(shù)量為 10000 個(成員數(shù)量過多)。
- 一個 Hash 格式的 Key,它的成員數(shù)量雖然只有 1000 個但這些成員的 value 總大小為 10MB(成員體積過大)。
Bigkey 的存在可能會引發(fā)以下問題。
- 內(nèi)存壓力增大: 大鍵會占用大量的內(nèi)存,可能導致 Redis 實例的內(nèi)存使用率過高,Redis 內(nèi)存不斷變大引發(fā) OOM,或者達到 maxmemory 設置值引發(fā)寫阻塞或重要 Key 被淘汰。
- 持久化延遲: 在進行持久化操作(如 RDB 快照、AOF 日志)時,處理 bigkey 可能導致持久化操作的延遲。
- 網(wǎng)絡傳輸壓力: 在主從復制中,如果有 bigkey 的存在,可能導致網(wǎng)絡傳輸?shù)膲毫υ龃蟆?/li>
- bigkey 的讀請求占用過大帶寬,自身變慢的同時影響到該服務器上的其它服務。
謝霸哥:“如何解決 Bigkey 問題呢?”
- 定期檢測: 使用工具如 redis-cli 的 --bigkeys 參數(shù)進行定期掃描和檢測。
- 優(yōu)化數(shù)據(jù)結構: 根據(jù)實際業(yè)務需求,優(yōu)化使用的數(shù)據(jù)結構,例如使用 HyperLogLog 替代 Set。
- 清理不必要的數(shù)據(jù): Redis 自 4.0 起提供了 UNLINK 命令,該命令能夠以非阻塞的方式緩慢逐步的清理傳入的 Key,通過 UNLINK,你可以安全的刪除大 Key 甚至特大 Key。
- 對大 key 拆分:如將一個含有數(shù)萬成員的 HASH Key 拆分為多個 HASH Key,并確保每個 Key 的成員數(shù)量在合理范圍,在 Redis Cluster 集群中,大 Key 的拆分對 node 間的內(nèi)存平衡能夠起到顯著作用。