緩存操作
讀緩存
讀緩存可以分為兩種情況命中(cache hit)和未命中(cache miss):
緩存命中
- 首先從緩存中獲取數據
- 將緩存中的數據返回
緩存未命中
- 首先從緩存中獲取數據
- 此時緩存未命中,從數據庫獲取數據
- 將數據寫入緩存
- 返回數據
讀緩存的的處理由緩存中有沒有數據? 決定,如果緩存中有數據那就是緩存命中,如果沒有那就是緩存未命中:
寫緩存
寫緩存可以分為更新緩存
和刪除緩存
。
更新緩存
更新緩存時需要分兩種情況:
- 更新簡單數據類型(如string)
- 更新復雜數據類型 (如hash)
對於簡單數據類型
可以直接更新緩存,如果是復雜數據類型會增加額外的更新開銷:
- 從緩存中獲取數據
- 將數據序反列化成對象
- 更新對象數據
- 將更新后的數據序列化存入緩存
對復雜數據緩存的更新最少需要4步,而且每次寫數據時都需要更新緩存,這樣對讀緩存較少的場景,可能更新數據7-8次讀緩存才發生一次想想就划不來,另外每次更新緩存時都要對緩存數據進行計算,很明顯寫數據時計算緩存數據然后再更新緩存是沒必要的,可以將緩存的更新,推遲到讀緩存(緩存未命中)時。
刪除緩存
刪除緩存也稱為淘汰緩存,刪除緩存的操作非常簡單的,直接將緩存從緩存庫中的刪除就可以了。
緩存操作順序
緩存一般都是配合數據庫一起使用,從數據庫中獲取數據然后再更新緩存。為什么要討論緩存操作的順序呢?因為在有些情況下不同的操作順序會產生不一樣的結果,常見的操作順序可以分為:
- 先數據庫,再緩存
- 先緩存,再數據庫
不管是哪種順序都要經過數據庫、緩存兩步操作,這兩操作不是一個原子性的操作在一些情況會出現數據不一致問題。下面來分別說明不同的順序所帶的數據不一致、並發等問題。
先數據庫后緩存
如上圖先將數據寫入數據庫,然后再去更新或刪除緩存。兩個步驟1、2都可能失敗,如果是第一步失敗可以通過拋出業務異常,業務調用方捕獲異常信息進行處理,因為這個時候並沒有操作緩存可以理解為寫數據庫失敗了。
如果是第一步成功(寫數據庫成功),然后再操作緩存的時候失敗,這里有兩種情況:
- 數據庫回滾:如果是業務需要保證緩存與數據庫強一致性時,可以拋出業務異常給調用方。
- 不作處理:與
數據庫回滾
相反,業務可以接受在緩存過期時間達到之前,緩存與數據庫允許數據不一致。
舉個例子,假設有一個字符串數據類型的緩存數據,它的key為name
並且現在數據庫和緩存中的值都是arch-digest
。
String name = "arch-digest";
現在要將name
的值更新成juejin
,按照先數據庫后緩存的順序:
//將name的值更新為juejin
public void update(String name){
db.insert(...); //更新數據庫
cache.delete(name); //更新緩存
}
正常情況下db.insert(...)
和cache.delete(name)
都執行成功沒有異議。如果是一些其他原因cache.delete(name)
執行失敗,那數據庫中的值是更新后的值juejin
,而緩存中的數據還是arch-digest
這樣在下次讀取緩存的時候拿到的值就是arch-digest
。
public String getNameFromCache(String name){
String value = cache.get(name); //從緩存中獲取數據
...
return value;
}
讀取緩存的時候在getNameFromCache
方法中,如果name
緩存沒有過期那會一直拿到arch-digest
,這樣情況就會導致用戶看到的數據不一致。
先緩存后數據庫
先緩存后數據庫和之前說到的先數據庫后緩存差不多除了會可能導致數據不一致外,還會有並發問題。
如上面現在是更新數據,如果是在更新數據庫
的時候失敗會發生什么呢?這里要根據緩存的操作分兩種情況:
- 更新緩存:更新緩存數據,緩存中為最新數據,數據庫中是老數據,下次讀取時會拿到緩存中的新數據(數據不一致)。
- 刪除緩存:刪除緩存中的數據,下次讀取時從數據庫中獲取(數據一致)。
更新緩存
和刪除緩存
操作上面已經介紹過了,不多做解釋了。很明顯關於更新緩存
和刪除緩存
在這種情況先刪除緩存
更合適,沒有數據不一致的問題,但是在使用刪除緩存
時也要注意會引發並發問題:
- 線程A刪除緩存成功
- 線程B讀取緩存未命中
- 線程B從數據庫中獲取數據
- 線程B將數據庫中的數據寫入緩存
- 線程A寫入數據庫成功
在高並發場景下,緩存和數據庫數據不一致的情況還是會出現。那要解決數據庫和緩存的數據一致性有哪些解決方案呢?
數據一致性優化方案
這里說的是優化方案
不是解決方案哦,因為在分布式環境下事務是個難題,現在也沒有好的解決方案。只能找到最適合業務的優化方案,使數據不一致的可能性或延遲降到一個業務可接受的范圍內。
常見的幾種優化方案可以包括:
- 不處理
- 延時雙刪
- 訂閱Binglog
3 種方案從簡單到復雜,可以根據業務需要選擇最合適的優化方案。
不處理
不處理是最簡單的方式了,即數據庫與緩存中的數據不一致時在業務允許的情況下不做處理。雖然有點不合適,但是很香!
延時雙刪
延時雙刪可以用來優化在先緩存后數據庫中的並發問題:
- 線程A刪除緩存成功
- 線程B讀取緩存未命中
- 線程B從數據庫中獲取數據
- 線程B將數據庫中的數據寫入緩存
- 線程A寫入數據庫成功
- 線程A休眠1秒然后刪除緩存
這種方案增加第6步,寫入數據庫完成后使寫入線程休眠1秒,然后再將緩存數據刪除掉,使其他線程再次讀取數據時導致緩存未命中從數據庫獲取數據並更新緩存。
這個1秒怎么確定的,具體該休眠多久呢?
針對上面的情形,應該自行評估自己的項目的讀數據業務邏輯的耗時。然后寫數據的休眠時間則在讀數據業務邏輯的耗時基礎上,加幾百ms即可。這么做的目的,就是確保讀請求結束,寫請求可以刪除讀請求造成的緩存臟數據。
采用這種同步淘汰策略,吞吐量降低怎么辦?
第二次刪除作為異步的。自己起一個線程,異步刪除。這樣,寫的請求就不用沉睡一段時間后了,再返回。這么做,加大吞吐量。
binlog訂閱
使用binlog訂閱,這樣一旦MySQL中產生了新的寫入、更新、刪除等操作,就可以把binlog相關的消息推送至Redis,Redis再根據binlog中的記錄,對Redis進行更新。
其實這種機制,很類似MySQL的主從備份機制,因為MySQL的主備也是通過binlog來實現的數據一致性。
這里可以結合使用canal(阿里的一款開源框架),通過該框架可以對MySQL的binlog進行訂閱,而canal正是模仿了mysql的slave數據庫的備份請求,使得Redis的數據更新達到了相同的效果。
當然,這里的消息推送工具你也可以采用別的第三方:kafka、rabbitMQ等來實現推送更新緩存。
每天一篇架構領域重磅好文,涉及一線互聯網公司應用架構(高可用、高性能、高穩定)、大數據、機器學習、Java架構等各個熱門領域。