Redis实战—黑马点评(二)缓存篇
目录
- Redis实战—黑马点评(二)缓存篇
- 1. 什么是缓存
- 1.1 缓存的作用和成本
- 2. 添加 Redis 缓存
- 3. 缓存更新策略
- 3.1 三种更新策略
- 3.1.1 主动更新策略
- 4. 缓存穿透
- 4.1 常见两种解决办法
- 4.1.1 缓存空值
- 4.1.2 布隆过滤器
- 4.2 小结
- 5. 缓存雪崩
- 6. 缓存击穿
- 6.1 常见解决方案两种
- 6.1.1 互斥锁
- 6.1.2 逻辑过期
- 6.1.3 方案对比
- 6.2 小收获
- 7. 缓存工具类封装
- 7.1 代码实现
- 问题:刚才的代码中,锁的key不应该在代码中指定,而是作为参数传入。
- 7.2 小收获
- 7.2.1 时间处理
- 7.2.2 泛型使用
- 7.2.3 Function对象
1. 什么是缓存
缓存即为数据交换的缓冲区,是数据临时存储的地方,读写性能高。
1.1 缓存的作用和成本
简单的说,用缓存好处多,可以提升程序性能,缺点就是麻烦。
2. 添加 Redis 缓存
不用缓存的话,我们的程序获取数据是从数据库:
用了缓存之后,减少了数据库的压力:
原本需要从数据库中查的数据现在只需要去缓存中查了(第一次除外)。
案例:添加商户缓存
具体代码就不写了,这个流程还存在缓存穿透的问题,后续会说明。
这里收获了一个小编码技巧,在软件工程中有“单一出口原则”的说法,在方法中就只有一个return,但有时候用多个return会大幅简洁代码,省掉大量的else。
3. 缓存更新策略
数据存到缓存后,会有一个数据一致性的人问题:
假如数据库更新了,用户还是从缓存查数据,那么查到的就是缓存中的旧数据了。
所以需要有一种更行缓存的机制。
3.1 三种更新策略
-
内存淘汰
Redis自带的机制,当内存不足时自动淘汰部分数据。下次查询时更新缓存。
-
超时剔除
给数据添加TTL值,到期自动删除。下次查询时更新。
-
主动更新
主动编写逻辑,在数据改变时,更行缓存。
3.1.1 主动更新策略
第一种就是主动编码,在数据库更新时更新缓存(编码多,麻烦)。
第二种就是将缓存和数据库整合为一个服务,由该服务来保证一致性(编码少,缺点就是这个服务质量不好保证,不好维护)。
第三种就是只操作缓存,由其他服务来将缓存同步到数据库,保证最终一致性。(完全不用关心数据库,但是当缓存没有同步时宕机了,数据将不会同步到数据库)。
第一二种都是强一致性,第三种是保证最终一致性。
方案一强一致且可控,用的较多。
在开始编码前,还有三个问题需要考虑:
-
更新数据库时,缓存是更新还是删除?
更新:每次更新数据库都更新缓存
删除:更新时删除缓存,查询时更新缓存
一般是选择删除的方案,因为不是每次更新都要查询的,不查询,也就省去了添加缓存的步骤,避免浪费内存,性能。
-
如何保证同时成功或失败?
单体系统:将缓存和数据库操作放在同一个事务中。
分布式系统:利用TCC等分布式事务方案。
-
先操作缓存还是数据库?
本质上是线程安全问题
看两个例子,初始状态数据库,缓存都为10
先删除缓存,在数据库更新这个耗时操作完成之前,其他线程将未更新前的数据更新到缓存,然后原来的线程再更新数据库,造成数据的不一致。
例2:
假设缓存在线程一查询时恰好失效(或没有该缓存),将会查询数据库,此时数据库是老数据,在将该老数据写入缓存时,线程二进行数据库更新和缓存删除操作,最后线程一写入老数据,造成数据不一致。
**例二这种情况发生概率很小,因为写缓存的操作是微秒级的,短时间内不太可能完成数据库更新和缓存删除的操作。例一的情况发生的概率还是比较大的。**所以一般先操作数据库再删缓存并且给数据加上一个TTL过期时间,最大限度保证线程安全。
4. 缓存穿透
缓存穿透是指,客户端的请求数据在数据库和缓存都不存在,但是这些请求永远会打向数据库,给数据库造成压力。
4.1 常见两种解决办法
4.1.1 缓存空值
即使数据库不存在,我们也在redis中缓存一个空。
优点:简单,易维护。
缺点:
- 额外的内存消耗(可加一个TTL缓解)
- 可能造成短期的数据不一致(当缓存空值时,该数据正好被插入数据库,客户端请求缓存获得空)
4.1.2 布隆过滤器
用算法实现的记录所请求的数据是否存在的过滤器。底层像是BitMap。当数据库中不存在该数据时,会拒绝请求,存在时才放行。
存在误判可能,有可能出现数据不存在布隆过滤器却认为存在的情况,但是布隆过滤器若是认为数据不存在,那就一定不存在。
优点就是不占用额外内存,缺点就是实现复杂,存在误判可能。
使用方案一,简单高效
所以商户缓存的流程将被优化为:
4.2 小结
5. 缓存雪崩
缓存雪崩是指,在同一时间大量的key同时失效或redis服务宕机,导致大量请求到达数据库,给数据库造成过大压力。
解决方案比较常见的有:
-
给不同的key的TTL添加随机值
不在同一时间过期,添加几分钟随机值(根据业务)。
-
Redis集群,提高可用性
避免redis服务全部挂掉。
-
缓存业务降级限流策略
亡羊补牢,数据库再崩了就是更大的损失。
-
添加多级缓存
浏览器缓存,nginx缓存等,即使redis崩了,还有其他缓存可查。
6. 缓存击穿
也被称为热点key问题,即 一个被高并发访问并且缓存重建业务复杂的key突然失效,大量请求给数据库带来巨大压力。
在缓存重建期间大量请求到达数据库。
6.1 常见解决方案两种
6.1.1 互斥锁
当缓存重建业务开始时,其他线程等待并重试,保证没有多余的请求到达数据库。该互斥锁的简单时间可以用setnx命令来实现。(在后续秒杀篇中会实现该分布式锁)
6.1.2 逻辑过期
用一个字段来保存过期时间,查询该缓存时判断缓存是否过期,如果过期则返回旧数据,并让另一个线程异步地重建缓存,重建缓存时需要加锁,其他线程来获取锁失败时,返回旧数据。
6.1.3 方案对比
方案一牺牲性能保证一致性,方案二则反之,根据业务需求选择。
6.2 小收获
若使用方案二,需要添加一个字段,如何添加呢?
在数据库添加是最笨的方式并且不合理。
在往常,我会新建一个DTO,然后添加该字段,使用该DTO对象进行数据传输。
在该课程中,我学会了如下方案:
-
使用MP的注解
但是这样就破坏了该对象的结构
-
创建一个新的类继承该类,然后添加新字段。(DTO)
-
创建一个新类,添加该字段,让原类继承该类。(和一一样,破坏了对象的结构)
-
创建一个新类,使用泛型,该类包含新的字段和一个泛型对象。(优雅)
@Data public class RedisData<T> { private LocalDateTime expireTime; private T data; }
7. 缓存工具类封装
7.1 代码实现
简单说就是:1、3配合解决缓存穿透,2、4配合解决缓存击穿,四个方法,两个设置两个读取。
方法一:
public <R> void set(String key, R value, Long time, TimeUnit unit) {
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value), time, unit);
}
方法二:
public <R> void setWithExpire(String key, R value, Long time, TimeUnit unit) {
RedisData<R> redisData = new RedisData<>();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
// 写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
方法三:
public <R, ID> R queryWithPassThrough(String keyPrefix, ID id, Type type, boolean ignoreError, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
R r;
String key = keyPrefix + id;
// 1. 从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2. 判断是否存在
if (StrUtil.isNotBlank(json)) {
// 3.1 存在
r = JSONUtil.toBean(json, type, ignoreError);
return r;
}
// 3.2 不存在
// 判断是否是空值(防止缓存穿透)
if (json != null) {
return null;
}
// 从数据库查询
r = dbFallback.apply(id);
// 4. 返回
if (r == null) {
// 不存在 返回错误 将空值写入redis
stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
return null;
}
// 存在 写入redis
this.set(key, r, time, unit);
return r;
}
方法四:
public <R, ID> R queryWithLogicalExpire(String keyPrefix, ID id, Type type, boolean ignoreError, Function<ID, R> dbFallback, Long time, TimeUnit unit) {
R r;
String key = keyPrefix + id;
// 1. 从redis查询商铺缓存
String json = stringRedisTemplate.opsForValue().get(key);
// 2. 判断是否命中
if (StrUtil.isBlank(json)) {
// 3.1 未命中
return null;
}
// 3.2 命中
RedisData<R> redisData = JSONUtil.toBean(json, new TypeReference<RedisData<R>>() {}.getType(), ignoreError);
r = JSONUtil.toBean(JSONUtil.toJsonStr(redisData.getData()), type, false);
if (LocalDateTime.now().isBefore(redisData.getExpireTime())) {
// 4. 判断是否过期
// 4.1 未过期 返回
return r;
}
// 4.2 过期
// 5. 缓存重建
// 5.1 获取互斥锁
String lockKey = LOCK_SHOP_KEY + id;
if (tryLock(lockKey)) {
// 5.2 成功 开启另一线程重建
CACHE_REBUILD_EXECUTOR.submit(() -> {
try {
// 查数据库
R newR = dbFallback.apply(id);
// 更新缓存
setWithExpire(key, newR, time, unit);
// 模拟业务延时
Thread.sleep(200);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
unLock(lockKey);
}
});
}
// 5.3 失败
// 无论是否成功都返回旧数据
return r;
}
锁的简单实现
private boolean tryLock(String key) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS);
// 当flag为null时,若直接返回将会拆箱失败,报错,所以使用hutool的BooleanUtil工具保证拆箱成功(不用也可以,用Boolean.TRUE.equals方法)
return BooleanUtil.isTrue(flag);
}
private void unLock(String key) {
stringRedisTemplate.delete(key);
}
问题:刚才的代码中,锁的key不应该在代码中指定,而是作为参数传入。
7.2 小收获
收获到了很多编码小技巧:
7.2.1 时间处理
-
判断某个值是否过期
if (LocalDateTime.now().isBefore(redisData.getExpireTime())) { }
可以用LocalDateTime的isBefore和isAfter方法
-
续期
使用LocalDateTime实例中的plusxxx方法,例如:
LocalDateTime time = LocalDateTime.now().plusSeconds(2000);
即可在当前时间的基础上添加2000秒得到一个新时间
-
单位转换
刚刚续期的代码中plusSeconds,不一定每次都是Seconds,很不方便,那如何同一代码呢?
使用TimeUtil中的方法:
System.out.println(TimeUnit.MINUTES.toSeconds(1)); // 60 System.out.println(TimeUnit.SECONDS.toMinutes(60)); // 1
所以续期的代码可以统一为:
public void setWithExpire(Long time, TimeUnit unit) { LocalDateTime.now().plusSeconds(unit.toSeconds(time)); }
7.2.2 泛型使用
泛型类:
Class<T> IData {
private T data;
}
泛型方法:
<R> void test(R r) {
// 定义泛型后,一般会在参数列表中接受泛型来指定泛型类型。
}
泛型的JSON转换:
将JSON字符串转换为含有自定义泛型的对象。
假设有如下类:
@Data
@AllArgsConstructor
@NoArgsConstructor
class IData<T> {
private Long id;
private T data;
}
@Data
@AllArgsConstructor
@NoArgsConstructor
class User {
private String name;
private int age;
}
一般情况下,因为有类型擦除,我们不会直接.class,我们都是使用TypeRefrence的方式:
/**
* 用哪种json解析工具用法都差不多 都是传type、class这些
*/
<R> R json2Obj3(String json, TypeReference<R> type) throws JsonProcessingException {
R data = JSONUtil.toBean(json, type, false);
return data;
}
@Test
void testParse() throws JsonProcessingException {
// 创建json字符串
User user = new User("张三", 18);
IData<User> iData = new IData<>(1L, user);
String json = JSONUtil.toJsonStr(iData);
// 解析
IData<User> data = json2Obj3(json, new TypeReference<IData<User>>() {
});
// 能输出name才算成功
System.out.println(data.getData().getName());
}
若是传class,那么就指定泛型的class对象:
<R> IData<R> json2Obj2(String json, Class<R> type) throws JsonProcessingException {
IData<R> data = JSONUtil.toBean(json, new TypeReference<IData<R>>() {}, false);
// 这个时候就需要转换两次了 因为在该方法中R就是R类型而不是User类型
data.setData(JSONUtil.toBean(JSONUtil.toJsonStr(data.getData()), type));
return data;
}
@Test
void test1() throws JsonProcessingException {
// 创建json字符串
User user = new User("张三", 18);
IData<User> iData = new IData<>(1L, user);
String json = JSONUtil.toJsonStr(iData);
// 解析
IData<User> data = json2Obj2(json, User.class);
// 能输出name才算成功
System.out.println(data.getData().getName());
}
类型探究:
<R, T> void testType(TypeReference<R> type1, Class<T> type2) {
System.out.println(type1);
System.out.println(type2);
System.out.println(new TypeReference<R>() {
});
System.out.println(new TypeReference<T>() {
});
}
@Test
void testType1() {
testType(new TypeReference<User>() {
}, User.class);
}
输出:
JSON转换小结:在泛型方法内,R就是R对象,不是其他的什么对象,只有将具体的类型传进来才能解析。
7.2.3 Function对象
Java8的新特性,可以像js一样丝滑:
void testFunc(String param, Function<String, String> function) {
String result = function.apply(param);
System.out.println("result: " + result);
}
@Test
void testFunc1() {
testFunc("hello", s -> {
System.out.println("in func: " + s + " world!");
return s + " result";
});
}
黑马点评中,Redis工具类的封装,当方法中需要查询数据库,但是各个业务的查询逻辑可能不同,于是就在参数列表中加上了Function对象用于传入该逻辑。当然也可以不用Function,直接接受结果,这个结果在方法之外获得。
Function还有很多用法:
function源码
@FunctionalInterface
public interface Function<T, R> {
R apply(T t);
default <V> Function<V, R> compose(Function<? super V, ? extends T> before) {
Objects.requireNonNull(before);
return (V v) -> apply(before.apply(v));
}
default <V> Function<T, V> andThen(Function<? super R, ? extends V> after) {
Objects.requireNonNull(after);
return (T t) -> after.apply(apply(t));
}
static <T> Function<T, T> identity() {
return t -> t;
}
}
andThen,compose返回值都是Function,可以链式编程,自由组合各种函数。
ion源码
@FunctionalInterface
public interface Function<T, R> {
R apply(T t);
default <V> Function<V, R> compose(Function<? super V, ? extends T> before) {
Objects.requireNonNull(before);
return (V v) -> apply(before.apply(v));
}
default <V> Function<T, V> andThen(Function<? super R, ? extends V> after) {
Objects.requireNonNull(after);
return (T t) -> after.apply(apply(t));
}
static <T> Function<T, T> identity() {
return t -> t;
}
}
andThen,compose返回值都是Function,可以链式编程,自由组合各种函数。