3、缓存清除cacheEvict

3.1 基本原理

在实际应用中,缓存并非是一成不变的,我们写入缓存之后还需要更新缓存。这常常存在于一些更新操作和删除操作中。

例如我们查询了一个产品列表的第一页,之后缓存写入了这一页,在缓存中我们有这样一个映射:
key : DAWN-CACHE.cn.hengyumo.dawn.example.service.ProductService.searchProduct.size=5&page=0

value:

{
    "code": 200,
    "message": "请求成功",
    "data": {
        "total": 625000,
        "list": [
            {
                "id": 1,
                "name": "Aa1",
                "createTime": "2021-03-02T01:01:01.357+08:00",
                "price": 1
            },
            {
                "id": 2,
                "name": "Aa2",
                "createTime": "2021-04-03T02:02:02.357+08:00",
                "price": 2
            },
            {
                "id": 3,
                "name": "Aa3",
                "createTime": "2021-05-04T03:03:03.357+08:00",
                "price": 3
            },
            {
                "id": 4,
                "name": "Aa4",
                "createTime": "2021-06-05T04:04:04.357+08:00",
                "price": 4
            },
            {
                "id": 5,
                "name": "Aa5",
                "createTime": "2021-07-06T05:05:05.357+08:00",
                "price": 5
            }
        ],
        "pageNum": 0,
        "pageSize": 5,
        "size": 5,
        "startRow": 1,
        "endRow": 5,
        "pages": 125000,
        "prePage": 0,
        "nextPage": 1,
        "isFirstPage": false,
        "isLastPage": false,
        "hasPreviousPage": false,
        "hasNextPage": true,
        "navigatePages": 8,
        "navigatepageNums": [
            1,
            2,
            3,
            4,
            5,
            6,
            7,
            8
        ],
        "navigateFirstPage": 1,
        "navigateLastPage": 8
    }
}

之后我们更新了id为1的产品,将其名称修改为“PRODUCT-B”,那么我们需要保证再次查询第一页时,能正确的反映我们的修改操作。

这时有两种思路,一种是更新key : DAWN-CACHE.cn.hengyumo.dawn.example.service.ProductService.searchProduct.size=5&page=0对应的缓存,将其id为1的产品的名称修改过来。

另一种就是直接清除该缓存,之后查询时发现没有缓存就会查询数据库,从而保证获得到的是最新的数据。

更新缓存明显逻辑更为复杂,而清除缓存的操作更为简单、不容易出错,因此我们大部分情况下选择清除缓存,而不是更新缓存。

类比上一节提到的@DawnCacheable注解,我们实现一个@DawnCacheEvict注解,用来实现清除缓存的功能,如下:

@DawnCacheEvict(name = "evictSearchProduct", method = "searchProduct", cacheType = DawnCacheType.REDIS)
 public Product updateProduct(Product product) {
    Product productFound = findProductById(product.getId());
    try {
        ClassUtil.coverNotNullProperties(productFound, product);
    } catch (IllegalAccessException e) {
        e.printStackTrace();
    }
    return productFound;
}

当这个方法被调用之后,在 @DawnCacheEvict 的注解声明作用下,它会自动的删除我们的searchProduct方法在Redis中产生的缓存。

从而使得下一次查询分页时从数据库查询,然后再写入新的缓存。

这个注解声明如下:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DawnCacheEvict {

    @AliasFor("name")
    String value() default "";

    /**
     * 命名
     */
    @AliasFor("value")
    String name() default "";

    /**
     * 写入缓存的方法
     *
     * @return 方法名
     */
    String method() default "";

    /**
     * 缓存的key生成类
     *
     * 默认是{@link DawnDefaultCacheKeyCreator}
     * @return DawnCacheKeyCreator
     */
    Class<? extends DawnCacheKeyCreator> cacheKeyCreator() default DawnDefaultCacheKeyCreator.class;

    /**
     * 使用的缓存方式
     *
     * @return 默认是 DawnCacheType.DEFAULT
     */
    DawnCacheType cacheType() default DawnCacheType.DEFAULT;
}

3.2 扩展cacheKeyCreator

在上节中,我们描述了cacheKeyCreator的设计:

cacheKeyCreator其设计的思路来自于mybatis的SqlProvider,这种通过实现一个class的方式来完成注解的某一块功能,具有灵活和易于阅读的优点

为了使其也支持生成要被清除的缓存前缀,我们给这个接口加上了一个方法:

/**
     * 生成要清除的缓存的KEY前缀
     * 
     * @param cacheMethodName 缓存方法名
     * @param request   请求
     * @param target    注解的方法的所在对象
     * @param method    方法携带的参数Map<String,Object>
     * @param params    Key 字符串
     * @return 要清除的缓存的KEY前缀
     */
    String createCacheEvictKey(String cacheMethodName, HttpServletRequest request, Object target, Method method, Map<String, Object> params);

其目的是在@DawnCacheEvict这个注解中通过cacheKeyCreator生成要清除的缓存的KEY前缀,之后就可以针对性的清除这些缓存,相比createCacheKey方法,多了的一个参数是清除的目标缓存方法的方法名。

完整的cacheKeyCreator接口要求如下:

public interface DawnCacheKeyCreator {

    /**
     * 生成KEY
     *
     * @param request 请求
     * @param target  注解的方法的所在对象
     * @param method  注解的方法
     * @param params  方法携带的参数Map<String,Object>
     * @return Key 字符串
     */
    String createCacheKey(HttpServletRequest request, Object target, Method method, Map<String, Object> params);

    /**
     * 生成要清除的缓存的KEY前缀
     *
     * @param cacheMethodName 缓存方法名
     * @param request   请求
     * @param target    注解的方法的所在对象
     * @param method    方法携带的参数Map<String,Object>
     * @param params    Key 字符串
     * @return 要清除的缓存的KEY前缀
     */
    String createCacheEvictKey(String cacheMethodName, HttpServletRequest request, Object target, Method method, Map<String, Object> params);
}

同样的扩展了DawnDefaultCacheKeyCreator这个默认的Key生成类,以支持生成清除的Key前缀:

public class DawnDefaultCacheKeyCreator implements DawnCacheKeyCreator {

    public final static String CACHE_PER = "DAWN-CACHE";
 
    @Override
    public String createCacheKey(HttpServletRequest request, Object target, Method method, Map<String, Object> params) {
        String queryString = request.getQueryString();
        String targetClassName = target.getClass().getName();
        String methodName = method.getName();
        if (StringUtil.isEmpty(queryString) && params != null && params.size() > 0) {
            queryString = JSON.toJSONString(params);
        }
        return String.format("%s.%s.%s.%s", CACHE_PER, targetClassName, methodName , queryString);
    }
 
    @Override
    public String createCacheEvictKey(String cacheMethodName, HttpServletRequest request, Object target, Method method, Map<String, Object> params) {
        String targetClassName = target.getClass().getName();
        return String.format("%s.%s.%s", CACHE_PER, targetClassName, cacheMethodName);
    }
}

createCacheKey它的key生成规则是,取 缓存前缀 DAWN-CACHE加上 目标对象的类名加上 目标方法的方法名加上url后边的请求参数或方法参数来生成的。

因此用createCacheKey 生成的key拥有一个固定的前缀,那就是 缓存前缀 DAWN-CACHE加上 目标对象的类名加上 目标方法的方法名,我们的createCacheEvictKey就只需要生成这样一个字符串,这便是我们需要清除的那些缓存的前缀。

例如:DAWN-CACHE.cn.hengyumo.dawn.example.service.ProductService.searchProduct.size=5&page=0DAWN-CACHE.cn.hengyumo.dawn.example.service.ProductService.searchProduct.size=5&page=1,这两个Key分别代表的是分页查询第一页、第二页的缓存Key,那么它们都拥有DAWN-CACHE.cn.hengyumo.dawn.example.service.ProductService.searchProduct这样一个前缀。

在@DawnCacheEvict注解中我们配置:

@DawnCacheEvict(name = "evictSearchProduct", method = "searchProduct", cacheType = DawnCacheType.REDIS)

那么它就会清除所有searchProduct这个方法产生的缓存,达到我们需要的效果。

3.3 清除多个不同方法产生的缓存

在实际使用中,对于同一份数据我们可能有多个缓存:
例如:

// 根据ID查询产品
    @DawnCacheable(sync = false, cacheKeyCreator = ProductIdCacheKeyCreator.class)
    public Product getProductById(Long id) 

	// 获取产品的最高价格
    @DawnCacheable(cacheType = DawnCacheType.SIMPLE, expire = 30)
    public BigDecimal getProductTopPrice()
    
    // 分页查询产品
    @DawnCacheable(cacheType = DawnCacheType.REDIS, expire = 1, cacheTimeUnit = TimeUnit.MINUTES)
    public PageInfo<Product> searchProduct(ProductSearchDto productSearchDto, Pageable pageable)

如果我们更新某个产品之后,需要清除缓存则需要同时清除这三个方法产生的缓存。

所以增加了一个注解,用来实现这个功能:

@DawnCacheEvicts(
            name = "evictProduct",
            dawnCacheEvicts = {
                    @DawnCacheEvict(name = "evictSearchProduct", method = "searchProduct", cacheType = DawnCacheType.REDIS),
                    @DawnCacheEvict(name = "evictGetProductById", cacheKeyCreator = ProductIdCacheKeyCreator.class),
                    @DawnCacheEvict(name = "evictGetProductTopPrice", method = "getProductTopPrice", cacheType = DawnCacheType.SIMPLE)
            })
    public Product updateProduct(Product product)

这个注解中包含了多个 @DawnCacheEvict 子注解,定义如下:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface DawnCacheEvicts {

    @AliasFor("name")
    String value() default "";

    /**
     * 命名
     */
    @AliasFor("value")
    String name() default "";

    /**
     * 清除缓存的规则列表
     *
     * @return DawnCacheEvict[]
     */
    DawnCacheEvict[] dawnCacheEvicts() default {};
}

这样我们在更新产品之后,就会清除掉上文那三个方法产生的缓存。

3.4 别名机制

在日常的使用中,我们可能会出现多个更新或者删除的方法都需要清除同一个缓存,例如:

// 更新产品
@DawnCacheEvicts(
            name = "evictProduct",
            dawnCacheEvicts = {
                    @DawnCacheEvict(name = "evictSearchProduct", method = "searchProduct", cacheType = DawnCacheType.REDIS),
                    @DawnCacheEvict(name = "evictGetProductById", cacheKeyCreator = ProductIdCacheKeyCreator.class),
                    @DawnCacheEvict(name = "evictGetProductTopPrice", method = "getProductTopPrice", cacheType = DawnCacheType.SIMPLE)
            })
public Product updateProduct(Product product)

// 删除产品
@DawnCacheEvicts("evictProduct")
public void deleteProduct(Long id)

// 添加产品
@DawnCacheEvicts(dawnCacheEvicts = {
        @DawnCacheEvict("evictSearchProduct"),
        @DawnCacheEvict("evictGetProductTopPrice")
})
public Product addProduct(Product product)

其中,更新产品和删除产品,我们需要清除三个缓存,(1)分页查询的缓存,(2)按Id查询的缓存,(3)获取产品最高价格的缓存

添加产品时,我们需要清除两个缓存,(1)分页查询的缓存,(2)获取产品最高价格的缓存

这时候别名就起作用了,它可以让我们少写代码,并且修改规则时只需要改一个地方。

别名的实现原理主要是以下这个方法:

private final static Map<String, Map<String, DawnCacheEvict[]>> EVICTS_CACHE
            = new ConcurrentHashMap<>();
            
    /**
     * 生成目标对象的缓存清除规则的命名缓存
     * 
     * @param target 目标对象的类型
     */
    private void cacheDawnCacheEvictsForTarget(Class<?> target) {
        Method[] methods = target.getMethods();
        String targetClassName = target.getName();
        Map<String, DawnCacheEvict[]> map = new HashMap<>();
        // 反射遍历这个对象的所有方法,然后遍历方法上的
        for (Method method : methods) {
            DawnCacheEvicts dawnCacheEvicts = AnnotationUtils.getAnnotation(method, DawnCacheEvicts.class);
            DawnCacheEvict dawnCacheEvict = AnnotationUtils.getAnnotation(method, DawnCacheEvict.class);
            if (dawnCacheEvicts != null) {
                String dawnCacheEvictsName = dawnCacheEvicts.name();
                DawnCacheEvict[] children = dawnCacheEvicts.dawnCacheEvicts();
                if (children.length > 0) {
                    for (DawnCacheEvict child : children) {
                        cacheDawnCacheEvict(map, child, targetClassName);
                    }
                    if (StringUtil.isNotEmpty(dawnCacheEvictsName)) {
                        map.put(String.format("%s.%s",
                                targetClassName, dawnCacheEvictsName), children);
                    }
                }
            }
            if (dawnCacheEvict != null) {
                cacheDawnCacheEvict(map, dawnCacheEvict, targetClassName);
            }
        }
        EVICTS_CACHE.put(targetClassName, map);
    }

    /**
     * 如果该缓存清除规则名字不为空,并且不是只配置了名字,则将其放入缓存Map中
     * 
     * @param map 保存这个对象的缓存清除规则
     * @param dawnCacheEvict 缓存清除规则注解
     * @param targetClassName 目标对象的类名
     */
    private void cacheDawnCacheEvict(Map<String, DawnCacheEvict[]> map,
                                     DawnCacheEvict dawnCacheEvict, String targetClassName) {
        String name = dawnCacheEvict.name();
        boolean isNotConfig = dawnCacheEvictIsNotConfig(dawnCacheEvict);
        if (StringUtil.isNotEmpty(name) && ! isNotConfig) {
            map.put(String.format("%s.%s", targetClassName, name),
                    new DawnCacheEvict[]{dawnCacheEvict});
        }
    }
    
    /**
     * 判断该缓存清除规则是否是未配置
     * 
     * @param dawnCacheEvict 缓存清除规则注解
     * @return true是未配置
     */
    private boolean dawnCacheEvictIsNotConfig(DawnCacheEvict dawnCacheEvict) {
        return dawnCacheEvict.cacheKeyCreator().equals(DawnDefaultCacheKeyCreator.class)
                && StringUtil.isEmpty(dawnCacheEvict.method())
                && dawnCacheEvict.cacheType().equals(DawnCacheType.DEFAULT);
    }

之后我们就可以通过缓存清除规则的名称尝试去Map里查找对应的缓存清除规则:

/**
     * 获取对于名称的缓存清除规则,如果没有初始化该类的缓存清除规则,那么会先进行初始化
     * 
     * @param target 目标类
     * @param evictName 缓存清除规则
     * @return 缓存清除规则列表DawnCacheEvict[]
     */
    private DawnCacheEvict[] getDawnCacheEvictsForTarget(Class<?> target, String evictName) {
        String targetClassName = target.getName();
        if (!EVICTS_CACHE.containsKey(targetClassName)) {
            synchronized (DawnCacheAspect.class) {
                if (!EVICTS_CACHE.containsKey(targetClassName)) {
                    cacheDawnCacheEvictsForTarget(target);
                }
            }
        }
        DawnCacheEvict[] dawnCacheEvicts = EVICTS_CACHE.get(targetClassName)
                .get(String.format("%s.%s", targetClassName, evictName));
        if (dawnCacheEvicts == null || dawnCacheEvicts.length == 0) {
            log.warn("{} 无法找到对应名称的缓存清除注解!", evictName);
        }
        return dawnCacheEvicts;
    }

END(2)