Day 18
0. Redis
Redis 是一种基于内存的数据库,对数据的读写操作都是在内存中完成,因此读写速度非常快,常用于缓存,消息队列、分布式锁等场景。
Redis 提供了多种数据类型来支持不同的业务场景,比如 String(字符串)、Hash(哈希)、List (列表)、Set(集合)、Zset(有序集合)、Bitmaps(位图)、HyperLogLog(基数统计)、GEO(地理信息)、Stream(流),并且对数据类型的操作都是原子性的,因为执行命令由单线程负责的,不存在并发竞争的问题。
除此之外,Redis 还支持事务、持久化、Lua 脚本、多种集群方案(主从复制模式、哨兵模式、切片集群模式)、发布/订阅模式,内存淘汰机制、过期删除机制等等。
1. MySQL和Redis的区别
MySQL 和 Redis 的区别主要体现在以下几个方面:
- 数据存储类型
- MySQL 是一种关系型数据库,支持多种数据类型,如整数、浮点数、字符串、日期等,并且以表格形式存储结构化数据。
- Redis 是一种非关系型数据库,支持多种数据结构,如字符串、哈希、列表、集合、有序集合等。
例如,在 MySQL 中存储用户信息,可能会有姓名、年龄、地址等字段;而在 Redis 中,可以将用户的 ID 作为键,用户的详细信息以哈希结构存储。
- 数据持久化
- MySQL 通常将数据持久化到磁盘,以保证数据的可靠性和持久性。
- Redis 支持数据持久化,但方式相对灵活,可以根据配置选择不同的持久化策略。
比如,MySQL 一般通过定期将数据写入磁盘来防止数据丢失;Redis 可以选择 RDB(定期将数据快照保存到磁盘)或 AOF(将写命令追加到文件)方式持久化。
- 数据查询方式
- MySQL 使用结构化查询语言(SQL)进行复杂的查询操作,能够处理多表关联、聚合计算等。
- Redis 主要通过命令来操作数据,更适合简单快速的键值对查询和数据操作。
以查找用户订单为例,在 MySQL 中可以通过复杂的 SQL 语句来筛选和汇总订单信息;而在 Redis 中,更多是直接通过键获取对应的订单数据。
- 性能特点
- MySQL 在处理大量复杂查询和数据量较大时,性能可能会受到一定影响。
- Redis 基于内存存储,具有极高的读写性能,适合处理高并发和快速响应的场景。
比如,对于需要频繁读写的缓存数据,Redis 能提供更快的响应速度;而对于大规模的事务处理和复杂数据分析,MySQL 更具优势。
- 适用场景
- MySQL 适用于需要长期保存、关系复杂、数据一致性要求高的场景,如企业级应用的核心数据存储。
- Redis 常用于缓存、排行榜、消息队列等对性能要求高、数据结构灵活的场景。
例如,电商网站的商品详情一般存储在 MySQL 中,而商品浏览量等频繁更新的数据可以放在 Redis 中作为缓存。
主要是因为 Redis 具备高性能和高并发两种特性。
2. Redis有什么特点?为什么用Redis查询会比较快?
1、Redis 具备高性能
数据储存在内存中,这使得数据的读写操作速度极快,能够实现高效的响应。例如,在处理高并发的实时数据请求时,如在线游戏中的玩家状态更新,Redis 能够迅速完成数据的存储和读取。
假如用户第一次访问 MySQL 中的某些数据。这个过程会比较慢,因为是从硬盘上读取的。将该用户访问的数据缓存在 Redis 中,这样下一次再访问这些数据的时候就可以直接从缓存中获取了,操作 Redis 缓存就是直接操作内存,所以速度相当快。
如果 MySQL 中的对应数据改变了之后,同步改变 Redis 缓存中相应的数据即可,不过这里会有 Redis 和 MySQL 双写一致性的问题,后面我们会提到。
2、Redis 具备高并发
Redis能够同时处理大量的并发请求。 像热门的电商促销活动期间,Redis 可以应对瞬间激增的访问量。
单台设备的 Redis 的 QPS(Query Per Second,每秒钟处理完请求的次数)是 MySQL 的 10 倍,Redis 单机的 QPS 能轻松破 10w,而 MySQL 单机的 QPS 很难破 1w。
所以,直接访问 Redis 能够承受的请求是远远大于直接访问 MySQL 的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。
3、支持持久化
可以将数据定期或在特定条件下保存到磁盘,防止数据丢失。例如,在电商网站中,即使服务器出现故障,重新启动后 Redis 能够恢复数据,保证业务的正常运行。
4、单线程模型
避免了多线程切换和竞争带来的开销,保证了操作的原子性和高效性。
5、丰富的数据结构
支持字符串、哈希、列表、集合、有序集合等多种数据结构,满足不同场景的需求。比如,在社交应用中,使用列表存储用户的动态,使用集合存储关注者或粉丝。
Redis 采用单线程为什么还这么快?
官方使用基准测试的结果是,单线程的 Redis 吞吐量可以达到 10W/每秒,下图所示:
之所以 Redis 采用单线程(网络 I/O 和执行命令)那么快,有如下几个原因:
- 高效的数据结构和算法:Redis 针对各种数据结构进行了优化,使得操作的时间复杂度较低。
- Redis 的大部分操作都在内存中完成,并且采用了高效的数据结构,因此 Redis 瓶颈可能是机器的内存或者网络带宽,而并非 CPU,既然 CPU 不是瓶颈,那么自然就采用单线程的解决方案了;
- Redis 采用单线程模型可以避免了多线程之间的竞争,省去了多线程切换带来的时间和性能上的开销,而且也不会导致死锁问题。
- Redis 采用了 I/O 多路复用机制处理大量的客户端 Socket 请求,IO 多路复用机制是指一个线程处理多个 IO 流,就是我们经常听到的 select/epoll 机制。简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听 Socket 和已连接 Socket。内核会一直监听这些 Socket 上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。
3. Redis的数据类型
Redis 提供了丰富的数据类型,常见的有五种数据类型:String(字符串),Hash(哈希),List(列表),Set(集合)、Zset(有序集合)。
随着 Redis 版本的更新,后面又支持了四种数据类型: BitMap(2.2 版新增)、HyperLogLog(2.8 版新增)、GEO(3.2 版新增)、Stream(5.0 版新增)。
Redis 五种数据类型的应用场景:
- String 类型的应用场景:缓存对象、常规计数、分布式锁、共享 session 信息等。
- List 类型的应用场景:消息队列(但是有两个问题:1. 生产者需要自行实现全局唯一 ID;2. 不能以消费组形式消费数据)等。
- Hash 类型:缓存对象、购物车等。
- Set 类型:聚合计算(并集、交集、差集)场景,比如点赞、共同关注、抽奖活动等。
- Zset 类型:排序场景,比如排行榜、电话和姓名排序等。
Redis 后续版本又支持四种数据类型,它们的应用场景如下:
- BitMap(2.2 版新增):二值状态统计的场景,比如签到、判断用户登陆状态、连续签到用户总数等;
- HyperLogLog(2.8 版新增):海量数据基数统计的场景,比如百万级网页 UV 计数等;
- GEO(3.2 版新增):存储地理位置信息的场景,比如滴滴叫车;
- Stream(5.0 版新增):消息队列,相比于基于 List 类型实现的消息队列,有这两个特有的特性:自动生成全局唯一消息 ID,支持以消费组形式消费数据。
五种常见的 Redis 数据类型是怎么实现?
下面是 Redis 数据类型和底层数据结构的对应关图,左边是 Redis 3.0 版本的,也就是《Redis 设计与实现》这本书讲解的版本,现在看还是有点过时了,右边是现在 Redis 7.0 版本的。
String 类型内部实现
String 类型的底层的数据结构实现主要是 SDS(简单动态字符串)。
SDS 和我们认识的 C 字符串不太一样,之所以没有使用 C 语言的字符串表示,因为 SDS 相比于 C 的原生字符串:
-
SDS 不仅可以保存文本数据,还可以保存二进制数据。因为 SDS 使用 len 属性的值而不是空字符来判断字符串是否结束,并且 SDS 的所有 API 都会以处理二进制的方式来处理 SDS 存放在
buf[]
数组里的数据。所以 SDS 不光能存放文本数据,而且能保存图片、音频、视频、压缩文件这样的二进制数据。 -
SDS 获取字符串长度的时间复杂度是 O(1)。因为 C 语言的字符串并不记录自身长度,所以获取长度的复杂度为 O(n);而 SDS 结构里用 len 属性记录了字符串长度,所以复杂度为 O(1)。
-
Redis 的 SDS API 是安全的,拼接字符串不会造成缓冲区溢出。因为 SDS 在拼接字符串之前会检查 SDS 空间是否满足要求,如果空间不够会自动扩容,所以不会导致缓冲区溢出的问题。
List 类型内部实现
List 类型的底层数据结构是由双向链表或压缩列表实现的:
-
如果列表的元素个数小于 512 个(默认值,可由 list-max-ziplist-entries 配置),列表每个元素的值都小于 64 字节(默认值,可由 list-max-ziplist-value 配置),Redis 会使用压缩列表作为 List 类型的底层数据结构;
-
如果列表的元素不满足上面的条件,Redis 会使用双向链表作为 List 类型的底层数据结构;
但是在 Redis 3.2 版本之后,List 数据类型底层数据结构就只由 quicklist 实现了,替代了双向链表和压缩列表。
Hash 类型内部实现
Hash 类型的底层数据结构是由压缩列表或哈希表实现的:
-
如果哈希类型元素个数小于 512 个(默认值,可由 hash-max-ziplist-entries 配置),所有值小于 64 字节(默认值,可由 hash-max-ziplist-value 配置)的话,Redis 会使用压缩列表作为 Hash 类型的底层数据结构;
-
如果哈希类型元素不满足上面条件,Redis 会使用哈希表作为 Hash 类型的底层数据结构。
在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。
Set 类型内部实现
Set 类型的底层数据结构是由哈希表或整数集合实现的:
-
如果集合中的元素都是整数且元素个数小于 512(默认值,set-maxintset-entries 配置)个,Redis 会使用整数集合作为 Set 类型的底层数据结构;
-
如果集合中的元素不满足上面条件,则 Redis 使用哈希表作为 Set 类型的底层数据结构。
ZSet 类型内部实现
Zset 类型的底层数据结构是由压缩列表或跳表实现的:
-
如果有序集合的元素个数小于 128 个,并且每个元素的值小于 64 字节时,Redis 会使用压缩列表作为 Zset 类型的底层数据结构;
-
如果有序集合的元素不满足上面的条件,Redis 会使用跳表作为 Zset 类型的底层数据结构;
在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。
4. Redis是单线程还是多线程的?
Redis是单线程的
Redis 单线程指的是接收客户端请求->解析请求 ->进行数据读写等操作->发送数据给客户端这个过程是由一个线程(主线程)来完成的,这也是我们常说 Redis 是单线程的原因。
但是,Redis 程序并不是单线程的,Redis 在启动的时候,是会启动后台线程(BIO)的:
- Redis 在 2.6 版本,会启动 2 个后台线程,分别处理关闭文件、AOF 刷盘这两个任务;
- Redis 在 4.0 版本之后,新增了一个新的后台线程,用来异步释放 Redis 内存,也就是 lazyfree 线程。例如执行 unlink key / flushdb async / flushall async 等命令,会把这些删除操作交给后台线程来执行,好处是不会导致 Redis 主线程卡顿。因此,当我们要删除一个大 key 的时候,不要使用 del 命令删除,因为 del 是在主线程处理的,这样会导致 Redis 主线程卡顿,因此我们应该使用 unlink 命令来异步删除大 key。
之所以 Redis 为关闭文件、AOF 刷盘、释放内存这些任务创建单独的线程来处理,是因为这些任务的操作都是很耗时的,如果把这些任务都放在主线程来处理,那么 Redis 主线程就很容易发生阻塞,这样就无法处理后续的请求了。
后台线程相当于一个消费者,生产者把耗时任务丢到任务队列中,消费者(BIO)不停轮询这个队列,拿出任务就去执行对应的方法即可。
关闭文件、AOF 刷盘、释放内存这三个任务都有各自的任务队列:
- BIO_CLOSE_FILE,关闭文件任务队列:当队列有任务后,后台线程会调用 close(fd) ,将文件关闭;
- BIO_AOF_FSYNC,AOF 刷盘任务队列:当 AOF 日志配置成 everysec 选项后,主线程会把 AOF 写日志操作封装成一个任务,也放到队列中。当发现队列有任务后,后台线程会调用 fsync(fd),将 AOF 文件刷盘,
- BIO_LAZY_FREE,lazy free 任务队列:当队列有任务后,后台线程会 free(obj) 释放对象 / free(dict) 删除数据库所有对象 / free(skiplist) 释放跳表对象;
Redis 单线程模式是怎样的?
Redis 6.0 版本之前的单线模式如下图:
图中的蓝色部分是一个事件循环,是由主线程负责的,可以看到网络 I/O 和命令处理都是单线程。
Redis 初始化的时候,会做下面这几件事情:
- 首先,调用 epoll_create() 创建一个 epoll 对象和调用 socket() 一个服务端 socket
- 然后,调用 bind() 绑定端口和调用 listen() 监听该 socket;
- 然后,将调用 epoll_ctl() 将 listen socket 加入到 epoll,同时注册连接事件处理函数。
初始化完后,主线程就进入到一个事件循环函数,主要会做以下事情:
- 首先,先调用处理发送队列函数,看是发送队列里是否有任务,如果有发送任务,则通过 write 函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没有发送完,就会注册写事件处理函数,等待 epoll_wait 发现可写后再处理。
- 接着,调用 epoll_wait 函数等待事件的到来:
- 如果是连接事件到来,则会调用连接事件处理函数,该函数会做这些事情:调用 accpet 获取已连接的 socket -> 调用 epoll_ctl 将已连接的 socket 加入到 epoll -> 注册读事件处理函数;
- 如果是读事件到来,则会调用读事件处理函数,该函数会做这些事情:调用 read 获取客户端发送的数据 -> 解析命令 -> 处理命令 -> 将客户端对象添加到发送队列 -> 将执行结果写到发送缓存区等待发送;
- 如果是写事件到来,则会调用写事件处理函数,该函数会做这些事情:通过 write 函数将客户端发送缓存区里的数据发送出去,如果这一轮数据没有发送完,就会继续注册写事件处理函数,等待 epoll_wait 发现可写后再处理。
Redis 6.0 之前为什么使用单线程?
我们都知道单线程的程序是无法利用服务器的多核 CPU 的,那么早期 Redis 版本的主要工作(网络 I/O 和执行命令)为什么还要使用单线程呢?我们不妨先看一下 Redis 官方给出的FAQ。
核心意思是:CPU 并不是制约 Redis 性能表现的瓶颈所在,更多情况下是受到内存大小和网络 I/O 的限制,所以 Redis 核心网络模型使用单线程并没有什么问题,如果你想要使用服务的多核 CPU,可以在一台服务器上启动多个节点或者采用分片集群的方式。
除了上面的官方回答,选择单线程的原因也有下面的考虑。
使用了单线程后,可维护性高,多线程模型虽然在某些方面表现优异,但是它却引入了程序执行顺序的不确定性,带来了并发读写的一系列问题,增加了系统复杂度、同时可能存在线程切换、甚至加锁解锁、死锁造成的性能损耗。
Redis 6.0 之后为什么引入了多线程?
虽然 Redis 的主要工作(网络 I/O 和执行命令)一直是单线程模型,但是在 Redis 6.0 版本之后,也采用了多个 I/O 线程来处理网络请求,这是因为随着网络硬件的性能提升,Redis 的性能瓶颈有时会出现在网络 I/O 的处理上。
所以为了提高网络 I/O 的并行度,Redis 6.0 对于网络 I/O 采用多线程来处理。但是对于命令的执行,Redis 仍然使用单线程来处理,所以大家不要误解 Redis 有多线程同时执行命令。
Redis 官方表示,Redis 6.0 版本引入的多线程 I/O 特性对性能提升至少是一倍以上。
Redis 6.0 版本支持的 I/O 多线程特性,默认情况下 I/O 多线程只针对发送响应数据(write client socket),并不会以多线程的方式处理读请求(read client socket)。要想开启多线程处理客户端读请求,就需要把 Redis.conf 配置文件中的 io-threads-do-reads 配置项设为 yes。
1 | //读请求也使用 io 多线程 |
同时,Redis.conf 配置文件中提供了 IO 多线程个数的配置项。
1 | // io-threads N,表示启用 N-1 个 I/O 多线程(主线程也算一个 I/O 线程) |
关于线程数的设置,官方的建议是如果为 4 核的 CPU,建议线程数设置为 2 或 3,如果为 8 核 CPU 建议线程数设置为 6,线程数一定要小于机器核数,线程数并不是越大越好。
因此,Redis 6.0 版本之后,Redis 在启动的时候,默认情况下会创建 6 个线程:
-
Redis-server:Redis 的主线程,主要负责执行命令;
-
bio_close_file、bio_aof_fsync、bio_lazy_free:三个后台线程,分别异步处理关闭文件任务、AOF 刷盘任务、释放内存任务;
-
io_thd_1、io_thd_2、io_thd_3:三个 I/O 线程,io-threads 默认是 4,所以会启动 3(4-1)个 I/O 多线程,用来分担 Redis 网络 I/O 的压力。