Redis

redis的删除策略有哪些

Redis的数据删除策略包括定时删除,惰性删除,定期删除

定时删除(时间换空间)

定时删除时给key都设置一个过期的时间,当达到删除时间时,立即对key进行删除

优点:节约内存,到时间执行删除,释放内存空间

缺点:CPU资源占用过高,当其他任务在执行时,会导致俩者同时进行,会影响俩者的效率,redis服务响应时间和指令吞吐收到影响

总结:用处理器的性能换去存储空间(时间换空间)适用范围,小内存,强cpu

惰性删除(空间换时间)

惰性删除的含义是:当删除的数据达到给定的时间时,先不进行删除操作,等下一次访问时,若数据已过期进行删除,客户端返回不存在,数据未过期,则返回数据

​ 优点:CPU的使用率大大降低,减轻其压力;

​ 缺点:内存空间占用率较高,会存在长期占用内存的数据

​ 总结:使用存储空间换取处理器性能(空间换时间),适用范围:大内存,弱CPU

定期删除(折中)

定时删除是对CPU和内存消耗取得一个折中方案,通过每隔一段时间执行一次删除过期key的操作,并且通过限制删除操作执行的时长和频率来减少删除操作对CPU造成的影响,但是限制删除操作执行的时长和频率需要合理地设置,否则可能会退化为成定时删除或惰性删除。

优点:cpu性能占用设置有峰值,检测频度可以自定义设置,内存压力不是很大,长期占用内存的冷数据会被持续清理

内存占用CPU占用特征
定时删除节约内存,无占用不分时段占用CPU资源,频度高时间换空间
惰性删除内存占用严重延时执行,CPU利用率高空间换时间
定期删除内存定期随机清理每秒花费固定的CPU资源维护内存随机抽查,重点抽查

redis默认的删除策略是惰性+定期删除

淘汰策略

淘汰策略名称 策略含义
noeviction 默认策略,不淘汰数据;大部分写命令都将返回错误(DEL等少数除外)
allkeys-lru 从所有数据中根据 LRU 算法挑选数据淘汰(最近最少使用)
volatile-lru 从设置了过期时间的数据中根据 LRU 算法挑选数据淘汰
allkeys-random 从所有数据中随机挑选数据淘汰
volatile-random 从设置了过期时间的数据中随机挑选数据淘汰
volatile-ttl 从设置了过期时间的数据中,挑选越早过期的数据进行删除
allkeys-lfu 从所有数据中根据 LFU 算法挑选数据淘汰(4.0及以上版本可用)
volatile-lfu 从设置了过期时间的数据中根据 LFU 算法挑选数据淘汰(4.0及以上版本可用)

逐出算法

逐出算法:Redis使用内存存储之前,通常会调用freeMemoryIfNeeded()检测内存是否充足。如果内存不满足新加入数据的最低存储要求,redis要临时删除一些数据为当前指令清理存储空间。清理数据的策略称为逐出算法。

然而,逐出过程不是100%清理足够的空间,需反复尝试,当处理完所有数据后依然不够,将出现如下错误信息:


影响数据逐出的相关配置
maxmemory:redis可使用内存占物理内存的最大比例,默认为0,表示不限制redis使用内存。生产环境中根据需求设定,通常设置在50%以上;

maxmemory-samples:每次选取待删除数据的个数,选取数据时并不会全库扫描,导致严重的性能消耗,降低读写性能。因此采用随机获取数据的方式作为待检测删除数据;

maxmemory-policy:达到最大内存后的,对被挑选出来的数据进行上面8种淘汰策略;

redisson

Redisson是一个高级分布式协调Redis客户端,能帮助用户在分布式环境中秦松实现一些java的对象

Redisson,Jedis,Lettuce是三个不同操作的java客户端,Jedis,Lettuce的API更侧重于堆Redis数据库的CURD(增删改查),而Redisson API侧重于分布式开发

Redisson支持,集群模式单例模式,哨兵模式,主从模式

根据Redisson官网的介绍,Redission是一个java客户端,与spring提供给我们的RedisTemplate工具没有本质区别,可以把它看做是一个功能更强大的客户端(虽网上声称Redisson不只是一个Java Redis客户端)

redis如何实现分布式锁

我们用到Redisson最多的场景一定是分布式锁,一个基础的分布式锁具有三个特性

  1. 互斥:在分布式高并发的条件下,需要保证,同一时刻只能有一个线程获得锁,这是最最基本的一点
  2. 防止死锁:在分布式高并发的条件下,比如有个线程获得死锁的同事,还没有来得及去释放锁,就因为系统故障或者其他原因使它无法执行释放锁的命令,导致其他线程都无法获得锁,造成死锁
  3. 可重入:我们知道ReentrantLock是可重入锁,那它的特点就是同一个线程可以重复拿到同一个资源的锁。

普通Redis分布式锁的缺陷

在网上看到的redis分布式锁的工具方法,大都满足,防止死锁的特性,有些工具方法会满足可重入的特性

如果只满足上述三种特性会有哪些隐患呢?redis分布式锁无法自动续期,比如,一个锁设置了一分钟超时释放,如果拿到这个锁的线程在一分钟内没有执行完毕,这个锁就会被其他线程拿到,可能会导致严重的线上问题,比如秒杀系统超卖

Redisson的分布式锁

Redisson锁的加锁机制如上图,线程去获取锁,获取成功则执行lua脚本,保存数据到redis数据库

如果获取失败:一直通过While循环尝试获取锁(可自定义等待时间,超时后返回),获取成功后,执行lua脚本,保存数据库到redis数据库

Redisson提供的分布式锁支持锁自动续期的,也就是说,如果线程仍旧没有执行完,那么redisson会给redis中的目标key延长超时时间,这在Redisson中称之为WatchDog机制.

同时redisson还有公平锁,读写锁的实习

private void redissonDoc() throws InterruptedException {
    //1. 普通的可重入锁
    RLock lock = redissonClient.getLock("generalLock");

    // 拿锁失败时会不停的重试
    // 具有Watch Dog 自动延期机制 默认续30s 每隔30/3=10 秒续到30s
    lock.lock();

    // 尝试拿锁10s后停止重试,返回false
    // 具有Watch Dog 自动延期机制 默认续30s
    boolean res1 = lock.tryLock(10, TimeUnit.SECONDS);

    // 拿锁失败时会不停的重试
    // 没有Watch Dog ,10s后自动释放
    lock.lock(10, TimeUnit.SECONDS);

    // 尝试拿锁100s后停止重试,返回false
    // 没有Watch Dog ,10s后自动释放
    boolean res2 = lock.tryLock(100, 10, TimeUnit.SECONDS);

    //2. 公平锁 保证 Redisson 客户端线程将以其请求的顺序获得锁
    RLock fairLock = redissonClient.getFairLock("fairLock");

    //3. 读写锁 没错与JDK中ReentrantLock的读写锁效果一样
    RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("readWriteLock");
    readWriteLock.readLock().lock();
    readWriteLock.writeLock().lock();
}

watch dog的自动延期机制

如果拿到分布式锁的节点宕机,且这个锁正好处于锁住的状态时,会出现锁死的状态,为了避免这种情况的发生,锁都会设置一个过期时间。这样也存在一个问题,加入一个线程拿到了锁设置了30s超时,在30s后这个线程还没有执行完毕,锁超时释放了,就会导致问题,Redisson给出了自己的答案,就是 watch dog 自动延期机制。

Redisson提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断延长锁的有效期,也就是说,如果一个拿到锁的线程一直没有完成逻辑,那么看门狗会帮助线程不断延长锁超时时间,锁不会因为超时而被释放

默认情况下,看门狗的续期时间是30s,也可以通过修改Config.lockWatchdogTimeout来另行指定。

另外Redisson 还提供了可以指定leaseTime参数的加锁方法来指定加锁的时间。超过这个时间后锁便自动解开了,不会延长锁的有效期。

private void scheduleExpirationRenewal(long threadId) {
    ExpirationEntry entry = new ExpirationEntry();
    //将线程放入缓存中
    ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
    //第二次获得锁后 不会进行延期操作
    if (oldEntry != null) {
        oldEntry.addThreadId(threadId);
    } else {
        entry.addThreadId(threadId);
        
        // 第一次获得锁 延期操作
        renewExpiration();
    }
}

// 进入 renewExpiration()
private void renewExpiration() {
    ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
    //如果缓存不存在,那不再锁续期
    if (ee == null) {
        return;
    }
    
    Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
        @Override
        public void run(Timeout timeout) throws Exception {
            ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
            if (ent == null) {
                return;
            }
            Long threadId = ent.getFirstThreadId();
            if (threadId == null) {
                return;
            }
            
            //执行lua 进行续期
            RFuture<Boolean> future = renewExpirationAsync(threadId);
            future.onComplete((res, e) -> {
                if (e != null) {
                    log.error("Can't update lock " + getName() + " expiration", e);
                    return;
                }
                
                if (res) {
                    //延期成功,继续循环操作
                    renewExpiration();
                }
            });
        }
        //每隔internalLockLeaseTime/3=10秒检查一次
    }, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
    
    ee.setTimeout(task);
}

//lua脚本 执行包装好的lua脚本进行key续期
protected RFuture<Boolean> renewExpirationAsync(long threadId) {
    return evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
            "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                    "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                    "return 1; " +
                    "end; " +
                    "return 0;",
            Collections.singletonList(getName()),
            internalLockLeaseTime, getLockName(threadId));
}

上述源码读过来我们可以记住几个关键情报:

  1. watch dog 在当前节点存活时每10s给分布式锁的key续期 30s;
  2. watch dog 机制启动,且代码中没有释放锁操作时,watch dog 会不断的给锁续期;
  3. 从可2得出,如果程序释放锁操作时因为异常没有被执行,那么锁无法被释放,所以释放锁操作一定要放到 finally {} 中;

看到3的时候,可能会有人有疑问,如果释放锁操作本身异常了,watch dog 还会不停的续期吗?下面看一下释放锁的源码,找找答案。

// 锁释放
public void unlock() {
    try {
        get(unlockAsync(Thread.currentThread().getId()));
    } catch (RedisException e) {
        if (e.getCause() instanceof IllegalMonitorStateException) {
            throw (IllegalMonitorStateException) e.getCause();
        } else {
            throw e;
        }
    }
}

// 进入 unlockAsync(Thread.currentThread().getId()) 方法 入参是当前线程的id
public RFuture<Void> unlockAsync(long threadId) {
    RPromise<Void> result = new RedissonPromise<Void>();
    //执行lua脚本 删除key
    RFuture<Boolean> future = unlockInnerAsync(threadId);

    future.onComplete((opStatus, e) -> {
        // 无论执行lua脚本是否成功 执行cancelExpirationRenewal(threadId) 方法来删除EXPIRATION_RENEWAL_MAP中的缓存
        cancelExpirationRenewal(threadId);

        if (e != null) {
            result.tryFailure(e);
            return;
        }

        if (opStatus == null) {
            IllegalMonitorStateException cause = new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: "
                    + id + " thread-id: " + threadId);
            result.tryFailure(cause);
            return;
        }

        result.trySuccess(null);
    });

    return result;
}

// 此方法会停止 watch dog 机制
void cancelExpirationRenewal(Long threadId) {
    ExpirationEntry task = EXPIRATION_RENEWAL_MAP.get(getEntryName());
    if (task == null) {
        return;
    }
    
    if (threadId != null) {
        task.removeThreadId(threadId);
    }

    if (threadId == null || task.hasNoThreads()) {
        Timeout timeout = task.getTimeout();
        if (timeout != null) {
            timeout.cancel();
        }
        EXPIRATION_RENEWAL_MAP.remove(getEntryName());
    }
}

释放锁的操作中 有一步操作是从 EXPIRATION_RENEWAL_MAP 中获取 ExpirationEntry 对象,然后将其remove,结合watch dog中的续期前的判断:

EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ent == null) {
    return;
}

可以得出结论:

如果释放锁操作本身异常了,watch dog 还会不停的续期吗?不会,因为无论释放锁操作是否成功,EXPIRATION_RENEWAL_MAP中的目标 ExpirationEntry 对象已经被移除了,watch dog 通过判断后就不会继续给锁续期了。

redis 是单线程还是多线程

redis单线程模型,指的是redis命令的核心模块是单线程的,而不是整个redis实例就是一个线程,redis其他模块还有各自的线程的

Redis基于Reactor模式开发了网络事件处理器,这个处理器被称为文件事件处理器。它的组成结构为4部分:多个套接字、IO多路复用程序、文件事件分派器、事件处理器。

一般来说redis瓶颈不在CPU,而在内存和网络,如果要使用CPU多核,可以搭建多个Redis实例来解决

Redis 4.0 为了防止耗时的命令阻塞线程,导致无法处理后续事件。引入了多线程来处理一些非阻塞命令。有:UNLINK、FLUSHALL ASYNC、FLUSHDB ASYNC等。
但是整个网络模型依然是单线程的,所以我们称之为单线程。

Redis 6.0 就真正的在网络模型上加入多线程IO来解决网络IO的性能瓶颈。
此时IO读写是多线程的,执行命令依旧是单线程的。

redis的网络事件处理器是基于Reactor模式,又叫做事件处理器

文件时间处理器使用I/O多苦复用来同时监听多个套接字,并根据套接字执行的任务关联到不同的时间处理器

文件事件以单线程方式运行,但通过使用I/O多路复用来监听多个套接字,文件事件处理器实现了高性能的网络通讯模型

Redis 在处理客户端的请求时,包括接收(socket读)、解析执行发送(socket 写) 等都由一个顺序串行的主线程处理,这就是所谓的单线程

Redis的 IO多路复用

Reactor模型

Redis的单线程网络模型,就是一个经典的Reactor模型,其本质上是 IO多路复用+非阻塞IO的模式

是一种基于事件驱动模型的设计

我们来看下Reactor里面有俩种经典的模型

单线程模型

Redis单线程模型就是使用的经典单线程Reactor模型

我们先看看单线程的Reactor模型

消息处理流程

  1. reactor对象通过select/poll/epoll等IO多路复用监控连接事件,收到事件后通过dispatcher事件分发器进行转发
  2. 如果连接建立的事件,则由acceptor接受连接,并创建Handler处理事件
  3. 如果不是建立连接事件,则Reactor会分发调用Handler来响应
  4. handler会完成 read->解析->执行->send的完整业务流程

优点:单线程运行,串行操作,不需要加锁,逻辑简单

缺点:仅用一个线程处理请求,对于多核资源机器来说有些浪费.当读写任务的线程负载比较重,将会阻塞后续的事件处理,导致整体延迟变大

应用:redis6.0版本之前

Master-Worker Reactor模型

比起单线程模型,它是将reactor分成俩部分

  • mainReactor负责监听server socket,用来处理网络IO连接建立操作,将建立的socketChannel指定注册给subReactor(只负责监听)
  • subReactor主要做和建立起来的socket做数据交互和时间业务处理操作.通常,subReactor个数上可与CPU个数同等.一般是多个,这样的话就能充分利用多核的优势(负责IO读写和命令的执行)

区别于单线程Reactor模式,这种模式不再是单线程的事件循环,而是有多个线程subReactors各自维护一个独立的事件循环,由 mainReactor 负责接收新连接并分发给 subReactors 去独立处理,最后 subReactors 回写响应给客户端。

优点:

  • 响应快,不必为单个同步时间所阻塞,虽然Reactor本身依然是同步的;
  • 可扩展性,可以方便地通过增加Reactor实例个数来充分利用CPU资源;

缺点:

  • 如果多个线程可能操作同一份数据,就涉及到底层数据同步的问题,则必然会引入某些同步机制,比如锁。增加了代码复杂度,同时增加了同步机制的开销。

Nginx, Netty, Swoole, Memcached就是使用的这个模型

Redis6.0的网络模型

Redis6.0版本之后,Redis正式在核心网络模型中引入了多线程,也就是所谓的I/O Threading,至此,redis拥有了多线程模型.但是redis的多线程模型却并非标注你的Master Workder Reactor模型.其他的多线程.只负责IO读写,不负责具体执行

为什么要使用多线程

之前说了,CPU不是redis的瓶颈,Redis的瓶颈最有可能是机器内存大小和网络带宽的限制

从Redis自身角度来说,因为读写网络的read/write系统调用占用了redis执行期间大部分CPU时间,瓶颈主要在网络的IO消耗,所以选择来读线程IO来实现读写,主线程来执行Redis命令

总结:主线程IO读写任务拆分出来给一组独立的线程处理,使得多个Socket读写可以并进行优化

为什么这么设计?

  1. 前面提到 Redis 最初选择单线程网络模型的理由是:CPU 通常不会成为性能瓶颈,瓶颈往往是内存和网络,因此单线程足够了。那么为什么现在 Redis 又要引入多线程呢?很简单,就是 Redis 的网络 I/O 瓶颈已经越来越明显了。所以这个多线程是为了解决IO的瓶颈的。
  2. 如果多线程包括了IO读写,解析和执行的整个过程,那么多线程需要面临线程安全的问题,Redis 6.0版本之前是没有考虑线程安全的,如果使用多线程来处理命令的执行,需要大量的改动来保证多线程的安全机制,实现更复杂。为了避免了不必要的上下文切换和竞争条件,多线程导致的切换而消耗 CPU,也不用考虑各种锁的问题,就让执行这一步只使用主线程。

什么是IO多路复用

文件事件是对套接字操作的抽象,当每一个套接字准备好执行连接,应答,写入,读取,关闭等操作时,就会产生一个文件事件.因为一个服务器通常会连接多个套接字,所以多个文件事件有可能会并发地出现

IO多路复用程序负责监听多个套接字并向文件分排气传送哪些产生了事件的套接字.文件时间分派器接受IO多路复用程序传来的套接字,并根据套接字产生的事件类型,调用相应的事件处理器.

Redis的IO多路复用程序的所有功能都是通过包装常见的select,poll,evport和kqueue这些io多路复用的函数来实现的,每个IO多路复用函数库在redis源码中都有一个对应的文件,Redis为每个IO多路复用函数库都实现了相同的API,所以IO多路复用程序的底层实现是可以互换的

Redis把所有连接与读写事件、还有我们没提到的时间事件一起集中管理,并对底层IO多路复用机制进行了封装,最终实现了单进程能够处理多个连接以及读写事件。这就是IO多路复用在redis中的应用。

总结

让我们来回顾一下 Redis 多线程网络模型的设计方案:

  • 使用 I/O 线程实现网络 I/O 多线程化,I/O 线程只负责网络 I/O 和命令解析,不执行具体的命令。

Redis 的多线程网络模型实际上并不是一个标准的 Master-Worker Reactor 模型,Redis 的多线程方案中,I/O 线程任务仅仅是通过 socket 读取客户端请求命令并解析,却没有真正去执行命令。

所有客户端命令最后还需要回到主线程去执行,因此对多核的利用率并不算高,而且每次主线程都必须在分配完任务之后忙轮询等待所有 I/O 线程完成任务之后才能继续执行其他逻辑。

Redis 目前的多线程方案更像是一个折中的选择:既保持了原系统的兼容性,又能利用多核提升 I/O 性能,来解决网络IO的性能瓶颈。

Last modification:November 17, 2023
如果觉得我的文章对你有用,请随意赞赏