Redis使用過程中要注意的事項
Redis使用起來很簡單,但是在實際應用過程中,一定會碰到一些比較麻煩的問題,常見的問題有
- redis和數據庫數據的一致性
- 緩存雪崩
- 緩存穿透
- 熱點數據發現
下面逐一來分析這些問題的原理及解決方案。
數據一致性
針對讀多寫少的高并發場景,我們可以使用緩存來提升查詢速度。當我們使用Redis作為緩存的時候,一般流程如圖3-4所示。
- 如果數據在Redis存在,應用就可以直接從Redis拿到數據,不用訪問數據庫。
- 如果Redis里面沒有,先到數據庫查詢,然后寫入到Redis,再返回給應用。
因為這些數據是很少修改的,所以在絕大部分的情況下可以命中緩存。但是,一旦被緩存的數據發生變化的時候,我們既要操作數據庫的數據,也要操作Redis的數據,所以問題來了。現在我們有兩種選擇:
先操作Redis的數據再操作數據庫的數據
先操作數據庫的數據再操作Redis的數據
到底選哪一種?
首先需要明確的是,不管選擇哪一種方案, 我們肯定是希望兩個操作要么都成功,要么都一個都不成功。不然就會發生Redis跟數據庫的數據不一致的問題。但是,Redis的數據和數據庫的數據是不可能通過事務達到統一的,我們只能根據相應的場景和所需要付出的代價來采取一些措施降低數據不一致的問題出現的概率,在數據一致性和性能之間取得一個權衡。
對于數據庫的實時性一致性要求不是特別高的場合,比如T+1的報表,可以采用定時任務查詢數據庫數據同步到Redis的方案。由于我們是以數據庫的數據為準的,所以給緩存設置一個過期時間,是保證最終一致性的解決方案。
Redis:刪除還是更新?
這里我們先要補充一點,當存儲的數據發生變化,Redis的數據也要更新的時候,我們有兩種方案,一種就是直接更新,調用set;還有一種是直接刪除緩存,讓應用在下次查詢的時候重新寫入。
這兩種方案怎么選擇呢?這里我們主要考慮更新緩存的代價。
更新緩存之前,判斷是不是要經過其他表的查詢、接口調用、計算才能得到最新的數據,而不是直接從數據庫拿到的值,如果是的話,建議直接刪除緩存,這種方案更加簡單,一般情況下也推薦刪除緩存方案。
這一點明確之后,現在我們就剩一個問題:
到底是先更新數據庫,再刪除緩存
還是先刪除緩存,再更新數據庫
先更新數據庫,再刪除緩存
正常情況:更新數據庫,成功。刪除緩存,成功。
異常情況:
1、更新數據庫失敗,程序捕獲異常,不會走到下一步,所以數據不會出現不一致。
2、更新數據庫成功,刪除緩存失敗。數據庫是新數據,緩存是舊數據,發生了不一致的情況。
這種問題怎么解決呢?我們可以提供一個重試的機制。
比如:如果刪除緩存失敗,我們捕獲這個異常,把需要刪除的key發送到消息隊列。然后自己創建一個消費者消費,嘗試再次刪除這個key,如圖3-5所示。
另外一種方案,異步更新緩存:
因為更新數據庫時會往binlog寫入日志,所以我們可以通過一個服務來監聽binlog的變化(比如阿里的canal),然后在客戶端完成刪除key的操作。如果刪除失敗的話,再發送到消息隊列。
總之,對于后刪除緩存失敗的情況,我們的做法是不斷地重試刪除,直到成功。無論是重試還是異步刪除,都是最終一致性的思想,如圖3-6所示。
基于數據庫增量日志解析,提供增量數據訂閱&消費,目前主要支持了mysql。
先刪除緩存,再更新數據庫
正常情況:刪除緩存,成功。更新數據庫,成功。
異常情況:
刪除緩存,程序捕獲異常,不會走到下一步,所以數據不會出現不一致。
刪除緩存成功,更新數據庫失敗。 因為以數據庫的數據為準,所以不存在數據不一致的情況。
看起來好像沒問題,但是如果有程序并發操作的情況下:
線程A需要更新數據,首先刪除了Redis緩存
線程B查詢數據,發現緩存不存在,到數據庫查詢舊值,寫入Redis,返回
線程A更新了數據庫
這個時候,Redis是舊的值,數據庫是新的值,發生了數據不一致的情況,如圖3-7所示,這種情況就比較難處理了,只有針對同一條數據進行串行化訪問,才能解決這個問題,但是這種實現起來對性能影響較大,因此一般情況下不會采用這種做法。
緩存雪崩
緩存雪崩就是Redis的大量熱點數據同時過期(失效),因為設置了相同的過期時間,剛好這個時候Redis請求的并發量又很大,就會導致所有的請求落到數據庫。
關于緩存過期
在實際開發中,我們經常會,比如限時優惠、緩存、驗證碼有效期等。一旦過了指定的有效時間就需要自動刪除這些數據,否則這些無效數據會一直占用內存但是缺沒有任何價值,因此在Redis中提供了Expire命令設置一個鍵的過期時間,到期以后Redis會自動刪除它。這個在我們實際使用過程中用得非常多。
expire key seconds # 設置鍵在給定秒后過期
pexpire key milliseconds # 設置鍵在給定毫秒后過期
expireat key timestamp # 到達指定秒數時間戳之后鍵過期
pexpireat key timestamp # 到達指定毫秒數時間戳之后鍵過期
EXPIRE 返回值為1表示設置成功,0表示設置失敗或者鍵不存在,如果向知道一個鍵還有多久時間被刪除,可以使用TTL命令
ttl key # 返回鍵多少秒后過期
pttl key # 返回鍵多少毫秒后過期
當鍵不存在時,TTL命令會返回-2,而對于沒有給指定鍵設置過期時間的,通過TTL命令會返回-1。
除此之外,針對String類型的key的過期時間,我們還可以通過下面這個方法來設置,其中可選參數ex
表示設置過期時間。
set key value [ex seconds]
如果向取消鍵的過期時間設置(使該鍵恢復成為永久的),可以使用PERSIST命令,如果該命令執行成功或者成功清除了過期時間,則返回1 。 否則返回0(鍵不存在或者本身就是永久的)
SET expire.demo 1 ex 20
TTL expire.demo
PERSIST expire.demo
TTL expire
除了PERSIST命令,使用set命令為鍵賦值的操作也會導致過期時間失效。
關于key過期的實現原理
Redis使用一個過期字典(Redis字典使用哈希表實現,可以將字典看作哈希表)存儲鍵的過期時間,字典的鍵是指向數據庫鍵的指針(使用指針可以避免浪費內存空間),字典的值是一個毫秒時間戳,所以在當前時間戳大于字典值的時候這個鍵就過期了,就可以對這個鍵進行刪除(刪除一個鍵不僅要刪除數據庫中的鍵,也要刪除過期字典中的鍵)。
設置過期時間的命令都是使用pexpireat
命令實現的,其他命令也會轉換成pexpireat
。給一個鍵設置過期時間,就是將這個鍵的指針以及給定的到期時間戳添加到過期字典中。比如,執行命令pexpireat key 1608290696843
,那么過期字典結構將如圖3-8所示。
過期鍵的刪除
過期鍵的刪除有兩種方法。
-
被動方式刪除
被動方式的核心原理是,當客戶端嘗試訪問某個key時,發現當前key已經過期了,就直接刪除這個key。
當然,有可能會存在一些key,一直沒有客戶端訪問,就會導致這部分key一直占用內存,因此加了一個主動刪除方式。
-
主動方式刪除
主動刪除就是Redis定期掃描國期間中的key進行刪除,它的刪除策略是:
- 從過期鍵中隨機獲取20個key,刪除這20個key中已經過期的key。
- 如果在這20個key中有超過25%的key過期,則重新執行當前步驟。實際上這是利用了一種概率算法。
Redis結合這兩種設計很好的解決了過期key的處理問題。
如何解決緩存雪崩
了解了過期key的刪除后,再來分析緩存雪崩問題。緩存雪崩有幾個方面的原因導致。
- Redis的大量熱點數據同時過期(失效)
- Redis服務器出現故障, 這種情況,我們需要考慮到redis的高可用集群,這塊后面再說。
我們來分析第一種情況,這種情況無非就是程序再去查一次數據庫,再把數據庫中的數據保存到緩存中就行,問題也不大。可是一旦涉及大數據量的需求,比如一些商品搶購的情景,或者是主頁訪問量瞬間較大的時候,單一使用數據庫來保存數據的系統會因為面向磁盤,磁盤讀/寫速度比較慢的問題而存在嚴重的性能弊端,一瞬間成千上萬的請求到來,需要系統在極短的時間內完成成千上萬次的讀/寫操作,這個時候往往不是數據庫能夠承受的,極其容易造成數據庫系統癱瘓,最終導致服務宕機的嚴重生產問題。
解決這類問題的方法有幾個。
- 對過期時間增加一個隨機值,避免同一時刻大量key失效。
- 對于熱點數據,不設置過期時間。
- 當從redis中獲取數據為空時,去數據庫查詢數據的地方互斥鎖,這種方式會造成性能下降。
- 增加二級緩存,以及緩存和二級緩存的過期時間不同,當一級緩存失效后,可以再通過二級緩存獲取。
緩存穿透
緩存穿透,一般是指當前訪問的數據在redis和mysql中都不存在的情況,有可能是一次錯誤的查詢,也可能是惡意攻擊。
在這種情況下,因為數據庫值不存在,所以肯定不會寫入Redis,那么下一次查詢相同的key的時候,肯定還是會再到數據庫查一次。試想一下,如果有人惡意設置大量請求去訪問一些不存在的key,這些請求同樣最終會訪問到數據庫中,有可能導致數據庫的壓力過大而宕機。
這種情況一般有兩種處理方法。
緩存空值
我們可以在數據庫緩存一個空字符串,或者緩存一個特殊的字符串,那么在應用里面拿到這個特殊字符串的時候,就知道數據庫沒有值了,也沒有必要再到數據庫查詢了。
但是這里需要設置一個過期時間,不然的會數據庫已經新增了這一條記錄,應用也還是拿不到值。
這個是應用重復查詢同一個不存在的值的情況,如果應用每一次查詢的不存在的值是不一樣的呢?即使你每次都緩存特殊字符串也沒用,因為它的值不一樣,比如我們的用戶系統登錄的場景,如果是惡意的請求,它每次都生成了一個符合ID規則的賬號,但是這個賬號在我們的數據庫是不存在的,那Redis就完全失去了作用,因此我們有另外一種方法,布隆過濾器。
布隆過濾器解決緩存穿透
先來了解一下布隆過濾器的原理,
- 首先,項目在啟動的時候,把所有的數據加載到布隆過濾器中。
- 然后,當客戶端有請求過來時,先到布隆過濾器中查詢一下當前訪問的key是否存在,如果布隆過濾器中沒有該key,則不需要去數據庫查詢直接反饋即可
下面我們通過一個案例來演示一下布隆過濾器的工作機制。
注意,該案例是在[springboot-redis-example]這個工程中進行演示。
-
添加guava依賴,guava中提供了布隆過濾器的api
<dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>21.0</version> </dependency>
-
增加一個ApplicationRunner實現,當spring boot啟動完成后執行初始化
@Slf4j @Component public class BloomFilterDataLoadApplicationRunner implements ApplicationRunner { @Autowired ICityService cityService; @Override public void run(ApplicationArguments args) throws Exception { List<City> cityList=cityService.list(); // expectedInsertions: 預計添加的元素個數 // fpp: 誤判率(后續再講) BloomFilter<String> bloomFilter=BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8),10000000,0.03); cityList.parallelStream().forEach(city -> { bloomFilter.put(RedisKeyConstants.CITY_KEY+":"+city.getId()); }); BooleanFilterCache.bloomFilter=bloomFilter; } }
-
添加一個controller用來訪問測試
@RestController public class BloomFilterController { @Autowired RedisTemplate redisTemplate; @GetMapping("/bloom/{id}") public String filter(@PathVariable("id")Integer id){ String key=RedisKeyConstants.CITY_KEY+":"+id; if(BooleanFilterCache.bloomFilter.mightContain(key)){ //判斷當前數據在布隆過濾器中是否存在,如果存在則從緩存中加載 return redisTemplate.opsForValue().get(key).toString(); } return "數據不存在"; } }
布隆過濾器存儲空間大小計算:
布隆過濾器原理分析
完成上述實驗過程后,很多同學會產生疑問,
- 老師,如果我的數據量有上千萬,那不會很占內存啊?
- 老師,布隆過濾器的實現原理是什么呀?
什么是布隆過濾器
布隆過濾器是Burton Howard Bloom在1970年提出來的,一種空間效率極高的概率型算法和數據結構,主要用來判斷一個元素是否在集合中存在。因為他是一個概率型的算法,所以會存在一定的誤差,如果傳入一個值去布隆過濾器中檢索,可能會出現檢測存在的結果但是實際上可能是不存在的,但是肯定不會出現實際上不存在然后反饋存在的結果。因此,Bloom Filter不適合那些“零錯誤”的應用場合。而在能容忍低錯誤率的應用場合下,Bloom Filter通過極少的錯誤換取了存儲空間的極大節省
BitMap(位圖)
所謂的Bit-map就是用一個bit位來標記某個元素對應的Value,通過Bit為單位來存儲數據,可以大大節省存儲空間.
ps:比特是一個二進制數的最小單元,就像我們現在金額的最小單位是分。只不過比特是二進制數而已,一個比特只能擁有一個值,不是0就是1,所以如果我給你一個值0,你可以說它就是一個比特,如果我給你兩個(00),你就可以說它們是兩個比特了。如果你將八個0或者1組合在一起,我們可以說說是8比特或者1個字節。在32位的機器上,一個int類型的數據會占用4個字節,也就是32個比特位。
在java中,一個int類型占32個比特,我們用一個int數組來表示時未new int[32],總計占用內存32*32bit,現假如我們用int字節碼的每一位表示一個數字的話,那么32個數字只需要一個int類型所占內存空間大小就夠了,這樣在大數據量的情況下會節省很多內存。
如果要存儲n個數字,那么具體思路如下。
-
1個int占4字節即4*8=32位,那么我們只需要申請一個int數組長度為 int tmp[1+N/32]即可存儲完這些數據,其中N代表要進行查找的總數,tmp中的每個元素在內存在占32位可以對應表示十進制數0~31,所以可得到BitMap表:
- tmp[0]:可表示0~31
- tmp[1]:可表示32~63
- tmp[2]可表示64~95
- .......
接著,我們只需要把對應的數字存儲到指定數組元素的bit中即可,如何判斷int數字在tmp數組的哪個下標,這個其實可以通過直接除以32取整數部分,例如:整數8除以32取整等于0,那么8就在tmp[0]上。另外,我們如何知道了8在tmp[0]中的32個位中的哪個位,這種情況直接mod上32就ok,又如整數8,在tmp[0]中的
8 mod 32
等于8,那么整數8就在tmp[0]中的第八個bit位(從右邊數起)
比如我們要存儲5(101)、9(1001)、3(11)、1(1)四個數字,那么我們申請int型的內存空間,會有32個比特位。這四個數字的二進制分別對應如下。
從右往左開始數,比如第一個數字是5,對應的二進制數據是101, 那么從有往左數到第5位,把對應的二進制數據存儲到32個比特位上。
第一個5就是 00000000000000000000000000101000
而輸入9的時候 00000000000000000000001001000000
輸入3時候 00000000000000000000000000001100
輸入1的時候 00000000000000000000000000000010
思想比較簡單,關鍵是十進制和二進制bit位需要一個map映射表,把10進制映射到bit位上,這樣的好處是內存占用少、效率很高(不需要比較和位移)。
布隆過濾器原理
有了對位圖的理解以后,我們對布隆過濾器的原理理解就會更容易了,基于前面的例子,我們把數據庫中的一張表的數據全部先保存到布隆過濾器中,用來判斷當前訪問的key是否存在于數據庫。
假設我們需要把id=1這個key保存到布隆過濾器中,并且該布隆過濾器中的hash函數個數為3{x、y、z},它的具體實現原理如下:
- 首先將位數組進行初始化,將里面每個位都設置位0。
- 對于集合里面的每一個元素,將元素依次通過3個哈希函數{x、y、z}進行映射,每次映射都會產生一個哈希值,這個值對應位數組上面的一個點,然后將位數組對應的位置標記為1。
- 查詢
id=1
元素是否存在集合中的時候,同樣的方法將W通過哈希映射到位數組上的3個點。- 如果3個點的其中有一個點不為1,則可以判斷該元素一定不存在集合中。
- 反之,如果3個點都為1,則該元素可能存在集合中。
接下來按照該方法處理所有的輸入對象,每個對象都可能把bitMap中一些白位置涂黑,也可能會遇到已經涂黑的位置,遇到已經為黑的讓他繼續為黑即可。處理完所有的輸入對象之后,在bitMap中可能已經有相當多的位置已經被涂黑。至此,一個布隆過濾器生成完成,這個布隆過濾器代表之前所有輸入對象組成的集合。
如何去判斷一個元素是否存在bit array中呢? 原理是一樣,根據k個哈希函數去得到的結果,如果所有的結果都是1,表示這個元素可能(假設某個元素通過映射對應下標為4,5,6這3個點。雖然這3個點都為1,但是很明顯這3個點是不同元素經過哈希得到的位置,因此這種情況說明元素雖然不在集合中,也可能對應的都是1)存在。 如果一旦發現其中一個比特位的元素是0,表示這個元素一定不存在
至于k個哈希函數的取值為多少,能夠最大化的降低錯誤率(因為哈希函數越多,映射沖突會越少),這個地方就會涉及到最優的哈希函數個數的一個算法邏輯。
fpp表示允許的錯誤概率
expectedInsertions: 預期插入的數量
public static void main(String[] args) {
BloomFilter<String> bloomFilter=BloomFilter.create(Funnels.stringFunnel(Charsets.UTF_8),10000000,0.03);
bloomFilter.put("Mic");
System.out.println(bloomFilter.mightContain("Mic"));
}