美文网首页
Redis学习之旅~原理篇

Redis学习之旅~原理篇

作者: 无一幸免 | 来源:发表于2019-06-22 09:34 被阅读0次

    内容依旧来自<redis深度历险>

    核心原理

    线程IO模型

    单线程非阻塞IO

    • redis是单线程模型。redis的指令很快,主要就是由于所有的运算都在内存,省去了磁盘IO的开销。由于是单线程,时间复杂度较高的指令和存储的key过大,都会导致redis卡顿。
    • 多路复用:使用的是非阻塞IO,这个就类似java的NIO。一般我们使用阻塞io进行读取的时候,read方法需要读取n个字节,如果一个字节都没有,线程就会在那里等待,一定要读够n个字节才能返回,线程才能做其他事。非阻塞IO就是就是,打开套接字的时候,读写不再阻塞。实际写了多少盒读了多少,会立马又返回值告诉程序实际读写多少字节。redis的线程不会因为读写而停顿,读写完的瞬间,就可以去处理其他业务。
    • 事件轮询:非阻塞IO没有解决的问题就是,线程要读或者写的数据,在读取了一部分就返回了。这时,线程肯定不能把数据直接返回给调用端。需要一个什么机制来保证相应线程数据到来的时候,线程能够被通知到。最简单的事件轮询api就是select函数。输入是读写描述符列表,同时对线程调用还提供了一个timeout参数。这个参数意味着,线程会等待timeout值得时间。如果在等待期间,有任何事件到来,就可以立即返回。拿到事件以后,线程就可以继续处理相应的事件。这里,需要写一个死循环,成为事件循环
    while (true) {
      eventList = select(readFds, writeFds, timeout);
      for (event in eventList) {
           handleEvent(event);
      }
    }
    
    • 指令队列和响应队列:redis会将每个客户端的套接字都关联一个指令队列,所有的指令都放到队列中进行顺序处理。如果redis有多个客户端连接的话,那就是先到的队列先处理。响应队列也是一样,通过队列将结果返回给客户端。如果队列为空的话,事件轮询是不是就不应该再去轮询这个队列了呢?redis的做法就是,如果队列为空,就把队列的文件描述符write_fds进行移除,然后移除事件轮询,等到队列有数据了,再给这个队列添加写文件描述符。这样可以避免redis的select获取到队列以后,发现没东西可写就立即返回。
    • 定时任务:redis除了处理指令以外,还需要处理其他的业务,比如定时任务,备份等。redis的定时任务存储在最小堆那里,维护一个最小堆所需要的时间是nlogn,时间复杂度不算高,基本线性时间。在事件循环的周期里面,redis会对最小堆里面的已经到时间点的定时任务进行处理。处理完毕以后,就会将下一个即将要执行的定时任务的时间获取到,这个时间就是select函数这个线程的睡眠时间。在这个时间区间之内,是可以预期没有其他任务需要处理的,可以休眠。但是,如果当休眠的时候有指令到来,select函数就会被激活,进行下一轮的事件循环。处理完指令以后,再去堆那里获取定时任务,如果有就执行,没有,就刷新timeout。

    通信协议

    背景

    • redis的作者认为,数据库的瓶颈不在网络流量,而在于内部的逻辑处理上面。Redis的传输协议是RESP协议,这个协议有很多的字符冗余,会浪费网络流量,但是其优势在于解析性能极好。

    最小单元类型,每个单元结束时候以\r\n结束

    • 单行字符串以"+"开头
    • 多行字符串以"$"开头,后面跟上字符串长度
    • 整数值以":"开头,后面跟整数的字符串形式
    • 错误消息以"-"开头
    • 数组以"*"开头,后面跟数组的长度
    • 单行字符串redis,表示为: +redis\r\n
    • 多行字符串hello world,表示为: $11\r\nhello world \r\n
    • 整数100,表示为: :100
    • 错误, -Wrong\r\n
    • 数组[1,2,3],表示为: *3\r\n:1\r\n:2\r\n:3\r\n
    • 客户端发送的指令和服务器返回的响应,也是这五种单元类型的组合
    • set指令set a a, 表示为一个字符串数组,*3\r\n3\r\nset\r\n1\r\na\r\n$1\r\na\r\na
    上面列举了这么多个类型,可以看出redis的传输协议里面有大量冗余的回车换行符。虽然它浪费了部分空间,但是胜在简洁。这里我需要思考的就是,性能并不总是一切,简单性、易理解和易实现也是要权衡的问题。

    redis持久化

    • redis的备份有rbd和aof两种。这两种方式都有自己的不足。
    • rbd快照全量备份的话,在服务器宕机的时候会丢失数据
    • aof增量备份的话,日志文件会变得无比巨大,这时就需要有一个定时任务去对aof文件进行整理。
    • 从上面我们知道,redis是单线程程序,线程需要处理指令和定时任务,进行快照备份是需要进行文件io的,这个会严重拖慢redis服务器的性能。那么,redis是如何实现一边处理线上指令,一边进行快照备份的呢?进行快照备份的时候,是如何解决内存数据结构改变的问题?
    • redis是使用操作系统的多进程特性来进行快照持久化的。在要进行持久化的时候,redis会fork一个子进程,快照持久化就完全交给子进程处理。子进程和父进程共享内存里面的代码段和数据段。在子进程产生的一瞬间,内存的增长几乎是没有明显变化。
    • 使用子进程做数据持久化,不会修改现有的内存数据,只是对数据结构进行遍历读取,然后序列化存储到磁盘中。如果这时父进程正在修改共享的数据的时候,父进程会对要修改的页面复制一份,分离出来,子进程看到的数据还是子进程产生时候的数据,所以称为快照。这样有页面被分离的时候,内存会有相应的增长,但是也不会超过原来内存的2倍。
    • redis的AOF日志存储的就是服务器顺序指令,只会记录修改数据的指令。这个备份是不会fork一个子进程。redis是先执行命令,然后才将日志存盘。为何要这样呢?这是造成redis不支持事务回滚的原因,因为发生异常的时候,没有用来进行回滚的日志。这一点和mysql不一样,mysql是先做日志,再做操作,所以mysql支持回滚。
    • redis提供了bgrewriteaof指令用于对aof日志文件进行瘦身,其原理就是开辟一个子进程对内存进行遍历,转换成为一系列的redis操作指令,序列化成为一个新的aof日志文件中。这个操作完成以后,再将期间发生的增量aof文件追加到新的aof文件中,这样就用新的文件替换旧的文件。
    • fsync:aof日志是异步写到文件中的。这时候有一个问题,如果服务器在写磁盘的时候突然宕机,就会导致内容没有来得及刷入磁盘,日志进行丢失。Linux提供了fsync(写设备命令),fwrite只是写入到缓冲区,加上fsync(fileno(fp))。该函数返回后,才能保证写入到了物理介质上。只要redis实时调用fsync命令,就能保证日志不丢失。但是,这个操作就涉及io了,会很慢。我们有三种设置,一种是永远不调用fsync(存盘完全交给操作系统),一种是每个指令都调用fsync(性能太差),一种设置是通常间隔1秒就调用一次fsync。最后一种方式一般用于生产环境,在性能和安全之间做一个平衡。所以,aof可能丢失的就是1秒的数据

    实际操作代码如下:

    cd /etc
    vim redis.conf
    
    修改如下配置
    appendonly yes
    # The name of the append only file (default: "appendonly.aof")
    appendfilename "appendonly.aof"
    往下面看,有三种刷盘方式,我们选择每秒刷一次
    # appendfsync always
    appendfsync everysec # 一秒调用一次
    # appendfsync no
    
    
    ...
    很后面有一行,这个是redis文件的配置
    dir /var/lib/redis
    
    运行几个命令
    set a 1 
    incr a 
    set b 2
    如此...
    

    接着去到/var/lib/redis文件夹,可以看到appendonly.aof文件已经生成,使用less命令进行查看,就会有如下命令

    *2
    $6
    SELECT
    $1
    0
    *3
    $3
    SET
    $1
    c
    $1
    2
    *3
    $3
    SET
    $1
    v
    $1
    1
    *3
    $3
    SET
    $1
    a
    $1
    1
    

    接下来尝试另外一个命令,bgrewriteaof对日志进行瘦身

    dbsize
    6
    //日志显示的文件大小
    [root@VM_75_157_centos redis]# ll
    total 20
    -rw-r--r-- 1 root  root  347 Jun 20 22:37 appendonly.aof
    
    然后执行bgrewriteaof命令:
    127.0.0.1:6379> bgrewriteaof
    Background append only file rewriting started
    redis开启了子进程进行瘦身
    [root@VM_75_157_centos redis]# ll
    total 20
    -rw-r--r-- 1 root  root  267 Jun 20 22:38 appendonly.aof
    文件大小从347降低到了267
    
    • redis的RBD和AOF方式都有优缺点。我们究竟采取何种方式呢?在redis4.0之前,我们是很少使用rbd来重启服务器的,这样会丢失大量数据。通常使用的是aof重放,但是这样启动时间就很长。好在redis4.0带来了一个新的持久化方式,混合持久化。将rbd文件的内容和aof的日志文件放在一起。这时的aof不再是全量的日志,而是自持久化开始到持久化结束结束的时间发生的增量aof日志。通常aof这部分的日志很小。然后,在进行重启的时候,先加载rbd文件的内容,然后重放aof日志。这样,重启效率就大大提升了。4.0版本的混合持久化默认关闭的,通过aof-use-rdb-preamble配置参数控制,yes则表示开启,no表示禁用,默认是禁用的,可通过config set修改。

    管道

    • redis客户端提供了管道技术,可以批量处理命令,效率有提高。为何会这样呢?一般来说,我们发送一条命令给redis服务器,它就返回了一个结果,这样就是一个网络数据包来回的时间。write->read的过程。管道是怎么回事呢?管道调整了指令的执行方式,将多个write命令先缓存起来,然后批量发送。比如发送两个指令,顺序就是write->read->write->read,消耗两个数据包时间。使用了管道以后,执行顺序就会变成了write->write->read->read,这时就只是花费了一个网络来回时间。
    public class PiplineTest {
        private static int count = 10000;
     
        public static void main(String[] args){
            useNormal();
            usePipeline();
        }
     
        public static void usePipeline(){
            ShardedJedis jedis = getShardedJedis();
            ShardedJedisPipeline pipeline = jedis.pipelined();
            long begin = System.currentTimeMillis();
            for(int i = 0;i<count;i++){
                pipeline.set("key_"+i,"value_"+i);
            }
            pipeline.sync();
            jedis.close();
            System.out.println("usePipeline total time:" + (System.currentTimeMillis() - begin));
        }
     
        public static void useNormal(){
            ShardedJedis jedis = getShardedJedis();
            long begin = System.currentTimeMillis();
            for(int i = 0;i<count;i++){
                jedis.set("key_"+i,"value_"+i);
            }
            jedis.close();
            System.out.println("useNormal total time:" + (System.currentTimeMillis() - begin));
        }
     
        public static ShardedJedis getShardedJedis(){
            JedisPoolConfig poolConfig = new JedisPoolConfig();
            poolConfig.setMaxTotal(2);
            poolConfig.setMaxIdle(1);
            poolConfig.setMaxWaitMillis(2000);
            poolConfig.setTestOnBorrow(false);
            poolConfig.setTestOnReturn(false);
            JedisShardInfo info1 = new JedisShardInfo("127.0.0.1",6379);
            JedisShardInfo info2 = new JedisShardInfo("127.0.0.1",6379);
            ShardedJedisPool pool = new ShardedJedisPool(poolConfig, Arrays.asList(info1,info2));
            return pool.getResource();
        }
    }
    

    消耗时间

    useNormal total time:772
    usePipeline total time:112
    
    • 使用管道的确是节省了时间。这种情况何时使用呢?对于可以允许少量失败的批量写入程序可以使用。比如信息群发,漏掉一两条无所谓,使用定时任务去补就好了。

    管道的本质:网络交互的简略流程如下

    • 客户端进程调用write将消息写到操作系统为套接字分配的缓冲区中
    • 客户端操作系统将缓冲区的内容发送出去
    • 服务器进程将数据放在操作系统为套接字分配的缓冲区中
    • 服务器调用write将响应消息写到套接字分配的缓冲区中
    • 服务器将内容发送出去
    • 客户端操作系统将接收到的数据放到为套接字分配的缓冲区中
    • 客户端进程调用read从缓冲区读取数据返回给上层使用
      我们开始以为,客户端的write操作是要等到对方收到消息以后才返回的,实际情况不是这样。实际情况是客户端的write负责把数据写到缓冲区就返回了。剩下的发送交给操作系统。但是,如果缓冲区满了,write操作就要等待缓冲区空出空间来,这个才是写操作IO真正的耗时。读取内容也是这么回事,读IO操作的耗时就是等待缓冲区有数据到来。
    • 对于单个命令的set a 1这样,写操作几乎没有耗时,读操作就有耗时了,这时就要等待网络消息的到来。
    • 对于管道来说,连续的write几乎不耗时,多个write也只是写入到了缓冲区。第一个read会比较耗时,会等到数据回来。但是,当第一个结果已经返回的时候,所有的响应都回到操作系统内核的缓冲区了,后续的read就可以直接拿结果,瞬间返回。

    redis事物

    普通数据库的事务大致如下:

    begin();
    try{
        //业务逻辑 
        ....
        commit();
    } catch(Exception e) {
        rollback();
    }
    

    redis的事务有如下的指令来支持,主要有multi事务开始,exec事务执行,discard事务丢弃。

    127.0.0.1:6379> multi
    OK
    127.0.0.1:6379> incr a
    QUEUED
    127.0.0.1:6379> incr a
    QUEUED
    127.0.0.1:6379> exec
    1) (integer) 9
    2) (integer) 10
    
    如果中途有命令是错误的呢?
    [root@VM_75_157_centos ~]# redis-cli
    127.0.0.1:6379> multi
    OK
    127.0.0.1:6379> incre a
    (error) ERR unknown command 'incre'
    127.0.0.1:6379> incr a
    QUEUED
    127.0.0.1:6379> incr a
    QUEUED
    127.0.0.1:6379> exec
    (error) EXECABORT Transaction discarded because of previous errors.
    这时就会告诉用户,事务被丢弃了
    a的值并没有改变。但是,这并没有确保是所有的指令都没有执行,redis的事务不支持原子性
    

    redis事务的执行流程就是所有的执行在exec指令之前,都不会执行,而是缓存在服务器的事务队列当中。服务器一旦接收到exec指令,才开始批量执行队列的指令。之前说过redis是单线程,所以可以保证队列里面的指令可以得到顺序执行,不会被其他指令抢占。保证了一批指令的批量执行。

    • 探讨redis事务的原子性
    (nil)
    127.0.0.1:6379> multi
    OK
    127.0.0.1:6379> set test test 
    QUEUED
    127.0.0.1:6379> incr test
    QUEUED
    127.0.0.1:6379> set test2 test2
    QUEUED
    127.0.0.1:6379> exec
    1) OK
    2) (error) ERR value is not an integer or out of range
    3) OK
    127.0.0.1:6379> get test
    "test"
    127.0.0.1:6379> get test2
    "test2"
    

    上面的事务,在 第二个指令执行的时候失败了。如果有使用mysql的经验,我们可能认为,后续的get命令,得到的会是null值。的确,mysql可以对事务进行回滚。但是,redis后续的指令都被执行了。redis事务不支持回滚的一个原因就是redis是先操作指令,然后再写日志。而mysql是先写日志,再进行操作。所以,发生错误的时候,mysql有可以回滚的日志,而redis没有。通过上述的操作,我们可以知道redis的事务不具备原子性,而是仅仅满足了事务隔离性种的串行化。

    对事务的操作,我们是可以进行一定的优化的,使用的方式就是前面提过的管道。之前的这几个命令,一个命令就消耗了一个网络来回,我们可以使用管道进行优化。

    watch指令,这个是redis提供的一种乐观锁的实现。如果有用过关系型数据库,乐观锁的实现的话,就是在表里面增加一个version版本号。在对某一行进行修改的时候,先select这一行,获得当前的版本号,然后执行更新的时候可以是

    update table set a = ? where id = ? and version = 当前线程select的版本号
    

    乐观锁可以处理Java程序的多线程并发修改。redis的watch也是同样的道理,在事务开启之前,先用watch盯住某个key,然后进行事务操作,如果key在事务执行之前,有被修改过,事务就执行失败。

    127.0.0.1:6379> set books java
    OK
    127.0.0.1:6379> watch books
    OK
    127.0.0.1:6379> set books redis
    OK
    127.0.0.1:6379> multi
    OK
    127.0.0.1:6379> set  books golang
    QUEUED
    127.0.0.1:6379> exec
    (nil)
    

    我们事先watch了books变量,但是,在事务之前,books被改变了,所以,后面执行事务的时候,就失败。redis乐观锁的指令顺序是watch->multi->exec。

    分布式锁

    • 如果redis配置了集群环境,redis的set指令扩展设置的分布式锁就会出现问题,它就变得不是绝对安全了。例如,redis有主从两个节点,线程1在主节点获得了一个锁。这时主节点挂掉了,从节点升为主节点,这时新的主节点并没有那个key,线程2请求加锁的时候,也会获得同一把锁。
    • 这个问题的解决,需要引入第三方的library,如redlock.使用redlock算法的话,可以保证加锁成功。它的原理是向大多数节点都发送set(key,value,nx,ex)指令,当半数节点都返回true的时候,才认为加锁成功。del也同样如此。由于要对多个节点进行操作,性能会有一定的下降。

    redis key的过期策略

    • redis的所有数据结构都可以设置过期时间,时间到了,就可以被自动删除。我之前一直很好奇的就是redis的key到底是怎么过期的。使用定时任务?可是如果同一时间太多key要过期,定时任务处理不过来。
    • redis会对设置了过期时间的可以放入一个独立的字典种,定时任务会去变量这个数据结构去删除过期的key。除了定时处理以外,redis还提供了惰性删除的方式。在客户端访问访问key的时候,会对key的过期时间进行检查,如果发现过期,就立即删除。

    定时扫描

    • redis默认每秒进行10次过期扫描,这里不会检查所有过期的key,采用的是一种贪心挑选的策略。
      1. 从字典中随机挑选20个key
      2. 删除这20个key中已经过期的key
      3. 如果过期的key的比例超过25%,就重复步骤1
    扫描策略的时间配置
    cd /etc
    vim redis.conf
    把文件拉到最后,会有一行
    hz 10
    

    修改这个值就可以改变定时过期扫描的频率,redis支持1~500,但是超过100的话,就不是一个good idea。

    • 这里我们会想到,如果一个redis的key在某一个时间段集中过期会怎么样?会不会导致redis卡顿?如果出现这种情况,redis是会出现卡顿的,但是redis对过期扫描设置了时间上限,默认不会超过25ms。就是说,当客户端的请求到来,如果这时redis正在执行过期,那么客户端请求会等待至少25ms才能返回。这时,就要注意客户端的超时时间设置得短的话,就有可能会超时。
    • 避免大量的key集中过期的话,我们可以使用一种随机的策略,将时间分散。
    jedis.expire(key, Math.random(86400) + time);
    

    从节点过期策略

    • 从节点不会进行过期扫描,这个处理是被动的。主节点在key到期的时候,会在aof文件中增加一个del指令,等到从节点同步aof以后,从节点执行这个del指令来删除相应的key。
    • 从节点同步的延迟,会导致数据在主节点已经被删除,但是从节点没有及时同步,已经过期的key还会在从节点查到。之前说的分布式锁在集群环境下会不安全,这个也是一大部分原因。

    相关文章

      网友评论

          本文标题:Redis学习之旅~原理篇

          本文链接:https://www.haomeiwen.com/subject/fclnfctx.html