Redis总结

170 阅读46分钟

Redis 总体认识

Redis 是一个开源的内存数据结构存储,支持多种高效数据类型的原子读写,具有集群方案(主从复制模式哨兵模式切片集群模式)、Lua 脚本、事务、日志持久性内存淘汰发布订阅模型过期删除等特性。 Redis 相比于 Memcached 多了很多新特性。 Redis 单机的 QPS 能轻松破 10w,是 Mysql 的十倍。

Redis 数据结构

理解 Redis 数据结构要从 C 语言角度出发

String

存储字符串、整数、浮点数

底层实现

底层数据结构使用 **int **或 SDS 如果一个字符串对象保存的是整数值,并且这个整数值可以用long类型来表示,那么编码方式就是int image.png 如果字符串对象保存的是一个字符串,并且这个字符串的长度小于等于 32 字节,那么编码方式就是embstr image.png 如果字符串对象保存的是一个字符串,并且这个字符串的长度大于 32 字节,那么编码方式就是raw

应用场景

存储对象

比如存储各种状态对象如 Session

  • 直接存储 json 字符串: SET user:1 '{"name":"xiaolin", "age":18}'
  • 将 key 进行分离为 user:id:属性,采用 MSET 存储: MSET user:2:name xiaolin user:2:age 18

原子计数

SET number 0
INCR number
DECR number

分布式锁

加锁
SET lock_key unique_value NX PX 10000

lock_key 唯一标识锁,unique_value 客户端唯一标识,设置 10s 过期时间

解锁

先判断自己是否为加锁客户端,是的话才可以释放锁,利用Lua脚本保证原子性。

// 释放锁时,先比较 unique_value 是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
  return redis.call("del",KEYS[1])
else
  return 0
end

常用命令

# 设置 key-value 类型的值
SET name lin
# 根据 key 获得对应的 value
GET name
# 判断某个 key 是否存在
EXISTS name
# 返回 key 所储存的字符串值的长度
STRLEN name
# 删除某个 key 对应的值
DEL name
# 批量设置 key-value 类型的值
MSET key1 value1 key2 value2 
# 批量获取多个 key 对应的 value
MGET key1 key2 
# 设置 key-value 类型的值
SET number 0
# 将 key 中储存的数字值增一
INCR number
# 将key中存储的数字值加 10
INCRBY number 10
# 将 key 中储存的数字值减一
DECR number
# 将key中存储的数字值键 10
DECRBY number 10
# 设置 key 在 60 秒后过期(该方法是针对已经存在的key设置过期时间)
EXPIRE name  60 
# 查看数据还有多久过期
TTL name
# 设置 key-value 类型的值,并设置该key的过期时间为 60 秒
SET key  value EX 60
# 不存在就插入(not exists)
SETNX key value

List

字符串列表,按照插入顺序排序,可以从头部或尾部向 List 列表添加元素。从慢查询和**大 key **的角度考虑,单个 List 的最大长度不要超过 10000。

LPUSH key value [value ...] 
RPUSH key value [value ...]
LPOP key     
RPOP key 
LRANGE key start stop
BLPOP key timeout
BRPOP key timeout

底层实现

底层数据结构使用双向链表压缩列表

  • 如果列表的元素个数小于512个,列表每个元素的值都小于 64 字节,使用压缩列表
  • 否则使用双向链表

Redis 3.2 版本之后,List数据类型底层数据结构改为由 **quicklist **实现

image.png

双向链表

image.png 链表的缺点是每个节点的内存都是不连续的,无法很好利用 CPU 缓存,所以在数据量较小的情况下 Redis 会采用压缩列表作为List的底层实现,压缩列表节省内存且是内存紧凑型数据结构

压缩列表

压缩列表占用一块连续的内存,可以充分利用 CPU 缓存,并且针对不同长度和类型的数据进行编码存储。压缩链表的缺陷在于查找的复杂度为 O(n),插入修改还存在连锁更新的问题,因此保存元素过多时效果很差。 image.png

  • prevlen:记录前一个节点的字节长度,用于实现从后向前遍历
  • encoding:记录当前节点数据的类型和长度
  • data:记录当前节点数据
连锁更新问题

压缩列表新增或修改某个元素时,会导致内存空间的重新分配,这时如果新插入的元素较大,会导致后续元素的 prevlen 占用空间发生变化,从而引起连续不断地内存空间重新分配。

quicklist

image.png quicklist 结合了双向链表和压缩列表,双向链表中每个节点不再只存储一个元素,而是存储一个限制长度的压缩列表。这样因为压缩列表的存在可以充分利用 CPU 缓存并且内存紧凑,并且每个压缩列表限制长度使得不会出现大量连锁更新

应用场景

消息队列

消息保序

List 数据类型本身自带顺序,利用BRPOP命令阻塞式获取消息 image.png 重复消息 需要我们自行为每个消息生成一个全局唯一 ID

LPUSH mq "111000102:stock:99"
消息可靠性

当消费者从 List 读取一条消息后,List 就不会再留存这条消息了,如果消费失败就会导致消息丢失。BRPOPLPUSH命令可以读取消息的同时再把这个消息插入到另一个 List 中。 List消息队列不支持消费者组。

常用命令

# 将一个或多个值value插入到key列表的表头(最左边),最后的值在最前面
LPUSH key value [value ...] 
# 将一个或多个值value插入到key列表的表尾(最右边)
RPUSH key value [value ...]
# 移除并返回key列表的头元素
LPOP key     
# 移除并返回key列表的尾元素
RPOP key 
# 返回列表key中指定区间内的元素,区间以偏移量start和stop指定,从0开始
LRANGE key start stop
# 从key列表表头弹出一个元素,没有就阻塞timeout秒,如果timeout=0则一直阻塞
BLPOP key [key ...] timeout
# 从key列表表尾弹出一个元素,没有就阻塞timeout秒,如果timeout=0则一直阻塞
BRPOP key [key ...] timeout

Hash

键值对集合特别适合存储对象

底层实现

底层数据结构使用压缩列表哈希表

  • 如果哈希类型元素小于 512 个,所有值小于 64 字节,使用压缩列表
  • 否则使用哈希表

在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 **listpack **数据结构来实现了。

哈希表

常见的数组+单链表结构 image.png

渐进式 rehash

一个 hash 结构里面有着两个哈希表,每次触发 rehash 的时候都会为空的哈希表申请一个两倍的空间,并将数据从当前哈希表迁移过去,但如果拷贝数据量过大会阻塞其他操作,因此 redis 采取**渐进式 hash **进行数据迁移。 在 rehash 期间,每次对哈希表元素进行增删改操作的时候,都会进行一部分的数据迁移,随着哈希表操作请求数量增多,最终会完成哈希表的整体迁移。

  • 查:先去原哈希表查,再去新哈希表查
  • 增:将原哈希表索引位置的整条链表迁移到新哈希表,并在新哈希表新增
  • 删:将原哈希表索引位置的整条链表迁移到新哈希表,并在新哈希表删除
  • 改:将原哈希表索引位置的整条链表迁移到新哈希表,并在新哈希表修改

注:哈希表中元素过多超过负载因子会触发 rehash。

应用场景

缓存对象

一般对象用 String + Json 存储,如果对象中某些属性频繁变化则用 Hash 类型存储。

常用命令

# 存储一个哈希表key的键值
HSET key field value   
# 获取哈希表key对应的field键值
HGET key field
# 在一个哈希表key中存储多个键值对
HMSET key field value [field value...] 
# 批量获取哈希表key中多个field键值
HMGET key field [field ...]       
# 删除哈希表key中的field键值
HDEL key field [field ...]    
# 返回哈希表key中field的数量
HLEN key       
# 返回哈希表key中所有的键值
HGETALL key 
# 为哈希表key中field键的值加上增量n
HINCRBY key field n  

Set

无序且唯一的字符串集合,支持多个集合取交集、并集、差集。

底层实现

底层数据结构使用哈希表整数集合

  • 如果集合中元素都为整数且小于512个,使用整数集合
  • 否则使用哈希表

整数集合

整数集合本质上是一块连续内存空间,它的结构定义如下:

typedef struct intset {
    //编码方式
    uint32_t encoding;
    //集合包含的元素数量
    uint32_t length;
    //保存元素的数组
    int8_t contents[];
} intset;

contents数组的真正类型取决于encoding属性的值。比如:

  • 如果属性值为INTSET_ENC_INT16,那么contents存放的元素就是int16_t
  • 如果属性值为INTSET_ENC_INT32,那么contents存放的元素就是int32_t
  • 如果属性值为INTSET_ENC_INT64,那么contents存放的元素就是int64_t
升级操作

比如当前整数集合里面全是int16_t的元素,插入的新元素是int32_t类型,则需要进行扩容升级。升级操作的核心目的是节省内存资源。 原数组: 升级过程:

应用场景

点赞

比如用户对文章点赞,就可以每一个文章用一个 set 集合保存都有哪些用户对该文章点赞了

共同关注

比如用户关注公众号,每一个用户用一个 set 集合保存关注了哪些公众号,可以利用集合操作得出两个人共同关注的公众号,或给一个人推荐另一个人关注了但自己没有关注的公众号。 聚合计算(并集、交集、差集)场景,比如点赞、共同关注、抽奖活动等。

抽奖

key 为抽奖活动名,value 为员工名称,把所有员工名称放入抽奖箱。如果允许重复中奖,可以使用 SRANDMEMBER 命令,如果不允许重复中奖,可以使用 SPOP 命令。

常用命令

# 往集合key中存入元素,元素存在则忽略,若key不存在则新建
SADD key member [member ...]
# 从集合key中删除元素
SREM key member [member ...] 
# 获取集合key中所有元素
SMEMBERS key
# 获取集合key中的元素个数
SCARD key
# 判断member元素是否存在于集合key中
SISMEMBER key member
# 从集合key中随机选出count个元素,元素不从key中删除
SRANDMEMBER key [count]
# 从集合key中随机选出count个元素,元素从key中删除
SPOP key [count]
# 交集运算
SINTER key [key ...]
# 将交集结果存入新集合destination中
SINTERSTORE destination key [key ...]
# 并集运算
SUNION key [key ...]
# 将并集结果存入新集合destination中
SUNIONSTORE destination key [key ...]
# 差集运算
SDIFF key [key ...]
# 将差集结果存入新集合destination中
SDIFFSTORE destination key [key ...]

ZSet

有序且唯一的字符串集合,有序指的不是插入顺序,而是按照分值排序。

底层实现

底层数据结构使用压缩列表跳表

  • 如果有序集合中元素个数小于 128 个且每个元素的值小于 64 字节,使用压缩列表
  • 否则使用跳表

跳表

ZSet 结构体里有两个数据结构:一个是跳表,一个是哈希表。其中哈希表的作用一是保证字符串唯一,二是通过字符串直接拿到 score。而跳表的作用主要是高效进行范围查询。 ZSet 对象在执行数据变更的时候会依次对跳表和哈希表进行变更,保证数据的一致。

跳表结构

跳表是一种多层有序链表,可以快速定位数据。 跳表节点的数据结构定义如下:

typedef struct zskiplistNode {
    //Zset 对象的元素值
    sds ele;
    //元素权重值
    double score;
    //后向指针
    struct zskiplistNode *backward;
  
    //节点的level数组,保存每层上的前向指针和跨度
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned long span;
    } level[];
} zskiplistNode;

可以看到每个节点保存了 score 值、元素值、第一层的后向指针、每一层的前向指针以及指针跨度。 跨度的作用是为了计算节点在跳表中的排位,在查找某个节点时,只要将查找过程中经过的所有跨度累加就得到了该节点在跳表中的排位。 注:跳表排序优先按 score 值排,score 值相同时按照字符串字典序排。

跳表如何查询

查找过程会从头节点的最高层开始,判断下一个节点的情况

  • 下一个节点不为 null 且下一个节点等于要查找的节点,则查找结束
  • 下一个节点不为 null 且下一个节点小于要查找的节点,向右继续查找
  • 下一个节点为 null 或者下一个节点不为 null 且下一个节点大于要查找的节点,往下走一层
跳表节点层数设置

跳表相邻两层的节点数量比例会影响跳表的查询性能,如果跳表相邻两层的节点数量的比例是 2:1,则查找复杂度可以降低到 O(log(N))。 Redis 维持比例的方式是,每个节点在创建的时候,会随机生成自己的层数。在第一层的基础上,有 0.25 的概率会升高一层,升高一层之后,又有 0.25 的概率会继续升高一层,直到不再升高。层高的最大限制为 32,跳表头节点的层高为 32。

为什么不使用平衡树
  1. 跳表的内存占用更低。平均一个节点的指针很少,平衡树一个节点的指针最少为 2。
  2. 跳表支持高效范围查询,平衡树只有 B+树可以做到。
  3. 跳表逻辑简单。比如插入和删除,跳表只需要在插入或删除位置做一些指针修改即可。

应用场景

排行榜

比如博文点赞排行榜,获取点赞数最高的三篇博文,获取点赞数在 100 到 200 之间的博文。

常用命令

# 往有序集合key中加入带分值元素
ZADD key score member [[score member]...]   
# 往有序集合key中删除元素
ZREM key member [member...]                 
# 返回有序集合key中元素member的分值
ZSCORE key member
# 返回有序集合key中元素个数
ZCARD key 

# 为有序集合key中元素member的分值加上increment
ZINCRBY key increment member 

# 正序获取有序集合key从start下标到stop下标的元素
ZRANGE key start stop [WITHSCORES]
# 倒序获取有序集合key从start下标到stop下标的元素
ZREVRANGE key start stop [WITHSCORES]

# 返回有序集合中指定分数区间内的成员,分数由低到高排序。
ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]

# 返回指定成员区间内的成员,按字典正序排列, 分数必须相同。
ZRANGEBYLEX key min max [LIMIT offset count]
# 返回指定成员区间内的成员,按字典倒序排列, 分数必须相同
ZREVRANGEBYLEX key max min [LIMIT offset count]

# 并集计算(相同元素分值相加),numberkeys一共多少个key,WEIGHTS每个key对应的分值乘积
ZUNIONSTORE destkey numberkeys key [key...] 
# 交集计算(相同元素分值相加),numberkeys一共多少个key,WEIGHTS每个key对应的分值乘积
ZINTERSTORE destkey numberkeys key [key...]

BitMap

一串连续的二进制数组,可以通过偏移量定位元素

底层实现

利用 String 类型进行存储,String 类型会保存为二进制字节数组,每个 bit 位对应一个二值状态。

应用场景

非常适合二值状态统计

签到统计

比如存放 ID 100 的用户在 2022 年 6 月的签到情况 记录该用户 6 月 3 号已签到:

SETBIT uid:sign:100:202206 2 1

检查该用户 6 月 3 日是否签到:

GETBIT uid:sign:100:202206 2

用户登录状态

key 设置为 login_status 表示存储用户登陆状态集合数据,将用户 ID 作为偏移量,在线就设置为 1,离线就设置为 0, 5000 万用户只需要 6 MB 的空间。

连续签到用户总数

现在有七天的用户打卡情况,如何统计连续七天均打卡的用户总数 把每天的日期作为 key,用户 ID 作为偏移量,若是打卡则将该位置设为 1。七个 Bitmap 按位做与运算,将结果保存到一个新的 Bitmap,然后统计 1 的个数即可。

# 与操作
BITOP AND destmap bitmap:01 bitmap:02 bitmap:03
# 统计 bit 位 =  1 的个数
BITCOUNT destmap

常用命令

# 设置值,其中value只能是 0 和 1
SETBIT key offset value

# 获取值
GETBIT key offset

# 获取指定范围内值为 1 的个数
# start 和 end 以字节为单位
BITCOUNT key start end

# BitMap间的运算
# operations 位移操作符,枚举值
  AND 与运算 &
  OR 或运算 |
  XOR 异或 ^
  NOT 取反 ~
# result 计算的结果,会存储在该key中
# key1 … keyn 参与运算的key,可以有多个,空格分割,not运算只能一个key
# 当 BITOP 处理不同长度的字符串时,较短的那个字符串所缺少的部分会被看作 0。返回值是保存到 destkey 的字符串的长度(以字节byte为单位),和输入 key 中最长的字符串长度相等。
BITOP [operations] [result] [key1] [keyn…]

# 返回指定key中第一次出现指定value(0/1)的位置
BITPOS [key] [value]

HyperLogLog

提供不精确的去重计数功能,HyperLogLog 的优点是即使输入元素的数量非常大,去重计数所需的内存也是固定的 12KB,和 Hash 结构相比非常节省空间。

底层实现

无需关注

应用场景

网页访客数统计

当有用户访问页面时,将其添加进 HyperLogLog 中

PFADD page1:uv user1 user2 user3 user4 user5

随时获取当前统计结果

PFCOUNT page1:uv

GEO

存储地理位置信息并对信息做操作

底层实现

底层数据结构采用 ZSet 存储,使用 GeoHash 编码方法实现了经纬度到 ZSet 中元素权重分数的转换。具体来讲,对二维地图做区间划分并对区间进行编码,一组经纬度落在某个区间后,就用区间的编码值表示权重分数。 利用 ZSet 按权重进行有序范围查找的特性,即可实现搜索附近等需求。

应用场景

滴滴叫车

用一个 GEO 集合保存所有车辆的经纬度,集合 key 是 cars:locations,将 ID 为 33 的车辆添加进集合。

GEOADD cars:locations 116.034579 39.030452 33

查找某经纬度为中心的 5 公里内的车辆信息,由近到远选 10 条。

GEORADIUS cars:locations 116.054579 39.030452 5 km ASC COUNT 10

常用命令

# 存储指定的地理空间位置,可以将一个或多个经度(longitude)、纬度(latitude)、位置名称(member)添加到指定的 key 中。
GEOADD key longitude latitude member [longitude latitude member ...]

# 从给定的 key 里返回所有指定名称(member)的位置(经度和纬度),不存在的返回 nil。
GEOPOS key member [member ...]

# 返回两个给定位置之间的距离。
GEODIST key member1 member2 [m|km|ft|mi]

# 根据用户给定的经纬度坐标来获取指定范围内的地理位置集合。
GEORADIUS key longitude latitude radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC] [STORE key] [STOREDIST key]

Stream

Redis 5.0 推出的专门用于消息队列设计的数据类型,支持消息持久化、自动生成全局唯一 ID、消息确认、消费组模型等特性。

底层实现

无需关注

应用场景

消息队列

直接插入消息,消息的全局唯一 ID 组成为:当前的毫秒时间-消息在当前毫秒内的序号

# * 表示让 Redis 为插入的数据自动生成一个全局唯一的 ID
# 往名称为 mymq 的消息队列中插入一条消息,消息的键是 name,值是 xiaolin
XADD mymq * name xiaolin

直接读取与阻塞读取

# 从 ID 号为 1654254953807-0 的消息开始,读取后续的所有消息(示例中一共 1 条)。
XREAD STREAMS mymq 1654254953807-0

# 命令最后的“$”符号表示读取最新的消息
XREAD BLOCK 10000 STREAMS mymq $
消费组

创建消费组

# 创建一个名为 group1 的消费组,0-0 表示从第一条消息开始读取。
XGROUP CREATE mymq group1 0-0

消费组 group1 内的消费者 consumer1 从 mymq 消息队列中读取所有消息

# 命令最后的参数“>”,表示从第一条尚未被消费的消息开始读取。
XREADGROUP GROUP group1 consumer1 STREAMS mymq >

消息队列中的消息一旦被消费组里的一个消费者读取了,就不会再被该消费组内其他消费者读取,但不会对其他消费组造成影响。 使用消费组的目的是让组内多个消费者共同分担读取消息,实现负载均衡。

# 让 group2 中的 consumer1 从 mymq 消息队列中消费一条消息
XREADGROUP GROUP group2 consumer1 COUNT 1 STREAMS mymq >

# 让 group2 中的 consumer2 从 mymq 消息队列中消费一条消息
XREADGROUP GROUP group2 consumer2 COUNT 1 STREAMS mymq >

# 让 group2 中的 consumer3 从 mymq 消息队列中消费一条消息
XREADGROUP GROUP group2 consumer3 COUNT 1 STREAMS mymq >
消息确认

所有已读取但未确认的消息都会留存在 Pending List 中,直到消费者使用 XACK 命令通知消息已经处理完成。 下面是 consumer2 确认自己在 mymq 队列 group2 消费组中,对于1654256265584-0这条消息的消费已经完成。

XACK mymq group2 1654256265584-0
Stream 相比专业消息队列的缺点

如果业务场景对数据丢失不敏感并且消息堆积的概率比较小,则可以选用 Stream。 如果业务场景不能接受数据丢失并且存在海量数据,则选用专业消息中间件。

消息丢失

Stream 的生产者和消费者不会丢失消息,但中间件本身可能丢消息。在单机模式下宕机重启无论采用 AOF 日志还是 RDB 快照恢复数据,都有可能出现数据丢失问题。在主从异步复制的集群模式下主从故障转移,一方面从节点本身数据就可能不是最新的,另一方面有可能出现脑裂,这两种情况都会导致消息丢失。而像 Kafka 这种专业队列中间件往往采用集群部署,通过多节点副本保证消息不丢失。

消息堆积

因为 Redis 数据都存储在内存,因此一旦消息堆积很可能出现内存溢出的风险。而像 Kafka 这种专业队列中间件,数据都是存储在磁盘上,消息堆积时无非是多占用一些空间。

Redis 线程模型

Redis 单线程指的是「接收客户端请求->解析请求 ->进行数据读写等操作->发送数据给客户端」这个过程是由一个线程(主线程)来完成的,并不是指 Redis 进程一共就一个线程。 Redis 为「关闭文件AOF 刷盘释放内存」这三种操作专门创建了三个后台线程来处理,原因是这些操作都是很耗时的,放在主线程处理会阻塞其他操作。 image.png Redis 主线程的工作模式如下 image.png

Redis 单线程还很快的原因

  • Redis 大部分操作都在内存完成,并且采用了高效的数据结构,高效的数据结构意味着存储一定量的数据占用的内存更少,读写数据的效率更高。Redis 的瓶颈在于内存和网络带宽而非 CPU,因此单线程并不会拉低效率。
  • Redis 单线程模型避免了多线程同步问题,减少了开发成本。
  • Redis 采用多路 IO 复用机制,支持单线程处理大量网络 IO 流。

注:Redis 6.0 后采用多线程处理网络 IO,但是对于命令的执行仍然使用单线程来处理 因此, Redis 6.0 版本之后,Redis 在启动的时候,默认情况下除了主线程外还会额外创建 6 个线程:

  • Redis-server : Redis的主线程,主要负责执行命令
  • bio_close_file、bio_aof_fsync、bio_lazy_free:三个后台线程,分别异步处理关闭文件任务、AOF 刷盘任务、释放内存任务
  • io_thd_1、io_thd_2、io_thd_3:三个 I/O 线程,用来分担 Redis 网络 IO 的压力

Redis 持久化

Redis 持久化的目的在于在单机模式下突然断电重启后,尽可能多地恢复内存中的数据。 Redis 共有三种数据持久化的方式:

  • AOF 日志:每执行一条写操作命令,就把该命令以追加的方式写入到一个文件里
  • RDB 快照:将某一时刻的内存数据,以二进制的方式写入磁盘
  • 混合持久化方式:Redis 4.0 新增的方式,集成了 AOF 和 RBD 的优点

AOF 日志

Redis 在执行完一条写操作命令后,就会把该命令以追加的方式写入到一个文件里,然后 Redis 重启时,会读取该文件记录的命令,然后逐一执行命令的方式来进行数据恢复。 image.png Redis 写入 AOF 日志的详细过程如下图: image.png 具体说说:

  1. Redis 执行完写操作命令后,会将命令追加到用户态内存缓冲区
  2. 然后通过 write() 系统调用,将用户态内存缓冲区的数据写入到 AOF 文件,此时数据并没有写入到硬盘,而是拷贝到了内核缓冲区 page cache,等待内核将数据写入硬盘
  3. 具体内核缓冲区的数据什么时候写入到硬盘,可以进行配置(每次直接写入磁盘、每秒写入磁盘、由内核决定何时写入磁盘),从前往后性能更好、断电丢失的数据更多

AOF 重写机制

当 AOF 文件过大时,Redis 就会使用 AOF 重写机制来压缩 AOF 文件。 AOF 重写机制会开启一个当前进程的子进程,子进程可以读取主进程在创建子进程时的数据副本,主进程可以继续处理请求和读写数据。当父子进程任意一方修改了共享内存,就会发生**「写时复制」,于是父子进程就有了独立的数据副本**,就不用加锁来保证数据安全。子进程会读取数据库里的所有数据,并逐一把内存数据的键值对转换成一条命令,再将命令记录到新的 AOF 文件,等到全部记录完后,就将新的 AOF 文件替换掉现有的 AOF 文件。

写时复制

主进程在通过 fork 系统调用生成 bgrewriteaof 子进程时,操作系统会把主进程的「页表」复制一份给子进程,这个页表记录着虚拟地址和物理地址映射关系,而不会复制物理内存,也就是说,两者的虚拟空间不同,但其对应的物理空间是同一个。 这样一来,子进程就共享了父进程的物理内存数据了,这样能够节约物理内存资源,页表对应的页表项的属性会标记该物理内存的权限为只读。 不过,当父进程或者子进程在向这个内存发起写操作时,CPU 就会触发写保护中断,这个写保护中断是由于违反权限导致的,然后操作系统会在「写保护中断处理函数」里进行物理内存的复制,并重新设置其内存映射关系,将父子进程的内存读写权限设置为可读写,最后才会对内存进行写操作,这个过程被称为「写时复制(Copy On Write)」。 操作系统复制父进程页表的时候,父进程也是阻塞中的,不过页表的大小相比实际的物理内存小很多,所以通常复制页表的过程是比较快的。 所以,有两个阶段会导致阻塞父进程

  • 创建 fork() 子进程的途中,由于要复制父进程的页表等数据结构,阻塞的时间跟页表的大小有关,页表越大,阻塞的时间也越长
  • 创建完子进程后,如果子进程或者父进程修改了共享数据,就会发生写时复制,这期间会拷贝物理内存,如果内存越大,自然阻塞的时间也越长

所以如果子进程重写过程中,父进程修改了一个大 key,这时的复制物理内存过程就存在阻塞主进程的风险。

AOF 重写缓冲区

重写过程中,主进程依然可以正常处理命令,只不过命令执行完后要同时写入「AOF 缓冲区」和 「AOF 重写缓冲区」。当子进程完成 AOF 重写工作,会向主进程发送一条信号,信号是进程间通讯的一种方式。主进程收到该信号后,会将 AOF 重写缓冲区中的所有内容追加到新的 AOF 的文件中,新的AOF文件覆盖现有AOF文件。

RDB 快照

AOF 日志是记录命令,RDB 快照是记录内存数据,RDB 恢复数据的效率要比 AOF 高,但 RDB 快照丢失数据要比 AOF 日志丢失数据更多。 Redis 提供了两个命令来生成 RDB 文件:

  • save 命令,主线程生成 RDB 文件,会阻塞主线程
  • bgsave 命令,fork 一个子进程来生成 RDB 文件,使用写时复制技术,保存的是创建子进程时刻的全量数据

混合持久化

在 AOF 重写日志时,fork 出来的重写子进程会将内存数据以 RDB 方式写入到 AOF 文件,然后主线程处理的操作命令会被记录在重写缓冲区里,重写缓冲区里的增量命令会以 AOF 方式写入到 AOF 文件,最后新的 AOF 文件替换旧的 AOF 文件。也就是说,使用了混合持久化,AOF 文件的前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据。 混合持久化的好处在于,重启 Redis 加载数据的时候,由于前半部分是 RDB 内容,这样加载的时候速度会很快,后半部分是 AOF 内容,可以使得数据更少的丢失。

大 key 对持久化的影响

AOF 写回

如果 AOF 写回策略是每次直接刷盘,则会在主线程执行 fsync() 函数将大 key 写入磁盘,这个时间会比较久。如果写回策略是每秒写入磁盘,则 fsync() 会在 bio_aof_fsync AOF 刷盘线程执行,如果写回策略是由操作系统决定何时写入磁盘,则 fsync() 会在内核线程执行,都不会阻塞主线程。

AOF 重写与 RDB 生成

AOF 重写机制和 bgsave 生成 RDB 快照方式都会通过 fork() 函数创建子进程。大 key 可能导致创建子进程的页表复制过程主进程修改数据的写时复制过程时间太久。

持久化之外的其他影响

  • 本身操作大 key 就可能阻塞主线程
  • 引发网络阻塞
  • del 删除大 key 可能阻塞主线程,建议使用 unlink 异步删除大 key
  • 集群分片时内存分配不均,存放大 key 的 redis 节点内存高、QPS 高

Redis 集群

主从复制

主从复制的目的包括提高读性能数据备份提高可用性。 主服务器可以进行读写操作,从服务器只能进行读操作。主服务器会异步将写操作同步给所有从服务器,主服务器并不会等到从服务器实际执行完命令后,再把结果返回给客户端,而是主服务器自己在本地执行完命令后,就会向客户端返回结果了。 因此无法实现强一致性,只能支持最终一致性

第一次同步

在从服务器上执行 replicaof 命令可以指定当前服务器的主服务器

# 服务器 B 执行这条命令
replicaof <服务器 A 的 IP 地址> <服务器 A 的 Redis 端口号>

接下来从服务器会主动和主服务器建立 TCP 连接,然后进行第一次同步,可以分为三个阶段 image.png

  1. 协商同步:从服务器向主服务器发送 psync 表示要进行数据同步,psync 命令包含两个参数,分别是主服务器的 runID 和复制进度 offset。主服务器收到 psync 命令后,会用 FULLRESYNC 作为响应命令返回给从服务器,表示进行全量复制。
  2. 全量同步:主服务器执行 bgsave 命令生成 RDB 快照并将快照发送给从服务器,从服务器收到 RDB 文件后会清空所有数据并加载 RDB 文件。主服务器会将下面三个时间间隙中的所有写操作都写入 replication buffer 缓冲区
    • 主服务器异步生成 RDB 文件期间
    • 主服务器发送 RDB 文件期间
    • 从服务器加载 RDB 文件期间
  3. 补充同步:从服务器完成 RDB 文件载入后会回复一个确认消息给主服务器,主服务器收到后会将 replication buffer 缓冲区中的所有写操作发往从服务器,完成补充同步。

持续同步

主从服务器在完成第一次同步后,双方会维护一个 TCP 长连接,后续主服务器会不断通过这个长连接将写操作同步给从服务器。 image.png

恢复同步

主服务器和从服务器之间的网络连接断开一段时间后又恢复,此时会根据情况选择是进行增量同步还是全量同步,主服务器会维护一个叫做 repl_backlog_buffer 的环形缓冲区,主服务器每次将命令同步给从服务器的同时也会将写操作记录到此缓冲区,当然由于缓冲区有限所以只能保存最近的写操作。 image.png 网络恢复后的过程如上图,首先从服务器在网络恢复后会发送 psync 给主服务器,psync 命令里面的 offset 标识了从服务器的复制进度。主服务器判断 offset 到最新的数据是否全都保存在 repl_backlog_buffer 环形缓冲区中,如果是就进行增量同步,如果否就进行全量同步。 增量同步时会将增量数据写入 replication buffer 缓冲区并进一步发送给从服务器。

多层级从服务器

如果从服务器非常多,主服务器需要对每个从服务器都进行一次 RDB 文件全量同步过程是压力很大的。所以可以使用多层级从服务器,客户端仍然读写主服务器,读所有从服务器,主服务器只写操作同步部分从服务器,剩余写操作同步又从服务器进行中转。 image.png

哨兵模式

哨兵模式提供了主从节点故障转移的功能,当 Redis 的主服务器出现故障宕机时,可以自动选举从节点切换成主节点,并将新主节点相关信息通知所有从节点和客户端。 哨兵本质是一个特殊的 Redis 进程,运行于一个独立的节点上,主要负责监控、选主、通知

如何判断主节点真的故障了

哨兵会每隔 1s 给所有主从节点发送 PING 命令,当主从节点收到 PING 命令后会返回一个 PONG 命令,当主从节点没有在规定时间内响应哨兵的 PING 命令,哨兵就会将它们标记为主观下线,这个规定时间可以由 down-after-milliseconds 配置。 当主节点没有及时回复哨兵的 PING 命令时,有可能由于系统压力大或网络拥塞导致,并没有发生实际的主节点不可用,因此哨兵会进一步判断主节点是否客观下线。为了减少误判的情况,通常采用多个哨兵节点(奇数)组成哨兵集群,当一个哨兵判断主节点客观下线的时候,会向所有其他哨兵询问它们是否认为主节点主观下线,如果赞成票数达到 quorum 配置,该哨兵就会判断主节点客观下线。 image.png

由哪个哨兵进行主从故障转移

当某个哨兵节点判断主节点客观下线的时候,这个哨兵节点就成为了一个候选者候选者会向所有其他哨兵节点发送选举命令,让其他哨兵节点对它进行投票。每个哨兵只有一次投票机会,可以投给自己或投给别人,但只有候选者可以投给自己。任何一个候选者只要拿到半数以上的投票并且票数大于等于 quorum 配置,就成为了 leader。 假设同时有多个哨兵判断主节点客观下线,这时就出现了多个候选者,但是每个哨兵节点只有一票这个机制保证了选举出来的 leader 最多只有一个。 其实选举机制的本质就是在分布式情况下保证唯一 leader,一旦主节点不可用,无论有多少个哨兵判断它主观下线了,最终只会选举出一个 leader 哨兵。 一般建议将 quorum 的值设置为哨兵个数的二分之一加 1,哨兵个数应该是奇数个

主从故障转移过程

  1. 从所有从节点里面挑选出一个从节点,将其转换为主节点
  2. 让所有从节点修改复制目标为新主节点
  3. 将新主节点的信息通过发布订阅机制通知给所有客户端
  4. 监视旧主节点,如果重新上线就将其降级为从节点

选出新主节点

首先过滤所有网络状态不好的从节点(曾多次与 leader 断连的节点),然后按照优先级复制进度、**ID **进行考察,选择优先级最高的从节点,如果优先级相同则选择复制进度最靠前的从节点,如果复制进度也相同就选择 ID 值最小的从节点。 选出从节点之后,哨兵 leader 会向被选中的从节点发送 SLAVEOF no one 命令,该从节点会自己升级成主节点。leader 发完 SLAVEOF no one 命令后会不断发送 INFO 命令进行问询,直到得知升级主节点完成的消息。

将所有从节点指向新主节点

leader 给所有从节点发送 SLAVEOF 命令,让所有从节点指向新主节点,leader 会等待所有从节点的响应 image.png

通知客户端

主从切换完成后,leader 哨兵会向 +switch-master 频道发布新主节点的 IP 地址和端口信息,所有客户端可以收到这条消息并切换主节点访问。

监视旧主节点

监视旧主节点,如果它上线就发送 SLAVEOF 命令,将它降级成新主节点的从节点。

哨兵集群组成过程

配置哨兵信息时,只需要填下面这几个参数,设置主节点名字、主节点的 IP 地址和端口号以及 quorum 值。

sentinel monitor <master-name> <ip> <redis-port> <quorum> 

哨兵节点通过主节点的发布订阅机制来相互发现,当一个哨兵节点和主节点建立连接后,主节点会将当前所有哨兵节点信息发给新接入的哨兵节点,并且会将新接入的哨兵节点的信息广播给所有已接入的节点。最终任意两个哨兵节点直接都建立了连接。 主节点知道所有从节点的信息,哨兵会每隔一段时间给主节点发 **INFO** 消息来获取所有从节点信息。

切片集群

当 Redis 缓存数据量大到一台服务器无法缓存时,就需要使用 Redis 切片集群方案,它将数据分布在不同的服务器上。 一个切片集群共有 16384 个哈希槽,这些哈希槽类似于数据分区,每个键值对都会根据它的 key,被映射到一个哈希槽中,具体执行过程分为两大步:

  • 根据键值对的 key,按照 CRC16 算法计算一个 16 bit 的值。
  • 再用 16bit 值对 16384 取模,得到 0~16383 范围内的模数,每个模数代表一个相应编号的哈希槽。

CRC16 映射再 16384 取模的作用就是尽可能均匀地散列 key。 哈希槽如何映射到具体的 Redis 节点上,有两种方式

  • 平均分配
  • 手动分配

面试题

Redis 如何判断节点是否正常工作

哨兵与主从节点之间、主节点与从节点之间、从节点与主节点之间都是通过 PING PONG 问询来判断节点是否正常工作

主从复制过程中内存淘汰如何处理

主节点内存淘汰删除了一个 key,会模拟出一条 DEL 命令发送给所有从节点

replication buffer 和 repl backlog buffer 的区别

replication buffer 在全量复制阶段和增量复制阶段都会用到,主节点会给每个从节点分配一个 replication buffer,一旦 replication buffer 满了,从节点会重新开始全量复制。 repl backlog buffer 只会在增量复制阶段用到,主节点只维护一个,环形缓冲区一旦满了就会覆盖。

如何应对 Redis 主从不一致问题

主从异步复制模式本身就不能保证数据的强一致性,只能保证最终一致性。 为了提高一致性性能,可以开发一个监控程序监视主从复制进度,一旦某个从节点的复制进度过慢,就禁止客户端访问这个从节点。

主从切换如何减少数据丢失

主节点还未将数据同步到从节点就宕机了,这时就发生了异步复制的数据丢失。解决方法是当主节点发现所有从节点的数据同步延迟都很长时就拒绝写服务。 主节点和所有从节点断开网络连接,但主节点仍正常为客户度提供写服务,这时发生主从节点故障转移,从节点中产生了一个新的主节点,也就是产生了脑裂。当客户端转而读写新主节点的时候,旧主节点的一部分数据就发生了丢失。解决方法和上面类似,也是当主节点发现从节点下线数量太多或从节点网络延迟太大等情况,主节点就拒绝写服务。

Redis 过期删除与内存淘汰

过期删除

Redis 支持对 key 设置过期时间,从客户端的角度来说就是过期的 key 是读不到的。 每当我们对一个 key 设置了过期时间时,Redis 会把该 key 带上过期时间存储到一个过期字典中,也就是说「过期字典」保存了数据库中所有 key 的过期时间。Redis 的过期删除策略是惰性删除+定期删除Redis 数据库结构如下图所示。 image.png

惰性删除

不主动删除过期键,每次客户端访问一个 key 时,如果该 key 不在过期字典或在过期字典但还未过期,就正常访问,如果该 key 在过期字典且已过期,就删除该 key 所占内存,然后返回 null 给客户端。 惰性删除策略不占 cpu,但是比较浪费内存,因为过期的 key 只要不访问就一直存在于内存中。

定期删除

每隔一段时间「随机」从过期字典中取出一定数量的 key 进行检查,并删除其中的过期 key。如果过期 key 比例较高,则再次执行这个过程。 定期删除策略不好确定执行频率。

Redis 持久化对过期键的处理

  • RDB 快照:写入时过期键不会保存,加载时过期键不会被载入数据库
  • AOF 文件:写入时由于 AOF 文件记录的是命令,所以当过期键被删除时,Redis 会向 AOF 文件追加一条 DEL 命令。重写时过期键不会保存

Redis 主从模型对过期键的处理

当 Redis 运行在主从模式下时,从库不会进行过期扫描,从库对过期的处理是被动的。也就是即使从库中的 key 过期了,如果有客户端访问从库时,依然可以得到 key 对应的值,像未过期的键值对一样返回。 从库的过期键处理依靠主服务器控制,主库在 key 到期时,会在 AOF 文件里增加一条 DEL 指令,同步到所有的从库,从库通过执行这条 DEL 指令来删除过期的 key。

内存淘汰

当Redis的内存占用达到了某个阈值,就会触发内存淘汰机制,这个阈值 maxmemory 可以在 Redis 的配置文件中找到。 Redis 有八种内存淘汰策略

  • **默认策略:**不淘汰任何数据,不再提供写服务,只可以执行删除操作和读操作
  • 在设置了过期时间的数据中进行淘汰
    • random:随机淘汰键值
    • ttl:优先淘汰更早过期的键值
    • lru:淘汰最久未使用的键值
    • lfu:淘汰使用频率最低的键值
  • 在所有数据范围内进行淘汰
    • random:随机淘汰键值
    • lru:淘汰最久未使用的键值
    • lfu:淘汰使用频率最低的键值

Redis 对 LRU 和 LFU 的实现

LRU

传统的 LRU 算法是基于链表结构,链表元素按照操作顺序从前往后排列,最新操作的元素会被移动到链表头,链表尾部的元素就是最久未被使用的元素。 Redis 中为每个 key 都设计链表开销太大,因此采取了一种近似的方式。Redis 的对象结构体中会记录此数据的最后一次访问时间,当进行内存淘汰时,会随机采样几个数据,然后淘汰最久未使用的那个。LRU 算法的缺点是无法解决缓存污染问题。

LFU

如果使用了 LFU 算法,则 Redis 对象头除了保存最后一次访问时间,还会保存访问频次的估计值。每次访问都会修改访问频次,首先根据距离上一次访问的时间进行衰减,越久衰减幅度越大,其次因为本次的访问再对频率进行增加,从而得到频率的大致估计。每次随机采样几个数据并按照访问频次进行淘汰。

Redis 缓存设计

缓存雪崩

大量缓存数据同一时间过期导致缓存雪崩

  • 均匀设置过期时间
  • 采用旁路策略但不设置过期时间

Redis 故障宕机导致缓存雪崩

  • 构建 Redis 高可用集群
  • 客户端服务熔断降级、数据库限流

缓存击穿

热点缓存失效导致的并发缓存重建问题

  • dcl 保证唯一缓存重建
    • 阻塞等缓存重建完成后返回新值:阻塞式获取分布式锁
    • 直接返回旧值:逻辑过期,非阻塞式获取分布式锁,获取锁失败直接返回旧值

缓存穿透

当用户访问的数据不存在,请求将会直接打到数据库

  • 查数据库得到空值后往缓存写入 null,旁路策略插入数据操作改为先插入数据库再删除缓存
  • 布隆过滤器过滤掉大部分对数据库中不存在数据的请求

布隆过滤器

布隆过滤器可以以很低的内存占用和时间复杂度对数据进行记录,并且判断数据是否被记录过如果布隆过滤器判断此数据未被记录过,则此数据一定没有被记录过,如果布隆过滤器判断此数据被记录过,则此数据有可能被记录过也有可能未被记录过。因此布隆过滤器适合于数据空间很大但是记录数据很少的情况,这样大部分情况就是布隆过滤器可以确定的未记录,布隆过滤器可以充分发挥它的优势。 布隆过滤器由初始值都为 0 的位图数组和 N 个哈希函数组成,当要插入数据的时候,会使用 N 个哈希函数对数据做哈希计算得到 N 个哈希值,将 N 个哈希值对位图数组长度取模,将位图数组对应位置设置为 1。当要查询某个数据时,再次利用 N 个哈希函数做映射并取模,只要对应位置有一个 0,就可以判断此数据一定不在数据库中,但及时全为 1 也不能判断数据一定存在于数据库中。

设计一个动态缓存热点数据的缓存

利用 Redis 的 List 结构,在里面存储最近查询访问的 1000 条数据,采用 LRU 算法

  • 增:数据库直接插入一条
  • 删:删除数据库中数据,然后 Lua 脚本查看 List 中是否有这条数据,有则删除
  • 改:修改数据库中数据,然后 Lua 脚本查看 List 中是否有这条数据,有则修改
  • 查:Lua 脚本查看 List 中是否有这条数据,有则将这条数据的位置调整到 List 头部,然后返回数据,没有则直接返回,然后去数据库中查询这条数据,没有则直接返回客户端,有则 Lua 脚本将数据插入到 List 中,判断如果 List 数据个数超过了 1000,则将超出的数据删除,最后再返回。

常见缓存更新策略

Cache Aside 旁路策略

常见缓存策略,适合读多写少的场景,因为大部分读都是读缓存,而所有写都要写数据库

Write Back 写回策略

  • 增:数据库直接插入一条(同旁路策略一致)
  • 删:先删数据库中数据,再删缓存(同旁路策略一致)
  • 改:判断缓存中是否有对应数据,如果有就修改并标记为脏,如果没有就从数据库中查出数据,修改后写入缓存中并标记为脏
  • 查:先查缓存,如果查到不管是不是脏数据都可以直接返回,如果没查到就再查数据库,如果没查到就返回,如果查到就写入缓存再返回

如果仅仅执行上面增删改查操作的话,则缓存会越来越多,因此需要设置缓存过期时间或者由一个定时任务定期的删除缓存。但写回策略需要实现的是,当出现缓存过期或删除缓存或内存淘汰的时候,需要保证脏数据的写回。 Cache Aside 旁路策略和 Write Back 写回策略的区别在于,写回策略将改操作中原本写数据库的操作推迟到了缓存删除时,因此 Write Back 写回更适合写多读少的场景,因为即使改操作很多,也都是针对缓存的因此速度很快。

Redis 实战

Redis 如何实现延迟任务队列

利用 ZSet,用 score 存储任务的延迟执行时间戳,另一个线程不断按照时间进行消费执行

Redis 管道

Redis 提供的一种批处理技术,用于一次处理多个 Redis 命令。

Redis 事务

如图所示是 Redis 的事务过程,开启事务后执行的操作都是放入队列暂存,事务提交的话统一执行,事务放弃的话统一放弃

#读取 count 的值4
127.0.0.1:6379> GET count
"1"
#开启事务
127.0.0.1:6379> MULTI 
OK
#发送事务的第一个操作,对count减1
127.0.0.1:6379> DECR count
QUEUED
#执行DISCARD命令,主动放弃事务
127.0.0.1:6379> DISCARD
OK
#再次读取a:stock的值,值没有被修改
127.0.0.1:6379> GET count
"1"

但是事务提交后,如果某条命令报错了,不会影响其他命令的执行。这说明 Redis 不保证事务执行的原子性。