redis八股文

1.数据结构

1.1 string

​ String 是最基本的 key-value 结构,key 是唯一标识,value 是具体的值,value不仅可以是字符串, 也可以是数字(整数或浮点数),value 最多可以容纳的数据长度是 512M

1.1.1 内部实现

  • SDS不仅可以保存文本数据,还可以保存二进制数据。
  • SDS获取字符串长度的时间复杂度是O(1)。
  • Redis的SDS API是安全的,拼接字符串不会造成缓冲区溢出。
  1. 如果一个字符串对象保存的是整数值,并且这个整数值可以用long型来表示,那么字符串对象会将整数值保存在字符串对象结构中的ptr属性,并将编码设置为int

redis八股文_第1张图片

  1. 如果字符串对象保存的是一个字符串,并且这个字符申的长度小于等于 44 字节(redis 5+版本),那么字符串对象将使用简单动态字符串(SDS)来保存这个,并将对象的编码设置为embstrembstr编码是专门用于保存短字符串的一种优化编码方式:

    一次内存分配函数,释放也只需要一次!

请添加图片描述

  1. 如果字符串对象保存的是一个字符串,并且这个字符串的长度大于 44 字节(redis 5+版本),那么字符串对象将使用一个简单动态字符串(SDS)来保存这个字符串,并将对象的编码设置为raw

    两次内存分配函数,释放也需要两次!

redis八股文_第2张图片

1.1.2 常用命令

//基本操作
set name ccz
get name 
exists name
strlen name
del name
//批量操作
mset k1 v1 k2 v2	
mget k1 k2
//计数器操作
set num 0
incr num
incrby num 10
decr num
decrby num 10
//过期操作
expire name 10
ttl name
setex name 60 ccz
//分布式锁
setnx key value

1.1.3 常用场景

  1. 缓存对象;

    • 直接缓存整个对象的 JSON,命令例子: SET user:1 '{"name":"xiaolin", "age":18}'
    • 采用将 key 进行分离为 user:ID:属性,采用 MSET 存储,用 MGET 获取各属性值。
  2. 常规计数

    因为 Redis 处理命令是单线程,所以执行命令的过程是原子的。(计算访问次数、点赞数、转发数、库存数)

  3. 分布式锁

    SET 命令有个 NX 参数可以实现「key不存在才插入」,可以用它来实现分布式锁:

    • 如果 key 不存在,则显示插入成功,可以用来表示加锁成功;
    • 如果 key 存在,则会显示插入失败,可以用来表示加锁失败。

​ 一般而言,还会对分布式锁加上过期时间,分布式锁的命令如下:set key unique_value nx ex 100

  1. 共享 Session 信息

    通常我们在开发后台管理系统时,会使用 Session 来保存用户的会话(登录)状态,这些 Session 信息会被保存在服务器端,但这只适用于单系统应用,如果是分布式系统此模式将不再适用。

    我们需要借助 Redis 对这些 Session 信息进行统一的存储和管理,这样无论请求发送到那台服务器,服务器都会去同一个 Redis 获取相关的 Session 信息,这样就解决了分布式系统下 Session 存储的问题。

1.2 List

List 列表是简单的字符串列表,按照插入顺序排序,可以从头部或尾部向 List 列表添加元素。

1.2.1 内部实现

在 Redis 3.2 版本之前,List类型的底层数据结构是由双向链表或者压缩列表实现的;在 Redis 3.2 版本之后,List 数据类型底层数据结构就只由 quicklist 实现了,替代了双向链表和压缩列表。

其实 quicklist 就是「双向链表 + 压缩列表」组合,因为一个 quicklist 就是一个链表,而链表中的每个元素又是一个压缩列表。

typedef struct quicklist {
    quicklistNode *head;	//quicklist的链表头
    quicklistNode *tail; 	//quicklist的链表尾
    unsigned long count;	//所有压缩列表中的总元素个数
    unsigned long len;    //quicklistNodes的个数  
    ...
} quicklist;

typedef struct quicklistNode {
    struct quicklistNode *prev;     //前一个quicklistNode
    struct quicklistNode *next;     //后一个quicklistNode
    unsigned char *zl;    					//quicklistNode指向的压缩列表          
    unsigned int sz;          			//压缩列表的的字节大小          
    unsigned int count : 16;        //压缩列表(ziplist)中的元素个数 
    ....
} quicklistNode;

在向quicklist添加一个元素的时候,首先检查插入的位置的压缩列表是否能容纳该元素,如果能容纳就直接保存到quicklistNode结构里的压缩列表,如果不能容纳,才会新建一个quicklistNode结构。

redis八股文_第3张图片

1.2.2 常用命令

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

1.2.3 常用场景

消息队列:满足三个需求(消息保序、处理重复消息、保证消息可靠性)

  1. 消息保序: List 本身就是按先进先出的顺序对数据进行存取的,所以作为队列来讲本身就满足这个需求;不过在消费者读取数据时,又一个潜在的性能风险点,即生产者往List 中写入数据时,并不会主动通知消费者有新消息写入,因此就需要消费者在程序中不停的调用RPOP命令(比如使用一个while(1)循环),这就会导致消费者程序的 CPU 一直消耗在执行 RPOP 命令上,带来不必要的性能损失。

    为了解决这个问题,Redis提供了 BRPOP 命令。BRPOP命令也称为阻塞式读取,客户端在没有读到队列数据时,自动阻塞,直到有新的数据写入队列,再开始读取新数据

  2. 处理重复消息:

    • 每个消息都有一个全局的 ID。(需要生产者自行实现)
    • 消费者要记录已经处理过的消息的 ID。
  3. 保证消息可靠性: 当消费者程序从 List 中读取一条消息后,List 就不会再留存这条消息了。所以,如果消费者程序在处理消息的过程出现了故障或宕机,就会导致消息没有处理完成,那么,消费者程序再次启动后,就没法再次从 List 中读取消息了。

    为了留存消息,List 类型提供了 BRPOPLPUSH 命令,这个命令的作用是让消费者程序从一个 List 中读取消息,同时,Redis 会把这个消息再插入到另一个 List(可以叫作备份 List)留存。

1.3 Hash

Hash 是一个键值对(key - value)集合,其中 value 的形式如: value=[{field1,value1},...{fieldN,valueN}]。Hash 特别适合用于存储对象。

1.3.1 内部实现

Hash 类型的底层数据结构是由压缩列表或哈希表实现的,在redis7版本以后,不再使用压缩列表结构来实现,而是交由listpack数据结构来实现。

listpack如压缩列表一样,用一块连续的内存空间来紧凑地保存数据,并且为了节省内存的开销,listpack 节点会采用不同的编码方式保存不同大小的数据。

redis八股文_第4张图片

listpack 头包含两个属性,分别记录了 listpack 总字节数和元素数量,然后 listpack 末尾也有个结尾标识。

每个 listpack 节点结构主要包含三个方面内容:

  • encoding,定义该元素的编码类型,会对不同长度的整数和字符串进行编码;
  • data,实际存放的数据;
  • len,encoding+data的总长度;

1.3.2 常用命令

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

1.3.3 常用场景

  1. 缓存对象: Hash 类型的 (key,field, value) 的结构与对象的(对象id, 属性, 值)的结构相似,也可以用来存储对象。

    在介绍 String 类型的应用场景时有所介绍,String + Json也是存储对象的一种方式,那么存储对象时,到底用 String + json 还是用 Hash 呢?

    一般对象用 String + Json 存储,对象中某些频繁变化的属性可以考虑抽出来用 Hash 类型存储。

  2. 购物车模块: 以用户 id 为 key,商品 id 为 field,商品数量为 value,恰好构成了购物车的3个要素。

    涉及的命令如下:

    • 添加商品:HSET cart:{用户id} {商品id} 1
    • 添加数量:HINCRBY cart:{用户id} {商品id} 1
    • 商品总数:HLEN cart:{用户id}
    • 删除商品:HDEL cart:{用户id} {商品id}
    • 获取购物车所有商品:HGETALL cart:{用户id}

    当前仅仅是将商品ID存储到了Redis 中,在回显商品具体信息的时候,还需要拿着商品 id 查询一次数据库,获取完整的商品的信息。

1.4 Set

Set 类型是一个无序并唯一的键值集合,它的存储顺序不会按照插入的先后顺序进行存储。(无序、不重复、支持交兵差等操作)

1.4.1 内部实现

Set 类型的底层数据结构是由哈希表或整数集合实现的:

  • 如果集合中的元素都是整数且元素个数小于 512 (默认值,set-maxintset-entries配置)个,Redis 会使用整数集合作为 Set 类型的底层数据结构;
  • 如果集合中的元素不满足上面条件,则 Redis 使用哈希表作为 Set 类型的底层数据结构。

1.4.2 常用命令

SADD key member [member...]
SREM key member [member...]
SMEMBERS key	// 获取集合key中所有元素
SCARD key			// 获取集合key中的元素个数
SISMEMBER key member	// 判断member元素是否存在于集合key中
SRANDMEMBER key [count]	// 从集合key中随机选出count个元素,元素不从key中删除
SPOP key [count]	// 从集合key中随机选出count个元素,元素从key中删除

SINTER key [key...]	// 交集运算
SINTERSTORE destination key [key ...]	// 将交集结果存入新集合destination中
SUNION key [key...]	// 并集运算
SUNIONSTORE key [key...]	// 将并集结果存入新集合destination中
SDIFF key [key...]	// 差集运算
SDIFFSTORE destination key [key...]	// 将差集结果存入新集合destination中

1.4.3 常用场景

  1. 点赞: Set 类型可以保证一个用户只能点一个赞,这里举例子一个场景,key 是文章id,value 是用户id。

    SADD article:1 uid:1 uid:2 uid:3	// uid:1、2、3用户对文章 article:1 点赞
    SREM article:1 uid:1 				// uid:1 取消了对 article:1 文章点赞
    SMEMBERS article:1 					// 获取 article:1 文章所有点赞用户
    SCARD article:1 						// 获取 article:1 文章的点赞用户数量
    SISMEMBER article:1 uid:1		// 判断用户 uid:1 是否对文章 article:1 点赞了
    
  2. 共同关注: 用来计算共同关注的好友、公众号等。key 可以是用户id,value 则是已关注的公众号的id。

  3. 抽奖活动: key为抽奖活动名,value为员工名称,把所有员工名称放入抽奖箱。

1.5 Zset

Zset 类型(有序集合类型)相比于 Set 类型多了一个排序属性 score(分值),对于有序集合 ZSet 来说,每个存储元素相当于有两个值组成的,一个是有序结合的元素值,一个是排序值。(元素不可以重复,但分值可以重复)

Zset 类型的底层数据结构是由压缩列表或跳表实现的:

  • 如果有序集合的元素个数小于 128 个,并且每个元素的值小于 64 字节时,Redis 会使用压缩列表作为 Zset 类型的底层数据结构;
  • 如果有序集合的元素不满足上面的条件,Redis 会使用跳表作为 Zset 类型的底层数据结构;

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

1.5.1 内部实现

zset 结构体里有两个数据结构:一个是跳表,一个是哈希表。这样的好处是既能进行高效的范围查询,也能进行高效单点查询。

Zset 对象在执行数据插入或是数据更新的过程中,会依次在跳表和哈希表中插入或更新相应的数据,从而保证了跳表和哈希表中记录的信息一致。

typedef struct zset {
    dict *dict;
    zskiplist *zsl;
} zset;

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

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

Zset 对象要同时保存「元素」和「元素的权重」,对应到跳表节点结构里就是 sds 类型的 ele 变量和 double 类型的 score 变量。每个跳表节点都有一个后向指针(struct zskiplistNode *backward),指向前一个节点,目的是为了方便从跳表的尾节点开始访问节点,这样倒序查找时很方便。

跳表是一个带有层级关系的链表,而且每一层级可以包含多个节点,每一个节点通过指针连接起来,实现这一特性就是靠跳表节点结构体中的zskiplistLevel 结构体类型的 level 数组

1.5.2 常用命令

ZADD key score member [[score member]...] 	
ZREM key member [member...] 
ZCARD key 	// 返回有序集合key中元素个数
ZINCRBY key increment member		// 为有序集合key中元素member的分值加上increment
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]

1.5.3 常用场景

  1. 排行榜: 有序集合比较典型的使用场景就是排行榜。例如学生成绩的排名榜、游戏积分排行榜、视频播放排名、电商系统中商品的销量排名等。

1.6 GEO

主要用于存储地理位置信息,并对存储的信息进行操作。

1.6.1 内部实现

GEO 本身并没有设计新的底层数据结构,而是直接使用了 ZSet 集合类型。GEO 类型使用 GeoHash 编码方法实现了经纬度到 Sorted Set 中元素权重分数的转换,这其中的两个关键机制就是「对二维地图做区间划分」和「对区间进行编码」。一组经纬度落在某个区间后,就用区间的编码值来表示,并把编码值作为 Sorted Set 元素的权重分数。

1.6.2 常用命令

# 存储指定的地理空间位置,可以将一个或多个经度(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]

1.6.3 常用场景

  1. 滴滴叫车
  2. 附近的人

1.7 HyperLogLog

是一种用于「统计基数」的数据集合类型,基数统计就是指统计一个集合中不重复的元素个数。但要注意,HyperLogLog 是统计规则是基于概率完成的,不是非常准确,标准误算率是 0.81%。

简单来说 HyperLogLog 提供不精确的去重计数

1.7.1 内部实现

1.7.2 常用命令

# 添加指定元素到 HyperLogLog 中
PFADD key element [element ...]

# 返回给定 HyperLogLog 的基数估算值。
PFCOUNT key [key ...]

# 将多个 HyperLogLog 合并为一个 HyperLogLog
PFMERGE destkey sourcekey [sourcekey ...]

1.7.3 常用场景

  1. 百万级网页UV(unique visitor)计数:在统计 UV 时,你可以用 PFADD 命令(用于向 HyperLogLog 中添加新元素)把访问页面的每个用户都添加到 HyperLogLog 中(PFADD page1:uv user1 user2 user3 user4 user5

    再用 PFCOUNT 命令直接获得 page1 的 UV 值了,这个命令的作用就是返回 HyperLogLog 的统计结果(PFCOUNT page1:uv)。

1.8 BitMap

即位图,是一串连续的二进制数组(0和1),可以通过偏移量(offset)定位元素。BitMap通过最小的单位bit来进行0|1的设置,表示某个元素的值或者状态,时间复杂度为O(1)。

由于 bit 是计算机中最小的单位,使用它进行储存将非常节省空间,特别适合一些数据量大且使用二值统计的场景

1.8.1 内部实现

Bitmap 本身是用 String 类型作为底层数据结构实现的一种统计二值状态的数据类型。

String 类型是会保存为二进制的字节数组,所以,Redis 就把字节数组的每个 bit 位利用起来,用来表示一个元素的二值状态,你可以把 Bitmap 看作是一个 bit 数组。

1.8.2 常用命令

SETBIT key offset value		// 设置值,其中value只能是0和1
GETBIT key offset				// 获取值
BITCOUNT key start end	// 	获取制定范围内值为1的个数
BITPOS [key] [value]		// 返回指定key中第一次出现指定value(0/1)的位置

1.8.3 常用场景

  1. 签到统计: 在签到打卡的场景中,我们只用记录签到(1)或未签到(0),所以它就是非常典型的二值状态。
  2. 判断用户登录态:只需要一个 key = login_status 表示存储用户登陆状态集合数据, 将用户 ID 作为 offset,在线就设置为 1,下线设置 0。通过 GETBIT判断对应的用户是否在线。
  3. 连续签到用户总数:
    • 我们把每天的日期作为 Bitmap 的 key,userId 作为 offset,若是打卡则将 offset 位置的 bit 设置成 1。
    • 一共有 7 个这样的 Bitmap,如果我们能对这 7 个 Bitmap 的对应的 bit 位做 『与』运算。同样的 UserID offset 都是一样的,当一个 userID 在 7 个 Bitmap 对应对应的 offset 位置的 bit = 1 就说明该用户 7 天连续打卡。
    • 结果保存到一个新 Bitmap 中,我们再通过 BITCOUNT 统计 bit = 1 的个数便得到了连续打卡 7 天的用户总数了。

1.9 Stream

在 Redis 5.0 Stream 没出来之前,消息队列的实现方式都有着各自的缺陷,例如:

  • 发布订阅模式,不能持久化也就无法可靠的保存消息,并且对于离线重连的客户端不能读取历史消息的缺陷;
  • List 实现消息队列的方式不能重复消费,一个消息消费完就会被删除,而且生产者需要自行实现全局唯一 ID。

Stream 类型也是此版本最重要的功能,用于完美地实现消息队列,它支持消息的持久化、支持自动生成全局唯一 ID、支持 ack 确认消息的模式、支持消费组模式等,让消息队列更加的稳定和可靠。

1.9.1 常用命令

XADD:插入消息,保证有序,可以自动生成全局唯一 ID;
XLEN :查询消息长度;
XREAD:用于读取消息,可以按 ID 读取数据;
XDEL : 根据消息 ID 删除消息;
DEL :删除整个 Stream;
XRANGE :读取区间消息
XREADGROUP:按消费组形式读取消息;
XPENDING 和 XACK:
XPENDING 命令可以用来查询每个消费组内所有消费者「已读取、但尚未确认」的消息;
XACK 命令用于向消息队列确认消息处理已完成;

针对 Redis 是否适合做消息队列,关键看你的业务场景:

  • 如果你的业务场景足够简单,对于数据丢失不敏感,而且消息积压概率比较小的情况下,把 Redis 当作队列是完全可以的。
  • 如果你的业务有海量消息,消息积压的概率比较大,并且不能接受数据丢失,那么还是用专业的消息队列中间件吧。

2. 持久化功能

2.1 AOF日志

​ Redis 每执行一条写操作命令,就把该命令以追加的方式写入到一个文件里,然后重启 Redis 的时候,先去读取这个文件里的命令,并且执行它,这不就相当于恢复了缓存数据了吗?这种写操作命令到日志的持久化方式,就是redis里的AOF(append only file)功能,读操作命令不会记录。re di s的AOF持久化功能默认不开启,需要到redis.conf文件里手动修改!

​ 【注意】Redis 是先执行写操作命令后,才将该命令记录到 AOF 日志里的,这么做其实有两个好处:

  • 避免额外的检查开销
  • 不会阻塞当前写操作命令的执行

2.1.1 写回策略

  1. Redis 执行完写操作命令后,会将命令追加到 server.aof_buf 缓冲区;
  2. 然后通过 write() 系统调用,将 aof_buf 缓冲区的数据写入到 AOF 文件,此时数据并没有写入到硬盘,而是拷贝到了内核缓冲区 page cache,等待内核将数据写入硬盘;
  3. 具体内核缓冲区的数据什么时候写入到硬盘,由内核决定。

Redis 提供了 3 种写回硬盘的策略,控制的就是上面说的第三步的过程。

  • Always: 每次写操作命令执行完后,同步将 AOF 日志数据写回硬盘;
  • Everysec:每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后每隔一秒将缓冲区里的内容写回到硬盘;
  • No:每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘。

这 3 种写回策略都无法能完美解决「主进程阻塞」和「减少数据丢失」的问题:

  • Always 策略的话,可以最大程度保证数据不丢失,但是由于它每执行一条写操作命令就同步将 AOF 内容写回硬盘,所以是不可避免会影响主进程的性能;
  • No 策略的话,是交由操作系统来决定何时将 AOF 日志内容写回硬盘,相比于 Always 策略性能较好,但是操作系统写回硬盘的时机是不可预知的,如果 AOF 日志内容没有写回硬盘,一旦服务器宕机,就会丢失不定数量的数据。
  • Everysec 策略的话,是折中的一种方式,避免了 Always 策略的性能开销,也比 No 策略更能避免数据丢失,当然如果上一秒的写操作命令日志没有写回到硬盘,发生了宕机,这一秒内的数据自然也会丢失。

redis八股文_第5张图片

  • Always 策略就是每次写入 AOF 文件数据后,就执行 fsync() 函数;
  • Everysec 策略就会创建一个异步任务来执行 fsync() 函数;
  • No 策略就是永不执行 fsync() 函数;

2.1.2 AOF重写机制

​ AOF 日志是一个文件,随着执行的写操作命令越来越多,文件的大小会越来越大;这时会带来性能问题,比如重启 Redis 后,需要读 AOF 文件的内容以恢复数据,如果文件过大,整个恢复的过程就会很慢;因此提供了 AOF 重写机制,当 AOF 文件的大小超过所设定的阈值后,Redis 就会启用 AOF 重写机制,来压缩 AOF 文件。

​ AOF 重写机制是在重写时,读取当前数据库中的所有键值对,然后将每一个键值对用一条命令记录到「新的 AOF 文件」,等到全部记录完后,就将新的 AOF 文件替换掉现有的 AOF 文件。

2.1.3 AOF后台重写

AOF日志重写的操作不能放在主进程里!

Redis 的重写 AOF 过程是由后台子进程 *bgrewriteaof* 来完成的,这个期间主进程可以继续处理命令请求。

redis八股文_第6张图片

  1. 主进程通过 fork 系统调用生成 bgrewriteaof 子进程,复制一份页表给子进程,页表记录着虚拟地址和物理地址的映射关系,不会复制物理内存。页表项的权限属性为只读,因此当父进程或子进程向内存发起写操作时会引起缺页中断,然后操作系统会在「缺页异常处理函数」里进行物理内存的复制,并重新设置其内存映射关系,将父子进程的内存读写权限设置为可读写,最后才会对内存进行写操作,这个过程被称为「写时复制(*Copy On Write*)」。

redis八股文_第7张图片

  1. 写时复制顾名思义,在发生写操作的时候,操作系统才会去复制物理内存,这样是为了防止 fork 创建子进程时,由于物理内存数据的复制时间过长而导致父进程长时间阻塞的问题。

  2. 但是还有两个阶段可能会导致父进程阻塞:

    • 创建子进程的途中,由于要复制父进程的页表等数据结构,阻塞的时间跟页表的大小有关,页表越大,阻塞的时间也越长;
    • 创建完子进程后,如果子进程或者父进程修改了共享数据,就会发生写时复制,这期间会拷贝物理内存,如果内存越大,自然阻塞的时间也越长;
  3. 这里有个问题: 重写 AOF 日志过程中,如果主进程修改了已经存在 key-value,此时这个 key-value 数据在子进程的内存数据就跟主进程的内存数据不一致了,这时要怎么办呢?

    解决办法:Redis 设置了一个 AOF 重写缓冲区,这个缓冲区在创建 bgrewriteaof 子进程之后开始使用;在重写 AOF 期间,当 Redis 执行完一个写命令之后,它会同时将这个写命令写入到 「AOF 缓冲区」和 「AOF 重写缓冲区」

    也就是说,在 bgrewriteaof 子进程执行 AOF 重写期间,主进程需要执行以下三个工作:

    • 执行客户端发来的命令;
    • 将执行后的写命令追加到 「AOF 缓冲区」;
    • 将执行后的写命令追加到 「AOF 重写缓冲区」;

    当子进程完成 AOF 重写工作后,会向主进程发送一条信号,信号是进程间通讯的一种方式,且是异步的。

    主进程收到该信号后,会调用一个信号处理函数,该函数主要做以下工作:

    • 将 AOF 重写缓冲区中的所有内容追加到新的 AOF 的文件中,使得新旧两个 AOF 文件所保存的数据库状态一致;
    • 新的 AOF 的文件进行改名,覆盖现有的 AOF 文件。

    信号函数执行完后,主进程就可以继续像往常一样处理命令了。

2.2 RDB快照

​ RDB 快照就是记录某一个瞬间的内存数据,记录的是实际数据,而 AOF 文件记录的是命令操作的日志,而不是实际的数据。
​ 因此在 Redis 恢复数据时, RDB 恢复数据的效率会比 AOF 高些,因为直接将 RDB 文件读入内存就可以,不需要像 AOF 那样还需要额外执行操作命令的步骤才能恢复数据。

2.2.1 快照怎么用

Redis 提供了两个命令来生成 RDB 文件,分别是 savebgsave,他们的区别就在于是否在「主线程」里执行:

  • 执行了 save 命令,就会在主线程生成 RDB 文件,由于和执行操作命令在同一个线程,所以如果写入 RDB 文件的时间太长,会阻塞主线程
  • 执行了 bgsave 命令,会创建一个子进程来生成 RDB 文件,这样可以避免主线程的阻塞

因此redis一般使用bgsave命令来生成RDB文件,相应的配置如下:

save 900 1	// 900 秒之内,对数据库进行了至少 1 次修改
save 300 10	// 300 秒之内,对数据库进行了至少 10 次修改
save 60 10000	// 60 秒之内,对数据库进行了至少 10000 次修改

缺点: 在服务器发生故障时,丢失的数据会比 AOF 持久化的方式更多,因为 RDB 快照是全量快照的方式,因此执行的频率不能太频繁,否则会影响 Redis 性能,而 AOF 日志可以以秒级的方式记录操作命令,所以丢失的数据就相对更少。

2.2.2 生成快照时,数据被修改

执行 bgsave 过程中,Redis 依然可以继续处理操作命令的,也就是数据是能被修改的。(通过写时复制)

这里和AOF日志不同的是,Redis 在使用 bgsave 快照过程中,如果主线程修改了内存数据,不管是否是共享的内存数据,RDB 快照都无法写入主线程刚修改的数据,因为此时主线程(父进程)的内存数据和子进程的内存数据已经分离了,子进程写入到 RDB 文件的内存数据只能是原本的内存数据。如果系统恰好在 RDB 快照文件创建完毕后崩溃了,那么 Redis 将会丢失主线程在快照期间修改的数据。

2.2.3 RDB 和 AOF 合体

混合持久化工作在 AOF 日志重写过程

​ 当开启了混合持久化时,在 AOF 重写日志时,fork 出来的重写子进程会先将与主线程共享的内存数据以 RDB 方式写入到 AOF 文件,然后主线程处理的操作命令会被记录在重写缓冲区里,重写缓冲区里的增量命令会以 AOF 方式写入到 AOF 文件,写入完成后通知主进程将新的含有 RDB 格式和 AOF 格式的 AOF 文件替换旧的的 AOF 文件。

也就是说,使用了混合持久化,AOF 文件的前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据

2.3 Redis大Key对持久化的影响

  1. 如果AOF 写回策略配置了 Always 策略,如果写入是一个大 Key,主线程在执行 fsync() 函数的时候,阻塞的时间会比较久,因为当写入的数据量很大的时候,数据同步到硬盘这个过程是很耗时的。

  2. AOF 重写机制和 RDB 快照(bgsave 命令)的过程,都会分别通过 fork() 函数创建一个子进程来处理任务。会有两个阶段会导致阻塞父进程(主线程):

    • 创建子进程的途中,由于要复制父进程的页表等数据结构,阻塞的时间跟页表的大小有关,页表越大,阻塞的时间也越长;
    • 创建完子进程后,如果父进程修改了共享数据中的大 Key,就会发生写时复制,这期间会拷贝物理内存,由于大 Key 占用的物理内存会很大,那么在复制物理内存这一过程,就会比较耗时,所以有可能会阻塞父进程。
  3. 客户端超时阻塞。由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。

  4. 引发网络阻塞。每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。

3. 过期删除和内存淘汰功能

3.1 过期删除

**定义:**Redis 是可以对 key 设置过期时间的,因此需要有相应的机制将已过期的键值对删除,而做这个工作的就是过期键值删除策略。

  1. 定时删除: 在设置key 的过期时间时,同时创建一个定时事件,当时间到达时,由事件处理器自动执行 key 的删除操作。

    • 优点:可以保证过期 key 会被尽快删除,也就是内存可以被尽快地释放;因此,定时删除对内存是最友好的。
    • 缺点:过期 key 比较多的情况下,删除过期 key 可能会占用相当一部分 CPU 时间,会对服务器的响应时间和吞吐量造成影响。所以,定时删除策略对 CPU 不友好。
  2. 惰性删除: 不主动删除过期键,每次从数据库访问 key 时,都检测 key 是否过期,如果过期则删除该 key。

    • 优点:每次访问时,才会检查 key 是否过期,所以此策略只会使用很少的系统资源,因此,惰性删除策略对 CPU 时间最友好。
    • 缺点:如果一个 key 已经过期,又仍然保留在数据库中,那么只要这个过期 key 一直没有被访问,它所占用的内存就不会释放,造成了一定的内存空间浪费。所以,惰性删除策略对内存不友好。
  3. 定期删除: 每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期key。

    • 优点:通过限制删除操作执行的时长和频率,来减少删除操作对 CPU 的影响,同时也能删除一部分过期的数据减少了过期键对空间的无效占用。
    • 缺点:难以确定删除操作执行的时长和频率。

3.2 Redis的过期删除策略

「惰性删除+定期删除」 配合使用!

3.2 内存淘汰

当 Redis 的运行内存已经超过 Redis 设置的最大内存之后,则会使用内存淘汰策略删除符合条件的 key,以此来保障 Redis 高效的运行。

3.2.1 LRU

全称是Least Recently Used,最近最久未使用,传统做法是:于「链表」结构,链表中的元素按照操作顺序从前往后排列,最新操作的键会被移动到表头,当需要内存淘汰时,只需要删除链表尾部的元素即可

Redis 是如何实现 LRU 算法的?

在 Redis 的对象结构体中添加一个额外的字段,用于记录此数据的最后一次访问时间

当 Redis 进行内存淘汰时,会使用随机采样的方式来淘汰数据,它是随机取 5 个值(此值可配置),然后淘汰最久没有使用的那个

3.2.2 LFU

全称是 Least Frequently Used,最近最少次使用,LFU 算法会记录每个数据的访问次数。当一个数据被再次访问时,就会增加该数据的访问次数。这样就解决了偶尔被访问一次之后,数据留存在缓存中很长一段时间的问题,相比于 LRU 算法也更合理一些。

Redis 是如何实现 LFU 算法的?

在 LFU 算法中,Redis对象头的 24 bits 的 lru 字段被分成两段来存储,高 16bit 存储 ldt(Last Decrement Time),低 8bit 存储 logc(Logistic Counter)。

redis八股文_第8张图片

  • ldt 是用来记录 key 的访问时间戳;
  • logc 是用来记录 key 的访问频次,它的值越小表示使用频率越低,越容易淘汰,每个新加入的 key 的logc 初始值为 5。

Redis 在访问 key 时,对于 logc 是这样变化的:

  1. 先按照上次访问距离当前的时长,来对 logc 进行衰减;
  2. 然后,再按照一定概率增加 logc 的值。

3.3 Redis的内存淘汰策略

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-oRIrL9BL-1667194793046)(/Users/bytedance/Desktop/自己总结八股pdf/截图/内存淘汰策略.webp)]

4. 缓存功能

4.1 缓存穿透、缓存击穿、缓存雪崩

4.1.1 缓存穿透

定义: 当用户访问的数据既不在内存也不在数据库的时候,导致缓存失效,进而大量的后续相同请求直接打到数据库上。

应对方案:

  • 非法请求限制,即在api入口处进行参数检查
  • 缓存空值或者默认值,针对查询的数据在缓存中设置空值或默认值
  • 使用布隆过滤器快速判断数据是否存在,避免通过查询数据库是否存在(redis自身也是支持布隆过滤器的)

4.1.2 缓存击穿

定义: 如果缓存中的某个热点数据过期了,此时大量的请求访问了该热点数据,导致缓存失效,直接访问数据库。

应对方案:

  • 互斥锁:保证同一时间只有一个业务线程更新缓存,未能获取互斥锁的请求要么等锁释放后重新读区缓存,要么就返回空值或默认值。
  • 不给热点数据设置过期时间,由后台异步更新缓存。

4.1.3 缓存雪崩

定义: 当大量缓存在同一时间过期或者Redis宕机故障时,此时大量的请求无法在redis中处理,会直接访问到数据库,导致数据库压力骤增,严重的话会造成数据库宕机,造成整个系统崩溃。

应对方案:

  • 均匀设置过期时间:对缓存数据设置过期时间时,给时间加上一个随机数
  • 互斥锁
  • 双key策略:一个是主 key,会设置过期时间,一个是备 key,不会设置过期
  • 后台更新缓存
  • 服务熔断或请求限流机制:暂停业务应用对缓存服务的访问,直接返回错误;或者只将少部分请求发送到数据库进行处理,再多请求就在入口处拒绝服务。
  • 构建Redis缓存高可用集群:主从节点

redis八股文_第9张图片

4.2 数据库和缓存如何保证一致性

  • 先更新数据库,再更新缓存
  • 先更新缓存,在更新数据库

但是以上两种方案都会存在并发一致性问题,即当两个请求并发更新同一条数据的时候,可能会出现缓存和数据库中不一致的现象。

解决办法: 更新数据库并删除缓存:在更新数据时,不更新缓存,而是删除缓存中的数据。然后,到读取数据时,发现缓存中没了数据之后,再从数据库中读取数据,更新到缓存中。(旁路缓存策略)

**兜底策略:**给缓存加上过期时间

写策略的步骤:

  • 更新数据库中的数据;
  • 删除缓存中的数据。

读策略的步骤:

  • 如果读取的数据命中了缓存,则直接返回数据;
  • 如果读取的数据没有命中缓存,则从数据库中读取数据,然后将数据写入到缓存,并且返回给用户。

这里还存在一个问题:更新数据库和删除缓存是两个操作,有可能出现第二个操作失败的情况,会导致缓存中的数据是旧值,而数据库中的数据是新值。

如何保证两个操作都能执行成功:1、重试机制 2、订阅MySQL binlog,在操作缓存。

5. 高可用功能

5.1 主从复制

redis八股文_第10张图片

5.2 分摊主服务器的压力

**问题:**我们知道主服务器是可以有多个从服务器的,如果从服务器数量过多,而且都与主服务器进行全量同步的话,会带来两个问题:

  • 由于是通过 bgsave 命令来生成 RDB 文件的,那么主服务器就会忙于使用 fork() 创建子进程,如果主服务器的内存数据非常大,在执行 fork() 函数时是会阻塞主线程的,从而使得 Redis 无法正常处理请求;
  • 传输 RDB 文件会占用主服务器的网络带宽,会对主服务器响应命令请求产生影响。

**解决:**设置经理服务器角色

redis八股文_第11张图片

5.3 增量复制

主从服务器在完成第一次同步后,就会基于长连接进行命令传播。如果因为网络故障断开连接后,又恢复链接,那么就要进行增量复制!

主要有三个步骤:

  • 从服务器在恢复网络后,会发送 psync 命令给主服务器,此时的 psync 命令里的 offset 参数不是 -1;
  • 主服务器收到该命令后,然后用 CONTINUE 响应命令告诉从服务器接下来采用增量复制的方式同步数据;
  • 然后主线程将主从服务器断线期间,所执行的写命令发送给从服务器,然后从服务器执行这些命令。

其中标记主从服务器复制位置的区域有两个:

  • repl_backlog_buffer,是一个「环形」缓冲区,用于主从服务器断连后,从中找到差异的数据;
  • replication offset,标记上面那个缓冲区的同步进度,主从服务器都有各自的偏移量,主服务器使用 master_repl_offset 来记录自己「」到的位置,从服务器使用 slave_repl_offset 来记录自己「」到的位置。

【注意】在主服务器进行命令传播时,不仅会将写命令发送给从服务器,还会将写命令写入到 repl_backlog_buffer 缓冲区里,因此 这个缓冲区里会保存着最近传播的写命令。

redis八股文_第12张图片

主服务器根据自己的 master_repl_offset 和 slave_repl_offset 之间的差距,然后来决定对从服务器执行哪种同步操作:

  • 如果判断出从服务器要读取的数据还在 repl_backlog_buffer 缓冲区里,那么主服务器将采用增量同步的方式;
  • 相反,如果判断出从服务器要读取的数据已经不存在 repl_backlog_buffer 缓冲区里,那么主服务器将采用全量同步的方式。

【注意】因为缓冲物是环形的,里面数据容易被覆盖;所以对于主服务器写入速度远超从服务器的情况下,可以适当调高repl_backlog_buffer的大小,减少被覆盖的概率。

5.4 总结

主从复制总共有三种模式:全量复制、基长链接命令传播、增量复制

  • 主从服务器第一次同步的时候,就是采用全量复制,此时主服务器会两个耗时的地方,分别是生成 RDB 文件和传输 RDB 文件。为了避免过多的从服务器和主服务器进行全量复制,引用经理服务器角色分摊主服务器的压力。

  • 第一次同步完成后,主从服务器都会维护着一个长连接,主服务器在接收到写操作命令后,就会通过这个连接将写命令传播给从服务器,来保证主从服务器的数据一致性。

  • 主从服务器网络恢复时,就会发生增量复制,不过也要看repl_backlog_buffer的大小;有可能发生「从服务器」想读的数据已经被覆盖了,那么这时就会导致主服务器采用全量复制的方式。

5.5 哨兵机制

在Redis的主从架构中,采用读写分离的主从模式;那么如果主节点挂了的话,就要选择一个从节点切换为主节点,然后让其它从节点指向新的主节点,同时通知上游连接Redis主节点的客户端,将其配置中的主节点IP更换为新主节点的IP地址。

哨兵(Sentinel)机制的作用就是实现主从节点故障转移,即上面的步骤。

哨兵节点主要负责三件事情:监控、选主、通知

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

​ 通过哨兵每隔一段时间(1秒)给所有主从节点的PING命令以及应答中,判断是否存在【主观下线】节点;然后和多个哨兵节点一起判断该【主观下线】节点是否是真的故障了,而不是由于系统压力过大或者网络用塞导致没在规定时间内响应,

5.5.2 主从故障转移过程

  • 第一步:在已下线主节点(旧主节点)属下的所有「从节点」里面,挑选出一个从节点,并将其转换为主节点(SLAVEOF no one命令)。(根据从节点的优先级、复制进度、ID 号
  • 第二步:让已下线主节点属下的所有「从节点」修改复制目标,修改为复制「新主节点」;
  • 第三步:将新主节点的 IP 地址和信息,通过「发布者/订阅者机制」通知给客户端;
  • 第四步:继续监视旧主节点,当这个旧主节点重新上线时,将它设置为新主节点的从节点;

5.5.3 哨兵集群如何组成

哨兵节点之间是通过 Redis 的发布者/订阅者机制来相互发现的。在主从集群中,主节点上有一个名为__sentinel__:hello的频道,哨兵 A 把自己的 IP 地址和端口的信息发布到__sentinel__:hello 频道上,哨兵 B 和 C 订阅了该频道。那么此时,哨兵 B 和 C 就可以从这个频道直接获取哨兵 A 的 IP 地址和端口号。然后,哨兵 B、C 可以和哨兵 A 互相建立网络连接。

redis八股文_第13张图片

主节点知道所有「从节点」的信息,所以哨兵会每 10 秒一次的频率向主节点发送 INFO 命令来获取所有「从节点」的信息,再与从节点建立连接,并进行监控;哨兵BC也是如此。

redis八股文_第14张图片

6.Redis常见面试题

6.1 Redis是单线程吗

Redis 单线程指的是「接收客户端请求->解析请求 ->进行数据读写等操作->发送数据给客户端」这个过程是由一个线程(主线程)来完成的,这也是我们常说 Redis 是单线程的原因。

​ 但是,Redis 程序并不是单线程的,Redis 在启动的时候,是会启动后台线程的,如下所示

  • BIO_CLOSE_FILE,关闭文件任务队列:当队列有任务后,后台线程会调用 close(fd) ,将文件关闭;
  • BIO_AOF_FSYNC,AOF刷盘任务队列:当 AOF 日志配置成 everysec 选项后,主线程会把 AOF 写日志操作封装成一个任务,也放到队列中。当发现队列有任务后,后台线程会调用 fsync(fd),将 AOF 文件刷盘,
  • BIO_LAZY_FREE,lazy free 任务队列:当队列有任务后,后台线程会 free(obj) 释放对象 / free(dict) 删除数据库所有对象 / free(skiplist) 释放跳表对象;

redis八股文_第15张图片

6.2 Redis的多线程

​ 随着网络硬件的性能提升,Redis 的性能瓶颈有时会出现在网络 IO 的处理上,也就是说,单个主线程处理网络请求的速度跟不上底层网络硬件的速度。Redis采用多个 IO 线程来处理网络IO请求,提高网络请求处理的并行度;对于读写命令的执行,Redis 仍然使用单线程来处理。

​ 阶段一:服务端和客户端建立 Socket 连接,并分配处理线程

​ 阶段二:IO 线程读取并解析请求

​ 阶段三:主线程执行请求操作

redis八股文_第16张图片

阶段四:IO 线程回写 Socket 和主线程清空全局队列

redis八股文_第17张图片

6.3 Redis集群脑裂导致数据丢失怎么办

定义: Redis因为是一主多从的架构模式,有可能因为网络原因导致主节点和从节点数据同步暂停,但是主节点和客户端通信正常,还再向主节点读写数据,但是这些数据此时无法同步给从节点;与此同时哨兵机制选举出了一个新主节点,这时集群就出现了两个主节点—脑裂现象。

问题:如果网络突然好了,哨兵机制因为选举出了新主节点,会把原来的旧主节点降级为从节点,然后从节点会向新的主节点请求数据同步;因为第一次同步会使用全量同步的方式,从节点会先把自己的数据清空,在执行同步,所以之前客户端在这个从节点写入的数据就会丢失。

解决方案: 当主节点发现从节点下线或者通信超时的总数量小于阈值时,那么禁止主节点进行写数据,直接把错误返回给客户端。

在 Redis 的配置文件中有两个参数我们可以设置:

  • min-slaves-to-write x,主节点必须要有至少 x 个从节点连接,如果小于这个数,主节点会禁止写数据。
  • min-slaves-max-lag x,主从数据复制和同步的延迟不能超过 x 秒,如果超过,主节点会禁止写数据。

你可能感兴趣的:(redis,数据库)