如何簡單高效的在程式碼中實現兩級快取的管理
導讀 | 快取的過期時間、過期策略以及多執行緒訪問的問題也都需要考慮進去,不過我們今天暫時先不考慮這些問題,先看一下如何簡單高效的在程式碼中實現兩級快取的管理。 |
在高效能的服務架構設計中,快取是一個不可或缺的環節。在實際的專案中,我們通常會將一些熱點資料儲存到Redis或MemCache這類快取中介軟體中,只有當快取的訪問沒有命中時再查詢資料庫。在提升訪問速度的同時,也能降低資料庫的壓力。
隨著不斷的發展,這一架構也產生了改進,在一些場景下可能單純使用Redis類的遠端快取已經不夠了,還需要進一步配合本地快取使用,例如Guava cache或Caffeine,從而再次提升程式的響應速度與服務效能。於是,就產生了使用本地快取作為一級快取,再加上遠端快取作為二級快取的兩級快取架構。
在先不考慮併發等複雜問題的情況下,兩級快取的訪問流程可以用下面這張圖來表示:
那麼,使用兩級快取相比單純使用遠端快取,具有什麼優勢呢?
本地快取基於本地環境的記憶體,訪問速度非常快,對於一些變更頻率低、實時性要求低的資料,可以放在本地快取中,提升訪問速度;
使用本地快取能夠減少和Redis類的遠端快取間的資料互動,減少網路I/O開銷,降低這一過程中在網路通訊上的耗時;
但是在設計中,還是要考慮一些問題的,例如資料一致性問題。首先,兩級快取與資料庫的資料要保持一致,一旦資料發生了修改,在修改資料庫的同時,本地快取、遠端快取應該同步更新。
另外,如果是分散式環境下,一級快取之間也會存在一致性問題,當一個節點下的本地快取修改後,需要通知其他節點也重新整理本地快取中的資料,否則會出現讀取到過期資料的情況,這一問題可以透過類似於Redis中的釋出/訂閱功能解決。
此外,快取的過期時間、過期策略以及多執行緒訪問的問題也都需要考慮進去,不過我們今天暫時先不考慮這些問題,先看一下如何簡單高效的在程式碼中實現兩級快取的管理。
在簡單梳理了一下要面對的問題後,下面開始兩級快取的程式碼實戰,我們整合號稱最強本地快取的Caffeine作為一級快取、效能之王的Redis作為二級快取。首先建一個springboot專案,引入快取要用到的相關的依賴:
com.github.ben-manes.caffeinecaffeine2.9.2org.springframework.bootspring-boot-starter-data-redisorg.springframework.bootspring-boot-starter-cacheorg.apache.commonscommons-pool22.8.1
在application.yml中配置Redis的連線資訊:
spring: redis: host: 127.0.0.1 port: 6379 database: 0 timeout: 10000ms lettuce: pool: max-active: 8 max-wait: -1ms max-idle: 8 min-idle: 0
在下面的例子中,我們將使用RedisTemplate來對redis進行讀寫操作,RedisTemplate使用前需要配置一下ConnectionFactory和序列化方式,這一過程比較簡單就不貼出程式碼了。
下面我們在單機環境下,將按照對業務侵入性的不同程度,分三個版本來實現兩級快取的使用。
我們可以透過手動操作Caffeine中的Cache物件來快取資料,它是一個類似Map的資料結構,以key作為索引,value儲存資料。在使用Cache前,需要先配置一下相關引數:
@Configuration public class CaffeineConfig { @Bean public CachecaffeineCache(){ return Caffeine.newBuilder() .initialCapacity(128)//初始大小 .maximumSize(1024)//最大數量 .expireAfterWrite(60, TimeUnit.SECONDS)//過期時間 .build(); } }
簡單解釋一下Cache相關的幾個引數的意義:
initialCapacity:初始快取空大小;
- maximumSize:快取的最大數量,設定這個值可以避免出現記憶體溢位;
- expireAfterWrite:指定快取的過期時間,是最後一次寫操作後的一個時間,這裡;
- 此外,快取的過期策略也可以透過expireAfterAccess或refreshAfterWrite指定。
在建立完成Cache後,我們就可以在業務程式碼中注入並使用它了。在沒有使用任何快取前,一個只有簡單的Service層程式碼是下面這樣的,只有crud操作:
@Service @AllArgsConstructor public class OrderServiceImpl implements OrderService { private final OrderMapper orderMapper; @Override public Order getOrderById(Long id) { Order order = orderMapper.selectOne(new LambdaQueryWrapper() .eq(Order::getId, id)); return order; } @Override public void updateOrder(Order order) { orderMapper.updateById(order); } @Override public void deleteOrder(Long id) { orderMapper.deleteById(id); } }
接下來,對上面的OrderService進行改造,在執行正常業務外再加上操作兩級快取的程式碼,先看改造後的查詢操作:
public Order getOrderById(Long id) { String key = CacheConstant.ORDER + id; Order order = (Order) cache.get(key, k -> { //先查詢 Redis Object obj = redisTemplate.opsForValue().get(k); if (Objects.nonNull(obj)) { log.info("get data from redis"); return obj; } // Redis沒有則查詢 DB log.info("get data from database"); Order myOrder = orderMapper.selectOne(new LambdaQueryWrapper() .eq(Order::getId, id)); redisTemplate.opsForValue().set(k, myOrder, 120, TimeUnit.SECONDS); return myOrder; }); return order; }
在Cache的get方法中,會先從快取中進行查詢,如果找到快取的值那麼直接返回。如果沒有找到則執行後面的方法,並把結果加入到快取中。
因此上面的邏輯就是先查詢Caffeine中的快取,沒有的話查詢Redis,Redis再不命中則查詢資料庫,寫入Redis快取的操作需要手動寫入,而Caffeine的寫入由get方法自己完成。
在上面的例子中,設定Caffeine的過期時間為60秒,而Redis的過期時間為120秒,下面進行測試,首先看第一次介面呼叫時,進行了資料庫的查詢:
而在之後60秒內訪問介面時,都沒有列印打任何sql或自定義的日誌內容,說明介面沒有查詢Redis或資料庫,直接從Caffeine中讀取了快取。
等到距離第一次呼叫介面進行快取的60秒後,再次呼叫介面:
可以看到這時從Redis中讀取了資料,因為這時Caffeine中的快取已經過期了,但是Redis中的快取沒有過期仍然可用。
下面再來看一下修改操作,程式碼在原先的基礎上新增了手動修改Redis和Caffeine快取的邏輯:
public void updateOrder(Order order) { log.info("update order data"); String key=CacheConstant.ORDER + order.getId(); orderMapper.updateById(order); //修改 Redis redisTemplate.opsForValue().set(key,order,120, TimeUnit.SECONDS); // 修改本地快取 cache.put(key,order); }
看一下下面圖中介面的呼叫、以及快取的重新整理過程。可以看到在更新資料後,同步重新整理了快取中的內容,再之後的訪問介面時不查詢資料庫,也可以拿到正確的結果:
最後再來看一下刪除操作,在刪除資料的同時,手動移除Reids和Caffeine中的快取:
public void deleteOrder(Long id) { log.info("delete order"); orderMapper.deleteById(id); String key= CacheConstant.ORDER + id; redisTemplate.delete(key); cache.invalidate(key); }
我們在刪除某個快取後,再次呼叫之前的查詢介面時,又會出現重新查詢資料庫的情況:
簡單的演示到此為止,可以看到上面這種使用快取的方式,雖然看起來沒什麼大問題,但是對程式碼的入侵性比較強。在業務處理的過程中要由我們頻繁的操作兩級快取,會給開發人員帶來很大負擔。那麼,有什麼方法能夠簡化這一過程呢?我們看看下一個版本。
在spring專案中,提供了CacheManager介面和一些註解,允許讓我們透過註解的方式來操作快取。先來看一下常用幾個註解說明:
@Cacheable:根據鍵從快取中取值,如果快取存在,那麼獲取快取成功之後,直接返回這個快取的結果。如果快取不存在,那麼執行方法,並將結果放入快取中。
@CachePut:不管之前的鍵對應的快取是否存在,都執行方法,並將結果強制放入快取。
@CacheEvict:執行完方法後,會移除掉快取中的資料。
如果要使用上面這幾個註解管理快取的話,我們就不需要配置V1版本中的那個型別為Cache的Bean了,而是需要配置spring中的CacheManager的相關引數,具體引數的配置和之前一樣:
@Configuration public class CacheManagerConfig { @Bean public CacheManager cacheManager(){ CaffeineCacheManager cacheManager=new CaffeineCacheManager(); cacheManager.setCaffeine(Caffeine.newBuilder() .initialCapacity(128) .maximumSize(1024) .expireAfterWrite(60, TimeUnit.SECONDS)); return cacheManager; } }
然後在啟動類上再新增上@EnableCaching註解,就可以在專案中基於註解來使用Caffeine的快取支援了。下面,再次對Service層程式碼進行改造。
首先,還是改造查詢方法,在方法上新增@Cacheable註解:
@Cacheable(value = "order",key = "#id") //@Cacheable(cacheNames = "order",key = "#p0") public Order getOrderById(Long id) { String key= CacheConstant.ORDER + id; //先查詢 Redis Object obj = redisTemplate.opsForValue().get(key); if (Objects.nonNull(obj)){ log.info("get data from redis"); return (Order) obj; } // Redis沒有則查詢 DB log.info("get data from database"); Order myOrder = orderMapper.selectOne(new LambdaQueryWrapper() .eq(Order::getId, id)); redisTemplate.opsForValue().set(key,myOrder,120, TimeUnit.SECONDS); return myOrder;
@Cacheable註解的屬性多達9個,好在我們日常使用時只需要配置兩個常用的就可以了。其中value和cacheNames互為別名關係,表示當前方法的結果會被快取在哪個Cache上,應用中透過cacheName來對Cache進行隔離,每個cacheName對應一個Cache實現。value和cacheNames可以是一個陣列,繫結多個Cache。
而另一個重要屬性key,用來指定快取方法的返回結果時對應的key,這個屬性支援使用SpringEL表示式。通常情況下,我們可以使用下面幾種方式作為key:
#引數名 #引數物件.屬性名 #p引數對應下標
在上面的程式碼中,我們看到新增了@Cacheable註解後,在程式碼中只需要保留原有的業務處理邏輯和操作Redis部分的程式碼即可,Caffeine部分的快取就交給spring處理了。
下面,我們再來改造一下更新方法,同樣,使用@CachePut註解後移除掉手動更新Cache的操作:
@CachePut(cacheNames = "order",key = "#order.id") public Order updateOrder(Order order) { log.info("update order data"); orderMapper.updateById(order); //修改 Redis redisTemplate.opsForValue().set(CacheConstant.ORDER + order.getId(), order, 120, TimeUnit.SECONDS); return order; }
注意,這裡和V1版本的程式碼有一點區別,在之前的更新操作方法中,是沒有返回值的void型別,但是這裡需要修改返回值的型別,否則會快取一個空物件到快取中對應的key上。當下次執行查詢操作時,會直接返回空物件給呼叫方,而不會執行方法中查詢資料庫或Redis的操作。
最後,刪除方法的改造就很簡單了,使用@CacheEvict註解,方法中只需要刪除Redis中的快取即可:
@CacheEvict(cacheNames = "order",key = "#id") public void deleteOrder(Long id) { log.info("delete order"); orderMapper.deleteById(id); redisTemplate.delete(CacheConstant.ORDER + id); }
可以看到,藉助spring中的CacheManager和Cache相關的註解,對V1版本的程式碼經過改進後,可以把全手動操作兩級快取的強入侵程式碼方式,改進為本地快取交給spring管理,Redis快取手動修改的半入侵方式。那麼,還能進一步改造,使之成為對業務程式碼完全無入侵的方式嗎?
模仿spring透過註解管理快取的方式,我們也可以選擇自定義註解,然後在切面中處理快取,從而將對業務程式碼的入侵降到最低。
首先定義一個註解,用於新增在需要操作快取的方法上:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented public @interface DoubleCache { String cacheName(); String key(); //支援springEl表示式 long l2TimeOut() default 120; CacheType type() default CacheType.FULL; }
我們使用cacheName + key作為快取的真正key(僅存在一個Cache中,不做CacheName隔離),l2TimeOut為可以設定的二級快取Redis的過期時間,type是一個列舉型別的變數,表示操作快取的型別,列舉型別定義如下:
public enum CacheType { FULL, //存取 PUT, //只存 DELETE //刪除 }
因為要使key支援springEl表示式,所以需要寫一個方法,使用表示式解析器解析引數:
public static String parse(String elString, TreeMapmap){ elString=String.format("#{%s}",elString); //建立表示式解析器 ExpressionParser parser = new SpelExpressionParser(); //透過evaluationContext.setVariable可以在上下文中設定變數。 EvaluationContext context = new StandardEvaluationContext(); map.entrySet().forEach(entry-> context.setVariable(entry.getKey(),entry.getValue()) ); //解析表示式 Expression expression = parser.parseExpression(elString, new TemplateParserContext()); //使用Expression.getValue()獲取表示式的值,這裡傳入了Evaluation上下文 String value = expression.getValue(context, String.class); return value; }
引數中的elString對應的就是註解中key的值,map是將原方法的引數封裝後的結果。簡單進行一下測試:
public void test() { String elString="#order.money"; String elString2="#user"; String elString3="#p0"; TreeMapmap=new TreeMap<>(); Order order = new Order(); order.setId(111L); order.setMoney(123D); map.put("order",order); map.put("user","Hydra"); String val = parse(elString, map); String val2 = parse(elString2, map); String val3 = parse(elString3, map); System.out.println(val); System.out.println(val2); System.out.println(val3); }
執行結果如下,可以看到支援按照引數名稱、引數物件的屬性名稱讀取,但是不支援按照引數下標讀取,暫時留個小坑以後再處理。
123.0 Hydra null
至於Cache相關引數的配置,我們沿用V1版本中的配置即可。準備工作做完了,下面我們定義切面,在切面中操作Cache來讀寫Caffeine的快取,操作RedisTemplate讀寫Redis快取。
@Slf4j @Component @Aspect @AllArgsConstructor public class CacheAspect { private final Cache cache; private final RedisTemplate redisTemplate; @Pointcut("@annotation(com.cn.dc.annotation.DoubleCache)") public void cacheAspect() { } @Around("cacheAspect()") public Object doAround(ProceedingJoinPoint point) throws Throwable { MethodSignature signature = (MethodSignature) point.getSignature(); Method method = signature.getMethod(); //拼接解析springEl表示式的map String[] paramNames = signature.getParameterNames(); Object[] args = point.getArgs(); TreeMaptreeMap = new TreeMap<>(); for (int i = 0; i < paramNames.length; i++) { treeMap.put(paramNames[i],args[i]); } DoubleCache annotation = method.getAnnotation(DoubleCache.class); String elResult = ElParser.parse(annotation.key(), treeMap); String realKey = annotation.cacheName() + CacheConstant.COLON + elResult; //強制更新 if (annotation.type()== CacheType.PUT){ Object object = point.proceed(); redisTemplate.opsForValue().set(realKey, object,annotation.l2TimeOut(), TimeUnit.SECONDS); cache.put(realKey, object); return object; } //刪除 else if (annotation.type()== CacheType.DELETE){ redisTemplate.delete(realKey); cache.invalidate(realKey); return point.proceed(); } //讀寫,查詢Caffeine Object caffeineCache = cache.getIfPresent(realKey); if (Objects.nonNull(caffeineCache)) { log.info("get data from caffeine"); return caffeineCache; } //查詢Redis Object redisCache = redisTemplate.opsForValue().get(realKey); if (Objects.nonNull(redisCache)) { log.info("get data from redis"); cache.put(realKey, redisCache); return redisCache; } log.info("get data from database"); Object object = point.proceed(); if (Objects.nonNull(object)){ //寫入Redis redisTemplate.opsForValue().set(realKey, object,annotation.l2TimeOut(), TimeUnit.SECONDS); //寫入Caffeine cache.put(realKey, object); } return object; } }
切面中主要做了下面幾件工作:
修改Service層程式碼,程式碼中只保留原有業務程式碼,再新增上我們自定義的註解就可以了:
@DoubleCache(cacheName = "order", key = "#id", type = CacheType.FULL) public Order getOrderById(Long id) { Order myOrder = orderMapper.selectOne(new LambdaQueryWrapper() .eq(Order::getId, id)); return myOrder; } @DoubleCache(cacheName = "order",key = "#order.id", type = CacheType.PUT) public Order updateOrder(Order order) { orderMapper.updateById(order); return order; } @DoubleCache(cacheName = "order",key = "#id", type = CacheType.DELETE) public void deleteOrder(Long id) { orderMapper.deleteById(id); }
到這裡,基於切面操作快取的改造就完成了,Service的程式碼也瞬間清爽了很多,讓我們可以繼續專注於業務邏輯處理,而不用費心去操作兩級快取了。
總結本文按照對業務入侵的遞減程度,依次介紹了三種管理兩級快取的方法。至於在專案中是否需要使用二級快取,需要考慮自身業務情況,如果Redis這種遠端快取已經能夠滿足你的業務需求,那麼就沒有必要再使用本地快取了。畢竟實際使用起來遠沒有那麼簡單,本文中只是介紹了最基礎的使用,實際中的併發問題、事務的回滾問題都需要考慮,還需要思考什麼資料適合放在一級快取、什麼資料適合放在二級快取等等的其他問題。
原文來自:
來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69955379/viewspace-2885635/,如需轉載,請註明出處,否則將追究法律責任。
相關文章
- SpringBoot中實現兩級快取Spring Boot快取
- 200 行程式碼實現一個高效快取庫行程快取
- Android 的二級快取如斯簡單Android快取
- Lfu快取在Rust中的實現及原始碼解析快取Rust原始碼
- LRU cache快取簡單實現快取
- 使用簡單的Java程式碼實現酒店管理系統Java
- myBatis原始碼解析-二級快取的實現方式MyBatis原始碼快取
- Java中的多級快取設計與實現Java快取
- 實現簡單的`Blazor`低程式碼Blazor
- 在Unity中實現一個簡單的訊息管理器Unity
- 自己動手實現Android中的三級快取框架Android快取框架
- SpringBoot整合MongoDB(實現一個簡單快取)Spring BootMongoDB快取
- 快取架構中的服務詳解!SpringBoot中二級快取服務的實現快取架構Spring Boot
- 探討下如何更好的使用快取 —— Redis快取的特殊用法以及與本地快取一起構建多級快取的實現快取Redis
- SpringBoot快取管理(二) 整合Redis快取實現Spring Boot快取Redis
- 聊聊如何利用redis實現多級快取同步Redis快取
- LRU 快取淘汰演算法的兩種實現快取演算法
- Mybatis的快取——一級快取和原始碼分析MyBatis快取原始碼
- 快取函式的簡單使用快取函式
- Caffeine快取的簡單介紹快取
- EVCache快取在 Spring Boot中的實戰快取Spring Boot
- 在Rust中如何高效實現WebSocket? - ahmadrosidRustWebROS
- Laravel 實現二級快取 提高快取的命中率和細粒化快取 keyLaravel快取
- Vue原始碼解析,keep-alive是如何實現快取的?Vue原始碼Keep-Alive快取
- html實現簡單ListViews效果的例項程式碼HTMLView
- 簡單的python程式碼實現語音朗讀Python
- 一個簡單的區塊鏈程式碼實現區塊鏈
- 順序審批流的簡單程式碼實現
- .net8 AOP 實現簡單的Json Redis 快取/業務分離JSONRedis快取
- [記]SAF 中快取服務的實現快取
- python 爬取 blessing skin 的簡單實現Python
- 簡單的檔案快取函式快取函式
- 咖啡汪日誌——實際開發中如何避免快取穿透和快取雪崩(程式碼示例實際展示)快取穿透
- 如何管理需要拼接的快取 Key快取
- 實現SpringBoot + Redis快取的原始碼與教程Spring BootRedis快取原始碼
- Android中SharePreferences的簡單實現Android
- 在 React 應用程式中實現簡單的頁面檢視跟蹤器React
- MySQL與Redis實現二級快取MySqlRedis快取