系列文章目录
- 第一章 C/C++语言篇
- 第二章 计算机网络篇
- 第三章 操作系统篇
- 第四章 数据库MySQL篇
- 第五章 数据库Redis篇
- 第六章 场景题/算法题
- 第七篇 常见HR问题篇
本系列专栏:点击进入 后端开发面经 关注走一波
秋招阶段,面过很多大中小厂,积攒了很多面经,都是高频问题!!!
前言:本系列文章初衷是为了整理出最全面最详细的面经,非常适用于想走后端/软件开发
的同学!近些年,越来越多的人投入互联网的浪潮,由于岗位hc有限,企业筛选门槛也随之提高。以往的简单八股问答也在不断升级,面试官开始更喜欢问为什么,会围绕八股的某一点不断深问。所以本系列文章的面经不仅仅是简单问答,而是帮你深入理解和掌握知识点,其中一些晦涩难懂的知识点,全都用案例和代码帮你彻底掌握,切记一定要理解原理,拒绝死记硬背!!!
文章目录
- 系列文章目录
- 关系型数据库和非关系数据库的区别
- MySQL、MongoDB和Redis的区别
- Redis相当于MySQL的优缺点
- Redis常用指令
- redis各种数据类型的应用场景
- redis各种数据类型的底层结构
- Redis数据结构-RedisObject
- Redis数据结构-String
- Redis数据结构-List
- Redis数据结构-Set结构
- Redis数据结构-ZSET
- Redis数据结构-Hash
- redis持久化机制详解
- 快照持久化-RDB (Redis Database)
- 增量持久化-AOF (Append Only File)
- RDB与AOF对比
- redis单线程模型
- redis 为什么快?
- Redis发布订阅
- 介绍 redis 缓存穿透问题以及解决思路
- 介绍 redis 缓存雪崩问题以及解决思路
- 介绍 redis 缓存击穿问题以及解决思路
- redis中热key和大key怎么处理?
- redis内存回收机制
- redis分布式锁的实现
- 为什么需要Redis主从复制?
- 主从节点的同步机制是什么?
- 全量同步
- 增量同步
- 主从同步优化
- Redis哨兵机制
- redis集群,怎么实现数据分布式存储?
- redis客户端对分布式集群发起一个查询,介绍整个过程
- 同一份数据,既要在MySQL存储,又要在Redis存储,要怎么设计实现?
关系型数据库和非关系数据库的区别
- 关系型数据库是一种使用表格形式存储数据的数据库系统,数据通过行和列组织,并且表之间可以通过外键建立关系,常见的有MySQL、PostgreSQL和Oracle等。
- 非关系型数据库则不采用固定的表格结构,可以支持多种数据存储模型,如文档、键值、图和列族,适合处理大规模和多样化的数据,常见的有MongoDB、Redis和Cassandra等。
下面是它们的主要区别:
对比类型 | 关系型数据库 | 非关系数据库 |
---|---|---|
数据结构 | 表格形式 | 文档型(mongdb)、键值型(redis) |
事务支持 | 支持ACID事务,保证强一致性 | 最终一致性,不是严格的事务管理 |
灵活性 | 数据结构固定,表结构的变更昂贵复杂 | 数据结构灵活存储,形式多样 |
查询语句 | SQL语句 | 类 SQL(mongdb)、命令行操作(redis) |
适用场景 | 适合需要复杂查询和事务支持的应用 | 适合需要高可扩展性、灵活数据模型和快速读写的应用 |
MySQL、MongoDB和Redis的区别
数据库 | MySQL | MongoDB | Redis |
---|---|---|---|
数据模型 | 关系型数据库 | 非关系型数据库 | 非关系型数据库 |
存储数据类型 | 二维表结构 | BSON格式文档存储 | 键值对 |
数据结构 | 数值、时间、字符串等常用类型 | 除常用类型,还支持数组和字典 | 字符串、列表、集合、哈希等 |
存储方式 | 不同引擎有不同的存储 | 基于内存,热数据存放物理内存,高速读写 | 完全基于内存,持久化可选 |
查询方式 | SQL语句 | 基于文档的查询语言 | 基于键的简单操作 |
多表联查 | 一对多、多对多情况下,使用外键,JOIN查询 | 嵌套或者通过ID引用其他集合 | 不支持联查 |
事务支持 | 支持 | 仅支持单文档事务 | 支持简单事务 |
数据一致性 | 强一致性,保证在所有副本上的数据是一致的 | 会牺牲一致性来获得更好的性能,采用最终一致性 | 支持单线程操作,强一致性 |
Redis相当于MySQL的优缺点
Redis 优点:
- 高性能:Redis 是内存数据库,读写速度非常快,适合对性能要求高的应用。
- 数据结构丰富:支持字符串、哈希、列表、集合、有序集合等多种数据类型,灵活性强。
- 支持持久化:可以将内存数据持久化到磁盘,支持 RDB 和 AOF 两种持久化方式。
- 高可扩展性:支持分布式部署,易于扩展。
- 适合缓存:常用于缓存数据,减少数据库压力,提高访问速度。
Redis 缺点:
- 内存限制:数据存储在内存中,受限于服务器内存大小,适合存储小到中型数据集。
- 复杂查询支持弱:不支持复杂的 SQL 查询,如联接和聚合操作。
- 数据一致性问题:由于是内存数据库,数据持久化时可能存在数据丢失的风险,尤其是在系统崩溃的情况下。
Redis常用指令
-
字符串操作
SET key value:设置键的值。
GET key:获取键的值。
DEL key:删除键。
INCR key:将键的值加 1。
DECR key:将键的值减 1。
APPEND key value:将值追加到键的值后。 -
哈希操作
HSET key field value:设置哈希表中的字段值。
HGET key field:获取哈希表中的字段值。
HDEL key field:删除哈希表中的字段。
HGETALL key:获取哈希表中的所有字段和值。 -
列表操作
LPUSH key value:在列表头部添加一个值。
RPUSH key value:在列表尾部添加一个值。
LPOP key:移除并返回列表的第一个元素。
RPOP key:移除并返回列表的最后一个元素。
LRANGE key start stop:获取列表指定范围内的元素。 -
集合操作
SADD key member:向集合添加成员。
SREM key member:从集合中删除成员。
SMEMBERS key:获取集合中的所有成员。
SISMEMBER key member:检查成员是否在集合中。 -
有序集合操作
ZADD key score member:向有序集合添加成员。
ZRANGE key start stop:获取有序集合中指定范围内的成员。
ZREM key member:从有序集合中删除成员。
ZScore key member:获取成员的分数。 -
事务
MULTI:标记事务的开始。
EXEC:执行事务。
DISCARD:放弃事务。
WATCH key:监视一个或多个键以实现乐观锁。 -
服务器管理
INFO:获取服务器信息和统计数据。
FLUSHDB:清空当前数据库。
FLUSHALL:清空所有数据库。
SAVE:将数据持久化到磁盘。
BGSAVE:在后台异步保存数据到磁盘。 -
其他
EXPIRE key seconds:设置键的过期时间。
TTL key:获取键的剩余生存时间。
PING:测试与服务器的连接。
redis各种数据类型的应用场景
- string:计数、存储特定字符串变量。底层采用SDS简单动态字符串(raw编码、embstr编码、int编码)。
- list:用来充当栈(RPUSH、RPOP)、队列(LPUSH、RPOP)。底层采用QuickList结构,双向链表+ziplist;之前版本用ziplist。
- set:可以得到交集、并集、差集,用来找共同关注,共同爱好,推荐好友等。底层用字典结构hash,数据量小的时候用intset。
- zset:需要用到排序的时候,如班级成绩表、工资表排序。底层用字典+SkipList,数据量小的时候用ZipList。
- hash:适合于对象的存储,如用户信息之类的。底层用字典结构hash,数据量小的时候用ZipList。
- Geospatial:用于存储地理位置和执行与地理位置相关的操作,比如位置服务、区域分析、路径计算等。
- Hyperloglog:是一种概率性的数据结构,主要用于基数估算,适用场景包括:访客计数、去重分析、数据分析。
- Bitmap:Bitmap 是一种高效的位数组,用于处理大量的二进制状态,常见应用场景包括:用户活跃度追踪、广告点击率等。
redis各种数据类型的底层结构
Redis数据结构-RedisObject
Redis中的任意数据类型的键和值都会被封装为一个RedisObject,也叫做Redis对象。
什么是 redisObject
从Redis的使用者的角度来看,⼀个Redis节点包含多个database(非cluster模式下默认是16个,cluster模式下只能是1个),而一个database维护了从key space到object space的映射关系。这个映射关系的key是string类型,⽽value可以是多种数据类型,比如:string, list, hash、set、sorted set等。我们可以看到,key的类型固定是string,而value可能的类型是多个。
⽽从Redis内部实现的角度来看,database内的这个映射关系是用⼀个dict来维护的。dict的key固定用⼀种数据结构来表达就够了,这就是动态字符串sds。而value则比较复杂,为了在同⼀个dict内能够存储不同类型的value,这就需要⼀个通⽤的数据结构,这个通用的数据结构就是robj,全名是redisObject。
Redis的编码方式
Redis中会根据存储的数据类型不同,选择不同的编码方式,共包含11种不同类型:
编号 | 编码方式 | 说明 |
---|---|---|
0 | OBJ_ENCODING_RAW | raw编码动态字符串 |
1 | OBJ_ENCODING_INT | long类型的整数的字符串 |
2 | OBJ_ENCODING_HT | hash表(字典dict) |
3 | OBJ_ENCODING_ZIPMAP | 已废弃 |
4 | OBJ_ENCODING_LINKEDLIST | 双端链表 |
5 | OBJ_ENCODING_ZIPLIST | 压缩列表 |
6 | OBJ_ENCODING_INTSET | 整数集合 |
7 | OBJ_ENCODING_SKIPLIST | 跳表 |
8 | OBJ_ENCODING_EMBSTR | embstr的动态字符串 |
9 | OBJ_ENCODING_QUICKLIST | 快速列表 |
10 | OBJ_ENCODING_STREAM | Stream流 |
五种数据结构
Redis中会根据存储的数据类型不同,选择不同的编码方式。每种数据类型的使用的编码方式如下:
数据类型 | 编码方式 |
---|---|
OBJ_STRING | int、embstr、raw |
OBJ_LIST | LinkedList和ZipList(3.2以前)、QuickList(3.2以后) |
OBJ_SET | intset、HT |
OBJ_ZSET | ZipList、HT、SkipList |
OBJ_HASH | ZipList、HT |
下面将详细介绍五种数据类型的底层原理。
Redis数据结构-String
我们都知道Redis中保存的Key是字符串,value往往是字符串或者字符串的集合。可见字符串是Redis中最常用的一种数据结构。
不过Redis没有直接使用C语言中的字符串,因为C语言字符串存在很多问题:
- 获取字符串长度的需要通过运算
- 非二进制安全
- 不可修改
Redis构建了一种新的字符串结构,称为简单动态字符串(Simple Dynamic String),简称SDS。
例如,我们执行命令:
那么Redis将在底层创建两个SDS,其中一个是包含“name”的SDS,另一个是包含“虎哥”的SDS。
Redis是C语言实现的,其中SDS是一个结构体,源码如下:
例如,一个包含字符串“name”的sds结构如下:
SDS之所以叫做动态字符串,是因为它具备动态扩容的能力,例如一个内容为“hi”的SDS:
假如我们要给SDS追加一段字符串“,Amy”,这里首先会申请新内存空间:
如果新字符串小于1M,则新空间为扩展后字符串长度的两倍+1;
如果新字符串大于1M,则新空间为扩展后字符串长度+1M+1。称为内存预分配。
String是Redis中最常见的数据存储类型,其基本编码方式是RAW,基于简单动态字符串(SDS)实现,存储上限为512mb。
如果存储的SDS长度小于44字节,则会采用EMBSTR编码,此时object head与SDS是一段连续空间。申请内存时只需要调用一次内存分配函数,效率更高。
底层实现⽅式:动态字符串sds 或者 long
String的内部存储结构⼀般是sds(Simple Dynamic String,可以动态扩展内存),但是如果⼀个String类型的value的值是数字,那么Redis内部会把它转成long类型来存储,从⽽减少内存的使用。
如果存储的字符串是整数值,并且大小在LONG_MAX范围内,则会采用INT编码:直接将数据保存在RedisObject的ptr指针位置(刚好8字节),不再需要SDS了。
确切地说,String在Redis中是⽤⼀个robj来表示的。
用来表示String的robj可能编码成3种内部表⽰:OBJ_ENCODING_RAW
,OBJ_ENCODING_EMBSTR
,OBJ_ENCODING_INT
。
其中前两种编码使⽤的是sds来存储,最后⼀种OBJ_ENCODING_INT编码直接把string存成了long型。
在对string进行incr, decr等操作的时候,如果它内部是 OBJ_ENCODING_INT 编码,那么可以直接行加减操作;如果它内部是OBJ_ENCODING_RAW或OBJ_ENCODING_EMBSTR编码,那么Redis会先试图把sds存储的字符串转成long型,如果能转成功,再进行加减操作。对⼀个内部表示成long型的string执行append, setbit, getrange这些命令,针对的仍然是string的值(即⼗进制表示的字符串),而不是针对内部表⽰的long型进⾏操作。比如字符串”32”,如果按照字符数组来解释,它包含两个字符,它们的ASCII码分别是0x33和0x32。当我们执行命令setbit key 7 0的时候,相当于把字符0x33变成了0x32,这样字符串的值就变成了”22”。⽽如果将字符串”32”按照内部的64位long型来解释,那么它是0x0000000000000020,在这个基础上执⾏setbit位操作,结果就完全不对了。因此,在这些命令的实现中,会把long型先转成字符串再进行相应的操作。
Redis数据结构-List
Redis的List类型可以从首、尾操作列表中的元素:
哪一个数据结构能满足上述特征,redis提供了几个关于链表的数据结构,如下:
- LinkedList :普通链表,可以从双端访问,内存占用较高,内存碎片较多。
- ZipList :压缩列表,可以从双端访问,内存占用低,存储上限低。
- QuickList:LinkedList + ZipList,可以从双端访问,内存占用较低,包含多个ZipList,存储上限高。
(1)LinkedList
这个就是普通的双端链表,属于常用数据结构,这里就不多讲这个类型了,把重点放在下面两个类型中。
(2)ZipList
ZipList 是一种特殊的“双端链表” ,由一系列特殊编码的连续内存块组成。可以在任意一端进行压入/弹出操作, 并且该操作的时间复杂度为 O(1)。
属性 | 类型 | 长度 | 用途 |
---|---|---|---|
zlbytes | uint32_t | 4 字节 | 记录整个压缩列表占用的内存字节数 |
zltail | uint32_t | 4 字节 | 记录压缩列表表尾节点距离压缩列表的起始地址有多少字节,通过这个偏移量,可以确定表尾节点的地址。 |
zllen | uint16_t | 2 字节 | 记录了压缩列表包含的节点数量。 最大值为UINT16_MAX (65534),如果超过这个值,此处会记录为65535,但节点的真实数量需要遍历整个压缩列表才能计算得出。 |
entry | 列表节点 | 不定 | 压缩列表包含的各个节点,节点的长度由节点保存的内容决定。 |
zlend | uint8_t | 1 字节 | 特殊值 0xFF (十进制 255 ),用于标记压缩列表的末端。 |
ZipListEntry
ZipList 中的Entry并不像普通链表那样记录前后节点的指针,因为记录两个指针要占用16个字节,浪费内存。而是采用了下面的结构:
- previous_entry_length:前一节点的长度,占1个或5个字节。
- 如果前一节点的长度小于254字节,则采用1个字节来保存这个长度值
- 如果前一节点的长度大于254字节,则采用5个字节来保存这个长度值,第一个字节为0xfe,后四个字节才是真实长度数据
- encoding:编码属性,记录content的数据类型(字符串还是整数)以及长度,占用1个、2个或5个字节
- contents:负责保存节点的数据,可以是字符串或整数
ZipList中所有存储长度的数值均采用小端字节序,即低位字节在前,高位字节在后。例如:数值0x1234,采用小端字节序后实际存储值为:0x3412
Encoding编码
ZipListEntry中的encoding编码分为字符串和整数两种:
字符串:如果encoding是以“00”、“01”或者“10”开头,则证明content是字符串
编码 | 编码长度 | 字符串大小 |
---|---|---|
|00pppppp| | 1 bytes | <= 63 bytes |
|01pppppp|qqqqqqqq| | 2 bytes | <= 16383 bytes |
|10000000|qqqqqqqq|rrrrrrrr|ssssssss|tttttttt| | 5 bytes | <= 4294967295 bytes |
例如,我们要保存字符串:“ab”和 “bc”
ZipListEntry中的encoding编码分为字符串和整数两种:
- 整数:如果encoding是以“11”开始,则证明content是整数,且encoding固定只占用1个字节
编码 | 编码长度 | 整数类型 |
---|---|---|
11000000 | 1 | int16_t(2 bytes) |
11010000 | 1 | int32_t(4 bytes) |
11100000 | 1 | int64_t(8 bytes) |
11110000 | 1 | 24位有符整数(3 bytes) |
11111110 | 1 | 8位有符整数(1 bytes) |
1111xxxx | 1 | 直接在xxxx位置保存数值,范围从0001~1101,减1后结果为实际值 |
ZipList的连锁更新问题
ZipList的每个Entry都包含previous_entry_length来记录上一个节点的大小,长度是1个或5个字节:
- 如果前一节点的长度小于254字节,则采用1个字节来保存这个长度值
- 如果前一节点的长度大于等于254字节,则采用5个字节来保存这个长度值,第一个字节为0xfe,后四个字节才是真实长度数据
现在,假设我们有N个连续的、长度为250~253字节之间的entry,因此entry的previous_entry_length属性用1个字节即可表示,如图所示:
ZipList这种特殊情况下产生的连续多次空间扩展操作称之为连锁更新(Cascade Update)。新增、删除都可能导致连锁更新的发生。
ZipList特性:
- 压缩列表的可以看做一种连续内存空间的"双向链表"。
- 列表的节点之间不是通过指针连接,而是记录上一节点和本节点长度来寻址,内存占用较低。
- 如果列表数据过多,导致链表过长,可能影响查询性能。
- 增或删较大数据时有可能发生连续更新问题。
(3)QuickList
问题1:ZipList虽然节省内存,但申请内存必须是连续空间,如果内存占用较多,申请内存效率很低。怎么办?
答:为了缓解这个问题,我们必须限制ZipList的长度和entry大小。
问题2:但是我们要存储大量数据,超出了ZipList最佳的上限该怎么办?
答:我们可以创建多个ZipList来分片存储数据。
问题3:数据拆分后比较分散,不方便管理和查找,这多个ZipList如何建立联系?
答:Redis在3.2版本引入了新的数据结构QuickList,它是一个双端链表,只不过链表中的每个节点都是一个ZipList。
为了避免QuickList中的每个ZipList中entry过多,Redis提供了一个配置项:list-max-ziplist-size来限制。
如果值为正,则代表ZipList的允许的entry个数的最大值
如果值为负,则代表ZipList的最大内存大小,其默认值为 -2,分5种情况:
- -1:每个ZipList的内存占用不能超过4kb
- -2:每个ZipList的内存占用不能超过8kb
- -3:每个ZipList的内存占用不能超过16kb
- -4:每个ZipList的内存占用不能超过32kb
- -5:每个ZipList的内存占用不能超过64kb
以下是QuickList的和QuickListNode的结构源码:
我们接下来用一段流程图来描述当前的这个结构:
QuickList的特点:
- 是一个节点为ZipList的双端链表。
- 节点采用ZipList,解决了传统链表的内存占用问题。
- 控制了ZipList大小,解决连续内存空间申请效率问题。
- 中间节点可以压缩,进一步节省了内存。
综上所述,List 底层原理如下:
- 在3.2版本之前,Redis采用ZipList和LinkedList来实现List,当元素数量小于512并且元素大小小于64字节时采用ZipList编码,超过则采用LinkedList编码。
- 在3.2版本之后,Redis统一采用QuickList来实现List。
Redis数据结构-Set结构
Set是Redis中的单列集合,满足下列特点:
- 不保证有序性
- 保证元素唯一
- 求交集、并集、差集
可以看出,Set对查询元素的效率要求非常高,思考一下,什么样的数据结构可以满足?
HashTable,也就是Redis中的Dict,不过Dict是双列集合(可以存键、值对)。下面来详细介绍一下 Dict 数据结构:
我们知道Redis是一个键值型(Key-Value Pair)的数据库,我们可以根据键实现快速的增删改查。而键与值的映射关系正是通过Dict来实现的。
Dict由三部分组成,分别是:哈希表(DictHashTable)、哈希节点(DictEntry)、字典(Dict)
当我们向Dict添加键值对时,Redis首先根据key计算出hash值(h),然后利用 h & sizemask来计算元素应该存储到数组中的哪个索引位置。我们存储k1=v1,假设k1的哈希值h =1,则1&3 =1,因此k1=v1要存储到数组角标1位置。
Dict由三部分组成,分别是:哈希表(DictHashTable)、哈希节点(DictEntry)、字典(Dict),源码如下:
数据结构如下:
Dict的结构:
- 类似java的HashTable,底层是数组加链表来解决哈希冲突
- Dict包含两个哈希表,ht[0]平常用,ht[1]用来rehash
Set是Redis中的集合,不一定确保元素有序,可以满足元素唯一、查询效率要求极高。
为了查询效率和唯一性,set采用HT编码(Dict)。Dict中的key用来存储元素,value统一为null。
当存储的所有数据都是整数,并且元素数量不超过set-max-intset-entries时,Set会采用IntSet编码,以节省内存
介绍一下 Dict 的扩容,即 rehash
Dict中的HashTable就是数组结合单向链表的实现,当集合中元素较多时,必然导致哈希冲突增多,链表过长,则查询效率会大大降低。
Dict在每次新增键值对时都会检查负载因子(LoadFactor = used/size) ,满足以下两种情况时会触发哈希表扩容:
- 哈希表的 LoadFactor >= 1,并且服务器没有执行 BGSAVE 或者 BGREWRITEAOF 等后台进程;
- 哈希表的 LoadFactor > 5 ;
源码如下:
不管是扩容还是收缩,必定会创建新的哈希表,导致哈希表的size和sizemask变化,而key的查询与sizemask有关。因此必须对哈希表中的每一个key重新计算索引,插入新的哈希表,这个过程称为rehash。过程是这样的:
- 计算新hash表的realeSize,值取决于当前要做的是扩容还是收缩:
- 如果是扩容,则新size为第一个大于等于dict.ht[0].used + 1的2^n
- 如果是收缩,则新size为第一个大于等于dict.ht[0].used的2^n (不得小于4)
- 按照新的realeSize申请内存空间,创建dictht,并赋值给dict.ht[1]
- 设置dict.rehashidx = 0,标示开始rehash
- 将dict.ht[0]中的每一个dictEntry都rehash到dict.ht[1],在每次增删查改时进行一次迁移操作,渐进式rehash
- 将dict.ht[1]赋值给dict.ht[0],给dict.ht[1]初始化为空哈希表,释放原来的dict.ht[0]的内存
- 将rehashidx赋值为-1,代表rehash结束
- 在rehash过程中,新增操作,则直接写入ht[1],查询、修改和删除则会在dict.ht[0]和dict.ht[1]依次查找并执行。这样可以确保ht[0]的数据只减不增,随着rehash最终为空
Redis数据结构-ZSET
ZSet也就是SortedSet,其中每一个元素都需要指定一个score值和member值:
- 可以根据score值排序后
- member必须唯一
- 可以根据member查询分数
因此,zset底层数据结构必须满足键值存储、键必须唯一、可排序、根据key找value这几个需求。需要下面的数据结构:
- SkipList:可以排序,并且可以同时存储score和ele值(member)
- HT(Dict):可以键值存储,并且可以根据key找value
源码如下:
当元素数量不多时,HT和SkipList的优势不明显,而且更耗内存。因此zset还会采用ZipList结构来节省内存,不过需要同时满足两个条件:
- 元素数量小于zset_max_ziplist_entries,默认值128
- 每个元素都小于zset_max_ziplist_value字节,默认值64
ziplist本身没有排序功能,而且没有键值对的概念,因此需要有zset通过编码实现:
- ZipList是连续内存,因此score和element是紧挨在一起的两个entry, element在前,score在后
- score越小越接近队首,score越大越接近队尾,按照score值升序排列
Redis数据结构-Hash
Hash结构与Redis中的Zset非常类似:
- 都是键值存储
- 都需求根据键获取值
- 键必须唯一
区别如下:
- zset的键是member,值是score;hash的键和值都是任意值
- zset要根据score排序;hash则无需排序
底层实现方式:压缩列表ziplist 或者 字典dict
当Hash中数据项比较少的情况下,Hash底层才⽤压缩列表ziplist进⾏存储数据,随着数据的增加,底层的ziplist就可能会转成dict,具体配置如下:
- hash-max-ziplist-entries 512
- hash-max-ziplist-value 64
当满足上面两个条件其中之⼀的时候,Redis就使⽤dict字典来实现hash。
Redis的hash之所以这样设计,是因为当ziplist变得很大的时候,它有如下几个缺点:
- 每次插⼊或修改引发的realloc操作会有更⼤的概率造成内存拷贝,从而降低性能。
- ⼀旦发生内存拷贝,内存拷贝的成本也相应增加,因为要拷贝更⼤的⼀块数据。
- 当ziplist数据项过多的时候,在它上⾯查找指定的数据项就会性能变得很低,因为ziplist上的查找需要进行遍历。
总之,ziplist本来就设计为各个数据项挨在⼀起组成连续的内存空间,这种结构并不擅长做修改操作。⼀旦数据发⽣改动,就会引发内存realloc,可能导致内存拷贝。
hash结构如下:
zset集合如下:
因此,Hash底层采用的编码与Zset也基本一致,只需要把排序有关的SkipList去掉即可:
Hash结构默认采用ZipList编码,用以节省内存。 ZipList中相邻的两个entry 分别保存field和value
当数据量较大时,Hash结构会转为HT编码,也就是Dict,触发条件有两个:
- ZipList中的元素数量超过了hash-max-ziplist-entries(默认512)
- ZipList中的任意entry大小超过了hash-max-ziplist-value(默认64字节)
redis持久化机制详解
Redis 是内存数据库,如果不将内存中的数据库状态保存到磁盘,那么一旦服务器进程退出,服务器中的数据库状态也会消失。所以 Redis 提供了持久化功能。
快照持久化-RDB (Redis Database)
RDB全称Redis Database Backup file(Redis数据备份文件),也被叫做Redis数据快照。简单来说就是把内存中的所有数据都记录到磁盘中。当Redis实例故障重启后,从磁盘读取快照文件,恢复数据。快照文件称为RDB文件,默认是保存在当前运行目录。
按照配置, 隔一段时间,fork一个子进程,把父进程的内存数据全部copy到磁盘文件。先将数据写入到一个临时文件中,等持久化过程都结束了,再用这个临时文件替换上次持久化好的文件(dump.rdb,Redis 默认只保留最近一次持久化的 RDB 文件,以保持最新的数据库状态)。整个过程中,主进程是不进行任何IO操作的,这就确保了极高的性能。将来server重启,直接把数据从磁盘加载到内存即可。
基于copy on write机制,fork并不会拷贝出完整的内存,而是共享父进程内存,但是只限于父子进程都只读,如果任意进程出现写操作(一般为父进程,有client写redis),则还是会复制两份内容,父子进程各一份,父进程可继续写入,子进程持久化完后回收内存。
copy on write的实现原理:
fork()之后,kernel把父进程中所有的内存页的权限都设为read-only,然后子进程的地址空间指向父进程。当父子进程都只读内存时,相安无事。当其中某个进程写内存时(如父进程有client写redis),CPU硬件检测到内存页是read-only的,于是触发页异常中断(page-fault),kernel就会把触发的异常的页复制一份,于是父子进程各自持有独立的一份,这时就相当于复制了两份,子进程的内存用完回收,父进程可以在持久化过程中继续写入。
Redis 触发 RDB 持久化的方式有以下几种:
-
Redis 配置文件 (redis.conf) 中,通过
save
指令配置在一定时间内发生一定次数写操作时触发 RDB 持久化。默认配置如下:save 900 1 # 900秒(15分钟)内至少有1次写操作 save 300 10 # 300秒(5分钟)内至少有10次写操作 save 60 10000 # 60秒(1分钟)内至少有10000次写操作
-
命令手动触发 RDB 持久化:
BGSAVE # 后台异步保存数据库快照,不会阻塞客户端请求 SAVE # 同步保存数据库快照,会阻塞所有客户端请求直到保存操作完成 FLUSHALL # 清空命令执行后,会立即进行一次持久化操作
-
Redis 正常关闭时,会自动触发 RDB 持久化操作
如果需要恢复不同版本的.rdb文件,只需把.rdb文件放到redis启动目录下,redis启动的时候会自动检查 dump.rdb 恢复其中的数据。
优点:
- 加载速度快:RDB 文件是一个紧凑的二进制文件,加载 RDB 文件可以快速恢复数据;
- 磁盘I/O少:RDB 持久化是通过定期的快照操作完成的,较少的磁盘 I/O 操作;
- fork开销小:得益于copy on write机制,fork并不会拷贝出完整的内存。所以fork本身的开销较少;
- 拷贝的数据比较精确,内存有啥就copy啥,不会出现冗余的磁盘占用,非常适合备份;
不足:
- 数据丢失风险:由于RDB是定期生成快照,如果在两次快照之间Redis服务器宕机,那么上次快照之后的所有数据将会丢失;
- 资源占用大:在创建快照期间会消耗大量的 CPU 和内存资源。
增量持久化-AOF (Append Only File)
AOF全称为Append Only File(追加文件)。Redis处理的每一个写命令都会记录在AOF文件,可以看做是命令日志文件。
将我们的所有写命令都记录下来,恢复的时候就把这个文件全部在执行一遍;以日志的形式来记录每个写操作,将Redis执行过得所有指令记录下来(读操作不记录),只许追加文件但不可以改写文件;Redis启动之初会读取该文件重新构建数据,即根据日志文件的内容将写指令从前到后执行一次以完成数据的恢复工作。
AOF 默认就是文件的无限追加,文件会越来越大。如果 AOF 文件大于默认 64MB,会fork一个子进程来将文件进行重写。子进程根据当前内存中的数据生成新的 AOF 文件,而主进程继续处理客户端请求。在子进程进行重写的同时,新的写命令会被追加到一个 重写缓冲区中。重写完成后,将重写缓冲区中的命令追加到新的 AOF 文件,然后用新的 AOF 文件替换旧的 AOF 文件。
重写相当于,丢弃之前记录的所有缓存,从当前时刻内存中的数据开始重新记录。同时,在重写过程中发生的写操作也要记录。
aof有三种主要的同步策略:
appendfsync always # 每个命令后立即同步
appendfsync everysec # 每秒同步一次
appendfsync no # 从不同步,即不会主动进行同步操作,操作系统会根据自己的调度策略来决定何时将数据写入appendonly.aof
优点:
- 更高的数据安全性:每次写操作后,很快同步到磁盘;
- 可恢复性强:AOF 文件是可读的,容易进行修改或修复。Redis 在重启时会自动检测 AOF 文件的完整性,并尝试修复不完整的日志记录。
不足:
- 磁盘I/O量大:每次写操作后都会记录日志,这会导致更多的磁盘写入操作;
- 文件大小增长:长时间的 AOF 可能会导致过大的日志文件。持久化日志将变成一个很冗余的文件;
- 加载速度慢:恢复数据时,需要重新执行文件中的所有写操作命令,这比直接加载 RDB 文件要慢。
RDB与AOF对比
RDB和AOF各有自己的优缺点,如果对数据安全性要求较高,在实际开发中往往会结合两者来使用。
redis单线程模型
对于 redis 开始设计时使用单线程模型,主要有如下的考虑:
- 通过 I/O 多路复用,redis 也可以很好的并发处理客户端的请求。
- redis 的使用瓶颈并不是 CPU,影响 redis的效率主要是内存和网络请求,如果 redis 提供的 1s 处理百万请求仍然无法满足要求,可以同时启动多个 redis 实例,使用分片的方式把请求交给不同的 redis 服务。对于内存:因为 redis 的所有操作都是在内存中进行,数据都在内存中,操作都是纳秒级的,同时它的网络协议也会简单,读写解码也都很快,除了持久化会数据保存到硬盘,会消耗较多时间。
- 如果 redis 使用多线程,不可避免的要增加对多线程的处理,线程切换,因为并发所要引入的加锁,解锁,死锁等机制所带来的额外消耗反而是一种负优化。
所以影响 redis 性能的主要是网络 IO,因此 redis6.0 中引入的多线程去优化了网络的 IO 请求时间,I/O 多路复用主要是让线程可以监听连接是否可读写,但是 IO 数据的读写仍然是阻塞,当 socket 里有数据的时候,redis 会通过系统调用将数据从内核态拷贝到用户态,供 redis 解析,数据量越大,时间消耗也就越大,在 redis6.0 中引入多线程机制主要是将主线程的 IO 读写任务拆分出来给一组独立的线程执行,使得多个 socket 的读写可以并行化,这样可以极大的提高效率。
而对于 redis4.0 中引入的多线程,主要是为了删除超大键值对的数据,当执行了 UNLINK 操作后,会将键从元数据中删除,释放内存空间的操作则会在后台异步执行,这样避免了删除操作阻塞待处理的任务,提高执行效率。
redis 为什么快?
redis 的使用瓶颈并不是 CPU,影响 redis的效率主要是内存和网络请求。
基于内存操作,速度快,不需要进行磁盘IO;引入多线程和IO多路复用,用于处理客户端请求。
Redis发布订阅
Redis 发布订阅(pub/sub)是一种消息通信模式:发送者(pub)发送消息,订阅者(sub)接收消息。
Redis 客户端可以订阅任意数量的频道。
订阅/发布消息图:第一个:消息发送者, 第二个:频道,第三个:消息订阅者。
当有新消息通过 PUBLISH 命令发送给频道 channel1 时, 这个消息就会被发送给订阅它的客户端:
Redis是使用C实现的,通过分析 Redis 源码里的 pubsub.c 文件,了解发布和订阅机制的底层实现,籍此加深对 Redis 的理解。
Redis 通过 PUBLISH 、SUBSCRIBE 和 PSUBSCRIBE 等命令实现发布和订阅功能。
通过 SUBSCRIBE 命令订阅某频道后,redis-server 里维护了一个字典,字典的键就是一个个频道,而字典的值则是一个链表,链表中保存了所有订阅这个 channel 的客户端。SUBSCRIBE 命令的关键就是将客户端添加到给定 channel 的订阅链表中。
通过 PUBLISH 命令向订阅者发送消息,redis-server 会使用给定的频道作为键,在它所维护的 channel 字典中查找记录了订阅这个频道的所有客户端的链表,遍历这个链表,将消息发布给所有订阅者。
Pub/Sub 从字面上理解就是发布(Publish)与订阅(Subscribe),在Redis中,你可以设定对某一个key值进行消息发布及消息订阅,当一个key值上进行了消息发布后,所有订阅它的客户端都会收到相应的消息。这一功能最明显的用法就是用作实时消息系统,比如普通的即时聊天,群聊等功能。
使用场景:实时消息系统、实时聊天室、订阅系统,关注系统。稍微复杂的场景我们就会使用 消息中间件 MQ。
介绍 redis 缓存穿透问题以及解决思路
缓存穿透 :缓存穿透是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库。
常见的解决方案有两种:
- 缓存空对象
- 优点:实现简单,维护方便
- 缺点:
- 额外的内存消耗
- 可能造成短期的不一致
- 布隆过滤
- 优点:内存占用较少,没有多余key
- 缺点:
- 实现复杂
- 存在误判可能
缓存空对象思路分析:当我们客户端访问不存在的数据时,先请求redis,但是此时redis中没有数据,此时会访问到数据库,但是数据库中也没有数据,这个数据穿透了缓存,直击数据库,我们都知道数据库能够承载的并发不如redis这么高,如果大量的请求同时过来访问这种不存在的数据,这些请求就都会访问到数据库,简单的解决方案就是哪怕这个数据在数据库中也不存在,我们也把这个数据存入到redis中去,这样,下次用户过来访问这个不存在的数据,那么在redis中也能找到这个数据就不会进入到缓存了
布隆过滤:布隆过滤器其实采用的是哈希思想来解决这个问题,通过一个庞大的二进制数组,走哈希思想去判断当前这个要查询的这个数据是否存在,如果布隆过滤器判断存在,则放行,这个请求会去访问redis,哪怕此时redis中的数据过期了,但是数据库中一定存在这个数据,在数据库中查询出来这个数据后,再将其放入到redis中。假设布隆过滤器判断这个数据不存在,则直接返回。
这种方式优点在于节约内存空间,但是存在误判,误判原因在于:布隆过滤器走的是哈希思想,只要哈希思想,就可能存在哈希冲突。
布隆筛选若不存在则一定是不存在,若存在,则不确定是否真的存在,不是百分百准确。
介绍 redis 缓存雪崩问题以及解决思路
缓存雪崩:是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。
解决方案:
- 给不同的Key的TTL添加随机值。
- 利用Redis集群提高服务的可用性。
- 给缓存业务添加降级限流策略。
- 给业务添加多级缓存。
介绍 redis 缓存击穿问题以及解决思路
缓存击穿问题也叫热点Key问题,就是一个被高并发访问并且缓存重建业务较复杂的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。
这里需要注意和缓存穿透的区别,缓存击穿是指一个key非常热点,在不停的扛着大并发,大并发集中对这一个点进行访问,当这个key在失效的瞬间,持续的大并发就穿破缓存,直接请求数据库,就像在一个屏障上凿开了一个洞。当某个key在过期的瞬间,有大量的请求并发访问,这类数据一般是热点数据,由于缓存过期,会同时访问数据库来查询最新数据,并且回写缓存,会导使数据库瞬间压力过大。
逻辑分析:假设线程1在查询缓存之后,本来应该去查询数据库,然后把这个数据重新加载到缓存的,此时只要线程1走完这个逻辑,其他线程就都能从缓存中加载这些数据了,但是假设在线程1没有走完的时候,后续的线程2,线程3,线程4同时过来访问当前这个方法, 那么这些线程都不能从缓存中查询到数据,那么他们就会同一时刻来访问查询缓存,都没查到,接着同一时间去访问数据库,同时的去执行数据库代码,对数据库访问压力过大。
常见的解决方案有两种:
- 互斥锁
- 逻辑过期(数据永不过期)
方案一,使用互斥锁来解决:
因为锁能实现互斥性。假设线程过来,只能一个人一个人的来访问数据库,从而避免对于数据库访问压力过大,但这也会影响查询的性能,因为此时会让查询的性能从并行变成了串行,我们可以采用tryLock方法 + double check来解决这样的问题。
假设现在线程1过来访问,他查询缓存没有命中,但是此时他获得到了锁的资源,那么线程1就会一个人去执行逻辑,假设现在线程2过来,线程2在执行过程中,并没有获得到锁,那么线程2就可以进行到休眠,直到线程1把锁释放后,线程2获得到锁,然后再来执行逻辑,此时就能够从缓存中拿到数据了。
解决方案二,逻辑过期方案:
方案分析:我们之所以会出现这个缓存击穿问题,主要原因是在于我们对key设置了过期时间,假设我们不设置过期时间,其实就不会有缓存击穿的问题,但是不设置过期时间,这样数据不就一直占用我们内存了吗,我们可以采用逻辑过期方案。
我们把过期时间设置在 redis的value中,注意:这个过期时间并不会直接作用于redis,而是我们后续通过逻辑去处理。假设线程1去查询缓存,然后从value中判断出来当前的数据已经过期了,此时线程1去获得互斥锁,那么其他线程会进行阻塞,获得了锁的线程他会开启一个 线程去进行 以前的重构数据的逻辑,直到新开的线程完成这个逻辑后,才释放锁, 而线程1直接进行返回,假设现在线程3过来访问,由于线程线程2持有着锁,所以线程3无法获得锁,线程3也直接返回数据,只有等到新开的线程2把重建数据构建完后,其他线程才能走返回正确的数据。
这种方案巧妙在于,异步的构建缓存,缺点在于在构建完缓存之前,返回的都是脏数据。
对比
互斥锁方案:由于保证了互斥性,所以数据一致,且实现简单,因为仅仅只需要加一把锁而已,也没其他的事情需要操心,所以没有额外的内存消耗,缺点在于有锁就有死锁问题的发生,且只能串行执行性能肯定受到影响。
逻辑过期方案: 线程读取过程中不需要等待,性能好,有一个额外的线程持有锁去进行重构数据,但是在重构数据完成前,其他的线程只能返回之前的数据,且实现起来麻烦。
redis中热key和大key怎么处理?
热key是指高请求的key,某个瞬间有大量的请求去访问Redis上某个固定的key,导致缓存击穿,请求都打到了DB上,压垮了缓存服务和DB服务
需要做一些处理来缓解redis压力:
-
多级缓存:在 Redis 之前添加一级缓存,比如在应用服务器上使用本地缓存,减少对 Redis 的直接访问。
-
将热key分散到不同的服务器中:不要让固定key老是走到同一台redis节点上;把这个key,在多个redis节点上都备份一份即可,高可读。
-
热key拆分:把这个key给细化拆分,让不同用户请求的key是不一样的;
如秒杀活动场景,不同用户根据人群规则命中的活动策略ID可能是不同的,因此我们可以将整个活动元信息拆分成以策略为维度,把活动信息的key细化;这样请求过来时,根据用户人群策略,只会去找该策略绑定的活动信息的key,全量用户的对活动信息的查询请求会分散到不同的活动策略key上,从而避免固定key单点大量查询的问题;
大key是指某个key-value存储的数据非常大,该怎么设计:
-
大key拆分:分片存储,将大key的数据拆分成多个小key,分别存储在 Redis 中。可以使用哈希或其他分片算法来管理这些小key。
def set_large_data(key, data, chunk_size=1024):# 将大数据拆分成多个小块并存储chunks = [data[i:i + chunk_size] for i in range(0, len(data), chunk_size)]for i, chunk in enumerate(chunks):redis_instance.set(f"{key}:{i}", chunk)redis_instance.set(f"{key}:chunks", len(chunks))def get_large_data(key):# 读取拆分的多个小块并合并chunks = int(redis_instance.get(f"{key}:chunks"))data = ""for i in range(chunks):data += redis_instance.get(f"{key}:{i}").decode('utf-8')return data# 示例使用 data = "A" * 5000 # 示例大数据 set_large_data("large_key", data) retrieved_data = get_large_data("large_key")
-
使用合适的数据结构:哈希(Hash)数据结构,将大数据拆分为多个字段存储在哈希中,可以有效利用内存并提高访问效率。
比如说存储用户信息,用字符串:
user:1 {“name”: “Jack”, “age”: 21} 使用hash改造:
user:1 name jack age 21 -
压缩数据:在存储大数据之前,对数据进行压缩,读取时在进行解压。
如果是指redis存储的数据量太大,则考虑搭建集群、分片。
redis内存回收机制
设置TTL过期回收,过期key的删除策略:
- 惰性清理:每次查找key时判断是否过期,如果过期则删除
- 定期清理:定期抽样部分key,判断是否过期,如果过期则删除。定期清理的两种模式:
- SLOW模式执行频率默认为10,每次不超过25ms
- FAST模式执行频率不固定,但两次间隔不低于2ms,每次耗时不超过1ms
当Redis内存使用达到设置的上限时,主动挑选部分key删除以释放更多内存的流程,Redis支持8种不同淘汰策略:
- noeviction(默认): 不淘汰任何key,但是内存满时不允许写入新数据。
- volatile-ttl: 对设置了TTL的key,比较key的剩余TTL值,TTL越小越先被淘汰
- allkeys-random:对全体key ,随机进行淘汰。也就是直接从db->dict中随机挑选
- volatile-random:对设置了TTL的key ,随机进行淘汰。也就是从db->expires中随机挑选。
- allkeys-lru: 对全体key,基于LRU算法进行淘汰
- volatile-lru: 对设置了TTL的key,基于LRU算法进行淘汰
- allkeys-lfu: 对全体key,基于LFU算法进行淘汰
- volatile-lfu: 对设置了TTL的key,基于LFU算法进行淘汰
比较容易混淆的有两个:
- LRU(Least Recently Used),最少最近使用。用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高。
- LFU(Least Frequently Used),最少频率使用。会统计每个key的访问频率,值越小淘汰优先级越高。
redis分布式锁的实现
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
分布式锁的核心思想就是让大家都使用同一把锁,只要大家使用的是同一把锁,那么我们就能锁住线程,不让线程进行,让程序串行执行,这就是分布式锁的核心思路
实现分布式锁时需要实现的两个基本方法:
- 获取锁:
- 互斥:确保只能有一个线程获取锁
- 非阻塞:尝试一次,成功返回true,失败返回false
- 释放锁:
- 手动释放
- 超时释放:获取锁时添加一个超时时间
核心思路:我们利用redis 的 setNx
方法,当有多个线程进入时,我们就利用该方法,第一个线程进入时,redis 中就有这个key 了,返回了1,如果结果是1,则表示他抢到了锁,那么他去执行业务,然后再删除锁,退出锁逻辑,没有抢到锁的线程,等待一定时间后重试即可。
先来实现基础版本的分布式锁,如下基本接口:
tryLock
:利用setnx方法进行加锁,设置一个key,同时增加过期时间,防止死锁。
unlock
:删除该key,表示释放锁。
上述是最简单的分布式锁实现,肯定是存在一些问题的,下面先来说说分布式锁误删的情况
逻辑说明:持有锁的线程在锁的内部出现了阻塞,导致他的锁自动释放,这时其他线程,线程2来尝试获得锁,就拿到了这把锁,然后线程2在持有锁执行过程中,线程1反应过来,继续执行,而线程1执行过程中,走到了删除锁逻辑,此时就会把本应该属于线程2的锁进行删除,这就是误删别人锁的情况说明。如下:
解决方案:在每个线程释放锁的时候,去判断一下当前这把锁是否属于自己,如果不属于自己,则不进行锁的删除。假设还是上边的情况,线程1卡顿,锁自动释放,线程2进入到锁的内部执行逻辑,此时线程1反应过来,然后删除锁,但是线程1,一看当前这把锁不是属于自己,于是不进行删除锁逻辑,当线程2走到删除锁逻辑时,如果没有卡过自动释放锁的时间点,则判断当前这把锁是属于自己的,于是删除这把锁。
修改之前的分布式锁实现,需要满足:在获取锁时存入线程标示(可以用UUID表示),在释放锁时先获取锁中的线程标示,判断是否与当前线程标示一致。
- 如果一致则释放锁
- 如果不一致则不释放锁
核心逻辑:在存入锁时,放入自己线程的标识,在删除锁时,判断当前这把锁的标识是不是自己存入的,如果是,则进行删除,如果不是,则不进行删除。
具体代码如下:加锁
private static final String ID_PREFIX = UUID.randomUUID().toString(true) + "-";
@Override
public boolean tryLock(long timeoutSec) {// 获取线程标示String threadId = ID_PREFIX + Thread.currentThread().getId();// 获取锁Boolean success = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);return Boolean.TRUE.equals(success);
}
释放锁
public void unlock() {// 获取线程标示String threadId = ID_PREFIX + Thread.currentThread().getId();// 获取锁中的标示String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);// 判断标示是否一致if(threadId.equals(id)) {// 释放锁stringRedisTemplate.delete(KEY_PREFIX + name);}
}
这样我们的分布式锁机制就完美了吗?还不是哦,上述修改后的代码还存在的原子性问题,下面来看看。
更为极端的误删逻辑说明:
线程1现在持有锁之后,在执行业务逻辑过程中,他正准备删除锁,而且已经走到了条件判断的过程中,比如他已经拿到了当前这把锁确实是属于他自己的,正准备删除锁,但是此时他的锁到期了,那么此时线程2进来,但是线程1他会接着往后执行,当他卡顿结束后,他直接就会执行删除锁那行代码,相当于条件判断并没有起到作用,这就是删锁时的原子性问题,之所以有这个问题,是因为线程1的拿锁、比锁、删锁,这三个操作实际上并不是原子性的,我们要防止刚才的情况发生。
利用Lua脚本解决多条命令原子性问题
Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。
这里重点介绍Redis提供的调用函数,语法如下:
redis.call('命令名称', 'key', '其它参数', ...)
例如,我们要执行set name jack,则脚本是这样:
# 执行 set name jack
redis.call('set', 'name', 'jack')
例如,我们要先执行set name Rose,再执行get name,则脚本如下:
# 先执行 set name jack
redis.call('set', 'name', 'Rose')
# 再执行 get name
local name = redis.call('get', 'name')
# 返回
return name
写好脚本以后,需要用Redis命令来调用脚本,调用脚本的常见命令如下:
例如,我们要执行 redis.call(‘set’, ‘name’, ‘jack’) 这个脚本,语法如下:
如果脚本中的key、value不想写死,可以作为参数传递。key类型参数会放入KEYS数组,其它参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数:
接下来我们来回想一下我们释放锁的逻辑:
释放锁的业务流程是这样的
- 获取锁中的线程标示
- 判断是否与指定的标示(当前线程标示)一致
- 如果一致则释放锁(删除)
- 如果不一致则什么都不做
如果用Lua脚本来表示则是这样的:
-- 这里的 KEYS[1] 就是锁的key,这里的ARGV[1] 就是当前线程标示
-- 获取锁中的标示,判断是否与当前线程标示一致
if (redis.call('GET', KEYS[1]) == ARGV[1]) then-- 一致,则删除锁return redis.call('DEL', KEYS[1])
end
-- 不一致,则直接返回
return 0
这样我们的一个基本分布式锁就写完了,小总结:
基于Redis的分布式锁实现思路:
- 利用set nx ex获取锁,并设置过期时间,保存线程标示
- 释放锁时先判断线程标示是否与自己一致,一致则删除锁
- 特性:
- 利用set nx满足互斥性
- 利用set ex保证故障时锁依然能释放,避免死锁,提高安全性
- 利用Redis集群保证高可用和高并发特性
- 特性:
一路走来,利用添加过期时间,防止死锁问题的发生,但是有了过期时间之后,可能出现误删别人锁的问题,这个问题我们开始是利用删之前通过拿锁、比锁、删锁这个逻辑来解决的,也就是删之前判断一下当前这把锁是否是属于自己的,但是现在还有原子性问题,也就是我们没法保证拿锁比锁删锁是一个原子性的动作,最后通过lua表达式来解决这个问题。
现在基本分布式锁已经做完了,要想继续完善,也是可以的,参考下面几点
- 重入问题:获得锁的线程可以再次进入到相同的锁的代码块中,可重入锁的意义在于防止死锁,比如HashTable这样的代码中,他的方法都是使用synchronized修饰的,假如他在一个方法内,调用另一个方法,那么此时如果是不可重入的,不就死锁了吗?所以可重入锁他的主要意义是防止死锁,我们的synchronized和Lock锁都是可重入的。
- 不可重试:是指目前的分布式只能尝试一次,我们认为合理的情况是:当线程在获得锁失败后,他应该能再次尝试获得锁。
- 超时释放:我们在加锁时增加了过期时间,这样的我们可以防止死锁,但是如果卡顿的时间超长,虽然我们采用了lua表达式防止删锁的时候,误删别人的锁,但是毕竟没有锁住,有安全隐患。
- 主从一致性:如果Redis提供了主从集群,当我们向集群写数据时,主机需要异步的将数据同步给从机,而万一在同步过去之前,主机宕机了,就会出现死锁问题。
现已有完整分布式锁的实现,Redisson
是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,其中就包含了各种分布式锁的实现。
redission可重入锁原理
数据结构,在 Redis 中,Redisson 可重入锁通常使用以下结构:
- 键:锁的标识(例如 “lock:myLock”)。
- 值:包含锁标识和重入计数的 JSON 字符串或哈希表。
获取锁:
- 当线程尝试获取锁时,首先检查 Redis 中是否已经存在锁。
- 如果锁不存在(键未设置),则设置锁并将当前线程的标识和重入计数(初始为 1)存储在 Redis 中。
- 如果锁已存在,检查当前线程是否为锁的拥有者(通过锁标识进行比对)。如果是,重入计数加 1。
- 如果不是锁的拥有者,则阻塞等待,直到锁被释放。
释放锁:
- 当线程完成任务并释放锁时,首先检查重入计数。
- 如果重入计数大于 1,则将计数减 1,并保持锁的状态。
- 如果计数减至 0,则从 Redis 中删除锁键,释放锁。
锁的过期:
- 为了防止死锁,Redisson 会为锁设置过期时间。如果线程在持有锁的情况下崩溃或未能释放锁,锁会在过期后自动释放。
redission锁重试机制
允许在尝试获取锁失败后,自动进行多次重试,直到成功获取锁或达到最大重试次数。其工作流程如下:
获取锁时重试:
- 当一个线程请求锁时,如果锁已被其他线程持有,Redisson 会在配置的重试次数内,等待一定的时间间隔后重新尝试获取锁。
- 重试次数和间隔可以通过配置设置,允许开发者根据具体需求调整。
redission的 WatchDog 机制
WatchDog 机制 主要用于锁的自动续期,防止持锁线程由于意外情况(如崩溃或长时间运行)未能释放锁而导致的死锁。
工作原理:
- 当线程成功获取锁后,Redisson 会启动一个 WatchDog 线程,定期检查锁的持有状态。
- WatchDog 会根据配置的过期时间,在锁的生存期内定期更新锁的过期时间,确保锁不会因超时而被意外释放。
续期操作:
- 每次续期时,WatchDog 会向 Redis 发送命令,更新锁的过期时间,保持锁的有效性。
- 续期操作是在后台进行的,不会影响持锁线程的执行。
锁释放:
- 当持锁线程完成任务并释放锁时,WatchDog 会停止工作,锁会被正常释放。
redission锁的MutiLock原理
MultiLock 允许一个线程在同一时间对多个 Redis 键进行加锁,确保在执行操作时,这些资源不会被其他线程访问。这种锁的特性保证了操作的原子性,即要么所有资源都被成功锁定,要么没有锁定。
锁定请求:
- 当一个线程请求 MultiLock 时,Redisson 会尝试同时锁定多个指定的资源。
- 为每个资源生成一个唯一的锁标识(通常是 UUID),用于跟踪每个锁的拥有者。
获取锁:
- Redisson 通过批量的 Redis 命令尝试锁定所有资源。如果所有资源都成功获取锁,MultiLock 将保持有效状态。
- 如果在获取某个资源的锁时失败(例如,某个资源已被其他线程锁定),则会立即释放已成功获取的锁,确保不会持有部分锁。
锁的重试机制:
- 类似于普通锁,MultiLock 也可以配置重试次数和重试间隔,以处理竞争条件,增加获取锁的成功率。
释放锁:
- 一旦线程完成对多个资源的操作,它会调用释放锁的方法。Redisson 将根据之前生成的锁标识释放所有锁。
- 释放时,Redisson 确保只有持有锁的线程才能释放相应的锁,避免误释放。
为什么需要Redis主从复制?
主从复制,是指将一台Redis服务器的数据,复制到其他的Redis服务器。前者称为主节点(master/leader),后者称为从节点(slave/follower);数据的复制是单向的,只能由主节点到从节点。Master以写为主,Slave 以读为主。默认情况下,每台Redis服务器都是主节点;且一个主节点可以有多个从节点(或没有从节点),但一个从节点只能有一个主节点。
主从复制的作用主要包括:
- 数据冗余:主从复制实现了数据的热备份,是持久化之外的一种数据冗余方式。
- 故障恢复:当主节点出现问题时,可以由从节点提供服务,实现快速的故障恢复;实际上是一种服务的冗余。
- 负载均衡:在主从复制的基础上,配合读写分离,可以由主节点提供写服务,由从节点提供读服务(即写Redis数据时应用连接主节点,读Redis数据时应用连接从节点),分担服务器负载;尤其是在写少读多的场景下,通过多个从节点分担读负载,可以大大提高Redis服务器的并发量。
- 高可用(集群)基石:除了上述作用以外,主从复制还是哨兵和集群能够实施的基础,因此说主从复制是Redis高可用的基础。
主从节点的同步机制是什么?
全量同步
主从第一次建立连接时,会执行全量同步,将master节点的所有数据都拷贝给slave节点,流程:
这里有一个问题,master如何得知salve是第一次来连接呢??
有几个概念,可以作为判断依据:
- Replication Id:简称replid,是数据集的标记,id一致则说明是同一数据集。每一个master都有唯一的replid,slave则会继承master节点的replid。
- offset:偏移量,随着记录在repl_baklog中的数据增多而逐渐增大。slave完成同步时也会记录当前同步的offset。如果slave的offset小于master的offset,说明slave数据落后于master,需要更新。
因此slave做数据同步,必须向master声明自己的replication id 和offset,master才可以判断到底需要同步哪些数据。
因为slave原本也是一个master,有自己的replid和offset,当第一次变成slave,与master建立连接时,发送的replid和offset是自己的replid和offset。
master判断发现slave发送来的replid与自己的不一致,说明这是一个全新的slave,就知道要做全量同步了。
master会将自己的replid和offset都发送给这个slave,slave保存这些信息。以后slave的replid就与master一致了。
因此,master判断一个节点是否是第一次同步的依据,就是看replid是否一致。如下图:
完整流程描述:
- slave节点请求增量同步
- master节点判断replid,发现不一致,拒绝增量同步
- master将完整内存数据生成RDB,发送RDB到slave
- slave清空本地数据,加载master的RDB
- master将RDB期间的命令记录在repl_baklog,并持续将log中的命令发送给slave
- slave执行接收到的命令,保持与master之间的同步
增量同步
全量同步需要先做RDB,然后将RDB文件通过网络传输个slave,成本太高了。因此除了第一次做全量同步,其它大多数时候slave与master都是做增量同步。
什么是增量同步?就是只更新slave与master存在差异的部分数据。如图:
那么master怎么知道slave与自己的数据差异在哪里呢?
这就要说到全量同步时的repl_baklog文件了。
这个文件是一个固定大小的数组,只不过数组是环形,也就是说角标到达数组末尾后,会再次从0开始读写,这样数组头部的数据就会被覆盖。
repl_baklog中会记录Redis处理过的命令日志及offset,包括master当前的offset,和slave已经拷贝到的offset:
slave与master的offset之间的差异,就是salve需要增量拷贝的数据了。
随着不断有数据写入,master的offset逐渐变大,slave也不断的拷贝,追赶master的offset:
直到数组被填满:
此时,如果有新的数据写入,就会覆盖数组中的旧数据。不过,旧的数据只要是绿色的,说明是已经被同步到slave的数据,即便被覆盖了也没什么影响。因为未同步的仅仅是红色部分。
但是,如果slave出现网络阻塞,导致master的offset远远超过了slave的offset:
如果master继续写入新数据,其offset就会覆盖旧的数据,直到将slave现在的offset也覆盖:
棕色框中的红色部分,就是尚未同步,但是却已经被覆盖的数据。此时如果slave恢复,需要同步,却发现自己的offset都没有了,无法完成增量同步了,只能做全量同步。
注意:repl_baklog大小有上限,写满后会覆盖最早的数据。如果slave断开时间过久,导致尚未备份的数据被覆盖,则无法基于log做增量同步,只能再次全量同步。
总结:
简述全量同步和增量同步区别?
- 全量同步:master将完整内存数据生成RDB,发送RDB到slave。后续命令则记录在repl_baklog,逐个发送给slave。
- 增量同步:slave提交自己的offset到master,master获取repl_baklog中从offset之后的命令给slave。
什么时候执行全量同步?
- slave节点第一次连接master节点时
- slave节点断开时间太久,repl_baklog中的offset已经被覆盖时
什么时候执行增量同步?
- slave节点断开又恢复,并且在repl_baklog中能找到offset时
主从同步优化
主从同步可以保证主从数据的一致性,非常重要。可以从以下几个方面来优化Redis主从就集群:
- 在master中配置repl-diskless-sync yes启用无磁盘复制,避免全量同步时的磁盘IO。
- Redis单节点上的内存占用不要太大,减少RDB导致的过多磁盘IO。
- 适当提高repl_baklog的大小,发现slave宕机时尽快实现故障恢复,尽可能避免全量同步。
- 限制一个master上的slave节点数量,如果实在是太多slave,则可以采用主-从-从链式结构,减少master压力。
主从从架构图:
Redis哨兵机制
哨兵的结构如图:
哨兵的作用如下:
- 监控:Sentinel 会不断检查您的master和slave是否按预期工作。
- 自动故障恢复:如果master故障,Sentinel会将一个slave提升为master。当故障实例恢复后也以新的master为主。
- 通知:Sentinel充当Redis客户端的服务发现来源,当集群发生故障转移时,会将最新信息推送给Redis的客户端。
Sentinel基于心跳机制监测服务状态,每隔1秒向集群的每个实例发送ping命令:
- 主观下线:如果某sentinel节点发现某实例未在规定时间响应,则认为该实例主观下线。
- 客观下线:若超过指定数量(quorum)的sentinel都认为该实例主观下线,则该实例客观下线。quorum值最好超过Sentinel实例数量的一半。
一旦发现master故障,sentinel需要在salve中选择一个作为新的master,选择依据是这样的:
- 首先会判断slave节点与master节点断开时间长短,如果超过指定值(down-after-milliseconds * 10)则会排除该slave节点
- 然后判断slave节点的slave-priority值,越小优先级越高,如果是0则永不参与选举
- 如果slave-prority一样,则判断slave节点的offset值,越大说明数据越新,优先级越高
- 最后是判断slave节点的运行id大小,越小优先级越高。
当选出一个新的master后,该如何实现切换呢?
流程如下:
- sentinel给备选的slave1节点发送slaveof no one命令,让该节点成为master。
- sentinel给所有其它slave发送slaveof 192.168.150.101 7002 命令,让这些slave成为新master的从节点,开始从新的master上同步数据。
- 最后,sentinel将故障节点标记为slave,当故障节点恢复后会自动成为新的master的slave节点。
小结
Sentinel的三个作用是什么?
- 监控
- 故障转移
- 通知
Sentinel如何判断一个redis实例是否健康?
- 每隔1秒发送一次ping命令,如果超过一定时间没有相向则认为是主观下线
- 如果大多数sentinel都认为实例主观下线,则判定服务下线
故障转移步骤有哪些?
- 首先选定一个slave作为新的master,执行slaveof no one
- 然后让所有节点都执行slaveof 新master
- 修改故障节点配置,添加slaveof 新master
redis集群,怎么实现数据分布式存储?
主从和哨兵可以解决高可用、高并发读的问题。但是依然有两个问题没有解决:
- 海量数据存储问题
- 高并发写的问题
使用分片集群可以解决上述问题,如图:
分片集群特征:
- 集群中有多个master,每个master保存不同数据。
- 每个master都可以有多个slave节点。
- master之间通过ping监测彼此健康状态。
- 客户端请求可以访问集群任意节点,最终都会被转发到正确节点。
分片集群的原理是利用散列插槽
,Redis会把每一个master节点映射到0~16383共16384个插槽(hash slot)上,查看集群信息时就能看到:
数据key不是与节点绑定,而是与插槽绑定。redis会根据key的有效部分计算插槽值,分两种情况:
- key中包含"{}",且“{}”中至少包含1个字符,“{}”中的部分是有效部分
- key中不包含“{}”,整个key都是有效部分
例如:key是num,那么就根据num计算,如果是{itcast}num,则根据itcast计算。计算方式是利用CRC16算法得到一个hash值,然后对16384取余,得到的结果就是slot值。
如图,在7001这个节点执行set a 1时,对a做hash运算,对16384取余,得到的结果是15495,因此要存储到103节点。
到了7003后,执行get num
时,对num做hash运算,对16384取余,得到的结果是2765,因此需要切换到7001节点。
Redis如何判断某个key应该在哪个实例?
- 将16384个插槽分配到不同的实例
- 根据key的有效部分计算哈希值,对16384取余
- 余数作为插槽,寻找插槽所在实例即可
如何将同一类数据固定的保存在同一个Redis实例?
- 这一类数据使用相同的有效部分,例如key都以{typeId}为前缀
redis客户端对分布式集群发起一个查询,介绍整个过程
连接到集群
- 客户端首先需要连接到集群中的任一节点。通常客户端库会处理连接的初始化和管理工作。
命令发送
- 计算键的哈希槽:Redis Cluster 将所有的键映射到 0-16383 总共 16384 个哈希槽中。客户端使用 CRC16 哈希函数对键名进行哈希计算,然后取模(mod 16384)来确定该键应该在哪一个哈希槽。
- 确定目标节点:每个哈希槽会被指派给一个主节点负责处理。客户端根据自己维护的集群的当前状态(哪个节点负责哪个槽),确定负责该槽的节点,并将命令发送到该节点。
查询处理
- 执行命令:负责该哈希槽的节点接收到命令后,会在其数据集中查找或操作对应的键。
- 返回结果:操作完成后,该节点会将结果返回给客户端。
错误处理与重定向
- Moved 重定向:如果客户端首次连接的节点不负责该键的哈希槽,那么该节点会返回一个
MOVED
错误,告诉客户端正确的节点地址。客户端接着会重新连接到正确的节点并重发命令。 - Ask 重定向:在进行键迁移的过程中,如果访问的键暂时处在两个节点之间,原节点可能返回
ASK
错误,客户端需要临时重定向到新节点,并使用ASKING
命令来发送原始请求。
客户端状态更新
- 在接收到
MOVED
或ASK
重定向响应后,客户端通常会更新自己维护的节点与槽的映射信息,以便后续请求能直接发送到正确的节点。
同一份数据,既要在MySQL存储,又要在Redis存储,要怎么设计实现?
要实现同一份数据在 MySQL 和 Redis 中存储,并确保两者之间的数据同步,可以采取以下设计方案:
-
数据架构设计
- MySQL:作为主数据存储,负责持久化和复杂查询。
- Redis:作为缓存层,存储热数据,以加速读取。
-
数据写入流程
写入 MySQL:- 当需要写入数据时,首先将数据插入 MySQL 数据库。
- 生成一条操作日志,包括操作类型(如新增、更新、删除)、数据内容和唯一标识符,将其存储到 Redis 中的日志队列。
写入 Redis:
- 紧接着,将相同的数据写入 Redis,以便快速读取。
- 使用合适的数据结构(如 Hash 或 String)来存储数据,确保可以快速访问。
-
数据同步机制
使用 Redis 日志:- Redis 中的日志可以用作数据同步的队列,记录所有对 MySQL 的写操作。
- 日志内容可以是 JSON 格式,包括操作类型、数据主键和相关字段。
-
数据一致性保证
异步同步:- 通过一个后台服务或定时任务读取 Redis 日志,并将未处理的操作应用到 MySQL 中。这种设计可以减轻 MySQL 的实时压力。
异常处理:
- 如果在写入 MySQL 时发生错误,记录该操作并采取重试机制,确保最终一致性。
- 监控 Redis 日志处理过程,记录失败的操作,并提供手动修复的接口。
-
数据读取流程
读取数据时,优先从 Redis 中获取。如果 Redis 中不存在,再从 MySQL 中读取,并将结果缓存到 Redis。 -
数据更新和删除
更新:- 更新数据时,遵循写入流程,先更新 MySQL,然后更新 Redis,并在日志中记录操作。
删除:
- 删除操作应在 MySQL 和 Redis 中同时进行,并记录日志。
-
监控和告警
监控 Redis 和 MySQL 之间的同步状态,设置告警机制,当日志处理失败或同步出现异常时及时通知相关人员。 -
定期清理
定期清理 Redis 中的日志数据,防止内存占用过高。
恭喜你全部读完啦!古人云:温故而知新。赶紧收藏关注起来,面试前再翻一翻吧~
📣推荐阅读
C/C++后端开发面试总结:点击进入 后端开发面经 关注走一波
C++重点知识:点击进入 C++重点知识 关注走一波