Redis专题

redis的缓存策略

主动更新有三种模式

1.旁路缓存模式(使用较多)

2.读写穿透

3.异步缓存写入

操作缓存和数据库时有三个问题需要考虑:

1.删除缓存还是更新缓存?
        更新缓存:每次更新数据库都更新缓存,无效写操作较多

        删除缓存:更新数据库时让缓存失效,查询时再更新缓存(优选)

2.如何保证缓存与数据库的操作的同时成功或失败?

        单体系统,将缓存与数据库操作放在一个事务

        分布式系统,利用TCC等分布式事务方案

3.先操作缓存还是先操作数据库?
        先删除缓存,再操作数据库

        先操作数据库,再删除缓存 

由于redis的读写操作时间是远小于数据库的读写时间,所以相较而言使用方案二更好。但是两者都会有出现缓存和数据库数据不一致的情况,但可以通过设置TTL来进行优化。

总结:

缓存更新策略的最佳实践方案:
1.低一致性需求:使用Redis自带的内存淘汰机制

2.高一致性需求:主动更新,并以超时剔除作为兜底方案
        读操作:
                缓存命中则直接返回
                缓存未命中则查询数据库,并写入缓存,设定超时时间
         写操作:
                先写数据库,然后再删除缓存
                要确保数据库与缓存操作的原子性

缓存穿透

缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。

常见的解决方案有两种 :

        缓存空对象
                优点:实现简单,维护方便

                缺点:额外的内存消耗可能造成短期的不一致
        布隆过滤
                优点:内存占用较少,没有多余的key

                缺点: 实现复杂存在误判可能

下面是一个实例

 public Result queryById(Long id) {
        //1.根据id从redis中查询数据
        String key = CACHE_SHOP_KEY + id;
        String shopJson = stringRedisTemplate.opsForValue().get(key);

        //2.判断是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            //3.存在 直接返回
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return Result.ok(shop);
        }

        if(shopJson!=null){
            return Result.fail("无此数据!");
        }
        //4.不存在 查询数据库
        Shop shop = getById(id);
        if (shop == null) {
            stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);
            return Result.fail("无此数据!");
        }

        //5.存在 写入redis
        stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
        return Result.ok(shop);
    }

 当检查到该数据库中不存在该数据时,为它缓存一个空对象stringRedisTemplate.opsForValue().set(key,"",CACHE_NULL_TTL,TimeUnit.MINUTES);

防止被请求多次被直接打到数据库。

总结

当然像布隆过滤和缓存空置都是一些被动防御的手段,我们也可以采取一些主动的措施:
在缓存null值,布隆过滤的基础上增强id的复杂度,避免被猜测id规律,做好数据的基础格式校验,加强用户权限校验,做好热点数据的限流。

缓存雪崩

缓存雪崩是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。

解决方案:
        给不同的Key的TTL添加随机值

        利用Redis集群提高服务的可用性

        给缓存业务添加降级限流策略

        给业务添加多级缓存

缓存击穿

缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。

常见的解决方案有两种:
        互斥锁

                优点:没有额外的内存消耗,保证一致性,实现简单

                缺点:线程需要等待,性能受影响,可能有死锁风险
        逻辑过期

                优点:线程无需等待,性能较好

                缺点:不保证一致性,有额外内存消耗,实现复杂

下面是一个实例

互斥锁实现
 public Shop queryWithPassThrough(Long id) {
       //1.根据id从redis中查询数据
        String key = CACHE_SHOP_KEY + id;
        String shopJson = stringRedisTemplate.opsForValue().get(key);

        //2.判断是否存在
        if (StrUtil.isNotBlank(shopJson)) {
            //3.存在 直接返回
            Shop shop = JSONUtil.toBean(shopJson, Shop.class);
            return shop;
        }

        if (shopJson != null) {
            return null;
        }

        //4.实现缓存重建
        //4.1.获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        Shop shop = null;
        try {
            boolean isLock = tryLock(lockKey);

            //4.2.判断是否获取成功
            if (isLock) {
                //4.3.失败 则休眠并重试
                Thread.sleep(50);
                return queryWithMutex(id);
            }
            //4.4 成功 根据id查询数据库
            shop = getById(id);

            //5.不存在 查询数据库
            if (shop == null) {
                stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES);
                return null;
            }

            //6.存在 写入redis
            stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            //7.释放互斥锁
            unLock(lockKey);
        }

        return shop;
    }
设置逻辑过期时间实现
public Shop queryWithLogicalExpire(Long id) {
        //1.根据id从redis中查询数据
        String key = CACHE_SHOP_KEY + id;
        String shopJson = stringRedisTemplate.opsForValue().get(key);

        //2.判断缓存是否存在
        if (StrUtil.isBlank(shopJson)) {
            //3.不存在 直接返回空
            return null;
        }

        //4.命中,需要先把json反序列化为对象
        RedisData redisData = JSONUtil.toBean(shopJson, RedisData.class);
        Shop shop = JSONUtil.toBean((JSONObject) redisData.getData(), Shop.class);
        LocalDateTime expireTime = redisData.getExpireTime();

        //5.判断是否过期
        if (expireTime.isAfter(LocalDateTime.now())) {
            // 5.1.未过期,直接返回店铺信息
            return shop;
        }

        // 5.2.已过期,需要缓存重建
        // 6.缓存重建
        // 6.1.获取互斥锁
        String lockKey = LOCK_SHOP_KEY + id;
        boolean isLock = tryLock(key);
        //6.2.判断是否获取锁成功
        if (isLock) {
            //6.3.成功,开启独立线程,实现缓存重建
            CACHE_REBUILD_EXECUTOR.submit(()->{
                try {
                    //重建缓存
                    this.saveShop2Redis(id,20L);
                } catch (Exception e) {
                    throw new RuntimeException(e);
                } finally {
                    //释放锁
                    unLock(lockKey);
                }
            });
        }
        // 6.4.返回过期的商铺信息
        return shop;
    }

分布式锁

在多线程环境中,如果多个线程同时访问共享资源(例如商品库存、外卖订单),会发生数据竞争,可能会导致出现脏数据或者系统问题,威胁到程序的正常运行

举个例子,假设现在有 100 个用户参与某个限时秒杀活动,每位用户限购 1 件商品,且商品的数量只有 3 个。如果不对共享资源进行互斥访问,就可能出现以下情况:

  • 线程 1、2、3 等多个线程同时进入抢购方法,每一个线程对应一个用户。
  • 线程 1 查询用户已经抢购的数量,发现当前用户尚未抢购且商品库存还有 1 个,因此认为可以继续执行抢购流程。
  • 线程 2 也执行查询用户已经抢购的数量,发现当前用户尚未抢购且商品库存还有 1 个,因此认为可以继续执行抢购流程。
  •  线程 1 继续执行,将库存数量减少 1 个,然后返回成功。
  •   线程 2 继续执行,将库存数量减少 1 个,然后返回成功。此时就发生了超卖问题,导致商品被多卖了一份。

这时候我们所谓的超卖就出现了,数据库的数据可能就出现了负数的情况。由于JDK自带的本地锁(ReentrantLock、synchronized)只能针对一个JVM进程内的多线程来实现加锁行为,而我们的大多数项目都是分布式系统,在这种情况下,JDK自带的加锁方案就无法对我们的想法进行满足。

 如何使用Redis实现分布式锁

无论是本地锁还是分布式锁,其核心都在于“互斥”

在redis中有个关键字“SETNX”(SET IF NOT EXISTS),如果key不存在,即对此key设置value,如果key存在,那什么也不做。

127.0.0.1:6379> SETNX lock 1
(integer 1)
127.0.0.1:6379> SETNX lock 2
(integer 0)
127.0.0.1:6379> DEL lock
(integer 1)

Lua脚本 

当然,为了保证加锁和释放锁的原子性,我们一般使用Lua脚本来对此进行操作,Redis可以完美适配Lua语言。

// 释放锁时,先比较锁对应的 value 值是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

这种事最简单的上放锁操作,同时也伴随一定的安全问题。比如如果某次解锁的逻辑挂掉了,可能就会导致后面的线程都拿不到资源的情况。所有我们需要给锁设置一个过期时间,来防止放锁失败的情况。

127.0.0.1:6379> SET lockKey uniqueValue EX 3 NX
OK

但这样的话,如果过期时间到了,但是我们的逻辑还没走完,导致锁提前释放掉了,同样伴随着问题。如果操作共享资源的时间大于过期时间,就会出现锁提前过期的问题,进而导致分布式锁直接失效。如果锁的超时时间设置过长,又会影响到性能。

Redisson

那有没有一种两全其美的办法呢,答案是有的,对于 Java 开发的小伙伴来说,已经有了现成的解:决方案:Redisson 。其他语言的解决方案,可以在 Redis 官方文档中找到,地址:Redis官方文档

Redisson 中的分布式锁自带自动续期机制,使用起来非常简单,原理也比较简单,其提供了一个专门用来监控和续期锁的 Watch Dog( 看门狗),如果操作共享资源的线程还未执行完成的话,Watch Dog 会不断地延长锁的过期时间,进而保证锁不会因为超时而被释放。

在redisson种有个方法叫做 getLockWatchdogTimeout()的方法,它返回的时间就是看门狗续锁的时间(默认30s)

//默认 30秒,支持修改
private long lockWatchdogTimeout = 30 * 1000;

public Config setLockWatchdogTimeout(long lockWatchdogTimeout) {
    this.lockWatchdogTimeout = lockWatchdogTimeout;
    return this;
}
public long getLockWatchdogTimeout() {
   return lockWatchdogTimeout;
}

下面是它的主要逻辑

private void renewExpiration() {
         //......
        Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
            @Override
            public void run(Timeout timeout) throws Exception {
                //......
                // 异步续期,基于 Lua 脚本
                CompletionStage<Boolean> future = renewExpirationAsync(threadId);
                future.whenComplete((res, e) -> {
                    if (e != null) {
                        // 无法续期
                        log.error("Can't update lock " + getRawName() + " expiration", e);
                        EXPIRATION_RENEWAL_MAP.remove(getEntryName());
                        return;
                    }

                    if (res) {
                        // 递归调用实现续期
                        renewExpiration();
                    } else {
                        // 取消续期
                        cancelExpirationRenewal(null);
                    }
                });
            }
         // 延迟 internalLockLeaseTime/3(默认 10s,也就是 30/3) 再调用
        }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);

        ee.setTimeout(task);
    }

下面是它的异步续期实现方法

protected CompletionStage<Boolean> renewExpirationAsync(long threadId) {
    return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
            // 判断是否为持锁线程,如果是就执行续期操作,就锁的过期时间设置为 30s(默认)
            "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                    "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                    "return 1; " +
                    "end; " +
                    "return 0;",
            Collections.singletonList(getRawName()),
            internalLockLeaseTime, getLockName(threadId));
}

Redisson可重入锁

local key=KEYS[1];--锁的key
local threadId = ARGV[1];--线程唯一标识
local releaseTime = ARGV[2];--锁的自动释放时间
--判断是否存在
if(redis.call('exists',key)== 0) then-不存在,获取锁
    redis.call('hset',key, threadId,"1');--设置有效期
    redis.call('expire', key, releaseTime);
    return 1;--返回结果
end;
--锁已经存在,判断threadId是否是自己
if(redis.call('hexists',key,threadId)== 1) then
    -- 不存在,获取锁,重入次数+1
    redis.call('hincrby', key, threadId,'1');
    --设置有效期
    redis.call('expire', key, releaseTime);
    return 1;--返回结果
end;
return0;--代码走到这里,说明获取锁的不是自己,获取锁失败

消息队列

消息队列(Message Queue),字面意思就是存放消息的队列。最简单的消息队列模型包括3个角色:

  • 消息队列:存储和管理消息,也被称为消息代理(MessageBroker)
  • 生产者:发送消息到消息队列
  • 消费者:从消息队列获取消息并处理消息

Redis提供了三种不同的方式来实现消息队列:

  • list结构:基于List结构模拟消息队列
  • Pubsub:基本的点对点消息模型
  • Stream:比较完善的消息队列模型

基于List结构模拟消息队列

消息队列(Message Queue),字面意思就是存放消息的队列。而Redis的list数据结构是一个双向链表,很容易模拟出队列效果。
队列是入口和出口不在一边,因此我们可以利用:LPUSH 结合 RPOP、或者 RPUSH 结合 LPOP来实现。

//消费者
127.0.0.1:6379> BRPOP l1 20
//生产者
127.0.0.1:6379> LPUSH l1 e1 
(integer) 2
//消费者
127.0.0.1:6379> BRPOP l1 20
1) "l1"
2) "e1"
(10.32s)
127.0.0.1:6379> BRPOP l1 20
1) "l1"
2) "e2"

基于List的消息队列有哪些优缺点?

优点:

  • 利用Redis存储,不受限于JVM内存上限
  • 基于Redis的持久化机制,数据安全性有保证
  • 可以满足消息有序性

缺点:

  • 无法避免消息丢失
  • 只支持单消费者

基于Pubsub的消息队列

Pubsub(发布订阅)是Redis2.0版本引入的消息传递模型。顾名思义,消费者可以订阅一个或多个channel,生产者向对应channel发送消息后,所有订阅者都能收到相关消息。

  • SUBSCRIBE channel[channe]:订阅一个或多个频道
  • PUBLISH channel msg:向一个频道发送消息
  • PSUBSCRlBE pattern[pattern]:订阅与pattern格式匹配的所有频道

 

基于Pubsub的消息队列有哪些优缺点?
优点:

  • 采用发布订阅模型,支持多生产、多消费

缺点:

  • 不支持数据持久化
  • 无法避免消息丢失
  • 消息堆积有上限,超出时数据丢失

  • 16
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值