图解Redis面试篇
认识Redis
什么是Redis?
Redis 是一种基于内存的数据库,对数据的读写操作都是在内存中完成,因此读写速度非常快,常用于缓存,消息队列、分布式锁等场景。
Redis提供了多种数据类型来支持不同的业务场景,比如String(字符串)、Hash(哈希)、List(列表)、Set(集合)、Zset(有序集合)、Bitmaps(位图)、HyperLogLog(基数统计)、GEO(地理信息)、Stream(流),并且对数据类型的操作都是原子性的,因为执行命令由单线程负责的,不存在并发竞争的问题。
除此之外,Redis还支持事务、持久化、Lua脚本、多种集群方案(主从复制模式、哨兵模式、切片集群模式)、发布/订阅模式,内存淘汰机制、过期删除机制等等。
Redis 和 Memcached 有什么区别?
Redis 与 Memcached 的共同点:
- 都是基于内存的数据库,一般都用来当做缓存使用。
- 都有过期策略。
- 两者的性能都非常高。
Redis 与 Memcached 的区别:
- Redis 支持的数据类型更丰富(Sring、Hash、List、Set、ZSet),而 Memcache 只支持最简单的 key-value 数据类型;
- Redis 支持此数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而 Memcached 没有持久化功能,数据全部存在内存之中,Memcached 重启或者挂掉后,数据就没了;
- Redis 原生支持集群模式,Memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;
- Redis 支持分布订阅模式、Lua 脚本、事务等功能,而 Memcached 不支持。
为什么用Redis作为MySQL的缓存?
主要是因为Redis 具备「高性能」和「高并发」两种特性。
- Redis 具备高性能
假如用户第一次访问MySQL中的某些数据。这个过程会比较慢,因为是从硬盘上读取的。将该用户访问的数据缓存在Redis中,这样下一次再访问这些数据的时候就可以直接从缓存中获取了,操作Redis缓存就是直接操作内存,所以速度相当快。

- Redis 具备高并发
单台设备的 Redis 的QPS (Query Per Second,每秒钟处理完请求的次数)是MySQL的10倍,Redis单机的QPS能轻松破10w,而MySQL单机的QPS很难破1w。
所以,直接访问Redis能够承受的请求是远远大于直接访问 MySQL 的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。
Redis数据结构
Redis数据类型以及使用场景分别是什么?
Redis提供了丰富的数据类型,常见的有五种数据类型:String(字符串),Hash(哈希),List(列表),Set(集合)、Zset(有序集合)。
| 结构类型 | 结构存储的值 | 结构的读写能力 |
|---|---|---|
| String字符串 | 可以是字符串、整数或浮点数 | 对整个字符串或字符串的一部分进行操作;对整数或浮点数进行自增或自减操作 |
| List列表 | 一个链表,链表上的每个节点都包含一个字符串 | 对链表的两端进行push和pop操作,读取单个或多个元素;根据值查找或删除元素 |
| Hash数列 | 包含键值对的无序散列表 | 包含方法有添加、获取、删除单个元素 |
| Set集合 | 包含字符串的无序集合 | 字符串的集合,包含基础的方法有看是否存在添加、获取、删除;还包括计算交集、并集、差集等 |
| ZSet有序集合 | 和散列一样,用于存储键值对 | 字符串成员与浮点数分数之间的有序映射;元素的排序顺序由分数的大小决定;包含方法有添加、获取、删除单个元素以及根据分值范围或成员来获取元素 |
- String 类型的应用场景:缓存对象、常规计数、分布式锁、共享 session 信息等;
- List 类型的应用场景:消息队列(但是有两个问题:1. 生产者需要自行实现全局唯一ID;2. 不能以消费组形式消费数据)等;
- Hash类型:缓存对象、购物车等;
- Set类型:聚合计算(并集、交集、差集)场景,比如点赞、共同关注、抽奖活动等;
- ZSet类型:排序场景,比如排行榜、电话和姓名排序等。
随着 Redis 版本的更新,后面又支持了四种数据类型: BitMap(2.2 版新增)、HyperLogLog(2.8 版新增)、 GEO(3.2 版新增)、Stream(5.0 版新增)。它们的应用场景如下:
- BitMap(位图):二值状态统计的场景,比如签到、判断用户登陆状态、连续签到用户总数等;
- HyperLogLog(超日志):海量数据基数统计的场景,比如百万级网页UV计数等;
- GEO(地理空间索引):存储地理位置信息的场景,比如滴滴叫车;
- Stream(流):消息队列,相比于基于List类型实现的消息队列,有这两个特有的特征:自动生成全局唯一消息ID,支持以消费组形式消费数据。
五种常见的Redis数据类型是怎么实现?

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类型的底层数据结构。
但是在Redis3.2版本之后,List数据类型底层数据结构就只由quicklist实现了,替代了双向链表和压缩列表。
Hash 类型的内部实现
Hash类型的底层数据结构是由压缩列表或哈希表实现的:
- 如果列表的元素个数小于512个(默认值,可由list-max-ziplist-entries配置),列表每个元素的值都小于64字节(默认值,可用hash-max-ziplist-value配置),Redis会使用压缩列表作为Hash类型的底层数据结构;
- 如果哈希类型的元素不满足上面的条件,Redis会使用哈希表作为Hash类型的底层数据结构。
在Redis 7.0中,压缩列表数据结构已经废弃了,交由listpack数据结构来实现了。
Set 类型的内部实现
Set类型的底层数据结构是由哈希表或整数集合实现的:
- 如果集合中的元素个数小于512个(默认值,可由set-max-ziplist-entries配置),Redis会使用整数集合作为Set类型的底层数据结构;
- 如果集合中的元素不满足上面条件,则Redis使用哈希表作为Set类型的底层数据结构。
ZSet 类型的内部实现
ZSet类型的底层数据结构是由压缩列表或跳表实现的:
- 如果有序集合的元素个数小于128个,并且每个元素的值小于64字节时,Redis会使用压缩列表作为ZSet类型的底层数据机构;
- 如果有序集合的元素不满足上面的条件,Redis会使用跳表作为ZSet类型的底层数据结构。
在Redis 7.0中,压缩列表数据结构已经废弃了,交由listpack数据结构来实现了。
Redis线程模型
Redis是单线程吗?
Redis单线程指的是「接受客户端请求->解析请求->进行数据读写等操作->发送数据给客户端」这个过程都是由一个线程(主线程)来完成的,这也是我们常说Redis是单线程的原因。
但是,Redis程序并不是单线程的,Redis在启动的时候,是会启动后台线程(BIO)的:
- Redis在2.6版本,会启动2个后台线程,分别处理关闭文件、AOF刷盘这两个任务;
- Redis在4.0版本之后,新增了一个新的后台线程,用来异步释放Redis内存,也就是Lazy free线程。例如执行unlink key/ flushdb anync/flushall anync等命令,会把这些删除操作交给后台线程来执行,好处是不会导致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函数等待事件的的到来:
- 如果是连接事件到来,则会调用连接事件处理函数,该函数会做这些事情:调用accept获取已连接的socket->调用epoll_ctl将已连接的socket加入到epoll -> 注册「读事件」处理函数;
- 如果是读事件到来,则会调用读事件处理函数,该函数会做这些事情:调用read获取客户端发送的数据 -> 解析命令 -> 处理命令 -> 将客户端对象添加到发送队列 -> 将执行结果写到发送缓存区等待发送;
- 如果是写事件到来,则会调用写事件处理函数,该函数会做这些事情:通过write函数将客户端发送缓存区的数据发送出去,如果这一轮数据没有发送完,就会继续注册写事件处理函数,等待epoll_wait 发现可写后再处理 。
Redis采用单线程为什么还这么快?
官方使用基准测试的结果是,单线程的 Redis 吞吐量可以达到 10W/每秒,之所以Redis采用单线程(网络I/O和执行命令)那么快,有如下几个原因:
- Redis 的大部分操作都在内存中完成,并且采用了高效的数据机构,因此Redis瓶颈可能是机器的内存或者网络带宽,而并非CPU,既然CPU不是瓶颈,那么自然就采用了单线程的解决方案了;
- Redis采用单线程模型可以避免了多线程之间的竞争,省去了多线程切换带来的时间和性能上的开销,而且也不会导致死锁。
- Redis采用了I/O多路复用机制处理大量的客户端Socket请求,IO多路复用机制是指一个线程处理多个IO流,就是我们经常听到的select/epoll机制。简单来说,在Redis只运行单线程的情况下,该机制允许内核中,同时存在多个监听Socket和已连接Socket。内核会一直监听这些Socket上的连接请求或数据请求。一旦有请求到达,就会交给Redis线程处理,这就实现了一个Redis线程处理多个IO流的效果。
Redis6.0之前为什么使用单线程?
官方回答:CPU并不是制约Redis性能表现的瓶颈所在,更多情况下是受到内存大小和网络I/O的限制所以Redis核心网络模型使用单线程并没有什么问题,如果你想要使用服务的多核CPU,可以在一台服务器上启动多个节点或者采用分片集群的方式。
除了上面的官方回答,选择单线程的原因也有下面的考虑。
使用了单线程后,可维护性高,多线程模型虽然在某些方面表现优异,但是它却引入了程序执行顺序的不确定性,带来了并发读写的一系列问题,增加了系统复杂度、同时可能存在线程切换、甚至加锁解锁、死锁造成的性能损耗。
Redis6.0之后为什么引入了多线程?
在Redis6.0版本之后,也采用了多个I/O线程来处理网络请求,这是因为随着网络硬件的性能提升,Redis的性能瓶颈有时会出现在网络I/O的处理上。
所以为了提高网络1/O的并行度,Redis6.0对于网络I/O采用多线程来处理。但是对于命令的执行,Redis仍然使用单线程来处理,所以大家不要误解Redis有多线程同时执行命令。
Redis官方表示,Redis6.0版本引入的多线程I/O特性对性能提升至少是一倍以上。
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-thrads默认是4,所以会启动3(4-1)个I/O多线程,用来分担Redis网络I/O的压力。
Redis持久化
Redis如何实现数据不丢失?
Redis实现数据持久化的机制,就是把数据存储到磁盘,这样Redis重启后就能够从磁盘中恢复原有的数据。
Redis共有三种数据持久化的方式:
- AOF日志:每执行一条写操作命令,就把该命令以追加的方式写入到一个文件里;
- RDB快照:将某时刻的内存数据,以二进制的方式写入磁盘;
- 混合持久化方式:Redis 4.0新增的方式,集成了AOF和RDB的优点。
AOF日志是如何实现的?
Redis 在执行完一条写操作命令后,就会把该命令以追加的方式写入到一个文件里,然后 Redis 重启时,会读取该文件记录的命令,然后逐一执行命令的方式来进行数据恢复。
为什么先执行命令,在把数据写入日志呢?
Redis 是执行写操作命令后,才将该命令记录到AOF日志里的,这样做其实有两个好处。
- 避免额外的检查开销:因为如果先将写操作命令记录到 AOF 日志里,再执行该命令的话,如果当前的命令语法有问题,那么如果不进行命令语法检查,该错误的命令记录到 AOF 日志里后,Redis 在使用日志恢复数据时,就可能会出错。
- 不会阻塞当前写操作命令的执行:为当写操作命令执行成功后,才会将命令记录到 AOF 日志。
当然这么做的也带来风险:
- 数据可能会丢失:执行写操作命令和记录日志是两个过程,那当 Redis 在还没来得及将命令写入到硬盘时,服务器发生宕机了,这个数据就会有丢失的风险。
- 可能阻塞其他操作:由于写操作命令执行成功后才记录到 AOF 日志,所以不会阻塞当前命令的执行,但因为 AOF 日志也是在主线程中执行,所以当 Redis 把日志文件写入磁盘的时候,还是会阻塞后续的操作无法执行。
AOF写回策略有几种?
Redis 写入 AOF 日志的过程:
- Redis 执行完写操作命令后,会将命令追加到 server.aof_buf 缓冲区;
- 然后通过 write() 系统调用,将 aof_buf 缓冲区的数据写入到 AOF 文件,此时数据并没有写入到硬盘,而是拷贝到了内核缓冲区 page cache,等待内核将数据写入硬盘;
- 具体内核缓冲区的数据什么时候写入到硬盘,由内核决定。
Redis 提供了 3 种写回硬盘的策略,控制的就是上面说的第三步的过程。在 Redis.conf 配置文件中的 appendfsync 配置项可以有以下 3 种参数可填:
- Always,每次写操作命令执行完后,同步将AOF 日志数据写回硬盘;
- Everysec,每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后每隔一秒将缓冲区里的内容写回到硬盘;
- No,意味着不由 Redis 控制写回硬盘的时机,转交给操作系统控制写回的时机,也就是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘。
| 写回策略 | 写回时机 | 优点 | 缺点 |
|---|---|---|---|
| Always | 同步写回 | 可靠性高、最大程度保证数不丢失 | 每个写命令都要写回硬盘,性能开销大 |
| Everysec | 每秒写回 | 性能适中 | 岩机时会丢失1秒内的数据 |
| No | 由操作系统控制写回 | 性能好 | 岩机时丢失的数据可能会很多 |
AOF 日志过大,会触发什么机制?
如果当AOF 日志文件过大会带来性能问题,比如重启 Redis 后,需要读 AOF 文件的内容以恢复数据,如果文件过大,整个恢复的过程就会很慢。
Redis 为了避免 AOF 文件越写越大,提供了AOF 重写机制,当 AOF 文件的大小超过所设定的阈值后, Redis 就会启用AOF 重写机制,来压缩 AOF 文件。
AOF 重写机制是在重写时,读取当前数据库中的所有键值对,然后将每一个键值对用一条命令记录到「新的 AOF 文件」,等到全部记录完后,就将新的 AOF 文件替换掉现有的 AOF 文件。
重写工作完成后,就会将新的 AOF 文件覆盖现有的 AOF 文件,这就相当于压缩了 AOF 文件,使得 AOF 文件体积变小了。
重写 AOF 日志的过程是怎样的?
Redis 的重写 AOF 过程是由后台子进程 bgrewriteaof来完成的,这么做可以达到两个好处:
- 子进程进行 AOF 重写期间,主进程可以继续处理命令请求,从而避免阻塞主进程;
- 子进程带有主进程的数据副本,这里使用子进程而不是线程,因为如果是使用线程,多线程之间会共享内存,那么在修改共享内存数据的时候,需要通过加锁来保证数据的安全,而这样就会降低性能。而使用子进程,创建子进程时,父子进程是共享内存数据的,不过这个共享的内存只能以只读的方式,而当父子进程任意一方修改了该共享内存,就会发生「写时复制」,于是父子进程就有了独立的数据副本,就不用加锁来保证数据安全。
触发重写机制后,主进程就会创建重写 AOF 的子进程,此时父子进程共享物理内存,重写子进程只会对这个内存进行只读,重写 AOF 子进程会读取数据库里的所有数据,并逐一把内存数据的键值对转换成一条命令,再将命令记录到重写日志(新的 AOF 文件)。
但是重写过程中,主进程依然可以正常处理命令,那问题来了,重写 AOF 日志过程中,如果主进程修改了已经存在 key-value,那么会发生写时复制,此时这个 key-value 数据在子进程的内存数据就跟主进程的内存数据不一致了,这时要怎么办呢?
为了解决这种数据不一致问题, Redis 设置了一个 AOF重写缓冲区,这个缓冲区在创建bgrewriteaof 子进程之后开始使用。
在重写 AOF 期间,当 Redis 执行完一个写命令之后,它会同时将这个写命令写入到 「AOF 缓冲区」和 「AOF 重写缓冲区」。

也就是说,在 bgrewriteaof 子进程执行 AOF 重写期间,主进程需要执行以下三个工作:
- 执行客户端发来的命令;
- 将执行后的写命令追加到「AOF 缓冲区」;
- 将执行后的写命令追加到「AOF 重写缓冲区」。
当子进程完成 AOF 重写工作(扫描数据库中所有数据,逐一把内存数据的键值对转换成一条命令,再将命令记录到重写日志)后,会向主进程发送一条信号,信号是进程间通讯的一种方式,而且是异步的。
主进程收到信号后,会调用一个信号处理函数,该函数主要做以下工作:
- 将
AOF 重写缓冲区中的所有内容追加到新的 AOF 的文件中,使得新旧两个 AOF 文件所保存的数据库状态一致; - 新的 AOF 的文件进行改名,覆盖现有的 AOF 文件。
信号函数执行完后,主进程就可以继续像往常一样处理命令了。
RDB快照是如何实现的呢?
因为 AOF 日志存储的是操作命令,不是实际的数据,所以用 AOF 方法做故障恢复时,需要全量把日志都执行一遍,一旦 AOF 日志非常多,势必会造成 Redis 的恢复操作缓慢。
为了解决这个问题, Redis 增加了 RDB 快照。所以, RDB 快照就是记录某一个瞬间的内存数据,记录的是实际数据,而 AOF 文件记录的是命令操作的日志,而不是实际的数据。
因此在 Redis 恢复数据时, RDB 恢复数据的效率会比 AOF 高些,因为直接将 RDB 文件读入内存就可以,不需要像 AOF 那样还需要额外执行操作命令的步骤才能恢复数据。
RDB 做快照时会阻塞线程吗?
Redis 提供了两个命令来生成 RDB 文件,分别是 save 和 bgsave,它们的区别就在于是否在「主线程」里执行:
- 执行了 save 命令,就会在主线程生成 RDB 文件,由于和执行操作命令在同一个线程,所以如果写入了 RDB 文件的时间太长,会阻塞主线程;
- 执行了 bgsave 命令,会创建一个子进程来生成 RDB 文件,这样可以避免主线程的阻塞。
Redis 还可以通过配置文件的选项来实现每隔一段时间自动执行一次 bgsave 命令,默认会提供以下配置:
save 900 1
save 300 10
save 60 10000
别看选项名叫 save,实际上执行的是 bgsave 命令,也就是会创建子进程来生成 RDB 快照文件。只要满足上面条件的任意一个,就会执行 bgsave,它们的意思分别是:
- 900 秒之内,对数据库进行了至少 1 次修改;
- 300 秒之内,对数据库进行了至少 10 次修改;
- 60 秒之内,对数据库进行了至少 10000 次修改。
Redis 的快照是全量快照,也就是说每次执行快照,都是把内存中的「所有数据」都记录到磁盘中。所以执行快照是一个比较重的操作,如果频率太频繁,可能会对 Redis 性能产生影响。如果频率太低,服务器故障时,丢失的数据会更多。
RDB 在执行快照的时候,数据能修改吗?
可以的,执行 bgsave 过程中,Redis 依然可以继续处理操作命令,也就是数据是能被修改的,关键的技术就在于写时复制技术(Copy-On-Write, COW)。
执行 bgsave 命令的时候,会通过 fork() 创建子进程,此时子进程和父进程是共享同一片内存数据的,因为创建子进程的时候,会复制父进程的页表,但是页表指向的物理内存还是一个,此时如果主线程执行读操作,则主线程和 bgsave 子进程互相不影响。

如果主线程执行写操作,则被修改的数据会复制一份副本,然后 bgsave 子进程会把该副本数据写入 RDB 文件,在这个过程中,主线程仍然可以直接修改原来的数据。

为什么会有混合持久化?
RDB 优点是数据恢复速度快,但是快照的频率不好把握。频率太低,丢失的数据就会比较多,频率太高,就会影响性能。
AOF 优点是丢失数据少,但是数据恢复不快。
为了集成了两者的优点, Redis 4.0 提出了混合使用 AOF 日志和内存快照,也叫混合持久化,既保证了 Redis 重启速度,又降低数据丢失风险。
混合持久化工作在AOF 日志重写过程,当开启了混合持久化时,在 AOF 重写日志时,fork 出来的重写子进程会先将与主线程共享的内存数据以 RDB 方式写入到 AOF 文件,然后主线程处理的操作命令会被记录在重写缓存区里,重写缓冲区里的增量命令会以 AOF 方式写入到 AOF 文件,写入完成后通知主进程将新的含有 RDB 格式和 AOF 格式的 AOF 文件替换旧的的 AOF 文件。
也就是说,使用了混合持久化,AOF 文件的前半部分是 RDB 格式的全量数据,后半部分是 AOF 格式的增量数据。

混合持久化优点:混合持久化结合了 RDB 和 AOF 持久化的优点,开头为 RDB 的格式,使得 Redis 可以更快的启动,同时结合 AOF 的优点,有减低了大量数据丢失的风险。
混合持久化缺点:
- AOF 文件中添加了 RDB 格式的内容,使得 AOF 文件的可读性变得很差;
- 兼容性差,如果开启混合持久化,那么此混合持久化 AOF 文件,就不能用在 Redis 4.0 之前版本了。
Redis集群
Redis如何实现服务高可用?
要想设计一个高可用的Redis服务,一定要从Redis的多服务节点来考虑,比如Redis的主从复制、哨兵模式、切片集群。
主从复制
主从复制是 Redis 高可用服务的最基础的保证,实现方案就是将从前的一台 Redis 服务器,同步数据到多台从 Redis 服务器上,即一主多从的模式,且主从服务器之间采用的是「读写分离」的方式。
主服务器可以进行读写操作,当发生写操作时自动将写操作同步给从服务器,而从服务器一般是只读,并接受主服务器同步过来写操作命令,然后执行这条命令。

也就是说,所有的数据修改只在主服务器上进行,然后将最新的数据同步给从服务器,这样就使得主从服务器的数据是一致的。
哨兵模式
在使用 Redis 主从服务的时候,会有一个问题,就是当 Redis 的主从服务器出现故障宕机时,需要手动进行恢复。
为了解决这个问题,Redis 增加了哨兵模式,因为哨兵模式做到了可以监控主从服务器,并且提供主从节点故障转移的功能。

切片集群
当 Redis 缓存数据量大到一台服务器无法缓存时,就需要使用 Redis 切片集群(Redis Cluster )方案,它将数据分布在不同的服务器上,以此来降低系统对单主节点的依赖,从而提高 Redis 服务的读写性能。
Redis Cluster方案采用哈希槽(Hash Slot),来处理数据和节点之间的映射关系。在Redis Cluster方案中,一个切片集群共有 16384 个哈希槽,这些哈希槽类似于数据分区,每个键值对都会根据它的 key,被映射到一个哈希槽中,具体执行过程分为两大步:
- 根据键值对的key,按照CRC16算法计算一个16bit的值。
- 再用 16bit 值对 16384 取模,得到 0~16383 范围内的模数,每个模数代表一个相应编号的哈希槽。
集群脑裂导致数据丢失怎么办?
什么是脑裂?
由于网络原因,集群节点之间失去联系。主从数据不同步;重新平衡选举,产生两个主服务。
等网络恢复,旧主节点会降级为从节点,再与新主节点进行同步复制的时候,由于从节点会清空自己的缓冲区,所以导致之前客户端写入的数据丢失了。
解决办法
当主节点发现从节点下线或者通信超时的总数量小于阈值时,那么禁止主节点进行写数据,直接把错误返回给客户端。
在 Redis 的配置文件中有两个参数我们可以设置:
- min-slaves-to-write x,主节点必须要有至少 x 个从节点连接,如果小于这个数,主节点会禁止写数据。
- min-slaves-max-lag x,主从数据复制和同步的延迟不能超过 x 秒,如果超过,主节点会禁止写数据。
我们可以把 min-slaves-to-write 和 min-slaves-max-lag 这两个配置项搭配起来使用,分别给它们设置一定的阈值,假设为 N 和 T。
Redis过期删除与内存淘汰
Redis使用的过期删除策略是什么?
Redis 是可以对 key 设置过期时间的,因此需要有相应的机制将已过期的键值对删除,而做这个工作的就是过期键值删除策略。
每当我们对一个 key 设置了过期时间时,Redis 会把该 key 带上过期时间存储到一个过期字典中,也就是说「过期字典」保存了数据库中所有 key 的过期时间。
当我们查询一个key时,Redis 首先会检查该 key 是否存在于过期字典中:
- 如果不在,则正常读取键值;
- 如果存在,则会获取该 key 的过期时间,然后与当前系统时间进行比对,如果比系统时间大,那就没有过期,否则判定该 key 已过期。
Redis 使用的过期删除策略是「惰性删除+定期删除」这两种策略配和使用。
什么是惰性删除策略?
惰性删除策略的做法是,不主动删除过期键,每次从数据库访问key时,都检测key是否过期,如果过期则删除该 key。
优点:因为每次访问时,才会检查 key 是否过期,所以此策略只会使用很少的系统资源,因此,惰性删除策略对 CPU 时间最友好。
缺点:只要这个过期 key 一直没有被访问,它所占用的内存就不会释放,造成了一定的内存空间浪费。
什么是定期删除策略?
定期删除策略的做法是,每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期key。
Redis 的定期删除的流程:
- 从过期字典中随机抽取 20 个 key;
- 检查这 20 个 key 是否过期,并删除已过期的 key;
- 如果本轮检查的已过期 key 的数量,超过 5 个(20/4),也就是「已过期 key 的数量」占比「随机抽取 key 的数量」大于 25%,则继续重复步骤 1;如果已过期的 key 比例小于 25%,则停止继续删除过期 key,然后等待下一轮再检查。
可以看到,定期删除是一个循环的流程。那 Redis 为了保证定期删除不会出现循环过度,导致线程卡死现象,为此增加了定期删除循环流程的时间上限,默认不会超过 25ms。
优点:通过限制删除操作执行的时长和频率,来减少删除操作对 CPU 的影响,同时也能删除一部分过期的数据减少了过期键对空间的无效占用。
缺点:难以确定删除操作执行的时长和频率。如果执行的太频繁,就会对 CPU 不友好;如果执行的太少,那又和惰性删除一样了,过期 key 占用的内存不会及时得到释放。
Redis 选择「惰性删除+定期删除」这两种策略配和使用,以求在合理使用 CPU 时间和避免内存浪费之间取得平衡。
Redis持久化时,对过期键会如何处理的?
Redis 持久化文件有两种格式:RDB(Redis Database)和 AOF (Append Only File),下面我们分别来看过期键在这两种格式中的呈现状态。
RDB 文件分为两个阶段,RDB 文件生成阶段和加载阶段。
-
RDB 文件生成阶段:从内存状态持久化成 RDB(文件)的时候,会对 key 进行过期检查,过期的键「不会」被保存到新的 RDB 文件中,因此 Redis 中的过期键不会对生成新 RDB 文件产生任何影响。
-
RDB 加载阶段:RDB 加载阶段时,要看服务器是主服务器还是从服务器,分别对应以下两种情况:
- 如果 Redis 是 「主服务器」运行模式的话,在载入 RDB 文件时,程序会对文件中保存的键进行检查,过期键「不会」被载入到数据库中。所以过期键不会对载入 RDB 文件的主服务器造成影响;
- 如果 Redis 是「从服务器」运行模式的话,在载入 RDB 文件时,不论键是否过期都会被载入到数据库中。但由于主从服务器在进行数据同步时,从服务器的数据会被清空。所以一般来说,过期键对载入 RDB 文件的从服务器也不会造成影响。
AOF 文件分为两个阶段,AOF 文件写入阶段和 AOF 重写阶段。
- AOF 文件写入阶段:当 Redis 以 AOF 模式持久化时,如果数据库某个过期键还没被删除,那么 AOF 文件会保留此过期键,当此过期键被删除后,Redis 会向 AOF 文件追加一条 DEL 命令来显式地删除该键值。
- AOF 重写阶段:执行 AOF 重写时,会对 Redis 中的键值对进行检查, 已过期的键不会被保存到重写后的AOF文件中,因此不会对 AOF 重写造成任何影响。
Redis主从模式中,对过期键会如何处理?
当 Redis 运行在主从模式下时,从库不会进行过期扫描,从库对过期的处理是被动的。也就是即使从库中的key 过期了,如果有客户端访问从库时,依然可以得到 key 对应的值,像未过期的键值对一样返回。
从库的过期键处理依靠主服务器控制,主库在 key 到期时,会在 AOF 文件里增加一条 del 指令,同步到所有的从库,从库通过执行这条 del 指令来删除过期的 key。
Redis内存满了,会发生什么?
在 Redis 的运行内存达到了某个阀值,就会触发内存淘汰机制,这个阀值就是我们设置的最大运行内存,此值在 Redis 的配置文件中可以找到,配置项为 maxmemory。
Redis内存淘汰策略有哪些?
Redis 内存淘汰策略共有八种,这八种策略大体分为「不进行数据淘汰」和「进行数据淘汰」两类策略。
1、不进行数据淘汰的策略
noeviction(Redis3.0之后,默认的内存淘汰策略) :它表示当运行内存超过最大设置内存时,不淘汰任何数据,而是不再提供服务,直接返回错误。
2、进行数据淘汰的策略
针对「进行数据淘汰」这一类策略。
- 在设置了过期时间的数据中进行淘汰:
- volatile-random:随机淘汰设置了过期时间的任意键值;
- volatile-ttl:优先淘汰更早过期的键值;
- volatile-lru:(Redis3.0 之前,默认的内存淘汰策略),淘汰所有设置了过期时间的键值对,最久未使用的键值;
- volatile-lfu:(Redis 4.0 后新增的内存淘汰策略),淘汰所有设置了过期时间的键值中,最少使用的键值。
- 在所有数据范围内进行淘汰:
- allkeys-random:随机淘汰任意键值;
- allkeys-lru:淘汰整个键值中最久未使用的键值;
- allkeys-lfu:(Redis 4.0 后新增的内存淘汰策略),淘汰整个键值中最少使用的键值。
LRU算法和LFU算法有什么区别?
什么是LRU算法?
LRU 全称为最近最少使用,会选择淘汰最近最少使用的数据。
Redis 并没有使用这样的方式实现 LRU算法,因为传统的 LRU 算法存在两个问题:
- 需要用链表管理所有的缓存数据,这会带来额外的空间开销;
- 当有数据被访问时,需要在链表上把该数据移动到头端,如果有大量数据被访问,就会带来很多链表移动操作,会很耗时,进而会降低 Redis 缓存性能。
Redis是如何实现LRU算法的?
Redis 实现的是一种近似LRU算法,目的是为了更好的节约内存,它的实现方式是在 Redis 的对象结构体中添加一个额外的字段,用于记录此数据的最后一次访问时间。
当 Redis 进行内存淘汰时,会使用随机采样的方式来淘汰数据,它是随机取 5 个值(此值可配置),然后淘汰最久没有使用的那个。
Redis 实现的LRU算法的优点:
- 不用为所有的数据维护一个大链表,节省了空间占用;
- 不用为每次数据访问时都移动链表项,提升了缓存的性能。
但是LRU算法有一个问题,无法解决缓存污染问题,比如应用一次读取了大量的数据,而这些数据只会被读取这一次,那么这些数据会留存在 Redis 缓存中很长一段时间,造成缓存污染。
因此,在 Redis 4.0 之后引入了 LFU 算法来解决这个问题。
什么是LFU算法?
LFU 是最近最不常用,FU 算法是根据数据访问次数来淘汰数据的,它的核心思想是“如果数据过去被访问多次,那么将来被访问的频率也更高。所以, LFU 算法会记录每个数据的访问次数。
Redis是如何实现LFU算法的?
LFU 算法相比于 LRU 算法的实现,多记录了「数据的访问频次」的信息。Redis 对象的结构如下:
typedef struct redisObject {...// 24 bits,用于记录对象的访问信息unsigned lru:24; ...
} robj;
Redis 对象头中的 lru 字段,在 LRU 算法下和 LFU 算法下使用方式并不相同。
- 在LRU算法中,Redis 对象头的 24 bits 的 lru 字段是用来记录 key 的访问时间戳,因此在 LRU 模式下,Redis可以根据对象头中的 lru 字段记录的值,来比较最后一次 key 的访问时间长,从而淘汰最久未被使用的 key。
- 在LFU算法中,Redis对象头的 24 bits 的 lru 字段被分成两段来存储,高 16bit 存储 ldt(Last Decrement Time),用来记录 key 的访问时间戳;低 8bit 存储 logc(Logistic Counter),用来记录 key 的访问频次。

Redis缓存设计
如何避免缓存雪崩、缓存击穿、缓存穿透?
如何避免缓存雪崩?
缓存雪崩:当大量缓存数据在同一时间过期(失效)时,如果此时有大量的用户请求,都无法在Redis中处理,于是全部请求都直接访问数据库,从而导致数据库的压力骤增,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃,这就是缓存雪崩。
对于缓存雪崩问题,我们可以采用两种方案解决。
- 将缓存失效时间随机打散:我们可以在原有的失效时间基础上增加一个随机值(比如1到10分钟)这样每个缓存的过期时间都不重复了,也就降低了缓存集体失效的概率。
- 设置缓存不过期:我们可以通过后台服务来更新缓存数据,从而避免因为缓存失效造成的缓存雪崩,也可以在一定程度上避免缓存并发问题。
如何避免缓存击穿?
我们的业务通常会有几个数据会被频繁地访问,比如秒杀活动,这类被频繁地访问的数据被称为热点数据。
如果缓存中的某个热点数据过期了,此时大量的请求访问了该热点数据,就无法从缓存中读取,直接访问数据库,数据库很容易就被高并发的请求冲垮,这就是缓存击穿的问题。

可以发现缓存击穿跟缓存雪崩很相似,你可以认为缓存击穿是缓存雪崩的一个子集。应对缓存击穿可以采取前面说到两种方案:
- 互斥锁方案(Redis中使用setNX方法设置一个状态位,表示这是一种锁定状态),保证同一时间只有一个业务线程请求缓存,未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。
- 不给热点数据设置过期时间,由后台异步更新缓存,或者在热点数据准备要过期前,提前通知后台线程更新缓存以及重新设置过期时间。
如何避免缓存穿透?
当发生缓存雪崩或击穿时,数据库中还是保存了应用要访问的数据,一旦缓存恢复相对应的数据,就可以减轻数据库的压力,而缓存穿透就不一样了。
缓存穿透:当用户访问的数据,既不在缓存中,也不在数据库中,导致请求在访问缓存时,发现缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据,没办法构建缓存数据,来服务后续的请求。那么当有大量这样的请求到来时,数据库的压力骤增,这就是缓存穿透的问题。

缓存穿透的发生一般有两种情况:
- 业务误操作,缓存中的数据和数据库中的数据都被误删除了,所以导致缓存和数据库中都没有数据;
- 黑客恶意攻击,故意大量访问某些读取不存在数据的业务。
应对缓存穿透的方案,常见的方案有三种:
- 非法请求的限制:当有大量恶意请求访问不存在的数据的时候,也会发生缓存穿透,因此在 API 入口处我们要判断求请求参数是否合理,请求参数是否含有非法值、请求字段是否存在,如果判断出是恶意请求就直接返回错误,避免进一步访问缓存和数据库。
- 设置空值或者默认值:当我们线上业务发现缓存穿透的现象时,可以针对查询的数据,在缓存中设置一个空值或者默认值,这样后续请求就可以从缓存中读取到空值或者默认值,返回给应用,而不会继续查询数据库。
- 使用布隆过滤器快速判断数据是否存在,避免通过查询数据库来判断数据是否存在:我们可以在写入数据库数据时,使用布隆过滤器做个标记,然后在用户请求到来时,业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在,就不用通过查询数据库来判断数据是否存在,即使发生了缓存穿透,大量请求只会查询 Redis 和布隆过滤器,而不会查询数据库,保证了数据库能正常运行,Redis 自身也是支持布隆过滤器的。
如何设计一个缓存策略,可以动态缓存热点数据呢?
由于数据存储受限,系统并不是将所有数据都需要存放到缓存中的,而只是将其中一部分热点数据缓存起来,所以我们要设计一个热点数据动态缓存的策略。
热点数据动态缓存的策略总体思路:通过数据最新访问时间来做排名,并过滤掉不常访问的数据,只留下经常访问的数据。
以电商平台场景中的例子,现在要求只缓存用户经常访问的Top 1000的商品。具体细节如下:
- 先通过缓存系统做一个排名队列(比如存放1000个商品),系统会根据商品的访问时间,更新队列信息,越是最近访问的商品排名越靠前;
- 同时系统会定期过滤掉队列中排名最后的200个商品,然后再从数据中随机读取出200个商品加到队列中;
- 这样当请求每次到达的时候,会先从队列中获取商品ID,如果命中,就根据ID再从另一个缓存数据结构中读取实际的商品信息,并返回。
在 Redis 中可以用zadd方法和zrange方法来完成排序队列和获取200个商品的操作。
说说常见的缓存更新策略?
常见的缓存更新策略共有3种:
- Cache Aside(旁路缓存)策略;
- Read/Write Through (读穿/写穿)策略;
- Write Back(写回)策略。
实际开发中,Redis 和 MySQL 的更新策略用的是 Cache Aside,另外两种策略应用不了。
Cache Aside(旁路缓存)策略
Cache Aside(旁路缓存)策略师最常用的,应用程序直接与「数据库、缓存」交互,并负责对缓存的维护,该策略又可以细分为「读策略」和「写策略」。

写策略的步骤:先更新数据库中的数据,再删除缓存中的数据。
读策略的步骤:
- 如果读取的数据命中了缓存,则直接返回数据;
- 如果读取的数据没有命中缓存,则从数据库中读取数据,然后将数据写入到缓存,并且返回到用户。
注意,写策略的步骤的顺序不能倒过来,即不能先删除换成再更新数据库,原因是在「读+写」并发的时候,会出现缓存和数据库的数据不一致性的问题。
为什么「先更新数据库再删除缓存」不会有数据不一致的问题?
因为缓存的写入通常要远远快于数据库的写入。
Cache Aside策略适合读多写少的场景,不适合写多的场景,因为当写入比较频繁时,缓存中的数据会被频繁地清理,这样会对缓存的命中率有一些影响。如果业务堆缓存命中率有严格的要求,那么可以考虑两种解决方案:
- 在更新数据时也更新缓存,只是在更新缓存前先加一个分布式锁,因为这样在同一时间只允许一个线程更新缓存,就不会产生并发问题了。当然这么做对于写入的性能会有一些影响;
- 也是在更新数据时更新缓存,只是给缓存加一个较短过期时间,这样即使新出现缓存不一致的情况,缓存的数据也会很快过期,对业务的影响也是可以接受。
Read/Write Through(读穿/写穿)策略
Read/Write Through(读穿 / 写穿)策略原则是应用程序只和缓存交互,不再和数据库交互,而是由缓存和数据库交互,相当于更新数据库的操作由缓存自己代理了。
1. Read Through 策略
先查询缓存中数据是否存在,如果存在则直接返回,如果不存在,则由缓存组件负责从数据库查询数据,并将结果写入到缓存组件,最后缓存组件将数据返回给应用。
2. Write Through 策略
- 如果缓存中数据已经存在,则更新缓存中的数据,并且由缓存组件同步更新到数据库中,然后缓存组件告知应用程序更新完成。
- 如果缓存中数据不存在,直接更新数据库,然后返回。
Read Through/Write Through 策略的特点是由缓存节点而非应用程序来和数据库打交道,在我们开发过程中相比Cache Aside策略要少见一些,原因是我们经常使用的分布式缓存组件,无论是Memcached 还是Redis 都不提供写入数据库和自动加载数据库中的数据的功能。而我们在使用本地缓存的时候可以考虑使用这种策略。
Write Back(写回)策略
Write Back(写回)策略在更新数据的时候,只更新缓存,同时将缓存数据设置为脏的,然后立马返回,并不会更新数据库。对于数据库的更新,会通过批量异步更新的方式进行。
实际上,Write Back(写回)策略也不能应用到我们常见的数据库和缓存的场景中,因为Redis并没有异步更新数据库的功能。
Write Back 是计算机体系结构中的设计,比如 CPU 的缓存、操作系统中文件系统的缓存都采用了 Write Back(写回)策略。
Write Back策略特别适合写多的场景,因为发生写操作的时候,只需要更新缓存,就立马返回了。比如,写文件的时候,实际上写入到文件系统的缓存就返回了,并不会写磁盘。
但是带来的问题是,数据不是强一致性的,而且会有数据丢失的风险,因为缓存一般使用内存,而内存是非持久化的,所以一旦缓存机器掉电,就会造成原本缓存中的脏数据丢失。所以你会发现系统在掉电之后,之前写入的文件会有部分丢失,就是因为 Page Cache 还没有来得及刷盘造成的。
如何保证缓存和数据库数据的一致性?
如果我们的业务对缓存命中率有很高的要求,我们可以采用「更新数据库 + 更新缓存」的方案,因为更新缓存并不会回西安缓存未命中的情况。
但是这个方案在两个更新请求并发执行的时候,会出现数据不一致的问题,因为更新数据库和更新缓存这两个操作是独立的,而我们又没有对操作做任何并发控制,那么当两个线程并发更新它们的话,就会因为写入顺序的不同造成数据的不一致。
所以我们得增加一些手段来解决这个问题,这里提供两种做法:
- 在更新缓存前先加个分布式锁,保证同一时间只运行一个请求更新缓存,就不会产生并发问题了,当然引入了锁后,对于写入的性能就会带来影响。
- 在更新完缓存时,给缓存加上较短的过期时间,这样即使出现缓存不一致的情况,缓存的数据也会很快过期,对业务还是能接受的。
Redis实战
Redis如何实现延迟队列?
延迟队列是指把当前要做的事情,往后推迟一段时间再做。延迟队列的常见使用场景有以下几种:
- 在淘宝、京东等购物平台上下单,超过一定时间未付款,订单会自动取消;
- 打车的时候,在规定时间没有车主接单,平台会取消你的单并提醒你暂时没有车主接单;
- 点外卖的时候,如果商家在10分钟还没接单,就会自动取消订单。
在Redis可以使用有序集合(ZSet)的方式来实现延迟消息队列的,ZSet有一个Score属性可以用来存储延迟执行的时间。
使用zadd score1 value1命令就可以一直往内存中生成消息。在利用zrange by score查询符合条件的所有待处理的任务,通过循环执行队列任务即可。

Redis 的大key 如何处理?
什么是Redis大key?
大 key 并不是指 key 的值很大,而是 key 对应的 value 很大。
一般而言,下面这两种情况被称为大 key:
- String 类型的值大于10KB;
- Hash、List、Set、ZSet类型的元素的个数超过5000个。
大key会造成什么问题?
大key会带来以下四种影响:
- 客户端超时阻塞:由于Redis执行命令是单线程处理,然后在操作大key时会比较耗时,那么就会阻塞Redis,从客户端这一视角看,就是很久很久都没有响应。
- 引发网络阻塞:每次获取大key产生的网络流量较大,如果一个key的大小是1MB,每秒访问量为1000,那么每秒会产生1000MB的流量,这对于普通千兆网卡的服务器来说是灾难性的。
- 阻塞工作线程:如果使用del删除大key时,会阻塞工作线程,这这样就没办法处理后续的命令。
- 内存分布不均:集群模型在slot分片均匀情况下,会出现数据和查询倾斜情况,部分有大key的Redis节点暂用内存多,QPS也会比较大。
QPS(Queries Per Second,每秒查询率)是指系统每秒能够处理的查询请求数量,用于衡量服务器在单位时间内处理请求的能力。
如何找到大key?
1、redis-cli --bigkys查询大key
可以通过redis-cli --bigkeys 命令查找大key:
redis-cli -h 127.0.0.1 -p6379 -a "password" -- bigkeys
使用的时候注意事项:
- 最好选择在从节点上执行该命令。因为主节点上执行时,会阻塞主节点;
- 如果没有从节点,那么可以选择在Redis实例业务压力的低峰阶段进行扫描查询,以免影响都爱实例的正常运行;或者可以使用-i参数控制扫描间隔,避免长时间扫描降低Redis实例的性能。
该方法的不足之处:
- 这个方法只能返回每种类型中最大的那个big key,无法得到大小排在前N位的big key;
- 对于集合类型来说,这个方法只统计集合元素个数的多少,而不是实际占用的内存量。但是,一个集合中的元素个数多,并不一定占用的内存就多。因为,有可能每个元素占用的内存很小,这样的话,即使元素个数有很多,总内存开销也不大。
2、使用SCAN命令查询大key
使用SCAN命令对数据库扫描,然后用TYPE命令获取返回的每一个key的类型。
对于String类型,可以直接使用STRLEN命令获取字符串的长度,也就是占用的内存空间字节数。
对于集合类型来说,有两种方法可以获得它占用的内存大小:
- 如果能够预先从业务层知道集合元素的平均大小,那么,可以使用下面的命令获取集合元素的个数,然后乘以集合元素的平均大小,这样就能获得集合占用的内存大小了。List类型:
LLEN命令;Hash类型:HLEN命令;Set 类型:SCARD命令;Sorted Set 类型:ZCARD命令; - 如果不能提前知道写入集合元素大小,可以使用
MEMORY USAGE命令(需要Redis 4.0及以上版本),查询一个键值对占用的内存空间。
3、使用RdbTools工具查找大key
使用RdbTools第三方开源工具,可以用来解析Redis快照(RDB)文件,找到其中的大key。
比如,下面这条命令,将大于 10 kb 的 key 输出到一个表格文件。
rdb dump.rdb -c memory --bytes 10240 -f redis.csv
如何删除大key?
删除操作的本质是要释放键值对占用的内存空间,不要小瞧内存的释放过程。
释放内存只是第一步,为了更加高效地管理内存空间,在应用程序是否内存时,操作系统需要把释放掉的内存块插入一个空闲内存块的链表,以便后续进行管理和再分配。这个过程本身需要一定时间,而且会阻塞当前释放内存的应用程序。
所以,如果一下子释放了大量内存,空闲内存块链表操作时间就会增加,相应地就会造成 Redis 主线程的阻塞,如果主线程发生了阻塞,其他所有请求可能都会超时,超时越来越多,会造成 Redis 连接耗尽,产生各种异常。
因此,删除大key这一个动作,我们要小心,具体要怎么做呢?这里给出两种方法:
- 分批次删除
- 异步删除(Redis 4.0版本以上)
1、分批次删除
- 对于删除大Hash,使用
hscan命令,每次获取100个字段,再用hdel命令,每次删除1个字段。 - 对于删除大List,通过
ltrim命令,每次删除少量元素。 - 对于删除大Set,使用
sscan命令,每次扫描集合中 100 个元素,再用srem命令每次删除一个键。 - 对于删除大ZSet,使用
zremrangbyrank命令,每次删除 top 100个元素。
2、异步删除
从 Redis 4.0 版本开始,可以采用异步删除法,用unlink命令代替del来删除。
这样 Redis 会将这个 key 放入到一个异步线程中进行删除,这样不会阻塞主线程。
除了主动调用 unlink 命令实现异步删除之外,我们还可以通过配置参数,达到某些条件的时候自动进行异步删除。
主要有4种场景,默认都是关闭的:
lazyfree-lazy-eviction no
lazyfree-lazy-expire no
lazyfree-lazy-server-del
noslave-lazy-flush no
它们代表的含义如下:
- lazyfree-lazy-eviction:表示当 Redis 运行内存超过 maxmeory 时,是否开启 lazy free 机制删除;
- lazyfree-lazy-expire:表示设置了过期时间的键值,当过期之后是否开启 lazy free 机制删除;
- lazyfree-lazy-server-del:有些指令在处理已存在的键时,会带有一个隐式的 del 键的操作,比如 rename 命令,当目标键已存在,Redis 会先删除目标键,如果这些目标键是一个 big key,就会造成阻塞删除的问题,此配置表示在这种场景中是否开启 lazy free 机制删除;
- slave-lazy-flush:针对 slave (从节点) 进行全量数据同步,slave 在加载 master 的 RDB 文件前,会运行 flushall 来清理自己的数据,它表示此时是否开启 lazy free机制删除。
建议开启其中的的 lazyfree-lazy-eviction、lazyfree-lazy-expire、lazyfree-lazy-server-del 等配置,这样就可以有效的提高主线程的执行效率。
Redis管道有什么用?
管道技术(Pipeline)是客户端提供的一种批处理技术,用于一次处理多个 Redis 命令,从而提高整个交互的性能。

使用管道技术可以解决多个命令执行时的网络等待,它是把多个命令整合到一起发送给服务器端处理之后统一返回给客户端,这样就免去了每条命令执行后都要等待的情况,从而有效地提高了程序的执行效率。
但使用管道技术也要注意避免发送的命令过大,或管道内的数据太多而导致的网络阻塞。
要注意的是,管道技术本质上是客户端提供的功能,而非 Redis 服务器端的功能。
Redis事务支持回滚吗?
MySQL 在执行事务时,会提供回滚机制,当事务执行发生错误时,事务中的所有操作都会撤销,已经修改的数据也会被恢复到事务执行前的状态。
Redis 中并没有提供回滚机制,虽然 Redis 提供了 DISCARD 命令,但是这个命令只能用来主动放弃事务执行,把暂存的命令队列清空,起不到回滚的效果。
事务执行过程中,如果命令入队时没报错,而事务提交后,实际执行时报错了,正确的命令依然可以正常执行,所以这可以看出Redis 并不一定保证原子性。
为什么Redis不支持事务回滚?
官方认为不支持事务回滚的原因有以下两个:
- Redis 事务的执行时,错误通常都是编程错误造成的,这种错误通常只会出现在开发环境中,而很少会在实际的生产环境中出现,所以认为没有必要为 Redis 开发事务回滚功能;
- 不支持事务回滚是因为这种复杂的功能和 Redis 追求的简单高效的设计主旨不符合。
这里不支持事务回滚,指的是不支持事务运行时错误的事务回滚。
如何用Redis实现分布式锁的?
分布式锁是用于分布式环境下并发控制的一种机制,用于控制某个资源在同一时刻只能被一个应用所使用。如下图所示:

Redis 本身可以被多个客户端共享访问,正好就是一个共享存储系统,可以用来保存分布式锁,而且 Redis 的读写性能高,可以应对高并发的锁操作场景。
Redis 的 SET 命令有个 NX 参数可以实现「key不存在才插入」,所以可以用它来实现分布式锁:
- 如果 key 不存在,则显示插入成功,可以用来表示加锁成功;
- 如果 key 存在,则会显示插入失败,可以用来表示加锁失败。
基于 Redis 节点实现分布式锁时,对于加锁操作,我们需要满足三个条件:
- 加锁包括了读取锁变量、检查锁变量值和设置锁变量值三个操作,但需要以原子操作的方式完成,所以,我们使用SET命令带上NX选项来实现加锁;
- 锁变量需要设置过期时间,以免客户端拿到锁后发生异常,导致锁一直无法释放,所以,我们在SET命令执行时加上EX/PX选项,设置其过期时间;
- 锁变量的值需要能区分来自不同客户端的加锁操作,以免在释放锁时,出现误释放操作,所以,我们使用 SET 命令设置锁变量值时,每个客户端设置的值是一个唯一值,用于标识客户端。
满足这三个条件的分布式命令如下:
SET lock_key unique_value NX PX 10000
- lock_key 就是 key 键;
- unique_value 是客户端生成的唯一的标识,区分来自不同客户端的锁操作;
- NX 代表只在 lock_key 不存在时,才对 lock_key 进行设置操作;
- PX 10000 表示设置 lock_key 的过期时间为 10s,这是为了避免客户端发生异常而无法释放锁。
而解锁的过程就是将 lock_key 键删除(del lock_key),但不能乱删,要保证执行操作的客户端就是加锁的客户端。所以,解锁的时候,我们要先判断锁的 unique_value 是否为加锁客户端,是的话,才将 lock_key 键删除。
可以看到,解锁是有两个操作,这时就需要Lua 脚本来保证解锁的原子性,因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,保证了锁释放操作的原子性。
这样一来,就通过使用 SET 命令和 Lua 脚本在 Redis 单节点上完成了分布式锁的加锁和解锁。
基于Redis实现分布式锁有什么优缺点?
基于 Redis 实现分布式锁的优点:
- 性能高效(这是选择缓存实现分布式锁最核心的出发点);
- 实现方便。很多研发工程师选择使用 Redis 来实现分布式锁,很大成分上是因为 Redis 提供了 setnx 方法,实现分布式锁很方便。
- 避免单点故障(因为 Redis 是跨集群部署的,自然就避免了单点故障)。
基于 Redis 实现分布式锁的缺点:
-
超时时间不好设置。
如果锁的超时时间设置过长,会影响性能,如果设置的超时时间过短会保护不到共享资源。比如在有些场景中,一个线程 A 获取到了锁之后,由于业务代码执行时间可能比较长,导致超过了锁的超时时间,自动失效,注意 A 线程没执行完,后续线程 B 又意外的持有了锁,意味着可以操作共享资源,那么两个线程之间的共享资源就没办法进行保护了。
- 那么如何合理设置超时时间呢?我们可以基于
续约的方式设置超时时间:先给锁设置一个超时时间,然后启动一个守护线程,让守护线程在一段时间后,重新设置这个锁的超时时间。实现方式就是:写一个守护线程,然后去判断锁的情况,当锁快失效的时候,再次进行续约加锁,当主线程执行完成后,销毁续约锁即可,不过这种方式实现起来相对复杂。
- 那么如何合理设置超时时间呢?我们可以基于
-
Redis 主从复制模式中的数据是异步复制的,这样导致分布式锁的不可靠性。如果在 Redis 主节点获取到锁后,在没有同步到其他节点时,Redis 主节点宕机了,此时新的 Redis 主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁。
Redis如何解决集群情况下分布式锁的可靠性?
为了保证集群环境下分布式锁的可靠性,Redis 官方已经设计了一个分布式锁算法 Redlock(红锁)。
它是基于多个 Redis 节点的分布式锁,即使有节点发生了故障,锁变量仍然是存在的,客户端还是可以完成锁操作。官方推荐是至少部署 5 个 Redis 节点,而且都是主节点,它们之间没有任何关系,都是一个个孤立的节点。
Redlock算法的基本思路,是让客户端和多个独立的 Redis 节点依次请求申请加锁,如果客户端能够和半数以上的节点成功地完成加锁操作,那么我们就认为,客户端成功地获得分布式锁,否则加锁失败。
这样一来,即使有某个 Redis 节点发生故障,因为锁的数据在其他节点上也有保存,所以客户端仍然可以正常地进行锁操作,锁的数据也不会丢失。
Redlock 算法加锁三个过程:
- 第一步,客户端获取当前时间(t1);
- 第二步,客户端按顺序依次向 N 个 Redis 节点执行加锁操作:
- 加锁操作使用 SET 命令,带上 NX,EX/PX 选项,以及带上客户端的唯一标识。
- 如果某个 Redis 节点发生故障了,为了保证在这种情况下,Redlock 算法能够继续运行,我们需要给「加锁操作」设置一个超时时间(不是对「锁」设置超时时间,而是对「加锁操作」设置超时时间),加锁操作的超时时间需要远远地小于锁的过期时间,一般也就是设置为几十毫秒。
- 第三步,一旦客户端从超过半数(大于等于 N/2+1)的 Redis 节点上成功获取到了锁,就再获取当前时间(t2),然后计算计算整个加锁过程的总耗时(t2-t1)。如果 t2-t1 < 锁的过期时间,此时,认为客户端加锁成功,否则认为锁失败。
可以看到,加锁成功要同时满足两个条件:如果有超过半数的 Redis 节点成功的获取到了锁,并且总耗时没有超过锁的有效时间,那么就是加锁成功。
加锁成功后,客户端需要重新计算这把锁的有效时间,计算的结果是「锁最初设置的过期时间」减去「客户端从大多数节点获取锁的总耗时(t2-t1)」。如果计算的结果已经来不及完成共享数据的操作了,我们可以释放锁,以免出现还没完成数据操作,锁就过期了的情况。
加锁失败后,客户端向所有 Redis 节点发起释放锁的操作,释放锁的操作和在单节点上释放锁的操作一样,只要执行释放锁的 Lua 脚本就可以了。
