Redis万字总结
# 1. RESP协议
Redis客户端和服务器端采用RESP
(Redis Serialization Protocal)进行通信,RESP协议有以下特点:
- 二进制安全;
- 以行为单位,CRLF作为换行符;
- 第一个字节作为数据格式标记;
二进制安全是指允许协议中出现任意字符而不会导致故障。比如 C 语言的字符串以
\0
作为结尾不允许字符串中间出现\0
, 而 Go语言的string则允许出现\0
,我们说Go语言的string
是二进制安全的,而C
语言字符串不是二进制安全的。
其数据格式如下有以下5种:
- 简单字符串(单行字符串,Simple String), 格式:
+字符串\r\n
+OK\r\n
- 错误信息(Errors), 格式:
-字符串\r\n
-WRONGTYPE Operation against a key holding the wrong kind of value\r\n
- 整数(Integer), 格式:
:整数\r\n
:1\r\n
- 字符串(多行字符串, Bluck String), 格式:
$字符串长度\r\n字符串\r\n
$3\r\nfoo\r\n
- 数组(字符串数组,Array), 格式:
*数组长度\r\nBluckString1\r\nBluckString2\r\n
*3\r\n$3\r\nfo1\r\n$3\r\nfo2\r\n$3\r\nfo3\r\n
# 2. Redis数据结构
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,支持以消费组形式消费数据。
String
类型的底层的数据结构实现主要是int
和SDS(简单动态字符串)
。List
类型的底层数据结构是由双向链表或压缩列表实现的,但是在 Redis 3.2 版本之后,List数据类型底层数据结构就只由quicklist
实现了,替代了双向链表和压缩列表。Zset
类型的底层数据结构是由压缩列表或跳表实现的。
# 2.1 Hash冲突
Redis通过链式哈希
解决冲突:也就是同一个桶里面的元素使用链表保存。但是当链表过长就会导致查找性能变差可能,所以 Redis为了追求快,使用了两个全局哈希表, 用于rehash
操作。
rehash
也就是增加现有的哈希桶数量,让逐渐增多的entry
元素能在更多的桶之间分散保存,减少单个桶中的元素数量,从而减少单个桶中的冲突。
开始默认使用 **「hash 表 1 」保存键值对数据,「hash 表 2」 **此刻没有分配空间。当数据越来越多触发rehash
操作,则执行以下操作:
- 给 「hash 表 2 」分配更大的空间;
- 将 「hash 表 1 」的数据重新映射拷贝到 「hash 表 2」 中;
- 释放 「hash 表 1」 的空间。
这个过程看似简单,但是第二步涉及大量的数据拷贝,如果一次性把哈希表1中的数据都迁移完,会造成Redis 线程阻塞,无法服务其他请求。此时,Redis就无法快速访问数据了。
为了避免这个问题,Redis采用了渐进式rehash。简单来说就是在第二步拷贝数据时,Redis仍然正常处理客户端请求,每处理一个请求时,从哈希表1中的第一个索引位置开始,顺带着将这个索引位置上的所有 entries拷贝到哈希表1中;等处理下一个请求时,再顺带拷贝哈希表1中的下一个索引位置的 entries。
这样就巧妙地把一次性大量拷贝的开销,分摊到了多次处理请求的过程中,避免了耗时操作,保证了数据的快速访问。
# 3. 持久化
官方文档:https://redis.io/docs/manual/persistence/
持久性是指将数据写入持久存储,例如固态磁盘 (SSD)。Redis 本身提供了一系列持久化选项:
- RDB(Redis DataBase):RDB 持久性以指定的时间间隔执行(例如:
save 900 1
)数据集的时间点快照。RDB默认开启。
################################ SNAPSHOTTING ################################
#
# Save the DB on disk:
#
# save <seconds> <changes>
#
# Will save the DB if both the given number of seconds and the given
# number of write operations against the DB occurred.
#
# In the example below the behaviour will be to save:
# after 900 sec (15 min) if at least 1 key changed
# after 300 sec (5 min) if at least 10 keys changed
# after 60 sec if at least 10000 keys changed
#
# Note: you can disable saving completely by commenting out all "save" lines.
#
# It is also possible to remove all the previously configured save
# points by adding a save directive with a single empty string argument
# like in the following example:
#
# save ""
save 900 1
save 300 10
save 60 10000
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
- AOF(Append Only File):AOF持久化记录服务器接收到的每个写操作,在服务器启动时再次播放,重建原始数据集。命令使用与 Redis 协议本身相同的格式以仅附加方式记录。当日志变得太大时,Redis 能够在后台重写日志。AOF默认关闭,通过将
redis.conf
中将appendonly no
,修改为appendonly yes
来开启AOF持久化功能。
# Please check http://redis.io/topics/persistence for more information.
appendonly no
# The name of the append only file (default: "appendonly.aof")
appendfilename "appendonly.aof"
2
3
4
- 无持久性:如果您愿意,您可以完全禁用持久性,如果您希望您的数据只要服务器正在运行就存在。
- RDB + AOF:可以在同一个实例中结合 AOF 和 RDB。请注意,在这种情况下,当 Redis 重新启动时,AOF 文件将用于重建原始数据集,因为它保证是最完整的。
从Redis 7.0.0开始,Redis使用了多部分AOF机制。也就是将原来的单个AOF文件拆分为基础文件(最多一个)和增量文件(可能不止一个)。基本文件表示重写AOF时存在的数据的初始(RDB或AOF格式)快照。增量文件包含自创建最后一个基本AOF文件以来的增量更改。所有这些文件都放在一个单独的目录中,并由清单文件跟踪。
# 3.1 AOF写回策略(刷盘策略)
- Redis执行完写操作命令后,会将命令追加到
server.aof_buf
缓冲区; - 然后通过
write()
系统调用,将aof_buf
缓冲区的数据写入到AOF文件,此时数据并没有写入到硬盘,而是拷贝到了内核缓冲区page cache
,等待内核将数据写入硬盘; - 具体内核缓冲区的数据什么时候写入到硬盘,由内核决定。
127.0.0.1:6379> config get appendfsync
1) "appendfsync"
2) "everysec"
2
3
Redis提供了3种写回硬盘的策略,控制的就是上面说的第三步的过程。
在redis.conf
配置文件中的appendfsync
配置项可以有以下 3 种参数可填:
- Always,这个单词的意思是「总是」,所以它的意思是每次写操作命令执行完后,同步将 AOF 日志数据写回硬盘;
- Everysec(默认),这个单词的意思是「每秒」,所以它的意思是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后每隔一秒将缓冲区里的内容写回到硬盘;
- No,意味着不由 Redis 控制写回硬盘的时机,转交给操作系统控制写回的时机,也就是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘。
3个写回策略的优缺点总结成了一张表格:
# 3.1.1 写回策略实现
这三种策略只是在控制fsync()
函数的调用时机。
当应用程序向文件写入数据时,内核通常先将数据复制到内核缓冲区中,然后排入队列,然后由内核决定何时写入硬盘。
如果想要应用程序向文件写入数据后,能立马将数据同步到硬盘,就可以调用fsync()
函数,这样内核就会将内核缓冲区的数据直接写入到硬盘,等到硬盘写操作完成后,该函数才会返回。
- Always策略就是每次写入AOF文件数据后,就执行
fsync()
函数; - Everysec策略就会创建一个异步任务来执行
fsync()
函数; - No策略就是永不执行
fsync()
函数
# 3.2 AOF重写机制
Redis为了避免AOF文件越写越大,提供了AOF重写机制,当AOF文件的大小超过所设定的阈值后,Redis 就会启用AOF重写机制,来压缩AOF文件。
AOF重写机制是在重写时,读取当前数据库中的所有键值对,然后将每一个键值对用一条命令记录到「新的 AOF 文件」,等到全部记录完后,就将新的AOF文件替换掉现有的AOF文件。
重写机制的妙处在于,尽管某个键值对被多条写命令反复修改,最终也只需要根据这个「键值对」当前的最新状态,然后用一条命令去记录键值对,代替之前记录这个键值对的多条命令,这样就减少了AOF文件中的命令数量。最后在重写工作完成后,将新的AOF文件覆盖现有的AOF文件。
# 3.3 AOF后台重写
写入AOF日志的操作虽然是在主进程完成的,因为它写入的内容不多,所以一般不太影响命令的操作。
所以,Redis的重写AOF过程是由后台子进程bgrewriteaof
来完成的,这么做可以达到两个好处:
- 子进程进行AOF重写期间,主进程可以继续处理命令请求,从而避免阻塞主进程;
- 子进程带有主进程的数据副本(数据副本怎么产生的后面会说),这里使用子进程而不是线程,因为如果是使用线程,多线程之间会共享内存,那么在修改共享内存数据的时候,需要通过加锁来保证数据的安全,而这样就会降低性能。而使用子进程,创建子进程时,父子进程是共享内存数据的,不过这个共享的内存只能以只读的方式,而当父子进程任意一方修改了该共享内存,就会发生「写时复制」,于是父子进程就有了独立的数据副本,就不用加锁来保证数据安全。
这样一来,子进程就共享了父进程的物理内存数据了,这样能够节约物理内存资源,页表对应的页表项的属性会标记该物理内存的权限为只读。
不过,当父进程或者子进程在向这个内存发起写操作时,CPU就会触发缺页中断,这个缺页中断是由于违反权限导致的,然后操作系统会在「缺页异常处理函数」里进行物理内存的复制,并重新设置其内存映射关系,将父子进程的内存读写权限设置为可读写,最后才会对内存进行写操作,这个过程被称为「写时复制(Copy On Write)」
。
写时复制
顾名思义,在发生写操作的时候,操作系统才会去复制物理内存,这样是为了防止fork
创建子进程时,由于物理内存数据的复制时间过长而导致父进程长时间阻塞的问题。
当然,操作系统复制父进程页表的时候,父进程也是阻塞中的,不过页表的大小相比实际的物理内存小很多,所以通常复制页表的过程是比较快的。
不过,如果父进程的内存数据非常大,那自然页表也会很大,这时父进程在通过fork
创建子进程的时候,阻塞的时间也越久。
所以,有两个阶段会导致阻塞父进程:
- 创建子进程的途中,由于要复制父进程的页表等数据结构,阻塞的时间跟页表的大小有关,页表越大,阻塞的时间也越长;
- 创建完子进程后,如果子进程或者父进程修改了共享数据,就会发生写时复制,这期间会拷贝物理内存,如果内存越大,自然阻塞的时间也越长;
在重写AOF期间,当Redis执行完一个写命令之后,它会同时将这个写命令写入到 **「AOF 缓冲区」**和 「AOF 重写缓冲区」。
也就是说,在bgrewriteaof
子进程执行AOF重写期间,主进程需要执行以下三个工作:
- 执行客户端发来的命令;
- 将执行后的写命令追加到 「AOF 缓冲区」;
- 将执行后的写命令追加到 「AOF 重写缓冲区」;
当子进程完成AOF重写工作(扫描数据库中所有数据,逐一把内存数据的键值对转换成一条命令,再将命令记录到重写日志)后,会向主进程发送一条信号,信号是进程间通讯的一种方式,且是异步的。
主进程收到该信号后,会调用一个信号处理函数,该函数主要做以下工作:
- 将AOF重写缓冲区中的所有内容追加到新的AOF的文件中,使得新旧两个AOF文件所保存的数据库状态一致;
- 新的AOF的文件进行改名,覆盖现有的AOF文件。
信号函数执行完后,主进程就可以继续像往常一样处理命令了。 在整个AOF后台重写过程中,除了发生写时复制会对主进程造成阻塞,还有信号处理函数执行时也会对主进程造成阻塞,在其他时候,AOF后台重写都不会阻塞主进程。
# 3.4 AOF 优势
- 使用AOF Redis更加持久:您可以有不同的
fsync
策略(刷盘策略):根本不fsync
、每秒fsync
、每次查询时fsync
。使用每秒fsync的默认策略,写入性能仍然很棒。fsync
是使用后台线程执行的,当没有fsync
正在进行时,主线程将努力执行写入,因此最多丢失一秒钟的写入。 - AOF日志是一个仅附加日志,因此不会出现寻道问题,也不会在断电时出现损坏问题。即使由于某种原因(磁盘已满或其他原因)日志以写一半的命令结束,
redis-check-aof
工具也能够轻松修复它。 - 当AOF变得太大时,Redis 能够在后台自动重写AOF。重写是完全安全的,因为当Redis继续附加到旧文件时,会使用创建当前数据集所需的最少操作集生成一个全新的文件,一旦第二个文件准备就绪,Redis就会切换两者并开始附加到新的那一个。
- AOF以易于理解和解析的格式依次包含所有操作的日志。您甚至可以轻松导出AOF文件。例如,即使您不小心使用该
FLUSHALL
命令刷新了所有内容,只要在此期间没有执行日志重写,您仍然可以通过停止服务器、删除最新命令并重新启动Redis来保存您的数据集.
# 3.5 AOF 缺点
- AOF文件通常比相同数据集的等效RDB文件大。
- 根据确切的
fsync
策略,AOF可能比RDB慢。一般来说,将fsync设置为每秒的性能仍然非常高,并且在禁用 fsync的情况下,即使在高负载下它也应该与RDB一样快。即使在巨大的写入负载的情况下,RDB仍然能够提供关于最大延迟的更多保证。 - 如果在重写期间有对数据库的写入,AOF可能会使用大量内存(这些被缓冲在内存中并在最后写入新的 AOF)。
- 重写期间到达的所有写入命令都会写入磁盘两次。
- Redis 可以在重写结束时冻结写入并将这些写入命令同步到新的AOF文件。
# 3.6 RDB快照
Redis提供了两个命令来生成RDB文件,分别是save
和bgsave
,他们的区别就在于是否在「主线程」里执行:
- 执行了
save
命令,就会在主线程生成RDB文件,由于和执行操作命令在同一个线程,所以如果写入 RDB 文件的时间太长,会阻塞主线程; - 执行了
bgsave
命令,会创建一个子进程来生成RDB文件,这样可以避免主线程的阻塞;
# 3.7 RDB的缺点
- 如果您需要在Redis停止工作时(例如断电后)将数据丢失的可能性降到最低,那么RDB并不好。您可以配置生成RDB的不同保存点(例如,在对数据集至少 5 分钟和 100 次写入之后,您可以有多个保存点)。但是,您通常会每五分钟或更长时间创建一个RDB快照,因此,如果Redis由于任何原因在没有正确关闭的情况下停止工作,您应该准备好丢失最新分钟的数据。
- RDB需要经常
fork()
以便使用子进程在磁盘上持久化。如果数据集很大,fork()
可能会很耗时,并且如果数据集很大并且CPU性能不是很好,可能会导致Redis停止为客户端服务几毫秒甚至一秒钟。AOF也需要fork()
但频率较低,您可以调整要重写日志的频率,而不需要对持久性进行任何权衡。
# 3.8 混合持久化
混合使用AOF日志和内存快照,也叫混合持久化。
如果想要开启混合持久化功能,可以在Redis配置文件将下面这个配置项设置成yes
:
aof-use-rdb-preamble yes
混合持久化工作在AOF日志重写过程。 当开启了混合持久化时,在AOF重写日志时,fork出来的重写子进程会先将与主线程共享的内存数据以RDB方式写入到AOF文件,然后主线程处理的操作命令会被记录在重写缓冲区里,重写缓冲区里的增量命令会以AOF方式写入到AOF文件,写入完成后通知主进程将新的含有RDB格式和AOF格式的AOF文件替换旧的的AOF文件。 也就是说,使用了混合持久化,AOF文件的前半部分是RDB格式的全量数据,后半部分是AOF格式的增量数据。
# 这样的好处在于,重启Redis加载数据的时候,由于前半部分是RDB内容,这样加载的时候速度会很快。 加载完RDB的内容后,才会加载后半部分的AOF内容,这里的内容是 Redis后台子进程重写AOF期间,主线程处理的操作命令,可以使得数据更少的丢失。
# 4. 缓存穿透、缓存雪崩、缓存击穿
# 4.1 缓存穿透
缓存穿透指查询一个根本不存在的key, 缓存层和持久层都不会命中,请求都会压到数据库,而从压垮数据库。
解决方案:
- 对请求进行限流,避免进行缓存穿透攻击;
- 校验Key的范围/合法性;
- 缓存空值,设置较短过期时间;
- 采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的bitmap中,一个一定不存在的数据会被 这个bitmap拦截掉,从而避免了对底层存储系统的查询压力。(不建议使用,相对复杂)
# 4.2. 缓存雪崩
缓存雪崩指大量Key同时失效,高并发时大量请求打到了数据库,从而压垮数据库;
解决方案:
- 避免大量Key同时过期,设置不同的过期时间;
- 设置Key不过期
# 4.3. 缓存击穿
缓存击穿指某个热点Key失效,高并发时大量请求打到了数据库,从而压垮数据库; 解决方案:
- 利用互斥锁,保证只有一个请求(进程/线程)从数据库重新读取数据;
- 可以多级缓存;
- 热点Key不过期,后台操作/定时更新;
# 4.4. 总结
- 穿透是指一通到底,都没任何拦截,缓存层和持久层都未命中;
- 雪崩即大量的Key无法被缓存层命中(一般是同时过期),造成持久化层压力过高;
- 击穿是指击穿了缓存层,缓存层未命中,高并发多个请求读取数据时导致数据库压力过高;
# 5. 缓存预热和缓存降级
# 5.1 缓存预热
缓存预热是指系统上线后,提前将相关的缓存数据加载到缓存系统。避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题,用户直接查询事先被预热的缓存数据。
如果不进行预热,那么Redis初始状态数据为空,系统上线初期,对于高并发的流量,都会访问到数据库中, 对数据库造成流量的压力。
缓存预热解决方案:
- 数据量不大的时候,工程启动的时候进行加载缓存动作;
- 数据量大的时候,设置一个定时任务脚本,进行缓存的刷新;
- 数据量太大的时候,优先保证热点数据进行提前加载到缓存。
# 5.2 缓存降级
缓存降级是指缓存失效或缓存服务器挂掉的情况下,不去访问数据库,直接返回默认数据或访问服务的内存数据。降级一般是有损的操作,所以尽量减少降级对于业务的影响程度。
在进行降级之前要对系统进行梳理,看看系统是不是可以丢卒保帅;从而梳理出哪些必须誓死保护,哪些可降级;比如可以参考日志级别设置预案:
- 一般:比如有些服务偶尔因为网络抖动或者服务正在上线而超时,可以自动降级;
- 警告:有些服务在一段时间内成功率有波动(如在95~100%之间),可以自动降级或人工降级,并发送告警;
- 错误:比如可用率低于90%,或者数据库连接池被打爆了,或者访问量突然猛增到系统能承受的最大阈值,此时可以根据情况自动降级或者人工降级;
- 严重错误:比如因为特殊原因数据错误了,此时需要紧急人工降级。
# 6. 高可用
# 6.1 主从复制
主从复制是为了避免出现单点故障,将数据实时同步到其他服务器上, 是实现Redis高可用的基础。
主服务器可以进行读写操作,当发生写操作时自动将写操作同步给从服务器,而从服务器一般是只读,并接受主服务器同步过来写操作命令,然后执行这条命令。
也就是说,所有的数据修改只在主服务器上进行,然后将最新的数据同步给从服务器,这样就使得主从服务器的数据是一致的。
我们可以使用replicaof
(Redis 5.0 之前使用slaveof
)命令形成主服务器和从服务器的关系。
比如,现在有服务器A和服务器B,我们在服务器B上执行下面这条命令:
# 服务器 B 执行这条命令
replicaof <服务器A的IP地址> <服务器A的 Redis 端口号>
2
接着,服务器B就会变成服务器A的「从服务器」,然后与主服务器进行第一次同步。
主从服务器间的第一次同步的过程可分为三个阶段:
- 第一阶段是建立链接、协商同步;
- 第二阶段是主服务器同步数据给从服务器;
- 第三阶段是主服务器发送新写操作命令给从服务器。
# 6.1.1 第一阶段:建立链接、协商同步
执行了replicaof
命令后,从服务器就会给主服务器发送psync
命令,表示要进行数据同步。
psync
命令包含两个参数,分别是主服务器的runID
和复制进度offset
。
- runID,每个Redis服务器在启动时都会自动生产一个随机的 ID 来唯一标识自己。当从服务器和主服务器第一次同步时,因为不知道主服务器的
run ID
,所以将其设置为 "?"。 - offset,表示复制的进度,第一次同步时,其值为-1。
主服务器收到psync
命令后,会用FULLRESYNC
作为响应命令返回给对方。
并且这个响应命令会带上两个参数:主服务器的runID
和主服务器目前的复制进度offset
。从服务器收到响应后,会记录这两个值。
FULLRESYNC
响应命令的意图是采用全量复制的方式,也就是主服务器会把所有的数据都同步给从服务器。
# 6.1.2 第二阶段:主服务器同步数据给从服务器
接着,主服务器会执行bgsave
命令来生成RDB文件,然后把文件发送给从服务器。
从服务器收到 RDB 文件后,会先清空当前的数据,然后载入RDB文件。
这里有一点要注意,主服务器生成RDB这个过程是不会阻塞主线程的,因为bgsave
命令是产生了一个子进程来做生成 RDB 文件的工作,是异步工作的,这样 Redis 依然可以正常处理命令。
但是,这期间的写操作命令并没有记录到刚刚生成的RDB文件中,这时主从服务器间的数据就不一致了。
那么为了保证主从服务器的数据一致性,主服务器在下面这三个时间间隙中将收到的写操作命令,写入到 replication buffer
缓冲区里:
- 主服务器生成RDB文件期间;
- 主服务器发送RDB文件给从服务器期间;
- 「从服务器」加载RDB文件期间;
# 6.1.3 第三阶段:主服务器发送新写操作命令给从服务器
在主服务器生成的RDB文件发送完,从服务器收到RDB文件后,丢弃所有旧数据,将RDB数据载入到内存。完成 RDB的载入后,会回复一个确认消息给主服务器。
接着,主服务器将replication buffer
缓冲区里所记录的写操作命令发送给从服务器,从服务器执行来自主服务器replication buffer
缓冲区里发来的命令,这时主从服务器的数据就一致了。
至此,主从服务器的第一次同步的工作就完成了。
主从服务器在完成第一次同步后,双方之间就会维护一个TCP连接。
后续主服务器可以通过这个连接继续将写操作命令传播给从服务器,然后从服务器执行该命令,使得与主服务器的数据库状态相同。
# 6.1.4 主从断开重连后,怎么保证一致性
网络恢复后的增量复制过程如下图:
主要有三个步骤:
- 从服务器在恢复网络后,会发送
psync
命令给主服务器,此时的psync
命令里的offset
参数不是 -1; - 主服务器收到该命令后,然后用
CONTINUE
响应命令告诉从服务器接下来采用增量复制的方式同步数据; - 然后主服务将主从服务器断线期间,所执行的写命令发送给从服务器,然后从服务器执行这些命令。
那么关键的问题来了,主服务器怎么知道要将哪些增量数据发送给从服务器呢?
答案藏在这两个东西里:
repl_backlog_buffer
,是一个「环形」缓冲区,用于主从服务器断连后,从中找到差异的数据;replication offset
,标记上面那个缓冲区的同步进度,主从服务器都有各自的偏移量,主服务器使用master_repl_offset
来记录自己「写」到的位置,从服务器使用slave_repl_offset
来记录自己「读」到的位置。
那repl_backlog_buffer
缓冲区是什么时候写入的呢?
在主服务器进行命令传播时,不仅会将写命令发送给从服务器,还会将写命令写入到repl_backlog_buffer
缓冲区里,因此 这个缓冲区里会保存着最近传播的写命令。
网络断开后,当从服务器重新连上主服务器时,从服务器会通过psync
命令将自己的复制偏移量 slave_repl_offset
发送给主服务器,主服务器根据自己的master_repl_offset
和 slave_repl_offset
之间的差距,然后来决定对从服务器执行哪种同步操作:
- 如果判断出从服务器要读取的数据还在
repl_backlog_buffer
缓冲区里,那么主服务器将采用增量同步的方式; - 相反,如果判断出从服务器要读取的数据已经不存在
repl_backlog_buffer
缓冲区里,那么主服务器将采用全量同步的方式。
当主服务器在repl_backlog_buffer
中找到主从服务器差异(增量)的数据后,就会将增量的数据写入到 replication buffer
缓冲区,这个缓冲区我们前面也提到过,它是缓存将要传播给从服务器的命令。
repl_backlog_buffer
缓行缓冲区的默认大小是1M,并且由于它是一个环形缓冲区,所以当缓冲区写满后,主服务器继续写入的话,就会覆盖之前的数据。因此,当主服务器的写入速度远超于从服务器的读取速度,缓冲区的数据一下就会被覆盖。
那么在网络恢复时,如果从服务器想读的数据已经被覆盖了,主服务器就会采用全量同步,这个方式比增量同步的性能损耗要大很多。
因此,为了避免在网络恢复时,主服务器频繁地使用全量同步的方式,我们应该调整下repl_backlog_buffer
冲区大小,尽可能的大一些,减少出现从服务器要读取的数据被覆盖的概率,从而使得主服务器采用增量同步的方式。
关于repl_backlog_buffer
大小修改的方法,只需要修改配置文件里下面这个参数项的值就可以。
repl-backlog-size 1mb
# 6.1.5 心跳
Redis 主从节点发送的心态间隔是不一样的,而且作用也有一点区别:
- Redis主节点默认每隔10秒对从节点发送ping命令,判断从节点的存活性和连接状态,可通过参数
repl-ping-slave-period
控制发送频率。 - Redis从节点每隔1秒发送
replconf ack{offset}
命令,给主节点上报自身当前的复制偏移量,目的是为了:- 实时监测主从节点网络状态;
- 上报自身复制偏移量, 检查复制数据是否丢失, 如果从节点数据丢失, 再从主节点的复制缓冲区中拉取丢失数据。
- 主节点收到 replconf 信息后,判断从节点超时时间,如果超过 repl-timeout 60 秒,则判断节点下线。
# Slaves send PINGs to server in a predefined interval. It's possible to change
# this interval with the repl_ping_slave_period option. The default value is 10
# seconds.
#
# repl-ping-slave-period 10
#
# 1) Bulk transfer I/O during SYNC, from the point of view of slave.
# 2) Master timeout from the point of view of slaves (data, pings).
# 3) Slave timeout from the point of view of masters (REPLCONF ACK pings).
#
# It is important to make sure that this value is greater than the value
# specified for repl-ping-slave-period otherwise a timeout will be detected
# every time there is low traffic between the master and the slave.
#
# repl-timeout 60
2
3
4
5
6
7
8
9
10
11
12
13
14
15
因为主从节点间的命令复制是异步进行的,所以无法实现强一致性保证(主从数据时时刻刻保持一致)。
# 6.1.6 CAP
C - Consistent ,一致性;A - Availability ,可用性;P - Partition tolerance ,分区容忍性 分布式系统的节点往往都是分布在不同的机器上进行网络隔离开的,这意味着必然会有网络断开的风险,这个网络断开的场景的专业词汇叫着「网络分区」。
在网络分区发生时,两个分布式节点之间无法进行通信,我们对一个节点进行的修改操作将无法同步到另外一个节点,所以数据的「一致性」将无法满足,因为两个分布式节点的数据不再保持一致。除非我们牺牲 「可用性」,也就是暂停分布式节点服务,在网络分区发生时,不再提供修改数据的功能,直到网络状况完全恢复正常再继续对外提供服务。
一句话概括 CAP 原理就是——网络分区发生时,一致性和可用性两难全。
# 6.1.6.1 最终一致
Redis 的主从数据是异步同步的,所以分布式的Redis系统并不满足「一致性」要求。 当客户端在Redis的主节点修改了数据后,立即返回,即使在主从网络断开的情况下,主节点依旧可以正常对外提供修改 服务,所以Redis满足「可用性」。
Redis保证 「最终一致性」,从节点会努力追赶主节点,最终从节点的状态会和主节点的状态将保持一致。如果网络断开了,主从节点的数据将会出现大量不一致,一旦网络恢复,从节点会采用多种策略努力追赶上 落后的数据,继续尽力保持和主节点一致。
# 6.2 哨兵机制
在Redis的主从架构中,由于主从模式是读写分离的,如果主节点(master)挂了, 要恢复服务的话,需要人工介入,选择一个「从节点」切换为「主节点」,然后让其他从节点指向新的主节点,同时还需要通知上游那些连接 Redis 主节点的客户端,将其配置中的主节点 IP 地址更新为「新主节点」的 IP 地址。
Redis在2.8版本以后提供的哨兵(Sentinel)机制,它的作用是实现主从节点故障转移。它会监测主节点是否存活,如果发现主节点挂了,它就会选举一个从节点切换为主节点,并且把新主节点的相关信息通知给从节点和客户端。
哨兵节点主要负责三件事情:监控
、选主
、通知
。
# 6.2.1 监控
哨兵会每隔1秒给所有主从节点发送PING
命令,当主从节点收到PING
命令后,会发送一个响应命令给哨兵,这样就可以判断它们是否在正常运行。
如果主节点或者从节点没有在规定的时间内响应哨兵的PING
命令,哨兵就会将它们标记为 「主观下线」 。这个「规定的时间」是配置项down-after-milliseconds
参数设定的,单位是毫秒。
# 6.2.2 客观下线/主观下线
客观下线只适用于主节点。之所以针对「主节点」设计「主观下线」和「客观下线」两个状态,是因为有可能「主节点」其实并没有故障,可能只是因为主节点的系统压力比较大或者网络发送了拥塞,导致主节点没有在规定时间内响应哨兵的PING
命令。
所以,为了减少误判的情况,哨兵在部署的时候不会只部署一个节点,而是用多个节点部署成哨兵集群(最少需要三台机器来部署哨兵集群),通过多个哨兵节点一起判断,就可以就可以避免单个哨兵因为自身网络状况不好,而误判主节点下线的情况。同时,多个哨兵的网络同时不稳定的概率较小,由它们一起做决策,误判率也能降低。
具体是怎么判定主节点为「客观下线」的呢?
当一个哨兵判断主节点为「主观下线」后,就会向其他哨兵发起命令,其他哨兵收到这个命令后,就会根据自身和主节点的网络状况,做出赞成投票或者拒绝投票的响应。
当这个哨兵的赞同票数达到哨兵配置文件中的 quorum 配置项设定的值后,这时主节点就会被该哨兵标记为「客观下线」。
哨兵判断完主节点客观下线后,哨兵就要开始在多个「从节点」中,选出一个从节点来做新主节点。
# 6.2.3 选主
为了更加“客观”的判断主节点故障了,一般不会只由单个哨兵的检测结果来判断,而是多个哨兵一起判断,这样可以减少误判概率,所以哨兵是以哨兵集群的方式存在的。
问题来了,由哨兵集群中的哪个节点进行主从故障转移呢?
所以这时候,还需要在哨兵集群中选出一个leade
,让leader
来执行主从切换。
选举 leader 的过程其实是一个投票的过程,在投票开始前,肯定得有个「候选者」。
哪个哨兵节点判断主节点为「客观下线」,这个哨兵节点就是候选者,所谓的候选者就是想当Leader的哨兵。 候选者会向其他哨兵发送命令,表明希望成为Leader来执行主从切换,并让所有其他哨兵对它进行投票。
每个哨兵只有一次投票机会,如果用完后就不能参与投票了,可以投给自己或投给别人,但是只有候选者才能把票投给自己。
quorum
的值建议设置为哨兵个数的二分之一加1,例如3个哨兵就设置2,5个哨兵设置为3,而且哨兵节点的数量应该是奇数。
# 6.2.4 故障迁移
故障转移操作第一步要做的就是在已下线主节点属下的所有「从节点」中,挑选出一个状态良好、数据完整的从节点,然后向这个「从节点」发送 SLAVEOF no one 命令,将这个「从节点」转换为「主节点」。
在发送SLAVEOF no one
命令之后,哨兵leader会以每秒一次的频率向被升级的从节点发送INFO
命令(没进行故障转移之前,INFO 命令的频率是每十秒一次),并观察命令回复中的角色信息,当被升级节点的角色信息从原来的 slave 变为 master 时,哨兵 leader 就知道被选中的从节点已经顺利升级为主节点了。
当新主节点出现之后,哨兵 leader 下一步要做的就是,让已下线主节点属下的所有「从节点」指向「新主节点」,这一动作可以通过向「从节点」发送 SLAVEOF 命令来实现。
故障转移操作最后要做的是,继续监视旧主节点,当旧主节点重新上线时,哨兵集群就会向它发送 SLAVEOF 命令,让它成为新主节点的从节点,
# 6.2.3 通知
经过前面一系列的操作后,哨兵集群终于完成主从切换的工作,那么新主节点的信息要如何通知给客户端呢? 这主要通过 Redis 的发布者/订阅者机制来实现的。每个哨兵节点提供发布者/订阅者机制,客户端可以从哨兵订阅消息。
哨兵提供的消息订阅频道有很多,不同频道包含了主从节点切换过程中的不同关键事件,几个常见的事件如下:
客户端和哨兵建立连接后,客户端会订阅哨兵提供的频道。主从切换完成后,哨兵就会向+switch-master
频道发布新主节点的IP地址和端口的消息,这个时候客户端就可以收到这条信息,然后用这里面的新主节点的 IP 地址和端口进行通信了。
# 6.2.4 总结
哨兵节点之间是通过 Redis 的发布者/订阅者机制来相互发现的。
在主从集群中,主节点上有一个名为__sentinel__:hello
的频道,不同哨兵就是通过它来相互发现,实现互相通信的。
在下图中,哨兵A把自己的IP地址和端口的信息发布到__sentinel__:hello
频道上,哨兵B和C订阅了该频道。那么此时,哨兵B和C就可以从这个频道直接获取哨兵A的IP地址和端口号。然后,哨兵B、C可以和哨兵 A 建立网络连接。
通过这个方式,哨兵B和C也可以建立网络连接,这样一来,哨兵集群就形成了。
哨兵集群会对「从节点」的运行状态进行监控,那哨兵集群如何知道「从节点」的信息?
主节点知道所有「从节点」的信息,所以哨兵会每10秒一次的频率向主节点发送INFO
命令来获取所有「从节点」的信息。
如下图所示,哨兵B给主节点发送INFO
命令,主节点接受到这个命令后,就会把从节点列表返回给哨兵。接着,哨兵就可以根据从节点列表中的连接信息,和每个从节点建立连接,并在这个连接上持续地对从节点进行监控。哨兵A和C可以通过相同的方法和从节点建立连接。
正式通过Redis的发布者/订阅者机制,哨兵之间可以相互感知,然后组成集群,同时,哨兵又通过INFO
命令,在主节点里获得了所有从节点连接信息,于是就能和从节点建立连接,并进行监控了。
# 7. 高级用法
# 7.1 管道Pipeline
管道技术(Pipeline)是客户端提供的一种批处理技术,用于一次处理多个Redis命令,从而提高整个交互的性能。
使用管道技术可以解决多个命令执行时的网络等待,它是把多个命令整合到一起发送给服务器端处理之后统一返回给客户端,这样就免去了每条命令执行后都要等待的情况,从而有效地提高了程序的执行效率。
但使用管道技术也要注意避免发送的命令过大,或管道内的数据太多而导致的网络阻塞。
要注意的是,管道技术本质上是客户端提供的功能,而非 Redis 服务器端的功能。
$ (printf "PING\r\nPING\r\nPING\r\n"; sleep 1) | nc localhost 6379
+PONG
+PONG
+PONG
2
3
4
/**
* Multi
*/
public const ATOMIC = 0;
public const MULTI = 1;
public const PIPELINE = 2;
RedisHelper::getRedis()->multi(Redis::PIPELINE)
2
3
4
5
6
7
8
# 7.2 事务
MySQL 在执行事务时,会提供回滚机制,当事务执行发生错误时,事务中的所有操作都会撤销,已经修改的数据也会被恢复到事务执行前的状态。
Redis中并没有提供回滚机制,虽然 Redis提供了DISCARD命令,但是这个命令只能用来主动放弃事务执行,把暂存的命令队列清空,起不到回滚的效果。
Redis事务从开始到结束通常会通过三个阶段:
- 开始事务
- 命令入队
- 执行事务
$redis->connect('127.0.0.1',6379);
// 开启事务
$redis->multi();
$redis->setex('keyTest', 60, 1);
$redis->get('keyTest');
$redis->incr('keyTest');
$redis->get('keyTest');
// 执行事务,$ret为一次执行的结果数组
$ret = $redis->exec();
2
3
4
5
6
7
8
9
10
11
# 7.3 发布订阅(pub/sub)
# 7.4 Bitmap
Bitmap
本身是用String
类型作为底层数据结构实现的一种统计二值状态的数据类型。
String 类型是会保存为二进制的字节数组,所以,Redis就把字节数组的每个bit
位利用起来,用来表示一个元素的二值状态,你可以把Bitmap看作是一个bit数组
。
Bitmap 类型非常适合二值状态统计的场景,这里的二值状态就是指集合元素的取值就只有 0 和 1 两种,在记录海量数据时,Bitmap能够有效地节省内存空间。
# 7.5 Hyperloglog
Redis HyperLogLog是Redis 2.8.9版本新增的数据类型,是一种用于「统计基数」的数据集合类型,基数统计就是指统计一个集合中不重复的元素个数。但要注意,HyperLogLog 是统计规则是基于概率完成的,不是非常准确,标准误算率是 0.81%。
所以,简单来说HyperLogLog提供不精确的去重计数。
HyperLogLog的优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的内存空间总是固定的、并且是很小的。
在Redis里面,每个HyperLogLog键只需要花费12 KB内存,就可以计算接近2^64个不同元素的基数,和元素越多就越耗费内存的Set和Hash类型相比,HyperLogLog就非常节省空间。
# 7.6 GEO
GEO本身并没有设计新的底层数据结构,而是直接使用了Sorted Set
集合类型。
GEO 类型使用GeoHash编码方法实现了经纬度到Sorted Set
中元素权重分数的转换,这其中的两个关键机制就是「对二维地图做区间划分」和「对区间进行编码」。一组经纬度落在某个区间后,就用区间的编码值来表示,并把编码值作为 Sorted Set 元素的权重分数。
这样一来,我们就可以把经纬度保存到Sorted Set
中,利用Sorted Set
提供的“按权重进行有序范围查找”的特性,实现LBS服务中频繁使用的“搜索附近”的需求。
# 7.7 lua脚本
# 7.8 分布式锁
SET lock_key unique_value NX PX 10000
基于 Redis 实现分布式锁的缺点:
- 超时时间不好设置。如果锁的超时时间设置过长,会影响性能,如果设置的超时时间过短会保护不到共享资源。比如在有些场景中,一个线程 A 获取到了锁之后,由于业务代码执行时间可能比较长,导致超过了锁的超时时间,自动失效,注意 A 线程没执行完,后续线程 B 又意外的持有了锁,意味着可以操作共享资源,那么两个线程之间的共享资源就没办法进行保护了。
- Redis 主从复制模式中的数据是异步复制的,这样导致分布式锁的不可靠性。如果在 Redis 主节点获取到锁后,在没有同步到其他节点时,Redis 主节点宕机了,此时新的 Redis 主节点依然可以获取锁,所以多个应用服务就可以同时获取到锁。
# 7.9 RedLock(红锁)
为了保证集群环境下分布式锁的可靠性,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节点成功的获取到了锁,并且总耗时没有超过锁的有效时间,那么就是加锁成功):
- 条件一:客户端从超过半数(大于等于 N/2+1)的Redis节点上成功获取到了锁;
- 条件二:客户端从大多数节点获取锁的总耗时(t2-t1)小于锁设置的过期时间。
加锁成功后,客户端需要重新计算这把锁的有效时间,计算的结果是「锁最初设置的过期时间」减去「客户端从大多数节点获取锁的总耗时(t2-t1)」。如果计算的结果已经来不及完成共享数据的操作了,我们可以释放锁,以免出现还没完成数据操作,锁就过期了的情况。
加锁失败后,客户端向所有Redis节点发起释放锁的操作,释放锁的操作和在单节点上释放锁的操作一样,只要执行释放锁的Lua脚本就可以了。
# 7.10 延迟队列
在 Redis 可以使用有序集合(ZSet)的方式来实现延迟消息队列的,ZSet有一个Score属性可以用来存储延迟执行的时间。
使用zadd score1 value1
命令就可以一直往内存中生产消息。再利用zrangebysocre查询符合条件的所有待处理的任务, 通过循环执行队列任务即可。
# 7.3 大Key(Big Key)
大key并不是指key的值很大,而是key对应的value很大,一般而言,下面这两种情况被称为大key:
- String 类型的值大于
10 KB
; - Hash、List、Set、ZSet类型的元素的个数超过
5000个
;
# 7.3.1 查找大Key
可以通过 redis-cli --bigkeys 命令查找大 key:
redis-cli -h 127.0.0.1 -p6379 -a "password" -- bigkeys
使用的时候注意事项:
- 最好选择在从节点上执行该命令。因为主节点上执行时,会阻塞主节点;
- 如果没有从节点,那么可以选择在 Redis 实例业务压力的低峰阶段进行扫描查询,以免影响到实例的正常运行;或者可以使用 -i 参数控制扫描间隔,避免长时间扫描降低 Redis 实例的性能。 该方式的不足之处:
- 这个方法只能返回每种类型中最大的那个 bigkey,无法得到大小排在前 N 位的 bigkey;
- 对于集合类型来说,这个方法只统计集合元素个数的多少,而不是实际占用的内存量。但是,一个集合中的元素个数多,并不一定占用的内存就多。因为,有可能每个元素占用的内存很小,这样的话,即使元素个数有很多,总内存开销也不大;
# 7.3.2 删除大Key
在应用程序释放内存时,操作系统需要把释放掉的内存块插入一个空闲内存块的链表,以便后续进行管理和再分配。这个过程本身需要一定时间,而且会阻塞当前释放内存的应用程序。
删除大Key如果一下子释放了大量内存,空闲内存块链表操作时间就会增加,相应地就会造成Redis主线程的阻塞,如果主线程发生了阻塞,其他所有请求可能都会超时,超时越来越多,会造成Redis连接耗尽,产生各种异常。
因此,删除大key这一个动作,我们要小心。具体要怎么做呢?这里给出两种方法:
分批次删除:例如删除大Hash,使用
hscan
命令,每次获取100
个字段,再用hdel
命令,每次删除1个字段。异步删除:从 Redis 4.0 版本开始,可以采用异步删除法,用
unlink
命令代替del
来删除。 这样 Redis 会将这个 key 放入到一个异步线程中进行删除,这样不会阻塞主线程。
# 7.3.3 Big key的危害
- 内存空间不均匀
- 超时阻塞:单线程特性,操作big key比较耗时,也就意味着阻塞redis可能性比较大
- 网络阻塞:每次获取big key产生的网络流量较大,假设一个big key为1MB,每秒访问量为1000,那么每秒产生1000MB的流量,对于普通的千兆网卡(按照字节算是128MB/s)的服务器来说简直是灭顶之灾,而且一般服务器会采用单机多实例的方式来部署,也就是说一个big key可能会对其他实例造成影响。
# 8. 过期删除策略/内存淘汰策略
# 8.1 过期删除
每当我们对一个key设置了过期时间时,Redis会把该key带上过期时间存储到一个过期字典(expires dict)中,也就是说「过期字典」保存了数据库中所有key的过期时间。
Redis使用的过期删除策略是「惰性删除+定期删除」这两种策略配合使用。
# 8.1.1 惰性删除
惰性删除策略的做法是,不主动删除过期键,每次从数据库访问 key 时,都检测 key 是否过期,如果过期则删除该 key。
惰性删除策略的优点:
- 因为每次访问时,才会检查 key 是否过期,所以此策略只会使用很少的系统资源,因此,惰性删除策略对 CPU 时间最友好。
惰性删除策略的缺点:
- 如果一个 key 已经过期,而这个 key 又仍然保留在数据库中,那么只要这个过期 key 一直没有被访问,它所占用的内存就不会释放,造成了一定的内存空间浪费。所以,惰性删除策略对内存不友好。
# 8.1.2 定期删除
定期删除策略的做法是,每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期key。
Redis 的定期删除的流程:
- 从过期字典中随机抽取 20 个 key;
- 检查这 20 个 key 是否过期,并删除已过期的 key;
- 如果本轮检查的已过期 key 的数量,超过 5 个(25%),也就是「已过期 key 的数量」占比「随机抽取 key 的数量」大于 25%,则继续重复步骤 1;如果已过期的 key 比例小于 25%,则停止继续删除过期 key,然后等待下一轮再检查。 可以看到,定期删除是一个循环的流程。那 Redis 为了保证定期删除不会出现循环过度,导致线程卡死现象,为此增加了定期删除循环流程的时间上限,默认不会超过 25ms。
定期删除策略的优点:
- 通过限制删除操作执行的时长(25ms)和频率(hz),来减少删除操作对 CPU 的影响,同时也能删除一部分过期的数据减少了过期键对空间的无效占用。 定期删除策略的缺点:
- 难以确定删除操作执行的时长和频率。如果执行的太频繁,就会对 CPU 不友好;如果执行的太少,那又和惰性删除一样了,过期 key 占用的内存不会及时得到释放。
在 Redis 中,默认每秒进行 10 次过期检查一次数据库,此配置可通过 Redis 的配置文件 redis.conf 进行配置,配置键为 hz 它的默认值是 hz 10。
# 8.1.3 定时删除
定时删除策略的做法是,在设置key的过期时间时,同时创建一个定时事件,当时间到达时,由事件处理器自动执行key的删除操作。 定时删除策略的优点:
- 可以保证过期key会被尽快删除,也就是内存可以被尽快地释放。因此,定时删除对内存是最友好的。 定时删除策略的缺点:
- 在过期key比较多的情况下,删除过期key可能会占用相当一部分CPU时间,在内存不紧张但CPU时间紧张的情况下,将CPU时间用于删除和当前任务无关的过期键上,无疑会对服务器的响应时间和吞吐量造成影响。所以,定时删除策略对CPU不友好。
可以看到,惰性删除策略和定期删除策略都有各自的优点,所以Redis选择「惰性删除+定期删除」这两种策略配和使用,以求在合理使用 CPU 时间和避免内存浪费之间取得平衡。
# 8.2 Redis主从模式中,对过期键会如何处理?
当Redis运行在主从模式下时,从库不会进行过期扫描,从库对过期的处理是被动的。也就是即使从库中的 key 过期了,如果有客户端访问从库时,依然可以得到 key 对应的值,像未过期的键值对一样返回。
从库的过期键处理依靠主服务器控制,主库在key到期时,会在AOF文件里增加一条del指令,同步到所有的从库,从库通过执行这条del指令来删除过期的key。
# 9. 内存淘汰
在Redis的运行内存达到了某个阀值,就会触发内存淘汰机制,这个阀值就是我们设置的最大运行内存,此值在 Redis的配置文件中可以找到,配置项为maxmemory
。
Redis内存淘汰策略共有八种,这八种策略大体分为「不进行数据淘汰」和「进行数据淘汰」两类策略。
# 9.1 不进行数据淘汰的策略
noeviction
(Redis3.0之后,默认的内存淘汰策略) :它表示当运行内存超过最大设置内存时,不淘汰任何数据,而是不再提供服务,直接返回错误。
# 9.2 进行数据淘汰的策略
针对「进行数据淘汰」这一类策略,又可以细分为「在设置了过期时间的数据中进行淘汰」和「在所有数据范围内进行淘汰」这两类策略。
在设置了过期时间的数据中进行淘汰:
- volatile-random:随机淘汰设置了过期时间的任意键值;
- volatile-ttl:优先淘汰更早过期的键值。
- volatile-lru(Redis3.0 之前,默认的内存淘汰策略):淘汰所有设置了过期时间的键值中,最久未使用的键值;
- volatile-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰所有设置了过期时间的键值中,最少使用的键值;
在所有数据范围内进行淘汰:
- allkeys-random:随机淘汰任意键值;
- allkeys-lru:淘汰整个键值中最久未使用的键值;
- allkeys-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰整个键值中最少使用的键值。
# 9.3 如何查看当前 Redis 使用的内存淘汰策略?
可以使用config get maxmemory-policy
命令,来查看当前Redis的内存淘汰策略,命令如下:
127.0.0.1:6379> config get maxmemory-policy
1) "maxmemory-policy"
2) "noeviction"
2
3
可以看出,当前Redis使用的是noeviction
类型的内存淘汰策略,它是Redis 3.0之后默认使用的内存淘汰策略,表示当运行内存超过最大设置内存时,不淘汰任何数据,但新增操作会报错。
# 9.3.1 如何修改 Redis 内存淘汰策略?
设置内存淘汰策略有两种方法:
- 方式一:通过“config set maxmemory-policy <策略>”命令设置。它的优点是设置之后立即生效,不需要重启 Redis 服务,缺点是重启 Redis 之后,设置就会失效。
- 方式二:通过修改 Redis 配置文件修改,设置“maxmemory-policy <策略>”,它的优点是重启 Redis 服务后配置不会丢失,缺点是必须重启 Redis 服务,设置才能生效。
# 9.4 LRU & LFU
LRU 全称是 Least Recently Used 翻译为最近最少使用(最久未使用),会选择淘汰最近最少使用的数据。 传统 LRU 算法的实现是基于「链表」结构,链表中的元素按照操作顺序从前往后排列,最新操作的键会被移动到表头,当需要内存淘汰时,只需要删除链表尾部的元素即可,因为链表尾部的元素就代表最久未被使用的元素。 Redis 并没有使用这样的方式实现 LRU 算法,因为传统的 LRU 算法存在两个问题:
- 需要用链表管理所有的缓存数据,这会带来额外的空间开销;
- 当有数据被访问时,需要在链表上把该数据移动到头端,如果有大量数据被访问,就会带来很多链表移动操作,会很耗时,进而会降低 Redis 缓存性能。
# 9.4.1 Redis 是如何实现 LRU 算法的?
Redis实现的是一种近似LRU
算法,目的是为了更好的节约内存,它的实现方式是在Redis的对象结构体中添加一个额外的字段,用于记录此数据的最后一次访问时间。
当 Redis 进行内存淘汰时,会使用随机采样的方式来淘汰数据,它是随机取 5 个值(此值可配置),然后淘汰最久没有使用的那个。
Redis 实现的 LRU 算法的优点:
- 不用为所有的数据维护一个大链表,节省了空间占用;
- 不用在每次数据访问时都移动链表项,提升了缓存的性能; 但是 LRU 算法有一个问题,无法解决缓存污染问题,比如应用一次读取了大量的数据,而这些数据只会被读取这一次,那么这些数据会留存在 Redis 缓存中很长一段时间,造成缓存污染。
因此,在 Redis 4.0之后引入了 LFU 算法来解决这个问题。
# 9.4.2 什么是 LFU 算法?
LFU 全称是 Least Frequently Used 翻译为最近最不常用的,LFU 算法是根据数据访问次数来淘汰数据的,它的核心思想是“如果数据过去被访问多次,那么将来被访问的频率也更高”。 所以, LFU 算法会记录每个数据的访问次数。当一个数据被再次访问时,就会增加该数据的访问次数。这样就解决了偶尔被访问一次之后,数据留存在缓存中很长一段时间的问题,相比于 LRU 算法也更合理一些。
# 9.4.3 Redis 是如何实现 LFU 算法的?
LFU 算法相比于 LRU 算法的实现,多记录了「数据的访问频次」的信息。Redis 对象的结构如下:
typedef struct redisObject {...// 24 bits,用于记录对象的访问信息unsigned lru:24;
...
} robj;
2
3
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),低 8bit 存储 logc(Logistic Counter)。
- ldt 是用来记录 key 的访问时间戳;
- logc 是用来记录 key 的访问频次,它的值越小表示使用频率越低,越容易淘汰,每个新加入的 key 的logc 初始值为 5。 注意,logc 并不是单纯的访问次数,而是访问频次(访问频率),因为 logc 会随时间推移而衰减的。 在每次 key 被访问时,会先对 logc 做一个衰减操作,衰减的值跟前后访问时间的差距有关系,如果上一次访问的时间与这一次访问的时间差距很大,那么衰减的值就越大,这样实现的 LFU 算法是根据访问频率来淘汰数据的,而不只是访问次数。访问频率需要考虑 key 的访问是多长时间段内发生的。key 的先前访问距离当前时间越长,那么这个 key 的访问频率相应地也就会降低,这样被淘汰的概率也会更大。 对 logc 做完衰减操作后,就开始对 logc 进行增加操作,增加操作并不是单纯的 + 1,而是根据概率增加,如果 logc 越大的 key,它的 logc 就越难再增加。 所以,Redis 在访问 key 时,对于 logc 是这样变化的:
- 先按照上次访问距离当前的时长,来对 logc 进行衰减;
- 然后,再按照一定概率增加 logc 的值 redis.conf 提供了两个配置项,用于调整 LFU 算法从而控制 logc 的增长和衰减:
- lfu-decay-time 用于调整 logc 的衰减速度,它是一个以分钟为单位的数值,默认值为1,lfu-decay-time 值越大,衰减越慢;
- lfu-log-factor 用于调整 logc 的增长速度,lfu-log-factor 值越大,logc 增长越慢。
# 9.5 数据库/缓存数据一致性
无论是「先更新数据库,再更新缓存」,还是「先更新缓存,再更新数据库」,这两个方案都存在并发问题,当两个请求并发更新同一条数据的时候,可能会出现缓存和数据库中的数据不一致的现象。
实际开发中,Redis和MySQL的更新策略用的是Cache Aside
。
Cache Aside(旁路缓存)策略是最常用的,应用程序直接与「数据库、缓存」交互,并负责对缓存的维护,该策略又可以细分为「读策略」和「写策略」。
写策略的步骤:
- 先更新数据库中的数据,再删除缓存中的数据。
读策略的步骤:
- 如果读取的数据命中了缓存,则直接返回数据;
- 如果读取的数据没有命中缓存,则从数据库中读取数据,然后将数据写入到缓存,并且返回给用户。
注意,写策略的步骤的顺序顺序不能倒过来,即不能先删除缓存再更新数据库,原因是在「读+写」并发的时候,会出现缓存和数据库的数据不一致性的问题。
可以看到,先删除缓存,再更新数据库,在「读 + 写」并发的时候,还是会出现缓存和数据库的数据不一致的问题。
Cache Aside策略适合读多写少的场景,不适合写多的场景,因为当写入比较频繁时,缓存中的数据会被频繁地清理,这样会对缓存的命中率有一些影响。如果业务对缓存命中率有严格的要求,那么可以考虑两种解决方案:
- 一种做法是在更新数据时也更新缓存,只是在更新缓存前先加一个分布式锁,因为这样在同一时间只允许一个线程更新缓存,就不会产生并发问题了。当然这么做对于写入的性能会有一些影响;
- 另一种做法同样也是在更新数据时更新缓存,只是给缓存加一个较短的过期时间,这样即使出现缓存不一致的情况,缓存的数据也会很快过期,对业务的影响也是可以接受。
# 10. Redis实现消息队列
Redis 消息队列的三种方案(List、Streams、Pub/Sub)。
# 10.1 List 实现消息队列
Redis 列表是简单的字符串列表,按照插入顺序排序。你可以添加一个元素到列表的头部(左边)或者尾部(右边)。
所以常用来做异步队列使用。将需要延后处理的任务结构体序列化成字符串塞进 Redis 的列表,另一个线程从这个列表中轮询数据进行处理。
# 10.1.1 即时消费问题
通过 LPUSH,RPOP 这样的方式,会存在一个性能风险点,就是消费者如果想要及时的处理数据,就要在程序中写个类似 while(true) 这样的逻辑,不停的去调用 RPOP 或 LPOP 命令,这就会给消费者程序带来些不必要的性能损失。 所以,Redis 还提供了 BLPOP、BRPOP 这种阻塞式读取的命令(带 B-Bloking的都是阻塞式),客户端在没有读到队列数据时,自动阻塞,直到有新的数据写入队列,再开始读取新数据。这种方式就节省了不必要的 CPU 开销。
- LPUSH、BRPOP 左进右阻塞出
- RPUSH、BLPOP 右进左阻塞出
因为 Redis 单线程的特点,所以在消费数据时,同一个消息会不会同时被多个 consumer 消费掉,但是需要我们考虑消费不成功的情况。
# 10.1.2 可靠队列模式 ack 机制
以上方式中, List 队列中的消息一经发送出去,便从队列里删除。如果由于网络原因消费者没有收到消息,或者消费者在处理这条消息的过程中崩溃了,就再也无法还原出这条消息。究其原因,就是缺少消息确认机制。 为了保证消息的可靠性,消息队列都会有完善的消息确认机制(Acknowledge),即消费者向队列报告消息已收到或已处理的机制。
Redis List怎么搞一搞呢?
再看上边的表格中,有两个命令, RPOPLPUSH、BRPOPLPUSH (阻塞)从一个 list 中获取消息的同时把这条消息复制到另一个 list 里(可以当做备份),而且这个过程是原子的。
这样我们就可以在业务流程安全结束后,再删除队列元素,实现消息确认机制。
数据标识从一个 List 取出后放入另一个 List,业务操作安全执行完成后,再去删除 List 中的数据,如果有问题的话,很好回滚。
当然,还有更特殊的场景,可以通过 zset 来实现延时消息队列,原理就是将消息加到 zset 结构后,将要被消费的时间戳设置为对应的 score 即可,只要业务数据不会是重复数据就 OK。
# 10.1.3 list方式的消息队列总结
- 实时性 实时性较好,可以通过brpop方式阻塞获取新消息,有高实时性;或者通过定时任务方式监听队列,存在较低延迟。
- 可靠性 综合可靠性一般,在点对点的消息推送机制下(lpush + rpop方案)不容易存在消息丢失,可以保证高可靠性。在其它场景可能存在并发安全性问题,但是可以通过加锁解决。存在事务失败问题,但是发生的概率较低。存在消息丢失问题,但是可以自己实现ack机制。
- 功能性 功能性一般,通过自己的额外扩展可以满足多种不同的消息队列功能,如多对多、发布订阅模式。
# 10.1.4 使用场景
基于list的特点,可以推导出在两种常见场景下的实现方案:
- 针对生产者都采用lpush的方式推送数据,
- 针对消费者:
- 对实时性和可靠性要求高的情况,消费者使用brpop阻塞读取和消费数据
- 对实时性和可靠性要求不高的情况,但是推送数据量大,要求处理性能高,消费者使用定时任务+lrange+ltrim方式读取和消费数据
# 10.2 订阅与发布实现消息队列
消息模型有两种
- 点对点:Point-to-Point(P2P)
- 发布订阅:Publish/Subscribe(Pub/Sub)
List 实现方式其实就是点对点的模式,下边我们再看下 Redis 的发布订阅模式(消息多播),这才是“根正苗红”的 Redis MQ。
"发布/订阅"模式同样可以实现进程间的消息传递,其原理如下:
- "发布/订阅"模式包含两种角色,分别是发布者和订阅者。
- 订阅者可以订阅一个或者多个频道(channel),而发布者可以向指定的频道(channel)发送消息,所有订阅此频道的订阅者都会收到此消息。
Redis 发布订阅 (pub/sub) 有个缺点就是消息无法持久化,如果出现网络断开、Redis 宕机等,消息就会被丢弃。而且也没有 Ack 机制来保证数据的可靠性,假设一个消费者都没有,那消息就直接被丢弃了。
# 10.2.1 pub-sub方式的消息队列总结
- 实时性 实时性极好,实现的消息推送机制本身就是高实时性的。
- 可靠性 可靠性较差。一旦消费者宕机消息就会直接被丢弃。
- 功能性 功能性一般,实现简单的发布订阅还好,但是为了满足一些高可用性就需要增加很多额外的操作。
# 10.2.2 使用场景
基于pub-sub的特点,可以推导出在其常用场景: 不在意消息丢失,不在意消息接收可靠性,需要发布订阅功能,需要高实时性的场景,这种场景一般常见于日志推送。
# 10.3 Streams 实现消息队列
Redis 5.0 版本新增了一个更强大的数据结构——Stream。它提供了消息的持久化和主备复制功能,可以让任何客户端访问任何时刻的数据,并且能记住每一个客户端的访问位置,还能保证消息不丢失。
# 10.3.1 按消费组消费
Stream提供了xreadgroup
指令可以进行消费组的组内消费,需要提供消费组名称、消费者名称和起始消息 ID。它同xread
一样,也可以阻塞等待新消息。读到新消息后,对应的消息ID就会进入消费者的PEL
(正在处理的消息) 结构里,客户端处理完毕后使用xack
指令通知服务器,本条消息已经处理完毕,该消息ID就会从 PEL
中移除。
# 10.3.2 stream方式的消息队列总结
- 实时性 实时性好,可以通过block方式阻塞获取新消息,有高实时性;
- 可靠性 可靠性好,redis的自带的持久化机制可以防止消息丢失,但是相比kafka的磁盘写入还是略不可靠。自带的ack机制可以满足消息应答实现,防止消息丢失。但是需要注意对于死信息积压在pending区的数据需要定时去处理回收。此外,积压的消息会一直保存在stream中,哪怕已经ack过,还需要额外的一个线程定时将已经读取完的消息删除。
- 功能性 功能性好,可以轻松实现多种不同的消息队列功能,如多对多、发布订阅模式。
# 10.3.3 使用场景
基于stream的特点,可以推导出在其常用场景: 大部分使用消息队列的场景都可以使用stream替代。基于redis的高性能和使用内存的机制使得其的性能优于大部分消息队列。在小规模场景会有更出色的表现。但是针对大流量的场景不推荐使用stream,毕竟内存的大小是有限的,这也是所有redis实现的消息队列的局限之处。
# 11. 其他
# 11.1 Redis与Memcached区别
- Redis支持的数据类型更丰富(String、Hash、List、Set、ZSet),而Memcached只支持最简单的 key-value数据类型;
- Redis支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而 Memcached没有持久化功能,数据全部存在内存之中,Memcached 重启或者挂掉后,数据就没了;
- Redis原生支持集群模式,Memcached没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;
- Redis 支持发布订阅模型、Lua脚本、事务等功能,而Memcached不支持;
# 11.2 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) 释放跳表对象;
# 11.1 Redis采用单线程为什么还这么快?
Redis采用单线程(网络I/O和执行命令)那么快,有如下几个原因:
- Redis的大部分操作都在内存中完成,并且采用了高效的数据结构,因此Redis瓶颈可能是机器的内存或者网络带宽,而并非 CPU,既然CPU不是瓶颈,那么自然就采用单线程的解决方案了;
- Redis采用单线程模型可以避免了多线程之间的竞争,省去了多线程切换带来的时间和性能上的开销,而且也不会导致死锁问题;
- Redis采用了I/O多路复用机制处理大量的客户端Socket请求,IO多路复用机制是指一个线程处理多个IO 流,就是我们经常听到的select/epoll机制;
# 11.2 Redis 6.0之前为什么使用单线程?
使用了单线程后,可维护性高,多线程模型虽然在某些方面表现优异,但是它却引入了程序执行顺序的不确定性,带来了并发读写的一系列问题,增加了系统复杂度、同时可能存在线程切换、甚至加锁解锁、死锁造成的性能损耗。
# 11.3 Redis 6.0 之后为什么引入了多线程?
在Redis 6.0版本之后,也采用了多个I/O线程来处理网络请求,这是因为随着网络硬件的性能提升,Redis的性能瓶颈有时会出现在网络I/O的处理上,也就是说,单个主线程处理网络请求的速度跟不上底层网络硬件的速度。
所以为了提高网络I/O的并行度,Redis6.0对于网络I/O采用多线程来处理。但是对于命令的执行,Redis仍然使用单线程来处理,所以大家不要误解 Redis 有多线程同时执行命令。
Redis官方表示,Redis 6.0版本引入的多线程I/O特性对性能提升至少是一倍以上。
在Redis 6.0中,多线程机制默认是关闭的,如果需要使用多线程功能,需要在redis.conf
中完成两个设置。
- 设置
io-thread-do-reads
配置项为yes
,表示启用多线程。
io-threads-do-reads yes
- 设置线程个数。一般来说,线程个数要小于Redis实例所在机器的CPU核个数,例如,对于一个8核的机器来说,Redis官方建议配置6个IO 线程。
io-threads 6
# 11.4 PHP中connect与pconnect的区别
connect
: PHP脚本结束之后连接就释放了。pconnect
:脚本结束之后连接不释放,连接保持在php-fpm进程中。每个php-fpm进程占用一个连接,当php-fpm进程结束时会释放掉。
所以使用pconnect
代替connect
,可以减少频繁建立redis连接的消耗。
# 11.5 Redis 可以做消息队列么?
Redis 消息队列的三种方案(List、Stream、Pub/Sub)。 Redis 5.0 新增加的一个数据结构 Stream 可以用来做消息队列,Stream 支持:
- 发布 / 订阅模式
- 按照消费者组进行消费
- 消息持久化(RDB 和 AOF)
不过,和专业的消息队列相比,还是有很多欠缺的地方比如消息丢失和堆积问题不好解决。因此,我们通常建议是不使用 Redis 来做消息队列的,你完全可以选择市面上比较成熟的一些消息队列比如 RocketMQ、Kafka。
# 11.6 性能优化经验
- Master最好不要做任何持久化工作,如RDB内存快照和AOF日志文件。如果数据比较重要,某个Slave开启 AOF备份数据,策略设置为每秒同步一次;
- 为了主从复制的速度和连接的稳定性, Master和Slave最好在同一个局域网内;
- 主从复制不要用图状结构,用单向链表结构更为稳定,即:
Master <- Slave1 <- Slave2 <- Slave3…
为了保证数据的可靠性,Redis需要在磁盘上读写AOF和RDB,但在高并发场景里,这就会直接带来两个新问题:一个是写AOF和RDB会造成Redis性能抖动,另一个是Redis集群数据同步和实例恢复时,读RDB比较慢,限制了同步和恢复速度。
# 11.7 内存回收机制
Redis flushdb
命令用于清空当前数据库中的所有key。
Redis并不总是可以将空闲内存立即归还给操作系统, 如果当前Redis内存有10G,当你删除了1GB的key后,再去观察内存,会发现内存变化不会太大。原因是操作系统回收内存是以页为单位,如果这个页上只要有一个 key 还在使用,那么它就不能被回收。Redis虽然删除了1GB的key,但是这些key分散到了很多页面中,每个页面都还有其它key存在,这就导致了内存不会立即被回收。
不过,如果你执行flushdb
,然后再观察内存会发现内存确实被回收了。原因是所有的key都干掉了,之前使用的页面都完全干净了,会立即被操作系统回收。
Redis虽然无法保证立即回收已经删除的key的内存,但是它会重用那些尚未回收的空闲内存。
# 11.8 慢查询
redis客户端执行一条命令分为:发送命令
、命令排队
、命令执行
、返回结果
,慢查询只统计“命令执行”的时间,所以没有慢查询并不代表客户端没有超时问题。
127.0.0.1:6379> config get slowlog-log-slower-than
1) "slowlog-log-slower-than"
2) "10000"
127.0.0.1:6379> config get slowlog-max-len
1) "slowlog-max-len"
2) "128"
2
3
4
5
6
slowlog-log-slower-than
默认10 000微秒,即超过10ms就算超时slowlog-max-len
默认128,慢查询日志存在内存列表中的条数
修改配置文件redis.conf
,重启redis, 或者使用命令动态配置:
CONFIG SET slowlog-log-slower-than 100
CONFIG SET slowlog-max-len 1024
2
查看慢查询日志:
slowlog get [n] # 返回包括:id、时间戳、命令耗时、执行命令和参数
slowlog len
slowlog reset(清空)
2
3