写在前面
redis 是一个非常优秀的 k-v 存储系统,其使用 单 Reactor 模式.最近在看 reids 设计与实现 以及 redis 实战,项目开发也使用过 redis, 但是对其底层实现不太了解,相结合书籍和源码阅读一下,在此记录一些点和不了解的地方.
源码阅读参考:http://blog.huangz.me/diary/2014/how-to-read-redis-source-code.html
黄建宏老师的书可以说是非常通俗易懂,而且让人对 redis 的架构和实现都有一定的了解.非常推荐他的书, redis 实战也是他翻译的.
另外,redis 源码相对于 nginx 源码来说太友善了,有时候一条语句会有很大篇幅的解释,让人能够看得比较明白.
redis 服务器中的数据库
源码相关:server.h
默认会创建 16 个 db.每个客户端会有自己的目标数据库.默认目标数据库为 0 号数据库.
redisDb 结构
typedef struct redisDb {
dict *dict; /* The keyspace for this DB */
dict *expires; /* Timeout of keys with a timeout set */
dict *blocking_keys; /* Keys with clients waiting for data (BLPOP)*/
dict *ready_keys; /* Blocked keys that received a PUSH */
dict *watched_keys; /* WATCHED keys for MULTI/EXEC CAS */
int id; /* Database ID */
long long avg_ttl; /* Average TTL, just for stats */
} redisDb;
键空间
源码相关:dict.c
dict 字典保存了数据库中的所有键值对,称其为键空间.其与用户所见的数据库直接对应.增删查改的操作都是对键空间操作的.
键过期
存储过期信息的空间
expires 中保存过期信息.key 指向键, val 为 long long 类型的过期时间.
过期策略
定时删除 (设置定时器),惰性删除(每次获取检查),定期删除(每隔一段时间删除) 前两种为主动删除策略,最后为被动删除策略.
redis 服务器实际使用的是后两种.
惰性删除的实现
源码相关:db.c/expireIfNeed 考虑键存在与否,以及是主还是从机的情况.
定期删除的实现
源码相关:expire.c/acitveExpireCycle
其实现比较复杂,在规定的时间内,分多次遍历服务器的数据库,从数据库的 expires 字典中随机检查一部分键的过期时间,并删除其中的过期键.
RDB对过期键的处理
已过期的键不会被保存到新创建的 RDB 文件中.
AOF文件对过期键的处理
不会有任何影响.该键被显示删除时,才会追加一条 DEL 命令.
复制模式下对过期键的处理(主从)
从服务器的过期键删除由主控制.访问从库,若主库没有发送删除命令,则从库则会继续返回.
RDB 持久化
源码相关:rdb.c
生成RDB 文件
源码相关:rdb.c/rdbSave /rdbSaveBackground
SAVE:阻塞 Redis 服务器,直到文件创建完成.
BGSAVE: 生成子进程,由子进程来完成.可设置自动间隔性保存.
载入 RDB 文件
源码相关: /rdbLoad
无专门命令,服务器启动自动执行.载入时服务器为阻塞状态.
ps: AOF 优先级比 RDB 高,所以 AOF 存在则使用 AOF.
自动执行 BGSAVE
源码相关:server.c/serverCron
检查条件首满足,满足就执行 BGSAVE.
RDB 文件结构 (二进制文件)
REDIS db_version databases EOF checker_sum
REDIS:标识为 RDB 文件
databases: 可存放多个数据库的信息,具体为 SELECTDB db_number key_value_pairs.SELECTDB 标识接下来读取的是一个数据库号码.
EOF:正文结束
完整的数据库 RDB 文件结构(包括数据库0和数据库3的信息):
REDIS db_vsersion SELECTDB 0 pairs SELECTDB 3 pairs EOF checker_sum
打印输出 RDB 文件
od -c dump.rdb
AOF 持久化
实现步骤
命令追加( append ),文件写入,文件同步( sync ) 三个步骤.
命令追加
每次执行完一个写命令,就追加到 aof_buf 末尾.
文件写入和同步
每次结束一个 event loop,就会调用 flushAppendOnlyFile,写入 aof 文件,是否同步考虑 appendfsync 的值.
appendsync 有三种,always(每次都写入并同步),everysec(默认,超过上次同步的时间1秒则同步,并且由另外线程进行),no(不同步,何时同步由 OS 决定).
载入与数据还原
在子进程中进行.使用进程,可避免在使用锁的过程中,保证数据的安全性.不过会导致数据不一致性. Redis 设置了一个 AOF 重写缓冲区来把父进程的写命令发送给子进程来解决此问题.过程:创建伪客户端->从 AOF 文件中读取并执行命令.
AOF 重写
原理
解决 AOF 文件体积膨胀问题,重写得到的新文件体积和比原来的小很多.
实现
不看之前的 AOF 文件,直接从数据库读状态,把多条语句合并成一条 (例如 set 有6个元素,把原来的6条语句变成1条)来代替记录.
事件
文件事件
是什么
Redis 基于 Reactor 开发了自己的网络事件处理器:该处理器即为文件事件处理器( file event handler ).
构成
四个组成部分,套接字, I/O 多路复用程序,文件事件分派器,事件处理器.
时间事件
包括定时和周期性事件.其实际处理时间通常会比设定的到达时间晚一些.(先处理文件事件)
客户端
源码参考:server.c/client
redis 使用链表来存储与服务器连接的所有客户端.
客户端属性
fd
记录了客户端正在使用的套接字描述符.伪客户端的 fd 为-1 (AOF 或者 Lua 脚本),普通客户端的 fd 为大于-1的整数值.
使用 client list 可以列出用户所用的 fd.
名字 ( name )
一般来说是没名字的.可以自己设置.
标志 ( flags )
记录了客户端的角色和状态.角色的标志有 REDIS_MASTER,REDIS_LUA_CLIENT 等,所处的状态有 REDIS_MONITOR, REDIS_BOLOCKED,REDIS_MULTI 等.
输入缓冲区 ( querybuf )
保存客户端发送的命令请求.大小动态变化.不能大于1GB.
命令与命令参数 ( argv argc )
argv 为数组.每个为一个字符串. argc 为数组的长度.
命令实现函数 ( cmd )
命令表是一个字典,键为 SDS ,值为对应的 redisCommand 结构.
输出缓冲区
每个客户端有两个.一个大小固定,存放长度较小的回复.例如 OK ,错误回复等.另一个大小可变(链表),存放比较大的回复.
身份验证 ( authenticated )
记录客户端通过了身份验证.通过为1.
时间
客户端有几个记录时间的域. ctime 记录了客户端的时间.可用命令 client list 来查看 ( age 域,单位为 s );lastinteraction 记录了客户端最后一次和服务器互动的时间.
客户端的创建与关闭
创建
创建 client 结构 -> 链接到 server 的 clients 域.
关闭
关闭有多种原因.例如:客户端退出或被杀死,发送了带有不符合协议格式的命令,客户端成为了 client kill 的目标,timeout, 发送的命令请求超过了输入缓冲区的限制大小(这里要看服务器设置的硬性限制还是软性限制,硬性则 kill )等等.
伪客户端
Lua 脚本的伪客户端在服务器初始化时创建,保存在 lua_client 中.
AOF 伪客户端在载入时创建,完成时关闭.
服务器
命令请求执行过程
客户端发送 -> 服 务端接受,数据库中操作,返回 OK -> 客户端收到 OK,打印回复给用户看.
服务器执行命令操作
查找命令:在命令报中查找对应的命令,并把找到的命令保存在客户端状态的 cmd 属性中.
预备操作:检查客户端 cmd 属性指向的 redisCommand 结构的属性是否合法,客户端是否通过身份验证等.
执行操作:调用执行命令函数.
后续操作:日志记录,传播,持久化等等.
管理服务器资源
源码相关:server.c/serverCron 函数
更新服务器时间缓存,LRU 时钟,服务器每秒执行命令次数,内存峰值记录,处理 SIGTERM 信号,管理客户端资源,管理数据库资源,执行被延迟的 BGREWRITEAOF ,检查持久化操作的运行状态,把 AOF 缓冲区中的内容写入 AOF 文件,关闭异步客户端,增加 cronloops 计数器的值等等.
初始化服务器状态结构
源码相关: server.c/initServerConfig () , initServer()
创建 redisServer 类型的实例变量 server -> 调用 initServerConfig() 初始化 -> 载入配置 -> 调用 initServer() 初始化服务器数据结构 ( server.clients, server.db, server.lua 等等 )
经过上述步骤, 终端会打印出 redis 服务器启动的内容.
-> 还原数据状态
会打印出 18131:M 01 Jun 14:40:42.686 * DB loaded from disk: 0.000 seconds 信息.
-> 准备和客户连接,执行服务器的时间循环.
打印出 18131:M 01 Jun 14:40:42.686 * Ready to accept connections 信息.
Sentinel
源码相关:sentinel.c
Redis 的高可用解决方案.其可监视一个主服务器和其下的从服务器,其会监视主从的状态,并在主服务器下线时自动让从服务器替换主服务器.
选举算法为 Raft 算法 (另一个算法为 Paxos, 不过它很难懂, Raft 的一大特点是它简单易懂)
算法原理参考:https://raft.github.io/
启动 Sentinel
初始化服务器 -> 将普通 Redis 服务器使用的代码替换成 Sentinel 专用代码 -> 初始化 Sentinel 状态 -> 根据给定的配置文件,初始化 Sentinel 的监视主服务器列表 -> 创建连向主服务器的网络连接.
初始化服务器
Sentinel 本质为一个特殊的 Redis 服务器,其不用持久化文件恢复 DB, get,set 等命令不可以用.
将普通 Redis 服务器使用的代码替换成 Sentinel 专用代码
例如, 服务器的命令表,sentinel 使用 sentinelcmds,其中的 info 命令会使用其专用实现 sentinelInfoCommand等.
初始化 Sentinel 状态
服务器会初始化一个 sentinel.c/sentinelState 结构,此结构保存了服务器中所有和 Sentinel 功能有关的状态.
根据给定的配置文件,初始化 Sentinel 的监视主服务器列表
Sentinel 状态中的 masters 字典 < 被监视服务器的名字, 被监视服务器对应的 sentinelRedisInstance 结构 > 记录了所有被 Sentinel 监视的主服务器的相关信息.
创建连向主服务器的网络连接
Sentinel 会创建两个连向主服务器的异步网络连接.用于 sentinel 和服务器双向通信.
命令连接: sentinel 向服务器发送消息用,并接受命令回复.
订阅连接:订阅服务器发送给 sentinel 的消息.(为了不丢失服务器发送给 sentinel 的消息)
集群
使用 cluster meet <ip> <port> 可让 node 节点与 ip 和 port 所指定的节点进行握手,然后把此节点加入到 node 节点当前所在的集群中.
源码相关:cluster.h cluster.c
启动节点
一个节点即一个运行在集群模式下的 Redis 服务器,其在启动时会根据 cluster-enabled 来决定是否开启集群模式.
集群数据结构
clusterNode
记录节点的状态,例如创建时间,名字,配置纪元, ip, 端口号等.
clusterLink
保存了连接节点的有关信息,例如 fd, I/O buffer 等.
clusterState
记录了在当前节点的视角下,集群目前所处的状态,例如集群是上线还是上线,节点个数等.
CLUSTER MEET 的实现原理
节点A 通过接受命令 cluster meet ,与节点 B 进行三次握手,把节点 B 加入到 A 所在集群中,完成后通过 Gossip 广播,把节点 B 介绍给集群内其他节点认识.
槽指派
集群通过分片的方式保存键值对.集群的整个数据库被分为 16384 个槽.每个节点可以处理 0-16384个槽.
如果每个槽都有节点管理,则为上线状态( ok );否则为下线状态( fail ).
指派命令为 cluster addslots <slot> [slot ...] 例如 cluster addslots 0 1 2 ... 100 把 0-100 归现在的节点管.
clusterStae.slots ( clusterNode 数组 )
记录了集群中所有槽的指派信息,这样查找某个槽是哪个节点负责的时候,时间复杂度为 O(1) ,而不为 O(N) .
clusterNode.slots (char 数组)
记录单个节点的槽指派信息.给其他节点传播某节点的槽信息的时候只要把相应节点的 clusterNode.slots 数组发送出去即可.
计算键值的算法
源码相关:cluster.c/KeyHashSlot
CRC16 ( key ) & 0x3FFF ( 16383 ) ;
节点数据库的实现
与单机服务器的区别:节点只能使用 0 号数据库,单机无此限制.
slots_to_keys (zskiplist *)
在 clusterState 结构中.用其来保存槽和键之间的关系.表的分值为槽号,键为键名.通过在其中记录各个数据库键所属的槽,节点可以很方便地对属于某个或某些槽的所有数据库键进行批量操作.
重新分片
此操作可以将任意数量已经指派给某节点的槽改为指派给另一个节点,并且相关槽所属的键值对也会从源节点被移动到目标节点.
此操作可以在线进行.
实现原理
此操作由 redis-trib 负责执行.
redis-trib
其通过向源节点和目标节点发送命令来进行重新分片操作.
如果涉及多个槽,则会对每个涉及的槽都做上述操作.
ASK 错误
即在进行迁移的时候,一半键值在源节点,一半在目标节点的时候,如果客户端要查找正在被迁移的一个键值,则会首先在源节点中查找,找到则返回;找不到则返回 ASK 错误,指引客户端往目标节点,病再次发送之前的命令.
ASK 错误和 MOVED 错误的区别
MOVED 是永久性转向,ASK 是临时性转向.
复制
Redis 集群中的主节点负责处理槽,从节点用于复制主节点,以及在主节点下线时替代主节点.
设置从节点
向某节点发送命令 cluster replicate <node_id> ,可让其成为 node_id 的从节点.
故障检测
疑似下线
集群中的每个节点会定期向集群中的其他节点发送 PING 消息,若没有在规定时间内返回 PONG,则其会被发送 PING 的节点标记为疑似下线.
下线
若某节点被板书以上的节点报告为疑似下线,则其会被标记为下线.
故障转移
步骤:所有从节点中选中一个 -> 被选中的节点成为新的主节点 ( SLAVE OF NO ONE ) -> 撤销对已下线节点的所有槽指派,并将这些槽指派给自己 -> 新主向集群广播一条 PONG 消息,通知其他节点 -> 新主负责处理相关命令,故障转移完毕.
选举新主
和选举领头 Sentinel 的方法非常相似,都是基于 Raft 算法的领头选举方法来实现的.
消息
源码相关:cluster.h/clusterMsgxx
集群中的各个节点通过发送和接收消息来进行通信,发送消息的节点为发送者,接收的为接收者.
消息由消息头和消息体组成.
消息类型
主要有五种. MEET , PING , PONG , FAIL , PUBLISH .
MEET: 请求接收者加入到发送者当前的集群.
FAIL:主节点发送一个关于节点 B 的消息,其他收到消息的节点会把 B 标记为已下线.
PUBLISH: 节点接受到一个 PUBLISH 命令,它会执行此命令,并且广播给其他节点,其他节点也会执行相同的 PUBLISH 命令.
MEET ,PING , PONG 消息的实现
集群中的节点通过 Gossip 协议来交换各自关于不同节点的状态信息. Gossip 协议由 MEET ,PING , PONG 三种消息实现.
FAIL 消息的实现
节点 7001 发现 7002 下线,向 7000 和 7003 发送 7002 下线通知 -> 7000, 7003 把 7002 标志为下线 -> 集群中超过半数认为 7002 下线,其他节点可对此主节点判断是否需要将其标记为下线,或者对其进行故障转移.
PUBLISH 消息的实现
向集群的某个节点发送命令 PUBLISH <channel> <message> ,将导致集群中的所有节点都向 cahnnel 频道发送 mesasge 消息.
独立功能的实现
发布与订阅
此功能由 PUBLISH , SUBSCRIBE, PSUBSCRIBE 等命令组成.
订阅与退订
源码相关:server.h
Redis 把所有频道的订阅关系都保存在服务器状态的 pubsub_channel < 被订阅的频道, 订阅这个频道的客户端链表 > 字典里面.
订阅
若键存在,则把客户端加入链表;不存在,则先创建键.
退订
与上反过来.如果有多个订阅者,则删除指定订阅者;否则把键也删除.
模式的订阅与退订与上相似.
发送消息
当一个 Redis 客户端执行 PUBLISH <channel> <message> 命令时,服务器需执行以下两步:
1.将消息发送给 channel 频道的所有订阅者;
2.若有一个或多个模式与频道相匹配,则将消息发送给 pattern 模式的订阅者.
事务
Redis 通过 MULTI , EXEC , WATCH 等命令来实现事务功能.
事务将多个命令打包,然后一次性,按顺序地执行,其会把所有命令都执行完毕,才会去处理其他客户端的命令请求.
事务的实现
一个事务从开始到结束一般经历三个阶段.事务开始 -> 命令入队 -> 事务执行.
事务开始
multi 命令.此命令是通过在客户端状态的 flags 属性中打开 REDIS_MULTI 标识来完成的.
命令入队
在事务状态下,若客户端发送的命令并非为 EXEC , DISCARD , WATCH , MULTI ,则服务器会把此命令放入一个事务队列中,然后向客户端返回 QUEUED 回复.
事务队列: 每个 Redis 客户端都有,保存在客户端状态的 mstate 属性里面.
事务执行
当处于事务状态的客户端向服务器发送 EXEC 命令时,这个命令将被立刻执行.服务器会遍历此客户端的事务队列,并执行队列中保存的所有命令,然后返回结果给客户端.
WATCH 命令的实现
源码相关:multi.c
watch 为一个乐观锁,其可在 exec 执行前监视任意数量的键,如果在 exec 命令执行时,发现被监视的键至少有一个被修改过,则服务器将拒绝执行事务.
监视机制的触发
所有对数据库进行修改的命令,在执行后都会调用 multi.c/touchWatchedKey() 来对 watched_keys 字典进行检查,如果有修改过,则把被修改键的客户端的 REDIS_DIRTY_CAS 打开,说明该客户端的事务安全性已经被破坏.
判断事务是否安全
Redis 与传统关系型数据库事务的区别
Redis 不支持事务回滚机制,即使某个命令执行期间出现错误,也要把所有命令执行完毕.
Redis 支持事务的 ACI , D 要看具体有没有开启持久化.
Lua 脚本
Redis 客户端可以使用 Lua 脚本,直接在服务器端院子地执行多个 Redis 命令.
创建并修改 Lua 环境
创建一个基础的 Lua 环境
服务器调用 Lua 的 C API 函数 lua_open
载入函数库
载入 基础库,表格库等.
创建 redis 全局表格
此表格包括 reids.call, redis.pcall, redis.log 等函数.
使用 Redis 自制的随机函数来替换 Lua 原有的随机函数
REdis 要求所有传入服务器的 Lua 脚本,以及 Lua 环境中的所有函数,都必须是无副作用的纯函数.
创建排序辅助函数
执行完一个带有不确定性的命令,调用辅助函数,使得同样的数据集总是产生相同的输出.例如( hkeys 等 )
创建 redis.pcall 函数的错误报告辅助函数
保护 Lua 的全局环境
Lua 通信协作组件
位数组
位数组的表示
使用 redis 中的字符串对象来表示。数组保存数据的顺序是逆序的。