Redis为什么那么快?
基于内存实现:
Redis 将数据存储在内存中,读写操作不会受到磁盘的 IO 速度限制,所以Redis的读写速度会非常的快。
CPU 不是 Redis 的瓶颈,Redis 的瓶颈是机器内存的大小或者网络带宽。
使用I/O多路复用模型:
Redis 线程不会阻塞在某一个特定的客户端请求处理上。
- 可以同时和多个客户端连接并处理请求,从而提升了并发性。
采用单线程模型:
Redis 的网络 IO 以及键值对指令读写是由一个线程来执行的。
- 对于 Redis 的持久化、集群数据同步、异步删除等都是其他线程执行。
单线程 避免了 线程切换 和 竞态 产生的消耗,对于服务端开发来说,锁和线程切换 通常是性能杀手。
高效的数据结构:
为了追求速度,不同数据类型使用不同的数据结构速度才得以提升。
数据结构
一个单机的 Redis 服务器默认情况下有 16 个数据库(0-15 号)。
- 默认使用的是 0 号数据库,可以使用
SELECT
命令切换数据库。
每个数据库都由一个
redis.h/redisDb
结构表示:
- 它记录了单个 Redis 数据库的键空间、所有键的过期时间、处于阻塞状态和就绪状态的键、数据库编号等。
dict:
- 一个记录键值对数据的字典。
- 键是一个字符串对象,值是字符串、列表、哈希表、集合和有序集合在内的任意一种 Redis 类型对象。
expires:
- 一个用于记录键的过期时间的字典。
- 键为 dict 中的数据库键,值为这个数据库键的过期时间戳,这个值以 long long 类型表示。
typedef struct redisDb {
// 数据库键空间,保存着数据库中的所有键值对
dict *dict;
// 键的过期时间,字典的键为键,字典的值为过期事件 UNIX 时间戳
dict *expires;
// 正处于阻塞状态的键
dict *blocking_keys;
// 可以解除阻塞的键
dict *ready_keys;
// 正在被 WATCH 命令监视的键
dict *watched_keys;
struct evictionPoolEntry *eviction_pool;
// 数据库编号
int id;
// 数据库的键的平均 TTL,统计信息
long long avg_ttl;
} redisDb;
数据类型
string
(字符串)、hash
(哈希)、list
(列表)、set
(集合)、zset
(有序集合)
String
它可以存储任意类型的数据,比如文本、数字、图片或者序列化的对象。
一个 string 类型的键最大可以存储 512 MB 的数据。
string 类型的底层实现是 SDS,它是一个动态字符串结构,由长度、空闲空间和字节数组三部分组成。
SDS有3种编码类型:
- embstr:占用64Bytes的空间,存储44Bytes的数据
- raw:存储大于44Bytes的数据
- int:存储整数类型
embstr和raw存储字符串数据,int存储整型数据。
应用场景:
- 缓存数据,提高访问速度和降低数据库压力。
- 计数器,利用 incr 和 decr 命令实现原子性的加减操作。
- 分布式锁,利用 setnx 命令实现互斥访问。
- 限流,利用 expire 命令实现时间窗口内的访问控制。
List
一个有序的字符串列表,它按照插入顺序排序,并且支持在两端插入或删除元素。
一个 list 类型的键最多可以存储
2^32 - 1
个元素。
redis3.2
以后,list 类型的底层实现只有一种结构:quicklist。应用场景:
- 消息队列,利用 lpush 和 rpop 命令实现生产者消费者模式。
- 最新消息,利用 lpush 和 ltrim 命令实现固定长度的时间线。
- 历史记录,利用 lpush 和 lrange 命令实现浏览记录或者搜索记录。
Hash
一个键值对集合,它可以存储多个字段和值,类似于编程语言中的 map 对象。
一个 hash 类型的键最多可以存储
2^32 - 1
个字段。Hash类型的底层实现有三种:
ziplist
:压缩列表,当hash达到一定的阈值时,会自动转换为hashtable
结构。listpack
:紧凑列表,在Redis7.0之后,listpack
正式取代ziplist
。
- 同样的,当hash达到一定的阈值时,会自动转换为
hashtable
结构。hashtable
:哈希表,类似map。应用场景:
hash 类型的应用场景主要是存储对象,比如:
- 用户信息,利用 hset 和 hget 命令实现对象属性的增删改查。
- 购物车,利用 hincrby 命令实现商品数量的增减。
- 配置信息,利用 hmset 和 hmget 命令实现批量设置和获取配置项。
Set
set
是一个无序的字符串集合,它不允许重复的元素。一个
set
类型的键最多可以存储2^32 - 1
个元素。
set
类型的底层实现有两种:
intset
,整数集合。hashtable
(哈希表)。
- 哈希表和 hash 类型的哈希表相同,它将元素存储在一个数组中,并通过哈希函数计算元素在数组中的索引。
应用场景:
- 去重,利用 sadd 和 scard 命令实现元素的添加和计数。
- 交集,并集,差集,利用 sinter,sunion 和 sdiff 命令实现集合间的运算。
- 随机抽取,利用 srandmember 命令实现随机抽奖或者抽样。
ZSet
Redis
中的zset
是一种有序集合类型,它可以存储不重复的字符串元素,并且给每个元素赋予一个排序权重值(score
)。
Redis
通过权重值来为集合中的元素进行从小到大的排序。
zset
的成员是唯一的,但权重值可以重复。一个
zset
类型的键最多可以存储2^32 - 1
个元素。应用场景:
- 排行榜,利用 zadd 和 zrange 命令实现分数的更新和排名的查询。
- 延时队列,利用 zadd 和 zpopmin 命令实现任务的添加和执行,并且可以定期地获取已经到期的任务。
- 访问统计,可以使用 zset 来存储网站或者文章的访问次数,并且可以按照访问量进行排序和筛选。
GEO
Redis
的Geo
在Redis 3.2
版本推出,这个功能可以推算地理位置的信息: 两地之间的距离,方圆几里的人。
查询键的数据类型
type key:
例如键
hello
字符串类型,返回:string
。键
mylist
列表类型,返回:list
。如果键不存在,则返回
none
。
127.0.0.1:6379> set a b
OK
127.0.0.1:6379> type a
string
127.0.0.1:6379> rpush mylist a b c d e f g
(integer) 7
127.0.0.1:6379> type mylist
list
内部编码
对于每种 数据类型,实际上都有自己底层的 内部编码 实现,而且是 多种实现。
这样
Redis
会在合适的 场景 选择合适的 内部编码。
通过 object encoding
命令查询 内部编码:
127.0.0.1:6379> object encoding hello
"embstr"
127.0.0.1:6379> object encoding mylist
"quicklist"
Hash底层原理
哈希类型 的 内部编码:
- ziplist(redis7.0之前使用)和listpack(redis7.0之后使用)
- hashTable
ziplist(压缩列表):
- 当 哈希类型 元素个数 小于
hash-max-ziplist-entries
配置,同时 所有值 都 小于hash-max-ziplist-value
配置时使用。ziplist
使用更加 紧凑的结构 实现多个元素的 连续存储,在 节省内存 方面比hashtable
更加优秀。hashtable(哈希表):
- 当 哈希类型 无法满足
ziplist
的条件时,Redis
会使用hashtable
作为 哈希 的 内部实现。- 因为此时
ziplist
的 读写效率 会下降,而hashtable
的读写 时间复杂度 为O(1)
。
List底层原理
列表类型的 内部编码:
- 在
Redis3.2
之前,list使用的是linkedlist
和ziplist
- 在
Redis3.2~Redis7.0
之间,list使用的是quickList
,是linkedlist
和ziplist
的结合- 在
Redis7.0
之后,list使用的也是quickList
,只不过将ziplist
转为listpack
,它是listpack、linkedlist结合版ziplist(压缩列表):
- 当列表的元素个数 小于
list-max-ziplist-entries
配置,同时列表中 每个元素 的值都 小于list-max-ziplist-value
配置时使用。linkedlist(链表):
- 当 列表类型 无法满足
ziplist
的条件时,Redis
会使用linkedlist
作为 列表 的 内部实现。
Set底层原理
集合类型 的 内部编码:
- 在
Redis7.2
之前,set使用的是intset
和hashtable
- 在
Redis7.2
之后,set使用的是intset
、listpack
、hashtable
intset(整数集合):
- 当集合中的元素都是 整数 且 元素个数 小于
set-max-intset-entries
配置时使用。hashtable(哈希表):
- 当集合类型 无法满足
intset
的条件时,Redis
会使用hashtable
作为集合的 内部实现。
为什么加入了listpack?
在
redis7.2
之前,sds
类型的数据会直接放入到编码结构式为hashtable
的set
中。
- 其中,
sds
其实就是redis
中的string
类型。而在
redis7.2
之后,sds类型的数据,首先会使用listpack
结构,当set
达到一定的阈值时,才会自动转换为hashtable
。添加
listpack
结构是为了提高内存利用率和操作效率,因为 hashtable 的空间开销和碰撞概率都比较高。
ZSet底层原理
有序集合的内部实现:
ziplist
(redis7.0
之前使用)和listpack(redis7.0
之后使用)skiplist
当有序集合的元素个数小于
zset-max-ziplist-entries
(默认为128个),并且每个元素成员的长度小于zset-max-ziplist-value
(默认为64字节)时,使用压缩列表作为有序集合的内部实现。
- 每个集合元素由两个紧挨在一起的两个压缩列表结点组成,其中第一个结点保存元素的成员,第二个结点保存元素的分支。
- 压缩列表中的元素按照分数从小到大依次紧挨着排列,有效减少了内存空间的使用。
当有序集合的元素个数大于等于
zset-max-ziplist-entries
(默认为128个),或者每个元素成员的长度大于等于zset-max-ziplist-value
(默认为64字节)时,使用跳跃表作为有序集合的内部实现。
- 在跳跃表中,所有元素按照从小到大的顺序排列。
- 跳跃表的结点中的
object
指针指向元素成员的字符串对象,score
保存了元素的分数。- 通过跳跃表,Redis可以快速地对有序集合进行分数范围、排名等操作。
在哈希表中,为有序集合创建了一个从元素成员到元素分数的映射。
键值对中的键指向元素成员的字符串对象,键值对中的值保存了元素的分数。
- 通过哈希表,Redis可以快速查找指定元素的分数。
虽然有序集合同时使用跳跃表和哈希表,但是这两种数据结构都使用指针共享元素中的成员和分数,不会额外的内存浪费。
Pipeline
通过将一批命令进行打包,然后发送给服务器,服务器执行完按顺序打包返回,这样就减少了频繁交互往返的时间,提升了性能。
- 客户端将执行的命令写入到缓冲区(内存)中,最后再一次性发送 Redis。
Pipeline的优点:
通过打包命令,一次性执行,可以节省连接->发送命令->返回结果这个过程所产生的往返时间,减少的I/O的调用(用户态到内核态之间的切换)次数。
Pipeline的缺点:
每批打包的命令不能过多,因为所有命令前先缓存起所有命令的处理结果,这样就有一个内存的消耗。
不保证原子性,执行命令过程中,如果一个命令出现异常,也会继续执行其他命令。
每次只能作用在一个Redis节点上。
内存机制
内存回收策略
Redis的内存回收机制主要体现在以下两个方面:
删除到达过期时间的键对象。
内存使用达到Maxmemory上限时触发内存溢出控制策略。
删除过期键对象:
Redis所有的键都可以设置过期属性,内部保存在过期字典中。
惰性删除:
- 当客户端读取带有超时属性的键时,如果已经超过键设置的过期时间,会执行删除操作并返回空。
定时任务删除:
- Redis内部维护一个定时任务,默认每秒运行10次。
内存溢出策略
当Redis所用内存达到Maxmemory上限时会触发相应的溢出策略:
noeviction:
- 默认策略,不会删除任何数据,拒绝所有写入操作并返回客户端错误信息,此时Redis只响应读操作。
volatile-lru:
- 根据LRU算法删除设置了超时属性的键。
- 如果没有可删除的键对象,回退到noeviction策略。
allkeys-lru:
- 根据LRU算法删除键,不管数据有没有设置超时属性。
allkeys-random:
- 随机删除所有键。
volatile-random:
- 随机删除过期键。
volatile-ttl:
- 根据键值对象的ttl属性,删除最近将要过期数据,如果没有 回退 到noeviction策略。
优先使用
allkeys-lru
策略。
- 业务数据中有明显的冷热数据区分,建议使用
allkeys-lru
策略。业务应用中的数据访问频率相差不大,没有明显的冷热数据区分,建议使用
allkeys-random
策略。业务中有置顶的需求,比如置顶新闻、置顶视频,可以使用
volatile-lru
策略。
事务
Redis提供了MULTI,EXEC两个命令来完成事务。
客户端使用一个命令MULTI开启事务。
客户端把事务中本身要执行的具体操作(例如增删改数据)发送给服务器端,这些命令暂存到一个命令队列中,并不会立即执行。
客户端向服务器端发送提交事务的命令EXEC,让数据库实际执行第二步中发送的具体操作。
原子性
命令入队时就报错,会放弃事务执行,保证原子性。
命令入队时没报错,实际执行时报错,不保证原子性。
- EXEC命令执行时实例故障,如果开启AOF日志,可以保证原子性。
一致性
在命令执行错误或 Redis 发生故障的情况下,Redis 事务机制对一致性是有保证的。
隔离性
并发操作在 EXEC 命令前执行,此时,隔离性的保证要使用 WATCH 机制来实现,否则隔离性无法保证。
并发操作在 EXEC 命令后执行,此时,隔离性可以保证。
WATCH机制的作用:
在事务执行前,监控一个或多个键的值变化情况,当事务调用EXEC命令执行时,Watch机制会先检查监控的键是否被其他客户端修改了,如果修改了,就放弃事务执行,避免事务的隔离性被破坏。
持久性
不管 Redis 采用什么持久化模式,事务的持久性属性是得不到保证的。
哨兵模式
Redis
的 主从复制 模式下,一旦 主节点 由于故障不能提供服务,需要手动将 从节点 晋升为 主节点,同时还要通知 客户端 更新 主节点地址。
Redis 2.8
以后提供了Redis Sentinel
哨兵机制 来解决这个问题。
Redis Sentinel的主要功能
Sentinel
是一个管理多个Redis
实例的工具,它可以实现对Redis
的 监控、通知、自动故障转移。监控
Sentinel
会不断的检查 主服务器 和 从服务器 是否正常运行。通知
- 当被监控的某个
Redis
服务器出现问题,Sentinel
通过API
脚本 向 管理员 或者其他的 应用程序 发送通知。自动故障转移
- 当 主节点 不能正常工作时,
Sentinel
会开始一次 自动的 故障转移操作,它会将与 失效主节点 是 主从关系 的其中一个 从节点 升级为新的 主节点,并且将其他的 从节点 指向 新的主节点。配置提供者
- 在
Redis Sentinel
模式下,客户端应用 在初始化时连接的是Sentinel
节点集合,从中获取 主节点 的信息。
主观下线和客观下线
默认情况下,每个
Sentinel
节点会以 每秒一次 的频率对Redis
节点和 其它 的Sentinel
节点发送PING
命令,并通过节点的 回复 来判断节点是否在线。主观下线
- 适用于所有 主节点 和 从节点。
- 如果在
down-after-milliseconds
毫秒内,Sentinel
没有收到 目标节点 的有效回复,则会判定 该节点 为 主观下线。客观下线
- 只适用于 主节点。
- 如果 主节点 出现故障,
Sentinel
节点会通过sentinel is-master-down-by-addr
命令,向其它Sentinel
节点询问对该节点的 状态判断。- 如果超过
<quorum>
个数的节点判定 主节点 不可达,则该Sentinel
节点会判断 主节点 为 客观下线。
工作原理
每个
Sentinel
以 每秒钟 一次的频率,向它所知的 主服务器、从服务器 以及其他Sentinel
实例 发送一个PING
命令。如果一个 实例距离 最后一次 有效回复
PING
命令的时间超过down-after-milliseconds
所指定的值,这个实例会被Sentinel
标记为 主观下线。如果一个 主服务器 被标记为 主观下线,并且有 足够数量 的
Sentinel
在指定的 时间范围 内同意这一判断,那么这个 主服务器 被标记为 客观下线。
Sentinel
和其他Sentinel
协商 主节点 的状态,如果 主节点 处于SDOWN
状态,则投票自动选出新的 主节点。将剩余的 从节点 指向 新的主节点 进行 数据复制。
脑裂问题
在 Redis 哨兵模式或集群模式中,由于网络原因,导致主节点(Master)与哨兵(Sentinel)和从节点(Slave)的通讯中断。
此时哨兵就会误以为主节点已宕机,就会在从节点中选举出一个新的主节点,此时 Redis 的集群中就出现了两个主节点的问题。
脑裂问题影响:
Redis脑裂问题会导致数据丢失。
当旧的 Master 变为 Slave 之后,的执行流程如下:
- Slave(旧 Master)会向 Master(新)申请全量数据。
- Master 会通过 Bgsave 的方式生成当前 RDB 快照,并将 RDB 发送给 Slave。
- Slave 拿到 RDB 之后,先进行 Flush 清空当前数据(此时第四步旧客户端给他的发送的数据就丢失了)。
- 之后再加载 RDB 数据,初始化自己当前的数据。
在执行到第三步的时候,原客户端在旧 Master 写入的数据就丢失了。
解决脑裂问题:
Redis提供了以下两个配置,通过以下两个配置可以尽可能的避免数据丢失的问题:
- min-slaves-to-write:
- 与主节点通信的从节点数量必须大于等于该值主节点,否则主节点拒绝写入。
- min-slaves-max-lag:
- 主节点与从节点通信的 ACK 消息延迟必须小于该值,否则主节点拒绝写入。
这两个配置项必须同时满足,不然主节点拒绝写入。
集群
Redis 3.0
之前,使用 哨兵(Sentinel
)机制来监控各个节点之间的状态。在
3.0
版本正式推出,解决了Redis
在 分布式 方面的需求。
数据分区
Redis Cluster
采用 虚拟槽分区,所有的 键 根据 哈希函数 映射到0~16383
整数槽内。
- 计算公式:
slot = CRC16(key)& 16383
。- 每个节点负责维护一部分槽以及槽所映射的 键值数据。
为什么Redis集群的最大槽数是16384个?
2^14^=16384、2^16^=65536
。
如果槽位是65536个,发送心跳信息的消息头是
65536/8/1024 = 8k
。如果槽位是16384个,发送心跳信息的消息头是
16384/8/1024 = 2k
。因为Redis每秒都会发送一定数量的心跳包,如果消息头是8k,有些太大了,浪费网络资源。
Redis的集群主节点数量一般不会超过1000个。
- 集群中节点越多,心跳包的消息体内的数据就越多,如果节点过多,也会造成网络拥堵。
因此Redis的作者不建议Redis Cluster的节点超过1000个,对于节点数在1000个以内的Redis Cluster,16384个槽位完全够用。
集群的功能限制
key
批量操作 支持有限:
- 类似
mset
、mget
操作,目前只支持对具有相同slot
值的key
执行 批量操作。- 对于 映射为不同
slot
值的key
由于执行mget
、mget
等操作可能存在于多个节点上,因此不被支持。
key
事务操作 支持有限:
- 只支持 多
key
在 同一节点上 的 事务操作,当多个key
分布在 不同 的节点上时 无法 使用事务功能。不支持 多数据库空间:
- 单机 下的
Redis
可以支持16
个数据库(db0 ~ db15
),集群模式 下只能使用 一个 数据库空间,即db0
。
持久化
RDB持久化
将内存中的数据生成快照保存到磁盘里面,保存的文件后缀是
.rdb
。rdb 文件是一个经过压缩的二进制文件,当 Redis 重新启动时,可以读取 rdb 快照文件恢复数据。
- 包括:rdbSave 和 rdbLoad 两个函数。
- rdbSave用于生成 RDB 文件并保存到磁盘,rdbLoad用于将 RDB 文件中的数据重新载入到内存中。
RDB 文件是一个单文件的全量数据,很适合数据的容灾备份与恢复。
- 通过 RDB 文件恢复数据库耗时较短,通常 1G 的快照文件载入内存只需 20s 左右。
RDB文件的生成方式
手动触发保存:
通过 SAVE 和 BGSAVE 命令手动触发快照生成。
SAVE 是一个同步式的命令,它会阻塞 Redis 服务器进程,直到 RDB 文件创建完成为止。
- 在服务器进程阻塞期间,服务器不能处理任何其他命令请求。
BGSAVE 是一个异步式的命令:
- 会派生出一个子进程,由子进程负责创建 RDB 文件,服务器进程(父进程)继续处理客户的命令。
基本过程:
客户端发起 BGSAVE 命令,Redis 主进程判断当前是否存在正在执行备份的子进程,如果存在则直接返回。
父进程 fork 一个子进程 (fork 的过程中会造成阻塞的情况)。
fork 创建的子进程开始根据父进程的内存数据生成临时的快照文件,然后替换原文件。
子进程备份完毕后向父进程发送完成信息。
自动触发保存:
通过 save 选项设置多个保存条件,只要其中任意一个条件被满足,服务器就会执行 BGSAVE 命令。
只要满足以下 3 个条件中的任意一个,BGSAVE 命令就会被自动执行:
- 服务器在 900 秒之内,对数据库进行了至少 1 次修改。
- 服务器在 300 秒之内,对数据库进行了至少 10 次修改。
- 服务器在 60 秒之内,对数据库进行了至少 10000 次修改。
AOF持久化
AOF 会把 Redis 服务器每次执行的写命令记录到一个日志文件中,当服务器重启时再次执行 AOF 文件中的命令来恢复数据。
如果 Redis 服务器开启了 AOF 持久化,会优先使用 AOF 文件来还原数据库状态。
只有在 AOF 的持久化功能处于关闭状态时,服务器才会使用 RDB 文件还原数据库状态。
执行流程
AOF 不需要设置任何触发条件,对 Redis 服务器的所有写命令都会自动记录到 AOF 文件中。
AOF 文件的写入流程可以分为 3 个步骤:
命令追加(append):将 Redis 执行的写命令追加到 AOF 的缓冲区
aof_buf
。文件写入(write)和文件同步(fsync):AOF 根据对应的策略将
aof_buf
的数据同步到硬盘。文件重写(rewrite):定期对 AOF 进行重写,从而实现对写命令的压缩。
AOF缓冲区的文件同步策略
appendfsync always:每执行一次命令保存一次
- 命令写入
aof_buf
缓冲区后立即调用系统 fsync 函数同步到 AOF 文件,fsync 操作完成后线程返回,整个过程是阻塞的。appendfsync no:不保存
- 命令写入
aof_buf
缓冲区后调用系统 write 操作,不对 AOF 文件做 fsync 同步。
- 同步由操作系统负责,通常同步周期为 30 秒。
appendfsync everysec:每秒钟保存一次
- 命令写入
aof_buf
缓冲区后调用系统 write 操作,write 完成后线程立刻返回,fsync 同步文件操作由单独的进程每秒调用一次。
文件同步策略 | write 阻塞 | fsync 阻塞 | 宕机时的数据丢失量 |
---|---|---|---|
always | 阻塞 | 阻塞 | 最多只丢失一个命令的数据 |
no | 阻塞 | 不阻塞 | 操作系统最后一次对 AOF 文件 fsync 后的数据 |
everysec | 阻塞 | 不阻塞 | 一般不超过 1 秒钟的数据 |
文件重写
把对 AOF 文件中的写命令进行合并,压缩文件体积,同步到新的 AOF 文件中,然后使用新的 AOF 文件覆盖旧的 AOF 文件。
触发机制:
手动触发:
- 调用 bgrewriteaof 命令,执行与 bgsave 有些类似
自动触发:
- 根据
auto-aof-rewrite-min-size
和auto-aof-rewrite-percentage
配置项,以及aof_current_size
和aof_base_size
的状态确定触发时机
- auto-aof-rewrite-min-size:执行 AOF 重写时,文件的最小体积,默认值为 64MB
- auto-aof-rewrite-percentage:
- 执行 AOF 重写时,当前 AOF 大小(
aof_current_size
)和上一次重写时 AOF 大小(aof_base_size
)的比值
重写流程:
客户端通过
bgrewriteaof
命令对 Redis 主进程发起 AOF 重写请求。主进程通过 fork 操作创建子进程,这个过程主进程是阻塞的。
主进程的 fork 操作完成后,继续处理其他命令,把新的写命令同时追加到
aof_buf
和aof_rewrite_buf
缓冲区中。
- 在文件重写完成之前,主进程会继续把写命令追加到
aof_buf
缓冲区,这样可以避免 AOF 重写失败造成数据丢失,保证原有的 AOF 文件的正确性。- 由于 fork 操作运用写时复制技术,子进程只能共享 fork 操作时的内存数据,主进程会把新命令追加到一个
aof_rewrite_buf
缓冲区中,避免 AOF 重写时丢失这部分数据。子进程读取 Redis 进程中的数据快照,生成写入命令并按照命令合并规则批量写入到新的 AOF 文件。
子进程写完新的 AOF 文件后,向主进程发信号。
主进程接受到子进程的信号以后,将
aof_rewrite_buf
缓冲区中的写命令追加到新的 AOF 文件。主进程使用新的 AOF 文件替换旧的 AOF 文件,AOF 重写过程完成。
RDB的优缺点:
优点:
- RDB 是一个压缩过的非常紧凑的文件,保存着某个时间点的数据集,适合做数据的备份、灾难恢复。
- 与 AOF 持久化方式相比,恢复大数据集的时候会更快。
缺点:
数据安全性是不如 AOF,保存整个数据集是个重量级的过程,可能要几分钟才进行一次持久化,如果服务器宕机,就可能丢失几分钟的数据。
Redis 数据集较大时,fork 的子进程要完成快照会比较耗费 CPU 和时间。
AOF的优缺点
优点:
- 数据更完整,安全性更高,秒级数据丢失。
- AOF 文件是一个只进行追加的命令文件,且写入操作是以 Redis 协议的格式保存的,内容是可读的,适合误删紧急恢复。
缺点:
对于相同的数据集,AOF 文件的体积要远远大于 RDB 文件,数据恢复也会比较慢。
RDB-AOF混合持久化
Redis 4.0 版本提供了一套基于 AOF-RDB 的混合持久化机制,保留了两种持久化机制的优点。
这样重写的 AOF 文件由两部份组成,一部分是 RDB 格式的头部数据,另一部分是 AOF 格式的尾部指令。
在 Redis 服务器重启的时候:
- 可以预先加载 AOF 文件头部全量的 RDB 数据。
- 然后再重放 AOF 文件尾部增量的 AOF 命令,从而大大减少了重启过程中数据还原的时间。
基本原理
Redis协议
RESP,它是一种简单的文本协议,用于在客户端和服务器之间操作和传输数据。
RESP协议描述了不同类型的数据结构,并且定义了请求和响应之间如何以这些数据结构进行交互。
RESP 非常简单且人类可读,这使得 Redis 能够易于使用和调试。
同时,RESP 也允许客户端和服务器以高效和低延迟的方式发送和接收数据。
单线程模式
Redis的网络IO和键值对读写是由一个线程来完成的。
Redis在处理客户端的请求时包括获取(读)、解析、执行、内容返回(写)等都由一个顺序串行的主线程处理。
由于Redis在处理命令的时候是单线程作业的,所以会有一个Socket队列。
- 每一个到达的服务端命令来了之后都不会马上被执行,而是进入队列,然后被线程的事件分发器逐个执行。
Redis的其他功能,比如持久化、异步删除、集群数据同步等等,其实是由额外的线程执行的。
Redis工作线程是单线程的,但是在4.0之后,对于整个Redis服务来说,还是多线程运作的。
常见问题
热Key
热 Key
带来问题:
流量集中,达到服务器处理上限(
CPU
、网络IO
等)。会影响在同一个
Redis
实例上其他Key
的读写请求操作。热
Key
请求落到同一个Redis
实例上,无法通过扩容解决。大量
Redis
请求失败,查询操作可能打到数据库,拖垮数据库,导致整个服务不可用。
如何发现热 Key:
客户端进行收集:
- 可以对客户端工具进行封装,在发送请求前进行收集采集,同时定时把收集到的数据上报到统一的服务进行聚合计算。
在代理层进行收集:
- 如果所有的
Redis
请求都经过Proxy
(代理)的话,可以考虑改动Proxy
代码进行收集。
热 Key 问题解决方案:
增加 Redis 实例副本数量:
- 对于出现热
Key
的Redis
实例,可以通过水平扩容增加副本数量,将读请求的压力分担到不同副本节点上。二级缓存(本地缓存)
热 Key 备份:
通过热
Key
备份的方式,给热Key
加上前缀或者后缀
- 把一个热
Key
的数量变成Redis
实例个数N
的倍数M
- 从而由访问一个
RedisKey
变成访问N*M
个RedisKey
。
N*M
个RedisKey
经过分片分布到不同的实例上,将访问量均摊到所有实例。
// N 为 Redis 实例个数,M 为 N 的 2倍
const M = N * 2
//生成随机数
random = GenRandom(0, M)
//构造备份新 Key
bakHotKey = hotKey + "_" + random
data = redis.GET(bakHotKey)
if data == NULL {
data = redis.GET(hotKey)
if data == NULL {
data = GetFromDB()
// 可以利用原子锁来写入数据保证数据一致性
redis.SET(hotKey, data, expireTime)
redis.SET(bakHotKey, data, expireTime + GenRandom(0, 5))
} else {
redis.SET(bakHotKey, data, expireTime + GenRandom(0, 5))
}
}
通过一个大于等于
1
小于M
的随机数,得到一个bakHotKey
- 程序会优先访问
bakHotKey
,在得不到数据的情况下
- 再访问原来的
hotkey
,并将hotkey
的内容写回bakHotKey
。
bakHotKey
的过期时间是hotkey
的过期时间加上一个较小的随机正整数
- 保证在
hotkey
过期时,所有bakHotKey
不会同时过期而造成缓存雪崩。
大key
如果String类型值大于10KB,
Hash,Set,Zset,List
类型的元素的个数大于5000个都可以称之为大Key。
大Key的危害:
客户端超时等待
阻塞工作线程
内存分布不均匀:
- 集群模型在Slot分片均匀的情况下,会出现数据和查询倾斜情况,部分有大Key的Redis节点占用内存多。
如何处理大Key:
对大Key进行拆分:
- 将一个Big Key拆分为多个
Key-Value
这样的小Key,并确保每个Key的成员数量或者大小在合理范围内。- 通过Get不同的key或者使用MGet批量获取。
删除BigKey:
Redis官方文档描述到:
- String 类型的key,DEL 时间复杂度是
O(1)
,大Key除外。List/Hash/Set/ZSet
类型的Key,DEL 时间复杂度是O(M)
,M 为元素数量,元素越多,耗时越久。
异步删除:
Redis从4.0开始,可以使用
UNLINK
命令来异步删除大Key,删除大Key的语法与DEL命令相同。
- 当使用UNLINK删除一个大Key时,Redis不会立即释放关联的内存空间,而是将删除操作放入后台处理队列中。
Redis会在处理命令的间隙,逐步执行后台队列中的删除操作,从而不会显著影响服务器的响应性能。
缓存一致性
方案 | 问题 | 问题出现概率 | 推荐程度 |
---|---|---|---|
更新缓存 -> 更新数据库 | 为了保证数据准确性,数据必须以数据库更新结果为准,所以该方案绝不可行 | 大 | 不推荐 |
更新数据库 -> 更新缓存 | 并发更新数据库场景下,会将脏数据刷到缓存 | 并发写场景,概率一般 | 写请求较多时会出现不一致问题,不推荐使用。 |
删除缓存 -> 更新数据库 | 更新数据库之前,若有查询请求,会将脏数据刷到缓存 | 并发读场景,概率较大 | 读请求较多时会出现不一致问题,不推荐使用 |
更新数据库 -> 删除缓存 | 在更新数据库之前有查询请求,并且缓存失效了,会查询数据库,然后更新缓存。 如果在查询数据库和更新缓存之间进行了数据库更新的操作,那么就会把脏数据刷到缓存。 |
并发读场景&读操作慢于写操作,概率最小 | 读操作比写操作更慢的情况较少,相比于其他方式出错的概率小一些。勉强推荐。 |
操作失败情况处理:
对数据库和缓存的操作,在实际生产中,由于网络抖动、服务下线等等原因,操作是有可能失败的。
举例说明:
应用要把数据 X 的值从 1 更新为 2,先成功更新了数据库,然后在
Redis
缓存中删除 X 的缓存。但是这个操作失败了,这个时候数据库中 X 的新值为 2,
Redis
中的 X 的缓存值为 1,出现了数据库和缓存数据不一致的问题。
- 不管是先操作数据库,还是先操作缓存,只要第二个操作失败都会出现数据一致的问题。
解决方法:
重试机制:
- 如果重试超过一定次数,还是没有成功,就需要向业务层发送报错信息了。
订阅
MySQL Binlog
,再操作缓存。
缓存击穿
解决方案:
使用互斥锁。
提前使用互斥锁:
- 即在value内部设置1个超时值(timeout1),timeout1比实际的redis timeout(timeout2)小。
- 当从cache读取到timeout1发现它已经过期时候,马上延长timeout1并重新设置到cache。
- 然后再从数据库加载数据并设置到cache中。
缓存永不过期。
缓存穿透
查询一个一定不存在的数据,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询。
在流量大时,要是DB无法承受瞬间流量冲击,DB可能就挂了。
解决方案:
缓存空数据。
布隆过滤器。
缓存雪崩
缓存中有大量数据同时过期,导致大量请求无法得到处理。
解决方案:
设计不同的过期时间。
对缓存增加多个副本。
应用场景
常见应用场景
登录鉴权:
用户登录鉴权,以及对应的登录验证码或Token到期失效。
- Redis Key的超时失效功能,非常适合于这种业务场景。
计数器:
类似于知乎的帖子点赞、收藏,电商的库存扣减等。
- 在高并发场景下,用MySQL数据库去硬扛这种读写压力是比较吃力的。
Redis的INCR、DECR、INCRBY、DECRBY相关命令,则解决了这个问题。
粉丝关注:
Set是一个无序的天然去重的集合
- 即:Key-Set。
Set还提供了求交集、求并集等一系列直接操作集合的方法
- 非常适合于求共同或单方好友、粉丝、爱好之类的业务场景。
redis> SADD Tony Mary //Mary成为了Tony的粉丝
(integer) 1
redis> SADD Tony Lynn //Lynn成为了Tony的粉丝
(integer) 1
redis> SMEMBERS Tony //Tony的粉丝列表
1) "Mary"
2) "Lynn"
redis> SADD Tom Mary //Mary成为了Tom的粉丝
(integer) 1
redis> SADD Tom Eric //Eric成为了Tom的粉丝
(integer) 1
redis> SMEMBERS Tom //Tom的粉丝列表
1) "Mary"
2) "Eric"
redis> SINTER Tony Tom //Tony和Tom的共同粉丝
1) "Mary"
redis> SUNION Tony Tom //Tony和Tom的所有粉丝
1) "Mary"
2) "Lynn"
3) "Eric"
redis> SDIFF Tony Tom //Tony的粉丝,但不是Tom的粉丝
1) "Lynn"
redis> SDIFF Tom Tony //Tom的粉丝,但不是Tony的粉丝
1) "Eric"
排行榜:
Zset(SortedSet),是Set的可排序版。
通过增加一个排序属性score来实现的
- 适用于排行榜和时间线之类的业务场景,且在高并发场景下具备非常优秀的性能。
防刷:
防刷:用户在极短时间内,频繁发起请求去调用系统中的某个接口,该情况下需要对其进行限制。
举例:限制用户每秒钟只能下单一次,若用户在一秒钟内连续三次下单
- 这时只有第一个下单是成功的,其他两个会通过Redis的过期时间机制,对其进行限制。
消息队列:
Redis可以通过list数据结构实现消息队列的功能,这样可以在电商秒杀
- 或者在线教育集中约课等高并发写场景下,提供消峰功能。
浏览器历史记录:
每当访问一个新的网页,浏览器就会自动存储下来
- 当点击 后退 按钮时,最近一次访问的网页就会展示出来。
可以通过
Redis list
来实现栈功能,进而实现浏览器历史记录场景。
用户签到:
用户签到、用户出勤、当天活跃用户等场景,用Redis Set数据结构也可以实现
- 但用户量级庞大的情况下,会极大占用内存空间。
这种情况下,非常适合
Redis BitMap
数据结构,通过其bit位来进行状态存储。
网站UV统计:
通过
Redis Set
存储用户ID的方式进行解决,非常耗费内存空间
- 可以使用
HyperLogLog
。Redis
HyperLogLog
提供不精确的去重计数方案,标准误差是 0.81%
- 但仅仅占用12k的内存空间,非常适用于大型网站UV统计这种空间消耗巨大,但数据不需要特别精确的业务场景。
分布式锁
使用分布式锁的目的,是保证同一时间只有一个客户端可以对共享资源进行操作。
使用SETNX
实现:
SETNX key value
,只在键Key
不存在的情况下,将键Key
的值设置为value。若键key存在,则
SETNX
不做任何动作。
boolean result = jedis.setnx("lock-key",true)== 1L;
if (result) {
try {
// do something
} finally {
jedis.del("lock-key");
}
}
某个线程在获取锁之后由于某些异常因素(比如宕机)而不能正常的执行解锁操作,这个锁就永远释放不掉了。
- 可以为这个锁加上一个超时时间。
SET key value EX seconds
的效果等同于执行SETEX key seconds value
SET key value PX milliseconds
的效果等同于执行PSETEX key milliseconds value
String result = jedis.set("lock-key",true, 5);
if ("OK".equals(result)) {
try {
// do something
} finally {
jedis.del("lock-key");
}
}
某线程A获取了锁设置了过期时间为10s,在执行业务逻辑时耗费了15s。
- 此时线程A获取的锁被
Redis
的过期机制自动释放了。在线程A获取锁并经过10s之后,改锁可能已经被其它线程获取到了。
- 当线程A执行完业务逻辑准备解锁(
DEL key
)的时候,有可能删除掉的是其它线程已经获取到的锁。所以在解锁时判断锁是否是自己的,可以在设置key的时候将value设置为一个唯一值
uniqueValue
。当解锁时,也就是删除key的时候先判断一下key对应的value是否等于先前设置的值,如果相等才能删除key。
String velue= String.valueOf(System.currentTimeMillis())
String result = jedis.set("lock-key",velue, 5);
if ("OK".equals(result)) {
try {
// do something
} finally {
//非原子操作
if(jedis.get("lock-key")==value){
jedis.del("lock-key");
}
}
}
GET
和DEL
是两个分开的操作,在GET执行之后且在DEL执行之前的间隙是可能会发生异常的。
- 只要保证解锁的代码是原子性的就能解决问题了。
由于
Lua
脚本的原子性,在Redis
执行该脚本的过程中,其他客户端的命令都需要等待该Lua
脚本执行完才能执行。
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
确保过期时间大于业务执行时间:
为了防止多个线程同时执行业务代码,需要确保过期时间大于业务执行时间。
增加一个boolean类型的属性
isOpenExpirationRenewal
,用来标识是否开启定时刷新过期时间。增加一个
scheduleExpirationRenewal
方法用于开启刷新过期时间的线程。加锁代码在获取锁成功后将
isOpenExpirationRenewal
置为true。
- 并且调用
scheduleExpirationRenewal
方法,开启刷新过期时间的线程。解锁代码增加一行代码,将
isOpenExpirationRenewal
属性置为false,停止刷新过期时间的线程轮询。
Redisson实现:
WatchdDog机制
获取锁成功就会开启一个定时任务,定时任务会定期检查去续期。
该定时调度每次调用的时间差是
internalLockLeaseTime/3
,也就10秒。默认情况下,加锁的时间是30秒。
- 如果加锁的业务没有执行完,那么到 30-10 = 20秒的时候,就会进行一次续期,把锁重置成30秒。
RedLock
在集群中,主节点挂掉时,从节点会取而代之,客户端上却并没有明显感知。
- 原先第一个客户端在主节点中申请成功了一把锁,但是这把锁还没有来得及同步到从节点,主节点突然挂掉了。
然后从节点变成了主节点,这个新的节点内部没有这个锁。
- 所以当另一个客户端过来请求加锁时,立即就批准了。
这样就会导致系统中同样一把锁被两个客户端同时持有,不安全性由此产生。
Redlock算法就是为了解决这个问题:
加锁时,它会向过半节点发送
set
指令,只要过半节点set
成功,那就认为加锁成功。释放锁时,需要向所有节点发送
del
指令。不过 Redlock 算法还需要考虑出错重试、时钟漂移等很多细节问题。
- 同时因为
Redlock
需要向多个节点进行读写,意味着相比单实例Redis
性能会下降一些。
假设当前集群有 5 个节点,运行 Redlock
算法的客户端依次执行下面各个步骤,来完成获取锁的操作:
客户端记录当前系统时间,以毫秒为单位。
依次尝试从 5 个
Redis
实例中,使用相同的 key 获取锁。当向
Redis
请求获取锁时,客户端应该设置一个网络连接和响应超时时间,超时时间应该小于锁的失效时间,避免因为网络故障出现的问题。
- 客户端使用当前时间减去开始获取锁时间就得到了获取锁使用的时间。
当且仅当从半数以上的
Redis
节点获取到锁,并且当使用的时间小于锁失效时间时,锁才算获取成功。如果获取到了锁,key 的真正有效时间等于有效时间减去获取锁所使用的时间,减少超时的几率。
如果获取锁失败,客户端应该在所有的
Redis
实例上进行解锁,即使是上一步操作请求失败的节点。
- 防止因为服务端响应消息丢失,但是实际数据添加成功导致的不一致。
也就是说,假设锁30秒过期,三个节点加锁花了31秒,自然是加锁失败了。
在 Redis 官方推荐的 Java 客户端
Redisson
中,内置了对RedLock
的实现。
RedLock问题:
RedLock
只是保证了锁的高可用性,并没有保证锁的正确性。
RedLock
是一个严重依赖系统时钟的分布式系统。
延时队列
使用 zset这个命令,用设置好的时间戳作为score进行排序,使用
zadd score1 value1 ....
命令就可以一直往内存中生产消息。再利用 zrangebysocre 查询符合条件的所有待处理的任务,通过循环执行队列任务即可。
- 通过
zrangebyscore key min max withscores limit 0 1
查询最早的一条任务,来进行消费。
private Jedis jedis;
public void redisDelayQueueTest() {
String key = "delay_queue";
// 实际开发建议使用业务 ID 和随机生成的唯一 ID 作为 value, 随机生成的唯一 ID 可以保证消息的唯一性, 业务 ID 可以避免 value 携带的信息过多
String orderId1 = UUID.randomUUID().toString();
jedis.zadd(queueKey, System.currentTimeMillis() + 5000, orderId1);
String orderId12 = UUID.randomUUID().toString();
jedis.zadd(queueKey, System.currentTimeMillis() + 5000, orderId2);
new Thread() {
@Override
public void run() {
while (true) {
Set<String> resultList;
// 只获取第一条数据, 只获取不会移除数据
resultList = jedis.zrangebyscore(key, System.currentTimeMillis(), 0, 1);
if (resultList.size() == 0) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
break;
}
} else {
// 移除数据获取到的数据
if (jedis.zrem(key, resultList.get(0)) > 0) {
String orderId = resultList.get(0);
log.info("orderId = {}", resultList.get(0));
this.handleMsg(orderId);
}
}
}
}
}.start();
}
public void handleMsg(T msg) {
System.out.println(msg);
}
进一步优化:
同一个任务可能会被多个进程取到之后再使用 zrem 进行争抢,那些没抢到 的进程都是白取了一次任务。
可以考虑使用
lua scripting
来优化一下这个逻辑,将zrangebyscore
和zrem
一同挪到服务器端进行原子化操作,这样多个进程之间争抢任务时就不会出现这种浪费了。
String luaScript = "local resultArray = redis.call('zrangebyscore', KEYS[1], 0, ARGV[1], 'limit' , 0, 1)\n" +
"if #resultArray > 0 then\n" +
" if redis.call('zrem', KEYS[1], resultArray[1]) > 0 then\n" +
" return resultArray[1]\n" +
" else\n" +
" return ''\n" +
" end\n" +
"else\n" +
" return ''\n" +
"end";
jedis.eval(luaScript, ScriptOutputType.VALUE, new String[]{key}, String.valueOf(System.currentTimeMillis()));
统计一个亿的Keys
HyperLogLog:
Redis提供了一个扩展类型HyperLogLog用于基数统计,计算
2^64
个元素大概只需要12KB的内存空间。但是
HyperLogLog
是存在误差的,大概是在0.81%
,如果需要精准的统计,还是需要使用Set
。
新特性
Redis6.0多线程模型
Redis 的性能瓶颈逐渐体现在网络 I/O 的读写上,单个线程处理网络 I/O 读写的速度跟不上底层网络硬件执行的速度。
Redis 在处理网络数据时,调用
Epoll
的过程是阻塞的,这个过程会阻塞线程。如果并发量很高,达到万级别的 QPS,就会形成瓶颈,影响整体吞吐能力。
读写网络的
read/write
系统调用占用了 Redis 执行期间大部分 CPU 时间,要想真正做到提速,必须改善网络IO性能。Redis支持多线程两个原因:
- 可以充分利用服务器CPU的多核资源,而主线程明显只能利用一个。
- 多线程任务可以分摊 Redis 同步 IO 读写负荷,降低耗时。
6.0版本优化之后,主线程和多线程网络IO的执行流程如下:
- 主线程建立连接,并接受数据,并将获取的
socket
数据放入等待队列。- 通过轮询的方式将
socket
读取出来并分配给 IO 线程。- 之后主线程保持阻塞,一直等到 IO 线程完成
socket
读取和解析。- I/O 线程读取和解析完成之后,返回给主线程,主线程开始执行 Redis 命令。
- 执行完Redis命令后,主线程阻塞,直到IO 线程完成 结果回写到
socket
的工作。- 主线程清空已完成的队列,等待客户端新的请求。
本质上是将主线程 IO 读写的这个操作独立出来,单独交给一个I/O线程组处理。
这样多个
socket
读写可以并行执行,整体效率也就提高了,同时注意 Redis 命令还是主线程串行执行。