SpringBoot AOP + Redis
延时双删保证数据一致性
一、业务场景
1.1 存在的问题
在使用
Redis
作为缓存的时候,会出现Redis中数据和数据库数据不一致的情况,在后面的查询过程中就会长时间去先查Redis
, 从而出现查询到的数据并不是数据库中的真实数据的严重问题。
1.2 解决方案
- 在使用
Redis
时,需要保证Redis
和数据库数据的一致性,目前已经有很多种解决方案,例如延时双删、canal+MQ
等策略,这里我们使用延迟双删策略来实现。 - 注意:
Redis
使用的场景是读数据远远大于写数据的场景,因为双删策略执行的结果是把Redis
中保存的那条数据删除了,后续的查询就都会去查询数据库,因此,经常修改的数据表不适合使用Redis
。
延时双删方案执行步骤
1> 删除缓存
2> 更新数据库
3> 延时1000毫秒 (根据具体业务设置延时执行的时间)
4> 删除缓存
二、代码实现
2.1 引入Redis
和SpringBoot AOP
依赖
<!-- 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 自定义SpringBoot AOP
注解
2.2.1 Cache
注解
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Target(ElementType.METHOD)
public @interface Cache {
String name() default "";
}
2.2.2 Cache
切面
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;
/**
* 切入点
*/
@Pointcut("@annotation(com.xxx.demo.aop.Cache)")
public void pointCut(){
}
/**
* 环绕通知
*/
@Around("pointCut()")
public Object aroundAdvice(ProceedingJoinPoint proceedingJoinPoint){
log.info("----------- 环绕通知 -----------");
log.info("环绕通知的目标方法名:{}", proceedingJoinPoint.getSignature().getName());
Signature signature1 = proceedingJoinPoint.getSignature();
MethodSignature methodSignature = (MethodSignature)signature1;
//方法对象
Method targetMethod = methodSignature.getMethod();
//反射得到自定义注解的方法对象
Cache annotation = targetMethod.getAnnotation(Cache.class);
String userId = getUserId();
Assert.notNull(userId, "id为null");
//获取自定义注解的方法对象的参数即name
String name = annotation.name();
String redisKey = name + ":"+ userId;
//模糊定义key
String res = stringRedisTemplate.opsForValue().get(redisKey);
if (!ObjectUtils.isEmpty(res)) {
log.info("从缓存返回,数据为:{}",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("从数据库返回,数据为:{}",proceed);
return proceed;
}
private static String getUserId() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attributes.getRequest();
// 获取请求路径中的所有路径参数
String requestURI = request.getRequestURI();
String[] pathSegments = requestURI.split("/");
return pathSegments[3];
}
}
2.2.3 ClearAndReloadCache
注解
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Target(ElementType.METHOD)
public @interface ClearAndReloadCache {
String name() default "";
}
2.2.3 ClearAndReloadCache
切面
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;
/**
* 切入点
*/
@Pointcut("@annotation(com.xxx.demo.aop.ClearAndReloadCache)")
public void pointCut() {
}
/**
* 环绕通知
*/
@Around("pointCut()")
public Object aroundAdvice(ProceedingJoinPoint proceedingJoinPoint) {
log.info("----------- 环绕通知 -----------");
log.info("环绕通知的目标方法名:{}", 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 targetMethod = methodSignature.getMethod();
// 反射得到自定义注解的方法对象
ClearAndReloadCache annotation = targetMethod.getAnnotation(ClearAndReloadCache.class);
// 获取自定义注解的方法对象的参数即name
String name = annotation.name();
String redisKey = name + ":" + bean.getId();
// 删除redis的key值
stringRedisTemplate.delete(redisKey);
// 执行加入双删注解的改动数据库的业务 即controller中的方法业务
Object proceed = null;
try {
proceed = proceedingJoinPoint.proceed();
} catch (Throwable throwable) {
throwable.printStackTrace();
}
// 开一个线程 延迟1秒(此处是1秒举例,可以改成自己的业务)
// 在线程中延迟删除 同时将业务代码的结果返回 这样不影响业务代码的执行
new Thread(() -> {
try {
Thread.sleep(1000);
stringRedisTemplate.delete(redisKey);
log.info("-----------1秒钟后,在线程中延迟删除完毕 -----------");
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
// 返回业务代码的值
return proceed;
}
}
2.3 SQL
脚本
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, '皇子');
INSERT INTO sys_user VALUES (2, '猪妹');
INSERT INTO sys_user VALUES (3, '机器人');
INSERT INTO sys_user VALUES (4, '沙皇');
INSERT INTO sys_user VALUES (5, '格温');
INSERT INTO sys_user VALUES (6, '鳄鱼');
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 = "测试-接口")
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.1 获取id=1
,缓存种不存在,去查数据库
3.2 获取id=1
,缓存中存在,直接返回缓存数据
3.3 修改id=1,username=嘉文四世
,删除缓存
3.4 再获取id=1
,这时缓存种不存在,去查数据库
四、总结
- 为何要延时1000毫秒?
答:这是为了在第二次删除Redis
之前能完成数据库的更新操作。如果没有延时操作时,有很大概率,在两次删除Redis
操作执行完毕之后,数据库的数据还没有更新,此时若有请求访问数据,还是会出现我们一开始提到的数据不一致问题。另外,这里延迟的时间需要根据自己系统的业务时间去设置。- 为何要两次删除缓存?
答:如果我们没有第二次删除操作,此时有请求访问数据,有可能是访问的之前未做修改的Redis
数据,删除操作执行后,Redis
为空,有请求进来时,便会去访问数据库,此时数据库中的数据已是更新后的数据,保证了数据的一致性。