Redis学习笔记
帮助网站:
官网:https://redis.io/commands/
中文:http://www.redis.cn/commands.html
三、源码解析篇(自己阅读源码的记录以及一些分析源码博客内容的总结)
基础篇
参考尚硅谷网课
Redis基本使用
配置 redis.conf
1. 默认 daemonize no --> daemonize yes
2. 默认 protected-mode yes --> protected_mode no
3. 默认 bind 127.0.0.1 --> 注释掉
4. 添加密码 --> requirepass 你的密码
启动
# root权限
# 启动redis服务器
redis-server {redis安装路径}/redis.conf
# 启动redis客户端 --raw避免出现中文乱码
redis-cli [-a {password}] [-p {端口号6379}] [--raw]
# 关闭redis服务器
# 单实例关闭
redis-cli -a {password} shutdown
# 多实例关闭
redis-cil -a {password} -p {端口号} shuntdown
# 查看redis 进程
ps -ef | grep redis | grep -v grep
Redis十大基本数据类型以及操作
-
概述
key-value键值对中,key一般都是字符串,而这里的基本数据类型描述的一般都是value。
- String:字符串,二进制安全,也就是说String可以存放任何数据,例如图片或者序列化对象,一个String最大512M;
- List:列表,按照插入顺序排序,底层是双端列表,最多包含2^32-1个数据(超过40亿);
- Hash:哈希表,一个String类型的field(字段)和value(值)的映射表,Redis每个哈希表最多存储2^32-1个键值对;
- Set:集合,无顺序,不重复,底层通过哈希表实现,添加查找删除时间复杂度O(1);
- Sort Set(ZSet):可排序集合,通过给值添加一个double类型的分数(score),利用分数进行排序,值不可重复,但是分数可以重复;
- GEO:地理空间,经纬度。包括添加、获取地理位置坐标,计算两位置之间的距离,根据用户给定的坐标来获取指定范围内地理位置集合等;
- HyperLogLog:基数统计,用来做基数统计的算法,HypperLogLog的有点是,在输入元素数量或者体积非常大时,计算基数所需空间总是固定且比较小的。在Redis中,每个HypperLogLog键只需花费12KB内存,就可以计算接近2^64个不同元素的基数,但是HypperLogLog只会根据输入元素来计算基数,而不会存储元素本身;
- bitmap:位图,由0/1状态表现的二进制数组,节省空间;
- bitfield:位域,可以操作连续的多个比特位,bitmap的扩展;
- Stream:流,Redis5.0新增,主要用于消息队列MQ。Redis Stream 提供类消息持久化和主备复制功能,可以让客户端访问任何时刻的数据,并且能记住每一个客户端访问的位置,还能保证消息不丢失。
-
关于key的操作
# key区分大小写 命令不区分大小写 key* # 查看当前库所有的key exists key # 判断某个key是否存在 type key # 查看key的类型 del key # 删除指定的key数据 unlink key # 非阻塞删除,仅仅将key从keyspace元数据中删除,真正的删除会在后续的异步操作中# del key 是原子的删除,只有删除成功了才 # 会返回删除结果,如果是删除大key用del会将后面的操作都阻塞,而unlink key 不会阻塞,它会在后台异步删除数据。 ttl key # 查看key还有多少秒过期,-1表示永不过期,-2表示已过期 expire key second # 为给定key设置过期时间 move key dbindex[0-15] # 将当前数据库中的key移动到给定的数据库db当中 一个redis服务器默认带着16个数据库 select dbindex[0-15] # 切换数据库0-15,默认0 dbsize # 查看当前数据库key的数量 flushdb # 清空当前数据库 flushall # 清空所有库
-
String
-
Set Key Value
set key value [NX|XX] [GET] [EX seconds|PX millisecond|EXAT unix-time-seconds|PXAT unix-time-milliseconds|KEEPTTL]
SET命令有EX、PX、NX、XX和KEEPTTL五个参数可选
- EX seconds:以秒为时间单位设置过期时间
- PX milliseconds:以毫秒为单位设置过期时间
- EXAT timestamp:设置以秒为单位的UNIX时间戳对应时间为过期时间
- PXAT milliseconds-timestamp:设置以毫秒为单位的UNIX时间戳对应时间为过期时间
- NX:键不存在的时候创建键值
- XX:键存在的时候重新设置键值
- KEEPTTL:保留设置前指定键的生存时间,因为重新设置值默认会使用永久生存时间
- GET:返回指定键原本的值,若键不存在返回nil,类似于函数返回值,不使用GET参数时该命令返回OK或者nil。
-
Get key
-
mset/mget
一次设置获取多键
mset k1 v1 k2 v2 mget k1 k2 # 上述命令也具有同样参数如nx,但是只允许全部成功,否则失败
-
getrange/setrange
获取/设置值的某一区间,[0,-1]表示全部,类似于substr操作
# sterange key offset value # getrange key start end set k1 123456 getrange k1 0 3 # 返回1234 setrange k1 0 5 # k1=523456
-
数值增减(需要保证string字面量是数字)
INCR key # 递增数值 INCRBY key increment # 增加指定的整数 DECR key # 递减数值 DECRBY key decrement # 减少指定的整数
-
获取字符串长度和内容追加
STRLEN key # strlen key APPEND key value # append # 上述两个命令返回字符串的长度
-
分布式锁
setnx key value setex key senconds value # set with expire setnx # set if not exist # 以上命令其实通过set + 参数均可实现,后续redis可能会弃用这些命令
redis单线程,通过setnx设置k-v键值对,实现分布式锁
-
getset
getset k1 value # 先将k1对应的值设为value,再返回原来的旧值
-
用途,点赞数量记录
-
-
List
单key多value
lpush key element [element ...] # 在key左端插入 rpush key element [element ...] # 在key右端插入 lrange key start stop # 从左端开始返回区间的值,类似遍历操作 [start,stop) # 没有rrange命令 为什么 lpop key [count] # 弹出左端count个元素,count默认为1 rpop key [count] # 弹出右端count个元素,count默认为1 lindex key index # 按照索引下标获得元素 llen key # 获取列表中元素的个数 lrem key count element # remove删除count个element元素(list允许元素重复)返回真实删除的个数 ltrim key start stop # 截取key的[start,stop)再重新赋值给key rpoplpush source destination # 将源列表的右端一个元素取出插入到目的列表左端 lset key index element # 重新设置列表index索引的值 linsert key before|after pivot element # 在一个已有值前/后添加一个值,插入成功返回列表长度,失败返回-1 # 常见使用场景 公众号消息列表
-
Hash
Redis中Hash的值是哈希表,是一张表可以存放很多键值对
hset key field value [field value ...] # 设置值 hget key field hmset key field value [field value ...] hmget key field [field ...] hgetall key # 获取全部值 hdel key field [field ...] hlen key # 获取key的数量 hexists key field # 判断key中是否存在field域,有返回1,没有返回0 hkeys key # 获取key中的所有field hvals key # 获取key中的所有值 hincrby key field increment # 对域中值加increment整数 hincrbyfloat key field increment # 对域中值加increment浮点数 hsetnx key field value # 不存在则创建 # 使用场景,以前的购物车设计
-
Set
单值多value且无重复
SADD key member [member ...] # 添加元素 SMEMBERS key # 遍历集合中的所有元素 SISMEMBER key member # 判断元素是否在集合中 SREM key member [member ...] # 删除集合中的元素 SCARD key # 获取集合中元素的个数 SRANDMEMBER key [count] # 随机展示集合中count个元素(不删除) SPOP key [count] # 从集合中随机弹出count个元素 SMOVE source destination member # 将source中的member移动给destination # 集合运算 SDIFF key [key ...] # 计算差集 SDIFF A B --> A-B 返回结果 SUNION key [key ...] # 计算并集 SUNION A B --> A并B SINTER key [key ...] # 计算交集 SINTERCARD numkeys key [key ...] [LIMIT limit] # redis7添加 numkeys是key的个数,LIMIT是可选参数,限制最大返回值。它不返回结果集,而只返回结果的基数(去重之后的元素个数) # 应用场景,社交好友
-
Zset
有序集合,在每个value前面加上score分数用来计算顺序
ZADD key [NX|XX] [GT|LT] [CH] [INCR] score member [score member ...] # 添加元素 ZRANGE key start stop [WITHSCORES] [BYSCORE|BYLEX] [REV] [LIMIT offset count] # 按照元素分数从小到大的顺序返回索引[start,stop)的元素 ZREVRANGE key start stop [WITHSCORES] # 反转集合 ZRANGEBYSCORE key [(]min max [WITHSCORES] [LIMIT offset count] # 获取[min,max]范围内的元素 (小括号表示左边不包含 # WITHSCORES选项的返回值是先返回value,再返回score,LIMIT是搜索限制,开始位置和步长 ZSCORE key member # 获取元素的分数 ZCARD key # 获取集合中元素的数量 ZREM key member [member ...] # 删除集合内指定元素 ZINCRBY key increment member # 增加某个元素的分数 ZCOUNT key min max # 获得分数在[min,max]之间的元素个数 ZMPOP numbers key [key ...] MIN|MAX [COUNT count] # Redis7.0添加 numbers修饰的是多少个key,MIN|MAX选择的是删去规则最小的还是最大的,COUNT是删去几个,返回值是实际删去的个数 ZRANK key member [WITHSCORE] # 获取对应下标,注意这里的WITHSCORE没有S ZREVRANK key member [WITHSCORE] # 获取逆序索引 # 应用场景,排行榜
-
bitmap
位图,由0和1状态表现的二进制位的bit数组
# 索引顺序是从左向右看的 setbit key offset value # 设置某一位的值 getbit key offset # 获取某一位的值 strlen key # 每超过8位自动扩充一个字节,返回值单位是字节 bitcount key [start end [BYTE|BIT]] # 指定范围内1的个数 bitop AND|OR|XOR|NOT destkey key [key ...] # bit option 运算结果保存在destkey中 # 使用场景,每日签到 记录
-
HyperLogLog
元素去重后的数量统计
PFADD key [element [element ...]] # 添加指定元素到HyperLogLog中 PFCOUNT key [key ...] # 返回给定HyperLogLog的基数估算值 PFMERGE destkey [sourcekey [sourcekey ...]] # 将多个HyperLogLog合并为一个HyperLogLog,存储在destkey中 # 使用场景,网页不重复访问计数
-
GEO
地理空间,经纬度,底层是Zset
将三维的地球变为二维的坐标,再将二维的坐标转换为一维的点块,最后将一维的点块转化为二进制再通过base32编码
# 多个经度longitude,维度latitude、位置名称member添加到指定key中 GEOADD key [NX|XX] [CH] longitude latitude member [longitude latitude member ...] # 从键里面返回所有给定位置元素的位置(经度和维度) GEOPOS key [member [member ...]] # 返回两个给定位置之间的距离 m/km/ft/mi GEODIST key member1 member2 [M|KM|FT|MI] # 以给定的经纬度为中心返回于中心处距离半径大小的所有位置元素 GEORADIUS key longitude latitude radius M|KM|FT|MI [WITHCOORD] [WITHDIST] [WITHHASH] # 与上面类似,只是参数是位置名称 GEORADIUSBYMEMBER key member radius M|KM|FT|MI [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count [ANY]] [ASC|DESC] # 返回一个或多个位置元素的GEOHASH表示 base32编码 GEOHASH key [member [member ...]] # 场景,附近共享单车
-
Stream
redis版本的MQ,消息中间件
# -和+表示最小和最大可能出现的ID # $表示当前流中最大ID的下一个ID,此时可能还未出现 # > 用于XREADGROUP命令,表示至今还没有发送给组中使用者的信息 # * 用于XADD命令中,让系统自动生成ID # 队列相关指令 # 添加消息到队列末尾 如果队列不存在则新建Stream队列 *表示自动生成ID,消息内容是filed-value ... 一次只能添加一条消息 XADD key [NOMKSTREAM] [MAXLEN|MINID [=|~] threshold [LIMIT count]] *|id field value [field value ...][ASC|DESC] # 控制stream的长度,如果已经超长会进行截取 可选项 截取的最大长度|截取的最小ID XTRIM key MAXLEN|MINID [=|~] threshold [LIMIT count] # 删除消息 XDEL key id [id ...] # 获取stream中的消息长度 XLEN key # 获取消息列表,可以指定范围,忽略删除的消息 XRANGE key start end [COUNT count] # XRANGE的逆序模式 XREVRANGE key end start [COUNT count] # 获取消息队列(阻塞/非阻塞),返回大于指定ID的消息 COUNT代表做多读取消息数量,默认不阻塞,BLOCK表示阻塞后面是最大阻塞时间,如果milliseconds=0,表示永远阻塞 XREAD [COUNT count] [BLOCK milliseconds] STREAMS key [key ...] id [id ...] # XREAD中COUNT取0-0、0 000表示stream中所有消息 # 消费组相关指令 # 创建消费者组,id取$表示从Stream尾部开始消费,取0表示从Stream头部开始消费 XGROUP CREATE key group id|$ [MKSTREAM] [ENTRIESREAD entries-read] # 消费组group内的消费者consumer从key消息队列中读取id消息, ">"表示从第一条尚未被消费的消息开始一直读取 # 注意,一个Stream中的消息一旦被一个消费者组里的一个消费者读取了,就不能再被同组的其他消费者读取了,但是不同消费组的消费者可以再次消费 XREADGROUP GROUP group consumer [COUNT count] [BLOCK milliseconds] [NOACK] STREAMS key [key ...] id [id ...] # 查询每个消费组内所有消费者 已读取,但尚未确认 的消息 XPENDING key group [[IDLE min-idle-time] start end count [consumer]] # 消费组确认已读消息 XACK key group id [id ...] # 打印 相关信息 XINFO STREAM|CONSUMER|GROUP key #
-
bitfield (了解)
bitfield命令可以将Redis字符串看作是一个由二进制位组成的数组,并对菏泽个数组中任意偏移进行访问。
Redis持久化
-
概述
为什么需要持久化:防止出现突然断电或者宕机导致的数据丢失。
如何实现持久化:通过将内存中的数据写入硬盘中获得持久化的功能。
-
RDB(Redis Database)
-
简介
Redis提供以指定的时间间隔执行数据集的时间点快照(snapshot)。类似照片,将某一时刻的数据和状态以文件的形式写入磁盘,恢复时将硬盘中的快照文件读入磁盘。此快照文件称为RDB(dump.rdb)。Redis6.0.16以及之前版本和之后的版本的默认快照频率有所不同。
- 频率设置,通过save命令或者配置文件
- 修改存储的二进制文件,修改配置文件中的路径(需要提前建立好文件夹)。
- 通过
config get dir
可以查看工作路径 - flushdb,shutdown也会保存在rdb文件中,但没有什么用处
- 物理恢复,一定要服务和备份分机隔离
手动触发:save和bgsave
# save在主程序中执行会阻塞当前redis服务器,直到持久化工作完成 生产环境禁止使用save # bgsave(默认)不会阻塞主程序,后台开辟一个子进程立刻来备份文件,最后替换原来的备份文件 # lastsave获取最后一次保存快照的时间,返回一个时间辍t # 通过 date -d @t 来将时间辍显示为正常的时间
-
优点
- RDB文件是非常紧凑的单文件时间点表示,非常适合备份
- RDB非常适合灾难恢复,他是一个可以传输到远程数据中心的压缩文件
- RDB最大限度的提高了Redis的性能,因为Redis父进程为了持久化需要的唯一工作就是派生一个将完成其余工作的子进程
- 与AOF相比,RDB允许使用大数据集更快的重启
- 在副本上,RDB支持重启和故障转移后的部分重新同步
适合大规模的数据恢复,按照业务定时备份,对数据完整性和一致性要求不高,RDB文件在内存的加载速度比AOF快的多。
-
缺点
并不完全安全(显而易见),fork较为频繁占用资源较多且数据集大时fork代价变大。
-
RDB文件修复
例如当RDB文件在写入时写入意外终止导致文件破损。
redis-check-rdb {file} # 使用该命令进行修复文件
-
触发RDB快照的情形
- 配置文件中默认的快照配置
- 手动save/bgsave命令
- 执行flushall/flushdb命令会产生dump.rdb文件,但是里面是空白
- 执行shutdown且没有设置开启AOF持久化
- 主从复制时,主节点自动触发
动态停止RDB保存的方法:
redis-cli config set save ""
/修改配置文件
-
-
AOF(Append Only File)
以日志的形式来记录每个写操作,将Redis执行国的所有写指令记录下来(读指令不记录),只许追加文件但不可以改写文件,redis启动之初会读取该文件重新构建数据,换言之,redis重启的话就根据日志文件的内容将写指令从前到后执行一次已完成数据的恢复工作。默认情况下,redis没有开启AOF功能,如果开启需要修改配置:
appendonly yes
,AOF保存的是appendonly.aof文件Redis7.0使用了Multi Part AOF设计
- BASE表示基础AOF,它一般由子进程通过重写产生,该文件最多只有一个
- INCR表示增量AOF,它一般会在AOFRW开始执行时被创建,该文件可能存在多个
- HISTORY表示历史AOF,它由BASE和INCR AOF变化而来,每次AOFRW成功完成时,本次AOFRW之前对应的BASE和INCR AOF都将变为HISTORY,HISTORY类型的AOF会被Redis自动删除,也就是在目录中看不到
- 为了管理这些AOF文件,引入manifest(清单)文件来跟踪管理这些AOF
-
AOF工作流程
修改数据的命令–>AOF缓冲区–>AOF文件–>AOF文件重写。如果AOF文件被破坏,Redis服务器将启动失败,
redis-check-aof {file} --fix
修复incr文件 -
三种写回策略
- always:同步回写,每个写命令执行完立刻同步地将日志写回磁盘
- everysec(默认),每秒写缓冲进入磁盘
- no:操作系统控制的回写,何时写入磁盘由操作系统决定
-
优点
更好的保护数据不丢失(例如最多一秒的丢失),性能高(子线程工作,有重写机制),可做紧急恢复(直接修改incr文件)
-
缺点
没有RDB性能好,恢复速度慢于RDB
-
AOF重写机制
只保留可以恢复数据的最小指令集,可以使用
bgrewriteaof
命令- 在重写开始之前,redis会创建一个重写子进程,这个子进程会读取现有的AOF文件,并将其包含的指令进行分析压缩并写入到一个临时文件中。
- 与此同时,主进程会将新接收到的写指令一边积累到缓冲区中,一边继续写入到原有的AOF文件中,这样做是保证原有的AOF文件的可用性,避免在重写的过程中出现意外。
- 当重写子进程完成重写工作后,他会给父进程发一个信号,父进程收到信号后就会将内存中缓存的写指令追加到新AOF文件中。
- 当追加结束后,redis就会用新AOF文件来代替旧AOF文件,之后再有新的写指令,就都会追加到新的AOF文件中。
- 重写AOF文件的操作,并没有读取旧的AOF文件,而是将整个内存中的数据库内容用命令的方式重写了一个新的AOF文件,这点和快照有点类似。
-
RDB和AOF混合使用
在同时开启AOF和RDB持久化时,(如果不开启混合模式)重启时只会加载aof文件而不会家在rdb文件,也就是说AOF优先级高于RDB。RDB更适合备份操作,建议二者同时使用。
混合使用时,RDB镜像做全量持久化,AOF做增量持久化。先使用RDB进行快照存储,然后使用AOF持久化记录所有的写操作,当重写策略满足或者手动触发重写的时候,将最新的数据存储为RDB记录。如此,重启服务的时候会从RDB和AOF两部分恢复数据,既保证数据的完整性,有提高了数据恢复的性能。
-
纯缓存模式
关闭RDB和AOF,性能最高但丧失持久化功能。
Redis事务(Transactions)
-
概述
数据库中的事务:一次会话中的所有操作,要么一起成功,要么一起失败。
Redis事务:为了保证一组Redis命令的原子性(不允许加塞)。在一个队列中,一次性,顺序性,排他性地执行一系列命令。
Redis事务和数据库事务的区别
- 单独的隔离操作:Redis事务仅仅是保证事务里的操作会被连续独占执行,redis命令执行是单线程架构,在执行完事务内所有指令前是不可能再去同时执行其他客户端的请求的
- 没有隔离级别的概念:因为事务提交前任何指令都不会被实际执行(被插入到一个待执行队列中),也就不存在“事务内的查询要看到事务里的更新,事务外的查询不能看到”这种问题
- 不保证原子性:Redis事务不保证原子性,也就是不保证所有指令同时成功或同时失败,只有决定是否开始执行全部指令的能力,没有执行到一半进行回滚的能力
- Redis会保证一个事务内的命令依次执行,而不会被其他命令插入。
-
使用
-
命令
MULTI #标记一个事务块的开始 EXEC # 执行所有事务块内的命令 DISCARD # 取消事务,放弃执行事务块内的所有命令 WATCH key [key ...] # 监视一个或者多个key,如果在事务执行之前这些key被其他命令所改动,那么事务EXEC将被打断 UNWATCH # 取消对所有key的监视
-
正常执行:MULTI + … + EXEC
-
放弃事务:MULTI + … + DISCARD
-
全体连坐:事务中出现错误两种情况,在执行EXEC之前输入命令出错,这会让所有命令取消。
-
冤头债主:EXEC之后命令执行出错,只有错误的不会执行,其他的会正确执行
-
watch监控,一般在事务前开启,时间点由watch命令执行时确定
-
Redis使用Watch来提供乐观锁,类似于CAS(Check-and-Set)
-
悲观锁
很悲观,每次取拿数据都认为别人会修改(认为会有人争夺资源),所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会block直到它拿到锁
-
乐观锁
每次拿数据的时候认为别人不会修改,所以不会上锁,但是在更新的时候会判断以下在此期间有没有人去更新这个数据。乐观锁策略:提交版本必须大于记录当前版本才能执行更新
-
CAS
check and set,检查之后决定设置值
一旦执行了EXEC命令,之前添加的监控所都会被取消,当客户端连接丢失的时候,所有数据会被取消监视。
-
-
-
-
Redis 管道
和Redis事务看起来类似,本质完全不同。管道是一种不注重原子性但为了提高性能的设计
-
概述
问题由来:解决客户端服务端频繁往返交互而导致的性能瓶颈
解决思路:管道(pipeline)可以一次性发送多条命令给服务端,服务端依次处理完毕后,通过一条相应一次性将结果返回,通过减少客户端与redis服务器的通信次数来降低往返延迟时间。pileline实现的原理是队列,先进先出保证数据的顺序性。
总之,批处理命令变种优化措施,类似Redis的原生批处理命令(mget,mset)。
-
使用
假设将需要执行的命令写在一个a.txt文件中,每行一个命令占用一行,在shell中输入即可,使用pipe参数
cat a.txt | redis-cli -a {password} --pipe
-
总结
-
pipeline与原生批处理命令(mset)对比
原生批处理命令具有原子性,而pipeline不具有原子性
原生批处理命令一次只能执行一种命令,pipeline支持批量执行不同命令
原生批处理命令服务端实现,而pipeline需要服务端与客户端共同完成
-
pipeline与事务对比
pipeline不具有原子性,事务具有原子性
pipeline一次性将多条命令发送到服务器,事务是一条一条发送,事务只有在接受到exec命令后才会真正开始执行
执行事务时会阻塞其他命令的执行,而管道不会(不保证原子性)
-
使用注意事项
pipeline中组装的命令不建议太多,否则会消耗大量内存
Redis 发布订阅(pub/sub)
了解即可
一种消息通信模式,发送者PUBLISH发送消息,订阅者SUBSCRIBE接受消息,可以实现进程间的消息传递。
Redis 复制(replica)
-
概述
主从备份,master以写为主,slave以读为主(负载均衡)。当master数据变化的时候,自动将最新的数据异步同步到其他slave数据库。配从(库)不配主(库)。
主从连接。master如果配置了requirepass参数,需要密码登陆,那么slave就要配置masterauth来设置校验密码,否则master会拒绝slave的访问请求。
-
使用
info rqlication # 可以查看复制节点的主从关系和配置信息 replicaof masterIP masterPort # 一般写入进从机redis.conf配置文件内 slaveof masterIP masterPort # 每次与master端开后,都需要重新连接,除非已经配置了redis.conf文件,此命令是在运行期间修改slave节点信息,可以更换主数据库 slaveof no one # 使当前数据库停止与其他数据库的同步
- 从机之可以读取,不允许写操作
- 从机无论何时切入,都会恢复主机所有的数据(包括切入之前)
- 主机关闭,从机会保持原状等待与从机的继续连接
- 从机作为中间节点虽然也是一个“主机”,但是依然没有写权限
- 一开始,全量复制:master节点收到sync命令后会开始在后台保存快照(RDB持久化,主从复制会触发RDB),同时收集所有接受到的用于修改数据集命令缓存起来,master节点执行完RDB持久化后将rdb快照文件和所有缓存命令发送到所有slave,以完成一次同步;而slave服务在接受到数据库文件后,将其存盘并加载到内存中,从而完成复制初始化
- 默认十秒钟发送一次“ping”保持联系
- 平稳后增量复制:master持续将新的所有收集到的修改命令自动依次传给slave,完成后续同步
- 断点续传:master会检查backlog里面的offset,master和slave都会保存一个复制的offset还有一个masterId,offset是保存在backlog中的,Master只会把已经复制的offset后面的数据复制给Slave。
-
缺点
- 复制延时,信号衰减,slave增加会使延时增加
- 主机掉线,系统瘫痪
-
Redis 复制(replica)
-
概述
主从备份,master以写为主,slave以读为主(负载均衡)。当master数据变化的时候,自动将最新的数据异步同步到其他slave数据库。配从(库)不配主(库)。
主从连接。master如果配置了requirepass参数,需要密码登陆,那么slave就要配置masterauth来设置校验密码,否则master会拒绝slave的访问请求。
-
使用
info rqlication # 可以查看复制节点的主从关系和配置信息 replicaof masterIP masterPort # 一般写入进从机redis.conf配置文件内 slaveof masterIP masterPort # 每次与master端开后,都需要重新连接,除非已经配置了redis.conf文件,此命令是在运行期间修改slave节点信息,可以更换主数据库 slaveof no one # 使当前数据库停止与其他数据库的同步
- 从机之可以读取,不允许写操作
- 从机无论何时切入,都会恢复主机所有的数据(包括切入之前)
- 主机关闭,从机会保持原状等待与从机的继续连接
- 从机作为中间节点虽然也是一个“主机”,但是依然没有写权限
- 一开始,全量复制:master节点收到sync命令后会开始在后台保存快照(RDB持久化,主从复制会触发RDB),同时收集所有接受到的用于修改数据集命令缓存起来,master节点执行完RDB持久化后将rdb快照文件和所有缓存命令发送到所有slave,以完成一次同步;而slave服务在接受到数据库文件后,将其存盘并加载到内存中,从而完成复制初始化
- 默认十秒钟发送一次“ping”保持联系
- 平稳后增量复制:master持续将新的所有收集到的修改命令自动依次传给slave,完成后续同步
- 断点续传:master会检查backlog里面的offset,master和slave都会保存一个复制的offset还有一个masterId,offset是保存在backlog中的,Master只会把已经复制的offset后面的数据复制给Slave。
-
缺点
- 复制延时,信号衰减,slave增加会使延时增加
- 主机掉线,系统瘫痪
Redis 哨兵(sentinel)
-
概述
吹哨人巡查监控后台master主机是否故障,如果故障了根据投票数自动将某一个库转换为新主库,继续对外服务。(主从复制+哨兵,不能同时使用集群)
哨兵的作用:监控,通知,自动容错,提供
- 主从监控:监控主redis库是否正常运行
- 消息通知:哨兵可以将故障转移结果发送给客户端
- 故障转移:如果master异常,会进行主从切换,将其中一个slave作为新的master
- 配置中心:客户端通过连接哨兵来获得当前Redis服务的主节点地址
为什么需要多个哨兵,因为网络是不绝对稳定的,一个哨兵可能和主机连接不流畅而误认为主机掉线,因此多个哨兵投票可以减少这种错误的判断。
-
使用
哨兵默认使用端口号26379
# 启动哨兵 redis-centinel /path/to/sentinel.conf # 或者 redis-server /path/to/sentinel.conf --sentinel
Broken Pipe异常
pipe是管道的意思,管道里面是数据流,通常是文件或网络套接字读取的数据。当该管道从另一端突然关闭时,会发生数据突然中断,即是broken。
注意:原来的主库应该也配置上主机密码,这样他断开重连后再次和新主库也可以正常连接而成为从库,以前的主库和新主库的配置文件也会被哨兵修改;一个哨兵可以监控多个master。
-
运行流程和选举原理
-
主观下线(Subjectively Down)
SDOWN(主观不可用)是单个sentinel自己主观检测到的关于master的状态,从sentinel的角度来看,如果发送了PING心跳后,在一定时间内没有收到合法的回复,就达到了SDOWN的条件。
sentinel配置文件中的down-after-milliseconds设置了判断主观下线的时间长度(默认30s)
-
客观下线(Objectively Down)
ODOWN需要一定数量的sentinel,多个哨兵达成一致意见才能认为一个master客观上已经宕机
-
选举出领导者哨兵
Raft算法,基本思路是先到先得
-
由领导者哨兵推动故障切换流程并选出一个新master
选出新master的规则,三个指标依次判断,相同则判断下一指标:priority(权限,高者优先)–>replication offset(复制偏移量,大者优先)–>Run ID(小者优先)。
在redis.conf文件中,slave-priority或者replica-priority最高的从节点优先级高。
sentinel leader会选举出新的master执行slave of no one操作,将其提升为master节点
sentinel leader向其他slave发送命令,让剩余的slave成为新的master节点的slave
以前的master重新连接后成为slave
-
-
使用建议
- 哨兵节点数量应为多个,哨兵本身应该集群,保证高可用
- 哨兵节点的数量应该是奇数
- 各个哨兵节点的配置应该一致(包括硬件配置)
- 如果哨兵节点部署在Docker等容器里面,尤其要注意u端口的正确映射
- 哨兵集群+主从复制,并不能保证数据零丢失
-
缺点
哨兵保证了自动维护出新主节点功能,但是中间会产生一段时间真空期导致数据丢失
Redis 集群(Cluster)
-
概述
由于数据量过大,单个Master复制集难以承受,因此需要对多个复制集进行集群,形成水平扩展,每个复制集只负责存储整个数据集的一部分,这就是Redis集群,其作用是提供在多个Redis节点间共享数据的程序集。
- Redis集群支持多个master,每个master又可以挂载多个slave,以实现读写分离,数据的高可用和海量数据的读写存储操作
- 由于Cluster自带Sentinel的故障转移机制,内置了高可用的支持,无需再去使用哨兵功能
- 客户端与Redis的节点连接,不再需要连接集群中所有的节点,只需要在任意连接集群中的一个可用节点即可
- 槽位slotfuze分配到各个物理服务节点,由对应的集群来负责维护节点/插槽和数据之间的关系
-
集群相关概念以及算法
-
槽位slot和分片
设计了16384个槽位,极限情况下一个槽位之对应一个节点(Redis服务器),但是官网建议不超过1000个节点(一个节点包含多个槽位)。
每个key通过CRC16校验后对16384取模来决定数据放在哪个槽位中
使用Redis集群时我们会将存储的数据分散到多台Redis服务器上,这称之为分片。为了找到给定的key分片,通过key进行CRC16(key)算法处理并对总分片取模,最后使用一个确定性的哈希函数找到对应的分片,这意味着给定的key将多次始终映射到同一分片上。
优势,方便扩容和缩容和数据分派查找
-
slot槽位映射,一般业界有三种解决方案
-
哈希取余分区
-
一致性哈希算法分区
提出一致性hash的解决方案,目的时的那个服务器个数发生变动时,尽量减少影响客户端到服务器的映射关系。
- 算法构建一致性哈希环
- 服务器IP节点映射
- key落到服务器的落键规则(顺时针向前走找到第一个可用服务器)
优点:容错性(只会有一部分数据受到影响),扩张性(不需要hash全部重新洗牌)
缺点:不均匀,更容易出现头重脚轻问题(负载均衡效果不好)
-
哈希槽分区(Redis)
解决一致性哈希算法中的均匀分配问题,在数据和节点之间又加入了一层哈希槽,用于管理数据和节点之间的关系,现在就相当于节点上放的是槽,槽里放的是数据。
槽解决的是粒度问题,相当于把粒度变大了这样便于数据移动;哈希解决的是映射问题,使用key的哈希值来计算所在的槽,便于数据分配。
HASH_SLOT = CRC16(key) mod 16384
-
-
为什么Redis集群的最大槽数是16384个?
- 减少心跳包的大小
- Redis集群的主节点数量基本不可能超过一千个,因此16384个槽位也够用了
-
Redis集群不保证强一致性
这意味着在特定条件下,Redis集群可能会丢掉一些被系统收到的请求命令
-
-
使用
设置好配置文件,使用该配置文件其同redis服务器
启动redis客户端的时候加上-c选项,实现重定向
CLUSTER FAILOVER
集群节点主从调整 -
扩容和缩减
当增加节点时,从之前每个节点区一部分拿给新节点
减少节点时,先将减少的主节点的从节点下线,然后将从节点的槽位全部分配给一个制定的节点,然后执行重新哈希操作,此时原本要减少的节点变成移动的目标节点的从节点了
-
其他
-
不在同一个slot槽位下的多键操作支持不好,通识占位符
不在同一个slot槽位下的键值无法使用mset,mget等多键操作,可以通过{}来定义同一个组的概念,使key中{}内相同内容的键值对放到一个slot槽位去
mset k1{z} v1 k2{z} v2 k3{z} v3 # z这里是通配标识符 mset k1{z} k2{z} k3{z} # 取值
-
Redis 分布式锁
使用 set [NX] [EX]
设置一个值,用来作为分布式锁
高级篇
参考尚硅谷网课
单线程、多线程和IO多路复用
面试题
- Redis是单线程还是多线程?
- IO多路复用听说过吗?
- Redis为什么这么快?
- Subtopic
Redis的多线程支持情况
Redis4之后才慢慢支持多线程(混合持久化RDB、AOF,异步删除等功能由额外线程完成,unlink key
,flushall async
),Redis6版本之后才逐渐稳定
Redis单线程是什么意思
主要是Redis的网络IO和键值对的读写是有一个线程来完成的,Redis服务器在处理客户端的请求(读取socket–>解析请求–>执行操作–>写入socket)由一个顺序串行的主线程处理,这就是所谓的单线程。
Redis采用Reactor模式的网络模型,对于一个客户端的请求主线程负责一个完整的处理。
但是,Redis整体是多线程的,而且随着多核CPU的发展,多线程自然优势体现
Redis为什么这么快
- 基于内存操作,Redis所有数据在内存中,所以性能比起数据库要高;
- 数据结构简单,Redis数据结构式专门设计的,简单的数据结构查找操作的时间复杂度很低;
- 多路复用和非阻塞I/O,Redis使用I/O多路复用功能来监听多个socket连接客户端,这样可以只使用一个线程,同时避免I/O阻塞
- 单线程版本时避免了上下文切换而且无锁
Redis瓶颈不在CPU而是在网络和内存。
Redis6之后使用多线程,但是只有在网络操作,解析命令等时才会使用多线程处理,对于读写操作命令的处理依然是单线程。
Unix网络编程中的五中I/O模型
blocking IO 阻塞IO
NoneBlocking 非阻塞IO,轮询代替阻塞
IO multiplexing IO多路复用
signal driven IO 信号驱动IO
asynchronous IO 异步IO
Redis主线程和IO线程是怎么协作完成请求处理的
IO的读写本身是阻塞的,比如当socket中有数据时,Redis会通过调用先将数据从内核态空间拷贝到用户态空间,再交给Redis调用,而这个拷贝过程就是阻塞的,当数据量越大时拷贝所需要的时间就越多,而这些操作都是基于单线程完成的。
从Redis6开始,就新增了多线程的功能来提高IO的读写性能,他的主要实现思路是将主线程的IO读写任务拆分给一个独立的线程去执行,这样就可以使多个socket的读写可以并行化了,采用IO多路复用技术可以让单个线程高效的处理多个连接请求,将最耗时的Socket的读取,请求解析,写入操作单独外包出去,剩下的命令执行仍然由主线程串行执行和内存的数据交互。
简单来说就是将网络数据读写,请求协议解析通过多个IO线程来处理从而提升网络IO性能,对于真正命令的执行仍然是主线程操作从而保证线程安全和高性能。这样同时用到了多线程和单线程的优势。
Redis7使用多线程
Redis6/7默认关闭多线程,如果RedisCPU开销不大,但是吞吐量没有提升,可以考虑开启多线程模式
在配置文件中的THREADED I/O中
IO多路复用要解决的问题
如果每来一个客户端连接就新开一个进程/线程来处理,这样资源开销很大也不实际,因此使用IO多路复用,使用一个进程就可以同时处理多个请求;同时,如果快速知道在很多个客户端中有哪个正在请求操作,这也是IO多路复用要解决的问题
Redis利用epoll
来实现IO多路复用,将连接信息和事件放到队列中,一次放到文件事件分派器,事件分派器将事件分发给事件处理器。
所谓IO多路复用机制,就是说通过一种机制,可以监视多个描述符,一但某个描述符就绪(一般是读就绪或者是写就绪),能够通知程序进行相应的读写操作。这种机制的使用需要select
,poll
,epoll
函数来配合。多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象上等待,无序阻塞等待所有连接。当某条连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理。
Redis的文件事件处理器
Redis基于Reactor的方式来实现文件事件处理器(每一个网络连接对应一个文件描述符)。文件事件处理器由4个主要部分构成,分别是多个套接字、IO多路复用程序、文件事件分派器、事件处理器。因为文件事件分派器的消费队列是单线程的,所以Redis命令执行时单线程,无需加锁。
异步同步
同步:调用者要一直等待调用结果的通知后才能进行后续的执行
异步:被调用方先返回应答让调用者先处理别的事,等到结果计算出来后才将结果通知调用方,异步调用想要获得结果一般需要进行回调。
同步、异步的讨论对象是被调用者(服务提供者),重点在于获得调用结果的消息通知方式上
select
select是一个阻塞函数,当没有数据时,会一直阻塞在select那一行
select函数工作在内核态,这样判断socket中的数据时就不需要反复在内核态用户态切换,效率更高。
缺点,select不知道是哪个实际触发,依然需要自己遍历。状态数组不能复用,最大数量有限制。
poll
与select类似,但是突破了select最大1024的限制,并且可以服用状态数组。
epoll
int epoll_creat(int size);
//给出建议值
int epoll_ctl(int epfd,int op,int fd ,struct epoll_event *event);
int epoll_wait(int epfd,struct epoll_event *events,int maxevents,int timeout);
epoll是非阻塞的,执行流程如下
- 当有数据的时候,会把相应的文件描述符”置位“,但是epoll没有revent标志位,所以并不是真正的置位。但是会把有数据的文件描述符放到队首。
- epoll会返回有数据的文件描述符的个数
- 根据返回的个数读取前N个文件描述符即可
- 读取,处理
多路复用快的原因在于操作系统提供了epoll这样的系统调用,这是的原来(非多路复用)中需要的循环系统调用判断变成了一次系统调用+内核层次遍历这些文件描述符。多路是指多个网络连接,复用是指复用一个线程。
epoll底层数据结构是红黑树+双向链表
BigKey问题
MoreKey 问题
生产上redis数据库有1000W条数据,你如何遍历?keys *可以用吗?
# 向文本文件写入100W条指令
for((i=1;i<100*10000;i++)); do echo "set k$i v$i" >> /tmp/redisTest.txt; done;
# 通过管道执行这些指令
cat /tmp/redisTest.txt | redis-cli -h 127.0.0.1 -p 6379 -a password --pipe
*生产上如何限制keys /flushdb/flushall等危险命令的使用
redis.conf配置文件的SECURITY这一选项中,将对应命令设置为空
*用不了keys ,那使用什么
用SCAN,和SQL中的 limit类似
SCAN cursor [MATCH pattern] [COUNT count]
每次返回一个数组,第一个是下一次迭代的游标,第二个是一定量的数据。
SCAN 不是从数据的第零位遍历到最后一位,而是采用高位进位加法来遍历的,这里关系到字典在渐进式冲哈希的时候的遍历策略,防止重复遍历。
BigKey问题
注意 ,大key是指key对应的value很大,因为key本身就不会很大
多大算大key
阿里云开发规范:String类型控制在10KB以内,hash、list、set、zset元素个数不要超过5000
BigKey的危害
内存不均,集群前移困难;超时删除导致阻塞;网络流量阻塞
BigKey如何产生
社交类:粉丝数据链表逐渐递增
汇总类:某个日志常年累月积累
如何发现
redis-cli --bigkeys -h -p -a
# 该shell命令会扫描redis数据全部内容
MEMORY USAGE key [SAMPLES count]
# redis命令
# 给出一个key和它的值在内存中占用的字节数
# 返回的结果是key的值以及管理该key分配的内存总字节数
如何删除
非字符串类型的BigKey,不要使用del删除,使用hscan、sscan、zscan等渐进式删除,同时防止BigKey过期自动删除导致阻塞的问题。
生产调优
redis.conf文件中LAZY FREEING 相关说明,建议使用非阻塞命令
缓存双写一致性之更新策略探讨
Redis和MySQL的共同使用
双写一致性,谈谈你的理解
Redis中数据状态
- 如果redis中有数据,需要保证redis中数据和数据库中一致
- 如果redis中没有数据,保证数据库中是最新数据,且准备将数据回写入redis
缓存读写策略
-
同步直写
写数据库后同步写入redis缓存,缓存和数据库中内容一致。对于读写缓存来说,要想保证缓存和数据库一致,就要采用同步直写策略。一般是特别重要或者敏感的数据采用。
-
异步缓写
正常业务中,MySQL数据变动了,但是可以在业务上容许出现一定时间之后才作用于redis,例如仓库,物流等;异常情况出现时,不得不将失败的动作重新修补,有可能需要使用Kafka等消息中间件,实现重写重试。
双检加锁策略
我们在redis没有查到缓存时,会进一步去操作数据库,这时候多线程并发就会出现问题,可能会让数据库宕机或者重复读写导致浪费性能。也就是说回写缓存进redis时需要加锁。双检的意思是加锁前和加锁后都查询一次redis缓存,然后进一步处理。因为多个线程并发,争抢锁,只有第一个人做了缓存,其他人其实不用去缓存了,直接查询redis即可,因此使用双检。这个场景主要发生在重建缓存的过程中。
数据库和缓存一致性的几种更新策略
给缓存设置过期时间,定期清理缓存并回写,是保持最终一致性的解决方案。
我们可以对存入缓存的数据设置过期时间,所有的写操作以数据库为准,对缓存操作只是尽最大努力即可。也就是说如果数据库写成功,缓存更新失败,只要到达过期时间,后面的读请求自然会从数据库中读取新值然后回填缓存,达到一致性,切记,要以MySQL数据库写入库为准。
服务可以停机的情况
容易解决,因为停止服务后直接开一个单线程去让redis同步MySQL即可。
四种更新策略
-
先更新数据库,再更新缓存
-
先更新缓存,再更新数据库
-
先删除缓存,再更新数据库
ABA情形:A先删除缓存,但还没来得及更新数据库,此时B查询,发现没缓存因此查询数据库得到久数据并回写到redis中,造成A第一次删除缓存失效。延时双删:A在成功更新数据库后再次删除缓存。为了能够保证A第二次正常删除,A更新数据库后需要睡眠一段时间t,这段时间t应该大于其他线程读数据库并回写缓存的时间。----->优化:启动一个异步线程,t时间之后去执行删除缓存操作。
缺点:先删除缓存再更新数据库,有可能导致请求因缓存确实打到数据库给数据增加压力;如果业务中处理时间不好计算,延时双删的等待时间也不好确定。
-
先更新数据库,再删除缓存(推荐使用)
MySQL一出现更新就立即通知Redis
canal
bitmap/hyperloglog/GEO
待补充…
布隆过滤器
什么是布隆过滤器(Bloom Filter)
布隆过滤器(Bloom Filter)是1970年由布隆提出的。它实际上是一个很长的二进制向量和一系列随机映射函数。布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都比一般的算法要好的多,缺点是有一定的误识别率和删除困难。
优点
- 时间复杂度低,增加和查询元素的时间复杂为O(N),(N为哈希函数的个数,通常情况比较小)
- 保密性强,布隆过滤器不存储元素本身
- 存储空间小,如果允许存在一定的误判,布隆过滤器是非常节省空间的(相比其他数据结构如Set集合)
缺点
- 有点一定的误判率,但是可以通过调整参数来降低
- 无法获取元素本身
- 很难删除元素
布隆过滤器的使用场景
布隆过滤器可以告诉我们 “某样东西一定不存在或者可能存在”,也就是说布隆过滤器说这个数不存在则一定不存,布隆过滤器说这个数存在可能不存在(误判,后续会讲),利用这个判断是否存在的特点可以做很多有趣的事情。
- 解决Redis缓存穿透问题(面试重点)
- 邮件过滤,使用布隆过滤器来做邮件黑名单过滤
- 对爬虫网址进行过滤,爬过的不再爬
- 解决新闻推荐过的不再推荐(类似抖音刷过的往下滑动不再刷到)
- HBase\RocksDB\LevelDB等数据库内置布隆过滤器,用于判断数据是否存在,可以减少数据库的IO请求
布隆过滤器的原理
数据结构
Redis中的布隆过滤器是一个大型数组(二进制数组)+多个无偏hash函数
无偏hash函数就是能把元素的hash值计算的比较均匀的hash函数,能使得计算后的元素下标比较均匀的映射到位数组中。
查找一个元素时,我们先将这个元素用所有的hash函数计算一遍获得若干个索引值,如果数组中这些索引的值都是1,表示该元素存在。插入元素同理,将对应索引位置1。下面图表示在布隆过滤器中使用a/b/chash函数查找k1和k2。
一般规律:
- 位数组越长,空间开销越大 ----> 错误率越低
- hash函数越多,计算时间越长 ----> 错误率越低
Redis源码解析笔记
部分参考资料
https://www.zhihu.com/column/c_1231226679504166912
https://learn.lianglianglee.com/%E4%B8%93%E6%A0%8F/Redis%20%E6%BA%90%E7%A0%81%E5%89%96%E6%9E%90%E4%B8%8E%E5%AE%9E%E6%88%98
redis-cli.c
redis-cli程序源文件
Reids 1.3.6
-
config结构体
static struct config { char *hostip; // IP 地址 int hostport; // 端口号 long repeat; // 返回次数? int dbnum; // 数据库编号,默认0 int interactive;// char *auth; // 密码 } config;
有些数据从配置文件中获取,有些从命令行获取
-
redisCommand
struct redisCommand { char *name; int arity; int flags; };
命令结构体,主要是命令的名称,参数的数量(包括名称自己),标志(用于控制输出信息等)
-
函数
一些基本功能的函数,如解析命令种类,与客户端建立连接,发送接受消息(socket编程),打印输出信息,将命令行命令使用自定义规则封装成指定格式(如添加*、$换行符等符号),命令行交换函数(等待你的输入)。
主函数执行流程,对应着你在命令行中输入
redis-cli -p xxxx [-a xxxxxxx]
首先会将config设置一些默认值,如IP设置为127.0.0.1,port设置为6379,然后解析启动的命令行参数,如-p -a等,如果参数全部解析完毕后没有其他参数类,进入交互模式,如果还有则执行这个命令(也就是只执行一次?)
这里没有看到读取配置文件的过程。
Redis为什么不用char *
char *
保存的字符串配合C语言标准库也可以提供较丰富的操作,但仍有其致命缺点。例如使用’\0’作为终止符导致char *
字符串无法保存’\0’,同时追加字符时间复杂度很大,因为每次都需要遍历找到最后位置,而且不能保存图片信息。
sds.c
数据结构 sds
Reids 1.3.6
typedef char * sds; // 这意味着 sds 其实就是一个 char *
-
sdshdr
struct sdshdr { long len; // 当前字符串长度不包含 long free;// 空间剩余可用长度 char buf[];// 空间指针 };
sds的设计初衷是为了实现一个扩容方便而且二进制安全的字符串
在64位系统中len和free字段各占8个字节,紧接着就是字符串了,由于buf是最后一个元素,所以其是一个柔性数组。
sds设计的好处:
- 有单独的变量统计len和free,可以很方便的知道字符串长度,避免了对字符串的遍历操作,降低了操作开销,进一步就可以帮助诸多字符串操作更加高效地完成,比如创建、追加、复制、比较等
- 内容直接存放在柔性数组buf中,SDS对上层暴露指针不是指向SDS接口体的指针而是指向buf的指针,这样可以像读取C字符串一样读取sds
但是使用long来记录在短字符串时有太浪费,所以在redis5.0之后,又提出了
sdshdr8/16/32/64
这些结构体,用来分别记录不同长度的字符串struct __attribute__((__packed__))sdshdrX { uintX_t len; uintX_t free; unsigned char flag; // 第三位存储类型,高五位预留位 char[] buf; }
这样看来,仍是柔性数组,其中
__attribute__((__packed__))
用来设置为按1个字节对齐,这样如果我们获得buf地址,做减一操作后就可以获得flag字段了。注意由于buf是一个柔性数组,所以sizeof(sdshdr)
的值是除了buf之外其他变量的总大小。// sdsnewlen函数,反映了sds的设计思路 sds sdsnewlen(const void *init, size_t initlen) { struct sdshdr *sh; sh = zmalloc(sizeof(struct sdshdr)+initlen+1); if (sh == NULL) return NULL; sh->len = initlen; sh->free = 0; if (initlen) { if (init) memcpy(sh->buf, init, initlen); else memset(sh->buf,0,initlen); } sh->buf[initlen] = '\0'; return (char*)sh->buf; }
利用
va_list
变量,va_start/va_end/vsnprintf
函数来进行可变参数列表的操作
zmalloc.c
此文件封装了malloc的一些操作
void *zmalloc(size_t size); // malloc
void *zrealloc(void *ptr, size_t size); // realloc
void zfree(void *ptr); // free
char *zstrdup(const char *s); // 复制
size_t zmalloc_used_memory(void); // malloc_used_memory
void zmalloc_enable_thread_safeness(void);// 开启线程安全的标志
zmalloc分配的内存,的首个sizeof(size_t)
大小的空间用来存储申请的内存大小,同样隐藏了前面的字段,从外部看起来就像malloc的行为一样
zipmap.c
String -> String Map data structure optimized for size。 string到string的映射
Reids 1.3.6
参考资料
https://zhuanlan.zhihu.com/p/138105887
zipmap本质就是一个unsigned char
类型的数组,初始时占用两个字节,第一个字节初始化为0,代表状态,第二个字节初始化为255,代表zipmap结束标志
由于是线性存储结构,查找时间复杂度是O(n)
内存友好的数据结构该如何细化设计
Redis作为一种内存型数据库,必然需要花精力去提高内存使用率。实际上,Redis主要是通过两个方法来实现对内存的高效利用的:数据结构的优化设计与使用和内存数据按一定规则淘汰。
结构体的位域定位方法,以redisObject
为例 位域
typedef struct redisObject {
unsigned type:4; //redisObject的数据类型,4个bits
unsigned encoding:4; //redisObject的编码类型,4个bits
unsigned lru:LRU_BITS; //redisObject的LRU时间,LRU_BITS为24个bits
int refcount; //redisObject的引用计数,4个字节
void *ptr; //指向值的指针,8个字节
} robj;
可以看到前三个成员变量没有声明具体类型,只是限制为无符号,但是通过冒号指明了占用了多少位。这样更加节省了内存,因为甚至一个字节都用不到。
SDS是一种内存友好的数据结构,首先为了高效存储不同长度的字符串,Redis设计了不同类型的结构头如sdshdr8/16/32/64,进而实现内存的高效使用。其次对于短字符串(44字节?),Redis将字符串具体内容与redisObject结构体存放在一个连续的空间,具体的做法就是自己开辟空间,利用C语言提供的指针操作将redisObject和字符串内容赋值到连续空间,这样可以减少内存碎片的出现,这种做法被称为嵌入字符串。
ziplist,listpack数据结构也是内存友好数据结构。Redis 设计了巧妙的编码方式,高效的组织和存储元素。
节省内存的数据访问,Redis使用过程中有些数据可能会被经常访问,因此redis提出了共享对象,在server.c中的createSharedObjects
函数中创建。共享对象适用于只读对象,如返回的提示词“OK”。
总结,对于实现数据结构来说,如果想要节省内存,Redis 就给我们提供了两个优秀的设计思想:一个是使用连续的内存空间,避免内存碎片开销;二个是针对不同长度的数据,采用不同大小的元数据,以避免使用统一大小的元数据,造成内存空间的浪费。
SDS 判断是否使用嵌入式字符串的条件是 44 字节,你知道为什么是 44 字节吗?
44是因为 N = 64 - 16(redisObject) - 3(sdshr8) - 1(‘\0’), N = 44 字节。那么为什么是64减呢,为什么不是别的,因为在目前的x86体系下,一般的缓存行大小是64字节,redis为了一次能加载完成,因此采用64自己作为embstr类型(保存redisObject)的最大长度。
如何实现一个性能优异的hash表
hash表两个最基本的问题是如何处理哈希冲突和如何降低rehash的影响。面对这两个问题,Redis的解决方案是开链法和渐进式rehash设计。
dict.c
哈希表 字典
参考资料
https://zhuanlan.zhihu.com/p/135447447
https://zhuanlan.zhihu.com/p/136217929
https://zhuanlan.zhihu.com/p/136818354
-
数据结构
-
dictEntry
哈希表的一个桶,可以看出来是一个单向链表,因此Redis中也是使用开链法解决冲突的
typedef struct dictEntry { void *key; //用于存储Key union { void *val; //可以用来保存一段具体的内存二进制数据 uint64_t u64; //可以用来存储一个64位无符号整形数据 int64_t s64; //可以用来存储一个64位有符号整形数据 double d; //可以用来存储一个双精度浮点数据 } v; //使用union来保存value数据 struct dictEntry *next; //由于使用基于拉链的哈希表实现,next用于指向桶中下一个key-value对。 } dictEntry;
-
dicht
typedef struct dictht { dictEntry **table; //table是一个数组结构,其中的每个元素都是一个dictEntry指针 unsigned long size; //table中桶的数量 unsigned long sizemask; //table中桶数量的掩码 unsigned long used; //该哈希表之中,已经保存的*key-value*的数量 } dictht;
这里体现了Redis哈希表与 g++ 中
unordered_map
的一个不同之处,g++ 总是会选取一个素数作为桶的数量, 而在Redis之中,桶的数量一定是2的n次方个,那么当dictht.size
为16的时候,dictht.sizemask
对应而二进制形式变为1111
, 这样对于一个给定的哈希值h
使用h & sizemask
可以获得哈希值对size
的取余操作结果。 根据余数,可以决定这个key-value
数据落在哪个哈希表的哪个桶中。 -
dictType
存储哈希基本操作的函数指针
typedef struct dictType { uint64_t (*hashFunction)(const void *key); //通给可定key,计算对应的哈希值 void *(*keyDup)(void *privdata, const void *key); //用于复制key的函数指针 void *(*valDup)(void *privdata, const void *obj); //用于复制value的函数指针 int (*keyCompare)(void *privdata, const void *key1, const void *key2); //两个key的比较函数 void (*keyDestructor)(void *privdata, void *key); //用于处理key的释放 void (*valDestructor)(void *privdata, void *obj); //用于处理val的释放 } dictType;
体现了C语言的函数式编程思想,有点贴近C++的面向对象设计思想Redis为不同的哈希表给出了不同的
dictType
,例如在src/server.c文件中,键是sds类型,而值可以是其他很多类型/* Db->dict, key是sds动态字符串, vals则是Redis对象类型 */ dictType dbDictType = { dictSdsHash, /* hash function */ NULL, /* key dup */ NULL, /* val dup */ dictSdsKeyCompare, /* key compare */ dictSdsDestructor, /* key destructor */ dictObjectDestructor /* val destructor */ };
-
dict
基于
dictht
以及dictType
这两个数据结构,Redis定义最终的哈希表数据结构dict
typedef struct dict { dictType *type; void *privdata; dictht ht[2]; long rehashidx; //重哈希索引,如果没有在进行中的重哈希,那么这个rehashidx的值为-1 unsigned long iterators; /* number of iterators currently running */ } dict;
可以注意到其中的一个特别设计之处, 也就是在
dict
结构中会有两个底层的哈希表数据结构dict.ht[2]
, 这与*g++*中的unordered_map
的设计有着显著的不同为什么Redis的哈希表要有两个呢???
之所以Redis在
dict
中使用两个hashtable,其主要用意是方便在进行ReHash操作时,进行数据转移。 Redis在执行重哈希操作时,不会一次性将所有的数据进行重哈希,而是采用一种增量的方式, 逐步地将数据转移到新的桶中。而这又是其与unordered_map
的不同之处,unordered_map
在进行重哈希的时候, 会一次性地将表中的所有元素移动到新的桶中,而不是增量进行的。究其原因,unordered_map
只是C++中存储数据的 手段之一,其有特定的应用场景,因此不需要增量地进行重哈希。而在Redis中,虽然官方给出了多种基础数据类型, 但是其在底层进行检索的时候,都是以哈希表进行存储的,同时Redis定义为是一种数据库,那么其在哈希表中所存储的数据的量级要远远大于通用的C++程序,如果在哈希表中有大量的key-value数据的话,对所有数据进行重哈希操作, 会导致系统阻塞在重哈希操作中无法退出,而Redis本身对于核心数据的操作又是单线程的模式, 这将导致Redis无法对外提供服务。为解决这个问题,Redis在dict
中给出了保存了两个哈希表,在进行重哈希操作时, Redis会将第二个哈希表进行扩容或者缩容,然后定期将第一个哈希表中的数据重哈希到第二个哈希表中。 而这时,保存在第一个哈希表中没有来得及进行重哈希的数据,对于客户端用户来说,依然是可用的。 当第一个哈希表中全部数据重哈希结束后,Redis会把数据从第二个哈希表中转移至第一个哈希表中,结束重哈希操作。
-
-
函数接口
Redis哈希表的基础底层操作在src/dict.h头文件之中,Redis定义了一组用于完成基本底层操作的宏:
#define dictFreeVal(d, entry)
,使用dict
中type
的valDestructor
来释放entry
节点的v.val
#define dictSetVal(d, entry, _val_)
,为entry
中的v.val
进行赋值#define dictSetSignedIntegerVal(entry, _val_)
,为entry
中的v.u64
进行赋值#define dictSetUnsignedIntegerVal(entry, _val_)
,为entry
中的v.s64
进行赋值#define dictSetDoubleVal(entry, _val_)
,为entry
中的v.d
进行赋值#define dictFreeKey(d, entry)
,使用dict
中type
的valDestructor
来释放entry
节点的key
#define dictSetKey(d, entry, _key_)
,为entry
中的key
进行赋值#define dictCompareKeys(d, key1, key2)
,对key1
和key2
进行比较#define dictHashKey(d, key)
,为key
计算其对应的哈希值#define dictSlots(d)
,获取dict
中桶的个数,由两个哈希表中的size
数据的加和组成#define dictSize(d)
,获取dict
中存储元素的个数,由两个哈希表中used
数据的加和组成#define dictIsRehashing(d)
,判断dict
是否处于重哈希过程中
关于
_dictClear
清空哈希表数据结构参数中,有一个参数是(void)(callback)(void *)
,这是一个回调函数,哈希表每删除65535个桶时就会触发一次该函数。因为删除很大的哈希表可能会阻塞程序,提供一个回调函数以处理其他情况一个简单的例子,在使用主从结构的Redis集群时,slave节点在异步读取从master节点 收到的SYNC数据时,会涉及到删除自己的旧数据以加载新数据,slave节点阻塞在从哈希表中删除旧数据, 而无法响应其他请求时,master节点可能会认为该节点超时,为了防止这种情况发生,我们可以在清空哈希表时, 传入如下的回调函数,定期向master节点发送一个newLine数据,确保master不会误认为改节点超时
重哈希
Redis使用了两个dicht元素就是为了进行增量形式的重哈希操作
int dictRehash(dict *d, int n); // 对d中的n个桶进行重哈希 --> 将ht[0]移动到ht[1]中,每一个桶的移动是原子的
int dictRehashMilliseconds(dict *d, int ms); // 在ms毫秒内连续执行 dictRehash(d,100) 操作,返回rehash的桶的个数
在Redis的服务器中,系统会以1毫秒一次的心跳来执行
databaseCron
接口,用于增量处理Redis数据库操作。dictIsRehashing // 改宏函数用来判断哈希表实在在重哈希的过程中
rehash的时机
Redis中用来判断是否触发rehash的函数是
_dictExpandlfNeeded
,具体的判定的条件是://如果Hash表为空,将Hash表扩为初始大小 if (d->ht[0].size == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE); //如果Hash表承载的元素个数超过其当前大小,并且可以进行扩容,或者Hash表承载的元素个数已是当前大小的5倍 if (d->ht[0].used >= d->ht[0].size &&(dict_can_resize || d->ht[0].used/d->ht[0].size > dict_force_resize_ratio)) { return dictExpand(d, d->ht[0].used*2); }
其中
dict_can_resize
变量是用来判断redis此时是否可以进行rehash操作的标志,对于Redis来说,如果没有RDB子进程并且没有AOF子进程在运行时,才可以进行rehashvoid dictEnableResize(void) { dict_can_resize = 1; } void dictDisableResize(void) { dict_can_resize = 0; } // 禁用rehash void updateDictResizePolicy(void) { if (server.rdb_child_pid == -1 && server.aof_child_pid == -1) dictEnableResize(); else dictDisableResize(); }
触发rehash的时机有很多,如添加一个键值对,修改一个键值对等。
添加与查找
如果哈希表不在重哈希过程中,则查找只在一号哈希表进行操作,否则在两个哈希表中进行查找;
如果哈希表不在重哈希过程中,则添加只在一号哈希表进行操作,否则在第二个哈希表中进行添加;
删除 先查找再删除
哈希表的迭代与遍历
/* If safe is set to 1 this is a safe iterator, that means, you can call * dictAdd, dictFind, and other functions against the dictionary even while * iterating. Otherwise it is a non safe iterator, and only dictNext() * should be called while iterating. */ typedef struct dictIterator { dict *d; long index; int table, safe; dictEntry *entry, *nextEntry; /* unsafe iterator fingerprint for misuse detection. */ unsigned long long fingerprint; } dictIterator;
- 安全迭代器:使迭代器在迭代的过程中进行数据的插入、查找以及其他操作,safe==1
- 非安全迭代器:在迭代过程中只能调用
dictNext
操作,也就是禁止对哈希表进行改变(因为不安全?)safe!=1
迭代器部分有点难理解,建议多看看第三个文章
从ziplist到quicklist再到listpack
ziplist
双端链表有两个缺点,一个是当存储元素过小,内存存储的指针将会占用较高比例的空间很浪费,另一个是链表是分散的,遍历效率低下。为此,Redis设计了ziplist,它占用一块连续的内存空间。
ziplist 的结构
<zlbytes> <zltail> <zllen> <entry> <entry> ... <entry> <zlend>
zlbytes: zl列表总字节数,32bits
zltail: zl列表最后一个entry的指针,32bits
zllen: zl列表entry总数,16bits
entry: zl列表元素
zlend: zl列表结束标志,8bits
ziplist元素entry包括三部分内容
<prevlen> <encoding> <data>
prevlen:前一项的长度。方便快速找到前一个元素地址
encoding:当前项长度信息的编码结果
data:当前项的实际存储数据
为什么需要prevlen字段
因为有时候我们需要逆向遍历列表,如果正向遍历,由于知道编码规则,我们可以简单获取一个entry的总大小,进而找到下一个entry。但是如果需要逆向遍历的话,编码规则就能很难使用了,因为我们不知道数据对应着什么含义。使用prevlen就可以逆向的向前查找一个一个元素了。
entry的prevlen和encoding都有不同的编码方式,也就是对应不同大小的数据,所使用的空间长度也不同。
<zlbytes> <zltail> <zllen> <entry1> <entry2> ... <entryX> <zlend>
假设现在有一个ziplist如上所示,现在我们希望在entry1和entry2之间插入一个新的entry,需要做什么?
从ziplist层次考虑,我们需要开辟更大的内存以容纳这个新元素,同时将entry2之后的元素向后移动。但是每一个entry中也存在着变量,由于一个entry的prevlen存放着前一个entry的长度信息,因此插入后entry2的prevlen必然会更新。再次假设entry1很小,但是新插入的entry很大,因此entry2的prevlen会从一个较小值变成一个较大值,同时prevlen本身是存在编码的,entry2可能因为他的prevlen需要变大而导致entry2总体变大,进而影响到entry3以及之后的更多entry,这就需要连续的内存移动修改了,这就是ziplist的一大缺点—连锁更新。
同时,由于ziplist的设计,他的查找复杂度较高为O(n)
quicklist
连锁更新带来的开销很大,因此Redis设计了quicklist数据结构,简单来说,一个 quicklist 就是一个链表,而链表中的每个元素又是一个 ziplist。
quicklist对每个节点中的ziplist大小严格限制,这样即时出现连锁更新也不会造成太严重的后果。
listpack
listpack又称紧凑列表,使用一块连续的空间来紧凑的保存数据,同时为了节省空间listpack列表项使用多种编码方式来表示不同长度,来表示不同长度的数据。
Redis 源码对于listpack的解释为“A list of strings serialization format”,一个字符串列表的序列格式化,也就是将一个字符串进行序列化存储。Redis listpack 可以用来存储整型或则字符串,结构如下
// <Total Bytes><Num Elem><Entry1>...<EntryX><End>
// <Entry> --> <encoding><content><backlen>
- Total Bytes 为整个listpack的空间大小,占用4个字节
- Num Elem为listpack中元素的个数,即Entry的个数,占用两个字节,这并不意味着listpack最多只能存放65535个Entry,当Entry的个数超过65534时,Num Elem只保存65535,但是实际个数需要遍历来进行统计
- End为结束标志,占用一个字节内容为0xFF,从代码看End其实应该两个字节,后面还有一个End自己的backlen?
- Entry为listpack中具体的元素,encoding是该元素的编码方式占用一个字节,content是内容字段,backlen是前两者的总字节数,但不包含自身的字节数,一个backlen最多占用5个字节
- 需要注意的是,整型存储中并不实际存储负数,而是将负数转换为正数进行存储,例如13位整型存储中[0,8191],[0,4095]代表本身,[4096,8191]实际代表[-4096,-1]。
listpack.h/listpack.c中相关的代码基本都是对一个unsigned char *
类型的变量进行操作,利用位运算进行bit尺度的赋值,根据listpack设计规则提供相关接口,如插入,删除,判断,统计等。
listpack中的backlen保存的值是自己这个entry的encoding和content字段的总长度。backlen有一个特殊的编码方式:backlen的每个字节的最高位代表此字节是否是backlen的最后一个字节(最高位为1表示backlen没有结束,需要继续向前遍历一个字节在决定;最高位为0表示结束了),因此在逆向遍历的时候可以向左遍历计算出backlen的具体值,然后得到整个entry的长度这样就可以找到前一个entry了。
总结,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 面临的连锁更新问题。
有序集合(Zset)为什么能够同时支持点询查和范围询查
Sort Set(ZSet):可排序集合,通过给值添加一个double类型的分数(score),利用分数进行排序,值不可重复,但是分数可以重复;
zset的代码实现在server.h和t_zset.c文件中。
zset同时使用了哈希表和跳表作为底层数据结构。
typedef struct zset {
dict *dict;
zskiplist *zsl; // 跳表
} zset;
哈希表的一大特点就是O(1)的查找时间复杂度。有序集合通过哈希表实现单个数据的快速询查。
跳表通过不同的层级的设计,在范围询查上非常有优势,因此将二者组合使用就可以同时支持高效的单点询查和范围询查。
Zset保持哈希表和跳表数据一致性的方法:
Sorted Set 中还将元素保存在了哈希表中,作为哈希表的 key,同时将 value 指向元素在跳表中的权重。
当插入一个元素时,首先使用哈希表判断该元素是否已经存在,如果不存在,则依次调用哈希表和跳表的插入函数进行元素插入;
如果元素存在则改变哈希结点的value和跳表对应节点的权重,这里就牵扯到跳表的修改操作了。
关于跳表:
跳表是一个多层的有序链表,在跳表中进行查询操作时,查询结点可以从最高层开始查询。层数越高,结点数越少,同时高层结点的跨度会比较大。因此,在高层查询结点时,查询一个结点可能就已经查到了链表的中间位置了。
这样一来,跳表就会先查高层,如果高层直接查到了等于待查元素的结点,那么就可以直接返回。如果查到第一个大于待查元素的结点后,就转向下一层查询。下层上的结点数多于上层,所以这样可以在更多的结点中进一步查找待查元素是否存在。
跳表的这种设计方法就可以节省查询开销,同时,跳表设计采用随机的方法来确定每个结点的层数,这样就可以避免新增结点时,引起结点连锁更新问题。
anet.c
封装了socket操作,用于客户端与服务器的连接建立,提供一些读写接口
ae_epoll.c
对 epoll_creat
,epoll_ctl
,epoll_wait
三个系统函数进行封装,进行一些检查和处理
ae.c
#ifdef HAVE_EVPORT
#include "ae_evport.c"
#else
#ifdef HAVE_EPOLL
#include "ae_epoll.c"
#else
#ifdef HAVE_KQUEUE
#include "ae_kqueue.c"
#else
#include "ae_select.c"
#endif
#endif
#endif
上述代码解释了为什么Redis建议在Linux操作系统上运行,因为Linux操作系统提供了epoll系统函数,可以实现高效的多路服用,而其他系统则使用效率较低一些的select系统函数
redis server 启动后做了什么
redis server 的main函数在 server.c 文件中
-
基本初始化,包括设置server运行的时区,设置哈希函数的随机种子等,还有一些测试代码
-
检查哨兵模式,并检查是否要执行RDB或者AOF检测
-
运行时参数的解析,对配置文件和命令行输入的参数合并处理然后为 Redis 各功能模块的关键参数设置合适的取值,以便 server 能高效地运行。
-
初始化server
在完成对运行参数的解析和设置后,main 函数会调用 initServer 函数,对 server 运行时的各种资源进行初始化工作。这主要包括了 server 资源管理所需的数据结构初始化、键值对数据库初始化、server 网络框架初始化等。
而在调用完 initServer 后,main 函数还会再次判断当前 server 是否为哨兵模式。如果是哨兵模式,main 函数会调用 sentinelIsRunning 函数,设置启动哨兵模式。否则的话,main 函数会调用 loadDataFromDisk 函数,从磁盘上加载 AOF 或者是 RDB 文件,以便恢复之前的数据。
Redis事件驱动框架
Reactor模型
Reactor模型是一种网络服务器端用来处理高并发网络IO请求的编程模型,这种模型的特征可以用两个三来描述:三类处理事件(连接事件,写事件,读事件),三个关键角色(reactor,acceptor,handler)
Reactor模型处理的是客户端和服务器端交互的过程,当客户端向服务器建立连接对应着连接事件,服务端给客户端写数据对应着写事件,服务器写给客户端或者反过来就是写事件。
连接事件由acceptor来处理,负责接收连接;acceptor在接收连接之后会创建handler用来处理读写事件。在高并发的场景中,连接事件,读写事件可能会同时发生,这时需要有一个reactor专门用来监听和分配事件,当有连接事件时,reactor将产生的连接事件交给acceptor处理;当有读写请求时将读写事件交给handler处理。
事件驱动框架
事件驱动框架就是在实现Reactor模型时需要实现的代码的整体控制逻辑。一般来说,事件驱动框架包括两个部分,一个是事件初始化,另一个是事件的捕获、分发和处理循环。
事件初始化一般在服务器初始化时完成,而事件的捕获等逻辑一般写在一个while循环中进行。
Redis事件数据结构
redis中有两大类事件,一种是IO事件用于处理客户端连接,读写数据等;第二种是时间事件一般用来处理定时任务,如某个键值对的过期销毁,AOF/RDB持久化等。
IO事件
typedef struct aeFileEvent {
int mask; /* one of AE_(READABLE|WRITABLE|BARRIER) */
aeFileProc *rfileProc;
aeFileProc *wfileProc;
void *clientData;
} aeFileEvent;
IO事件设计显而易见,mask表示事件的性质(读、写还是屏障,屏障就是优先级很高的事件);r/wfileProc代表事件处理函数,clientData代表事件私有数据,因此,一个acFileEvent就是一个事件。
时间事件
typedef struct aeTimeEvent {
long long id; //时间事件ID
long when_sec; //事件到达的秒级时间戳
long when_ms; //事件到达的毫秒级时间戳
aeTimeProc *timeProc; //时间事件触发后的处理函数
aeEventFinalizerProc *finalizerProc; //事件结束后的处理函数
void *clientData; //事件相关的私有数据
struct aeTimeEvent *prev; //时间事件链表的前向指针
struct aeTimeEvent *next; //时间事件链表的后向指针
} aeTimeEvent;
不难发现时间事件是以链表形式存在的,因此对于时间事件的处理,我们定时遍历整个链表,如果当前事件和时间事件对应时间相符则执行改时间,否则跳过该事件。
aeEventLoop 结构体
typedef struct aeEventLoop {
…
aeFileEvent *events; //IO事件数组
aeFiredEvent *fired; //已触发事件数组
aeTimeEvent *timeEventHead; //记录时间事件的链表头
…
void *apidata; //和API调用接口相关的数据
aeBeforeSleepProc *beforesleep; //进入事件循环流程前执行的函数
aeBeforeSleepProc *aftersleep; //退出事件循环流程后执行的函数
} aeEventLoop;
这个结构体保存了所有事件,是对Redis所有事件的封装。注意到这里设置有循环前后的处理函数有点类似构造析构函数的意思,可以用来处理一些初始化和回复客户端信息的操作。在 ae_epoll.c文件中封装了epoll_creat
,epoll_ctl
,epoll_wait
三个系统函数,这些系统函数相关数据保存在此结构体的apidata变量中,如epfd,epoll_wait函数执行后参数传出的值。在添加事件时通过apidata中的相关epfd来对事件进行注册,在获取事件时,通过此数据获得触发的事件数组。
Redis的事件处理流程
ae.c文件中实现了aeMain函数和aeProcessEvents函数,前者实现了事件模型中的循环判断,后者则是实现事件模型中的事件捕获与分发。
监听事件
在Redis服务器启动后,会做一些初始化工作,然后创建一个aeEventLoop对象保存事件。紧接着就会创建第一个IO事件-----进行TCP监听,等待客户端连接。具体来说,在 initServer 函数的执行过程中,initServer 函数会根据启用的 IP 端口个数,为每个 IP 端口上的网络事件,调用 aeCreateFileEvent,创建读事件的监听,并且注册这个读事件的处理 函数,也就是 acceptTcpHandler 函数。这些就是对应socket编程的那一套。这一步的操作对应Reactor模型便是创建了acceptor,在IO复用的层面上来说就是将监听套接字加入epoll中等待触发。
读事件
当Redis Server接收到客户端连接请求时(accepter处理连接事件),就会使用注册好的acceptTcpHandler进行处理,此函数会与客户端进行TCP连接,然后将连接获取的对方套接字传递给acceptCommonHandler函数。之后acceptCommonHndler函数会调用createCilent函数,而createClient函数中又会调用aeCreateFileEvent函数创建IO读事件,并且使用readQueryFromClient函数作为处理函数。这里对应Reactor模型便是穿件handler,对应epoll便是将客户端的socket加入epoll中等待触发。
写事件
Redis在事件驱动框架每次循环进入事件处理前,均会调用beforeSleep函数进行一些任务处理,这里就包括了服务器对客户端的写事件。具体来说beforeSleep函数会调用handleClientsWithPendingWrites函数,该函数会遍历每一个待写回数据的客户端,然后调用writeToClient函数进行数据写回。由于写回事服务器自己的操作,所以不涉及epoll。
时间事件处理
时间事件的创建发生在server.c中的initServer函数中,使用的是aeCreateTimeEvent函数。时间事件是服务器自己处理不需要IO复用
时间事件的处理函数是serverCron函数,serverCron 函数是在 server.c 文件中实现的。一方面,它会顺序调用一些函数,来实现时间事件被触发后,执行一些后台任务。比如,serverCron 函数会检查是否有进程结束信号,若有就执行 server 关闭操作。serverCron 会调用 databaseCron 函数,处理过期 key 或进行 rehash等。另一方面,serverCron 函数还会以不同的频率周期性执行一些任务,这是通过执行宏 run_with_period 来实现的。该宏定义会根据 Redis 实例配置文件 redis.conf 中定义的 hz 值,来判断参数 ms 表示的时间戳是否到达。一旦到达,serverCron 就可以执行相应的任务了。比如,serverCron 函数中会以 1 秒 1 次的频率,检查 AOF 文件是否有写错误。如果有的话,serverCron 就会调用 flushAppendOnlyFile 函数,再次刷回 AOF 文件的缓存数据。
时间事件的触发处理时机是在aeMain->aeProcessEvents函数的最后,调用processTimeEvents函数处理对应事件。
Redis真的是单线程吗
基于 Redis 5.0
对于redis服务器来说,他的主要工作包括接收客户端请求,解析请求和进行数据读写操作,也就是主要都是IO操作,而这些操作都是在单线程中执行的,这个线程被称为主IO线程。
我们在shell中输入redis-server redis.conf
启动server发生的事有:
-
shell进程执行fork,execve系统调用创建redis-server进程,如果redis-server以守护模式运行,shell进程会退出,之后redis-server进程会创建新的session。之后就会执行上面【redis server 启动后做了什么】中的初始化server步骤为止,都是单进程。
-
初始化server的基本数据后,会在main函数中调用
InitServerLast
函数,此函数内部调用bioInit
函数,就要开始创建后台线程了,这些操作也在bioInit
函数中执行。 -
bioInit函数的主要工作是设置线程属性并创建线程。一般情况下,会创建三个后台线程(
BIO_CLOSE_FILE
,BIO_AOF_FSYNC
,BIO_LAZY_FREE
),分别对应1,2,3号线程,分别执行文件关闭,AOF同步,惰性删除操作。这三个线程执行的函数均是bioProcessBackgroundJobs
,其内部是一个死循环不断等待新的任务到来并取出任务执行。而这些任务是在需要的时候才创建的,对应的函数是:void bioCreateCloseJob(int fd, int need_fsync, int need_reclaim_cache); void bioCreateCloseAofJob(int fd, long long offset, int need_reclaim_cache); void bioCreateFsyncJob(int fd, long long offset, int need_reclaim_cache);
-
至此,redis server 已经是多线程执行了
不难发现redis这三个后台线程是生产者-消费者模型,创建任务–消费任务,实现异步任务执行。
Redis的IO多线程机制
在 Redis 5.0中,Redis server会开启三个后台线程分别处理文件关闭,文件同步,数据删除等耗时操作。Redis 6.0 之后,Redis
启用了IO多线程机制,进一步利用多核CPU的性能。
IO线程的创建 IO多线程的初始化发生在上面提到的bioInit
函数之后,也就是在server.c中的main函数中的InitServerLast
函数中调用的。这个函数是initThreadedIO
,这个函数会判断IO线程数量,如果数量唯一,则该函数直接返回,这代表着我们不启用IO多线程机制,其他情况和 Redis 5.0 中一样了。如果IO线程数量大于一且小于一个合理值时,该函数继续执行,进行IO线程的创建工作。
IO线程的执行 IO线程执行的函数是IOThreadMain
,该函数的内部是一个死循环,不断将该线程对应的待处理的客户端列表中的客户端取出并进行处理。IO线程主要处理两种事件:一种是读取客户端发送的数据,用宏IO_THREADS_OP_READ
表示,另一种是向客户端发送数据,用宏IO_THREADS_OP_WRITE
表示。
读客户端数据和写客户端数据的推迟 一些条件判断,暂不记录
如何把读客户端分配给IO线程 之前提到过,Redis在事件驱动框架每次循环进入事件处理前,均会调用beforeSleep函数进行一些任务处理,这里就包含了将读客户端分配给指定线程的操作,而具体处理这个分配操作的函数是**handleClientsWithPendingReadsUsingThreads
**函数,该函数的具体执行流程如下:
- 判断Redis server 是否开启了IO多线程,如果没有直接返回,使用主线程进行IO读取。
- 从
clients_pending_read
中依次取出客户端,每个客户端都会有一个序号,将该序号对IO线程数量进行取余,得到目标IO线程的序号,然后将这个客户端加入目标线程的读客户端处理队列即可。分配完所有客户端后,该函数会等待获取每个IO线程处理客户端的数量。(干嘛的?) - 接下来,该函数让0号IO线程(主IO线程)直接处理解析后的客户端数据,对应函数
readQueryFromCLient
,也就是先让主IO线程先处理刚刚由自己读取的客户端的具体请求操作。 - 主线程执行完刚才客户端的操作后会继续等待所有IO线程解析完毕,然后统一处理所有的客户端请求。
如何把写客户端分配给IO线程 这个过程与上面大体类似,具体的处理函数是**handleClientsWithPendingWritesUsingThreads
**,略有不同的是,如果待写客户端的数量小于IO线程数量的两倍,则不会启用IO多线程进行写操作,这可能是为了节省CPU开销。
总结(借鉴)
Redis 6.0 先是在初始化过程中,根据用户设置的 IO 线程数量,创建对应数量的 IO 线程。
当 Redis server 初始化完成后正常运行时,它会在 readQueryFromClient 函数中通过调用 postponeClientRead 函数来决定是否推迟客户端读操作。同时,Redis server 会在 addReply 函数中通过调用 prepareClientToWrite 函数,来决定是否推迟客户端写操作。而待读写的客户端会被分别加入到 clients_pending_read 和 clients_pending_write 两个列表中。
这样,每当 Redis server 要进入事件循环流程前,都会在 beforeSleep 函数中分别调用 handleClientsWithPendingReadsUsingThreads 函数和 handleClientsWithPendingWritesUsingThreads 函数,将待读写客户端以轮询方式分配给 IO 线程,加入到 IO 线程的待处理客户端列表 io_threads_list 中。
而 IO 线程一旦运行后,本身会一直检测 io_threads_list 中的客户端,如果有待读写客户端,IO 线程就会调用 readQueryFromClient 或 writeToClient 函数来进行处理。
最后,我也想再提醒你一下,多 IO 线程本身并不会执行命令,它们只是利用多核并行地读取数据和解析命令,或是将 server 数据写回(下节课我还会结合分布式锁的原子性保证,来给你介绍这一部分的源码实现。)。所以,Redis 执行命令的线程还是主 IO 线程。这一点对于你理解多 IO 线程机制很重要,可以避免你误解 Redis 有多线程同时执行命令。
这样一来,我们原来针对 Redis 单个主 IO 线程做的优化仍然有效,比如避免 bigkey、避免阻塞操作等。
RDB的生成与结构
RDB是Redis持久化的一种机制,Redis提供以指定的时间间隔执行数据集的时间点快照(snapshot)。类似照片,将某一时刻的数据和状态以文件的形式写入磁盘,恢复时将硬盘中的快照文件读入磁盘。
在什么情况下会生成RDB文件
- save,bgsave命令的执行必然会生成RDB文件,其中bgsave会创建子进程来进行生成RDB的工作
- 主从复制时主节点会生成RDB文件并通过套接字发送给从节点
- flushall 和 shutdown 命令执行后也会生成RDB文件
RDB文件的组成
文件头:Redis魔术其实是一个相对固定的字符串,在Redis 7.2.4版本中 RDB_VERSION == 11
snprintf(magic,sizeof(magic),"REDIS%04d",RDB_VERSION); // 生成魔术数
文件数据:当我们记录一个键值对的时候其实会记录三个值,分别是键值对的类型标识,键,值。RDB 文件内容是自包含的,也就是说,无论是属性信息还是键值对,RDB 文件都会按照类型、长度、实际数据的格式来记录,这样方便程序对 RDB 文件的解析。
例如键值对 string-string: “hello” : “redis” 保存起来就是“05hello5redis”,第一个0是string的标志,两个5分别是键和值的长度。
文件尾:rdbSaveRio 函数会先调用 rdbSaveType 函数,写入文件结束操作码 RDB_OPCODE_EOF,然后调用 rioWrite 写入检验值
AOF 文件
AOF日志会记录服务器收到的所有写操作。因此AOF为越来越大,当AOF达到一定限制时,redis server会重写AOF文件,也就是针对数据库的每个键值对只更新最新的写入操作而忽略之前的操作。
AOF 重写的时机
首先如果想让AOF开始重写,必须同时满足一下两个条件:
- 当前没有已经存在AOF重写子进程正在执行
- 当前没有创建RDB的子进程正在执行
如果以上两个条件没有被满足,则redis server会设置状态 aof_rewrite_scheduled=1
,即设置为待调度执行,之后Redis Server 会周期性触发时间时事件,如果serverCron==1
,时间事件触发时就会不断尝试重写AOF文件。
触发重写的时机:
- bgrewriteaof命令被执行
- 主从复制完成RDB文件解析和加载时(更新AOF文件)
- AOF文件重写被设置为待调度执行
- AOF文件的大小比例超过阈值,或者是AOF文件绝对大小超过阈值
AOF 文件重写的基本过程
AOF 文件重写的主要流程实现在aof.c文件中的 rewriteAppendOnlyFileRio
函数,一般来说Redis server会开辟一个子进程,然后子进程执行这个函数。
该函数会遍历Redis server 的每一个数据库,把每一个键值对读取出来,然后记录该类型的插入命令,例如一个String类型的键值对(K-V),就会记录 SET K V,如果是Set类型键值对,就会记录 SADD 命令。这样一来,需要恢复数据库时,只需要将AOF文件中的命令全部执行一遍即可。
另外,父进程还会将aof_rewrite_scheduled
置为0,并记录重写AOF文件的时间以及AOF重写的进程号。同时在AOF重写期间,父进程还会调用updateDictResizePolicy
函数,禁止在AOF重写期间进行rehash操作。
int rewriteAppendOnlyFileBackground(void) {
...
if ((childpid = fork()) == 0) { //创建子进程
...
//子进程调用rewriteAppendOnlyFile进行AOF重写
if (rewriteAppendOnlyFile(tmpfile) == C_OK) {
size_t private_dirty = zmalloc_get_private_dirty(-1);
...
exitFromChild(0);
} else {
exitFromChild(1);
}
}
else{ //父进程执行的逻辑
...
server.aof_rewrite_scheduled = 0;
server.aof_rewrite_time_start = time(NULL);
server.aof_child_pid = childpid; //记录重写子进程的进程号
updateDictResizePolicy(); //关闭rehash功能
}
AOF 重写和 RDB 创建是比较类似的,它们都会创建一个子进程来遍历所有的数据库,并把数据库中的每个键值对记录到文件中。
Redis7.0使用了Multi Part AOF设计
- BASE表示基础AOF,它一般由子进程通过重写产生,该文件最多只有一个
- INCR表示增量AOF,它一般会在AOFRW开始执行时被创建,该文件可能存在多个
- HISTORY表示历史AOF,它由BASE和INCR AOF变化而来,每次AOFRW成功完成时,本次AOFRW之前对应的BASE和INCR AOF都将变为HISTORY,HISTORY类型的AOF会被Redis自动删除,也就是在目录中看不到
- 为了管理这些AOF文件,引入manifest(清单)文件来跟踪管理这些AOF
Redis server 在重写AOF文件时,子进程会重写 BASE AOF 和以前的 INCR AOF 进而形成新的 BASE AOF,而父进程会将最新的写命令记录在新的INCR AOF中。
主从复制:基于状态机的设计与实现
从原理来说,Redis的主从复制包括了全量复制、增量复制和长连接同步三种情况。全量复制传输RDB文件,增量复制传输主从断连期间的命令,而长连接同步则是将主节点正常收到的请求传输给从节点。
Redis 采用状态机的设计思想来清晰实现不同状态以及状态间的转换。
主从复制的四大阶段
- 初始化阶段,主要主库和从库互相获得对方的IP地址和端口号
- 建立连接阶段,从库和主库建立TCP连接,并在已建立的网络连接上监听主库的命令
- 主从握手阶段,交换主库与从库的状态信息
- 复制类型判断与执行阶段