备战面试日记(6.1) - (缓存相关.Redis全知识点)
本人本科畢業,21屆畢業生,一年工作經驗,簡歷專業技能如下,現根據簡歷,并根據所學知識復習準備面試。
記錄日期:2022.1.15
大部分知識點只做大致介紹,具體內容根據推薦博文鏈接進行詳細復習。
文章目錄
- Redis
- 數據結構與對象
- 數據類型分類(對象)
- 數據類型概述
- 編碼和底層實現
- 數據結構
- SDS字符串
- SDS定義
- SDS 與 C字符串的區別
- 獲取字符串長度
- 緩沖區溢出
- 內存重分配次數
- 二進制安全
- 兼容< string.h >庫的函數
- 鏈表
- 鏈表定義
- 特性總結
- 字典
- 字典定義
- 哈希沖突
- 哈希算法
- 解決哈希沖突
- rehash
- rehash概述
- rehash條件
- 漸進式hash過程
- 漸進式hash執行期間進行哈希表操作
- 漸進式hash的缺點
- 跳躍表
- 為什么redis選擇了跳躍表而不是紅黑樹?
- 整數集合
- 整數集合定義
- 整數集合升級
- 升級的好處
- 整數集合降級
- 壓縮列表
- 壓縮列表定義
- 列表節點構成
- previous_entry_length
- encoding
- content
- 連鎖更新
- 編碼轉換時機
- 字符串
- int
- raw
- embstr
- 為什么raw和embstr的臨界值是44字節?
- 列表
- ziplist
- linkedlist
- 哈希
- ziplist
- hashtable
- 集合
- intset
- hashtable
- 有序集合
- ziplist
- skiplist
- 持久化
- RDB
- 觸發方式
- 手動觸發
- 自動觸發
- RDB優缺點
- 優點
- 缺點
- AOF
- 執行流程
- 觸發方式
- 手動觸發
- 自動觸發
- AOF文件同步策略
- AOF持久化配置
- 文件事件處理器
- 組成部分
- 處理機制
- 拓展
- 內存淘汰機制
- 內存淘汰策略
- 策略介紹
- noeviction
- volatile-ttl、volatile-random、volatile-lru、volatile-lfu
- allkeys-random、allkeys-lru、allkeys-lfu
- LRU & LFU算法
- LRU
- LRU 篩選邏輯
- Redis 對 LRU 的實現
- LFU
- LFU 篩選邏輯
- LFU 的具體實現
- Redis 對 LFU 的實現
- LFU 中的 counter 值的衰減機制
- 使用總結
- 事務
- 概念
- 事務階段
- 事務錯誤處理
- Watch 監控
- 引入
- watch 命令
- unwatch 命令
- 總結說明
- Redis集群
- 主從復制
- 主從復制架構
- 開啟主從復制方式
- 命令
- 配置
- 啟動命令
- 復制的實現【重點】
- 1. 設置主服務器的地址和端口
- 2. 建立套接字連接
- 3. 發送 PING 命令
- 4. 身份驗證
- 5. 發送端口信息
- 6. 同步
- 7. 命令傳播
- 主從復制優缺點
- 優點
- 缺點
- 總結
- 哨兵模式
- 哨兵模式架構
- 哨兵進程
- 哨兵進程的作用
- 哨兵(Sentinel) 和 一般Redis 的區別?
- 哨兵的工作方式
- 創建連接
- 獲取主服務器信息
- 獲取從服務器信息
- 向主服務器和從服務器發送信息
- 接收來自主服務器和從服務器的頻道信息
- 故障檢測
- 檢測主觀下線
- 檢測客觀下線
- 選舉領頭 Sentinel
- 故障遷移
- 選出新的主服務器
- 修改從服務器的復制目標
- 將舊主服務器變為從服務器
- 集群模式
- 集群模式架構
- 集群數據結構
- 集群連接方式
- 分布式尋址算法【引入】
- hash 算法
- 一致性 hash 算法
- hash 環數據傾斜 & 虛擬節點
- hash slot 算法
- 一致性 hash 算法 和 hash slot 算法的區別?
- 定位規則區別
- 應對熱點緩存區別
- 擴容和縮容區別
- 集群的槽指派
- 指派節點槽信息
- CLUSTER ADDSLOTS 的命令實現
- 傳播節點槽信息
- 記錄集群所有槽的指派信息
- 使用 `clusterState.slots` 和使用 `clusterNode.slots` 保存指派信息相比的好處?
- 集群執行命令
- MOVED 錯誤
- 節點數據庫的實現
- 重新分片(比如在線擴容)
- ASK 錯誤 - (保證集群在線擴容的安全性)
- CLUSTER SETSLOT IMPORTING 命令的實現
- CLUSTER SETSLOT MIGRATING 命令的實現
- ASKING 命令
- 復制和故障轉移
- 設置從節點方式
- 故障檢測
- 故障轉移
- 選舉新的主節點過程
- Redis應用
- Redis 分布式鎖
- 引入
- 為什么需要分布式鎖?
- 什么時候用分布式鎖?
- 分布式鎖需要哪些特性呢?
- 加鎖
- 解鎖
- 續鎖
- 守護線程“續命”存在的問題
- RedLock
- RedLock 算法
- 失敗重試
- RedLock 的問題
Redis
書籍推薦:《Redis的設計與實現》
博客面試文章推薦:全網最硬核 Redis 高頻面試題解析(2021年最新版)
Redis這篇主要要講解的內容包括:數據結構、redis持久化(aof、rdb)、文件事務處理器、redis內存淘汰機制、事務、redis集群(一致性hash等...)、redis分布式鎖都放在Redis的文章里說明。
還有一部分緩存問題,比如緩存設計以及緩存數據一致性、解決方案-緩存雪崩緩存穿透緩存擊穿等另起一篇寫。
數據結構與對象
數據類型分類(對象)
數據類型概述
Redis主要有5種數據類型,包括String,List,Set,Zset,Hash,滿足大部分的使用要求。
| STRING | 字符串、整數或者浮點數 | 對整個字符串或者字符串的其中一部分執行操作;對整數和浮點數執行自增或者自減操作。 | 做簡單的鍵值對緩存 |
| LIST | 列表 | 從兩端壓入或者彈出元素;對單個或者多個元素進行修剪;只保留一個范圍內的元素 | 存儲一些列表型的數據結構,類似粉絲列表、文章的評論列表之類的數據 |
| SET | 無序集合 | 添加、獲取、移除單個元素;檢查一個元素是否存在于集合中;計算交集、并集、差集;從集合里面隨機獲取元素 | 交集、并集、差集的操作,比如交集,可以把兩個人的粉絲列表整一個交集 |
| HASH | 包含鍵值對的無序散列表 | 添加、獲取、移除單個鍵值對;獲取所有鍵值對;檢查某個鍵是否存在 | 結構化的數據,比如一個對象 |
| ZSET | 有序集合 | 添加、獲取、刪除元素;根據分值范圍或者成員來獲取元素;計算一個鍵的排名 | 去重但可以排序,如獲取排名前幾名的用戶 |
另外還有高級的4種數據類型:
- HyperLogLog:通常用于基數統計。使用少量固定大小的內存,來統計集合中唯一元素的數量。統計結果不是精確值,而是一個帶有0.81%標準差(standard error)的近似值。所以,HyperLogLog適用于一些對于統計結果精確度要求不是特別高的場景,例如網站的UV統計。
- Geo:redis 3.2 版本的新特性。可以將用戶給定的地理位置信息儲存起來, 并對這些信息進行操作:獲取2個位置的距離、根據給定地理位置坐標獲取指定范圍內的地理位置集合。
- Bitmap:位圖。
- Stream:主要用于消息隊列,類似于 kafka,可以認為是 pub/sub 的改進版。提供了消息的持久化和主備復制功能,可以讓任何客戶端訪問任何時刻的數據,并且能記住每一個客戶端的訪問位置,還能保證消息不丟失。
編碼和底層實現
主要是講述上述五種基本類型的底層編碼實現:
| REDIS_STRING | REDIS_ENCODING_INT | 使用整數值來實現的字符串對象 |
| REDIS_STRING | REDIS_ENCODING_EMBSTR | 使用embstr編碼的簡單動態字符串實現的字符串對象 |
| REDIS_STRING | REDIS_ENCODING_RAW | 使用簡單動態字符串實現的字符串對象 |
| REDIS_LIST | REDIS_ENCODING_ZIPLIST | 使用壓縮列表實現的列表對象 |
| REDIS_LIST | REDIS_ENCODING_LINKEDLIST | 使用雙端鏈表實現的列表對象 |
| REDIS_HASH | REDIS_ENCODING_ZIPLIST | 使用壓縮列表實現的哈希對象 |
| REDIS_HASH | REDIS_ENCODING_HT | 使用字典實現的哈希對象 |
| REDIS_SET | REDIS_ENCODING_INTSET | 使用整數集合實現的集合對象 |
| REDIS_SET | REDIS_ENCODING_HT | 使用字典實現的集合對象 |
| REDIS_ZSET | REDIS_ENCODING_ZIPLIST | 使用壓縮列表實現的有序集合對象 |
| REDIS_ZSET | REDIS_ENCODING_SKIPLIST | 使用跳躍表和字典實現的有序集合對象 |
參考《Redis設計與實現》第一部分 數據結構與對象 的 第八章 對象,p63。
通過上面的整理我們就可以知道他們的具體編碼實現了,整理如下:
- String:SDS
- list:壓縮列表、雙向鏈表。
- hash:壓縮列表、字典。
- set:整數集合、字典。
- zset:壓縮列表、跳表。
在Redis中我們可以通過 OBJECT ENCODING命令來查看一個數據庫鍵的值對象的編碼:
redis> SET msg "hello world" OK redis> OBJECT ENCODING msg "embstr"關于他們具體在什么時候使用什么編碼格式,我們在下文詳細說明!
數據結構
主要說明七種對象:簡單動態字符串、鏈表、字典、跳躍表、整數集合、壓縮列表。
SDS字符串
簡單動態字符串(SDS),用作Redis的默認字符串表示。
SDS定義
每個 sds.h/sdshdr 結構標識一個SDS值:
struct sdshdr {int len; // 記錄buf數組中已使用的字節數量,等于SDS所保存字符串的長度int free; // 記錄buf數組中未使用的字節數量char buf[]; // 字節數組,用于保存字符串 }tip:buf數組最后一個字節會用來保存’/0’,這也是遵循C字符串以空字符結尾的慣例,但是這個字符不會被計算在len長度中。
遵循的好處就是它可以直接重用一部分C字符串函數庫里面的函數。
SDS 與 C字符串的區別
如果一張表來說明,即:
| 獲取字符串長度的復雜度為O(N) | 獲取字符串長度的復雜度為O(1) |
| API是不安全的,可能會造成緩沖區溢出 | API是安全的,不會造成緩沖區溢出 |
| 修改字符串長度N次必然需要執行N次內存重分配 | 修改字符串長度N次最多需要執行N次內存重分配 |
| 只能保存文本數據 | 可以保存文本數據或者二進制數據 |
| 可以使用所有的<string.h>庫中的函數 | 可以使用一部分<string.h>庫中的函數 |
那我們根據這五點來說明,這五大區別的產生原因:
獲取字符串長度
原因如下:
- C字符串必須遍歷字符串直到碰到結尾的空字符為止,復雜度為O(N)。
- SDS字符串在len屬性中記錄了SDS本身的長度,復雜度為O(1)。
其中SDS長度的設置與更新是由SDS的API執行時自動完成的。
緩沖區溢出
因為C字符串沒有記錄字符串長度,所以如果使用如下方法:
char *strcat(char *dest, const char *src);當開發者已經為 dest 字符串分配了一定的內存,此時如果 src 字符串中內容拼接進去后的內存大于分配的內存,則會造成緩沖區溢出。
那么SDS字符串是如何解決的呢?
當 SDS API 需要對 SDS 進行修改時,API 會先檢查 SDS 的空間是否滿足所需的要求,如果不滿足的話,API 會自動將 SDS 的空間擴展至執行修改所需的大小,然后才執行實際的修改操作,所以使用 SDS 既不需要后動修改 SDS 的空間大小,也不會出現C字符串中的緩沖區溢出問題。
內存重分配次數
因為C字符串的底層實現總是 N + 1 個字符串長度的數組。所以每次執行 增長字符串 或是 縮短字符串時,都要先通過重分配擴展底層數組的空間大小 或是 釋放字符串不再使用的空間,來防止緩沖區溢出 或者 內存泄漏。
那么SDS字符串是如何解決的呢?
SDS中使用free屬性記錄未使用空間的字節數量。
通過未使用的空間,SDS 實現了 空間預分配 和 惰性空間釋放 兩種優化策略。
空間預分配的操作是:當 SDS 的 API 對一個 SDS 進行修改,并且需要對 SDS 進行空間擴展的時候,程序不僅會為 SDS 分配修改所必須要的空間,還會為 SDS 分配額外的未使用空間。
這里存在兩種修改情況:
惰性空間釋放的操作是:當 SDS 的 API 對 一個 SDS 進行修改,并且需要對 SDS 所保存的字符串進行縮短時,程序并不立即使用內存重分配來回收縮短后多出來的字節,而是使用 free屬性 將這些字節的數量記錄起來,并等待將來使用。
當然,如果需要真正地釋放 SDS 的未使用空間,會有 API 去實現,這里不說明。
二進制安全
C字符串的字符必須符合某種編碼(比如ASCII),并且除了末尾空字符外,不能包含任何空字符,否則會被程序誤認為是末尾,這使得C字符串只能保存文本數據,而不能保存二進制數據。
那么SDS字符串是如何解決的呢?
SDS 的 API 都是二進制安全的,所有的 SDS API 都會以處理二進制的方式來處理 SDS 存放的 buf數組 里的數據。
所以SDS 的 buf屬性被稱為字節數組,就是因為它是用來保存一系列二進制數據。
兼容< string.h >庫的函數
上面說過了,SDS 也遵循C字符串以空字符結尾的慣例,就是為了能讓它使用部分<string.h>庫的函數。
鏈表
鏈表定義
每個鏈表節點使用一個 adlist.h/listNode 結構來表示:
typedef struct listNode {struct listNode *prev; // 前置指針struct listNode *next; // 后置指針void *value; // 節點的值 }說明該鏈表是一個雙向鏈表。
當我們使用多個 listNode 組成鏈表,就會直接使用 adlist.h/list 來持有該鏈表進行操作:
typedef struct list {listNode *head; // 表頭節點listNode *tail; // 表尾節點unsigned long len; // 鏈表所包含的節點數量void *(*dup) (void *ptr); // 節點值復制函數void *(*free) (void *ptr); // 節點值釋放函數int (*match) (void *ptr, void *key); // 節點值對比函數 }特性總結
- 雙端:節點有 prev 和 next 指針,復雜度為O(1)。
- 無環:對鏈表的訪問都是以NULL為終點。
- 帶頭尾指針:list 中有head 和 tail 指針,復雜度為O(1)。
- 帶鏈表長度計數屬性:len屬性保存節點數,復雜度為O(1)。
- 多態:使用 void*指針保存節點值,可以保存不同類型的值。
字典
即數組 + 鏈表實現。
字典定義
Redis 字典所使用的哈希表由 dict.h/dictht 結構定義:
typedef struct dictht {dictEntry **table; // 哈希表數組unsigned long size; // 哈希表大小unsigned long sizemask; // 哈希表大小掩碼,用于計算索引值,總是等于 size - 1unsigned long used; // 哈希表已有節點數量 }哈希表節點使用 dictEntry 結構表示,每個 dictEntry 結構都保存著一個kv對:
typedef struct dictEntry {void *key; // 鍵union { // 值void *val;uint64_t u64;uint64_t s64;}struct dictEntry *next; // 指向下個哈希表節點,形成鏈表 }Redis 中的字典由 dict.h/dict 結構表示:
typedef struct dict {dictType *type; // 類型特定函數void *privdata; // 私有數據dictht ht[2]; // 哈希表int trehashidx; // rehash索引,當rehash不在進行時,值為1 }哈希沖突
哈希算法
在添加新的鍵值到字典里是,要先進行對key的哈希,根據哈希值計算出索引值,根據索引將新的kv對放到哈希表數組的指定索引上。
index = hash&dict -> ht[0].sizemaskRedis 使用 MurmurHash 算法。
解決哈希沖突
Redis 的哈希表使用鏈地址法解決哈希沖突,并且使用的是頭插法。
rehash
hash 對象在擴容時使用了一種叫 “漸進式 rehash” 的方式。
rehash概述
擴展和收縮哈希表的工作都是通過執行 rehash 來完成的。
reash的步驟如下:
計算新表(ht[1])的空間大小,取決于舊表(ht[0])當前包含的鍵值以及數量。
將保存在舊表(ht[0])的所有鍵值rehash到新表(ht[1])上。
當舊表(ht[0])全部遷移完成后,釋放舊表(ht[0]),將新表設置為 ht[0] 并在 ht[1]重新創建一張空白哈希表。
這兩個哈希表的套路是不是有點像jvm運行時數據區的年輕代的幸存者區?可以引申一下。
rehash條件
當下面兩個條件任意一個被滿足時,程序就會自動開始對哈希表進行擴展操作:
為什么這兩個命令的是否正在執行,和服務器執行擴展操作的負載因子并不相同?
答:是因為在執行BGSAVE命令或者BGREWRITEAOF命令的過程中,Redis需要fork子線程,而大多數os都采用與時復制技術來優化子進程的使用效率,所以子進程存在的期間,服務器會提高執行擴展操作所需的負載因子,從而盡可能地避免在子進程存在期間進行哈希擴容,可以避免不必要的內存寫入操作,節約內存。
與時復制:copy-on-write,即不用復制寫入直接引用父進程的物理過程。
BGSAVE命令:fork子進程去完成備份持久化。(區別于SAVE命令,阻塞線程去完成備份持久化)
BGREWRITEAOF命令:異步執行AOF重寫,優化原文件大小(該命令執行失敗不會丟失數據,成功才會真正修改數據,2.4以后手動觸發該命令)
漸進式hash過程
漸進式rehash的詳細步驟:
漸進式hash采取 分而治之 的思想,將rehash鍵值對所需的計算工作均攤到字典的每個添加、刪除、查找、更新操作上,避免集中式hash。
漸進式hash執行期間進行哈希表操作
漸進式hash的缺點
擴容期開始時,會先給 ht[1] 申請空間,所以在整個擴容期間,會同時存在 ht[0] 和 ht[1],會占用額外的空間。
擴容期間同時存在 ht[0] 和 ht[1],查找、刪除、更新等操作有概率需要操作兩張表,耗時會增加。
redis 在內存使用接近 maxmemory 并且有設置驅逐策略的情況下,出現 rehash 會使得內存占用超過 maxmemory,觸發驅逐淘汰操作,導致 master/slave 均有有大量的 key 被驅逐淘汰,從而出現 master/slave 主從不一致。
跳躍表
可以把他理解為一個可以二分查找的鏈表。
它在Redis中只用到過兩處:一是有序集合zset;二是集群節點的內部數據結構。
這塊的實現就不整理,看博客 或者 看書吧,《Redis設計與實現》p38。
參考博客鏈接一:面試準備 – Redis 跳躍表
參考博客鏈接二:Redis中的跳躍表
參考博客鏈接三:跳躍表以及跳躍表在redis中的實現
為什么redis選擇了跳躍表而不是紅黑樹?
- 在做范圍查找的時候,平衡樹比 skiplist 操作要復雜。
- 在平衡樹上,我們找到指定范圍的小值之后,還需要以中序遍歷的順序繼續尋找其它不超過大值的節點。如果不對平衡樹進行一定的改造,這里的中序遍歷并不容易實現。
- 而在 skiplist 上進行范圍查找就非常簡單,只需要在找到小值之后,對第1層鏈表進行若干步的遍歷就可以實現。
- 平衡樹的插入和刪除操作可能引發子樹的調整,邏輯復雜,而 skiplist 的插入和刪除只需要修改相鄰節點的指針,操作簡單又快速。
- 從內存占用上來說,skiplist比平衡樹更靈活一些。
- 平衡樹每個節點包含2個指針(分別指向左右子樹)。
- skiplist 每個節點包含的指針數目平均為1/(1-p),具體取決于參數p的大小。如果像Redis里的實現一樣,取p=1/4,那么平均每個節點包含1.33個指針,比平衡樹更有優勢。
- 查找單個key,skiplist和平衡樹的時間復雜度都為O(log n),大體相當;而哈希表在保持較低的哈希值沖突概率的前提下,查找時間復雜度接近O(1),性能更高一些。所以我們平常使用的各種 Map 或 dictionary 結構,大都是基于哈希表實現的。
- 從算法實現難度上來比較,skiplist 比平衡樹要簡單得多。
整數集合
整數集合定義
每個 intset.h/intset 結構表示一個整數集合:
typedef struct intset {uint32_t encoding; // 編碼方式uint32_t length; // 集合包含的元素數量int8_t contents[]; // 保存元素的數組 }其中 contents[]就是整數集合的底層實現:整數集合的每個元素都是該數組的一個數組項,各個項在數組中是從小到大有序排列,并且不重復。
雖然 contents[] 屬性聲明是 int8_t,但是真正類型取決于 encoding。
整數集合升級
整數升級,即當我們將一個新元素添加到集合中時,新元素的類型比原集合的類型都要長時,整數集合需要升級,然后才能將新元素添加到集合中。
具體升級并添加元素的步驟分為三步:
該過程的復雜度為 O(N)。
升級的好處
整數集合降級
整數集合不支持降級操作!
壓縮列表
它的存在意義就是為了節約內存。
壓縮列表定義
壓縮列表就是一個由一系列特殊編碼的連續內存塊組成的順序型數據結構。
壓縮列表的各個組成部分說明如下表:
| zlbytes | uint32_t | 4字節 | 記錄整個壓縮鏈表占用的字節數,在對壓縮列表進行內存重分配,或者計算zlend的位置時使用。 |
| zltail | uint32_t | 4字節 | 記錄壓縮列表表尾節點距離壓縮列表起始地址有多少個字節:通過這個偏移量,程序無須遍歷整個壓縮列表就可以確定尾節點的地址。 |
| zllen | uint16_t | 2字節 | 記錄了壓縮列表包含的字節數量,該屬性小于UINT16_MAX(65535)時,該值為壓縮列表包含節點的數量;該屬性等于UINT16_MAX(65535)時,節點的真實數量需要遍歷壓縮列表獲得。 |
| entryX | 列表節點 | 不定 | 壓縮列表包含的各個節點,節點的長度由節點保存的內容而定。 |
| zlend | uint8_t | 1字節 | 特殊值0xFF(十進制255),用于標記壓縮列表的末端。 |
列表節點構成
每個壓縮列表節點可以保存一個字節數組或者一個整數值。其中,字節數組可以是以下三種長度之一:
- 長度小于等于63(2^6 - 1)字節的字節數組;
- 長度小于等于16383(2^14 - 1)字節的字節數組;
- 長度小于等于4294967295(2^32 - 1)字節的字節數組;
而整數值則可以是以下六種長度的其中一種:
- 4位長,介于0至12之間的無符號整數;
- 1字節長的有符號;
- 3字節長的有符號整數;
- int16_t類型整數;
- int32_t類型整數;
- int64_t類型整數。
每個壓縮列表節點都由 previous_entry_length、encoding、content三個部分組成:
previous_entry_length
節點的 previous_entry_length 屬性以字節為單位,記錄了壓縮列表中前一個節點的長度。
previous_entry_length 屬性的長度可以是1字節 或者 5字節:
- 如果前一節點的長度小于254字節,那么 previous_entry_length 屬性的長度為1字節:前一節點的長度就保存在這一個字節里面。
- 如果前一節點的長度大于等于254字節,那么 previous_entry_length 屬性的長度為5字節:其中屬性的第一字節會被設置為0xFE(十進制254),而之后的四個字節則用于保存前一節點的長度。
它的好處就是,因為節點的 previous_entry_length 屬性記錄了前一個節點的長度,所以程序可以通過指針運算,根據當前節點的起始地址來計算出前一節點的起始地址。
壓縮列表的從表尾向表頭遍歷操作就是使用這一原理實現的,只要我們擁有一個指向某個節點起始地址的指針,那么通過這個指針以及這個節點的 previous_entry_length 屬性,程序就可以一直向前一個節點回溯,最終到達壓縮列表的表頭節點。
encoding
節點的 encoding 屬性記錄了節點的 content 屬性所保存數據的類型以及長度:
- 1字節、2字節或者5字節長,值的最高位為00、01或者10的是字節數組編碼:這種編碼表示節點的 content 屬性保存著字節數組,數組的長度由編碼除去最高兩位之后的其他位記錄;
- 1字節長,值的最高位以11開頭的是整數編碼:這種編碼表示節點的 content 屬性保存著整數值,整數值的類型和長度由編碼除去最高兩位之后的其他位記錄。
content
節點的 content 屬性負責保存節點的值,節點值可以是一個字節數組或者整數值,值的類型和長度由節點的 encoding 屬性決定。
連鎖更新
redis中的壓縮列表在插入數據的時候可能存在連鎖擴容的情況。
在壓縮列表中,節點需要存放上一個節點的長度:當上一個entry節點長度小于254個字節的時候,將會一個字節的大小來存放entry中的數據;但是當上一個entry節點長度大于等于254個字節的時候,就會需要更大的空間來存放數據。
在壓縮列表中,會把大于等于254字節長度用5個字節來存儲,第一個字節是254,當讀到254的時候,將會確認接下來的4個字節大小將是entry的長度數據。當第一個字節為255的時候,就證明壓縮列表已經到達末端。
由于表示長度的字節大小不一樣,當新節點的插入可能會導致下一個節點原本存放表示上一節點的長度的空間大小不夠導致需要擴容這一字段。相應的該字段將會由一個字節擴容到五個字節,四個字節的長度變化,當發生變化的節點原本長度在250到253之間的時候,將會導致下一個節點存儲上節點長度的空間發生變化,引起一個連鎖擴容的情況,這一情況將會直到一個不需要擴容的節點為止。
擴容邏輯代碼如下,可參考:
while (p[0] != ZIP_END) {zipEntry(p, &cur);rawlen = cur.headersize + cur.len;rawlensize = zipStorePrevEntryLength(NULL,rawlen);/* Abort if there is no next entry. */if (p[rawlen] == ZIP_END) break;zipEntry(p+rawlen, &next);/* Abort when "prevlen" has not changed. */if (next.prevrawlen == rawlen) break;if (next.prevrawlensize < rawlensize) {/* The "prevlen" field of "next" needs more bytes to hold* the raw length of "cur". */offset = p-zl;extra = rawlensize-next.prevrawlensize;zl = ziplistResize(zl,curlen+extra);p = zl+offset;/* Current pointer and offset for next element. */np = p+rawlen;noffset = np-zl;/* Update tail offset when next element is not the tail element. */if ((zl+intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))) != np) {ZIPLIST_TAIL_OFFSET(zl) =intrev32ifbe(intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl))+extra);}/* Move the tail to the back. */memmove(np+rawlensize,np+next.prevrawlensize,curlen-noffset-next.prevrawlensize-1);zipStorePrevEntryLength(np,rawlen);/* Advance the cursor */p += rawlen;curlen += extra;} else {if (next.prevrawlensize > rawlensize) {/* This would result in shrinking, which we want to avoid.* So, set "rawlen" in the available bytes. */zipStorePrevEntryLengthLarge(p+rawlen,rawlen);} else {zipStorePrevEntryLength(p+rawlen,rawlen);}/* Stop here, as the raw length of "next" has not changed. */break;} }代碼邏輯是:首先,從新插入的節點的下一個節點開始,如果下一個節點存放上一個字節的空間大小大于或等于當前的節點長度,那么在存放了這一長度數據之后,該次連鎖擴容直接宣告結束。如果下一個節點存放長度的空間不能容納當前節點的長度,那么就會將下一個節點進行擴容,并重新申請內存大小,并復制數據,移動指向尾部節點的指針。最后移動到下一個節點,在下一個循環中判斷是否需要繼續擴容。
編碼轉換時機
Redis中的每個對象都由一個 redisObject 結構來表示:
typedef struct redisObject {unsigned type:4; // 類型unsigned encoding:4; // 編碼void *ptr; // 指向底層實現數據結構的指針 }類型包括基本的五種,編碼指對應類型下的不同編碼實現。
Redis可以根據不同的使用場景,來為一個對象設置不同的編碼,從而優化對象在某一場景下的效率。
字符串
字符串的編碼可以是 int 、 raw 或者是 embstr。
int
如果一個字符串對象保存的是整數值,并且這個整數值可以用long類型來表示,那么這個字符串對象會將整數值保存在字符串對象結構的 ptr 屬性中(將 void* 轉換成 long),并將字符串對象的編碼設置為int。
raw
如果一個字符串對象保存的是一個字符串值,并且長度大于44字節,那么這個字符串對象將使用簡單動態字符串(SDS)來保存,并且編碼設置為 raw。
embstr
如果一個字符串對象保存的是一個字符串值,并且長度小于等于44字節,那么同上,但是編碼設置為embstr。
embstr 是專門用于保存短字符串的優化編碼方式。它和 raw 的區別在于,raw編碼會調用兩次內存分配函數來分別創建 redisObject 和 sdshdr 結構,而embstr 編碼則通過調用一次內存分配函數來分配一塊連續的空間,空間中依次包含 redisObject 和 sdshdr 結構。
使用 embstr 的好處:
不過,embstr 編碼沒有任何相應的修改程序,它實際上只是只讀的,當 embstr 編碼的字符串執行修改命令時,總會變成 raw。
為什么raw和embstr的臨界值是44字節?
如果看過書的同學有疑問很正常,因為在《Redis的設計與實現》中,它寫的臨界值是39字節,但是實際上經過查找資料,在3.2版本之后就改成了44字節了。主要原因是為了內存優化,具體解釋如下:
我們知道對于每個 sds 都有一個 sdshdr,里面的 len 和 free 記錄了這個 sds 的長度和空閑空間,但是這樣的處理十分粗糙,使用的 unsigned int 可以表示很大的范圍,但是對于很短的 sds 有很多的空間被浪費了(兩個unsigned int 8個字節)。而這個 commit 則將原來的 sdshdr 改成了 sdshdr16 , sdshdr32 , sdshdr64 ,里面的 unsigned int 變成了 uint8_t ,uint16_t…(還加了一個char flags)這樣更加優化小 sds 的內存使用。
本身就是針對短字符串的 embstr 自然會使用最小的 sdshdr8 ,而 sdshdr8 與之前的 sdshdr 相比正好減少了5個字節(sdsdr8 = uint8_t * 2 + char = 1*2+1 = 3, sdshdr = unsigned int * 2 = 4 * 2 = 8),所以其能容納的字符串長度增加了5個字節變成了44。
列表
列表的編碼可以是 ziplist 或者 linkedlist。(壓縮列表 或者 雙向鏈表)
ziplist
如果列表對象保存的所有字符串元素的長度都小于64字節,并且列表對象保存的元素數量小于512個時,編碼為 ziplist。
linkedlist
上面兩個條件,只要一個不滿足,就采取 linkedlist 編碼。
哈希
哈希對象的編碼可以是 ziplist 或者 hashtable。(壓縮列表 或者 字典)
ziplist
如果哈希對象保存的所有鍵值對的鍵和值的字符串長度都小于64字節,并且哈希對象保存的鍵值對數量小于512個時,編碼為 ziplist。
hashtable
上面兩個條件,只要一個不滿足,就采取 hashtable 編碼。
集合
集合對象的編碼可以是 intset 或者 hashtable。
intset
如果集合對象保存的所有元素都是整數值,并且哈希對象保存的元素數量小于512個時,編碼為 intset。
hashtable
上面兩個條件,只要一個不滿足,就采取 hashtable 編碼。
有序集合
有序集合的編碼可以是 ziplist 或者 skiplist。
ziplist
如果有序集合對象保存的所有元素成員的長度都小于64字節,并且有序集合對象保存的元素數量小于128個時,編碼為 ziplist。
skiplist
上面兩個條件,只要一個不滿足,就采取 skiplist 編碼。
持久化
詳細了解參考文章:Redis的兩種持久化RDB和AOF(超詳細)
Redis對數據的操作都是基于內存的,當遇到了進程退出、服務器宕機等意外情況,如果沒有持久化機制,那么Redis中的數據將會丟失無法恢復。有了持久化機制,Redis在下次重啟時可以利用之前持久化的文件進行數據恢復。
Redis支持的兩種持久化機制:
- RDB:把當前數據生成快照保存在硬盤上。
- AOF:記錄每次對數據的操作到硬盤上。
- 混合持久化:在 redis 4 引入,RDB + AOF 混合使用的方式,RDB 持久化全量數據,AOF 持久化增量數據。
RDB
RDB(Redis DataBase)持久化是把當前Redis中全部數據生成快照保存在硬盤上。RDB持久化可以手動觸發,也可以自動觸發。
觸發方式
手動觸發
save 和 bgsave 命令都可以手動觸發RDB持久化。
- 執行save命令會手動觸發RDB持久化,但是save命令會阻塞Redis服務,直到RDB持久化完成。當Redis服務儲存大量數據時,會造成較長時間的阻塞,不建議使用。
- 執行bgsave命令也會手動觸發RDB持久化,和save命令不同是:Redis服務一般不會阻塞。Redis進程會執行fork操作創建子進程,RDB持久化由子進程負責,不會阻塞Redis服務進程。Redis服務的阻塞只發生在fork階段,一般情況時間很短。
- 執行 bgsave 命令,Redis進程先判斷當前是否存在正在執行的RDB或AOF子線程,如果存在就是直接結束。
- Redis進程執行 fork 操作創建子進程,在fork操作的過程中Redis進程會被阻塞。
- Redis進程 fork 完成后, bgsave 命令就結束了,自此Redis進程不會被阻塞,可以響應其他命令。
- 子進程根據Redis進程的內存生成快照文件,并替換原有的RDB文件。
- 子進程通過信號量通知Redis進程已完成。
簡單說明,save命令會全程阻塞,bgsave只在創建子線程時會阻塞。
自動觸發
在以下幾種場景下,會自動觸發RDB持久化:
RDB優缺點
優點
缺點
AOF
執行流程
觸發方式
手動觸發
使用 bgrewriteaof 命令。
自動觸發
根據 auto-aof-rewrite-min-size 和 auto-aof-rewrite-percentage 配置確定自動觸發的時機。
- auto-aof-rewrite-min-size 表示運行AOF重寫時文件大小的最小值,默認為64MB。
- auto-aof-rewrite-percentage 表示當前AOF文件大小和上一次重寫后AOF文件大小的比值的最小值,默認為100。
只用前兩者同時超過閾值時才會自動觸發文件重寫。
AOF文件同步策略
AOF持久化流程中的文件同步有以下幾個策略:
- always:每次寫入緩存區都要同步到AOF文件中,硬盤的操作比較慢,限制了Redis高并發,不建議配置。
- no:每次寫入緩存區后不進行同步,同步到AOF文件的操作由操作系統負責,每次同步AOF文件的周期不可控,而且增大了每次同步的硬盤的數據量。
- eversec:每次寫入緩存區后,由專門的線程每秒鐘同步一次,做到了兼顧性能和數據安全。是建議的同步策略,也是默認的策略。
AOF持久化配置
# appendonly改為yes,開啟AOF appendonly yes # AOF文件的名字 appendfilename "appendonly.aof" # AOF文件的寫入方式 # everysec 每個一秒將緩存區內容寫入文件 默認開啟的寫入方式 appendfsync everysec # 運行AOF重寫時AOF文件大小的增長率的最小值 auto-aof-rewrite-percentage 100 # 運行AOF重寫時文件大小的最小值 auto-aof-rewrite-min-size 64mb文件事件處理器
推薦博客文章:Redis全面解析一:redis是單線程結構為何還可以支持高并發
我們經常說Redis是單線程的,但是為什么這么說呢?
因為 Redis 內部用的是基于 Reactor 模式開發的文件事件處理器,文件事件處理器是以單線程方式運行的,所以redis才叫單線程模型。
組成部分
基于 Reactor 模式設計的四個組成部分的結構如下所示:
它們分別是:
- 套接字
- IO多路復用程序
- 文件事件分派器
- 事件處理器
處理機制
文件事件處理器大致可分為三個處理流程:
拓展
關于Redis6.0的多線程升級博客參考鏈接:Redis6 新特性多線程解析
內存淘汰機制
Redis 緩存使用內存保存數據,避免了系統直接從后臺數據庫讀取數據,提高了響應速度。由于緩存容量有限,當緩存容量到達上限,就需要刪除部分數據挪出空間,這樣新數據才可以添加進來。Redis 定義了「淘汰機制」用來解決內存被寫滿的問題。
緩存淘汰機制,也叫緩存替換機制,它需要解決兩個問題:
- 決定淘汰哪些數據。
- 如何處理那些被淘汰的數據。
內存淘汰策略
截至在 4.0 之后,Redis定義了「8種內存淘汰策略」用來處理 redis 內存滿的情況:
- noeviction:不會淘汰任何數據,當使用的內存空間超過 maxmemory 值時,返回錯誤。
- volatile-ttl:篩選設置了過期時間的鍵值對,越早過期的越先被刪除。
- volatile-random:篩選設置了過期時間的鍵值對,隨機刪除。
- volatile-lru:使用 LRU 算法篩選設置了過期時間的鍵值對。
- volatile-lfu:使用 LFU 算法選擇設置了過期時間的鍵值對。
- allkeys-random:在所有鍵值對中,隨機選擇并刪除數據。
- allkeys-lru:使用 LRU 算法在所有數據中進行篩選。
- allkeys-lfu:使用 LFU 算法在所有數據中進行篩選。
根據它們的名稱和前綴我們就能如下分類:
- 不淘汰數據:noeviction。
- 淘汰數據
- 在設置了過期時間的鍵值對中進行淘汰:volatile-ttl、volatile-random、volatile-lru、volatile-lfu。
- 對所有數據進行淘汰:allkeys-random、allkeys-lru、allkeys-lfu。
策略介紹
noeviction
noeviction 策略,也是 Redis 的默認策略,它要求 Redis 在使用的內存空間超過 maxmemory 值時,也不進行數據淘汰。一旦緩存被寫滿了,再有寫請求來的時候,Redis 會直接返回錯誤。
我們實際項目中,一般不會使用這種策略。因為我們業務數據量通常會超過緩存容量的,而這個策略不淘汰數據,導致有些熱點數據保存不到緩存中,失去了使用緩存的初衷。
volatile-ttl、volatile-random、volatile-lru、volatile-lfu
volatile-random、volatile-ttl、volatile-lru、volatile-lfu 這四種淘汰策略。它們淘汰數據的時候,只會篩選設置了過期時間的鍵值對上。
比如,我們使用 EXPIRE 命令對一批鍵值對設置了過期時間,那么會有兩種情況會對這些數據進行清理:
其中 volatile-ttl、volatile-random的篩選規則比較簡單,而volatile-lru、volatile-lfu分別用到了 LRU 和 LFU 算法。
allkeys-random、allkeys-lru、allkeys-lfu
allkeys-random,allkeys-lru,allkeys-lfu 這三種策略跟上述四種策略的區別是:淘汰時數據篩選的數據范圍是所有鍵值對。
其中allkeys-random的篩選規則比較簡單,而allkeys-lru,allkeys-lfu分別用到了LRU 和 LFU 算法。
LRU & LFU算法
LRU
LRU 算法全稱 Least Recently Used,一種常見的頁面置換算法。按照「最近最少使用」的原則來篩選數據,篩選出最不常用的數據,而最近頻繁使用的數據會留在緩存中。
LRU 篩選邏輯
RU 會把所有的數據組織成一個鏈表,鏈表的頭和尾分別表示 MRU 端和 LRU 端,分別代表「最近最常使用」的數據和「最近最不常用」的數據。
每次訪問數據時,都會把剛剛被訪問的數據移到 MRU 端,就可以讓它們盡可能地留在緩存中。
如果此時有新數據要寫入時,并且沒有多余的緩存空間,那么該鏈表會做兩件事情:
簡單說明,即它認為剛剛被訪問的數據,肯定還會被再次訪問,所以就把它放在 MRU端;LRU 端的數據被認為是長久不訪問的數據,在緩存滿時,就優先刪除它。
Redis 對 LRU 的實現
Redis 3.0 前,隨機選取 N 個淘汰法。
Redis 默認會記錄每個數據的最近一次訪問的時間戳(由鍵值對數據結構 RedisObject 中的 lru 字段記錄)。
在 Redis 決定淘汰的數據時,隨機選 N(默認5) 個 key,把空閑時間(idle time)最大的那個 key 移除。這邊的 N 可通過 maxmemory-samples 配置項修改:
config set maxmemory-samples 100當需要再次淘汰數據時,Redis 需要挑選數據進入「第一次淘汰時創建的候選集合」。
挑選的標準是:能進入候選集合的數據的 lru 字段值必須小于「候選集合中最小的 lru 值」。
當有新數據進入備選數據集后,如果備選數據集中的數據個數達到了設置的閾值時。Redis 就把備選數據集中 lru 字段值最小的數據淘汰出去。
Redis3.0后,引入了緩沖池(默認容量為16)概念。
當每一輪移除 key 時,拿到了 N(默認5)個 key 的 idle time,遍歷處理這 N 個 key,如果 key 的 idle time 比 pool 里面的 key 的 idle time 還要大,就把它添加到 pool 里面去。
當 pool 放滿之后,每次如果有新的 key 需要放入,需要將 pool 中 idle time 最小的一個 key 移除。這樣相當于 pool 里面始終維護著還未被淘汰的 idle time 最大的 16 個 key。
當我們每輪要淘汰的時候,直接從 pool 里面取出 idle time 最大的 key(只取1個),將之淘汰掉。
整個流程相當于隨機取 5 個 key 放入 pool,然后淘汰 pool 中空閑時間最大的 key,然后再隨機取 5 個 key放入 pool,繼續淘汰 pool 中空閑時間最大的 key,一直持續下去。
在進入淘汰前會計算出需要釋放的內存大小,然后就一直循環上述流程,直至釋放足夠的內存。
LFU
在一些場景下,有些數據被訪問的次數非常少,甚至只會被訪問一次。當這些數據服務完訪問請求后,如果還繼續留存在緩存中的話,就只會白白占用內存空間。這種情況,就是緩存污染。
為了應對緩存污染問題,Redis 從 4.0 版本開始增加了 LFU 淘汰策略。
LFU 緩存策略是在 LRU 策略基礎上,為每個數據增加了一個「計數器」,來統計這個數據的訪問次數。
LFU 篩選邏輯
- 當使用 LFU 策略篩選淘汰數據時,首先會根據數據的訪問次數進行篩選,把訪問次數最低的數據淘汰出緩存。
- 如果兩個數據的訪問次數相同,LFU 策略再比較這兩個數據的訪問時效性,把距離上一次訪問時間更久的數據淘汰出緩存。
LFU 的具體實現
我們在前面說過,為了避免操作鏈表的開銷,Redis 在實現 LRU 策略時使用了兩個近似方法:
- Redis 在 RedisObject 結構中設置了 lru 字段,用來記錄數據的訪問時間戳。
- Redis 并沒有為所有的數據維護一個全局的鏈表,而是通過「隨機采樣」方式,選取一定數量的數據放入備選集合,后續在備選集合中根據 lru 字段值的大小進行篩選刪除。
在此基礎上,Redis 在實現 LFU 策略的時候,只是把原來 24bit 大小的 lru 字段,又進一步拆分成了兩部分:
- ldt 值:lru 字段的前 16bit,表示數據的訪問時間戳。
- counter 值:lru 字段的后 8bit,表示數據的訪問次數。
但是我們會發現一個問題,counter 值的最大記錄值只有255。當幾個緩存數據的 counter 值 都達到255值,就無法正確根據訪問次數來決定數據的淘汰了。
所以Redis 針對這個問題進行了優化:在實現 LFU 策略時,Redis 并沒有采用數據每被訪問一次,就給對應的 counter 值加 1 的計數規則,而是采用了一個更優化的計數規則。
Redis 對 LFU 的實現
Redis 實現 LFU 策略時采用計數規則:
Redis的部分源碼實現如下:
double r = (double)rand() / RAND_MAX; // 隨機數 r 值 // ...... // baseval 是計數器當前的值,初始值默認是 5,是由代碼中的 LFU_INIT_VAL 常量設置 double p = 1.0 / (baseval * server.lfu_log_factor + 1); // ((計數器當前值 * 配置項參數) + 1 )的倒數 if (r < p) counter++;為什么 baseval 的初始值是5,而不是0?是因為這樣可以避免數據剛被寫入緩存,就因為訪問次數少而被立即淘汰。
使用了這種計算規則后,我們可以通過設置不同的 lfu_log_factor 配置項,來控制計數器值增加的速度,避免 counter 值很快就到 255 了。
這張表是根據Redis官網獲得的,進一步說明 LFU 策略計數器遞增的效果。
它記錄了當 lfu_log_factor 取不同值時,在不同的實際訪問次數情況下,計數器值的變化情況。
| 0 | 104 | 255 | 255 | 255 | 255 |
| 1 | 18 | 49 | 255 | 255 | 255 |
| 10 | 10 | 18 | 142 | 255 | 255 |
| 100 | 8 | 11 | 49 | 143 | 255 |
通過上表的分析:
- 當 lfu_log_factor 取值為 1 時,實際訪問次數為 100K 后,counter 值就達到 255 了,無法再區分實際訪問次數更多的數據了。
- 當 lfu_log_factor 取值為 100 時,當實際訪問次數為 10M 時,counter 值才達到 255。
使用這種非線性遞增的計數器方法,即使緩存數據的訪問次數成千上萬,LFU 策略也可以有效的區分不同的訪問次數,從而合理的進行數據篩選。
從剛才的表中,我們可以看到,當 lfu_log_factor 取值為 10 時,百、千、十萬級別的訪問次數對應的 counter 值 已經有明顯的區分了。所以,我們在應用 LFU 策略時,一般可以將 lfu_log_factor 取值為 10。
但是對于一些業務場景,上方的設計會存在問題:比如說有些數據在「短時間內被大量訪問后就不會再被訪問了」。
那么再按照訪問次數來篩選的話,這些數據會被留存在緩存中,但不會提升緩存命中率。
為此,Redis 在實現 LFU 策略時,還設計了一個「 counter 值的衰減機制」。
LFU 中的 counter 值的衰減機制
簡單來說,LFU 策略使用 lfu_decay_time(衰減因子配置項) 來控制訪問次數的衰減。
通過上方的第二點,我們就能知道一個規律,lfu_decay_time 值越大,那么相應的衰減值會變小,衰減效果也會減弱;反之相應的衰減值會變大,衰減效果也會增強。
所以,如果業務應用中有短時高頻訪問的數據的話,建議把 lfu_decay_time 值設置為 1。
使用總結
事務
Redis 事務相對于Mysql 事務來說較為簡單,大家可以將二者進行對比,下文也會整理。
概念
Redis 事務的本質是一組命令的集合。
事務支持一次執行多個命令,一個事務中所有命令都會被序列化。在事務執行過程,會按照順序串行化執行隊列中的命令,其他客戶端提交的命令請求不會插入到事務執行命令序列中。
簡單理解,Redis 中的事務,就是具有一次性、順序性、排他性地在命令序列中執行多個命令。
它的主要作用就是串聯多個命令防止別的命令插隊。
事務階段
我們可以把Redis 事務的執行分為三個階段:
從輸入Multi命令開始,輸入的命令都會依次進入命令隊列中,但不會執行,直到輸入 Exec 后,Redis會將之前的命令隊列中的命令依次執行。組隊的過程中可以通過 discard。
事務錯誤處理
事務的錯誤分為兩種情況:
- 如果組隊中某個命令報出了錯誤,執行時整個的所有隊列都會被取消。
- 如果執行階段某個命令報出了錯誤,則只有報錯的命令不會被執行,而其他的命令都會執行,不會回滾。
這說明在 Redis 中,雖然單條命令是原子性執行的,但是事務不保證原子性,且沒有回滾。事務中任意命令執行失敗,其余的命令仍會被執行。
Watch 監控
引入
Redis 中的 悲觀鎖 和 樂觀鎖,簡單提及以下:
悲觀鎖(Pessimistic Lock),每次去拿數據的時候都認為別人會修改,所以每次在拿數據的時候都會上鎖,這樣別人想拿這個數據就會block直到它拿到鎖。傳統的關系型數據庫里邊就用到了很多這種鎖機制,比如行鎖,表鎖等,讀鎖,寫鎖等,都是在做操作之前先上鎖。
樂觀鎖(Optimistic Lock),每次去拿數據的時候都認為別人不會修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個數據,可以使用版本號等機制。樂觀鎖適用于多讀的應用類型,這樣可以提高吞吐量。Redis就是利用這種check-and-set機制實現事務的。
watch 命令
在執行 multi 之前,先執行watch key1 [key2],可以監視一個(或多個) key ,如果在事務執行之前這個(或這些) key 被其他命令所改動,那么事務將被打斷。
舉例說明:
假如我賬戶上有100元,此時我們準備再給賬戶充值50元,準備買149元的傳說皮膚。
但是此時,以一位糟糕的程序員修改了我們的賬戶,改成了999元。
我很生氣,因為我充值失敗了,但是我去賬戶上一看,變成999元了,我馬上給自己一巴掌,“在生氣什么呢?”…
模擬上方情景,這是控制臺1的操作:
模擬上方情景,這是控制臺2的操作:
注意:只要執行了EXEC,之前加的監控鎖都會被取消!Redis的事務不保證原子性,一條命令執行失敗了,其他的仍然會執行,且不會回滾。
unwatch 命令
取消 WATCH 命令對所有 key 的監視。
如果在執行 WATCH 命令之后,EXEC 命令或 DISCARD 命令先被執行了的話,那么就不需要再執行 UNWATCH 了。
總結說明
redis 的事務不推薦在實際中使用,如果要使用事務,推薦使用 Lua 腳本,redis 會保證一個 Lua 腳本里的所有命令的原子性。
Redis集群
除去Redis的單例模式,Redis 的集群模式可以分為三種:主從復制、哨兵模式、集群模式。
主從復制
Redis 官方文檔【主從復制】:REDIS sentinel-old – Redis中國用戶組(CRUG)
主從復制架構
主從復制,將 Redis 實例分為兩中角色,一種是被復制的服務器稱為主服務器(master),而對主服務器進行復制的服務器被稱為從服務器(slave)。
當主數據庫有數據寫入,會將數據同步復制給從節點,一個主數據庫可以同時擁有多個從數據庫,而從數據庫只能擁有一個主數據庫。值得一提的是,從節點也可以有從節點,呈現級聯結構。
我們可以看到,在主從復制中,只有一個是主機,其他的都是從機,并且從機下面還可以有任意多個從機。
主數據庫可以進行讀寫操作,從數據庫只能有讀操作(并不一定,只是推薦這么做)。
開啟主從復制方式
命令
通過slaveof 命令,將 127.0.0.1:6380 的redis實例成為 127.0.0.1:6379 的redis實例的從服務器:
slaveof 127.0.0.1 6379測試如下:
配置
通過編寫配置文件,例如先為主配置文件命名為 master.conf 進行編寫配置:
# 通用配置 # bind 127.0.0.1 # 綁定監聽的網卡IP,注釋掉或配置成0.0.0.0可使任意IP均可訪問 port 6379 # 設置監聽端口 #是否開啟保護模式,默認開啟。 # 設置為no之后最好設置一下密碼 protected-mode no #是否在后臺執行,yes:后臺運行;no:不是后臺運行 daemonize yes # 復制選項,slave復制對應的master。 # replicaof <masterip> <masterport> #如果master設置了requirepass,那么slave要連上master,需要有master的密碼才行。masterauth就是用來 # 配置master的密碼,這樣可以在連上master后進行認證。 # masterauth <master-password>在啟動節點時輸入命令
redis-server master.conf redis-server slave1.conf redis-server slave2.conf不過在docker容器中的Redis鏡像配置存在一些問題,大家自己找一下資料吧。
啟動命令
參考博客鏈接:redis啟動命令及集群創建
復制的實現【重點】
1. 設置主服務器的地址和端口
例如客戶端操作從服務器執行如下命令:
127.0.0.1> SLAVEOF 127.0.0.1 6379從服務器會將客戶端給定的主服務器IP地址以及端口號保存到當前從服務器狀態的 masterhost 屬性和 masterport 屬性中。
SLAVEOF 命令是一個異步命令,在完成屬性的設置工作后,從服務器會向客戶端返回"OK",之后開始執行真正的復制工作。
2. 建立套接字連接
從服務器根據指定的 IP地址和端口號,創建連向主服務器套接字(socket)連接。
主服務器在接受(accept) 從服務器的套接字連接之后,為該套接字創建相應的客戶端狀態。
這個時候可以將從服務器理解為主服務器的客戶端。
3. 發送 PING 命令
從服務器向主服務器發送一個 PING 命令,以檢査套接字的讀寫狀態是否正常、 主服務器能否正常處理命令請求。
從服務器在發送 PING 命令后,會遇到三種情況:
4. 身份驗證
存在這一步的前提是:從服務器設置了 masterauth 選項,那么就要進行這一步的身份驗證,否則跳過。
從服務器將 masterauth 選項的值封裝成AUTH password 命令并向主服務器發送來進行身份驗證。
從服務器在身份驗證階段可能會遇到以下幾種情況:
5. 發送端口信息
從服務器向主服務器發送當前服務器的監聽端口號, 主服務器收到后記錄在從服務器所對應的客戶端狀態的 slave_listening_port 屬性中。
執行命令為 REPLCONF listening-port <port-number> ,port-number 即為端口號。
目前 slave_listening_port 唯一的作用就是在主服務器執行 INFO replication 命令時打印從服務器端口號。
6. 同步
從服務器向主服務器發送 PSYNC 命令,執行同步操作,此時兩者互為客戶端。
PSYNC 命令有兩種執行情況:
主服務器返回從服務器也有三種情況:
從上方可知,主要包括全量數據同步 和 增量數據同步的情況,這跟Redis是否第一次連接和在連接過程中是否離線有關。
7. 命令傳播
當完成了同步之后,就會進入命令傳播階段,這時主服務器只要一直將自己執行的寫命令發送給從服務器,而從服務器只要一直接收并執行主服務器發來的寫命令,就可以保證主從一致了。
主從復制優缺點
優點
- 同一個Master可以同步多個Slaves。
- master能自動將數據同步到slave,可以進行讀寫分離,分擔master的讀壓力
- master、slave之間的同步是以非阻塞的方式進行的,同步期間,客戶端仍然可以提交查詢或更新請求
缺點
- 不具備自動容錯與恢復功能,master或slave的宕機都可能導致客戶端請求失敗,需要等待機器重啟或手動切換客戶端IP才能恢復
- master宕機,如果宕機前數據沒有同步完,則切換IP后會存在數據不一致的問題
- 難以支持在線擴容,Redis的容量受限于單機配置
總結
其實redis的主從模式很簡單,在實際的生產環境中很少使用,不建議在實際的生產環境中使用主從模式來提供系統的高可用性,之所以不建議使用都是由它的缺點造成的,在數據量非常大的情況,或者對系統的高可用性要求很高的情況下,主從模式也是不穩定的。雖然這個模式很簡單,但是這個模式是其他模式的基礎,所以理解了這個模式,對其他模式的學習會很有幫助。
命令傳播階段后的心跳檢測 以及 PSYNC 的實現,具體參照書中,不多解釋了。
哨兵模式
Redis官方文檔【高可用】:REDIS sentinel-old – Redis中國用戶組(CRUG)
參考公眾號文章:全面分析Redis高可用的奧秘 - Sentinel
哨兵模式架構
哨兵(Sentinel) 是 Redis 的高可用性解決方案:由一個或多個 Sentinel 實例組成的 Sentinel 系統可以監視任意多個主服務器,以及這些主服務器屬下的所有從服務器。
Sentinel 可以在被監視的主服務器進入下線狀態時,自動將下線主服務器的某個從服務器升級為新的主服務器,然后由新的主服務器代替已下線的主服務器繼續處理命令請求。
哨兵進程
哨兵(Sentinel)其實也是Redis 實例,只不過它在啟動時初始化將 Redis 服務器使用的代碼替換成 Sentinel 專用代碼。
哨兵進程的作用
哨兵(Sentinel) 和 一般Redis 的區別?
哨兵的工作方式
創建連接
這一步是初始化 Sentinel 的最后一步,Sentinel 成為主服務器的客戶端,可以向主服務器發送命令。
每個sentinel都會創建兩個連向主服務器的異步網絡連接。
- 命令連接:用于向master服務發送命令,并接收命令回復。
- 訂閱連接:用于訂閱、接收master服務的 __sentinel__:hello 頻道。
為什么有兩個連接?
命令連接的原因是:Sentinel 必須向主服務器發送命令,以此來與主服務器通信。
訂閱連接的原因是:目前Redis版本的發布訂閱功能無法保存被發送的信息,如果接收信息的客戶端離線,那么這個客戶端就會丟失這條信息,為了不丟失 __sentinel__:hello 頻道的任何信息,Sentinel 專門用一個訂閱連接來接收該頻道的信息。
【簡單理解:不僅需要發信息,也需要收信息】
獲取主服務器信息
Sentinel 默認會以10秒一次通過命令連接向被監視的主服務器發送 INFO 命令,主服務器收到后回復自己的run_id、IP、端口、對應的主服務器信息及主服務器下的所有從服務器信息。
Sentinel 根據返回的主服務器信息更新自身的 *masters 實例結構;至于主服務器返回的從服務器信息用于更新對應的slaves 字典列表。
更新 slaves 字典時有兩種情況:
獲取從服務器信息
Sentinel 同樣會和從服務器建立異步的命令連接和訂閱連接,并也會默認10秒一次向從服務器發送 INFO 命令,從服務器會回復自己的運行run_id、角色role、從服務器復制偏移量offset、主服務器的ip和port、主從服務器連接狀態、從服務器優先級等信息,sentinel會根據返回信息更新對應的 slave 實例結構。
向主服務器和從服務器發送信息
Sentinel 默認會以2秒一次通過命令連接向所有被監控的主服務器和從服務器的_sentinel:hello頻道發送信息,信息的內容包含兩種參數:
參數列表展示參考:
| s_ip | Sentinel 的 IP地址 |
| s_port | Sentinel 的端口號 |
| s_runid | Sentinel 的運行ID |
| s_epoch | Sentinel 當前的配置紀元(configuration epoch) |
| m_name | 主服務器的名字 |
| m_ip | 主服務器的IP地址 |
| m_port | 主服務器的端口號 |
| m_epoch | 主服務器當前的配置紀元 |
接收來自主服務器和從服務器的頻道信息
Sentinel通過訂閱連接向服務器發送命令 SUBSCRIBE __sentinel__:hello,保證對_sentinel_:hello的訂閱一直持續到 Sentinel 與 服務器的連接斷開為止。
_sentinel_:hello頻道 與 Sentinel 的關系是一對多的關系,作用在于發現多個監控同一master的sentinel。
在接收到其他 sentinel 發送的頻道信息后,會根據信息更新 master 對應的 Sentinel 。
與 master 數據結構綁定后,會建立 Sentinel 與 Sentinel 的命令連接,為后續通訊做準備。
故障檢測
檢測主觀下線
Sentinel 默認會以1秒一次的頻率向與它建立命令連接的所有實例(包括master、slave以及發現的其他sentinel)發送 PING 命令,對方接收后返回兩種回復:
- **有效回復:**包括運行正常(+PONG)、正在加載(-LOADING)、和主機下線(-MASTERDOWN)。
- **無效回復:**除有效回復的三種以外都是無效回復,或者在指定時限內沒有返回任何回復。
在固定時間內,即 down-after-milliseconds(默認單位為毫秒) 配置的時間內收到的都是無效回復,Sentinel 就會標記 master 為主觀下線。與此同時,Sentinel 會將 master 數據結構中對應的flags屬性更新為 SRI_S_DOWN 標識,表示被監控的master在當前sentinel中已經進入主觀下線狀態。
down-after-milliseconds 的值,不僅是sentinel 用來判斷主服務器主觀下線狀態,還用來判斷主服務器下所有從服務器,以及所有同樣監視這個主服務器的其他Sentinel的主觀下線狀態。
簡單說明,即 down-after-millsseconds 配置是作用于當前sentinel所監控的所有服務上的,也就是對應master下的slave,以及其他sentinel。另外每個sentinel可以配置不同down-after-millsenconds,所以判定主觀下線的時間也就是不同的。
檢測客觀下線
判定 master 為主觀下線狀態的 Sentinel,通過命令詢問其他同樣監控這一主服務器的 Sentinel,看它們是否認為該 master 真的進入了下線狀態。
Sentinel 發送給其他 Sentinel 的命令為:
SEBTUBEL is-master-down-by-addr <ip> <port> <current_epoch> <runid>參數說明:
- Ip:被 Sentinel 判斷為主觀下線的主服務器的IP地址。
- port:被 Sentinel 判斷為主觀下線的主服務器的端口號。
- current_epoch:Sentinel 當前的配置紀元,用于選舉領頭 Sentinel。
- runid:可以是 * 符號 或 Sentinel 的 run_id,用 * 符號僅用于檢測主服務器的客觀下線狀態;用Sentinel 的 run_id 是用于選舉領頭 Sentinel。
其他 Sentinel 接收到 SEBTUBEL is-master-down-by-addr 命令后,會根據其中的主服務器IP和端口號,檢查主服務器是否已下線,然后向源 Sentinel 返回一條包含三個參數的 Multi Bulk 回復:
<down_state> <leader_runid> <leader_epoch>參數說明:
down_state:返回目標 Sentinel 對主服務器的檢查結果,1 代表已下線,0 代表為下線。
leader_runid:可以是 * 符號 或 目標 Sentinel 的 run_id,用 * 符號僅用于檢測主服務器的下線狀態;用局部領頭 Sentinel 的 run_id 是用于選舉局部領頭 Sentinel。
leader_epoch:目標 Sentinel 的局部領頭 Sentinel 的配置紀元,用于選舉領頭 Sentinel。【僅在 leader_runid 的值不為 * 時有效,如果 leader_runid 的值為 *,則 leader_epoch 總為0】
當 Sentinel 收到從其他 Sentinel 返回的足夠數量的已下線判斷之后,Sentinel會將主服務器實例結構的 flags 屬性的 SRI_O_DOWN 標識打開,表示主服務器已經進入客觀下線狀態。
足夠數量的已下線判斷是多少呢?
不同的 Sentinel 判斷客觀下線狀態的條件是不同的,具體不解釋了,看《Redis設計與實現》P238。
選舉領頭 Sentinel
當一個主服務器被判斷為客觀下線時,監測這個下線主服務器的各個 Sentinel 會進行協商,選舉出一個領頭 Sentinel,并由領頭 Sentinel 對下線主服務器執行故障轉移操作。
下面盡量直白地介紹選舉領頭 Sentinel 的規則和方法:
-
每個在線的 Sentinel 都有被選為領頭 Sentinel 的資格。
-
同一個配置紀元內(本質是計數器,在每次選舉后自增一次),每個 Sentinel 都有一次將某個 Sentinel 設置為局部領頭 Sentinel 的機會,并且設置后,在這個配置紀元里不能再更改。
-
每個發現主服務器進入客觀下線 的Sentinel 都會要求其他 Sentinel 將自己設置為局部領頭Sentinel。
-
拉票方式為發送 SEBTUBEL is-master-down-by-addr 命令,剛才的 *號替換為源 Sentinel的 run_id,表示希望目標 Sentinel 設置自己為它的局部領頭 Sentinel。
-
接收拉票命令的目標 Sentinel 可是非常單純,誰的命令先發給它,它就選誰當自己的局部領頭 Sentinel,之后的拉票全部拒絕。
-
當然,既然目標 Sentinel根據先到先得確定了局部領頭 Sentinel,那也得和大家回個話,它會為發送拉票命令的源 Sentinel 回復命令,記錄了自身選擇的局部領頭 Sentinel的 run_id 和 配置紀元。
-
如果某個 Sentinel 被半數以上的 Sentinel 設置為了局部領頭 Sentinel,那么這個局部領頭sentinel就變成了領頭sentinel,同一個配置紀元內可能會出現多個局部領頭sentinel,但是領頭sentinel只會產生一個。
-
如果在給定的時限內,沒有任何一個 Sentinel 被選舉為領頭 Sentinel,那么各個 Sentinel 會在一段時間后再次選舉,直到選出領頭 Sentinel 為止。
故障遷移
在選舉出領頭 Sentinel 之后,領頭 Sentinel 會對已下線的主服務器執行故障轉移操作,可分為三個步驟:
選出新的主服務器
(一)、新的主服務器是從原主服務器下的從服務器中選擇的,所以需要選擇狀態良好、數據完整的從服務器。領頭 Sentinel 的數據結構中保存了原master對應的 slave ,Sentinel 會刪除狀態較差的slave。過濾執行順序如下:
對應第三條,我可以解釋一下,前面提到過,在 down-after-millisecond 設置的時長內沒有收到有效回復,可以判定當前復制的主服務器主觀下線。所以,越遲和主服務器斷開連接的從服務器,數據越新。
(二)、現在過濾出的都是健康的從服務器了,然后 Sentinel 開始選擇新的主服務器,有以下三個優先級順序:
(三)、選出新的主服務器后,領頭 Sentinel 向被選中的從服務器發送 SLAVEOF no one 命令。
在發送 SLAVEOF no one 命令后,領頭 Sentinel 會以每秒一次的頻率(平時是十秒一次)向被選中的從服務器發送 INFO 命令,當被升級的服務器的 role 字段從 slave 變為 master 時,領頭 Sentinel 就知道它已經順利成為新主服務器了。
修改從服務器的復制目標
領頭 Sentinel 給已下線主服務器下的所有從服務器發送 SLAVEOF 命令,讓它們去復制新的主服務器。
將舊主服務器變為從服務器
因為舊主服務器下線,領頭Sentinel 會修改它對應主服務器下的實例結構中的設置。
等舊主服務器重新上線時,Sentinel 就會向它發送 SLAVEOF 命令,讓他成為新的主服務器的從服務器。
集群模式
《Redis設計與實現》第十七章 集群 p245;
官方文檔【集群教程】:REDIS cluster-tutorial – Redis中文資料站 – Redis中國用戶組(CRUG)
官方文檔【集群規范】:REDIS cluster-spec – Redis中文資料站 – Redis中國用戶組(CRUG)
官方文檔【分區】:REDIS 分區 – Redis中國用戶組(CRUG)
集群模式架構
哨兵模式最大的缺點就是所有的數據都放在一臺服務器上,無法較好的進行水平擴展。
為了解決哨兵模式的痛點,集群模式應運而生。在高可用上,集群基本是直接復用的哨兵模式的邏輯,并且針對水平擴展進行了優化。
它具有的特點有:
下面將會根據它的特點逐步說明該集群的核心技術。
集群數據結構
使用 clusterNode 結構保存一個節點的當前狀態,比如創建時間、名稱、配置紀元、IP、端口號等。
每個節點都會為自己和集群中所有其他節點都創建一個對應的 clusterNode 結構來記錄各自的節點狀態。
struct clusterNode {// 創建節點的時間mstime_t ctime;// 節點的名稱,由40個十六進制字符組成,例如68eef66df23420a5862208ef5...f2ffchar name[REDIS_CLUSTER_NAMELEN];// 節點標識,使用各種不同表示值記錄節點的角色(主節點或從節點);以及節點目前的狀態(在線或下線)int flags;// 節點當前的配置紀元,用于實現故障轉移uint64_t configEpoch;// 節點的IP地址char ip[REDIS_IP_STR_LEN];// 節點的端口號int port;// 保存連接節點所需的相關信息clusterLink *link;// ... };其中的 link 屬性是一個 clusterLink 結構,該結構保存連接節點所需的相關信息,包括套接字描述符、輸入緩沖區、輸出緩沖區。
typedef struct clusterLink {// 連接的創建時間mestime_t ctime;// TCP 套接字描述符int fd;// 輸出緩沖區,保存著待發送給其他節點的信息(message)sds sndbuf;// 輸入緩沖區,保存著從其他節點接收到的信息sds rcvbuf;// 與這個連接相關聯的節點,如果沒有的話就為 NULLstruct clusterNode *node; }最后一點,每個節點都保存著一個 clusterState 結構,這個結構記錄了當前節點視角下,所在集群目前所處的狀態。
例如集群在線或下線狀態、包含節點個數、集群當前的配置紀元等信息。
typedef struct clsterState {// 指向當前節點的指針clusterNode *myself;// 集群當前的配置紀元,用于實現故障轉移uint64_t currentEpoch;// 集群當前的狀態,是在線還是下線int state;// 集群節點名單(包含myself節點)// 字典的key是節點的名字,value是節點對應的 clusterNode 結構dict *nodes; }集群連接方式
通過發送 CLUSTER MEET 命令,可以讓目標節點A將另一個命令攜帶的節點B添加到目標節點A當前所在的集群中。
CLUSTER MEET <ip> <port>收到命令后開始進行節點A和節點B的握手階段,以此來確認彼此的存在,為后面的通信打好基礎,該過程簡單說明:
之后,節點A和節點B會通過Gossip 協議傳播給集群其他的節點,讓他們也和節點B握手,最終整個集群達成共識。
一般集群元數據的維護有兩種方式:集中式、Gossip 協議。在Redis集群中采用Gossip 協議進行通信,所以說它是去中心化的集群。
下面說一下這兩種方式的區別:
集中式:是將集群元數據(節點信息、故障等等)幾種存儲在某個節點上。集中式元數據集中存儲的一個典型代表,就是大數據領域的 storm。它是分布式的大數據實時計算引擎,是集中式的元數據存儲的結構,底層基于 zookeeper(分布式協調的中間件)對所有元數據進行存儲維護。
gossip 協議:所有節點都持有一份元數據,不同的節點如果出現了元數據的變更,就不斷將元數據發送給其它的節點,讓其它節點也進行元數據的變更。
集中式的好處在于,元數據的讀取和更新,時效性非常好,一旦元數據出現了變更,就立即更新到集中式的存儲中,其它節點讀取的時候就可以感知到;不好在于,所有的元數據的更新壓力全部集中在一個地方,可能會導致元數據的存儲有壓力。
gossip 協議的好處在于,元數據的更新比較分散,不是集中在一個地方,更新請求會陸陸續續打到所有節點上去更新,降低了壓力;不好在于,元數據的更新有延時,可能導致集群中的一些操作會有一些滯后。
分布式尋址算法【引入】
如果會的同學可以跳過,這里只做引申說明。
一般分布式尋址算法有下列幾種:
- hash 算法(大量緩存重建)
- 一致性 hash 算法(自動緩存遷移)+ 虛擬節點(自動負載均衡)
- redis cluster 的 hash slot 算法
hash 算法
來了一個 key,首先計算 hash 值,然后對節點數取模。然后打在不同的 master 節點上。一旦某一個 master 節點宕機,所有請求過來,都會基于最新的剩余 master 節點數去取模,嘗試去取數據。這會導致大部分的請求過來,全部無法拿到有效的緩存,導致大量的流量涌入數據庫。
一致性 hash 算法
一致性 hash 算法將整個 hash 值空間組織成一個虛擬的圓環,整個空間按順時針方向組織,下一步將各個 master 節點(使用服務器的 ip 或主機名)進行 hash。這樣就能確定每個節點在其哈希環上的位置。
一致性 hash 算法也是使用取模的方法 hash算法的取模法是對服務器的數量進行取模,而一致性 hash 算法是對 **2^32 ** 取模:
hash(服務器A的IP地址) % 2^32 hash(服務器B的IP地址) % 2^32 hash(服務器C的IP地址) % 2^32來了一個 key,首先計算 hash 值,并確定此數據在環上的位置,從此位置沿環順時針“行走”,遇到的第一個 master 節點就是 key 所在位置。
使用 hash 算法時,服務器數量發生改變時,所有服務器的所有緩存在同一時間失效了,而使用一致性哈希算法時,服務器的數量如果發生改變,并不是所有緩存都會失效,而是只有部分緩存會失效,例如如果一個節點掛了,受影響的數據僅僅是此節點到環空間前一個節點(沿著逆時針方向行走遇到的第一個節點)之間的數據,其它不受影響。增加一個節點也同理。
hash 環數據傾斜 & 虛擬節點
然而當一致性 hash 算法在節點太少或是節點位置分布不均勻時,容易造成大量請求都集中在某一個節點上,而造成緩存熱點的問題。如果i此時該熱點節點出現故障,那么失效緩存的數量也將達到最大值,在極端情況下,有可能引起系統的崩潰,這種情況被稱之為 數據傾斜。
為了預防 數據傾斜 的問題,一致性 hash 算法引入了虛擬節點機制,即對每一個節點計算多個 hash,每個計算結果位置都放置一個虛擬節點。這樣就實現了數據的均勻分布,負載均衡。
具體說明,每一個服務節點計算多個哈希,每個計算結果位置都放置一個此服務節點。具體做法可以在服務器ip或主機名的后面增加編號來實現。可以為每臺服務器計算三個虛擬節點,于是可以分別計算 “Node1#1”、“Node1#2”、“Node1#3”、“Node2#1”、“Node2#2”、“Node2#3”的哈希值,這樣可以讓hash 環中存在多個節點,使節點的分布更均勻,當然可以虛擬出更多的虛擬節點,以便減小hash環偏斜所帶來的影響,虛擬節點越多,hash環上的節點就越多,緩存被均勻分布的概率就越大。
圖就不畫了…理解理解TAT
hash slot 算法
redis 集群采用數據分片的哈希槽來進行數據存儲和數據的讀取。
redis 集群中有固定的 16384 個槽(slot),對每個 key 計算 CRC16 值,然后對 16384 取模,可以獲取 key 對應的 hash slot。
redis 集群中每個 master 都會被指派部分的槽(slot),假如說當前集群中有3個節點服務器,可能是這樣分配的 [0,5000]、[5001,10000]、[10001,16383]。
槽位的實現其實就是一個長度為 16384 的二進制數組,根據指定索引位上的二進制位值來判斷節點是否處理指定索引的槽位。
所以槽位的遷移非常簡單:
移動槽位的成本是非常低的。客戶端的 api,可以對指定的數據,讓他們走同一個槽位,通過 hash tag 來實現。
在Redis中通過 CLUSTER ADDSLOTS 命令來指派負責的槽位,后面會詳細說明。
每個節點都會記錄哪些槽指派給了自己,哪些槽指派給了其他節點。客戶端向節點發送鍵命令,節點要計算這個鍵屬于哪個槽。如果是自己負責這個槽,那么直接執行命令,如果不是,向客戶端返回一個 MOVED 錯誤,指引客戶端轉向正確的節點。
任何一臺機器宕機,另外兩個節點,不影響的。因為 key 找的是 hash slot,不是機器。
架構圖參照上方《集群模式架構》中。
可能有人問,為什么一致性hash算法是65535(2^32)個位置,而hash slot 算法卻是16384(2^14)個位置?【翻譯官方回答】
因此,16384個插槽處于正確的范圍內,以確保每個主站有足夠的插槽,最多1000個節點,但足夠小的數字可以輕松地將插槽配置傳播為原始位圖。 請注意,在小型集群中,位圖難以壓縮,因為當N很小時,位圖將設置插槽/ N位,這是設置的大部分位。
一致性 hash 算法 和 hash slot 算法的區別?
定位規則區別
它并不是閉合的,key的定位規則是根據 CRC-16(key) % 16384 的值來判斷屬于哪個槽區,從而判斷該key屬于哪個節點,而一致性 hash 算法是根據 hash(key) 的值來順時針找第一個 hash(ip或主機名) 的節點,從而確定key存儲在哪個節點。
應對熱點緩存區別
一致性 hash 算法是創建虛擬節點來實現節點宕機后的數據轉移并保證數據的安全性和集群的可用性的。
redis 集群是采用master節點有多個slave節點機制來保證數據的完整性的。master節點寫入數據,slave節點同步數據。當master節點掛機后,slave節點會通過選舉機制選舉出一個節點變成master節點,實現高可用。但是這里有一點需要考慮,如果master節點存在熱點緩存,某一個時刻某個key的訪問急劇增高,這時該mater節點可能操勞過度而死,隨后從節點選舉為主節點后,同樣宕機,一次類推,造成緩存雪崩。(簡單說明就是,都是被大量請求一套秒的,誰上來都一樣QAQ…)
擴容和縮容區別
一致性 hash 算法在新增和刪除節點后,數據會按照順時針自動來重新分布節點。
redis 集群的新增和刪除節點都需要手動來分配槽區。
集群的槽指派
Redis集群通過分片來保存數據庫的鍵值對:集群整個數據庫被分為16384個槽(slot),數據庫的每個鍵都屬于這16384個槽其中的一個,集群中的每個節點可以處理0個到16384個槽。
指派節點槽信息
當集群使用 CLUSTER MEET 命令,整個集群仍處于下線狀態,此時必須通過它們指派槽,通過發送 CLUSTER ADDSLOTS 命令給節點,將一個或多個槽指派給節點負責:
CLUSTER ADDSLOTS <slot> [slot...]比如說將 0 到 5000 個槽指派給節點7000負責:
CLUSTER ADDSLOTS 0 1 2 3 4 ... 5000然后以此類推給其他節點指派槽。
槽位是在 clusterNode 結構中的 slots 屬性和 numslot 屬性記錄的,記錄當前節點負責處理哪些槽:
struct clusterNode {//...// 二進制位數組unsigned char slots[16384/8];// 記錄節點負責處理的槽的數量,即slots數組中值為1的二進制位的數量int numslots; }在上面小節《分布式尋址算法》的《hash slot 算法》中說過,槽的本質就是一個二進制位數組,通過對[0,16383]上的對應索引為標記來判斷是否處理該槽位:如果slots數組上在指定索引位的二進制位的值為1,標識節點負責處理該槽,反之同理。
CLUSTER ADDSLOTS 的命令實現
CLUSTER ADDSLOTS 命令的實現也比較簡單:
執行完畢后,開始廣播通知給集群中的其他節點,自己目前處理的槽位。
傳播節點槽信息
節點會將自己的 slots 數組通過消息發送給集群中的其他節點,告知它們自己目前負責的槽位。
當其他節點接收到消息,會更新自己的在 clusterState.nodes 字典中對應節點的 clusterNode 結構中的 slots 數組。
記錄集群所有槽的指派信息
在 clusterState 結構中的 slots 數組記錄了集群中所有 16384 個槽的指派信息:
typedef struct clusterState {//...clusterNode *slots[16384];//... }slots 數組包含 16384 個項,每個數組項都是一個指向 clusterNode 的指針:對應指針指向 NULL 時,說明還未分配;指向 clusterNode 結構時,說明已經指派給了對應結構所代表的節點。
使用 clusterState.slots 和使用 clusterNode.slots 保存指派信息相比的好處?
使用clusterState.slots 比使用 clusterNode.slots 能夠更高效地解決問題。
- 如果只使用 clusterNode.slots來記錄,每次都需要遍歷所有 clusterNode 結構,復雜度為O(N)。
- 但如果使用 clusterState.slots 來記錄,只需要訪問 clusterState.slots對應的索引位即可,復雜度為O(1)。
集群執行命令
建立集群,并且分配完槽位,此時集群就會進入上線狀態,這時候客戶端就可以向集群中的節點發送數據指令了。
客戶端在向節點發送與數據庫鍵有關的命令時,接收命令的節點就會計算出命令要處理的數據庫鍵屬于哪個槽,并檢查這個槽是否指派個了自己:
- 如果鍵所在的槽正好指派給當前節點,那么節點就直接執行這個命令。
- 如果鍵所在的槽沒有指派給當前節點,那么節點就會向客戶端返回 MOVED 錯誤,指引客戶端向正確的節點,并再次發送之前想要執行的命令。
節點會使用以下算法來給指定 key 進行計算:
def slot_number(key):return CRC16(key) & 16383- CRC16(key):計算鍵 key 的 CRC-16 校驗和。
- & 16383:計算出介于0至16383之間的整數作為鍵 key 的槽號。
當節點計算出鍵所屬的槽后,節點會檢查自己 clusterState.slots 數組中的指定槽位,判斷是否由自己負責:
- 如果 clusterState.slot[i] 等于 clusterState.myself,說明是由當前節點負責的。
- 如果 clusterState.slot[i] 不等于 clusterState.myself,說明不是由當前節點負責的,會根據 clusterState.slot[i] 指向的 clusterNode 結構中所記錄的 IP 和 端口號,返回客戶端 MOVED 錯誤,指引客戶端轉向正在處理該槽的節點。
MOVED 錯誤
MOVED 錯誤的格式為:
MOVED <slot> <ip>:<port>- slot:鍵所在的槽。
- ip:port:負責處理該槽節點的IP地址和端口號。
MOVED 錯誤一般是不會打印的,而是根據該錯誤自動進行節點轉向,并打印轉向信息。
如果在單機 redis 的情況下,是會被客戶端打印出來的。
節點數據庫的實現
節點只能使用0號數據庫,而單機Redis服務器則沒有限制。
節點除了將鍵值對保存在數據庫中之外,還會用 clusterState 結構中的 slots_to_keys跳躍表來保存槽和鍵之間的關系:
typedef struct clusterState {//...zskiplist *slots_to_keys;//... }slots_to_keys 跳表中每個節點的分值(score)都是一個槽位號;每個節點的成員(member)都是一個數據庫鍵。
- 當節點往數據庫中添加新的鍵值對時,節點會將鍵的槽位號以及這個鍵關聯到 slot_to_keys 跳表中。
- 當節點刪除數據庫中的某個鍵值對時,節點就會在 slot_to_keys跳表中解除它們的關聯關系。
重新分片(比如在線擴容)
Redis 集群的重新分片操作可以將任意數量已經指派給某個節點的槽改為指派給另一個節點,并且相關聯槽位的鍵值對也會從源節點移動到目標節點。
重新分片的操作是可以在線進行的,保證了高可用。
我們就以在線擴容節點的情況來說吧:比如現在準備在集群中增加一個節點,如何將原有分片中的若干個槽位指派給新添加的節點?
Redis 集群的重新分片操作是由 Redis 集群管理軟件 redis-trib 負責執行的:Redis 提供重新分配的所有命令,而 redis-trib 通過向源節點和目標接待你發送命令來進行重新分片操作。
redis-trib 對集群的單個槽進行重新分片的步驟如下:
如果涉及多個槽,則給每個槽重復執行上述本步驟。
ASK 錯誤 - (保證集群在線擴容的安全性)
在重新分片操作期間,可能會出現一部分鍵值對被遷出,一部分鍵值還未被遷出,即在源節點和目標節點都由對應槽的數據。
當節點向源節點發送一個與數據庫鍵相關的命令,并且該鍵的槽位正好處在重新分片的過程中:
ASK 錯誤同 MOVED 錯誤類似,也是不會打印的,也會根據錯誤提供的 IP 和 端口號自動進行轉向操作。
同理,單機模式下會打印錯誤。
那 ASK 錯誤 和 MOVED 錯誤有什么區別呢?
雖然它們能導致客戶端轉向,但是 MOVED 錯誤代表槽的負責權已經交給另一個節點了;而 ASK 錯誤只是兩個節點在遷移槽的過程中使用的臨時措施。
CLUSTER SETSLOT IMPORTING 命令的實現
clusterState 結構的 importing_slots_from 數組記錄了當前節點正在從其他節點導入的槽:
typedef struct clusterState {//...clusterNode *importing_slots_from[16384];//... }如果 importing_slots_from[i] 的值不為 NULL,而是指向一個 clusterNode 結構,那么表示當前節點正在從 clusterNode 所代表的節點導入該槽。
在對集群重新分片的時候,向目標節點發送 CLUSTER SETSLOT IMPORTING 命令:
CLUSTER SETSLOT <slot> IMPORTING <source_id>可以將目標節點 clusterState.importing_slots_from[i] 的值設置為 source_id所代表的節點的 clusterNode 結構。
CLUSTER SETSLOT MIGRATING 命令的實現
clusterState 結構的 migrating_slots_to 數組記錄了當前節點正在遷移至其他節點的槽:
typedef struct clusterState {//...clusterNode *migrating_slots_to[16384];//... }如果 migrating_slots_to[i] 的值不為 NULL,而是指向一個 clusterNode 結構,那么表示當前節點正在將該槽遷移到 clusterNode 所代表的節點。
ASKING 命令
當客戶端接收到 ASK 錯誤并轉向正在導入槽的節點時,客戶端會先向節點發送一個 ASKING 命令,然后才重新發送要執行的命令,這是因為客戶端如果不發送 ASKING 命令,而直接發送想要執行的命令的話,那么客戶端發送的命令會被節點拒絕執行,并返回 MOVED 錯誤。
復制和故障轉移
Redis 集群中節點可分為主節點(master)和從節點(slave)。
主節點用于處理槽;從節點用于復制某個主節點,并在主節點下線時,代替下線主節點繼續處理命令請求。
設置從節點方式
向一個節點發送命令:
CLUSTER REPLICATE <node_id>可以讓接收命令的節點成為 node_id 所指定的節點的從節點,并開始對主節點進行復制操作,具體步驟如下:
故障檢測
集群中每個節點都會定期向其他節點發送 PING 信息,以此檢測對方是否在線,如果接收 PING 信息的節點沒有在規定時間內返回 PONG 信息,那么發送消息的節點會將接收消息的節點標記為疑似下線(PFALL)。
如果在集群中,半數以上負責槽的主節點都將某個主節點標記為疑似下線,那么這個主節點就會被標記為已下線(FALL)。
將該主節點標記為已下線的節點會向集群廣播關于該節點的 FALL 消息,所有收到這條 FALL 信息的節點都會立即將該節點標記為已下線。
故障轉移
當一個從節點發現自己正在復制的主節點進入了下線狀態時,從節點會對下線主節點進行故障轉移,按照以下的執行步驟:
選舉新的主節點過程
新的主節點也是通過選舉產生的,簡單介紹一下它的選舉過程:
類似于領頭 Sentinel 的選舉,可以對比來看。它們都是基于 Raft 算法的領頭選舉方法來實現的。
有的小伙伴可能覺得 領頭Sentinel 的選舉不算 Raft,因為它最后是通過領頭 Sentinel 來控制故障遷移的具體過程,這個就是仁者見仁智者見智了。
Raft 算法的實現可以參考一下Nacos 源碼中 RaftCore 類的實現,比較通俗易懂。有時間我會發一下 Nacos 源碼中Raft選舉的實現。
Redis應用
Redis 分布式鎖
官方文檔:REDIS distlock – Redis中國用戶組(CRUG)
我最早覺得比較好的實現分布式鎖思路文章:10分鐘精通Redis分布式鎖中的各種門道
引入
為什么需要分布式鎖?
我們在開發項目時,如果需要在同進程內的不同線程并發訪問某項資源,可以使用各種互斥鎖、讀寫鎖。
如果一臺主機上的多個進程需要并發訪問某項資源,則可以使用進程間同步的原語,例如信號量、管道、共享內存等。
但如果多臺主機需要同時訪問某項資源,就需要使用一種在全局可見并具有互斥性的鎖了。
這種鎖就是分布式鎖,可以在分布式場景中對資源加鎖,避免競爭資源引起的邏輯錯誤。
什么時候用分布式鎖?
一般我們使用分布式鎖有兩個場景:
- 效率:使用分布式鎖可以避免不同節點重復相同的工作,這些工作會浪費資源。比如用戶注冊后調用發送郵箱的接口發送通知,可能不同節點會發出多封郵箱。
- 安全:加分布式鎖同樣可以避免破壞正確性的發生,如果兩個節點在同一條數據上面操作,比如多個節點機器對同一個訂單操作不同的流程有可能會導致該筆訂單最后狀態出現錯誤,造成損失。
分布式鎖需要哪些特性呢?
大部分特性其實都類似于 Java 中的鎖,包括互斥性、可重入、鎖超時、公平鎖和非公平鎖、一致性。
- 互斥性:在同一時間點,只有一個客戶端持有鎖。
- 可重入:同一個節點上的同一個線程如果獲取了鎖之后那么也可以再次獲取這個鎖。
- 鎖超時:在客戶端離線(硬件故障或網絡異常等問題)時,鎖能夠在一段時間后自動釋放防止死鎖,即超時自動解鎖。
- 公平鎖和非公平鎖:公平鎖即按照請求加鎖的順序獲得鎖,非公平鎖即相反是無序的。
- 一致性:比如說用Redis 實現分布式鎖時,發生宕機情況,此時會有主從故障轉移的過程中,需要在此過程仍然保持鎖的原狀態。
- 續鎖:為了防止死鎖大多數會有鎖超時的設置,但是如果業務的執行時間的不確定性,就需要保證在業務仍在執行過程中時,客戶端仍要持有鎖。
加鎖
在Redis中加鎖一般都是使用 SET 命令,使用 SET 命令完成 SETNX 和 EXPIRE 操作,并且這是一個原子操作:
set key value [EX seconds] [PX milliseconds] [NX|XX]上面這條指令是 SET 指令的使用方式,參數說明如下:
- key、value:鍵值對。
- EX seconds:設置失效時長,單位秒。
- PX milliseconds:設置失效時長,單位毫秒。
- NX:key不存在時設置value,成功返回OK,失敗返回(nil),SET key value NX 效果等同于 SETNX key value。
- XX:key存在時設置value,成功返回OK,失敗返回(nil)。
其中,NX 參數用于保證在多個線程并發 set 下,只會有1個線程成功,起到了鎖的“唯一”性。
舉例:
// 設置msg = helloword,失效時長1000ms,不存在時設置 1.1.1.1:6379> set msg helloworld px 1000 nx解鎖
解鎖一般使用 DEL 命令,但是直接刪除鎖可能存在問題。
一般解鎖需要兩步操作:
查詢當前“鎖”是否還是我們持有,因為存在過期時間,所以可能等你想解鎖的時候,“鎖”已經到期,然后被其他線程獲取了,所以我們在解鎖前需要先判斷自己是否還持有“鎖”。
如果“鎖”還是我們持有,則執行解鎖操作,也就是刪除該鍵值對,并返回成功;否則,直接返回失敗。
由于當前 Redis 還沒有原子命令直接支持這兩步操作,所以當前通常是使用 Lua 腳本來執行解鎖操作,Redis 會保證腳本里的內容執行是一個原子操作。
以下是 Redis 官方給出的 Lua 腳本:
if redis.call("get",KEYS[1]) == ARGV[1] thenreturn redis.call("del",KEYS[1]) elsereturn 0 end參數說明如下:
- KEYS[1]:我們要解鎖的 key。
- ARGV[1]:我們加鎖時的 value,用于判斷當“鎖”是否還是我們持有,如果被其他線程持有了,value 就會發生變化。
續鎖
一般為了防止死鎖,比如服務器宕機或斷線的情況下無法手動解鎖,此時就需要給分布式鎖加上過期時間。
但是假如在我們業務執行的過程中,Redis 分布式鎖過期了,業務還沒處理完怎么辦?
首先,我們在設置過期時間時要結合業務場景去設計,盡量設置一個比較合理的值,就是理論上正常處理的話,在這個過期時間內是一定能處理完畢的。
然后我們需要應對一些特殊惡劣情況進行設計。
目前的解決方案一般有兩種:
同時,需要進行告警,人為介入驗證數據的正確性,然后找出超時原因,是否需要對超時時間進行優化等等。
守護線程“續命”存在的問題
Redisson 使用看門狗(守護線程)“續命”的方案在大多數場景下是挺不錯的,也被廣泛應用于生產環境,但是在極端情況下還是會存在問題。
問題例子如下:
解決方法:上述問題的根本原因主要是由于 Redis 異步復制帶來的數據不一致問題導致的,因此解決的方向就是保證數據的一致。
當前比較主流的解法和思路有兩種:
這里我們來說一下第一種 RedLock 的解決思路。
RedLock
紅鎖是Redis作者提出的一致性解決方案。紅鎖的本質是一個概率問題:如果一個主從架構的Redis在高可用切換期間丟失鎖的概率是k%,那么相互獨立的 N 個 Redis 同時丟失鎖的概率是多少?如果用紅鎖來實現分布式鎖,那么丟鎖的概率是(k%)^N。鑒于Redis極高的穩定性,此時的概率已經完全能滿足產品的需求。
說明紅鎖的實現并非這樣嚴格,一般保證M(1<M=<N)個同時鎖上即可,但通常仍舊可以滿足需求。
RedLock 算法
算法很易懂,起 5 個 master 節點,分布在不同的機房盡量保證可用性。為了獲得鎖,client 會進行如下操作:
失敗重試
如果一個 client 申請鎖失敗了,那么它需要稍等一會在重試避免多個 client 同時申請鎖的情況,最好的情況是一個 client 需要幾乎同時向 5 個 master 發起鎖申請。另外就是如果 client 申請鎖失敗了它需要盡快在它曾經申請到鎖的 master 上執行 unlock 操作,便于其他 client 獲得這把鎖,避免這些鎖過期造成的時間浪費,當然如果這時候網絡分區使得 client 無法聯系上這些 master,那么這種浪費就是不得不付出的代價了。
RedLock 的問題
- 占用的資源過多,為了實現紅鎖,需要創建多個互不相關的云Redis實例或者自建Redis,成本較高。
- 嚴重依賴系統時鐘。如果線程1從3個實例獲取到了鎖,但是這3個實例中的某個實例的系統時間走的稍微快一點,則它持有的鎖會提前過期被釋放,當他釋放后,此時又有3個實例是空閑的,則線程2也可以獲取到鎖,則可能出現兩個線程同時持有鎖了。
- 如果線程1從3個實例獲取到了鎖,但是萬一其中有1臺重啟了,則此時又有3個實例是空閑的,則線程2也可以獲取到鎖,此時又出現兩個線程同時持有鎖了。
總結
以上是生活随笔為你收集整理的备战面试日记(6.1) - (缓存相关.Redis全知识点)的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: Vetur can‘t find `ts
- 下一篇: php mysql 抽奖_jQuery+