深入淺出帶你走進 Redis!

本文推選自騰訊雲開發者社區 -【技思廣益 · 騰訊技術人原創集】專欄。該專欄是騰訊雲開發者社區爲騰訊技術人與廣泛開發者打造的分享交流窗口。欄目邀約騰訊技術人分享原創的技術積澱,與廣泛開發者互啓迪共成長。本文作者是騰訊後臺開發工程師劉波。

本文主要講述 Redis 的基礎知識和常識性內容,幫助大家瞭解和熟悉 Redis;後續通過閱讀源碼、實踐 Redis 後會總結相關的知識點,再繼續分享給大家。

什麼是 Redis

Redis 是一個開源、基於內存、使用 C 語言編寫的 key-value 數據庫,並提供了多種語言的 API。它的數據結構十分豐富,基礎數據類型包括:string(字符串)、list(列表,雙向鏈表)、hash(散列,鍵值對集合)、set(集合,不重複)和 sorted set(有序集合)。主要可以用於數據庫、緩存、分佈式鎖、消息隊列等...

以上的數據類型是 Redis 鍵值的數據類型,其實就是數據的保存形式,但是數據類型的底層實現是最重要的,底層的數據結構主要分爲 6 種,分別是簡單動態字符串、雙向鏈表、壓縮鏈表、哈希表、跳錶和整數數組。各個數據類型和底層結構的對應關係如下:

各個底層實現的時間複雜度如下:

可以看出除了 string 類型的底層實現只有一種數據結構,其他四種均有兩種底層實現,這四種類型爲集合類型,其中一個鍵對應了一個集合的數據。

(一)Redis 鍵值是如何保存的呢?

Redis 爲了快速訪問鍵值對,採用了哈希表來保存所有的鍵值對,一個哈希表對應了多個哈希桶,所謂的哈希桶是指哈希表數組中的每一個元素,當然哈希表中保存的不是值本身,是指向值的指針,如下圖。

其中哈希桶中的 entry 元素中保存了 * key 和 * value 指針,分別指向了實際的鍵和值。通過 Redis 可以在 O(1) 的時間內找到鍵值對,只需要計算 key 的哈希值就可以定位位置,但從下圖可以看出,在 4 號位置出現了衝突,兩個 key 映射到了同一個位置,這就產生了哈希衝突,會導致哈希表的操作變慢。雖然 Redis 通過鏈式衝突解決該問題,但如果數據持續增多,產生的哈希衝突也會越來越多,會加重 Redis 的查詢時間。

Redis 保存數據示意圖

爲了解決上述的哈希衝突問題,Redis 會對哈希表進行 rehash 操作,也就是增加目前的哈希桶數量,使得 key 更加分散,進而減少哈希衝突的問題,主要流程如下:

上述的步驟可能會存在一個問題,當哈希表 A 向 B 複製的時候,是需要一定的時間的,可能會造成 Redis 的線程阻塞,就無法服務其他的請求了。

針對上述問題,Redis 採用了漸進式 rehash,主要的流程是:Redis 還是繼續處理客戶端的請求,每次處理一個請求的時候,就會將該位置所有的 entry 都拷貝到哈希表 B 中,當然也會存在某個位置一直沒有被請求。Redis 也考慮了這個問題,通過設置一個定時任務進行 rehash,在一些鍵值對一直沒有操作的時候,會週期性的搬移一些數據到哈希表 B 中,進而縮短 rehash 的過程。

(二)Redis 爲什麼採用單線程呢?

首先要明確的是 Redis 單線程指的是網絡 IO 和鍵值對讀寫是由一個線程來完成的,但 Redis 持久化、集羣數據等是由額外的線程執行的。瞭解 Redis 使用單線程之前可以先了解一下多線程的開銷。

通常情況下,使用多線程可以增加系統吞吐率或者可以增加系統擴展性,但多線程通常會存在同時訪問某些共享資源,爲了保證訪問共享資源的正確性,就需要有額外的機制進行保證,這個機制首先會帶來一定的開銷。其實對於多線程併發訪問的控制一直是一個難點問題,如果沒有精細的設計,比如說,只是簡單地採用一個粗粒度互斥鎖,就會出現不理想的結果。即使增加了線程,大部分線程也在等待獲取訪問共享資源的互斥鎖,並行變串行,系統吞吐率並沒有隨着線程的增加而增加。

這也是 Redis 使用單線程的主要原因。

值得注意的是在 Redis6.0 中引入了多線程。在 Redis6.0 之前,從網絡 IO 處理到實際的讀寫命令處理都是由單個線程完成的,但隨着網絡硬件的性能提升,Redis 的性能瓶頸有可能會出現在網絡 IO 的處理上,也就是說單個主線程處理網絡請求的速度跟不上底層網絡硬件的速度。針對此問題,Redis 採用多個 IO 線程來處理網絡請求,提高網絡請求處理的並行度,但多 IO 線程只用於處理網絡請求,對於讀寫命令,Redis 仍然使用單線程處理!

(三)Redis 單線程爲什麼還這麼快?

IO 多路複用機制:使其在網絡 IO 操作中能併發處理大量的客戶端請求從而實現高吞吐率。

IO 多路複用機制是指一個線程處理多個 IO 流,也就是常說的 select/epoll 機制。在 Redis 運行單線程的情況下,該機制允許內核中同時存在多個監聽套接字和已連接套接字。內核會一直監聽這些套接字上的連接請求或數據請求。一旦有請求到達,就會交給 Redis 線程處理,這就實現了一個 Redis 線程處理多個 IO 流的效果,進而提升併發性。

Redis 是基於內存的,絕大部分請求都是內存操作,十分的迅速。

Redis 具有高效的底層數據結構,爲優化內存,對每種類型基本都有兩種底層實現方式。

主要執行過程是單線程,避免了不必要的上下文切換和資源競爭,不存在多線程導致的 CPU 切換和鎖的問題。

Redis 數據丟失問題

由上一小節我們大概瞭解了 Redis 的存儲和快的主要原因,通常情況下我們會把 Redis 當作緩存使用,將後端數據庫中的數據存儲在內存中,然後從內存中直接讀取數據,響應速度會非常快。但是如果服務器宕機了,內存中的數據也就會丟失,當然我們可以重新從後端數據庫中恢復這些緩存數據,但是頻繁訪問數據庫,會給數據庫帶來一定的壓力;另一方面數據是從慢速的數據庫中讀取的,性能肯定比不上 Redis,也會導致這些數據的應用程序響應變慢。

所以對 Redis 來說,實現數據的持久化,避免從後端恢復數據是至關重要的,目前 Redis 持久化主要有兩大機制,分別是 AOF(Append Only File)日誌和 RDB 快照。

(一)AOF 日誌

AOF 日誌是寫後日志,也就是 Redis 先執行命令,然後將數據寫入內存,最後才記錄日誌,如下圖:

Redis AOF 操作過程

AOF 日誌中記錄的是 Redis 收到的每一條命令,這些命令都是以文本的形式保存的,例如我們以 Redis 收到 set key value 命令後記錄的日誌爲例,AOF 文件中保存的數據如下圖所示,其中 * 3 代表當前命令分爲三部分,每部分都是通過 $+ 數字開頭,其中數字表示該部分的命令、鍵、值一共有多少字節。

Redis AOF 日誌內容

AOF 爲了避免額外的檢查開銷,並不會檢查命令的正確性,如果先記錄日誌再執行命令,就有可能記錄錯誤的命令,再通過 AOF 日誌恢復數據的時候,就有可能出錯,而且在執行完命令後記錄日誌也不會阻塞當前的寫操作。但是 AOF 是存在一定的風險的,首先是如果剛執行一個命令,但是 AOF 文件中還沒來得及保存就宕機了,那麼這個命令和數據就會有丟失的風險,另外 AOF 雖然可以避免對當前命令的阻塞(因爲是先寫入再記錄日誌),但有可能會對下一次操作帶來阻塞風險(可能存在寫入磁盤較慢的情況)。這兩種情況都在於 AOF 什麼時候寫入磁盤,對於這個問題 AOF 機制提供了三種選擇(appendfsync 的三個可選值),分別是 Always、Everysec、No 具體如下:

我們可以根據不同的場景來選擇不同的方式:

雖然有一定的寫回策略,但畢竟 AOF 是通過文件的形式記錄所有的寫命令,但如果指令越來越多的時候,AOF 文件就會越來越大,可能會超出文件大小的限制;另外,如果文件過大再次寫入指令的話效率也會變低;如果發生宕機,需要把 AOF 所有的命令重新執行,以用於故障恢復,數據過大的話這個恢復過程越漫長,也會影響 Redis 的使用。

此時,AOF 重寫機制就來了:

AOF 重寫就是根據所有的鍵值對創建一個新的 AOF 文件,可以減少大量的文件空間,減少的原因是:AOF 對於命令的添加是追加的方式,逐一記錄命令,但有可能存在某個鍵值被反覆更改,產生了一些冗餘數據,這樣在重寫的時候就可以過濾掉這些指令,從而更新當前的最新狀態。

AOF 重寫的過程是通過主線程 fork 後臺的 bgrewriteaof 子進程來實現的,可以避免阻塞主進程導致性能下降,整個過程如下:

(二)RDB 快照

上一小節裏瞭解了避免 Redis 數據丟失的 AOF 方法,這個方法記錄的是操作命令,而不是實際的數據,如果日誌非常多的話,Redis 恢復的就很緩慢,會影響到正常的使用。

這一小節主要是講述的另一種 Redis 數據持久化的方式:內存快照。即記錄內存中的數據在某一時刻的狀態,並以文件的形式寫到磁盤上,即使服務器宕機,快照文件也不會丟失,數據的可靠性也就得到了保證,這個文件稱爲 RDB(Redis DataBase) 文件。可以看出 RDB 記錄的是某一時刻的數據,和 AOF 不同,所以在數據恢復的時候只需要將 RDB 文件讀入到內存,就可以完成數據恢復。但爲了 RDB 數據恢復的可靠性,在進行快照的時候是全量快照,會將內存中所有的數據都記錄到磁盤中,這就有可能會阻塞主線程的執行。Redis 提供了兩個命令來生成 RDB 文件,分別是 save 和 bgsave:

我們可以採用 bgsave 的命令來執行全量快照,提供了數據的可靠性保證,也避免了對 Redis 的性能影響。執行快照期間數據能不能修改呢? 如果不能修改,快照過程中如果有新的寫操作,數據就會不一致,這肯定是不符合預期的。Redis 借用了操作系統的寫時複製,在執行快照的期間,正常處理寫操作。

主要流程爲:

寫時複製機制保證快照期間數據可修改

通過上述方法就可以保證快照的完整性,也可以允許主線程處理寫操作,可以避免對業務的影響。那多久進行一次快照呢?

理論上來說快照時間間隔越短越好,可以減少數據的丟失,畢竟 fork 的子進程不會阻塞主線程,但是頻繁的將數據寫入磁盤,會給磁盤帶來很多壓力,也可能會存在多個快照競爭磁盤帶寬(當前快照沒結束,下一個就開始了)。另一方面,雖然 fork 出的子進程不會阻塞,但 fork 這個創建過程是會阻塞主線程的,當主線程需要的內存越大,阻塞時間越長。

針對上面的問題,Redis 採用了增量快照,在做一次全量快照後,後續的快照只對修改的數據進行記錄,需要記住哪些數據被修改了,可以避免全量快照帶來的開銷。

(三)混合使用 AOF 日誌和 RDB 快照

雖然跟 AOF 相比,RDB 快照的恢復速度快,但快照的頻率不好把握,如果頻率太低,兩次快照間一旦宕機,就可能有比較多的數據丟失。如果頻率太高,又會產生額外開銷,那麼,還有什麼方法既能利用 RDB 的快速恢復,又能以較小的開銷做到儘量少丟數據呢?

在 Redis4.0 提出了混合使用 AOF 和 RDB 快照的方法,也就是兩次 RDB 快照期間的所有命令操作由 AOF 日誌文件進行記錄。這樣的好處是 RDB 快照不需要很頻繁的執行,可以避免頻繁 fork 對主線程的影響,而且 AOF 日誌也只記錄兩次快照期間的操作,不用記錄所有操作,也不會出現文件過大的情況,避免了重寫開銷。

通過上述方法既可以享受 RDB 快速恢復的好處,也可以享受 AOF 記錄簡單命令的優勢。

對於 AOF 和 RDB 的選擇問題:

Redis 數據同步

當 Redis 發生宕機的時候,可以通過 AOF 和 RDB 文件的方式恢復數據,從而保證數據的丟失從而提高穩定性。但如果 Redis 實例宕機了,在恢復期間就無法服務新來的數據請求;AOF 和 RDB 雖然可以保證數據儘量的少丟失,但無法保證服務儘量少中斷,這就會影響業務的使用,不能保證 Redis 的高可靠性。

Redis 其實採用了主從庫的模式,以保證數據副本的一致性,主從庫採用讀寫分離的方式:從庫和主庫都可以接受讀操作;對於寫操作,首先要到主庫執行,然後主庫再將寫操作同步到從庫。

只有主庫接收寫操作可以避免客戶端將數據修改到不同的 Redis 實例上,其他

客戶端進行讀取時可能就會讀取到舊的值;當然,如果非要所有的庫都可以進行寫操作,就要涉及到鎖、實例間協商是否完成修改等一系列操作,會帶來額外的開銷。

(一)主從庫如何進行第一次數據同步

當存在多個 Redis 實例的時候,可以通過 replicaof 命令形成主庫和從庫的關係,在從庫中輸入:replicaof 主庫 ip 6379 就可以在主庫中複製數據,具體有三個階段:

如果從庫的實例過多,對於主庫來說有一定的壓力,主庫會頻繁 fork 子進程以生成 RDB 文件,fork 這個操作會阻塞主線程處理正常請求,導致響應變慢,Redis 採用了主 - 從 - 從的模式,可以手動選擇一個從庫,用來同步其他從庫的數據,以減少主庫生成 RDB 文件和傳輸 RDB 文件的壓力;如下圖:

級聯的 “主 - 從 - 從” 模式

這樣從庫就可以知道在進行數據同步的時候,不需要和主庫直接交互,只需要和選擇的從庫進行寫操作同步就可以了,從而減少主庫的壓力。

(二)主庫如果掛了呢?

Redis 採用主從庫的模式保證數據副本的一致性,在這個模式下如果從庫發生故障,客戶端可以向其他主庫或者從庫發送請求,但如果主庫掛了,客戶端就沒法進行寫操作了,也無法對從庫進行相應的數據複製操作。

不管是寫服務中斷還是從庫無法進行數據同步,都是不能接受的,所以當主庫掛了以後,需要一個新的主庫來代替掛掉的主庫,這樣就就會產生三個問題:

Redis 採用了哨兵機制應對這些問題,哨兵機制是實現主從庫自動切換的關鍵機制,在主從庫運行的同時,它也在進行監控、選擇主庫和通知的操作。

下圖展示了哨兵的幾個操作的任務:

哨兵機制的三項任務與目標

但這樣也會存在一個問題,哨兵判斷主從庫是否下線如果出現失誤呢?

對於從庫,下線影響不大,集羣的對外服務也不會間斷。但是如果哨兵誤判主庫下線,可能是因爲網絡擁塞或者主庫壓力大的情況,這時候也就需要進行選主並讓從庫和新的主庫進行數據同步,這個過程是有一定的開銷的,所以我們要儘可能地避免誤判的情況。哨兵機制也考慮了這一點,它通常採用多實例組成的集羣模式進行部署,也被稱爲哨兵集羣;通過引入多個哨兵實例一起判斷,就可以儘可能地避免單個哨兵產生的誤判問題。這時候判斷主庫是否下線不是由一個哨兵決定的,只有大多數哨兵認爲該主庫下線,主庫纔會標記爲 “客觀下線”。

簡單的來說” 客觀下線 “的標準是當 N 個哨兵實例,有 N/2+1 個實例認爲該主庫爲下線狀態,該主庫就會被認定爲 “客觀下線”。這樣就可以儘量的避免單個哨兵產生的誤判問題(N/2+1 這個值也可以通過參數改變);

如果判斷了主庫爲主觀下線,怎麼選取新的主庫呢?

上面有說到,這一部分也是由哨兵機制來完成的,選取主庫的過程分爲 “篩選 和打分”。主要是按照一定的規則過濾掉不符合的從庫,再按照一定的規則給其餘的從庫打分,將最高分的從庫作爲新的主庫。

由此哨兵可以選擇出一個新的主庫。

由哪個哨兵來執行主從庫切換呢?

這個過程和判斷主庫 “客觀下線” 類似,也是一個投票的過程。如果某個哨兵判斷了主庫爲下線狀態,就會給其他的哨兵實例發送 is-master-down-by-addr 的命令,其他實例會根據自己和主庫的連接狀態作出 Y 或 N 的響應,Y 相當於贊成票,N 爲反對票。一個哨兵獲得一定的票數後,就可以標記主庫爲“客觀下線”,這個票數是由參數 quorum 設置的。如下圖:

例如:現在有 3 個哨兵,quorum 配置的是 2,那麼,一個哨兵需要 2 張贊成票,就可以標記主庫爲 “客觀下線” 了。這 2 張贊成票包括哨兵自己的一張贊成票和另外兩個哨兵的贊成票。

這個時候哨兵就可以給其他哨兵發送消息,表示希望自己來執行主從切換,並讓所有的哨兵進行投票,這個過程稱爲 “Leader 選舉”,進行主從切換的哨兵稱爲 Leader。任何一個想成爲 Leader 的哨兵都需要滿足兩個條件:

以上就可以選出 Leader 然後進行主從庫切換了。

Redis 集羣

(一)數據量過多如何處理?

當數據量過多的情況下,一種簡單的方式是升級 Redis 實例的資源配置,包括增加內存容量、磁盤容量、更好配置的 CPU 等,但這種情況下 Redis 使用 RDB 進行持久化的時候響應會變慢,Redis 通過 fork 子進程來完成數據持久化,但 fork 在執行時會阻塞主線程,數據量越大,fork 的阻塞時間就越長,從而導致 Redis 響應變慢。

Redis 的切片集羣可以解決這個問題,也就是啓動多個 Redis 實例來組成一個集羣,再按照一定的規則把數據劃分爲多份,每一份用一個實例來保存,這樣客戶端只需要訪問對應的實例就可以獲取數據。在這種情況下 fork 子進程一般不會給主線程帶來較長時間的阻塞,如下圖:

切片集羣架構圖

將 20GB 的數據分爲 4 分,每份包含 5GB 數據,客戶端只需要找到對應的實例就可以獲取數據,從而減少主線程阻塞的時間。

當數據量過多的時候,可以通過升級 Redis 實例的資源配置或者通過切片集羣的方式。前者實現起來簡單粗暴,但這數據量增加的時候,需要的內存也在不斷增加,主線程 fork 子進程就有可能會阻塞,而且該方案受到硬件和成本的限制。相比之下第二種方案是一種擴展性更好的方案,如果想保存更多的數據,僅需要增加 Redis 實例的個數,不用擔心單個實例的硬件和成本限制。在面向百萬、千萬級別的用戶規模時,橫向擴展的 Redis 切片集羣會是一個非常好的選擇。

選擇切片集羣也是需要解決一些問題的:

Redis 採用了 Redis Cluster 的方案來實現切片集羣,具體的 Redis Cluster 採用了哈希槽(Hash Slot)來處理數據和實例之間的映射關係。在 Redis Cluster 中,一個切片集羣共有 16384 個哈希槽(爲什麼 Hash Slot 的個數是 16384),這些哈希槽類似於數據的分區,每個鍵值對都會根據自己的 key 被影射到一個哈希槽中,映射步驟如下:

這時候可以得到一個 key 對應的哈希槽了,哈希槽又是如何找到對應的實例的呢?

在部署 Redis Cluster 的時候,可以通過 cluster create 命令創建集羣,此時 Redis 會自動把這些槽分佈在集羣實例上,例如一共有 N 個實例,那麼每個實例包含的槽個數就爲 16384/N。當然可能存在 Redis 實例中內存大小配置不一的問題,內存大的實例具有更大的容量。這種情況下可以通過 cluster addslots 命令手動分配哈希槽。

要注意的是,如果採用 cluster addslots 的方式手動分配哈希槽,需要將 16384 個槽全部分配完,否則 Redis 集羣無法正常工作。現在通過哈希槽,切片集羣就實現了數據到哈希槽、哈希槽到實例的對應關係,那麼客戶端如何確定需要訪問的實例是哪一個呢?

(二)客戶端定位集羣中的數據

客戶端請求的 key 可以通過 CRC16 算法計算得到,但客戶端還需要知道哈希槽分佈在哪個實例上。在最開始客戶端和集羣實例建立連接後,實例就會把哈希槽的分配信息發給客戶端,實例之間會把自己的哈希槽信息發給和它相連的實例,完成哈希槽的擴散。這樣客戶端訪問任何一個實例的時候,都能獲取所有的哈希槽信息。當客戶端收到哈希槽的信息後會把哈希槽對應的信息緩存在本地,當客戶端發送請求的時候,會先找到 key 對應的哈希槽,然後就可以給對應的實例發送請求了。

但是,哈希槽和實例的對應關係不是一成不變的,可能會存在新增或者刪除的情況,這時候就需要重新分配哈希槽;也可能爲了負載均衡,Redis 需要把所有的實例重新分佈。

雖然實例之間可以互相傳遞消息以獲取最新的哈希槽分配信息,但是客戶端無法感知這個變化,就會導致客戶端訪問的實例可能不是自己所需要的了。

Redis Cluster 提供了重定向的機制,當客戶端給實例發送數據讀寫操作的時候,如果這個實例上沒有找到對應的數據,此時這個實例就會給客戶端返回 MOVED 命令的相應結果,這個結果中包含了新實例的訪問地址,此時客戶端需要再給新實例發送操作命令以進行讀寫操作,MOVED 命令如下:

GET hello:key
(error) MOVED  33.33.33.33:6379

返回的信息代表客戶端請求的 key 所在的哈希槽爲 3333,實際是在 33.33.33.33 這個實例上,此時客戶端只需要向 33.33.33.33 這個實例發送請求就可以了。

此時也存在一個小問題,哈希槽中對應的數據過多,導致還沒有遷移到其他實例,此時客戶端就發起了請求,在這種情況下,客戶端就對實例發起了請求,如果數據還在對應的實例中,會給客戶端返回數據;如果請求的數據已經被轉移到其他實例上,客戶端就會收到實例返回的 ASK 命令,該命令表示:哈希槽中數據還在前一種、ASK 命令把客戶端需要訪問的新實例返回了。此時客戶端需要給新實例發送 ASKING 命令以進行請求操作。

值得注意的是 ASK 信息和 MOVED 信息不一樣,ASK 信息並不會更新客戶端本地的緩存的哈希槽分配信息,也就是說如果客戶端再次訪問該哈希槽還是會請求之前的實例,直到數據遷移完成。

參考資料:

Redis 核心技術與實戰

** 作者簡介**

**劉波
**

騰訊後臺開發工程師

騰訊後臺開發工程師,目前負責智慧零售營銷產品部相關開發工作。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/ThVtw8TVuhxIyYxJy6sOWw