緩存由于其高并發和高性能的特性,已經在項目中被廣泛使用。在讀取緩存方面,大家沒啥疑問,都是按照下圖的流程來進行業務操作。
1、首先redis是什么
Redis(Remote Dictionary Server ),是一個高性能的基于Key-Value結構存儲的NoSQL開源數據庫。大部分公司采用Redis來實現分布式緩存,用來提高數據查詢效率。
2、為什么會選Redis
在Web應用發展的初期,系統的訪問和并發并不高,交互也比較少。但隨著業務的擴大,訪問量的提升,使得服務器負載和關系型數據庫出現瓶頸,而導致瓶頸的源頭,主要體現在磁盤IO上。隨著互聯網的進一步發展,對系統性能有了更高的要求,Redis的出現,解決了很多問題。至于我們為什么要選擇Redis,我總結為以下六個原因:
1)、基于內存存儲,可以降低對關系型數據庫的訪問頻次,從而緩解數據庫壓力
2)、數據IO操作能支持更高級別的QPS,官方發布的指標是10W;
3)、提供了比較多的數據存儲結構,比如string、list、hash、set、zset等等。
4)、采用單線程實現IO操作,避免了并發情況下的線程安全問題。
5)、可以支持數據持久化,避免因服務器故障導致數據丟失的問題
6)、Redis還提供了更多高級功能,比如分布式鎖、分布式隊列、排行榜、查找附近的人等功能,為更復雜的需求提供了成熟的解決方案。
3、 應用場景
緩存,作為Key-Value形態的內存數據庫,Redis 最先會被想到的應用場景便是作為數據緩存
分布式鎖,分布式環境下對資源加鎖
分布式共享數據,在多個應用之間共享
排行榜,自帶排序的數據結構(zset)
消息隊列,pub/sub功能也可以用作發布者 / 訂閱者模型的消息
4、redis用作緩存時
4.1、作為緩存使用流程
緩存由于其高并發和高性能的特性,已經在項目中被廣泛使用。在讀取緩存方面,大家沒啥疑問,都是按照下圖的流程來進行業務操作。
4.2、數據性一致性問題
例如我們使用Redis來作為緩存時,讓請求先訪問到Redis,而不是直接訪問數據庫。而在這種業務場景下,可能會出現緩存和數據庫數據不一致性的問題。
在更新的時候,操作緩存和數據庫無疑就是以下四種可能之一:
- 先更新緩存,再更新數據庫
- 先更新數據庫,再更新緩存
- 先刪除緩存,再更新數據庫
- 先更新數據庫,再刪除緩存
4.2.1、先更新緩存,再更新數據庫
如果我成功更新了緩存,但是在執行更新數據庫的那一步,服務器突然宕機了,那么此時,我的緩存中是最新的數據,而數據庫中是舊的數據。
臟數據就因此誕生了,并且如果我緩存的信息(是單獨某張表的),而且這張表也在其他表的關聯查詢中,那么其他表關聯查詢出來的數據也是臟數據,結果就是直接會產生一系列的問題。
4.2.2、先更新數據庫,在更新緩存
只有等到緩存過期之后,才能訪問到正確的信息。那么在緩存沒過期的時間段內,所看到的都是臟數據。
以上兩圖中只要執行第二步時失敗了,就必然會產生臟數據。
4.2.3、先刪除緩存,在更新數據庫
這種方式在沒有高并發的情況下,是可能保持數據一致性的。
如果只有第一步執行成功,而第二步失敗,那么只有緩存中的數據被刪除了,但是數據庫沒有更新,那么在下一次進行查詢的時候,查不到緩存,只能重新查詢數據庫,構建緩存,這樣其實也是相對做到了數據一致性。
但如果是處于讀寫并發的情況下,還是會出現數據不一致的情況:
執行完成后,明顯可以看出,1號用戶所構建的緩存,并不是最新的數據,還是存在問題的
4.2.4、先更新數據庫,在刪除緩存
如果更新數據庫成功了,而刪除緩存失敗了,那么數據庫中就會是新數據,而緩存中是舊數據,數據就出現了不一致情況。
和之前一樣,如果兩段代碼都執行成功,在并發情況下會是什么樣呢?
還是會造成數據的不一致性。
但是此處達成這個數據不一致性的條件明顯會比起其他的方式更為困難 :
- 時刻1:讀請求的時候,緩存正好過期
- 時刻2:讀請求在寫請求更新數據庫之前查詢數據庫,
- 時刻3:寫請求,在更新數據庫之后,要在讀請求成功寫入緩存前,先執行刪除緩存操作。
這通常是很難做到的,因為在真正的并發開發中,更新數據庫是需要加鎖的,不然沒一點安全性~
一定程度上來講,這種方式還是解決了一定程度上的數據不一致性問題的。
4.3、總結
以上四種方式無論選擇那種方式,如果實在多服務或時并發的情況下,其實都是有可能產生數據不一致性的。
為了解決這個存在的問題有以下方式:
4.3.1、延遲雙刪
先進行緩存清除,再執行update,最后(延遲N秒)再執行緩存清除。進行兩次刪除,且中間需要延遲一段時間
public void write(String key,Object data){
// 延遲雙刪偽代碼
deleteRedisCache(key); // 刪除redis緩存
updateMySQLSql(obj); // 更新mysql
Thread.sleep(100); // 延遲一段時間
deleteRedisCache(key); // 再次刪除該key的緩存
}
- 1.
- 2.
- 3.
- 4.
- 5.
- 6.
- 7.
延遲雙刪的流程圖:
解決這樣的問題,其實最好的方式就是在執行完更新數據庫的操作后,先休眠一會兒,再進行一次緩存的刪除,以確保數據一致性
首先延遲刪除的時間需要大于 1號用戶執行流程的總時間
就是1號用戶從數據庫讀取數據 寫入緩存時間
4.3.2、通過發送MQ,在消費者線程去同步Redis
無論是更新緩存還是刪除緩存,在同時操作緩存和數據庫時,都無法保證兩者都能一次性操作成功,所以我們最好的辦法就是重試,這個重試并不是立即重試,因為緩存和數據庫可能因為網絡或者其它原因停止服務了,立即重試成功率極低,而且重試會占用線程資源,顯然不合理,所以我們需要采用異步重試機制。
異步重試我們可以使用消息隊列來完成,因為消息隊列可以保證消息的可靠性,消息不會丟失,也可以保證正確消費,當且僅當消息消費成功后才會將消息從消息隊列中刪除。
優點1:可以大幅減少接口的延遲返回的問題
優點2:MQ本身有重試機制,無需人工去寫重試代碼
優點3:解耦,把查詢Mysql和同步Redis完全分離,互不干擾
4.3.3、Canal 訂閱日志實現
當我們業務修改數據時,我們只需要更新數據庫,無需修改緩存,那什么時候修改緩存呢?
以mysql為例,在數據庫一條記錄發生變更時就會生成一條binlog日志,我們可以訂閱這種消息,拿到具體的數據,然后根據日志消息更新緩存,訂閱日志目前比較流行的就是阿里開源的canal,那么我們的架構就變為如下形式。
訂閱數據庫變更日志,當數據庫發生變更時,我們可以拿到具體操作的數據,然后再去根據具體的數據,去刪除對應的緩存。
當然Canal 也是要配合消息隊列一起來使用的,因為其Canal本身是沒有數據處理能力的。
這個方式算的上徹底解耦了,應用程序代碼無需再管消息隊列方面發送失敗問題,全交由 Canal來發送。