Redis最常用的一個場景就是作為緩存,本文主要探討Redis作為緩存,在實踐中可能會有哪些問題?比如一致性, 穿擊, 穿透, 雪崩, 污染等。
為什么要理解Redis緩存問題
在高并發的業務場景下,數據庫大多數情況都是用戶并發訪問最薄弱的環節。所以,就需要使用redis做一個緩沖操作,讓請求先訪問到redis,而不是直接訪問Mysql等數據庫。這樣可以大大緩解數據庫的壓力。
當緩存庫出現時,必須要考慮如下問題:
- 緩存穿透
- 緩存穿擊
- 緩存雪崩
- 緩存污染(或者滿了)
- 緩存和數據庫一致性
緩存穿透
1. 問題本質
緩存穿透是指 查詢一個不存在的數據,導致請求繞過緩存直接訪問數據庫。由于數據庫也沒有該數據,無法回填緩存,導致惡意請求反復穿透緩存壓垮數據庫。
2. 攻擊場景
- 惡意請求:如頻繁查詢
id=-1
或超大ID等不存在的數據。 - 漏洞利用:利用業務接口缺陷偽造非法參數。
3. 解決方案對比
(1) 參數校驗(最基礎防御)
- 實現:在API層攔截明顯非法請求(如非正整數ID、超長字符串)。
- 優點:簡單高效,攔截80%低級攻擊。
- 缺點:無法防御精心構造的合法參數(如隨機不存在的ID)。
示例代碼:
if (id <= 0 || id > MAX_ID) {throw new IllegalArgumentException("非法ID");
}
(2) 緩存空值(簡單有效)
- 實現:數據庫未命中時,緩存
key-null
并設置短TTL(如30秒)。 - 優點:避免同一Key反復穿透。
- 缺點:
-
- 內存浪費(需存儲大量空值)。
- 短時間仍可能被攻擊(如海量不同Key)。
示例代碼:
String data = cache.get(key);
if (data == null) {data = db.query(key);if (data == null) { // 數據庫不存在cache.set(key, "NULL", 30); // 緩存空值} else {cache.set(key, data, 3600);}
}
return "NULL".equals(data) ? null : data;
(3) 布隆過濾器(終極防御)
- 原理:
-
- 使用位數組和哈希函數,判斷元素 “一定不存在” 或 “可能存在”。
- 將所有合法Key預先存入布隆過濾器,查詢前先檢查過濾器。
- 優點:
-
- 內存占用極低(1億Key約占用12MB)。
- 攔截不存在Key的效率接近O(1)。
- 缺點:
-
- 誤判率:可能將不存在的Key誤判為存在(可通過調整哈希函數和數組大小控制)。
- 無法刪除:傳統布隆過濾器不支持刪除(需用變種如Counting Bloom Filter)。
實現示例(Guava庫):
BloomFilter<String> filter = BloomFilter.create(Funnels.stringFunnel(), 1000000, // 預期元素數量0.01 // 誤判率
);// 預熱數據
for (String validKey : allValidKeys) {filter.put(validKey);
}// 查詢前檢查
if (!filter.mightContain(key)) {return null; // 絕對不存在
}
緩存擊穿
緩存擊穿(Cache Breakdown)是緩存系統中的一種典型問題,通常發生在高并發訪問某個熱點數據時,該數據恰好緩存過期,導致大量請求瞬間穿透緩存直接壓垮數據庫。
問題本質
- 觸發條件:
-
- 緩存中的熱點Key過期(如秒殺商品、首頁頭條)。
- 同時有大量并發請求訪問該Key。
- 直接后果:
-
- 所有請求繞過緩存,直接查詢數據庫。
- 數據庫短時間內承受極高QPS(可能崩潰)。
解決方案及實現
方案1:熱點數據永不過期
- 適用場景:極高頻訪問且數據更新不頻繁(如配置信息)。
- 實現方式:
-
- 物理永不過期:緩存不設TTL。
- 邏輯續期:異步檢查數據版本,有更新時重新加載緩存。
- 優點:徹底避免擊穿。
- 缺點:數據更新不及時(需結合其他機制如消息隊列通知)。
// 示例:邏輯續期(偽代碼)
public String getHotData(String key) {String data = cache.get(key);if (data == null) {data = loadFromDB(key);cache.set(key, data); // 不設置過期時間scheduleRefresh(key); // 異步定時刷新}return data;
}
方案2:互斥鎖(Mutex Lock)
- 核心思想:只允許一個請求重建緩存,其他請求阻塞或輪詢等待。
- 實現方式:
-
- 分布式鎖:如Redis的
SETNX
(推薦)。 - 本地鎖:僅單機有效(如Java的
synchronized
)。
- 分布式鎖:如Redis的
分布式鎖實現(Redis)
public String getDataWithLock(String key) {String data = cache.get(key);if (data == null) {String lockKey = "lock:" + key;try {// 嘗試獲取分布式鎖(SETNX + 超時)boolean locked = redis.setnx(lockKey, "1", 10, TimeUnit.SECONDS);if (locked) {data = loadFromDB(key); // 查數據庫cache.set(key, data, 1, TimeUnit.HOURS); // 回填緩存} else {Thread.sleep(100); // 短暫等待后重試return getDataWithLock(key); // 遞歸調用}} finally {redis.del(lockKey); // 釋放鎖}}return data;
}
- 優點:保證數據庫不會被重復查詢。
- 缺點:
-
- 鎖競爭可能導致部分請求延遲。
- 需處理鎖超時和死鎖問題。
方案3:限流與熔斷降級
- 適用場景:突發流量無法用緩存完全攔截時(如明星出軌新聞)。
- 實現工具:
-
- 限流:Guava
RateLimiter
、Sentinel、Nginx。 - 熔斷:Hystrix、Resilience4j。
- 限流:Guava
- 示例:
// Guava 限流(每秒10個請求)
RateLimiter limiter = RateLimiter.create(10.0);
public String getDataWithRateLimit(String key) {if (!limiter.tryAcquire()) {return "系統繁忙,請稍后再試"; // 降級響應}return getData(key); // 正常邏輯
}
- 優點:保護數據庫不被突發流量擊垮。
- 缺點:可能誤傷正常用戶請求。
緩存雪崩
問題本質
緩存雪崩是指緩存中數據大批量到過期時間,而查詢數據量巨大,引起數據庫壓力過大甚至down機。和緩存擊穿不同的是,緩存擊穿指并發查同一條數據,緩存雪崩是不同數據都過期了,很多數據都查不到從而查數據庫。
觸發條件
- 大批量Key同時過期:如緩存數據初始化時設置了相同的TTL(如默認1小時)。
- 緩存服務宕機:Redis集群崩潰,所有請求直接打到數據庫。
解決方案
- 緩存數據的過期時間設置隨機,防止同一時間大量數據過期現象發生。
- 如果緩存數據庫是分布式部署,將熱點數據均勻分布在不同的緩存數據庫中。(緩存服務高可用)
- 設置熱點數據永遠不過期。
緩存污染(或滿了)
緩存污染問題說的是緩存中一些只會被訪問一次或者幾次的的數據,被訪問完后,再也不會被訪問到,但這部分數據依然留存在緩存中,消耗緩存空間。
緩存污染會隨著數據的持續增加而逐漸顯露,隨著服務的不斷運行,緩存中會存在大量的永遠不會再次被訪問的數據。緩存空間是有限的,如果緩存空間滿了,再往緩存里寫數據時就會有額外開銷,影響Redis性能。這部分額外開銷主要是指寫的時候判斷淘汰策略,根據淘汰策略去選擇要淘汰的數據,然后進行刪除操作。
最大緩存設置多大
系統的設計選擇是一個權衡的過程:大容量緩存是能帶來性能加速的收益,但是成本也會更高,而小容量緩存不一定就起不到加速訪問的效果。一般來說,我會建議把緩存容量設置為總數據量的 15% 到 30%,兼顧訪問性能和內存空間開銷。
對于 Redis 來說,一旦確定了緩存最大容量,比如 4GB,你就可以使用下面這個命令來設定緩存的大小了:
CONFIG SET maxmemory 4gb
不過,緩存被寫滿是不可避免的, 所以需要數據淘汰策略。
緩存淘汰策略
Redis共支持八種淘汰策略,分別是noeviction、volatile-random、volatile-ttl、volatile-lru、volatile-lfu、allkeys-lru、allkeys-random 和 allkeys-lfu 策略。
怎么理解呢?主要看分三類看:
- 不淘汰
-
- noeviction (v4.0后默認的)
- 對設置了過期時間的數據中進行淘汰
-
- 隨機:volatile-random
- ttl:volatile-ttl
- lru:volatile-lru
- lfu:volatile-lfu
- 全部數據進行淘汰
-
- 隨機:allkeys-random
- lru:allkeys-lru
- lfu:allkeys-lfu
具體對照下:
1. noeviction
該策略是Redis的默認策略。在這種策略下,一旦緩存被寫滿了,再有寫請求來時,Redis 不再提供服務,而是直接返回錯誤。這種策略不會淘汰數據,所以無法解決緩存污染問題。一般生產環境不建議使用。
其他七種規則都會根據自己相應的規則來選擇數據進行刪除操作。
2. volatile-random
這個算法比較簡單,在設置了過期時間的鍵值對中,進行隨機刪除。因為是隨機刪除,無法把不再訪問的數據篩選出來,所以可能依然會存在緩存污染現象,無法解決緩存污染問題。
3. volatile-ttl
這種算法判斷淘汰數據時參考的指標比隨機刪除時多進行一步過期時間的排序。Redis在篩選需刪除的數據時,越早過期的數據越優先被選擇。
4. volatile-lru
LRU算法:LRU 算法的全稱是 Least Recently Used,按照最近最少使用的原則來篩選數據,優先淘汰 最久未被訪問 的數據(基于最近訪問時間)。這種模式下會使用 LRU 算法篩選設置了過期時間的鍵值對。
Redis優化的LRU算法實現:
Redis會記錄每個數據的最近一次被訪問的時間戳。在Redis在決定淘汰的數據時,第一次會隨機選出 N 個數據,把它們作為一個候選集合。接下來,Redis 會比較這 N 個數據的 lru 字段,把 lru 字段值最小的數據從緩存中淘汰出去。通過隨機讀取待刪除集合,可以讓Redis不用維護一個巨大的鏈表,也不用操作鏈表,進而提升性能。
Redis 選出的數據個數 N,通過 配置參數 maxmemory-samples 進行配置。個數N越大,則候選集合越大,選擇到的最久未被使用的就更準確,N越小,選擇到最久未被使用的數據的概率也會隨之減小。
5. volatile-lfu
優先淘汰 訪問頻率最低 的數據,頻率相同則淘汰最久未訪問的。會使用 LFU 算法選擇設置了過期時間的鍵值對。
LFU 算法:LFU 緩存策略是在 LRU 策略基礎上,為每個數據增加了一個計數器,來統計這個數據的訪問次數。當使用 LFU 策略篩選淘汰數據時,首先會根據數據的訪問次數進行篩選,把訪問次數最低的數據淘汰出緩存。如果兩個數據的訪問次數相同,LFU 策略再比較這兩個數據的訪問時效性,把距離上一次訪問時間更久的數據淘汰出緩存。 Redis的LFU算法實現:
當 LFU 策略篩選數據時,Redis 會在候選集合中,根據數據 lru 字段的后 8bit 選擇訪問次數最少的數據進行淘汰。當訪問次數相同時,再根據 lru 字段的前 16bit 值大小,選擇訪問時間最久遠的數據進行淘汰。
Redis 只使用了 8bit 記錄數據的訪問次數,而 8bit 記錄的最大值是 255,這樣在訪問快速的情況下,如果每次被訪問就將訪問次數加一,很快某條數據就達到最大值255,可能很多數據都是255,那么退化成LRU算法了。所以Redis為了解決這個問題,實現了一個更優的計數規則,并可以通過配置項,來控制計數器增加的速度。
參數 :
lfu-log-factor
,用計數器當前的值乘以配置項 lfu_log_factor 再加 1,再取其倒數,得到一個 p 值;然后,把這個 p 值和一個取值范圍在(0,1)間的隨機數 r 值比大小,只有 p 值大于 r 值時,計數器才加 1。
lfu-decay-time
, 控制訪問次數衰減。LFU 策略會計算當前時間和數據最近一次訪問時間的差值,并把這個差值換算成以分鐘為單位。然后,LFU 策略再把這個差值除以 lfu_decay_time 值,所得的結果就是數據 counter 要衰減的值。
lfu-log-factor
設置越大,遞增概率越低,lfu-decay-time設置越大,衰減速度會越慢。
我們在應用 LFU 策略時,一般可以將 lfu_log_factor 取值為 10。 如果業務應用中有短時高頻訪問的數據的話,建議把 lfu_decay_time 值設置為 1。可以快速衰減訪問次數。
volatile-lfu 策略是 Redis 4.0 后新增。
6. allkeys-lru
使用 LRU 算法在所有數據中進行篩選。具體LFU算法跟上述 volatile-lru 中介紹的一致,只是篩選的數據范圍是全部緩存,這里就不在重復。
7. allkeys-random
從所有鍵值對中隨機選擇并刪除數據。volatile-random 跟 allkeys-random算法一樣,隨機刪除就無法解決緩存污染問題。
8. allkeys-lfu
使用 LFU 算法在所有數據中進行篩選。具體LFU算法跟上述 volatile-lfu 中介紹的一致,只是篩選的數據范圍是全部緩存,這里就不在重復。
allkeys-lfu 策略是 Redis 4.0 后新增。
數據庫和緩存一致性
問題來源
使用redis做一個緩沖操作,讓請求先訪問到redis,而不是直接訪問MySQL等數據庫:
讀取緩存步驟一般沒有什么問題,但是一旦涉及到數據更新:數據庫和緩存更新,就容易出現緩存(Redis)和數據庫(MySQL)間的數據一致性問題。
不管是先寫MySQL數據庫,再刪除Redis緩存;還是先刪除緩存,再寫庫,都有可能出現數據不一致的情況。舉一個例子:
1.如果刪除了緩存Redis,還沒有來得及寫庫MySQL,另一個線程就來讀取,發現緩存為空,則去數據庫中讀取數據寫入緩存,此時緩存中為臟數據。
2.如果先寫了庫,在刪除緩存前,寫庫的線程宕機了,沒有刪除掉緩存,則也會出現數據不一致情況。
因為寫和讀是并發的,沒法保證順序,就會出現緩存和數據庫的數據不一致的問題。
4種相關模式
更新緩存的的Design Pattern有四種:Cache aside, Read through, Write through, Write behind caching;
節選最最常用的Cache Aside Pattern, 總結來說就是
- 讀的時候,先讀緩存,緩存沒有的話,就讀數據庫,然后取出數據后放入緩存,同時返回響應。
- 更新的時候,先更新數據庫,然后再刪除緩存。
其具體邏輯如下:
- 失效:應用程序先從cache取數據,沒有得到,則從數據庫中取數據,成功后,放到緩存中。
- 命中:應用程序從cache中取數據,取到后返回。
- 更新:先把數據存到數據庫中,成功后,再讓緩存失效。
注意,我們的更新是先更新數據庫,成功后,讓緩存失效。那么,這種方式是否可以沒有文章前面提到過的那個問題呢?我們可以腦補一下。
一個是查詢操作,一個是更新操作的并發,首先,沒有了刪除cache數據的操作了,而是先更新了數據庫中的數據,此時,緩存依然有效,所以,并發的查詢操作拿的是沒有更新的數據,但是,更新操作馬上讓緩存的失效了,后續的查詢操作再把數據從數據庫中拉出來。而不會像文章開頭的那個邏輯產生的問題,后續的查詢操作一直都在取老的數據。
這是標準的design pattern,包括Facebook的論文《Scaling Memcache at Facebook》也使用了這個策略。為什么不是寫完數據庫后更新緩存?你可以看一下Quora上的這個問答《Why does Facebook use delete to remove the key-value pair in Memcached instead of updating the Memcached during write request to the backend?》,主要是怕兩個并發的寫操作導致臟數據。
那么,是不是Cache Aside這個就不會有并發問題了?不是的,比如,一個是讀操作,但是沒有命中緩存,然后就到數據庫中取數據,此時來了一個寫操作,寫完數據庫后,讓緩存失效,然后,之前的那個讀操作再把老的數據放進去,所以,會造成臟數據。
但,這個case理論上會出現,不過,實際上出現的概率可能非常低,因為這個條件需要發生在讀緩存時緩存失效,而且并發著有一個寫操作。而實際上數據庫的寫操作會比讀操作慢得多,而且還要鎖表,而讀操作必需在寫操作前進入數據庫操作,而又要晚于寫操作更新緩存,所有的這些條件都具備的概率基本并不大。
所以,這也就是Quora上的那個答案里說的,要么通過2PC或是Paxos協議保證一致性,要么就是拼命的降低并發時臟數據的概率,而Facebook使用了這個降低概率的玩法,因為2PC太慢,而Paxos太復雜。當然,最好還是為緩存設置上過期時間。
方案一:隊列 + 重試機制
流程如下所示
- 更新數據庫數據;
- 緩存因為種種問題刪除失敗
- 將需要刪除的key發送至消息隊列
- 自己消費消息,獲得需要刪除的key
- 繼續重試刪除操作,直到成功
然而,該方案有一個缺點,對業務線代碼造成大量的侵入。于是有了方案二,在方案二中,啟動一個訂閱程序去訂閱數據庫的binlog,獲得需要操作的數據。在應用程序中,另起一段程序,獲得這個訂閱程序傳來的信息,進行刪除緩存操作。
方案二:異步更新緩存(基于訂閱binlog的同步機制)
- 技術整體思路:
MySQL binlog增量訂閱消費+消息隊列+增量數據更新到redis
1)讀Redis:熱數據基本都在Redis
2)寫MySQL: 增刪改都是操作MySQL
3)更新Redis數據:MySQ的數據操作binlog,來更新到Redis
- Redis更新
1)數據操作主要分為兩大塊:
- 一個是全量(將全部數據一次寫入到redis)
- 一個是增量(實時更新)
這里說的是增量,指的是mysql的update、insert、delate變更數據。
2)讀取binlog后分析 ,利用消息隊列,推送更新各臺的redis緩存數據。
這樣一旦MySQL中產生了新的寫入、更新、刪除等操作,就可以把binlog相關的消息推送至Redis,Redis再根據binlog中的記錄,對Redis進行更新。
其實這種機制,很類似MySQL的主從備份機制,因為MySQL的主備也是通過binlog來實現的數據一致性。
這里可以結合使用canal(阿里的一款開源框架),通過該框架可以對MySQL的binlog進行訂閱,而canal正是模仿了mysql的slave數據庫的備份請求,使得Redis的數據更新達到了相同的效果。
當然,這里的消息推送工具你也可以采用別的第三方:kafka、rabbitMQ等來實現消息推送更新Redis。