用無感知的方式為你的資料加上一層快取

沒有氣的汽水發表於2022-05-15

前言

本篇文章會介紹一個我自己寫的庫,庫地址在這裡,主要作用是提供一個註解,在你方法上使用這個註解,庫提供的功能會幫你把資料自動快取起來,下次再呼叫這個方法只要入參是一致的則直接會從快取裡面拿資料,不會再執行方法了(方法裡面的內容可能是走DB或者PRC)。

寫這個庫的原因是公司內部有一個類似工具,剛開始並沒有Get到它有多大的用處,隨著在公司接觸更多的業務需求,發現這真的是太香了。並且我對它有一些自己的更多想法,所以打算自己寫一個開源出來一方面是可以給自己其他開源專案使用,另外一方面我覺得有相同需求的人還蠻多的,而Github上也沒有一個盡善盡美的類似庫。

不同量級使用者的程式碼真的是不一樣,我在原來公司也做的也是C端的業務,對於快取運用雖然非常多,但是主要是利用Redis資料結構特點來做一些業務上的實現會更多一些,例如用string來做互斥鎖/配置開關/狀態記錄,用zset來做榜單/實現dmq需求,用list/set/hash來做高效能短時資料庫。對於很多DB的資料都是直接查,主要當時的產品使用者量非常平穩,拿了融資的那段時間公司有錢,資料庫用的非常好,從庫也多, 上線不需要壓測,隨便點點RTT自己覺得過得去就行,所以我覺得偶爾寫一下讀寫redis的程式碼也沒啥用工作量。

但是現在的公司,C端介面都做限流,除了分頁資料幾乎所有資料都加一層快取,甚至分頁資料的第一頁都會做。這就很麻煩,每次都要寫一大堆重複程式碼,從Redis取值,空走就DB/RPC,然後回寫Redis。最可怕的是批量取值,從Redis去拿完以後還要把沒有命中的篩選出來然後去DB/RPC取,然後回寫這部分值到Redis。因為使用者量很大,還有羊毛黨會刷介面,未命中的值可能還需要做空快取防止穿透到DB。

本倉庫包含以下內容:

  1. @Cache註解一個
  2. 對指定方法進行自動快取(Redis或者caffeine本地快取)
  3. 可對不存在的資料進行自動空快取,併發下防止快取穿透
  4. 可開啟獲取快取時自動進行互斥鎖,防止快取擊穿保護DB(下個版本更新)

安裝匯入

本庫已經上架maven中央倉庫,已經引入到自己專案pom檔案中就行,請注意直接在mvnrepository會出現很多2.0.0以下的版本,請不要使用,那...那...是我上架的是做測試不小心發到release上的debug版本。

所有版本查詢請點選這裡 這裡 這裡

Maven

<!-- https://mvnrepository.com/artifact/cn.someget/cache-anno -->
<dependency>
    <groupId>cn.someget</groupId>
    <artifactId>cache-anno</artifactId>
    <version>2.0.0</version>
</dependency>

Gradle

// https://mvnrepository.com/artifact/cn.someget/cache-anno
implementation group: 'cn.someget', name: 'cache-anno', version: '2.0.0'

匯入jar包就好了,除此之外無需為本庫做任何配置,所有bean也已經spring.factories暴露出來,可以直接被啟動類掃描到。

使用說明

一. 注意事項

  1. 匯入本庫後配置中必須要有redis相關的配置,支援jedis和lettuce,只要spring自動裝配或者你手動裝配一個redisTemplate就行(注意如果你有多個RedisTemplate<String, String>你需要給其中一個指定@Primary),不然無法使用本庫。

  2. 使用註解存入的資料,序列化方式均為String,當然註解自動讀資料反序列化也是String,所以如果你有使用註解存入資料,但是不適用註解讀取資料的需求,請使用String反序列化讀取。

  3. 所有redis的io異常都已經捕獲,有異常只會打日誌,不會影響你的業務,不會影響你的資料讀取,兜底會走db查詢。

二. 使用方法

  1. 查詢一個資料,例如下面,通過uid查詢使用者的詳細資料,加上@Cache(注意不要導錯包哈)註解,prefix是你快取資料Redis的Key字首,注意需要加一個佔位符,我自動寫入的時候會把入參拼接到這個prefix上。查詢之前會根據註解中的prefix拼接上入參uid從Redis中嘗試獲取資料,如果沒有資料則執行方法,執行完方法再自動寫入快取。那麼下次在執行這個方法的時候,又會執行上面步驟prefix拼接入參嘗試從Redis獲取資料,但是因為上次自動寫入了,所以拿到資料就直接返回了,不會再執行方法走DB查詢了。
// 建議prefix定義成常量,便於複用, one to one可以不用傳clazz引數
@Cache(prefix = "user:info:%d")
public UserInfoBO getIpUserInfo(Long uid) {
    UserInfo userInfo = userInfoMapper.selectByUid(uid);
    if (userInfo == null) {
     	 return null;
    }
    UserInfoBO bo = new UserInfoBO();
    BeanUtils.copyProperties(userInfo, bo);
    return bo;
}
  1. 查詢多個資料,例如下面,通過一堆itemId獲取多個item的詳細資料,和上面一樣你需要指定prefix,查詢之前會通過把入參所有的itemId進行和註解裡面的prefix拼接,然後批量一次性嘗試從redis裡面獲取,如果所有itemId都獲取到則直接返回,但是如果有未命中的itemId則會把這未命中的itemId再統一走方法進行遠端獲取最後和Redis裡面的彙總(遠端獲取完 會自動寫入Redis,方便你下次命中)
// 返回值是集合型別,clazz必須要傳
@Cache(prefix = "mall:item:%d", clazz = MallItemsBO.class)
public Map<Long, MallItemsBO> listItems(List<Long> itemsIds) {
    BaseResult<List<MallItemsDTO>> result = itemsRemoteClient.listItems(new ItemsReqDTO());
    if (result == null || CollectionUtils.isEmpty(result.getData())) {
     	 return Collections.emptyMap();
    }
    return result.getData().stream()
      .map(mallItemsDTO -> {
       	 MallItemsBO mallItemsBO = new MallItemsBO();
       	 BeanUtils.copyProperties(mallItemsDTO, mallItemsBO);
       	 return mallItemsBO;
      }).collect(Collectors.toMap(MallItemsBO::getItemId, Function.identity()));
}

三. 實現原理

核心原理是利用AOP對你為註解設定的引數和入參進行拼接成一個key,每次在方法執行前走快取去查詢一遍,如果快取有資料則直接返回不再執行方法,如果快取沒有查資料,則執行方法拿到資料取寫入快取。
單個資料獲取

如果是資料批量獲取,還會看Redis命中的數量來判斷是全部返回,還是把未命中的去執行方法,最後把快取和方法查詢結果一起彙總返回給方法呼叫方

多個資料獲取

四. 所有支援的方法型別

型別 prefix 入參 出參 備註
one to one 自定義:佔位符 包裝型別或者String ? extends Object 有幾個入參就要有幾個佔位符,不然無法使用
ont to list 自定義:佔位符 包裝型別或者String List<? extends Object> 同上,理論上來說List和object對於本庫是一個東西,因為我是用的是String的序列化,相同理解就好。
list to map_one 自定義:佔位符 List<包裝型別或者String> Map<對應入參包裝型別或者String, ? extends Object> 如果是批量查詢,第一個入參一定要是對應的查詢List。list裡面的每一個元素都會與prefix拼接,所以prefix的佔位符是List裡面的元素對應的佔位符。
list to map_map 自定義:佔位符 List<包裝型別或者String> Map<對應入參包裝型別或者String, List<? extends Object>> 本型別其實也同上,上型別List中每一個元素對應的是一個物件,這個型別List每個元素對應的是一個list,我反序列化都是以一樣的,所以本質一樣。
因為java的泛型擦除的限制我無法判斷Map的value泛型具體是什麼, 請@Cache中的引數hasMoreValue需要設定成true,請切記

其中佔位符要注意,如果是String佔位符要%s,整型佔位符%d,浮點型佔位符%f 詳情請參考這裡

其實總的來說就是分為兩類,入參是物件或者List,也就是單個獲取和批量獲取,如果是批量獲取的話切記List要在一號位,並且方法入參不能超過兩個,否則會報錯提示不支援。

五. 註解裡面的引數含義

名稱 含義 備註
prefix Redis中key的字首, 注意要使用佔位符,如入參是long, 佔位符就是prefixKey:%d
expire 過期時間(單位秒) 如果使用註解的時候不設定則預設10分鐘,注意本庫寫入快取都有過期時間,因為我想不到你為啥要不設定TTL
missExpire 空快取過期時間(單位秒) 如果是0則表示不開啟空快取(預設是0),空快取過期時間表示如果從db也沒查到生成空快取到Redis,這個空快取的過期時間(肯定正常快取短,推薦3-10秒)
hasMoreValue 是否list to map_map型別 因為java的泛型擦除的限制我無法判斷Map的value泛型具體是什麼, 請@Cache中的引數hasMoreValue需要設定成true,請切記
clazz 集合類返回值對應型別 如果返回值是List或者Map這個必傳,因為java泛型擦除我不知道你集合泛型,反序列化需要使用。如果是one to one型別的話,這個可以省略。
usingLocalCache 是否使用本地快取 設定true以後從Redis讀取之前會查詢一遍本地快取(使用caffeine),同理拿完資料也會回寫到caffeine

六. 其他功能詳細說明

啟用空快取寫入

@Cache含有missExpire的屬性,屬性含義是DB中不存在的值的過期時間(單位是秒),預設值是0,如果是0則表示如果查詢的值DB中不存在的話,不進行空快取處理。如果是非0,那麼從Redis中查詢值未命中會走方法查詢,方法查詢返回結果也是不存在那麼會儲存一個空值(如果是物件的是"{}",如果是集合則是[]),過期時間是missExpire這個值(推薦3-10秒左右),支援上述表格中的所有型別。

空快取寫入邏輯

注意:

  • 開啟空快取以後插入記錄後也要進行刪除快取處理,因為可能對應值DB中已經有了,但是Redis還存在空值正處於TTL中。
  • 空快取如果是物件是會快取id為-1的物件,如果是集合會快取一個空集合,id為-1的物件不會返回給方法呼叫方,會直接被過濾掉,符合大家編碼習慣。要注意的是快取物件必須要有id欄位(Integer和Long都可以),否則無法過濾會返回的一個所有屬性都是null的空物件。2.0.1版已經支援設定物件空快取為"{}"所以不用必須含有id欄位了,如果命中空快取方法呼叫方拿到的是null,符合大家編碼習慣,但是所有空快取物件一定要有無參構造,否則反序列化無法生成空物件。

啟用本地快取

@Cache含有usingLocalCache的屬性,屬性含義是是否啟用本地快取,在一些場景下電商營銷域要頻繁通過RPC獲取商品域的商品資料,因為營銷場景QPS非常高,即便在RPC之前有一層Redis,但是頻繁獲取商品導致Redis的QPS非常高。而商品資料是不怎麼變化的,所以在Redis之前再加一層本地快取就顯得非常有必要,Redis的QPS能降低不少。

有不少場景進行本地快取提升都非常大,本庫也支援進行本地快取,只需使用註解是把usingLocalCache的屬性設定為true(預設是false),本庫使用的本地快取是最近風頭壓過guava的caffeine,這樣獲取資料之前先從本地快取進行查詢,如果本地快取沒有命中則再去查詢Redis。

注意:多層快取會增加Cache-DB不一致可能,一定程度抗流可以用,但是不要過分依賴,這裡本地快取預設TTL是3秒,暫時不支援修改。

後言

下一步計劃是

  1. 寫好單元測試,這個庫我還會寫一篇文章,基於單測的code更加直觀來介紹它是怎麼的好用。
  2. 防止快取擊穿已經有好的方案,下一個版本會迭代進去,對於高風險介面可以開啟。
  3. 目前沒有配套的快取逐出註解,參考Spring-Cache我想一下怎麼做到最好

如果你感興趣或者有相同的需求歡迎使用本庫,更加提一個 Issue 或者提交一個 Pull Request。

相關文章