Redis除了做缓存,还能做什么?
- 分布式锁,可以通过SETNX命令+lua脚本+定时器实现一个可重入、防误删、自动续期的分布式锁
- 限流:通过Redis+Lua脚本实现限流
- 消息队列 使用List数据结构可以作为一个简单的队列使用,Redis 5.0中增加的Stream类型的数据结构更加适合用来做消息队列
- 复杂业务场景 通过Redis以及Reids扩展提供的数据结构完成很多业务场景比如bitmap统计活跃用户 hyperlong统计网站uv、sorted set维护排行榜
Redis 数据结构
- 5种基础数据结构 String(字符串)、List(列表)、Hash(散列)、Zset(有序集合)
- 3种特殊数据结构 HyperLogLog(基数统计)、BitMap(位存储)、Stream(消息队列)
字符串
Redis自己构建了一种简单动态字符串(SDS),相比于C的原生字符串,Redis的SDS不光可以保存文本数据还可以保存二进制数据 ,获取字符串长度复杂读位O(1),除此之外Redi的SDS API是安全的,不会造成缓冲区溢出。
优点:
- O(1)复杂度获取字符串长度
- 二进制安全,可以存放二进制数据
- 不会发生缓冲区溢出
- flag字段可以根据不同类型设置不同的数据长度,节省内存空间 使用场景:需要存储常规数据的场景、需要计数的场景
- 缓存session、token、图片地址、序列化后的对象
- 用户单位时间的请求数、页面单位时间的访问数
- 分布式锁
List
Redis的List实现为一个双向链表,即可以支持反向查找和遍历,方便操作,不过带来了部分额外的内存开销,因此在数据量比较少的情况会采用压缩列表作用底层数据结构。
链表数据结构
链表节点结构:
typedef struct listNode {
//前置节点
struct listNode *prev;
//后置节点
struct listNode *next;
//节点的值
void *value;
} listNode;
链表结构:
typedef struct list {
//链表头节点
listNode *head;
//链表尾节点
listNode *tail;
//节点值复制函数
void *(*dup)(void *ptr);
//节点值释放函数
void (*free)(void *ptr);
//节点值比较函数
int (*match)(void *ptr, void *key);
//链表节点数量
unsigned long len;
} list;
优点:
- 获取某个节点的前置节点或后置节点的时间都是O(1)
- 获取头节点和尾节点的时间都是O(1)
- 链表节点可以保存不同类型的值
缺点:
- 链表不是连续的,无法很好利用CPU缓存,同时查找其他的链表节点的复杂度是O(n)
- 保存一个链表节点的值需要一个链表节点结构头的分配,内存开销较大
压缩列表数据结构
由连续内存块组成的顺序型数据结构,类似于数组 表头:
- zlbytes,记录整个压缩列表占用对内存字节数;
- zltail,记录压缩列表「尾部」节点距离起始地址由多少字节,也就是列表尾的偏移量;
- zllen,记录压缩列表包含的节点数量;
- zlend,标记压缩列表的结束点,固定值 0xFF(十进制255)。 优点:查找第一个元素和最后一个元素的时间复杂度是O(1) 缺点: 查找其他节点的时间是O(n) 表节点:
- prevlen 记录前一个节点的长度,目的是为了实现从后向前遍历
- encoding 记录了当前节点实际数据的类型和长度。
- data 记录了当前节点的实际数据。
当我们往压缩列表中插入数据,压缩列表会根据数据类型是字符串还是整数,以及数据的大小,会使用不同空间大小的prevlen和encoding。根据数据大小和类型进行不同的空间大小分配,可以节省内存。
连锁更新问题 压缩列表新增某个元素或修改某个元素时,如果空间不够,压缩列表占用的内存空间就需要重新分配,当新插入的元素较大时,可能会导致后续元素的prevlen占用空间都发生变化,从而引起连锁更新问题,导致每个元素的空间都要重新分配,造成访问压缩列表性能的下降。
因此,压缩列表只会用于保存的节点数量不多的场景,只要节点数量足够小,即使发生连锁更新,也是能接受的。
Redis 针对压缩列表在设计上的不足,在后来的版本中,新增设计了两种数据结构:quicklist(Redis 3.2 引入) 和 listpack(Redis 5.0 引入)。
quicklist
数据结构: 双向链表+压缩列表,一个quicklist就是一个链表,而链表中的每个节点又是一个压缩列表
quicklist会控制节点结构里的压缩列表的大小或者元素个数,来规避潜在的连锁更新的风险
listpack
作用:替代压缩列表,每个节点不再保存前一个节点的长度,解决了连锁更新问题。
- encoding:定义该元素的编码类型,会对不同长度的整数和字符串进行编码
- data: 实际存放的数据
- len: encoding+data的总长度
使用场景
使用场景:信息流展示 最新文章、最新动态、消息队列(不能持久化)
Hash
KEY-VAULE键值对的映射表,适合用于存储对象。数据结构是数组+链表
使用场景:对象数据存储场景
哈希冲突解决方式:链地址法,被分配到同一个哈希桶上的多个节点可以用这个单向链表连接起来
缺点: 随着链表的长度的增加,查询耗时也会增加。因为我链表的查询时间复杂度是O(n)
解决:当数据逐渐增多,我们需要对哈希表进行扩容,增加桶的数量,通过rehash的方式使链表上的数据分散到不同的桶上,降低装载因子。
rehash操作:
- 创建一个新的哈希表,容量是原哈希表的两倍
- 将原哈希表的数据通过哈希算法迁移到新的哈希表
- 释放哈希表1的空间,把新的哈希表2设置为哈希表1
性能抖动问题;如果哈希表1的数据量非常大,那么在迁移过程中会设计大量的数据拷贝,可能会造成Redis的阻塞 解决方法:
渐进式rehash:数据的迁移工作不再是一次性迁移完成,而是分多次迁移 过程:
- 给哈希表2分配空间
- 在rehash进行期间,每次哈希表元素进行新增、删除、查找或者更新操作时,Redis会同时将哈希表1中索引位置上的所有键值对迁移到哈希表2上
- 随着哈希表操作请求数量越来越多,最终会把哈希表1的键值对全部迁移到哈希表2上。
rehash操作触发条件:
- 当负载因子大于等于1,并且Redis没有在执行RDB快照或者没有进行AOF重写的时候,就会进行rehash操作。
- 当负载因子大于等于5,不管是否进行RDB快照或者AOF重写,强制进行rehash操作
数据结构
哈希表结构
typedef struct dictht {
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值
unsigned long sizemask;
//该哈希表已有的节点数量
unsigned long used;
} dictht;
哈希表节点结构
typedef struct dictEntry {
//键值对中的键
void *key;
//键值对中的值
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
//指向下一个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
Set
Redis中的Set类型是一种无序集合,集合中的元素没有先后顺序但都唯一。可以基于Set轻易实现交集、并集、差集的操作 应用场景:
- 需要存放的数据不能重复的场景 :数据量较小的UV统计、文章点赞、动态点赞
- 需要获取多个数据源交集、并集和差集: 共同好友、粉丝、关注,好友、音乐推荐。
- 需要随机获取数据源中的元素的场景 举例:抽奖系统、随机
Sorted Set
Sorted Set类似于Set,但和Set相比,Soerted Set增加了一个权重参数score,使得集合中的元素能够按score进行有序排列,还可以通过score的范围获取元素的列表
应用场景: 各种排行榜
ZSET的数据结构为跳表和哈希表
好处:既能进行高效的范围查询,也能进行高效单点查询。原因是哈希表查询节点的时间复杂度是O(1)
Zset对象在执行数据插入或是数据更新的过程中,会依次在跳表和哈希表中插入或更新相应的数据,从而保证了跳表和哈希表中记录的信息一致。
数据结构
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
跳表
优点:查询节点的时间复杂度为O(logN)
跳表节点数据结构
typedef struct zskiplistNode {
//Zset 对象的元素值
sds ele;
//元素权重值
double score;
//后向指针,指向前一个节点
struct zskiplistNode *backward;
//节点的level数组,保存每层上的前向指针和跨度,每一个元素代表跳表的一层
struct zskiplistLevel {
//前向指针指向下一个节点
struct zskiplistNode *forward;
//跨度用于记录两个节点之间的距离
unsigned long span;
} level[];
} zskiplistNode;
跨度实际上是为了计算这个节点在跳表的排位,我们只需要从头节点到该节点的查询路径上,将沿途访问过的所有层的跨度累加起来,得到的结果就是目标节点在跳表中的排位。
跳表节点查询过程
- 从头节点的最高层开始,逐一遍历每一层
- 遍历某一个跳表节点时,根据SDS类型的元素和元素的权重判断
- 如果当前节点的权重小于要查找的权重时,跳表就会访问该层上的下一个节点
- 如果当前节点的权重等于要查找的权重时,并且当前节点的SDS类型数据小于要查找的数据时,跳表就会访问该层上的下一个节点
- 如果两个条件都不满足,或者下一个节点为空时,跳表就会使用目前遍历到的节点的level数组里的下一层指针,然后沿着下一层指针继续查找
实例: 查找[元素:abcd,权重:4]的节点
- 从头节点的最高层开始查询,即元素abc 权重 3 当前节点的权重小于要查找的权重,访问下一个节点。下一个节点为null,访问level数组里的下一层指针level[1],即元素[abcde,权重4]
- 当前元素权重等于查找的权重,但是SDS类型数据大于要查找的数据,继续跳到下一层查找,即level[0] 元素[abcd,权重4]
- 查找节点成功,查询结束。
跳表节点层数设置
跳表的相邻两层的节点数量最理想的比例是2:1,查找复杂度可以降低到O(logN)
如何维持相邻两层的节点数量的比例为2:1?
跳表在创建节点时,会生成范围为[0-1]的一个随机数,如果这个随机数小于0.25,层数就增加1层,然后继续生成下一个随机数,直到随机数的结果大于0.25,最终确定该节点的层数。层高最大限制是64
ZSET为什么用跳表而不用平衡树?
- 改变关于节点具有给定级别数的概率,跳表比平衡树占用更少的内存
- Zset经常需要执行ZRANGE或ZREVRANGE的命令,即作为链表遍历跳表,此时跳表的缓存局部性至少与其他类型的平衡树一样好
- 跳表更易实现,调试,跳表比平衡树要简单得多,在做范围查找的时候,跳表比平衡树操作要简单。
Redis线程模型
对于读写命令来说,Redis一直是单线程模型,不过,在Redis4.0版本之后引入了多线程来执行一些大键值对的异步删除操作,Redis6.0版本之后引入了多线程来处理网络请求(提高网络IO读写性能)
Redis基于Reactor模式设计开发了一套高效的事件处理模型,即Redis中的文件事件处理器,由于文件事件处理器是单线程方式运行的,所以我们一般都说Redis是单线程模型。
- 文件事件处理器使用IO多路复用程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器。
- 当被监听的套接字准备好执行连接应答、读取、写入、关闭等操作时,与操作相对应的文件事件就会产生,这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。
虽然文件事件处理器以单线程方式运行,但通过IO多路复用程序来监听多个套接字,文件事件处理器即实现了高性能的网络通信模型,又可以很好地与REDIS服务器中其他以单线程运行地模块对接。这保持了Redis内部单线程设计的简单性。
Redis过期的数据的删除策略
- 惰性删除:只会在取出KEY的时候才检测是否过期,这样对CPU友好,但是会造成大量过期的KEY不能被及时删除
- 定期删除,定期抽取一批KEY检测是否过期,同时Redis底层会通过限制删除操作执行的时长和频率来减少删除操作对CPU时间的影响,定期删除对内存较为友好。Redis采用的是惰性删除+定期删除。但是,仅仅通过给KEY设置过期时间是完全不够的。仍然可能存在大量过期的KEY未被删除,造成OOM现象。所以我们还需要Redis内存淘汰机制
Redis持久化时,对过期键如何处理?
RDB文件分为两个阶段,RDB文件生成阶段和加载阶段
- RDB文件生成阶段:过期的键不会被保存到新的RDB文件重
- RDB加载阶段:
- Redis是主服务器:过期键不会被载入到数据库中
- Redis是从服务器,过期间会被载入到数据库中
AOF文件分为两个阶段,AOF文件写入阶段和AOF重写阶段
- AOF文件写入阶段:过期键未被删除会被写入AOF文件,当过期键被删除,Redis会向AOF文件追加一条DEL命令
- AOF重写阶段: 已过期的键不会被保存到AOF文件中
Redis内存淘汰机制
- 从过期的数据中淘汰
- volatile-LRU:从已设置过期时间的数据集中挑选最近最少的使用的数据淘汰
- volatile-ttl:从已设置过期时间的数据集中挑选将要过期的数据淘汰
- volatile-random:从已设置过期时间的数据集中任意选择数据淘汰
- 从所有数据范围内进行淘汰
- allkeys-lru:当内存不足以容纳新写入数据时,在键空间中,移除最近最少使用的key
- allkeys-random: 从数据集中任意选择数据淘汰
- noeviction:当内存不足时,新写入操作会报错(默认使用)
Redis LRU算法
LRU:淘汰最近最少使用的键值对
Redis实现的是一种近似LRU算法,目的是为了更好的节约内存,实现方式是在Redis的对象结构体中添加一个额外的字段,用于记录此数据的最后一次访问时间。
当Redis进行内存淘汰时,会使用随机采样的方式来淘汰数据,随机取n个值,淘汰最久没有使用的那个。
Redis实现LRU算法优点:
- 不用为所有的数据维护一个链表,节省了空间
- 不用在每次数据访问时移动链表,提升缓存性能
问题: 缓存污染问题,应用一次读取了大量缓存数据,而这些数据只会被读取这一样,那么这些数据会留存在Redis缓存中很长一段时间,造成缓存污染。
Redis LFU算法
LFU:淘汰最近最不常用的键值对,根据使用次数进行淘汰
Redis实现: LFU算法相比于LRU算法的实现多记录了数据的访问频次的信息。
Redis对象头中有一个lru字段,在LRU算法下和LFU算法下使用方式并不相同。
- LRU算法: 字段用来记录key的访问时间戳,因此Redis可以根据时间戳淘汰最久未被使用的key
- LFU算法:Redis对象头的24bits的lru字段被分成两段来存储,高16bit存储key的访问时间戳,低8bit存储访问次数
Redis持久化机制
怎么保证Redis挂掉之后再重启数据可以进行恢复?
使用REDIS持久化机制:RDB(快照)和AOF(只追加文件)
RDB持久化
Redis可以通过创建快照来获得存储在内存里面的数据在某个时间点上的副本。Redis创建快照之后,可以对快照进行备份,可以将快照复制到其他服务器从而搭建Redis主从结构提高Redis性能,还可以将快照留在原地以便重启恢复
RDB持久化是Redis默认的持久化方式
RDB使用save创建快照会阻塞主线程,而是用bgsave是子线程执行,不会阻塞主线程(默认)
AOF持久化
Redis默认没有开启AOF,需要通过appendonly参数开启,开启AOF持久化后Redis会将每一条更改命令写入内存缓存中,在根据appendfsync配置决定合适将器同步到硬盘中。
三种appendfsync配置:
always: 每次有数据修改就立即写入硬盘,性能较低
everysec: 每秒同步内存缓存到硬盘,兼顾性能和安全
no: 让操作系统决定何时进行同步
如何实现AOF日志?
关系型数据库(Mysql)通常都是执行命令之前记录日志,方便故障恢复,而Redis AOF持久化机制是在执行完命令之后再记录日志。
为什么是执行完命令后记录日志?
- 避免额外的检查开销,AOF记录日志不会对命令进行语法检查
- 在命令执行完之后再记录,不会阻塞当前的命令执行
缺点: 如果刚执行完命令Redis就宕机会导致对应的修改丢失 ,可能会阻塞后续其他命令的执行(AOF记录日志在Redis主线程中进行)
AOF重写
当AOF变得太大,Reids会在后台自动重写一个新的AOF文件,新文件与原文件状态一样,但是体积更小。Redis会维护一个AOF重写缓冲区,该缓冲区会在子进程创建新AOF文件期间,记录服务器所执行的命令。当AOF重写完成后,会将该缓冲区的内容追加到新AOF文件中,使得新AOF文件的状态与数据库状态保持一致。最后会将新AOF文件替换掉旧AOF文件。
使用子进程进行AOF重写
好处:
- 避免阻塞主进程
- 如果使用线程进行重写,而内存之间可以共享内存,此时我们需要加锁保证数据安全,性能低下。子进程不用加锁,性能较高。
写时复制:在父进程或者子进程对共享内存进行操作的时候,操作系统才会去复制物理内存。
好处:防止fork创建子进程时,由于物理内存数据的复制时间过长而导致父进程长时间阻塞的问题。
当我们进行写时复制的时候也会阻塞父进程
RDB快照
RDB快照就是记录某一个瞬间的内存数据,记录的是实际数据。而AOF文件记录的是命令操作
- RDB快照时会阻塞线程
- Redis的快照是全量快照,每次执行快照会把内存中的所有数据都记录到磁盘中,所以执行快照是一个比较重的操作,不能操作太频繁。
- Redis在执行快照期间,数据仍然可以被修改
RDB和AOF比较
- RDB存储的内容是经过压缩的二进制数据,文件小,适合做数据的备份。AOF文件存储的是每一次写命令,文件较大。同时如果在AOF重写期间有写入命令,AOF可能会使用大量内存。重写期间每个命令都会写入磁盘两次。
- 使用RDB恢复数据的速度比AOF快,RDB直接解析还原数据,AOF需要依次执行每个命令
- RDB的数据安全性不如AOF,没有办法实时或者秒级持久化数据。同时RDB在写入RDB文件时会对机器的CPU资源和内存资源产生影响。
- AOF以一种易于理解和解析的格式包含所有操作的日志,我们可以导出AOF文件进行分析。
Redis4.0对于持久化机制的优化
由于RDB和AOF各有优势,于是Redis4.0开始支持RDB和AOF的混合持久化。如果打开混合持久化,AOF重写的时候就直接把RDB的内容写到AOF文件开头。这样做的好处是可以结合RDB和AOF的优点,快速加载同时避免丢失过多的数据。当然缺点也是有的:AOF里面的RDB部分是压缩格式不再是AOF格式,可读性较差。
Redis事务
MULTI开启事务 命令入队(先进先出FIFO) 执行事务(EXEC) 取消事务 (DISCARD) 监听指定的KEY是否被其他客户端修改当调用EXEC命令执行事务如果一个被监听的KEY被其他客户端修改,整个事务都不会执行(WATCH)
Redis事务特性
- 原子性:事务是执行的最小单位,一个事务的动作要么全部完成,要么都不完成
- 一致性: 事务执行前后的数据是保持一致的,多个事务对同一个数据读取的结果是相同的。
- 隔离性: 并发访问数据库时,一个用户的事务不会被其他事务干扰,各并发事务之间是独立的。
- 持久性: 一个事务被提交之后对数据的修改是持久的保存在硬盘。即使数据库发生故障也不会对事务有任何影响。
Redis事务在运行错误的情况下,除了执行过程中出现错误的命令外,其他命令都能正常执行。同时Redis不支持回滚操作,所以Redis事务不满足原子性。因为Redis是内存数据库,如果事务提交后没有来得及持久化就宕机了,那就不满足持久性。
Redis不支持事务回滚的原因: 简单便捷,性能较高。即使命令执行错误也应该在开发过程中发现,而不应该是生产过程。
同时Redis事务中的每条命令都会与Redis服务器进行网络交互,无法进行批处理操作,这是比较浪费资源的行为。因此Redis事务是不建议在日常开发中使用的。
如何解决事务的缺陷?
Redis执行LUA脚本可以保证命令的原子性,且LUA脚本可以执行多个REDIS指令,这些REDIS命令会被提交到Redis服务器一次性执行完成,大幅减少网络开销。
Redis性能优化
Redis bigkey
定义:
- string类型的value超过10kb
- 复合类型的value包含的元素超过5000个即被定义为大KEY
大key不仅会消耗更多的内存空间,同时对性能也有很大影响 - 客户端超时阻塞,操作大key时会比较耗时,那么就会阻塞Redis
- 引发网络阻塞,每次获取大key产生的网络流量较大。
- 阻塞工作线程,如果使用del删除大key,会阻塞工作线程,所以我们在删除大key时可以采用unlink命令异步删除
- 内存分布不均,集群模型在slot分片均匀情况下,会出现数据和查询倾斜情况,部分有大key的Redis节点占用内存多。
- 当我们用AOF重写以及Always策略的时候,主线程在同步调用fsync函数写入到硬盘的耗时就会比较大,但是使用EverySec 策略异步调用fsync函数就不会有该问题。
- 当我们使用AOF重写或者RDB快照通过fork()函数创建子进程,大KEY就会造成页表变得很大,那么这个复制过程耗时就会增加
- 持久化过程中父进程或者子进程发生写时复制时,会把物理内存复制一份,由于大KEY占用的物理内存是比较大的,所以复制的耗时就会比较长,造成主进程阻塞
如何发现大key 使用redis自带的
--bigkeys命令或者在RDB持久化条件下分析RDB文件
大量key集中过期问题(缓存雪崩)
对于过期key定期删除过程中,如果遇到大量过期的key,客户端请求必须等待定期清理过期key任务线程执行完成,因为或者定期任务线程是在主线程完成的,这会导致客户端请求没办法被及时处理。
解决方法:
1.给key设置随机过期时间
2.开启lazy-free,让redis采用异步方式延迟释放key使用的内存,将该操作交给单独的子线程处理,避免阻塞主线程
缓存穿透
定义: 大量请求的KEY不存在与缓存中,导致请求直接到了数据库上,根本没有经过缓存这一层。导致大量请求落到数据库。例子: 黑客故意制造我们缓存数据库中不存在的key发起大量请求。
解决方法:
- 参数校验,一些不合法的参数直接抛出异常信息返回客户端
- 缓存无效KEY:如果缓存和数据库都查不到某个key的数据,就写一个到Redis并设置过期时间。这不是一个好的方案,因为黑客可能会伪造不同的key
- 使用布隆过滤器: 把所有可能存在的请求的值都存放在布隆过滤器,当用户请求过来,先判断用户发来的请求的值是否存在于布隆过滤器。不存在的话,直接返回错误给客户端
如何保证缓存和数据库数据的一致性?
我们一般很难做到缓存和数据库的强一致性,所以当我们考虑使用缓存的话,前提是我们不要求对数据的强一致性。 方案:
-
先更新缓存,再更新数据库:
-
先更新数据库,再更新缓存;
-
先删除缓存,再更新数据库; 这三种方法都有可能因为第二步操作失败或者高并发情况下不能保证缓存和数据库操作的原子性,导致数据不一致问题。
-
先更新数据库,再删除缓存: 该方案,在极端情况下仍会发生数据不一致,但是发生数据不一致的概率是最低的。因此我们应该使用该种方案。
缓存和数据库一致性问题,看这篇就够了 - 水滴与银弹
如何保证两步都执行成功?
1.使用重试,但是重试可能会一直占用线程资源,所以我们应该使用消息队列异步重试 2.订阅数据库变更日志,再操作缓存。(推荐)
如何保证高并发和主从库延迟的数据一致性?
使用延迟双删方案:删除缓存后,先休眠一会,再删除一次缓存或者删除缓存后生成一条延时消息,写到消息队列中,消费者延时删除缓存。
Redis实现延迟队列
通过Zset实现,使用zadd命令可以一直往内存中生产消息,利用zrangebyscore查询符合条件的所有待处理的任务,通过循环执行队列任务即可。