Redis-总结
Redis总结
1. 基本概念
1.1 理论
1.1.1 CAP理论
CAP定理(CAP theorem),又被称作布鲁尔定理(Brewer's theorem),它指出对于一个分布式计算系统来说,不可能同时满足以下三点:
- 一致性(Consistency) (等同于所有节点访问同一份最新的数据副本)
- 可用性(Availability)(每次请求都能获取到非错的响应——但是不保证获取的数据为最新数据)
- 分区容错性(Partition tolerance)(系统如果不能在时限内达成数据一致性,就意味着发生了分区的情况,必须就当前操作在C和A之间做出选择[3]。)
下面对这三个性质做出解释
1️⃣ 一致性 C
也可以理解为原子性,代表持久化到数据库中的数据必定是最新且正确的。
对于一般的单点Mysql数据库,由于不存在分布式系统且有着ACID特性的支持,一般都具有较好的一致性
2️⃣ 可用性 A
在高并发的场景下,如果可以提供最有效但不一定是最新的数据,就代表支持可用性
3️⃣ 分区容错性 P
分区容错性一般指一个大的分布式系统对外表现为一个整体的系统,但是内部有许多分区存在,为了保证在某个节点宕机后仍然能查询到数据,往往会把一份数据拷贝到多个节点中,那么向该系统发出一次请求时,如果恰好对应节点宕机,如果能在足够短的时间内从别的节点中查询到数据,那么就是具有分区容错性的,否则就不支持。
从上面可以看出,分布式系统只能满足三项中的两项而不可能满足全部三项
如果满足CA,那么代表是一个单点集群系统,可以支持强一致性和可用性,在分布式系统下,不可能保证每个节点都是CA的,所以不满足P--Mysql
如果满足AP,那么代表是一个高可用且分布式的系统,在这种情况下,为了保证高可用,一般是将一条信息在多个节点中存储大量的拷贝,这样就可以实现高可用,但是对于大量副本的情况下,很难保证一致性,因而牺牲了C
如果满足CP,那么代表是一个强一致性的分布式系统,在这种情况下,为了保证强一致性,就会尽量减少副本数量且保证每个副本的数据都是最新且正确的,但这样就不可能达到高可用,在保证一致性的过程中就牺牲了大量的效率 -- redis/mongodb
1.1.2 BASE理论
BASE就是为了解决关系数据库强一致性引起的问题而引起的可用性降低而提出的解决方案。
BASE即: 基本可用(Basically Available) 软状态(Soft state) 最终一致(Eventually consistent)它的思想是通过让系统放松对某一时刻数据一致性的要求来换取系统整体伸缩性和性能上改观。由于大型系统往往由于地域分布和极高性能的要求,不可能采用分布式事务来完成这些指标,要想获得这些指标,最佳准则就是满足BASE的要求
1.1.3 趋势
为了更好的应对高并发的场景,一般都是保证AP,引入了最终一致性的概念。
但是对于Redis来说,其存在的目的是为了降低Mysql的读写压力以及提高访问其他非关系型数据的访问效率,一般存在于DAO和Mysql之间,且必须要保证数据是准确的,因而必须要保证是CP的。
1.2 架构
1.2.1 数据库
基于内存的NoSql,默认有16个db,采用select 0/15的方式切换db
1.2.2 单线程
1)定义
Redis4中执行命令的模块采用单线程的方式处理客户端请求,采用IO多路复用的方式(类似于epoll)来监听多个套接字,最终将被激活的socket对应事件交由文件事件分配器分派给对应事件处理器来执行,如下图所示
2)优劣
- 优
- 易维护
- 不需要考虑多线程并发的问题
- 性能瓶颈在网络IO和内存,不在CPU
- 采用I/O多路复用足以应对并发请求
- 劣
- 采用单线程处理消息队列会降低IO效率,redis6中引入多线程解决了此问题
- 在删除较大的键值对时,如果同步删除会引来较大延迟,redis4之后引入了异步的删除操作,如UNLINK等
1.3 数据结构
k-v键值对的方式存储
k固定为字符串,采用字典(hashtable)的形式存储,每个key对应两张hashtable,为的是适应渐进式hash分批进行hash的特性
val可以为五种形式
1.3.1 string
底层采用SDS(Simple Dynamic String)进行存储,是一个结构体,如下
typedef char *sds;
struct sdshdr {
int len;
int free;
char buf[];
};
-
char数组
-
预分配
会提前分配一部分内存,以防append时带来内存重分配的开销
-
懒回收
在delete一些字符时,不会立马回收内存,而是保留内存空间,避免内存重分配
-
二进制安全
尾部固定为'/0',因而可以向buf中写入任意类型字符,因为最终都会有一个冗余的'/0'标识结束
-
-
len
使得取字符串长度的复杂度为O(1)
-
free
获取空闲的长度
1.3.2list 链表
底层可采用ziplist或linkedlist实现,内容较少时使用ziplist,链表较长、节点较大时使用linkedlist
-
ziplist
ziplist中每个节点为entry,下图为entry的结构
area |<------------------- entry -------------------->| +------------------+----------+--------+---------+ component | pre_entry_length | encoding | length | content | +------------------+----------+--------+---------+
下图为ziplist的结构
area |<---- ziplist header ---->|<----------- entries ------------->|<-end->| size 4 bytes 4 bytes 2 bytes ? ? ? ? 1 byte +---------+--------+-------+--------+--------+--------+--------+-------+ component | zlbytes | zltail | zllen | entry1 | entry2 | ... | entryN | zlend | +---------+--------+-------+--------+--------+--------+--------+-------+ ^ ^ ^ address | | | ZIPLIST_ENTRY_HEAD | ZIPLIST_ENTRY_END | ZIPLIST_ENTRY_TAIL
由上可见,ziplist有如下特点
- 分布在一块连续的内存空间上,内存起始区域存储了链表的长度,首节点地址,尾节点地址
- 每个节点存储了前一个节点的长度以及本节点的长度,因而可以实现双向遍历,但是不可随机访问
- 由于在连续的内存空间上,因而遍历效率远高于双端链表
-
linkedlist
普通双端链表
1.3.3 hash hashMap
1)结构
可以采用ziplist或字典实现,字典是一个结构体,如下所示
//字典
typedef struct dict {
// 特定于类型的处理函数
dictType *type;
// 类型处理函数的私有数据
void *privdata;
// 哈希表(2 个)
dictht ht[2];
// 记录 rehash 进度的标志,值为 -1 表示 rehash 未进行
int rehashidx;
// 当前正在运作的安全迭代器数量
int iterators;
} dict;
其包含两个dictht(dictHashTable),一个rehashIdx,dictht结构如下
//哈希表
typedef struct dictht {
// 哈希表节点指针数组(俗称桶,bucket)
dictEntry **table;//**可用于指代*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;
} v;
// 链往后继节点
struct dictEntry *next;
} dictEntry;
如下图所示,可见,同样采用拉链法来解决hash冲突
2)渐进式rehash
(1)负载因子
load_factor = hash表中已有的entry个数 / hash表的长度
-
扩容
在bgsave和bgrewriteaof
-
开启时
当load_factor大于5时,触发扩容
-
未开启时
当load_factor大于1时,触发扩容
-
-
缩容
当load_factor小于0.1时,触发缩容
(2)rehash流程
-
_dictRehashStep
在每次get/set时触发,被动rehash
-
dictRehashMilliseconds
由 Redis 服务器常规任务程序(server cron job)执行,主动rehash
在未扩容前,只使用table[0],当扩容时,会发生渐进式rehash操作,将table[1]扩容两倍,每次只rehash一部分table[0]的数据,
-
对于在table[0]中的数据
当使用或修改table[0]的数据时会触发rehash,之后删除table[0]中的记录,在table[1]中插入新记录,
-
对于新添加的数值
直接rehash到table[1],当rehash完成时,交换两者地址
1.3.4 set hashset
可采用整数集合或字典实现,整数集合是一个int类型的数组
1.3.5 zset treeset
可采用ziplist或skiplist,skiplist是跳表结构,
-
插入
先插入最底一层位置,之后决定是否上浮创建镜像节点,
-
查询
从最高层开始遍历,如果当前层未命中且遇到了更大的值,则下潜,设每层需要查m个点,每两个点建一个上浮点,那么总层高为log2n,总查询复杂度为O(mlog2n),由于m不超过3,简化为O(log2n),假设每三个点创建则近似O(log3n),但是会增加m
2.常见问题
2.1 内存过期与淘汰
2.1.1 内存过期删除机制
可以为key设置过期时间,这样在时间到时,就会从缓存中删除此数据;过期字典中记录各个key的过期时间
具体的删除策略可以有下面三种
-
定时
需要在key过期的第一时间通知CPU进行删除,这样会造成CPU忙碌,对CPU不友好,对内存友好
-
懒删除
key过期时不第一时间删除,而是在下一次get、set等操作之前,检查要操作的key是否过期,如果是则删除,这种方式对CPU友好,对内存不友好
-
定期
周期性的遍历expire字典,删除其中过期的keys,周期的大小决定时CPU友好还是内存友好
redis中采用懒删除+定期的策略,从而优化了定时策略的CPU忙碌以及懒删除策略的内存占用
2.2.2 内存淘汰机制
redis通过maxmemory设定最大内存空间,在内存即将溢出时,有如下maxmemory-policy
-
设置了过期时间的keys
- lru volatile-lru
- random volatile-random
- ttl 即将过期 volatile-ttl
-
所有keys
- lru allkeys-lru
- random allkeys-random
-
no-eviction
不进行淘汰,直接返回错误
2.2 持久化
2.2.1 rdb
大规模数据恢复+弱一致性
默认的方式,通过在内存中备份redis的数据库快照(这样短时间内,内存中有两份一样的数据),并定期将快照写入磁盘的方式,写入的周期如下
- save 900 1 #超过900s且有至少1个key变动,则触发保存---长期少量
- save 300 10 #超过300s且有至少10个key变动,则触发保存---中期中量
- save 60 10000 #超过60s且有至少10000个key变动,则触发保存---短期大量
或者通过手动触发的方式
-
save
通过save会立即阻塞所有操作并进行rdb文件的持久化
-
bgsave
通过bgsave创建子进程,可以在后台异步的完成rdb文件持久化,可以通过lastsave获取最近一次的保存时间
-
flushall
即使是清空所有数据库,也会触发一次rdb文件的持久化,只不过为空
2.2.2 aof
rdb辅助,强一致性
可选方式,作为rdb的补充,执行时先把数据写入缓存,再把每个写指令写入aof文件中,写入时只允许在尾部追加
同步aof_buf到磁盘上的aof文件的可选取周期如下
- appendfsync always #每次有数据修改发生时都会写入AOF文件,这样会严重降低Redis的速度
- appendfsync everysec #每秒钟同步一次,显示地将多个写命令同步到硬盘
- appendfsync no #让操作系统决定何时进行同步
aof过大时,会触发重写瘦身机制,通过对redis中存储的数据分析,逆向分析出存储这些数据最少需要执行的命令,并写入到新的aof文件中,之后覆盖原文件
也可以采用bgrewriteaof指令手动fork子进程来负责aof重写
redis的aof是写后日志,也即先写入缓存,再写入aof
mysql的redolog是写前日志,也即先写入log,再写入磁盘,这样就需要在写入log前对指令合法性做校验
2.2.3 持久化与fork
对于rdb的后台持久化和aof的子进程重写压缩,都是采用创建子进程的方式来完成的,也即使用fork()
,fork时CopyOnWrite
的特性如下
- 父子均占用完整的虚拟内存空间,且页表内容一致,共享同一片物理内存,且均为只读权限
- 当父子任意一方对内存数据进行修改时,进行写时复制:检测到页面是只读,触发缺页中断,这时会复制一份页面,这样父子就拥有两个独立的页面
那么对于持久化来讲,fork为其带来的下面好处
- 子进程避免复制全量的父进程内存空间,只需要复制页表
- 触发持久化本就是一个当前快照序列化的操作,fork满足这一特点
- 子进程只做读操作,很少会触发cow
但是,fork对于bgrewriteaof带来一些问题,由于在子进程创建新的aof文件期间,父进程可能会执行一些新的写操作,这时对子进程不可见的。因而redis提供了一个aof缓冲区,将这期间发生的新的写操作命令写入到aof重写缓冲区中,这样,在子进程返回时,父进程就可以将aof缓冲区中的命令追加到子进程创建的新的aof文件后,之后再把旧的aof文件覆盖,就可以保证aof压缩的同时可以不丢失新的写操作
此外,在持久化期间,如果进行rehash,就不可避免的会发生较多写操作,也因此,load_factor会调高至5
2.2.4 恢复策略
一般来讲,用rdb作为冷备及主从复制的依据,aof作为宕机后恢复的依据
Redis4.0之后,引入了混合持久化的概念,其是aof重写时的一种新策略,在上一节中,使用bgrewriteaof会导致父进程的写操作对子进程不可见,此时提供了aof缓冲区来记录重写期间出现的新的写操作。那么对于混合持久化来说,子进程中重写aof文件时,是以rdb的形式进行记录,在子进程返回后,再把aof缓冲区中的记录按照aof的格式追加到新的aof文件后,这样便减少了aof文件的大小,但是也降低了aof文件的可读性
2.3 事务
2.3.1 MULTI
打开命令缓存队列,之后输入的命令均会放入队列中,之后通过EXEC命令一次性执行队列中的命令,如果
- 有语法错误的命令,则整个队列都撤销
- 出现了命令与操作数不匹配(对string做INCR),则只撤销出现此类问题的命令
2.3.2 WATCH
对某个字段打开监控(上乐观锁),通过此方式,可以达到事务全部回滚的特性,使用时,首先客户端AWATCH K1,K1即为要监视的对象,之后使用MULTI打开队列,如果此时其他客户端对K1进行修改,那么如果队列中
-
涉及到关于K1的操作,整个队列中的命令都被撤销
-
否则,队列不被撤销
2.4 扩容
2.4.1 主从(垂直扩容-灾备/读写分离)
1)定义
通过在redis-2上调用salveof redis-1的IP 端口,即可实现手动设置主从关系(保持到下一次重启),这样主机会将rdb文件拷贝到从机,从而实现与主机内容同步。主从方式不会"真正"增加可以存储的数据,只是提高了主机的可靠性且可以将读压力转移到从机上
2)实现
设置主从关系时,一个机器只能有一个主机,一个主机可以有多个从机,可以设计多级slave,来分担同步压力
2.4.2 集群(水平扩容)
1)插槽
redis集群默认使用插槽的方式来管理分布式存储,其工作机制如下
1️⃣ redis集群整体共有16384个插槽,根据集群中主节点数量,来平分管理插槽
2️⃣ 当我们存取key的时候,Redis会根据CRC16的算法得出一个结果,然后把结果对16384求余数,这样每个key都会对应一个编号在0-16383之间的哈希槽,通过这个值,去找到对应的插槽所对应的节点,然后直接自动跳转到这个对应的节点上进行存取操作
3️⃣ 为了保证高可用,Cluster模式也引入主从复制模式,一个主节点对应一个或者多个从节点,当主节点宕机的时候,就会启用从节点
4️⃣ 当其它节点ping一个节点A时,如果半数以上的节点与A通信超时,那么认为节点A宕机了。如果节点A和它的从节点都宕机了,那么该集群就无法再提供服务了
2)一致性hash
(1)背景
在redis3.0之前,没有默认的集群模式,想要实现redis集群,通常采用一致性hash来管理分布式存储,支持集群中节点数量的弹性变化
(2)定义
一致性hash提供了很大的取值空间,其实现流程如下
-
将存储节点映射到环上
通过给定一个取值空间非常大的hash环,通常为[0,2^32-1],先把节点的主机名取hash值,映射到hash环上,假设有三个节点a,b,c顺时针方向分布在环上,那么从a->b这区间的值都由b来存储
-
解决数据倾斜
假设多个节点在环上的分布很密,那么会产生某些节点存储数据量非常大,某些存储的数据量非常小的问题,为了解决此问题,通过创建虚拟节点,来使得分布更加均匀。假设存在节点a,b,c,为了解决数据倾斜,可以为每个节点增加两个虚拟节点,并映射到环上,将环进一步分割,使得每个节点的存储的数据量更加平均
(3)用途
一致性hash最大的特点为
- 有很大的取值空间
- 由不同节点来切割环
那么对于一个redis集群,就很适合集群中出现的下列状况
-
集群增加节点
只需要在环上映射一些节点来进一步切割环,即可完成数据迁移。假设顺时针方向为a->b,当前在a,b之间插入c,那么顺时针方向为a->c->b,此时可以将原来a到c之间存储在b的数据迁移到c即可
-
集群删除节点
只需要将环上的节点删除,即可完成数据迁移。假设顺时针方向为a->b->c,那么此时删除b,那么就需要将原来存储在b的a->b之间的数据迁移到c即可
集群增加和删除节点时,可能会因为大量的数据迁移导致可用性降低
(4)与传统hash对比
假设存在一个3个节点的集群,存在三个值a,b,c,使用hash(x)%3
来确定存放到哪个节点,a,b,c分别存在三个不同的节点上,此时去掉一个节点,就需要对a,b,c进行rehash,因为模值变了
而采用hash环,最终落到hash环的位置是固定的,也即模值不会发生变化,这样就可以更加灵活的应对集群内增加节点及下线节点的情况
2.5 IO多路复用
参见IO多路复用 - Linus1 - 博客园 (cnblogs.com)
redis中,采用IO多路复用模型,构建了多路复用器,从而可以在单线程处理并发的IO请求
2.6 失效
2.6.1 缓存穿透
1)定义
一般情况下,要访问的数据不在redis中时,就会到mysql中去获取,若有人恶意捏造根本不存在的数据请求,则会为mysql带来巨大压力,因而需要对传来的请求进行数据校验,保证其合法性
2)布隆过滤器
(1)背景
判断一个值是否存在于某集合,如缓存穿透,最直观的方式是记录所有合法的信息,之后校验请求值是否存在于集合中,常见的数据结构为hashset,但这种方式在数据量较大时,会带来很大的空间复杂度,为此布隆提出一种占用空间更小的数据结构
(2)定义
布隆过滤器有两个关键组成部分
-
位图
是一个一维数组,每一位只有0,1两种取值
-
一组hash函数
多个hash函数,用于计算hash值
(3)操作
将位图初始化为0
- 插入
- 对于值a,使用多个hash函数计算出多个输出值
- 根据这一组输出值,将位图中所有对应位置置为
1
- 查询
- 对于值a,使用多个hash函数计算出多个输出值
- 根据这一组输出值,查看对应位置是否有
0
,如果有则代表值a不存在于集合中,否则,可能存在,返回true
由上可见,布隆过滤器为了压缩空间,做出了如下改进
-
多组hash函数
单个hash函数带来的hash碰撞域是很大的,通过提供多个hash函数,从而使得碰撞域减少
-
位图
位图用于记录hash值是否存在,用多个hash值是否存在来判断原数值是否存在
(4)缺点
-
误判
尽管使用多个hash函数,仍然存在hash碰撞的可能,且随着数值增多,碰撞会加剧
-
不支持删除
同样囿于hash碰撞,删除某个值对应的所有hash值时,可能会影响到其他值也被删除,大大降低判断准确度
2.6.2 缓存雪崩
redis中的数据大面积失效,这时大量的请求导向mysql,为mysql带来巨大压力,这时有两种解决手段
-
提高可靠性
主从复制,或者集群多点备份数据
-
降流
采用消息队列缓存请求,避免冲击mysql
2.7 与memcached对比
主要有如下几点
- memcached的value只支持string
- memcached不能持久化
# 参考
Redis | CS-Notes (cyc2018.xyz)
为什么 Redis 选择单线程模型 - 面向信仰编程 (draveness.me)
为什么 Redis 快照使用子进程 - 面向信仰编程 (draveness.me)