缓存与Redis分布式锁初窥

一、高并发下缓存失效问题

1、缓存穿透

说明:指查询一个一定不存在的数据,由于缓存数据不存在,需要从数据库查询,查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到数据库去查询,造成缓存穿透。
风险:利用不存在的数据进行攻击,数据库瞬间压力增大,最终导致崩溃
解决:
  • 对查询结果为空的情况也进行缓存,缓存时间设置短一点,或者该 key 对应的数据更新了之后清理缓存。
  • 对一定不存在的 key 进行过滤。

2、缓存雪崩

说明:当缓存服务器重启或者大量缓存集中在某一时间段失效,发生大量的缓存穿透,所有的查询都集中在数据库上,数据库瞬间压力过重雪崩。
解决:
  • 加锁排队:在缓存失效后,通过加锁或者队列来控制读数据库写缓存的线程数量。比如对某个 key 只允许一个线程查询数据和写缓存,其他线程等待。
  • 数据预热:可以通过缓存 reload 机制,预先去更新缓存,在即将发生大并发访问前手动触发加载缓存不同的 key,设置不同的过期时间,让缓存失效的时间点尽量均匀。

3、缓存击穿

说明:对于设置了过期时间的 key 在大量请求同时进来前正好失效,那么所有对这个 key 的数据查询都集中在数据库中,称之为缓存击穿。
解决:
  • 使用锁,单机使用 synchronized,JUC 中的 lock 等,分布式下用分布式锁。大量并发让一个请求去查数据库,其他请求等待,查到数据以后存入缓存并释放锁,其他人获得锁,先查缓存,就会有数据,不用再操作数据库。

4、本地锁

说明:本地锁也就是使用 synchronized 或者使用 JUC 中的 Lock 对代码进行加锁。
示例:假如一个商品服务部署到8台服务器上,当8w请求同时进来,由于负载均衡机制,8w请求被平均分到8台服务器,this为当前实例对象,由于SpringBoot所有的组件都在容器中都是单例的,一个项目一个容器,8台服务器相当于有8个容器,每一个this代表当前实例的对象,也就是每一个this都是不同的锁,最终也就是加了8把锁,因此,在分布式下有几台机器,就会有几台机器的线程进来,相当于有8个线程同时去查数据库。

在这里插入图片描述

缺点:本地锁只能锁住当前进程,分布式下锁不住所有的服务。

实例代码:

@Override
public Map<String, List<User>> getUserJson(){
    //从缓存中获取数据
    String jsonString = stringRedisTemplate.opsForValue().get("list");
    //判断是否为空,为空就入缓存
    if (StringUtils.isEmpty(jsonString)) {
        log.info("缓存不命中,查询数据库......");
        Map<String, List<User>> userJsonFromDb = getUserJsonFromDb();
        return userJsonFromDb;
    }
    log.info("缓存命中,直接返回......");
    Map<String, List<User>> resultMap = JSON.parseObject(jsonString,new TypeReference<Map<String, List<User>>>(){});
    return resultMap;
}

/**
 * 从数据库获取分类数据
 * @return
 */
public Map<String, List<User>> getUserJsonFromDb() {
    synchronized (this) {
        //拿到锁以后,再次在缓存中确定一次,如果缓存中没有才需要继续查询
        String jsonString = stringRedisTemplate.opsForValue().get("list");
        //缓存不为空,直接返回数据
        if (!StringUtils.isEmpty(jsonString)) {
            Map<String, List<User>> resultMap = JSON.parseObject(jsonString,new TypeReference<Map<String, List<User>>>(){});
            return resultMap;
        }
        log.info("查询了数据库......");
        //查询所有用户数据
        List<User> list = this.list();
        Map<String, List<User>> listMap = list.stream().collect(Collectors.toMap(k -> k.getUserId().toString(), v -> v));
        //将信息放入缓存中
        stringRedisTemplate.opsForValue().set("list", JSON.toJSONString(listMap));
        return listMap;
    }
}

结果分析:

通过Jmter模拟大量请求访问,1和2为两个线程,此时线程1进入getUserJsonFromDb方法获得锁,发现没有缓存信息,就查询数据库,并将查询到的结果放入缓存中,这一系列动作都是原子操作的,线程2只能等待线程1解锁才能向下执行,因此“查询了数据库…”会被打印一次,本地锁实现完成。

在这里插入图片描述

二、分布式锁简单实现

1、概述

定义:在分布式系统下,在高并发的场景下,我们为了协调资源不被随意修改而做的对系统共享资源的保护,保证数据正确性。
示例:用户下单后减库存,当用户A对商品A下单并创建了新的订单,库存系统同步去减库存,若此时用户B也对商品A进行下单,但是在分布式系统下用户A和用户B可能请求的是不同的服务器,那么B此时拿到的库存数量可能还是之前的数量,这就可能导致超卖的情况发生。

2、实现原理

示例:还是拿8个商品服务举例,本地锁的情况下还是会查8次数据库,但是在使用分布式锁后,就只会有一个拿到锁并执行业务逻辑,其他的就必须原地等待,直到锁的释放。

在这里插入图片描述

利用redis的setnx、expire、getset、del这4个命令对应的提供的API函数接口实现:
1、setnx:是『SET if Not Exists』(如果不存在,则 SET)的简写
  • 命令格式:SETNX key value
  • 使用:只在键 key 不存在的情况下,将键 key 的值设置为 value 。若键 key 已经存在, 则 SETNX 命令不做任何动作。
  • 返回值:命令在设置成功时返回 1 ,设置失败时返回 0 。
2、getset
  • 命令格式:GETSET key value
  • 使用:将键 key 的值设为 value ,并返回键 key 在被设置之前的旧的value。
  • 返回值:如果键 key 没有旧值, 也即是说, 键 key 在被设置之前并不存在, 那么命令返回 nil 。当键 key 存在但不是字符串类型时,命令返回一个错误。
3、expire
  • 命令格式:EXPIRE key seconds
  • 使用:为给定 key 设置生存时间,当 key 过期时(生存时间为 0 ),它会被自动删除。
  • 返回值:设置成功返回 1 。 当 key 不存在或者不能为 key 设置生存时间时(比如在低于 2.1.3 版本的 Redis 中你尝试更新 key 的生存时间),返回 0 。
4、del
  • 命令格式:DEL key [key …]
  • 使用:删除给定的一个或多个 key ,不存在的 key 会被忽略。
  • 返回值:被删除 key 的数量。

3、分布式锁实现一

向 Redis 中添加一个 lockKey 锁标志位,如果添加成功则能够继续向下执行业务操作,最后再释放此标志位

在这里插入图片描述

代码实现:

@Override
public Map<String, List<User>> getUserJson(){
    //从缓存中获取数据
    String jsonString = stringRedisTemplate.opsForValue().get("list");
    //判断是否为空,为空就查库入缓存
    if (StringUtils.isEmpty(jsonString)) {
        log.info("缓存不命中,查询数据库......");
        Map<String, List<User>> userJsonFromDb = getUserJsonFromDbWithRedisLock();
        return userJsonFromDb;
    }
    log.info("缓存命中,直接返回......");
    Map<String, List<User>> resultMap = JSON.parseObject(jsonString,new TypeReference<Map<String, List<User>>>(){});
    return resultMap;
}

/**
 * 使用分布式锁方式一
 * @return
 */
public Map<String, List<User>> getUserJsonFromDbWithRedisLock() {
    //设置锁标志位
    String lockKey = "lock";
    //占分布式锁,向redis添加一个锁标志位
    Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(lockKey,"1111");
    if (lock) {
        //拿到锁以后,再次在缓存中确定一次,如果缓存中没有才需要继续查询
        String jsonString = stringRedisTemplate.opsForValue().get("list");
        //缓存不为空,直接返回数据
        if (!StringUtils.isEmpty(jsonString)) {
            Map<String, List<User>> resultMap = JSON.parseObject(jsonString,new TypeReference<Map<String, List<User>>>(){});
            return resultMap;
        }
        log.info("查询了数据库......");
        //查询所有用户
        List<User> list = this.list();
        Map<String, List<User>> listMap = list.stream().collect(Collectors.toMap(k -> k.getUserId().toString(), v -> v));
        //入缓存
        stringRedisTemplate.opsForValue().set("list", JSON.toJSONString(listMap));
        //执行完业务删除锁
        stringRedisTemplate.delete(lockKey);
        return listMap;
    } else {
        //加锁失败重试,可以设置休眠时间,避免频繁执行
        return getUserJsonFromDbWithRedisLock();//自旋的方式
    }
}
缺陷:锁标志位设置成功后,如果业务代码异常或者程序在页面过程中宕机,没有执行到删除锁的逻辑,就会造成死锁
解决办法:设置锁的自动过期,即使没有删除锁,锁也会自动过期。

4、分布式锁实现二

向 Redis 中添加一个 lockKey 锁标志位,并且设置自动过期时间

在这里插入图片描述

代码实现:

@Override
public Map<String, List<User>> getUserJson(){
    //从缓存中获取数据
    String jsonString = stringRedisTemplate.opsForValue().get("list");
    //判断是否为空,为空就查库入缓存
    if (StringUtils.isEmpty(jsonString)) {
        log.info("缓存不命中,查询数据库......");
        Map<String, List<User>> userJsonFromDb = getUserJsonFromDbWithRedisLock();
        return userJsonFromDb;
    }
    log.info("缓存命中,直接返回......");
    Map<String, List<User>> resultMap = JSON.parseObject(jsonString,new TypeReference<Map<String, List<User>>>(){});
    return resultMap;
}

/**
 * 使用分布式锁方式二
 * @return
 */
public Map<String, List<User>> getUserJsonFromDbWithRedisLock() {
    //设置锁标志位
    String lockKey = "lock";
    //占分布式锁,向redis添加一个锁标志位
    Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(lockKey,"1111");
    if (lock) {
        //设置锁的过期时间30s,此处可能会出现还没有设置过期时间的情况下,服务已经宕机了,还是会发生死锁
        stringRedisTemplate.expire(lockKey,30,TimeUnit.SECONDS);
        //拿到锁以后,再次在缓存中确定一次,如果缓存中没有才需要继续查询
        String jsonString = stringRedisTemplate.opsForValue().get("list");
        //缓存不为空,直接返回数据
        if (!StringUtils.isEmpty(jsonString)) {
            Map<String, List<User>> resultMap = JSON.parseObject(jsonString,new TypeReference<Map<String, List<User>>>(){});
            return resultMap;
        }
        log.info("查询了数据库......");
        //查询所有用户
        List<User> list = this.list();
        Map<String, List<User>> listMap = list.stream().collect(Collectors.toMap(k -> k.getUserId().toString(), v -> v));
        //入缓存
        stringRedisTemplate.opsForValue().set("list", JSON.toJSONString(listMap));
        //执行完业务删除锁
        stringRedisTemplate.delete(lockKey);
        return listMap;
    } else {
        //加锁失败重试,可以设置休眠时间,避免频繁执行
        return getUserJsonFromDbWithRedisLock();//自旋的方式
    }
}
缺陷:锁标志位设置成功后,正要去设置过期时间,这时,恰好遇到程序宕机,又出现死锁
解决方法:这是因为设置锁标志位和设置过期时间不是一个原子性,要么一起成功或一起失败。

5、分布式锁实现三(原子锁)

从redis2.6.12版开始,redis为set命令增加(set [key] NX/XX EX/PX [expiration])
  • EX(seconds):设置key的过期时间,单位是秒
  • PX(milliseconds):设置key的过期时间,单位是毫秒
  • NX:只有key不存在的时候才会设置key的值
  • XX:只有key存在的时候才会设置key的值
# 加锁
SET key value [expiration EX seconds|PX milliseconds] [NX|XX]

在这里插入图片描述

代码实现:

@Override
public Map<String, List<User>> getUserJson(){
    //从缓存中获取数据
    String jsonString = stringRedisTemplate.opsForValue().get("list");
    //判断是否为空,为空就查库入缓存
    if (StringUtils.isEmpty(jsonString)) {
        log.info("缓存不命中,查询数据库......");
        Map<String, List<User>> userJsonFromDb = getUserJsonFromDbWithRedisLock();
        return userJsonFromDb;
    }
    log.info("缓存命中,直接返回......");
    Map<String, List<User>> resultMap = JSON.parseObject(jsonString,new TypeReference<Map<String, List<User>>>(){});
    return resultMap;
}

/**
 * 使用分布式锁方式三
 * @return
 */
public Map<String, List<User>> getUserJsonFromDbWithRedisLock() {
    //设置锁标志位
    String lockKey = "lock";
    //占分布式锁,向redis添加一个锁标志位,并设置自动过期时间,保证添加标志位和设置过期时间是一个原子性
    Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(lockKey,"1111",60,TimeUnit.SECONDS);
    if (lock) {
        //拿到锁以后,再次在缓存中确定一次,如果缓存中没有才需要继续查询
        String jsonString = stringRedisTemplate.opsForValue().get("list");
        //缓存不为空,直接返回数据
        if (!StringUtils.isEmpty(jsonString)) {
            Map<String, List<User>> resultMap = JSON.parseObject(jsonString,new TypeReference<Map<String, List<User>>>(){});
            return resultMap;
        }
        log.info("查询了数据库......");
        //查询所有用户
        List<User> list = this.list();
        Map<String, List<User>> listMap = list.stream().collect(Collectors.toMap(k -> k.getUserId().toString(), v -> v));
        //入缓存
        stringRedisTemplate.opsForValue().set("list", JSON.toJSONString(listMap));
        //执行完业务删除锁
        stringRedisTemplate.delete(lockKey);
        return listMap;
    } else {
        //加锁失败重试,可以设置休眠时间,避免频繁执行
        return getUserJsonFromDbWithRedisLock();//自旋的方式
    }
}
缺陷:加锁以及设置过期时间确实保证了原子性;假如有两个线程进来了,线程A加锁成功,线程B等待,此时,线程A执行的业务逻辑时间很长,超过了锁的过期时间,这时锁标志位过期释放了,线程B就设置锁成功,然而此时线程A业务执行完成后,执行释放锁代码,顺手把线程B持有的锁释放了。
解决方法:加锁的时候给锁加一个唯一标识身份的值,每个线程只能释放和自己匹配的锁。

6、分布式锁实现四

向 Redis 中添加一个 lockKey 锁标志位,设置自动过期时间,并设置唯一身份id

在这里插入图片描述

代码实现:

@Override
public Map<String, List<User>> getUserJson(){
    //从缓存中获取数据
    String jsonString = stringRedisTemplate.opsForValue().get("list");
    //判断是否为空,为空就查库入缓存
    if (StringUtils.isEmpty(jsonString)) {
        log.info("缓存不命中,查询数据库......");
        Map<String, List<User>> userJsonFromDb = getUserJsonFromDbWithRedisLock();
        return userJsonFromDb;
    }
    log.info("缓存命中,直接返回......");
    Map<String, List<User>> resultMap = JSON.parseObject(jsonString,new TypeReference<Map<String, List<User>>>(){});
    return resultMap;
}

/**
 * 使用分布式锁方式四
 * @return
 */
public Map<String, List<User>> getUserJsonFromDbWithRedisLock() {
    //设置锁标志位
    String lockKey = "lock";
    //设置唯一身份id
    String lockValue = UUID.randomUUID().toString();
    //占分布式锁,向redis添加一个锁标志位,并设置自动过期时间与唯一身份id,保证添加标志位和设置过期时间是一个原子性
    Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(lockKey,lockValue,60,TimeUnit.SECONDS);
    if (lock) {
        //拿到锁以后,再次在缓存中确定一次,如果缓存中没有才需要继续查询
        String jsonString = stringRedisTemplate.opsForValue().get("list");
        //缓存不为空,直接返回数据
        if (!StringUtils.isEmpty(jsonString)) {
            Map<String, List<User>> resultMap = JSON.parseObject(jsonString,new TypeReference<Map<String, List<User>>>(){});
            return resultMap;
        }
        log.info("查询了数据库......");
        //查询所有用户
        List<User> list = this.list();
        Map<String, List<User>> listMap = list.stream().collect(Collectors.toMap(k -> k.getUserId().toString(), v -> v));
        //入缓存
        stringRedisTemplate.opsForValue().set("list", JSON.toJSONString(listMap));
        //判断是否是同一身份id,但是此处会出现问题
        if (Objects.equals(stringRedisTemplate.opsForValue().get(lockKey),lockValue)) {
            //执行完业务删除锁
            stringRedisTemplate.delete(lockKey);
        }
        return listMap;
    } else {
        //加锁失败重试,可以设置休眠时间,避免频繁执行
        return getUserJsonFromDbWithRedisLock();//自旋的方式
    }
}
缺陷:加锁时保证了原子性,但是在解锁的时候,判断身份和删除锁并不是原子操作的,所以还会存在误删。假如有两个线程进来了,线程A执行到判断用户身份成功,此时刚好线程A获得锁的时间过期,删除锁逻辑出现延迟,线程B立即获得锁,由于身份确认了,线程A继续执行删除锁操作,就会释放了线程B的锁(误删)。
解决方法:解决这种非原子操作的方式只能将判断元素值和删除标志位当作一个原子操作,使用 Lua语言编写脚本传到Redis中执行。

7、分布式锁实现五(使用Lua)

由于del操作并没有提供原子命令,因此会出现误删;但是在Redis 2.6 推出了脚本功能, 允许开发者使用 Lua 语言编写脚本传到 Redis 中执行,很好的解决了del操作非原子问题。
使用 Lua 脚本的好处:
  • 减少网络开销:原本我们需要向 Redis 服务请求多次命令, 可以将命令写在 Lua 脚本中, 这样执行只会发起一次网络请求
  • 原子操作:Redis 会将 Lua 脚本中的命令当作一个整体执行, 中间不会插入其它命令

在这里插入图片描述

代码实现:

@Override
public Map<String, List<User>> getUserJson(){
    //从缓存中获取数据
    String jsonString = stringRedisTemplate.opsForValue().get("list");
    //判断是否为空,为空就查库入缓存
    if (StringUtils.isEmpty(jsonString)) {
        log.info("缓存不命中,查询数据库......");
        Map<String, List<User>> userJsonFromDb = getUserJsonFromDbWithRedisLock();
        return userJsonFromDb;
    }
    log.info("缓存命中,直接返回......");
    Map<String, List<User>> resultMap = JSON.parseObject(jsonString,new TypeReference<Map<String, List<User>>>(){});
    return resultMap;
}

/**
 * 使用分布式锁方式五
 * @return
 */
public Map<String, List<User>> getUserJsonFromDbWithRedisLock() {
    //设置锁标志位
    String lockKey = "lock";
    //设置唯一身份id
    String lockValue = UUID.randomUUID().toString();
    //占分布式锁,向redis添加一个锁标志位,并设置自动过期时间与唯一身份id,保证添加标志位和设置过期时间是一个原子性
    Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent(lockKey,lockValue,60,TimeUnit.SECONDS);
    if (lock) {
        log.info("获取分布式锁成功......");
        //拿到锁以后,再次在缓存中确定一次,如果缓存中没有才需要继续查询
        String jsonString = stringRedisTemplate.opsForValue().get("list");
        //缓存不为空,直接返回数据
        if (!StringUtils.isEmpty(jsonString)) {
            Map<String, List<User>> resultMap = JSON.parseObject(jsonString,new TypeReference<Map<String, List<User>>>(){});
            return resultMap;
        }
        log.info("查询了数据库......");
        //查询所有用户
        List<User> list = this.list();
        Map<String, List<User>> listMap = list.stream().collect(Collectors.toMap(k -> k.getUserId().toString(), v -> v));
        //入缓存
        stringRedisTemplate.opsForValue().set("list", JSON.toJSONString(listMap));
        //使用Lua脚本解锁
        String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
        //执行脚本
        Long execute = stringRedisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class), Arrays.asList(lockKey), lockValue);
        return listMap;
    } else {
        log.info("获取分布式锁失败,重试......");
        //加锁失败重试,可以设置休眠时间,避免频繁执行
        return getUserJsonFromDbWithRedisLock();//自旋的方式
    }
}
总结:在加锁和删除锁的时候都保证了原子性,分布式锁实现完成。

8、抽取分布式锁工具类

package com.itan.lock;

import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.connection.RedisStringCommands;
import org.springframework.data.redis.connection.ReturnType;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.types.Expiration;
import java.nio.charset.StandardCharsets;
import java.util.UUID;


/**
 * @Description: 分布式锁工具类
 */
@Slf4j
public class RedisLock {
    private RedisTemplate<String, Object> redisTemplate;
    /**
     * 默认锁的有效时间(s)
     */
    public static final int EXPIRE = 60;
    /**
     * 默认请求锁的超时时间(ms 毫秒)
     */
    private static final long TIME_OUT = 0;
    /**
     * 默认请求间隔时间(ms 毫秒)
     */
    private static final long WAIT_MILLI_SPER = 10;
    /**
     * 解锁的lua脚本
     */
    public static final String UNLOCK_LUA;

    static {
        StringBuilder sb = new StringBuilder();
        sb.append("if redis.call(\"get\",KEYS[1]) == ARGV[1] ");
        sb.append("then ");
        sb.append("    return redis.call(\"del\",KEYS[1]) ");
        sb.append("else ");
        sb.append("    return 0 ");
        sb.append("end ");
        UNLOCK_LUA = sb.toString();
    }

    /**
     * 锁标志对应的key
     */
    private String lockKey;
    /**
     * 锁对应的值
     */
    private String lockValue;
    /**
     * 锁的有效时间(s)
     */
    private int expireTime = EXPIRE;
    /**
     * 请求锁的超时时间(ms)
     */
    private long timeOut = TIME_OUT;

    /**
     * 使用默认的锁过期时间和请求锁的超时时间
     * @param redisTemplate
     * @param lockKey       锁的key(Redis的Key)
     */
    public RedisLock(RedisTemplate<String, Object> redisTemplate, String lockKey) {
        this.redisTemplate = redisTemplate;
        this.lockKey = lockKey + "_lock";
    }

    /**
     * 使用默认的请求锁的超时时间,指定锁的过期时间
     * @param redisTemplate
     * @param lockKey       锁的key(Redis的Key)
     * @param expireTime    锁的过期时间(单位:秒)
     */
    public RedisLock(RedisTemplate<String, Object> redisTemplate, String lockKey, int expireTime) {
        this(redisTemplate, lockKey);
        this.expireTime = expireTime;
    }

    /**
     * 使用默认的锁的过期时间,指定请求锁的超时时间
     * @param redisTemplate
     * @param lockKey       锁的key(Redis的Key)
     * @param timeOut       请求锁的超时时间(单位:毫秒)
     */
    public RedisLock(RedisTemplate<String, Object> redisTemplate, String lockKey, long timeOut) {
        this(redisTemplate, lockKey);
        this.timeOut = timeOut;
    }

    /**
     * 锁的过期时间和请求锁的超时时间都是用指定的值
     * @param redisTemplate
     * @param lockKey       锁的key(Redis的Key)
     * @param expireTime    锁的过期时间(单位:秒)
     * @param timeOut       请求锁的超时时间(单位:毫秒)
     */
    public RedisLock(RedisTemplate<String, Object> redisTemplate, String lockKey, int expireTime, long timeOut) {
        this(redisTemplate, lockKey, expireTime);
        this.timeOut = timeOut;
    }

    /**
     * 尝试获取锁 超时返回
     * @return
     */
    public boolean tryLock() {
        boolean result = setRedisLock(lockKey,expireTime);
        long waitMax = timeOut;
        long waitAlready = 0;
        /**
         * 获取失败,重试
         */
        while (!result && waitAlready < waitMax ){
            try {
                Thread.sleep(WAIT_MILLI_SPER);
                waitAlready += WAIT_MILLI_SPER;
            }catch (Exception e){
                result = false;
            }
            result = setRedisLock(lockKey,expireTime);
        }
        return result;
    }

    /**
     * 设置锁
     * @param key        锁的key(Redis的Key)
     * @param expire     锁的过期时间(单位:秒)
     * @return
     */
    private boolean setRedisLock(String key, long expire) {
        try {
            RedisCallback<Boolean> callback = (connection) -> {
                // 生成随机key
                lockValue = UUID.randomUUID().toString();
                return connection.set(key.getBytes(), lockValue.getBytes(), Expiration.seconds(expire), RedisStringCommands.SetOption.SET_IF_ABSENT);
            };
            return redisTemplate.execute(callback);
        }catch (Exception e){
            log.error("set redis error", e);
            return false;
        }
    }

    /**
     * 解锁
     */
    public Boolean unlock() {
        RedisCallback<Boolean> callback = (connection) -> connection.eval(UNLOCK_LUA.getBytes(), ReturnType.BOOLEAN ,1, lockKey.getBytes(StandardCharsets.UTF_8), lockValue.getBytes(StandardCharsets.UTF_8));
        return redisTemplate.execute(callback);
    }
}

调用代码:

@Autowired
private RedisTemplate redisTemplate;

@Override
public Map<String, List<User>> getUserJson(){
    //从缓存中获取数据
    String jsonString = stringRedisTemplate.opsForValue().get("list");
    //判断是否为空,为空就查库入缓存
    if (StringUtils.isEmpty(jsonString)) {
        Map<String, List<User>> userJsonFromDb = getUserJsonFromDbWithRedisLock();
        return userJsonFromDb;
    }
    log.info("缓存命中,直接返回......");
    Map<String, List<User>> resultMap = JSON.parseObject(jsonString,new TypeReference<Map<String, List<User>>>(){});
    return resultMap;
}

/**
 * 使用分布式锁工具类
 * @return
 */
public Map<String, List<User>> getUserJsonFromDbWithRedisLock() {
    //设置锁标志位
    String lockKey = "lock";
    RedisLock redisLock = new RedisLock(redisTemplate,lockKey,30);
    try {
        //获得锁
        if (redisLock.tryLock()) {
            //拿到锁以后,再次在缓存中确定一次,如果缓存中没有才需要继续查询
            String jsonString = stringRedisTemplate.opsForValue().get("list");
            //缓存不为空,直接返回数据
            if (!StringUtils.isEmpty(jsonString)) {
                Map<String, List<User>> resultMap = JSON.parseObject(jsonString,new TypeReference<Map<String, List<User>>>(){});
                return resultMap;
            }
            log.info("查询了数据库......");
            //查询所有用户
            List<User> list = this.list();
            Map<String, List<User>> listMap = list.stream().collect(Collectors.toMap(k -> k.getUserId().toString(), v -> v));
            //入缓存
            stringRedisTemplate.opsForValue().set("list", JSON.toJSONString(listMap));
            return listMap;
        }
    } finally {
        //解锁
        redisLock.unlock();
    }
    return null;
}

三、分布式锁Redisson

1、概述

Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务。其中包括(BitSet, Set, Multimap, SortedSet, Map, List, Queue, BlockingQueue, Deque, BlockingDeque, Semaphore, Lock, AtomicLong, CountDownLatch, Publish / Subscribe, Bloom filter, Remote service, Spring cache, Executor service, Live Object service, Scheduler service) Redisson提供了使用Redis的最简单和最便捷的方法。Redisson的宗旨是促进使用者对Redis的关注分离(Separation of Concern),从而让使用者能够将精力更集中地放在处理业务逻辑上。
中文文档

2、整合使用

1、导入Maven依赖

<!-- 使用redisson作为分布式锁,分布式对象等功能框架 -->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.12.0</version>
</dependency>

2、Redisson配置类

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.redisson.config.SingleServerConfig;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.io.IOException;

/**
 * @Author: yeyanbin
 * @Date: 2021/1/29
 * Redisson配置类
 */
@Configuration
public class RedissonConfig {
    /**
     * 所有对Redisson的使用都是通过RedissonClient对象
     * @return
     * @throws IOException
     */
    @Bean(destroyMethod="shutdown")
    public RedissonClient redisson() throws IOException {
        Config config = new Config();
        //单节点模式
        SingleServerConfig singleServerConfig = config.useSingleServer();
        //设置redis地址,"rediss://"来启用SSL安全连接,"redis://"普通连接
        singleServerConfig.setAddress("redis://8.139.86.156:6379");
        //设置redis密码,用于节点身份验证
        singleServerConfig.setPassword("123456");
        //设置数据库,默认为0
        singleServerConfig.setDatabase(10);
        return Redisson.create(config);
    }
}

3、可重入锁(Reentrant Lock)

基于 Redis 的 Redisson 分布式可重入锁RLock,Java对象实现了java.util.concurrent.locks.Lock接口。同时还提供了 异步(Async)反射式(Reactive)RxJava2标准的接口。

Redisson - Lock 锁测试(不带超时时间):

@Slf4j
@RestController
public class WebController {
    @Autowired
    private RedissonClient redissonClient;

    @RequestMapping("/test")
    public String test(){
        //获取锁,只要锁的名字一样就是同一把锁
        RLock lock = redissonClient.getLock("my_lock");
        //加锁,阻塞式等待,默认加的锁都是30s时间
        lock.lock();
        try {
            System.out.println("加锁成功,执行业务...当前线程号:" + Thread.currentThread().getId());
            //休眠15秒,模拟超时
            Thread.sleep(15000);
        } catch (Exception e) {

        } finally {
            System.out.println("解锁成功...当前线程号:" + Thread.currentThread().getId());
            //解锁
            lock.unlock();
        }
        return "test";
    }
}

测试分析:

1、情景一启动测试访问接口:http://localhost:8093/test

在这里插入图片描述

结果:加锁成功
2、情景二启动测试多访问几次接口:http://localhost:8093/test

在这里插入图片描述

在这里插入图片描述

结果:当第一个在执行的时候,第二个请求在一直等待,说明是阻塞式锁,同时查看redis中缓存的信息,发现缓存的时间是30s,运行期间自动给锁续上新的30s。
3、情景三启动测试,复制一个启动类并设置启动端口号,模拟分布式,访问这两个上面的接口,运行期间,停掉其中的一个服务,模拟服务宕机

在这里插入图片描述

结果:当服务一宕机后,服务二接口调用一直等待,当宕机时间到达30s后,服务二的接口获取锁成功,并解锁成功。
结论:
  • 锁会自动续期:如果业务时间超长,运行期间会自动给锁续上新的30s,不用担心业务时间过长,锁自动过期后被释放。
  • 锁自动释放:业务只要执行完成,就不会给当前锁续期,即使不手动解锁,锁默认会在30s以后自动释放。

Redisson - Lock 锁测试(带超时时间):

@Slf4j
@RestController
public class WebController {
    @Autowired
    private RedissonClient redissonClient;

    @RequestMapping("/test")
    public String test(){
        //获取锁,只要锁的名字一样就是同一把锁
        RLock lock = redissonClient.getLock("my_lock");
        //设置超时时间10s,但是得注意自动解锁时间一定要大于业务的时间
        lock.lock(10, TimeUnit.SECONDS);
        try {
            System.out.println("加锁成功,执行业务...当前线程号:" + Thread.currentThread().getId());
            //休眠15秒,模拟超时
            Thread.sleep(15000);
        } catch (Exception e) {

        } finally {
            System.out.println("解锁成功...当前线程号:" + Thread.currentThread().getId());
            //解锁
            lock.unlock();
        }
        return "test";
    }
}

测试分析:

1、启动测试访问接口:http://localhost:8093/test

在这里插入图片描述

结果:设置锁的时间为10s,但是业务执行时间超过10s,当超过10s,锁会自动过期,锁不会自动续期,解锁的时候抛出异常java.lang.IllegalMonitorStateException: attempt to unlock lock, not locked by current thread by node id

下载并进入源码分析原因:

1、点击lock进入RedissonLocklock方法的实现

在这里插入图片描述

2、再次进入lock方法,发现调用了tryAcquire方法

在这里插入图片描述

3、进入tryAcquire方法获取锁,发现里面调用tryAcquireAsync方法,再进入tryAcquireAsync方法

在这里插入图片描述

在这里插入图片描述

4、未设置超时时间,调用的lock方法会给leaseTime设置默认值-1

在这里插入图片描述

5、由于设置了超时时间,执行步骤3中条件判断不等于-1的代码中,发现调用了tryLockInnerAsync方法,进入tryLockInnerAsync

在这里插入图片描述

6、如果没有设置超时时间,就会跳过步骤3中条件判断不等于-1代码逻辑,向下执行代码

在这里插入图片描述

7、执行tryLockInnerAsync占锁,另外通过onComplete方法进行监听,如果发生异常,也就是e!=null直接返回,没有就会调用scheduleExpirationRenewal方法,根据方法名知道这个与定时任务有关,进入这个调度方法

在这里插入图片描述

8、进入renewExpiration方法

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

总结:
  • 如果设置了锁的超时时间,就发送给 redis 执行脚本,进行占锁,默认超时就是我们指定的时间
  • 如果未设置锁的超时时间,就是用 30 * 1000 LockWatchchdogTimeout看门狗的默认时间,只要占锁成功,就会启动一个定时任务,【重新给锁设置过期时间,新的过期时间就是看门狗的默认时间】,每隔10s就自动续期
  • 推荐使用设置锁的超时时间,可以省掉整个续期操作,手动释放锁就行,即使真的出现问题,锁也会在到达设定的时间释放

4、读写锁(ReadWriteLock)

基于 Redis 的 Redisson 分布式可重入读写锁RReadWriteLock,Java对象实现了java.util.concurrent.locks.ReadWriteLock接口。其中读锁和写锁都继承了RLock接口。分布式可重入读写锁允许同时有多个读锁和一个写锁处于加锁状态。
我们都知道,如果负责储存这个分布式锁的 Redis 节点宕机以后,而且这个锁正好处于锁住的状态时,这个锁会出现锁死的状态。为了避免这种情况的发生,Redisson 内部提供了一个监控锁的看门狗,它的作用是在 Redisson 实例被关闭前,不断的延长锁的有效期。默认情况下,看门狗的检查锁的超时时间是30秒钟,也可以通过修改Config.lockWatchdogTimeout来另行指定。另外 Redisson 还通过加锁的方法提供了leaseTime的参数来指定加锁的时间。超过这个时间后锁便自动解开了。

读写锁示例:

@Slf4j
@RestController
public class WebController {
    @Autowired
    private RedissonClient redissonClient;
    
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @ResponseBody
    @RequestMapping("/write")
    public String writeLock(){
        //获取读写锁对象
        RReadWriteLock lock = redissonClient.getReadWriteLock("rw-lock");
        String s = "";
        //获得写锁
        RLock rLock = lock.writeLock();
        try {
            //改数据加写锁
            rLock.lock();
            s = UUID.randomUUID().toString();
            Thread.sleep(30000);
            stringRedisTemplate.opsForValue().set("writeValue",s);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            //解锁
            rLock.unlock();
        }
        return s;
    }

    @ResponseBody
    @RequestMapping("/read")
    public String readLock(){
        //获取读写锁对象
        RReadWriteLock lock = redissonClient.getReadWriteLock("rw-lock");
        String s = "";
        //获得读锁
        RLock rLock = lock.readLock();
        try {
            //读数据加读锁
            rLock.lock();
            s = stringRedisTemplate.opsForValue().get("writeValue");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            //解锁
            rLock.unlock();
        }
        return s;
    }
}

测试分析:

1、启动测试,并且向redis中存一个数据writeValue="hello"
2、访问读数据接口http://localhost:8093/read,发现能正确读出数据
3、当访问写数据接口http://localhost:8093/write,再访问读数据接口,发现读数据接口一直在等待,这是因为写数据接口中有一个休眠30s,只有当写数据业务都完成,读数据接口才会返回数据。并且 redis 中有一个写锁占位。

在这里插入图片描述

几种模式:
  • 读 + 读模式:相当于无锁,并发读,只会在redis中记录好所有当前的读锁,他们都会同时加锁成功。
  • 写 + 读模式:需要等待写锁释放。
  • 写 + 写模式:阻塞方式,需要等待上一个写锁释放,才能进行写操作。
  • 读 + 写模式:有读锁,写锁也必须等待。
总结:
  • 读写锁保证一定能读到最新数据,修改期间,写锁是一个排它锁(互斥锁、独享锁)。读锁是一个共享锁,写锁没有释放读数据就必须等待。
  • 只要有写的存在,都必须等待。

5、信号量(Semaphore)

基于 Redis 的 Redisson 的分布式信号量(Semaphore)Java对象RSemaphore采用了与java.util.concurrent.Semaphore相似的接口和用法。同时还提供了异步(Async)反射式(Reactive)RxJava2标准的接口。

信号量示例:

@Slf4j
@RestController
public class WebController {
    @Autowired
    private RedissonClient redissonClient;
    
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @ResponseBody
    @RequestMapping("/reduce")
    public String reduce() throws InterruptedException {
        //获取一个信号量对象
        RSemaphore count = redissonClient.getSemaphore("count");
        //占位,传人几个表示占几个位,默认是一个
        count.acquire(3);
        //尝试占位,成功true,失败false
        // boolean b = count.tryAcquire();
        return "ok";
    }

    @ResponseBody
    @RequestMapping("/add")
    public String add() throws InterruptedException {
        //获取一个信号量对象
        RSemaphore count = redissonClient.getSemaphore("count");
        //释放占位,默认释放一个,传入几个就释放几个
        count.release(2);
        return "ok";
    }
}

测试分析:

1、启动测试,并且向redis中存一个数据count=10
2、访问reduce接口,发现rediscount值减少了3,表示占位成功。
3、访问add接口,发现rediscount值增加了2,表示释放成功。

6、闭锁(CountDownLatch)

基于Redisson的Redisson分布式闭锁(CountDownLatch)Java对象RCountDownLatch采用了与java.util.concurrent.CountDownLatch相似的接口和用法。

闭锁示例:

@Slf4j
@RestController
public class WebController {
    @Autowired
    private RedissonClient redissonClient;
    
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    @ResponseBody
    @RequestMapping("/lockDoor")
    public String lockDoor() throws InterruptedException {
        //获取闭锁
        RCountDownLatch door = redissonClient.getCountDownLatch("door");
        //设置总数
        door.trySetCount(5);
        //等待闭锁都完成
        door.await();
        return "锁门成功...";
    }

    @ResponseBody
    @RequestMapping("/go")
    public String go() throws InterruptedException {
        //获取闭锁
        RCountDownLatch door = redissonClient.getCountDownLatch("door");
        //计数减一
        door.countDown();
        return "下班了...";
    }
}

测试分析:

1、启动测试,调用lockDoor接口发现一直等待,查看redis中的值为5
2、连续调用5go接口,发现lcokDoor接口立马返回锁门成功...

7、缓存数据一致性问题

1、双写模式:更新完数据库,同时写入缓存

在这里插入图片描述

分析:写完数据库再写缓存,假设有两个并发进来了,被负载到两台机器,线程A先执行了,将数据库中数据修改了,但是由于各种原因出现了延迟卡顿,这时线程B修改了数据库,同时修改了缓存,这时线程A又继续执行了写缓存的操作,这样就会造成数据不一致问题,出现了短暂性脏数据问题。
解决:将写数据操作和写缓存操作放到锁里面执行。
2、失效模式:当更新完数据库后,删除相关的缓存

在这里插入图片描述

分析:数据更新修改数据库,然后删缓存,假设有三个并发进来了,被负载到三台机器,线程A将数据中数据更改,然后删除缓存后,线程B进来后将数据也修改了,但是由于机器负载比较重,处理能力比较慢,出现延迟卡顿等问题,这时线程C读缓存发现没有数据,就去读数据库(读到的是线程A修改后的数据即老数据),这时如果线程C出现卡顿,刚好线程B执行了删缓存操作,然后线程C又更新了缓存(此时缓存中存放的数据是线程A修改后的数据即老数据),如果线程C没有出现卡顿,在线程B执行删缓存操作之前执行了就更新了数据,虽然是老数据,但是线程B会执行删缓存操作,缓存中就没有数据就不会出现脏数据问题。
解决:可以加锁来解决
缺点:虽然都能通过加锁的方式来解决,但是加锁以后系统就会显得笨重,但是如果是经常修改的数据,去加锁的话,就会变得非常的慢。

8、缓存数据一致性解决方案

无论是双写模式还是失效模式,都会到这缓存不一致的问题,即多个实例同时更新会出事,怎么办?
1、如果是用户维度数据(订单数据、用户数据),这并发几率很小,几乎不用考虑这个问题,缓存数据加上过期时间,每隔一段时间触发读的主动更新即可。
2、如果是一些基础数据,也可以不用考虑这个问题,如果考虑数据问题可以去使用 canal 订阅,binlog 的方式。Canel是阿里的一个开源中间件,可以模拟成数据库的从服务器,数据库中有数据更新,Canel就会同步更新数据,然后更新redis中数据。

在这里插入图片描述

3、缓存数据 + 过期时间也足够解决大部分业务对缓存的要求
4、通过加锁保证并发读写,写 + 写的时候按照顺序排好队,读 + 读无所谓,所以适合读写锁,(业务不关心脏数据,允许临时脏数据可忽略)。
总结:
1、能放入缓存的数据本来就不应该是实时性、一致性要求超高的。所以缓存数据的时候加上过期时间,保证每天拿到当前的最新值即可。
2、不应该过度设计,增加系统的复杂性。
3、遇到实时性、一致性要求高的数据,就应该查数据库,即使慢点也无所谓。

四、SpringCache整合Redis

1、说明

关于SpringCache的介绍请参考《SpringBoot与缓存》本篇是对《SpringBoot》的一个补充。

2、整合SpringCache简化缓存开发

1、导入Maven依赖

<!--redis-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!--redis依赖commons-pool-->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>
<!--使用SpringCache-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

2、配置yml

spring:
  # 配置使用redis作为缓存
  cache:
    type: redis
    redis:
      # 指定缓存数据的存活时间(毫秒)
      time-to-live: 6000
      # 是否使用缓存前缀
      use-key-prefix: true
      # 设置缓存key的前缀,如果没有设置就默认使用缓存的名字作为前缀
      key-prefix: CACHE_
      # 是否缓存空值,防止缓存穿透
      cache-null-values: true

3、启动类上加注解@EnableCaching开启基于注解的缓存

4、配置类

import org.springframework.boot.autoconfigure.cache.CacheProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;

/**
 * @Date: 2021/1/31
 */
@Configuration
//开启基于注解的缓存
@EnableCaching
//让CacheProperties类的绑定生效
@EnableConfigurationProperties(CacheProperties.class)
public class CacheRedisConfig {
    /**
     * 配置文件上的设置没有生效的原因:
     * 1、原来和配置文件绑定的配置类是这样子的
     *      @ConfigurationProperties(prefix = "Spring.cache")
     * 2、设置生效
     *      @EnableConfigurationProperties(CacheProperties.class)
     * @param cacheProperties
     * @return
     */
    @Bean
    RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties){
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
        // 设置key的序列化
        config = config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()));
        // 设置value序列化 ->JackSon
        config = config.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
        //获取所有redis的配置
        CacheProperties.Redis redisProperties = cacheProperties.getRedis();
        if (redisProperties.getTimeToLive() != null) {
            config = config.entryTtl(redisProperties.getTimeToLive());
        }
        if (redisProperties.getKeyPrefix() != null) {
            config = config.prefixKeysWith(redisProperties.getKeyPrefix());
        }
        if (!redisProperties.isCacheNullValues()) {
            config = config.disableCachingNullValues();
        }
        if (!redisProperties.isUseKeyPrefix()) {
            config = config.disableKeyPrefix();
        }
        return config;
    }
}

5、使用请参考《SpringBoot与缓存》

3、不足之处

1、读模式:
  • 缓存穿透:查询一个null数据,解决:缓存空数据:配置cache-null-values=true
  • 缓存击穿:大量并发进来同时查询一个正好过期的数据,解决:加锁,默认是无加锁,可以使用sync=true控制并发读
  • 缓存雪崩:大量的key同时过期,解决:加上随机时间,配置time-to-live: 6000
2、写模式:
  • 读写加锁
  • 引入canal中间件,感知到MySQL的更新去更新数据库
  • 读多写多,直接去数据库查询就行
3、总结:
  • 常规数据(读多写少,即时性,一致性要求不高的数据)完全可以使用SpringCache写模式( 只要缓存数据有过期时间就足够)
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值