一、Redis的緩存設計不合理會存在的問題
Redis作為緩存,但是緩存設計的不合理就會有以下的問題:
- 緩存失效
- 緩存穿透
- 緩存雪崩
緩存失效
由於大批量的緩存在同一個時間點失效,可能造成大量請求同時穿透緩存直達數據庫,可能造成數據庫的壓力瞬間增大,甚至數據庫掛掉的情況。
例如:熱點緩存在初始化的時候,會有拿出很多的數據,為保證數據的最新特性,一般都會設置一個超時時間;但是當這個超時時間到的時候,數據緩存就會全部失效,造成所有請求壓力全部作用到數據庫上。
解決方法:在緩存初始化的時候,超時時間設置的不一樣。
偽代碼,如下:
String get(String key) {
// 從緩存中獲取數據
String cacheValue = cache.get(key);
// 緩存為空
if (StringUtils.isBlank(cacheValue)) {
// 從存儲中獲取
String storageValue = storage.get(key);
cache.set(key, storageValue);
//設置一個過期時間(300到600之間的一個隨機數)
int expireTime = new Random().nextInt(300) + 300;
if (storageValue == null) {
cache.expire(key, expireTime);
}
return storageValue;
} else {
// 緩存非空
return cacheValue;
}
}
緩存穿透
緩存穿透是指查詢一個根本不存在的數據,緩存層不會命中,大量的請求全部落到數據庫存儲層上,嚴重時造成數據庫掛掉。
通常是出於容錯的考慮,如果從存儲層查詢不到的不到數據,則不寫入到緩存層。
造成緩存穿透的原因主要有兩個:
(1)自身業務代碼或數據出現問題;
(2)一些惡意攻擊、爬蟲等造成大量空命中;
解決方法
方法一:將空對象緩存到Redis,並設置超時時間;但是若黑客制造了上千萬個key,那存儲到redis就會占用很大的空間。
偽代碼如下:
String get(String key) {
// 從緩存中獲取數據
String cacheValue = cache.get(key);
// 緩存為空
if (StringUtils.isBlank(cacheValue)) {
// 從存儲中獲取
String storageValue = storage.get(key);
cache.set(key, storageValue);
// 如果存儲數據為空, 需要設置一個過期時間(300秒)
if (storageValue == null) {
cache.expire(key, 60 * 5);
}
return storageValue;
} else {
// 緩存非空
return cacheValue;
}
}
方式二: 布隆過濾器
對於惡意攻擊,向服務器請求大量不存在的數據造成的緩存穿透,還可以用布隆過濾器先做一次過濾,對於不存在的數據布隆過濾器一般都能夠過濾掉,不讓請求再往后端發送。當布隆過濾器說某個值存在時,這個值可能不存在;當它說不存在時,那就肯定不存在。
布隆過濾器的底層實際上一個大型的二進制數組(BitArray),即里面只能放0和1,它的里面存儲的不是真正的值而是0或1;真正的數據值經過hash函數計算后,得到一個數字n,那么就設置 BitArray[n] = 1 (即數據為n的下標存儲的值變為1)。為了防止 hash沖突,可以使用多個 hash函數經過多次計算得到。因為hash沖突的存在,所以說某個值存在時,它可能不存在;當它不存在時,那肯定就不存在。
布隆過濾器占用的空間很少,效率也高。
(1)用guvua包自帶的布隆過濾器,引入依賴
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>22.0</version>
</dependency>
(2)示例偽代碼
import com.google.common.hash.BloomFilter;
//初始化布隆過濾器
//1000:期望存入的數據個數,0.001:期望的誤差率
BloomFilter<String> bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charset.forName("utf‐8")), 1000, 0.001);
//把所有數據存入布隆過濾器
void init(){
for (String key: keys) {
bloomFilter.put(key);
}
}
String get(String key) {
// 從布隆過濾器這一級緩存判斷下key是否存在
Boolean exist = bloomFilter.mightContain(key);
if(!exist){
return "";
}
// 從緩存中獲取數據
String cacheValue = cache.get(key);
// 緩存為空
if (StringUtils.isBlank(cacheValue)) {
// 從存儲中獲取
String storageValue = storage.get(key);
cache.set(key, storageValue);
// 如果存儲數據為空, 需要設置一個過期時間(300秒)
if (storageValue == null) {
cache.expire(key, 60 * 5);
}
return storageValue;
} else {
// 緩存非空
return cacheValue;
}
}
注意:此處使用的是單機版的布隆過濾器,實際上 Redisson 也實現了布隆過濾器。
緩存雪崩
緩存雪崩指的是緩存層支撐不住或掛掉后,流量全部作用到存儲層上,造成存儲層也給掛掉。
解決方法:
(1)保證緩存層服務的高可用,使用 Redis的哨兵或Redis的集群方式;
(2)依賴隔離組件為后端限流和降級。使用 springCloud 的組件 Hystrix 來限流降級;
熱點緩存key重建
Redis的緩存層中沒有數據,但是在同一時刻獲取該數據的請求多達幾十W的QPS,造成這么多的請求全部作用到數據庫上。
例如:某一個突發的新聞,但是沒有緩存到Redis,但是同一個時刻查看該新聞的人多達幾十萬,造成這么多的請求全部作用到數據庫上,導致數據庫掛掉。
解決方法:並發量較大的時候,可以讓一個請求去數據庫查詢,其他請求等待。(使用分布式鎖實現)
偽代碼如下:
String get(String key) { // 從Redis中獲取數據 String value = redis.get(key); // 如果value為空, 則開始重構緩存 if (value == null) { // 只允許一個線程重建緩存, 使用setnx, 並設置過期時間ex String mutexKey = "mutext:key:" + key; if (redis.set(mutexKey, "1", "ex 180", "nx")) { // 從數據源獲取數據 value = db.get(key);
// 回寫Redis, 並設置過期時間 redis.setex(key, timeout, value);13 // 刪除key_mutex redis.delete(mutexKey); } // 其他線程休息50毫秒后重試 else { Thread.sleep(50); get(key); } } return value; }
二、Redis使用的規范
1、鍵值的設計
(1)key的命名:
- 【建議】以業務名或數據庫名為前綴,用冒號分割; trade:order:1
- 【建議】保證語義的前提下,控制 key 的長度(key比較多時占用的內存比較大)
user:{uid}:friends:messages:{mid}
簡化為
u:{uid}:fr:m:{mid}
(2)value 的設計
- 【強制】拒絕 bigkey (防止網卡流量、查詢慢)
在Redis中,一個字符串最大512MB,一個二級數據結構(例如hash、list、set、zset)可以存儲大約40億個(2^32-1)個元素,但實際中如果下面兩種情況,我就會認為它是bigkey。
a. 字符串類型:它的big體現在單個value值很大,一般認為超過10KB就是bigkey。
b. 非字符串類型:哈希(hash)、列表(list)、集合(set)、有序集合(zset),它們的big體現在元素個數太多,不要超過5000個。
問題:假如出現了非字符串類型的 bigkey,我們怎么去處理呢?
解答:非字符串的bigkey,不要使用del刪除,使用hscan、sscan、zscan方式漸進式刪除,同時要注意防止bigkey過期時間自動刪除問題(例如一個200萬的zset設置1小時過期,會觸發del操作,造成阻塞)
BigKey的危害:
- 導致Redis的阻塞;
- 造成網絡阻塞;
bigkey也就意味着每次獲取要產生的網絡流量較大,假設一個bigkey為1MB,客戶端每秒訪問量為1000,那么每秒產生1000MB的流量,對於普通的千兆網卡(按照字節算是128MB/s)的服務器來說簡直是滅頂之災,而且一般服務器會采用單機多實例的方式來部署,也就是說一個bigkey可能會對其他實例也造成影響,其后果不堪設想。
有個bigkey,它安分守己(只執行簡單的命令,例如hget、lpop、zscore等),但它設置了過期時間,當它過期后,會被刪除,如果沒有使用Redis 4.0的過期異步刪除(lazyfree-lazyexpire yes),就會存在阻塞Redis的可能性。
- 【建議】選擇適合的數據類型。
例如:實體類型(要合理控制和使用數據結構,但也要注意節省內存和性能之間的平衡)
反例:
set user:1:name tom
set user:1:age 19
set user:1:favor football
正例:
hmset user:1 name tom age 19 favor football
2、命令的使用
- 【推薦】遍歷的需求可以使用hscan、sscan、zscan,來代替 hgetall、lrange、smembers、zrange、sinter等。
- 【推薦】禁用命令;通過redis的rename機制禁掉命令:keys、flushall、flushdb等。
- 【推薦】使用批量操作提高效率;但要注意控制一次批量操作的元素個數(例如500以內,實際也和元素字節數有關)。
原生命令:例如mget、mset。
非原生命令:可以使用pipeline提高效率。
注意兩者不同:
a. 原生是原子操作,pipeline是非原子操作;
b. pipeline可以打包不同的命令,原生做不到;
c. pipeline需要客戶端和服務端同時支持。
- 【建議】Redis事務功能較弱,不建議過多使用,可以用lua替代。
3、客戶端使用
- 【推薦】避免多個應用使用一個Redis實例。正例:不相干的業務拆分,公共數據做服務化。
- 【推薦】使用帶有連接池的數據庫,可以有效控制連接,同時提高效率。
連接池參數說明
參數名 | 含義 | 默認值 | 使用建議 |
maxTotal | 資源池中最大連接數 | 8 | |
maxIdle | 資源池允許最大空閑 的連接數 |
8 | |
minIdle | 資源池確保最少空閑 的連接數 |
0 | |
blockWhenExhausted |
當資源池用盡后,調用者是否要等待。只有當為true時,下面的maxWaitMillis才會生效 |
true | 建議使用默認值 |
maxWaitMillis | 當資源池連接用盡后,調用者的最大等待時間(單位為毫秒) |
-1:表示永不超時 | 不建議使用默認值 |
testOnBorrow | 向資源池借用連接時是否做連接有效性檢測(ping),無效連接會被移除 |
false | 業務量很大時候建議 設置為false(多一次 ping的開銷)。 |
testOnReturn | 向資源池歸還連接時是否做連接有效性檢測(ping),無效連接會被移除 |
false | 業務量很大時候建議 設置為false(多一次 ping的開銷)。 |
jmxEnabled | 是否開啟jmx監控,可用於監控 |
true | 建議開啟,但應用本身也有開啟 |
(1)maxTotal:最大連接數,早期的版本叫 maxActive;
設置 maxTotal 的值時,需要考慮以下場景:
- 業務希望Redis並發量;
- 客戶端執行命令時間;
- Redis資源:例如 nodes(例如應用個數) * maxTotal 是不能超過redis的配置文件中最大連接數 maxclients。
- 資源開銷:例如雖然希望控制空閑連接(連接池此刻可馬上使用的連接),但是不希望因為連接池的頻繁釋放創建連接造成不必靠開銷,例如:
假設一次Redis命令時間的平均耗時約為1ms,那么一個連接的QPS大約是1000;業務期望的QPS是50000,那么理論上需要的資源池大小是50000 / 1000 = 50個。但事實上這是個理論值,還要考慮到要比理論值預留一些資源,通常來講maxTotal可以比理論值大一些。但這個值不是越大越好,一方面連接太多占用客戶端和服務端資源,另一方面對於Redis這種高QPS的服務器,一個大命令的阻塞即使設置再大資源池仍然會無濟於事。
(2)maxIdle
maxIdle實際上才是業務需要的最大連接數,maxTotal是為了給出余量,所以maxIdle不要設置過小,否則會有new Jedis(新連接)開銷。
連接池的最佳性能是maxTotal = maxIdle(maxTotal設置過大就不能一樣),這樣就避免連接池伸縮帶來的性能干擾。但是如果並發量不大或者maxTotal設置過高,會導致不必要的連接資源浪費。一般推薦maxIdle可以設置為按上面的業務期望QPS計算出來的理論連接數,maxTotal可以再放大一倍。
(3)minIdle
minIdle(最小空閑連接數),與其說是最小空閑連接數,不如說是"至少需要保持的空閑連接數",在使用連接的過程中,如果連接數超過了minIdle,那么繼續建立連接,如果超過了maxIdle,當超過的連接執行完業務后會慢慢被移出連接池釋放掉。
Redis連接池創建的過程:
假如Redis的連接池設置參數:maxTotal=100,maxIdle=50,minIdle=10;
(1)業務服務啟動之后連接池是不會去創建連接的,接着業務系統要去連接池里面拿連接操作Redis,這才會 new Jedis操作,用完之后就會把該連接放入到連接池里面;
(2)當並發量比較大變為 70 的時候,這個時候連接池里面創建了 70個連接;
(3)過來一會,Redis的並發量變得比較小了,就會慢慢的去釋放連接池中多余的 70 - 50 = 20個連接;默認釋放到 maxIdle=50 就可以了;
如果系統啟動完馬上就會有很多的請求過來,那么我們可以給redis連接池做預熱,比如快速的創建一些redis連接,執行簡單命令,類似ping(),快速的將連接池里的空閑連接提升到minIdle的數量。要根據實際系統的QPS和調用redis客戶端的規模整體評估每個節點所使用的連接池大小。
連接池預熱實例代碼:
List<Jedis> minIdleJedisList = new ArrayList<Jedis>(jedisPoolConfig.getMinIdle());
for (int i = 0; i < jedisPoolConfig.getMinIdle(); i++) {
Jedis jedis = null;
try {
jedis = pool.getResource();
minIdleJedisList.add(jedis);
jedis.ping();
}
catch (Exception e) {
logger.error(e.getMessage(), e);
}
finally {
//注意,這里不能馬上close將連接還回連接池,否則最后連接池里只會建立1個連接。。
//jedis.close();
}
}
//統一將預熱的連接還回連接池
for (int i = 0; i < jedisPoolConfig.getMinIdle(); i++) {
Jedis jedis = null;
try {
jedis = minIdleJedisList.get(i);
//將連接歸還回連接池
jedis.close();
}
catch (Exception e) {
logger.error(e.getMessage(), e);
}
finally {
}
}
Redis的過期鍵的三種清除策略
- 被動刪除:當讀/寫一個已經過期的key時,會觸發惰性刪除策略,直接刪除掉這個過期key;
- 主動刪除:由於惰性刪除策略無法保證冷數據被及時刪掉,所以Redis會定期主動淘汰一批已過期的key;
- 當前已經使用內存超過 maxmemory 限定時,會觸發主動清理策略;
Redis一定要根據實際情況設置 maxmemory, 因為若不設置 maxmemory 就會一直使用物理內存,物理內存使用完之后就會去使用磁盤,那么Redis的性能就會急劇的下降。
當REDIS運行在主從模式時,只有主結點才會執行被動和主動這兩種過期刪除策略,然后把刪除操作”del key”同步到從結點。
當前已用內存超過maxmemory限定時,會觸發主動清理策略:
默認策略是volatile-lru,即超過最大內存后,在過期鍵中使用lru算法進行key的剔除,保證不過期數據不被刪除,但是可能會出現OOM問題。
其他策略如下:
- allkeys-lru:根據LRU算法刪除鍵,不管數據有沒有設置超時屬性,直到騰出足夠空間為止。
- allkeys-random:隨機刪除所有鍵,直到騰出足夠空間為止。
- volatile-random: 隨機刪除過期鍵,直到騰出足夠空間為止。
- volatile-ttl:根據鍵值對象的ttl屬性,刪除最近將要過期數據。如果沒有,回退到noeviction策略。
- noeviction:不會剔除任何數據,拒絕所有寫入操作並返回客戶端錯誤信息"(error)OOM command not allowed when used memory",此時Redis只響應讀操作。