Redis总结(二)

Redis总结(二)

本总结以黑马的视频为主要参考来源,请以视频为主

缓存穿透

如果客户端查找的数据,数据库和Redis中都没有,这时候如果一直请求,Redis缓存未命中,请求全会到数据库,这会对数据库造成巨大压力。解决办法是:

  • null值,在 Redis中存入 NULL,再来访问时,直接返回,就不用访问数据库了
  • 布隆过滤器

缓存雪崩

如果大量的key同时过期,这时候所有的请求打到数据库,造成巨大压力

  • 对不同的 key设置不同的过期时间

缓存击穿

热点key突然失效,大量的请求到数据库

  • 互斥锁( Redissetnx就是互斥的)
  • 逻辑过期

逻辑分析:假设线程1在查询缓存之后,本来应该去查询数据库,然后把这个数据重新加载到缓存的,此时只要线程1走完这个逻辑,其他线程就都能从缓存中加载这些数据了,但是假设在线程1没有走完的时候,后续的线程2,线程3,线程4同时过来访问当前这个方法, 那么这些线程都不能从缓存中查询到数据,那么他们就会同一时刻来访问查询缓存,都没查到,接着同一时间去访问数据库,同时的去执行数据库代码,对数据库访问压力过大

解决缓存击穿和缓存穿透的代码

public class CacheClient {
    private final StringRedisTemplate stringRedisTemplate;

    public CacheClient(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    /**
     * 将key value 保存起来, 并设置ttl
     * @param key key
     * @param value value
     * @param time 时间
     * @param unit 单位
     */

    public void set(String key, Object value, Long time, TimeUnit unit) {
        stringRedisTemplate.opsForValue()
                .set(key, JSONUtil.toJsonStr(value), time, unit);
    }

    /**
     * 逻辑过期的缓存
     * @param key key
     * @param value value
     * @param time 逻辑过期的时间
     * @param unit 单位
     */

    public void setLogicalExpire(String key, Object value, Long time, TimeUnit unit) {
        //将value 对象封装为有逻辑过期时间的对象
        RedisData redisData = new RedisData();
        redisData.setData(value);
        redisData.setExpireTime(
                LocalDateTime.now().plusSeconds(unit.toSeconds(time)));
        //加入redis
        stringRedisTemplate.opsForValue()
                .set(key, JSONUtil.toJsonStr(redisData));
    }

    /**
     *
     * @param prefix key 的前缀
     * @param id id , 由于不知道这个id的类型, 所以用泛型
     * @param type 操作对象的类型
     * @param dbFallBack 查数据库的操作
     * @param <T> type
     * @param <K> id type
     * @param time 过期时间
     * @param unit 单位
     * @return result
     */

    public<T, K> T queryWithPassThrough(String prefix, K id, Class<T> type,
                                        Function<K, T> dbFallBack,
                                        Long time, TimeUnit unit)
 
{
        //查缓存
        String key = prefix + id;
        String cacheJson = stringRedisTemplate.opsForValue().get(key);
        //redis 存在, 直接返回
        if (StrUtil.isNotBlank(cacheJson)) {
            return JSONUtil.toBean(cacheJson, type);
        }
        //redis 不存在
        //是不是空值, 解决缓存穿透
        //不是空值, 那就是redis加入的空字符串, 不用查数据库了, 这条数据不存在
        if(cacheJson != null) {
            //直接返回空
            return null;
        }
        //是空值, 查数据库
        //T result = getById(id);
        //这个地方不能直接查数据, 因为不知道查哪个表, 所以交给调用者, 想到可以用函数式编程
        //function可以解决
        T reslut = dbFallBack.apply(id);

        //数据库没查到, 没有这条数据
        if(reslut == null) {
            //加入redis, 解决缓存穿透
            stringRedisTemplate.opsForValue().set(key, "",CACHE_NULL_TTL, TimeUnit.MINUTES );
            return null;
        }

        //result != null 数据存在, 加入redis
       this.set(key, reslut, time, unit);
        return reslut;
    }


    /**
     * 线程池
     */

    private static final ExecutorService CACHE_REBUILD_EXECUTOR = Executors.newFixedThreadPool(10);

    /**
     *
     * @param prefix key 的前缀
     * @param id id 的类型不清楚, 所以用泛型
     * @param type 操作对象的类型
     * @param dbFallback 操作数据库
     * @param time 逻辑过期时间
     * @param unit 单位
     * @return
     * @param <T> Object type
     * @param <K> id type
     */

    public<T, K> T queryWithLogicalExpire(String prefix, K id, Class<T> type,
                                          Function<K, T> dbFallback,
                                          Long time, TimeUnit unit)
{
        //1.从redis中查
        //redis 的key是prefix + id
        String key = prefix + id;
        String shopJson = stringRedisTemplate.opsForValue().get(key);
        //2.不存在,直接返回
        if (StrUtil.isBlank(shopJson)) {
            return null;
        }

        //存在, 判断是否过期
        //这时候需要反序列化json对象
        RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
        //
        T reslut = JSONUtil.toBean((JSONObject) redisData.getData(), type);
        //逻辑过期时间
        LocalDateTime expireTime = redisData.getExpireTime();
        //未过期, 返回
        //expireTime 在当前时间之后, 说明没有过期
        if (expireTime.isAfter(LocalDateTime.now())) {
            return reslut;
        }
        //过期, 缓存重建
        //获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(key);
        //成功, 开启独立线程, 去缓存重建
        if (isLock) {
            CACHE_REBUILD_EXECUTOR.submit(() -> {
                try {
                    //重建缓存,
                    //1. 查数据库
                    T r = dbFallback.apply(id);
                    //2. 加入缓存
                    this.setLogicalExpire(key, r, time, unit);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    //释放锁
                    unLock(lockKey);
                }
            });

        }
        //如果没有成功获取锁, 返回的还是旧的
        return reslut;
    }


    /**
     * 得到互斥锁
     * setnx操作,
     * @param key key
     * @return 得到时, 返回true
     */

    private boolean tryLock(String key) {
        // 尝试修改这个key的value, 如果没有key会修改成功, 如果有key, 不会修改
        //也就是互斥锁
        //后边的为有效期和单位
        Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1"10, TimeUnit.SECONDS);
        //不要直接返回flag. 可能出现nullPoint
        //直接返回flag时要解封装, 所以可能出现nullPoint
        return BooleanUtil.isTrue(flag);
    }

    /**
     * 释放锁
     * @param key lockKey
     */

    private void unLock(String key) {
        stringRedisTemplate.delete(key);
    }
}

秒杀相关 ——乐观锁实现

乐观锁在修改时会看是否和查到的版本号相等

select version from product where id = ${id};
update table set stock = stock - 1 , 
version = version + 1 where id = ${id} and version = version;

但是在这里我么可以优化,直接看剩余量不就行了吗?

update table set stock = stock - 1 where id = ${id} and stock = stock;

还可以简洁一点,就是直接判断剩余量大于0就行

update table set stock = stock - 1 where id = ${id} and stock > 0;

一人一单

逻辑就是查订单号和userId看有没有,如果有记录,说明已经买过了,不可以重新购买。

这个是insert逻辑,所以用悲观锁更合适

@Synchronized就是悲观锁

问题

不能解决集群问题

分布式锁(Redis)

其实就是setnx

获取锁

   private boolean tryLock(String key) {
        // 尝试修改这个key的value, 如果没有key会修改成功, 如果有key, 不会修改
        //也就是互斥锁
        //后边的为有效期和单位
        Boolean flag = stringRedisTemplate.opsForValue()
            .setIfAbsent(key, "1"10, TimeUnit.SECONDS);
        //不要直接返回flag. 可能出现nullPoint
        //直接返回flag时要解封装, 所以可能出现nullPoint
        return BooleanUtil.isTrue(flag);
    }

释放锁

   /** 
     * 释放锁
     * @param key lockKey
     */

    private void unLock(String key) {
        stringRedisTemplate.delete(key);
    }

问题:超时误删

线程1,获取锁成功,但是在执行业务时阻塞,导致锁超时释放;这时候线程2获取锁成功,线程1也活了,它把线程2的锁给删了。

alt

解决:可以把setnxvalue写为线程ID,释放锁的时候可以判断一下是不是当前线程。

// 获取锁
public boolean tryLock(Long timeoutSec) {
        //将线程的id加前缀作为value加入redis
        String value = ID_PREFIX + Thread.currentThread().getId();
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(KEY_PREFIX + name, value, timeoutSec, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success);
    }

public void unlock() {
        //释放锁
        String key = KEY_PREFIX + name;
        String cacheValue = stringRedisTemplate.opsForValue().get(key);
        long id = Thread.currentThread().getId();
        String curValue = ID_PREFIX + id;
        if (curValue.equals(cacheValue)) {
            stringRedisTemplate.delete(KEY_PREFIX + name);
        }
    }

问题:原子性

线程1获取锁成功,执行业务,判断也成功了,释放锁的时候阻塞了。然后超时释放了;这时候线程2获取锁成功,然后线程1阻塞回来,把线程2的锁给删了。

alt

解决:说明释放锁的时候要有原子性,Lua脚本

-- 锁的key
local key = KEYS[1]
-- 线程的标识
local threadId = ARGV[1]
-- 获取锁的线程id
local id = redis.call('get',key)
-- 比较,相同的话直接删掉
if(id == threadId) then
 return redis.call('del', key)
end

return 0
   /**
     * 加载lua脚本
     */

    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
    static {
        UNLOCK_SCRIPT = new DefaultRedisScript<>();
        UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
        UNLOCK_SCRIPT.setResultType(Long.class);
    }
    public void unlock() {
        // 调用Lua脚本释放锁
        // 以前释放锁是判断加释放锁分开执行,
        // 可能会误删, 现在是一行代码, 一块执行, 释放锁的过程不会阻塞
        stringRedisTemplate.execute(UNLOCK_SCRIPT,
                Collections.singletonList(KEY_PREFIX + name),
                ID_PREFIX + Thread.currentThread().getId());

    }

:clown_face:

目前还存在的问题

  1. 可重入性

    同一个线程应该可以一直获取锁才对,但是目前是不可以的。

    如果业务1的代码需要获取锁,而且调用了业务2,业务2也需要获取锁(明显是同一个线程),这时候,业务2要等到业务1释放锁,业务1也在等业务2获取锁。这就导致了死锁。

  2. 可重试性

    如果一个线程获取锁失败,当前点代码是没办法重新尝试获取的。

  3. 超时释放

    如果一个业务需要一段时间,不应该让他的锁释放。

  4. 主从一致: 如果Redis提供了主从集群,当我们向集群写数据时,主机需要异步的将数据同步给从机,而万一在同步过去之前,主机宕机了,就会出现死锁问题。

Redission

上述问题Redission都解决了

配置

@Configuration
public class RedissonConfig {
    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        //单节点模式
        config.useSingleServer().setAddress("redis://localhost:port")
                .setPassword("password");
        return Redisson.create(config);
    }
}

获取锁

@Resource
private RedissionClient redissonClient;

void testRedisson() throws Exception{
    //获取锁(可重入),指定锁的名称
    RLock lock = redissonClient.getLock("anyLock");
    //尝试获取锁,参数分别是:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
    boolean isLock = lock.tryLock(1,10,TimeUnit.SECONDS);
    //判断获取锁成功
    if(isLock){
        try{
            System.out.println("执行业务");          
        }finally{
            //释放锁
            lock.unlock();
        }

    }
}

底层实现,写死的Lua脚本

KEYS[1] : 锁名称

ARGV[1]: 锁失效时间

**ARGV[2]**: id + ":" + threadId; 锁的小key

return evalWriteAsync(getName(), LongCodec.INSTANCE, command,
                "if (redis.call('exists', KEYS[1]) == 0) then " + // 是否存在key
                        "redis.call('hincrby', KEYS[1], ARGV[2], 1); " + //不存在, 创建&value 自增 1
                        "redis.call('pexpire', KEYS[1], ARGV[1]); " +// 重置有效期
                        "return nil; " +
                        "end; " +
                        "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +// 是不是第二次来
                        "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +//是, value + 1
                        "redis.call('pexpire', KEYS[1], ARGV[1]); " + // 重置有效期
                        "return nil; " +
                        "end; " +
                        "return redis.call('pttl', KEYS[1]);"// 没有获取成功

释放锁

ARGV[2] = 锁失效时间

**ARGV[3]**: id + ":" + threadId; 锁的小key

"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " + //是否存在
                        "return nil;" +
                        "end; " +
                        "local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
                        "if (counter > 0) then " +
                        "redis.call('pexpire', KEYS[1], ARGV[2]); " +
                        "return 0; " +
                        "else " +
                        "redis.call('del', KEYS[1]); " +
                        "redis.call('publish', KEYS[2], ARGV[1]); " +
                        "return 1; " +
                        "end; " +
                        "return nil;",
  1. 可重入性

    Redission是用Hsah来存的

    key

    filed ThreadId

    value 该线程获取锁的次数

  2. 可重试性

    开始追源码 :nerd_face:

    watchdog机制,如果没有传的话,默认是30s,建议不要传,后边会说为什么不建议

    RedissionLock class

    alt

    如果没有传leaseTime的话会有一个默认的值,是30s

    继续进入方法,就是获取锁的具体实现

    alt

    从上边可以看到如果获取成功返回的是null,如果失败是有值的

    alt

    上边可以看到,如果ttl是null,说明获取成功了,直接返回true

    current是进入获取锁之前的时间,time是传入的等待时间。

    那么上边的代码的意思就是减去第一次获取消耗的时间,如果剩余等待时间变负数了,直接返回false

    current继续赋值为当前时间


    下边的意思释放锁的时候有publish消息,我们这个地方可订阅

alt

然后又是看剩余等待时间用完了没,

没有的话去拿锁,tll = null说明拿锁成功了,否则就继续判断剩余等待时间

alt

​ 看下图,如果ttl < time,说明不需要等待time,ttl时超时释放的时间。 alt

  1. 超时释放

    获取锁成功后,会调用一个scheduleExpirationRenewal方法

alt

ConcurrentHashMap

如果有当前的entryName就不会加入,返回的是旧的entry

如果没有当前的entryName就会加入,返回的是null

也就是说这个返回的一直是第一个entry

如果oldEntry != NULL说明是第二次来

如果oldEntry == NULL说明是第一次来

都会把线程id加入entry

oldEntry == NULL调用renewExpiration重置有效时间。

alt

getName()返回的是id + namename是我们传入的锁的名字 alt alt

alt
alt

每隔internalLockLeaseTime / 3的时间刷新一次,这个时间是看门狗,默认30s

alt

释放锁的时候会调用cancelExpirationRenewal

alt

删除entry

alt
  1. 主从一致

    调用的是RedissonMultiLock

    如果有三台Redis服务器,会同时向这三个服务器申请锁,就算有一个(或者可以设置最大限制)没有申请成功,就不会获得锁。

关注一下公众号呗!!! alt

本文由 mdnice 多平台发布

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值