写在前面
- 这里主要记录一下Redis的框架原理;
一、Redis概述
1. 定义
- 是一种基于内存的数据库;
- 对数据的读写操作都是在内存中完成的;
2. 特点
-
(1) 高性能:
- 直接在内存读写,速度非常快;
-
(2) 高并发:
- 采用I/O多路复用机制,可以用一个线程处理多个I/O流;
- Redis6.0后,采用多个I/O线程处理网络请求;
二、数据类型
- Redis支持的五种常用的value数据类型如下:
- String:字符串;
- Hash:哈希;
- List:列表;
- Set:集合;
- Zset:有序集合;
1. String
- 可以存放字符串或者数字(整数或者浮点数);
- 最大数据长度是
512M
;
1.1 内部实现
- 使用
int
和简单动态字符串SDS
;
-
有三种编码方式:
int
:保存可以用long
类型表示的整数;embstr
:保存小于等于32
字节的短字符串;raw
:保存大于32
字节的长字符串;
-
(1)
int
只需要保存redisObject
和整数即可:
- (2)
embstr
的redisObject
和SDS
空间是连续分配的:
- (3)
raw
的redisObject
和SDS
空间是分别分配的;
1.2 应用场景
-
(1) 缓存对象:
- 直接用
value
缓存整个对象的JSON; - 用
key = object.成员变量
和value
拆分对象内容进行缓存;
- 直接用
-
(2) 常规计数;
- 记录访问次数等;
-
(3) 共享Session信息;
- 让分布式系统中的所有服务器都去Redis中取用户的Session信息;
2. List
- 用于存放字符串数组;
- 列表的最大长度是
2^32-1
;
2.1 内部实现
-
在Redis3.2前:
- 元素个数少于
512
,且每个元素值小于64
字节,用压缩列表; - 否则,使用双向链表;
- 元素个数少于
-
在Redis3.2之后:
- 只使用快速列表;
下面介绍两种压缩列表和quicklist的数据结构;
-
(1) 压缩列表ziplist
- 使用连续内存空间保存链表数据;
- 只适合保存小规模数据;
- 因为每个节点都记录前一个节点的长度,所以会有连锁更新问题;
-
(2) 快速列表quicklist
- 是压缩列表和双向链表的混合体;
- 总体是双向链表,可以方便扩展元素;
- 每个链表节点都是一个压缩列表,进而可以节约空间;
2.2 应用场景
- (1) 消息队列
- 消息保序:使用
LPUSH + RPOP
或者RPUSH + LPOP
;- 但消费者只能忙查询,消息队列不会主动通知消费者有消息到来;
- 可以用
BRPOP
或者BLPOP
做阻塞查询; - 一直阻塞到有消息到来,避免忙查询;
- 重复消息处理:生产者自行实现全局唯一 ID;
- 消息的可靠性:使用
BRPOPLPUSH
;- 在取消息的同时,将消息插入到另一个List中留存;
- 可以避免消息未正常处理而丢失消息;
- 但不能让多个消费者消费同一条消息;
- 消息保序:使用
3. Hash
3.1 内部实现
- 在Redis7.0前:
- 元素个数少于
512
,且每个元素值小于64
字节,用压缩列表; - 否则,使用哈希表;
- 元素个数少于
- 在Redis7.0之后:
- 只使用listpack;
下面介绍哈希表和listpack;
- (1) 哈希表
- 使用的是开链法;
dictEntry
是桶数组,后面接的是dictEntry
链表;- 使用渐进
rehash
方法对哈希数组扩容:- 仅当有哈希元素进行新增、删除、查找或者更新操作时,才将哈希元素迁移到扩容后的桶数组中;
- 这样可以避免阻塞Redis导致可用性降低;
- (2) listpack
- 类似于压缩列表,使用连续的内存空间保存元素信息;
- 但每个节点只记录当前元素的长度,可以避免连锁更新问题;
- 目的就是用于替代压缩列表的;
3.2 应用场景
-
(1) 缓存对象:
- 按照
对象id-对象成员变量-value
的方式缓存对象;
- 按照
-
(2) 购物车:
- 按照
用户id-商品id-value
的方式缓存对象;
- 按照
4. Set
- 存放无序且非重复元素;
- 一个集合最多可以存储
2^32-1
个元素;
4.1 内部实现
- 如果集合中的元素都是整数且元素个数小于
512
,则使用整数集合; - 否则,使用哈希表;
下面介绍整数集合;
- (1) 整数集合
- 本质上是一块连续的内存区域,由数组实现;
- 可以保存
16
、32
和64
位的整数; - 支持升级,也就是从保存短整数升级到支持长整数;
- 扩容的时候先附加所需空间,然后从后向前拷贝已有元素到扩容后的位置;
- 这样可以避免使用额外的空间和覆盖掉原有的数据;
4.2 应用场景
-
(1) 点赞:
- 一个用户只能点一个赞;
- 则某个文章的点赞用户可以用Set来保存;
-
(2) 共同关注:
- 可以用集合的交集运算获取两个集合之间的交集;
5. Zset
- 存放有序且非重复元素;
5.1 内部实现
- 在Redis7.0之前:
- 如果有序集合的元素个数小于
128
个,且每个元素的值小于64
字节,则使用压缩列表; - 否则,使用跳表;
- 如果有序集合的元素个数小于
- 在Redis7.0后:
- 只使用listpack;
下面介绍跳表;
- (1) 跳表
- 相当于是一个多层双向链表;
- 每一层链表的跨度(搜索粒度)都不一样,这样可以加速查找的过程;
- 查找时先从最高层开始查找;
- 最高层查找不到再往下一层查找,也就是往细粒度查找;
- 决定每个节点的层数策略:
- 随机生成每个节点的层数;
- 如果
rand < 0.25
,则为当前节点增加一层,直到rand >= 0.25
为止; - 最大层数一般设置为
32
层或者64
层;
- 和平衡树相比的优势:
- 内存使用更少,每个节点平均只需要
1/(1-p)
个指针,p=0.25
; - 做范围查找时,操作更简单,只需要顺序访问第0层节点即可;
- 实现也更加简单;
- 内存使用更少,每个节点平均只需要
- 也可参考:
5.2 应用场景
-
(1) 排行榜:
- 可以根据权重进行排序;
-
(2) 电话或者姓名排序;
6. 总结
- 常用的五种数据类型和它们对应的底层结构如下:
三、线程模型
1. 单线程模型
- 其实就是单线程多路复用模型;
- 根据不同的事件调用不同的事件处理函数;
2. 其他线程
- 除了执行命令的Redis主线程外,还有一些其他的线程负责其他功能:
- 三个后台线程:
- (1)
bio_close_file
:异步关闭文件; - (2)
bio_aof_fsync
:AOF重写任务; - (3)
bio_lazy_free
:释放内存任务; - 以及三个I/O线程,负责处理网络I/O;
四、持久化
- 目的:
- 保证数据在Redis重启后不丢失;
1. AOF日志
- AOF日志文件中包含了能够完整重建Redis数据库的所有写命令;
- 逐一执行所有的写命令即可还原Redis的数据状态;
1.1 实现
- 在执行完一条写操作命令后,将该命令以追加的方式写入到AOF日志文件中;
- 先执行后写日志;
- 好处:
- 不会阻塞写操作执行;
- 避免写日志时的语法检查开销;
- 恢复时,读取AOF日志文件中的内容进行恢复;
1.2 三种写回策略
-
(1)
Always
:- 每次写操作命令执行完后,同步将AOF日志写入到硬盘中;
-
(2)
Everysec
:- 每次写操作命令执行完后,先将命令写入到AOF文件的内核缓冲区;
- 然后每隔一秒将缓冲区里的内容写回到硬盘;
-
(3)
No
:- 由内核决定何时将缓冲区中的内容写回到硬盘;
1.3 重写机制
- 作用:
- 降低AOF文件所占的空间;
- 主要是避免记录多个相同的key写入命令,仅保存最新的一条写入命令;
- 实现:
- 当AOF文件超过一定大小时,进行重写;
- 读取当前数据库中的所有键值对(这些键值对就对应最新的写入命令);
- 将每一个键值对用一条命令记录到新的AOF文件中;
- 最后用新的AOF文件替换旧的AOF文件;
2. RDB快照
- RDB快照记录某一个瞬间的内存数据,而非写操作命令;
- 恢复数据的效率比AOF文件高;
- 但可能会丢失较多数据,因为快照不能实时更新;
2.1 实现
- 记录快照时,直接
fork()
一个子进程; - 由于子进程和父进程共享内存数据,所以子进程为父进程进行后台的快照记录;
- 由于父子进程之间的内存使用的是写时复制技术,所以父进程仍然可以正常更新内存数据,不影响子进程记录快照的过程执行;
3. 混合持久化
3.1 实现
- 在重写AOF日志时,先直接用RDB快照格式记录主要部分的数据;
- 再将重写缓冲区中的增量数据按照AOF日志格式写入到文件中;
- 这样AOF日志文件就分为两部分的内容:
- 第一部分是RDB格式的内容;
- 保存了主要的数据;
- 加载速度快;
- 第二部分是AOF格式的内容;
- 保存了重写时的增量数据;
- 加载速度慢,但可以保持数据的时效性;
- 第一部分是RDB格式的内容;
- 之后的AOF日志记录可以跟着第二部分继续添加;
- 混合的AOF日志文件结构如下:
五、高可用
- Redis主要是通过以下方式实现高可用:
- 主从复制;
- 哨兵模式;
- 切片集群;
1. 主从复制
- 特点:
- 一主多从;
- 主从复制;
- 读写分离:
- 从服务器是响应客户端的读请求,主服务器是响应客户端的读写请求;
2. 哨兵模式
- 监控主从服务器;
- 提供主从节点故障转移功能;
3. 切片集群
- 当Redis中缓存的数据在一台服务器上无法缓存时,就需要使用切片集群方案;
- 即将缓存的数据分布到多台服务器上,以此降低系统对单台服务器的依赖;
- 具体是通过求模运算实现不同
key
的分配的;
六、键值过期删除
- 过期删除策略是指:
- 为
key
设置过期时间; - 在过期时间之后,删除过期的键值;
- 通常是采用惰性删除+定期删除的策略;
- 为
1. 惰性删除
-
实现:
- 不主动删除过期的
key
; - 等到访问该
key
时再验证是否过期; - 如果过期则删除该
key
值;
- 不主动删除过期的
-
优缺点:
- 对CPU占用少,因为不需要专门进行过期
key
的扫描; - 对内存占用大,因为有些过期的
key
不能定期清理;
- 对CPU占用少,因为不需要专门进行过期
2. 定期删除
-
实现:
- (1) 随机选
20
个key
,检查是否已过期; - (2) 如果检查已过期的
key
的数量占比超过25%
,则重复步骤(1),否则完成本次删除; - 定期删除的最大时间不超过
25ms
;
- (1) 随机选
-
优缺点:
- 对CPU占用大;
- 对内存占用小;
- 但定期删除的时间间隔要合理设置,以取得平衡;
七、缓存
- 这里的缓存其实就是Redis数据库,因为它充当了MySQL的中间缓存;
1. 缓存引发的问题
1.1 缓存雪崩
- 原因:
- 大量缓存数据在同一时间过期或者Redis故障宕机;
- 大量用户请求无法通过Redis响应,必须访问数据库;
- 进而导致数据库承压过大而崩溃;
- 解决方法:
- 针对大量缓存同时过期:
- (1) 均匀设置键值的过期时间;
- (2) 设置互斥锁,让同一时间只有一个请求访问数据库来构建未击中的缓存;
- (3) 后台更新缓存,不设置键值的过期时间;
- 针对故障宕机:
- (1) 服务熔断或者请求限流机制,如果Redis无法访问则直接返回错误,不访问数据库;
- (2) 构建Redis集群,避免宕机;
- 针对大量缓存同时过期:
1.2 缓存击穿
-
原因:
- 某个热点数据过期,导致大量请求直接访问数据;
- 进而冲垮数据库;
-
解决方法:
- 和缓存雪崩的解决方法类似:
- (1) 设置互斥锁,让同一时间只有一个请求访问数据库来构建未击中的缓存;
- (2) 后台更新缓存,不设置热点数据键值的过期时间;
- 和缓存雪崩的解决方法类似:
1.3 缓存穿透
-
原因:
- 用户访问的数据既不在Redis缓存中,也不在数据库中;
- 所以每次请求都需要访问数据库进行验证,进而冲垮数据库;
-
情景:
- 业务误操作,导致数据被删除;
- 黑客恶意攻击;
-
解决方法:
- (1) 限制非法请求;
- (2) 在Redis中缓存空值,这样只需要访问数据库一次;
- (3) 使用布隆过滤器记录数据不存在,这样也只需要访问数据库一次;
- 布隆过滤器是通过哈希值记录是否存在的;
- 如果不存在,则真的不存在;
- 如果存在,则可能不存在,因为会有多个哈希key映射到同一个值上;
1.4 总结
2. 缓存一致性
- 使用Redis的前提是保证Redis始终是数据库的实时缓存;
- 一旦出现Redis和数据库数据不一致的情况,则Redis的使用是没有意义的;
- 所以考虑缓存一致性是重中之重;
2.1 更新数据库时更新缓存
- (1)
先更新数据库再更新缓存- 如果有两个并发的更新,会导致缓存和数据库不一致;
- 不推荐使用;
- (2)
先更新缓存再更新数据库- 更不推荐,因为连数据库内的记录都无法保证是最新;
2.2 更新数据库时删除缓存
- 即旁路缓存策略(Cache Aside);
- (1) 先更新数据库再删除缓存
- 推荐使用;
- 虽然也会导致缓存不一致,但发生的概率比先更新数据库再更新缓存小得多;
- (2)
先删除缓存再更新数据库- 因为更新数据库的速度远比更新缓存的速度慢,所以出现缓存不一致的概率很大;
- 不推荐用这种方式;
2.3 保证删除缓存成功
- 更新数据库时,如果删除缓存不成功,则缓存将一直不一致;
- 解决方法:
- (1) 设置键值定期删除;
- (2) 增加消息队列保证缓存能够成功删除,如果删除失败则不断重试;
八、分布式锁实现
1. 实现原理
-
加锁:
- 向多个Redis服务器插入键值;
- 如果
key
不存在则插入,否则则插入失败; - 如果半数以上的服务器均插入成功且未超时,则加锁成功;
- 如果加锁失败,还需要向各个Redis节点解锁;
-
解锁:
- 用
value
唯一标识加锁的客户端; - 只有加锁的客户端才能进行解锁;
- 同时也应该为
key
设置过期时间,避免锁一直无法释放;
- 用