布隆过滤器
是什么
- 布隆过滤器是一个很长的二进制数组(初始值都为0)+一系列随机hash算法映射函数,主要用于判断一个元素是否在集合中。
应用场景
通常我们会遇到很多要判断一个元素是否在某个集合中的业务场景,一般就是用集合将所有的元素保存起来,然后再做比较,如果是大数据量亿级数据,集合就不好使了,这个时候就可以用布隆过滤器。
-
解决缓存穿透的问题
-
缓存穿透是什么
- 一般情况下,先查询缓存redis是否有该条数据,缓存中没有时,再查询数据库。当数据库也不存在该条数据时,每次查询都要访问数据库,这就是缓存穿透。缓存透带来的问题是,当有大量请求查询数据库不存在的数据时,就会给数据库带来压力,甚至会拖垮数据库。
-
可以使用布隆过滤器解决缓存穿透的问题
-
把已存在数据的key存在布隆过滤器中,相当于redis前面挡着一个布隆过滤器。当有新的请求时,先到布隆过滤器中查询是否存在:
如果布隆过滤器中不存在该条数据则直接返回;
如果布隆过滤器中已存在,才去查询缓存redis,如果redis里没查询到则穿透到Mysql数据库。 -
有,是可能有;无,是肯定无。
-
-
大数量情况下,判断一个元素是否在集合中
-
短视频推荐,不会推荐相同的视频,就可以用布隆过滤器
特点
- 高效地插入和查询,占用空间少,返回的结果是不确定性的。
- 一个元素如果判断结果为存在的时候元素不一定存在,但是判断结果为不存在的时候则一定不存在。
- 布隆过滤器可以添加元素,但是不能删除元素。因为删掉元素会导致误判率增加。
- 误判只会发生在过滤器没有添加过的元素,对于添加过的元素不会发生误判。
使用原理
-
hash函数会有hash碰撞的情况,所以布隆过滤器采用多个hash函数对一个值进行hash,减少hash碰撞
-
实质就是一个大型位数组和几个不同的无偏hash函数(无偏表示分布均匀)。由一个初值都为零的bit数组和多个个哈希函数构成,用来快速判断某个数据是否存在。
-
添加key时:使用多个hash函数对key进行hash运算得到一个整数索引值,对位数组长度进行取模运算得到一个位置,
每个hash函数都会得到一个不同的位置,将这几个位置都置1就完成了add操作。 -
查询key时:只要有其中一位是零就表示这个key不存在,但如果都是1,则不一定存在对应的key。
-
有,是可能有;无,是肯定无。
-
当我们向布隆过滤器中添加数据时,为了尽量地址不冲突,会使用多个 hash 函数对 key 进行运算,算得一个下标索引值,然后对位数组长度进行取模运算得到一个位置,每个 hash 函数都会算得一个不同的位置。再把位数组的这几个位置都置为 1 就完成了 add 操作。
如果这些点,有任何一个为零则被查询变量一定不在,
如果都是 1,则被查询变量很可能存在,为什么说是可能存在,而不是一定存在呢?那是因为映射函数本身就是散列函数,散列函数是会有碰撞的,会产生误判。 -
使用时最好不要让实际元素数量远大于初始化数量。当实际元素数量超过初始化数量时,应该对布隆过滤器进行重建,
重新分配一个 size 更大的过滤器,再将所有的历史元素批量 add 进行
优缺点
- 优点:高效地插入和查询,占用空间少
- 缺点:
- 存在误判不同的数据可能出来相同的hash值
- 不能删除元素。因为删掉元素会导致误判率增加,因为hash冲突同一个位置可能存的东西是多个共有的,你删除一个元素的同时可能也把其它的删除了。
使用方法
- 创建布隆过滤器
- 将数据添加到布隆过滤器中
- 判断某些数据是否存在布隆过滤器中
缓存预热+缓存雪崩+缓存击穿+缓存穿透
缓存预热
- Redis缓存预热是指在系统启动或重启之前,提前将一些热门或常用的数据加载到Redis缓存中。通过预先加载这些数据,可以避免系统刚启动时出现大量的缓存穿透或缓存击穿现象,提高系统的性能和响应速度。
- 缓存预热可以通过以下几种方式进行:
手动预热:在系统启动或重启之前,手动编写代码将需要预热的数据主动加载到Redis缓存中。
定时预热:通过定时任务或定时器,在系统启动后的某个时间点,自动将需要预热的数据加载到Redis缓存中。
缓存雪崩
- 产生的原因:redis主机挂了,比如缓存中有大量数据同时过期
- 解决方案:redis缓存集群实现高可用,开启Redis持久化机制aof/rdb,尽快恢复缓存集群;ehcache本地缓存 + Hystrix或者阿里sentinel限流&降级。
缓存穿透
- 是什么:请求去查询一条记录,先redis后mysql发现都查询不到该条记录,但是请求每次都会打到数据库上面去,导致后台数据库压力暴增,这种现象我们称为缓存穿透。
- 解决方案
- 方案1:空对象缓存:请求打过来,redis和MySQL都没有数据,就可以设置一个空值比如0,把这个值存到redis中,并设置过期时间,防止多个请求把数据库打爆,如果MySQL中有值了,过了过期时间,就可以读到真实值了。
- 方案2:Google布隆过滤器Guava解决缓存穿透(只能单机使用不推荐)
- 方案3:Redis布隆过滤器解决缓存穿透(推荐)
public class RedissonBloomFilterDemo2
{
public static final int _1W = 10000;
//布隆过滤器里预计要插入多少数据
public static int size = 100 * _1W;
//误判率,它越小误判的个数也就越少
public static double fpp = 0.03;
static RedissonClient redissonClient = null;
static RBloomFilter rBloomFilter = null;
static
{
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.111.147:6379").setDatabase(0);
//构造redisson
redissonClient = Redisson.create(config);
//通过redisson构造rBloomFilter
rBloomFilter = redissonClient.getBloomFilter("phoneListBloomFilter",new StringCodec());
rBloomFilter.tryInit(size,fpp);
// 1测试 布隆过滤器有+redis有
rBloomFilter.add("10086");
redissonClient.getBucket("10086",new StringCodec()).set("chinamobile10086");
// 2测试 布隆过滤器有+redis无
//rBloomFilter.add("10087");
//3 测试 ,都没有
}
public static void main(String[] args)
{
String phoneListById = getPhoneListById("10087");
System.out.println("------查询出来的结果: "+phoneListById);
//暂停几秒钟线程
try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }
redissonClient.shutdown();
}
private static String getPhoneListById(String IDNumber)
{
String result = null;
if (IDNumber == null) {
return null;
}
//1 先去布隆过滤器里面查询
if (rBloomFilter.contains(IDNumber)) {
//2 布隆过滤器里有,再去redis里面查询
RBucket<String> rBucket = redissonClient.getBucket(IDNumber, new StringCodec());
result = rBucket.get();
if(result != null)
{
return "i come from redis: "+result;
}else{
result = getPhoneListByMySQL(IDNumber);
if (result == null) {
return null;
}
// 重新将数据更新回redis
redissonClient.getBucket(IDNumber,new StringCodec()).set(result);
}
return "i come from mysql: "+result;
}
return result;
}
private static String getPhoneListByMySQL(String IDNumber)
{
return "chinamobile"+IDNumber;
}
}
缓存击穿
缓存穿透和缓存击穿的区别:穿透是redis和MySQL中都没有对应的数据。击穿是热点key突然失效,请求直接打到MySQL中,先击中,再穿透。
- 是什么:大量的请求同时查询一个 key 时,此时这个key正好失效了,就会导致大量的请求都打到数据库上面去。
- 危害:会造成某一时刻数据库请求量过大,压力剧增。
- 解决方案
- 互斥更新、差异更新,就是key设置的过期时间不要设置的一样,防止同时过期
- 对于访问频繁的热点key,干脆就不设置过期时间
- 使用互斥锁(也是双端检锁):多个线程同时去查询数据库的这条数据,那么我们可以在第一个查询数据的请求上使用一个 互斥锁来锁住它。其他的线程走到这一步拿不到锁就等着,等第一个线程查询到了数据,然后做缓存。后面的线程进来发现已经有缓存了,就直接走缓存。
- 案例分析:
淘宝的聚划算,先把参加活动的商铺中的商品信息从MySQL中抽取到redis中,这一步一般使用定时任务来做,然后数据存到redis中,并设置过期时间,20分钟后商品下架,新的商品上架。支持分页功能,一页20条数据。redis数据结构选型:List。
上述案例中,用户点击商品时,就会产生缓存击穿的问题。在某一时刻商品信息更新,此时请求打过来,就会产生缓存击穿。
解决方案:采用定时轮询,互斥更新,差异失效时间。(ps:加锁也可以,但会降低性能)
对一条数据,设置两个Key:A和B,两个key保存的是相同数据,但过期时间不同,A比B更早过期。
在查询数据时,先查询A,如果A过期了,再查询B,并同时对A进行修补,因为下个请求还是先查询A
在更新数据时,先更新B,再更新A,确保B更新成功了才能更新A。
总结
分布式锁
锁的种类
- 单机版:所谓的单机版是在同一个JVM虚拟机内,使用synchronized或者Lock接口,实现对资源类加锁。
- 分布式版:在分布式环境下,涉及多台机器不同的JVM虚拟机内,多个服务多个线程在竞争同一个资源时,synchronized只能锁一个服务中的线程,但是在分布式下,访问同一个资源,是来自不同的服务器,所以说synchronized是不行的,这时候就需要使用分布式锁。
A线程进到A服务,然后拿到锁,处理数据;然后B线程又进入A服务,拿不到锁等待。C线程进入到B服务,然后可以拿到锁,此时A线程还在处理,这个时候就有两个线程在使用同一个资源。这个时候就不能保证数据的一致性了。这个时候就要加上redis分布式锁,所有的请求都要先拿redis中的锁,才能保证一个线程操作资源。
分布式锁的特点
- 独占性:OnlyOne,任何时刻只能有且仅有一个线程持有
- 高可用:若redis集群环境下,不能因为某一个节点挂了而出现获取锁和释放锁失败的情况。集群
- 防死锁:杜绝死锁,必须有超时控制机制或者撤销操作,有个兜底终止跳出方案。设置过期时间,释放锁放在finally里
- 不乱抢:防止张冠李戴,不能unlock别人的锁,只能自己加锁自己释放
- 重入性:同一个节点的同一个线程如果获得锁之后,它也可以再次获取这个锁
Redis分布式锁的基础命令
set key value [EX seconds] [PX milliseconds] [NX|XX]
案例分析
使用场景
多个服务间保证同一时刻同一时间段内同一用户只能有一个请求(防止关键业务出现并发攻击)访问资源
Redis配置类:
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Serializable> redisTemplate(LettuceConnectionFactory connectionFactory)
{
RedisTemplate<String, Serializable> redisTemplate = new RedisTemplate<>();
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.setConnectionFactory(connectionFactory);
return redisTemplate;
}
}
操作类
@RestController
public class GoodController
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String serverPort;
@GetMapping("/buy_goods")
public String buy_Goods()
{
String result = stringRedisTemplate.opsForValue().get("goods:001");
int goodsNumber = result == null ? 0 : Integer.parseInt(result);
if(goodsNumber > 0)
{
int realNumber = goodsNumber - 1;
stringRedisTemplate.opsForValue().set("goods:001",realNumber + "");
System.out.println("你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort);
return "你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort;
}else{
System.out.println("商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort);
}
return "商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort;
}
}
以上代码会出现的问题
问题1:单机版没加锁,并发下会产生超买超卖
- 解决方案:单机版加锁:加synchronized、加ReentrantLock。加哪一个?还是都可以?
根据问题解决方案来讲,两个都可以。在实际生产过程中:根据业务来选择,synchronized性能快,自动加锁解锁,但一旦没抢到锁就会一直等待,不见不散。Lock接口是手动加锁和解锁,Lock接口还可以判断线程是否枪锁成功trylock,如果没成功就返回,如果成功就走业务流程,过时不候。
修改后的代码为:演示使用synchronized
@RestController
public class GoodController
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String serverPort;
@GetMapping("/buy_goods")
public String buy_Goods()
{
//加锁
synchronized (this) {
String result = stringRedisTemplate.opsForValue().get("goods:001");
int goodsNumber = result == null ? 0 : Integer.parseInt(result);
if(goodsNumber > 0)
{
int realNumber = goodsNumber - 1;
stringRedisTemplate.opsForValue().set("goods:001",realNumber + "");
System.out.println("你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort);
return "你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort;
}else{
System.out.println("商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort);
}
return "商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort;
}
}
}
问题2:分布式部署后,单机锁还是出现超卖现象,需要分布式锁
- 在单机环境下,可以使用synchronized或Lock来实现。但是在分布式系统中,因为竞争的线程可能不在同一个节点上(同一个jvm中),
所以需要一个让所有进程都能访问到的锁来实现(比如redis或者zookeeper来构建)。 - 不同进程jvm层面的锁就不管用了,那么可以利用第三方的一个组件,来获取锁,未获取到锁,则阻塞当前想要运行的线程
- 解决方案:使用redis分布式锁
@RestController
public class GoodController
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String serverPort;
@GetMapping("/buy_goods")
public String buy_Goods()
{
//定义一个key
String key = "zzyyRedisLock";
//value是随机数,主要是key
String value = UUID.randomUUID().toString()+Thread.currentThread().getName();
//如果key不存在则设置key,也就是上锁。如果key存在则上锁失败,没有抢到锁
Boolean flagLock = stringRedisTemplate.opsForValue().setIfAbsent(key, value);
if(!flagLock)
{
return "抢夺锁失败,o(╥﹏╥)o";
}
String result = stringRedisTemplate.opsForValue().get("goods:001");
int goodsNumber = result == null ? 0 : Integer.parseInt(result);
if(goodsNumber > 0)
{
int realNumber = goodsNumber - 1;
stringRedisTemplate.opsForValue().set("goods:001",realNumber + "");
//删除key,也就是释放锁
stringRedisTemplate.delete(key);
System.out.println("你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort);
return "你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort;
}else{
System.out.println("商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort);
}
return "商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort;
}
}
问题3:释放资源必须在finally中
在问题2的改动中,出异常的话,可能无法释放锁,必须要在代码层面finally释放锁。加锁解锁,lock/unlock必须同时出现并保证调用。
@RestController
public class GoodController
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String serverPort;
@GetMapping("/buy_goods")
public String buy_Goods()
{
String key = "zzyyRedisLock";
String value = UUID.randomUUID().toString()+Thread.currentThread().getName();
try {
Boolean flagLock = stringRedisTemplate.opsForValue().setIfAbsent(key, value);
if(!flagLock)
{
return "抢锁失败,o(╥﹏╥)o";
}
String result = stringRedisTemplate.opsForValue().get("goods:001");
int goodsNumber = result == null ? 0 : Integer.parseInt(result);
if(goodsNumber > 0)
{
int realNumber = goodsNumber - 1;
stringRedisTemplate.opsForValue().set("goods:001",realNumber + "");
System.out.println("你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort);
return "你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort;
}else{
System.out.println("商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort);
}
return "商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort;
} finally {
stringRedisTemplate.delete(key);
}
}
}
问题4:服务宕机了
- 部署了微服务jar包的机器挂了,代码层面根本没有走到finally这块,没办法保证解锁,这个key没有被删除,需要加入一个过期时间限定key。
@RestController
public class GoodController
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String serverPort;
@GetMapping("/buy_goods")
public String buy_Goods()
{
String key = "zzyyRedisLock";
String value = UUID.randomUUID().toString()+Thread.currentThread().getName();
try {
Boolean flagLock = stringRedisTemplate.opsForValue().setIfAbsent(key, value);
//设置过期时间
stringRedisTemplate.expire(key,10L,TimeUnit.SECONDS);
if(!flagLock)
{
return "抢锁失败,o(╥﹏╥)o";
}
String result = stringRedisTemplate.opsForValue().get("goods:001");
int goodsNumber = result == null ? 0 : Integer.parseInt(result);
if(goodsNumber > 0)
{
int realNumber = goodsNumber - 1;
stringRedisTemplate.opsForValue().set("goods:001",realNumber + "");
System.out.println("你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort);
return "你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort;
}else{
System.out.println("商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort);
}
return "商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort;
} finally {
stringRedisTemplate.delete(key);
}
}
}
问题5:设置key+过期时间分开了,必须要合并成一行具备原子性
@RestController
public class GoodController
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String serverPort;
@GetMapping("/buy_goods")
public String buy_Goods()
{
String key = "zzyyRedisLock";
String value = UUID.randomUUID().toString()+Thread.currentThread().getName();
try {
//两行代码合成一行,保证原子性操作
Boolean flagLock = stringRedisTemplate.opsForValue().setIfAbsent(key,value,10L,TimeUnit.SECONDS);
if(!flagLock)
{
return "抢锁失败,o(╥﹏╥)o";
}
String result = stringRedisTemplate.opsForValue().get("goods:001");
int goodsNumber = result == null ? 0 : Integer.parseInt(result);
if(goodsNumber > 0)
{
int realNumber = goodsNumber - 1;
stringRedisTemplate.opsForValue().set("goods:001",realNumber + "");
System.out.println("你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort);
return "你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort;
}else{
System.out.println("商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort);
}
return "商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort;
} finally {
stringRedisTemplate.delete(key);
}
}
}
问题6:严谨张冠李戴,删除别人的锁。
- 问题产生原因:A线程获取锁成功,并设置过期时间,由于业务处理的时间比较久,锁过期自动释放了;此时,B线程进来发现锁空闲,可以抢到锁并上锁,然后处理自己的业务,此时A线程处理完业务,删除锁,删除的是B的锁。B处理完了,要删除锁的时候,没有锁。
- 解决方案:在删除表的时候,先校验值是否相同,不相同不允许删除
@RestController
public class GoodController
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String serverPort;
@GetMapping("/buy_goods")
public String buy_Goods()
{
String key = "zzyyRedisLock";
String value = UUID.randomUUID().toString()+Thread.currentThread().getName();
try {
Boolean flagLock = stringRedisTemplate.opsForValue().setIfAbsent(key,value,10L,TimeUnit.SECONDS);
if(!flagLock)
{
return "抢锁失败,o(╥﹏╥)o";
}
String result = stringRedisTemplate.opsForValue().get("goods:001");
int goodsNumber = result == null ? 0 : Integer.parseInt(result);
if(goodsNumber > 0)
{
int realNumber = goodsNumber - 1;
stringRedisTemplate.opsForValue().set("goods:001",realNumber + "");
System.out.println("你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort);
return "你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort;
}else{
System.out.println("商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort);
}
return "商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort;
} finally {
//删除之前先校验是不是自己的锁
if (stringRedisTemplate.opsForValue().get(key).equals(value)) {
stringRedisTemplate.delete(key);
}
}
}
}
问题7:问题6中的finally中的操作不是原子性的
- 问题描述:finally块的判断+del删除操作不是原子性的
- 解决方案:使用Lua脚本。Redis调用Lua脚本通过eval命令保证代码执行的原子性
public class RedisUtils
{
private static JedisPool jedisPool;
static {
JedisPoolConfig jedisPoolConfig=new JedisPoolConfig();
jedisPoolConfig.setMaxTotal(20);
jedisPoolConfig.setMaxIdle(10);
jedisPool=new JedisPool(jedisPoolConfig,"192.168.111.147",6379);
}
public static Jedis getJedis() throws Exception {
if(null!=jedisPool){
return jedisPool.getResource();
}
throw new Exception("Jedispool was not init");
}
}
@RestController
public class GoodController
{
public static final String REDIS_LOCK_KEY = "redisLockPay";
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String serverPort;
@GetMapping("/buy_goods")
public String buy_Goods()
{
String value = UUID.randomUUID().toString()+Thread.currentThread().getName();
try {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(REDIS_LOCK_KEY, value,30L,TimeUnit.SECONDS);
if(!flag)
{
return "抢夺锁失败,请下次尝试";
}
String result = stringRedisTemplate.opsForValue().get("goods:001");
int goodsNumber = result == null ? 0 : Integer.parseInt(result);
if(goodsNumber > 0)
{
int realNumber = goodsNumber - 1;
stringRedisTemplate.opsForValue().set("goods:001",realNumber + "");
System.out.println("你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort);
return "你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort;
}else{
System.out.println("商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort);
}
return "商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort;
} finally {
//使用Lua脚本,要整操作的原子性
Jedis jedis = RedisUtils.getJedis();
String script = "if redis.call('get', KEYS[1]) == ARGV[1] " +
"then " +
"return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
try {
Object result = jedis.eval(script, Collections.singletonList(REDIS_LOCK_KEY), Collections.singletonList(value));
if ("1".equals(result.toString())) {
System.out.println("------del REDIS_LOCK_KEY success");
}else{
System.out.println("------del REDIS_LOCK_KEY error");
}
} finally {
if(null != jedis) {
jedis.close();
}
}
}
}
}
截至至此,基于单个Redis节点实现分布式锁
- 但是还有存在的一些问题:
- 确保redisLock过期时间大于业务执行时间的问题:Redis分布式锁如何续期?
- Redis集群如果保证锁的同步。
- redis单机是CP(数据一致性),集群是AP(高可用)
- 由于Redis集群是AP,redis集群在异步复制时,会造成的锁丢失,比如:主节点没来的及把刚刚set进来这条数据给从节点,master就挂了,从机上位但从机上无该数据。这是就会出现下个线程进来又可以加锁,出现多锁的情况。
- Zookeeper集群是CP,主机宕机之后,从机上位成功之前,不允许注册。所以要保证数据一致性,需要使用zookeeper,
- Eureak是AP原理
上述描述的是手写的分布式锁,下面引入RedLock
- redis集群环境下,我们自己写的也不OK,直接上RedLock之Redisson落地实现。
- Redisson单机和集群都可用,有了Redisson,我们就不用手写,Redisson更简单,可以解决以上的所有问题
- 使用redisson
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Serializable> redisTemplate(LettuceConnectionFactory connectionFactory)
{
RedisTemplate<String, Serializable> redisTemplate = new RedisTemplate<>();
redisTemplate.setKeySerializer(new StringRedisSerializer());
redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer());
redisTemplate.setConnectionFactory(connectionFactory);
return redisTemplate;
}
@Bean
public Redisson redisson()
{
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.111.140:6379").setDatabase(0);
return (Redisson) Redisson.create(config);
}
}
@RestController
public class GoodController
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String serverPort;
@Autowired
private Redisson redisson;
@GetMapping("/buy_goods")
public String buy_Goods()
{
String key = "zzyyRedisLock";
//使用redisson中的RLock 实现上锁
RLock redissonLock = redisson.getLock(key);
redissonLock.lock();
try
{
String result = stringRedisTemplate.opsForValue().get("goods:001");
int goodsNumber = result == null ? 0 : Integer.parseInt(result);
if(goodsNumber > 0)
{
int realNumber = goodsNumber - 1;
stringRedisTemplate.opsForValue().set("goods:001",realNumber + "");
System.out.println("你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort);
return "你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort;
}else{
System.out.println("商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort);
}
return "商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort;
}finally {
//删锁时,判断是不是自己的锁,防止张冠李戴
if(redissonLock.isLocked() && redissonLock.isHeldByCurrentThread())
{
redissonLock.unlock();
}
}
}
}
分布式锁中会遇到的问题与解决方案总总结
- synchronized单机版OK,分布式多个服务,多个jvm,不ok
- 取消单机锁,上redis分布式锁setnx
- 只加了锁,没有释放锁,出异常的话,可能无法释放锁,必须要在代码层面finally释放锁
- 宕机了,部署了微服务代码层面根本没有走到finally这块,没办法保证解锁,这个key没有被删除,需要有lockKey的过期时间设定
- 为redis的分布式锁key,增加过期时间,此外,还必须要setnx+过期时间必须同一行,保证原子性操作
- 必须规定只能自己删除自己的锁,你不能把别人的锁删除了,防止张冠李戴,1删2,2删3
- redis集群环境下,我们自己写的也不OK,直接上RedLock之Redisson落地实现。
- 当Redisson客户端获取到分布式锁后,在锁的过期时间内,如果客户端与Redis服务器之间的连接断开,Redis服务器会自动将锁自动释放,以避免锁一直被占用而无法释放。
Redis分布式锁-Redlock算法-底层原理
使用场景
- 多个服务间保证同一时刻同一时间段内同一用户只能有一个请求(防止关键业务出现并发攻击)
单机案例:一台Redis,一般不用手写,太复杂,扛不住并发。单机也用Redisson。
- 加锁:加锁实际上就是在redis中,给Key键设置一个值,为避免死锁,并给定一个过期时间
- 解锁:将Key键删除。但也不能乱删,不能说客户端1的请求将客户端2的锁给删除掉,只能自己删除自己的锁。判断的时候需要使用Lua脚本,保证原子性操作
- 加锁关键逻辑:
public static boolean tryLock(String key, String uniqueId, int seconds) {
return "OK".equals(jedis.set(key, uniqueId, "NX", "EX", seconds));
}
- 解锁关键逻辑:
public static boolean releaseLock(String key, String uniqueId) {
//使用Lua脚本,保证原子性
String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) else return 0 end";
return jedis.eval(
luaScript,
Collections.singletonList(key),
Collections.singletonList(uniqueId)
).equals(1L);
}
集群案例
- Redis分布式锁比较正确的姿势是采用redisson这个客户端工具
- 基于setnx的分布式锁有什么缺点?
线程 1 首先获取锁成功,将键值对写入 redis 的 master 节点;
在 redis 将该键值对同步到 slave 节点之前,master 发生了故障;
redis 触发故障转移,其中一个 slave 升级为新的 master;
此时新的 master 并不包含线程 1 写入的键值对,因此线程 2 尝试获取锁也可以成功拿到锁;
此时相当于有两个线程获取到了锁,可能会导致各种预期之外的情况发生,例如最常见的脏数据。
Redisson解决上述Redis集群问题
- 集群使用全主模式:所有机器都是主机,上锁时,所有机器上都有锁,解锁时,把所有机器都解锁。
- Redlock算法:容错算法:N = 2X + 1 (N是最终部署机器数,X是容错机器数),如果在生产过程中,允许有一台机器发生故障,那么集群最少部署三台服务器。
- 为什么是奇数?最少的机器,最多的产出效果。
Redisson在集群中使用案例
- 在配置文件中配置全主模式:
spring.redis.single.address1=192.168.111.147:6381
spring.redis.single.address2=192.168.111.147:6382
spring.redis.single.address3=192.168.111.147:6383 - 编写缓存配置类,获取三台机器的RedissonClient
@Configuration
@EnableConfigurationProperties(RedisProperties.class)
public class CacheConfiguration {
@Autowired
RedisProperties redisProperties;
@Bean
RedissonClient redissonClient1() {
Config config = new Config();
String node = redisProperties.getSingle().getAddress1();
node = node.startsWith("redis://") ? node : "redis://" + node;
SingleServerConfig serverConfig = config.useSingleServer()
.setAddress(node)
.setTimeout(redisProperties.getPool().getConnTimeout())
.setConnectionPoolSize(redisProperties.getPool().getSize())
.setConnectionMinimumIdleSize(redisProperties.getPool().getMinIdle());
if (StringUtils.isNotBlank(redisProperties.getPassword())) {
serverConfig.setPassword(redisProperties.getPassword());
}
return Redisson.create(config);
}
@Bean
RedissonClient redissonClient2() {
Config config = new Config();
String node = redisProperties.getSingle().getAddress2();
node = node.startsWith("redis://") ? node : "redis://" + node;
SingleServerConfig serverConfig = config.useSingleServer()
.setAddress(node)
.setTimeout(redisProperties.getPool().getConnTimeout())
.setConnectionPoolSize(redisProperties.getPool().getSize())
.setConnectionMinimumIdleSize(redisProperties.getPool().getMinIdle());
if (StringUtils.isNotBlank(redisProperties.getPassword())) {
serverConfig.setPassword(redisProperties.getPassword());
}
return Redisson.create(config);
}
@Bean
RedissonClient redissonClient3() {
Config config = new Config();
String node = redisProperties.getSingle().getAddress3();
node = node.startsWith("redis://") ? node : "redis://" + node;
SingleServerConfig serverConfig = config.useSingleServer()
.setAddress(node)
.setTimeout(redisProperties.getPool().getConnTimeout())
.setConnectionPoolSize(redisProperties.getPool().getSize())
.setConnectionMinimumIdleSize(redisProperties.getPool().getMinIdle());
if (StringUtils.isNotBlank(redisProperties.getPassword())) {
serverConfig.setPassword(redisProperties.getPassword());
}
return Redisson.create(config);
}
/**
* 单机
* @return
*/
/*@Bean
public Redisson redisson()
{
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.111.147:6379").setDatabase(0);
return (Redisson) Redisson.create(config);
}*/
}
- 编写RedisPoolProperties 配置类,创建Redis连接池
@Data
public class RedisPoolProperties {
private int maxIdle;
private int minIdle;
private int maxActive;
private int maxWait;
private int connTimeout;
private int soTimeout;
/**
* 池大小
*/
private int size;
}
- 编写配置RedisProperties ,创建redis配置对象
@ConfigurationProperties(prefix = "spring.redis", ignoreUnknownFields = false)
@Data
public class RedisProperties {
private int database;
/**
* 等待节点回复命令的时间。该时间从命令发送成功时开始计时
*/
private int timeout;
private String password;
private String mode;
/**
* 池配置
*/
private RedisPoolProperties pool;
/**
* 单机信息配置
*/
private RedisSingleProperties single;
}
- 编写RedisSingleProperties 配置
@Data
public class RedisSingleProperties {
private String address1;
private String address2;
private String address3;
}
- 执行业务
@RestController
@Slf4j
public class RedLockController {
public static final String CACHE_KEY_REDLOCK = "ZZYY_REDLOCK";
@Autowired
RedissonClient redissonClient1;
@Autowired
RedissonClient redissonClient2;
@Autowired
RedissonClient redissonClient3;
@GetMapping(value = "/redlock")
public void getlock() {
//CACHE_KEY_REDLOCK为redis 分布式锁的key
RLock lock1 = redissonClient1.getLock(CACHE_KEY_REDLOCK);
RLock lock2 = redissonClient2.getLock(CACHE_KEY_REDLOCK);
RLock lock3 = redissonClient3.getLock(CACHE_KEY_REDLOCK);
//分别对不同机器都上锁
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
boolean isLock;
try {
//waitTime 锁的等待时间处理,正常情况下 等5s
//leaseTime就是redis key的过期时间,正常情况下等5分钟。
isLock = redLock.tryLock(5, 300, TimeUnit.SECONDS);
log.info("线程{},是否拿到锁:{} ",Thread.currentThread().getName(),isLock);
if (isLock) {
//TODO if get lock success, do something;
//暂停20秒钟线程
try { TimeUnit.SECONDS.sleep(20); } catch (InterruptedException e) { e.printStackTrace(); }
}
} catch (Exception e) {
log.error("redlock exception ",e);
} finally {
// 防止删除别人锁
if(redissonLock.isLocked() && redissonLock.isHeldByCurrentThread())
{
redissonLock.unlock();
}
System.out.println(Thread.currentThread().getName()+"\t"+"redLock.unlock()");
}
}
}
缓存续命:看门狗
- 问题引入:Redis 分布式锁过期了,但是业务逻辑还没处理完怎么办
- Redisson 使用“看门狗”定期检查(每1/3的锁时间检查1次),如果线程还持有锁,则刷新过期时间;也就是额外起一个线程,定期检查线程是否还持有锁,如果有则延长过期时间。
- 在获取锁成功后,给锁加一个 watchdog,watchdog 会起一个定时任务,在锁没有被释放且快要过期的时候会续期。
- 通过redisson新建出来的锁key,默认是30秒
- 客户端A加锁成功,就会启动一个watch dog看门狗,他是一个后台线程,会每隔10秒检查一下,如果客户端A还持有锁key,那么就会不断的延长锁key的生存时间,默认每次续命又从30秒新开始
- 流程解释
通过exists判断,如果锁不存在,则设置值和过期时间,加锁成功
通过hexists判断,如果锁已存在,并且锁的是当前线程,则证明是重入锁,加锁成功
如果锁已存在,但锁的不是当前线程,则证明有其他线程持有锁。返回当前锁的过期时间(代表了lockzzyy这个锁key的剩余生存时间),加锁失败
解锁时,重入多少次,就解锁几次
Redis的缓存过期淘汰策略
Redis内存
- redis默认内存多少?redis默认是不设置最大内存的,如果不设置在64位中是不限制内存大小的,32位中最大内存是3GB
- 如何修改redis内存设置?修改redis配置文件,设置maxmemory参数,maxmemory是bytes字节类型
- 一般生产上你如何配置?一般推荐Redis设置内存为最大物理内存的四分之三
- 什么命令查看redis内存使用情况? info memory
- 如果Redis内存使用超出了设置的最大值会怎样?会启动redis的淘汰策略,删除部分Redis中的数据
缓存过期淘汰策略
问题引入:如果一个键是过期的,那它到了过期时间之后是不是马上就从内存中被被删除呢??肯定不是
Redis过期键的删除策略:
立即删除:拿时间换空间
- Redis不可能时时刻刻遍历所有被设置了生存时间的key,来检测数据是否已经到达过期时间,然后对它进行删除。
- 立即删除能保证内存中数据的最大新鲜度,因为它保证过期键值会在过期后马上被删除,其所占用的内存也会随之释放。但是立即删除 对cpu是最不友好的。因为删除操作会占用cpu的时间,如果刚好碰上了cpu很忙的时候,比如正在做交集或排序等计算的时候,就会给cpu造成额外的压力,让CPU心累,时时需要删除,忙死。。。。。。。
- 这会产生大量的性能消耗,同时也会影响数据的读取操作。
惰性删除:拿空间换时间
- 数据到达过期时间,不做处理。等下次访问该数据时,
如果未过期,返回数据 ;
发现已过期,删除,返回不存在。 - 惰性删除策略的缺点是,它对内存是最不友好的。
- 如果一个键已经过期,而这个键又仍然保留在redis中,那么只要这个过期键不被删除,它所占用的内存就不会释放。在使用惰性删除策略时,如果数据库中有非常多的过期键,而这些过期键又恰好没有被访问到的话,那么它们也许永远也不会被删除(除非用户手动执行FLUSHDB),我们甚至可以将这种情况看作是一种内存泄漏–无用的垃圾数据占用了大量的内存,而服务器却不会自己去释放它们,这对于运行状态非常依赖于内存的Redis服务器来说,肯定不是一个好消息
定期删除:以上两种都比较极端,这个是折中方案
-
定期删除策略每隔一段时间执行一次删除过期键操作,并通过限制删除操作执行的时长和频率来减少删除操作对CPU时间的影响。
-
周期性轮询redis库中的时效性数据,采用随机抽取的策略,利用过期数据占比的方式控制删除频度
特点1:CPU性能占用设置有峰值,检测频度可自定义设置
特点2:内存压力不是很大,长期占用内存的冷数据会被持续清理
总结:周期性抽查存储空间 (随机抽查,重点抽查) -
举例:redis默认每个100ms检查,是否有过期的key,有过期key则删除。注意:redis不是每隔100ms将所有的key检查一次而是随机抽取进行检查。因此,如果只采用定期删除策略,会导致很多key到时间没有删除。
-
定期删除策略的难点是确定删除操作执行的时长和频率:如果删除操作执行得太频繁,或者执行的时间太长,定期删除策略就会退化成立即删除策略,以至于将CPU时间过多地消耗在删除过期键上面。如果删除操作执行得太少,或者执行的时间太短,定期删除策略又会和惰性删除束略一样,出现浪费内存的情况。因此,如果采用定期删除策略的话,服务器必须根据情况,合理地设置删除操作的执行时长和执行频率。
-
以上三种方式,都不理想,定期删除,会有一个key从来没有被删除过。
redis缓存淘汰策略
-
noeviction: 不会驱逐任何key
-
allkeys-lru: 对所有key使用LRU算法进行删除
-
volatile-lru: 对所有设置了过期时间的key使用LRU算法进行删除
-
allkeys-random: 对所有key随机删除
-
volatile-random: 对所有设置了过期时间的key随机删除
-
volatile-ttl: 删除马上要过期的key
-
allkeys-lfu: 对所有key使用LFU算法进行删除
-
volatile-lfu: 对所有设置了过期时间的key使用LFU算法进行删除
-
怎么用?可以通过用命令,或者直接修改配置文件。
-
你平常用哪个?volatile-lru: 对所有设置了过期时间的key使用LRU算法进行删除
Redis五大数据类型底层结构
缓存双写一致性
Redis与MySQL数据双写一致性工程落地案例-------canal的使用
- 场景引入:只要用缓存,就可能会涉及到缓存与数据库双存储双写,只要是双写,就一定会有数据一致性的问题,那么何解决一致性问题?
- canal是什么:Canal是基于MySQL变更日志增量订阅和消费的组件。MySQL数据库中的数据如果发生改变,会将改变的信息写入binlog日志中,canal就是通过订阅和消费binlog日志中的内容,来监控MySQL数据变化,并获取变化的数据。
- canal能干什么?数据库镜像、数据库实时备份等
- 传统MySQL主从复制工作原理步骤:
1、当 master 主服务器上的数据发生改变时,则将其改变写入二进制事件日志文件中;
2、salve 从服务器会在一定时间间隔内对 master 主服务器上的二进制日志进行探测,探测其是否发生过改变,如果探测到 master 主服务器的二进制事件日志发生了改变,则开始一个 I/O Thread 请求 master 二进制事件日志;
3、同时 master 主服务器为每个 I/O Thread 启动一个dump Thread,用于向其发送二进制事件日志;
4、slave 从服务器将接收到的二进制事件日志保存至自己本地的中继日志文件中;
5、salve 从服务器将启动 SQL Thread 从中继日志中读取二进制日志,在本地重放,使得其数据和主服务器保持一致;
6、最后 I/O Thread 和 SQL Thread 将进入睡眠状态,等待下一次被唤醒;
- canal工作原理
- 分布式系统只有最终一致性,很难做到强一致性
mysql-canal-redis双写一致性案例
在MySQL中开启binlog日志
查看SHOW VARIABLES LIKE 'log_bin';
![在这里插入图片描述](https://img-blog.csdnimg.cn/022e511ac4094f458d5fb479542bf8fa.png)
- 开启 MySQL的binlog写入功能:操作MySQL配置文件my.ini
log-bin=mysql-bin #开启 binlog
binlog-format=ROW #选择 ROW 模式
server_id=1 #配置MySQL replaction需要定义,不要和canal的 slaveId重复
其中,ROW模式 除了记录sql语句之外,还会记录每个字段的变化情况,能够清楚的记录每行数据的变化历史,但会占用较多的空间。
- 然后重启mysql
- 再次查看SHOW VARIABLES LIKE 'log_bin';
- 授权canal连接MySQL账号
DROP USER 'canal'@'%';
CREATE USER 'canal'@'%' IDENTIFIED BY 'canal';
GRANT ALL PRIVILEGES ON *.* TO 'canal'@'%' IDENTIFIED BY 'canal';
FLUSH PRIVILEGES;
SELECT * FROM mysql.user;
配置canal
- 下载canal:https://github.com/alibaba/canal/releases
- 解压:/mycanal路径
- 配置修改
- /mycanal/canal.deployer-1.1.5/conf/example路径下的instance.properties
![在这里插入图片描述](https://img-blog.csdnimg.cn/e847dbc768bc41b8b9b09825d4e02982.png)
![在这里插入图片描述](https://img-blog.csdnimg.cn/159fd1682b8c4d88bc86000e0b117668.png)
- 启动:/mycanal/canal.deployer-1.1.5/bin路径下执行:./startup.sh
- 可以通过查询日志,判断是否启动成功
工程落地
- RedisUtils
public class RedisUtils
{
public static JedisPool jedisPool;
static {
JedisPoolConfig jedisPoolConfig=new JedisPoolConfig();
jedisPoolConfig.setMaxTotal(20);
jedisPoolConfig.setMaxIdle(10);
jedisPool=new JedisPool(jedisPoolConfig,"192.168.111.147",6379);
}
public static Jedis getJedis() throws Exception {
if(null!=jedisPool){
return jedisPool.getResource();
}
throw new Exception("Jedispool is not ok");
}
public static void main(String[] args) throws Exception
{
try(Jedis jedis = RedisUtils.getJedis())
{
System.out.println(jedis);
jedis.set("k1","xxx2");
String result = jedis.get("k1");
System.out.println("-----result: "+result);
System.out.println(RedisUtils.jedisPool.getNumActive());//1
}catch (Exception e){
e.printStackTrace();
}
}
}
- RedisCanalClientExample
public class RedisCanalClientExample
{
public static final Integer _60SECONDS = 60;
public static void main(String args[]) {
// 创建链接canal服务端
CanalConnector connector = CanalConnectors.newSingleConnector(new InetSocketAddress("192.168.111.147",
11111), "example", "", "");
int batchSize = 1000;
int emptyCount = 0;
try {
connector.connect();
//connector.subscribe(".*\\..*");
//要监控的哪张表
connector.subscribe("db2020.t_user");
connector.rollback();
int totalEmptyCount = 10 * _60SECONDS;
while (emptyCount < totalEmptyCount) {
Message message = connector.getWithoutAck(batchSize); // 获取指定数量的数据
long batchId = message.getId();
int size = message.getEntries().size();
if (batchId == -1 || size == 0) {
emptyCount++;
try { TimeUnit.SECONDS.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }
} else {
emptyCount = 0;
//真正干活的方法
printEntry(message.getEntries());
}
connector.ack(batchId); // 提交确认
// connector.rollback(batchId); // 处理失败, 回滚数据
}
System.out.println("empty too many times, exit");
} finally {
connector.disconnect();
}
}
/**
*将监控到的数据进行处理
*/
private static void printEntry(List<Entry> entrys) {
for (Entry entry : entrys) {
if (entry.getEntryType() == EntryType.TRANSACTIONBEGIN || entry.getEntryType() == EntryType.TRANSACTIONEND)
{
continue;
}
RowChange rowChage = null;
try {
rowChage = RowChange.parseFrom(entry.getStoreValue());
} catch (Exception e) {
throw new RuntimeException("ERROR ## parser of eromanga-event has an error,data:" + entry.toString(),e);
}
EventType eventType = rowChage.getEventType();
System.out.println(String.format("================> binlog[%s:%s] , name[%s,%s] , eventType : %s",
entry.getHeader().getLogfileName(), entry.getHeader().getLogfileOffset(),
entry.getHeader().getSchemaName(), entry.getHeader().getTableName(), eventType));
for (RowData rowData : rowChage.getRowDatasList()) {
if (eventType == EventType.INSERT) {
redisInsert(rowData.getAfterColumnsList());
} else if (eventType == EventType.DELETE) {
redisDelete(rowData.getBeforeColumnsList());
} else {//EventType.UPDATE
redisUpdate(rowData.getAfterColumnsList());
}
}
}
}
//插入reids
private static void redisInsert(List<Column> columns)
{
JSONObject jsonObject = new JSONObject();
for (Column column : columns)
{
System.out.println(column.getName() + " : " + column.getValue() + " update=" + column.getUpdated());
jsonObject.put(column.getName(),column.getValue());
}
if(columns.size() > 0)
{
try(Jedis jedis = RedisUtils.getJedis())
{
jedis.set(columns.get(0).getValue(),jsonObject.toJSONString());
}catch (Exception e){
e.printStackTrace();
}
}
}
//删除redis数据
private static void redisDelete(List<Column> columns)
{
JSONObject jsonObject = new JSONObject();
for (Column column : columns)
{
jsonObject.put(column.getName(),column.getValue());
}
if(columns.size() > 0)
{
try(Jedis jedis = RedisUtils.getJedis())
{
jedis.del(columns.get(0).getValue());
}catch (Exception e){
e.printStackTrace();
}
}
}
//更改redis
private static void redisUpdate(List<Column> columns)
{
JSONObject jsonObject = new JSONObject();
for (Column column : columns)
{
System.out.println(column.getName() + " : " + column.getValue() + " update=" + column.getUpdated());
jsonObject.put(column.getName(),column.getValue());
}
if(columns.size() > 0)
{
try(Jedis jedis = RedisUtils.getJedis())
{
jedis.set(columns.get(0).getValue(),jsonObject.toJSONString());
System.out.println("---------update after: "+jedis.get(columns.get(0).getValue()));
}catch (Exception e){
e.printStackTrace();
}
}
}
}
缓存双写一致性策略
- 缓存双写一致性,谈谈你的理解:
- 如果redis中有数据,需要和数据库中的值相同
- 如果redis中无数据,数据库中的值要是最新值
- 目的:达到最终一致性
- 给缓存设置过期时间,是保证最终一致性的解决方案。
我们可以对存入缓存的数据设置过期时间,所有的写操作以数据库为准,对缓存操作只是尽最大努力即可。也就是说如果数据库写成功,缓存更新失败,那么只要到达过期时间,则后面的读请求自然会从数据库中读取新值然后回填缓存,达到一致性,切记以mysql的数据库写入库为准。但是缓存失效需要一定的时间,这个时候会产生短时间的脏数据,所有的更新策略都不是100%绝对正确,都存在一些问题,在这种情况下,我们选择最优、最合适的打法。
数据库和缓存一致性的更新策略
先更新数据库,再更新缓存
产生的问题:
1 先更新mysql的某商品的库存,当前商品的库存是100,更新为99个。
2 先更新mysql修改为99成功,然后更新redis。
3 此时假设异常出现,更新redis失败了,这导致mysql里面的库存是99而redis里面的还是100 。
4 上述发生,会让数据库里面和缓存redis里面数据不一致,读到脏数据
先删除缓存,再更新数据库
异常问题:
A线程先成功删除了redis里面的数据,然后去更新mysql,此时mysql正在更新中,还没有结束。(比如网络延时)
B突然出现要来读取缓存数据。通过并发控制(MVCC)来处理并发访问,B线程可以访问旧的数据。
B会把获得的旧值写回redis,获得旧值数据后返回前台并回写进redis(刚被A线程删除的旧数据有极大可能又被写回了)。
异常导致的结果:
- A线程更新完mysql,发现redis里面的缓存是脏数据
- 两个并发操作,一个是更新操作,另一个是查询操作,A更新操作删除缓存后,B查询操作没有命中缓存,B先把老数据读出来后放到缓存中,然后A更新操作更新了数据库。
- 于是,在缓存中的数据还是老的数据,导致缓存中的数据是脏的,而且还一直这样脏下去了。
以上问题会产生的风险:热点key突然失效,高并发下,产生缓存击穿,MySQL数据库有打爆的风险。
解决方案:
- 采用延时双删策略:A线程先删redis,然后更新数据库之后,等一会,然后把reids中的数据删了。
- 这个删除该休眠多久呢?
线程A sleep的时间,就需要大于线程B读取数据再写入缓存的时间。
这个时间怎么确定呢?
在业务程序运行的时候,统计下线程读数据和写缓存的操作时间,自行评估自己的项目的读数据业务逻辑的耗时,
以此为基础来进行估算。然后写数据的休眠时间则在读数据业务逻辑的耗时基础上加百毫秒即可。
这么做的目的,就是确保读请求结束,写请求可以删除读请求造成的缓存脏数据。
- 当前演示的效果是mysql单机,如果mysql主从读写分离架构如何?
(1)请求A进行写操作,删除缓存
(2)请求A将数据写入数据库了,
(3)请求B查询缓存发现,缓存没有值
(4)请求B去从库查询,这时,还没有完成主从同步,因此查询到的是旧值
(5)请求B将旧值写入缓存
(6)数据库完成主从同步,从库变为新值 上述情形,就是数据不一致的原因。还是使用双删延时策略。
只是,睡眠时间修改为在主从同步的延时时间基础上,加几百ms - 这种同步淘汰策略,吞吐量降低怎么办?
延时双删,由于要暂停线程,吞吐量下降。可以使用多线程ComplatableFuture异步删除。
注:下面代码的暂停20s是模拟业务处理时间。
先更新数据库,再删除缓存
- 会产生的问题:A线程更新数据库中的某个值,在A还没更新结束时,B线程读取到缓存的值,此时B读取的是旧值。
- 解决方案:使用canal
- 解决思想:监听数据库binlog日志+消息队列
1、可以把要删除的缓存值或者是要更新的数据库值暂存到消息队列中(例如使用Kafka/RabbitMQ等)。
2、当程序没有能够成功地删除缓存值或者是更新数据库值时,可以从消息队列中重新读取这些值,然后再次进行删除或更新。
3、如果能够成功地删除或更新,我们就要把这些值从消息队列中去除,以免重复操作,此时,我们也可以保证数据库和缓存的数据一致
了,否则还需要再次进行重试
4、如果重试超过的一定次数后还是没有成功,我们就需要向业务层发送报错信息了,通知运维人员。
不存在先更新缓存,再更新数据库这种策略,更新操作只能先动数据库
方案2和方案3怎么选?
- 在大多数业务场景下,我们会把Redis作为只读缓存使用。假如定位是只读缓存来说,理论上我们既可以先删除缓存值再更新数据库,也可以先更新数据库再删除缓存,但是没有完美方案,两害相衡趋其轻的原则。
- 优先使用先更新数据库,再删除缓存的方案。理由如下:
- 先删除缓存值再更新数据库,有可能导致请求因缓存缺失而访问数据库,给数据库带来压力,严重导致打满mysql。缓存击穿。
- 如果业务应用中读取数据库和写缓存的时间不好估算,那么,延迟双删中的等待时间就不好设置。降低程序的吞吐量
- 当然,先更新数据库,再删除缓存,会产生一段时间的脏数据,此时可以为key设置过期时间,下次再来取得时候就是新的了
- 只能保证最终一致性,在最终一致性的前提下,推荐使用方案3
IO多路复用
为什么是单线程?因为文件事件处理器有个事件队列,所有的命令都是排队执行,没办法多线程。所以之操作redis中的数据,不涉及mysql,即使在高并发情况下,也可以不用加锁。
异步非阻塞:服务器先去忙,忙忘了,有结果,你再来通知我,客户端也不在这等着,既然有通知,那我就先去忙,等你通知了我再来。各忙各的,各干各的
BIO
select就是将有数据的socket标记为1(也就是准备就绪的socket),然后将这些socket读进内核态,然后内核态去遍历
epoll是一次性将socket读进内核态,然后那些socket准备就绪了,就排在队列的前面,并返回个数,内核只需读取这几个准备就绪的就可以。
Windows没有epoll函数,所以redis装进了Windows中只能用select,在Linux中使用的是epoll
Redis 是单线程还是多线程?
- 3.x 最早版本,redis是单线程
- 4.x版本,严格意义来说也不是单线程,而是负责处理客户端请求的线程是单线程,但是开始加了点多线程的东西(异步删除)。
- redis从6.0版本以后,正式采用多线程。
- 演化过程:
Redis3.X版本,使用单线程,此时Redis依旧很快,是因为它是基于内存操作,数据结构简单,采用多路复用和非阻塞IO(注意:不是IO多路复用),同时单线程可以避免上下文切换,省去多线程切换带来的时间和性能的消耗,还可以避免死锁。
但是这个有个问题,如果某个大key需要删除,删除时间比较长,此时就会阻塞。Redis4.0之后加入了异步删除。
对于Redis主要的性能瓶颈是内存或者网络带宽而并非 CPU。而内存很便宜易扩容,所以问题就来到了网络IO(网络带宽)。为了解决这个瓶颈,Redis6.0引入Linux网络编程中的IO多路复用技术,正式变为多线程。
- 结论:Redis工作线程是单线程的,但是,整个Redis来说,是多线程的;
Redis的IO多路复用
Redis执行命令的是单线程的,那它为什么还那么快?
Redis利用epoll来实现IO多路复用,将连接信息和事件放到队列中,一次放到文件事件分派器,事件分派器将事件分发给事件处理器。
- Redis 是跑在单线程中的,所有的操作都是按照顺序线性执行的,但是由于读写操作等待用户输入或输出都是阻塞的,所以 I/O 操作在一般情况下往往不能直接返回,这会导致某一文件的 I/O 阻塞导致整个进程无法对其它客户提供服务,而 I/O 多路复用就是为了解决这个问题而出现
- 所谓 I/O 多路复用机制,就是说通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或写就绪),能够通知程序进行相应的读写操作。这种机制的使用需要 select 、 poll 、 epoll 来配合。多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象上等待,无需阻塞等待所有连接。当某条连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理。
- Redis 服务采用 Reactor 的方式来实现文件事件处理器(每一个网络连接其实都对应一个文件描述符)
- Redis基于Reactor模式开发了网络事件处理器,这个处理器被称为文件事件处理器。它的组成结构为4部分:
- 多个套接字
- IO多路复用程序
- 文件事件分派器
- 事件处理器
- 因为文件事件分派器队列的消费是单线程的,所以Redis才叫单线程模型
IO多路复用模型
- I/O :网络 I/O
- 多路:多个客户端连接(连接就是套接字描述符,即 socket 或者 channel)
- 复用:复用一个或几个线程。也就是说一个或一组线程处理多个 TCP 连接,使用单进程就能够实现同时处理多个客户端的连接
- IO多路复用就是:一个服务端进程可以同时处理多个套接字描述符。
同步异步和阻塞非阻塞
- 同步:调用者要一直等待调用结果的通知后才能进行后续的执行。我现在就要结果,我可以等,等出结果为止。
- 异步:指被调用方先返回应答让调用者先回去,然后再计算调用结果,计算完最终结果后再通知并返回给调用方。异步调用要想获得结果一般通过回调。
- 同步与异步的理解:同步、异步的讨论对象是被调用者(服务提供者),重点在于获得调用结果的消息通知方式上
- 阻塞:调用方一直在等待而且别的事情什么都不做,当前进/线程会被挂起,啥都不干。
- 非阻塞:调用在发出去后,调用方先去忙别的事情,不会阻塞当前进/线程
- 阻塞与非阻塞的理解:阻塞、非阻塞的讨论对象是调用者(服务请求者),重点在于等消息时候的行为,调用者是否能干其它事
- 以上两两组合中,异步非阻塞最好,各干各的,互不干扰,都不阻塞。