过期key的删除策略

Redis所有的键都可以设置过期时间.

思考一个问题,所有到期的键都一定会被删除吗?

假设同一时间内redis上有大量的key一起过期,那么这段时间redis用于删除key的时间就会太长,会严重影响到对客户端的请求处理.

所以答案是 : 不会.Redis对于过期的key有两种删除策略,定时扫描(主动)和惰性删除(被动)

而对于外部而言,redis过期的键是否删除是不确定的,但如果外部主动去观察(查询)的话,那么过期的键一定会被删除,是不是很像薛定谔的猫呢?

过期的key集合

设置了过期时间的key,redis会把它们放到一个独立的字典里去,方便删除时的遍历.

两种策略

定时扫描删除(主动)

Redis默认会每秒进行十次过期扫描,考虑到效率的问题,并不会扫描过期字典里全部的key,而是采用了一种简单的贪心策略:

  • 从过期字典中随机20个key.
  • 删除这20个key中已经过期的.
  • 如果过期的key比率超过25%,那就重复第一步.
  • 如果本次扫描用时超过25ms,则停止扫描.

可以看到,Redis在过期扫描这里,为了不过分阻塞客户端的请求,在回收过期key的量和时间上都做了限制.有没有觉得,这里和JVM的G1收集器设计很像呢?

不过即便如此,如果每次扫描都达到了时间上限,那么每秒就会有250ms,也就是25%的时间用于回收key,还是会有很大的性能影响.因此要注意合理设置key的过期时间,防止同时过期.

惰性删除(被动)

在客户端访问key的时候,redis也会检查它的过期时间,如果过期了就立即删除.

定时删除是集中,主动处理,而惰性删除是零散,被动处理.

AOF与RDB对过期key的处理

RDB

  • 创建RDB文件时,会对数据库的过期键检查,过期的key不会写入RDB文件里(保留一个疑问,还不确定检查的同时会不会一起把数据库的过期key也删了).
  • 而载入RDB文件时,也同样会检查,过期的key会被忽略.也就是说,无论是写入还是读取RDB文件,都不会存在过期的key.

AOF

  • 写入AOF文件时,由于AOF保留的是命令集,键被删除会有del命令,所以不需要额外检查key是否过期,会保留所有过期但未被删除的key.
  • 载入aof文件和aof重写时也会像RDB一样去检查过期的key.

从库的过期策略

从库不会进行过期扫描,从库对过期key的处理是被动的,由主库统一控制.

主库在key到期时,除了向aof里增加一条del指令,也会同步到所有的从库,从库通过执行这条del指令删除过期的key.

内存淘汰机制

如果有大量过期的key既没有被定时扫描选中,也没有任何客户端去访问它们,那么它们一直待在内存里,会使得内存爆炸.

为了解决这个问题,redis设计了内存淘汰机制. 可以通过配置项maxmemory来设置Redis存储数据时限制的内存大小.例如:

maxmemory 100mb

设置maxmemory为0代表没有内存限制。对于64位的系统这是个默认值,对于32位的系统默认内存限制为3GB。

当达到指定的内存限制大小时,Redis有种内存淘汰策略:

noeviction 返回错误(大部分的写入指令,但DEL和几个例外)
allkeys-lru 尝试回收最少使用的键(LRU)
volatile-lru 尝试回收最少使用的键(LRU),但仅限于在过期集合的键
allkeys-random 回收随机的键
volatile-random 回收随机的键,但仅限于在过期集合的键。
volatile-ttl 回收在过期集合的键,并且优先回收存活时间(TTL)较短的键

如果没有键符合回收条件,那么volatile-xxx就和noeviction的效果一样了.而且为key设置过期时间也会消耗内存,当redis的内存有压力时,这显然不是什么好的选择.

所以一般情况下,allkeys-lru会是更加高效的选择.

del指令

由于过期的key与del指令息息相关,这里也顺便提一下.

删除指令del会直接释放对象的内存,大部分情况下,这个指令非常快,但如果删除的key是非常大的对象,那么实际操作起来可能导致单线程卡顿.

因此,Redis在4.0后引入了unlink指令,它能对删除操作进行懒处理,丢给后台线程来异步回收内存.

而且这并不会引发线程安全问题,一旦unlink指令发出,主线程的其它指令就无法访问这个key了.

可以理解为要删除key的时候,主线程先标记,被标记的key此后无法再被主线程访问,而是交由后台线程去处理.

通信协议

RESP是Redis序列化协议的简写,是文本协议.

Redis协议在以下几点之间做出了折衷:

  • 简单的实现
  • 快速地被计算机解析
  • 简单得可以能被人工解析

作者认为数据库系统的瓶颈一般不在于网络流量,而是数据库自身内部逻辑处理上.所以即使Redis使用了浪费流量的文本协议,依然可以取得极高的访问性.

而且解析性能非常好,我们不需要特殊的redis客户端,仅靠telnet或者是文本流就可以跟redis进行通讯.

客户端的命令格式:

  • 简单字符串 Simple Strings, 以 “+“加号 开头
  • 错误 Errors, 以”-“减号 开头
  • 整数型 Integer, 以 “:” 冒号开头
  • 多行字符串类型 Bulk Strings, 以 “$“美元符号开头
  • 数组类型 Arrays,以 “*“星号开头

例如我们想给服务器发送一个set hello abc,使用以下代码即可实现:

set hello abc

I/O多路复用

前面讲单线程模型的时候提到过, I/O多路复用是提高Redis性能的关键.这部分就来详细介绍一下.

五种I/O模型

在最原始的I/O(BIO,阻塞IO)里,写过socket相关应用的人都知道,调用read()函数时线程是会被阻塞的.

假如客户端开始向服务器发送指令,这期间客户端指令没发完,去做别的准备工作了,服务器没收到结束的信号,就只能傻傻等着,白白浪费CPU的资源.

后来人们为了提高I/O的性能,又相继发明了非阻塞I/O,多路复用I/O,信号驱动I/O和异步I/O等模型.

Redis使用的就是多路复用I/O.

文件事件处理器

Redis里的事件分为文件事件时间事件.

文件事件是对套接字操作的抽象,也就是Redis服务器和客户端通信所产生的事件,比如我们常见的客户端向服务器发送get请求,或者服务器向客户端发送它请求的结果.也可以把文件事件理解为网络事件.

而文件事件处理器就专门处理文件事件,它包括四个部分:

  • 多个套接字(socket)
  • I/O多路复用程序
  • 文件事件分派器
  • 事件处理器

文件事件处理器

其工作原理:

  • 事件包括accept(连接应答),write(写入),read(读取),close(关闭)等,由socket产生.
  • 由于socket一般会有多个,因此多个事件可能会并发地出现.
  • I/O多路复用程序负责监听多个socket(事件轮询),并向文件事件分派器传送那些产生了事件的socket.
  • I/O多路复用程序维护了一个事件队列,产生了事件的socket会进入此队列,每次只会从该队列里传输一个socket给分派器.通过此队列,I/O多路复用程序保证了到达分派器的socket是有序,同步的.
  • 当上一个socket产生的事件被处理完毕后,I/O多路复用程序才会继续传输下一个socket,保证了事件的处理是串行的,避免了线程安全以及竞态问题.
  • 分派器负责把产生了事件的socket分派到对应的事件处理器.

事件队列

注意,由于事件是串行处理的,如果某些事件的处理时间过长, 那么就会阻塞到后面的事件,那么Redis就不快了.所以要避免执行一些像keys这样的指令.

I/O多路复用的实现

前面说过,Redis的I/O多路复用是通过epoll实现的,这其实并不准确.

事实上,Redis包装了常见的select,epoll,evport和kqueue这些I/O多路复用函数库来实现多路复用.

由于操作系统各异,不是每个函数都能在所有系统上执行,所以程序会自动选择在系统中性能最高的函数库来作为底层实现.一般选择的就是epoll了.

关于epoll在性能上领先的地方,这将在讲到网络编程时再详细展开.

多路复用

事务

Redis也有事务,不过按照传统的事务定义ACID来看,redis的事务并不具备ACID的全部特性.

事务相关命令

命令 作用
multi 事务开启的标志,后续的一系列指令都为事务指令,配合exec使用
exec 事务提交/执行的标志,服务器在收到multi命令后,会把后续的命令先入队,但直到收到exec才会一起执行
watch 乐观锁,监视某个key是否在事务提交前被修改了
discard 在exec之前发送,丢弃本次事务.

事务的实现

Redis的事务从开始到结束通常会经历三个阶段:

  • 事务开始 — multi
  • 命令入队
  • 事务执行 — exec

事务开始

multi命令标志着事务的开始.

命令入队

当客户端处于非事务状态时,它发送的命令会被服务器立即执行.

但当客户端处于事务状态时,服务器会根据命令的不同而执行不同的操作:

  • 如果命令为exec,discard,watch,multi中的一个,那么服务器立即执行该命令.
  • 如果命令为其它命令,服务器不会立即执行,而是把命令放入一个事务队列里面,然后向客户端返回queued回复.

事务执行

当服务器收到exec命令时,就会遍历执行这个事务队列的所有命令,最后将执行命令所得结果全部返回给客户端.

是否执行事务

来看一个事务的例子:

client > multi

redis > ok

client > set a 1234

redis > queued

client > get "a"

redis > queued

client > exec

redis > ok

redis > "1234"

乐观锁WATCH

watch命令是一个乐观锁,它可以在exec命令执行之前,监视数据库的任意key.

如果在exec执行时,被watch监视的key至少有一个被修改了,那么服务器将拒绝执行事务,并向客户端返回代表事务执行失败的空回复(nil或null).一般客户端失败后都会选择重试.

下面就是个失败的例子:

watch failed

监视机制的触发

那么,watch是如何知道哪些键被修改的呢? —改动方主动通知

所有对数据库进行修改的命令,比如set,lpush,sadd等,在执行完后都会去查看被修改的key是否被某些客户端监视.若有,则会将监视被修改键的客户端的标志(redis_dirty_cas)打开.

当服务器执行exec时,就会根据该客户端的标志是否打开来决定是否执行事务.

事务是否安全

注意 : Redis禁止在multi和exec之间执行watch指令,否则会报错.

事务的ACID性质

前面我们提到,redis并不具备ACID的全部特性,接下来就逐一分析下.

先上结论,根据<Redis设计与实现>的说法:

redis的事务总能保证原子性,一致性和隔离性,且在aof持久化模式下,并且appendfsync选项的值为always时,事务也具备持久性

实际上,如果你用mysql的事务去看这段话,你会发现除了隔离性和持久性,其余的都是在扯犊子.

原子性

事务具备原子性指的是,数据库将事务中多个操作当作一个整体来执行,服务要么执行事务中所有的操作,要不一个操作也不会执行。

实现原子性的重要前提是能否保证事务可以回滚.

显然redis不具备回滚事务的能力,因为它是先执行,保存完数据才会去写aof日志.如果执行到后面发现有指令执行错误,前面的指令已经持久化了,根本无法回滚.

而mysql是先写日志,边写边执行,结果暂不提交.如果写到后面发现有指令执行错误,就可以根据日志内容进行回滚.

顺便一提,redis之所以不支持事务回滚也是为了性能考虑,者与redis追求简单高效的设计主旨不符.而且作者认为回滚的情况很少会在实际的生产环境中出现,所以也没有去设计的必要.

那为什么书中会认为redis具有原子性呢?

因为redis把指令的错误分为两种:

  • 入队错误 : 指令语法的错误,例如不存在的指令,或者缺失必要参数的指令.在入队的时候就可以检查出来.
  • 执行错误 : 一般是数据类型的错误,例如对list的键执行了hash的操作.入队的时候无法判断,错误只能在执行时才能暴露出来.

如果是入队错误:

入队错误

例如提交了一个不存在的"YAHOOOO"指令,那么redis在入队的时候能检查出来,然后整个事务都会被拒绝执行.

作者认为这种情况体现了"原子性”.

但如果错误到了执行阶段才暴露出来,redis无法回滚已经执行的命令,并且会继续执行后续可执行的命令.也就是到了执行阶段,所有命令都会被执行,执行失败的命令不回影响到其他命令的正常执行.

一致性

对于事务的理解不同,一致性的定义也有两种版本.

版本1

如果数据库在事务开始之前是完整的,那么事务结束以后数据库的完整性也没有被破坏。

版本2

事务使得系统从一个正确的状态到另一个正确的状态

这里的完整性具体是指,数据库是否存在会持续报错的缺陷,例如,存在两个同名的表,又或者类型是int的list却存进了几个字符串.

书中作者采用了第一种定义,只关注数据库的状态.

作者认为对于redis的错误操作都会在入队阶段或者执行阶段被检查出来,从而被拒绝执行;

而当发生宕机的情况后,redis也能通过aof或者rdb文件进行还原,因此事务的一致性可以得到保证.

但是在传统的ACID概念中,一致性不单单要保证数据库的正确状态,而且在逻辑上也是要正确的.

来看著名的转账问题:

A转账给B分为两步:

  • A减少10块钱
  • B增加10块钱.

因为redis不支持回滚,那么当A的钱减少了之后,系统错误,B的钱没有变化,那么A的钱就白白减少了.这在逻辑上是不正确的.

隔离性和持久性就不单独讨论了,没啥问题.

从本质上来说,ACID中的AID都是手段/过程,C才是事务的最终目的.也就是说我们是通过事务的原子性,隔离性,持久性来保证一致性.

redis无法保证原子性,自然也无法保证一致性.

结论

说了一大堆,并不是想批判redis不遵守事务的规范,从redis的设计思想就可以看出它无意实现传统意义上的ACID,只是想提醒各位使用redis的小伙伴 : redis中的事务和传统意义上的事务还是有很大区别的,使用的时候要注意这点,不要到时候因为无法回滚事务而搞出大问题.

Pipeline

pipeline也就是管道,或者说流水线,使用了这条命令后,客户端可以把一连串指令打包,然后一起发送给服务器.

这可以提高客户端与服务器之间通信的效率.原本需要多次通信,使用管道后只需通信一次.

使用pipeline之前 :

beforepipeline

使用pipeline之后:

afterpipeline

而且如果这些命令之间执行结果不会相互影响,那么pipeline还可以通过改变指令的执行顺序来合并相关指令,进而进一步提升效率.

例如原先的指令顺序:

指令顺序

经过优化合并后,可以视作只有一个write和一个read :

指令顺序优化

实现

pipeline有两种实现方法:

  • 用multi和exec包裹起这些命令,即使它们没有事务的需求.
  • 像事务一样收集起这些命令,等到命令收集完毕后再一起发送给服务器.这种方法效率更高,可以合并指令.