【Spring Boot】第26課-使用快取並整合 Redis 來優化效能


https://unsplash.com/photos/LmyPLbbUWhA

在系統中,讀者是否有察覺某些資料不太會變動呢?例如使用者的姓名和 email,可能以後也不會再改了。又或是每日的推薦文章清單,雖然內容會變,但更新頻率相對不高。

我們知道存取資料庫(DB)是一種耗時的 I/O 操作。當前端好幾次透過 API 發出請求,會使得後端經常查詢或計算出這種內容固定的資料,其實是一種效能上的浪費。

本文將介紹 Spring Boot 的「快取」(cache),會示範將資料放入記憶體中,藉此減少實際查詢 DB 的動作。後面也會整合知名的 Redis 資料庫,將快取資料存入其中。

本文的練習用專案:
https://github.com/ntub46010/SpringBootTutorial/tree/Ch26-start


一、範例專案介紹

本文範例專案的情境,是使用者可以建立文章,並且按讚。接著取得文章詳情時,能得到建立者和按讚者的名字。然而這些人名,在 DB 中並未與文章儲存在同一個結構(schema),所以取得文章時需要跨表查詢。這就是本文要透過快取來優化的地方。

(一)資料結構

UserPO 類別代表使用者資料,欄位包含 id 、名字和建立時間。

LikePO 代表按讚資料,欄位包含文章和按讚者的 id。

PostPO 代表文章資料,欄位包含 id、建立者的 id 和標題。

PostVO 代表文章詳情,可視為前端要呈現的資料。欄位比 PostPO 多了建立者和按讚者的名字。

(二)業務邏輯

範例專案並未串接如 MySQL 這種真實的資料庫,因此資料將以 Map 或 Set 的形式儲存在各 service 中。

UserService 提供建立、更新和取得使用者的方法。還能取得使用者 id 對應名字的 Map。

LikeService 提供按讚和計算按讚數的方法,另外也能從 UserService 取得按讚者人名。

PostService 提供建立、取得文章,以及找出前 3 篇最多讚的文章的方法。

二、建立快取機制

Spring Boot 提供一些標記(annotation),讓我們以 AOP 的概念,將快取機制套用在元件(bean)的方法上,如此就不必調整業務邏輯的程式碼。

請在啟動類別加上 @EnableCaching 標記以啟用快取。此標記的作用,是為了掃描上述具有快取相關標記的 public 方法,對它們進行「代理」。

以下是可用在元件方法上的快取標記,筆者在逐一介紹後,也會應用在範例專案中。

(一)@Cacheable

此標記的用途,是讓方法在執行前,先檢查快取中是否有對應的資料。若有,則優先採用並回傳。否則才會實際執行方法,並將回傳值存入快取,供下次使用。

以「UserService.getUser」方法為例,標記使用方式如下。

可看到標記接收了多個參數。

  • cacheNames:是快取的名稱。就像生活中,學校班級有分成甲、乙、丙班那樣。此參數亦可傳入陣列,藉此操作多個快取。
  • key:用來在快取中對應到資料的鍵,就像是班級的學生座號。且同一個快取中不會重複。此處的「#p0」代表方法的第 1 個參數。
  • unless:用來定義不要將回傳值存入快取的條件。此處的「#result.id」代表回傳值(result)的 id 欄位。範例的意思是 id 的值若是以「test-」開頭,則不存入快取。

再以 LikeService 的「getLikeUserNames」與「getLikeCount」方法為例。此處分別存入名為「likeUserNames」與「likeCount」的快取。而標記的「key」參數亦可直接指定方法參數的名稱「postId」。

最後我們再套用在「PostService.getMostLikedPosts」方法上。這筆資料的快取 key,筆者想採用固定的值,所以加上單引號。

(三)@CachePut

此標記的用途,是在方法執行後,將回傳值存入快取。若快取資料已存在,則進行覆蓋。

以「UserService.createUser」方法為例,筆者希望建立使用者資料後,能隨即存入快取。標記使用方式如下。

這裡用了另一個叫做「conditional」的標記參數,它能定義要將方法回傳值存入快取的條件,正好與前述的 unless 互為相反。該方法為了測試用途,故直接外加一個方法參數「saveCache」來決定。

再以「UserService.createUser」方法為例,在更新使用者後,快取中的資料也一併更新。

(四)@CacheEvict

此標記的用途,是在方法執行前或後,從快取中刪除指定 key 的資料。

以「LikeService.createLike」方法為例,由於前面「getLikeUserNames」方法所得出的文章按讚者清單,會隨著其他人建立按讚而發生改變,因此筆者希望在這個動作後一律刪除快取,待需要時再重建。

@CacheEvict 標記尚有其他專屬的參數。

  • allEntries:可提供 boolean 值,傳入 true 代表要刪除該快取的所有資料(無視 key 參數)。
  • beforeInvocation:可提供 boolean 值,傳入 true 代表會先刪除快取再執行方法。反之則在執行完畢後才刪除,且若方法發生例外,會導致刪除快取的動作不被執行。

(四)@Caching

上面介紹了三種標記,但 Spring 只允許每個方法僅能使用一種。若想使用多種,可利用 @Caching 標記,在參數中傳入多個想要使用的標記。

再次以「LikeService.createLike」方法為例,除了前面的按讚者清單(likeUserNames),別忘了按讚數(likeCount)也會隨之改變。因此讓我們同時清除兩筆快取資料。

@Caching 標記提供 cacheable、put 與 evict 參數,此處傳入兩個 @CacheEvict。

附帶一提,該方法若不使用 @Caching 標記,剛好可簡化成下面其中一種:

@CacheEvict(cacheNames = {"likeUserNames", "likeCount"}, key = "#postId")
public void createLike(String userId, String postId) {
    // ...
}

或

@CacheEvict(cacheNames = {"likeUserNames", "likeCount"}, key = "#p1")
public void createLike(String userId, String postId) {
    // ...
}

(五)標記參數整理

茲將快取相關標記的參數整理如下:


cacheNameskeyconditionunlessallEntriesbeforeInvocation
@CacheableOOOOXX
@CachePutOOOOXX
@CacheEvictOOOXOO

其中 key、condition 與 unless 支援「SpEL」表達式,因此範例中才寫出「#p0」、「#result」等字眼。有興趣可以另外搜尋還有哪些寫法。

三、測試快取效果

當添加完快取的標記,我們可確認該機制是否發揮作用。這邊簡單地示範經由撰寫測試的方式來呼叫 service,並印出 log。以下的測試情境,是建立使用者時,未隨即存入快取。後續進行第一次取得時,會實際執行方法。第二次取得則直接從快取得到資料。

更多測試程式,有興趣的讀者請參考文末附上的完成後範例專案。


四、引進 Redis 快取服務

實際上,快取預設是儲存在程式中的 ConcurrentHashMap,而本節我們要將快取改成存在名為 Redis 的 In-memory 資料庫。它的特色是在運行期間,資料都存放於記憶體中,故執行速度相當快,適合作為快取用途。

(一)事前準備

請參考「【Redis】基本介紹與在 Windows Docker 上安裝」的第二節,或 Windows 的安裝檔(版本 3.0.504),下載並啟動 Redis 伺服器。

接著回到程式專案,在 application.properties 檔寫好 Redis 的連線參數。

最後在 pom 檔添加函式庫,以便使用裡面提供的 API 來配置 Redis。

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

(二)建立 CacheManager

請新增一個 Configuration 類別,並在裡面建立 CacheManager 介面的元件。還記得前面介紹的快取相關標記嗎?建立此元件主要是放著給 Spring 自行使用的。

CacheManager 是 Spring 本身就有提供的介面。若未引進外部的快取服務,預設會採用內建的 CurrentMapCacheManager。現在專案添加了 Redis 的函式庫,接下來請建立出「RedisCacheManager」。

並且,透過 RedisCacheConfiguration 物件可提供一些設定。

(三)設定有效時間

透過 RedisCacheConfiguration 物件的「entryTtl」方法,便能決定每一筆快取資料的有效間。附帶一提,Ttl 是「time to live」的意思。

.entryTtl(Duration.ofMinutes(10))

以上的例子是設為 10 分鐘,讀者可自行決定 Duration 物件參數的時間與單位。

(四)設定序列化

由於在範例專案中,我們想要將「UserPO」、「List<String>」和「List<PostVO>」這樣子的「物件」存入快取,所以需要對物件進行「序列化」(serialize)。

在本文,筆者採用「GenericJackson2JsonRedisSerializer」。它會將資料轉換為 JSON 字串,連同類別的 package 路徑一起存入 Redis。

.serializeValuesWith(
        RedisSerializationContext.SerializationPair.fromSerializer(
                new GenericJackson2JsonRedisSerializer()))

示範至此,完整的 RedisCacheManager 元件之配置程式碼如下。

而資料在 Redis 中的儲存狀況如下。

Java 物件被轉換成 JSON 字串後存進 Redis
Java 物件被轉換成 JSON 字串後存進 Redis

後續讀者可像第三節那樣,準備測試程式來驗證這兩種快取方式的結果均相同。

本文的完成專案:
https://github.com/ntub46010/SpringBootTutorial/tree/Ch26-fin

上一篇:【Spring Boot】第25課-定時任務排程

留言