高性能分布式缓存Redis--- Redis底层结构和缓存原理 --- 持续更新

高性能分布式缓存Redis全系列文章主目录(进不去说明还没写完)https://blog.csdn.net/grd_java/article/details/124192973

本文只是整个系列笔记的第二章:Redis底层结构和缓存原理,非常重要的理论知识,可以帮助你更好的使用redis,也是面试的高频题目。

文章目录

  • 1. 概述
  • 2. Redis底层数据结构
    • 2.1 redis存储结构
    • 2.2 底层数据结构:字符串SDS
    • 2.3 底层数据结构:跳跃表(重点)
    • 2.4 底层数据结构:字典(重点)
    • 2.5 底层数据结构:列表&集合
    • 2.6 底层数据结构:快速列表(重点)
    • 2.7 新增的stream对象使用的listpack(紧凑列表)和Rax Tree(基数树)
    • 2.8 10种编码
  • 3. Redis缓存原理
    • 3.1 Redis缓存过期
    • 3.2 Redis删除(淘汰)策略
    • 3.3 如何选择淘汰策略

1. 概述

Redis中命令可以忽略大小写(set或Set或SET都一样),key是不忽略大小写的(Name和name是两个不一样的key)

Redis数据类型和应用场景

Redis是Key-Value存储系统,由ANSI C语言编写,key的类型是字符串,value的数据类型如下:

  1. 常用:string(字符串)、list(列表)、set(集合)、sortedset(zset有序集合)、hash类型
  2. 不常用:bitmap(位图)、geo(地理位置)类型
  3. Redis 5.0新增:stream类型

Redis中Key的设计方案(大家统一比较认同的一种方案,当然你可以自己规定)

用“:”分隔,把表名转换为key的前缀(例如user:),第二段放置主键值,第三段放置列名。例如将下面的user表转换为redis的key-value存储
在这里插入图片描述

  1. username的key:“user:9:username”,见名知意,不容易被覆盖
  2. 如果value是string类型,完整的存储是{“user:9:username”:“zhangf”}

string字符串类型,redis的string类型可以表达3种值类型:字符串、整数、浮点数(100.01是六位的串,小数点也算)

常见操作命令如下表:

命令名 命令格式(了解即可/重点) 描述(知道有这个东西,用到了来查/重点,背会了)
set set key value 给指定key赋指定value值,key不存在就创建
get get key 获取指定key的value值
getset getset key value 取指定key的当前值,然后给这个key赋value值
setnx setnx key value 当value不存在就赋值,否则不赋值,常用于实现分布式锁
对应的,原子操作如下(赋值并且设置有效期,一条命令完成,如果两条就不是原子性操作了):
set key value NX PX 3000 nx表示存在就不赋值,px设置有效期毫秒数。也就是说,setnx相当于 set key value NX
append append key value 向尾部追加值,不覆盖
strlen strlen key 获取字符串长度
incr incr key 递增数字(每次加1),常用于实现乐观锁watch(事务)
incrby incrby key increment 对指定key对应的value值,做增加操作,增加的是整数increment
decr decr key 递减数字(每次减1),常用于实现乐观锁watch(事务)
decrby decrby key decrement 指定key的value减少指定的整数decrement

setnx案例(setnx和set … NX 是一样的)

# setnx 一个key = user:01:name,value = lisi 的数据
set user:01:name lisi NX # 结果为OK
# 再次setnx 就会返回(nil),无法覆盖
set user:01:name wangwu NX # 结果为(nil)
# 此时get,会获取lisi的值,因为NX操作,如果已经存在,是不会覆盖的
get user:01:name # 结果为"lisi"
# 此时直接set,就可以覆盖,因为没有加NX
set user:01:name wangwu # 结果为OK


# 当然也可以使用px来指定过期时间,当nx操作的key已经超过px指定时间,就可以覆盖
set user:02:name lisi NX px 10000 # 结果OK
set user:02:name wangwu NX # 结果(nil)
# 过了10000毫秒后
set user:02:name wangwu NX # 结果OK

list列表类型,可以存储有序、可重复元素,获取头或尾部附近元素极快。元素个数最多232-1个(40亿)。常作为栈或队列使用,可用于各种列表(用户列表、商品列表、评论列表等)的场景
常见操作如下表:

命令名 命令格式(了解即可/重点) 描述(知道有这个东西,用到了来查/重点,背会了)
lpush lpush key v1 v2 v3 … 从对应key的列表左侧插入
lpop lpop key 从对应key的列表左侧取出一个元素
rpush rpush key v1 v2 v3 … 从列表右侧插入
rpop rpop key 从列表右侧取出
lpushx lpushx key value 将值插入到列表头
rpushx rpushx key value 将值插入到列表尾
blpop blpop key timeout 从列表左侧取出,当列表为空时阻塞,可以设置timeout表示最大阻塞时间,单位s(秒)
brpop brpop key timeout 从列表右侧取出,列表为空时阻塞,可以设置最大阻塞时间,单位秒
llen llen key 获取列表中元素个数
lindex lindex key index 获取列表中下标为index的元素(从0开始)
lrange lrange key start end 返回列表中指定区间的元素,区间通过start和end指定
lrem lrem key count value 删除列表中与value相等的元素
当count>0时,lrem会从列表左边开始删除;当count<0时,lrem会从列表后边开始删除;当count=0时,lrem删除所有值为value的元素
lset lset key index value 将列表index位置的元素设置成value的值
ltrim ltrim key start end 对列表进行修剪,只保留start到end区间
rpoplpush rpoplpush key1 key2 从key1列表右侧弹出并插入到key2列表左侧
brpoplpush brpoplpush key1 key2 从key1列表右侧弹出并插入到key2列表左侧,会阻塞
linsert linsert key BEFORE/AFTER pivot value 将value插入到列表,且位于值pivot之前或之后

set集合类型,存储无序、唯一的元素(去重,不可重复),最大元素数为232-1(40亿),适用于不可重复还不需要顺序的数据结构,比如黑名单,随机抽奖,关注的用户等
常见操作如下

命令名 命令格式(了解即可/重点) 描述(知道有这个东西,用到了来查/重点,背会了)
sadd sadd key mem1 mem2 … 为key对应的set集合添加新成员
srem srem key mem1 mem2 … 删除集合中指定成员
smembers smembers key 获取集合中所有元素
spop spop key 返回并删除key对应的集合中一个随机元素
srandmember srandmember key 返回但不删除集合中一个随机元素
scard scard key 获取集合中元素的数量
sismember sismember key member 判断指定元素member,是否在key对应的set集合中
sinter sinter key1 key2 key3 … 求指定集合的交集
sdiff sdiff key1 key2 key3 … 求指定集合的查集
sunion sunion key1 key2 key3… 求指定集合的并集

sortedset(ZSet)有序集合类型,元素本身无序不重复,但是每个元素关联一个score分数,按分数进行排序,分数可重复。适用于按分值排序的场景。例如排行榜(点击、销量、关注排行榜等)
常见操作如下:

命令名 命令格式(了解即可/重点) 描述(知道有这个东西,用到了来查/重点,背会了)
zadd zadd key score1 member1 score2 member2… 为有序集合添加新成员(member),需要为每个成员指定分数score
zrem zrem key mem1 mem2 … 删除zset中指定成员mem
zcard zcard key 获取zset中元素个数
zcount zcount key min max 返回集合中score值在[min,max]区间的元素数量
zincrby zincrby key increment member 在集合的member分值上加increment
zscore zscore key member 获取集合中member的分值
zrank zrank key member 获取集合中member的排名(按分值从小到大)
zrevrank zrevrank key member 获取集合中member的排名(按分值从大到小)
zrange zrange key start end 获取集合中指定区间成员,按分数递增排序
zrevrange zrevrange key start end 获取集合中指定区间成员,按分数递减排序

hash(散列表)类型,一个string类型的field和value的映射表,提供了字段和字段值的映射,每个hash可以存储232-1个键值对(40多亿),常用于对象的存储(例如Java对象),表数据的映射(映射一行数据)。
高性能分布式缓存Redis--- Redis底层结构和缓存原理 --- 持续更新_第1张图片
常用操作如下:

命令名 命令格式(了解即可/重点) 描述(知道有这个东西,用到了来查/重点,背会了)
hset hset key field value 赋值,不区别新增或修改,没有就新增,有就覆盖
hmset hmset key field value1 field2 value2 批量赋值
hsetnx hsetnx key field value 赋值,如果filed存在则不操作
hexists hexists key field 查看指定field是否存在
hget hget key field 获取指定field的值
hmget hmget key field1 field2 … 获取多个field的值
hgetall hgetall key 获取所有field字段
hdel hdel key field1 field2 … 删除指定field指定
hincrby hincrby key field increment 指定field字段值进行自增increment操作
hlen hlen key 获取field字段的数量

bitmap位图类型,极大的节省存储空间,进行位操作的类型,通过一个bit位来表示某元素对应的值或者状态,其中key就是对应元素本身。常用于标识key的状态,比如用户每月签到(id为key,日期为偏移量,1表示签到,0表示没有签到),统计活跃用户(日期为key、用户id为偏移量,1表示活跃),用户在线状态(日期为key,用户id为偏移量,1表示在线)
高性能分布式缓存Redis--- Redis底层结构和缓存原理 --- 持续更新_第2张图片
常用操作如下:

命令名 命令格式(了解即可/重点) 描述(知道有这个东西,用到了来查/重点,背会了)
setbit setbit key offset value 设置key在offset处的bit值(0或1)
getbit getbit key offset 获取key在offset处的bit值
bitcount bitcount key 获取key在bit位为1的个数
bitpos bitpos key value 返回第一个被设置为bit值的索引值
bitop bitop and[or/xor/not] destkey key [key …] 对多个key进行逻辑运算后存入destkey中

geo地理位置类型(基于zset实现),主要用于记录地理位置,计算距离,查找附近的人等,是redis用来处理位置信息的,Redis 3.2 正式使用。利用Z阶曲线、Base32编码,也就是geohash算法,将一个二维平面图(x,y坐标系)通过z阶曲线将其转换为一维二进制(6位二进制),然后通过Base32编码将二进制编码为字符串。这个过程就是geohash算法。

1. Z阶曲线:在x轴和y轴上将十进制转化为二进制数,采用x轴和y轴对应的二进制数依次交叉后得到一个六位数编码。把数字从小到大依次连接起来的曲线称为Z阶曲线,是把多维转换成一维的一种方法。
高性能分布式缓存Redis--- Redis底层结构和缓存原理 --- 持续更新_第3张图片
2. Base32编码:数据编码机制,用来将二进制数据,编码成可见的字符串,编码规则是:任意给定一个二进制数据,以5个位(bit)为一组进行切分(base64以6个位(bit)为一组),对切分而成的每组进行编码得到一个可见字符
Base32编码表字符集中的字符总数为32个(0-9,b-z去掉a、i、l、o),也是Base32名字的由来。
高性能分布式缓存Redis--- Redis底层结构和缓存原理 --- 持续更新_第4张图片
3. geohash算法:2008年2月上线geohash.org网站,是一种地理位置信息编码方法。经过geohash映射后,地球上任意位置的经纬度坐标可以表示成一个较短的字符串。可以方便的存储在数据库中。可以附在邮件上,还可以方便的使用在其它服务中。

  1. 以北京坐标为例,[39.928167,116.389550]可以转换为wx4g0s8q3jf9
  2. Redis中经纬度使用52位的整数进行编码,放进zset中,zset的value元素是key,score是GeoHash的52位整数值,在使用Redis进行Geo查询时,其内部对应的操作其实只是zset(skiplist)的操作,通过zset的score进行排序就可以得到坐标附加的其它元素,通过将score还原成坐标值就可以得到元素的原始坐标。

命令名 命令格式(了解即可/重点) 描述(知道有这个东西,用到了来查/重点,背会了)
geoadd geoadd key 经度 纬度 成员名称1 经度1 纬度1 成员名称2 经度2 纬度2 … 添加地理坐标
geohash geohash key 成员名称1 成员名称2 … 返回标准的geohash串
geopos geopos key 成员名称1 成员名称2 … 返回成员经纬度
geodist geodist key 成员1 成员2 单位 计算成员间距离
georadiusbymember georadiusbymember key 成员 值单位 count 数 asc[desc] 根据成员查找附近成员

stream数据流类型,Reids 5.0后新增的数据结构,用于可持久化的消息队列,满足消息队列具备的全部内容(消息ID的序列化生成,消息遍历,消息阻塞和非阻塞读取,消息的分组消费,未完成消息的处理,消息队列监控),每个Stream都有唯一的名称(就是Redis的key),首次使用xadd指令注解消息时自动创建。
常用操作如下:

命令名 命令格式(了解即可/重点) 描述(知道有这个东西,用到了来查/重点,背会了)
xadd xadd key id <*> field1 value1 … 将指定消息数据追加到指定队列(key)中,*表示最新生成的id(当前时间+序列号)
xread xread [COUNT count] [BLOCK milliseconds] STREAMS key [key …] ID [ID …] 从消息队列中读取,COUNT:读取条目,BLODK:阻塞读(默认不阻塞),key:队列名称,ID:消息id
xrange xrange key start end [COUNT] 读取队列中给定ID范围的消息 COUNT:返回消息条目(消息ID从小到大)
xrevrange xrevrange key start end [COUNT] 读取队列中给定ID范围的消息 COUNT:返回消息条数(消息ID从大到小)
xdel xdel key id 删除队列的消息
xgroup xgroup create key groupname id 创建一个新的消费组
xgroup xgroup destory key groupname 删除指定消费组
xgroup xgroup delconsumer key groupname cname 删除指定消费组中的某个消费者
xgroup xgroup setid key id 修改指定消息的最大id
xreadgroup xreadgroup group groupname consumer COUNT streams key 从队列中的消费组中创建消费者并消费数据(consumer不存在则创建)

2. Redis底层数据结构

1. redis的字符串和整型,都使用SDS(简单动态字符串)存储
2. reids中,zset有序集合,使用跳跃表来实现
3. redis数据库整个的K-V存储体系,散列表(Has)对象,哨兵模式中主从结点管理,都是使用字典(散列表)来实现的
4. redis中,zset和hash结构,如果元素个数少且存储的是较小的整数或短字符串,会直接使用ziplist(压缩列表)来存储
5. Redis中,集合类型(set、zset)的元素,如果都是整数,并且都处于64位有符号整数范围内(264),将使用结构体(intset,整数集合)存储,但是如果存储的数在上述范围之外,会通过hashtable存储
6. redis中,list列表,使用快速列表实现。快速列表(quicklist=双向列表adlist+压缩列表ziplist)

2.1 redis存储结构

Redis存储系统:每个实例指向多个DB(RedisDB),每个DB指向多个键值对,而键值对的value是RedisObject对象(这个对象有一个指针可以指向各种数据结构)
高性能分布式缓存Redis--- Redis底层结构和缓存原理 --- 持续更新_第5张图片
RedisDB的结构(DB的结构)

Redis中存在“数据库”的概念,该结构有redis.h中的redisDb定义。当redis服务器初始化时,会预先分配16个数据库(DB),所有数据库保存到redisServer的一个成员redisServer.db数组中,redisClient中存在一个名叫db的指针指向当前使用的数据库
源码中RedisDB结构体的源码如下:

typedef struct redisDb{
	int id;//数据库序号,0-15(默认redis有16个数据库)
	long avg_ttl; //存储的数据库对象的平均ttl(time to live存活时间),用于统计
	dict *dict; //存储数据库所有的key-value
	dict *expires; //存储key的过期时间
	dict *blocking_keys; //blpop存储阻塞key和客户端对象
	dict *ready_keys; //阻塞后push 响应阻塞客户端 存储阻塞后push的key和客户端对象
	dict *watched_keys; //存储watch监控的key和客户端对象
} redisDb;

RedisObject结构(value的结构),value是一个对象,通过ptr指针,指向底层实现数据结构的指针(字符串,列表,哈希,集合等),源码中结构体定义如下:

typedef struct redisObject{
	unsigned type:4;//类型 五种对象类型
	unsigned encoding:4;//编码
	void *ptr; //指向底层实现数据结构的指针
	//...省略
	int refcount; //引用计数
	//...
	unsigned lru:LRU_BITS; //LRU_BITS为24bit,记录最后一次被命令程序访问的时间
	//...
}robj;

type属性,表示对象类型,占4位;比如REDIS_STRING(字符串)、REDIS_LIST(列表)等,当我们执行type命令时,就可以通过读取RedisObject的type字段获取对象类型

127.0.0.1:6379> type a1
string

encoding属性,表示对象内部编码,占4位;每个对象有不同的实现编码,可以根据不同场景来为对象设置不同编码,提高redis灵活性和效率。执行object encoding命令,可以查看对象采用的编码方式

127.0.0.1:6379> object encoding a1
"int"

lru属性:记录对象最后一次被命令程序访问的时间(4.0版本占24位,2.6版本占22位)。高16位存储一个分钟数级别的时间戳,低8位存储访问计数(lfu:最近访问次数)。lru策略和lfu策略都使用这个属性

  1. lru策略:使用高16位,记录最后被访问时间
  2. lfu策略:使用低8位,记录最近访问次数

refcount属性:记录该对象被引用次数(类型是整型),主要作用是对象的引用计数和内存回收。当refcount>1时,称为共享对象。Redis为了节省内存,当有一些对象重复出现时,新的程序不会创建新的对象,而是仍然使用原来的对象。

ptr属性:这个指针指向具体数据(数据结构,并且存储了数据),例如命令set hello world执行后,这个RedisObject对象的ptr,将指向包含字符串world的字符串数据结构(SDS简单动态字符串)。

2.2 底层数据结构:字符串SDS

字符串对象(没有直接使用C语言的字符数组,而是进行了封装。c语言是以"\0"表示字符串结束,所以一般计算数组长度,都需要+1):Redis使用SDS(Simple Dynamic String简单动态字符串,redis在c语言中定义成了结构体sdshdr)存储字符串和整型数据。如下图所示:
高性能分布式缓存Redis--- Redis底层结构和缓存原理 --- 持续更新_第6张图片
源码如下:

struct sdshdr{
	//记录buf数组中已经使用的字节的数量
	int len;
	//记录buf数组中未使用的字节数量
	int free;
	//字节数组,保存字符串
	char buf[]; //想要知道buf[]的长度,可以通过公式计算:长度 = len + free + 1
}

SDS优势

  1. SDS在C字符串的基础上加入free和len字段,获取字符串长度:SDS是O(1),C字符串是O(n),因为得遍历一遍算出来。buf数组长度=free+len+1。
  2. SDS由于记录了长度,在可能造成缓冲区溢出时会自动重新分配内存,杜绝了缓冲区溢出。
  3. 可以存取二进制数据,存取二进制数据时,会以字符串长度len来作为结束标识(不是二进制还会以"\0"来作为标识)。如果直接用C字符串,会以"\0"来作为结束标识,而空字符串和二进制数据都包括空字符串,所以没有办法存取二进制数据。

适用场景

  1. 存储字符串和整型数据
  2. 存储key(一定要记住,key是用SDS存储的,面试时候可不要说是字符串存储的就完事了)、AOF缓冲区
  3. 用户输入缓冲

2.3 底层数据结构:跳跃表(重点)

是有序集合(sorted-set)的底层实现,效率高,实现简单。基本思想就是将有序链表中的部分节点分层,每一层都有一个有序链表。说白了就是,一个链表拿过来,我不会按部就班的遍历它(比如0、1、2、3、4、5),而是玩点花的,跳着找(比如1、3、5、7)。给它分个层次(以便一次跳多点),提高遍历速度。

跳跃表的特点如下:

1. 跳跃表有多层(多个有序链表),每层是一个有序链表
2. 底层包括所有元素,而向上每一层的元素个数都成倍减少(2倍,大家都统一2倍,倍数高了跳跃表的优势就不如"树"了,使用跳跃表而不用树就是因为它简单并且高效,适合高并发,而红黑树等太过复杂,不适合高并发。)
3. 空间复杂度为O(n)也就是扩充了一倍
4. 查找次数近似于层数(1/2),比如9个元素的跳跃表,会分4层,查找元素最坏的情况下会查找4次。

查找(为了方便理解,不断的分层演示效果,实际上跳跃表构建完成后,就已经分好层次了,而不是每次执行查询等操作时都需要分层。)

查找时,先从最高层开始向后查,当到达某节点发现这个节点的next的值,大于要查找的值,或next指向null,就下降一层继续找。

假设有如下有序链表,我们要查找元素9。根据跳跃表的特性,我们不能按部就班的挨个遍历,遍历8次找到9。而是应该分层,以达到我们跳着找的目的。
在这里插入图片描述
第一次分层,我们以基数2进行分层,如下图,此时,我们发现,只需遍历5次就可以找到9。但是还不够,我们还要分,以达到更快的速度。
高性能分布式缓存Redis--- Redis底层结构和缓存原理 --- 持续更新_第7张图片
第二次分层,继续以基数2进行分层,如下图,此时,我们发现,遍历4次就可以找到9。0找到6,发现下一个是10,比9大。就降下一层,找到8。发现下一个是10,再降下一层,找到9.
高性能分布式缓存Redis--- Redis底层结构和缓存原理 --- 持续更新_第8张图片
第四层,继续以基数2分层,发现依然遍历4次找到9。
高性能分布式缓存Redis--- Redis底层结构和缓存原理 --- 持续更新_第9张图片

也就是说,跳跃表就是一堆链表(典型的空间换时间),只不过用的时候的是跳跃的,所以叫跳跃表。由上面的例子可以发现,它具有二分查找的功能

从本质上来说,跳跃表是在单链表的基础上在选取部分结点添加索引,这些索引在逻辑关系上构成了一个新的线性表,并且索引的层数可以叠加,生成二级索引、三级索引、多级索引,以实现对结点的跳跃查找的功能.下图中,每个索引的down指针,指向下层的原始结点。
高性能分布式缓存Redis--- Redis底层结构和缓存原理 --- 持续更新_第10张图片

插入操作(因为链表已经分层完成,插入想要满足二分规则(不用非常严谨的规则),可以通过统计概率学中的"频率的稳定性",以二分之一的概率(抛硬币),不断进行判断,直到达到频率的稳定性).通过抛硬币(概率1/2)的方式决定新插入结点跨越的层数

  1. 如果是正面,就插入上层
  2. 背面,就不插入
  3. 当达到1/2的概率后(计算次数),进行插入

稍微有点难理解。就是说当我确定插入到某一层时,比如第二层。我就不用管上层了,因为都是有序列表,我这里插入,就是上层两个结点之间的结点,抛硬币是为了达到一个二分的效果,让这个结点插入到一个相对合适的层数

当决定插入某一层后,需要获取要插入结点的前驱,然后为这一层和下面的每一层链表,进行插入,比如下图中要插入16这个结点,到最顶层。那么最顶层和它下面的每层都需要找到它应该插入的位置的前驱结点
高性能分布式缓存Redis--- Redis底层结构和缓存原理 --- 持续更新_第11张图片
找到以后,进行一系列插入操作,这个操作就是单链表的插入思路。
高性能分布式缓存Redis--- Redis底层结构和缓存原理 --- 持续更新_第12张图片
最终插入完成的效果
高性能分布式缓存Redis--- Redis底层结构和缓存原理 --- 持续更新_第13张图片

删除操作,就是找到每层要删除的元素的前驱,然后删除就可以了,如果每层是双向链表的话,直接找到要删除的元素就可以了

Redis跳表的实现:优点就是可以快速找到所需节点,获取头、尾结点,长度和高度(层数)等都是O(1)的时间复杂度。应用场景就是实现有序集合。
高性能分布式缓存Redis--- Redis底层结构和缓存原理 --- 持续更新_第14张图片

//跳跃表节点
typedef struct zskiplistNode{
	sds ele; //存储字符串类型数据 redis3.0版本中使用的是robj类型表示,
			// redis 4.0.1中直接使用sds类型表示
	double score; //存储排序的分值,以此来实现有序
	struct zskiplistNode *backward; //后退指针,指向当前节点最底层的前一个结点
	/*
		层,柔性数组,随机生成1-64的值
	*/
	struct zskiplistLevel{
		struct zskiplistNode *forward; //指向本层下一个及结点
		unsigned int span; //本层下一个结点到本结点的元素个数
	}level [];
} zskiplistNode;

//链表
typedef struct zskiplist{
	//表头结点和表尾结点
	struct zskiplistNode *header,*tail;
	//表中节点数量
	unsigned long length;
	//表中层数最大的结点的层数,也就是跳跃表的最大层数
	int level;
}zskiplist;

2.4 底层数据结构:字典(重点)

字典(dict,散列表,Hash表),存储键值对的一种数据结构,Redis整个数据库就是用字典存储(K-V结构)。主要用于主数据库的K-V数据存储,散列表(Has)对象,哨兵模式中主从结点管理

散列表一般由2到3种数据结构组合实现。比如数组+链表或数组+链表+红黑树。

  1. 数组+链表:一个固定长度的数组,每个数组元素是一个链表
  2. 数组+链表+红黑树:一个固定长度的数组,每个数组元素是一个链表,当链表长度达到一定值,就转换为红黑树。

因为数组可以采用头指针+偏移量的方式,以O(1)的时间复杂度定位到数据所在内存地址,所以它一般作为散列表的外层容器,在Redis海量存储的情况下,有极快的检索效率

当一个元素要插入时(key),会通过Hash函数,进行散列。目的就是通过特定hash算法,计算出这个元素应该去的数组下标。也就是将任意的字符输入(key)通过散列算法,转换成固定类型、固定长度的散列值。

  1. Hash函数可以将Redis的key(字符串、整数、浮点数统一转换为整数(数组下标))
  2. 数组下标=hash(key)%数组容量,也就是key的hash值%数组容量得到的余数就是数组下标。但是%操作是效率不高的,并且这样算出来的下标非常容易冲突。所以一般会通过位运算(让高16位参与运算),并通过系数因子(Java是0.75),让下标更加的"散"

Hash冲突:两个不同的key,经过计算后,算出的数组下标一致,就叫hash冲突。
当Hash冲突发生后,两个不同的key不会发生覆盖,而是插入链表
因此,如果Hash冲突频繁发生,会导致链表长度过长,或者导致红黑树层数过高,及其影响效率。

Redis字典的实现包括如下结构体:字典(dict)、Hash表(dictht)、Hash表结点(dictEntry),下图中dictType相当于java中的多态。
高性能分布式缓存Redis--- Redis底层结构和缓存原理 --- 持续更新_第15张图片

Redis中dictht(Hash表)的结构体实现

typedef struct dictht{
	dictEntry **table;//哈希表数组
	unsigned long size;//哈希表数组大小
	unsigned long sizemask;//映射位置的掩码,值永远等于(size-1)
	unsigned long used;//哈希表已有结点的数量,包括next单链表的数据
}dictht;

1. hash表数组初始容量为4,随着k-v存储量的增加需要对hash表数组进行扩容,扩容量为当前容量的1倍(4,8,16,32,…)
2. 索引值=Hash值&掩码值。位运算,相当于Hash值与Hash表容量取余。

Redis中dictEntry(Hash表结点)的结构体实现

typedef struct dictEntry{
	void *key;//键
	union{//值v的类型,可以是下面4种
		void *val;
		uint64_t u64;
		int64_t s64;
		double d;
	}v;
	struct dictEntry *next;//指向下一个哈希表结点,形成单向链表,解决Hash冲突。
}dictEntry;

1. key字段存储的是键值对中的键
2. v字段是个联合体,存储的是键值对中的值
3. next指向下一个哈希表结点,用于解决hash冲突
高性能分布式缓存Redis--- Redis底层结构和缓存原理 --- 持续更新_第16张图片

Redis中dict(字典)的结构体实现

typedef struct dict{
	dictType *type;//该字典对应的特定操作函数
	void *privdata;//上述类型函数对应的可选参数
	dictht ht[2];//两张hash表,存储键值对数据,ht[0]为原生哈希表,ht[1]为rehash哈希表。
	//rehash标识 当等于-1时表示没有在rehash,否则表示正在进行rehash操作
	//存储的值表示hash表ht[0]的rehash进行到哪个索引值(数组下标)
	long rehashidx;
	int iterators;//当前运行的迭代器数量
}dict;

type字段,指向dictType结构体,里面包括了对该字典操作的函数指针。就像Java的多态一样,使用的都是*type这个指针,但是dictType的实现,才是真正对应的函数操作。

Redis中dictType的结构体实现,可以理解为Java中的抽象接口

typedef struct dictType{
	//计算哈希值函数
	unsigned int (*hashFunction)(const void *key);
	//复制键的函数
	void *(*keyDup)(void *privadata,const void *key);
	//复制值的函数
	void *(*valDup)(void *privadata,const void *obj);
	//比较键的函数
	int (*keyCompare)(void *privdata,const void *key1,const void *key2);
	//销毁键的函数
	void (*keyDestructor)(void *privdata,void *key);
	//销毁值的函数
	void (*valDestructor)(void *privdata,void *obj);
}dictType;

因为Redis字典除了主数据库的K-V数据存储以外,还需要用于:散列表对象、哨兵模式中的主从结点管理等,在不同的应用中,字典的形态都可能不同,dictType是为了实现各种形态的字典而抽象出来的操作函数(多态)。

因此,完整的Redis字典结构如下图
高性能分布式缓存Redis--- Redis底层结构和缓存原理 --- 持续更新_第17张图片

字典扩容:当字典达到存储上限(当前数组的长度,不满足要求了),就需要rehash(扩容)
高性能分布式缓存Redis--- Redis底层结构和缓存原理 --- 持续更新_第18张图片

  1. 申请空间:初次申请默认容量为4个dictEntry,非初次申请,就是当前hash表容量的一倍
  2. rehashidx = 0,表示要进行rehash操作
  3. 此时,添加数据,会添加到新的hash表h[1]中
  4. 而修改、删除、查询会在h[0]和h[1]中,此时,h[0]会不断将结点移入h[1]。
  5. 最后将h[0]的数据重新计算索引值后全部迁移到新hash表h[1]中,整个这个过程叫rehash。

渐进式rehash:因为rehash整个过程是比较花费时间的,但是rehash的时候,不能就负责扩容,不对外提供服务,因此,Redis采用渐进式rehash

当数据量巨大时,rehash过程比较缓慢,需要优化。服务器比较忙的时候,只对一个节点进行rehash。当服务器比较闲的时候,可批量rehash(100个结点)

2.5 底层数据结构:列表&集合

压缩列表(ziplist):有一系列特殊编码的连续内存块组成的顺序型数据结构,有效节省内存。

  1. 用于sorted-set和hash元素个数少且存储的是较小的整数或短字符串(直接使用了ziplist)
  2. list用快速链表(quicklist)数据结构存储,而快速链表是双向列表+压缩列表(间接使用了ziplist)。

基本上就是用一个字节数组实现,可以包含多个结点(entry)。每个结点可以保存一个字节数组或一个整数。数据结构如下图:
在这里插入图片描述

  1. zlbytes:压缩列表的字节长度
  2. zltail:压缩列表尾元素相对于压缩列表起始地址的偏移量
  3. zllen:压缩列表的元素个数
  4. entry1…entryN:元素列表的各个节点,编码结构如下:
    在这里插入图片描述
  1. previous_entry_length:前一个元素的字节长度
  2. encoding:表示当前元素的编码
  3. content:数据内容
  1. zlend:压缩列表的结尾,占一个字节,恒为0xFF(255)

Redis中ziplist结构体实现如下:

typedef struct zlentry{
	unsigned int prevrawlensize; //previous_entry_length字段的长度
	unsigned int prevrawlen; 	 //previous_entry_length字段存储的内容
	
	unsigned int lensize;		 //encoding字段的长度
	unsigned int len;			 //数据内容长度
	
	unsigned int headersize;	 //当前元素的首部长度,即previous_entry_length字段长度与encoding字段长度之和
	
	unsigned char encoding;		 //数据类型
	
	unsigned char *p;			 //当前元素首地址
}zlentry;

整数集合(intset):一个有序的(整数升序)、存储整数的连续存储结构。当Redis集合类型的元素都是整数,并且都处于64位有符号整数范围内(264),将使用该结构体(intset)存储,但是如果存储的数在上述范围之外,会通过hashtable存储。
高性能分布式缓存Redis--- Redis底层结构和缓存原理 --- 持续更新_第19张图片
结构图和Redis的intset结构体实现如下:
高性能分布式缓存Redis--- Redis底层结构和缓存原理 --- 持续更新_第20张图片

typedef struct intset{
	//编码方式
	uint32_t encoding;
	//集合包含的元素数量
	uint32_t length;
	//保存元素的数组
	int8_t contents[];
}intset;

intset,可以保存类型为int16_t、int32_t或int64_t的整数值,并且保证集合中不会出现重复元素

2.6 底层数据结构:快速列表(重点)

快速列表(quicklist):列表的底层实现(Redis3.2之前,用双向链表adlist和压缩列表ziplist实现,之后redis结合adlist和ziplist的优势,设计了quicklist)
在这里插入图片描述
用于实现Redis的List、发布与订阅、慢查询、监视器等功能

双向列表(adlist):单链表的基础上,每个结点加了一个指向前驱的指针,现在一共结点,可以通过指针,找到自己的前一个和后一个结点了。
高性能分布式缓存Redis--- Redis底层结构和缓存原理 --- 持续更新_第21张图片

快速列表(quicklist):双链表的一种,但是每个结点都是一个ziplist结构,quicklist中每个结点ziplist都能存储多个数据元素。
高性能分布式缓存Redis--- Redis底层结构和缓存原理 --- 持续更新_第22张图片
Redis中定义的quicklist结构体如下:

//快速列表
typedef struct quicklist{
	quicklistNode *head;	//指向quicklist的头部
	quicklistNode *tail;	//指向quicklist的尾部
	unsigned long count;	//列表中所有数据项的个数总和
	unsigned int len;		//quicklist结点的个数,即ziplist的个数
	int fill : 16;			//ziplist大小限定,有list-max-ziplist-size给定(Redis设定)
	unsigned int compress : 16; //结点压缩深度设置,有list-compress-depth给定(Redis设定)
}quicklist;

//快速列表的结点
typedef struct quicklistNode{
	struct quicklistNode *prev;	//指向上一个ziplist结点
	struct quicklistNode *next; //指向下一个ziplist结点
	unsigned char *zl;			//数据指针,如果没有被压缩,就指向ziplist结构。否则指向quicklistLZF结构
	unsigned int sz;			//指向ziplist结构的总长度(内存占用长度)
	unsigned int count : 16;	//ziplist中的数据项个数
	unsigned int encoding : 2;	//编码方式,1--ziplist,2--quicklistLZF
	unsigned int recompress : 1;//解压标记,当查看一个被压缩的数据时,需要暂时解压,标记此参数为1,之后再重新压缩
	unsigned int attempted_compress : 1;//测试相关
	unsigned int extra : 10;	//扩展字段,暂时没用
} quicklistNode;

//LZF压缩算法,压缩后结点
typedef struct quicklistLZF{
	unsigned int sz;	//LZF压缩后占用的字节数
	char compressed[];	//柔性数组,指向数据部分
}quicklistLZF;

quicklist每个结点的实际数据存储结构为ziplist,这种结构的优势在于节省存储空间。但是Redis为了进一步降低ziplist的存储空间,对ziplist采用LZF压缩算法再次压缩。
基本思想是,如果要存储的数据和前面存储的数据重复,就记录重复的位置和长度。而不重复的,才记录原始数据。

2.7 新增的stream对象使用的listpack(紧凑列表)和Rax Tree(基数树)

stream主要由:消息、生产者、消费者和消费组构成

高性能分布式缓存Redis--- Redis底层结构和缓存原理 --- 持续更新_第23张图片

listpack紧凑列表,表示一个字符串列表的序列化。可用于存储字符串或整数。用于存储stream的消息内容。
高性能分布式缓存Redis--- Redis底层结构和缓存原理 --- 持续更新_第24张图片

Rax树:一个有序字典树(Radix Tree基数树),按key的字典序排序,支持快速定位、插入和删除操作。
高性能分布式缓存Redis--- Redis底层结构和缓存原理 --- 持续更新_第25张图片

被用在Redis Stream结构里面,用于存储消息队列。在Stream里面消息ID的前缀是时间戳+序号(可以理解为时间序列消息),使用Rax结构进行存储,就可以快速地根据消息ID定位到具体消息,然后继续遍历指定消息之后的所有消息。
高性能分布式缓存Redis--- Redis底层结构和缓存原理 --- 持续更新_第26张图片

2.8 10种编码

除了type表示对象使用的存储结构以外,还有encoding表示对象内部编码,占4位。
基本上,Redis对于比较少和比较小的数据,采用小的和压缩的存储方式,体现了redis的灵活性,提高redis存储量和执行效率。

前面我们用set对象举过例子,如果存储元素是64位以内的整数,用intset,否则用hashtable
高性能分布式缓存Redis--- Redis底层结构和缓存原理 --- 持续更新_第27张图片

string的编码

1. int(REDIS_ENCODING_INT,表示int类型的整数)

127.0.0.1:6379> set n1 123
OK
127.0.0.1:6379> object encoding n1
"int"

2. embstr(REDIS_ENCODING_EMBSTR,编码的简单动态字符串),小的字符串,长度小于44个字节,会使用embstr

127.0.0.1:6379> set name:001 zhangfei
OK
127.0.0.1:6379> object encoding name:001
"embstr"

3. raw(REDIS_ENCODING_RAW,简单动态字符串,没有编码的),大字符串,长度大于44字节,会用raw

127.0.0.1:6379> set name:001 zhangfeizhangfeizhangfeizhangfeizhangfeizhangfeizhangfeizhangfeizhangfeizhangfeizhangfeizhangfeizhangfeizhangfeizhangfeizhangfeizhangfeizhangfeizhangfeizhangfeizhang
OK
127.0.0.1:6379> object encoding name:001
"raw"
list的编码

quicklist(REDIS_ENCODING_QUICKLIST,快速列表)

127.0.0.1:6379> lpush list:001 1 2 3 4 5
(integer) 5
127.0.0.1:6379> object encoding list:001
"quicklist"
hash的编码

1. hashtable(REDIS_ENCODING_HT,就是dict,字典),当散列表元素个数较多或元素不是较小的整数或者存储的是长字符串时,编码采用hashtable,也就是前面说的dict

127.0.0.1:6379> hmset user:003 username12331321321233132132123313213212331321321233132132123313213212331321321233132132 zhangfei password 111 num 40000000000000000000000000000000000000000000000000
OK
127.0.0.1:6379> object encoding user:003 
"hashtable"

2. ziplist(REDIS_ENCODING_ZIPLIST,压缩列表),当散列表元素个数较少,元素都是较小整数或短字符串,会使用ziplist。

127.0.0.1:6379> hmset user:001 username zhangfei password 111 num 400
OK
127.0.0.1:6379> object encoding user:001
"ziplist"
set的编码

1. intset(REDIS_ENCODING_INTSET,整数集合),当redis集合类型的元素都是整数并且都处于64位有符号整数范围内(<18446744073709551616)

127.0.0.1:6379> sadd set:001 1 3 4 7 9
OK
127.0.0.1:6379> object encoding set:001
"intset"

2. dict(REDIS_ENCODING_HT,字典),当redis集合类型的元素都是整数且都处于64位有符号整数范围外(>18446744073709551616)

127.0.0.1:6379> sadd set:004 1 10000000000000000000000000000000000000 9999999999999999999999
(integer) 3
127.0.0.1:6379> object encoding set:004
"hashtable"
zset的编码

1. ziplist(REDIS_ENCODING_ZIPLIST,压缩列表),当元素个数较少,元素都是较小整数或较短字符串时,使用ziplist

127.0.0.1:6379> zadd hit:1 100 item1 20 item2 45 item3
(integer) 3
127.0.0.1:6379> object encoding hit:1
"ziplist"

2. skiplist + dict(REDIS_ENCODING_SKIPLIST,跳跃表+字典),当元素个数比较多或者存储的是较大整数或字符串时,使用skiplist

127.0.0.1:6379> zadd hit:2 100 item1111111111111111111111111111111111111111111111111111111111111111111111111111111111 20 item2 45 item3
(integer) 3
127.0.0.1:6379> object encoding hit:2
"skiplist"

3. Redis缓存原理

3.1 Redis缓存过期

redis性能非常高(官方数据显示,每秒读能力为110000次/s。写能力为81000次/s),长期使用,key会不断增加,Redis作为缓存使用时,物理内存也会满。而内存与硬盘交换(swap)虚拟内存,频繁IO,性能也会急剧下降。

redis中有maxmemory属性,可以设置redis最大使用内存限制,默认是0表示不限制,可用的物理内存多少,就是多少。一旦满了,就会和硬盘申请虚拟内存,频繁IO(swap交换)
同时redis有maxmemory-policy属性,用于设置淘汰策略,默认noeviction(禁止淘汰),也就是默认是不淘汰的,直接向硬盘申请虚拟内存

一般,可以不设置maxmemory的场景如下:

  1. redis的key是固定的,不会增加,只要初始内存充足。也就不存在内存用完的情况了。
  2. redis作为DB使用,和上面一样的道理。只不过这个内存会增加。为了保证数据完整性,不能淘汰,所以可以做集群横向扩展。

必须设置的场景如下:

redis作为缓存使用,不断增加key,这样肯定会内存满,而且无法横向扩展(key的数量都不确定)。

设置多少合适?

1个Redis实例,一般设置物理内存3/4就可以,保证系统运行留有1G或更多,剩下的都可以设置给redis。但是如果是主从架构,slaver要留出一定的内存(同步等操作需要额外的资源)

设置方式就是修改redis的配置文件,redis.conf。添加如下配置

注意:设置了maxmemory一定要配置淘汰策略maxmemory-policy,而不设置maxmemory则需要设置淘汰策略为禁止驱逐(默认的策略),maxmemory-policy noeviction。可以配置的淘汰策略有6种,如下:

# 设置最大内存为1024兆
maxmemory 1024mb
# 设置淘汰策略
maxmemory-policy 策略
/*
	1,noeviction:不执行任何淘汰策略,当达到内存限制的时候客户端执行命令会报错。
	2,allkeys-lru:从所有数据范围内查找到最近最少使用的数据进行淘汰,直到有足够的内存来存放新数据。
	3,volatile-lru:从所有的最近最少访问数据范围内查找设置到过期时间的数据进行淘汰,如果查找不到数据,则回退到noeviction。
	4,allkeys-random:从所有数据范围内随机选择key进行删除。
	5,volatile-random:从设置了过期时间的数据范围内随机选择key进行删除。
	6,volatile-ttl:从设置了过期时间的数据范围内优先选择设置了TTL的key进行删除。
*/

获取(查看)参数的命令

# CONFIG GET xxxxxx参数
CONFIG GET maxmemory

从上面的淘汰策略可以看出,部分淘汰策略,会从访问少的key中淘汰,部分淘汰策略会根据过期时间淘汰

expire数据结构,在Redis中可以使用expire命令设置一个键的存活时间(ttl:time to live),过了这段时间,键就会自动删除。
高性能分布式缓存Redis--- Redis底层结构和缓存原理 --- 持续更新_第28张图片
原理和结构体实现

typedef struct redisDb{
	dict *dict; //整个redis DB的k-v键值对
	dict *expires;  //上面dict字典中的key,对应的过期时间存储在这里
	dict *blocking_keys;
	dict *ready_keys;
	dict *watched_keys;
	int id;
}redisDb;
  1. 上面是redisDb结构体,除了id外,都是指向字典的指针,而dict和expires两个字典就是实现过期时间的关键。
  2. dict指针用来维护一个redis数据库中包含的所有K-V对,expires指针维护一个Redis数据库中设置失效时间的键。也就是key与失效时间之间有了映射。
  3. 当我们使用expire命令设置一个key的失效时间,redis首先到dict字典中查找要设置的key是否存在,如果存在就将这个key和失效时间添加到expires字典
  4. 当使用setex命令(设置值的时候直接将过期时间一起设置的原子操作,使用 set … PX 3000也是一样的,用px直接指定过期时间)向系统插入数据,redis首先将key和value添加到dict字典,然后将key和失效时间添加到expires字典

3.2 Redis删除(淘汰)策略

1. noeviction:不执行任何淘汰策略,当达到内存限制的时候客户端执行命令会报错
2. allkeys-lru:从所有数据范围内查找到最近最少使用的数据进行淘汰,直到有足够的内存来存放新数据
3. volatile-lru:从所有的最近最少访问数据范围内查找设置到过期时间的数据进行淘汰,如果查找不到数据,则回退到noeviction
4. allkeys-random:从所有数据范围内随机选择key进行删除
5. volatile-random:从设置了过期时间的数据范围内随机选择key进行删除
6. volatile-ttl:从设置了过期时间的数据范围内优先选择设置了TTL的key进行删除

redis的数据删除有定时删除、惰性删除和主动删除三种方式。目前主要采用惰性删除+主动删除的方式。

定时删除:设置键的过期时间的同时,创建定时器,让定时器在key的过期时间来临时,立即执行对键的删除操作。这种方式需要创建定时器,而且消耗CPU,一般不推荐使用。

惰性删除:key被访问时如果发现它已经失效,那么就删除它。

源码:调用expireIfNeeded函数(读取数据前,先检查它有没有失效,如果失效就删除)

int expireIfNeeded (redisDb *db,robj *key){
	//获取主键失效时间------get当前时间 - 创建时间 > ttl
	long long when = getExpire(db,key);
	//假如失效时间为负数,说明该主键未设置失效时间(失效时间默认为-1),直接返回0
	if(when < 0) return 0;
	//假如Redis服务器正在从RDB文件中加载数据,暂时不进行失效主键的删除,直接返回0
	if(server.loading) return 0;
	//....省略
	//如果上面条件都不满足,就将主键失效时间与当前时间对比,如果发现指定主键还未失效,就返回0
	if(mstime() <= when) return 0;
	//如果已经失效,首先更新关于失效主键的统计个数,然后将该主键失效的信息进行广播,最后将其从redis中删除
	server.stat_expiredkeys++;
	propagateExpire(db,key);
	return dbDelete(db,key);
}

主动删除:采用LRU算法进行淘汰

LRU(Least recently used,最近使用最少的,采用末尾淘汰法,新数据从链表头部插入,释放空间时,从末尾淘汰。如果数据是热点数据,会逐步向头部移动,反之逐步向末尾移动):此算法根据数据的历史访问记录进行淘汰,核心思想是“如果数据最近被访问过,那么将来被访问几率也更高,因此,插入时,都是从头插入”。常见的实现方式是用一个链表来保存缓存数据,详细算法实现如下:

  1. 新数据插入到链表头部
  2. 每当缓存命中(即缓存数据被访问),则将数据移动到链表头部
  3. 当链表满时,将链表尾部数据丢弃
  4. java中可以使用LinkHashMap(哈希链表)实现LRU

Redis的LRU数据淘汰机制

  1. 服务器配置中保存了lru计数器server.lrulock,会定时(redis定时程序serverCorn())更新,server.lrulock的值是根据server.unixtime计算得出。
  2. 前面介绍redisObject结构体中,每个redis对象都会设置相应的lru。每次访问数据时,都会更新redisObject.lru
  3. LRU数据淘汰机制为,在数据集中随机挑选几个键值对,取出其中lru最大的键值对淘汰。也就是他不会遍历key(key是非常多的)
  4. 挑选完成后,用当前时间-最近访问时间,越大,说明访问间隔越长
  1. volatile-lru淘汰策略:从已经设置过期时间的数据集(server.db[i].expires)中挑选最近最少使用的数据淘汰。
  2. allkeys-lru淘汰策略:从数据集(server.db[i].dict)中挑选最近最少使用的数据淘汰。

LFU(Least frequently used,最近不经常使用),如果一个数据最近一段时间内使用次数很少,那么将来一段时间内被使用几率也很小。对应淘汰策略是volatile-lfu和allkeys-lfu。用的太少,所以我没有列在上面。和LRU差不多,只是它选择不常用的,而不是使用最少的。

random随机,对应淘汰策略为:volatile-random,从已经设置过期时间的数据集(server.db[i].expires)中任意选择数据淘汰。allkeys-random,从数据集(server.db[i].dict)中任意选择数据淘汰

ttl(有效时间),对应策略volatile-ttl,从已设置过期时间的数据集(server.db[i].expires)中挑选将要过期的数据淘汰。redis数据集数据结构中保存了键值对过期时间和表(redisDb.expires)。TTL淘汰机制就是从过期时间表中随机挑选几个键值对,取出ttl最小的淘汰。

3.3 如何选择淘汰策略

  1. allkeys-lru:不确定时,一般选这个就行,可以实现冷热数据交换。
  2. volatile-lru:比allkeys-lru性能差,需要存过期时间
  3. allkeys-random:希望请求,可以符合平均分布(每个元素以相同的概率被访问)
  4. volatile-ttl :自己用过期时间控制,会有缓存击穿的问题,需要特殊处理(后面章节会讲)
  5. noenviction:不淘汰。如果reids要作为数据库使用(key的个数在自己掌控范围,不会像缓存一样频繁生成),请不要设置内存大小(maxmemory)。请使用默认淘汰策略noenviction,因为这些数据key一旦被淘汰,就会出现缓存击穿。

你可能感兴趣的:(数据库,java,redis)