绿色健康小清新

耐得住寂寞,守得住繁华

Redis原理和源码解析

Redis简介

简单介绍

Redis:开源、免费、非关系型数据库、K-V数据库、内存数据库,支持持久化、事务和备份,集群(支持16个库)等高可用功能。并且性能极高(可以达到100000+的QPS),易扩展,丰富的数据类型,所有操作都是单线程,原子性的。

NOSQL:非关系型数据库,数据与数据之间没有关联关系。就是为了解决大规模数据集合多重数据种类带来的挑战,尤其是大数据应用难题

类型

  1. 键值(key-value)存储数据库
  2. 列存储数据库:键仍然存在,但是指向了多个列,HBase (eg:博客平台(标签和文章),日志)
  3. 文档型数据库 MongoDb (eg:淘宝商品的评价)
  4. 图形数据库 Neo4j (eg:好友列表)

扩展:

MongoDB是一个基于分布式文件存储的数据库。有C++语言编写。旨在为WEB应用提供可扩展的高性能数据存储解决方案。

MongoDB是一个介于关系型数据库和非关系型数据库之间的产品,是非关系数据库当中功能最丰富,最像关系型数据库的。

文档(document)是MongoDB中数据的基本单元,非常类似于关系型数据库系统中的行(但是比行要复杂的多);
集合(collection)就是一组文档,如果说MongoDB中的文档类似于关系型数据库中的行,那么集合就如同表;

使用场景:

  1. 数据模型比较简单
  2. 需要灵活更强的IT系统
  3. 对数据库性能要求比较高
  4. 不需要高度的数据一致性
  5. 对于给定的key,比较容易映射复杂值的环境

SQL:关系型数据库,表与表之间建立关联关系

redis的安装

1
2
3
4
#拉取镜像
docker pull redis
#挂载数据卷并运行容器
docker run -p 6379:6379 --name redis -v /root/redis/data:/data -v /root/redis/conf/redis.conf:/usr/local/etc/redis/redis.conf -d redis redis-server /usr/local/etc/redis/redis.conf --appendonly yes --requirepass "xxx"

为什么使用NOSQL

单机 MySQL 的美好时代

在90年代,一个网站的访问量一般都不大,用单个数据库完全可以轻松应付。在那个时候,更多的都是静态网页,动态交互类型的网站不多。

创建

DAL : Data Access Layer(数据访问层 – Hibernate,MyBatis)

上述架构下,我们来看看数据存储的瓶颈是什么?

  1. 数据量的总大小一个机器放不下时。
  2. 数据的索引(B+ Tree)一个机器的内存放不下时。
  3. 访问量(读写混合)一个实例不能承受。

如果满足了上述1 or 3个时,只能对数据库的整体架构进行重构。


Memcached(缓存)+MySQL+垂直拆分

关于垂直拆分和水平拆分的简单介绍

后来,随着访问量的上升,几乎大部分使用MySQL架构的网站在数据库上都开始出现了性能问题,web程序不再仅仅专注在功能上,同时也在追求性能。程序员们开始大量的使用缓存技术来缓解数据库的压力,优化数据库的结构和索引。开始比较流行的是通过文件缓存来缓解数据库压力,但是当访问量继续增大的时候,多台web机器通过文件缓存不能共享,大量的小文件缓存也带了了比较高的IO压力。在这个时候,Memcached就自然的成为一个非常时尚的技术产品。

Memcached作为一个独立的分布式的缓存服务器,为多个web服务器提供了一个共享的高性能缓存服务,在Memcached服务器上,又发展了根据hash算法来进行多台Memcached缓存服务的扩展,然后又出现了一致性hash来解决增加或减少缓存服务器导致重新hash带来的大量缓存失效的弊端。

Mysql主从读写分离

由于数据库的写入压力增加,Memcached只能缓解数据库的读取压力。读写集中在一个数据库上让数据库不堪重负,大部分网站开始使用主从复制技术来达到读写分离,以提高读写性能和读库的可扩展性。Mysql的master-slave模式成为这个时候的网站标配了。

分库分表+水平拆分+mysql集群

在Memcached的高速缓存,MySQL的主从复制,读写分离的基础之上,这时MySQL主库的写压力开始出现瓶颈,而数据量的持续猛增,由于MyISAM在写数据的时候会使用表锁,在高并发写数据的情况下会出现严重的锁问题,大量的高并发MySQL应用开始使用InnoDB引擎代替MyISAM。

ps:这就是为什么 MySQL 在 5.6 版本之后使用 InnoDB 做为默认存储引擎的原因 – MyISAM 写会锁表,InnoDB 有行锁,,并且是事务优先,发生冲突的几率低,并发性能高。

注意锁的几个概念:行锁和表锁,读锁和写锁,乐观锁和悲观锁,还有一个间隙锁

详情请看锁的介绍

四种NoSQL对比

Nosql对比

3V+3高

1
2
3
4
5
6
7
8
9
10
11
12
graph TB
subgraph 互联网需求的3高
3h((3高))-->h1[高并发]
3h-->h2[高可用]
3h-->h3[高性能]
end

subgraph 大数据时代的3V
3v((3v))-->V1[海量Volumn]
3v-->v2[多样Variety]
3v-->v3[实时Velocity]
end

ACID

事务是由一组SQL语句组成的逻辑处理单元,事务具有4属性,通常称为事务的ACID属性。

  • 原子性(Actomicity):事务是一个原子操作单元,其对数据的修改,要么全都执行,要么全都不执行。

  • 一致性(Consistent):在事务开始和完成时,数据都必须保持一致状态。这意味着所有相关的数据规则都必须应用于事务的修改,以操持完整性;事务结束时,所有的内部数据结构(如B树索引或双向链表)也都必须是正确的。

    eg:有3个人进行转账操作,为了保证一致性(即3个人 的账号金额总数不变),那在我写代码的时候,如果写了代码:A=A-5000;此时数据时不一致的。那就必须要写上,B=B+5000,或者是C=C+5000,这样的代码才能保证了数据库的一致性状态。

  • 隔离性(Isolation):数据库系统提供一定的隔离机制,保证事务在不受外部并发操作影响的“独立”环境执行。这意味着事务处理过程中的中间状态对外部是不可见的,反之亦然。具体看下面的几个隔离级别和并发问题。

  • 持久性(Durable):事务完成之后,它对于数据的修改是永久性的,即使出现系统故障也能够保持

CAP

C:consistency,数据在多个副本中能保持一致的状态。

A:Availability,整个系统在任何时刻都能提供可用的服务,通常达到99.99%四个九可以称为高可用

P:Partition tolerance,分区容错性,在分布式中,由于网络的原因无法避免有时候出现数据不一致的情况,系统如果不能在时限内达成数据一致性,就意味着发生了分区的情况,必须就当前操作在C和A之间做出选择,换句话说,系统可以跨网络分区线性的伸缩和扩展。

CAP理论的核心:一个分布式系统不可能同时很好的满足一致性,可用性和分区容错性这三个需求,最多只能同时较好的满足两个

  • CA:单点集群,满足一致性,可用性的系统,通常在可扩展上不太强大。应用:传统的Oracle数据库
  • CP:满足一致性,分区容错性的系统,通常性能不是特别高。应用:Redis,MongoDB,银行
  • AP:满足可用性,分区容错性,通常可能对一致性要求低一些。应用:大多数网站架构的选择

CAP理论就是说在分布式存储系统中,最多只能实现上面的两个。而由于当前的网络硬件肯定会出现延迟丢包等问题。所以

分区容忍性是我们必须需要实现的

所以我们只能在一致性和高可用之间进行权衡,没有NoSQL系统能同时保证三点。为什么呢?

为何CAP三者不可兼得

现在我们就来证明一下,为什么不能同时满足三个特性?

假设有两台服务器,一台放着应用A和数据库V,一台放着应用B和数据库V,他们之间的网络可以互通,也就相当于分布式系统的两个部分。

在满足一致性的时候,两台服务器 N1和N2,一开始两台服务器的数据是一样的,DB0=DB0。在满足可用性的时候,用户不管是请求N1或者N2,都会得到立即响应。在满足分区容错性的情况下,N1和N2有任何一方宕机,或者网络不通的时候,都不会影响N1和N2彼此之间的正常运作。

当用户通过N1中的A应用请求数据更新到服务器DB0后,这时N1中的服务器DB0变为DB1,通过分布式系统的数据同步更新操作,N2服务器中的数据库V0也更新为了DB1,这时,用户通过B向数据库发起请求得到的数据就是即时更新后的数据DB1。

上面是正常运作的情况,但分布式系统中,最大的问题就是网络传输问题,现在假设一种极端情况,N1和N2之间的网络断开了,但我们仍要支持这种网络异常,也就是满足分区容错性,那么这样能不能同时满足一致性和可用性呢?

假设N1和N2之间通信的时候网络突然出现故障,有用户向N1发送数据更新请求,那N1中的数据DB0将被更新为DB1,由于网络是断开的,N2中的数据库仍旧是DB0;

如果这个时候,有用户向N2发送数据读取请求,由于数据还没有进行同步,应用程序没办法立即给用户返回最新的数据DB1,怎么办呢?有二种选择,第一,牺牲数据一致性,响应旧的数据DB0给用户;第二,牺牲可用性,阻塞等待,直到网络连接恢复,数据更新操作完成之后,再给用户响应最新的数据DB1。

上面的过程比较简单,但也说明了要满足分区容错性的分布式系统,只能在一致性和可用性两者中,选择其中一个。也就是说分布式系统不可能同时满足三个特性。这就需要我们在搭建系统时进行取舍了。

Base

Base就是为了解决关系型数据库强一致性引起的问题而引起的可用性降低而提出的解决方案。

Base其实是下面三个术语的缩写:

  • 基本可用(Basically Available)
  • 软状态(Soft state)状态可以有一段时间不同步
  • 最终一致(Eventually consistent)最终数据是一致的就可以了,而不是时时保持强一致

它的思想是通过让系统放松对某一时刻数据一致性的要求来换取系统整体伸缩性和性能上改观。为什么这么说呢,缘由就在于大型系统往往由于地域分布和极高性能的要求,不可能采用分布式事务来完成这些指标,要想获得这些指标,我们必须采用另外一种方式来完成,这里BASE就是解决这个问题的办法。

案例转账为例,我们把用户A给用户B转账分成四个阶段,第一个阶段用户A准备转账,第二个阶段从用户A账户扣减余额,第三个阶段对用户B增加余额,第四个阶段完成转账。系统需要记录操作过程中每一步骤的状态,一旦系统出现故障,系统能够自动发现没有完成的任务,然后,根据任务所处的状态,继续执行任务,最终完成任务,达到一致的最终状态。

在实际应用中,上面这个过程通常是通过持久化执行任务的状态和环境信息,一旦出现问题,定时任务会捞取未执行完的任务,继续未执行完的任务,直到执行完成为止,或者取消已经完成的部分操作回到原始状态。这种方法在任务完成每个阶段的时候,都要更新数据库中任务的状态,这在大规模高并发系统中不会有太好的性能,一个更好的办法是用Write-Ahead Log(写前日志),这和数据库的Bin Log(操作日志)相似,在做每一个操作步骤,都先写入日志,如果操作遇到问题而停止的时候,可以读取日志按照步骤进行恢复,并且继续执行未完成的工作,最后达到一致。写前日志可以利用机械硬盘的追加写而达到较好性能,因此,这是一种专业化的实现方式,多数业务系系统还是使用数据库记录的字段来记录任务的执行状态,也就是记录中间的“软状态”,一个任务的状态流转一般可以通过数据库的行级锁来实现,这比使用Write-Ahead Log实现更简单、更快速。

分布式和集群

分布式:不同的多台服务器上面部署不同的服务模块(工程)

集群:不同的多台服务器上面部署相同的服务模块。通过分布式调度软件进行统一的调度,对外提供服务和访问。

Redis的数据类型

Redis六种数据类型:string、hash、list、set、zset、stream

公用命令

  • del key
  • dump key:序列化给定key,返回被序列化的值
  • exists key:检查key是否存在
  • expire key second:为key设定过期时间,以秒计算,可以不写second,默认为秒
  • ttl key:返回key剩余时间,-1为永久,-2为失效
  • persist key:移除key的过期时间,key将持久保存
  • keys pattern:查询所有符号给定模式的key eg:keys *
  • randomkey:随机返回一个key
  • rename key newkey:修改key的名称
  • move key db:移动key至指定数据库中 eg:move a 1
  • type key:返回key所储存的值的类型

expirekey second的使用场景
1、限时的优惠活动
2、网站数据缓存
3、手机验证码
4、限制网站访客频率

key的命名建议

  1. key不要太长,尽量不要超过1024字节。不仅消耗内存,也会降低查找的效率
  2. key不要太短,太短可读性会降低
  3. 在一个项目中,key最好使用统一的命名模式,如user:123:password
  4. key区分大小写

String

string 数据结构是简单的 key-value 类型。虽说是String类型,但是其value值在底层存储可以是数字类型和字符串类型,它的编码格式有三种:int,raw,embstr。

  • set key_name value:命令不区分大小写,但是key_name区分大小写
  • setnx key value:当key不存在时设置key的值。(SET if Not eXists),分布式锁的问题
  • setex:创建一个key,并且设置他的过期时间
  • get key_name
  • getrange key start end:获取key中字符串的子字符串,从start开始,end结束
  • setrange key offset value:设置从offset往后的值
  • mget key1 [key2 …]:获取多个key
  • getset key_name value:返回key的旧值,并设定key的值。当key不存在,返回nil
  • strlen key:返回key所存储的字符串的长度
  • incr key_name :INCR命令key中存储的值+1,如果不存在key,则key中的值话先被初始化为0再加1
  • INCRBY KEY_NAME 增量
  • DECR KEY_NAME:key中的值自减一
  • DECRBY KEY_NAME
  • append key_name value:字符串拼接,追加至末尾,如果不存在,为其赋值

String应用场景
1、String通常用于保存单个字符串或JSON字符串数据
2、因为String是二进制安全的,所以可以把保密要求高的图片文件内容作为字符串来存储
3、计数器:常规Key-Value缓存应用,如微博数、粉丝数。INCR本身就具有原子性特性,所以不会有线程安全问题

hash

Redis hash相当于JDK1.8前的HashMap,是一个数组+链表,每个key值对应一个string类型的field和value的映射表,hash特别适用于存储对象。每个hash可以存储232-1(40亿左右)键值对。可以看成KEY和VALUE的MAP容器。相比于JSON,hash占用很少的内存空间。

常用命令

  • hset key_name field value:为指定的key设定field和value
  • hmset key field value[field1,value1]
  • hsetnx:当不存在才创建该field
  • hget key field
  • hmget key field[field1]
  • hgetall key:返回hash表中所有字段和值
  • hkeys key:获取hash表所有字段
  • hvals key:获取hash表所有值
  • hlen key:获取hash表中的字段数量
  • hdel key field [field1]:删除一个或多个hash表的字段
  • hexists:在key里面是否存在指定的field
  • hincrby key field increment:增加某个field的值

应用场景

Hash的应用场景,通常用来存储一个用户信息的对象数据。

  1. 相比于存储对象的string类型的json串,json串修改单个属性需要将整个值取出来。而hash不需要。
  2. 相比于多个key-value存储对象,hash节省了很多内存空间
  3. 如果hash的属性值被删除完,那么hash的key也会被redis删除

list

list是一个双向链表,两端都可以进行插入和删除。编码格式有两种:ziplist、linkedlist

  • lpush key value1 [value2]:从左侧插入,右边的先出,相当于一个栈

  • eg:lpush list 1 2 3 lrange list 0 -1 输出:3 2 1

  • rpush key value1 [value2]: 从右侧插入,左边的先出

  • eg:rpush list 1 2 3 lrange list 0 -1 输出:1 2 3

  • lpushx key value:从左侧插入值,如果list不存在,则不操作

  • rpushx key value:从右侧插入值,如果list不存在,则不操作

  • llen key:获取列表长度

  • lindex key index:获取指定索引的元素,从零开始

  • lrange key start stop:获取列表指定范围的元素

  • lpop key :从左侧移除第一个元素

  • prop key:移除列表最后一个元素

  • irem:删除指定个数的同一元素

  • eg:irem list 2 3 删掉了集合中的两个三

  • blpop key [key1] timeout:移除并获取列表第一个元素,如果列表没有元素会阻塞列表到等待超时或发现可弹出元素为止

  • brpop key [key1] timeout:移除并获取列表最后一个元素,如果列表没有元素会阻塞列表到等待超时或发现可弹出元素为止

  • ltrim key start stop :对列表进行修改,让列表只保留指定区间的元素,不在指定区间的元素就会被删除

  • eg:list1中元素1 2 3 4 5 ltrim list1 2 3 list1剩余元素:3 4

  • lset key index value :指定索引的值

  • linsert key before|after world value:在列表元素前或则后插入元素

应用场景

  1. 对数据大的集合数据删减、分页

       列表显示、关注列表、粉丝列表、留言评价…分页、热点新闻等

  2. 消息队列
       list通常用来实现一个消息队列,而且可以确保先后顺序,不必像MySQL那样通过order by来排序

补充:

  • rpoplpush list1 list2 移除list1最后一个元素,并将该元素添加到list2并返回此元素
    用此命令可以实现订单下单流程、用户系统登录注册短信等。

性能总结

它是一个字符串链表,left、right都可以插入添加;
如果键不存在,创建新的链表;
如果键已存在,新增内容;
如果值全移除,对应的键也就消失了。
链表的操作无论是头和尾效率都极高,但假如是对中间元素进行操作,效率就很惨淡了。

set

唯一、无序

  • sadd key value1[value2]:向集合添加成员

  • scard key:返回集合成员数

  • smembers key:返回集合中所有成员

  • sismember key member:判断memeber元素是否是集合key成员的成员

  • srandmember key [count]:返回集合中一个或多个随机数

  • srem key member1 [member2]:移除集合中一个或多个成员

  • spop key:移除并返回集合中的一个随机元素

  • smove source destination member:将member元素从source集合移动到destination集合

  • sdiff key1 [key2]:返回给定的第一个集合和其他集合的差集(即在key1中的值而在其他key中找不到)

  • sdiffstore destination key1[key2]:返回给定的第一个集合与其他的集合的差集并存储在destination中

    eg:set1:1 2 3 set2:3 4 5 6 sdiffstore set3 set1 set2 smembers set3 result:1 2

  • sinter key1 [key2]:返回所有集合的交集

  • sunion key1 [key2]:返回所有集合的并集

对两个集合间的数据[计算]进行交集、并集、差集运算
1、以非常方便的实现如共同关注、共同喜好、二度好友等功能。对上面的所有集合操作,你还可以使用不同的命令选择将结果返回给客户端还是存储到一个新的集合中。
2、利用唯一性,可以统计访问网站的所有独立 IP

zset

有序且不重复。每个元素都会关联一个double类型的分数,Redis通过分数进行从小到大的排序。分数可以重复

  • zadd key score1 memeber1

  • zcard key :获取集合中的元素数量

  • zcount key min max 计算在有序集合中指定区间分数的成员数

  • zcount key min max 计算在有序集合中指定区间分数的成员数

  • zrange key start stop 指定输出索引范围内的成员

  • zrangebyscore key min max 指定输出score区间内的成员

  • zrank key member:返回有序集合指定成员的索引

  • zrevrange key start stop :返回有序集中指定区间内的成员,通过索引,分数从高到底

  • zrem key member [member …] 移除有序集合中的一个或多个成员

  • zremrangebyrank key start stop 移除有序集合中给定的索引区间的所有成员(第一名是0)(低到高排序)

  • zremrangebyscore key min max 移除有序集合中给定的分数区间的所有成员

常用于需要根据权重来进行排序的系统,比如弹幕,排行榜:

  1. 如推特可以以发表时间作为score来存储
  2. 存储成绩
  3. 还可以用zset来做带权重的队列,让重要的任务先执行

stream

Stream 实际上是一个具有消息发布/订阅功能的组件,也就常说的消息队列。其实这种类似于 broker/consumer(生产者/消费者)的数据结构很常见,比如 RabbitMQ 消息中间件、Celery 消息中间件,以及 Kafka 分布式消息系统等,而 Redis Stream 正是借鉴了 Kafaka 系统。

Stream 消息队列主要由四部分组成,分别是:消息本身、生产者、消费者和消费组

一个 Stream 队列可以拥有多个消费组,每个消费组中又包含了多个消费者,组内消费者之间存在竞争关系。当某个消费者消费了一条消息时,同组消费者,都不会再次消费这条消息。被消费的消息 ID 会被放入等待处理的 Pending_ids 中。每消费完一条信息,消费组的游标就会向前移动一位,组内消费者就继续去争抢下消息。

  • Stream direction:表示数据流,它是一个消息链,将所有的消息都串起来,每个消息都有一个唯一标识 ID 和对应的消息内容(Message content)。
  • Consumer Group :表示消费组,拥有唯一的组名,使用 XGROUP CREATE 命令创建。一个 Stream 消息链上可以有多个消费组,一个消费组内拥有多个消费者,每一个消费者也有一个唯一的 ID 标识。
  • last_delivered_id :表示消费组游标,每个消费组都会有一个游标 last_delivered_id,任意一个消费者读取了消息都会使游标 last_delivered_id 往前移动。
  • pending_ids :Redis 官方称为 PEL,表示消费者的状态变量,它记录了当前已经被客户端读取的消息 ID,但是这些消息没有被 ACK(确认字符)。如果客户端没有 ACK,那么这个变量中的消息 ID 会越来越多,一旦被某个消息被 ACK,它就开始减少。
命令说明
XADD添加消息到末尾。
XTRIM对 Stream 流进行修剪,限制长度。
XDEL删除指定的消息。
XLEN获取流包含的元素数量,即消息长度。
XRANGE获取消息列表,会自动过滤已经删除的消息。
XREVRANGE反向获取消息列表,ID 从大到小。
XREAD以阻塞或非阻塞方式获取消息列表。
XGROUP CREATE创建消费者组。
XREADGROUP GROUP读取消费者组中的消息。
XACK将消息标记为"已处理"。
XGROUP SETID为消费者组设置新的最后递送消息ID。
XGROUP DELCONSUMER删除消费者。
XGROUP DESTROY删除消费者组。
XPENDING显示待处理消息的相关信息。
XCLAIM转移消息的归属权。
XINFO查看 Stream 流、消费者和消费者组的相关信息。
XINFO GROUPS查看消费者组的信息。
XINFO STREAM查看 Stream 流信息。
XINFO CONSUMERS key group查看组内消费者流信息。

redis的底层原理

redisDb

在源码的server.h中可以看到,dict存储了我们所有的key及其对应的具体redisobject对象

1
2
3
4
5
6
7
8
9
10
typedef struct redisDb {
dict *dict; /* The keyspace for this DB */
dict *expires; /* Timeout of keys with a timeout set 用来存放key的过期时间。*/
dict *blocking_keys; /* Keys with clients waiting for data (BLPOP)* 处于阻塞状态的键和相应的client(主要用于List类型的阻塞操作)/
dict *ready_keys; /* Blocked keys that received a PUSH 准备好数据可以解除阻塞状态的键和相应的client*/
dict *watched_keys; /* WATCHED keys for MULTI/EXEC CAS 被watch命令监控的key和相应client */
int id; /* Database ID 数据库ID标识*/
long long avg_ttl; /* Average TTL, just for stats 数据库内所有键的平均TTL(生存时间) */
list *defrag_later; /* List of key names to attempt to defrag one by one, gradually. */
} redisDb;

而进一步在db.c中可以看到,每一个key值就是一个redisObject,其实就是SDS对象

1
2
3
4
5
6
7
8
9
10
void dbAdd(redisDb *db, robj *key, robj *val) {
sds copy = sdsdup(key->ptr);
int retval = dictAdd(db->dict, copy, val);

serverAssertWithInfo(NULL,key,retval == DICT_OK);
if (val->type == OBJ_LIST ||
val->type == OBJ_ZSET)
signalKeyAsReady(db, key);
if (server.cluster_enabled) slotToKeyAdd(key);
}

dict中存储的数据结构如下:

参考:👉 【Redis源码剖析】 - Redis之数据库redisDb_Fred_的博客-CSDN博客

redisObject

redisObject 是 Redis 类型系统的核心, 数据库中的每个键、值, 以及 Redis 本身处理的参数, 都表示为这种数据类型。比如list,set, hash等redis支持的数据类型,在底层都会以redisObject的方式来存储。redisObject是个结构体:

1
2
3
4
5
6
7
8
9
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
* LFU data (least significant 8 bits frequency
* and most significant 16 bits access time). */
int refcount;
void *ptr;
} robj;

由五个属性组成:

  1. type 表示当前值对象的一个数据类型,由六种取值:string,hash,set,list,zset,stream(Redis 5.x 支持六种数据类型)

  2. enconding 表示当前值对象底层存储的编码格式。Redis 针对每种数据结构都设计有多种编码格式进行数据存储:

    • string:int、raw、embstr
    • hash:ziplist、hashtable
    • list:ziplist、linkedlist
    • set:hashtable、intser
    • zset:skiplist、ziplist
    • stream:stream
  3. lru 这个字段用来记录对象最后一次访问时间,当前时间 - lru时间为空转时长,如果服务器打开了maxmemory选项,并且服务器用于回收内存的算法为volatile-1ru或者allkeys-1ru,那么当服务器占用的内存数超过了maxmemory选项所设置的上限值时,空转时长较高的那部分键会优先被服务器释放,从而回收内存。

  4. refcount 记录了当前对象被引用的次数当 refcount =0时,表示可以安全回收当前对象。

  5. ptr 就是指向真实存储数据的指针。指向真实数据。

其实我的理解就是,不管是什么数据类型,key-value都是存在redisDb的字典中,key值保存在一个sds字符串对象中,value值使用一个redisObject来存储,而根据类型的不同,再考虑使用不同的编码格式,又或者说是不同的数据结构来存储,string类型可以有三种编码格式,而其它类型在存储时可以嵌套string对象。因此最基础的数据也只有两种:字符型和数字型(double,long,int等)。

编码格式

string

int:当字符串能用long类型表达,就会采用int格式进行存储,直接将redisObject中ptr字段的值改为该数字(注:当数值为0-10000时,key会指向预先创建好的共享对象池中的redisObject,而不需要新建redisObject)。如果我们向对象执行了一些命令,使得这个对象保存的不再是整数值,而是一个字符串值,那么字符串对象的编码将从int变为raw

**raw:**如果字符串对象保存的是一个字符串值,并且这个字符串值的长度大于32字节,那么那么字符串对象将使用raw编码的方式来保存这个字符串值,ptr指针指向对应的sdshdr。

**embstr:**如果字符串对象保存的是一个字符串值,并且这个字符串值的长度小于等于32字节,那么字符串对象将使用embstr编码的方式来保存这个字符串值。

embstr编码是专门用于保存短字符串的一种优化编码方式,这种编码和raw编码一样,都使用redisObject结构和sdshdr结构来表示字符串对象,但raw编码会调用两次内存分配函数来分别创建redisobject结构和sdshdr结构,而embstr编码则通过调用一次内存分配函数来分配一块连续的空间,空间中依次包含redisobject和sdshdr两个结构。

embstr编码的字符串对象实际上是只读的,因为Redis没有为embstr编码的字符串对象编写任何相应的修改程序(只有int编码的字符串对象和raw编码的字符串对象有这些程序)。当我们对embstr编码的字符串对象执行任何修改命令时,程序会先将对象的编码从embstr转换成raw,然后再执行修改命令。

好处在于:①只需要分配一次内存和释放一次内存 ②两个对象都保存在一块内存,更能利用缓存带来的优势

最后要说的是,可以用long double类型表示的浮点数在Redis中也是作为字符串值来保存的。如果我们要保存一个浮点数到字符串对象里面,那么程序会先将这个浮点数转换成字符串值,然后再保存转换所得的字符串值。在有需要的时候,程序会将保存在字符串对象里面的字符串值转换回浮点数值,执行某些操作,然后再将执行操作所得的浮点数值转换回字符串值,并继续保存在字符串对象里面。

各种类型的编码方式:

对应源码,位于object.c下,自行体会:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
/* Try to encode a string object in order to save space */
robj *tryObjectEncoding(robj *o) {
long value;
sds s = o->ptr;
size_t len;

/* Make sure this is a string object, the only type we encode
* in this function. Other types use encoded memory efficient
* representations but are handled by the commands implementing
* the type. */
serverAssertWithInfo(NULL,o,o->type == OBJ_STRING);

/* We try some specialized encoding only for objects that are
* RAW or EMBSTR encoded, in other words objects that are still
* in represented by an actually array of chars. */
if (!sdsEncodedObject(o)) return o;

/* It's not safe to encode shared objects: shared objects can be shared
* everywhere in the "object space" of Redis and may end in places where
* they are not handled. We handle them only as values in the keyspace. */
if (o->refcount > 1) return o;

/* Check if we can represent this string as a long integer.
* Note that we are sure that a string larger than 20 chars is not
* representable as a 32 nor 64 bit integer. */
len = sdslen(s);
if (len <= 20 && string2l(s,len,&value)) {
/* This object is encodable as a long. Try to use a shared object.
* Note that we avoid using shared integers when maxmemory is used
* because every object needs to have a private LRU field for the LRU
* algorithm to work well. */
if ((server.maxmemory == 0 ||
!(server.maxmemory_policy & MAXMEMORY_FLAG_NO_SHARED_INTEGERS)) &&
value >= 0 &&
value < OBJ_SHARED_INTEGERS)
{
decrRefCount(o);
incrRefCount(shared.integers[value]);
return shared.integers[value];
} else {
if (o->encoding == OBJ_ENCODING_RAW) {
sdsfree(o->ptr);
o->encoding = OBJ_ENCODING_INT;
o->ptr = (void*) value;
return o;
} else if (o->encoding == OBJ_ENCODING_EMBSTR) {
decrRefCount(o);
return createStringObjectFromLongLongForValue(value);
}
}
}

/* If the string is small and is still RAW encoded,
* try the EMBSTR encoding which is more efficient.
* In this representation the object and the SDS string are allocated
* in the same chunk of memory to save space and cache misses. */
if (len <= OBJ_ENCODING_EMBSTR_SIZE_LIMIT) {
robj *emb;

if (o->encoding == OBJ_ENCODING_EMBSTR) return o;
emb = createEmbeddedStringObject(s,sdslen(s));
decrRefCount(o);
return emb;
}

/* We can't encode the object...
*
* Do the last try, and at least optimize the SDS string inside
* the string object to require little space, in case there
* is more than 10% of free space at the end of the SDS string.
*
* We do that only for relatively large strings as this branch
* is only entered if the length of the string is greater than
* OBJ_ENCODING_EMBSTR_SIZE_LIMIT. */
trimStringObjectIfNeeded(o);

/* Return the original object. */
return o;
}
hash

ziplist:当所有键和值的字符串长度都小于64B且键值对个数小于512个就用ziplist编码。ziplist编码的哈希对象使用压缩列表作为底层实现,每个键值对使用两个紧挨在一起的压缩列表节点来保存,先在列表表尾保存一个键的压缩结点,再保存值的压缩结点。因此:①同一个键值对键在前值在后 ②先添加的键值对在表头方向,后添加的在表尾方向。

hashtable:当一个键或值的字符串长度都大于等于64B或键值对个数大于等于512个就用hashtable编码。hashtable编码的哈希对象使用字典作为底层实现,哈希对象中的每个键值对都使用一个字典键值对来保存。字典的每个键都是一个字符串对象,对象中保存了键值对的键;字典的每个值都是一个字符串对象,对象中保存了健值对的值。

在redis7.0中,hash的编码方式就变成了listpack和hashtable。

list

ziplist: 当所有字符串对象的元素长度都小于64字节且元素个数小于512个就是用ziplist编码。ziplist编码的列表对象使用压缩列表作为底层实现,每个压缩列表节点(entry)保存了一个列表元素

linkedlist: 当有一个字符串对象的元素长度大于等于64字节或者元素个数大于等于512个就用linkedlist编码,linkedlist编码的列表对象使用双端链表作为底层实现,每个双端链表节点(node)都保存了一个字符串对象,而每个字符串对象都保存了一个列表元素。

字符串对象具体的格式为:

在redis3.2之后(包括),list只有一种编码格式:quicklist。

set

**intset:**当集合元素都是整数且元素个数小于512个就用intset编码。intset使用整数集合作为底层实现,所有元素都保存在整数集合中。

**hashtable:**当集合元素有一个不是整数或元素个数大于等于512个就用hashtable编码。hashtable使用字典作为底层实现,字典的每个键都是一个字符串对象,每个字符串对象包含了一个集合元素,而字典的值则全部被设置为NULL。

zset

ziplist:当所有字符串对象的元素长度都小于64字节且元素个数小于128个就使用ziplist编码。ziplist编码的集合对象使用压缩列表作为底层实现,每个集合元素使用两个紧挨在一起的压缩列表节点来保存,第一个节点保存元素的值,而第二个保存元素的分值( score )。压缩列表内的集合元素按分值从小到大进行排序,分值较小的元素被放置在靠近表头的方向,而分值较大的元素则被放置在靠近表尾的方向。

skiplist:当有一个字符串对象的元素长度都大于等于64字节或元素个数大于等于128个就使用skiplist编码。skiplist编码的集合对象使用跳表和字典作为底层实现。

  • 跳表:每个跳跃表节点都保存了一个集合元素,跳跃表节点的ele属性是一个字符串对象,保存了元素的值,而跳跃表节点的score属性则保存了元素的分值。通过这个跳跃表,程序可以对有序集合进行范围型操作,比如ZRANK、ZRANGE等命令就是基于跳跃表API来实现的。
  • 字典:字典为有序集合创建了一个从值到分值的映射,字典中的每个键值对都保存了一个集合元素,字典的键是一个字符串对象,保存了元素的值,而字典的值也是一个字符串对象,保存了元素的分值。通过这个字典,程序可以用O(1)复杂度查找给定值的分数,ZSCORE命令就是根据这一特性实现的,而很多其他有序集合命令都在实现的内部用到了这一特性。

有序集合每个元素的值都是一个字符串对象,而每个元素的分值都是一个double类型的浮点数。值得一提的是,虽然skiplist编码同时使用跳跃表和字典来保存有序集合元素,但这两种数据结构都会通过指针来共享相同元素的值和分数,所以同时使用跳跃表和字典来保存集合元素不会产生任何重复成员或者分值,也不会因此而浪费额外的内存。

上图为了展示没有共享值和分数。

共享对象池

创建大量重复的整数类型势必会耗费大量内存,所以在Redis内部维护了一个从0到9999的整数对象池,这就是共享对象池

1
2
3
4
5
6
7
8
127.0.0.1:6379> set one-more-num1 404
OK
127.0.0.1:6379> object refcount one-more-num1
(integer) 2
172.24.130.22:6379> set one-more-num2 404
OK
127.0.0.1:6379> object refcount one-more-num2
(integer) 3

设置one-more-num1为404后,直接使用共享池中的整数对象,所以引用数为2(另外一个引用在对象池上);再设置one-more-num2为404后,引用数变成了3。

不过需要注意的是:当设置最大内存值(maxmemory)并且启用LRU相关淘汰策略(如:volatile-lru、allkeys-lru)时,共享对象池将会被禁止使用。

为什么没有字符串对象池?

共享对象池中一个关键操作是判断对象是否相等。Redis中只有整数类型的对象池,是因为整数的比较算法的时间复杂度是O(1),也只保留了10000个整数为了防止对象池的过度浪费。相对而言,字符串的比较算法的时间复杂度是O(n),特别是长字符串的比较更加消耗性能。

而且,整数类型被重复使用的概率很大,字符串被重复使用的概率相比就会小很多很多,所以在Redis中只用整数类型的对象共享池。

数据结构

在对于数据的存储中,不同的编码格式可能会使用不同的数据结构,主要由:SDS、链表、字典、跳表、整数结合、压缩列表。

SDS(简单动态字符串)

SDS是Redis中定义的一种用于存储字符串的数据结构。对于 SDS ,Redis 有五种实现方式 SDS_TYPE_5、 SDS_TYPE_8、 SDS_TYPE_16、 SDS_TYPE_32、 SDS_TYPE_64。根据初始化的长度决定使用哪种类型,从而减少内存的使用。(Redis中所有的key都是用SDS来存储)

1
2
3
4
5
6
7
8
9
10
struct __attribute__ ((__packed__)) sdshdr5 {
unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* used */
uint8_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};

sdshdr5中只有两个元素flags和buf数组

sdshdr8以及其它的有四个元素:

  • buf:一个char类型数组,用于存储数据,最后一个字节保存空字节‘\0’
  • len:字符串长度,不包括空字节
  • alloc:字符串可用空间大小,不包括空字节
  • flags:标志位,用于标识sds的类型,目前只用了3位,还有5位空

SDS和C字符串的一个共同点就是字符串末尾都是一个空字节,但是它们有很大的区别:

  1. SDS保存了字符串长度,而C并没有
  2. SDS不会发生缓冲区溢出,C的strcat操作可能会溢出,SDS因为记录了字符串可用空间大小,在修改时会先检查空间是否满足修改的需求,不满足会进行扩容。
  3. SDS减少了内存重分配次数,C每次增长和缩短字符串都会导致内存重分配,因为SDS采用了空间预分配和惰性空间释放,字符串长度变长后不用每次都重新分配空间;字符串缩短后不用立即重分配空间,当我们有需要的时候再释放。
  4. 二进制安全。C字符申中的字符必须符合某种编码(比如ASCII),并且除了字符串的末尾之外,字符串里面不能包含空字符,否则最先被程序读入的空字符将被误认为是字符串结尾,这些限制使得C字符串只能保存文本数据,而不能保存像图片、音频、视频、压縮文件这样的二进制数据。而SDS中使用一个字段来保存了字符串的长度,因此SDS API都会以处理二进制的方式来处理SDS存放在buf数组里的数据,程序不会对其中的数据做任何限制、过滤、或者假设,数据在写人时是什么样的,它被读取时就是什么样。所以我们可以将SDS的buf属性称为字节数组:Redis不是用这个数组来保存字符,而是用它来保存一系列二进制数据。

参考:

list(链表)

链表在redis不仅作为list数据类型的一种底层存储,发布订阅、慢查询、监视器等功能也用到了链表,redis服务器本身还使用链表来保存多个客户端的状态信息。

数据结构定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
typedef struct listNode {
struct listNode *prev;
struct listNode *next;
void *value;
} listNode;

typedef struct list {
listNode *head;
listNode *tail;
void *(*dup)(void *ptr); //dup 函数用于复制链表节点所保存的值;
void (*free)(void *ptr); //free函数用于释放链表节点所保存的值;
int (*match)(void *ptr, void *key); //match函数则用于对比链表节点所保存的值和另一个输入值是否相等。
unsigned long len;
} list;

Redis链表特性:

  1. 双端:链表具有前置节点和后置节点的引用,获取这两个节点时间复杂度都为O(1)。
  2. 无环:表头节点的 prev 指针和表尾节点的 next 指针都指向 NULL,对链表的访问都是以 NULL 结束。
  3. 带链表长度计数器:通过 len 属性获取链表长度的时间复杂度为 O(1)。
  4. 多态:链表节点使用 void* 指针来保存节点值,可以保存各种不同类型的值。

dict(字典)

字典在redis不仅作为hash数据类型的一种底层存储,在redisDb中也用来保存所有的键值对、过期的键值对等信息。

Redis的字典使用哈希表作为底层实现,使用拉链发来处理冲突。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
typedef struct dictType {
uint64_t (*hashFunction)(const void *key);
void *(*keyDup)(void *privdata, const void *key);
void *(*valDup)(void *privdata, const void *obj);
int (*keyCompare)(void *privdata, const void *key1, const void *key2);
void (*keyDestructor)(void *privdata, void *key);
void (*valDestructor)(void *privdata, void *obj);
} dictType;

typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2];
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
unsigned long iterators; /* number of iterators currently running */
} dict;

typedef struct dictht {
dictEntry **table;
unsigned long size;
unsigned long sizemask;
unsigned long used;
} dictht;

typedef struct dictEntry {
void *key;
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next;
} dictEntry;

redis对于dict的实现,主要是三个数据结构:dict、dictht、dictEntry。

dict:

  • type属性指向一个dictType结构的指针,每个dictType保存了一组用于操作特定类型键值对的函数。如下的一个例子:

    1
    2
    3
    4
    5
    6
    7
    8
    dictType BenchmarkDictType = {
    hashCallback,
    NULL,
    NULL,
    compareCallback,
    freeCallback,
    NULL
    };
  • privdata属性保存了需要传给dictType中特定函数的可选参数。

  • ht属性是一个包含两个项的数组,数组中的每个项都是一个dictht哈希表,一般情况下,字典只使用ht[0]哈希表,ht[1]哈希表只会在对ht [0]哈希表进行rehash时使用。

  • rehashidx属性记录了rehash目前的进度,如果没有rehash,值为-1。

  • iterators属性记录了有多少个迭代器正在运行。

dictht(哈希表):

  • table属性是一个数组,每个数组元素指向一个键值对(dictEntry)。

  • size属性记录了数组的大小。

  • sizemask属性用于计算索引值,等于size-1,将hash与sizemask进行与操作得到下标,

  • 等同于取模操作。

    1
    2
    11%3 = 2
    1011 & 0010 = 0010 = 2
  • used属性表示哈希表已有节点的数量。

dictEntry(哈希表节点):

  • key属性记录键值对的键。
  • val属性是一个uint64,int64,double类型,或者是一个指针,指向具体的值对象。
  • next属性指向当前链表的下一个结点。
rehash

随着操作的不断执行,哈希表保存的键值对会逐渐地增多或者减少,为了让哈希表的负载因子( load factor)维持在一个合理的范围之内,当哈希表保存的键值对数量太多或者太少时,程序需要对哈希表的大小进行相应的扩展或者收缩。

扩展和收缩哈希表的工作可以通过执行rehash(重新散列)操作来完成,Redis对字典的哈希表执行rehash的步骤如下:

  1. 为字典的ht[1]哈希表分配空间,这个哈希表的空间大小取决于要执行的操作,以及ht[0]当前包含的键值对数量(也即是ht [0].used属性的值):

    • 如果执行的是扩展操作,那么ht [1]的大小为第一个大于等于ht [0].used*2的2n

    • 如果执行的是收缩操作,那么ht[1]的大小为第一个大于等于ht[0].used的2n

      看源码:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      static unsigned long _dictNextPower(unsigned long size)
      {
      unsigned long i = DICT_HT_INITIAL_SIZE;

      if (size >= LONG_MAX) return LONG_MAX + 1LU;
      while(1) {
      if (i >= size)
      return i;
      i *= 2;
      }
      }
  2. 将保存在ht[0]中的所有键值对rehash到ht[1]上面,rehash指的是重新计算键的哈希值和索引值,然后将键值对放置到ht[1]哈希表的指定位置上。redis的rehash使用了渐进式rehash。

  3. 当ht [0]包含的所有键值对都迁移到了ht[1]之后(ht[0]变为空表),释放ht[0],将ht[1]设置为ht[0],并在ht[1]新创建一个空白哈希表,为下一次rehash做准备。

哈希表的扩展的触发条件主要有两个:

  1. 服务器目前没有在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于1。
  2. 服务器目前正在执行BGSAVE命令或者BGREWRITEAOF命令,并且哈希表的负载因子大于等于5。

    =÷=ht[0].used÷ht[0].size负载因子 = 哈希表已保存结点数量 ÷ 哈希表大小 = ht[0].used ÷ ht[0].size

而当哈希表的负载因子小于0.1时,程序自动开始对哈希表执行收缩操作。

根据BGSAVE命令或BGREWRITEAOF命令是否正在执行,服务器执行扩展操作所需的负载因子并不相同,这是因为在执行BGSAVE命令或BGREWRITEAOF命令的过程中,Redis需要创建当前服务器进程的子进程,而大多数操作系统都采用写时复制( copy-on-write)技术来优化子进程的使用效率,所以在子进程存在期间,服务器会提高执行扩展操作所需的负载因子,从而尽可能地避免在子进程存在期间进行哈希表扩展操作,这可以避免不必要的内存写人操作,最大限度地节约内存。

渐进式rehash

上一节说过,扩展或收缩哈希表需要将ht[0]里面的所有键值对rehash到ht [1]里面,但是,这个rehash动作并不是一次性、集中式地完成的,而是分多次、渐进式地完成的。

这样做的原因在于,如果ht[0]里只保存着四个键值对,那么服务器可以在瞬间就将这些键值对全部rehash到ht[1];但是,如果哈希表里保存的键值对数量不是四个,而是四百万、四千万甚至四亿个键值对,那么要一次性将这些键值对全部rehash到ht [1]的话,庞大的计算量可能会导致服务器在一段时间内停止服务。

因此,为了避免rehash对服务器性能造成影响,服务器不是一次性将ht[0]里面的所有键值对全部rehash到ht[1],而是分多次、渐进式地将ht[0]里面的键值对慢慢地rehash到ht [1]。

以下是哈希表渐进式rehash的详细步骤:

  1. 在字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示 rehash工.作正式开始。
  2. 在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定的操作以外,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash 到ht[1],当rehash工作完成之后,程序将rehashidx属性的值增一。
  3. 随着字典操作的不断执行,最终在某个时间点上,ht [0]的所有键值对都会被rehash至ht[1],这时程序将rehashidx属性的值设为-1,表示rehash操作已完成。

渐进式rehash 的好处在于它采取分而治之的方式,将rehash键值对所需的计算工作均摊到对字典的每个添加、删除、查找和更新操作上,从而避免了集中式rehash而带来的庞大计算量。

因为在进行渐进式rehash的过程中,字典会同时使用ht [0]和ht[1]两个哈希表,所以在渐进式rehash进行期间,字典的删除( delete)、查找(find)、更新( update)等操作会在两个哈希表上进行。例如,要在字典里面查找一个键的话,程序会先在ht[0]里面进行查找,如果没找到的话,就会继续到ht[1〕里面进行查找,诸如此类。

另外,在渐进式rehash执行期间,新添加到字典的键值对一律会被保存到ht[1〕里面,而ht[0]则不再进行任何添加操作,这一措施保证了ht[0]包含的键值对数量会只减不增,并随着rehash 操作的执行而最终变成空表。

zskiplist(跳表)

跳跃表(skiplist)是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。

数据结构

看下下面的一个跳表例子:

server.h中关于数据结构的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef struct zskiplistNode {
sds ele;
double score;
struct zskiplistNode *backward;
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned long span;
} level[];
} zskiplistNode;

typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
} zskiplist;

在跳表中有四个属性:

  • header:指向跳跃表的表头节点。
  • tail:指向跳跃表的表尾节点。
  • level:记录目前跳跃表内,层数最大的那个节点的层数(表头节点的层数不计算在内)。
  • length:记录跳跃表的长度,也即是,跳跃表目前包含节点的数量(表头节点不计算在内)。

在跳表节点中也有四个属性:

  • level数组:level数组可以包含多个元素,每个元素都包含一个指向其他节点的指针和跨度,跳表就是通过这些层来加快访问其他节点的速度,一般来说,层的数量越多,访问其他节点的速度就越快。每次创建一个新跳跃表节点的时候,就会调用**randomLevel()**随机生成当节点的层数。

    每一层的的forward指针指向当前层的下一个结点;span表示当前层到下一个结点的距离,也就是在原始链表上的中间结点数,它的作用是用来计算排位的,也就是在原始链表中的位置。

  • backward指针:指向当前层的前一个结点,因为每个结点只有一个该指针,因此只能回退一个结点。

  • score:一个double类型的分数,跳表根据分数来进行排序。

  • ele:一个sds字符串对象,指向真实数据。

查询

跳表查询数据时,会从最高层进行查找,若查找数据比当前数据大,比下一跳数据小,就下降一层继续查找,再遍历两者之间的结点,直到找到一个比查找数据小和大的结点区间后,再下降一层继续遍历两者间的结点,直到找到匹配的结点。因此跳表是可以实现二分查找的有序链表

而跳表的设计中,可以指定每一层遍历几个结点,比如每一层中每两个结点必有一个抽出作为上一层的结点,因此每一层遍历时最多遍历3个结点。又表高为log2nlog_2n,因此时间复杂度为 O(3logn)O(3*logn),省略常数即:O(logn)O(logn)。而为了满足每一层遍历3个结点的设计,在插入时就要做一些处理。

例子:

图中所示,现在到达第 k 级索引,我们发现要查找的元素 x 比 y 大比 z 小,所以,我们需要从 y 处下降到 k-1 级索引继续查找,k-1级索引中比 y 大比 z 小的只有一个 w,所以在 k-1 级索引中,我们遍历的元素最多就是 y、w、z,发现 x 比 w大比 z 小之后,再下降到 k-2 级索引。所以,k-2 级索引最多遍历的元素为 w、u、z。

插入

插入时,尽量让元素有 1/2 的几率建立在一层、1/4 的几率建立二层、1/8 的几率建立三层,以此类推,就能满足我们上面的条件。

需要创建一个randomLevel() 方法,随机生成 1~MAX_LEVEL 之间的数(MAX_LEVEL表示索引的最高层数),通过设置晋升概率,使有 1/2的概率返回 1、1/4的概率返回 2、1/8的概率返回 3 … randomLevel() 方法返回 1 不建索引,仅插入原始链表、返回2建一级索引、返回 3 建二级索引、返回 4 建三级索引 …

以下是redis中的实现:

1
2
3
4
5
6
7
8
9
10
11
// 该 randomLevel 方法会随机生成 1~MAX_LEVEL 之间的数,且 :
// 1/2 的概率返回 1
// 1/4 的概率返回 2
// 1/8 的概率返回 3 以此类推
private int randomLevel() {
int level = 1;
// 当 level < MAX_LEVEL,且随机数小于设定的晋升概率时,level + 1
while (Math.random() < SKIPLIST_P && level < MAX_LEVEL)
level += 1;
return level;
}

我们的案例中晋升概率 SKIPLIST_P 设置的 1/2,即:每两个结点抽出一个结点作为上一级索引的结点。如果我们想节省空间利用率,可以适当的降低代码中的 SKIPLIST_P,从而减少索引元素个数,Redis 的 zset 中 SKIPLIST_P 设定的 0.25。

例子:

现在我们要插入数据 6 到跳表中,首先 randomLevel() 返回 3,表示需要建二级索引,即:一级索引和二级索引需要增加元素 6。该跳表目前最高三级索引,首先找到三级索引的 1,发现 6 比 1大比 13小,所以,从 1 下沉到二级索引。

下沉到二级索引后,发现 6 比 1 大比 7 小,此时需要在二级索引中 1 和 7 之间加一个元素6 ,并从元素 1 继续下沉到一级索引。

下沉到一级索引后,发现 6 比 1 大比 4 大,所以往后查找,发现 6 比 4 大比 7 小,此时需要在一级索引中 4 和 7 之间加一个元素 6 ,并把二级索引的 6 指向 一级索引的 6,最后,从元素 4 继续下沉到原始链表。

下沉到原始链表后,就比较简单了,发现 4、5 比 6小,7比6大,所以将6插入到 5 和 7 之间即可,整个插入过程结束。

删除

删除元素的过程跟查找元素的过程类似,只不过在查找的路径上如果发现了要删除的元素 x,则执行删除操作。跳表中,每一层索引其实都是一个有序的单链表,单链表删除元素的时间复杂度为 O(1),索引层数为 logn 表示最多需要删除 logn 个元素,所以删除元素的总时间包含 查找元素的时间加 删除 logn个元素的时间为 O(logn) + O(logn) = 2 O(logn),忽略常数部分,删除元素的时间复杂度为 O(logn)。

参考:

👉 Skip List–跳表(全网最详细的跳表文章没有之一) - 简书 (jianshu.com)

intset(整数集合)

整数集合( intset)是集合键的底层实现之一,当集合元素都是整数且元素个数小于512个就用intset编码。

1
2
3
4
5
typedef struct intset {
uint32_t encoding;
uint32_t length;
int8_t contents[];
} intset;
  • contents数组是整数集合的底层实现:整数集合的每个元素都是contents数组的一个数组项( item ),各个项在数组中按值的大小从小到大有序地排列,并且数组中不包含任何重复项。
  • length属性记录了整数集合包含的元素数量,也即是contents数组的长度。
  • 虽然intset结构将contents属性声明为int8_t类型的数组,但实际上contents数组并不保存任何int8_t类型的值,contents数组的真正类型取决于encoding属性的值。

每当我们要将一个新元素添加到整数集合里面,并且新元素的类型比整数集合现有所有元素的类型都要长时,整数集合需要先进行升级(upgrade),然后才能将新元素添加到整数集合里面。

升级整数集合并添加新元素共分为三步进行:

  1. 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。
  2. 将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,需要继续维持底层数组的有序性质不变。
  3. 将新元素添加到底层数组里面。(一般就是放在最后一个)

升级的好处:

  1. 提升灵活性:一般一个数组中只能放一种类型的元素,而intset采用升级来适应新类型元素。
  2. 节约内存:数组初始设置为int8_t,随着元素值类型变化来重新设置数组类型,从而节省了内存空间。当然,要让一个数组可以同时保存int16_t、int32_t、int64_t三种类型的值,最简单的做法就是直接使用int64_t类型的数组作为整数集合的底层实现。不过这样一来,即使添加到整数集合里面的都是int16_t类型或者int32_t类型的值,数组都需要使用int64_t类型的空间去保存它们,从而出现浪费内存的情况。

不支持降级。

ziplist

ziplist是一个经过特殊编码的双向链表,旨在提高内存效率。它可以储存字符串和整型值,其中,整型值被编码为实际整数,字符串被编码为字节数组。压缩列表(ziplist)是列表键和哈希键的底层实现之一。

  • hash:当所有键和值的字符串长度都小于64B且键值对个数小于512个就用ziplist编码。ziplist编码的哈希对象使用压缩列表作为底层实现,每个键值对使用两个紧挨在一起的压缩列表节点来保存,先在列表表尾保存一个键的压缩结点,再保存值的压缩结点。
  • list:当所有字符串对象的元素长度都小于64字节且元素个数小于512个就是用ziplist编码。ziplist编码的列表对象使用压缩列表作为底层实现,每个压缩列表节点(entry)保存了一个列表元素
数据结构

每个ziplist占用的内存布局如下,分为五个部分:

  • zlbytes是一个无符号整数,表示当前ziplist占用的总字节数。
  • zltail是ziplist最后一个entry的指针相对于ziplist最开始的偏移量。通过它,不需要完全遍历ziplist就可以对最后的entry进行操作。
  • zllen是ziplist的entry数量。当zllen比65534大时,需要完全遍历entry列表来获取entry的总数目。
  • zlend是一个单字节的特殊值,为0xFF,等于255,标识着ziplist的内存结束点。
1
2
3
4
5
6
7
8
9
10
11
12
/* We use this function to receive information about a ziplist entry.
* Note that this is not how the data is actually encoded, is just what we
* get filled by a function in order to operate more easily. */
typedef struct zlentry {
unsigned int prevrawlensize; //记录prevrawlen需要的字节数
unsigned int prevrawlen; //记录上个节点的长度
unsigned int lensize; //记录len需要的字节数
unsigned int len; //记录节点长度
unsigned int headersize; //prevrawlensize+lensize
unsigned char encoding; //编码格式
unsigned char *p; //具体的数据指针
} zlentry;

重点看注释,Note that this is not how the data is actually encoded,这句话说明这并不是数据的实际存储格式。因为,这个结构存储实在是太浪费空间了。这个结构32位机占用了25(int类型5个,每个int占4个字节,char类型1个,每个char占用1个字节,char*类型1个,每个char*占用4个字节,所以总共5*4+1*1+1*4=25)个字节,在64位机占用了29(int类型5个,每个int占4个字节,char类型1个,每个char占用1个字节,char*类型1个,每个char*占用8个字节,所以总共5*4+1*1+1*8=29个字节)。这不符合压缩列表的设计目的。

所以Redis对上述结构进行了改进了,抽象合并了三个参数:

  • prev_entry_len: 前一个节点的长度,可以是一个字节和五个字节。

    • 如果前一个节点长度小于254字节,那么prev_entry_len使用一个字节表示。
    • 如果前一个节点长度大于等于254字节,那么prev_entry_len使用五个字节表示。第一个字节为常数0xFE(254),后面四位为真正的前一个节点的长度。

    因为节点的previous_entry_length属性记录了前一个节点的长度,所以程序可以通过指针运算,根据当前节点的起始地址来计算出前一个节点的起始地址。

  • encoding:记录了节点的content属性所保存数据的类型以及长度。

    • 一字节、两字节或者五字节长,值的最高位为00、01或者10的是字节数组编码:这种编码表示节点的content属性保存着字节数组,数组的长度由编码除去最高两位之后的其他位记录;
    • 一字节长,值的最高位以11开头的是整数编码:这种编码表示节点的content属性保存着整数值,整数值的类型和长度由编码除去最高两位之后的其他位记录;
  • content:具体的数据。

Redis并没有像之前的字符串SDS,字典,跳跃表等结构一样,封装一个结构体来保存压缩列表的信息。而是通过定义一系列宏来对数据进行操作。也就是说压缩列表是一堆字节码,Redis通过操纵字节码来存储、获取数据。

  1. ZIP_IS_STR(enc)
1
#define ZIP_IS_STR(enc) (((enc) & ZIP_STR_MASK) < ZIP_STR_MASK)

ZIP_STR_MASK是0b0011_0000,将它与enc进行AND操作,如果enc是string类型,那么其前两位应该是00、01或10,因此计算之后的数值应该小于ZIP_STR_MASK的。

  1. ZIPLIST_BYTES(zl)

这个宏就是把ziplist最开头的zlbytes提取出来,代码也很简单,通过指针取zl对应的值。

1
#define ZIPLIST_BYTES(zl)       (*((uint32_t*)(zl)))
  1. ZIPLIST_TAIL_OFFSET(zl)

这个宏是提取ziplist的zltail内容,它的指针则是对zl偏移一个uint32_t大小(zlbytes的长度)获得的。

1
#define ZIPLIST_TAIL_OFFSET(zl) (*((uint32_t*)((zl)+sizeof(uint32_t))))
  1. ZIPLIST_LENGTH(zl)

    获取zllen的内容,其指针的获取同上,只不过需要偏移两个uint32_t的内存大小。

    1
    #define ZIPLIST_LENGTH(zl)   (*((uint16_t*)((zl)+sizeof(uint32_t)*2)))
  2. ZIPLIST_ENTRY相关

    redis使用三个宏来定位ziplist中entry的首尾位置。

    首先计算了ziplist中第一个entry到ziplist开头的偏移地址,其实就是zlbytes、zltail和zllen占用的内存大小。

    1
    #define ZIPLIST_HEADER_SIZE     (sizeof(uint32_t)*2+sizeof(uint16_t))

    接下来通过宏获得ziplist第一个entry的指针地址。

    1
    #define ZIPLIST_ENTRY_HEAD(zl)  ((zl)+ZIPLIST_HEADER_SIZE)

    那么ziplist的最后一个entry的指针地址也可以通过ZIPLIST_TAIL_OFFSET的宏获得。

    1
    #define ZIPLIST_ENTRY_TAIL(zl)  ((zl)+intrev32ifbe(ZIPLIST_TAIL_OFFSET(zl)))

    entry列表的地址边界也可以获得,ziplist最后是一个字节的zlend,因此,zl偏移zlbytes-1就可以获得其指针了。

    1
    #define ZIPLIST_ENTRY_END(zl)   ((zl)+intrev32ifbe(ZIPLIST_BYTES(zl))-1)
  3. ZIPLIST_INCR_LENGTH(zl,incr)

    这个宏用来调整ziplist的entry数量,即zllen。因为ziplist每次只pop或push一个数据,因此这个宏的incr一般为1或-1。代码如下,当ZIPLIST_LENGTH(zl)大于UINT16_MAX时,就已经不再更新zllen了,之后统计ziplist长度就需要进行遍历。

    1
    2
    3
    4
    #define ZIPLIST_INCR_LENGTH(zl,incr) { \
    if (ZIPLIST_LENGTH(zl) < UINT16_MAX) \
    ZIPLIST_LENGTH(zl) = intrev16ifbe(intrev16ifbe(ZIPLIST_LENGTH(zl))+incr); \
    }

    我当时迷糊了好久,为啥第三行代码ZIPLIST_LENGTH(zl)的结果可以直接赋值呢?我翻了好久也没翻出来,突然发现,ZIPLIST_LENGTH(zl)是宏定义。它在编译时是将定义的代码直接插入到第三行代码中的,这样就可以进行指针赋值了。

  4. ZIP_ENTRY_ENCODING(ptr, encoding)

    这个宏是用来设置entry的encoding字段,

    1
    2
    3
    4
    #define ZIP_ENTRY_ENCODING(ptr, encoding) do {  \
    (encoding) = (ptr[0]); \
    if ((encoding) < ZIP_STR_MASK) (encoding) &= ZIP_STR_MASK; \
    } while(0)

    代码中第三行,如果encoding小于ZIP_STR_MASK(0b1100_0000),就通过AND操作将encoding后6位设置为0。

  5. ZIP_DECODE_LENGTH(ptr, encoding, lensize, len)

    这个宏的实现目标为:根据encoding设置lensize,即len占用的字节数;根据ptr指向的数据设置len。接下来介绍lensize和len的设置,具体编码参考1.2章的encoding格式:

    • 如果encoding==ZIP_STR_06B(0b0000_0000),它的lensize为1,len即为ptr[0]的后六位。
    • 如果encoding==ZIP_STR_14B(0b0100_0000),lensize为2,len为((ptr[0]&0x3f)<<8)|ptr[1]。
    • 如果encoding==ZIP_STR_32B(0b1100_0000),lensize为5,len为ptr[1]-ptr[4]组成的uint64类型整数。
    • 否则的话encoding>0b1100_0000,说明其是数字,则调用zipIntSize(encoding)设置它的len,lensize为1.
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    static unsigned int zipIntSize(unsigned char encoding) {
    switch(encoding) {
    case ZIP_INT_8B: return 1;
    case ZIP_INT_16B: return 2;
    case ZIP_INT_24B: return 3;
    case ZIP_INT_32B: return 4;
    case ZIP_INT_64B: return 8;
    default: return 0; /* 4 bit immediate */
    }
    assert(NULL);
    return 0;
    }
  6. ZIP_DECODE_PREVLENSIZE(ptr, prevlensize)和ZIP_DECODE_PREVLEN(ptr, prevlensize, prevlen)

    entry会记录上一个entry的大小,如果超过254则使用5个字节来储存其长度,否则只使用1个字节就够了。因此,ZIP_DECODE_PREVLENSIZE(ptr,prevlensize)就是用来设置entry中的prevlensize字段。

    1
    2
    3
    4
    5
    6
    7
    #define ZIP_DECODE_PREVLENSIZE(ptr, prevlensize) do {                          \
    if ((ptr)[0] < ZIP_BIGLEN) { \
    (prevlensize) = 1; \
    } else { \
    (prevlensize) = 5; \
    } \
    } while(0);

    那ZIP_DECODE_PREVLEN(ptr, prevlensize, prevlen)就很简单了,就是把ptr[1]-ptr[4]的数据复制到prevlen。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    #define ZIP_DECODE_PREVLEN(ptr, prevlensize, prevlen) do {                     \
    ZIP_DECODE_PREVLENSIZE(ptr, prevlensize); \
    if ((prevlensize) == 1) { \
    (prevlen) = (ptr)[0]; \
    } else if ((prevlensize) == 5) { \
    assert(sizeof((prevlensize)) == 4); \
    memcpy(&(prevlen), ((char*)(ptr)) + 1, 4); \
    memrev32ifbe(&prevlen); \
    } \
    } while(0);

参考:👉 ziplist源码阅读 - 知乎 (zhihu.com)

连锁更新

现在,考虑这样一种情况:在一个压缩列表中,有多个连续的、长度介于250字节到253字节之间的节点e1至eN。

因为e1至eN的所有节点的长度都小于254字节,所以记录这些节点的长度只需要1字节长的previous_entry_length属性,换句话说,e1至eN的所有节点的previous_entry_length属性都是1字节长的。

现在,麻烦的事情来了,e1原本的长度介于250字节至253字节之间,在为previous_entry_length属性新增四个字节的空间之后,e1的长度就变成了介于254字节至257字节之间,而这种长度使用1字节长的previous_entry_length属性是没办法保存的;因此,为了让e2的previous_entry_length属性可以记录下e1的长度,程序需要再次对压缩列表执行空间重分配操作,并将e2节点的previous_entry_length属性从原来的1字节长扩展为5字节长;正如扩展e1引发了对e2的扩展一样,扩展e2也会引发对e3的扩展,而扩展e3又会引发对e4的扩展……为了让每个节点的previous_entry_length属性都符合压缩列表对节点的要求,程序需要不断地对压缩列表执行空间重分配操作,直到eN为止。

以上情况就叫做连锁更新。除了添加新节点可能会引发连锁更新之外,删除节点也可能会引发连锁更新。

因为连锁更新在最坏情况下需要对压缩列表执行N次空间重分配操作,而每次空间重分配的最坏复杂度为O(N),所以连锁更新的最坏复杂度为O(N2)。要注意的是,尽管连锁更新的复杂度较高,但它真正造成性能问题的几率是很低的:

  • 首先,压缩列表里要恰好有多个连续的、长度介于250字节至253字节之间的节点,连锁更新才有可能被引发,在实际中,这种情况并不多见;
  • 其次,即使出现连锁更新,但只要被更新的节点数量不多,就不会对性能造成任何影响。
ziplist的缺陷

在前面ziplist的介绍中,可以知道ziplist的最大特点就是它被设计成一种内存紧凑型的数据结构,占用一块连续的内存空间,以达到节省内存的目的。

但是,在计算机系统中,任何一个设计都是有利有弊的。对于 ziplist 来说,这个道理同样成立。虽然 ziplist 节省了内存开销,可它也存在两个设计代价:

  • 一是不能保存过多的元素,否则访问性能会降低;
  • 二是不能保存过大的元素,否则容易导致内存重新分配,甚至可能引发连锁更新的问题。

对于这两个代价,也就引出了ziplist的两个缺陷:

1)查找复杂度高

因为 ziplist 头尾元数据的大小是固定的,并且在 ziplist 头部记录了最后一个元素的位置,所以,当在 ziplist 中查找第一个或最后一个元素的时候,就可以很快找到。但问题是,当要查找列表中间的元素时,ziplist 就得从列表头或列表尾遍历才行。而当 ziplist 保存的元素过多时,查找中间数据的复杂度就增加了。更糟糕的是,如果 ziplist 里面保存的是字符串,ziplist 在查找某个元素时,还需要逐一判断元素的每个字符,这样又进一步增加了复杂度。

也正因为如此,我们在使用 ziplist 保存 Hash 或 Sorted Set 数据时,都会在 redis.conf 文件中,通过 hash-max-ziplist-entries 和 zset-max-ziplist-entries 两个参数,来控制保存在 ziplist 中的元素个数。

不仅如此,除了查找复杂度高以外,ziplist 在插入元素时,如果内存空间不够了,ziplist 还需要重新分配一块连续的内存空间,而这还会进一步引发连锁更新的问题。

2)连锁更新风险。这一点在前面已经介绍过了。

所以说,虽然 ziplist 紧凑型的内存布局能节省内存开销,但是如果保存的元素数量增加了,或是元素变大了,ziplist 就会面临性能问题。那么,有没有什么方法可以避免 ziplist 的问题呢?这就是接下来要介绍的 quicklistlistpack,这两种数据结构的设计目标,就是尽可能地保持 ziplist 节省内存的优势,同时避免 ziplist 潜在的性能下降问题。

quicklist

quicklist 的设计,其实是结合了链表和 ziplist 各自的优势。简单来说,**一个 quicklist 就是一个链表,而链表中的每个元素又是一个 ziplist。**可以看下下面这张图:

数据结构

首先,来看下quicklist:

1
2
3
4
5
6
7
8
9
10
11
typedef struct quicklist {
quicklistNode *head; //指向链表头
quicklistNode *tail; //指向链表尾
unsigned long count; //所有ziplist数据项的个数总和
unsigned long len; //quicklist节点的个数
int fill : QL_FILL_BITS; //ziplist大小设置,存放list-max-ziplist-size参数的值
unsigned int compress : QL_COMP_BITS; //节点压缩深度设置,存放list-compress-depth参数的值
//bookmakrs是可选的,主要用来重新分配这个结构体
unsigned int bookmark_count: QL_BM_BITS;
quicklistBookmark bookmarks[];
} quicklist;
  • head: 指向头节点(左侧第一个节点)的指针。
  • tail: 指向尾节点(右侧第一个节点)的指针。
  • count: 所有ziplist数据项的个数总和。
  • len: quicklist节点的个数。
  • fill: 16bit,ziplist大小设置,存放list-max-ziplist-size参数的值。
  • compress: 16bit,节点压缩深度设置,存放list-compress-depth参数的值。

quicklist 中的每个元素就是 quicklistNode:

1
2
3
4
5
6
7
8
9
10
11
12
typedef struct quicklistNode {
struct quicklistNode *prev; // 前一个quicklistNode
struct quicklistNode *next; // 后一个quicklistNode
unsigned char *zl; // 数据指针。如果当前节点的数据没有压缩,那么它指向一个ziplist结构;否则,它指向一个quicklistLZF结构。
unsigned int sz; // ziplist的字节大小(包括zlbytes, zltail, zllen, zlend和各个数据项),如果ziplist被压缩了,那么这个sz的值仍然是压缩前的ziplist大小。
unsigned int count : 16; // ziplist中的元素个数
unsigned int encoding : 2; // 编码格式,原生字节数组或压缩存储 RAW==1 or LZF==2
unsigned int container : 2; // 存储方式 NONE==1 or ZIPLIST==2
unsigned int recompress : 1; // 数据是否被压缩
unsigned int attempted_compress : 1; // 数据能否被压缩,这个值只对Redis的自动化测试程序有用。我们不用管它。
unsigned int extra : 10; // 预留的bit位
} quicklistNode;
  • prev: 指向链表前一个节点的指针。
  • next: 指向链表后一个节点的指针。
  • zl: 数据指针。如果当前节点的数据没有压缩,那么它指向一个ziplist结构;否则,它指向一个quicklistLZF结构。
  • sz: 表示zl指向的ziplist的总大小(包括zlbytes, zltail, zllen, zlend和各个数据项)。需要注意的是:如果ziplist被压缩了,那么这个sz的值仍然是压缩前的ziplist大小。
  • count: 表示ziplist里面包含的数据项个数。这个字段只有16bit。稍后我们会一起计算一下这16bit是否够用。
  • encoding: 表示ziplist是否压缩了(以及用了哪个压缩算法)。目前只有两种取值:2表示被压缩了(而且用的是LZF压缩算法),1表示没有压缩。
  • container: 本来设计是用来表明存储方式的,即是直接存数据,还是使用ziplist存数据。但是,在目前的实现中,这个值是一个固定的值2,表示使用ziplist作为数据容器。
  • recompress: 当我们使用类似lindex这样的命令查看了某一项本来压缩的数据时,需要把数据暂时解压,这时就设置recompress=1做一个标记,等有机会再把数据重新压缩。
  • attempted_compress: 这个值只对Redis的自动化测试程序有用。我们不用管它。
  • extra: 其它扩展字段。目前Redis的实现里也没用上。
1
2
3
4
typedef struct quicklistLZF {
unsigned int sz; /* LZF size in bytes*/
char compressed[];
} quicklistLZF;

quicklistLZF结构表示一个被压缩过的ziplist。其中:

  • sz: 表示压缩后的ziplist大小。
  • compressed: 是个柔性数组(flexible array member),存放压缩后的ziplist字节数组。
插入

quicklist可以选择在头部或者尾部进行插入(quicklistPushHeadquicklistPushTail),而不管是在头部还是尾部插入数据,都包含两种情况:

  • 如果头节点(或尾节点)上ziplist大小没有超过限制(即_quicklistNodeAllowInsert返回1),那么新数据被直接插入到ziplist中(调用ziplistPush)。
  • 如果头节点(或尾节点)上ziplist太大了,那么新创建一个quicklistNode节点(对应地也会新创建一个ziplist),然后把这个新创建的节点插入到quicklist双向链表中。

也可以从任意指定的位置插入。quicklistInsertAfterquicklistInsertBefore就是分别在指定位置后面和前面插入数据项。这种在任意指定位置插入数据的操作,要比在头部和尾部的进行插入要复杂一些。

  • 当插入位置所在的ziplist大小没有超过限制时,直接插入到ziplist中就好了;
  • 当插入位置所在的ziplist大小超过了限制,但插入的位置位于ziplist两端,并且相邻的quicklist链表节点的ziplist大小没有超过限制,那么就转而插入到相邻的那个quicklist链表节点的ziplist中;
  • 当插入位置所在的ziplist大小超过了限制,但插入的位置位于ziplist两端,并且相邻的quicklist链表节点的ziplist大小也超过限制,这时需要新创建一个quicklist链表节点插入。
  • 对于插入位置所在的ziplist大小超过了限制的其它情况(主要对应于在ziplist中间插入数据的情况),则需要把当前ziplist分裂为两个节点,然后再其中一个节点上插入数据。

listpack

listpack 也叫紧凑列表,它的特点就是用一块连续的内存空间来紧凑地保存数据,同时为了节省内存空间,listpack 列表项使用了多种编码方式,来表示不同长度的数据,这些数据包括整数和字符串

Redis源码对于listpack的解释为 A lists of strings serialization format,一个字符串列表的序列化格式,也就是将一个字符串列表进行序列化存储

数据结构

listpack的结构:

listpack由4部分组成:total Bytes、Num Elem、Entry以及End:

  • Total Bytes为整个listpack的空间大小,占用4个字节,每个listpack最多占用4294967295Bytes(232-1);
  • Num Elem为listpack中的元素个数,即Entry的个数,占用2个字节,值得注意的是,这并不意味着listpack最多只能存放65535个Entry,当Entry个数大于等于65535时,Num Elem被设置为65535,此时如果需要获取元素个数,需要遍历整个listpack;
  • Entry为每个具体的元素;
  • End为listpack结束标志,占用1个字节,内容为0xFF。

可以看下 listpack 的创建函数 lpNew,因为从这个函数的代码逻辑中,我们可以了解到 listpack 的整体结构。lpNew 函数创建了一个空的 listpack,一开始分配的大小是 LP_HDR_SIZE 再加 1 个字节。LP_HDR_SIZE 宏定义是在 listpack.c 中,它默认是 6 个字节,其中 4 个字节是记录 listpack 的总字节数,2 个字节是记录 listpack 的元素数量。此外,listpack 的最后一个字节是用来标识 listpack 的结束,其默认值是宏定义 LP_EOF。和 ziplist 列表项的结束标记一样,LP_EOF 的值也是 255。

1
2
#define LP_HDR_SIZE 6       /* 32 bit total len + 16 bit number of elements. */
#define LP_EOF 0xFF
1
2
3
4
5
6
7
8
9
10
11
12
13
/* Create a new, empty listpack.
* On success the new listpack is returned, otherwise an error is returned.
* Pre-allocate at least `capacity` bytes of memory,
* over-allocated memory can be shrinked by `lpShrinkToFit`.
* */
unsigned char *lpNew(size_t capacity) {
unsigned char *lp = lp_malloc(capacity > LP_HDR_SIZE+1 ? capacity : LP_HDR_SIZE+1);
if (lp == NULL) return NULL;
lpSetTotalBytes(lp,LP_HDR_SIZE+1);
lpSetNumElements(lp,0);
lp[LP_HDR_SIZE] = LP_EOF;
return lp;
}

Entry为listpack中的具体元素,其内容可以为字符串或者整型,每个Entry如下:

从组成上可以看出,和 ziplist 列表项类似,listpack 列表项也包含了元数据信息和数据本身。

不过,为了避免 ziplist 引起的连锁更新问题,listpack 中的每个列表项不再像 ziplist 列表项那样,保存其前一个列表项的长度,它只会包含三个方面内容,分别是当前元素的编码类型(encoding)、元素数据 (data)、以及编码类型和元素数据这两部分的长度 (len)。

其中 encoding 和 len 一定有值;有时 data 可能会缺失,因为对于一些小的元素可以直接将data放在encoding字段中。

len记录了这个Entry的长度(encoding + data),注意并不包括 len 自身的长度,占用的字节数小于等于5:

  • len 所占用的每个字节的第一个 bit 用于标识;0代表结束,1代表尚未结束,每个字节只有7 bit 有效;
  • len 主要用于从后向前遍历,当我们需要找到当前元素的上一个元素时,我们可以从后向前依次查找每个字节,找到上一个Entry的 len 字段的结束标识,进而可以计算出上一个元素的长度。
    例如 len 为0000000110001000,代表该元素的长度为00000010001000,即136字节。通过计算即可算出上一个元素的首地址(entry的首地址)。

需要注意的是,在整型存储中,并不实际存储负数,而是将负数转换为正数进行存储。例如,在13位整型存储中,存储范围为[0, 8191],其中[0, 4095]对应非负的[0, 4095](当然,[0, 127]将会采用7位无符号整型存储),而[4096, 8191]则对应[-4096, -1]。

编码格式

listpack 列表项编码方法我们先来看下 listpack 元素的编码类型。如果你看了 listpack.c 文件,你会发现该文件中有大量类似 LP_ENCODING_N_BIT_INT 和 LP_ENCODING_N_BIT_STR 的宏定义,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#define LP_ENCODING_7BIT_UINT 0
#define LP_ENCODING_7BIT_UINT_MASK 0x80
#define LP_ENCODING_IS_7BIT_UINT(byte) (((byte)&LP_ENCODING_7BIT_UINT_MASK)==LP_ENCODING_7BIT_UINT)

#define LP_ENCODING_6BIT_STR 0x80
#define LP_ENCODING_6BIT_STR_MASK 0xC0
#define LP_ENCODING_IS_6BIT_STR(byte) (((byte)&LP_ENCODING_6BIT_STR_MASK)==LP_ENCODING_6BIT_STR)

#define LP_ENCODING_13BIT_INT 0xC0
#define LP_ENCODING_13BIT_INT_MASK 0xE0
#define LP_ENCODING_IS_13BIT_INT(byte) (((byte)&LP_ENCODING_13BIT_INT_MASK)==LP_ENCODING_13BIT_INT)

#define LP_ENCODING_12BIT_STR 0xE0
#define LP_ENCODING_12BIT_STR_MASK 0xF0
#define LP_ENCODING_IS_12BIT_STR(byte) (((byte)&LP_ENCODING_12BIT_STR_MASK)==LP_ENCODING_12BIT_STR)

#define LP_ENCODING_16BIT_INT 0xF1
#define LP_ENCODING_16BIT_INT_MASK 0xFF
#define LP_ENCODING_IS_16BIT_INT(byte) (((byte)&LP_ENCODING_16BIT_INT_MASK)==LP_ENCODING_16BIT_INT)

#define LP_ENCODING_24BIT_INT 0xF2
#define LP_ENCODING_24BIT_INT_MASK 0xFF
#define LP_ENCODING_IS_24BIT_INT(byte) (((byte)&LP_ENCODING_24BIT_INT_MASK)==LP_ENCODING_24BIT_INT)

#define LP_ENCODING_32BIT_INT 0xF3
#define LP_ENCODING_32BIT_INT_MASK 0xFF
#define LP_ENCODING_IS_32BIT_INT(byte) (((byte)&LP_ENCODING_32BIT_INT_MASK)==LP_ENCODING_32BIT_INT)

#define LP_ENCODING_64BIT_INT 0xF4
#define LP_ENCODING_64BIT_INT_MASK 0xFF
#define LP_ENCODING_IS_64BIT_INT(byte) (((byte)&LP_ENCODING_64BIT_INT_MASK)==LP_ENCODING_64BIT_INT)

#define LP_ENCODING_32BIT_STR 0xF0
#define LP_ENCODING_32BIT_STR_MASK 0xFF
#define LP_ENCODING_IS_32BIT_STR(byte) (((byte)&LP_ENCODING_32BIT_STR_MASK)==LP_ENCODING_32BIT_STR)

大概可以分为三种:

1)单字节数字:

LP_ENCODING_7BIT_UINT :表示元素的实际数据是一个 7 bit 的无符号整数。又因为 LP_ENCODING_7BIT_UINT 本身的宏定义值为 0,所以编码类型的值也相应为 0,占 1 个 bit。此时,编码类型和元素实际数据共用 1 个字节,这个字节的最高位为 0,表示编码类型,后续的 7 位用来存储 7 bit 的无符号整数。

1
0|xxxxxxx

2)单字节字符串

LP_ENCODING_6BIT_STR:6位字符串长度,后6位为字符串长度,再之后则是字符串内容(0 ~ 63 bytes)。

1
10|xxxxxx <string-data>

3)多字节编码

如果第一个字节的最高2bit被设置为1,采用如下两种编码方式

1
2
110|xxxxx xxxxxxxx -- LP_ENCODING_13BIT_INT,即13位整型,后5位及下个字节为数据内容
1110|xxxx xxxxxxxx -- LP_ENCODING_12BIT_STR,即12位长度的字符串,后4位及下个字节为字符串长度,再之后的为字符串数据。

如果第一个字节的最高4bit被设置为1,将采用以下几种方式编码:

1
2
3
4
5
6
7
1111|0000 <4 bytes len> <large string>,LP_ENCODING_32BIT_STR,即32位长度字符串,后4字节为字符串长度,再之后为字符串内容
1111|0001 <16 bits signed integer>,LP_ENCODING_16BIT_INT,即16位整型,后2字节为数据
1111|0010 <24 bits signed integer>,LP_ENCODING_24BIT_INT,即24位整型,后3字节为数据
1111|0011 <32 bits signed integer>,LP_ENCODING_32BIT_INT,即32位整型,后4字节为数据
1111|0100 <64 bits signed integer>,LP_ENCODING_64BIT_INT,即64位整型,后8字节为数据
1111|0101 to 1111|1110 are currently not used. 当前不用编码
1111|1111 End of listpack,结尾标识
listpack 避免连锁更新的实现方式

最后,我们再来了解下 listpack 列表项是如何避免连锁更新的。在 listpack 中,因为每个列表项只记录自己的长度,而不会像 ziplist 中的列表项那样,会记录前一项的长度。所以,当我们在 listpack 中新增或修改元素时,实际上只会涉及每个列表项自己的操作,而不会影响后续列表项的长度变化,这就避免了连锁更新。

不过,你可能会有疑问:如果 listpack 列表项只记录当前项的长度,那么 listpack 支持从左向右正向查询列表,或是从右向左反向查询列表吗?

其实,listpack 是能支持正、反向查询列表的。

当应用程序从左向右正向查询 listpack 时,我们可以先调用 lpFirst 函数。该函数的参数是指向 listpack 头的指针,它在执行时,会让指针向右偏移 LP_HDR_SIZE 大小,也就是跳过 listpack 头。你可以看下 lpFirst 函数的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
/* Return a pointer to the first element of the listpack, or NULL if the
* listpack has no elements. */
unsigned char *lpFirst(unsigned char *lp) {
// 跳过listpack头部6个字节
unsigned char *p = lp + LP_HDR_SIZE; /* Skip the header. */
// 如果已经是listpack的末尾结束字节,则返回NULL
if (p[0] == LP_EOF) return NULL;
lpAssertValidEntry(lp, lpBytes(lp), p);
return p;
}

然后,再调用 lpNext 函数,该函数的参数包括了指向 listpack 某个列表项的指针。lpNext 函数会进一步调用 lpSkip 函数,并传入当前列表项的指针,如下所示:

1
2
3
4
5
6
unsigned char *lpNext(unsigned char *lp, unsigned char *p) {
((void) lp);
p = lpSkip(p);
if (p[0] == LP_EOF) return NULL;
return p;
}

最后,lpSkip 函数会先后调用 lpCurrentEncodedSize 和 lpEncodeBacklen 这两个函数:

  • lpCurrentEncodedSize函数是根据当前列表项第 1 个字节的取值,来计算当前项的编码类型,并根据编码类型,计算当前项编码类型和实际数据的总长度;
  • lpEncodeBacklen函数会根据编码类型和实际数据的长度之和,进一步计算列表项最后一部分 entry-len 本身的长度。这样一来,lpSkip 函数就知道当前项的编码类型、实际数据和 len 的总长度了,也就可以将当前项指针向右偏移相应的长度,从而实现查到下一个列表项的目的。

下面代码展示了 lpEncodeBacklen 函数的基本计算逻辑,你可以看下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
/* Store a reverse-encoded variable length field, representing the length
* of the previous element of size 'l', in the target buffer 'buf'.
* The function returns the number of bytes used to encode it, from
* 1 to 5. If 'buf' is NULL the function just returns the number of bytes
* needed in order to encode the backlen. */
unsigned long lpEncodeBacklen(unsigned char *buf, uint64_t l) {
// 编码类型和实际数据的总长度小于等于127,entry-len长度为1字节
if (l <= 127) {
if (buf) buf[0] = l;
return 1;
} else if (l < 16383) {
// 编码类型和实际数据的总长度大于127但小于16383,entry-len长度为2字节
if (buf) {
buf[0] = l>>7;
buf[1] = (l&127)|128;
}
return 2;
} else if (l < 2097151) {
// 编码类型和实际数据的总长度大于16383但小于2097151,entry-len长度为3字节
if (buf) {
buf[0] = l>>14;
buf[1] = ((l>>7)&127)|128;
buf[2] = (l&127)|128;
}
return 3;
} else if (l < 268435455) {
// 编码类型和实际数据的总长度大于2097151但小于268435455,entry-len长度为4字节
if (buf) {
buf[0] = l>>21;
buf[1] = ((l>>14)&127)|128;
buf[2] = ((l>>7)&127)|128;
buf[3] = (l&127)|128;
}
return 4;
} else {
// 否则,entry-len长度为5字节
if (buf) {
buf[0] = l>>28;
buf[1] = ((l>>21)&127)|128;
buf[2] = ((l>>14)&127)|128;
buf[3] = ((l>>7)&127)|128;
buf[4] = (l&127)|128;
}
return 5;
}
}

大概流程就是:

再来看下从右向左反向查询 listpack

首先,我们根据 listpack 头中记录的 listpack 总长度,就可以直接定位到 listapck 的尾部结束标记。然后,我们可以调用 lpPrev 函数,该函数的参数包括指向某个列表项的指针,并返回指向当前列表项前一项的指针。lpPrev 函数中的关键一步就是调用 lpDecodeBacklen 函数。

lpDecodeBacklen 函数会从右向左,逐个字节地读取当前列表项的 len。那么,lpDecodeBacklen 函数如何判断 len 是否结束了呢?

这就依赖于 len 的编码方式了。len 每个字节的最高位,是用来表示当前字节是否为 len 的最后一个字节,这里存在两种情况,分别是:

  • 最高位为 1,表示 len 还没有结束,当前字节的左边字节仍然表示 len 的内容;
  • 最高位为 0,表示当前字节已经是 len 最后一个字节了。

而 len 每个字节的低 7 位,则记录了实际的长度信息。这里你需要注意的是,len 每个字节的低 7 位采用了大端模式存储,也就是说,len 的低位字节保存在内存高地址上:

正是因为有了 len 的特别编码方式,lpDecodeBacklen 函数就可以从当前列表项起始位置的指针开始,向左逐个字节解析,得到前一项的 len 值,也就可以得到encoding + data的总长度,从而得出entry的总长度;减去entry的总长度,就得到了前一个entry的地址。

ziplist & quicklist & listpack

你要知道,ziplist 的不足主要在于一旦 ziplist 中元素个数多了,它的查找效率就会降低。而且如果在 ziplist 里新增或修改数据,ziplist 占用的内存空间还需要重新分配;更糟糕的是,ziplist 新增某个元素或修改某个元素时,可能会导致后续元素的 prevlen 占用空间都发生变化,从而引起连锁更新问题,导致每个元素的空间都要重新分配,这就会导致 ziplist 的访问性能下降。

所以,为了应对 ziplist 的问题,Redis 先是在 3.0 版本中设计实现了 quicklist。quicklist 结构在 ziplist 基础上,使用链表将 ziplist 串联起来,链表的每个元素就是一个 ziplist。这种设计减少了数据插入时内存空间的重新分配,以及内存数据的拷贝。同时,quicklist 限制了每个节点上 ziplist 的大小,一旦一个 ziplist 过大,就会采用新增 quicklist 节点的方法。不过,又因为 quicklist 使用 quicklistNode 结构指向每个 ziplist,无疑增加了内存开销。

为了减少内存开销,并进一步避免 ziplist 连锁更新问题,Redis 在 5.0 版本中,就设计实现了 listpack 结构。listpack 结构沿用了 ziplist 紧凑型的内存布局,把每个元素都紧挨着放置。listpack 中每个列表项不再包含前一项的长度了,因此当某个列表项中的数据发生变化,导致列表项长度变化时,其他列表项的长度是不会受影响的,因而这就避免了 ziplist 面临的连锁更新问题。

总而言之,Redis 在内存紧凑型列表的设计与实现上,从 ziplist 到 quicklist,再到 listpack,你可以看到 Redis 在内存空间开销和访问性能之间的设计取舍,这一系列的设计变化,是非常值得学习的。

rax(前缀树)

rax 是 redis 自己实现的基数树, 它是一种基于存储空间优化的前缀树数据结构, 在 redis 的许多地方都有使用到,比如:streams这个类型里面的 consumer group(消费者组) 的名称还有集群名称;集群状态下clusterState中的slots_to_keys用rax保存了从槽到key之间的关系。

通常来讲, 一个基数树(前缀树) 看起来如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
*              (f) ""
* \
* (o) "f"
* \
* (o) "fo"
* \
* [t b] "foo"
* / \
* "foot" (e) (a) "foob"
* / \
* "foote" (r) (r) "fooba"
* / \
* "footer" [] [] "foobar"

然而, 当前的代码实现使用了一种非常常见的优化策略, 把只有单个子的节点连续几个节点压缩成一个节点, 这个节点有一个字符串, 不再是只存储单个字符, 上述的结构可以优化成如下结构:

1
2
3
4
5
6
7
*                  ["foo"] ""
* |
* [t b] "foo"
* / \
* "foot" ("er") ("ar") "foob"
* / \
* "footer" [] [] "foobar"

字符串 mygroup1 在 rax 中也是以压缩节点的方式存储的, 可以用如下表示:

1
2
3
*                  ["mygroup1"] ""
* |
* [] "mygroup1"

第一个节点存储了压缩过的整个字符串 mygroup1, 第二个节点是一个空的叶子节点, 他是一个 key 值, 表示到这个节点之前合起来的字符串存储在了当前的 raxNode中。

rax结构代表一个Rax树:

1
2
3
4
5
typedef struct rax {
  raxNode *head;
  uint64_t numele;
  uint64_t numnodes;
} rax;
  • head:指向rax的头节点;
  • numele:rax元素的个数,即key的个数;
  • numnodes:节点个数。
1
2
3
4
5
6
7
8
9
10
11
12
typedef struct raxNode {
//节点是否包含key
uint32_t iskey:1; /* Does this node contain a key? */
//节点的值是否为NULL
uint32_t isnull:1; /* Associated value is NULL (don't store it). */
//节点是否被压缩
uint32_t iscompr:1; /* Node is compressed. */
//节点大小
uint32_t size:29; /* Number of children, or compressed string len. */
//节点的实际存储数据
unsigned char data[];
} raxNode;
  • iskey 表示当前的节点是否为 key 节点,即表示从 Radix Tree 的根节点到当前节点路径上的字符组成的字符串,是否表示了一个完整的 key。这里需要注意的是,当前节点所表示的 key,并不包含该节点自身的内容
  • isnull 表示当前节点是否有存储额外的值(data的指针是否为空)
  • iscompr 表示当前节点是否为压缩的节点
  • size 是子节点数量或者压缩的字符串长度,如果当前节点是压缩节点,该值表示压缩数据的长度;如果是非压缩节点,该值表示该节点指向的子节点个数。
  • data存储数据

在 Radix Tree 中存在两类节点:

  • 第一类节点是非压缩节点,这类节点会包含多个指向不同子节点的指针,以及多个子节点所对应的字符。data 数组包括子节点对应的字符、指向子节点的指针,以及节点表示 key 时对应的 value 指针;
  • 第二类节点是压缩节点,这类节点会包含一个指向子节点的指针,以及子节点所代表的合并的字符串。data 数组包括子节点对应的合并字符串、指向子节点的指针,以及节点为 key 时的 value 指针。

在 raxNode 的实现中,无论是非压缩节点还是压缩节点,其实具有两个特点:

  • 它们所代表的 key,是从根节点到当前节点路径上的字符串,但并不包含当前节点;
  • 它们本身就已经包含了子节点代表的字符或合并字符串。而对于它们的子节点来说,也都属于非压缩或压缩节点,所以,子节点本身又会保存,子节点的子节点所代表的字符或合并字符串。

可以简单看下这个例子:

这张图上显示了 Radix Tree 最右侧分支的 4 个节点 r、e、d、is 和它们各自的 raxNode 内容。其中,节点 r、e 和 d 都不代表 key,所以它们的 iskey 值为 0,isnull 值为 1,没有为 value 指针分配空间。

节点 r 和 e 指向的子节点都是单字符节点,所以它们不是压缩节点,iscompr 值为 0。而节点 d 的子节点包含了合并字符串“is”,所以该节点是压缩节点,iscompr 值为 1。最后的叶子节点 is,它的 raxNode 的 size 为 0,没有子节点指针。不过,因为从根节点到节点 is 路径上的字符串代表了 key“redis”,所以,节点 is 的 value 指针指向了“redis”对应的 value 数据。

这里,你需要注意的是,为了满足内存对齐的需要,raxNode 会根据保存的字符串长度,在字符串后面填充一些字节,也就是图中的 padding 部分。

下图是字符串 mygroup1 当前所在的 rax 的实际图示:

第一个节点的 iscompr 值为 1, 并且整个字符串 mygroup1 存储在了当前这一个节点中, size 为 8 表示当前节点存储了 8 个 char 字符, iskey为 0, 表示当前的节点不是 key 节点, 我们需要继续往下搜索。

第二个节点的 iskey 为 1, 表示当前的节点为 key 节点, 它表示在到这个节点之前的所有字符串连起来(也就是mygroup1) 存在当前的前缀树中, 也就是说当前的前缀树有包含 mygroup1 这个字符串, isnull 为 0 表示在当前这个 key 节点的 data 尾部存储了一个指针, 这个指针是函数调用者进行存储的, 在当前的情况它是一个指向 streamCG 的指针, 但是实际上他可以是指向任意对象的指针, 比如集群名称或者其他对象。

我们再来插入一个 consumer group 名称到当前的前缀树中:

1
2
127.0.0.1:6379> XGROUP CREATE mystream mygroup2 $
OK

从上图可知, 第一个节点被裁剪了, 并且它后面插入了一个新的节点, 左下角的节点是原先存在的节点, 右下角的节点也是一个新插入的节点:

1
2
3
4
5
*                  ["mygroup"] ""
* |
* [1 2] "mygroup"
* / \
* "mygroup1" [] [] "mygroup2"

中间的节点未被压缩(iscompr 这个位没有被设置), data 字段中存储了 size 个字符, 在这些字符之后, 同样会存储 size 个指向与之前字符一一对应的 raxNode 的结构的指针。

底下两个节点的 iskey = 1 并 isnull = 0, 表示当到达任意一个这样的节点时, 当前的字符串是存储在这个前缀树中的, 并且在 data 字段最尾部存储了一个辅助的指针, 这个指针具体指向什么对象取决于调用者。

stream

Stream 会使用 Radix Tree 来保存消息 ID,然后将消息内容保存在 listpack 中,并作为消息 ID 的 value,用 raxNode 的 value 指针指向对应的 listpack。

数据结构
1
2
3
4
5
6
typedef struct stream {
  rax *rax; /* 存储生产者生产的具体消息,以消息ID为键,消息内容为值存储在rax中,值得注意的是,rax中的一个节点可能存储多个消息*/
  uint64_t length; /*当前stream中的消息个数(不包括已经删除的消息)。*/
  streamID last_id; /* 当前stream中最后插入的消息的ID,stream空时,设置为0。. */
  rax *cgroups; /* 存储了当前stream相关的消费组,rax中: name -> streamCG */
} stream;
  • rax:指向rax的的头节点,存储生产者生产的具体消息,以消息ID为键,消息内容为值存储在rax中;
  • length:stream中消息的个数,不包括已经删除的消息;
  • last_id: 当前stream中最后插入的消息的ID,stream空时,设置为0;
  • cgoups:指向rax的头节点,存储了当前stream相关的消费组。

每个Stream会有多个消费组,每个消费组通过组名称进行唯一标识,同时关联一个streamCG结构,该结构定义如下:

1
2
3
4
5
typedef struct streamCG {
  streamID last_id; // 该消费组已经确认的最后一个消息的ID
  rax *pel; // 该消费组尚未确认的消息,消息ID为键,streamNACK(一个尚未确认的消息)为值;
  rax *consumers; // 该消费组中所有的消费者,消费者的名称为键,streamConsumer(代表一个消费者)为值。
} streamCG;

每个消费者通过streamConsumer唯一标识,该结构如下:

1
2
3
4
5
typedef struct streamConsumer {
  mstime_t seen_time; /* 该消费者最后一次活跃的时间; */
  sds name; /* 消费者的名称*/
  rax *pel; /* 消费者尚未确认的消息,以消息ID为键,streamNACK为值。 */
} streamConsumer;

未确认消息(streamNACK)维护了消费组或者消费者尚未确认的消息,值得注意的是,消费组中的pel的元素与每个消费者的pel中的元素是共享的,即该消费组消费了某个消息,这个消息会同时放到消费组以及该消费者的pel队列中,并且二者是同一个streamNACK结构。

1
2
3
4
5
6
/* Pending (yet not acknowledged) message in a consumer group. */
typedef struct streamNACK {
mstime_t delivery_time; /* 该消息最后发送给消费方的时间 */
uint64_t delivery_count; /*为该消息已经发送的次数(组内的成员可以通过xclaim命令获取某个消息的处理权,该消息已经分给组内另一个消费者但其并没有确认该消息)。*/
streamConsumer *consumer; /* 该消息当前归属的消费者 */
} streamNACK;

此外,还可以看下迭代器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
typedef struct streamIterator {
stream *stream; /*当前迭代器正在遍历的消息流 */
streamID master_id; /* 消息内容实际存储在listpack中,每个listpack都有一个masterentry(也就是第一个插入的消息),master_id为该消息id */
uint64_t master_fields_count; /* master entry中field域的个数. */
unsigned char *master_fields_start; /*master entry field域存储的首地址*/
unsigned char *master_fields_ptr; /*当listpack中消息的field域与master entry的field域完全相同时,该消息会复用master entry的field域,在我们遍历该消息时,需要记录当前所在的field域的具体位置,master_fields_ptr就是实现这个功能的。 */
int entry_flags; /* 当前遍历的消息的标志位 */
int rev; /*当前迭代器的方向 */
uint64_t start_key[2]; /* 该迭代器处理的消息ID的范围 */
uint64_t end_key[2]; /* End key as 128 bit big endian. */
raxIterator ri; /*rax迭代器,用于遍历rax中所有的key. */
unsigned char *lp; /* 当前listpack指针*/
unsigned char *lp_ele; /* 当前正在遍历的listpack中的元素, cursor. */
unsigned char *lp_flags; /* Current entry flags pointer.指向当前消息的flag域 */
//用于从listpack读取数据时的缓存
unsigned char field_buf[LP_INTBUF_SIZE];
unsigned char value_buf[LP_INTBUF_SIZE];
} streamIterator;
存储方式

stream 结构体中的 rax 指针,指向了 Radix Tree 的头节点,也就是 rax 结构体。rax 结构体中的头指针进一步指向了第一个 raxNode。因为我们假设就只有一个 streamID,暂时没有其他 streamID 和该 streamID 共享前缀,所以,当前这个 streamID 就可以用压缩节点保存。

然后,第一个 raxNode 指向了下一个 raxNode,也是 Radix Tree 的叶子节点。这个节点的 size 为 0,它的 value 指针指向了实际的消息内容。

streamID可以自己指定,也可以由redis生成,即由每个消息创建时的时间(1970年1月1号至今的毫秒数)以及序号组成,共128位:

1
2
3
4
typedef struct streamID {
  uint64_t ms; /* Unix time in milliseconds. */
  uint64_t seq; /* Sequence number. */
} streamID;

而在消息内容这里,是使用了 listpack 进行保存的。

一个listpack可以存储多个消息,也就是说多个raxNode可能会指向同一个listpack。

每个listpack都有一个master entry,该结构中存储了创建这个listpack时待插入消息的所有field,这主要是考虑同一个消息流,消息内容通常具有相似性,如果后续消息的field与master entry内容相同,则不需要再存储其field。master entry中每一个元素都是一个单独的entry(下图省略了listpack每个元素存储时的encoding以及backlen字段)

  • count 为当前listpack中的所有未删除的消息个数;
  • deleted 为当前listpack中所有已经删除的消息个数;
  • num-fields 为下面的field的个数;
  • field-1,…,filed-N 为当前listpack中第一个插入的消息的所有field域;
  • 0 为标识位,在从后向前遍历该listpack的所有消息时使用。

存储一个消息时,如果该消息的field域与master entry的域完全相同,则不需要再次存储field域:

  • flags字段为消息标志位,STREAM_ITEM_FLAG_NONE代表无特殊标识, STREAM_ITEM_FLAG_DELETED代表该消息已经被删除, STREAM_ITEM_FLAG_SAMEFIELDS代表该消息的field域与master entry完全相同;
  • streamID.ms以及streamID.seq为该消息ID减去master entry id之后的值;
  • value域存储了该消息的每个field域对应的内容;
  • lp-count为该消息占用listpack的元素个数,也就是3+N。

消息的field域与master entry不完全相同,存储如下:

  • flags为消息标志位,与上面一致;
  • streamID.ms,streamID.seq为该消息ID减去master entry id之后的值;
  • num-fields为该消息field域的个数;
  • field-value存储了消息的域值对,也就是消息的具体内容;
  • lp-count为该消息占用的listpack的元素个数,也就是4+2N。
添加消息

Redis提供了streamAppendItem函数,用于向stream中添加一个新的消息:

1
int streamAppendItem(stream *s, robj **argv, int64_t numfields, streamID *added_id, streamID *use_id)
  • s 为待插入的数据流;
  • argv 为待插入的消息内容,argv[0]为field_1,argv[1]为value_1,依此类推;
  • numfields 为待插入的消息的field的总数;
  • added_id 不为空,并且插入成功时,将新插入的消息id写入added_id以供调用方使用;
  • use_id 为调用方为该消息定义的消息id,该消息id应该大于s中任意一个消息的id。

大概流程如下:

  1. 获取rax的最后一个key所在的节点,由于Rax树是按照消息id的顺序存储的,所以最后一个key节点存储了上一次插入的消息;
  2. 查看该节点是否可以插入这条新的消息;
  3. 如果该节点已经不能再插入新的消息(listpack为空或者已经达到设定的存储最大值),在rax中插入新的节点(以消息id为key,新建listpack为value),并初始化新建的listpack;
  4. 如果仍然可以插入消息,则对比插入的消息与listpack中的master消息对应的fields是否完全一致,完全一致则表明该消息可以复用master的field;
  5. 将待插入的消息内容插入到新建的listpack中或者原来的rax的最后一个key节点对应的listpack中,这一步主要取决于前2步的结果。
删除消息

streamIteratorRemoveEntry函数用于移除某个消息,值得注意的是,该函数通常只是设置待移除消息的标志位为已删除,并修改master entry的统计信息,而不会将该消息从所在的listpack中删除。当消息所在的整个listpack的所有消息都已删除时,则会从rax中释放该节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void streamIteratorRemoveEntry(streamIterator *si, streamID *current) {
unsigned char *lp = si->lp;
int64_t aux;
int flags = lpGetInteger(si->lp_flags);
flags |= STREAM_ITEM_FLAG_DELETED;
lp = lpReplaceInteger(lp,&si->lp_flags,flags); // 设置消息的标志位

/* Change the valid/deleted entries count in the master entry. */
unsigned char *p = lpFirst(lp);
aux = lpGetInteger(p);
if (aux == 1) {
/* 当前Listpack只有待删除消息,可以直接删除节点. */
lpFree(lp);
raxRemove(si->stream->rax,si->ri.key,si->ri.key_len,NULL);
} else {
/* 修改listpack master enty中的统计信息 */
lp = lpReplaceInteger(lp,&p,aux-1);
p = lpNext(lp,p); /* Seek deleted field. */
aux = lpGetInteger(p);
lp = lpReplaceInteger(lp,&p,aux+1);
/* 查看listpack是否有变化(listpack中元素变化导致的扩容缩容) . */
if (si->lp != lp)
raxInsert(si->stream->rax,si->ri.key,si->ri.key_len,lp,NULL);
}
.....
}

解析配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
#是否在后台运行;no:不是后台运行
daemonize yes

#是否开启保护模式,默认开启。要是配置里没有指定bind和密码。开启该参数后,redis只会本地进行访问,拒绝外部访问。
protected-mode yes

#redis的进程文件
pidfile /var/run/redis/redis-server.pid

#redis监听的端口号。
port 6379

#此参数确定了TCP连接中已完成队列(完成三次握手之后)的长度, 当然此值必须不大于Linux系统定义的/proc/sys/net/core/somaxconn值,默认是511,而Linux的默认参数值是128。当系统并发量大并且客户端速度缓慢的时候,可以将这二个参数一起参考设定。该内核参数默认值一般是128,对于负载很大的服务程序来说大大的不够。一般会将它修改为2048或者更大。在/etc/sysctl.conf中添加:net.core.somaxconn = 2048,然后在终端中执行sysctl -p。
tcp-backlog 511

#指定 redis 只接收来自于该 IP 地址的请求,如果不进行设置,那么将处理所有请求
#bind 127.0.0.1
#bind 0.0.0.0

#配置unix socket来让redis支持监听本地连接。
# unixsocket /var/run/redis/redis.sock

#配置unix socket使用文件的权限
# unixsocketperm 700

# 此参数为设置客户端空闲超过timeout,服务端会断开连接,为0则服务端不会主动断开连接,不能小于0。
timeout 0

#tcp keepalive参数。如果设置不为0,就使用配置tcp的SO_KEEPALIVE值,使用keepalive有两个好处:检测挂掉的对端。降低中间设备出问题而导致网络看似连接却已经与对端端口的问题。在Linux内核中,设置了keepalive,redis会定时给对端发送ack。检测到对端关闭需要两倍的设置值。
tcp-keepalive 0

#指定了服务端日志的级别。级别包括:debug(很多信息,方便开发、测试),verbose(许多有用的信息,但是没有debug级别信息多),notice(适当的日志级别,适合生产环境),warn(只有非常重要的信息)
loglevel notice

#指定了记录日志的文件。空字符串的话,日志会打印到标准输出设备。后台运行的redis标准输出是/dev/null。
logfile /var/log/redis/redis-server.log

#是否打开记录syslog功能
# syslog-enabled no

#syslog的标识符。
# syslog-ident redis

#日志的来源、设备
# syslog-facility local0

#数据库的数量,默认使用的数据库是DB 0。可以通过SELECT命令选择一个db
databases 16

# redis是基于内存的数据库,可以通过设置该值定期写入磁盘。
# 注释掉“save”这一行配置项就可以让保存数据库功能失效
# 900秒(15分钟)内至少1个key值改变(则进行数据库保存--持久化)
# 300秒(5分钟)内至少10个key值改变(则进行数据库保存--持久化)
# 60秒(1分钟)内至少10000个key值改变(则进行数据库保存--持久化)
save 900 1
save 300 10
save 60 10000

#当RDB持久化出现错误后,是否依然进行继续进行工作,yes:不能进行工作,no:可以继续进行工作,可以通过info中的rdb_last_bgsave_status了解RDB持久化是否有错误
stop-writes-on-bgsave-error yes

#使用压缩rdb文件,rdb文件压缩使用LZF压缩算法,yes:压缩,但是需要一些cpu的消耗。no:不压缩,需要更多的磁盘空间
rdbcompression yes

#是否校验rdb文件。从rdb格式的第五个版本开始,在rdb文件的末尾会带上CRC64的校验和。这跟有利于文件的容错性,但是在保存rdb文件的时候,会有大概10%的性能损耗,所以如果你追求高性能,可以关闭该配置。
rdbchecksum yes

#rdb文件的名称
dbfilename dump.rdb

#数据目录,数据库的写入会在这个目录。rdb、aof文件也会写在这个目录
dir /data


############### 主从复制 ###############

#复制选项,slave复制对应的master。
# slaveof <masterip> <masterport>

#如果master设置了requirepass,那么slave要连上master,需要有master的密码才行。masterauth就是用来配置master的密码,这样可以在连上master后进行认证。
# masterauth <master-password>

#当从库同主机失去连接或者复制正在进行,从机库有两种运行方式:1) 如果slave-serve-stale-data设置为yes(默认设置),从库会继续响应客户端的请求。2) 如果slave-serve-stale-data设置为no,除去INFO和SLAVOF命令之外的任何请求都会返回一个错误”SYNC with master in progress”。
slave-serve-stale-data yes

#作为从服务器,默认情况下是只读的(yes),可以修改成NO,用于写(不建议)。
slave-read-only yes

#是否使用socket方式复制数据。目前redis复制提供两种方式,disk和socket。如果新的slave连上来或者重连的slave无法部分同步,就会执行全量同步,master会生成rdb文件。有2种方式:disk方式是master创建一个新的进程把rdb文件保存到磁盘,再把磁盘上的rdb文件传递给slave。socket是master创建一个新的进程,直接把rdb文件以socket的方式发给slave。disk方式的时候,当一个rdb保存的过程中,多个slave都能共享这个rdb文件。socket的方式就的一个个slave顺序复制。在磁盘速度缓慢,网速快的情况下推荐用socket方式。
repl-diskless-sync no

#diskless复制的延迟时间,防止设置为0。一旦复制开始,节点不会再接收新slave的复制请求直到下一个rdb传输。所以最好等待一段时间,等更多的slave连上来。
repl-diskless-sync-delay 5

#slave根据指定的时间间隔向服务器发送ping请求。时间间隔可以通过 repl_ping_slave_period 来设置,默认10秒。
# repl-ping-slave-period 10

#复制连接超时时间。master和slave都有超时时间的设置。master检测到slave上次发送的时间超过repl-timeout,即认为slave离线,清除该slave信息。slave检测到上次和master交互的时间超过repl-timeout,则认为master离线。需要注意的是repl-timeout需要设置一个比repl-ping-slave-period更大的值,不然会经常检测到超时。
# repl-timeout 60

#是否禁止复制tcp链接的tcp nodelay参数,可传递yes或者no。默认是no,即使用tcp nodelay。如果master设置了yes来禁止tcp nodelay设置,在把数据复制给slave的时候,会减少包的数量和更小的网络带宽。但是这也可能带来数据的延迟。默认我们推荐更小的延迟,但是在数据量传输很大的场景下,建议选择yes。
repl-disable-tcp-nodelay no

#复制缓冲区大小,这是一个环形复制缓冲区,用来保存最新复制的命令。这样在slave离线的时候,不需要完全复制master的数据,如果可以执行部分同步,只需要把缓冲区的部分数据复制给slave,就能恢复正常复制状态。缓冲区的大小越大,slave离线的时间可以更长,复制缓冲区只有在有slave连接的时候才分配内存。没有slave的一段时间,内存会被释放出来,默认1m。
# repl-backlog-size 5mb

#master没有slave一段时间会释放复制缓冲区的内存,repl-backlog-ttl用来设置该时间长度。单位为秒。
# repl-backlog-ttl 3600

#当master不可用,Sentinel会根据slave的优先级选举一个master。最低的优先级的slave,当选master。而配置成0,永远不会被选举。
slave-priority 100

#redis提供了可以让master停止写入的方式,如果配置了min-slaves-to-write,健康的slave的个数小于N,mater就禁止写入。master最少得有多少个健康的slave存活才能执行写命令。这个配置虽然不能保证N个slave都一定能接收到master的写操作,但是能避免没有足够健康的slave的时候,master不能写入来避免数据丢失。设置为0是关闭该功能。
# min-slaves-to-write 3

#延迟小于min-slaves-max-lag秒的slave才认为是健康的slave。
# min-slaves-max-lag 10

# 设置1或另一个设置为0禁用这个特性。
# Setting one or the other to 0 disables the feature.
# By default min-slaves-to-write is set to 0 (feature disabled) and
# min-slaves-max-lag is set to 10.


############### 安全相关 ###############

#requirepass配置可以让用户使用AUTH命令来认证密码,才能使用其他命令。这让redis可以使用在不受信任的网络中。为了保持向后的兼容性,可以注释该命令,因为大部分用户也不需要认证。使用requirepass的时候需要注意,因为redis太快了,每秒可以认证15w次密码,简单的密码很容易被攻破,所以最好使用一个更复杂的密码。注意只有密码没有用户名。
# requirepass foobared

#把危险的命令给修改成其他名称。比如CONFIG命令可以重命名为一个很难被猜到的命令,这样用户不能使用,而内部工具还能接着使用。
# rename-command CONFIG b840fc02d524045429941cc15f59e41cb7be6c52

#设置成一个空的值,可以禁止一个命令
# rename-command CONFIG ""


############### 进程限制相关 ###############

# 设置能连上redis的最大客户端连接数量。默认是10000个客户端连接。由于redis不区分连接是客户端连接还是内部打开文件或者和slave连接等,所以maxclients最小建议设置到32。如果超过了maxclients,redis会给新的连接发送’max number of clients reached’,并关闭连接。
# maxclients 10000

#redis配置的最大内存容量。当内存满了,需要配合maxmemory-policy策略进行处理。注意slave的输出缓冲区是不计算在maxmemory内的。所以为了防止主机内存使用完,建议设置的maxmemory需要更小一些。
# maxmemory <bytes>

#内存容量超过maxmemory后的处理策略。
#volatile-lru:利用LRU算法移除设置过过期时间的key。
#volatile-random:随机移除设置过过期时间的key。
#volatile-ttl:移除即将过期的key,根据最近过期时间来删除(辅以TTL)
#allkeys-lru:利用LRU算法移除任何key。
#allkeys-random:随机移除任何key。
#noeviction:不移除任何key,只是返回一个写错误。
#上面的这些驱逐策略,如果redis没有合适的key驱逐,对于写命令,还是会返回错误。redis将不再接收写请求,只接收get请求。写命令包括:set setnx setex append incr decr rpush lpush rpushx lpushx linsert lset rpoplpush sadd sinter sinterstore sunion sunionstore sdiff sdiffstore zadd zincrby zunionstore zinterstore hset hsetnx hmset hincrby incrby decrby getset mset msetnx exec sort。
# maxmemory-policy noeviction

#lru检测的样本数。使用lru或者ttl淘汰算法,从需要淘汰的列表中随机选择sample个key,选出闲置时间最长的key移除。
# maxmemory-samples 5


############### APPEND ONLY 持久化方式 ###############

#默认redis使用的是rdb方式持久化,这种方式在许多应用中已经足够用了。但是redis如果中途宕机,会导致可能有几分钟的数据丢失,根据save来策略进行持久化,Append Only File是另一种持久化方式,可以提供更好的持久化特性。Redis会把每次写入的数据在接收后都写入 appendonly.aof 文件,每次启动时Redis都会先把这个文件的数据读入内存里,先忽略RDB文件。
appendonly no

#aof文件名
appendfilename "appendonly.aof"

#aof持久化策略的配置
#no表示不执行fsync,由操作系统保证数据同步到磁盘,速度最快。
#always表示每次写入都执行fsync,以保证数据同步到磁盘。
#everysec表示每秒执行一次fsync,可能会导致丢失这1s数据。
appendfsync everysec

# 在aof重写或者写入rdb文件的时候,会执行大量IO,此时对于everysec和always的aof模式来说,执行fsync会造成阻塞过长时间,no-appendfsync-on-rewrite字段设置为默认设置为no,是最安全的方式,不会丢失数据,但是要忍受阻塞的问题。如果对延迟要求很高的应用,这个字段可以设置为yes,,设置为yes表示rewrite期间对新写操作不fsync,暂时存在内存中,不会造成阻塞的问题(因为没有磁盘竞争),等rewrite完成后再写入,这个时候redis会丢失数据。Linux的默认fsync策略是30秒。可能丢失30秒数据。因此,如果应用系统无法忍受延迟,而可以容忍少量的数据丢失,则设置为yes。如果应用系统无法忍受数据丢失,则设置为no。
no-appendfsync-on-rewrite no

#aof自动重写配置。当目前aof文件大小超过上一次重写的aof文件大小的百分之多少进行重写,即当aof文件增长到一定大小的时候Redis能够调用bgrewriteaof对日志文件进行重写。当前AOF文件大小是上次日志重写得到AOF文件大小的二倍(设置为100)时,自动启动新的日志重写过程。
auto-aof-rewrite-percentage 100
#设置允许重写的最小aof文件大小,避免了达到约定百分比但尺寸仍然很小的情况还要重写
auto-aof-rewrite-min-size 64mb

#aof文件可能在尾部是不完整的,当redis启动的时候,aof文件的数据被载入内存。重启可能发生在redis所在的主机操作系统宕机后,尤其在ext4文件系统没有加上data=ordered选项(redis宕机或者异常终止不会造成尾部不完整现象。)出现这种现象,可以选择让redis退出,或者导入尽可能多的数据。如果选择的是yes,当截断的aof文件被导入的时候,会自动发布一个log给客户端然后load。如果是no,用户必须手动redis-check-aof修复AOF文件才可以。
aof-load-truncated yes


############### LUA SCRIPTING ###############

# 如果达到最大时间限制(毫秒),redis会记个log,然后返回error。当一个脚本超过了最大时限。只有SCRIPT KILL和SHUTDOWN NOSAVE可以用。第一个可以杀没有调write命令的东西。要是已经调用了write,只能用第二个命令杀。
lua-time-limit 5000


############### 集群相关 ###############

#集群开关,默认是不开启集群模式。
# cluster-enabled yes

#集群配置文件的名称,每个节点都有一个集群相关的配置文件,持久化保存集群的信息。这个文件并不需要手动配置,这个配置文件有Redis生成并更新,每个Redis集群节点需要一个单独的配置文件,请确保与实例运行的系统中配置文件名称不冲突
# cluster-config-file nodes-6379.conf

#节点互连超时的阀值。集群节点超时毫秒数
# cluster-node-timeout 15000

#在进行故障转移的时候,全部slave都会请求申请为master,但是有些slave可能与master断开连接一段时间了,导致数据过于陈旧,这样的slave不应该被提升为master。该参数就是用来判断slave节点与master断线的时间是否过长。判断方法是:
#比较slave断开连接的时间和(node-timeout * slave-validity-factor) + repl-ping-slave-period
#如果节点超时时间为三十秒, 并且slave-validity-factor为10,假设默认的repl-ping-slave-period是10秒,即如果超过310秒slave将不会尝试进行故障转移
# cluster-slave-validity-factor 10

#master的slave数量大于该值,slave才能迁移到其他孤立master上,如这个参数若被设为2,那么只有当一个主节点拥有2 个可工作的从节点时,它的一个从节点会尝试迁移。
# cluster-migration-barrier 1

#默认情况下,集群全部的slot有节点负责,集群状态才为ok,才能提供服务。设置为no,可以在slot没有全部分配的时候提供服务。不建议打开该配置。
# cluster-require-full-coverage yes


############### SLOW LOG 慢查询日志 ###############

###slog log是用来记录redis运行中执行比较慢的命令耗时。当命令的执行超过了指定时间,就记录在slow log中,slog log保存在内存中,所以没有IO操作。
#执行时间比slowlog-log-slower-than大的请求记录到slowlog里面,单位是微秒,所以1000000就是1秒。注意,负数时间会禁用慢查询日志,而0则会强制记录所有命令。
slowlog-log-slower-than 10000

#慢查询日志长度。当一个新的命令被写进日志的时候,最老的那个记录会被删掉。这个长度没有限制。只要有足够的内存就行。你可以通过 SLOWLOG RESET 来释放内存。
slowlog-max-len 128

############### 延迟监控 ###############
#延迟监控功能是用来监控redis中执行比较缓慢的一些操作,用LATENCY打印redis实例在跑命令时的耗时图表。只记录大于等于下边设置的值的操作。0的话,就是关闭监视。默认延迟监控功能是关闭的,如果你需要打开,也可以通过CONFIG SET命令动态设置。
latency-monitor-threshold 0

############### EVENT NOTIFICATION 订阅通知 ###############
#键空间通知使得客户端可以通过订阅频道或模式,来接收那些以某种方式改动了 Redis 数据集的事件。因为开启键空间通知功能需要消耗一些 CPU ,所以在默认配置下,该功能处于关闭状态。
#notify-keyspace-events 的参数可以是以下字符的任意组合,它指定了服务器该发送哪些类型的通知:
##K 键空间通知,所有通知以 __keyspace@__ 为前缀
##E 键事件通知,所有通知以 __keyevent@__ 为前缀
##g DEL 、 EXPIRE 、 RENAME 等类型无关的通用命令的通知
##$ 字符串命令的通知
##l 列表命令的通知
##s 集合命令的通知
##h 哈希命令的通知
##z 有序集合命令的通知
##x 过期事件:每当有过期键被删除时发送
##e 驱逐(evict)事件:每当有键因为 maxmemory 政策而被删除时发送
##A 参数 g$lshzxe 的别名
#输入的参数中至少要有一个 K 或者 E,否则的话,不管其余的参数是什么,都不会有任何 通知被分发。详细使用可以参考http://redis.io/topics/notifications

notify-keyspace-events ""

############### ADVANCED CONFIG 高级配置 ###############
#数据量小于等于hash-max-ziplist-entries的用ziplist,大于hash-max-ziplist-entries用hash
hash-max-ziplist-entries 512
#value大小小于等于hash-max-ziplist-value的用ziplist,大于hash-max-ziplist-value用hash
hash-max-ziplist-value 64

#数据量小于等于list-max-ziplist-entries用ziplist,大于list-max-ziplist-entries用list。
list-max-ziplist-entries 512
#value大小小于等于list-max-ziplist-value的用ziplist,大于list-max-ziplist-value用list。
list-max-ziplist-value 64

#数据量小于等于set-max-intset-entries用iniset,大于set-max-intset-entries用set
set-max-intset-entries 512

#数据量小于等于zset-max-ziplist-entries用ziplist,大于zset-max-ziplist-entries用zset。
zset-max-ziplist-entries 128
#value大小小于等于zset-max-ziplist-value用ziplist,大于zset-max-ziplist-value用zset。
zset-max-ziplist-value 64

#value大小小于等于hll-sparse-max-bytes使用稀疏数据结构(sparse),大于hll-sparse-max-bytes使用稠密的数据结构(dense)。一个比16000大的value是几乎没用的,建议的value大概为3000。如果对CPU要求不高,对空间要求较高的,建议设置到10000左右。
hll-sparse-max-bytes 3000

#Redis将在每100毫秒时使用1毫秒的CPU时间来对redis的hash表进行重新hash,可以降低内存的使用。当你的使用场景中,有非常严格的实时性需要,不能够接受Redis时不时的对请求有2毫秒的延迟的话,把这项配置为no。如果没有这么严格的实时性要求,可以设置为yes,以便能够尽可能快的释放内存。
activerehashing yes

##对客户端输出缓冲进行限制可以强迫那些不从服务器读取数据的客户端断开连接,用来强制关闭传输缓慢的客户端。
#对于normal client,第一个0表示取消hard limit,第二个0和第三个0表示取消soft limit,normal client默认取消限制,因为如果没有寻问,他们是不会接收数据的。
client-output-buffer-limit normal 0 0 0
#对于slave client和MONITER client,如果client-output-buffer一旦超过256mb,又或者超过64mb持续60秒,那么服务器就会立即断开客户端连接。
client-output-buffer-limit slave 256mb 64mb 60
#对于pubsub client,如果client-output-buffer一旦超过32mb,又或者超过8mb持续60秒,那么服务器就会立即断开客户端连接。
client-output-buffer-limit pubsub 32mb 8mb 60

#redis执行任务的频率为1s除以hz。
hz 10

#在aof重写的时候,如果打开了aof-rewrite-incremental-fsync开关,系统会每32MB执行一次fsync。这对于把文件写入磁盘是有帮助的,可以避免过大的延迟峰值。
aof-rewrite-incremental-fsync yes

Redis的持久化

RDB

介绍

RDB是redis持久化的一种方式,可以手动或在指定的时间间隔内生成内存中整个数据集的持久化快照。RDB文件是一个经过压缩的二进制文件,默认被存储在当前文件夹中,名称为dump.rdb,可以通过dir和dbfilename参数来修改默认值。

配置文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# redis是基于内存的数据库,可以通过设置该值定期写入磁盘。
# 注释掉“save”这一行配置项就可以让保存数据库功能失效
# 900秒(15分钟)内至少1个key值改变(则进行数据库保存--持久化)
# 300秒(5分钟)内至少10个key值改变(则进行数据库保存--持久化)
# 60秒(1分钟)内至少10000个key值改变(则进行数据库保存--持久化)
save 900 1
save 300 10
save 60 10000

#当RDB持久化出现错误后,是否依然进行继续进行工作,yes:不能进行工作,no:可以继续进行工作,可以通过info中的rdb_last_bgsave_status了解RDB持久化是否有错误
stop-writes-on-bgsave-error yes

#使用压缩rdb文件,rdb文件压缩使用LZF压缩算法,yes:压缩,但是需要一些cpu的消耗。no:不压缩,需要更多的磁盘空间
rdbcompression yes

#是否校验rdb文件。从rdb格式的第五个版本开始,在rdb文件的末尾会带上CRC64的校验和。这跟有利于文件的容错性,但是在保存rdb文件的时候,会有大概10%的性能损耗,所以如果你追求高性能,可以关闭该配置。
rdbchecksum yes

#rdb文件的名称
dbfilename dump.rdb

#数据目录,数据库的写入会在这个目录。rdb、aof文件也会写在这个目录
dir /data

创建RDB文件

命令创建

redis中有两个命令可以生成RDB文件:SAVE和BGSAVE,创建RDB文件的实际工作由rdb.c/rdbsave函数完成。

SAVE命令会阻塞 Redis服务器进程,直到RDB文件创建完毕为止,在服务器进程阻塞期间,服务器不能处理任何命令请求;

BGSAVE命令会fork出一个子进程,然后由子进程负责创建RDB文件,服务器进程(父进程)继续处理命令请求。但是,在BGSAVE命令执行期间,服务器处理SAVE、BGSAVE、BGREWRITEAOF三个命令的方式会和平时有所不同。

  • 在BGSAVE命令执行期间,客户端发送的SAVE命令会被服务器拒绝,服务器禁止SAVE命令和BGSAVE命令同时执行是为了避免父进程(服务器进程)和子进程同时执行两个rdbSave调用,防止产生竞争条件;

  • 在BGSAVE命令执行期间,客户端发送的BGSAVE命令会被服务器拒绝,因为同时执行两个BGSAVE命令也会产生竞争条件;

  • BGREWRITEAOF和BGSAVE两个命令不能同时执行,如果BGSAVE命令正在执行,那么客户端发送的BGREWRITEAOF命令会被延迟到BGSAVE命令执行完毕之后执行;如果BGREWRITEAOF命令正在执行,也同样会被延迟到BGSAVE命令执行完毕之后执行。(具体的可以看源码分析,bgsaveCommand和bgrewriteaofCommand都会分别设置一个scheduled,之后在serverCron中再进行判断执行对应的处理函数)

    其实BGREWRITEAOF和BGSAVE两个命令并没有什么冲突的地方,不同时执行是基于性能方面的考虑,如果同时两个线程都同时执行大量的磁盘写入操作,确实不是一个好主意。

自动间隔创建

Redis 允许用户通过设置服务器配置的save选项,让服务器每隔一段时间自动执行一次BGSAVE命令。

1
2
3
save 900 1
save 300 10
save 60 10000
  • 服务器在900秒之内,对数据库进行了至少1次修改;
  • 服务器在300秒之内,对数据库进行了至少10次修改;
  • 服务器在60秒之内,对数据库进行了至少10000次修改。

接着,服务器程序会根据save选项所设置的保存条件,设置服务器状态redisserver结构的saveparams数组,数组中的每个元素都是一个saveparam结构,每个saveparam结构都保存了一个save选项设置的保存条件:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct redisServer {
...
long long dirty; /* Changes to DB from the last save */
long long dirty_before_bgsave; /* Used to restore dirty on failed BGSAVE */
struct saveparam *saveparams; /* Save points array for RDB */
int saveparamslen; /* Number of saving points */
...
};

struct saveparam {
time_t seconds;
int changes;
};

除了saveparams数组之外,服务器状态还维持着一个dirty计数器,以及一个lastsave属性:

  • lastsave属性是一个UNIX时间截,记录了服务器上一次成功执行SAVE命令或者BGSAVE命令的时间。
  • dirty计数器记录距离上一次成功执行SAVE命令或者BGSAVE命令之后,服务器对数据库状态(服务器中的所有数据库)进行了多少次修改(包括写入、删除、更新等操作)。

Redis的服务器周期性操作函数servercron 默认每隔100毫秒就会执行一次,该函数用于对正在运行的服务器进行维护,它的其中一项工作就是检查save选项所设置的保存条件是否已经满足,如果满足的话,就执行BGSAVE命令。

其它

通过flushall命令,也会产生dump.rdb文件,但是里面是空的,无意义。

通过shutdown命令,安全退出,也会生成快照文件(和异常退出形成对比,比如:kill杀死进程的方式)

恢复

Redis并没有专门用于载入RDB文件的命令,只要Redis服务器在启动时检测到RDB文件存在,它就会自动载入RDB文件。

如果服务器开启了AOF持久化功能,那么服务器会优先使用AOF 文件来还原数据,因为AOF 文件的更新频率通常比RDB文件的更新频率高。只有在AOF持久化功能处于关闭状态时,服务器才会使用RDB文件来还原数据库状态。

1
2
3
appendonly no
dbfilename dump.rdb
dir /var/lib/redis #可以自行指定

appendonly 设置成no,redis启动时会把/var/lib/redis 目录下的dump.rdb 中的数据恢复。dir 和dbfilename 都可以设置。我测试时appendonly 设置成yes 时候不会将dump.rdb文件中的数据恢复

BGSAVE

BGSAVE是基于COW(copy-on-write)实现的,Redis创建子进程以后,利用cow方式完成快照文件的生成。

cow的意思是资源的复制是在只有需要写入时才会发生,如果有多个调用者(callers)同时请求相同资源(如内存或磁盘上的数据存储),他们会共同获取相同的指针指向相同的资源,直到某个调用者试图修改资源的内容时,系统才会真正复制一份专用副本(private copy)给该调用者,而其他调用者所见到的最初的资源仍然保持不变。此作法主要的优点是如果调用者没有修改该资源,就不会有副本(private copy)被建立,因此多个调用者只是读取操作时可以共享同一份资源。它的实现是依靠fork()和exec()两个函数。

fork&exec

fork用于创建子进程,这个子进程是通过父进程复制得到的,和父进程除了pid其它完全相同。内核会给子进程的数据段、堆栈段分配相应的物理空间(至此两者有各自的进程空间,互不影响),而代码段继续共享父进程的物理空间(两者的代码完全相同),并且虚拟空间相同。

fork作为一个函数被调用。这个函数会有两次返回,三种可能的值,将子进程的PID返回给父进程,0返回给子进程。如果出现错误,fork返回一个负值。

在linux中,init进程是所有进程的爹,Linux的进程都通过init进程或init的子进程fork(vfork)出来的。

vfock:内核连子进程的虚拟地址空间结构也不创建了,直接共享了父进程的虚拟空间,当然了,这种做法就顺水推舟的共享了父进程的物理空间。

exec函数的作用就是:装载一个新的程序(可执行映像)覆盖当前进程内存空间中的映像,从而执行不同的任务。由于两者执行的代码不同,子进程的代码段也会分配单独的物理空间。

cow

fork()会产生一个和父进程完全相同的子进程(除了pid),如果按传统的做法,会直接将父进程的数据拷贝到子进程中,拷贝完之后,父进程和子进程之间的数据段和堆栈是相互独立的

但是,以我们的使用经验来说:往往子进程都会执行exec()来做自己想要实现的功能。所以,如果按照上面的做法的话,创建子进程时复制过去的数据是没用的(因为子进程执行exec(),原有的数据会被清空)

既然很多时候复制给子进程的数据是无效的,于是就有了Copy On Write这项技术了,原理也很简单:

  • fork创建出的子进程,与父进程共享内存空间。也就是说,如果父/子进程不对内存空间进行写入操作的话,内存空间中的数据并不会复制给子进程,这样创建子进程的速度就很快了!(不用复制,直接引用父进程的物理空间)。
  • 并且如果在fork函数返回之后,子进程第一时间exec一个新的可执行映像,那么也不会浪费时间和内存空间了。

也就是:在fork之后exec之前两个进程用的是相同的物理空间(内存区),子进程的代码段、数据段、堆栈都是指向父进程的物理空间,也就是说,两者的虚拟空间不同,但其对应的物理空间是同一个。当父子进程中有更改相应段的行为发生时,再为子进程相应的段分配物理空间。如果不是因为exec,内核会给子进程的数据段、堆栈段分配相应的物理空间(至此两者有各自的进程空间,互不影响),而代码段继续共享父进程的物理空间(两者的代码完全相同)。而如果是因为exec,由于两者执行的代码不同,子进程的代码段也会分配单独的物理空间。

Copy On Write技术实现原理:

fork()之后,kernel把父进程中所有的内存页的权限都设为read-only,然后子进程的地址空间指向父进程。当父子进程都只读内存时,相安无事。当其中某个进程写内存时,CPU硬件检测到内存页是read-only的,于是触发页异常中断(page-fault),陷入kernel的一个中断例程。中断例程中,kernel就会把触发的异常的页复制一份,于是父子进程各自持有独立的一份。

Copy On Write技术好处是什么?

  • COW技术可减少分配和复制大量资源时带来的瞬间延时
  • COW技术可减少不必要的资源分配。比如fork进程时,并不是所有的页面都需要复制,父进程的代码段和只读数据段都不被允许修改,所以无需复制

Copy On Write技术缺点是什么?

  • 如果在fork()之后,父子进程都还需要继续进行写操作,那么会产生大量的分页错误(页异常中断page-fault),这样就得不偿失。

如果有需要,我们会用exec()把当前进程映像替换成新的进程文件,完成自己想要实现的功能。

Redis的cow
  • Redis创建子进程以后,根本不进行数据的copy,主进程与子线程是共享数据的。主进程继续对外提供读写服务。
  • 虽然不copy数据,但是kernel会把主进程中的所有内存页的权限都设为read-only,主进程和子进程访问数据的指针都指向同一内存地址。
  • 主进程发生写操作时,因为权限已经设置为read-only了,所以会触发页异常中断(page-fault)。在中断处理中,需要被写入的内存页面会复制一份,复制出来的旧数据交给子进程使用,然后主进程该干啥就干啥。

也就是说,在进行IO操作写盘的过程中(on write),对于没有改变的数据,主进程和子进程资源共享;只有在出现了需要变更的数据时(写脏的数据),才进行copy操作。

在最理想的情况下,也就是生成RDB文件的过程中,一直没有写操作的话,就根本不会发生内存的额外占用。

文件系统的cow

更新数据块时,数据块被读入内存,进行修改,然后写入新位置,而旧数据则保持不变。

好处是保护数据:本地文件系统由于有备份机制,不会因为文件系统崩溃导致大量甚至全部数据丢失。

RDB文件结构

RDB总体结构
  • RIDES常量的长度为5字节,保存了“REDIS”五个字符。通过这五个字符,程序可以在载入文件时,快速检查所载入的文件是否RDB文件。
  • db_version记录了RDB文件的版本号,为4字节。“0006”就是第六版。
  • databases部分包含着零个或任意多个数据库,以及各个数据库中的键值对数据。如果服务器的数据库状态为空(所有数据库都是空的),那么这个部分也为空,长度为0字节;如果服务器的数据库状态为非空(有至少一个数据库非空),那么这个部分也为非空。
  • EOF常量的长度为1字节,为255,即FF,这个常量标志着RDB文件正文内容的结束,当读入程序遇到这个值的时候,它知道所有数据库的所有键值对都已经载入完毕了。
  • check_sum是一个8字节长的无符号整数,保存着一个校验和,这个校验和是程序通过对REDIS、db_version、databases、EOF 四个部分的内容进行计算得出的。服务器在载入RDB文件时,会将载入数据所计算出的校验和与check_sum所记录的校验和进行对比,以此来检查RDB文件是否有出错或者损坏的情况出现。

为了方便区分,常量是大写,变量和数据是小写。(《redis设计与实现》这么)

例子:

database结构

每个非空数据库都可以保存:

  • SELECTDB常量的长度为1字节,为254,即FE,表示接下来读的是一个数据库号码。
  • db_number保存着一个数据库号码,根据号码的大小不同,这个部分的长度可以是1字节、2字节或者5字节。当程序读人db_number部分之后,服务器会调用SELECT命令,根据读入的数据库号码进行数据库切换,使得之后读入的键值对可以载入到正确的数据库中。
  • key_value_pairs 部分保存了数据库中的所有键值对数据,如果键值对带有过期时间,那么过期时间也会和键值对保存在一起。根据键值对的数量、类型、内容以及是否有过期时间等条件的不同,key_value_pairs 部分的长度也会有所不同。
key_value_pairs

不带过期时间的键值对:

  • TYPE记录了value的类型,表明了value的编码格式,为1字节,值为常量其中一个:REDIS_RDB_TYPE_STRINGR、EDIS_RDB_TYPE_LIST、REDIS_RDB_TYPE_SET、REDIS_RDB_TYPE_ZSET、REDIS_RDB_TYPE_HASH、REDIS_RDB_TYPE_LIST_ZIPLIST、REDIS_RDB_TYPE__SET_INTSET、REDIS_RDB_TYPE_ZSET_ZIPLIST、REDIS_RDB_TYPE_HASH_ZIPLIST
  • 其中key总是一个字符串对象,它的编码方式和REDIS_RDB_TYPE_STRING类型的value一样
  • value根据type类型的不同,以及保存内容长度的不同,结构和长度也会有所不同。

带过期时间的键值对:

  • EXPIRETIME_MS常量的长度为1字节,它告知读入程序,接下来要读入的将是一个以毫秒为单位的过期时间
  • ms是一个8字节长带符号整数,记录着一个以毫秒为单位的UNIX时间戳,这个时间戳就是键值对的过期时间

value:RDB文件中的每个value部分都保存了一个值对象,每个值对象的类型都由与之对应的TYPE记录,根据类型的不同,value部分的结构,长度也会有所不同

  1. 字符串对象

    字符串对象的编码可以是REDIS_ENCODING_INT或者REDIS_ENCODING_RAW

    如果编码为REDIS_ENCODING_INT,那么说明对象中保存的长度不超过32位的整数,这种编码将以如图所示:

    其中,ENCODING的值可以是REDIS_RDB_ENC_INT8,REDIS_RDB_ENC_INT16或者R。EDIS_RDB_ENC_INT32三个常量的其中一个,他们分别代表RDB文件使用8位,16位或者32位来保存整数值integer。

    如果字符串对象的编码为REDIS_ENCODING_RAW,那么说明对象所保存的是一个字符串值,根据字符串长度的不同,有压缩和不压缩两种方法来保存:

    • 长度小于等于20字节,原样保存
    • 长度大于20字节,压缩保存

    无压缩结构:

    压缩结构:

    采用LZF算法进行压缩,读入程序在碰到这个常量后,会根据之后的compressed_len记录的是字符串被压缩之后的长度,而origin_len记录的是原长度。

  2. 哈希对象

    hash_size记录了哈希表的大小,也即是这个哈希表保存了多少键值对,读入程序可以通过这个大小知道自己应该读入多少个键值对

    key_value_pair开头的部分代表哈希表中的键值对,键值对的键和值都是字符串对象,所以程序会以处理字符串对象的方式来保存和读入键值对

  3. 列表对象

    list_length记录了列表的长度,它记录列表保存了多少个项(item),读入程序可以通过这个长度知道自己应该读入多少个列表项

  4. 集合对象

    set_size是集合的大小,它记录集合保存了多少个元素。

    图中以elem开头的部分代表集合的元素,因为每个集合元素都是一个字符串对象,所以程序会以处理字符串对象的方式来保存和读入集合元素

  5. 有序集合对象

    sorted_set_size记录了有序集合的大小,以element开头的部分代表有序集合中的元素,分为成员分值两部分。

  6. INTSET编码的集合

    数集合对象,RDB文件保存这种对象的方法是,先将整数集合转换为字符串对象,然后将这个字符串对象保存到RDB 文件里面。读入时先读入字符串对象,再将这个字符串对象转换成原来的整数集合对象。

  7. ZIPLIST编码的列表,哈希表或者有序集合

    RDB文件保存这种对象的方法:将压缩列表转换成一个字符串对象,然后将转换所得的字符串对象保存到RDB文件。恢复操作:读入字符串对象,并将它转换成原来的压缩列表对象,然后根据TYPE的值,进行不同类型的转换

优势

  1. 恢复数据的速度很快,适合大规模的数据恢复,而又对部分数据不敏感的情况
  2. dump.db文件是一个压缩的二进制文件,文件暂用空间小

劣势

  1. 当出现异常退出时,会丢失最后一次快照后的数据
  2. 当fork的时候,内存的中的数据会被克隆一份,大致两倍的膨胀需要考虑。而且,当数据过大时,fork操作占用过多的系统资源,造成主服务器进程假死。

使用场景

  1. 数据备份
  2. 服务器数据同步
  3. 可容忍部分数据丢失
  4. 跨数据中心的容灾备份

源码分析

RDB的所有相关操作都存在rdb.c文件中,通过saveCommand(client *c)、bgsaveCommand(client *c)这两个函数可以知道save命令和bgsave命令真正执行的持久化逻辑是来自于:rdbSave(char *filename, rdbSaveInfo *rsi)、rdbSaveBackground。

先来看下saveCommand

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void saveCommand(client *c) {
// 检查是否后台已经有子进程在执行save,如果有就停止执行。
if (server.rdb_child_pid != -1) {
addReplyError(c,"Background save already in progress");
return;
}
rdbSaveInfo rsi, *rsiptr;
rsiptr = rdbPopulateSaveInfo(&rsi);

//调用rdbsave进行持久化
if (rdbSave(server.rdb_filename,rsiptr) == C_OK) {
//回复信息
addReply(c,shared.ok);
} else {
addReply(c,shared.err);
}
}

addReply方法可以看服务端中的介绍。

bgsaveCommand

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
void bgsaveCommand(client *c) {
int schedule = 0;

/* The SCHEDULE option changes the behavior of BGSAVE when an AOF rewrite
* is in progress. Instead of returning an error a BGSAVE gets scheduled. */
if (c->argc > 1) {
if (c->argc == 2 && !strcasecmp(c->argv[1]->ptr,"schedule")) {
schedule = 1;
} else {
addReply(c,shared.syntaxerr);
return;
}
}

rdbSaveInfo rsi, *rsiptr;
rsiptr = rdbPopulateSaveInfo(&rsi);


if (server.rdb_child_pid != -1) {
// 检查是否后台已经有子进程在执行save,如果有就停止执行。
addReplyError(c,"Background save already in progress");
} else if (server.aof_child_pid != -1) {
if (schedule) {
// 如果aof重写已经在执行中了,这次执行会放到serverCron中执行
server.rdb_bgsave_scheduled = 1;
addReplyStatus(c,"Background saving scheduled");
} else {
addReplyError(c,
"An AOF log rewriting in progress: can't BGSAVE right now. "
"Use BGSAVE SCHEDULE in order to schedule a BGSAVE whenever "
"possible.");
}
} else if (rdbSaveBackground(server.rdb_filename,rsiptr) == C_OK) {
//调用rdbSaveBackground进行持久化
addReplyStatus(c,"Background saving started");
} else {
addReply(c,shared.err);
}
}
rdbSave

Redis 的 rdbSave 函数是真正进行 RDB 持久化的函数,它的大致流程如下:

  • 首先创建一个临时文件;
  • 创建并初始化rio,rio是redis对io的一种抽象,提供了read、write、flush、checksum……等方法;
  • 调用 rdbSaveRio(),将当前 Redis 的内存信息全量写入到临时文件中;
  • 调用 fflush、 fsync 和 fclose 接口将文件写入磁盘中;
  • 使用 rename 将临时文件改名为 正式的 RDB 文件;
  • 将server.dirty清零,server.dirty是用了记录在上次生成rdb后有多少次数据变更,会在serverCron中用到。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
/* rdb磁盘写入操作 */
int rdbSave(char *filename, rdbSaveInfo *rsi) {
char tmpfile[256];
char cwd[MAXPATHLEN]; /* Current working dir path for error messages. */
FILE *fp = NULL;
rio rdb;
int error = 0;

//创建并获取一个临时文件
snprintf(tmpfile,256,"temp-%d.rdb", (int) getpid());
fp = fopen(tmpfile,"w");
if (!fp) {
char *cwdp = getcwd(cwd,MAXPATHLEN);
serverLog(LL_WARNING,
"Failed opening the RDB file %s (in server root dir %s) "
"for saving: %s",
filename,
cwdp ? cwdp : "unknown",
strerror(errno));
return C_ERR;
}

// 初始化rio
rioInitWithFile(&rdb,fp);
startSaving(RDBFLAGS_NONE);

if (server.rdb_save_incremental_fsync)
rioSetAutoSync(&rdb,REDIS_AUTOSYNC_BYTES);

// 将内存数据dump到rdb
if (rdbSaveRio(&rdb,&error,RDBFLAGS_NONE,rsi) == C_ERR) {
errno = error;
goto werr;
}

//调用fflush将输出缓冲区刷新到page cache(内核缓冲区),然后调用fsync将cache中的内容写盘,最后关闭文件。
if (fflush(fp)) goto werr;
if (fsync(fileno(fp))) goto werr;
if (fclose(fp)) { fp = NULL; goto werr; }
fp = NULL;

//把临时文件重命名为正式文件名
if (rename(tmpfile,filename) == -1) {
char *cwdp = getcwd(cwd,MAXPATHLEN);
serverLog(LL_WARNING,
"Error moving temp DB file %s on the final "
"destination %s (in server root dir %s): %s",
tmpfile,
filename,
cwdp ? cwdp : "unknown",
strerror(errno));
unlink(tmpfile);
stopSaving(0);
return C_ERR;
}

//最后,打印日志,重置dirty和lastsave,这两个值会影响被动触发rdb dump的时机(自动间隔创建)。
serverLog(LL_NOTICE,"DB saved on disk");
server.dirty = 0;
server.lastsave = time(NULL);
server.lastbgsave_status = C_OK;
stopSaving(1);
return C_OK;

werr:
serverLog(LL_WARNING,"Write error saving DB on disk: %s", strerror(errno));
if (fp) fclose(fp);
unlink(tmpfile);
stopSaving(0);
return C_ERR;
}

将内存中的数据写入到文件中主要是rdbSaveRio函数(注意了解rdb文件的格式):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
int rdbSaveRio(rio *rdb, int *error, int flags, rdbSaveInfo *rsi) {
dictIterator *di = NULL;
dictEntry *de;
char magic[10];
int j;
uint64_t cksum;
size_t processed = 0;

if (server.rdb_checksum)
rdb->update_cksum = rioGenericUpdateChecksum;
// rdb文件中最先写入的内容就是magic,magic就是REDIS这个字符串+4位版本号
snprintf(magic,sizeof(magic),"REDIS%04d",RDB_VERSION);
if (rdbWriteRaw(rdb,magic,9) == -1) goto werr;
if (rdbSaveInfoAuxFields(rdb,flags,rsi) == -1) goto werr;
if (rdbSaveModulesAux(rdb, REDISMODULE_AUX_BEFORE_RDB) == -1) goto werr;

//遍历每一个db
for (j = 0; j < server.dbnum; j++) {
redisDb *db = server.db+j;
//获取当前存储K-V对的字典,先判断DB是否为空,如果为空就跳过。然后获取该DB的迭代器。
dict *d = db->dict;
if (dictSize(d) == 0) continue;
di = dictGetSafeIterator(d);

/* Write the SELECT DB opcode */
//写入selectdb的opcode,定义为254。即FE,然后是对应的DB号。具体格式是,1字节的OPcode,加上1,2或4字节的DB号。
if (rdbSaveType(rdb,RDB_OPCODE_SELECTDB) == -1) goto werr;
if (rdbSaveLen(rdb,j) == -1) goto werr;

/* Write the RESIZE DB opcode. We trim the size to UINT32_MAX, which
* is currently the largest type we are able to represent in RDB sizes.
* However this does not limit the actual size of the DB to load since
* these sizes are just hints to resize the hash tables. */
uint64_t db_size, expires_size;
db_size = dictSize(db->dict);
expires_size = dictSize(db->expires);
if (rdbSaveType(rdb,RDB_OPCODE_RESIZEDB) == -1) goto werr;
if (rdbSaveLen(rdb,db_size) == -1) goto werr;
if (rdbSaveLen(rdb,expires_size) == -1) goto werr;

/* Iterate this DB writing every entry */
//遍历所有K-V对,并进行dump。对于每个KV,获取key,value和expire time
while((de = dictNext(di)) != NULL) {
sds keystr = dictGetKey(de);
robj key, *o = dictGetVal(de);
long long expire;

initStaticStringObject(key,keystr);
expire = getExpire(db,&key);
//对K-V对进行dump
if (rdbSaveKeyValuePair(rdb,&key,o,expire) == -1) goto werr;

/* When this RDB is produced as part of an AOF rewrite, move
* accumulated diff from parent to child while rewriting in
* order to have a smaller final write. */
if (flags & RDB_SAVE_AOF_PREAMBLE &&
rdb->processed_bytes > processed+AOF_READ_DIFF_INTERVAL_BYTES)
{
processed = rdb->processed_bytes;
aofReadDiffFromParent();
}
}
dictReleaseIterator(di);
di = NULL; /* So that we don't release it again on error. */
}

/* If we are storing the replication information on disk, persist
* the script cache as well: on successful PSYNC after a restart, we need
* to be able to process any EVALSHA inside the replication backlog the
* master will send us. */
if (rsi && dictSize(server.lua_scripts)) {
di = dictGetIterator(server.lua_scripts);
while((de = dictNext(di)) != NULL) {
robj *body = dictGetVal(de);
if (rdbSaveAuxField(rdb,"lua",3,body->ptr,sdslen(body->ptr)) == -1)
goto werr;
}
dictReleaseIterator(di);
di = NULL; /* So that we don't release it again on error. */
}

if (rdbSaveModulesAux(rdb, REDISMODULE_AUX_AFTER_RDB) == -1) goto werr;

/* EOF opcode */
//输出EOF opcode(255,即FF)表示文件的结束
if (rdbSaveType(rdb,RDB_OPCODE_EOF) == -1) goto werr;

/* CRC64 checksum. It will be zero if checksum computation is disabled, the
* loading code skips the check in this case. */
//计算CRC16 checksum并写入
cksum = rdb->cksum;
memrev64ifbe(&cksum);
if (rioWrite(rdb,&cksum,8) == 0) goto werr;
return C_OK;

werr:
if (error) *error = errno;
if (di) dictReleaseIterator(di);
return C_ERR;
}

其中rdbSaveKeyValuePair函数是对每一个K-V对进行写入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int rdbSaveKeyValuePair(rio *rdb, robj *key, robj *val,
long long expiretime, long long now)
{
/* Save the expire time */
if (expiretime != -1) {
// 如果过期时间少于当前时间,那么表示该key已经失效,返回不做任何保存;
/* If this key is already expired skip it */
if (expiretime < now) return 0;
// 如果当前遍历的entry有失效时间属性,那么保存REDIS_RDB_OPCODE_EXPIRETIME_MS即252,即"FC"以及失效时间到rdb文件中,
if (rdbSaveType(rdb,REDIS_RDB_OPCODE_EXPIRETIME_MS) == -1) return -1;
if (rdbSaveMillisecondTime(rdb,expiretime) == -1) return -1;
}

// 接下来保存redis key的类型,key,以及value到rdb文件中;
/* Save type, key, value */
if (rdbSaveObjectType(rdb,val) == -1) return -1;
if (rdbSaveStringObject(rdb,key) == -1) return -1;
if (rdbSaveObject(rdb,val) == -1) return -1;
return 1;
}
rdbSaveBackground

通过阅读rdbSaveBackground(char *filename)的源码可知,其最终的实现还是调用rdbSave(char *filename),只不过是通过fork()出的子进程来执行罢了,所以bgsave和save的实现是殊途同归:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
int rdbSaveBackground(char *filename) {
pid_t childpid;
long long start;

// 如果已经有RDB持久化任务,那么rdb_child_pid的值就不是-1,那么返回REDIS_ERR;
if (server.rdb_child_pid != -1) return REDIS_ERR;

server.dirty_before_bgsave = server.dirty;
server.lastbgsave_try = time(NULL);

// 记录RDB持久化开始时间
start = ustime();
//fork一个子进程
if ((childpid = fork()) == 0) {
// 如果fork()的结果childpid为0,即当前进程为fork的子进程,那么接下来调用rdbSave()进程持久化;
int retval;

/* Child */
//子进程首先关闭监听socket,避免接收客户端连接。
closeListeningSockets(0);
redisSetProcTitle("redis-rdb-bgsave");
// bgsave事实上就是通过fork的子进程调用rdbSave()实现, rdbSave()就是save命令业务实现;
retval = rdbSave(filename);
if (retval == REDIS_OK) {
size_t private_dirty = zmalloc_get_private_dirty();

if (private_dirty) {
// RDB持久化成功后,如果是notice级别的日志,那么log输出RDB过程中copy-on-write使用的内存
redisLog(REDIS_NOTICE,
"RDB: %zu MB of memory used by copy-on-write",
private_dirty/(1024*1024));
}
}
exitFromChild((retval == REDIS_OK) ? 0 : 1);
} else {
// 父进程更新redisServer记录一些信息,例如:fork进程消耗的时间stat_fork_time,
/* Parent */
server.stat_fork_time = ustime()-start;
// 更新redisServer记录fork速率:每秒多少G;zmalloc_used_memory()的单位是字节,所以通过除以(1024*1024*1024),得到GB;由于记录的fork_time即fork时间是微妙,所以*1000000,得到每秒钟fork多少GB的速度;
server.stat_fork_rate = (double) zmalloc_used_memory() * 1000000 / server.stat_fork_time / (1024*1024*1024); /* GB per second. */
latencyAddSampleIfNeeded("fork",server.stat_fork_time/1000);
// 如果fork子进程出错,即childpid为-1,更新redisServer,记录最后一次bgsave状态是REDIS_ERR;
if (childpid == -1) {
server.lastbgsave_status = REDIS_ERR;
redisLog(REDIS_WARNING,"Can't save in background: fork: %s",
strerror(errno));
return REDIS_ERR;
}
redisLog(REDIS_NOTICE,"Background saving started by pid %d",childpid);
// 如果fork成功,最后在redisServer中记录的save开始时间重置为空,并记录执行bgsave的子进程id,即child_pid;
server.rdb_save_time_start = time(NULL);
server.rdb_child_pid = childpid;
server.rdb_child_type = REDIS_RDB_CHILD_TYPE_DISK;
updateDictResizePolicy();
return REDIS_OK;
}
return REDIS_OK; /* unreached */
}
serverCron中处理rdb

上面生成rdb的两种方式都是被动触发的,也就是通过两种命令分别触发的,redis也提供定期生成rdb的机制。定期生成rdb的实现在server.c 中的serverCron中。serverCron是redis每次执行完一次eventloop执行的定期调度任务,里面就有rdb和aof的执行逻辑,rdb相关具体如下:

注意:为了方便看,我这里将rdb和aof的相关源码全部贴出来了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
...

/*
* AOF重写在serverCron中第一个触发点:
* 需要确认当前没有aof rewrite和rdb dump在进行,并且设置了aof_rewrite_scheduled(在bgrewriteaofCommand里设置的),
* 调用rewirteAppendOnlyFileBackground进行aof rewrite。
*/
if (server.rdb_child_pid == -1 && server.aof_child_pid == -1 &&
server.aof_rewrite_scheduled)
{
rewriteAppendOnlyFileBackground();
}

// 检测bgsave、aof重写是否在执行过程中,或者是否有子线程
if (server.rdb_child_pid != -1 || server.aof_child_pid != -1 ||
ldbPendingChildren())
{
int statloc;
pid_t pid;

//等待所有的子进程
if ((pid = wait3(&statloc,WNOHANG,NULL)) != 0) {
//取得子进程exit()返回的结束代码
int exitcode = WEXITSTATUS(statloc);
int bysignal = 0;

//如果子进程是因为信号而结束则此宏值为真
if (WIFSIGNALED(statloc)) bysignal = WTERMSIG(statloc);

if (pid == -1) {
//如果此时没有子线程
serverLog(LL_WARNING,"wait3() returned an error: %s. "
"rdb_child_pid = %d, aof_child_pid = %d",
strerror(errno),
(int) server.rdb_child_pid,
(int) server.aof_child_pid);
} else if (pid == server.rdb_child_pid) {
//如果已经完成了bgsave,会调用backgroundSaveDoneHandler函数做最后处理
backgroundSaveDoneHandler(exitcode,bysignal);
if (!bysignal && exitcode == 0) receiveChildInfo();
} else if (pid == server.aof_child_pid) {
//如果已经完成了aof重写,会调用backgroundRewriteDoneHandler函数做最后处理
backgroundRewriteDoneHandler(exitcode,bysignal);
if (!bysignal && exitcode == 0) receiveChildInfo();
} else {
if (!ldbRemoveChild(pid)) {
serverLog(LL_WARNING,
"Warning, detected child with unmatched pid: %ld",
(long)pid);
}
}
updateDictResizePolicy();
closeChildInfoPipe();
}
} else {
/*
* 如果此时没有子线程在进行save或rewrite,则判断是否要save或rewrite
*/

//先判断是否要进行save
/*
* RDB的持久化在serverCron中第一个触发点,根据条件判断定期触发:
*/
for (j = 0; j < server.saveparamslen; j++) {
//每一个saveparam表示一个配置文件中的save
struct saveparam *sp = server.saveparams+j;

/* Save if we reached the given amount of changes,
* the given amount of seconds, and if the latest bgsave was
* successful or if, in case of an error, at least
* CONFIG_BGSAVE_RETRY_DELAY seconds already elapsed. */
//判断修改次数和时间
if (server.dirty >= sp->changes &&
server.unixtime-server.lastsave > sp->seconds &&
(server.unixtime-server.lastbgsave_try >
CONFIG_BGSAVE_RETRY_DELAY ||
server.lastbgsave_status == C_OK))
{
serverLog(LL_NOTICE,"%d changes in %d seconds. Saving...",
sp->changes, (int)sp->seconds);
rdbSaveInfo rsi, *rsiptr;
rsiptr = rdbPopulateSaveInfo(&rsi);
//调用rdbSaveBackground进行save
rdbSaveBackground(server.rdb_filename,rsiptr);
break;
}
}

/*
* AOF重写在serverCron中第二个触发点:
* 判断aof文件的大小超过预定的百分比,
* 当aof文件超过了预定的最小值,并且超过了上一次aof文件的一定百分比,则会触发aof rewrite。
*/
if (server.aof_state == AOF_ON &&
server.rdb_child_pid == -1 &&
server.aof_child_pid == -1 &&
server.aof_rewrite_perc &&
server.aof_current_size > server.aof_rewrite_min_size)
{
long long base = server.aof_rewrite_base_size ?
server.aof_rewrite_base_size : 1;
long long growth = (server.aof_current_size*100/base) - 100;
if (growth >= server.aof_rewrite_perc) {
serverLog(LL_NOTICE,"Starting automatic rewriting of AOF on %lld%% growth",growth);
rewriteAppendOnlyFileBackground();
}
}
}


/* AOF postponed flush: Try at every cron cycle if the slow fsync
* completed. */
//进行AOF buffer的刷盘:
// 在aof_flush_postponed_start不为0时调用,即存在延迟flush的情况。
// 主要是保证fsync完成之后,可以快速的进入下一次flush。
// 尽量保证fsync策略是everysec时,每秒都可以进行fsync,同时缩短两次fsync的间隔,减少影响。
if (server.aof_flush_postponed_start) flushAppendOnlyFile(0);

/* AOF write errors: in this case we have a buffer to flush as well and
* clear the AOF error in case of success to make the DB writable again,
* however to try every second is enough in case of 'hz' is set to
* an higher frequency. */
//进行AOF buffer的刷盘:保证aof出错时,尽快执行下一次flush,以便从错误恢复。
run_with_period(1000) {
if (server.aof_last_write_status == C_ERR)
flushAppendOnlyFile(0);
}

...

/*
* RDB的持久化在serverCron中第二个触发点,需要确认当前没有aof rewrite和rdb dump在进行,并且设置了rdb_bgsave_scheduled:
* 如果上次触发bgsave时已经有进程在执行aof的重写了(rdbSaveBackground中),就会标记rdb_bgsave_scheduled=1,
* 然后放到serverCron,然后在serverCron的最后在进行判断是否能够执行
*/
if (server.rdb_child_pid == -1 && server.aof_child_pid == -1 &&
server.rdb_bgsave_scheduled &&
(server.unixtime-server.lastbgsave_try > CONFIG_BGSAVE_RETRY_DELAY ||
server.lastbgsave_status == C_OK))
{
rdbSaveInfo rsi, *rsiptr;
rsiptr = rdbPopulateSaveInfo(&rsi);
if (rdbSaveBackground(server.rdb_filename,rsiptr) == C_OK)
server.rdb_bgsave_scheduled = 0;
}

...
}

代码中使用了wait3函数,这里看下wait3和wait4的区别:

pid_t wait3 ( int *status, int option, struct rusage *ru );

pid_t wait4 ( pid_t pid, int *status, int option, struct rusage *ru );

option的可选值有:WNOHANG、WCONTINUED、WUNTRACED。

wait3等待所有的子进程;wait4可以像waitpid一样指定要等待的子进程:pid>0表示子进程ID;pid=0表示当前进程组中的子进程;pid=-1表示等待所有子进程;pid<-1表示进程组ID为pid绝对值的子进程。

子进程的结束状态返回后存于status,底下有几个宏可判别结束情况:

  • WIFEXITED(status)如果子进程正常结束则为非0值;
  • WEXITSTATUS(status)取得子进程exit()返回的结束代码,一般会先用WIFEXITED 来判断是否正常结束才能使用此宏;
  • WIFSIGNALED(status)如果子进程是因为信号而结束则此宏值为真;
  • WTERMSIG(status)取得子进程因信号而中止的信号代码,一般会先用WIFSIGNALED 来判断后才使用此宏;
  • WIFSTOPPED(status)如果子进程处于暂停执行情况则此宏值为真。一般只有使用WUNTRACED 时才会有此情况;
  • WSTOPSIG(status)取得引发子进程暂停的信号代码,

再看下当子线程bgsave完成后的后序处理backgroundSaveDoneHandler(rdb.c):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
void backgroundSaveDoneHandler(int exitcode, int bysignal) {
switch(server.rdb_child_type) {
case RDB_CHILD_TYPE_DISK: //
backgroundSaveDoneHandlerDisk(exitcode,bysignal);
break;
case RDB_CHILD_TYPE_SOCKET: //
backgroundSaveDoneHandlerSocket(exitcode,bysignal);
break;
default:
serverPanic("Unknown RDB child type.");
break;
}
}

void backgroundSaveDoneHandlerDisk(int exitcode, int bysignal) {
if (!bysignal && exitcode == 0) {
//正常退出 修改dirty 为执行rbd任务到完成时候 dirty的差值
serverLog(LL_NOTICE,
"Background saving terminated with success");
server.dirty = server.dirty - server.dirty_before_bgsave;
server.lastsave = time(NULL);
server.lastbgsave_status = C_OK;
} else if (!bysignal && exitcode != 0) {
//表示子进程执行失败 设置lastbgsave_status 下次重试
serverLog(LL_WARNING, "Background saving error");
server.lastbgsave_status = C_ERR;
} else {
mstime_t latency;

serverLog(LL_WARNING,
"Background saving terminated by signal %d", bysignal);
latencyStartMonitor(latency);
//删除临时文件
rdbRemoveTempFile(server.rdb_child_pid);
latencyEndMonitor(latency);
latencyAddSampleIfNeeded("rdb-unlink-temp-file",latency);
/* SIGUSR1 is whitelisted, so we have a way to kill a child without
* tirggering an error condition. */
if (bysignal != SIGUSR1)
server.lastbgsave_status = C_ERR;
}
server.rdb_child_pid = -1;
server.rdb_child_type = RDB_CHILD_TYPE_NONE;
server.rdb_save_time_last = time(NULL)-server.rdb_save_time_start;
server.rdb_save_time_start = -1;
/* Possibly there are slaves waiting for a BGSAVE in order to be served
* (the first stage of SYNC is a bulk transfer of dump.rdb) */
//用于在主从同步中,完成rdb dump,通知向slave传输rdb。
updateSlavesWaitingBgsave((!bysignal && exitcode == 0) ? C_OK : C_ERR, RDB_CHILD_TYPE_DISK);
}
rdbLoad

在redis.c的main函数中,完成初始化后,会调用loadDataFromDisk()完成数据的加载。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
void loadDataFromDisk(void) {
long long start = ustime();
//如果开启了aof,调用loadAppendOnlyFile进行加载
if (server.aof_state == AOF_ON) {
if (loadAppendOnlyFile(server.aof_filename) == C_OK)
serverLog(LL_NOTICE,"DB loaded from append only file: %.3f seconds",(float)(ustime()-start)/1000000);
} else {
//否则,调用rdbLoad进行加载
rdbSaveInfo rsi = RDB_SAVE_INFO_INIT;
if (rdbLoad(server.rdb_filename,&rsi) == C_OK) {
serverLog(LL_NOTICE,"DB loaded from disk: %.3f seconds",
(float)(ustime()-start)/1000000);

/* Restore the replication ID / offset from the RDB file. */
if ((server.masterhost ||
(server.cluster_enabled &&
nodeIsSlave(server.cluster->myself))) &&
rsi.repl_id_is_set &&
rsi.repl_offset != -1 &&
/* Note that older implementations may save a repl_stream_db
* of -1 inside the RDB file in a wrong way, see more
* information in function rdbPopulateSaveInfo. */
rsi.repl_stream_db != -1)
{
memcpy(server.replid,rsi.repl_id,sizeof(server.replid));
server.master_repl_offset = rsi.repl_offset;
/* If we are a slave, create a cached master from this
* information, in order to allow partial resynchronizations
* with masters. */
replicationCacheMasterUsingMyself();
selectDb(server.cached_master,rsi.repl_stream_db);
}
} else if (errno != ENOENT) {
serverLog(LL_WARNING,"Fatal error loading the DB: %s. Exiting.",strerror(errno));
exit(1);
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
int rdbLoad(char *filename, rdbSaveInfo *rsi) {
FILE *fp;
rio rdb;
int retval;

//打开rdb文件
if ((fp = fopen(filename,"r")) == NULL) return C_ERR;
startLoading(fp);
rioInitWithFile(&rdb,fp);
//进行读取rdb文件
retval = rdbLoadRio(&rdb,rsi,0);
fclose(fp);
//标志结束
stopLoading();
return retval;
}

int rdbLoadRio(rio *rdb, rdbSaveInfo *rsi, int loading_aof) {
uint64_t dbid;
int type, rdbver;
redisDb *db = server.db+0;
char buf[1024];

//设置校验和函数
rdb->update_cksum = rdbLoadProgressCallback;
//这次每次处理的最大块
rdb->max_processing_chunk = server.loading_process_events_interval_bytes;
//读取redis版本
if (rioRead(rdb,buf,9) == 0) goto eoferr;
buf[9] = '\0';
//验证是否REDIS开头
if (memcmp(buf,"REDIS",5) != 0) {
serverLog(LL_WARNING,"Wrong signature trying to load DB from file");
errno = EINVAL;
return C_ERR;
}
//验证版本 必须大于1 并且 比当前版本低
rdbver = atoi(buf+5);
if (rdbver < 1 || rdbver > RDB_VERSION) {
serverLog(LL_WARNING,"Can't handle RDB format version %d",rdbver);
errno = EINVAL;
return C_ERR;
}

/* Key-specific attributes, set by opcodes before the key type. */
long long lru_idle = -1, lfu_freq = -1, expiretime = -1, now = mstime();
long long lru_clock = LRU_CLOCK();

//开始读取
while(1) {
robj *key, *val;

/* Read type. */
//读取当前行的类型
if ((type = rdbLoadType(rdb)) == -1) goto eoferr;

/* Handle special types. */
if (type == RDB_OPCODE_EXPIRETIME) {
//读取秒级别的过期时间,continue后进行读取对应的K-V
/* EXPIRETIME: load an expire associated with the next key
* to load. Note that after loading an expire we need to
* load the actual type, and continue. */
expiretime = rdbLoadTime(rdb);
expiretime *= 1000;
continue; /* Read next opcode. */
} else if (type == RDB_OPCODE_EXPIRETIME_MS) {
//读取毫秒级别的过期时间,continue后进行读取对应的K-V
/* EXPIRETIME_MS: milliseconds precision expire times introduced
* with RDB v3. Like EXPIRETIME but no with more precision. */
expiretime = rdbLoadMillisecondTime(rdb,rdbver);
continue; /* Read next opcode. */
} else if (type == RDB_OPCODE_FREQ) {
/* FREQ: LFU frequency. */
uint8_t byte;
if (rioRead(rdb,&byte,1) == 0) goto eoferr;
lfu_freq = byte;
continue; /* Read next opcode. */
} else if (type == RDB_OPCODE_IDLE) {
/* IDLE: LRU idle time. */
uint64_t qword;
if ((qword = rdbLoadLen(rdb,NULL)) == RDB_LENERR) goto eoferr;
lru_idle = qword;
continue; /* Read next opcode. */
} else if (type == RDB_OPCODE_EOF) {
//结束
/* EOF: End of file, exit the main loop. */
break;
} else if (type == RDB_OPCODE_SELECTDB) {
//选择切换db
/* SELECTDB: Select the specified database. */
if ((dbid = rdbLoadLen(rdb,NULL)) == RDB_LENERR) goto eoferr;
if (dbid >= (unsigned)server.dbnum) {
serverLog(LL_WARNING,
"FATAL: Data file was created with a Redis "
"server configured to handle more than %d "
"databases. Exiting\n", server.dbnum);
exit(1);
}
db = server.db+dbid;
continue; /* Read next opcode. */
} else if (type == RDB_OPCODE_RESIZEDB) {
/* RESIZEDB: Hint about the size of the keys in the currently
* selected data base, in order to avoid useless rehashing. */
uint64_t db_size, expires_size;
if ((db_size = rdbLoadLen(rdb,NULL)) == RDB_LENERR)
goto eoferr;
if ((expires_size = rdbLoadLen(rdb,NULL)) == RDB_LENERR)
goto eoferr;
dictExpand(db->dict,db_size);
dictExpand(db->expires,expires_size);
continue; /* Read next opcode. */
} else if (type == RDB_OPCODE_AUX) {
/* AUX: generic string-string fields. Use to add state to RDB
* which is backward compatible. Implementations of RDB loading
* are requierd to skip AUX fields they don't understand.
*
* An AUX field is composed of two strings: key and value. */
robj *auxkey, *auxval;
if ((auxkey = rdbLoadStringObject(rdb)) == NULL) goto eoferr;
if ((auxval = rdbLoadStringObject(rdb)) == NULL) goto eoferr;

if (((char*)auxkey->ptr)[0] == '%') {
/* All the fields with a name staring with '%' are considered
* information fields and are logged at startup with a log
* level of NOTICE. */
serverLog(LL_NOTICE,"RDB '%s': %s",
(char*)auxkey->ptr,
(char*)auxval->ptr);
} else if (!strcasecmp(auxkey->ptr,"repl-stream-db")) {
if (rsi) rsi->repl_stream_db = atoi(auxval->ptr);
} else if (!strcasecmp(auxkey->ptr,"repl-id")) {
if (rsi && sdslen(auxval->ptr) == CONFIG_RUN_ID_SIZE) {
memcpy(rsi->repl_id,auxval->ptr,CONFIG_RUN_ID_SIZE+1);
rsi->repl_id_is_set = 1;
}
} else if (!strcasecmp(auxkey->ptr,"repl-offset")) {
if (rsi) rsi->repl_offset = strtoll(auxval->ptr,NULL,10);
} else if (!strcasecmp(auxkey->ptr,"lua")) {
/* Load the script back in memory. */
if (luaCreateFunction(NULL,server.lua,auxval) == NULL) {
rdbExitReportCorruptRDB(
"Can't load Lua script from RDB file! "
"BODY: %s", auxval->ptr);
}
} else {
/* We ignore fields we don't understand, as by AUX field
* contract. */
serverLog(LL_DEBUG,"Unrecognized RDB AUX field: '%s'",
(char*)auxkey->ptr);
}

decrRefCount(auxkey);
decrRefCount(auxval);
continue; /* Read type again. */
} else if (type == RDB_OPCODE_MODULE_AUX) {
/* Load module data that is not related to the Redis key space.
* Such data can be potentially be stored both before and after the
* RDB keys-values section. */
uint64_t moduleid = rdbLoadLen(rdb,NULL);
int when_opcode = rdbLoadLen(rdb,NULL);
int when = rdbLoadLen(rdb,NULL);
if (when_opcode != RDB_MODULE_OPCODE_UINT)
rdbExitReportCorruptRDB("bad when_opcode");
moduleType *mt = moduleTypeLookupModuleByID(moduleid);
char name[10];
moduleTypeNameByID(name,moduleid);

if (!rdbCheckMode && mt == NULL) {
/* Unknown module. */
serverLog(LL_WARNING,"The RDB file contains AUX module data I can't load: no matching module '%s'", name);
exit(1);
} else if (!rdbCheckMode && mt != NULL) {
if (!mt->aux_load) {
/* Module doesn't support AUX. */
serverLog(LL_WARNING,"The RDB file contains module AUX data, but the module '%s' doesn't seem to support it.", name);
exit(1);
}

RedisModuleIO io;
moduleInitIOContext(io,mt,rdb,NULL);
io.ver = 2;
/* Call the rdb_load method of the module providing the 10 bit
* encoding version in the lower 10 bits of the module ID. */
if (mt->aux_load(&io,moduleid&1023, when) || io.error) {
moduleTypeNameByID(name,moduleid);
serverLog(LL_WARNING,"The RDB file contains module AUX data for the module type '%s', that the responsible module is not able to load. Check for modules log above for additional clues.", name);
exit(1);
}
if (io.ctx) {
moduleFreeContext(io.ctx);
zfree(io.ctx);
}
uint64_t eof = rdbLoadLen(rdb,NULL);
if (eof != RDB_MODULE_OPCODE_EOF) {
serverLog(LL_WARNING,"The RDB file contains module AUX data for the module '%s' that is not terminated by the proper module value EOF marker", name);
exit(1);
}
continue;
} else {
/* RDB check mode. */
robj *aux = rdbLoadCheckModuleValue(rdb,name);
decrRefCount(aux);
continue; /* Read next opcode. */
}
}

/* Read key */
//读取key
if ((key = rdbLoadStringObject(rdb)) == NULL) goto eoferr;
/* Read value */
//读取value
if ((val = rdbLoadObject(type,rdb,key)) == NULL) goto eoferr;
/* Check if the key already expired. This function is used when loading
* an RDB file from disk, either at startup, or when an RDB was
* received from the master. In the latter case, the master is
* responsible for key expiry. If we would expire keys here, the
* snapshot taken by the master may not be reflected on the slave. */
//如果为redis 主 则忽略过期key,并将key val的引用计数减少
if (server.masterhost == NULL && !loading_aof && expiretime != -1 && expiretime < now) {
decrRefCount(key);
decrRefCount(val);
} else {
/* Add the new object in the hash table */
//将key val加到指定的db中
dbAdd(db,key,val);

/* Set the expire time if needed */
//如果存在过期时间 则设置过期
if (expiretime != -1) setExpire(NULL,db,key,expiretime);

/* Set usage information (for eviction). */
objectSetLRUOrLFU(val,lfu_freq,lru_idle,lru_clock);

/* Decrement the key refcount since dbAdd() will take its
* own reference. */
//加入完成减少key的引用计数
decrRefCount(key);
}

/* Reset the state that is key-specified and is populated by
* opcodes before the key, so that we start from scratch again. */
expiretime = -1;
lfu_freq = -1;
lru_idle = -1;
}
/* Verify the checksum if RDB version is >= 5 */
//验证redis 版本,验证校验和
if (rdbver >= 5) {
uint64_t cksum, expected = rdb->cksum;

if (rioRead(rdb,&cksum,8) == 0) goto eoferr;
if (server.rdb_checksum) {
memrev64ifbe(&cksum);
if (cksum == 0) {
serverLog(LL_WARNING,"RDB file was saved with checksum disabled: no check performed.");
} else if (cksum != expected) {
serverLog(LL_WARNING,"Wrong RDB checksum. Aborting now.");
rdbExitReportCorruptRDB("RDB CRC error");
}
}
}
return C_OK;

eoferr: /* unexpected end of file is handled here with a fatal exit */
serverLog(LL_WARNING,"Short read or OOM loading DB. Unrecoverable error, aborting now.");
rdbExitReportCorruptRDB("Unexpected EOF reading RDB file");
return C_ERR; /* Just to avoid warning */
}

AOF

介绍

AOF是redis持久化的一种方式,以日志的形式来记录每个写操作,将Redis执行过的所有写指令记录下来(读操作补不可记录),只许追加文件但不可以改写文件,保存的是appendonly.aof文件。

aof机制默认关闭,可以通过appendonly = yes参数开启aof机制,通过appendfilename = myaoffile.aof指定aof文件名称。

写入AOF

当AOF持久化功能处于打开状态时,服务器在执行完一个写命令之后,会调用propagate(call()函数中)将被执行的写命令追加到服务器状态的aof_buf缓冲区的末尾。

1
2
3
4
struct redisServer {
// sds 是redis定义的char数组
sds aof_buf;
}
1
2
3
4
5
6
7
8
9
void propagate(struct redisCommand *cmd, int dbid, robj **argv, int argc, int flags)
{
if (server.aof_state != AOF_OFF && flags & PROPAGATE_AOF)
// aof功能打开的前提下,把新的追加aof_buffer
feedAppendOnlyFile(cmd,dbid,argv,argc);
if (flags & PROPAGATE_REPL)
replicationFeedSlaves(server.slaves,dbid,argv,argc);
}

然后在serverCron(server.c)定时任务末尾,都会调用flushAppendOnlyFile(0)函数(aof.c),考虑是否需要将aof_buf缓冲区中的内容写入和保存到AOF 文件里面。

flushAppendonlyFile函数的行为由服务器配置的appendfsync选项的值来决定,各个不同值产生的行为如下:

1
2
3
4
5
#aof持久化策略的配置
#no表示不执行fsync, 仅仅调用write函数将缓冲区的数据写如操作系统内核缓冲区,至于内核缓冲区的数据什么时候写入磁盘,由操作系统决定,性能最快。
#always表示每次事件循环都执行fsync,以保证数据同步到磁盘。
#everysec表示每秒执行一次fsync,可能会导致丢失这1s数据。
appendfsync everysec

恢复

因为AOF文件里面包含了重建数据库状态所需的所有写命令,所以服务器只要读入并重新执行一遍AOF文件里面保存的写命令,就可以还原服务器关闭之前的数据库状态。

步骤如下:

  1. 创建一个不带网络连接的伪客户端( fake client )。因为Redis 的命令只能在客户端上下文中执行,而载人AOF文件时所使用的命令直接来源于AOF文件而不是网络连接,所以服务器使用了一个没有网络连接的伪客户端来执行AOF文件保存的写命令,伪客户端执行命令的效果和带网络连接的客户端执行命令的效果完全一样。
  2. 从AOF文件中分析并读取出一条写命令。
  3. 使用伪客户端执行被读出的写命令。
  4. 一直执行步骤⒉和步骤3,直到AOF文件中的所有写命令都被处理完毕为止。

重写

因为AOF持久化是通过保存被执行的写命令来记录数据库状态的,所以随着服务器运行时间的流逝,AOF文件中的内容会越来越多,文件的体积也会越来越大,如果不加以控制的话,体积过大的AOF文件很可能对Redis服务器、甚至整个宿主计算机造成影响,并且AOF 文件的体积越大,使用AOF 文件来进行数据还原所需的时间就越多。

为了解决AOF文件体积膨胀的问题,Redis提供了AOF文件重写(rewrite)功能。通过该功能,Redis 服务器可以创建–个新的AOF文件来替代现有的AOF 文件,新旧两个AOF文件所保存的数据库状态相同,但新AOF文件不会包含任何浪费空间的冗余命令,所以新AOF文件的体积通常会比旧AOF文件的体积要小得多。

aof触发的时机:

  1. 用户调用BGREWRITEAOF命令;
  2. aof日志大小超过预设的限额。

对于触发aof重写机制也可以通过配置文件来进行设置:

1
2
3
4
# aof自动重写配置。当目前aof文件大小超过上一次重写的aof文件大小的百分之多少进行重写,即当aof文件增长到一定大小的时候Redis能够调用bgrewriteaof对日志文件进行重写。当前AOF文件大小是上次日志重写得到AOF文件大小的二倍(设置为100)时,自动启动新的日志重写过程。
auto-aof-rewrite-percentage 100
# 设置允许重写的最小aof文件大小,避免了达到约定百分比但尺寸仍然很小的情况还要重写
auto-aof-rewrite-min-size 64mb

当aop重写时会引发重写和持久化追加同时发生的问题,可以通过no-appendfsync-on-rewrite no进行配置

1
2
# 在aof重写或者写入rdb文件的时候,会执行大量IO,此时对于everysec和always的aof模式来说,执行fsync会造成阻塞过长时间,no-appendfsync-on-rewrite字段设置为默认设置为no,是最安全的方式,不会丢失数据,但是要忍受阻塞的问题。如果对延迟要求很高的应用,这个字段可以设置为yes,设置为yes表示rewrite期间对新写操作不fsync,暂时存在内存中,不会造成阻塞的问题(因为没有磁盘竞争),等rewrite完成后再写入,这个时候redis会丢失数据。Linux的默认fsync策略是30秒。可能丢失30秒数据。因此,如果应用系统无法忍受延迟,而可以容忍少量的数据丢失,则设置为yes。如果应用系统无法忍受数据丢失,则设置为no。
no-appendfsync-on-rewrite no

aof_rewrite函数可以创建新的AOF文件,但是这个函数会进行大量的写入操作,所以调用这个函数的线程将被长时间的阻塞,因为Redis服务器使用单线程来处理命令请求;所以如果直接是服务器进程调用AOF_REWRITE函数的话,那么重写AOF期间,服务器将无法处理客户端发送来的命令请求;

Redis不希望AOF重写会造成服务器无法处理请求,所以Redis决定将AOF重写程序放到子进程(后台)里执行。这样处理的最大好处是:

  • 子进程进行AOF重写期间,主进程可以继续处理命令请求;
  • 子进程带有主进程的数据副本,使用子进程而不是线程,可以避免在锁的情况下,保证数据的安全性。

子进程在进行AOF重写期间,服务器进程还要继续处理命令请求,而新的命令可能对现有的数据进行修改,这会让当前数据库的数据和重写后的AOF文件中的数据不一致。

为了解决这种数据不一致的问题,Redis增加了一个AOF重写缓存,这个缓存在fork出子进程之后开始启用,Redis服务器主进程在执行完写命令之后,会同时将这个写命令追加到AOF缓冲区和AOF重写缓冲区。

当子进程完成对AOF文件重写之后,它会向父进程发送一个完成信号,父进程接到该完成信号之后,会调用一个信号处理函数,该函数完成以下工作:

  • 将AOF重写缓存中的内容全部写入到新的AOF文件中;这个时候新的AOF文件所保存的数据库状态和服务器当前的数据库状态一致;
  • 对新的AOF文件进行改名,原子的覆盖原有的AOF文件;完成新旧两个AOF文件的替换。

当这个信号处理函数执行完毕之后,主进程就可以继续像往常一样接收命令请求了。在整个AOF后台重写过程中,**只有最后的 “主进程写入命令到AOF缓存” 和 “对新的AOF文件进行改名,覆盖原有的AOF文件” **这两个步骤(信号处理函数执行期间)会造成主进程阻塞,在其他时候,AOF后台重写都不会对主进程造成阻塞,这将AOF重写对性能造成的影响降到最低。

伪代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
def AOF_REWRITE(tmp_tile_name):

f = create(tmp_tile_name)

# 遍历所有数据库
for db in redisServer.db:

# 如果数据库为空,那么跳过这个数据库
if db.is_empty(): continue

# 写入 SELECT 命令,用于切换数据库
f.write_command("SELECT " + db.number)

# 遍历所有键
for key in db:

# 如果键带有过期时间,并且已经过期,那么跳过这个键
if key.have_expire_time() and key.is_expired(): continue

if key.type == String:

# 用 SET key value 命令来保存字符串键

value = get_value_from_string(key)

f.write_command("SET " + key + value)

elif key.type == List:

# 用 RPUSH key item1 item2 ... itemN 命令来保存列表键

item1, item2, ..., itemN = get_item_from_list(key)

f.write_command("RPUSH " + key + item1 + item2 + ... + itemN)

elif key.type == Set:

# 用 SADD key member1 member2 ... memberN 命令来保存集合键

member1, member2, ..., memberN = get_member_from_set(key)

f.write_command("SADD " + key + member1 + member2 + ... + memberN)

elif key.type == Hash:

# 用 HMSET key field1 value1 field2 value2 ... fieldN valueN 命令来保存哈希键

field1, value1, field2, value2, ..., fieldN, valueN =\
get_field_and_value_from_hash(key)

f.write_command("HMSET " + key + field1 + value1 + field2 + value2 +\
... + fieldN + valueN)

elif key.type == SortedSet:

# 用 ZADD key score1 member1 score2 member2 ... scoreN memberN
# 命令来保存有序集键

score1, member1, score2, member2, ..., scoreN, memberN = \
get_score_and_member_from_sorted_set(key)

f.write_command("ZADD " + key + score1 + member1 + score2 + member2 +\
... + scoreN + memberN)

else:

raise_type_error()

# 如果键带有过期时间,那么用 EXPIREAT key time 命令来保存键的过期时间
if key.have_expire_time():
f.write_command("EXPIREAT " + key + key.expire_time_in_unix_timestamp())

# 关闭文件
f.close()

实际为了避免执行命令时造成客户端输入缓冲区溢出,重写程序在处理list hash set zset时,会检查键所包含的元素的个数,如果元素的数量超过了redis.h/REDIS_AOF_REWRITE_ITEMS_PER_CMD常量的值,那么重写程序会使用多条命令来记录键的值,而不是单使用一条命令。该常量默认值是64– 即每条命令设置的元素的个数 是最多64个,使用多条命令重写实现集合键中元素数量超过64个的键;

优势

  1. 根据不同的策略,可以实现每秒,每一次修改操作的同步持久化,就算在最恶劣的情况下只会丢失不会超过两秒数据。

  2. 当文件太大时,会触发重写机制,确保文件不会太大。

  3. 文件可以简单的读懂

劣势

  1. aof文件的大小太大,就算有重写机制,但重写所造成的阻塞问题是不可避免的
  2. aof文件恢复速度慢

源码分析

序列化

在“写入AOF中”部分,我们已经知道了redis每次执行完写操作后,会调用propagate函数将写操作追加到aof_buf缓冲区和处理主从复制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
void call(client *c, int flags) {
...
//在执行前先将该命令的相关信息发送给各个监视器,监视器是用来监听服务器要处理的命令
if (listLength(server.monitors) &&
!server.loading &&
!(c->cmd->flags & (CMD_SKIP_MONITOR|CMD_ADMIN)))
{
replicationFeedMonitors(c,server.monitors,c->db->id,c->argv,c->argc);
}
...
//dirty用于记录更新操作的次数,用于完成save配置(即用于RDB)
dirty = server.dirty;
updateCachedTime(0);
start = server.ustime;
//执行命令对应的处理函数
c->cmd->proc(c);
duration = ustime()-start;
dirty = server.dirty-dirty;
if (dirty < 0) dirty = 0;
...
if (flags & CMD_CALL_PROPAGATE &&
(c->flags & CLIENT_PREVENT_PROP) != CLIENT_PREVENT_PROP)
{
int propagate_flags = PROPAGATE_NONE;

/* Check if the command operated changes in the data set. If so
* set for replication / AOF propagation. */
if (dirty) propagate_flags |= (PROPAGATE_AOF|PROPAGATE_REPL);

/* If the client forced AOF / replication of the command, set
* the flags regardless of the command effects on the data set. */
if (c->flags & CLIENT_FORCE_REPL) propagate_flags |= PROPAGATE_REPL;
if (c->flags & CLIENT_FORCE_AOF) propagate_flags |= PROPAGATE_AOF;

/* However prevent AOF / replication propagation if the command
* implementations called preventCommandPropagation() or similar,
* or if we don't have the call() flags to do so. */
if (c->flags & CLIENT_PREVENT_REPL_PROP ||
!(flags & CMD_CALL_PROPAGATE_REPL))
propagate_flags &= ~PROPAGATE_REPL;
if (c->flags & CLIENT_PREVENT_AOF_PROP ||
!(flags & CMD_CALL_PROPAGATE_AOF))
propagate_flags &= ~PROPAGATE_AOF;

/* Call propagate() only if at least one of AOF / replication
* propagation is needed. Note that modules commands handle replication
* in an explicit way, so we never replicate them automatically. */
//调用propagate函数将写操作追加到aof_buf缓冲区和处理主从复制
if (propagate_flags != PROPAGATE_NONE && !(c->cmd->flags & CMD_MODULE))
propagate(c->cmd,c->db->id,c->argv,c->argc,propagate_flags);
}
...
}

propagate:

1
2
3
4
5
6
7
8
void propagate(struct redisCommand *cmd, int dbid, robj **argv, int argc, int flags)
{
if (server.aof_state != AOF_OFF && flags & PROPAGATE_AOF)
// aof功能打开的前提下,把新的追加aof_buffer
feedAppendOnlyFile(cmd,dbid,argv,argc);
if (flags & PROPAGATE_REPL)
replicationFeedSlaves(server.slaves,dbid,argv,argc);
}

在redis开启aof,并且该命令需要记录aof时,会调用feedAppendOnlyFile函数用于生成并写入aof。下面看一下这个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
void feedAppendOnlyFile(struct redisCommand *cmd, int dictid, robj **argv, int argc) {
sds buf = sdsempty();
robj *tmpargv[3];

/* The DB this command was targeting is not the same as the last command
* we appended. To issue a SELECT command is needed. */
//当前操作的db与aof对应的db不同时,需要一个切换db的命令
//在全局server结构中得aof_selected_db记录当前aof对应的数据库,如果当前命令操作的数据库与之不同的话,首先需要切换数据库。
if (dictid != server.aof_selected_db) {
char seldb[64];

snprintf(seldb,sizeof(seldb),"%d",dictid);
buf = sdscatprintf(buf,"*2\r\n$6\r\nSELECT\r\n$%lu\r\n%s\r\n",
(unsigned long)strlen(seldb),seldb);
server.aof_selected_db = dictid;
}

//将命令序列化,并保存到buf
if (cmd->proc == expireCommand || cmd->proc == pexpireCommand ||
cmd->proc == expireatCommand) {
/* Translate EXPIRE/PEXPIRE/EXPIREAT into PEXPIREAT */
buf = catAppendOnlyExpireAtCommand(buf,cmd,argv[1],argv[2]);
} else if (cmd->proc == setexCommand || cmd->proc == psetexCommand) {
/* Translate SETEX/PSETEX to SET and PEXPIREAT */
tmpargv[0] = createStringObject("SET",3);
tmpargv[1] = argv[1];
tmpargv[2] = argv[3];
buf = catAppendOnlyGenericCommand(buf,3,tmpargv);
decrRefCount(tmpargv[0]);
buf = catAppendOnlyExpireAtCommand(buf,cmd,argv[1],argv[2]);
} else if (cmd->proc == setCommand && argc > 3) {
int i;
robj *exarg = NULL, *pxarg = NULL;
/* Translate SET [EX seconds][PX milliseconds] to SET and PEXPIREAT */
buf = catAppendOnlyGenericCommand(buf,3,argv);
for (i = 3; i < argc; i ++) {
if (!strcasecmp(argv[i]->ptr, "ex")) exarg = argv[i+1];
if (!strcasecmp(argv[i]->ptr, "px")) pxarg = argv[i+1];
}
serverAssert(!(exarg && pxarg));
if (exarg)
buf = catAppendOnlyExpireAtCommand(buf,server.expireCommand,argv[1],
exarg);
if (pxarg)
buf = catAppendOnlyExpireAtCommand(buf,server.pexpireCommand,argv[1],
pxarg);
} else {
/* All the other commands don't need translation or need the
* same translation already operated in the command vector
* for the replication itself. */
buf = catAppendOnlyGenericCommand(buf,argc,argv);
}

/* Append to the AOF buffer. This will be flushed on disk just before
* of re-entering the event loop, so before the client will get a
* positive reply about the operation performed. */
//判断是否开启aof,如果开启则将aof追加到aof buffer。此处,应该可以提前判断,避免关闭aof时的aof的序列化开销。
if (server.aof_state == AOF_ON)
server.aof_buf = sdscatlen(server.aof_buf,buf,sdslen(buf));

/* If a background append only file rewriting is in progress we want to
* accumulate the differences between the child DB and the current one
* in a buffer, so that when the child process will do its work we
* can append the differences to the new append only file. */
// 如果开启了aof rewrite进程,将命令也添加到aof rewrite buf中,等rewrite完之后,再将rewrite buf的数据追加到文件中
// aof_child_pid记录aof rewrite进程的pid,如果rewrite正在进行,这个值不为-1;
// 如果当前正在进行aof rewrite,则将命令的aof追加到aof rewrite buffer,待rewrite结束后进行replay。
if (server.aof_child_pid != -1)
aofRewriteBufferAppend((unsigned char*)buf,sdslen(buf));

sdsfree(buf);
}

这里注意一个问题,在feedAppendOnlyFile的源码中,同一份aof数据既保存到了aof buffer中,又保存到了aof rewrite buffer中,是不是显得很冗余?当然不是,我们需要知道的一点是:重写是由fork出来的子进程来完成的,在前面RDB的COW中我们介绍了,当前这个子进程并不是和主进程共享数据的,子进程拥有自己的一份副本数据,当前这个副本数据并不包含fork之后的新加的命令,因此需要在重写完成后的后序处理中再写入aof rewrite buffer中的数据。这样子,新的aof文件和旧的aof文件就是完全相同的了。

刷盘

具体的刷盘时机是靠aof持久化策略的配置(appendfsync)来决定的,在每一次循环前的beforeSleep中进行一次刷盘:

1
2
3
4
5
6
7
8
void beforeSleep(struct aeEventLoop *eventLoop) {
...
/* Write the AOF buffer on disk */
//在beforeSleep中,是为了在给客户端发送响应内容前进行,保证返回给客户端的内容都是写过aof的。
// 同时,也保证一轮事件循环,对于多个客户端的请求处理只写一次aof,提升性能(当然,这样做的缺点就是不能保证数据的一致性)。
flushAppendOnlyFile(0);
...
}

在每次执行serverCron时也会对AOF刷盘进行判断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
...
/* AOF postponed flush: Try at every cron cycle if the slow fsync
* completed. */
//在aof_flush_postponed_start不为0时调用,即存在延迟flush的情况。
// 主要是保证fsync完成之后,可以快速的进入下一次flush。
// 尽量保证fsync策略是everysec时,每秒都可以进行fsync,同时缩短两次fsync的间隔,减少影响。
if (server.aof_flush_postponed_start) flushAppendOnlyFile(0);

/* AOF write errors: in this case we have a buffer to flush as well and
* clear the AOF error in case of success to make the DB writable again,
* however to try every second is enough in case of 'hz' is set to
* an higher frequency. */
//保证aof出错时,尽快执行下一次flush,以便从错误恢复。
run_with_period(1000) {
if (server.aof_last_write_status == C_ERR)
flushAppendOnlyFile(0);
}
...
}

看具体的刷盘函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
/*
* force表示是否是强迫进行flush:
* 如果是serverCron中或是beforeSleep中调用则为0,不是强迫;
* 如果是redis关闭前调用则为1,强迫进行刷盘
*/
void flushAppendOnlyFile(int force) {
ssize_t nwritten;
int sync_in_progress = 0;
mstime_t latency;

// 检查aof buffer是否为空,空的话直接返回,没必要进行flush。
if (sdslen(server.aof_buf) == 0) {
/* Check if we need to do fsync even the aof buffer is empty,
* because previously in AOF_FSYNC_EVERYSEC mode, fsync is
* called only when aof buffer is not empty, so if users
* stop write commands before fsync called in one second,
* the data in page cache cannot be flushed in time. */
if (server.aof_fsync == AOF_FSYNC_EVERYSEC &&
server.aof_fsync_offset != server.aof_current_size &&
server.unixtime > server.aof_last_fsync &&
!(sync_in_progress = aofFsyncInProgress())) {
goto try_fsync;
} else {
return;
}
}

/*
* fsync是阻塞操作,避免影响主线程的事件循环,fsync操作由后台线程完成
*/

//如果设置的fsync策略是everysec,获取是否有后台线程正在进行fsync
if (server.aof_fsync == AOF_FSYNC_EVERYSEC)
sync_in_progress = aofFsyncInProgress();

if (server.aof_fsync == AOF_FSYNC_EVERYSEC && !force) {
/* With this append fsync policy we do background fsyncing.
* If the fsync is still in progress we can try to delay
* the write for a couple of seconds. */
if (sync_in_progress) {
//记录上次延迟flush的时间戳,如果等于0,说明没有延迟
if (server.aof_flush_postponed_start == 0) {
/* No previous write postponing, remember that we are
* postponing the flush and return. */
server.aof_flush_postponed_start = server.unixtime;
return;
//如果当前时间戳server.unixtime与延迟flush的时间戳间隔小于2s,那么没有违反everysec策略,不进行flush,直接返回
} else if (server.unixtime - server.aof_flush_postponed_start < 2) {
/* We were already waiting for fsync to finish, but for less
* than two seconds this is still ok. Postpone again. */
return;
}
/* Otherwise fall trough, and go write since we can't wait
* over two seconds. */
server.aof_delayed_fsync++;
serverLog(LL_NOTICE,"Asynchronous AOF fsync is taking too long (disk is busy?). Writing the AOF buffer without waiting for fsync to complete, this may slow down Redis.");
}
}
/* We want to perform a single write. This should be guaranteed atomic
* at least if the filesystem we are writing is a real physical one.
* While this will save us against the server being killed I don't think
* there is much to do about the whole server stopping for power problems
* or alike */
/*
* 如果没有当前没有进行fsync,或者当前时间戳server.unixtime与延迟flush的时间戳间隔大于2s,
* 就会跳过这段代码,进行flush操作。
*/

latencyStartMonitor(latency);
//调用write函数将aof_buf的数据写入文件的内核缓冲区,此处只是写入page cache,还需要fsync
nwritten = aofWrite(server.aof_fd,server.aof_buf,sdslen(server.aof_buf));
latencyEndMonitor(latency);
/* We want to capture different events for delayed writes:
* when the delay happens with a pending fsync, or with a saving child
* active, and when the above two conditions are missing.
* We also use an additional event name to save all samples which is
* useful for graphing / monitoring purposes. */
if (sync_in_progress) {
latencyAddSampleIfNeeded("aof-write-pending-fsync",latency);
} else if (server.aof_child_pid != -1 || server.rdb_child_pid != -1) {
latencyAddSampleIfNeeded("aof-write-active-child",latency);
} else {
latencyAddSampleIfNeeded("aof-write-alone",latency);
}
latencyAddSampleIfNeeded("aof-write",latency);

/* We performed the write so reset the postponed flush sentinel to zero. */
// 重置aof_flush_postponed_start,因为接下来会进行flush。
server.aof_flush_postponed_start = 0;

//判断aof buf中的数据是否全部写入
if (nwritten != (ssize_t)sdslen(server.aof_buf)) {
//错误分支
static time_t last_write_error_log = 0;
int can_log = 0;

/* Limit logging rate to 1 line per AOF_WRITE_LOG_ERROR_RATE seconds. */
// 限制记录错误日志的频率
if ((server.unixtime - last_write_error_log) > AOF_WRITE_LOG_ERROR_RATE) {
can_log = 1;
last_write_error_log = server.unixtime;
}

/* Log the AOF write error and record the error code. */
//write返回值是-1,说明调用错误,只记录日志。
if (nwritten == -1) {
if (can_log) {
serverLog(LL_WARNING,"Error writing to the AOF file: %s",
strerror(errno));
server.aof_last_write_errno = errno;
}
} else {
//接下来是处理部分写的情况
if (can_log) {
serverLog(LL_WARNING,"Short write while writing to "
"the AOF file: (nwritten=%lld, "
"expected=%lld)",
(long long)nwritten,
(long long)sdslen(server.aof_buf));
}

// aof_current_size记录当前正确写入的aof的长度,当前write只写入部分数据,此处保证完整性,将写入的部分数据删掉
//如果ftruncate成功,会设置nwritten为-1;
//如果失败的话,后面会将aof_current_size增加部分写的数据长度,同时将aof_buf中截取已写入部分。
if (ftruncate(server.aof_fd, server.aof_current_size) == -1) {
if (can_log) {
serverLog(LL_WARNING, "Could not remove short write "
"from the append-only file. Redis may refuse "
"to load the AOF the next time it starts. "
"ftruncate: %s", strerror(errno));
}
} else {
/* If the ftruncate() succeeded we can set nwritten to
* -1 since there is no longer partial data into the AOF. */
nwritten = -1;
}
server.aof_last_write_errno = ENOSPC;
}

/* Handle the AOF write error. */
//fsync策略是always,write失败后,不能恢复,直接退出
if (server.aof_fsync == AOF_FSYNC_ALWAYS) {
/* We can't recover when the fsync policy is ALWAYS since the
* reply for the client is already in the output buffers, and we
* have the contract with the user that on acknowledged write data
* is synced on disk. */
serverLog(LL_WARNING,"Can't recover from AOF write error when the AOF fsync policy is 'always'. Exiting...");
exit(1);
} else {
/* Recover from failed write leaving data into the buffer. However
* set an error to stop accepting writes as long as the error
* condition is not cleared. */
//修改最后一次的写入状态
server.aof_last_write_status = C_ERR;

/* Trim the sds buffer if there was a partial write, and there
* was no way to undo it with ftruncate(2). */
if (nwritten > 0) {
//在ftruncate失败时,修改aof_current_size,并把已经写入内核缓冲区的数据从buffer中清空
//剩余的数据下次再尝试写入和刷盘
server.aof_current_size += nwritten;
sdsrange(server.aof_buf,nwritten,-1);
}
return; /* We'll try again on the next call... */
}
} else {
//正确分支
/* Successful write(2). If AOF was in error state, restore the
* OK state and log the event. */
// 如果fsync策略不是always,在write出错时,会有server.aof_last_write_status记录错误状态。
// 如果后续的write操作正常,此处只是打印日志,表示错误恢复正常。
if (server.aof_last_write_status == C_ERR) {
serverLog(LL_WARNING,
"AOF write error looks solved, Redis can write again.");
server.aof_last_write_status = C_OK;
}
}
//修改aof文件的大小
server.aof_current_size += nwritten;

/* Re-use AOF buffer when it is small enough. The maximum comes from the
* arena size of 4k minus some overhead (but is otherwise arbitrary). */
// aof_buf已成功写入文件,可以清空。
// 为避免频繁分配、释放内存,此处保证在buf小于4K时,会一直重用该buf。如果大于4K,就会释放旧的buf,分配新的。
if ((sdslen(server.aof_buf)+sdsavail(server.aof_buf)) < 4000) {
sdsclear(server.aof_buf);
} else {
sdsfree(server.aof_buf);
server.aof_buf = sdsempty();
}

try_fsync:
// 如果配置了no-appendfsync-on-rewrite,即在有aof rewrite或者是rdb save的子进程时不进行fsync,
// 主要是避免对磁盘产生过大压力,这里会直接返回,不进行fsync。
if (server.aof_fsync == AOF_FSYNC_ALWAYS) {
/* redis_fsync is defined as fdatasync() for Linux in order to avoid
* flushing metadata. */
latencyStartMonitor(latency);
//调用fsync进行刷盘
redis_fsync(server.aof_fd); /* Let's try to get this data on the disk */
latencyEndMonitor(latency);
latencyAddSampleIfNeeded("aof-fsync-always",latency);
server.aof_fsync_offset = server.aof_current_size;
server.aof_last_fsync = server.unixtime;
} else if ((server.aof_fsync == AOF_FSYNC_EVERYSEC &&
server.unixtime > server.aof_last_fsync)) {
//因为unixtime是以秒为单位的,因此就可以判断出是不是超过了一秒
//如果此时没有在fsync
if (!sync_in_progress) {
//提交一个aof刷盘的后台任务,由指定类型的BIO后台线程进行执行。类似于fsync,也是一个子线程进行执行,但并不一定立即执行,因为当前任务可能会等待
aof_background_fsync(server.aof_fd);
server.aof_fsync_offset = server.aof_current_size;
}
server.aof_last_fsync = server.unixtime;
}
}

可以看一下aof_background_fsync函数:

1
2
3
4
void aof_background_fsync(int fd) {
//创建一个AOF Fsync后台任务
bioCreateBackgroundJob(BIO_AOF_FSYNC,(void*)(long)fd,NULL,NULL);
}

BIO后台任务相关的内容可以看后面的“线程问题”章节。

载入

AOF加载的流程要简单些,在启动后,读取AOF,然后将每条命令进行replay即可。下面看一下具体代码。在redis.c的main函数中,完成初始化后,会调用loadDataFromDisk()完成数据的加载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
void loadDataFromDisk(void) {
long long start = ustime();
//如果开启了aof,调用loadAppendOnlyFile进行加载
if (server.aof_state == AOF_ON) {
if (loadAppendOnlyFile(server.aof_filename) == C_OK)
serverLog(LL_NOTICE,"DB loaded from append only file: %.3f seconds",(float)(ustime()-start)/1000000);
} else {
//否则,调用rdbLoad进行加载
rdbSaveInfo rsi = RDB_SAVE_INFO_INIT;
if (rdbLoad(server.rdb_filename,&rsi) == C_OK) {
serverLog(LL_NOTICE,"DB loaded from disk: %.3f seconds",
(float)(ustime()-start)/1000000);

/* Restore the replication ID / offset from the RDB file. */
if ((server.masterhost ||
(server.cluster_enabled &&
nodeIsSlave(server.cluster->myself))) &&
rsi.repl_id_is_set &&
rsi.repl_offset != -1 &&
/* Note that older implementations may save a repl_stream_db
* of -1 inside the RDB file in a wrong way, see more
* information in function rdbPopulateSaveInfo. */
rsi.repl_stream_db != -1)
{
memcpy(server.replid,rsi.repl_id,sizeof(server.replid));
server.master_repl_offset = rsi.repl_offset;
/* If we are a slave, create a cached master from this
* information, in order to allow partial resynchronizations
* with masters. */
replicationCacheMasterUsingMyself();
selectDb(server.cached_master,rsi.repl_stream_db);
}
} else if (errno != ENOENT) {
serverLog(LL_WARNING,"Fatal error loading the DB: %s. Exiting.",strerror(errno));
exit(1);
}
}
}

其中调用loadAppendOnlyFile进行加载:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
int loadAppendOnlyFile(char *filename) {
//声明一个伪客户端
struct client *fakeClient;
//接下来打开aof文件并检查其大小
FILE *fp = fopen(filename,"r");
struct redis_stat sb;
int old_aof_state = server.aof_state;
long loops = 0;
off_t valid_up_to = 0; /* Offset of latest well-formed command loaded. */
off_t valid_before_multi = 0; /* Offset before MULTI command loaded. */

if (fp == NULL) {
serverLog(LL_WARNING,"Fatal error: can't open the append log file for reading: %s",strerror(errno));
exit(1);
}

/* Handle a zero-length AOF file as a special case. An empty AOF file
* is a valid AOF because an empty server with AOF enabled will create
* a zero length file at startup, that will remain like that if no write
* operation is received. */
if (fp && redis_fstat(fileno(fp),&sb) != -1 && sb.st_size == 0) {
server.aof_current_size = 0;
server.aof_fsync_offset = server.aof_current_size;
fclose(fp);
return C_ERR;
}

/* Temporarily disable AOF, to prevent EXEC from feeding a MULTI
* to the same file we're about to read. */
//要短暂的关闭AOF,防止事务向我们要读取的文件执行命令
server.aof_state = AOF_OFF;

//创建伪客户端,fd=-1
fakeClient = createFakeClient();
//startLoading会设置状态信息:
// 1. redisServer.loading置为1,表示当前正处于数据加载阶段。此时有客户端访问时,会根据loading状态返回“数据正在加载...”;
// 2. 将当前时间赋值给redisServer.loading_start_time,用以统计数据加载时间;
// 3. 将aof文件大小赋值给redisServer.loading_total_bytes,用以统计加载进度。
startLoading(fp);

/* Check if this AOF file has an RDB preamble. In that case we need to
* load the RDB file and later continue loading the AOF tail. */
//检查AOF文件是否有一个RDB格式的前缀。在这种情况下,我们需要先加载RDB文件,然后再加载后面的AOF文件
char sig[5]; /* "REDIS" */
if (fread(sig,1,5,fp) != 5 || memcmp(sig,"REDIS",5) != 0) {
/* No RDB preamble, seek back at 0 offset. */
if (fseek(fp,0,SEEK_SET) == -1) goto readerr;
} else {
/* RDB preamble. Pass loading the RDB functions. */
rio rdb;

serverLog(LL_NOTICE,"Reading RDB preamble from AOF file...");
if (fseek(fp,0,SEEK_SET) == -1) goto readerr;
rioInitWithFile(&rdb,fp);
if (rdbLoadRio(&rdb,NULL,1) != C_OK) {
serverLog(LL_WARNING,"Error reading the RDB preamble of the AOF file, AOF loading aborted");
goto readerr;
} else {
serverLog(LL_NOTICE,"Reading the remaining AOF tail...");
}
}

/* Read the actual AOF file, in REPL format, command by command. */
//接下来是一个while循环,不断的读取命令并执行
while(1) {
int argc, j;
unsigned long len;
robj **argv;
char buf[128];
sds argsds;
struct redisCommand *cmd;

/* Serve the clients from time to time */
// loops记录循环次数,在每执行1000次循环时,会更新一下加载进度。
if (!(loops++ % 1000)) {
loadingProgress(ftello(fp));
//由于加载过程一般比较长,所以此处会调用processEventsWhileBlocked函数,处理文件io事件,避免客户端一直阻塞。
// 这个函数可以完成,客户端连接的建立,同时响应请求(数据正在加载,不完整,所以响应的内容都是返回错误,并提示“数据正在加载...”)。
processEventsWhileBlocked();
}


/*
* 接下来根据inline和multibulk协议的格式进行分别的加载!!!这一部分在客户端的multibulk有讲解
*/

//读一行,遇到\n
if (fgets(buf,sizeof(buf),fp) == NULL) {
//读到eof,加载完毕
if (feof(fp))
break;
else
goto readerr;
}
//处理'*MULTI_BULK_LEN\r\n'
if (buf[0] != '*') goto fmterr;
if (buf[1] == '\0') goto readerr;
argc = atoi(buf+1);
if (argc < 1) goto fmterr;

/* Load the next command in the AOF as our fake client
* argv. */
argv = zmalloc(sizeof(robj*)*argc);
fakeClient->argc = argc;
fakeClient->argv = argv;

// 读取multi bulk的长度,接下来是一个for循环,一次读取每个bulk。
for (j = 0; j < argc; j++) {
/* Parse the argument len. */
//处理'$BULK_LEN\r\n'
char *readres = fgets(buf,sizeof(buf),fp);
if (readres == NULL || buf[0] != '$') {
fakeClient->argc = j; /* Free up to j-1. */
freeFakeClientArgv(fakeClient);
if (readres == NULL)
goto readerr;
else
goto fmterr;
}
len = strtol(buf+1,NULL,10);

/* Read it into a string object. */
//分配响应大小的buffer
argsds = sdsnewlen(SDS_NOINIT,len);
//二进制读取len大小的buffer
if (len && fread(argsds,len,1,fp) == 0) {
sdsfree(argsds);
fakeClient->argc = j; /* Free up to j-1. */
freeFakeClientArgv(fakeClient);
goto readerr;
}
argv[j] = createObject(OBJ_STRING,argsds);

/* Discard CRLF. */
//跳过\r\n
if (fread(buf,2,1,fp) == 0) {
fakeClient->argc = j+1; /* Free up to j. */
freeFakeClientArgv(fakeClient);
goto readerr;
}
}

/* Command lookup */
//解析出完整命令后,需要执行该命令,首先根据命令名,查找对应的command结构,最后回调命令处理函数。
cmd = lookupCommand(argv[0]->ptr);
if (!cmd) {
serverLog(LL_WARNING,
"Unknown command '%s' reading the append only file",
(char*)argv[0]->ptr);
exit(1);
}

if (cmd == server.multiCommand) valid_before_multi = valid_up_to;

/* Run the command in the context of a fake client */
fakeClient->cmd = cmd;
if (fakeClient->flags & CLIENT_MULTI &&
fakeClient->cmd->proc != execCommand)
{
queueMultiCommand(fakeClient);
} else {
cmd->proc(fakeClient);
}

/* The fake client should not have a reply */
// fake client对应的socket fd为负数,准备响应的函数prepareClientToWrite会据此作判断,不返回响应内容
//此处进行校验,因为fake client不可能有响应内容
serverAssert(fakeClient->bufpos == 0 &&
listLength(fakeClient->reply) == 0);

/* The fake client should never get blocked */
serverAssert((fakeClient->flags & CLIENT_BLOCKED) == 0);

/* Clean up. Command code may have changed argv/argc so we use the
* argv/argc of the client instead of the local variables. */
//最后清理fake client,以便下一个命令的执行。
// valid_up_to记录当前正确解析的日志长度,在数据不完整(提前读到eof)并且设置aof_load_truncated时,会将aof文件截断到valid_up_to字节。
freeFakeClientArgv(fakeClient);
fakeClient->cmd = NULL;
if (server.aof_load_truncated) valid_up_to = ftello(fp);
}
重写

先提一句,我觉得重写的源码和rdbSaveBackground的相关源码有很多相似之处,可以对比看一看。

首先看一下,BGREWRITEAOF命令对应的处理函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void bgrewriteaofCommand(redisClient *c) {
//of_child_pid指示进行aof rewrite进程的pid,rdb_child_pid指示进行rdb dump的进程pid。
if (server.aof_child_pid != -1) {
//如果当前正在进行aof rewrite,则返回客户端错误;
addReplyError(c,"Background append only file rewriting already in progress");
} else if (server.rdb_child_pid != -1) {
//如果当前正在进行rdb dump,为了避免对磁盘造成压力,将aof_rewrite_scheduled置为1,随后在没有进行aof rewrite和rdb dump时,再开启rewrite;
server.aof_rewrite_scheduled = 1;
addReplyStatus(c,"Background append only file rewriting scheduled");
} else if (rewriteAppendOnlyFileBackground() == REDIS_OK) {
//如果当前没有aof rewrite和rdb dump在进行,则调用rewriteAppendOnlyFileBackground进行aof rewrite。
addReplyStatus(c,"Background append only file rewriting started");
} else {
//异常情况,直接返回错误。
addReply(c,shared.err);
}
}

下面,看一下serverCron中是如何触发aof rewrite的:

注意:为了方便看,我这里将rdb和aof的相关源码全部贴出来了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
...

/*
* AOF重写在serverCron中第一个触发点:
* 需要确认当前没有aof rewrite和rdb dump在进行,并且设置了aof_rewrite_scheduled(在bgrewriteaofCommand里设置的),
* 调用rewirteAppendOnlyFileBackground进行aof rewrite。
*/
if (server.rdb_child_pid == -1 && server.aof_child_pid == -1 &&
server.aof_rewrite_scheduled)
{
rewriteAppendOnlyFileBackground();
}

// 检测bgsave、aof重写是否在执行过程中,或者是否有子线程
if (server.rdb_child_pid != -1 || server.aof_child_pid != -1 ||
ldbPendingChildren())
{
int statloc;
pid_t pid;

//等待所有的子进程
if ((pid = wait3(&statloc,WNOHANG,NULL)) != 0) {
//取得子进程exit()返回的结束代码
int exitcode = WEXITSTATUS(statloc);
int bysignal = 0;

//如果子进程是因为信号而结束则此宏值为真
if (WIFSIGNALED(statloc)) bysignal = WTERMSIG(statloc);

if (pid == -1) {
//如果此时没有子线程
serverLog(LL_WARNING,"wait3() returned an error: %s. "
"rdb_child_pid = %d, aof_child_pid = %d",
strerror(errno),
(int) server.rdb_child_pid,
(int) server.aof_child_pid);
} else if (pid == server.rdb_child_pid) {
//如果已经完成了bgsave,会调用backgroundSaveDoneHandler函数做最后处理
backgroundSaveDoneHandler(exitcode,bysignal);
if (!bysignal && exitcode == 0) receiveChildInfo();
} else if (pid == server.aof_child_pid) {
//如果已经完成了aof重写,会调用backgroundRewriteDoneHandler函数做最后处理
backgroundRewriteDoneHandler(exitcode,bysignal);
if (!bysignal && exitcode == 0) receiveChildInfo();
} else {
if (!ldbRemoveChild(pid)) {
serverLog(LL_WARNING,
"Warning, detected child with unmatched pid: %ld",
(long)pid);
}
}
updateDictResizePolicy();
closeChildInfoPipe();
}
} else {
/*
* 如果此时没有子线程在进行save或rewrite,则判断是否要save或rewrite
*/

//先判断是否要进行save
/*
* RDB的持久化在serverCron中第一个触发点,根据条件判断定期触发:
*/
for (j = 0; j < server.saveparamslen; j++) {
//每一个saveparam表示一个配置文件中的save
struct saveparam *sp = server.saveparams+j;

/* Save if we reached the given amount of changes,
* the given amount of seconds, and if the latest bgsave was
* successful or if, in case of an error, at least
* CONFIG_BGSAVE_RETRY_DELAY seconds already elapsed. */
//判断修改次数和时间
if (server.dirty >= sp->changes &&
server.unixtime-server.lastsave > sp->seconds &&
(server.unixtime-server.lastbgsave_try >
CONFIG_BGSAVE_RETRY_DELAY ||
server.lastbgsave_status == C_OK))
{
serverLog(LL_NOTICE,"%d changes in %d seconds. Saving...",
sp->changes, (int)sp->seconds);
rdbSaveInfo rsi, *rsiptr;
rsiptr = rdbPopulateSaveInfo(&rsi);
//调用rdbSaveBackground进行save
rdbSaveBackground(server.rdb_filename,rsiptr);
break;
}
}

/*
* AOF重写在serverCron中第二个触发点:
* 判断aof文件的大小超过预定的百分比,
* 当aof文件超过了预定的最小值,并且超过了上一次aof文件的一定百分比,则会触发aof rewrite。
*/
if (server.aof_state == AOF_ON &&
server.rdb_child_pid == -1 &&
server.aof_child_pid == -1 &&
server.aof_rewrite_perc &&
server.aof_current_size > server.aof_rewrite_min_size)
{
long long base = server.aof_rewrite_base_size ?
server.aof_rewrite_base_size : 1;
long long growth = (server.aof_current_size*100/base) - 100;
if (growth >= server.aof_rewrite_perc) {
serverLog(LL_NOTICE,"Starting automatic rewriting of AOF on %lld%% growth",growth);
rewriteAppendOnlyFileBackground();
}
}
}


/* AOF postponed flush: Try at every cron cycle if the slow fsync
* completed. */
//进行AOF buffer的刷盘:
// 在aof_flush_postponed_start不为0时调用,即存在延迟flush的情况。
// 主要是保证fsync完成之后,可以快速的进入下一次flush。
// 尽量保证fsync策略是everysec时,每秒都可以进行fsync,同时缩短两次fsync的间隔,减少影响。
if (server.aof_flush_postponed_start) flushAppendOnlyFile(0);

/* AOF write errors: in this case we have a buffer to flush as well and
* clear the AOF error in case of success to make the DB writable again,
* however to try every second is enough in case of 'hz' is set to
* an higher frequency. */
//进行AOF buffer的刷盘:保证aof出错时,尽快执行下一次flush,以便从错误恢复。
run_with_period(1000) {
if (server.aof_last_write_status == C_ERR)
flushAppendOnlyFile(0);
}

...

/*
* RDB的持久化在serverCron中第二个触发点,需要确认当前没有aof rewrite和rdb dump在进行,并且设置了rdb_bgsave_scheduled:
* 如果上次触发bgsave时已经有进程在执行aof的重写了(rdbSaveBackground中),就会标记rdb_bgsave_scheduled=1,
* 然后放到serverCron,然后在serverCron的最后在进行判断是否能够执行
*/
if (server.rdb_child_pid == -1 && server.aof_child_pid == -1 &&
server.rdb_bgsave_scheduled &&
(server.unixtime-server.lastbgsave_try > CONFIG_BGSAVE_RETRY_DELAY ||
server.lastbgsave_status == C_OK))
{
rdbSaveInfo rsi, *rsiptr;
rsiptr = rdbPopulateSaveInfo(&rsi);
if (rdbSaveBackground(server.rdb_filename,rsiptr) == C_OK)
server.rdb_bgsave_scheduled = 0;
}

...
}

接下来看看重写的具体逻辑吧,也就是rewriteAppendOnlyFileBackground, rewrite的大致流程是:创建子进程,获取当前快照,同时将之后的命令记录到aof_rewrite_buf中,子进程遍历db生成aof临时文件,然后退出;父进程wait子进程,待结束后,将aof_rewrite_buf中的数据追加到该aof文件中,最后重命名该临时文件为正式的aof文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
int rewriteAppendOnlyFileBackground(void) {
pid_t childpid;
long long start;

//如果已经有aof重写子线程或rdb持久化子线程就直接返回
if (server.aof_child_pid != -1 || server.rdb_child_pid != -1) return C_ERR;
if (aofCreatePipes() != C_OK) return C_ERR;
openChildInfoPipe();
//获取当前时间,用于统计fork耗时
start = ustime();
//调用fork,进入子进程的流程
if ((childpid = fork()) == 0) {
char tmpfile[256];

/* Child */
//子进程首先关闭监听socket,避免接收客户端连接
closeClildUnusedResourceAfterFork();
//设置进程的title
redisSetProcTitle("redis-aof-rewrite");
//生成临时aof文件名
snprintf(tmpfile,256,"temp-rewriteaof-bg-%d.aof", (int) getpid());
//调用rewriteAppendOnlyFile进行rewrite
if (rewriteAppendOnlyFile(tmpfile) == C_OK) {
//如果rewrite成功,统计copy-on-write的脏页并记录日志,然后以退出码0退出进程
size_t private_dirty = zmalloc_get_private_dirty(-1);

if (private_dirty) {
serverLog(LL_NOTICE,
"AOF rewrite: %zu MB of memory used by copy-on-write",
private_dirty/(1024*1024));
}

server.child_info_data.cow_size = private_dirty;
sendChildInfo(CHILD_INFO_TYPE_AOF);
exitFromChild(0);
} else {
//如果rewrite失败,则退出进程并返回1作为退出码
exitFromChild(1);
}
} else {
/* Parent */
// 父进程更新redisServer记录一些信息,例如:fork进程消耗的时间stat_fork_time,
server.stat_fork_time = ustime()-start;
// 更新redisServer记录fork速率:每秒多少G;zmalloc_used_memory()的单位是字节,所以通过除以(1024*1024*1024),得到GB;由于记录的fork_time即fork时间是微妙,所以*1000000,得到每秒钟fork多少GB的速度;
server.stat_fork_rate = (double) zmalloc_used_memory() * 1000000 / server.stat_fork_time / (1024*1024*1024); /* GB per second. */
latencyAddSampleIfNeeded("fork",server.stat_fork_time/1000);

// 如果fork子进程出错,即childpid为-1
if (childpid == -1) {
closeChildInfoPipe();
serverLog(LL_WARNING,
"Can't rewrite append only file in background: fork: %s",
strerror(errno));
aofClosePipes();
return C_ERR;
}
serverLog(LL_NOTICE,
"Background append only file rewriting started by pid %d",childpid);
//如果fork子进程成功
//对aof_rewrite_scheduled清零,记录rewrite开始时间以及aof_child_pid(redis通过这个属性判断是否有aof rewrite在进行)
server.aof_rewrite_scheduled = 0;
server.aof_rewrite_time_start = time(NULL);
server.aof_child_pid = childpid;
//调用updateDictResizePolicy调整db的key space的rehash策略
//由于创建了子进程,避免copy-on-write复制大量内存页,这里会禁止dict的rehash
updateDictResizePolicy();
/* We set appendseldb to -1 in order to force the next call to the
* feedAppendOnlyFile() to issue a SELECT command, so the differences
* accumulated by the parent into server.aof_rewrite_buf will start
* with a SELECT statement and it will be safe to merge. */
//将aof_selected_db置为-1,目的是,下一条aof会首先生成一条select db的日志,同时会写到aof_rewrite_buf中,
//这样就可以将aof_rewrite_buf正常的追加到rewrite之后的文件。(序列化中的FeedAppendOnlyFile)
server.aof_selected_db = -1;
replicationScriptCacheFlush();
return C_OK;
}
return C_OK; /* unreached */
}

看一下具体的rewrite过程,即rewriteAppendOnlyFile,大体上,就是遍历所有key,进行序列化,然后记录到aof文件中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
int rewriteAppendOnlyFile(char *filename) {
rio aof;
FILE *fp;
char tmpfile[256];
char byte;

/* Note that we have to use a different temp name here compared to the
* one used by rewriteAppendOnlyFileBackground() function. */
//生成临时文件名并创建该文件
snprintf(tmpfile,256,"temp-rewriteaof-%d.aof", (int) getpid());
fp = fopen(tmpfile,"w");
if (!fp) {
serverLog(LL_WARNING, "Opening the temp file for AOF rewrite in rewriteAppendOnlyFile(): %s", strerror(errno));
return C_ERR;
}

// rio就是面向流的I/O接口,底层可以有不同实现,目前提供了文件和内存buffer的实现。这里对rio进行初始化。
server.aof_child_diff = sdsempty();
//如果配置了server.aof_rewrite_incremental_fsync,则在写aof时会增量地进行fsync,
//这里配置的是每写入32M就sync一次。避免集中sync导致磁盘跑满。
rioInitWithFile(&aof,fp);

if (server.aof_rewrite_incremental_fsync)
rioSetAutoSync(&aof,REDIS_AUTOSYNC_BYTES);

if (server.aof_use_rdb_preamble) {
int error;
if (rdbSaveRio(&aof,&error,RDB_SAVE_AOF_PREAMBLE,NULL) == C_ERR) {
errno = error;
goto werr;
}
} else {
//借助rio进行所有K-V的重写
if (rewriteAppendOnlyFileRio(&aof) == C_ERR) goto werr;
}

/* Do an initial slow fsync here while the parent is still sending
* data, in order to make the next final fsync faster. */
if (fflush(fp) == EOF) goto werr;
if (fsync(fileno(fp)) == -1) goto werr;

/* Read again a few times to get more data from the parent.
* We can't read forever (the server may receive data from clients
* faster than it is able to send data to the child), so we try to read
* some more data in a loop as soon as there is a good chance more data
* will come. If it looks like we are wasting time, we abort (this
* happens after 20 ms without new data). */
int nodata = 0;
mstime_t start = mstime();
while(mstime()-start < 1000 && nodata < 20) {
if (aeWait(server.aof_pipe_read_data_from_parent, AE_READABLE, 1) <= 0)
{
nodata++;
continue;
}
nodata = 0; /* Start counting from zero, we stop on N *contiguous*
timeouts. */
aofReadDiffFromParent();
}

/* Ask the master to stop sending diffs. */
if (write(server.aof_pipe_write_ack_to_parent,"!",1) != 1) goto werr;
if (anetNonBlock(NULL,server.aof_pipe_read_ack_from_parent) != ANET_OK)
goto werr;
/* We read the ACK from the server using a 10 seconds timeout. Normally
* it should reply ASAP, but just in case we lose its reply, we are sure
* the child will eventually get terminated. */
if (syncRead(server.aof_pipe_read_ack_from_parent,&byte,1,5000) != 1 ||
byte != '!') goto werr;
serverLog(LL_NOTICE,"Parent agreed to stop sending diffs. Finalizing AOF...");

/* Read the final diff if any. */
aofReadDiffFromParent();

/* Write the received diff to the file. */
serverLog(LL_NOTICE,
"Concatenating %.2f MB of AOF diff received from parent.",
(double) sdslen(server.aof_child_diff) / (1024*1024));
if (rioWrite(&aof,server.aof_child_diff,sdslen(server.aof_child_diff)) == 0)
goto werr;

/* Make sure data will not remain on the OS's output buffers */
//调用fflush将输出缓冲区刷新到page cache(内核缓冲区),然后调用fsync将cache中的内容写盘,最后关闭文件。
if (fflush(fp) == EOF) goto werr;
if (fsync(fileno(fp)) == -1) goto werr;
if (fclose(fp) == EOF) goto werr;

/* Use RENAME to make sure the DB file is changed atomically only
* if the generate DB file is ok. */
//将临时文件重命名(其实还是一个临时文件名,两个格式有所不同,最终的命名在后序处理中backgroundRewriteDoneHandler中),确保生成的aof文件完全ok,避免出现aof不完整的情况
if (rename(tmpfile,filename) == -1) {
serverLog(LL_WARNING,"Error moving temp append only file on the final destination: %s", strerror(errno));
unlink(tmpfile);
return C_ERR;
}
serverLog(LL_NOTICE,"SYNC append only file rewrite performed");
return C_OK;

werr:
serverLog(LL_WARNING,"Write error writing append only file on disk: %s", strerror(errno));
fclose(fp);
unlink(tmpfile);
return C_ERR;
}

借助RIO对所有的K-V对进行重写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
int rewriteAppendOnlyFileRio(rio *aof) {
dictIterator *di = NULL;
dictEntry *de;
size_t processed = 0;
int j;

//接下来是一个循环,用于遍历redis的每个db,对其进行rewirte
for (j = 0; j < server.dbnum; j++) {
//首先,生成对应db的select命令,然后查看如果db为空的话,就跳过,rewrite下一个db
char selectcmd[] = "*2\r\n$6\r\nSELECT\r\n";
redisDb *db = server.db+j;
dict *d = db->dict;
if (dictSize(d) == 0) continue;
//然后获取该db的迭代器
di = dictGetSafeIterator(d);

/* SELECT the new DB */
//将select db的命令写入文件
if (rioWrite(aof,selectcmd,sizeof(selectcmd)-1) == 0) goto werr;
if (rioWriteBulkLongLong(aof,j) == 0) goto werr;

//遍历db的每一个key,生成相应的命令
while((de = dictNext(di)) != NULL) {
sds keystr;
robj key, *o;
long long expiretime;

keystr = dictGetKey(de);
o = dictGetVal(de);
initStaticStringObject(key,keystr);

expiretime = getExpire(db,&key);

//redis3.0中做了一个判断 if (expiretime != -1 && expiretime < now) continue;
//也就是重写会跳过已经过期的键,这里没有


// 接下来,根据对象的类型,序列化成相应的命令。并将命令写入aof文件中
if (o->type == OBJ_STRING) {
/* Emit a SET command */
char cmd[]="*3\r\n$3\r\nSET\r\n";
if (rioWrite(aof,cmd,sizeof(cmd)-1) == 0) goto werr;
/* Key and value */
if (rioWriteBulkObject(aof,&key) == 0) goto werr;
if (rioWriteBulkObject(aof,o) == 0) goto werr;
} else if (o->type == OBJ_LIST) {
if (rewriteListObject(aof,&key,o) == 0) goto werr;
} else if (o->type == OBJ_SET) {
if (rewriteSetObject(aof,&key,o) == 0) goto werr;
} else if (o->type == OBJ_ZSET) {
if (rewriteSortedSetObject(aof,&key,o) == 0) goto werr;
} else if (o->type == OBJ_HASH) {
if (rewriteHashObject(aof,&key,o) == 0) goto werr;
} else if (o->type == OBJ_STREAM) {
if (rewriteStreamObject(aof,&key,o) == 0) goto werr;
} else if (o->type == OBJ_MODULE) {
if (rewriteModuleObject(aof,&key,o) == 0) goto werr;
} else {
serverPanic("Unknown object type");
}
// 如果有超时时间,同样序列化成命令记录到aof文件
if (expiretime != -1) {
char cmd[]="*3\r\n$9\r\nPEXPIREAT\r\n";
if (rioWrite(aof,cmd,sizeof(cmd)-1) == 0) goto werr;
if (rioWriteBulkObject(aof,&key) == 0) goto werr;
if (rioWriteBulkLongLong(aof,expiretime) == 0) goto werr;
}
/* Read some diff from the parent process from time to time. */
if (aof->processed_bytes > processed+AOF_READ_DIFF_INTERVAL_BYTES) {
processed = aof->processed_bytes;
aofReadDiffFromParent();
}
}
dictReleaseIterator(di);
di = NULL;
}
return C_OK;

werr:
if (di) dictReleaseIterator(di);

后序处理/重写追加

多进程编程中,子进程退出后,父进程需要对其进行清理,否则子进程会编程僵尸进程。同样是在serverCron函数中,主进程完成对rewrite进程的清理。

下面这段代码应该不陌生,前面也多次出现了,在前面的RDB的serverCron处理中已经对RDB的后序处理backgroundSaveDoneHandler函数进行了简单说明,这里就接下来看看AOF的后序处理backgroundRewriteDoneHandler(aof.c)函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// 检测bgsave、aof重写是否在执行过程中,或者是否有子线程
if (server.rdb_child_pid != -1 || server.aof_child_pid != -1 ||
ldbPendingChildren())
{
int statloc;
pid_t pid;

//等待所有的子进程
if ((pid = wait3(&statloc,WNOHANG,NULL)) != 0) {
//取得子进程exit()返回的结束代码
int exitcode = WEXITSTATUS(statloc);
int bysignal = 0;

//如果子进程是因为信号而结束则此宏值为真
if (WIFSIGNALED(statloc)) bysignal = WTERMSIG(statloc);

if (pid == -1) {
//如果此时没有子线程
serverLog(LL_WARNING,"wait3() returned an error: %s. "
"rdb_child_pid = %d, aof_child_pid = %d",
strerror(errno),
(int) server.rdb_child_pid,
(int) server.aof_child_pid);
} else if (pid == server.rdb_child_pid) {
//如果已经完成了bgsave,会调用backgroundSaveDoneHandler函数做最后处理
backgroundSaveDoneHandler(exitcode,bysignal);
if (!bysignal && exitcode == 0) receiveChildInfo();
} else if (pid == server.aof_child_pid) {
//如果已经完成了aof重写,会调用backgroundRewriteDoneHandler函数做最后处理
backgroundRewriteDoneHandler(exitcode,bysignal);
if (!bysignal && exitcode == 0) receiveChildInfo();
} else {
if (!ldbRemoveChild(pid)) {
serverLog(LL_WARNING,
"Warning, detected child with unmatched pid: %ld",
(long)pid);
}
}
updateDictResizePolicy();
closeChildInfoPipe();
}
} else {
...

在看具体的后序处理函数之前,这里先讲一个概念:

在linux中,删除文件时,会判断打开这个文件的所有进程是否都已经关闭,如果还有一个进程没有关闭,那么这个文件的空间将不会释放。只有所有打开这个文件的进程都关闭以后,这个文件的空间才会释放。因此在接下来的这个函数中,因为我们在initServer中或者前一个backgroundRewriteDoneHandler中已经打开了旧的aof文件,因此就算将新的临时aof文件重命名,表面上旧的aof文件已经删除了,但实际上,因为我们仍然保留着旧的aof文件描述符,这个文件还是打开着的,然而这个旧aof文件后序的情况其实对主程序来说没有任何影响,因此可以使用BIO后台任务异步的进行关闭,而减轻我们主线程的阻塞情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
void backgroundRewriteDoneHandler(int exitcode, int bysignal) {
// 如果正常退出的情况下,就是没有被信号kill,并且退出码等于0
if (!bysignal && exitcode == 0) {
int newfd, oldfd;
char tmpfile[256];
long long now = ustime();
mstime_t latency;

//首先是记录日志
serverLog(LL_NOTICE,
"Background AOF rewrite terminated with success");

/* Flush the differences accumulated by the parent to the
* rewritten AOF. */
//然后打开临时写入的rewrite文件
latencyStartMonitor(latency);
snprintf(tmpfile,256,"temp-rewriteaof-bg-%d.aof",
(int)server.aof_child_pid);
newfd = open(tmpfile,O_WRONLY|O_APPEND);
if (newfd == -1) {
serverLog(LL_WARNING,
"Unable to open the temporary AOF produced by the child: %s", strerror(errno));
goto cleanup;
}

//将rewrite buf追加到文件
if (aofRewriteBufferWrite(newfd) == -1) {
serverLog(LL_WARNING,
"Error trying to flush the parent diff to the rewritten AOF: %s", strerror(errno));
close(newfd);
goto cleanup;
}
latencyEndMonitor(latency);
latencyAddSampleIfNeeded("aof-rewrite-diff-write",latency);

serverLog(LL_NOTICE,
"Residual parent diff successfully flushed to the rewritten AOF (%.2f MB)", (double) aofRewriteBufferSize() / (1024*1024));

if (server.aof_fd == -1) {
/* AOF disabled */

/* Don't care if this fails: oldfd will be -1 and we handle that.
* One notable case of -1 return is if the old file does
* not exist. */
oldfd = open(server.aof_filename,O_RDONLY|O_NONBLOCK);
} else {
/* AOF enabled */
oldfd = -1; /* We'll set this to the current AOF filedes later. */
}

/* Rename the temporary file. This will not unlink the target file if
* it exists, because we reference it with "oldfd". */
latencyStartMonitor(latency);
//将临时文件重命名为最终的aof文件,因为我们保留了旧文件的文件描述符,因此这个文件并没有关闭
if (rename(tmpfile,server.aof_filename) == -1) {
serverLog(LL_WARNING,
"Error trying to rename the temporary AOF file %s into %s: %s",
tmpfile,
server.aof_filename,
strerror(errno));
close(newfd);
if (oldfd != -1) close(oldfd);
goto cleanup;
}
latencyEndMonitor(latency);
latencyAddSampleIfNeeded("aof-rename",latency);

if (server.aof_fd == -1) {
/* AOF disabled, we don't need to set the AOF file descriptor
* to this new file, so we can close it. */
close(newfd);
} else {
//将server中旧的文件描述符替换成新的文件描述符
oldfd = server.aof_fd;
server.aof_fd = newfd;
//进行一次fsync刷盘,因为前面又将rewrite buf中的数据写入文件了
if (server.aof_fsync == AOF_FSYNC_ALWAYS)
redis_fsync(newfd);
else if (server.aof_fsync == AOF_FSYNC_EVERYSEC)
//创建一个aof刷盘的后台任务,由指定类型的BIO后台线程进行执行,类似于fsync,也是一个子线程进行执行,但并不一定立即执行,因为当前任务可能会等待
aof_background_fsync(newfd);
server.aof_selected_db = -1; /* Make sure SELECT is re-issued */
aofUpdateCurrentSize();
server.aof_rewrite_base_size = server.aof_current_size;
server.aof_fsync_offset = server.aof_current_size;

/* Clear regular AOF buffer since its contents was just written to
* the new AOF from the background rewrite buffer. */
sdsfree(server.aof_buf);
server.aof_buf = sdsempty();
}

server.aof_lastbgrewrite_status = C_OK;

serverLog(LL_NOTICE, "Background AOF rewrite finished successfully");
/* Change state from WAIT_REWRITE to ON if needed */
//更新状态,这里我看好像整个流程没有修改状态为AOF_WAIT_REWRITE,
//只有在主从同步之后调用restartAOFAfterSYNC,从而调用startAppendOnly函数才进行了更新
if (server.aof_state == AOF_WAIT_REWRITE)
server.aof_state = AOF_ON;

/* Asynchronously close the overwritten AOF. */
//异步关闭之前的aof文件。创建一个关闭文件的后台任务,由指定类型的BIO后台线程进行执行。
if (oldfd != -1) bioCreateBackgroundJob(BIO_CLOSE_FILE,(void*)(long)oldfd,NULL,NULL);

serverLog(LL_VERBOSE,
"Background AOF rewrite signal handler took %lldus", ustime()-now);
} else if (!bysignal && exitcode != 0) {
//如果rewrite子进程异常退出,由信号kill或者退出码非0,则只是记录 日志。
server.aof_lastbgrewrite_status = C_ERR;

serverLog(LL_WARNING,
"Background AOF rewrite terminated with error");
} else {
/* SIGUSR1 is whitelisted, so we have a way to kill a child without
* tirggering an error condition. */
if (bysignal != SIGUSR1)
server.aof_lastbgrewrite_status = C_ERR;

serverLog(LL_WARNING,
"Background AOF rewrite terminated by signal %d", bysignal);
}

cleanup:
aofClosePipes();
aofRewriteBufferReset();
aofRemoveTempFile(server.aof_child_pid);
server.aof_child_pid = -1;
server.aof_rewrite_time_last = time(NULL)-server.aof_rewrite_time_start;
server.aof_rewrite_time_start = -1;
/* Schedule a new rewrite if we are waiting for it to switch the AOF ON. */
if (server.aof_state == AOF_WAIT_REWRITE)
server.aof_rewrite_scheduled = 1;
}

总结

  1. 如果你只希望你的数据在服务器运行的时候存在,可以不使用任何的持久化方式

  2. 一般建议同时开启两种持久化方式。AOF进行数据的持久化,确保数据不会丢失太多,而RDB更适合用于备份数据库,留着一个做万一的手段。

  3. 性能建议:

    因为RDB文件只用做后备用途,建议只在slave上持久化RDB文件,而且只要在15分钟备份一次就够了,只保留900 1这条规则。

    如果Enalbe AOF,好处是在最恶劣情况下也只会丢失不超过两秒数据,启动脚本较简单只load自己的AOF文件就可以了。代价:1、带来了持续的IO;2、AOF rewrite的最后将rewrite过程中产生的新数据写到新文件造成的阻塞几乎是不可避免的。只要硬盘许可,应该尽量减少AOF rewrite的频率,AOF重写的基础大小默认值64M太小了,可以设到5G以上。默认超过原大小100%大小时重写可以改到适当的数值。

    如果不Enable AOF,仅靠Master-Slave Replication 实现高可用性也可以。能省掉一大笔IO也减少了rewrite时带来的系统波动。代价是如果Master/Slave同时宕掉,会丢失10几分钟的数据,启动脚本也要比较两个Master/Slave中的RDB文件,载入较新的那个。新浪微博就选用了这种架构。

事件(event loop)

redis服务器是一个事件驱动程序,Redis的服务器进程就是一个事件循环(event loop),所有的操作都会被封装为event,主要有两个类型的event:

  1. File Event :服务器和客户端的通信会产生对应的文件事件
  2. Time Event :为1s执行一次的计划任务, 会处理以下逻辑:①过期key的清理;②内部的调用性能统计;③DB对象的rehash扩容;④RDB&AOF的数据持久化(如果有必要);⑤及其他一些检查

event loop

redis服务初始化分为五个阶段:①初始化服务配置;②载入配置选项;③服务初始化;④还原数据库状态;⑤启动event loop(具体可看服务端章节)

event loop为单线程处理:①所有event的处理因为是单线程顺序处理, 所以在操作DB等内存数据时是无锁的;②在每个process循环中都尝试处理所有已加入队列的io event和time event;③io event和time event是在同一个loop processor中顺序执行;④event loop中process的时延直接决定了redis server的吞吐量

  1. 创建event loop(server.c#initServer):

    1
    2
    3
    4
    5
    6
    7
    server.el = aeCreateEventLoop(server.maxclients+CONFIG_FDSET_INCR);
    if (server.el == NULL) {
    serverLog(LL_WARNING,
    "Failed creating the event loop. Error message: '%s'",
    strerror(errno));
    exit(1);
    }
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    aeEventLoop *aeCreateEventLoop(int setsize) {
    aeEventLoop *eventLoop;
    int i;

    // setsize指定事件循环监听的fd的数目
    // 由于内核保证新创建的fd是最小的正整数,所以直接创建setsize大小的数组,存放对应的event
    if ((eventLoop = zmalloc(sizeof(*eventLoop))) == NULL) goto err;
    eventLoop->events = zmalloc(sizeof(aeFileEvent)*setsize);
    eventLoop->fired = zmalloc(sizeof(aeFiredEvent)*setsize);
    if (eventLoop->events == NULL || eventLoop->fired == NULL) goto err;
    eventLoop->setsize = setsize;
    eventLoop->lastTime = time(NULL);
    eventLoop->timeEventHead = NULL;
    eventLoop->timeEventNextId = 0;
    eventLoop->stop = 0;
    eventLoop->maxfd = -1;
    eventLoop->beforesleep = NULL;
    //aeApiCreate主要是创建epoll的fd,以及要监听的epoll_event
    if (aeApiCreate(eventLoop) == -1) goto err;
    /* Events with mask == AE_NONE are not set. So let's initialize the
    * vector with it. */
    for (i = 0; i < setsize; i++)
    eventLoop->events[i].mask = AE_NONE;
    return eventLoop;

    err:
    if (eventLoop) {
    zfree(eventLoop->events);
    zfree(eventLoop->fired);
    zfree(eventLoop);
    }
    return NULL;
    }
  2. 启动event loop(server.c#main):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 每个event执行的前置函数
    aeSetBeforeSleepProc(server.el,beforeSleep);
    // 每个event执行完成后的后置函数
    aeSetAfterSleepProc(server.el,afterSleep);
    // 会真正启动event loop的处理, 一旦调用当前线程将block直至系统退出
    aeMain(server.el);
    // 系统退出前的关闭event loop
    aeDeleteEventLoop(server.el);
    return 0;

EventLoop很简单, 核心主要是event selector 和 event processor

  1. event loop中保留了file events列表和time events链表

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    typedef struct aeFileEvent {
    /* 监听事件类型掩码,值可以是 AE_READABLE 或 AE_WRITABLE ,或者 AE_READABLE | AE_WRITABLE */
    int mask;
    aeFileProc *rfileProc; // 读事件处理器
    aeFileProc *wfileProc; // 写事件处理器
    void *clientData; // 多路复用库的私有数据
    } aeFileEvent;


    typedef struct aeTimeEvent {
    long long id; // 时间事件的唯一标识符
    // 事件的到达时间,即执行的时间
    long when_sec; /* seconds */
    long when_ms; /* milliseconds */
    // 事件处理函数
    aeTimeProc *timeProc;
    aeEventFinalizerProc *finalizerProc; // 事件释放函数
    void *clientData; // 多路复用库的私有数据
    struct aeTimeEvent *next; // 指向下个时间事件结构,形成链表
    } aeTimeEvent;

    typedef struct aeFiredEvent {
    int fd; // 已就绪文件描述符
    /* 事件类型掩码,值可以是 AE_READABLE 或 AE_WRITABLE,或者是两者的或 */
    int mask;
    } aeFiredEvent;


    typedef struct aeEventLoop {
    // 目前已注册的最大描述符
    int maxfd; /* highest file descriptor currently registered */
    // 追踪的最大描述符,也就是客户端最大数量
    int setsize; /* max number of file descriptors tracked */
    // 用于生成时间事件 id
    long long timeEventNextId;
    // 最后一次执行时间事件的时间
    time_t lastTime; /* Used to detect system clock skew */
    // 已注册的文件事件
    aeFileEvent *events; /* Registered events */
    // 已就绪的文件事件
    aeFiredEvent *fired; /* Fired events */
    // 时间事件
    aeTimeEvent *timeEventHead;
    // 事件处理器的开关
    int stop;
    // 多路复用库的私有数据,即存放epoll、select等实现相关的数据
    void *apidata; /* This is used for polling API specific data */
    // 在处理事件前要执行的函数,比如调用flushAppendOnlyFile进行一次刷盘
    aeBeforeSleepProc *beforesleep;
    } aeEventLoop;
  2. event selector 其实就是简单的while true系循环, 执行顺序如下:①执行event loop前置的钩子函数 beforesleep;②调用event processor函数–aeProcessEvents执行所有队列中的io event 和 time event

    1
    2
    3
    4
    5
    6
    7
    8
    9
    void aeMain(aeEventLoop *eventLoop) {
    eventLoop->stop = 0;
    //beforeSleep 函数会调用 handleClientsWithPendingWrites 函数来处理 clients_pending_write 列表,和进行一次AOF的刷盘
    while (!eventLoop->stop) {
    if (eventLoop->beforesleep != NULL)
    eventLoop->beforesleep(eventLoop);
    aeProcessEvents(eventLoop, AE_ALL_EVENTS|AE_CALL_AFTER_SLEEP);
    }
    }
  3. event processor 执行顺序如下:①通过aeApiPoll获取所有注册的客户端套接字的事件 ;②在循环中顺序执行event;③check是否有待执行的time event, 如果有会执行time event

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    int aeProcessEvents(aeEventLoop *eventLoop, int flags)
    {
    int processed = 0, numevents;
    if (!(flags & AE_TIME_EVENTS) && !(flags & AE_FILE_EVENTS)) return 0;
    // 当有文件事件或者时间事件需要处理的时候,进入这个处理环节
    /*
    * 在两种情况下进入poll,阻塞等待事件发生:
    * 1)有需要监听的客户端描述符,即有file event时(maxfd != -1)
    * 2)需要处理定时器事件,并且DONT_WAIT开关关闭的情况下
    */
    if (eventLoop->maxfd != -1 ||
    ((flags & AE_TIME_EVENTS) && !(flags & AE_DONT_WAIT))) {
    int j;
    aeTimeEvent *shortest = NULL;
    struct timeval tv, *tvp;

    /*
    * 根据最快发生的定时器事件的发生时间,确定此次poll阻塞的时间
    */
    if (flags & AE_TIME_EVENTS && !(flags & AE_DONT_WAIT))
    // 由于时间事件链表是时间有序的,所以aeSearchNearestTimer是一个线性搜索函数,搜索最近需要被触发的时间事件
    shortest = aeSearchNearestTimer(eventLoop);
    //如果有定时器事件,则根据它触发的时间,计算sleep的时间(ms单位)
    if (shortest) {
    long now_sec, now_ms;
    // aeGetTime获取当前秒数和毫秒
    aeGetTime(&now_sec, &now_ms);
    tvp = &tv;
    // 计算还有多少毫秒该事件要被触发?
    long long ms =
    (shortest->when_sec - now_sec)*1000 +
    shortest->when_ms - now_ms;
    // 还没到触发点,更新tvp使得tv为触发时间点
    if (ms > 0) {
    tvp->tv_sec = ms/1000;
    tvp->tv_usec = (ms % 1000)*1000;
    } else {
    // 已经到了或者过了触发点了,需要马上触发
    tvp->tv_sec = 0;
    tvp->tv_usec = 0;
    }
    } else {
    /*
    * 如果没有定时器事件,则根据情况是立即返回,或者永远阻塞
    */

    // 如果是一个即时事件(AE_DONT_WAIT),马上触发
    if (flags & AE_DONT_WAIT) {
    tv.tv_sec = tv.tv_usec = 0;
    tvp = &tv;
    } else {
    // 当前没有需要触发的事件,tvp=NULL可以使得poll阻塞直到有新事件
    tvp = NULL; /* wait forever */
    }
    }

    //传入前面计算的sleep时间,等待io事件发生。
    // 如果tvp==NULL那么epoll_wait的时间会被设为-1,此时epoll_wait就会一直阻塞
    numevents = aeApiPoll(eventLoop, tvp);

    /* After sleep callback. */
    // poll后的回调函数
    if (eventLoop->aftersleep != NULL && flags & AE_CALL_AFTER_SLEEP)
    eventLoop->aftersleep(eventLoop);
    // aeApiPoll会返回需要触发的事件数,用循环来进行处理
    for (j = 0; j < numevents; j++) {
    // 获取对应触发fd的文件事件
    aeFileEvent *fe = &eventLoop->events[eventLoop->fired[j].fd];
    int mask = eventLoop->fired[j].mask;
    int fd = eventLoop->fired[j].fd;
    // fired为已触发的事件数
    int fired = 0; /* Number of events fired for current fd. */

    // 判断AE_BARRIER是否被设置
    // 如果被设置,那么就需要先写后读
    int invert = fe->mask & AE_BARRIER;

    // 如果没有设置AE_BARRIER并且该事件还没有被处理,开始处理读事件,调用R文件回调函数
    if (!invert && fe->mask & mask & AE_READABLE) {
    fe->rfileProc(eventLoop,fd,fe->clientData,mask);
    fired++;
    }

    // 触发写事件,调用W文件回调函数
    //如果读事件要响应,会在networking.c#handleClientsWithPendingWrites中直接写入sockt,若写入不成功,就会创建一个写事件
    /* Fire the writable event. */
    if (fe->mask & mask & AE_WRITABLE) {
    // 如果还没触发或者R和W并不相同的时候,触发写事件
    if (!fired || fe->wfileProc != fe->rfileProc) {
    fe->wfileProc(eventLoop,fd,fe->clientData,mask);
    fired++;
    }
    }

    // 如果需要先写后读,因为刚才已经调过AE_WRITABLE了(AE_BARRIER=4,设置AE_BARRIER&AE_WRITABLE就大于0了)
    // 这个时候再调用读事件
    if (invert && fe->mask & mask & AE_READABLE) {
    if (!fired || fe->wfileProc != fe->rfileProc) {
    fe->rfileProc(eventLoop,fd,fe->clientData,mask);
    fired++;
    }
    }
    // 统计处理的事件数
    processed++;
    }
    }
    /* Check time events */
    if (flags & AE_TIME_EVENTS)
    // 如果是时间事件,处理时间事件
    processed += processTimeEvents(eventLoop);

    return processed; /* return the number of processed file/time events */
    }

所以,redis的所有定时任务都是基于serverCron来驱动的,而eventloop直接驱动的只有serverCron。

File Event

redis reactor

Redis 的网络框架实现了 Reactor 模型,但是Redis是基于Reactor模式开发了自己的网络事件处理器:这个处理器被称为文件事件处理器( file event handler ) :

  • 文件事件处理器使用IO多路复用( multiplexing)程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器。
  • 当被监听的套接字准备好执行连接应答( accept)、读取( read)、写人( write)、关闭( close)等操作时,与操作相对应的文件事件就会产生,这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。

虽然文件事件处理器以单线程方式运行,但通过使用IO多路复用程序来监听多个套接字,文件事件处理器既实现了高性能的网络通信模型,又可以很好地与Redis服务器中其他同样以单线程方式运行的模块进行对接,这保持了Redis内部单线程设计的简单性。

文件事件处理器分为四个部分:套接字、I/O多路复用程序、文件事件分派器、事件处理器。

  • 文件事件是对套接字操作的抽象,每当一个套接字准备好执行连接应答( accept)、写入、读取、关闭等操作时,就会产生一个文件事件。因为一个服务器通常会连接多个套接字,所以多个文件事件有可能会并发地出现。

  • IO多路复用程序负责监听多个套接字,并向文件事件分派器传送那些产生了事件的套接字。

    尽管多个文件事件可能会并发地出现,但IO多路复用程序总是会将所有产生事件的套接字都放到一个队列里面,然后通过这个队列,以有序( sequentially)、同步( synchronously)、每次一个套接字的方式向文件事件分派器传送套接字。当上一个套接字产生的事件被处理完毕之后(该套接字为事件所关联的事件处理器执行完毕),IO多路复用程序才会继续向文件事件分派器传送下一个套接字。

  • 文件事件分派器接收I/O多路复用程序传来的套接字,并根据套接字产生的事件的类型,调用相应的事件处理器。

  • 服务器会为执行不同任务的套接字关联不同的事件处理器,这些处理器是一个个函数,它们定义了某个事件发生时,服务器应该执行的动作。

具体可以看 ae.h、ae.c这两个文件,Redis 为了实现事件驱动框架,相应地定义了事件的数据结构、框架主循环函数、事件捕获分发函数、事件和 handler 注册函数。这在event loop、服务器的命令执行过程这两个小节有分析。

Nginx 采用多 Reactor 多进程模型,不过与标准的多 Reactor 多进程模型有些许差异。Nginx 的主进程只用来初始化 socket,不会 accept 连接,而是由子进程 accept 连接,之后这个连接的所有处理都在子进程中完成。

nginx:nginx是多进程模型,master进程不处理网络IO,每个Wroker进程是一个独立的单Reacotr单线程模型。

netty:通信绝对的王者,默认是多Reactor,主Reacotr只负责建立连接,然后把建立好的连接给到从Reactor,从Reactor负责IO读写。当然可以专门调整为单Reactor。

kafka:kafka也是多Reactor,但是因为Kafka主要与磁盘IO交互,因此真正的读写数据不是从Reactor处理的,而是有一个worker线程池,专门处理磁盘IO,从Reactor负责网络IO,然后把任务交给worker线程池处理。

I/O多路复用

Redis 的I/O多路复用程序的所有功能都是通过包装常见的select、epoll、evport和kqueue这些IO多路复用函数库来实现的,每个I/O多路复用函数库在Redis源码中都对应一个单独的文件,比如ae_select.c、ae_epoll.c、ae_kqueue.c、ae_evport,诸如此类。

因为Redis为每个IO多路复用函数库都实现了相同的API,所以IO多路复用程序的底层实现是可以互换的。

具体来看一下:

以 Get 请求为例,SimpleKV 为了处理一个 Get 请求,需要监听客户端请求(bind/listen),和客户端建立连接(accept),从 socket 中读取请求(recv),解析客户端发送请求(parse),根据请求类型读取键值数据(get),最后给客户端返回结果,即向 socket 中写回数据(send)。下图显示了这一过程,其中,bind/listen、accept、recv、parse 和 send 属于网络 IO 处理,而 get 属于键值数据操作。既然 Redis 是单线程,那么,最基本的一种实现是在一个线程中依次执行上面说的这些操作。

下图显示了这一过程,其中,bind/listen、accept、recv、parse 和 send 属于网络 IO 处理,而 get 属于键值数据操作。既然 Redis 是单线程,那么,最基本的一种实现是在一个线程中依次执行上面说的这些操作。

但是,在这里的网络 IO 操作中,有潜在的阻塞点,分别是 accept() 和 recv()。当 Redis 监听到一个客户端有连接请求,但一直未能成功建立起连接时,会阻塞在 accept() 函数这里,导致其他客户端无法和 Redis 建立连接。类似的,当 Redis 通过 recv() 从一个客户端读取数据时,如果数据一直没有到达,Redis 也会一直阻塞在 recv()。

这就导致 Redis 整个线程阻塞,无法处理其他客户端请求,效率很低。不过,幸运的是,socket 网络模型本身支持非阻塞模式。

Socket 网络模型的非阻塞模式设置,主要体现在三个关键的函数调用上,如果想要使用 socket 非阻塞模式,就必须要了解这三个函数的调用返回类型和设置模式。接下来,我们就重点学习下它们。在 socket 模型中,不同操作调用后会返回不同的套接字类型。socket() 方法会返回主动套接字,然后调用 listen() 方法,将主动套接字转化为监听套接字,此时,可以监听来自客户端的连接请求。最后,调用 accept() 方法接收到达的客户端连接,并返回已连接套接字

针对监听套接字,我们可以设置非阻塞模式:当 Redis 调用 accept() 但一直未有连接请求到达时,Redis 线程可以返回处理其他操作,而不用一直等待。但是,你要注意的是,调用 accept() 时,已经存在监听套接字了。

虽然 Redis 线程可以不用继续等待,但是总得有机制继续在监听套接字上等待后续连接请求,并在有请求时通知 Redis。类似的,我们也可以针对已连接套接字设置非阻塞模式:Redis 调用 recv() 后,如果已连接套接字上一直没有数据到达,Redis 线程同样可以返回处理其他操作。我们也需要有机制继续监听该已连接套接字,并在有数据达到时通知 Redis。这样才能保证 Redis 线程,既不会像基本 IO 模型中一直在阻塞点等待,也不会导致 Redis 无法处理实际到达的连接请求或数据。到此,Linux 中的 IO 多路复用机制就要登场了。
Linux 中的 IO 多路复用机制是指一个线程处理多个 IO 流,就是我们经常听到的 select/epoll 机制。简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听套接字和已连接套接字。内核会一直监听这些套接字上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。

下图就是基于多路复用的 Redis IO 模型。图中的多个 FD(file descriptor) 就是刚才所说的多个套接字。Redis 网络框架调用 epoll 机制,让内核监听这些套接字。此时,Redis 线程不会阻塞在某一个特定的监听或已连接套接字上,也就是说,不会阻塞在某一个特定的客户端请求处理上。正因为此,Redis 可以同时和多个客户端连接并处理请求,从而提升并发性。

为了在请求到达时能通知到 Redis 线程,select/epoll 提供了基于事件的回调机制,即针对不同事件的发生,调用相应的处理函数。那么,回调机制是怎么工作的呢?其实,服务器的accept操作和对各个客户端都对应一个FD,而每一个FD都会绑定一个对应的文件事件处理器,select/epoll 一旦监测到 FD 上有请求到达时,就会触发相应的事件。这些事件会被放进一个事件队列,Redis 单线程对该事件队列不断进行处理。这样一来,Redis 无需一直轮询是否有请求实际发生,这就可以避免造成 CPU 资源浪费。同时,Redis 在对事件队列中的事件进行处理时,会调用相应的处理函数,这就实现了基于事件的回调。因为 Redis 一直在对事件队列进行处理,所以能及时响应客户端请求,提升 Redis 的响应性能。

可以看下具体的相关函数介绍:

  • int socket(int domain,int type,int protocol)

    domain(协议族):常用的协议族便是IPV4(PF_INET), IPV6(PF_INET6),本地通信协议的UNIX族(PF_LOCAL);
    type:数据传输类型;典型数据传输类型:SOCK_DGRAM(数据报套接字/无连接的套接字),SOCK_RAW,SOCK_SEQPACKET,SOCK_STREAM(流格式套接字/面向连接的套接字);
    protocal:具体协议,通常为0,表示按给定的域或套接字类型选择默认协议。当对同一域和套接字类型支持多个协议时,可以使用protocol参数选择一个特定协议,常用的有 IPPROTO_TCP 和 IPPTOTO_UDP,分别表示 TCP 传输协议和 UDP 传输协议。

    作用:socket() 函数用来创建套接字,返回值就是一个 int 类型的文件描述符。

  • int bind(int sock, struct sockaddr *addr, socklen_t addrlen)

    int connect(int sock, struct sockaddr *serv_addr, socklen_t addrlen)

    sock 为 socket 文件描述符,addr 为 sockaddr 结构体变量的指针,addrlen 为 addr 变量的大小,可由 sizeof() 计算得出。

    作用:服务端用 bind() 函数将套接字与特定的 IP 地址和端口绑定起来,只有这样,流经该 IP 地址和端口的数据才能交给套接字处理。类似地,客户端也要用 connect() 函数建立连接。

  • int listen(int sock, int backlog)

    sock 为需要进入监听状态的套接字,backlog 为请求队列的最大长度。

    请求队列:当套接字正在处理客户端请求时,如果有新的请求进来,套接字是没法处理的,只能把它放进缓冲区,待当前请求处理完毕后,再从缓冲区中读取出来处理。如果不断有新的请求进来,它们就按照先后顺序在缓冲区中排队,直到缓冲区满。这个缓冲区,就称为请求队列(Request Queue)。如果将 backlog 的值设置为 SOMAXCONN,就由系统来决定请求队列长度,这个值一般比较大,可能是几百,或者更多。当请求队列满时,就不再接收新的请求,对于 Linux,客户端会收到 ECONNREFUSED 错误,对于 Windows,客户端会收到 WSAECONNREFUSED 错误。

    作用:对于服务器端程序,使用 bind() 绑定套接字后,还需要使用 listen() 函数让套接字进入被动监听状态。所谓被动监听,是指当没有客户端请求时,套接字处于“睡眠”状态,只有当接收到客户端请求时,套接字才会被“唤醒”来响应请求。

  • int accept(int sock, struct sockaddr *addr, socklen_t *addrlen)

    sock 为服务器端套接字,addr 为 sockaddr_in 结构体变量,addrlen 为参数 addr 的长度,可由 sizeof() 求得。

    作用:accept() 返回一个新的套接字来和客户端通信,addr 保存了客户端的IP地址和端口号,而 sock 是服务器端的套接字,要注意区分。后面和客户端通信时,要使用这个新生成的套接字,而不是原来服务器端的套接字。

  • 1)linux下数据的接收发送:

    ssize_t read(int fd, void *buf, size_t nbytes)

    ssize_t write(int fd, const void *buf, size_t nbytes)

    fd 为要读取/写入的文件的描述符,buf 为要读取/写入的数据的缓冲区地址,nbytes 为要读取/写入的数据的字节数。

    作用:read() 函数会从 fd 文件中读取 nbytes 个字节并保存到缓冲区 buf,成功则返回读取到的字节数(但遇到文件结尾则返回0),失败则返回 -1;write() 函数会将缓冲区 buf 中的 nbytes 个字节写入文件 fd,成功则返回写入的字节数,失败则返回 -1。

    2)win下数据的接收发送:

    int recv(SOCKET sock, char *buf, int len, int flags)

    int send(SOCKET sock, const char *buf, int len, int flags)

    sock 为要接收/发送数据的套接字,buf 为要接收/发送的数据的缓冲区地址,len 为要接收/发送的数据的字节数,flags 为发送数据时的选项,一般设置为 0 或 NULL。

事件的类型

IO多路复用程序可以监听多个套接字的ae.h/AE_READABLE事件和ae.h/AE_WRITABLE事件,这两类事件和套接字操作之间的对应关系如下:

  • 当套接字变得可读时(客户端对套接字执行write操作,或者执行close操作),或者有新的可应答( acceptable)套接字出现时(客户端对服务器的监听套接字执行connect操作),套接字产生AE_READABLE事件。
  • 当套接字变得可写时(客户端对套接字执行read操作),套接字产生AE_WRITABLE事件。

I/O多路复用程序允许服务器同时监听套接字的AE_READABLE事件和 AE_WRITABLE事件,如果一个套接字同时产生了这两种事件,那么文件事件分派器会优先处理AE_READABLE事件,等到AE_READABLE事件处理完之后,才处理AE_WRITABLE事件。

还有一种事件:AE_BARRIER,即先读后写。

结构体

1
2
3
4
5
6
typedef struct aeFileEvent {
int mask; /* one of AE_(READABLE|WRITABLE|BARRIER) */
aeFileProc *rfileProc; //读处理器
aeFileProc *wfileProc; //写处理器
void *clientData;
} aeFileEvent;

文件事件的处理器

  1. 连接应答处理器。networking.c/acceptTcpHandler函数是Redis 的连接应答处理器,这个处理器用于对连接服务器监听套接字的客户端进行应答,具体实现为sys/socket.h/accept函数的包装。

    当Redis服务器进行初始化的时候,程序会将这个连接应答处理器和服务器监听套接字的AE_READABLE事件关联起来当有客户端用sys/socket.h/connect函数连接服务器监听套接字的时候,套接字就会产生AE_READABLE事件,引发连接应答处理器执行,并执行相应的套接字应答操作。

    看下initServer函数,redis在将监听socket初始化完毕之后,会将他们添加到事件循环中:

    1
    2
    3
    4
    5
    6
    7
    8
    for (j = 0; j < server.ipfd_count; j++) {
    if (aeCreateFileEvent(server.el, server.ipfd[j], AE_READABLE,
    acceptTcpHandler,NULL) == AE_ERR)
    {
    serverPanic(
    "Unrecoverable error creating server.ipfd file event.");
    }
    }

    每一个事件对应一个acceptTcpHandler:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    void acceptTcpHandler(aeEventLoop *el, int fd, void *privdata, int mask) {
    int cport, cfd, max = MAX_ACCEPTS_PER_CALL;
    char cip[REDIS_IP_STR_LEN];
    REDIS_NOTUSED(el);
    REDIS_NOTUSED(mask);
    REDIS_NOTUSED(privdata);

    //最多迭代MAX_ACCEPTS_PER_CALL(1000)次,也就是说每次事件循环最多可以处理1000个客户端的连接
    while(max--) {
    //函数anetTcpAccept用于accept客户端的连接,其返回值是客户端对应的socket
    cfd = anetTcpAccept(server.neterr, fd, cip, sizeof(cip), &cport);
    if (cfd == ANET_ERR) {
    if (errno != EWOULDBLOCK)
    redisLog(REDIS_WARNING,
    "Accepting client connection: %s", server.neterr);
    return;
    }
    redisLog(REDIS_VERBOSE,"Accepted %s:%d", cip, cport);
    //对连接以及客户端进行初始化
    acceptCommonHandler(cfd,0);
    }
    }

    static void acceptCommonHandler(int fd, int flags, char *ip) {
    client *c;
    //调用createClient初始化客户端相关数据结构以及对应的socket
    if ((c = createClient(fd)) == NULL) {
    redisLog(REDIS_WARNING,
    "Error registering fd event for the new client: %s (fd=%d)",
    strerror(errno),fd);
    close(fd); /* May be already closed, just ignore errors */
    return;
    }
    /* If maxclient directive is set and this is one client more... close the
    * connection. Note that we create the client instead to check before
    * for this condition, since now the socket is already set in non-blocking
    * mode and we can send an error for free using the Kernel I/O */
    //判断当前连接的客户端是否超过最大值,如果超过的话,会拒绝这次连接。否则,更新客户端连接数的计数。
    if (listLength(server.clients) > server.maxclients) {
    char *err = "-ERR max number of clients reached\r\n";

    /* That's a best effort error message, don't check write errors */
    if (write(c->fd,err,strlen(err)) == -1) {
    /* Nothing to do, Just to avoid the warning... */
    }
    server.stat_rejected_conn++;
    freeClient(c);
    return;
    }
    server.stat_numconnections++;
    c->flags |= flags;
    }

    client *createClient(int fd) {
    //创建redisClient
    client *c = zmalloc(sizeof(client));
    //设置socket的属性
    if (fd != -1) {
    anetNonBlock(NULL,fd);
    anetEnableTcpNoDelay(NULL,fd);
    if (server.tcpkeepalive)
    anetKeepAlive(NULL,fd,server.tcpkeepalive);
    //创建该client的事件,是一个READABLE事件,对应的处理函数就是readQueryFromClient。具体的函数介绍可以看后面的事件创建部分的源码
    if (aeCreateFileEvent(server.el,fd,AE_READABLE,
    , c) == AE_ERR)
    {
    close(fd);
    zfree(c);
    return NULL;
    }
    }
    ...
    }

    可以看到acceptTcpHandler会将给定套接字的事件加人到I/O多路复用程序的监听范围之内,并对事件和事件处理器进行关联。

  2. 命令请求处理器。networking.c /readQueryFromclient函数是Redis的命令请求处理器,这个处理器负责从套接字中读入客户端发送的命令请求内容,具体实现为unistd.h/ read函数的包装。
    当一个客户端通过连接应答处理器成功连接到服务器之后,服务器会将客户端套接字的AE_READABLE事件和命令请求处理器关联起来,当客户端向服务器发送命令请求的时候,套接字就会产生AE_READABLE事件,引发命令请求处理器执行,并执行相应的套接字读入操作。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    void readQueryFromClient(aeEventLoop *el, int fd, void *privdata, int mask) {
    //设置当前服务的client,然后是设置这次从socket读取的数据的默认大小(REDIS_IOBUF_LEN为16KB)
    client *c = (client*) privdata;
    int nread, readlen;
    size_t qblen;
    UNUSED(el);
    UNUSED(mask);

    readlen = PROTO_IOBUF_LEN;
    /* If this is a multi bulk request, and we are processing a bulk reply
    * that is large enough, try to maximize the probability that the query
    * buffer contains exactly the SDS string representing the object, even
    * at the risk of requiring more read(2) calls. This way the function
    * processMultiBulkBuffer() can avoid copying buffers to create the
    * Redis Object representing the argument. */
    //这段代码重新设置读取数据的大小,避免频繁拷贝数据
    //如果当前请求是一个multi bulk类型的,并且要处理的bulk的大小大于REDIS_MBULK_BIG_ARG(32KB),则将读取数据大小设置为该bulk剩余数据的大小。
    if (c->reqtype == PROTO_REQ_MULTIBULK && c->multibulklen && c->bulklen != -1
    && c->bulklen >= PROTO_MBULK_BIG_ARG)
    {
    ssize_t remaining = (size_t)(c->bulklen+2)-sdslen(c->querybuf);

    /* Note that the 'remaining' variable may be zero in some edge case,
    * for example once we resume a blocked client after CLIENT PAUSE. */
    if (remaining > 0 && remaining < readlen) readlen = remaining;
    }

    //读取的请求内容会存储到redisClient->querybuf中
    //此处代码调整querybuf大小以便容纳这次read的数据
    qblen = sdslen(c->querybuf);
    if (c->querybuf_peak < qblen) c->querybuf_peak = qblen;
    //可能存在一次copy,如果buffer的空闲空间小于readlen,则buffer大小翻倍,并将数据拷贝到新buffer
    c->querybuf = sdsMakeRoomFor(c->querybuf, readlen);
    //调用read系统调用,读取readlen大小的数据,并存储到querybuf中
    nread = read(fd, c->querybuf+qblen, readlen);
    //校验read的返回值,检测出错。如果read返回0,则客户端关闭连接,会释放掉该客户端。
    if (nread == -1) {
    //EAGAIN表示read不到数据
    if (errno == EAGAIN) {
    return;
    } else {
    serverLog(LL_VERBOSE, "Reading from client: %s",strerror(errno));
    freeClient(c);
    return;
    }
    } else if (nread == 0) {
    serverLog(LL_VERBOSE, "Client closed connection");
    freeClient(c);
    return;
    } else if (c->flags & CLIENT_MASTER) {
    /* Append the query buffer to the pending (not applied) buffer
    * of the master. We'll use this buffer later in order to have a
    * copy of the string applied by the last command executed. */
    c->pending_querybuf = sdscatlen(c->pending_querybuf,
    c->querybuf+qblen,nread);
    }

    sdsIncrLen(c->querybuf,nread);
    c->lastinteraction = server.unixtime;
    if (c->flags & CLIENT_MASTER) c->read_reploff += nread;
    server.stat_net_input_bytes += nread;
    //判断客户端的请求buffer是否超过配置的值server.client_max_querybuf_len(1GB),如果超过,会拒绝服务,并关闭该客户端。
    if (sdslen(c->querybuf) > server.client_max_querybuf_len) {
    sds ci = catClientInfoString(sdsempty(),c), bytes = sdsempty();

    bytes = sdscatrepr(bytes,c->querybuf,64);
    serverLog(LL_WARNING,"Closing client that reached max query buffer length: %s (qbuf initial bytes: %s)", ci, bytes);
    sdsfree(ci);
    sdsfree(bytes);
    freeClient(c);
    return;
    }

    /* Time to process the buffer. If the client is a master we need to
    * compute the difference between the applied offset before and after
    * processing the buffer, to understand how much of the replication stream
    * was actually applied to the master state: this quantity, and its
    * corresponding part of the replication stream, will be propagated to
    * the sub-slaves and to the replication backlog. */
    // 最后,会调用processInputBuffer函数解析请求。
    processInputBufferAndReplicate(c);
    }
  3. 命令回复处理器。networking.c/sendReplyToclient函数是Redis 的命令回复处理器,这个处理器负责将服务器执行命令后得到的命令回复通过套接字返回给客户端,具体实现为unistd.h/write函数的包装,主要的逻辑是writeToClient 函数。
    当服务器有命令回复需要传送给客户端的时候,服务器会将客户端套接字的AE_WRITABLE事件和命令回复处理器关联起来,当客户端准备好接收服务器传回的命令回复时,就会产生AE_WRITABLE事件,引发命令回复处理器执行,并执行相应的套接字写入操作。

  4. 集群中使用的处理器。①clusterAcceptHandler。监听其它节点发来的建链请求,并创建对应套接字的可读事件clusterReadHandler;②clusterReadHandler 。读取集群中其它节点发送的消息并进行处理;

事件创建和监听事件

这方面的阅读最好结合event loop的介绍和源码

ae.c#aeCreateFileEvent函数接受一个套接字描述符、一个事件类型,以及一个事件处理器作为参数,将给定套接字的给定事件加人到I/O多路复用程序的监听范围之内,并对事件和事件处理器进行关联。这个函数的调用可以看下面的acceptTcpHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/*
* 获取File Event,存在events中
*/
int aeCreateFileEvent(aeEventLoop *eventLoop, int fd, int mask,
aeFileProc *proc, void *clientData)
{
// 因为events数组的最大大小就是setsize的,所以传进来的fd不能超过setsize,否则会events数组越界
if (fd >= eventLoop->setsize) {
errno = ERANGE;
return AE_ERR;
}
// 获取对应fd的文件事件,在创建eventLoop时已经创建了setsize个fileEvent,因此直接获取就可以了
aeFileEvent *fe = &eventLoop->events[fd];
// 往底层的poll增加文件事件
if (aeApiAddEvent(eventLoop, fd, mask) == -1)
return AE_ERR;、
// 给该文件事件添加(或维持原样)mask
fe->mask |= mask;
// 对R/W回调函数根据mask来赋值
if (mask & AE_READABLE) fe->rfileProc = proc;
if (mask & AE_WRITABLE) fe->wfileProc = proc;
fe->clientData = clientData;
// 更新事件中心最大fd
if (fd > eventLoop->maxfd)
eventLoop->maxfd = fd;
return AE_OK;
}

注意一下aeApiAddEvent函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static int aeApiAddEvent(aeEventLoop *eventLoop, int fd, int mask) {
aeApiState *state = eventLoop->apidata;
struct epoll_event ee;
/* If the fd was already monitored for some event, we need a MOD
* operation. Otherwise we need an ADD operation. */
int op = eventLoop->events[fd].mask == AE_NONE ?
EPOLL_CTL_ADD : EPOLL_CTL_MOD;

ee.events = 0;
mask |= eventLoop->events[fd].mask; /* Merge old events */
if (mask & AE_READABLE) ee.events |= EPOLLIN;
if (mask & AE_WRITABLE) ee.events |= EPOLLOUT;
ee.data.u64 = 0; /* avoid valgrind warning */
ee.data.fd = fd;
//向epoll中添加事件
if (epoll_ctl(state->epfd,op,fd,&ee) == -1) return -1;
return 0;
}

struct epll_event用于指定要监听的事件,以及该文件描述符绑定的data,在事件触发时可以返回。这里将data直接存为fd,通过这个数据,便可以找到对应的事件,然后调用其处理函数。这个epoll_ctl函数很关键,是向 epoll对象中添加、修改或者删除感兴趣的事件:

1
int epoll_ctl(int epfd , int op , int fd , struct epoll_event * event );
  • epfd:就是指定epoll文件描述符。
  • op : 需要执行的操作,添加,修改,删除。EPOLL_CTL_ADD:在epoll的监视列表中添加一个文件描述符(即参数fd),指定监视的事件类型(参数event);EPOLL_CTL_MOD:修改监视列表中已经存在的描述符(即参数fd),修改其监视的事件类型(参数event); EPOLL_CTL_DEL:将某监视列表中已经存在的描述符(即参数fd)删除,参数event传NULL。
  • fd:需要添加,修改,删除的套接字。
  • event:需要epoll监视的时间类型。

ae_select.c、ae_epoll.c、ae_kqueue.c、ae_evport中都有aeApiPoll函数,接受一个sys/time.h/struct timeval结构为参数,并在指定的时间内,阻塞并等待所有被aeCreateFileEvent函数设置为监听状态的套接字产生文件事件,当有至少一个事件产生,或者等待超时后,函数返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/*
* 获取File Event,存在fired中
*/
static int aeApiPoll(aeEventLoop *eventLoop, struct timeval *tvp) {
aeApiState *state = eventLoop->apidata;
int retval, numevents = 0;

// 阻塞等待,将触发的事件存放到state->events数组中,retval就是事件的个数,也是数组的大小
retval = epoll_wait(state->epfd,state->events,eventLoop->setsize,
tvp ? (tvp->tv_sec*1000 + tvp->tv_usec/1000) : -1);

// 有至少一个事件就绪?
if (retval > 0) {
int j;
// 为已就绪事件设置相应的模式
// 并加入到 eventLoop 的 fired 数组中
numevents = retval;
for (j = 0; j < numevents; j++) {
int mask = 0;
struct epoll_event *e = state->events+j;

if (e->events & EPOLLIN) mask |= AE_READABLE;
if (e->events & EPOLLOUT) mask |= AE_WRITABLE;
if (e->events & EPOLLERR) mask |= AE_WRITABLE;
if (e->events & EPOLLHUP) mask |= AE_WRITABLE;

eventLoop->fired[j].fd = e->data.fd;
eventLoop->fired[j].mask = mask;
}
}
// 返回已就绪事件个数
return numevents;
}

时间监听的函数调用发生在ae.c/aeProcessEvents函数中,其是文件事件分派器,它先调用aeApiPoll函数来等待事件产生,然后遍历所有已产生的事件,并调用相应的事件处理器来处理这些事件。(看前文)

完整流程示例

让我们来追踪一次Redis客户端与服务器进行连接并发送命令的整个过程,看看在过程中会产生什么事件,而这些事件又是如何被处理的。

假设一个Redis服务器正在运作,那么这个服务器的监听套接字的AE_READABLE事件应该正处于监听状态之下,而该事件所对应的处理器为连接应答处理器。

如果这时有一个Redis客户端向服务器发起连接,那么监听套接字将产生AE_READABLE事件,触发连接应答处理器执行。处理器会对客户端的连接请求进行应答,然后创建客户端套接字,以及客户端状态,并将客户端套接字的AE_READABLE事件与命令请求处理器进行关联,使得客户端可以向主服务器发送命令请求。

之后,假设客户端向主服务器发送一个命令请求,那么客户端套接字将产生AE_READABLE事件,引发命令请求处理器执行,处理器读取客户端的命令内容,然后传给相关程序去执行。

执行命令将产生相应的命令回复,为了将这些命令回复传送回客户端,服务器会将客户端套接字的AE_WRITABLE事件与命令回复处理器进行关联。当客户端尝试读取命令回复的时候,客户端套接字将产生AE_WRITABLE事件,触发命令回复处理器执行,当命令回复处理器将命令回复全部写人到套接字之后,服务器就会解除客户端套接字的AE_WRITABLE事件与命令回复处理器之间的关联。

Time Event

Redis的时间事件分为两类:

  1. 定时事件:让一段程序在指定的时间之后执行一次。
  2. 周期性事件:让一段程序每隔指定的时间就执行一次。(比如serverCron函数,每秒执行次数为server.hz)

一个时间事件是定时事件还是周期性事件取决于时间事件处理器的返回值:

  1. 如果事件处理器返回ae.h/AE_NOMORE,那么这个事件为定时事件:该事件在达到一次之后就会被删除,之后不再到达。
  2. 如果事件处理器返回一个非AE_NOMORE的整数值,那么这个事件为周期性时间:当一个时间事件到达之后,服务器会根据事件处理器返回的值,对时间事件的when属性进行更新,让这个事件在一段时间之后再次到达,并以这种方式一直更新并运行下去。比如说,如果一个时间事件的处理器返回整数值30,那么服务器应该对这个时间事件进行更新,让这个事件在30毫秒之后再次到达。

目前版本的Redis只使用周期性事件,而没有使用定时事件。(serverCron)

结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef struct aeTimeEvent {
// 时间事件的唯一标识符
long long id; /* time event identifier. */
// 事件的到达时间,即执行的时间
long when_sec; /* seconds */
long when_ms; /* milliseconds */
// 事件处理函数
aeTimeProc *timeProc;
// 事件释放函数
aeEventFinalizerProc *finalizerProc;
// 多路复用库的私有数据
void *clientData;
// 指向下个时间事件结构,形成链表
struct aeTimeEvent *next;
} aeTimeEvent;

实现

服务器将所有时间事件都放在一个无序链表中(timeEventHead),无序链表指的不是链表不按ID排序,而是说,该链表不按when属性的大小排序。正因为链表没有按when属性进行排序,所以当时间事件执行器运行的时候,它必须遍历链表中的所有时间事件,这样才能确保服务器中所有已到达的时间事件都会被处理。

上图链表中包含了三个不同的时间事件:因为新的时间事件总是插入到链表的表头,所以三个时间事件分别按ID逆序排序,表头事件的ID为3,中间事件的ID为2,表尾事件的ID为1。

serverCron

持续运行的Redis服务器需要定期对自身的资源和状态进行检查和调整,从而确保服务器可以长期、稳定地运行,这些定期操作由redis.c/serverCron函数负责执行,它的主要工作包括:

  1. 更新服务器的各类统计信息,比如LRU全局时间戳、内存占用、数据库占用情况等,并清理数据库中的过期键值对;
  2. 关闭和清理连接失效的客户端;
  3. 尝试进行AOF或RDB持久化操作,以及AOF的重写操作。每一种操作都有两种判断方式,具体的看持久化的源码分析部分。
  4. 如果服务器是主服务器,那么对从服务器进行定期同步。如果处于集群模式,对集群进行定期同步和连接测试;
  5. Redis服务器以周期性事件的方式来运行serverCron函数,在服务器运行期间,每隔一段时间,servercron就会执行一次,直到服务器关闭为止。

在Redis2.6版本,服务器默认规定servercron每秒运行10次,平均每间隔100毫秒运行一次。

从Redis2.8开始,redis有一个主频的概念(server.hz),用户可以通过修改hz选项来调整servercron的每秒执行次数,它表示的其实就是1秒钟这个serverCron会被调用多少次。默认server.hz = 10,也就是1秒钟调用10次,也就是100ms调用一次。

由于redis几乎所有需要定时处理的任务都是在serverCron里面驱动的,所以理论上,你可以通过调整这个主频,来改善性能。

1
#define run_with_period(_ms_) if ((_ms_ <= 1000/server.hz) || !(server.cronloops%((_ms_)/(1000/server.hz))))

service.c#initServer函数会注册一个1s执行一次的time event:

1
2
3
4
5
6
7
/* Create the timer callback, this is our way to process many background
* operations incrementally, like clients timeout, eviction of unaccessed
* expired keys and so forth. */
if (aeCreateTimeEvent(server.el, 1, serverCron, NULL, NULL) == AE_ERR) {
serverPanic("Can't create event loop timers.");
exit(1);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
int serverCron(struct aeEventLoop *eventLoop, long long id, void *clientData) {
int j;
UNUSED(eventLoop);
UNUSED(id);
UNUSED(clientData);

/* Software watchdog: deliver the SIGALRM that will reach the signal
* handler if we don't return here fast enough. */
//软件看门狗,如果没有快速的返回,就会收到 SIGTERM 信号,从而判断关闭服务器
//Watchdog 是 Linux 系统一个很重要的机制,其目的是监测系统运行的情况,一旦出现锁死,死机的情况,能及时重启机器(取决于设置策略)
if (server.watchdog_period) watchdogScheduleSignal(server.watchdog_period);

/*
* 更新服务器时间缓存:Redis服务器中有不少功能需要获取系统的当前时间,而每次获取系统的当前时间都需要执行一次系统调用,
* 为了减少系统调用的执行次数,服务器状态中的unixtime属性(秒)、ustime属性(微秒)、mstime属性(毫秒)被用作当前时间的缓存。
* 因为serverCron函数默认会以大概每100毫秒一次的频率更新unixtime属性、ustune属性、mstime属性,所以这三个属性记录的时间的精确度并不高:
* 服务器只会在打印日志、命令开始执行时间、决定是否执行持久化任务、计算服务器上线时间这类对时间精确度要求不高的功能上
* 使用unixtime、ustime、mstime属性。对于设置全局lru时钟值、为键设置过期时间、添加慢查询日志这种需要高精确度时间的功能来说,
* 服务器还是会再次执行系统调用,从而获得最准确的系统当前时间。
*/
updateCachedTime(1);

//读取配置文件的hz,hz就是主频,主要用于调整serverCron的每秒执行次数
server.hz = server.config_hz;
//根据配置的客户端数量调整 server.hz 值。如果我们有很多客户端,我们希望以更高的频率调用 serverCron()
if (server.dynamic_hz) {
while (listLength(server.clients) / server.hz >
MAX_CLIENTS_PER_CLOCK_TICK)
{
server.hz *= 2;
if (server.hz > CONFIG_MAX_HZ) {
server.hz = CONFIG_MAX_HZ;
break;
}
}
}

/*
* 限制该代码块的执行最小周期,是100ms
* 比如`serverCron`每秒钟执行10次,代表100ms执行一次,则每次执行serverCron方法里面的代码块都会执行
* 比如`serverCron`每秒钟执行20次,代表50ms执行一次,则每两次执行serverCron方法里面的代码才会执行一次
* 这里每100ms一次的频率采样,统计时间段内服务器请求数、流量input、output等信息
*/
run_with_period(100) {
trackInstantaneousMetric(STATS_METRIC_COMMAND,server.stat_numcommands);
trackInstantaneousMetric(STATS_METRIC_NET_INPUT,
server.stat_net_input_bytes);
trackInstantaneousMetric(STATS_METRIC_NET_OUTPUT,
server.stat_net_output_bytes);
}


//更新全局的LRU时间戳,具体使用可以看LRU的源码解析部分
unsigned long lruclock = getLRUClock();
atomicSet(server.lruclock,lruclock);

/* Record the max memory used since the server was started. */
// 记录服务器的内存峰值
//stat_peak_memory属性记录了服务器的内存峰值大小,如果当前使用的内存数量比stat_peak_memory属性记录的值要大,
//那么程序就将当前使用的内存数量记录到stat_peak_memory属性里面。
if (zmalloc_used_memory() > server.stat_peak_memory)
server.stat_peak_memory = zmalloc_used_memory();

run_with_period(100) {
/* Sample the RSS and other metrics here since this is a relatively slow call.
* We must sample the zmalloc_used at the same time we take the rss, otherwise
* the frag ratio calculate may be off (ratio of two samples at different times) */
server.cron_malloc_stats.process_rss = zmalloc_get_rss();
server.cron_malloc_stats.zmalloc_used = zmalloc_used_memory();
/* Sampling the allcator info can be slow too.
* The fragmentation ratio it'll show is potentically more accurate
* it excludes other RSS pages such as: shared libraries, LUA and other non-zmalloc
* allocations, and allocator reserved pages that can be pursed (all not actual frag) */
zmalloc_get_allocator_info(&server.cron_malloc_stats.allocator_allocated,
&server.cron_malloc_stats.allocator_active,
&server.cron_malloc_stats.allocator_resident);
/* in case the allocator isn't providing these stats, fake them so that
* fragmention info still shows some (inaccurate metrics) */
if (!server.cron_malloc_stats.allocator_resident) {
/* LUA memory isn't part of zmalloc_used, but it is part of the process RSS,
* so we must desuct it in order to be able to calculate correct
* "allocator fragmentation" ratio */
size_t lua_memory = lua_gc(server.lua,LUA_GCCOUNT,0)*1024LL;
server.cron_malloc_stats.allocator_resident = server.cron_malloc_stats.process_rss - lua_memory;
}
if (!server.cron_malloc_stats.allocator_active)
server.cron_malloc_stats.allocator_active = server.cron_malloc_stats.allocator_resident;
if (!server.cron_malloc_stats.allocator_allocated)
server.cron_malloc_stats.allocator_allocated = server.cron_malloc_stats.zmalloc_used;
}

/* We received a SIGTERM, shutting down here in a safe way, as it is
* not ok doing so inside the signal handler. */
/*
* 服务器进程收到 SIGTERM 信号,关闭服务器:
* 在服务器初始化的时候会调用setupSignalHandlers 设置信号关联处理函数,
* 设置SIGTERM信号关联的处理函数是sigShutdownHandler,sigShutdownHandler会设置服务状态shutdown_asap标识为1;
*/
if (server.shutdown_asap) {
//尝试关闭
if (prepareForShutdown(SHUTDOWN_NOFLAGS) == C_OK) exit(0);
// 如果关闭失败,那么打印 LOG ,并移除关闭标识
serverLog(LL_WARNING,"SIGTERM received but errors trying to shut down the server, check the logs for more information");
server.shutdown_asap = 0;
}

/* Show some info about non-empty databases */
// 记录数据库的键值对信息
run_with_period(5000) {
for (j = 0; j < server.dbnum; j++) {
long long size, used, vkeys;

// 可用键值对的数量
size = dictSlots(server.db[j].dict);
// 已用键值对的数量
used = dictSize(server.db[j].dict);
// 带有过期时间的键值对数量
vkeys = dictSize(server.db[j].expires);
// 记录相关信息
if (used || vkeys) {
serverLog(LL_VERBOSE,"DB %d: %lld keys (%lld volatile) in %lld slots HT.",j,used,vkeys,size);
/* dictPrintStats(server.dict); */
}
}
}

/* Show information about connected clients */
// 如果服务器没有运行在 SENTINEL 模式下,那么打印客户端的连接信息
if (!server.sentinel_mode) {
run_with_period(5000) {
serverLog(LL_VERBOSE,
"%lu clients connected (%lu replicas), %zu bytes in use",
listLength(server.clients)-listLength(server.slaves),
listLength(server.slaves),
zmalloc_used_memory());
}
}

/*
* clientsCron会做以下事情:
* 1. 检查客户端与服务器之间连接是否超时(长时间没有和服务端互动),如果长时间没有互动,那么释放这个客户端;
* 2. 客户端输入缓冲区是否超过一定限制,如果超过限制,那么释放输入缓冲区,并创建一个默认大小的缓冲区,防止占用内存过多;
* 3. 跟踪最近几秒钟内使用最大内存量的客户端。 这样可以给info命令提供相关信息,从而避免O(n)遍历client列表,sentinel就是通过info命令获取主从服务器信息的。
*/
clientsCron();

/* Handle background operations on Redis databases. */
// 对数据库执行各种操作,比如:删除过期键、对字典进行rehash
databasesCron();


/*
* AOF重写在serverCron中第一个触发点:
* 需要确认当前没有aof rewrite和rdb dump在进行,并且设置了aof_rewrite_scheduled(在bgrewriteaofCommand里设置的),
* 调用rewirteAppendOnlyFileBackground进行aof rewrite。
*/
if (server.rdb_child_pid == -1 && server.aof_child_pid == -1 &&
server.aof_rewrite_scheduled)
{
rewriteAppendOnlyFileBackground();
}

// 检测bgsave、aof重写是否在执行过程中,或者是否有子线程
if (server.rdb_child_pid != -1 || server.aof_child_pid != -1 ||
ldbPendingChildren())
{
int statloc;
pid_t pid;

//等待所有的子进程
if ((pid = wait3(&statloc,WNOHANG,NULL)) != 0) {
//取得子进程exit()返回的结束代码
int exitcode = WEXITSTATUS(statloc);
int bysignal = 0;

//如果子进程是因为信号而结束则此宏值为真
if (WIFSIGNALED(statloc)) bysignal = WTERMSIG(statloc);

if (pid == -1) {
//如果此时没有子线程
serverLog(LL_WARNING,"wait3() returned an error: %s. "
"rdb_child_pid = %d, aof_child_pid = %d",
strerror(errno),
(int) saeCreateEventLooperver.rdb_child_pid,
(int) server.aof_child_pid);
} else if (pid == server.rdb_child_pid) {
//如果已经完成了bgsave,会调用backgroundSaveDoneHandler函数做最后处理
backgroundSaveDoneHandler(exitcode,bysignal);
if (!bysignal && exitcode == 0) receiveChildInfo();
} else if (pid == server.aof_child_pid) {
//如果已经完成了aof重写,会调用backgroundRewriteDoneHandler函数做最后处理
backgroundRewriteDoneHandler(exitcode,bysignal);
if (!bysignal && exitcode == 0) receiveChildInfo();
} else {
if (!ldbRemoveChild(pid)) {
serverLog(LL_WARNING,
"Warning, detected child with unmatched pid: %ld",
(long)pid);
}
}
updateDictResizePolicy();
closeChildInfoPipe();
}
} else {
/*
* 如果此时没有子线程在进行save或rewrite,则判断是否要save或rewrite
*/

//先判断是否要进行save
/*
* RDB的持久化在serverCron中第一个触发点,根据条件判断定期触发:
*/
for (j = 0; j < server.saveparamslen; j++) {
//每一个saveparam表示一个配置文件中的save
struct saveparam *sp = server.saveparams+j;

/* Save if we reached the given amount of changes,
* the given amount of seconds, and if the latest bgsave was
* successful or if, in case of an error, at least
* CONFIG_BGSAVE_RETRY_DELAY seconds already elapsed. */
//判断修改次数和时间
if (server.dirty >= sp->changes &&
server.unixtime-server.lastsave > sp->seconds &&
(server.unixtime-server.lastbgsave_try >
CONFIG_BGSAVE_RETRY_DELAY ||
server.lastbgsave_status == C_OK))
{
serverLog(LL_NOTICE,"%d changes in %d seconds. Saving...",
sp->changes, (int)sp->seconds);
rdbSaveInfo rsi, *rsiptr;
rsiptr = rdbPopulateSaveInfo(&rsi);
//调用rdbSaveBackground进行save
rdbSaveBackground(server.rdb_filename,rsiptr);
break;
}
}

/*
* AOF重写在serverCron中第二个触发点:
* 判断aof文件的大小超过预定的百分比,
* 当aof文件超过了预定的最小值,并且超过了上一次aof文件的一定百分比,则会触发aof rewrite。
*/
if (server.aof_state == AOF_ON &&
server.rdb_child_pid == -1 &&
server.aof_child_pid == -1 &&
server.aof_rewrite_perc &&
server.aof_current_size > server.aof_rewrite_min_size)
{
long long base = server.aof_rewrite_base_size ?
server.aof_rewrite_base_size : 1;
long long growth = (server.aof_current_size*100/base) - 100;
if (growth >= server.aof_rewrite_perc) {
serverLog(LL_NOTICE,"Starting automatic rewriting of AOF on %lld%% growth",growth);
rewriteAppendOnlyFileBackground();
}
}
}


/* AOF postponed flush: Try at every cron cycle if the slow fsync
* completed. */
//进行AOF buffer的刷盘:
// 在aof_flush_postponed_start不为0时调用,即存在延迟flush的情况。
// 主要是保证fsync完成之后,可以快速的进入下一次flush。
// 尽量保证fsync策略是everysec时,每秒都可以进行fsync,同时缩短两次fsync的间隔,减少影响。
if (server.aof_flush_postponed_start) flushAppendOnlyFile(0);

/* AOF write errors: in this case we have a buffer to flush as well and
* clear the AOF error in case of success to make the DB writable again,
* however to try every second is enough in case of 'hz' is set to
* an higher frequency. */
//进行AOF buffer的刷盘:保证aof出错时,尽快执行下一次flush,以便从错误恢复。
run_with_period(1000) {
if (server.aof_last_write_status == C_ERR)
flushAppendOnlyFile(0);
}

/* Close clients that need to be closed asynchronous */
freeClientsInAsyncFreeQueue();

/* Clear the paused clients flag if needed. */
clientsArePaused(); /* Don't check return value, just use the side effect.*/

/* Replication cron function -- used to reconnect to master,
* detect transfer failures, start background RDB transfers and so forth. */
run_with_period(1000) replicationCron();

/* Run the Redis Cluster cron. */
//如果使用了集群,则运行集群的时间事件处理函数
run_with_period(100) {
if (server.cluster_enabled) clusterCron();
}

//如果当前运行的是哨兵,则运行哨兵的时间事件处理函数
if (server.sentinel_mode) sentinelTimer();

/* Cleanup expired MIGRATE cached sockets. */
//当某个连接长时间不用时,需要断开连接,删除缓存的migrateCachedSocket结构
//migrateCachedSocket是一个缓存链接,用于节点A->B的迁移
run_with_period(1000) {
migrateCloseTimedoutSockets();
}

/* Start a scheduled BGSAVE if the corresponding flag is set. This is
* useful when we are forced to postpone a BGSAVE because an AOF
* rewrite is in progress.
*
* Note: this code must be after the replicationCron() call above so
* make sure when refactoring this file to keep this order. This is useful
* because we want to give priority to RDB savings for replication. */
/*
* RDB的持久化在serverCron中第二个触发点,需要确认当前没有aof rewrite和rdb dump在进行,并且设置了rdb_bgsave_scheduled:
* 如果上次触发bgsave时已经有进程在执行aof的重写了(rdbSaveBackground中),就会标记rdb_bgsave_scheduled=1,
* 然后放到serverCron,然后在serverCron的最后在进行判断是否能够执行
*/
if (server.rdb_child_pid == -1 && server.aof_child_pid == -1 &&
server.rdb_bgsave_scheduled &&
(server.unixtime-server.lastbgsave_try > CONFIG_BGSAVE_RETRY_DELAY ||
server.lastbgsave_status == C_OK))
{
rdbSaveInfo rsi, *rsiptr;
rsiptr = rdbPopulateSaveInfo(&rsi);
if (rdbSaveBackground(server.rdb_filename,rsiptr) == C_OK)
server.rdb_bgsave_scheduled = 0;
}

server.cronloops++;
//返回下一次多久后执行,在processTimeEvents中使用!!!
return 1000/server.hz;
}

事件创建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
/*
* 创建时间事件
* 返回时间事件的id
*/
long long aeCreateTimeEvent(aeEventLoop *eventLoop, long long milliseconds,
aeTimeProc *proc, void *clientData,
aeEventFinalizerProc *finalizerProc)
{
// 更新时间计数器
long long id = eventLoop->timeEventNextId++;

// 创建时间事件结构
aeTimeEvent *te;

te = zmalloc(sizeof(*te));
if (te == NULL) return AE_ERR; //事件执行状态:出错

// 设置 ID
te->id = id;

// 设定处理事件的时间
aeAddMillisecondsToNow(milliseconds,&te->when_sec,&te->when_ms);
// 设置事件处理器
te->timeProc = proc;
//设置事件释放函数
te->finalizerProc = finalizerProc;
// 设置私有数据
te->clientData = clientData;

// 将新事件放入表头
te->next = eventLoop->timeEventHead;
eventLoop->timeEventHead = te;

return id;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/*
* 在当前时间上加上 milliseconds 毫秒,
* 并且将加上之后的秒数和毫秒数分别保存在 sec 和 ms 指针中。
*/
static void aeAddMillisecondsToNow(long long milliseconds, long *sec, long *ms) {
long cur_sec, cur_ms, when_sec, when_ms;

// 获取当前时间
aeGetTime(&cur_sec, &cur_ms);

// 计算增加 milliseconds 之后的秒数和毫秒数
when_sec = cur_sec + milliseconds/1000;
when_ms = cur_ms + milliseconds%1000;

// 进位:
// 如果 when_ms 大于等于 1000
// 那么将 when_sec 增大一秒
if (when_ms >= 1000) {
when_sec ++;
when_ms -= 1000;
}

// 保存到指针中
*sec = when_sec;
*ms = when_ms;
}

事件处理

每次执行ae.c#aeProcessEvents时,会调用processTimeEvents进行处理所有的time event。

1
2
3
/* Check time events */
if (flags & AE_TIME_EVENTS)
processed += processTimeEvents(eventLoop);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
static int processTimeEvents(aeEventLoop *eventLoop) {
int processed = 0;
aeTimeEvent *te;
long long maxId;
time_t now = time(NULL);
// 如果系统时钟被移动到未来,然后返回到正确的值,那么时间事件可能会以一种随机的方式延迟。通常这意味着计划的操作不会很快执行。
// 在这里,我们试图检测系统时钟的倾斜,并在发生这种情况时强制所有的时间事件被处理。
// 早期处理事件比无限期地延迟它们的危险要小
// 通过重置事件的运行时间,
// 防止因时间穿插(skew)而造成的事件处理混乱
if (now < eventLoop->lastTime) {
te = eventLoop->timeEventHead;
while(te) {
te->when_sec = 0;
te = te->next;
}
}
// 更新最后一次处理时间事件的时间
eventLoop->lastTime = now;

// 遍历链表
// 执行那些已经到达的事件
te = eventLoop->timeEventHead;
maxId = eventLoop->timeEventNextId-1;
while(te) {
long now_sec, now_ms;
long long id;

// 跳过无效事件
if (te->id > maxId) {
te = te->next;
continue;
}

// 获取当前时间
aeGetTime(&now_sec, &now_ms);

// 如果当前时间等于或等于事件的执行时间,那么说明事件已到达,执行这个事件
if (now_sec > te->when_sec ||
(now_sec == te->when_sec && now_ms >= te->when_ms))
{
int retval;

id = te->id;
// 执行事件处理器,并获取返回值
retval = te->timeProc(eventLoop, id, te->clientData);
processed++;
// 记录是否有需要循环执行这个事件时间
if (retval != AE_NOMORE) {
// 是的, retval 毫秒之后继续执行这个时间事件
aeAddMillisecondsToNow(retval,&te->when_sec,&te->when_ms);
} else {
// 不,将这个事件删除
aeDeleteTimeEvent(eventLoop, id);
}

// 因为执行事件之后,事件列表可能已经被改变了
// 因此需要将 te 放回表头,继续开始执行事件
te = eventLoop->timeEventHead;
} else {
te = te->next;
}
}
return processed;
}

通过遍历时间事件链表,来处理所有已经到达的时间事件,需要注意的是:

  1. 如果系统时钟被移动到未来,那么时间事件可能会以一种随机的方式延迟,意味着计划的操作不会很快被执行。所以需要用eventLoop中定义的lastTime来检测系统时钟的倾斜,确保发生这种情况时强制所有的时间事件被执行:te->when_sec=0。
  2. 执行完具体的事件处理器后,根据返回值retval来判断是否需要循环执行这个时间事件,若要循环执行就要重新设置处理事件的到达时间,否则就删除这个时间。
  3. 执行完具体的事件后,事件链表可能已经被改变了,因此需要重新迭代至表头,继续判断执行事件。

所以,redis的所有定时任务都是基于serverCron来驱动的,而eventloop直接驱动的只有serverCron。

客户端

结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
typedef struct client {
uint64_t id; //客户端id,-1表示伪客户端
int fd; //套接字描述符
redisDb *db; //指向当前使用的redisDb
robj *name; //客户端名称
sds querybuf; //输入端缓冲区
size_t qb_pos; //缓冲区已读取的字节数
sds pending_querybuf; /* If this client is flagged as master, this buffer
represents the yet not applied portion of the
replication stream that we are receiving from
the master. */
size_t querybuf_peak; /* Recent (100ms or more) peak of querybuf size. */
int argc; //当前命令的参数个数
robj **argv; //当前命令的参数
struct redisCommand *cmd, *lastcmd; //cmd:待执行的客户端命令;解析命令请求后,会根据命令名称查找该命令对应的命令对象,存储在客户端cmd字段;lastcmd:上次执行的命令
int reqtype; //客户端有两种请求协议:PROTO_REQ_INLINE 1;PROTO_REQ_MULTIBULK 2(看multibulk具体解析)
int multibulklen; //剩余读取的bulk数量
long bulklen; //每个bulk的数量
list *reply; //发送给客户端的回复链表
unsigned long long reply_bytes;//记录回复链表总大小
size_t sentlen; //表示当前该对象已发送的长度
time_t ctime; //客户端创建时间
time_t lastinteraction; //客户端最后一次交互的时间
time_t obuf_soft_limit_reached_time;
int flags; //客户端标识,常见的 CLIENT_SLAVE, 表示 slave; CLENT_MASTER 表示客户端为 MASTER , CLIETN_BLOCKED 表示客户端处理等待操作(执行某些阻塞操作);CLIENT_MULTI 表示可兑换正在处理事务 CLIENT_FORCE_AOF表示强制执行 AOP(pubsub 时,所有指令都会 aof )
int authenticated; /* When requirepass is non-NULL. */
int replstate; /* Replication state if this is a slave. */
int repl_put_online_on_ack; /* Install slave write handler on first ACK. */
int repldbfd; /* Replication DB file descriptor. */
off_t repldboff; /* Replication DB file offset. */
off_t repldbsize; /* Replication DB file size. */
sds replpreamble; /* Replication DB preamble. */
long long read_reploff; /* Read replication offset if this is a master. */
long long reploff; /* Applied replication offset if this is a master. */
long long repl_ack_off; /* Replication ack offset, if this is a slave. */
long long repl_ack_time;/* Replication ack time, if this is a slave. */
long long psync_initial_offset; /* FULLRESYNC reply offset other slaves
copying this slave output buffer
should use. */
char replid[CONFIG_RUN_ID_SIZE+1]; /* Master replication ID (if master). */
int slave_listening_port; /* As configured with: SLAVECONF listening-port */
char slave_ip[NET_IP_STR_LEN]; /* Optionally given by REPLCONF ip-address */
int slave_capa; /* Slave capabilities: SLAVE_CAPA_* bitwise OR. */
multiState mstate; //记录当前客户端的事务状态,事务状态包含一个事务队列以及一个命令计数器
int btype; /* Type of blocking op if CLIENT_BLOCKED. */
blockingState bpop; //记录客户端阻塞相关的状态,包括超时时间,引起阻塞的 key 等
long long woff; /* Last write global replication offset. */
list *watched_keys; /* Keys WATCHED for MULTI/EXEC CAS */
dict *pubsub_channels; //当前客户端订阅的频道
list *pubsub_patterns; //当前客户端订阅的模式
sds peerid; /* Cached peer ID. */
listNode *client_list_node; /* list node in client list */

/* Response buffer */
int bufpos;
char buf[PROTO_REPLY_CHUNK_BYTES];
} client;

客户端状态包含的属性可以分为两类:

  1. 一类是比较通用的属性,这些属性很少与特定功能相关,无论客户端执行的是什么工作,它们都要用到这些属性。
  2. 另外一类是和特定功能相关的属性,比如操作数据库时需要用到的db属性和dictid属性,执行事务时需要用到的mstate属性,以及执行WATCH命令时需要用到的watched_keys属性等等。

主要包括以下属性:

  1. 客户端的套接字描述符。客户端的名字。
  2. 客户端的标志值( flag )。
  3. 指向客户端正在使用的数据库的指针,以及该数据库的号码。
  4. 客户端当前要执行的命令、命令的参数、命令参数的个数,以及指向命令实现函数的指针。
  5. 客户端的输人缓冲区和输出缓冲区。
  6. 客户端的复制状态信息,以及进行复制所需的数据结构。
  7. 客户端执行BRPOP、BLPOP等列表阻塞命令时使用的数据结构。客户端的事务状态,以及执行 WATCH命令时用到的数据结构。客户端执行发布与订阅功能时用到的数据结构。
  8. 客户端的身份验证标志。
  9. 客户端的创建时间,客户端和服务器最后一次通信的时间,以及客户端的输出缓冲区大小超出软性限制( soft limit)的时间。

所有的client都保存在redisServer的一个clients链表中:

1
2
3
4
5
struct redisServer {
...
list *clients; /* List of active clients */
...
}

套接字描述符-fd(int)

1
2
3
typedef struct client {
int fd; /* Client socket. */
}

套接字描述符是一个整数类型的值,就如程序通过文件描述符访问文件一样,套接字描述符是访问套接字的一种路径。每个进程的进程空间里都有一个套接字描述符表(每个进程维护一个单独的套接字描述符表。因此,应用程序可以拥有相同的套接字描述符),该表中存放着套接字描述符和套接字数据结构的对应关系。该表中有一个字段存放新创建的套接字的描述符,另一个字段存放套接字数据结构的地址,因此根据套接字描述符就可以找到其对应的套接字数据结构。每个进程在自己的进程空间里都有一个套接字描述符表但是套接字数据结构都是在操作系统的内核缓冲里。

从某种意义上说,套接字也是文件,所以许多对文件描述符使用的函数,对套接字描述符同样适用。每个 socket 被创建后,都会分配两个缓冲区,输入缓冲区和输出缓冲区。

  • write()/send() 并不立即向网络中传输数据,而是先将数据写入缓冲区中,再写入协议栈,即由TCP协议将数据从缓冲区发送到目标机器。一旦将数据写入到缓冲区,函数就可以成功返回,不管它们有没有到达目标机器,也不管它们何时被发送到网络,这些都是TCP协议负责的事情。
  • read()/recv() 函数也是如此,也从输入缓冲区中读取数据,而不是直接从网络中读取。

参考:👉 socket套接字及缓冲区详解_青萍之末的博客-CSDN博客_套接字缓冲区

两个相关函数:

  • int socket(int domain,int type,int protocol)

    domain(协议族):常用的协议族便是IPV4(PF_INET), IPV6(PF_INET6),本地通信协议的UNIX族(PF_LOCAL)
    type:数据传输类型;典型数据传输类型:SOCK_DGRAM,SOCK_RAW,SOCK_SEQPACKET,SOCK_STREAM
    protocal:具体协议,通常为0,表示按给定的域或套接字类型选择默认协议。当对同一域和套接字类型支持多个协议时,可以使用protocol参数选择一个特定协议。

  • int shutdown(int socketfd,int how)

    关闭sockfd指向的套接字的how。其中how的取值可以为:SHUT_RD,SHUT_WR,SHUT_RDWR。

    与close的区别:close是关闭一个指向文件的文件描述符,其实只是关闭了这个文件描述符对文件表的指针。如果该文件仍有其他文件描 述符引用的话,该文件的V节点表并没有关闭。只有当关闭的文件描述符是最后一个指向文件的文件描述符,V节点才能也被关闭;而shutdown是关闭对一文件的读写等属性,不问有多少个文件描述符对该文件引用。

在redis中套接字描述法主要有两种:

  • 伪客户端( fake client)的fd属性的值为-1:伪客户端处理的命令请求来源于AOF文件或者Lua脚本,而不是网络,所以这种客户端不需要套接字连接,自然也不需要记录套接字描述符。目前Redis服务器会在两个地方用到伪客户端,一个用于载入AOF文件并还原数据库状态,而另一个则用于执行Lua脚本中包含的Redis命令。
  • 普通客户端的fd属性的值为大于-1的整数:普通客户端使用套接字来与服务器进行通信,所以服务器会用fd属性来记录客户端套接字的描述符。因为合法的套接字描述符不能是–1,所以普通客户端的套接字描述符的值必然是大于-1的整数。

注意另一个属性:slave_listening_port,其会在设置服务器主从关系的时候设置,仅仅在主服务器执行INFO replication命令时打印出服务器的端口号。

名字-name(robj*)

1
2
3
typedef struct client {
robj *name; /* As set by CLIENT SETNAME. */
}client

如果客户端没有为自己设置名字,那么相应客户端状态的name属性指向NULL指针;相反地,如果客户端为自己设置了名字,那么name属性将指向一个字符串对象,而该对象就保存着客户端的名字。

标志-flags(int)

1
2
3
typedef struct client {
int flags; /* Client flags: CLIENT_* macros. */
}client

客户端的标志属性flags记录了客户端的角色( role),以及客户端目前所处的状态。flags可以是单个标志,也可以是多个标志的二进制或。

1
2
flags = <flag>
flags = <flag1> | <flag2> | ...

每个标志使用一个常量表示,一部分标志记录了客户端的角色:

  • 在主从服务器进行复制操作时,主服务器会成为从服务器的客户端,而从服务器也会成为主服务器的客户端。REDIs_MASTER标志表示客户端代表的是一个主服务器,REDIS_SIAVE标志表示客户端代表的是一个从服务器。
  • REDIS_PRE_PSYNC标志表示客户端代表的是一个版本低于Redis2.8的从服务器,主服务器不能使用PSYNC命令与这个从服务器进行同步。这个标志只能在REDIS_SLAVE标志处于打开状态时使用。
  • REDIS_LUA_CLIENT标识表示客户端是专门用于处理Lua脚本里面包含的Redis命令的伪客户端。

而另外一部分标志则记录了客户端目前所处的状态:

  • REDIs_MONITOR标志表示客户端正在执行MONITOR命令。
  • REDIS_UNIx_SOCKET标志表示服务器使用UNIX套接字来连接客户端。REDIS_BLOCKED标志表示客户端正在被BRPOP、BLPOP等命令阻塞。
  • REDIS_UNBLOCKED标志表示客户端已经从REDIS_BLOCKED标志所表示的阻塞状态中脱离出来,不再阻塞。
  • REDIS_ UNBLOCKED 标志只能在REDIS BLOCKED 标志已经打开的情况下使用。
  • REDIS_ MULTI 标志表示客户端正在执行事务。
  • REDIS_ DIRTY_ CAS标志表示事务使用WATCH命令监视的数据库键已经被修改,
  • REDIS__ DIRTY_ EXEC标志表示事务在命令人队时出现了错误,以上两个标志都表示事务的安全性已经被破坏,只要这两个标记中的任意-一个被打开,EXEC命令必然会执行失败。这两个标志只能在客户端打开了REDIS_ MULTI 标志的情况下使用。
  • REDIS_ CLOSE_ ASAP标志表示客户端的输出缓冲区大小超出了服务器允许的范围,服务器会在下一-次执行serverCron函数时关闭这个客户端,以免服务器的稳定性受到这个客户端影响。积存在输出缓冲区中的所有内容会直接被释放,不会返回给客户端。
  • REDIS_ CLOSE_ AFTER_ REPLY标志表示有用户对这个客户端执行了CLIENT KILL命令,或者客户端发送给服务器的命令请求中包含了错误的协议内容。服务器会将客户端积存在输出缓冲区中的所有内容发送给客户端,然后关闭客户端。
  • REDIS_ ASKING标志表示客户端向集群节点(运行在集群模式下的服务器)发送了ASKING命令。
  • REDIS_FORCE_AOF标志强制服务器将当前执行的命令写人到AOF文件里面,REDIS_FORCE_REPL标志强制主服务器将当前执行的命令复制给所有从服务器。执行PUBSUB命令会使客户端打开REDIS_ FORCE _AOF标志,执行SCRIPT LOAD命令会使客户端打开REDIS_ FORCE_ AoF标志和REDIS_ FORCE_ REPL标志。
  • 在主从服务器进行命令传播期间,从服务器需要向主服务器发送REPLICATION ACK命令,在发送这个命令之前,从服务器必须打开主服务器对应的客户端的REDIS_MASTER_ FORCE_ _REPLY标志,否则发送操作会被拒绝执行。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#客户端是一个主服务器
REDIS_MASTER

#客户端正在被列表命令阻塞
REDIS_BLOCKED

#客户端正在执行事务、但事务的安全性巳被破坏
REDIS_MULTI lREDIs_DIRTY__cAs

#客户端是一个从服务器,并且版本低于Redis 2.8
REDIS_SLAVE lREDIS_PRE_PsYNc

#这是专门用于执行Lua脚本包含的Redis命令的伪客户端
#它强制凰务器将当前执行的命令写入AOF文件,并复制给从服务
REDIS_LUA_CLIENT REDIs_PORCE_AOFI REDIS_PORCE_REPL

输入缓冲区-querybuf(sds)

1
2
3
typedef struct client {
sds querybuf; /* Buffer we use to accumulate client queries. */
}client

客户端状态的输入缓冲区用于保存客户端发送的命令请求,输人缓冲区的大小会根据输人内容动态地缩小或者扩大,但它的最大大小不能超过1GB,否则服务器将关闭这个客户端。

输出缓冲区-buf/reply

1
2
3
4
5
6
typedef struct client {
list *reply; /* List of reply objects to send to the client. */
/* Response buffer */
int bufpos;
char buf[PROTO_REPLY_CHUNK_BYTES];
}client

执行命令所得的命令回复会被保存在客户端状态的输出缓冲区里面,每个客户端都有两个输出缓冲区可用,一个缓冲区的大小是固定的,另一个缓冲区的大小是可变的:

  • 固定大小的缓冲区用于保存那些长度比较小的回复,比如oK、简短的字符串值、整数值、错误回复等等。
  • 可变大小的缓冲区用于保存那些长度比较大的回复,比如一个非常长的字符串值,一个由很多项组成的列表,一个包含了很多元素的集合等等。

固定缓冲区是由buf和bufpos两个属性组成的:buf是一个大小为REDIS_REPLY_CHUNK_BYTES字节的字节数组,而bufpos属性则记录了buf数组目前已使用的字节数量。REDIS_REPLY_CHUNK_BYTES 常量目前的默认值为16*1024,也即是说,buf数组的默认大小为16KB。

当buf数组的空间已经用完,或者回复因为太大而没办法放进buf数组里面时,服务器就会开始使用可变大小缓冲区。可变大小缓冲区由reply链表和一个或多个字符串对象组成。

命令和命令参数-argv(robj**)

在服务器将客户端发送的命令请求保存到客户端状态的querybuf属性之后,服务器将对命令请求的内容进行分析,并将得出的命令参数以及命令参数的个数分别保存到客户端状态的argv属性和argc属性。

1
2
3
4
typedef struct client {
int argc; /* Num of arguments of current command. */
robj **argv; /* Arguments of current command. */
}client

argv属性是一个数组,数组中的每个项都是一个字符串对象,其中argv[0]是要执行的命令,而之后的其他项则是传给命令的参数。
argc属性则负责记录argv数组的长度。

命令的实现函数-cmd(struct *redisCommand)

当服务器从协议内容中分析并得出argv属性和argc属性的值之后,服务器将根据项argv [0]的值,在命令表中查找命令所对应的命令实现函数。

上图展示了一个命令表示例,该表是一个字典,字典的键是一个SDS结构,保存了命令的名字,字典的值是命令所对应的redisCommand结构,这个结构保存了命令的实现函数、命令的标志、命令应该给定的参数个数、命令的总执行次数和总消耗时长等统计信息。

命令表存在redisServer对象中:

1
2
3
struct redisServer {
dict *commands; /* Command table */
}

dict的键是一个SDS结构,保存了命令的名字,字典的值是命令所对应的redisCommand结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct redisCommand {
char *name;
redisCommandProc *proc;
int arity;
char *sflags; /* Flags as string representation, one char per flag. */
int flags; /* The actual flags, obtained from the 'sflags' field. */
/* Use a function to determine keys arguments in a command line.
* Used for Redis Cluster redirect. */
redisGetKeysProc *getkeys_proc;
/* What keys should be loaded in background when calling this command? */
int firstkey; /* The first argument that's a key (0 = no keys) */
int lastkey; /* The last argument that's a key */
int keystep; /* The step between first and last key */
long long microseconds, calls;
};

redisCommand保存了命令的名字、命令的实现函数、命令的标志、命令应该给定的参数个数、命令的总执行次数和总消耗时长等统计信息。

sflags属性可以使用的标志值和意义:

这里注意一个数据结构:server.c#redisCommandTable:

1
2
3
4
5
6
7
struct redisCommand redisCommandTable[] = {
{"module",moduleCommand,-2,"as",0,NULL,0,0,0,0,0},
{"get",getCommand,2,"rF",0,NULL,1,1,1,0,0},
{"set",setCommand,-3,"wm",0,NULL,1,1,1,0,0},
...
{"lolwut",lolwutCommand,-1,"r",0,NULL,0,0,0,0,0}
};

这个数组中保存了所有命令的信息,并封装在每个redisCommand中。

然后在server.c#populateCommandTable中会将在redisCommandTable中的所有redisCommand存储到redisServer的commands字典中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
/* Populates the Redis Command Table starting from the hard coded list
* we have on top of redis.c file. */
void populateCommandTable(void) {
int j;
int numcommands = sizeof(redisCommandTable)/sizeof(struct redisCommand);

for (j = 0; j < numcommands; j++) {
struct redisCommand *c = redisCommandTable+j;
char *f = c->sflags;
int retval1, retval2;

while(*f != '\0') {
switch(*f) {
case 'w': c->flags |= CMD_WRITE; break;
case 'r': c->flags |= CMD_READONLY; break;
case 'm': c->flags |= CMD_DENYOOM; break;
case 'a': c->flags |= CMD_ADMIN; break;
case 'p': c->flags |= CMD_PUBSUB; break;
case 's': c->flags |= CMD_NOSCRIPT; break;
case 'R': c->flags |= CMD_RANDOM; break;
case 'S': c->flags |= CMD_SORT_FOR_SCRIPT; break;
case 'l': c->flags |= CMD_LOADING; break;
case 't': c->flags |= CMD_STALE; break;
case 'M': c->flags |= CMD_SKIP_MONITOR; break;
case 'k': c->flags |= CMD_ASKING; break;
case 'F': c->flags |= CMD_FAST; break;
default: serverPanic("Unsupported command flag"); break;
}
f++;
}

retval1 = dictAdd(server.commands, sdsnew(c->name), c);
/* Populate an additional dictionary that will be unaffected
* by rename-command statements in redis.conf. */
retval2 = dictAdd(server.orig_commands, sdsnew(c->name), c);
serverAssert(retval1 == DICT_OK && retval2 == DICT_OK);
}
}

multibulk

Redis支持两种数据解析协议,一种是inline,一种是multibulk。inline协议是老协议,现在一般只在命令行下的redis客户端使用,其他情况(如程序客户端)一般是使用multibulk协议。

如果客户端传送的数据的第一个字符时‘*’,那么传送数据将被当做multibulk协议处理,否则将被当做inline协议处理。Inline协议的具体解析函数是processInlineBuffer(),multibulk协议的具体解析函数是processMultibulkBuffer()。 当协议解析完毕,即客户端传送的数据已经解析出命令字段和参数字段,接下来进行命令处理,命令处理函数是processCommand。

networking.c#processInputBuffer:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
server.current_client = c;

/* Keep processing while there is something in the input buffer */
//只要querybuf中还包含完整的命令就会一直处理
while(c->qb_pos < sdslen(c->querybuf)) {
/* Return if clients are paused. */
if (!(c->flags & CLIENT_SLAVE) && clientsArePaused()) break;

/* Immediately abort if the client is in the middle of something. */
if (c->flags & CLIENT_BLOCKED) break;

/* Don't process input from the master while there is a busy script
* condition on the slave. We want just to accumulate the replication
* stream (instead of replying -BUSY like we do with other clients) and
* later resume the processing. */
if (server.lua_timedout && c->flags & CLIENT_MASTER) break;

/* CLIENT_CLOSE_AFTER_REPLY closes the connection once the reply is
* written to the client. Make sure to not let the reply grow after
* this flag has been set (i.e. don't process more commands).
*
* The same applies for clients we want to terminate ASAP. */
if (c->flags & (CLIENT_CLOSE_AFTER_REPLY|CLIENT_CLOSE_ASAP)) break;

//根据命令的格式,决定请求的类型
if (!c->reqtype) {
if (c->querybuf[c->qb_pos] == '*') {
c->reqtype = PROTO_REQ_MULTIBULK;
} else {
c->reqtype = PROTO_REQ_INLINE;
}
}

//根据请求的类型,分别调用processInlineBuffer和processMultibulkBuffer解析请求
if (c->reqtype == PROTO_REQ_INLINE) {
if (processInlineBuffer(c) != C_OK) break;
} else if (c->reqtype == PROTO_REQ_MULTIBULK) {
if (processMultibulkBuffer(c) != C_OK) break;
} else {
serverPanic("Unknown request type");
}

/* Multibulk processing could see a <= 0 length. */
//解析完成后,接下来调用processCommand函数进行命令处理
if (c->argc == 0) {
resetClient(c);
} else {
/* Only reset the client when the command was executed. */
if (processCommand(c) == C_OK) {
if (c->flags & CLIENT_MASTER && !(c->flags & CLIENT_MULTI)) {
/* Update the applied replication offset of our master. */
c->reploff = c->read_reploff - sdslen(c->querybuf) + c->qb_pos;
}

/* Don't reset the client structure for clients blocked in a
* module blocking command, so that the reply callback will
* still be able to access the client argv and argc field.
* The client will be reset in unblockClientFromModule(). */
if (!(c->flags & CLIENT_BLOCKED) || c->btype != BLOCKED_MODULE)
resetClient(c);
}
/* freeMemoryIfNeeded may flush slave output buffers. This may
* result into a slave, that may be the active client, to be
* freed. */
if (server.current_client == NULL) break;
}
}

/* Trim to pos */
if (server.current_client != NULL && c->qb_pos) {
sdsrange(c->querybuf,c->qb_pos,-1);
c->qb_pos = 0;
}

server.current_client = NULL;

networking.c#processInlineBuffer:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
//格式上,inline请求就是诸如”+PING\r\n”。在解析时,只需要查找\n即可。
//首先,就是查找\n。如果没有找到,说明buffer中的内容不是完整的请求,
// 然后判断一下querybuf的大小是否超过REDIS_INLINE_MAX_SIZE(64KB),
// 超过则向客户端发送错误响应。否则,返回REDIS_ERR,继续read数据。
int processInlineBuffer(client *c) {
char *newline;
int argc, j, linefeed_chars = 1;
sds *argv, aux;
size_t querylen;

//查找 \n 第一次出现的位置
newline = strchr(c->querybuf+c->qb_pos,'\n');

// 没有\n,需要下一次epoll迭代,继续read
if (newline == NULL) {
if (sdslen(c->querybuf)-c->qb_pos > PROTO_INLINE_MAX_SIZE) {
addReplyError(c,"Protocol error: too big inline request");
setProtocolError("too big inline request",c);
}
return C_ERR;
}

// 找到\n,则读到一个请求,接下来会将该请求数据拷贝到一个新的buffer中,等待解析。
if (newline && newline != c->querybuf+c->qb_pos && *(newline-1) == '\r')
newline--, linefeed_chars++;

/* Split the input buffer up to the \r\n */
querylen = newline-(c->querybuf+c->qb_pos);
//存在一次内存拷贝,即所有请求都需要拷贝一次
aux = sdsnewlen(c->querybuf+c->qb_pos,querylen);
// 接下来,根据空格将请求分割成多个部分,存储到argv数组,大小存于argc中
argv = sdssplitargs(aux,&argc);
sdsfree(aux);
if (argv == NULL) {
addReplyError(c,"Protocol error: unbalanced quotes in request");
setProtocolError("unbalanced quotes in inline request",c);
return C_ERR;
}

/* Newline from slaves can be used to refresh the last ACK time.
* This is useful for a slave to ping back while loading a big
* RDB file. */
// 更新一下主从同步时间
if (querylen == 0 && getClientType(c) == CLIENT_TYPE_SLAVE)
c->repl_ack_time = server.unixtime;

/* Move querybuffer position to the next query in the buffer. */
//修改当前的querybuffer的未处理数据的位置
c->qb_pos += querylen+linefeed_chars;

//创建redisClient->argv数组
if (argc) {
if (c->argv) zfree(c->argv);
c->argv = zmalloc(sizeof(robj*)*argc);
}

//给所有的参数创建一个robj,并存到redisClient->argv中
for (c->argc = 0, j = 0; j < argc; j++) {
c->argv[c->argc] = createObject(OBJ_STRING,argv[j]);
c->argc++;
}
zfree(argv);
return C_OK;
}

networking.c#processMultibulkBuffer

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
// 这个函数解析multi bulk的请求。会以一个bulk为单位解析整个命令,在redisClient->multibulklen存储bulk的数量,
// 然后循环一次解析每个bulk,如果multibulklen=0,则首先需要解析出multibulklen。redisClient->bulklen存储
// 当前要解析的bulk的大小,如果bulk=-1,则首先要解析出bulk。下面代码,如果multibulklen=0,说明是一个新的请求,
// 首先需要解析出multibulklen。通过pos,指示当前querybuf中为解析的部分。如果后续在解析一个请求过程中,是不会走这个分支的。
int processMultibulkBuffer(client *c) {
char *newline = NULL;
int ok;
long long ll;

if (c->multibulklen == 0) {
/* The client should have been reset */
serverAssertWithInfo(c,NULL,c->argc == 0);

/* Multi bulk length cannot be read without a \r\n */
newline = strchr(c->querybuf+c->qb_pos,'\r');
if (newline == NULL) {
if (sdslen(c->querybuf)-c->qb_pos > PROTO_INLINE_MAX_SIZE) {
addReplyError(c,"Protocol error: too big mbulk count string");
setProtocolError("too big mbulk count string",c);
}
return C_ERR;
}

/* Buffer should also contain \n */
if (newline-(c->querybuf+c->qb_pos) > (ssize_t)(sdslen(c->querybuf)-c->qb_pos-2))
return C_ERR;

/* We know for sure there is a whole line since newline != NULL,
* so go ahead and find out the multi bulk length. */
serverAssertWithInfo(c,NULL,c->querybuf[c->qb_pos] == '*');
ok = string2ll(c->querybuf+1+c->qb_pos,newline-(c->querybuf+1+c->qb_pos),&ll);
if (!ok || ll > 1024*1024) {
addReplyError(c,"Protocol error: invalid multibulk length");
setProtocolError("invalid mbulk count",c);
return C_ERR;
} else if (ll > 10 && server.requirepass && !c->authenticated) {
addReplyError(c, "Protocol error: unauthenticated multibulk length");
setProtocolError("unauth mbulk count", c);
return C_ERR;
}

c->qb_pos = (newline-c->querybuf)+2;

if (ll <= 0) return C_OK;

c->multibulklen = ll;

/* Setup argv array on client structure */
if (c->argv) zfree(c->argv);
c->argv = zmalloc(sizeof(robj*)*c->multibulklen);
}

serverAssertWithInfo(c,NULL,c->multibulklen > 0);
// 循环multibulklen次,解析出对应个数的bulk。下面看一下这个循环内部:
// 首先,也是如果bulklen=-1,说明要解析的是一个新的bulk,需要解析bulklen。
while(c->multibulklen) {
/* Read bulk length if unknown */
if (c->bulklen == -1) {
newline = strchr(c->querybuf+c->qb_pos,'\r');
if (newline == NULL) {
if (sdslen(c->querybuf)-c->qb_pos > PROTO_INLINE_MAX_SIZE) {
addReplyError(c,
"Protocol error: too big bulk count string");
setProtocolError("too big bulk count string",c);
return C_ERR;
}
break;
}

/* Buffer should also contain \n */
if (newline-(c->querybuf+c->qb_pos) > (ssize_t)(sdslen(c->querybuf)-c->qb_pos-2))
break;

if (c->querybuf[c->qb_pos] != '$') {
addReplyErrorFormat(c,
"Protocol error: expected '$', got '%c'",
c->querybuf[c->qb_pos]);
setProtocolError("expected $ but got something else",c);
return C_ERR;
}

ok = string2ll(c->querybuf+c->qb_pos+1,newline-(c->querybuf+c->qb_pos+1),&ll);
if (!ok || ll < 0 || ll > server.proto_max_bulk_len) {
addReplyError(c,"Protocol error: invalid bulk length");
setProtocolError("invalid bulk length",c);
return C_ERR;
} else if (ll > 16384 && server.requirepass && !c->authenticated) {
addReplyError(c, "Protocol error: unauthenticated bulk length");
setProtocolError("unauth bulk length", c);
return C_ERR;
}

c->qb_pos = newline-c->querybuf+2;
if (ll >= PROTO_MBULK_BIG_ARG) {
/* If we are going to read a large object from network
* try to make it likely that it will start at c->querybuf
* boundary so that we can optimize object creation
* avoiding a large copy of data.
*
* But only when the data we have not parsed is less than
* or equal to ll+2. If the data length is greater than
* ll+2, trimming querybuf is just a waste of time, because
* at this time the querybuf contains not only our bulk. */
if (sdslen(c->querybuf)-c->qb_pos <= (size_t)ll+2) {
sdsrange(c->querybuf,c->qb_pos,-1);
c->qb_pos = 0;
/* Hint the sds library about the amount of bytes this string is
* going to contain. */
c->querybuf = sdsMakeRoomFor(c->querybuf,ll+2);
}
}
c->bulklen = ll;
}

/* Read bulk argument */
// 接下来,是解析bulk,首先会判断querybuf是否包含足够的内容。不够则返回,否则解析该bulk。
//在解析bulk,如果bulk不是大对象(不超过64KB),则会调用createStringObject创建argv,
// 这个函数内部会调用strnewlen函数,拷贝传入的buffer。如果是大对象,并且querybuf中只
// 包含该bulk的内容,则调用createObject函数,直接以querybuf为底层存储,创建argv,避免
// 了大对象的拷贝。最后,在解析出bulk后,需要将bulklen设置为-1,并将multibulklen减1。

if (sdslen(c->querybuf)-c->qb_pos < (size_t)(c->bulklen+2)) {
/* Not enough data (+2 == trailing \r\n) */
break;
} else {
/* Optimization: if the buffer contains JUST our bulk element
* instead of creating a new object by *copying* the sds we
* just use the current sds string. */
if (c->qb_pos == 0 &&
c->bulklen >= PROTO_MBULK_BIG_ARG &&
sdslen(c->querybuf) == (size_t)(c->bulklen+2))
{
c->argv[c->argc++] = createObject(OBJ_STRING,c->querybuf);
sdsIncrLen(c->querybuf,-2); /* remove CRLF */
/* Assume that if we saw a fat argument we'll see another one
* likely... */
c->querybuf = sdsnewlen(SDS_NOINIT,c->bulklen+2);
sdsclear(c->querybuf);
} else {
c->argv[c->argc++] =
createStringObject(c->querybuf+c->qb_pos,c->bulklen);
c->qb_pos += c->bulklen+2;
}
c->bulklen = -1;
c->multibulklen--;
}
}

/* We're done when c->multibulk == 0 */
//如果multibulklen=0,说明已经解析出命令所有的bulk,即命令解析成功,则返回REDIS_OK。
if (c->multibulklen == 0) return C_OK;

/* Still not ready to process the command */
return C_ERR;
}

服务端

命令请求的执行过程

  1. 发送命令请求。Redis服务器的命令请求来自Redis客户端,当用户在客户端中键人一个命令请求时,客户端会将这个命令请求转换成协议格式,然后通过连接到服务器的套接字,将协议格式的命令请求发送给服务器。

  2. 读取命令请求。当客户端与服务器之间的连接套接字因为客户端的写入而变得可读时,服务器将调用命令请求处理器来执行以下操作:①读取套接字中协议格式的命令请求,并将其保存到客户端状态的输人缓冲区里面。对输人缓冲区中的命令请求进行分析,提取出命令请求中包含的命令参数,以及命令参数的个数,然后分别将参数和参数个数保存到客户端状态的argv属性和argc属性里面。调用命令执行器,执行客户端指定的命令。

    (①和②是在networking.c# processInputBuffer中进行的,调用流程是aeProcessEvents–>aeApiPoll–>fe->rfileProc,rfileProc就是和每个fe绑定的readQueryFromClient,会调用processInputBufferAndReplicate–>processInputBuffer;③就是在processInputBuffer中调用了server.c#processCommand进行执行客户端的命令)

  3. 命令执行-1-查找命令。命令执行器要做的第一件事就是根据客户端状态的argv[0]参数,在命令表( server.commands)中查找参数所指定的命令,并将找到的命令保存到客户端状态的cmd属性里面。(具体可以看客户端中的命令的实现函数-cmd)

  4. 命令执行-2-执行预备操作。在真正执行之前,还需要做一些预备操作,从而保证命令可以正确的、顺利地执行。比如:①检查客户端状态的cmd指针是否指向NULL,如果是的话,那么说明用户输入的命令名字找不到相应的命令实现,服务器不再执行后续步骤,并向客户端返回一个错误;②根据客户端cmd 属性指向的rediscommand结构的arity属性,检查命令请求所给定的参数个数是否正确,当参数个数不正确时,不再执行后续步骤,直接向客户端返回一个错误。比如说,如果rediscommand结构的arity属性的值为-3,那么用户输入的命令参数个数必须大于等于3个才行;③检查客户端是否已经通过了身份验证,未通过身份验证的客户端只能执行AUTH命令,如果未通过身份验证的客户端试图执行除AUTH命令之外的其他命令,那么服务器将向客户端返回一个错误;④如果服务器打开了maxmemory 功能,那么在执行命令之前,先检查服务器的内存占用情况,并在有需要时进行内存回收,从而使得接下来的命令可以顺利执行。如果内存回收失败,那么不再执行后续步骤,向客户端返回一个错误等,具体的可以去看《redis设计与实现》.p181

  5. 命令执行-3-调用命令的实现函数。在进行一系列的数据检查后,会调用call命令,进而调用c->cmd->proc©来执行函数,即调用命令对应的命令处理函数被调用的命令实现函数会执行指定的操作,并产生相应的命令回复,这些回复会被保存在客户端状态的输出缓冲区里面( buf属性和reply属性),之后实现函数还会为客户端的套接字关联命令回复处理器,这个处理器负责将命令回复返回给客户端。

  6. 命令执行-4-执行后续工作。①如果服务器开启了慢查询日志功能,那么慢查询日志模块会检查是否需要为刚刚执行完的命令请求添加一条新的慢查询日志;②根据刚刚执行命令所耗费的时长,更新被执行命令的rediscommand结构的milliseconds属性,并将命令的rediscommand结构的calls计数器的值增一;③如果服务器开启了AOF持久化功能,那么AOF持久化模块会将刚刚执行的命令请求写入到AOF缓冲区里面(server.c#call);④如果有其他从服务器正在复制当前这个服务器,那么服务器会将刚刚执行的命令传播给所有从服务器(networking.c#processInputBufferAndReplicate)。

  7. 命令执行-5将命令恢复发送给客户端。命令实现函数会将命令回复保存到客户端的输出缓冲区里面,并放入clients_pending_write列表中,当处理时会直接发送给客户端,若还有树没发完,就为客户端的套接字关联命令回复处理器,当客户端套接字变为可写状态时,服务器就会执行命令回复处理器,将保存在客户端输出缓冲区中的命令回复发送给客户端。

Redis 命令执行过程(下) - 程序员历小冰 - 博客园 (cnblogs.com)

命令全过程源码跟踪

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
//1.每次进行循环
//ae.c#aeMain
void aeMain(aeEventLoop *eventLoop) {
eventLoop->stop = 0;
while (!eventLoop->stop) {
if (eventLoop->beforesleep != NULL)
eventLoop->beforesleep(eventLoop);
aeProcessEvents(eventLoop, AE_ALL_EVENTS|AE_CALL_AFTER_SLEEP);
}
}

//2. 监听获取命令,并调用执行,具体的函数可以看event loop部分的介绍,aeApiPoll可以看File Event部分的介绍。
//ae.c#aeProcessEvents
int aeProcessEvents(aeEventLoop *eventLoop, int flags){
...
numevents = aeApiPoll(eventLoop, tvp);
...
if (!invert && fe->mask & mask & AE_READABLE) {
fe->rfileProc(eventLoop,fd,fe->clientData,mask);
fired++;
}

/* Fire the writable event. */
if (fe->mask & mask & AE_WRITABLE) {
if (!fired || fe->wfileProc != fe->rfileProc) {
fe->wfileProc(eventLoop,fd,fe->clientData,mask);
fired++;
}
}

/* If we have to invert the call, fire the readable event now
* after the writable one. */
if (invert && fe->mask & mask & AE_READABLE) {
if (!fired || fe->wfileProc != fe->rfileProc) {
fe->rfileProc(eventLoop,fd,fe->clientData,mask);
fired++;
}
}
...
}

//3.file event绑定的是readQueryFromClient,因此fe->rfileProc执行的就是这个函数
//最后会执行processInputBufferAndReplicate(c);
//networking.c#readQueryFromClient
void readQueryFromClient(aeEventLoop *el, int fd, void *privdata, int mask) {
//设置当前服务的client,然后是设置这次从socket读取的数据的默认大小(REDIS_IOBUF_LEN为16KB)
client *c = (client*) privdata;
int nread, readlen;
size_t qblen;
UNUSED(el);
UNUSED(mask);

readlen = PROTO_IOBUF_LEN;
/* If this is a multi bulk request, and we are processing a bulk reply
* that is large enough, try to maximize the probability that the query
* buffer contains exactly the SDS string representing the object, even
* at the risk of requiring more read(2) calls. This way the function
* processMultiBulkBuffer() can avoid copying buffers to create the
* Redis Object representing the argument. */
//这段代码重新设置读取数据的大小,避免频繁拷贝数据
//如果当前请求是一个multi bulk类型的,并且要处理的bulk的大小大于REDIS_MBULK_BIG_ARG(32KB),则将读取数据大小设置为该bulk剩余数据的大小。
if (c->reqtype == PROTO_REQ_MULTIBULK && c->multibulklen && c->bulklen != -1
&& c->bulklen >= PROTO_MBULK_BIG_ARG)
{
ssize_t remaining = (size_t)(c->bulklen+2)-sdslen(c->querybuf);

/* Note that the 'remaining' variable may be zero in some edge case,
* for example once we resume a blocked client after CLIENT PAUSE. */
if (remaining > 0 && remaining < readlen) readlen = remaining;
}

//读取的请求内容会存储到redisClient->querybuf中
//此处代码调整querybuf大小以便容纳这次read的数据
qblen = sdslen(c->querybuf);
if (c->querybuf_peak < qblen) c->querybuf_peak = qblen;
//可能存在一次copy,如果buffer的空闲空间小于readlen,则buffer大小翻倍,并将数据拷贝到新buffer
c->querybuf = sdsMakeRoomFor(c->querybuf, readlen);
//调用read系统调用,读取readlen大小的数据,并存储到querybuf中
nread = read(fd, c->querybuf+qblen, readlen);
//校验read的返回值,检测出错。如果read返回0,则客户端关闭连接,会释放掉该客户端。
if (nread == -1) {
//EAGAIN表示read不到数据
if (errno == EAGAIN) {
return;
} else {
serverLog(LL_VERBOSE, "Reading from client: %s",strerror(errno));
freeClient(c);
return;
}
} else if (nread == 0) {
serverLog(LL_VERBOSE, "Client closed connection");
freeClient(c);
return;
} else if (c->flags & CLIENT_MASTER) {
/* Append the query buffer to the pending (not applied) buffer
* of the master. We'll use this buffer later in order to have a
* copy of the string applied by the last command executed. */
c->pending_querybuf = sdscatlen(c->pending_querybuf,
c->querybuf+qblen,nread);
}

sdsIncrLen(c->querybuf,nread);
c->lastinteraction = server.unixtime;
if (c->flags & CLIENT_MASTER) c->read_reploff += nread;
server.stat_net_input_bytes += nread;
//判断客户端的请求buffer是否超过配置的值server.client_max_querybuf_len(1GB),如果超过,会拒绝服务,并关闭该客户端。
if (sdslen(c->querybuf) > server.client_max_querybuf_len) {
sds ci = catClientInfoString(sdsempty(),c), bytes = sdsempty();

bytes = sdscatrepr(bytes,c->querybuf,64);
serverLog(LL_WARNING,"Closing client that reached max query buffer length: %s (qbuf initial bytes: %s)", ci, bytes);
sdsfree(ci);
sdsfree(bytes);
freeClient(c);
return;
}

/* Time to process the buffer. If the client is a master we need to
* compute the difference between the applied offset before and after
* processing the buffer, to understand how much of the replication stream
* was actually applied to the master state: this quantity, and its
* corresponding part of the replication stream, will be propagated to
* the sub-slaves and to the replication backlog. */
// 最后,会调用processInputBuffer函数解析请求。
processInputBufferAndReplicate(c);
}

//4. 进行处理和同步到子节点
//networking.c#processInputBufferAndReplicate
void processInputBufferAndReplicate(client *c) {
if (!(c->flags & CLIENT_MASTER)) {
processInputBuffer(c);
} else {
size_t prev_offset = c->reploff;
processInputBuffer(c);
size_t applied = c->reploff - prev_offset;
if (applied) {
replicationFeedSlavesFromMasterStream(server.slaves,
c->pending_querybuf, applied);
sdsrange(c->pending_querybuf,applied,-1);
}
}
}

//5. 进一步调用processCommand
//networking.c#processInputBuffer
void processInputBuffer(client *c) {
server.current_client = c;

/* Keep processing while there is something in the input buffer */
//只要querybuf中还包含完整的命令就会一直处理
while(c->qb_pos < sdslen(c->querybuf)) {
/* Return if clients are paused. */
if (!(c->flags & CLIENT_SLAVE) && clientsArePaused()) break;

/* Immediately abort if the client is in the middle of something. */
if (c->flags & CLIENT_BLOCKED) break;

/* Don't process input from the master while there is a busy script
* condition on the slave. We want just to accumulate the replication
* stream (instead of replying -BUSY like we do with other clients) and
* later resume the processing. */
if (server.lua_timedout && c->flags & CLIENT_MASTER) break;

/* CLIENT_CLOSE_AFTER_REPLY closes the connection once the reply is
* written to the client. Make sure to not let the reply grow after
* this flag has been set (i.e. don't process more commands).
*
* The same applies for clients we want to terminate ASAP. */
if (c->flags & (CLIENT_CLOSE_AFTER_REPLY|CLIENT_CLOSE_ASAP)) break;

//决定请求的类型
if (!c->reqtype) {
if (c->querybuf[c->qb_pos] == '*') {
c->reqtype = PROTO_REQ_MULTIBULK;
} else {
c->reqtype = PROTO_REQ_INLINE;
}
}

//根据请求的类型,分别调用processInlineBuffer和processMultibulkBuffer解析请求
if (c->reqtype == PROTO_REQ_INLINE) {
if (processInlineBuffer(c) != C_OK) break;
} else if (c->reqtype == PROTO_REQ_MULTIBULK) {
if (processMultibulkBuffer(c) != C_OK) break;
} else {
serverPanic("Unknown request type");
}

/* Multibulk processing could see a <= 0 length. */
//解析完成后,接下来调用processCommand函数进行命令处理
if (c->argc == 0) {
resetClient(c);
} else {
/* Only reset the client when the command was executed. */
if (processCommand(c) == C_OK) {
if (c->flags & CLIENT_MASTER && !(c->flags & CLIENT_MULTI)) {
/* Update the applied replication offset of our master. */
c->reploff = c->read_reploff - sdslen(c->querybuf) + c->qb_pos;
}

/* Don't reset the client structure for clients blocked in a
* module blocking command, so that the reply callback will
* still be able to access the client argv and argc field.
* The client will be reset in unblockClientFromModule(). */
if (!(c->flags & CLIENT_BLOCKED) || c->btype != BLOCKED_MODULE)
resetClient(c);
}
/* freeMemoryIfNeeded may flush slave output buffers. This may
* result into a slave, that may be the active client, to be
* freed. */
if (server.current_client == NULL) break;
}
}

/* Trim to pos */
if (server.current_client != NULL && c->qb_pos) {
sdsrange(c->querybuf,c->qb_pos,-1);
c->qb_pos = 0;
}

server.current_client = NULL;
}

//6. 先进行预备操作,再调用call进行执行
//server.c#processCommand
int processCommand(client *c) {
...
//根据argv[0],查找command table,找到对应的命令
c->cmd = c->lastcmd = lookupCommand(c->argv[0]->ptr);
...
//接下里进行一系列的校验,比如内存的清理
if (server.maxmemory && !server.lua_timedout) {
//如果设置了maxmemory 配置项为非 0 值,且Lua 脚本没有在超时运行则判断是否要进行内存的的清理,具体的清理根据内存淘汰策略会有所不同,具体看内存淘汰机制。
int out_of_memory = freeMemoryIfNeededAndSafe() == C_ERR;
...
if (c->flags & CLIENT_MULTI &&
c->cmd->proc != execCommand && c->cmd->proc != discardCommand &&
c->cmd->proc != multiCommand && c->cmd->proc != watchCommand)
{
queueMultiCommand(c);
addReply(c,shared.queued);
} else {
call(c,CMD_CALL_FULL);
c->woff = server.master_repl_offset;
if (listLength(server.ready_keys))
handleClientsBlockedOnKeys();
}
return C_OK;
}

//7. 真正调用命令对应的函数,并将命令写入到AOF缓存
//server.c#call
void call(client *c, int flags) {
...
//在执行前先将该命令的相关信息发送给各个监视器,监视器是用来监听服务器要处理的命令
if (listLength(server.monitors) &&
!server.loading &&
!(c->cmd->flags & (CMD_SKIP_MONITOR|CMD_ADMIN)))
{
replicationFeedMonitors(c,server.monitors,c->db->id,c->argv,c->argc);
}
...
//dirty用于记录更新操作的次数,用于完成save配置(即用于RDB)
dirty = server.dirty;
updateCachedTime(0);
start = server.ustime;
//执行命令对应的处理函数
c->cmd->proc(c);
duration = ustime()-start;
dirty = server.dirty-dirty;
if (dirty < 0) dirty = 0;
...
if (flags & CMD_CALL_PROPAGATE &&
(c->flags & CLIENT_PREVENT_PROP) != CLIENT_PREVENT_PROP)
{
int propagate_flags = PROPAGATE_NONE;

/* Check if the command operated changes in the data set. If so
* set for replication / AOF propagation. */
if (dirty) propagate_flags |= (PROPAGATE_AOF|PROPAGATE_REPL);

/* If the client forced AOF / replication of the command, set
* the flags regardless of the command effects on the data set. */
if (c->flags & CLIENT_FORCE_REPL) propagate_flags |= PROPAGATE_REPL;
if (c->flags & CLIENT_FORCE_AOF) propagate_flags |= PROPAGATE_AOF;

/* However prevent AOF / replication propagation if the command
* implementations called preventCommandPropagation() or similar,
* or if we don't have the call() flags to do so. */
if (c->flags & CLIENT_PREVENT_REPL_PROP ||
!(flags & CMD_CALL_PROPAGATE_REPL))
propagate_flags &= ~PROPAGATE_REPL;
if (c->flags & CLIENT_PREVENT_AOF_PROP ||
!(flags & CMD_CALL_PROPAGATE_AOF))
propagate_flags &= ~PROPAGATE_AOF;

/* Call propagate() only if at least one of AOF / replication
* propagation is needed. Note that modules commands handle replication
* in an explicit way, so we never replicate them automatically. */
//调用propagate函数将写操作追加到aof_buf缓冲区和处理主从复制
if (propagate_flags != PROPAGATE_NONE && !(c->cmd->flags & CMD_MODULE))
propagate(c->cmd,c->db->id,c->argv,c->argc,propagate_flags);
}
...
}

//8.以string类型的get为例
//t_string.c#getGenericCommand
int getGenericCommand(redisClient *c) {
robj *o;

//获取当前值的对象,如果为空,响应空
if ((o = lookupKeyReadOrReply(c,c->argv[1],shared.nullbulk)) == NULL)
return REDIS_OK;

//如果值的对象不是string类型,则报错
if (o->type != REDIS_STRING) {
addReply(c,shared.wrongtypeerr);
return REDIS_ERR;
}
//响应值,并返回Ok
else {
addReplyBulk(c,o);
return REDIS_OK;
}
}

//9.处理返回数据
//networking.c#addReply
void addReply(client *c, robj *obj) {
//判断是否需要返回数据,并且将当前 client 添加到等待写返回数据队列中(server.clients_pending_write)。
if (prepareClientToWrite(c) != C_OK) return;

if (sdsEncodedObject(obj)) {
// 需要将响应内容添加到output buffer中。总体思路是,先尝试向固定buffer添加,添加失败的话,在尝试添加到响应链表
if (_addReplyToBuffer(c,obj->ptr,sdslen(obj->ptr)) != C_OK)
_addReplyStringToList(c,obj->ptr,sdslen(obj->ptr));
} else if (obj->encoding == OBJ_ENCODING_INT) {
char buf[32];
size_t len = ll2string(buf,sizeof(buf),(long)obj->ptr);
if (_addReplyToBuffer(c,buf,len) != C_OK)
_addReplyStringToList(c,buf,len);
} else {
serverPanic("Wrong obj->encoding in addReply()");
}
}


/*
* 10.判断是否需要返回数据
* networking.c#prepareClientToWrite
*prepareClientToWrite 首先根据客户端设置的标识进行一系列的判断,判断了当前 client是否需要返回数据:
* Lua 脚本执行的 client 则需要返回值;
* 如果客户端发送来 REPLY OFF 或者 SKIP 命令,则不需要返回值;
* 如果是主从复制时的主实例 client,则不需要返回值;
* 当前是在 AOF loading 状态的假 client,则不需要返回值。
* 接着如果这个 client 还未处于延迟等待写入 (CLIENT_PENDING_WRITE)的状态,则将其设置为该状态,并将其加入到 *Redis 的等待写入返回值客户端队列中,也就是 clients_pending_write队列。
*/
int prepareClientToWrite(client *c) {
// 如果是 lua client 则直接OK
if (c->flags & (CLIENT_LUA|CLIENT_MODULE)) return C_OK;
// 客户端发来过 REPLY OFF 或者 SKIP 命令,不需要发送返回值
if (c->flags & (CLIENT_REPLY_OFF|CLIENT_REPLY_SKIP)) return C_ERR;
// master 作为client 向 slave 发送命令,不需要接收返回值
if ((c->flags & CLIENT_MASTER) &&
!(c->flags & CLIENT_MASTER_FORCE_REPLY)) return C_ERR;
// AOF loading 时的假client 不需要返回值
if (c->fd <= 0) return C_ERR;

//如果当前客户端没有待写回数据,调用clientInstallWriteHandler函数
if (!clientHasPendingReplies(c)) clientInstallWriteHandler(c);
if (!clientHasPendingReplies(c) &&
!(c->flags & CLIENT_PENDING_WRITE) &&
(c->replstate == REPL_STATE_NONE ||
(c->replstate == SLAVE_STATE_ONLINE && !c->repl_put_online_on_ack)))
{

c->flags |= CLIENT_PENDING_WRITE;
listAddNodeHead(server.clients_pending_write,c);
}
// 表示已经在排队,进行返回数据
return C_OK;
}

void clientInstallWriteHandler(client *c) {
/*
* 1. 客户端没有设置过 CLIENT_PENDING_WRITE 标识,即没有被推迟过执行写操作;
* 2. 客户端所在实例没有进行主从复制,或者客户端所在实例是主从复制中的从节点,但全量复制的 RDB 文件已经传输完成,客户端可以接收请求。
*/
if (!(c->flags & CLIENT_PENDING_WRITE) &&
(c->replstate == REPL_STATE_NONE ||
(c->replstate == SLAVE_STATE_ONLINE && !c->repl_put_online_on_ack)))
{
//将客户端的标识设置为待写回,即CLIENT_PENDING_WRITE
c->flags |= CLIENT_PENDING_WRITE;
// 将客户端加入clients_pending_write列表,下次事件周期会创建事件进行返回值写入
listAddNodeHead(server.clients_pending_write,c);
}
}

beforeSleep 函数会调用 handleClientsWithPendingWrites 函数来处理 clients_pending_write 列表。

1
2
3
4
5
6
7
8
9
10
void aeMain(aeEventLoop *eventLoop) { // ae.c
eventLoop->stop = 0;
while (!eventLoop->stop) {
/* 如果有需要在事件处理前执行的函数,那么执行它 */
if (eventLoop->beforesleep != NULL)
eventLoop->beforesleep(eventLoop);
/* 开始处理事件*/
aeProcessEvents(eventLoop, AE_ALL_EVENTS|AE_CALL_AFTER_SLEEP);
}
}

handleClientsWithPendingWrites 方法会遍历 clients_pending_write 列表,对于每个 client 都会先调用 writeToClient 方法来尝试将返回数据从输出缓存区写入到 socekt中,如果还未写完,则只能调用 aeCreateFileEvent 方法来注册一个写数据事件处理器 sendReplyToClient,等待 Redis 事件机制的再次调用。

这样的好处是对于返回数据较少的客户端,不需要麻烦的注册写数据事件,等待事件触发再写数据到 socket,而是在下一次事件循环周期就直接将数据写到 socket中,加快了数据返回的响应速度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// 直接将返回值写到client的输出缓冲区中,不需要进行系统调用,也不需要注册写事件处理器
//networking.c#handleClientsWithPendingWrites
int handleClientsWithPendingWrites(void) {
listIter li;
listNode *ln;
// 获取系统延迟写队列的长度
int processed = listLength(server.clients_pending_write);

listRewind(server.clients_pending_write,&li);
// 依次处理
while((ln = listNext(&li))) {
client *c = listNodeValue(ln);
c->flags &= ~CLIENT_PENDING_WRITE;
listDelNode(server.clients_pending_write,ln);

// 将缓冲值写入client的socket中,如果写完,则跳过之后的操作。
if (writeToClient(c->fd,c,0) == C_ERR) continue;

// 还有数据未写入,只能注册写事件处理器了
if (clientHasPendingReplies(c)) {
int ae_flags = AE_WRITABLE;
if (server.aof_state == AOF_ON &&
server.aof_fsync == AOF_FSYNC_ALWAYS)
{
ae_flags |= AE_BARRIER;
}
// 注册写事件处理器 sendReplyToClient,等待执行
if (aeCreateFileEvent(server.el, c->fd, ae_flags,
sendReplyToClient, c) == AE_ERR)
{
freeClientAsync(c);
}
}
}
return processed;
}

而在下一次循环中,就会处理AE_WRITABLE事件,即调用sendReplyToClient进行处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
int aeProcessEvents(aeEventLoop *eventLoop, int flags){
...
if (!invert && fe->mask & mask & AE_READABLE) {
fe->rfileProc(eventLoop,fd,fe->clientData,mask);
fired++;
}

/* Fire the writable event. */
if (fe->mask & mask & AE_WRITABLE) {
if (!fired || fe->wfileProc != fe->rfileProc) {
fe->wfileProc(eventLoop,fd,fe->clientData,mask);
fired++;
}
}

/* If we have to invert the call, fire the readable event now
* after the writable one. */
if (invert && fe->mask & mask & AE_READABLE) {
if (!fired || fe->wfileProc != fe->rfileProc) {
fe->rfileProc(eventLoop,fd,fe->clientData,mask);
fired++;
}
}
...
}

初始化服务器

首先来看一下server.c中的main函数,里面由以下几个主要的部分(sentinel初始化的部分就不列出了):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int main(int argc, char **argv) {
...
initServerConfig();
...
loadServerConfig(configfile,options);
...
initServer();
...
loadDataFromDisk();
...
InitServerLast();
...
aeMain(server.el);
}

redis服务初始化分为六个阶段:①初始化服务配置;②载入配置选项;③服务器初始化;④还原数据库状态;⑤服务器最终初始化;⑥启动event loop

初始化服务器的第一步就是创建一个struct redisServer类型的实例变量server作为服务器的状态。并为结构中的各个属性设置默认值。

初始化服务配置

初始化server变量的工作由redis.c/initserverconfig函数完成,initserverconfig函数主要完成完成的主要工作:①设置服务器的运行ID;②设置服务器的默认运行频率;③设置服务器的默认配置文件路径;④设置服务器的运行架构;⑤设置服务器的默认端口号;⑥设置服务器的默认RDB持久化条件和AOF持久化条件;⑦初始化服务器的全局LRU时钟;⑧创建命令表

创建命令表:调用populateCommandTable函数对redis的命令表初始化。全局变量redisCommandTable是redisCommand类型的数组,保存redis支持的所有命令。server.commands是一个dict,保存命令名到redisCommand的映射。populateCommandTable函数会遍历全局redisCommandTable表,把每条命令插入到server.commands中,根据每个命令的属性设置其flags。

initserverconfig函数设置的服务器状态属性基本都是一些整数、浮点数、或者字符串属性,除了命令表之外,initServerConfig函数没有创建服务器状态的其他数据结构,数据库、慢查询日志、Lua环境、共享对象这些数据结构在之后的initServer中才会被创建出来。

当initserverConfig函数执行完毕之后,服务器就可以进入初始化的第二个阶段一载人配置选项。

载入配置选项

在初始化server变量之后,解析服务器启动时从命令行传入的参数,如果服务器启动时指定了配置文件,则这里就会开始载入用户给定的配置参数和配置文件redis.conf,并根据用户设定的配置,对server变量相关属性值进行更新。

会调用loadServerConfigFromString进行解析,会有多个分支,每一个分支处理每一种配置,而且其中一个分支还会处理sentinel的配置文件。

服务初始化

initServerConfig函数初始化时,程序只创建了命令表一个数据结构,除了这个命令表外,服务器状态还包括其它数据结构需要设置,以及集群的初始化,因此initServer函数的工作如下:

(1) 设置信号处理函数:忽略SIGHUP和SIGPIPE两种信号,设置信号SIGTERM的信号处理函数为sigtermHandler,即进程在收到该信号时打印信息,终止服务器;

(2) 服务器的当前客户端(server.current_client)设置为NULL;

(3) 服务器的客户端链表(server.clients)初始化,这个链表记录了所有服务器相连的客户端状态结构;

(4) 服务器的待异步关闭的客户端链表(server.clients_to_close)初始化,这个链表记录了所有待异步关闭的客户端,一般在serverCron函数中关闭;

(5) 服务器的从服务器链表(server.slaves)初始化,这个链表保存了所有从服务器;

(6) 服务器的监视器链表(server.monitors)初始化,这个链表保存了所有监视器,即哨兵服务器;

(7) 服务器的未阻塞的客户端链表(server.unblocked_clients)初始化,这个链表保存了下一个循环前未阻塞的客户端;

(8) 服务器的等待回复的客户端链表(server.clients_waiting_acks)初始化,这个链表保存了等待回复的客户端列表,即阻塞在“WAIT”命令上的客户端,“WAIT”命令表示主从服务器同步复制,客户端的命令中如果带有“WAIT”,用户指定至少多少个replication成功以及超时时间;

(9) 服务器的客户端中是否有等待slave回复的客户端(server.get_ack_from_slaves)初始化为0,如果为1,表示有客户端正在等待从服务器slave的回复;

(10) 调用函数createSharedObjects()创建共享对象,通过复用来减少内存碎片:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct sharedObjectsStruct {
robj *crlf, *ok, *err, *emptybulk, *czero, *cone, *cnegone, *pong, *space,
*colon, *nullbulk, *nullmultibulk, *queued,
*emptymultibulk, *wrongtypeerr, *nokeyerr, *syntaxerr, *sameobjecterr,
*outofrangeerr, *noscripterr, *loadingerr, *slowscripterr, *bgsaveerr,
*masterdownerr, *roslaveerr, *execaborterr, *noautherr, *noreplicaserr,
*busykeyerr, *oomerr, *plus, *messagebulk, *pmessagebulk, *subscribebulk,
*unsubscribebulk, *psubscribebulk, *punsubscribebulk, *del, *rpop, *lpop,
*lpush, *emptyscan, *minstring, *maxstring,
*select[REDIS_SHARED_SELECT_CMDS],
*integers[REDIS_SHARED_INTEGERS],
*mbulkhdr[REDIS_SHARED_BULKHDR_LEN], /* "*<value>\r\n" */
*bulkhdr[REDIS_SHARED_BULKHDR_LEN]; /* "$<value>\r\n" */
};

(11) 调用函数adjustOpenFilesLimit()根据服务器最大客户端数量(server.maxclients)调整一个进程最大可以打开的文件个数

(12) 调用函数aeCreateEventLoop()初始化服务器的事件循环结构体(server.el)(注意serverCron和acceptTcpHandler):

1
server.el = aeCreateEventLoop(server.maxclients+CONFIG_FDSET_INCR);

函数中需要对结构体进行初始化,包括:

  • 初始化文件事件结构体数组:eventLoop->events
  • 初始化已就绪文件事件结构体数组:eventLoop->fired
  • 设置已追踪的最大描述符大小为server.maxclients + REDIS_EVENTLOOP_FDSET_INCR
  • 初始化时间时间结构eventLoop->timeEventHead、eventLoop->timeEventNextId等
  • 调用函数aeApiCreate()创建epoll句柄,并初始化eventLoop->apidata
  • 调用函数 listenToPort()创建套接字,并且打开监听端口

(13) 根据数据库的数量(server.dbnum)给服务器的数据库数组(server.db)分配内存,这个数组包含了服务器的所有数据库,并且初始化数组中每个元素的所有字典项:

1
2
3
4
5
6
7
8
9
10
typedef struct redisDb {
dict *dict; // 数据库键空间,保存着数据库中的所有键值对
dict *expires; // 键的过期时间,字典的键为键,字典的值为过期事件 UNIX 时间戳
dict *blocking_keys; // 正处于阻塞状态的键
dict *ready_keys; // 可以解除阻塞的键
dict *watched_keys; // 正在被 WATCH 命令监视的键
struct evictionPoolEntry *eviction_pool; /* Eviction pool of keys */
int id; // 数据库号码
long long avg_ttl; // 数据库的键的平均 TTL ,统计信息
} redisDb;

(14) 初始化server.pubsub_channels和server.pubsub_patterns:用于保存频道订阅信息和模式订阅信息

(15) 初始化server.lua:保存用于执行Lua脚本的Lua环境

(16) 初始化server.slowlog:用于保存慢查询日志

(17) 调用函数aeCreateTimeEvent()注册时间事件,将函数serverCron()注册给时间事件处理器,并且设定1ms后执行这个函数,并且将注册的时间时间放在事件处理器的时间事件链表的表头节点.

1
2
3
4
if (aeCreateTimeEvent(server.el, 1, serverCron, NULL, NULL) == AE_ERR) {
serverPanic("Can't create event loop timers.");
exit(1);
}

(18) 调用函数aeCreateFileEvent()为监听套接字关联连接应答处理器(acceptTcpHandler),这个函数做的事儿为:

  • 将监听的套接字加入到epoll的句柄中,并且将事件类型mask设置为AE_READABLE;
  • 取出套接字在事件处理器中对应的文件事件aeFileEvent,并且初始化文件事件的事件类型mask设置AE_READABLE,文件事件的读事件处理器和写事件处理器设置为acceptTcpHandler()。
1
2
3
4
5
6
7
8
9
10
for (j = 0; j < server.ipfd_count; j++) {
if (aeCreateFileEvent(server.el, server.ipfd[j], AE_READABLE,
acceptTcpHandler,NULL) == AE_ERR)
{
serverPanic(
"Unrecoverable error creating server.ipfd file event.");
}
}
if (server.sofd > 0 && aeCreateFileEvent(server.el,server.sofd,AE_READABLE,
acceptUnixHandler,NULL) == AE_ERR) serverPanic("Unrecoverable error creating server.sofd file event.");

(19) 如果AOF持久化功能打开,那么打开现有的AOF文件,如果AOF文件不存在,那么创建并打开一个新的AOF文件,为AOF写入做好准备。

(20) 如果是32位的架构,则设置服务器的最大内存(server.maxmemory)为3GB,内存淘汰策略为REDIS_MAXMEMORY_NO_EVICTION,如果是64位架构,则没这个限制

(21) 初始化Lua脚本系统:scriptingInit()

(22) 初始化慢查询功能:slowlogInit()

(23) 初始化服务器的后台异步I/O模块,为将来的I/O操作做好准备:bioInit(),这里的BIO模块其实是创建一个线程池,线程数量大小为2(REDIS_BIO_NUM_OPS),每个线程会处理一种类型的后台处理任务,分别是关闭文件任务和调用fdatasync,将AOF文件缓冲区的内容写入到磁盘 。线程池中需要使用到两个互斥量,分别用于对两个任务队列进行加锁,需要使用到两个条件变量。

还原数据库状态

完成对server变量的初始化之后,服务器需要载入RDB文件或者AOF文件,并根据文件记录的内容来还原服务器的数据库状态。

  1. 如果开启了AOF持久化功能,那么会优先使用AOF文件来恢复数据库,调用函数为:loadAppendOnlyFile()。
  2. 如果没有开启AOF持久化功能,就会使用RDB文件来恢复数据库,调用函数为:rdbLoad()。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
void loadDataFromDisk(void) {
long long start = ustime();
//如果开启了aof,调用loadAppendOnlyFile进行加载
if (server.aof_state == AOF_ON) {
if (loadAppendOnlyFile(server.aof_filename) == C_OK)
serverLog(LL_NOTICE,"DB loaded from append only file: %.3f seconds",(float)(ustime()-start)/1000000);
} else {
//否则,调用rdbLoad进行加载
rdbSaveInfo rsi = RDB_SAVE_INFO_INIT;
if (rdbLoad(server.rdb_filename,&rsi) == C_OK) {
serverLog(LL_NOTICE,"DB loaded from disk: %.3f seconds",
(float)(ustime()-start)/1000000);

/* Restore the replication ID / offset from the RDB file. */
if ((server.masterhost ||
(server.cluster_enabled &&
nodeIsSlave(server.cluster->myself))) &&
rsi.repl_id_is_set &&
rsi.repl_offset != -1 &&
/* Note that older implementations may save a repl_stream_db
* of -1 inside the RDB file in a wrong way, see more
* information in function rdbPopulateSaveInfo. */
rsi.repl_stream_db != -1)
{
memcpy(server.replid,rsi.repl_id,sizeof(server.replid));
server.master_repl_offset = rsi.repl_offset;
/* If we are a slave, create a cached master from this
* information, in order to allow partial resynchronizations
* with masters. */
replicationCacheMasterUsingMyself();
selectDb(server.cached_master,rsi.repl_stream_db);
}
} else if (errno != ENOENT) {
serverLog(LL_WARNING,"Fatal error loading the DB: %s. Exiting.",strerror(errno));
exit(1);
}
}
}

服务器最终初始化

服务器初始化过程最后会调用InitServerLast函数,这个函数就两个作用:①调用bioInit()初始化三个常驻BIO线程 ;②设置服务器内存使用量

1
2
3
4
5
6
void InitServerLast() {
//初始化三个常驻BIO线程
bioInit();
//设置服务器内存使用量
server.initial_memory_usage = zmalloc_used_memory();
}

bioInit 函数是在bio.c文件中实现的,它的主要作用就是初始化BIO线程使用的数据结构,以及调用 pthread_create 函数创建三个常驻BIO线程:

  • BIO_CLOSE_FILE:关闭文件任务;
  • BIO_AOF_FSYNC:AOF新增数据刷盘任务;
  • BIO_LAZY_FREE:惰性删除任务。

数据过期expire

命令

一般情况下,我们设置保存的缓存数据的时候都会设置一个过期时间。为什么呢?

因为内存是有限的,如果缓存中的所有数据都是一直保存的话,分分钟直接Out of memory。

Redis 自带了给缓存数据设置过期时间的功能,比如:

1
2
3
4
5
6
127.0.0.1:6379> exp key  60 # 数据在 60s 后过期
(integer) 1
127.0.0.1:6379> setex key 60 value # 数据在 60s 后过期 (setex:[set] + [ex]pire)
OK
127.0.0.1:6379> ttl key # 查看数据还有多久过期
(integer) 56

注意:**Redis中除了字符串类型有自己独有设置过期时间的命令 setex 外,其他方法都需要依靠 expire 命令来设置过期时间 。另外, persist 命令可以移除一个键的过期时间。 **

过期时间除了有助于缓解内存的消耗,还有什么其他用么?

很多时候,我们的业务场景就是需要某个数据只在某一时间段内存在,比如我们的短信验证码可能只在1分钟内有效,用户登录的 token 可能只在 1 天内有效。

如果使用传统的数据库来处理的话,一般都是自己判断过期,这样更麻烦并且性能要差很多。

保存过期时间

Redis 通过一个保存在redisDb中的expires字典来保存数据过期的时间。

  • 过期字典的键指向Redis数据库中的某个key对象
  • 过期字典的值是一个long long类型的整数,这个整数保存了key所指向的数据库键的过期时间,是一个毫秒精度的UNIX时间戳。

过期字典是存储在redisDb这个结构里的:

1
2
3
4
5
6
7
typedef struct redisDb {
...

dict *dict; //数据库键空间,保存着数据库中所有键值对
dict *expires // 过期字典,保存着键的过期时间
...
} redisDb;

过期键判定

通过过期字典,程序可以用以下步骤检查一个给定键是否过期:

  • 检查给定键是否存在于过期字典:如果存在,那么取得键的过期时间。
  • 检查当前UNIX时间截是否大于键的过期时间:如果是的话,那么键已经过期;否则的话,键未过期。

过期键删除策略

如果假设你设置了一批 key 只能存活 1 分钟,那么 1 分钟后,Redis 是怎么对这批 key 进行删除的呢?

常用的过期数据的删除策略就两个(重要!自己造缓存轮子的时候需要格外考虑的东西):

  1. 定时删除:在设置键的过期时间的同时,创建一个定时器( timer ),让定时器在键的过期时间来临时,立即执行对键的删除操作。
  2. 惰性删除 :只会在取出key的时候才对数据进行过期检查。这样对CPU最友好,但是可能会造成太多过期 key 没有被删除。
  3. 定期删除 : 每隔一段时间抽取一批 key 执行删除过期key操作。并且,Redis 底层会通过限制删除操作执行的时长和频率来减少删除操作对CPU时间的影响。

定时删除对内存友好,但对CPU最不友好。惰性删除对CPU更加友好。而定期删除是前两种的整合和折中,但是间隔时间不好控制,如果执行间隔太过频繁,就会变成定时删除,如果执行间隔较长,就会变成惰性删除。

Redis 采用的是 定期删除+惰性/懒汉式删除

但是,仅仅通过给 key 设置过期时间还是有问题的。因为还是可能存在定期删除和惰性删除漏掉了很多过期 key 的情况。这样就导致大量过期 key 堆积在内存里,然后就Out of memory了。

怎么解决这个问题呢?答案就是: Redis 内存淘汰机制。

RDB和AOF以及复制

RDB:

  • 生成RDB:在执行SAVE命令或者BGSAVE命令创建一个新的RDB文件时,程序会对数据库中的键进行检查,已过期的键不会被保存到新创建的RDB文件中。
  • 读取RDB:在服务器启动时,若服务器是以主服务器模式运行,载入RDB文件时会对保存的键进行检查,过期的键不会被载入数据库;若服务器是以从服务器模式运行,载入RDB文件时不管是否过期都载入,因为当主服务器进行数据同步时从服务器数据库会被清空。

AOF:

  • 写入:当服务器以AOF持久化模式运行时,如果数据库中的某个键已经过期,但它还没有被惰性删除或者定期删除,那么AOF文件不会因为这个过期键而产生任何影响。当过期键被惰性删除或者定期删除之后,程序会向AOF文件追加( append)一条DEL命令,来显式地记录该键已被删除。
  • 重写:和生成RDB文件时类似,在执行AOF重写的过程中,程序会对数据库中的键进行检查,已过期的键不会被保存到重写后的AOF文件中。(但我看redis5.0中的rewriteAppendOnlyFileRio源码部分依然是写入的,redis3.0是跳过的)

Redis 内存淘汰机制

相关问题:MySQL 里有 2000w 数据,Redis 中只存 20w 的数据,如何保证 Redis 中的数据都是热点数据?

Redis 提供 6 种数据淘汰策略:

  1. volatile-lru(least recently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰
  2. volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰
  3. volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰
  4. allkeys-lru(least recently used):当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的 key(这个是最常用的)
  5. allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
  6. no-eviction:禁止驱逐数据,也就是说当内存不足以容纳新写入数据时,新写入操作会报错。这个应该没人使用吧!

4.0 版本后增加以下两种:

  1. volatile-lfu(least frequently used):从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常使用的数据淘汰
  2. allkeys-lfu(least frequently used):当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的 key

lfu并不是简单地将使用的数据计数器+1,而是使用了对数递增计数值的方法,这一点要注意!

只有设置了maxmemory,才能设置maxmemory-policy配置项决定内存淘汰机制。

LRU源码分析

LRU的基本原理

从基本原理上来说,LRU 算法会使用一个链表来维护缓存中每一个数据的访问情况,并根据数据的实时访问,调整数据在链表中的位置,然后通过数据在链表中的位置,来表示数据是最近刚访问的,还是已经有一段时间没有访问了。

而具体来说,LRU 算法会把链表的头部和尾部分别设置为 MRU 端和 LRU 端。其中,MRU 是 Most Recently Used 的缩写,MRU 端表示这里的数据是刚被访问的。而 LRU 端则表示,这里的数据是最近最少访问的数据。

LRU 算法的执行,可以分成三种情况:

  1. 当有新数据插入时,LRU 算法会把该数据插入到链表头部,同时把原来链表头部的数据及其之后的数据,都向尾部移动一位;
  2. 当有数据刚被访问了一次之后,LRU 算法就会把该数据从它在链表中的当前位置,移动到链表头部。同时,把从链表头部到它当前位置的其他数据,都向尾部移动一位;
  3. 当链表长度无法再容纳更多数据时,若再有新数据插入,LRU 算法就会去除链表尾部的数据,这也相当于将数据从缓存中淘汰掉。

所以你其实可以发现,如果要严格按照 LRU 算法的基本原理来实现的话,你需要在代码中实现如下内容:

  • 要为 Redis 使用最大内存时,可容纳的所有数据维护一个链表;
  • 每当有新数据插入或是现有数据被再次访问时,需要执行多次链表操作。

而假设 Redis 保存的数据比较多的话,那么,这两部分的代码实现,就既需要额外的内存空间来保存链表,还会在访问数据的过程中,让 Redis 受到数据移动和链表操作的开销影响,从而就会降低 Redis 访问性能。

所以说,无论是为了节省宝贵的内存空间,还是为了保持 Redis 高性能,Redis 源码并没有严格按照 LRU 算法基本原理来实现它,而是提供了一个近似 LRU 算法的实现。

近似LRU算法

在了解 Redis 对近似 LRU 算法的实现之前,我们需要先来看下,Redis 的内存淘汰机制是如何启用近似 LRU 算法的,这可以帮助我们了解和近似 LRU 算法相关的配置项。

实际上,这和 Redis 配置文件 redis.conf 中的两个配置参数有关:

  • maxmemory,该配置项设定了 Redis server 可以使用的最大内存容量,一旦 server 使用的实际内存量超出该阈值时,server 就会根据 maxmemory-policy 配置项定义的策略,执行内存淘汰操作;

  • maxmemory-policy,该配置项设定了 Redis server 的内存淘汰策略,主要包括近似 LRU 算法、LFU 算法、按 TTL 值淘汰和随机淘汰等几种算法。

所以,一旦我们设定了 maxmemory 选项,并且将 maxmemory-policy 配置为 allkeys-lru 或是 volatile-lru 时,近似 LRU 算法就被启用了。

近似 LRU 算法的实现分成了三个部分:

  • 全局 LRU 时钟值的计算:这部分包括,Redis 源码为了实现近似 LRU 算法的效果,是如何计算全局 LRU 时钟值的,以用来判断数据访问的时效性;
  • 键值对 LRU 时钟值的初始化与更新:这部分包括,Redis 源码在哪些函数中对每个键值对对应的 LRU 时钟值,进行初始化与更新;
  • 近似 LRU 算法的实际执行:这部分包括,Redis 源码具体如何执行近似 LRU 算法,也就是何时触发数据淘汰,以及实际淘汰的机制是怎么实现的。
全局LRU时钟值的计算

在前面的介绍中,我们已经知道了每一个键值对的值都是一个redisObject,而这个对象中有一个24位的lru属性,保存了该键值对最近一次访问的时间戳。

1
2
3
4
5
6
7
8
9
typedef struct redisObject {
unsigned type:4;
unsigned encoding:4;
unsigned lru:LRU_BITS; /* LRU time (relative to global lru_clock) or
* LFU data (least significant 8 bits frequency
* and most significant 16 bits access time). */
int refcount;
void *ptr;
} robj;

那么,每个键值对的 LRU 时钟值具体是如何计算的呢?其实,Redis server 使用了一个实例级别的全局 LRU 时钟,每个键值对的 LRU 时钟值会根据全局 LRU 时钟进行设置。

这个全局 LRU 时钟保存在了 Redis 全局变量 server 的成员变量 lruclock 中。当 Redis server 启动后,调用 initServerConfig 函数初始化各项参数时,就会对这个全局 LRU 时钟 lruclock 进行设置。具体来说,initServerConfig 函数是调用 getLRUClock 函数,来设置 lruclock 的值,如下所示:

1
2
3
4
// 调用getLRUClock函数计算全局LRU时钟值
unsigned int lruclock = getLRUClock();
//设置lruclock为刚计算的LRU时钟值
atomicSet(server.lruclock,lruclock);

所以,全局 LRU 时钟值就是通过 getLRUClock 函数计算得到的。getLRUClock 函数是在evict.c文件中实现的:

1
2
3
4
5
6
/* Return the LRU clock, based on the clock resolution. This is a time
* in a reduced-bits format that can be used to set and check the
* object->lru field of redisObject structures. */
unsigned int getLRUClock(void) {
return (mstime()/LRU_CLOCK_RESOLUTION) & LRU_CLOCK_MAX;
}

上诉相关的宏都定义在server.h中:

1
2
3
#define LRU_BITS 24
#define LRU_CLOCK_MAX ((1<<LRU_BITS)-1) /* Max value of obj->lru */
#define LRU_CLOCK_RESOLUTION 1000 /* LRU clock resolution in ms */
  • LRU_BITS定义了redisObject中lru的位数;
  • LRU_CLOCK_RESOLUTION表示的是以毫秒为单位的 LRU 时钟精度,也就是以毫秒为单位来表示的 LRU 时钟最小单位。因为 LRU_CLOCK_RESOLUTION 的默认值是 1000,所以,LRU 时钟精度就是 1000 毫秒,也就是 1 秒。这样一来,你需要注意的就是,如果一个数据前后两次访问的时间间隔小于 1 秒,那么这两次访问的时间戳就是一样的。因为 LRU 时钟的精度就是 1 秒,它无法区分间隔小于 1 秒的不同时间戳;
  • LRU_CLOCK_MAX 表示的是 LRU 时钟能表示的最大值。

现在再来看看上方的getLRUClock,先调用 mstime 函数(在server.c文件中)获得以毫秒为单位计算的 UNIX 时间戳,除以 LRU_CLOCK_RESOLUTION 后得到对应精度的时间戳,再与LRU_CLOCK_MAX进行与运算,得到最终的时间戳。(与运算其实就相当于取模运算)

而该全局LRU时钟值的更新操作则是在serverCron 中。serverCron作为一个周期性事件,会定期进行执行,因此全局 LRU 时钟值就会按照这个函数的执行频率,定期调用 getLRUClock 函数进行更新。这也是为什么全局LRU时钟值要设置一个精度的原因。

键值对 LRU 时钟值的初始化与更新

首先,对于一个键值对来说,它的 LRU 时钟值最初是在这个键值对被创建的时候,进行初始化设置的,这个初始化操作是在 createObject 函数中调用的。createObject 函数实现在object.c文件当中,当 Redis 要创建一个键值对时,就会调用这个函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
robj *createObject(int type, void *ptr) {
robj *o = zmalloc(sizeof(*o));
o->type = type;
o->encoding = OBJ_ENCODING_RAW;
o->ptr = ptr;
o->refcount = 1;

/* Set the LRU to the current lruclock (minutes resolution), or
* alternatively the LFU counter. */
if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
o->lru = (LFUGetTimeInMinutes()<<8) | LFU_INIT_VAL;
} else {
o->lru = LRU_CLOCK();
}
return o;
}

而 createObject 函数除了会给 redisObject 结构体分配内存空间之外,它还会根据我刚才提到的 maxmemory_policy 配置项的值,来初始化设置 redisObject 结构体中的 lru 变量:

  • 如果 maxmemory_policy 配置为使用 LFU 策略,那么 lru 变量值会被初始化设置为 LFU 算法的计算值;
  • 如果 maxmemory_policy 配置项没有使用 LFU 策略,那么,createObject 函数就会调用 LRU_CLOCK 函数来设置 lru 变量的值,也就是键值对对应的 LRU 时钟值。

LRU_CLOCK 函数是在 evict.c 文件中实现的,它的作用就是返回当前的全局 LRU 时钟值。因为一个键值对一旦被创建,也就相当于有了一次访问,所以它对应的 LRU 时钟值就表示了它的访问时间戳。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* This function is used to obtain the current LRU clock.
* If the current resolution is lower than the frequency we refresh the
* LRU clock (as it should be in production servers) we return the
* precomputed value, otherwise we need to resort to a system call. */
unsigned int LRU_CLOCK(void) {
unsigned int lruclock;
//如果serverCron平均每次的执行时间小于设置的精度,则直接获取全局的LRU时钟值
if (1000/server.hz <= LRU_CLOCK_RESOLUTION) {
atomicGet(server.lruclock,lruclock);
} else {
//否则,直接获取最新的LRU时钟值
lruclock = getLRUClock();
}
return lruclock;
}

那么到这里,又出现了一个新的问题:一个键值对的 LRU 时钟值又是在什么时候被再次更新的呢

其实,只要一个键值对被访问了,它的 LRU 时钟值就会被更新。而当一个键值对被访问时,访问操作最终都会调用 lookupKey 函数。lookupKey 函数是在db.c文件中实现的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/* Low level key lookup API, not actually called directly from commands
* implementations that should instead rely on lookupKeyRead(),
* lookupKeyWrite() and lookupKeyReadWithFlags(). */
robj *lookupKey(redisDb *db, robj *key, int flags) {
dictEntry *de = dictFind(db->dict,key->ptr);
if (de) {
// 获取键值对对应的redisObject结构体
robj *val = dictGetVal(de);

/* Update the access time for the ageing algorithm.
* Don't do it if we have a saving child, as this will trigger
* a copy on write madness. */
if (!hasActiveChildProcess() && !(flags & LOOKUP_NOTOUCH)){
if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
// 如果使用了LFU策略,调用updateLFU函数更新lru值
updateLFU(val);
} else {
// 否则,调用LRU_CLOCK函数获取全局LRU时钟值
val->lru = LRU_CLOCK();
}
}
return val;
} else {
return NULL;
}
}

它会从全局哈希表中查找要访问的键值对。如果该键值对存在,那么 lookupKey 函数就会根据 maxmemory_policy 的配置值,来更新键值对的 LRU 时钟值,也就是它的访问时间戳:

  • 如果 maxmemory_policy 配置为使用 LFU 策略,那么修改LFU计数值,也就是修改lru属性;
  • 如果 maxmemory_policy 配置项没有使用 LFU 策略,lookupKey 函数就会调用 LRU_CLOCK 函数,来获取当前的全局 LRU 时钟值,并将其赋值给键值对的 redisObject 结构体中的 lru 变量。
近似 LRU 算法的实际执行

现在我们已经知道,Redis 之所以实现近似 LRU 算法的目的,是为了减少内存资源和操作时间上的开销。那么在这里,我们其实可以从两个方面来了解近似 LRU 算法的执行过程,分别是:

  • 何时触发算法执行?
  • 算法具体如何执行?

何时触发算法执行?

首先,近似 LRU 算法的主要逻辑是在 freeMemoryIfNeeded 函数中实现的,而这个函数本身是在 evict.c 文件中实现。freeMemoryIfNeeded 函数是被 freeMemoryIfNeededAndSafe 函数(在 evict.c 文件中)调用,而 freeMemoryIfNeededAndSafe 函数又是被 processCommand 函数所调用的。

server.c#processCommand:

1
2
3
if (server.maxmemory && !server.lua_timedout) {
//如果设置了maxmemory 配置项为非 0 值,且Lua 脚本没有在超时运行则判断是否要进行内存的的清理,具体的清理根据内存淘汰策略会有所不同,具体看内存淘汰机制。
int out_of_memory = freeMemoryIfNeededAndSafe() == C_ERR;

evict.c#freeMemoryIfNeededAndSafe:

1
2
3
4
5
int freeMemoryIfNeededAndSafe(void) {
if (server.lua_timedout || server.loading) return C_OK;
//Lua脚本没有超时运行,且Redis server没有在加载数据,则进一步判断是否要进行内存的清理
return freeMemoryIfNeeded();
}

算法具体如何执行?

lru相关的大概执行流程:

直接看evict.c#freeMemoryIfNeeder()函数(可以只看lru部分):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
/*
* 根据当前的“maxmemory”设置,定期调用此函数以查看是否有可用内存。 如果我们超过内存限制,该函数会根据不同的策略尝试释放一些内存以返回低于限制。
* 如果我们低于内存限制或超过限制但尝试释放内存成功,该函数将返回 C_OK。
* 否则,如果我们超过了内存限制,但没有足够的内存被释放以返回低于限制,则该函数返回 C_ERR。
*/
int freeMemoryIfNeeded(void) {
/* By default replicas should ignore maxmemory
* and just be masters exact copies. */
// 默认情况下,从节点应该忽略maxmemory指令,仅仅做从节点该做的事情就好
if (server.masterhost && server.repl_slave_ignore_maxmemory) return C_OK;

//mem_reported记录已使用的内存量,mem_tofree记录需要释放的内存量,mem_freed记录已经释放的内存量
size_t mem_reported, mem_tofree, mem_freed;
mstime_t latency, eviction_latency;
long long delta;
int slaves = listLength(server.slaves);

/* When clients are paused the dataset should be static not just from the
* POV of clients not being able to write, but also from the POV of
* expires and evictions of keys not being performed. */
// 当客户端暂停时,数据集应该是静态的,不仅来自客户端的 POV 无法写入,还有来自POV过期和驱逐key也无法执行。
if (clientsArePaused()) return C_OK;
// 检查内存状态,有没有超出限制,如果有,会计算需要释放的内存和已使用内存,并返回C_ERR。C_OK则表示没有超过限制,直接返回。
if (getMaxmemoryState(&mem_reported,NULL,&mem_tofree,NULL) == C_OK)
return C_OK;

mem_freed = 0;

/*
* 1. 如果是禁止驱逐数据策略,则直接结束,接下来新写入会报错
*/
if (server.maxmemory_policy == MAXMEMORY_NO_EVICTION)
goto cant_free; /* We need to free memory, but policy forbids. */

latencyStartMonitor(latency);

/*
* 根据 maxmemory 策略,遍历字典,释放内存并记录被释放内存的字节数
*/
while (mem_freed < mem_tofree) {
int j, k, i, keys_freed = 0;
static unsigned int next_db = 0;
sds bestkey = NULL; // 最佳淘汰key
int bestdbid; //最佳淘汰key所属的dbid
redisDb *db;
dict *dict;
dictEntry *de;

//先选择最佳淘汰key

/*
* 2. 如果是LRU策略、LFU策略、VOLATILE_TTL策略
*/
if (server.maxmemory_policy & (MAXMEMORY_FLAG_LRU|MAXMEMORY_FLAG_LFU) ||
server.maxmemory_policy == MAXMEMORY_VOLATILE_TTL)
{
// 创建一个淘汰池,该数组的大小由宏定义 EVPOOL_SIZE 决定,默认是 16 个元素
struct evictionPoolEntry *pool = EvictionPoolLRU;

while(bestkey == NULL) {
unsigned long total_keys = 0, keys;


// 从每个数据库抽样key填充淘汰池
for (i = 0; i < server.dbnum; i++) {
//server的db是一个db数组
db = server.db+i;
// 判断淘汰策略是否是针对所有键的,从而选取dict还是expires进行抽样
dict = (server.maxmemory_policy & MAXMEMORY_FLAG_ALLKEYS) ?
db->dict : db->expires;
// 计算字典元素数量,不为0才可以挑选key
if ((keys = dictSize(dict)) != 0) {
// 填充淘汰池,四个参数分别为dbid,候选集合,主字典集合,淘汰池
// 填充完的淘汰池内部是有序的,按空闲时间升序
evictionPoolPopulate(i, dict, db->dict, pool);
total_keys += keys;
}
}
// 如果 total_keys = 0,没有要淘汰的key(redis没有key或者没有设置过期时间的key),break
if (!total_keys) break; /* No keys to evict. */

// 遍历淘汰池,从淘汰池末尾(空闲时间最长)开始向前迭代
for (k = EVPOOL_SIZE-1; k >= 0; k--) {
if (pool[k].key == NULL) continue;
bestdbid = pool[k].dbid;

if (server.maxmemory_policy & MAXMEMORY_FLAG_ALLKEYS) {
// 如果淘汰策略针对所有key,从 redisDb.dict 中获取当前key的entry
de = dictFind(server.db[pool[k].dbid].dict,
pool[k].key);
} else {
//否则,从 redisDb.expires 中获取当前key的entry
de = dictFind(server.db[pool[k].dbid].expires,
pool[k].key);
}

// 从池中删除这个key,不管这个key还在不在(这个节点可能已经不存在了,比如到了过期时间被删除了)
if (pool[k].key != pool[k].cached)
sdsfree(pool[k].key);
pool[k].key = NULL;
pool[k].idle = 0;


// 如果这个节点存在,就跳出这个循环,否则尝试下一个元素
if (de) {
bestkey = dictGetKey(de);
break;
} else {
/* Ghost... Iterate again. */
}
}
}
}

/*
* 3. 如果是volatile-random、allkeys-random策略,就轮询每一个db,并从对应的dict中随机选取一个key
*/
else if (server.maxmemory_policy == MAXMEMORY_ALLKEYS_RANDOM ||
server.maxmemory_policy == MAXMEMORY_VOLATILE_RANDOM)
{
/* When evicting a random key, we try to evict a key for
* each DB, so we use the static 'next_db' variable to
* incrementally visit all DBs. */
for (i = 0; i < server.dbnum; i++) {
j = (++next_db) % server.dbnum;
db = server.db+j;
dict = (server.maxmemory_policy == MAXMEMORY_ALLKEYS_RANDOM) ?
db->dict : db->expires;
if (dictSize(dict) != 0) {
de = dictGetRandomKey(dict);
bestkey = dictGetKey(de);
bestdbid = j;
break;
}
}
}


// 如果选择了一个最佳淘汰key则进行删除
if (bestkey) {
db = server.db+bestdbid;
robj *keyobj = createStringObject(bestkey,sdslen(bestkey));
//将删除key的信息传递给从库和AOF文件
propagateExpire(db,keyobj,server.lazyfree_lazy_eviction);

delta = (long long) zmalloc_used_memory();
latencyStartMonitor(eviction_latency);
// 是否开启lazyfree机制
// lazyfree的原理就是在删除对象时只是进行逻辑删除,然后把对象丢给后台,让后台线程去执行真正的destruct,避免由于对象体积过大而造成阻塞。
if (server.lazyfree_lazy_eviction)
dbAsyncDelete(db,keyobj);
else
dbSyncDelete(db,keyobj);
latencyEndMonitor(eviction_latency);
latencyAddSampleIfNeeded("eviction-del",eviction_latency);
latencyRemoveNestedEvent(latency,eviction_latency);
// 计算删除key后的内存变化量
delta -= (long long) zmalloc_used_memory();
// 计算已释放内存
mem_freed += delta;
server.stat_evictedkeys++;
notifyKeyspaceEvent(NOTIFY_EVICTED, "evicted",
keyobj, db->id);
decrRefCount(keyobj);
keys_freed++;

/* When the memory to free starts to be big enough, we may
* start spending so much time here that is impossible to
* deliver data to the slaves fast enough, so we force the
* transmission here inside the loop. */
if (slaves) flushSlavesOutputBuffers();

/*
* 通常我们的停止条件是能够释放固定的、预先计算的内存量。
* 然而,当我们在另一个线程中删除对象时,最好不时检查我们是否已经到达我们的目标内存,因为“mem_freed”数量仅在 dbAsyncDelete() 调用中计算,而线程可以无时无刻释放内存
*/
if (server.lazyfree_lazy_eviction && !(keys_freed % 16)) {
//如果内存没有超出限制
if (getMaxmemoryState(NULL,NULL,NULL,NULL) == C_OK) {
// 手动满足停止条件
mem_freed = mem_tofree;
}
}
}

if (!keys_freed) {
latencyEndMonitor(latency);
latencyAddSampleIfNeeded("eviction-cycle",latency);
goto cant_free; /* nothing to free... */
}
}
latencyEndMonitor(latency);
latencyAddSampleIfNeeded("eviction-cycle",latency);
return C_OK;

cant_free:
/* We are here if we are not able to reclaim memory. There is only one
* last thing we can try: check if the lazyfree thread has jobs in queue
* and wait... */
while(bioPendingJobsOfType(BIO_LAZY_FREE)) {
if (((mem_reported - zmalloc_used_memory()) + mem_freed) >= mem_tofree)
break;
usleep(1000);
}
return C_ERR;
}

在lru、lfu和ttl的实现中,有一个淘汰池(evict.c.)的概念,主要是用来存储待淘汰的key:

1
2
3
4
5
6
7
8
#define EVPOOL_SIZE 16
#define EVPOOL_CACHED_SDS_SIZE 255
struct evictionPoolEntry {
unsigned long long idle; // 待淘汰的键值对的空闲时间
sds key; // 待淘汰的键值对的key
sds cached; // 用来存储一个sds对象留待复用,注意我们要复用的是sds的内存空间,只需关注cached的长度(决定是否可以复用),无需关注他的内容
int dbid; // 待淘汰键值对的key所在的数据库ID
};

而对于每一个db,都需要往该淘汰池中进行填充key,具体看evict.c#evictionPoolPopulate:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
/*
* 这是 freeMemoryIfNeeded() 的辅助函数,用于在每次我们想要key过期时用一些条目填充 evictionPool。
* 添加空闲时间小于当前所有key空闲时间的key,如果池是空的则key会一直被添加
* 我们按升序将键依次插入,因此空闲时间较小的键在左侧,而空闲时间较长的键在右侧。
*/
void evictionPoolPopulate(int dbid, dict *sampledict, dict *keydict, struct evictionPoolEntry *pool) {
int j, k, count;
// 初始化抽样集合,大小为 server.maxmemory_samples
dictEntry *samples[server.maxmemory_samples];

// 此函数对字典进行采样以从随机位置返回一些键
count = dictGetSomeKeys(sampledict,samples,server.maxmemory_samples);
for (j = 0; j < count; j++) {
unsigned long long idle;
sds key;
robj *o;
dictEntry *de;

de = samples[j];
key = dictGetKey(de);

/* 如果我们采样的字典不是主字典(而是过期的字典),我们需要在键字典中再次查找键以获得值对象。*/
if (server.maxmemory_policy != MAXMEMORY_VOLATILE_TTL) {
if (sampledict != keydict) de = dictFind(keydict, key);
o = dictGetVal(de);
}

/* 根据策略计算空闲时间*/
if (server.maxmemory_policy & MAXMEMORY_FLAG_LRU) {
// 使用近似的 LRU 算法返回未请求过该对象的最小毫秒数
idle = estimateObjectIdleTime(o);
} else if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
//LFU 算法其实是用 255 (255是次数的最大值)减去键值对的访问次数,会衰减一次键值对的访问次数,以便能更加准确地反映实际选择待淘汰数据时,数据的访问频率。
idle = 255-LFUDecrAndReturn(o);
} else if (server.maxmemory_policy == MAXMEMORY_VOLATILE_TTL) {
// idle = 一个固定值 - 该key的过期时间,过期时间越小,该值越大,越先淘汰
idle = ULLONG_MAX - (long)dictGetVal(de);
} else {
serverPanic("Unknown eviction policy in evictionPoolPopulate()");
}

//将当前的key插入淘汰池
k = 0;
// 遍历淘汰池,从左边开始,找到第一个空位置或者第一个空闲时间大于等于待选元素的下标,k是该元素的坐标
while (k < EVPOOL_SIZE &&
pool[k].key &&
pool[k].idle < idle) k++;
if (k == 0 && pool[EVPOOL_SIZE-1].key != NULL) {
/* 如果当前key的idle小于池子中下标为0 key的idle,则无法插入
* key == 0 说明上面的while循环一次也没有进入
* 要么第一个元素就是空的,要么所有已有元素的空闲时间都大于等于待插入元素的空闲时间(待插入元素比已有所有元素都优质)
* 又因为数组最后一个key不为空,因为是从左边开始插入的,所以排除了第一个元素是空的
*/
continue;
} else if (k < EVPOOL_SIZE && pool[k].key == NULL) {
//插入一个空位置,后序直接插入
} else {
//插入中间,现在 k 指向比要插入的元素空闲时间大的第一个元素
if (pool[EVPOOL_SIZE-1].key == NULL) {
//数组末尾有空位置,将所有元素从 k 向右移动到末尾,要先保存一下该cached
sds cached = pool[EVPOOL_SIZE-1].cached;
memmove(pool+k+1,pool+k,
sizeof(pool[0])*(EVPOOL_SIZE-k-1));
pool[k].cached = cached;
} else {
//右边没有可用的空间,在k-1处插入
k--;
//将k(包含)左侧的所有元素向左移动,因此我们丢弃空闲时间较小的元素。(注意:这里左侧一定是满的)
sds cached = pool[0].cached; /* Save SDS before overwriting. */
if (pool[0].key != pool[0].cached) sdsfree(pool[0].key);
memmove(pool,pool+1,sizeof(pool[0])*k);
pool[k].cached = cached;
}
}

/*
* 尝试重用在池条目中分配的缓存 SDS 字符串,因为分配和释放此对象的成本很高
* 注意真正要复用的sds内存空间,避免重新申请内存,而不是他的值
*/
int klen = sdslen(key);
// 判断字符串长度来决定是否复用sds
if (klen > EVPOOL_CACHED_SDS_SIZE) {
// 字符串长度大于cached大小,不能复用,复制一个新的 sds 字符串并赋值
pool[k].key = sdsdup(key);
} else {
//能复用
//内存拷贝函数,从数据源拷贝num个字节的数据到目标数组
memcpy(pool[k].cached,key,klen+1);
//重新设置sds长度
sdssetlen(pool[k].cached,klen);
pool[k].key = pool[k].cached;
}
pool[k].idle = idle;
pool[k].dbid = dbid;
}
}

LFU源码分析

LFU的基本原理

LFU叫做最不频繁使用算法,LFU 算法在进行数据淘汰时,会把最不频繁访问的数据淘汰掉。

因为 LFU 算法是根据数据访问的频率来选择被淘汰数据的,所以 LFU 算法会记录每个数据的访问次数。当一个数据被再次访问时,就会增加该数据的访问次数。

不过,访问次数和访问频率还不能完全等同。访问频率是指在一定时间内的访问次数,也就是说,在计算访问频率时,我们不仅需要记录访问次数,还要记录这些访问是在多长时间内执行的。否则,如果只记录访问次数的话,就缺少了时间维度的信息,进而就无法按照频率来淘汰数据了。

LFU的实现

和上一节中介绍的 LRU 算法类似,LFU 算法的启用,是通过设置 Redis 配置文件 redis.conf 中的 maxmemory 和 maxmemory-policy。其中,maxmemory 设置为 Redis 会用的最大内存容量,而 maxmemory-policy 可以设置为 allkeys-lfu 或是 volatile-lfu,表示淘汰的键值对会分别从所有键值对或是设置了过期时间的键值对中筛选。

LFU 算法的实现可以分成三部分内容,分别是键值对访问频率记录、键值对访问频率初始化和更新,以及 LFU 算法淘汰数据。

键值对访问频率记录

通过 LRU 算法的学习,现在我们已经了解到,每个键值对的值都对应了一个 redisObject 结构体,其中有一个 24 bits 的 lru 变量。lru 变量在 LRU 算法实现时,是用来记录数据的访问时间戳。因为 Redis server 每次运行时,只能将 maxmemory-policy 配置项设置为使用一种淘汰策略,所以,LRU 算法和 LFU 算法并不会同时使用。而为了节省内存开销,Redis 源码就复用了 lru 变量来记录 LFU 算法所需的访问频率信息

具体来说,当 lru 变量用来记录 LFU 算法的所需信息时,它会用 24 bits 中的低 8 bits 作为计数器,来记录键值对的访问次数,同时它会用 24 bits 中的高 16 bits,记录访问的时间戳

键值对访问频率的初始化与更新

其实LFU的一系列操作和LRU有着相同的入口函数:

对于键值对访问频率的初始化来说,当一个键值对被创建后,createObject 函数就会被调用,用来分配 redisObject 结构体的空间和设置初始化值:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
robj *createObject(int type, void *ptr) {
robj *o = zmalloc(sizeof(*o));
o->type = type;
o->encoding = OBJ_ENCODING_RAW;
o->ptr = ptr;
o->refcount = 1;

/* Set the LRU to the current lruclock (minutes resolution), or
* alternatively the LFU counter. */
if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
//高16位是unix的时间戳,低8位默认为5
o->lru = (LFUGetTimeInMinutes()<<8) | LFU_INIT_VAL;
} else {
o->lru = LRU_CLOCK();
}
return o;
}

/* Return the current time in minutes, just taking the least significant
* 16 bits. The returned time is suitable to be stored as LDT (last decrement
* time) for the LFU implementation. */
unsigned long LFUGetTimeInMinutes(void) {
//以1分钟为精度,且<2^(16),即为16位可以表示的值
return (server.unixtime/60) & 65535;
}

而 createObject 函数除了会给 redisObject 结构体分配内存空间之外,它还会根据我刚才提到的 maxmemory_policy 配置项的值,来初始化设置 redisObject 结构体中的 lru 变量:

  • 如果 maxmemory_policy 配置为使用 LFU 策略,那么 lru 变量值会被初始化设置为 LFU 算法的计算值;
  • 如果 maxmemory_policy 配置项没有使用 LFU 策略,那么,createObject 函数就会调用 LRU_CLOCK 函数来设置 lru 变量的值,也就是键值对对应的 LRU 时钟值。

使用LFU算法时,lru变量包括了两部分:

  • 第一部分是 lru 变量的高 16 位,是以 1 分钟为精度的 UNIX 时间戳。这是通过调用 LFUGetTimeInMinutes 函数(在 evict.c 文件中)计算得到的;
  • 第二部分是 lru 变量的低 8 位,是计数器,被设置为宏定义 LFU_INIT_VAL(在server.h文件中),默认值为 5。

下面,我们再来看下键值对访问频率的更新。

当一个键值对被访问时,Redis 会调用 lookupKey 函数进行查找:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/* Low level key lookup API, not actually called directly from commands
* implementations that should instead rely on lookupKeyRead(),
* lookupKeyWrite() and lookupKeyReadWithFlags(). */
robj *lookupKey(redisDb *db, robj *key, int flags) {
dictEntry *de = dictFind(db->dict,key->ptr);
if (de) {
// 获取键值对对应的redisObject结构体
robj *val = dictGetVal(de);

/* Update the access time for the ageing algorithm.
* Don't do it if we have a saving child, as this will trigger
* a copy on write madness. */
if (!hasActiveChildProcess() && !(flags & LOOKUP_NOTOUCH)){
if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
// 如果使用了LFU策略,调用updateLFU函数更新lru值
updateLFU(val);
} else {
// 否则,调用LRU_CLOCK函数获取全局LRU时钟值
val->lru = LRU_CLOCK();
}
}
return val;
} else {
return NULL;
}
}

它会从全局哈希表中查找要访问的键值对。如果该键值对存在,那么 lookupKey 函数就会根据 maxmemory_policy 的配置值,来更新键值对的 LRU 时钟值,也就是它的访问时间戳:

  • 如果 maxmemory_policy 配置为使用 LFU 策略,调用updateLFU函数更新lru值。
  • 如果 maxmemory_policy 配置项没有使用 LFU 策略,lookupKey 函数就会调用 LRU_CLOCK 函数,来获取当前的全局 LRU 时钟值,并将其赋值给键值对的 redisObject 结构体中的 lru 变量。

这个db.c#updateLFU很重要:

1
2
3
4
5
6
7
8
void updateLFU(robj *val) {
//根据距离上次访问的时长,衰减访问次数
unsigned long counter = LFUDecrAndReturn(val);
//根据当前访问更新访问次数
counter = LFULogIncr(counter);
//更新 lru 变量值
val->lru = (LFUGetTimeInMinutes()<<8) | counter;
}

它的执行逻辑比较明确,一共分成三步:

第一步:updateLFU 函数首先会调用 evict.c#LFUDecrAndReturn 函数对键值对的访问次数进行衰减操作。

你可能会有疑问:访问键值对时不是要增加键值对的访问次数吗,为什么要先衰减访问次数呢

其实,这就是我在前面一开始和你介绍的,LFU 算法是根据访问频率来淘汰数据的,而不只是访问次数。访问频率需要考虑键值对的访问是多长时间段内发生的。键值对的先前访问距离当前时间越长,那么这个键值对的访问频率相应地也就会降低,这就是所谓的访问频率衰减。具体可以看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
unsigned long LFUDecrAndReturn(robj *o) {
// 获取当前键值对的上一次访问时间,lru右移8位,相当于保留的是前面16位的时间戳
unsigned long ldt = o->lru >> 8;
// 获取当前的访问次数,相当于后8位与255做与运算,即得到计数器
unsigned long counter = o->lru & 255;
// 计算衰减大小
unsigned long num_periods = server.lfu_decay_time ? LFUTimeElapsed(ldt) / server.lfu_decay_time : 0;
// 如果衰减大小不为0
if (num_periods)
// 如果衰减大小小于当前访问次数,那么,衰减后的访问次数是当前访问次数减去衰减大小;否则,衰减后的访问次数等于0
counter = (num_periods > counter) ? 0 : counter - num_periods;
// 如果衰减大小为0,则返回原来的访问次数
return counter;
}

具体来说,LFUDecrAndReturn 函数会首先获取当前键值对的上一次访问时间,这是保存在 lru 变量高 16 位上的值。然后,LFUDecrAndReturn 函数会根据全局变量 server 的 lru_decay_time 成员变量的取值,来计算衰减的大小 num_period。

这个计算过程会判断 lfu_decay_time 的值是否为 0。如果 lfu_decay_time 值为 0,那么衰减大小也为 0。此时,访问次数不进行衰减。

否则的话,LFUDecrAndReturn 函数会调用 LFUTimeElapsed 函数(在 evict.c 文件中),计算距离键值对的上一次访问已经过去的时长。这个时长也是以 1 分钟为精度来计算的。有了距离上次访问的时长后,LFUDecrAndReturn 函数会把这个时长除以 lfu_decay_time 的值,并把结果作为访问次数的衰减大小。

这里,你需要注意的是,lfu_decay_time 变量值,是由 redis.conf 文件中的配置项 lfu-decay-time 来决定的。Redis 在初始化时,会通过 initServerConfig 函数来设置 lfu_decay_time 变量的值,默认值为 1。所以,在默认情况下,访问次数的衰减大小就是等于上一次访问距离当前的分钟数。比如,假设上一次访问是 10 分钟前,那么在默认情况下,访问次数的衰减大小就等于 10。

当然,如果上一次访问距离当前的分钟数,已经超过访问次数的值了,那么访问次数就会被设置为 0,这就表示键值对已经很长时间没有被访问了。

第二步,updateLFU 函数会调用 evict.c#LFULogIncr函数更新访问次数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#define LFU_INIT_VAL 5

/* Logarithmically increment a counter. The greater is the current counter value
* the less likely is that it gets really implemented. Saturate it at 255. */
/*
*对数递增计数值
*核心就是访问次数越大,计算得到的值越小,该值大于一个随机概率的可能性就越小,进而访问次数被递增的可能性越小,最大 255,此外你可以在配置 redis.conf 中写明访问多少次递增多少。
*/
uint8_t LFULogIncr(uint8_t counter) {
// 到最大值了,不能在增加了
if (counter == 255) return 255;
// 获取一个随机概率:rand()产生一个0-0x7fff的随机数,一个随机数去除以 RAND_MAX也就是Ox7FFF,也就是随机概率
double r = (double)rand()/RAND_MAX;
// 减去新对象初始化的基数值 (LFU_INIT_VAL 默认是 5)
double baseval = counter - LFU_INIT_VAL;
// baseval 如果小于零,说明这个对象快不行了,不过本次 incr 将会延长它的寿命
if (baseval < 0) baseval = 0;
// baseval * LFU对数计数器因子 + 1保证分母大于1
// 当 baseval 特别大时,最大是 (255-5),p 值会非常小,很难会走到 counter++ 这一步
// p 就是 counter 通往 [+1] 权力的门缝,baseval 越大,这个门缝越窄,通过就越艰难
double p = 1.0/(baseval*server.lfu_log_factor+1);
// 如果随机概率小于当前计算的访问概率,那么访问次数加1
if (r < p) counter++;
return counter;
}

阈值p 的值大小,其实是由两个因素决定的。一个是当前访问次数和宏定义 LFU_INIT_VAL 的差值 baseval,另一个是 reids.conf 文件中定义的配置项 lfu-log-factor

当计算阈值 p 时,我们是把 baseval 和 lfu-log-factor 乘积后,加上 1,然后再取其倒数。所以,baseval 或者 lfu-log-factor 越大,那么其倒数就越小,也就是阈值 p 就越小;反之,阈值 p 就越大。也就是说,这里其实就对应了两种影响因素。

  • baseval 的大小:这反映了当前访问次数的多少。比如,访问次数越多的键值对,它的访问次数再增加的难度就会越大;
  • lfu-log-factor 的大小:这是可以被设置的。也就是说,Redis 源码提供了让我们人为调节访问次数增加难度的方法。

第三步,更新 lru 变量值。

最后,到这一步,updateLFU 函数已经完成了键值对访问次数的更新。接着,它就会调用 LFUGetTimeInMinutes 函数,来获取当前的时间戳,并和更新后的访问次数组合,形成最新的访问频率信息,赋值给键值对的 lru 变量。

好了,到这里,你就了解了,Redis 源码在更新键值对访问频率时,对于访问次数,它是先按照上次访问距离当前的时长,来对访问次数进行衰减。然后,再按照一定概率增加访问次数。这样的设计方法,就既包含了访问的时间段对访问频率的影响,也避免了 8 bits 计数器对访问次数的影响。而对于访问时间来说,Redis 还会获取最新访问时间戳并更新到 lru 变量中。

那么最后,我们再来看下 Redis 是如何基于 LFU 算法淘汰数据的。

LFU 算法淘汰数据

LFU算法淘汰数据其实和LRU的步骤基本一样,只是在evictionPoolPopulate 函数中,往淘汰池中增加key时,使用了不同的方法来计算每个待淘汰键值对的空闲时间。(如果对LRU的源码淘汰数据的源码很了解的话,可以直接看这个函数)

首先,LFU算法主要逻辑是在 freeMemoryIfNeeded 函数中实现的,而这个函数本身是在 evict.c 文件中实现。freeMemoryIfNeeded 函数是被 freeMemoryIfNeededAndSafe 函数(在 evict.c 文件中)调用,而 freeMemoryIfNeededAndSafe 函数又是被 processCommand 函数所调用的。

server.c#processCommand:

1
2
3
if (server.maxmemory && !server.lua_timedout) {
//如果设置了maxmemory 配置项为