前面的博客我们学习了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命令可以用来发布消息。

   以上述的消息队列为例,在之前一次面试的时候,被问到如何改进为一个可靠的消息队列,因为可能消息在消费的时候失败,但是列表中已经没有此消息,导致该消息没有被处理。解决方案:

  1. 维护两个队列:pending队列和doing队列
  2. 消息生产的时候,推送一条消息到pending队列
  3. 消费消息的时候,在消费的时候同时将消息放到doing队列中
  4. 每次消费完成后,从doing队列中删除msg
  5. 然后在开一个线程定时查doing队列,如果在一定时间内,这个任务还没有被消费,那么认为消费失败,将其从doing队列中删除,重新放回到pending队列。

    但是可能会发生查看doing中的任务的时候,消费者因为某些原因慢了,那么会导致doing中的消息重新放回到pending队列,导致消息重复消费。但是消费者的确完成了,导致消息重复消费了。我们可以在上述步骤4的时候,从doing中删除消息,如果返回值失败,那么认为消息被重新塞到pending中,那么这个时候只要把pending中的对应消息删除就行了。但是,又有可能pending中消息已经被消费了,那么这条消息还是被消费了两次。其实我们可以做好幂等性处理即可。