Redis
redis为什么快
- 内存操作,不需要读磁盘
- 单线程操作, 没有上下文切换
- io多路复用,有空查查
redis的数据结构
Redis 是基于内存的数据结构, 考虑到内存的容量有限,所以redis的数据结构设计的很精妙,坚持不浪费一个字节的原则。
Redis 有五种基本类型, 每种类型根据存储数据的特性提供了多种编码方式。基本数据类型为String,hash,list,set,zset。 String 类型的编码方式有int、sds两种,sds可以分为embstr和raw两种。 Hashtable 可以使用ziplist和hash两种。 List的改动比较多, 3.2之前是ziplist或linklist, 3.2之后改为了quicklylist, 后来又推出listpack代替ziplist。 Set 分为intset和hashtable。 zset使用ziplist或者跳表实现。
redisObject是最基础的数据结构,redis 所有的键值,参数都是使用这种结构, 固定16字节,分别是半字节的数据类型,半字节的编码类型,三字节的lru。4字节的引用计数,8字节的存储指针引用。
redisSever 启动时会初始化redisDb, 默认是16个但一般只使用一个, 集群模式下每个节点只有一个。 每个redisDb中有多个dict, 包括一些常规dict,过期dict,阻塞dict等。
String
缓存,配置文件,分布式锁等
由于redisObject 存储指针是使用八个字节存放的, 如果存储的值是一个整数的字符串, 并且该整数值小于八个字节,会直接使用这个指针的存储空间存放, 不用额外再申请空间。
字符串类型的数据使用sds结构存储, c语言中的字符串长度固定, 如果需要修改的需要重新申请内存, sds 使用空间换时间的策略, 预分配一些存储空间, 使用已使用的长度和总长度来控制内存分配。 预分配策略是小于1m扩大2倍,大于1m的话每次都会多申请1m。 同时sds也保留了结束符, 方便使用c语言库中的各种字符串函数。 由于字符串长度的不固定性, 怎么定义长度和总长度也是一个问题, 使用较小的数据类型int,支持字符串的长度会较短, 使用较大的数据类型long, 可以支持到2的64次方, 但针对较短的字符串空间浪费严重,所有redis提供了5种sds结构, 根据字符串长度的不同选择不同的结构, sdshdr5,8,16,32,64. 其中hdr5没有使用,最小的就是8. sdshdr中除了总长度和已使用的长度,还用了一个字节flag来标识当前类型。 所以最小的sds头应该占用3个字节。
redis提供了embstr 和raw两种格式来编码字符串类型,主要思想是减少内存申请次数。因为内存分配都是以2的整数次幂分配内存的, 一个字符串存储的额外空间至少需要redisObject和sdshdr的19个字节, 向上取整至少需要32个字节, 再配合预分配策略, 一个字符串至少需要64个字节。我们可以在这64个字节中存储一些较短的字符串,就能节省一次为字符串额外申请空间的开销。 这其中减去头部19字节和结束符1个字节,所以embstr最大可以存储44字节。 这个数在redis中是以常量的方式写死的。
需要注意的是embstr 和int类型都是只读的, 任意的修改都会将其转化为raw编码再进行操作,而且操作完不会转变回来。
List
有序集合, 最新文章等
Redis中的list是一种允许重复元素的集合类型, 保证插入有序。 3.2 之前使用linkedlist或ziplist来存储,3.2之后使用quicklist。
传统的linkedlist 是一个双端列表, 维护着头尾节点方便正反查询, 每个节点维护着前后指针。这种方式能高效的实现查找和添加需求。 但每个节点维护前后指针,这就需要额外的16个字节存储, 如果集合的元素比较少, 指针的存储将占较大的一块内存空间,redis 提供了一个ziplist的存储方案来节省这部分内存。 当集合中的元素少于512个,且每个元素的大小都小于64字节时,会使用ziplist存储, 这两个值可以通过配置文件修改。
ziplist使用一块连续的内存存放集合数据,每个元素存储着当前元素的字节长度。 这样根据首字节的地址和长度就能依次访问元素。 为了兼容反向查找, ziplist也记录了尾节点,同时每个元素都记录了前一个节点的长度。 需要注意的是这个长度字段大小不是固定的,使用1字节或5字节存储。 ziplist的优点很明显,能大大节省内存。 但缺点也同样明显, 由于内存是连续的, 每次添加或删除元素都需要重新申请内存, 而且极端情况下如果首节点的长度发生变化过大, 由1字节扩展到了5字节,因为后续节点记录了前节点的长度, 就会出发连锁更新,耗时会大大增加。
quicklist使用了这两种的混合方式, 先将linkedlist切分,每个段内部使用ziplist存储,段之间使用指针链接。
后续redis提供了一种listpack的方式优化ziplist,使用固定分隔符来分割元素
Hash
可以存储
Hashtable 同样有两种存储方式, 如果元素数量小于512个, 且元素大小都小于64字节时使用ziplist存储, 如果大于则使用hashtab存储, hashtable的数据结构为dict, 每个dict中含有两个dictht字段,实现渐进式hash, 如果暂停线程进行扩容的话,会降低吞吐量, 所以使用渐进式, 新增会在新ht上进行, 查改删都会在两个ht中执行。
Set
不允许数据重复的集合
set分为intset和hashtable, 其中元素小于512且都小于64字节时使用int数组存放, 根据int数组中最大数字占用的字符。 int的类型也是分为多种,int16,int32,int64。
Zset
不允许数据重复, 且根据指定分值排序的set。
小于128用ziplist。 大于128使用跳表实现
跳表是一个带索引的双向链表, 有着近似二叉树查找的时间复杂度, 且更新的成本远远低于二叉树。 每个skipNode除了存储的值、前驱指针外,还维护了一个前进指针数组, 以层的方式进行扩展,层数的确定随机确定,每层有25%的概率增加上一层。
同时也维护了一个dict结构, 用于支持单值查找
Bitmap
布隆过滤器, 使用intset或sds数组实现,Redisson 直接提供了boolfilter的实现, 整体实现思路是一个bit数组,每个值都是0或1,能用来定位元素是否存在, 而且bool过滤器使用多个hash函数计算多个位置, 能有效降低冲突。 如果布隆过滤器认为不存在则一定不存在。缺点是如果数组长度过短或hash函数不够好,错误率可能会挺高,而且不能删除元素,因为删除bit可能会影响别的元素,增加误判率。
有一种增强的布隆过滤器,他存储的不再是01值,而且累加的整数,一定程度上可以做到删除操作。 但误判率可能依然很高。
还有一种布谷鸟过滤器,他的解决思路是发生hash冲突时进行线性寻址。 将被冲突的元素拿出来hash,为啥不是自己再hash? 可能参考了最近最久使用。 可能会发生循环查找问题,所以需要设定寻址阈值。
Hyperloglog
一种统计总数的方式, 有万分之几的误差,底层也是字节数组, 16k的数据能存2亿数据, 可以做一些实时pv统计。 原理大概是扔硬币那个概率事件,伯努利实验, 具体没仔细研究过。
淘汰策略
Redis 是基于内存的数据库, 存储的数量有限,所以也需要一种数据淘汰策略, redis的数据淘汰分两种, 一种是设置了过期时间的数据到期后淘汰, 一种是内存不足时淘汰。
过期淘汰有两个方式, 一是惰性删除, 访问过期key时校验,如果超时则淘汰。 二是主动删除, redis每10s会在设置了过期时间的key中随机抽取20个,淘汰掉其中的元素, 如果淘汰比例超过25%,可以认为有大量数据超时,会重复这个步骤。
内存不足淘汰有多种策略可以选择,而且可以选择在所有key中执行还是带有过期时间的key中执行。 有两个不区分字典的淘汰策略,分别是拒绝写入(默认)和按过期时间较久的淘汰, 都有可能会导致内存溢出。 还有三种算法,lru、lfu和random,都可以选择在allkey还是ttlkeuy中执行, random比较简单,随机淘汰。 lru是最近最少使用的元素, lfu是使用频率最低的元素。
lru是经典的内存淘汰策略了, 基本思想是最近被使用的数据, 很可能接下来也会被使用。 redisObject使用了3字节来存储lru变量, 在lru模式下记录的是最近一次被访问的时间戳, 数据淘汰时基于此淘汰数据。 lru有个缺点是可能最近一次访问的数据并不是热点数据,有可能会将热点数据淘汰出去。 比如缓存的容量为2, 对象A访问了100,然后对象B访问了一次, 这时候来了个对象C需要淘汰数据, 按照lru原则就会淘汰对象A,但对象B可能是一次临时访问,mysql 的缓存池也有这个问题, 它采用的是改进lru, 将数据分为了old区和new区, 新的数据会进入new区,等待一段时间后才会被放到old区。 淘汰时优先淘汰new区的数据。 主要是为了防止全表扫描把数据页都淘汰掉。 redis基于这种场景设计了lfu算法,用户可以根据自己的业务场景选择lfu还是lru。
lfu的思想是淘汰使用频率最低的数据, 每个元素都会记录被访问次数, 淘汰时淘汰掉访问次数较低的值。lfu也是使用了redisObject中的lru字段, 其中高16位存储添加时间, 单位是分钟, 低8位存储访问频率。 lfu这个访问频率并不是每次都增加, 而是按概率增加。 可以通过配置文件设置增加频率。 具体的实现是取一个随机数p,在根据配置值计算一个数字r,如果p < r就+1。lfu还有两个问题需要考虑, 一是新增加的元素可能很快被淘汰,二是很久之前访问频率较高的元素可能很难被淘汰。 新增加的元素lfu将访问频率直接设置为了5,减少被淘汰的概率。 而且存储了元素的添加时间, 根据时间衰减访问频率。
持久化策略
Redis 是基于内存的数据库, 当进程突然结束数据会全部丢失, 所以redis需要借助持久化策略将数据同步到磁盘,redis提供了两种持久化策略, 默认开启rdb, 可以选择是否开启aof。
rdb
rdb, rdb的实现方案是保存redis的数据快照。 考虑到备份时也有数据写入, RDB提供了两种模式来处理, SAVE 会阻塞用户的写入, 当数据全部备份完成后才允许用户线程执行,可以保证dump下来的数据是完整的数据。 另一种是BackupGround Save 方式, 会在当前线程中开启一个子线程进行数据同步, 这中间的发生的数据写入会写到一个临时缓存区, 当dump完成后再将缓存区数据同步到redis中, 这样只能导致dump下来的数据是dump开启时刻的快照。 bgSave的开启方式有两种, 一是手动执行bgsave命令, 一是在配置文件中配置多个save 条件,比如多长时间进行了多少次操作就进行备份, redisServer 记录了上一次备份的时间和修改次数, 在每个命令执行完成后都会检测是否满足save条件。 针对过期键的处理, rdb备份时不会写入。 rdb文件的格式很精简,魔数REDIS标识这是redis文件, 加下来1字节是rdb文件版本,然后就是database信息, 再之后是结束符和校验和,用来检验文件是否损坏。 database存储了库id和具体的键值对信息, 每个键值对由值类型、key和value组成。
aof
aof,aof的实现方案是保存每一个变更命令,是以追加的方式写到aof buffer中中,aof提供了三种刷盘模式来兼容并发和数据一致性, 可以选择每次都写入磁盘、每秒刷一次、或者依赖操作系统自主刷盘。 数据持久化要求越高,并发度就会越低。 当数据过期时,会添加一条DEl指令到AOF中, aof日志的大小会随着时间增加逐步增长, 其中会出现很多重复的命令, redis提供了aof的重做机制。 他的重做不会对原有的重做日志有任何影响, 而是参考rdb快照的方式,从库中取出数据生成一条新的set指令。 这其中新发生的变更会写到一块新开辟的内存中,等待aof重做结束后追加到新日志结尾。
Rdb 和 aof的优缺点: rbd是默认的同步方式, 高效的存储了当前数据库的状态, 适合大量数据的快速恢复, 由于是保存的某一时刻的快照信息, 所有宕机后会丢失上一次快照存储之后的数据。 对数据完整性要求严格的不适用于rdb。 Aof 保存了完整的变更日志, 但缺点是日志可能过大, 恢复较慢。 aof提供了重做日志的优化,而且每次追加写磁盘可能会降低并发度, aof也提供了多种刷盘策略来优化, 当redis重启时,如果存在aof日志会优先使用aof进行重做。
高可用方案
主从
redis支持配置一个节点成为另一个节点的从节点,从节点会同步主节点的全部数据, 可以减轻主节点的压力,可以做容灾库快速恢复。 但可能会造成数据不一致情况, 网络延迟等。 可以通过salveof命令配置一个节点成为另一个节点的从节点。 缺点是数据不一致情况、容灾恢复慢, 且需要使用方手动切换域名。
数据通过主要是两个方式, 一个为全量同步,一个为增量同步。 全量同步时从节点会向主节点发送sync命令, 主节点接收到命令会执行bgSave生成数据库rdb快照, 同时通过缓冲区记录增量变更。 等快照生成后会发送给从节点, 从节点会根据rdb重做数据库, 同时接受缓冲记录来同步变更。 如果从节点down机,2.8之前的老版本会重新拉全量rdb。耗时过长且浪费资源过多,因为变更的数据可能远远小于总量。 2.8之后提供了另一种psync同步方式, 核心思想是同步宕机过程中发生的增量数据。 与kafka的数据同步类似, 主从节点会维护一个偏移量和缓冲区, 从节点同步时会从缓存区拉取偏移量之后的数据。 每个节点都有一个runId,主节点会记录每个runId的偏移,如果缓冲区查不到当前runId的偏移, 可以认为宕机过久或第一次同步,会执行bgSave生成rdb快照。
哨兵
主从模式下需要手动切换从节点,如果监控机制不完善或维护同学失联,可能会导致严重后果。 Redis 提供了哨兵机制来自动检测节点状态并完成主从切换。 基本思路是一个或多个哨兵节点组成哨兵组, 监控所有主从节点的状态。 哨兵节点通过订阅机制从主节点拉取主从关系, 从节点从哨兵节点拉取主从关系。 哨兵向每个节点发送ping消息。
如果哨兵节点ping某个节点ping不同, 会标记为主观下线状态, 同时别的哨兵节点也去ping该节点,如果多数哨兵都ping不同则认为的确下线了,如果有回复就移除下线状态。 如果认为节点真的下线, 就执行节点下线。 从节点直接下线, 如果是主节点则需要进行重新选主。
首先哨兵组会选一个哨兵leader, 主要是采用raft算法。
哨兵leader会从所有从节点中移除不可用节点。 然后按照优先级选取主节点, 如果优先级相同, 会按照偏移量选一个较大的节点。 因为偏移量较大的数据较新。 如果偏移量也相同则按照runId选一个最小的, 然后其余的节点会成为新主节点的从节点,并进行数据同步。 重启后的主节点会成为新的从节点。
缺点是哨兵本质上还是单节点存储, 单机的存储总量有上限,当数据量特别大时数据阻塞时间会过长。
集群
基本思想
集群是提供的一种高可用、高性能、可扩展模式。 核心思想是对redis进行分库提高吞吐量、同时为每个分库创建副本保障高可用。
具体实现
Redis 分库的思路采用了常规hash后取余的方式。为了兼容后续的扩容需求, 直接将数组分为了一个较大的值16384。 每个节点可以负责一个或多个槽, 扩容时可以只扩容节点,只需要迁移部分槽数据就可以了。具体的hash算法采用crc16生成。 redis集群是去中心化的, 每个节点都维护着全量的集群信息, 具体的数据结构为clusterState,里面有几个比较核心的属性,首先是三个slot数组,其中元素都是cluterNode指针,clusterNode是集群中的节点信息, 包括ip、端口等。slots维护着当前槽的对象关系, from和to数组维护着当前正在迁移的槽信息。
查询过程
查询或修改数据时, redis会判断当前key分配的槽是否为当前节点, 如果是直接执行命令, 如果不是则会通过slots数组找到对应的节点信息, 返回给客户端一个moved标识及节点ip和端口,客户端会使用该ip和端口重试命令。
迁移过程
clusterState维护slots数组,每个数组元素指向clusterNode, 里面记录了节点的ip和端口, 代表哪个节点来处理当前槽。 发生机器扩容时, 需要指定新节点处理哪些槽。 整个过程可以分两步,先迁移对应槽数据到新节点, 然后修改slots数组并同步集群所有节点。 槽数据的迁移也是渐进式的, 会根据slots_to_key 这个跳表结构批量返回当前槽的键, 一批批的迁移数据。 如果迁移数据过程中有命令执行, 如果是set命令直接执行,因为后续会被迁移到新节点。 如果是get请求, 会查询当前节点是否存在, 如果不存在就查询slots_to数组对应的槽是否有值(这个数组记录了哪些槽正在迁出, form数组记录了哪些槽正在迁入)。 如果对应的to槽有clusterNode信息, 证明当前槽正在迁移, 返回ASK错误和目标节点信息,客户端根据目标节点信息,去目标节点查询。
moved 和ack 都是用来请求转发的,区别是客户端接受到某一个key的转发节点信息,下次直接就向该key转发,而ack不会
复制和故障转移
redis集群的所有节点都可以理解为哨兵, 互相进行随机ping通信, 如果多数节点ping不通某一节点, 则认为该节点宕机,由该节点的从节点选主并同步其他集群节点,也是使用的raft协议
分布式锁
核心是利用redis的setnx命令, 如果key不存在则设置值,key存在返回失败。 由于redis是单线程操作,可以利用这一特性完成资源互斥。
可能会出现的问题及解决方案:
持有锁的线程挂掉,锁无法释放
比如线程A通过setnx命令加锁成功,但可能拿到锁执行业务的过程中线程A挂掉,永远无法释放锁,别的等待线程永远拿不到锁了
可以通过给锁加超时时间, 一段时间不释放锁自动过期。 让别的线程去竞争锁。 为了保证原子性,redis支持setnx 和 设置超时合并为一条语句。
释放的锁非当前线程的锁
当线程A持有锁,执行超时后释放锁。 这时线程B获取锁, 执行过程中线程A结束,释放锁,但这时候释放的是线程B的锁。
可以加锁时申请一个uuid放到value中, 解锁时通过比较uuid判断是否为当前线程加的锁, 需要使用lua脚本将比较和解锁的动作放到一块,防止并发问题。 lua脚本并不能保证原子性,只能保障语法错误不执行。
锁超时,但线程未执行结束
还有一个问题是锁的超时时间不好设定,我们并不能准确预估每一次同步业务的执行时间,所以超时时间很难设定
redission作为一个基于redis的分布式框架, 封装了redis的加锁及续期操作。可以做到分布式锁,分布式队列,分布式ratelimit等。
Redission 分布式锁主要是基于lua脚本,实现了锁超时和锁续期的能力, 使用hash表结构, key为客户端id+线程id,value为加锁次数。 实现了可重入、可超时的能力。
lua脚本分析:
- 判断当前要加锁的key是否存在, 如果不存在则进行hset操作。 并设置过期时间默认10s。 返回null
- 如果当前key存在,判断下key是否对应,如果对应使用hinc方法将value+1,并重置锁的过期时间。 返回null
- 返回锁的过期时间。
加锁过程分析:
-
尝试获取锁
- 使用lua脚本加锁, 如果返回的ttl为空证明加锁成功,启动看门狗给锁续期,主要原理是通过一个10s的延时队列续期30s。
-
获取锁失败
- 封装观察者, 等待持有锁的线程释放,主要是通过redis的订阅机制。
- 通过循环获取等待锁。使用信号量机制挂起线程,防止空转浪费cpu
解锁过程分析:
使用lua脚本, 判断当前key是否存在, 是否是当前线程加的锁。 如果是就把value值-1. 判断如果value值> 0 给锁重新续期,否则删除key。
主从切换导致加锁失败
给主库加锁, 还未同步到从库, 主库宕机,从升主,别的线程成功加锁。
Redis 集群模式提供了redlok, 大多数节点加锁成功才算成功,不太建议使用,因为无法回滚。
如果对加锁有强一致性要求,可以使用zookeeper, 但效率会相较redis较低。
常见问题
缓存穿透
查询大量不存在于缓存的key
布隆过滤器,缓存空对象
缓存击穿
大量请求访问的热点key过期,导致导致打到数据库
同步加锁, 热点key主动续期或设置永不过期
缓存雪崩
大量key同一时间过期
缓存时间随机、多级缓存,不同级过期时间不同
数据一致性
串行化加读写锁,强一致性,并发度太低,失去缓存意义
延迟双删, 最终一致性。
Redis响应时间突然变慢是怎么回事?
• 查看是否有慢操作,只要有一个慢操作即会拖累整个集群的响应时间
• 碎片率过大,适当进行碎片回收会提升访问效率
• 查看内存使用情况,是否达到内存上限,达到上限后会触发内存清理策略
• 查看是否有大量KEY批量过期,同时过多的KEY过期会阻塞正常请求的处理
• 查看操作系统网络连接数,如果太多的网络连接会导致新连接创建变慢
• 查看操作系统网卡带宽情况,如果达到上限会出现网络阻塞甚至丢包现象
• 查看CPU消耗情况,Redis单线程单核处理,CPU负载过高会影响正常请求处理时间
• 查看持久化模式是否开启,是否出现因为写入过大导致磁盘刷新变慢
• 其它原因