• Halo 开源项目学习(七):缓存机制


    基本介绍

    我们知道,频繁操作数据库会降低服务器的系统性能,因此通常需要将频繁访问、更新的数据存入到缓存。Halo 项目也引入了缓存机制,且设置了多种实现方式,如自定义缓存、Redis、LevelDB 等,下面我们分析一下缓存机制的实现过程。

    自定义缓存

    1. 缓存的配置

    由于数据在缓存中以键值对的形式存在,且不同类型的缓存系统定义的存储和读取等操作都大同小异,所以本文仅介绍项目中默认的自定义缓存。自定义缓存指的是作者自己编写的缓存,以 ConcurrentHashMap 作为容器,数据存储在服务器的内存中。在介绍自定义缓存之前,我们先看一下 Halo 缓存的体系图:

    本人使用的 Halo 1.4.13 版本中并未设置 Redis 缓存,上图来自 1.5.2 版本。

    可以看到,作者的设计思路是在上层的抽象类和接口中定义通用的操作方法,而具体的缓存容器、数据的存储以及读取方法则是在各个实现类中定义。如果希望修改缓存的类型,只需要在配置类 HaloProperties 中修改 cache 字段的值:

    @Bean
    @ConditionalOnMissingBean
    AbstractStringCacheStore stringCacheStore() {
        AbstractStringCacheStore stringCacheStore;
        // 根据 cache 字段的值选择具体的缓存类型
        switch (haloProperties.getCache()) {
            case "level":
                stringCacheStore = new LevelCacheStore(this.haloProperties);
                break;
            case "redis":
                stringCacheStore = new RedisCacheStore(stringRedisTemplate);
                break;
            case "memory":
            default:
                stringCacheStore = new InMemoryCacheStore();
                break;
        }
        log.info("Halo cache store load impl : [{}]", stringCacheStore.getClass());
        return stringCacheStore;
    }
    

    上述代码来自 1.5.2 版本。

    cache 字段的默认值为 "memory",因此缓存的实现类为 InMemoryCacheStore(自定义缓存):

    public class InMemoryCacheStore extends AbstractStringCacheStore {
    
        /**
         * Cleaner schedule period. (ms)
         */
        private static final long PERIOD = 60 * 1000;
    
        /**
         * Cache container.
         */
        public static final ConcurrentHashMap<String, CacheWrapper<String>> CACHE_CONTAINER =
            new ConcurrentHashMap<>();
    
        private final Timer timer;
    
        /**
         * Lock.
         */
        private final Lock lock = new ReentrantLock();
    
        public InMemoryCacheStore() {
            // Run a cache store cleaner
            timer = new Timer();
            // 每 60s 清除一次过期的 key
            timer.scheduleAtFixedRate(new CacheExpiryCleaner(), 0, PERIOD);
        }
        // 省略部分代码
    }
    

    InMemoryCacheStore 成员变量的含义如下:

    1. CACHE_CONTAINER 是 InMemoryCacheStore 的缓存容器,类型为 ConcurrentHashMap。使用 ConcurrentHashMap 是为了保证线程安全,因为缓存中会存放缓存锁相关的数据(下文中介绍),每当用户访问后台的服务时,就会有新的数据进入缓存,这些数据可能来自于不同的线程,因此 CACHE_CONTAINER 需要考虑多个线程同时操作的情况。
    1. timer 负责执行周期任务,任务的执行频率为 PERIOD,默认为一分钟,周期任务的处理逻辑是清除缓存中已经过期的 key。

    2. lock 是 ReentrantLock 类型的排它锁,与缓存锁有关。

    2. 缓存中的数据

    缓存中存储的数据包括:

    1. 系统设置中的选项信息,其实就是 options 表中存储的数据。

    2. 已登录用户(博主)的 token。

    3. 已获得文章授权的客户端的 sessionId。

    4. 缓存锁相关的数据。

    在之前的文章中,我们介绍过 token 和 sessionId 的存储和获取,因此本文就不再赘述这一部分内容了,详见 Halo 开源项目学习(三):注册与登录Halo 开源项目学习(四):发布文章与页面。缓存锁我们在下一节再介绍,本节中我们先看看 Halo 如何保存 options 信息。

    首先需要了解一下 options 信息是什么时候存入到缓存中的,实际上,程序在启动后会发布 ApplicationStartedEvent 事件,项目中定义了负责监听 ApplicationStartedEvent 事件的监听器 StartedListener(listener 包下),该监听器在事件发布后会执行 initThemes 方法,下面是 initThemes 方法中的部分代码片段:

    private void initThemes() {
        // Whether the blog has initialized
        Boolean isInstalled = optionService
            .getByPropertyOrDefault(PrimaryProperties.IS_INSTALLED, Boolean.class, false);
        // 省略部分代码
    } 
    

    该方法会调用 getByPropertyOrDefault 方法从缓存中查询博客的安装状态,我们从 getByPropertyOrDefault 方法开始,沿着调用链向下搜索,可以追踪到 OptionProvideService 接口中的 getByKey 方法:

    default Optional<Object> getByKey(@NonNull String key) {
        Assert.hasText(key, "Option key must not be blank");
        // 如果 val = listOptions().get(key) 不为空, 返回 value 为 val 的 Optional 对象, 否则返回 value 为空的 Optional 对象
        return Optional.ofNullable(listOptions().get(key));
    }
    

    可以看到,重点是这个 listOptions 方法,该方法在 OptionServiceImpl 类中定义:

    public Map<String, Object> listOptions() {
        // Get options from cache
        // 从缓存 CACHE_CONTAINER 中获取 "options" 这个 key 对应的数据, 并将该数据转化为 Map 对象
        return cacheStore.getAny(OPTIONS_KEY, Map.class).orElseGet(() -> {
            // 初次调用时需要从 options 表中获取所有的 Option 对象
            List<Option> options = listAll();
            // 所有 Option 对象的 key 集合
            Set<String> keys = ServiceUtils.fetchProperty(options, Option::getKey);
    
            /*
                * options 表中存储的记录其实就是用户自定义的 Option 选项, 当用户修改博客设置时, 会自动更新 options 表,
                * Halo 中对一些选项的 value 设置了确定的类型, 例如 EmailProperties 这个类中的 HOST 为 String 类型, 而
                * SSL_PORT 则为 Integer 类型, 由于 Option 类中 value 一律为 String 类型, 因此需要将某些 value 转化为指
                * 定的类型
                */
            Map<String, Object> userDefinedOptionMap =
                ServiceUtils.convertToMap(options, Option::getKey, option -> {
                    String key = option.getKey();
    
                    PropertyEnum propertyEnum = propertyEnumMap.get(key);
    
                    if (propertyEnum == null) {
                        return option.getValue();
                    }
                    // 对 value 进行类型转换
                    return PropertyEnum.convertTo(option.getValue(), propertyEnum);
                });
    
            Map<String, Object> result = new HashMap<>(userDefinedOptionMap);
    
            // Add default property
            /*
                * 有些选项是 Halo 默认设定的, 例如 EmailProperties 中的 SSL_PORT, 用户未设置时, 它也会被设定为默认的 465,
                * 同样, 也需要将默认的 "465" 转化为 Integer 类型的 465
                */
            propertyEnumMap.keySet()
                .stream()
                .filter(key -> !keys.contains(key))
                .forEach(key -> {
                    PropertyEnum propertyEnum = propertyEnumMap.get(key);
    
                    if (StringUtils.isBlank(propertyEnum.defaultValue())) {
                        return;
                    }
                    // 对 value 进行类型转换并存入 result
                    result.put(key,
                        PropertyEnum.convertTo(propertyEnum.defaultValue(), propertyEnum));
                });
    
            // Cache the result
            // 将所有的选项加入缓存
            cacheStore.putAny(OPTIONS_KEY, result);
    
            return result;
        });
    }
    

    服务器首先从 CACHE_CONTAINER 中获取 "options" 这个 key 对应的数据,然后将该数据转化为 Map 类型的对象。由于初次查询时 CACHE_CONTAINER 中 并没有 "options" 对应的 value,因此需要进行初始化:

    1. 首先从 options 表中获取所有的 Option 对象,并将这些对象存入到 Map 中。其中 key 和 value 均为 Option 对象中的 key 和 value,但 value 还需要进行一个类型转换,因为在 Option 类中 value 被定义为了 String 类型。例如,"is_installed" 对应的 value 为 "true",为了能够正常使用 value,需要将字符串 "true" 转化成 Boolean 类型的 true。结合上下文,我们发现程序是根据 PrimaryProperties 类(继承 PropertyEnum 的枚举类)中定义的枚举对象 IS_INSTALLED("is_installed", Boolean.class, "false") 来确认目标类型 Boolean 的。

    2. options 表中的选项是用户自定义的选项,除此之外,Halo 中还设置了一些默认的选项,这些选项均在 PropertyEnum 的子类中定义,例如 EmailProperties 类中的 SSL_PORT("email_ssl_port", Integer.class, "465"),其对应的 key 为 "email_ssl_port",value 为 "465"。服务器也会将这些 key - value 对存入到 Map,并对 value 进行类型转换。

    以上便是 listOptions 方法的处理逻辑,我们回到 getByKey 方法,当获取到 listOptions 方法返回的 Map 对象后,服务器可以根据指定的 key(如 "is_installed")获取到对应的属性值(如 true)。当用户在管理员后台修改博客的系统设置时,服务器会根据用户的配置更新 options 表,并发布 OptionUpdatedEvent 事件,之后负责处理事件的监听器会将缓存中的 "options" 删除,下次查询时再根据上述步骤执行初始化操作(详见 FreemarkerConfigAwareListener 中的 onOptionUpdate 方法)。

    3. 缓存的过期处理

    缓存的过期处理是一个非常重要的知识点,数据过期后,通常需要将其从缓存中删除。从上文中的 cacheStore.putAny(OPTIONS_KEY, result) 方法中我们得知,服务器将数据存储到缓存之前,会先将其封装成 CacheWrapper 对象:

    class CacheWrapper<V> implements Serializable {
    
        /**
         * Cache data
         */
        private V data;
    
        /**
         * Expired time.
         */
        private Date expireAt;
    
        /**
         * Create time.
         */
        private Date createAt;
    }
    

    其中 data 是需要存储的数据,createAt 和 expireAt 分别是数据的创建时间和过期时间。Halo 项目中,"options" 是没有过期时间的,只有当数据更新时,监听器才会将旧的数据删除。需要注意的是,token 和 sessionId 均有过期时间,对于有过期时间的 key,项目中也有相应的处理办法。以 token 为例,拦截器拦截到用户的请求后会确认用户的身份,也就是查询缓存中是否具有 token 对应的用户 id,这个查询操作的底层调用的是 get 方法(在 AbstractCacheStore 类中定义):

    public Optional<V> get(K key) {
        Assert.notNull(key, "Cache key must not be blank");
    
        return getInternal(key).map(cacheWrapper -> {
            // Check expiration
            // 过期
            if (cacheWrapper.getExpireAt() != null
                && cacheWrapper.getExpireAt().before(run.halo.app.utils.DateUtils.now())) {
                // Expired then delete it
                log.warn("Cache key: [{}] has been expired", key);
    
                // Delete the key
                delete(key);
    
                // Return null
                return null;
            }
            // 未过期返回缓存数据
            return cacheWrapper.getData();
        });
    }
    

    服务器获取到 key 对应的 CacheWrapper 对象后,会检查其中的过期时间,如果数据已过期,那么直接将其删除并返回 null。另外,上文中提到,timer(InMemoryCacheStore 的成员变量)的周期任务也负责删除过期的数据,下面是 timer 周期任务执行的方法:

    private class CacheExpiryCleaner extends TimerTask {
    
        @Override
        public void run() {
            CACHE_CONTAINER.keySet().forEach(key -> {
                if (!InMemoryCacheStore.this.get(key).isPresent()) {
                    log.debug("Deleted the cache: [{}] for expiration", key);
                }
            });
        }
    }
    

    可见,周期任务也是通过调用 get 方法来删除过期数据的。

    缓存锁

    Halo 项目中的缓存锁也是一个比较有意思的模块,其作用是限制用户对某个功能的调用频率,可认为是对请求的方法进行加锁。缓存锁主要利用自定义注解 @CacheLock 和 AOP 来实现,@CacheLock 注解的定义如下:

    @Target(ElementType.METHOD)
    @Retention(RetentionPolicy.RUNTIME)
    @Documented
    @Inherited
    public @interface CacheLock {
    
        @AliasFor("value")
        String prefix() default "";
    
    
        @AliasFor("prefix")
        String value() default "";
    
    
        long expired() default 5;
    
    
        TimeUnit timeUnit() default TimeUnit.SECONDS;
    
    
        String delimiter() default ":";
    
    
        boolean autoDelete() default true;
    
    
        boolean traceRequest() default false;
    }
    

    各个成员变量的含义为:

    • prefix:用于构建 cacheLockKey(一个字符串)的前缀。

    • value:同 prefix。

    • expired:缓存锁的持续时间。

    • timeUnit:持续时间的单位。

    • delimiter:分隔符,构建 cacheLockKey 时使用。

    • autoDelete:是否自动删除缓存锁。

    • traceRequest:是否追踪请求的 IP,如果是,那么构建 cacheLockKey 时会添加用户的 IP。

    缓存锁的使用方法是在需要加锁的方法上添加 @CacheLock 注解,然后通过 Spring 的 AOP 在方法执行前对方法进行加锁,方法执行结束后再将锁取消。项目中的切面类为 CacheLockInterceptor,负责加/解锁的逻辑如下:

    Around("@annotation(run.halo.app.cache.lock.CacheLock)")
    public Object interceptCacheLock(ProceedingJoinPoint joinPoint) throws Throwable {
        // 获取方法签名
        // Get method signature
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
    
        log.debug("Starting locking: [{}]", methodSignature.toString());
    
        // 获取方法上的 CacheLock 注解
        // Get cache lock
        CacheLock cacheLock = methodSignature.getMethod().getAnnotation(CacheLock.class);
        // 构造缓存锁的 key
        // Build cache lock key
        String cacheLockKey = buildCacheLockKey(cacheLock, joinPoint);
        System.out.println(cacheLockKey);
        log.debug("Built lock key: [{}]", cacheLockKey);
    
        try {
            // Get from cache
            Boolean cacheResult = cacheStore
                .putIfAbsent(cacheLockKey, CACHE_LOCK_VALUE, cacheLock.expired(),
                    cacheLock.timeUnit());
    
            if (cacheResult == null) {
                throw new ServiceException("Unknown reason of cache " + cacheLockKey)
                    .setErrorData(cacheLockKey);
            }
    
            if (!cacheResult) {
                throw new FrequentAccessException("访问过于频繁,请稍后再试!").setErrorData(cacheLockKey);
            }
            // 执行注解修饰的方法
            // Proceed the method
            return joinPoint.proceed();
        } finally {
            // 方法执行结束后, 是否自动删除缓存锁
            // Delete the cache
            if (cacheLock.autoDelete()) {
                cacheStore.delete(cacheLockKey);
                log.debug("Deleted the cache lock: [{}]", cacheLock);
            }
        }
    }
    

    @Around("@annotation(run.halo.app.cache.lock.CacheLock)") 表示,如果请求的方法被 @CacheLock 注解修饰,那么服务器不会执行该方法,而是执行 interceptCacheLock 方法:

    1. 获取方法上的 CacheLock 注解并构建 cacheLockKey。

    2. 查看缓存中是否存在 cacheLockKey,如果存在,那么抛出异常,提醒用户访问过于频繁。如果不存在,那么将 cacheLockKey 存入到缓存(有效时间为 expired),并执行请求的方法。

    3. 如果 CacheLock 注解中的 autoDelete 为 true,那么方法执行结束后立即删除 cacheLockKey。

    缓存锁的原理和 Redis 的 setnx + expire 相似,如果 key 已存在,就不能再次添加。下面是构建 cacheLockKey 的逻辑:

    private String buildCacheLockKey(@NonNull CacheLock cacheLock,
        @NonNull ProceedingJoinPoint joinPoint) {
        Assert.notNull(cacheLock, "Cache lock must not be null");
        Assert.notNull(joinPoint, "Proceeding join point must not be null");
    
        // Get the method
        MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
    
        // key 的前缀
        // Build the cache lock key
        StringBuilder cacheKeyBuilder = new StringBuilder(CACHE_LOCK_PREFIX);
        // 分隔符
        String delimiter = cacheLock.delimiter();
        // 如果 CacheLock 中设置了前缀, 那么直接使用该前缀, 否则使用方法名
        if (StringUtils.isNotBlank(cacheLock.prefix())) {
            cacheKeyBuilder.append(cacheLock.prefix());
        } else {
            cacheKeyBuilder.append(methodSignature.getMethod().toString());
        }
        // 提取被 CacheParam 注解修饰的变量的值
        // Handle cache lock key building
        Annotation[][] parameterAnnotations = methodSignature.getMethod().getParameterAnnotations();
    
        for (int i = 0; i < parameterAnnotations.length; i++) {
            log.debug("Parameter annotation[{}] = {}", i, parameterAnnotations[i]);
    
            for (int j = 0; j < parameterAnnotations[i].length; j++) {
                Annotation annotation = parameterAnnotations[i][j];
                log.debug("Parameter annotation[{}][{}]: {}", i, j, annotation);
                if (annotation instanceof CacheParam) {
                    // Get current argument
                    Object arg = joinPoint.getArgs()[i];
                    log.debug("Cache param args: [{}]", arg);
    
                    // Append to the cache key
                    cacheKeyBuilder.append(delimiter).append(arg.toString());
                }
            }
        }
        // 是否添加请求的 IP
        if (cacheLock.traceRequest()) {
            // Append http request info
            cacheKeyBuilder.append(delimiter).append(ServletUtils.getRequestIp());
        }
        return cacheKeyBuilder.toString();
    }
    

    可以发现,cacheLockKey 的结构为 cache_lock_ + CacheLock 注解中设置的前缀或方法签名 + 分隔符 + CacheParam 注解修饰的参数的值 + 分隔符 + 请求的 IP,例如:

    cache_lock_public void run.halo.app.controller.content.api.PostController.like(java.lang.Integer):1:127.0.0.1
    

    CacheParam 同 CacheLock 一样,都是为实现缓存锁而定义的注解。CacheParam 的作用是将锁的粒度精确到具体的实体,如点赞请求:

    @PostMapping("{postId:\\d+}/likes")
    @ApiOperation("Likes a post")
    @CacheLock(autoDelete = false, traceRequest = true)
    public void like(@PathVariable("postId") @CacheParam Integer postId) {
        postService.increaseLike(postId);
    }
    

    参数 postId 被 CacheParam 修饰,根据 buildCacheLockKey 方法的逻辑,postId 也将是 cacheLockKey 的一部分,这样锁定的就是 "为 id 等于 postId 的文章点赞" 这一方法,而非锁定 "点赞" 方法。

    此外,CacheLock 注解中的 traceRequest 参数也很重要,如果 traceRequest 为 true,那么请求的 IP 会被添加到 cacheLockKey 中,此时缓存锁仅限制同一 IP 对某个方法的请求频率,不同 IP 之间互不干扰。如果 traceRequest 为 false,那么缓存锁就是一个分布式锁,不同 IP 不能同时访问同一个功能,例如当某个用户为某篇文章点赞后,短时间内其它用户不能为该文章点赞。

    最后我们再分析一下 putIfAbsent 方法(在 interceptCacheLock 中被调用),其功能和 Redis 的 setnx 相似,该方法的具体处理逻辑可追踪到 InMemoryCacheStore 类中的 putInternalIfAbsent 方法:

    Boolean putInternalIfAbsent(@NonNull String key, @NonNull CacheWrapper<String> cacheWrapper) {
        Assert.hasText(key, "Cache key must not be blank");
        Assert.notNull(cacheWrapper, "Cache wrapper must not be null");
    
        log.debug("Preparing to put key: [{}], value: [{}]", key, cacheWrapper);
        // 加锁
        lock.lock();
        try {
            // 获取 key 对应的 value
            // Get the value before
            Optional<String> valueOptional = get(key);
            // value 不为空返回 false
            if (valueOptional.isPresent()) {
                log.warn("Failed to put the cache, because the key: [{}] has been present already",
                    key);
                return false;
            }
            // 在缓存中添加 value 并返回 true
            // Put the cache wrapper
            putInternal(key, cacheWrapper);
            log.debug("Put successfully");
            return true;
        } finally {
            // 解锁
            lock.unlock();
        }
    }
    

    上节中我们提到,自定义缓存 InMemoryCacheStore 中有一个 ReentrantLock 类型的成员变量 lock,lock 的作用就是保证 putInternalIfAbsent 方法的线程安全性,因为向缓存容器中添加 cacheLockKey 是多个线程并行执行的。如果不添加 lock,那么当多个线程同时操作同一个 cacheLockKey 时,不同线程可能都会检测到缓存中没有 cacheLockKey,因此 putInternalIfAbsent 方法均返回 true,之后多个线程就可以同时执行某个方法,添加 lock 后就能够避免这种情况。

    结语

    关于 Halo 项目缓存机制就介绍到这里了,如有理解错误,欢迎大家批评指正 ( ̳• ◡ • ̳)。

    作者:John同学
    欢迎任何形式的转载,但请务必注明出处。
    文章和代码如有不当之处,欢迎批评指正!

  • 相关阅读:
    漫画 | 老板裁掉我两周后,又把我请回去,工资翻番!
    计数器(JS闭包)
    FFmpeg引入x264扩展
    Linux can配参
    Qt之读写文件
    SpringBoot的starter到底是什么?
    iOS之APP上架
    在瑞芯微 Rockchip SDK中增加自己的程序并使用CMake编译
    SpringBoot 过滤器filter当中的自定义异常捕获问题
    详解设计模式:抽象工厂模式
  • 原文地址:https://www.cnblogs.com/johnlearning/p/16205499.html