章节目录:
一、缓存
1.1 什么是缓存?
缓存是计算机已经接收并使用过一次,然后保存以备将来使用的数据。
为了系统性能的提升,我们一般都会将部分数据放入缓存中。在一些场景下会直接从缓存中获取数据,这样就释放了频繁读取带给数据库的压力,而数据库则主要承担数据落盘工作。这样可以有效加快页面加载速度,就如同你一次买了一个星期的食物,存放在冰箱里面,然后就不去商店,而是直接去冰箱取就行了一样。
1.2 什么数据适合放入缓存?
- 即时性、一致性要求不高的数据。
- 读多写少,访问量大且更新频率不高的数据。
1.3 读模式下的缓存流程
- 流程图:
-
伪代码说明:
public class SampleDemo { public static void main(String[] args) { // 从缓存加载数据 data = cache.load(id); if(data == null){ // 从数据库加载数据 data = db.load(id); // 保存到 缓存 中 cache.put(id,data); } return data; } }
二、 Redis作为缓存
Redis
(Remote Dictionary Server ),即远程字典服务,是一个开源的使用ANSIC
语言编写、支持网络、可基于内存亦可持久化的日志型、Key-Value
数据库,并提供多种语言的API
。
2.1 为什么要使用Redis作为缓存?
Redis
将其数据库完全保存在内存中,仅使用磁盘进行持久化。- 与其它键值数据存储相比,
Redis
有一组相对丰富的数据类型。 Redis
可以将数据复制到任意数量的从机中。- 简而言之:使用
Redis
作为缓存可以减少数据库的压力,特别是需要频繁查询大量数据的情形下。使用Redis
还可以提高访问速度,因为Redis
的数据会被保存在内存中。
2.2 整合Redis
- 添加 jedis 客户端依赖:
<!-- redis -->
<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>
- 配置:
spring:
redis:
host: yourIp
port: 6379
- 测试示例:
@Slf4j
@RunWith(SpringRunner.class)
@SpringBootTest
public class ApplicationTests {
@Autowired
StringRedisTemplate stringRedisTemplate;
@Test
public void testInsertDataToRedis() {
ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
// 保存
ops.set("hello", "world_" + UUID.randomUUID());
// 查询
String hello = ops.get("hello");
System.out.println(" 查询保存key为 `hello` 的数据 " + hello);
}
}
三、缓存失效问题
3.1 缓存穿透
-
缓存穿透是指:查询一个一定不存在的数据,由于缓存是不命中,将去查询数据库,但是数据库也无此记录,我们没有将这次查询的
null
写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。 -
在流量大时,可能
DB
就挂掉了,要是有人利用不存在的key
频繁攻击我们的应用,这就是漏洞。 -
解决方法:缓存空结果、并且设置短的过期时间。
-
示意图:
3.2 缓存击穿
-
对于一些设置了过期时间的
key
,如果这些key
可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据。 -
这个时候,需要考虑一个问题:如果这个
key
在大量请求同时进来前正好失效,那么所有对这个key
的数据查询都落到DB
,我们称为缓存击穿。 -
解决方法:加锁。大量并发只让一个人去查,其他人等待,查到之后释放锁,其他人获取到锁,先查缓存,就会有数据,不用去查数据库。
-
示意图:
3.3 缓存雪崩
-
缓存雪崩是指在我们设置缓存时采用了相同的过期时间,导致缓存在某一时刻同时失效,请求全部转发到
DB
,DB
瞬时压力过重雪崩。 -
解决方法:原有的失效时间基础上增加一个随机值,比如 1-5 分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
-
示意图:
四、缓存数据一致性
Redis
缓存和数据库都保存了数据信息,当我们更新了数据库的数据时,应该如何保证Redis
和数据库的数据同步呢?当前比较常用的是双写模式和失效模式。
4.1 双写模式
-
双写模式:每次修改数据库的数据后,然后在更新
Redis
中的数据,使用了两次写操作,称为双写模式 -
双写模式存在的问题:高并发下有可能会有脏数据。
-
场景说明:
- 线程1在修改数据库之后、更新缓存之前,由于其他原因,
cpu
时间片被线程2抢到,线程2直接完成写库、写缓存操作。 - 此时线程1再拿到
cpu
,执行更新缓存操作,那么此时缓存中的数据更新的就是线程1的脏数据。 - 但其实我们想要的是线程2最新修改的数据,这样就出现了双写模式下不一致的情况,产生了脏数据!
- 线程1在修改数据库之后、更新缓存之前,由于其他原因,
-
提示:这是暂时性的脏数据问题,但是在数据稳定,缓存过期以后,又能得到最新的正确数据。
-
示意图:
4.2 失效模式
-
场景一:先写库,再删除缓存。
- 线程1修改数据库数据,并删除了缓存中对应的数据;
- 线程2要获取数据,发现缓存中没有,就去数据库查到线程1修改后的数据,然后准备更新缓存,但在更新之前,由于其他原因,
cpu
时间片被线程3抢到了,此时线程3也修改了数据库数据,并删除已经为空的缓存; - 然后
cpu
又被线程2抢到,线程2继续执行更新缓存操作,此时缓存中更新的是线程1修改后的数据,但其实我们想要的是线程3修改后的数据,这就产生了数据不一致的情况!
-
场景二:先删除缓存,再写库。
- 以减库存为例,假设库存量为100。线程1要减库存,会先删除
Redis
数据,再对数据库中的库存量执行减 1 操作; - 当线程1删除完
redis
数据, 准备执行减 1 操作时,cpu
时间片被线程2抢占; - 线程2要查询库存,此时查到的还是原来的库存量(100),因为此时线程1还没来得及执行减 1 操作。然后线程2把原来的库存量100更新到
Redis
- 线程2执行完毕后,
cpu
时间片又回到线程1手中,线程1继续执行减 1 操作,执行完毕,数据库库存量为99,与redis中的100不相等,数据不一致!
- 以减库存为例,假设库存量为100。线程1要减库存,会先删除
-
示意图:
-
解决方案:延迟双删
它是在失效模式的基础上,在删除reids缓存时,让程序睡眠几十毫秒,再次执行删除缓存操作,可有效预防失效模式中缓存不一致问题,但是并不推荐。因为数据不一致问题本来就是极少情况发生的,如果使用延时双删,那么大部分正常的请求都会被阻塞几十毫秒,系统性能下降,显然得不偿失!
4.3 方案总结
- 如上所示,无论是双写模式还是失效模式,都无法完美解决缓存一致性问题!但在不同的业务场景对数据一致性的要求也不同,并非所有场景都需要数据强一致性,我们要根据实际业务场景来分析。
- 对于并发很小的数据,比如个人信息、用户数据等。这些数据在使用双写或者失效模式后,由于并发量小,根本不需要考虑缓存一致性问题。可以给缓存数据加上过期时间,每隔一段时间触发读操作的主动更新即可!
- 如果并发量很高,但业务上能容忍短时间的缓存数据不一致,比如商品名称,商品分类三级菜单等。为缓存数据加上过期时间依然可以解决大部分业务对于缓存的要求。
- **如果并发量很高,且无法容忍数据不一致,**比如库存。
- 改进方案一:可以使用分布式锁来保证一致性!但也不用读写操作都加一把重量级的分布式锁,使用轻量级读写锁即可,通过添加读写锁保证写数据时读写都阻塞,仅读数据时相当于无锁!
- 改进方案二:也可以使用
Canal
组件完成数据同步,Canal
使我们的业务代码只关注于数据库的交互,不用管Redis
缓存的问题,因为Canal
可以订阅mysql
数据库的每一次更新,只要mysql
数据库有更新,Canal
就会把数据同步到Redis
(如下图所示)。
五、分布式锁
5.1 为什么要用分布式锁?
通常情况下,我们会给程序中部分需要同步的方法加锁。
常见的锁有
synchronized
和lock
锁,这些都是本地锁。
-
本地锁的局限性:当服务部署在多台实例上,则都会有自己的本地锁,而它仅仅只能锁住当前进程。
-
示意图:
-
解决方式:需要一把公共锁(分布式锁)。
-
约定:
Redis
中有一个Setnx
命令,该命令会向Redis
中保存一条数据,如果不存在则保存成功,存在则返回失败。我们约定保存成功即为加锁成功,之后加锁成功的线程才能执行真正的业务操作。
-
处理流程:
- 多个客户端同时获取锁(
Setnx
) -> 加锁; - 获取成功,执行业务逻辑(从
DB
中获取数据,放入缓存),执行完成释放锁(Del
) -> 释放锁。
- 多个客户端同时获取锁(
-
示意图:
5.2 使用Redisson 完成分布式锁
Redisson 是架设在Redis基础上的一个
Java
驻内存数据网格(In-Memory Data Grid)。充分的利用了Redis
键值数据库提供的一系列优势,基于Java实用工具包中常用接口,为使用者提供了一系列具有分布式特性的常用工具类。使得原本作为协调单机多线程并发程序的工具包获得了协调分布式多机多线程并发系统的能力,大大降低了设计和研发大规模分布式 系统的难度。同时结合各富特色的分布式服务,更进一步简化了分布式环境中程序相互之间 的协作。
- 依赖引入:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.11.1</version>
</dependency>
- 配置 redisson:
@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://yourIp:6379");
// 2、根据 Config 创建出 RedissonClient 实例
return Redisson.create(config);
}
}
- 使用分布式锁 :
public class SampleDemo {
public static void main(String[] args) {
// 1、创建分布式锁。锁的粒度,越细越快,比如具体缓存的是某个数据。
// 创建读锁
RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("anyLock");
RLock rLock = readWriteLock.readLock();
Map<String, List<DataVo>> dataFromDb = null;
try {
rLock.lock();
// 加锁成功...执行业务
dataFromDb = getDataFromDB();
} finally {
rLock.unlock();
}
return dataFromDb;
}
}
- 其他具体使用请参考:官方文档 https://github.com/redisson/redisson/wiki
六、Spring Cache
6.1 Spring Cache简介
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、从缓存中读取之前缓存存储的数据。
6.2 Spring Cache使用
- 依赖引入:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
-
配置方式一:自动配置:
-
CacheAutoConfiguration
会导入RedisCacheConfiguration
; -
会自动装配缓存管理器
RedisCacheManager
。
-
-
配置方式二:手动配置(
application.properties
及配置类配置):
spring.cache.type=redis
#spring.cache.cache-names=myCache,毫秒为单位
spring.cache.redis.time-to-live=3600000
#如果指定了前缀就用我们指定的前缀,如果没有就默认使用缓存的名字作为前缀
#spring.cache.redis.key-prefix=CACHE_
spring.cache.redis.use-key-prefix=true
#是否缓存空值,防止缓存穿透
spring.cache.redis.cache-null-values=true
@EnableConfigurationProperties(CacheProperties.class)
@Configuration
@EnableCaching
public class MyCacheConfig {
/**
* 配置文件的配置没有用上
* 1. 原来和配置文件绑定的配置类为:@ConfigurationProperties(prefix = "spring.cache")
* public class CacheProperties
* <p>
* 2. 生效要加上 @EnableConfigurationProperties(CacheProperties.class)
*/
@Bean
public RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
// config = config.entryTtl();
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;
}
}
- 常用注解:
- 注解参数说明:
- SpEL表达式:
- ServiceImplDemo:
@Cacheable(value = {"customCacheName"}, key = "#root.method.name", sync = true)
@Override
public void getBusinessData() {
// 业务逻辑
}
6.3 Spring Cache总结
6.3.1 Spring-Cache
的不足及解决方式:
- 读模式:
- 缓存穿透:查询一个
null
数据。-> 解决方案:spring.cache.redis.cache-null-values=true
。 - 缓存击穿:大量并发进来同时查询一个正好过期的数据。-> 解决方案:默认是无加锁的,使用
sync = true
来解决击穿问题。 - 缓存雪崩:大量的key同时过期。-> 解决:加随机时间。加上过期时间
spring.cache.redis.time-to-live=3600000
。
- 缓存穿透:查询一个
- 写模式:(缓存与数据库一致)
- 读写加锁。
- 引入
Canal
,感知到MySQL
的更新去更新Redis
。 - 读多写多,直接去数据库查询就行。
- 总结:
- 常规数据(读多写少,即时性,一致性要求不高的数据,完全可以使用
Spring-Cache
); - 写模式(只要缓存的数据有过期时间就足够了),如果是要求高的特殊数据需要特殊处理。
- 常规数据(读多写少,即时性,一致性要求不高的数据,完全可以使用
七、结束语
“-------怕什么真理无穷,进一寸有一寸的欢喜。”
微信公众号搜索:饺子泡牛奶。