记录专辑详情缓存、Redisson 分布式锁、自定义缓存注解、布隆过滤器和 MySQL/Redis 一致性问题。

本篇要点

  • 给专辑详情增加缓存
  • 用 Redisson 控制热点回源
  • 区分穿透、击穿和缓存一致性

**学习提示:**Redisson 在这里解决并发锁问题,不是分布式事务;布隆过滤器主要用于拦截不存在的键。

内容回顾

1、缓存问题

  • 穿透
  • 雪崩
  • 击穿
  • 数据一致性

2、分布式锁

  • 使用分布式锁解决缓存击穿问题

  • 使用Redis实现分布式锁

  • 使用Redis里面 setnx + 过期时间 + uuid + lua脚本

3、分布式锁四个条件

  • 互斥性。
  • 不会发生死锁。
  • 解铃还须系铃人。
  • 加锁和解锁必须具有原子性。

今天内容

1、专辑详情添加缓存

  • 缓存添加 + 解决缓存击穿(分布式锁)
  • setnx + 过期时间 + uuid + lua脚本实现

在专辑详情service方法里面添加

image-20251103091112231

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
//修改-根据专辑id获取专辑数据
@Override
public AlbumInfo getAlbumInfo(Long albumId) {
// "album:info:"+1
String albumKey = RedisConstant.ALBUM_INFO_PREFIX+albumId;
//1 根据redis里面key:专辑id查询redis
AlbumInfo albumInfo = (AlbumInfo)redisTemplate.opsForValue().get(albumKey);
//2 如果没有查询到数据
if(albumInfo == null) {
//2.0 添加分布式锁解决缓存击穿问题
// setnx加锁 + uuid + 过期时间 + LUA脚本
String lockKey = RedisConstant.ALBUM_INFO_PREFIX+albumId+ ":lock";
String uuid = UUID.randomUUID().toString();
Boolean ifAbsent = redisTemplate.opsForValue().setIfAbsent(lockKey, uuid,
5, TimeUnit.SECONDS);
if(ifAbsent) { //加锁成功
try {
//2.1 根据专辑id查询mysql,把数据返回,并且把数据放到redis里面
AlbumInfo albumInfoData = this.getAlbumInfoData(albumId);

if (null == albumInfoData){
// 设置控制存储到缓存
AlbumInfo albumInfo1 = new AlbumInfo();
redisTemplate.opsForValue().set(albumKey,albumInfo1,RedisConstant.ALBUM_TEMPORARY_TIMEOUT,TimeUnit.SECONDS);
return albumInfo1;
}

//把查询mysql数据放到redis里面
redisTemplate.opsForValue().set(albumKey,albumInfoData,
10, TimeUnit.MINUTES);
return albumInfoData;
}finally {
//解锁,lua脚本
delRedisKey(lockKey, uuid);
}
} else { //没有获取锁
//自旋
// 没有获取到锁的线程,自旋
return getAlbumInfo(albumId);
}
} else {//3 如果查询到数据,直接返回
return albumInfo;
}
}

//解锁
private void delRedisKey(String lockKey, String uuid) {
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
String script = "if redis.call(\"get\",KEYS[1]) == ARGV[1]\n" +
"then\n" +
" return redis.call(\"del\",KEYS[1])\n" +
"else\n" +
" return 0\n" +
"end";
redisScript.setScriptText(script);
redisScript.setResultType(Long.class);
redisTemplate.execute(redisScript, Arrays.asList(lockKey),uuid);
}
  • 上面代码虽然可以实现缓存+分布式锁功能,但是有两个缺点

第一个:代码太复杂了(使用Redisson减少复杂度

第二个:业务代码和加锁解锁代码混合在一起(自定义注解+aop简化

2、Redisson实现分布式锁

概述

  • Redisson是一个基于Redis的Java客户端,提供了丰富的功能,比如分布式锁,布隆过滤器,延迟队列等等,使用很简单代码实现这些强大功能

  • 目前使用Redisson实现分布式锁。

  • Redisson实现分布式锁有两个重要机制!!

第一个机制:重试机制

重试机制是指在分布式锁中,如果没有获取到锁,Redisson会自动进行重试,直到获取到锁或者超时。当尝试获取锁时,需要传入了时间参数!

第二个机制:看门狗机制(WatchDog,自动续期机制)

Redisson会启动一个守护线程来监控锁的情况,如果锁快要过期了,守护线程会自动续期。目的是保证操作在有锁状态下执行。

如果启动看门狗机制,首先不能传递过期时间,使用默认过期时间是30s

每个10s检查一次,当前锁是否被持有,如果被持有,对锁进行续期,续期到30s

底层都是通过 lua脚实现的

专辑详情使用Redisson分布式锁

第一步 引入Redisson依赖

  • 在service-util模块引入依赖
1
2
3
4
5
<!-- redisson -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
</dependency>

第二步 初始化RedissonClient对象

  • 在service-util模块创建配置类,初始化RedissonClient对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Data
@Configuration
@ConfigurationProperties("spring.data.redis")
public class RedissonConfig {
private String host;

private String password;

private String port;

private int timeout = 3000;
private static String ADDRESS_PREFIX = "redis://";

/**
* 自动装配
*
*/
@Bean
RedissonClient redissonSingle() {
Config config = new Config();

if(StringUtils.isEmpty(host)){
throw new RuntimeException("host is empty");
}
SingleServerConfig serverConfig = config.useSingleServer()
.setAddress(ADDRESS_PREFIX + this.host + ":" + port)
.setTimeout(this.timeout);
if(!StringUtils.isEmpty(this.password)) {
serverConfig.setPassword(this.password);
}
return Redisson.create(config);
}
}

第三步 在专辑详情方法添加缓存+分布式锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@Autowired
private RedissonClient redissonClient;

//使用Redisson实现分布式锁
public AlbumInfo getAlbumInfoRedisson(Long albumId) throws Exception {
String albumKey = RedisConstant.ALBUM_INFO_PREFIX+albumId;
//1 查询redis
AlbumInfo albumInfo =
(AlbumInfo)redisTemplate.opsForValue().get(albumKey);

//2 如果没有查询,查询mysql,返回数据,放到redis里面
if(albumInfo == null) {
// 缓存击穿问题,添加分布式锁
String lockKey = RedisConstant.ALBUM_INFO_PREFIX+albumId+ ":lock";
//获取锁对象
RLock rLock = redissonClient.getLock(lockKey);
//加锁
boolean tryLock = rLock.tryLock(3,5,TimeUnit.SECONDS);
if(tryLock) { //加锁成功
try {
AlbumInfo albumInfoData = this.getAlbumInfoData(albumId);

redisTemplate.opsForValue().set(albumKey,albumInfoData,
10,TimeUnit.MINUTES);
return albumInfoData;
}finally {
//解锁
rLock.unlock();
}

} else {
//自旋
this.getAlbumInfoRedisson(albumId);
}
} else {
//3 如果查询到数据,直接返回
return albumInfo;
}
return null;
}

3、自定义注解+AOP专辑缓存

概述

  • 使用Redisson实现分布式锁,但是目前代码存在缺陷,加锁和加锁代码和业务代码混合在一起

  • 目的:加锁解锁代码 和 业务代码分离

  • 实现方式:自定义注解 + AOP方式实现

专辑详情方法还原

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//修改-根据专辑id获取专辑数据
@Override
public AlbumInfo getAlbumInfo(Long albumId) {
return this.getAlbumInfoData(albumId);
}

//根据专辑id查询mysql方法
private AlbumInfo getAlbumInfoData(Long albumId) {
//1 根据专辑id获取专辑基本信息
AlbumInfo albumInfo = albumInfoMapper.selectById(albumId);

if(albumInfo != null) {
//2 根据专辑id获取标签数据
LambdaQueryWrapper<AlbumAttributeValue> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(AlbumAttributeValue::getAlbumId,albumId);
List<AlbumAttributeValue> albumAttributeValueList = albumAttributeValueMapper.selectList(wrapper);

//3 把获取标签数据list集合封装到专辑对象里面
albumInfo.setAlbumAttributeValueVoList(albumAttributeValueList);
}
return albumInfo;
}

创建自定义注解

  • 在service-util添加自定义注解
1
2
3
4
5
6
7
8
9
10
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface GuiGuCache {

/**
* 缓存key的前缀
* @return
*/
String prefix() default "cache";
}

创建切面类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
@Aspect   //切面类
@Component
public class GuiGuCacheAspect {

@Autowired
private RedisTemplate redisTemplate;

@Autowired
private RedissonClient redissonClient;

@SneakyThrows
@Around("@annotation(com.atguigu.tingshu.common.cache.GuiGuCache)") //方法上包含GuiGuCache注解执行环绕通知
public Object cacheAspect(ProceedingJoinPoint joinPoint) throws Throwable {
//1 从带GuiGuCache注解方法里面,获取注解里面prefix值
//获取带GuiGuCache注解方法里面参数值
//@GuiGuCache(prefix = "album:info:")
//public AlbumInfo getAlbumInfo(Long albumId) {
//获取被增强方法参数列表
Object[] args = joinPoint.getArgs();
//从被增强方法上面,获取GuiGuCache注解里面prefix属性的值
MethodSignature signature = (MethodSignature)joinPoint.getSignature();
Method method = signature.getMethod();
GuiGuCache guiGuCache = method.getAnnotation(GuiGuCache.class);
//guiGuCache获取属性值
String prefix = guiGuCache.prefix();
//通过上面两部分值构建redis里面key album:info:+albumId
String key = prefix + Arrays.asList(args).toString();

//2 查询redis
Object obj = redisTemplate.opsForValue().get(key);

//3 如果没有查询到,查询mysql,把mysql数据放到redis里面,返回查询数据
if (obj == null) {
//查询mysql防止缓存击穿问题,Redisson添加分布式锁
RLock rLock = redissonClient.getLock(key + ":lock");
boolean tryLock = rLock.tryLock(3, 5,
TimeUnit.SECONDS);
if(tryLock) {//获取锁成功
try {
//查询mysql
obj = joinPoint.proceed(args);
if (null == obj){
// 并把结果放入缓存
Object o = new Object();
this.redisTemplate.opsForValue().set(key, o, 10, TimeUnit.MINUTES);
return o;
}
//把mysql数据放到redis里面,返回查询数据
redisTemplate.opsForValue().set(key, obj,10, TimeUnit.MINUTES);
return obj;
}finally {
//释放锁
rLock.unlock();
}

} else {//获取锁失败,自旋
// 没有获取到锁的用户自旋
return cacheAspect(joinPoint);
}
} else {
//4 如果redis查询到数据,直接返回
return obj;
}
}
}

在专辑详情方法上添加注解

1
2
3
4
5
6
7
//修改-根据专辑id获取专辑数据
// album:info:+albumId
@GuiGuCache(prefix = "album:info:")
@Override
public AlbumInfo getAlbumInfo(Long albumId) {
return this.getAlbumInfoData(albumId);
}

4、布隆过滤器

概述

  • 缓存穿透问题:查询数据,在缓存和mysql都不存在
  • 解决方案一:把null添加缓存,没法防止随机穿透
  • 解决方案二:使用布隆过滤器解决

布隆过滤器(Bloom Filter),是1970年,由一个叫布隆的小伙子提出的,距今已经五十年了。

  • 它实际上是一个很长的二进制向量和一系列随机映射函数。

    – 是一个存储0或者1的数组,0或者1 代表不同含义

    – 通过映射函数决定数据放到数组中的哪个位置

  • 如何把数据放到布隆过滤器?

image-20251103151702378

第一步 使用n个映射函数计算数据在数组里面位置,n个映射函数计算出n个位置

第二步 把数组中计算出来的位置的值修改为1

  • 如何判断一个数据在数组里面是否存在?

第一步 使用n个映射函数计算数据在数组里面位置,n个映射函数计算出n个位置

第二步 查看计算出位置的值,如果有任何一个值是0肯定不存在的,如果位置中值都是1可能存在

  • 因为计算hash值可能相同的,有一定的误识别率和删除困难

  • 误判率和数据大小、映射函数个数以及数组长度相关的

专辑详情添加布隆过滤器

image-20251103153155721

初始化布隆过滤器

  • 一次性把所有专辑id都放到布隆过滤器里面
  • 每次添加专辑之后,把专辑id放到布隆过滤器里面
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@SpringBootApplication
@EnableDiscoveryClient
@EnableFeignClients
public class ServiceAlbumApplication implements CommandLineRunner {

public static void main(String[] args) {
SpringApplication.run(ServiceAlbumApplication.class, args);
}

@Autowired
private RedissonClient redissonClient;

@Autowired
private AlbumInfoService albumInfoService;

/**
* Springboot应用初始化后会执行一次该方法
* @param args
* @throws Exception
*/
@Override
public void run(String... args) throws Exception {
//1 创建布隆过滤器
RBloomFilter<Object> bloomFilter =
redissonClient.getBloomFilter(RedisConstant.ALBUM_BLOOM_FILTER);
//2 设置布隆过滤器预计元素个数和期望误判率
bloomFilter.tryInit(100000,0.01);
//3 查询所有专辑id,把所有专辑id放到布隆过滤器里面
List<AlbumInfo> list = albumInfoService.list();
for (AlbumInfo albumInfo : list) {
//专辑id
Long albumId = albumInfo.getId();
//把albumId放到布隆过滤器
bloomFilter.add(albumId);
}
}
}

在专辑详情方法里面添加布隆过滤器

1
2
3
4
5
6
7
8
9
10
11
12
13
//修改-根据专辑id获取专辑数据
@GetMapping("getAlbumInfo/{albumId}")
public Result<AlbumInfo> getAlbumInfo(@PathVariable Long albumId) {
//添加布隆过滤器判断
RBloomFilter<Object> bloomFilter =
redissonClient.getBloomFilter(RedisConstant.ALBUM_BLOOM_FILTER);
if(!bloomFilter.contains(albumId)) {
throw new GuiguException(228,"专辑不存在:"+albumId);
}

AlbumInfo albumInfo = albumInfoService.getAlbumInfo(albumId);
return Result.ok(albumInfo);
}

5、mysql 与 redis 数据一致性

概述

  • 专辑数据添加redis缓存,如果修改mysql数据之后,同步redis不及时,可能造成mysql和redis数据不一致
  • 登录之后用户信息添加redis里面,如果修改用户信息,同步redis不及时,可能造成mysql和redis数据不一致

延时双删策略

  1. 先删除缓存;
  2. 然后执行写数据库的操作;
  3. 休眠一段时间(例如500毫秒);
  4. 再次删除缓存。

image-20251103162012158

  • 使用以上策略不能保证数据实时一致性的,只能保证数据最终一致性

canal同步方案

image-20251103162614241

  • canal是阿里巴巴开发一款数据同步工具,相当于mysql主从复制中从机角色
  • 机制:

– canal实时监控主机里面二进制日志变化,当二进制日志发生变化,canal实时获取变化的数据,把变化数据同步到redis里面