0%

Redis数据库

0概述一下你认识的Redis?

Redis本质上是一个Key-Value类型的内存数据库,很像memcached,整个数据库统统加载 在内存当中进行操作,定期通过异步操作把数据库数据flush到硬盘上进行保存。 因为是纯内存操作,Redis的性能非常出色,每秒可以处理超过 10万次读写操作,是已知性能 最快的Key-Value DB。 Redis的出色之处不仅仅是性能,Redis最大的魅力是支持保存多种数据结构,此外单个value 的最大限制是1GB,不像 memcached只能保存1MB的数据,因此Redis可以用来实现很多有用的功能。比方说用他的List来做FIFO双向链表,实现一个轻量级的高性 能消息队列服务,用他的Set可 以做高性能的tag系统等等。 另外Redis也可以对存入的Key-Value设置expire时间,因此也可以被当作一 个功能加强版的 memcached来用。 Redis的主要缺点是数据库容量受到物理内存的限制,不能用作海量数据 的高性能读写,因此Redis适合的场景主要局限在较小数据量的高性能操作和运算上。

0.1 Redis 与其他 key - value 缓存产品有以下三个特点

  • Redis支持数据的持久化,可以将内存中的数据保存在磁盘中,重启的时候可以再次加载进行使用。
  • Redis不仅仅支持简单的key-value类型的数据,同时还提供list,set,zset,hash等数据结构的存储。
  • Redis支持数据的备份,即master-slave模式的数据备份。

0.2 Redis 优势

  • 性能极高 。Redis能读的速度是110000次/s,写的速度是81000次/s 。
  • 丰富的数据类型。 Redis支持二进制案例的 Strings, Lists, Hashes, Sets 及 Ordered Sets数据类型操作。
  • 操作的原子性。 Redis的所有操作都是原子性的,意思就是要么成功执行要么失败完全不执行。单个操作是原子性的。多个操作也支持事务,即原子性,通过MULTI和EXEC指令包起来。
  • 丰富的特性。Redis还支持 publish/subscribe, 通知, key 过期等等特性

1. Redis数据类型

Redis提供了String,Hash,List,Set,Zset五种数据类型。

1.1 String

String数据结构是最简单的key-value类型,value不仅可以是String,也可以是数字,包括整数,浮点数和二进制数。

string 数据结构是简单的 key-value 类型。虽然 Redis 是用 C 语言写的,但是 Redis 并没有使用 C 的字符串表示,而是自己构建了一种 简单动态字符串(simple dynamic string,SDS)。相比于 C 的原生字符串:

  • Redis 的 SDS 不光可以保存文本数据还可以保存二进制数据
  • 并且获取字符串长度复杂度为 O(1)(C 字符串为 O(N))
  • 除此之外,Redis 的 SDS API 是安全的,不会造成缓冲区溢出。因为 SDS 在拼接字符串之前会检查 SDS 空间是否满足要求,如果空间不够会自动扩容,所以不会导致缓冲区溢出的问题

主要的应用有:缓存,计数(比如用户的访问次数、热点文章的点赞转发数量等等),共享session和限速。

  • int:8个字节的长整型
  • embstr:小于等于39个字节的字符串(一次内存分配操作来保存redisObject和SDS,因此只需释放一次)
  • raw:大于39个字节的字符串(调用两次内存分配操作来保存redisObject和SDS,因此需释放两次)

各个指令的时间复杂度

  • SET:为一个 key 设置 value,可以配合 EX/PX 参数指定 key 的有效期,通过 NX/XX 参数针对 key 是否存在的情况进行区别操作,时间复杂度 O(1)
  • GET:获取某个 key 对应的 value,时间复杂度 O(1)
  • GETSET:为一个 key 设置 value,并返回该 key 的原 value,时间复杂度 O(1)
  • MSET:为多个 key 设置 value,时间复杂度 O(N)
  • MSETNX:同 MSET,如果指定的 key 中有任意一个已存在,则不进行任何操作,时间复杂度 O(N)
  • MGET:获取多个 key 对应的 value,时间复杂度 O(N)
  • INCR:将 key 对应的 value 值自增1,并返回自增后的值。只对可以转换为整型的 String 数据起作用。时间复杂度 O(1)
  • INCRBY:将 key 对应的 value 值自增指定的整型数值,并返回自增后的值。只对可以转换为整型的 String 数据起作用。时间复杂度 O(1)
  • DECR/DECRBY:同 INCR/INCRBY,自增改为自减。

1.1.1 string类型的应用

缓存对象

使用 String 来缓存对象有两种方式:

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

因为 Redis 处理命令是单线程,所以执行命令的过程是原子的。因此 String 数据类型适合计数场景,比如计算访问次数、点赞、转发、库存数量等等。

1
2
3
4
5
6
7
8
9
10
11
12
# 初始化文章的阅读量
> SET aritcle:readcount:1001 0
OK
#阅读量+1
> INCR aritcle:readcount:1001
(integer) 1
#阅读量+1
> INCR aritcle:readcount:1001
(integer) 2
# 获取对应文章的阅读量
> GET aritcle:readcount:1001
"2"

分布式锁

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

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

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

例如用户一的 Session 信息被存储在服务器一,但第二次访问时用户一被分配到服务器二,这个时候服务器并没有用户一的 Session 信息,就会出现需要重复登录的问题,问题在于分布式系统每次会把请求随机分配到不同的服务器。

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

1.2 Hash

Hash是一个string类型的fieldvalue的映射表,hash特别适合用于存储对象,后续操作的时候,可以直接仅仅修改这个对象某个字段的值。比如可以用hash数据结构来存储用户信息,商品信息等。

1
2
3
4
5
6
7
key = JavaUser
value = {
"id":1,
"name":"xiaoming",
"age": 22,
"location": "GuangDong,Jieyang"
}

主要应用有:将关系型数据库每一行数据存储为一个哈希键

内部编码主要:

  • ziplist(压缩列表):当哈希类型元素个数小于hash-max-ziplist-entries配置(默认512个字节),同时所有值小于hash-max-ziplist-value配置(默认64个字节)时,使用ziplist作为内部实现,ziplist使用更加紧凑的结构实现多个元素的连续存储,在节省内存方面更加优秀
  • hashtable(哈希表):当哈希类型无法满足ziplist的条件时,使用hashtable作为内部实现,因为此时ziplist读写效率会下降,而hashtable读写时间复杂度为O(1)

1.2.1 各个指令的时间复杂度

与 Hash 相关的常用命令:

  • HSET:将 key 对应的 Hash 中的 field 设置为 value。如果该 Hash 不存在,会自动创建一个。时间复杂度 O(1)
  • ***:返回指定 Hash 中 field 字段的值,时间复杂度 O(1)
  • HMSET/HMGET:同 HSET 和 HGET,可以批量操作同一个 key 下的多个 field,时间复杂度:O(N),N为一次操作的 field 数量
  • HSETNX:同 HSET,但如 field 已经存在,HSETNX 不会进行任何操作,时间复杂度 O(1)
  • HEXISTS:判断指定Hash中 field 是否存在,存在返回1,不存在返回0,时间复杂度 O(1)
  • HDEL:删除指定 Hash 中的 field(1个或多个),时间复杂度:O(N),N 为操作的 field 数量
  • HINCRBY:同 INCRBY 命令,对指定 Hash 中的一个 field 进行 INCRBY,时间复杂度 O(1)

应谨慎使用的Hash相关命令:

  • HGETALL:返回指定 Hash 中所有的 field-value 对。返回结果为数组,数组中 field 和 value 交替出现。时间复杂度 O(N)
  • HKEYS/HVALS:返回指定 Hash 中所有的 field/value,时间复杂度 O(N)
  • 上述三个命令都会对 Hash 进行完整遍历,Hash中的 field 数量与命令的耗时线性相关,对于尺寸不可预知的 Hash,应严格避免使用上面三个命令,而改为使用 HSCAN 命令进行游标式的遍历

1.2.2应用场景

缓存对象

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

购物车

以用户 idkey,商品 idfield,商品数量为 value,恰好构成了购物车的3个要素

1.3 List

list就是链表,Redis中list的应用场景非常多,也是Redis最重要的数据结构之一

list的实现是一个双向链表,既可以支持反向查找和遍历,更方便操作,不过带来了部分额外的内存开销。

另外可以通过lrange,就是从某个元素开始读取多少个元素,可以基于list实现分页查询,基于 redis实现简单的高性能分页,可以做类似微博那种下拉不断分页的东西(一页一页的往下走),性能高。

主要的应用有:栈、队列,消息队列(抢购),文章列表等

内部编码有:

  • ziplist(压缩列表)当哈希类型元素个数小于list-max-ziplist-entries配置(默认512),同时所有值小于list-max-ziplist-value配置(默认64)时,使用ziplist作为内部实现,ziplist使用更加紧凑的结构实现多个元素的连续存储,在节省内存方面更加优秀
  • linkedlist(链表):当列表类型无法满足ziplist条件时,使用链表作为内部实现

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

1.3.1 各个指令的时间复杂度

  • LPUSH:向指定List的左侧(即头部)插入 1 个或多个元素,返回插入后的List 长度。时间复杂度O(N)N为插入元素的数量
  • RPUSHLPUSH,向指定List的右侧(即尾部)插入 1 或多个元素
  • LPOP从指定List的左侧(即头部)移除一个元素并返回,时间复杂度 O(1)
  • RPOPLPOP,从指定 List 的右侧(即尾部)移除 1 个元素并返回
  • LPUSHX/RPUSHX:与 LPUSH/RPUSH 类似,区别在于,LPUSHX/RPUSHX 操作的 key 如果不存在,则不会进行任何操作
  • BLPOP key timeout:从key列表表头弹出一个元素,没有就阻塞timeout秒,如果timeout=0则一直阻塞
  • BRPOP key timeout:从key列表尾巴弹出一个元素,没有就阻塞timeout秒,如果timeout=0则一直阻塞`
  • LLEN:返回指定List的长度,时间复杂度 O(1)
  • LRANGE:返回指定 List 中指定范围的元素(双端包含,即 LRANGE key 0 10会返回 11 个元素),时间复杂度 O(N)。应尽可能控制一次获取的元素数量,一次获取过大范围的 List 元素会导致延迟,同时对长度不可预知的List,避免使用 LRANGE key 0 -1这样的完整遍历操作。

应谨慎使用的List相关命令:

  • LINDEX:返回指定List指定 index 上的元素,如果index 越界,返回nilindex数值是回环的,即 -1代表 List 最后一个位置,-2代表List倒数第二个位置。时间复杂度 O(N)
  • LSET:将指定 List指定 index 上的元素设置为 value,如果 index 越界则返回错误,时间复杂度 O(N),如果操作的是头/尾部的元素,则时间复杂度为O(1)
  • LINSERT:向指定 List 中指定元素之前/之后插入一个新元素,并返回操作后的 List 长度。如果指定的元素不存在,返回-1。如果指定key 不存在,不会进行任何操作,时间复杂度 O(N)

由于Redis 的 List 是链表结构的,上述的三个命令的算法效率较低,需要对 List进行遍历,命令的耗时无法预估,在List长度大的情况下耗时会明显增加,应谨慎使用。

1.3.2 应用场景

消息队列

消息队列在存取消息时,必须要满足三个需求,分别是消息保序、处理重复的消息和保证消息可靠性,Redis的List可以保证这三个需求:

  • 满足消息保序List 本身就是按先进先出的顺序对数据进行存取的,所以,如果使用 List 作为消息队列保存消息的话,就已经能满足消息保序的需求了。List 可以使用LPUSH + RPOP(命令实现消息队列。

  • 处理重复的消息: 可以使用一个全局ID来区分消息,但List 并不会为每个消息生成 ID 号,所以我们需要自行为每个消息生成一个全局唯一ID,生成之后,我们在用 LPUSH 命令把消息插入 List 时,需要在消息中包含这个全局唯一 ID。

    1
    2
    *把一条全局 ID 为 111000102、库存量为 99 的消息插入了消息队列
    LPUSH mq "111000102:stock:99"

  • 保证消息可靠性:当消费者程序从 List 中读取一条消息后,List 就不会再留存这条消息了.所以,如果消费者程序在处理消息的过程出现了故障或宕机,就会导致消息没有处理完成,那么,消费者程序再次启动后,就没法再次从 List 中读取消息了。为了留存消息,List 类型提供了BRPOPLPUSH 命令,这个命令的作用是让消费者程序从一个 List 中读取消息,同时,Redis 会把这个消息再插入到另一个 List(可以叫作备份 List)留存。

redis消息队列的缺点:List 不支持多个消费者消费同一条消息,因为一旦消费者拉取一条消息后,这条消息就从 List 中删除了,无法被其它消费者再次消费。

1.4 Set

集合(set)可以保存多个字符串元素,但是不允许有重复元素,并且集合中的元素是无序的,一个集合最多可以存储2^32-1个元素,集合可以进行内部的增删改查和多个集合取交集,并集,差集。

主要的应用有:标签,生成随机数(抽奖),社交需求(共同好友,粉丝等等)

内部编码主要有:

  • ** intset(整数集合)**:当集合中的元素都是整数而且元素个数小于set-max-intset-entries配置(默认512个)时,使用该编码减少内存的使用
  • hashtable(哈希表):其它条件下使用哈希表作为内部实现

1.4.1 各个指令的时间复杂度

  • SADD:向指定 Set 中添加 1 个或多个 member,如果指定 Set 不存在,会自动创建一个。时间复杂度 O(N),N 为添加的 member 个数
  • SREM:从指定 Set 中移除 1 个或多个 member,时间复杂度 O(N),N 为移除的 member 个数
  • SRANDMEMBER:从指定 Set 中随机返回 1 个或多个 member,时间复杂度 O(N),N 为返回的 member 个数
  • SPOP:从指定 Set 中随机移除并返回 count 个 member,时间复杂度 O(N),N 为移除的 member 个数
  • SCARD:返回指定 Set 中的 member 个数,时间复杂度 O(1)
  • SISMEMBER:判断指定的 value 是否存在于指定 Set 中,时间复杂度 O(1)
  • SMOVE:将指定 member 从一个 Set 移至另一个 Set

慎用的Set相关命令:

  • SMEMBERS:返回指定 Hash 中所有的 member,时间复杂度 O(N)
  • SUNION/SUNIONSTORE:计算多个 Set 的并集并返回/存储至另一个 Set 中,时间复杂度 O(N),N 为参与计算的所有集合的总 member 数
  • SINTER/SINTERSTORE:计算多个 Set 的交集并返回/存储至另一个 Set 中,时间复杂度 O(N),N 为参与计算的所有集合的总 member 数
  • SDIFF/SDIFFSTORE:计算 1 个 Set 与 1 或多个 Set 的差集并返回/存储至另一个 Set 中,时间复杂度 O(N),N 为参与计算的所有集合的总 member 数

上述几个命令涉及的计算量大,应谨慎使用,特别是在参与计算的 Set 尺寸不可知的情况下,应严格避免使用。可以考虑通过 SSCAN 命令遍历获取相关 Set 的全部 member,如果需要做并集/交集/差集计算,可以在客户端进行,或在不服务实时查询请求的 Slave 上进行

1.4.2 应用场景

集合的主要几个特性,无序、不可重复、支持并交差等操作。因此 Set 类型比较适合用来数据去重和保障数据的唯一性,还可以用来统计多个集合的交集、错集和并集等,当我们存储的数据是无序并且需要去重的情况下,比较适合使用集合类型进行存储。

但是要提醒你一下,这里有一个潜在的风险。Set 的差集、并集和交集的计算复杂度较高,在数据量较大的情况下,如果直接执行这些计算,会导致 Redis 实例阻塞。

在主从集群中,为了避免主库因为 Set 做聚合计算(交集、差集、并集)时导致主库被阻塞,我们可以选择一个从库完成聚合统计,或者把数据返回给客户端,由客户端来完成聚合统计

点赞

Set 类型可以保证一个用户只能点一个赞,这里举例子一个场景,key 是文章id,value 是用户id。 uid:1uid:2uid:3 三个用户分别对 article:1 文章点赞了。

1
2
3
4
5
6
7
8
9
# uid:1 用户对文章 article:1 点赞
> SADD article:1 uid:1
(integer) 1
# uid:2 用户对文章 article:1 点赞
> SADD article:1 uid:2
(integer) 1
# uid:3 用户对文章 article:1 点赞
> SADD article:1 uid:3
(integer) 1
uid:1 取消了对 article:1 文章点赞。
1
2
> SREM article:1 uid:1
(integer) 1
获取 article:1 文章所有点赞用户 :
1
2
3
> SMEMBERS article:1
1) "uid:3"
2) "uid:2"
获取 article:1 文章的点赞用户数量:
1
2
> SCARD article:1
(integer) 2

共同关注

set 类型支持交集运算,所以可以用来计算共同关注的好友、公众号等。key 可以是用户id,value 则是已关注的公众号的id。

uid:1 用户关注公众号 id5、6、7、8、9uid:2 用户关注公众号 id7、8、9、10、11

1
2
3
4
5
6
# uid:1 用户关注公众号 id 为 5、6、7、8、9
> SADD uid:1 5 6 7 8 9
(integer) 5
# uid:2 用户关注公众号 id 为 7、8、9、10、11
> SADD uid:2 7 8 9 10 11
(integer) 5
uid:1 和 uid:2 共同关注的公众号:
1
2
3
4
5
# 获取共同关注
> SINTER uid:1 uid:2
1) "7"
2) "8"
3) "9"

1.5 ZSet

有序集合(zset)保留集合元素不能重复的特性,但是有序集合中的元素可以排序,它为每一个元素设定一个score作为排序的依据

应用:排行榜系统,用户点赞。需要对数据根据某个权重进行排序的场景。比如在直播系统中,实时排行信息包含直播间在线用户列表,各种礼物排行榜,弹幕消息(可以理解为按消息维度的消息排行榜)等信息。

内部编码实现:

  • ziplist(压缩列表):当哈希类型元素个数小于zset-max-ziplist-entries配置(默认128个),同时所有值小于zset-max-ziplist-value配置(默认64)时,使用ziplist作为内部实现,ziplist使用更加紧凑的结构实现多个元素的连续存储,在节省内存方面更加优秀。
  • skiplist(跳表):当ziplist条件不满足时,有序集合会使用skiplist作为内部实现,因为此时ziplist的读写效率会下降

1.5.1 各个指令的时间复杂度

  • ZADDZADD key score member [[score member]...]
  • ZREMZREM key member [member...]
  • ZCOUNT:返回指定 Sorted Set 中指定 score 范围内的 member 数量,时间复杂度:O(log(N))
  • ZCARD:返回指定 Sorted Set 中的 member 数量,时间复杂度 O(1)
  • ZSCORE:返回指定 Sorted Set 中指定 member 的 score,时间复杂度 O(1),ZSCORE key member
  • ZRANK/ZREVRANK:返回指定 member 在 Sorted Set 中的排名,ZRANK 返回按升序排序的排名,ZREVRANK 则返回按降序排序的排名。时间复杂度 O(log(N))
  • ZINCRBY:同 INCRBY,对指定 Sorted Set 中的指定 member 的 score 进行自增,时间复杂度 O(log(N))

慎用的Sorted Set相关命令:

  • ZRANGE/ZREVRANGE:返回指定 Sorted Set 中指定排名范围内的所有 member,ZRANGE 为按 score 升序排序,ZREVRANGE 为按 score 降序排序,时间复杂度 O(log(N)+M),M为本次返回的 member 数
  • ZRANGEBYSCORE/ZREVRANGEBYSCORE:返回指定 Sorted Set 中指定 score 范围内的所有 member,返回结果以升序/降序排序,min 和 max 可以指定为 -inf和+ inf,代表返回所有的 member。时间复杂度 O(log(N)+M)
  • ZREMRANGEBYRANK/ZREMRANGEBYSCORE:移除 Sorted Set 中指定排名范围/指定 score 范围内的所有 member。时间复杂度 O(log(N)+M)

上述几个命令,应尽量避免传递[0 -1][-inf +inf]这样的参数,来对 Sorted Set 做一次性的完整遍历,特别是在 Sorted Set 的尺寸不可预知的情况下。可以通过 ZSCAN 命令来进行游标式的遍历,或通过 LIMIT 参数来限制返回 member 的数量(适用于 ZRANGEBYSCORE 和 ZREVRANGEBYSCORE 命令),以实现游标式的遍历。

1.5.2 应用场景

Zset 类型(Sorted Set,有序集合) 可以根据元素的权重来排序,我们可以自己来决定每个元素的权重值。比如说,我们可以根据元素插入 Sorted Set 的时间确定权重值,先插入的元素权重小,后插入的元素权重大。

在面对需要展示最新列表、排行榜等场景时,如果数据更新频繁或者需要分页显示,可以优先考虑使用 Sorted Set

排行榜

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

我们以博文点赞排名为例,小林发表了五篇博文,分别获得赞为200、40、100、50、150

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# arcticle:1 文章获得了200个赞
> ZADD user:xiaolin:ranking 200 arcticle:1
(integer) 1
# arcticle:2 文章获得了40个赞
> ZADD user:xiaolin:ranking 40 arcticle:2
(integer) 1
# arcticle:3 文章获得了100个赞
> ZADD user:xiaolin:ranking 100 arcticle:3
(integer) 1
# arcticle:4 文章获得了50个赞
> ZADD user:xiaolin:ranking 50 arcticle:4
(integer) 1
# arcticle:5 文章获得了150个赞
> ZADD user:xiaolin:ranking 150 arcticle:5
(integer) 1
文章 arcticle:4 新增一个赞,可以使用 ZINCRBY 命令(为有序集合key中元素member的分值加上increment):
1
2
> ZINCRBY user:xiaolin:ranking 1 arcticle:4
"51"
获取小林文章赞数最多的 3 篇文章,可以使用 ZREVRANGE 命令(倒序获取有序集合 key 从start下标到stop下标的元素):
1
2
3
4
5
6
7
8
# WITHSCORES 表示把 score 也显示出来
> ZREVRANGE user:xiaolin:ranking 0 2 WITHSCORES
1) "arcticle:1"
2) "200"
3) "arcticle:5"
4) "150"
5) "arcticle:3"
6) "100"

在大约1亿个玩家中那只需要前100名的具体排序,其他玩家只用给一个范围,如何实现 - 可以使用redis的有序集合zset实现:Redis 的有序集合非常适合这种场景。你可以将玩家的分数作为成员(member),分数作为分数(score),然后将它们存储在有序集合中。Redis 会自动根据分数对成员进行排序。 - 获取前100名:使用 ZREVRANGE 命令可以很容易地获取分数最高的前100名玩家。这个命令会返回指定范围内的成员和它们的分数。 - 对于其他玩家,你可能不需要知道他们的具体排名或分数,而只需要知道他们所处的分数段或排名范围。这可以通过几种方式来实现: - ** 使用 ZCOUNT 命令统计分数段内的玩家数量:你可以使用 ZCOUNT 命令来统计某个分数段内的玩家数量。例如,要统计分数在 min_score 和 max_score 之间的玩家数量,你可以使用:ZCOUNT key min_score max_score - 近似估算:**对于非常大的数据集,你可能不需要精确的排名范围,而只需要一个大致的估计。在这种情况下,你可以使用Redis的近似算法或数据结构(如HyperLogLog)来估算总玩家数量或分数分布,从而得出一个近似的排名范围。

1.6 BitMap

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

1.6.1 内部实现

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

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

1.6.2 基本操作

1
2
3
4
5
6
7
8
9
# 设置值,其中value只能是 01
SETBIT key offset value

# 获取值
GETBIT key offset

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

1.6.3 应用场景

Bitmap 类型非常适合二值状态统计的场景,这里的二值状态就是指集合元素的取值就只有 0 和 1 两种,在记录海量数据时,Bitmap 能够有效地节省内存空间。

签到统计

在签到打卡的场景中,我们只用记录签到(1)或未签到(0),所以它就是非常典型的二值状态。

签到统计时,每个用户一天的签到用 1 个 bit 位就能表示,一个月(假设是 31 天)的签到情况用 31 个 bit 位就可以,而一年的签到也只需要用 365 个 bit 位,根本不用太复杂的集合类型。

假设我们要统计 ID 100 的用户在 2022 年 6 月份的签到情况,就可以按照下面的步骤进行操作。

第一步,执行下面的命令,记录该用户 6 月 3 号已签到。

1
SETBIT uid:sign:100:202206 2 1
第二步,检查该用户 6 月 3 日是否签到。
1
GETBIT uid:sign:100:202206 2 
第三步,统计该用户在 6 月份的签到次数。
1
BITCOUNT uid:sign:100:202206

1.7 GEO

Redis GEO 是 Redis 3.2 版本新增的数据类型,主要用于存储地理位置信息,并对存储的信息进行操作

在日常生活中,我们越来越依赖搜索“附近的餐馆”、在打车软件上叫车,这些都离不开基于位置信息服务(Location-Based Service,LBS)的应用。LBS 应用访问的数据是和人或物关联的一组经纬度信息,而且要能查询相邻的经纬度范围,GEO 就非常适合应用在 LBS 服务的场景中

1.7.1 内部实现

GEO 本身并没有设计新的底层数据结构,而是直接使用了 Sorted Set 集合类型。

GEO 类型使用GeoHash编码方法实现了经纬度到 Sorted Set 中元素权重分数的转换,这其中的两个关键机制就是「对二维地图做区间划分」和「对区间进行编码」。一组经纬度落在某个区间后,就用区间的编码值来表示,并把编码值作为 Sorted Set 元素的权重分数。

1.7.2 常用命令

1
2
3
4
5
6
7
8
9
10
11
# 存储指定的地理空间位置,可以将一个或多个经度(longitude)、纬度(latitude)、位置名称(member)添加到指定的 key 中。
GEOADD key longitude latitude member [longitude latitude member ...]

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

# 返回两个给定位置之间的距离。m米,km千米
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.7.3 应用场景

滴滴叫车

这里以滴滴叫车的场景为例,介绍下具体如何使用 GEO 命令:GEOADD 和 GEORADIUS 这两个命令。

假设车辆 ID 是 33,经纬度位置是(116.034579,39.030452),我们可以用一个 GEO 集合保存所有车辆的经纬度,集合 key 是 cars:locations。

执行下面的这个命令,就可以把 ID 号为 33 的车辆的当前经纬度位置存入 GEO 集合中:

1
GEOADD cars:locations 116.034579 39.030452 33

当用户想要寻找自己附近的网约车时,LBS 应用就可以使用 GEORADIUS 命令。LBS 应用执行下面的命令时,Redis 会根据输入的用户的经纬度信息(116.054579,39.030452 ),查找以这个经纬度为中心的 5 公里内的车辆信息,并返回给 LBS 应用。

1
GEORADIUS cars:locations 116.054579 39.030452 5 km ASC COUNT 10

1.8 Stream

Stream是Redis 专门为消息队列设计的数据类型。在 Redis 5.0 Stream 没出来之前,消息队列的实现方式都有着各自的缺陷,例如:

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

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

1.8.1 常见命令

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

2. 为什么要用redis/为什么要用缓存

主要从“高性能”和“高并发”这两个点来看待这个问题

高性能:

Redis中的数据是存储在内存中的,所以读写速度非常快。假如用户第一次访问数据库中的某些数据,这个过程会比较慢,因为是从硬盘上读取的。将该用户访问的数据存在缓存中,这样下一次再访问这些数据的时候就可以直接从缓存中获取了。操作缓存就是直接操作内存,所以速度相当快。如果数据库中的对应数据改变之后,同步改变缓存中相应的数据即可。

高并发:

一般像 MySQL 这类的数据库的 QPS 大概都在 1w 左右(4 核 8g) ,但是使用 Redis 缓存之后很容易达到 10w+,甚至最高能达到 30w+(就单机 redis 的情况,redis 集群的话会更高)。

QPS(Query Per Second):服务器每秒可以执行的查询次数;

直接操作缓存能够承受的请求是远远大于直接访问数据库的,所以可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库

使用Redis的好处有哪些? 1、访问速度快,因为数据存在内存中,类似于Java中的HashMap或者C++中的哈希表(如unordered_map/unordered_set),这两者的优势就是查找和操作的时间复杂度都是O(1)

2、数据类型丰富,支持String,list,set,sorted set,hash这五种数据结构

3、支持事务,Redis中的操作都是原子性,换句话说就是对数据的更改要么全部执行,要么全部不执行,这就是原子性的定义

4、特性丰富:Redis可用于缓存,消息,按key设置过期时间,过期后将会自动删除。

3. Redis的数据怎么存储在内存中(内存这么有限,怎么存储的)

4. 为什么使用redis而不直接在程序中使用map做缓存?

缓存分为本地缓存和分布式缓存,使用语言自带得map实现的是本地缓存,最主要得特点是轻量以及快速,生命周期随着该实例的销毁而结束,并且在多实例的情况下,每个实例都需要各自保存一份缓存,缓存不具有一致性。

使用redis或memcached之类的称为分布式缓存,在多实例的情况下,各实例共用一份缓存数据,缓存具有一致性。缺点是需要保持redis或memcached服务的高可用,整个程序架构上较为复杂。

5. 4.0前的redis的线程模型

总览:

  • 4.0以前纯单线程

  • 4.0以及之后增加了执行的删除指令的多线程

  • 6.0后为克服网络IO带来的瓶颈,在IO阶段使用多线程,socket的变化和命令的执行都是主线程复杂的

Redis 基于 Reactor 模式来设计开发了自己的一套高效的事件处理模型 。redis内部使用文件事件处理器file event handler,这个文件事件处理器是单线程的,所以redis才叫做单线程的模型。

它采用IO多路复用机制同时监听多个socket,它会将感兴趣的事件及类型(读、写)注册到内核中并监听每个事件是否发生。根据socket上的事件来选择对应的事件处理器进行处理。

这样的好处非常明显: I/O 多路复用技术的使用让 Redis 不需要额外创建多余的线程来监听客户端的大量连接,降低了资源的消耗(和 NIO 中的 Selector 组件很像)。

另外, Redis 服务器是一个事件驱动程序,服务器需要处理两类事件:

  • 文件事件;
  • 时间事件。

时间事件不需要多花时间了解,我们接触最多的还是 文件事件(客户端进行读取写入等操作,涉及一系列网络通信)。

Redis 设计与实现》有一段话是如是介绍文件事件的:

Redis 基于 Reactor 模式开发了自己的网络事件处理器:这个处理器被称为文件事件处理器(file event >handler)。文件事件处理器使用 I/O 多路复用(multiplexing)程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器。

当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关 闭(close)等操作时,与操作相对应的文件事件就会产生,这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。

虽然文件事件处理器以单线程方式运行,但通过使用 I/O 多路复用程序来监听多个套接字,文件事件处理器既实现了高性能的网络通信模型,又可以很好地与 Redis 服务器中其他同样以单线程方式运行的模块进行对接,这保持了 Redis 内部单线程设计的简单性。

文件事件处理器的结构包含4各部分:

  • 多个socket
  • IO 多路复用程序
  • 文件事件分派器
  • 事件处理器(连接应答处理器,命令请求处理器、命令回复处理器)

多个socket可能会并发产生不同的操作,每个操作对应不同的文件事件,但是IO多路服用程序会监听多个socket,会将socket产生的事件放入队列中排队,事件分派器每次从队列中取出一个事件,把该事件交给对应的事件处理器进行处理。

客户端与redis的一次通信过程如下:

客户端socket01redisserver socket请求建立连接,此时server socket会产生一个AE_READBLE事件,IO多路复用程序监听到server socket产生的事件后,将该事件压入队列中。文件事件分派器从队列中获取该事件,交给连接应答处理器。连接应答处理器会创建一个能与客户端通信的socket01,并将该socket01AE_READBLE事件与命令请求处理器相关联。

假设此时客户端发送了一个set key value请求,此时redissocket01会产生AE_READABLE事件,IO多路复用程序将事件压入队列,此时事件分派器将事件交给命令请求处理器来处理。命令请求处理器读取socket01中的key value并在自己内存中完成key value的设置。操作完成后,它会将socket01AE_WRITABLE事件与命令回复处理器相关联。

如果此时客户端准备好接收返回结果了,那么redis中的socket01会产生一个AE_WRITABLE事件,同样压入队列中,事件分派器找到相关联的的命令回复处理器,由命令回复处理器对socket01输入本次操作的一个结果,比如ok,之后解除socket01AE_WRITABLE事件与命令回复处理器的关联。

这就完成了一次通信。

单线程的Redis为什么这么快? 主要是有三个原因: 1、Redis的全部操作都是纯内存的操作; 2、Redis采用单线程,有效避免了频繁的上下文切换; 3、采用了非阻塞I/O多路复用机制。

6. Redis 使用单线程的原因

Redis 从一开始就选择使用单线程模型处理来自客户端的绝大多数网络请求,这种考虑其实是多方面的,其中最重要的几个原因如下:

  • 使用单线程模型能带来更好的可维护性,方便开发和调试;
  • 使用单线程模型也能并发的处理客户端的请求;
  • Redis 服务中运行的绝大多数操作的性能瓶颈都不是 CPU;

上述三个原因中的最后一个是最终使用单线程模型的决定性因素,其他的两个原因都是使用单线程模型额外带来的好处,在这里按顺序介绍上述的几个原因。

6.1 可维护性

可维护性对于一个项目来说非常重要,如果代码难以调试和测试,问题也经常难以复现,这对于任何一个项目来说都会严重地影响项目的可维护性。多线程模型虽然在某些方面表现优异,但是它却引入了程序执行顺序的不确定性,代码的执行过程不再是串行的,多个线程同时访问的变量如果没有谨慎处理就会带来诡异的问题。

如果计算机中的两个进程(线程同理)同时尝试修改一个共享内存的内容,在没有并发控制的情况下,最终的结果依赖于两个进程的执行顺序和时机,如果发生了并发访问冲突,最后的结果就会是不正确的。

引入了多线程,就必须要同时引入并发控制来保证在多个线程同时访问数据时程序行为的正确性,这就需要工程师额外维护并发控制的相关代码,例如,会需要在可能被并发读写的变量上增加互斥锁。

在访问这些变量或者内存之前也需要先对获取互斥锁,一旦忘记获取锁或者忘记释放锁就可能会导致各种诡异的问题,管理相关的并发控制机制也需要付出额外的研发成本和负担。

6.2 并发处理

使用单线程模型也并不意味着程序不能并发的处理任务,Redis 虽然使用单线程模型处理用户的请求,但是它却使用 I/O 多路复用机制并发处理来自客户端的多个连接,同时等待多个连接发送的请求。

在 I/O 多路复用模型中,最重要的函数调用就是 select 以及类似函数,该方法的能够同时监控多个文件描述符(也就是客户端的连接)的可读可写情况,当其中的某些文件描述符可读或者可写时,select 方法就会返回可读以及可写的文件描述符个数。

使用 I/O 多路复用技术能够极大地减少系统的开销,系统不再需要额外创建和维护进程和线程来监听来自客户端的大量连接,减少了服务器的开发成本和维护成本。

6.3 性能瓶颈

这个就是 Redis 选择单线程模型的决定性原因 —— 多线程技术能够帮助我们充分利用 CPU 的计算资源来并发的执行不同的任务,但是 CPU 资源往往都不是 Redis 服务器的性能瓶颈。哪怕在一个普通的 Linux 服务器上启动 Redis 服务,它也能在 1s 的时间内处理 1,000,000 个用户请求。

如果这种吞吐量不能满足我们的需求,更推荐的做法是使用分片的方式将不同的请求交给不同的 Redis 服务器来处理,而不是在同一个 Redis 服务中引入大量的多线程操作。

简单总结一下,Redis 并不是 CPU 密集型的服务,如果不开启 AOF 备份,所有 Redis 的操作都会在内存中完成不会涉及任何的计算机的 I/O 操作,这些数据的读写由于只发生在内存中,所以处理速度是非常快的;整个服务的瓶颈在于读写网络数据,也就是网络 I/O,因此对于命令处理只需要主线程参与就可以,真正多线程的是在于网络IO处

多线程网络IO带来的问题: - 多个线程对同一个客户端请求进行读取,带来数据的不保序问题, - 同时假如一个线程读取发现返回的ret=0,说明客户端关闭了连接,那么服务器也就执行关闭,但此时另一个线程正好对改连续对读取,完了,服务器报错了。(致命)

  • 如何解决: Redis使用了队列+主线程阻塞的措施,在主线程通过epoll对链接的监听,不断向队列添加可读socket,然后当队列到达一定或者一定时间后,主线程自旋阻塞等待,并行I/O线程组将所有可读socket读完放入全局队列,完成后主线程按顺序取出处理

多线程虽然会更充分地利用 CPU 资源,但是操作系统上线程的切换也不是免费的,线程切换其实会带来额外的开销,其中包括:

  • 保存线程 1 的执行上下文;
  • 加载线程 2 的执行上下文;

频繁的对线程的上下文进行切换可能还会导致性能地急剧下降,这可能会导致不仅没有提升请求处理的平均速度,反而进行了负优化,所以这也是为什么 Redis 对于使用多线程技术非常谨慎。

7 Redis 多线程

7.1 Redis 4.0

虽然说 Redis 是单线程模型,但是, 实际上,Redis 在 4.0 之后的版本中就已经加入了对多线程的支持。

不过,Redis 4.0 增加的多线程主要是针对一些大键值对的删除操作的命令,使用这些命令就会使用主处理之外的其他线程来“异步处理”。

Redis 在最新的几个版本中加入了一些可以被其他线程异步处理的删除操作,例如 UNLINK、FLUSHALL ASYNC、FLUSHDB ASYNC等非阻塞的删除操作。为什么会需要这些删除操作,而它们为什么需要通过多线程的方式异步处理?

7.1.1 删除操作多线程的原因

可以在 Redis 在中使用 DEL 命令来删除一个键对应的值,如果待删除的键值对占用了较小的内存空间,那么哪怕是同步地删除这些键值对也不会消耗太多的时间。

是对于 Redis 中的一些超大键值对,几十 MB 或者几百 MB 的数据并不能在几毫秒的时间内处理完,Redis 可能会需要在释放内存空间上消耗较多的时间,这些操作就会阻塞待处理的任务,影响 Redis 服务处理请求的 PCT99 和可用性。

然而释放内存空间的工作其实可以由后台线程异步进行处理,这也就是 UNLINK 命令的实现原理,它只会将键从元数据中删除,真正的删除操作会在后台异步执行。

大体上来说,Redis 6.0 之前主要还是单线程处理。

7.2 Redis6.0 之后引入了多线程

引入多线程的原因:

Redis将所有数据放在内存中,内存的响应时长大约为100纳秒,对于小数据包,Redis服务器可以处理80,000到100,000 QPS,这也是Redis处理的极限了,对于80%的公司来说,单线程的Redis已经足够使用了。

但随着越来越复杂的业务场景,有些公司动不动就上亿的交易量,因此需要更大的QPS。常见的解决方案是在分布式架构中对数据进行分区并采用多个服务器,但该方案有非常大的缺点,例如要管理的Redis服务器太多,维护代价大;某些适用于单个Redis服务器的命令不适用于数据分区;数据分区无法解决热点读/写问题;数据偏斜,重新分配和放大/缩小变得更加复杂等等。

从Redis自身角度来说,因为读写网络的read/write系统调用占用了Redis执行期间大部分CPU时间,瓶颈主要在于网络的 IO 消耗, 优化主要有两个方向:

  • 提高网络 IO 性能,典型的实现比如使用 DPDK 来替代内核网络栈的方式
  • 使用多线程充分利用多核,典型的实现比如 Memcached。

协议栈优化的这种方式跟 Redis 关系不大,支持多线程是一种最有效最便捷的操作方式。所以总结起来,redis支持多线程主要就是两个原因:

  • 可以充分利用服务器 CPU 资源,目前主线程只能利用一个核
  • 多线程任务可以分摊 Redis 同步 IO 读写负荷

Redis6.0 引入多线程主要是为了提高网络 IO 读写性能,因为这个算是 Redis 中的一个性能瓶颈(Redis 的瓶颈主要受限于内存和网络)。 虽然,Redis6.0 引入了多线程,但是 Redis 的多线程只是在网络数据的读写这类耗时操作上使用了, 执行命令仍然是单线程顺序执行。因此,也不需要担心线程安全问题。

Redis6.0 的多线程默认是禁用的,只使用主线程。如需开启需要修改 redis 配置文件 redis.conf :

1
>io-threads-do-reads yes
开启多线程后,还需要设置线程数,否则是不生效的。同样需要修改 redis 配置文件 redis.conf :
1
>io-threads 4 #官网建议4核的机器建议设置为2或3个线程,8核的建议设置为6个线程
关于线程数的设置,官方有一个建议:4核的机器建议设置为2或3个线程,8核的建议设置为6个线程,线程数一定要小于机器核数。还需要注意的是,线程数并不是越大越好,官方认为超过了8个基本就没什么意义了。

7.3 Redis 多线程实现机制

轮询调度算法(Round-Robin Scheduling)轮询调度算法的原理是每一次把来自用户的请求轮流分配给内部中的服务器,从1开始,直到N (内部服务器个数),然后重新开始循环。

流程简述如下:

  1. 主线程负责接收建立连接请求,并监听可读socket,将可读 socket 放入全局等待读处理队列

  2. 队列慢满或者一定时间后,将这些连接分配给这些 IO 线程组,IO线程组与这些Socket绑定并且并行执行IO

  3. 主线程阻塞等待 IO 线程读取 socket 完毕,IO线程将这些请求读取并解析请求,放入队列

  4. 主线程通过单线程的按序取队列方式执行串行化执行请求命令,然后依次放入全局等待写处理队列。

  5. 主线程阻塞等待 IO 线程将数据回写 socket 完毕

  6. 解除绑定

该设计有如下特点:

  • IO 线程要么同时在读 socket,要么同时在写,不会同时读或写

  • IO 线程只负责并行读写 socket和解析命令,不负责命令执行,执行由主线程处理

8 Memcached与Redis的区别都有哪些?

8.1 共同点

  • 都是基于内存的数据库,一般都用来当做缓存使用。
  • 都有过期策略。
  • 两者的性能都非常高。

8.2 区别

  • Redis支持更丰富的数据类型(支持更复杂的应用场景):Redis不仅仅支持简单的k/v类型的数据,同时还提供list,hash,set,zset等数据结构的存储。memcached支持简单数据类型String(k/v)。
  • Redis支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而Memcached把数据全部存在内存之中
  • Redis 有灾难恢复机制。 因为可以把缓存中的数据持久化到磁盘上。
  • Redis 在服务器内存使用完之后,可以将不用的数据放到磁盘上。但是,Memcached 在服务器内存使用完之后,就会直接报异常。
  • 集群模式:memcached没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;但是redis目前是原生支持cluster模式的
  • Memcached是多线程的,非阻塞IO复用的网络模型;Redis使用单线程的多路复用IO模型(Redis 6.0 引入了多线程 IO )
  • Redis 支持发布订阅模型、Lua 脚本、事务等功能,而 Memcached 不支持。并且,Redis 支持更多的编程语言。
  • Memcached过期数据的删除策略只用了惰性删除,而 Redis 同时使用了惰性删除与定期删除。

Redis比Memcached的优势在哪里? 1、Memcached所有的值均是简单字符串,Redis作为其替代者,支持更为丰富的数据类型

2、Redis 的速度比 Memcached 快很多

3、Redis可以做到持久化数据

9 Redis为什么要给缓存数据设置过期时间

一般情况下,设置保存的缓存数据的时候都会设置一个过期时间。

因为内存是有限的,如果缓存中的所有数据都是一直保存的话,分分钟直接Out of memory

Redis 自带了给缓存数据设置过期时间的功能,比如:

1
2
3
4
5
6
127.0.0.1:6379> exp key  60 # 数据在 60s 后过期
(integer) 1
127.0.0.1:6379> setex key 60 value # 数据在 60s 后过期 (setex:[set] + [ex]pire)
OK
127.0.0.1:6379> ttl key # 查看数据还有多久过期
(integer) 56
>注意:Redis中除了字符串类型有自己独有设置过期时间的命令 setex外,其他方法都需要依靠 expire 命令来设置过期时间 。另外, persist 命令可以移除一个键的过期时间

过期时间除了有助于缓解内存的消耗,还有什么其他用么?

很多时候,我们的业务场景就是需要某个数据只在某一时间段内存在,比如我们的短信验证码可能只在1分钟内有效,用户登录的 token 可能只在 1 天内有效。

如果使用传统的数据库来处理的话,一般都是自己判断过期,这样更麻烦并且性能要差很多。

10 Redis 判断数据过期的原理

Redis 通过一个叫做过期字典(可以看作是hash表)来保存数据过期的时间。过期字典的键指向Redis数据库中的某个key(键),过期字典的值是一个long long类型的整数,这个整数保存了key所指向的数据库键的过期时间(毫秒精度的UNIX时间戳)。

过期字典是存储在redisDb结构里的:

1
2
3
4
5
6
7
typedef struct redisDb {
...

dict *dict; //数据库键空间,保存着数据库中所有键值对
dict *expires // 过期字典,保存着键的过期时间
...
} redisDb;

11 redis过期键处理方式

Redis中有个设置时间过期的功能,即对存储在 redis 数据库中的值可以设置一个过期时间。作为一个缓存数据库,这是非常实用的。如一般项目中的token或者一些登录信息,尤其是短信验证码都是有时间限制的,按照传统的数据库处理方式,一般都是自己判断过期,这样无疑会严重影响项目性能。

set key的时候,都可以给一个expire time,就是过期时间,通过过期时间可以指定这个key可以存活的时间。

Redis对过期的键采用的删除方式是:定期删除+惰性删除

  • 定期删除:redis默认是每隔100ms就随机抽取一些设置了过期时间的key,检查其是否过期,如果过期就删除。注意这里是随机抽取的。采用随机抽取的方式是因为如果Redis存了很多key的话,每隔100ms就遍历所有的设置过期时间的key的话,就会给CPU带来很大的负载。
  • 惰性删除:定期删除可能会导致很多过期key到了时间并没有被删除掉。所以就有了惰性删除。对于过期的key,如果过了时间还没有被定期删除,还停留在内存中,只有在系统中查询一下这个key,redis才会把它给删除掉,这就是所谓的惰性删除。

但是仅仅通过设置过期时间还是有问题的。如果定期删除漏掉了很多过期key,然后也没及时去查,也就没走惰性删除,此时会有大量过期key堆积在内存里,导致redis内存块耗尽了。redis采用内存淘汰机制进行处理。

12 redis内存淘汰机制(MySQL中有2000w数据,Redis中只存了20w数据,如何保证Redis中的数据都是热点数据?)

可以使用Redis的数据淘汰策略,Redis 内存数据集大小上升到一定大小的时候,就会施行这种策略。具体说来,主要有 6种内存淘汰策略:

  • olatile-lru:从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰
  • volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰
  • volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰
  • allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的key(这个是最常用的)
  • allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
  • no-eviction:禁止驱逐数据,也就是说当内存不足以容纳新写入的数据时,新写入操作会报错。

另一种问法:定期和惰性一定能保证删除数据吗?如果不能,Redis会有什么应对措施?

4.0版本以后增加了以下两种:

  • volatile-lfu:从已设置过期时间的数据集(server.db[i].expires)中挑选最不经常使用的数据淘汰
  • allkeys-lfu:当内存不足以容纳新写入数据时,在键空间中,移除最不经常使用的key

13 缓存中常说的热点数据和冷数据是什么?

其实就是名字上的意思,热数据就是访问次数较多的数据,冷数据就是访问很少或者从不访问的数据。

需要注意的是只有热点数据,缓存才有价值 对于冷数据而言,大部分数据可能还没有再次访问到就已经被挤出内存,不仅占用内存,而且价值不大。

数据更新前至少读取两次,缓存才有意义。这个是最基本的策略,如果缓存还没有起作用就失效了,那就没有太大价值了。

14 Redis持久化机制

Redis是一个支持持久化的内存数据库,通过持久化机制把内存中的数据同步到硬盘文件来保证数据持久化。当Redis重启后通过把硬盘文件重新加载到内存,就能达到恢复数据的目的。

很多时候我们需要持久化数据也就是将内存中的数据写入到硬盘里面,大部分原因是为了之后重用数据(比如重启机 器、机器故障之后回复数据),或者是为了防止系统故障而将数据备份到一个远程位置。

实现机理:单独创建fork()一个子进程,将当前父进程的数据库数据复制到子进程的内存中,然后由子进程写入到临时文件中,持久化的过程结束了,再用这个临时文件替换上次的快照文件,然后子进程退出,内存释放

Redis支持两种持久化方案,分别是RDB(快照)和AOF(只追加文件)

14.1 快照持久化(RDB持久化)

Redis可以通过创建快照RDB来获得存储在某个时间点上数据的副本。RDB就是是一个紧凑压缩的二进制文件,代表Redis在某个时间点上的一个数据快照。同时因为RDB保存在磁盘上,所以即使Redis服务器进程退出,只要RDB存在,就能够还原数据库状态。因此非常适用于备份,全量复制等场景。

Redis创建快照之后,可以对快照进行备份,可以将快照复制到其他服务器从而创建具有相同数据的服务器副本(Redis主从结构,主要用来提高Redis性能),还可以将快照留在原地以便重启服务器的时候使用。快照持久化是Redis默认采用的持久化方式。

14.1.1 RDB文件结构

  • redis:5字节,保存"REDIS"五个字符,以便在载入文件时,快速检查载入文件是否为RDB文件,5字节长度。
  • db_version:存储字符串形式整数,指示RDB文件的版本号,4字节长度。
  • databases:包含零个或多个数据库,以及各个数据库中的键值对数据。
    • 如果服务器的数据库状态为空,那么这部分为空。
    • 非空,那么这个部分会根据数据库所保存的键值对数量、类型和内容不同来开辟空间。

14.1.2 RDB文件的创建及自动触发

创建RDB文件的任务由rdb.c/rdbSave函数完成,Redis有两种命令来生成RDB文件,分别是savabgsave,这两个命令以不同形式调用rdb.c/rdbSave

  • save:由主线程执行生成RDB操作,因此会阻塞当前Redis,直到RDB过程完成,对于内存比较大的实例会造成阻塞,已经被淘汰
  • bgsave:Redis主线程进行执行fork操作创建子进程,RDB持久化过程由子进程完成,完成后自动结束,阻塞只发生在fork阶段,一般时间很短。

自动触发

  • 使用save相关配置,会自动出发bgsave,在redis.conf配置文件中默认有此下配置:

    1
    2
    3
    save 900 1           #在900秒(15分钟)之后,如果至少有1个key发生变化,Redis就会自动触发BGSAVE命令创建快照。
    save 300 10 #在300秒(5分钟)之后,如果至少有10个key发生变化,Redis就会自动触发BGSAVE命令创建快照。
    save 60 10000 #在60秒(1分钟)之后,如果至少有10000个key发生变化,Redis就会自动触发BGSAVE命令创建快照。

  • 如果从节点执行全量复制操作,主节点自动执行bgsave生成RDB文件并发送给从节点
  • 执行debug reload命令时重新加载Redis时,也会自动触发save操作
  • 默认情况下执行shutdown命令,如果没有开启AOF持久化功能则自动执行bgsave

自动触发流程:

  • 其设置会保存在redisSever结构的saveparams
  • 处理saveparams,redis服务器还维护一个dirty计数器,以及lastsave属性,dirty计数器记录数据库记录了离上一次savebgsave执行了多少次修改,lastsave是一个unix时间戳
  • Redis周期性的执行一个操作函数serverCron,其中一项工作就是检查savebgsave所设置的条件是否满足。满足则触发生成RDB操作。

14.1.3 SAVE执行时服务器状态

  • SAVE命令执行时,Redis服务会阻塞,所以当SAVE命令正在执行时,客户端发生的所有命令请求都会被拒绝。
  • save完成后,才可以处理客户端的下一条命令

14.1.4 BGSAVE执行是服务器状态

  • Redis为了避免产生条件禁止,禁止SAVE命令和BGSAVE命令同时调用rdb.c/rdbSave;因此在BGSAVE命令下,SAVEBGSAVE命令会被拒绝;
  • BGSAVE命令下,BGREWRITEAOF命令会被延迟到BGSAVE命令执行完毕后执行。虽然BGREWRITEAOFBGSAVE不会冲突,但两个子进程同时执行大量的磁盘写入操作,这显然不是一个好主意。(多线程下,数据安全要保证)

bgsave执行的流程如下:

  1. 执行bgsave命令,Redis父进程判断当前是否存在正在执行的子进程,如果RDB/AOF子进程存在则直接返回
  2. 父进程执行fork操作创建子进程,fork操作过程父进程会阻塞。(通过info stats查看latest_fork_usec选项,获得最近一个fork操作的耗时,单位为微秒)
  3. 父进程fork完成后,bgsave命令返回Background saving started信息并不再阻塞父进程,可以继续响应其他命令
  4. 子进程创建RDB文件,根据父进程内存生成的临时快照文件,完成后对原有文件进行原子替换,执行lastsave可以获取最后一次生成RDB的事件,对应info统计的rdb_last_save_time
  5. 进程发送信号给父进程表示完成,父进程更新统计信息,存放在infoPersistence下。

14.1.5 RDB持久化的优缺点

RDB模式的优点
  • RDB快照保存了某个时间点的数据,可以通过脚本执行redis指令bgsave(非阻塞,后台执行)或者save(会阻塞写操作,不推荐)命令自定义时间点备份,可以保留多个备份,当出现问题可以恢复到不同时间点的版本,很适合备份,并且此文件格式也支持有不少第三方工具可以进行后续的数据分析,并且能够把备份的数据导出到指定的文件下,其他redis重启进行加载。比如: 可以在最近的24小时内,每小时备份一次RDB文件,并且在每个月的每一天,也备份一个RDB文件。这样的话,即使遇上问题,也可以随时将数据集还原到不同的版本。

  • RDB可以最大化Redis的性能,父进程在保存 RDB文件时唯一要做的就是fork出一个子进程,然后这个子进程就会处理接下来的所有保存工作,父进程无须执行任何磁盘工/0操作。

  • 因为是直接数据恢复,不是操作恢复。因此RDB在大量数据,比如几个G的数据,恢复的速度比AOF的快

RDB模式的缺点
  • 不能实时保存数据,可能会丢失自上一次执行RDB备份到这一次还未达到条件备份但已发生部分修改的数据
  • 如果你需要尽量避免在服务器故障时丢失数据,那么RDB并不适合。虽然Redis允许设置不同的保存点(save point)来控制保存RDB文件的频率,但是,因为RDB文件需要保存整个数据集的状态,所以它并不是一个轻松快速的操作。因此一般会超过5分钟以上才保存一次RDB文件。在这种情况下,一旦发生故障停机,你就可能会丢失好几分钟的数据。

  • 当数据量非常大的时候,从父进程fork子进程进行保存至RDB文件时需要一点时间,可能是毫秒或者秒。因此在数据集比较庞大时,fork()可能会非常耗时,造成服务器在一定时间内停止处理客户端﹔如果数据集非常巨大,并且CPU时间非常紧张的话,那么这种停止时间甚至可能会长达整整一秒或更久。

14.2 AOF持久化

与RDB持久化通过保存数据库中的键值对来记录数据库的状态不同,AOF持久化是通过保存Redis服务器的写命令来记录数据库的状态。

以独立日志的方式记录每次写命令,将写命令添加到AOF 文件(Append Only File)的末尾。重启时再重新执行AOF文件中的命令达到恢复数据的目的。AOF的主要作用是解决数据持久化的实时性,因此已成为主流的持久化方案。

默认情况下Redis没有开启AOF方式的持久化,可以通过以下配置开启:

1
appendonly yes
开启AOF持久化后每执行一条会更改Redis中的数据的命令,Redis就会将该命令写入硬盘中的AOF文件。AOF文件的保存位置和RDB文件的位置相同,都是通过dir参数设置的,默认的文件名是appendonly.aof

14.2.1 AOF持久化的实现

AOF持久化的实现可以分为命令追加、文件写入、文件同步:

  • 写入命令(append):所有的写入命令都会追加到aof_buf缓冲区
  • 文件写入和同步:通过flushAppendOnlyFile函数考虑是否将aof_buf缓冲区的命令写入AOF文件,flushAppendOnlyFile的行为由服务器配置的appendfsync选项的值来决定;然后AOF缓冲区根据对应的策略向硬盘做同步操作:
    1
    2
    3
    appendfsync always    #每次有数据修改发生时都会写入AOF文件,这样会严重降低Redis的速度
    appendfsync everysec #每秒钟同步一次,显示地将多个写命令同步到硬盘
    appendfsync no #让操作系统决定何时进行同步
    • always:写入aof_buf后调用系统fsync操作同步到AOF文件,fsync完成后线程返回;每次写入都要进行文件同步,严重降低Redis速度,一般不建议使用
    • everysec:命令写入aof_buf后调用系统write操作,完成后线程返回。fsync同步文件操作由专门线程每秒调用一次;建议的策略,理论上在系统突然宕机的情况下会丢失1秒数据,fsync完成后会与上次fsync时间做对比,超过两秒后主线程阻塞,直到同步操作完成,因此最多可能丢失2秒数据,不是1秒
    • no:命令写入aof_buf后调用系统write操作,不对AOF文件做fsync同步,同步硬盘操作由操作系统负责,通常同步周期最长30秒,周期不可控,加大每次同步的数据量,虽然提升了性能,安全性无法保证
  • 重启加载(load):当Redis服务器重启时,可以加载AOF文件进行数据恢复

14.2.2 Redis 4.0 对于持久化机制的优化

尽管 RDB 比 AOF 的数据恢复速度快,但是快照的频率不好把握:

  • 如果频率太低,两次快照间一旦服务器发生宕机,就可能会比较多的数据丢失;
  • 如果频率太高,频繁写入磁盘和创建子进程会带来额外的性能开销。

那有没有什么方法不仅有 RDB 恢复速度快的优点和,又有 AOF 丢失数据少的优点呢?

Redis 4.0 开始支持 RDB 和 AOF 的混合持久化(默认关闭,可以通过配置项 aof-use-rdb-preamble 开启)。

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

这样的好处在于,

  • 重启 Redis 加载数据的时候,由于前半部分是 RDB 内容,这样加载的时候速度会很快。

  • 加载完 RDB 的内容后,才会加载后半部分的 AOF 内容,这里的内容是 Redis 后台子进程重写 AOF 期间,主线程处理的操作命令,可以使得数据更少的丢失。

14.2.3 AOF重写机制

随着命令不断写入AOF,文件会越来越大,如果不加以控制的话,使用AOF文化进行数据还原所需的时间会越来越多,因此Redis引入重写机制压缩文件体积,AOF文件重写是把Redis进程内的数据转化为写命令同步到新AOF文件的过程

AOF重写并不是对现有的AOF文件进行任何读取、分析和写入操作,而是通过读取服务器的当前数据库状态来实现的。其由重写程序aof_rewrite函数实现,因为这个函数会进行大量的写入操作,所以调用这个函数的线程将会长时间阻塞,因此redis将这个AOF重写程序放到子进程执行,即让服务器进程可以举行处理请求,同时避免使用锁的情况下保证数据安全性。

重写后AOF文件变小的原理:

  • 进程内已经超时的数据不再写入文件
  • 旧的AOF文件含有无效命令,如del keyhdel key2srem keysset a1set a2等,重写时使用进程内的数据直接生成,这样新的AOF文件只保留最终数据的写入命令
  • 多条写的命令合并为一条,如lpush list alpush list b转化为lpush list a b,为了防止过多造成客户端缓冲区溢出,以64个元素为界拆分多条

重写的优点:降低文件占用空间,更快的被Redis加载

AOF重写缓冲区

虽然AOF使用了子进程来执行重写程序,避免服务器父进程的阻塞,以及能在不使用锁前提下保证数据安全,但也有一种情况要考虑,在子进程重写AOF期间,服务器进程还需要继续处理客户端请求,新的命令可能会对数据库状态修改,从而使得服务器当前的数据库状态与重写的AOF所保存的数据库状态不一致

为解决该问题,提出了AOF重写缓冲区,这个缓冲区在服务器创建AOF子进程后开始使用,当Redis服务器执行完一个写命令后,它会同时将这个写命令发送给AOF缓冲区和AOF重写缓冲区,当子进程完成创建新AOF文件的工作之后,服务器会将重写缓冲区中的所有内容 追加到新AOF文件的末尾,使得新旧两个AOF文件所保存的数据库状态一致

重写过程的触发:
  • 手动触发:使用bgrewriteaof命令
  • 自动触发:配置文件配置auto-aof-rewrite-min-size,auto-aof-rewrite-percentage,前者表示AOF重写时文件最小体积,默认64MB,后者代表AOF文件空间(aof_current_size)和上一次重写后AOF文件空间(aof_base_size)的比值
重写流程
  1. 执行AOF重写请求,如果当前进程正在执行AOF重写,请求不执行;如果当前进程正在执行bgsave操作,重写命令延迟到bgsave完成之后再执行
  2. 父进程执行fork创建子进程,开销等同于bgsave
  3. (1).主进程fork操作完成后,继续响应其他命令,所有修改命令依然写入AOF缓冲区并根据appendfsync策略同步到硬盘,保证原有AOF机制正确性 (2).由于fork操作运用写时复制技术,子进程只能共享fork操作时的内部数据。由于父进程依然响应命令,Redis使用AOF重写缓冲区保证这部分新数据,防止新的AOF文件生成期间丢失这部分数据
  4. 子进程根据内存快照,按照命令合并规则写入到新的AOF文件,每次批量写入硬盘数据量由配置aof-rewrite-incremental-fsync控制,默认32MB,防止单次刷盘数据过多造成硬盘阻塞
  5. (1). 新AOF文件写入完成后,子进程发送信号给父进程,父进程更新统计信息 (2). 父进程把AOF重写缓冲区的数据写入到新的AOF文件 (3). 使用新的AOF文件替换老文件,重写完成

14.2.4 AOF模式的优缺点

AOF模式的优点
  • 可以提供实时性保存,数据安全性相对较高。根据所使用的fsync策略(fsync是同步内存中redis所有已经修改的文件到存储设备),默认是appendfsync everysec,即每秒执行一次 fsync,在这种配置下,Redis 仍然可以保持良好的性能,并且就算发生故障停机,也最多只会丢失一秒钟的数据( fsync会在后台线程执行,所以主线程可以继续努力地处理命令请求)

  • 由于该机制对日志文件的写入操作采用的是append模式,因此在写入过程中不需要seek, 即使出现宕机现象,也不会破坏日志文件中已经存在的内容。然而如果本次操作只是写入了一半数据就出现了系统崩溃问题,不用担心,在Redis下一次启动之前,可以通过 redis-check-aof 工具来解决数据一致性的问题

  • Redis可以在 AOF文件体积变得过大时,自动地在后台对AOF进行重写,重写后的新AOF文件包含了恢复当前数据集所需的最小命令集合。整个重写操作是绝对安全的,因为Redis在创建新 AOF文件的过程中,append模式不断的将修改数据追加到现有的 AOF文件里面,即使重写过程中发生停机,现有的 AOF文件也不会丢失。而一旦新AOF文件创建完毕,Redis就会从旧AOF文件切换到新AOF文件,并开始对新AOF文件进行追加操作。

  • AOF包含一个格式清晰、易于理解的日志文件用于记录所有的修改操作。事实上,也可以通过该文件完成数据的重建。AOF文件有序地保存了对数据库执行的所有写入操作,这些写入操作以Redis协议的格式保存,因此 AOF文件的内容非常容易被人读懂,对文件进行分(parse)也很轻松。

AOF模式的缺点
  • 即使有些操作是重复的也会全部记录,AOF 的文件大小要大于 RDB 格式的文件
  • AOF 在恢复大数据集时的速度比 RDB 的恢复速度要慢,重复操作过程,不是直接导入数据。
  • 根据fsync策略不同,AOF速度可能会慢于RDB
  • bug 出现的可能性更多

14.3 RDB和AOF 的选择

  • 如果主要充当缓存功能,或者可以承受数分钟数据的丢失, 通常生产环境一般只需启用RDB即可,此也是默认值

  • 如果数据需要持久保存,一点不能丢失,可以选择同时开启RDB和AOF

  • 一般不建议只开启AOF

15 缓存雪崩是什么,如何解决?

缓存雪崩指的是缓存同一时间大面积的失效,所以后面的请求都会落到数据库上,造成数据库短时间内承受大量的请求而崩掉。可以理解为由于原有缓存失效,新缓存未到期间(例如:我们设置缓存时采用了相同的过期时间,在同一时刻出现大面积的缓存过期),此时所有原本应该访问缓存的请求都去查询数据库了,这对数据库CPU和内存造成巨大压力,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃。

针对大量数据同时过期而引发的缓存雪崩问题,常见的应对方法有下面这几种:

  • 均匀设置过期时间:如果要给缓存数据设置过期时间,应该避免将大量的数据设置成同一个过期时间。我们可以在对缓存数据设置过期时间时,给这些数据的过期时间加上一个随机数,这样就保证数据不会在同一时间过期。
  • 互斥锁:当业务线程在处理用户请求时,如果发现访问的数据不在 Redis 里,就加个互斥锁,保证同一时间内只有一个请求来构建缓存,避免大量请求直接落到数据库上
  • 后台更新缓存:业务线程不再负责更新缓存,缓存也不设置有效期,而是让缓存“永久有效”,并将更新缓存的工作交由后台线程定时更新。

针对 Redis 故障宕机而引发的缓存雪崩问题,常见的应对方法有下面这几种:

  • 事前:尽量保证整个 Redis 集群的高可用性,发现机器宕机尽快补上,选择合适的内存淘汰策略。
  • 事中:本地ehcache缓存 + hystrix限流&降级,避免MySQL崩掉, 通过加锁或者队列来控制读数据库写缓存的线程数量。比如对某个key只允许一个线程查询数据和写缓存,其他线程等待。
  • 事后:利用 Redis 持久化机制保存的数据尽快恢复缓存

16 缓存穿透是什么,如何解决?

缓存穿透是指查询一个一定不存在的数据,由于缓存不命中,接着查询数据库也无法查询出结果,因此也不会写入到缓存中,这将会导致每个查询都会去请求数据库,造成缓存穿透。

举个例子:某个黑客故意制造我们缓存中不存在的 key 发起大量请求,导致大量请求落到数据库。

16.1 解决方法

最基本的就是首先做好参数校验,一些不合法的参数请求直接抛出异常信息返回给客户端。比如查询的数据库 id 不能小于 0、传入的邮箱格式不对的时候直接返回错误消息给客户端等等。

16.1.1 方法一:布隆过滤器

将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。

具体是这样做的:把所有可能存在的请求的值都存放在布隆过滤器中,当用户请求过来,先判断用户发来的请求的值是否存在于布隆过滤器中。不存在的话,直接返回请求参数错误信息给客户端,存在的话才会走下面的流程。即对所有可能查询的参数以hash形式存储,在控制层先进行校验,不符合则丢弃,从而避免了对底层存储系统的查询压力;

流程如下:

这里稍微科普一下布隆过滤器: >布隆过滤器是引入了k(k>1)k(k>1)个相互独立的哈希函数,保证在给定的空间、误判率>>下,完成元素判重的过程。 它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率和删除困难。 > >该算法的核心思想就是利用多个不同的Hash函数来解决“冲突”。Hash存在一个冲突(碰撞)的问题,用同一个Hash得到的两个URL的值有可能相同。为了减少冲突,我们可以多引入几个Hash,如果通过其中的一个Hash值我们得出某元素不在集合中,那么该元素肯定不在集合中。只有在所有的Hash函数告诉我们该元素在集合中时,才能确定该元素存在于集合中。这便是布隆过滤器的基本思想,一般用于在大数据量的集合中判定某元素是否存在。

16.1.2 方法二:缓存空对象

当存储层不命中后,即使返回的空对象也将其缓存起来,同时会设置一个过期时间,之后再访问这个数据将会从缓存中获取,保护了后端数据源;如果一个查询返回的数据为空(不管是数据不存 在,还是系统故障),我们仍然把这个空结果进行缓存,但它的过期时间会很短,最长不超过五分钟。

但是这种方法会存在两个问题:

  • 1、如果空值能够被缓存起来,这就意味着缓存需要更多的空间存储更多的键,因为这当中可能会有很多的空值的键;(对于黑客攻击来说,内存极有可能分分钟out of memory,因此对于空值键其过期时间应该短一些)

  • 2、即使对空值设置了过期时间,还是会存在缓存层和存储层的数据会有一段时间窗口的不一致,这对于需要保持一致性的业务会有影响。

我们可以从适用场景和维护成本两方面对这两汇总方法进行一个简单比较:

  • 适用场景:缓存空对象适用于数据命中不高但数据频繁变化且实时性较高 ;而布隆过滤器适用数据命中不高但数据相对固定即实时性较低

  • 维护成本:缓存空对象的方法代码维护简单但需要较多的缓存空间,而且数据会出现不一致的现象;布隆过滤器的代码维护较复杂但缓存空间要少一些

17 缓存预热

热数据就是访问次数较多的数据,冷数据就是访问很少或者从不访问的数据。只有热点数据,缓存才有价值,对于冷数据而言,大部分数据可能还没有再次访问到就已经被挤出内存,不仅占用内存,而且价值不大。

对于热点数据,我们希望缓存应该总是命中的,缓存预热是指系统上线后,将相关的缓存数据直接加载到缓存系统。这样就可以避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题。用户会直接查询事先被预热的缓存数据!

方法:

  • 直接写个缓存刷新页面,上线时手工操作下;
  • 数据量不大,可以在项目启动的时候自动进行加载;
  • 定时刷新缓存;

18. 缓存击穿是什么

缓存击穿,是指一个key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在一个屏障上凿开了一个洞。

比如常见的电商项目中,某些货物成为“爆款”了,可以对一些主打商品的缓存直接设置为永不过期。即便某些商品自己发酵成了爆款,也是直接设为永不过期就好了。

应对缓存击穿可以采取前面说到两种方案:

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

19 缓存降级是什么

当访问量剧增、服务出现问题(如响应时间慢或不响应)或非核心服务影响到核心流程的性能时,这时仍然需要保证服务还是可用的,即使是有损服务。此时系统可以根据一些关键数据进行自动降级,也可以配置开关实现人工降级。 降级的最终目的是保证核心服务可用,即使是有损的

服务降级的目的,是为了防止Redis服务故障,导致数据库跟着一起发生雪崩问题。因此,对于不重要的缓存数据,可以采取服务降级策略,例如一个比较常见的做法就是,Redis出现问题,不去数据库查询,而是直接返回默认值给用户。

可以参考日志级别设置预案: (1)一般:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级; (2)警告:有些服务在一段时间内成功率有波动(如在95~100%之间),可以自动降级或人工降级,并发送告警; (3)错误:比如可用率低于90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能承受的最大阀值,此时可以根据情况自动降级或者人工降级; (4)严重错误:比如因为特殊原因数据错误了,此时需要紧急人工降级。

20 如何解决Redis的并发竞争key问题

所谓Redis的并发竞争Key的问题也就是多个系统同时对一个Key进行操作,但是最后执行的顺序和期望的顺序不同,这样也就导致了结果的不同。

解决方案:可以使用分布式锁(Zookeeper和 redis 都可以实现分布式锁)。(如果不存在Redis的并发竞争Key问题,不要使用分布式锁,这样会影响性能)

基于zookeeper临时有序节点可以实现的分布式锁。大致思想为:

  • 每个客户端在尝试获取锁时,会在该方法指定节点的目录(如/lock)下,生成一个唯一的瞬时有序零时节点(/lock/locknode)。
  • 客户端创建的瞬时有序节点会按照创建的顺序排列。因此该客户端判断是否获取锁的方式很简单,只需要判断它的瞬时有序节点中序号是否为最小的一个,是则获得该key的锁,否则客户端会寻找比自己创建的节点序号小的下一个节点,并对它注册一个事件监听器,以便节点删除时获得通知。
  • 当释放锁的时候,zookeeper只需将这个瞬时节点删除即可(使用临时节点可以确保在客户端与 ZooKeeper 之间的连接断开时,临时节点会自动删除,避免了死锁的问题。),其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。

在实践中,当然是以可靠性为主,所以首推Zookeeper。

你要写入缓存的数据,都是从 mysql 里查出来的,都得写入 mysql 中,写入 mysql 中的时候必须保存一个时间戳,从 mysql 查出来的时候,时间戳也查出来。

每次要写之前,先判断一下当前这个 value 的时间戳是否比缓存里的 value 的时间戳要新。如果是的话,那么可以写,否则,就不能用旧的数据覆盖新的数据。s

20.1 进一步提问:了解redis的CAS方案吗?

Redis提供CAS 乐观锁方案能够天然的解决这个问题。

redis支持了简单的事务,提供了以下几个命令:

  • WATCH:监控某些键值对;
  • MULTI:用于开启一个事务;
  • EXEC:执行事务;
  • DISCARD:取消事务;
  • UNWATCH:取消监控。

Redis事物 Redis 通过MULTI 、EXEC、WATCH等命令来实现事物功能。事物提供了一种将多个命令请求打包,然后一次性、按顺序的执行多个命令的机制,并且在事物执行期间,服务器不会中断事物而去执行其他客户端的命令请求,它会将事物中所有的命令都执行完毕,然后才去处理其他客户端的请求。

  • 事务首先以MULTI命令开始,MULTI后接下来对键的操作命令都会入队列;
  • EXEC相当于提交事务执行
  • WATCH命令是一个乐观锁,它可以在EXCE命令执行之前,监视任意数量的数据库键;并在EXCE执行时,检查被监视的键是否至少有一个已经被修改过了,如果是的话,服务器拒绝执行事务,并向客户端返回代表事务执行失败的空回复。

WATCH命令的乐观锁机制

每个redis数据库都保存一个watch_key字典属性,这个字典保存着被watch的键和客户端形式的键值对。当某一个客户端执行watch某个键后,该字典就会进行记录,之后所有对数据库修改的命令,比如SET、LPUSH、SADD、ZREM、DEL、等等,都会调用touchWatchKey函数对watch_key字典进行检查,查看是否有客户端已经修改过这个被监视的健,如果有,对应客户端的REDIS_DIATY_CAS标识被打开,表示这个客户端的事务安全性被破坏,那么服务器会拒绝执行这个客户端的对应事务,以此来保证事务安全性。

上图表示,该数据库中的name被客户端c1、c2监视着,agec3监视着,addressc2、c4监视着,在客户端c10086执行下述命令后,nameage也被c100088监视着。之

1
2
3
4
5
6
7
8
9
10
11
//客户端c10086
redis>watch "age" "name"
OK
//开启事务
redis>MULTI
OK
redis>SET "name" "trluper"
QUEUE
redis>EXCE
(nil)
//nil说明有另一个客户端对name执行修改,服务器对c10086的该事务拒绝执行

21 Redis事务

Redis提供了简单的事务功能,将一组需要执行的命令放到multiexec之间,multi代表事务开始,exec代表事务结束,只有执行了exec后中间的命令才会被执行

如果要停止事务的执行,可以使用discard命令代替exec

事务中出现错误的情况:

  • 命令错误:例如语法错误,会导致整个事务无法执行
  • 运行时错误:例如对于字符串键值,错将SET写成LPUSH,这时候执行exec时正确的命令会被执行,因此Redis不支持回滚功能

在事务之前如果需要确保事务中的key没有被其他客户端修改才能执行,否则不执行(乐观锁),可以通过在multi之前先执行watch命令来实现

Redis事务提供了一种将多个命令请求打包的功能。然后,再按顺序执行打包的所有命令,并且不会被中途打断。

在传统的关系式数据库中,常常用 ACID 性质来检验事务功能的可靠性和安全性。在 Redis 中,事务总是具有原子性(Atomicity)、一致性(Consistency)和隔离性(Isolation),并且当 Redis 运行在某种特定的持久化模式下时,事务也具有持久性(Durability)。

  • 原子性:Redis的事务中对多个操作当以整体执行,要么都执行,要么都不执行。当redis不支持回滚功能,对于运行时的错误,整个事务会继续执行下去。
  • 一致性:无论事务是否成功执行,数据库也应该仍然一致的,即数据库的状态符合要求,没有包含非法或者无效数据。
    • 入队错误(命令错误):例如语法错误,会导致整个事务无法执行,一致性能保证。
    • 运行时错误:在执行过程在,出错的的命令会被服务器识别出来,并进行错误处理,所以这些出错命令不会对数据库做出修改。
    • 服务器宕机,若开启了持久化,可将数据库还原到一个一致性状态;未开启,一个空的数据库就是一个一致性状态。
  • 隔离性:Redis使用单线程来处理命令,而事务是被认为当个命令来执行的,隔离性能够保证。
  • 持久性:Redis事务的持久性由Redis所使用的持久化模式决定。
    • 对于RDB,异步执行BGSAVE不能保证事务数据被第一时间保存到硬盘,因此无法保证持久性。
    • 对于AOF,当选择appendfsync选择always时,程序同步保存,这种配置下事务具有持久性。

22 如何保证缓存与数据库双写时的数据一致性?

互联网公司非常喜欢问这道面试题因为缓存在互联网公司使用非常频繁 在高并发的业务场景下,数据库的性能瓶颈往往都是用户并发访问过大。所以,一般都使用Redis做一个缓冲操作,让请求先访问到Redis,而不是直接去访问MySQL等数据库,从而减少网络请求的延迟响应。

我们使用缓存的目的是为了提升查询的性能。大多数情况下,我们是这样使用缓存的:

如果数据库中的某条数据,放入缓存之后,又立马被更新了,那么该如何更新缓存呢,这里就涉及到了双写问题。你只要用缓存,就可能会涉及到缓存与数据库双存储双写,你只要是双写,就一定会有数据一致性的问题,那么你如何解决一致性问题?要弄明白这个问题,并且能够理解各种情况,需要一步步的说明:

22.1 什么是一致性

一致性就是数据保持一致,在分布式系统中,可以理解为多个节点中数据的值是一致的。

  • 强一致性:这种一致性级别是最符合用户直觉的,它要求系统写入什么,读出来的也会是什么,用户体验好,但实现起来往往对系统的性能影响大
  • 弱一致性:这种一致性级别约束了系统在写入成功后,不承诺立即可以读到写入的值,也不承诺多久之后数据能够达到一致,但会尽可能地保证到某个时间级别(比如秒级别)后,数据能够达到一致状态
  • 最终一致性:最终一致性是弱一致性的一个特例,系统会保证在一定时间内,能够达到一个数据一致的状态。这里之所以将最终一致性单独提出来,是因为它是弱一致性中非常推崇的一种一致性模型,也是业界在大型分布式系统的数据一致性上比较推崇的模型

22.2 不同的双写策略

对于缓存和数据库的更新有以下四种:①先写缓存,再写数据库;②先写数据库,再写缓存;③先删缓存,再写数据库;④先写数据库,再删缓存(Cache Aside Pattern旁路缓存模式

  • 先写缓存,再写数据库:拒绝使用。试想某一个用户的每一次写操作,如果刚写完缓存,突然网络出现了异常,导致写数据库失败了。其结果是缓存更新成了最新数据,但数据库根本没有,这样缓存中的数据变成脏数据了。(就好像你往银行存款1000元,你是存进缓存了,但是没有进数据库,那么当银行因为某些原因缓存更新,发现你这1000块不翼而飞,这个问题极其严重)。

  • 先写数据库,再写缓存:也不推荐使用,在高并发场景下,写数据库与写缓存不在同一事务中,如果写数据库成功,但写缓存失败,这就会导致数据库是新数据,而缓存是旧数据,两边数据不一致的情况。
    • 另一个问题就是,多个写并发场景下,由于写缓存的网络卡顿,导致数据不一致

22.2.1 Cache Aside Pattern旁路缓存模式(先写数据库,再删缓存)

解决双写数据一致性问题的最经典的模式,就是Cache Aside Pattern旁路缓存模式,它的提出尽可能地解决缓存与数据库的数据不一致问题。

  • 读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。
  • 更新的时候,先更新数据库,再删除缓存

但是旁路缓存模式也不是百分百的保证缓存与数据库一致,

  • 问题:先更新数据库,再删除缓存,如果删除缓存失败了(删除命令阻塞在网络中),导致数据库中是新数据,缓存中是旧数据,就出现数据不一致的问题。假设存在下面这种情况缓存失效,查询先于写,:

    • 缓存过期时间到了,自动失效。
    • 请求f查询缓存,发缓存中没有数据,查询数据库的旧值,但由于网络原因卡顿了,没有来得及更新缓存。
    • 请求e先写数据库,接着删除了缓存。
    • 请求f更新旧值到缓存中。

22.2.2 先删缓存,再写数据库

Cache Aside Pattern旁路缓存模式存在问题:先更新数据库,再删除缓存,如果删除缓存失败了,导致数据库中是新数据,缓存中是旧数据,就出现数据不一致的问题。

解决思路:先删除缓存,再更新数据库。

  • 缓存删除失败:如果缓存删除失败,那么就不会继续执行,数据库信息没有被修改,保持了数据的一致性;
  • 缓存删除成功,数据库更新失败:此时数据库里的是旧数据,缓存是空的,查询时发现缓存不存在,就查询数据库并更新缓存,数据保持一致。

问题:上面的方案也存在不足,如果删除完缓存更新数据库出现网络卡顿时,这时如果一个请求过来查询数据,缓存不存在,就查询数据库的旧数据,更新旧数据到缓存中。随后数据更新完成,修改了数据库的数据,此时缓存和数据库的数据就会出现不一致了。高并发下会出现这种数据库 + 缓存不一致的情况。 如果不采用给缓存设置过期时间策略,该数据永远都是脏数据。

解决方案:采用双删除策略。写请求先删除缓存,再去更新数据库,等待一段时间后再异步删除缓存。这样可以保证在读取错误数据时能及时被修正过来。

还有一种策略,就是:写请求先修改缓存为指定值,然后再去更新数据库,再更新缓存。读请求过来后,会先读缓存,判断是指定值后就进入循环读取状态,等到写请求更新缓存。如果循环超时就去数据库读取数据,更新缓存。这种方案保证了读写的一致性,但由于读请求等待写请求的完成,会降低系统的吞吐量。

进一步提问,那二次删除也失败怎么办?
  • 解决方法:引入删除缓存重试机制。既然删除失败那就多删除几次,保证删除缓存成功。
    • 可以把删除失败的key放进消息队列mq,然后消费消息队列的消息,获取要删除的key,重试删除缓存操作。
    • 也可以用定时任务进行重试多次。

上面采用mq的方法做重试机制,对业务都有一定的侵入性。在使用定时任务的方案中,需要在业务代码中增加额外逻辑,如果删除缓存失败,需要将数据写入重试表。而使用mq的方案中,如果删除缓存失败了,需要在业务代码中发送mq消息到mq服务器。

binlog异步淘汰key

上述的步骤,都是在业务线里面执行,可新增一个线下的读取binlog异步淘汰缓存模块,读取binlog总的数据,然后进行异步淘汰。因此另一种更优雅方法就是监听binlog+重试机制

  • 读请求走Redis:热数据基本都在Redis
  • 写请求走MySQL: 增删改都操作MySQL

一旦MySQL中产生了新的写入、更新、删除等操作,就可以把binlog相关的消息推送至Redis,Redis再根据binlog中的记录,对Redis进行更新,就无需在从业务线去操作缓存内容。

  1. mysql发生变更产生一条binlog
  2. binlog写进消息队列(MQ)
  3. 程序监听消息队列,得到binlog消息
  4. 解析binlog,得到变更的内容
  5. 将变更的内容更新至redis

22.3 为什么采用删除而不是更新缓存?

因为很多时候复杂的缓存场景,缓存不是仅仅从数据库中取出来的值。可能是关联多张表的数据并通过计算才是缓存需要的值。缓存存在的意义是优化查询速度,对于需要频繁写操作,而读操作很少的时候,每次进行数据库的修改,缓存也要随之更新,会造成系统吞吐的下降,但此时缓存并不会被频繁访问到,用到的缓存才去算缓存。删除缓存而不是更新缓存,是一种懒加载的思想,不是每次都重复更新缓存,只有用到的时候才去更新缓存,同时即使有大量的读请求,实际也就更新了一次,后面的请求不会重复读

23 Redlock分布式锁

RedLock算法是Redis作者提出基于Redis在分布式锁的一种实现。在介绍RedLock之前,先来看看传统的单机锁和分布式锁的比较,还有常见的分布式锁实现方案。

23.1 单机锁 vs 分布式锁

当我们的业务数据流量上来了之后,系统的架构就会从单机集中式系统升级位分布式架构。在单机系统高并发的情况下,我们直接使用内置的锁比如Synchronize或者是ReentrantLock或者mutex就可以实现业务需求。

这类锁属于单机锁,对于单机架构来说是完全够用的。但并不适用于在分布式架构中。用户请求通过负载均衡设备打在每个服务上面的,单机锁只能够限制打入到当前机器的请求,并不能限制整个分布式集群。 在分布式环境下,如果我们想要并发严格控制资源,那么就需要用到分布式锁。

23.2 单机锁SETNX

Key的唯一性

一种实现方案是基于Key的唯一性。也就是setNx,那条指令。用于在 Redis 中设置一个键的值,但仅当该键不存在时才设置成功

1
原理:setNx 就是 set if not Existed (存入Key如果没有存在的话

在单点上获取分布式锁: 一般我们都会携带超时时间,避免释放锁的时候出现故障导致Key一直存活在Redis里面无法再次进行锁的获取。

1
SET resource_name my_random_value NX PX 30000
该命令仅当key不存在(NX保证)时,set值,并且设置过期时间为3000ms(PX保证),值my_random_value必须是所有client和所有锁请求发生期间唯一的,释放锁的逻辑是:
1
2
3
4
5
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
上述实现避免了释放另一个client创建的锁。如果只有del命令的话,如果client1拿到lock1之后因为某些操作阻塞了很长时间,此时Redislock1已经过期了并且已经被重新分配给了client2,那么client1此时再去释放这把锁就会造成client2原本获取到的锁被client1无故释放了,但现在为每个client分配一个uniquestring值可以避免这个问题。至于如何去生成这个unique string,方法很多随意选择一种就行了(而redlock是靠客户端生成一个时间戳来看这个锁是否是自己创建,如果是且未过期就释放)。

缺点:Redis基于Key唯一性只能使用于单Redis实例,不支持Redis集群。 并且如果锁所在的Redis实例挂掉了之后,别的客户端就可以趁机而入进行锁的获取,但是已经拿到锁的客户端无法感知。

那有没有能够支持Redis集群的锁呢?现在Redis基本都是集群架构来抗并发压力了。答案其实是有的RedLock。

23.2 常见分布式锁实现

常见的分布式锁实现有基于Redis、Mysql、Zookeeper的。归根到底是因为这些中间件可以提供共享资源的一个能力。

23.2.1 Redis

基于Redis的实现,也是非常常见的一种解决方案。因为一个系统可能没有Zookeeper,可能没有消息中间件,但是Redis缓存肯定会有

23.3 RedLock

SETNX只能适用于单机redis,而RedLock是利用SETNX这种锁特性来实现分布式锁的 #### 23.3.1 算法思想 - 1.获取当前时间戳: 获取当前时间戳,用于计算锁的有效期。 - 2.尝试获取锁: 客户端尝试在多个Redis实例上获取锁,对每个实例执行以下操作: - 用 SETNX 命令尝试获取锁,同时设置过期时间为一个较小的值。 - 如果获取锁成功(SETNX 返回 1),则锁获取成功。 - 如果获取锁失败(SETNX 返回 0),则尝试比较当前时间和之前获取锁的时间,如果已经超过锁的过期时间,则认为锁已经过期,可以尝试重新获取锁。 - 3.确认锁的持有者: 客户端在超过半数的 Redis 实例上都成功获取到了锁,然后需要确认锁的持有者。 - 4.释放锁: 如果客户端持有锁的时间没有超过锁的过期时间,并且客户端确定自己确实是锁的持有者,那么客户端可以安全地释放锁。(即使持有者宕机了,因为设置了过期时间,锁依然能够的带释放)

23.3.2 RedLock是否真的能彻底解决分布式锁的问题呢?

正常情况下,是可以解决分布式问题的。但是面对极端情况下,RedLock可能就不包熟了!

  • 加锁的节点宕机情景 原本ClientA通过RedLock加锁成功在Redis_1、Redis_2、Redis_3实例上成功加锁!但过了一段时间后,Redis_3节点宕机掉后重启加入集群,但加锁的数据没了,此时被ClientB趁虚而入,在Redis_3、Redis_4、Redis_5节点成功超半数加锁,那么ClientA和ClientB同时持有锁,这个锁就不包熟了!
    • 解决办法:
      • 持久化数据,使用AOF方式来存储数据,尽可能地保存全部锁的数据,当节点宕机之后也能保证重启之后锁依然在Redis中。AOF同步策略中,有每秒同步、每次同步。设置位每秒同步,每次进行写操作的时候都会写日志,就是效率优点低。
      • 延迟启动。光靠持久化数据还不够,必须估计到数据还没有持久化到磁盘后就宕机的情况。此时我们可以采取延迟启动。Redis宕机之后不要立即重启,而是要等分布式锁中最长的Key的TTL(超时时间)过了之后再启动,保证全部Key都被强制解锁了。但这种方案需要用一个东西来存储每个分布式锁的TTL时间。
  • 极端场景二:由于Key在Redis中具有超时自动释放的机制,而客户端无法感知自己的锁失效了。那么就会出现一种情况,客户端client1获得分布式锁后,恰巧遇到客户端执行垃圾回收时GC中STW(stop the world)停顿机制导致客户端阻塞一段时间,此时client1失效,client2获得锁,而client1不知道自己的锁已经失效了,这时候client1再进行写时就会发生错误. >除了GC停顿,还有很多原因可能导致进程pause。例如进程可能读取尚未进入内存的数据,所以它得到一个 page fault (错误页面)并且等待 page 被加载进缓存;还有可能你依赖于网络服务;或者其他进程占用 CPU;或者其他意外发生 SIGSTOP 等。

    • 解决方法:
      • 使用Fencing(栏栅)使锁变安全:领域大牛Martin提出在每次写操作时加入一个fencing token,这个场景下,fencing token可以是一个递增的数字(lock service可以做到),每次有client申请锁就递增一次: client1 申请锁同时拿到token33,然后它进入长时间的停顿锁也过期了。client2 得到锁和token34写入数据,紧接着 client1 活过来之后尝试写入数据,自身token3334小因此写入操作被拒绝。注意这需要存储层来检查token,但这并不难实现。如果使用Zookeeper作为lock service的话那么可以使用zxid作为递增数字。 但是对于Redlock ,没什么生成fencing token的方式,并且怎么修改Redlock 算法使其能产生fencing toke并不那么显而易见。因为产生token需要在集群中共享单调递增,除非在单节点Redis上完成但是这又没有高可靠性,需要引进一致性协议来让Redlock 产生可靠的fencing token
  • RedLock过于依赖时钟:在分布式架构中,其中的一个特点就是缺乏全局时钟。而RedLock的上锁机制依赖于分布式的时钟一致性,这存在很大的隐患。

Martin批评RedLock算法太过于依赖时间,大概意思就是:强调一个好的算法,不管时间维度上出现问题,还是网络通信上出现了问题,算法可以没有立刻得到正确的答案,但算法会在未来的时间内给出正确的答案而并非是错误的答案。

总结RedLock的两个缺点就是:

  • 1、客户端无法感知锁失效。
  • 2、RedLock过于依赖时钟。

Redlock 不是一个好的选择,对于需求性能的分布式锁应用它太重了且成本高;对于需求正确性的应用来说它不够安全。因为它对高危的时钟或者说其他上述列举的情况进行了不可靠的假设,如果应用只需要高性能的分布式锁不要求多高的正确性,那么单节点 Redis 够了;如果应用想要保住正确性,那么不建议 Redlock,建议使用一个合适的一致性协调系统,例如Zookeeper,且保证存在fencing token

24 Redis高并发和高可用是如何保证的?

  • 主从复制集群+redis Sentinel是一种高并发高可用方案
  • redis Cluster也是一种高高并发高可用方案(redis集合可主从复制和哨兵机制) 后面会讲到

这样的问题主要是在并发读写访问的时候,缓存和数据相互交叉执行

  • 高并发
    • Redis的主从架构模式是实现高并发的主要依赖,一般很多项目只需要一主多从就可以实现其所需要的功能。通常使用单主用来写入数据,单机几万 QPS;多从一般是查询数据,同时这样也可以很轻松实现水平扩容,支撑读高并发。
    • 同时一些项目需要在实现高并发的同时,尽可能多的容纳大量的数据,这时需要使用Redis 集群,使用Redis 集群之后,可以提供每秒几十万的读写并发。
  • 高可用
    • Redis 高可用,如果是做主从架构部署,那么加上哨兵就可以实现,任何一个实例宕机,可以进行主备切换。

Redis replication(redis主从复制) -> 主从架构 -> 读写分离 -> 水平扩容支撑读高并发

redis的文件事件 虽然文件事件处理器以单线程方式运行,但通过使用 I/O 多路复用程序来监听多个套接字,文件事件处理器既实现了高性能的网络通信模型应对并发场景,又可以很好地与 Redis 服务器中其他同样以单线程方式运行的模块进行对接。 Redis6.0的多线程 Redis6.0 引入多线程主要是为了提高网络 IO 读写性能,因为这个算是 Redis 中的一个性能瓶颈(Redis 的瓶颈主要受限于内存和网络)。 虽然,Redis6.0 引入了多线程,但是 Redis 的多线程只是在网络数据的读写这类耗时操作上使用了, 执行命令仍然是单线程顺序执行。因此,也不需要担心线程安全问题。其原因概括来说就两点: - 可以充分利用服务器 CPU 资源,目前主线程只能利用一个核 - 多线程任务可以分摊 Redis 同步 IO 读写负荷

25 主从复制

25.1 Redis replication 的核心机制

  • Redis 采用异步方式复制数据到slave从节点,不过 Redis2.8 开始,slave node从节点会周期性地确认自己每次复制的数据量;
  • 一个 master node 是可以配置多个 slave node 的;
  • slave node 也可以连接其他的 slave node;
  • slave node 做复制的时候,不会阻塞 master node 的正常工作;
  • slave node 在做复制的时候,也不会阻塞对自己的查询操作,它会用旧的数据集来提供服务;但是复制完成的时候,需要删除旧数据集,加载新数据集,这个时候就会暂停对外服务了;
  • slave node 主要用来进行横向水平扩容,做读写分离,扩容的 slave node 可以提高读的吞吐量。

注意,如果采用了主从架构,那么建议必须开启 master node 的持久化,同时不建议用 slave node 作为 master node 的数据热备。如果你关掉 master 的持久化,可能在 master 宕机重启的时候数据是空的,然后可能一经过复制, slave node 的数据也丢了。

另外,master 的各种备份方案,也需要做。万一本地的所有文件丢失了,从备份中挑选一份rdb 去恢复 master,这样才能确保启动的时候,是有数据的,即使采用了后续讲解的高可用机制,slave node 可以自动接管 master node,但也可能 sentinel 还没检测到master failuremaster node就自动重启了,还是可能导致上面所有的 slave node 数据被清空。

25.2 Redis 主从复制的核心原理(流程)

  • 当启动一个 slave node 的时候,会在自己本地保存master node 的信息,包括 master nodehostip ,但是复制流程没开始。

  • slave node 内部有个定时任务,每秒检查是否有新的 master node 要连接和复制,如果发现,就跟master node建立socket网络连接。然后 slave node 发送 ping命令给master node。如果 master 设置了 requirepass,那么 slave node 必须发送 masterauth 的口令过去进行认证。

  • 如果这是 slave node 初次连接到 master node,那么会触发一次 full resynchronization 全量复制。此时 master 会启动一个后台线程,开始生成一份 RDB 快照文件,同时还会将从客户端 client 新收到的所有写命令缓存在内存中。 RDB 文件生成完毕后, master 会将这个RDB 发送给 slave,slave会先写入本地磁盘,然后再从本地磁盘加载到内存中,接着 master 会将内存中缓存的写命令发送到 slave,slave 也会同步这些数据。

  • slave node 如果跟 master node 有网络故障,断开了连接,会自动重连,连接之后 master node 仅会复制给 slave 部分缺少的数据,也称为断点续传

  • 在后续,master node 持续将写命令,异步执行增量复制给 slave node

25.3 主从复制的断点续传

从 Redis2.8 开始,就支持主从复制的断点续传,如果主从复制过程中,网络连接断掉了,那么可以接着上次复制的地方,继续复制下去,而不是从头开始复制一份。

master node 会在内存中维护一个 backlogmasterslave 都会保存一个 replication offset 还有一个 master run idoffset 就是保存在 backlog 中的。如果 masterslave 网络连接断掉了,slave 会让 master 从上次 replica offset 开始继续复制,如果没有找到对应的offset,那么就会执行一次 resynchronization全量复制。

如果根据 host+ip 定位 master node,是不靠谱的,如果 master node 重启或者数据出现了变化,那么 slave node 应该根据不同的 run id 区分

25.4 无磁盘化复制

master 在内存中直接创建 RDB ,然后发送给 slave,不会在自己本地落地磁盘了。只需要在配置文件中开启 repl-diskless-sync yes 即可。

1
2
3
4
repl-diskless-sync yes

# 等待 5s 后再开始复制,因为要等更多 slave 重新连接过来
repl-diskless-sync-delay 5Copy to clipboardErrorCopied

25.5 从节点过期key处理

由于从节点只负责读业务,因此从节点不会自己主动删除过期的key,而是由主节点控制,从节点只会等待master 过期key。如果 master 过期了一个 key,或者通过 LRU 淘汰了一个 key,那么会模拟一条 del 命令发送给 slave

25.6 全量复制

  • master 执行 bgsave ,在本地生成一份 rdb 快照文件。
  • master node 将 rdb 快照文件发送给 slave node,如果 rdb 复制时间超过 60 秒(repl-timeout),那么 slave node 就会认为复制失败,可以适当调大这个参数(对于千兆网卡的机器,一般每秒传输 100MB,6G 文件,很可能超过 60s)
  • master node 在生成 rdb 时,会将所有新的写命令缓存在内存中,在 slave node 保存了 rdb 之后,再将新的写命令复制给 slave node。
  • 如果在复制期间,内存缓冲区持续消耗超过 64MB,或者一次性超过 256MB,那么停止复制,复制失败。
    1
    client-output-buffer-limit slave 256MB 64MB 60Copy to clipboardErrorCopied
  • slave node 接收到 rdb 之后,清空自己的旧数据,然后重新加载 rdb 到自己的内存中,同时基于旧的数据版本对外提供服务。
  • 如果 slave node 开启了 AOF,那么会立即执行 BGREWRITEAOF,重写 AOF

25.6 增量复制

  • 如果全量复制过程中,master-slave 网络连接断掉,那么 slave 重新连接 master 时,会触发增量复制。
  • master 直接从自己的 backlog 中获取部分丢失的数据,发送给 slave node,默认 backlog 就是 1MB。
  • master 就是根据 slave 发送的 psync 中的 offset 来从 backlog 中获取数据的

26 Redis 如何才能做到高可用?

如果系统在 365 天内,有 99.99% 的时间,都是可以哗哗对外提供服务的,那么就说系统是高可用的。

一个 slave 挂掉了,是不会影响可用性的,还有其它的 slave 在提供相同数据下的相同的对外的查询服务。

但是,如果 master node 死掉了,会怎么样?没法写数据了,写缓存的时候,全部失效了。slave node 还有什么用呢,没有 master 给它们复制数据了,系统相当于不可用了。

Redis 的高可用架构,叫做 failover 故障转移,也可以叫做主备切换

master node 在故障时,自动检测,并且将某个 slave node 自动切换为 master node 的过程,叫做主备切换。这个过程,实现了 Redis 的主从架构下的高可用

27 Redis基于哨兵集群实现高可用

Redis 哨兵(Sentinel)是Redis提供的一种高可用实现方案,Redis在主从复制下,一旦主节点出现问题,需要人工干预,手动将一个从节点更新为主节点(slaveof no one),同时还要通知应用方新的主节点,让其他从节点去复制新的从节点。这种方式存在弊端大,Redis Sentinel高可用方案就是为了解决这种问题。

Redis Sentinel 是一个分布式架构,其中包含若干个Sentinel节点和Redis数据节点,每个Sentinel节点会对数据节点和其余Sentinel节点进行监控,当它发现节点不可达时,会对节点做下线标识。如果被标识的是主节点,它还会和其他Sentinel节点进行“协商”,当大多数Sentinel节点都认为主节点不可达时,它们会选举出一个Sentinel节点来完成自动故障转移的工作,同时会将这个变化实时通知给Redis应用方。

因此哨兵节点主要负责三件事情:监控、选主、通知。所以,我们重点要学习这三件事情: >哨兵节点是如何监控节点的?又是如何判断主节点是否真的故障了? >根据什么规则选择一个从节点切换为主节点? >怎么把新主节点的相关信息通知给从节点和客户端呢?

27.1 部署方法

  • 首先部署主节点和从节点
  • 部署sentinel节点
  • 在Redis安装目录下有一个 sentinel.conf 的文件,是默认的 sentinel 节点配置文件,对其进行复制和修改
  • 启动Sentinel节点 >Sentinel节点默认的端口是26379

启动节点的方式有两种:

  • 使用redis-sentinel命令

    1
    redis-sentinel sentinel配置文件.conf

  • 使用redis-server命令加上 --sentinel 参数

    1
    redis-server sentinel配置文件.conf —sentinel
    >每个sentinel节点会对主节点和所有从节点进行监控,同时Sentinel节点之间也会相互监控

27.2 哨兵节点是如何监控节点的?

Redis Sentinel通过三个定时监控任务完成对每个节点发现和监控:

  1. 每隔10秒,每个Sentinel节点会向主节点和从节点发送info命令获取最新的拓扑结构,Sentinel节点可以通过info replication的结果进行解析找到相应的从节点。 >作用:通过向主节点执行 info 命令,获取从节点的信息,这也是为什么 Sentinel 节点不需要显式配置监控从节点 >当有新的从节点加入时都可以立刻感知出来。 >节点不可达或者故障转移后,可以通过 info 命令实时更新节点拓扑信息。

  2. 每隔2秒,每个Sentinel会向Redis数据节点的 __sentinel__:hello 频道发送该 Sentinel 节点的信息,同时每个 Sentinel 节点也会订阅该频道,来了解其他 Sentinel 节点以及他们对主节点的判断 >作用:发现新的Sentinel节点:通过订阅主节点的 sentinel:hello通道了解其他的Sentinel节点信息,如果是新加入的 Sentinel 节点,将该 Sentinel 节点信息保存起来,并与该 Sentinel 节点创建连接 >Sentinel 节点之间交换主节点的状态,作为后面客观下线以及领导者选举的依据。

  3. 每隔1秒,每个Sentinel节点会向主节点、从节点、其余Sentinel节点发送一条ping命令做一次心跳检测,来确认这些节点当前是否可达。 >作用:通过对上面的定时任务,Sentinel 节点对主节点、从节点,其余 Sentinel 节点都建立起连接,实现对每个节点的监控,这个定时任务是节点失败判定的重要依据。

如果主节点或者从节点没有在规定的时间内响应哨兵的 PING 命令,哨兵就会将它们标记为「主观下线」。这个「规定的时间」是配置项down-after-milliseconds 参数设定的,单位是毫秒。

27.3 如何判断主节点是否真的故障了?

  • 主观下线:每个 Sentinel 节点每隔1秒对主节点、从节点、其他Sentinel节点发送ping命令做心脏检测,当这些节点超过 down-after-milliseconds 没有进行有效恢复时,Seintinel 节点会对该节点做失败判定,这个行为称为主观下线。

  • 客观下线:当 Sentinel 主观下线的节点是主节点时,该 Sentinel 节点会通过 sentinelis-master-down-by-addr命令向其他 Sentinel 节点询问对主节点的判断。当超过 quorum 个数 Sentinel 节点认为主节点确实有问题,这时就会做出客观下线的决定

PS:quorum 的值一般设置为哨兵个数的二分之一加1,例如 3 个哨兵就设置 2。

27.4 哨兵领导者选举方法

在进行故障转移之前,Sentinel们需要先选择一个领导者,让它来指定谁应该成为新的主节点。在之前的客观下线,当一个Sentinel is-master-down-by-addr命令向其他 Sentinel 节点询问对主节点的判断,那么该哨兵节点就成为了候选者:

  • 每个在线的Sentinel节点都有资格成为领导者,当它确认主节点主观下线时候,会向其他Sentinel节点发送 sentinel is-master-down-by-addr 命令, 要求将自己设置为领导者。
  • 收到命令的Sentinel节点,如果没有同意过其他 Sentinel节点的 sentinel is-master-down-by-addr 命令,将同意该请求,否则拒绝。
  • 如果该 Sentinel 节点发现自己的票数已经大于等于max(quorum, num(sentinels)/2+1),那么它将成为领导者。
  • 如果此过程没有选举出领导者,将进入下一次选举。 >事实上每个Sectinel只有一票,会最先给发起请求的节点。基本上谁先完成客观下线,就会成为领导者

27.5 根据什么规则选择一个从节点切换为主节点(如何进行主从故障转移)?

为了在从节点中选举出主节点,其选择规则如下:

  1. 首先进行过滤,滤除那些“不健康”(主观下线、断线)、5秒内没有回复过Sentinel节点ping响应、与主节点失联超过 down-after-milliseconds*10 秒,接着经过最多三步考察来确定主节点。
  2. 选择slave-priority(从节点优先级)最高的从节点列表,如果存在则返回,不存在则继续下一步考察。
  3. 选择复制偏移量最大的从节点(复制的最完整),如果存在则返回,不存在则继续。
  4. 如果优先级和下标都相同,选择run id最小的从节点。

选出主节点后,Sentinel领导者会做以下工作:

  • Sentinel领导者节点会对选出来的从节点执行slaveof no one命令让其成为主节点。
  • Sentinel领导者节点会向剩余的从节点发送命令,让它们成为新主节点的从节点,复制规则和parallel-syncs参数有关
  • Sentinel节点集合会将原来的主节点更新为从节点,并保持着对其关注,当其恢复后命令它去复制新的主节点。

更换主节点后,客户端怎么知道主节点是哪个? 哨兵主节点信息会发布到这个频道中:switch-master,客户端只需要订阅该指定频道,当发生故障转移后,该频道就可以收到新的主节点信息,客户端依据信息更改

27.6 哨兵节点之间如何互相发现,哨兵如何发现从节点(哨兵集群如何建立的?)

在设置哨兵集群时,只需要填下面这几个参数,设置主节点名字、主节点的 IP 地址和端口号以及 quorum 值,不需要填写哨兵间的连续:

1
sentienl monitor <master-name> <ip> <redis-port> <quorum>
这是因为哨兵节点之间是通过 Redis 的发布者/订阅者机制来相互发现的。在主从集群中,主节点上有一个名为__sentinel__:hello的频道,不同哨兵就是通过它来相互发现,实现互相通信的。

在下图中,哨兵A把自己的** IP 地址和端口的信息发布到__sentinel__:hello频道上,哨兵BC订阅了该频道。那么此时,哨兵BC就可以从这个频道直接获取哨兵A IP 地址和端口号**。然后,哨兵 B、C可以和哨兵A建立网络连接。通过这个方式,哨兵 B 和 C 也可以建立网络连接,这样一来,哨兵集群就形成了。

哨兵集群会对「从节点」的运行状态进行监控,那哨兵集群如何知道「从节点」的信息?

主节点知道所有「从节点」的信息,所以哨兵会每 10 秒一次的频率向主节点发送 INFO 命令来获取所有「从节点」的信息。

如下图所示,哨兵 B 给主节点发送 INFO 命令,主节点接受到这个命令后,就会把从节点列表返回给哨兵。接着,哨兵就可以根据从节点列表中的连接信息,和每个从节点建立连接,并在这个连接上持续地对从节点进行监控。哨兵 A 和 C 可以通过相同的方法和从节点建立连接。

总结:通过 Redis 的发布者/订阅者机制,哨兵之间可以相互感知,然后组成集群,同时,哨兵又通过 INFO 命令,在主节点里获得了所有从节点连接信息,于是就能和从节点建立连接,并进行监控了。

27.7 总结

Redis 在 2.8 版本以后提供的哨兵(Sentinel)机制,它的作用是实现主从节点故障转移。它会监测主节点是否存活,如果发现主节点挂了,它就会选举一个从节点切换为主节点,并且把新主节点的相关信息通知给从节点和客户端。

哨兵一般是以集群的方式部署,至少需要 3 个哨兵节点,哨兵集群主要负责三件事情:监控、选主、通知

哨兵节点通过 Redis 的发布者/订阅者机制,哨兵之间可以相互感知,相互连接,然后组成哨兵集群,同时哨兵又通过 INFO 命令,在主节点里获得了所有从节点连接信息,于是就能和从节点建立连接,并进行监控了。

28 Redis 哨兵主备切换的数据丢失问题

28.1 导致数据丢失的两种情况

主备切换的过程,可能会导致数据丢失:

  • 异步复制导致的数据丢失:因为 master->slave 的复制是异步的,所以可能有部分数据还没复制到 slavemaster 就宕机了,此时这部分数据就丢失了。
  • 脑裂导致的数据丢失:某个 master 所在机器突然脱离了正常的网络,跟其他 slave 机器不能连接,但是实际上 master 还运行着。此时哨兵可能就会认为 master 宕机了,然后开启选举,将其他 slave 切换成了 master。这个时候,集群里就会有两个 master ,也就是所谓的脑裂。此时虽然某个 slave 被切换成了 master,但是可能 client 还没来得及切换到新的 master,还继续向旧 master 写数据。因此旧 master 再次恢复的时候,会被作为一个 slave 挂到新的 master 上去,自己的数据会清空,重新从新的 master 复制数据。而新的 master 并没有后来 client 写入的数据,因此,这部分数据也就丢失了

28.2 数据丢失问题的解决方案

上面的出现数据丢失均是master的数据未能及时写进slave,可以进行如下配置,设置最大延迟10s,超过10s不允许写数据:

1
2
min-slaves-to-write 1
min-slaves-max-lag 10 Copy to clipboardErrorCopied
上面规定要求至少有 1 个 slave,数据复制和同步的延迟不能超过 10 秒。如果说一旦所有的 slave,数据复制和同步的延迟都超过了 10 秒钟,那么这个时候,master 就不会再接收任何请求了,这就保证最多会丢失10s的数据。

  • 减少异步复制数据的丢失:有了 min-slaves-max-lag 这个配置,就可以确保说,一旦 slave 复制数据和 ack 延时太长,就认为可能 master 宕机后损失的数据太多了,那么就拒绝写请求,这样可以把 master 宕机时由于部分数据未同步到 slave 导致的数据丢失降低的可控范围内。

  • 减少脑裂的数据丢失:如果一个 master 出现了脑裂,跟其他 slave 丢了连接,那么上面两个配置可以确保说,如果不能继续给指定数量的 slave 发送数据,而且 slave 超过 10 秒没有给自己 ack 消息,那么就直接拒绝客户端的写请求。因此在脑裂场景下,最多就丢失10秒的数据

29 Redis集群(介绍一下redis cluster)

redis集群是实现高可用的方式之一,它采用无中心节点方式实现,无需proxy代理,客户端直接与redis集群的每个节点连接,与主从复制集群模式只提供一个master不同,Redis集群会提供多个master节点提供写服务,每个master节点中存储的数据都不一样,这些数据通过数据分片的方式被自动分割到不同的master节点上实现水平扩容。

同时为了保证集群的高可用,每个master节点还会添加slave节点,这样当某个master节点发生故障后,可以从它的slave节点中选举一个作为新的master节点继续提供服务。

29.1 集群数据的是怎么分区存储的?

因为redis集群使用多个master存储数据,每个master分片存储数据是不一样的。redis Cluste中一共会分为16384(\(2^{14}\))个槽,假如集群中有三个master,那么master1节点包含\(0~5500\)号哈希槽,master2节点包含\(5501~11000\)号哈希槽,master3节点包含\(11001~16384\)号哈希槽:

因为客户端是无中心节点实现,直接与每个节点连接,key是怎么存储的呢,它是对每个 key 计算 CRC16 值,然后对 16384 取模,可以获取 key 对应的 hash slot确定其节点。

节点虚拟槽的特点:

  • 解耦数据和节点之间的关系,简化了节点扩容和收缩难度。
  • 节点自身维护槽的映射关系,不需要客户端或者代理服务维护槽分区元数据。
  • 支持节点、槽、键之间的映射查询,用于数据路由、在线伸缩等场景。

29.2 Redis集群中节点的通信

既然Redis集群中的数据是通过哈希槽的方式分开存储的,那么集群中每个节点都需要知道其他所有节点的元数据信息(包括当前集群状态、集群中各节点负责的哈希槽、集群中各节点的master-slave状态、集群中各节点的存活状态等)

集群元数据的维护有两种方式:集中式、Gossip 协议。Redis cluster 节点间采用 gossip 协议进行通信。

29.2.1 集中式

集中式是将集群元数据(节点信息、故障等等)集中存储在某个节点上。集中式元数据集中存储的一个典型代表,就是大数据领域的 storm 。它是分布式的大数据实时计算引擎,是集中式的元数据存储的结构,底层基于 zookeeper(分布式协调的中间件)对所有元数据进行存储维护。

gossip

Redis 维护集群元数据采用另一个方式, gossip 协议,所有节点都持有一份元数据,不同的节点如果出现了元数据的变更,就不断将元数据发送给其它的节点,让其它节点也进行元数据的变更。

Gossip 协议工作原理就是节点彼此不断通信交换信息,一段时间后所有的节点都会知道集群完整的信息。

通信原理:

  • 集群中的每个节点都会单独开辟一个 TCP 通道,用于节点之间彼此通信,通信端口号在基础端口上加10000。
  • 每个节点在固定周期内通过特定规则选择几个节点发送 ping 消息。
  • 接收到 ping 消息的节点用 pong 消息作为响应。

29.3 Gossip消息

Gossip protocol 也叫 Epidemic Protocol (流行病协议),实际上它还有很多别名,比如:“流言算法”、“疫情传播算法”等。

Gossip 过程是由种子节点发起,当一个种子节点有状态需要更新到网络中的其他节点时,它会随机的选择周围几个节点散播消息,收到消息的节点也会重复该过程,直至最终网络中所有的节点都收到了消息。这个过程可能需要一定的时间,由于不能保证某个时刻所有节点都收到消息,但是理论上最终所有节点都会收到消息,因此它是一个最终一致性协议。

Gossip 协议的主要职责就是信息交换。信息交换的载体就是节点彼此发送的 Gossip 消息。常用的 Gossip消息可分为:ping 消息、pong 消息、meet 消息、fail 消息。

  • meet 消息:用于通知新节点加入。消息发送者通知接收者加入到当前集群,meet 消息通信正常完成后,接收节点会加入到集群中并进行周期性的 ping、pong 消息交换。
  • ping 消息:集群内交换最频繁的消息,集群内每个节点每秒向多个其他节点发送 ping 消息,用于检测节点是否在线和交换彼此状态信息。ping 消息发送封装了自身节点和部分其他节点的状态数据。
  • pong 消息:当接收到 ping、meet 消息时,作为响应消息回复给发送方确认消息正常通信。pong 消息内部封装了自身状态数据。节点也可以向集群内广播自身的 pong 消息来通知整个集群对自身状态进行更新。
  • fail 消息:当节点判定集群内另一个节点下线时,会向集群内广播一个 fail 消息,其他节点接收到 fail 消息之后把对应节点更新为下线状态。

所有的消息格式划分为:消息头和消息体。

29.3.1 优势

  • 扩展性:网络可以允许节点的任意增加和减少,新增加的节点的状态最终会与其他节点一致。
  • 容错:网络中任何节点的宕机和重启都不会影响 Gossip 消息的传播,Gossip 协议具有天然的分布式系统容错特性。
  • 去中心化:Gossip 协议不要求任何中心节点,所有节点都可以是对等的,任何一个节点无需知道整个网络状况,只要网络是连通的,任意一个节点就可以把消息散播到全网。
  • 一致性收敛:Gossip 协议中的消息会以一传十、十传百一样的指数级速度在网络中快速传播,因此系统状态的不一致可以在很快的时间内收敛到一致。消息传播速度达到了 logN。

29.3.2 Gossip 的缺陷

分布式网络中,没有一种完美的解决方案,Gossip 协议跟其他协议一样,也有一些不可避免的缺陷,主要是两个:

  • 消息的延迟:由于 Gossip 协议中,节点只会随机向少数几个节点发送消息,消息最终是通过多个轮次的散播而到达全网的,因此使用 Gossip 协议会造成不可避免的消息延迟。不适合用在对实时性要求较高的场景下。

  • 消息冗余:Gossip 协议规定,节点会定期随机选择周围节点发送消息,而收到消息的节点也会重复该步骤,因此就不可避免的存在消息重复发送给同一节点的情况,造成了消息的冗余,同时也增加了收到消息的节点的处理压力。而且,由于是定期发送而且不反馈,因此,即使节点收到了消息,还是会反复收到重复消息,加重了消息的冗余。

29.4 Redis集群是怎么去选择节点来通信?

Redis集群内节点通信采用固定频率(定时任务每秒执行10次),并且随机选择选取5个节点找出最久没有通信的节点发送ping消息,用于保证 Gossip 信息交换的随机性。

29.4.1 选择发送消息的节点数量

  • 集群内每个节点维护定时任务默认每秒执行10次,每秒会随机选取5个节点找出最久没有通信的节点发送ping消息,用于保证 Gossip 信息交换的随机性。
  • 每 100 毫秒都会扫描本地节点列表,如果发现节点最近一次接受 pong 消息的时间大于 cluster_node_timeout/2,则立刻发送 ping 消息,防止该节点信息太长时间未更新。
  • 根据以上规则得出每个节点每秒需要发送 ping 消息的数量 = 1+10*num(node.pong_received>cluster_node_timeout/2)

30 Redis 集群如何进行故障迁移

当集群内少量节点出现故障时通过自动故障转移保证集群可以正常对外提供服务,Redis集群的故障迁移与redis Sentinel极为相似。

30.1 故障发现

Redis 集群内节点通过 ping/pong 消息实现节点通信,消息不但可以传播节点槽信息,还可以传播其他状态如:主从状态、节点故障等。因此故障发现也是通过消息传播机制实现的,主要环节包括:主观下线(pfail)和客观下线(fail)

  • 主观下线:指某个节点认为另一个节点不可用,即下线状态,这个状态并不是最终的故障判定,只能代表一个节点的意见,可能存在误判情况。
  • 客观下线:指标记一个节点真正的下线,集群内多个节点都认为该节点不可用,从而达成共识的结果。如果是持有槽的主节点故障,需要为该节点进行故障转移。

  • 如果一个节点认为另外一个节点宕机,那么就是 pfail ,主观宕机。如果多个节点都认为另外一个节点宕机了,那么就是fail ,客观宕机,跟哨兵的原理几乎一样。

  • cluster-node-timeout 内,某个节点一直没有返回 pong ,那么就被认为 pfail

  • 如果一个节点认为某个节点 pfail 了,那么会在 gossip ping 消息中, ping 给其他节点,如果超过半数的节点都认为 pfail 了,那么就会变成 fail 。

30.2 从节点过滤

对宕机的master node,从其所有的 slave node 中,选择一个切换成 master node

检查每个 slave nodemaster node 断开连接的时间,如果超过了 cluster-node-timeout * cluster-slave-validity-factor ,那么就没有资格切换成 master

30.3 从节点选举

每个从节点,都根据自己对master复制数据的offset,来设置一个选举时间,offset 越大(复制数据越多)的从节点,选举时间越靠前,优先进行选举。

所有的 master node 开始 slave 选举投票,给要进行选举的slave进行投票,如果大部分 master node (N/2 + 1) 都投票给了某个从节点,那么选举通过,那个从节点可以切换成 master

从节点执行主备切换,从节点切换为主节点。

31 Redis Sentinel和Redis Cluster的区别和联系

Redis Sentinel是官方从Redis 2.6版本提供的高可用方案,在Redis主从复制集群的基础上,增加Sentinel集群监控整个Redis主从复制集群。当Redis主从集群master节点发生故障时,Sentinel进行故障切换,选举出新的master,即Sentinel的引入是为了支持高可用集群部署。

Redis Sentinal和Redis Cluster的区别主要在于侧重点不同:

  • Redis Sentinal主要聚焦于高可用,在主从架构基础上引入Sentinel集群,当master宕机时会自动将slave提升为master,继续提供服务。
  • Redis Cluster也是一种高可用方案。但相比于Redis Sentinel,Redis Cluster不需要额外部署Sentine集群,而是通过集群内部通信实现集群监控,故障时主从切换,同时,支持内部基于哈希实现数据分片,支持动态水平扩容。

32 redis为什么这么快?

Redis 快的原因主要有:

  • 纯内存操作:是将数据储存在内存里,结构类似于 HashMap,HashMap 的优势就是查找和操作的时间复杂度都是O(1)。它的绝大部分请求是纯粹的内存操作,内存响应大约100纳秒,所以他读写数据的时候都不会受到硬盘 I/O 速度的限制,所以速度极快。
  • 单线程:采用单线程,保证了每个操作的原子性,也减少了线程的上下文切换和竞争,也不存在多进程或者多线程导致的切换而消耗 CPU,不用去考虑各种锁的问题,不存在加锁释放锁操作。
  • 使用多路I/O复用模型,非阻塞IO。(这里“多路”指的是多个网络连接,“复用”指的是复用同一个线程。采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求,减少网络 IO 的时间消耗)
  • 高效的数据结构:5种数据结构都有自己的应用场景
  • 合理的数据编码:根据具体使用情况使用不一样的编码(字典渐进式Rehash,跳跃表)
  • 其他方面的优化:定期删除+惰性删除等

33 Redis 集群伸缩(增加节点、删除节点)

集群的伸缩包括新节点的加入和旧节点退出。

新节点时加入时,我们需要把一部分数据迁移到新节点来达到集群的负载均衡,旧节点退出时,我们需要把其上的数据迁移到其他节点上,确保该节点上的数据能够被正常访问。

我们发现集群伸缩的核心其实是数据的迁移,而在 Redis 集群中,数据是以 slot 为单位的,那么也就是说,Redis 集群的伸缩本质上是 slot 在不同机器节点间的迁移。同时,要实现扩缩容,我们不仅需要解决数据迁移,我们还需要解决数据路由问题。比如 A 节点正在向 B 节点迁移 slot1 的数据,在未完成迁移时,slot1 中的一部分数据存在节点A上,一部分数据存在节点B上。那么以下三种情况下我们该如何路由 slot1 的客户端请求?

  1. 当除了 A、B 之外的其他节点接收到 slot1 的数据请求时,其他节点该路由给哪个节点?
  2. 当节点 A 接收到 slot1 的数据请求时,A 该自己处理还是路由给 B 节点?
  3. 当节点 B 接收到 slot1 的数据请求时,B 该自己处理还是路由给A节点?

33.1 集群扩容

Redis集群加入新节点主要分为如下几步:

  1. 准备新节点
  2. 加入集群
  3. 迁移slot到新节点。

即首先启动一个集群模式下的 Redis 节点,然后通过与任意一个集群中的节点握手使得新的节点加入集群,最后再向新的节点分配它负责的 slot 以及向其迁移 slot 对应的数据。由于 Redis 采用 Gossip 协议,所以可以让新节点与任意一个现有集群节点握手,一段时间后整个集群都会知道新节点的加入。

例如我们向该集群中新加入一个节点 6385。由于我们要追求负载均衡,所以加入后四个节点每个节点负责 4096 个slots,但是集群中原来的每个节点都负责 5462 个slots,所以 6379、6380、6381 节点都需要向新的节点 6385 迁移 1366 个slots。需要说明的是,Redis 集群并没有一个自动实现负载均衡的工具,把多少 slots 从哪个节点迁移到哪个节点完全是由用户自己来指定的。

设置节点迁入迁出状态——解决路由困境

每个 Redis 集群节点的clusterState 都会存储整个集群中 slot 和 Redis 节点的对应关系用于路由。当 6379 迁移 slot1 时,会首先标级该槽属于正在迁移的状态 IMGRATING,而同样 6385 也需要标记 slot1 属于正在导入的状态 IMPORTING。从实现上看,就是分别设置 migrating_slots_to 和 importing_slots_from 两个数组的对应 index 的值。迁入迁出的状态设置主要是为了方便数据路由的实现。在未完成迁移之前,集群中的所有节点都会将 slot1 的请求重定向到6379节点。

而当 6379 把 slot1 标记为MIGRATING时,该节点会接收所有关于 slot1 的请求,但只有当请求中的 key 存在于 6379 中时该节点才会处理该请求。否则 6379 会把该请求通过 ASK 重定向到 slot1 的迁移目标节点,即 6385 节点。

而当 6385 把 slot1 标记为 IMPORTING 时,该节点也可以接受关于 slot1 的请求,但前提是该请求中必须包含 ASKING 命令。如果关于 slot1 的请求中没有 ASKING 命令,那么 6385 节点会把该请求通过 MOVED 重定向到 6379 节点。

这样我们就解决了上述的三个问题,即:

  • 当除了 A、B 之外的其他节点接收到 slot1 的数据请求时,其他节点该路由给 A 节点
  • 当节点A接收到 slot1 的数据请求时,如果请求的key存在,那么就会处理,不存在就会ASK重定向到B
  • 当节点B接收到 slot1 的数据请求时,如果请求中有 ASKING 命令,那么就会自己处理。如果没有,那么重定向到 A。

当迁移 slot1 结束后,slot1 就不再由 6379 负责而是交给 6385 节点负责。但是从其他节点的视角看,slot1 仍然由 6379 节点负责,他们接收到关于 slot1 的键的请求还是会路由到 6379 节点。所以迁移结束之后我们要向集群广播 slot1 由 6385 节点负责的消息,这样每个节点都会更新内部的路由数据,之后就可以正确的把 slot1 的键的请求路由到 6385 节点。需要说明的是,我们可以把上述的更新信息只告诉一个节点,那么随着各个节点信息的交换,一段时间后整个集群的所有节点都会更新路由。但是这样显然更新的延迟会很高,那些还没来得及更新的节点仍然会错误的把 slot1 的请求路由给 6379 节点。所以我们需要向每个节点进行广播消息。

33.2 集群收缩

集群收缩即让其中一些节点安全下线。所谓的安全下线指的是让一个节点下线之前我们需要把其负责的所有 slots 迁移到别的节点,否则该节点下线后其负责的 slots 就没法继续被服务了。节点下线的流程如下图所示:

在上面的扩容完成后,集群中共有四个节点:6379、6380、6381、6385,我们以下线 6381 为例介绍下线的流程。下线 6381 节点首先需要把其上负责 slots 的数据分别迁移到三个节点上,然后通知所有集群中的节点忘记 6381 节点,最后 6381 节点关闭下线。

Redis 的元数据在每个节点中都有一份,即每个 Redis 节点维护者从它的视角看过去集群中所有其他节点的状态。那么当集群中的所有其他节点接收到 CLUSTER FORGET 命令时会删除自己保存的 NODE_ID 对应的节点的状态,同时把 NODE_ID 对应的节点加入到黑名单中 60s。把一个节点放入黑名单意味着其他节点不会再去更新自己维护的该节点的信息,也就意味着当我们向集群中的所有节点发送CLUSTER FORGET 6381 后,6381节点 60s 内不能再次加入集群中。至此就完成了集群的缩容。

34 一致性哈希算法

使用一致性哈希可以做到解决分布式系统中节点动态变化时数据重分布过多、负载不均衡等问题

一致性哈希算法是解决分布式缓存中的节点动态加入和移除对数据分布的影响问题。在分布式系统中,节点的增减可能会导致数据重新分布,这不仅增加了数据迁移的开销,还可能影响系统的性能和稳定性。一致性哈希算法通过特定的映射和算法设计,尽可能地减少了节点变化时数据的迁移量。

致性哈希算法通过将哈希空间映射到一个环状空间,并将数据和节点都映射到该环上,比如说IPV4是32位的,我们可以,我们可以将这\(2^32\)个值抽象成一个圆环,圆环的正上方的点代表0,顺时针排列,那么通过一致性哈希固定值\(2^32\)取模就能确定它在圆环何处,如果圆环上有多个服务器,如果计算到的hash不是精确落在节点服务器上(NODE A),那么从此位置沿环顺时针“行走”,遇到的第一个 master 节点就是 key 所在位置 换一句话说:一致性Hash就是:将原本单个点的Hash映射,转变为了在一个环上的某个片段上的映射!

一致性哈希的解决方案:

  • 节点容错:一致性哈希可以通过虚拟节点(Virtual Nodes)的方式增加容错性。虚拟节点将一个物理节点映射到多个位置上,如果某个物理节点失效,其责任范围内的数据可以由下一个其他物理节点承担,从而提高系统的容错能力
  • 负载均衡:一致性哈希通过虚拟节点的方式,也能让每个物理节点承载均匀的数据量。通过分配多个虚拟节点,能有效地平衡数据在各节点之间的分布,避免某些节点承担过多负载的问题,解决了热点key问题

  • 减少数据重分布
    • 问题背景:在传统哈希算法中,当节点发生变化(增加或减少)时,数据需要重新分配到不同的节点上。由于哈希值会根据节点总数发生显著变化,导致大量的数据需要重新映射,这样会导致大量数据迁移,影响系统性能和可用性。
    • 一致性哈希的解决方案:一致性哈希算法在节点变化时,只会影响很少一部分数据,即当一个节点加入或退出集群时,只有相邻的一小部分数据需要重新分配,而不是全部数据迁移。这样大大降低了节点变化时的数据重分布量,提升了系统的稳定性和效率。

虚拟节点并不会让同一个key的请求分散到不同的物理节点上,只是在多个物理节点之间京可能均匀分配不同的key,从而平衡整体负载。

总结:

  • 所有的key哈希映射成一个环,然后物理节点哈希映射在环上某个节点,那么按顺时针顺序得到的第一个物理节点就是key所在的物理节点。但上面只映射一个物理节点,可能会导致有的物理节点负载的key多,有的少,所以用虚拟节点在环上更加均匀负载这些key,达到每个节点负载的数据尽量均衡一些,已达到负载均衡的目的
  • 某个物理节点挂了(虚拟节点挂了也意味着对应的物理节点挂了)。那么对于一致性哈希环来说,其不用为所有key重新哈希,而是只用将该物理节点(虚拟节点)的key顺时针给下一个物理节点(虚拟节点)即可,这样就解决了分布式系统中节点动态变化时数据重分布过多以及分布式当中的容错性

34 Sorted Set(即 ZSet 实现原理)

ZSet 内部编码实现:

  • ziplist(压缩列表):当哈希类型元素个数小于zset-max-ziplist-entries配置(默认128个),同时所有值小于zset-max-ziplist-value配置(默认64)时,使用ziplist作为内部实现,ziplist使用更加紧凑的结构实现多个元素的连续存储,在节省内存方面更加优秀。
  • skiplist(跳表):ziplist条件不满足时,有序集合会使用skiplist作为内部实现,因为此时ziplist的读写效率会下降

ZipList

ziplist 编码的 Zset 使用紧挨在一起的压缩列表节点来保存,第一个节点保存member,第二个保存 scoreziplist 内的集合元素按score 从小到大排序,其实质是一个双向链表。虽然元素是按 score 有序排序的, 但对 ziplist 的节点指针只能线性地移动,所以在 REDIS_ENCODING_ZIPLIST 编码的Zset 中, 查找某个给定元素的复杂度为 O(N)。

Skiplist

skiplist 编码的 Zset 底层为一个被称为 zset 的结构体,这个结构体中包含一个字典和一个跳跃表。跳跃表按 score 从小到大保存所有集合元素,查找时间复杂度为平均 O(logN),最坏 O(N) 。字典则保存着从 member 到 score 的映射,这样就可以用 O(1) 的复杂度来查找 member 对应的 score 值。虽然同时使用两种结构,但它们会通过指针来共享相同元素的 member 和 score,因此不会浪费额外的内存。

1
2
3
4
5
6
7
/* zset结构体 */
typedef struct zset {
// 字典,维护元素值和分值的映射关系
dict *dict;
// 按分值对元素值排序序,支持O(logN)数量级的查找操作
zskiplist *zsl;
} zset;

跳表数据结构

跳表查找时间复杂度为平均 O(logN),最差 O(N),在大部分情况下效率可与平衡树相媲美,但实现比平衡树简单的多,跳表是一种典型的以空间换时间的数据结构。

跳表具有以下几个特点:

  • 由许多层结构组成。
  • 每一层都是一个有序的链表。
  • 最底层 (Level 1) 的链表包含所有元素。
  • 如果一个元素出现在 Level i 的链表中,则它在 Level i 之下的链表也都会出现。
  • 每个节点包含两个指针,一个指向同一链表中的下一个元素,一个指向下面一层的元素。

跳表的查找会从顶层链表的头部元素开始,然后遍历该链表,直到找到元素大于或等于目标元素的节点,如果当前元素正好等于目标,那么就直接返回它。如果当前元素小于目标元素,那么就垂直下降到下一层继续搜索,如果当前元素大于目标或到达链表尾部,则移动到前一个节点的位置,然后垂直下降到下一层。正因为 Skiplist 的搜索过程会不断地从一层跳跃到下一层的,所以被称为跳跃表。 跳表是一个“概率型”的数据结构,指的就是跳表在插入操作时,元素的插入层数完全是随机指定的。实际上该决定插入层数的随机函数对跳表的查找性能有着很大影响,这并不是一个普通的服从均匀分布的随机数,它的计算过程如下:

  • 指定一个节点最大的层数 MaxLevel,指定一个概率 p, 层数 lvl 默认为 1 。
  • 生成一个 0~1 的随机数 r,若 r < p,且 lvl < MaxLevel ,则执行 lvl++。
  • 重复第 2 步,直至生成的 r > p 为止,此时的 lvl 就是要插入的层数。

Skiplist 与平衡树、哈希表的比较

  • Skiplist 和各种平衡树(如AVL、红黑树等)的元素是有序排列的,而哈希表不是有序的。因此,在哈希表上只能做单个 key 的查找,不适宜做范围查找。
  • 在做范围查找的时候,平衡树比 Skiplist 操作要复杂。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在 skiplist 上进行范围查找就非常简单,只需要在找到小值之后,对第 1 层链表进行若干步的遍历就可以实现。
  • 平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而 Skiplist 的插入和删除只需要修改相邻节点的指针,操作简单又快速。
  • 从内存占用上来说,Skiplist 比平衡树更灵活一些。一般来说,平衡树每个节点包含 2 个指针(分别指向左右子树),而 Skiplist 每个节点包含的指针数目平均为 1/(1−p),具体取决于参数 p 的大小。如果像 Redis 里的实现一样,取 p=1/4,那么平均每个节点包含 1.33 个指针,比平衡树更有优势。
  • 查找单个 key,Skiplist 和平衡树的时间复杂度都为 O(logN);而哈希表在保持较低的哈希值冲突概率的前提下,查找时间复杂度接近 O(1),性能更高一些。
  • 从算法实现难度上来比较,Skiplist 比平衡树要简单得多。

35 Redis应用场景

热点数据

存取数据优先从 Redis 操作,如果不存在再从文件(例如 MySQL)中操作,从文件操作完后将数据存储到 Redis 中并返回。同时有个定时任务后台定时扫描 Redis 的 key,根据业务规则进行淘汰,防止某些只访问一两次的数据一直存在 Redis 中。

例如使用 Zset 数据结构,存储 Key 的访问次数/最后访问时间作为 Score,最后做排序,来淘汰那些最少访问的 Key。

会话维持 Session

会话维持 Session 场景,即使用 Redis 作为分布式场景下的登录中心存储应用。每次不同的服务在登录的时候,都会去统一的 Redis 去验证 Session 是否正确。但是在微服务场景,一般会考虑 Redis + JWT 做 Oauth2 模块。

其中 Redis 存储 JWT 的相关信息主要是留出口子,方便以后做统一的防刷接口,或者做登录设备限制等。

分布式锁 SETNX

命令格式:SETNX key value:当且仅当 key 不存在,将 key 的值设为 value。若给定的 key 已经存在,则 SETNX 不做任何动作。

超时时间设置:获取锁的同时,启动守护线程,使用 expire 进行定时更新超时时间。如果该业务机器宕机,守护线程也挂掉,这样也会自动过期。如果该业务不是宕机,而是真的需要这么久的操作时间,那么增加超时时间在业务上也是可以接受的,但是肯定有个最大的阈值。

但是为了增加高可用,需要使用多台 Redis,就增加了复杂性,就可以参考 Redlock:Redlock分布式锁

表缓存

Redis 缓存表的场景有黑名单、禁言表等。访问频率较高,即读高。根据业务需求,可以使用后台定时任务定时刷新 Redis 的缓存表数据。

消息队列 list

主要使用了 List 数据结构。

List 支持在头部和尾部操作,因此可以实现简单的消息队列。

  • 发消息:在 List 尾部塞入数据。
  • 消费消息:在 List 头部拿出数据。

同时可以使用多个 List,来实现多个队列,根据不同的业务消息,塞入不同的 List,来增加吞吐量。

计数器 string

主要使用了 INCR、DECR、INCRBY、DECRBY 方法。

INCR key:给 key 的 value 值增加一 DECR key:给 key 的 value 值减去一

36 Redis 压力测试

Redis 自带了一个叫 redis-benchmark 的工具来模拟 N 个客户端同时发出 M 个请求。 (类似于 Apache ab 程序)。你可以使用 redis-benchmark -h 来查看基准参数。

37 生产环境中的 Redis 是怎么部署的?

看看你了解不了解你们公司的 Redis 生产集群的部署架构,如果你不了解,那么确实你就很失职了,你的 Redis 是主从架构?集群架构?用了哪种集群方案?有没有做高可用保证?有没有开启持久化机制确保可以进行数据恢复?线上 Redis 给几个 G 的内存?设置了哪些参数?压测后你们 Redis 集群承载多少 QPS?

Redis cluster,10 台机器,5 台机器部署了 Redis 主实例,另外 5 台机器部署了 Redis 的从实例,每个主实例挂了一个从实例,5 个节点对外提供读写服务,每个节点的读写高峰 QPS 可能可以达到每秒 5 万,5 台机器最多是 25 万读写请求每秒。

机器是什么配置?32G 内存+ 8 核 CPU + 1T 磁盘,但是分配给 Redis 进程的是 10g 内存,一般线上生产环境,Redis 的内存尽量不要超过 10g,超过 10g 可能会有问题。

5 台机器对外提供读写,一共有 50g 内存。

因为每个主实例都挂了一个从实例,所以是高可用的,任何一个主实例宕机,都会自动故障迁移,Redis 从实例会自动变成主实例继续提供读写服务。

你往内存里写的是什么数据?每条数据的大小是多少?商品数据,每条数据是 10kb。100 条数据是 1mb,10 万条数据是 1g。常驻内存的是 200 万条商品数据,占用内存是 20g,仅仅不到总内存的 50%。目前高峰期每秒就是 3500 左右的请求量。

其实大型的公司,会有基础架构的 team 负责缓存集群的运维

38 哪些场景适合用Redis?

  1. 缓存:对热点数据进行缓存,减轻数据库的压力,提高系统性能。
  2. 排行榜:利用 Redis 的 SortSet(有序集合)实现排行榜功能;
  3. 计数器/限速器:利用 Redis 中原子性的自增操作,我们可以统计类似用户点赞数、用户访问数等。这类操作如果用 MySQL,频繁的读写会带来相当大的压力;限速器比较典型的使用场景是限制某个用户访问某个 API 的频率,常用的有抢购时,防止用户疯狂点击带来不必要的压力;
  4. 好友关系:利用集合的一些命令,比如求交集、并集、差集等。可以方便解决一些共同好友、共同爱好之类的功能;
  5. 消息队列:除了 Redis 自身的发布/订阅模式,我们也可以利用 List 来实现一个队列机制,比如:到货通知、邮件发送之类的需求,不需要高可靠,但是会带来非常大的 DB 压力,完全可以用 List 来完成异步解耦;
  6. Session 共享:Session 是保存在服务器的文件中,如果是集群服务,同一个用户过来可能落在不同机器上,这就会导致用户频繁登陆;采用 Redis 保存 Session 后,无论用户落在那台机器上都能够获取到对应的 Session 信息。

39 单个 Redis 中有热 Key,压力特别大,怎么解决?

  • 分离热 Key:将热 Key 分离出来,存储在一个独立的 Redis 实例或集群中,以减轻主 Redis 实例的负载。
  • 缓存热点数据:在应用层引入缓存机制,将热 Key 的数据缓存到本地缓存中,减少对 Redis 的访问
  • 分布式缓存:
    • 如果热 Key 的数据量大到单个 Redis 实例无法承载,可以考虑使用分布式缓存方案。
    • 将热 Key 的数据分散到多个 Redis 实例或分片中,通过分片键(sharding key)进行路由,实现负载均衡
  • 使用读写分离:
    • 对于读操作特别频繁的热 Key,可以考虑使用读写分离架构,将读请求和写请求分散到不同的 Redis 实例上。
    • 写请求仍然发送到主 Redis 实例,而读请求可以发送到从 Redis 实例或从 Redis 集群中的多个节点。
  • 使用 Redis 集群:
    • 如果单个 Redis 实例已经无法满足需求,可以考虑使用 Redis 集群进行水平扩展。
    • Redis 集群可以自动将数据分散到多个节点上,实现负载均衡和高可用性。

40.Redis 什么情况会导致读写性能突然变慢?

Redis的读写性能突然变慢可能由多种因素导致。以下是一些常见的原因:

  • 内存不足:当Redis使用的内存达到其上限时,操作系统可能会开始使用交换分区(swap),这会导致Redis的读写操作变慢。此外,如果Redis实例运行的机器内存不足,也可能导致性能下降。
  • 网络延迟:网络问题,如网络IO压力大或客户端使用短连接与Redis相连,都可能导致读写性能下降。短连接需要频繁地建立和关闭连接,增加了额外的开销。
  • 复杂命令或查询:使用复杂度高的命令或一次性查询全量数据会增加Redis的处理时间,导致性能下降。
  • 大键(bigkey)操作:操作包含大量元素或占用大量内存空间的键(bigkey)会导致性能问题。例如,删除、修改或查询bigkey时,Redis需要消耗更多的CPU和内存资源。
  • 大量键集中过期:当大量键在相近的时间点集中过期时,Redis需要处理大量的过期事件,这可能导致性能突然下降。
  • 数据持久化:当Redis数据量较大时,无论是生成RDB快照还是进行AOF重写,都会导致fork耗时严重,从而影响读写性能。此外,如果AOF的写回策略设置为always,那么每个操作都需要同步刷回磁盘,这也会增加写操作的延迟。

41. 如何设计缓冲

在设计缓存系统时,一致性、性能、数据量是三个重要的设计维度,彼此之间可能存在权衡。以下是根据这三个维度设计缓存的原则和考虑:

41.1 一致性

  • 定义:缓存中的数据和数据库中的数据是否一致,尤其是在数据更新时,如何保证缓存和数据库同步。
  • 设计要点:有强一致性和最终一致性
    • 强一致性:缓存中的数据与数据库中的数据时刻保持一致。这种设计通常较为复杂,但可以通过以下几种方法实现:
      • 写通过(Write-Through):先写入缓存,再写入数据库,缓存和数据库数据始终保持同步。
      • 写回(Write-Back):先将数据写入缓存,缓存数据再异步写入数据库。虽然可以提升写性能,但在故障时可能导致数据不一致。
      • 双写检测:每次写数据库时强制刷新缓存,或者在数据变化时主动使缓存失效。
    • 最终一致性:允许短暂的数据不一致,最终会达到一致。这种设计较为灵活,适合对一致性要求不高的场景,通常通过 TTL(Time to Live,缓存生存时间)来控制:
      • 缓存失效机制:设置缓存过期时间,当缓存过期时重新从数据库加载数据,保证数据最终一致。
      • 消息队列异步更新:数据更新后,通过消息队列异步通知缓存系统进行更新。
  • 场景考虑:
    • 强一致性:金融、支付等对数据一致性要求高的场景,不能容忍数据差异。
    • 最终一致性:电商、社交平台等对数据一致性要求不高的场景,短暂的缓存数据差异是可以接受的。

41.2 性能

  • 定义:缓存的读写速度、并发处理能力等,决定了系统的响应时间和吞吐量。

  • 设计要点:
    • 热点数据缓存:将最常访问的数据放入缓存,提升读取性能。
    • 读写分离:缓存系统主要负责读取操作,将写操作集中到数据库,以提升缓存读取性能。
    • 数据预热:在系统启动或重启时,预先将高频访问的数据加载到缓存中,避免缓存未命中的冷启动问题。
    • 批量操作:针对大量更新或查询的场景,可以批量写入缓存或批量从缓存读取,减少单次操作的开销。
    • 并发控制:为了避免缓存穿透、击穿和雪崩,可以采取以下措施:
    • 缓存穿透:对不存在的键查询时频繁请求数据库,可通过布隆过滤器阻止无效请求。
    • 缓存击穿:热点数据失效时大量请求同时访问数据库,使用分布式锁或限流策略来控制访问量。
    • 缓存雪崩:大量缓存同时过期导致数据库瞬时负载过高,可以设置过期时间的随机化,或使用渐进式缓存刷新机制。
  • 场景考虑:
    • 低延迟高性能要求:如高并发的社交平台、游戏系统等,需要缓存数据尽可能地减少数据库查询,提升响应速度。
    • 批量数据操作:如大数据分析、报表生成等场景,可以通过批量写入/读取缓存来减少数据库压力。
  • 同时要考虑本地缓存和异地缓存:
    • 本地缓存:本地缓存是指将缓存数据存储在应用服务器的本地内存中;
    • 异地缓存:异地缓存是指将缓存数据存储在独立的缓存服务器或分布式缓存系统中(如Redis、Memcached),应用服务器通过网络访问这些缓存,异地缓存需要额外的网络通信耗时延时

41.3 数据量

  • 定义:缓存系统中要存储的数据量的大小,影响缓存策略和硬件资源的选择。

  • 设计要点:
    • 数据分片:对于大规模数据,可以使用分片机制,将数据分布到多个缓存节点上。常见的分片策略包括:
      • 一致性哈希:将数据均匀分布到多个缓存节点上,避免数据倾斜问题。
      • 范围分片:根据某个键的范围(如ID范围)将数据分布到不同节点上。
    • 数据淘汰策略:缓存的存储容量有限时,需要设置合适的淘汰策略来管理数据:
      • LRU(Least Recently Used):优先淘汰最近最少使用的数据,适合热点数据变化频繁的场景。
      • LFU(Least Frequently Used):优先淘汰访问次数最少的数据,适合访问频率差异大的场景。
      • FIFO(First In First Out):根据进入缓存的时间顺序淘汰数据,适合有时间顺序特性的场景。
    • 压缩与去重:对于大规模数据缓存,可以考虑对缓存的数据进行压缩或去重,减少缓存占用的空间。
    • 持久化:在数据量特别大的场景下,可以使用持久化缓存方案,将缓存数据部分存储到磁盘,使用内存作为缓存的热数据层。
  • 场景考虑:
    • 大规模数据缓存:电商推荐系统、广告系统、日志系统等,数据量巨大且需要高效查询,适合使用分片和淘汰策略。
    • 中等规模数据缓存:社交平台、内容管理系统等,可以根据业务需求选择适合的缓存容量与淘汰策略。

41.4 结合三维度的设计方案

  • 高一致性 + 高性能 + 中等数据量
    • 场景:金融系统、支付系统。
    • 设计思路:采用写通过或双写检测来保证强一致性,使用分布式缓存来提升性能。数据量中等,可以使用LRU或LFU淘汰策略。
  • 最终一致性 + 高性能 + 大数据量
    • 场景:电商推荐系统、广告系统、社交平台。
    • 设计思路:采用缓存失效机制保证最终一致性,使用分片机制和一致性哈希分布数据,提升性能。通过LFU淘汰策略和数据压缩来处理大规模数据。
  • 弱一致性 + 高性能 + 大数据量
    • 场景:日志系统、数据分析系统。
    • 设计思路:可以容忍弱一致性,采用持久化缓存,将大规模数据分片存储到磁盘,同时使用缓存预热和淘汰策略来保持热点数据在内存中。
  • 高一致性 + 中等性能 + 小数据量
    • 场景:后台管理系统、小型金融系统。
    • 设计思路:采用写通过保证强一致性,由于数据量较小且并发量不大,可以通过简单的缓存淘汰策略如FIFO来管理缓存。

42 读写分离适用场景

protobuf是什么?

Protocol Buffers是一种支持跨语言、跨平台、可扩展行好的能够序列化结构数据的协议工具。因此可用作通讯协议、数据存储等用途。另外它与json/xml等区别最大的就是

  • ProtoBuf是经过编码压缩的二进制格式。因此ProtoBuf相比于json,其体积更小(3 ~ 10倍,压缩编码)、速度更快(20 ~ 100倍,计算机的语言就是二进制)。
  • 更为简单:只需要定义相应的.protoc文件就能生成指定的源码供使用。

Protocol Buffer 的序列化 和反序列化简单、速度快的原因

  • 使用二进制的形式,比json用文本形式更接近计算机处理语言
  • 编码 / 解码 方式简单,只需要简单的数学运算 和位移等等。

  • 采用 Protocol Buffer 自身的框架代码和编译器共同完成。

Protocol Buffer 的数据压缩效果好(即序列化后的数据量体积小)的原因是

  • 采用了独特的编码方式,如 Varint、Zigzag 编码方式等等。
    • 比如varint是一种变长编码方式,将数据按7个bit为一组进行分组, 每分组前加1bit标示是否有下一组数据,1代表有数据,0代表没有不用存储了。依靠这种编码技术能够省去不必要的存储空间。比如说一个整数,有时候我们只用到了后面2个字节,前面都是0传输没有意义,varint就是这样压缩的。
    • 而zigzag是补充varint对于负数不友好的情况,先使用Zigzag将有符号数表示成无符号数,再采用 Varint编码
  • 采用T - L - V的数据存储方式:减少了分隔符的使用,使得数据存储得更紧凑

protobuf和json对比

可以从优缺点来对比protobuf和json,相较于json,protobuf具有优点:

  • 在性能上,其使用编码进行二进制数据流形式传输,压缩性好,能够一定程度上减小流量,从而节省网络带宽和省电。其序列化和烦序列化的速度要比json快2-100倍,传输的速度也更加快。
  • 在便捷性上,使用较为简单,能够依靠protoc自动生成序列化和反序列化的目标代码;
  • 维护成本低,只需要维护指定的.protoc文件即可,加密性较好,只有通过proto文件才能了解数据结构
  • 兼容性较好,跨平台,能够支持各种主流语言。

缺点:

  • 自解释性差:只有通过proto文件才能了解数据结构,这一点源于它的加密性好,才导致自解释性差。

protobuf的序列化和反序列化原理

protobuf以序列化和反序列化快,体积小,原理在于其采用了独特的编码技术,其编码,并使用采用T - L - V表示每个字段,所有字段拼接成一个二进制数据流的形式,减少了分隔符的使用,使得数据存储更加紧凑。

文章参考来源: >分布式锁:RedLock 你这锁也不包熟啊!

《redis设计与实现》

面试官:Redis的数据完全是存在内存中的吗?Redis的虚拟内存机制是什么?

Redis哨兵