Redis笔记(2)单机数据库实现

1.前言

  上节总结了一下redis的数据结构和对象构成,本章介绍redis数据库一个基本面貌,是如何设计的。

2.数据库

  服务器结构redisServer:

    redisDb *db: 一个数组,保存服务器中所有的数据库

    dbnum:  服务器的数据库数量,默认16个

    saveparam *saveparams: 设置的保存选项

    long dirty: 修改计数器

    time_t lastsave: 上一次执行保存的时间

    sds aof_buf: AOF缓冲区

    list *clients: 链表,保存了所有客户端状态

  默认情况下连接的数据库是0,可以通过select命令切换到目标数据库。

  客户端结构redisClient:

    redisDb *db: 记录客户端当前正在使用的数据库

    sds querybuf: 输入缓冲区

    robj **argv: 命令解析的内容

    int argc: argv数组的长度

    struct redisCommand *cmd: 命令指向的数据结构

  数据库结构redisDb:

    dict *dict 数据库键空间,保存着数据库所有键值对

    dict *expires: 保存所有键的过期时间

  命令的基本操作都是查找键、再取值。

  读写键空间的维护操作:

    读取一个键后,服务器会根据键是否存在来更新服务器的键命中次数或不命中次数,可以通过INFO stats命令查看keyspace_hits属性和keyspace_misses属性中查看。

    读取一个键后,会更新该键的lru时间。

    读取一个键时,发现该键已过期,会先删除再进行后续操作。

    WATCH一个键后,如果修改了,会被标记为脏

    每修改一次键,都会对脏计数+1,会触发持久化操作和复制操作。

    如果开启了通知功能,修改后会发送对应数据库通知。

  设置键过期:

    EXPIRE 秒级过期时间

    PEXPIRE 毫秒过期时间

    SETEX命令只针对字符串键,可以在设置时同时设置过期时间。

    EXPIREAT和PEXPIREAT设置具体的过期时间,是unix时间戳。

  四个命令最终EXPIRE、PEXPIRE、EXPIREAT都是通过PEXPIREAT实现的。

  保存过期时间:

    设置了过期时间的键会放入db的过期时间字典表中。

  移除过期时间:

    PERSIST

  剩余生存时间:

    TTL

  过期键的判定:

    1.是否存在于过期键的表中

    2.是否已经过期

  过期键的删除策略:

    1.定时删除,为过期键创建定时器,到时间就立刻删除。

      优点:快速释放资源

      缺点:消耗CPU,此外定时器使用到时间事件,实现方式是无序链表,O(N)复杂度。该方法不现实。

    2.惰性删除:取键的时候检测过期时间,过期了就删除。

      优先:不消耗CPU

      缺点:消耗内存

    3.定期删除:每一段时间执行一次删除操作,可以限制频率和时长,减少对CPU的影响。折中手段。

    Redis采取了惰性删除和定期删除的策略。

    定期删除过程:1.从一定数量的数据库中(小于16个用全部,大于等于取16个)中取出一定数量的随机键(默认20),删除其中的过期键

           2.current_db记录当前检查的进度,下次检查时,接着处理。比如这次处理到10,下次就处理11号数据库。

           3.检查了所有的数据库,重置current_db为0。

  AOF、RDB和复制功能对过期键的处理:

    RDB:生成RDB时,过期键不会被保存。主服务器启动时,未过期被载入,过期忽略。从服务器启动所有键都被载入,数据同步时从服务器会被清空,所以一般不会影响。

    AOF:键过期了,但没有被检测删除,无影响,检测删除后会追加一条DEL命令。AOF重写时会忽略过期键。

    复制:主从复制模式中,只有主服务器会删除过期键,再发送一个消息给从服务器删除,在删除之前,从服务器会将过期键返回客户端。

  数据库通知:

    redis 2.8之后客户端可以订阅给定的频道或者模式,获知数据库中键的变化,以及数据库中命令的执行情况。

    通知有两种类型:键空间通知——某个键执行了什么命令,关注的是具体键

            键事件通知——某个命令被什么键执行了,关注的是具体命令

    这个可以设置notify-keyspace-events来决定发送的通知类型:

      二者都要,设置 AKE

      所有类型的键空间,设置AK

      所有类型的键事件,设置AE

      只关注字符串键有关的键空间和事件,设置K$

      只关注列表键有关的键事件通知,设置El

    其余的具体见官方文档。

3.RDB持久化

  RDB文件的创建与载入:

    手动方式有两个命令:SAVE、BGSAVE。

    SAVE会阻塞服务器,BGSAVE会fork一个子进程,由其处理。

    载入是在服务器启动时自动执行的,所以没有专门的载入命令。

    注意:AOF文件更新频率比RDB文件要高,所以如果服务器开启了AOF持久化功能,那么服务器会优先使用AOF文件来还原数据库。只有AOF关闭的情况下才会使用RDB文件。

    BGSAVE执行期间:SAVE命令会被拒绝,BGSAVE命令也会被拒绝,BGREWRITEAOF不能同时执行:BGSAVE执行期间,BGREWRITEAOF会等BGSAVE执行完毕再执行,如果是后者执行期间,BGSAVE会被拒绝。

  自动间隔性保存:

    可以配置save选项,让服务器隔一段时间自动执行BGSAVE命令。

    save 900 1      900秒内修改了一次

    save 300 10      300秒内修改了10次

    save 60 10000     60秒内修改了10000次

  检查保存条件:

    serverCron函数默认每隔100毫秒执行一次,检查保存条件是否满足,满足就会执行BGSAVE,重置lastsave和dirty计数。

  RDB文件结构:

    REDIS: 5个字节,保存REDIS五个字符,说明该文件是RDB文件

    db_version: 4个字节,字符串整数,记录RDB文件的版本号。

    database: 包含若干个数据库的数据

      SELECTDB:1个字节,意味着是个数据库,后面是数据库号码

      db_number:数据库号码,可以是1字节、2字节、5字节。服务器会调用SELECT命令,根据号码切换数据库。

      key_value_pairs:保存了所有键值对数据,如果带有过期时间,会和键值对保存在一起。长度不定。

        EXPIRETIME_MS:标志带有过期时间

        ms: 8字节带符号整数,记录毫秒为单位的unix时间戳。

        TYPE:记录了value的类型,长度1字节,可以是以下类型:

            string、list、set、zset、hash、list_ziplist、set_inset、zset_ziplist、hash_ziplist

        key:一个字符串对象,与string类型一致

        value:

            string类型结构:  encoding: int(8,16,32)   raw.

                    小于等于20字节原样,大于20字节压缩(开启了压缩,否则原样)

                   压缩结构:LZF标志LZF算法  compressed_len压缩长度,origin_len原长度,compressed_string 压缩后字符串

            list结构:list_length:元素个数 item长度 item值

            hash结构:hash_size: key1 value1,都是长度和值

            有序集合对象:sorted_set_size member1 socre1 也是长度和值 2 "pi" 4  "3.14"

            intset集合:将整型转成字符串对象,再保存。

            ziplist: 转换成一个字符串对象,保存。读取的时候判断是ziplist,转换成原来的对象。

    EOF:1字节,标志文件内容结束。

    check_sum: 8字节长的无符号整数,保存着一个校验和,对前面四部分进行计算得到的值,用于检测文件是否损坏。

4.AOF持久化

  AOF持久化:

    该持久化通过保存写命令来记录数据库的状态。分为3个步骤:命令追加,文件写入,文件同步三个步骤。

    AOF开启时,有写命令,会按协议格式写入服务器的aof_buf中。

    redis服务器是个事件循环,循环接收客户端请求,向客户端发送命令,每次结束一个循环后,会决定是否将aof_buf的内容写入AOF文件中。

    可以配置appendfsync决定持久化行为:

      always: 将所有缓冲数据写入AOF文件,并同步

      everysec: 将aof_buf所有内容写入AOF文件,如果上次同步时间距离现在超过1s,再次同步,同步操作由一个线程专门负责。默认是这个选项。

      no: 将缓冲区写入AOF文件,但不立刻同步,由操作系统决定。

    文件的写入和同步的含义在于:现代操作系统为了提高文件写的效率,会将数据暂存到内存缓存区,等缓冲区填满或者超过了指定的时限,才写入磁盘。虽然提升了效率,但也带来了风险。计算机如果停机,那么保存的数据也会丢失。所以系统提供了fsync和fdatasync两个同步函数,可以将缓存区中的数据立刻写入磁盘,保证数据安全性。

  AOF文件的加载和数据还原:

    1.创建一个伪客户端,因为redis命令只在客户端上下文中执行。

    2.从AOF文件中分析读取一条写命令

    3.使用伪客户端执行被读出的写命令

    4.重复2,3步骤,直到全部处理完毕。

  这样数据库就被还原成原本的状态了。

  AOF重写:

  因为AOF是通过保存写命令来记录状态的,意味着每次修改都会产生记录,文件内容会越来越多,远超实际数据量,所以需要对AOF文件进行优化。redis提供了AOF文件重写功能来解决这一问题。

  虽然称为重写,但不是读取现有的AOF来完成的,而是直接根据当前数据库状态实现的。比如当前数据有个数据number 值为3,就添加一条写记录SET number 3。而且对于list, set类型可以将多个值合并成一个命令,比如RPUSH list a b c d e。整个过程是:遍历数据库,遍历读取未过期的键,根据键类型进行重写,如果有过期时间,过期时间也要重写。新的AOF文件是当前数据库的所有状态,所以不会浪费空间。

  注意,由于list,set等会将多个值合并到一个命令中,就可能产生一个命令过长,导致缓冲区溢出。所以在重写list,hash,set,zset时会检查元素数量,如果超过redis.h/REDIS_AOF_REWRITE_ITEMS_PER_CMD常量的值,就会使用多个命令分批处理。目前是64。

  AOF后台重写:

  同样的原因,如果数据量很大,很容易阻塞服务的线程,所以redis采取子进程的方法执行。之所以是子进程不是子线程在于子进程带有服务器进程的数据副本,可以在避免使用锁的情况下,保证数据安全。

  这样会产生一个问题,在子进程重写过程中,处理新的写入操作,AOF会丢失这部分数据。为了解决该问题,redis服务设置了一个AOF重写缓冲区,在创建子进程之后开始使用。redis执行完一个写命令后,会将这个写命令同时发送给aof缓存区和aof重写缓冲区。这样可以保证在重写过程中,原AOF文件按照原步骤一样继续写入,重写的AOF文件也可以在重写缓冲区中读取到这段时间新增加的数据。写完数据库状态后,子进程会发一个信号给父进程,父进程开始将AOF重写缓冲区的数据写入新的AOF文件,再替换掉旧的文件。这个过程由父进程完成,就避免了在这段时间内有新的请求发送过来,或者是命令只写了aof缓存区还没来得及写入aof重写缓冲区造成丢失数据。全部完成之后,就可以像往常一样处理数据了。

5.事件

  redis服务器是一个事件驱动程序,主要处理两类事件:文件事件和时间事件。

5.1 文件事件

  redis基于reactor模式开发了自己的网络事件处理器,文件事件处理器。使用IO多路复用程序来监听多个套接字,为套接字目前执行的任务来为套接字关联不同的事件处理器。应答,读取,写入,关闭等操作都会关联相关的事件处理器来处理这些事件。

  文件事件处理器的构成有四个部分:套接字、IO多路复用程序、文件事件分派器、事件处理器。尽管多个文件事件可以并发出现(套接字),但是通过多路IO复用程序就会成有序,同步每次一个套接字的方式向文件事件分派器传递套接字,上一个处理完了,才会轮到下一个。

  实现都是使用常见的select、epoll、evport、kqueue这些库来实现的,会根据操作系统自动选择底层实现。

  文件事件的处理器:

    连接应答处理器:有套接字连接的时候服务端就会产生AE_READABLE事件,引发连接应答处理器执行。

    命令请求处理器:读取客户端发送的命令请求内容。客户端产生AE_READABLE事件,执行命令请求处理器。

    命令回复处理器:客户端产生AE_WRITEABLE事件,执行命令回复处理器。

5.2 时间事件

  redis事件分为2类:

    定时事件:让一段程序在指定时间之后执行一次。比如说:让程序X在当前时间的30毫秒之后执行一次。

    周期性事件:让一段程序每隔指定时间执行一次。比如说:让程序Y每隔30毫秒执行一次。

  一个时间事件包含3部分:id 全局唯一  when 事件执行时间  timeProc时间事件处理器。

  如果事件是定时事件,执行完后就会移除。周期事件就会更新when的值。所有的事件都放入了一个无序链表中,每次执行时间事件会遍历整个链表,查询可执行的时间事件进行执行。无序指的是事件不按when排列,所以需要遍历整个链表。

  时间事件的应用就是serverCron函数,其主要为了维护redis的资源,保证redis长期稳定运行。主要工作如下:

    更新服务器的各类统计信息,比如时间、内存占用、数据库占用情况

    清理数据库中过期的键值对

    关闭和清理连接失效的客户端

    尝试进行AOF或RDB持久化操作

    如果服务器是主服务器,那么对从服务器进行定期同步

    如果处于集群模式,对集群进行定期同步和连接测试

  serverCron在redis2.6中每秒运行10次。

6.客户端

6.1客户端属性

  属性分为2类:通用属性,特定功能相关的属性,比如操作数据库时需要使用的db和dictid属性,执行事务时需要用到的mstate属性,以及watch命令时需要的watched_keys属性。

  int fd 套接字描述符,属性值为-1 伪客户端  大于-1的整数普通客户端。CLIENT list显示所有客户端

  robj *name 客户端名称,可以通过CLIENT setname设置,更清晰是哪个客户端

  int flags 客户端的标志属性,记录客户端的角色和目前所处状态。

    REDIS_MASTER 主服务器

    REDIS_SLAVE 从服务器

    REDIS_LUA_CLIENT 处理lua脚本里面包含的redis命令的伪客户端

    REDIS_MONITOR 客户端在执行monitor命令

    REDIS_UNIX_SOCKET 服务器使用unix套接字来连接客户端

    REDIS_BLOCKED 客户端被brpop blpop等命令阻塞

    REDIS_UNBLOCKED 标志表示客户端从BLOCKED状态脱离

    REDIS_MULTI标志客户端正在执行事务。

    REDIS_DIRTY_CAS 表明事务使用watch命令已经被修改

    REDIS_DIRTY_EXEC 事务在命令入队时出现了错误

    REDIS_CLOSE_ASAP 客户端缓冲区大小超过服务器限制

    REDIS_CLOSE_AFTER_REPLY 客户端发起了Client kill命令或者客户端发送了错误的协议内容

    REDIS_ASKING 标志表示客户端向集群节点发送了ASKING命令

    REDIS_FORCE_AOF 强制服务器将当前执行的命令写入AOF文件里面

    REDIS_FORCE_REPL 将主服务器的命令复制给所有从服务器。

6.2 输入缓冲区

  客户端发送命令请求,会写入到redisClient的querybuf中,最大不能超过1G,否则服务器将关闭这个客户端。服务器会对命令进行解析,存在客户端的argv和argc属性中,前者是一个数据,放置解析后的命令,后者是一个计数,记录argv的长度。之后服务器根据argv[0]的命令将cmd指向具体的数据结构。然后指向命令。

6.3 输出缓冲区

  客户端有两个输出缓冲区,一个固定大小,用于输出长度比较小的回复,比如ok等。另一个是可变大小的缓冲区,用于保存长度较大的回复。

  char buf[REDIS_REPLY_CHUNK_BYTES]  默认大小是16*1024 16kb, bufpos用于记录使用的位置。如果该空间用完了,或者无法放入,就会使用可变大小的缓冲区,list *reply。

6.4 身份验证

  int authenticated,该值如果为0,表示客户端未通过身份验证,如果为1则通过。未通过的时候只接受AUTH命令。

5.5 普通客户端关闭的原因

  1.客户端退出或者被杀死,网络连接将被关闭,从而客户端被关闭

  2.客户端发送了带有不符合协议格式的命令请求,这个客户端也会被服务器关闭

  3.客户端成了CLIENT KILL的目标,那么也会被关闭

  4.用户在服务器上设置了timeout配置项,客户端空转时间超过这个值会被关闭。例外情况:客户端是主服务器,被BLPOP等命令阻塞,或者在指向SUBSCRIBE、PSUBSCRIBE等订阅命令不会因为空转被关闭。

  5.输入缓冲区超过1G,被关闭

  6.输出缓冲区超过限制,会执行相应的操作:一种是硬性限制,超过就会被关闭,一种是软性限制,超过会被监控,如果指定时间内一直超过会被关闭,恢复正常范围则不会。

    client-output-buffer-limit指标   normal 0 0 0

                  slave 256mb 64mb 60

                  pubsub 32mb 8mb 60

7 服务器

7.1 命令执行过程

  1.客户端发送命令,转成协议格式,最后通过套接字传递给服务器。

  2.服务器监听到可读事件,读取命令请求,放入客户端的输入缓冲区。对输入缓冲区进行解析,提取命令和参数保存在argv和argc属性中。调用命令执行器,执行客户端指定的命令。

    执行过程:

      1)根据argv[0]参数在命令表中查找到指定的命令,保存在cmd属性中。

      2)预处理:

          检查cmd是否为null

          参数个数是否正确,

          是否通过身份认证,

          如果打开了maxmemory功能,检查内存情况,决定是否需要回收内存。

          检查上一次BGSAVE命令是否出错,并且如果打开了stop-writes-on-bgsave-error功能,拒绝写命令。

          客户端是否在用SUBSCRIBE命令订阅频道,只会执行SUBSCRIBE、PSUBSCRIBE、UNSUBSCRIBE、PUNSUBSCRIBE命令,其它的会被拒绝。

          如果服务器在载入数据,客户端发送的命令必须带有1标识(INFO、SHUTDOWN、PUBLISH)才会执行,其它拒绝。

          如果服务端执行Lua脚本超时阻塞,只会执行SHUTDOWN nosave命令和SCRIPT KILL 命令,其它拒绝。

          如果客户端执行事务,只会执行EXEC、DISCARD、MULTI、WATCH命令,其它会被放入事务队列。

          如果服务器打开了监视器功能,服务器会将要执行的命令和参数等信息发送给监视器。

      3)调用命令的实现函数,传入参数,返回结果会被保存在客户端状态的输出缓冲区。

      4)执行后的后续工作:

          如果服务端开始了慢查询日志功能,那么慢查询日志模块会检查是否需要为刚刚执行的命令添加一条新的慢查询日志

          根据刚刚执行命令所耗费的时长,更新被执行命令的redisCommand结构的milliseconds,将calls计数器的值增加1.

          如果开启了AOF持久化,会将其写入AOF缓冲区。

          如果有从服务器正在复制这个服务器,将命令广播给所有从服务器。

  3.命令回复会保存在客户端的输出缓冲区,变成可写状态,服务器会执行命令回复处理器,将回复内容发送给客户端。

7.2 serverCron函数

  该函数每100毫秒执行一次,负责管理服务器资源。

  1.redis中很多地方使用到了时间,为了减少系统调用,使用了缓存。redisServer中的time_t unixtime 保存了秒级精度的系统当前unix时间戳,long mstime保存了毫秒级的。每100毫秒会更新这两个属性,这两个精度并不高,redis只会打印日志、更新LRU时钟、决定是否执行持久化任务,计算服务器上线时间这类精度不高要求的功能上使用这些属性。对于键的过期时间,慢查询日志这种精度高的还是会执行系统调用获取当前时间。

  2.更新LRU时钟。redisServer中有个lruclock属性,默认每10秒更新一次,用于计算键的空转时长,redisObject中有一个lru属性,保存了对象最后一次被命令访问的时间,键的空转时间就用lruclock - lru的时间。

  3.更新服务器每秒执行命令次数,trackOperationsPerSecond函数会以100毫秒一次的频率执行,抽样计算的方式,估计记录服务器在最近1秒处理的命令数量,可以通过INFO status的instantaneous_ops_per_sec域查看。是一个估计值

  4.更新服务器内存峰值记录,记录当前服务器使用内存数量,比较stat_peak_memory,如果大于就替换。INFO memory命令的used_memory_peak和used_memory_peak_human用两种格式记录了内存峰值。

  5.处理sigterm信号,启动服务器时,redis会为服务器进程的sigterm信号关联处理器sigtermHandler,在服务器接受到sigterm信号时,打开服务器状态的shutdown_asap标识,serverCron会检查这个属性,决定是否关闭服务器。

  6.管理客户端资源,对一定数量的客户端进行2个检查:如果连接超时,释放客户端。如果客户端在上一次执行命令请求后,输入缓冲区的大小超过了一定长度,那么程序会释放客户端当前的输入缓冲区,并创建一个默认大小的输入缓冲区,防止客户端的输入缓冲区耗费过多内存。

  7.管理数据库资源,删除过期键

  8.执行被延迟的BGREWRITEOF命令,aof_rewrite_scheduled标识记录了服务器是否延迟了BGREWRITEAOF命令。

  9.检查持久化操作的运行状态,通过rdb_child_pid和aof_child_pid来判断BGSAVE或者BGREWRITEAOF命令是否正在执行。如果有一个值不为-1,程序就会执行一次wait3函数,检查子进程是否有信号发来服务器进程。如果有意味着备份完成,则进行后续操作,替换新的AOF文件之类的,没有意味着未完成,不处理。如果没有执行备份操作,检查BGREWRITEAOF是否被延迟了,检查自动保存条件是否被满足了,检查AOF重写条件是否满足了。

  10.将AOF缓冲区中的内容写入AOF文件。

  11.关闭异步客户端。

  12.增加cronloops的值,记录serverCron执行次数。

7.3 初始化服务器

  1.初始化服务器状态结构:redis.c/initServerConfig函数完成。主要工作是:

    设置服务器运行ID

    设置服务器的默认运行频率

    设置服务器的默认配置文件路径

    设置服务器的运行架构

    设置服务器的默认端口号

    设置服务器的默认RDB持久化条件和AOF持久化条件

    初始化服务器的LRU时钟

    创建命令表

  2.载入配置选项

    可以通过命令的方式  redis-server --port xxx 或者配置文件的方式 redis-server redis.conf

    如果没有设置,就使用代码里默认写的配置。

  3.初始化服务器数据结构

    initServerConfig函数初始化只创建了命令表一个数据结构,还有其它的没完成,比如:

      server.clients链表,记录了所有与服务器项相连的客户端的状态结构

      server.db 服务器的所有数据库

      server.pubsub_channels字典,server.pubsub_patterns链表,发布订阅使用

      lua环境server.lua

      用于保存慢查询日志的server.slowlog属性

    这个时候才做的原因在于必须先载入配置选项才能正确初始化数据结构。

    这里还做了其它操作:

      为服务器设置进程信号处理器

      创建共享对象,比如OK  整数字符串等等

      打开服务器的监听端口,并为监听套接字关联连接应答事件处理器

      为serverCron创建时间事件

      如果AOF持久化打开,那么打开现有的AOF文件,如果不存在创建一个,为写入准备

      初始化服务器的后台IO模块。

  4.还原数据库状态

    如果设置了AOF使用AOF还原,否则使用RDB还原。

  5.执行事件循环,初始化完成,等待客户端的连接请求。

posted @ 2018-07-01 15:57  dark_saber  阅读(319)  评论(0编辑  收藏  举报