洞悉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。

image-20210411123953576

REDIS_ENCODING_EMBSTR

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

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

image-20210411124010720

 

 

REDIS_ENCODING_RAW

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

image-20210411124023165

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

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

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

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

REDIS_ENCODING_EMBSTR 類型的數據,操作之後,變爲REDIS_ENCODING_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

我們執行以下命令:

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

image-20210413075537996

REDIS_ENCODING_HT

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

image-20210413071439026

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

1.2.4、REDIS_SET

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

使用哪一種數據結構:

image-20210619221947770

REDIS_ENCODING_INTSET

執行以下命令:

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

image-20210413070629814

REDIS_ENCODING_HT

執行以下命令:

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

image-20210413072232507

1.2.5、REDIS_ZSET

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

使用哪一種編碼:

image-20210524223853137

REDIS_ENCODING_ZIPLIST

執行以下命令:

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

image-20210413075431085

REDIS_ENCODING_SKIPLIST

執行以下命令:

則會得到一個skiplist編碼的zset,skiplist編碼的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與其他數據類型的區別
特性StreamList, 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編碼。

Decimal0123456789101112131415
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。

https://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:主庫每秒平均生成的命令數據量(寫命令和數據大小的總和)。

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

更多關於集羣數據複製的說明和配置,參考:https://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性能。

https://github.com/junegunn/redis-stat

RedisLive

RedisLive是一個採用Python開發的Redis可視化及查詢分析工具。

https://github.com/nkrode/RedisLive

redis_exporter

如果您正在使用Prometheus,則可以使用這個工具。redis_exporter是爲Prometheus提供的Redis指標監控exporter,支持Redis 2.x, 3.x, 4.x, 5.x, and 6.x。

https://github.com/oliver006/redis_exporter

redmon

簡單的基於 sinatra 的 redis 儀表板,提供了用於管理 redis 的 Web 界面:cli、admin,同時能夠實時監控Redis。

https://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文件

歡迎大家關注我的公衆號Java架構雜談進一步獲取更多的內容,JVM、併發編程、網絡編程、存儲數據結構等系列知識,希望能夠幫助大家形成自己的技術知識體系,避免碎片化學習。

References

[1]: redis.io/commands#list

[2]: antirez / sds. Retrieved from https://github.com/antirez/sds

[3]: Redis - Strings. Retrieved from https://www.tutorialspoint.com/redis/redis_strings.htm

[4]: Redis Keyspace Notifications. Retrieved from https://redis.io/topics/notifications

[5]: Replication. Retrieved from https://redis.io/topics/replication

[6]: Redis Sentinel Documentation. Retrieved from https://redis.io/topics/sentinel

[7]: Bloom Filters – Introduction and Implementation. Retrieved from https://www.geeksforgeeks.org/bloom-filters-introduction-and-python-implementation/

[8]: Bloom Filter Calculator. Retrieved from https://hur.st/bloomfilter

[9]: RedisBloom Bloom Filter Command Documentation. Retrieved from https://oss.redislabs.com/redisbloom/Bloom_Commands/

[10]: Geohash. Retrieved from https://en.wikipedia.org/wiki/Geohash

[11]: Redis 中 HyperLogLog 講解. Retrieved from https://www.huaweicloud.com/articles/4318da1b4433ab32b21e385dde2247d6.html

[12]: HyperLogLog: A Simple but Powerful Algorithm for Data Scientists. Retrieved from https://towardsdatascience.com/hyperloglog-a-simple-but-powerful-algorithm-for-data-scientists-aed50fe47869

[13]: Redis 中 HyperLogLog 的使用場景. Retrieved from https://www.cnblogs.com/54chensongxia/p/13803465.html

[14]: RPC11.md | Redis Change Proposals. Retrieved from https://github.com/redis/redis-rcp/blob/master/RCP11.md

[15]: Redis Modules: an introduction to the API. Retrieved from https://redis.io/topics/modules-intro

[16]: Redis Modules. Retrieved from https://redis.io/modules

[17]: ZRANGEBYSCORE. Retrieved from https://redis.io/commands/ZRANGEBYSCORE

[18]: NUMA Deep Dive Part 2: System Architecture. Retrieved from http://www.staroceans.org/system_architecture.htm

[19]: NUMACTL, taskset and other tools for controlling NUMA accesses notes. Retrieved from https://yunmingzhang.wordpress.com/2015/07/22/numactl-notes-and-tutorialnumactl-localalloc-physcpubind04812162024283236404448525660646872768084889296100104108112116120124/

[20]: Caching Strategies and How to Choose the Right One. Retrieved from https://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 https://www.ehcache.org/documentation/2.8/get-started/introduction.html

[23]: redis/src/evict.c. Retrieved from https://github.com/redis/redis/blob/unstable/src/evict.

[24]: Distributed locks with Redis. Retrieved from https://redis.io/topics/distlock

[25]: Codis 使用文檔. Retrieved from https://github.com/CodisLabs/codis/blob/release3.2/doc/tutorial_zh.md

[26]: Redis Cluster Specification. Retrieved from https://redis.io/topics/cluster-spec

[27]: Understanding the Top 5 Redis Performance Metrics. Retrieved from https://www.datadoghq.com/pdf/Understanding-the-Top-5-Redis-Performance-Metrics.pdf

[28]: SLOWLOG subcommand [argument]. Retrieved from https://redis.io/commands/slowlog

[29]: INFO [section]. Retrieved from https://redis.io/commands/INFO

[30]: How fast is Redis?. Retrieved from https://redis.io/topics/benchmarks

[31]: 黃健宏. Redis設計與實現. 機械工業出版社

[32]: 蔣德鈞. Redis核心技術與實戰. 極客時間

[33]: redis.io/documentation. Retrieved from https://redis.io/documentation

本文作者: arthinking

博客鏈接: https://www.itzhai.com/articles/redis-technology-insider-cache-data-structure-concurrency-clustering-and-algorithm.html

洞悉Redis技術內幕:緩存,數據結構,併發,集羣與算法

版權聲明: 版權歸作者所有,未經許可不得轉載,侵權必究!聯繫作者請加公衆號。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章