原創(chuàng):微信公眾號
碼農(nóng)參上
,歡迎分享悯蝉,轉(zhuǎn)載請保留出處归形。
在高性能的服務(wù)架構(gòu)設(shè)計中,緩存是一個不可或缺的環(huán)節(jié)鼻由。在實際的項目中暇榴,我們通常會將一些熱點數(shù)據(jù)存儲到Redis
或MemCache
這類緩存中間件中,只有當緩存的訪問沒有命中時再查詢數(shù)據(jù)庫蕉世。在提升訪問速度的同時蔼紧,也能降低數(shù)據(jù)庫的壓力。
隨著不斷的發(fā)展狠轻,這一架構(gòu)也產(chǎn)生了改進奸例,在一些場景下可能單純使用Redis
類的遠程緩存已經(jīng)不夠了,還需要進一步配合本地緩存使用向楼,例如Guava cache
或Caffeine
查吊,從而再次提升程序的響應(yīng)速度與服務(wù)性能。于是湖蜕,就產(chǎn)生了使用本地緩存作為一級緩存逻卖,再加上遠程緩存作為二級緩存的兩級緩存架構(gòu)。
在先不考慮并發(fā)等復(fù)雜問題的情況下重荠,兩級緩存的訪問流程可以用下面這張圖來表示:
[圖片上傳失敗...(image-bf739e-1648427675508)]
優(yōu)點與問題
那么,使用兩級緩存相比單純使用遠程緩存虚茶,具有什么優(yōu)勢呢戈鲁?
- 本地緩存基于本地環(huán)境的內(nèi)存,訪問速度非赤诮校快婆殿,對于一些變更頻率低、實時性要求低的數(shù)據(jù)罩扇,可以放在本地緩存中婆芦,提升訪問速度
- 使用本地緩存能夠減少和
Redis
類的遠程緩存間的數(shù)據(jù)交互,減少網(wǎng)絡(luò)I/O開銷喂饥,降低這一過程中在網(wǎng)絡(luò)通信上的耗時
但是在設(shè)計中消约,還是要考慮一些問題的,例如數(shù)據(jù)一致性問題员帮。首先或粮,兩級緩存與數(shù)據(jù)庫的數(shù)據(jù)要保持一致,一旦數(shù)據(jù)發(fā)生了修改捞高,在修改數(shù)據(jù)庫的同時氯材,本地緩存渣锦、遠程緩存應(yīng)該同步更新。
另外氢哮,如果是分布式環(huán)境下袋毙,一級緩存之間也會存在一致性問題,當一個節(jié)點下的本地緩存修改后冗尤,需要通知其他節(jié)點也刷新本地緩存中的數(shù)據(jù)听盖,否則會出現(xiàn)讀取到過期數(shù)據(jù)的情況,這一問題可以通過類似于Redis中的發(fā)布/訂閱功能解決生闲。
此外媳溺,緩存的過期時間、過期策略以及多線程訪問的問題也都需要考慮進去碍讯,不過我們今天暫時先不考慮這些問題悬蔽,先看一下如何簡單高效的在代碼中實現(xiàn)兩級緩存的管理。
準備工作
在簡單梳理了一下要面對的問題后捉兴,下面開始兩級緩存的代碼實戰(zhàn)蝎困,我們整合號稱最強本地緩存的Caffeine
作為一級緩存、性能之王的Redis
作為二級緩存倍啥。首先建一個springboot項目禾乘,引入緩存要用到的相關(guān)的依賴:
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
<version>2.9.2</version>
</dependency>
<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>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
<version>2.8.1</version>
</dependency>
在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
和序列化方式始藕,這一過程比較簡單就不貼出代碼了,有需要本文全部示例代碼的可以在文末獲取氮趋。
下面我們在單機環(huán)境下伍派,將按照對業(yè)務(wù)侵入性的不同程度,分三個版本來實現(xiàn)兩級緩存的使用剩胁。
V1.0版本
我們可以通過手動操作Caffeine
中的Cache
對象來緩存數(shù)據(jù)诉植,它是一個類似Map
的數(shù)據(jù)結(jié)構(gòu),以key
作為索引昵观,value
存儲數(shù)據(jù)晾腔。在使用Cache
前,需要先配置一下相關(guān)參數(shù):
@Configuration
public class CaffeineConfig {
@Bean
public Cache<String,Object> caffeineCache(){
return Caffeine.newBuilder()
.initialCapacity(128)//初始大小
.maximumSize(1024)//最大數(shù)量
.expireAfterWrite(60, TimeUnit.SECONDS)//過期時間
.build();
}
}
簡單解釋一下Cache
相關(guān)的幾個參數(shù)的意義:
-
initialCapacity
:初始緩存空大小 -
maximumSize
:緩存的最大數(shù)量啊犬,設(shè)置這個值可以避免出現(xiàn)內(nèi)存溢出 -
expireAfterWrite
:指定緩存的過期時間灼擂,是最后一次寫操作后的一個時間,這里
此外觉至,緩存的過期策略也可以通過expireAfterAccess
或refreshAfterWrite
指定缤至。
在創(chuàng)建完成Cache
后,我們就可以在業(yè)務(wù)代碼中注入并使用它了。在沒有使用任何緩存前领斥,一個只有簡單的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<Order>()
.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
進行改造细层,在執(zhí)行正常業(yè)務(wù)外再加上操作兩級緩存的代碼,先看改造后的查詢操作:
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<Order>()
.eq(Order::getId, id));
redisTemplate.opsForValue().set(k, myOrder, 120, TimeUnit.SECONDS);
return myOrder;
});
return order;
}
在Cache
的get
方法中唬涧,會先從緩存中進行查找疫赎,如果找到緩存的值那么直接返回。如果沒有找到則執(zhí)行后面的方法碎节,并把結(jié)果加入到緩存中捧搞。
因此上面的邏輯就是先查找Caffeine
中的緩存狮荔,沒有的話查找Redis
,Redis
再不命中則查詢數(shù)據(jù)庫殖氏,寫入Redis
緩存的操作需要手動寫入晚树,而Caffeine
的寫入由get
方法自己完成。
在上面的例子中婚瓜,設(shè)置Caffeine
的過期時間為60秒宝鼓,而Redis
的過期時間為120秒,下面進行測試闰渔,首先看第一次接口調(diào)用時席函,進行了數(shù)據(jù)庫的查詢:
[圖片上傳失敗...(image-c5c312-1648427675508)]
而在之后60秒內(nèi)訪問接口時铐望,都沒有打印打任何sql或自定義的日志內(nèi)容冈涧,說明接口沒有查詢Redis
或數(shù)據(jù)庫,直接從Caffeine
中讀取了緩存正蛙。
等到距離第一次調(diào)用接口進行緩存的60秒后督弓,再次調(diào)用接口:
[圖片上傳失敗...(image-969d83-1648427675508)]
可以看到這時從Redis
中讀取了數(shù)據(jù),因為這時Caffeine
中的緩存已經(jīng)過期了乒验,但是Redis
中的緩存沒有過期仍然可用愚隧。
下面再來看一下修改操作,代碼在原先的基礎(chǔ)上添加了手動修改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);
}
看一下下面圖中接口的調(diào)用锻全、以及緩存的刷新過程狂塘÷济海可以看到在更新數(shù)據(jù)后,同步刷新了緩存中的內(nèi)容荞胡,再之后的訪問接口時不查詢數(shù)據(jù)庫妈踊,也可以拿到正確的結(jié)果:
[圖片上傳失敗...(image-83c741-1648427675508)]
最后再來看一下刪除操作,在刪除數(shù)據(jù)的同時泪漂,手動移除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);
}
我們在刪除某個緩存后廊营,再次調(diào)用之前的查詢接口時,又會出現(xiàn)重新查詢數(shù)據(jù)庫的情況:
[圖片上傳失敗...(image-7bd156-1648427675508)]
簡單的演示到此為止萝勤,可以看到上面這種使用緩存的方式露筒,雖然看起來沒什么大問題,但是對代碼的入侵性比較強敌卓。在業(yè)務(wù)處理的過程中要由我們頻繁的操作兩級緩存慎式,會給開發(fā)人員帶來很大負擔(dān)。那么假哎,有什么方法能夠簡化這一過程呢瞬捕?我們看看下一個版本。
V2.0版本
在spring
項目中舵抹,提供了CacheManager
接口和一些注解肪虎,允許讓我們通過注解的方式來操作緩存。先來看一下常用幾個注解說明:
-
@Cacheable
:根據(jù)鍵從緩存中取值惧蛹,如果緩存存在扇救,那么獲取緩存成功之后,直接返回這個緩存的結(jié)果香嗓。如果緩存不存在迅腔,那么執(zhí)行方法,并將結(jié)果放入緩存中靠娱。 -
@CachePut
:不管之前的鍵對應(yīng)的緩存是否存在沧烈,都執(zhí)行方法,并將結(jié)果強制放入緩存 -
@CacheEvict
:執(zhí)行完方法后像云,會移除掉緩存中的數(shù)據(jù)锌雀。
如果要使用上面這幾個注解管理緩存的話,我們就不需要配置V1版本中的那個類型為Cache
的Bean
了迅诬,而是需要配置spring
中的CacheManager
的相關(guān)參數(shù)腋逆,具體參數(shù)的配置和之前一樣:
@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<Order>()
.eq(Order::getId, id));
redisTemplate.opsForValue().set(key,myOrder,120, TimeUnit.SECONDS);
return myOrder;
}
@Cacheable
注解的屬性多達9個上遥,好在我們?nèi)粘J褂脮r只需要配置兩個常用的就可以了。其中value
和cacheNames
互為別名關(guān)系争涌,表示當前方法的結(jié)果會被緩存在哪個Cache
上露该,應(yīng)用中通過cacheName
來對Cache
進行隔離,每個cacheName
對應(yīng)一個Cache
實現(xiàn)第煮。value
和cacheNames
可以是一個數(shù)組解幼,綁定多個Cache
。
而另一個重要屬性key
包警,用來指定緩存方法的返回結(jié)果時對應(yīng)的key
撵摆,這個屬性支持使用SpringEL
表達式。通常情況下害晦,我們可以使用下面幾種方式作為key
:
#參數(shù)名
#參數(shù)對象.屬性名
#p參數(shù)對應(yīng)下標
在上面的代碼中特铝,我們看到添加了@Cacheable
注解后,在代碼中只需要保留原有的業(yè)務(wù)處理邏輯和操作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版本的代碼有一點區(qū)別殴俱,在之前的更新操作方法中政冻,是沒有返回值的void
類型,但是這里需要修改返回值的類型线欲,否則會緩存一個空對象到緩存中對應(yīng)的key
上明场。當下次執(zhí)行查詢操作時,會直接返回空對象給調(diào)用方李丰,而不會執(zhí)行方法中查詢數(shù)據(jù)庫或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
相關(guān)的注解踱讨,對V1版本的代碼經(jīng)過改進后魏蔗,可以把全手動操作兩級緩存的強入侵代碼方式砍的,改進為本地緩存交給spring
管理痹筛,Redis
緩存手動修改的半入侵方式。那么,還能進一步改造帚稠,使之成為對業(yè)務(wù)代碼完全無入侵的方式嗎谣旁?
V3.0版本
模仿spring
通過注解管理緩存的方式,我們也可以選擇自定義注解滋早,然后在切面中處理緩存榄审,從而將對業(yè)務(wù)代碼的入侵降到最低。
首先定義一個注解杆麸,用于添加在需要操作緩存的方法上:
@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
為可以設(shè)置的二級緩存Redis
的過期時間昔头,type
是一個枚舉類型的變量饼问,表示操作緩存的類型,枚舉類型定義如下:
public enum CacheType {
FULL, //存取
PUT, //只存
DELETE //刪除
}
因為要使key
支持springEl
表達式揭斧,所以需要寫一個方法莱革,使用表達式解析器解析參數(shù):
public static String parse(String elString, TreeMap<String,Object> map){
elString=String.format("#{%s}",elString);
//創(chuàng)建表達式解析器
ExpressionParser parser = new SpelExpressionParser();
//通過evaluationContext.setVariable可以在上下文中設(shè)定變量。
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;
}
參數(shù)中的elString
對應(yīng)的就是注解中key
的值盅视,map
是將原方法的參數(shù)封裝后的結(jié)果。簡單進行一下測試:
public void test() {
String elString="#order.money";
String elString2="#user";
String elString3="#p0";
TreeMap<String,Object> map=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);
}
執(zhí)行結(jié)果如下旦万,可以看到支持按照參數(shù)名稱闹击、參數(shù)對象的屬性名稱讀取,但是不支持按照參數(shù)下標讀取成艘,暫時留個小坑以后再處理拇砰。
123.0
Hydra
null
至于Cache
相關(guān)參數(shù)的配置,我們沿用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();
TreeMap<String, Object> treeMap = 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;
}
}
切面中主要做了下面幾件工作:
- 通過方法的參數(shù)丹莲,解析注解中
key
的springEl
表達式光坝,組裝真正緩存的key
- 根據(jù)操作緩存的類型,分別處理存取甥材、只存盯另、刪除緩存操作
- 刪除和強制更新緩存的操作,都需要執(zhí)行原方法洲赵,并進行相應(yīng)的緩存刪除或更新操作
- 存取操作前鸳惯,先檢查緩存中是否有數(shù)據(jù)商蕴,如果有則直接返回,沒有則執(zhí)行原方法芝发,并將結(jié)果存入緩存
修改Service
層代碼绪商,代碼中只保留原有業(yè)務(wù)代碼,再添加上我們自定義的注解就可以了:
@DoubleCache(cacheName = "order", key = "#id",
type = CacheType.FULL)
public Order getOrderById(Long id) {
Order myOrder = orderMapper.selectOne(new LambdaQueryWrapper<Order>()
.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
的代碼也瞬間清爽了很多,讓我們可以繼續(xù)專注于業(yè)務(wù)邏輯處理独悴,而不用費心去操作兩級緩存了例书。
總結(jié)
本文按照對業(yè)務(wù)入侵的遞減程度,依次介紹了三種管理兩級緩存的方法刻炒。至于在項目中是否需要使用二級緩存雾叭,需要考慮自身業(yè)務(wù)情況,如果Redis這種遠程緩存已經(jīng)能夠滿足你的業(yè)務(wù)需求落蝙,那么就沒有必要再使用本地緩存了织狐。畢竟實際使用起來遠沒有那么簡單,本文中只是介紹了最基礎(chǔ)的使用筏勒,實際中的并發(fā)問題移迫、事務(wù)的回滾問題都需要考慮,還需要思考什么數(shù)據(jù)適合放在一級緩存管行、什么數(shù)據(jù)適合放在二級緩存等等的其他問題厨埋。
那么,這次的分享就到這里捐顷,我是Hydra荡陷,下期見。
本文的全部代碼示例已傳到了Hydra的Github上迅涮,公眾號
碼農(nóng)參上
后臺回復(fù)緩存獲取鏈接
作者簡介废赞,碼農(nóng)參上,一個熱愛分享的公眾號叮姑,有趣唉地、深入、直接传透,與你聊聊技術(shù)耘沼。個人微信DrHydra9,歡迎添加好友朱盐,進一步交流群嗤。