过期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
,使用以下代码即可实现:
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是如何知道哪些键被修改的呢? —改动方主动通知
所有对数据库进行修改的命令,比如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之前 :
使用pipeline之后:
而且如果这些命令之间执行结果不会相互影响,那么pipeline还可以通过改变指令的执行顺序来合并相关指令,进而进一步提升效率.
例如原先的指令顺序:
经过优化合并后,可以视作只有一个write和一个read :
实现
pipeline有两种实现方法:
- 用multi和exec包裹起这些命令,即使它们没有事务的需求.
- 像事务一样收集起这些命令,等到命令收集完毕后再一起发送给服务器.这种方法效率更高,可以合并指令.