SpringBoot AOP + Redis delayed double deletion to ensure data consistency

SpringBoot AOP + Redis Delayed double deletion ensures data consistency

1. Business scenario

1.1 Problems

When using Redis as a cache, there will be inconsistencies between the data in Redis and the database data. In the subsequent query process, it will take a long time to check Redis first, thus The occurrence of queried data is not a serious problem with the real data in the database.

1.2 Solution

  1. When using Redis, you need to ensure the consistency of Redis and database data. There are many solutions, such as delayed double deletion, canal + MQ and other strategies, here we use the delayed double deletion strategy to achieve it.
  2. Note: Redis is used in a scenario where reading data is much greater than writing data, because the result of the double delete strategy is to delete the data saved in Redis. Subsequent queries will query the database. Therefore, frequently modified data tables are not suitable for using Redis.

Implementation steps of delayed double deletion plan
1> Delete cache
2> Update database
3> Delay 1000 milliseconds (set the delay execution time according to the specific business)
4> Delete cache

2. Code implementation

2.1 Introduce Redis and SpringBoot AOP dependencies

<!-- Redis -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- aop -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-aop</artifactId>
</dependency>

2.2 Customized SpringBoot AOP annotations

2.2.1 CacheAnnotations
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Target(ElementType.METHOD)
public @interface Cache {<!-- -->
    String name() default "";
}
2.2.2 CacheAspects
package com.xxx.demo.aop;

import cn.hutool.core.lang.Assert;
import cn.hutool.json.JSONUtil;
import com.alibaba.fastjson.JSON;
import com.xxx.demo.modules.system.entity.User;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.Signature;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.ObjectUtils;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;

import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import java.lang.reflect.Method;

@Aspect
@Component
@Slf4j
public class CacheAspect {<!-- -->

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    /**
     * entry point
     */
    @Pointcut("@annotation(com.xxx.demo.aop.Cache)")
    public void pointCut(){<!-- -->

    }

    /**
     * Surround notifications
     */
    @Around("pointCut()")
    public Object aroundAdvice(ProceedingJoinPoint proceedingJoinPoint){<!-- -->
        log.info("---------- Surround notification -----------");
        log.info("Target method name of surrounding notification: {}", proceedingJoinPoint.getSignature().getName());

        Signature signature1 = proceedingJoinPoint.getSignature();
        MethodSignature methodSignature = (MethodSignature)signature1;
        //method object
        Method targetMethod = methodSignature.getMethod();
        //Reflect to get the method object of the custom annotation
        Cache annotation = targetMethod.getAnnotation(Cache.class);
        String userId = getUserId();
        Assert.notNull(userId, "id is null");
        //Get the parameter of the method object of the custom annotation, which is name
        String name = annotation.name();
        String redisKey = name + ":" + userId;
        //Fuzzy definition key
        String res = stringRedisTemplate.opsForValue().get(redisKey);
        if (!ObjectUtils.isEmpty(res)) {<!-- -->
            log.info("Returned from cache, data is: {}",res);
            return JSONUtil.toBean(res, User.class);
        }
        Object proceed = null;
        try {<!-- -->
            proceed = proceedingJoinPoint.proceed();
        } catch (Throwable throwable) {<!-- -->
            throwable.printStackTrace();
        }
        if (!ObjectUtils.isEmpty(proceed)) {<!-- -->
            stringRedisTemplate.opsForValue().set(redisKey, JSON.toJSONString(proceed));
        }
        log.info("Returned from the database, the data is: {}",proceed);
        return proceed;
    }

    private static String getUserId() {<!-- -->
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();
        // Get all path parameters in the request path
        String requestURI = request.getRequestURI();
        String[] pathSegments = requestURI.split("/");
        return pathSegments[3];
    }
}
2.2.3 ClearAndReloadCacheAnnotations
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Target(ElementType.METHOD)
public @interface ClearAndReloadCache {<!-- -->
    String name() default "";
}
2.2.3 ClearAndReloadCacheAspects
package com.xxx.demo.aop;

import cn.hutool.json.JSONUtil;
import com.alibaba.fastjson.JSON;
import com.xxx.demo.modules.system.entity.User;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.Signature;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;

import javax.annotation.Resource;
import java.lang.reflect.Method;

@Aspect
@Component
@Slf4j
public class ClearAndReloadCacheAspect {<!-- -->

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    /**
     * entry point
     */
    @Pointcut("@annotation(com.xxx.demo.aop.ClearAndReloadCache)")
    public void pointCut() {<!-- -->

    }

    /**
     * Surround notifications
     */
    @Around("pointCut()")
    public Object aroundAdvice(ProceedingJoinPoint proceedingJoinPoint) {<!-- -->
        log.info("---------- Surround notification -----------");
        log.info("Target method name of surrounding notification: {}", proceedingJoinPoint.getSignature().getName());
        Object[] args = proceedingJoinPoint.getArgs();
        String userString = JSON.toJSONString(args[0]);
        User bean = JSONUtil.toBean(userString, User.class);
        Signature signature1 = proceedingJoinPoint.getSignature();
        MethodSignature methodSignature = (MethodSignature) signature1;
        // method object
        Method targetMethod = methodSignature.getMethod();
        // Reflect to get the method object of the custom annotation
        ClearAndReloadCache annotation = targetMethod.getAnnotation(ClearAndReloadCache.class);
        // Get the parameter of the method object of the custom annotation, which is name
        String name = annotation.name();
        String redisKey = name + ":" + bean.getId();
        //Delete the key value of redis
        stringRedisTemplate.delete(redisKey);
        // Execute the business of modifying the database with double deletion annotations, that is, the method business in the controller
        Object proceed = null;
        try {<!-- -->
            proceed = proceedingJoinPoint.proceed();
        } catch (Throwable throwable) {<!-- -->
            throwable.printStackTrace();
        }
        //Open a thread and delay for 1 second (here is an example of 1 second, you can change it to your own business)
        // Delay deletion in the thread and return the result of the business code at the same time so that it does not affect the execution of the business code
        new Thread(() -> {<!-- -->
            try {<!-- -->
                Thread.sleep(1000);
                stringRedisTemplate.delete(redisKey);
                log.info("-----------After 1 second, the delayed deletion in the thread will be completed -----------");
            } catch (InterruptedException e) {<!-- -->
                e.printStackTrace();
            }
        }).start();
        // Return the value of the business code
        return proceed;
    }
}

2.3 SQLScript

DROP TABLE IF EXISTS sys_user;
CREATE TABLE sys_user (
  id int(4) NOT NULL AUTO_INCREMENT,
  username varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
  PRIMARY KEY (id) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 8 CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

INSERT INTO sys_user VALUES (1, 'Prince');
INSERT INTO sys_user VALUES (2, 'Pig Girl');
INSERT INTO sys_user VALUES (3, 'robot');
INSERT INTO sys_user VALUES (4, 'Tsar');
INSERT INTO sys_user VALUES (5, 'Gwen');
INSERT INTO sys_user VALUES (6, 'crocodile');

2.4 UserController.java

package com.xxx.demo.modules.system.api;

import com.xxx.demo.aop.Cache;
import com.xxx.demo.aop.ClearAndReloadCache;
import com.xxx.demo.modules.system.entity.User;
import com.xxx.demo.modules.system.service.IUserService;
import io.swagger.annotations.Api;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/user")
@Api(description = "Test-Interface")
public class UserController {<!-- -->

    @Autowired
    private IUserService iUserService;

    @GetMapping("/get/{id}")
    @Cache(name = "getUser")
    public User get(@PathVariable("id") Integer id){<!-- -->
        return iUserService.get(id);
    }

    @PostMapping("/update")
    @ClearAndReloadCache(name = "getUser")
    public int updateData(@RequestBody User user){<!-- -->
        return iUserService.update(user);
    }
}

3. Testing and verification

3.1 Get id=1, the cache type does not exist, check the database

3.2 Get id=1, it exists in the cache, and directly return the cached data

3.3 Modify id=1, username=Jiawen IV, delete cache

3.4 Get id=1 again. At this time, the cache does not exist. Check the database

4. Summary

  1. Why the delay of 1000 milliseconds?
    Answer: This is to complete the database update operation before deleting Redis for the second time. If there is no delayed operation, there is a high probability that after the two deletion Redis operations are completed, the data in the database has not been updated. If there is a request to access the data at this time, the problem we started with will still appear. The data inconsistency issues mentioned. In addition, the delay time here needs to be set according to the business time of your own system.
  2. Why delete the cache twice?
    Answer: If we do not have a second deletion operation and there is a request to access data at this time, it may be the accessed Redis data that has not been modified before. After the deletion operation is executed, Redis > is empty. When a request comes in, the database will be accessed. At this time, the data in the database is already updated, ensuring data consistency.