架构设计之自定义延迟双删缓存注解(上)
架构设计之自定义延迟双删缓存注解(上)
小薛博客官方架构设计之自定义延迟双删缓存注解(上)地址
1、业务场景问题
在多线程并发情况下,假设有两个数据库修改请求,为保证数据库与redis的数据一致性,修改请求的实现中需要修改数据库后,级联修改Redis中的数据。
- 请求一:A修改数据库数据 B修改Redis数据
- 请求二:C修改数据库数据 D修改Redis数据
- 正常情况:A —> B—>C —> D
**并发情况下就会存在A —> C —> D —> B的情况,**问题在哪里?
要理解线程并发执行多组原子操作执行顺序是可能存在交叉现象的
A修改数据库
的数据最终保存到了Redis中,但是在A没有修改完成是不会同步进mysql
的,
C在AB操作之间来查询,缓存redis数据没有删除,那A已经修改新数据了但是没有同步进B步骤进redis
导致C查看的还是老数据,此时出现了Redis中数据和数据库数据不一致的情况,从而出现查询到的数据并不是数据库中的真实数据的严重问题。
2、解决方案
在使用Redis时,需要保持Redis和数据库数据的一致性,最流行的解决方案之一就是延时双删策略。
**注意:**经常修改的数据表不适合使用Redis,因为双删策略执行的结果是把Redis中保存的那条数据删除了,以后的查询短时间内就都会去查询数据库,导致mysql侧压力瞬间增大,所以Redis使用的是读远远大于改的数据缓存。
- 删除缓存
- 更新数据库
- 延时500毫秒 (根据具体业务设置延时执行的时间,保证数据库更新或者删除业务操作完成即可,看你自己实际情况)
- 删除缓存
3、为何要两次删除缓存?
如果我们没有第二次删除操作,此时有请求访问数据,有可能是访问的之前未做修改的Redis数据,删除操作执行后,Redis为空,有请求进来时,便会去访问数据库,此时数据库中的数据已是更新后的数据,保证了数据的一致性。
因为mysql做了update操作,
导致数据不一致
查都是先查redis,一旦redis和mysql数据不一致,我返回的是redis里面的老数据,导致了这个生产bug
那一不做二不休,直接删掉redis,没有redis了,只有唯一的一个底单数据源mysql,数据源现在在唯一了
大家都来自mysql
更新动作完成之前,redis没有值,都从mysql一个数据源来
4、新建xx-delay-double-del
1、SQL
USE
`xx_db2025`;
DROP TABLE IF EXISTS `user_db`;
CREATE TABLE `user_db`
(
`id` int NOT NULL AUTO_INCREMENT,
`username` varchar(32) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL,
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC;
insert into `user_db`(`id`, `username`)
values (1, 'Version1');
2、pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.1.0</version>
<relativePath/>
</parent>
<artifactId>xx-delay-double-del</artifactId>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- spring boot的web开发所需要的起步依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- mybatis-plus-boot3-starter -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.7</version>
</dependency>
<!-- mybatis-plus-extension -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-extension</artifactId>
<version>3.5.7</version>
</dependency>
<!--pagehelper-->
<dependency>
<groupId>com.github.pagehelper</groupId>
<artifactId>pagehelper-spring-boot-starter</artifactId>
<version>2.1.0</version>
</dependency>
<!--SpringBoot集成druid连接池druid-spring-boot-starter -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.21</version>
</dependency>
<!-- mysql-connector-j -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.3.0</version>
</dependency>
<!--boot-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>
<dependency>
<groupId>com.xx</groupId>
<artifactId>xx-common-core</artifactId>
<version>1.6.1</version>
</dependency>
<!-- md5 加密包 -->
<dependency>
<groupId>commons-collections</groupId>
<artifactId>commons-collections</artifactId>
<version>3.2.2</version>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
</dependency>
<!-- commons -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
3、application.yml
server:
port: 8001
spring:
data:
redis:
database: 0
host: 127.0.0.1
password: 123456
port: 6379
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
# url: jdbc:mysql://127.0.0.1:3306/xx_db2023?connectTimeout=6000&socketTimeout=6000&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
url: jdbc:mysql://127.0.0.1:3306/xx_db2025?connectTimeout=6000&socketTimeout=6000&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
username: root
password: mac_root
# mybatis-plus相关配置
mybatis-plus:
# global-config:
# db-config:
# id-type: auto
# xml扫描,多个目录用逗号或者分号分隔(告诉 Mapper 所对应的 XML 文件位置)
mapper-locations: classpath:mapper/*Mapper.xml
# type-enums-package: com.xx.enums
configuration:
# 是否开启自动驼峰命名规则映射:从数据库列名到Java属性驼峰命名的类似映射
map-underscore-to-camel-case: true
# 如果查询结果中包含空值的列,则 MyBatis 在映射的时候,不会映射这个字段
call-setters-on-nulls: true
# 这个配置会将执行的sql打印出来,在开发或测试的时候可以用
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
cache-enabled: true
db-config:
logic-delete-field: del_flag # 全局逻辑删除的实体字段名(since 3.3.0,配置后可以忽略不配置步骤2)
logic-delete-value: 1 # 逻辑已删除值(默认为 1)
logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)
---
spring:
sql:
init:
mode: always # 总是初始化数据库
schema-locations: classpath:db/init.sql # 初始化SQL文件位置
#Mybatis输出sql日志
logging:
level:
com.xx.mapper: info
4、启动类
package com.xx;
import com.xx.utils.LocalIpUtil;
import com.xx.utils.ValidationUtil;
import lombok.extern.slf4j.Slf4j;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.core.env.Environment;
import org.springframework.util.StopWatch;
import java.net.UnknownHostException;
/**
* @Author: xueqimiao
* @Date: 2025/3/17 14:22
*/
@SpringBootApplication
@MapperScan("com.xx.mapper")
@Slf4j
public class DelayDoubleDelApplication {
public static void main(String[] args) throws UnknownHostException {
StopWatch stopWatch = new StopWatch();
stopWatch.start();
ConfigurableApplicationContext application = SpringApplication.run(DelayDoubleDelApplication.class, args);
stopWatch.stop();
Environment env = application.getEnvironment();
String ip = LocalIpUtil.getLocalIp();
String port = env.getProperty("server.port");
String path = env.getProperty("server.servlet.context-path");
path = ValidationUtil.isEmpty(path) ? "" : path;
log.info("\n--------------------------------------------------------\n\t" +
"Application Manager is running! Access URLs:\n\t" +
"Local: \t\thttp://127.0.0.1:" + port + path + "/\n\t" +
"External: \thttp://" + ip + ":" + port + path + "/\n\t" +
"Swagger文档: \thttp://" + ip + ":" + port + path + "/doc.html\n\t" +
"服务启动完成,耗时: \t" + stopWatch.getTotalTimeSeconds() + "S\n" +
"----------------------------------------------------------");
}
}
5、RedisUtils
package com.xx.utils;
import jakarta.annotation.Resource;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Service;
import java.util.Set;
import java.util.concurrent.TimeUnit;
/**
* @Author: xueqimiao
* @Date: 2025/3/17 14:19
*/
@Service
public class RedisUtils {
@Resource
private RedisTemplate<String, String> redisTemplate;
/**
* RedisAutoConfiguration
* 写入缓存+过期时间
*
* @param key
* @param value
* @param expireTime
* @param timeUnit
* @return
*/
public boolean set(String key, String value, Long expireTime, TimeUnit timeUnit) {
ValueOperations<String, String> operations = redisTemplate.opsForValue();
operations.set(key, value);
redisTemplate.expire(key, expireTime, timeUnit);
return true;
}
/**
* 通过key获取value
*
* @param key
* @return
*/
public String get(String key) {
ValueOperations<String, String> operations = redisTemplate.opsForValue();
return operations.get(key);
}
/**
* 批量删除 k-v
*
* @param keys
* @return
*/
public boolean del(final String... keys) {
for (String key : keys) {
if (redisTemplate.hasKey(key)) { //key存在就删除
redisTemplate.delete(key);
}
}
return true;
}
public boolean del(final Set<String> keys) {
for (String key : keys) {
if (redisTemplate.hasKey(key)) { //key存在就删除
redisTemplate.delete(key);
}
}
return true;
}
/**
* 获取key集合
* @param key
* @return
*/
public Set<String> keys(final String key) {
Set<String> keys = redisTemplate.keys(key);
return keys;
}
}
6、RedisConfig
package com.xx.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
/**
* @Author: xueqimiao
* @Date: 2025/3/17 14:19
*/
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<Integer,Integer> redisTemplate(RedisConnectionFactory factory){
RedisTemplate<Integer,Integer> redisTemplate = new RedisTemplate<>();
// 设置连接工厂类
redisTemplate.setConnectionFactory(factory);
// 设置k-v的序列化方式
// Jackson2JsonRedisSerializer 实现了 RedisSerializer接口
Jackson2JsonRedisSerializer jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer(Object.class);
redisTemplate.setValueSerializer(jackson2JsonRedisSerializer);
redisTemplate.setKeySerializer(new StringRedisSerializer());
return redisTemplate;
}
}
7、User
package com.xx.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
/**
* @Author: xueqimiao
* @Date: 2025/3/17 14:13
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName("user_db")
public class User implements Serializable {
@TableId(type = IdType.AUTO)
private Integer id;
private String username;
}
8、UserMapper
package com.xx.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.xx.entity.User;
/**
* @Author: xueqimiao
* @Date: 2025/3/17 14:14
*/
public interface UserMapper extends BaseMapper<User> {
}
9、UserService
package com.xx.service;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.xx.common.Result;
import com.xx.entity.User;
import com.xx.mapper.UserMapper;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
/**
* @Author: xueqimiao
* @Date: 2025/3/17 14:17
*/
@Service
public class UserService {
@Resource
private UserMapper userMapper;
public Result update(User user) {
int i = userMapper.updateById(user);
if (i > 0) {
return Result.ok(i);
}
return Result.error(500, "操作数据库失败");
}
public Result get(Integer id) {
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getId, id);
User user = userMapper.selectOne(wrapper);
return Result.ok(user);
}
}
5、自定义缓存注解@Cache
package com.xx.cache;
import java.lang.annotation.*;
/**
* @Author: xueqimiao
* @Date: 2025/3/17 14:24
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Cache {
/**
* 过期时间,默认60s
* @return
*/
long expire() default 5 * 60 * 1000;
/**
* 缓存标识name
* @return
*/
String name() default "";
}
6、CacheAspect
package com.xx.cache;
import com.alibaba.fastjson.JSON;
import com.xx.common.Result;
import com.xx.utils.RedisUtils;
import com.xx.utils.ValidationUtil;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.digest.DigestUtils;
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.springframework.stereotype.Component;
import java.lang.reflect.Method;
import java.util.concurrent.TimeUnit;
/**
* @Author: xueqimiao
* @Date: 2025/3/17 14:25
*/
@Component
@Aspect
@Slf4j
public class CacheAspect {
@Resource
private RedisUtils redisUtils;
/**
* aop切点
* 拦截被指定注解修饰的方法
*/
@Pointcut("@annotation(com.xx.cache.Cache)")
public void cache() {
}
/**
* 缓存操作
*
* @param pjp
* @return
*/
@Around("cache()")
public Object toCache(ProceedingJoinPoint pjp) {
try {
// 思路: 设置存储的格式,获取即可
Signature signature = pjp.getSignature();
// 类名
String className = pjp.getTarget().getClass().getSimpleName();
// 方法名
String methodName = signature.getName();
// 参数处理
Object[] args = pjp.getArgs();
Class[] parameterTypes = new Class[args.length];
String params = "";
for (int i = 0; i < args.length; i++) {
if (args[i] != null) {
parameterTypes[i] = args[i].getClass();
params += JSON.toJSONString(args[i]);
}
}
if (!ValidationUtil.isEmpty(params)) {
//加密 以防出现key过长以及字符转义获取不到的情况
params = DigestUtils.md5Hex(params);
}
// 获取controller中对应的方法
Method method = signature.getDeclaringType().getMethod(methodName, parameterTypes);
// 获取Cache注解
Cache annotation = method.getAnnotation(Cache.class);
long expire = annotation.expire();
String name = annotation.name();
// 访问redis(先尝试获取,没有则访问数据库)
String redisKey = name + "::" + className + "::" + methodName + "::" + params;
String redisValue = redisUtils.get(redisKey);
if (!ValidationUtil.isEmpty(redisValue)) {
// 不为空返回数据
Result result = JSON.parseObject(redisValue, Result.class);
log.info("数据从redis缓存中获取,key: {}", redisKey);
return result; // 跳出方法
}
Object proceed = pjp.proceed();// 放行
redisUtils.set(redisKey, JSON.toJSONString(proceed), expire, TimeUnit.MILLISECONDS);
log.info("数据存入redis缓存,key: {}", redisKey);
return proceed;
} catch (Throwable throwable) {
return Result.error(500, "系统错误");
}
}
}
7、自定义延迟双删注解@ClearAndReloadCache
package com.xx.cache;
import java.lang.annotation.*;
/**
* @Author: xueqimiao
* @Date: 2025/3/17 14:26
*/
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Target(ElementType.METHOD)
public @interface ClearAndReloadCache {
String name() default "";
}
8、ClearAndReloadCacheAspect
package com.xx.cache;
import com.xx.utils.RedisUtils;
import jakarta.annotation.Resource;
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.stereotype.Component;
import java.lang.reflect.Method;
import java.util.Set;
/**
* @Author: xueqimiao
* @Date: 2025/3/17 14:26
*/
@Aspect
@Component
@Slf4j
public class ClearAndReloadCacheAspect {
@Resource
private RedisUtils redisUtils;
/**
* 切入点
* 切入点,基于注解实现的切入点 加上该注解的都是Aop切面的切入点
*/
@Pointcut("@annotation(com.xx.cache.ClearAndReloadCache)")
public void pointCut() {
}
/**
* 环绕通知第一个参数必须是org.aspectj.lang.ProceedingJoinPoint类型
*
* @param proceedingJoinPoint
*/
@Around("pointCut()")
public Object aroundAdvice(ProceedingJoinPoint proceedingJoinPoint) {
log.info("----------- 环绕通知 -----------");
log.info("环绕通知的目标方法名:" + proceedingJoinPoint.getSignature().getName());
Signature signature = proceedingJoinPoint.getSignature();
MethodSignature methodSignature = (MethodSignature) signature;
Method targetMethod = methodSignature.getMethod();//方法对象
ClearAndReloadCache annotation = targetMethod.getAnnotation(ClearAndReloadCache.class);//反射得到自定义注解的方法对象
String name = annotation.name();//获取自定义注解的方法对象的参数即name
Set<String> keys = redisUtils.keys("*" + name + "*");//模糊定义key
redisUtils.del(keys);//模糊删除redis的key值
//执行加入双删注解的改动数据库的业务 即controller中的方法业务
Object proceed = null;
try {
proceed = proceedingJoinPoint.proceed();//放行
} catch (Throwable throwable) {
throwable.printStackTrace();
}
//新开开一个线程延迟0.5秒(时间可以改成自己的业务需求),等着mysql那边业务操作完成
//在线程中延迟删除 同时将业务代码的结果返回 这样不影响业务代码的执行
new Thread(() -> {
try {
Thread.sleep(500);
Set<String> keys1 = redisUtils.keys("*" + name + "*");//模糊删除
redisUtils.del(keys1);
log.info("-----------0.5秒后,在线程中延迟删除完毕 -----------");
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
return proceed;//返回业务代码的值
}
}
9、UserController
可以在 redisUtils.del(keys)的时候打断点调试
package com.xx.controller;
import com.xx.cache.Cache;
import com.xx.cache.ClearAndReloadCache;
import com.xx.common.Result;
import com.xx.entity.User;
import com.xx.service.UserService;
import jakarta.annotation.Resource;
import org.springframework.web.bind.annotation.*;
/**
* @Author: xueqimiao
* @Date: 2025/3/17 14:27
*/
@RestController
@RequestMapping("/user")
public class UserController {
@Resource
private UserService userService;
@PostMapping("/updateData")
@ClearAndReloadCache(name = "get_user")
public Result updateData(@RequestBody User user) {
return userService.update(user);
}
@GetMapping("/get")
@Cache(name = "get_user")
public Result get(@RequestParam Integer id) {
return userService.get(id);
}
}
写在最后
Java是一种强大的语言,它赋予了我们创造无限可能的力量。无论你是在开发企业级应用、移动应用还是大数据处理系统,Java都在背后默默支持着你。在这个瞬息万变的技术时代,保持学习的热情和对新技术的敏感度是至关重要的。每一个新的Java版本发布,都带来了新的特性和改进,这是我们提升自己的绝佳机会。让我们一起在Java的道路上勇往直前,用代码构建更加美好的数字世界,因为你手中的代码,有可能成为下一个改变世界的伟大发明。