Redis深度历险
Redis深度歷險--核心原理與應用實踐:
?
Redis是互聯網技術領域應用非常廣泛的存儲中間件,它是remote dictionary service的簡稱,遠程字典服務。
?
Redis 可以做什么?
Redis的業務應用范圍非常廣泛,讓我們以掘金技術社區(juejin.im)的帖子模塊為實例,梳理一下,Redis 可以用在哪些地方?
?
?
Redis 基礎數據結構
Redis 有 5 種基礎數據結構,分別為:string (字符串)、list (列表)、set (集合)、hash (哈希) 和 zset (有序集合)。
?
string (字符串)
字符串 string 是 Redis 最簡單的數據結構。Redis 所有的數據結構都是以唯一的 key 字符串作為名稱,然后通過這個唯一 key 值來獲取相應的 value 數據。不同類型的數據結構的差異就在于 value 的結構不一樣。
Redis 的字符串是動態字符串,是可以修改的字符串,內部結構實現上類似于 Java 的 ArrayList
批量鍵值對:
過期和set命令擴展:
可以對 key 設置過期時間,到點自動刪除,這個功能常用來控制緩存的失效時間。
?
混合使用:
計數,增or減:
?
list (列表)
Redis 的列表相當于 Java 語言里面的 LinkedList,注意它是鏈表而不是數組。這意味著 list 的插入和刪除操作非常快,時間復雜度為 O(1)。
當列表彈出了最后一個元素之后,該數據結構自動被刪除,內存被回收。
?
右邊進左邊出:隊列
右邊進右邊出:棧
?
hash (字典)
Redis 的字典相當于 Java 語言里面的 HashMap,它是無序字典。內部實現結構上同 Java 的 HashMap 也是一致的,同樣的數組 + 鏈表二維結構。第一維 hash 的數組位置碰撞時,就會將碰撞的元素使用鏈表串接起來。不同的是,Redis 的字典的值只能是字符串,另外它們 rehash 的方式不一樣,因為 Java 的 HashMap 在字典很大時,rehash 是個耗時的操作,需要一次性全部 rehash。Redis 為了高性能,不能堵塞服務,所以采用了漸進式 rehash 策略。
有點奇怪,有時候會報錯:那是因為book這個變量已經被使用了,通過type book即可知道
set (集合)
?
Redis 的集合相當于 Java 語言里面的 HashSet,它內部的鍵值對是無序的唯一的。它的內部實現相當于一個特殊的字典,字典中所有的 value 都是一個值NULL。
?
?
zset (有序集合)
zset 可能是 Redis 提供的最為特色的數據結構,它也是在面試中面試官最愛問的數據結構。它類似于 Java 的 SortedSet 和 HashMap 的結合體,一方面它是一個 set,保證了內部 value 的唯一性,另一方面它可以給每個 value 賦予一個 score,代表這個 value 的排序權重。
?
zset 可以用來存粉絲列表,value 值是粉絲的用戶 ID,score 是關注時間。我們可以對粉絲列表按關注時間進行排序。
zset 還可以用來存儲學生的成績,value 值是學生的 ID,score 是他的考試成績。我們可以對成績按分數進行排序就可以得到他的名次。
?
?
容器型數據結構的通用規則
list/set/hash/zset 這四種數據結構是容器型數據結構,它們共享下面兩條通用規則:
create if not exists
如果容器不存在,那就創建一個,再進行操作。比如 rpush 操作剛開始是沒有列表的,Redis 就會自動創建一個,然后再 rpush 進去新元素。
drop if no elements
如果容器里元素沒有了,那么立即刪除元素,釋放內存。這意味著 lpop 操作到最后一個元素,列表就消失了。
過期時間
Redis 所有的數據結構都可以設置過期時間,時間到了,Redis 會自動刪除相應的對象。需要注意的是過期是以對象為單位,比如一個 hash 結構的過期是整個 hash 對象的過期,而不是其中的某個子 key。
還有一個需要特別注意的地方是如果一個字符串已經設置了過期時間,然后你調用了 set 方法修改了它,它的過期時間會消失。
127.0.0.1:6379> set codehole yoyo OK 127.0.0.1:6379> expire codehole 600 (integer) 1 127.0.0.1:6379> ttl codehole (integer) 597 127.0.0.1:6379> set codehole yoyo OK 127.0.0.1:6379> ttl codehole (integer) -1?
千帆競發 —— 分布式鎖:
?
? ? 分布式鎖本質上要實現的目標就是在 Redis 里面占一個“茅坑”,當別的進程也要來占時,發現已經有人蹲在那里了,就只好放棄或者稍后再試。
? ? 占坑一般是使用 setnx(set if not exists) 指令,只允許被一個客戶端占坑。先來先占, 用完了,再調用 del 指令釋放茅坑。
但是有個問題,如果邏輯執行到中間出現異常了,可能會導致 del 指令沒有被調用,這樣就會陷入死鎖,鎖永遠得不到釋放。
于是我們在拿到鎖之后,再給鎖加上一個過期時間,比如 5s,這樣即使中間出現異常也可以保證 5 秒之后鎖會自動釋放。
? ?但是以上邏輯還有問題。如果在 setnx 和 expire 之間服務器進程突然掛掉了,可能是因為機器掉電或者是被人為殺掉的,就會導致 expire 得不到執行,也會造成死鎖。
? ? 為了治理這個亂象,Redis 2.8 版本中作者加入了 set 指令的擴展參數,使得 setnx 和 expire 指令可以一起執行,徹底解決了分布式鎖的亂象。
?
上面這個指令就是 setnx 和 expire 組合在一起的原子指令,它就是分布式鎖的奧義所在。
?
緩兵之計 —— 延時隊列:
?
異步消息隊列
Redis 的 list(列表) 數據結構常用來作為異步消息隊列使用,使用rpush/lpush操作入隊列,使用lpop 和 rpop來出隊列。
隊列空了怎么辦?
如果隊列空了,客戶端就會陷入 pop 的死循環,不停地 pop,沒有數據,接著再 pop,又沒有數據。這就是浪費生命的空輪詢。
通常我們使用 sleep 來解決這個問題,讓線程睡一會,睡個 1s 鐘就可以了。但是會導致消息延遲。
可以通過阻塞讀blpop/brpop:
阻塞讀在隊列沒有數據的時候,會立即進入休眠狀態,一旦數據到來,則立刻醒過來。消息的延遲幾乎為零。用blpop/brpop替代前面的lpop/rpop,就完美解決了上面的問題。
?
空閑連接自動斷開
?空閑連接的問題。
如果線程一直阻塞在哪里,Redis 的客戶端連接就成了閑置連接,閑置過久,服務器一般會主動斷開連接,減少閑置資源占用。這個時候blpop/brpop會拋出異常來。
所以編寫客戶端消費者的時候要小心,注意捕獲異常,還要重試。
?
鎖沖突處理
前面我們講了分布式鎖的問題,但是沒有提到客戶端在處理請求時加鎖沒加成功怎么辦。一般有 3 種策略來處理加鎖失敗:
直接拋出特定類型的異常
這種方式比較適合由用戶直接發起的請求,用戶看到錯誤對話框后,會先閱讀對話框的內容,再點擊重試,這樣就可以起到人工延時的效果。如果考慮到用戶體驗,可以由前端的代碼替代用戶自己來進行延時重試控制。它本質上是對當前請求的放棄,由用戶決定是否重新發起新的請求。
sleep
sleep 會阻塞當前的消息處理線程,會導致隊列的后續消息處理出現延遲。如果碰撞的比較頻繁或者隊列里消息比較多,sleep 可能并不合適。如果因為個別死鎖的 key 導致加鎖不成功,線程會徹底堵死,導致后續消息永遠得不到及時處理。
延時隊列
這種方式比較適合異步消息處理,將當前沖突的請求扔到另一個隊列延后處理以避開沖突。
延時隊列的實現
延時隊列可以通過 Redis 的 zset(有序列表) 來實現。我們將消息序列化成一個字符串作為 zset 的value,這個消息的到期處理時間作為score,然后用多個線程輪詢 zset 獲取到期的任務進行處理,多個線程是為了保障可用性,萬一掛了一個線程還有其它線程可以繼續處理。因為有多個線程,所以需要考慮并發爭搶任務,確保任務不能被多次執行。
?
?
四兩撥千斤 —— HyperLogLog:
?
我們先思考一個常見的業務問題:如果你負責開發維護一個大型的網站,有一天老板找產品經理要網站每個網頁每天的 UV 數據,然后讓你來開發這個統計模塊,你會如何實現?
?
?
?
如果統計 PV 那非常好辦,給每個網頁一個獨立的 Redis 計數器就可以了,這個計數器的 key 后綴加上當天的日期。這樣來一個請求,incrby 一次,最終就可以統計出所有的 PV 數據。
但是 UV 不一樣,它要去重,同一個用戶一天之內的多次訪問請求只能計數一次。這就要求每一個網頁請求都需要帶上用戶的 ID,無論是登陸用戶還是未登陸用戶都需要一個唯一 ID 來標識。
你也許已經想到了一個簡單的方案,那就是為每一個頁面一個獨立的 set 集合來存儲所有當天訪問過此頁面的用戶 ID。當一個請求過來時,我們使用 sadd 將用戶 ID 塞進去就可以了。通過 scard 可以取出這個集合的大小,這個數字就是這個頁面的 UV 數據。沒錯,這是一個非常簡單的方案。
但是,如果你的頁面訪問量非常大,比如一個爆款頁面幾千萬的 UV,你需要一個很大的 set 集合來統計,這就非常浪費空間。如果這樣的頁面很多,那所需要的存儲空間是驚人的。為這樣一個去重功能就耗費這樣多的存儲空間,值得么?有沒有更好的解決方案呢?
這要引入的一個解決方案,Redis 提供了 HyperLogLog 數據結構就是用來解決這種統計問題的。HyperLogLog 提供不精確的去重計數方案,雖然不精確但是也不是非常不精確,標準誤差是 0.81%,這樣的精確度已經可以滿足上面的 UV 統計需求了。
HyperLogLog 數據結構是 Redis 的高級數據結構,它非常有用,但是令人感到意外的是,使用過它的人非常少。
?
使用方法
HyperLogLog 提供了兩個指令 pfadd 和 pfcount,根據字面意義很好理解,一個是增加計數,一個是獲取計數。pfadd 用法和 set 集合的 sadd 是一樣的,來一個用戶 ID,就將用戶 ID 塞進去就是。pfcount 和 scard 用法是一樣的,直接獲取計數值。
?
層巒疊嶂 —— 布隆過濾器:
?
前面我們學會了使用 HyperLogLog 數據結構來進行估數,它非常有價值,可以解決很多精確度不高的統計需求。
但是如果我們想知道某一個值是不是已經在 HyperLogLog 結構里面了,它就無能為力了,它只提供了 pfadd 和 pfcount 方法,沒有提供 pfcontains 這種方法。
講個使用場景,比如我們在使用新聞客戶端看新聞時,它會給我們不停地推薦新的內容,它每次推薦時要去重,去掉那些已經看過的內容。問題來了,新聞客戶端推薦系統如何實現推送去重的?
你會想到服務器記錄了用戶看過的所有歷史記錄,當推薦系統推薦新聞時會從每個用戶的歷史記錄里進行篩選,過濾掉那些已經存在的記錄。問題是當用戶量很大,每個用戶看過的新聞又很多的情況下,這種方式,推薦系統的去重工作在性能上跟的上么?
?
?
?
實際上,如果歷史記錄存儲在關系數據庫里,去重就需要頻繁地對數據庫進行 exists 查詢,當系統并發量很高時,數據庫是很難扛住壓力的。
你可能又想到了緩存,但是如此多的歷史記錄全部緩存起來,那得浪費多大存儲空間啊?而且這個存儲空間是隨著時間線性增長,你撐得住一個月,你能撐得住幾年么?但是不緩存的話,性能又跟不上,這該怎么辦?
這時,布隆過濾器 (Bloom Filter) 閃亮登場了,它就是專門用來解決這種去重問題的。它在起到去重的同時,在空間上還能節省 90% 以上,只是稍微有那么點不精確,也就是有一定的誤判概率。
?
布隆過濾器是什么?
布隆過濾器可以理解為一個不怎么精確的 set 結構,當你使用它的 contains 方法判斷某個對象是否存在時,它可能會誤判。但是布隆過濾器也不是特別不精確,只要參數設置的合理,它的精確度可以控制的相對足夠精確,只會有小小的誤判概率。
當布隆過濾器說某個值存在時,這個值可能不存在;當它說不存在時,那就肯定不存在。打個比方,當它說不認識你時,肯定就不認識;當它說見過你時,可能根本就沒見過面,不過因為你的臉跟它認識的人中某臉比較相似 (某些熟臉的系數組合),所以誤判以前見過你。
?
Redis 中的布隆過濾器
Redis 官方提供的布隆過濾器到了 Redis 4.0 提供了插件功能之后才正式登場。布隆過濾器作為一個插件加載到 Redis Server 中,給 Redis 提供了強大的布隆去重功能。
下面我們來體驗一下 Redis 4.0 的布隆過濾器,為了省去繁瑣安裝過程,我們直接用 Docker 吧。
> docker pull redislabs/rebloom # 拉取鏡像 > docker run -p6379:6379 redislabs/rebloom # 運行容器 > redis-cli # 連接容器中的 redis 服務如果上面三條指令執行沒有問題,下面就可以體驗布隆過濾器了。
?
布隆過濾器基本使用
布隆過濾器有二個基本指令,bf.add?添加元素,bf.exists?查詢元素是否存在,它的用法和 set 集合的 sadd 和 sismember 差不多。注意?bf.add?只能一次添加一個元素,如果想要一次添加多個,就需要用到?bf.madd?指令。同樣如果需要一次查詢多個元素是否存在,就需要用到?bf.mexists?指令。
?
注意事項
布隆過濾器的initial_size估計的過大,會浪費存儲空間,估計的過小,就會影響準確率,用戶在使用之前一定要盡可能地精確估計好元素數量,還需要加上一定的冗余空間以避免實際元素可能會意外高出估計值很多。
布隆過濾器的error_rate越小,需要的存儲空間就越大,對于不需要過于精確的場合,error_rate設置稍大一點也無傷大雅。比如在新聞去重上而言,誤判率高一點只會讓小部分文章不能讓合適的人看到,文章的整體閱讀量不會因為這點誤判率就帶來巨大的改變。
?
近水樓臺 —— GeoHash:
?
Redis 在 3.2 版本以后增加了地理位置 GEO 模塊,意味著我們可以使用 Redis 來實現摩拜單車「附近的 Mobike」、美團和餓了么「附近的餐館」這樣的功能了。
如果要計算「附近的人」,也就是給定一個元素的坐標,然后計算這個坐標附近的其它元素,按照距離進行排序,該如何下手?
?
?
?
如果現在元素的經緯度坐標使用關系數據庫 (元素 id, 經度 x, 緯度 y) 存儲,你該如何計算?
首先,你不可能通過遍歷來計算所有的元素和目標元素的距離然后再進行排序,這個計算量太大了,性能指標肯定無法滿足。一般的方法都是通過矩形區域來限定元素的數量,然后對區域內的元素進行全量距離計算再排序。這樣可以明顯減少計算量。如何劃分矩形區域呢?可以指定一個半徑 r,使用一條 SQL 就可以圈出來。當用戶對篩出來的結果不滿意,那就擴大半徑繼續篩選。
select id from positions where x0-r < x < x0+r and y0-r < y < y0+r為了滿足高性能的矩形區域算法,數據表需要在經緯度坐標加上雙向復合索引 (x, y),這樣可以最大優化查詢性能。
但是數據庫查詢性能畢竟有限,如果「附近的人」查詢請求非常多,在高并發場合,這可能并不是一個很好的方案。
?
GeoHash 算法
業界比較通用的地理位置距離排序算法是 GeoHash 算法,Redis 也使用 GeoHash 算法。GeoHash 算法將二維的經緯度數據映射到一維的整數,這樣所有的元素都將在掛載到一條線上,距離靠近的二維坐標映射到一維后的點之間距離也會很接近。當我們想要計算「附近的人時」,首先將目標位置映射到這條線上,然后在這個一維的線上獲取附近的點就行了。
那這個映射算法具體是怎樣的呢?它將整個地球看成一個二維平面,然后劃分成了一系列正方形的方格,就好比圍棋棋盤。所有的地圖元素坐標都將放置于唯一的方格中。方格越小,坐標越精確。然后對這些方格進行整數編碼,越是靠近的方格編碼越是接近。那如何編碼呢?一個最簡單的方案就是切蛋糕法。設想一個正方形的蛋糕擺在你面前,二刀下去均分分成四塊小正方形,這四個小正方形可以分別標記為 00,01,10,11 四個二進制整數。然后對每一個小正方形繼續用二刀法切割一下,這時每個小小正方形就可以使用 4bit 的二進制整數予以表示。然后繼續切下去,正方形就會越來越小,二進制整數也會越來越長,精確度就會越來越高。
?
?
上面的例子中使用的是二刀法,真實算法中還會有很多其它刀法,最終編碼出來的整數數字也都不一樣。
編碼之后,每個地圖元素的坐標都將變成一個整數,通過這個整數可以還原出元素的坐標,整數越長,還原出來的坐標值的損失程度就越小。對于「附近的人」這個功能而言,損失的一點精確度可以忽略不計。
?
在使用 Redis 進行 Geo 查詢時,我們要時刻想到它的內部結構實際上只是一個 zset(skiplist)。通過 zset 的 score 排序就可以得到坐標附近的其它元素 (實際情況要復雜一些,不過這樣理解足夠了),通過將 score 還原成坐標值就可以得到元素的原始坐標。
?
Redis 的 Geo 指令基本使用
Redis 提供的 Geo 指令只有 6 個,使用時,讀者務必再次想起,它只是一個普通的 zset 結構。
?
增加
geoadd 指令攜帶集合名稱以及多個經緯度名稱三元組,注意這里可以加入多個三元組
127.0.0.1:6379> geoadd company 116.48105 39.996794 juejin (integer) 1 127.0.0.1:6379> geoadd company 116.514203 39.905409 ireader (integer) 1 127.0.0.1:6379> geoadd company 116.489033 40.007669 meituan (integer) 1 127.0.0.1:6379> geoadd company 116.562108 39.787602 jd 116.334255 40.027400 xiaomi (integer) 2也許你會問為什么 Redis 沒有提供 geo 刪除指令?前面我們提到 geo 存儲結構上使用的是 zset,意味著我們可以使用 zset 相關的指令來操作 geo 數據,所以刪除指令可以直接使用 zrem 指令即可。
距離
geodist 指令可以用來計算兩個元素之間的距離,攜帶集合名稱、2 個名稱和距離單位。
127.0.0.1:6379> geodist company juejin ireader km "10.5501" 127.0.0.1:6379> geodist company juejin meituan km "1.3878" 127.0.0.1:6379> geodist company juejin jd km "24.2739" 127.0.0.1:6379> geodist company juejin xiaomi km "12.9606" 127.0.0.1:6379> geodist company juejin juejin km "0.0000"我們可以看到掘金離美團最近,因為它們都在望京。距離單位可以是 m、km、ml、ft,分別代表米、千米、英里和尺。
獲取元素位置
geopos 指令可以獲取集合中任意元素的經緯度坐標,可以一次獲取多個。
127.0.0.1:6379> geopos company juejin 1) 1) "116.48104995489120483"2) "39.99679348858259686" 127.0.0.1:6379> geopos company ireader 1) 1) "116.5142020583152771"2) "39.90540918662494363" 127.0.0.1:6379> geopos company juejin ireader 1) 1) "116.48104995489120483"2) "39.99679348858259686" 2) 1) "116.5142020583152771"2) "39.90540918662494363"我們觀察到獲取的經緯度坐標和 geoadd 進去的坐標有輕微的誤差,原因是 geohash 對二維坐標進行的一維映射是有損的,通過映射再還原回來的值會出現較小的差別。對于「附近的人」這種功能來說,這點誤差根本不是事。
獲取元素的 hash 值
geohash 可以獲取元素的經緯度編碼字符串,上面已經提到,它是 base32 編碼。
127.0.0.1:6379> geohash company ireader 1) "wx4g52e1ce0" 127.0.0.1:6379> geohash company juejin 1) "wx4gd94yjn0"?
附近的公司
georadiusbymember 指令是最為關鍵的指令,它可以用來查詢指定元素附近的其它元素,它的參數非常復雜。
# 范圍 20 公里以內最多 3 個元素按距離正排,它不會排除自身 127.0.0.1:6379> georadiusbymember company ireader 20 km count 3 asc 1) "ireader" 2) "juejin" 3) "meituan" # 范圍 20 公里以內最多 3 個元素按距離倒排,排除本身 127.0.0.1:6379> georadiusbymember company ireader 20 km count 3 desc 1) "jd" 2) "meituan" 3) "juejin" # 三個可選參數 withcoord withdist withhash 用來攜帶附加參數 # withdist 很有用,它可以用來顯示距離 127.0.0.1:6379> georadiusbymember company ireader 20 km withcoord withdist withhash count 3 asc 1) 1) "ireader"2) "0.0000"3) (integer) 40698860083613984) 1) "116.5142020583152771"2) "39.90540918662494363" 2) 1) "juejin"2) "10.5501"3) (integer) 40698871543881674) 1) "116.48104995489120483"2) "39.99679348858259686" 3) 1) "meituan"2) "11.5748"3) (integer) 40698871790834784) 1) "116.48903220891952515"2) "40.00766997707732031"除了 georadiusbymember 指令根據元素查詢附近的元素,Redis 還提供了根據坐標值來查詢附近的元素,這個指令更加有用,它可以根據用戶的定位來計算「附近的車」,「附近的餐館」等。它的參數和 georadiusbymember 基本一致,除了將目標元素改成經緯度坐標值。
127.0.0.1:6379> georadius company 116.514202 39.905409 20 km withdist count 3 asc 1) 1) "ireader"2) "0.0000" 2) 1) "juejin"2) "10.5501" 3) 1) "meituan"2) "11.5748"?
小結 & 注意事項
在一個地圖應用中,車的數據、餐館的數據、人的數據可能會有百萬千萬條,如果使用 Redis 的 Geo 數據結構,它們將全部放在一個 zset 集合中。在 Redis 的集群環境中,集合可能會從一個節點遷移到另一個節點,如果單個 key 的數據過大,會對集群的遷移工作造成較大的影響,在集群環境中單個 key 對應的數據量不宜超過 1M,否則會導致集群遷移出現卡頓現象,影響線上服務的正常運行。
所以,這里建議 Geo 的數據使用單獨的 Redis 實例部署,不使用集群環境。
如果數據量過億甚至更大,就需要對 Geo 數據進行拆分,按國家拆分、按省拆分,按市拆分,在人口特大城市甚至可以按區拆分。這樣就可以顯著降低單個 zset 集合的大小。
?
大海撈針 —— Scan:
在平時線上 Redis 維護工作中,有時候需要從 Redis 實例成千上萬的 key 中找出特定前綴的 key 列表來手動處理數據,可能是修改它的值,也可能是刪除 key。這里就有一個問題,如何從海量的 key 中找出滿足特定前綴的 key 列表來?
Redis 提供了一個簡單暴力的指令?keys?用來列出所有滿足特定正則字符串規則的 key。
127.0.0.1:6379> set codehole1 a OK 127.0.0.1:6379> set codehole2 b OK 127.0.0.1:6379> set codehole3 c OK 127.0.0.1:6379> set code1hole a OK 127.0.0.1:6379> set code2hole b OK 127.0.0.1:6379> set code3hole b OK 127.0.0.1:6379> keys * 1) "codehole1" 2) "code3hole" 3) "codehole3" 4) "code2hole" 5) "codehole2" 6) "code1hole" 127.0.0.1:6379> keys codehole* 1) "codehole1" 2) "codehole3" 3) "codehole2" 127.0.0.1:6379> keys code*hole 1) "code3hole" 2) "code2hole" 3) "code1hole"這個指令使用非常簡單,提供一個簡單的正則字符串即可,但是有很明顯的兩個缺點。
面對這兩個顯著的缺點該怎么辦呢?
Redis 為了解決這個問題,它在 2.8 版本中加入了大海撈針的指令——scan。scan?相比?keys?具備有以下特點:
scan 基礎使用
在使用之前,讓我們往 Redis 里插入 10000 條數據來進行測試
import redisclient = redis.StrictRedis() for i in range(10000):client.set("key%d" % i, i)好,Redis 中現在有了 10000 條數據,接下來我們找出以 key99 開頭 key 列表。
scan 參數提供了三個參數,第一個是?cursor 整數值,第二個是?key 的正則模式,第三個是遍歷的 limit hint。第一次遍歷時,cursor 值為 0,然后將返回結果中第一個整數值作為下一次遍歷的 cursor。一直遍歷到返回的 cursor 值為 0 時結束。
127.0.0.1:6379> scan 0 match key99* count 1000 1) "13976" 2) 1) "key9911"2) "key9974"3) "key9994"4) "key9910"5) "key9907"6) "key9989"7) "key9971"8) "key99"9) "key9966"10) "key992"11) "key9903"12) "key9905" 127.0.0.1:6379> scan 13976 match key99* count 1000 1) "1996" 2) 1) "key9982"2) "key9997"3) "key9963"4) "key996"5) "key9912"6) "key9999"7) "key9921"8) "key994"9) "key9956"10) "key9919" 127.0.0.1:6379> scan 1996 match key99* count 1000 1) "12594" 2) 1) "key9939"2) "key9941"3) "key9967"4) "key9938"5) "key9906"6) "key999"7) "key9909"8) "key9933"9) "key9992" ...... 127.0.0.1:6379> scan 11687 match key99* count 1000 1) "0" 2) 1) "key9969"2) "key998"3) "key9986"4) "key9968"5) "key9965"6) "key9990"7) "key9915"8) "key9928"9) "key9908"10) "key9929"11) "key9944"從上面的過程可以看到雖然提供的 limit 是 1000,但是返回的結果只有 10 個左右。因為這個 limit 不是限定返回結果的數量,而是限定服務器單次遍歷的字典槽位數量(約等于)。如果將 limit 設置為 10,你會發現返回結果是空的,但是游標值不為零,意味著遍歷還沒結束。
?
字典的結構
在 Redis 中所有的 key 都存儲在一個很大的字典中,這個字典的結構和 Java 中的 HashMap 一樣,是一維數組 + 二維鏈表結構,第一維數組的大小總是 2^n(n>=0),擴容一次數組大小空間加倍,也就是 n++。
?
?
?
scan 指令返回的游標就是第一維數組的位置索引,我們將這個位置索引稱為槽 (slot)。如果不考慮字典的擴容縮容,直接按數組下標挨個遍歷就行了。limit 參數就表示需要遍歷的槽位數,之所以返回的結果可能多可能少,是因為不是所有的槽位上都會掛接鏈表,有些槽位可能是空的,還有些槽位上掛接的鏈表上的元素可能會有多個。每一次遍歷都會將 limit 數量的槽位上掛接的所有鏈表元素進行模式匹配過濾后,一次性返回給客戶端。
?
scan 遍歷順序
scan 的遍歷順序非常特別。它不是從第一維數組的第 0 位一直遍歷到末尾,而是采用了高位進位加法來遍歷。之所以使用這樣特殊的方式進行遍歷,是考慮到字典的擴容和縮容時避免槽位的遍歷重復和遺漏。
首先我們用動畫演示一下普通加法和高位進位加法的區別。
?
?
?
從動畫中可以看出高位進位法從左邊加,進位往右邊移動,同普通加法正好相反。但是最終它們都會遍歷所有的槽位并且沒有重復。
?
字典擴容:
Java 中的 HashMap 有擴容的概念,當 loadFactor 達到閾值時,需要重新分配一個新的 2 倍大小的數組,然后將所有的元素全部 rehash 掛到新的數組下面。rehash 就是將元素的 hash 值對數組長度進行取模運算,因為長度變了,所以每個元素掛接的槽位可能也發生了變化。又因為數組的長度是 2^n 次方,所以取模運算等價于位與操作。
a mod 8 = a & (8-1) = a & 7 a mod 16 = a & (16-1) = a & 15 a mod 32 = a & (32-1) = a & 31這里的 7, 15, 31 稱之為字典的 mask 值,mask 的作用就是保留 hash 值的低位,高位都被設置為 0。
接下來我們看看 rehash 前后元素槽位的變化。
假設當前的字典的數組長度由 8 位擴容到 16 位,那么 3 號槽位 011 將會被 rehash 到 3 號槽位和 11 號槽位,也就是說該槽位鏈表中大約有一半的元素還是 3 號槽位,其它的元素會放到 11 號槽位,11 這個數字的二進制是 1011,就是對 3 的二進制 011 增加了一個高位 1。
?
?
?
抽象一點說,假設開始槽位的二進制數是 xxx,那么該槽位中的元素將被 rehash 到 0xxx 和 1xxx(xxx+8) 中。 如果字典長度由 16 位擴容到 32 位,那么對于二進制槽位 xxxx 中的元素將被 rehash 到 0xxxx 和 1xxxx(xxxx+16) 中。
對比擴容縮容前后的遍歷順序
?
?
?
觀察這張圖,我們發現采用高位進位加法的遍歷順序,rehash 后的槽位在遍歷順序上是相鄰的。
假設當前要即將遍歷 110 這個位置 (橙色),那么擴容后,當前槽位上所有的元素對應的新槽位是 0110 和 1110(深綠色),也就是在槽位的二進制數增加一個高位 0 或 1。這時我們可以直接從 0110 這個槽位開始往后繼續遍歷,0110 槽位之前的所有槽位都是已經遍歷過的,這樣就可以避免擴容后對已經遍歷過的槽位進行重復遍歷。
再考慮縮容,假設當前即將遍歷 110 這個位置 (橙色),那么縮容后,當前槽位所有的元素對應的新槽位是 10(深綠色),也就是去掉槽位二進制最高位。這時我們可以直接從 10 這個槽位繼續往后遍歷,10 槽位之前的所有槽位都是已經遍歷過的,這樣就可以避免縮容的重復遍歷。不過縮容還是不太一樣,它會對圖中 010 這個槽位上的元素進行重復遍歷,因為縮融后 10 槽位的元素是 010 和 110 上掛接的元素的融合。
漸進式 rehash:
Java 的 HashMap 在擴容時會一次性將舊數組下掛接的元素全部轉移到新數組下面。如果 HashMap 中元素特別多,線程就會出現卡頓現象。Redis 為了解決這個問題,它采用漸進式 rehash。
它會同時保留舊數組和新數組,然后在定時任務中以及后續對 hash 的指令操作中漸漸地將舊數組中掛接的元素遷移到新數組上。這意味著要操作處于 rehash 中的字典,需要同時訪問新舊兩個數組結構。如果在舊數組下面找不到元素,還需要去新數組下面去尋找。
scan 也需要考慮這個問題,對與 rehash 中的字典,它需要同時掃描新舊槽位,然后將結果融合后返回給客戶端。
?
更多的 scan 指令
scan 指令是一系列指令,除了可以遍歷所有的 key 之外,還可以對指定的容器集合進行遍歷。比如 zscan 遍歷 zset 集合元素,hscan 遍歷 hash 字典的元素、sscan 遍歷 set 集合的元素。
它們的原理同 scan 都會類似的,因為 hash 底層就是字典,set 也是一個特殊的 hash(所有的 value 指向同一個元素),zset 內部也使用了字典來存儲所有的元素內容。
大 key 掃描
有時候會因為業務人員使用不當,在 Redis 實例中會形成很大的對象,比如一個很大的 hash,一個很大的 zset 這都是經常出現的。這樣的對象對 Redis 的集群數據遷移帶來了很大的問題,因為在集群環境下,如果某個 key 太大,會數據導致遷移卡頓。另外在內存分配上,如果一個 key 太大,那么當它需要擴容時,會一次性申請更大的一塊內存,這也會導致卡頓。如果這個大 key 被刪除,內存會一次性回收,卡頓現象會再一次產生。
在平時的業務開發中,要盡量避免大 key 的產生。
如果你觀察到 Redis 的內存大起大落,這極有可能是因為大 key 導致的,這時候你就需要定位出具體是那個 key,進一步定位出具體的業務來源,然后再改進相關業務代碼設計。
那如何定位大 key 呢?
為了避免對線上 Redis 帶來卡頓,這就要用到 scan 指令,對于掃描出來的每一個 key,使用 type 指令獲得 key 的類型,然后使用相應數據結構的 size 或者 len 方法來得到它的大小,對于每一種類型,保留大小的前 N 名作為掃描結果展示出來。
上面這樣的過程需要編寫腳本,比較繁瑣,不過 Redis 官方已經在 redis-cli 指令中提供了這樣的掃描功能,我們可以直接拿來即用。
redis-cli -h 127.0.0.1 -p 7001 –-bigkeys如果你擔心這個指令會大幅抬升 Redis 的 ops 導致線上報警,還可以增加一個休眠參數。
redis-cli -h 127.0.0.1 -p 7001 –-bigkeys -i 0.1上面這個指令每隔 100 條 scan 指令就會休眠 0.1s,ops 就不會劇烈抬升,但是掃描的時間會變長。
?
鞭辟入里 —— 線程 IO 模型:
Redis 是個單線程程序!這點必須銘記。
也許你會懷疑高并發的 Redis 中間件怎么可能是單線程。很抱歉,它就是單線程,你的懷疑暴露了你基礎知識的不足。莫要瞧不起單線程,除了 Redis 之外,Node.js 也是單線程,Nginx 也是單線程,但是它們都是服務器高性能的典范。
Redis 單線程為什么還能這么快?
因為它所有的數據都在內存中,所有的運算都是內存級別的運算。正因為 Redis 是單線程,所以要小心使用 Redis 指令,對于那些時間復雜度為 O(n) 級別的指令,一定要謹慎使用,一不小心就可能會導致 Redis 卡頓。
非阻塞 IO
當我們調用套接字的讀寫方法,默認它們是阻塞的,比如read方法要傳遞進去一個參數n,表示最多讀取這么多字節后再返回,如果一個字節都沒有,那么線程就會卡在那里,直到新的數據到來或者連接關閉了,read方法才可以返回,線程才能繼續處理。而write方法一般來說不會阻塞,除非內核為套接字分配的寫緩沖區已經滿了,write方法就會阻塞,直到緩存區中有空閑空間挪出來了。
?
?
?
非阻塞 IO 在套接字對象上提供了一個選項Non_Blocking,當這個選項打開時,讀寫方法不會阻塞,而是能讀多少讀多少,能寫多少寫多少。能讀多少取決于內核為套接字分配的讀緩沖區內部的數據字節數,能寫多少取決于內核為套接字分配的寫緩沖區的空閑空間字節數。讀方法和寫方法都會通過返回值來告知程序實際讀寫了多少字節。
有了非阻塞 IO 意味著線程在讀寫 IO 時可以不必再阻塞了,讀寫可以瞬間完成然后線程可以繼續干別的事了。
?
事件輪詢 (多路復用)
非阻塞 IO 有個問題,那就是線程要讀數據,結果讀了一部分就返回了,線程如何知道何時才應該繼續讀。也就是當數據到來時,線程如何得到通知。寫也是一樣,如果緩沖區滿了,寫不完,剩下的數據何時才應該繼續寫,線程也應該得到通知。
?
?
?
事件輪詢 API 就是用來解決這個問題的,最簡單的事件輪詢 API 是select函數,它是操作系統提供給用戶程序的 API。輸入是讀寫描述符列表read_fds & write_fds,輸出是與之對應的可讀可寫事件。同時還提供了一個timeout參數,如果沒有任何事件到來,那么就最多等待timeout時間,線程處于阻塞狀態。一旦期間有任何事件到來,就可以立即返回。時間過了之后還是沒有任何事件到來,也會立即返回。拿到事件后,線程就可以繼續挨個處理相應的事件。處理完了繼續過來輪詢。于是線程就進入了一個死循環,我們把這個死循環稱為事件循環,一個循環為一個周期。
每個客戶端套接字socket都有對應的讀寫文件描述符。
read_events, write_events = select(read_fds, write_fds, timeout) for event in read_events:handle_read(event.fd) for event in write_events:handle_write(event.fd) handle_others() # 處理其它事情,如定時任務等因為我們通過select系統調用同時處理多個通道描述符的讀寫事件,因此我們將這類系統調用稱為多路復用 API。現代操作系統的多路復用 API 已經不再使用select系統調用,而改用epoll(linux)和kqueue(freebsd & macosx),因為 select 系統調用的性能在描述符特別多時性能會非常差。它們使用起來可能在形式上略有差異,但是本質上都是差不多的,都可以使用上面的偽代碼邏輯進行理解。
服務器套接字serversocket對象的讀操作是指調用accept接受客戶端新連接。何時有新連接到來,也是通過select系統調用的讀事件來得到通知的。
事件輪詢 API 就是 Java 語言里面的 NIO 技術
Java 的 NIO 并不是 Java 特有的技術,其它計算機語言都有這個技術,只不過換了一個詞匯,不叫 NIO 而已。
?
指令隊列
Redis 會將每個客戶端套接字都關聯一個指令隊列。客戶端的指令通過隊列來排隊進行順序處理,先到先服務。
響應隊列
Redis 同樣也會為每個客戶端套接字關聯一個響應隊列。Redis 服務器通過響應隊列來將指令的返回結果回復給客戶端。 如果隊列為空,那么意味著連接暫時處于空閑狀態,不需要去獲取寫事件,也就是可以將當前的客戶端描述符從write_fds里面移出來。等到隊列有數據了,再將描述符放進去。避免select系統調用立即返回寫事件,結果發現沒什么數據可以寫。出這種情況的線程會飆高 CPU。
定時任務??
服務器處理要響應 IO 事件外,還要處理其它事情。比如定時任務就是非常重要的一件事。如果線程阻塞在 select 系統調用上,定時任務將無法得到準時調度。那 Redis 是如何解決這個問題的呢?
Redis 的定時任務會記錄在一個稱為最小堆的數據結構中。這個堆中,最快要執行的任務排在堆的最上方。在每個循環周期,Redis 都會將最小堆里面已經到點的任務立即進行處理。處理完畢后,將最快要執行的任務還需要的時間記錄下來,這個時間就是select系統調用的timeout參數。因為 Redis 知道未來timeout時間內,沒有其它定時任務需要處理,所以可以安心睡眠timeout的時間。
Nginx 和 Node 的事件處理原理和 Redis 也是類似的
?
交頭接耳 —— 通信協議:
Redis 的作者認為數據庫系統的瓶頸一般不在于網絡流量,而是數據庫自身內部邏輯處理上。所以即使 Redis 使用了浪費流量的文本協議,依然可以取得極高的訪問性能。Redis 將所有數據都放在內存,用一個單線程對外提供服務,單個節點在跑滿一個 CPU 核心的情況下可以達到了 10w/s 的超高 QPS。
?
RESP(Redis Serialization Protocol)
RESP 是 Redis 序列化協議的簡寫。它是一種直觀的文本協議,優勢在于實現異常簡單,解析性能極好。
Redis 協議將傳輸的結構數據分為 5 種最小單元類型,單元結束時統一加上回車換行符號\r\n。
單行字符串?hello world
+hello world\r\n多行字符串?hello world
$11\r\nhello world\r\n多行字符串當然也可以表示單行字符串。
整數?1024
:1024\r\n錯誤?參數類型錯誤
-WRONGTYPE Operation against a key holding the wrong kind of value\r\n數組?[1,2,3]
*3\r\n:1\r\n:2\r\n:3\r\nNULL?用多行字符串表示,不過長度要寫成-1。
$-1\r\n空串?用多行字符串表示,長度填 0。
$0\r\n\r\n注意這里有兩個\r\n。為什么是兩個?因為兩個\r\n之間,隔的是空串。
客戶端 -> 服務器
客戶端向服務器發送的指令只有一種格式,多行字符串數組。比如一個簡單的 set 指令set author codehole會被序列化成下面的字符串。
*3\r\n$3\r\nset\r\n$6\r\nauthor\r\n$8\r\ncodehole\r\n在控制臺輸出這個字符串如下,可以看出這是很好閱讀的一種格式。
*3 $3 set $6 author $8 codehole服務器 -> 客戶端
服務器向客戶端回復的響應要支持多種數據結構,所以消息響應在結構上要復雜不少。不過再復雜的響應消息也是以上 5 中基本類型的組合。
單行字符串響應
127.0.0.1:6379> set author codehole OK這里的 OK 就是單行響應,沒有使用引號括起來。
+OK錯誤響應
127.0.0.1:6379> incr author (error) ERR value is not an integer or out of range試圖對一個字符串進行自增,服務器拋出一個通用的錯誤。
-ERR value is not an integer or out of range整數響應
127.0.0.1:6379> incr books (integer) 1這里的1就是整數響應
:1多行字符串響應
127.0.0.1:6379> get author "codehole"這里使用雙引號括起來的字符串就是多行字符串響應
$8 codehole數組響應
127.0.0.1:6379> hset info name laoqian (integer) 1 127.0.0.1:6379> hset info age 30 (integer) 1 127.0.0.1:6379> hset info sex male (integer) 1 127.0.0.1:6379> hgetall info 1) "name" 2) "laoqian" 3) "age" 4) "30" 5) "sex" 6) "male"這里的 hgetall 命令返回的就是一個數組,第 0|2|4 位置的字符串是 hash 表的 key,第 1|3|5 位置的字符串是 value,客戶端負責將數組組裝成字典再返回。
*6 $4 name $6 laoqian $3 age $2 30 $3 sex $4 male嵌套
127.0.0.1:6379> scan 0 1) "0" 2) 1) "info"2) "books"3) "author"scan 命令用來掃描服務器包含的所有 key 列表,它是以游標的形式獲取,一次只獲取一部分。
scan 命令返回的是一個嵌套數組。數組的第一個值表示游標的值,如果這個值為零,說明已經遍歷完畢。如果不為零,使用這個值作為 scan 命令的參數進行下一次遍歷。數組的第二個值又是一個數組,這個數組就是 key 列表。
*2 $1 0 *3 $4 info $5 books $6 author?
小結
Redis 協議里有大量冗余的回車換行符,但是這不影響它成為互聯網技術領域非常受歡迎的一個文本協議。有很多開源項目使用 RESP 作為它的通訊協議。在技術領域性能并不總是一切,還有簡單性、易理解性和易實現性,這些都需要進行適當權衡。
?
未雨綢繆 —— 持久化:
Redis 的數據全部在內存里,如果突然宕機,數據就會全部丟失,因此必須有一種機制來保證 Redis 的數據不會因為故障而丟失,這種機制就是 Redis 的持久化機制。
Redis 的持久化機制有兩種,第一種是快照,第二種是 AOF 日志。快照是一次全量備份,AOF 日志是連續的增量備份。快照是內存數據的二進制序列化形式,在存儲上非常緊湊,而 AOF 日志記錄的是內存數據修改的指令記錄文本。AOF 日志在長期的運行過程中會變的無比龐大,數據庫重啟時需要加載 AOF 日志進行指令重放,這個時間就會無比漫長。所以需要定期進行 AOF 重寫,給 AOF 日志進行瘦身。
?
快照原理
我們知道 Redis 是單線程程序,這個線程要同時負責多個客戶端套接字的并發讀寫操作和內存數據結構的邏輯讀寫。
在服務線上請求的同時,Redis 還需要進行內存快照,內存快照要求 Redis 必須進行文件 IO 操作,可文件 IO 操作是不能使用多路復用 API。
這意味著單線程同時在服務線上的請求還要進行文件 IO 操作,文件 IO 操作會嚴重拖垮服務器請求的性能。還有個重要的問題是為了不阻塞線上的業務,就需要邊持久化邊響應客戶端請求。持久化的同時,內存數據結構還在改變,比如一個大型的 hash 字典正在持久化,結果一個請求過來把它給刪掉了,還沒持久化完呢,這尼瑪要怎么搞?
?
那該怎么辦呢?
Redis 使用操作系統的多進程 COW(Copy On Write) 機制來實現快照持久化,這個機制很有意思,也很少人知道。多進程 COW 也是鑒定程序員知識廣度的一個重要指標。
fork(多進程)
Redis 在持久化時會調用 glibc 的函數fork產生一個子進程,快照持久化完全交給子進程來處理,父進程繼續處理客戶端請求。子進程剛剛產生時,它和父進程共享內存里面的代碼段和數據段。這時你可以將父子進程想像成一個連體嬰兒,共享身體。這是 Linux 操作系統的機制,為了節約內存資源,所以盡可能讓它們共享起來。在進程分離的一瞬間,內存的增長幾乎沒有明顯變化。
?
?
?
用 Python 語言描述進程分離的邏輯如下。fork函數會在父子進程同時返回,在父進程里返回子進程的 pid,在子進程里返回零。如果操作系統內存資源不足,pid 就會是負數,表示fork失敗。
pid = os.fork() if pid > 0:handle_client_requests() # 父進程繼續處理客戶端請求 if pid == 0:handle_snapshot_write() # 子進程處理快照寫磁盤 if pid < 0:# fork error子進程做數據持久化,它不會修改現有的內存數據結構,它只是對數據結構進行遍歷讀取,然后序列化寫到磁盤中。但是父進程不一樣,它必須持續服務客戶端請求,然后對內存數據結構進行不間斷的修改。
重點::這個時候就會使用操作系統的 COW 機制來進行數據段頁面的分離。數據段是由很多操作系統的頁面組合而成,當父進程對其中一個頁面的數據進行修改時,會將被共享的頁面復制一份分離出來,然后對這個復制的頁面進行修改。這時子進程相應的頁面是沒有變化的,還是進程產生時那一瞬間的數據。
?
?
?
隨著父進程修改操作的持續進行,越來越多的共享頁面被分離出來,內存就會持續增長。但是也不會超過原有數據內存的 2 倍大小。另外一個 Redis 實例里冷數據占的比例往往是比較高的,所以很少會出現所有的頁面都會被分離,被分離的往往只有其中一部分頁面。每個頁面的大小只有 4K,一個 Redis 實例里面一般都會有成千上萬的頁面。
子進程因為數據沒有變化,它能看到的內存里的數據在進程產生的一瞬間就凝固了,再也不會改變,這也是為什么 Redis 的持久化叫「快照」的原因。接下來子進程就可以非常安心的遍歷數據了進行序列化寫磁盤了。
?
?
AOF 原理
AOF 日志存儲的是 Redis 服務器的順序指令序列,AOF 日志只記錄對內存進行修改的指令記錄。
假設 AOF 日志記錄了自 Redis 實例創建以來所有的修改性指令序列,那么就可以通過對一個空的 Redis 實例順序執行所有的指令,也就是「重放」,來恢復 Redis 當前實例的內存數據結構的狀態。(恢復的過程)
Redis 會在收到客戶端修改指令后,進行參數校驗進行邏輯處理后,如果沒問題,就立即將該指令文本存儲到 AOF 日志中,也就是先執行指令才將日志存盤。這點不同于leveldb、hbase等存儲引擎,它們都是先存儲日志再做邏輯處理。
Redis 在長期運行的過程中,AOF 的日志會越變越長。如果實例宕機重啟,重放整個 AOF 日志會非常耗時,導致長時間 Redis 無法對外提供服務。所以需要對 AOF 日志瘦身。
AOF 重寫
Redis 提供了 bgrewriteaof 指令用于對 AOF 日志進行瘦身。其原理就是開辟一個子進程對內存進行遍歷轉換成一系列 Redis 的操作指令,序列化到一個新的 AOF 日志文件中。序列化完畢后再將操作期間發生的增量 AOF 日志追加到這個新的 AOF 日志文件中,追加完畢后就立即替代舊的 AOF 日志文件了,瘦身工作就完成了。
fsync
AOF 日志是以文件的形式存在的,當程序對 AOF 日志文件進行寫操作時,實際上是將內容寫到了內核為文件描述符分配的一個內存緩存中,然后內核會異步將臟數據刷回到磁盤的。
這就意味著如果機器突然宕機,AOF 日志內容可能還沒有來得及完全刷到磁盤中,這個時候就會出現日志丟失。那該怎么辦?
Linux 的glibc提供了fsync(int fd)函數可以將指定文件的內容強制從內核緩存刷到磁盤。只要 Redis 進程實時調用 fsync 函數就可以保證 aof 日志不丟失。但是 fsync 是一個磁盤 IO 操作,它很慢!如果 Redis 執行一條指令就要 fsync 一次,那么 Redis 高性能的地位就不保了。
所以在生產環境的服務器中,Redis 通常是每隔 1s 左右執行一次 fsync 操作,周期 1s 是可以配置的。這是在數據安全性和性能之間做了一個折中,在保持高性能的同時,盡可能使得數據少丟失。
Redis 同樣也提供了另外兩種策略,一個是永不 fsync——讓操作系統來決定何時同步磁盤,很不安全,另一個是來一個指令就 fsync 一次——非常慢。但是在生產環境基本不會使用,了解一下即可。
運維
快照是通過開啟子進程的方式進行的,它是一個比較耗資源的操作。
所以通常 Redis 的主節點是不會進行持久化操作,持久化操作主要在從節點進行。從節點是備份節點,沒有來自客戶端請求的壓力,它的操作系統資源往往比較充沛。
但是如果出現網絡分區,從節點長期連不上主節點,就會出現數據不一致的問題,特別是在網絡分區出現的情況下又不小心主節點宕機了,那么數據就會丟失,所以在生產環境要做好實時監控工作,保證網絡暢通或者能快速修復。另外還應該再增加一個從節點以降低網絡分區的概率,只要有一個從節點數據同步正常,數據也就不會輕易丟失。
?
Redis 4.0 混合持久化
重啟 Redis 時,我們很少使用 rdb 來恢復內存狀態,因為會丟失大量數據。我們通常使用 AOF 日志重放,但是重放 AOF 日志性能相對 rdb 來說要慢很多,這樣在 Redis 實例很大的情況下,啟動需要花費很長的時間。
Redis 4.0 為了解決這個問題,帶來了一個新的持久化選項——混合持久化。將 rdb 文件的內容和增量的 AOF 日志文件存在一起。這里的 AOF 日志不再是全量的日志,而是自持久化開始到持久化結束的這段時間發生的增量 AOF 日志,通常這部分 AOF 日志很小。
?
?
?
于是在 Redis 重啟的時候,可以先加載 rdb 的內容,然后再重放增量 AOF 日志就可以完全替代之前的 AOF 全量文件重放,重啟效率因此大幅得到提升。
?
雷厲風行 —— 管道:
大多數同學一直以來對 Redis 管道有一個誤解,他們以為這是 Redis 服務器提供的一種特別的技術,有了這種技術就可以加速 Redis 的存取效率。但是實際上 Redis 管道 (Pipeline) 本身并不是 Redis 服務器直接提供的技術,這個技術本質上是由客戶端提供的,跟服務器沒有什么直接的關系。下面我們對這塊做一個深入探究。
?
Redis 的消息交互
當我們使用客戶端對 Redis 進行一次操作時,如下圖所示,客戶端將請求傳送給服務器,服務器處理完畢后,再將響應回復給客戶端。這要花費一個網絡數據包來回的時間。
?
?
?
如果連續執行多條指令,那就會花費多個網絡數據包來回的時間。如下圖所示。
?
?
?
回到客戶端代碼層面,客戶端是經歷了寫-讀-寫-讀四個操作才完整地執行了兩條指令。
?
?
?
現在如果我們調整讀寫順序,改成寫—寫-讀-讀,這兩個指令同樣可以正常完成。
?
?
?
兩個連續的寫操作和兩個連續的讀操作總共只會花費一次網絡來回,就好比連續的 write 操作合并了,連續的 read 操作也合并了一樣。
?
?
?
這便是管道操作的本質,服務器根本沒有任何區別對待,還是收到一條消息,執行一條消息,回復一條消息的正常的流程。客戶端通過對管道中的指令列表改變讀寫順序就可以大幅節省 IO 時間。管道中指令越多,效果越好。
?
管道壓力測試
接下來我們實踐一下管道的力量。
Redis 自帶了一個壓力測試工具redis-benchmark,使用這個工具就可以進行管道測試。
首先我們對一個普通的 set 指令進行壓測,QPS 大約 5w/s。
> redis-benchmark -t set -q SET: 51975.05 requests per second我們加入管道選項-P參數,它表示單個管道內并行的請求數量,看下面P=2,QPS 達到了 9w/s。
> redis-benchmark -t set -P 2 -q SET: 91240.88 requests per second再看看P=3,QPS 達到了 10w/s。
SET: 102354.15 requests per second但如果再繼續提升 P 參數,發現 QPS 已經上不去了。這是為什么呢?
因為這里 CPU 處理能力已經達到了瓶頸,Redis 的單線程 CPU 已經飆到了 100%,所以無法再繼續提升了。
?
深入理解管道本質
接下來我們深入分析一個請求交互的流程,真實的情況是它很復雜,因為要經過網絡協議棧,這個就得深入內核了。
?
?
?
上圖就是一個完整的請求交互流程圖。我用文字來仔細描述一遍:
其中步驟 5~8 和 1~4 是一樣的,只不過方向是反過來的,一個是請求,一個是響應。
我們開始以為?write?操作是要等到對方收到消息才會返回,但實際上不是這樣的。write?操作只負責將數據寫到本地操作系統內核的發送緩沖然后就返回了。剩下的事交給操作系統內核異步將數據送到目標機器。但是如果發送緩沖滿了,那么就需要等待緩沖空出空閑空間來,這個就是寫操作 IO 操作的真正耗時。
我們開始以為?read?操作是從目標機器拉取數據,但實際上不是這樣的。read?操作只負責將數據從本地操作系統內核的接收緩沖中取出來就了事了。但是如果緩沖是空的,那么就需要等待數據到來,這個就是讀操作 IO 操作的真正耗時。
所以對于value = redis.get(key)這樣一個簡單的請求來說,write操作幾乎沒有耗時,直接寫到發送緩沖就返回,而read就會比較耗時了,因為它要等待消息經過網絡路由到目標機器處理后的響應消息,再回送到當前的內核讀緩沖才可以返回。這才是一個網絡來回的真正開銷。
而對于管道來說,連續的write操作根本就沒有耗時,之后第一個read操作會等待一個網絡的來回開銷,然后所有的響應消息就都已經回送到內核的讀緩沖了,后續的?read?操作直接就可以從緩沖拿到結果,瞬間就返回了。
小結
關于管道
1:不是什么Redis特有的技術
2:本質節省的是網絡響應的時間開銷,客戶端一次發送多條命令,服務端照常一條一條處理,不過客戶端等待響應讀取數據的時間少了
3:之所以能節省網絡響應的時間開銷,在于客戶端讀寫的本質,是和本地操作系統內核打交道,寫幾乎不耗時,讀需要等待響應耗時
管道理解就是好比網購,買多件東西分開下單,會比較慢,假如一次下單買多個就比較快,而write好比是下單操作或是賣家發貨很快,read好比是收快遞,耗時的是快遞傳送過程
?
同舟共濟 —— 事務:
為了確保連續多個操作的原子性,一個成熟的數據庫通常都會有事務支持,Redis 也不例外。Redis 的事務使用非常簡單,不同于關系數據庫,我們無須理解那么多復雜的事務模型,就可以直接使用。不過也正是因為這種簡單性,它的事務模型很不嚴格,這要求我們不能像使用關系數據庫的事務一樣來使用 Redis。
Redis 事務的基本使用
每個事務的操作都有 begin、commit 和 rollback,begin 指示事務的開始,commit 指示事務的提交,rollback 指示事務的回滾。它大致的形式如下。
begin(); try {command1();command2();....commit(); } catch(Exception e) {rollback(); }Redis 在形式上看起來也差不多,分別是 multi/exec/discard。multi 指示事務的開始,exec 指示事務的執行,discard 指示事務的丟棄。
> multi OK > incr books QUEUED > incr books QUEUED > exec (integer) 1 (integer) 2上面的指令演示了一個完整的事務過程,所有的指令在 exec 之前不執行,而是緩存在服務器的一個事務隊列中,服務器一旦收到 exec 指令,才開執行整個事務隊列,執行完畢后一次性返回所有指令的運行結果。因為 Redis 的單線程特性,它不用擔心自己在執行隊列的時候被其它指令打攪,可以保證他們能得到的「原子性」執行。
?
QUEUED 是一個簡單字符串,同 OK 是一個形式,它表示指令已經被服務器緩存到隊列里了。
原子性
事務的原子性是指要么事務全部成功,要么全部失敗,那么 Redis 事務執行是原子性的么?
下面我們來看一個特別的例子。
> multi OK > set books iamastring QUEUED > incr books QUEUED > set poorman iamdesperate QUEUED > exec 1) OK 2) (error) ERR value is not an integer or out of range 3) OK > get books "iamastring" > get poorman "iamdesperate上面的例子是事務執行到中間遇到失敗了,因為我們不能對一個字符串進行數學運算,事務在遇到指令執行失敗后,后面的指令還繼續執行,所以 poorman 的值能繼續得到設置。
到這里,你應該明白 Redis 的事務根本不能算「原子性」,而僅僅是滿足了事務的「隔離性」,隔離性中的串行化——當前執行的事務有著不被其它事務打斷的權利。
discard(丟棄)
Redis 為事務提供了一個 discard 指令,用于丟棄事務緩存隊列中的所有指令,在 exec 執行之前。
> get books (nil) > multi OK > incr books QUEUED > incr books QUEUED > discard OK > get books (nil)我們可以看到 discard 之后,隊列中的所有指令都沒執行,就好像 multi 和 discard 中間的所有指令從未發生過一樣。
優化
上面的 Redis 事務在發送每個指令到事務緩存隊列時都要經過一次網絡讀寫,當一個事務內部的指令較多時,需要的網絡 IO 時間也會線性增長。所以通常 Redis 的客戶端在執行事務時都會結合 pipeline 一起使用,這樣可以將多次 IO 操作壓縮為單次 IO 操作。比如我們在使用 Python 的 Redis 客戶端時執行事務時是要強制使用 pipeline 的。
pipe = redis.pipeline(transaction=true) pipe.multi() pipe.incr("books") pipe.incr("books") values = pipe.execute()Watch
考慮到一個業務場景,Redis 存儲了我們的賬戶余額數據,它是一個整數。現在有兩個并發的客戶端要對賬戶余額進行修改操作,這個修改不是一個簡單的 incrby 指令,而是要對余額乘以一個倍數。Redis 可沒有提供 multiplyby 這樣的指令。我們需要先取出余額然后在內存里乘以倍數,再將結果寫回 Redis。
這就會出現并發問題,因為有多個客戶端會并發進行操作。我們可以通過 Redis 的分布式鎖來避免沖突,這是一個很好的解決方案。分布式鎖是一種悲觀鎖,那是不是可以使用樂觀鎖的方式來解決沖突呢?
Redis 提供了這種 watch 的機制,它就是一種樂觀鎖。有了 watch 我們又多了一種可以用來解決并發修改的方法。 watch 的使用方式如下:
while True:do_watch()commands()multi()send_commands()try:exec()breakexcept WatchError:continuewatch 會在事務開始之前盯住 1 個或多個關鍵變量,當事務執行時,也就是服務器收到了 exec 指令要順序執行緩存的事務隊列時,Redis 會檢查關鍵變量自 watch 之后,是否被修改了 (包括當前事務所在的客戶端)。如果關鍵變量被人動過了,exec 指令就會返回 null 回復告知客戶端事務執行失敗,這個時候客戶端一般會選擇重試。
> watch books OK > incr books # 被修改了 (integer) 1 > multi OK > incr books QUEUED > exec # 事務執行失敗 (nil)當服務器給 exec 指令返回一個 null 回復時,客戶端知道了事務執行是失敗的,通常客戶端 (redis-py) 都會拋出一個 WatchError 這種錯誤,不過也有些語言 (jedis) 不會拋出異常,而是通過在 exec 方法里返回一個 null,這樣客戶端需要檢查一下返回結果是否為 null 來確定事務是否執行失敗。
?
注意事項
Redis 禁止在 multi 和 exec 之間執行 watch 指令,而必須在 multi 之前做好盯住關鍵變量,否則會出錯。
接下來我們使用 Python 語言來實現對余額的加倍操作。
# -*- coding: utf-8 import redisdef key_for(user_id):return "account_{}".format(user_id)def double_account(client, user_id):key = key_for(user_id)while True:client.watch(key)value = int(client.get(key))value *= 2 # 加倍pipe = client.pipeline(transaction=True)pipe.multi()pipe.set(key, value)try:pipe.execute()break # 總算成功了except redis.WatchError:continue # 事務被打斷了,重試return int(client.get(key)) # 重新獲取余額client = redis.StrictRedis() user_id = "abc" client.setnx(key_for(user_id), 5) # setnx 做初始化 print double_account(client, user_id)下面我們再使用 Java 語言實現一遍。
import java.util.List; import redis.clients.jedis.Jedis; import redis.clients.jedis.Transaction;public class TransactionDemo {public static void main(String[] args) {Jedis jedis = new Jedis();String userId = "abc";String key = keyFor(userId);jedis.setnx(key, String.valueOf(5)); # setnx 做初始化System.out.println(doubleAccount(jedis, userId));jedis.close();}public static int doubleAccount(Jedis jedis, String userId) {String key = keyFor(userId);while (true) {jedis.watch(key);int value = Integer.parseInt(jedis.get(key));value *= 2; // 加倍Transaction tx = jedis.multi();tx.set(key, String.valueOf(value));List<Object> res = tx.exec();if (res != null) {break; // 成功了}}return Integer.parseInt(jedis.get(key)); // 重新獲取余額}public static String keyFor(String userId) {return String.format("account_%s", userId);}}?
?
小道消息 —— PubSub:
前面我們講了 Redis 消息隊列的使用方法,但是沒有提到?Redis 消息隊列的不足之處,那就是它不支持消息的多播機制。
?
?
?
消息多播
消息多播允許生產者生產一次消息,中間件負責將消息復制到多個消息隊列,每個消息隊列由相應的消費組進行消費。它是分布式系統常用的一種解耦方式,用于將多個消費組的邏輯進行拆分。支持了消息多播,多個消費組的邏輯就可以放到不同的子系統中。
如果是普通的消息隊列,就得將多個不同的消費組邏輯串接起來放在一個子系統中,進行連續消費。
?
?
?
PubSub:
為了支持消息多播,Redis 不能再依賴于那 5 種基本數據類型了。它單獨使用了一個模塊來支持消息多播,這個模塊的名字叫著 PubSub,也就是 PublisherSubscriber,發布者訂閱者模型。我們使用 Python 語言來演示一下 PubSub 如何使用。
# -*- coding: utf-8 -*- import time import redisclient = redis.StrictRedis() p = client.pubsub() p.subscribe("codehole") time.sleep(1) print p.get_message() client.publish("codehole", "java comes") time.sleep(1) print p.get_message() client.publish("codehole", "python comes") time.sleep(1) print p.get_message() print p.get_message() {'pattern': None, 'type': 'subscribe', 'channel': 'codehole', 'data': 1L} {'pattern': None, 'type': 'message', 'channel': 'codehole', 'data': 'java comes'} {'pattern': None, 'type': 'message', 'channel': 'codehole', 'data': 'python comes'} None?
?
客戶端發起訂閱命令后,Redis 會立即給予一個反饋消息通知訂閱成功。因為有網絡傳輸延遲,在subscribe?命令發出后,需要休眠一會,再通過?get\_message?才能拿到反饋消息。客戶端接下來執行發布命令,發布了一條消息。同樣因為網絡延遲,在?publish?命令發出后,需要休眠一會,再通過get\_message?才能拿到發布的消息。如果當前沒有消息,get\_message?會返回空,告知當前沒有消息,所以它不是阻塞的。
?
Redis PubSub 的生產者和消費者是不同的連接,也就是上面這個例子實際上使用了兩個 Redis 的連接。這是必須的,因為 Redis 不允許連接在 subscribe 等待消息時還要進行其它的操作。
在生產環境中,我們很少將生產者和消費者放在同一個線程里。如果它們真要在同一個線程里,何必通過中間件來流轉,直接使用函數調用就行。所以我們應該將生產者和消費者分離,接下來我們看看分離后的代碼要怎么寫。
消費者
# -*- coding: utf-8 -*- import time import redisclient = redis.StrictRedis() p = client.pubsub() p.subscribe("codehole") while True:msg = p.get_message()if not msg:time.sleep(1)continueprint msg生產者
# -*- coding: utf-8 -*- import redisclient = redis.StrictRedis() client.publish("codehole", "python comes") client.publish("codehole", "java comes") client.publish("codehole", "golang comes")必須先啟動消費者,然后再執行生產者,消費者我們可以啟動多個,pubsub 會保證它們收到的是相同的消息序列。
{'pattern': None, 'type': 'subscribe', 'channel': 'codehole', 'data': 1L} {'pattern': None, 'type': 'message', 'channel': 'codehole', 'data': 'python comes'} {'pattern': None, 'type': 'message', 'channel': 'codehole', 'data': 'java comes'} {'pattern': None, 'type': 'message', 'channel': 'codehole', 'data': 'golang comes'}我們從消費者的控制臺窗口可以看到上面的輸出,每個消費者窗口都是同樣的輸出。第一行是訂閱成功消息,它很快就會輸出,后面的三行會在生產者進程執行的時候立即輸出。 上面的消費者是通過輪詢get_message?來收取消息的,如果收取不到就休眠 1s。這讓我們想起了第 3 節的消息隊列模型,我們使用 blpop 來代替休眠來提高消息處理的及時性。
PubSub 的消費者如果使用休眠的方式來輪詢消息,也會遭遇消息處理不及時的問題。不過我們可以使用 listen 來阻塞監聽消息來進行處理,這點同 blpop 原理是一樣的。下面我們改造一下消費者
阻塞消費者
# -*- coding: utf-8 -*- import time import redisclient = redis.StrictRedis() p = client.pubsub() p.subscribe("codehole") for msg in p.listen():print msg模式訂閱
上面提到的訂閱模式是基于名稱訂閱的,消費者訂閱一個主題是必須明確指定主題的名稱。如果我們想要訂閱多個主題,那就 subscribe 多個名稱。
> subscribe codehole.image codehole.text codehole.blog # 同時訂閱三個主題,會有三條訂閱成功反饋信息 1) "subscribe" 2) "codehole.image" 3) (integer) 1 1) "subscribe" 2) "codehole.text" 3) (integer) 2 1) "subscribe" 2) "codehole.blog" 3) (integer) 3這樣生產者向這三個主題發布的消息,這個消費者都可以接收到。
> publish codehole.image https://www.google.com/dudo.png (integer) 1 > publish codehole.text " 你好,歡迎加入碼洞 " (integer) 1 > publish codehole.blog '{"content": "hello, everyone", "title": "welcome"}' (integer) 1如果現在要增加一個主題codehole.group,客戶端必須也跟著增加一個訂閱指令才可以收到新開主題的消息推送。
為了簡化訂閱的繁瑣,redis 提供了模式訂閱功能Pattern Subscribe,這樣就可以一次訂閱多個主題,即使生產者新增加了同模式的主題,消費者也可以立即收到消息
> psubscribe codehole.* # 用模式匹配一次訂閱多個主題,主題以 codehole. 字符開頭的消息都可以收到 1) "psubscribe" 2) "codehole.*" 3) (integer) 1消息結構
前面的消費者消息輸出時都是下面的這樣一個字典形式
{'pattern': None, 'type': 'subscribe', 'channel': 'codehole', 'data': 1L} {'pattern': None, 'type': 'message', 'channel': 'codehole', 'data': 'python comes'} {'pattern': None, 'type': 'message', 'channel': 'codehole', 'data': 'java comes'} {'pattern': None, 'type': 'message', 'channel': 'codehole', 'data': 'golang comes'}那這幾個字段是什么含義呢?
data?這個毫無疑問就是消息的內容,一個字符串。
channel?這個也很明顯,它表示當前訂閱的主題名稱。
type?它表示消息的類型,如果是一個普通的消息,那么類型就是 message,如果是控制消息,比如訂閱指令的反饋,它的類型就是 subscribe,如果是模式訂閱的反饋,它的類型就是 psubscribe,還有取消訂閱指令的反饋 unsubscribe 和 punsubscribe。
pattern?它表示當前消息是使用哪種模式訂閱到的,如果是通過 subscribe 指令訂閱的,那么這個字段就是空。
PubSub 缺點
PubSub 的生產者傳遞過來一個消息,Redis 會直接找到相應的消費者傳遞過去。如果一個消費者都沒有,那么消息直接丟棄。如果開始有三個消費者,一個消費者突然掛掉了,生產者會繼續發送消息,另外兩個消費者可以持續收到消息。但是掛掉的消費者重新連上的時候,這斷連期間生產者發送的消息,對于這個消費者來說就是徹底丟失了。
如果 Redis 停機重啟,PubSub 的消息是不會持久化的,畢竟 Redis 宕機就相當于一個消費者都沒有,所有的消息直接被丟棄。
正是因為 PubSub 有這些缺點,它幾乎找不到合適的應用場景。所以 Redis 的作者單獨開啟了一個項目 Disque 專門用來做多播消息隊列。該項目目前沒有成熟,一直長期處于 Beta 版本,但是相應的客戶端 sdk 已經非常豐富了,就待 Redis 作者臨門一腳發布一個 Release 版本。關于 Disque 的更多細節,本小冊不會多做詳細介紹,感興趣的同學可以去閱讀相關文檔。
補充
近期 Redis5.0 新增了 Stream 數據結構,這個功能給 Redis 帶來了持久化消息隊列,從此 PubSub 可以消失了,Disqueue 估計也永遠發不出它的 Release 版本了。具體內容請讀者閱讀 Stream 章節內容.
?
?
有備無患 —— 主從同步:
很多企業都沒有使用到 Redis 的集群,但是至少都做了主從。有了主從,當 master 掛掉的時候,運維讓從庫過來接管,服務就可以繼續,否則 master 需要經過數據恢復和重啟的過程,這就可能會拖很長的時間,影響線上業務的持續服務。
在了解 Redis 的主從復制之前,讓我們先來理解一下現代分布式系統的理論基石——CAP 原理。
CAP 原理
CAP 原理就好比分布式領域的牛頓定律,它是分布式存儲的理論基石。自打 CAP 的論文發表之后,分布式存儲中間件猶如雨后春筍般一個一個涌現出來。理解這個原理其實很簡單,本節我們首先對這個原理進行一些簡單的講解。
- C?- Consistent ,一致性
- A?- Availability ,可用性
- P?- Partition tolerance ,分區容忍性
分布式系統的節點往往都是分布在不同的機器上進行網絡隔離開的,這意味著必然會有網絡斷開的風險,這個網絡斷開的場景的專業詞匯叫著「網絡分區」。
在網絡分區發生時,兩個分布式節點之間無法進行通信,我們對一個節點進行的修改操作將無法同步到另外一個節點,所以數據的「一致性」將無法滿足,因為兩個分布式節點的數據不再保持一致。除非我們犧牲「可用性」,也就是暫停分布式節點服務,在網絡分區發生時,不再提供修改數據的功能,直到網絡狀況完全恢復正常再繼續對外提供服務。
?
?
一句話概括 CAP 原理就是——網絡分區發生時,一致性和可用性兩難全。
最終一致
Redis 的主從數據是異步同步的,所以分布式的 Redis 系統并不滿足「一致性」要求。當客戶端在 Redis 的主節點修改了數據后,立即返回,即使在主從網絡斷開的情況下,主節點依舊可以正常對外提供修改服務,所以 Redis 滿足「可用性」。
Redis 保證「最終一致性」,從節點會努力追趕主節點,最終從節點的狀態會和主節點的狀態將保持一致。如果網絡斷開了,主從節點的數據將會出現大量不一致,一旦網絡恢復,從節點會采用多種策略努力追趕上落后的數據,繼續盡力保持和主節點一致。
主從同步
Redis 同步支持主從同步和從從同步,從從同步功能是 Redis 后續版本增加的功能,為了減輕主庫的同步負擔。后面為了描述上的方便,統一理解為主從同步。
增量同步:
Redis 同步的是指令流,主節點會將那些對自己的狀態產生修改性影響的指令記錄在本地的內存 buffer 中,然后異步將 buffer 中的指令同步到從節點,從節點一邊執行同步的指令流來達到和主節點一樣的狀態,一邊向主節點反饋自己同步到哪里了 (偏移量)。
因為內存的 buffer 是有限的,所以 Redis 主庫不能將所有的指令都記錄在內存 buffer 中。Redis 的復制內存 buffer 是一個定長的環形數組,如果數組內容滿了,就會從頭開始覆蓋前面的內容。
如果因為網絡狀況不好,從節點在短時間內無法和主節點進行同步,那么當網絡狀況恢復時,Redis 的主節點中那些沒有同步的指令在 buffer 中有可能已經被后續的指令覆蓋掉了,從節點將無法直接通過指令流來進行同步,這個時候就需要用到更加復雜的同步機制 —— 快照同步。
?
快照同步
快照同步是一個非常耗費資源的操作,它首先需要在主庫上進行一次 bgsave 將當前內存的數據全部快照到磁盤文件中,然后再將快照文件的內容全部傳送到從節點。從節點將快照文件接受完畢后,立即執行一次全量加載,加載之前先要將當前內存的數據清空。加載完畢后通知主節點繼續進行增量同步。
在整個快照同步進行的過程中,主節點的復制 buffer 還在不停的往前移動,如果快照同步的時間過長或者復制 buffer 太小,都會導致同步期間的增量指令在復制 buffer 中被覆蓋,這樣就會導致快照同步完成后無法進行增量復制,然后會再次發起快照同步,如此極有可能會陷入快照同步的死循環。所以務必配置一個合適的復制 buffer 大小參數,避免快照復制的死循環。
?
增加從節點
當從節點剛剛加入到集群時,它必須先要進行一次快照同步,同步完成后再繼續進行增量同步。
無盤復制
主節點在進行快照同步時,會進行很重的文件 IO 操作,特別是對于非 SSD 磁盤存儲時,快照會對系統的負載產生較大影響。特別是當系統正在進行 AOF 的 fsync 操作時如果發生快照,fsync 將會被推遲執行,這就會嚴重影響主節點的服務效率。
所以從 Redis 2.8.18 版開始支持無盤復制。所謂無盤復制是指主服務器直接通過套接字將快照內容發送到從節點,生成快照是一個遍歷的過程,主節點會一邊遍歷內存,一邊將序列化的內容發送到從節點,從節點還是跟之前一樣,先將接收到的內容存儲到磁盤文件中,再進行一次性加載。
Wait 指令
Redis 的復制是異步進行的,wait 指令可以讓異步復制變身同步復制,確保系統的強一致性 (不嚴格)。wait 指令是 Redis3.0 版本以后才出現的。
> set key value OK > wait 1 0 (integer) 1wait 提供兩個參數,第一個參數是從庫的數量 N,第二個參數是時間 t,以毫秒為單位。它表示等待 wait 指令之前的所有寫操作同步到 N 個從庫 (也就是確保 N 個從庫的同步沒有滯后),最多等待時間 t。如果時間 t=0,表示無限等待直到 N 個從庫同步完成達成一致。
假設此時出現了網絡分區,wait 指令第二個參數時間 t=0,主從同步無法繼續進行,wait 指令會永遠阻塞,Redis 服務器將喪失可用性。
小結
主從復制是 Redis 分布式的基礎,Redis 的高可用離開了主從復制將無從進行。后面的章節我們會開始講解 Redis 的集群模式,這幾種集群模式都依賴于本節所講的主從復制。
不過復制功能也不是必須的,如果你將 Redis 只用來做緩存,跟 memcache 一樣來對待,也就無需要從庫做備份,掛掉了重新啟動一下就行。但是只要你使用了 Redis 的持久化功能,就必須認真對待主從復制,它是系統數據安全的基礎保障。
?
集群 1:李代桃僵 —— Sentinel
目前我們講的 Redis 還只是主從方案,最終一致性。讀者們可思考過,如果主節點凌晨 3 點突發宕機怎么辦?就坐等運維從床上爬起來,然后手工進行從主切換,再通知所有的程序把地址統統改一遍重新上線么?毫無疑問,這樣的人工運維效率太低,事故發生時估計得至少 1 個小時才能緩過來。如果是一個大型公司,這樣的事故足以上新聞了。
?
?
?
所以我們必須有一個高可用方案來抵抗節點故障,當故障發生時可以自動進行從主切換,程序可以不用重啟,運維可以繼續睡大覺,仿佛什么事也沒發生一樣。Redis 官方提供了這樣一種方案 —— Redis Sentinel(哨兵)。
?
?
?
我們可以將 Redis Sentinel 集群看成是一個 ZooKeeper 集群,它是集群高可用的心臟,它一般是由 3~5 個節點組成,這樣掛了個別節點集群還可以正常運轉。
它負責持續監控主從節點的健康,當主節點掛掉時,自動選擇一個最優的從節點切換為主節點。客戶端來連接集群時,會首先連接 sentinel,通過 sentinel 來查詢主節點的地址,然后再去連接主節點進行數據交互。當主節點發生故障時,客戶端會重新向 sentinel 要地址,sentinel 會將最新的主節點地址告訴客戶端。如此應用程序將無需重啟即可自動完成節點切換。比如上圖的主節點掛掉后,集群將可能自動調整為下圖所示結構。
?
?
從這張圖中我們能看到主節點掛掉了,原先的主從復制也斷開了,客戶端和損壞的主節點也斷開了。從節點被提升為新的主節點,其它從節點開始和新的主節點建立復制關系。客戶端通過新的主節點繼續進行交互。Sentinel 會持續監控已經掛掉了主節點,待它恢復后,集群會調整為下面這張圖。
?
?
?
此時原先掛掉的主節點現在變成了從節點,從新的主節點那里建立復制關系。
?
消息丟失
Redis 主從采用異步復制,意味著當主節點掛掉時,從節點可能沒有收到全部的同步消息,這部分未同步的消息就丟失了。如果主從延遲特別大,那么丟失的數據就可能會特別多。Sentinel 無法保證消息完全不丟失,但是也盡可能保證消息少丟失。它有兩個選項可以限制主從延遲過大。
min-slaves-to-write 1 min-slaves-max-lag 10第一個參數表示主節點必須至少有一個從節點在進行正常復制,否則就停止對外寫服務,喪失可用性。
何為正常復制,何為異常復制?這個就是由第二個參數控制的,它的單位是秒,表示如果 10s 沒有收到從節點的反饋,就意味著從節點同步不正常,要么網絡斷開了,要么一直沒有給反饋。
Sentinel 基本使用
接下來我們看看客戶端如何使用 sentinel,標準的流程應該是客戶端可以通過 sentinel 發現主從節點的地址,然后在通過這些地址建立相應的連接來進行數據存取操作。我們來看看 Python 客戶端是如何做的。
>>> from redis.sentinel import Sentinel >>> sentinel = Sentinel([('localhost', 26379)], socket_timeout=0.1) >>> sentinel.discover_master('mymaster') ('127.0.0.1', 6379) >>> sentinel.discover_slaves('mymaster') [('127.0.0.1', 6380)]sentinel 的默認端口是 26379,不同于 Redis 的默認端口 6379,通過 sentinel 對象的 discover_xxx 方法可以發現主從地址,主地址只有一個,從地址可以有多個。
>>> master = sentinel.master_for('mymaster', socket_timeout=0.1) >>> slave = sentinel.slave_for('mymaster', socket_timeout=0.1) >>> master.set('foo', 'bar') >>> slave.get('foo') 'bar'通過 xxx_for 方法可以從連接池中拿出一個連接來使用,因為從地址有多個,redis 客戶端對從地址采用輪詢方案,也就是 RoundRobin 輪著來。
有個問題是,但 sentinel 進行主從切換時,客戶端如何知道地址變更了 ? 通過分析源碼,我發現 redis-py 在建立連接的時候進行了主庫地址變更判斷。
連接池建立新連接時,會去查詢主庫地址,然后跟內存中的主庫地址進行比對,如果變更了,就斷開所有連接,重新使用新地址建立新連接。如果是舊的主庫掛掉了,那么所有正在使用的連接都會被關閉,然后在重連時就會用上新地址。
但是這樣還不夠,如果是 sentinel 主動進行主從切換,主庫并沒有掛掉,而之前的主庫連接已經建立了在使用了,沒有新連接需要建立,那這個連接是不是一致切換不了?
繼續深入研究源碼,我發現 redis-py 在另外一個點也做了控制。那就是在處理命令的時候捕獲了一個特殊的異常ReadOnlyError,在這個異常里將所有的舊連接全部關閉了,后續指令就會進行重連。
主從切換后,之前的主庫被降級到從庫,所有的修改性的指令都會拋出ReadonlyError。如果沒有修改性指令,雖然連接不會得到切換,但是數據不會被破壞,所以即使不切換也沒關系。
?
?
?
?
?
?
?
?
?
?
?
?
?
?
?
?
?
?
?
?
?
?
?
?
?
?
總結
- 上一篇: 计算机考试400,400作文:电脑考试
- 下一篇: 动态代理与静态代理