Redis使用清单
Redis 是一个开源的高性能键值对数据库,支持多种数据结构(如字符串、列表、集合等)和功能(如持久化、发布订阅、Stream 消息队列等)。基于内存操作和单线程模型,Redis 提供极快的读写速度和高并发性能,广泛用于缓存、消息队列、实时数据处理等场景。
什么是Redis?
Redis 是一个开源的、高性能的键值对(Key-Value)数据库,它支持多种数据结构并且非常快速,通常用作缓存或消息队列,并且由于其高性能,它常用于需要快速读写数据的场景。
Redis支持以下数据结构:
- 字符串(String):实际上可以是字符串、整数或者浮点数,最大的存储容量为512M。能够对整个字符串或字符串的一部分进行操作。对整数或浮点数进行自增或自减操作。可以用于缓存数据、计数器以及用户会话管理等。
- 字符串的底层是一个简单动态字符串(SDS,Simple Dynamic String),由三部分构成:buf(实际存储字符串的内存区域)、len(字符串的实际长度)、alloc(已分配空间)。在字符串进行修改的时候,会首先根据记录的len属性检查内存空间是否满足,如果不满足就会进行相应的空间扩展,然后再进行修改,避免了缓冲区溢出的问题。同时借助len属性与alloc属性,能够实现空间预分配(对字符串进行空间扩展的时候实际扩展的内存比实际需要的要多)以及惰性空间的释放(对字符串进行缩短操作时不立即使用内存重新分配来回收缩短后的多余字节,而是使用alloc属性记录,等待后续使用)。
- 列表(List):一个链表,链表上面的每个节点都会包含一个字符串,可以对链表的两端进行push和pop操作,读取单个或多个元素;根据值查找或者删除元素。通过该数据结构的
LPUSH
和RPOP
命令,可以实现一个高效的先进先出队列,可以用于消息队列、任务调度、推送系统等。- 列表的底层实现主要有两种:双向链表(Doubly Linked List)以及压缩列表(Ziplist)。当列表中的元素较少时,Redis会选用压缩链表,压缩列表以连续内存块的形式存储元素的结构,将多个元素存储在一个连续的内存区域中,每个元素会有两部分:长度字段:记录该元素的长度;数据字段:存储实际的元素值。压缩列表因为其内存连续,因此只需要直接遍历即可。当元素较多的时候,压缩列表就会转为双向链表。双向链表的每个节点除了存储元素值以外,还需要额外内存来存储两个指针,这样能够快速插入或者删除。
LPUSH
或RPUSH
都能够在O(1)时间内完成。
- 列表的底层实现主要有两种:双向链表(Doubly Linked List)以及压缩列表(Ziplist)。当列表中的元素较少时,Redis会选用压缩链表,压缩列表以连续内存块的形式存储元素的结构,将多个元素存储在一个连续的内存区域中,每个元素会有两部分:长度字段:记录该元素的长度;数据字段:存储实际的元素值。压缩列表因为其内存连续,因此只需要直接遍历即可。当元素较多的时候,压缩列表就会转为双向链表。双向链表的每个节点除了存储元素值以外,还需要额外内存来存储两个指针,这样能够快速插入或者删除。
- 集合(Set):包含字符串的无序集合,包含的基础方法有添加、获取、删除以及计算交集、并集、差集等。集合可以用于去重操作,利用自动去重的特性存储不重复的元素如用户ID、IP地址等,可以用于标签系统、社交网络中的共同好友等功能。
- 集合的底层实现也是主要有两种:哈希(Hash Table)或者整数集合(Intset)。当集合中包含有非整数类型的元素时,Redis会使用哈希表来存储数据。反之就会使用整数集合进行存储,它可以将整数集合存储为有序的整数数组,内存的占用更小。
- 散列(Hash):包含键值对的无序散列表,包含的基础方法有添加、获取、删除单个元素。散列是存储对象的不二之选,可以将一个用户的所有信息都存储在一个哈希表中,使用
HSET
来设置用户的属性,HGET
获取属性,可以用于分布式缓存、简易配置存储等。- 散列的底层实现自然是哈希表。但是当哈希表的元素较少的时候,Redis会使用一种哈希压缩表(ziplist)来减少内存的开销。只有当哈希表变得比较大的时候,Redis才会转回常规的哈希表实现。
- 有序集合(Zset):用于存储键值对,不同的是字符串成员与浮点数分数之间存在一个映射关系,元素的排列顺序由分数的大小决定,包含的基础方法依旧有添加、获取、删除单个元素以及根据分值范围来获取元素。因为每一个元素都有一个关联的分数,因此可以用于排行榜/计分系统(
ZADD
添加用户及其积分、ZRANGE
获取排名前N的用户)、实时数据分析、延迟队列等。- 有序集合的底层实现主要是跳表(Skip List)和哈希表(Hash Table)。跳表是一种允许高效范围查询的概率型数据结构,通过多层索引加速查找的过程。除了跳表以外Redis还使用哈希表来存储元素和分数的映射关系,这使得在有序集合中插入和删除元素的时候,操作能够在常数时间内完成。
可以看到Redis的数据结构是相对而言比较简单的,对数据操作也简单明了,在其完全基于内存的加持之下,Redis拥有高效的数据访问。
Redis还有一个特别之处就是单线程特性,这使得Redis避免了多线程导致的上下文切换开销以及数据竞争。因为Redis是单线程的,自然也就不存在加锁释放锁的问题,更加没有可能因为死锁而导致性能消耗。
虽然是单线程,但是却能够同时处理大量的请求,因为Redis采用了非阻塞I/O,使用epoll(基本思想是通过一个内核事件表来跟踪多个文件描述符的状态。它允许应用程序在一个线程中监控大量的文件描述符,而无需每次都轮询每个文件描述符,减少了性能开销)或其他类似的时间通知机制,能够处理大量的并发请求而不需要使用多线程。每一次请求都会被排队等待处理,Redis通过事件循环不断轮询这些请求,确保请求能够被高效执行。
Redis Stream
Redis Stream是Redis 5.0版本引入的一种新的数据类型,它被用来高效地处理消息队列、事件流、日志系统等场景。Redis Stream结合了消息队列和日志系统的特点,具有高效、可扩展、可靠的优势,适用于处理大规模数据流的应用。
Redis Stream是一个有序的消息集合,由一系列按时间顺序生成的消息组成,每条消息都附带一个唯一的ID,消息的ID是自动生成的,通常由时间戳和序号组合而成。时间戳是毫秒级单位,是生成消息的Redis服务器时间,它是个64位整型(int64)。序号是在这个毫秒时间点内的消息序号,是单调有序的,它也是个64位整型。由于ID包含时间戳部分,为了避免服务器时间错误带来的问题,Redis的每个Stream类型数据都会维护一个latest_generated_id
属性,用于记录最后一个消息的ID。如果发现时间戳退后,就采用时间戳不变而序号递增的方案来作为新消息的ID。
Redis作为消息队列
- 使用List实现消息队列:这是使用Redis实现消息队列的最基础的方式,基本原理就是使用
LPUSH
往队列中插入信息,使用RPOP
从队列中消费信息,比较适合单生产者-消费者的场景,然而这种方式不支持消费分组,多个消费者会争抢消息,难以实现负载均衡;同时没有持久化ACK确认机制,消费者异常退出时会丢失未处理的消息。 - 使用PUB/SUB实现发布订阅:生产者通过
PUBLISH
发布消息到某个频道,消费者通过SUBSCRIBE
订阅该频道并且接收信息,这种方式适合广播场景,一个消息可以被多个订阅者同时接收,同时实时性较高,消息一旦发布,所有的订阅者都能够接收到。但是这种方式并不支持消息的持久化,消费者离线期间的消息会丢失,无法确保消息被成功处理。
Redis Stream很好地弥补了以上消息队列的缺点。为了避免消费者崩溃带来的信息丢失的问题,Stream设计了Pending列表,用于记录读取但未处理完毕的消息。可以使用命令XPENDING
来获取消费组或者消费组内的未处理完毕的消息。每个Pending消息有四个属性:消息ID、所属消费者、IDLE(已读取时长)、delivery counter(被读取次数),当消费者处理完毕以后会使用命令XACK来完成告知消息处理完成,这种方式意味着消费者在读取消息但是未处理时,消息是不会丢失的。等待消费者再次上线之后,可以读取Pending列表,就可以继续处理该消息,保证消息的有序性与不丢失。此外,如果某个消息不能被消费者处理,就会长时间在Pending列表中,此时delivery counter就会累加,当累加到某个临界值的时候,就会认为该消息是死信,从而将其删除。
Redis持久化
虽然Redis是一个基于内存的数据库,但是出于容灾考虑,它也有进行持久化的需要。Redis的持久化机制主要有RDB、AOF以及RDB与AOF混用三种。
RDB
RDB (Redis DataBase) 是 Redis 提供的一种持久化方式,它会将 Redis 在内存中的数据以快照 (snapshot) 的形式保存到磁盘上的一个二进制文件中,这个文件通常命名为dump.rdb。当 Redis 重启时,可以通过加载 RDB 文件来快速恢复数据到保存快照时的状态。因此,RDB 也常被称为“内存快照”。
RDB的工作原理
- fork子进程:Redis主进程使用fork系统调用创建一个子进程。这个子进程拥有父进程的完整内存副本,但他们是独立的进程。
- 子进程写入RDB文件,子进程负责遍历内存中的所有数据,并且将数据序列化成RDB文件格式,然后写入到磁盘中。
- 主进程继续工作:在子进程创建和写入RDB文件的过程中,主进程可以继续处理客户端的请求,不会被阻塞。
- 写时复制:如果在子进程写入RDB文件的过程中,主进程修改了某个数据,那么被修改的数据所在的内存页面会被复制一份。主进程使用新的内存页,而子进程仍然使用旧的内存页,这样就保证了子进程写入RDB文件的数据是一致的,不会受到主进程修改数据的影响。
RDB的触发方式
- 自动触发:在redis.conf配置文件中,可以通过
save
指令来配置自动出发RDB快照的条件。save
的指令格式为save <seconds> <changes>
,表示在指定的秒数内,如果数据库发生了指定次数的修改,就会自动触发RDB快照。如下:
- 手动触发:可以使用
SAVE
或BGSAVE
来手动触发RDB快照。区别之处就在于SAVE
命令会在主进程中执行,阻塞主进程直到RDB快照完成,不适合在生产环境中使用。而BGSAVE
则是会在后台创建一个子进程来执行RDB快照,不会阻塞主进程。 - 执行
SHUTDOWN
命令:当执行SHUTDOWN
时,如果RDB持久化是开启的并且没有开启AOF持久化,Redis会先执行一次BGSAVE
命令,然后再关闭服务器。 - 主从复制:当从节点要从主节点进行全量复制的时候也会触发
BGSAVE
操作,生成当时的快照并发送到从节点。主从复制使用的是RDB而不是AOF的原因是RDB是经过压缩的二进制数据,文件很小且使用快速,并且不需要像AOF一样重放每个写命令,经历冗长的处理逻辑。假设使用AOF做全量复制,就必须要打开AOF功能,打开AOF就需要选择同步的策略,选择不当还会严重影响Redis的性能。
RDB存储的文件是二进制文件,加载速度要比AOF文件快得多,同时其存储的是数据的快照,文件大小通常会比AOF更小,节省磁盘空间,可以方便地将RDB文件复制到其他地方进行备份和灾难恢复,对性能的影响也较小。但是也有其缺点:如果Redis在两次快照之间发生故障,那么最后一次快照之后的数据就会丢失,数据丢失的量取决于快照的频率。同时在执行BGSAVE
命令的时候,由于需要fork子进程,如果内存较大,fork的过程会比较耗时,而且如果在fork以后主进程又修改了大量的数据,可能就会导致内存占用翻倍。
RDB进行快照的时候如果发生了服务崩溃,那么在没有将数据全部写入到磁盘前,这次快照操作都不算成功,如果出现了服务器崩溃的情况,就会以上一次完整的RDB快照文件作为恢复内存数据的参考,即在快照操作的过程中不能影响上一次备份数据。Redis会在磁盘上创建一个临时文件进行数据操作,等待操作成功之后才会使用该临时文件替换掉上一次的备份。
AOF
AOF通过将写操作逐条记录到日志文件来实现持久化,从而保证数据的安全性。相比于RDB机制,AOF的主要目标是提供更高的数据可靠性和更细粒度的持久化。
AOF的工作原理
- 命令写入
aof_buf
缓冲区:当客户端发送写命令给Redis服务器时,Redis会先将这些命令追加到aof_buf
缓冲区中。- 写入时机:该方法首先将命令写入内存,然后才记录日志。这样可以减少额外的检查成本,并且不会阻塞当前的写入操作。然而,这也带来了一些风险:如果在写入日志之前系统崩溃,已完成的命令可能会丢失数据。此外,主线程在写入磁盘时的压力较大,可能会导致写入速度变慢,从而阻塞后续操作。
- 缓冲区同步到磁盘:根据配置的同步策略,Redis会将
aof_buf
缓冲区中的数据同步到磁盘上的AOF文件中。- 同步策略:
- Always:同步写回,每个写命令执行完毕都会立马同步将日志写回磁盘,该策略的性能影响较大,每个写命令都要落盘。
- EverySec:每秒写回,每个写命令执行完,只是先将日志写到AOF的内存缓冲区中,每隔一秒吧缓冲区中的内容写入到磁盘中,宕机时只丢失一秒内的数据。
- No:由操作系统控制写回,这种方式的性能最好,但是宕机的时候丢失的数据较多。
- 同步策略:
- 文件重写:随着时间的推移,AOF文件会变得越来越大,AOF重写机制可以创建一个新的AOF文件,其中只包含重建当前数据集所需要的最少命令。这个重写的过程由后台的bgrewriteaof来完成,主线程fork出后台的bgrewriteaof子进程,并且将主线程的内存拷贝到子线程中,里面包含了数据库的最新数据,而后其就能在不影响主线程的情况下逐一将拷贝的数据写为操作,记入到新的重写日志中。因此在重写的过程中,fork进程时会阻塞主线程。
- 重写时机:
- auto-aof-rewrite-min-size:表示运行AOF重写时文件的最小大小,默认为64MB。
- auto-aof-rewrite-percentage:表示当前 AOF 文件大小比上一次重写后的 AOF 文件大小的百分比。例如,设置为 100 表示当 AOF 文件大小比上一次重写后的大小增加了一倍时触发重写。
- 重写日志时如果有新数据写入,主线程会将命令记录到两个aof日志内存缓冲区中,如果AOF写回的策略配置的是Always,就会直接将命令写回到旧的日志文件,并且保存一份命令到AOF重写缓冲区。而在bgrewriteaof子进程完成会日志文件的重写操作后,会提示主线程已经完成重写操作,主线程会将AOF重写缓冲中的命令追加到新的日志文件后面。这时候在高并发的情况下,AOF重写缓冲区积累可能会很大,这样就会造成阻塞,Redis后来通过Linux管道技术让AOF重写期间就能同时进行回放,这样AOF重写结束后只需回放少量剩余的数据即可。最后再通过修改文件名的方式保证文件切换的原子性。
- 主线程的阻塞时间:fork子进程拷贝虚拟页表的过程中会对主线程进行阻塞,或者当主进程有bigkey写入的时候,操作系统会创建页面的副本,并且拷贝原来的数据,对主线程进行阻塞。此外,子线程重写日志完成后,主线程追加AOF重写缓冲区时也可能对主线程进行阻塞。
- 重写时机:
- 重启加载:当Redis服务器重启时,如果开启了AOF持久化,它会加载AOF文件中的命令,并且逐个执行这些命令,从而恢复数据。
RDB与AOF混合持久化
RDB 和 AOF 混用持久化方式,也称为混合持久化,是 Redis 4.0 引入的一种新的持久化方式。它结合了 RDB 和 AOF 各自的优点,既能实现快速的数据恢复,又能最大限度地减少数据丢失的风险。
混合持久化工作原理
核心思想其实就是在AOF重写时,将重写以前的数据以RDB的格式写入到AOF文件的开头,然后将重写期间的增量操作以AOF格式追加到文件末尾。这样AOF文件就分为了两部分:一部分是RDB,一部分是AOF,这样能够很好地利用RDB与AOF各自的优点。
参考资料:
Java 全栈知识点问题汇总(上) | Java 全栈知识体系
Data Structures - Redis
Redis持久化|文档 --- Redis persistence | Docs
Redis 中仅追加文件的重要性 |数九 --- Importance of Append-only File in Redis | Severalnines