是一个基于内存的、可持久化的键值对(key-value型)存储系统,数据读写是内存中的。
1.是单线程吗?为什么还这么快?
是也不是,Redis单线程指的是 接受客户端请求->解析请求->进行数据读写操作->发送数据给客户端 这个过程是由一个线程来完成,这是我们常说Redis是单线程的原因,关闭文件、AOF刷盘、释放内存又有各自的线程,这是说Redis多线程的原因。
单线程Redis吞吐量可以达到10W/每秒,Redis 采用单线程(网络 I/O 和执行命令)那么快,有如下几个原因:
- Redis的数据读写是在内存中完成,所有的数据都存储在内存中。内存的读写速度远快于磁盘,这极大地提升了数据的访问速度。
- Redis内部使用了多种高效的数据结构,如String、list、hash、set、zset,支持快速的查找、插入和删除操作。
- 采用单线程可以避免多线程之间的竞争,省去了多线程切换带来的时间和性能上的、锁竞争的开销,不会导致死锁问题。
- Redis采用I/O多路复用机制处理大量客户端的Socket请求,一个线程处理多个I/O流。Redis只运行单线程的情况下,内核会一直监听这些socket上的连接请求和数据请求,一旦请求到达,就会交给Redis线程处理,就实现了一个Redis线程处理多个IO流的效果。
2.分布式锁
分布式锁(多个进程(服务)之间的互斥访问):在分布式系统中,当多个进程或服务需要访问共享资源时,保证同一时间只有一个进程或服务可以访问该资源,以避免并发问题。
多个用户访问不同的服务时,访问同一个数据库,但是用户在不同的进程里,数据库在内存中无法共享,可以把令牌放到线程所共享的Redis中,每个进程执行时都去Redis中检查令牌是否存在,如果有则难道令牌去执行代码,如果没有则等待其他进程将其删除。令牌在无锁,不在有锁。
3. 缓存
3.1缓存击穿
如果缓存中的某个热点数据过期(被频繁访问的的数据,例如秒杀活动)了,此时大量的请求访问该热点数据就无法从缓存中读取,直接访问数据库,数据库很容易就被高并发的请求冲垮,这就是缓存击穿问题。
解决方案:
- 置不给热点数据设过期时间,由后台异步更新缓存,或者在热点数据准备要过期前 提前通知后台线程更新缓存以及重新设置过期时间。
- 分布式锁,保证同一时间只有一个业务线程请求缓存,未能获取分布式锁的,等待锁释放后读取缓存。但是这种的话有一个弊端,那就是获取分布式锁的请求,都会执行一遍查询数据库,并更新到缓存。理论上只有第一个加载数据库记录请求是有效的针对这个问题,可以通过双重判定锁的形式,在获取到分布式锁之后再次查询一次缓存是否存在。如果缓存中存在数据,就直接返回;如果不存在,才继续执行查询数据库的操作。这样就可以避免大量请求访问数库。双重判定锁有效提升了锁性能以及数据库访问。
一个用户线程去Redis缓存查看代码数据,而Redis缓存中的数据已过期,就会去数据库中查找,在查询数据库之前,使用分布式锁来确保只有一个线程或进程去数据库查询,查到后将数据放到Redis中,由于数据已经被更新,所以能够直接从Redis中获取数据,无需再访问数据库。当并发数太多,每台用户都要完成以上步骤很繁杂,可以加入分布式锁,第一个用户将数据放到Redis中,所有的线程共享此Redis,以后的用户线程只需要再次查看Redis就可以了,无需再次访问数据库。(存在两次查看Reids,double check,第一次是第一个用户查看Redis缓存中是否有数据,若没有则存数据库中加载数据放到Redis,第二次查看是加入分布式锁后,以后的用户去Redis中拿令牌查看数据,无需访问数据库)确保缓存的一致性和减少数据库的压力。
3.2缓存穿透
当用户访问的数据,既不在缓存中,也不在数据库中,导致请求在访问缓存时,发现缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据,没办法构建缓存数据,来服务后续的请求。那么当有大量这样的请求到来时,数据库的压力骤增,这就是缓存穿透的问题。
缓存穿透的发生有两种情况:
- 业务误操作,缓存中的数据和数据库中的数据都被误删除了,所以导致缓存和数据库中都没有数据;
- 黑客恶意攻击,故意大量访问某些读取不存在数据的业务。
解决方案
1.空对象值缓存:当查询结果为空时,也将结果进行缓存,但是设置一个较短的过期时间。这样在接下来的一段时间内,如果再次请求相同的数据,就可以直接从缓存中获取,而不是再次访问数据库,可以一定程度上解决缓存穿透问题。
这种方式是比较简单的一种实现方案,会存在一些弊端。那就是当短时间内存在大量恶意请求,缓存系统会存在大量的内存占用。如果要解决这种海量恶意请求带来的内存占用问题,需要搭配一套风控系统,对用户请求缓存不存在数据进行统计,进而封禁用户。整体设计就较为复杂,不推荐使用。
2.使用锁:当请求发现缓存不存在时,可以使用锁机制来避免多个相同的请求同时访问数据库,只让一个请求去加载数据,其他请求等待。
这种方式可以解决数据库压力过大问题,如果会出现“误杀“现象,那就是如果缓存中不存在但是数据库存在这种情况,也会等待获取锁,用户等待时间过长,不推荐使用。
3.布隆过滤器:布隆过滤器是一种数据结构,可以用于判断一个元素是否存在于一个集合中。它可以在很大程度上减轻缓存穿透问题,因为它可以快速判断一个数据是否可能存在于缓存中。
这种方式较为推荐,可以将所有存量数据全部放入布隆过滤器,然后如果缓存中不存在数据,紧接着判断布隆过滤器是否存在,如果存在访问数据库请求数据,如果不存在直接返回错误响应即可。但是这种问题还是会有一些小概率问题,那就是如果使用一种小概率误判的缓存进行攻击,依然会对数据库造成比较大的压力。
4.组合方案:如果说缓存不存在,那么就通过布隆过滤器进行初步选,然后判断是否存在缓存空值,如果存在直接返回失败。
如果不存在缓存空值,使用锁机制避免多个相同请求同时访问数据库。最后,如果请求数据库为空,那么将为空的 Key 进行空对象值缓存。
请求布隆过滤器和缓存空值判断会向 Redis 发起两次网络 I0,如果想优化的话,可以使用管道或者 Lua 命令来提高性能。
3.数据结构
Redis提供了丰富的数据类型,常见的有五种
键值对中的Key就是字符串对象,而value可以是字符串对象,也可以是集合数据类型的对象
3.1String(字符串)
key-value结构,key是唯一标识,value是具体的值
基本命令:
set key value 设置值;get key获取值;incr key递增1;
适用场景:
1.全局ID/分布式ID:在一个系统中,无论数据分布在哪个节点或数据库,生成的ID都是全局唯一的;功能与全局ID相同,但是是在分布式系统中。例如全局计数器。
例如分表,一张数据表中存储的数据量太大,会导致B+树的高度增加导致查询效率变低,可以采用水平分表的方式将一个表分成两个,两个新表数据都来自同一个表但是会有两份新的自增ID,导致不能通过ID来唯一标识,这时可以将表数据存储到Redis中,借助String类型的incr让ID key每次自增1,形成唯一标识的自增ID列表。
- 当需要插入新数据时,先向Redis发送
INCR
命令,获取一个新的全局唯一ID。- 使用这个全局唯一ID作为新数据的主键或唯一标识。
- 将新数据插入到相应的分表中。
2.缓存:将数据库中的热点数据存储到Redis的String类型中,极大的提高系统的访问速度,减少数据库的访问压力;用户的会话信息(登录状态、购物车内容等)可以存储在Redis的中,以便请求共享。
3.常规计数
4.分布式锁
内部实现
底层的数据结构实现是SDS简单动态字符串和int
len,记录了字符串的长度(buf中已占用空间的长度),获取字符型串长度的时候只需返回这个成员变量的值就行。
free,buff中剩余可用空间的长度。通过剩余空间的长度可以知道空间占用情况,当超过负载值动态扩容。也可通过修改len与free实现缩容。
- 扩容:当字符串长度小于1M时,扩容都是加倍修改后的空间,如果超过1M,扩容时一次只会多扩1M。
- 缩容:不会释放,修改len与free,供下次使用。
buf[],数据空间。
好处:返回字符串长度的时间复杂度低O(1);二进制安全,用len属性记录字符串的长度可以包含‘\0’,数据写入是什么样,它被读取时就是什么样;自动扩容不会发生缓冲区溢出
3.2List(列表)
List 类型的底层数据结构是由双向链表或压缩列表实现的:
- 如果列表的元素个数小于 512 个(默认值,可由 list-max-ziplist-entries 配置),列表每个元素的值都小于 64 字节(默认值,可由 list-max-ziplist-value 配置),Redis 会使用压缩列表作为 List 类型的底层数据结构;
- 如果列表的元素不满足上面的条件,Redis 会使用双向链表作为List 类型的底层数据结构;
但是在 Redis 3.2 版本之后,List 数据类型底层数据结构就只由quicklist 实现了,替代了双向链表和压缩列表。
常见命令
ZipLIist
结构
是Redis为节约内存而开发的,是由连续内存块组成的顺序型数据结构,类似数组。
表头:
- zlbytes,记录整个压缩列表占用内存字节数;
- zltail,记录压缩列表尾部节点距离起始地址有多少字节
- zlen,记录压缩列表包含的节点数量
entry压缩列表节点:
prevlen,记录 前一个节点 的长度,目的是实现从后向前遍历;
prevlen是如何根据数据的大小和类型进行不同空间大小的分配?
如果前一个节点的长度小于254字节,那么prevlen属性需要用1字节的空间来保存这个长度值
如果前一个节点的长度大于254字节,那么prevlen属性需要用5字节的空间来保存这个长度
encoding,记录当前节点的实际数据的类型和长度 ,类型主要有两种:字符串和整数;
encoding是如何根据数据的大小和类型进行不同空间大小的分配?
如果当前节点是整数,则encoding会使用1字节的空间进行编码,也就是encoding长度为1字节。
如果当前节点是字符串,根据字符串的大小,encoding会使用1字节/2字节/5字节的空间进行编码。
data,记录当前节点的实际数据;
zlend标记压缩列表的结束点
优缺点
- 优点:一种内存紧凑型的数据结构,占用一块连续的内存空间,可以利用cpu缓存,也可以针对不同长度的数据进行相应的编码,有效的节省内存开销。顺序存储,查询效率高。
- 缺点:不能保存过多的元素,否则查询效率会降低;新增或修改某个元素,压缩列表占用的内存空间需要重新分配,甚至可能引发连锁更新问题。
存在的问题
连锁更新(连续多次空间扩展)
压缩列表新增某个元素或删除某个元素时,如果空间不够,压缩列表占用的内存空间就需要重新分配(例如新插入一个254字节的,那prevlen应为5,如果原来是1,那么以后得每个entry都应+4)。而当新插入的元素较大时,可能会导致后续元素的(entry1)prevlen占用空间都发生变化,从而引起 连锁更新 问题(entry2的长度、entry3的长度...变大),导致每个元素的空间都要重新分配,造成访问压缩列表性能的下降。
quickList
Redis3.0之前,List对象的底层数据结构是ZipList。在Redis3.2,List对象的底层由quicklist数据结构实现。是双向链表+压缩链表的组合,因为quicklist是一个链表,而链表中的每个元素又是一个压缩列表。
解决连锁更新问题
通过控制每个链表节点(quickListNode)中的压缩列表的大小或者元素个数,来规避连锁更新的问题。(因为压缩列表元素越少或越小,连锁更新来的的影响就越小,从而提供了更好的访问性能)能缓解连锁更新问题,链表中某个节点变长不会影响后面的节点,但是遍历的效率低。
- 提供配置项list-max-ziplist-size来配置ZipList的大小(数值大小、内存空间大小)避免每个ZipList中的entry过多。
- 对节点的ZipList做压缩,通过配置项list-compress-depth来控制,这个参数控制的是首尾不压缩的节点个数。
quickList的结构
quickList节点的结构
quickList的节点里包含了前一个节点和下一个节点的指针,这样每个quicklistNode形成了一个双向链表,链表节点的元素保存的是一个压缩列表。
在向 quicklist 添加一个元素的时候,不会像普通的链表那样,直接新建一个链表节点。而是会检查插入位置的压缩列表是否能容纳该元素,如果能容纳就直接保存到 quicklistNode 结构里的压缩列表,如果不能容纳,才会新建一个新的quicklistNode 结构。
quicklist 会控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来规避潜在的连锁更新的风,险,但是这并没有完全解决连锁更新的问题。
3.3Set(集合)
Set 类型的底层数据结构是由哈希表或整数集合实现的:
- 如果集合中的元素都是整数且元素个数小于 512(默认值,set.maxintset-entries配置)个,Redis 会使用整数集合作为 Set类型的底层数据结构;
- 如果集合中的元素不满足上面条件,则 Redis 使用哈希表作为Set 类型的底层数据结构。
适用场景:
- 需要存储不重复的元素集合,如用户ID、商品ID等。
- 可以实现社交应用中的共同好友、关注列表等功能。
- 支持集合间的交集、并集、差集等操作,适用于需要这些操作的场景。
常用命令:
- SADD:向集合添加一个或多个成员,如果成员已存在则忽略。
- SMEMBERS:返回集合中的所有成员。
- SREM:移除集合中一个或多个成员。
整数集合
可以看到,保存元素的容器是一个 contents 数组,虽然 contents被声明为 int8_t类型的数组,但是实际上 contents 数组并不保存任何 int8 t类型的元素,contents 数组的真正类型取决于 intset 结
构体里的 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;
不同类型的 contents 数组,意味着数组的大小也会不同 。
3.4ZSet(有序集合)
Zset 类型的底层数据结构是由压缩列表或跳表实现的:
- 如果有序集合的元素个数小于 128 个,并且每个元素的值小于64 字节时, Redis 会使用压缩列表作为 Zset 类型的底层数据结构;
- 如果有序集合的元素不满足上面的条件,Redis 会使用跳表作为Zset 类型的底层数据结构;
适用场景:
- 需要存储不重复的元素,并且元素之间需要保持顺序,如排行榜、用户积分榜等。
- 支持范围查询和按分值范围的操作,非常适合处理需要排序的数据。
常用命令:
- ZADD:向有序集合添加一个或多个成员,或者更新已存在成员的分数。
- ZREM:移除有序集合中的一个或多个成员。
- ZSCORE:返回有序集合中指定成员的分数。
内部实现
跳表
链表在查找元素的时候,因为需要逐一查找,所以查询效率非常低,时间复杂度是O(N),于是就出现了跳表。跳表是在链表基础上改进过来的,实现了一种「多层」的有序链表,这样的好处是能快读定位数据。
跳表结构里包含了:
- 跳表的头尾节点,便于在O(1)时间复杂度内访问跳表的头节点和尾节点;
- 跳表的长度,便于在O(1)时间复杂度获取跳表节点的数量;
- 跳表的最大层数,便于在O(1)时间复杂度获取跳表中层高最大的那个节点的层数量;
每个跳表节点由对象的元素值、元素权重值、后向指针(指向前一个节点,目的是为了方便从跳表的尾结点开始访问节点,使倒序查找方便)、节点的level数组(保存每层上的前向指针和跨度(计算这个节点在跳表中的排位))
3.5Hash(哈希)
互斥锁(一个进程中多个线程之间的互斥访问)
Hash 类型的底层数据结构是由压缩列表或哈希表实现的:
- 如果哈希类型元素个数小于 512 个(默认值,可由 hash-max-ziplist-entries 配置),所有值小于 64 字节(默认值,可由hash-max-ziplist-value 配置)的话,Redis 会使用压缩列表作为Hash 类型的底层数据结构,
- 如果哈希类型元素不满足上面条件,Redis 会使用哈希表作为Hash 类型的底层数据结构。
适用场景:
- 存储对象信息,如用户信息(用户名、密码、邮箱等),每个字段独立存储,便于管理和更新。
- 适用于需要频繁读取/写入的数据结构,因为Hash类型在内存中的存储效率较高。
常用命令:
- HSET:设置哈希表field的值。
- HGET:获取哈希表中指定field的值。
- HDEL:删除哈希表中的一个或多个字段。
内部实现
哈希表的结构如下
dictEntry 结构里不仅包含指向键和值的指针,还包含了指向下一个哈希表节点的指针,这个指针可以将多个哈希值相同的键值对链接起来,以此来解决哈希冲突的问题,这就是链式哈希。
4.持久化
RDB
是Redis数据备份文件,默认是RDB快照,存储的是二进制数据,是异步且定时每隔一段时间将内存数据放入磁盘,当系统若宕机后内存中没有数据,重启后就能从磁盘中加载到内存,在内存做数据处理。Redis停机时会自动执行一次RDB快照。
redis -cli 连接RDB
bgsave 开启子线程执行RDB,避免主线程收到影响
saving started + 间隔时间
RDB方式bgsave(开启子线程将数据持久化到磁盘)的基本流程
- fork主进程得到一个子进程,共享内存空间
- 子进程读取内存数据并写入RDB文件
- 用新的RDB文件替换旧的RDB文件
缺点:
- RDB执行间隔时间长,两次RDB之间写入数据有丢失的风险
- fork子进程、压缩、写出RDB文件都比较耗时
AOF
追加文件,记录比RDB文件大。Redis处理的每一个写命令都会记录在AOF文件,可以看作是命令日志文件。
默认关闭,需要开启,需要修改Redis.conf配置文件来开启AOF,AOF的三种写回策略:
因为是记录命令,AOF文件会比RDB文件大得多。而且AOF会记录对同一个key的多次写操作,但只有最后一次写操作才有意义。通过执行bgrewriteaof命令,可以让AOF执行重写功能,用最少得命令达到相同的效果。
相比较:
两者结合使用。