基础篇(1-10)
0.Redis学习体系
我们在实践中,肯定会遇到一些Redis性能问题:比如,为了保证数据的可靠性,Redis 需要在磁盘上读写 AOF 和 RDB,但在高并发场景里,这就会直接带来两个新问题:一个是写 AOF 和 RDB 会造成 Redis 性能抖动,另一个是 Redis 集群数据同步和实例恢复时,读 RDB 比较慢,限制了同步和恢复速度。
总体来说,Redis使用遇到的“坑”主要集中在四个方面:
- CPU处理器 使用上的“坑”,例如数据结构的复杂度、跨 CPU 核的访问;
- 内存使用上的“坑”,例如主从同步和 AOF 的内存竞争;
- 存储持久化上的“坑”,例如在 SSD 上做快照的性能抖动;
- 网络通信上的“坑”,例如多实例时的异常网络丢包。
1.Redis的知识体系
Redis 知识全景图?简单来说,可以归纳为“两大维度,三大主线”,如下图:
两大维度:
- 系统维度:例如 run-to-complete 模型、epoll 网络模型等各项关键技术的设计原理。
- 应用维度:缓存、集群、数据结构的应用。
三大主线:
- 高性能:线程模型、数据结构、持久化、网络框架;
- 高可靠:主从复制、哨兵机制;
- 可扩展:数据分片、负载均衡。
2.Redis 问题画像图
概括了常见Redis问题解决方案,如下图:
实际工作中遇到的问题,也可以像上图一样,按照“问题 –> 主线 –> 技术点”的方式梳理出来,积累经验。
小结
提问
Redis使用时会遇到哪些坑?
Redis的整体知识体系?
1.基本架构:一个键值数据库包含什么?
本篇我们先来构建一个简单的键值数据库,我们暂时命名为SimpleKV。我们只需要关注整体架构和核心模块即可。
首先要考虑里面可以存什么样的数据,对数据可以做什么样的操作,即数据模型和操作接口。
1.数据模型:可以存哪些数据
键值数据库,键值对当然就是基本的数据模型。其中:
- key:不同的键值数据库支持的key类型差别不大。
- value:不同的键值数据库支持的 value 类型差别较大。如,Memcached 支持的 value 类型仅为 String 类型,而 Redis 支持的 value 类型包括了 String、哈希表、列表、集合等。键值数据库支持的 value 类型是选型的重要依据,Redis丰富的value类型就是它那么流行的原因之一。
SimpleKV的数据模型提供“字符串:字符串”就可以。
2.数据接口:可以怎么操作数据
下面看数据接口。SimpleKV支持增删查改就行。
SimpleKV 需要支持的 3 种基本操作,即 PUT、GET 和 DELETE,如下:
- PUT:新写入或更新一个 key-value 对;有key为更新,无key为插入。有些数据库是SET命令,一样的。
- GET:根据一个 key 读取相应的 value 值;
- DELETE:根据一个 key 删除整个 key-value 对。
此外,还有批量查询操作。所以,PUT/GET/DELETE/SCAN 是一个键值数据库的基本操作集合。
3.数据放哪:内存还是外存
内存和外存优缺点如下:
- 内存:访问快,百ns级别。但是掉电会丢数据。
- 外存:访问慢,几ms级别。但是数据可持久化。
选哪种,主要看业务场景。如果是缓存,那么要求速度快,掉电丢数据无所谓。
为了和Redis一致,SimpleKV我们选择存在内存。
4.具体实现框架总览
大体来说,一个键值数据库的实现包括以下四个模块:
- 访问框架
- 操作模块
- 索引模块
- 存储模块
SimpleKV实现总体架构如下图:
下面具体讲解这几个模块。
5.访问框架:怎么访问数据库
1.两种访问模式
一般有两种访问模式:
- 通过函数库调用的方式供外部应用使用:如上图中的 libsimplekv.so,是以动态链接库的形式链接到我们自己的程序中的。
- 通过网络框架以 Socket 通信的形式对外提供键值对操作:如上图中,网络框架中包括 Socket Server 和协议的解析。
实际的键值数据库也是上述两种方式,如RocksDB 以动态链接库的形式使用,而 Memcached 和 Redis 则是通过网络框架访问。
2.I/O 模型设计
上述的网络框架方式中,命令是封装在网络包里发送的,当键值数据库网络框架接收到网络包,并按照相应的协议进行解析之后,就可以得到具体的命令,然后开始实际的数据处理。
I/O 模型设计概念:上述过程,网络连接的处理、网络请求的解析,以及数据存取的处理,是用一个线程、多个线程,还是多个进程来交互处理呢?该如何进行设计和取舍呢?我们一般把这个问题称为 I/O 模型设计。各个方案特点如下:
- 一个线程:网络连接 -> 请求解析 -> 数据存取,一旦某一步阻塞,整个流程就被阻塞。
- 多个线程:可以防止阻塞,但是有资源竞争。即不同线程间如果需要访问共享资源,会产生线程竞争。
上述线程的取舍,后面会详细分析。会涉及到,Redis是如何做到“单线程、高性能”的。
6.索引模块:如何定位键值对的位置?
索引模块:让键值数据库根据 key 找到相应 value 的存储位置。
索引的类型:有很多种,常见的有哈希表、B+ 树、字典树等。不同索引,在时间、空间、并发控制等方面有不同的特征。如,Memcached 和 Redis 采用哈希表作为 key-value 索引,而 RocksDB 则采用跳表作为内存中 key-value 的索引。
一般来说,内存键值数据库(例如 Redis)采用哈希表作为索引。因为数据是保存在内存中,内存的高性能随机访问特性,可以很好地与哈希表 O(1) 的操作复杂度相匹配。
Redis的特点:Redis的 value 支持多种类型,所以当我们通过索引找到一个 key 所对应的 value 后,还需要从 value 的复杂结构(例如集合和列表)中进一步找到我们实际需要的数据。
7.操作模块:不同操作的具体逻辑是怎样的?
找到相应的 value 后,如何进一步操作?SimpleKV 的操作模块就实现了不同操作的具体逻辑:
- GET/SCAN 操作:此时根据 value 的存储位置返回 value 值即可
- PUT 新的键值对数据:为该键值对分配内存空间;
- DELETE 操作:删除键值对,并释放相应的内存空间,这个过程由分配器完成。
注意到,PUT和DELETE操作,需要分配/释放内存空间。
那么如何分配或释放内存空间?就涉及到了存储模块。
8.存储模块:如何实现重启后快速提供服务?
1.内存分配器
直接使用内存分配器分配内存:SimpleKV 采用了常用的内存分配器 glibc 的 malloc 和 free,因此,SimpleKV 并不需要特别考虑内存空间的管理问题。
Redis 的内存分配器:Redis提供了多种内存分配器,之后详细解答。因为不同的内存分配器有不同特点,例如,glibc 的分配器在处理随机的大小内存块分配时,一旦保存的键值对数据规模过大,就可能会造成较严重的内存碎片问题。
2.持久化
内存分配有专门的内存分配器。
所以存储模块方面,我们重点考虑持久化的问题。
- 如何保存:直接调用FS接口保存到本地。鉴于磁盘管理要比内存管理复杂,SimpleKV 就直接采用了文件形式,将键值数据通过调用本地文件系统的操作接口保存在磁盘上。
- 保存时机:
- 每一个键值对,都落盘保存:性能较差。数据可靠性高。
- 周期性地批量保存到文件中:性能好。数据可靠性低。
Redis 也提供了持久化,它提供了多种执行机制和优化改进,后续讲解。
小结
以上,前三步是从应用的角度进行设计的,也就是应用视角;后四步是具体实现,即 SimpleKV 完整的内部构造,
SimpleKV 和 Redis对比如下图:
从 SimpleKV 演进到 Redis,有以下几个重要变化:
- Redis 主要通过网络框架进行访问:而不再是动态库了,这也使得 Redis 可以作为一个基础性的网络服务进行访问,扩大了 Redis 的应用范围。
- Redis 有更多的操作接口:这是因为数据模型中的 value 类型很丰富,所以操作接口很多。例如面向列表的 LPUSH/LPOP,面向集合的 SADD/SREM 等。在下一篇,我将和你聊聊这些 value 模型背后的数据结构和操作效率,以及它们对 Redis 性能的影响。
- Redis 的持久化模块能支持两种方式:日志(AOF)和快照(RDB),这两种持久化方式具有不同的优劣势,影响到 Redis 的访问性能和可靠性。
- Redis 支持高可靠集群和高可扩展集群:相比于SimpleKV 这个简单的单机键值数据库,Redis 中包含了相应的集群功能支撑模块。
本篇所讲就是单机版Redis的核心。后续讲进行详细讲解。
提问
设计一个简单的KV数据库?需要考虑哪些方面?如何设计?如何实现?应用视角设计,架构层面实现。
和你了解的 Redis 相比,你觉得,SimpleKV 里面还缺少什么功能组件或模块吗?缺少高可用和可扩展的设计
2.数据结构:快速的Redis有哪些慢操作?
Redis很快,能以微秒级别查询到数据。
为什么Redis这么快:
- Redis是内存数据库:所有操作均在内存上完成,内存的访问速度本身就很快。
- Redis有高效的数据结构:键值对是按一定的数据结构来组织的,操作键值对最终就是对数据结构进行增删改查操作,所以高效的数据结构是 Redis 快速处理数据的基础。
1.值类型与底层数据结构总览
Redis的value值类型主要有5种:String(字符串)、List(列表)、Hash(哈希)、Set(集合)、 Sorted Set(有序集合)。
Redis底层的数据结构有6种:简单动态字符串、双向链表、压缩列表、哈希表、跳表、整数数组。
关系如下图所示:
这张图更清晰:
在上图中,我们可以看到,根据底层数据结构,value的类型主要可以分为两类:
- String 类型:底层只有一种数据结构实现。
- 集合类型:即List、Hash、Set 和 Sorted Set 。底层都是两种数据结构实现。
那么,有以下疑问:
- 键和值本身之间如何组织?上述的数据结构都是值的实现。
- 集合类型底层如何组织?它们为什么都有两个底层结构呢。
- 简单动态字符串和常用的字符串有什么区别?
接下来讨论前两个问题。字符串问题后续讨论。
2.键和值用什么结构组织?
键值对总体组织方式:
- Redis 用一个哈希表来保存所有键值对,来实现从键到值的快速访问。
- 哈希表的本质,就是一个数组。数组的每个元素称为一个哈希桶。
- 哈希桶里面只保存指向具体值的指针。并不保存值。
哈希桶中的 entry 元素中保存了*key指针和*value指针,分别指向了实际的键和值。如下图所示。
全局哈希表:可以看到这个哈希表保存了所有的键值对,所以,我也把它称为全局哈希表。
组织形式:如上图。全局哈希表 -> entry -> key指针,value指针 -> 具体的key值、value值。只需要计算key的hash值,就能拿到哈希桶位置。
3.为什么哈希表操作变慢了?
操作变慢的原因:有时候我们会发现,操作突然变慢了,这是为什么?很可能就是哈希表的冲突问题和 rehash 可能带来的操作阻塞。
1.哈希冲突
哈希冲突就是两个key落到了同一个哈希桶内。
Redis 解决哈希冲突的方式:链式哈希。如下图所示:
链式哈希的问题:冲突太多,链表也会变得很长,这样查找效率也会降下来。解决方式是rehash操作。
2.rehash操作
rehash 操作:增加现有的哈希桶数量,减少单个桶中的元素数量。
为了让rehash操作更高效,Redis 默认使用了两个全局哈希表:哈希表 1 和哈希表 2。具体实现如下:
- 只用表1:刚插入数据时,默认使用哈希表 1,暂时不用哈希表2。数据增多后再rehash。
- 申请更长的表2:给哈希表 2 分配更大的空间,比如是当前哈希表 1 大小的两倍;
- 数据从表1拷贝到表2:把哈希表 1 中的数据重新映射并拷贝到哈希表 2 中;
- 释放表1:释放哈希表 1 的空间。
- 之后两表交替进行。
上述第3步,涉及到了数据的大量拷贝。如果一次性copy过去,Redis线程肯定会被阻塞。索引引入了渐进式哈希。
渐进式哈希,简单来说,就是把copy分散。从0开始,每次请求copy一个哈希桶的数据:
- copy数据时,Redis 仍然正常处理客户端请求。
- 每处理一个请求时,从哈希表 1 中的第一个索引位置开始,顺带着将这个索引位置上的所有 entries 拷贝到哈希表 2 中;
- 等处理下一个请求时,再顺带拷贝哈希表 1 中的下一个索引位置的 entries。如下图所示:
如下图所示。
4.集合数据操作效率
String类型的值操作,只有一步,找到值就可以直接操作,所以操作复杂度是O(1)。
集合类型的值操作,有两步。第一步:通过全局哈希表找到对应的哈希桶位置。第二步:是在集合中再增删改查,此时操作效率涉及到两方面,分别是集合的数据结构,以及是单元素操作还是范围操作。所以操作复杂度需要都考虑到。
下面,我们就来看下集合类型两个方面的操作复杂度,即底层数据结构,以及单元素或范围操作。
5.有哪些底层数据结构?
集合类型的底层数据结构主要有 5 种,整数数组、双向链表、哈希表、跳表、压缩列表:
- 整数数组、双向链表:操作都是顺序读,比较简单。复杂度O(N),效率比较低。
- 哈希表:复杂度O(1)。
- 压缩列表、跳表:比较少见,但是Redis重要的数据结构,下面重点讲解。
1.压缩列表
压缩列表:实际上就是一个加了自身信息的数组。不同的是:
- 在表头有三个字段 zlbytes、zltail 和 zllen:分别表示列表长度、列表尾的偏移量和列表中的 entry 个数;
- 在表尾还有一个 zlend:表示列表结束。
如下图所示。
压缩列表的特点:优点是查找第一个元素和最后一个元素时,可以通表头三个字段的长度直接定位,此时复杂度为O(1)。查找其他元素时,复杂度和数组一样,是O(N)。
2.跳表
有序链表:只能逐一查找元素,导致操作起来非常缓慢。
跳表:其实就是加了多级索引的链表,增加了多级索引,通过索引位置的几个跳转,实现数据的快速定位。如下图所示:
当数据量很大时,跳表的查找复杂度就是 O(logN)。
综上,底层各个数据结构时间复杂度总结如下图:
6.不同操作的复杂度
上面讲了对于集合来说,不同底层数据结构的影响。
下面讲对于集合来说,单元素和范围操作等不同操作的复杂度。
一般规律如下:
- 单元素操作是基础;
- 范围操作非常耗时;
- 统计操作通常高效;
- 例外情况只有几个。
下面分别讲解。
1.单元素操作
单元素操作:每种集合类型,对单个数据实现的增删改查操作。
操作复杂度由具体的数据结构决定。例如:
- Hash 集合的 HGET、HSET 和 HDEL:对哈希表做操作,复杂度都是 O(1)
- Set 集合的 SADD、SREM、SRANDMEMBER:用哈希表作为底层数据结构时,复杂度也是 O(1)。
注意一点, Hash 类型的 HMGET 和 HMSET,Set 类型的 SADD 也支持同时增加多个元素。比如同时操作N个元素,此时复杂度就是O(N)。
2.范围操作
范围操作:集合类型中的遍历操作,可以返回集合中的所有数据或范围数据。
这类操作的复杂度一般是 O(N),比较耗时,应该尽量避免。例如:
- Hash 类型的 HGETALL:返回所有数据。
- Set 类型的 SMEMBERS:返回所有数据。
- List 类型的 LRANGE:返回范围内数据。
- ZSet 类型的 ZRANGE:返回范围内数据。
SCAN 系列操作:Redis 从 2.8 版本开始提供了 SCAN 系列操作(包括 HSCAN,SSCAN 和 ZSCAN),这类操作实现了渐进式遍历,每次只返回有限数量的数据。从而避免了一次性返回所有元素而导致的 Redis 阻塞。
3.统计操作
统计操作:指集合类型对集合中所有元素个数的记录。
例如 LLEN 和 SCARD。操作复杂度为 O(1),因为当集合类型采用压缩列表、双向链表、整数数组这些数据结构时,这些结构中专门记录了元素的个数统计。
4.例外情况
例外情况:指某些数据结构的特殊记录。
例如,压缩列表和双向链表都会记录表头和表尾的偏移量。这样一来,对于 List 类型的 LPOP、RPOP、LPUSH、RPUSH 这四个操作,它们是在列表的头尾增删元素,就可通过偏移量直接定位,所以它们的*复杂度也只有 O(1)*,可以实现快速操作。
小结
Redis的value值类型主要有5种:String(字符串)、List(列表)、Hash(哈希)、Set(集合)、Sorted Set(有序集合)。
Redis底层的数据结构有6种:简单动态字符串、双向链表、压缩列表、哈希表、跳表、整数数组。
1.Redis 的底层数据结构:
- Redis 中用来保存每个键和值的全局哈希表结构
- 支持集合类型实现的底层结构:
- 双向链表
- 压缩列表
- 整数数组
- 哈希表
- 跳表
2.操作效率方面:
- 单元素效率比较高:String、Hash 和 Set使用了O(1)的哈希表,Sorted Set使用了 O(logN) 的跳表。
- 范围操作效率低:设计到遍历,一般是O(N)。建议用SCAN 来代替,避免在 Redis 内部产生费时的全集合遍历操作。
3.复杂度较高的List:
List的两种底层实现结构:双向链表和压缩列表的操作复杂度都是 O(N)。建议是:因地制宜地使用 List 类型。例如,既然它的 POP/PUSH 效率很高,那么就将它主要用于 FIFO 队列场景,而随机读写的场合不要用它。
平时使用时,根据各个value类型的底层数据结构,根据业务场景,灵活选择即可。
提问
为什么Redis这么快?内存、数据结构、单线程/网络多路复用、其他改进….
Redis的value值有哪些类型?底层数据结构有哪些类型?对应关系?有何区别?操作时间复杂度?根据底层数据结构分为两类。实现分别是…。操作分为单元素操作和范围操作…
键值对底层的组织形式?怎么存的?全局哈希表 -> entry -> key指针,value指针 -> 具体的key值、value值。见图。
Redis哈希冲突时怎么办?rehash怎么操作?
各个结合的底层数据结构?单元素范围操作等不同操作的复杂度?
整数数组和压缩列表在查找时间复杂度方面并没有很大的优势,那为什么 Redis 还会把它们作为底层数据结构呢?内存利用率,数组和压缩列表都是非常紧凑的数据结构,它比链表占用的内存要更少。Redis是内存数据库,大量数据存到内存中,此时需要做尽可能的优化,提高内存的利用率。
3.高性能IO模型:为什么单线程Redis能那么快?
首先理清Redis单线程的概念:Redis 的单线程,主要是指 Redis 的网络 IO 和键值对读写是由一个线程来完成的,这也是 Redis 对外提供键值存储服务的主要流程。但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。
1.Redis 为什么用单线程?
1.多线程的开销
多线程是快,可以增加系统吞吐率,或增加系统扩展性。但是前提是资源的合理分配。因为多线程间对共享资源是有竞争的。
如下图,左图是理想情况,右图是实际情况:
右图的关键瓶颈,就是多线程对共享资源的竞争。多线程竞争时,需要有一套机制来保证共享资源的正确性,这个额外的机制,就带来了额外的开销。
举例如下,假如Redis 采用多线程,两个线程A和B分别对List进行LPUSH和LPOP操作,此时队列要加1和减1,为了保证队列长度正确性,就需要让线程 A 和 B 的 LPUSH 和 LPOP 串行执行。这就是多线程编程模式面临的共享资源的并发访问控制问题。如下图:
多线程开发一般还会引入同步原语来保护共享资源的并发访问,这也会降低系统代码的易调试性和可维护性。
2.单线程 Redis 为什么那么快?
一般单线程比较慢,但是Redis却能达到每秒数十万。快在哪里呢?
- 大部分操作在内存上完成。
- 采用高效的数据结构。
- 采用了多路复用机制。使其在网络 IO 操作中能并发处理大量的客户端请求,实现高吞吐率。
首先我们来搞明白,网络操作和基本IO模型的阻塞点。
3.基本 IO 模型与阻塞点
以前面的SimpleKV举例。处理一个Get请求时,顺序如下:
- 监听请求:bind / listen,监听客户端请求。
- 建立连接:accept,和客户端建立连接。
- 读取请求:recv,从 socket 中读取请求。
- 解析请求:parse,解析客户端发送请求。
- 数据操作:get,根据请求类型读取键值数据。
- 返回结果:send,向 socket 中写回数据,给客户端返回结果。
如下图所示:
所有流程都在一个线程里完成。线程**潜在的阻塞点,分别是 accept() 和 recv()**:
- accept() 阻塞:监听到一个客户端有连接请求,但一直未能成功建立起连接。
- recv() 阻塞:从一个客户端读取数据时,数据一直没有到达。
如何解决阻塞问题:socket 网络模型本身支持非阻塞模式,能够解决上述阻塞问题。
4.非阻塞模式
Socket 网络模型支持非阻塞模式:
主要体现在三个关键的函数调用上,即socket()、listen()、accept() 方法调用:
- socket():返回主动套接字。
- *listen()*:将主动套接字 –转化为–> 监听套接字。
- accept() :接收到达的客户端连接,并返回已连接套接字。
如下图所示:
监听套接字可设置为非阻塞模式:Redis 调用 accept() 时,如果监听套接字上一直没有连接请求到达,此时线程不必等待请求,可返回处理其他操作。有机制会负责请求到达时通知Redis。
已连接套接字可设置为非阻塞模式:Redis 调用 recv() 时,如果已连接套接字上一直没有数据到达,此时线程不必等待数据,可返回处理其他操作。有机制会负责数据到达时通知Redis。
此时,Redis就不会像基本IO模型中一直在阻塞点等待,机制也保证了连接请求或数据到达时 Redis 可以收到通知进行处理。
OS提供了支持Socket 网络模型非阻塞模式的机制。这个机制就是Linux 的 IO 多路复用机制。其他OS也有,名字不同。
5.基于多路复用的高性能 I/O 模型
Linux 中的 IO 多路复用机制,即 select/epoll 机制,指一个线程处理多个 IO 流的机制。
机制原理:本质上是允许一个线程有多个监听套接字和已连接套接字,并且内核提供监听支持。在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听套接字和已连接套接字。内核会一直监听这些套接字上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个Redis线程处理多个IO流的效果。
下图就是基于多路复用的 Redis IO 模型:图中多个 FD 就是多个套接字。Redis 网络框架调用 epoll 机制,让内核监听这些套接字。此时,Redis 线程不会阻塞在某一个特定的监听套接字或已连接套接字上,即不会阻塞在某一个特定的客户端请求处理上。因此,Redis 可以同时和多个客户端连接并处理请求,从而提升并发性。
select/epoll 提供了基于事件的回调机制,即针对不同事件的发生,调用相应的处理函数。
回调机制工作过程:select/epoll
监测到 FD 上有请求到达-》触发相应事件-》事件入事件队列-》Redis 单线程不断处理该事件队列。因此Redis无需轮询,只需在处理事件时,回调相应的处理函数即可。
不同的OS,基本上都有对应的多路复用机制实现:Linux是select/epoll
模式,FreeBSD 是 kqueue
,Solaris 是 evport
等等。
小结
Redis的单线程:指的是网络 IO 和键值对读写是由一个线程来完成,但 Redis 的其他功能,比如持久化、异步删除、集群数据同步等,其实是由额外的线程执行的。
综上,用单线程的原因:
- 多线程的共享资源竞争消耗性能。
- 多线程的代码不易调试和维护。
- Linux等OS提供了底层高效IO多路复用的支持,解决了单线程阻塞问题。
总体逻辑:单线程基本IO会阻塞(accept等待请求,recv等待数据) -》 Socket网络模型支持非阻塞模式 -》OS提供了支持该模式的机制(多路复用I/O模型),底层回调功能由硬件支持。
整体流程:socket() -》主动套接字 -》listen() -》监听套接字 -》(客户端请求) -》accept() -》已连接套接字 -》(数据) -》recv() -》parse() -》键值数据读写 -》(数据) -》send()
另外,2020年5月,Redis6.0提出了多线程模型。后续会介绍。
提问
Redis是单线程还是多线程的?为什么使用单线程?
多线程有什么弊端?单线程有什么问题?为什么用单线程?共享资源竞争消耗资源。accept()和recv()会阻塞,不过可以用OS的多路复用机制解决,底层是由硬件来支持回调的,linux就是select/epoll机制。
什么是多路复用机制?阻塞非阻塞、异步同步有何区别?该机制可以用来支持Socket网络模型的非阻塞模式。本质上是允许一个线程有多个监听套接字和已连接套接字,并且内核提供监听功能。
Redis请求访问整体流程?画图解答。工作队列?
详细说说select/epoll模型?
4.AOF日志:宕机了,Redis如何避免数据丢失?
服务器宕机时,如何恢复内存中的数据?
容易想到的方案是从后端数据库恢复。但是后端数据库恢复有两个缺点:
- 数据库压力大:因为需要频繁访问数据库。
- 恢复速度太慢:比较从DB一个一个读,很慢。
因此,我们选择从Redis恢复。Redis恢复有两个方案,AOF日志和RDB快照。
本篇主要介绍AOF日志。AOF就是Append Only File,表示文件只能追加写。
1.AOF日志如何实现?
1.AOF是写后日志
我们还记得MySQL是写前日志(Write Ahead Log, WAL),先写日志,在写数据。
不同的是,Redis的AOF是写后日志。先执行命令把数据写到内存,在写日志。如下图所示:
2.AOF日志的格式内容
为啥要用写后日志?我们先了解下AOF日志的格式。
AOF 里记录的是 Redis 收到的每一条命令,这些命令是以文本形式保存的。
如下图,执行“set testkey testvalue
”命令后记录的日志,举例:
*3是说命令有三个部分,每部分由“$+数字”开头。“$+数字”后面跟具体的命令/键/值,数字表示一共有多少字节。
为了避免额外的检查开销,Redis 在向 AOF 里记录日志时,并不会先去对这些命令进行语法检查。
3.AOF的优缺点
根据前文,AOF写后记录有以下好处:
- 在命令执行后记录,可以保证命令一定是正确的。
- 在命令执行后记录,可以避免阻塞当前的写操作。
当然,也有缺点和风险:
- 数据可能会丢:比如刚执行完一个命令,还没写日志,就宕机。
- 可能会阻塞下一个操作:AOF日志是在主线程执行,如果磁盘写压力大导致写盘慢,后续操作就会被阻塞。
所以,规避风险的关键点是AOF写回磁盘的时机。
2.AOF日志写回策略
和MySQL类似,AOF 提供了 appendfsync
参数配置项,可以进行选择:
- Always:每次执行完命令,立即写回。
- EverySec:每次执行完命令,先写到AOF文件的内存缓冲区,之后每隔一秒写回磁盘。
- No:每次执行完命令,先写到AOF文件的内存缓冲区,之后由OS决定何时写回。
三个选项优缺点如下图:
之后又遇到新问题,随之时间推移,AOF日志文件变大怎么办?此时会影响性能:
- 文件系统本身:对文件大小有要求。
- 往后面追加命令时:大文件效率会变低。
- 发生宕机时:大文件会让你执行到地老天荒。
所以,我们提供了AOF重写机制。
3.AOF日志重写机制:解决大文件
AOF日志重写机制:根据数据库的现状创建一个新的 AOF 文件。具体操作是,读取数据库中的所有键值对,然后对每一个键值对用一条命令记录描述它,然后写入AOF日志。
重写机制解决的问题:
- 减少日志数量:老的日志,如果一个key的value被多次修改,那么肯定是记了很多次操作命令。重写后,采用最新的值只生成一条命令,内容大大减少了。而且恢复时,也只用执行一条命令,提高效率。
不过要把整个数据库的数据都重新生成日志写回磁盘,也是一个很耗时的过程。怎么解决呢?重写会不会阻塞主线程?
4.AOF 重写流程:防止阻塞主线程?
AOF 日志由主线程写回不同,重写过程是由后台线程 bgrewriteaof 来完成的,所以不会阻塞主线程。
重写过程:可以总结为“一个拷贝,两处日志”:
- 一个拷贝:每次执行重写时,主线程 fork 出后台的 bgrewriteaof 子进程。此时,fork 会把主线程的内存拷贝一份给 bgrewriteaof 子进程,这里面就包含了数据库的最新数据。然后,bgrewriteaof 子进程就可以在不影响主线程的情况下,逐一把拷贝的数据写成操作,记入重写日志。注意到,这里的数据拷贝,其实是拷贝父进程的页表,而不是物理内存,此时也相当于是得到了父进程的所有数据。
- 两处日志:此时主线程还未阻塞。新的操作过来,会被分别写入到当前AOF日志和备份AOF日志的缓冲区,所有备份完成后,再把这些操作写入新的AOF文件。最后再用新的AOF替代旧文件即可。
总体如下图:
小结
AOF是写后日志。也是为了保证命令是正确的。
提供了三种写回磁盘的选择。
避免大文件,采用重写机制。
防止阻塞主线程,采用“一个拷贝,两处日志”方式。
提问
服务器宕机了,如何恢复内存中的数据?
AOF如何实现?有何优缺点?写后日志。优点:可保证命令一定对,且不阻塞当前操作。缺点:先到内存后写日志,宕机时会丢数据。写盘慢时,会阻塞下一个操作。
AOF什么时候写回磁盘?立即写回,写到文件的内存缓冲器后每秒一次写回,写到文件的内存缓冲器后由OS决定协会时机。
AOF日志太大怎么办?重写过程?AOF重写机制。重写是根据值的最新状态生成日志,避免了以前一个数据多次修改的日志,可以缩小日志文件。一个拷贝,两处日志。
AOF 日志重写的时候,是由 bgrewriteaof 子进程来完成的,不用主线程参与,我们今天说的非阻塞也是指子进程的执行不阻塞主线程。但是,你觉得,这个重写过程有没有其他潜在的阻塞风险呢?如果有的话,会在哪里阻塞?fork子进程,fork这个瞬间一定是会阻塞主线程的(注意,fork时并不会一次性拷贝所有内存数据给子进程。fork子进程时,子进程是会拷贝父进程的页表,即虚实映射关系,而不会拷贝物理内存。子进程复制了父进程页表,也能共享访问父进程的内存数据了)
AOF 重写也有一个重写日志,为什么它不共享使用 AOF 本身的日志呢?
5.RDB快照:宕机后,Redis如何实现快速恢复?
AOF方式,一般而言,只要采用的不是 always 持久化策略,就不会对性能造成太大影响。
但是AOF恢复时效率低:需要逐行读取执行命令来恢复,很慢。
那么,有没有既可以保证可靠性,还能在宕机时实现快速恢复的其他方法呢?RDB解决了这个问题。
1.内存快照概念
内存快照:把某一时刻的状态以文件的形式写到磁盘上,也就是快照。这个快照文件就称为 RDB 文件,其中,RDB 就是 Redis DataBase
的缩写。
由于内存快照记录的是某一时刻的数据,而不是操作,所以数据恢复时我们可以直接读入内存,速度很快。但是我们需要考虑两个关键问题:
- 对哪些数据做快照?这关系到快照的执行效率;
- 做快照时,数据还能被增删改吗?这关系到 Redis 是否被阻塞,能否同时正常处理请求。
2.给哪些内存数据做快照?
为了提供所有数据的可靠性保证,内存快照执行的是全量快照。
Redis 提供了两个命令来生成 RDB 文件,分别是 save 和 bgsave。
- save:在主线程中执行,会导致阻塞;
- bgsave:创建一个子进程,专门用于写入 RDB 文件,避免了主线程的阻塞。这也是 Redis RDB 文件生成的默认配置。
我们通过 bgsave 命令来执行全量快照即可,这样就不会阻塞主线程。
3.快照时数据能修改吗?
如果不能修改,就会造成快照时需要暂停写操作,显然无法接受。
Redis 借助了OS提供的写时复制技术(Copy-On-Write, COW),来实现在执行快照的同时,进行正常写操作处理。如下图所示:
简单来说,bgsave 子进程是由主线程 fork 生成的,bgsave 子进程可以共享主线程的所有内存数据。此时:
- 如果主线程对数据进行读操作(例如图中的键值对 A):主线程和 bgsave 子进程相互不影响。
- 如果主线程对数据进行写操作(例如图中的键值对 C):这块数据就会被复制生成一份副本。然后,bgsave 子进程会把这个副本数据写入 RDB 文件。而在这个过程中,主线程仍然可以直接修改原来的数据。
4.可以每秒做一次快照吗?
即是否可以连续做快照,这样由于快照间隔时间短,所以数据恢复时丢失的数据不多。
但是,频繁的进行快照是不行的。
频繁全量快照,有以下开销:
- 磁盘压力大:频繁将全量数据写入磁盘,会给磁盘带来很大压力。可能前一个快照还没做完,后一个又开始了。
- 阻塞主线程:虽然子进程在创建后不会再阻塞主线程,但是fork 这个创建过程本身会阻塞主线程。
如果我们做增量快照,结果也会消耗大量的额外内存空间,得不偿失。
为了解决以上问题,Redis 4.0 中提出了一个混合使用 AOF 日志和内存快照的方法:内存快照以一定的频率执行,在两次快照之间,使用 AOF 日志记录这期间的所有命令操作。
如下图所示,T1 和 T2 时刻的修改,用 AOF 日志记录,到第二次做全量快照时,就可以清空 AOF 日志:
小结
比起AOF,RDB可以快速恢复数据。
RDB的 bgsave 子进程是由主线程 fork 生成。AOF的重写由主线程 fork 出后台的 bgrewriteaof 子进程来执行。AOF的写回应该是由主线程直接写回或写到缓存(待验证)。
虽然,Redis 设计了 bgsave 和写时复制方式,尽可能减少了内存快照对正常读写的影响,但是,频繁快照仍然是不太能接受的。而混合使用 RDB 和 AOF,正好可以取长补短,以较小的性能开销保证数据可靠性和性能。
关于 AOF 和 RDB 的选择问题,给出三点建议:
- 数据不能丢失时,内存快照和 AOF 的混合使用是一个很好的选择;
- 如果允许分钟级别的数据丢失,可以只使用 RDB;
- 如果只用 AOF,优先使用 everysec 的配置选项,因为它在可靠性和性能之间取了一个平衡。
RDB参数配置:打开redis.conf 文件,找到 SNAPSHOTTING 配置即可。
提问
为什么要用内存快照?AOF数据恢复时,速度很慢。
快照时数据能修改吗?可以,OS只是写时复制。
频繁快照有什么影响?具体使用时怎么配合?Redis4.0,AOF+RDB。
在哪里配置RDB和AOF混合?打开redis.conf 文件,啥都能配。
Redis会丢数据吗?自己的思考,不一定对:极端情况下,是会丢失的。RDB只能定期。AOF即使每次操作都写入数据(即aways),还是存在写数据之前突然宕机的风险。
6.数据同步:主从库如何实现数据一致?
Redis 的高可靠性包括两个方面:
- 数据尽量少丢失:这个主要通过AOF和RDB实现。
- 服务尽量少中断:这个通过增加副本冗余来实现,将一份数据同时保存在多个实例上。
前面的文章已经讲了第一个方面。下面我们讲第二个方面。
1.Redis的主从模式
Redis 提供了主从库模式,来保证数据副本的一致。
主从库之间采用的是读写分离的方式,主从都能读,只有主库可以写:
- 读操作:主库、从库都可接收;
- 写操作:首先到主库执行,然后,主库将写操作同步给从库。
如下图所示:
如上图,如果主从都能写,那么会涉及数据在三个实例上的一致,就要涉及到加锁、实例间协商是否完成修改等一系列操作,但这会带来巨额的开销。
所以采用了读写分离的形式。
下面我们来看下主从同步中的问题:一次同步还是分批同步?网络断了怎么办?等等。
2.主从库间如何进行第一次同步?
replicaof
命令设置主从关系:我们启动多个Redis实例时,它们相互之间就可通过 replicaof(Redis 5.0 之前用 slaveof)命令形成主库和从库的关系,之后会按照三个阶段完成数据的第一次同步。例如,
# 有实例 1(ip:172.16.19.3)和实例 2(ip:172.16.19.5)。
# 我们在实例 2 上执行以下这个命令后,实例 2 就变成了实例 1 的从库,并从实例 1 上复制数据:
replicaof 172.16.19.3 6379
下面将第一次同步的三个阶段。总体如下图所示:
总体如下:
- 第一阶段:从库 -> 主库,建立连接、协商同步。psync - 1。得到回应后保持主节点信息。
- 第二阶段:主库 -> 从库,同步数据。生成RDB,发送RDB。
- 第三阶段:主库 -> 从库,新的写命令。发送 repl buffer。
1.第一阶段
主要是主从库间建立连接、协商同步的过程,主要是为全量复制做准备。
- 从库给主库发送
psync
命令,表示要进行数据同步。psync 命令包含了主库的 runID 和复制进度 offset 两个参数:- runID:唯一标记Redis示例的ID,每个 Redis 实例启动时自动生成的随机ID。首次复制时,由于从库不知道主库的runID,所以是设置成?。
- offset:复制进度。第一次时设为 -1,表示第一次复制。
- 收到 psync 命令后,主库给从库发送
FULLRESYNC
响应命令。命令带上两个参数:主库 runID 和主库目前的复制进度 offset,返回给从库。从库收到响应后,会记录下这两个参数。
FULLRESYNC 响应表示第一次复制采用的全量复制,也就是说,主库会把当前所有的数据都复制给从库。
2.第二阶段
主库将所有数据同步给从库。从库收到数据后,在本地完成数据加载。该过程依赖于RDB文件。
- 主库发文件:主库执行 bgsave 命令,生成 RDB 文件,接着将文件发给从库。
- 从库清数据写文件:从库接收到 RDB 文件后,会先清空当前数据库,然后加载 RDB 文件。清空数据是因为,从库在通过 replicaof 命令开始和主库同步前,可能保存了其他数据。
- 主库缓存最新写命令:在数据同步期间,为了保持数据一致性,主库会在内存中用专门的
replication buffer
,记录 RDB 文件生成后收到的所有写操作。
3.第三个阶段
主库把第二阶段执行过程中新收到的写命令,再发送给从库。
在主库完成 RDB 文件发送后,就会把此时 replication buffer
中的修改操作发给从库,从库再重新执行这些操作。
至此,第一次主从数据同步完成。
3.主从级联:分担同步时主库全量复制压力
1.为什么要主从级联
主从库间第一次数据同步时,需要全量复制。
此时主库的耗时操作有:生成 RDB 文件和传输 RDB 文件。
如果从库数量很多时,会有两个问题:
- 阻塞主线程:此时主库一直在生成RDB。此时会有大量的fork子进程生成,会阻塞主线程。
- 阻塞网络:大量的 RDB 文件传输,会占满主库网络。
2.主从级联方案
解决方案:通过“主 - 从 - 从”模式将主库生成 RDB 和传输 RDB 的压力,以级联的方式分散到从库上。简单来说,就是从库可以和其他从库建立主从关系:
# 在从库上,依赖其他从库
replicaof 所选从库的IP 6379
示意图如下:
基于长连接的命令传播:至此主从全量复制完成。之后,主从间会一直通过第一次建立起来的连接,进行数据同步。
但是问题又出现了,连接断了怎么办?
4.主从库间网络断了怎么办?
Redis 2.8 之前:重新全量复制。
Redis 2.8 开始:增量复制,网络断连期间主库收到的命令。
repl_backlog_buffer
缓冲区:主从库断连后,主库此时收到的写操作命令,会写入 replication buffer,同时也会写入 repl_backlog_buffer 缓冲区。
1.repl_backlog_buffer缓冲区恢复过程
repl_backlog_buffer
构造:是一个环形区域。主库会记录自己写到的位置,从库则会记录自己已经读到的位置。
开始时,主从库位置一样。随着时间推移,主库会累计写偏移量 master_repl_offset
,从库会累计读偏移量 slave_repl_offset
。如下图所示:
master_repl_offset
是单调增加的,它的值可以大于repl_backlog_size
。
repl_backlog_idx
:Redis会用一个名为repl_backlog_idx
的值记录在环形缓冲区中的最新写位置。如果repl_backlog_idx
等于repl_backlog_size时,repl_backlog_idx
会被置为0,表示从环形缓冲区开始位置继续写入。
主从库连接恢复后:
- 从库首先会给主库发送 psync 命令,并把自己当前的
slave_repl_offset
发给主库。 - 主库判断自己的
master_repl_offset
和slave_repl_offset
之间的差距,将其间的命令操作同步给从库即可。
总体如下图:
2.repl_backlog_buffer缓冲区大小设置
数据可能被覆盖:由于是环形的,如果主库写太快、从库读太慢,或者主从断开时间太久,数据是有可能被覆盖的。
通过参数设置大小:可以调整 repl_backlog_size 这个参数来设置缓冲区大小。
大小计算方法:缓冲空间的计算公式是:缓冲空间大小 = 主库写入命令速度 * 操作大小 - 主从库间网络传输命令速度 * 操作大小
。为了保险起见,我们一般再 * 2。
例如:主库每秒写入 2000 个操作,每个操作的大小为 2KB。网络每秒能传输 1000 个操作。此时,有 1000 个操作需要缓冲起来,这就至少需要 2MB 的缓冲空间。最终把 repl_backlog_size 设为 4MB。
解决主库压力过大:如果主库的写压力还是太大,缓冲空间容不下。那么我们可以考虑切片集群,后续会讲。
环形缓冲区再大,从库长期断网时,数据也会被覆盖,此时进行全量复制:
- 从库发送心跳:从库正常情况下会每秒给主库发送一个replconf ack命令。
- 主库判断是否断网:主库会根据replconf ack命令的达到时间判断和从库的连网情况。如果距离最后一次ack命令收到的时间已经超过了
repl_timeout
时间,就会和从库断开连接了。 - 被覆盖就全量复制:从库再和主库连接时,会发送自己的复制进度,如果要复制内容在缓冲区中已经被覆盖了,那么就不再做增量复制了,而是进行全量复制。
小结
Redis主从同步的三种模式:
- 全量复制。
- 基于长连接的命令传播。
- 增量复制。
全量复制比较耗时,建议单个Redis实例不要太大,一般在在几 GB 级别比较合适,这样RDB消耗较小。
repl_backlog_buffer缓冲区用来解决主从复制的延迟问题,包括延迟断开时。
断开重连时,如果缓冲区数据已被覆盖,则进行全量复制。
本篇是讲了从库故障。那么主库故障了从库该怎么办,数据还能保持一致吗,Redis 还能正常提供服务吗?后面两篇进行讲解。
提问
Redis的主从模式如何实现?读写分离。有三种模式,全量复制。基于长连接的命令传播。增量复制。
Redis的主从模式具体过程?首次建立连接,3个阶段。
从库太多,导致同步开销太大,阻塞主库,怎么办?主从级联
主从连接网络断了怎么办?如何恢复?具体恢复过程?repl_backlog_buffer缓冲区。
Redis设置多大合适?为什么?单个实例几GB,减小RDB开销。
主从库间的复制不使用 AOF 呢?RDB文件内容是经过压缩的二进制数据(不同数据类型数据做了针对性优化),文件很小。而AOF文件记录的是每一次写操作的命令,写操作越多文件会变得很大,其中还包括很多对同一个key的多次冗余操作。
7.哨兵机制:主库挂了,如何不间断服务?
主库故障了,从库怎么办?
按照Redis的主从模式是读写分离,此时读没问题,写一定需要主库,怎么办么?如下图:
主库挂了,我们其实我们需要考虑以下几个问题:
- 主库真的挂了吗?
- 该选择哪个从库作为主库?
- 怎么把新主库的相关信息通知给从库和客户端?
在 Redis 主从集群中,我们用哨兵机制来解决以上问题。下面讲哨兵机制。
1.哨兵机制的基本流程
哨兵:其实就是一个运行在特殊模式下的 Redis 进程,主从库实例运行的同时,它也在运行。
哨兵的主要职责就是三个任务:监控、选主(选择主库)和通知:
- 监控:给所有的主从库周期性地发送 PING 命令,如果没有在规定时间内响应,则判断为下线。从库下线无所谓,主库下线则需要开始自动切换主库的流程(此时涉及主观下线、客观下线)。
- 选主:从很多个从库里,按照一定的规则选择一个从库实例,把它作为新的主库。
- 通知:把新主库的连接信息发给其他从库,让它们执行 replicaof 命令,和新主库建立连接,并进行数据复制。同时,把新主库的连接信息通知给客户端,让它们把请求操作发到新主库上。
总体如下图:
上面三步中,通知比较简单。而监控和选主是涉及决策逻辑的:
- 监控:需要判断主库是否处于下线状态;
- 选主:需要决定选择哪个从库实例作为主库。
所以下面我们对这两个步骤进行讲解。
1.监控:主观下线和客观下线
主观下线:就是哨兵自己发现主库或从库对PING命令响应超时。
客观下线:哨兵集群中,N个哨兵都判断某个主库已下线。这个N数量可以自己设置,最好是超过哨兵数的一半。
客观下线解决什么问题:防止误判,因为网络阻塞、主库压力大等都可能导致PING响应超时。而且主从切换的代价是比较大的,选主和通知操作都很耗时。
客观下线,如下图所示:
2.选主:如何选定新主库?
过程分为两步,“筛选 + 打分”:
- 筛选:按照一定的筛选条件,把不符合条件的从库去掉。
- 打分:按照一定的规则,给剩下的从库逐个打分,将得分最高的从库选为新主库。
如下图所示:
下面具体来看。
1.筛选
剔除网络状况不好的从库:当前状态,已下线的肯定不行。过去状态,经常断开连接也不行。
具体逻辑:用down-after-milliseconds
配置项配置主从库断连的最大连接超时时间。如果发生断连的次数超过了 10 次,就说明这个从库网络状况不好,就剔除掉。
2.打分
按照以下三个规则依次打分,在某一个规则中得到最高分,就选它:
- 从库优先级:
slave-priority
配置项配置最优先的从库。 - 从库复制进度:根据repl_backlog_buffer中slave_repl_offset 最接近 master_repl_offset的从库。
- 从库 ID 号:选ID号最小的从库。
(1)第一轮:选优先级最高的从库
用户可以通过 slave-priority
配置项来配置优先级。比如两个从库,某个内存大,我们就手动把它优先级配高。
如果优先级都一样,则进入下一轮。
(2)第二轮:从库复制进度
选择和旧主库同步最接近的那个从库作为主库。
上篇中说道,主从复制时: repl_backlog_buffer
中,主库会用 master_repl_offset
记录最新写位置,从库会用 slave_repl_offset
记录最新读位置。
选择slave_repl_offset
最接近 master_repl_offset
的从库即可。实际的选主代码层面,sentinel是*直接比较从库的slave_repl_offset
*,来选择和主库最接近的从库。如下图所示:
如果还是一样,则进入下一轮。
(3)第三轮:从库ID号
每个实例都会有一个 ID。
在优先级和复制进度都相同的情况下,ID 号最小的从库得分最高,会被选为新主库。
至此,选主过程完成。
小结
哨兵完成了三大功能:
监控:监控主库运行状态,并判断主库是否客观下线;
- 主观下线
- 客观下线
选主:在主库客观下线后,选取新主库;
- 筛选:下线的、过去连接不稳定的
- 打分:优先级、主从复制进度、ID号
通知:选出新主库后,通知从库和客户端。
客观下线涉及到哨兵集群。那么有以下问题:
- 哨兵集群有实例挂了,影响“客观下线”的判断和选主吗
- 判断完“客观下线”后,由哪个实例执行主从切换?
以上问题下一篇解答。
提问
主库挂了怎么办?哨兵机制。看主库是不是真的挂了,挂了选哪个从库,新的主库怎么通知给其他从库和客户端。
哨兵用来干什么?监控、选主、通知。
哨兵机制的优缺点?和cluster比?PING命令只能检测主机正常,无法检测Redis正常。
哨兵如何监控主从库已下线?主观下线、客观下线…
哨兵监控如何选主?筛选,down-after-milliseconds配置项。打分,优先级/复制进度/ID号。
8.哨兵集群:哨兵挂了,主从库还能切换吗?
哨兵集群负责判断主库“客观下线”,以及负责主从切换。如果有哨兵实例在运行时发生了故障,主从库还能正常切换吗?
其实是可以的。还能完整执行监控、选主、通知功能。
配置哨兵:
- 在配置哨兵时,我们只需要用到下面的这个配置项,设置主库的 IP 和端口即可。
sentinel monitor <master-name> <ip> <redis-port> <quorum>
那么既然哨兵都不知道彼此的地址,哨兵是怎么组成集群的呢?
以下解答,哨兵的机制。
1.基于 pub/sub 机制的哨兵集群组成
1. pub/sub 机制
哨兵实例之间可以相互发现,要归功于 Redis 提供的 pub/sub 机制,也就是发布 / 订阅机制。
多个哨兵实例都在主库上做了发布和订阅操作后,它们之间就能知道彼此的 IP 地址和端口。
Redis 的 pub/sub 机制:Redis提供的发布/订阅机制。Redis 会以频道(即消息类别)的形式,对这些消息进行分类。应用之间,只有订阅了相同的频道,才能通过发布的消息进行信息交换。
2.哨兵如何获取其他哨兵地址
在主从集群中,哨兵通过“__sentinel__:hello”
频道来获取其他哨兵的地址,并进行相互通信。
举例,如下图:
如上图所示,过程如下:
- 获取地址:哨兵 1 把自己的 IP(172.16.19.3)和端口(26579)发布到
“__sentinel__:hello”
频道。其他哨兵进行类似操作,最后形成哨兵集群。 - 相互通信:集群建立完成后,哨兵之间可以通过网络连接来进行通信了,比如判断和协商主库有没有下线。
3.哨兵如何获取从库地址
获取从库地址:由哨兵向主库发送 INFO
命令来完成。
如下图所示:哨兵 2 给主库发送 INFO 命令,主库返回从库列表,之后哨兵和从库建立连接:
以上,哨兵通过 pub/sub 机制连接其他哨兵,通过INFO命令连接从库,此时可以顺利进行监控、选主。那么,通知还需通过什么方式通知客户端呢?
仍然是 pub/sub 机制。
2.基于 pub/sub 机制的客户端事件通知
场景:让客户端能够获取到哨兵集群在监控、选主、切换这个过程中发生的各种事件。
哨兵的本质:其实哨兵就是一个运行在特定模式下的 Redis 实例,只不过它并不服务请求操作,只是完成监控、选主和通知的任务。所以,哨兵也是可以提供 pub/sub 机制的。
哨兵实现通知功能:客户端从哨兵订阅消息即可实现哨兵的通知功能。哨兵提供的订阅频道很多,下图是主要的频道:
客户端获取哨兵地址:客户端可以读取哨兵的配置文件,来获得哨兵的地址和端口,并和哨兵建立网络连接。
客户端订阅举例:
# 订阅“所有实例进入客观下线状态”事件
SUBSCRIBE +odown
# 订阅所有事件
PSUBSCRIBE *
# 事件发生后客户端收到以下消息。比如主从切换完成:新主库的 IP 地址和端口信息就包含到消息里
switch-master <master name> <oldip> <oldport> <newip> <newport>
这样,客户端就可以监控到主从库切换过程中发生的各个重要事件。
至此,哨兵集群的监控、选主和通知三个任务就基本可以正常工作了。
我们还需要考虑一个问题:哨兵集群有多个实例,主库故障以后,怎么确定由哪个哨兵来进行实际的主从切换?
3.由哪个哨兵执行主从切换?
和“客观下线”类似,都是“投票仲裁”。
我们先来看,客观下线的投票过程。
1.客观下线投票
具体过程:
第一阶段,投票客观下线:
- 哨兵发送询问命令:某个哨兵自己判断主库“主观下线”后,先给自己投一票,然后向其他哨兵发送
is-master-down-by-addr
询问命令。 - 其他哨兵应答:其他哨兵收到命令后,会根据自己和主库的连接情况,做出 Y 或 N 的响应。Y赞成下线,N反之。
- 哨兵判断票数:判断是否获得了所需的赞成票数N,获得则标记主库为“客观下线”。所需的赞成票数是通过哨兵配置文件中的
quorum
配置项设定的。注意,哨兵自己本身也算一票。
如下图所示:
如果“客观下线”投票通过,则进入下一阶段,Leader选举,即决定由谁来执行主从切换。
第二阶段,投票Leader选举:
- 哨兵发送询问命令:先给自己投一票。然后给其他哨兵发送命令,表明希望由自己来执行主从切换,并让所有其他哨兵进行投票。
- 判断投票是否通过:需要同时满足两个条件:
- 拿到半数以上的赞成票;
- 票数还需大于等于 quorum 值(哨兵配置文件中配置)。
- 如果没能成功选举:如果因为网络阻塞等,造成选举失败,即没有任何一个哨兵拿到需要的票数。那么,哨兵集群会等待一段时间(哨兵故障转移超时时间的 2 倍),再重新选举。成功选举后,Leader执行主从切换。
某个哨兵投票的判断逻辑:
- 如果已经给自己投票,那么其他所有哨兵的请求都反对。
- 如果没有给自己投票,那么支持第一个询问自己的哨兵。
既然是谁先到选谁,那么选举时,如何避免大家都同时投票给自己造成选举失败:
- 同时给自己投票,需要所有哨兵基本同时判定主库客观下线:由于不同哨兵的网络连接、系统压力不完全一样,接收到下线协商消息的时间也可能不同,所以,它们同时做出主库客观下线判定的概率较小,一般都有个先后关系。
- 哨兵对主从库进行的在线状态检查等操作是属于一种时间事件,用一个定时器来完成,一般来说每100ms执行一次这些事件。每个哨兵的定时器执行周期都会加上一个小小的随机时间偏移,目的是让每个哨兵执行上述操作的时间能稍微错开些,也是为了避免它们都同时判定主库下线,同时选举Leader。
如果哨兵集群只有 2 个实例,此时,一个哨兵要想成为 Leader,必须获得 2 票,而不是 1 票。挂掉一个哨兵就玩完,所以我们一般哨兵至少会配置3个。
举例,是选举Leader过程,如下图:
小结
支持哨兵集群的这些关键机制,包括:
- 基于 pub/sub 机制的哨兵集群组成过程;
- 基于 INFO 命令的从库列表,这可以帮助哨兵和从库建立连接;
- 基于哨兵自身的 pub/sub 功能,这实现了客户端和哨兵之间的事件通知。
主从切换分两步:判断“客观下线”投票。选举Leader投票。都是先投自己一票。
注意一点:要保证所有哨兵实例的配置是一致的,尤其是主观下线的判断值 down-after-milliseconds。不然就会有“坑”。当时,在项目中,如果这个值在不同的哨兵上配置不一致,会导致哨兵集群一直无法对主库是否有故障形成共识,也就没有及时切换主库,最终的结果就是集群服务不稳定。
提问
怎么配置哨兵?哨兵如何知道其他哨兵地址?哨兵如何知道从库地址?“__sentinel__:hello”
频道。INFO命令。
哨兵的消息怎么通知到客户端?
选主结束后,由哪个哨兵来执行主从切换?判断“客观下线”投票。选举Leader投票。都是先投自己一票。
一个 Redis 集群,是“一主四从”,同时配置了包含 5 个哨兵实例的集群,quorum 值设为 2。在运行过程中,如果有 3 个哨兵实例都发生故障了,此时,Redis 主库如果有故障,还能正确地判断主库“客观下线”吗?如果可以的话,还能进行主从库自动切换吗?此外,哨兵实例是不是越多越好呢,如果同时调大 down-after-milliseconds 值,对减少误判是不是也有好处呢?
9.切片集群:数据增多了,是该加内存还是加实例?
前面文章建议,每个Redis实例,大小在几G为宜。
因为实例太大时,在用RDB持久化时,Redis 会 fork 子进程来完成,fork 操作的用时和 Redis 的数据量是正相关的,而 fork 在执行时会阻塞主线程。而且AOF恢复时也会很慢,AOF文件还会很大。
fork耗时:可以用 INFO 命令查看 Redis 的 latest_fork_usec
指标值,表示最近一次 fork 的耗时。25G的数据,大概会阻塞到秒级。
所以,大数据量时,单纯的加大内存,不是好的解决方案。
好的解决方案是,切片集群,也叫分片集群。比如25G的数据,分成5片,每片就只有5G了,这样响应速度就变快。下面具体来看下。
1.如何保存更多数据?
两个思路,纵向扩展和横向扩展:
- 纵向扩展:升级单个 Redis 实例的资源配置。包括增加内存容量、增加磁盘容量、使用更高配置的 CPU等。
- 优点:操作简单,直接加配置。
- 缺点:1.阻塞主库主线程:RDB 对数据进行持久化时,数据量太大。fork时会造成长时等待。不过若数据不用持久化,那么就不用RDB。2.扩充有上限:收硬件的性能和成本限制。
- 横向扩展:切片集群,增加当前 Redis 实例的个数。
- 优点:性能好,不阻塞主库主线程。扩展上限很高。在面向百万、千万级别的用户规模时,是很好的选择。
- 缺点:引入了新的复杂性。
引入切片集群,需要解决以下问题:
- 数据怎么存:数据切片后,在多个实例之间如何分布?
- 数据哪里取:客户端怎么确定想要访问的数据在哪个实例上?
2.数据存哪里:数据切片和实例的对应分布关系
切片集群中,数据需要分布在不同实例上,那么,数据和实例之间如何对应?
切片集群是一种通用机制,可以有不同的实现方案:
- Redis 3.0 之前:没有官方方案。主要有,基于客户端分区的 ShardedJedis,基于代理的 Codis、Twemproxy 等。后面会讲。
- Redis 3.0 开始:官方引入了 Redis Cluster 方案。该方案中就规定了数据和实例的对应规则。
Redis Cluster具体方案如下:
哈希槽:Redis Cluster引入哈希槽(Hash Slot)的概念,相当于数据分区。一个切片集群共有 16384 个哈希槽,每个键值对会根据它的 key,被映射到一个哈希槽中。
数据如何映射到哈希槽:1.先计算hash值:根据键值对的 key,按照 CRC16 算法计算一个 16 bit 的值;2.根据hash值映射到哈希槽:这个 16bit 值对 16384 取模,得到 0~16383 范围内的模数,该模数就是对应哈希槽的编号。
哈希槽如何映射到 Redis 实例:
- 自动部署:部署 Redis Cluster 时,使用
cluster create
命令自动创建集群,此时,Redis 会自动把这些槽平均分布在集群实例上。假如有N个集群,那么每个实例分配 16384/N 个槽。 - 手动部署:1.使用
cluster meet
命令手动建立实例间的连接,形成集群。2.再使用cluster addslots
命令,指定每个实例上的哈希槽个数。此时可以根据各个实例的性能,灵活配置槽数。
注意,在手动分配哈希槽时,需要把 16384 个槽都分配完,否则 Redis 集群无法正常工作。
手动部署举例:
# 手动分配哈希槽:实例 1 保存哈希槽 0 和 1,实例 2 保存哈希槽 2 和 3,实例 3 保存哈希槽 4。
redis-cli -h 172.16.19.3 –p 6379 cluster addslots 0,1
redis-cli -h 172.16.19.4 –p 6379 cluster addslots 2,3
redis-cli -h 172.16.19.5 –p 6379 cluster addslots 4
分布如下图:
此时,就完成了“数据 -> 哈希槽 -> Redis实例”的分配,知道了数据存到哪里。
那么,客户端怎么知道数据到哪里取呢?下面讲解。
3.数据哪里取:客户端如何定位数据?
1.实例和客户端怎么获得哈希槽
实例同步其他实例哈希槽:Redis 实例会把自己的哈希槽发给与它相连接的其它实例,这样在实例都相互连接后,每个实例都会有所有哈希槽的映射关系。
实例把哈希槽发给客户端:集群实例和客户端建立连接后,实例就会把哈希槽的分配信息发给客户端。
客户端把哈希槽缓存到本地:收到哈希槽信息后,会把哈希槽信息缓存在本地。当客户端请求键值对时,会先在本地计算键所对应的哈希槽,然后就可以给相应的实例发送请求了。
2.哈希槽信息变更时怎么办
实例和哈希槽的对应关系是会变化的,最常见的情况:
- 集群增删实例:此时Redis 需要重新分配哈希槽;
- 重置哈希槽分布:为了负载均衡,Redis 需要把哈希槽在所有实例上重新分布一遍。
此时变更的最新哈希槽信息怎么同步呢?
- 实例之间:直接通过相互传递消息来同步。
- 客户端:Redis Cluster重定向机制。MOVED 命令
3.Redis Cluster 的重定向机制
重定向机制:客户端给实例发送数据读写操作,若该实例上没有对应的数据,客户端需要再给另一个新实例发送操作命令。
假设客户端访问的Slot 2的数据,正在由实例A向实例B迁移。具体过程:
客户端给实例A发送请求:客户端根据本地缓存的哈希槽,根据key计算后在slot2,根据本地缓存查到slot2在实例A。此时给实例A发送请求。
实例A接到访问Slot2的请求。此时:
如果Slot2数据已全部迁移至实例B:
实例A给客户端返回 MOVED 命令:该命令中就包含了新实例的访问地址。
# 实例A返回 MOVED 命令 # MOVED 命令表示,客户端请求的键值对所在的哈希槽 13320,实际是在 172.16.19.5 这个实例上。 GET hello:key (error) MOVED 13320 172.16.19.5:6379
客户端重新访问新实例B:客户端根据返回的MOVED命令信息,访问新实例。同时本地缓存进行更新。
如果Slot2数据只部分迁移到实例B:
实例A给客户端返回 ASK 报错:该命令中包含了数据实际所在实例的访问地址。
# 实例A返回 ASK 报错 # ASK 命令表示,客户端请求的键值对所在的哈希槽 13320,在 172.16.19.5 这个实例上,但是这个哈希槽正在迁移。 GET hello:key (error) ASK 13320 172.16.19.5:6379
客户端重新访问实际实例:
- 此时需要先向实例发送一个ASKING命令,再发送操作命令。ASKING命令的含义是让该实例允许执行客户端接下来发送的命令。
- 此时客户端本地的缓存不更新,下次访问时还是访问实例A。
整个过程,Slot2数据已全部迁移的情况,如下图所示:
Slot2数据只迁移了一部分的情况,如下图所示:
小结
Redis 切片集群,数据可以通过键的哈希值映射到哈希槽,再通过哈希槽分散保存到不同的实例上。哈希槽信息,实例间会相互同步,客户端会缓存到本地。
Slot信息更新时,实例间直接同步。客户端用MOVED命令重定向并更新内存,或者用ASK + ASKING命令访问实际数据所在实例,此时本地缓存不更新。
在 Redis 3.0 之前,Redis 官方并没有切片集群方案,但是,当时已经有了一些切片集群的方案,例如基于客户端分区的 ShardedJedis,基于代理的 Codis、Twemproxy 等。这些方案,在支撑的集群实例规模、集群稳定性、客户端友好性方面也都有着各自的优势,后续会讲。
提问
切片集群解决什么问题?数据量很大时,不能单纯的加大内存。fork时会阻塞主线程。
简单说说RedisCluster方案?数据存哪里?数据到哪里取?哈希槽,key->哈希槽,哈希槽->Redis实例…
slot数据在迁移时/迁移后,实例slot信息怎么更新?客户端信息怎么更新?直接更新。迁移完成,MOVED命令重定向并更新缓存。迁移到一半,ASK命令告诉实际实例地址,客户端先ASKING,再执行操作命令,本地缓存不更新。
Redis Cluster 方案通过哈希槽的方式把键值对分配到不同的实例上,这个过程需要对键值对的 key 做 CRC 计算,然后再和哈希槽做映射,这样做有什么好处吗?如果用一个表直接把键值对和实例的对应关系记录下来(例如键值对 1 在实例 2 上,键值对 2 在实例 1 上),这样就不用计算 key 和哈希槽的对应关系了,只用查表就行了,Redis 为什么不这么做呢?
10.第1~9讲思考题答案及常见问题答疑(暂略待补充)
实践篇:数据结构(11-15)
接下来5篇,主要讲,如何节省内存开销、保存和统计海量数据的数据类型、及其底层数据结构,以及这些数据结构的应用场景(例如地址位置查询、时间序列数据库读写和消息队列存取)。
11.“万金油”的String,为什么不好用 了?
本篇讲,String 类型的内存空间消耗,以及如何选择节省内存开销的数据类型。
String 类型比较通用,甚至可以保存二进制字节流。但是String的底层结构,内存开销比较大,保存数据时所消耗的内存空间较多。
业务场景:需要保存大量的key:value键值对,但是用String占用空间太大。最后改用二级编码的方法,实现了用集合类型保存单值键值对。
本篇主题:
- String 类型的内存空间消耗在哪
- 用什么数据结构可以节省内存
- 如何用集合类型保存单值键值对
1.为什么 String 类型内存开销大?
元数据:除了记录实际数据,String 类型还需要额外的内存空间记录数据长度、空间使用等信息,这些信息也叫作元数据。
元数据比实际数据本身大时,就很耗空间。
业务场景:一组图片 ID 及其存储对象 ID 的记录,都是 10 位数,我们可以用两个 8 字节的 Long 类型表示这两个 ID,实际只需要 16 字节就可以了。为什么会用掉 64 字节呢?我们来看下。
1.String保存数据的方式:SDS结构体额外开销
String保存数据有以下方式:
- int 编码方式:如果保存的数据是64位有符号整数,直接保存为一个8字节的Long类型整数。
- SDS 方式:如果保存的数据含字符串,则用SDS方式,即Simple Dynamic String,简单动态字符串结构体。
SDS方式结构如下图所示:
- buf:字节数据,保存实际数据。数组最后有1个“\0”,表示字节数组结束。多占用1个字节。
- len:占 4 个字节,表示 buf 的已用长度。
- alloc:也占个 4 字节,表示 buf 的实际分配长度,一般大于 len。
所以,SDS额外占用了9个字节。
2.RedisObject 结构体开销
RedisObject 结构体:Redis 的数据类型有很多,而且,不同数据类型都有些相同的元数据要记录(比如最后一次访问的时间、被引用的次数等),所以,Redis 会用一个 RedisObject 结构体来统一记录这些元数据,同时指向实际数据。
RedisObject 结构体示意:包含了 8 字节的元数据和一个 8 字节指针,这个指针再进一步指向具体数据类型的实际数据所在,例如指向 String 类型的 SDS 结构所在的内存地址。
如下图所示:
RedisObject 结构体优化:
- 直接赋值:保存的数据类型是Long型时,直接把值赋值到指针ptr上,节省8B的空间。
- embstr 编码方式:保存的数据类型是字符串且小于44字节时,元数据、指针和 SDS 一起存放,是一块连续的内存区域,避免内存碎片。
- raw 编码方式:字符串大于 44 字节时,SDS 和 RedisObject 分开存放。
具体三种方式示意图如下:
3.全局哈希表额外开销
全局哈希表 dictEntry
的结构体:Redis 会使用一个全局哈希表保存所有键值对,哈希表的每一项是一个 dictEntry 的结构体,用来指向一个键值对。dictEntry 结构中有三个 8 字节的指针,分别指向 key、value 以及下一个 dictEntry,三个指针共 24 字节。如下图所示:
4.额外开销汇总计算
现在我们来计算下额外开销:
- SDS结构体:图片ID为Key,存储对象ID为Value,都是Long型,直接两个RedisObject直接存到原指针位置上,不用SDS结构体,没有额外开销。原始数据共(数据8字节)* 2 = 16字节。
- RedisObject 结构体:原始数据存到它指针部分,元数据部分多8字节。共(元数据8字节)* 2 = 16字节。
- dictEntry结构体:全局哈希表,dictEntry结构体,整个键值对多占用24字节。
- jemalloc 分配库:Redis 使用的内存分配库 jemalloc,按2的幂次数分配。前述总共占用 56 字节,总共分配64字节。
综上,一个“图片ID:存储对象ID”键值对,占用64字节。
2.用什么数据结构可以节省内存?
1.集合可以节省dictEntry内存消耗
用集合可以大幅节省内存:当用 String 类型时,一个键值对就有一个 dictEntry,要用 32 字节空间。但采用集合类型时,一个 key 就对应一个集合的数据,能保存的数据多了很多,但也只用了一个 dictEntry,
所以,我们用集合代替String来存数据,可以节省大量内存。
2.用压缩列表更节省内存
这个讲的是value位置,集合底层的实现方式。List、Hash、SortedSet集合,底层在一定数据规模内,都是用压缩列表实现。
我们来看下压缩列表为什么省空间,它的结构示意图如下:
构成解析:
- zlbytes:列表长度
- zltail:列表尾的偏移量
- zllen:entry 个数
- entry:数据
- prev_len:前一个 entry 的长度。取值方式有1或5字节两种。1字节取值方式时,表示前一个entry少于254字节(因为zlend默认值为255)。否则按5字节方式取值。
- encoding:编码方式,1字节。
- len:自己的长度,4 字节。
- content:实际数据
- zlend:列表结束标志
由于entry是连续放置,无需指针,因此节省了指针空间。
例如,保存图片存储对象 ID (8字节),prev_len 1字节,encoding 1字节,len 4字节,自己 8 字节,总共14字节,实际分配16字节。前面String的int编码方式,则需要64字节。
3.压缩列表和哈希表的取舍
阈值决定什么时候转换:以 Redis Hash 集合为例,它底层用压缩列表或是哈希表来实现。当保存的数据量在阈值内,则用压缩列表实现。在阈值外,则由哈希表实现。两个阈值如下:
- hash-max-ziplist-entries:表示用压缩列表保存时哈希集合中的最大元素个数。
- hash-max-ziplist-value:表示用压缩列表保存时哈希集合中单个元素的最大长度。
以上两个阈值,有一个超过时,压缩列表就会转换为哈希表,且不会再转换回来。
压缩列表和哈希表的取舍:压缩列表和哈希表,前者省内存,后者查询快。优缺点正好反过来,所以我们要确定一个合适的阈值,来决定数据量多大的时候完成转换。
3.如何用集合类型保存单值的键值对?
前文提到,为了省内存,我们把String类型用集合类型代替。我们来看,原本String类型的键值对,怎么转换为集合存储。
二级编码方法:把原本的key拆为两部分,分别作为新的key,以及对应Hash集合的键即可。以图片 ID 1101000060 和图片存储对象 ID 3302000080 为例,我们可以把图片 ID 的前 7 位(1101000)作为 Hash 类型的键,把图片 ID 的最后 3 位(060)和图片存储对象 ID 分别作为 Hash 类型值中的 key 和 value。
原来,新增一条数据需要64字节,此时,由于无需新增dictEntry,也无需新增 key 和 value 的RedisObject,只需要新增压缩列表上面的数据16字节即可。
用二级编码key长度限制存储结构为压缩列表:如前文所示,为了节省空间,防止压缩列表变为哈希表,我们设定hash-max-ziplist-entries
阈值为1000,并在二级编码时把 Hash 集合的key设置为3位,控制在1000以内,这样就大量节省了空间。
小结
提问
一条String记录是怎么存的?占多少字节?为什么它很费内存?集合类型占多少字节呢?详细分析。SDS多0-9字节(int或SDS),RedisObject多8-16字节(元数据+直接赋值/指针),dictEntry多24-32字节(key,value,next共24字节,总共分配满64字节)。String类型,一个键值对就有一个 dictEntry,多占用24+8 = 32字节的空间。集合类型(list, hash, set, sortedset),一个key可以对应很多条记录。
12.有一亿个keys要统计,应该用哪种集合
业务场景:一个key对应很大的数据量,且对这些数据量有统计要求,用什么集合来存储比较好?
- 聚合统计:“一天:一系列用户ID”,统计每天的新增用户数和第二天的留存用户数;
- 排序统计:“商品ID:一系列评论”,统计最新的评论
- 二值状态统计:“一天:一系列用户签到记录”,统计一个月内连续打卡的用户数;
- 基数统计:“一个网页:一系列访问点击”,统计独立访客(Unique Visitor,UV)量。
通常数据量是亿级,所以我们要根据业务需求,选择合适的集合。
1.聚合统计:Set
聚合统计:多个集合元素的聚合结果,比如求它们的交集、差集、并集。
需求是,求每天的新增用户数和第二天的留存用户数。解法:
- 记录每天登录过的ID即可。用A集合记录所有登录过的ID,B集合记录当天登录过的ID。
- 新增用户数:A和B的差集。
- 留存用户数:A和B的交集。
使用Set集合即可:
- A集合,所有登录记录:“user:id”:“[用户ID的Set]”
- B集合:每日登录记录:“user:id:当天日期时间戳”:“[用户ID的Set]”
聚合计算适合用 Set 集合类型:当你需要对多个集合进行聚合计算时,Set 类型会是一个非常不错的选择。
注意:Set 的差集、并集和交集的计算复杂度较高,在数据量较大的情况下,直接执行计算会导致 Redis 实例阻塞。最好是单独用一个从库做聚合计算,或者读到客户端进行计算。
2.排序统计:List、SortedSet
排序统计,需要有序集合来存数据。
有序集合:在 Redis 常用的 4 个集合类型中(List、Hash、Set、Sorted Set),List 和 Sorted Set 属于有序集合。
- List:按元素进入顺序排序。
- SortedSet:按元素权重排序。
1.List
操作方法:用LPUSH直接插到队头就行。
缺点:对分页不友好,插入时会使属性改名。例如{A, B, C, D, E}。
# 假设每页展示2条记录。
# 看第二页的数据
LRANGE product1 2 3 # 显示 C D
# 此时如果刚好又插入一个F到队头,那么顺序就会改变。显示会变为 B C
# 用 LRANGE 读取时,可能会读到旧元素
2.Sorted Set
操作方法:按评论时间的先后给每条评论设置一个权重值,然后再把评论保存到 Sorted Set 中。
获取时,使用 ZRANGEBYSCORE
命令,它是根据元素的实际权重来排序和获取数据的。
获得最新的 10 条评论:
ZRANGEBYSCORE comments N-9 N
展示最新列表、排行榜等场景,如果数据更新频繁或者需要分页显示,可以优先考虑使用 Sorted Set
。
3.二值状态统计:Bitmap
二值状态统计:集合元素的取值就只有 0 和 1 两种。比如,打卡状态。
此时每个数据用1个bit位存储就足够,非常省内存。
Bitmap:Redis 提供的扩展数据类型。底层的数据结构是String类型,String类型保存为二进制的字节数组。底层实际上就是一个 bit 数组。
Bitmap操作:
GETBIT
、SETBIT
操作:用来赋值取值,使用偏移值offset来指标操作哪一位,由0开始。BITCOUNT
操作:用来统计这个 bit 数组中所有“1”的个数。BITOP
操作:对多个 Bitmap 按位做“与”“或”“异或”操作,操作结果会保存到一个新的 Bitmap 中。
应用示例:
# 记录用户 8 月 3 号已签到
SETBIT uid:sign:3000:202008 2 1
# 检查用户 8 月 3 日是否签到
GETBIT uid:sign:3000:202008 2
# 统计用户在 8 月份的签到次数
BITCOUNT uid:sign:3000:202008
# 统计 1 亿个用户连续 10 天的签到情况
# 1.对 10 个 Bitmap 做“与”操作,得到一个新的Bitmap,能表示1 亿个用户是否连续10天签到的情况。
# 2.对新的Bitmap使用BITCOUNT,即可统计 1 亿个用户连续 10 天的签到情况
# 内存开销计算:每天1 个 1 亿位的 Bitmap,大约占 12MB 的内存(10^8/8/1024/1024)。10天为120MB,开销不大。
统计数据的二值状态,特别是海量数据时,适合用Bitmap。
4.基数统计:Set、Hash、HyperLogLog
基数统计:统计一个集合中不重复的元素个数。
例如,统计网页点击数,需要去重,一个用户一天内的多次访问只能算作一次。
1.Set
Set天然支持去重。
# 添加用户访问记录
SADD page1:uv user1
# 统计该页面访问次数
SCARD page1:uv
缺点是,太费内存。假如这个页面是热点,那么set集合就要保存巨量的用户ID。而这仅仅是1个页面的访问数据。
2.Hash
Hash和Set类似:
# 添加用户访问记录
HSET page1:uv user1 1
# 统计该页面访问次数
HLEN page1:uv
缺点和Set一样的,数据量大时,太费内存。
3.HyperLogLog
HyperLogLog:用于统计基数的数据集合类型。它的最大优势就在于,它计算基数所需的空间总是固定的,和数据量无关。
在 Redis 中,每个 HyperLogLog 只需要花费 12 KB 内存,就可以计算接近 2^64 个元素的基数。
# 添加用户访问记录,5条
PFADD page1:uv user1 user2 user3 user4 user5
# 统计该页面访问次数
PFCOUNT page1:uv
注意:HyperLogLog 的统计规则是基于概率完成的,所以它给出的统计结果是有一定误差的,标准误算率是 0.81%。
实现原理:了解即可。内部维护了 16384 个桶(bucket)来记录各自桶的元素数量。当一个元素到来时,通过 hash 算法将这个元素分派到其中的一个小集合存储,以一定的概率影响这个桶的计数值。这样总的计数就是所有小集合大小的总和。然后,对每个桶又进行了处理,并不直接存储元素值,而是记录的是桶中元素数量的对数值。最后再根据统计学原理,反推出整体数量。
小结
各个集合适用的业务场景都放到了标题了。总体总结如下:
整合如下:
- 聚合统计:集合运算。Set、SortedSet、Bitmap
- 排序统计:排序运算。SortedSet、List
- 二值状态统计:值只有0或1。Bitmap
- 基数统计:去重后统计。Set、SortedSet、Bitmap、HyperLogLog
提问
各个统计计算用什么集合合适?聚合计算,Set。
13.GEO是什么?还可以定义新的数据类型吗?
GEO用于存储计算地理位置经纬度信息。
Redis 的 5 大基本数据类型,已经基本满足大多数的数据存储需求:
- String
- List
- Hash
- Set
- Sorted Set
面对海量数据统计时,为了节省内存,Redis提供了额外的三种扩展类型:
- Bitmap
- HyperLogLog
- GEO
其他都已讲过,本篇我们来看GEO。另外介绍开发自定义的新数据类型的基本步骤,以满足一些特别的需求。
接下来,先来看 GEO 的实现原理和使用方法。
GEO主要用来处理地理经纬度信息。
1.GEO 的底层结构
在设计一个数据类型的底层结构时,我们首先需要知道,要处理的数据有什么访问特点。
业务场景:打车场景:
- 每一辆网约车都有一个编号(例如 33),网约车需要将自己的经度信息(例如 116.034579)和纬度信息(例如 39.000452 )发给叫车应用。
- 用户在叫车的时候,叫车应用会根据用户的经纬度位置(例如经度 116.054579,纬度 39.030452),查找用户的附近车辆,并进行匹配。
- 等把位置相近的用户和车辆匹配上以后,叫车应用就会根据车辆的编号,获取车辆的信息,并返回给用户。
那么,怎么存经纬度信息呢?
1.Hash存经纬度
可以用Hash存,用HSET命令快速更新位置。如下图所示:
但是问题是,我们是需要计算用户和车的经纬度距离的,也就是说,value要有序。很显然,Hash不满足。
2.SortedSet存经纬度
有序,自然考虑到用SortedSet存,经纬度可以根据权重排序。实际上,GEO 类型的底层数据结构就是用 Sorted Set 来实现的。
SortedSet如下图所示:
那么问题又来了,Sorted Set 元素的权重分数是一个浮点数(float 类型),而Value是一组经纬度,如何映射权重呢?
这就要用到 GEO 类型中的 GeoHash 编码了。
2.GeoHash 的编码方法
GeoHash 编码方法:让经纬度可以比较。基本原理就是“二分区间,区间编码”
编码过程:假设最终映射为10位bit。那么我们把经度映射为5个bit,纬度映射为5个bit,最后经纬度交替拉链式合并,就得到了映射的10个bit。每一位就代码一个经纬度小方格。
具体映射:
- 经度编码:假如经度范围是[-180, 180]。那么第1次分区为[-180,0) 和 [0,180]。落到左边则第一个bit位为0,右边则为1。如此二分5次,即可得到5bit位的经度编码。
- 纬度编码:用同样的方式,二分纬度[-90,90],得到5bit位的纬度编码。
- 合并编码:经度和纬度编码拉链式合并,集合得到最终编码。
如下图所示:
这样,就可以用映射完的值来表示权重了。该编码的每个数字,就对应到了地图上的一个小方格,值相差小的,自然离得近。
注意一点,有些编码虽然差值小,但是离得远如下图 0111 和 1000 的两个方格所示:
为了避免查询不准确问题,我们可以同时查询给定经纬度所在的方格周围的 4 个或 8 个方格。
3.如何操作 GEO 类型?
常用两个命令,分别是 GEOADD 和 GEORADIUS:
GEOADD
命令:把一组经纬度信息和相对应的一个 ID 记录到 GEO 类型集合中;GEORADIUS
命令:根据输入的经纬度位置,查找以这个经纬度为中心的一定范围内的其他元素。可以自己定义这个范围。
使用示例:
# 将车辆经纬度加到GEO集合,集合 key 是 cars:locations。假设车辆 ID 是 33,经纬度位置是(116.034579,39.030452)
GEOADD cars:locations 116.034579 39.030452 33
# 用户寻找自己附近的网约车。假设用户的经纬度信息(116.054579,39.030452 )
# 查找以这个经纬度为中心的 5 公里内的车辆信息
# ASC 选项:正序排列
# COUNT 选项:指定返回数量,避免返回太多
GEORADIUS cars:locations 116.054579 39.030452 5 km ASC COUNT 10
有些场景下,我们对数据类型会有特殊需求,例如,我们需要一个数据类型既能像 Hash 那样支持快速的单键查询,又能像 Sorted Set 那样支持范围查询。此时,我们就需要自定义数据类型。
4.如何自定义数据类型?
首先,我们需要了解 Redis 的基本对象结构 RedisObject,因为 Redis 键值对中的每一个值都是用 RedisObject 保存的。
1.Redis 的基本对象结构
RedisObject
的内部结构包括了 type、encoding,、lru 和 refcount 4 个元数据,以及 1 个*ptr指针:
- type:值的类型。涵盖了我们前面学习的五大基本类型,也可加入自定义类型;
- encoding:值的编码方式。实现各个基本类型的底层数据结构,例如 SDS、压缩列表、哈希表、跳表等;
- lru:记录了这个对象最后一次被访问时间,用于淘汰过期的键值对;
- refcount:记录了对象的引用计数;
- *ptr:指向数据的指针。
RedisObject 结构借助*ptr指针,就可以指向不同的数据类型。例如,*ptr指向一个 SDS 就是String类型,指向一个跳表,就是Sorted Set 类型。
所以,我们在定义新的数据类型后,只需在 RedisObject 中设置好新类型的 type 和 encoding,再用*ptr指向新类型的实现即可。
2.开发一个新的数据类型
由前可以,开发一个新的数据类型只需执行4个步骤即可:
- 定义新的类型,及其结构。
- 在RedisObject中增加新类型的 type、encoding定义。
- 实现创建、释放函数。
- 实现基本命令。
下面以开发一个名字叫作 NewTypeObject
的新数据类型为例来说明。
1.定义新数据类型:底层结构
用 newtype.h
文件来保存这个新类型的定义:
// 新类型定义
struct NewTypeObject {
struct NewTypeNode *head;
size_t len;
}NewTypeObject;
// 新类型的底层结构,一个long类型的单向链表。也可以用其他结构,例如B+树。
struct NewTypeNode {
long value;
struct NewTypeNode *next;
};
2.定义加入到RedisObject定义:加到 RedisObject 的 type 属性中
加到 Redis 的 server.h 文件即可。
#define OBJ_STRING 0 /* String object. */
#define OBJ_LIST 1 /* List object. */
#define OBJ_SET 2 /* Set object. */
#define OBJ_ZSET 3 /* Sorted set object. */
…
#define OBJ_NEWTYPE 7 // 新定义的类型
3.实现新数据类型:创建、释放函数
创建和释放函数的定义在 object.c 文件,往该文件增加即可。
// 增加 NewTypeObject 的创建函数 createNewTypeObject
robj *createNewTypeObject(void){
NewTypeObject *h = newtypeNew(); // newtypeNew()定义在后续文件中
// createObject(...)是 Redis本身提供的 RedisObject 创建函数。参数是数据类型的 type 和指向数据类型实现的指针*ptr。
robj *o = createObject(OBJ_NEWTYPE,h);
return o;
}
newtypeNew(…)函数:Redis 默认会为每个数据类型定义一个单独文件,实现这个类型的创建和命令操作,例如,t_string.c 和 t_list.c 分别对应 String 和 List 类型。按照 Redis 的惯例,我们就把 newtypeNew 函数定义在名为 t_newtype.c 的文件中:
// newtypeNew 函数:新数据类型初始化内存结构。主要是用 zmalloc 做底层结构分配空间,以便写入数据。
NewTypeObject *newtypeNew(void){
NewTypeObject *n = zmalloc(sizeof(*n));
n->head = NULL;
n->len = 0;
return n;
}
createObject(…)函数:
robj *createObject(int type, void *ptr) {
robj *o = zmalloc(sizeof(*o));
o->type = type;
o->ptr = ptr;
...
return o;
}
对于释放函数来说,它是创建函数的反过程,是用 zfree 命令把新结构的内存空间释放掉。
4.实现新数据类型:命令操作
1.在 t_newtype.c 文件中增加命令操作的实现。
// 定义 ntinsertCommand 函数,由它实现对 NewTypeObject 单向链表的插入操作:
void ntinsertCommand(client *c){
//基于客户端传递的参数,实现在NewTypeObject链表头插入元素
}
2.在 server.h 文件中,声明我们已经实现的命令,以便在 server.c 文件引用该命令
void ntinsertCommand(client *c)
3.在 server.c 文件中的 redisCommandTable 里面,把新增命令和实现函数关联起来。
struct redisCommand redisCommandTable[] = {
...
{"ntinsert",ntinsertCommand,2,"m",...}
}
至此,就完成了一个自定义的 NewTypeObject 数据类型,可以实现基本的命令操作了。
如果需要新的数据类型能被持久化保存,则还需要在 Redis 的 RDB 和 AOF 模块中增加对新数据类型进行持久化保存的代码。暂略,后续再说。
小结
提问
打车场景,需要计算两个位置是否相近,经纬度怎么存?
GEO原理?
如何自定义数据类型?
14.如何在Redis中保存时间序列数据?
RedisTimeSeries 是 Redis 的一个扩展模块,不是Redis原生的。用于进行时间序列数据的复杂聚合计算。
业务场景1:存储海量的用户访问网站行为。
UserID, OperaType, TimeStamp
业务场景2:存储海量的设备状态监控记录。
DeviceID, Pressure, Temperature, Humidity, TimeStamp
数据特点:没有严格的关系模型,记录的信息可以表示成键和值的关系。
针对以上特点,由于没有严格的关系模型,可以不用传统DB存储。
Redis基于自身数据结构以及扩展模块,提供了两种解决方案。这就是本篇的主题,以存储海量设备状态为例来说明。
下面先来看数据特点。
1.时间序列数据的读写特点
1.写特点
如下:
- 持续的高并发写入。
- 主要操作是写入新数据,而不是更新现有数据。也就是说,一个时间序列数据被记录后一般就不会变了。
所以,最核心的要求就是,选择的数据类型,插入要快,复杂度要低,尽量不要阻塞。
想到了 Hash 和 String,插入复杂度都是O(1)。String保存小数据时占用空间太大。
2.读特点
如下,查询模式很多:
- 有单条记录查询:查询某设备某时间的状态。
- 有范围记录查询:每天8点到10点设备状态。
- 有复杂的查询:各种聚合计算,均值、最大、最小等。
3.选型
写要快:Redis本身就是内存数据库,不用考虑太多。
查询复杂:要支持单点查询、范围查询和聚合计算。Redis提供了保存时间序列数据的两种方案:
- 基于 Hash 和 Sorted Set 实现
- 基于 RedisTimeSeries 模块实现
下面分别讲解。
2.基于 Hash 和 Sorted Set 保存时间序列数据
优点:Hash 和 Sorted Set 是 Redis 内在的数据类型,代码成熟和性能稳定。
1.为什么要用两种集合来存
一条记录,分别作为两份存进Hash和SortedSet。分析如下:
Hash集合:单键查询快,但是不支持范围查询。可以把时间戳作为 key,记录的设备状态值作为 value。用 HGET 命令或 HMGET 命令即可单键或批量查询。
HGET device:temperature 202008030905 "25.1" HMGET device:temperature 202008030905 202008030907 1) "25.1" 2) "25.9"
Sorted Set :支持范围查询。以时间戳作为score,数据记录作为元素即可。
# 范围查询示例 ZRANGEBYSCORE device:temperature 202008030907 202008030910 1) "25.9" 2) "24.9"
2.如何保证两个集合存入原子性
新的问题就是,如何保证写入 Hash 和 Sorted Set 是一个原子性的操作。
Redis实现简单事务:MULTI 和 EXEC 命令。后续会详细讲,现在只需要知道怎么用。
- MULTI 命令:表示一系列原子性操作的开始。收到命令先存起来,放到内部命令队列。
- EXEC 命令:表示一系列原子性操作的结束。内部命令队列的命令,一起提交执行。
示意图如下:
操作示例:
# 把设备在 2020 年 8 月 3 日 9 时 5 分的温度,分别用 HSET 命令和 ZADD 命令写入 Hash 集合和 Sorted Set 集合
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> HSET device:temperature 202008030911 26.8
QUEUED # 表示命令暂时存入命令队列
127.0.0.1:6379> ZADD device:temperature 202008030911 26.8
QUEUED
127.0.0.1:6379> EXEC
1) (integer) 1
2) (integer) 1
3.如何进行聚合计算
聚合计算一般被用来周期性地统计数据,会频繁执行。
取回客户端计算,占用大量网络资源:由于Sorted Set 只支持范围查询,无法直接进行聚合计算,只能取回客户端计算。此时会大量占用网络资源。
使用 RedisTimeSeries,直接在Redis实例上计算:RedisTimeSeries 支持直接在 Redis 实例上进行聚合计算。为了避免客户端和 Redis 实例间频繁的大量数据传输,我们可以使用 RedisTimeSeries 来保存时间序列数据。
综上,Hash 和 Sorted Set 的组合,特点是性能好,稳定性高。但当涉及大量的聚合计算时,可以使用RedisTimeSeries。
3.基于 RedisTimeSeries 模块保存时间序列数据
RedisTimeSeries 是 Redis 的一个扩展模块,支持在 Redis 实例上直接对数据进行按时间范围的聚合计算。
需要编译成dll库后加载:
# 因为 RedisTimeSeries 不属于 Redis 的内建功能模块,在使用时,我们需要先把它的源码单独编译成动态链接库 redistimeseries.so,再使用 loadmodule 命令进行加载
loadmodule redistimeseries.so
用于时间序列数据存取时,RedisTimeSeries 的操作主要有 5 个:
- TS.CREATE:创建时间序列数据集合;
- TS.ADD:插入数据;
- TS.GET:读取最新数据;
- TS.MGET:按标签过滤查询数据集合;
- TS.RANGE:支持聚合计算的范围查询。
下面一一讲解。
1.TS.CREATE 创建时间序列数据集合
需要设置集合的 key 和数据过期时间,还可以为数据集合设置标签,来表示数据集合的属性。
使用示例:
# 创建一个 key 为 device:temperature、数据有效期为 600s 的时间序列数据集合。
# 设置了一个标签属性{device_id:1},表明这个数据集合中记录的是属于设备 ID 号为 1 的数据。
TS.CREATE device:temperature RETENTION 600000 LABELS device_id 1
OK
2.TS.ADD 插入数据,TS.GET 读取最新数据
使用示例:
# 往 device:temperature 集合中插入了一条数据,记录设备在 2020 年 8 月 3 日 9 时 5 分的设备温度
TS.ADD device:temperature 1596416700 25.1
1596416700
# 把刚才插入的数据读出来。读的是最新的数据。
TS.GET device:temperature
25.1
3.TS.MGET 按标签过滤查询数据集合
在保存多个设备的时间序列数据时,我们通常会把不同设备的数据保存到不同集合中。
集合都带了标签,表明它是哪个设备的数据。我们可以通过标签,来选择读取哪些集合。
使用 FILTER 配置项设置过滤条件。
使用示例:
# 使用 TS.MGET 命令,以及 FILTER 设置(该配置项用来设置集合标签的过滤条件)
# 查询 device_id 不等于 2 的所有其他设备的数据集合,并返回各自集合中的最新的一条数据。
TS.MGET FILTER device_id!=2
1) 1) "device:temperature:1"
2) (empty list or set)
3) 1) (integer) 1596417000
2) "25.3"
2) 1) "device:temperature:3"
2) (empty list or set)
3) 1) (integer) 1596417000
2) "29.5"
3) 1) "device:temperature:4"
2) (empty list or set)
3) 1) (integer) 1596417000
2) "30.1"
4.TS.RANGE 进行需要聚合计算的范围查询
用 AGGREGATION 参数来指定要执行的聚合计算类型,包括求均值(avg)、求最大 / 最小值(max/min),求和(sum)等。
使用示例:
# 按照每 180s 的时间窗口,对 2020 年 8 月 3 日 9 时 5 分和 2020 年 8 月 3 日 9 时 12 分这段时间内的数据进行均值计算。
TS.RANGE device:temperature 1596416700 1596417120 AGGREGATION avg 180000
1) 1) (integer) 1596416700
2) "25.6"
2) 1) (integer) 1596416880
2) "25.8"
3) 1) (integer) 1596417060
2) "26.1"
RedisTimeSeries 支持在 Redis 实例上直接进行聚合计算,以及按标签属性过滤查询数据集合。在需要进行大量的时间序列数据聚合计算时,它的优势比较突出。
小结
本篇主要针对Redis保存时间序列。
时间序列数据的写入特点是要能快速写入,而查询的特点有三个:
- 点查询,根据一个时间戳,查询相应时间的数据;
- 范围查询,查询起始和截止时间戳范围内的数据;
- 聚合计算,针对起始和截止时间戳范围内的所有数据进行计算,例如求最大 / 最小值,求均值等。
Redis有两个方案来处理时间序列数据。
- 使用 Redis 内置的 Hash 和 Sorted Set 类型:稳定性能高,但是进行聚合计算时,本身不支持,需要取回客户端计算。会占用大量网络资源。
- 使用 RedisTimeSeries 扩展模块:专门为存取时间序列数据而设计的扩展模块。需要编译为dll后加载。注意到,RedisTimeSeries 的底层数据结构使用了链表,它的范围查询的复杂度是 O(N) 级别的,同时,它的 TS.GET 查询只能返回最新的数据,没有办法像第一种方案的 Hash 类型一样,可以返回任一时间点的数据。
总体来说,带宽高内存大,选第一种。带宽小内存小且聚合计算频繁,可以考虑第二种。
提问
存储大量的时间序列数据,Redis怎么存?写特点、读特点分析。选型两种方案讲解,Hash+SortedSet,RedisTimSeries。
15.消息队列的考验:Redis有哪些解决方案?
Redis 适合做消息队列吗?这里包含两个核心问题:
- 消息队列的消息存取需求是什么?
- Redis 如何实现消息队列的需求?
本篇来说一下MQ的特征,以及Redis提供的MQ方案。
1.MQ的消息存取需求
MQ模型如下图所示:
MQ 的核心需求有三个:
- 保证消息顺序性:消息需要按照发送的先后顺序,进行消费。
- 重复消息处理:由于网络不稳定等问题,消息可能会重复发送以保证可靠,需要处理幂等性。
- 保证消息可靠性:生产者,故障宕机时,重启后消息不丢。消费者,故障宕机时,重启后可重新消费未处理消息。
2.Redis 基于 List 的 MQ 解决方案
1.顺序性需求
List 本身就是按先进先出的顺序对数据进行存取的,天然满足消息顺序性需求。
- 生产者发送消息:LPUSH命令
- 消费者读取消息:RPOP命令,或BRPOP命令。
BRPOP命令:由于生产者往List发送消息后,Redis并不会通知消费者消息到达。所以需要消费者循环RPOP。为了解决这个问题,Redis提供了阻塞式读取,即BRPOP命令。没有消息到达时,消费者线程阻塞。
2.重复消费处理
需要消费者程序本身进行判断:生产者给消息加入全局唯一ID号,消费者进行判重和幂等性处理。
3.可靠性需求
BRPOPLPUSH 命令:List中的数据取掉就没有了。为了防止消费者宕机后丢失消息,Redis提供了BRPOPLPUSH 命令。
消费者程序用BRPOPLPUSH 命令从一个 List 中读取消息,同时,Redis 会把这个消息再插入到另一个 List(可以叫作备份 List)留存。如果消费者宕机,重启后可以取备份List的消息来消费。
消息堆积问题:生产者太快,消费者太慢,产生消息堆积。此时需要用多个消费者去消费,但是List集合不支持消费组的实现。
Redis 从 5.0 版本开始提供的 Streams 数据类型,满足消息三大需求,且支持消费组形式的消息读取。
下面来看 Streams 数据类型。
3.基于 Streams 的 MQ 解决方案
Streams
是 Redis 专门为消息队列设计的数据类型,它提供了丰富的 MQ 操作命令:
- XADD:插入消息,保证有序,可以自动生成全局唯一 ID;
- XREAD:读取消息,可以按 ID 读取数据;
- XREADGROUP:按消费组形式读取消息;
- XPENDING 和 XACK:XPENDING 用来查询每个消费组内所有消费者已读取但尚未确认的消息。 XACK 用于向消息队列确认消息处理已完成。
1.XADD 插入消息
使用示例:
# 往名称为 mqstream 的消息队列中插入一条消息
# 消息的键是 repo,值是 5
# *表示让 Redis 为插入的数据自动生成一个全局唯一的 ID。也可以直接在消息队列名称后自己设定一个 ID 号,保证全局唯一即可。
XADD mqstream * repo 5
"1599203861727-0"
# 全局ID解析:1599203861727:以毫秒为单位的服务器当前时间戳。0:插入消息在当前毫秒内的消息序号。
2.XREAD 读取消息
指定一个消息 ID,并从这个消息 ID 的下一条消息开始进行读取。
使用示例:
# 从 ID 号为 1599203861727-0 的消息开始,读取后续的所有消息(示例中一共 3 条)
# block 配置项:实现类似于 BRPOP 的阻塞读取操作。MQ中没有消息时,阻塞100毫秒
XREAD BLOCK 100 STREAMS mqstream 1599203861727-0
1) 1) "mqstream"
2) 1) 1) "1599274912765-0"
2) 1) "repo"
2) "3"
2) 1) "1599274925823-0"
2) 1) "repo"
2) "2"
3) 1) "1599274927910-0"
2) 1) "repo"
2) "1"
# 阻塞 10000 毫秒,然后再返回。下例子10秒后还是没消息,返回了null。
# $:读取最新消息。
# List 也支持该功能。
XREAD block 10000 streams mqstream $
(nil)
(10.00s)
3.XGROUP 创建消费组
XGROUP
:Streams 本身可以使用 XGROUP 创建消费组。
XREADGROUP
:创建消费组之后,可以用 XREADGROUP 命令让消费组内的消费者读取消息。
示例:
# 创建一个名为 group1 的消费组,该消费组消费的消息队列是 mqstream
XGROUP create mqstream group1 0
OK
# 让 group1 消费组里的消费者 consumer1 从 mqstream 中读取所有消息
# “>”,表示从第一条尚未被消费的消息开始读取。
XREADGROUP group group1 consumer1 streams mqstream >
1) 1) "mqstream"
2) 1) 1) "1599203861727-0"
2) 1) "repo"
2) "5"
2) 1) "1599274912765-0"
2) 1) "repo"
2) "3"
3) 1) "1599274925823-0"
2) 1) "repo"
2) "2"
4) 1) "1599274927910-0"
2) 1) "repo"
2) "1"
# 消息队列中的消息一旦被消费组里的一个消费者读取了,就不能再被该消费组内的其他消费者读取
# 再次读取已是空值
XREADGROUP group group1 consumer2 streams mqstream 0
1) 1) "mqstream"
2) (empty list or set)
# 让每个消费者读取部分消息,从而实现消息读取负载在多个消费者间是均衡分布的
# 让 group2 中的 consumer1、2、3 各自读取一条消息
XREADGROUP group group2 consumer1 count 1 streams mqstream >
1) 1) "mqstream"
2) 1) 1) "1599203861727-0"
2) 1) "repo"
2) "5"
XREADGROUP group group2 consumer2 count 1 streams mqstream >
1) 1) "mqstream"
2) 1) 1) "1599274912765-0"
2) 1) "repo"
2) "3"
XREADGROUP group group2 consumer3 count 1 streams mqstream >
1) 1) "mqstream"
2) 1) 1) "1599274925823-0"
2) 1) "repo"
2) "2"
Streams 会自动使用内部队列(也称为 PENDING List)留存消费组里每个消费者读取的消息。
XACK 命令:消费者使用 XACK 命令后,消息才会被删除。
XPENDING 命令:可以查看已读取、但尚未确认处理完成的消息
4.XPENDING 查看未确认消息
示例:
# 查看一下 group2 中各个消费者已读取、但尚未确认的消息个数
# 返回结果的第二、三行分别表示 group2 中所有消费者读取的消息最小 ID 和最大 ID
XPENDING mqstream group2
1) (integer) 3
2) "1599203861727-0"
3) "1599274925823-0"
4) 1) 1) "consumer1"
2) "1"
2) 1) "consumer2"
2) "1"
3) 1) "consumer3"
2) "1"
# 进一步查看某个消费者具体读取了哪些数据
# 可以看到,consumer2 已读取的消息的 ID 是 1599274912765-0
XPENDING mqstream group2 - + 10 consumer2
1) 1) "1599274912765-0"
2) "consumer2"
3) (integer) 513336
4) (integer) 1
5.XACK 确认消息已处理
示例:
# 消息 1599274912765-0 被 consumer2 处理后,consumer2 可使用 XACK 命令通知 Streams,然后这条消息就会被删除。
# 再使用 XPENDING 命令查看时,就可以看到,consumer2 已经没有已读取、但尚未确认处理的消息。
XACK mqstream group2 1599274912765-0
(integer) 1
XPENDING mqstream group2 - + 10 consumer2
(empty list or set)
Redis 5.0版本后才支持 Streams。
小结
MQ的三大需求:
- 消息保序:消息数据有序存取
- 重复消息处理:消息数据具有全局唯一编号
- 消息可靠性保证:消息数据在消费完成后再删除
List 和 Stream 提供了MQ功能,特点和区别如下:
是否要用Redis做MQ:Redis 是非常轻量的 MQ。可靠性不太好。可根据具体业务场景做抉择。
提问
Redis可以当MQ用吗?有什么好坏处?用什么数据类型。List,Stream(支持多消费者,提供了ACK功能)。
实践篇:性能和内存(16-22)
Redis流行的一个重要原因就是,它的性能很好。
影响 Redis 性能的因素有很多,如:命令操作、系统配置、关键机制、硬件配置等。
本篇介绍影响 Redis 性能的 5 大类潜在因素:
- Redis 内部的阻塞式操作;
- CPU 核和 NUMA 架构的影响;
- Redis 关键系统配置;
- Redis 内存碎片;
- Redis 缓冲区。
16.异步机制:如何避免单线程模型的阻塞?
Redis 的网络 IO 和键值对读写是由主线程完成,这些操作执行时间长了就会阻塞主线程。
Redis 的操作有很多,例如:
键值对增删改查操作:由服务客户端发起请求。
持久化操作:保证可靠性。
数据同步操作:用来进行主从复制。
操作很多,哪些会引起阻塞呢?下面来看下。
1.Redis 实例有哪些阻塞点?
Redis 和不同的对象交互,就会有不同的阻塞点。这些交互对象有:
- 客户端:网络 IO,键值对增删改查操作,数据库操作;
- 磁盘:生成 RDB 快照,记录 AOF 日志,AOF 日志重写;
- 主从节点:主库生成、传输 RDB 文件,从库接收 RDB 文件、清空数据库、加载 RDB 文件;
- 切片集群实例:向其他实例传输哈希槽信息,数据迁移。
如下图所示:
下面来一一分析,哪些操作会引起阻塞。
1.和客户端交互时的阻塞点
1.网络IO操作:不会阻塞
网络IO不会阻塞。Redis 使用了 IO 多路复用机制,避免了主线程一直等待。
2.键值对增删改查操作:会阻塞
增删改查操作是Redis 主线程执行最频繁的任务之一。所以,复杂度高的增删改查操作肯定会阻塞 Redis。
如何判断操作复杂度高:看操作的复杂度是否为 O(N)。
- 阻塞点一:查询:集合聚合和全量查询操作。例如集合元素全量查询操作
HGETALL
、SMEMBERS
,以及集合的聚合统计操作,例如求交、并和差集。这些复杂度都至少在O(N)。 - 阻塞点二:删除:bigkey 删除操作。删除操作可能会造成阻塞隐患。删除操作的本质是要释放键值对占用的内存空间。内存释放后,为了高效地管理内存空间,OS会把释放掉的内存块插入一个空闲内存块的链表,以便后续进行管理和再分配。如果瞬间释放大量内存,那么操作空闲内存块链表的时间就会增加,就会阻塞 Redis 主线程。那么什么时候会瞬间释放大量内存呢?就是大量删除键值对的时候。例如删除包含了大量元素的集合,即bigkey删除。删除时间随删除数量,成高于线性的比例增长。删除 100 万个元素的Hash,最大的删除时间绝对值已经达到了 1.98s。
- 阻塞点三:清空DB:清空数据库。同样的,也涉及大量键值对的删除,有前者阻塞风险。例如 FLUSHDB 和 FLUSHALL 操作。
2.和磁盘交互时的阻塞点
磁盘 IO 一般都是比较费时费力的,需要重点关注,所以单独列出来。
Redis 设计为采用子进程的方式生成 RDB 快照文件,以及执行 AOF 日志重写操作。所以不会阻塞主线程。
但是,Redis 直接记录 AOF 日志时,会根据不同的写回策略对数据做落盘保存:
- 阻塞点四:插入和更新:AOF 日志同步写。一个同步写磁盘的操作的耗时大约是 1~2ms,如果有大量的写操作需要记录到 AOF 日志中,并同步写回的话,就会阻塞主线程。
3.主从节点交互时的阻塞点
在主从集群中,主库需要生成 RDB 文件,并传输给从库:
- 主库复制:创建和传输 RDB 文件都是由子进程来完成的,不会阻塞主线程。
- 从库接收:阻塞点三:清空数据库:可能会阻塞。也就是前面的阻塞点三。从库接收 RDB 文件后,需要使用 FLUSHDB 命令清空当前数据库。
- 从库接收:阻塞点五:加载 RDB文件:从库接收RDB后进行加载,如果RDB过大,加载就慢,就会阻塞。
4.切片集群实例交互时的阻塞点
部署 Redis 切片集群时:
- 哈希槽信息传递:每个 Redis 实例上分配的哈希槽信息需要在不同实例间进行传递。不过哈希槽信息量不大,不会阻塞。
- 实例间数据迁移:进行负载均衡或者有实例增删时,数据需要在不同实例间迁移。数据迁移是渐进式执行的,一般问题不大。
阻塞点六(可解决):集群数据迁移:一般没有bigkey的话,数据迁移是渐近式的,问题不大。不过,如果使用了 Redis Cluster 方案,而且同时正好迁移的是 bigkey 的话,就会造成主线程阻塞,因为 Redis Cluster 使用了同步迁移。后续会介绍不同切片集群方案对数据迁移造成的阻塞的解决方法。
5.阻塞点汇总
下面汇总前面提到的Redis主线程阻塞点:
- 查:集合全量查询和聚合操作。
- 删:bigkey 删除。
- 增改:大量 AOF 日志同步写。
- DB:清空数据库。
- 主从同步:从库清空数据库、加载 RDB 文件。
- 集群同步(可解决):集群数据迁移。
以上操作,主要是关注前五个操作,数据量大时,就会阻塞主线程。
异步线程机制:为了避免阻塞式操作,Redis 提供了异步线程机制。即启动一些子线程,然后把一些任务交给这些子线程,让它们在后台完成。
那么,上述五大阻塞式操作都可以被异步执行吗?下面来看下
2.哪些阻塞点可以异步执行?
1.什么操作可以异步执行
非关键路径操作:只有不是Redis 主线程的关键路径上的操作,才可以异步执行。也就是说,不能是客户端等着返回结果的操作。
如下图所示:
操作1不用等待结果返回,所以可以异步执行。操作2反之。
2.各阻塞点能否异步执行
以下是各个阻塞点异步执行情况:
- 阻塞点一:集合全量查询和聚合操作。不能异步执行。读操作是典型的关键路径操作。
- 阻塞点二:bigkey 删除。可以异步执行。删除操作不用等待返回结果。
- 阻塞点三:清空数据库。可以异步执行。删除操作不用等待返回结果。
- 阻塞点四:增改,AOF 日志同步写。可以异步执行。Redis 实例需要保证 AOF 日志中的操作记录已经落盘,该操作虽然需要实例等待,但无需返回具体的数据结果。
- 阻塞点五:主从,从库加载RDB文件。不能异步执行。从库要想对客户端提供数据存取服务,就必须把 RDB 文件加载完成。
综上,除了“集合全量查询和聚合操作”和“从库加载 RDB 文件”不能异步执行。bigkey 删除,清空数据库,以及 AOF 日志同步写都可以异步执行。
具体异步机制如何实现呢,下面来看下。
3.异步的子线程机制
创建线程池:Redis 主线程启动后,会使用OS提供的 pthread_create 函数创建 3 个子线程,分别由它们负责异步执行:
- AOF 日志写操作
- 键值对删除
- 文件关闭。
消费任务队列,惰性删除:
- 将操作放入任务队列:主线程通过一个链表形式的任务队列和子线程进行交互。在收到键值对删除和清空数据库的操作时,主线程会把该操作封装成一个任务,放入到任务队列中,然后直接给客户端返回一个删除完成信息。
- 后台子线程消费任务:等到后台子线程从任务队列中读取任务后,才开始实际删除键值对,并释放相应的内存空间。这种异步删除也称为惰性删除。
消费任务队列,写AOF日志:
- 将操作放入任务队列:和惰性删除类似,当 AOF 日志配置成 everysec 选项后,主线程会把 AOF 写日志操作封装成一个任务,也放到任务队列中。
- 后台子线程消费任务:后台子线程读取任务后,开始自行写入 AOF 日志。
总体示意图如下图:
异步的键值对删除 和 数据库清空操作在 Redis 4.0 后提供,需要使用新的命令来执行这两个操作:
- UNLINK 命令:键值对删除:当你的集合类型中有大量元素(例如有百万级别或千万级别元素)需要删除时使用 。
- 在 FLUSHDB 和 FLUSHALL 命令后加上 ASYNC 选项:这样就可以让后台子线程异步地清空数据库。
小结
4 大类交互对象:客户端、磁盘、主从库实例、切片集群实例。
五(六)个阻塞点:集合全量查询和聚合操作、bigkey 删除、清空数据库、AOF 日志同步写,从库加载 RDB 文件,(实例间数据同步)。按顺序:查、删、删库、增改、主从、集群。
异步子线程机制:类似线程池。创建3个线程。操作来时放入任务队列,直接返回完成,后续由子线程执行。
异步解决阻塞:删(回收内存空间)、删库(回收内存空间)、增改(写AOF日志)可以异步执行。集群数据同步后面会讲。
4.0之前删除建议(未提供异步):先使用集合类型提供的 SCAN 命令读取数据,然后再分批删除。因为用 SCAN 命令可以每次只读取一部分数据并进行删除。
同步操作建议:查(数据聚合,全量查询)、主从(从库删除老数据,加载新RDB)无法异步执行。不过有两个小建议:
- 集合全量查询和聚合操作:可使用 SCAN 命令,分批读取数据,再在客户端进行聚合计算;
- 从库加载 RDB 文件:把主库的数据量大小控制在 2~4GB 左右,以保证 RDB 文件能以较快的速度加载。
其他疑问:
- 异步操作,会不会影响接下来立马进行的下一个操作,会不会读到脏数据?
- 异步操作,Redis挂掉,会不会丢数据或者造成脏数据?
- Redis有没有维护一个类型JVM的内存用来缓冲删除时的内存回收?
提问
Redis单线程还是多线程?哪些地方会阻塞主线程?如何解决?
哪些阻塞点可以异步操作?无法异步操作时怎么避免阻塞呢?
Redis如何实现异步子线程机制?
17.为什么CPU结构也会影响Redis的性能?
单核的CPU快慢会影响Redis性能,多核的CPU也会影响。
本篇主题:基于 CPU 多核架构和多 CPU 架构优化 Redis 性能的方法。
1.主流的 CPU 架构
主流的CPU架构:
Socket:每个服务器会有多个插槽。每个插槽一个CPU。一般会有十几二十个物理核。
物理核:每个 CPU 处理器一般有多个物理核。
物理核私有缓存L1/L2:每个物理核都拥有私有的L1、L2。即一级缓存和二级缓存,缓存空间只能被当前物理核使用。L1包括一级指令缓存和一级数据缓存。
- 访问时延: 访问L1、L2一般不超过10纳秒,而访问内存则需要百纳秒级别。
- 大小:一般L1、L2大小只有KB级别。
物理核共享缓存L3:不同物理核共享一个三级缓存L3。为了尽量避免访问内存。不同的物理核,通过L3共享数据。
- 大小:一般L3大小为几十MB。
逻辑核:主流的 CPU 处理器中,每个物理核通常都会运行两个超线程,也叫作逻辑核。逻辑核共享L1、L2。
物理核、逻辑核、L1、L2、L3关系示意图:
可以看到,要想Redis速度快,就要尽量在同一个物理核操作。
多 CPU 架构,一般会有多个插槽Socket 。关系如下图所示:
可以看到,不同的插槽,通过总线连接。
远端内存访问:Redis有可能在不同的CPU上运行,例如先在 Socket 1 上运行一段时间,把数据保存到了内存,然后再被调度到 Socket 2 上运行。此时,Redis再进行内存访问时,需要访问之前 Socket 上连接的内存,这就是远端内存访问。此时会增加应用程序的延迟。
NUMA 架构:在多 CPU 架构下,一个应用程序访问所在 Socket 的本地内存和访问远端内存的延迟并不一致,所以,我们也把这个架构称为非统一内存访问架构(Non-Uniform Memory Access,NUMA 架构)
简单总结下 CPU 架构对应用程序运行的影响:
- 充分利用L1、L2缓存:因为L1、L2 缓存中的指令和数据的访问速度很快,是物理核内缓存的,是访问内存的10倍多。所以,充分利用 L1、L2 缓存,可以有效缩短应用程序的执行时间;
- NUMA 架构远端内存访问会增加延迟:NUMA架构下,如果应用程序从一个 Socket 上调度到另一个 Socket 上,就可能会出现远端内存访问的情况,这会直接增加应用程序的执行时间。
2.CPU 多核对 Redis 性能的影响
1.总述
单核场景:在一个 CPU 核上运行时,应用程序需要记录自身使用的软硬件资源信息(例如栈指针、CPU 核的寄存器值等),我们把这些信息称为运行时信息。同时,应用程序访问最频繁的指令和数据还会被缓存到 L1、L2 缓存上,以便提升执行速度。
多核场景:应用程序需要重写在另一个新的物理核上运行时,运行时信息需要重新加载到新的物理核上,而且新物理核的L1、L2也需要重写加载数据,需要从L3,甚至是内存重新加载,而且L1、L2又是读写最频繁的数据和指令。此时运行时间就会增加。
2.应用举例
业务场景:多核 CPU,使用 GET/PUT 复杂度为 O(1) 的 String 类型进行数据存取,关闭 RDB 和 AOF,在24 个 CPU 核的服务器上运行 Redis 实例,GET 和 PUT 的 99% 尾延迟分别是 504 微秒和 1175 微秒,延迟太高,不满足99% 尾延迟优化要求。仔细检测了 Redis 实例运行时的服务器 CPU 的状态指标值,发现CPU 的 context switch(即上下文切换) 次数比较多。
99% 尾延迟:把所有请求的处理延迟从小到大排个序,99% 的请求延迟小于的值。
原因排查:Redis 实例被频繁调度到不同 CPU 核上运行,物理核频繁切换时,需要重新加载L1、L2。
3.性能优化:taskset 命令绑核
上述优化方法,就是要让Redis不要在CPU之间跳来跳去。
taskset 命令:把一个程序绑定在一个核上运行。
例如,把Redis 实例绑在了 0 号核:
# -c 选项用于设置要绑定的核编号
taskset -c 0 ./redis-server
3.CPU的NUMA 架构,对 Redis 性能的影响
1.网络中断程序绑核优化
网络中断程序绑核优化:在实际应用 Redis 时,为了提升 Redis 的网络性能,可以把OS的网络中断处理程序和 CPU 核绑定。这样就可以避免网络中断处理程序在不同核上来回调度执行,可以提升 Redis 的网络处理性能。
网络中断程序是要和 Redis 实例进行网络数据交互的,所以要注意它绑定的物理核和Redis实例所在的物理核是不是同一个,或者至少要在一个Socket上。
2.Redis 实例和网络中断程序数据交互
Redis 实例和网络中断程序的数据交互:
- 网络中断处理程序从网卡硬件中读取数据,并把数据写入到OS内核维护的一块内存缓冲区。
- 内核会通过 epoll 机制触发事件,通知 Redis 实例。
- Redis 实例把数据从内核的内存缓冲区拷贝到自己的内存空间。
网卡硬件 -> 内核内存缓冲区 -> Redis实例内存空间,如下图所示:
3.跨CPU Socket 访问风险
NUMA架构下的潜在风险:网络中断程序绑核。Redis实例也绑核。如果网络中断处理程序和 Redis 实例各自所绑的 CPU 核不在同一个 CPU Socket 上,那么,Redis 实例读取网络数据时,就需要跨 CPU Socket 访问内存,这个过程会花费较多时间。
如下图所示:
可以看到,此时是通过总线来交换数据的。比较耗时。
曾经做过测试,访问 CPU Socket 和本地内存相比,跨 CPU Socket 的内存访问延迟增加了 18%。
所以,最好把网络中断程序和 Redis 实例绑在同一个 CPU Socket 上,这样Redis 实例就可以直接从本地内存读取网络数据,如下图所示:
4.NUMA 架构下,CPU 核的特殊编号规则
绑核时,注意CPU和编号规则:是先给每个 CPU Socket 中每个物理核的第一个逻辑核依次编号,再给每个 CPU Socket 中的物理核的第二个逻辑核依次编号。并不是先把一个 CPU Socket 中的所有逻辑核编完,再对下一个 CPU Socket 中的逻辑核编码。
例如,执行lscpu 命令,查看核编号:
# 假设有 2 个 CPU Socket,每个 Socket 上有 6 个物理核,每个物理核又有 2 个逻辑核,总共 24 个逻辑核。
# S:插槽。P:物理核。L:逻辑核。
# 最小单个单位是物理核,按列对每个插槽进行排序。
# 编码顺序为:S1P1LN, S2P1LN, S1P2LN, S2P2LN
lscpu
Architecture: x86_64
...
NUMA node0 CPU(s): 0-5,12-17
NUMA node1 CPU(s): 6-11,18-23
...
所以,并不是连续的编号就是在一个socket上。比如说,我们把网络中断程序和 Redis 实例分别绑到编号为 1 和 7 的 CPU 核上,此时,它们仍然是在 2 个 CPU Socket 上,Redis 实例仍然需要跨 Socket 读取网络数据。
Redis关于CPU的性能优化总结:
- 用 taskset 命令将Redis实例绑核:在 CPU 多核的场景下,用 taskset 命令把 Redis 实例和一个核绑定,可以减少 Redis 实例在不同核上被来回调度执行的开销,避免较高的尾延迟;
- Redis实例和OS网络中段程序要绑到同一个Socket:在多 CPU 的 NUMA 架构下,如果对网络中断程序做了绑核操作,建议你同时把 Redis 实例和网络中断程序绑在同一个 CPU Socket 的不同核上,这样可以避免 Redis 跨 Socket 访问内存中的网络数据的时间开销。
当然,绑核也有风险。下面来看。
4.绑核的风险和解决方案
Redis绑核子线程竞争:Redis 除了主线程以外,还有用于 RDB 生成(4)和 AOF 重写(5)的子进程,以及Redis的后台线程(16)。如果把Redis实例绑到一个逻辑核上时,子线程就会和主线程竞争CPU,造成主线程阻塞。
1.解决方案一:1个Redis实例对应一个物理核。
绑定物理核:我们不要把一个实例和一个逻辑核绑定,而是和一个物理核绑定,此时把一个物理核的 2 个逻辑核都会用上。
还是前面的例子,我们可以绑定两个逻辑核,这两个逻辑核在1个物理核上:
taskset -c 0,12 ./redis-server
但是,因为只用了 2 个逻辑核,它们相互之间的 CPU 竞争仍然存在。
下面看方案2。
2.解决方案二:优化 Redis 源码
改源码:通过修改 Redis 源码,把子进程和后台线程绑到不同的 CPU 核上。
以下方案是,通用的通过编程实现绑核的做法。
通过编程实现绑核时,要用到操作系统提供的 1 个数据结构 cpu_set_t
和 3 个函数 CPU_ZERO
、CPU_SET
和 sched_setaffinity
:
- cpu_set_t 数据结构:是一个位图,每一位用来表示服务器上的一个 CPU 逻辑核。
- CPU_ZERO 函数:以 cpu_set_t 结构的位图为输入参数,把位图中所有的位设置为 0。
- CPU_SET 函数:以 CPU 逻辑核编号和 cpu_set_t 位图为参数,把位图中和输入的逻辑核编号对应的位设置为 1。
- sched_setaffinity 函数:以进程 / 线程 ID 号和 cpu_set_t 为参数,检查 cpu_set_t 中哪一位为 1,就把输入的 ID 号所代表的进程 / 线程绑在对应的逻辑核上。
运用3个函数绑核步骤:
- 第一步:创建一个 cpu_set_t 结构的位图变量;
- 第二步:使用 CPU_ZERO 函数,把 cpu_set_t 结构的位图所有的位都设置为 0;
- 第三步:根据要绑定的逻辑核编号,使用 CPU_SET 函数,把 cpu_set_t 结构的位图相应位设置为 1;
- 第四步:使用 sched_setaffinity 函数,把程序绑定在 cpu_set_t 结构位图中为 1 的逻辑核上。
线程绑核代码如下:
//线程函数
void worker(int bind_cpu){
cpu_set_t cpuset; //创建位图变量
CPU_ZERO(&cpu_set); //位图变量所有位设置0
CPU_SET(bind_cpu, &cpuset); //根据输入的bind_cpu编号,把位图对应为设置为1
sched_setaffinity(0, sizeof(cpuset), &cpuset); //把程序绑定在cpu_set_t结构位图中为1的逻辑核
//实际线程函数工作
}
int main(){
pthread_t pthread1
//把创建的pthread1绑在编号为3的逻辑核上
pthread_create(&pthread1, NULL, (void *)worker, 3);
}
// 对于 Redis 来说,它是在 bio.c 文件中的 bioProcessBackgroundJobs 函数中创建了后台线程。bioProcessBackgroundJobs 函数类似于以上的 worker 函数,在这个函数中实现绑核四步操作,就可以把后台线程绑到和主线程不同的核上了。
类似地,当我们使用 fork 创建子进程时,也可以把刚刚说的四步操作实现在 fork 后的子进程代码中,示例代码如下:
int main(){
//用fork创建一个子进程
pid_t p = fork();
if(p < 0){
printf(" fork error\n");
}
//子进程代码部分
else if(!p){
cpu_set_t cpuset; //创建位图变量
CPU_ZERO(&cpu_set); //位图变量所有位设置0
CPU_SET(3, &cpuset); //把位图的第3位设置为1
sched_setaffinity(0, sizeof(cpuset), &cpuset); //把程序绑定在3号逻辑核
//实际子进程工作
exit(0);
}
...
}
对于 Redis 来说,生成 RDB 和 AOF 日志重写的子进程分别是下面两个文件的函数中实现的:
- rdb.c 文件:rdbSaveBackground 函数;
- aof.c 文件:rewriteAppendOnlyFileBackground 函数。
这两个函数中都调用了 fork 创建子进程,所以,我们可以在子进程代码部分加上绑核的四步操作。
5.其他补充
Redis 6.0 出来后,可以支持 CPU 核绑定的配置操作了,无需再修改源码。
此外,针对部署在K8s容器上的Redis绑核,以及网络优化,可参照这篇文章。
小结
CPU架构:逻辑核(share:L1、L2) -> 物理核(share:L3)-> 插槽(share:总线)。
Redis实例绑核可提高性能:为了防止子线程和主线程竞争,需要绑定到同一个物理核的两个逻辑核上,避免走L3或内存或总线。或者修改源码绑定不同核。
网络中断程序绑核可提高性能:为何提高性能,需要和Redis实例绑定到同一个插槽上,避免走总线。
提问
CPU架构?多Socket、多核、多逻辑核、L1 / L2 / L3。不同的逻辑核共享L1、L2数据,不同的物理核共享L3数据,不同的CPU插槽通过总线连接。
单核或多核的CPU架构,对Redis性能分别有什么影响?可以怎么解决?
网络中断程序和 Redis 实例绑在同一个 CPU Socket 和不同CPU Socket 上有什么区别?
Redis绑到一个逻辑核,有何风险?如何解决?
18.波动的响应延迟:如何应对变慢的Redis?(上)
Redis可能会有性能波动,就是突然一下变慢了。
由于Redis是中间件,这可能还会影响到其他系统。例如,一个事务,在本地写,改变Redis标记位,把数据发送出去。这时如果Redis变慢,那么整个事务都会被阻塞。
前面已经说过,可能会导致Redis变慢的阻塞点以及解决方案,即异步线程机制和 CPU 绑核。
接下来两篇,来看下如何系统性的解决Redis变慢问题,即问题认定、系统性排查和应对方案。
1.Redis 真的变慢了吗?
首先要确实,Redis是不是真的变慢了。
1.查看Redis响应延迟
最直接的方法,就是查看 Redis 的响应延迟。
看 Redis 延绝对值。比如突然出现几秒到十几秒的响应延迟,那肯定就是Redis变慢了。
不过这个看的是绝对值。
2.基于当前环境下的 Redis 基线性能判断
基线性能:系统在低压力、无干扰下的基本性能,该性能只由当前的软硬件配置决定。
如何确定基线性能:从 2.8.7 版本开始,redis-cli 命令提供了–intrinsic-latency
选项,可以用来监测和统计测试期间内的最大延迟,这个延迟可以作为 Redis 的基线性能。其中,测试时长可以用–intrinsic-latency 选项的参数来指定。
网络对基线性能的影响:为了排除网络对基线性能的影响,上述命令需要在服务器端直接运行。如果想了解网络对 Redis 性能的影响,可以用 iPerf
这样的工具,测量从 Redis 客户端到服务器端的网络延迟。如果延迟有几十毫秒甚至是几百毫秒,那可能就是网络中有其他大流量应用程序在运行,导致网络阻塞了。
测试举例:
# 一般测120s就够了
./redis-cli --intrinsic-latency 120
Max latency so far: 17 microseconds.
Max latency so far: 44 microseconds.
Max latency so far: 94 microseconds.
Max latency so far: 110 microseconds.
Max latency so far: 119 microseconds.
36481658 total runs (avg latency: 3.2893 microseconds / 3289.32 nanoseconds per run).
Worst run took 36x longer than the average latency.
一般认为,观察到的 Redis 运行时延迟是其基线性能的 2 倍及以上,就可以认定 Redis 变慢了。
因为运行时OS、硬件配置和空闲时也不太一样。例如,如果是运行在虚拟机或容器下,就会有多余的开销。
2.如何应对 Redis 变慢?
下面来诊断,Redis哪里导致变慢了。
需要根据Redis的架构,一层层排查。第1篇中有Redis架构图。
影响 Redis 性能的三大要素:Redis 自身的操作特性、文件系统和操作系统。
以下是进一步丰富的Redis架构图,重点关注红色部分:
接下来,从这三大要素入手,结合实际的应用场景,来看下如何解决实际问题。
本篇介绍Redis 的自身操作特性的影响,下篇再重点研究操作系统和文件系统的影响。
3.Redis 自身操作特性的影响
来看 Redis 提供的键值对命令操作对延迟性能的影响。
重点介绍两类关键操作:慢查询命令和过期 key 操作。
1.慢查询命令
变慢原因:主要跟命令的时间复杂度有关。各个命令复杂度可以查看官方文档。比如,当Value为String时,GET/SET 操作是O(1)。当 Value 为 Set 时,SORT、SUNION/SMEMBERS 操作复杂度分别为 O(N+M*log(M)) 和 O(N),N为元素个数,M为返回个数。
排查方式:可以通过 Redis 日志,或者是 latency monitor
工具,查询变慢的请求。看下这个请求的操作,是不是高复杂度的操作。
处理方式:
- 全量操作等慢查询用其他高效命令代替:比如说,全量返回改迭代批量返回。即如果返回一个 SET 中的所有成员,不要使用 SMEMBERS 命令,而是使用
SSCAN
多次迭代批量返回,避免一次返回大量数据,造成线程阻塞。 - 聚合操作在客户端完成:当你需要执行排序、交集、并集操作时,可以在客户端完成,而不要用 SORT、SUNION、SINTER 这些命令,以免拖慢 Redis 实例。
- KEYS 命令不要用于生产环境:这个命令比较容易忽视。例如,
KEYS *name*
,用于返回所有包含“name”字符串的 keys,此时需要遍历所有存储的key值,会导致Redis延迟。
如果确实是业务需要,需要慢查询命令,那么只能用更好的硬件了。
2.过期 key 操作
过期 key 的自动删除:即自动删除过期Key。删除操作是阻塞的,且可能会有大量key同时过期,所以这个操作可能会引起阻塞。
过期 key 运行原理,Redis 默认每 100 毫秒会删除一些过期 key,具体的算法如下:
- 采样并删除N个过期key:采样 参数
ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP
个数(默认20)的 key,并将其中过期的 key 全部删除。 - 未删除干净则继续删除:如果超过 25% 的 key 过期了,则重复删除的过程,直到过期 key 的比例降至 25% 以下。
上述第1步,影响不大。而第2步,当有大量key同时过期时,Redis 就会一直删除以释放内存空间。注意,删除操作是阻塞的(Redis 4.0 后可以用异步线程机制来减少阻塞影响)。此时就会影响其他键值对操作。
变慢原因:一个重要原因是,频繁使用带有相同时间参数的 EXPIREAT 命令设置过期 key,这就会导致,在同一秒内有大量的 key 同时过期。
排查方式:检查业务代码。多个key有没有使用相同过期时间:看下在使用 EXPIREAT 命令设置 key 过期时间时,有没有使用了相同的 UNIX 时间。有没有使用 EXPIRE 命令给批量的 key 设置相同的过期秒数。
处理方式:1.不要设置全局统一过期时间:根据实际业务的使用需求,决定 EXPIREAT 和 EXPIRE 的过期时间参数。2.同一批过期的key可以设置范围内随机过期时间:其次,如果一批 key 的确是同时过期,还可以在 EXPIREAT 和 EXPIRE 的过期时间参数上,加上一个一定大小范围内的随机数,这样,既保证了 key 在一个邻近时间范围内被删除,又避免了同时过期造成的压力。
小结
提问
怎么判断Redis是真的变慢了?明显的长延迟。基于基线性能的延迟判断。网络延迟用iPerf测下。
如何诊断Redis变慢?变慢有哪些原因?诊断后如何处理?即变慢原因、排查方式、处理方式。参考架构图,从Redis自身操作特性、OS系统、文件系统三大方面来判断。
在 Redis 中,还有哪些其他命令可以代替 KEYS 命令,实现同样的功能呢?这些命令的复杂度会导致 Redis 变慢吗?如果想要获取整个实例的所有key,建议使用SCAN命令代替。客户端通过执行SCAN $cursor COUNT $count可以得到一批key以及下一个游标$cursor,然后把这个$cursor当作SCAN的…
19.波动的响应延迟:如何应对变慢的Redis?(下)
上篇讲了基于响应延迟和基线性能判断Redis变慢,以及从命令操作层面排查解决问题的两种方案。如果没有慢查询,也没有同时删除大量过期key,但是Redis也慢,怎么办呢?
本篇主题:文件系统、操作系统对Redis性能的影响。
文件系统:Redis需要持久化。文件系统将数据写回磁盘的机制,会直接影响到 Redis 持久化的效率。持久化的效率高低又会影响到 Redis 处理请求的性能。
操作系统:Redis 内存操作非常频繁。OS的内存机制会直接影响到 Redis 的处理效率,例如内存不够时OS会启动Swap机制。
回顾上上一篇的Redis架构图:
1.文件系统:AOF 模式
1.原因分析
Redis持久化,有AOF 日志和 RDB 快照两种模式。
AOF 日志提供了三种日志写回策略:no
、everysec
、always
。三种策略如下
三种写回策略依赖文件系统的两个系统调用完成: write
、 fsync
。其中,write 把日志记录写到内核缓冲区后立即返回。而fsync 需要把日志记录写回到磁盘后才能返回,需要等待。
三种写回策略如下图:
AOF写回比较:
- no:OS定期写回。后台OS执行 fsync 操作,宕机时数据会丢。
- everysec:每秒写回。后台线程执行 fsync 操作,允许丢失1秒数据。
- always:每次写回。阻塞主线程执行 fsync 操作,不允许数据丢失。
- AOF重写:为避免日志文件不断增大,会生成体量缩小的新的 AOF 日志文件。后台线程执行。
以上操作其实都可能阻塞主线程,阻塞风险分析:
- always:肯定阻塞主线程。因为必须要拿到返回结果,是由主线程执行。
- everysec、always:可能阻塞主线程。虽然是后台执行,但是主线程会监控它的执行状态。主线程发起本次写回时,上次写回必须执行完毕,否则就会阻塞主线程。所以当后台子线程执行的 fsync 频繁阻塞的话(比如 AOF 重写占用了大量的磁盘 IO 带宽),是可能阻塞主线程的。
- AOF重写:可能阻塞主线程。虽然是有后台执行,但是AOF重写会对磁盘进行大量 IO 操作,可能会阻塞子线程 fsync操作,进而阻塞主线程操作。但是当 AOF 重写的压力比较大时:AOF重写占用大量磁盘带宽 -> 子线程 fsync 被阻塞 -> 主线程被阻塞:主线程发起子线程 fsync,上次 fsync 未完成,主线程被阻塞。
以下是磁盘压力小和压力大时,fsync
后台子线程和主线程受到的影响示意图:
2.排查和解决方法
检查 Redis 配置文件中的 appendfsync
配置项,查看 AOF 日志写回策略,如下图所示:
避免 fsync 操作引起的阻塞:如果没必要保证数据的绝对可靠性,那么写回策略可以使用 no 配置。例如Redis用作缓存,那就没必要用 aways。
避免 AOF 重写引起的阻塞:如果业务应用对延迟敏感,但允许一定量的数据丢失,那可以把配置项 no-appendfsync-on-rewrite
设置为 yes,表示在 AOF 重写时,不进行 fsync 操作,此时写命令写到内存后立即返回。如下所示:
# 默认为no
no-appendfsync-on-rewrite yes
从硬件上解决问题:如果的确需要高性能,同时也需要高可靠数据保证,那么可以考虑使用高速的固态硬盘作为 AOF 日志的写入设备。固态盘的带宽和并发度比传统的机械硬盘的要高出 10 倍及以上。
2.操作系统:swap
如果 Redis 的 AOF 日志配置只是 no,或者就没有采用 AOF 模式,那么,还会有什么问题导致性能变慢吗?有可能是OS的内存swap。
1.原因分析
内存 swap:OS将内存数据在内存和磁盘间来回换入和换出的机制,涉及到磁盘的读写。
swap会阻塞主线程:正常情况下,Redis 的操作是直接通过访问内存完成。一旦触发 swap,请求操作必须等到磁盘数据读写完成,此时会阻塞主线程。曾经有过线上测试,延迟增加了将近 48 倍。
内存 swap原因:原因主要是物理机器内存不足。对于Redis,一般是两种情况:
- Redis内存使用量过大。
- 和其他内存需求大的应用一起运行到同一台机器,被其他应用影响。
2.排查和解决方法
排查方法:OS会在后台记录每个进程的 swap 使用情况,即有多少数据量发生了 swap。可到proc
目录查看相应进程的smaps
信息:
# 查询Redis 进程号,这里是 5332。
$ redis-cli info | grep process_id
process_id: 5332
# 进入 Redis 所在机器的 /proc 目录下的该进程目录中
cd /proc/5332
# 运行下面的命令,查看该 Redis 进程的使用情况。这里只copy了部分。
$cat smaps | egrep '^(Swap|Size)'
Size: 584 kB
Swap: 0 kB
Size: 4 kB
Swap: 4 kB
Size: 4 kB
Swap: 0 kB
Size: 462044 kB
Swap: 462008 kB
Size: 21392 kB
Swap: 0 kB
# Size 表示的是 Redis 实例所用的一块内存大小,
# Size 下方的 Swap 和它相对应,表示这块 Size 大小的内存区域有多少已经被换出到磁盘上了。
# 如果这两个值相等,就表示这块内存区域已经完全被换出到磁盘了。
上述可以看到,Size有大有小。不同内存块被换出到磁盘上的大小也不一样。
swap 指标:swap 的大小是排查 Redis 性能变慢是否由 swap 引起的重要指标。当出现百 MB,甚至 GB 级别的 swap 大小时,就表明此时Redis 实例的内存压力很大,很有可能会变慢。
解决方法:
- 加内存:最直接的解决方法就是增加机器内存。
- 加实例:如果该实例在一个 Redis 切片集群中,可以增加 Redis 集群的实例个数,来分摊每个实例服务的数据量。
- 单独部署:如果 Redis 实例和其他操作大量文件的程序(例如数据分析程序)共享机器,可以将 Redis 实例迁移到单独的机器上运行。因为,操作大量文件,本身是很耗内存的,需要把文件读入到内存。
3.操作系统:内存大页
1.原因分析
除了swap,内存大页也是一个影响因素。
内存大页机制(Transparent Huge Page, THP):内存的分页粒度增大。Linux 内核从 2.6.38 开始支持 2MB 大小的内存页分配,常规分配粒度是4KB。
内存大页对Redis的影响,劣势大于优势,要trade-off (取舍)一下:
- 优势:在分配相同的内存量时,可以减少分配次数。
- 劣势:写时复制延迟增大。Redis是需要持久化的,当它的子线程在后台执行持久化时,如果客户端请求修改到了正在持久化的数据,就会触发写时复制。即,一旦有数据要被修改,不会直接修改内存中的数据,而是将这些数据拷贝一份,然后再进行修改。此时,如果客户端只请求了100B的数据,如果用了内存大页,则需要拷贝2MB数据,拷贝的粒度变大了,显然性能会变慢。
2.排查和解决方法
排查方法:直接执行命令查询配置文件即可:
cat /sys/kernel/mm/transparent_hugepage/enabled
# 结果为aways:大页机制已启动。
# 结果为never:大页机制被禁止。
解决方法:直接关闭内存大页。生产中建议关闭内存大页:
echo never /sys/kernel/mm/transparent_hugepage/enabled
小结
本节主要说的是,非Redis本身带来的性能问题。即它的运行环境带来的问题。
下面是Redis性能变慢时的9个checklist,逐一检查即可:
- 获取 Redis 实例在当前环境下的基线性能。
- 是否用了慢查询命令?如果是的话,就使用其他命令替代慢查询命令,或者把聚合计算命令放在客户端做。
- 是否对过期 key 设置了相同的过期时间?对于批量删除的 key,可以在每个 key 的过期时间上加一个随机数,避免同时删除。
- 是否存在 bigkey删除? 对于 bigkey 的删除操作,如果你的 Redis 是 4.0 及以上的版本,可以直接利用异步线程机制减少主线程阻塞;如果是 Redis 4.0 以前的版本,可以使用 SCAN 命令迭代删除;对于 bigkey 的集合查询和聚合操作,可以使用 SCAN 命令在客户端完成。
- Redis AOF 配置级别是什么?业务层面是否的确需要这一可靠性级别?如果我们需要高性能,同时也允许数据丢失,可以将配置项 no-appendfsync-on-rewrite 设置为 yes,避免 AOF 重写和 fsync 竞争磁盘 IO 资源,导致 Redis 延迟增加。当然, 如果既需要高性能又需要高可靠性,最好使用高速固态盘作为 AOF 日志的写入盘。
- Redis 实例的内存使用是否过大?发生 swap 了吗?如果是的话,就增加机器内存,或者是使用 Redis 集群,分摊单机 Redis 的键值对数量和内存压力。同时,要避免出现 Redis 和其他内存需求大的应用共享机器的情况。
- 是否启用了透明大页机制?如果在 Redis 实例的运行环境中启用了,直接关闭内存大页机制就行了。
- 是否运行了 Redis 主从集群?如果是的话,把主库实例的数据量大小控制在 2~4GB,以免主从复制时,从库因加载大的 RDB 文件而阻塞。
- 是否使用了多核 CPU 或 NUMA 架构的机器运行 Redis 实例?使用多核 CPU 时,可以给 Redis 实例绑定物理核;使用 NUMA 架构时,注意把 Redis 实例和网络中断处理程序运行在同一个 CPU Socket 上。
如果遇到其他情况,就检查下Redis 所在的机器上有没有一些其他占内存、磁盘 IO 和网络 IO 的程序,比如说数据库程序或者数据采集程序。
总体的思路是:给 Redis 充足的计算、内存和 IO 资源,给它提供一个“安静”的环境。
提问
磁盘性能对Redis的性能有影响吗?为什么?如何解决?1.持久化为always时,主线程执行,性能也会变慢。2.即使不为aways,是后台执行,但本次执行,仍需要上次执行已结束,否则就会阻塞主线程。
内存太小对Redis的性能有什么影响?为什么?如何解决?1.内存太小,Swap操作频繁。2.内存大页,持久化时如果有大量写请求,则会造成大量写时复制,让Redis变慢。
Redis变慢了,排查思路?看下9个checklist,结合本篇和上一篇内容做回答。
20.删除数据后,为什么内存占用率还是很高?
业务场景:Redis删除数据,数据量已经不大了,用top命令查看,Redis还是占用了很多内存。这是因为,Redis释放的内存空间是由内存分配器管理的,不会立即返回给OS。
风险点:Redis空间释放的空间不是连续的,会有很多内存碎片。内存碎片太多时,会产生大量的闲置内存。(碎片太小,放不下数据,只能闲置)
本节主题:Redis 的内存空间存储效率问题。数据已经删了,空出来的空间却没法用(内存碎片),怎么排查和解决。
1.什么是内存碎片?
一般OS有内存空间闲置,往往就是内存碎片引起的。
内存碎片:应用申请的是一块连续地址空间的 N 字节,如果剩余的内存空间中没有大小为 N 字节的连续空间了,那么这些剩余空间就是内存碎片。
下面来看Redis的内存碎片是怎么形成的。
2.内存碎片是如何形成的?
内存碎片的形成原因,主要有有内因和外因。简单来说:
- 内因:操作系统的内存分配机制,内存分配器按固定大小分配内存。
- 外因: Redis 的负载特征,数据键值对大小不一,以及数据键值对随时在删改。
1.内因:内存分配器的分配策略
内存分配器的分配策略是按固定大小分配:这就就决定了OS无法做到“按需分配”。因为不是完全按照应用程序申请的内存空间大小给程序分配内存的。
Redis 可以使用 libc
、jemalloc
、tcmalloc
多种内存分配器来分配内存,默认使用 jemalloc。下面以 jemalloc 为例来说明,其他分配器也是一样的。
按固定大小分配是为了减少分配次数:jemalloc 的分配策略之一,是按照一系列固定的大小划分内存空间,例如 8 字节、16 字节、32 字节、48 字节,…, 2KB、4KB、8KB 等。当程序申请的内存最接近某个固定值时,jemalloc 会给它分配相应大小的空间。这样就可以减少分配次数(类似Hash表自增长?)。例如,Redis 申请一个 20 字节的空间保存数据,jemalloc 就会分配 32 字节,此时,如果应用还要写入 10 字节的数据,Redis 就不用再向操作系统申请空间了。
外因:由于是按固定大小分配,如果 Redis 每次向分配器申请的内存空间大小不一样,就会有形成碎片的风险。
2.外因:键值对大小不一样和删改操作
Redis导致的外因主要有两个:
- 键值对大小不一样:不同业务应用的数据会带来不同大小的键值对。
- 键值对修改和删除:这会导致空间的扩容和释放。修改键值对时,改大或改小会申请或释放空间。删除键值对时,会释放空间。
键值对的修改和删除,会导致空间的占用和挪用,数据位置也会发生改变。
以下是示意图,可以看到随着删改,应用B数据挪动了,应用C、D处产生了内存碎片。整个是一个动态的过程,数据之间的删改互相影响:
3.如何判断是否有内存碎片?
1.查看内存碎片指标
Redis 自身提供了 INFO 命令,可以用来查询内存使用的详细信息:
INFO memory
# Memory
used_memory:1073741736
used_memory_human:1024.00M
used_memory_rss:1997159792
used_memory_rss_human:1.86G
…
mem_fragmentation_ratio:1.86
内存碎片率:重点关注 mem_fragmentation_ratio
指标,它表示的就是 Redis 当前的内存碎片率。其实它就是两个指标相除:
mem_fragmentation_ratio = used_memory_rss / used_memory
used_memory_rss
指标:OS实际给 Redis 分配到的全部物理内存空间,里面包含了碎片。
used_memory
指标: Redis 为保存数据实际申请使用的内存空间。
例如,Redis 申请使用了 100 字节(used_memory),操作系统实际分配了 128 字节(used_memory_rss),此时,mem_fragmentation_ratio 是 1.28,表示多用了 0.28。
2.如何判断内存碎片是否太多
内存碎片指标判断标准如下,以下是经验值:
mem_fragmentation_ratio
在1 到 1.5之间:内存碎片量合理。内存碎片不可避免,毕竟,内因的内存分配器是一定要使用的,分配策略都是通用的,不会轻易修改;而外因由 Redis 负载决定,也无法限制。mem_fragmentation_ratio
大于 1.5 :内存碎片量过多。表明内存碎片率已经超过了 50%。一般情况下,此时需要采取一些措施来降低内存碎片率了。
4.如何清理内存碎片?
两个思路:
- 直接重启Redis实例。
- 4.0-RC3 版本后,Redis提供了自动清理的配置。
1.直接重启Redis实例
直接重启,简单粗暴直接。代价也很大:
- 可能丢失数据:如果 Redis 中的数据没有持久化,那么,数据就会丢失;
- 恢复时单个服务会中断:即使 Redis 数据持久化了,我们还需要通过 AOF 或 RDB 进行恢复,时长取决于 AOF 或 RDB 的大小,如果只有一个 Redis 实例,恢复阶段无法提供服务。
2.Redis自带清理机制
从 4.0-RC3 版本以后,Redis 自身提供了内存碎片自动清理的方法。
自动清理原理:简单来说,就是“搬家让位,合并空间”。不断地把不连续的空间,整理为连续空间。
类似JVM的内存整理算法。如下图所示,就是挪动下数据:
碎片清理是有代价的,主要是两方面的时间开销:数据拷贝,原空间释放。
碎片清理的影响,这些都会带来延迟:
- 可能阻塞Redis操作:因为 Redis 是单线程,在数据拷贝时,Redis 只能等着,这就导致 Redis 无法及时处理请求,性能就会降低。
- 数据拷贝有顺序性要求:如上图,需要先拷贝D,D空间释放后,再拷贝B。
3.内存碎片清理参数配置
为了减少内存碎片清理影响,可以配置一些参数。
内存碎片清理启用:
- 把
activedefrag
配置项设置为 yes:config set activedefrag yes
内存碎片清理触发大小,上面只是启用,以下两个同时满足时才触发:
- 配置内存碎片绝对大小:
active-defrag-ignore-bytes
100mb:表示内存碎片的字节数达到 100MB 时,开始清理; - 配置内存碎片相对比例:
active-defrag-threshold-lower
10:表示内存碎片空间,占操作系统分配给 Redis 的总空间的比例,达到 10% 时,开始清理。
内存碎片清理CPU占用时间限制:自动内存碎片清理功能在执行时,会监控清理操作占用的 CPU 时间。还设置了两个参数来其所占 CPU 时间比例的上、下限,这样可以避免降低 Redis 性能。两个参数具体如下:
- 配置清理操作占用CPU时间比例下限:
active-defrag-cycle-min
25: 表示自动清理过程所用 CPU 时间的比例不低于 25%,保证清理能正常开展; - 配置清理操作占用CPU时间比例上限:
active-defrag-cycle-max
75:表示自动清理过程所用 CPU 时间的比例不高于 75%,超过时就停止清理,从而避免大量的内存拷贝阻塞 Redis,导致响应延迟升高。
总共五个参数:是否启用,触发绝对大小,触发相对比例,触发时执行时间下限,触发时执行时间上限。根据需要,灵活调整。
小结
识别和处理内存碎片。简单来说,就是“三个一”:
- info memory 命令是一个好工具,可以帮助你查看碎片率的情况;
- 碎片率阈值是一个好经验,可以帮忙你有效地判断是否要进行碎片清理了;
- 内存碎片自动清理是一个好方法,可以避免因为碎片导致 Redis 的内存实际利用率降低,提升成本收益率。
注意:内存碎片自动清理涉及内存拷贝,可能会阻塞Redis。如果你在实践过程中遇到 Redis 性能变慢,记得通过日志看下是否正在进行碎片清理。如果 Redis 的确正在清理碎片,那么建议调小 active-defrag-cycle-max 的值,以减轻对正常请求处理的影响。
提问
Redis为什么会产生内存碎片?内因,内存分配器按固定大小分配内存。外因,数据键值对大小不一,以及数据键值对随时在删改。
怎么看Redis内存碎片?多大算太多?怎么处理,有哪些配置项?
如果 mem_fragmentation_ratio 小于 1 了,Redis 的内存使用是什么情况呢?会对 Redis 的性能和内存空间利用率造成什么影响?mem_fragmentation_ratio小于1,说明used_memory_rss小于了used_memory,这意味着操作系统分配给Redis进程的物理内存,要小于Redis实际存储数据的内存,也就是说Redis发生swap,严重降低读写性能。
21.缓冲区:一个可能引发“惨案”的地方
缓冲区的功能:主要就是用一块内存空间来暂时存放命令和数据,防止处理速度太慢、发送速度太快,导致数据丢失。
缓冲区的风险:写入速度持续大于处理速度,达到缓冲区上限时,可能导致缓冲区溢出而会丢数据。如果缓冲区上限设置太大,占用内存过多时,甚至可能把Redis实例搞挂。
缓冲区主要运用场景:
- 客户端与服务端通信:暂存客户端发送的命令和数据,或者暂存服务器端返回给客户端的数据。
- 主从节点同步:暂存主节点接收的写命令和数据。
本篇主题:Redis 中缓冲区的用法。服务器端和客户端、主从集群间的缓冲区溢出问题,以及应对方案。
1.服务器端为客户端提供的输入和输出缓冲区
客户端输入缓冲区和输出缓冲区:服务器端给每个连接的客户端都设置了一个输入缓冲区和输出缓冲区,以解决请求和处理速度的不匹配。
处理过程:
- 客户端发送过来的命令暂存到输入缓冲区
- Redis 主线程从输入缓冲区中读取命令进行处理。
- Redis 主线程把结果写入到输出缓冲区。
- 缓冲区数据发送到客户端。
整体如下图所示。
下面,分别来看,输入缓冲区和输出缓冲区溢出的情况。
2.如何应对输入缓冲区溢出?
暂存客户端的数据和命令。
1.溢出原因
输入缓冲区溢出主要原因:
- 写入太快:写入了 bigkey:比如一下子写入了多个百万级别的集合类型数据。
- 处理太慢:服务器端处理请求的速度过慢:例如,Redis 主线程出现了间歇性阻塞,无法及时处理正常发送的请求,导致客户端发送的请求在缓冲区越积越多。
2.如何查看输入缓冲区的内存使用情况
使用 CLIENT LIST
命令:可查看服务端为每个客户端分配的输入缓冲区使用情况:
CLIENT LIST
id=5 addr=127.0.0.1:50487 fd=9 name= age=4 idle=0 flags=N db=0 sub=0 psub=0 multi=-1 qbuf=26 qbuf-free=32742 obl=0 oll=0 omem=0 events=r cmd=client
重点信息解析:
- 连接的客户端信息:addr=127.0.0.1:50487。如果是多个客户端的话,端口号会不一样。
- 输入缓冲区相关参数,重要的有三个:
cmd
:表示客户端最新执行的命令。上例中执行的是 CLIENT 命令。qbuf
:表示输入缓冲区已使用空间大小。上例中的 CLIENT 命令已使用了 26 字节大小的缓冲区。qbuf-free
:表示输入缓冲区剩余空间大小。上例中的 CLIENT 命令还可以使用 32742 字节的缓冲区。qbuf 和 qbuf-free 的总和就是服务器端为已连接的这个客户端分配的缓冲区总大小。这个例子中总共分配了 26 + 32742 = 32768 字节,也就是 32KB 的缓冲区。
如果 qbuf 很大,而同时 qbuf-free 很小,很可能后续就会溢出了。
单个缓冲区溢出:Redis处理的方法是,直接关闭该客户端连接。
多个缓冲区溢出:
- 超过配置项,引发数据淘汰:这种情况的意思是,有多个客户端进行连接,大家都没溢出。但是大家加起来的总量,超过 Redis 的 maxmemory 配置项了。处理方法:会触发 Redis 进行数据淘汰。
- 超过总内存,导致Redis崩溃:多个客户端导致 Redis 内存占用过大,也会导致内存溢出(out-of-memory)而会引起 Redis 崩溃。
3.如何避免输入缓冲区溢出
从两个角度考虑:
- 把缓冲区调大:无法调节。Redis 代码里面写死了1GB,没有提供调节参数。即服务器端允许为每个客户端最多暂存 1GB 的命令和数据。
- 控制数据发送和处理速度:
- 控制生产:避免客户端写入 bigkey。
- 控制消费:避免 Redis 主线程阻塞。
3.如何应对输出缓冲区溢出?
1.溢出原因
输出缓冲区主要暂存的 Redis 主线程要返回给客户端的数据。数据量有大有小,如 OK 响应(例如,执行 SET 命令)或报错信息的小数据,和HGET的大数据。
输出缓冲区设计:为了提高效率,Redis的输出缓冲区分为了两部分:
- 一个大小固定为 16KB 的缓冲空间:暂存 OK 响应和出错信息;
- 一个可动态增加的缓冲空间:暂存大小可变的响应结果。
其中,以下三种溢出情况如下:
- 服务器端返回 bigkey 的大量结果。
- 执行了
MONITOR
命令。 - 缓冲区大小设置不合理。
下面详细看下都怎么处理。
2.溢出处理:bigkey
bigkey 原本就会占用大量的内存空间,没啥办法。
3.溢出处理:MONITOR 命令
MONITOR 命令:用来检测 Redis 执行情况,该命令执行后,会持续输出检测到的各个命令操作。
MONITOR
OK
1600617456.437129 [0 127.0.0.1:50487] "COMMAND"
1600617477.289667 [0 127.0.0.1:50487] "info" "memory"
很明显,MONITOR 的输出结果会持续占用输出缓冲区,且越占越多,最后就导致发生溢出。
处理建议:生产环境中不要持续使用 MONITOR,最多只能偶尔用下,检查Redis 的命令执行情况。
4.溢出处理:输出缓冲区大小设置
和输入缓冲区大小被写死不同,输出缓冲区是可以设置大小的。
可通过 client-output-buffer-limit
配置项来设置缓冲区的大小:
- 设置缓冲区的大小上限阈值;
- 设置输出缓冲区持续写入数据的数量上限阈值,和持续写入数据的时间上限阈值。
具体设置时,需要区分客户端类型,主要两种:
- 普通客户端:常规和 Redis 服务器端进行读写命令交互。
- 订阅客户端:订阅了 Redis 频道。
- 主节点上的从节点客户端:Redis 主从集群,用来和从节点进行数据同步。后面主从集群中缓冲区部分会详细介绍。
给普通客户端设置缓冲区大小:直接在 Redis 配置文件中设置即可。
普通客户端设置:是阻塞式发送,执行完上一条命令,才执行下一条命令。所以一般数据量不大的话,输出缓冲区一般不会被阻塞。所以一般全部设成0,即不做限制。
client-output-buffer-limit normal 0 0 0
# normal:当前的客户端是普通客户端
# 第 1 个 0:设置的是缓冲区大小限制
# 第 2 个 0 :缓冲区持续写入大小限制
# 第 3 个 0 :缓冲区持续写入时间限制
订阅客户端设置:非阻塞式发送。因为一旦订阅的Redis频道有消息,服务端就把通过输出缓冲区把消息发给客户端。所以一般配置如下:
client-output-buffer-limit pubsub 8mb 2mb 60
# pubsub :表示当前是对订阅客户端进行设置
# 8mb :输出缓冲区的大小上限为 8MB,一旦实际占用的缓冲区大小超过 8MB,服务器端就会直接关闭客户端的连接;
# 2mb 和 60 :每个客户端连接,如果连续 60 秒内对输出缓冲区的写入量超过 2MB ,服务器端就会关闭客户端连接。
总结,如何应对输出缓冲区溢出:
- 避免 bigkey 操作返回大量数据结果;
- 避免在线上环境中持续使用 MONITOR 命令。
- 使用
client-output-buffer-limit
设置合理的缓冲区大小上限,或是缓冲区连续写入时间和写入量上限。其中普通客户端、订阅客户端分开配置,因为一个是阻塞式,一个是非阻塞式。
4.主从集群中的缓冲区
主从集群间的数据复制包括全量复制和增量复制两种:
- 全量复制:同步所有数据
- 增量复制:只把主从库网络断连期间主库收到的命令,同步给从库
两种方式都会用到缓冲区,不过在溢出影响和大小设置方面不一样。
1.全量复制:复制缓冲区的溢出问题
全量复制缓冲区,本质上就是一个输出缓冲区。
全量复制场景:主节点在向从节点传输 RDB 文件,传输期间,主节点接收到的写命令先保存到复制缓冲区,待 RDB 文件传输完成后,再发送给从节点执行。
主节点上会为每个从节点都维护一个复制缓冲区,如下图所示:
全量复制缓冲区溢出风险:从节点接收和加载RDB过慢,且主节点接收到大量写命令,那么就可能溢出。复制缓冲区一旦发生溢出,主节点就会直接关闭和从节点进行复制操作的连接,导致全量复制失败。
如何避免:
- 控制主节点保存的数据量大小。主节点的数据量经验值是 2~4GB。
- 使用
client-output-buffer-limit
配置项,来合理设置复制缓冲区大小。设置依据是,主节点的数据量大小、主节点的写负载压力和主节点本身的内存大小。
配置举例:
config set client-output-buffer-limit slave 512mb 128mb 60
# slave:表明该配置项是针对复制缓冲区
# 512mb :将缓冲区大小的上限设置为 512MB;
# 128mb 和 60 :如果连续 60 秒内的写入量超过 128MB ,就触发缓冲区溢出。
# 数据量估算:假设一条写命令数据是 1KB,那么,复制缓冲区可以累积 512K 条(512MB/1KB = 512K)写命令。同时,主节点在全量复制期间,可以承受的写命令速率上限是 2000 条 /s(128MB/1KB/60 约等于 2000)。
操作步骤:
- 估算负载情况:根据写命令数据的大小和应用的实际负载情况(也就是写命令速率),粗略估计缓冲区中会累积的写命令数据量。
- 设置复制缓冲区大小:根据估算情况,和设置的复制缓冲区大小作比较。
此外,即使某个节点不溢出,但是多个节点相加也可能溢出,所以要控制节点数量。
总结,如何应对主从全量复制时缓冲区溢出:
- 主节点保存的数据量不要太大:2-4G即可。
- 并设置合理的复制缓冲区大小:可根据业务量估算。
- 控制从节点的数量:避免主节点中复制缓冲区占用过多内存的问题。
2.增量复制:复制积压缓冲区的溢出问题
增量复制时使用的缓冲区,称为复制积压缓冲区。
复制积压缓冲区:为了防止节点中断数据丢失,常规主从复制期间,主节点在把接收到的写命令同步给从节点时,会同时把这些写命令写入到复制积压缓冲区。这样,当主从节点断开,然后再次恢复连接时,从节点就可以从复制积压缓冲区中,读取断连期间主节点接收到的写命令,进而进行增量同步,如下图所示:
其实就是我们前面第6篇学到的 repl_backlog_buffer
。
复制积压缓冲区溢出的影响:复制积压缓冲区是一个大小有限的环形缓冲区。当主节点把复制积压缓冲区写满后,主节点会继续覆盖缓冲区中的旧命令数据。此时如果从节点还没有同步这些旧命令数据,就会造成主从节点间重新开始执行全量复制。
如何应对复制积压缓冲区的溢出:调整复制积压缓冲区的大小即可,也就是设置 repl_backlog_size
参数的值。具体的调整依据和计算方法,参考第6篇内容。
小结
从缓冲区溢出对 Redis 的影响的角度,我们把四个缓冲区分成两类做总结:
- 缓冲区溢出导致网络连接关闭:普通客户端、订阅客户端,以及从节点客户端,它们使用的缓冲区,本质上都是 Redis 客户端和服务器端之间,或是主从节点之间为了传输命令数据而维护的。这些缓冲区一旦发生溢出,处理机制都是直接把客户端和服务器端的连接,或是主从节点间的连接关闭。网络连接关闭造成的直接影响,就是业务程序无法读写 Redis,或者是主从节点全量同步失败,需要重新执行。
- 缓冲区溢出导致命令数据丢失:主节点上的复制积压缓冲区属于环形缓冲区,一旦发生溢出,新写入的命令数据就会覆盖旧的命令数据,导致旧命令数据的丢失,进而导致主从节点重新进行全量复制。
从本质上看,缓冲区溢出及处理方案如下,总共三个原因:
- 命令数据发送过快过大:
- 普通客户端:避免 bigkey。
- 复制缓冲区:避免过大的 RDB 文件。
- 命令数据处理较慢:减少 Redis 主线程上的阻塞操作,如使用异步的删除操作。
- 缓冲区空间过小:
- 输出缓冲区、复制缓冲区和复制积压缓冲区:使用 client-output-buffer-limit 配置项设置合理大小。
- 输入缓冲区:大小默认是固定的,无法通过配置修改,除非修改 Redis 源码。
提问
Redis内存满了怎么办?
Redis 内部有哪些地方使用了缓冲区?是怎么设计的?缓冲区溢出有何影响?如何应对?输入缓冲区、输出缓冲区、复制缓冲区(全量复制,用来临时存主节点写命令)和复制积压缓冲区(增量复制,环形区域,用来存发送给从节点的命令数据,从节点断连后重连时,可快速恢复)
22.第11~21讲课后思考题答案及常见问题答疑(暂略待补充)
小结
提问
实践篇:缓存(23-28)
Redis 业务中经常用来做缓存。如果 Redis 做缓存时出现了问题,比如说缓存失效,DB的压力是很大的,甚至可能宕机。
所以,我们需要系统地掌握缓存的一系列内容,包括工作原理、替换策略、异常处理和扩展机制。
所以,本篇阐述以下4个关键问题:
- Redis 作为缓存具体如何工作?
- Redis 缓存如果满了怎么办?
- Redis 异常如何处理?即缓存一致性、缓存穿透、缓存雪崩、缓存击穿如何应对?
- Redis 缓存如何扩展?Redis的内存毕竟有限,如果用快速的固态硬盘来保存数据,可以增加缓存的数据量,那么,Redis 缓存可以使用快速固态硬盘吗?
23.旁路缓存:Redis是如何工作的?
本节主题:了解下缓存的特征、 Redis 适用于缓存的天然优势、 Redis 缓存的具体工作机制。
1.缓存的特征
缓存的意义:一个系统中的不同层之间的访问速度不一样,可以把一些需要频繁访问的数据放在缓存中,以加快它们的访问速度。
以计算机系统为例,下图是计算机系统中的三层存储结构,以及它们各自的常用容量和访问性能:
如果CPU每次执行都要等着从磁盘读数据,那么整个系统速度就被拖累了。
所以,计算机系统中默认有两种缓存:
- CPU 里面的末级缓存:LLC,用来缓存内存中的数据,避免每次从内存中存取数据。
- 内存中的高速页缓存:page cache,用来缓存磁盘中的数据,避免每次从磁盘中存取数据。
以上,得到以下结论:
- 缓存的第一个特征:在一个层次化的系统中,缓存一定是一个快速子系统。这也是为啥要想法设法让 Redis 提供高性能的访问,因为如果访问速度很慢,Redis 作为缓存的价值就不大了。
- 缓存的第二个特征:缓存系统的容量大小总是小于后端慢速系统的,我们不可能把所有数据都放在缓存系统中。所以,缓存和后端慢速系统之间,必然存在数据写回和再读取的交互过程。简单来说,缓存中的数据需要按一定规则淘汰出去,所以会有淘汰策略,写回后端系统。而新的数据又要从后端系统中读取进来,写入缓存。
下面来看缓存如何处理请求。
2.Redis 缓存处理请求的两种情况
把 Redis作为缓存时,我们是把它放到DB前面。业务访问是,有两种情况:
- 缓存命中:Redis 中有相应数据,直接从 Redis 读取数据,性能非常快。
- 缓存缺失:Redis 中没有保存相应数据,需要从后端 DB读取数据,性能就会变慢。而且,一旦发生缓存缺失,为了让后续请求能从缓存中读取到数据,我们需要把缺失的数据写入 Redis,这个过程叫作缓存更新。缓存更新操作会涉及到保证缓存和数据库之间的数据一致性问题,关于这一点,后续会在第 25 讲中再具体介绍。
如下图所示:
外部访问数据时,基本有三个操作:
- 应用读取数据时,需要先读取 Redis;
- 发生缓存缺失时,需要从DB读取数据;
- 发生缓存缺失时,还需要更新缓存。
上述三个操作由谁来完成呢?具体看Redis 缓存的使用方式。
下面介绍 Redis 作为旁路缓存的使用操作方式。
3.Redis 作为旁路缓存的使用操作
旁路缓存:如果应用程序想要使用 Redis 缓存,需要在程序中增加相应的缓存操作代码。所以,我们也把 Redis 称为旁路缓存,也就是说,读取缓存、读取数据库和更新缓存的操作都需要在应用程序中完成,应用程序调用Redis来执行操作。和前面的LLC、pageCache不一样,它们不需要应用程序显式调用。
对应上面的三个操作,我们需要在应用程序中增加三方面的代码:
- 应用程序读取数据:在代码中显式调用 Redis 的 GET 操作接口,进行查询;
- 发生缓存缺失:应用程序需要和 DB 连接,从 DB 中读取数据;
- 缓存中的数据需要更新时:在应用程序中显式地调用 SET 操作接口,把更新的数据写入缓存。
伪代码示例:
String cacheKey = “productid_11010003”;
String cacheValue = redisCache.get(cacheKey);
//缓存命中
if ( cacheValue != NULL)
return cacheValue;
//缓存缺失
else
cacheValue = getProductFromDB();
redisCache.put(cacheValue) //缓存更新
可以看到,通过一个表示缓存系统的实例对象 redisCache 来进行相关操作。
旁路缓存的特点:
- 不足:需要在应用程序中增加操作代码,增加了使用 Redis 缓存的额外工作量。
- 优点:旁路缓存是一个独立的系统,我们可以单独对 Redis 缓存进行扩容或性能优化。
上面讲了Redis的读操作。那么写操作怎么使用呢?下面来看下。
4.缓存的类型
除了从 Redis 缓存中查询、读取数据,应用程序还可能会对数据进行修改。此时,我们既可以在缓存中修改,也可以在后端数据库中进行修改,该如何选择?
涉及到了 Redis 缓存的两种类型:
- 只读缓存:加速读请求
- 读写缓存:同时加速读写请求。提供了两种写回策略,根据业务来取舍。
下面具体看下。
1.只读缓存
读操作在Redis处理,懒加载。写操作在DB处理,同时删除Redis旧数据。最新的数据在DB中。
操作过程:
- 读操作:调用 Redis GET 接口,查询数据是否存在:
- 缓存命中:直接从Redis取回。
- 缓存缺失:把数据从DB中读出来,并写到缓存中。
- 写操作:
- 直接到DB删改数据即可。
- 如果Redis已经缓存这些数据,需要把缓存上的旧数据删除。
如下图所示:
只读缓存特点:最新的数据都在DB中,数据没有丢失风险。适用于缓存图片、短视频等用户只读数据。
2.读写缓存
读写操作都在Redis处理,最新的数据在Redis中,根据可靠性要求,写回策略有两种:
- 读操作:调用Redis GET接口,查询数据是否存在
- 缓存命中:直接从Redis取回。
- 缓存缺失:把数据从DB中读出来,并写到缓存中。(已有数据,修改时是直接修改,不是删除。这点和只读缓存有细微差别。)
- 写操作
- 同步直写:写请求发给Redis,同时发给DB。Redis和DB都写完,才返回结果。可靠性优先。DB也保存了最新数据,可靠性高,性能差一些。
- 异步写回:写请求全部先在Redis处理,等到这些增改的数据要被从Redis中淘汰出来时,Redis再将它们写回DB。响应性优先,有数据丢失风险。
总体如下图所示:
关于是选择只读缓存,还是读写缓存,主要看对写请求是否有加速的需求:
- 需要对写请求进行加速:读写缓存。
- 写请求很少,或只需提升读请求性能:只读缓存。
举例,在商品大促的场景中,商品的库存信息会一直被修改。如果每次修改都需到数据库中处理,就会拖慢整个应用,此时,我们通常会选择读写缓存的模式。而在短视频 App 的场景中,虽然视频的属性有很多,但是,一般确定后,修改并不频繁,此时,在数据库中进行修改对缓存影响不大,所以只读缓存模式是一个合适的选择。
小结
缓存的两个特征:
- 在分层系统中,数据暂存在快速子系统中有助于加速访问。
- 缓存容量有限,缓存写满时,数据需要被淘汰。
旁路缓存:需要在应用程序中新增缓存逻辑处理的代码。
Redis 做缓存的两种模式:
- 只读缓存:加强读请求性能。查询数据时,缓存缺失需要从DB加载。更新数据时到DB更新,Redis上的老数据直接删除。
- 读写缓存:读操作和只读缓存一样。写操作分为同步直写和异步写回两种模式,根据实际的业务场景需求来进行选择:
- 同步直写模式:Redis和DB同时删改写回。侧重于保证数据可靠性。
- 异步写回模式:只写Redis,数据要被淘汰时再写回DB。侧重于提供低延迟访问。
此外,我们提到了 Redis 有数据淘汰机制,但并没有具体展开讲。下一节具体介绍。
提问
你们Redis缓存是怎么用的?只读还是读写?有什么区别?
Redis 的只读缓存和使用直写策略的读写缓存,都会把数据同步写到后端数据库中,它们有什么区别吗?当有缓存数据被修改时,在只读缓存中,业务应用会直接修改数据库,并把缓存中的数据标记为无效。在读写缓存中,业务应用需要同时修改缓存和数据库。
24.替换策略:缓存满了怎么办?
把所有数据都放到缓存,性价比不高。20%的数据就能解决80%的业务需求。
数据不会全部缓存,那么就涉及了缓存数据的淘汰机制,简单来说,包括两步:
- 选出数据:根据一定的策略,筛选出对应用访问来说“不重要”的数据。
- 删除数据:将这些数据从缓存中删除,为新来的数据腾出空间。
本节主题:缓存满了之后的数据淘汰机制,也叫缓存替换机制。
在了解淘汰机制之前,我们需要先知道缓存设置多少合适,也就是设置缓存容量的依据和方法。
1.设置多大的缓存容量合适?
实际应用中的数据访问是具有局部性的,简单来说,就是数据的访问是集中的。
如下图所示:
长尾效应:图中蓝线所示,20% 的数据贡献了 80% 的访问,剩余的数据虽然体量很大,但只贡献了 20% 的访问量。这 80% 的数据在访问量上就形成了一条长长的尾巴,所以叫长尾效应。
重尾效应:图中红线所示。这时近年来一些研究人员专门对互联网应用(例如视频播放网站)中,用户请求访问内容的分布情况做出的分析。他们发现,80% 的数据贡献的访问量,超过了传统的长尾效应中 80% 数据能贡献的访问量。原因是,现在的应用,用户的个性化需求越来越多,在一个业务应用中,不同用户访问的内容可能差别很大。也就是说,20% 的数据可能贡献不了 80% 的访问,而剩余的 80% 数据反而贡献了更多的访问量,我们称之为重尾效应。
具体实践:不能简单地按照20%来设置,现实中,可能缓存容量占总数据量的比例,从 5% 到 40% 的都有。需要结合应用数据实际访问特征和成本开销来综合考虑。一般来说,建议把缓存容量设置**为总数据量的 15% 到 30%**,兼顾访问性能和内存空间开销。
Redis可以用CONFIG SET
命令来设置缓存大小maxmemory
:
CONFIG SET maxmemory 4gb
当然,缓存被写满是不可避免的。所以我们接下来看缓存淘汰策略,即:决定淘汰哪些数据,如何处理那些被淘汰的数据。
2.Redis 缓存有哪些淘汰策略?
总共8种淘汰策略:
- 不进行数据淘汰的策略,1种: noeviction
- 会进行数据淘汰的策略:
- 在设置了过期时间的数据中进行淘汰,4种:volatile-ttl、volatile-random、volatile-lru、volatile-lfu。
- 在所有数据范围内进行淘汰,3种:allkeys-random、allkeys-lru、allkeys-lfu。
总体如下图所示:
下面详细解释。
1.noeviction 策略
淘汰逻辑:默认策略。内存空间超过 maxmemory 值时,不淘汰数据。缓存被写满后,再有写请求来时,直接返回错误,Redis 不再提供写服务。
2.淘汰有标记过期的数据
淘汰逻辑:筛选的候选数据范围,被限制在已经设置了过期时间的键值对上。用EXPIRE
命令设置过期时间。包括volatile-random、volatile-ttl、volatile-lru 和 volatile-lfu 四种策略。如果缓存写满了,即内存空间超过 maxmemory 值时,即使没到过期时间,也可能被淘汰:
volatile-ttl
:在设置了过期时间的键值对中,根据过期时间的先后进行删除,越早过期的越先被删除。volatile-random
:在设置了过期时间的键值对中,进行随机删除。volatile-lru
:在设置了过期时间的键值对中,使用 LRU 算法筛选删除。volatile-lfu
:在设置了过期时间的键值对中, 使用 LFU 算法筛选删除。
LRU算法: Least Recently Used,最近最少使用。后文详解。
LFU算法:是在 LRU 算法的基础上,同时考虑了数据的访问时效性和数据的访问次数,可以看作是对淘汰策略的优化。
3.淘汰所有范围的数据
淘汰逻辑:数据筛选范围,扩大为所有的键值对。包括 allkeys-lru、allkeys-random、allkeys-lfu 三种策略。无论有没有设置过期时间,只要缓存写满了,在全数据范围筛选淘汰:
allkeys-random
:在所有键值对中,随机选择并删除数据;allkeys-lru
:在所有键值对中,使用 LRU 算法筛选删除。allkeys-lfu
:在所有键值对中,使用 LFU 算法筛选删除。
4.LRU算法
LRU 算法:全称 Least Recently Used,按照最近最少使用的原则来筛选数据。
具体实现: 会把所有的数据组织成一个链表,链表的头和尾分别表示 MRU 端和 LRU 端,即代表最近最常使用的数据和最近最不常用的数据。如下图所示:
如上图:
- 当有数据被访问时:把它移到链表最前端,即MRU端,其他数据往后挪一位。如上图20、3所示。
- 当新数据要写入,而缓存已满时:把新数据放到MRU端,淘汰LRU端最后一位。如上图15、5所示。
LRU 算法背后的想法非常朴素:它认为刚刚被访问的数据,肯定还会被再次访问,所以就把它放在 MRU 端;长久不访问的数据,肯定就不会再被访问了,所以就让它逐渐后移到 LRU 端,在缓存满时,就优先删除它。
5.Redis对LRU算法的简化
LRU算法有开销:LRU 算法在实际实现时,需要用链表管理所有的缓存数据,所以会带来额外的空间开销。而且数据被访问时,需要移动它的位置,如果有大量数据被范围,就需要频繁进行链表移动,会降低Redis性能。所以Redis进行了简化。
Redis 中的 LRU 算法简化,简单来说就是增加了一个随机选取候选集合的操作:
- 记录每个数据的访问时间:Redis 默认会记录每个数据的最近一次访问的时间戳(由键值对数据结构 RedisObject 中的 lru 字段记录)。
- 随机选取一个集合:Redis 在决定淘汰的数据时,第一次会随机选出 N 个数据,把它们作为一个候选集合。
- 淘汰集合中lru字段最小的数据:接下来,Redis 会比较这 N 个数据的 lru 字段,把 lru 字段值最小的数据从缓存中淘汰出去。
- 需要再次淘汰数据时:选取集合外,lru 字段值小于候选集合中最小的 lru 值的数据,进入候选集合,直到选满集合。重复步骤3。
这样,Redis就不用维护一个大链表了,也不用频繁的进行链表数据交换操作。
候选集合的个数,可以通过配置参数 maxmemory-samples
来设置:
# 让 Redis 选出 100 个数据作为候选数据集
CONFIG SET maxmemory-samples 100
6.总体使用建议
以下是各个策略的一些实际使用建议:
- 优先使用 allkeys-lru 策略:这样可以充分利用 LRU 这一经典缓存算法的优势,把最近最常访问的数据留在缓存中,提升应用的访问性能。如果你的业务数据中有明显的冷热数据区分,我建议你使用 allkeys-lru 策略。
- 如果业务应用中的数据访问频率相差不大:没有明显的冷热数据区分,则建议使用 allkeys-random 策略,随机选择淘汰的数据就行。
- 如果业务中有置顶的需求:比如置顶新闻、置顶视频,那么,可以使用 volatile-lru 策略,同时不给置顶数据设置过期时间。这样一来,这些需要置顶的数据一直不会被删除,而其他数据会在过期时根据 LRU 规则进行筛选。
数据淘汰后怎么处理呢?下面来看下。
3.如何处理被淘汰的数据?
淘汰数据,分情况处理:
- 干净数据:直接删除。
- 脏数据:写回数据库。
如下图所示:
如何判断数据是干净数据还是脏数据:
- 干净数据:和最初从后端数据库里读取时的值相比没有被修改,所以DB里是最新值。
- 脏数据:曾经被修改过的,已经和后端数据库中保存的数据不一致。此时Redis是最新值,所以需要同步到DB。
但是,对于Redis来说,数据淘汰后是立即删除,不会做处理。所以,我们在使用 Redis 缓存时,如果数据被修改了,需要在数据修改时就同步把它写回DB。
小结
8 种数据淘汰策略,从淘汰数据的候选集范围来看,我们有两种候选范围:
- 一种是所有数据都是候选集,
- 一种是设置了过期时间的数据是候选集。
淘汰策略:两种候选集,都有三种策略,分别是随机选择,根据 LRU 算法选择,以及根据 LFU 算法选择。
淘汰数据处理:使用 Redis 缓存时,如果数据被修改了,需要在数据修改时就同步把它写回DB。避免数据成为脏数据,Redis是直接删除,不会处理脏数据。
缓存策略使用建议:先根据是否有始终会被频繁访问的数据(例如置顶消息),来选择淘汰数据的候选集,也就是决定是针对所有数据进行淘汰,还是针对设置了过期时间的数据进行淘汰。候选数据集范围选定后,建议优先使用 LRU 算法,也就是,allkeys-lru 或 volatile-lru 策略。
缓存大小选取建议:结合实际应用的数据总量、热数据的体量,以及成本预算,设置在总数据量的 15% 到 30% 这个区间。
提问
设置多大的缓存合适?怎么设置?根据长尾分布和重尾分布原理,以及具体业务,设置15%-30%。
Redis有哪些数据淘汰策略?LRU算法是什么?Redis怎么实现的?Redis进行了一个简化,加了一个随机选取候选集合的操作。
25.缓存异常(上):如何解决缓存和数据库的数据不一致问题?
实际使用Redis常遇到的4个问题:缓存数据和数据库不一致、缓存雪崩、缓存击穿、缓存穿透。
本篇主题:缓存数据和数据库不一致问题。其他三个问题下一节解答。
1.缓存和数据库的数据不一致是如何发生的?
1.什么是缓存的数据一致性
数据的一致性包含两层含义:
- 缓存中有数据时:缓存数据要和DB数据一致。
- 缓存中没数据时:DB的值必须是最新的。
不符合上述情况,就出现缓存不一致了。
不同的读写模式,发生缓存不一致的情况不一样。下面我们分模式讨论。根据是否接收写请求,缓存可以分成读写缓存和只读缓存。
2.读写缓存模式的数据不一致
数据不一致,肯定是写操作导致的。
读写缓存写操作:数据增删改是在缓存里,根据两种写回策略,决定是否写回DB:
- 同步直写策略:同时写缓存、写数据库,缓存和DB数据一致。
- 异步写回策略:先写缓存,待数据从缓存淘汰时,才写回DB。写回DB前如果缓存挂了,就会导致数据不一致。
所以,对于读写缓存,如果要保证数据一致,就要用同步直写策略。此时需要要在业务应用中使用事务机制,来保证缓存和 DB 的更新具有原子性。
当然,如果对数据的一致性要求不高,如对商品短视频的修改,那么可以使用异步写回策略。
3.只读缓存模式的数据不一致
只读缓存逻辑:
- 写操作:数据直接写入DB。同时,如果缓存中有该数据,则删除之。
- 读操作:缓存有数据则从缓存读,没数据则从DB读,同时把数据加载到缓存。
只读缓存写操作:会有数据不一致性:
- 新增数据:数据直接写进DB,不操作缓存。缓存没数据,DB数据为最新,符合前述第二种情况,数据一致。
- 删改数据:删改DB,同时删除缓存。需要保证两个步骤的原子性,数据才一致。
- 删除缓存成功,删改DB失败:DB是旧数据,缓存为空。再次读时,从DB加载到旧数据。数据不一致。
- 删改缓存失败,删改DB成功:DB是新数据,缓存是旧数据。数据不一致。
上述描述的够清楚了,还不理解的话参照下图所示:
只读缓存,数据删改操作,如下图:
只读缓存,缓存删除成功,DB更新失败,如下图:
只读缓存,缓存删除失败,DB更新成功,如下图:
只读缓存不一致问题,如下图:
2.如何解决数据不一致问题?
使用重试机制。
操作方法:把要删除的缓存数据、或是要删改的数据库值,放到MQ。再进行删改操作,成功则移除MQ中的消息,失败则从MQ读取值,再次进行删改操作。
1.单线程情况下,单个操作失败导致数据不一致示例
单线程,一个操作不成功,导致数据不一致。
以下举例,单线程时,更新DB成功,删除缓存失败,再次尝试后删除缓存成功。如下图所示:
相当于是,删除DB成功后,把删除缓存的消息丢到MQ,让应用消费。(中间状态怎么办?此时是弱一致,不是强一致的)
实际上,即使更新DB和删除缓存两个操作都成功了,当有大量并发请求时,还是可能读到不一致的数据。
即,多线程,两个操作都成功,数据还是不一致。下面分别介绍。
2.多线程情况一:先删除缓存,再更新数据库
业务场景:
- 线程A删除缓存
- 线程B读数据(发现数据缺失)
- 线程B去数据库读旧数据
- 线程B把旧数据加载到缓存
- 线程A将新数据更新到数据库
- 产生数据不一致:DB是新值、缓存是旧值。
如下图所示:
解决方案:延迟双删,相当于是把其他线程更新的缓存旧值再次删掉。即在线程 A 更新完数据库值以后,让它先 sleep 一小段时间,再进行一次缓存删除操作。sleep时间根据统计下线程读数据和写缓存的操作时间,以此为基础来进行估算。(sleep多久??有不确定性)
延迟双删伪代码示例:
redis.delKey(X)
db.update(X)
Thread.sleep(N)
redis.delKey(X)
3.多线程情况二:先更新数据库,再删除缓存
业务场景:
- 线程 A 删除数据库值。
- 线程 B 读缓存,读到旧值。产生数据不一致。
- 线程 A 删除缓存值。
不过一般由于线程A马上就删除缓存值了,所以对业务影响小一点。
具体如下图所示:
以上。
总结下缓存和数据库的数据不一致的两个原因,以及解决方案:
单线程删除失败导致一致:删除缓存值或更新数据库失败而导致数据不一致。解决方案:可以使用重试机制确保删除或更新操作成功。
多线程并发读到旧数据:在删除缓存值、更新数据库的这两步操作中,有其他线程的并发读操作,导致其他线程读取到旧值。解决方案:延迟双删。
(后续还需要再仔细研究下。上述方案并未做到绝对杜绝脏数据)
小结
数据一致性:
- 读写缓存写操作:用事务、同步直写来保证数据一致性。异步写回无法保证。
- 只读缓存写操作:用事务保证删改操作的原子性。新增数据是数据一致的。
总体原因及应对方案如下图所示:
补图。
在大多数业务场景下,我们会把 Redis 作为只读缓存使用。
对于只读缓存来说,我们既可以先删除缓存值再更新DB,也可以先更新DB再删除缓存。建议优先使用先更新数据库再删除缓存,原因主要有两个:
- 删除缓存后DB压力大:先删除缓存值再更新数据库,有可能导致请求因缓存缺失而访问数据库,给数据库带来压力;
- 延迟双删时间不好设置:如果业务应用中读取数据库和写缓存的时间不好估算,那么,延迟双删中的等待时间就不好设置。
必须读取一致场景:如果使用先更新数据库再删除缓存时,如果业务层要求必须读取一致,那么就需要在更新DB时,先在 Redis 缓存客户端暂存并发读请求,等数据库更新完、缓存值删除后,再读取数据,从而保证数据一致性。
提问
什么时候会发生缓存不一致的情况?如何处理?分两种模式讨论,读写缓存(同步直写保证原子性,异步写回没法避免)、只读缓存(保证写时原子性删除缓存记录)。
如何解决数据不一致问题?缓存和DB操作都成功,有可能数据不一致吗?事务。重试机制。
26.缓存异常(下):如何解决缓存雪崩、击穿、穿透难题?
除了数据不一致问题,我们常常还会面临缓存异常的三个问题,分别是:
- 缓存雪崩:大量数据同时失效,大量请求打到DB。
- 缓存击穿:少量热点数据失效,大量请求打到DB。
- 缓存穿透:数据本来就不存在,大量请求打到DB。
此时大量的请求打到DB,如果DB挂了,就是严重的生产事故了。
本篇来分别解析一下。
1.缓存雪崩
缓存雪崩:大量的应用请求无法在 Redis 缓存中进行处理,之后直接打到DB。
缓存雪崩的两个原因和解决方法:
- 缓存中大量数据同时过期,导致大量请求无法得到处理。
- 解决方案1:随机微调过期时间(1-3min)。
- 解决方案2:服务降级。不重要的数据请求直接返回预定义的空值或错误,不走缓存和DB。重要的数据正常查询。
- Redis 缓存实例发生故障宕机,大量请求全部打到DB。Redis性能是万级,而DB是千级,相差了10倍。
- 解决方案1:事前预防。Redis采用主从高可用设计。
- 解决方案2:事后弥补。监控Redis和DB负载,负载异常时,在业务系统中触发服务熔断或请求限流机制。
下面来详细看下。
1.原因1:大量数据同时过期
大量数据同时过期,导致请求打到DB上。
解决方案:
- 方案1:事前预防:随机微调过期时间(1-3min)。
- 方案2:事后弥补:发送雪崩时,服务降级。不重要的数据直接返回预定义的空值或错误,不走缓存和DB。
服务降级:发生缓存雪崩时,针对不同的数据采取不同的处理方式:
- 访问的是非核心数据(如电商商品属性):暂停从缓存中查询,直接返回预定义信息、空值或错误信息。
- 访问的是核心数据(如电商商品库存):正常处理,仍然允许查询缓存,如果缓存缺失,也可以继续通过DB读取。
服务降级如下图所示:
2.原因2:Redis 缓存实例故障宕机
DB的性能是千级,Redis性能是万级,此时DB的压力是它能力的十倍。
解决方案:
- 方案1:事前预防。构建 Redis 主从高可靠集群。
- 方案2:事后弥补。在业务系统中实现服务熔断或请求限流机制
方案1比较直观,下面详细说下方案2。
服务熔断:在发生缓存雪崩时,为了防止引发连锁的数据库雪崩,甚至是整个系统的崩溃,我们暂停业务应用对缓存系统的接口访问。再具体点说,就是业务应用调用缓存接口时,缓存客户端并不把请求发给 Redis 缓存实例,而是直接返回,等到 Redis 缓存实例重新恢复服务后,再允许应用请求发送到缓存系统。这样就防止大量请求打到 DB。
具体操作:在业务系统运行时,可以监测 Redis 缓存所在机器和数据库所在机器的负载指标,例如每秒请求数、CPU 利用率、内存利用率等。如果发现 Redis 缓存实例宕机了,而数据库所在机器的负载压力突然增加(例如每秒请求数激增),此时就可以启动熔断机制。如下图所示:
请求限流:服务熔断对整个系统影响太大,我们可以改为请求限流。
如下图所示,缓存平时处理9000个请求,DB处理1000个。当Redis实例挂掉时,立马限流到1000:
使用服务熔断或是请求限流机制,都是缓存雪崩已经发生了,事后弥补。
2.缓存击穿
缓存雪崩是大量数据同时失效。缓存击穿是少量数据被大量访问。
缓存击穿:针对某个访问非常频繁的热点数据的请求,无法在缓存中进行处理,打到DB。
缓存击穿的原因:一般是热点数据过期失效。
缓存击穿如下图所示:
解决方法:对于访问特别频繁的热点数据,就不设置过期时间了。
下面看缓存穿透。
3.缓存穿透
缓存穿透:要访问的数据既不在 Redis 缓存中,也不在DB中。请求会一直打到DB。
如下图所示:
缓存穿透的原因,一般两种:
- 业务层误操作:缓存中的数据和数据库中的数据被误删除了,所以缓存和数据库中都没有数据;
- 恶意攻击:专门访问数据库中没有的数据。
对于缓存穿透,有3种解决方案:
- 缓存空值或默认值。
- 用布隆过滤器快速判断数据是否存在,避免到DB查询。
- 前端进行请求检测,过滤恶意请求。
1.解决方案1:缓存空值或缺省值
发生缓存穿透时,对查询的数据缓存空值或缺省值即可。
2.解决方案2:布隆过滤器判断数据是否存在
用布隆过滤器快速判断数据是否存在,避免请求都打到DB。布隆过滤器可以使用 Redis 实现。
布隆过滤器原理:包括一个初始值都为0的bit数组,以及N个哈希函数。标记数据时:
- 用N个哈希函数算出N个哈希值。
- N个哈希值对bit数组的长度取模,得到标记下标。
- 把对应的下标处都标为1。
判断时,数据做同样的运算,看下N个下标处的值是否都为1即可。
如下图所示:
思考:布隆过滤器保护的数据只能加不能删,如果用来保护的业务数据会有删除的情况怎么办呢?
3.解决方案3:前端请求检测
在请求入口前端,对业务系统接收到的请求进行合法性检测,把恶意的请求(例如请求参数不合理、请求参数是非法值、请求字段不存在)直接过滤掉,不让它们访问后端缓存和DB。
小结
缓存雪崩、击穿、穿透三大问题的原因和应对方案总结如下图:
服务熔断、服务降级、请求限流这些方法都是属于“有损”方案,在保证数据库和整体系统稳定的同时,会对业务应用带来负面影响。
所以,尽量使用预防式方案:
- 缓存雪崩:合理设置数据过期时间,以及搭建高可靠缓存集群;
- 缓存击穿:在缓存访问非常频繁的热点数据时,不要设置过期时间;
- 缓存穿透:提前在入口前端实现恶意请求检测,或者规范数据库的数据删除操作,避免误删除。
提问
什么是缓存雪崩、缓存击穿、缓存穿透?如何应对?
缓存雪崩可以采用服务熔断、服务降级、请求限流的方法来应对,那么这三个机制可以用来应对缓存穿透问题吗?
27.缓存被污染了,该怎么办?
缓存污染:即缓存了很少用到的数据。在一些场景下,有些数据被访问的次数非常少,甚至只会被访问一次。当这些数据服务完访问请求后,如果还继续留存在缓存中的话,就只会白白占用缓存空间。这种情况,就是缓存污染。
缓存污染严重时,会占满缓存空间,此时需要淘汰数据,就增加了时间消耗,降低了Redis性能。
1.如何解决缓存污染问题?
解决方案:把很少用到的数据筛选出来淘汰掉。就不用再等到缓存满了后才逐一淘汰旧数据。
第24篇讲到,常用的淘汰算法有8种:noeviction、volatile-random、volatile-ttl、volatile-lru、volatile-lfu、allkeys-lru、allkeys-random 和 allkeys-lfu 策略。
不同的规则对于解决缓存污染问题,是否都有效呢?接下来,我们一一分析。
其中,noeviction不进行数据淘汰,无法解决缓存污染,我们不讲。LRU算法最常用,我们放到后面单独讲。另外会将LFU和LRU的区别。
1.volatile-random 和 allkeys-random策略
对解决缓存污染作用不大:都是随机选取,没法很好的筛选出不用的数据。
2.volatile-ttl
对解决缓存污染作用不大:选取剩余存活时间最短的数据淘汰掉,但剩余时间最短,不代表不是使用最频繁的数据。
例外情况:故意根据数据使用频率设置过期时间。例如把最常用的数据,过期时间设成最长。那么此时是可以筛选出来的。不过这种情况不具有代表性。
下面来看LRU策略,以及Redis4.0只有添加的LFU策略。第24篇已经讲过LRU如何实现。
2.LRU 缓存策略
LRU 策略的核心思想:如果一个数据刚刚被访问,那么这个数据肯定是热数据,还会被再次访问。
具体算法:在每个数据对应的 RedisObject 结构体中设置一个 lru 字段,用来记录数据的访问时间戳。在进行数据淘汰时,在候选数据集中淘汰掉 lru 字段值最小的数据(也就是访问时间最久的数据)。
LRU的问题:由于只看数据的访问时间,使用 LRU 策略在处理扫描式单次查询操作时,无法解决缓存污染。
扫描式单次查询操作:应用对大量的数据进行一次全体读取,每个数据都会被读取,而且只会被读取一次。此时,因为这些被查询的数据刚刚被访问过,所以 lru 字段值都很大。此时如果用LRU算法,这些数据会留在缓存里很久,造成缓存污染。
如下图所示:
3.LFU缓存策略
LRU无法解决扫描式单次查询的缓存污染问题,因此Redis4.0后引入了LFU。
LFU 策略:从两个维度来筛选并淘汰数据:一是,数据访问的时效性(访问时间离当前时间的远近);二是,数据的被访问次数。
简单来说,LFU在LRU的基础上,增加了次数。也就是说,LRU更注重时效性,LFU更注重访问频次。
LFU 缓存策略算法:在 LRU 策略基础上,为每个数据增加了一个计数器,来统计这个数据的访问次数。先根据数据的访问次数进行筛选,把访问次数最低的数据淘汰出缓存。如果两个数据的访问次数相同,再根据访问时效性筛选,将距离上一次访问时间更久的数据淘汰出缓存。
1.LRU的具体实现算法
复习下LRU的实现算法:
为了避免操作链表的开销,Redis 在实现 LRU 策略时使用了两个近似方法:
- Redis 是用 RedisObject 结构来保存数据的,RedisObject 结构中设置了一个 lru 字段,用来记录数据的访问时间戳;
- Redis 并没有为所有的数据维护一个全局的链表,而是通过随机采样方式,选取一定数量(例如 10 个)的数据放入候选集合,后续在候选集合中根据 lru 字段值的大小进行筛选。
2.LFU的实现算法
与LRU相比,仅仅是把原来 24bit 大小的 lru 字段,进一步拆分成了两部分:
ldt 值:lru 字段的前 16bit,表示数据的访问时间戳;
counter 值:lru 字段的后 8bit,表示数据的访问次数。
那么问题又来了,访问次数8位,只有255。如何区分开那么大的访问次数?如果按实际访问次数计算的话,大家都大于255次了,显然无法区分开数据优先级。
3.LFU算法改进:访问次数非线性递增
优化的计数规则:每当数据被访问一次时:
- 首先,用计数器当前的值乘以配置项
lfu_log_factor
再加1 ,再取其倒数,得到一个 p 值; - 然后,把这个 p 值和一个取值范围在(0,1)间的随机数 r 值比大小,只有 p 值大于 r 值时,计数器才加 1。
这样,就不是线性增加了。源码如下,乘以系数,加1,取倒,和(0, 1)随机值比较,大于随机数才加1。注意到默认开始次数是5,避免刚写入就被淘汰:
// baseval 是计数器当前的值。计数器的初始值默认是 5(由代码中的 LFU_INIT_VAL 常量设置),而不是 0,这样可以避免数据刚被写入缓存,就因为访问次数少而被立即淘汰。
double r = (double)rand() / RAND_MAX;
...
double p = 1.0 / (baseval * server.lfu_log_factor + 1); // 很重要。乘以系数,加1,取倒,和随机值比较,大于才加1
if (r < p) counter++;
可通过设置不同的 lfu_log_factor
配置项,来控制计数器值增加的速度。
官方给出的配置值参考如下图:
实际运用中,一般设置到10就够用了。
4.LFU算法改进:访问次数自动衰减
还有一个场景:有些数据在短时间内被大量访问后就不会再被访问了。此时如果还按照访问次数来计算淘汰优先级的话,显然又产生缓存污染了。
所以,Redis 在实现 LFU 策略时,还设计了一个 counter
值的衰减机制。
具体算法:LFU 策略使用衰减因子配置项 lfu_decay_time 来控制访问次数的衰减。
- 计算当前时间和数据最近一次访问时间的差值,并把该值换算成以分钟为单位。
- 然后,LFU 策略再把这个差值除以
lfu_decay_time
值,所得的结果就是数据 counter 要衰减的值。
简单来说,就是N分钟没被访问,就衰减 (差值 / lfu_decay_time) 次。当等于1时,数据N分钟没被访问,访问次数就衰减N。
如果业务应用中有短时高频访问的数据的话,建议把 lfu_decay_time 值设置为 1,这样就可以较快地衰减它们的访问次数。当然,参数值根据实际业务设置。
小结
8大淘汰策略应对缓存污染:
- noviction策略:没用
- volatile-random 和 allkeys-random策略:没用。
- volatile-ttl:没用。例外情况:手动根据访问优先级设置ttl大小时,有用。
- LRU策略:有用。无法应对扫描式单次查询场景。
- LFU策略:有用,在LRU基础上增加了次数纬度。通过参数
lfu_log_factor
设置访问次数递增速度(乘以系数,加1,取倒,和(0, 1)随机值比较,大于随机数才加1),通过参数lfu_decay_time
设置访问次数衰减速度(成反比,越小越快。N分钟没被访问,衰减 (差值 / lfu_decay_time) 次)。
LRU 策略更加关注数据的时效性,而 LFU 策略更加关注数据的访问频次。
从长期来看,我觉得应该是避免频繁执行数据淘汰,否则会影响redis的效率,较好的做法应该是监控redis服务器的内存情况,以及相应的报警机制,定期统计redis中的key分布情况……
提问
八个淘汰策略,哪些可以解决缓存污染问题?
LFU对比LRU,改进了哪些地方,解决了什么问题?LRU单次扫描查询问题。LFU增加了次数纬度。次数使用非线性递增(8位只有255),同时设置了自动衰减(短期内大量访问后不再访问)。
使用了 LFU 策略后,你觉得缓存还会被污染吗?1.被污染的概率取决于LFU的配置,也就是lfu-log-factor和lfu-decay-time参数。2.访问次数衰减期间,是污染的。
28.Pika如何基于SSD实现大容量Redis?(暂略待补充)
业务场景,存储大量数据:
- Redis单个实例不能存太大的数据(只能2~4G),否则会在实例恢复、主从同步过程中会引起一系列潜在问题,例如恢复时间增长、主从切换开销大、缓冲区易溢出。
- Redis多个实例又增加了复杂性,又造成了集群规模扩大,让集群的运维管理变得复杂,增加开销。
360 公司的 Pika 做了一些取舍,降低了一些访问性能,但单例可以存更多数据:
- 底层用Rocks键值对数据库,再用SSD硬盘持久化binlog。
- 写性能10w+,读性能20w+。
- 兼容 Redis。
下面来看下。先讲 Redis 单实例存太多数据的问题,再讲 Pika 的设计与实现。
1.大内存 Redis 实例的潜在问题
主要是自身RDB生成和恢复、主从切换两方面的影响:
- 内存快照 RDB 生成和恢复效率低:内存大,RDB文件就会变大。
- 生成:RDB 文件生成的 fork 时长增加,导致 Redis 实例阻塞。
- 恢复:用 RDB 文件来恢复的时长增加,导致 Redis 较长时间无法对外提供服务。
- 主从节点全量同步时长增加、缓冲区易溢出。
- 主从首次同步:此时是全量同步。主节点生成 RDB 文件,传给从节点,从节点再加载。此时全量同步的时长增加。如果缓冲区内存不够,还可能导致复制缓冲区溢出,同步失败。
- 故障时主从切换:此时所有从库都要和新主库进行一次全量同步。RDB文件过大,则耗时增加,影响业务可用性。
Pika使用了 RocksDB、binlog 机制 和 Nemo 来增大单例可用内存。下面来看下 Pika 的设计和实现。
2.Pika 的整体架构(略)
3.Pika 如何基于 SSD 保存更多数据?(略)
4.Pika 如何实现 Redis 数据类型兼容?(略)
5.Pika 的其他优势与不足(略)
小结
Redis单实例存储太多数据的影响:
- RDB 生成和恢复慢:生成:fork时间过程,阻塞主线程。恢复:恢复时间过程,此时Redis无法对外提供服务。
- 主从节点同步:首次同步:全量同步,同步时长增加,且可能缓冲区溢出。故障时主从切换:全量同步。所有从库都要和新主库全量同步,耗时都增加。
Pika 是把数据保存到了 SSD 上,数据访问要读写 SSD,所以,读写性能要弱于 Redis。
提问
Redis单个实例存太多数据会有什么问题?
实践篇:锁(29-31)
29.无锁的原子操作:Redis如何应对并发访问?
业务场景:使用Redis时,多个业务对Redis数据并发写,可能导致数据改错。需要保证并发访问的正确性。
Redis提供了两种方法来保证并发访问的正确性:
- 加锁:读写数据前,先获得锁。完成操作后,释放锁。加锁方案的两个问题:
- 降低性能:加锁操作多,会降低系统的并发访问性能。
- 引入复杂性:Redis 客户端要加锁时,需要用到分布式锁。分布式锁实现复杂,需要用到额外的存储系统来提供加解锁操作
- 原子性操作:执行过程保持原子性的操作。此时就无需加锁,减少了对系统并发性能的影响。
本节主要介绍原子性操作。下节介绍加锁操作。
原子操作的目标是实现并发访问控制。下面先来看,并发访问控制,需要控制的是什么。
1.并发访问中需要对什么进行控制?
并发访问控制:主要是控制多个客户端读写同一份数据的过程。保证任何一个客户端发送的操作在 Redis 实例上执行时具有互斥性。
主要是控制写操作。当客户端需要写数据时,基本流程分为两步:
- 客户端读取数据到本地,在本地修改数据。
- 客户端把修改完的数据,写回 Redis。
RMW操作:这个流程叫做“读取 - 修改 - 写回”操作(Read-Modify-Write,简称为 RMW 操作)。
临界区代码:多个客户端对同一份数据执行 RMW 操作时,执行的代码。
示例,以下临界区代码:
// Reed: 根据商品 id,从 Redis 中读取商品当前的库存值 current
current = GET(id);
// Modify: 客户端对库存值减 1
current--;
// Wreite: 结果写回Redis
SET(id, current);
假设有两个客户端同时操作(例如同时减1),不进行并发访问控制的话,就会出错。如下图所示:
补图。
2.用锁保证互斥性
出现上述错误的原因是,不同客户端的RMW操作不具有互斥性。
我们可以尝试用锁保证互斥性。伪代码示例:
// 加锁
LOCK();
current = GET(id);
current--;
SET(id, current);
// 解锁
UNLOCK();
加锁虽然保证了互斥性,但会导致系统并发性能降低。
如下图所示,t1 时段内只有 A 能访问共享数据,t2 时段内只有 B 能访问共享数据,系统的并发性能下降了:
补图。
原子操作也能实现并发控制,但是原子操作对系统并发性能的影响较小。下面来看下。
3.Redis 的两种原子操作方法
Redis 的原子操作有两种方法:
- 单命令操作:
INCR / DECR
命令,把多个操作在 Redis 中实现成一个操作; - Lua脚本:Lua 脚本,把多个操作写到一个 Lua 脚本中,以原子性方式执行单个 Lua 脚本。
1.简单增减:INCR / DECR单命令操作
单命令操作:Redis 是使用单线程来串行处理客户端的请求操作命令的,所以,当 Redis 执行某个命令操作时,其他命令是无法执行的,这就相当于命令操作是互斥执行的。当然,快照生成、AOF 重写操作是后台子线程执行,不过只读不写。
INCR / DECR
命令:Redis 提供了 INCR / DECR
命令,实现增值 / 减值操作。即把读数据、数据增减、写回数据三个操作合并为一个原子操作。
例如,库存扣减例子中,DMW操作使用一条命令即可:
DECR id
简单的增减可用INCR / DECR
命令,复杂的操作怎么办呢?使用 lua
脚本。
2.复杂操作:Lua脚本
Lua脚本:Redis 会把整个 Lua 脚本作为一个整体执行,在执行的过程中不会被其他命令打断,从而保证了 Lua 脚本中操作的原子性。
EVAL 命令:把要执行的操作编写到一个 Lua 脚本中。然后,用 Redis 的 EVAL 命令来执行 Lua 脚本。
使用方法:假设脚本名称为 lua.script,用 Redis 客户端,带上 eval 选项执行脚本即可。脚本所需的参数通过命令中的 keys 和 args 传递。如下所示:
redis-cli --eval lua.script keys , args
客户端操作,都有相应的JavaApi接口。使用Reddisson客户端的话,直接在java里定义好String,作为参数传入即可。使用示例:
private static final String BATCH_DEL_SCRIPT = "for k,v in pairs(ARGV) do \n " +
"redis.call('del',string.gsub(v,'\"',''),'') \n" +
"end \n" +
"return #ARGV";
redissonClient.getScript().evalAsync(RScript.Mode.READ_WRITE, BATCH_DEL_SCRIPT, RScript.ReturnType.INTEGER, keys, keys.toArray());
3.Lua 脚本使用示例
下面举个例子,如何使用Lua脚本。
业务场景:业务访问限流。当一个业务应用的访问用户增加时,我们有时需要限制某个客户端在一定时间范围内的访问次数,比如爆款商品的购买限流、社交网络中的每分钟点赞次数限制等。
算法:我们可以把客户端 IP 作为 key,把客户端的访问次数作为 value,保存到 Redis 中。客户端每访问一次后,我们就用 INCR 增加访问次数。
每分钟的访问次数不能超过 20 次:在客户端第一次访问时,给对应键值对设置过期时间为 60s。在客户端每次访问时,读取客户端当前的访问次数,如果次数超过阈值,就报错,限制客户端再次访问。
// ===== 普通方式,不支持并发访问
// 获取ip对应的访问次数
current = GET(ip)
//如果超过访问次数超过20次,则报错
IF current != NULL AND current > 20 THEN
ERROR "exceed 20 accesses per second"
ELSE
//如果访问次数不足20次,增加一次访问计数
value = INCR(ip)
//如果是第一次访问,将键值对的过期时间设置为60s后
IF value == 1 THEN
EXPIRE(ip,60)
END
//执行其他操作
DO THINGS
END
// ===== lua方式,支持并发访问。以下写到文件lua.script
// 访问次数加 1、判断访问次数是否为 1,设置过期时间这三个操作原子性地执行。
local current
current = redis.call("incr",KEYS[1])
if tonumber(current) == 1 then
redis.call("expire",KEYS[1],60)
end
// 在客户端执行文件,参数通过 keys 和 args 传递
redis-cli --eval lua.script keys , args
SpringBoot使用Lua脚本示例:
/**
* @author Howinfun
* @desc lua 脚本测试
* @date 2019/11/5
*/
@RunWith(SpringJUnit4ClassRunner.class)
@ActiveProfiles("test")
@SpringBootTest(classes = ThirdPartyServerApplication.class)
public class RedisTest {
/** 释放锁lua脚本 */
private static final String RELEASE_LOCK_LUA_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
@Autowired
private StringRedisTemplate redisTemplate;
@Test
public void contextLoads() {
String lockKey = "123";
String UUID = cn.hutool.core.lang.UUID.fastUUID().toString();
boolean success = redisTemplate.opsForValue().setIfAbsent(lockKey,UUID,3, TimeUnit.MINUTES);
if (!success){
System.out.println("锁已存在");
}
// 指定 lua 脚本,并且指定返回值类型
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(RELEASE_LOCK_LUA_SCRIPT, Long.class);
// 参数一:redisScript,参数二:key列表,参数三:arg(可多个)
Long result = redisTemplate.execute(redisScript, Collections.singletonList(lockKey), UUID);
System.out.println(result);
}
}
// 为什么返回值不用 Integer 接收而是用 Long。这里是因为 spring-boot-starter-data-redis 提供的返回类型里面不支持 Integer。
小结
Redis处理并发访问:
- 加锁。
- 原子性操作:简单的自增减用 INCR/DECR命令。复杂的逻辑用 Lua脚本。
Lua 脚本中操作过多,会导致 Redis 执行脚本的时间增加:要避免把不需要做并发控制的操作写入Lua 脚本脚本中。
提问
Redis怎么处理并发访问,以防止脏数据?两种方法。原子性操作、加锁。
Redis的原子操作怎么实现?简单操作:INCR / DECR单命令。复杂操作:Lua脚本。
代码中怎么用?
30.如何使用Redis实现分布式锁?
应对Redis并发访问问题,除了原子操作,还可以用分布式锁。此时锁是保存在一个共享存储系统中的,可被多个客户端共享访问和获取。可以用Redis保存,性能也高。
分布式锁和单机锁有相似性,也有区别。下面先对比下分布式锁和单机上的锁的区别和联系。
1.单机上的锁和分布式锁的联系与区别
单机上运行的多线程程序,如何加锁:锁可以用一个变量表示:
- 变量值为 0 时:表示没有线程获取锁;
- 变量值为 1 时:表示已经有线程获取到锁了。
操作逻辑:
- 获取锁时:检查变量,为0则把它变为1,获取锁成功。为0则锁已被占用,获取锁失败。
- 释放锁时:把变量改为0。
分布式锁的逻辑是一样的:
- 获取锁时:同样需要判断锁变量的值,根据锁变量值来判断能否加锁成功。
- 释放锁时:需要把锁变量值设置为 0,表明客户端不再持有锁。
分布式锁和单机锁的不同点:
- 锁变量需要由一个共享存储系统来维护。相应的加锁和释放锁的操作就变成了读取、判断和设置共享存储系统中的锁变量值。
所以分布式锁有两个要求:
- 要求一:保证锁操作原子性:分布式锁的加锁和释放锁的过程,涉及多个操作。所以,在实现分布式锁时,我们需要保证这些锁操作的原子性;
- 要求二:保证共享存储系统可靠性:共享存储系统保存了锁变量,如果共享存储系统发生故障或宕机,那么客户端也就无法进行锁操作了。在实现分布式锁时,我们需要考虑保证共享存储系统的可靠性,进而保证锁的可靠性。
下面来看Redis如何实现分布式锁。可以单机实现,也可以集群实现。两种实现,锁的可靠性不一样。
2.基于单个 Redis 节点实现分布式锁
实现:可使用键值对保存锁变量,再接收和处理不同客户端发送的加锁和释放锁的操作请求。key:锁的变量名。value:锁的值。
SETNX命令:返回null、数值…..??
- 带标识的SETNX命令:即分布式锁的值是带有客户端自己的ID的,防止其他客户端误删。
- 带过期时间:防止异常无法释放锁。
单机实现有丢锁风险:
SET key_name my_random_value NX PX 30000
NX 表示if not exist 就设置并返回True,否则不设置并返回False
PX 表示过期时间用毫秒级, 30000 表示这些毫秒时间后此key过期
方式实现的redis分布锁
但有缺点:
只作用在一个Redis节点上,即使Redis通过sentinel保证高可用,如果这个master节点由于某些原因发生了主从切换,那么就会出现锁丢失的情况:
在Redis的master节点上拿到了锁;
但是这个加锁的key还没有同步到slave节点;
master故障,发生故障转移,slave节点升级为master节点;
导致锁丢失。
集群高可用Redlock算法(重点阐述):基本思路,是让客户端和多个独立的 Redis 实例依次请求加锁,如果客户端能够和半数以上的实例成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁了,否则加锁失败。。
Redlock具体实现原理
- 客户端获取当前时间
- 客户端按顺序依次向 N 个 Redis 实例执行加锁操作:这里的加锁操作和在单实例上执行的加锁操作一样,使用 SET 命令,带上 NX,EX/PX 选项,以及带上客户端的唯一标识、超时时间。如果客户端在和一个 Redis 实例请求加锁时,一直到超时都没有成功,那么此时,客户端会和下一个 Redis 实例继续请求加锁。加锁操作的超时时间需要远远地小于锁的有效时间,一般也就是设置为几十毫秒。
- 一旦客户端完成了和所有 Redis 实例的加锁操作,客户端就要计算整个加锁过程的总耗时。
满足下面的这两个条件时,才能认为是加锁成功。
条件一:客户端从超过半数(大于等于 N/2+1)的 Redis 实例上成功获取到了锁;
条件二:客户端获取锁的总耗时没有超过锁的有效时间。
锁的有效时间,计算的结果是锁的最初有效时间减去客户端为获取锁的总耗时。
如果客户端在和所有实例执行完加锁操作后,没能同时满足这两个条件,那么,客户端向所有 Redis 节点发起释放锁的操作,执行释放锁的 Lua 脚本即可。
小结
RedLock分布式锁的实现。后续可以再配上更具体的图文解释。
提问
Redis分布式锁怎么实现?和ZK比各有什么特点?为什么需要,单机的实现(SETNX命令具体功能),集群的实现(具体算法、缺陷,Reddition的各种大坑)。
31.事务机制:Redis能实现ACID属性吗?(待补充)
1.事务的ACID属性要求
- A:原子性。一个事务中的多个操作必须都完成,或者都不完成。
- C:一致性。数据库中的数据在事务执行前后是一致的。
- I:隔离性。在执行事务时,其它操作无法存取到正在执行事务访问的数据。
- D:持久性。在执行事务后,数据的修改要被持久化保存下来。
2.Redis 如何实现事务?
Redis 提供了 MULTI、EXEC 两个命令来完成:
- 开启事务:客户端使用MULTI命令开启事务。
- 发送命令:客户端发送具体操作, Redis 把这些操作命令暂存到一个命令队列中。
- 执行事务:客户端使用EXEC 命令提交事务。
#开启事务
127.0.0.1:6379> MULTI
OK
#将a:stock减1,
127.0.0.1:6379> DECR a:stock
QUEUED
#将b:stock减1
127.0.0.1:6379> DECR b:stock
QUEUED
#实际执行事务
127.0.0.1:6379> EXEC
1) (integer) 4
2) (integer) 9
3.Redis 的事务机制能保证哪些属性?
以上操作符合ACID吗?下面来分析。
原子性
分情况讨论:
正常执行:满足原子性。
在执行 EXEC 命令前,客户端发送的某个操作命令有错误:取消执行该命令,但已执行的命令没法回滚,不满足原子性。Redis 中并没有提供回滚机制。虽然 Redis 提供了 DISCARD 命令,但是,这个命令只能用来主动放弃事务执行,把暂存的命令队列清空,起不到回滚的效果。
# DISCARD命令示意 #读取a:stock的值4 127.0.0.1:6379> GET a:stock "4" #开启事务 127.0.0.1:6379> MULTI OK #发送事务的第一个操作,对a:stock减1 127.0.0.1:6379> DECR a:stock QUEUED #执行DISCARD命令,主动放弃事务 127.0.0.1:6379> DISCARD OK #再次读取a:stock的值,值没有被修改 127.0.0.1:6379> GET a:stock "4"
在执行事务的 EXEC 命令时,Redis 实例发生了故障,导致事务执行失败:
- 如果开启了 AOF 日志,那么只会有部分的事务操作被记录到 AOF 日志中。需要手动处理,使用 redis-check-aof 工具检查 AOF 日志文件,把已完成的事务操作从 AOF 文件中去除,从而满足原子性。
- 如果未开启AOF日志,那么没法保证原子性。
简单小结:
命令入队时就报错,会放弃事务执行,可以保证原子性;
命令入队时没报错,实际执行时报错,不保证原子性;
EXEC 命令执行时实例故障,如果开启了 AOF 日志,可以保证原子性。
一致性
分情况讨论:
- 命令入队时就报错:事务本身就会被放弃执行,可以保证一致性。
- 命令入队时没报错,实际执行时报错:有错误的命令不会被执行,正确的命令可以正常执行,可以保证一致性。
- EXEC 命令执行时实例发生故障:总的来说,可以保证一致性。
- 没有开启 RDB 或 AOF:实例故障重启后,数据都没有了,数据是一致的。
- 使用了 RDB 快照:RDB 快照不会在事务执行时执行,事务命令操作的结果不会被保存到 RDB 快照中,使用 RDB 快照进行恢复时,数据是一致的。
- 使用了 AOF 日志:如果有部分操作被记录到了 AOF 日志,需使用 redis-check-aof 清除事务中已经完成的操作,数据库恢复后才能保持一致。
隔离性
针对两个阶段,分成两种情况:
- 并发操作在 EXEC 命令前执行:隔离性的保证要使用 WATCH 机制来实现,否则隔离性无法保证。
- 并发操作在 EXEC 命令后执行:隔离性可以保证。
WATCH 机制:在事务执行前,监控一个或多个键的值变化情况,当事务调用 EXEC 命令执行时,WATCH 机制会先检查监控的键是否被其它客户端修改了。如果修改了,就放弃事务执行,避免事务的隔离性被破坏。
WATCH 机制的具体实现是由 WATCH 命令实现:
持久性
持久化保存完全取决于 Redis 的持久化配置模式。不管 Redis 采用什么持久化模式,事务的持久性属性都得不到保证:
- 没有使用 RDB 或 AOF:肯定没法保证。
- RDB 模式:在一个事务执行后,而下一次的 RDB 快照还未执行前,实例宕机,此时没法保证。
- AOF 模式:no、everysec 和 always,都会存在数据丢失的情况。
小结
Redis 通过 MULTI、EXEC、DISCARD 和 WATCH 四个命令来支持事务机制:
Redis 的事务机制可以保证一致性和隔离性,但是无法保证持久性。
原子性的情况比较复杂,当事务中使用的命令语法有误时,原子性无法保证。在其它情况下,事务都可以原子性执行。
提问
事务的ACID属性?
Redis 如何实现事务?MULTI、EXEC 两个命令。
Redis 的事务机制能保证ACID哪些属性?一一分析。
在执行事务时,如果 Redis 实例发生故障,而 Redis 使用了 RDB 机制,那么,事务的原子性还能得到保证吗?可以吧?RDB不会在事务执行的时候执行。
实践篇:集群(32-38)
32.Redis主从同步与故障切换,有哪些坑?
本节介绍主从机制三个常见的坑:
- 主从数据不一致
- 读到过期数据
- 配置项不合理导致服务挂掉。
1.主从数据不一致
主从数据不一致:指客户端从从库中读取到的值和主库中的最新值并不一致。
业务场景举例:主从库保存年龄为19,主库修改为20,读从库值,依然为19。
1.产生原因
产生原因:主从库间的命令复制是异步进行的。
具体分析:
- 主库发送命令给从库:主从库命令传播阶段,主库收到新的写命令后,会发送给从库。
- 主库执行后立即返回:主库在本地执行完命令后,会立即向客户端返回结果。
- 从库可能会滞后执行同步命令:此时若从库还没有执行主库同步过来的命令,主从库间的数据就不一致了。
从库滞后执行同步命令的原因:
- 网络延迟:主从库间的网络可能会有传输延迟,从库不能及时收到同步命令。
- 从库阻塞:从库主线程被其它复杂度高的命令(例如集合操作命令)阻塞,从库不能及时执行同步命令。
2.解决方案
解决方案:
- 硬件配置:尽量保证主从库间网络连接状况良好。例如,避免把主从库部署到不同机房,避免把网络密集应用和Redis一起部署。
- 监控主从库复制进度:开发一个外部程序来监控主从库间的复制进度,慢的从库就干掉它。
查看主从库复制进度,可以使用 Redis 的 INFO replication
命令:
master_repl_offset
:主库接收写命令进度。slave_repl_offset
:从库复制写命令进度。
监控思路:
- 计算进度差值:用 master_repl_offset 减去 slave_repl_offset,就得到了从库和主库间的复制进度差值。
- 与阈值比较,差值太大则断开:若某个从库的进度差值大于预设阈值,就让客户端别再这个从库读数据,即不再和该从库连接。
- 与阈值比较,差值合格则连接:当从库的复制进度赶上主库时:
- 若该从库是已连接的,则继续连接。
- 若是断开连接的,则允许客户端再次和该从库连接。
- 周期性检测,阈值设大一些,防止客户端和所有从库都不能连接。
整体流程如下图所示:
除了主从不一致,我们还会读到过期数据,下面来详细看看。
2.读取过期数据
业务场景:数据A的过期时间是09:00,但是09:10时,仍然可以在从库读到该数据。
产生原因:由过期数据删除策略引起。
过期数据删除的两种策略:
- 惰性删除策略:数据到期不立即删除,访问到它时,发现它已经过期,再删除。
- 好处:减少CPU占用。不用浪费时间检查、删除过期数据。
- 坏处:大量过期数据占用内存资源。
- 定期删除策略:每隔一段时间(默认100ms),随机选出N个数据,把其中过期的数据删除。
1.产生原因1:删除策略不当导致读到过期数据
两种策略读到过期数据原因分析:
- 惰性删除策略:
- 客户端访问主库:此时主库会触发数据删除,并且不会返回过期数据。
- 客户端访问从库:从库不会触发数据删除。Redis 3.2 前,从库不会进行过期数据判断,因此会返回过期数据。Redis 3.2及之后,从库会进行过期数据判断,过期时返回空值。
- 定期删除策略:
- 为了避免过多删除操作对删除操作产生影响,因此每次随机检查数据的量不多。如果数据很多,那么过期数据就不能及时删除,此时过期数据就留到了内存中。所以定期删除策略有可能会返回过期数据。
2.产生原因2:过期时间设置命令不当导致读到过期数据
由前面可知,选择惰性删除策略,且Redis版本在3.2后,才能保证不会因为删除策略,导致读到过期数据。
那么此时就不会读到过期数据了吗?其实还有可能,这个跟设置过期时间的命令有关系,下面来看下。
设置数据过期时间的命令共有 4 个,分成两类:
EXPIRE
和PEXPIRE
:数据的过期时间是从命令执行时开始计算的存活时间;EXPIREAT
和PEXPIREAT
:数据的过期时间为具体的一个时间点。
含义如下图所示:
使用示例:
# testkey 的过期时间设置为 60s 后
EXPIRE testkey 60
# testkey 在 2020 年 10 月 24 日上午 9 点过期
EXPIREAT testkey 1603501200
主从全量同步延迟导致EXPIRE
过期时间延后:当主从库全量同步时,主库接收到了一条 EXPIRE 命令并执行。从库在全量同步完成后,收到该命令执行。从库是基于同步完成之后的时间点开始计时的,所以数据删除时间会延后。
使用建议:在业务应用中使用 EXPIREAT
/PEXPIREAT
命令,在指定时间点删除数据。不过,有个地方需要注意下,由于设置的是时间点,所以,主从节点上的时钟要保持一致。具体的做法是,让主从节点和相同的 NTP 服务器(时间服务器)进行时钟同步。
除了以上两个坑,主从故障切换时,也会因为配置不合理而踩坑。下面来看下。
3.不合理配置项导致的服务挂掉
涉及到的配置项有两个,分别是 protected-mode
和 cluster-node-timeout
。
1.protected-mode 配置项
配置项作用:配置哨兵实例能否被其他服务器访问:
yes
:哨兵实例只能在部署的服务器本地访问no
:其他服务器也可以访问该哨兵实例。
哨兵保护模式导致无法相互通信:protected-mode
被设置为 yes
时,其余哨兵实例是部署在其它服务器的,此时,哨兵实例间就无法通信。当主库故障时,哨兵无法判断主库下线,也无法进行主从切换,最终 Redis 服务不可用。
最佳实践:在应用主从集群时,关闭保护模式, protected-mode 配置项设置为 no,但是将 bind 配置项设置为其它哨兵实例的 IP 地址。此时只有在 bind 中设置了 IP 地址的哨兵,才可以访问当前实例,保证了安全性。
配置实例:
protected-mode no
bind 192.168.10.3 192.168.10.4 192.168.10.5
2.cluster-node-timeout 配置项
配置项作用:设置 Redis Cluster 中实例响应心跳消息的超时时间。
原因分析:
- 主从切换:在 Redis Cluster 集群中为每个实例配置了“一主一从”模式时,如果主实例发生故障,从实例会切换为主实例。
- 心跳超时:受网络延迟和切换操作执行的影响,切换时间可能较长,就会导致实例的心跳超时(超出 cluster-node-timeout)。
- 半数以上实例误判为异常:实例超时后,就会被 Redis Cluster 判断为异常。而 Redis Cluster 正常运行的条件是,有半数以上的实例都能正常运行。
最佳实践:建议将 cluster-node-timeout
调大些(例如 10 到 20 秒)。
小结
总结下三个坑:
- 主从数据不一致:Redis 采用的是异步复制,所以无法实现强一致性保证(主从数据时时刻刻保持一致),数据不一致是难以避免的。应对方法:保证良好网络环境,以及使用程序监控从库复制进度,一旦从库复制进度超过阈值,不让客户端连接从库。
- 读到过期数据:可以提前规避。
- 使用定期删除策略,并使用 Redis 3.2 及以上版本;
- 使用 EXPIREAT/PEXPIREAT 命令设置过期时间,避免从库上的数据过期时间滞后。注意主从节点时钟要同步。
- 不合理配置:
- 哨兵不要设置保护模式。最佳实践是关闭保护模式,但是bind固定服务器IP。
- 集群心跳检测时间设置长一点。10-20秒。防止误判从节点异常。
具体如下图所示:
一个小建议:Redis 中的 slave-serve-stale-data
配置项设置了从库能否处理数据读写命令,把它设置为 no。这样从库只能服务 INFO
、SLAVEOF
命令,就可避免在从库中读到不一致的数据了。
不过要注意*slave-serve-stale-data
和 slave-read-only
配置项的区别*:slave-read-only 是设置从库能否处理写命令,slave-read-only 设置为 yes 时,从库只能处理读请求,无法处理写请求。
提问
Redis主从数据不一致是什么原因?如何解决?如果一定要保证强一致性呢?
读到过期数据是什么原因?如何解决?
我们把 slave-read-only 设置为 no,让从库也能直接删除数据,以此来避免读到过期数据,你觉得,这是一个好方法吗?主从复制中的增删改都需要在主库执行,即使从库能做删除,也不要在从库删除。否则会造成数据不一致。
33.脑裂:一次奇怪的数据丢失
业务场景:主从集群有 1 个主库、5 个从库和 3 个哨兵实例,在使用的过程中,我们发现客户端发送的一些数据丢失了,这直接影响到了业务层的数据可靠性。
问题排查:脑裂:在主从集群中,同时有两个主节点,它们都能接收写请求。而脑裂最直接的影响,就是客户端不知道应该往哪个主节点写入数据,结果就是不同的客户端会往不同的主节点上写入数据。严重时,就导致数据丢失了。
本节主题:主从集群中为什么会发生脑裂?脑裂为什么会导致数据丢失?如何避免脑裂发生?
1.为什么会发生脑裂
问题排查:为什么数据会丢失?是不是数据同步出了问题?
1.第一步:确认是不是数据同步出现了问题
主从集群中数据丢失最常见的原因:
- 主库的数据还没有同步到从库,此时主库发生故障
- 从库升级为主库,未同步的数据丢失。
排查方法:比对主从库上的复制进度差值即可,计算 master_repl_offset 和 slave_repl_offset 的差值,从库上的 slave_repl_offset 小于原主库的 master_repl_offset,则是该原因导致数据丢失。
如果不是该原因引起。那么所有的数据操作都是从客户端发送给 Redis 实例的,那么,是不是可以从客户端操作日志中发现问题呢?
2.第二步:排查客户端的操作日志,发现脑裂现象
排查客户端的操作日志发现:在主从切换后的一段时间内,有一个客户端仍然在和原主库通信,并没有和升级的新主库进行交互。这就相当于一个集群同时有两个主库。
但是,不同客户端给两个主库发送数据写操作,按道理来说,只会导致新数据会分布在不同的主库上,并不会造成数据丢失。那么,为什么我们的数据仍然丢失了呢?
再来溯源一下,来看主从切换的执行过程
3.第三步:发现是原主库假故障导致的脑裂
- 哨兵判断主库下线,进行切换:集群采用哨兵机制进行主从切换。某个时刻,超过预设数量(quorum 配置项)的哨兵实例和主库的心跳都超时,主库判断被为客观下线,然后,哨兵开始执行切换操作。哨兵切换完成后,客户端会和新主库进行通信,发送请求操作。
- 在切换过程中,客户端仍然和原主库通信,这就表明,原主库并没有真的发生故障。
- (1)主库由于某些原因无法处理请求,也没有响应哨兵的心跳,被哨兵错误地判断为客观下线。(2)开始主从切换。(3)哨兵还没有完成主从切换,原主库又重新开始处理请求了,此时客户端仍然可以和原主库通信,客户端发送的写操作在原主库上写入了数据。(4)主从切换完成,数据全量同步,原主库新写数据丢失。
- 故障还原:查看原主库所在机器监控,发现有一段时间一个数据采集程序导致CPU飙升,此时无法及时响应哨兵心跳数据,被误判为客观下线。
整体流程如下图:
2.为什么脑裂会导致数据丢失?
丢失原因:
- 老主库与新主库进行全量同步:主从切换后,从库升级为新主库,哨兵就会让原主库执行 slave of 命令,和新主库重新进行全量同步。
- 新主库数据覆盖掉老主库里的新写数据:而在全量同步执行的最后阶段,原主库需要清空本地的数据,加载新主库发送的 RDB 文件,这样一来,原主库在主从切换期间保存的新写数据就丢失了。
3.如何应对脑裂问题?
主要的想法是,限制主库的接收请求,防止主库被占满。
Redis 提供了两个配置项来限制主库对于客户端的请求处理:
min-slaves-to-write
:主库能进行数据同步的最少从库数量。min-slaves-max-lag
:主从库间进行数据复制时,从库给主库发送 ACK 消息的最大延迟(以秒为单位)。
最佳实践:
- 假设min-slaves-to-write 和 min-slaves-max-lag的值分别为N和T,我们设置主库连接的从库中至少有 N 个从库,和主库进行数据复制时的 ACK 消息延迟不能超过 T 秒,否则,主库就不再接收客户端的请求。
- 即使发生假故障,此时主库无法响应哨兵心跳、无法和从库同步、所以也不会和从库进行 ACK 确认。此时客户端就被上述设置限制,就不能写入新数据了。此时假故障导致新主库上线,由于老主库没有被写入数据,所以没有风险。
小结
脑裂发生的原因主要是原主库发生了假故障,我们来总结下假故障的两个原因:
- 和主库部署在同一台服务器上的其他程序临时占用了大量资源(例如 CPU 资源),导致主库资源使用受限,短时间内无法响应心跳。其它程序不再使用资源时,主库又恢复正常。
- 主库自身遇到了阻塞的情况,例如,处理 bigkey 或是发生内存 swap(你可以复习下第 19 讲中总结的导致实例阻塞的原因),短时间内无法响应心跳,等主库阻塞解除后,又恢复正常的请求处理了。
为了应对脑裂,可以在主从集群部署时,通过合理地配置参数 min-slaves-to-write 和 min-slaves-max-lag,来预防脑裂的发生。此时即使发生了假故障,进行的新主库切换,切换期间老主库也被锁定不让接收新请求,所以不会同时存在两个可访问的主机,即脑裂。
(?:Redis无法完全避免脑裂的产生,因为其不保证主从的强一致,所以必然有产生脑裂的可能性)
提问
脑裂会导致数据丢失吗?
怎么防止主库被占满而产生假故障而造成哨兵误判?做好两个配置项,让老主库在主从切换期间暂停接收客户端请求。
假设我们将 min-slaves-to-write 设置为 1,min-slaves-max-lag 设置为 15s,哨兵的 down-after-milliseconds 设置为 10s,哨兵主从切换需要 5s。主库因为某些原因卡住了 12s,此时,还会发生脑裂吗?主从切换完成后,数据会丢失吗?
34.第23~33讲课后思考题答案及常见问题答疑(暂略待补充)
小结
提问
35.Codis VS Redis Cluster:我该选择哪一个集群方案?
之前我们已经学过Redis Cluster集群方案。今天来看下,Redis Cluster发布之前,Redis的集群解决方案:Codis。由豌豆荚开发。
1.Codis 的整体架构和基本流程
1.整体架构
Codis 集群中包含了 4 类关键组件。
- codis server:二次开发的 Redis 实例,增加了额外的数据结构,支持数据迁移操作。主要负责处理具体的数据读写请求。
- codis proxy:接收客户端请求,并把请求转发给 codis server。
- Zookeeper 集群:保存集群元数据。例如数据位置信息和 codis proxy 信息。
- codis dashboard 和 codis fe:共同组成了集群管理工具。
- codis dashboard :负责执行集群管理工作,包括增删 codis server、codis proxy 和进行数据迁移。
- codis fe :负责提供 dashboard 的 Web 操作界面,便于我们直接在 Web 界面上进行集群管理。
总体如下图所示:
2.基本流程
Codis 的请求处理流程:
- 设置访问地址:用 codis dashboard 设置 codis server 和 codis proxy 的访问地址,完成设置后,codis server 和 codis proxy 才会开始接收连接。
- 客户端直接访问codis proxy:当客户端要读写数据时,客户端直接和 codis proxy 建立连接。codis proxy 本身支持 Redis 的 RESP 交互协议,所以,客户端访问 codis proxy 时,和访问原生的 Redis 实例没什么区别。
- codis proxy转发请求并返回结果:
- codis proxy 接收到请求,会查询请求数据和 codis server 的映射关系,把请求转发给相应的 codis server 进行处理。
- codis server 处理完请求后,会把结果返回给 codis proxy,proxy 再把数据返回给客户端。
整体流程如下图所示:
2.Codis 的关键技术原理
影响切片集群使用效果的技术因素主要是 4 个方面:
- 数据分布
- 集群扩容和数据迁移
- 客户端兼容性
- 可靠性保证
下面一一来进行分析,Codis的设计取舍。
1.数据如何在集群里分布?
1.数据分布算法
在 Codis 集群中,一个数据应该保存在哪个 codis server 上,是通过逻辑槽(Slot)映射来完成的。具体来说,总共分成两步:
- 给server手动或自动分配Slot:Codis 集群一共有 1024 个 Slot,编号依次是 0 到 1023。可以把它们手动分给codis server,每个 server 上包含一部分 Slot。也可以让 codis dashboard 进行自动分配,例如均分。
- 计算哈希值并取模得到slot:当客户端要读写数据时,使用 CRC32 算法计算数据 key 的哈希值,并将该哈希值对 1024 取模,即可得到数据所在slot。从而找到server。
数据、Slot 和 codis server 的映射保存关系示例如下图:
2.映射关系保存
数据路由表:我们把 Slot 和 codis server 的映射关系称为数据路由表(简称路由表),需要用一个存储系统来保存它。
保存方式如下:
- dashboard -> proxy:我们在 codis dashboard 上分配好路由表后,dashboard 会把路由表发送给 codis proxy。codis-proxy 会把路由表缓存在本地,当它接收到客户端请求后,直接查询本地的路由表。
- dashboard -> Zookeeper :同时,dashboard 也会把路由表保存在 Zookeeper 中。
如下图所示:
![image-20220227145719264](D:\Program Files (x86)\Typora\md-images\image-20220227145719264.png)
3.Codis和Redis Cluster数据分布的区别
Codis 和 Redis Cluster 很相似,都采用了 key 映射到 Slot、Slot 再分配到实例上的机制。
但是路由表修改时,有一个明显的区别:
- Codis路由表修改效率高:Codis路由表是保存在Zookeeper,并缓存在proxy。数据位置发生变化时(例如有实例增减),codis dashbaord 直接把最新路由表发送给 codis proxy,proxy 可以立即根据最新的路由信息转发请求了。
- Redis Cluster修改效率低:RedisCluster路由表是每个实例上保存一份,通过实例相互间进行通信传递。路由表修改时,需要在所有实例间相互通过网络消息来传递。如果实例数量较多,会消耗较多集群网络资源。
2.集群扩容和数据迁移如何进行?
前文讲了数据分布。数据量太大时,就需要集群扩容和数据迁移。
Codis 集群扩容包括了两方面:增加 codis server 和增加 codis proxy。
1.增加 codis server
增加 codis server 主要是两步:
- 启动新的 codis server,将它加入集群;
- 把部分数据迁移到新的 server。
数据迁移流程,按照Slot的粒度进行数据迁移:
- 源server随机发送:在源 server 上,Codis 从要迁移的 Slot 中随机选择一个数据,发送给目的 server。
- 目的 server 确认后,源 server删除:此时,源 server 删除刚才迁移的数据。
- 前两步就是单个数据的迁移过程。不断重复此过程,直到要迁移的 Slot 中的数据全部迁移完成。
总体流程如下图:
针对单个数据,Codis 实现了两种迁移模式:
- 同步迁移:数据发送时,源 server 阻塞,无法处理新的请求。
- 优点:实现简单。
- 缺点:源 server 可能会阻塞很久。因为迁移过程中会涉及多个操作(包括数据在源 server 序列化、网络传输、在目的 server 反序列化,以及在源 server 删除),且迁移的数据可能是一个 bigkey。
- 异步迁移:有以下两个特点:
- 异步执行:源 server 把数据发送给目的 server 后就可处理其他请求。此时源 server 不阻塞,此时被迁移数据标记为只读。目的 server 在收到数据并反序列化保存到本地后,给源 server 发送一个 ACK 消息表示迁移完成。此时,源 server 在本地把刚才迁移的数据删除。
- 拆分指令:对于 bigkey,采用了拆分指令方式。对 bigkey 中每个元素,用一条指令进行迁移,而不是把整个 bigkey 进行序列化后再整体传输。这就避免了 bigkey 迁移时,因为要序列化大量数据而阻塞源 server 的问题。*
异步迁移补充:
- 原子性问题:为了防止bigkey迁移到一半宕机,破坏操作的原子性。会在目标Server给bigkey设定一个过期时间,发生故障时,目标 server 上的 key 会在过期后被删除,不会影响迁移的原子性。正常迁移完成时,过期时间最后会被移除。
- 设置每次迁移多个key以提升效率:为了提升迁移的效率,Codis 在异步迁移 Slot 时,允许每次迁移多个 key。你可以通过异步迁移命令
SLOTSMGRTTAGSLOT-ASYNC
的参数numkeys
设置每次迁移的 key 数量。
2.增加 codis proxy
所有的请求都是经过codis proxy,请求量大时,需要扩充proxy。
增加 proxy :比较容易,我们直接启动 proxy,再通过 codis dashboard 把 proxy 加入集群就行。
客户端通过zookeeper获取新proxy信息:新的 proxy 注册到 Zookeeper 后,客户端就可以从 Zookeeper 上读取 proxy 访问列表,把请求发送给新增的 proxy。
整体流程如下图所示:
3.集群客户端需要重新开发吗?
客户端访问单例和访问集群不同:
- 访问 Redis 单实例:客户端只要符合 RESP 协议,就可以和实例进行交互和读写数据。
- 访问切片集群:多了数据迁移操作等功能,且数据访问请求可能要被重定向(如 Redis Cluster 中的 MOVE 命令)。所以,客户端可能需要增加和集群功能相关的命令操作的支持。
Codis集群在设计时,就充分考虑了对现有单实例客户端的兼容性:
- Codis 使用 codis proxy 直接和客户端连接,codis proxy 符合 RESP 协议,是和单实例客户端兼容的。
- 集群相关的管理工作(例如请求转发、数据迁移等),都由 codis proxy、codis dashboard 这些组件来完成,不需要客户端参与。
综上,Codis无需开发新的集群客户端。
4.怎样保证集群可靠性
对于一个分布式系统来说,它的可靠性和系统中的组件个数有关:组件越多,潜在的风险点也就越多。Redis Cluster 只包含 Redis 实例,而Codis 集群包含的组件有 4 类。那么多组件会降低 Codis 集群的可靠性吗?
下面我们来看Codis 不同组件的可靠性保证方法。
1.codis server
codis server 其实就是 Redis 实例,只不过增加了和集群操作相关的命令。
使用主从集群来保证 codis server 的可靠性:Codis 给每个 server 配置一个或多个从库,并使用哨兵机制进行监控,故障时自动进行主从切换。
如下图所示:
2.codis proxy 和 Zookeeper
因为它们是一起搭配使用的,所以一起来讲。
在 Codis 集群设计时,proxy 上的信息源头都是来自 Zookeeper(例如路由表)。
Zookeeper 集群使用多个实例来保存数据,只要有超过半数的实例正常,整个集群就正常。
当 codis proxy 发生故障后,直接重启 proxy 即可。重启后的 proxy,重新注册到Zookeeper,并可通过 codis dashboard 从 Zookeeper 集群上获取路由表。
3.codis dashboard 和 codis fe
主要提供配置管理和管理员手工操作,负载压力不大,不用提供额外的可靠性保证。
5.切片集群方案选择建议
Codis 和 Redis Cluster两种方案区别如下图所示:
两种方案的抉择,有 4 条建议:
- Codis更成熟稳定:从稳定性和成熟度来看,Codis 应用得比较早,在业界已经有了成熟的生产部署。虽然 Codis 引入了 proxy 和 Zookeeper,增加了集群复杂度,但是,proxy 的无状态设计和 Zookeeper 自身的稳定性,也给 Codis 的稳定使用提供了保证。而 Redis Cluster 的推出时间晚于 Codis,相对来说,成熟度要弱于 Codis,如果你想选择一个成熟稳定的方案,Codis 更加合适些。
- Codis对老的单例客户端兼容性更好,不过不是单例客户端就无所谓:从业务应用客户端兼容性来看,连接单实例的客户端可以直接连接 codis proxy,而原本连接单实例的客户端要想连接 Redis Cluster 的话,就需要开发新功能。所以,如果你的业务应用中大量使用了单实例的客户端,而现在想应用切片集群的话,建议你选择 Codis,这样可以避免修改业务应用中的客户端。
- Cluster支持最新的命令和特性:从使用 Redis 新命令和新特性来看,Codis server 是基于开源的 Redis 3.2.8 开发的,所以,Codis 并不支持 Redis 后续的开源版本中的新增命令和数据类型。另外,Codis 并没有实现开源 Redis 版本的所有命令,比如 BITOP、BLPOP、BRPOP,以及和与事务相关的 MUTLI、EXEC 等命令。Codis 官网上列出了不被支持的命令列表,你在使用时记得去核查一下。所以,如果你想使用开源 Redis 版本的新特性,Redis Cluster 是一个合适的选择。
- Codis数据迁移性能更好,不过不是频繁迁移也无所谓:从数据迁移性能维度来看,Codis 能支持异步迁移,异步迁移对集群处理正常请求的性能影响要比使用同步迁移的小。所以,如果你在应用集群时,数据迁移比较频繁的话,Codis 是个更合适的选择。
小结
主流的 Redis 集群方案大概分为三种:
- Redis Cluster
- 大厂/小项目组开源的解决方案—— Twitter 开源的 Twemproxy、Codis
- 买专有的 Redis 服务器—— Aliyun AparaCache(这个开源的没有 slot 的实现)、AWS ElasticCache
Codis 集群包含 codis server、codis proxy、Zookeeper、codis dashboard 和 codis fe 这四大类组件:
- codis proxy 和 codis server 负责处理数据读写请求,其中,codis proxy 和客户端连接,接收请求,并转发请求给 codis server,而 codis server 负责具体处理请求。
- codis dashboard 和 codis fe 负责集群管理,其中,codis dashboard 执行管理操作,而 codis fe 提供 Web 管理界面。
- Zookeeper 集群负责保存集群的所有元数据信息,包括路由表、proxy 实例信息等。这里,有个地方需要你注意,除了使用 Zookeeper,Codis 还可以使用 etcd 或本地文件系统保存元数据信息。
关于 Codis 和 Redis Cluster 的选型,可以从稳定性成熟度、客户端兼容性、Redis 新特性使用以及数据迁移性能四个方面考虑。
一个Codis 使用上的小建议:当你有多条业务线要使用 Codis 时,可以启动多个 codis dashboard,每个 dashboard 管理一部分 codis server,同时,再用一个 dashboard 对应负责一个业务线的集群管理,这样,就可以做到用一个 Codis 集群实现多条业务线的隔离管理了。
提问
Codis的整体架构?客户端请求访问流程?客户端 -> proxy -> server
Codis的数据怎么分布的?slot分布。和RedisCluster的区别时,一个从proxy中心获取路由,一个每个实例分布式保存路由。
Codis集群扩容时如何做?proxy扩容,直接扩。server扩容,同步迁移 / 异步迁移。
Codis集群可靠性如何保证?server、proxy/zookeeper、dashboard/fe。
Codis和RedisCluster如何选择?成熟稳定、单例客户端(Codis直接访问proxy,跟单例一样)、新特性、数据迁移性能(Codis支持异步迁移)。
假设 Codis 集群中保存的 80% 的键值对都是 Hash 类型,每个 Hash 集合的元素数量在 10 万~20 万个,每个集合元素的大小是 2KB。你觉得,迁移一个这样的 Hash 集合数据,会对 Codis 的性能造成影响吗?不会有性能影响…
36.Redis支撑秒杀场景的关键技术和实践都有哪些
秒杀场景的业务特点是限时限量,即用户只能在某个时间点买,且库存量非常有限。业务系统要处理瞬时的大量高并发请求。
不过,秒杀场景包含了多个环节,可以分成秒杀前、秒杀中和秒杀后三个阶段,每个阶段的请求处理需求并不相同,Redis 并不能支撑秒杀场景的每一个环节。
本节主题:Redis 在秒杀场景中具体是在哪个环节起到支撑作用?又是如何支持?
先来分析业务场景。
1.秒杀场景的负载特征对支撑系统的要求
秒杀场景的负载特征:
- 瞬时并发访问量非常高:一般的DB每秒支撑千级别的请求,Redis能到万级。大量并发请求涌入时,需要用 Redis 先拦截大部分请求,避免请求打到DB。
- 读多写少,且读的是简单内容:秒杀场景,用户要先查库存余量,有库存才能下单。商品库存查询操作(读操作)要远多于库存扣减和下单操作(写操作)。
Redis 可以在哪些环节发挥作用呢?下面来看下秒杀活动的整体流程。
2.Redis 可以在秒杀场景的哪些环节发挥作用?
分为活动前、中、后3个阶段,每个阶段,Redis的作用也不一样。
1.秒杀活动前
场景:用户不断刷新商品详情页。
处理方式:尽量把商品详情页的页面元素静态化,然后使用 CDN 或是浏览器把这些静态化的元素缓存起来,此时请求很少能到服务端。
有 CDN 和浏览器缓存就OK,无需Redis。
2.秒杀活动中
场景:大量点击秒杀按钮:
- 库存查验:最大的并发压力在该步骤上。
- 库存扣减:有库存就扣减库存。
- 订单处理:扣减库存成功后创建订单。
处理方式:
- 库存查验环节:使用 Redis 保存库存量。
- 库存扣减环节:使用 Redis ,不能交给DB处理。交给DB会有两个问题:
- 额外的开销:Redis保存库存量,库存量最新值又在DB,DB更新后需要同步到Redis,增加了额外操作。
- 超售,下单量大于实际库存量:DB太慢,大量请求会在Redis读到旧的库存量,出现超卖。
- 订单处理环节:可以在DB执行。虽然操作复杂,还会涉及多张表,但是请求压力已经不大,而且需要保证多张表的事务性,所以可以用DB处理。订单处理会涉及支付、商品出库、物流等多个关联操作,这些操作本身涉及数据库中的多张数据表,要保证处理的事务性,需要在数据库中完成。
具体处理流程:直接在 Redis 中进行库存扣减。库存查验完成后,一旦库存有余量,就立即在 Redis 中扣减库存。而且为了避免请求查询到旧的库存值,库存查验和库存扣减这两个操作需要保证原子性。
3.秒杀活动结束后
场景:
- 部分未买到的用户:少量刷新商品详情页,尝试等待有其他用户退单。
- 已成功下单的用户:刷新订单详情,跟踪订单的进展。
此时并发量已经下来了,服务端一般能够支撑。
综上,在秒杀场景中需要 Redis 参与的两个环节如下图所示:
3.Redis 的哪些方法可以支撑秒杀场景?
秒杀场景对 Redis 操作的根本要求有两个:
- 支持高并发:Redis 本身高并发就能够支撑。如果有多个秒杀商品,可以用切片集群保存。
- 库存查验和库存扣减原子性执行:用 Redis 的原子操作或是分布式锁即可。
下面来看前面提到的两个方法,如何基于原子操作或分布式锁来支撑秒杀场景。
1.基于原子操作支撑秒杀场景
数据结构选择:保存商品信息:在秒杀场景中,一个商品的库存对应了两个信息,分别是总库存量和已秒杀量。这个数据模型正好是一个 key(商品 ID)对应了两个属性(总库存量和已秒杀量),可以用一个 Hash 来保存,如下所示:
key: itemID # 商品编号
value: {total: N, ordered: M} # 总库存量,已秒杀量
实现思路:库存查验和库存扣减是两个操作,无法用一条原子命令解决,我们用Lua脚本搞定,在 Redis 客户端用 EVAL 命令执行该脚本即可:
# Lua 脚本伪代码示例
# 获取商品库存信息
local counts = redis.call("HMGET", KEYS[1], "total", "ordered");
# 将总库存转换为数值
local total = tonumber(counts[1])
# 将已被秒杀的库存转换为数值
local ordered = tonumber(counts[2])
# 如果当前请求的库存量加上已被秒杀的库存量仍然小于总库存量,就可以更新库存
if ordered + k <= total then
# 更新已秒杀的库存量
redis.call("HINCRBY",KEYS[1],"ordered",k) return k;
end
return 0
也可以用分布式锁,下面来看下分布式锁。
2.基于分布式锁来支撑秒杀场景
实现思路:客户端向 Redis 申请分布式锁,只有拿到锁的客户端才能执行库存查验和库存扣减。没拿到锁的客户端直接返回秒杀失败即可,此时大量的请求就被抢锁这个操作过滤掉了。
伪代码示例如下:
//使用商品ID作为key
key = itemID
//使用客户端唯一标识作为value
val = clientUniqueID
//申请分布式锁,Timeout是超时时间
lock =acquireLock(key, val, Timeout)
//当拿到锁后,才能进行库存查验和扣减
if(lock == True) {
//库存查验和扣减
availStock = DECR(key, k)
//库存已经扣减完了,释放锁,返回秒杀失败
if (availStock < 0) {
releaseLock(key, val)
return error
}
//库存扣减成功,释放锁
else{
releaseLock(key, val)
//订单处理
}
}
//没有拿到锁,直接返回
else
return
小建议:切片集群中分布式锁和商品库存信息不要放到同一个实例,缓解单个实例的压力。
小结
秒杀场景有 2 个负载特征
- 瞬时高并发请求
- 读多写少
处理场景:
- 秒杀前:无需Redis,CDN即可。
- 秒杀中:查询库存 -> 扣减库存 -> 订单操作,前两步需要Redis,且需要保证两步操作的原子性(原子性命令/Lua脚本或分布式锁)。
- 秒杀后:已买用户查订单,未买用户查有没有退单,正常流量,服务端能够支撑。
秒杀系统是一个系统性工程,Redis 实现了对库存查验和扣减这个环节的支撑,此外,还有 4 个环节需要处理好:
- 前端静态页面的设计:秒杀页面上能静态化处理的页面元素,尽量静态化,这样可以充分利用 CDN 或浏览器缓存服务秒杀开始前的请求。
- 请求拦截和流控:在秒杀系统的接入层对恶意请求进行拦截,避免对系统的恶意攻击,例如使用黑名单禁止恶意 IP 进行访问。如果 Redis 实例的访问压力过大,为了避免实例崩溃,我们也需要在接入层进行限流,控制进入秒杀系统的请求数量。
- 库存信息过期时间处理:Redis 中保存的库存信息其实是数据库的缓存,为了避免缓存击穿问题,我们不要给库存信息设置过期时间。
- 数据库订单异常处理:如果数据库没能成功处理订单,可以增加订单重试功能,保证订单最终能被成功处理。
一个小建议:用单独的实例保存秒杀商品的库存信息,不要和日常业务系统的数据保存在一个实例,避免相互干扰。
提问
如何使用Redis支持秒杀场景?秒杀前,无需Redis,CDN即可。秒杀中,查询库存 -> 扣减库存 -> 订单操作,前两步需要Redis,且需要保证两步操作的原子性(原子性操作或分布式锁)。秒杀后:已买用户查订单,未买用户查有没有退单,正常流量,服务端能够支撑。
分布式锁有什么用?例如,Redis原子性操作,不想用Lua脚本时。
假设一个商品的库存量是 800,我们使用一个包含了 4 个实例的切片集群来服务秒杀请求。我们让每个实例各自维护库存量 200,然后,客户端的秒杀请求可以分发到不同的实例上进行处理,你觉得这是一个好方法吗?使用切片集群分担秒杀请求,可以降低每个实例的请求压力,前提是秒杀请求可以平均打到每个实例上,否则会出现秒杀请求倾斜的情况,反而会增加某个实例的压力,而且会导致商品没有全部卖出的情况。
37.数据分布优化:如何应对数据倾斜?
在切片集群,虽然按照 CRC 算法把数据分配到不同的slot上,分配算法简单,但是也有数据倾斜问题。
数据倾斜:
- 数据量的倾斜:在某些情况下,一部分实例上的数据很多。
- 数据访问倾斜:虽然每个实例数据量差不多,但某个实例上的数据是热点数据,访问量很大。
数据倾斜时,某些实例的压力很大,可能会导致内存耗尽而崩溃。
本节主题:两种数据倾斜如何发生?如何避免?
1.数据量倾斜成因及应对方法
数据量倾斜主要是三个原因:
- 某个实例保存了 bigkey
- Slot 分配不均衡
- Hash Tag
1.bigkey 导致倾斜
产生原因:
- bigkey占用内存:实例上保存了 bigkey。bigkey 的 value 值很大(String 类型),或者是 bigkey 保存了大量集合元素(集合类型),从而占用大量内存。
- bigkey阻塞 IO:bigkey 访问量比较大时,会阻塞该实例的 IO 线程,从而影响实例上其它请求的处理速度。
应对方法:
- 避免产生bigkey:在业务层生成数据时,要尽量避免把过多的数据保存在同一个键值对。
- 拆分bigkey集合:如果 bigkey 是集合类型,可以把 bigkey 拆成很多个小集合,分散保存到不同实例。例如,把100万个值的Hash拆为10个10万的Hash,分布到不同实例。
2.Slot 分配不均衡导致倾斜
产生原因:
- 运维人员手动给性能强的实例多分配Slot。但是其实Slot和性能需求并不一定是相关的,例如前面的bigkey。
解决方法:
- 查看slot分布,避免过多的 Slot 集中到了同一个实例,否则进行Slot迁移。
查看Slot 分配:
- Redis Cluster:CLUSTER SLOTS 命令。
- Codis:在 codis dashboard 上查看。
查看示例:
127.0.0.1:6379> cluster slots
1) 1) (integer) 0
2) (integer) 4095
3) 1) "192.168.10.3"
2) (integer) 6379
2) 1) (integer) 12288
2) (integer) 16383
3) 1) "192.168.10.5"
2) (integer) 6379
当分配不均匀时,就需要slot迁移。
Redis Cluster 中 Slot 的迁移命令,用3个完成:
CLUSTER SETSLOT
:使用选项进行三种设置,分别是设置 Slot 要迁入的目标实例,Slot 要迁出的源实例,以及 Slot 所属的实例。CLUSTER GETKEYSINSLOT
:获取某个 Slot 中一定数量的 key。MIGRATE
:把一个 key 从源实例实际迁移到目标实例。
RedisCluster迁移示例,把 Slot 300 从源实例(ID 为 3)迁移到目标实例(ID 为 5),分5步:
# 1.在目标实例 5 上执行命令:将 Slot 300 的源实例设置为实例 3,表示要从实例 3 上迁入 Slot 300。
CLUSTER SETSLOT 300 IMPORTING 3
# 2.在源实例 3 上执行命令:把 Slot 300 的目标实例设置为 5,表示 Slot 300 要迁出到实例 5 上
CLUSTER SETSLOT 300 MIGRATING 5
# 3.分批迁移Slot里面的key:从 Slot 300 中获取 100 个 key。因为 Slot 中的 key 数量可能很多,所以需要在客户端上多次执行下面的命令,分批次获得并迁移 key。
CLUSTER GETKEYSINSLOT 300 100
# 4.把刚才获取的 100 个 key 中的 key1 迁移到目标实例 5 上(IP 为 192.168.10.5),同时把要迁入的数据库设置为 0 号数据库,把迁移的超时时间设置为 timeout。重复执行 MIGRATE 命令,把 100 个 key 都迁移完。
MIGRATE 192.168.10.5 6379 key1 0 timeout
# 5.重复执行第 3 和第 4 步,直到 Slot 中的所有 key 都迁移完成。
从 Redis 3.0.6 开始,可以使用 KEYS 选项,一次迁移多个 key(key1、2、3),提升迁移效率:
MIGRATE 192.168.10.5 6379 "" 0 timeout KEYS key1 key2 key3
Codis 迁移示例,执行下面的命令进行数据迁移。其中,把 dashboard 组件的连接地址设置为 ADDR,并且把 Slot 300 迁移到编号为 6 的 codis server group 上:
codis-admin --dashboard=ADDR -slot-action --create --sid=300 --gid=6
3.Hash Tag 导致倾斜
Hash Tag :是指加在键值对 key 中的一对花括号{}。这对括号会把 key 的一部分括起来,客户端在计算 key 的 CRC16 值时,只对 Hash Tag 花括号中的 key 内容进行计算。如果没用 Hash Tag 的话,客户端计算整个 key 的 CRC16 的值。例如,key为user:profile:{3231},则计算CRC16时只按3231计算。 如果Hash Tag 内容都一样,那么,这些 key 对应的数据会被映射到同一个 Slot 中。
为什么要用HashTag:用在 Redis Cluster 和 Codis 中,支持事务操作和范围查询。因为 Redis Cluster 和 Codis 本身并不支持跨实例的事务操作和范围查询,当业务应用有这些需求时,就只能:1.事务处理:把这些数据读取到业务层进行事务处理。2.范围查询:逐个查询每个实例,得到范围查询的结果。而此时,直接使用HashTag把数据都映射到同一个实例,就避免了跨实例访问的问题了。
很显然,使用Hash Tag会导致数据倾斜,我们需要在范围查询、事务执行的需求和数据倾斜带来的访问压力之间进行取舍。
应对建议:如果使用 Hash Tag 会带来较大的访问压力,那么最好是不要用 Hash Tag。我们需要保证实例的稳定,客户端是可以扩展的,数据放到客户端处理没有问题。
2.数据访问倾斜的成因和应对方法
根本原因:数据访问倾斜的根本原因,就是实例上存在热点数据。
热点数据通常是一个或几个数据,直接重新分配 Slot 并不能解决热点数据问题。
应对方案:热点数据以服务读操作为主,我们可以采用热点数据多副本的方法来应对。
热点数据多副本:把热点数据复制多份,在每一个数据副本的 key 中增加一个随机前缀,让它和其它副本数据不会被映射到同一个 Slot 中。
热点数据多副本方法只能针对只读的热点数据。写数据时,需要额外保证多副本之间的数据一致性,此时只能通过增加硬件性能来解决问题。
小结
数据倾斜的两种情况:
- 数据量倾斜、数据访问倾斜。
数据量倾斜的原因主要有三个:
- 数据中有 bigkey,导致某个实例的数据量增加;
- Slot 手工分配不均,导致某个或某些实例上有大量数据;
- 使用了 Hash Tag,导致数据集中到某些实例上。
数据访问倾斜的主要原因:
- 有热点数据存在,导致大量访问请求集中到了热点数据所在的实例上。
应对数据倾斜问题的四个方法,如下图所示:
如果已经发生了数据倾斜,可以通过数据迁移来缓解。Redis Cluster 和 Codis 集群都提供了查看 Slot 分配和手工迁移 Slot 的命令。
关于集群的实例资源配置的小建议:在构建切片集群时,尽量使用大小配置相同的实例(例如实例内存配置保持相同),这样可以避免因实例资源不均衡而在不同实例上分配不同数量的 Slot。
提问
什么是数据倾斜?有哪些原因?如何应对?
在有数据访问倾斜时,如果热点数据突然过期了,而 Redis 中的数据是缓存,数据的最终值保存在后端数据库,此时会发生什么问题?此时会发生缓存击穿,热点请求会直接打到后端数据库上,数据库的压力剧增,可能会压垮数据库。
38.通信开销:限制Redis Cluster规模的关键因素
Redis 官方给出的 Redis Cluster 的规模上限,是一个集群运行 1000 个实例。
为什么要限定集群规模呢?因为实例间的通信开销会随着实例规模增加而增大,在集群超过一定规模时(比如 800 节点),集群吞吐量反而会下降。所以,集群的实际规模会受到限制。
本节主题:集群实例间的通信开销如何影响 Redis Cluster 规模,如何降低实例间的通信开销。
目的:通过合理的配置来扩大 Redis Cluster 的规模,同时保持高吞吐量。
1.实例通信方法和对集群规模的影响
Redis Cluster 在运行时,每个实例上都会保存Slot 映射表,以及自身的状态信息。
Gossip 协议:为了让集群中的每个实例都知道其它所有实例的状态信息,实例之间会按照一定的规则进行通信。这个规则就是 Gossip 协议。
Gossip 协议的工作原理,可概括为两点:
- 定期发送检测消息:每个实例之间会按照一定的频率,从集群中随机挑选一些实例,把 PING 消息发送给挑选出来的实例,用来检测这些实例是否在线,并交换彼此的状态信息。PING 消息中封装了发送消息的实例自身的状态信息、部分其它实例的状态信息,以及 Slot 映射表。
- 回复检测消息:一个实例在接收到 PING 消息后,会给发送 PING 消息的实例,返回一个 PONG 消息。PONG 消息包含的内容和 PING 消息一样。
这样,Gossip 协议可以保证在一段时间后,集群中的每一个实例都能获得其它所有实例的状态信息。
流程如下图所示:
由上可知,实例间使用 Gossip 协议进行通信时,通信开销受到通信消息大小和通信频率两方面的影响。
下面来看下这两方面影响。
1.Gossip 消息大小
PING 消息的消息体是 clusterMsgDataGossip
结构体,104字节,如下所示:
typedef struct {
char nodename[CLUSTER_NAMELEN]; //40字节。表示 nodename
uint32_t ping_sent; //4字节
uint32_t pong_received; //4字节
char ip[NET_IP_STR_LEN]; //46字节。表示ip。
uint16_t port; //2字节
uint16_t cport; //2字节
uint16_t flags; //2字节
uint32_t notused1; //4字节
} clusterMsgDataGossip;
Gossip消息的大小:
- 状态消息:每个实例在发送一个 Gossip 消息时,除了传递自身的状态信息,默认还会传递集群十分之一实例的状态信息。对于一个1000实例的集群,包含100个实例信息,及10400字节,约10KB。
- Slot映射表:PING 消息中还带有一个长度为 16,384 bit 的 Bitmap,这个 Bitmap 的每一位对应了一个 Slot,如果某一位为 1,就表示这个 Slot 属于当前实例。这个 Bitmap 大小换算成字节后,是 2KB。
- PONG 消息:和 PING 消息的内容一样,大小大约是 12KB。每次发送一个PING-PONG,共计24KB。
虽然24KB不大,但是假如客户端的访问正常也就是几KB,那么显然PING-PONG的24KB是比较大的。集群规模增大时,就占据了一部分集群的网路带宽。
除了通信消息大小,通信频率也会影响通信开销,下面来看下。
2.实例间通信频率
定时随机选取发送:Redis Cluster 的实例启动后,默认会每秒从本地的实例列表中随机选出 5 个实例,再从这 5 个实例中找出一个最久没有通信的实例,把 PING 消息发送给该实例。
可能的问题:选出来的最久未通信实例,是从随机的5个实例中选出来的,不一定是最久未通信的。就有可能,有些实例一直没有被发送 PING 消息,导致它们维护的集群状态已经过期。
定期扫描检查发送:为解决上述问题,Redis Cluster 的实例会按照每 100ms 一次的频率,扫描本地的实例列表,如果发现有实例最近一次接收 PONG 消息的时间大于配置项 cluster-node-timeout 的一半了(cluster-node-timeout/2),就会立刻给该实例发送 PING 消息,更新这个实例上的集群状态信息。
集群规模扩大导致延迟增加:集群规模扩大时,网络拥塞或不同服务器间流量竞争,导致实例间网络通信延迟增加。此时若部分示例未能及时收到其他实例发送的PONG消息,就会引起实例之间频繁地发送 PING 消息,这又会对集群网络通信带来额外的开销了。恶性循环。
单实例每秒会发送的 PING 消息数量,如下所示:
- PING 消息发送数量 =
1 + 10 * 实例数
(最近一次接收 PONG 消息的时间超出 cluster-node-timeout/2)
1是指每 1 秒发送一个 PING 消息,10 是指每 1 秒内实例执行 10 次检查,每次检查后给 PONG 消息超时的实例发送消息。
示例:假设单个实例检测发现,每 100 毫秒有 10 个实例的 PONG 消息接收超时,那么,这个实例每秒就会发送 101 个 PING 消息,约占 1.2MB/s 带宽。如果集群中有 30 个实例按照这种频率发送消息,就会占用 36MB/s 带宽,这就会挤占集群中用于服务正常请求的带宽。
所以,要想办法降低通信开销。
2.如何降低实例间的通信开销?
显然无法减小实例传输的消息大小(PING/PONG 消息、Slot 分配信息)。
只能同发送消息的频率想办法:
每个实例每 1 秒发送一条 PING 消息:这个频率不算高,如果再降低该频率的话,集群中各实例的状态可能就没办法及时传播了。
每个实例每 100 毫秒做一次检测:给 PONG 消息接收超过 cluster-node-timeout/2 的节点发送 PING 消息。实例按照每 100 毫秒进行检测的频率,是 Redis 实例默认的周期性检查任务的统一频率,我们一般不需要修改它。
综上,就只有修改 cluster-node-timeout
配置项了。
cluster-node-timeout
:集群实例被判断为故障的心跳超时时间,默认 15 秒。
集群规模较大时,为了避免过多的心跳消息挤占集群带宽,我们可以调大 cluster-node-timeout
值,比如说调大到 20 秒或 25 秒,从而缓解 PONG 消息接收超时的情况,不用频繁每秒发送10次PING。
也不可调太大,否则就需要等待 cluster-node-timeout 时长后,才能检测到实例故障,会导致实际的故障恢复时间被延长,影响集群服务的正常使用。
调整建议:可以在调整 cluster-node-timeout 值的前后,使用 tcpdump 命令抓取实例发送心跳信息网络包情况来分析。通过分析网络包的数量和大小,就可判断调整 cluster-node-timeout 值前后,心跳消息占用的带宽情况了。
如下所示:
tcpdump host 192.168.10.3 port 16379 -i 网卡名 -w /tmp/r1.cap
在实际应用中,如果不是特别需要大容量集群,我建议你把 Redis Cluster 的规模控制在 400~500 个实例。
小结
通信消息大小:状态消息、Slot信息、PONG消息。
通信频率:每秒随机选取发送1个,每秒扫描选取发送最多10个(PONG超时)。可通过修改cluster-node-timeout
值来缓解过多的PONG过期,而导致过多的PING发送。
在实际应用中,如果不是特别需要大容量集群,建议把 Redis Cluster 的规模控制在 400~500 个实例。
假设单个实例每秒能支撑 8 万请求操作(8 万 QPS),每个主实例配置 1 个从实例,那么,500 个实例可支持 2000 万 QPS(250 个主实例 * 8 万 QPS = 2000 万 QPS),这个吞吐量性能可以满足不少业务应用的需求。
提问
RedisCluster的实例之间通过什么通信?Gossip协议。
RedisCluster最多可以运行多少个实例?跟通信开销有关。Gossip协议、通信消息大小、通信频率。
如果我们采用跟 Codis 保存 Slot 分配信息相类似的方法,把集群实例状态信息和 Slot 分配信息保存在第三方的存储系统上(例如 Zookeeper),这种方法会对集群规模产生什么影响吗?因为集群的信息保存在了第三方存储系统上,意味着redis cluster内部不用再沟通了,这将节省下大量的集群内部的沟通成本。当然就整个集群而言部署、维护也会更加复杂,毕竟引入了一个第三方组件来管理集群。
未来篇(39-40)
39.Redis 6.0的新特性:多线程、客户端缓存与安全
Redis在2020年5月推出了 6.0 版本,增加了很多新特性。来了解一下。
几个关键新特性:
- 面向网络处理的多 IO 线程:提升Redis性能。提高网络请求处理的速度
- 客户端缓存:提升Redis性能。让应用可以直接在客户端本地读取数据
- 细粒度的权限控制: 加强 Redis 安全保护,可以按照命令粒度控制不同用户的访问权限。
- RESP 3 协议的使用:增强客户端的功能,让应用更加方便地使用 Redis 的不同数据类型。
1.从单线程处理网络请求到多线程处理
1.为什么要用多线程网络IO
遇到的问题:随着网络硬件的性能提升,Redis 的性能瓶颈有时会出现在网络 IO 的处理上,也就是说,单个主线程处理网络请求的速度跟不上底层网络硬件的速度。
解决思路有两个:
用用户态网络协议栈(例如 DPDK)取代内核网络协议栈:让网络请求的处理不用在内核里执行,直接在用户态完成处理就行。
避免频繁让内核进行网络请求处理,可以很好地提升请求处理效率。但是,这个方法需要在 Redis 的整体架构中,添加对用户态网络协议栈的支持,需要修改 Redis 源码中和网络相关的部分(例如修改所有的网络收发请求函数)。工作量大,而且可能引入大量Bug。所以没有采用该方案。
用多个 IO 线程来处理网络请求,提高网络请求处理的并行度:6.0使用了该方案。
但是,Redis 的多 IO 线程只是用来处理网络请求的,对于读写命令,Redis 仍然使用单线程来处理。因为Redis处理请求,一般瓶颈在网络。且单线程命令避免额外开发复杂的多线程互斥机制。
2.Redis6.0多线程网络IO原理
下面来看,在 Redis 6.0 中,主线程和 IO 线程具体如何协作完成请求处理。多线程主要是用来读取解析Socket请求、结果回写Socket。
请求处理分为四个阶段:
1.阶段一:服务端和客户端建立 Socket 连接,主线程分配处理线程
主线程负责接收建立连接请求。当有客户端请求和实例建立 Socket 连接时,主线程会创建和客户端的连接,并把 Socket 放入全局等待队列中。紧接着,主线程通过轮询方法把 Socket 连接分配给 IO 线程。
2.阶段二:IO 线程读取并解析请求
主线程一旦把 Socket 分配给 IO 线程,就会进入阻塞状态,等待 IO 线程完成客户端请求读取和解析。因为有多个 IO 线程在并行处理,所以,这个过程很快就可以完成。
3.阶段三:主线程执行请求操作
IO 线程解析完请求后,主线程还是会以单线程的方式执行这些命令操作。
前三步是请求处理,如下图所示:
4.阶段四:IO 线程回写 Socket 和主线程清空全局队列
主线程执行完请求操作后,会把需要返回的结果写入缓冲区,然后,主线程会阻塞等待 IO 线程把这些结果回写到 Socket 中,并返回给客户端。
和 IO 线程读取和解析请求一样,IO 线程回写 Socket 时,也是多个线程在并发执行,所以回写 Socket 的速度也很快。
等到 IO 线程回写 Socket 完毕,主线程会清空全局队列,等待客户端的后续请求。
第四步是返回处理,如下图所示:
5.如何启用多线程
Redis 6.0默认关闭多线程机制,需要使用时,在 redis.conf 中完成两个设置:
启用多线程:设置 io-thread-do-reads 配置项为 yes:表示启用多线程。
io-threads-do-reads yes
设置线程个数:一般需要小于实例的CPU核数。例如,对于8核机器,官方建议配6个线程:
io-threads 6
在实际应用中,如果发现 Redis 实例的 CPU 开销不大,吞吐量却没有提升,就可以考虑使用 Redis 6.0 的多线程机制,加速网络处理,进而提升实例的吞吐量。
2.实现服务端协助的客户端缓存
Redis 6.0 新增了服务端协助的客户端缓存功能,也称为跟踪(Tracking)功能。
把数据缓存到Redis客户端本地,可以快速读取数据。
那么,Redis端数据更新或过期时,怎么处理呢?
1.普通模式和广播模式
6.0 实现的 Tracking 功能实现了两种模式,来解决这个问题:
普通模式:实例在服务端记录客户端读取过的 key,即客户端需要先读取key,在key 的值发生变化时,服务端给客户端发送 invalidate 消息,通知客户端缓存失效。注意到,这种监控是一次性的:
- 服务端对于记录的 key 只会报告一次 invalidate 消息:也就是说,发送完消息后,key第2次修改时,服务端不会再通知。
- 客户端再次执行读命令时,服务端才会再次刷新,重新监测被读取的 key:此时key修改时,才会发送一次invalidate 消息。
- 这样设计是为了节省内存,不要保存无效信息。
- 打开或关闭普通模式下的 Tracking 功能:
CLIENT TRACKING ON|OFF
广播模式:服务端给客户端广播所有 key 的失效情况。注意到客户端无需先读取key。如果key频繁更新,则广播消息会占用大量网络带宽资源。
实际使用:让客户端注册希望跟踪的 key 的前缀,以此限制广播范围,当带有注册前缀的 key 被修改时,服务端会把失效消息广播给所有注册的客户端。之前没访问过key也无所谓。
示例:客户端注册追踪user前缀:
CLIENT TRACKING ON BCAST PREFIX user
# 在客户端执行下面的命令,如果服务端更新了user:id:1003 这个 key,客户端就会收到 invalidate 消息。 CLIENT TRACKING ON BCAST PREFIX user
普通模式和广播模式,需要客户端使用 RESP 3 协议。该协议由Redis6.0引入。
2.重定向模式
对于使用 RESP 2 协议的客户端,需要使用重定向模式:
- 本客户端订阅消息频道:重定向模式下,客户端需要执行订阅命令 SUBSCRIBE,专门订阅用于发送失效消息的频道 _redis_:invalidate。
- 另一个客户端配置消息转发:再使用另外一个客户端,执行 CLIENT TRACKING 命令,设置服务端将失效消息转发给使用 RESP 2 协议的客户端。
使用举例:
假设客户端 B 想要获取失效消息,但是客户端 B 只支持 RESP 2 协议,客户端 A 支持 RESP 3 协议。我们可以分别在客户端 B 和 A 上执行 SUBSCRIBE 和 CLIENT TRACKING,如下所示:
# 键值对被修改时,客户端 B 就可以通过 _redis_:invalidate 频道,获得失效消息了。
# 客户端B执行,客户端B的ID号是303
SUBSCRIBE _redis_:invalidate
# 客户端A执行
CLIENT TRACKING ON BCAST REDIRECT 303
3.从简单的基于密码访问到细粒度的权限控制
访问粒度只有密码:Redis 6.0 版本之前,实例的安全访问只能通过设置密码来控制,例如客户端连接实例前需要输入密码。
避免客户端执行高风险命令:在 Redis 6.0 之前,一些高风险的命令(例如 KEYS、FLUSHDB、FLUSHALL 等),我们只能通过 rename-command 来重新命名这些命令,避免客户端直接调用。
Redis 6.0 提供了更加细粒度的访问权限控制,支持用户创建,以及命令、key粒度的访问控制。主要有两方面:
支持创建不同用户来使用 Redis:6.0 版本前没有用户概念,直接相当于就是root。6.0 版本后,可以使用
ACL SETUSER
命令创建用户。例如,创建并启用一个用户 normaluser,密码为“abc”:ACL SETUSER normaluser on > abc
支持以用户为粒度配置命令操作的访问权限:加号(+)和减号(-)表示给予或收回。如下图所示:
补图。
支持以 key 为粒度设置访问权限:使用波浪号“~”和 key 的前缀来表示控制访问的 key。
使用示例:
# 设置用户 normaluser 只能调用 Hash 类型的命令操作,而不能调用 String 类型的命令
ACL SETUSER normaluser +@hash -@string
# 设置用户 normaluser 只能对以“user:”为前缀的 key 进行命令操作:
ACL SETUSER normaluser ~user:* +@all
4.启用 RESP 3 协议
Redis 6.0 实现了 RESP 3 通信协议,之前使用的是 RESP 2。区别如下:
- 在 RESP 2 中,客户端和服务器端的通信内容都是以字节数组形式进行编码的,客户端需要根据操作的命令或是数据类型自行对传输的数据进行解码,增加了客户端开发复杂度。
- 而 RESP 3 直接支持多种数据类型的区分编码,包括空值、浮点数、布尔值、有序的字典集合、无序的集合等。
- 此外,RESP 3 协议还支持客户端以普通模式和广播模式实现客户端缓存。
所谓区分编码,就是指直接通过不同的开头字符,区分不同的数据类型,这样一来,客户端就可以直接通过判断传递消息的开头字符,来实现数据转换操作了,提升了客户端的效率。
小结
Redis 6.0 的新特性总结如下图:
使用建议:Redis6.0刚出来,还需踩坑。如果要试用 Redis 6.0,先在非核心业务上使用。一方面可以验证新特性带来的性能或功能优势,另一方面,也避免因为新特性不稳定而导致核心业务受到影响。
提问
Redis6.0的新特性有哪些?执行流程?怎么使用?
Redis 6.0 的哪个或哪些新特性会对你有帮助呢?Redis 6.0 提供的多 IO 线程和客户端缓存这两大特性,对于我们使用 Redis 帮助最大。
40.Redis的下一步:基于NVM内存的实践
NVM:新型非易失存储(Non-Volatile Memory,NVM),容量大、性能快、能持久化保存数据。由于NVM 器件像 DRAM 一样,可以让软件以字节粒度进行寻址访问,所以,在实际应用中,NVM 可以作为内存来使用,我们称为 NVM 内存。
Redis 发展的下一步,就可以基于 NVM 内存来实现大容量实例,或者是实现快速持久化数据和恢复。本节来了解下这个趋势。
本节主要内容:NVM 内存的特性,以及软件使用 NVM 内存的两种模式。在不同的使用模式下,软件能用到的 NVM 特性是不一样的。
1.NVM 内存的特性与使用模式
NVM 内存的特点:
- 能持久化保存数据:DRAM 则是掉电后数据就丢失。
- 读写速度和 DRAM 接近:NVM 内存访问速度,实测读延迟大约是 200
300ns,而写延迟大约是 100ns。读写带宽方面,单根 NVM 内存条的写带宽大约是 12GB/s,而读带宽约是 5~6GB/s。 - 容量大:NVM 器件的密度大,单个 NVM 的存储单元可以保存更多数据。单根 NVM 内存条就能达到 128GB 的容量,最大可以达到 512GB。
现在业界已经有了实际的 NVM 内存产品,就是 Intel 在 2019 年 4 月份时推出的 Optane AEP 内存条(简称 AEP 内存)。
AEP 内存给软件提供了两种使用模式,分别对应使用了 NVM 的容量大和持久化保存数据两个特性:
- Memory 模式:把 NVM 内存作为大容量内存来使用。此时没有启用数据持久化的功能。在 Memory 模式下,服务器上仍然需要配置 DRAM 内存,但是DRAM 内存是被 CPU 用作 AEP 内存的缓存,DRAM 的空间对应用软件不可见。换句话说,软件系统能使用到的内存空间,就是 AEP 内存条的空间容量。
- App Direct 模式:启用了 NVM 持久化数据的功能。此时数据写到 AEP 内存上时,数据就直接持久化。使用了 App Direct 模式的 AEP 内存,也叫做持久化内存(Persistent Memory,PM)。
Redis如何使用这两种模式呢,下面来看下。
2.基于 NVM 内存的 Redis 实践
在 Memory 模式下,AEP 内存的访问延迟会比 DRAM 高一点。Redis 实例读性能会有所降低,需要在保存大量数据和读性能较慢两者之间做个取舍。
使用 App Direct 模式时,Redis 该如何利用 PM 快速持久化数据的特性呢?我们来分析下 Redis 的数据可靠性保证需求和现有机制。
1.Redis持久化时候的问题
为了保证数据可靠性,Redis 设计了 RDB 和 AOF 两种机制,把数据持久化保存到硬盘:
- 对于 RDB:虽然 Redis 实例可以通过子进程生成 RDB 文件,但是,实例主线程 fork 子进程时,仍然会阻塞主线程。且RDB 文件的生成需要经过文件系统,文件本身会有一定的操作开销。
- 对于 AOF:虽然 Redis 提供了 always、everysec 和 no 三个选项,
- always 选项以 fsync 的方式落盘保存数据,虽然保证了数据的可靠性,但是面临性能损失的风险。
- everysec 选项避免了每个操作都要实时落盘,改为后台每秒定期落盘。在这种情况下,Redis 的写性能得到了改善,但是,应用会面临秒级数据丢失的风险。
- 数据恢复:使用 RDB 文件或 AOF 文件对 Redis 进行恢复时,需要把 RDB 文件加载到内存中,或者是回放 AOF 中的日志操作。这个恢复过程的效率受到 RDB 文件大小和 AOF 文件中的日志操作多少的影响。
所以,不能让单个 Redis 实例过大,否则会导致 RDB 文件过大。在主从集群应用中,过大的 RDB 文件就会导致低效的主从同步。
小结下现在 Redis 在涉及持久化操作时的问题:
- RDB 文件创建时的 fork 操作会阻塞主线程;
- AOF 文件记录日志时,需要在数据可靠性和写性能之间取得平衡;
- 使用 RDB 或 AOF 恢复数据时,恢复效率受 RDB 和 AOF 大小的限制。
2.使用 PM 来支持 Redis 的持久化
如果我们使用持久化内存,就可以充分利用 PM 快速持久化的特点,来避免 RDB 和 AOF 的操作。我们就可以把 Redis 直接运行在 PM 上。同时,数据本身就可以在 PM 上持久化保存了,就无需额外的 RDB 或 AOF 日志机制来保证数据可靠性。
PM 的使用方法:
查看设备:服务器中部署了 PM 后,我们可以在操作系统的 /dev 目录下看到一个 PM 设备,如下所示:
/dev/pmem0
格式化设备:使用 ext4-dax 文件系统来格式化该设备:
mkfs.ext4 /dev/pmem0
把格式化好的设备挂载到服务器上的一个目录下:
mount -o dax /dev/pmem0 /mnt/pmem0
在这个目录下创建文件。创建好了以后,再把这些文件通过内存映射(mmap)的方式映射到 Redis 的进程空间。此时就可以把 Redis 接收到的数据直接保存到映射的内存空间上了,而这块内存空间是由 PM 提供的。所以,数据写入这块空间时,就可以直接被持久化保存了。
修改或删除数据:PM 本身也支持以字节粒度进行数据访问,Redis 可以直接在 PM 上修改或删除数据。
实例数据恢复:Redis 宕机,因为数据本身已经持久化保存在 PM 上了,可以直接使用 PM 上的数据进行实例恢复。无需像之前通过加载 RDB 文件或是重放 AOF 日志恢复。
PM 的读写速度比 DRAM 慢,如果使用 PM 来运行 Redis,需要评估下 PM 提供的访问延迟和访问带宽,是否能满足业务层的需求。
评估 PM 带宽对 Redis 业务的支撑,评估示例:
假设业务层需要支持 1 百万 QPS,平均每个请求的大小是 2KB,那么,就需要机器能支持 2GB/s 的带宽(1 百万请求操作每秒 * 2KB 每请求 = 2GB/s)。如果这些请求正好是写操作的话,单根 PM 的写带宽可能不太够用了,需要加PM,或者用切片集群。
小结
NVM 的三大特点:性能高、容量大、数据可以持久化保存。
软件系统可以像访问传统 DRAM 内存一样,访问 NVM 内存。目前,Intel 已经推出了 NVM 内存产品 Optane AEP。
Optane AEP给软件提供了两种使用模式,分别是 Memory 模式和 App Direct 模式。
- Memory 模式时,Redis 可以利用 NVM 容量大的特点,实现大容量实例,保存更多数据。
- App Direct 模式时,Redis 可以直接在持久化内存上进行数据读写,在这种情况下,Redis 不用再使用 RDB 或 AOF 文件了,数据在机器掉电后也不会丢失。而且,实例可以直接使用持久化内存上的数据进行恢复,恢复速度特别快。
提问
什么是NVM?怎么使用?
有了持久化内存后,还需要 Redis 主从集群吗?肯定还是需要主从集群的。持久化内存只能解决存储容量和数据恢复问题,关注点在于单个实例……
41.第35~40讲课后思考题答案及常见问题答疑(暂略待补充)
小结
提问
其他篇(加餐,暂略)
- 加餐(一)_ 经典的Redis学习资料有哪些?
- 加餐(二)_ Kaito:我是如何学习Redis的?
- 加餐(三)丨Kaito:我希望成为在压力中成长的人
- 加餐(四)丨Redis客户端如何与服务器端交换命令和数据?
- 加餐(五)丨 Redis有哪些好用的运维工具?
- 加餐(六)_ Redis的使用规范小建议
- 加餐(七) _ 从微博的Redis实践中,我们可以学到哪些经验?
转载请注明来源