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 "資料不存在"; } }
布隆過濾器儲存空間大小計算: https://hur.st/bloomfilter/?n=1000000&p=0.03&m=&k=
布隆過濾器原理分析
完成上述實驗過程後,很多同學會產生疑問,
- 老師,如果我的資料量有上千萬,那不會很佔記憶體啊?
- 老師,布隆過濾器的實現原理是什麼呀?
什麼是布隆過濾器
布隆過濾器是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"));
}
關注[跟著Mic學架構]公眾號,獲取更多精品原創