爆肝!整理了2.2W字的Redis八股——小林coding精简版
本来是想看guide哥的,但确实一些高频考点讲的不是很细。所以就直接all in 小林了。后续会持续更新,把一些低频考点也补上。
期待的话请多多为我点赞收藏吧QAQ
Redis
基础
Redis
定义:基于 C 语言开发的开源 NoSQL 数据库。数据存储在内存中,支持持久化。数据是KV键值对数据。
好处:
- 高性能:访问速度快
- 高并发
- 功能全面
-
- 分布式锁
- 限流
- 消息队列
为什么Redis这么快
- redis基于内存,内存的访问速度要比磁盘快
- redis内置多种优化后的数据类型,其底层编码会随状态而变化
- redis基于reactor模式设计开发了一套高效事件处理模型,主要是单线程事件循环和IO多路复用
- redis通信协议实现简单且解析高效
分布式缓存常见的技术选型方案有哪些?
以前有Memcached。腾讯开源的Tendis,但几乎不维护更新了。
目前,比较业界认可的 Redis 替代品还是下面这两个开源分布式缓存(都是通过碰瓷 Redis 火的):
- Dragonfly:一种针对现代应用程序负荷需求而构建的内存数据库,完全兼容 Redis 和 Memcached 的 API,迁移时无需修改任何代码,号称全世界最快的内存数据库。
- KeyDB: Redis 的一个高性能分支,专注于多线程、内存效率和高吞吐量。
首选还是redis。
什么是Redis Module?
在redis4.0之后,支持使用module来扩展功能。这些module以动态链接库(so文件)的形式被加载到redis。
我们可以基于redis来定制开发module,比如实现搜索引擎功能,自定义分布式锁和分布式限流。
目前,被 Redis 官方推荐的 Module 有:
- RediSearch:用于实现搜索引擎的模块。
- RedisJSON:用于处理 JSON 数据的模块。
- RedisGraph:用于实现图形数据库的模块。
- RedisTimeSeries:用于处理时间序列数据的模块。
- RedisBloom:用于实现布隆过滤器的模块。
- RedisAI:用于执行深度学习/机器学习模型并管理其数据的模块。
- RedisCell:用于实现分布式限流的模块。
- ……
关于 Redis 模块的详细介绍,可以查看官方文档:redis.io/modules
应用
Redis 除了做缓存,还能做什么?
- 分布式锁:基于Redisson实现。
- 限流:使用redis+lua来实现。也可以直接使用Redisson的
RRateLimiter
来实现分布式限流,底层就是基于lua+令牌桶算法 - 消息队列:自带的list数据类型可以作为简单的消息队列使用。redis5.0新增的 Stream类型 更适合做消息队列,它类似kafka,有主题和消费组的概念,支持消息持久化和ack机制。
- 复杂业务场景:使用redis或者redis扩展提供的数据结构实现。比如bitmap统计活跃用户,sortedset维护排行榜。
基于redis实现分布式锁
数据类型
常见数据类型
5种基本数据类型:
- String
- List
- Hash
- Set
- Zset
3种特殊数据类型:
- HyperLogLog
- Bitmap
- Geospatial
String
定义:redis中最常用的数据类型,是二进制安全的,可以用来存储任何类型数据,如字符串,整数,浮点数、序列化后的对象。
场景:
- 缓存对象:JSON数据,或者通过key来分离 user:id:属性
- 常规的计数:redis处理命令是单线程的,所以适用于计数场景,如文章阅读量,订单个数。
- 分布式锁:为锁加上过期时间防止锁无人解锁一直存在,解锁时需要用lua脚本里判断(保证原子性)是否是持锁的客户端,是才能解锁。
- 共享session:在分布式系统中,session保存在各自的服务器中,如果用户访问不同的服务器,session就不再通用,所以需要用redis来作一个分布式服务器的session统一。
底层数据结构
string类型的底层数据结构实现主要是 int 和 简单动态字符串(SDS),SDS相比c语言的字符串:
- 不仅能保存文本数据,还能保存二进制数据(比如图片):因为它使用len属性来判断字符串是否结束,而不是空字符来判断,并且SDS的api也都是以二进制的方式处理数据的。
- 获取字符串长度的时间复杂度是O(1):因为sds结构里len属性记录了字符串长度。
- SDS API是安全的,拼接字符串不会造成缓冲区溢出:因为SDS在拼接字符串前会先检查SDS空间是否足够,不够就扩容。
内部编码
- int
- embstr
- raw
int
如果字符串对象保存的是整数,并且在long类型的范围内,那么字符串对象结构里的ptr将直接转为long类型并存储该值,并将字符串对象的编码设置为int
(没错虽然是long类型的值,但是字符串编码是int)
embstr
如果字符串对象保存的是字符串,并且字符串大小在44字节内,那么它将以简单动态字符串(SDS)来保存该字符串,并把字符串对象编码设为embstr
raw
如果字符串对象保存的是字符串,并且字符串大小大于44字节,那么它将以简单动态字符串(SDS)来保存该字符串,并把字符串对象编码设为raw
不同rredis版本embstr和raw的边界值是不一样的
在redis5.0以后是44字节
embstr和raw编码都使用SDS来存值,但embstr编码只需要一次内存分配,因为它的SDS和redisobject是连在一起的(连续内存),而raw编码需要两次内存分配,一次redisobject,一次SDS。
好处:
- embstr编码的字符串对象内存分配次数更少,包括内存释放也只需要一次,减少内存碎片
- embstr编码的字符串对象所有数据保存在一块连续内存可以更好利用cpu缓存提升性能
坏处:
- 如果字符串长度增加需要重新分配内存时,就要同时重新分配redisobject和sds的内存空间,所以embstr编码的字符串对象直接设计成了只读,当我们对该编码下的字符串对象进行修改时,实际会把该字符串对象的编码改为raw,然后再执行命令。
List
定义:字符串列表,按插入顺序排序,可以在头尾两端操作元素。列表最大长度为2^32 - 1
场景:
- 消息队列:有普通的和阻塞式读取。
处理重复消息:可以通过给消息生成一个全局唯一id,然后在收到消息时拿消息id和记录中已处理的消息id比对。如
LPUSH mq "111000102:stock:99"
保证消息可靠性:使用BRPOPLPUSH
命令,在读取消息的同时,把该消息插入到另一个list中备份。如果消息没能正常处理,就可以通过从备份list中重新读取消息处理了。 坏处:
-
- 不支持多个消费者消费同一条消息:因为一个消费者消费完就删除了。(解决方法:Stream类型支持消费组形式读取)
底层数据结构
QuickList
Hash
定义:键值对集合。它的value形式是field对应value。
场景:
- 缓存对象:String类型也可以,但相比它,对于对象中的属性修改要方便很多。
- 购物车:用户id为key,商品id为field,商品数量为value
底层数据结构
- ZipList:如果哈希类型元素个数小于512个,所有值小于64字节,就使用压缩列表。
- Dict:不满足ZipList条件就使用哈希表。
Set
定义:无序、唯一的键值集合。集合的最大容量为2^32 -1
场景:
- 点赞:key为文章id,value为用户id
- 共同关注:
SINTER
取交集 - 抽奖活动:key为抽奖活动名,value为员工名,如果运行重复中奖,使用
SRANDMEMBER
命令;如果不允许重复中奖,使用SPOP
命令
底层数据结构
- Intset:如果集合内元素全为整数并且个数小于512个,就会使用整数集合作为Set的底层数据结构。
- Dict:不满足Intset的使用条件,就使用哈希表。
Zset
定义:有序集合,相比set多了一个score用于排序。所以每个存储元素有score和value两个值。
场景:
- 最新列表:先插入的score小,后插入的score大
- 排行榜:key为用户,score为点赞数,value为文章id
- 电话、姓名排序:score统一设为同一个值,这样就会仅靠value进行排序,并且能达到去重效果。
底层数据结构
- ZipList:如果有序集合内元素小于128个,并且每个元素的值小于64字节,就使用压缩列表
- SkipList:如果不满足ZipList,就使用跳表 ps:其实是跳表+哈希表,只不过哈希表只用于获取元素的权重,大部分操作还是跳表实现的。
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
BitMap
定义:位图。是一串连续的二进制数组(只含0和1)。可以通过偏移量来定位元素。元素使用bit作为单位,能节省内存空间。
场景:
- 签到统计:key为年月,offset为 当天-1,value为1,
BITCOUNT
来统计签到次数 - 判断用户登录态:key为用户登录状态集合,offset为用户id
- 连续签到用户总数:key为日期,offset为用户id,要连续几天的签到,就设置几个BitMap,对这些BitMap做与运算,就能得到满足连续签到n天条件的用户。
底层数据结构
使用String类型,Redis把String类型的字节数组的每个bit位利用起来,作为元素的二值判断。所以可以把bitmap看做bit数组。
常用命令
bitmap 基本操作:
# 设置值,其中value只能是 0 和 1
SETBIT key offset value
# 获取值
GETBIT key offset
# 获取指定范围内值为 1 的个数
# start 和 end 以字节为单位
BITCOUNT key start end
bitmap 运算操作:
# BitMap间的运算
# operations 位移操作符,枚举值
AND 与运算 &
OR 或运算 |
XOR 异或 ^
NOT 取反 ~
# result 计算的结果,会存储在该key中
# key1 … keyn 参与运算的key,可以有多个,空格分割,not运算只能一个key
# 当 BITOP 处理不同长度的字符串时,较短的那个字符串所缺少的部分会被看作 0。返回值是保存到 destkey 的字符串的长度(以字节byte为单位),和输入 key 中最长的字符串长度相等。
BITOP [operations] [result] [key1] [keyn…]
# 返回指定key中第一次出现指定value(0/1)的位置
BITPOS [key] [value]
HyperLogLog
定义:一个用于统计基数的集合,基数统计就是统计一个集合中不重复元素的个数。它是基于概率完成的,结果并非非常精确。标准误算率是 0.81%
好处:在输入元素数量或体积很大时,计算基数所需的内存总是固定的,并且很小。每个HyperLogLog键只需要12kb内存就能算出近2^64个不同元素的基数。
场景:
- 统计大用户量的网页UV,key为网页,element为用户id。(如果要精确统计,还是使用Set或Hash类型)
常见命令
# 添加指定元素到 HyperLogLog 中
PFADD key element [element ...]
# 返回给定 HyperLogLog 的基数估算值。
PFCOUNT key [key ...]
# 将多个 HyperLogLog 合并为一个 HyperLogLog
PFMERGE destkey sourcekey [sourcekey ...]
底层数据结构
太难,不会。
GEO
定义:存储地理位置。
场景:
- 打车:key为所有车辆的集合,经纬度,然后member为车辆id
底层数据结构
使用的SortedSet类型,对经纬度进行编码,然后转换为对应元素的权重分数。
常用命令
# 存储指定的地理空间位置,可以将一个或多个经度(longitude)、纬度(latitude)、位置名称(member)添加到指定的 key 中。
GEOADD key longitude latitude member [longitude latitude member ...]
# 从给定的 key 里返回所有指定名称(member)的位置(经度和纬度),不存在的返回 nil。
GEOPOS key member [member ...]
# 返回两个给定位置之间的距离。
GEODIST key member1 member2 [m|km|ft|mi]
# 根据用户给定的经纬度坐标来获取指定范围内的地理位置集合。
GEORADIUS key longitude latitude radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC] [STORE key] [STOREDIST key]
Stream
定义:专为消息队列设置的数据类型
原来用List实现的消息队列,有着 无法重复消费消息,消息无法持久化 的缺陷。而Stream可以解决。
好处:
- 支持消息的持久化
- 支持自动生成全局唯一 ID,解决消息重复消费问题
- 支持 ack 确认消息的模式,解决消息未处理完丢失的问题
- 支持消费组模式,解决消息不能被多个消费者消费的问题
场景:
- 消息队列
全局id在插入消息后自动返回,由两部分组成,一部分是服务器当前计算的时间,另一部分是消息序号,从0开始计数。
# * 表示让 Redis 为插入的数据自动生成一个全局唯一的 ID
# 往名称为 mymq 的消息队列中插入一条消息,消息的键是 name,值是 xiaolin
> XADD mymq * name xiaolin
"1654254953808-0"
XREAD
读取消息可以从指定消息id的下一个消息开始读取
# 从 ID 号为 1654254953807-0 的消息开始,读取后续的所有消息(示例中一共 1 条)。
> XREAD STREAMS mymq 1654254953807-0
1) 1) "mymq"
2) 1) 1) "1654254953808-0"
2) 1) "name"
2) "xiaolin"
阻塞读可以在XREAD后面加上BLOCK
配置项,单位为毫秒
# 命令最后的“$”符号表示读取最新的消息
> XREAD BLOCK 10000 STREAMS mymq $
(nil)
(10.00s)
XGROUP
可以创建消费组,XREADGROUP
可以让消费组内的消费者读取消息。
# 创建一个名为 group1 的消费组,0-0 表示从第一条消息开始读取。
> XGROUP CREATE mymq group1 0-0
OK
# 创建一个名为 group2 的消费组,0-0 表示从第一条消息开始读取。
> XGROUP CREATE mymq group2 0-0
OK
# 消费组 group1 内的消费者 consumer1 从 mymq 消息队列中读取所有消息的命令如下
# 命令最后的参数“>”,表示从第一条尚未被消费的消息开始读取。
> XREADGROUP GROUP group1 consumer1 STREAMS mymq >
1) 1) "mymq"
2) 1) 1) "1654254953808-0"
2) 1) "name"
2) "xiaolin"
同一个消费组不能消费同一条消息,不同的消费组可以,前提是创建消费组的时候消费消息开始位置相同。
使用消费组的目的是为了让不同的消费者共同承担消费消息。
常见命令
- XADD:插入消息,保证有序,可以自动生成全局唯一 ID;
- XLEN :查询消息长度;
- XREAD:用于读取消息,可以按 ID 读取数据;
- XDEL : 根据消息 ID 删除消息;
- DEL :删除整个 Stream;
- XRANGE :读取区间消息
- XREADGROUP:按消费组形式读取消息;
- XPENDING 和 XACK:
-
- XPENDING 命令可以用来查询每个消费组内所有消费者「已读取、但尚未确认」的消息;
- XACK 命令用于向消息队列确认消息处理已完成;
基于 Stream 实现的消息队列,如何保证消费者在发生故障或宕机再次重启后,仍然可以读取未处理完的消息?
Stream会使用内部队列(pending list)来存储消费者中读取但未处理的消息。
消费者在处理完消息后,需要执行XACK命令来确认消息已经处理完。如果没有发送该命令,消息就会留存在内部队列。我们可以使用XPENDING
命令来读取这些未被处理的消息。
127.0.0.1:6379> XPENDING mymq group2
1) (integer) 3
2) "1654254953808-0" # 表示 group2 中所有消费者读取的消息最小 ID
3) "1654256271337-0" # 表示 group2 中所有消费者读取的消息最大 ID
4) 1) 1) "consumer1"
2) "1"
2) 1) "consumer2"
2) "1"
3) 1) "consumer3"
2) "1"
如果想查看某个消费者具体读取了哪些数据,可以执行下面的命令:
# 查看 group2 里 consumer2 已从 mymq 消息队列中读取了哪些消息
> XPENDING mymq group2 - + 10 consumer2
1) 1) "1654256265584-0"
2) "consumer2"
3) (integer) 410700
4) (integer) 1
此时,一旦消息"1654256265584-0"被消费者2处理了,消费者2就可以使用XACK
来通知Stream确认处理完该消息,随后该消息就会被移出内部队列。
> XACK mymq group2 1654256265584-0
(integer) 1
> XPENDING mymq group2 - + 10 consumer2
(empty array)
基于 Stream 消息队列与专业的消息队列有哪些差距?
- 消息不丢失
- 消息可堆积
Stream在三个环节对于消息丢失情况讨论:
- 生产者:只要能正常收到mq的ack确认就代表消息发送成功,如果返回异常就重发就行,所以这个阶段不会丢消息。
- 消费者:Stream提供了内部队列(PENDING List)来保存消费者读取了但未处理的消息,就算消费端出问题,也能在恢复后通过该队列继续处理消息,并最终向Stream发送XACK确认命令。所以也不会丢消息。
- Redis中间件:可能会丢消息。
-
- AOF持久化为每秒写盘,写盘操作是异步的,可能会数据丢失
- 主从复制也是异步的,也可能会数据丢失
Stream 消息可堆积吗?
可以堆积但不多,但如果消息堆积过多,会导致内存占用过高,有Out Of Memory的风险。所以Stream有指定队列的最大长度,当队列消息超过上限后,旧消息会被删除,也就是会丢失数据。
而专业的消息队列数据都存储在磁盘上,就算出现消息堆积也就多占点磁盘空间。
Redis 发布/订阅机制为什么不可以作为消息队列?
- 它不具备数据持久化能力,消息容易丢失。
- 消息不能暂存。发布者发布消息后,如果订阅者离线就没法接收到消息了。
数据结构
键值对数据库
使用 哈希表 来保存键值对。
- redisDB:表示redis的结构,里面存放了指向dict结构的指针
- dict结构:存放了两个hash表,一个是存储数据,一个是rehash时使用。
- dictht 结构:哈希表的结构,里面存放了哈希表数组,数组里每个元素都是一个指向hash节点的指针
- dictEntry 结构:hash节点的结构,里面分别有key的指针和value的指针,key指针指向String对象,value指针可以指向各种数据类型的对象。
key和value指针指向的都是redis对象,由redisobject表示。
- type:表示对象是哪种数据类型(String,List,Hash...)
- encoding:表示对象的底层数据结构。
- ptr:指向底层数据结构的指针
SDS
定义:Redis是用c语言实现的,但字符串并没有使用c语言的char*数组,而是使用了简单动态字符串作为数据结构来表示字符串。
不使用c语言的字符串的原因
- 获取字符串长度时间为O(n)——需要遍历到'\0'字符为止
- 字符串不能保存二进制数据——遇到“\0”字符就会认为读到了末尾。
- 字符串操作函数效率不高(遍历到"\0"),并且不安全,如strcat拼接函数有缓冲区溢出风险。——c语言字符串不会记录自身缓冲区大小,所以在原字符串内存不足时增加过多的字符,可能会出现缓冲区溢出,造成程序终止。
结构设计
- len:记录字符串长度,所以获取时只需要O(1)时间复杂度(虽然sds不再需要 \0 来标记结尾了,但为了兼容c语言的字符串函数,仍然会加上)
- alloc:分配的空间长度,所以alloc-len就可以获得剩余内存空间,可用于判断剩余空间是否满足字符串操作需求,不满足就可以自动扩容,能避免缓冲区溢出问题 扩容函数:先计算出新的需要的容量,然后判断是否小于1MB,如果是,就扩容为所需容量的2倍;如果不是,就扩容到所需容量+1MB。
- flags:用于表示不同类型的sds,一共有五种,sdshdr5,sdshdr8,sdshdr16,sdshdr32,sdshdr64
- buf[] :字符数组,存放实际数据,可以存储二进制数据。
flags成员变量设计
五种类型的区别在于,len和alloc的数据类型不同
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len;
uint16_t alloc;
unsigned char flags;
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
uint32_t len;
uint32_t alloc;
unsigned char flags;
char buf[];
};
sdshdr16
类型的 len 和 alloc 的数据类型都是uint16_t
,表示字符数组长度和分配空间大小不能超过 2 的 16 次方。sdshdr32
则都是uint32_t
,表示表示字符数组长度和分配空间大小不能超过 2 的 32 次方。
设计不同数据类型的len和alloc,可以灵活地保存各种大小的字符串,来节约空间。
除此之外,编译中还添加了__attribute__ ((packed))
来优化,取消了字节优化对齐,改为按实际占用字节对齐。
不添加__attribute__ ((packed))
,如
struct test1 {
char a;
int b;
} test1;
链表
定义:C语言没有设计链表数据结构,所以redis自己设计了一个。是一个双向链表。
好处:
- 获取表头和表尾节点的复杂度为O(1),获取某个节点的上个或下个节点也是。
- 获取链表节点数量时间复杂度为O(1)——链表结构提供了len属性。
- 链表节点的值可以是不同数据类型——链表节点的value属性的数据类型是void指针,并且有dup,free,match函数指针来对节点设置个性的实现。
缺点:
- 无法很好利用cpu缓存——链表节点内存是不连续的,而只有像数组那样连续内存空间才能更充分地利用cpu缓存来加速访问。
- 链表节点的结构内存开销比较大。
因此,redis的list对象在数据较少时,底层使用压缩列表来节约内存空间。
不过压缩列表存在性能问题,所以redis在后续版本设计了新的数据结构QuickList,并让它作为list的底层实现
后续redis又设计了新的数据结构listpack,它也使用了压缩列表的紧凑型内存布局,并把它作为压缩列表的替代,作为hash对象和zset对象的底层实现。
结构设计
链表节点结构
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;
dup,free,match函数可自定义实现。
压缩列表
定义:一种内存紧凑型数据结构,它占用一段连续的内存空间,可以利用cpu缓存优化,而且可以针对不同长度的数据进行相应的编码来节约内存占用。
缺点:
- 不能存储过多数据,否则查询效率会降低
- 新增或修改元素时,内存会重新分配,可能会导致连锁更新问题。
所以,一般在redis对象(LIst,Hash,Zset)包含元素较少时,才会使用压缩列表作为底层数据结构。
结构设计
压缩列表结构
zlbytes
:记录整个压缩列表占用对内存字节数;zltail
:记录压缩列表「尾部」节点距离起始地址由多少字节,也就是列表尾的偏移量;zllen
:记录压缩列表包含的节点数量;zlend
:标记压缩列表的结束点,固定值 0xFF(十进制255)。
如果只是查找首尾元素,那么可以直接通过zltail和三字段的内存占用作偏移量直接得到,而其他元素就只能通过遍历了,所以并不适合存储过多元素。
压缩列表节点结构
- prevlen:记录前一个节点的长度,用于从后往前的节点遍历
- encoding:记录当前节点数据的类型和长度,类型分为 字符串 和 整数
- data:记录当前节点的数据,类型和长度由encoding决定
当往压缩列表插入数据,压缩列表会自动判断数据类型是字符串还是整数,并且使用对应的prevlen和encoding来封装数据,这样作更能节省内存。
prevlen
- 如果前一个节点的长度小于254字节,就只用1字节空间来保存这个值
- 如果前一个节点的长度大于等于254字节,就要用5字节空间来保存这个值
encoding
- 如果节点数据是整数,那么encoding字段长度为1字节。而不同的encoding编码也确认了数据的不同类型和长度。
- 如果节点数据是字符串,那么encoding字段长度是1/2/5节点,不同的encoding编码也确认了数据的不同长度。
连锁更新
当压缩列表新增或修改一个元素时,如果原有空间不够,该压缩列表节点的内存就要重新分配。而如果此时其他后续的节点的长度都在250-253字节范围内,当一个节点的prevlen字段长度增加,整个节点的长度也超出了254节点,那么它又会导致下一个节点的prevlen字段长度增加,由此形成连续多个节点内存的连锁更新。反过来prevlen字段长度减小也会。
哈希表
定义:保存键值对的数据结构。key是唯一的,value不唯一。
好处:
- 快速查询数据——通过对key进行哈希计算,计算出在哈希数组里的索引值
缺点:
- 数据过多时哈希冲突概率会变大 解决方法:使用拉链法来解决哈希冲突。
结构设计
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;
哈希表节点不仅包含了指向键和值的指针,还包含了指向下一个哈希表节点的指针。这就是拉链法的实现原理。
值: 哈希表节点里定义的值是一个联合体,它可以是一个void指针,也可以是无符号或有符号的64位整数,也可以是double类的值,这样如果是整数或者浮点数时,就可以直接把值嵌入数据结构里,而不是单独再去开辟一块内存。
哈希冲突
一个键值对的键经过hash函数计算后得到的哈希值,并对其进行%哈希数组大小,得到的结果就是对应的哈希数组的下标。当两个不同的key经过运算后得到了相同的下标,就是哈希冲突。
链式哈希(拉链法)
哈希表节点上有一个next指针,用于指向下一个哈希表节点,被分配在同一个哈希数组的节点之间就可以通过这种方法连接起来,解决哈希冲突。
坏处:如果链表长度过长,查询时间会变为O(n)
解决这个问题,需要rehash对哈希表进行扩容。
rehash
在实际使用哈希表时,redis定义了一个结构体,结构体里定义了两个哈希表。
typedef struct dict {
…
//两个Hash表,交替使用,用于rehash操作
dictht ht[2];
…
} dict;
其中第二个哈希表就是用来rehash扩容的。
正常情况,插入的数据只会写入哈希表1,而哈希表2没有被分配空间。当数据慢慢增多到负载因子超出阈值,会进行三步操作
- 给哈希表2分配空间,空间为第一个大于等于哈希表1已有节点数量+1的2的幂(收缩时是第一个大于等于哈希表1已有节点数量的2的幂)
- 把哈希表1已有的数据迁移到哈希表2
- 释放原哈希表1的空间,把哈希表2设为哈希表1,然后再在把哈希表2设为空的哈希表,用于下一次的rehash
如果哈希表1数据非常大,在迁移时会拷贝大量数据,可能会造成redis的阻塞。所以redis采用了渐进式rehash扩容
渐进式rehash
在数据迁移时不再是一次性迁移完,而是在每次对哈希表进行crud的同时,顺便做一次数据迁移。(dict有一个rehashidx字段,可以维护每次迁移原哈希数组的下标)。当然,如果长期不操作哈希表,redis也会通过后台定期进行rehash操作。
在此期间,对key的新增操作是直接写入哈希表2的,而查询、修改和删除操作都是从哈希表1到哈希表2依次查找。这样可以保证哈希表1的数据只减不增。
rehash触发条件
和负载因子有关
- 当负载因子大于等于 1 ,并且 Redis 没有在执行 bgsave 命令或者 bgrewiteaof 命令,也就是没有执行 RDB 快照或没有进行 AOF 重写的时候,就会进行 rehash 操作。
- 当负载因子大于等于 5 时,此时说明哈希冲突非常严重了,不管有没有有在执行 RDB 快照或 AOF 重写,都会强制进行 rehash 操作
- 收缩时,负载因子要小于0.1
整数集合
定义:只存储整数的集合。当Set对象内只包含整数,并且数量不多时,使用该数据结构作为底层实现
结构设计
typedef struct intset {
//编码方式
uint32_t encoding;
//集合包含的元素数量
uint32_t length;
//保存元素的数组
int8_t contents[];
} intset;
保存元素的容器是一个数组,所以说它占用的是一段连续内存空间。虽然它的数据类型被声明为int8_t
,但它其实是一个柔性数组(c语言的一种特性),它在实际使用时,真正的数据类型取决于encoding
属性。
- 如果 encoding 属性值为 INTSET_ENC_INT16,那么 contents 就是一个 int16_t 类型的数组,数组中每一个元素的类型都是 int16_t;
- 如果 encoding 属性值为 INTSET_ENC_INT32,那么 contents 就是一个 int32_t 类型的数组,数组中每一个元素的类型都是 int32_t;
- 如果 encoding 属性值为 INTSET_ENC_INT64,那么 contents 就是一个 int64_t 类型的数组,数组中每一个元素的类型都是 int64_t;
柔性数组在分配内存空间时,按encoding对应的实际数据类型进行分配;在插入数据时,也是通过指针强转,以实际数据类型插入数据;在读取时,也是依靠指针强转成实际数据类型的指针来读取。
升级操作
当新元素加入到集合里时,新元素的数据类型比原有元素的数据类型要更大时,整数集合会先升级,按新元素的数据类型进行扩容,然后把旧元素的数据类型改为新元素的数据类型,并保持有序性地添加回去,最后添加新元素。
整数集合升级不会重新分配一个新数组,而是在原有的数组上进行扩展空间。
整数集合不支持降级操作,也就是一旦对数组升级就不会再降级。
好处:可以节约内存资源——一开始不用设置为大数据类型,而是在需要时再扩容。
跳表
定义:多层的有序链表。当数据量很大时,它能够解决链表的查询效率低问题,从O(n)变为O(logn)
结构设计
跳表结构
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
} zskiplist;
- 跳表的头尾节点,可以O(1)获得
- 跳表的长度,可以O(1)获得
- 跳表的最大层级,可以O(1)获得
跳表节点结构
typedef struct zskiplistNode {
//Zset 对象的元素值
sds ele;
//元素权重值
double score;
//后向指针
struct zskiplistNode *backward;
//节点的level数组,保存每层上的前向指针和跨度
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned long span;
} level[];
} zskiplistNode;
Zset对象要同时保存元素和元素权重,就是靠sds类型的ele属性和double类的score。每个跳表节点还有一个向后指针用来从后往前遍历节点。
跳表是带有层级关系的链表。跳表节点中的level数组,每一个元素就代表一层,结构体中包含下一个跳表节点的指针和跨度(用来记录两节点之间的距离)
跨度不是用来遍历操作的,遍历只需要向前指针就行了。跨区是用来计算节点在跳表中的排位的,如图中的跳表节点序号,该节点前的某层路径上的跨度累加起来就是该节点的排位。
头节点也是跳表节点,只是没有向后指针,权重和元素值。
跳表节点查询过程
查询一个跳表节点,会先从跳表头结点的最高层开始找,一层一层地遍历,通过元素权重和sds类元素来进行大小判断。
- 如果当前节点的权重「小于」要查找的权重时,跳表就会访问该层上的下一个节点。
- 如果当前节点的权重「等于」要查找的权重时,并且当前节点的 SDS 类型数据「小于」要查找的数据时,跳表就会访问该层上的下一个节点。
如果上面两条件都不满足,或者下一个节点为null,就会在当前节点的level数组里找下一层的指针继续查找,如此循环。
举个例子,下图有个 3 层级的跳表。
如果要查找「元素:abcd,权重:4」的节点,查找的过程是这样的:
- 先从头节点的最高层开始,L2 指向了「元素:abc,权重:3」节点,这个节点的权重比要查找节点的小,所以要访问该层上的下一个节点;
- 但是该层的下一个节点是空节点( leve[2]指向的是空节点),于是就会跳到「元素:abc,权重:3」节点的下一层去找,也就是 leve[1];
- 「元素:abc,权重:3」节点的 leve[1] 的下一个指针指向了「元素:abcde,权重:4」的节点,然后将其和要查找的节点比较。虽然「元素:abcde,权重:4」的节点的权重和要查找的权重相同,但是当前节点的 SDS 类型数据「大于」要查找的数据,所以会继续跳到「元素:abc,权重:3」节点的下一层去找,也就是 leve[0];
- 「元素:abc,权重:3」节点的 leve[0] 的下一个指针指向了「元素:abcd,权重:4」的节点,该节点正是要查找的节点,查询结束。
跳表节点层级设置
跳表的相邻两层的节点数量比例会影响查询性能。
理想的相邻两层跳表节点比例是2:1 ,查询复杂度就是O(logn)
但是redis并没有在新增或删除节点时来调整跳表节点比例,因为这样会需要额外开销。redis选择在创建节点时,生成随机数来确定节点的层数。——随机值在0到1的范围内,如果小于0.25,则层数加一,然后继续生成随机数,直到随机数大于0.25,最后确定了跳表节点的层数。
选择0.25是平衡了时间复杂度和空间复杂度。
跳表的层数最高为64(redis7.0定义为32),并且在创造头结点时,会直接创建64层高的头节点。
为什么Zset用跳表而不是平衡树?(todo)
- 它们不是非常内存密集型的。基本上由你决定。改变关于节点具有给定级别数的概率的参数将使其比 btree 占用更少的内存。
- Zset 经常需要执行 ZRANGE 或 ZREVRANGE 的命令,即作为链表遍历跳表。通过此操作,跳表的缓存局部性至少与其他类型的平衡树一样好。
- 它们更易于实现、调试等。例如,由于跳表的简单性,我收到了一个补丁(已经在Redis master中),其中扩展了跳表,在 O(log(N) 中实现了 ZRANK。它只需要对代码进行少量修改。
- 从内存占用上来比较,跳表比平衡树更灵活一些。平衡树每个节点包含 2 个指针(分别指向左右子树),而跳表每个节点包含的指针数目平均为 1/(1-p),具体取决于参数 p 的大小。如果像 Redis里的实现一样,取 p=1/4,那么平均每个节点包含 1.33 个指针,比平衡树更有优势。
- 在做范围查找的时候,跳表比平衡树操作要简单。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在跳表上进行范围查找就非常简单,只需要在找到小值之后,对第 1 层链表进行若干步的遍历就可以实现。
- 从算法实现难度上来比较,跳表比平衡树要简单得多。平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而跳表的插入和删除只需要修改相邻节点的指针,操作简单又快速。
QuickList
定义:就是双向链表+压缩列表,因为一个QuickList就是一个双向链表,而链表的每个节点内保存的是压缩列表。
它解决(规避)了压缩列表“连锁更新”的问题——通过控制每个链表节点中压缩列表的大小,如果压缩列表大小大了我就直接再加个节点创一个新的压缩列表,这样保持压缩列表元素不多,可以提供更高效的查询。
使用:List
结构设计
链表结构
typedef struct quicklist {
//quicklist的链表头
quicklistNode *head; //quicklist的链表头
//quicklist的链表尾
quicklistNode *tail;
//所有压缩列表中的总元素个数
unsigned long count;
//quicklistNodes的个数
unsigned long len;
...
} quicklist;
链表节点结构
quicklistNode
typedef struct quicklistNode {
//前一个quicklistNode
struct quicklistNode *prev; //前一个quicklistNode
//下一个quicklistNode
struct quicklistNode *next; //后一个quicklistNode
//quicklistNode指向的压缩列表
unsigned char *zl;
//压缩列表的的字节大小
unsigned int sz;
//压缩列表的元素个数
unsigned int count : 16; //ziplist中的元素个数
....
} quicklistNode;
里面有前后节点指针,来构成双向链表。链表节点中有一个指向压缩列表的指针。
在添加元素时,会检查插入位置的压缩列表能否容纳该元素,如果能,那就直接插入;如果不能,但是插入位置是列表头或尾,可以看相邻的节点的压缩列表头或尾能否容纳该元素,如果还是不能,那就会涉及到对压缩列表的分裂,以及新链表节点的插入。包括在中间插入等情况。
listpack
定义:redis5.0后,用于代替压缩列表的数据结构。它在节点中不在使用像压缩节点的prevlen字段了,因此也就没有了“连锁更新”的问题。
结构设计
listpack结构
listpack节点结构
- encoding:和压缩列表的encoding一样,定义数据的类型,字符串或整数
- data:存放数据
- len:encoding+data的总长度。
listpack节点没了prevlen也可以实现从后往前遍历:通过lpDecodeBacklen
函数,可以从当前的节点的起始位置向左逐字节解析,直到得到上个节点的len值,从而实现向前遍历。
线程模型
redis是单线程吗?
redis的基本操作(接收客户端请求->解析请求->数据读写->发送数据给客户端)是单线程(主线程)的。
但是redis程序并不是单线程的,redis是会启动后台线程(BIO)的:
- 用后台线程处理关闭文件,AOF刷盘
- 通过lazyfree线程异步释放redis内存,所以删除大key的时候别用del,会导致主线程阻塞,而是使用unlink来调用后台线程。
关闭文件、AOF刷盘、释放内存三个任务都有各自的任务队列。
后台线程可以处理耗时任务,可以避免主线程阻塞。
redis单线程模式
redis初始化时:
- 先创建一个epoll对象和一个服务端socket
- 然后绑定端口并监听这个socket
- 然后再把这个监听的socket加入到epoll,并注册连接事件来处理函数
初始化完后,主线程就来到事件循环函数:
- 先调用处理发送队列函数,寻找发送队列中有无发送任务,有就通过write把客户端发送缓冲区的数据发送,如果这轮数据没发送完,就注册写事件来处理函数,等epoll_wait发现可写后在处理。
- 调用epoll_wait函数来等待事件
-
- 如果是连接事件来了,就调用连接事件处理函数:调用accept获取已连接的socket,再把该socket加入到epoll并注册读事件来处理函数。
- 如果是读事件来了,就调用读事件处理函数:调用read接受数据,然后解析并执行命令,再把客户端对象加入写发送队列,最后把执行结果写入发送缓存区来等待发送。
- 如果是写事件来了,就调用写事件处理函数:通过write发送客户端发送缓存区的数据,如果这轮数据没发送完,就注册写事件来处理函数,等epoll_wait发现可写后再处理。
Redis 采用单线程为什么还这么快?
单线程的 Redis 吞吐量可以达到 10W每秒
原因:
- 因为redis大部分操作都在内存中完成,并且使用了高效的数据结构。redis的效率瓶颈主要不在cpu,更多是在网络带宽和内存大小,所以单线程一般够用了。
- redis采用单线程模型可以避免多线程之间的竞争所造成的线程切换开销,以及避免死锁等问题。
- redis采用IO多路复用机制来处理客户端的socket请求,使得在单线程的情况下,可以同时监听多个socket,谁就绪了就处理其事件,可以充分利用cpu资源。(因为是单线程,在处理事件的时候,如果有socket就绪,会通过回调函数自动先加入就绪FD的链表(epoll特有),等线程处理完当前事件后,在从链表中继续获取就绪socket)
文件描述符(File Descriptor,简称FD)在Linux中,一切皆文件。Socket也是。
Redis 6.0 之前为什么使用单线程?6.0 之后为什么引入了多线程?
之前是因为cpu并不是redis性能的主要瓶颈。更多受限于内存大小和网络带宽。如果想要利用服务器的多核cpu,就启动多节点或者部署分片集群。而且使用多线程也会增加系统复杂度,带来程序执行顺序,死锁等问题,以及线程切换,加锁解锁等性能损耗。
之后有引入多线程来处理网络请求是因为随着网络硬件的性能提升,性能瓶颈也开始显现在网络IO上。所以为了提高网络IO的并行度,开始使用多线程处理网络IO。(执行命令仍然是使用单线程)
默认情况下,多线程只应用于发送响应数据,而想在处理读请求也使用多线程,需要自己在配置文件中配置。(也能配置多线程的个数)
在redis6.0之后,redis启动时将默认额外创建6个线程(不包括主线程)
- Redis-server : Redis的主线程,主要负责执行命令;
- bio_close_file、bio_aof_fsync、bio_lazy_free:三个后台线程,分别异步处理关闭文件任务、AOF刷盘任务、释放内存任务;
- io_thd_1、io_thd_2、io_thd_3:三个 I/O 线程,io-threads 默认是 4 ,所以会启动 3(减去主线程)个 I/O 多线程,用来分担 Redis 网络 I/O 的压力。
持久化
写时复制:当父进程或子进程在向共享内存发起写操作时,cpu会因为它违反权限而触发写保护中断,然后操作系统就会在写保护中断函数里对物理内存进行复制,并重新设置内存映射的关系,然后那个进程的内存读写权限就可以改为可读写,就可以对内存进行写操作了。
为什么是写时复制——因为可以防止在fork子进程时,因为物理内存数据复制时间太长导致父进程长时间阻塞
坏处:如果共享内存修改的越多,物理内存复制的也就越多,极端情况下就要完整复制所有共享内存了。
Redis 如何实现数据不丢失?
通过数据持久化到磁盘,使redis能够读取磁盘文件恢复数据。
三种数据持久化的方式:
- AOF日志:每执行一条写操作命令,都会把该命令追加到一个文件里
- RDB快照:将某一刻的内存数据以二进制方式写入磁盘
- 混合持久化:同时使用AOF和RDB
AOF日志
定义:每执行一条写操作命令,都会把该命令追加到一个文件里。(主进程执行)redis重启时,会读取该文件内的命令来恢复数据,确保数据的一致性和完整性。
redis默认关闭AOF。
例子
「*3」表示当前命令有三个部分,每部分都是以「+数字」开头,后面紧跟着具体的命令、键或值。然后,这里的「数字」表示这部分中的命令、键或值一共有多少字节。例如,「+数字」开头,后面紧跟着具体的命令、键或值。然后,这里的「数字」表示这部分中的命令、键或值一共有多少字节。例如,「+数字」开头,后面紧跟着具体的命令、键或值。然后,这里的「数字」表示这部分中的命令、键或值一共有多少字节。例如,「3 set」表示这部分有 3 个字节,也就是「set」命令这个字符串的长度。
为什么先执行命令,再把数据写入日志呢?
- 避免额外的对语法检查开销
- 不会阻塞当前写的命令
坏处:
- 数据日志丢失
- 阻塞随后的操作
AOF 写回策略
redis写入AOF日志过程:
- redis执行写操作命令后,命令会追加到server.aof_buf缓冲区
- 然后通过write系统调用,把缓冲区的数据写入aof,此时数据还没写入硬盘,而是拷贝到了内核缓冲区page cache,等待内核把数据写入磁盘
- 具体内核缓冲区什么时候写入磁盘由内核决定(写回硬盘策略)
redis有三种写回硬盘策略,在redis.conf配置文件中的appendfsync中填写:(本质就是调整调用fsync
函数的时机)
- Always:每次写操作命令执行完,就把aof日志数据写回磁盘
- Everysec:每秒从aof文件的内核缓冲区把内容写回磁盘(异步)
- No:由操作系统来控制什么时候把内核缓冲区的数据写回磁盘
AOF 日志重写
原因:随着写操作越来越多,AOF日志文件就会越来越大。当redis需要通过AOF日志恢复数据时,速度就会变慢,比如redis重启时需要读AOF恢复数据。
定义:AOF日志重写时,会去读取数据库中相应的键值对,把每个键值对用一条命令记录到新的AOF文件。最后用新的AOF日志文件去替换原有的AOF日志文件。(防止AOF重写失败污染原有AOF文件)
触发:
- 手动触发:执行
bgrewriteaof
命令。 - 自动触发:redis配置中有 当前AOF日志和上次重写后AOF日志的文件大小比值 和 触发重写最小文件大小 这两配置项。
举例:多条命令对一个key的value进行修改,但通过重写后,就只会读取key-value的最新值,来生成命令并记录到AOF日志。
重写过程
重写AOF是由后台子进程bgrewriteaof
来完成的,这么做有两个好处:
- 可以避免阻塞主进程,让它继续执行redis命令。
- 子进程是使用的主进程的数据副本,而如果是用子线程的话,多线程之间是共享内存,在多线程都要修改共享内存时,需要加锁保证数据一致,这样就会降低性能;而用父子进程它们在共享内存数据时,是以只读的形式,修改内存时,会发生写时复制,就不需要加锁来保证数据一致。
重写AOF时,主进程会创建重写aof的子进程,子进程会去读取数据库里相应的数据,并把键值对都转换为一条命令,然后写入一个新的AOF文件。
重写过程中,主进程仍可以处理命令。在修改到已经存在的key-value时,会发生写时复制。此时这个key-value的数据在父子进程不一致了。
为了解决该问题,redis设置了一个AOF重写缓冲区,在创建后台重写子进程时会使用。
在AOF重写期间,redis在执行完写命令后,会同时把该命令写入AOF缓冲区和AOF重写缓冲区
当子进程完成AOF重写后,会异步地向主进程发送信号,主进程接受到后,会调用信号处理函数,做以下工作:
- 把AOF重写缓冲区的内容追加到新的AOF文件里
- 新的AOF文件会覆盖现有的AOF文件
信号处理函数执行完,主进程就恢复往常来处理命令了。
RDB快照
定义:记录某一瞬间的内存数据。
相比AOF日志:因为AOF日志记录的是命令,所以占用内存更大,而且命令过多,里面有很多无效的命令,并且执行命令本身也需要时间,会导致redis数据恢复时速度很慢。
生成RDB快照
redis提供两个命令来生成RDB文件,它们区别在于是否在主线程执行。
save
:主线程上生成RDB文件,会阻塞主线程执行命令bgsave
:在子进程中生成RDB文件,可以避免阻塞主线程,还可以通过配置文件来实现定时执行bgsave
redis的快照是全量快照,也就是要把内存中所有数据都记录到磁盘。所以它的效率比较低,频率要合理把控。太高会影响redis性能,太低可能在服务器故障时丢失很多数据。
redis在生成RDB快照时,也可以继续执行命令。原理和AOF重写时能执行命令一样,依靠写时复制技术(Copy on write)
执行bgsave时,会fork一个子进程,子进程会复制父进程的页表,但它们俩的页表(页表项可以标记内存为只读)都指向同一个物理内存。
- 读操作时,主线程和子进程互不影响。
- 主线程执行写操作时,被修改的数据就会复制一份副本,主线程就操作那个副本。因为两者不在指向同一物理内存了,所以子进程不会把该副本数据写入RDB文件,只能等待下次RDB快照生成。因此,主线程在生成RDB快照时,也可以继续执行命令。
混合持久化
- RDB优点是数据恢复快,缺点是快照生成频率不好把握
- AOF优点是丢失数据少,缺点是数据恢复慢(因为redis执行命令是单线程,aof日志恢复是顺序执行每一条命令)。
定义:同时使用RDB和AOF,来集成它们的优点。(redis4.0提出)
开启该功能在redis配置文件中改这个配置项
aof-use-rdb-preamble yes
持久化过程
混合持久化工作在AOF日志重写时,对于AOF日志中原有的数据将从内存中以RDB方式重写入AOF文件,然后在此期间,主线程处理的命令会记录在AOF重写缓冲区里,重写缓冲区里的命令就会以AOF的形式写入AOF文件。写入数据完成后,会通知主进程把新的含有RDB格式和AOF格式的AOF文件替换旧AOF文件。
也就是说如果使用混合持久化,AOF文件长这样:
好处:
- 加载速度更快——因为有RDB格式的数据
- 数据更少丢失——因为有重写AOF期间主线程处理的命令也能加载进AOF文件
坏处:
- 可读性差——有AOF和RDB两种格式内容
- 兼容性差——redis4.0之前版本未引入混合持久化
大Key对持久化的影响
对AOF日志的影响
如果使用的是Always策略来写入AOF日志,那因为写入的是大key,所以主线程在执行fsync()
函数同步到磁盘的时候,阻塞时间会比较久。
而Everysec策略是异步执行的,所以不会影响主线程。 No策略不执行fsync函数,而是转移操作权给数据库,所以也不影响主线程。
对AOF日志重写和RDB的影响
在AOF重写和RDB文件生成时,需要fork子进程,如果页表很大,复制过程会很耗时,因为fork是主线程调用的,所以会阻塞主线程。
可以执行 info 命令获取到 latest_fork_usec
指标,表示 Redis 最近一次 fork 操作耗时。
# 最近一次 fork 操作耗时
latest_fork_usec:315
如果fork耗时很长,比如超过1秒,可以作以下优化:
- 把单个实例的内存占用控制在10G以下。
- 如果对redis数据恢复要求不高,可以关闭AOF,减少调用fork函数次数。
- 通过对一些缓冲区的大小修改(如
repl-backlog-size
,用于主从服务器之间传输数据)来可以减少全量同步,因为全量同步是要创建RDB文件的,也就是调用fork函数。
如果创建完子进程后,父进程对共享内存的大key进行修改,那么就会发送写时复制,把物理内存复制一份,而大key的内存占用是很大的,所以复制物理内存是比较耗时的,这就会导致父进程阻塞。
所以,有两个阶段会阻塞父进程:
- 创建子进程的时候,复制父进程的页表时,页表比较大会阻塞。
- 创建子进程后,如果父进程修改了共享内存触发了写时复制,物理内存比较大就会阻塞。
如果Linux开启了内存大页,也会影响redis性能。
linux支持内存大页机制,它支持2MB大小的内存页分配,而常规内存页分配只有4KB。
如果使用了内存大页,那么即使客户端只修改了4KB以内大小的共享数据,那么发送写时复制时,也要拷贝2MB的内存大页,而常规只需要拷贝4KB。这会拖慢写操作的执行时间。
解决方法:可以关闭linux的内存大页机制(默认是关的)。
echo never > /sys/kernel/mm/transparent_hugepage/enabled
大key除了影响持久化以外,还影响:
- 客户端阻塞
- 网络阻塞
- 内存分配不均(分片集群下)
Redis集群(高可用篇todo)
Redis 实现服务高可用
主从复制
定义:一台服务器同步数据给其他从服务器,主从服务器之间采用读写分离模式。
主服务器可以进行读写操作,写操作会同步给从服务器;从服务器一般是只读,接收到主服务器同步过来的写操作时,才会执行该写命令。(命令复制是异步的)
主从复制无法保证强一致性,因为主服务器在把写操作同步给从服务器时,不会阻塞等待从服务器执行完命令,而是直接执行完命令后返回结果。如果此时从服务器没执行完命令,此时主从服务器之间的数据就不一致了。
哨兵模式
原因:使用redis主从服务时,redis主从服务器发生故障宕机时,需要手动恢复。
定义:可以监控主从服务器,并提供主从服务器故障转移功能。
切片集群模式
定义:把数据分布到不同的服务器,来提高redis数据的存储能力。
切片集群采用哈希槽(Hash Slot),来处理数据和节点之间的映射。一个切片集群有16384个哈希槽,每个键值对会根据它的key来映射到对应的哈希槽里:
- 根据键值对的 key,按照 CRC16 算法 计算一个 16 bit 的值。
- 再用 16bit 值对 16384 取模,得到 0~16383 范围内的模数,每个模数代表一个相应编号的哈希槽。
哈希槽映射到redis节点:
- 平均分配:创建集群时,redis会把所有哈希槽平均分配到集群的每个节点上。
- 手动分配:可以自己指定每个节点上的哈希槽个数。(必须把所有哈希槽都分配完才能正常工作)
集群脑裂导致数据丢失怎么办?
脑裂:集群中的不同节点因为通信故障等原因,而导致分开形成独立的子集,出现了多个主节点的情况。此时如进行数据操作,就可能导致数据不一致、丢失等情况。
举例:在redis的主从结构中,如果主节点突然出现网络问题,与其他从节点失联了,但它与客户端的通信仍然正常。此时客户端对主节点的写操作有效,但主节点无法把写操作同步给其他从节点。其他从节点通过哨兵机制会重新选出一个主节点,此时集群就有了两个主节点了(脑裂)。当失联主节点与其他节点恢复通信时,因为哨兵机制它会降级为从节点,然后清除数据做一次全量同步,因此之前和客户端之间的操作数据都丢失了。
解决方法
当主节点发现从节点下线或者主从复制的时间超时时,禁止主节点写数据,直接给客户端返回错误。
redis配置文件中参数:
- min-slaves-to-write x,主节点必须要有至少 x 个从节点连接,如果小于这个数,主节点会禁止写数据。
- min-slaves-max-lag x,主从数据复制和同步的延迟不能超过 x 秒,如果超过,主节点会禁止写数据。
两个参数可以搭配使用。这样如果原主库假故障,就会因为没有从节点连接,以及主从复制超时而被禁止写数据,等到新主节点上线,就可以接管它来处理客户端请求,而当它恢复通信时,因为没有新数据产生所以可以正常同步。
Redis 过期删除与内存淘汰
过期删除策略
redis对于设置了过期时间的key会把它存储到一个过期字典里,value就是过期时间。
当查询一个key时,会先去检查key在过期字典里是否存在:
- 如果不在,就正常去读取键值。
- 如果存在,就获得它的过期时间,并与系统时间进行对比,如果比系统时间大,就没有过期,否则就是过期。
redis使用的过期策略是 惰性删除+定期删除 。
惰性删除
定义:不主动删除过期的键值对,而是当访问到键值对,去判断它是否过期时,如果过期了再删除。
好处:
- 占用很少的系统资源
坏处:
- 如果过期的key长时间不被访问,会一直占用内存空间不被删除。
定期删除
定义:每隔一段时间去过期字典取出一些key来进行检查是否过期。(主线程执行)
流程:
- 从过期字典中随机取出20个key
- 检查是否过期,如果过期就删除
- 最后去看本轮的过期key:所有检查的key,如果大于25%,就重复这个流程,直到小于为止。
当然定期删除如果遇到一坨过期的key可能会循环很久,为了防止主线程长期阻塞,设置了循环的时间上限,默认是25ms。
好处:
- 可以删除过期的但访问频率低的key
坏处:
- 难以确定定期删除策略的频率,太频繁占用cpu,频率太低又不能及时释放过期key占用的空间。
Redis 持久化时,对过期键会如何处理的?
- RDB文件生成阶段:会对key进行过期检查,过期的key不会被保存到RDB文件中。
- RDB文件加载阶段:
-
- 如果是主服务器加载的话,会对保存的key进行过期检查,过期的键值不会保存到数据库。
- 如果是从服务器加载的话,无论有没有过期都会保存到数据库。但因为主从服务器会进行全量同步,所以对从服务器数据没有影响。
- AOF文件写入阶段:正常添加过期键,当过期删除了就向AOF文件追加一条del命令
- AOF重写阶段:会对redis中的键值对检查,过期的key不会保存到重写后的AOF文件。
Redis 主从模式中,对过期键会如何处理?
redis 3.2版本之前,从库的数据过期处理是完全依赖主库的,所以如果如果直接访问从库的过期数据,依然是可以得到value的。所以需要主库保证及时处理过期键值对并同步删除命令给从库。
redis 3.2版本之后,从库能在读操作时判断数据是否过期了。当然不同服务器的时钟不一致,主库和从库的对于过期判断肯定也会有那么点偏差,但这是微乎其微的。(不能容忍还搞什么主从同步,读写分离)
内存淘汰策略
redis内存满了,会触发内存淘汰机制。阈值是我们设置的最大运行内存,在redis配置文件中。
内存淘汰策略有八种,大致分为 不进行数据淘汰 和 进行数据淘汰 两种。
- 不进行数据淘汰的策略
-
- noeviction(默认):当运行内存超过阈值,不淘汰任何数据,而是给客户端返回错误。
- 进行数据淘汰的策略
-
- 对设置了过期时间的数据进行淘汰
-
-
- volatile-random:随机淘汰设置了过期时间的键值。
- volatile-ttl:优先淘汰过期时间早的键值
- volatile-lru:淘汰设置了过期时间的键值中,最长时间未使用的键值
- volatile-lfu:淘汰设置了过期时间的键值中,最少使用的键值
-
-
- 对所有数据进行淘汰
-
-
- allkeys-random:随机淘汰任意键值
- allkeys-lru:淘汰所有键值中,最长时间未使用的键值
- allkeys-lfu:淘汰所有键值中,最少使用的键值
-
LRU
定义:全称是Least Recently Used,会淘汰最长时间未使用的数据。
传统的LRU算法是由链表实现的,链表里的元素按元素操作时间顺序来排序,链表尾是最长时间未操作的元素,最新操作的元素会被移到表头,所以淘汰时直接直接删除链表尾部元素就行。
缺点:会造成缓存污染。——数据是根据时间来淘汰的,如果一次性访问大量缓存,然后这些缓存一直不再被使用,但它们却要很长时间后才能被清理,会占用空间。
redis并没有使用传统方法来实现LRU算法,因为它有两个缺点:
- 链表会产生空间开销
- 数据访问太多链表移动操作也会有很多,会降低redis性能
Redis的LRU算法实现
一种近似LRU算法,是在redis的对象结构中添加一个额外字段,来记录数据的最后一次访问时间。当redis要进行内存淘汰时,会随机取5个值根据它们的字段来淘汰数据。
好处:
- 不用为所有数据库数据维护链表,节省空间占用
LFU
定义:全称是 Least Frequently Used,会淘汰最少使用的数据。
LFU算法会记录每个数据的访问次数,当数据被访问一次,就会增加它的访问次数。这就解决了LRU中的缓存偶尔被访问一次,但却会被留存很长一段时间。
Redis的LFU算法实现
LFU 算法相比于 LRU 算法的实现,多记录了「数据的访问频次」的信息
typedef struct redisObject {
...
// 24 bits,用于记录对象的访问信息
unsigned lru:24;
...
} robj;
redis对象头中的lru字段,在LRU算法和LFU算法下的使用方法不同。
- LRU:24bit都用来记录key的访问时间戳。
- LFU:lru字段分为两段,高16bit存储key的访问时间戳,低8bit存储key的访问频次。
Redis缓存设计
缓存雪崩
定义:大量缓存数据同时失效或者redis突然宕机,大量用户请求就不能在redis中处理,就全都去访问数据库。数据库可能会因为压力过大直接宕机。
可以看到,引起缓存雪崩的原因有两个:
- 大量缓存数据过期
- redis故障宕机
不同原因,应对策略也不同。
大量缓存同时过期
应对方法有:
均匀设置过期时间
给数据的过期时间加随机数,让缓存不在同一时间过期。
互斥锁
访问数据时,如果发现redis里不存在,就加上互斥锁,保证同一时间内只有一个请求来构建缓存(从数据库读数据,然后写回redis)。缓存构建完后,再释放锁。没能获取互斥锁的请求,要么等锁释放后去读缓存,要么就返回空值或默认值。
获取互斥锁时最好加上超时时间,防止请求出现意外一直不释放锁。
后台更新缓存
直接让缓存永久有效,并把缓存更新操作交给后台线程定时更新。但当redis内存紧张时,因为内存淘汰策略,一样有可能清理缓存。此时就可能导致业务线程读不到缓存。
解决方法:
- 让后台线程也同时负责检查缓存是否有效。如果失效了就去从数据库读取并缓存。(但需要比较频繁地检查)
- 在业务线程发现缓存失效后,通过消息队列发送消息让后台线程去更新缓存,后台线程收到后发现缓存确实没有就更新,有就不更新了。
在业务刚上线时,不要等待用户访问来触发缓存的构建,而是提前缓存。——缓存预热
Redis故障宕机
应对方法有:
服务熔断或请求限流机制
服务熔断机制来暂停业务对数据库的访问,直接返回错误。等redis恢复再允许业务访问。但这会使所有业务都无法工作。为了减少对业务的影响,可以启用请求限流机制,只让少部分请求发送到数据库处理,其他请求就拒绝,等redis恢复再解除请求限流。
构建Redis缓存高可靠集群
这是预防的手段。如果主节点故障宕机,从节点可以变为主节点继续提供服务。
缓存击穿
定义:某个热点数据过期了,大量请求同时访问该数据,读不到缓存就都去读数据库,导致数据库压力山大,可能会被冲垮。缓存击穿和缓存雪崩类似,可以说是它的一个子集(一个是侧重某个,一个侧重一群)
应对方法:
互斥锁(同缓存雪崩方案)。
后台更新缓存(同缓存雪崩方案)
缓存穿透
定义:访问的数据既不在缓存中,也不在数据库中。如果有大量请求访问,数据库压力就很大。
应对方法:
非法请求的限制
如果有大量恶意请求来访问不存在的数据时,就会发送缓存穿透。所以要我们在API入口处作判断,它的请求的数据是否合理,不合理就直接返回错误。
缓存空值或默认值
如果已经发生了一次缓存穿透,我们可以对查询的数据做个一个空值或默认值的缓存,这样以后相同的请求就可以直接通过查询缓存得到结果,而不用查询数据库。
布隆过滤器判断数据是否存在
在往数据库写入数据的时候,使用布隆过滤器做个标记,用户请求过来时发现缓存失效后,可以通过布隆过滤器来判断数据库里数据是否存在,存在再去获取,不存在就不去查询数据库。
Redis可以实现布隆过滤器(通过redis扩展模块)。
布隆过滤器
定义:由 初始值全为0的位图数组 和 多个哈希函数(具体个数原理todo) 组成。当我们往数据库写入数据时,就可以在布隆过滤器里做标记,下次访问数据库前,就可以通过布隆过滤器来判断数据是否存在,如果数据没被标记,说明就不存在;有标记则表示存在,可以借此来决定是否访问数据库,可以减轻数据库压力。
原理:
- 使用多个哈希函数对数据进行哈希运算,然后得到多个哈希值
- 让这些哈希值对位图数据长度进行取模,得到那些哈希值在位图数组里对应的下标。
- 把对应位置的位图数组里的数据设为1
当我们要查某个数据是否在数据库存在时,就可以通过该方法,获取该数据对应位图数组的各个元素,如果都为1,表示存在;否则表示不存在。
布隆过滤器是基于哈希函数实现的,所以也会存在哈希冲突的可能,就会导致误判。但如果布隆过滤器计算得出数据存在,它不一定真的存在;但如果计算得出不存在,它就一定不存在。
动态缓存热点数据策略
因为数据存储受限,不是所有数据都能放在缓存中的,所以我们只缓存热点数据。
设计思路:通过数据访问时间来做排名,过滤掉不常访问的,留下访问频率高的。可以使用Redis数据类型里的Zset,score作为访问频率来排名,value就是在另一个缓存数据结构中的id(通过该id再去读取那个缓存中存储的数据信息)。
举例:比如只要缓存访问量top1000的商品。
- 用zset先做个排序队列来存放这些商品id,score作为商品的访问时间,越新排名越靠前。
- 编写定时任务会定时清理掉队列最后的200个商品,然后再从数据库中随机获取200个商品加入队列(也要同时对商品信息缓存操作,保持数据一致)
- 这样请求到达后,会先获取商品id,然后再去另一个缓存数据结构中读取实际商品信息并返回。
为什么不把访问时间排序队列和实际数据缓存整合在一起
- 有利于使用更好的数据结构存储实际数据
- 可以避免大key的出现
常见的缓存更新策略todo
实际开发中,mysql和redis使用的是Cache Aside,其他两种策略用不了。
Cache Aside 旁路缓存策略
数据库与缓存的一致性
更新数据库与缓存
该方法适合业务对缓存命中有高要求的,但该方法存在并发问题可能会导致数据不一致,所以需要方法解决:
- 使用分布式锁——保证同时只有一个请求来更新数据库和缓存,就不会产生并发问题了,但对性能有影响。
- 给缓存加过期时间——业务需要能允许短暂的数据不一致。
先更新数据库,再更新缓存
最后数据库的值为2,而缓存的值为1,两者数据不一致。
先更新缓存,再更新数据库
最后数据库的值为1,而缓存的值为2,两者数据还是不一致。
所以这两种方案都会存在并发问题。
所以我们不再同时更新数据和缓存。而是更新数据库的时候,把缓存删除。等数据被读取时,再从数据库读取数据写入缓存。——Cache Aside 策略(旁路缓存策略)
Cache Aside策略又可分为读策略和写策略
写策略:
- 更新数据库
- 删除缓存
读策略:
- 读到缓存就直接返回
- 没读到缓存就去读数据库,然后再把数据写入缓存
写策略中,两者的顺序该如何分配呢?
更新数据库并删除缓存
先删除缓存,再更新数据库
请求A删掉缓存后,请求B去访问数据库,就会把数据库的旧数据写回缓存,然后请求A再更新数据库为新值,此时缓存的旧值就会让数据不一致。所以读写并发时,还是会出现数据不一致。
解决方法:可以使用延迟双删。
#删除缓存
redis.delKey(X)
#更新数据库
db.update(X)
#睡眠,确保这段时间其他请求能完成数据库的读取和缓存的更新操作
Thread.sleep(N)
#再删除缓存
redis.delKey(X)
但这个方法有个问题,就是必须要求睡眠的时间里,其他请求一定要完成“数据库的读取和缓存的更新”操作,这对睡眠时间的设置有很严格的要求,所以该方法普遍也无法完全保证数据一致性。所以还是推荐使用“先更新数据库,再删除缓存”方案。
先更新数据库,再删除缓存
假设某数据在缓存中还不存在,请求B在读到数据库的旧值后,请求A会去更新数据库为新值,然后再删除缓存,然后请求B又去把旧值写入缓存。不过这种可能性在实际中不高,因为缓存的写入速度要远快于数据库的写入。很难出现先等数据库更新完,缓存才被写入。
所以该方法是相对能保证数据的一致性的。
而且,还可以给缓存加上过期时间,如果缓存数据不一致了,那也只会是一段时间的数据不一致。
但是,如果该方法的“删除缓存”操作失败了,还是会导致缓存是旧值。(不过倒是有过期时间作保底)
如何保证两个操作都能执行成功?
这个问题无论是更新数据库和删除缓存谁先谁后都会有。
解决方法:
重试机制
使用消息队列,把要删除的缓存这个数据加入消息队列,让消费者去处理。
- 如果缓存删除失败,就在消息队列中重新读取消息,然后再尝试删除缓存。这就是重试机制。 但如果重试次数过多,就需要向业务层发送报错信息。
- 如果缓存删除成功,就把缓存数据从消息队列中移除。
订阅MySQL的binlog,再操作缓存 (不过这跟能保证两个操作执行成功有什么关系)
只适用于”先更新数据库,再删除缓存“。
更新数据库成功后,会在binlog文件生成一条日志,然后我们可以去订阅binlog日志,拿到要操作的数据,再去执行缓存删除。
Canal中间件就是这样实现的。Canal伪装成一个MySQL的从节点,向主节点去发送dump请求,MySQL收到后就会推送binlog给Canal,然后Canal解析binlog字节流,转换为结构化数据,共下游程序订阅使用。
这两种方法有一个共同点,就是都是使用异步操作
转载自:https://juejin.cn/post/7381371976030928936