持久化
三种方式实现它的持久化:
RDB持久化
全称Redis数据备份文件,又称Redis数据快照
这种就是将Redis内存中所有数据记录到磁盘中,当实例出故障后,从磁盘中读快照文件进行恢复数据。
一般使用bgsave指令实现
复制主线程得到一个子进程,共享同一内存空间
子进程读取Redis内存数据,并写入一个新RDB文件
最后,用新的RDB文件替代旧的
可以配置:save 60 100
代表60s内至少执行100次触发RDB
缺点
RDB执行间隔长,两次RDB之间写有数据丢失风险
fork子进程、压缩、写RDB文件都耗时
AOF持久化
全称追加文件
Redis处理的每一个写命令都记录在AOF文件(类似操作日志文件)
配置
默认关闭,修改redis.conf开启
记录频率的三种方式
always(立刻记录)
everysec(每隔一秒记录)
no(由操作系统决定)
混合持久化
结合了 RDB 和 AOF 持久化的优点,开头为 RDB 的格式,使得 Redis 可以更快的启动,同时结合 AOF 的优点,有减低了大量数据丢失的风险。
缺点是可读性差,兼容性差
Redis主从架构
搭建主从集群,实现读写分离
全量同步:master将完整的内存数据生成RDB,发送RDB到slave后。后续命令则记录在repl_baklog,逐个发送给slave
增量同步:slave提交自己的offset到master,master获取repl_backlog中从offset之后命令给slave
主从数据同步流程
slave节点请求增量同步
master判断replid ,如果不一致拒绝增量
slave情况数据,加载master的RDB
master将RDB期间的命令记录在repl_baklog,并持续将log中的命令发送给slave
slave执行接收到的命令,并与master保持同步
什么时候执行全量同步
slave节点第一次连接master的时候
slave节点断开时间太久,repl_baklog中的offset已经被覆盖的时候
Redis哨兵
哨兵是Redis提供的一种利用心跳机制监听主节点是否存活的机制。
如果主节点挂了,通过判断slave的slave-priority选举一个新的。
分片集群
集群中多个master,每个master保持不同数据。
就是将多个Redis组合成了一个大的。
Redis会把每个master节点映射到0-16383供16384个插槽上(hash slot),查看集群信息就能看到。
Redis如何判断某个key应该在哪个实例?
-
将16384个插槽分配到不同的实例中
-
根据key的有效部分计算hash值,对16384取余
-
余数作为插槽,寻找插槽所在实例
多级缓存
JVM进程缓存
使用Caffeine,利用JVM的进程缓存。
Caffeine 的3中缓存淘汰策略:
基于容量
基于时间
基于对象引用。
设置对象为 软引用或弱引用,利用GC来回收缓存数据。性能较差,不建议使用。
Nginx-反向代理缓存
在服务端,可以利用反向代理(如Nginx)设置本地缓存。当请求到达Nginx时,它会首先检查本地是否有请求的数据,如果有则直接返回,避免了到达应用服务器的请求。
Canal
使用Canal实现数据一致性
Redis键值设计
一般约定:
-
基本格式:[业务名称]:[数据名]:[id]
-
长度不超过44字节
-
不包括特殊字符
拒绝BigKey,
key本身数据量过大:一个string类型的key,值为5MB
key中成员数过多:一个zset类型的key,成员数量为:10000个
key中成员数据量过大:一个hash类型的key,成员数量虽只有1000个,但这个value的总大小为100MB
推荐key值:
单个key的value小于10KB
对于集合类型的key,建议元素数量为小于1000
bigKey危害:
执行对BigKey读的时候,少量的QPS可能导致带宽使用占满
导致数据倾斜,bigkey所在Redis实例内存远超其他实例
可能运算耗时过久,导致主线程阻塞
批处理优化
-
Mset(处理数据类型有限)
-
mset
-
hmset
-
-
Pipeline(可以对复制数据类型的批处理需要)
@Test void testPipeline(){创建管道 Pipeline pipeline = jedis.pipelined(); for(int i=1;i <= 100000; i++){ //放入命令到管道 pipeline.set("test:key_" + i,"value_"+i); if(i % 1000 == 0){ //批量执行 pipeline.sync(); } } }
集群下数据处理
使用Spring提供的stringRedisTemplate就行,底层使用的是并行slot
Redis底层数据结构
动态字符串SDS
具备自动扩容的能力。
结构:
len:字符串字节数
alloc:申请的总字节数
flags:不同的SDS头类型,用来控制头的大小
buf:具体的字符串
申请空间策略:
-
当新字符串小于1M,则新空间扩展后为字符串长度的两倍+1
-
当新字符串大于1M,则新空间为扩展后字符串长度 + 1M +1。(称为内存预分配)
SDS是一种由C语言实现的动态字符串,具有空间预分配和惰性释放空间的特点
IntSet
-
Inset是Redis中set集合的一种实现方式
-
基于整形数组来实现,并具备长度可变、有序等特性。(适合使用于数据量不多的情况)
结构:
encoding:编码方式
length:元素个数
contents[]:整数数组,保存集合数据
总结:
-
Redis确保Intset元素唯一、有序
-
具备类型升级机制(升级编码方式到合适大小),可省内存空间
-
底层采用二分查询
intset是一种元素全部都是整形的set集合,具有对新增数据判断升级、底层采用二叉查找数据、保证数据有序且唯一的特点
Dict
实现了键值的映射关系
组成部分:哈希表(dictht:数组+链表)、哈希节点(DictEntry)、字典(Dict)
dictEntry结构:
key:键
v:值
struct dictEntry *next:下一个Entry指针
dictht(dictHashTable)结构:
dictEntry **table:entry类型数组
size:哈希表大小
sizemask:哈希表大小的掩码,总等于:size-1
used:entry个数
dict结构:
type:dict类型,内置不同的hash函数
privdate:私有数据,做特殊hash运算使用
ht[2]:包含两个哈希表,一个是当前数据,另一个是空,rehash使用
rehashidx:rehash进度,-1为未进行
pauserehash:rehash是否暂停,1暂停,0继续
扩容条件
因为dict中的hashtable是由数组+单向链表实现。当元素过多的时候,会导致哈希冲突;而且链表过长,影响查询效率,所以这个时候就需要扩容了。
LoadFactor=used(所有的entry)/size(哈希表大小-1)
下面这两种情况会扩容:
LoadFactor >= 1,并服务器没有执行bgsave / bgrewriteaof等后台进行时候
LoadFactor > 5
每次扩容到2^n
Dict收缩
LoadFactor < 0.1时,进行收缩
每次收缩到2^n,但是必须大于 4
rehash
-
因为我们知道扩容还是收缩都要创建一个新的哈希表,但是这会导致size和sizemask变化。
-
所以必须对哈希表中每个key重新计算索引,插入新的哈希表中,这个过程就叫rehash
rehash过程:
计算哈希表大小 realeSize(应分配的哈希表大小)
扩容:realeSize为 大于等于dict.ht[0].used + 1的新的2^n
收缩:同上,但是是小于等于,但不能小于4
申请内存空间并创建一个新dictht
设置dict.rehashidx=0,标志rehash的开始
重新对旧hash表中的数据进行hash求值,并复制到新hash表中
切换hash表,并释放旧哈希表中的内存空间
渐进式rehash:(因为一次性搬迁哈希表可能出现阻塞情况,所以可以采用渐进式,将一步分为多步实现)
渐进式rehash相对普通的rehash而言就是,在数据迁移的时候,rehash是一次性的操作,而渐进式rehash是通过客户端请求过程中同时进行迁移操作
在渐进式rehash搬迁数据的时候,如果要查询数据,是对两个hash表中进行查询
ziplist
ziplist是一种类似双端队列的设计,可以在头尾两端进行加/减元素操作,然后他存储的方式是一种连续内存的方式,当前元素记录上一个元素的长度,可以根据元素的type类型区分是字符串还是数字,然后选择是全数字的ziplist还是全字符串的。
ziplist结构:
zlbytes:记录整个压缩列表占用内存字节数
zltail:记录压缩列表表尾部节点距离压缩列表的起始地址有多少字节。通过这个偏移量,可以确定表尾节点的地址。
zlen:记录了压缩列表包含的节点数量。
entry:压缩列表包含的各个节点,节点的长度由字节保存的内容决定。
zlend:特殊值OxFF(十进制 255),用于标记压缩列表的末端
entry结构:(整个entry字节数=前节点长度 + 编码 + 当前节点内容)
previous_entry_length:前一节点长度,占1或5个字节
如果前一节点长度小于254字节,则采用1个字节保存这个长度值
大于254字节,用5个字节
encoding:编码属性,记录content的数据类型(字符串/整数)以及长度。占用1/2/5个字节
contents:负责保存节点的数据,可以是字符串或整数
编码:
-
字符串:编码是以 “00”、“01”、“10”开头
-
数字:“11”开始,且encoding固定占用1个字节
问题:连锁更新 => 导致内存连续被申请喝销毁
QuickList
用来解决ziplist连续空间、无法存储大量数据、数据拆分后分散不易管理的问题。
quicklist是一个双端链表,每个节点都是一个ziplist。
限制ziplist的entry数量:Redis提供了一个
list-max-ziplist-size
的配置项,用来限制ziplist的entry过多。
-1:不超过4kb
-2:8
-3:16
-4:32
-5:64
限制ziplist的大小:quicklist还可以通过配置
list-compress-depth
进行对ziplist做压缩
0:特殊值,不压缩
1:首尾1个节点不压缩,中间节点压缩
2:首尾2个节点不压缩,中间节点压缩
。。。同上类推
QuickListNode结构:
QuickListNode *prev:前一节点指针
QuickListNode *next:后一节点指针
char *zl:当前节点的ziplist指针
sz:当前节点的ziplist的字节大小
count:当前节点的ziplist的entry个数
encoding:编码(1:ziplist,2:lzf压缩模式)
container:数据容器类型(预留)1:其他,2:ziplist
...
quicklist结构:
head:头节点指针
tail:尾节点指针
count:所有ziplist的entry的数量
len:ziplist总数量
fill:ziplist的entry上限
compress:首尾不压缩节点数量
...
总结
我的理解:
quicklist是一种用来解决ziplist连续存储空间、无法存储超大容量数据、数据拆分不易管理而提出的一种数据类型,是一个双端链表,节点上存储的是一个ziplist。
skiplist
skiplist是一种特殊的链表,具有升序排序存储、节点包含多个不同跨度的指针的特点。
zskiplistNode结构:
ele:节点存储的值
score:节点分数。用来排序、查询
backword:前一个节点指针
level[] :多级索引数组
forward:下一个节点指针
span:索引跨度
zskiplist:
header,tail:头尾指针
length:节点数量
level:索引层级,默认1,最多32级
总结:
个人理解:
skiplist是一种按score值升序排序存储的双向链表,对这个链表按照全局固定的一个span值进行划分一个索引层次,可以用这个层次来加速查询
RedisObject
通过对上述的任意数据类型的键值封装成一个RedisObject形成Redis的基本数据类型。
结构:
type:对象类型。string、hash、list、set、zset(占4bit)
encoding:底层编码方式,11种(占4bit)
lru:
refcount:对象引用计数器,计数器为0时,无人引用,可被回收
ptr:指向实际的数据存储空间
上述的头部已经占用了16个字节了。
这就是为什么存储大容量数据不用string类型的原因了,一个string数据就多占用16个字节
11种编码方式:
对应着不同数据类型采用的不同编码:
Redis的5种基本数据类型
string
采用的编码:int、embstr、raw
-
基本编码方式是raw,基于动态字符串(SDS)实现,存储上限512MB
-
当SDS长度小于44字节,则采用embstr编码。
-
当存储的字符串是整数值,并在long_max范围,采用int编码
-
直接将数据保存在RedisObject的ptr指针位置(刚好有8个字节大小),不需要SDS了
-
list
采用的编码
-
3.2之前:linkedlist + ziplist
-
3.2之后:quicklist
set
采用的编码:intset、ht
-
当存储的所有数据都是整数的时候,并且元素的数量不超过set-max-intset-entries,set会采用intset这种编码节省空间
-
其他时候采用ht编码(Dict),key用来存储元素,value统一为null(虽然这个value为null有点浪费,但是整体来说还是值得的)
zset
采用的编码:ziplist、ht、skiplist
因为zset的特点:键值存、键必须唯一、可排序
通过skiplist + ht(Dict)实现:
-
skiplist:可排序,可存储score喝ele值(member)
-
ht(Dict):存储键值,根据key找value
typedef struct zset{
dict *dict;
zskiplist *zls;
}
当元素数量不多的时候,ht+skiplist的优势不明显,而且更耗内存。这个时候会通过ziplist来节省内存。
要满足以下两个条件:
-
元素数量小于zset_ziplist_entries,默认128
-
每个元素都小于 zset_max_ziplist_value字节,默认64
ziplist本身没有键值存储、没有排序功能,这个时候要功能代码进行实现:
-
ziplist是连续内存,所以score和ele是紧凑一起的两个entry,ele前,score后
-
score越小越近队首,score越大越接近队尾,按score升序排序
hash
采用的编码:ziplist、ht
hash底层跟zset基本一致,只需把排序有关的skiplist去掉即可:
-
hash默认采用ziplist,节省内存,相邻的两个entry分别保存field 和 value
-
数据较大时,hash转ht编码(Dict)。触发条件:
-
ziplist的元素数量超过了hash-max-ziplist-entries(默认512)
-
ziplist种任意的entry大小超过了hash-max-ziplist-value(默认64字节)
-
总结
string
intset:全整数+数据小时
embstr:小于44字节
raw默认这种,采用SDS,最大512字节
list
3.2前:看情况使用ziplist和linkedlist(大)实现
3.2后:统一使用quicklist实现
set
intset:全数字 + 不超多一个阙值
dict
zset
ziplist:数据量小时=> 头小,尾大;前ele,后score
skiplist(排序) + dict(唯一)
hash
ziplist:数据量小时=> 头小,尾大;前field,后value
dict(唯一)
Linux网络模型
在Linux操作系统中,用户是不是没有直接的权限去操作文件的,而是通过调用系统内核的接口实现调用的。这里分为用户空间和内核空间两个概念了。
阻塞IO
-
要读取数据,发现没有数据,就阻塞等待。
-
在等待数据阶段和拷贝数据阶段这两个阶段都在阻塞。
非阻塞IO
-
通过反复轮询请求访问内核,看看是否有数据。
-
等待阶段是非阻塞的(如果没有数据就返回一个错误代码),拷贝数据这个阶段是阻塞的
-
非阻塞IO的关键在于减少阻塞等待的时间,使得线程或进程在等待IO操作完成的同时可以去做其他事情
IO多路复用
在Linux系统中,开启了许多个FD,通过使用一个单线程检测各个FD是否完成,进行返回结果。
fd(文件描述符):Linux系统中任意一个资源的打开,包括socket。
实现方式
select模式
初始化fd_set
定义个存储fd的集合:fd_set
情况fd_set
将fd添加到fd_set中
select函数的调用
select函数调用,将fd_set拷贝到内核空间中
遍历fd_set,没有就绪则休眠
等待数据就绪被唤醒或超时
处理结果
检查返回值,看看有多少的fd准备好了
集合遍历,看看指定的fd是否在就绪集合中
处理就绪的fd
poll模式
初始化
创建pollfd数组,并添加关注的fd信息,数组大小自定义
调用poll函数
调用poll函数,将pollfd数组拷贝到内核中,转无上限链表存储
遍历fd,判断是否就绪
数据就绪/超时,拷贝pollfd数组到用户空间,返回fd就绪个数n
处理结果
用户进行判断n是否大于0
大于0,遍历poll数组,找到就绪的fd
epoll模式
初始化 epoll_create()
在内核空间创建一个eventpoll的结构体
结构体包括:rb_root rbr红黑树,list_head rdlist就绪列表
添加fd epoll_ctl()
添加fd添加到epoll的rbr红黑树中,并设置ep_poll_callback
当callback触发时,把对应的FD添加到rdlist就绪列表中
检查rdlist列表是否为空,不为空则直接返回就绪的FD数量 epoll_wait()
上述小结:
select模式的问题:
能监听的FD不超过1024
每次select都需要把要监听的fd拷贝到内核空间
每次都要遍历所有的fd来判断就绪状态
poll模式的问题
poll利用链表解决了select监听fd上限问题,但仍需遍历所有的fd。如果监听较多,性能下降
epoll解决上述问题:
基于epoll实例中,红黑树保存监听fd,理论上无上限,增删改查性能较高,性能不随着监听的fd数量增多而下降
每个fd只需执行一次epoll_ctl添加到红黑树,之后epol_wait无需传递任何参数,无需重复拷贝fd到内核空间
事件通知机制
当rdlist中有数据可读时,调用epoll_wait可以得到通知,得到事务通知的模式有以下两种:
-
levelTriggered(LT):当FD有数据可读时,会重复通知多次,知道数据处理完成(默认)
-
EdgeTriggered(ET):当FD有数据可读时,只会通知一次,不管是数据是否完成
ET模式避免了LT模式可能出现的惊群现象
ET模式最好结合非阻塞IO读取FD数据,相对比LT复杂点,但是效率更高
惊群现象:当多个套接字(sockets)同时处于等待事务的状态,但实际上只有一个套接字会接收到数据通知时所发生的情况
Web服务中流程
信号驱动IO
写真正的实现了非阻塞,但读时候,仍有阻塞。
用户应用与内核进行了一个绑定,如果没有数据,用户应用可以去做其他的事,但是当有数据的时候,会发送一个通知给用户应用,让他来处理事务
异步IO
用户应用只用调用,内核会自动去做完一切,到最后通知。
但是有一个问题:如果在高并发请求下,疯狂的调用,直接会导致崩溃。所以需要进行调用限流,但是这个限流实现还是有点小麻烦的。
总结
只有异步IO是异步的,其他都是同步。
Redis网络模型
Redis是单线程还是多线程?
Redis核心业务部分(命令处理)是单线程
处理客户端请求、执行命令时,Redis 主要依赖于单个线程来完成。这种设计简化了并发控制,提高了 Redis 在处理大量连接和请求时的效率。
整体上Redis 是多线程
在持久化过程中,执行IO操作会利用到多线程
在某些高级特性如 Redis Sentinel、Redis Cluster 中,可能会使用多个线程来处理监视、故障转移等任务。
在一些 Redis 模块中,可能会引入多线程以支持特定的功能或提高性能
持久化操作或者部分计算密集型操作,Redis 会使用额外的线程来执行这些任务,以避免阻塞主线程。
为什么Redis要采用单线程?
简化设计和实现:单线程模型使得 Redis 的设计和实现变得相对简单,减少了并发控制和同步的复杂性。这降低了开发和维护的成本,并且使得 Redis 的代码更加清晰和易于理解。
多线程不会提高太大的性能提升:抛开持久化不说,Redis是纯内存操作,执行速度非常快,它的性能瓶颈是网络延迟,而不是执行速度,因此多线程并不会带来巨大的性能提升。
高效利用CPU:虽然 Redis 是单线程的,但它可以利用多个 CPU 核心,并行地处理多个客户端请求。通过使用非阻塞 I/O 和事件驱动的方式,Redis 能够高效地利用 CPU 资源,并在处理高并发时表现出色。
减少上下文切换和内存开销:单线程模型减少了线程创建和销毁的开销,以及线程上下文切换的成本,从而提高了 Redis 在高并发环境下的性能和稳定性。此外,单线程模型也减少了为每个连接分配线程所需的内存开销。
Redis单线程网络模型流程
网络模型引入多线程
Redis处理时候速度非常快,但是在IO网络请求的速度没有那么快,容易出现网络阻塞,所以在网络读/写的时候加入多线程提高系统效率。
Redis内存策略
过期key处理
Redis本身是一个key-value内存存储的数据库——redisDb。
key,value都保存在Dict结构中(两个Dict)
一个Dict存储key-value
一个记录key-TTL
结构:
dict:存储key-value
expires:存储key的TTL
Redis是如何知道一个key是否过期的?
利用两个Dict分别记录key-value和key-ttl
有两种策略发现是否过期,并将该过期key删除
惰性删除:在访问一个key对象时,检查该key的存活事件,如果已过则删除
周期删除:周期性抽样部分过期key,然后执行删除
(了解即可)SLOW:设置一个定时任务serverCron(),按照server.hz频率执行过期key清理
(了解即可)FAST:每个事件循环前会调用beforeSleep(),执行过期key
内存淘汰机制
内存淘汰:当Redis内存使用达到阙值的时候,Redis主动挑选部分Key删除释放更多内存。
内存淘汰策略
补充一下lru和lfu:
lru:最近最少使用。用当前事件减去最后一次访问时间,值越大越先淘汰
lfu:最少频率使用。会统计每个key的访问频率,值越小淘汰越优先
noeviction:不淘汰任何key,但是内存满了不允许写入新数据(默认)
ttl:淘汰最小TTL值
volatile-ttl:对设置了TTL的key,淘汰最小的TTL值
random:随机
volatile-random:对设置了TTL的key,随机进行淘汰
allkeys-random:对所有的key,随机进行淘汰
lru:最近最少使用的值(淘汰掉最近一段时间内最少被访问的数据)
volatile-lru:对设置了TTL的key,采用lru进行淘汰
allkeys-lru:对所有的key,采用lru进行淘汰
lfu:最少频率使用(淘汰访问频率低的)
volatile-lfu:对设置了TTL的key,采用lfu进行淘汰
allkeys-lfu:对所有的key,采用lfu进行淘汰
补充:
Redis中队TTL、LRU、LFU策略使用的时候,使用的是抽样部分淘汰