【Redis】初识[1]
简介
Redis是一种基于内存的数据库,可以作为数据库、缓存、消息队列等场景;
提供多种数据结构,并且保证对数据操作的原子性;
还支持持久化、Lua脚本、集群化、发布/订阅模式、内存淘汰和过期删除策略;
具有高性能和高并发两种特性;
数据结构
Redis支持五种常见的数据类型:String(字符串)、List(列表)、Hash(哈希)、Set(集合)、Zset(有序集合);后续又新增了四种数据类型:BitMap、HyperLogLog、GEO、Stream。
String
底层数据结构基于SDS(简单动态字符串)实现,优势在于:
- SDS不仅可以保存文本数据,也支持保存二进制数据
- 使用 len 属性保存字符串长度,快速获取长度
- SDS的API安全,操作(如拼接字符串)之前会检查空间,空间不足自动扩容,不会造成缓冲区溢出
Value可以是字符串、整数或浮点数;
支持操作字符串、对数字自增或自减操作;
List
底层数据结构由[双向链表]或压缩列表实现,新版本(3.2版本)之后,只由[quick list]实现:
- 列表元素个数小于512,每个元素值小于64字节,会使用压缩列表
- 不满足上述条件的,会使用双向链表实现
Value是一个String List;
支持对链表两端push和pop操作、读取单个或多个元素、根据值查找或删除元素;
Hash
底层数据结构由[哈希表]或压缩列表实现,Redis7.0中,压缩列表结构被废弃,交由listpack来实现:
- 哈希类型的元素个数小于512,所有值小于64字节,会使用压缩列表
- 不满足上述条件的,会使用哈希表实现
Value为包含键值对的无序散列表;
支持添加、获取、删除单个元素;
Set
底层数据结构由[哈希表]或整数集合实现:
- 集合元素都是整数且元素个数小于512,会使用整数集合
- 不满足上述条件的,会使用哈希表实现
Value为包含字符串的无序集合;
支持查看是否存在、添加、获取、删除,还包含计算交集、并集、差集等;
Zset
底层数据结构由[跳表]或压缩列表实现,Redis7.0中,压缩列表结构被废弃,交由listpack来实现
- 有序集合元素个数小于128,每个元素值小于64字节,会使用压缩列表
- 不满足上述条件的,会使用跳表实现
有序集合,Value和散列一样,存储键值对;
支持字符串成员和浮点数分数之间的有序映射;排序由分数大小决定;支持添加、获取、删除单个元素,以及根据分数范围来获取元素;
BitMap
适用于二值状态统计的场景,例如签到、登录状态等;
支持设置、获取某个offset的值,获取start和end之间为1的个数,对1个或多个BitMap进行and、or、xor、not;
HyperLogLog
适用于海量数据基数统计的场景,例如热门网站访问ip统计、热门网页uv统计等;
是Redis对基数计数概率算法(HyperLogLog)的实现,支持添加1个或多个元素到HyperLogLog、获取1个或多个HyperLogLog的计数、合并多个HyperLogLog并算出计数;
GEO
适用于需要存储地理位置信息的场景;
Stream
对消息队列的支持,相比于List实现的队列:支持自动生成全局唯一的消息ID、支持以消费组的形式消费数据;
持久化
由于Redis的读写操作都是在内存中,所以重启或者宕机后,内存中的数据就会丢失,因此需要数据持久化的机制,把数据存到磁盘中,Redis重启后就可以从磁盘中恢复数据。
Redis共提供3种数据持久化的方式:AOF日志、RDB快照、混合持久化(4.0后支持,集成前两种)
AOF日志
Redis在执行一条写操作命令后,将该命令以追加的形式写到一个文件中,重启后,逐一执行其中的命令来恢复数据;
AOF记录的并不是实际数据,而是一系列的操作命令;
写入AOF日志的过程
- 命令追加(append):Redis执行完写操作命令后,将命令追加到「AOF缓冲区」
- 文件写入(write):通过write()系统调用,将AOF缓冲区的数据写入到AOF文件,此时数据并没有写入到硬盘,而是拷贝到了内核缓冲区,等待内核将数据写入硬盘
- 文件同步(fsync):内核缓冲区的数据什么时候写入硬盘由内核决定
Redis提供了3种写回硬盘的策略,需要在性能和可靠性之间取舍:
- Always,每次写命令后,同步将AOF日志数据写回到硬盘
- Everysec,每次写命令后,先将命令写入到AOF文件的内核缓冲区,然后每隔一秒将缓冲区内容写回到硬盘
- No,每次写命令后,先将命令写入到AOF文件的内核缓冲区,由操作系统决定何时将缓冲区内容写到硬盘
AOF日志不断累积,过大会如何处理?
为了避免AOF文件越写越大,影响数据恢复的性能,Redis提供了AOF重写机制,重写时,会读取当前数据库的所有键值对,然后将每一个键值对用一条命令来记录,全部记录完成后,用新的AOF文件替换掉现有的,相当于压缩了AOF文件。
重写AOF文件的过程
重写AOF是由后台子进程 bgrewriteaof 来完成的,有两个好处:
- 重写过程中,主进程可以继续处理请求,从而避免阻塞主进程
- 子进程有主进程的数据副本,不用通过加锁来保证数据安全
那么在重写过程中,如果主进程修改了内存的某一条数据,此时子进程和主进程的内存数据会不一致,为了解决这个问题,Redis设置了一个「AOF重写缓冲区」(在创建了子进程之后开始使用),在重写AOF期间,当Redis执行完一条命令后,会同时将这条命令写入到「AOF 缓冲区」和 「AOF 重写缓冲区」。
当子进程完成AOF重写工作后,会向主进程发送一条信号,主进程收到信号后,进行以下处理:
- 将AOF重写缓冲区的内容追加到新生成的AOF文件中,保持新旧两个AOF文件保存的数据库状态一致
- 将新的AOF文件改名,覆盖现有的AOF文件
至此,完成一个AOF文件的重写。
RDB快照
RDB快照就是记录某个瞬间的内存数据,记录的是实际数据,恢复时只需要将RDB文件读入内存即可;
存储快照时会阻塞主线程吗?
Redis提供两个命令来生成RDB文件,分别是 save 和 bgsave;
- 执行save命令,会在主线程生成RDB文件,如果写入RDB文件的时间太长,会阻塞主线程;
- 执行bgsave命令,会创建一个子进程来生成RDB文件,不会阻塞主线程;
支持通过配置文件调整触发存储RDB的频率,在性能和数据完整性之间做平衡;
通过子进程存储RDB文件时,子进程和父进程会共享同一片内存数据,如果主进程需要修改数据,会发生「写时复制」(即被修改的数据会复制一份副本,然后子进程将该副本数据写入RDB文件中)。
混合持久化
RDB的优点是数据恢复速度快,但触发的频率不好把握;AOF的优点是丢失数据少,但数据恢复的性能差;
Redis4.0提出了混合使用AOF日志和RDB快照,集成两种方式的优点。
执行过程:
- 初始还是以AOF日志的形式记录操作命令;
- 在AOF日志重写的过程中,fork出来的重写子进程会先将主线程共享的内存数据以RDB的形式写入新文件中;
- 这个过程中,主线程处理的操作命令会被记录在重写缓冲区中;
- 重写缓冲区的增量命令会以AOF的形式写入到新文件中;
- 写入完成后通过主进程替换旧的AOF文件;
这种模式下,AOF文件的前半部分是RDB格式的全量数据,后半部分是AOF格式的增量数据;
优点:
- 集成了两种持久化的优点,加快了恢复数据的速度,同时结合AOF,减少了大量数据丢失的风险
缺点:
- AOF文件中增加了RDB格式的数据,使得AOF文件的可读性变得很差;
- 对Redis4.0之前版本的兼容性变差;
集群化
实际系统中,需要集群化部署来保证整个Redis服务的高可用性;
主从复制模式
即「一主多从」的模式,将一台主Redis服务器,同步数据到多台从Redis服务器上,且主从之间采用读写分离的方式;
主服务器可以进行读写操作,发生写操作时自动同步给从服务器,而从服务器一般是只读,并接受主服务器同步过来的写命令,然后执行;
主服务器收到写操作命令后,会发送给从服务器,但不会等待从服务器执行完写命令,而是主服务器在本地执行完写命令后,就向客户端返回结果,因此无法实现强一致性保证;
哨兵模式
主从模式的一个问题是服务器发生故障宕机后,需要手动进行恢复;为此增加了哨兵模式,可以监控主从服务器,并提供主从节点故障转移的功能;
集群脑裂
- 主从模式中,如果主节点的网络异常,与所有的从节点失联,但与客户端之间的网络正常,因此客户端继续向主节点A写入数据,但由于网络问题,新写入的数据无法被同步给其余的从节点
- 哨兵发现主节点失联后,认为主节点故障,会在其余的从节点中选举一个leader作为新的主节点B(脑裂出现了)
- 旧的主节点A网络恢复后,哨兵由于已经选举出了新的主节点,会将A降级为从节点,然后A会向新的主节点B请求数据同步,第一次同步为全量同步的方式,因此会清空A本地的数据,从而导致客户端之前在A写入的数据丢失
总结为:由于网络或其他原因,集群节点之间失联,然后经过重新选举,产生了两个主节点。
解决方案
当主节点发现从节点下线或者通信超时,正常的从节点数量小于阈值时,禁止主节点写数据,返回错误给客户端。
Redis提供两个参数进行配置:
- 「min-slaves-to-write N」:主节点必须有至少N个从节点连接,否则禁止写数据
- 「min-slaves-max-lag T」:主从数据复制和同步的延迟不能超过T秒,否则禁止写数据
这样在主从节点之间失联后,由于不满足上述条件,会限制向主节点写入数据,等到新的主节点上线后,就只有新的主节点能接受和处理客户端请求,旧的主节点被降级为从节点,即使数据清空也不会发生数据丢失。
切片集群模式
之前的集群模式,每台服务器都会存储整个Redis数据库中的数据,存在一定的空间浪费,为此有了切片集群模式;将数据分布在不同的服务器上,可以降低对单节点的依赖,从而提供Redis服务的读写性能;
采用哈希槽来处理数据和服务器节点之间的映射关系,此方案中,一个切片集群共有16384个哈希槽;
- 根据数据键值对的key,按照CRC16算法计算一个16bit的值
- 用该值对16384取模,每个模数代表一个相应编号的哈希槽
下一步就是将这些哈希槽映射到具体的Redis节点上,由两种方案:
- 平均分配:在使用「cluster create」创建Redis集群时,自动把所有的哈希槽平均分配到集群节点上
- 手动分配:可以用「cluster meet」手动建立节点之间的连接,组成集群,再使用「cluster addslots」指定每个节点上哈希槽数量;需要注意:手动分配下, 需要把16384个槽分配完,Redis集群才可以正常工作
过期删除
使用Redis对key设置过期时间时,会将key带上过期时间存储到一个过期字典中,读取时,首先检查该key是否在过期字典中,如果不在则正常读取键值,如果在,获取该key的过期时间,判断是否已经过期。
因此需要一定的机制将已过期的键值对删除,即过期删除策略。
惰性删除
不主动删除过期键,每次访问key时,检测该key是否过期,如果过期则删除该key,并返回null;
优点:只有访问时才会检查是否过期,对CPU时间友好
缺点:只要不访问就不会删除,造成一定的空间浪费,对内存不友好
定期删除
每隔一段时间「随机」取出一定数量的key进行检查,并删除其中过期的key;
Redis的定期删除流程:
- 从过期字典中随机抽取20个key
- 检查是否过期,并删除过期的key
- 如果本轮检查的已过期key超过5(20/4)个,即占比超过25%,重复步骤1;否则等待下一轮检查
- 为了保证不会出现循环过度卡死线程,增加了循环流程的时间上限,默认不会超过25ms
优点:通过限制执行的时长和频率,来减少删除操作对CPU的影响,同时也能删除一部分过期的key
缺点:难以确定删除操作执行的时长和频率,太频繁对CPU不友好,执行太少又会导致过期key得不到释放
Redis的选择
「惰性删除+定期删除」配合使用,以求在合理使用CPU时间和避免内存浪费之间取得平衡。
持久化时,对过期键的处理
AOF文件分为写入和重写阶段:
- AOF文件写入:以AOF模式持久化时,如果某个过期键还没被删除,那么AOF文件会保留此键,当此键被删除后,Redis会向AOF文件追加一条DEL命令来显式地删除该键值
- AOF重写阶段:执行重写时,会对Redis中的键值进行过期检查,已过期的键不会被保存到新的AOF文件中
RDB文件分为文件生成和加载阶段:
- RDB文件生成:从内存持久化为RDB文件时,会进行过期检查,过期的key不会保存到新的RDB文件中
- RDB文件加载:加载阶段,要看服务器类型:
- 主服务器加载时,会对RDB文件中保存的键进行检查,过期键不会被加载到数据库中
- 从服务器加载时,不论是否过期一律加载,但由于主从服务器数据同步时,从服务器的数据会被清空,所以一般来说,过期键的载入对从服务器也不会造成影响
内存淘汰
上述过期删除是指删除那些已过期的键值对,但随着Redis的运行,内存的数据达到某个阈值,即我们设置的最大运行内存(此值在Redis配置文件中通过 maxmemory 设置),就会触发内存淘汰机制。
Redis的内存淘汰策略
共有8种,大体分为「不进行数据淘汰」和「进行数据淘汰」两种
不进行数据淘汰
- noeviction(Redis3.0之后默认的内存淘汰策略):当运行内存超出最大设置内存后,不淘汰数据,直接返回错误,不再提供服务
进行数据淘汰——在设置了过期时间的数据中进行淘汰
- volatile-random:随机淘汰设置了过期时间的任意键值
- volatile-ttl:优先淘汰更早过期的键值
- volatile-lru(Redis3.0之前默认的内存淘汰策略):淘汰所有设置了过期时间的键值中,最久未使用的键值
- volatile-lfu(Redis4.0之后新增的内存淘汰策略):淘汰所有设置了过期时间的键值中,最少使用的键值
进行数据淘汰——在所有数据范围进行淘汰
- allkeys-random:随机淘汰任意键值
- allkeys-lru:淘汰所有键值中最久未使用的键值
- allkeys-lfu(Redis4.0之后新增的内存淘汰策略):淘汰所有键值中最少使用的键值
什么是LRU算法
LRU全称为 Least Recently Used,即最近最少使用。
传统LRU实现是基于链表的,元素按照操作顺序从前往后排列,最新操作的键会被移动到表头,当需要内存淘汰时,只需要删除链表尾部的元素即可;
Redis没有采用这样的方式实现LRU,存在两个问题:
- 需要链表来管理所有的缓存数据,会带来新的空间开销
- 有数据被访问时,需要将该数据移动到链表头部,大量的数据访问带来大量的链表操作,降低了Redis的性能
Redis实现的是一种近似LRU算法,目的是为了更好地节约内存,实现方式是在Redis的对象结构体中添加一个额外的字段,用于记录此数据的最后一次访问时间,当进行内存淘汰时,采用随机采样的方式,随机5个值(可配置),然后淘汰其中最久没有被使用的那个。
这样实现的优点就是对传统做法的改进:
- 不用为所有的数据维护一个大链表,节省空间
- 不同每次访问数据后都进行链表操作,节省时间
但LRU算法无法解决缓存污染问题,比如一次读取了大量的数据,而这些数据可能只会读取这一次,那就会留在内存中很长一段时间,造成缓存污染,因此在Redis4.0之后,引入了LFU算法来解决这个问题。
什么是LFU算法
LFU全称为 Least Frequently Used,即最近最不常用。
LFU是根据数据访问次数来淘汰数据,核心思想为“如果数据在过去被访问多次,那么将来被访问的频率也更高”,所以,LFU算法会记录每个数据的访问次数,这样就解决了LRU带来的缓存污染问题;
Redis的实现:在对象的结构体中,增加了24bit的lru属性,用于记录对象的访问信息:
- 在LRU算法中,此字段用来记录key的访问时间戳
- 在LFU算法中,此字段被分为两部分,高16bit存储key的访问时间戳,低8bit存储key的访问频次
线程模型
Redis的线程模型
常说的Redis单线程是指「接受客户端请求->解析请求->进行数据读写->返回数据给客户端」这个过程由一个线程(主线程)来完成。
但Redis程序并不是单线程,启动时会启动后台线程:
- Redis在2.6,会启动2个后台线程,分别处理关闭文件、AOF刷盘任务
- Redis在4.0之后,新增1个 lazyfree 后台线程,用来异步释放Redis内存,会把一部分删除操作(例如 unlink key、flushdb async、flushall async等)交给后台线程来执行,不会导致主线程的卡顿。因此,删除一些大key的时候,使用unlink命令来异步删除,避免使用del命令卡顿主线程。
关闭文件、AOF刷盘、释放内存这些任务用后台线程执行是因为比较耗时,主线程执行的话很容易阻塞;
后台线程相当于一个消费者,生产者把耗时任务丢到任务队列中,消费者轮询队列,拿到任务就去执行,分别对应三个任务队列(BIO_CLOSE_FILE、BIO_AOF_FSYNC、BIO_LAZY_FREE)。
Redis的单线程模式是什么样的?
可以看到,网络I/O和命令处理部分都是单线程。
Redis基于 Reactor 模式开发了自己的网络事件处理器:这个处理器被称为文件事件处理器(file event handler)。
- 文件事件处理器使用I/O多路复用程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器
- 当被监听的套接字准备好连接、读取、写入、关闭等操作时,与操作相对应的文件事件就会产生,这是文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件
Redis初始化时,会做如下事情:
- 首先,调用 epoll_create() 创建一个 epoll 对象、调用 socket() 创建一个服务端 socket
- 其次,调用bind() 绑定端口、调用 listen() 监听该 socket
- 然后,调用 epoll_ctl() 将监听的 socket 加入到 epoll,同时注册连接事件处理函数
初始化完成后,主线程进入一个事件循环函数:
- 先调用处理发送队列函数,发送队列如果有任务,则通过write函数将客户端发送缓存区里的数据发送出去,如果这一轮没有发送完,就注册写事件处理函数,等待 epoll_wait 发现可写后再处理
- 然后,调用 epoll_wait 函数等待事件的到来
个人理解:
I/O多路复用(epoll):实质上通过一个线程同时监听来自客户端的大量连接,并将客户端的请求创建相应的文件事件并分发给相应的文件事件处理器
Redis的处理过程(1-2为Redis初始化,3-4为客户端连接,5-9为处理客户端命令):
- Redis初始化时,首先创建 epoll 对象以及服务端 socket A
- 将该 socket A 添加到 epoll 的监听列表中,并为该 socket A 注册 连接事件处理器
- epoll 监听到 socket A 有来自客户端的连接请求,创建连接事件并分发给A之前注册的连接事件处理器
- 连接事件处理器:接受客户端 socket B 的连接,将B添加到epoll的监听列表,并为B注册 读事件处理器
- 客户端请求执行命令,epoll 监听到 socket B 可以执行读取(read),创建读事件并分发给之前注册的读事件处理器
- 读事件处理器:调用 read 读取 socket B 的内容,解析并执行来自客户端的命令,然后将客户端对象添加到发送队列,并将执行结果写到发送缓存区等待发送
- 主线程的事件循环除了等待来自 epoll 的事件,还会检查发送队列是否有任务,如果有,就调用 write 将发送缓存区的数据发送到相应客户端的 socket,如果不可写或者本轮没有发送完,就为该客户端B注册一个 写事件处理器
- epoll 监听到 socket B 可以执行写入(write),创建写事件并分发给之前注册的写事件处理器
- 写事件处理器:调用 write 将发送缓存区的数据发送到相应客户端的 socket,本轮没有发送完,就继续注册写事件处理器,等待 epoll 发现可写后再处理
Redis采用单线程为什么还这么快?
单线程(网络I/O和执行命令)的Redis吞吐量可以达到 10W/每秒 。
- Redis大部分操作都在内存中完成,并采用了高效的数据结构,因此瓶颈一般是机器的内存或者网络带宽,而非CPU,而CPU不是瓶颈就可以采用单线程的方案;
- 采用单线程避免了多线程之间的竞争,省去了多线程切换带来的时间和性能开销,而且也不会导致死锁问题;
- 采用I/O多路复用机制处理大量的客户端请求,I/O多路复用是指一个线程处理多个I/O流;即Redis允许内核中,同时存在多个监听socket和已连接socket,内核会一直监听这些socket上的连接或数据请求,有请求到达就交给Redis线程处理,实现一个Redis线程处理多个I/O流的效果;
Redis6.0之前为什么使用单线程?
单线程的程序是无法利用服务器的多核CPU的,Redis官方给出的FAQ中,核心意思是:CPU并不是制约Redis性能表现的瓶颈所在,更多情况下是受内存和网络的限制。
除此之外,选择单线程也有其他方面的考虑:多线程模型虽然某些方面表现优异,但引入了程序执行顺序的不确定性,带来了并发读写的一些列问题,增加了系统复杂度、同时也存在线程切换、加锁解锁、死锁造成的性能损耗。
Redis6.0之后为什么引入多线程?
在Redis6.0之后,也采用了多个I/O线程来处理网络请求,是因为随着网络硬件的性能提升,Redis的性能瓶颈有时也会出现在网络I/O的处理上;
所以为了提高网络I/O的并行度,6.0版本对于网络I/O采用多线程处理,但是对于命令的执行,仍然使用单线程来处理;
6.0版本支持的I/O多线程,默认情况下只针对发送响应数据,并不会以多线程来处理读请求,要开启多线程处理客户端读请求,需要通过配置文件 Redis.conf 中的 io-threads-do-reads,设置为 yes 来实现;同时配置文件中也提供设置IO多线程的个数 io-threads N(主线程也算一个I/O线程,N-1个I/O多线程),线程数的设置,官方建议线程数一定要小于机器核数,并不是越大越好。
因此,Redis6.0版本之后,启动时默认情况下会额外创建6个线程:
- Redis-server:主线程,负责执行命令;
- bio_close_file、bio_aof_fsync、bio_lazy_free:三个后台线程,异步处理一些耗时任务;
- io_thd_1、io_thd_2、io_thd_3:三个I/O线程(io-threads默认是4),用来分担网络I/O的压力;
选型优势
对比Memcached
Redis最常见的用途是用作缓存,相比于同样基于内存的数据库 Memcached
二者的相同点:
- 都是基于内存的数据库
- 都有数据过期策略
- 性能都非常高
二者的区别:
- Redis支持的数据结构更加丰富,而 Memcached 只支持最简单的 key-value 形式
- Redis支持数据的持久化,可以将数据保存在磁盘中,数据的安全性和可恢复性更高,后者不支持
- Redis原生支持集群模式,Memcached没有原生支持,需要依靠客户端实现向集群中的分片写入数据
- Redis支持订阅模型、Lua脚本、事务等功能,而后者不支持
作为MySQL的缓存
最大的优势在于「高性能」和「高并发」两种特性。
- 高性能在于操作内存的速度要远大于操作硬盘的速度,不过会存在Redis和MySQL数据一致性的问题
- 高并发在于Redis单机的QPS(每秒处理完请求的次数)是MySQL的10倍,将数据库的部分数据转移到缓存中,这样用户的一部分请求可以不用经过数据库