服务模型
redis服务器可以与多个客户端建立连接,客户端将命令请求通过网络传输给服务器。redis服务器从在相应的数据库上执行读写操作。
redis服务是通过单线程单进程的方式处理客户端的请求。当一个redis客户端与redis服务器建立连接之后,服务器为客户端在服务器内部维护一个结构体RedisClient表示客户端状态,这个结构体用于保存当前客户的上下文信息。主要就是与客户端建立连接的套接字描述符、输入/输出缓冲区等与客户端有关的信息。
套接字描述符属性记录了客户端正在使用的套接字描述符。而输入缓存区用于保存redis客户端向redis服务器发送的命令,输出缓冲区用于保存redis服务器执行完毕命令,待回写到redis客户端的命令。
redis服务器可以一次性服务多个用户,这些用户使用链表结构组织起来。redis本质上就是一个事件驱动程序,可以处理文件事件和时间时间。文件事件就是服务器对套接字操作的抽象,因为send和receive本身就可以看作特殊的文件接口,而对端的socket输入缓冲区就是打开的文件。时间时间是需要在给定时间点执行的事件,是服务器定时操作的抽象。
redis的文件处理器是reactor模型,基于事件驱动的。文件事件处理器使用I/O多路复用程序可以同时监听多个套接字,并且注册感兴趣的事件,包括accept(连接建立事件)、read(读事件)、write(写事件)、close(关闭事件)、slaveof(主从复制)等
Redis单进程线程的架构意思是:从网络IO到实际处理读写事件、时间事件等都是有单个线程基于I/O复用完成的。并不是整个redis中只有一个主线程和单一进程。
Redis 6支持多线程技术,仅针对处理网络请求的过程采用了多线程,而数据的读写命令仍然采用单线程进行处理。这里使用多线程IO的原因是在等待网络IO的时候最大化利用CPU资源。
多路复用的IO模型,处理网络请求的时候,select()调用是阻塞的。如果并发量很高的情况下,可能成为瓶颈。多线程可以利用CPU多核的优势,使得多个线程并行。当select()调用返回的时候,请求依次交给多个线程去处理,充分利用CPU多核的优势。
但是处理事件(执行事件处理器)本身是很快的,不存在CPU瓶颈。而且可以避免线程安全问题。
虽然多线程模型执行读写事件能够提升并发性能,但是引入了多线程会使得程序的执行具有不确定性,还会造成额外的切换开销
redis基于内存数据库,本身在执行上不存在CPU瓶颈。如果采用多线程反而会增加上下文切换带来的开销,以及线程安全问题,为程序的执行带来不确定性。redis采用I/O多路复用模型,使得它可以同时响应多个事件,这极大地提升了I/O利用率。另外redis的对象底层根据不同的场景,会使用不同的数据结构进行实现,优化了性能。
redis真正的瓶颈在于网络带宽和机器内存大小。
(redis4.0也支持了多线程技术,主要是用于后台处理包括对象回收、过期键回收等redis服务器部分的时间事件的功能)
文件事件
文件事件处理器由四个部分组成:套接字、I/O多路复用程序、文件事件分派器(dispatcher)、事件处理器(controller)
【1】redis客户端与redis服务器建立连接后,redis服务器就会在内存中维护一个redis客户端的对象,并且将redis客户端的套接字注册在I/O多路复用程序上
【2】I/O多路复用程序监听这些套接字,一旦有感兴趣的事件发生,就会传输产生了事件的套接字给文件事件分派器(他们通过队列通信,I/O复用程序将套接字同步有序的放入队列,而分派器则从队列中取出,类似基于生产者消费者模型的阻塞队列)
【3】分派器根据文件事件的种类,调用相应的事件处理器接口(事件处理函数,例如连接应答、命令请求、命令回复)(可以类比springMVC中的dispatcherSerlet和controller)
其中I/O多路复用程序底层依赖/O复用类库如select、epoll等。
时间事件
时间事件主要分为定时事件和周期事件,一个事件是定时事件还是周期事件取决于时间事件处理器的返回值(redis内部定义的常量值)。服务器会将所有的时间事件存入一个无序的链表。当时间事件执行器运行的时候,他就遍历整个链表,找到所有已达到的时间事件,并调用相应的事件处理器。redis作为内存数据库,遍历链表的操作是可以达到常量级别的。
redis服务器需要定时对自身的资源和状态进行检查和调整,这些定时操作由serverCron函数负责,包括定期清理过期键值对、更新记账信息、与从服务器定期同步、定期持久化操作等。正常模式下,redis服务器只运行serverCron一个时间事件,并且是周期事件。
serverCron默认每隔100毫秒执行一次,负责管理服务器的资源。包括更新LRU时钟、更新服务器每秒执行命令的次数、更新服务器内存峰值记录、处理kill (15即sig term) 的信号、检查持久化操作的运行状态。
serverCron函数每次执行的时候,都会调用clientsCron函数管理客户端资源,以及调用databasesCron函数,管理数据库资源
文件事件和时间时间都是原子、有序、同步地执行的,它们都会尽可能少地减少程序的阻塞时间,并且在有需要的时候主动让出执行权。如果执行时间、数据大小超过预设的阈值,通常会留在下一轮事件循环中执行。它们之间是合作关系,服务器会轮流执行这两个事件,并且执行过程不会互相抢占。
一次请求过程
【1】当redis客户端向redis服务器发送一个命令请求时,这个命令会以某种格式(自解释协议),通过连接传输到对端的套接字。当命令成功传输到对端后,服务器对应该客户端的连接套接字变得可读,该套接字经由分派器转发到对应的读处理器进行处理——将请求保存到输入缓冲区,将请求命令与参数进行解析并保存到客户端状态中。
客户端的命令传输到服务器后,服务器将命令保存在客户端状态的输入缓冲区,并按照协议解析出命令和命令参数。服务器会搜索命令表(一个KV结构,命令标识->命令实现函数),并找到为命令的抽象出的对象RedisCommand(结构体),并且将保存在服务器的客户端状态中的cmd属性指向RedisCommand对象。接着服务器就可以执行这个命令请求,并向客户端返回结果。
【2】调用命令执行器的接口,执行命令。命令执行器根据请求的命令标识在命令表中查找命令对象RedisCommand,并且保存到客户端状态的cmd属性(RedisClient对象的RedisCommand成员cmd指向目标RedisCommand对象)。在命令正式执行之前还会做一些校验工作包括检查执行权限、参数校验、检查内存空间等。
【3】RedisCommand的proc属性是一个函数指针,指向命令的实现函数。服务器执行命令就是回调RedisClient的cmd成员指向的RedisCommand对象的proc回调函数。最终的调用结果将会保存在客户端状态的输出缓冲区。此时连接套接字的状态变为可写,分派器则将套接字分派给命令回复处理器进行处理。(当redis客户端收到命令,它会将信息转换为可读性更高的格式输出到终端)
【4】最后就是一些记账相关的操作,例如记录慢查询日志、保存到AOF缓冲区、命令传播给从服务器、更新RedisCommand对象的执行耗时milliseconds和调用计数器calls属性
数据库
redis服务器将所有的数据库状态都保存在了服务器状态RedisServer的db数组中,每一项都是一个redisDb结构体。每个redis客户端都可以使用服务器的某个数据库,RedisClient的RedisDb类型指针db指向当前数据库,切换数据库就是切换RedisClient的db指向(即select命令)。
RedisDb结构的字典保存了数据库中所有的键值对,这个字典也称为键空间。与数据库的交互本质上是与键空间这个字典进行交互。
通过expire或pexpire(precise)命令,客户端可以以秒或者毫秒精度为数据库中的某个键设置生存时间(TTL)。当到底过期时间后,会达到一个逻辑删除的效果——键不会被立刻删除,而是由定时事件处理器serverCron进行异步删除,但是在获取的时候会“视而不见”。
通过TTL或PTTL命令可以查询这个键还有多少时间过期。
setex time就是将set k v 和 expire k time 合成了一个原子命令,但是只能对字符串使用。
RedisDb结构的expire字典保存了数据库中所有键的过期时间,称为过期字典。键空间的键和过期字典的键都是指针类型,他们共同指向同一个对象(RedisObject结构体),因此不会造成空间的浪费。
当expire命令执行成功后,数据库中的目标键就会被关联上一个过期时间。(persist命令可以解除过期时间,尤其是检测到某些数据是当前热点数据)
过期键删除策略
redis的每个对象都是RedisObject结构体的实例,而删除一个键其实就是将这个对象close掉。
redis支持两种删除过期键的策略。
【1】惰性删除
对内存不友好,因为如果一段时间内不释放内存,那么这些内存是一种内存泄露的表现。对CPU时间友好,因为redis服务器在执行用户任务的同时,顺便释放了无用的内存空间。
惰性删除的引入,其中就是在命令真正执行之前多加了一层判断,判断当前的键是否过期或者存在。
【2】定时删除
对内存有好,但是会在某一时间段内窃取CPU周期,相当于文件事件和定时事件需要竞争CPU周期。
定时删除操作由时间函数serverCron负责,它会在规定的时间内分多次遍历服务器的各个数据库,从数据库的过期字典中随机检查一部分键的过期时间,并删除其中的过期键。
为了保证软实时并没有遍历检查所有的key,而是随机抽取若干个key进行检查,并且删除其中过期的key。这是因为redis可以存储成千上万个key,每次都遍历所有的key是不现实的,更加难以实现软实时。
生成RDB文件的时候,已经过期的键不会被保存到新建的RDB文件中。如果服务器开启了AOF功能,但是写入AOF文件后这个键才过期,程序会向AOF文件追加一条DEL命令来显示删除目标键。已经过期的键也不会被保存到重写后的AOF文件。
当启动服务器并载入RDB文件的时候,如果是主服务器,RDB文件中过期的键会被直接忽略。如果服务器以从服务器的模式运行,载入RDB文件的时候过期的键也会被保存到数据库中,不过在数据同步的时候,过期键就会被清除掉。(命令传播阶段会收到主服务器传递的DEL命令)
在一主多从模式下,无法达到实时一致性,只能达到最终一致性。这是C(一致性)A(可用性)P(分区容错性)选择后两者的权衡
内存淘汰策略
不管是惰性删除还是定时删除,都无法精确的删除所有过期键,并且不考虑过期键,redis本身也是有上限的(在开始maxMemory之后)。因此必须考虑内存淘汰(过期键导致的内存泄露以及内存不够用导致的内存溢出)的场景。
内存淘汰策略基本就是各种“删除类型”以及“删除算法”的组合
首先,默认的策略就是内存超过maxMemory的时候直接抛出异常/返回错误。
删除范围包括:所有键allkeys、配置了过期时间的键volatile。
删除算法包括:LRU最近的长时间未使用的、LFU最近的使用频率最小的、ttl马上过期的、random随机删除
持久化
redis的数据库状态使用RedisDb结构体定义,RedisServer维护RedisDb的数组,为了将数据库状态保存起来可以使用redis的持久化功能。
RDB
RDB持久化是默认开启的,是采用快照的方式将数据库状态在某一个时间点的数据进行截取,并保存仅一个RDB文件。RDB文件是一个经过压缩的二进制文件。
redis有两种方式可以生成RDB文件,save命令会阻塞redis服务器进程。bgSave会fork出一个子进程,然后由子进程负责创建RDB文件。仅仅在fork()调用创建子进程的时候会进行阻塞。
如果数据十分庞大或内存吃紧,以至于fork()创建子进程的代价十分大,可以考虑save
RDB文件的载入工作是在服务器启动的时候自动执行的,只要redis服务器在启动的时候检测到RDB文件的存在,就会自动载入RDB文件。
但是如果服务器开启了AOF功能,会优先使用AOF文件进行数据库状态的还原。只有当AOF功能处于关闭的时候,服务器才会使用RDB文件进行数据库状态的还原。
同一时间内只能执行一个bgsave命令,在某一个bgsave命令执行期间,客户端发送的save和bgsave命令都会被拒绝,因为它们会产生竞争条件,这是从性能的方面做出的考虑——如果并发执行多个子进程,那么它们将同时做出大量磁盘写入操作,降低性能。
服务器在载入RDB期间,会一直处于阻塞状态。
可以通过设置redis.conf的save选项,设定服务器每隔一段时间自动执行的周期
触发机制:
【1】满足save的规则情况下,会自动触发rdb规则
【2】执行flushall也会触发rdb规则
【3】退出Redis,也会产生rdb
bgsave的实现原理:
Redis会单独创建(fork)一个子线程进行持久化,会先将数据写入一个临时文件中,待持久化过程结束了,再使用这个临时文件替换之前的持久化文件。(dump.rdb)
整个过程,主线程是不进行任何IO操作的,这确保了极高的性能。如果需要进行大规模数据的恢复,且对于数据恢复的完整性不是非常敏感,那么RDB更加高效。另外,如果redis意外宕机,可能造成一段时间的数据丢失,同时数据量如果很大,导致需要复制大量数据,fork()可能很耗时。
子进程创建后,和父进程共享数据段、代码段,本质上是将子进程的页面指针指向了父进程的页表,并且将映射目标数据页的页表项标记为只读,当父进程或子进程(即共享了该物理内存/数据页的进程)视图修改这些数据的时候会出现异常导致CPU陷入内核。操作系统将会为子进程分配独立的地址空间,陷入退出后,子进程和父进程的地址空间就是独立的了。
AOF
AOF通过保存redis服务器所执行的写命令来记录数据库状态,类似于mysql binlog的statement格式。被写入redis的命令按照请求协议格式保存,是纯文本格式的,可读性较高(命令传输的时候也是按照这个格式,命令传播一定程度上也通过AOF实现)。
AOF功能默认是关闭的,需要append only yes进行开启。
AOF的实现分为三个部分:
【1】服务器执行完一个(写)命令后,会以协议格式追加到服务器状态(RedisServer)的缓冲区中(就是一个sds字符串)
【2】每一个redis服务器的事件循环的末尾,都会考虑是否将内存缓冲区的内容不同到文件中(每次都会将redis缓冲区的内容复制到操作系统的page cache,但是刷盘事件根据配置文件的参数而定)。
可以在redis.conf配置文件中配置三种选择:每个操作都同步always、每秒everysec(默认)、仅仅复制到操作系统的page cache,刷盘时机取决于操作系统 no。
如果redis突然掉电,需要分情况讨论:如果使用AOF的配置对每一条指令同步磁盘,则不会丢失数据。如果是定时sync,如每N秒syn一次,则最多丢失N秒内的数据。
Redis 是先执行写操作,在将记录保存在AOF日志中的,好处:
【1】避免额外的检查开销(只有当该命令执行成功时,才会将命令记录在AOF日志中,不需要额外的检查开销,保证AOF日志中的命令一定是正确的)
【2】不会阻塞当前写操作命令的执行
风险:掉电后,AOF日志可能会丢失至少一次事件循环的操作
当载入AOF持久化文件的时候,会创建一个本地客户端(伪客户端),伪客户端从本地AOF文件中读取指令并发送给redis服务器执行命令。
AOF由于记录的是命令,并且是基于文本格式的,为了防止AOF文件体积膨胀过大的问题(本质上是为了缩短数据恢复的时间),redis提供了AOF文件重写的功能。redis会创建一个新的AOF文件去替换原来的文件,并且体积小很多。该功能通过读取当前服务器状态进行实现——从数据库中读取键值,使用一条命令去记录键值对,替代原来的记录该键值的多条命令。
一般常用AOF后台重写的功能,通过为了保证当前数据库状态和重写后的AOF文件保存的数据库状态是一致的,redis额外设置了AOF重写缓冲区,服务器创建子进程之后开始使用。
当redis服务器执行完一个写命令之后,他会同时将这个命令发送给AOF缓冲区和AOF重写缓冲区
,当AOF重写任务执行完毕后,子进程向父进程发送信号,父进程调用事件处理函数,将AOF重写缓冲区的内容保存进一个新的AOF文件中,并原子地覆盖旧的AOF文件。
重写缓冲区不是一次申请好的,而是边用边申请的,当无法继续申请时打印一条日志后进程退出,
为什么bgsave和AOF重写日志的时候通常创建子进程,而不是子线程
因为如果创建的是线程,多个线程之间会共享内存,那么访问执行快照生成或AOF缓冲区访问的时候必须先申请锁(如果主线程要执行写,还可能会被阻塞),这会降低性能。
而采用子进程时,基于COW,子进程即可以不加锁地读取原副本,一旦父进程的主线程(进程)执行写操作,生成独立数据副本,减少锁的开销。
总结
RDB持久化本质上是数据快照,而AOF持久化本质上是记录增量指令。
RDB适合做冷备份和灾难恢复 ,它会生成多个数据文件,每个数据文件代表某一时刻redis里面的数据。如果服务挂了,可以拷贝前若干分钟的数据。同时,RDB对redis的性能影响非常小,因为同步数据的时候,redis会fork一个子进程进行持久化,而它在进行大数据量恢复的情况下,速度也快于AOF。
由于RDB是快照文件,不宜频繁的生成,默认五分钟生成一次快照文件。而且如果生成的文件很大可能会使得客户端的正常请求收到影响。
AOF比RDB更加可靠,默认AOF一秒一次(everysec)去通过一个后台线程fsync操作,最多丢失一秒钟的数据,而且AOF文件的可读性更高。性能上,AOF将每个增量命令追加到内存缓冲区,并通过异步的方式进行磁盘文件同步,不会造成主线程的阻塞。适合进行热备份
但是,同样的数据,AOF文件往往比RDB更加大,加载持久化文件的时候执行速度也慢一些。AOF开启后,redis的QPS(每秒查询次数)比基于RDB持久化更低,因为每秒都需要额外去异步刷新日志,相当于窃取了CPU时间。
两种机制全部开启的时候,redis在重启的时候会默认使用AOF去重新构建数据,因为AOF的数据比RDB更加完整
选择:先使用RDB数据恢复(快),然后使用AOF做数据补全(出事瞬间,数据丢失少)
RDB做镜像全量持久化,AOF做增量持久化。RDB会耗费较长时间,不够实时,而且宕机时可能丢失大量数据,需要AOF配合。
在redis实例重启的时候,会使用RDB持久化文件构建基础的数据库状态,然后使用AOF重放最近的操作指令,来完整恢复重启之前的状态。
redis事务
redis事务更像是一个打包的命令,把一组客户端的命令进行打包,然后放入队列,再进行执行。执行期间依次顺序执行命令,每条指令是独立的,单条指令出错不会影响其他指令。打包后的命令可以看作一条指令,因此执行“队列”中的命令时,和其他单条命令或打包命令串行执行。
开启事务对应multi命令(感觉是声明多条指令),然后客户端再发送指令后,这些指令不会立即执行,而是入队(每天指令都需要和服务器进行交互),当调用exec时才会真正执行任务。使用discard会舍弃任务。redis事务不支持回滚。
127.0.0.1:6379> multi
OK
127.0.0.1:6379> set k1 v1
QUEUED
127.0.0.1:6379> set k2 v2
QUEUED
127.0.0.1:6379> exec
1) OK
2) OK
如果代码出现语法问题,那么事务中的所有命令都不会执行,如果运行时出现错误,那么出错的指令不会影响其他的指令。这是一部分人或书籍对redis原子性的解释,但是这些“这些不是应该的吗?”,我认为redis没有原子性。
redis事务还有watch和unwatch指令,在exec命令执行之前,会监视任意数量的数据库键,如果在exec执行时,至少一个被监视的键值被修改,那么服务器拒绝执行队列中的命令。
每个redis数据库(RedisServer的db成员结构体)都会保存一个watched_key字典,如果某个客户端对某个key进行watch调用,那么表示该客户端的对象将被加入到watched_key对应key所在的链表。
一旦某个key被修改,那么redis服务器依次将对应客户端的redis_dirty_cas成员置位,标识事务已经破坏,如果该客户端向服务器发送exec指令,那么服务器检查该标志后将拒绝执行命令。
对redis事务ACID的思考
Redis的事务和关系型数据库的事务不一样,它不保证原子性,也不支持回滚,也没有隔离级别的概念。
【1】没有隔离级别
隔离性在关系型数据库中,指的是即使数据库中有多个事务并发执行,各个事务之间也不会互相影响,并发状态下执行的事务和串行执行的事务产生的结果完全相同。但是关系式数据库考虑到并发性能,并没有使用统一的串行化,而是根据隔离程度划分了隔离级别。
事务期间的命令放入队列,但是没有执行,只有当提交的时候命令才按照顺序执行。也就是说redis的事务本质上是将一组指令入队,并且这组指令是一次性执行完的,但是提交之前仍然是会执行其他指令的。并且如果存在其他的事务(或者说打包命令),那么他们是串行化被执行的。
这里强调的至少“打包”命令的执行与其他命令的执行是串行的,但是“入队”的过程,其他命令是会被立刻执行的、
【2】残缺的原子性
队列中指令的执行是一次性执行完的,中途不会执行其他客户端的命令。但是一旦出现错误是不提供回滚的。也就是说不能保证“要么成功要么失败”,即使事务队列中的某个命令在执行期间出现了错误,整个事务也会继续执行下去,直到将队列中的命令执行完毕。
【3】几乎没有持久性
如果没有开启持久化则不具有持久性。如果在RDB模式下运行,也不具备(因为生成RDB文件总是和时间事件有关,是异步的。包括非always的AOF也无法保证持久性)。只有当基于always的AOF模式下,才能保证持久性(也可以考虑提交事务前,在队列中加入一个save命令,不过比较低效)
【4】没有一致性的概念
一致性应该是建立在以上三个性质的,即使书上说它可以通过错误检测和简单设计保证一致性,但是我认为它的一致性还是比较残缺的。这取决于它原子性的缺失。另一方面,关系式数据库的一致性还依赖应用层去进行约束数据的正确性,但是redis更多的是作为一个缓存区使用,没有所谓的“约束”。
总结
redis的事务更像是一个命令打包、一次批量执行的命令,并且在执行的过程中不会转去执行其他命令。命令入队的时候总是需要和服务器进行交换,增加带宽。
如果我们真正想要去一次性执行多个redis命令,应该将redis多条命令封装为一个lua脚本即多条指令变成一条指令去执行。
redis事务本质上还是多条指令包括开启、入队和执行,这期间是会被其他客户端影响的。而且lua脚本可以做到redis事务做不到的事——使用逻辑关系运算如if/else等。(不过使用该方式也是不能支持回滚的,除非自己写补偿的代码)
使用LUA脚本的好处:
【1】相对redis事务,减少请求的带宽
【2】原子操作,redis将脚本作为一个整体执行,中间不会被其他客户端的命令进行插入。(凡是多条redis需要捆绑在一起执行的原子操作,都需要使用LUA脚本进行实现)
【3】脚本相当于是一个存储过程,可以被复用
换一种思路思考的话,redis只是完成了“它所指代”的事务。
【1】redis认为的原子性——事务队列中的每个指令是独立的,每个指令要么成功,要么失败,整体的执行是不被中断的
【2】一定程度上的持久性
【3】每个“队列命令”之间的执行都是串行化的,即他们之间的执行是隔离的。(没有隔离级别概念,因为总是串行化执行的)