数据库Redis

Redis数据类型

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

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 是安全的,不会造成缓冲区溢出。

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

内部编码主要有:

  • int:8个字节的长整型
  • embstr:小于等于39个字节的字符串
  • raw:大于39个字节的字符串

各个指令的时间复杂度

  • 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,自增改为自减。

Hash

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

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)

各个指令的时间复杂度

与 Hash 相关的常用命令:

  • HSET:将 key 对应的 Hash 中的 field 设置为 value。如果该 Hash 不存在,会自动创建一个。时间复杂度 O(1)
  • HGET:返回指定 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 命令进行游标式的遍历

List

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

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

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

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

内部编码有:

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

各个指令的时间复杂度

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

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

  • LINDEX:返回指定 List 指定 index 上的元素,如果 index 越界,返回nil。index 数值是回环的,即 -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 长度大的情况下耗时会明显增加,应谨慎使用。

Set

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

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

内部编码主要有:

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

各个指令的时间复杂度

  • 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 上进行

ZSet

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

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

内部编码实现:

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

各个指令的时间复杂度

  • ZADD:向指定 Sorted Set 中添加 1 个或多个 member,时间复杂度 O(Mlog(N)),M 为添加的 member 数量,N 为 Sorted Set 中的 member 数量
  • ZREM:从指定 Sorted Set 中删除 1 个或多个 member,时间复杂度 O(Mlog(N)),M 为删除的 member 数量,N 为 Sorted Set 中的 member 数量
  • ZCOUNT:返回指定 Sorted Set 中指定 score 范围内的 member 数量,时间复杂度:O(log(N))
  • ZCARD:返回指定 Sorted Set 中的 member 数量,时间复杂度 O(1)
  • ZSCORE:返回指定 Sorted Set 中指定 member 的 score,时间复杂度 O(1)
  • 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 命令),以实现游标式的遍历。

Bitmaps

计算机使用二进制位(位)作为信息的基础单位,1个字节等于8位,Redis 提供 Bitmaps 可以实现对位的操作。Bitmaps 本身不是一种数据结构,实际上它是字符串,但可以对字符串的位进行操作

可以把 Bitmaps 看成一个以位为单位的数组,数组的每个单元只能存储 0 和 1,数组的下标在 Bitmaps 中叫做偏移量

常用指令

  • setbit key offset value:设置键的第 offset 个位的值
  • getbit key offset:获取值
  • bitcount [start][end]:获取 Bitmaps 指定范围值为 1 的个数,其中 start 和 end 代表起始和结束字节数(一个字节占8位,例如start=2表示从下标16开始算)

Bitmaps 间的运算:

  • bitop op destkey key[key …]:op可以是and(交集),or(并集),not(非),xor(异或)操作, 操作结果保存在 deskey 中
  • bitpos key targetBit [start][end]:计算 Bitmaps 中第一个值为targetBit的偏移量

应用场景:
当用户量很大时,用来记录当天访问人数,可以大幅度减少内存

HyperLogLog

HyperLogLog 实际类型为字符串类型,它是一种基数算法,可以利用极小的内存空间独立完成总数的统计

  • pfadd key element [element …]:添加
  • pfcount key [key …]:计算独立用户数
  • pfmerge destkey sourcekey [sourcekey …]:合并:求出多个 HyperLogLog 的并集并赋值给 deskey

HyperLogLog 内存占用量非常小,但是存在错误率

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

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

高性能

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

高并发

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

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

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

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

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

redis的线程模型

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

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

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

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

  1. 文件事件;
  2. 时间事件。

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

《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事件处理器

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

redis的一次通信过程

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

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

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

这就完成了一次通信。

Redis 使用单线程的原因

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

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

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

可维护性

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

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

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

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

并发处理

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

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

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

性能瓶颈

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

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

简单总结一下,Redis 并不是 CPU 密集型的服务,如果不开启 AOF 备份,所有 Redis 的操作都会在内存中完成不会涉及任何的 I/O 操作,这些数据的读写由于只发生在内存中,所以处理速度是非常快的;整个服务的瓶颈在于网络传输带来的延迟和等待客户端的数据传输,也就是网络 I/O,所以使用多线程模型处理全部的外部请求可能不是一个好的方案。

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

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

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

Redis 多线程

Redis 4.0

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

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

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

删除操作多线程的原因

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

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

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

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

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

io-threads-do-reads yes

开启多线程后,还需要设置线程数,否则是不生效的。同样需要修改 redis 配置文件 redis.conf :

io-threads 4 #官网建议4核的机器建议设置为2或3个线程,8核的建议设置为6个线程

关于线程数的设置,官方有一个建议:4核的机器建议设置为2或3个线程,8核的建议设置为6个线程,线程数一定要小于机器核数。还需要注意的是,线程数并不是越大越好,官方认为超过了8个基本就没什么意义了。

Redis 多线程实现机制

Redis多线程实现机制

流程简述如下:

  1. 主线程负责接收建立连接请求,获取 socket 放入全局等待读处理队列

  2. 主线程处理完读事件之后,通过 RR(Round Robin) 将这些连接分配给这些 IO 线程

  3. 主线程阻塞等待 IO 线程读取 socket 完毕

  4. 主线程通过单线程的方式执行请求命令,请求数据读取并解析完成,但并不执行

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

  6. 解除绑定,清空等待队列

Redis多线程实现流程

该设计有如下特点:

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

  2. IO 线程只负责读写 socket 解析命令,不负责命令处理

redis和memcached的区别

共同点

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

区别

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

Redis 给缓存数据设置过期时间有啥用?

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

因为内存是有限的,如果缓存中的所有数据都是一直保存的话,很容易直接导致 Out of memory。

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

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 天内有效。

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

Redis 判断数据过期的原理

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

Redis过期时间实现原理

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

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

redis过期键处理方式

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

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

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

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

从 Redis 对过期键的处理方式可以看出,仅仅通过设置过期时间来保证不出现 OOM 还是有问题的。如果定期删除漏掉了很多过期key,然后也没及时去查,也就没走惰性删除,此时会有大量过期 key 堆积在内存里,导致 redis 内存块耗尽。因此,redis 采用内存淘汰机制进行处理。

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

当Redis中内存使用量超出时,会施行数据淘汰策略

Redis支持6种淘汰策略:

  • volatile-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:禁止驱逐数据,也就是说当内存不足以容纳新写入的数据时,新写入操作会报错。

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

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

Redis数据持久化(怎么保证Redis挂掉之后再重启数据不会丢失)

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

RDB

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

触发机制

手动触发

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

自动触发

  1. 使用save相关配置,会自动触发bgsave,在redis.conf配置文件中默认有此下配置:
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命令创建快照。
  1. 如果从节点执行全量复制操作,主节点自动执行bgsave生成RDB文件并发送给从节点
  2. 执行debug reload命令时重新加载Redis时,也会自动触发save操作
  3. 默认情况下执行shutdown命令,如果没有开启 AOF 持久化功能则自动执行bgsave

流程说明

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下。

优缺点

优点

  • RDB是一个紧凑压缩的二进制文件,代表Redis在某个时间点上的一个数据快照,非常适用于备份,全量复制等场景
  • Redis加载RDB恢复数据远远快于AOF的方式

缺点

  • 没法做到实时持久化/秒级持久化
  • RDB使用特定的二进制格式保存,Redis演变过程中有很多RDB版本,存在老版本无法兼容新版本的问题
  • 如果数据量很大,保存快照的时间会很长。

AOF

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

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

appendonly yes

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

工作流程

  1. 写入命令(append):所有的写入命令都会追加到aof_buf缓冲区
  2. 文件同步(aync):AOF缓冲区根据对应的策略向硬盘做同步操作
  3. 文件重写(rewrite):随着AOF文件越来越大,定期对AOF文件进行重写,达到压缩的目的
  4. 重启加载(load):当Redis服务器重启时,可以加载AOF文件进行数据恢复

在Redis的配置文件中存在三种不同的 AOF 持久化同步策略,它们分别是:

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秒,周期不可控,加大每次同步的数据量,虽然提升了性能,安全性无法保证

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

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

如果把混合持久化打开,AOF 重写的时候就直接把 RDB 的内容写到 AOF 文件开头。这样做的好处是可以结合 RDB 和 AOF 的优点, 快速加载同时避免丢失过多的数据。当然缺点也是有的, AOF 里面的 RDB 部分是压缩格式不再是 AOF 格式,可读性较差。

AOF重写机制

随着命令不断写入AOF,文件会越来越大,Redis引入重写机制压缩文件体积,AOF文件重写是把Redis进程内的数据转化为写命令同步到新AOF文件的过程
重写后AOF文件变小的原理:

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

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

重写过程的触发:

  • 手动触发:使用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文件生成期间丢失这部分数据

  1. 子进程根据内存快照,按照命令合并规则写入到新的AOF文件,每次批量写入硬盘数据量由配置aof-rewrite-incremental-fsync控制,默认32MB,防止单次刷盘数据过多造成硬盘阻塞

  2. (1). 新AOF文件写入完成后,子进程发送信号给父进程,父进程更新统计信息

(2). 父进程把AOF重写缓冲区的数据写入到新的AOF文件

(3). 使用新的AOF文件替换老文件,重写完成

Redis事务

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

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

事务中出现错误的情况:

  • 语法错误(编译器错误):语法错误,会导致整个事务无法执行。例如在开启事务后,修改k1值为11,k2值为22,但k2语法错误,最终导致事务提交失败,k1、k2保留原值:

  • 运行时错误:例如错将sadd写成zadd,这时候执行exec正确的命令会被执行,Redis不支持回滚功能。例如:在开启事务后,修改k1值为11,k2值为22,但将k2的类型作为List,在运行时检测类型错误,最终导致事务提交失败,此时事务并没有回滚,而是跳过错误命令继续执行, 结果k1值改变、k2保留原值:

    127.0.0.1:6379> set k1 v1
    OK
    127.0.0.1:6379> set k1 v2
    OK
    127.0.0.1:6379> MULTI
    OK
    127.0.0.1:6379> set k1 11
    QUEUED
    127.0.0.1:6379> lpush k2 22
    QUEUED
    127.0.0.1:6379> EXEC
    1) OK
    2) (error) WRONGTYPE Operation against a key holding the wrong kind of value
    127.0.0.1:6379> get k1
    "11"
    127.0.0.1:6379> get k2
    "v2"
    127.0.0.1:6379>
    

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

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

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

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

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

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

基于zookeeper临时有序节点可以实现的分布式锁,大致思想为:每个客户端对某个方法加锁时,在zookeeper上的与该方法对应的指定节点的目录下,生成一个唯一的瞬时有序节点。 判断是否获取锁的方式很简单,只需要判断有序节点中序号最小的一个。 当释放锁的时候,只需将这个瞬时节点删除即可。同时,其可以避免服务宕机导致的锁无法释放,而产生的死锁问题。完成业务流程后,删除对应的子节点释放锁。

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

Redlock分布式锁

Redis官方提出一种基于Redis实现的分布式锁的方式叫Redlock,这种方法比原先单节点的方法更安全。它可以保证以下特性:

  • 安全特性:互斥访问,即永远只有一个client能拿到锁
  • 避免死锁:最终client都可能拿到锁,不会出现死锁的情况,即使原本锁住某资源的clientcrash了或者出现了网络分区
  • 容错性:只要大部分Redis节点存活就可以正常提供服务

怎么在单点上实现分布式锁

主要通过以下命令:

SET resource_name my_random_value NX PX 30000

该命令仅当key不存在(NX保证)时,set值,并且设置过期时间为3000ms(PX保证),值my_random_value必须是所有client和所有锁请求发生期间唯一的,释放锁的逻辑是:

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

上述实现避免了释放另一个client创建的锁,如果只有del命令的话,如果client1拿到lock1之后因为某些操作阻塞了很长时间,此时Redis端lock1已经过期了并且已经被重新分配给了client2,那么client1此时再去释放这把锁就会造成client2原本获取到的锁被client1无故释放了,但现在为每个client分配一个uniquestring值可以避免这个问题。至于如何去生成这个unique string,方法很多随意选择一种就行了。

Redlock算法

假设有5个master节点,分布在不同的机房,为了获得锁,client会进行如下操作:

  1. 得到当前的时间,微秒单位
  2. 尝试顺序的在5个实例上申请锁,当然需要使用相同的keyrandom value,这里一个client需要合理设置与master节点沟通的timeout大小,避免长时间和一个fail的节点浪费时间
  3. client在大于等于 3 个master上成功申请到锁的时候,且它会计算申请锁消耗了多少时间,这部分消耗的时间采用获得锁的当下时间减去第一步获得的时间戳得到,如果锁的持续时长(lock validity time)比流逝的时间多的话,那么锁就真正获取到了。
  4. 如果锁申请到了,那么锁真正的lock validity time应该是origin(lock validity time) - 申请锁期间流逝的时间
  5. 如果client申请锁失败了,那么它就会在少部分申请成功锁的master节点上执行释放锁的操作,重置状态。

这个算法是基于一个假设:虽然不存在可以跨进程的同步时钟,但是不同进程时间都是以差不多相同的速度前进,这个假设不一定完全准确,但是和自动释放锁的时间长度相比不同进程时间前进速度差异基本是可以忽略不计的。

失败重试

如果一个client申请锁失败了,那么它需要稍等一会再重试避免多个client同时申请锁的情况,最好的情况是一个client需要几乎同时向5个master发起申请锁申请。另外就是如果client申请锁失败了它需要尽快在它曾经申请到锁的master上执行unlock操作,便于其它client获取这把锁,避免这些锁过期造成的时间浪费,当然如果这时候网络分区使得client无法联系上这些master,那么这种浪费就是不得不付出的代价了。

放锁

放锁操作很简单,就是依次释放所有节点上的锁就行了

性能、崩溃恢复和fsync

如果节点没有持久化机制,client从 5 个master中的 3 个处获得了锁,然后其中一个重启了,这时注意整个环境中又出现了 3 个master可供另一个client申请同一把锁! 违反了互斥性。如果开启了 AOF 持久化那么情况会稍微好转一些,因为 Redis 的过期机制是语义层面实现的,所以在server挂了的时候时间依旧在流逝,重启之后锁状态不会受到污染。但是考虑断电之后呢,AOF部分命令没来得及刷回磁盘直接丢失了,除非配置刷回策略为fsnyc = always,但这会损伤性能。

解决这个问题的方法是,当一个节点重启之后,规定在max TTL期间它是不可用的,这样它就不会干扰原本已经申请到的锁,等到它crash前的那部分锁都过期了,环境不存在历史锁了,那么再把这个节点加进来正常工作。

RedLock缺陷

需要用到锁的主要有以下两种场景考虑:

  • 性能:拥有这把锁使得你不会重复劳动(例如一个job做了两次),如果这把锁fail了,两个节点同时做了这个job,那么这个job增加了你的成本
  • 正确性:拥有锁可以防止并发操作污染了你的系统或者数据,如果这把锁fail了,两个节点同时操作了一份数据,结果可能是数据不一致、数据丢失、file冲突等,会导致严重的后果

RedLock算法不可靠的场景

在分布式环境下,锁比mutex这类复杂,因为涉及到不同节点、网络通信并且他们随时可能无征兆的fail。假设现在有一个场景:一个client要修改一个文件,它先申请得到锁,然后修改文件写回,放锁。另一个client再申请锁。代码流程如下:

function writeData(filename,data){
    var lock = lockService.acquireLock(filename);
    if(!lock) {
        throw 'Failed to acquire lock!';
    }
    
    try {
        var file = storage.readFile(filename);
        var updated = updateContents(file,data);
        sotrage.writeFile(filename,updated);
    }finally {
        lock.release();
    }
}

上述代码在以下流程中还是有可能出现bug:

RedLock锁流程

在上述图中,得到锁的client1在持有锁的期间pause(暂停)了一段时间,例如GC停顿。锁有过期时间(一般叫租约,为了防止某个 client 崩溃之后一直占有锁),但是如果 GC 停顿太长超过了锁租约时间,此时锁已经被另一个 client2 所得到,原先的 client1 还没有感知到锁过期,这时候client再进行写时就会发生错误。即使在client1写回之前检查一下锁是否过期也无法解决这个问题,因为GC可能在任何时候发生,即使在最后的检查和写操作期间。

除了GC停顿,还有很多原因可能导致进程pause。例如进程可能读取尚未进入内存的数据,所以它得到一个 page fault (错误页面)并且等待 page 被加载进缓存;还有可能你依赖于网络服务;或者其他进程占用 CPU;或者其他意外发生 SIGSTOP 等。

解决方式

使用Fencing(栏栅)使锁变安全

在每次写操作时加入一个fencing token,这个场景下,fencing token可以是一个递增的数字(lock service可以做到),每次有client申请锁就递增一次:

使用Fencing解决锁不安全问题

client1 申请锁同时拿到token33,然后它进入长时间的停顿锁也过期了。client2 得到锁和token34写入数据,紧接着 client1 活过来之后尝试写入数据,自身token3334小因此写入操作被拒绝。

注意这需要存储层来检查token,但这并不难实现。如果使用Zookeeper作为lock service的话那么可以使用zxid作为递增数字。 但是对于 Redlock ,没什么生成fencing token的方式,并且怎么修改 Redlock 算法使其能产生fencing toke并不那么显而易见。因为产生token需要单调递增,除非在单节点Redis上完成但是这又没有高可靠性,需要引进一致性协议来让 Redlock 产生可靠的fencing token

使用时间来解决一致性

学术界有个说法,算法对时间不做假设:因为进程可能pause一段时间、数据包可能因为网络延迟延后到达、时钟可能根本就是错的。而可靠的算法依旧要在上述假设下做正确的事情。

同样Redlock算法也是假设所有 Redis 节点都能对同一个 Key 在其过期前持有差不多的时间、跟过期时间相比网络延迟很小、跟过期时间相比进程 pause 很短。

Redlock不可靠的例子

由于时间问题

  1. client1从ABC三个节点处申请到锁,DE由于网络原因请求没有到达
  2. C节点的时钟往前推了(或者C崩溃了)导致lock过期
  3. client2在CDE出获得了锁,AB由于网络原因请求未到达
  4. 此时client1client2都获得了锁

由于进程pause而不是时钟不可靠发生的问题

  1. client1从ABCDE处获得了锁
  2. 当获得锁的response还没到达client1client1进入GC停顿
  3. 停顿期间锁已经过期了
  4. client2在ABCDE处获得了锁
  5. client1GC完成收到了锁的response,此时两个client又拿到了同一把锁

这些例子说明了,仅有在假设了一个同步性系统模型的基础上,Redlock 才能正常工作,也就是系统能满足以下属性:

  • 网络延时边界,即假设数据包一定能在某个最大延时之内到达
  • 进程停顿边界,即进程停顿一定在某个最大时间之内
  • 时钟错误边界,即不会从一个坏的 NTP 服务器处取得时间

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

Redis底层实现/用到了哪些数据结构

字典(也叫哈希)

Redis 使用的是key-value的存储形式。

Redis的字典dict中包含两个哈希表dictht,这是为了方便进行rehash操作。在扩容时,将其中一个dictht上的键值对rehash到另一个dictht上面,完成之后释放空间并交换两个dictht角色。

typedef struct dict {
    dictType *type;
    void *privdata;
    dictht ht[2];
    long rehashidx;
    unsigned long iterators;
} dict;

dictht是一个散列表结构,使用拉链法解决哈希冲突

typedef struct dictht {
    dictEntry **table;
    unsigned long size;
    unsigned long sizemask;
    unsigned long used;
} dictht;
typedef struct dictEntry {
    void *key;
    union {
        void *val;
        uint64_t u64;
        ini64_t s64;
        double d;
    } v;
    struct dictEntry *next;
} dictEntry;

rehash操作不是一次性完成的,而是采用渐进式,这是为了避免一次性执行过多的rehash操作给服务器造成过大的负担。

渐进式rehash通过记录dictrehashidx完成,它从0开始,然后每执行一次rehahsh都会递增。例如在一次rehash中,要把dict[0] rehash 到 dictht[1],这一次会把dictht[0]table[rehashidx]的键值对rehashdictht[1]上,dictht[0]table[rehashidx]指向null,并令rehashidx++

rehash期间,每次对字典执行添加、删除、查找或者更新操作时,都会执行一次渐进式rehash

采用渐进式rehash会导致字典中的数据分散在两个dictht上,因此对字典的查找操作也需要到对应的dictht去执行。

跳跃表

是有序集合(ZSet)的底层实现之一。

与红黑树相比,跳跃表有以下优点:

  • 插入速度非常快速,因为不需要进行旋转等操作来维护平衡性
  • 更容易实现
  • 支持无锁操作

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,第二个保存 score。ziplist 内的集合元素按 score 从小到大排序,其实质是一个双向链表。虽然元素是按 score 有序排序的, 但对 ziplist 的节点指针只能线性地移动,所以在 REDIS_ENCODING_ZIPLIST 编码的 Zset 中, 查找某个给定元素的复杂度为 O(N)。

ziplist结构图

Skiplist

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

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

ZSet中skiplist结构

Redis 中 Skiplist 的实现

跳表是一个“概率型”的数据结构,指的就是跳表在插入操作时,元素的插入层数完全是随机指定的。实际上该决定插入层数的随机函数对跳表的查找性能有着很大影响,这并不是一个普通的服从均匀分布的随机数,它的计算过程如下:

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

在 Redis 的 skiplist 实现中,p=1/4 ,MaxLevel=32。

Redis中的 Skiplist 与经典 Skiplist 相比,有如下不同:

  • 分数(score)允许重复,即 Skiplist 的 key 允许重复,经典 Skiplist 中是不允许的。
  • 在比较时,不仅比较分数(相当于 Skiplist 的 key),还比较数据本身。在 Redis 的 Skiplist 实现中,数据本身的内容唯一标识这份数据,而不是由 key 来唯一标识。另外,当多个元素分数相同的时候,还需要根据数据内容来进字典排序。
  • 第 1 层链表不是一个单向链表,而是一个双向链表。这是为了方便以倒序方式获取一个范围内的元素。

Redis Zset 采用跳表而不是平衡树的原因

  1. 虽然是空间换时间,但也不是非常耗费内存,实际上取决于生成层数函数里的概率 p,取决得当的话其实和平衡树差不多。

  2. 因为有序集合经常会进行 ZRANGE 或 ZREVRANGE 这样的范围查找操作,跳表里面的双向链表可以十分方便地进行这类操作。

  3. 实现简单,ZRANK 操作还能达到 O(logN) 的时间复杂度。

Redis为什么使用跳表而不使用B+树或二叉树呢?

MySQL 之所以使用 B+树,是考虑到B+树的层数比较低,可以减少磁盘IO。而redis 是纯纯的内存数据库。

进行读写数据都是操作内存,跟磁盘没啥关系,因此也不存在磁盘IO了,所以层高就不再是跳表的劣势了。

并且B+树是有一系列合并拆分操作的,换成红黑树或者其他AVL树的话也是各种旋转,目的也是为了保持树的平衡

而跳表插入数据时,只需要随机一下,就知道自己要不要往上加索引,根本不用考虑前后结点的感受,也就少了旋转平衡的开销

使用跳转能够拥有更高的插入性能,因此,redis选了跳表,而不是B+树。

Redis 主从复制

建立复制的方法有三种:

  1. 在配置文件中加入slaveof {masterHost} {masterPort},随Redis启动生效
  2. redis-server启动命令后加入--slaveof {masterHost} {masterPort}生效
  3. 直接使用命令:slaveof {masterHost} {masterPort}生效

slaveof 命令指定主节点,并将当前节点设置为从节点,建立成功后,从节点会复制主节点的数据。

复制过程:

  1. 保存主节点信息:执行slaveof后从节点只保存主节点的地址信息便直接返回,还未建立复制的完整流程
  2. 主从建立socket连接:从节点内部通过每秒运行的定时任务维护复制的相关逻辑,当定时任务发现存在新的主节点后,会尝试与该节点建立网络连接(通过建立socket套接字,接受主节点发送的复制命令)。如果从节点无法建立连接,定时任务会无限重试直到连接成功或者执行slaveof no one
  3. 发送ping命令:连接建立成功后,从节点发送 ping 命令请求首次通信,检测主从之间网络套接字是否可用,同时检测主节点当前是否可接受处理命令。如果发送ping命令后,从节点没有收到主节点的pong回复或者超时,比如网络超时或者主节点正在阻塞无法响应命令,从节点会断开复制连接,下次定时任务会发起重连
  4. 权限验证:如果主节点设置了requirepass参数,则需要密码验证,从节点必须配置masterauth参数保证与主节点相同的密码才能通过验证;如果验证失败复制将终止,从节点重新发起复制流程。
  5. 同步数据集:主从复制连接正常通信后,对于首次建立复制的场景,主节点会把持有的数据全部发送给从节点,这部分操作是耗时最长的步骤。Redis在2.8版本以后采用新复制命令psync进行数据同步,原来的sync命令依然支持,保证新旧版本的兼容性。新版同步划分两种情况:全量同步和部分同步。
  6. 命令持续复制:当主节点把当前的数据同步给从节点后,便完成了复制的建立流程。接下来主节点会持续地把写命令发送给从节点,保证主从数据一致性。

    写命令的发送过程是异步完成,也就是说主节点自身处理完写命令后直接返回给客户端,并不等待从节点复制完成。这就会造成从节点的数据相对主节点存在延迟,具体延迟多少字节,可以通过在主节点执行info replication命令查看。

主从复制下数据同步方法

2.8以后Redis使用 psync 命令完成主从数据复制,数据同步过程分为全量复制和部分复制

  • 全量复制:一般用于初次复制场景,Redis早期支持的复制功能只有全量复制,它会把主节点全部数据一次性发送给从节点,当数据量较大时,会对主从节点和网络造成很大的开销。

  • 部分复制:用于处理在主从复制中因网络闪断等原因造成的数据丢失场景,当从节点再次连上主节点后,如果条件允许,主节点会补发丢失数据给从节点。因为补发的数据远远小于全量数据,可以有效避免全量复制的过高开销。

    psync命令运行需要以下组件:

  1. 主从节点各自复制偏移量
  2. 主节点复制积压缓冲区
  3. 主节点运行 id

部分复制流程如下:

  1. 当主从节点之间网络出现中断时,如果超过 repl-timeout 时间,主节点会认为从节点故障并中断复制连接。
  2. 主从连接中断期间主节点依然响应命令,但因复制连接中断命令无法发送给从节点,不过主节点内部存在的复制积压缓冲区,依然可以保存最近一段时间的写命令数据,默认最大缓存1MB。
  3. 当主从节点网络恢复后,从节点会再次连上主节点。
  4. 当主从连接恢复后,由于从节点之前保存了自身已复制的偏移量和主节点的运行ID。因此会把它们当作 psync 参数发送给主节点,要求进行部分复制操作。
  5. 主节点接到 psync 命令后首先核对参数 runId 是否与自身一致,如果一致,说明之前复制的是当前主节点;之后根据参数 offset 在自身复制积压缓冲区查找,如果偏移量之后的数据存在缓冲区中,则对从节点发送 +CONTINUE 响应,表示可以进行部分复制。
  6. 主节点根据偏移量把复制积压缓冲区里的数据发送给从节点,保证主从复制进入正常状态。

Redis 哨兵模式

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

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

部署方式

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

    Sentinel节点默认的端口是26379

启动节点的方式有两种:

  1. 使用redis-sentinel命令
redis-sentinel sentinel配置文件.conf
  1. 使用redis-server命令加上 --sentinel 参数
redis-server sentinel配置文件.conf —sentinel

每个sentinel节点会对主节点和所有从节点进行监控,同时Sentinel节点之间也会相互监控

实现原理

三个定时任务

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 节点都建立起连接,实现对每个节点的监控,这个定时任务是节点失败判定的重要依据。

主观下线和客观下线

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

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

领导者Sentinel节点的选取

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

事实上每个Sectinel只有一票,会最先给发起请求的节点。基本上谁先完成客观下线,就会成为领导者

故障转移

  1. 在从节点列表中选出一个节点作为新的主节点,选择方法如下:
  • 过滤:“不健康”(主观下线、断线)、5秒内没有回复过Sentinel节点ping响应、与主节点失联超过 down-after-milliseconds*10 秒。
  • 选择slave-priority(从节点优先级)最高的从节点列表,如果存在则返回,不存在则继续。
  • 选择复制偏移量最大的从节点(复制的最完整),如果存在则返回,不存在则继续。
  • 选择runid最小的从节点。
  1. Sentinel领导者节点会对第一步选出来的从节点执行slaveof no one命令让其成为主节点。
  2. Sentinel领导者节点会向剩余的从节点发送命令,让它们成为新主节点的从节点,复制规则和parallel-syncs参数有关
  3. Sentinel节点集合会将原来的主节点更新为从节点,并保持着对其关注,当其恢复后命令它去复制新的主节点。

Redis 为什么那么快?

Redis 快的原因主要有:

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

Redis 集群原理

集群数据分区

Redis Cluster 采用虚拟槽分区,所有的键根据哈希函数映射到 0~16383 整数槽内,计算公式:slot=CRC16(key)& 16383。每一个节点负责维护一部分槽以及槽所映射的键值数据。

Redis数据分区

Redis虚拟槽分区的特点:

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

可以使用 redis-cli --cluster 搭建集群

节点通信

通信流程:在分布式存储中需要提供维护节点元数据信息的机制,所谓元数据是指:节点负责哪些数据,是否出现故障等状态信息。常见的元数据维护方式分为:集中式和 P2P 方式。Redis集群采用 P2P 的 Gossip(流言)协议,Gossip 协议工作原理就是节点彼此不断通信交换信息,一段时间后所有的节点都会知道集群完整的信息。

通信过程:

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

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 消息之后把对应节点更新为下线状态。

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

优势

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

Gossip 的缺陷

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

  • 消息的延迟:

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

  • 消息冗余:

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

节点选择

Redis集群内节点通信采用固定频率(定时任务每秒执行10次)。

Redis 集群节点选择

由上图可以看出:消息交换的成本主要体现在单位时间选择发送消息的节点数量和每个消息携带的数据量

选择发送消息的节点数量

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

消息数据量

每个 ping 消息的数据量体现在消息头和消息体中,其中消息头主要占用空间的字段是 myslots[CLUSTER_SLOTS/8],占用 2KB,这块空间占用相对固定。消息体会携带一定数量的其他节点信息用于信息交换。

def get_wanted():
    int total_size = size(cluster.nodes) 
    # 默认包含节点总量的1/10 594 
    int wanted = floor(total_size/10); 
    if wanted < 3: 
        # 至少携带3个其他节点信息 
        wanted = 3; 
    if wanted > total_size -2 : 
        # 最多包含total_size - 2个 
        wanted = total_size - 2; 
    return wanted;

Redis 集群如何进行故障迁移

当集群内少量节点出现故障时通过自动故障转移保证集群可以正常对外提供服务

故障发现

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

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

主观下线

集群中每个节点都会定期向其他节点发送 ping 消息,接收节点回复 pong 消息作为响应。如果在 cluster-node-timeout 时间内通信一直失败,则发送节点会认为接收节点存在故障,把接收节点标记为主观下线(pfail)状态。

具体流程:

  1. 节点 a 发送 ping 消息给节点 b,如果通信正常将接收到 pong 消息,节点 a 更新最近一次与节点 b 的通信时间。
  2. 如果节点 a 与节点 b 通信出现问题则断开连接,下次会进行重连。如果一直通信失败,则节点 a 记录的与节点 b 最后通信时间将无法更新。
  3. 节点 a 内的定时任务检测到与节点 b 最后通信时间超过 cluster-node-timeout 时,更新本地对节点 b 的状态为主观下线(pfail)。

客观下线

当某个节点判断另一个节点主观下线后,相应的节点状态会跟随消息在集群内传播。ping/pong 消息的消息体会携带集群 1/10 的其他节点状态数据,当接受节点发现消息体中含有主观下线的节点状态时,会在本地找到故障节点的 ClusterNode 结构,保存到下线报告链表中。结构如下:

struct clusterNode { /* 认为是主观下线的clusterNode结构 */ 
    list *fail_reports; /* 记录了所有其他节点对该节点的下线报告 */ 
    ... 
};

通过 Gossip 消息传播,集群内节点不断收集到故障节点的下线报告。当半数以上持有槽的主节点都标记某个节点是主观下线时,触发客观下线流程。

集群模式下只有处理槽的主节点才负责读写请求和集群槽等关键信息维护,而从节点只进行主节点数据和状态信息的复制。所以必须是负责槽的主节点参与故障发现决策

客观下线流程:

  1. 当消息体内含有其他节点的 pfail 状态会判断发送节点的状态,如果发送节点是主节点则对报告的 pfail 状态处理,从节点则忽略。
  2. 找到 pfail 对应的节点结构,更新 clusterNode 内部下线报告链表。
  3. 根据更新后的下线报告链表告尝试进行客观下线。

下线报告链表

每个 ClusterNode 结构中都会存在一个下线链表结构,保存了其他主节点针对当前节点的下线报告:

typedef struct clusterNodeFailReport { 
    struct clusterNode *node; /* 报告该节点为主观下线的节点 */ 
    mstime_t time; /* 最近收到下线报告的时间 */ 
} clusterNodeFailReport;

下线报告中保存了报告故障的节点结构和最近收到下线报告的时间,当接收到 fail 状态时,会维护对应节点的下线上报链表。每个下线报告都存在有效期,每次在尝试触发客观下线时,都会检测下线报告是否过期,对于过期的下线报告将被删除。如果在 cluster-node-time*2 的时间内该下线报告没有得到更新则过期并删除.

尝试客观下线

集群中的节点每次接收到其他节点的 pfail 状态,都会尝试触发客观下线:

  1. 首先统计有效的下线报告数量,如果小于集群内持有槽的主节点总数的一半则退出。
  2. 当下线报告大于槽主节点数量一半时,标记对应故障节点为客观下线状态。
  3. 向集群广播一条 fail 消息,通知所有的节点将故障节点标记为客观下线,fail 消息的消息体只包含故障节点的ID。

广播 fail 消息有一下两个作用:

  • 通知集群内所有的节点标记故障节点为客观下线状态并立即生效
  • 通知故障节点的从节点触发故障转移流程

故障恢复

故障节点变为客观下线后,如果下线节点是持有槽的主节点,则需要在它的从节点中选出一个替换它,从而保证集群的高可用。下线主节点的所有从节点承担故障恢复的义务,当从节点通过内部定时任务发现自身复制的主节点进入客观下线时,将会触发故障恢复流程:

  • 资格检查
  • 准备选举时间
  • 发起选举
  • 选举投票
  • 替换主节点
  • 资格检查

资格检查

每个从节点都要检查最后与主节点断线时间,判断是否有资格替换故障的主节点。如果从节点与主节点断线时间超过 cluster-node-time*cluster-slave-validity-factor,则当前从节点不具备故障转移资格。参数cluster-slavevalidity-factor用于从节点的有效因子,默认为10。

准备选举时间

当从节点符合故障转移资格后,更新触发故障选举的时间,只有到达该时间后才能执行后续流程。故障选举时间相关字段如下:

struct clusterState { 
    ... 
    mstime_t failover_auth_time; /* 记录之前或者下次将要执行故障选举时间 */ 
    int failover_auth_rank; /* 记录当前从节点排名 */ 
}

采用延迟触发机制,主要是通过对多个从节点使用不同的延迟选举时间来支持优先级问题。复制偏移量越大说明从节点延迟越低,那么它应该具有更高的优先级来替换故障主节点。

准备选举时间

发起选举

当从节点定时任务检测到达故障选举时间(failover_auth_time)到达后,发起选举流程如下:

更新配置纪元:

配置纪元是一个只增不减的整数,每个主节点自身维护一个配置纪元(clusterNode.configEpoch)标示当前主节点的版本,所有主节点的配置纪元都不相等,从节点会复制主节点的配置纪元。整个集群又维护一个全局的配置纪元(clusterState.current Epoch),用于记录集群内所有主节点配置纪元的最大版本。执行 cluster info 命令可以查看配置纪元信息。配置纪元会跟随 ping/pong 消息在集群内传播,当发送方与接收方都是主节点且配置纪元相等时代表出现了冲突,nodeId 更大的一方会递增全局配置纪元并赋值给当前节点来区分冲突。

配置纪元的主要作用:

  • 标示集群内每个主节点的不同版本和当前集群最大的版本。
  • 每次集群发生重要事件时,这里的重要事件指出现新的主节点(新加入的或者由从节点转换而来),从节点竞争选举。都会递增集群全局的配置纪元并赋值给相关主节点,用于记录这一关键事件。
  • 主节点具有更大的配置纪元代表了更新的集群状态,因此当节点间进行ping/pong消息交换时,如出现slots 等关键信息不一致时,以配置纪元更大的一方为准,防止过时的消息状态污染集群。
  • 从节点每次发起投票时都会自增集群的全局配置纪元,并单独保存在 clusterState.failover_auth_epoch 变量中用于标识本次从节点发起选举的版本。

广播选举消息

在集群内广播选举消息(FAILOVER_AUTH_REQUEST),并记录已发送过消息的状态,保证该从节点在一个配置纪元内只能发起一次选举。消息内容如同 ping 消息只是将 type 类型变为 FAILOVER_AUTH_REQUEST。

选举投票

只有持有槽的主节点才会处理故障选举消息(FAILOVER_AUTH_REQUEST),因为每个持有槽的节点在一个配置纪元内都有唯一的一张选票,当接到第一个请求投票的从节点消息时回复 FAILOVER_AUTH_ACK 消息作为投票,之后相同配置纪元内其他从节点的选举消息将忽略。

当从节点收集到 N/2 + 1 个持有槽的主节点投票时,从节点可以执行替换主节点操作。

  • 投票作废:每个配置纪元代表了一次选举周期,如果在开始投票之后的 cluster-node-timeout * 2 时间内从节点没有获取足够数量的投票,则本次选举作废。从节点对配置纪元自增并发起下一轮投票,直到选举成功为止。

替换主节点

当前从节点取消复制变为主节点。

执行 clusterDelSlot 操作撤销故障主节点负责的槽,并执行 clusterAddSlot 把这些槽委派给自己。
向集群广播自己的 pong 消息,通知集群内所有的节点当前从节点变为主节点并接管了故障主节点的槽信息。

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节点?

集群扩容

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

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

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

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

Redis集群数据迁移

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

每个 Redis 集群节点的 clusterState 都会存储整个集群中 slot 和 Redis 节点的对应关系用于路由。当 6379 迁移 slot1 时,会首先标级该槽属于正在迁移的状态 IMGRATING,而同样 6385 也需要标记 slot1 属于正在导入的状态 IMPORTING。从实现上看,就是分别设置 migrating_slots_toimporting_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 节点。

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

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

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

集群收缩

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

Redis集群节点安全下线

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

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

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 值减去一

Redis 压力测试

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

参数:

选项 描述 默认值
-h 指定redis server 主机名 localhost
-p 指定redis 服务端口 6379
-s 指定服务器socket
-c clients,代表客户端并发连接数 50
-n 指定请求数 10000
-d 以字节形式指定SET/GET值的数值大小 2
-k 1 = keepalive 0 = reconnect 1
-r 向 redis 中插入更多的键。-r 选项会在键名上加一个12位的后缀,-r 10000 表示只对后四位做随机处理,SET/GET/INCR 使用随机 key, SADD 使用随机值
-P 通过管道传输 <numreq> 请求 1
-q 强制退出 redis.仅显示 query per sec 信息
-csv 以 csv 格式输出
-l 生成循环 永久执行测试
-t 对指定命令做基准测试,仅运行以逗号分隔的测试命令列表
-I Idle模式,仅打开 N 个 idle 连接并等待

Redis 大 key,热 key 如何发现和处理

大 key 的发现和处理

通常会将含有较大数据或含有大量成员、列表数的Key称之为大Key,下面将用几个实际的例子对大 Key 的特征进行描述:

  • 一个STRING类型的Key,它的值为5MB(数据过大)
  • 一个LIST类型的Key,它的列表数量为20000个(列表数量过多)
  • 一个ZSET类型的Key,它的成员数量为10000个(成员数量过多)
  • 一个HASH格式的Key,它的成员数量虽然只有1000个但这些成员的value总大小为100MB(成员体积过大)

需要注意的是,在以上的例子中,为了方便理解,对大Key的数据、成员、列表数给出了具体的数字。为了避免误导,在实际业务中,大 Key 的判定仍然需要根据Redis的实际使用场景、业务场景来进行综合判断。

大 key 带来的问题

  • Client 发现 Redis 变慢
  • Redis 内存不断变大引发 OOM,或达到 maxmemory 设置值引发写阻塞或重要 Key 被逐出
  • Redis 集群中的某个 node 内存远超其余 node,但因 Redis 集群的数据迁移最小粒度为 Key 而无法将 node 上的内存均衡化(Redis 采用虚拟槽分区)
  • 大 Key 上的读请求使 Redis 占用服务器全部带宽,自身变慢的同时影响到该服务器上的其它服务
  • 删除一个大 Key 造成主库较长时间的阻塞并引发同步中断或主从切换

大 key 产生的可能原因

  • 在不适用的场景下使用Redis,易造成Key的value过大,如使用String类型的Key存放大体积二进制文件型数据;
  • 业务上线前规划设计不足,没有对Key中的成员进行合理的拆分,造成个别Key中的成员数量过多;
  • 未定期清理无效数据,造成如HASH类型Key中的成员持续不断地增加;
  • 使用LIST类型Key的业务消费侧发生代码故障,造成对应Key的成员只增不减。

大 key 定位

通过Redis内置命令对目标Key进行分析

使用 debug object 命令对 Key 进行分析。该命令能够根据传入的对象(Key的名称)来对 Key 进行分析并返回大量数据,其中 serializedlength 的值为该 Key 的序列化长度,通过该数据可以判断对应 Key 是否符合大Key判定标准。

需要注意的是,Key 的序列化长度并不等同于它在内存空间中的真实长度,此外,debug object 属于调试命令,运行代价较大,并且在其运行时,进入 Redis 的其余请求将会被阻塞直到其执行完毕。而该命令的运行的时间长短取决于传入对象(Key名)序列化长度的大小,因此,在线上环境中并不推荐使用该命令来分析大Key,这可能引发故障。

Redis自4.0起提供了 MEMORY USAGE 命令来帮助分析 Key 的内存占用,相对 debug object 它的执行代价更低,但由于其时间复杂度为 O(N) 因此在分析大 Key 时仍有阻塞风险。

建议通过风险更低方式来对Key进行分析,Redis对于不同的数据结构提供了不同的命令来返回其长度或成员数量:

  • STRING类型:执行STRLEN命令,返回对应Key的value的字节数。
  • LIST类型:执行LLEN命令,返回对应Key的列表长度。
  • HASH类型:执行HLEN命令,返回对应Key的成员数量。
  • SET类型:执行SCARD命令,返回对应Key的成员数量。
  • ZSET类型:执行ZCARD命令,返回对应Key的成员数量。
  • STREAM类型:执行XLEN命令,返回对应Key的成员数量。

通过以上Redis内置命令可以方便且安全的对Key进行分析而不会影响线上服务,但由于它们返回的结果非Key的真实内存占用数据,因此不够精确,仅可作为参考。

通过Redis官方客户端redis-cli的bigkeys参数发现大Key

Redis提供了bigkeys参数能够使redis-cli以遍历的方式分析整个Redis实例中的所有Key并汇总以报告的方式返回结果。该方案的优势在于方便及安全,而缺点也非常明显:分析结果不可定制化。

bigkeys仅能分别输出Redis六种数据结构中的最大Key,如果想只分析STRING类型或是找出全部成员数量超过10的HASH Key,那么bigkeys在此类需求场景下将无能为力。

GitHub上有大量的开源项目能够实现bigkeys的加强版使结果能够按照配置定制化输出,另外可也以动手使用SCAN + TYPE并配合上文表格中的命令自己实现一个Redis实例级的大Key分析工具。

同样,该方案的实现方式及返回结果使其不具备精确性与实时性,建议仅作为参考。

大 key 的处理

对大Key进行拆分

如将一个含有数万成员的HASH Key拆分为多个HASH Key,并确保每个Key的成员数量在合理范围,在Redis Cluster结构中,大Key的拆分对node间的内存平衡能够起到显著作用。

对大Key进行清理

将不适合Redis能力的数据存放至其它存储,并在Redis中删除此类数据。需要注意的是,在上文提到一个过大的Key可能引发Redis集群同步的中断,Redis自4.0起提供了UNLINK命令,该命令能够以非阻塞的方式缓慢逐步的清理传入的Key,通过UNLINK,可以安全的删除大Key甚至特大Key。

时刻监控Redis的内存水位

突然出现的大Key问题会措手不及,因此,在大Key产生问题前发现它并进行处理是保持服务稳定的重要手段。可以通过监控系统并设置合理的Redis内存报警阈值来提醒此时可能有大Key正在产生,如:Redis内存使用率超过70%,Redis内存1小时内增长率超过20%等。

通过此类监控手段可以在问题发生前解决问题,如:LIST的消费程序故障造成对应Key的列表数量持续增长,将告警转变为预警从而避免故障的发生。

对失效数据进行定期清理

例如会在HASH结构中以增量的形式不断写入大量数据而忽略了这些数据的时效性,这些大量堆积的失效数据会造成大Key的产生,可以通过定时任务的方式对失效数据进行清理。在此类场景中,建议使用HSCAN并配合HDEL对失效数据进行清理,这种方式能够在不阻塞的前提下清理无效数据。

热 key 的发现和处理

在某个Key接收到的访问次数、显著高于其它 Key 时,可以将其称之为热Key,常见的热 Key 如:

  • 某 Redis 实例的每秒总访问量为10000,而其中一个 Key 的每秒访问量达到了7000(访问次数显著高于其它Key)
  • 对一个拥有上千个成员且总大小为1MB的 HASH Key 每秒发送大量的 HGETALL(带宽占用显著高于其它Key)
  • 对一个拥有数万个成员的 ZSET Key 每秒发送大量的 ZRANGE(CPU时间占用显著高于其它Key)

热 key 带来的问题

  • 热 Key 占用大量的 Redis CPU 时间使其性能变差并影响其它请求
  • Redis 集群中各 node 流量不均衡造成 Redis 集群的分布式优势无法被 Client 利用,一个分片负载很高而其它分片十分空闲从而产生读/写热点问题
  • 在抢购、秒杀活动中,由于商品对应库存 Key 的请求量过大超出 Redis 处理能力造成超卖
  • 热 Key 的请求压力数量超出 Redis 的承受能力造成缓存击穿,此时大量请求将直接指向后端存储将其打挂并影响到其它业务

热 key 产生的可能原因

  • 预期外的访问量陡增,如突然出现的爆款商品、访问量暴涨的热点新闻、直播间某主播搞活动带来的大量刷屏点赞、游戏中某区域发生多个工会之间的战斗涉及大量玩家等。

热 key 定位

通过Redis官方客户端redis-cli的hotkeys参数发现热Key

Redis自4.0起提供了hotkeys参数来方便用户进行实例级的热Key分析功,该参数能够返回所有Key的被访问次数,它的缺点同样为不可定制化输出报告,大量的信息会使得在分析结果时复杂度较大,另外,使用该方案的前提条件是将redis-server的maxmemory-policy参数设置为LFU。

通过业务层定位热Key

指向Redis的每一次访问都来自业务层,因此可以通过在业务层增加相应的代码对Redis的访问进行记录并异步汇总分析。该方案的优势为能够准确并及时的分析出热Key的存在,缺点为业务代码复杂度的增加,同时可能会降低一些性能。

使用monitor命令在紧急情况时找出热Key

Redis的monitor命令能够打印Redis中的所有请求,包括时间信息、Client信息、命令以及Key信息。在发生紧急情况时,可以通过短暂执行monitor命令并将输出重定向至文件,再关闭monitor命令后通过对文件中请求进行归类分析即可找出这段时间中的热Key。

由于monitor命令对Redis的CPU、内存、网络资源均有一定的占用。因此,对于一个已处于高压状态的Redis,monitor可能会起到雪上加霜的作用。同时,这种异步收集并分析的方案的时效性较差,并且由于分析的精确度取决于monitor的执行时间,因此在多数无法长时间执行该命令的线上场景中本方案的精确度也不够好。

热 key 的处理

在Redis Cluster结构中对热Key进行复制

在Redis Cluster中,热Key由于迁移粒度问题造成请求无法打散使单一node的压力无法下降。此时可以将对应热Key进行复制并迁移至其他node,例如为热Key foo 复制出3个内容完全一样的Key并名为foo2,foo3,foo4,然后将这三个Key迁移到其他node来解决单一node的热Key压力。

该方案的缺点在于代码需要联动修改,同时,Key一变多带来了数据一致性挑战:由更新一个Key演变为需要同时更新多个Key,在很多时候,该方案仅建议用来临时解决当前的棘手问题。

使用读写分离架构

如果热Key的产生来自于读请求,那么读写分离是一个很好的解决方案。在使用读写分离架构时可以通过不断的增加从节点来降低每个Redis实例中的读请求压力。

然而,读写分离架构在业务代码复杂度增加的同时,同样带来了Redis集群架构复杂度的增加:不仅要为多个从节点提供转发层(如Proxy,LVS等)来实现负载均衡,还要考虑从节点数量显著增加后带来的故障率增加的问题,Redis集群架构变更的同时为监控、运维、故障处理带来了更大的挑战。

读写分离架构同样存在缺点,在请求量极大的场景下,读写分离架构会产生不可避免的延迟,此时会有读取到脏数据的问题。因此,在读、写压力都较大且对数据一致性要求很高的场景下,读写分离架构并不是最优方案。

参考阿里云Tair的QueryCache特性

阿里云数据库Redis会根据高效的排序和统计算法识别出实例中存在的热点Key(通常热点Key的QPS大于3,000),开启该功能后,代理节点Proxy会根据设定的规则缓存热点Key的请求和查询结果(仅缓存热点Key的查询结果,无需缓存整个Key)。当在缓存有效时间内收到相同的请求时,Proxy会直接返回结果至客户端,无需和后端的数据分片执行交互。在提升读取速度的同时,降低了热点Key对数据分片的性能影响,避免访问倾斜。

开通该功能后,来自客户端的同样请求无需再与Proxy后端的Redis进行交互而是由Proxy直接返回数据,指向热Key的请求由一个Redis节点承担转为多个Proxy共同承担,能够大幅度降低Redis节点的热Key压力。同时Tair的QueryCache功能还提供了大量的命令来方便查看、管理代理查询缓存的情况,例如通过querycache keys命令查看所有被缓存热Key,通过querycache listall命令获取所有已缓存的所有命令等。

参考内容

主要参考以来两篇博客以及相关博客推荐,因找的博客比较多,没注意记录,最后好多忘了在哪2333,如果有侵权,请及时联系我,非常抱歉。

https://github.com/Snailclimb/JavaGuide

https://github.com/CyC2018/CS-Notes

深入理解Redis-ZSet原理

Redis面试篇 – 如何保证缓存与数据库的双写一致性?

redis压力测试

Redis集群(中) —— 集群伸缩

Redis基础、常用类型介绍、时间复杂度

Redis 6.0 新特性-多线程连环 13 问

为什么 Redis 选择单线程模型

发现并处理Redis的大Key和热Key

深度干货|一文详解 Redis 中 BigKey、HotKey 的发现与处理

MySQL 的索引为什么使用 B+ 树而不使用跳表?

Redis进阶 - 事务:Redis事务详解