This the multi-page printable view of this section. Click here to print.
Redis
1 - CH01-基本类型
概览
Redis 中所有的 Key 都是字符串。我们在谈基础数据结构时,讨论的是存储值的数据类型,主要包括常见的5种数据类型,分别是:String、List、Set、Zset、Hash。
结构类型 | 值的形式 | 读写能力 |
---|---|---|
String | 字符串、整数、浮点数 | 对整个字符串或字符串的一部分进行操作;对整数或浮点数进行自增或自减操作; |
List | 由字符串构造的链表 | 对链表的两端进行push和pop操作,读取单个或多个元素;根据值查找或删除元素; |
Set | 由无重复字符串构成的集合 | 字符串的集合,包含基础的方法有看是否存在添加、获取、删除;还包含计算交集、并集、差集等 |
Hash | 由键值对构成的无序散列表 | 包含方法有添加、获取、删除单个元素 |
Zset | 由键值对构成的有序集合 | 字符串成员与浮点数分数之间的有序映射;元素的排列顺序由分数的大小决定;包含方法有添加、获取、删除单个元素以及根据分值范围或成员来获取元素 |
String
String是redis中最基本的数据类型,一个key对应一个value。
常用命令
命令 | 简述 | 使用 |
---|---|---|
GET | 获取存储在给定键中的值 | GET name |
SET | 设置存储在给定键中的值 | SET name value |
DEL | 删除存储在给定键中的值 | DEL name |
INCR | 将键存储的值加1 | INCR key |
DECR | 将键存储的值减1 | DECR key |
INCRBY | 将键存储的值加上整数 | INCRBY key amount |
DECRBY | 将键存储的值减去整数 | DECRBY key amount |
应用场景
缓存: 经典使用场景,把常用信息,字符串,图片或者视频等信息放到redis中,redis作为缓存层,mysql做持久化层,降低mysql的读写压力。
计数器:redis是单线程模型,一个命令执行完才会执行下一个,同时数据可以一步落地到其他的数据源。
session:常见方案spring session + redis实现session共享
List
Redis中的List其实就是链表(Redis用双端链表实现List)。
常用命令
命令 | 简述 | 使用 |
---|---|---|
RPUSH | 将给定值推入到列表右端 | RPUSH key value |
LPUSH | 将给定值推入到列表左端 | LPUSH key value |
RPOP | 从列表的右端弹出一个值,并返回被弹出的值 | RPOP key |
LPOP | 从列表的左端弹出一个值,并返回被弹出的值 | LPOP key |
LRANGE | 获取列表在给定范围上的所有值 | LRANGE key 0 -1 |
LINDEX | 通过索引获取列表中的元素。你也可以使用负数下标,以 -1 表示列表的最后一个元素, -2 表示列表的倒数第二个元素,以此类推。 | LINEX key index |
应用场景
- 微博TimeLine: 有人发布微博,用lpush加入时间轴,展示新的列表信息。
- 消息队列
Set
Redis 的 Set 是 String 类型的无序集合。集合成员是唯一的,这就意味着集合中不能出现重复的数据。
Redis 中集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是 O(1)。
常用命令
命令 | 简述 | 使用 |
---|---|---|
SADD | 向集合添加一个或多个成员 | SADD key value |
SCARD | 获取集合的成员数 | SCARD key |
SMEMBER | 返回集合中的所有成员 | SMEMBER key member |
SISMEMBER | 判断 member 元素是否是集合 key 的成员 | SISMEMBER key member |
应用场景
实战场景
- 标签(tag),给用户添加标签,或者用户给消息添加标签,这样有同一标签或者类似标签的可以给推荐关注的事或者关注的人。
- 点赞,或点踩,收藏等,可以放到set中实现
Hash
Redis hash 是一个 string 类型的 field(字段) 和 value(值) 的映射表,hash 特别适合用于存储对象。
常用命令
命令 | 简述 | 使用 |
---|---|---|
HSET | 添加键值对 | HSET hash-key sub-key1 value1 |
HGET | 获取指定散列键的值 | HGET hash-key key1 |
HGETALL | 获取散列中包含的所有键值对 | HGETALL hash-key |
HDEL | 如果给定键存在于散列中,那么就移除这个键 | HDEL hash-key sub-key1 |
应用场景
- 缓存: 能直观,相比string更节省空间,的维护缓存信息,如用户信息,视频信息等。
ZSet
Redis 有序集合和集合一样也是 string 类型元素的集合,且不允许重复的成员。不同的是每个元素都会关联一个 double 类型的分数。redis 正是通过分数来为集合中的成员进行从小到大的排序。
有序集合的成员是唯一的,但分数(score)却可以重复。集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是 O(1)。
常用命令
命令 | 简述 | 使用 |
---|---|---|
ZADD | 将一个带有给定分值的成员添加到哦有序集合里面 | ZADD zset-key 178 member1 |
ZRANGE | 根据元素在有序集合中所处的位置,从有序集合中获取多个元素 | ZRANGE zset-key 0-1 withccores |
ZREM | 如果给定元素成员存在于有序集合中,那么就移除这个元素 | ZREM zset-key member1 |
应用场景
- 排行榜:有序集合经典使用场景。例如小说视频等网站需要对用户上传的小说视频做排行榜,榜单可以按照用户关注数,更新时间,字数等打分,做排行。
2 - CH02-高级类型
HyperLogLogs:基数
什么是基数
举个例子,A = {1, 2, 3, 4, 5}, B = {3, 5, 6, 7, 9};那么基数(不重复的元素)= 1, 2, 4, 6, 7, 9; (允许容错,即可以接受一定误差)
基本用途
这个结构可以非常省内存的去统计各种计数,比如注册 IP 数、每日访问 IP 数、页面实时UV、在线用户数,共同好友数等。
结构优点
一个大型的网站,每天 IP 比如有 100 万,粗算一个 IP 消耗 15 字节,那么 100 万个 IP 就是 15M。而 HyperLogLog 在 Redis 中每个键占用的内容都是 12K,理论存储近似接近 2^64 个值,不管存储的内容是什么,它一个基于基数估算的算法,只能比较准确的估算出基数,可以使用少量固定的内存去存储并识别集合中的唯一元素。而且这个估算的基数并不一定准确,是一个带有 0.81% 标准错误的近似值(对于可以接受一定容错的业务场景,比如IP数统计,UV等,是可以忽略不计的)。
操作命令
127.0.0.1:6379> pfadd key1 a b c d e f g h i # 创建第一组元素
(integer) 1
127.0.0.1:6379> pfcount key1 # 统计元素的基数数量
(integer) 9
127.0.0.1:6379> pfadd key2 c j k l m e g a # 创建第二组元素
(integer) 1
127.0.0.1:6379> pfcount key2
(integer) 8
127.0.0.1:6379> pfmerge key3 key1 key2 # 合并两组:key1 key2 -> key3 并集
OK
127.0.0.1:6379> pfcount key3
(integer) 13
Bitmap:位图
Bitmap 即位图数据结构,都是操作二进制位来进行记录,只有0 和 1 两个状态。
基本用途
比如:统计用户信息,活跃,不活跃! 登录,未登录! 打卡,不打卡! 两个状态的,都可以使用 Bitmaps!
如果存储一年的打卡状态需要多少内存呢? 365 天 = 365 bit 1字节 = 8bit 46 个字节左右!
操作命令
使用bitmap 来记录 周一到周日的打卡! 周一:1 周二:0 周三:0 周四:1 ……
127.0.0.1:6379> setbit sign 0 1
(integer) 0
127.0.0.1:6379> setbit sign 1 1
(integer) 0
127.0.0.1:6379> setbit sign 2 0
(integer) 0
127.0.0.1:6379> setbit sign 3 1
(integer) 0
127.0.0.1:6379> setbit sign 4 0
(integer) 0
127.0.0.1:6379> setbit sign 5 0
(integer) 0
127.0.0.1:6379> setbit sign 6 1
(integer) 0
查看某一天是否有打卡!
127.0.0.1:6379> getbit sign 3
(integer) 1
127.0.0.1:6379> getbit sign 5
(integer) 0
统计操作,统计 打卡的天数!
127.0.0.1:6379> bitcount sign # 统计这周的打卡记录,就可以看到是否有全勤!
(integer) 3
Geospatial:地理位置
用于地理位置坐标的存储与计算。
添加地理位置
127.0.0.1:6379> geoadd china:city 118.76 32.04 manjing 112.55 37.86 taiyuan 123.43 41.80 shenyang
(integer) 3
127.0.0.1:6379> geoadd china:city 144.05 22.52 shengzhen 120.16 30.24 hangzhou 108.96 34.26 xian
(integer) 3
获取地理位置
127.0.0.1:6379> geopos china:city taiyuan manjing
1) 1) "112.54999905824661255"
1) "37.86000073876942196"
2) 1) "118.75999957323074341"
1) "32.03999960287850968"
计算距离
127.0.0.1:6379> geodist china:city taiyuan shenyang m
"1026439.1070"
127.0.0.1:6379> geodist china:city taiyuan shenyang km
"1026.4391"
范围查找
# 以 100,30 这个坐标为中心, 寻找半径为1000km的城市
127.0.0.1:6379> georadius china:city 110 30 1000 km
1) "xian"
2) "hangzhou"
3) "manjing"
4) "taiyuan"
127.0.0.1:6379> georadius china:city 110 30 500 km
1) "xian"
127.0.0.1:6379> georadius china:city 110 30 500 km withdist
1) 1) "xian"
2) "483.8340"
127.0.0.1:6379> georadius china:city 110 30 1000 km withcoord withdist count 2
1) 1) "xian"
2) "483.8340"
3) 1) "108.96000176668167114"
2) "34.25999964418929977"
2) 1) "manjing"
2) "864.9816"
3) 1) "118.75999957323074341"
2) "32.03999960287850968"
参数:key 经度 纬度 半径 单位 显示结果的经度和纬度 显示结果的距离 显示的结果的数量
范围相交
显示与指定成员一定半径范围内的其他成员:
127.0.0.1:6379> georadiusbymember china:city taiyuan 1000 km
1) "manjing"
2) "taiyuan"
3) "xian"
127.0.0.1:6379> georadiusbymember china:city taiyuan 1000 km withcoord withdist count 2
1) 1) "taiyuan"
2) "0.0000"
3) 1) "112.54999905824661255"
2) "37.86000073876942196"
2) 1) "xian"
2) "514.2264"
3) 1) "108.96000176668167114"
2) "34.25999964418929977"
取 Hash 值
将二维的经纬度转换为一维的字符串, 如果两个字符串越接近, 则距离越近
127.0.0.1:6379> geohash china:city taiyuan shenyang
1) "ww8p3hhqmp0"
2) "wxrvb9qyxk0"
底层实现
geo底层的实现原理实际上就是Zset, 我们可以通过Zset命令来操作geo:
127.0.0.1:6379> type china:city
zset
查看全部元素 删除指定的元素:
127.0.0.1:6379> zrange china:city 0 -1 withscores
1) "xian"
2) "4040115445396757"
3) "hangzhou"
4) "4054133997236782"
5) "manjing"
6) "4066006694128997"
7) "taiyuan"
8) "4068216047500484"
9) "shenyang"
1) "4072519231994779"
2) "shengzhen"
3) "4154606886655324"
127.0.0.1:6379> zrem china:city manjing
(integer) 1
127.0.0.1:6379> zrange china:city 0 -1
1) "xian"
2) "hangzhou"
3) "taiyuan"
4) "shenyang"
5) "shengzhen"
3 - CH03-Stream
概览
Redis5.0 中还增加了一个数据结构Stream,从字面上看是流类型,但其实从功能上看,应该是Redis对消息队列(MQ,Message Queue)的完善实现。
用过Redis做消息队列的都了解,基于Reids的消息队列实现有很多种,例如:
- PUB/SUB,订阅/发布模式:
- 但是发布订阅模式是无法持久化的,如果出现网络断开、Redis 宕机等,消息就会被丢弃;
- 基于 List LPUSH+BRPOP 或者基于Sorted-Set 的实现:
- 支持了持久化,但是不支持多播,分组消费等
为什么上面的结构无法满足广泛的MQ场景? 这里便引出一个核心的问题:如果我们期望设计一种数据结构来实现消息队列,最重要的就是要理解设计一个消息队列需要考虑什么?初步的我们很容易想到
- 消息的生产
- 消息的消费
- 单播和多播(多对多)
- 阻塞和非阻塞读取
- 消息有序性
- 消息的持久化
基本结构
每个 Stream 都有唯一的名称,它就是 Redis 的 key,在我们首次使用 xadd 指令追加消息时自动创建。
上图解析:
Consumer Group
:消费组,使用 XGROUP CREATE 命令创建,一个消费组有多个消费者(Consumer), 这些消费者之间是竞争关系。last_delivered_id
:游标,每个消费组会有个游标 last_delivered_id,任意一个消费者读取了消息都会使游标 last_delivered_id 往前移动。pending_ids
:消费者(Consumer)的状态变量,作用是维护消费者的未确认的 id。 pending_ids 记录了当前已经被客户端读取的消息,但是还没有ack
(Acknowledge character:确认字符)。如果客户端没有ack,这个变量里面的消息ID会越来越多,一旦某个消息被ack,它就开始减少。这个pending_ids变量在Redis官方被称之为PEL,也就是Pending Entries List,这是一个很核心的数据结构,它用来确保客户端至少消费了消息一次,而不会在网络传输的中途丢失了没处理。
此外我们还需要理解两点:
消息ID
: 消息ID的形式是timestampInMillis-sequence,例如1527846880572-5,它表示当前的消息在毫米时间戳1527846880572时产生,并且是该毫秒内产生的第5条消息。消息ID可以由服务器自动生成,也可以由客户端自己指定,但是形式必须是整数-整数,而且必须是后面加入的消息的ID要大于前面的消息ID。消息内容
: 消息内容就是键值对,形如hash结构的键值对,这没什么特别之处。
基本操作
消息队列相关命令:
- XADD - 添加消息到末尾
- XTRIM - 对流进行修剪,限制长度
- XDEL - 删除消息
- XLEN - 获取流包含的元素数量,即消息长度
- XRANGE - 获取消息列表,会自动过滤已经删除的消息
- XREVRANGE - 反向获取消息列表,ID 从大到小
- XREAD - 以阻塞或非阻塞方式获取消息列表
# *号表示服务器自动生成ID,后面顺序跟着一堆key/value
127.0.0.1:6379> xadd codehole * name laoqian age 30 # 名字叫laoqian,年龄30岁
1527849609889-0 # 生成的消息ID
127.0.0.1:6379> xadd codehole * name xiaoyu age 29
1527849629172-0
127.0.0.1:6379> xadd codehole * name xiaoqian age 1
1527849637634-0
127.0.0.1:6379> xlen codehole
(integer) 3
127.0.0.1:6379> xrange codehole - + # -表示最小值, +表示最大值
127.0.0.1:6379> xrange codehole - +
1) 1) 1527849609889-0
1) 1) "name"
1) "laoqian"
2) "age"
3) "30"
2) 1) 1527849629172-0
1) 1) "name"
1) "xiaoyu"
2) "age"
3) "29"
3) 1) 1527849637634-0
1) 1) "name"
1) "xiaoqian"
2) "age"
3) "1"
127.0.0.1:6379> xrange codehole 1527849629172-0 + # 指定最小消息ID的列表
1) 1) 1527849629172-0
2) 1) "name"
2) "xiaoyu"
3) "age"
4) "29"
2) 1) 1527849637634-0
2) 1) "name"
2) "xiaoqian"
3) "age"
4) "1"
127.0.0.1:6379> xrange codehole - 1527849629172-0 # 指定最大消息ID的列表
1) 1) 1527849609889-0
2) 1) "name"
2) "laoqian"
3) "age"
4) "30"
2) 1) 1527849629172-0
2) 1) "name"
2) "xiaoyu"
3) "age"
4) "29"
127.0.0.1:6379> xdel codehole 1527849609889-0
(integer) 1
127.0.0.1:6379> xlen codehole # 长度不受影响
(integer) 3
127.0.0.1:6379> xrange codehole - + # 被删除的消息没了
1) 1) 1527849629172-0
2) 1) "name"
2) "xiaoyu"
3) "age"
4) "29"
2) 1) 1527849637634-0
2) 1) "name"
2) "xiaoqian"
3) "age"
4) "1"
127.0.0.1:6379> del codehole # 删除整个Stream
(integer) 1
独立消费
我们可以在不定义消费组的情况下进行Stream消息的独立消费,当Stream没有新消息时,甚至可以阻塞等待。Redis设计了一个单独的消费指令xread,可以将Stream当成普通的消息队列(list)来使用。使用xread时,我们可以完全忽略消费组(Consumer Group)的存在,就好比Stream就是一个普通的列表(list)。
# 从Stream头部读取两条消息
127.0.0.1:6379> xread count 2 streams codehole 0-0
1) 1) "codehole"
2) 1) 1) 1527851486781-0
2) 1) "name"
2) "laoqian"
3) "age"
4) "30"
2) 1) 1527851493405-0
2) 1) "name"
2) "yurui"
3) "age"
4) "29"
# 从Stream尾部读取一条消息,毫无疑问,这里不会返回任何消息
127.0.0.1:6379> xread count 1 streams codehole $
(nil)
# 从尾部阻塞等待新消息到来,下面的指令会堵住,直到新消息到来
127.0.0.1:6379> xread block 0 count 1 streams codehole $
# 我们从新打开一个窗口,在这个窗口往Stream里塞消息
127.0.0.1:6379> xadd codehole * name youming age 60
1527852774092-0
# 再切换到前面的窗口,我们可以看到阻塞解除了,返回了新的消息内容
# 而且还显示了一个等待时间,这里我们等待了93s
127.0.0.1:6379> xread block 0 count 1 streams codehole $
1) 1) "codehole"
2) 1) 1) 1527852774092-0
2) 1) "name"
2) "youming"
3) "age"
4) "60"
(93.11s)
客户端如果想要使用xread进行顺序消费,一定要记住当前消费到哪里了,也就是返回的消息ID。下次继续调用xread时,将上次返回的最后一个消息ID作为参数传递进去,就可以继续消费后续的消息。
block 0表示永远阻塞,直到消息到来,block 1000表示阻塞1s,如果1s内没有任何消息到来,就返回nil
127.0.0.1:6379> xread block 1000 count 1 streams codehole $
(nil)
(1.07s)
按组消费
相关命令
- XGROUP CREATE - 创建消费者组
- XREADGROUP GROUP - 读取消费者组中的消息
- XACK - 将消息标记为"已处理"
- XGROUP SETID - 为消费者组设置新的最后递送消息ID
- XGROUP DELCONSUMER - 删除消费者
- XGROUP DESTROY - 删除消费者组
- XPENDING - 显示待处理消息的相关信息
- XCLAIM - 转移消息的归属权
- XINFO - 查看流和消费者组的相关信息;
- XINFO GROUPS - 打印消费者组的信息;
- XINFO STREAM - 打印流信息
Stream通过xgroup create指令创建消费组(Consumer Group),需要传递起始消息ID参数用来初始化last_delivered_id变量。
Stream提供了xreadgroup指令可以进行消费组的组内消费,需要提供消费组名称、消费者名称和起始消息ID。它同xread一样,也可以阻塞等待新消息。读到新消息后,对应的消息ID就会进入消费者的PEL(正在处理的消息)结构里,客户端处理完毕后使用xack指令通知服务器,本条消息已经处理完毕,该消息ID就会从PEL中移除。
信息监控
Stream提供了XINFO来实现对服务器信息的监控,可以查询:
- 查看队列信息
- 消费组信息
- 消费者组成员信息
应用场景
可用作时通信等,大数据分析,异地数据备份等
客户端可以平滑扩展,提高处理能力
消息ID的设计是否考虑了时间回拨的问题
XADD生成的1553439850328-0,就是Redis生成的消息ID,由两部分组成:时间戳-序号。时间戳是毫秒级单位,是生成消息的Redis服务器时间,它是个64位整型(int64)。序号是在这个毫秒时间点内的消息序号,它也是个64位整型。
可以通过multi批处理,来验证序号的递增:
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> XADD memberMessage * msg one
QUEUED
127.0.0.1:6379> XADD memberMessage * msg two
QUEUED
127.0.0.1:6379> XADD memberMessage * msg three
QUEUED
127.0.0.1:6379> XADD memberMessage * msg four
QUEUED
127.0.0.1:6379> XADD memberMessage * msg five
QUEUED
127.0.0.1:6379> EXEC
1) "1553441006884-0"
2) "1553441006884-1"
3) "1553441006884-2"
4) "1553441006884-3"
5) "1553441006884-4"
由于一个redis命令的执行很快,所以可以看到在同一时间戳内,是通过序号递增来表示消息的。
为了保证消息是有序的,因此Redis生成的ID是单调递增有序的。由于ID中包含时间戳部分,为了避免服务器时间错误而带来的问题(例如服务器时间延后了),Redis的每个Stream类型数据都维护一个latest_generated_id属性,用于记录最后一个消息的ID。若发现当前时间戳退后(小于latest_generated_id所记录的),则采用时间戳不变而序号递增的方案来作为新消息ID(这也是序号为什么使用int64的原因,保证有足够多的的序号),从而保证ID的单调递增性质。
强烈建议使用Redis的方案生成消息ID,因为这种时间戳+序号的单调递增的ID方案,几乎可以满足你全部的需求。但同时,记住ID是支持自定义的,别忘了!
消费者崩溃带来的会不会消息丢失问题
为了解决组内消息读取但处理期间消费者崩溃带来的消息丢失问题,STREAM 设计了 Pending 列表,用于记录读取但并未处理完毕的消息。命令XPENDIING 用来获消费组或消费内消费者的未处理完毕的消息。演示如下:
127.0.0.1:6379> XPENDING mq mqGroup # mpGroup的Pending情况
1) (integer) 5 # 5个已读取但未处理的消息
2) "1553585533795-0" # 起始ID
3) "1553585533795-4" # 结束ID
4) 1) 1) "consumerA" # 消费者A有3个
2) "3"
2) 1) "consumerB" # 消费者B有1个
2) "1"
3) 1) "consumerC" # 消费者C有1个
2) "1"
127.0.0.1:6379> XPENDING mq mqGroup - + 10 # 使用 start end count 选项可以获取详细信息
1) 1) "1553585533795-0" # 消息ID
2) "consumerA" # 消费者
3) (integer) 1654355 # 从读取到现在经历了1654355ms,IDLE
4) (integer) 5 # 消息被读取了5次,delivery counter
2) 1) "1553585533795-1"
2) "consumerA"
3) (integer) 1654355
4) (integer) 4
# 共5个,余下3个省略 ...
127.0.0.1:6379> XPENDING mq mqGroup - + 10 consumerA # 在加上消费者参数,获取具体某个消费者的Pending列表
1) 1) "1553585533795-0"
2) "consumerA"
3) (integer) 1641083
4) (integer) 5
# 共3个,余下2个省略 ...
每个Pending的消息有4个属性:
- 消息ID
- 所属消费者
- IDLE,已读取时长
- delivery counter,消息被读取次数
上面的结果我们可以看到,我们之前读取的消息,都被记录在Pending列表中,说明全部读到的消息都没有处理,仅仅是读取了。那如何表示消费者处理完毕了消息呢?使用命令 XACK 完成告知消息处理完成,演示如下:
127.0.0.1:6379> XACK mq mqGroup 1553585533795-0 # 通知消息处理结束,用消息ID标识
(integer) 1
127.0.0.1:6379> XPENDING mq mqGroup # 再次查看Pending列表
1) (integer) 4 # 已读取但未处理的消息已经变为4个
2) "1553585533795-1"
3) "1553585533795-4"
4) 1) 1) "consumerA" # 消费者A,还有2个消息处理
2) "2"
2) 1) "consumerB"
2) "1"
3) 1) "consumerC"
2) "1"
127.0.0.1:6379>
有了这样一个Pending机制,就意味着在某个消费者读取消息但未处理后,消息是不会丢失的。等待消费者再次上线后,可以读取该Pending列表,就可以继续处理该消息了,保证消息的有序和不丢失。
消费者彻底宕机后如何转移给其它消费者处理
还有一个问题,就是若某个消费者宕机之后,没有办法再上线了,那么就需要将该消费者Pending的消息,转移给其他的消费者处理,就是消息转移。
消息转移的操作时将某个消息转移到自己的Pending列表中。使用语法XCLAIM来实现,需要设置组、转移的目标消费者和消息ID,同时需要提供IDLE(已被读取时长),只有超过这个时长,才能被转移。演示如下:
# 当前属于消费者A的消息1553585533795-1,已经15907,787ms未处理了
127.0.0.1:6379> XPENDING mq mqGroup - + 10
1) 1) "1553585533795-1"
2) "consumerA"
3) (integer) 15907787
4) (integer) 4
# 转移超过3600s的消息1553585533795-1到消费者B的Pending列表
127.0.0.1:6379> XCLAIM mq mqGroup consumerB 3600000 1553585533795-1
1) 1) "1553585533795-1"
2) 1) "msg"
2) "2"
# 消息1553585533795-1已经转移到消费者B的Pending中。
127.0.0.1:6379> XPENDING mq mqGroup - + 10
1) 1) "1553585533795-1"
2) "consumerB"
3) (integer) 84404 # 注意IDLE,被重置了
4) (integer) 5 # 注意,读取次数也累加了1次
以上代码,完成了一次消息转移。转移除了要指定ID外,还需要指定IDLE,保证是长时间未处理的才被转移。被转移的消息的IDLE会被重置,用以保证不会被重复转移,以为可能会出现将过期的消息同时转移给多个消费者的并发操作,设置了IDLE,则可以避免后面的转移不会成功,因为IDLE不满足条件。例如下面的连续两条转移,第二条不会成功。
127.0.0.1:6379> XCLAIM mq mqGroup consumerB 3600000 1553585533795-1
127.0.0.1:6379> XCLAIM mq mqGroup consumerC 3600000 1553585533795-1
这就是消息转移。至此我们使用了一个Pending消息的ID,所属消费者和IDLE的属性,还有一个属性就是消息被读取次数,delivery counter,该属性的作用由于统计消息被读取的次数,包括被转移也算。这个属性主要用在判定是否为错误数据上。
坏消息问题,Dead Letter,死信问题
正如上面所说,如果某个消息,不能被消费者处理,也就是不能被XACK,这是要长时间处于Pending列表中,即使被反复的转移给各个消费者也是如此。此时该消息的delivery counter就会累加(上一节的例子可以看到),当累加到某个我们预设的临界值时,我们就认为是坏消息(也叫死信,DeadLetter,无法投递的消息),由于有了判定条件,我们将坏消息处理掉即可,删除即可。删除一个消息,使用XDEL语法,演示如下:
# 删除队列中的消息
127.0.0.1:6379> XDEL mq 1553585533795-1
(integer) 1
# 查看队列中再无此消息
127.0.0.1:6379> XRANGE mq - +
1) 1) "1553585533795-0"
2) 1) "msg"
2) "1"
2) 1) "1553585533795-2"
2) 1) "msg"
2) "3"
注意本例中,并没有删除Pending中的消息因此你查看Pending,消息还会在。可以执行XACK标识其处理完毕!
4 - CH04-对象机制
结构概览
上图反映了Redis的每种对象其实都由对象结构(redisObject) 与 对应编码的数据结构组合而成,而每种对象类型对应若干编码方式,不同的编码方式所对应的底层数据结构是不同的。
所以,我们需要从几个个角度来着手底层研究:
- 对象设计机制: 对象结构(redisObject)
- 编码类型和底层数据结构: 对应编码的数据结构
RedisObject
在redis的命令中,用于对键进行处理的命令占了很大一部分,而对于键所保存的值的类型,键能执行的命令又各不相同。如: LPUSH
和 LLEN
只能用于列表键, 而 SADD
和 SRANDMEMBER
只能用于集合键; 另外一些命令, 比如 DEL
、 TTL
和 TYPE
, 可以用于任何类型的键;但是要正确实现这些命令, 必须为不同类型的键设置不同的处理方式: 比如说, 删除一个列表键和删除一个字符串键的操作过程就不太一样。
以上的描述说明, Redis 必须让每个键都带有类型信息, 使得程序可以检查键的类型, 并为它选择合适的处理方式.
比如说, 集合类型就可以由字典和整数集合两种不同的数据结构实现, 但是, 当用户执行 ZADD 命令时,应该不必关心集合使用的是什么编码, 只要 Redis 能按照 ZADD 命令的指示, 将新元素添加到集合就可以了。
这说明, 操作数据类型的命令除了要对键的类型进行检查之外, 还需要根据数据类型的不同编码进行多态处理.
为了解决以上问题, Redis 构建了自己的类型系统, 这个系统的主要功能包括:
- redisObject 对象.
- 基于 redisObject 对象的类型检查.
- 基于 redisObject 对象的显式多态函数.
- 对 redisObject 进行分配、共享和销毁的机制.
数据结构
redisObject 是 Redis 类型系统的核心, 数据库中的每个键、值, 以及 Redis 本身处理的参数, 都表示为这种数据类型。
/*
* Redis 对象
*/
typedef struct redisObject {
// 类型
unsigned type:4;
// 编码方式
unsigned encoding:4;
// LRU - 24位, 记录最末一次访问时间(相对于lru_clock); 或者 LFU(最少使用的数据:8位频率,16位访问时间)
unsigned lru:LRU_BITS; // LRU_BITS: 24
// 引用计数
int refcount;
// 指向底层数据结构实例
void *ptr;
} robj;
下图对应上面的结构:
其中type、encoding和ptr是最重要的三个属性。
- type记录了对象所保存的值的类型,它的值可能是以下常量中的一个:
/*
* 对象类型
*/
#define OBJ_STRING 0 // 字符串
#define OBJ_LIST 1 // 列表
#define OBJ_SET 2 // 集合
#define OBJ_ZSET 3 // 有序集
#define OBJ_HASH 4 // 哈希表
- encoding记录了对象所保存的值的编码,它的值可能是以下常量中的一个:
/*
* 对象编码
*/
#define OBJ_ENCODING_RAW 0 /* Raw representation */
#define OBJ_ENCODING_INT 1 /* Encoded as integer */
#define OBJ_ENCODING_HT 2 /* Encoded as hash table */
#define OBJ_ENCODING_ZIPMAP 3 /* 注意:版本2.6后不再使用. */
#define OBJ_ENCODING_LINKEDLIST 4 /* 注意:不再使用了,旧版本2.x中String的底层之一. */
#define OBJ_ENCODING_ZIPLIST 5 /* Encoded as ziplist */
#define OBJ_ENCODING_INTSET 6 /* Encoded as intset */
#define OBJ_ENCODING_SKIPLIST 7 /* Encoded as skiplist */
#define OBJ_ENCODING_EMBSTR 8 /* Embedded sds string encoding */
#define OBJ_ENCODING_QUICKLIST 9 /* Encoded as linked list of ziplists */
#define OBJ_ENCODING_STREAM 10 /* Encoded as a radix tree of listpacks */
ptr是一个指针,指向实际保存值的数据结构,这个数据结构由type和encoding属性决定。举个例子, 如果一个redisObject 的type 属性为
OBJ_LIST
, encoding 属性为OBJ_ENCODING_QUICKLIST
,那么这个对象就是一个Redis 列表(List),它的值保存在一个QuickList的数据结构内,而ptr 指针就指向quicklist的对象;lru属性: 记录了对象最后一次被命令程序访问的时间
空转时长:当前时间减去键的值对象的lru时间,就是该键的空转时长。Object idletime命令可以打印出给定键的空转时长
如果服务器打开了maxmemory选项,并且服务器用于回收内存的算法为volatile-lru或者allkeys-lru,那么当服务器占用的内存数超过了maxmemory选项所设置的上限值时,空转时长较高的那部分键会优先被服务器释放,从而回收内存。
命令的类型检查与多态
当执行一个处理数据类型命令的时候,redis执行以下步骤
- 根据给定的key,在数据库字典中查找和他相对应的redisObject,如果没找到,就返回NULL;
- 检查redisObject的type属性和执行命令所需的类型是否相符,如果不相符,返回类型错误;
- 根据redisObject的encoding属性所指定的编码,选择合适的操作函数来处理底层的数据结构;
- 返回数据结构的操作结果作为命令的返回值。
比如现在执行LPOP命令:
对象共享
redis一般会把一些常见的值放到一个共享对象中,这样可使程序避免了重复分配的麻烦,也节约了一些CPU时间。
redis预分配的值对象如下:
- 各种命令的返回值,比如成功时返回的OK,错误时返回的ERROR,命令入队事务时返回的QUEUE,等等
- 包括0 在内,小于REDIS_SHARED_INTEGERS的所有整数(REDIS_SHARED_INTEGERS的默认值是10000)
注意:共享对象只能被字典和双向链表这类能带有指针的数据结构使用。像整数集合和压缩列表这些只能保存字符串、整数等字面值的内存数据结构。
为什么redis不共享列表对象、哈希对象、集合对象、有序集合对象,只共享字符串对象?
- 列表对象、哈希对象、集合对象、有序集合对象,本身可以包含字符串对象,复杂度较高。
- 如果共享对象是保存字符串对象,那么验证操作的复杂度为O(1)
- 如果共享对象是保存字符串值的字符串对象,那么验证操作的复杂度为O(N)
- 如果共享对象是包含多个值的对象,其中值本身又是字符串对象,即其它对象中嵌套了字符串对象,比如列表对象、哈希对象,那么验证操作的复杂度将会是O(N的平方)
如果对复杂度较高的对象创建共享对象,需要消耗很大的CPU,用这种消耗去换取内存空间,是不合适的。
引用计数以及对象的消毁
redisObject中有refcount属性,是对象的引用计数,显然计数0那么就是可以回收。
- 每个redisObject结构都带有一个refcount属性,指示这个对象被引用了多少次;
- 当新创建一个对象时,它的refcount属性被设置为1;
- 当对一个对象进行共享时,redis将这个对象的refcount加一;
- 当使用完一个对象后,或者消除对一个对象的引用之后,程序将对象的refcount减一;
- 当对象的refcount降至0 时,这个RedisObject结构,以及它引用的数据结构的内存都会被释放。
总结
redis使用自己实现的对象机制(redisObject)来实现类型判断、命令多态和基于引用次数的垃圾回收;
redis会预分配一些常用的数据对象,并通过共享这些对象来减少内存占用,和避免频繁的为小对象分配内存。
5 - CH05-底层结构
简单动态字符串:SDS
Redis 是用 C 语言写的,但是对于Redis的字符串,却不是 C 语言中的字符串(即以空字符’\0’结尾的字符数组),它是自己构建了一种名为 简单动态字符串(simple dynamic string,SDS)的抽象类型,并将 SDS 作为 Redis的默认字符串表示。
定义
这是一种用于存储二进制数据的一种结构, 具有动态扩容的特点. 其实现位于src/sds.h与src/sds.c中。
其中sdshdr
是头部, buf
是真实存储用户数据的地方. 另外注意, 从命名上能看出来, 这个数据结构除了能存储二进制数据, 显然是用于设计作为字符串使用的, 所以在buf中, 用户数据后总跟着一个\0. 即图中 "数据" + "\0"
是为所谓的buf。
SDS有五种不同的头部. 其中sdshdr5实际并未使用到. 所以实际上有四种不同的头部, 分别如下:
其中:
len
保存了SDS保存字符串的长度buf[]
数组用来保存字符串的每个元素alloc
分别以uint8, uint16, uint32, uint64表示整个SDS, 除过头部与末尾的\0, 剩余的字节数.flags
始终为一字节, 以低三位标示着头部的类型, 高5位未使用.
为什么使用SDS
为什么不使用C语言字符串实现,而是使用 SDS呢?这样实现有什么好处?
常数复杂度获取字符串长度
由于 len 属性的存在,我们获取 SDS 字符串的长度只需要读取 len 属性,时间复杂度为 O(1)。而对于 C 语言,获取字符串的长度通常是经过遍历计数来实现的,时间复杂度为 O(n)。通过
strlen key
命令可以获取 key 的字符串长度。杜绝缓冲区溢出
我们知道在 C 语言中使用
strcat
函数来进行两个字符串的拼接,一旦没有分配足够长度的内存空间,就会造成缓冲区溢出。而对于 SDS 数据类型,在进行字符修改的时候,会首先根据记录的 len 属性检查内存空间是否满足需求,如果不满足,会进行相应的空间扩展,然后在进行修改操作,所以不会出现缓冲区溢出。减少修改字符串的内存重新分配次数
C语言由于不记录字符串的长度,所以如果要修改字符串,必须要重新分配内存(先释放再申请),因为如果没有重新分配,字符串长度增大时会造成内存缓冲区溢出,字符串长度减小时会造成内存泄露。
而对于SDS,由于
len
属性和alloc
属性的存在,对于修改字符串SDS实现了空间预分配和惰性空间释放两种策略:空间预分配
:对字符串进行空间扩展的时候,扩展的内存比实际需要的多,这样可以减少连续执行字符串增长操作所需的内存重分配次数。惰性空间释放
:对字符串进行缩短操作时,程序不立即使用内存重新分配来回收缩短后多余的字节,而是使用alloc
属性将这些字节的数量记录下来,等待后续使用。(当然SDS也提供了相应的API,当我们有需要时,也可以手动释放这些未使用的空间。)
二进制安全
因为C字符串以空字符作为字符串结束的标识,而对于一些二进制文件(如图片等),内容可能包括空字符串,因此C字符串无法正确存取;而所有 SDS 的API 都是以处理二进制的方式来处理
buf
里面的元素,并且 SDS 不是以空字符串来判断是否结束,而是以 len 属性表示的长度来判断字符串是否结束。兼容部分 C 字符串函数
虽然 SDS 是二进制安全的,但是一样遵从每个字符串都是以空字符串结尾的惯例,这样可以重用 C 语言库<string.h>
中的一部分函数。
空间预分配补进一步理解
当执行追加操作时,比如现在给key=‘Hello World’
的字符串后追加‘ again!’
则这时的len=18,free由0变成了18,此时的buf='Hello World again!\0....................'
(.表示空格),也就是buf的内存空间是18+18+1=37个字节,其中‘\0’占1个字节redis给字符串多分配了18个字节的预分配空间,所以下次还有append追加的时候,如果预分配空间足够,就无须在进行空间分配了。在当前版本中,当新字符串的长度小于1M时,redis会分配他们所需大小一倍的空间,当大于1M的时候,就为他们额外多分配1M的空间。
思考:这种分配策略会浪费内存资源吗?
答:执行过APPEND 命令的字符串会带有额外的预分配空间,这些预分配空间不会被释放,除非该字符串所对应的键被删除,或者等到关闭Redis 之后,再次启动时重新载入的字符串对象将不会有预分配空间。因为执行APPEND 命令的字符串键数量通常并不多,占用内存的体积通常也不大,所以这一般并不算什么问题。另一方面,如果执行APPEND 操作的键很多,而字符串的体积又很大的话,那可能就需要修改Redis 服务器,让它定时释放一些字符串键的预分配空间,从而更有效地使用内存。
小结
redis的字符串表示为sds,而不是C字符串(以\0结尾的char*), 它是Redis 底层所使用的字符串表示,它被用在几乎所有的Redis 模块中。可以看如下对比:
一般来说,SDS 除了保存数据库中的字符串值以外,SDS 还可以作为缓冲区(buffer):包括 AOF 模块中的AOF缓冲区以及客户端状态中的输入缓冲区
压缩列表:ZipList
ziplist是为了提高存储效率而设计的一种特殊编码的双向链表。它可以存储字符串或者整数,存储整数时是采用整数的二进制而不是字符串形式存储。他能在O(1)的时间复杂度下完成list两端的push和pop操作。但是因为每次操作都需要重新分配ziplist的内存,所以实际复杂度和ziplist的内存使用量相关。
ZipList 结构
整个ziplist在内存中的存储格式如下:
zlbytes
字段的类型是uint32_t, 这个字段中存储的是整个ziplist所占用的内存的字节数zltail
字段的类型是uint32_t, 它指的是ziplist中最后一个entry的偏移量. 用于快速定位最后一个entry, 以快速完成pop等操作zllen
字段的类型是uint16_t, 它指的是整个ziplit中entry的数量. 这个值只占2bytes(16位): 如果ziplist中entry的数目小于65535(2的16次方), 那么该字段中存储的就是实际entry的值. 若等于或超过65535, 那么该字段的值固定为65535, 但实际数量需要一个个entry的去遍历所有entry才能得到.zlend
是一个终止字节, 其值为全F, 即0xff. ziplist保证任何情况下, 一个entry的首字节都不会是255
Entry 结构
第一种情况:一般结构 <prevlen> <encoding> <entry-data>
prevlen
:前一个entry的大小,编码方式见下文;
encoding
:不同的情况下值不同,用于表示当前entry的类型和长度;
entry-data
:真是用于存储entry表示的数据;
第二种情况:在entry中存储的是int类型时,encoding和entry-data会合并在encoding中表示,此时没有entry-data字段;
redis中,在存储数据时,会先尝试将string转换成int存储,节省空间;
此时entry结构:<prevlen> <encoding>
- prevlen编码
当前一个元素长度小于254(255用于zlend)的时候,prevlen长度为1个字节,值即为前一个entry的长度,如果长度大于等于254的时候,prevlen用5个字节表示,第一字节设置为254,后面4个字节存储一个小端的无符号整型,表示前一个entry的长度;
<prevlen from 0 to 253> <encoding> <entry> //长度小于254结构
0xFE <4 bytes unsigned little endian prevlen> <encoding> <entry> //长度大于等于254
- encoding编码
encoding的长度和值根据保存的是int还是string,还有数据的长度而定;
前两位用来表示类型,当为“11”时,表示entry存储的是int类型,其他表示存储的是string;
存储string时:
|00pppppp|
:此时encoding长度为1个字节,该字节的后六位表示entry中存储的string长度,因为是6位,所以entry中存储的string长度不能超过63;
|01pppppp|qqqqqqqq|
此时encoding长度为两个字节;此时encoding的后14位用来存储string长度,长度不能超过16383;
|10000000|qqqqqqqq|rrrrrrrr|ssssssss|ttttttt|
此时encoding长度为5个字节,后面的4个字节用来表示encoding中存储的字符串长度,长度不能超过2^32 - 1;
存储int时:
|11000000|
encoding为3个字节,后2个字节表示一个int16;
|11010000|
encoding为5个字节,后4个字节表示一个int32;
|11100000|
encoding 为9个字节,后8字节表示一个int64;
|11110000|
encoding为4个字节,后3个字节表示一个有符号整型;
|11111110|
encoding为2字节,后1个字节表示一个有符号整型;
|1111xxxx|
encoding长度就只有1个字节,xxxx表示一个0 - 12的整数值;
|11111111|
还记得zlend么?
- 源码中数据结构支撑
你可以看到为了操作上的简易实际还增加了几个属性
/* 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; /* Bytes used to encode the previous entry len*/
unsigned int prevrawlen; /* Previous entry len. */
unsigned int lensize; /* Bytes used to encode this entry type/len.
For example strings have a 1, 2 or 5 bytes
header. Integers always use a single byte.*/
unsigned int len; /* Bytes used to represent the actual entry.
For strings this is just the string length
while for integers it is 1, 2, 3, 4, 8 or
0 (for 4 bit immediate) depending on the
number range. */
unsigned int headersize; /* prevrawlensize + lensize. */
unsigned char encoding; /* Set to ZIP_STR_* or ZIP_INT_* depending on
the entry encoding. However for 4 bits
immediate integers this can assume a range
of values and must be range-checked. */
unsigned char *p; /* Pointer to the very start of the entry, that
is, this points to prev-entry-len field. */
} zlentry;
prevrawlensize
表示 previous_entry_length字段的长度prevrawlen
表示 previous_entry_length字段存储的内容lensize
表示 encoding字段的长度len
表示数据内容长度headersize
表示当前元素的首部长度,即previous_entry_length字段长度与encoding字段长度之和encoding
表示数据类型p
表示当前元素首地址
为什么ZipList特别省内存
- ziplist节省内存是相对于普通的list来说的,如果是普通的数组,那么它每个元素占用的内存是一样的且取决于最大的那个元素(很明显它是需要预留空间的);
- 所以ziplist在设计时就很容易想到要尽量让每个元素按照实际的内容大小存储,所以增加encoding字段,针对不同的encoding来细化存储大小;
- 这时候还需要解决的一个问题是遍历元素时如何定位下一个元素呢?在普通数组中每个元素定长,所以不需要考虑这个问题;但是ziplist中每个data占据的内存不一样,所以为了解决遍历,需要增加记录上一个元素的length,所以增加了prelen字段。
为什么我们去研究ziplist特别节省内存的数据结构? 在实际应用中,大量存储字符串的优化是需要你对底层的数据结构有一定的理解的,而ziplist在场景优化的时候也被考虑采用的首选。
缺点
- ziplist也不预留内存空间, 并且在移除结点后, 也是立即缩容, 这代表每次写操作都会进行内存分配操作.
- 结点如果扩容, 导致结点占用的内存增长, 并且超过254字节的话, 可能会导致链式反应: 其后一个结点的entry.prevlen需要从一字节扩容至五字节.
- 最坏情况下, 第一个结点的扩容, 会导致整个ziplist表中的后续所有结点的entry.prevlen字段扩容.
- 虽然这个内存重分配的操作依然只会发生一次, 但代码中的时间复杂度是o(N)级别, 因为链式扩容只能一步一步的计算.
- 但这种情况的概率十分的小, 一般情况下链式扩容能连锁反映五六次就很不幸了.
- 这样的坏场景下, 其实时间复杂度并不高: 依次计算每个entry新的空间占用, 也就是o(N), 总体占用计算出来后, 只执行一次内存重分配, 与对应的memmove操作, 就可以了.
快表:QuickList
quicklist这个结构是Redis在3.2版本后新加的, 之前的版本是list(即linkedlist), 用于String数据类型中。
它是一种以ziplist为结点的双端链表结构. 宏观上, quicklist是一个链表, 微观上, 链表中的每个结点都是一个ziplist。
QuickList 结构
内部定义了6个结构体:
quicklistNode
, 宏观上, quicklist是一个链表, 这个结构描述的就是链表中的结点. 它通过zl字段持有底层的ziplist. 简单来讲, 它描述了一个ziplist实例quicklistLZF
, ziplist是一段连续的内存, 用LZ4算法压缩后, 就可以包装成一个quicklistLZF结构. 是否压缩quicklist中的每个ziplist实例是一个可配置项. 若这个配置项是开启的, 那么quicklistNode.zl字段指向的就不是一个ziplist实例, 而是一个压缩后的quicklistLZF实例quicklistBookmark
, 在quicklist尾部增加的一个书签,它只有在大量节点的多余内存使用量可以忽略不计的情况且确实需要分批迭代它们,才会被使用。当不使用它们时,它们不会增加任何内存开销。quicklist
. 这就是一个双链表的定义. head, tail分别指向头尾指针. len代表链表中的结点. count指的是整个quicklist中的所有ziplist中的entry的数目. fill字段影响着每个链表结点中ziplist的最大占用空间, compress影响着是否要对每个ziplist以LZ4算法进行进一步压缩以更节省内存空间.quicklistIter
是一个迭代器quicklistEntry
是对ziplist中的entry概念的封装. quicklist作为一个封装良好的数据结构, 不希望使用者感知到其内部的实现, 所以需要把ziplist.entry的概念重新包装一下.
内存布局图
更多信息
下面是有关quicklist的更多额外信息:
quicklist.fill
的值影响着每个链表结点中, ziplist的长度.
- 当数值为负数时, 代表以字节数限制单个ziplist的最大长度. 具体为:
- -1 不超过4kb
- -2 不超过 8kb
- -3 不超过 16kb
- -4 不超过 32kb
- -5 不超过 64kb
- 当数值为正数时, 代表以entry数目限制单个ziplist的长度. 值即为数目. 由于该字段仅占16位, 所以以entry数目限制ziplist的容量时, 最大值为2^15个
quicklist.compress
的值影响着quicklistNode.zl字段指向的是原生的ziplist, 还是经过压缩包装后的quicklistLZF
- 0 表示不压缩, zl字段直接指向ziplist
- 1 表示quicklist的链表头尾结点不压缩, 其余结点的zl字段指向的是经过压缩后的quicklistLZF
- 2 表示quicklist的链表头两个, 与末两个结点不压缩, 其余结点的zl字段指向的是经过压缩后的quicklistLZF
- 以此类推, 最大值为2^16
quicklistNode.encoding
字段, 以指示本链表结点所持有的ziplist是否经过了压缩. 1代表未压缩, 持有的是原生的ziplist, 2代表压缩过quicklistNode.container
字段指示的是每个链表结点所持有的数据类型是什么. 默认的实现是ziplist, 对应的该字段的值是2, 目前Redis没有提供其它实现. 所以实际上, 该字段的值恒为2quicklistNode.recompress
字段指示的是当前结点所持有的ziplist是否经过了解压. 如果该字段为1即代表之前被解压过, 且需要在下一次操作时重新压缩.
quicklist的具体实现代码篇幅很长, 这里就不贴代码片断了, 从内存布局上也能看出来, 由于每个结点持有的ziplist是有上限长度的, 所以在与操作时要考虑的分支情况比较多。
quicklist有自己的优点, 也有缺点, 对于使用者来说, 其使用体验类似于线性数据结构, list作为最传统的双链表, 结点通过指针持有数据, 指针字段会耗费大量内存. ziplist解决了耗费内存这个问题. 但引入了新的问题: 每次写操作整个ziplist的内存都需要重分配. quicklist在两者之间做了一个平衡. 并且使用者可以通过自定义quicklist.fill
, 根据实际业务情况, 经验主义调参.
字典:Dict
数据结构
哈希表结构定义:
typedef struct dictht{
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值
//总是等于 size-1
unsigned long sizemask;
//该哈希表已有节点的数量
unsigned long used;
}dictht
哈希表是由数组 table 组成,table 中每个元素都是指向 dict.h/dictEntry 结构,dictEntry 结构定义如下:
typedef struct dictEntry{
//键
void *key;
//值
union{
void *val;
uint64_tu64;
int64_ts64;
}v;
//指向下一个哈希表节点,形成链表
struct dictEntry *next;
}dictEntry
key 用来保存键,val 属性用来保存值,值可以是一个指针,也可以是uint64_t整数,也可以是int64_t整数。
注意这里还有一个指向下一个哈希表节点的指针,我们知道哈希表最大的问题是存在哈希冲突,如何解决哈希冲突,有开放地址法和链地址法。这里采用的便是链地址法,通过next这个指针可以将多个哈希值相同的键值对连接在一起,用来解决哈希冲突。
要点理解
- 哈希算法:Redis计算哈希值和索引值方法如下:
#1、使用字典设置的哈希函数,计算键 key 的哈希值
hash = dict->type->hashFunction(key);
#2、使用哈希表的sizemask属性和第一步得到的哈希值,计算索引值
index = hash & dict->ht[x].sizemask;
解决哈希冲突:这个问题上面我们介绍了,方法是链地址法。通过字典里面的 *next 指针指向下一个具有相同索引值的哈希表节点。
扩容和收缩:当哈希表保存的键值对太多或者太少时,就要通过 rerehash(重新散列)来对哈希表进行相应的扩展或者收缩。具体步骤:
1、如果执行扩展操作,会基于原哈希表创建一个大小等于
ht[0].used*2n
的哈希表(也就是每次扩展都是根据原哈希表已使用的空间扩大一倍创建另一个哈希表)。相反如果执行的是收缩操作,每次收缩是根据已使用空间缩小一倍创建一个新的哈希表。2、重新利用上面的哈希算法,计算索引值,然后将键值对放到新的哈希表位置上。
3、所有键值对都迁徙完毕后,释放原哈希表的内存空间。
触发扩容的条件:
1、服务器目前没有执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且负载因子大于等于1。
2、服务器目前正在执行 BGSAVE 命令或者 BGREWRITEAOF 命令,并且负载因子大于等于5。
ps:负载因子 = 哈希表已保存节点数量 / 哈希表大小。
渐近式 rehash
- 什么叫渐进式 rehash?也就是说扩容和收缩操作不是一次性、集中式完成的,而是分多次、渐进式完成的。如果保存在Redis中的键值对只有几个几十个,那么 rehash 操作可以瞬间完成,但是如果键值对有几百万,几千万甚至几亿,那么要一次性的进行 rehash,势必会造成Redis一段时间内不能进行别的操作。所以Redis采用渐进式 rehash,这样在进行渐进式rehash期间,字典的删除查找更新等操作可能会在两个哈希表上进行,第一个哈希表没有找到,就会去第二个哈希表上进行查找。但是进行 增加操作,一定是在新的哈希表上进行的。
整数集:IntSet
整数集合(intset)是集合类型的底层实现之一,当一个集合只包含整数值元素,并且这个集合的元素数量不多时,Redis 就会使用整数集合作为集合键的底层实现。
结构
首先看源码结构
typedef struct intset {
uint32_t encoding;
uint32_t length;
int8_t contents[];
} intset;
encoding
表示编码方式,的取值有三个:INTSET_ENC_INT16, INTSET_ENC_INT32, INTSET_ENC_INT64length
代表其中存储的整数的个数contents
指向实际存储数值的连续内存区域, 就是一个数组;整数集合的每个元素都是 contents 数组的一个数组项(item),各个项在数组中按值得大小从小到大有序排序,且数组中不包含任何重复项。(虽然 intset 结构将 contents 属性声明为 int8_t 类型的数组,但实际上 contents 数组并不保存任何 int8_t 类型的值,contents 数组的真正类型取决于 encoding 属性的值)
内存布局
content数组里面每个元素的数据类型是由encoding来决定的,那么如果原来的数据类型是int16, 当我们再插入一个int32类型的数据时怎么办呢?这就是下面要说的intset的升级。
升级
当在一个int16类型的整数集合中插入一个int32类型的值,整个集合的所有元素都会转换成32类型。 整个过程有三步:
- 根据新元素的类型(比如int32),扩展整数集合底层数组的空间大小,并为新元素分配空间。
- 将底层数组现有的所有元素都转换成与新元素相同的类型, 并将类型转换后的元素放置到正确的位上, 而且在放置元素的过程中, 需要继续维持底层数组的有序性质不变。
- 最后改变encoding的值,length+1。
那么如果我们删除掉刚加入的int32类型时,会不会做一个降级操作呢?
不会。主要还是减少开销的权衡。
跳表:ZSkipList
跳跃表结构在 Redis 中的运用场景只有一个,那就是作为有序列表 (Zset) 的使用。跳跃表的性能可以保证在查找,删除,添加等操作的时候在对数期望时间内完成,这个性能是可以和平衡树来相比较的,而且在实现方面比平衡树要优雅,这就是跳跃表的长处。
跳跃表的缺点就是需要的存储空间比较大,属于利用空间来换取时间的数据结构。
跳跃表
对于于一个单链表来讲,即便链表中存储的数据是有序的,如果我们要想在其中查找某个数据,也只能从头到尾遍历链表。这样查找效率就会很低,时间复杂度会很高,是 O(n)。比如查找12,需要7次查找:
如果我们增加如下两级索引,那么它搜索次数就变成了3次:
Redis 跳跃表
redis跳跃表并没有在单独的类(比如skplist.c)中定义,而是其定义在server.h中, 如下:
/* ZSETs use a specialized version of Skiplists */
typedef struct zskiplistNode {
sds ele;
double score;
struct zskiplistNode *backward;
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned int span;
} level[];
} zskiplistNode;
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
} zskiplist;
内存布局:
zskiplist的核心设计要点
头结点不持有任何数据, 且其level[]的长度为32
每个结点
ele
字段,持有数据,是sds类型score
字段, 其标示着结点的得分, 结点之间凭借得分来判断先后顺序, 跳跃表中的结点按结点的得分升序排列.backward
指针, 这是原版跳跃表中所没有的. 该指针指向结点的前一个紧邻结点.level
字段, 用以记录所有结点(除过头结点外);每个结点中最多持有32个zskiplistLevel结构. 实际数量在结点创建时, 按幂次定律随机生成(不超过32). 每个zskiplistLevel中有两个字段
forward
字段指向比自己得分高的某个结点(不一定是紧邻的), 并且, 若当前zskiplistLevel实例在level[]中的索引为X, 则其forward字段指向的结点, 其level[]字段的容量至少是X+1. 这也是上图中, 为什么forward指针总是画的水平的原因.span
字段代表forward字段指向的结点, 距离当前结点的距离. 紧邻的两个结点之间的距离定义为1.
为什么不选择平衡树或哈希表
作者:
There are a few reasons:
They are not very memory intensive. It’s up to you basically. Changing parameters about the probability of a node to have a given number of levels will make then less memory intensive than btrees. A sorted set is often target of many ZRANGE or ZREVRANGE operations, that is, traversing the skip list as a linked list. With this operation the cache locality of skip lists is at least as good as with other kind of balanced trees.
They are simpler to implement, debug, and so forth. For instance thanks to the skip list simplicity I received a patch (already in Redis master) with augmented skip lists implementing ZRANK in O(log(N)). It required little changes to the code.
About the Append Only durability & speed, I don’t think it is a good idea to optimize Redis at cost of more code and more complexity for a use case that IMHO should be rare for the Redis target (fsync() at every command). Almost no one is using this feature even with ACID SQL databases, as the performance hint is big anyway. About threads: our experience shows that Redis is mostly I/O bound. I’m using threads to serve things from Virtual Memory. The long term solution to exploit all the cores, assuming your link is so fast that you can saturate a single core, is running multiple instances of Redis (no locks, almost fully scalable linearly with number of cores), and using the “Redis Cluster” solution that I plan to develop in the future.
简而言之就是实现简单且达到了类似效果。
skiplist和各种平衡树(如AVL、红黑树等)的元素是有序排列的,而哈希表不是有序的。因此,在哈希表上只能做单个key的查找,不适宜做范围查找。所谓范围查找,指的是查找那些大小在指定的两个值之间的所有节点。
在做范围查找的时候,平衡树比skiplist操作要复杂。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在skiplist上进行范围查找就非常简单,只需要在找到小值之后,对第1层链表进行若干步的遍历就可以实现。
平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而skiplist的插入和删除只需要修改相邻节点的指针,操作简单又快速。
从内存占用上来说,skiplist比平衡树更灵活一些。一般来说,平衡树每个节点包含2个指针(分别指向左右子树),而skiplist每个节点包含的指针数目平均为1/(1-p),具体取决于参数p的大小。如果像Redis里的实现一样,取p=1/4,那么平均每个节点包含1.33个指针,比平衡树更有优势。
查找单个key,skiplist和平衡树的时间复杂度都为O(log n),大体相当;而哈希表在保持较低的哈希值冲突概率的前提下,查找时间复杂度接近O(1),性能更高一些。所以我们平常使用的各种Map或dictionary结构,大都是基于哈希表实现的。
从算法实现难度上来比较,skiplist比平衡树要简单得多。
6 - CH06-缓存机制
在实际的工作项目中, 缓存成为高并发、高性能架构的关键组件 ,那么Redis为什么可以作为缓存使用呢?首先可以作为缓存的两个主要特征:
- 在分层系统中处于内存/CPU具有访问性能良好,
- 缓存数据饱和,有良好的数据淘汰机制
由于 Redis 天然就具有这两个特征,Redis 基于内存操作的,且其具有完善的数据淘汰机制,十分适合作为缓存组件。
其中,基于内存操作,容量可以为 32-96GB,且操作时间平均为 100ns,操作效率高。而且数据淘汰机制众多,在Redis 4.0 后就有 8 种了促使 Redis 作为缓存可以适用很多场景。
淘汰机制
Redis 对于缓存被写满的情况,就需要缓存数据淘汰机制,通过一定淘汰规则将一些数据刷选出来删除,让缓存服务可再使用。那么 Redis 使用哪些淘汰策略进行刷选删除数据?
在 Redis 4.0 之后,Redis 缓存淘汰策略 6+2 种,包括分成三大类:
- 不淘汰数据
- noeviction ,不进行数据淘汰,当缓存被写满后,Redis不提供服务直接返回错误。
- 在设置过期时间的键值对中,
- volatile-random ,在设置过期时间的键值对中随机删除
- volatile-ttl ,在设置过期时间的键值对,基于过期时间的先后进行删除,越早过期的越先被删除。
- volatile-lru , 基于LRU(Least Recently Used) 算法筛选设置了过期时间的键值对, 最近最少使用的原则来筛选数据
- volatile-lfu ,使用 LFU( Least Frequently Used ) 算法选择设置了过期时间的键值对, 使用频率最少的键值对,来筛选数据。
- 在所有的键值对中
- allkeys-random, 从所有键值对中随机选择并删除数据
- allkeys-lru, 使用 LRU 算法在所有数据中进行筛选
- allkeys-lfu, 使用 LFU 算法在所有数据中进行筛选
Note: LRU( 最近最少使用,Least Recently Used)算法, LRU维护一个双向链表 ,链表的头和尾分别表示 MRU 端和 LRU 端,分别代表最近最常使用的数据和最近最不常用的数据。
LRU 算法在实际实现时,需要用链表管理所有的缓存数据,这会带来额外的空间开销。而且,当有数据被访问时,需要在链表上把该数据移动到 MRU 端,如果有大量数据被访问,就会带来很多链表移动操作,会很耗时,进而会降低 Redis 缓存性能。
其中,LRU和LFU 基于Redis的对象结构redisObject
的lru
和refcount
属性实现的:
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;
Redis
的LRU
会使用redisObject
的lru
记录最近一次被访问的时间,随机选取参数maxmemory-samples
配置的数量作为候选集合,在其中选择 lru
属性值最小的数据淘汰出去。
在实际项目中,那么该如何选择数据淘汰机制呢?
- 优先选择
allkeys-lru
算法,将最近最常访问的数据留在缓存中,提升应用的访问性能。 - 有顶置数据使用
volatile-lru
算法 ,顶置数据不设置缓存过期时间,其他数据设置过期时间,基于LRU 规则进行筛选 。
在理解了Redis缓存淘汰机制后,来看看Redis作为缓存共有多少种模式呢?
缓存模式
Redis 缓存模式基于是否接收写请求,可以分成只读缓存和读写缓存:
只读缓存:只处理读操作,所有的更新操作都在数据库中,这样数据不会有丢失的风险。
- Cache Aside模式
读写缓存:读写操作都在缓存中执行,出现宕机故障,会导致数据丢失。缓存写回数据到数据库有分成两种同步和异步:
同步:访问性能偏低,其更加侧重于保证数据可靠性
- Read-Throug模式
- Write-Through模式
异步:有数据丢失风险,其侧重于提供低延迟访问
- Write-Behind模式
Cache Aside 模式
查询数据先从缓存读取数据,如果缓存中不存在,则再到数据库中读取数据,获取到数据之后更新到缓存Cache中,但更新数据操作,会先去更新数据库种的数据,然后将缓存种的数据失效。
而且Cache Aside模式会存在并发风险:执行读操作未命中缓存,然后查询数据库中取数据,数据已经查询到还没放入缓存,同时一个更新写操作让缓存失效,然后读操作再把查询到数据加载缓存,导致缓存的脏数据。
Read/Write-Throug 模式
查询数据和更新数据都直接访问缓存服务,缓存服务同步方式地将数据更新到数据库。出现脏数据的概率较低,但是就强依赖缓存,对缓存服务的稳定性有较大要求,但同步更新会导致其性能不好。
Write Behind 模式
查询数据和更新数据都直接访问缓存服务,但缓存服务使用异步方式地将数据更新到数据库(通过异步任务) 速度快,效率会非常高,但是数据的一致性比较差,还可能会有数据的丢失情况,实现逻辑也较为复杂。
生产实践
在实际项目开发中根据实际的业务场景需求来进行选择缓存模式。那了解上述后,我们的应用中为什么需要使用到redis
缓存呢?
在应用使用Redis
缓存可以提高系统性能和并发,主要体现在
- 高性能:基于内存查询,KV结构,简单逻辑运算
- 高并发:
Mysql
每秒只能支持2000左右的请求,Redis
轻松每秒1W以上。让80%以上查询走缓存,20%以下查询走数据库,能让系统吞吐量有很大的提高
虽然使用Redis缓存可以大大提升系统的性能,但是使用了缓存,会出现一些问题,比如,缓存与数据库双向不一致、缓存雪崩等,对于出现的这些问题该怎么解决呢?
常见问题
使用了缓存,会出现一些问题,主要体现在:
- 缓存与数据库双写不一致
- 缓存雪崩: Redis 缓存无法处理大量的应用请求,转移到数据库层导致数据库层的压力激增;
- 缓存穿透:访问数据不存在在Redis缓存中和数据库中,导致大量访问穿透缓存直接转移到数据库导致数据库层的压力激增;
- 缓存击穿:缓存无法处理高频热点数据,导致直接高频访问数据库导致数据库层的压力激增;
数据一致性
只读缓存(Cache Aside模式)
对于只读缓存(Cache Aside
模式),读操作都发生在缓存中,数据不一致只会发生在删改操作上(新增操作不会,因为新增只会在数据库处理),当发生删改操作时,缓存将数据中标志为无效和更新数据库。因此在更新数据库和删除缓存值的过程中,无论这两个操作的执行顺序谁先谁后,只要有一个操作失败了就会出现数据不一致的情况。
总结出,当不存在并发的情况使用重试机制(消息队列使用),当存在高并发的情况,使用延迟双删除(在第一次删除后,睡眠一定时间后,再进行删除),具体如下:
操作顺序 | 高并发 | 潜在问题 | 现象 | 应对方案 |
---|---|---|---|---|
先删除缓存,再更新数据库 | 否 | 缓存删除成功,数据库更新失败 | 读到数据库的旧值 | 重试机制(数据库更新) |
先更新数据库,再删除缓存 | 否 | 数据库更新成功,缓存删除失败 | 读到缓存的旧值 | 重试机制(缓存删除) |
先删除缓存,再更新数据库 | 是 | 缓存删除后,尚未更新数据库,有并发读请求 | 并发读请求读到数据库旧值,并更新到缓存,导致之后的读请求读到旧值 | 延迟双删 |
先更新数据库,再删除缓存 | 是 | 数据库更新成功,尚未删除缓存 | 读到缓存的旧值 | 不一致的情况短暂存在,对业务影响较小 |
延迟双删除伪代码:
redis.delKey(X) db.update(X) Thread.sleep(N) redis.delKey(X)
读写缓存(Read/Write-Throug、Write Behind模式 )
对于读写缓存,写操作都发生在缓存中,后再更新数据库,只要有一个操作失败了就会出现数据不一致的情况。
总结出,当不存在并发的情况使用重试机制(消息队列使用),当存在高并发的情况,使用分布锁。具体如下:
操作顺序 | 高 并发 | 潜在问题 | 现象 | 应对方案 |
---|---|---|---|---|
先更新缓存,再更新数据库 | 否 | 缓存更新成功,数据库更新失败 | 会从缓存中读到最新值,短期影响不大 | 重试机制(数据库更新) |
先更新数据库,再更新缓存 | 否 | 数据库更新成功,缓存更新失败 | 会从缓存读到旧值 | 重试机制(缓存删除) |
先更新数据库,再更新缓存 | 写+读并发 | 线程A先更新数据库,之后线程B读取数据,之后线程A更新缓存 | B会命中缓存,读取到旧值 | A更新缓存前,对业务有短暂影响 |
先更新缓存,再更新数据库 | 写+读并发 | 线程A先更新缓存成功,之后线程B读取数据,此时线程B命中缓存,读取到最新值后返回,之后线程A更新数据库成功 | B会命中缓存,读取到最新值 | 业务没影响 |
先更新数据库,再更新缓存 | 写+写并发 | 线程A和线程B同时更新同一条数据,更新数据库的顺序是先A后B,但更新缓存时顺序是先B后A,这会导致数据库和缓存的不一致 | 数据库和缓存的不一致 | 写操作加分布式锁 |
先更新缓存,再更新数据库 | 写+写并发 | 线程A和线程B同时更新同一条数据,更新缓存的顺序是先A后B,但是更新数据库的顺序是先B后A,这也会导致数据库和缓存的不一致 | 数据库和缓存的不一致 | 写操作加分布式锁 |
缓存雪崩
缓存雪崩,由于缓存中有大量数据同时过期失效或者缓存出现宕机,大量的应用请求无法在 Redis 缓存中进行处理,进而发送到数据库层导致数据库层的压力激增,严重的会造成数据库宕机。
对于缓存中有大量数据同时过期,导致大量请求无法得到处理, 解决方式:
数据预热,将发生大并发访问前手动触发加载缓存不同的key, 可以避免在用户请求的时候,先查询数据库
设置不同的过期时间,让缓存失效的时间点尽量均匀
双层缓存策略, 在原始缓存上加上拷贝缓存,原始缓存失效时可以访问拷贝缓存,且原始缓存失效时间设置为短期,拷贝缓存设置为长期
服务降级 , 发生缓存雪崩时,针对不同的数据采取不同的降级方案 ,比如,非核心数据直接返回预定义信息、空值或是错误信息
对于缓存出现宕机,解决方式:
- 业务系统中实现服务熔断或请求限流机制,防止大量访问导致数据库出现宕机
缓存穿透
缓存穿透,数据在数据库和缓存中都不存在,这样就导致查询数据,在缓存中找不到对应key
的value
,都要去数据库再查询一遍,然后返回空(相当于进行了两次无用的查询)。
当有大量访问请求,且其绕过缓存直接查数据库,导致数据库层的压力激增,严重的会造成数据库宕机。
对于缓存穿透,解决方式:
- 缓存空值或缺省值,当一个查询返回的数据为空时, 空结果也将进行缓存,并将它的过期时间设置比较短,下次访问直接从缓存中取值,避免了把大量请求发送给数据库处理,造成数据库出问题。
- 布隆过滤器( BloomFilter ),将所有可能查询数据
key
哈希到一个足够大的bitmap
中 , 在查询的时候先去BloomFilter
去查询key
是否存在,如果不存在就直接返回,存在再去查询缓存,缓存中没有再去查询数据库 ,从而避免了数据库层的压力激增出现宕机。
缓存击穿
缓存击穿,针对某个访问非常频繁的热点数据过期失效,导致访问无法在缓存中进行处理,进而会有导致大量的直接请求数据库,从而使得数据库层的压力激增,严重的会造成数据库宕机。
对于缓存击穿,解决方式:
- 不设置过期时间,对于访问特别频繁的热点数据,不设置过期时间。
问题总结
在大多数业务场景下,Redis缓存作为只读缓存使用。针对只读缓存来说, 优先使用先更新数据库再删除缓存的方法保证数据一致性 。
其中,缓存雪崩,缓存穿透,缓存击穿三大问题的原因和解决方式
问题 | 原因 | 解决方式 |
---|---|---|
缓存雪崩 | 大量数据同时过期失效缓存出现宕机 | 数据预热设置不同的过期时间双层缓存策略服务降级服务熔断限流机制 |
缓存穿透 | 数据在数据库和缓存中都不存在 | 缓存空值或缺省值布隆过滤器( BloomFilter ) |
缓存击穿 | 访问非常频繁的热点数据过期失效 | 对于访问特别频繁的热点数据,不设置过期时间 |
7 - CH07-高可用性
主从复制
Redis 主从复制模式可以将主节点的数据同步给从节点,从而保障当主节点不可达的情况下,从节点可以作为后备顶上来,并且可以保障数据尽量不丢失。主从复制可以保障最终一致性
从节点可以扩展主节点的读能力,一旦主节点不能支持大规模并发量的读操作,从节点可以在一定程度上分担主节点的压力。
主从复制面临的问题:
- 当主节点发生故障的时候,需要手动的将一个从节点晋升为主节点,同时通知应用方修改主节点地址并重启应用,同时需要命令其它从节点复制新的主节点,整个过程需要人工干预。
- 主节点的写能力受到单机的限制。
- 主节点的存储能力受到单机的限制。
复制过程
一般当slave
第一次启动连接master
,或者“被认为是第一次连接”,是主从采用全量复制。全量复制的执行流程如下:
slave redis
启动. 会从redis.conf
中读取master ip
和host
。- 定时任务每秒检查是否有新的
mater
需要连接,如果发现就与master
建立socket
连接。 slave
发送ping
指令到mater
。- 如果
mater
配置require pass
,slave
需要发送认证给master
。 Salve
会发送sync
命令到Master
。Master
启动一个后台进程,将Redis
中的数据快照rdb
保存到文件中。- 启动后台进程的同时,
Master
会将保存数据快照期间接收到的写命令缓存起来。 Master
完成写文件操作后,将rdb
发送给Salve
。Salve
将rdb
保存到磁盘上,然后加载rdb
到redis
内存中。- 当
Salve
完成数据快照的恢复后,aster
将这期间收集的写命令发送给Salve
端。 - 后续
Master
收集到的写命令都会通过之前建立的连接. 增量发送给salve
端。
增量复制
当slave
节点与master
全量同步后,master
节点上数据再次发生更新,就会触发增量复制。
当我们在 master
服务器增减数据的时候,就会触发 replicationFeedSalves()
函数,接下来在 Master
服务器上调用的每一个命令都会使用replicationFeedSlaves()
函数来同步到Slave
服务器。当然,在执行此函数之前master
服务器会判断用户执行的命令是否有数据更新,如果有数据更新并且slave
服务器不为空,才会执行此函数,函数主要的工作就是把用户执行的命令发送到所有的 slave
服务器,让slave
服务器执行。
流程如下图:
断点续传
断点续传或者说是断点恢复复制,也就是说 slave 因为某种原因与master
断开连接了一段时间,然后又与master
发生重连。redis2.8
以后对于这种场景进行了优化,开始加入了PSYNC
同步策略。这种策略性能一定是大于全量复制的。
- 从服务器向主服务器发送
PSYNC
命令,携带主服务器的runid
和复制偏移量; - 主服务器验证
runid
和自身runid
是否一致,如不一致,则进行全量复制; - 主服务器验证复制偏移量是否在积压缓冲区内,如不在,则进行全量复制;
- 如都验证通过,则主服务器将保持在积压区内的偏移量后的所有数据发送给从服务器,主从服务器再次回到一致状态。
PSYNC 核心参数
断点续传的几个核心参数,offset
、backlog
、runid
。这三个参数在 PSYNC 中起到了至关重要的作用,下面我们来一一介绍一下。
offet
复制偏移量 ,offset
是用来记录master
和lslave
某个时段的数据版本状态的,slave
每秒会向master
上报offset
,master
保存下来,当触发 PSYNC 时再拿来和master
的offset
数据作对比。说白了,它就是记录数据在某一时刻的快照,用来对比 master 和 slave 数据差异用的。backlog
积压缓冲区- 这个也是一个非常核心的参数,它默认大小为
1mb
,复制积压缓冲区是由Master
维护的一个固定长度的FIFO
队列,它的作用是缓存已经传播出去的命令。当Master
进行命令传播时,不仅将命令发送给所有Slave
,还会将命令写入到复制积压缓冲区里面。 - 全量复制的时候,
master
的数据更新(读写操作,主动过期删除等)会临时存放在backlog
中待全量复制完成后增量发到slave,必须为此保留足够的空间。 - 断点续传时,
backlog
会存下slave
断开连接后,master
变更的数据。当然由于它大小有限制,而且先进先出特性,所以达到缓冲大小后会弹出老数据。这样,就可以把它作为一个衡量执行sync
还是psync
的一个标准(backlog = offset : 部分同步,backlog < offset 执行全量同步)
。一般为了避免,大规模全量复制,我们都会给一个恰当的值,根据公式second*write_size_per_second
来估算:其中second
为从服务器断线后重新连接上主服务器所需的平均时间(以秒计算);而write_size_per_second
则是主服务器平均每秒产生的写命令数据量(协议格式的写命令的长度总和);
- 这个也是一个非常核心的参数,它默认大小为
- master run id,
master
唯一标示,slave
连接master
时会传runid
,master
每次重启runid
都发生变化,当slave
发现master
的runid
变化时都会触发全量复制流程。
优缺点
优点:
- 高可靠性:一方面,采用双机主备架构,能够在主库出现故障时自动进行主备切换,从库提升为主库提供服务,保证服务平稳运行;另一方面,开启数据持久化功能和配置合理的备份策略,能有效的解决数据误操作和数据异常丢失的问题;
- 读写分离策略:从节点可以扩展主库节点的读能力,有效应对大并发量的读操作。
缺点:
- 故障恢复复杂,如果没有
RedisHA
系统(需要开发),当主库节点出现故障时,需要手动将一个从节点晋升为主节点,同时需要通知业务方变更配置,并且需要让其它从库节点去复制新主库节点,整个过程需要人为干预,比较繁琐; - 主库的写能力受到单机的限制,可以考虑分片;
- 主库的存储能力受到单机的限制,可以考虑
Pika
; - 原生复制的弊端在早期的版本中也会比较突出,如:
Redis
复制中断后,Slave
会发起psync
,此时如果同步不成功,则会进行全量同步,主库执行全量备份的同时可能会造成毫秒或秒级的卡顿;又由于COW
机制,导致极端情况下的主库内存溢出,程序异常退出或宕机;主库节点生成备份文件导致服务器磁盘IO
和CPU
(压缩)资源消耗;发送数GB
大小的备份文件导致服务器出口带宽暴增,阻塞请求,建议升级到最新版本。
Redis Sentinel
当主节点出现故障时,Redis Sentinel 能自动完成故障发现和故障转移,并通知应用方,从而实现真正的高可用。RedisSentinel 是一个分布式架构,其中包含若干个 Sentinel 节点和 Redis 数据节点,每个 Sentinel 节点会对数据节点和其余 Sentinel 节点进行监控,当它发现节点不可达时,会对节点做下线标识。如果被标识的是主节点,它还会和其他的 Sentinel 节点进行协商,当大多数 Sentinel 节点都认为主节点不可达时,它们会选举一个 Sentinel 节点来完成自动故障转移的工作,同时会将这个变化实时通知给 Redis 应用方。整个过程是自动的,不需要人工干预,解决了Redis 的高可用问题。
Redis Sentinel 包含了若干个 Sentinel 节点,这样做也带来了两个好处:
- 对节点的故障判断是由多个 Sentinel 节点共同完成,这样可以有效的防止误判。
- Sentinel 节点集合是由若干个 Sentinel 节点组成的,这样即使个别 Sentinel 节点不可用,整个Sentinel节点集合依然是健壮的。
Redis Sentinel 具有以下几个功能:
- 监控:Sentinel 会定期检测 Redis 数据节点、其余 Sentinel 节点是否可到达
- 通知:Sentinel 会将故障转移的结果通知给应用方。
- 主节点故障转移:实现从节点晋升为主节点并维护后续正确的主从关系。
- 配置提供者:在 RedisSentinel 结构中,客户端在初始化的时候连接的是 Sentinel 节点集合,从中获取主节点信息。
RedisSentinel 处理流程:
- Sentinel 集群通过给定的配置文件发现 master,启动时会监控 master。通过向 master 发送 info 信息获得该服务器下面的所有从服务器。
- Sentinel 集群通过命令连接向被监视的主从服务器发送 hello 信息(每秒一次),该信息包括 Sentinel 本身的 IP、端口、id 等内容,以此来向其他 Sentinel 宣告自己的存在。
- Sentinel 集群通过订阅连接接收其他 Sentinel 发送的 hello 信息,以此来发现监视同一个主服务器的其他 Sentinel;集群之间会互相创建命令连接用于通信,因为已经有主从服务器作为发送和接收 hello 信息的中介,Sentinel 之间不会创建订阅连接。
- Sentinel 集群使用 ping 命令来检测实例的状态,如果在指定的时间内(down-after-milliseconds)没有回复或则返回错误的回复,那么该实例被判为下线。
- 当 failover 主备切换被触发后,failover 并不会马上进行,Sentinel 中的大多数 Sentinel 授权后才可以进行 failover,即进行 failover 的 Sentinel 会去获得指定 quorum 个的 Sentinel 的授权,成功后进入 ODOWN 状态。如在 5 个 Sentinel 中配置了 2 个 quorum,等到 2 个 Sentinel 认为 master 死了就执行 failover。
- Sentinel 向选为 master 的 slave 发送 SLAVEOF NO ONE 命令,选择 slave 的条件是 Sentinel 首先会根据 slaves 的优先级来进行排序,优先级越小排名越靠前。如果优先级相同,则查看复制的下标,哪个从 master 接收的复制数据多,哪个就靠前。如果优先级和下标都相同,就选择进程 ID 较小的。
- Sentinel 被授权后,它将会获得宕掉的 master 的一份最新配置版本号 (config-epoch),当 failover 执行结束以后,这个版本号将会被用于最新的配置,通过广播形式通知其它 Sentinel,其它的 Sentinel 则更新对应 master 的配置。
1 到 3 是自动发现机制:
- 以 10 秒一次的频率,向被监视的 master 发送 info 命令,根据回复获取 master 当前信息。
- 以 1 秒一次的频率,向所有 redis 服务器、包含 Sentinel 在内发送 PING 命令,通过回复判断服务器是否在线。与主节点,从节点,其余 Sentinel 都建立起连接,实现了对每个节点的监控。
- 以 2 秒一次的频率,通过向所有被监视的 master,slave 服务器发送当前 Sentinel master 信息的消息。这个定时任务可以完成以下两个工作:
- 发现新的 Sentinel 节点:通过订阅主节点的 Sentinel:hello 了解其他 Sentinel 节点信息。如果是新加入的 Sentinel 节点,将该 Sentinel 节点信息保存起来,并与该 Sentinel 节点创建连接
- Sentinel 节点之间交换主节点状态,作为后面客观下线以及领导者选举的依据
4 是检测机制,5 和 6 是 failover 机制,7 是更新配置机制
Leader 选举
其实在 sentinels 故障转移中,仍然需要一个 Leader 来调度整个过程:master 的选举以及 slave 的重配置和同步。当集群中有多个 sentinel 实例时,如何选举其中一个 sentinel 为 leader 呢?
在配置文件中 can-failover、quorum 参数,以及 is-master-down-by-addr 指令配合来完成整个过程。
- can-failover 用来表明当前 sentinel 是否可以参与 failover 过程,如果为 YES 则表明它将有能力参与 Leader 的选举,否则它将作为 Observer ,observer 参与 leader 选举投票但不能被选举;
- quorum 不仅用来控制 master ODOWN 状态确认,同时还用来选举 leader 时最小「赞同票」数;
- is-master-down-by-addr,在上文中以及提到,它可以用来检测 ip + port 的 master 是否已经处于 SDOWN 状态,不过此指令不仅能够获得 master 是否处于 SDOWN,同时它还额外的返回当前 sentinel 本地「投票选举」的 Leader 信息 (runid);
每个 sentinel 实例都持有其他的 sentinels 信息,在 Leader 选举过程中(当为 leader 的 sentinel 实例失效时,有可能 master server 并没失效,注意分开理解),sentinel 实例将从所有的 sentinels 集合中去除 can-failover = no 和状态为 SDOWN 的 sentinels,在剩余的 sentinels 列表中按照 runid 按照「字典」顺序排序后,取出 runid 最小的 sentinel 实例,并将它「投票选举」为 Leader,并在其他 sentinel 发送的 is-master-down-by-addr 指令时将推选的 runid 追加到响应中。每个 sentinel 实例都会检测 is-master-down-by-addr 的响应结果,如果「投票选举」的 leader 为自己,且状态正常的 sentinels 实例中,赞同者的自己的 sentinel 个数不小于(>=) 50% + 1,且不小与 ,那么此 sentinel 就会认为选举成功且 leader 为自己。
在 sentinel.conf 文件中,我们期望有足够多的 sentinel 实例配置 can-failovers,这样能够确保当 leader 失效时,能够选举某个 sentinel 为 leader,以便进行 failover。如果 leader 无法产生,比如较少的 sentinels 实例有效,那么 failover 过程将续。
failover 过程
在 Leader 触发 failover 之前,首先 wait 数秒(随机 0~5),以便让其他 sentinel 实例准备和调整,如果一切正常,那么 leader 就需要开始将一个 salve 提升为 master,此 slave 必须为状态良好(不能处于 SDOWN/ODOWN 状态)且权重值最低(redis.conf中)的,当 master 身份被确认后,开始 failover:
- +failover-triggered: Leader 开始进行 failover,此后紧跟着 +failover-state-wait-start ,wait 数秒。
- +failover-state-select-slave: Leader 开始查找合适的 slave
- +selected-slave: 已经找到合适的 slave
- +failover-state-sen-slaveof-noone: Leader 向 slave 发送 slaveof no one 指令,此时 slave 已经完成角色转换,此 slave 即为 master
- +failover-state-wait-promotition: 等待其他 sentinel 确认 slave
- +promoted-slave:确认成功
- +failover-state-reconf-slaves: 开始对 slaves 进行 reconfig 操作。
- +slave-reconf-sent: 向指定的 slave 发送 slaveof 指令,告知此 slave 跟随新的 master
- +slave-reconf-inprog: 此 slave 正在执行 slaveof + SYNC 过程,如过 slave 收到 +slave-reconf-sent 之后将会执行 slaveof 操作。
- +slave-reconf-done: 此 slave 同步完成,此后 leader 可以继续下一个 slave 的 reconfig 操作。循环步骤 10
- +failover-end: 故障转移结束
- +switch-master:故障转移成功后,各个 sentinel 实例开始监控新的 master。
总结
Redis-Sentinel 是 Redis 官方推荐的高可用性解决方案,Redis-sentinel 本身也是一个独立运行的进程,它能监控多个 master-slave 集群,发现 master 宕机后能进行自动切换。Sentinel 可以监视任意多个主服务器(复用),以及主服务器属下的从服务器,并在被监视的主服务器下线时,自动执行故障转移操作。
为了防止 sentinel 的单点故障,可以对 sentinel 进行集群化,创建多个 sentinel。
Redis Cluster
Redis
集群是一个分布式(distributed
)、容错(fault-tolerant
)的 Redis
实现, 集群可以使用的功能是普通单机 Redis
所能使用的功能的一个子集(subset
)。
Redis
集群中不存在中心(central
)节点或者代理(proxy
)节点, 集群的其中一个主要设计目标是达到线性可扩展性(linear scalability
)。
Redis
集群提供了一种运行 Redis
的方式,其中数据在多个 Redis
节点间自动分区。Redis
集群还在分区期间提供一定程度的可用性,即在实际情况下能够在某些节点发生故障或无法通信时继续运行。但是,如果发生较大故障(例如,大多数主站不可用时),集群会停止运行。
集群模型
所有的节点通过服务通道直接相连,各个节点之间通过二进制协议优化传输的速度和带宽。
客户端与节点之间通过 ascii 协议进行通信。
客户端与节点直连,不需要中间 Proxy 层。客户端不需要连接集群所有节点,连接集群中任何一个可用节点即可。
尽管这些节点彼此相连,功能相同,但是仍然分为两种节点:master 和 slave。
节点间传递信息
各个节点之间通过 PING-PONG 机制通信,下面是一段关于 PING-PONG 机制的会话”内容”。
节点M:PING,嘿,朋友你好吗?我是 XYZ 哈希槽的 master ,配置信息是 FF89X1JK。
节点N:PONG,我很好朋友,我也是 XYZ 哈希槽的 master ,配置信息是 FF89X1JK。
节点M:我这里有一些关于我最近收到的其他节点的信息 ,A 节点回复了我的 PING 消息,我认为 A 节点是正常的。B 没有回应我的消息,我猜它现在可能出问题了,但是我需要一些 ACK(Acknowledgement) 消息来确认。
节点N:我也想给你分享一些关于其它节点的信息,C 和 D 节点在指定的时间内回应了我, 我认为它们都是正常的,但是 B 也没有回应我,我觉得它现在可能已经挂掉了。
每个节点会向集群中的其他节点发送节点状态信息,如果某个节点挂掉停止了服务,那么会执行投票容错机制,关于这个机制,会在下面讲到。
Hash 槽(slot)
Redis 集群不使用一致的散列,而是一种不同的分片形式,其中每个键在概念上都是我们称之为散列槽的一部分,目的是使数据均匀的存储在诸多节点中。这点类似于 HashMap 中的桶(bucket)。
Redis 集群中有 16384 个散列槽,为了计算给定密钥的散列槽,Redis 对 key 采用 CRC16 算法,以下是负责将键映射到槽的算法:
slot = crc16(key) mod NUMER_SLOTS
例如,你可能有 3 个节点,其中一个集群:
- 节点 A 包含从 0 到 5500 的散列槽。
- 节点 B 包含从 5501 到 11000 的散列槽。
- 节点 C 包含 从 11001 到 16383 的散列槽。
Hash 槽可以轻松地添加和删除集群中的节点。例如,如果我想添加一个新节点 D,我需要将节点 A,B,C 中的一些散列槽移动到 D。同样,如果我想从节点 A 中删除节点 A,可以只移动由 A 服务的散列槽到 B 和 C。当节点 A 为空时,可以将它从群集中彻底删除。
- 对象保存到 Redis 之前先经过 CRC16 哈希到一个指定的 Node 上,例如 Object4 最终 Hash 到了 Node1 上。
- 每个 Node 被平均分配了一个 Slot 段,对应着 0-16384,Slot 不能重复也不能缺失,否则会导致对象重复存储或无法存储。
- Node 之间也互相监听,一旦有 Node 退出或者加入,会按照 Slot 为单位做数据的迁移。例如 Node1 如果掉线了,0-5640 这些 Slot 将会平均分摊到 Node2 和 Node3 上,由于 Node2 和 Node3 本身维护的 Slot 还会在自己身上不会被重新分配,所以迁移过程中不会影响到 5641-16384Slot 段的使用。
想扩展并发读就添加 Slaver,想扩展并发写就添加 Master,想扩容也就是添加 Master,任何一个 Slaver 或者几个 Master 挂了都不会是灾难性的故障。
简单总结下哈希 Slot 的优缺点:
缺点:每个 Node 承担着互相监听、高并发数据写入、高并发数据读出,工作任务繁重
优点:将 Redis 的写操作分摊到了多个节点上,提高写的并发能力,扩容简单。
容错
- 集群中的节点不断的
PING
其他的节点,当一个节点向另一个节点发送PING
命令, 但是目标节点未能在给定的时限内回复, 那么发送命令的节点会将目标节点标记为PFAIL
(possible failure
,可能已失效)。 - 当节点接收到其他节点发来的信息时, 它会记下那些被其他节点标记为失效的节点。 这被称为失效报告(
failure report
)。 - 如果节点已经将某个节点标记为
PFAIL
, 并且根据节点所收到的失效报告显式, 集群中的大部分其他主节点也认为那个节点进入了失效状态, 那么节点会将那个失效节点的状态标记为FAIL
。 - 一旦某个节点被标记为
FAIL
, 关于这个节点已失效的信息就会被广播到整个集群, 所有接收到这条信息的节点都会将失效节点标记为FAIL
。
简单来说, 一个节点要将另一个节点标记为失效, 必须先询问其他节点的意见, 并且得到大部分主节点的同意才行。
如果被标记为 FAIL
的是从节点, 那么当这个节点重新上线时, FAIL
标记就会被移除。 一个从节点是否处于 FAIL
状态, 决定了这个从节点在有需要时能否被提升为主节点。
如果一个主节点被打上 FAIL
标记之后, 经过了节点超时时限的四倍时间, 再加上十秒钟之后, 针对这个主节点的槽的故障转移操作仍未完成, 并且这个主节点已经重新上线的话, 那么移除对这个节点的 FAIL
标记。在不符合上面的条件后,一旦某个主节点进入 FAIL
状态, 如果这个主节点有一个或多个从节点存在, 那么其中一个从节点会被升级为新的主节点, 而其他从节点则会开始对这个新的主节点进行复制。
优缺点
优点:
- 无中心架构;
- 数据按照
slot
存储分布在多个节点,节点间数据共享,可动态调整数据分布; - 可扩展性:可线性扩展到 1000 多个节点,节点可动态添加或删除;
- 高可用性:部分节点不可用时,集群仍可用。通过增加
Slave
做standby
数据副本,能够实现故障自动failover
,节点之间通过gossip
协议交换状态信息,用投票机制完成Slave
到Master
的角色提升; - 降低运维成本,提高系统的扩展性和可用性。
缺点:
Client
实现复杂,驱动要求实现Smart Client
,缓存slots mapping
信息并及时更新,提高了开发难度,客户端的不成熟影响业务的稳定性。目前仅JedisCluster
相对成熟,异常处理部分还不完善,比如常见的“max redirect exception”
。- 节点会因为某些原因发生阻塞(阻塞时间大于
clutser-node-timeout
),被判断下线,这种failover
是没有必要的。 - 数据通过异步复制,不保证数据的强一致性。
- 多个业务使用同一套集群时,无法根据统计区分冷热数据,资源隔离性较差,容易出现相互影响的情况。
Slave
在集群中充当“冷备”,不能缓解读压力,当然可以通过SDK
的合理设计来提高Slave
资源的利用率。Key
批量操作限制,如使用mset
、mget
目前只支持具有相同slot
值的Key
执行批量操作。对于映射为不同slot
值的Key
由于Keys
不支持跨slot
查询,所以执行mset
、mget
、sunion
等操作支持不友好。Key
事务操作支持有限,只支持多key
在同一节点上的事务操作,当多个Key
分布于不同的节点上时无法使用事务功能。Key
作为数据分区的最小粒度,不能将一个很大的键值对象如hash
、list
等映射到不同的节点。- 不支持多数据库空间,单机下的
redis
可以支持到 16 个数据库,集群模式下只能使用 1 个数据库空间,即 db 0。 - 复制结构只支持一层,从节点只能复制主节点,不支持嵌套树状复制结构。
- 避免产生
hot-key
,导致主库节点成为系统的短板。 - 避免产生
big-key
,导致网卡撑爆、慢查询等。 - 重试时间应该大于
cluster-node-time
时间。 Redis Cluster
不建议使用pipeline
和multi-keys
操作,减少max redirect
产生的场景。
Codis
Codis
是一个代理中间件,如下图,Codis
在系统的位置是这样的。
Codis
分为四个部分,分别是Codis Proxy
(codis-proxy
)、Codis Dashboard
(codis-config
)、Codis Redis
(codis-server
)和ZooKeeper/Etcd
.
Codis
就是起着一个中间代理的作用,能够把所有的Redis
实例当成一个来使用,在客户端操作着SDK
的时候和操作Redis
的时候是一样的,没有差别。
因为Codis
是一个无状态的,所以可以增加多个Codis
来提升QPS
,同时也可以起着容灾的作用。
分片原理
在Codis
中,Codis
会把所有的key
分成 1024 个槽,这 1024 个槽对应着的就是Redis
的集群,这个在Codis
中是会在内存中维护着这 1024 个槽与Redis
实例的映射关系。这个槽是可以配置,可以设置成 2048 或者是 4096 个。看你的Redis
的节点数量有多少,偏多的话,可以设置槽多一些。
Codis
中key
的分配算法,先是把key
进行CRC32
后,得到一个 32 位的数字,然后再hash%1024
后得到一个余数,这个值就是这个key
对应着的槽,这槽后面对应着的就是redis
的实例。(可以思考一下,为什么 Codis 很多命令行不支持,例如 KEYS 操作)
CRC32
:CRC
本身是“冗余校验码”的意思,CRC32
则表示会产生一个32bit
(8 位十六进制数)的校验值。由于CRC32
产生校验值时源数据块的每一个bit
(位)都参与了计算,所以数据块中即使只有一位发生了变化,也会得到不同的CRC32
值。
Codis中Key的算法代码如下:
hash = crc32(command.key)
slot_index = hash % 1024
redis = slots[slot_index].redis
redis.do(command)
槽位同步
思考一个问题:如果这个 Codis 节点只在自己的内存里面维护着槽位与实例的关系,那么它的槽位信息怎么在多个实例间同步呢?
Codis 把这个工作交给了 ZooKeeper 来管理,当 Codis 的 Codis Dashbord 改变槽位的信息的时候,其他的 Codis 节点会监听到 ZooKeeper 的槽位变化,会及时同步过来。如图:
节点扩容
思考一个问题:在 Codis 中增加了 Redis 节点后,槽位的信息怎么变化,原来的 key 怎么迁移和分配?如果在扩容的时候,这个时候有新的 key 进来,Codis 的处理策略是怎么样的?
因为Codis
是一个代理中间件,所以这个当需要扩容Redis
实例的时候,可以直接增加redis
节点。在槽位分配的时候,可以手动指定Codis Dashbord
来为新增的节点来分配特定的槽位。
在Codis
中实现了自定义的扫描指令SLOTSSCAN
,可以扫描指定的slot
下的所有的key
,将这些key
迁移到新的Redis
的节点中(话外语:这个是Codis
定制化的其中一个好处)。
首先,在迁移的时候,会在原来的Redis
节点和新的Redis
里都保存着迁移的槽位信息,在迁移的过程中,如果有key
打进将要迁移或者正在迁移的旧槽位的时候,这个时候Codis
的处理机制是,先是将这个key
强制迁移到新的Redis
节点中,然后再告诉Codis
,下次如果有新的key
的打在这个槽位中的话,那么转发到新的节点。代码策略如下:
slot_index = crc32(command.key) % 1024
if slot_index in migrating_slots:
do_migrate_key(command.key) # 强制执行迁移
redis = slots[slot_index].new_redis
else:
redis = slots[slot_index].redis
redis.do(command)
自动均衡策略
面对着上面讲的迁移策略,如果有成千上万个节点新增进来,都需要我们手动去迁移吗?那岂不是得累死啊。当然,Codis
也是考虑到了这一点,所以提供了自动均衡策略。自动均衡策略是这样的,Codis
会在机器空闲的时候,观察Redis
中的实例对应着的slot
数,如果不平衡的话就会自动进行迁移。
Codis 的牺牲
因为Codis
在Redis
的基础上的改造,所以在Codis
上是不支持事务的,同时也会有一些命令行不支持,在官方的文档上有(Codis
不支持的命令)
官方的建议是单个集合的总容量不要超过 1M,否则在迁移的时候会有卡顿感。在Codis
中,增加了proxy
来当中转层,所以在网络开销上,是会比单个的Redis
节点的性能有所下降的,所以这部分会有些的性能消耗。可以增加proxy
的数量来避免掉这块的性能损耗。
MGET 的过程
思考一个问题:如果熟悉 Redis 中的 MGET、MSET 和 MSETNX 命令的话,就会知道这三个命令都是原子性的命令。但是,为什么 Codis 支持 MGET 和 MSET,却不支持 MSETNX 命令呢?
原因如下:
在Codis
中的MGET
命令的原理是这样的,先是在Redis
中的各个实例里获取到符合的key
,然后再汇总到Codis
中,如果是MSETNX
的话,因为key
可能存在在多个Redis
的实例中,如果某个实例的设值成功,而另一个实例的设值不成功,从本质上讲这是不成功的,但是分布在多个实例中的Redis
是没有回滚机制的,所以会产生脏数据,所以 MSETNX 就是不能支持了。
8 - CH08-百亿量级
需求背景
该应用场景为 DMP 缓存存储需求,DMP 需要管理非常多的第三方 id 数据,其中包括各媒体 cookie 与自身 cookie(以下统称 supperid)的 mapping 关系,还包括了 supperid 的人口标签、移动端id(主要是idfa和imei)的人口标签,以及一些黑名单id、ip等数据。
在 hdfs 的帮助下离线存储千亿记录并不困难,然而 DMP 还需要提供毫秒级的实时查询。由于 cookie 这种 id 本身具有不稳定性,所以很多的真实用户的浏览行为会导致大量的新 cookie 生成,只有及时同步 mapping 的数据才能命中 DMP 的人口标签,无法通过预热来获取较高的命中,这就跟缓存存储带来了极大的挑战。
经过实际测试,对于上述数据,常规存储超过五十亿的kv记录就需要1T多的内存,如果需要做高可用多副本那带来的消耗是巨大的,另外kv的长短不齐也会带来很多内存碎片,这就需要超大规模的存储方案来解决上述问题。
缓存内容
人⼝标签主要是 cookie、imei、idfa 以及其对应的 gender(性别)、age(年龄段)、geo(地域)等;mapping 关系主要是媒体 cookie 对 supperid 的映射。以下是数据存储⽰示例:
1、PC端的ID:
媒体编号-媒体 cookie=>supperid
supperid => { age=>年龄段编码,gender=>性别编码,geo=>地理位置编码 }
2、Device 端的 ID:
imei or idfa => { age=>年龄段编码,gender=>性别编码,geo=>地理位置编码 }
显然 PC 数据需要存储两种 key=>value 还有 key=>hashmap,⽽ Device 数据需要存储一种 key=>hashmap 即可。
数据特点
短 key 短 value:
其中superid为21位数字:比如1605242015141689522;
imei为小写md5:比如2d131005dc0f37d362a5d97094103633;
idfa为大写带”-”md5:比如:51DFFC83-9541-4411-FA4F-356927E39D04;
- 媒体自身的 cookie 长短不一;
- 需要为全量数据提供服务,supperid 是百亿级、媒体映射是千亿级、移动 id 是几十亿级;
- 每天有十亿级别的 mapping 关系产生;
- 对于较大时间窗口内可以预判热数据(有一些存留的稳定 cookie);
- 对于当前 mapping 数据无法预判热数据,有很多是新生成的 cookie;
技术挑战
- 长短不一容易造成内存碎片;
- 由于指针大量存在,内存膨胀率比较高,一般在7倍,纯内存存储通病;
- 虽然可以通过cookie的行为预判其热度,但每天新生成的id依然很多(百分比比较敏感,暂不透露);
- 由于服务要求在公网环境(国内公网延迟60ms以下)下100ms以内,所以原则上当天新更新的 mapping 和人口标签需要全部 in memory,而不会让请求落到后端的冷数据;
- 业务方面,所有数据原则上至少保留35天甚至更久;
- 内存至今也比较昂贵,百亿级Key乃至千亿级存储方案势在必行!
解决方案
淘汰策略
存储吃紧的一个重要原因在于每天会有很多新数据入库,所以及时清理数据尤为重要。主要方法就是发现和保留热数据淘汰冷数据。
网民的量级远远达不到几十亿的规模,id 有一定的生命周期,会不断的变化。所以很大程度上我们存储的id实际上是无效的。而查询其实前端的逻辑就是广告曝光,跟人的行为有关,所以一个 id 在某个时间窗口的(可能是一个campaign,半个月、几个月)访问行为上会有一定的重复性。
数据初始化之前,我们先利用 HBase 将日志的id聚合去重,划定TTL的范围,一般是35天,这样可以砍掉近35天未出现的id。另外在 Redis 中设置过期时间是35天,当有访问并命中时,对 key 进行续命,延长过期时间,未在 35 天出现的自然淘汰。这样可以针对稳定 cookie 或 id 有效,实际证明,续命的方法对 idfa 和 imei 比较实用,长期积累可达到非常理想的命中。
减少膨胀
Hash 表空间大小和 Key 的个数决定了冲突率(或者用负载因子衡量),再合理的范围内,key 越多自然hash表空间越大,消耗的内存自然也会很大。再加上大量指针本身是长整型,所以内存存储的膨胀十分可观。先来谈谈如何把 key 的个数减少。
大家先来了解一种存储结构。我们期望将 key1=>value1 存储在 redis 中,那么可以按照如下过程去存储。先用固定长度的随机散列md5(key)值作为 redis 的 key,我们称之为 BucketId,而将key1=>value1存储在hashmap结构中,这样在查询的时候就可以让client按照上面的过程计算出散列,从而查询到 value1。
过程变化简单描述为:get(key1) -> hget(md5(key1), key1) 从而得到 value1。
如果我们通过预先计算,让很多key可以在BucketId空间里碰撞,那么可以认为一个BucketId下面挂了多个key。比如平均每个BucketId下面挂10个key,那么理论上我们将会减少超过90%的redis key的个数。
具体实现起来有一些麻烦,而且用这个方法之前你要想好容量规模。我们通常使用的 md5 是 32 位的 hexString(16进制字符),它的空间是 128bit,这个量级太大了,我们需要存储的是百亿级,大约是 33 bit(2的33次方),所以我们需要有一种机制计算出合适位数的散列,而且为了节约内存,我们需要利用全部字符类型(ASCII码在0~127之间)来填充,而不用 HexString,这样 Key 的长度可以缩短到一半。
下面是具体的实现方式:
public static byte [] getBucketId(byte [] key, Integer bit) {
MessageDigest mdInst = MessageDigest.getInstance("MD5");
mdInst.update(key);
byte [] md = mdInst.digest();
byte [] r = new byte[(bit-1)/7 + 1];// 因为一个字节中只有7位能够表示成单字符,ascii码是7位
int a = (int) Math.pow(2, bit%7)-2;
md[r.length-1] = (byte) (md[r.length-1] & a);
System.arraycopy(md, 0, r, 0, r.length);
for(int i=0;i<r.length;i++) {
if(r[i]<0) r[i] &= 127;
}
return r;
}
参数 bit 决定了最终 BucketId 空间的大小,空间大小集合是2的整数幂次的离散值。这里解释一下为何一个字节中只有7位可用,是因为 redis 存储 key 时需要是 ASCII(0~127),而不是 byte array。如果规划百亿级存储,计划每个桶分担10个kv,那么我们只需 2^30=1073741824 的桶个数即可,也就是最终 key 的个数。
减少碎片
碎片主要原因在于内存无法对齐、过期删除后,内存无法重新分配。通过上文描述的方式,我们可以将人口标签和mapping数据按照上面的方式去存储,这样的好处就是redis key是等长的。另外对于hashmap中的key我们也做了相关优化,截取cookie或者deviceid的后六位作为key,这样也可以保证内存对齐,理论上会有冲突的可能性,但在同一个桶内后缀相同的概率极低(试想id几乎是随机的字符串,随意10个由较长字符组成的id后缀相同的概率*桶样本数=发生冲突的期望值«0.05,也就是说出现一个冲突样本则是极小概率事件,而且这个概率可以通过调整后缀保留长度控制期望值)。而 value 只存储 age、gender、geo 的编码,用三个字节去存储。
另外提一下,减少碎片还有个很 low 但是有效的方法,将 slave 重启,然后强制的 failover 切换主从,这样相当于给master整理的内存的碎片。
推荐 Google-tcmalloc, facebook-jemalloc 内存分配,可以在 value 不大时减少内存碎片和内存消耗。有人测过大 value 情况下反而libc更节约。
9 - CH09-实现搜索
场景
下面以一个例子开始,这是某购物网站的搜索条件,如果让你实现这样的一个搜索接口,你会如何实现?
从上图中可以看出,搜索总共分为6大类,每大类中又分了各个子类。这中间,各大类条件之间是取的交集,各子类中有单选、多选、以及自定义的情况,最终输出符合条件的结果集。
好了,既然需求很明确了,我们就开始来实现。
实现一
以下面这段代码为例:
select ... from table_1
left join table_2
left join table_3
left join (select ... from table_x where ...) tmp_1
...
where ...
order by ...
limit m,n
代码在测试环境跑了一把,结果好像都匹配上了,于是准备上预发。这一上预发,问题就开始暴露出来。预发为了尽可能的逼真线上环境,所以数据量自然而然要比测试大的多。所以这么一个复杂的 SQL,它的执行效率可想而知。
实现二
通过了explain
关键字进行SQL性能分析,对该加索引的地方都加上了索引。同时将一条复杂SQL拆分成多条SQL,计算结果在程序内存中进行计算。
$result_1 = query('select ... from table_1 where ...');
$result_2 = query('select ... from table_2 where ...');
$result_3 = query('select ... from table_3 where ...');
...
$result = array_intersect($result_1, $result_2, $result_3, ...);
这种方案从性能上明显比第一种要好很多,但查询速度不够快。每次查询都会向数据库查询多次,而且有些历史原因,部分条件是做不到单表查询的,所以查询等待的时间是避免不了的。
实现三
从上面的方案中看到了优化的空间。方案二在思路上是没问题的,将复杂条件拆分,计算各个子维度的结果集,最后将所有的子结果集进行一个汇总合并,得到最终想要的结果。
如果事先将各个子维度的结果集给缓存起来,这要查询的时候直接去取想要的子集,而不用每次去查库计算。
这里采用 Redis 来存储缓存数据,用它的主要原因是,它提供了多种数据结构,并且在 Redis 中进行集合的交并集操作是一件很容易的事情。
具体方案,如图所示:
这里每个条件都事先将计算好的结果集ID存入对应的key中,选用的数据结构是集合(Set)。查询操作包括:
- 子类单选:直接根据条件 key,获取对应结果集;
- 子类多选:根据多个条件 Key,进行并集操作,获取对应结果集;
- 最终结果:将获取的所有子类结果集进行交集操作,得到最终结果;
这其实就是所谓的反向索引。
这里会发现,漏了一个价格的条件。从需求中可知,价格条件是个区间,并且是无穷举的。所以上述的这种穷举条件的 Key-Value 方式是做不到的。这里我们采用 Redis 的另一种数据结构进行实现,有序集合(Sorted Set):
将所有商品加入 Key 为价格的有序集合中,值为商品ID,每个值对应的分数为商品价格的数值。这样在 Redis 的有序集合中就可以通过ZRANGEBYSCORE
命令,根据分数(价格)区间,获取相应结果集。
至此,方案三的优化已全部结束,将数据的查询与计算通过缓存的手段,进行了分离。在每次查找时,只需要简单的查找 Redis 几次就能得出结果。查询速度上符合了验收的要求。
扩展
分页
这里你或许发现了一个严重的功能缺陷,列表查询怎么能没有分页。是的,我们马上来看 Redis 是如何实现分页的。
分页主要涉及排序,这里简单起见,就以创建时间为例。
如图所示:
图中蓝色部分是以创建时间为分值的商品有序集合,蓝色下方的结果集即为条件计算而得的结果,通过ZINTERSTORE
命令,赋结果集权重为0,商品时间结果为1,取交集而得的结果集赋予创建时间分值的新有序集合。对新结果集的操作即能得到分页所需的各个数据:
- 页面总数为:
ZCOUNT
命令 - 当前页内容:
ZRANGE
命令 - 若以倒序排列:
ZREVRANGE
命令
更新
关于索引数据更新的问题,有两种方式来进行。一种是通过商品数据的修改,来即时触发更新操作,一种是通过定时脚本来进行批量更新。这里要注意的是,关于索引内容的更新,如果暴力的删除 Key,再重新设置 Key。因为 Redis 中两个操作不会是原子性进行的,所以中间可能存在空白间隙,建议采用仅移除集合中失效元素,添加新元素的方式进行。
性能优化
Redis 是内存级操作,所以单次的查询会很快。但是如果我们的实现中会进行多次的 Redis 操作,Redis 的多次连接时间可能是不必要时间消耗。通过使用MULTI
命令,开启一个事务,将 Redis 的多次操作放在一个事务中,最后通过EXEC
来进行原子性执行(注意:这里所谓的事务,只是将多个操作在一次连接中执行,如果执行过程中遇到失败,是不会回滚的)。
10 - CH10-常见问题
操作问题
过期时间意外丢失?
SET 除了可以设置 key-value 之外,还可以设置 key 的过期时间,就像下面这样:
127.0.0.1:6379> SET testkey val1 EX 60
OK
127.0.0.1:6379> TTL testkey
(integer) 59
此时如果你想修改 key 的值,但只是单纯地使用 SET 命令,而没有加上「过期时间」的参数,那这个 key 的过期时间将会被「擦除」。
127.0.0.1:6379> SET testkey val2
OK
127.0.0.1:6379> TTL testkey // key永远不过期了!
(integer) -1
看到了么?testkey 变成永远不过期了!
SET 命令如果不设置过期时间,那么 Redis 会自动「擦除」这个 key 的过期时间。
DEL 竟然也会阻塞 Redis?
删除一个 key,你肯定会用 DEL 命令,不知道你没有思考过它的时间复杂度是多少?
O(1)?其实不一定。
如果你有认真阅读 Redis 的官方文档,就会发现:删除一个 key 的耗时,与这个 key 的类型有关。
Redis 官方文档在介绍 DEL 命令时,是这样描述的:
- key 是 String 类型,DEL 时间复杂度是 O(1)
- key 是 List/Hash/Set/ZSet 类型,DEL 时间复杂度是 O(M),M 为元素数量
也就是说,如果你要删除的是一个非 String 类型的 key,这个 key 的元素越多,那么在执行 DEL 时耗时就越久!
原因在于,删除这种 key 时,Redis 需要依次释放每个元素的内存,元素越多,这个过程就会越耗时。
而这么长的操作耗时,势必会阻塞整个 Redis 实例,影响 Redis 的性能。
所以,当你在删除 List/Hash/Set/ZSet 类型的 key 时,一定要格外注意,不能无脑执行 DEL,而是应该用以下方式删除:
- 查询元素数量:执行 LLEN/HLEN/SCARD/ZCARD 命令
- 判断元素数量:如果元素数量较少,可直接执行 DEL 删除,否则分批删除
- 分批删除:执行 LRANGE/HSCAN/SSCAN/ZSCAN + LPOP/RPOP/HDEL/SREM/ZREM 删除
我们再来分析下,删除一个 String 类型的 key 会不会有这种问题?其实这也不一定!如果一个 Key 存储了 500MB 的数据也会出现一样的问题。
这是因为,Redis 释放这么大的内存给操作系统,也是需要时间的,所以操作耗时也会变长。所以,对于 String 类型来说,你最好也不要存储过大的数据,否则在删除它时,也会有性能问题。
此时,你可能会想:Redis 4.0 不是推出了 lazy-free 机制么?打开这个机制,释放内存的操作会放到后台线程中执行,那是不是就不会阻塞主线程了?
即使 Redis 打开了 lazy-free,在删除一个 String 类型的 bigkey 时,它仍旧是在主线程中处理,而不是放到后台线程中执行。所以,依旧有阻塞 Redis 的风险!
RANDOMKEY 竟然也会阻塞 Redis?
如果你想随机查看 Redis 中的一个 key,通常会使用 RANDOMKEY 这个命令。这个命令会从 Redis 中「随机」取出一个 key。
如果你对 Redis 的过期策略有所了解,应该知道 Redis 清理过期 key,是采用定时清理 + 懒惰清理 2 种方式结合来做的。整个流程就是这样的:
- master 随机取出一个 key,判断是否已过期
- 如果 key 已过期,删除它,继续随机取 key
- 以此循环往复,直到找到一个不过期的 key,返回
但这里就有一个问题了:如果此时 Redis 中,有大量 key 已经过期,但还未来得及被清理掉,那这个循环就会持续很久才能结束,而且,这个耗时都花费在了清理过期 key + 寻找不过期 key 上。
导致的结果就是,RANDOMKEY 执行耗时变长,影响 Redis 性能。
以上流程,其实是在 master 上执行的。如果在 slave 上执行 RANDOMEKY,那么问题会更严重!
主要原因就在于,slave 自己是不会清理过期 key。那 slave 什么时候删除过期 key 呢?
其实,当一个 key 要过期时,master 会先清理删除它,之后 master 向 slave 发送一个 DEL 命令,告知 slave 也删除这个 key,以此达到主从库的数据一致性。
还是同样的场景:Redis 中存在大量已过期,但还未被清理的 key,那在 slave 上执行 RANDOMKEY 时,就会发生以下问题:
- slave 随机取出一个 key,判断是否已过期
- key 已过期,但 slave 不会删除它,而是继续随机寻找不过期的 key
- 由于大量 key 都已过期,那 slave 就会寻找不到符合条件的 key,此时就会陷入「死循环」!
也就是说,在 slave 上执行 RANDOMKEY,有可能会造成整个 Redis 实例卡死!
修复的解决方案是,在 slave 上执行 RANDOMKEY 时,会先判断整个实例所有 key 是否都设置了过期时间,如果是,为了避免长时间找不到符合条件的 key,slave 最多只会在哈希表中寻找 100 次,无论是否能找到,都会退出循环。这个方案就是增加上了一个最大重试次数,这样一来,就避免了陷入死循环。
虽然这个方案可以避免了 slave 陷入死循环、卡死整个实例的问题,但是,在 master 上执行这个命令时,依旧有概率导致耗时变长。
所以,你在使用 RANDOMKEY 时,如果发现 Redis 发生了「抖动」,很有可能是因为这个原因导致的!
O(1) 复杂度的 SETBIT,竟然会导致 Redis OOM?
在使用 Redis 的 String 类型时,除了直接写入一个字符串之外,还可以把它当做 bitmap 来用。
具体来讲就是,我们可以把一个 String 类型的 key,拆分成一个个 bit 来操作,就像下面这样:
127.0.0.1:6379> SETBIT testkey 10 1
(integer) 1
127.0.0.1:6379> GETBIT testkey 10
(integer) 1
其中,操作的每一个 bit 位叫做 offset。如果这个 key 不存在,或者 key 的内存使用很小,此时你要操作的 offset 非常大,那么 Redis 就需要分配「更大的内存空间」,这个操作耗时就会变长,影响性能。
所以,当你在使用 SETBIT 时,也一定要注意 offset 的大小,操作过大的 offset 也会引发 Redis 卡顿。
这种类型的 key,也是典型的 bigkey,除了分配内存影响性能之外,在删除它时,耗时同样也会变长。
执行 MONITOR 也会导致 Redis OOM?
当你在执行 MONITOR 命令时,Redis 会把每一条命令写到客户端的「输出缓冲区」中,然后客户端从这个缓冲区读取服务端返回的结果。
但是,如果你的 Redis QPS 很高,这将会导致这个输出缓冲区内存持续增长,占用 Redis 大量的内存资源,如果恰好你的机器的内存资源不足,那 Redis 实例就会面临被 OOM 的风险。
所以,你需要谨慎使用 MONITOR,尤其在 QPS 很高的情况下。
持久化问题
Redis 的数据持久化,分为 RDB 和 AOF 两种方式。其中,RDB 是数据快照,而 AOF 会记录每一个写命令到日志文件中。
master 宕机,slave 数据也丢失了?
如果你的 Redis 采用如下模式部署,就会发生数据丢失的问题:
- master-slave + 哨兵部署实例
- master 没有开启数据持久化功能
- Redis 进程使用 supervisor 管理,并配置为「进程宕机,自动重启」
如果此时 master 宕机,就会导致下面的问题:
- master 宕机,哨兵还未发起切换,此时 master 进程立即被 supervisor 自动拉起
- 但 master 没有开启任何数据持久化,启动后是一个「空」实例
- 此时 slave 为了与 master 保持一致,它会自动「清空」实例中的所有数据,slave 也变成了一个「空」实例
看到了么?在这个场景下,master / slave 的数据就全部丢失了。
这时,业务应用在访问 Redis 时,发现缓存中没有任何数据,就会把请求全部打到后端数据库上,这还会进一步引发「缓存雪崩」,对业务影响非常大。
所以,你一定要避免这种情况发生,我给你的建议是:
- Redis 实例不使用进程管理工具自动拉起
- master 宕机后,让哨兵发起切换,把 slave 提升为 master
- 切换完成后,再重启 master,让其退化成 slave
AOF everysec 真的不会阻塞主线程吗?
当 Redis 开启 AOF 时,需要配置 AOF 的刷盘策略。基于性能和数据安全的平衡,你肯定会采用 appendfsync everysec 这种方案。
这种方案的工作模式为,Redis 的后台线程每间隔 1 秒,就把 AOF page cache 的数据,刷到磁盘(fsync)上。
其优势在于,把 AOF 刷盘的耗时操作,放到了后台线程中去执行,避免了对主线程的影响。
但真的不会影响主线程吗?答案是否定的。
其实存在这样一种场景:Redis 后台线程在执行 AOF page cache 刷盘(fysnc)时,如果此时磁盘 IO 负载过高,那么调用 fsync 就会被阻塞住。
此时,主线程仍然接收写请求进来,那么此时的主线程会先判断,上一次后台线程是否已刷盘成功。
如何判断呢?
后台线程在刷盘成功后,都会记录刷盘的时间。
主线程会根据这个时间来判断,距离上一次刷盘已经过去多久了。整个流程是这样的:
- 主线程在写 AOF page cache(write系统调用)前,先检查后台 fsync 是否已完成?
- fsync 已完成,主线程直接写 AOF page cache
- fsync 未完成,则检查距离上次 fsync 过去多久?
- 如果距离上次 fysnc 成功在 2 秒内,那么主线程会直接返回,不写 AOF page cache
- 如果距离上次 fysnc 成功超过了 2 秒,那主线程会强制写 AOF page cache(write系统调用)
- 由于磁盘 IO 负载过高,此时,后台线程 fynsc 会发生阻塞,那主线程在写 AOF page cache 时,也会发生阻塞等待(操作同一个 fd,fsync 和 write 是互斥的,一方必须等另一方成功才可以继续执行,否则阻塞等待)
通过分析我们可以发现,即使你配置的 AOF 刷盘策略是 appendfsync everysec,也依旧会有阻塞主线程的风险。
其实,产生这个问题的重点在于,磁盘 IO 负载过高导致 fynsc 阻塞,进而导致主线程写 AOF page cache 也发生阻塞。
所以,你一定要保证磁盘有充足的 IO 资源,避免这个问题。
AOF everysec 真的只会丢失 1 秒数据?
如上所述,这里我们需要重点关注上面的步骤 4。
也就是:主线程在写 AOF page cache 时,会先判断上一次 fsync 成功的时间,如果距离上次 fysnc 成功在 2 秒内,那么主线程会直接返回,不再写 AOF page cache。
这就意味着,后台线程在执行 fsync 刷盘时,主线程最多等待 2 秒不会写 AOF page cache。
如果此时 Redis 发生了宕机,那么,AOF 文件中丢失是 2 秒的数据,而不是 1 秒!
我们继续分析,Redis 主线程为什么要等待 2 秒不写 AOF page cache 呢?
其实,Redis AOF 配置为 appendfsync everysec 时,正常来讲,后台线程每隔 1 秒执行一次 fsync 刷盘,如果磁盘资源充足,是不会被阻塞住的。
也就是说,Redis 主线程其实根本不用关心后台线程是否刷盘成功,只要无脑写 AOF page cache 即可。
但是,Redis 作者考虑到,如果此时的磁盘 IO 资源比较紧张,那么后台线程 fsync 就有概率发生阻塞风险。
所以,Redis 作者在主线程写 AOF page cache 之前,先检查一下距离上一次 fsync 成功的时间,如果大于 1 秒没有成功,那么主线程此时就能知道,fsync 可能阻塞了。
所以,主线程会等待 2 秒不写 AOF page cache,其目的在于:
- 降低主线程阻塞的风险(如果无脑写 AOF page cache,主线程则会立即阻塞住)
- 如果 fsync 阻塞,主线程就会给后台线程留出 1 秒的时间,等待 fsync 成功
但代价就是,如果此时发生宕机,AOF 丢失的就是 2 秒的数据,而不是 1 秒。
这个方案应该是 Redis 作者对性能和数据安全性的进一步权衡。
无论如何,这里你只需要知道的是,即使 AOF 配置为每秒刷盘,在发生上述极端情况时,AOF 丢失的数据其实是 2 秒。
RDB 和 AOF rewrite 时,Redis 发生 OOM?
最后,我们来看一下,当 Redis 在执行 RDB 快照和 AOF rewrite 时,会发生的问题。
Redis 在做 RDB 快照和 AOF rewrite 时,会采用创建子进程的方式,把实例中的数据持久化到磁盘上。
创建子进程,会调用操作系统的 fork 函数。
fork 执行完成后,父进程和子进程会同时共享同一份内存数据。
但此时的主进程依旧是可以接收写请求的,而进来的写请求,会采用 Copy On Write(写时复制)的方式操作内存数据。
也就是说,主进程一旦有数据需要修改,Redis 并不会直接修改现有内存中的数据,而是先将这块内存数据拷贝出来,再修改这块新内存的数据,这就是所谓的「写时复制」。
写时复制你也可以理解成,谁需要发生写操作,谁就先拷贝,再修改。
你应该发现了,如果父进程要修改一个 key,就需要拷贝原有的内存数据,到新内存中,这个过程涉及到了「新内存」的申请。
如果你的业务特点是「写多读少」,而且 OPS 非常高,那在 RDB 和 AOF rewrite 期间,就会产生大量的内存拷贝工作。
这会有什么问题呢?
因为写请求很多,这会导致 Redis 父进程会申请非常多的内存。在这期间,修改 key 的范围越广,新内存的申请就越多。
如果你的机器内存资源不足,这就会导致 Redis 面临被 OOM 的风险!
这就是你会从 DBA 同学那里听到的,要给 Redis 机器预留内存的原因。
其目的就是避免在 RDB 和 AOF rewrite 期间,防止 Redis OOM。
以上这些,就是「数据持久化」会遇到的坑,你踩到过几个?
下面我们再来看「主从复制」会存在哪些问题。
主从复制问题
Redis 为了保证高可用,提供了主从复制的方式,这样就可以保证 Redis 有多个「副本」,当主库宕机后,我们依旧有从库可以使用。
主从复制会丢数据吗?
首先,你需要知道,Redis 的主从复制是采用「异步」的方式进行的。
这就意味着,如果 master 突然宕机,可能存在有部分数据还未同步到 slave 的情况发生。
这会导致什么问题呢?
如果你把 Redis 当做纯缓存来使用,那对业务来说没有什么影响。
master 未同步到 slave 的数据,业务应用可以从后端数据库中重新查询到。
但是,对于把 Redis 当做数据库,或是当做分布式锁来使用的业务,有可能因为异步复制的问题,导致数据丢失 / 锁丢失。
关于 Redis 分布式锁可靠性的更多细节,这里先不展开,后面会单独写一篇文章详细剖析这个知识点。这里你只需要先知道,Redis 主从复制是有概率发生数据丢失的。
同样命令查询一个 key,主从库却返回不同的结果?
不知道你是否思考过这样一个问题:如果一个 key 已过期,但这个 key 还未被 master 清理,此时在 slave 上查询这个 key,会返回什么结果呢?
- slave 正常返回 key 的值
- slave 返回 NULL
你认为是哪一种?可以思考一下。
答案是:不一定。
嗯?为什么会不一定?
其实,返回什么结果,这要取决于以下 3 个因素:
- Redis 的版本
- 具体执行的命令
- 机器时钟
先来看 Redis 版本。
如果你使用的是 Redis 3.2 以下版本,只要这个 key 还未被 master 清理,那么,在 slave 上查询这个 key,它会永远返回 value 给你。
也就是说,即使这个 key 已过期,在 slave 上依旧可以查询到这个 key。
// Redis 2.8 版本 在 slave 上执行
127.0.0.1:6479> TTL testkey
(integer) -2 // 已过期
127.0.0.1:6479> GET testkey
"testval" // 还能查询到!
但如果此时在 master 上查询这个 key,发现已经过期,就会把它清理掉,然后返回 NULL。
// Redis 2.8 版本 在 master 上执行
127.0.0.1:6379> TTL testkey
(integer) -2
127.0.0.1:6379> GET testkey
(nil)
发现了吗?在 master 和 slave 上查询同一个 key,结果竟然不一样?
其实,slave 应该要与 master 保持一致,key 已过期,就应该给客户端返回 NULL,而不是还正常返回 key 的值。
为什么会发生这种情况?
其实这是 Redis 的一个 Bug:3.2 以下版本的 Redis,在 slave 上查询一个 key 时,并不会判断这个 key 是否已过期,而是直接无脑返回给客户端结果。
这个 Bug 在 3.2 版本进行了修复,但是,它修复得「不够彻底」。
什么叫修复得「不够彻底」?
这就要结合前面提到的,第 2 个影响因素「具体执行的命令」来解释了。
Redis 3.2 虽然修复了这个 Bug,但却遗漏了一个命令:EXISTS。
也就是说,一个 key 已过期,在 slave 直接查询它的数据,例如执行 GET/LRANGE/HGETALL/SMEMBERS/ZRANGE 这类命令时,slave 会返回 NULL。
但如果执行的是 EXISTS,slave 依旧会返回:key 还存在。
// Redis 3.2 版本 在 slave 上执行
127.0.0.1:6479> GET testkey
(nil) // key 已逻辑过期
127.0.0.1:6479> EXISTS testkey
(integer) 1 // 还存在!
原因在于,EXISTS 与查询数据的命令,使用的不是同一个方法。
Redis 作者只在查询数据时增加了过期时间的校验,但 EXISTS 命令依旧没有这么做。
直到 Redis 4.0.11 这个版本,Redis 才真正把这个遗漏的 Bug 完全修复。
如果你使用的是这个之上的版本,那在 slave 上执行数据查询或 EXISTS,对于已过期的 key,就都会返回「不存在」了。
这里我们先小结一下,slave 查询过期 key,经历了 3 个阶段:
- 3.2 以下版本,key 过期未被清理,无论哪个命令,查询 slave,均正常返回 value
- 3.2 - 4.0.11 版本,查询数据返回 NULL,但 EXISTS 依旧返回 true
- 4.0.11 以上版本,所有命令均已修复,过期 key 在 slave 上查询,均返回「不存在」
最后,我们来看影响查询结果的第 3 个因素:「机器时钟」。
假设我们已规避了上面提到的版本 Bug,例如,我们使用 Redis 5.0 版本,在 slave 查询一个 key,还会和 master 结果不同吗?
答案是,还是有可能会的。
这就与 master / slave 的机器时钟有关了。
无论是 master 还是 slave,在判断一个 key 是否过期时,都是基于「本机时钟」来判断的。
如果 slave 的机器时钟比 master 走得「快」,那就会导致,即使这个 key 还未过期,但以 slave 上视角来看,这个 key 其实已经过期了,那客户端在 slave 上查询时,就会返回 NULL。
是不是很有意思?一个小小的过期 key,竟然藏匿这么多猫腻。
如果你也遇到了类似的情况,就可以通过上述步骤进行排查,确认是否踩到了这个坑。
主从切换会导致缓存雪崩?
这个问题是上一个问题的延伸。
我们假设,slave 的机器时钟比 master 走得「快」,而且是「快很多」。
此时,从 slave 角度来看,Redis 中的数据存在「大量过期」。
如果此时操作「主从切换」,把 slave 提升为新的 master。
它成为 master 后,就会开始大量清理过期 key,此时就会导致以下结果:
- master 大量清理过期 key,主线程发生阻塞,无法及时处理客户端请求
- Redis 中数据大量过期,引发缓存雪崩
你看,当 master / slave 机器时钟严重不一致时,对业务的影响非常大!
所以,如果你是 DBA 运维,一定要保证主从库的机器时钟一致性,避免发生这些问题。
master / slave 大量数据不一致?
还有一种场景,会导致 master / slave 的数据存在大量不一致。
这就涉及到 Redis 的 maxmemory 配置了。
Redis 的 maxmemory 可以控制整个实例的内存使用上限,超过这个上限,并且配置了淘汰策略,那么实例就开始淘汰数据。
但这里有个问题:假设 master / slave 配置的 maxmemory 不一样,那此时就会发生数据不一致。
例如,master 配置的 maxmemory 为 5G,而 slave 的 maxmemory 为 3G,当 Redis 中的数据超过 3G 时,slave 就会「提前」开始淘汰数据,此时主从库数据发生不一致。
另外,尽管 master / slave 设置的 maxmemory 相同,如果你要调整它们的上限,也要格外注意,否则也会导致 slave 淘汰数据:
- 调大 maxmemory 时,先调整 slave,再调整 master
- 调小 maxmemory 时,先调整 master,再调整 slave
以此方式操作,就避免了 slave 提前超过 maxmemory 的问题。
其实,你可以思考一下,发生这些问题的关键在哪?
其根本原因在于,slave 超过 maxmemory 后,会「自行」淘汰数据。
如果不让 slave 自己淘汰数据,那这些问题是不是都可以规避了?
没错。
针对这个问题,Redis 官方应该也收到了很多用户的反馈。在 Redis 5.0 版本,官方终于把这个问题彻底解决了!
Redis 5.0 增加了一个配置项:replica-ignore-maxmemory,默认 yes。
这个参数表示,尽管 slave 内存超过了 maxmemory,也不会自行淘汰数据了!
这样一来,slave 永远会向 master 看齐,只会老老实实地复制 master 发送过来的数据,不会自己再搞「小动作」。
至此,master / slave 的数据就可以保证完全一致了!
如果你使用的恰好是 5.0 版本,就不用担心这个问题了。
slave 竟然会有内存泄露问题?
是的,你没看错。
这是怎么发生的?我们具体来看一下。
当你在使用 Redis 时,符合以下场景,就会触发 slave 内存泄露:
- Redis 使用的是 4.0 以下版本
- slave 配置项为 read-only=no(从库可写)
- 向 slave 写入了有过期时间的 key
这时的 slave 就会发生内存泄露:slave 中的 key,即使到了过期时间,也不会自动清理。
如果你不主动删除它,那这些 key 就会一直残留在 slave 内存中,消耗 slave 的内存。
最麻烦的是,你使用命令查询这些 key,却还查不到任何结果!
这就 slave 「内存泄露」问题。
这其实也是 Redis 的一个 Bug,Redis 4.0 才修复了这个问题。
解决方案是,在可写的 slave 上,写入带有过期时间 key 时,slave 会「记录」下来这些 key。
然后 slave 会定时扫描这些 key,如果到达过期时间,则清理之。
如果你的业务需要在 slave 上临时存储数据,而且这些 key 也都设置了过期时间,那么就要注意这个问题了。
你需要确认你的 Redis 版本,如果是 4.0 以下版本,一定要避免踩这个坑。
其实,最好的方案是,制定一个 Redis 使用规范,slave 必须强制设置为 read-only,不允许写,这样不仅可以保证 master / slave 的数据一致性,还避免了 slave 内存泄露问题。
为什么主从全量同步一直失败?
在主从全量同步时,你可能会遇到同步失败的问题,具体场景如下:
slave 向 master 发起全量同步请求,master 生成 RDB 后发给 slave,slave 加载 RDB。
由于 RDB 数据太大,slave 加载耗时也会变得很长。
此时你会发现,slave 加载 RDB 还未完成,master 和 slave 的连接却断开了,数据同步也失败了。
之后你又会发现,slave 又发起了全量同步,master 又生成 RDB 发送给 slave。
同样地,slave 在加载 RDB 时,master / slave 同步又失败了,以此往复。
这是怎么回事?
其实,这就是 Redis 的「复制风暴」问题。
什么是复制风暴?
就像刚才描述的:主从全量同步失败,又重新开始同步,之后又同步失败,以此往复,恶性循环,持续浪费机器资源。
为什么会导致这种问题呢?
如果你的 Redis 有以下特点,就有可能发生这种问题:
- master 的实例数据过大,slave 在加载 RDB 时耗时太长
- 复制缓冲区(slave client-output-buffer-limit)配置过小
- master 写请求量很大
主从在全量同步数据时,master 接收到的写请求,会先写到主从「复制缓冲区」中,这个缓冲区的「上限」是配置决定的。
当 slave 加载 RDB 太慢时,就会导致 slave 无法及时读取「复制缓冲区」的数据,这就引发了复制缓冲区「溢出」。
为了避免内存持续增长,此时的 master 会「强制」断开 slave 的连接,这时全量同步就会失败。
之后,同步失败的 slave 又会「重新」发起全量同步,进而又陷入上面描述的问题中,以此往复,恶性循环,这就是所谓的「复制风暴」。
如何解决这个问题呢?我给你以下几点建议:
- Redis 实例不要太大,避免过大的 RDB
- 复制缓冲区配置的尽量大一些,给 slave 加载 RDB 留足时间,降低全量同步失败的概率
如果你也踩到了这个坑,可以通过这个方案来解决。