Redis快取何以一枝獨秀?(2) —— 聊聊Redis的資料過期、資料淘汰以及資料持久化的實現機制

架構悟道發表於2023-01-11

大家好,又見面了。


本文是筆者作為掘金技術社群簽約作者的身份輸出的快取專欄系列內容,將會透過系列專題,講清楚快取的方方面面。如果感興趣,歡迎關注以獲取後續更新。


上一篇文章中呢,我們簡單的介紹了下Redis的整體情況。作為集中式快取的優秀代表,Redis可以幫助我們在專案中完成很多特定的功能。Redis準確的說是一個非關係型資料庫,但是由於其超高的併發處理效能,及其對於快取場景所提供的一系列能力構建,使其成為了分散式系統中的集中快取的絕佳選擇。

Redis對於快取能力場景的支援,除了基礎的快取增刪改查,還支援對記錄的過期時間設定,支援多種不同的資料淘汰策略等等。此外為了解決記憶體型元件資料可靠性問題,還提供了一系列的資料持久化方案。

本篇文章中,我們就一起聊一聊這方面內容。

資料過期能力

為了節約記憶體的使用量,保證有限的記憶體空間能夠被更有價值的資料使用,所以很多記憶體快取元件都會支援資料過期能力。之前我們提過的本地快取元件Guava Cache、Caffeine等支援基於快取容器物件級別設定統一的過期時間,而Redis則支援對每條記錄設定單獨的過期時間。

建立時設定過期時間

可以在建立記錄的時候指定過期時間,redis提供了setex命令可以實現插入的時候同步指定過期時間。比如:

setex key1 5 value1

上述命令實現了往redis中寫入一個key1記錄,並同時設定了5s後過期。如果在JAVA SpringBoot專案中可以直接使用相關API介面來實現:

stringRedisTemplate.opsForValue().set("key1", "value1", 5, TimeUnit.SECONDS);

這樣快取寫入5s之後,快取記錄就會過期失效。描述到這裡可以看出,這是一種基於建立時間來判定是否過期的機制,也即常規上說的TTL策略,當設定了過期時間之後不管有沒有被使用都會到期被強制清理掉。但有很多場景下也會期望資料能夠按照TTI(指定時間未使用再過期)的方式來過期清理,如使用者鑑權場景:

假設使用者登入系統後生成token並儲存到Redis中,指定token有效期30分鐘,那麼如果使用者一直在使用系統的時候突然時間到了然後退出要求重新登入,這個體驗感就會很差。正確的預期應該是使用者連續操作的時候就不要退出登入,只有連續30分鐘沒有操作的時候才過期處理。

略有遺憾的是,Redis並不支援按照TTI機制來做資料過期處理。但是作為補償,Redis提供了一個重新設定某個key值過期時間的方法,可以透過expire方法來實現指定key的續期操作,以一種曲線救國的方式滿足訴求。

實現快取的續期

透過expire命令,可以對已有的記錄重新設定過期時間,如果此前已經有設定了過期時間,則覆蓋原先的過期時間。

expire key1 30

執行上述命令,可以將key1的過期時間給重新設定為30s,不管此前是否有過期時間。同樣地,在程式碼中也可以方便的實現這一命令:

stringRedisTemplate.expire("key1", 30, TimeUnit.SECONDS);

對於上面說的使用者token續期的訴求,可以這樣來操作:

使用者首次登入成功後,會生成一個token令牌,然後將令牌與使用者資訊儲存到redis中,設定30分鐘有效期。
每次請求介面中攜帶token來鑑權,每次get請求的時候,就重新透過expire操作將token的過期時間重新設定為30分鐘。
持續30分鐘無請求後,此條token快取資訊過期失效。

同樣實現了TTI的效果。

實現指定時刻過期

Redis的過期時間設定,是基於當前命令執行時刻開始的相對過期時間,只能設定距離當前多久後失效,如果想要實現在固定時刻失效,還需要呼叫端執行一點小小的換算處理來實現。

public void test() {
    LocalDateTime dateTime = LocalDateTime.parse("2022-11-23 22:00:00", DateTimeFormatter.ofPattern("yyyy-MM-dd " +
            "HH:mm:ss"));
    Date date = Date.from(dateTime.atZone(ZoneId.systemDefault()).toInstant());
    long expireTimeLong = date.getTime() - System.currentTimeMillis();
    stringRedisTemplate.expire("key1", expireTimeLong, TimeUnit.MILLISECONDS);
}

透過計算出目標時刻與當前時刻的時間差值,作為過期時間設定到記錄上,即可。

資料淘汰策略

前面強調過,Redis是一個基於記憶體的快取資料庫,而記憶體的容量通常是有限的。雖然Reids有提供資料過期處理邏輯,但是當資料量特別多的時候就需要資料淘汰機制來兜底了。

這裡資料淘汰策略與資料過期兩個概念的差異要先弄清楚:

  • 資料過期,是符合業務預期的一種資料刪除機制,為記錄設定過期時間,過期後從快取中移除。

  • 資料淘汰,是一種“有損自保”的降級策略,是業務預期之外的一種資料刪除手段。指的是所儲存的資料沒達到過期時間,但快取空間滿了,對於新的資料想要加入記憶體時,為了避免OOM而需要執行的一種應對策略。

試想下,把Redis當做一個容器,容器已滿的情況下繼續往裡面放東西,應對之法其實就兩種:

  1. 直接拒絕放入。

  2. 扔掉容器中部分已有內容,騰出空間接納新內容放入。

遵循上述認知,Redis提供了6種不同的資料淘汰機制,供使用方按需選擇,將有限的空間僅用來儲存熱點資料,實現快取的價值最大化。如下:

對幾種策略具體含義梳理歸納如下表所示:

資料淘汰策略 具體含義說明
noeviction 淘汰新進入的資料,即拒絕新內容寫入快取,直到快取有新的空間。
allkeys-lru 將記憶體中已有的key內容按照LRU策略將最久沒有使用的記錄淘汰掉,然後騰出空間用來存放新的記錄。
volatile-lru 從設定了過期時間的key裡面按照LRU策略,淘汰掉最久沒有使用的記錄。與allkeys-lru相比,這種方式僅會在設定了過期時間的key裡面進行淘汰。
allkeys-random 從已有的所有key裡面隨機剔除部分,騰出空間容納新資料。
volatile-random 從已有的設定了過期時間的key裡面隨機剔除部分,騰出空間容納新的資料
volatile-ttl 從已有的設定了過期時間的key裡面,將最近將要過期的資料提前剔除掉,與volatile-lru的區別在於排序邏輯不一樣,一個基於ttl規則排序,一個基於lru策略排序。

從上述策略裡面可以看出,根據LRURandom兩種操作的範圍不同,各自又細分了兩種不同的執行策略。

  • 從設定過期時間的key裡進行淘汰

相對來說,設定了過期時間的資料,說明業務層面已經默許了其可以被刪除,所以即使被提前淘汰了,對業務層面的影響也是比較小的。

系統中快取最近30分鐘的使用者瀏覽歷史記錄,即使這些資料被刪除淘汰,對系統主體功能而言,不會受損。

  • 從全量key裡面執行淘汰

從全量資料裡面執行淘汰,就有可能淘汰掉沒有設定過期時間的key記錄。未設定過期時間的資料如果資料被淘汰掉,很有可能會影響業務的執行邏輯邏輯正確性。

快取中儲存了系統內的黑名單使用者列表,使用者鑑權的時候,會判斷使用者是否在黑名單中,如果在黑名單中則禁止登入。這個黑名單是永久的,不會自己解封。如果由於被動淘汰策略觸發刪除部分黑名單,那原先的黑名單使用者就會不受限制而進入到系統中,導致預期之外的情況發生。

不得不說,Redis的這一細分處理原則,還是很貼心的。具體實踐中,可以根據自身系統記憶體儲的資料體量以及儲存的資料內容性質,選擇合適的資料淘汰策略。

資料持久化方案

除了容量有限之外,儲存在記憶體中的資料最大的風險點是什麼?資料丟失!

因為記憶體中的資料是非持久化儲存的,一旦斷電或者出現系統異常等情況,很容易導致記憶體資料丟失。所以大部分的系統裡面都只是將記憶體型快取用作資料庫的輔助扛壓,最終的資料儲存在DB等可以持久化儲存容器中,同步一份資料到快取中用於併發場景下的業務使用。

這種組網場景下,Redis的資料其實是沒有持久化的訴求的,因為Redis中資料僅僅是一份副本,最終資料在DB中都有。即使系統異常或者掉電重啟,也可以基於資料庫的資料進行快取重建 —— 最多就是資料量特別巨大的時候,重建快取的耗時會比較長。

另外一種場景,業務裡面會有有些寫操作會比較頻繁、強依賴Redis特性來實現的功能,這部分資料不能丟、但又沒有重要到必須每次更新都需要存入DB的地步。比如部落格系統中的文章閱讀量資料,文章每次被讀取都需要更新閱讀數,寫操作非常頻繁,如果閱讀量儲存到DB中,會導致DB壓力較大,這種情況就希望可以將資料儲存在記憶體中,然後記憶體資料可以持久化儲存。

Redis提供了多種持久化方案,可以實現將記憶體資料定期儲存到磁碟上,重啟時候可以從磁碟載入到記憶體中,以此來避免資料的丟失。

下面一起看下。

RDB全量持久化模式

全量模式很好理解,就是定時將當前記憶體裡面所有的key-value鍵值對內容,全部匯出一份快照資料儲存到磁碟上。這樣下次如果需要使用的時候,就可以從磁碟上載入快照檔案,實現記憶體資料的恢復。

RDB全量模式持久化將資料寫入磁碟的動作可以分為SAVEBGSAVE兩種。所謂BGSAVE就是background-save,也就是後臺非同步save,區別點在於SAVE是由Redis的命令執行執行緒按照普通命令的方式去執行操作,而BGSAVE是透過fork出一個新的程式,在新的獨立程式裡面去執行save操作。

還記得前面文章中說的麼?Redis的請求命令執行是透過單執行緒的方式執行的,所以要儘量避免耗時操作,而save動作需要將記憶體全部資料寫入到磁碟上,對於redis而言,這一操作是非常耗時的,會阻塞住全部正常業務請求,所以save操作的觸發只有兩個場景:

  1. 客戶端手動傳送save命令執行
  2. Redis在shutdown的時候自動執行

從資料儲存完備性方面看,這兩種方式都起不到自動持久化備份的能力,如果出現一些機器掉電等情況,是不會觸發redis shutdown操作的,將面臨資料丟失的風險。

相比而言,bgsave的殺傷力要小一些、適用度也更好一些,它可以保證在持久化期間Redis主程式可以繼續處理業務請求。bgsave增加了過程中自動持久化操作的機制,觸發條件更加的“智慧”:

  1. 客戶端手動命令觸發bgsave操作
  2. Redis配置定時任務觸發(支援間隔時間+變更資料量雙重維度綜合判斷,達到任一條件則觸發)

此外,在master-slave主從部署的場景中還支援僅由slave節點觸發bgsave操作,來降低對master節點的影響。值得注意的是,在fork子程式的時候需要將redis主程式中記憶體所有資料都複製一份到子程式中,所以bgsave操作實際上是將子程式記憶體中的資料快照匯出到磁碟上,在執行期間對機器的剩餘記憶體有較高要求,如果機器剩餘記憶體不足,則可能導致fork的時候兩份記憶體資料量超過機器實體記憶體大小,導致系統啟用虛擬記憶體,複製速度大打折扣(虛擬記憶體本質上就是把磁碟當記憶體用,操作速度相比實體記憶體大大降低),會阻塞住Redis主程式的命令執行。

如果開啟了RDB的bgsave定時觸發執行機制,在出現異常掉電等情況,可能會丟失最後一部分尚未來及持久化的內容。在恢復的時候,Redis啟動之後會先去讀取RDB檔案然後將其寫入記憶體中恢復此前的快取資料,資料恢復期間不受理外部業務請求。

AOF增量同步方式

RDB全量模式簡單粗暴,直接將記憶體全量資料儲存為快照序列化到本地。AOF(Append Only File)與RDB的思路不同,AOF更像是記錄住Redis的每一次寫請求執行命令,將每次執行的寫操作命令記錄儲存到磁碟上,然後透過一種類似命令重放執行的方式,來實現資料的恢復。

AOF具體實現的時候,包含幾種不同的策略:

  • always

可以簡單的理解為每一條redis寫請求執行的時候會觸發一次磁碟寫入操作,且只有在磁碟寫入完成之後,請求的響應才會返回。這種方式可以保證AOF記錄的準確性,但是會嚴重影響Redis的併發吞吐量。

  • every sec

非同步執行,任務執行執行緒執行命令後將命令寫入任務放入佇列中,由子執行緒非同步方式每秒一次將執行命令分批寫入檔案中,相比always方式在異常情況下可能會丟失最後1s的執行記錄,但可以大大降低對redis命令執行效率的影響。

  • no

redis不控制落盤時間,由作業系統去決定什麼時候該往磁碟flush,這種情況一般不推薦使用,無法準確掌控是否落盤,可靠性不夠。

AOF的方式落盤持久化的時候,每次僅寫入增量的部分,所以對系統整體執行期的影響較小,但隨著系統線上執行時長的累加,AOF中儲存的命令也越來越多,這樣問題也隨著出現:

  1. AOF寫入的方式類似與日誌列印,將請求追加寫入到磁碟檔案中,文字檔案未經過壓縮,時間久了之後會佔據大量磁碟空間,易造成磁碟滿的問題。
  2. 在需要從AOF檔案回放重新構建快取內容時,可能會耗時較久(相當於要將長期累積下來的寫操作命令逐個重新執行一下)。

RDB與AOF混合使用

從前面的介紹中可以看出:

  • RDB在過程中每次寫磁碟的時候對Redis業務處理的效能影響較大,但是從磁碟載入到記憶體重建快取的時候效率很高。

  • AOF透過增量的方式降低了執行過程中對Redis業務處理的影響,但是命令回放重建快取的時候效率較差。

如果將兩者結合起來使用,是否可以取長補短呢?事實似乎的確如此。從4.0版本開始,Redis支援了RDB + AOF的混合持久化方式,透過rewrite機制來實現。需要在redis的配置檔案中開啟對應開關:

aof-use-rdb-preamble yes

開啟之後,redis在每次執行aof操作的時候會判斷下是否達到了觸發rewrite的條件,如果達到,則fork出一個新的子程式進行RDB操作將當前時刻全量記憶體資料生成RDB資料然後寫入到AOF檔案中,而後續的寫操作命令則繼續append方式追加記錄到AOF檔案中。這樣一來AOF檔案實際上由兩部分內容組成。如下圖所示:

透過RDB + AOF混合的策略,很好的實現了兩者的優勢互補:

  1. 先透過AOF的方式記錄命令,達到門檻的時候才執行rewrite操作生成RDB,最大限度降低了RDB執行頻率,降低了對redis業務命令處理過程的影響。
  2. 透過RDB的方式替代了前期大量的AOF命令儲存,有效的降低了磁碟佔用。
  3. 透過RDB + AOF的方式,系統重建快取的時候,先載入RDB檔案完成主體資料的重建,然後在此基礎上重放AOF增量命令,大大降低了啟動時AOF重放的耗時。

小結回顧

好啦,關於Redis的資料過期設定、資料淘汰機制以及資料持久化策略等方面的問題,就討論到這裡了。那麼你對Redis是否有了新的瞭解呢?你覺得Redis的哪個方面特性最打動了你呢?歡迎評論區一起交流下,期待和各位小夥伴們一起切磋、共同成長。

? 補充說明

本文屬於《深入理解快取原理與實戰設計》系列專欄的內容之一。該專欄圍繞快取這個宏大命題進行展開闡述,全方位、系統性地深度剖析各種快取實現策略與原理、以及快取的各種用法、各種問題應對策略,並一起探討下快取設計的哲學。

如果有興趣,也歡迎關注此專欄。

我是悟道,聊技術、又不僅僅聊技術~

如果覺得有用,請點贊 + 關注讓我感受到您的支援。也可以關注下我的公眾號【架構悟道】,獲取更及時的更新。

期待與你一起探討,一起成長為更好的自己。

相關文章