# 20 圖 |6000 字 |實戰快取(上篇)

前言

先說個小事情,今天試了下做動圖,就一張動圖都花了我 1 個小時,還做得很難看。。

本文主要內容如下:

上一篇講到如何做性能調優的方法,比如給表加索引、動靜分離、減少不必要的日誌列印。但有一個很強大的優化方式沒有提到,那就是加快取,比如查詢小程式的廣告位配置,因為沒什麼人會去頻繁的改,將廣告位配置丟到快取裡面再適合不過了。那我們就給開源 Spring Cloud 實戰項目 PassJava 加下快取來提升下性能。

我把後端、前端、小程式都上傳到同一個倉庫裡面了,大家可以通過 github 或 碼雲訪問。地址如下:

Github: //github.com/Jackson0714/PassJava-Platform

碼雲//gitee.com/jayh2018/PassJava-Platform

配套教程:www.passjava.cn

在實戰之前,我們先來看下使用快取的原理和問題。

一、快取

1.1 為什麼要用快取

20 年前常見的系統就是單機的,比如 ERP 系統,對性能要求不高,使用快取的並不常見,但現如今,已經步入到互聯網時代,高並發、高可用、高性能總是被提起,而快取在這「三高」中立下汗馬功勞。

我們通過會將部分數據放入快取中,來提高訪問速度,然後資料庫承擔存儲的工作。

那麼哪些數據適合放入快取中呢?

  • 即時性。例如查詢最新的物流狀態資訊。

  • 數據一致性要求不高。例如門店資訊,修改後,資料庫中已經改了,5 分鐘後快取中才是最新的,但不影響功能使用。

  • 訪問量大且更新頻率不高。比如首頁的廣告資訊,訪問量,但是不會經常變化。

當我們想要查詢數據時,使用快取的流程如下:

讀模式快取使用流程

1.2 本地快取

最簡單的使用快取的方式就是用本地快取。

比如現在有一個需求,前端小程式需要查詢題目的類型,而題目類型放在小程式的首頁在,訪問量是非常高的,但是又不是經常變化的數據,所以可以將題目類型數據放到快取中。

image-20210418172719159

最簡單的使用快取的方式是使用本地快取,也就是在記憶體中快取數據,可以用 HashMap、數組等數據結構來快取數據。

1.2.1 不使用快取

我們先來看下不使用快取的情況:前端的請求先經過網關,然後請求到題目微服務,然後查詢資料庫,返回查詢結果。

再來看下核心程式碼是怎麼樣的。

先自定義一個 Rest API 用來查詢題目類型列表,數據是從資料庫查詢出來後直接返回給前端。

@RequestMapping("/list")
public R list(){
    // 從資料庫中查詢數據
    typeEntityList = ITypeService.list(); 
    return R.ok().put("typeEntityList", typeEntityList);
}

1.2.2 使用快取

來看下使用快取的情況:前端先經過網關,然後到題目微服務,先判斷快取中有沒有數據,如果沒有,則查詢資料庫再更新快取,最後返回查詢到的結果。

那我們現在創建一個 HashMap 來快取題目的類型列表:

private Map<String, Object> cache = new HashMap<>();

先獲取快取的類型列表

List<TypeEntity> typeEntityListCache = (List<TypeEntity>) cache.get("typeEntityList");

如果快取中沒有,則先從資料庫中獲取。當然,第一次查詢快取時,肯定是沒有這個數據的。

// 如果快取中沒有數據
if (typeEntityListCache == null) {
  System.out.println("The cache is empty");
  // 從資料庫中查詢數據
  List<TypeEntity> typeEntityList = ITypeService.list();
  // 將數據放入快取中
  typeEntityListCache = typeEntityList;
  cache.put("typeEntityList", typeEntityList);
}
return R.ok().put("typeEntityList", typeEntityListCache);

我們用 Postman 工具來看下查詢結果:

請求URL://github.com/Jackson0714/PassJava-Platform

返回了題目類型列表,共 14 條數據。

以後再次查詢時,因為快取中已經有該數據了,所以直接走快取,不會再從資料庫中查詢數據了。

從上面的例子中我們可以知道本地快取有哪些優點呢?

  • 減少和資料庫的交互,降低因磁碟 I/O 引起的性能問題。
  • 避免資料庫的死鎖問題。
  • 加速相應速度。

當然,本地快取也存在一些問題:

  • 佔用本地記憶體資源。
  • 機器宕機重啟後,快取丟失。
  • 可能會存在資料庫數據和快取數據不一致的問題。
  • 同一台機器中的多個微服務快取的數據不一致。

  • 集群環境下存在快取的數據不一致的問題。

基於本地快取的問題,我們引入了分散式快取 Redis 來解決。

二、快取 Redis

2.1 Docker 安裝 Redis

首先需要安裝 Redis,我是通過 Docker 來安裝 Redis。另外我在 ubuntu 和 Mac M1 上都裝過 docker 版的 Redis,大家可以參照這兩篇來安裝。

《Ubuntu 上到 Docker 安裝redis》

《M1 運行 Docker》

2.2 引入 Redis 組件

我用的是 passjava-question 微服務,所以是在 passjava-question 模組下的配置文件 pom.xml 中引入 redis 組件。

文件路徑:/passjava-question/pom.xml

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

2.3 測試 Redis

我們可以寫一個測試方法來測試引入的 redis 是否能存數據,以及能否查出存的數據。

我們都是使用 StringRedisTemplate 庫來操作 Redis,所以可以自動裝載下 StringRedisTemplate

@Autowired
StringRedisTemplate stringRedisTemplate;

然後在測試方法中,測試存儲方法:ops.set(),以及 查詢方法:ops.get()

@Test
public void TestStringRedisTemplate() {
    // 初始化 redis 組件
    ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
    // 存儲數據
    ops.set("悟空", "悟空聊架構_" + UUID.randomUUID().toString());
    // 查詢數據
    String wukong = ops.get("悟空");
    System.out.println(wukong);
}

set 方法的第一個參數是 key,比如示例中的 「悟空」。

get 方法的參數也是 key。

最後列印出了 redis 中 key = 「悟空」 的快取的值:

另外也可以通過客戶端工具來查看,如下圖所示:

我下載的是這個軟體:Redis Desktop Manager windows下載地址:

//www.pc6.com/softview/SoftView_450180.html

2.4 用 Redis 改造業務邏輯

用 redis 替換 hashmap 也不難,把用到hashmap 到都用 redis 改下。另外需要注意的是:

從資料庫中查詢到的數據先要序列化成 JSON 字元串後再存入到 Redis 中,從 Redis 中查詢數據時,也需要將 JSON 字元串反序列化為對象實例。

public List<TypeEntity> getTypeEntityList() {
  // 1.初始化 redis 組件
  ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
  // 2.從快取中查詢數據
  String typeEntityListCache = ops.get("typeEntityList");
  // 3.如果快取中沒有數據
  if (StringUtils.isEmpty(typeEntityListCache)) {
    System.out.println("The cache is empty");
    // 4.從資料庫中查詢數據
    List<TypeEntity> typeEntityListFromDb = this.list();
    // 5.將從資料庫中查詢出的數據序列化 JSON 字元串
    typeEntityListCache = JSON.toJSONString(typeEntityListFromDb);
    // 6.將序列化後的數據存入快取中
    ops.set("typeEntityList", typeEntityListCache);
    return typeEntityListFromDb;
  }
  // 7.如果快取中有數據,則從快取中拿出來,並反序列化為實例對象
  List<TypeEntity> typeEntityList = JSON.parseObject(typeEntityListCache, new TypeReference<List<TypeEntity>>(){});
  return typeEntityList;
}

整個流程如下:

  • 1.初始化 redis 組件。

  • 2.從快取中查詢數據。

  • 3.如果快取中沒有數據,執行步驟 4、5、6。

  • 4.從資料庫中查詢數據

  • 5.將從資料庫中查詢出的數據轉化為 JSON 字元串

  • 6.將序列化後的數據存入快取中,並返回資料庫中查詢到的數據。

  • 7.如果快取中有數據,則從快取中拿出來,並反序列化為實例對象

2.5 測試業務邏輯

我們還是用 postman 工具進行測試:

通過多次測試,第一次請求會稍微慢點,後面幾次速度非常快。說明使用快取後性能有提升。

另外我們用 Redis 客戶端看下結果:

Redis key = typeEntityList,Redis value 是一個 JSON 字元串,裡面的內容是題目分類列表。

三、快取穿透、雪崩、擊穿

高並發下使用快取會帶來的幾個問題:快取穿透、雪崩、擊穿。

3.1 快取穿透

3.1.1 快取穿透的概念

快取穿透指一個一定不存在的數據,由於快取未命中這條數據,就會去查詢資料庫,資料庫也沒有這條數據,所以返回結果是 null。如果每次查詢都走資料庫,則快取就失去了意義,就像穿透了快取一樣。

3.1.2 帶來的風險

利用不存在的數據進行攻擊,資料庫壓力增大,最終導致系統崩潰。

3.1.3 解決方案

對結果 null 進行快取,並加入短暫的過期時間。

3.2 快取雪崩

3.2.1 快取雪崩的概念

快取雪崩是指我們快取多條數據時,採用了相同的過期時間,比如 00:00:00 過期,如果這個時刻快取同時失效,而有大量請求進來了,因未快取數據,所以都去查詢資料庫了,資料庫壓力增大,最終就會導致雪崩。

3.2.2 帶來的風險

嘗試找到大量 key 同時過期的時間,在某時刻進行大量攻擊,資料庫壓力增大,最終導致系統崩潰。

3.2.3 解決方案

在原有的實效時間基礎上增加一個碎擠汁,比如 1-5 分鐘隨機,降低快取的過期時間的重複率,避免發生快取集體實效。

3.3 快取擊穿

3.3.1 快取擊穿的概念

某個 key 設置了過期時間,但在正好失效的時候,有大量請求進來了,導致請求都到資料庫查詢了。

3.3.2 解決方案

大量並發時,只讓一個請求可以獲取到查詢資料庫的鎖,其他請求需要等待,查到以後釋放鎖,其他請求獲取到鎖後,先查快取,快取中有數據,就不用查資料庫。

四、加鎖解決快取擊穿

怎麼處理快取穿透、雪崩、擊穿的問題呢?

  • 對空結果進行快取,用來解決快取穿透問題。
  • 設置過期時間,且加上隨機值進行過期偏移,用來解決快取雪崩問題。
  • 加鎖,解決快取擊穿問題。另外需要注意,加鎖對性能會帶來影響。

這裡我們來看下用程式碼演示如何解決快取擊穿問題。

我們需要用 synchronized 來進行加鎖。當然這是本地鎖的方式,分散式鎖我們會在下篇講到。

public List<TypeEntity> getTypeEntityListByLock() {
  synchronized (this) {
    // 1.從快取中查詢數據
    String typeEntityListCache = stringRedisTemplate.opsForValue().get("typeEntityList");
    if (!StringUtils.isEmpty(typeEntityListCache)) {
      // 2.如果快取中有數據,則從快取中拿出來,並反序列化為實例對象,並返回結果
      List<TypeEntity> typeEntityList = JSON.parseObject(typeEntityListCache, new TypeReference<List<TypeEntity>>(){});
      return typeEntityList;
    }
    // 3.如果快取中沒有數據,從資料庫中查詢數據
    System.out.println("The cache is empty");
    List<TypeEntity> typeEntityListFromDb = this.list();
    // 4.將從資料庫中查詢出的數據序列化 JSON 字元串
    typeEntityListCache = JSON.toJSONString(typeEntityListFromDb);
    // 5.將序列化後的數據存入快取中,並返回資料庫查詢結果
    stringRedisTemplate.opsForValue().set("typeEntityList", typeEntityListCache, 1, TimeUnit.DAYS);
    return typeEntityListFromDb;
  }
}
  • 1.從快取中查詢數據。

  • 2.如果快取中有數據,則從快取中拿出來,並反序列化為實例對象,並返回結果。

  • 3.如果快取中沒有數據,從資料庫中查詢數據。

  • 4.將從資料庫中查詢出的數據序列化 JSON 字元串。

  • 5.將序列化後的數據存入快取中,並返回資料庫查詢結果。

五、本地鎖的問題

本地鎖只能鎖定當前服務的執行緒,如下圖所示,部署了多個題目微服務,每個微服務用本地鎖進行加鎖。

本地鎖在一般情況下沒什麼問題,但是當用來鎖庫存就有問題了:

  • 1.當前總庫存為 100,被快取在 Redis 中。

  • 2.庫存微服務 A 用本地鎖扣減庫存 1 之後,總庫存為 99。

  • 3.庫存微服務 B 用本地鎖扣減庫存 1 之後,總庫存為 99。

  • 4.那庫存扣減了 2 次後,還是 99,就超賣了 1 個。

那如何解決本地加鎖的問題呢?

快取實戰(中篇):實戰分散式鎖。