敖丙思维导图-Redis - 图2

Redis快速的原因

  • 纯内存操作。
  • 单线程操作,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗CPU,不用去考虑各种锁的问题。

    Redis在处理客户端的请求时,包括获取 (socket 读)、解析、执行、内容返回 (socket 写) 等都由一个顺序串行的主线程处理,这就是所谓的“单线程”。但如果严格来讲从Redis4.0之后并不是单线程,除了主线程外,它也有后台线程在处理一些较为缓慢的操作,例如清理脏数据、无用连接的释放、大 key 的删除等等。
    使用Redis时,几乎不存在CPU成为瓶颈的情况, Redis主要受限于内存和网络。6.0版本带来了多线程特性,因为读写网络的read/write系统调用占用了Redis执行期间大部分CPU时间,瓶颈主要在于网络的 IO 消耗,。-》多线程任务可以分摊 Redis 同步 IO 读写负荷。
    Redis的多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程顺序执行。

  • 采用了非阻塞I/O多路复用机制。多路指的是多个socket连接,复用指的是复用一个线程。Redis使用epoll作为I/O多路复用技术的实现,再加上Redis自身的事件处理模型将epoll的read、write、close等都转换成事件,不在网络I/O上浪费过多的时间。

    当有多个请求发送到服务端的时候,实际上会有一个文件事件处理器同时监听多个套接字,并且根据套接字目前执行的任务来关联不同的事件处理器。
    事件处理器只需要将它们做绑定即可,io多路复用程序是会将所有产生的套接字都存入一个有序且同步的队列中,最后redis会有逐一地对这个队列中的元素进行处理。
    epoll没有最大并发连接的限制,只管你“活跃”的连接 ,而跟连接总数无关。Epoll使用了“共享内存 ”,省去内存拷贝。

非阻塞I/O

每个tcp socket创建时,os会为它分配读缓冲区、写缓冲区。
非阻塞I/O在套接字上提供了一个选项 Non_Blockiing,这个选项打开后,读写方法不会阻塞。能读多少取决于内核为套接字分配的读缓冲区内部的数据字节数,能写多少取决于内核为套接字分配的写缓冲区空闲空间字节数

  1. 阻塞式IO (处理一个socket就要占用一个线程)
    让出CPU,进到等待队列,等socket就绪后再次获得时间片继续执行。
  2. 非阻塞式IO
    不让出CPU,频繁检查socket就绪状态(忙等待,难把握轮询间隔,空耗CPU)
  3. IO多路复用 (一次系统调用,监听多个socket)
    操作系统提供支持,把需要等待的socket加入到监听集合。

多路复用(事件轮询)

非阻塞I/O有个问题,线程要读数据,结果读了一部分就返回了,那么如何知道何时该继续呢?事件轮询解决。

事件轮询的API,就是Java中的NIO技术

这里“多路”指的是多个网络连接,“复用”指的是复用同一个线程。采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络IO的时间消耗)。
UNIX操作系统提供了select/poll/epoll这样的系统调用。你告知我一批套接字(socket),当这些套接字的可读或可写事件发生时,我通知你这些事件信息。多路 I/O 复用模型是利用select、poll、epoll可以同时监察多个流的 I/O 事件的能力,在空闲的时候,会把当前线程阻塞掉,当有一个或多个流有I/O事件时,就从阻塞态中唤醒,于是程序就会轮询一遍所有的流(epoll是只轮询那些真正发出了事件的流),并且只依次顺序的处理就绪的流,这种做法就避免了大量的无用操作。
程序注册一组socket文件描述符给操作系统,表示”我要监视这些fd是否有IO事件发生,有了就告诉程序处理”。

以前都是用select但是

  1. 16*64=1024 最多监听1024个fd
  2. 每次调用select都要传入所有监听集合,频繁的从用户态到内核态拷贝数据。
  3. 每次都要遍历所有集合,判断哪个fd是可操作的
    epoll解决了这些问题

Redis单线程带来的问题

• 只能使用CPU一个核
• 删除的key过大,阻塞服务端
• QPS难以提高

Redis在4.0【Lazy Free】6.0【多线程IO】

Redis服务器是一个时间驱动程序,服务器需要处理:

  1. 文件事件。服务器与客户端通信会产生相应的文件事件(套接字),服务器监听它们完成通信操作(accept、read、write、close)。
  2. 时间事件。redis中一些操作(过期键清理、服务状态统计)需要在给定的时间点执行。
    Redis基于Reactor模式开发了自己的I/O事件处理器,采用了I/O多路复用机制,同时监听多个套接字。
    4.0【Lazy Free】
    解决慢操作 -》 可以用渐进式处理,比如删除set每次只删除一部分,但会导致回收速度过慢。未采用。
    Redis将大Key删除操作异步化,采用非阻塞删除。空间回收交给单独线程处理,主线程只做关系解除。
    Redis还去掉了共享对象直接采用数据拷贝。(可见Zset节点value实现)
    6.0【多线程IO】-》并未改动事件处理(改动事件处理会带来锁竞争、频繁上下文切换),而是利用多核分担I/O读写负荷。
    在事件处理线程每次获取到可读事件时,会将就绪的可读事件分配给I/O线程,并进行等待,在所有I/O线程完成读操作后,事件处理线程开始执行任务处理,处理结束后,再给I/O线程完成写操作。6.0多线程并非彻底多线程
    (I/O线程只能同时执行读或者同时执行写操作,期间事件处理线程一致处于等待状态)有很多轮询等待开销。
    作者更倾向于slow operations threading,例如【Lazy Free】

    Redis数据结构底层实现

    1. String (动态字符串sds(Simple Dynamic String, 简单动态字符串)代替c字符串)-最大存放512M的value

    String 缓存结构体用户信息,计数( value 是一个整数,还可以对它使用 INCR 命令进行 原子性 的自增操作)。
    Redis 为了对内存做极致的优化,采用预分配庸余空间减少内存的频繁分配
    SDS 与 C 字符串的区别:
  • 获取字符串长度为 O(N) 级别的操作 → 因为 C 不保存数组的长度,每次都需要遍历一遍整个数组;
  • 不能很好的杜绝 缓冲区溢出/内存泄漏 的问题 → 跟上述问题原因一样,如果执行拼接 or 缩短字符串的操作,如果操作不当就很容易造成上述问题;
  • C 字符串 只能保存文本数据 → 因为 C 语言中的字符串必须符合某种编码
  1. struct sdshdr {
  2. // buf 中已占用空间的长度
  3. int len;
  4. // buf 中剩余可用空间的长度
  5. int free;
  6. // 字节数组
  7. char buf[];
  8. };

没有直接采用c语言自带的字符串,好处有以下几点:
减少原先繁琐的内存扩增问题。(会根据初始化的值,提前给出更多的空间,避免出现空间溢出问题)
通过空间预分配机制来减少内存重分配问题。

2. 字典Hash (”数组 + 链表” 的链地址法来解决部分 哈希冲突)

保存结构体信息可部分获取不用序列化所有字段。
实际上字典结构的内部包含两个 hashtable,通常情况下只有一个 hashtable 是有值的,但是在字典扩容缩容时,需要分配新的 hashtable,然后进行 渐进式 rehash

3. 双向链表 List (相当于LinkedList链表,栈和队列都能实现)

twitter的关注列表,粉丝列表等都可以用Redis的list结构来实现(支持反向查找和遍历)

  • LPUSH【生产消息】 和 RPUSH 分别可以向 list 的左边(头部)和右边(尾部)添加一个新元素;
  • LRANGE 命令可以从 list 中取出一定范围的元素;
  • LPOP【消费消息】 命令可以从移出并获取列表的第一个元素

异步消息队列

使用rpush/lpush操作入队列,使用 blpop 和 brpop(阻塞读:没消息时阻塞到消息到来) 来出队列。

如果线程一直阻塞在哪里,Redis 的客户端连接就成了闲置连接,闲置过久,服务器一般
会主动断开连接,减少闲置资源占用。这个时候 blpop/brpop 会抛出异常来。

pub/sub 有什么缺点?
在消费者下线的情况下,生产的消息会丢失,得使用专业的消息队列如 RabbitMQ等。

如何实现延时队列?

使用 sortedset,拿时间戳作为score,消息内容作为 key 调用 zadd 来生产消息,消费者用 zrangebyscore 指令获取 N 秒之前的数据轮询进行处理。

4. 集合 Set (相当于HashSet,集合的元素具有唯一性,无序性)

去重的场景,交集(sinter)、并集(sunion)、差集(sdiff),实现如共同关注、共同喜好。
它的内部实现相当于一个特殊的字典,字典中所有的 value 都是一个值 NULL。

sadd key member 添加一个 string 元素到 key 对应 set 集合中,成功返回 1,如果元素以及 在集合中则返回 0

5. 有序列表Zset ( SortedSet + HashMap,每个唯一 value 赋予一 score 值,用来代表排序的权重 )

可用来实现延时队列、排行榜。内部使用HashMap和跳跃表(SkipList)来保证数据的存储和有序,HashMap里放的是成员到score的映射,而跳跃表里存放的是所有的成员,排序依据是HashMap里存的score,使用跳跃表的结构可以获得比较高的查找效率,并且在实现上比较简单。

当zsort的score相同的情况下,redis是以key的字典序进行排名的。
一级以外的维度不变的情况下可以直接用 key 排序,比较简单。
如果维度会更新,可以使用拆分二进制或十进制的方法存储,二进制的优点是存储的数比较大,而且可以用位运算。
十进制的优点是计算简单,可读性比较好。各个维度的长度还可以做成配置项,这样就可以满足不同的业务需求了。

跳跃列表

最下面一层所有的元素都会串起来。然后每隔几个元素挑选出一个代表来,再将这几个代表使用另外一级指针串起来。「跳跃列表」之所以「跳跃」,是因为内部的元素可能「身兼数职」
敖丙思维导图-Redis - 图3

跳跃列表采取一个随机策略来决定新元素可以兼职到第几层。首先 L0 层肯定是 100% 了,L1 层只有 50% 的概率,L2 层只有 25% 的概率,L3 层只有 12.5% 的概率,一直随机到最顶层 L31 层。绝大多数元素都过不了几层。(很公平)

整数集合(只包含整数值元素,并且这个集合的元素数量不多)

当一个集合(Set)只包含整数值元素,并且这个集合的元素数量不多时, Redis i就会使用整数集合作为集合键的底层实现。

整数集合升级过程

  1. 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。
  2. 将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,3需要继续维持底层数组的有序性质不变。
  3. 将新元素添加到底层数组里面。

整数集合升级的优点

  • 提升灵活性 (因为C语言是静态类型语言,为了避免类型错误,我们通常不会将两种不同类型的值放在同一个数据结构里面。)
  • 节约内存
  • 整数集合是Redis自己设计的一种存储结构,集合键的底层实现之一。
  • 整数集合的底层实现为数组,这个数组以有序、无重复的方式保存集合元素,在有需要时,程序会根据新添加元素的类型,改变这个数组的类型。
  • 升级操作为整数集合带来了操作上的灵活性,并且尽可能地节约了内存。
  • 整数集合只支持升级操作,不支持降级操作

Zset分数相同的时候自定义排序规则

Redis 默认实现是,相同分数的成员按字典顺序排序(0 ~9 , A ~Z,a ~ z),所以相同价格排序就不能根据时间优先来排序。

分数 = 价格 + 时间 (当前系统时间戳)
分数为64 位的长整型 int64_t, 价格作为高位存储, 时间作为低位存储,时间精度上面,精确到秒级别。

  1. int64_t分数,二进制用高 32位存价格,低32位存储当时与某一个时刻的时间差(秒),那么数据看起是这样
  2. 这里有一个最大时间 MAX_TIME = 2208960000204011日)(服务超过这个时间无效)
  3. A 玩家,(10 * 价格偏移) + MAX_TIME - 11111111111111 时间戳)
  4. B 玩家,(10 * 等级偏移) + MAX_TIME - 1111122222 时间戳)
  5. 最终分数A > B ,
  6. 最终排序,A 玩家会排到B前面。通过分数可以解析出真实价格和时间

距离当前时间的毫秒值之差作为小数部分,得分(整数)作为整数部分,存入缓存;从缓存取出得分时截取整数部分即为真正得分。

应用

延时队列

延时队列可以通过 Redis 的 zset(有序列表) 来实现。我们将消息序列化成一个字符串作为 zset 的 value,这个消息的到期处理时间作为 score,然后用多个线程(保障可用性)轮询 zset ,zrem获取到期的任务进行处理。

简单限流处理

用一个zset结构记录用户的行为历史,每一个行为都作为zset中的一个key保存下来。同一用户的同一行为用一个zset记录。每一行为到来时,都维护一次时间窗口。将时间窗口外的记录全部清理掉,只保留窗口内的记录。这里的操作都是对同一个key的,使用pipline能提升效率。

也可以使用redis-cell自带的漏斗限流工具,它提供了原子的限流指令。
例如限制:用户sam查询行为,每60s最多30次(流水速率)

HyperLogLog(有误差的去重基数统计UV)

用于去重的基数统计(有误差),在输入元素的数量或者体积非常非常大时,计算基数所需的空间总是固定的、并且是很小的。(稀疏矩阵-》稠密矩阵)
操作:pfadd、pfcount、pfmerge

GEO

支持存储地理位置信息用来实现诸如附近位置、摇一摇这类依赖于地理位置信息的功能.geo的数据类型为zset. (删除用zrem)

其它

底层字典结构

Redis数据库就是使用字典来作为底层实现(字典还是哈希键的底层实现之一),而字典使用哈希表作为底层实现,一个哈希表里面有多个哈希节点,而每个哈希表节点就保存了字典中的一个键值对。

哈希表节点中还包括:指向下一个哈希表节点,形成链表的struct dictEntry *next;

  1. // Redis中的字典是由dict.h/dict结构表示
  2. typedef struct dict{
  3. //类型特定函数
  4. dictType *type;
  5. //私有数据
  6. void *privdata;
  7. //哈希表
  8. dictht ht[2];
  9. //rehash索引
  10. //当rehash不在进行时,值为-1
  11. int rehashidx;
  12. }dict;

ht属性是一个包含了两个项的数组,数组中每个项都是一个dictht哈希表,一般情况下,字典只使用ht[0]哈希表,而ht[1]哈希表只对ht[0]哈希表进行rehash时使用
下图是完整版本的字典结构。其中:dict.h/dictht(字典所使用的哈希表)、dictEntry(哈希表节点)
敖丙思维导图-Redis - 图4

rehash(重新散列)

负载因子=哈希表保存的节点数量/哈希表的大小
load_factor=ht[0].used/ht[0].size

什么时候才会rehash呢
1)服务器目前没有执行的BGSAVE命令或者BGREWRUTEAOF命令,并且哈希表的负载因子大于等于1;
2)服务器目前正在执行BGSAVE命令或者BGREWRUTEAOF命令,并且哈希表的负载因子大于等于5;

渐进式 rehash
大字典的扩容是比较耗时间的,需要重新申请新的数组,然后将旧字典所有链表中的元素重新挂接到新的数组下面,这是一个 O(n) 级别的操作,作为单线程的 Redis 很难承受这样耗时的过程,所以 Redis 使用 渐进式 rehash 小步搬迁。在 rehash 的同时,保留新旧两个 hash 结构。查询时会同时查询两个 hash 结构,然后在后续的定时任务以及 hash 操作指令中,循序渐进的把旧字典的内容迁移到新字典中。
正常情况下,当 hash 表中 元素的个数等于第一维数组的长度时,就会开始扩容,扩容的新数组是 原数组大小的 2 倍。不过如果 Redis 正在做 bgsave(持久化命令),为了减少内存也得过多分离,Redis 尽量不去扩容,但是如果 hash 表非常满了,达到了第一维数组长度的 5 倍了,这个时候就会 强制扩容

  1. 为 ht[1] 分配空间, 让字典同时持有 ht[0] 和 ht[1] 两个哈希表。
  2. 在字典中维持一个索引计数器变量 rehashidx , 并将它的值设置为 0 , 表示 rehash 工作正式开始。
  3. 在 rehash 进行期间, 每次对字典执行添加、删除、查找或者更新操作时, 程序除了执行指定的操作以外, 还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1] , 当 rehash 工作完成之后, 程序将 rehashidx 属性的值增一。
  4. 随着字典操作的不断执行, 最终在某个时间点上, ht[0] 的所有键值对都会被 rehash 至 ht[1] , 这时程序将 rehashidx 属性的值设为 -1 , 表示 rehash 操作已完成。

在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定操作以外,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1]中,当rehash工作完成之后,程序将rehashidx属性的值+1。

Redis 自己设计的数据存储结构

压缩列表

数组的优势占用一片连续的空间可以很好的利用CPU缓存访问数据。如果我们想要保留这种优势,又想节省存储空间(存储小于 20 个字节长度的字符串的时候,便会浪费部分存储空间。)可以对数组进行压缩。但遍历它的时候由于不知道每个元素的大小是多少,因此也就无法计算出下一个节点的具体位置。这个时候我们可以给每个节点增加一个length的属性。

压缩列表是Redis为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结枃。

当一个列表(list)只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做列表的底层实现。
当一个哈希(hash)只包含少量键值对,比且每个键值对的键和值要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做哈希的底层实现。

  • 压缩列表是Redis为节约内存自己设计的一种顺序型数据结构。
  • 压缩列表被用作列表键和哈希键的底层实现之一。
  • 压缩列表可以包含多个节点,每个节点可以保存一个字节数组或者整数值。
  • 添加新节点到压缩列表,或者从压缩列表中删除节点,可能会引发连锁更新操作,但这种操作出现的几率并不高。

Pub/Sub (消息队列-用于查看订阅与发布系统状态)

Redis 发布订阅(pub/sub)是一种消息通信模式:发送者(pub)发送消息,订阅者(sub)接收消息。

如果 Redis 停机重启,PubSub 的消息是不会持久化的,毕竟 Redis 宕机就相当于一个消费者都没有,所有的消息直接被丢弃。(消费者挂掉后重启无法再接到旧消息)

BitMap (set上的扩展。位图,其实也就是普通的字符串【 byte 数组】,用二进制表示,只有 0 和 1 两个数字。)用户统计每日用户的登录数/几天的活跃数。

Redis BitMap 的底层数据结构实际上是 String 类型,Redis 对于 String 类型有最大值限制不得超过 512M,即 2^32 次方 byte。(所以用户ID不能太大哦)

  • bitops 位图查找指令(字节索引,指定的位范围是8的整数)
  • bitcount 位图统计指令

使用时间作为cacheKey,然后用户ID为offset,如果当日活跃过就设置为1。
因为bit的值为 0或1,用户是否登录也可以用 0或1 来表示
setbit userlogin:20200618 101 1
我们把每天的用户登录信息记录到一个key中,值中的每个offset的值就是用户登录的标识
再调用bitcount userlogin:20200618

BITOP 命令支持 AND 、 OR 、 NOT 、 XOR 这四种操作中的任意一种参数

统计6.18且6.19活跃的用户数
setbit userlogin:20200619 101 1
BITOP AND test1 userlogin:20200618 userlogin:20200619
bitcount test1

redis pipeline管道(非原子性)

pipeline是非原子性的,它把一组命令打包,然后一次发送过去。管道中指令越多,效果越好。

  1. 1 pipelinen条命令) = 1 次网络时间 + n 次命令时间

对于管道来说,连续的 write 操作根本没有耗时,之后第一个 read 操作会等待一个网络的来回开销,然后所有的响应消息就都已经回送到内核的读缓冲了,后续的 read 操作直接就可以从缓冲拿到结果,瞬间就返回了。

事务(批处理原子性指令,不会被打断)

redis事务就是一次性、顺序性、排他性的执行一个队列中的一系列命令。

  • Redis中,单条命令是原子性执行的,但事务不保证原子性,且没有回滚。事务中任意命令执行失败,其余的命令仍会被执行。
  1. WATCH XXX 查询记录版本号
  2. ... (查询剩余库存是否为0
  3. MULTI 开启事务
  4. ...
  5. EXEC 提交事务,更新版本号

watch指令类似于乐观锁,在事务提交时,如果watch监控的多个KEY中任何KEY的值已经被其他客户端更改,则使用EXEC执行事务时,事务队列将不会被执行,同时返回Nullmulti-bulk应答以通知调用者事务执行失败。

Redis Lua 脚本

在redis里面使用lua脚本主要用三个命令

  1. eval 用来直接执行lua脚本
  2. evalsha 根据缓存码执行脚本内容
  3. script load 把脚本加载到脚本缓存中,返回SHA1校验和。但不会立马执行
    优点
  • 减少网络开销:多个请求通过脚本一次发送,减少网络延迟
  • 原子操作:将脚本作为一个整体执行,中间不会插入其他命令,无需使用事务
  • 复用:客户端发送的脚本永久存在redis中,其他客户端可以复用脚本
  • 可嵌入性:可嵌入JAVA,C#等多种编程语言,支持不同操作系统跨平台交互

    Redis分布式锁

  1. SETNX + EXPIRE 这两个操作不是原子性的【别的线程有可能拿不到锁】
  2. SETNX + value(系统时间+过期时间)【并发抢锁时,锁没有保存持有者的唯一标识】
  3. 使用Lua脚本(包含SETNX + EXPIRE指令)
  4. SET扩展(SET EX PX NX)【锁过期释放了,业务还在执行。锁被别的线程误删】
  5. SET EX PX NX + 校验唯一随机值 【判断是不是当前线程加的锁 和 释放锁不是原子操作】
  6. Redisson【只要线程一加锁成功,就会启动一个watch dog,每隔10s检查持有锁情况,要还持有就会加长过期时间】
  7. Redlock(多机分布式)

    常用命令

  • Keys (批量删除,不能用在生产的环境中)
    使用SCAN 命令替代。
    • 复杂度虽然也是 O(n),通过游标分步进行不会阻塞线程;
    • 有限制参数 COUNT ;
    • 同 keys命令 一样提供模式匹配功能;
    • 服务器不需要为游标保存状态,游标的唯一状态就是 scan 返回给客户端的游标整数;
      SCAN cursor(游标) [MATCH pattern](要匹配的正则) [COUNT count](单次遍历的槽位)
  • Getset 命令用于设置指定 key 的值,并返回 key 的旧值。
  • setnx ((SET if Not eXists) 在指定的 key 不存在时,为 key 设置指定的值。)
    双重防死锁,使用setNx + getSet两个原子性方法
  1. @Scheduled(cron="0 */1 * * * ?")
  2. public void closeOrderTaskV3(){
  3. long lockTimeout = Long.parseLong(PropertiesUtil.getProperty("lock.timeout","5000"));
  4. Long setnxResult = RedisShardedPoolUtil.setnx(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK,String.valueOf(System.currentTimeMillis()+lockTimeout));
  5. if(setnxResult != null && setnxResult.intValue() == 1){
  6. closeOrder(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
  7. }else{
  8. //未获取到锁,继续判断,判断时间戳,看是否可以重置并获取到锁
  9. String lockValueStr = RedisShardedPoolUtil.get(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
  10. if(lockValueStr != null && System.currentTimeMillis() > Long.parseLong(lockValueStr)){
  11. String getSetResult = RedisShardedPoolUtil.getSet(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK,String.valueOf(System.currentTimeMillis()+lockTimeout));
  12. //返回给定的key的旧值,->旧值判断,是否可以获取锁
  13. //当key没有旧值时,即key不存在时,返回nil ->获取锁
  14. //这里我们set了一个新的value值,获取旧的值。
  15. if(getSetResult == null || (getSetResult != null && StringUtils.equals(lockValueStr,getSetResult))){
  16. //真正获取到锁
  17. closeOrder(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
  18. }else{
  19. log.info("没有获取到分布式锁:{}",Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
  20. }
  21. }else{
  22. log.info("没有获取到分布式锁:{}",Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
  23. }
  24. }
  25. log.info("关闭订单定时任务结束");
  26. }
  • expire 用于设置 key 的过期时间,key 过期后将不再可用。

Redis数据备份

1. 半持久化RDB模式

Redis备份默认方式,是通过快照完成的,当符合在Redis.conf配置文件中设置的条件时Redis会自动将内存中的所有数据进行快照并存储在硬盘上,完成数据备份。

  • Redis使用fork函数复制一份当前进程(父进程)的副本(子进程),父进程继续接收并处理客户端发来的命令,而子进程开始将内存中的数据写入硬盘中的临时文件,当子进程写入完所有数据后会用该临时文件替换旧的RDB文件,至此一次快照操作完成。
  • 执行fork的时操作系统会使用多进程写时复制(copy-on-write)策略,即fork函数发生的一刻父子进程共享同一内存数据,当父进程要更改其中某片数据时,操作系统会将该片数据复制一份以保证子进程的数据不受影响,所以新的RDB文件存储的是执行fork一刻的内存数据。aof每次都是写盘操作,毫米级别。没法比。

copyonwritefork()出来的子进程共享主进程的物理空间,当主子进程有内存写入操作时,read-only内存页发生中断,将触发的异常的内存页复制一份(其余的页还是共享主进程的)。

2. 全持久化AOF模式

开启AOF持久化后每执行一条会更改Redis中的数据的命令,Redis就会将该命令写入硬盘中的AOF文件。(处理请求是串行化的)

当程序对 AOF 日志文件进行写操作时,实际上是将内容写到了内核为文件描述符分配的一个内存缓存中,然后内核会异步将脏数据刷回到磁盘的。

  • Linux的glibc提供了fsync(int fd)函数可以将指定的文件强制从内核缓存刷到磁盘。

在生产环境的服务器中,Redis 通常是每隔 1s 左右执行一次 fsync 操作。这是在数据安全性和性能之间做了一个折中,在保持高性能的同时,尽可能使得数据少丢失。(持久化操作主要在从节点进行)

AOF rewrite - 重复的命令给去掉

4.0之前的做法效率很是低下,需要逐条命令对比。4.0开始的bgrewriteaof支持混合模式(也是就是rdb和aof一起用),直接将rdb持久化的方式来操作将二进制内容覆盖到aof文件中(rdb是二进制,所以很小),然后再有写入的话还是继续append追加到文件原始命令,等下次文件过大的时候再次rewrite(还是按照rdb持久化的方式将内容覆盖到aof中)。

RDB-AOF混合持久化

混合持久化也是通过bgrewriteaof完成的,fork出的子进程先将共享的内存副本全量以RDB的方式写入aof。写完还是通知主进程,然后再将重写缓冲区的内容以AOF方式写入到文件,然后替换旧的aof文件。也就是说这种模式下的aof文件发生rewrite后前半部分是rdb格式(REDIS开头的二进制数据),后半部分是正常的aof追加的命令(重写缓冲区里的)。

持久化配置:RDB做冷备,AOF做数据恢复(数据更可靠)

数据恢复会优先看是否存在aof文件,若存在则先按照aof文件恢复,因为aof毕竟比rdb全。
打开aof的操作不是修改配置文件然后重启,而是先热修改让他生成aof,这次生成肯定是会带着内存中完整的数据的。然后再修改配置文件重启。

Redis的同步机制

Redis主从同步

Redis的主从同步机制可以确保redis的master和slave之间的数据同步。按照同步内容的多少可以分为全同步和部分同步;按照同步的时机可以分为slave刚启动时的初始化同步正常运行过程中的数据修改同步

CAP:网络分区发生时,一致性和可用性两难全。

快照同步(初始化同步)

内存的 buffer 是有限的,从节点将无法直接通过指令流来进行同步
(1)slave启动后向master发送同步指令SYNC,master接收到SYNC指令之后将调用该命令的处理函数syncCommand()进行同步处理;
(2)在函数syncCommand中,将调用函数rdbSaveBackground启动一个备份进程用于数据同步,如果已经有一个备份进程在运行了,就不会再重新启动了。
(3)备份进程将执行函数rdbSave() 完成将redis的全部数据保存为rdb文件
(4)在redis的时间事件函数serverCron(redis的时间处理函数是指它会定时被redis进行操作的函数)中,将对备份后的数据进行处理,在serverCron函数中将会检查备份进程是否已经执行完毕,如果备份进程已经完成备份,则调用函数backgroundSaveDoneHandler完成后续处理。
(5)在函数backgroundSaveDoneHandler中,首先更新master的各种状态,例如,备份成功还是失败,备份的时间等等。然后调用函数updateSlavesWaitingBgsave,将备份的rdb数据发送给等待的slave。
(6)在函数updateSlavesWaitingBgsave中,将遍历所有的等待此次备份的slave,将备份的rdb文件发送给每一个slave。另外,这里并不是立即就把数据发送过去,而是将为每个等待的slave注册写事件,并注册写事件的响应函数sendBulkToSlave,即当slave对应的socket能够发送数据时就调用函数sendBulkToSlave(),实际发送rdb文件的操作都在函数sendBulkToSlave中完成。
(7)sendBulkToSlave函数将把备份的rdb文件发送给slave。

增量同步 (数据修改同步)

(1)master接收到一条用户的操作后,将调用函数call函数来执行具体的操作函数(此过程可参考另一文档《redis命令执行流程分析》),在该函数中首先通过proc执行操作函数,然后将判断操作是否需要扩散到各slave,如果需要则调用函数propagate()来完成此操作。
(2)propagate()函数完成将一个操作记录到aof文件中或者扩散到其他slave中;在该函数中通过调用feedAppendOnlyFile()将操作记录到aof中,通过调用replicationFeedSlaves()将操作扩散到各slave中。
(3)函数feedAppendOnlyFile()中主要保存操作到aof文件,在该函数中首先将操作转换成redis内部的协议格式,并以字符串的形式存储,然后将字符串存储的操作追加到aof文件后。
(4)函数replicationFeedSlaves()主要将操作扩散到每一个slave中;在该函数中将遍历自己下面挂的每一个slave,以此对每个slave进行如下两步的处理:将slave的数据库切换到本操作所对应的数据库(如果slave的数据库id与当前操作的数据id不一致时才进行此操作);将命令和参数按照redis的协议格式写入到slave的回复缓存中。写入切换数据库的命令时将调用addReply,写入命令和参数时将调用addReplyMultiBulkLen和addReplyBulk,函数addReplyMultiBulkLen和addReplyBulk最终也将调用函数addReply。
(5)在函数addReply中将调用prepareClientToWrite()设置slave的socket写入事件处理函数sendReplyToClient(通过函数aeCreateFileEvent进行设置),这样一旦slave对应的socket发送缓存中有空间写入数据,即调用sendReplyToClient进行处理。
(6)函数sendReplyToClient()的主要功能是将slave中要发送的数据通过socket发出去。

Redis 同步的是指令流,主节点会将那些对自己的状态产生修改性影响的指令记录在本地内存 buffer 中,然后异步将 buffer 中的指令同步到从节点,从节点一边执行同步的指令流来达到和主节点一样的状态,一遍向主节点反馈自己同步到哪里。

如果出现网络闪断或者命令丢失等异常情况,从节点之前保存了自身已复制的偏移量和主节点的运行ID
主节点根据偏移量把复制积压缓冲区里的数据发送给从节点,保证主从复制进入正常状态。

集群 1:哨兵(Sentinel)模式

主从切换需要人工干预,费事费力,还会造成一段时间内服务不可用。必须有一个高可用方案来抵抗节点故障,当故障发生时可以自动进行从主切换,程序可以不用重启。也就是Redis集群。
敖丙思维导图-Redis - 图5

  • 通过发送命令,让Redis服务器返回监控其运行状态,包括主服务器和从服务器。
  • 当哨兵监测到master宕机,会自动将slave切换成master,然后通过发布订阅模式通知其他的从服务器,修改配置文件,让它们切换主机。(多个 sentinel 会选出一个 leader,具体的选举机制是依据 Raft 分布式一致性协议。)

假设主服务器宕机,哨兵1先检测到这个结果,系统并不会马上进行failover过程,仅仅是哨兵1主观的认为主服务器不可用,这个现象成为主观下线。当后面的哨兵也检测到主服务器不可用,并且数量达到一定值时,那么哨兵之间就会进行一次投票,投票的结果由一个哨兵发起,进行failover操作。切换成功后,就会通过发布订阅模式,让各个哨兵把自己监控的从服务器实现切换主机,这个过程称为客观下线。

redis的集群脑裂:(sentinel集群无法感知到master,有两个出现)

  • 连接到master的最少slave数量
  • slave连接到master的最大延迟时间

集群 2:Codis(中心化,国内团队维护)

敖丙思维导图-Redis - 图6

Codis 在设计上相比 Redis Cluster 官方集群方案要简单很多,因为它将分布式的问题交给了第三方 zk/etcd 去负责。

  • 因为 Codis 中所有的 key分散在不同的 Redis 实例中,所以事务就不能再支持了
  • Codis 因为增加了 Proxy 作为中转层,所有在网络开销上要比单个 Redis 大。
  • Codis 的集群配置中心使用 zk 来实现。

集群 3:Redis Cluster(去中心化)

Redis Sentinel 水平扩容牵涉到数据的迁移。迁移过程一方面要保证自己的业务是可用的,一方面要保证尽量不丢失数据所以数据能不迁移就尽量不迁移。

每个节点负责其中一部分槽位(平均)。槽位的信息存储于每个节点中,它不像 Codis,它不需要另外的分布式存储来存储节点槽位信息。
当 Redis Cluster 的客户端来连接集群时,它也会得到一份集群的槽位配置信息。这样当客户端要查找某个 key 时,可以直接定位到目标节点。

Redis Cluster把数据集划分到多个节点上,每个节点负责整个数据的一个子集。采用 哈希算法 将 Redis 数据的 key 进行散列,通过 hash 函数,特定的 key会 映射 到特定的 Redis 节点上。-》最小配置 6 个节点以上(3 主 3 从),其中主节点提供读写操作,从节点作为备用节点,不提供请求,只作为故障转移使用。

为什么Redis集群有16384个槽

Redis 集群没有使用一致性 hash,而是引入了哈希槽的概念,Redis 集群有16384 个哈希槽,每个 key 通过 CRC16 校验后对 16384 取模来决定放置哪个槽,集群的每个节点负责一部分 hash 槽。
https://github.com/antirez/redis/issues/2576

对于客户端请求的key,根据公式HASH_SLOT=CRC16(key) mod 16384,计算出映射到哪个分片上,然后Redis会去相应的节点进行操作

(1)如果槽位为65536,发送心跳信息的消息头达8k,发送的心跳包过于庞大。
(2)redis的集群主节点数量基本不可能超过1000个。
(3)槽位越小,节点少的情况下,压缩比高

Redis主节点的配置信息中,它所负责的哈希槽是通过一张bitmap的形式来保存的,在传输过程中,会对bitmap进行压缩,但是如果bitmap的填充率slots / N很高的话(N表示节点数),bitmap的压缩率就很低。
如果节点数很少,而哈希槽数量很多的话,bitmap的压缩率就很低。

敖丙思维导图-Redis - 图7

Redis内存使用

自身内存+键值对象内存+缓冲区内存+内存碎片

缓冲区内存

  1. 客户端缓存。
    客户端:主节点会为每一个从节点建立一条连接用于命令复制,避免挂载超过2个从节点。
    订阅客户端:发布订阅功能链接客户端使用单独的缓冲区。消费慢于生产会导致缓冲区积压。
    普通客户端:无缓冲区限制,注意最大连接数,大吞吐量命令。
  2. 复制积压缓冲区。
    可重用固定大小缓冲区,用以实现向从节点的部分复制功能。
  3. AOF缓冲区。
    AOF重写期间增量写入命令保存,取决于AOF重写时间及增量。

    Redis子进程内存消耗

    子进程即redis执行持久化(RDB/AOF)时fork的子任务进程。

  4. Linux写时复制机制。
    父子进程共享相同物理内存页,父进程处理写请求会复制副本进行修改,子进程读取的内存为fork时父进程的内存快照,所以子进程内存消耗由写操作增量决定。

  5. Linux透明大页机制THP
    THP会降低fork子进程的速度。写时复制内存页由4kb增加到2M,高并发下内存占用消耗较大,选择性关闭。
  6. Linux配置
    Vm_overcommit_memory=1 允许系统可以分配所有的物理内存。

    Redis 常见问题解决

    缓存雪崩*

    同一时刻大量缓存失效;
    (1):永不过期/设置不同的缓存失效时间 (加**随机值**)
    (2):限流降级 (通过加锁或者队列来控制读数据库写缓存的线程数量。)
    (3):集群部署/采用云Redis
    (4):多缓存结合

    缓存击穿

  • 设置热点数据永远不过期
  • 加互斥锁 (缓存中没有数据,第1个进入的线程,获取锁并从数据库去取数据,没释放锁之前,其他并行进入的线程会等待100ms)

某些热点数据是同时存入到redis的话,那么它们的过期时间最好是能够做成随机值,防止出现时间到达后缓存大面积失效,导致缓存击穿

缓存穿透*

  • 布隆过滤器【key移除了,无法删除】一个对一个key进行k个hash算法获取k个值,在比特数组中将这k个值散列后设定为1,然后查的时候如果特定的这几个位置都为1,那么布隆过滤器判断该key存在。(不存在漏报,可能会误判。误判率越低,比特数组占用空间就越多)
  • 缓存空对象(常用/简单/可以过期):访问数据库后返回为空,此时也将该空对象进行缓存。

    布谷鸟过滤器源于布谷鸟Hash算法,布谷鸟Hash表有两张,分别两个Hash函数,当有新的数据插入的时候,它会计算出这个数据在两张表中对应的两个位置,这个数据一定会被存在这两个位置之一(表1或表2)。一旦发现其中一张表的位置被占,就将改位置原来的数据踢出,被踢出的数据就去另一张表找对应的位置。通过不断的踢出数据,最终所有数据都找到了自己的归宿。
    但仍会有数据不断的踢出,最终形成循环,总有一个数据一直没办法找到落脚的位置,这代表布谷Hash表走到了极限,需要将Hash算法优化或Hash表扩容。

双写一致性(先更新数据库,再删缓存)

  1. 先更新数据库,再更新缓存
    不可靠,A写后B写B更新A更新,因为网络等原因,B却比A更早更新了缓存
  2. 先删缓存,再更新数据库 (延时双删策略)
    (1)先淘汰缓存
    (2)再写数据库(这两步和原来一样)
    (3)休眠1秒(读数据业务逻辑的耗时),再次淘汰缓存
  3. 先更新数据库,再删缓存 (facebook也在论文《Scaling Memcache at Facebook》中提出,他们用的也是先更新数据库,再删缓存的策略。删除失败,保障的重试机制)

第二次删除,如果删除失败怎么办?

  1. 更新数据库数据;
  2. 数据库会将操作信息写入binlog日志当中;
  3. 订阅程序提取出所需要的数据以及key;
  4. 另起一段非业务代码,获得该信息;
  5. 试删除缓存操作,发现删除失败;
  6. 将这些信息发送至消息队列;
  7. 重新从消息队列中获得该数据,重试操作;

并发竞争

  • 分布式锁+时间戳 (setnx()建立锁,修改的value加时间戳,每次更新修改它)
  • 消息队列

大Key

  • bigkeys命令可以找到它
  • 4.0引入了memory usage命令(内存维度的抽样算法)和lazyfree机制(异步删除)
  1. redis-cli -h 127.0.0.1 -p 3306 -bigkeys -i 0.1
  2. 每隔100scan命令休眠0.1sops不会剧烈抬升

lazy free机制(主动+被动都可设置)

当删除键的时候,redis提供异步延时释放key内存的功能,把key释放操作放在bio(Background I/O)单独的子线程处理中,减少删除big key对redis主线程的阻塞。

热点Key

  • 服务端缓存,二级缓存:即将热点数据缓存至服务端的内存中 (Redis的事件通知机制实现一致性)
  • 备份热点Key:即将热点Key+随机数,随机分配至Redis其他节点中。这样访问热点key的时候就不会全部命中到一台机器上了。
  • redis-cli –hotkeys就能找出热点Key

Redis缓存过期策略以及内存淘汰机制

过期策略:定期删除 + 惰性删除 (unlink 丢给后台线程异步回收)

redis采用惰性删除+定期删除策略。(在进行get或setnx等操作时,先检查key是否过期;对指定个数个库的每一个库随机删除小于等于指定个数个过期key)

  1. 惰性删除:当某个key被设置了过期时间之后,客户端每次对该key的访问(读写)都会事先检测该key是否过期,如果过期就直接删除;
  2. 定期删除:有一些键只访问一次,因此需要主动删除,默认情况下redis每秒检测10次(也有可能随机检查),检测的对象是所有设置了过期时间的键集合,每次从这个集合中随机检测20个键查看他们是否过期,如果过期就直接删除,如果删除后还有超过25%的集合中的键已经过期,那么继续检测过期集合中的20个随机键进行删除。这样可以保证过期键最大只占所有设置了过期时间键的25%。

    内存淘汰机制:淘汰机制-LRU淘汰算法(最近最久未使用)

    如果定期删除没删除key。然后你也没即时去请求key,也就是说惰性删除也没生效。这样,redis的内存会越来越高。那么就应该 采用内存淘汰机制。在redis.conf中有一行配置
    maxmemory-policy volatile-lru
    volatile-lru:从已设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰
    volatile-ttl:从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰
    volatile-random:从已设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰
    allkeys-lru:从数据集(server.db[i].dict)中挑选最近最少使用的数据淘汰
    allkeys-random:从数据集(server.db[i].dict)中任意选择数据淘汰
    no-enviction(驱逐):禁止驱逐数据

    LRU->recently 时间上 LFU->frequently 动作/次数上

  • Redis的LRU实现
    给每个key增加一个额外的字段,这个字段占24bit,也就是最后一次被访问的时间戳。然后随机采样出5个key淘汰掉最旧的key,直到Redis占用内存小于maxmemory为止。

    如果按照HashMap和双向链表实现,需要额外的存储存放 next 和 prev 指针,牺牲比较大的存储空间,显然是不划算的。

自己去实现LRU的方式: 基于 HashMap 和 双向链表实现 LRU

使用 HashMap 存储 key,而 HashMap 的 Value 记录需要缓存数据在 LRU 存储中的槽。

save(key, value)——首先在 HashMap 找到 key 对应的节点, (a)如果节点存在,更新节点的值,并把这个节点移动到队头。 (b)如果不存在,需构造新的节点,并且尝试把节点塞到队头。 (c)如果LRU空间不足,则通过 tail 淘汰掉队尾的节点,同时在 HashMap 中移除 key。 [新增key value的时候首先在链表结尾添加Node节点,如果超过LRU设置的阈值就淘汰队头的节点并删除掉HashMap中对应的节点。
get(key)——通过 HashMap 找到 LRU 链表节点,因为根据LRU 原理,这个节点是最新访问的,所以要把节点插入到队头,然后返回缓存的值。 [访问key对应的值的时候把访问的Node节点移动到队尾即可。]
[修改key对应的值的时候先修改对应的Node中的值,然后把Node节点移动队尾。]

Redis 实现限流

  1. setnx操作
    10秒内限定20个请求,那么我们在setnx的时候可以设置过期时间10。(1-10秒的时候,无法统计2-11)
  2. 数据结构zset
    限流涉及的最主要的就是滑动窗口。value保持唯一,可以用UUID生成,而score用当前时间戳表示,因为score我们可以用来计算当前时间戳之内有多少的请求数量。而zset(sorted set)数据结构也提供了range方法让我们可以很轻易的获取到2个时间戳内有多少请求

sorted set 当items内容大于64的时候同时使用了hash和skiplist两种设计实现。

  1. Redis的令牌桶算法
    每访问一次请求的时候,可以从Redis中获取一个令牌,如果拿到令牌了,那就说明没超出限制。
    依靠List的pop来获取令牌。
    Java的定时任务,定时往Listpush令牌。
    还可以使用redsi-cell 实现令牌桶算法

漏桶算法思路很简单,水(请求)先进入到漏桶里,漏桶以一定的速度出水(接口有响应速率),当水流入速度过大会直接溢出(访问频率超过接口响应速率),然后就拒绝请求,可以看出漏桶算法能强行限制数据的传输速率。

跳跃表

  1. 新节点和各层索引节点逐一比较,确定原链表的插入位置。O(logN)
  2. 把索引插入到原链表。O(1)
  3. 利用抛硬币的随机方式,决定新节点是否提升为上一级索引。结果为“正”则提升并继续抛硬币,结果为“负”则停止。O(logN)
    总体上,跳跃表插入操作的时间复杂度是O(logN),而这种数据结构所占空间是2N,既空间复杂度是 O(N)。它维持平衡的成本低,而二叉查找树多次插入删除后需要rebalance。

使用Redis的时候需要注意的点

拒绝bigkey的出现

key的值不宜设置地过大,尽量保证简洁明了,减少对于内存的占用。通常来说,当一个单独存储的value值大于10kb的时候就会被认为是bigkey了。

  1. 对于hash,list,set,zset这类数据结构而言,尽量不要让其数目超过5000个。
  2. 删除bigkey可以结合redis自身提供的机制 异步 删除机制 。
  3. 优化big key
  • 优化big key的原则就是string减少字符串长度,list、hash、set、zset等减少成员数
    以hash类型举例来说,对于field过多的场景,可以根据field进行hash取模,生成一个新的key,
    1. 例如原来的
    2. hash_key:{filed1:value, filed2:value, filed3:value ...},可以hash取模后形成如下key:value形式
    3. hash_key:mod1:{filed1:value}
    4. hash_key:mod2:{filed2:value}
  1. 禁止命令的设置
    生产环境是禁用keys,flushall,flushdb这类命令
    1. 通过修改redis.conf中的SECURITY项,在里头新增以下几行,即可实现对危险指令的禁用
    2. rename-command FLUSHALL ""
    3. rename-command FLUSHDB ""
    4. rename-command CONFIG ""
    5. rename-command KEYS ""

Redis 集群会有写操作丢失吗?

以下情况可能导致写操作丢失:

  • 过期 key 被清理
  • 最大内存不足,导致 Redis 自动清理部分 key 以节省空间
  • 主库故障后自动重启,从库自动同步
  • 单独的主备方案,网络不稳定触发哨兵的自动切换主从节点,切换期间会有数据丢失

    DEL会阻塞Redis:命令时间复杂度,与key类型有关

  1. key是string类型,o(1)。大key出现时,即使开启Lazy Free仍旧会在主线程处理,阻塞Redis。
  2. key是list/Hash/Set/ZSet类型,DEL时间复杂度是o(M),M为元素数量(判断元素数量后,若数量大则分批次删除)

    Random key可能会阻塞Redis

  3. 由于Redis过期策略时定时清理+惰性清理。若有大量key过期,则会有大量循环在 清理过期key+寻找不过期key。

  4. 在slave上执行,由于slave不会清理过期key,则会有大量循环在 判断过期key+寻找不过期key。【5.0版本设置了最多寻找100次】

Monitor会导致OOM

执行Monitor命令,Redis会把每一条命令写到客户端的输出缓冲区,客户端从缓冲区读取服务器返回结果。

Redis最佳实践

  1. 控制key的长度。
  2. 避免存储bigkey。
  3. 选择合适的数据类型。【String、Set在存储int数据时,会采用整数编码存储。Hash、Zset在元素较少时,会使用ziplist存储。】
  4. 把Redis当成缓存来用(过期时间)。
  5. 实例设置maxmemeory+淘汰策略。【lru:保留最近访问过的数据;lfu:保留访问次数最频繁的数据;ttl:优先淘汰即将过期的数据;random:随机淘汰】
  6. 数据压缩后存入redis。

    运维Redis

  7. 禁止使用 KEYS/FLUSHALL/FLUSHDB

  8. SCAN扫描线上实例,设置休眠时间
  9. 慎用MONITOR命令。(redis会把每一条命令写到客户端的 输出缓冲区。)这时如果Redis QPS很高,会导致OOM。
  10. 从库设置为slave-read-only
  11. 合理配置timeout和tcp-keepalive参数。(当客户端与服务端发生问题时,服务端并不会立即释放这个client fd。)Redis内部有一个定时任务,会定时检测所有client的空闲时间是否超过配置的timeout值。如果没有开启tcp-keepalive【定时向客户端发送心跳包】,服务端直到timeout才会清理释放这个client fd。
  12. 调整maxmemory时,注意主从库的调整顺序。

    Redis问题定位

  13. 基准性能测试取得对应实例性能。

  14. 查看Redis慢日志。使用了复杂度过高的命令,或者是返回的数据过大?【不要使用O(N)复杂度过高的命令,数据聚合操作放在客户端做;执行O(N)命令,保证N尽量小】
  15. 查看Redis慢日志。发现操作了BIGKEY?【用bigkeys就可以扫描;redis4.0版本以上使用UNLINK代替DEL,redis6.0版本开启lazy-free机制
  16. key集中过期?主动过期key的定时任务是在redis主线程执行的,大量删除key会出现卡顿。【加随机过期时间;开启lazy-free机制】
  17. 实例内存达到上限?Redis必须从实例中剔除一部分数据让内存维持在maxmemory下。【淘汰策略从LRU改成随机淘汰;拆分实例将key分摊;开启lazy-free机制
  18. fork耗时严重?开启RDB+AOF后,执行时需要主进程创建子进程(fork)进行数据的持久化(此时父进程要修改一个key,需要copy原有内存数据,在修改新的内存),在redis上执行INFO命令,可以看到fork耗时。【控制redis实例内存10G以下;在slave节点执行RDB备份,对于丢失数据不敏感项目,关闭AOF;降低主从库全量同步频率】
  19. 开启内存大页?应用程序向操作系统申请内存时,按内存页申请(默认4KB)。当Redis执行后台RDB和AOF rewire时,采用fork子进程形式。但主进程任然可以接受写请求,写请求会使用Copy on Write方式。这时如果Linux开启内存大页机制,申请内存时间变长,写请求耗时增加出现延迟。【不建议在Redis实例上开启内存大页机制】
  20. 开启AOF?使用 appendfsync everysec。Redis后台线程在执行AOF page cache(fysnc)时,如果磁盘IO负载很高,后台线程fsync会被阻塞住。主线程接受写请求,需要把数据写到文件内存中(write系统调用),但此时子线程负载过高,fsync发生阻塞不能返回(操作同一个fd,fsync和write是互斥的)。那主线程的write系统调用也会被阻塞。【当子进程在AOF rewrite期间,设置后台子线程不执行刷盘操作】
  21. appendfsync everysec丢失2秒数据?降低主线程堵塞风险;如果fsync阻塞,主线程给后台线程1s时间等待fsync成功。
  22. 绑定CPU?为降低程序在多个CPU核心之间上下文切换带来的性能损耗,可以采用进程绑定CPU的形式。服务器会有多个CPU,CPU包含多个物理核心,每个物理核心又分为多个逻辑核心,每个物理核心共用L1/L2 Cache。【Redis6.0可以配置对主线程、后台线程、后台RDB进程、AOF rewrite进程绑定固定的CPU逻辑核心。】
  23. 使用SWAP?操作系统缓解内存不足的情况,会将内存的一部分放在磁盘上(Swap)。【监控redis使用Swap的情况】
  24. 碎片整理?当程序频繁篡改Redis的数据时,会产生内存碎片。redis碎片整理也是在主线程上的。
  25. 网络带宽过载?
  26. 其他原因。频繁短链接、运维监控、其它程序抢占资源。
  27. 在slave上执行RANDOMKEY。slave自己不会清理过期的key。(master删除一个key后会发送DEL同步)如果我们在slave上执行RANDOMKEY,有可能造成Redis实例卡死。(大量过期的key,导致slave找不到符合条件的key。5.0版本增加了最大重试次数,避免死循环)

如果一个key已过期,但这个key未被master清理,此时在slave上查询这个key,会返回什么?

取决于:Redis版本(4.0.11以上版本过期key查询返回不存在) + 具体执行的命令(3.2-4.0.11版本间,EXISTS返回TRUE) + 机器时钟(要是slave时钟走得快,它就会返回null)

缓存雪崩

  1. 大量数据同时过期 【均匀设置过期时间 / 互斥锁 / 双key策略 / 后台更新缓存】
  2. redis故障 【服务熔断或请求限流机制 / 构建redis缓存高可靠集群】

布隆过滤器由【初始值都为0的位图数组】和【N个哈希函数】组成。(存在hash冲突,会误判,不会漏判)

  1. 使用N个哈希函数对数据计算,得到N个哈希值
  2. 将N个哈希值对位图数组长度取模,得到每个哈希值在位图数组的对应位置。
  3. 将每个哈希值在位图数组的对应位置的值置为1。

    拓展

    RESP 文本序列化协议

Redis使用了浪费流量的文本序列化协议,但实现异常简单,且解析性能好。

内存回收机制

Redis 并不总是可以将空闲内存立即归还给操作系统。
如果当前 Redis 内存有 10G,当你删除了 1GB 的 key 后,再去观察内存,你会发现内存变化不会太大。原因是操作系统回收内存是以页为单位,如果这个页上只要有一个 key还在使用,那么它就不能被回收。

Redis 虽然无法保证立即回收已经删除的 key 的内存,但是它会重用那些尚未回收的空闲内存。

cpu层面的缓存管理机制

cpu将缓存分为了L1,L2,L3,其速度值大小为L3<L2<L1,当cpu需要获取数据的时候会先从自己的寄存器中提取数据,然后再从L1中查询,L1都能查询的缓存数据若没有命中,则会返回到L2查询,如果L2也查询不到就会追溯到L3查询,通常情况下L3中能够命中80%的数据信息。L3如果没有命中数据则会到内存里面查询。MESI的缓存一致性协议确保缓存一致性