redis缓存、分布式锁、Spring Cache
一、缓存简介
为了系统性能的提升,我们一般都会将部分数据放入缓存中,加速访问。而 db 承担数据落
盘工作。
哪些数据适合放入缓存?
- 及时性、数据一致性要求不高的
- 访问量大且更新频率不高的数据(读多,写少)
二、分布式锁简介
大型网站及应用基本上都是以分布式部署,分布式场景中的数据一致性问题一直是一个比较重要的话题。分布式的CAP理论告诉我们“任何一个分布式系统都无法同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance),最多只能同时满足两项。”所以,很多系统在设计之初就要对这三者做出取舍。在互联网领域的绝大多数的场景中,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证“最终一致性”,只要这个最终时间是在用户可以接受的范围内即可。
三、缓存失效问题
缓存穿透
缓存穿透是指 查询一个一定不存在的数据,由于缓存是不命中,将去查询数据库,但是数据库也无此记录,我们没有将这次查询的 null 写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。在流量大时,可能 DB 就挂掉了,要是有人利用不存在的 key 频繁攻击我们的应用,这就是漏洞。
解决方法:缓存空结果、并且设置短的过期时间。
缓存雪崩
缓存雪崩是指在我们设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到 DB,DB 瞬时压力过重雪崩。
解决方法:原有的失效时间基础上增加一个随机值,比如 1-5 分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
缓存击穿
对于一些设置了过期时间的 key,如果这些 key 可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。这个时候,需要考虑一个问题:如果这个 key 在大量请求同时进来前正好失效,那么所有对这个 key 的数据查询都落到 db,我们称为缓存击穿。
解决方法:加锁。大量并发只让一个人去查,其他人等待,查到之后释放锁,其他人获取到锁,先查缓存,就会有数据,不用去查数据库。
四、缓存数据一致性问题
1、双写模式
数据更新—>写数据库—>写缓存:读到最新数据有延迟,最终一致性
有可能产生脏数据,但在数据稳定、缓存过期以后,又能得到最新的正确数据
2、失效模式
数据更新—>写数据库—>删缓存:读到最新数据有延迟,最终一致性
3、分布式读写锁
分布式读写锁。读数据等待写数据整个操作完成
@Autowired
private RedissonClient redisson;
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
* 保证一定能读到最新数据,修改期间,写锁是一个排它锁(互斥锁、独享锁),读锁是一个共享锁
* 写锁没释放读锁必须等待
* 读 + 读 :相当于无锁,并发读,只会在Redis中记录好,所有当前的读锁。他们都会同时加锁成功
* 写 + 读 :必须等待写锁释放
* 写 + 写 :阻塞方式
* 读 + 写 :有读锁。写也需要等待
* 只要有读或者写的存都必须等待
* @return
*/
@GetMapping(value = "/write")
@ResponseBody
public String writeValue() {
String s = "";
RReadWriteLock readWriteLock = redisson.getReadWriteLock("rw-lock");
RLock rLock = readWriteLock.writeLock();
try {
//1、改数据加写锁,读数据加读锁
rLock.lock();
System.out.println("写锁加锁成功:"+Thread.currentThread().getId());
s = UUID.randomUUID().toString();
ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
ops.set("writeValue",s);
//TimeUnit.SECONDS.sleep(30);
Thread.sleep(30000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
rLock.unlock();
System.out.println("写锁释放:"+Thread.currentThread().getId());
}
return s;
}
@GetMapping(value = "/read")
@ResponseBody
public String readValue() {
String s = "";
RReadWriteLock readWriteLock = redisson.getReadWriteLock("rw-lock");
//加读锁
RLock rLock = readWriteLock.readLock();
try {
rLock.lock();
System.out.println("读锁加锁成功:"+Thread.currentThread().getId());
ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
s = ops.get("writeValue");
//try { TimeUnit.SECONDS.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); }
Thread.sleep(30000);
} catch (Exception e) {
e.printStackTrace();
} finally {
rLock.unlock();
System.out.println("读锁释放:"+Thread.currentThread().getId());
}
return s;
}
五、整合redis
1、引入redis依赖包
lettuce 客户端:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
注意:这里可能报堆外内存溢出异常(OutOfDirectMemoryError):升级 lettuce 客户端,或使用 jedis 客户端,即可解决堆外内存溢出异常。
jedis 客户端:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>
2、编写application.yml配置文件
spring:
redis:
host: 192.168.1.123
port: 6379
3、给业务中加入缓存
这里使用依赖中自带的 StringRedisTemplate 来操作 Redis。这里存储的值为转化成 JSON 字符串的对象信息。
@Autowired
private StringRedisTemplate redisTemplate;
// 1.先从缓存中读取分类信息
String testJSON = redisTemplate.opsForValue().get("testJSON");
if (StringUtils.isEmpty(testJSON)) {
// 2. 缓存中没有,查询数据库
// 3. 查询到的数据存放到缓存中,将对象转成 JSON 存储
redisTemplate.opsForValue().set("testJSON", JSON.toJSONString("从数据库查出的数据"));
}
六、本地锁
- 1、先读取redis缓存中数据
- 2、是否命中
- 3、命中—>返回结果—>结束
- 4、未命中—>加本地锁—>再次确认(即查缓存)—>如果有数据直接返回结束—>否则查询数据库—>将数据放入redis缓存中—>返回数据—>结束
//只要是同一把锁,就能锁住需要这个锁的所用线程
//1、synchronized (this):this表示当前对象:spring boot所有的组件在容器中都是单列的
// 本地锁:synchronized,JUC(Lock),在分布式情况下,想要锁住所有,必须使用分布式锁
public String localLockTest() {
//1、先读取redis缓存中数据
//2、是否命中
//3、命中—>返回结果—>结束
//4、未命中
synchronized (this) { //加本地锁
//再次确认(即查缓存)—>如果有数据直接返回结束—>否则查询数据库—>将数据放入redis缓存中—>返回数据—>结束
}
}
七、分布式锁
本地锁只能锁住当前服务的进程,每一个单独的服务都会有一个进程读取数据库,不能达到只读取依次数据库的效果,所以需要分布式锁。
- 1、先读取redis缓存中数据
- 2、是否命中
- 3、命中—>返回结果—>结束
- 4、未命中—>加分布式锁—>再次确认(即查缓存)—>如果有数据直接返回结束—>否则查询数据库—>将数据放入redis缓存中—>返回数据—>结束
1、redis分布式锁
redis 中有一个 SETNX 命令,该命令会向 redis 中保存一条数据,如果不存在则保存成功,存在则返回失败。
我们约定保存成功即为加锁成功,之后加锁成功的线程才能执行真正的业务操作。
public String redisLock() {
//1、占分布式锁(即到redis中占到位置)
String uuid = UUID.randomUUID().toString();
// 设置过期时间和加锁必须是同步的,即原子的
Boolean lock = redisTemplate.opsForValue().setIfAbsent("lock", uuid, 300, TimeUnit.SECONDS);
if (lock) {
System.out.println("获取分布式锁成功");
String dataFromDb;
try {
//加锁成功...执行业务(即可以存入数据)
dataFromDb = getDataFromDb(); //==========需加锁执行的业务=========
} finally {
// lua 脚本解锁
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
// 删除锁
redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class),
//Collections.singletonList("lock"), uuid);
Arrays.asList("lock"), uuid);
}
return dataFromDb;
} else {
System.out.println("获取分布式锁失败");
//加锁失败...重试。 synchronized:自旋不断监听是否有释放锁
//休眠 100ms 重试
try {
TimeUnit.MILLISECONDS.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
return getCatalogJsonFromDbWithRedisLock(); //自旋的方式
}
}
2、redisson分布式锁
官方文档:https://github.com/redisson/redisson/wiki
- 锁的自动续期,如果业务时间很长,运行期间自动给锁续期 30 s,不用担心业务时间过长,锁自动过期被删掉;
- 加锁的业务只要运行完成,就不会给当前锁续期,即使不手动续期,默认也会在 30 s 后解锁;
(1)引入依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.16.6</version>
</dependency>
(2) 编写redisson配置文件
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.io.IOException;
@Configuration
public class MyRedissonConfig {
/**
* 所有对 Redisson 的使用都是通过 RedissonClient
*
* @return
* @throws IOException
*/
@Bean(destroyMethod = "shutdown")
public RedissonClient redisson() throws IOException {
// 1、创建配置
Config config = new Config();
// Redis url should start with redis:// or rediss://
config.useSingleServer().setAddress("redis://192.168.1.123:6379");
// 2、根据 Config 创建出 RedissonClient 实例
return Redisson.create(config);
}
}
(3)可重入锁
@Autowired
private RedissonClient redisson;
@ResponseBody
@GetMapping(value = "/hello")
public String hello() {
//1、获取一把锁,只要锁的名字一样,就是同一把锁
RLock myLock = redisson.getLock("my-lock");
//2、加锁,可重入锁
myLock.lock(); //阻塞式等待。默认加的锁都是30s
//1)、锁的自动续期,如果业务超长,运行期间自动锁上新的30s。不用担心业务时间长,锁自动过期被删掉
//2)、加锁的业务只要运行完成,就不会给当前锁续期,即使不手动解锁,锁默认会在30s内自动过期,不会产生死锁问题
// myLock.lock(10,TimeUnit.SECONDS); //10秒钟自动解锁,自动解锁时间一定要大于业务执行时间
//问题:在锁时间到了以后,不会自动续期
//1、如果我们传递了锁的超时时间,就发送给redis执行脚本,进行占锁,默认超时就是 我们制定的时间
//2、如果我们指定锁的超时时间,就使用 lockWatchdogTimeout = 30 * 1000 【看门狗默认时间】
//只要占锁成功,就会启动一个定时任务【重新给锁设置过期时间,新的过期时间就是看门狗的默认时间】,每隔10秒都会自动的再次续期,续成30秒
// internalLockLeaseTime 【看门狗时间】 / 3, 10s
try {
System.out.println("加锁成功,执行业务..." + Thread.currentThread().getId());
try { TimeUnit.SECONDS.sleep(20); } catch (InterruptedException e) { e.printStackTrace(); }
} catch (Exception ex) {
ex.printStackTrace();
} finally {
//3、解锁 假设解锁代码没有运行,Redisson会不会出现死锁
System.out.println("释放锁..." + Thread.currentThread().getId());
myLock.unlock();
}
return "hello";
}
(4)读写锁
@Autowired
private RedissonClient redisson;
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
* 保证一定能读到最新数据,修改期间,写锁是一个排它锁(互斥锁、独享锁),读锁是一个共享锁
* 写锁没释放读锁必须等待
* 读 + 读 :相当于无锁,并发读,只会在Redis中记录好,所有当前的读锁。他们都会同时加锁成功
* 写 + 读 :必须等待写锁释放
* 写 + 写 :阻塞方式
* 读 + 写 :有读锁。写也需要等待
* 只要有读或者写的存都必须等待
* @return
*/
@GetMapping(value = "/write")
@ResponseBody
public String writeValue() {
String s = "";
RReadWriteLock readWriteLock = redisson.getReadWriteLock("rw-lock");
RLock rLock = readWriteLock.writeLock();
try {
//1、改数据加写锁,读数据加读锁
rLock.lock();
System.out.println("写锁加锁成功:"+Thread.currentThread().getId());
s = UUID.randomUUID().toString();
ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
ops.set("writeValue",s);
//TimeUnit.SECONDS.sleep(30);
Thread.sleep(30000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
rLock.unlock();
System.out.println("写锁释放:"+Thread.currentThread().getId());
}
return s;
}
@GetMapping(value = "/read")
@ResponseBody
public String readValue() {
String s = "";
RReadWriteLock readWriteLock = redisson.getReadWriteLock("rw-lock");
//加读锁
RLock rLock = readWriteLock.readLock();
try {
rLock.lock();
System.out.println("读锁加锁成功:"+Thread.currentThread().getId());
ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
s = ops.get("writeValue");
//try { TimeUnit.SECONDS.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); }
Thread.sleep(30000);
} catch (Exception e) {
e.printStackTrace();
} finally {
rLock.unlock();
System.out.println("读锁释放:"+Thread.currentThread().getId());
}
return s;
}
(5)信号量(Semaphore)
/**
* 信号量(Semaphore)
* 车库停车
* 3车位
* 信号量也可以做分布式限流
*/
@GetMapping(value = "/park")
@ResponseBody
public String park() throws InterruptedException {
RSemaphore park = redisson.getSemaphore("park");
park.acquire(); //获取一个信号、获取一个值,占一个车位
boolean flag = park.tryAcquire();
if (flag) {
//执行业务
} else {
return "error";
}
return "ok=>" + flag;
}
@GetMapping(value = "/go")
@ResponseBody
public String go() {
RSemaphore park = redisson.getSemaphore("park");
park.release(); //释放一个车位
return "ok";
}
(6)闭锁(CountDownLatch)
/**
* 闭锁(CountDownLatch)
* 放假、锁门
* 1班没人了
* 5个班,全部走完,我们才可以锁大门
* 分布式闭锁
*/
@GetMapping(value = "/lockDoor")
@ResponseBody
public String lockDoor() throws InterruptedException {
RCountDownLatch door = redisson.getCountDownLatch("door");
door.trySetCount(5);
door.await(); //等待闭锁完成
return "放假了...";
}
@GetMapping(value = "/gogogo/{id}")
@ResponseBody
public String gogogo(@PathVariable("id") Long id) {
RCountDownLatch door = redisson.getCountDownLatch("door");
door.countDown(); //计数-1
return id + "班的人都走了...";
}
八、Spring Cache
1、简介
- Spring 从 3.1 开始定义了 org.springframework.cache.Cache和org.springframework.cache.CacheManager 接口来统一不同的缓存技术;并支持使用 JCache(JSR-107)注解简化我们开发;
- Cache 接口为缓存的组件规范定义,包含缓存的各种操作集合;Cache 接 口 下 Spring 提 供 了 各 种 xxxCache 的 实 现 ; 如 RedisCache , EhCacheCache , ConcurrentMapCache 等;
- 每次调用需要缓存功能的方法时,Spring 会检查检查指定参数的指定的目标方法是否已 经被调用过;如果有就直接从缓存中获取方法调用后的结果,如果没有就调用方法并缓 存结果后返回给用户。下次调用直接从缓存中获取。
- 使用 Spring 缓存抽象时我们需要关注以下两点;
- 1、确定方法需要被缓存以及他们的缓存策略
- 2、从缓存中读取之前缓存存储的数据
2、引入依赖
<!-- 引入redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--引入SpringCache依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
3、添加application.yml配置
spring:
redis:
host: 192.168.1.123
port: 6379
cache:
# 缓存类型
type: redis
redis:
# 缓存过期时间设置一个小时, 毫秒为单位
time-to-live: 3600000
#如果指定了前缀就用我们指定的前缀,如果没有就默认使用缓存的名字作为前缀
#key-prefix: CACHE_
use-key-prefix: true
#是否缓存空值,防止缓存穿透
cache-null-values: true
4、编写Java配置文件(可按需求添加)
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.cache.CacheProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@EnableConfigurationProperties(CacheProperties.class)
@Configuration
@EnableCaching
public class MyCacheConfig {
/* @Autowired
private CacheProperties cacheProperties;*/
@Bean
public RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties){
//存入缓存中的数据设置为json格式
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
config = config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()));
config = config.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
//让配置文件有效,即设置的失效时间有效
CacheProperties.Redis redisProperties = cacheProperties.getRedis();
if (redisProperties.getTimeToLive() != null) {
config = config.entryTtl(redisProperties.getTimeToLive());
}
if (redisProperties.getKeyPrefix() != null) {
config = config.prefixKeysWith(redisProperties.getKeyPrefix());
}
if (!redisProperties.isCacheNullValues()) {
config = config.disableCachingNullValues();
}
if (!redisProperties.isUseKeyPrefix()) {
config = config.disableKeyPrefix();
}
return config;
}
}
5、使用
/**
* @Cacheable({"category"}) //代表当前方法的结果需要缓存,如果缓存中有,方法不用调用,
* 如果缓存中没有,会调用方法,最后将结果存入缓存中。
* 1、key默认生成,这里默认缓存名字category::SimpleKey []
* 2、默认ttl时间为 -1,永不失效
* 3、缓存中的value值,默认使用jdk序列化机制,将序列化的数据存入redis
* 4、如果缓存中有数据,方法不用调用
* 5、默认无加锁
* 自定义:1、指定缓存中使用的key,key属性指定接受一个SpEL
* 2、指定缓存数据的失效时间,可以在配置文件配置
* 3、数据保存格式设置为json
*/
//使用sync = true 开启加锁来解决击穿问题
@Cacheable(value = "category", key = "#root.methodName",sync = true)
//使用@Caching组合使用
//失效模式:比如更新数据,先更新数据库,再删除缓存中的数据,再从数据库获取数据时,再存入缓存
@Caching(evict = {
@CacheEvict(value = "category",key = "'getLevel1Categorys'"),
@CacheEvict(value = "category",key = "'getCatalogJson'")
})
//指定删除某个分区下的所有数据
@CacheEvict(value = "category",allEntries = true)
6、常用注解
-
@Cacheable :触发将数据保存到缓存的操作;
-
@CacheEvict : 触发将数据从缓存删除的操作;
-
@CachePut :不影响方法执行更新缓存;
-
@Cacheing:组合以上多个操作;
-
@CacheConfig:在类级别共享缓存的相同配置;
7、spring-cache总结
- Spring-Cache的不足之处:
- 1)、读模式
- 缓存穿透:查询一个null数据。解决方案:缓存空数据
- 缓存击穿:大量并发进来同时查询一个正好过期的数据。解决方案:加锁 ? 默认是无加锁的;使用sync = true来解决击穿问题
- 缓存雪崩:大量的key同时过期。解决:加随机时间。加上过期时间
- 2)、写模式:(缓存与数据库一致)
- 1)、读写加锁。
- 2)、引入Canal,感知到MySQL的更新去更新Redis
- 3)、读多写多,直接去数据库查询就行
- 1)、读模式
- 总结:
- 常规数据(读多写少,即时性,一致性要求不高的数据,完全可以使用Spring-Cache):写模式(只要缓存的数据有过期时间就足够了)
- 特殊数据:特殊设计
- 原理:
CacheManager(RedisCacheManager)->Cache(RedisCache)->Cache负责缓存的读写