阿里云-云小站(无限量代金券发放中)
【腾讯云】云服务器、云数据库、COS、CDN、短信等热卖云产品特惠抢购

如何简单高效的在代码中实现两级缓存的管理

283次阅读
没有评论

共计 9459 个字符,预计需要花费 24 分钟才能阅读完成。

导读 缓存的过期时间、过期策略以及多线程访问的问题也都需要考虑进去,不过我们今天暂时先不考虑这些问题,先看一下如何简单高效的在代码中实现两级缓存的管理。

在高性能的服务架构设计中,缓存是一个不可或缺的环节。在实际的项目中,我们通常会将一些热点数据存储到 Redis 或 MemCache 这类缓存中间件中,只有当缓存的访问没有命中时再查询数据库。在提升访问速度的同时,也能降低数据库的压力。

随着不断的发展,这一架构也产生了改进,在一些场景下可能单纯使用 Redis 类的远程缓存已经不够了,还需要进一步配合本地缓存使用,例如 Guava cache 或 Caffeine,从而再次提升程序的响应速度与服务性能。于是,就产生了使用本地缓存作为一级缓存,再加上远程缓存作为二级缓存的两级缓存架构。

在先不考虑并发等复杂问题的情况下,两级缓存的访问流程可以用下面这张图来表示:

如何简单高效的在代码中实现两级缓存的管理

优点与问题

那么,使用两级缓存相比单纯使用远程缓存,具有什么优势呢?

本地缓存基于本地环境的内存,访问速度非常快,对于一些变更频率低、实时性要求低的数据,可以放在本地缓存中,提升访问速度;
使用本地缓存能够减少和 Redis 类的远程缓存间的数据交互,减少网络 I / O 开销,降低这一过程中在网络通信上的耗时;
但是在设计中,还是要考虑一些问题的,例如数据一致性问题。首先,两级缓存与数据库的数据要保持一致,一旦数据发生了修改,在修改数据库的同时,本地缓存、远程缓存应该同步更新。

另外,如果是分布式环境下,一级缓存之间也会存在一致性问题,当一个节点下的本地缓存修改后,需要通知其他节点也刷新本地缓存中的数据,否则会出现读取到过期数据的情况,这一问题可以通过类似于 Redis 中的发布 / 订阅功能解决。

此外,缓存的过期时间、过期策略以及多线程访问的问题也都需要考虑进去,不过我们今天暂时先不考虑这些问题,先看一下如何简单高效的在代码中实现两级缓存的管理。

准备工作

在简单梳理了一下要面对的问题后,下面开始两级缓存的代码实战,我们整合号称最强本地缓存的 Caffeine 作为一级缓存、性能之王的 Redis 作为二级缓存。首先建一个 springboot 项目,引入缓存要用到的相关的依赖:


    com.github.ben-manes.caffeine
    caffeine
    2.9.2


    org.springframework.boot
    spring-boot-starter-data-redis


    org.springframework.boot
    spring-boot-starter-cache


    org.apache.commons
    commons-pool2
    2.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 和序列化方式,这一过程比较简单就不贴出代码了。

下面我们在单机环境下,将按照对业务侵入性的不同程度,分三个版本来实现两级缓存的使用。

V1.0 版本

我们可以通过手动操作 Caffeine 中的 Cache 对象来缓存数据,它是一个类似 Map 的数据结构,以 key 作为索引,value 存储数据。在使用 Cache 前,需要先配置一下相关参数:

@Configuration
public class CaffeineConfig {
    @Bean
    public Cache caffeineCache(){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);
    }

    我们在删除某个缓存后,再次调用之前的查询接口时,又会出现重新查询数据库的情况:

    如何简单高效的在代码中实现两级缓存的管理

    简单的演示到此为止,可以看到上面这种使用缓存的方式,虽然看起来没什么大问题,但是对代码的入侵性比较强。在业务处理的过程中要由我们频繁的操作两级缓存,会给开发人员带来很大负担。那么,有什么方法能够简化这一过程呢? 我们看看下一个版本。

    V2.0 版本

    在 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 缓存手动修改的半入侵方式。那么,还能进一步改造,使之成为对业务代码完全无入侵的方式吗?

    V3.0 版本

    模仿 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, TreeMap map){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";   
    
        TreeMap 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);
    }

    执行结果如下,可以看到支持按照参数名称、参数对象的属性名称读取,但是不支持按照参数下标读取,暂时留个小坑以后再处理。

    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();
            TreeMap treeMap = new TreeMap();
            for (int i = 0; i 
    

    切面中主要做了下面几件工作:

  • 通过方法的参数,解析注解中 key 的 springEl 表达式,组装真正缓存的 key。
  • 根据操作缓存的类型,分别处理存取、只存、删除缓存操作。
  • 删除和强制更新缓存的操作,都需要执行原方法,并进行相应的缓存删除或更新操作。
  • 存取操作前,先检查缓存中是否有数据,如果有则直接返回,没有则执行原方法,并将结果存入缓存。
  • 修改 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 这种远程缓存已经能够满足你的业务需求,那么就没有必要再使用本地缓存了。毕竟实际使用起来远没有那么简单,本文中只是介绍了最基础的使用,实际中的并发问题、事务的回滚问题都需要考虑,还需要思考什么数据适合放在一级缓存、什么数据适合放在二级缓存等等的其他问题。

    阿里云 2 核 2G 服务器 3M 带宽 61 元 1 年,有高配

    腾讯云新客低至 82 元 / 年,老客户 99 元 / 年

    代金券:在阿里云专用满减优惠券

    正文完
    星哥玩云-微信公众号
    post-qrcode
     0
    星锅
    版权声明:本站原创文章,由 星锅 于2024-07-25发表,共计9459字。
    转载说明:除特殊说明外本站文章皆由CC-4.0协议发布,转载请注明出处。
    【腾讯云】推广者专属福利,新客户无门槛领取总价值高达2860元代金券,每种代金券限量500张,先到先得。
    阿里云-最新活动爆款每日限量供应
    评论(没有评论)
    验证码
    【腾讯云】云服务器、云数据库、COS、CDN、短信等云产品特惠热卖中

    星哥玩云

    星哥玩云
    星哥玩云
    分享互联网知识
    用户数
    4
    文章数
    19351
    评论数
    4
    阅读量
    8015317
    文章搜索
    热门文章
    星哥带你玩飞牛NAS-6:抖音视频同步工具,视频下载自动下载保存

    星哥带你玩飞牛NAS-6:抖音视频同步工具,视频下载自动下载保存

    星哥带你玩飞牛 NAS-6:抖音视频同步工具,视频下载自动下载保存 前言 各位玩 NAS 的朋友好,我是星哥!...
    星哥带你玩飞牛NAS-3:安装飞牛NAS后的很有必要的操作

    星哥带你玩飞牛NAS-3:安装飞牛NAS后的很有必要的操作

    星哥带你玩飞牛 NAS-3:安装飞牛 NAS 后的很有必要的操作 前言 如果你已经有了飞牛 NAS 系统,之前...
    我把用了20年的360安全卫士卸载了

    我把用了20年的360安全卫士卸载了

    我把用了 20 年的 360 安全卫士卸载了 是的,正如标题你看到的。 原因 偷摸安装自家的软件 莫名其妙安装...
    再见zabbix!轻量级自建服务器监控神器在Linux 的完整部署指南

    再见zabbix!轻量级自建服务器监控神器在Linux 的完整部署指南

    再见 zabbix!轻量级自建服务器监控神器在 Linux 的完整部署指南 在日常运维中,服务器监控是绕不开的...
    飞牛NAS中安装Navidrome音乐文件中文标签乱码问题解决、安装FntermX终端

    飞牛NAS中安装Navidrome音乐文件中文标签乱码问题解决、安装FntermX终端

    飞牛 NAS 中安装 Navidrome 音乐文件中文标签乱码问题解决、安装 FntermX 终端 问题背景 ...
    阿里云CDN
    阿里云CDN-提高用户访问的响应速度和成功率
    随机文章
    星哥带你玩飞牛NAS-6:抖音视频同步工具,视频下载自动下载保存

    星哥带你玩飞牛NAS-6:抖音视频同步工具,视频下载自动下载保存

    星哥带你玩飞牛 NAS-6:抖音视频同步工具,视频下载自动下载保存 前言 各位玩 NAS 的朋友好,我是星哥!...
    星哥带你玩飞牛 NAS-10:备份微信聊天记录、数据到你的NAS中!

    星哥带你玩飞牛 NAS-10:备份微信聊天记录、数据到你的NAS中!

    星哥带你玩飞牛 NAS-10:备份微信聊天记录、数据到你的 NAS 中! 大家对「数据安全感」的需求越来越高 ...
    240 元左右!五盘位 NAS主机,7 代U硬解4K稳如狗,拓展性碾压同价位

    240 元左右!五盘位 NAS主机,7 代U硬解4K稳如狗,拓展性碾压同价位

      240 元左右!五盘位 NAS 主机,7 代 U 硬解 4K 稳如狗,拓展性碾压同价位 在 NA...
    开发者福利:免费 .frii.site 子域名,一分钟申请即用

    开发者福利:免费 .frii.site 子域名,一分钟申请即用

      开发者福利:免费 .frii.site 子域名,一分钟申请即用 前言 在学习 Web 开发、部署...
    我把用了20年的360安全卫士卸载了

    我把用了20年的360安全卫士卸载了

    我把用了 20 年的 360 安全卫士卸载了 是的,正如标题你看到的。 原因 偷摸安装自家的软件 莫名其妙安装...

    免费图片视频管理工具让灵感库告别混乱

    一言一句话
    -「
    手气不错
    开源MoneyPrinterTurbo 利用AI大模型,一键生成高清短视频!

    开源MoneyPrinterTurbo 利用AI大模型,一键生成高清短视频!

      开源 MoneyPrinterTurbo 利用 AI 大模型,一键生成高清短视频! 在短视频内容...
    零成本上线!用 Hugging Face免费服务器+Docker 快速部署HertzBeat 监控平台

    零成本上线!用 Hugging Face免费服务器+Docker 快速部署HertzBeat 监控平台

    零成本上线!用 Hugging Face 免费服务器 +Docker 快速部署 HertzBeat 监控平台 ...
    颠覆 AI 开发效率!开源工具一站式管控 30+大模型ApiKey,秘钥付费+负载均衡全搞定

    颠覆 AI 开发效率!开源工具一站式管控 30+大模型ApiKey,秘钥付费+负载均衡全搞定

      颠覆 AI 开发效率!开源工具一站式管控 30+ 大模型 ApiKey,秘钥付费 + 负载均衡全...
    星哥带你玩飞牛NAS-14:解锁公网自由!Lucky功能工具安装使用保姆级教程

    星哥带你玩飞牛NAS-14:解锁公网自由!Lucky功能工具安装使用保姆级教程

    星哥带你玩飞牛 NAS-14:解锁公网自由!Lucky 功能工具安装使用保姆级教程 作为 NAS 玩家,咱们最...
    浏览器自动化工具!开源 AI 浏览器助手让你效率翻倍

    浏览器自动化工具!开源 AI 浏览器助手让你效率翻倍

    浏览器自动化工具!开源 AI 浏览器助手让你效率翻倍 前言 在 AI 自动化快速发展的当下,浏览器早已不再只是...