緩存把我坑慘了..

来源:https://www.cnblogs.com/kdaddy/p/18072326
-Advertisement-
Play Games

故事 春天,辦公室外的世界總是讓人神往的,小貓帶著耳機,托著腮幫,望著外面美好的春光神游著... 一聲不和諧的座機電話聲打破這份本該屬於小貓的寧靜,“hi,小貓,線上有個客戶想購買A產品規格的商品,投訴說下單總是失敗,幫忙看一下啥原因。”客服部小姐姐甜美的聲音從電話那頭傳來。“哦哦,好,我看一下,把 ...


故事

春天,辦公室外的世界總是讓人神往的,小貓帶著耳機,托著腮幫,望著外面美好的春光神游著...

一聲不和諧的座機電話聲打破這份本該屬於小貓的寧靜,“hi,小貓,線上有個客戶想購買A產品規格的商品,投訴說下單總是失敗,幫忙看一下啥原因。”客服部小姐姐甜美的聲音從電話那頭傳來。“哦哦,好,我看一下,把商品編號發一下吧......”

由於前一段時間的系統熟悉,小貓對現在的數據表模型已經瞭然於胸,當下就直接定位到了商品規格信息表,發現資料庫中客戶想購買的規格已經被下架了,但是前端的緩存好像並沒有被刷新。

小貓在系統中找到了之前開發人員留的後門介面,直接curl語句重新刷新了一下介面,緩存問題搞定了。

關於商品緩存和資料庫不一致的情況,其實小貓一周會遇到好幾個這樣的客訴,他深受DB以及緩存不一致的苦,於是他下定決心想要從根本上解決問題,而不是curl調用後門介面......

寫在前面

小貓的態度其實還是相當值得肯定的,當他下定決心從根本上排查問題的時候開始,小貓其實就是一名合格而且負責的研發,這也是我們每一位軟體研發人員所需要具備的處理事情的態度。

在軟體系統演進的過程中,只有我們在修複歷史遺留的問題的時候,才是真正意義上地對系統進行了維護,如果我們使用一些極端的手段(例如上述提到的後門介面curl語句)來保持古老而陳腐的代碼繼續工作的時候,這其實是一種苟且。一旦系統有了問題,我們其實就需要及時進行優化修複,否則會形成不好的示範,更多的後來者傾向於類似的方式解決問題,這也是為什麼FixController存在的原因,這其實就是系統腐化的標誌。

言歸正傳,關於緩存和DB不一致相信大家在日常開發的過程中都有遇到過,那麼我們接下來就和大家好好盤一盤,緩存和DB不一致的時候,咱們是如何去解決的。接下來,大家會看到解決方案以及實戰。
緩存概要

常規介面緩存讀取更新

常規緩存讀取

看到上面的圖,我們可以清晰地知道緩存在實際場景中的工作原理。

  1. 發生請求的時候,優先讀取緩存,如果命中緩存則返回結果集。
  2. 如果緩存沒有命中,則回歸資料庫查詢。
  3. 將資料庫查詢得到的結果集再次同步到緩存中,並且返回對應的結果集。

這是大家比較熟悉的緩存使用方式,可以有效減輕資料庫壓力,提升介面訪問性能。但是在這樣的一個架構中,會有一個問題,就是一份數據同時保存在資料庫和緩存中,如果數據發生變化,需要同時更新緩存和資料庫,由於更新是有先後順序的,並且它不像資料庫中多表事務操作滿足ACID特性,所以這樣就會出現數據一致性的問題。

DB和緩存不一致方案與實戰DEMO

關於緩存和DB不一致,其實無非就是以下四種解決方案:

  1. 先更新緩存,再更新資料庫
  2. 先更新資料庫,再更新緩存
  3. 先刪除緩存,後更新資料庫
  4. 先更新資料庫,後刪除緩存

先更新緩存,再更新資料庫(不建議)

更新緩存後更新資料庫

這種方案其實是不提倡的,這種方案存在的問題是緩存更新成功,但是更新資料庫出現異常了。這樣會導致緩存數據與資料庫數據完全不一致,而且很難察覺,因為緩存中的數據一直都存在。

先更新資料庫,再更新緩存

先更新資料庫,再更新緩存,如果緩存更新失敗了,其實也會導致資料庫和緩存中的數據不一致,這樣客戶端請求過來的可能一直就是錯誤的數據。

更新資料庫之後更新緩存

先刪除緩存,後更新資料庫

這種場景在併發量比較小的時候可能問題不大,理想情況是應用訪問緩存的時候,發現緩存中的數據是空的,就會從資料庫中載入並且保存到緩存中,這樣數據是一致的,但是在高併發的極端情況下,由於刪除緩存和更新資料庫非原子行為,所以這期間就會有其他的線程對其訪問。於是,如下圖。

高併發刪除緩存,後更新資料庫

解釋一下上圖,老貓羅列了兩個線程,分別是線程1和線程2。

  1. 線程1會先刪除緩存中的數據,但是尚未去更新資料庫。
  2. 此時線程2看到緩存中的數據是空的,就會去資料庫中查詢該值,並且重新更新到緩存中。
  3. 但是此時線程1並沒有更新成功,或者是事務還未提交(MySQL的事務隔離級別,會導致未提交的事務數據不會被另一個線程看到),由於線程2快於線程1,所以線程2去資料庫查詢得到舊值。
  4. 這種情況下最終發現緩存中還是為舊值,但是資料庫中卻是最新的。

由此可見,這種方案其實也並不是完美的,在高併發的情況下還是會有問題。那麼下麵的這種總歸是完美的了吧,有小伙伴肯定會這麼認為,讓我們一起來分析一下。

先更新資料庫,後刪除緩存

先說結論,其實這種方案也並不是完美的。咱們通過下圖來說一個比較極端的場景。

更新資料庫,後刪除緩存

上圖中,我們執行的時間順序是按照數字由小到大進行。在高併發場景下,我們說一下比較極端的場景。

上面有線程1和線程2兩個線程。其中線程1是讀線程,當然它也會負責將讀取的結果集同步到緩存中,線程2是寫線程,主要負責更新和重新同步緩存。

  1. 由於緩存失效,所以線程1開始直接查詢的就是DB。
  2. 此時寫線程2開始了,由於它的速度較快,所以直接完成了DB的更新和緩存的刪除更新。
  3. 當線程2完成之後,線程1又重新更新了緩存,那此時緩存中被更新之後的當然是舊值了。

如此,咱們又發現了問題,又出現了資料庫和緩存不一致的情況。

那麼顯然上面的這四種方案其實都多多少少會存在問題,那麼究竟如何去保持資料庫和緩存的一致性呢?

保證強一致性

如果有人問,那我們能否保證緩存和DB的強一致性呢?回答當然是肯定的,那就是針對更新資料庫和刷新緩存這兩個動作加上鎖。當DB和緩存數據完成同步之後再去釋放,一旦其中任何一個組件更新失敗,我們直接逆向回滾操作。我們可能還得做快照便於其歷史緩存重寫。那這種設計顯然代價會很大。

其實在很大一部分情況下,要求緩存和DB數據強一致大部分都是偽需求。我們可能只要達到最終儘量保持緩存一致即可。有緩存要求的大部分業務其實也是能接受數據在短期內不一致的情況。所以我們就可以使用下麵的這兩種最終一致性的方案。

錯誤重試達到最終一致

如下示意圖所示:

基於消息隊列

上面的圖中我們看到。當然上述老貓只是畫了更新線程,其實讀取線程也一樣。

  1. 更新線程優先更新數據,然後再去更新緩存。
  2. 此時我們發現緩存更新失敗了,咱們就將其重新放到消息隊列中。
  3. 單獨寫一個消費者接收更新失敗記錄,然後進行重試更新操作。

說到消息隊列重試,還有一種方式是基於非同步任務重試,咱們可以把更新緩存失敗的這個數據保存到資料庫,然後通過另外的一個定時任務進而掃描待執行任務,然後去做相關的緩存更新動作。

當然上面我們提到的這兩種方案,其實比較依賴我們的業務代碼做出相對應的調整。我們當然也可以藉助Canal組件來監控MySQL中的binlog的日誌。通過資料庫的 binlog 來非同步淘汰 key,利用工具(canal)將 binlog日誌採集發送到 MQ 中,然後通過 ACK 機制確認處理刪除緩存。先更新DB,然後再去更新緩存,這種方式,被稱為 Cache Aside Pattern,屬於緩存更新的經典設計模式之一。

基於canal

上述我們總結了緩存使用的一些方案,我們發現其實沒有一種方案是完美的,最完美的方案其實還是得去結合具體的業務場景去使用。方案已經同步了,那麼如何去擼資料庫以及緩存同步的代碼呢?接下來,和大家分享的當然是日常開發中比較好用的SpringCache緩存處理框架了。

SpringCache實戰

SpringCache是一個框架,實現了基於註解緩存功能,只需要簡單地加一個註解,就能實現緩存功能。
SpringCache提高了一層抽象,底層可以切換不同的cache實現,具體就是通過cacheManager介面來統一不同的緩存技術,cacheManager是spring提供的各種緩存技術抽象介面。

目前存在以下幾種:

  • EhCacheCacheManager:將緩存的數據存儲在記憶體中,以提高應用程式的性能。
  • GuavaCaceManager:使用Google的GuavaCache作為緩存技術。
  • RedisCacheManager:使用Redis作為緩存技術。

配置

我們日常開發中用到比較多的其實是redis作為緩存,所以咱們就可以用RedisCacheManager,做一下代碼演示。咱們以springboot項目為例。

老貓這裡拿看一下redisCacheManager來舉例,項目開始的時候我們當忽然要在pom文件依賴的時候就肯定需要redis啟用項。如下:

<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--使用註解完成緩存技術-->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

因為我們在application.yml中就需要配置redis相關的配置項:

spring:
  redis:
    host: localhost
    port: 6379
    database: 0 
    jedis:
      pool:
        max-active: 8 # 最大鏈接數據
        max-wait: 1ms # 連接池最大阻塞等待時間
        max-idle: 4 # 連接線中最大的空閑鏈接
        min-idle: 0 # 連接池中最小空閑鏈接
   cache:
    redis:
      time-to-live: 1800000 

常用註解

關於SpringCache常用的註解,整理如下:

SpringCache常用註解

針對上述的註解,咱們做一下demo用法,如下:

用法簡單盤點

@Slf4j
@SpringBootApplication
@ServletComponentScan
@EnableCaching
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(ReggieApplication.class);
    }
}

在service層我們註入所需要用到的cacheManager:

@Autowired
private CacheManager cacheManager;

/**
 * 公眾號:程式員老貓
 * 我們可以通過代碼的方式主動清除緩存,例如
 **/
public void clearCache(String productCode) {
  try {
      RedisCacheManager redisCacheManager = (RedisCacheManager) cacheManager;

      Cache backProductCache = redisCacheManager.getCache("backProduct");
      if(backProductCache != null) {
          backProductCache.evict(productCode);
      }
  } catch (Exception e) {
      logger.error("redis 緩存清除失敗", e);
  }
}

接下來我們看一下每一個註解的用法,以下關於緩存用法的註解,我們都可以將其加到dao層:

第一種@Cacheable

在方法執行前spring先查看緩存中是否有數據,如果有數據,則直接返回緩存數據;若沒有數據,調用方法並將方法返回值放到緩存中。

@Cacheable 註解中的核心參數有以下幾個:

  • value:緩存的名稱,可以是一個字元串數組,表示該方法的結果可以被緩存到哪些緩存中。預設值為一個空數組,表示緩存到預設的緩存中。
  • key:緩存的 key,可以是一個 SpEL 表達式,表示緩存的 key 可以根據方法參數動態生成。預設值為一個空字元串,表示使用預設的 key 生成策略。
  • condition:緩存的條件,可以是一個 SpEL 表達式,表示緩存的結果是否應該被緩存。預設值為一個空字元串,表示不考慮任何條件,緩存所有結果。
  • unless:緩存的排除條件,可以是一個 SpEL 表達式,表示緩存的結果是否應該被排除在緩存之外。預設值為一個空字元串,表示不排除任何結果。

上述提及的SpEL是是Spring Framework中的一種表達式語言,此處不展開,不瞭解的小伙伴可以自己去查閱一下相關資料。

代碼使用案例:

@Cacheable(value="picUrlPrefixDO",key="#id")
public PicUrlPrefixDO selectById(Long id) {
    PicUrlPrefixDO picUrlPrefixDO = writeSqlSessionTemplate.selectOne("PicUrlPrefixDao.selectById", id);
    return picUrlPrefixDO;
}

第二種@CachePut

表示將方法返回的值放入緩存中。
註解的參數列表和@Cacheable的參數列表一致,代表的意思也一樣。
代碼使用案例:

@CachePut(value = "userCache",key = "#users.id")
@GetMapping()
public User get(User user){
   User users= dishService.getById(user);
   return users;
}

第三種@CacheEvict

表示從緩存中刪除數據。使用案例如下:

@CacheEvict(value="picUrlPrefixDO",key="#urfPrefix")
public Integer deleteByUrlPrefix(String urfPrefix) {
  return writeSqlSessionTemplate.delete("PicUrlPrefixDao.deleteByUrlPrefix", urfPrefix);
}

上述和大家分享了一下SpringCache的用法,對於上述提及的三個緩存註解中,老貓在日常開發過程中用的比較多的是@CacheEvict以及@Cacheable,如果對SpringCache實現原理感興趣的小伙伴可以查閱一下相關的源碼。

使用緩存的其他註意點

當我們使用緩存的時候,除了會遇到資料庫和緩存不一致的情況之外,其實還有其他問題。嚴重的情況下可能還會出現緩存雪崩。關於緩存失效造成雪崩,大家可以看一下這裡【糟糕!緩存擊穿,商詳頁進不去了】。

另外如果加了緩存之後,應用程式啟動或服務高峰期之前,大家一定要做好緩存預熱從而避免上線後瞬時大流量造成系統不可用。關於緩存預熱的解決方案,由於篇幅過長老貓在此不展開了。不過方案概要可以提供,具體如下:

  • 定時預熱。採用定時任務將需要使用的數據預熱到緩存中,以保證數據的熱度。
  • 啟動時載入預熱。在應用程式啟動時,將常用的數據提前載入到緩存中,例如實現InitializingBean 介面,併在 afterPropertiesSet 方法中執行緩存預熱的邏輯。
  • 手動觸發載入:在系統達到高峰期之前,手動觸發載入常用數據到緩存中,以提高緩存命中率和系統性能。
  • 熱點預熱。將系統中的熱點數據提前載入到緩存中,以減輕系統壓力。5
  • 延遲非同步預熱。將需要預熱的數據放入一個隊列中,由後臺非同步任務來完成預熱。
  • 增量預熱。按需預熱數據,而不是一次性預熱所有數據。通過根據數據的訪問模式和優先順序逐步預熱數據,以減少預熱過程對系統的衝擊。

如果小伙伴們還有其他的預熱方式也歡迎大家留言。

總結

上述總結了關於緩存在日常使用的時候的一些方案以及坑點,當然這些也是面試官最喜歡提問的一些點。文中關於緩存的介紹老貓其實並沒有說完,很多其實還是需要小伙伴們自己去抽時間研究研究。不得不說緩存是一門以空間換時間的藝術。要想使用好緩存,死記硬背策略肯定是行不通的。真實的業務場景往往要複雜的多,當然解決方案也不同,老貓上面提及的這些大家可以做一個參考,遇到實際問題還是需要大傢具體問題具體分析。

我是老貓,10year+資深研發,讓我們一起聊聊技術,聊聊職場,聊聊人生~ 更多精彩,歡迎關註公眾號“程式員老貓”。 個人博客:https://blog.ktdaddy.com/
您的分享是我們最大的動力!

-Advertisement-
Play Games
更多相關文章
  • 在上篇文章中,我們介紹了Future相關的用法,使用它可以獲取非同步任務執行的返回值。我們再次回顧一下Future相關的用法。 ...
  • dbcp 系列 從零開始手寫 mybatis (三)jdbc pool 如何從零手寫實現資料庫連接池 dbcp? 萬字長文深入淺出資料庫連接池 HikariCP/Commons DBCP/Tomcat/c3p0/druid 對比 Database Connection Pool 資料庫連接池概覽 c ...
  • 前言 學習命令的正確方式,其實是先手動操作一個簡單的命令,然後瞭解命令的基本含義,然後再看命令的相關文章。 所以,網上哪些docker的文章,基本上都不適於學習入門。 基礎命令 基礎命令如下: FROM openjdk:8-jre-alpine LABEL author="kiba <xxx@126 ...
  • Java程式的運行包含編寫、編譯和運行三個主要步驟。 1.在編寫階段: 開發人員在Java開發環境中輸入程式代碼,形成尾碼名為.java的Java源文件。 2.在編譯階段: 使用Java編譯器對源文件進行錯誤排查,並生成尾碼名為.class的位元組碼文件。 3.最後,在運行階段: JRE中的Java解 ...
  • 關於阿裡的通義靈碼,之前DD就給大家推薦過,雖然比起GitHub Copilot還有一些差距。但日常使用,大部分場景還是游刃有餘的。另外,它還是免費使用的,還要什麼自行車? 最近正好看到它們在搞活動,不管你之前是否已經使用,還是沒有體驗過,這次都推薦來嘗試一下!因為不管你覺得好不好,都有 拿啊 ...
  • Pandas無疑是我們數據分析時一個不可或缺的工具,它以其強大的數據處理能力、靈活的數據結構以及易於上手的API贏得了廣大數據分析師和機器學習工程師的喜愛。 然而,隨著數據量的不斷增長,如何高效、合理地管理記憶體,確保Pandas DataFrame在運行時不會因記憶體不足而崩潰,成為我們每一個人必須面 ...
  • 在前幾篇線程系列文章中,我們介紹了線程池的相關技術,任務執行類只需要實Runnable介面,然後交給線程池,就可以輕鬆的實現非同步執行多個任務的目標,提升程式的執行效率,比如如下非同步執行任務下載。 ...
  • 拓展閱讀 第一節 從零開始手寫 mybatis(一)MVP 版本。 第二節 從零開始手寫 mybatis(二)mybatis interceptor 插件機制詳解 第三節 從零開始手寫 mybatis(三)jdbc pool 從零實現資料庫連接池 第四節 從零開始手寫 mybatis(四)- myb ...
一周排行
    -Advertisement-
    Play Games
  • 移動開發(一):使用.NET MAUI開發第一個安卓APP 對於工作多年的C#程式員來說,近來想嘗試開發一款安卓APP,考慮了很久最終選擇使用.NET MAUI這個微軟官方的框架來嘗試體驗開發安卓APP,畢竟是使用Visual Studio開發工具,使用起來也比較的順手,結合微軟官方的教程進行了安卓 ...
  • 前言 QuestPDF 是一個開源 .NET 庫,用於生成 PDF 文檔。使用了C# Fluent API方式可簡化開發、減少錯誤並提高工作效率。利用它可以輕鬆生成 PDF 報告、發票、導出文件等。 項目介紹 QuestPDF 是一個革命性的開源 .NET 庫,它徹底改變了我們生成 PDF 文檔的方 ...
  • 項目地址 項目後端地址: https://github.com/ZyPLJ/ZYTteeHole 項目前端頁面地址: ZyPLJ/TreeHoleVue (github.com) https://github.com/ZyPLJ/TreeHoleVue 目前項目測試訪問地址: http://tree ...
  • 話不多說,直接開乾 一.下載 1.官方鏈接下載: https://www.microsoft.com/zh-cn/sql-server/sql-server-downloads 2.在下載目錄中找到下麵這個小的安裝包 SQL2022-SSEI-Dev.exe,運行開始下載SQL server; 二. ...
  • 前言 隨著物聯網(IoT)技術的迅猛發展,MQTT(消息隊列遙測傳輸)協議憑藉其輕量級和高效性,已成為眾多物聯網應用的首選通信標準。 MQTTnet 作為一個高性能的 .NET 開源庫,為 .NET 平臺上的 MQTT 客戶端與伺服器開發提供了強大的支持。 本文將全面介紹 MQTTnet 的核心功能 ...
  • Serilog支持多種接收器用於日誌存儲,增強器用於添加屬性,LogContext管理動態屬性,支持多種輸出格式包括純文本、JSON及ExpressionTemplate。還提供了自定義格式化選項,適用於不同需求。 ...
  • 目錄簡介獲取 HTML 文檔解析 HTML 文檔測試參考文章 簡介 動態內容網站使用 JavaScript 腳本動態檢索和渲染數據,爬取信息時需要模擬瀏覽器行為,否則獲取到的源碼基本是空的。 本文使用的爬取步驟如下: 使用 Selenium 獲取渲染後的 HTML 文檔 使用 HtmlAgility ...
  • 1.前言 什麼是熱更新 游戲或者軟體更新時,無需重新下載客戶端進行安裝,而是在應用程式啟動的情況下,在內部進行資源或者代碼更新 Unity目前常用熱更新解決方案 HybridCLR,Xlua,ILRuntime等 Unity目前常用資源管理解決方案 AssetBundles,Addressable, ...
  • 本文章主要是在C# ASP.NET Core Web API框架實現向手機發送驗證碼簡訊功能。這裡我選擇是一個互億無線簡訊驗證碼平臺,其實像阿裡雲,騰訊雲上面也可以。 首先我們先去 互億無線 https://www.ihuyi.com/api/sms.html 去註冊一個賬號 註冊完成賬號後,它會送 ...
  • 通過以下方式可以高效,並保證數據同步的可靠性 1.API設計 使用RESTful設計,確保API端點明確,並使用適當的HTTP方法(如POST用於創建,PUT用於更新)。 設計清晰的請求和響應模型,以確保客戶端能夠理解預期格式。 2.數據驗證 在伺服器端進行嚴格的數據驗證,確保接收到的數據符合預期格 ...