当前位置:网站首页>Halo open source project learning (VII): caching mechanism

Halo open source project learning (VII): caching mechanism

2022-04-23 18:21:00 John came to study

Basic introduction

We know , Operating the database frequently will reduce the system performance of the server , Therefore, frequent access to 、 The updated data is stored in the cache .Halo The project also introduces a caching mechanism , And set a variety of implementation methods , Such as custom cache 、Redis、LevelDB etc. , Let's analyze the implementation process of caching mechanism .

Custom cache

Because the data exists in the form of key value pairs in the cache , And different types of cache systems define similar storage and read operations , Therefore, this article only introduces the default custom cache in the project . Custom cache refers to the cache written by the author , With ConcurrentHashMap As a container , The data is stored in the memory of the server . Before introducing custom caching , Let's take a look first Halo System diagram of cache :

<img src=/img/28/1e8b5766f5a352b3f7ff473a361370.png width="70%">

I use Halo 1.4.13 Version is not set Redis cache , The above is from 1.5.2 edition .

You can see , The author's design idea is to define general operation methods in the upper abstract classes and interfaces , And the specific cache container 、 Data storage and reading methods are defined in each implementation class . If you want to modify the type of cache , Just configure the class HaloProperties Revision in China cache Value of field :

@Bean
@ConditionalOnMissingBean
AbstractStringCacheStore stringCacheStore() {
    AbstractStringCacheStore stringCacheStore;
    //  according to  cache  Select the specific cache type according to the value of the field 
    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;
}
The above code comes from 1.5.2 edition .

cache The default value of the field is "memory", Therefore, the implementation class of cache is InMemoryCacheStore( Custom cache ):

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();
        //  Every time  60s  Clear an expired  key
        timer.scheduleAtFixedRate(new CacheExpiryCleaner(), 0, PERIOD);
    }
    //  Omitted code 
}

InMemoryCacheStore The meaning of member variable is as follows :

  1. CACHE_CONTAINER yes InMemoryCacheStore Cache container for , The type is ConcurrentHashMap. Use ConcurrentHashMap To ensure thread safety , Because the data related to cache lock will be stored in the cache ( The following describes ), Whenever a user accesses a service in the background , New data will enter the cache , This data may come from different threads , therefore CACHE_CONTAINER You need to consider the case of multiple threads operating at the same time .
    <!-- 2.
  2. We are Halo Open source project learning ( Four ): Publish articles and pages Mentioned in the article ,Halo Set up authorization mechanism for private articles , When the client obtains the authorization of the article , The server adds the client's to the cache sessionId. Because different users may apply for authorization at the same time , therefore CACHE_CONTAINER You need to consider the case of multiple threads operating at the same time . -->
  3. timer Responsible for performing periodic tasks , The frequency of task execution is PERIOD, Default is one minute , The processing logic of periodic tasks is to clear the expired data in the cache key.
  4. lock yes ReentrantLock Type of exclusive lock , Related to cache lock .

Data in cache

The data stored in the cache includes :

  1. Option information in system settings , In fact, that is options Data stored in tables .
  2. Logged in users ( Blogger ) Of token.
  3. The name of the client that has been authorized by the article sessionId.
  4. Cache lock related data .

In the previous post , We introduced token and sessionId Storage and acquisition of , Therefore, this article will not repeat this part , See Halo Open source project learning ( 3、 ... and ): Sign up and log in and Halo Open source project learning ( Four ): Publish articles and pages . Cache lock will be introduced in the next section , In this section, we first look at Halo How to save options Information .

First of all, I need to understand options When is the information stored in the cache , actually , The program will be released after startup ApplicationStartedEvent event , It is defined in the project to be responsible for listening ApplicationStartedEvent Event listener StartedListener(listener It's a bag ), The listener will execute after the event is published initThemes Method , Here is initThemes Partial code snippet in method :

private void initThemes() {
    // Whether the blog has initialized
    Boolean isInstalled = optionService
        .getByPropertyOrDefault(PrimaryProperties.IS_INSTALLED, Boolean.class, false);
    //  Omitted code 
} 

This method is called getByPropertyOrDefault Method to query the installation status of the blog from the cache , We from getByPropertyOrDefault Method start , Search down the call chain , You can trace OptionProvideService Interface getByKey Method :

default Optional<Object> getByKey(@NonNull String key) {
    Assert.hasText(key, "Option key must not be blank");
    //  If  val = listOptions().get(key)  Not empty ,  return  value  by  val  Of  Optional  object ,  Otherwise return to  value  Empty  Optional  object 
    return Optional.ofNullable(listOptions().get(key));
}

You can see , The point is this listOptions Method , The method in OptionServiceImpl Definition in class :

public Map<String, Object> listOptions() {
    // Get options from cache
    //  From the cache  CACHE_CONTAINER  In order to get  "options"  This  key  Corresponding data ,  And convert the data into  Map  object 
    return cacheStore.getAny(OPTIONS_KEY, Map.class).orElseGet(() -> {
        //  The first call needs to start from  options  Get all... From the table  Option  object 
        List<Option> options = listAll();
        //  all  Option  Object's  key  aggregate 
        Set<String> keys = ServiceUtils.fetchProperty(options, Option::getKey);

        /*
            * options  The records stored in the table are actually user-defined  Option  Options ,  When users modify blog settings ,  It will update automatically  options  surface ,
            * Halo  For some options in  value  Set a certain type ,  for example  EmailProperties  In this class  HOST  by  String  type ,  and 
            * SSL_PORT  Then for  Integer  type ,  because  Option  Class  value  All are  String  type ,  Therefore, some  value  Translate into 
            *  Set the type 
            */
        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();
                }
                //  Yes  value  Do type conversion 
                return PropertyEnum.convertTo(option.getValue(), propertyEnum);
            });

        Map<String, Object> result = new HashMap<>(userDefinedOptionMap);

        // Add default property
        /*
            *  Some of the options are  Halo  The default setting is ,  for example  EmailProperties  Medium  SSL_PORT,  When not set by the user ,  It will also be set to the default  465,
            *  Again ,  You also need to set the default  "465"  Turn into  Integer  Type of  465
            */
        propertyEnumMap.keySet()
            .stream()
            .filter(key -> !keys.contains(key))
            .forEach(key -> {
                PropertyEnum propertyEnum = propertyEnumMap.get(key);

                if (StringUtils.isBlank(propertyEnum.defaultValue())) {
                    return;
                }
                //  Yes  value  Type conversion and save  result
                result.put(key,
                    PropertyEnum.convertTo(propertyEnum.defaultValue(), propertyEnum));
            });

        // Cache the result
        //  Add all options to the cache 
        cacheStore.putAny(OPTIONS_KEY, result);

        return result;
    });
}

The server method starts with CACHE_CONTAINER In order to get "options" This key Corresponding data , The data is then converted to Map Object of type . Because the first query CACHE_CONTAINER in did not "options" Corresponding value, Therefore, initialization is required :

  1. First of all, from the options Get all... From the table Option object , And store these objects in Map in . among key and value Are all Option Object key and value, but value You also need to do a type conversion , Because in Option Class value Defined as String type . for example ,"is_installed" Corresponding value by "true", In order to be able to use value, The string needs to be "true" Turn it into Boolean Type of true. Combined with the context , We found that the procedure was based on PrimaryProperties class ( Inherit PropertyEnum Enumeration class of ) Enumeration objects defined in IS_INSTALLED("is_installed", Boolean.class, "false") To confirm the target type Boolean Of .
  2. options The options in the table are user-defined options , besides ,Halo Some default options are also set in , These options are in PropertyEnum Defined in subclasses of , for example EmailProperties Class SSL_PORT("email_ssl_port", Integer.class, "465"), Their corresponding key by "email_ssl_port",value by "465". The server will also send these key - value Yes, to Map, Also on value Do type conversion .

The above is listOptions Method processing logic , We go back to getByKey Method , You can find , Get listOptions Method Map After the object , The server can be based on the specified key( Such as "is_installed") Get the corresponding attribute value ( Such as true). When the user modifies the system settings of the blog in the administrator background , The server will update according to the user's configuration options surface , And publish OptionUpdatedEvent event , After that, the listener responsible for processing the event will send the... In the cache "options" Delete , Perform the initialization operation according to the above steps next time ( See FreemarkerConfigAwareListener Medium onOptionUpdate Method ).

Cache expiration processing

It is very important to cache the expired knowledge , After the data has expired , It usually needs to be removed from the cache . From the above cacheStore.putAny(OPTIONS_KEY, result) In the method we know , Before the server stores the data in the cache , It will be encapsulated into CacheWrapper object :

class CacheWrapper<V> implements Serializable {

    /**
     * Cache data
     */
    private V data;

    /**
     * Expired time.
     */
    private Date expireAt;

    /**
     * Create time.
     */
    private Date createAt;
}

among data Is the data that needs to be stored ,createAt and expireAt They are the creation time and expiration time of the data .Halo In the project ,"options" There is no expiration time , Only when the data is updated , The listener will delete the old data . It should be noted that ,token and sessionId There are expiration dates , For those with expiration time key, There are also corresponding treatment methods in the project . With token For example , After intercepting the user's request, the interceptor will confirm the user's identity , That is, whether there is in the query cache token The corresponding user id, The underlying call of this query operation is get Method ( stay AbstractCacheStore Definition in class ):

public Optional<V> get(K key) {
    Assert.notNull(key, "Cache key must not be blank");

    return getInternal(key).map(cacheWrapper -> {
        // Check expiration
        //  Be overdue 
        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;
        }
        //  Cache data returned without expiration 
        return cacheWrapper.getData();
    });
}

Server get to key Corresponding CacheWrapper After the object , Will check the expiration time , If the data has expired , Then delete it directly and return to null. in addition , As mentioned above ,timer(InMemoryCacheStore Member variables of ) The periodic task is also responsible for deleting expired data , Here is timer The method of executing periodic tasks :

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);
            }
        });
    }
}

so , Periodic tasks are also performed by calling get Method to delete expired data .

Buffer lock

Halo The cache lock in the project is also an interesting module , Its function is to limit the frequency of users calling a function , It can be considered as locking the requested method . Cache locks mainly use custom annotations @CacheLock and AOP To achieve ,@CacheLock Notes are defined as follows :

@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;
}

The meaning of each member variable is :

  • prefix: Used to build cacheLockKey( A string ) The prefix of .
  • value: Same as prefix.
  • expired: Duration of cache lock .
  • timeUnit: Unit of duration .
  • delimiter: Separator , structure cacheLockKey When using .
  • autoDelete: Whether to delete cache lock automatically .
  • traceRequest: Whether to track the requested IP, If it is , So build cacheLockKey Will add the user's IP.

The use of cache lock is to add... To the method that needs to be locked @CacheLock annotation , And then through Spring Of AOP Lock the method before it is executed , After the method is executed, cancel the lock . The facet class in the project is CacheLockInterceptor, Responsible for adding / The logic of unlocking is as follows :

Around("@annotation(run.halo.app.cache.lock.CacheLock)")
public Object interceptCacheLock(ProceedingJoinPoint joinPoint) throws Throwable {
    //  Get method signature 
    // Get method signature
    MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();

    log.debug("Starting locking: [{}]", methodSignature.toString());

    //  On the method  CacheLock  annotation 
    // Get cache lock
    CacheLock cacheLock = methodSignature.getMethod().getAnnotation(CacheLock.class);
    //  To construct a cache lock  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(" Too many visits , Please try again later !").setErrorData(cacheLockKey);
        }
        //  Methods of performing annotation modification 
        // Proceed the method
        return joinPoint.proceed();
    } finally {
        //  After method execution ,  Whether to delete cache lock automatically 
        // Delete the cache
        if (cacheLock.autoDelete()) {
            cacheStore.delete(cacheLockKey);
            log.debug("Deleted the cache lock: [{}]", cacheLock);
        }
    }
}

@Around("@annotation(run.halo.app.cache.lock.CacheLock)") Express , If the requested method is @CacheLock To modify , Then the server will not execute this method , But to perform interceptCacheLock Method :

  1. On the method CacheLock Annotate and build cacheLockKey.
  2. Check to see if there is cacheLockKey, If there is , So throw an exception , Remind users to visit too often . If it doesn't exist , It will be cacheLockKey Store in cache ( The effective time is expired), And execute the requested method .
  3. If CacheLock In the annotations autoDelete by true, Then delete immediately after the method execution cacheLockKey.

Principle and of cache lock Redis Of setnx + expire be similar , If key Already exists , You can't add... Again . Here's the build cacheLockKey The logic of :

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  The prefix of 
    // Build the cache lock key
    StringBuilder cacheKeyBuilder = new StringBuilder(CACHE_LOCK_PREFIX);
    //  Separator 
    String delimiter = cacheLock.delimiter();
    //  If  CacheLock  The prefix is set in ,  Then use the prefix directly ,  Otherwise, use the method name 
    if (StringUtils.isNotBlank(cacheLock.prefix())) {
        cacheKeyBuilder.append(cacheLock.prefix());
    } else {
        cacheKeyBuilder.append(methodSignature.getMethod().toString());
    }
    //  Extraction quilt  CacheParam  The value of the variable modified by the annotation 
    // 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());
            }
        }
    }
    //  Whether to add the requested  IP
    if (cacheLock.traceRequest()) {
        // Append http request info
        cacheKeyBuilder.append(delimiter).append(ServletUtils.getRequestIp());
    }
    return cacheKeyBuilder.toString();
}

You can find ,cacheLockKey The structure of is cache_lock_ + CacheLock Prefix or method signature set in annotation + Separator + CacheParam The value of the parameter modified by the annotation + Separator + Requested IP, for example :

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

CacheParam Same as CacheLock equally , Are annotations defined to implement cache locks .CacheParam The function of is to make the granularity of the lock accurate to the specific entity , Such as like request :

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

Parameters postId By CacheParam modification , according to buildCacheLockKey The logic of the method ,postId It will also be cacheLockKey Part of , What's locked in this way is " by id be equal to postId Like your article " This method , Instead of locking " give the thumbs-up " Method .

Besides ,CacheLock In the annotations traceRequest Parameters are also important , If traceRequest by true, So the requested IP Will be added to cacheLockKey in , At this time, the cache lock only limits the same IP The frequency of requests for a method , Different IP Mutual non-interference . If traceRequest by false, Then the cache lock is a distributed lock , Different IP You cannot access the same function at the same time , For example, when a user likes an article , Other users cannot like this article in a short time .

Finally, let's analyze putIfAbsent Method ( stay interceptCacheLock In the called ), Its functions and functions Redis Of setnx be similar , The specific processing logic of this method can be traced to InMemoryCacheStore Class putInternalIfAbsent Method :

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.lock();
    try {
        //  obtain  key  Corresponding  value
        // Get the value before
        Optional<String> valueOptional = get(key);
        // value  Not empty return  false
        if (valueOptional.isPresent()) {
            log.warn("Failed to put the cache, because the key: [{}] has been present already",
                key);
            return false;
        }
        //  Add to cache  value  And back to  true
        // Put the cache wrapper
        putInternal(key, cacheWrapper);
        log.debug("Put successfully");
        return true;
    } finally {
        //  Unlock 
        lock.unlock();
    }
}

In the last section we mentioned , Custom cache InMemoryCacheStore There is one of them. ReentrantLock A member variable of type lock,lock The role of is to ensure putInternalIfAbsent Method thread safety , Because adding... To the cache container cacheLockKey It is executed by multiple threads in parallel . If you do not add lock, So when multiple threads operate on the same cacheLockKey when , Different threads may detect that there is no... In the cache cacheLockKey, therefore putInternalIfAbsent Methods return true, Then multiple threads can execute a method at the same time , add to lock This situation can be avoided after .

Conclusion

About Halo The caching mechanism is introduced here , If there is a mistake in understanding , Welcome criticism ( ̳• ◡ • ̳).

版权声明
本文为[John came to study]所创,转载请带上原文链接,感谢
https://yzsam.com/2022/04/202204231719544392.html