前言
在日常開發中,緩存是提升系統瓶頸的最簡單方法之一,如果緩存使用得當,緩存可以增加系統吞吐量,減少響應時間、減少數據庫負載等。
但在不同的場景下,所適用緩存讀寫策略是不盡相同的,這篇文章將介紹不同緩存讀寫策略在不同場合下的使用與存在問題的分析,并給出解決方案~
Cache Aside PatternCache Aside Pattern 意為旁路緩存模式,是我們平時最常用的一個緩存讀寫模式,應用程序需要一起操作 DB 和 Cache,并且以 DB 的數據為準。 下面我們來看一下這個模式下的緩存讀寫步驟。
方案寫更新 DB刪除 Cache讀從 Cache 中讀取數據,有數據直接返回Cache中沒數據的話,從 DB 中讀取數據,數據更新到 Cache 中后返回。代碼實施public class DataCacheManager { public Data query(Long id) { String key = "keyPrefix:" + id; //查詢緩存 Data data = cacheService.get(key); if(data == null) { //查詢DB data = dataDao.get(id); //更新緩存 cacheService.t(key, data); } return data; } public Data update(Data data) { //更新DB data = dataDao.update(data); //更新緩存 String key = "keyPrefix:" + data.getId(); cacheService.del(key); }}復制代碼
在工作中的時候,我的應用分層是按照《阿里巴巴Java開發手冊》進行的,如下圖所示。所以我一般會將緩存的這塊邏輯單獨抽離,介于Dao層和Service層之間的Manager中實現,這樣多個Service通常可以復用這樣的緩存代碼。
存在的問題看完上面的方案,可能你心里會有一些疑惑,為什么是刪除緩存,而不是更新緩存?為什么是先更新DB,再刪除Cache,可以交換下順序嗎?按照Cache Aside Pattern的實現,先更新DB,后刪除Cache就一定沒有問題了嗎?我們一個個來分析。
1.為什么是刪除緩存,而不是更新緩存?從直觀的角度上來看,更新操作,直接把緩存一起更新了應該是個更容易理解的方案。但從性能和安全的角度上來看,直接更新緩存就不一定是合理的了。
性能對于一些比較大的key,更新Cache比刪除Cache更耗費性能。甚至當寫操作比較多時,可能會存在剛更新的緩存還沒有被讀過,又再次被更新的情況(這常被稱為緩存擾動),導致緩存利用率不高。所以,基于懶加載的思想,不用就沒必要存在,所以Cache Aside更支持直接del。
實際上,一般key也不會太大,而且我在生產中使用緩存的場景讀請求流量也不會低,所以對于性能的影響個人感覺還好。
安全在并發場景下,多個寫請求同時更新緩存可能會造成數據不一致的問題,看下面這個過程:
寫請求1
寫請求2
讀請求3
更新數據A到DB
更新數據B到DB
更新數據B到Cache
更新數據A到Cache
查詢Cache數據,讀取到A
由于線程調度等原因,寫請求 1 的更新Cache操作晚于寫請求 2 中的更新Cache操作,這樣會導致最終寫入緩存中的是來自寫請求 1 的數據A,從而使得后面的讀操作讀取到的都是舊值。
2.為什么是先更新DB,再刪除Cache,可以交換下順序嗎?同樣地,先刪除Cache,再更新DB,也可能會造成DB數據和Cache數據的不一致。為什么呢?看下面這個過程:
寫請求1
讀請求2
讀請求3
刪除Cache數據A
查詢Cache數據A不存在
從DB讀取數據A
更新數據A到Cache
更新數據B到DB
查詢Cache數據,讀取到A
當多個請求并發時,寫請求1的兩個操作之間穿插了請求2的所有操作(主要是寫DB比較慢,需要分配更多的時間片才能執行完成),導致Cache的數據沒有正確更新。只有等下次更新或者緩存自動過期后才會把最新的數據B存入緩存,如果是更新則有可能再次發生這樣的問題,導致一直不一致...
3.按照Cache Aside Pattern的實現,先更新DB,后刪除Cache就一定沒有問題了嗎?理論上來說還是可能會出現數據不一致性的問題,看下面這個過程:
請求1
請求2
請求3
查詢Cache,不存在
從DB讀取數據A
更新數據B到DB
刪除Cache數據A
更新數據A到Cache
查詢Cache,讀取到數據A
同樣可能由于線程調度等原因,讀請求 1 的更新Cache操作晚于寫請求 2 中的刪除Cache操作,這樣會導致最終寫入緩存中的是來自請求 1 的舊值A,而寫入數據庫中的是來自請求 2 的新值B,即緩存數據落后于數據庫,此時再有讀請求 3 命中緩存,讀取到的便是舊值A。
但與之前不同的是,這種場景出現的概率要小許多,因為更新DB所需的線程調度時間要遠大于更新Cache,所以一般情況下都是Cache先執行完成。
4.Cache Aside Pattern如何完全杜絕數據不一致問題?兩個字,加鎖,單機加JVM鎖,集群加分布式鎖。
對于寫操作,需要將更新DB和刪除Cache鎖住,
對于讀操作,需要將查詢Cache不存在之后的操作鎖住。
并且需要注意讀操作和寫操作的鎖需要使用一把!!!即讀操作沒有命中緩存的時候不能進行寫操作,反之同理。
來優化下之前的代碼,假設是集群環境,使用分布式鎖。
public class DataCacheManager { public Data query(Long id) { String key = "keyPrefix:" + id; //查詢緩存 Data data = cacheService.get(key); if(data == null) { try { cacheService.lock(key, 2); //查詢DB data = dataDao.get(id); //更新緩存 cacheService.t(key, data); } finally { cacheService.unLock(key); } } return data; } @RedisLock(keySuffix = "#data.id", keyPrefix = "keyPrefix:") public Data update(Data data) { //更新DB data = dataDao.update(data); //更新緩存 String key = "keyPrefix:" + data.getId(); cacheService.del(key); }}復制代碼
注:對@RedisLock不熟悉的推薦參考下巧用 分布式鎖 - 掘金,能更簡單的使用分布式鎖~
不過,加鎖勢必會影響性能,導致系統吞吐量下降,并發高時可能還會造成堵塞線程過多從而OOM。
5.Cache Aside Pattern如何盡量降低數據不一致的影響?既然加鎖會降低性能,如果能接受短暫時間的數據不一致場景,應該怎么盡量降低其影響呢?
解決辦法就是更新Cache的同時給Cache增加一個比較短的過期時間,這樣可以保證即使數據不一致的話影響也比較小。
但如果在短時間內,有大量緩存同時過期,導致大量的請求直接查詢數據庫,從而對數據庫造成了巨大的壓力,嚴重情況下可能會導致數據庫宕機,這種情況叫做緩存雪崩。緩存雪崩的解決方案稍后再討論,接著往下看~
6.Cache Aside Pattern首次讀請求問題對于第一次讀取的Cache,數據一定不在Cache中,如果服務發布后一下來很多個讀請求,很可能同時繞過if(data == null)判斷條件從而一起請求DB,造成壓力過大。
如何解決這個問題呢?
可以將熱點數據提前加載到Cache中,比如讀取配置獲取熱點數據Key,然后使用@PostConstruct注解在服務啟動之前加載數據。
public class DataCacheManager { @PostConstruct public void init() { //假設配置的熱點id為520,666 List<Long> hotIds = Lists.newArrayList(520L, 666L); for (Long hotId : hotIds) { Data data = dataDao.get(hotId); //更新緩存 cacheService.t(key, data, 60); } } //...}復制代碼適用場景
由上述分析,該方案適合讀多寫少的場景,并且盡量使用在對數據一致性要求沒有那么高的場景,例如商品詳情頁的商品數據緩存,商品描述和價格等信息變化的頻次一般都很低,即使價格有變化,在下單的時候訂單系統會讀取最新的商品價格,確保數據準確。
Read Through PatternRead-Through 意為讀穿透模式,它的流程和 Cache-Aside 讀操作基本類似,不同點在于 Read-Through 中多了一個訪問控制層,應用讀請求只和訪問控制層進行交互,而背后緩存命中與否的邏輯則由訪問控制層與數據源進行交互。
這樣做可以使業務層的實現會更加簡潔,并且對于緩存層及持久化層的交互封裝做得更好,可以更輕松的擴展和遷移。
方案應用程序讀請求訪問控制層訪問控制層從 Cache 中讀取數據,讀取到就直接返回。讀取不到的話,先從 DB 加載,寫入到 Cache 后返回。舉個例子,著名的本地緩存Guava Cache采用的就是該模式。
//初始化LoadingCache<String, Data> loadingCache = CacheBuilder.newBuilder() .maximumSize(1000) .expireAfterAccess(1, TimeUnit.MINUTES) .build( new CacheLoader<String, Data>() { public Data load(String key) { return dataDao.query(key); } } );//讀操作,里面包含了 獲取緩存-如果沒有-則計算"[get-if-abnt-compute]的原子語義loadingCache.get(key);復制代碼
實際上,在Cache Aside Pattern 中的實現上我們通過將緩存的邏輯抽離到Manager層中,一定程度上也算是勉強達到了降低應用層復雜度的效果(主要是將Service層當作了應用層)。
適用場景該方案適合讀請求多的場景,并且對數據一致性要求沒有那么高的場景。另外,該方案也同樣存在首次讀取問題,可以在初始化時模擬外部讀請求使數據能提前加載。
Write Through PatternWrite-Through 意為寫穿透模式,它也增加了訪問控制層來提供更高程度的封裝。不同于 Cache-Aside 的是,Write-Through 直寫模式在寫請求更新Cache之后,更新DB,并且這兩步操作需要控制層保證是一個原子操作。
方案應用程序請求訪問控制層訪問控制層 更新Cache同步更新DB存在的問題1.為什么先更新Cache,再更新DB?這里順序關系不大,不管是先更新Cache還是先更新DB,都可能存在之前Cache Aside問題一中提過的臟數據問題。解決辦法就是問題四的方式,通過加鎖解決并發問題。
2.如何保證兩步操作的原子性?俊峰之前也沒嘗試過這種方案,但可以提出一些自己的想法~
如果更新Cache失敗了,由于是第一步,可以返回異常讓客戶端重試。
如果是更新DB失敗了,需要看怎么設計,如果希望客戶端重試的話,可以把更新Cache回滾。如果不希望客戶端重試,可以把失敗的請求發送到消息隊列中,然后消費該消息補償失敗。
適用場景Write Through通常會和Read Through一同使用,滿足讀寫需求。該方案主要適用于寫請求比較多的場景,并且對數據一致性要求較高的場景,比如銀行系統。
俊峰平時在開發過程很少見到有項目使用Write Through方案,除了必須保證更新Cache和更新DB的原子性會造成一定性能方面的影響外,最主要的是緩存服務的封裝是比較難實現的,或者可以接入一些現成的框架,比如Redis官網推薦的RedisGears。
Write Behind Pattern(異步緩存寫入)Write Behind 又叫 Write Back,意為異步回寫模式。它與Read-Through/Write-Through 一樣,具有類似的訪問控制層提供到應用程序。不同的是,Write behind 在處理寫請求時,只更新Cache后就返回,對于數據庫的更新,則是通過批量異步更新的方式進行的,批量寫入的時間點可以選在數據庫負載較低的時間進行。
方案應用程序請求訪問控制層訪問控制層 更新Cache異步更新DB存在的問題1.異步相比Write Through帶來了哪些好處和問題?在 Write-Behind 模式下,由于不用同步更新DB,寫請求延遲大大降低,并減輕了數據庫的壓力,具有較好的吞吐性。
但數據庫和緩存的一致性較弱,比如當更新的數據還未被寫入數據庫時,直接從數據庫中查詢數據是落后于緩存的。同時,緩存的負載較大,如果緩存宕機會導致數據丟失,所以需要做好緩存的高可用(以后會介紹~)。
適用場景顯然,根據上面的分析,Write behind 模式下非常適合大量寫操作的場景,比如電商秒殺場景中庫存的扣減。
在之前的文章——如何選擇一個合適的庫存扣減方案?中我們提到過Redis配合lua腳本的方案,實際上和Write Behind思想是一致的,都是先更新Cache,后異步更新DB,只是沒有單獨封裝訪問控制層。
總結四種緩存方案各有優缺點,這也印證了那句老話,沒有最完美的方案,只有最適合的方案。
俊峰在實際項目中使用的最多的就是Cache Aside(用于Redis),有時候會結合Read Through(用于本地緩存guava)構成二級緩存,后面會單獨寫一篇文章介紹~
作者:史俊峰在搬磚鏈接:https://juejin.cn/post/7124541481259368462
本文發布于:2023-02-28 20:03:00,感謝您對本站的認可!
本文鏈接:http://m.newhan.cn/zhishi/a/167765191774943.html
版權聲明:本站內容均來自互聯網,僅供演示用,請勿用于商業和其他非法用途。如果侵犯了您的權益請與我們聯系,我們將在24小時內刪除。
本文word下載地址:寫入緩存策略(u盤寫入緩存策略).doc
本文 PDF 下載地址:寫入緩存策略(u盤寫入緩存策略).pdf
| 留言與評論(共有 0 條評論) |