一、SpringDataRedis
SpringData是Spring中数据操作的模块,包含对各种数据库的集成,其中对Redis的集成模块就叫做SpringDataRedis,官网地址:https://spring.io/projects/spring-data-redis
提供了对不同Redis客户端的整合(Lettuce和Jedis)
提供了对RedisTemplate统一API来操作Redis
支持Redis的发布订阅模型
支持Redis哨兵和Redis集群
支持基于Lettuce的响应式编程
支持基于JDK、JSON、字符串、Spring对象的数据序列化及反序列化
支持基于Redis的JDKCollection实现
SpringDataRedis中提供了RedisTemplate工具类,其中封装了各种对Redis的操作。并且将不同数据类型的操作API封装到了不同的类型中
二、SpringDataRedis的序列化方式
RedisTemplate可以接收任意Object作为值写入Redis,只不过写入前会把Object序列化为字节形式,默认采用JDK序列化没得到的结果是这样的:
可读性差、内存真用较大
我们可以自定义RedisTemplate的序列化方式,代码如下
@Configuration
public Class RedisConfig(){
@Bean
public RedisTemplate<String,Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) throws Exception{
//创建Template
RedisTemplate<String,Object> redisTemplate = new RedisTemplate<>();
//设置连接工厂
redisTemplate.setConnectionFactory(redisConnectionFactory);
//设置序列化工具
GenericJackson2JsonRedisSerializer jsonRedisSerializer =
new GenericJackson2JsonRedisSerializer();
//key 和 hashKey采用String序列化
redisTemplate.setKeySerializer(RedisSerializer.string());
redisTemplate.setHashKeySerializer(RedisSerializer.string());
//value和 hashValue采用Json序列化
redisTemplate.setValueSerializer(jsonRedisSerializer);
redisTemplate.setHashValueSerializer(jsonRedisSerializer);
return redisTemplate;
}
}
依然还存在一些问题如图:
为了在反序列化时知道对象的类型,JSON序列化器会将类的class类型写入json结果中,存入redis,会带来额外的内存开销
解决方法:统一使用String序列化器,要求只能存储String类型的key和value。当需要存储java对象时,手动完成对象的序列化和反序列化(可以使用第三方工具类)
三、使用Redis有哪些好处
读取速度快,因为数据存在内存中,所以数据获取快;
支持多种数据结构,包括字符串、列表、集合、有序集合、哈希等;
支持事务,且操作遵守原子性,即对数据的操作要么都执行,要么都不支持;
还拥有其他丰富的功能,队列、主从复制、集群、数据持久化等功能。
四、Redis替代session要考虑的问题
选择合适的数据结构
选择合适的key
选择合适的存储粒度
五、什么是缓存
缓存就是数据交换的缓冲区,是存贮数据的临时地方,一般读写性能较高
缓存的作用:降低后端负载、提高读写效率,降低响应时间
缓存的成本:数据一致性成本、代码维护成本、运维成本
为什么要使用缓存:Redis 的读写性能比 Mysql 好的多,我们就可以把 Mysql 中的热点数据缓 存到 Redis 中,提升读取性能,同时也减轻了 Mysql 的读取压力。
六、缓存更新策略
低一致性需求:使用内存淘汰机制。如 店铺类型的缓存
高一致性需求:使用主动更新,并以超时剔除作为兜底方案。如 店铺详情的缓存
主动更新策略:
读操作:缓存命中则直接返回,未命中则查询数据库,并写入缓存,设定超时时间
写操作:先写数据库,然后再删除缓存,要确保数据库与缓存操作的原子性。可以使用LUA脚本
七、缓存穿透
缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库
解决方案:缓存空值、布隆过滤、增强id复杂度,避免被猜测id规律、做好数据的基础格式校验、做好热点参数的限流
两种常见解决方案:
缓存空对象:缓存和数据库都未命中时,在缓存中写入一个空对象,并设置超时时间,下次同样的请求过来直接返回缓存的空对象。
实现简单,维护方便。但会消耗额外的内存,如果这时数据库添加了对应数据,会造成短期不一致
布隆过滤:在缓存与客户端之间添加一个布隆过滤器,请求先到布隆过滤器,布隆过滤器会判断请求的数据是否存在,存在则放行到缓存,不存在直接拒绝。缓存命中则直接返回数据,未命中则查询数据库,写入缓存,返回数据
内存占用较少,没有多余key。实现较复杂,存在误判可能
布隆过滤器原理:布隆过滤器里面存储的是数据基于某种hash算法计算出hash值,然后将这些hash值转换为二进制位保存到布隆过滤器中,在判断数据是否存在时就是去判断对应的位置是0还是1,并不能保证100%准确(他说不存在就肯定不存在,说存在那不一定存在),有一定穿透风险
八、缓存雪崩
缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis宕机,导致大量请求到达数据库,带来巨大压力
解决方案:
给不同的Key的TTL添加随机值
利用Redis集群提高服务的可用性
跟缓存业务添加降级限流策略
给业务添加多级缓存
Key同时失效,是指大量的key在同一时间TTL到期导致失效
九、缓存击穿
缓存击穿问题也叫热点key问题,是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击
常见解决方案有两种:互斥锁、逻辑过期
互斥锁:多个线程查询缓存未命中时,在去重建缓存之前,要先获得互斥锁,成功才能去查询数据库重建缓存,然后写入缓存,释放锁
未抢到锁的线程是不能重建缓存的,会休眠一会继续重试,直到锁被释放,说明缓存重建完毕,这时缓存命中,直接返回数据
互斥锁可以使用 String类型的redis 中的 setnx命令(java :setIfAbsent)
/**
* 获得锁
* @param key
* @return
*/
private boolean tyrLock(String key){
Boolean flag = stringRedisTemplate.opsForValue()
.setIfAbsent(key, "1", 10L, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
/**
* 释放锁
* @param key
*/
private void unLock(String key){
stringRedisTemplate.delete(key);
}
逻辑过期:给数据添加逻辑过期字段,查询缓存时判断逻辑时间是否到期,未到期则返回数据,到期则先获取互斥锁,然后开启一个新线程去查询数据库重建缓存,并重置逻辑过期时间,最后释放锁。自己则返回过期的数据,其他未获得锁的线程,直接返回过期数据
//首先创建一个实体类
@Data
public class RedisData {
private LocalDateTime expireTime; //逻辑过期字段
private Object data; //原对象数据
}
//将原数据保存在data中,并设置逻辑过期时间
//将RedisData类序列化后存储到Redis
十、基于StringRedisTemplate封装一个缓存工具类
@Component
public class RedisCacheUtil {
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
* 将任意java对象序列化为json并存储在string类型的key中,可以设置TTL过期时间
* @param key key键
* @param value value值
* @param time 时间
* @param unit 时间单位
*/
public void set(String key, Object value, Long time, TimeUnit unit){
//写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(value),time,unit);
}
/**
* 将任意java对象序列化为json并存储在string类型的key中,可以设置逻辑过期时间,用于处理缓存击穿问题
* @param key
* @param value
* @param time
* @param unit
*/
public void setWithLogicalExpire(String key, Object value, Long time, TimeUnit unit){
//设置逻辑过期时间
RedisData redisData = new RedisData();
redisData.setData(value);
redisData.setExpireTime(LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
//写入redis
stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(redisData));
}
/**
* 根据id查询数据库
* 根据指定的key查询缓存,并反序列化为指定类型,利用缓存空值解决缓存穿透问题
* @param keyPrefix key前缀
* @param id id
* @param type 对象类型
* @param dbFallback 查询数据库所用函数
* @param time 时间
* @param unit 时间单位
* @param <R>
* @param <ID>
* @return
*/
public <R,ID> R queryWithPassThrough(
String keyPrefix, ID id, Class<R> type, Function<ID,R> dbFallback, Long time, TimeUnit unit){
//拼接 前缀和id 得到 key
String key = keyPrefix + id;
//根据 key 查询缓存
String json = stringRedisTemplate.opsForValue().get(key);
//判断是否命中
if (StrUtil.isNotBlank(json)){
//命中 序列化为json并返回
return JSONUtil.toBean(json,type);
}
//判断命中的是否为空值
if (json != null){
//是空值 返回 空
return null;
}
//未命中 查询数据库
R r = dbFallback.apply(id);
//判断是否有值
if (r == null) {
//为空 将空值写入缓存 并返回错误信息
stringRedisTemplate.opsForValue().set(key,"",2L,TimeUnit.MINUTES);
return null;
}
//不为空 写入缓存
this.set(key,r,time,unit);
//返回结果
return r;
}
//线程池
private static final ExecutorService THREAD_POOL = new ThreadPoolExecutor(
5,8,20L,TimeUnit.MINUTES, new LinkedBlockingDeque());
/**
* 根据id查询数据库
* 根据指定 key 查询缓存,并反序列化为指定的类型, 利用逻辑过期解决缓存击穿问题
* @param keyPrefix key前缀
* @param id id
* @param type 对象类型
* @param dbFallback 查询数据库所用函数
* @param time 时间
* @param unit 时间单位
* @param <R>
* @param <ID>
* @return
*/
public <R,ID> R queryWithLogicalExpire(
String keyPrefix, ID id, Class<R> type, Function<ID,R> dbFallback, Long time, TimeUnit unit
){
//拼接key
String key = keyPrefix + id;
//从redis查询缓存
String json = stringRedisTemplate.opsForValue().get(key);
//判断是否命中
if (StrUtil.isBlank(json)){
//未命中 返回空
return null;
}
//命中 json序列化为对象
RedisData redisData = JSONUtil.toBean(json, RedisData.class);
R r = JSONUtil.toBean((JSONObject) redisData.getData(), type);
LocalDateTime expireTime = redisData.getExpireTime();
//判断是否过期
if (expireTime.isAfter(LocalDateTime.now())){
//未过期 只会返回信息
return r;
}
//过期了 缓存重建 先获取互斥锁
String lockKey = RedisConstants.LOCK_SHOP_KEY + id;
boolean isLock = tyrLock(lockKey);
//判断是否取锁成功
if (isLock) {
//成功 开启独立线程,实现重建缓存
THREAD_POOL.submit(() -> {
try {
//查询数据库
R r1 = dbFallback.apply(id);
//写入redis
this.setWithLogicalExpire(key, r1,time ,unit);
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
//释放锁
unLock(lockKey);
}
});
}
//返回过期的信息
return r;
}
/**
* 获得锁
* @param key
* @return
*/
private boolean tyrLock(String key){
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10L, TimeUnit.SECONDS);
return BooleanUtil.isTrue(flag);
}
/**
* 释放锁
* @param key
*/
private void unLock(String key){
stringRedisTemplate.delete(key);
}
}
十一、解决超卖问题
超卖问题是典型的多线程安全问题,针对这一问题的常见解决方案就是加锁:
悲观锁:认为线程安全问题一定会发生,因此在操作数据之前就先获取锁,确保线程串行执行
如:Synchronized、Lock都属于悲观锁
悲观锁 简单粗暴 但是 性能一般
乐观锁:认为线程安全问题不一定会发生,因此不加锁,只在更新数据时去判断有没有其他线程对数据做了修改。如果没有则认为是安全的,自己才更新数据,如果有说明发生了安全问题,此时可以重试或异常。
乐观锁 性能好 但是 成功率较低
乐观锁的关键是判断之前查询到的数据是否有被修改,常见方式有两种:
版本号法
CAS法
上面所用到的方法 是在SQL语句中添加where id=? And stock=? 条件来实现的,会造成失败率大,秒杀卷没有被抢购完的问题
将where id=? And stock=? 改为where id=? And stock>0 可以大大提高成功率甚至于完美解决
十二、集群模式下或分布式系统下的问题
每个JVM都有自己的锁监视器,在集群模式下或分布式系统下, 多个JVM用的都是自己的监视器,就会出现线程安全问题。采用分布式锁解决
十三、分布式锁
分布式锁就是满足分布式系统或集群模式下多进程可见并且互斥的锁
十四、基于Redis的分布式锁
获取锁:互斥(NX)、非阻塞(尝试一次,成功返回true,失败返回false)
set lock thread1 NX EX 10
释放锁:手动释放、超时释放(获取锁时添加一个超时时间)
DEL lock
问题:若线程1获得锁成功后,业务因为某种原因阻塞,会导致锁超时释放,此时别的线程2获取锁会成功,在线程2执行业务过程中,线程1阻塞结束业务完成,会去释放锁,导致线程2业务尚未执行完成,锁就已经释放了,此时线程3也可以得到锁成功
解决:在获得锁时添加一个标识,释放锁的时候判断一下标识是不是自己,是则释放
上述解决方案还存在一个问题
若在释放锁时 因为某种原因导致 判断通过释放之前阻塞 如:JVM垃圾回收会阻塞所有
若阻塞时间过长,导致锁到期自动释放,此时阻塞结束执行锁释放,还是会释放掉他人的锁,因为已经通过了判断才阻塞的 (可采用Lua脚本解决)
Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性
网址:https://www.runoob.com/lua/lua-tutorial.html
执行redis命令: redis.call('命令名称','key','其他参数')
如: set name jack ===》 redis.call('set','name','jack')