前面的博客我们学习了redis的基础知识,现在来看下redis在实际的使用以及实际中的一些问题。
分布式锁
分布式锁的概念:当多个进程不在同一个系统中,用分布式锁控制多个进程对资源的访问。在分布式的环境下,线程A和线程B不在同一个jvm中,这时线程锁就没有了作用,需要使用分布式锁。使用分布式锁需要注意下面几点:
- 任意时刻,只能有一个线程拥有锁
- 不会发生死锁。使用redis实现分布式锁,需要注意一点,锁必须是有过期时间的,不能让一个线程长期占有一个锁而导致死锁。即使一个线程在锁持有的时间崩溃了,但是也要保证后续的线程能加锁
- 解锁和加锁必须是同一个客户端
- 具有容错性,只要大部分节点运行,客户端还是可以加锁
//加锁
public static boolean tryGetLock(Jedis jedis){
//key当做锁,key是唯一的
//value可以用于表示哪个客户端加的锁
//NX,SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作;
//PX,加一个过期的设置
//100:过期时间
String result = jedis.set("key","value","NX","PX",100);
//执行上面会有两种结果,一是没有锁,key不存在,那么加锁,否则已有锁,不做任何处理
if("OK".equals(result)){
return true;
}
return false;
}
//解锁
public static boolean releaseLock(Jedis jedis){
//Lua脚本代码,首先获取锁对应的value值,检查是否与requestId相等,如果相等则删除锁(解锁)
//Lia脚本代码确保是原子性的
String script = "if redis.call('get', KEYS[1]) == ARGV[1] " +
"then return redis.call('del', KEYS[1]) else return 0 end";
//解锁
Object result = jedis.eval(script, Collections.singletonList("key"), Collections.singletonList("value"));
Long success =1L;
if (success.equals(result)) {
return true;
}
return false;
}
去重
在开发中,我们会遇到这种场景,比如产品要求在10分钟内,一个用户的一个订单号只能查一次,那么可以使用redis实现。其原理还是给一个唯一key判断如果已经存在,那么说明这个weiyikey对应的业务已经在一段时间内加过锁了,那么在key未过期时间范围内,不能再进行操作。这种方式与分布式锁的区别是,不需要必须是同一个客户端对key进行加锁和解锁操作。
//根据orderId去重,加锁
public static boolean uniqueLock(Jedis jedis,String orderId){
//根据orderId为主键进行加锁,加锁失败返回null
String result = jedis.set(orderId,"value","NX","EX",100);
//true 加锁成功,false加锁失败
return result!=null;
}
//根据orderId去重,解锁,或者等过了过期时间
public static void uniqueUnLock(Jedis jedis,String orderId){
//根据orderId为主键进行加锁,加锁失败返回null
jedis.del(orderId);
}
缓存穿透
在一般情况下,我们使用缓存是在查询前优先从缓存中获取数据,但是如果key不存在或者过期,那么从数据库中获取,然后更新缓存,否则直接获取缓存中的值。而缓存穿透是指一个key对应的数据一定不存在。每次针对这个key的获取,无法从缓存中获取到,那么导致请求始终打到数据库中,可能导致数据库崩溃。在公司的开发中,遇到了类似的问题,导致加了缓存后读取数据库的qps一直没有下降的现象,排查才发现,大部分的请求从数据库中查找的都是空的值,那么按照平常的开发,肯定导致请求全打在数据库中。后续改进,即使从数据库中查到的数据时空的,依然以key-value的形式进行缓存,只不过value是一个表示nul了的值。结果读数据库的qps逐渐下降,大部分的请求都看从redis中获取到值,降低了对数据库的读。
//缓存穿透解决
public static void breakCache(Jedis jedis){
String value ="从数据库获取的值"; //value表示从数据库获取的值
if(null == value || value.trim().equals("")){
jedis.setex("key",100,"一个表示空的值");//即使获取的值为空,仍然进行缓存
}else{
jedis.setex("key",100,value); //缓存一定的时间
}
}
缓存雪崩
是指当大量缓存在每一段时间内失效,那么假设突然有上万的请求瞬间到达,那么数据库将承担巨大的压力。解决方法是对不同的key设置不同的缓存时间(比如在原有的失效时间基础上增加一个随机值),尽肯能的分散key的过期时间。
//缓存雪崩解决
public static void snowCache(Jedis jedis){
//设置不同的过期时间,不要让key在同一时间内大量失效
jedis.setex("key", (new Random()).nextInt(),"value");
}
缓存击穿
一个缓存的key在过期之后,突然打了的请求打了过来,那么导致走到数据库,这个时候大并发的请求导致数据库压力剧增。这个问题暂时没有遇见过。解决方案可以是:将特定key的过期时间设为不过期(不推荐),或者通过分布式锁的形式。在根据key获得的value值为空时,先锁上,再从数据库加载,加载完毕,释放锁,若其他线程发现获取锁失败,则睡眠n 毫秒后重试(代码复杂,常用的方式)。
public static void througnKey(Jedis jedis){
String value = jedis.get("key");//缓存获取
if(null == value){
if(tryGetLock(jedis)){ //分布式加锁
String dbValue ="db";//从数据库读
jedis.setex("key",100,dbValue);
releaseLock(jedis);//释放锁
}else{
Thread.sleep(100);
througnKey(jedis);//重试
}
}
}
消息队列
redis的列表类型可以用来实现队列,并且支持阻塞式读取,可以实现一个高性能的优先队列。我们可以在redis的列表的头部或者尾部添加元素。redis对list的操作l开头的命令表示从左边获取或者插入,r开头的命令表示从右边获取或者插入。下面我们实现一个简单消息队列。
//消息的生产者
public void producer(Jedis jedis){
new Thread(){
@Override
public void run() {
//插入消息进队列
jedis.lpush("key","value");
}
}.start();
}
//消息的消费
public void consumer(Jedis jedis){
String value =jedis.rpop("key");//从队列中获取消息
//处理 value
}
上述只是一个简单的消息队列,而且采用的是消费者和生产者模式。还有一种发布与订阅的模式,包含两种角色,分别是发布者和订阅者,订阅者可以订阅多个频道,而发布者可以向指定的频道发送消息,所有该频道的订阅者都会受到此消息。订阅的命令时subscribe,publish命令可以用来发布消息。
以上述的消息队列为例,在之前一次面试的时候,被问到如何改进为一个可靠的消息队列,因为可能消息在消费的时候失败,但是列表中已经没有此消息,导致该消息没有被处理。解决方案:
- 维护两个队列:pending队列和doing队列
- 消息生产的时候,推送一条消息到pending队列
- 消费消息的时候,在消费的时候同时将消息放到doing队列中
- 每次消费完成后,从doing队列中删除msg
- 然后在开一个线程定时查doing队列,如果在一定时间内,这个任务还没有被消费,那么认为消费失败,将其从doing队列中删除,重新放回到pending队列。
但是可能会发生查看doing中的任务的时候,消费者因为某些原因慢了,那么会导致doing中的消息重新放回到pending队列,导致消息重复消费。但是消费者的确完成了,导致消息重复消费了。我们可以在上述步骤4的时候,从doing中删除消息,如果返回值失败,那么认为消息被重新塞到pending中,那么这个时候只要把pending中的对应消息删除就行了。但是,又有可能pending中消息已经被消费了,那么这条消息还是被消费了两次。其实我们可以做好幂等性处理即可。