架構設計之自定義延遲雙刪緩存注解(上)
小薛博客官方架構設計之自定義延遲雙刪緩存注解(上)地址
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: 8001spring:data:redis:database: 0host: 127.0.0.1password: 123456port: 6379datasource: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/Shanghaiurl: jdbc:mysql://127.0.0.1:3306/xx_db2025?connectTimeout=6000&socketTimeout=6000&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=trueusername: rootpassword: 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.enumsconfiguration:# 是否開啟自動駝峰命名規則映射:從數據庫列名到Java屬性駝峰命名的類似映射map-underscore-to-camel-case: true# 如果查詢結果中包含空值的列,則 MyBatis 在映射的時候,不會映射這個字段call-setters-on-nulls: true# 這個配置會將執行的sql打印出來,在開發或測試的時候可以用log-impl: org.apache.ibatis.logging.stdout.StdOutImplcache-enabled: truedb-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 {@Resourceprivate 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 {@Beanpublic 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 {@Resourceprivate 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 {@Resourceprivate 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 {@Resourceprivate 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();//獲取自定義注解的方法對象的參數即nameSet<String> keys = redisUtils.keys("*" + name + "*");//模糊定義keyredisUtils.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 {@Resourceprivate 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的道路上勇往直前,用代碼構建更加美好的數字世界,因為你手中的代碼,有可能成為下一個改變世界的偉大發明。