根本介绍

咱们晓得,频繁操作数据库会升高服务器的零碎性能,因而通常须要将频繁拜访、更新的数据存入到缓存。Halo 我的项目也引入了缓存机制,且设置了多种实现形式,如自定义缓存、Redis、LevelDB 等,上面咱们剖析一下缓存机制的实现过程。

自定义缓存

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

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

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

@Bean@ConditionalOnMissingBeanAbstractStringCacheStore 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 须要思考多个线程同时操作的状况。
    <!-- 2.
  2. 咱们在 Halo 开源我的项目学习(四):公布文章与页面 一文中提到,Halo 为私密文章设置了受权机制,当客户端取得文章的受权后,服务器会在缓存中增加客户端的 sessionId。因为不同用户可能同时申请受权,因而 CACHE_CONTAINER 须要思考多个线程同时操作的状况。 -->
  3. timer 负责执行周期工作,工作的执行频率为 PERIOD,默认为一分钟,周期工作的解决逻辑是革除缓存中曾经过期的 key。
  4. lock 是 ReentrantLock 类型的排它锁,与缓存锁无关。

缓存中的数据

缓存中存储的数据包含:

  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 办法)。

缓存的过期解决

缓存的过期解决是一个十分重要的知识点,数据过期后,通常须要将其从缓存中删除。从上文中的 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@Inheritedpublic @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 我的项目缓存机制就介绍到这里了,如有了解谬误,欢送大家批评指正 ( • ◡ • )。