洞悉Redis技術內幕:快取,數據結構,並發,集群與演算法

IMG_2100(20210621-234944)

「為什麼這個功能用不了?」 程式設計師:「清一下快取」

上篇洞悉系列文章給大家詳細介紹了MySQL的存儲內幕:洞悉MySQL底層架構:遊走在緩衝與磁碟之間。既然聊過了磁碟存儲,我們今天就進一步來聊聊記憶體存儲。

大多數並發量稍微高點的項目中都不會讓請求直達MySQL這類的關係型資料庫,而是中間加一道或者幾道快取,就如同作業系統中的CPU的多級快取,以及主存那樣,通過更快速的硬體去提高數據讀取的效率,進而加快系統的處理速度,避免讓IO成為系統的瓶頸。

而Redis作為一個成熟的快取中間件,被廣大的互聯網公司直接採用或者進行二次開發。如果要開發並發量高的項目,Redis是一個不錯的快取技術選型。

今天,我們就來詳細聊聊Redis技術內幕。

為了讓大家對Redis有一個全面的了解,本文,我們會從以下幾個角度去分析Redis:

image-20210619205853326

每個部分涉及的內容可能會比較多,為了讓大家對每個部分的內容有一個整體的認識,在每個部分的最開頭,都會把該部分的內容以思維導圖的方式給出。下面開始進入正題吧。

Part Ⅰ. 存儲

image-20210619210840837

1、記憶體

既然要把數據放到記憶體中,就需要提供索引數據的方式,常見的索引實現技術有:Hash表,B+樹,字典樹等。MySQL中的索引是通過B+樹實現的。而Redis作為KV記憶體資料庫,其是採用哈希表來實現索引的。

為了實現鍵值快速訪問,Redis使用了個哈希表來存儲所有的鍵值對。

在記憶體中的布局如下:

image-20210404114945237

哈希桶中存儲entry元素,entry元素包含了key和value指針,指向實際的key和value內容。

key指向的是字元串,value指向的是實際的各種redis數據結構。

這種結構下,只要哈希衝突不多,那麼尋找kv鍵值對的效率就是很高的,而redis與Memcached相比,最重要的亮點就是value中提供的各種數據結構。這些數據結構的實現決定了執行各種操作命令獲取數據的性能。

我們接下來看看這些數據結構。

1.1、redis中的各種數據結構

redis主要的數據類型有:String,List,Hash,Set,SortedSet,也稱為對象,而這些數據類型,底層是基於特定的數據結構來實現的。

我們之前也在IT宅(itzhai.com)以及Java架構雜談中聊過了一些常見的數據結構:

而Redis中,基於存儲效率和訪問效率的考慮,使用到了一些其他的數據結構。我們首先來看看Redis中常見的這些數據結構,然後在看看這些數據類型是由什麼數據結構組成的

1.1.1、SDS

我們知道,String類內部定義了常量數組進行存儲字元串,是不可以修改的,每次對字元串操作都會另外分配一個新的常量數組空間。

image-20210404132906069

而Redis中的字元串是動態的。字元串是Redis中最為常見的存儲類型,底層採用簡單動態字元串實現(SDS[2], simple dynamic string),是可以修改的字元串,類似於Java中的ArrayList,採用預分配冗餘空間的方式來減少記憶體的頻繁分配。

Redis中的鍵值對中的鍵、值裡面的字元串、緩衝區、AOF緩衝區、等都是用的SDS。

下面是SDS數據結構的一個圖示:

image-20210404141202214

亮點:

  • 獲取字元串長度時間複雜度為O(1),而C語言則需要遍歷整個數組才能得到長度;
  • 採用空間預分配避免反覆對位元組數組進程擴容,如上圖的SDS,還有2個位元組的空閑空間,如果只是追加一個字元,就不用擴容了。避免了類似C語言要手動重新分配記憶體的情況下,忘記了進行分配而導致的緩衝區溢出或者記憶體泄露問題;
  • 惰性空間釋放:當要縮短字元串長度的時候,程式不會立刻釋放記憶體,而是通過free屬性將這些需要釋放的位元組數量記錄下來,等待將來重複使用;
  • 二進位安全:所有的SDS API都會以處理二進位的方式來處理SDS的buf數組數據,這樣就避免了C字元串讀取到空字元就返回,導致讀取不完整字元串的問題。二進位安全的SDS,是的Redis可以保存任意格式的二進位數據,而不僅僅是文本數據。
  • 如果是C語言,字元串存入了一種使用空字元(\0,不是指空格)來分隔單詞的特殊數據格式,存儲如下內容:image-20210404140751336
  • 使用C字元串函數讀取到的結果會丟失空字元後面的內容,得到:itzhai,丟失了com。
  • SDS使用len屬性的值來判斷字元串是否結束,而不是空字元
  • 兼容部分C字元串函數:為了兼容部分C字元串函數庫,SDS字元串遵循C字元串以空字元結尾的慣例。
空間預分配規則
  • 字元串大小小於1M的時候,每次擴容加倍;
  • 超過1M,擴容只會多擴容1M的空間,上限是512M。

更多關於Redis字元串的命令:Redis – Strings[3]

1.1.2、鏈表 linkedlist

Redis中List[1]使用的是雙向鏈表存儲的。

如下圖:

image-20210404165515794

List特點:

  • 雙向鏈表;
  • 無環;
  • 帶head和tail指針;
  • 帶鏈表長度;
  • 多態,鏈表節點可存儲不同類型的值;

List提供了以下常用操作參考:命令列表:redis.io/commands#list[1]

通過LPUSH,LPOP,RPUSH,RPOP操作,可以把List當成隊列或者棧來使用:

image-20210404103227629

1.1.3、Hash字典

Redis的字典使用哈希表作為底層實現。該數據結構有點複雜,我直接畫一個完整的結構圖,如下:

image-20210413071658562

如上圖,字典結構重點屬性:

  • type和privdata主要是為創建多態字典而設置的;

  • ht[1]: 包含兩個哈希表,正常情況下,只使用ht[0],當需要進行rehash的時候,會使用到ht[1];

  • rehashidx: 記錄rehash目前的進度,如果沒有在rehash,則值為-1;

而哈希表又有如下屬性:

  • 哈希表數組,裡面的元素作為哈希表的哈希桶,該數組每個元素都會指向一個dictEntry結構指針,dictEntry結構保存具體的鍵值對;
  • 數組大小,記錄了哈希表數組的大小;
  • 哈希掩碼,主要用於計算哈希索引值,這個屬性和哈希值決定一個鍵在哈希表數組中的位置;
  • 已有節點個數,記錄哈希表目前已有節點的數量。

dictEntry結構如圖中程式碼所示:

  • key保存鍵值對的鍵的指針;
  • v保存著鍵值對的值,值可以是一個指針,或者是unit64_t整數,或者是int64_t整數。
1.1.3.1、hash和hash衝突

作為哈希表,最重要的就是哈希函數,計算新的數據應該存入哪個哈希桶中。

具有良好統一的哈希函數的時候,才能真正的實現花費恆定時間操作哈希表。由於哈希演算法計算的數據是無限的,而計算結果是有限的,因此最終會出現哈希衝突。常用的兩種解決哈希衝突的方式是鏈地址法和開放定址法。而Redis中使用的是鏈地址法

1.1.3.2、字典是如何進行rehash的?

隨著哈希衝突的不斷加劇,hash查找的效率也就變慢了,為了避免這種情況的出現,我們要讓哈希表的負載因子維持在一個合理地範圍之內。

當哈希衝突增加的時候,就需要執行rehash操作了。

rehash操作,也就是指增加哈希表中的哈希桶數量,讓超負載哈希桶中的entry元素重新分散到更多的桶裡面,從而減少單個桶中的元素數量,減少哈希衝突,從而提高查找效率。

rehash的流程

Redis的rehash是一個漸進的rehash的過程。為什麼要這樣做呢?

如果需要rehash的字典非常大,有幾百上千萬個鍵值對,那麼執行rehash就要很長的時間了,這個期間有客戶端需要寫入新的元素,就會被卡住了,因為Redis執行命令是單執行緒的,最終將導致Redis伺服器在這段時間不能正常提供服務,後果還是比較嚴重的。

這種這種情況,我們可以採用分而治之的思想,把rehash的過程分小步一步一步來處理,每一步遷移少量鍵值對,並在對字典的操作流程中做一些兼容處理,確保rehash流程對客戶端無感知,這就是我們所說的漸進式rehash。

大致流程如下:

image-20210404230057032

這樣,從第0個哈希桶開始,每次執行命令的時候,都rehash下一個哈希桶中的entry,並且新增的元素直接往ht[1]添加。於是ht[0]的元素逐漸減少,最終全部轉移到了ht[1]中,實現了哈希表的平滑rehash,最小程度的降低對Redis服務的影響。

1.1.4、跳躍表 skiplist

Redis中的跳躍表是一種有序數據結構,通過在每個節點維持指向其他節點的指針,從而實現快速訪問節點的目的。

Redis中的有序集合就是使用跳躍表來實現的。

為了簡化跳躍表模型,我們先來看看,假設所有跳躍表節點的層級都是1的情況,如下圖:

image-20210405130544835

這個時候,跳躍表相當於一個雙向鏈表,查找元素的複雜度為最差的O(N),即需要遍歷所有的節點。

為了提高遍歷效率,讓跳躍表真正的跳起來,現在我們嘗試在各個節點添加更多的Level,讓程式可以沿著這些Level在各個節點之間來回跳躍,如下圖,我們現在要查找score=9的節點,查找流程如下圖紅線所示:

image-20210405132105594

這種查找類似於二分查找,首先從最高層L3進行查找,發現L3的下一個節點score=10,比目標節點大,於是下降到L2繼續比較,發現L2的下一個節點為5,比目標節點小,於是繼續找下一個節點,為10,比目標節點大,於是在score為5的節點中繼續下降到L1,查找下一個節點,剛好為9,至此,我們就找到了目標節點,查找的時間複雜度為O(log N)。

如果每一層節點數是下面一層節點個數的一半,那就是最理想的類,跟二分查找一樣。但是這樣每次插入新元素,都會打亂上下兩層鏈表節點個數2:1的比例關係,如果要維持這種關係,就必須對插入節點的後面所有節點進行重新調整。為了避免這種問題,跳躍表不做這個嚴格的上下級比例約束,而是每個節點隨機出一個Level層數。

跳躍表節點如何生成level數組?

每次創建新的跳躍表節點的時候,都會根據冪次定律,隨機生成一個介於1~32之間的數組大小,這個大小就是level數組元素個數。

插入節點操作只需要修改插入節點前後的指針就可以了,降低了插入的複雜度。

1.1.5、整數集合 intset

在Redis中,當一個集合只包含整數值,並且集合元素不多的時候,會使用整數集合保存整數集,集合中的數據不會出現重複,集合元素從小到大有序排列,可以保存int16_t,int32_t,int64_t的整數值。

下面是整數集合的數據結構:

image-20210413070322698

在記憶體中,整數集合是在一塊連續的記憶體空間中的,如下圖所示:

image-20210413070403868

  • contents數組按從小到大的順序保存集合的元素;
  • encoding編碼,可選編碼:
  • INTSET_ENC_INT16(From −32,768 to 32,767)
  • INTSET_ENC_INT32(From −2,147,483,648 to 2,147,483,647)
  • INTSET_ENC_INT64(From −9,223,372,036,854,775,808 to 9,223,372,036,854,775,807)
整數集合是如何升級的?

當往整數集合添加的元素比當前所有元素類型都要長的時候,需要先對集合進行升級,確保集合可以容納這個新的元素。

升級方向:INTSET_ENC_INT16 –> INTSET_ENC_INT32 –> INTSET_ENC_INT64

注意,一旦升級之後,就不可以降回去了。

下面是一個升級的過程說明:

原本整數數組存的都是INTSET_ENC_INT16編碼的元素,接下來往裡面插入一個元素 32768,剛好超出了原來編碼的範圍,於是需要對整數集合進行升級。於是對intset進行記憶體擴容,擴容後也是通過一塊連續空間存儲的,這有可能帶來一次數據拷貝

image-20210413070448291

如果要插入的元素在中間,說明不用進行升級,這個時候會使用二分查找演算法找到插入的位置,然後擴容插入元素,重新調整元素位置。

intset特點
  • 由於intset是從小到到排列的,所以可以進行二分查找,查找性能比ziplist的遍歷查找性能高;
  • intset只能存儲整數,並且由於是記憶體緊湊的存儲模式,沒有攜帶len資訊,所以每個元素必須統一編碼;
  • intset存儲可能變成字典存儲,條件:
  • 添加了一個超過64bit的有符號數字之後;
  • 添加的集合元素個數超過了set-max-intset-entries配置的值;

1.1.6、壓縮列表 ziplist

壓縮列表是為了節省Redis記憶體,提高存儲效率而開發的數據結構,是列表鍵和哈希鍵的底層實現之一,當hash中的數據項不多,並且hash中的value長度不大的時候,就會採用壓縮列表存儲hash。

為什麼要這樣設計呢?是因為ziplist不擅長存儲大量數據:

  • 數據量大了,每次插入或者修改引發的realloc操作可能會造成記憶體拷貝,加大系統開銷;
  • ziplist數據量大了,由於是遍歷查找,查找性能會變得很低。

以下是壓縮列表的數據結構:

image-20210408232501021

尾節點地址 = 壓縮列表起始指針地址 + zltail(偏移量)

我們再來看看壓縮列表節點的結構:

image-20210410113939315

xxxx部分表示存儲的長度內容,或者是0~12整數。

壓縮列表的content可以保存一個位元組數組或者一個整數值。

如上圖,ziplist通過特殊的編碼約定,通過使用儘可能少的空間,很巧妙的存儲了編碼和長度資訊,並且通過entry中的屬性,可以在ziplist中進行雙向遍歷,效果相當於雙向鏈表,但是佔用更少的記憶體。

整數編碼 1111xxxx為什麼能存儲2^4-1個整數?

由於11110000,11111110分別跟24位有符號整數和8位有符號整數衝突了,所以只能存儲 0001~1101範圍,代表1~13數值,但是數值應該從0開始計數,所以分別代表 0~12。

什麼是ziplist連鎖更新問題?

假設我們有一個ziplist,所有節點都剛好是253位元組大小,突然往中間插入了一個254位元組以上大小的節點,會發生什麼事情呢?

image-20210410122526288

如下圖,由於新的節點插入之後,新節點的下一個節點的previous_entry_length為了記錄新節點的大小,就必須擴大4個位元組了。然後又會觸發後續節點的previous_entry_length擴大4個位元組,一直這樣傳遞下去。所以這個過程也成為連鎖更新。

最壞情況進行N次空間重新分配,每次重新分配最壞複雜度O(N)。觸發連鎖更新的最壞複雜度為O(N^2)。

但是實際情況,出現連續多個節點長度結語250~253之間的情況還是比較少的,所以實際被連續更新的節點數量不會很多,平均時間複雜度為O(N)

由於ziplist的數據是存儲在一塊連續的空間中,並不擅長做修改操作,數據發生改動則會觸發realloc並且觸發連鎖更新,可能導致記憶體拷貝,從而降低操作性能。

1.1.7、quicklist

linkedlist由於需要存儲prev和next指針,消耗記憶體空間比較多,另外每個節點的記憶體是單獨分配的,會加劇記憶體的碎片化,影響記憶體管理的效率。

於是,在Redis 3.2之後的版本,重新引入了一個新的數據結構:quicklist,用來代替ziplist和linkedlist。

為何需要quicklist?

我們得先來說說ziplist和linkedlist的優缺點:

  • linkedlist優點:插入複雜度很低;
  • linkedlist缺點:有prev和next指針,記憶體開銷比較大;
  • ziplist優點:數據存儲在一段連續的記憶體空間中,存儲效率高;
  • ziplist缺點:修改複雜度高,可能會觸發連鎖更新。

為了整合linkedlist和ziplist的優點,於是引入了quicklist。quicklist底層是基於ziplist和linkedlist來實現的。為了進一步節省空間,Redis還會對quicklist中的ziplist使用LZF演算法進行壓縮

quicklist結構

image-20210523115704156

如上圖所示,quicklist結構重點屬性

  • head:指向鏈表表頭;
  • tail:指向鏈表表尾;
  • count:所有quicklistNode中的ziplist的所有entry節點數;
  • len:鏈表的長度,即quicklistNode的個數。

quicklistNode重點屬性

  • *prev:指向鏈表前一個節點的指針;
  • *next:指向鏈表後一個節點的指針;
  • *zl:數據指針,如果當前節點數據沒有被壓縮,那麼指向一個ziplist結構;否則,指向一個quicklistLZF結構;
  • sz:不管有沒有被壓縮,sz都表示zl指向的ziplist佔用的空間大小;
  • count:ziplist裡面包含的entry個數;
  • encoding:ziplist是否被壓縮,1沒有被壓縮,2被壓縮了;
  • container:預留欄位,固定為2,表示使用ziplist作為數據容器;
  • recompress:之前是否已經壓縮過此節點?當我們使用類似index這樣的命令查看已經壓縮的節點數據的時候,需要暫時解壓數據,通過這個屬性標記後邊需要把數據重新壓縮。

quicklist配置項

list-max-ziplist-size:指定quicklist中每個ziplist最大的entry元素個數,負數表示:

  • -5:指定快速列表的每個ziplist不能超過64 KB;
  • -4:指定快速列表的每個ziplist不能超過32 KB;
  • -3:指定快速列表的每個ziplist不能超過16 KB;
  • -2:指定快速列表的每個ziplist不能超過8 KB。這是默認值;
  • -1:指定快速列表的每個ziplist不能超過4 KB。

list-compress-depth:指定列表中兩端未壓縮的條目數。有效值:0到65535。

  • 0:不壓縮列表中的節點。這是默認值;
  • 1到65535:不壓縮列表兩端的指定節點數,但是壓縮所有中間節點。

可以看出,表頭和表尾總是不會被壓縮,以便在兩端進行快速存取。


而實際上,Redis頂層是以對象(數據類型)的形式提供數據的操作API的,而對象的底層實現,則是用到了以上的數據結構。

接下來我們繼續看看Redis中的對象。

1.2、對象

對象可以理解為Redis的數據類型,數據類型底層可以使用不同的數據結構來實現。

我們先來看看對象的基本格式:

image-20210411115641093

常見的有5種數據類型,底層使用10種數據結構實現,隨著Redis版本的升級,數據類型和底層的數據結構也會增加。

而這5種數據類型,根據不同場景來選擇不同的編碼格式,如下表所示:

數據類型 編碼格式
REDIS_STRING REDIS_ENCODING_INT
  REDIS_ENCODING_EMBSTR
  REDIS_ENCODING_RAW
REDIS_LIST REDIS_ENCODING_LINKEDLIST
  REDIS_ENCODING_ZIPLIST
  REDIS_ENCODING_QUICKLIST
REDIS_SET REDIS_ENCODING_INTSET
  REDIS_ENCODING_HT
REDIS_ZSET REDIS_ENCODING_ZIPLIST
  REDIS_ENCODING_SKIPLIST
REDIS_HASH REDIS_ENCODING_ZIPLIST
  REDIS_ENCODING_HT

Redis對象的其他特性:

  • 對象共享:多個鍵都需要保存同一個字面量的字元串對象,那麼多個鍵將共享同一個字元串對象,其中對象的refcount記錄了該對象被引用的次數;
  • 記憶體回收:Redis實現了基於引用計數的記憶體回收機制,當對象的refcount變為0的時候,就表示對象可以被回收了;
  • 空轉時長:通過OBJECT IDLETIME命令,可以獲取鍵的空轉時長,該時長為當前時間 – 對象lru時間計算得到,lru記錄了對象最後一次被命令訪問的時間。

接下來我們逐個展示。

1.2.1、REDIS_STRING

REDIS_ENCODING_INT

如果一個字元串對象保存的是整數,並且可以用long類型表示,那麼ptr會從void * 變為long類型,並且設置字元串編碼為REDIS_ENCODING_INT。

1127.0.0.1:6379> set itzhai 10000
2OK
3127.0.0.1:6379> OBJECT ENCODING itzhai
4"int"

image-20210411123953576

REDIS_ENCODING_EMBSTR

如果存儲的是字元串,並且值長度小於等於44個位元組,那麼將使用embstr編碼的SDS來保存這個字元串值。

raw編碼的SDS會調用兩次記憶體分配函數來分別創建redisObject和sdshdr結構,而embstr編碼只需要調用一次記憶體分配函數就可以了,redisObject和sdshdr保存在一塊連續的空間中,如下圖:

image-20210411124010720

1127.0.0.1:6379> set name "itzhai"
2OK
3127.0.0.1:6379> OBJECT ENCODING name
4"embstr"
REDIS_ENCODING_RAW

如果存儲的是字元串值,並且值長度大於44位元組,那麼將使用SDS來保存這個字元串值,編碼為raw:

image-20210411124023165

 127.0.0.1:6379> set raw_string "abcdefghijklmnopqrstuvwxyzabcdefghijklumnopqr"
2OK
3127.0.0.1:6379> STRLEN raw_string
4(integer) 45
5127.0.0.1:6379> OBJECT ENCODING raw_string
6"raw"
7127.0.0.1:6379> set raw_string "abcdefghijklmnopqrstuvwxyzabcdefghijklumnopq"
8OK
9127.0.0.1:6379> STRLEN raw_string
10(integer) 44
11127.0.0.1:6379> OBJECT ENCODING raw_string
12"embstr"

注意:不同版本的Redis,Raw和embstr的分界位元組數會有所調整,本節指令運行於 Redis 6.2.1

STRING是如何進行編碼轉換的?

浮點數會以REDIS_ENCODING_EMBSTR編碼的格式存儲到Redis中:

1127.0.0.1:6379> OBJECT ENCODING test
2"embstr"
3127.0.0.1:6379> INCRBYFLOAT test 2.0
4"3.28000000000000025"
5127.0.0.1:6379> OBJECT ENCODING test
6"embstr"

long類型的數字,存儲之後,為REDIS_ENCODING_INT編碼,追加字元串之後,為REDIS_ENCODING_RAW編碼:

1127.0.0.1:6379> set test 12345
2OK
3127.0.0.1:6379> OBJECT ENCODING test
4"int"
5127.0.0.1:6379> APPEND test " ..."
6(integer) 9
7127.0.0.1:6379> OBJECT ENCODING test
8"raw"

REDIS_ENCODING_EMBSTR 類型的數據,操作之後,變為REDIS_ENCODING_RAW編碼:

1127.0.0.1:6379> OBJECT ENCODING test
2"embstr"
3127.0.0.1:6379> APPEND test "c"
4(integer) 3
5127.0.0.1:6379> OBJECT ENCODING test
6"raw"

總結一下:

image-20210411231336938

EMBSTR編碼的字元串不管追加多少字元,不管有沒有到達45位元組大小,都會轉為RAW編碼,因為EMBSTR編碼字元串沒有提供修改的API,相當於是只讀的,所以修改的時候,總是會先轉為RAW類型再進行處理。

1.2.2、REDIS_LIST

Redis 3.2版本開始引入了quicklist,LIST底層採用的數據結構發生了變化。

Redis 3.2之前的版本

列表對象底層可以是ziplist或者linkedlist數據結構。

使用哪一種數據結構:

image-20210524223948091

REDIS_ENCODING_ZIPLIST

ziplist結構的列表對象如下圖所示:

image-20210412223117523

REDIS_ENCODING_LINKEDLIST

linkedlist結構的列表對象如下圖所示

image-20210412224624529

linkedlist為雙向列表,每個列表的value是一個字元串對象,在Redis中,字元串對象是唯一一種會被其他類型的對接嵌套的對象。

Redis 3.2之後的版本

而Redis 3.2之後的版本,底層採用了quicklist數據結構進行存儲。

1.2.3、REDIS_HASH

哈希對象的編碼可以使ziplist或者hashtable數據結構。

使用哪一種數據結構:

image-20210524223754223

REDIS_ENCODING_ZIPLIST

我們執行以下命令:

1127.0.0.1:6379> HSET info site itzhai.com
2(integer) 1
3127.0.0.1:6379> HSET info author arthinking
4(integer) 1

得到如下ziplist結構的哈希對象:

image-20210413075537996

REDIS_ENCODING_HT

hashtable結構的哈希對象如下圖所示:

image-20210413071439026

其中,字典的每個鍵和值都是一個字元串對象。

1.2.4、REDIS_SET

集合對象的編碼可以是intset或者hashtable數據結構。

使用哪一種數據結構:

image-20210619221947770

REDIS_ENCODING_INTSET

執行以下命令:

1127.0.0.1:6379> SADD ids 1 3 2
2(integer) 3
3127.0.0.1:6379> OBJECT ENCODING ids
4"intset"

則會得到一個intset結構的集合對象,如下圖:

image-20210413070629814

REDIS_ENCODING_HT

執行以下命令:

1127.0.0.1:6379> SADD site_info "itzhai.com" "arthinking" "Java架構雜談"
2(integer) 3
3127.0.0.1:6379> OBJECT ENCODING site_info
4"hashtable"

則會得到一個hashtable類型的集合對象,hashtable的每個鍵都是一個字元串對象,每個字元串對象包含一個集合元素,hashtable的值全部被置為NULL,如下圖:

image-20210413072232507

1.2.5、REDIS_ZSET

有序集合可以使用ziplist或者skiplist編碼。

使用哪一種編碼:

image-20210524223853137

REDIS_ENCODING_ZIPLIST

執行以下命令:

1127.0.0.1:6379> ZADD weight 1.0 "Java架構雜談" 2.0 "arthinking" 3.0 "itzhai.com"
2(integer) 3
3127.0.0.1:6379> OBJECT ENCODING weight
4"ziplist"
5127.0.0.1:6379> ZRANGE weight 1 2
61) "arthinking"
72) "itzhai.com"

則會得到一個ziplist編碼的zset,如下圖:

image-20210413075431085

REDIS_ENCODING_SKIPLIST

執行以下命令:

1127.0.0.1:6379> ZADD weight 1 "itzhai.com" 2 "aaaaaaaaaabbbbbbbbbccccccccccddddddddddeeeeeeeeeeffffffffffgggggg"
2(integer) 1
3127.0.0.1:6379> OBJECT ENCODING weight
4"skiplist"

則會得到一個skiplist編碼的zset,skiplist編碼的zset底層同時包含了一個字典和跳躍表:

1typedef struct zset {
2  zskiplist *zsl;
3  dict * dict;
4} zset;

如下圖所示:

image-20210413081358345

其中:

  • 跳躍表按照分值從小到大保存了所有的集合元素,一個跳躍表節點對應一個集合元素,object屬性保存元素成員,score屬性保存元素的分值,ZRANK,ZRANGE,ZCARD,ZCOUNT,ZREVRANGE等命令基於跳躍表來查找的;
  • 字典維護了一個從成員到分值的映射,通過該結構查找給定成員的分值(ZSCORE),複雜度為O(1);
  • 實際上,字典和跳躍表會共享元素成員和分值,所以不會造成額外的記憶體浪費。

1.2.6、REDIS_MODULE

從Redis 4.0開始,支援可擴展的Module,用戶可以根據需求自己擴展Redis的相關功能,並且可以將自定義模組作為插件附加到Redis中。這極大的豐富了Redis的功能。

關於Modules的相關教程:Redis Modules: an introduction to the API[15]

Redis第三方自定義模組列表(按照GitHub stars數排序):Redis Modules[16]

1.2.7、REDIS_STREAM

這是Redis 5.0引入的一個新的數據類型。為什麼需要引入這個數據類型呢,我們可以查閱一下:RPC11.md | Redis Change Proposals^14

1This proposal originates from an user hint:
2
3During the morning (CEST) of May 20th 2016 I was spending some time in the #redis channel on IRC. At some point Timothy Downs, nickname forkfork wrote the following messages:
4
5<forkfork> the module I'm planning on doing is to add a transaction log style data type - meaning that a very large number of subscribers can do something like pub sub without a lot of redis memory growth
6<forkfork> subscribers keeping their position in a message queue rather than having redis maintain where each consumer is up to and duplicating messages per subscriber

這使得Redis作者去思考Apache Kafka提供的類似功能。同時,它有點類似於Redis LIST和Pub / Sub,不過有所差異。

STREAM工作原理

image-20210523184021276

如上圖:生產者通過XADD API生產消息,存入Stream中;通過XGROUP相關API管理分組,消費者通過XREADGROUP命令從消費分組消費消息,同一個消費分組的消息只會分配各其中的一個消費者進行消費,不同消費分組的消息互不影響(可以重複消費相同的消息)。

Stream中存儲的數據本質是一個抽象日誌,包含:

  • 每條日誌消息都是一個結構化、可擴展的鍵值對;
  • 每條消息都有一個唯一標識ID,標識中記錄了消息的時間戳,單調遞增;
  • 日誌存儲在記憶體中,支援持久化;
  • 日誌可以根據需要自動清理歷史記錄。
Stream相關的操作API
  • 添加日誌消息:

  • XADD:這是將數據添加到Stream的唯一命令,每個條目都會有一個唯一的ID:

    • bash<br /># * 表示讓Redis自動生成消息的ID<br />127.0.0.1:6379&gt; XADD articles * title redis author arthinking<br /># 自動生成的ID<br />1621773988308-0<br />127.0.0.1:6379&gt; XADD articles * title mysql author Java架構雜談<br />1621774078728-0<br />
  • 讀取志消息:

  • XREAD:按照ID順序讀取日誌消息,可以從多個流中讀取,並且可以以阻塞的方式調用:

    • bash<br /># 第一個客戶端執行以下命令,$ 表示獲取下一條消息,進入阻塞等待<br />127.0.0.1:6379&gt; XREAD BLOCK 10000 STREAMS articles $<br /># 第二個客戶端執行以下命令:<br />127.0.0.1:6379&gt; XADD articles * title Java author arthinking<br />1621774841555-0<br /># 第一個客戶端退出阻塞狀態,並輸出以下內容<br />articles<br />1621774841555-0<br />title<br />Java<br />author<br />arthinking<br />
  • XRANGE key start end [COUNT count]:按照ID範圍讀取日誌消息;

  • XREVRANGE key end start [COUNT count]:以反向的順序返回日誌消息;

  • 刪除志消息:

  • XDEL key ID [ID …]:從Stream中刪除日誌消息;

  • XTRIM key MAXLEN|MINID [=|~] threshold [LIMIT count]XTRIM將Stream裁剪為指定數量的項目,如有需要,將驅逐舊的項目(ID較小的項目);

  • 消息消費:

  • XGROUP:用於管理消費分組:

    • bash<br /># 給articles流創建一個group,$表示使用articles流最新的消息ID作為初始ID,後續group內的Consumer從初始ID開始消費<br />127.0.0.1:6379&gt; XGROUP CREATE articles group1 $<br /># 指定消費組的消費初始ID<br />127.0.0.1:6379&gt; XGROUP SETID articles group1 1621773988308-0<br />OK<br /># 刪除指定的消費分組<br />127.0.0.1:6379&gt; XGROUP DESTROY articles group1<br />1<br />
  • XREADGROUP:XREADROUP是對XREAD的封裝,支援消費組:

    • bash<br /># articles流的group1分組中的消費者consumer-01讀取消息,&gt; 表示讀取沒有返回過給別的consumer的最新消息<br />127.0.0.1:6379&gt; XREADGROUP GROUP group1 consumer-01 COUNT 1 STREAMS articles &gt;<br />articles<br />1621774078728-0<br />title<br />mysql<br />author<br />Java架構雜談<br />
  • XPENDING key group [start end count] [consumer]:檢查待處理消息列表,即每個消費組內消費者已讀取,但是尚未得到確認的消息;

  • XACK:Acknowledging messages,用於確保客戶端正確消費了消息之後,才提供下一個消息給到客戶端,避免消息沒處理掉。執行了該命令後,消息將從消費組的待處理消息列表中移除。如果不需要ACK機制,可以在XREADGROUP中指定NOACK:

    • bash<br />127.0.0.1:6379&gt; XACK articles group1 1621776474608-0<br />1<br />
  • XCLAIM:如果某一個客戶端掛了,可以使用此命令,讓其他Consumer主動接管它的pending msg:

    • bash<br /># 1621776677265-0 消息閑置至少10秒並且沒有原始消費者或其他消費者進行推進(確認或者認領它)時,將所有權分配給消費者consumer-02<br />XCLAIM articles group1 consumer-02 10000 1621776677265-0<br />
  • 運行資訊:

  • XINFO:用於檢索關於流和關聯的消費者組的不同的資訊;

  • XLEN:給出流中的條目數。

Stream與其他數據類型的區別
特性 Stream List, Pub/Sub, Zset
查找元素複雜度 O(long(N)) List: O(N)
偏移量 支援,每個消息有一個ID List:不支援,如果某個項目被逐出,則無法找到最新的項目
數據持久化 支援,Streams持久化道AOF和RDB文件中 Pub/Sub:不支援
消費分組 支援 Pub/Sub:不支援
ACK 支援 Pub/Sub:不支援
性能 不受客戶端數量影響 Pub/Sub:受客戶端數量影響
數據逐出 流通過阻塞以驅逐太舊的數據,並使用Radix Tree和listpack來提高記憶體效率 Zset消耗更多記憶體,因為它不支援插入相同項目,阻止或逐出數據
隨機刪除元素 不支援 Zset:支援
Redis Stream vs Kafka

Apache Kafka是Redis Streams的知名替代品,Streams的某些功能更收到Kafka的啟發。Kafka運行所需的配套比較昂貴,對於小型、廉價的應用程式,Streams是更好的選擇。

1.3、Redis高級功能

基於基礎的數據類型,Redis擴展了一些高級功能,如下圖所示:

數據類型 數據類型
Bitmap REDIS_STRING
HyperLogLog REDIS_STRING
Bloom Filter REDIS_STRING,Bitmap實現
Geospatial REDIS_ZSETZ

作為一個注重系統化學習的部落格 IT宅(itzhai.com),作為一個追求技術發展的公眾號,Java架構雜談怎麼能少了這些高級特性的內容了,下面,arthinking我就詳細展開來看看這些特性。

1.3.1、BitMap

Bitmap,點陣圖演算法,核心思想是用比特數組,將具體的數據映射到比特數組的某個位置,通過0和1記錄其狀態,0表示不存在,1表示存在。通過使用BitMap,可以將極大域的布爾資訊存儲到(相對)小的空間中,同時保持良好的性能。

由於一條數據只佔用1個bit,所以在大數據的查詢,去重等場景中,有比較高的空間利用率。

image-20210511081057504

注意:BitMap數組的高低位順序和字元位元組的位順序是相反的。

由於點陣圖沒有自己的數據結構,BitMap底層使用的是REDIS_STRING進行存儲的。而STRING的存儲上限是512M(2^32 -1),如下,最大上限為4294967295:

1127.0.0.1:30001> setbit user_status 4294967295 1
20
3127.0.0.1:30001> memory usage user_status
4536887352
5127.0.0.1:30001> setbit user_status 4294967296 1
6ERR bit offset is not an integer or out of range

如果要存儲的值大於2^32 -1,那麼就必須通過一個的數據切分演算法,把數據存儲到多個bitmap中了。

Redis中BitMap相關API
  • SETBIT key offset value:設置偏移量offset的值,value只能為0或者1,O(1);
  • GETBIT key offset:獲取偏移量的值,O(1);
  • BITCOUNT key start end:獲取指定範圍內值為1的個數,注意:start和end以位元組為單位,而非bit,O(N);
  • BITOP [operations] [result] [key1] [key2] [key…]:BitMap間的運算,O(N)
  • operations:位移操作符
    • AND 與運算 &
    • OR 或運算 |
    • XOR 異或 ^
    • NOT 取反 ~
  • result:計算的結果,存儲在該key中
  • key:參與運算的key
  • 注意:如果操作的bitmap在不同的集群節點中,則會提示如下錯誤:CROSSSLOT Keys in request don't hash to the same slot,可以使用HashTag把要對比的bitmap存儲到同一個節點中;
  • BITPOS [key] [value]:返回key中第一次出現指定value的位置

如下例子,兩個bitmap AND操作結果:

1127.0.0.1:30001> SETBIT {user_info}1 1001 1
20
3127.0.0.1:30001> SETBIT {user_info}2 1001 0
41
5127.0.0.1:30001> BITOP AND {user_info}3 {user_info}1 {user_info}2
6126
7127.0.0.1:30001> GETBIT {user_info}3 1001
80
性能與存儲評估
關於BitMap的空間大小

BitMap空間大小是一個影響性能的主要因素,因為對其主要的各種操作複雜度是O(N),也就意味著,越大的BitMap,執行運算操作時間越久。

Redis的BitMap對空間的利用率是很低的,我們可以做個實驗:

1127.0.0.1:30002> SETBIT sign_status 100000001 1
21
3127.0.0.1:30002> memory usage sign_status
412501048

可以看到,我們只是往BitMap裡面設置了一位,就給BitMap分配了12501048的空間大小。

這是由於Redis的BitMap的空間分配策略導致的。由於底層是用的Redis字元串存儲的,所以擴容機制跟字元串一致。執行SETBIT命令,當空間不足的時候,就會進行擴容,以確保可以在offset處保留一個bit。

所以我們一開始給100000001偏移量進行設置,就會立刻申請一個足夠大的空間,這個申請過程中,會短時間阻塞命令的執行。

為了避免使用較大的BitMap,Redis文檔建議將較大的BitMap拆分為多個較小的BitMap,處理慢速的BITOP建議在從節點上執行。提前拆分,這樣可以了更好的應對未來潛在的數據增長。

關於BitMap的存儲空間優化

從上面的分析可知,直接就設置很大的offset,會導致數據分散式很稀疏,產生很多連續的0。針對這種情況,我們可以採用RLE(行程編碼,Run Length Encoding, 又稱遊程編碼、行程長度編碼、變動長度編碼)編碼對存儲空間進行優化,不過Redis中是沒有做相關存儲優化的。

大致的思想是:表達同樣的一串數字 000000,沒有編碼的時候是這樣說的 :零零零零零零,編碼之後可以這樣說:6個零,是不是簡單很多呢?

給如下的BitMap:

1000000110000000000100001000000

可以優化存儲為:

10,6,2,10,1,4,1,6

表示第一位是0,連續有6個0,接下來是2個1,10個0,1個1,4個0,1個1,6個0。這個例子只是大致的實現思路。

guava中的EWAHCompressedBitmap是一種壓縮的BitMap的實現。EWAH是完全基於RLE進行壓縮的,很好的解決了存儲空間的浪費問題。

1.3.2、Bloom Filter

考慮一個這樣的場景,我們在網站註冊帳號,輸入用戶名,需要立刻檢測用戶名是否已經註冊過,如果已註冊的用戶數量巨大,有什麼高效的方法驗證用戶名是否已經存在呢?

我們可能會有以下的解法:

  • 線性查找,複雜度O(n)這是最低效的方式;
  • 二分查找,複雜度O(log2n),比線性查找好很多,但是仍繞需要多個查找步驟;
  • HashTable查找,快速,佔用記憶體多。

而如果使用Boolean Filter,則可以做到既節省資源,執行效率又高。

布隆過濾器是一種節省空間的概率數據結構,用於測試元素是否為集合的成員,底層是使用BitMap進行存儲的。

例如,檢查用戶名是否存在,其中集合是所有已註冊用戶名的列表。

它本質上是概率性的,這意味著可能會有一些誤報:它可能表明給定的用戶名已被使用,但實際上未被使用。

布隆過濾器的有趣特性
  • 與HashTable不同,固定大小的布隆過濾器可以表示具有任意數量元素的集合;
  • 添加元素永遠不會失敗。但是,隨著元素的添加,誤判率會越來越高。如果要降低誤報結果的可能性,則必須使用更多數量的哈希函數和更大的位數組,這會增加延遲
  • 無法從過濾器中刪除元素,因為如果我們通過清除k個散列函數生成的索引處的位來刪除單個元素,則可能會導致其他幾個元素的刪除;
  • 重點:判定不在的數據一定不存在,存在的數據不一定存在。
Bloom Filter如何工作

我們需要k個哈希函數來計算給定輸入的哈希值,當我們要在過濾器中添加項目時,設置k個索引h1(x), h2(x), …hk(x)的位,其中使用哈希函數計算索引。

如下圖,假設k為3,我們在Bloom Filter中標識”itzhai.com”存在,則需要分別通過三個哈希函數計算得到三個偏移量,分別給這三個偏移量中的值設置為1:

image-20210517235341926

當我們需要檢索判斷”itzhai.com”是否存在的時候,則同樣的使用者三個哈希函數計算得到三個偏移量,判斷三個偏移量所處的位置的值是否都為1,如果為1,則表示”itzhai.com”是存在的。

Bloom Filter中的誤判

假設我們繼續往上面的Bloom Filter中記錄一個新的元素「Java架構雜談」,這個時候,我們發現h3函數計算出來的偏移量跟上一個元素相同,這個時候就產生了哈希衝突:

image-20210517235351662

這就會導致,即使偏移量為12的這個值為1,我們也不知道究竟是「Java架構雜談」這個元素設置進去的,還是”itzhai.com”這個元素設置進去的,這就是誤判產生的原因。

在Bloom Filter中,我們為了空間效率而犧牲了精度。

如何減少誤判

Bloom Filter的精度與BitMap數組的大小以及Hash函數的個數相關,他們之間的計算公式如下:

1p = pow(1exp(−k/(m/n)),k)

其中:

  • m:BitMap的位數
  • k:哈希函數的個數
  • n:Bloom Filter中存儲的元素個數

為了更方便的合理估算您的數組大小和哈希函數的個數,您可以使用Thomas Hurst提供的這個工具來進行測試:Bloom Filter Calculator [8]

Redis中的Bloom Filter

Redis在4.0版本開始支援自定義模組,可以將自定義模組作為插件附加到Redis中,官網推薦了一個RedisBloom[9]作為Redis布隆過濾器的Module。可以通過以下幾行程式碼,從Github獲取RedisBloom,並將其編譯到rebloom.so文件中:

1$ git clone --recursive [email protected]:RedisBloom/RedisBloom.git
2cd RedisBloom
3$ make
4$ redis-server --loadmodule /path/to/rebloom.so

這樣,Redis中就支援Bloom Filter過濾器數據類型了:

1BF.ADD visitors arthinking
2BF.EXISTS visitors arthinking

除了引入這個模組,還有以下方式可以引入Bloom Filter:

  • pyreBloom(Python + Redis + Bloom Filter = pyreBloom);
  • lua腳本來實現;
  • 直接通過Redis的BitMap API來封裝實現。
Bloom Filter在業界的應用
  • Medium使用Bloom Filter通過過濾用戶已看到的帖子來向用戶推薦帖子;
  • Quora在Feed後端中實現了一個共享的Bloom Filter,以過濾掉人們以前看過的故事;
  • Google Chrome瀏覽器曾經使用Bloom Filter來識別惡意網址;
  • Google BigTable,Apache HBase和Apache Cassandra以及Postgresql使用Bloom Filter來減少對不存在的行或列的磁碟查找。

1.3.3、HyperLogLog

HyperLogLog是從LogLog演算法演變而來的概率演算法,用於在不用存儲大集合所有元素的情況下,統計大集合裡面的基數。

基數:本節該術語用於描述具有重複元素的數據流中不同元素的個數。而在多集合理論中,該術語指的是多集合的重複元素之和。

HyperLogLog演算法能夠使用1.5KB的記憶體來估計超過10^9個元素的基數,並且控制標準誤差在2%。這比點陣圖或者Set集合節省太多的資源了 。

HyperLogLog演算法原理

接下來我們使用一種通俗的方式來講講HyperLogLog演算法的原理,不做深入推理,好讓大家都能大致了解。

我們先從一個事情說起:你正在舉辦一個畫展,現在給你一份工作,在入口處記下每一個訪客,並統計出有多少個不重複的訪客,允許小的誤差,你會如何完成任務呢?

你可以把所有的用戶電話號碼都記下來,然後每次都做一次全量的對比,統計,得到基數,但這需要耗費大量的工作,也沒法做到實時的統計,有沒有更好的方法呢?

image-20210520231941699

我們可以跟蹤每個訪客的手機號的後6位,看看記錄到的後六位的所有號碼的最長前導0序列。

例如:

  • 123456,則最長前導0序列為0
  • 001234,則最長前導0序列為2

隨著你記錄的人數越多,得到越長的前導0序列的概率就越大。

在這個案例中,平均每10個元素就會出現一次0序列,我們可以這樣推斷:

假設L是您在所有數字中找到的最長前導0序列,那麼估計的唯一訪客接近10ᴸ。

當然了,為了獲得更加均勻分布的二進位輸出,我們可以對號碼進行哈希處理。最後在引入一個修正係數φ用於修正引入的可預測偏差,最終我能可以得到公式:

2ᴸ/ φ

這就是Flajolet-Martin演算法(Philippe Flajolet和G. Nigel Martin發明的演算法)。

但是假如我們第一個元素就碰到了很長的前導0序列,那麼就會影響我們的預測的準確度了。

為此,我們可以將哈希函數得到的結果拆到不同的存儲區中,使用哈希值的前幾位作為存儲區的索引,使用剩餘內容計算最長的前導0序列。根據這種思路我們就得到了LogLog演算法

為了得到更準確的預測結果,我們可以使用調和平均值取代幾何平均值來平均從LogLog得到的結果,這就是HyperLogLog的基本思想。

更加詳細專業的解讀,如果覺得維基百科的太學術了,不好理解,可以進一步閱讀我從網上找的幾篇比較好理解的講解:

Redis中的HyperLogLog

Redis在2.8.9版本中添加了HyperLogLog結構。在Redis中,每個HyperLogLog只需要花費12KB的記憶體,就可以計算接近2^64個不同元素的基數。

Redis中提供了以下命令來操作HyperLogLog:

  • PFADD key element [element …]:向HyperLogLog添加元素
  • PFCOUNT key [key …]:獲取HyperLogLog的基數
  • PFMERGE destkey sourcekey [sourcekey …]:將多個HyperLogLog合併為一個,合併後的HyperLogLog基數接近於所有被合併的HyperLogLog的並集基數。

以下是使用例子:

 1# 存儲第一個HyperLogLog
2127.0.0.1:6379> PFADD visitors:01 arthinking Jim itzhai
31
4# 獲取第一個HyperLogLog的基數
5127.0.0.1:6379> PFCOUNT visitors:01
63
7# 存儲第二個HyperLogLog
8127.0.0.1:6379> PFADD visitors:02 arthinking itzhai Jay
91
10# 獲取第二個HyperLogLog的基數
11127.0.0.1:6379> PFCOUNT visitors:02
123
13# 合併兩個HyperLogLog
14127.0.0.1:6379> PFMERGE result visitors:01 visitors:02
15OK
16# 獲取合併後的基數
17127.0.0.1:6379> PFCOUNT result
184
19# 獲取HyperLogLog中存儲的內容
20127.0.0.1:6379> GET result
21"HYLL\x01\x00\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00Lo\x80JH\x80GD\x84_;\x80B\xc1"

1.3.4、Geospatial

現在的App,很多都會利用用戶的地理位置,做一些實用的功能,比如,查找附近的人,查找附近的車,查找附近的餐廳等。

Redis 3.2開始提供的一種高級功能:Geospatial(地理空間),基於GeoHash和有序集合實現的地理位置相關的功能。

如果叫你實現一個這樣的功能,你會如何實現呢?

用戶的地理位置,即地理坐標系統中的一個坐標,我們的問題可以轉化為:在坐標系統的某個坐標上,查找附近的坐標。

而Redis中的Geo是基於已有的數據結構實現的,已有的數據結構中,能夠實現數值對比的就只有ZSET了,但是坐標是有兩個值組成的,怎麼做比較呢?

核心思想:將二維的坐標轉換為一維的數據,就可以通過ZSET,B樹等數據結構進行對比查找附近的坐標了。

我們可以基於GeoHash編碼,把坐標轉換為一個具體的可比較的值,再基於ZSET去存儲獲取。

GeoHash編碼

GeoHash編碼的大致原理:將地球理解為一個二維平面,將平面遞歸分解為子塊,每個子塊在一定的經緯度範圍有相同的編碼。

總結來說就是利用二分法分區間,再給區間編碼,隨著區塊切分的越來越細,編碼長度也不斷追加,變得更精確。

為了能夠直觀的對GeoHash編碼有個認識,我們來拿我們的貝吉塔行星來分析,如下圖,我們把行星按照地理位置系統展開,得到如下圖所示的坐標系統:

image-20210518221215288

我們可以給坐標系統上面的區塊進行分塊標識,規則如下:

  • 把緯度二等分:左邊用0表示,右邊用1表示;
  • 把經度二等分:左邊用0表示,右邊用1表示。

偶數位放經度,奇數位放維度,劃分後可以得到下圖:

image-20210519083524526

其中,箭頭為數值遞增方向。上圖可以映射為一維空間上的連續編碼: 00 01 10 11,相鄰的編碼一般距離比較接近。

我們進一步遞歸劃分,劃分的左邊或者下邊用0表示,右邊或者上邊用1表示,可以得到下圖:

image-20210620073202127 image-20210519090719418

GeoHash Base32編碼

最後,使用以下32個字元對以上區塊的經緯度編碼進行base 32編碼,最終得到區塊的GeoHash Base32編碼。

Decimal 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
Base 32 0 1 2 3 4 5 6 7 8 9 b c d e f g
Decimal 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
Base 32 h j k m n p q r s t u v w x y z

同一個區塊內,編碼是相同的。可以發現:

  • 編碼長度越長,那麼編碼代表的區塊就越精確;
  • 字元串相似編碼所代表的區塊距離相近,但有特殊情況;

根據Geohash[10]可知,當GeoHash Base32編碼長度為8的時候,距離精度在19米左右

關於區塊距離的誤差

基於GeoHash產生的空間填充曲線最大缺點是突變性:有些編碼相鄰但是距離卻相差很遠,如下圖:

image-20210519233722296

因此,為了避免曲突變造成的影響,在查找附近的坐標的時候,首先篩選出GeoHash編碼相近的點,然後進行實際的距離計算。

Redis中的Geo

Redis中的Geo正是基於GeoHasn編碼,把地理坐標的編碼值存入ZSET中,進行查找的。

下面我們演示一下相關API的用法:

 1# 添加中國的6個旅遊地點
2127.0.0.1:6379> GEOADD destinations 100.26764 25.60648 大理
31
4127.0.0.1:6379> GEOADD destinations 99.74317 27.84254 香格里拉
51
6127.0.0.1:6379> GEOADD destinations 100.29829 29.03704 稻城
71
8127.0.0.1:6379> GEOADD destinations 119.73572 49.21336 呼倫貝爾
91
10127.0.0.1:6379> GEOADD destinations 117.37836 49.59655 滿洲里
111
12127.0.0.1:6379> GEOADD destinations 116.23128 40.22077 北京
131
14# 查找坐標 116.23128 40.22077 1500公里範圍內的旅遊地點
15127.0.0.1:6379> GEORADIUS destinations 116.23128 40.22077 1500 km ASC COUNT 10
16北京
17呼倫貝爾
18滿洲里
19# 查找坐標 116.23128 40.22077 2000公里範圍內的旅遊地點
20127.0.0.1:6379> GEORADIUS destinations 116.23128 40.22077 2000 km ASC COUNT 10
21北京
22呼倫貝爾
23滿洲里
24稻城
25# 查找坐標 116.23128 40.22077 3000公里範圍內的旅遊地點
26127.0.0.1:6379> GEORADIUS destinations 116.23128 40.22077 3000 km ASC COUNT 10
27北京
28呼倫貝爾
29滿洲里
30稻城
31香格里拉
32大理

正是因為命令執行完全是在記憶體中處理的,所以redis執行速度非常快,但是為了數據的持久化,我們就不能離開磁碟了,因為一旦斷點,記憶體的數據就都丟失了。

下面再來講講Redis是怎麼通過磁碟來提供數據持久化,和宕機後的數據恢復的。

2、磁碟

Redis是一個記憶體的鍵值對資料庫,但是要是服務進程掛了,如何恢複數據呢?這個時候我們就要來講講Redis的持久化了。

Redis的持久化有兩種方式:RDB和AOF。

2.1、RDB

RDB是Redis持久化存儲記憶體中的數據的文件格式。RDB,即Redis Database的簡寫。也稱為記憶體快照

2.1.1、如何創建RDB文件?

觸發生成RDB文件命令是SAVEBGSAVE。從命名的命名就可以知道,BGSAVE是在後台運行的:

  • SAVE:執行SAVE命令創建RDB文件的過程中,會阻塞Redis服務進程,此時伺服器不能處理任何命令;
  • BGSAVE:BGSAVE會派生出一個子進程來創建RDB文件,Redis父進程可以繼續處理命令請求。
BGSAVE執行流程

BGSAVE執行流程如下:

image-20210620075737419

在發起BGSAVE命令之後,Redis會fork出一個子進程用於執行生成RDB文件。fork的時候採用的是寫時複製(Copy-on-write)技術。不會立刻複製所有的記憶體,只是複製了頁表,保證了fork執行足夠快。如上圖,Redis父進程和執行BGSAVE的子進程的頁表都指向了相同的記憶體,也就是說,記憶體並沒有立刻複製。

然後子進程開始執行生成RDB。

在生成RDB過程中,如果父進程有執行新的操作命令,那麼會複製需要操作的鍵值對,父子進程之間的記憶體開始分離:

image-20210620080006826

如上圖,父進程執行命令修改了一些鍵值對的時候,該部分鍵值對實際上會複製一份進行修改,修改完成之後,父進程中的該記憶體數據的指針會指向被複制的的記憶體。而子進程繼續指向原來的數據,原來的數據內容是不會被修改的。

在生成RDB文件過程中,父進程中對數據的修改操作不會被持久化。

執行BGSAVE會消耗很多記憶體嗎?

由上面描述可知,BGSAVE並不會立刻複製記憶體數據,而是採用了寫時複製技術,所以並不會立刻消耗很多記憶體。

但是如果Redis實例寫比讀的執行頻率高很多,那麼勢必會導致執行BGSAVE過程中大量複製記憶體數據,並且消耗大量CPU資源,如果記憶體不足,並且機器開啟了Swap機制,把部分數據從記憶體swap到磁碟上,那麼性能就會進一步下降了。

服務端什麼時候會觸發BGSAVE?

Redis怎麼知道什麼時候應該創建RDB文件呢?我們得來看看redisServer中的幾個關鍵屬性了,如下圖:

image-20210414233045485

  • dirty計數器:記錄距離上一次成功執行SAVE或者BGSAVE命令之後,伺服器資料庫進行了多少次修改操作(添加、修改、刪除了多少個元素);
  • lastsave時間:一個Unix時間戳,記錄伺服器上一次成功執行SAVE或者BGSAVE命令的時間;
  • saveparams配置:觸發BGSAVE命令的條件配置資訊,如果沒有手動設置,那麼伺服器默認設置如上圖所示:
  • 伺服器在900秒內對資料庫進行了至少1次修改,那麼執行BGSAVE命令;
  • 伺服器在300秒內對資料庫進行了至少10次修改,那麼執行BGSAVE命令;
  • 伺服器在60秒內對資料庫進行了至少10000次修改,那麼只需BGSAVE命令。

Redis默認的每隔100毫秒會執行一次serverCron函數,檢查並執行BGSAVE命令,大致的處理流程如下圖所示:

image-20210415000715445

2.1.2、如何從RDB文件恢復?

Redis只會在啟動的時候嘗試載入RDB文件,但不是一定會載入RDB文件的,關鍵處理流程如下圖:

image-20210415001110975

伺服器在載入RDB文件期間,一直處於阻塞狀態。

2.1.3、RDB文件結構是怎樣的?

我們用一張圖來大致了解下RDB文件的結構,如下圖所示:

image-20210415083349145

具體格式以及格式說明參考上圖以及圖中的描述。

而具體的value,根據不同的編碼有不同的格式,都是按照約定的格式,緊湊的存儲起來。

2.2、AOF

從上一節的內容可知,RDB是把整個記憶體的數據按照約定的格式,輸出成一個文件存儲到磁碟中的。

而AOF(Append Only File)則有所不同,是保存了Redis執行過的命令。

AOF,即Append Only File的簡寫。

我們先來看看,執行命令過程中是如何生成AOF日誌的:

image-20210417085503553

如上圖,是Redis執行命令過程中,產生AOF日誌的一個過程:

  • 執行完命令之後,相關數據立刻寫入記憶體;
  • 追加命令到AOF緩衝區(對應redisServer中的aof_buf屬性),該緩衝區用於把AOF日誌追寫回到磁碟的AOF文件中,有三種不同的寫回策略,由appendfsync參數控制:
  • Always:同步寫回,每個寫命令執行完畢之後,立刻將AOF緩衝區的內容寫入到AOF文件緩衝區,並寫回磁碟;
  • Everysec:每秒寫回,每個寫命令執行完後,將AOF緩衝區所有內容寫入到AOF文件緩衝區,如果AOF文件上次同步時間距離現在超過了一秒,那麼將再次執行AOF文件同步,將AOF文件寫回磁碟;
  • No:作業系統控制寫回,每個寫命令執行完畢之後,將AOF緩衝區的內容寫入到AOF文件緩衝區,具體寫回磁碟的時間,由作業系統的機制來決定。

2.2.1、AOF文件格式

AOF文件格式如上圖最右邊所示:

  • *3:表示當前命令有三部分;
  • $3:每個部分以$ + 數字打頭,數字表示這部分有多少位元組;
  • 字元串:該部分具體的命令內容。

2.2.2、應該用哪種AOF寫回策略?

可以看到appendfsync是實現數據持久化的關鍵技術了,哪種寫回策略最好呢?

這裡,我們通過一個表格來對比下:

寫回策略 寫回時機 優點 缺點
Always 同步寫回 數據基本不丟失 每次寫數據都要同步,性能較差
Everysec 每秒寫回 性能與可靠性的平衡 宕機將丟失一秒的數據
No 作業系統控制寫回 性能好 宕機將丟失上一次同步以來的數據

2.2.3、如何通過AOF實現數據還原?

為了實現數據還原,需要把AOF日誌中的所有命令執行一遍,而Redis命令只能在客戶端上下文中執行,所以會先創建一個不帶網路套接字的偽客戶端進行執行命令,大致流程如下:

image-20210416081232144

2.2.4、AOF文件太大了,影響載入速度怎麼辦?

如果AOF文件太大,需要執行的命令就很多,載入速度回變慢,為了避免這種問題,Redis中提供了AOF重寫機制,把原來的多個命令壓縮成一個,從而減小AOF文件的大小和AOF文件中的命令數量,加快AOF文件的載入速度。

Redis中觸發AOF重寫的是bgrewriteaof命令。

要注意的是,AOF重寫並不是讀取原來的AOF文件的內容進行重寫,而是根據系統鍵值對的最新狀態,生成對應的寫入命令。

重寫效果

比如執行了以下命令:

1RPUSH list "a
2RPUSH list "
b" "c"
3RPOP list
4HMSET map "
site" "itzhai.com"
5HMSET map "
author" "arthinking"
6HMSET map "
wechat" "Java架構雜談"
7

那麼,理想的情況,執行AOF重寫之後,生成的AOF文件的內容會變為如下所示:

1RPUSH list "a" "b"
2HMSET map "site" "itzhai.com" "author" "arthinking" "wechat" "Java架構雜談"

最終,每個鍵,都壓縮成了一個命令。

如果集合中的元素太多了,如何生成命令?

為了避免命令太長,導致客戶端輸入緩衝區溢出,重寫生成命令的時候,會檢查元素個數,如果超過了redis.h/REDIS_AOF_REWRITE_ITEMS_PER_CMD(64),那麼將拆分為多條命令。

AOF重寫運行原理

重寫運行原理如下圖所示:

image-20210620083402533

  • 1、觸發bgrewriteaof命令;
  • 2、fork子進程,採用寫時複製,複製頁表;
  • 如果此時父進程還需要執行操作命令,則會拷貝記憶體數據並修改,同時追加命令到AOF緩衝區和AOF重寫緩衝區中;
  • 3、根據記憶體數據生成AOF文件;
  • 4、生成完成之後,向父進程發送訊號;
  • 5、父進程執行訊號處理函數,這一步會把AOF重寫緩衝區中的數據追加到新生成的AOF文件中,最終替換掉舊的AOF文件。
AOF重寫涉及到哪些關鍵設計點?
  • 不停服:所謂的不停服,指的是父進程可以繼續執行命令;
  • 雙寫:因為重寫不一定會成功,所以在重寫過程中執行的操作命令,需要同時寫到AOF緩衝區和AOF重寫緩衝區中。這樣一來:
  • 即使重寫失敗了,也可以繼續使用AOF緩衝區,把數據回寫到AOF文件;
  • 如果重寫成功了,那麼就把AOF重寫緩衝區的數據追加到新的AOF文件即可;
  • 記憶體優化:這裡採用的是寫時複製技術,保證fork效率,以及儘可能少的佔用過多的記憶體。

2.3、有沒有那種快速恢復、開銷小、數據不丟失的持久化方案?

Redis 4.0開始提供了RDB+AOF的混合持久化方式,結合了兩者的優點。對應的配置項開關為:aof-use-rdb-preamble。

開啟了混合持久化之後,serverCron定時任務以及BGREWRITEAOF命令會觸發生成RDB文件,在兩次生成RDB文件之間執行的操作命令,使用AOF日誌記錄下來。

最終生成的RDB和AOF都存儲在一個文件中。

通過這種方案,基本保證了數據的不丟失,但是在分散式主從集群系統中,一旦發生了故障導致主從切換或者腦裂問題,就不可避免的導致主從數據不一致,可能導致數據丟失。有沒有修復主從數據不一致問題的方法決定了數據會不會丟失,很可惜,Redis不管怎麼做,都是有可能丟失消息的,我們在分散式章節會詳細介紹這類問題。

2.4、RDB、AOF仍然不夠快?

雖然RDB文件生成,或者AOF重寫都用上了寫時複製技術,但是如果記憶體中的數據實在太多了,那也是會造成Redis實例阻塞的。

有沒有更好的方案呢?有,可以實現的思路:讓記憶體中的數據不能保存太多,記憶體只存儲熱點數據,對於冷數據,可以寫入到SSD硬碟中,再把未寫入SSD硬碟的數據通過某種方式記錄下來,可以參考MySQL的binlog,這樣不用RDB或者AOF,就實現了數據的持久化。

比如,360開源的Pika。

//github.com/Qihoo360/pika

不過該方案也是有缺點的,如果要頻繁的從SSD中載入數據,那麼查詢的性能就會低很多。另外SSD硬碟的使用壽命也和擦寫次數有關,頻繁的改寫,SSD硬碟成本也是一個問題。

這種方案適合需要大容量存儲、訪問延遲沒有嚴格要求低業務場景中使用。

Part Ⅱ. 記憶體資料庫

image-20210619210953760

接下來,我們來看看Redis是如何組織這些數據類型,來構建一個記憶體資料庫的。

以下是Redis資料庫的結構:

image-20210414071036935

Redis伺服器程式所有的資料庫都保存在redisService結構體中,其中有個db數組,為redisDb類型,每個元素為一個資料庫。

db數組可配置,默認為16個,redisDb中保存了一個字典,該字典保存了資料庫中所有的鍵值對,我們也稱該字典為鍵空間(key space)。

字典的key為String類型,字典的value就是我們上一節提到的各種數據類型了,這些數據類型讓Redis可以存儲多樣化的數據,利用特定的數據結構實現一些業務場景,我們後面在應用場景小節具體介紹。

客戶端連接哪個資料庫?

默認情況下,Redis客戶端會連接0號資料庫,可以通過SELECT命令切換資料庫。

1127.0.0.1:6379> SET name arthinking
2OK
3127.0.0.1:6379> GET name
4"arthinking"
5127.0.0.1:6379> DEL name
6(integer) 1
7127.0.0.1:6379> GET name
8(nil)

3、讀寫鍵的時候做了啥

當通過命令對資料庫進行了讀寫之後,Redis同時會做一些維護工作:

image-20210413234751485

4、如何存儲鍵的過期時間

4.1、過期相關命令

  • EXPIRE key seconds,設置key的生存秒數;
  • PEXPIRE key milliseconds,設置key的生存毫秒數;
  • EXPIREAT key timestamp,設置key的過期時間戳(秒);
  • PEXPIREAT key timestamp,設置key的過期時間戳(毫秒)
  • SETEX,設置一個字元串的過期時間;
  • TTL與PTTL,接收一個帶有生存時間的鍵,返回鍵的剩餘生成時間。

這些設置過期時間命令,本質上都會轉成PEXPIREAT命令來執行,資料庫中存儲的是鍵的過期時間點。

4.2、應該使用哪個過期命令比較靠譜?

注意:建議直接使用EXPIREAT命令來設置過期時間,避免主從同步延遲,導致從庫實際的EXPIREAT時間比主庫的晚,最終客戶端在從庫上讀取到了過期的數據(主庫已過期,從庫未過期)。

4.3、過期字典

我們注意到,上面的資料庫結構圖中,包含了一個expires過期字典,該字典的鍵是一個紙指向鍵空間中某個資料庫鍵的指針,值是一個long long類型的整數,保存資料庫鍵的過期時間(毫秒時間戳)。

image-20210414072645293

5、如何刪除過期鍵

在一般程式設計中,我們也會用三種策略來實現數據的過期刪除:

image-20210620084316350

定時刪除策略是一種方案,但是如果設置的不合理,就會即浪費CPU,或者記憶體及時刪除。為此,Redis採用了惰性刪除和定期刪除配合工作的方式。

  • Redis中的惰性刪除:接收讀寫資料庫命令,判斷是否已過期,如果過期則刪除並返回空回復,否則執行實際的命令流程;
  • Redis中的定期刪除:每次運行,從一定量資料庫中取出一定量隨機鍵進行檢查,然後把過期的鍵刪除掉,通過一個全局表示current_db記錄處理進度,確保所有資料庫都可以被處理。

5.1、從庫的key過期了可以被清理掉嗎?

當主庫鍵key過期時時,會同步一個DEL操作到從庫,從庫不會自己刪除過期key,只會應用從主庫同步過來的DEL操作,這樣就避免了快取一致性的錯誤。

這樣就會有一個問題,如果從庫在同步DEL操作之前,就有客戶端請求從庫獲取key,那麼就有可能讀取到主庫已經刪除,但是從庫還未刪除的key。

好在從Redis 3.2開始,對從庫讀取key做了優化:在從庫發起讀請求的時候,會先判斷這個key是否過期,如果過期了,就直接返回nil,從而避免了在從庫中讀取到了過期的key的問題。

另外:建議直接使用EXPIREAT命令來設置過期時間,避免主從同步延遲,導致從庫實際的EXPIREAT時間比主庫的晚,最終客戶端在從庫上讀取到了過期的數據(主庫已過期,從庫未過期)。

6、Redis中的發布訂閱機制

Redis的發布訂閱功能有以下命令組成:

  • SUBSCRIBE channel [channel ...]

  • 訂閱給定的一個或多個頻道的資訊;

  • SUBSCRIBE channel

  • UNSUBSCRIBE [channel [channel ...]]

  • 退訂給定的頻道

  • UNSUBSCRIBE channel

  • PUBLISH channel message

  • 用於將資訊發送到指定的頻道;

  • PUBLISH channel itzhai.com

  • PUBSUB subcommand [argument [argument ...\]]

  • 查看訂閱與發布系統狀態

  • PUBSUB CHANNELS

  • PSUBSCRIBE pattern [pattern ...]

  • 訂閱一個或多個符合給定模式的頻道

  • PSUBSCRIBE site.*

  • PUNSUBSCRIBE [pattern [pattern ...\]]

  • 退訂給定模式的頻道

  • > UNSUBSCRIBE site.*

如下,通過給定的模式頻道進行訂閱和發布消息:

 1# 客戶端A訂閱模式頻道
2127.0.0.1:6379> PSUBSCRIBE site.*
3psubscribe
4site.*
51
6
7# 客戶端B向模式頻道發布消息
8127.0.0.1:6379> PUBLISH site.itzhai "hello world!!!"
91
10
11# 客戶端A輸出
12pmessage
13site.*
14site.itzhai
15hello world!!!

Redis在服務端通過鏈表的形式維護了每個頻道的客戶端的訂閱記錄,每次發布消息的時候,都從鏈表中找到所有相關的客戶端的socket連接,並發送訂閱消息給各個客戶端。Redis中存放客戶端訂閱關係的相關數據結構:

1struct redisServer {
2    // ...
3    dict *pubsub_channels;  // 保存所有頻道訂閱關係
4    list *pubsub_patterns;  // 保存所有模式訂閱關係
5    // ...
6};

具體結構如下圖所示:

image-20210616080545961

在dict字典中,每個鍵值對存儲一個頻道的訂閱關係,key為頻道名稱,value為鏈表結構,存儲該頻道所有訂閱的客戶端。

每當執行SUBSCRIBE命令的時候,執行把客戶端追加到字典中對應頻道的key的values鏈表中即可。

每當執行PUBLISH命令的時候,從字典中找到對應的頻道鍵值對,依次遍歷values中所有的客戶端進行發送消息即可。

在list鏈表中,保存了所有的模式頻道訂閱關係。

每當執行PUBLISH命令的時候,除了在pubsub_channels尋找頻道訂閱關係,發給具體的頻道的所有客戶端之外,同時,Redis會遍歷pubsub_patterns中的所有訂閱模式頻道,找到與當前發布消息頻道匹配的模式頻道,將消息發送給該模式頻道的客戶端。

6.1、發布訂閱的優缺點

通過使用Redis的發布訂閱機制,很容易實現消息的訂閱與通知,可以快速實現一個消息隊列。

但是,該消息隊列的缺點也比較明顯,請大家慎用:

  • 發布的消息不支援持久化,如果Redis掛了,那麼發布的消息也就丟失了;或者消息發送給了一半的訂閱者,Redis就掛了,那麼剩下的一般訂閱者也就不會收到消息了;或者準備發送消息給其中一個訂閱者的時候,該訂閱者失去連接了,消息也會丟失;
  • 消息發送缺少ACK機制,不能保證消息一定會被消費…

針對可靠性要求低的業務,為了簡單快速實現,可以使用Redis的發布訂閱機制來實現消息通知。而對於可靠性要求比較高的,則可以嘗試Redis 5.0的新數據結構Stream,具體用法和原理,參考Part I部分相關內容。

7、實現資料庫通知

基於Redis的發布訂閱機制,我們就可以實現資料庫通知功能了。該功能常常用於作為對數據或者命令的監控。

因為開啟資料庫通知需要消耗一定的CPU,所以默認配置下,是關閉狀態的。為了開啟這個功能更,我們可以修改redis.conf文件:

1notify-keyspace-events KElg

如上,我們開啟了:

  • K:鍵空間通知,所有通知以keyspace@為前綴;
  • E:鍵事件通知,所有通知以keyevent@為前綴;
  • l:列表命令通知;
  • g:DELEXPIRERENAME 等類型無關的通用命令的通知。

更多關於notify-keyspace-events的配置,請參考官方文檔:Redis Keyspace Notifications[4]

現在我們啟動Redis伺服器,就支援資料庫通知了。

現在我們在一個客戶端1訂閱一個鍵空間通知,監聽我的錢包my_money:

1127.0.0.1:6379> SUBSCRIBE __keyspace@0__:my_money
2Reading messages... (press Ctrl-C to quit)
31) "subscribe"
42) "__keyspace@0__:my_money"
53) (integer) 1

在另一個客戶端2,給我的錢包打100塊錢看看:

1127.0.0.1:6379> SET my_money 100
2OK

結果我們在客戶端1可以收到以下到賬通知:

11) "message"
22) "__keyspace@0__:my_money"
33) "set"

另外,我們也可以監聽某一個命令:

1127.0.0.1:6379> SUBSCRIBE __keyevent@0__:del

8、Redis客戶服務程式設計

Redis在傳輸層,使用的是TCP協議,每當有客戶端連接到伺服器的時候,都會創建一個Socket連接,對應一個套接字文件描述符fd。

而在Redis伺服器中,是如何維護這些網路連接的呢,接下來我們就來看看。

8.1、客戶端資訊

當客戶端與Redis伺服器連接之後,redisServer結構中會存儲一個客戶端的鏈表,該鏈表節點用來記錄與客戶端通訊的各種資訊:

image-20210417232241895

我們重點來關注redisClient以下資訊:

  • int fd:文件描述符,-1代表偽客戶端;
  • robj * name:客戶端名字;
  • int flags:客戶端標誌,flags可以是單個標誌,或者多個標誌的二進位或,常見的標誌:
  • REDIS_LUA_CLIENT:表示客戶端是用於處理Lua腳本的偽戶端;
  • REDIS_MONITOR:客戶端正在執行MONITOR命令;
  • REDIS_UNIX_SOCKET:伺服器使用UNIX套接字來連接客戶端;
  • REDIS_BLOCKED:客戶端正在被BRPOP、BLPOP等命令阻塞;
  • REDIS_UNBOKCKED:表示客戶端已從阻塞狀態中脫離出來,該標誌只能在REDIS_BLOCKED標誌已經打開的情況下使用;
  • REDIS_MULTI:客戶端正在執行事務
  • REDIS_FORCE_AOF:強制將執行的命令寫入到AOF文件裡面,一般情況,Redis只會對資料庫進行了修改的命令寫入到AOF文件中,而對於PUBSUB和SCRIPT LOAD命令,則需要通過該標誌,強制將這個命令寫入到AOF文件中,另外,為了讓主從伺服器可以正確載入SCRIPT LOAD命令指定的腳本,需要使用REDIS_FORCE_REPL標誌,強制將SCRIPT LOAD命令複製給所有從伺服器;
  • sds querybuf:客戶端輸入緩衝區,命令請求字元串,最大大小不能超過1G,否則客戶端將被伺服器關閉;
  • robj **argv:要執行的命令,以及所有參賽構成的數組;
  • int argc:argv數組的長度;
  • struct redisCommand *cmd:客戶端請求對應的命令,從字典結構的命令表中查找得到;
  • char buf[REDIS_REPLY_CHUNK_BYTES]:固定大小緩衝區,REDIS_REPLY_CHUNK_BYTES值默認為16KB;
  • int bufpos:固定大小緩衝區易用位元組數量;
  • list *reply:可變大小緩衝區由鏈表組成,每個節點為一個字元串對象;
  • int authenticated:客戶端身份驗證狀態,1表示驗證通過,如果Redis啟用了身份驗證功能,則需要用到該欄位;
  • time_t ctime:客戶端連接創建時間;
  • time_t lastinteraction:客戶端與伺服器最後一次通訊時間;
  • time_t obuf_soft_limit_reached_time:輸出緩衝區第一次達到軟性限制的時間;

8.2、命令執行流程

一個命令執行的完整流程如下圖所示:

image-20210418093326432

可以發現,Redis執行命令的整個過程,相關的中間資訊都存儲在redisClient中。

而整個執行過程中,執行緒模型是怎樣的,我們在後面小節會詳細介紹。

Part Ⅲ. 網路處理與執行緒模型

image-20210619211022907

9、Redis網路處理與執行緒模型

相信大家已經聽過無數遍「Redis是單執行緒的」這句話了,Redis真的是單執行緒的嗎,又是如何支撐那麼大的並發量,並且運用到了這麼多的互聯網應用中的呢?

其實,Redis的單執行緒指的是Redis內部會有一個主處理執行緒,充分利用了非阻塞、IO多路復用模型,實現的一個Reactor架構。但是在某些情況下,Redis會生成執行緒或者子進程來執行某些比較繁重的任務。

Redis包含了一個稱為AE事件模型的強大的非同步事件庫來包裝不同作業系統的IO復用技術,如epoll、kqueue、select等。

關於Redis的網路處理與執行緒模型,我在Java架構雜談公眾號以及IT宅(itzhai.com)中的這篇文章有詳細的介紹了:性能追擊:萬字長文30+圖揭秘8大主流伺服器程式執行緒模型,感興趣的朋友可以進一步閱讀。

Part Ⅳ. 性能

image-20210620085715257

10、rehash會導致操作阻塞嗎?

如下圖,Redis的字典結構中包含了兩個哈希表:

image-20210413071439026

默認是往ht[0]寫數據的,隨著數據主鍵增多,Redis就會觸發執行rehash操作了,主要步驟如下:

  • 給ht[1]分配更大的空間;
  • 將ht[0]的數據拷貝到ht[1];
  • 釋放ht[0]的空間。

如果直接拷貝數據,肯定是會花很長時間的,進一步會導致阻塞Redis。

為了避免這個問題,Redis中使用的是漸進式的rehash,進一步了解,可以閱讀Part I中Hash字典小節內容。

11、如何高效存儲數據

為了節省存儲空間,針對不同的數據類型,優先採用更加緊湊型的編碼格式。通過特定條件,可以讓Redis選擇緊湊型的編碼:

image-20210524224039027

比如我們有一個很大集合的數據需要存儲,如果數據有一定規律,並且鍵值大小都小於 hash-max-ziplist-value,並且可以劃分成若干份,每份都可以保持在 hash-max-ziplist-entries以內,那麼我們就可以分開來存儲成多個Hash,這樣就可以保證每個Hash都是用上ZIPLIST編碼,極大的節省了空間。

但這是用時間換空間的做法:使用了壓縮列表,也就意味著查詢需要遍歷ziplist,降低了查詢的性能。在時間和空間之間,我們需要根據具體場景做出合理的抉擇。

12、Redis中的性能殺手

Part III 部分,我們已經了解到,接收客戶端網路執行命令的網路請求,網路開銷對Redis的開銷影響很小,那麼在Redis中,究竟有哪些影響性能的點呢?這節,我們詳細來探討下。

不像線上性能分析,可以根據各種參數指標去挖掘定位性能點,為了摸底Redis的性能,那麼就得從Redis的方法面面入手,我們將從以下各個維度去解析性能影響點。

12.1、記憶體數據操作

讀操作

每當客戶端請求數據的時候,都需要等待命令執行完,獲取到執行的結果,所以執行命令的複雜度決定了性能的好壞,我們在實際操作中,要盡量使用複雜度低的命令,少用複雜度低的命令,控制一次讀取的數據量。

以下是複雜度比較高的命令:

  • 集合統計排序:

  • SINTER 交集,複雜度O(N*M),其中 N 是最小集合的基數,M 是集合的數量;

  • SUNION 並集,複雜度O(N),其中 N 是所有給定集合中元素的總數;

  • SDIFF 差集,複雜度O(N) ,其中 N 是所有給定集合中元素的總數;

  • SORT 排序,複雜度O(N+M*log(M)),其中 N 是列表或集合中要排序的元素數,M 是返回元素的數量。

  • 大數據量查詢:

  • HGETALL 返回存儲在字典中所有的鍵值對,時間複雜度O(N),其中 N 是字典的大小;

  • SMEMBERS 返回集合中的所有成員key,時間複雜度O(N),其中N是設置的基數。

如果實在要執行此類操作,而數據量又比較大,建議將此類操作放到單獨的從庫中執行。

刪除操作

如果我們刪除了bigkey,那麼就可能導致阻塞主執行緒。為什麼呢?

因為刪除操作除了會釋放記憶體空間,還會把空閑空間插入作業系統的空閑記憶體塊鏈表中,刪除的key越大,那麼就越耗時。

為了避免刪除bigkey對主執行緒的阻塞,Redis 4.0開始新增了UNLINK命令實現惰性刪除。刪除bigkey,建議都使用UNLINK命令。

UNLINK命令是先釋放掉字典和過期字典中的鍵值對引用,然後在符合以下任一條件的情況,決定是否需要放到lazyfree隊列中進行非同步刪除:

  • Hash、Set底層採用哈希,並且元素個數超過64;
  • ZSET底層採用跳躍表,並且元素個數超過64;
  • List節點數量超過64。

否則,還是會直接刪除。

可見惰性刪除也不一定會起效,所以為了杜絕此類性能問題,最好避免在Redis中存儲bigkey。

另外,如果我們執行FLUSHALL或者FLUSHDB,也會阻塞執行緒。為了避免此種情況,可以通過向FLUSHALL/FLUSHDB添加async非同步清理選項,redis在清理整個實例或db時會以非同步運行。

12.2、磁碟寫

AOF日誌落盤

如果我們的AOF寫回策略是Always同步寫,那麼每次寫數據的過程中,都會因寫磁碟而阻塞住了。

如果可以容忍一秒鐘的數據丟失,那麼,我們可以把AOF寫回策略設置為Everysec,這樣就會通過非同步執行緒去落盤了,從而避免阻塞主執行緒。

如果我們使用了Always策略,那麼就需要注意了,如果剛好Redis在執行AOF重新,會導致大量的磁碟IO,最終導致作業系統fsync被阻塞,最終導致主執行緒也被fsync調用阻塞住了

為了進一步減小重寫AOF被阻塞的風險,可以設置為AOF重寫是,不進行fsync:

1no-appendfsync-on-rewrite yes

12.3、主從同步

當我們要進行主從同步的時候,首先,主庫會生成一份完整的RDB,傳給從庫,從庫首先執行FLUSHDB清空原來資料庫,然後從庫在載入RDB文件,這個過程會導致從庫被阻塞

12.4、切片集群

在切片集群場景,如果剛好有big key需要遷移到其他節點,那麼就會導致主執行緒阻塞,因為Redis Cluster是用的同步遷移。遷移過程中會同時阻塞源節點和目標節點。

而如果使用Codis進行集群,則可以利用其非同步遷移的特性減少big key遷移對集群性能的影響。

13、NUMA陷阱

Redis的性能跟CPU也有關?沒錯。接下來看看NUMA陷阱對Redis性能的影響。

13.1、NUMA

NUMA(Non-Uniform Memory Access),非統一記憶體訪問架構,為多處理器的電腦設計的記憶體架構,記憶體訪問時間取決於記憶體相對於處理器的位置。

在NUMA下,處理器訪問它自己的本地記憶體的速度比非本地記憶體(記憶體位於另一個處理器,或者是處理器之間共享的記憶體)快一些。

誕生背景

現代 CPU 的運行速度比它們使用的主記憶體快得多。在計算和數據處理的早期,CPU 通常比自己的記憶體運行得慢。

隨著第一台超級電腦的出現,處理器和記憶體的性能線在 1960 年代出現了轉折點。從那時起,CPU 越來越多地發現自己「數據匱乏」,不得不在等待數據從記憶體中到達時停止運行。1980 年代和 1990 年代的許多超級電腦設計專註於提供高速記憶體訪問,而不是更快的處理器,使電腦能夠以其他系統無法接近的速度處理大型數據集。

NUMA 嘗試通過為每個處理器提供單獨的記憶體來解決這個問題,避免在多個處理器嘗試定址同一記憶體。

NUMA架構解析

以下是NUMA架構圖示:(圖片來源:NUMA Deep Dive Part 2: System Architecture[18])

image-20210530175848027

每個CPU有自己的物理核、L3快取,以及連接的記憶體,每個物理核包括L1、L2快取。不同處理器之間通過QPI匯流排連接。

每個CPU可以訪問本地記憶體和系統中其他CPU控制的記憶體,由其他CPU管理的記憶體被視為遠程記憶體,遠程記憶體通過QPI訪問。

在上圖中,包含兩個CPU,每個CPU包含4個核,每個CPU包含4個通道,每個通道最多3個DIMM,每個通道都填充了一個16 GB的記憶體,每個CPU用64GB記憶體,系統總共有128GB記憶體。

13.2、NUMA對Redis性能有何影響?

讀取遠程記憶體導致的問題

在NUMA架構上,程式可以在不同的CPU上運行,假設一個程式現在CPU 0上面運行,並把數據保存到了CPU 0的記憶體中,然後繼續在CPU 1上面運行,這個時候需要通過QPI訪問遠程記憶體,導致增加數據讀取的時間,從而導致性能變差。

另外,每次切換CPU,就需要從L3快取重新載入相關的指令和數據到L1、L2快取中,如果L3快取也找不到,則會從記憶體中載入,導致增加CPU的處理時間。

如何解決?

linux 作業系統提供了一個名為 NUMACTL 的函數,NUMACTL 提供了控制的能力:

  1. NUMA 調度策略,例如,我想在哪些內核上運行這些任務
  2. 記憶體放置策略,在哪裡分配數據

為了解決這個問題,我們可以把我們的程式綁定到某一個CPU上面來調度執行,相關命令:

1# 查看CPU資訊
2numactl --hardware 
3# 指定進程在某個CPU上運行
4taskset -c 1 ./redis-server
5numactl --cpubind=0 --membind=0 ./redis-server

當然,也可以通過Taskset,進行設置,更詳細的相關操作說明,參考:NUMACTL, taskset and other tools for controlling NUMA accesses notes[19]

如果我們的網路中斷處理程式也做了綁核操作,建議把Redis和網路中斷成本綁定到同一個CPU上。

切記:Redis是需要用到多執行緒能力的(RDB、AOF文件生成、惰性刪除…),我們不能把Redis只綁定到某一個內核中,否則就失去了多執行緒的能力。

14、記憶體問題

14.1、記憶體不足,性能急劇下降

在記憶體不足的情況下,作業系統會啟用swap機制,這個時候,Redis主執行緒會導致大量磁碟IO,極大的增加Redis響應時間,導致Redis性能急劇下降。

為此,我們除了要給Redis服務設置最大記憶體之外,還需要監控Redis伺服器的記憶體使用情況,避免與其他記憶體需求大的應用一起運行。

如何監控swap情況

1# 找到Redis的進程號,進入/proc目錄,進行查看
2cd /proc/進程號
3cat smaps | egrep '^(Swap|Size)'

如果發現很多幾百MB或者上GB的swap,就說明實例的記憶體壓力很大,需要排查是否記憶體不足導致Redis性能變慢。

14.2、記憶體大頁機制

在發起BGSAVE命令之後,Redis會fork出一個子進程用於執行生成RDB文件。fork的時候採用的是寫時複製(Copy-on-write)技術。不會立刻複製所有的記憶體,只是複製了頁表,保證了fork執行足夠快

如果此時正好開啟了記憶體大頁機制,即使客戶端請求的數據只有幾k,也會拷貝2MB的大頁,最終導致大量的記憶體拷貝,從而影響Redis的訪問速度。

為了避免這種情況,可以關閉記憶體大頁機制:

 1[root@VM_0_14_centos ~]# echo never > /sys/kernel/mm/transparent_hugepage/enabled 
2[root@VM_0_14_centos ~]# echo never > /sys/kernel/mm/transparent_hugepage/defrag 
3[root@VM_0_14_centos ~]# cat /sys/kernel/mm/transparent_hugepage/defrag
4[always] madvise never
5[root@VM_0_14_centos ~]# cat /sys/kernel/mm/transparent_hugepage/enabled
6[always] madvise never
7# 驗證是否生效
8[root@VM_0_14_centos ~]# grep Huge /proc/meminfo
9AnonHugePages:     18432 kB
10HugePages_Total:       0
11HugePages_Free:        0
12HugePages_Rsvd:        0
13HugePages_Surp:        0
14Hugepagesize:       2048 kB
15[root@VM_0_14_centos ~]# cat /proc/sys/vm/nr_hugepages
160

14.3、碎片

Redis並沒有實現自己的記憶體池,也沒有基於標準的系統記憶體分配器進行擴展。因此,系統記憶體分配器的性能和碎片率會對Redis產生一定的性能影響。向jemalloc這類的分配策略,是以一系列固定大小來進行記憶體空間的劃分的,如8bit、16bit、32bit…這無形之中會導致一些分配的記憶體用不上,導致記憶體碎片的出現。

而記憶體碎片的出現,會導致記憶體空間利用率不高,導致無法分配新的空間。

如,在Redis中,SDS的空間預分配策略就很容易造成記憶體碎片。另外,頻繁的刪改操作、鍵值對大小不一致,也是造成記憶體碎片的原因之一。

為了監控Redis是否存在記憶體碎片,可以執行以下命令:

1127.0.0.1:6379> INFO memory
2...
3mem_fragmentation_ratio:2.64
4...

2.64,這個值大不大呢?

這個值的計算公式:

mem_fragmentation_ratio = used_memory_rss/ used_memory

used_memory_rss:作業系統分配給Redis實例的記憶體大小,表示Redis進程佔用的物理記憶體大小;

used_memory:Redis使用其分配器分配的記憶體大小;

mem_fragmentation_ratio < 1 表示Redis記憶體分配超出了物理記憶體,作業系統正在進行記憶體交換;

mem_fragmentation_ratio > 1 合理的指標值;

mem_fragmentation_ratio > 1.5 表示Redis消耗了實際需要物理記憶體的150%以上,其中50%是記憶體碎片率。

如果這個值超過1.5(50%)了,說明碎片化還是比較嚴重的,需要進行優化了。

記憶體碎片清理方法

在Redis 4.0之前,解決記憶體碎片問題的唯一方法就是重啟Redis了,從Redis 4.0開始,提供了主動記憶體碎片整理的方式。

通過以下配置,來讓Redis觸發自動記憶體清理:

 1# 啟用主動碎片整理
2activedefrag yes
3# 啟動活動碎片整理的最小碎片浪費量
4active-defrag-ignore-bytes 100mb
5# 啟動活動碎片整理的最小碎片百分比
6active-defrag-threshold-lower 10
7# 主動碎片整理所用CPU時間最小佔比,保證能夠正常展開清理
8active-defrag-cycle-min 25
9# 主動碎片整理所用CPU時間最大佔比,超過這個值,就停止清理,避免因為清理導致阻塞Redis
10active-defrag-cycle-max 75

您可以在不用重啟Redis的情況下,通過以下方式啟用碎片整理:

1redis-cli config set activedefrag "yes"

Part Ⅴ. 分散式

image-20210619211251320

所謂分散式系統,就是把多台機器通過特定約定組合連接起來,構成一個集群,讓他們共同完成一件任務,可以是計算任務,也可以是存儲任務。

而Redis則可以用於構建實現存儲任務的分散式系統。一般在構建分散式系統的時候,為了保證系統的可靠性,除了通過RDB+AOF實現數據的持久化和恢復之外,還需要保證服務持續運行,都需要搭建主從集群。而為了實現存儲更多的數據,需要做切片集群,讓數據分散到分散式系統的各個節點中,均攤計算和存儲壓力。

首先我們從主從集群說起,看看Redis是如何通過主從集群構建高可靠的系統的。

幾年前我寫了一系列的技術文章,發布到了IT宅(itzhai.com),但是由於伺服器出了一些問題,而我的又沒有做備份,導致我的那兩年的文章都丟失了,可見備份有多麼重要。同樣的,我們也應該給Redis實例備份,避免出現故障導致服務無法訪問。

15、主從集群

Redis搭建主從集群還是比較簡單的,只需要通過

replicaof 主庫IP 主庫埠

命令就可以了。如下圖,我們分別在101和102機器上執行命令:

1replicaof 192.168.0.1 6379

我們就構建了一個主從集群:

image-20210419232621783

為了保證主從數據的一致性,主從節點採用讀寫分離的方式:主庫負責寫,同步給從庫,主從均可負責讀。

那麼,我們搭建好了主從集群之後,他們是如何實現數據同步的呢?

15.1、主從集群數據如何同步?

當從庫和主庫第一次做數據同步的時候,會先進行全量數據複製,大致流程如下圖所示:

image-20210420075431330

  • 從庫執行replicaof命令,與主庫建立連接,發送psync命令,請求同步,剛開始從庫不認識主庫,也還不知道他的故事,所以說了一句:那誰,請開始講你的故事(psync ? -1);
  • 主庫收到從庫的打招呼,於是告訴了從庫它的名字(runId),要給他講整個人生的經歷(FULLRESYNC,全量複製),以及講故事的進度(offset);
  • 主庫努力回憶自己的故事(通過BGSAVE命令,生成RDB文件);
  • 主庫開始跟從庫講故事(發送RDB文件);
  • 從庫摒棄主觀偏見,重新開始從主庫的故事中認識了解主庫;
  • 主庫講故事過程中,又有新的故事發生了(收快遞),於是繼續講給從庫聽(repl buffer)。

15.1.1、主從複製過程中,新產生的改動如何同步?

從上面的流程圖中,我們也可以知道,主庫會在複製過程中,把新的修改操作追加到replication buffer中,當發送完RDB文件後,再把replication buffer中的修改操作繼續發送給從庫:

15.1.2、從庫太多了,主庫如何高效同步?

當我們要同步的從庫越多,主庫就會執行多次BGSAVE進行數據全量同步,為了減小主庫的壓力,可以做如下的集群配置,通過選擇一個硬體配置比較高的從庫作為其他從庫的同步源,把壓力分擔給從庫:

image-20210420080340046

15.1.3、首次同步完成之後,如何繼續同步增量數據?

主從同步完成之後,主庫會繼續使用當前創建的網路長連接,以命令傳播的方式進行同步增量數據。

15.1.4、主從斷開了,如何繼續同步?

當主從斷開重連之後,從庫會採用增量複製的方式進行同步。

為了實現增量複製,需要藉助於repl_backlog_buffer緩衝區。

如下圖:

image-20210420084029605

Redis主庫會給每一個從庫分別創建一個replication buffer,這個buffer正是用於輔助傳播操作命令到各個從庫的。

同時,Redis還持有一個環形緩衝 repl_backlog_buffer,主庫會把操作命令同時存儲到這個環形緩衝區中,每當有從庫斷開連接的時候,就會向主庫重新發送命令:

psync runID offset

主庫獲取都這個命令,到repl_backlog_buffer中獲取已經落後的數據,重新發送給從庫。

注意:由於repl_backlog_buffer是一個環形緩衝區,如果主庫進度落後太多,導致要同步的offset都被重寫覆蓋掉了,那麼只能重新進行全量同步了。

所以,為了保證repl_backlog_buffer有足夠的空間,要設置好該緩衝區的大小。

計算公式:repl_backlog_buffer = second * write_size_per_second

  • second:從伺服器斷開連接並重新連接到主伺服器所需的平均時間;
  • write_size_per_second:主庫每秒平均生成的命令數據量(寫命令和數據大小的總和)。

為了應對突然壓力,一般要對這個計算值乘於一個適當的倍數。

更多關於集群數據複製的說明和配置,參考://redis.io/topics/replication[5]

15.2、主庫掛了,怎麼辦?

一個Redis主從集群中,如果主庫掛了?怎麼辦?首先,我們怎麼知道主庫掛了?不可能讓運維人員隔一段時間去檢查檢查機器狀況吧?

所以,我們的第一個問題是:怎麼知道主庫掛了?

15.2.1、怎麼知道主庫下線了?

既然不能讓運維同事盯著電腦去監控主庫狀態,那麼我們是不是可以安排一個機器人來做這件事情呢?

很慶幸,Redis中提供了一個這樣的角色:哨兵(Sentinel),專門來幫忙監控主庫狀態。除此之外,監控到主庫下線了,它還會幫忙重新選主,以及切換主庫。

Redis的哨兵是一個運行在特殊模式下的Redis進程。

Redis安裝目錄中有一個sentinel.conf文件,裡面可以進行哨兵的配置:

1sentinel monitor itzhaimaster 192.168.0.1 6379 2 # 指定監控的master節點
2sentinel down-after-milliseconds itzhaimaster 30000  # 主觀下線時間,在此時間內實例不回復哨兵的PING,或者回復錯誤
3sentinel parallel-syncs itzhaimaster 1  # 指定在發生failover主備切換時最大的同步Master從庫數量,值越小,完成failover所需的時間就越長;值越大,越多的slave因為replication不可用
4sentinel failover-timeout mymaster 180000   # 指定內給宕掉的Master預留的恢復時間,如果超過了這個時間,還沒恢復,那哨兵認為這是一次真正的宕機。在下一次選取時會排除掉該宕掉的Master作為可用的節點,然後等待一定的設定值的毫秒數後再來檢測該節點是否恢復,如果恢復就把它作為一台從庫加入哨兵監測節點群,並在下一次切換時為他分配一個」選取號」。默認是3分鐘
5protected-mode no  # 指定當前哨兵是僅限被localhost本地網路的哨兵訪問,默認為yes,表示只能被部署在本地的哨兵訪問,如果要設置為no,請確保實例已經通過防火牆等措施保證不會受到外網的攻擊
6bind  
7...

然後執行命令啟動哨兵進程即可:

./redis-sentinel ../sentinel.conf

注意:如果哨兵是部署在不同的伺服器上,需要確保將protected-mode設置為no,否則哨兵將不能正常工作。

為啥要哨兵集群?

即使是安排運維同事去幫忙盯著螢幕,也可能眼花看錯了,走神了,或者上廁所了,或者Redis主庫剛好負載比較高,需要處理一下,或者運維同事的網路不好等導致錯誤的認為主庫下線了。同樣的,我們使用哨兵也會存在這個問題。

為了避免出現這種問題,我們多安排幾個哨兵,來協商確認主庫是否下線了。

以下是三個哨兵組成的集群,在監控著主從Redis集群:

image-20210422075008755

如上圖,哨兵除了要監控Redis集群,還需要與哨兵之間交換資訊。哨兵集群的監控主要有三個關鍵任務組成:

  • sentinel:hello:哨兵之間每2秒通過主庫上面的sentinel:hello頻道交換資訊:
  • 發現其他哨兵,並建立連接;
  • 交換對節點的看法,以及自身資訊;
  • INFO:每個哨兵每10秒向主庫發送INFO命令,獲取從庫列表,發現從節點,確認主從關係,並與從庫建立連接;
  • ping:每個哨兵每1秒對其他哨兵和Redis執行ping命令,判斷對方在線狀態。

這樣,哨兵進去之間就可以開會討論主庫是不是真正的下線了。

如何確認主庫是真的下線了?

當一個哨兵發現主庫連不上的時候,並且超過了設置的down-after-milliseconds主觀下線時間,就會把主庫標記為主觀下線,這個時候還不能真正的任務主庫是下線了,還需要跟其他哨兵進行溝通確認,因為,也許是自己眼花了呢?

比如哨兵2把主庫標記為客觀下線了,這個時候還需要跟其他哨兵進行溝通確認,如下圖所示:

image-20210422233051775

哨兵2判斷到主庫下線了,於是請求哨兵集群的其他哨兵,確認是否其他哨兵也認為主庫下線了,結果收到了哨兵1的下線確認票,但是哨兵3卻不清楚主庫的狀況。

只有哨兵2拿到的確認票數超過了quorum配置的數量之後,才可以任務是客觀下線。這裡哨兵2已經拿到兩個確認票,quorum=2,此時,哨兵2可以把主庫標識為客觀下線狀態了。

關於quorum:建議三個節點設置為2,超過3個節點設置為 節點數/2+1。

15.2.2、主庫下線了,怎麼辦?

主庫掛了,當然是要執行主從切換了,首先,我們就要選出一位哨兵來幫我們執行主從切換。

如何選舉主從切換的哨兵?

一個哨兵要想被哨兵集群選舉為Leader進行主從切換,必須符合兩個條件:

  • 拿到票數要大於等於哨兵配置文件的quorum值;
  • 拿到整個集群半數以上的哨兵票數。

注意:與判斷客觀下線不太一樣的,這裡多了一個條件。

如果一輪投票下來,發現沒有哨兵勝出,那麼會等待一段時間之後再嘗試進行投票,等待時間為:哨兵故障轉移超時時間failover_timeout * 2,這樣就能夠爭取有足夠的時間讓集群網路好轉。

15.2.3、切換完主庫之後,怎麼辦?

切換完成之後,哨兵會自動重寫配置文件,讓其監控新的主庫。

15.2.4、客戶端怎麼知道主從正在切換?

哨兵之間可以通過主庫上面的_sentinel_:hello進行交換資訊。同樣的,客戶端也可以從哨兵上面的各個訂閱頻道獲取各種主從切換的資訊,來判斷當前集群的狀態。

以下是常見的訂閱頻道:

  • 主庫下線:
  • +sdown :哨兵實例進入主觀下線(Subjectively Down)狀態;
  • -sdown :哨兵實例退出主觀下線(Objectively Down)狀態;
  • +odown :進入客觀下線狀態;
  • -odown :退出客觀下線狀態;
  • 主從切換:
  • failover-end :故障轉移操作順利完成。所有從伺服器都開始複製新的主伺服器了;
  • +switch-master :配置變更,主伺服器的 IP 和地址已經改變。 這是絕大多數外部用戶都關心的資訊。

有了這些資訊之後,客戶端就可以知道主從切換進度,並且獲取到新的主庫IP,並使用新的主庫IP和埠進行通訊,執行寫操作了。

更多關於Redis哨兵機制的說明和配置方法,參考文檔:Redis Sentinel Documentation[6]

通過主從集群,保證了Redis的高可靠,但是隨著Redis存儲的數據量越來越多,勢必會導致佔用記憶體越來越大,記憶體太大產生問題:

  • 重啟,從磁碟恢複數據的時間會變長;
  • Redis在做持久化的時候,需要fork子進程,雖然是通過寫時複製進行fork,拷貝的只是頁表數據,也是會導致拷貝時間變長,導致阻塞主執行緒,最終影響到服務的可用性。

為此,我們需要保證單個節點的數據量不能太大,於是引入了切片集群。下面就來詳解介紹切片集群的實現技術。

15.2.5、如何避免主庫腦裂導致的數據丟失問題?

我們直接看一下這個場景。

假設quorum=2,由於網路問題,兩個哨兵都主觀判斷到master下線了,最終master被判斷為客觀下線:

image-20210609233936439

於是進行主從切換,但是在切換期間,原master又恢復正常了,可以正常接收客戶端請求,現在就有兩個master都可以同時接收客戶端的請求了。這個時候,集群裡面會有兩個master。原本只有一個大腦的分散式系統,分裂成了兩個,所以稱為腦裂現象。

這個時候如果有寫請求到達原來的主庫,新的主庫就沒有這部分數據了。等到主從切換完成之後,哨兵會讓原來的主庫會執行slave of命令,進而觸發和新主庫的全量同步,最終導致主從切換期間源主庫接收的數據丟失了。

如何避免腦裂問題?

為了避免以上問題,最關鍵的就是避免客戶端同時對兩個主庫進行寫。

Redis通過配置,可以支援這樣的功能:如果響應主庫的消息延遲小於或等於M秒的從庫的數量至少有N個,那麼主從才會繼續接寫入。如果響應主庫的消息延遲小於或等於 M 秒的從庫數量少於 N 個,則主庫會停止接受寫入。相關配置:

  • min-slaves-to-write:N 至少要連接的從庫數;
  • min-slaves-max-lag:M 主庫向從庫ping的ACK最大延遲不能超過的秒數

當然,這樣不能保證避免腦裂問題。

場景1

以下場景則可以避免腦裂問題,假設從節點為一個,主觀下線時間和客觀下線時間相差無幾,如下圖:

image-20210610083455378

雖然主庫恢復正常之後,還在進行主從切換,由於只有一個從庫,並且延遲超過了min-slaves-max-lag,所以主庫被限制停止接受消息了

場景2

以下場景則不可以避免腦裂問題,假設從節點為一個,主觀下線時間和客觀下線時間相差無幾,如下圖:

image-20210610084921223

雖然主庫恢復正常後,還在進行主從切換,由於只有一個從庫,但是延遲還沒有超過min-slaves-max-lag,所以原主庫可以繼續接收消息,最終導致主從切換完之後,數據丟失。

也就是說,min-slaves-to-write 和 min-slaves-max-lag也不一定能夠避免腦裂問題,只是降低了腦裂的風險。

進一步探討問題的本質?

作為一個分散式系統,節點之間的數據如果要保持強一致性,那麼就需要通過某種分散式一致性協調演算法來實現,而Redis中沒有。

而類似Zookeeper則要求大多數節點都寫成功之後,才能算成功,避免腦裂導致的集群數據不一致。

注意:如果只有一個從庫,設置min-slaves-to-write=1有一定的風險,如果從庫因為某些原因需要暫停服務,那麼主庫也就沒法提供服務了。如果是手工運維導致需要暫停從庫,那麼可以先開啟另一台從庫。

16、切片集群

集群的擴容可以通過垂直擴容(增加集群硬體配置),或者通過水平擴容(分散數據到各個節點)來實現。切片集群屬於水平擴容。

16.1、Redis Cluster方案

從Redis 3.0開始引入了Redis Cluster方案來搭建切片集群。

16.1.2、Redis Cluster原理

Redis Cluster集群的實現原理如下圖所示:

image-20210425223704772

Redis Cluster不使用一致哈希,而是使用一種不同形式的分片,其中每個key從概念上講都是我們稱為哈希槽的一部分

Redis集群中有16384哈希槽,要計算給定key的哈希槽,我們只需對key的CRC16取模16384就可以了。

如上圖,key為wechat,value為」Java架構雜談「的鍵值對,經過計算後,最終得到5598,那麼就會把數據落在編號為5598的這個slot,對應為 192.168.0.1 這個節點。

16.1.3、可以按預期工作的最小集群配置

以下是redis.conf最小的Redis群集配置:

1port 7000  # Redis實例埠
2cluster-enabled yes  # 啟用集群模式
3cluster-config-file nodes.conf  # 該節點配置存儲位置的文件路徑,它不是用戶可編輯的配置文件,而是Redis Cluster節點每次發生更改時都會自動持久保存集群配置的文件
4cluster-node-timeout 5000  # Redis群集節點不可用的最長時間,如果主節點無法訪問的時間超過指定的時間長度,則其主節點將對其進行故障轉移
5appendonly yes  # 開啟AOF

除此之外,最少需要配置3個Redis主節點,最少3個Redis從節點,每個主節點一個從節點,以實現最小的故障轉移機制。

Redis的utils/create-cluster文件是一個Bash腳本,可以運行直接幫我們在本地啟動具有3個主節點和3個從節點的6節點群集。

注意:如果cluster-node-timeout設置的過小,每次主從切換的時候花的時間又比較長,那麼就會導致主從切換不過來,最終導致集群掛掉。為此,cluster-node-timeout不能設置的過小。

如下圖,是一個最小切片集群:

image-20210424180230283

有三個主節點,三個從節點。

其中灰色虛線為節點間的通訊,採用gossip協議。

可以通過create-cluster腳本在本地創建一個這樣的集群:

image-20210424174707187

如上圖,已經創建了6個以集群模式運行的節點。

image-20210424175625164

繼續輸入yes,就會按照配置資訊進行集群的構建分配:

image-20210424175925874

一切準備就緒,現在客戶端就可以連上集群進行數據讀寫了:

 1➜  create-cluster redis-cli -p 30001 -c --raw  # 連接到Redis集群
2127.0.0.1:30001> set wechat "Java架構雜談"  # 添加鍵值對
3OK
4127.0.0.1:30001> set qq "Java架構雜談"  # 添加鍵值對
5-> Redirected to slot [5598] located at 127.0.0.1:30002  # slot不在當前節點,重定向到目標節點
6OK
7127.0.0.1:30002> get wechat
8-> Redirected to slot [410] located at 127.0.0.1:30001
9Java架構雜談
10127.0.0.1:30001> get qq
11-> Redirected to slot [5598] located at 127.0.0.1:30002
12Java架構雜談

注意:-c,開啟reidis cluster模式,連接redis cluster節點時候使用。

為了能讓客戶端能夠在檢索數據時通過Redis伺服器返回的提示,跳轉到正確的的節點,需要在redis-cli命令中添加-c參數。

16.1.4、客戶端獲取集群數據?

上一節的實例,可以知道,我們在客戶端只是連接到了集群中的某一個Redis節點,然後就可以通訊了。

但是想要訪問的key,可能並不在當前連接的節點,於是,Redis提供了兩個用於實現客戶端定位數據的命令:

MOVED和ASK命令

這兩個命令都實現了重定向功能,告知客戶端要訪問的key在哪個Redis節點,客戶端可以通過命令資訊進行重定向訪問。

MOVED命令

我們先來看看MOVED命令,我們做如下操作:

1➜  create-cluster redis-cli -p 30001--raw
2127.0.0.1:30001> set qq "Java架構雜談"
3(error) MOVED 5598 127.0.0.1:30002

可以發現,終端列印了MOVED命令,比提供了重定向的IP和埠,這個是key對應的slot所在的Redis節點地址。我們只要通過集群模式連接Redis伺服器,就可以看到重定向資訊了:

1127.0.0.1:30001> set qq "Java架構雜談"
2-> Redirected to slot [5598] located at 127.0.0.1:30002
3OK

MOVED命令的重定向流程如下:

image-20210425224026115

  1. 客戶端連接到M1,準備執行:set wechat "Java架構雜談"
  2. wechat key執行CRC16取模後,為5598,M1響應MOVED命令,告知客戶端,對應的slot在M2節點;
  3. 客戶端拿到MOVED資訊之後,可以重定向連接到M2節點,在發起set寫命令;
  4. M2寫成功,返回OK。

如果我們使用客戶端的集群模式,就可以看到這個重定向過程了:

1127.0.0.1:30001> set qq "Java架構雜談"
2-> Redirected to slot [5598] located at 127.0.0.1:30002
3OK

一般在實現客戶端的時候,可以在客戶端快取slot與Redis節點的映射關係,當收到MOVED響應的時候,修改快取中的映射關係。這樣,客戶端就可以基於已保存的映射關係,把請求發送到正確的節點上了,避免了重定向,提升請求效率。

ASK命令

如果M2節點的slot a中的key正在遷移到M1節點的slot中,但是還沒有遷移完,這個時候我們的客戶端連接M2節點,請求已經遷移到了M1節點部分的key,M2節點就會響應ASK命令,告知我們這些key已經移走了。如下圖:

image-20210426000351395

  • slot 5642 正在從M2遷移到M1,其中site:{info}:domain已經遷移到M1了;
  • 這個時候連接M2,嘗試執行get site:{info}:domain,則響應ASK,並提示key已經遷移到了M1;
  • 客戶端收到ASK,則連接M1,嘗試獲取數據。

下面可以通過 hash_tag 演示一下遷移一個slot過程中,嘗試get遷移的key,Redis伺服器的響應內容:

 1# 先通過hash_tag往M2節點的5642 slot寫入三個key
2127.0.0.1:30002> set site:{info}:domain itzhai
3OK
4127.0.0.1:30002> set site:{info}:author arthinking
5OK
6127.0.0.1:30002> set site:{info}:wechat Java架構雜談
7OK
8127.0.0.1:30002> keys *{info}*
9site:{info}:author
10site:{info}:wechat
11site:{info}:domain
12127.0.0.1:30002> cluster keyslot *{info}*
135642  # 三個key都在M2節點的5642 slot中
14
15# 查看節點情況
16127.0.0.1:30001> cluster nodes 
1756e4d53f3f6d7dfa831c7dea2ccbdabb6f907209 127.0.0.1:30005@40005 slave 1d18c2134103192c5263db88631322428aac808f 0 1619363985000 2 connected
181d18c2134103192c5263db88631322428aac808f 127.0.0.1:30002@40002 master - 0 1619363984913 2 connected 5461-10922
1946a135012c95707ea39dd7ac6a670100c40ddf45 127.0.0.1:30001@40001 myself,master - 0 1619363984000 1 connected 0-5460 [5642-<-1d18c2134103192c5263db88631322428aac808f]
208ad5106289da3725bb0e8161c678b2939ecdc300 127.0.0.1:30004@40004 slave 46a135012c95707ea39dd7ac6a670100c40ddf45 0 1619363985114 1 connected
2115f867e43d2fd33ee13cb107a479099748337590 127.0.0.1:30003@40003 master - 0 1619363985215 3 connected 10923-16383
22770329c173c7250adff7a4cd30ce31e064ea47fc 127.0.0.1:30006@40006 slave 15f867e43d2fd33ee13cb107a479099748337590 0 1619363985315 3 connected
23# M1節點執行importing,準備接受遷移過來的slot,hash值為M2節點
24127.0.0.1:30001> cluster setslot 5642 importing 1d18c2134103192c5263db88631322428aac808f
25OK
26# M2節點執行migrating,準備把slot遷移出去,hash值為M1節點
27127.0.0.1:30002> cluster setslot 5642 migrating 46a135012c95707ea39dd7ac6a670100c40ddf45
28OK
29# 遷移5642的site:{info}:domain到M1節點
30127.0.0.1:30002> migrate 127.0.0.1 30001 site:{info}:domain 0 10000
31OK
32# 這個時候,再從M2節點請求site:{info}:domain,則響應ASK命令
33127.0.0.1:30002> get site:{info}:domain
34ASK 5642 127.0.0.1:30001
35# 遷移slot中剩餘的key
36127.0.0.1:30002> migrate 127.0.0.1 30001 site:{info}:wechat 0 10000
37OK
38127.0.0.1:30002> migrate 127.0.0.1 30001 site:{info}:author 0 10000
39OK
40# 通知所有主節點,槽已遷移完成
41127.0.0.1:30002> cluster setslot 5642 node 46a135012c95707ea39dd7ac6a670100c40ddf45
42OK
43127.0.0.1:30002>
44➜  create-cluster redis-cli -p 30001 -c --raw
45127.0.0.1:30001> cluster setslot 5642 node 46a135012c95707ea39dd7ac6a670100c40ddf45
46OK
47127.0.0.1:30001>
48➜  create-cluster redis-cli -p 30003 -c --raw
49127.0.0.1:30003> cluster setslot 5642 node 46a135012c95707ea39dd7ac6a670100c40ddf45
50OK
51# 再次在M1節點指向get命令,已經遷移完成,響應MOVED命令
52➜  create-cluster redis-cli -p 30002 --raw
53127.0.0.1:30002> get site:{info}:domain
54MOVED 5642 127.0.0.1:30001
55# 再次查看,發現5642節點已經在M1節點中
56127.0.0.1:30001> cluster nodes
5756e4d53f3f6d7dfa831c7dea2ccbdabb6f907209 127.0.0.1:30005@40005 slave 1d18c2134103192c5263db88631322428aac808f 0 1619365718115 2 connected
581d18c2134103192c5263db88631322428aac808f 127.0.0.1:30002@40002 master - 0 1619365718115 2 connected 5461-5641 5643-10922
5946a135012c95707ea39dd7ac6a670100c40ddf45 127.0.0.1:30001@40001 myself,master - 0 1619365718000 7 connected 0-5460 5642
608ad5106289da3725bb0e8161c678b2939ecdc300 127.0.0.1:30004@40004 slave 46a135012c95707ea39dd7ac6a670100c40ddf45 0 1619365718015 7 connected
6115f867e43d2fd33ee13cb107a479099748337590 127.0.0.1:30003@40003 master - 0 1619365718115 3 connected 10923-16383
62770329c173c7250adff7a4cd30ce31e064ea47fc 127.0.0.1:30006@40006 slave 15f867e43d2fd33ee13cb107a479099748337590 0 1619365718115 3 connected

hash_tag:如果鍵中包含{},那麼集群在計算哈希槽的時候只會使用{}中的內容,而不是整個鍵,{}內的內容稱為hash_tag,這樣就保證了不同的鍵都映射到相同的solot中,這通常用於Redis IO優化。

MOVED和ASK的區別
  • MOVED相當於永久重定向,表示槽已經從一個節點轉移到了另一個節點;
  • ASK表示臨時重定向,表示槽還在遷移過程中,但是要找的key已經遷移到了新節點,可以嘗試到ASK提示的新節點中獲取鍵值對。

16.1.5、Redis Cluster是如何實現故障轉移的

Redis單機模式下是不支援自動故障轉移的,需要Sentinel輔助。而Redis Cluster提供了內置的高可用支援,這一節我們就來看看Redis Cluster是如何通過內置的高可用特性來實現故障轉移的。

我們再來回顧下這張圖:

image-20210424180230283

Redis節點之間的通訊是通過Gossip演算法實現的,Gossip是一個帶冗餘的容錯,最終一致性的演算法。節點之間完全對等,去中心化,而冗餘通訊會對網路頻寬和CPU造成負載,所以也是有一定限制的。如上圖的集群圖灰色線條所示,各個節點都可以互相發送資訊。

Redis在啟動之後,會每間隔100ms執行一次集群的周期性函數clusterCron(),該函數裡面又會調用clusterSendPing函數,用於將隨機選擇的節點資訊加入到ping消息體中並發送出去。

如何判斷主庫下線了?

集群內部使用Gossip協議進行資訊交換,常用的Gossip消息如下:

  • MEET:用於邀請新節點加入集群,接收消息的節點會加入集群,然後新節點就會與集群中的其他節點進行周期性的ping pong消息交換;
  • PING:每個節點都會頻繁的給其他節點發送ping消息,其中包含了自己的狀態和自己維護的集群元數據,和部分其他節點的元數據資訊,用於檢測節點是否在線,以及交換彼此狀態資訊;
  • PONG:接收到meet和ping消息之後,則響應pong消息,結構類似PING消息;節點也可以向集群廣播自己的pong消息,來通知整個集群自身運行狀況;
  • FAIL:節點PING不通某個節點,並且超過規定的時間後,會向集群廣播一個fail消息,告知其他節點。

疑似下線:當節發現某個節沒有在規定的時間內向發送PING消息的節點響應PONG,發送PING消息的節點就會把接收PING消息的節點標註為疑似下線狀態(Probable Fail,Pfail)。

交換資訊,判斷下線狀態:集群節點交換各自掌握的節點狀態資訊,交換之後,如果判斷到超過半數的主節點都將某個主節點A判斷為疑似下線,那麼該主節點A就會標記為下線狀態,並廣播出去,所有接收到廣播消息的節點都會立刻將主節點標記為fail。

疑似下線狀態是有時效性的,如果超過了cluster-node-timeout *2的時間,疑似下線狀態就會被忽略。

如何進行主從切換?

拉票:從節點發現自己所屬的主節點已經下線時,就會向集群廣播消息,請求其他具有投票權的主節點給自己投票;

最終,如果某個節點獲得超過半數主節點的投票,就成功當選為新的主節點,這個時候會開始進行主從切換,

16.1.6、Redis Cluster能支援多大的集群?

我們知道Redis節點之間是通過Gossip來實現通訊的,隨著集群越來越大,內部的通訊開銷也會越來越大。Redis官方給出的Redis Cluster規模上限是1000個實例。更多關於Redis的集群規範:Redis Cluster Specification[26]

而在實際的業務場景中,建議根據業務模組不同,單獨部署不同的Redis分片集群,方便管理。

如果我們把slot映射資訊存儲到第三方存儲系統中,那麼就可以避免Redis Cluster這樣的集群內部網路通訊開銷了,而接下來介紹的Codis方案,則是採用這樣的思路。

16.2、Codis方案

與Redis Cluster不同,Codis是一種中心化的Redis集群解決方案。

Codis是豌豆莢公司使用Go語言開發的一個分散式Redis解決方案。選用Go語言,同時保證了開發效率和應用性能。

對於Redis客戶端來說,連接到Codis Proxy和連接原生的Redis沒有太大的區別,Redis客戶端請求統一由Codis Proxy轉發給實際的Redis實例。

Codis實現了在線動態節點擴容縮容,不停機數據遷移,以及故障自動恢復功能。

中心化:通過一個中間層來訪問目標節點

去中心化:客戶端直接與目標節點進行訪問。

以下是Codis的架構圖:

image-20210615224907815

( 以下各個組件說明來自於Codis官方文檔:Codis 使用文檔[25] )

  • Codis Server:基於 redis-3.2.8 分支開發。增加了額外的數據結構,以支援 slot 有關的操作以及數據遷移指令。具體的修改可以參考文檔 redis 的修改
  • Codis Proxy:客戶端連接的 Redis 代理服務, 實現了 Redis 協議。 客戶端訪問codis proxy時,和訪問原生的Redis實例沒有什麼區別,方便原單機Redis快速遷移至Codis。除部分命令不支援以外(不支援的命令列表),表現的和原生的 Redis 沒有區別(就像 Twemproxy)。
  • 對於同一個業務集群而言,可以同時部署多個 codis-proxy 實例;
  • 不同 codis-proxy 之間由 codis-dashboard 保證狀態同步。
  • Codis Dashboard:集群管理工具,支援 codis-proxy、codis-server 的添加、刪除,以及據遷移等操作。在集群狀態發生改變時,codis-dashboard 維護集群下所有 codis-proxy 的狀態的一致性。
  • 對於同一個業務集群而言,同一個時刻 codis-dashboard 只能有 0個或者1個;
  • 所有對集群的修改都必須通過 codis-dashboard 完成。
  • Codis Admin:集群管理的命令行工具。
  • 可用於控制 codis-proxy、codis-dashboard 狀態以及訪問外部存儲。
  • Codis FE:集群管理介面。
  • 多個集群實例共享可以共享同一個前端展示頁面;
  • 通過配置文件管理後端 codis-dashboard 列表,配置文件可自動更新。
  • Storage:為集群狀態提供外部存儲。
  • 提供 Namespace 概念,不同集群的會按照不同 product name 進行組織;
  • 目前僅提供了 Zookeeper、Etcd、Fs 三種實現,但是提供了抽象的 interface 可自行擴展。

16.2.1、Codis如何分布與讀寫數據?

Codis採用Pre-sharding技術實現數據分片,默認分為1024個slot,通過crc32演算法計算key的hash,然後對hash進行1024取模,得到slot,最終定位到具體的codis-server:

1slotId = crc32(key) % 1024

我們可以讓dashboard自動分配這些slot到各個codis-server中,也可以手動分配。

slot和codis-server的映射關係最終會存儲到Storage中,同時也會快取在codis-proxy中。

以下是Codis執行一個GET請求的處理流程:

image-20210615223543594

16.2.2、Codis如何保證可靠性

從以上架構圖可以看出,codis-group中使用主從集群來保證codis-server的可靠性,通過哨兵監聽,實現codis-server在codis-group內部的主從切換。

16.2.3、Codis如何實現在線擴容

擴容codis-proxy

我們直接啟動新的codis-proxy,然後在codis-dashboard中把proxy加入集群就可以了。每當增加codis-proxy之後,zookeeper上就會有新的訪問列表。客戶端可以從zookeeper中讀取proxy列表,通過特定的負載均衡演算法,把請求發給proxy。

擴容codis-server

首先,啟動新的codis-server,將新的server加入集群,然後把部分數據遷移到新的codis-server。

Codis會逐個遷移slot中的數據。

codis 2.0版本同步遷移性能差,不支援大key遷移。

不過codis 3.x版本中,做了優化,支援slot同步遷移、非同步遷移和並發遷移,對key大小無任何限制,遷移性能大幅度提升。

同步遷移與非同步遷移

  • 同步遷移:數據從源server發送到目標server過程中,源server是阻塞的,無法處理新請求。

  • 非同步遷移:數據發送到目標server之後,就可以處理其他的請求了,並且遷移的數據會被設置為只讀。

當目標server收到數據並保存到本地之後,會發送一個ACK給源server,此時源server才會把遷移的數據刪除掉。

歡迎大家關注我的公眾號Java架構雜談,獲取更多體系化技術文章。

Part Ⅵ. 快取常見問題

image-20210619211426479

17、如何同步快取和資料庫的數據?

技術的出現都是有特定的背景的,我們摸清了技術的發展脈絡,也就能更好的掌握這門技術,也能理解未來的發展趨勢。所以我在Java架構雜談公眾號以及IT宅(itzhai.com)中寫的一些技術文章有可能會順便梳理一下發展脈絡。如:架構演變之路:為何要搞微服務架構?, 三萬長文50+趣圖帶你領悟web編程的內功心法

為了同步快取和資料庫的數據,我們也先來看看傳統的快取策略。常見的有以下幾種更新策略:

17.1、Cache-Aside策略

我們學過作業系統的快取之後,知道無論是LLC還是page cache,我們都不會顯示的去維護它,而是在作業系統內部直接集成了這些快取。

而Redis的快取是獨立於應用程式的,我們要使用Redis快取,必須手動在程式中添加快取操作程式碼,所以我們把這種使用快取的方式叫旁路快取(Cache-Aside)^20

以下是Cache-Aside的查詢數據的圖示:

image-20210601223207726

這也是最常用的快取方法,快取位於側面(Aside),應用程式直接與快取和資料庫通訊。具體交互:

  1. 應用程式首先檢查快取
  2. 如果快取命中,直接返回數據給客戶端
  3. 如果快取沒有命中,則查詢資料庫以讀取數據,並將其返回給客戶端以及存儲在快取中。

一般寫數據流程如下圖:

image-20210601223423460

  1. 更新資料庫中的快取
  2. 讓快取失效

17.1.1、優缺點

優點:

  • Cache-Aside很適合讀取繁重的場景,使用Cache-Aside使系統對快取故障具有彈性,如果快取集群宕機了,系統仍然可以通過直接訪問資料庫來運行。不過響應時間可能變得很慢,在最壞的情況下,資料庫可能會停止工作;
  • 這種方式,快取的數據模型和資料庫中的數據模型可以不同,比如,把資料庫中多張表的數據組合加工之後,再放入快取。

缺點:

  • 使用Cache-Aside的時候,寫數據之後,很容易導致數據不一致。可以給快取設置一個TTL,讓其自動過期,如果要保證數據的實時性,那麼必須手動清除快取。

17.1.2、數據不一致問題

並發寫導致的臟數據

有些寫快取的程式碼會按如下邏輯編寫:

  1. 更新數據
  2. 更新快取

這種方案,可能會因為並發寫導致臟數據,如下圖:

image-20210601230633302

執行緒1設置a為12,執行緒2設置a為14,由於執行順序問題。最終,資料庫中的值是14,而快取中的值是12,導致快取數據和資料庫中的數據不一致。

為此,我們一般不寫完資料庫之後立刻更新快取。

讀寫並發導致的臟數據

即使是寫完數據之後,我們直接刪掉快取,也是有可能導致快取中出現臟數據的,如下圖:

image-20210601231022228

資料庫被執行緒2更新為了12,但是快取最終又載入到了老的值10。

不過這個場景概率很低,因為一方面一般讀操作比寫操作只需快得多,並且另一方面還需要讀操作必須在寫操作之前就進入資料庫查詢,才能導致這種場景的出現。

我們最常用的兜底策略是設置一個快取過期時間,好讓這種極端場景產生的臟數據能夠定時被淘汰。當然,用2PC或者Paxos協議來保證一致性也可以,不過實現起來太複雜了。

17.2、Read/Write Through策略

前面的Cache-Aside需要應用程式參與整個快取和資料庫的同步過程,而Read/Write Throught策略則不需要應用程式參與,而是讓快取自己來代理快取和數據的同步,在應用層看來,後端就是一個單一的存儲介質,至於存儲內部的快取機制,應用層無需關注

就像作業系統的快取,無論是LLC還是page cache,我們都不會顯示的去維護它,而是在作業系統內部直接集成了這些快取。

接下來看看這個策略。

Read-Through

Read-Through,快取與資料庫保持一致,當快取未命中的時候,從資料庫中載入丟失的快取,填充快取,並將其返回給應用程式。載入快取過程對應用透明:

image-20210601233735927

這種策略要保證資料庫和快取中的數據模型必須相同。

Write-Through

Write-Through,當更新數據的時候,如果沒有命中快取,則直接更新資料庫,然後返回。如果命中了快取,則直接更新快取,快取內部觸發自動更新資料庫,都更新完成之後,再返回。

快取和資料庫保持一致,寫入總是通過快取到達資料庫。如下圖:

image-20210601234604320

Write-Back

Write-Back,更新數據的時候,只更新快取,不立刻更新資料庫,而是非同步的批量更新數據到資料庫中。

這個策略與Write-Through相比,寫入的時候,避免了要同步寫資料庫,讓寫入的速度有了很大的提高,但是確定也很明顯:快取和資料庫中的數據不是強一致性的,還有可能會導致數據丟失

image-20210601235127681

Write-Back策略適用於寫入繁重的場景,通過與Read-Through配合使用,可以很好的適用於讀寫都和頻繁的場景。

我們可以稍微來總結一下快取策略的選用:

快取策略 優點 缺點 使用場景
Read-Through 讀效率高 快取和資料庫的數據可能不一致 適合讀取繁重的場景
Write-Through 保證數據一致性,避免快取失效,保證快取數據是最新的 寫效率低 寫入不是很頻繁,對數據一致性要求比較高的場景,如資訊網站,部落格
Write-Back 寫效率高,保證快取數據是最新的 資料庫可能丟數據 適合寫入繁重但是數據可靠性要求不是很嚴格的場景,如評論系統,消息通知系統
Cache-Aside 快取和資料庫的數據模型不要求一致,可根據業務靈活組織,應用不強依賴快取,快取實時性高 編碼複雜,快取與資料庫可能不一致 數據模型複雜的業務系統

Read-Through的快取和資料庫數據不一致解決方案在於寫入策略,只要我們配合合理的寫入策略,就更好的保證快取和資料庫數據的一致性。

在實際的使用場景中,我們會關注使用的快取要不要求實時更新。根據實時性,Redis快取更新可以分為兩種策略:實時策略,非同步策略。這兩種策略都是離不開以上介紹的幾種的快取策略的思想。

實時策略

實時策略,是最常用的快取策略。

類似Cache-Aside策略的實現就是實時策略,

讀取:先從快取讀取數據,如果快取沒有命中,則從資料庫中讀取,讀取到了則放到快取中。如果快取命中,則直接從快取中讀取數據。

寫入:寫入的時候,先把數據寫入到資料庫中,然後在讓快取失效,快取下次讀取的時候,從資料庫中載入數據。

這種方案會存在數據不一致問題,在Cache-Aside策略小節已經有講過了。

對於快取與資料庫的數據一致性要求高的場景,建議使用實時策略;如果對快取與資料庫一致性要求不高,則可以使用非同步策略。接下來講講非同步策略。

非同步策略

所謂的非同步,我們可以做成讀寫都是非同步的:

  • 讀取:當從快取中讀取不到數據的時候,不直接從資料庫載入,而是返回一個fallback數據,然後往消息隊列中放入一個載入數據的消息,通過非同步消費消息去載入數據。可以避免因為快取失效,導致高並發大流量一起請求到資料庫,從而對資料庫造成壓力。

  • 寫入:總是先寫入資料庫或者快取,然後非同步更新另一個:

  • 先更新資料庫:生成消息非同步更新快取,優點:數據持久性可以得到保證,缺點:快取實時性差

  • 先更新快取:非同步刷新快取到資料庫,相當於把快取當成了資料庫在用,優點:完全使用快取,IO效率高,缺點:可能丟數據

定時策略

針對讀寫並發量過大的場景,我們可以進一步降級,定時的把快取中的數據刷到資料庫,可以在快取中對數據進行整合,然後在刷新到資料庫中。

優點:IO效率高,比非同步策略更高,缺點:可能丟數據。

其實MySQL的Change Buffer就是採用非同步策略,同時又使用Redo Log來實現數據的不丟失。進一步閱讀:洞悉MySQL底層架構:遊走在緩衝與磁碟之間[21]

來總結對比下這幾種方案:

策略 優點 缺點 適用場景
實時策略 實時性高 快取更新困難,容易導致數據不一致 金融,交易系統等業務數據實時性要求高,數據可靠性要求高的場景
非同步策略-先更新資料庫 數據持久性可以得到保證 快取實時性差 產品詳情,文章詳情等不要求實時展現,但要求不丟數據
非同步策略-先更新快取 完全使用快取,IO效率高 可能丟數據 評論系統,消息通知
定時策略 IO效率高,比非同步策略更高 比非同步策略-先更新快取更容易丟數據 評論系統,消息通知

其實,在使用實時策略的時候,我們關注的是如何進一步降低丟數據的風險,有兩種處理方式

  • 用2PC或者Paxos協議來保證一致性也可以,不過實現起來太複雜了;
  • 通過各種其他五花八門的騷操作,來進一步降低實時策略丟數據的概率。

降低丟數據的概率的常用措施有哪些?

  • 實時策略場景:

  • 快取雙刪,可以在第二次刪除之前休眠一小段時間。進一步降低了數據不一致的概率,但是也不能避免數據不一致;

  • 增加組件訂閱binglog,完成快取的更新,適合快取結構和資料庫結構一致的場景。如果快取結構複雜,也不好寫成通用的組件;

  • 通過分散式事務,把快取操作和資料庫操作通過一個事務完成,但由於Redis並不支援類似MySQL的事務,所以在分散式處理過程中,還是可能導致其他客戶端讀取到中間數據,導致臟讀問題。

  • 非同步策略場景:

  • 優先修改快取數據,通過隊列非同步寫到請求到資料庫中;(非同步直寫),如果消息隊列可靠,則可以保證數據最終寫入資料庫,

18、快取雪崩

當大量快取數據同時過期,或者Redis實例突然宕機的時候,就會有大量的請求打到DB,這種場景我們稱為快取雪崩。

雪崩這個詞,很形象。想像一下,當快取都擋不住了,一大堆流量湧向資料庫 的場景,像極了當山坡積雪內部的內聚力抗拒不了它所受到的重力拉引,導致向下滑動,引起大量雪體崩塌的場景…

為了應對快取雪崩,建議系統做如下設計。

18.1、服務降級

當Redis宕機的時候,讓非核心的業務暫時返回空數據或者錯誤資訊,從而避免大量請求直接訪問到資料庫。

對於核心業務,我們則可以考慮熔斷限流。

18.2、熔斷限流

基於現有的業務規劃,我們可以做基準測試和容量測試,得到系統在沒有快取的情況下能夠支撐的並發數,這個參數可以作為快取不可用場景下的限流值。

另外,一般在系統出現雪崩的情況,請求響應速度回立刻降下來,通過設置響應的閾值來觸發熔斷,從而避免系統被源源不斷的流量壓垮。可以採用Hystrix或者Sentinel等熔斷限流組件來實現該需求。

18.3、搭建可靠集群

如果我們只有一個主節點,那麼當主節點宕機之後,快取服務就再也不可用使用了,從而導致快取雪崩問題。

如果我們搭建了主從集群,假設主節點宕機了,還可以通過切換主從節點,繼續提供快取服務,而在主從切換期間,可以暫時讓服務降級,從而避免了因快取雪崩導致資料庫面臨過大壓力。

18.4、盡量避免讓key同時過期

如果不是Redis宕機,那麼產生快取雪崩的原因就是大量的key同時過期了,為了避免這種情況發生,建議不要設置大量key在同一時間過期。如果不可以避免,則可以嘗試在過期時間添加合理的隨機數,讓過期時間盡量錯開。

19、快取擊穿

快取擊穿,指的是快取中訪問率很高的的key,因為過期了或者被淘汰了等原因,導致無法從快取中讀取,進而導致大量請求直接打到資料庫,給資料庫帶來巨大的壓力。

image-20210604075734317

我們可以通過如下的措施,避免快取擊穿:

  • 不給此類熱點key設置過期時間,並且淘汰策略設置為volatile,然後通過非同步刷新策略或者定時刷新策略進行更新快取;
  • 在快取快要過期之前,通過分散式鎖,限制放一個執行緒請求後端資料庫進行更新快取。

20、快取穿透

快取穿透則指的是由於快取和資料庫都不存在數據,導致請求永遠不會止步於快取,每次都會打到DB,快取就跟透明的一樣。

  • 這可能會被不懷好意的人利用,大量請求不存在的key頻繁攻擊我們的資料庫;
  • 或者在高並發場景,我們要查詢的數據剛好不在資料庫中,快取中也沒有,也會導致DB壓力增加。比如大促場景,商品突然被誤刪了,或者用戶註冊場景校驗用戶名是否存在的時候,如果快取設計不合理,很可能導致大量請求查庫。

我們可以通過如下的措施,避免快取穿透:

  • 使用Bloom Filter校驗數據是否存在,從而阻擋大部分流量。例如用戶註冊用戶名校驗場景,可以把用戶名存在的狀態在Bloom Filter中 設置為1,這樣就可以快速判斷用戶名是否存在了。
  • 要注意的是:Bloom Filter判定不在的數據一定不存在,存在的數據不一定存在。對於不存在的數據誤判為存在的情況,需要評估業務是否接受這種結果,對於註冊業務來說影響不大。
  • 快取空結果。如果我們要讀取的不僅僅是二狀值,而是一個完整的數據,那麼就可以把空結果也快取起來,從而讓不存在的數據也走快取;
  • 對於惡意請求,我們則可以多從網關層下功夫,比如設置限流避免同一個IP大量請求到伺服器,入參合法性校驗等。

21、快取何時淘汰

當我們的快取空間不夠的時候,還想載入新的數據怎麼辦呢?要麼就載入不了了,要麼就得刪掉一些使用率比較低的快取,騰出空間來載入新的數據到快取中。

那麼,我們的快取應該設置多大比較合理?

21.1、快取應該設置多大?

Wired雜誌的Chris Anderson,創造了術語「長尾」來指代電子商務系統中少量的商品可以構成大部分的銷售額,少量的部落格可以獲得大部分的點擊量,還有一些是不太受歡迎的「長尾巴」,如下圖所示(來源 Hello, Ehcache[22] ):

image-20210602221229194

長尾是冪律分布(Power Law)的一個示例,還有帕累托分布或八二法則(80:20 rule)。

基於八二法則,我們可以分析快取使用效率的一般規律:20% 的對象在 80% 的時間內被使用,剩餘的數據量雖然很大,但是只有20%的訪問量。

我們可以基於八二法則,加上對業務的訪問特徵評估,來預估要分配的快取的大小。

一旦確定下來之後,就可以到redis.conf中進行配置了:

1# maxmemory <bytes>
2maxmemory 1G

21.2、快取何時淘汰?

當Redis佔用的記憶體超過了設置的maxmemory的值的時,Redis將啟用快取淘汰策略來刪除快取中的key,設置淘汰策略配置參數:

1maxmemory-policy noeviction  # 默認的淘汰策略,不刪除任何key,記憶體不足的時候寫入返回錯誤

當記憶體到達上限之後,Redis會選擇什麼數據進行淘汰呢?這個跟設置的淘汰策略有關。可選的淘汰策略:

  • volatile,只針對設置了過期時間的key的淘汰策略
  • volatile-lru:使用近似 LRU(Least Recently Used, 最近最少使用) 驅逐,淘汰帶有過期時間的鍵值對
  • volatile-lfu:使用近似的 LFU(Least Frequently Used, 最不經常使用) 驅逐,淘汰帶有過期時間的鍵值對
  • volatile-random:隨機驅除,淘汰帶有過期時間的鍵值對
  • volatile-ttl:刪除過期時間最近的鍵值對(最小TTL)
  • allkeys,對所有範圍的key有效的淘汰策略
  • allkeys-lru:使用近似 LRU 驅逐,淘汰任何鍵值對
  • allkeys-lfu:使用近似 LFU 驅逐,淘汰任何鍵值對
  • allkeys-random:刪除隨機鍵值對
  • noeviction:不要驅逐任何東西,只是在寫操作時返回一個錯誤。

在記憶體不足的時候,如果一個鍵值對被刪除策略選中了,即使還沒有過期,也會被刪掉。

LRU, Least Recently Used, 最近最少使用淘汰演算法,淘汰最長時間沒有被使用的快取;
LFU, Least Frequently Used, 最不經常使用淘汰演算法,淘汰一段時間內,使用次數最少的快取。

注意:使用上述任何一種策略,當沒有合適的鍵可以驅逐時,Redis 將在需要更多記憶體的寫操作時返回錯誤。

出於效率的考慮,LRU、LFU 和 volatile-ttl 都不是精確演算法,而是使用近似隨機演算法實現的。

Redis中的LRU,LFU演算法是如何實現的?我應該是用哪種策略?

Redis中的LRU演算法

為了實現一個LRU,我們需要一個鏈表,每訪問了鏈表中的某個元素之後,該元素都需要往鏈表表頭移動。

使用鏈表存儲的缺點:

  • 想像一下,假如Redis的key也放入一個這樣的鏈表,大量的key同時被訪問,導致鏈表內部頻繁移動,這極大的降低了Redis的訪問性能;
  • 我們需要存儲鏈表的前繼指針prev,後繼指針next,至少佔用16個位元組,消耗空間比較大。

為此,Redis並沒有按照鏈表的方式實現LRU演算法。

我們在介紹RedisObject的時候,有提到其中有一個lru欄位:

1typedef struct redisObject {
2    unsigned type:4;
3    unsigned encoding:4;
4    unsigned lru:LRU_BITS; /* lru time (relative to server.lruclock) */
5    int refcount;
6    void *ptr;
7} robj;

該欄位用於記錄最近一次的訪問時鐘。這個欄位只使用改了24位(3個位元組)來存儲。

Redis通過隨機取樣獲取取一組數據,放入驅逐池(實際上是一個鏈表)中,池中的key按照空閑時間從小到大排列,最終從池末尾選一個鍵進行刪除,從而把lru值最小的數據淘汰掉;不斷重複這個過程,直到記憶體使用量低於限制。

Redis中的LRU演算法會有什麼問題?

Redis中每次讀取新的數據到記憶體中,都會更新該快取的lru欄位,如果我們一次性讀取了大量不會再被讀取的數據到快取中,而此時剛好要執行快取數據淘汰,那麼這些一次性的數據就會把其他lru欄位比較小,但是使用很頻繁的快取給淘汰掉了,這也稱為快取污染

舉個例子,有家中餐店,裡面有8個顧客正在吃飯,這個時候,突然來了四個不得不接待的顧客,需要騰出4個位置,該如何選擇呢?

image-20210605123402781

在Redis中,則會直接把入店時間最久的4個給淘汰掉,可以發現,我們直接把熟客給打發走了,最終導致丟失了常客,得不償失:

image-20210605123502648

為了避免LRU隊列的快取污染問題,在Redis 4.0版本開始,提供了一種新的淘汰策略:LFU,下面簡單介紹下。

MySQL如何避免LRU隊列被沖刷掉?

MySQL也會有類似的全表掃描導致讀取一次性的數據沖刷LRU隊列的問題。為此,MySQL實現了一種改進版本的LRU演算法,避免全表掃描導致LRU隊列中的最近頻繁使用的數據被沖刷掉。參考:洞悉MySQL底層架構:遊走在緩衝與磁碟之間#3.1.1、緩衝池LRU演算法[21]

Redis中的LFU演算法

在Redis中LFU策略中,會記錄每個鍵值對的訪問次數。當進行淘汰數據的時候,首先會把訪問次數最低的數據淘汰出快取,如果淘汰過程中發現兩個鍵值對訪問次數一樣,那麼再把上一次訪問時間更久的鍵值對給淘汰掉:

訪問次數低的先淘汰,次數相同則淘汰lru比較小的。

舉個例子,還是那個Java架構雜談茶餐廳,來了4個得罪不起的顧客,需要騰出4個位置,使用了Redis的LFU演算法之後,則是騰出這四個:

image-20210605123740351

可以發現,我們優先把總消費次數比較低的顧客給打發走了,3號和8號顧客總消費次數一樣,所以我們把那個進店比較久的8號顧客打發走了。

這樣就可以最大程度的優先保證了熟客的用餐體驗,至於我們在3號和8號顧客之間,選擇打發走8號顧客,是因為可能8號顧客只點了幾個咖喱魚蛋,30秒狼吞虎咽就吃完了呢?也不是沒可能,所以就讓他先出去了。

LFU演算法如何記錄訪問次數?

Redis中的LFU演算法是基於LRU演算法改進的,而Redis 的驚人之處在於它只是重用了 24 位 LRU 時鐘空間,也就是 3 個八位位元組中存儲了訪問計數器和最近一次衰減時間。

在key超過一定時間沒被訪問之後,Redis就會衰減counter的值,這是必要的,否則計數器將收斂到 255 並且永遠不會回歸,這勢必會影響記憶體驅逐機制,具體確定要衰減的方法:

衰減值:

  • 距離上一次衰減分鐘數 = 當前時間 – 最近衰減時間
  • 衰減值 = 距離上一次衰減分鐘數 / lfu_decay_time

其中控制衰減周期的參數:lfu_decay_time,單位為分鐘。

可見,如果要讓key衰減的快點,可以把lfu_decay_time設置的小點。

Redis將這24位分成兩部分,最高兩個位元組用於存儲衰減時間,最低一個位元組用於存儲訪問計數器。

1+----------------+----------------+----------------+
2|        last decay time          |     counter    |
3+----------------+----------------+----------------+

只有8bit來存儲訪問次數,所以裡面存儲的並不是精確數據,而是一個大概的數據。

下面是計數器增加的關鍵程式碼:

 1/* Logarithmically increment a counter. The greater is the current counter value
2 * the less likely is that it gets really implemented. Saturate it at 255. */

3uint8_t LFULogIncr(uint8_t counter) {
4    if (counter == 255return 255;
5    double r = (double)rand()/RAND_MAX;
6    double baseval = counter - LFU_INIT_VAL;
7    if (baseval < 0) baseval = 0;
8    double p = 1.0/(baseval*server.lfu_log_factor+1);
9    if (r < p) counter++;
10    return counter;
11}

可以看出,隨著計數器越來越大,計數器增加的概率越來越小。

為什麼沒有設置過期時間的key被刪除了?

使用了allkeys策略,導致對所有範圍內的key進行淘汰。

Part Ⅶ. 應用場景

image-20210619211510942

既然Redis給我提供了這麼豐富的數據類型,那麼我們就在各種業務場景中用起來吧,接下來我們介紹常見的應用場景。

22、App DAU統計,留存統計 – SET

App每日活躍用戶數,每日留存統計,是一個很常見的需求。在Redis中,我們剛好可以通過SET來記錄所有的用戶,並通過SET提供的各種操作API來實現對比統計。

每日DAU統計例子:

 1# 記錄20210525這天的活躍用戶
2127.0.0.1:6379> SADD user:20210525 10010 10011
32
4127.0.0.1:6379> SADD user:20210525 10010 10086 10090
52
6127.0.0.1:6379> SADD user:20210525 10086 12345 999900 100000
73
8# 可以看到沒有重複的內容
9127.0.0.1:6379> SMEMBERS user:20210525
1010010
1110011
1210086
1310090
1412345
15100000
16999900
17# 記錄20210526這天的活躍用戶
18127.0.0.1:6379> SADD user:20210526 10010 10086
192
20# 通過交集獲取26號的留存
21127.0.0.1:6379> SINTERSTORE result user:20210525 user:20210526
222
23127.0.0.1:6379> SMEMBERS result
2410010
2510086

image-20210524232314123

注意:SET數據類型的並集和交集計算複雜度比較高,如果SET數據量過大,可能會導致操作阻塞,建議此類操作放到單獨的從庫中進行。

23、評論分頁 – ZSET

我們在給具有分頁的評論列表添加快取的時候,由於新的評論一直在入庫,所以分頁的界限也會在變化。如果按照以下得到的分頁結果進行快取:

1select * from t_user order by id desc limit 10 offset 15;

那麼,每當有新的記錄插入表的時候,所有分頁內容都將產生變化,導致所有分頁快取都會失效。

如何避免大量分頁快取失效?

如果是寫評率比較少的場景,那麼我們可以把讀取評率比較高的前幾頁內容給快取起來,每次只觸發更新這幾頁快取即可。

但是如果寫的很頻繁,那麼就需要頻繁的更新這幾頁的內容了,會導致寫操作變重。或者業務需要,前幾十頁的訪問評論都是比較高的場景,有什麼比較好的快取方法呢?

這個時候我們就可以使用Redis中的有序集合來實現分頁快取了:

  • 我們可以給每個評論設置一個權重值,可以是當前時間戳,通過ZADD添加到ZSET中;
  • 然後通過 ZRANGEBYSCORE 按照score進行分頁查找

ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count][17]

Available since 1.0.5.

時間複雜度:O(log(N)+ M),其中N是排序集中的元素數,M是要返回的元素數。如果M為常數(例如,始終要求使用LIMIT限制前10個元素),則可以將其視為O(log(N))。

以下是具體的例子:

 1# 按照評論時間微秒設置每條評論的score
2127.0.0.1:6379> ZADD comments 1621900305499398 '沙發'
3127.0.0.1:6379> ZADD comments 1621900588459950 '板凳'
4127.0.0.1:6379> ZADD comments 1621900628104233 '牛逼'
5127.0.0.1:6379> ZADD comments 1621900661283904 'itzhai.com'
6
7# 按照score排序進行範圍查找,實現分頁效果,查找第一頁
8127.0.0.1:6379> zrangebyscore comments 0 1621901132532229  withscores limit 0 3
9沙發
101621900305499398
11板凳
121621900588459950
13牛逼
141621900628104233
15
16# 查找第二頁,使用上一頁返回的最後一個微秒時間戳作為查找的min參數
17127.0.0.1:6379> zrangebyscore comments (1621900628104233 1621901132532229  withscores limit 0 3
18itzhai.com
191621900661283904

image-20210525083549396

另外,對於更新非常頻繁需要排序的列表,都可以考慮使用ZSET。

24、每日簽到統計 – BitMap

很多網站為了保持用戶的活躍,都會搞簽到活動,每日簽到一次送金幣啥的。

也許你會想到把簽到狀態放到一個HashMap中,標識用戶已簽到,但是隨著用戶數越來越多,我們就要尋找更加節省記憶體的存儲結構了,這個時候,BitMap就派上用場了。

以下是記錄某一天簽到記錄的例子:

 1# 通過 sign:20210525 記錄5月25日的簽到的用戶記錄
2127.0.0.1:6379> SETBIT sign:20210525 10010 1
30
4127.0.0.1:6379> SETBIT sign:20210525 10086 1
50
6127.0.0.1:6379> SETBIT sign:20210525 99980 1
70
8# 統計5月25日的前導記錄
9127.0.0.1:6379> BITCOUNT sign:20210525
103

以下是統計連續兩天簽到的用戶記錄數:

1127.0.0.1:6379> SETBIT sign:20210526 10010 1
20
3# 通過與操作獲取連續兩天都簽到的用戶數
4127.0.0.1:6379> BITOP AND result sign:20210525 sign:20210526
512498
6127.0.0.1:6379> BITCOUNT result
71

image-20210620223402315

如果用戶的標識比較複雜,不能直接作為BitMap的偏移量,或者用戶標識已經超過了Redis的BitMap能夠存儲的範圍,我們可以進一步使用BloomFilter,通過哈希函數去做映射,當然這意味著你需要接受一定範圍內的偏差

254、查找附近的景點 – Geospatial

這種場景,我們很自讓的想到了要用Geospatial功能了。

下面我們初始化一些景點的坐標:

 1127.0.0.1:6379> GEOADD inner-mongolia 119.933018 50.829028 恩和俄羅斯民族鄉
2127.0.0.1:6379> GEOADD inner-mongolia 117.95893 49.493591 呼倫貝爾大草原
3127.0.0.1:6379> GEOADD inner-mongolia 119.766941 49.219447 盧布里西餐廳
4127.0.0.1:6379> GEOADD inner-mongolia 120.346281 47.208699 阿爾山國家森林公園
5127.0.0.1:6379> GEOADD inner-mongolia 120.133019 50.536113 白樺林景區
6127.0.0.1:6379> GEOADD inner-mongolia 117.684185 49.5045 猛獁公園旅遊景區
7127.0.0.1:6379> GEOADD inner-mongolia 119.752034 49.216568 呼倫貝爾古城
8127.0.0.1:6379> GEOADD inner-mongolia 117.671052 49.339278 呼倫湖
9127.0.0.1:6379> GEOADD inner-mongolia 121.481696 50.785409 敖魯古雅使鹿部落景區
10127.0.0.1:6379> GEOADD inner-mongolia 101.087036 41.967675 額濟納胡楊林

現在我們剛從海拉爾機場出來,坐標:119.83751,49.214948,查找附近150公里的景點:

1127.0.0.1:6379> GEORADIUS inner-mongolia 119.83751 49.214948 150 km ASC COUNT 10 WITHDIST
2盧布里西餐廳
35.1516
4呼倫貝爾古城
56.2127
6呼倫貝爾大草原
7139.5841
8白樺林景區
9148.4668

走,玩去~ 🐎

26、消息隊列 – LIST、STREAM

如果要實現一個消息隊列,在Redis 5.0之前,我們可能會想到LIST。在前面我們也提到,通過LPUSH,LPOP,RPUSH,RPOP操作,可以把List當成隊列或者棧來使用。

26.1、使用LIST

使用List實現的一個最簡單的消息隊列模型如下:

image-20210528083742180

消息隊列由Redis的List充當,Provider使用LPUSH命令往消息隊列中推送消息,Consumer使用RPOP從消息隊列中拉取消息。

當然,可以支援多個消費者:

image-20210528083953236

List中的元素一旦出隊列之後,就再也找不到了這會導致消息一旦消費失敗,就會導致消息丟失。為了保證消息的可靠性,我們可以引入多一個備份消息列表。每當執行POP的時候,順便把消息寫入到備份消息隊列中,等待消費者真正的處理完消息之後,再從備份的消息隊列中刪除掉消息:

image-20210529095724953

當然,也可以引入ACK機制,在消息消費完畢之後,再讓消息從隊列中POP出來,但這樣會導致消息不能多個消費者並行消費,必須等到上一個消息處理完,並且發送了ACK之後,才會從List中取出,才能得繼續讀取下一條消息。

但是,使用List作為消息隊列會存在以下問題:

  • ID由客戶端自己生成,需要客戶端另外準備一個唯一ID生成器組件;
  • 不支援消息分組:雖然可以支援多個消費者同時消費同一個消息隊列,但是list這個結構不支援一條消息被多個消費者重複消費;
  • 消息可靠性無法保證,消息可能會丟失,導致未被消費到,雖然可以通過備份消息隊列處理。

為此,在Redis 5.0中,引入了Stream數據類型,非常適合用作消息隊列。

為了進一步提高CPU效率,我們可以使用阻塞式的API,如BRPOPBRPOPLPUSH,這樣可以掛起執行緒,讓執行緒進入等待,避免在隊列還沒有元素的時候反覆的進行網路請求,減少系統資源消耗。

26.2、使用Stream

Stream,抽象日誌類型,存儲起來的數據,不會立刻刪除掉,而是可以傳入一個偏移量進行反覆讀取。

Stream支援一下特性:

  • 自動生成ID,ID順序增長,保證有序性;
  • 支援消息的分組消費,這個特性借鑒了kafka;
  • 支援消息的ACK機制,支援重複讀取消息,不像List中的消息,POP出來之後,就再也找不到了。

使用Stream實現消息隊列的關鍵命令如下:

  • XADD:添加日誌消息
  • XREAD:讀取日誌消息
  • XGROUP:創建分組
  • XREADGROUP:按分組讀取消息
  • XPENDING:檢查待處理消息列表,即每個消費組內消費者已讀取,但是尚未得到確認的消息
  • XACK:用於消費完之後,發送ACK消息給Stream,這個時候消息將從XPENDING中移除
  • XCLAIM:如果某一個客戶端掛了,可以使用此命令,讓其他Consumer主動接管它的pending msg

具體命令使用,參考Part I中的相關內容。

使用Stream實現的消息隊列,我們在講Stream數據類型的時候已經講過了,我們再來回顧一下這張圖:

image-20210603233305059

  • 生產者通過XADD命令往Stream中添加消息:
1XADD articles * title redis author arthinking
  • 默認的,會為每條消息生成一個唯一ID;

  • 通過XGROUP CREATE創建Group分組;

  • 消費者通過XREADGROUP命令消費分組中的消息,一條消息只會被同一個消費分組下的一個消費者消費,不同消費分組可以消費相同的消息

  • 如果XREADGROUP命令沒有指定NOACK選項,那麼默認的會把每個消費分組中被消費者取出的消息放入待處理消息列表中;

  • 消費者消費完消息後,執行XACK命令,把消息從待處理消息列表中刪除。

使用消息隊列需要注意什麼

一般的,我們在使用消息隊列的時候,業務實現盡量不要依賴消息的順序,業務本身做好冪等,最後,要考慮消息可靠性,我們是否需要確保消息不能丟失,如果不能丟失,那麼就要考慮裝滿的消息隊列中間件了。

26.3、可以使用Stream代替消息隊列中間件嗎?

使用Redis的Stream做消息隊列的優勢是,部署簡單,不需要依賴其他第三方組件。

需要注意的是,由於Redis持久化機制會導致丟數據的問題,Stream也可能丟消息。如果需要更加強大的消息隊列,比如,金融業務場景,不允許丟失消息的場景,那麼就得用上專業的消息隊列組件了。

而Redis的Stream則更適用於發簡訊、消息推送等對可靠性要求不高的業務場景。

27、統計不重複的訪客 – HyperLogLog

當然,我們也可以使用BitMap進行統計,但是有沒有一種更加節省記憶體,統計效率更高的方式呢?如果你允許支援一定範圍內的誤差,那麼HyperLogLog就派上用場了。

關於HyperLogLog的原理,我在 Part 1部分已經做了介紹了,如果要用HyperLogLog統計不重複訪客,操作起來很簡單:

1# 往HyperLogLog中記錄訪客資訊
2127.0.0.1:6379> PFADD visitors 10010 10086 itzhai.com arthinking itzhai.com 10010
31
4# 獲取不重複的訪客
5127.0.0.1:6379> PFCOUNT visitors
64

28、如何實現多個Redis命令原子操作

想像以下,我們要執行以下的操作:

1a = GET test;
2a ++;
3SET test a

有沒有辦法保證原子性呢?如果直接這樣順序執行,多執行緒場景下,可能會導致數據錯誤。

為了實現這個功能更,Redis實現了原子操作命令:DECR, INCR

7.1、DECR, INCR

DECR key

時間複雜度: O(1),將存儲在 key 中的數字減一。如果該鍵不存在,則在執行操作前將其設置為 0。如果鍵包含錯誤類型的值或包含不能表示為整數的字元串,則返回錯誤。此操作僅限於 64 位有符號整數。

返回:遞減後key的值。

INCR key

時間複雜度: O(1),將存儲在 key 中的數字增加 1。如果該鍵不存在,則在執行操作前將其設置為 0。如果鍵包含錯誤類型的值或包含不能表示為整數的字元串,則返回錯誤。此操作僅限於 64 位有符號整數。

注意:這是一個字元串操作,因為 Redis 沒有專用的整數類型。存儲在鍵中的字元串被轉換為64 位有符號10進位整數來執行操作。

返回:遞增後key的值。

通過這兩個命令,可以保證整數的原子遞增和遞減。

如果我們要原子執行的是多個Redis命令,那麼如何實現呢。這對這種場景,可以使用Lua腳本來實現。

7.2、Redis中執行Lua腳本實現原子操作

我們先來舉個需要原子執行多個Redis命令的例子,可能例子不是很恰當,不過足以說明沒有原子性執行一批Redis命令導致的問題。

我們需要在Redis中分別記錄4個商品的最後購買人,而在業務邏輯中,4個商品是批量下單更新的,更新完之後,再分別設置了商品的購買人,邏輯如下:

1update product set buyer = Jack where id in(1, 2, 3, 4);
2HSET prod:1 BUYER Jack;
3HSET prod:2 BUYER Jack;
4HSET prod:3 BUYER Jack;
5HSET prod:4 BUYER Jack;

如果此時,有多個執行緒同時對這些商品執行了庫存扣減操作,如果這幾行程式碼不是原子性執行,那麼就可能導致4個商品的最後購買人和product表裡面的不一致了,如下圖:

image-20210605185002879

為了避免這個問題,我們可以通過Lua腳本寫一個批量更新商品最近購買人的腳本:

1local name = ARGV[1];
2for k, v in ipairs(KEYS) do
3  redis.call("hset", v, "BUYER",  name);
4end;

然後直接執行即可

redis-cli -p 6379 -c –eval batch_update.lua prod:1 prod:2 prod:3 prod:4 , arthinking

最終,可以發現,4個商品的BUYER欄位都同時更新了:

1➜  script redis-cli -p 6379 -c --raw
2127.0.0.1:6379> HGET prod:1 BUYER
3arthinking
4127.0.0.1:6379> HGET prod:2 BUYER
5arthinking
6127.0.0.1:6379> HGET prod:3 BUYER
7arthinking
8127.0.0.1:6379> HGET prod:4 BUYER
9arthinking

為了避免每次執行lua腳本,都需要通過網路傳遞腳本到Redis伺服器,我們可以通過SCRIPT LOAD命令把lua腳本載入到Redis中,然後通過EVALSHA命令執行:

1127.0.0.1:6379> script load 'local name = ARGV[1];for k, v in ipairs(KEYS) do redis.call("hset", v, "BUYER",  name); end;'
2526b107ee608d0695e33f34f9358d5a18858400d
3
4127.0.0.1:6379> EVALSHA 526b107ee608d0695e33f34f9358d5a18858400d 2 prod:1 prod:2 itzhai
5
6127.0.0.1:6379> hget prod:1 BUYER
7itzhai
8127.0.0.1:6379> hget prod:2 BUYER
9itzhai

SCRIPT LOAD:將腳本載入到腳本快取中,但不執行它,並返回腳本的SHA1摘要。除非調用SCRIPT FLUSH,否則腳本會一直存在快取中。

EVALSHA:使用該命令,通過腳本的SHA1進行調用腳本。

除了Redis命令的原子操作的場景,我們面臨更多的問題是,在分散式系統中,對業務程式碼中的一組業務需要保證原子性。這個時候,就只能使用分散式鎖了。

29、分散式鎖:如何實現業務原子操作

在一個JVM,如果一組業務操作要確保原子性,我們可以通過JDK提供的各種鎖,如synchronized和ReentrantLock等。

而在一個分散式如果一個業務操作必須要確保原子性,單靠JDK的鎖是無法鎖住的。此時,我們就需要藉助一個共享存儲系統來實現一個分散式鎖。

29.1、可否直接使用資料庫鎖實現分散式鎖?

在並發度不高的場景中,我們可以使用資料庫的行鎖或者間隙鎖來作為分散式鎖,只有獲取到了資料庫鎖的節點才可以繼續往下執行。這資料庫行鎖是悲觀鎖,在其他執行緒獲取不到鎖的情況下,會進入阻塞狀態,如果這種並發競爭度高的話,那麼就會對資料庫性能有開銷了。

總結下資料庫悲觀鎖的優缺點:

優點:

  • 部署成本低,除了資料庫,無需依賴其他組件;
  • 資料庫保證了持久化,可靠性高。

缺點:

  • 如果並發度高,資料庫鎖的性能開銷會增加,並且導致佔用大量資料庫連接,可能導致資料庫連接池耗盡。

為了避免對資料庫連接池造成影響,我們可以通過其他方式實現分散式鎖,Redis就可以用來實現分散式鎖。

29.2、如何用Redis實現一把單機版的分散式鎖

Redis中的SETNX命令可以在設置key的時候,同時返回key是否已經存在,這有點像上鎖,判斷鎖是否已經存在的場景。

SETNX命令

如果不存在,則設置key為保持字元串,並返回1;

當已經持有一個值時,不執行任何操作,並返回0。

在Redis 2.6.12之前,可以通過這個命令實現分散式鎖

不過從Redis2.6.12版本開始,可以使用以下更簡單的鎖定原語:

SET key value [EX seconds|PX milliseconds|EXAT timestamp|PXAT milliseconds-timestamp|KEEPTTL] [NX|XX] [GET]

  • NX:僅在不存在的情況下設置key;
  • XX:僅設置已存在的key;
  • GET:返回存儲在key中的舊值,或者當key不存在時,返回nil。

如果是實現單Redis實例的分散式鎖,則可以通過使用SET命令來實現。

獲取鎖

1// 客戶端client_01嘗試獲取分散式鎖distributed_lock,鎖的過期時間為10秒
2SET distributed_lock client_01 PX 10000 NX

釋放鎖

釋放鎖的時候,為了避免勿刪其他客戶端的鎖,我們需要先判斷當前鎖的持有者,如果當前鎖的持有者為當前客戶端,才可以發起釋放鎖,我們為了保證執行的原子性,這裡用lua腳本來實現,release_lock.lua:

1if redis.call("get",KEYS[1]) == ARGV[1then
2    return redis.call("del",KEYS[1])
3else
4    return 0
5end

執行如下命令進行釋放鎖:

1➜  script redis-cli -p 6379 -c --eval release_lock.lua distributed_lock , client_01
2(integer) 1

如果我們這個時候,在另一個客戶端client_02執行釋放鎖,那麼將返回0,表示沒有釋放掉鎖,因為該鎖不屬於client_02:

1# 獲取鎖
2➜  script redis-cli -p 6379 -c --raw
3127.0.0.1:6379> SET distributed_lock client_01 PX 1000000 NX
4OK
5
6# 嘗試用另一個客戶端釋放鎖
7127.0.0.1:6379>
8➜  script redis-cli -p 6379 -c --eval del_lock.lua distributed_lock , client_03
9(integer) 0

續命鎖

細心的同學應該會看到,我在上面的例子中,給鎖設置了10秒的過期時間。

那麼問題來了:要是10秒內,業務沒有執行完畢,而此時,鎖有過期了,不是會被其他執行緒獲取到鎖就立刻開始執行業務了嗎?

為了避免這種情況,我們需要給即將過期的鎖進行續命。

如果我們的鎖超時時間為10秒,那麼我們可以在獲取到鎖之後,開啟一個非同步執行緒,設置一個間隔時間(10秒內)定時重新給鎖過期時間為10秒後,直到業務執行完畢,然後再非同步執行緒終止操作,再釋放鎖。這樣就可以保證業務執行過程中,鎖都不會過期了。

以上是實現單Redis實例的分散式鎖。不過單Redis實例的分散式鎖具有單點故障的風險,為了增加分散式鎖的可靠性,我們需要實現多Redis節點的分散式鎖。

29.3、如何用Redis實現一把集群版的分散式鎖

Redis的作者設計了用於實現多節點的Redis分散式鎖演算法:Redlock,並推薦使用該演算法實現分散式。

該演算法的關鍵思路是:讓客戶端依次向多個Redis節點請求加鎖,如果能夠獲得半數以上的實例的鎖,那麼就表示獲取鎖成功。否則表示加鎖失敗。

加鎖失敗的情況下,需要向所有Redis節點發起釋放鎖的請求。

如果獲取鎖的過程消耗的時間超過了鎖的有效時間,那麼也算加鎖失敗。

可用的Redis分散式實現:

更多關於Redis分散式鎖的相關內容,參考:Distributed locks with Redis[24]

29.4、通過lua腳本執行多個Redis命令,原子性一定可以得到保證嗎?

Redis作為記憶體資料庫,其「事務」與大多數人認為的經典 DBMS 中的事務完全不同

29.4.1、Redis事務與MySQL事務有何不同?

Redis並沒有類似MySQL的redolog基於WAL機制去實現原子性和持久性,通過undolog的MVCC支援隔離性,從而避免幻讀。

雖然Redis有MULTI和EXEC命令配合使用來實現多個操作的事務塊,但並不能實現MySQL那樣的ACID事務特性。為了保證原子性,MULTI/EXEC 塊將 Redis 命令的執行延遲到 EXEC。所以客戶端只會把命令命令堆在記憶體的一個指令隊列里,直到執行EXEC的時候,一起執行所有的命令,從而實現原子性。

image-20210607084102538

把指令記錄到指令隊列過程中,如果檢測出語法有錯誤的命令,這種情況下執行EXEC命令會丟棄事務,原子性可以得到保證。

如果Redis事務塊執行過程中部分命令報錯報錯之後,數據是不會回滾的。此時原子性得不到保證

例如,如果指令語法沒問題,只是操作的類型不匹配,是檢測不出來的,實際執行EXEC的時候,會正確執行沒問題的指令,有問題的指令報錯,導致事務塊的原子性不能得到保證:

 1127.0.0.1:6379> MULTI
2OK
3127.0.0.1:6379(TX)> SET str1 itzhai
4QUEUED
5# 這裡實際執行的時候應該報錯
6127.0.0.1:6379(TX)> LPUSH str1 com
7QUEUED
8127.0.0.1:6379(TX)> SET str2 arthinking
9QUEUED
10127.0.0.1:6379(TX)> EXEC
11OK
12WRONGTYPE Operation against a key holding the wrong kind of value
13
14OK
15127.0.0.1:6379> GET str1
16itzhai
17# 可以發現雖然執行過程中報錯了,但是str2也成功設置了值
18127.0.0.1:6379> GET str2
19arthinking

另外,執行事務塊的過程中,是不會觸發執行RDB的,所以事務命令操作不會保存到RDB中。但是會記錄到AOF文件中。

如果事務執行過程中異常停機,導致AOF文件出錯,此時可以使用redis-check-aof對原來的 AOF 文件進行修復,清除事務中已完成的操作,進而再啟動redis。這種情況,原子性是可以得到保證的。

既然談到了原子性,我們再來看看Redis事務如何才能實現隔離性。

29.4.2、如何保證Redis的隔離性?

隔離性:不同事務先後提交,最終的執行效果是串列的,也就是在執行過程中,事務能感知到數據的變化只有是自己操作引起的,不會因為其他事務操作導致感知到數據變化。

在MySQL的InnoDB引擎的可重複讀隔離級別中,為了避免幻讀,引入了間隙鎖,為了避免不可重複讀,引入了MVCC。

當需要修改數據的時候,會採用當前讀模式,鎖定需要修改的記錄,從而避免多個事務同時更新同一條記錄導致的並發過程中數據被覆蓋,不能得到預期的執行結果。

而Redis中修改數據是不會鎖定需要修改的記錄的,並沒有MySQL的當前讀機制。

當前讀和快照讀

當前讀:讀取記錄的最新版本,並且讀取時要保證其他並發事務不能修改當前記錄,為此會對讀取的記錄進行加鎖。

可以使用SELECT … LOCK IN SHARE MODE、SELECT … FOR UPDATE、UPDATE、DELETE、INSERT、操作實現當前讀。本質上都是在這些操作的過程中,先請求獲取鎖。

快照度:讀取的是快照版本,也就是歷史版本,通過MVCC + undo log實現。

為了保證隔離性,就需要通過其他方式來實現了。Redis是通過WATCH命令來實現MySQL的當前讀機制的。

與MySQL的事前鎖定記錄不同,Redis採用的是事後通知記錄變更進而取消需要當前讀的操作。

WATCH命令:
Redis Watch 命令用於監視一個(或多個) key ,如果在事務執行之前這個(或這些) key 被其他命令所改動,那麼事務將被打斷。

以下是通過WATCH命令保證隔離性的例子:

image-20210608081441618

 

Part Ⅷ. 好用的Redis性能分析與監控工具

image-20210619211600933

30、Redis性能分析與監控工具

30.1、Redis監控工具

info命令

最常用的就是Redis內置的info命令了。通過info命令,可以列印有關Redis伺服器的指標和資訊。

輸出的資訊分為以下10組:server, client, memory, persistence, stats, replication, cpu, commandstats, cluster, keyspace。

啟用慢日誌[28]

Redis 慢日誌是一種記錄超過指定執行時間的查詢的系統。執行時間不包括與客戶端通訊、發送回復等 I/O 操作,而只是實際執行命令所需的時間(這是命令執行的唯一階段,該階段Redis執行緒會被阻塞)。

可以通過編輯redis.conf或在伺服器運行時使用 CONFIG GET 和 CONFIG SET 命令來完成配置。

如,可以通過以下命令設置慢日誌監控參數:

 1# 命令執行超過5毫秒則記錄為慢日誌,注意,設置為負數會禁用慢日誌,設置為0會強制記錄每個命令
2127.0.0.1:6379> CONFIG SET slowlog-log-slower-than 5000
3OK
4# 只保留最近1000條慢日誌
5127.0.0.1:6379> CONFIG SET slowlog-max-len 1000
6OK
7# 查看慢日誌
8127.0.0.1:6379> slowlog get 2
91) 1) (integer) 14
10   2) (integer) 1309448221
11   3) (integer) 15
12   4) 1) "ping"
132) 1) (integer) 13
14   2) (integer) 1309448128
15   3) (integer) 30
16   4) 1) "slowlog"
17      2) "get"
18      3) "100"

redis-stat

Redis-stat是一個Redis指標可視化監控工具,採用ruby開發,基於Redis的info命令來統計,不影響Redis性能。

//github.com/junegunn/redis-stat

RedisLive

RedisLive是一個採用Python開發的Redis可視化及查詢分析工具。

//github.com/nkrode/RedisLive

redis_exporter

如果您正在使用Prometheus,則可以使用這個工具。redis_exporter是為Prometheus提供的Redis指標監控exporter,支援Redis 2.x, 3.x, 4.x, 5.x, and 6.x。

//github.com/oliver006/redis_exporter

redmon

簡單的基於 sinatra 的 redis 儀錶板,提供了用於管理 redis 的 Web 介面:cli、admin,同時能夠實時監控Redis。

//github.com/steelThread/redmon

接下來介紹其中最常用的性能指標[27]

30.2、Redis性能監控常用指標

30.2.1、used_memory

used_memory:給Redis分配的記憶體總位元組數

used_memory_human:以更易讀懂的格式給出相同的值

用於判斷是否由於記憶體交換引起的性能問題

如果used_memory超過了作業系統總可用記憶體,作業系統將開始進行記憶體交換,舊的記憶體部分將被寫入磁碟,從磁碟寫讀寫數據的速度比從記憶體中讀寫數據的速度慢了5個數量級,如果記憶體交換髮生在Redis進程上,Redis的性能以及依賴Redis的任何應用程式性能將受到嚴重的影響。

跟蹤記憶體使用情況,以解決性能問題

如果沒有開啟RDB,那麼Redis可以使用的記憶體總量超過可用記憶體的95%。

如果開啟了RDB,那麼如果Redis使用超過45%的可用記憶體,就會變得有風險了。當然,這個比例也不是絕對的,如果Redis實例寫比讀的執行頻率高很多,則需要儘可能保證這個比例。如果讀比寫頻率高很多,那可以適當調高這個比例。

如何減少Redis記憶體佔用?
  • 如果規劃要存儲的數據小於4GB,請使用32位的Redis實例,因為32位的指針大小是64位的一半,其記憶體佔用小多了;
  • 為鍵設置過期時間;
  • 通過將maxmemory設置為可用記憶體的45%(未開啟RDB則為95%),並通過選擇volatile-ttl或者allkey-lru等作為驅逐策略,如非必要,避免使用noeviction,可用嚴格限制Redis的記憶體使用,以確保不會產生記憶體交換。

30.2.2、total_commands_processed

處理的命令數,可以通過該指標來診斷延遲。

例如,如果處理的命令激增,並且導致Redis處理不過來,那麼就會導致Redis響應變慢,該指標值突然增加;或者如果正在執行一個複雜度很高的命令,導致Redis被阻塞住,那麼同樣會導致Redis響應變慢,該指標值下降或者停滯了。

可以設置一個腳本來定期記錄total_commands_processed指標,並測量延遲。如果觀測到指標相比之前有所增加,可能是突然接受了一大批命令,並且在排隊等待執行,此時可能導致Redis處理不過來導致響應延遲;如果觀測到指標相比之前有所減少,可能是有幾個阻塞系統的慢命令在執行。

如何解決大批命令排隊或者慢命令導致的延遲問題?
  • 通過使用多參數命令,如MGET,MSET,LPUSH, RPUSH等,把一批數據一次性傳遞給Redis,而不是循環一個一個命令的傳遞,這可以減少命令數量;
  • 使用pipeline,將多個命令一起發送給Redis,這樣可以減少由於網路開銷導致的延遲;
  • 避免使用bigkey,避免使用複雜度高的命令;

30.2.3、Latency

Latency,延遲,衡量Redis伺服器響應所需的平均時間,次指標無法通過Redis info命令獲得,可以通過以下命令得到:

1➜  ~ redis-cli -p 6379 --latency
2min: 0, max: 2, avg: 0.20 (8293 samples)
如何診斷和解決Redis延遲問題?

一旦您確定延遲是一個問題,可以採用以下措施來診斷和解決相關性能問題:

  • 使用慢日誌識別慢命令:通過查看Redis慢日誌,可以快速識別超過用戶指定執行時間的命令;

  • 1➜  ~ redis-cli -p 6379
    2127.0.0.1:6379> slowlog get
    3(empty array)
    4# 通過以下命令設置慢日誌的閾值
    5127.0.0.1:6379> config set slowlog-log-slower-than 5000
    6OK
  • 監控客戶端連接:由於Redis是單執行緒的,隨著客戶端連接數量增加,分配給每個客戶端的資源百分比就會減少,導致每個客戶端花費越來越多的時間等待Redis伺服器執行操作和響應。監控客戶端連接數很重要,因為應用程式可能無法有效關閉未使用的連接:

  •  1127.0.0.1:6379> info clients
    2# Clients
    3connected_clients:3
    4cluster_connections:0
    5maxclients:10000
    6client_recent_max_input_buffer:48
    7client_recent_max_output_buffer:0
    8blocked_clients:0
    9tracking_clients:0
    10clients_in_timeout_table:0
  • 限制客戶端連接:Redis 2.6以及更高版本可以在redis.conf中或者redis-cli中配置Redis伺服器的最大連接數maxclients,你應該設置maxclients為預期峰值的110~150%;

  • 改善記憶體管理:需要重點監控記憶體是否夠用。

30.2.4、Fragmentation Ratio

碎片率,可以通過mem_fragmentation_ratio指標得到記憶體碎片率。

mem_fragmentation_ratio = used memory rss / used memory

used_memory_rss:作業系統分配給Redis實例的記憶體大小,表示Redis進程佔用的物理記憶體大小;

used_memory:Redis使用其分配器分配的記憶體大小。

什麼時候碎片化嚴重?

mem_fragmentation_ratio < 1 表示Redis記憶體分配超出了物理記憶體,作業系統正在進行記憶體交換;

mem_fragmentation_ratio > 1 合理的指標值;

mem_fragmentation_ratio > 1.5 表示Redis消耗了實際需要物理記憶體的150%以上,其中50%是記憶體碎片率。

如果這個值超過1.5(50%)了,說明碎片化還是比較嚴重的,需要進行優化了。

30.2.5、Evictions

evicted_keys 指標給出了已被驅逐(刪除)的key數量。

如果evicted_keys增長比較快,說明部分CPU用來回收key了。

您可以通過將 maxmemory-policy 設置為 volatile-lru 或 volatile-ttl 等選擇不同的驅逐演算法。

如果您的 evicted_keys 始終高於0,可能會導致命令延遲的增加,因為此時Redis除了處理客戶端命令請求之外,還需要處理頻繁的驅逐工作。但是驅逐並不像記憶體交換那樣對性能具有更大的殺傷力。

如何減少Redis驅逐的影響?

這裡有兩種方法可以減少Redis的驅逐次數:

  • 增加maxmemory限制,但是要注意,不要讓記憶體不足導致進行記憶體交換;
  • 對您的實例進行分區。

30.2.6、其他更多指標[29]

  • keyspace_misses:key值查找未命中快取的次數,如果出現次數太多,則要考慮是否有快取穿透問題,或者是否被惡意攻擊;
  • blocked_clients:等待阻塞調用的客戶端數量(BLPOP、BRPOP、BRPOPLPUSH、BLMOVE、BZPOPMIN、BZPOPMAX);
  • instantaneous_ops_per_sec:每秒處理的命令數;
  • total_commands_processed:伺服器處理的命令總數;
  • total_connections_received:伺服器接受的鏈接總數;
  • expired_stale_perc:可能已過期的key的百分比;
  • used_cpu_sys:Redis伺服器消耗的系統CPU,是服務進程所有執行緒的消耗系統CPU之和;
  • used_cpu_sys_main_thread:Redis伺服器主執行緒消耗的系統CPU;
  • used_cpu_user_main_thread:Redis伺服器主執行緒消耗的用戶CPU…
如何快速得到Redis伺服器的性能?

Redis 包含redis-benchmark[30]模擬 N 個客戶端同時發送 M 個查詢執行的運行命令的實用程式。

可以通過redis-benchmark命令對Redis進行性能測試:

./redis-benchmark -c 100 -n 5000

對應為100個連接,執行5000次請求的性能結果。


以上就是Redis相關的內容,由於Redis的思維導圖比較大,所以沒有在文章中放完整的截圖,另外由於文章篇幅比較長,在公眾號上面閱讀比較吃力,有些朋友希望我搞一個PDF出來,方便閱讀,於是我便製作了一個PDF文件。感興趣的朋友可以到Java架構雜談公眾號回復 Redis 獲取思維導圖文件PDF文件

文章裡面涵蓋了Redis的8大部分,5萬+字,100+圖片,雖然跟一般的書籍對比,內容還是太少了,但是如果想快速對Redis有個比較全面的認識,並且大家一個知識體系框架,這個PDF應該足夠了。

一般我會把技術要點以圖片形式展示,並盡量以通俗易懂的文字,對該技術點最值得學習品味的地方進行說明,點到即止,為的就是盡量讓大家看完之後就能夠有比較直觀的認識,和形成更加深刻的記憶。更多的延伸閱讀,大家可以進一步點擊文章中關鍵詞的腳註中的超鏈接,我會儘可能的把官方的文檔,源程式碼,以及作者輸出的第一手資料,或者是其他比較優質的資料內容添加到附錄中。

為啥要寫這麼長的文章?

關注了我的公眾號的朋友可以發現,我的公眾號發表文章並不是很頻繁,只是偶爾會發一篇比較長的文章。

為了保持內容的嚴謹性,我在輸出文章過程中,會同時閱讀相關官方或者比較權威的文檔,盡量形成一個體系,通過發散學習,深入思考進行融匯貫通,最終輸出一個體系的文章,這也是我寫Java架構雜談公眾號的初衷。

如何閱讀這麼長的文章?

我不太鼓勵只是在手機上面看技術文章,因為有些內容要自己親身實踐才真正的懂,並且還可能需要查閱更多的資料。而在電腦端,為了閱讀像Java架構雜談中的長文的時候,你可以安裝一個自動生成文章目錄的瀏覽插件,如Smart TOC等,閱讀效果如下:

當然,也可以直接到我的部落格IT宅(itzhai.com)上面閱讀相關文章,在文章頁面的右側會有完整的文章目錄:

歡迎大家關注我的公眾號Java架構雜談進一步獲取更多的內容,JVM、並發編程、網路編程、存儲數據結構等系列知識,希望能夠幫助大家形成自己的技術知識體系,避免碎片化學習。

References

[1]: redis.io/commands#list

[2]: antirez / sds. Retrieved from //github.com/antirez/sds

[3]: Redis – Strings. Retrieved from //www.tutorialspoint.com/redis/redis_strings.htm

[4]: Redis Keyspace Notifications. Retrieved from //redis.io/topics/notifications

[5]: Replication. Retrieved from //redis.io/topics/replication

[6]: Redis Sentinel Documentation. Retrieved from //redis.io/topics/sentinel

[7]: Bloom Filters – Introduction and Implementation. Retrieved from //www.geeksforgeeks.org/bloom-filters-introduction-and-python-implementation/

[8]: Bloom Filter Calculator. Retrieved from //hur.st/bloomfilter

[9]: RedisBloom Bloom Filter Command Documentation. Retrieved from //oss.redislabs.com/redisbloom/Bloom_Commands/

[10]: Geohash. Retrieved from //en.wikipedia.org/wiki/Geohash

[11]: Redis 中 HyperLogLog 講解. Retrieved from //www.huaweicloud.com/articles/4318da1b4433ab32b21e385dde2247d6.html

[12]: HyperLogLog: A Simple but Powerful Algorithm for Data Scientists. Retrieved from //towardsdatascience.com/hyperloglog-a-simple-but-powerful-algorithm-for-data-scientists-aed50fe47869

[13]: Redis 中 HyperLogLog 的使用場景. Retrieved from //www.cnblogs.com/54chensongxia/p/13803465.html

[14]: RPC11.md | Redis Change Proposals. Retrieved from //github.com/redis/redis-rcp/blob/master/RCP11.md

[15]: Redis Modules: an introduction to the API. Retrieved from //redis.io/topics/modules-intro

[16]: Redis Modules. Retrieved from //redis.io/modules

[17]: ZRANGEBYSCORE. Retrieved from //redis.io/commands/ZRANGEBYSCORE

[18]: NUMA Deep Dive Part 2: System Architecture. Retrieved from //www.staroceans.org/system_architecture.htm

[19]: NUMACTL, taskset and other tools for controlling NUMA accesses notes. Retrieved from //yunmingzhang.wordpress.com/2015/07/22/numactl-notes-and-tutorialnumactl-localalloc-physcpubind04812162024283236404448525660646872768084889296100104108112116120124/

[20]: Caching Strategies and How to Choose the Right One. Retrieved from //codeahoy.com/2017/08/11/caching-strategies-and-how-to-choose-the-right-one/

[21]: 洞悉MySQL底層架構:遊走在緩衝與磁碟之間. Retrieved from ttps://www.itzhai.com/articles/insight-into-the-underlying-architecture-of-mysql-buffer-and-disk.html

[22]: Hello, Ehcache. Retrieved from //www.ehcache.org/documentation/2.8/get-started/introduction.html

[23]: redis/src/evict.c. Retrieved from //github.com/redis/redis/blob/unstable/src/evict.

[24]: Distributed locks with Redis. Retrieved from //redis.io/topics/distlock

[25]: Codis 使用文檔. Retrieved from //github.com/CodisLabs/codis/blob/release3.2/doc/tutorial_zh.md

[26]: Redis Cluster Specification. Retrieved from //redis.io/topics/cluster-spec

[27]: Understanding the Top 5 Redis Performance Metrics. Retrieved from //www.datadoghq.com/pdf/Understanding-the-Top-5-Redis-Performance-Metrics.pdf

[28]: SLOWLOG subcommand [argument]. Retrieved from //redis.io/commands/slowlog

[29]: INFO [section]. Retrieved from //redis.io/commands/INFO

[30]: How fast is Redis?. Retrieved from //redis.io/topics/benchmarks

[31]: 黃健宏. Redis設計與實現. 機械工業出版社

[32]: 蔣德鈞. Redis核心技術與實戰. 極客時間

[33]: redis.io/documentation. Retrieved from //redis.io/documentation

本文作者: arthinking

部落格鏈接: //www.itzhai.com/articles/redis-technology-insider-cache-data-structure-concurrency-clustering-and-algorithm.html

洞悉Redis技術內幕:快取,數據結構,並發,集群與演算法

版權聲明: 版權歸作者所有,未經許可不得轉載,侵權必究!聯繫作者請加公眾號。