敖丙思维导图系列目录 这些知识整理都是自己查阅帅丙资料(本文还参考了《Redis深度历险:核心原理和应用实践》这本书)加以总结滴~ 每周都会更新知识进去。
如有不全或错误还请大家在评论中指出~
- 敖丙思维导图-集合
- 敖丙思维导图-多线程之synchronized\ThreadLocal\Lock\Volatitle\线程池
- 敖丙思维导图-JVM知识整理
- 敖丙思维导图-Spring
- 敖丙思维导图-Redis
- 敖丙思维导图-RocketMQ+Zookeeper
- 敖丙思维导图-Mysql数据库
- 敖丙思维导图-网络基础
- 敖丙思维导图-Dubbo
本文章目录
- 敖丙思维导图系列目录
-
- Redis快速的原因
-
- 非阻塞I/O
- 多路复用(事件轮询)
- Redis数据结构底层实现
-
- 1. `String` (动态字符串sds(Simple Dynamic String, 简单动态字符串)代替c字符串)
- 2. 字典`Hash` ("数组 + 链表" 的链地址法来解决部分 哈希冲突)
- 3. 双向链表 `List` (相当于LinkedList链表,`栈和队列`都能实现)
-
- 异步消息队列
- 4. 集合 `Set` (相当于HashSet,集合的元素具有唯一性,无序性)
- 5. 有序列表`Zset` ( SortedSet + HashMap,每个唯一 value 赋予一 score 值,用来代表排序的权重 )
-
- 跳跃列表
- 整数集合(只包含整数值元素,并且这个集合的元素数量不多)
- Zset分数相同的时候自定义排序规则
- 应用
-
- 延时队列
- 简单限流处理
- `HyperLogLog`(有误差的去重基数统计UV)
- `GEO`
- 其它
-
- 底层字典结构
-
- rehash(重新散列)
- Redis 自己设计的数据存储结构
-
- 压缩列表
- Pub/Sub (消息队列-用于查看订阅与发布系统状态)
- BitMap (`set`上的扩展。位图,其实也就是普通的字符串【 byte 数组】,用二进制表示,只有 0 和 1 两个数字。)用户统计每日用户的登录数/几天的活跃数。
- redis pipeline管道(非原子性)
- 事务(批处理原子性指令,不会被打断)
- Redis Lua 脚本
-
- 优点
- 常用命令
- Redis数据备份
-
- 1. 半持久化`RDB`模式
- 2. 全持久化`AOF`模式
-
- AOF rewrite - 重复的命令给去掉
- RDB-AOF混合持久化
- Redis的同步机制
-
- Redis主从同步
-
- 快照同步(初始化同步)
- 增量同步 (数据修改同步)
- 集群 1:哨兵(Sentinel)模式
- 集群 2:Codis(中心化,国内团队维护)
- 集群 3:Redis Cluster(去中心化)
-
- 为什么Redis集群有16384个槽
- Redis 常见问题解决
-
- 缓存雪崩
- 缓存击穿
- 缓存穿透
- 双写一致性(先更新数据库,再删缓存)
-
- 第二次删除,如果删除失败怎么办?
- 并发竞争
- 大Key
- 热点Key
- 过期策略
-
- **淘汰机制-LRU淘汰算法**(最近最久未使用)
- Redis 实现限流
- 跳跃表
- 使用Redis的时候需要注意的点
-
- 拒绝bigkey的出现
- 拓展
-
- RESP 文本序列化协议
- 内存回收机制
- cpu层面的缓存管理机制
文章图片
Redis快速的原因
- 纯内存操作。
- 单线程操作,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗CPU,不用去考虑各种锁的问题。
Redis在处理客户端的请求时,包括获取 (socket 读)、解析、执行、内容返回 (socket 写) 等都由一个顺序串行的主线程处理,这就是所谓的“单线程”。但如果严格来讲从Redis4.0之后并不是单线程,除了主线程外,它也有后台线程在处理一些较为缓慢的操作,例如清理脏数据、无用连接的释放、大 key 的删除等等。
使用Redis时,几乎不存在CPU成为瓶颈的情况, Redis主要受限于内存和网络。6.0版本带来了多线程特性,因为读写网络的read/write系统调用占用了Redis执行期间大部分CPU时间,瓶颈主要在于网络的 IO 消耗,。-》多线程任务可以分摊 Redis 同步 IO 读写负荷。
Redis的多线程部分只是用来处理网络数据的读写和协议解析,执行命令仍然是单线程顺序执行。
- 采用了非阻塞I/O多路复用机制。多路指的是多个socket连接,复用指的是复用一个线程。Redis使用epoll作为I/O多路复用技术的实现,再加上Redis自身的事件处理模型将epoll的read、write、close等都转换成事件,不在网络I/O上浪费过多的时间。
当有多个请求发送到服务端的时候,实际上会有一个文件事件处理器同时监听多个套接字,并且根据套接字目前执行的任务来关联不同的事件处理器。非阻塞I/O
事件处理器只需要将它们做绑定即可,io多路复用程序是会将所有产生的套接字都存入一个有序且同步的队列中,最后redis会有逐一地对这个队列中的元素进行处理。
epoll没有最大并发连接的限制,只管你“活跃”的连接 ,而跟连接总数无关。Epoll使用了“共享内存 ”,省去内存拷贝。
每个tcp socket创建时,os会为它分配读缓冲区、写缓冲区。
非阻塞I/O在套接字上提供了一个选项
Non_Blockiing
,这个选项打开后,读写方法不会阻塞。能读多少取决于内核为套接字分配的读缓冲区内部的数据字节数,能写多少取决于内核为套接字分配的写缓冲区空闲空间字节数。多路复用(事件轮询)
- 阻塞式IO (处理一个socket就要占用一个线程)
让出CPU,进到等待队列,等socket就绪后再次获得时间片继续执行。- 非阻塞式IO
不让出CPU,频繁检查socket就绪状态(忙等待,难把握轮询间隔,空耗CPU)- IO多路复用 (一次系统调用,监听多个socket)
操作系统提供支持,把需要等待的socket加入到监听集合。
非阻塞I/O有个问题,线程要读数据,结果读了一部分就返回了,那么如何知道何时该继续呢?事件轮询解决。
事件轮询的API,就是Java中的NIO技术这里“多路”指的是多个网络连接,“复用”指的是复用同一个线程。采用多路 I/O 复用技术可以让单个线程高效的处理多个连接请求(尽量减少网络IO的时间消耗)。
UNIX操作系统提供了
select/poll/epoll
这样的系统调用
。你告知我一批套接字(socket),当这些套接字的可读或可写事件发生时,我通知你这些事件信息。多路 I/O 复用模型是利用select、poll、epoll可以同时监察
多个流的 I/O 事件的能力,在空闲的时候,会把当前线程阻塞
掉,当有一个或多个流有I/O事件时,就从阻塞态中唤醒,于是程序就会轮询
一遍所有的流(epoll是只轮询
那些真正发出了事件的流
),并且只依次顺序的处理就绪的流,这种做法就避免了大量的无用操作。程序注册一组socket文件描述符给操作系统,表示"我要监视这些fd是否有IO事件发生,有了就告诉程序处理"。
以前都是用select但是Redis数据结构底层实现 1.
- 16*64=1024 最多监听1024个fd
- 每次调用select都要传入所有监听集合,频繁的从用户态到内核态拷贝数据。
- 每次都要遍历所有集合,判断哪个fd是可操作的
epoll解决了这些问题
String
(动态字符串sds(Simple Dynamic String, 简单动态字符串)代替c字符串)String 缓存结构体用户信息,计数( value 是一个整数,还可以对它使用 INCR 命令进行
原子性
的自增操作)。Redis 为了对内存做极致的优化,采用预分配庸余空间减少内存的频繁分配。
SDS 与 C 字符串的区别:
- 获取字符串长度为 O(N) 级别的操作 → 因为 C 不保存数组的长度,每次都需要遍历一遍整个数组;
- 不能很好的杜绝 缓冲区溢出/内存泄漏 的问题 → 跟上述问题原因一样,如果执行拼接 or 缩短字符串的操作,如果操作不当就很容易造成上述问题;
- C 字符串 只能保存文本数据 → 因为 C 语言中的字符串必须符合某种编码
struct sdshdr {
// buf 中已占用空间的长度
int len;
// buf 中剩余可用空间的长度
int free;
// 字节数组
char buf[];
};
没有直接采用c语言自带的字符串,好处有以下几点:2. 字典
减少原先繁琐的内存扩增问题。(会根据初始化的值,提前给出更多的空间,避免出现空间溢出问题)
通过空间预分配机制来减少内存重分配问题。
Hash
(“数组 + 链表” 的链地址法来解决部分 哈希冲突)保存结构体信息可部分获取不用序列化所有字段。
实际上
字典结构的内部包含两个 hashtable
,通常情况下只有一个 hashtable 是有值的,但是在字典扩容缩容时,需要分配新的 hashtable,然后进行 渐进式 rehash
3. 双向链表
List
(相当于LinkedList链表,栈和队列
都能实现)twitter的关注列表,粉丝列表等都可以用Redis的list结构来实现(支持反向查找和遍历)
LPUSH 和 RPUSH
分别可以向 list 的左边(头部)和右边(尾部)添加一个新元素;LRANGE
命令可以从 list 中取出一定范围的元素;LPOP
命令可以从移出并获取列表的第一个元素
如果线程一直阻塞在哪里,Redis 的客户端连接就成了闲置连接,闲置过久,服务器一般4. 集合
会主动断开连接,减少闲置资源占用。这个时候 blpop/brpop 会抛出异常来。
Set
(相当于HashSet,集合的元素具有唯一性,无序性)去重的场景,交集(sinter)、并集(sunion)、差集(sdiff),实现如共同关注、共同喜好。
它的内部实现相当于一个特殊的字典,字典中所有的 value 都是一个值 NULL。
sadd key member 添加一个 string 元素到 key 对应 set 集合中,成功返回 1,如果元素以及 在集合中则返回 05. 有序列表
Zset
( SortedSet + HashMap,每个唯一 value 赋予一 score 值,用来代表排序的权重 )可用来实现延时队列、排行榜。内部使用HashMap和跳跃表(SkipList)来保证数据的存储和有序,HashMap里放的是成员到score的映射,而跳跃表里存放的是所有的成员,排序依据是HashMap里存的score,使用跳跃表的结构可以获得比
较高的查找效率
,并且在实现上比较简单。当zsort的score相同的情况下,redis是以key的字典序进行排名的。跳跃列表 最下面一层所有的元素都会串起来。然后每隔几个元素挑选出一个代表来,再将这几个代表使用另外一级指针串起来。「跳跃列表」之所以「跳跃」,是因为内部的元素可能「身兼数职」
一级以外的维度不变的情况下可以直接用 key 排序,比较简单。
如果维度会更新,可以使用拆分二进制或十进制的方法存储,二进制的优点是存储的数比较大,而且可以用位运算。
十进制的优点是计算简单,可读性比较好。各个维度的长度还可以做成配置项,这样就可以满足不同的业务需求了。
文章图片
跳跃列表采取一个随机策略来决定新元素可以兼职到第几层。首先 L0 层肯定是 100% 了,L1 层只有 50% 的概率,L2 层只有 25% 的概率,L3 层只有 12.5% 的概率,一直随机到最顶层 L31 层。绝大多数元素都过不了几层。(很公平)整数集合(只包含整数值元素,并且这个集合的元素数量不多)
当一个集合(Set)只包含整数值元素,并且这个集合的元素数量不多时, Redis i就会使用整数集合作为集合键的底层实现。整数集合升级过程
- 根据新元素的类型,扩展整数集合底层数组的空间大小,并为新元素分配空间。
- 将底层数组现有的所有元素都转换成与新元素相同的类型,并将类型转换后的元素放置到正确的位上,而且在放置元素的过程中,3需要继续维持底层数组的有序性质不变。
- 将新元素添加到底层数组里面。
-
提升灵活性
(因为C语言是静态类型语言,为了避免类型错误,我们通常不会将两种不同类型的值放在同一个数据结构里面。)
- 节约内存
- 整数集合是Redis自己设计的一种存储结构,集合键的底层实现之一。
- 整数集合的底层实现为数组,这个数组以有序、无重复的方式保存集合元素,在有需要时,程序会根据新添加元素的类型,改变这个数组的类型。
- 升级操作为整数集合带来了操作上的灵活性,并且尽可能地节约了内存。
- 整数集合
只支持升级操作,不支持降级操作
Redis 默认实现是,相同分数的成员按字典顺序排序(0 ~9 , A ~Z,a ~ z),所以相同价格排序就不能根据时间优先来排序。分数 = 价格 + 时间 (当前系统时间戳)
分数为64 位的长整型 int64_t, 价格作为高位存储, 时间作为低位存储,时间精度上面,精确到秒级别。
int64_t分数,二进制用高 32位存价格,低32位存储当时与某一个时刻的时间差(秒),那么数据看起是这样
这里有一个最大时间 MAX_TIME = 2208960000(2040年1月1日)(服务超过这个时间无效)
A 玩家,(10 * 价格偏移) + MAX_TIME - 11111111111111( 时间戳)
B 玩家,(10 * 等级偏移) + MAX_TIME - 1111122222( 时间戳)
最终分数A > B ,
最终排序,A 玩家会排到B前面。通过分数可以解析出真实价格和时间
距离当前时间的毫秒值之差作为小数部分,得分(整数)作为整数部分,存入缓存;从缓存取出得分时截取整数部分即为真正得分。
应用 延时队列 延时队列可以通过 Redis 的 zset(有序列表) 来实现。我们将消息序列化成一个字符串作为 zset 的 value,这个消息的到期处理时间作为 score,然后用多个线程(保障可用性)轮询 zset ,zrem获取到期的任务进行处理。
简单限流处理 用一个zset结构记录用户的行为历史,每一个行为都作为zset中的一个key保存下来。同一用户的同一行为用一个zset记录。每一行为到来时,都维护一次时间窗口。将时间窗口外的记录全部清理掉,只保留窗口内的记录。这里的操作都是对同一个key的,使用pipline能提升效率。
也可以使用redis-cell自带的漏斗限流工具,它提供了原子的限流指令。
例如限制:用户sam查询行为,每60s最多30次(流水速率)
HyperLogLog
(有误差的去重基数统计UV)用于去重的基数统计(有误差),在输入元素的数量或者体积非常
非常大
时,计算基数所需的空间总是固定的、并且是很小的。(稀疏矩阵-》稠密矩阵)操作:
pfadd、pfcount、pfmerge
GEO
支持存储地理位置信息用来实现诸如附近位置、摇一摇这类依赖于地理位置信息的功能.geo的数据类型为zset. (删除用zrem)
其它
底层字典结构 Redis数据库就是使用字典来作为底层实现(字典还是哈希键的底层实现之一),而字典使用哈希表作为底层实现,
一个哈希表
里面有多个哈希节点
,而每个哈希表节点就保存了字典中的一个键值对。哈希表节点中还包括:指向下一个哈希表节点,形成链表的struct dictEntry *next;
// Redis中的字典是由dict.h/dict结构表示
typedef struct dict{
//类型特定函数
dictType *type;
//私有数据
void *privdata;
//哈希表
dictht ht[2];
//rehash索引
//当rehash不在进行时,值为-1
int rehashidx;
}dict;
ht属性是一个包含了两个项的数组,数组中每个项都是一个dictht哈希表,一般情况下,
字典只使用ht[0]哈希表,而ht[1]哈希表只对ht[0]哈希表进行rehash时使用
。下图是完整版本的字典结构。其中:dict.h/dictht(字典所使用的哈希表)、dictEntry(哈希表节点)
文章图片
rehash(重新散列) 负载因子=哈希表保存的节点数量/哈希表的大小
load_factor=ht[0].used/ht[0].size
什么时候才会rehash呢渐进式 rehash
1)服务器目前没有执行的BGSAVE命令或者BGREWRUTEAOF命令,并且哈希表的负载因子大于等于1;
2)服务器目前正在执行BGSAVE命令或者BGREWRUTEAOF命令,并且哈希表的负载因子大于等于5;
大字典的扩容是比较耗时间的,需要重新申请新的数组,然后将旧字典所有链表中的元素重新挂接到新的数组下面,这是一个 O(n) 级别的操作,作为单线程的 Redis 很难承受这样耗时的过程,所以 Redis 使用 渐进式 rehash 小步搬迁。在 rehash 的同时,保留新旧两个 hash 结构。
查询时会同时查询两个 hash 结构,然后在后续的定时任务以及 hash 操作指令中,循序渐进的把旧字典的内容迁移到新字典中。
正常情况下,当 hash 表中 元素的个数等于第一维数组的长度时,就会开始扩容,扩容的新数组是
原数组大小的 2 倍
。不过如果 Redis 正在做 bgsave(持久化命令),为了减少内存也得过多分离,Redis 尽量不去扩容,但是如果 hash 表非常满了,达到了第一维数组长度的 5 倍了,这个时候就会 强制扩容
。- 为 ht[1] 分配空间, 让字典同时持有 ht[0] 和 ht[1] 两个哈希表。
- 在字典中维持一个索引计数器变量 rehashidx , 并将它的值设置为 0 , 表示 rehash 工作正式开始。
- 在 rehash 进行期间, 每次对字典执行添加、删除、查找或者更新操作时, 程序除了执行指定的操作以外, 还会顺带将 ht[0] 哈希表在 rehashidx 索引上的所有键值对 rehash 到 ht[1] , 当 rehash 工作完成之后, 程序将 rehashidx 属性的值增一。
- 随着字典操作的不断执行, 最终在某个时间点上, ht[0] 的所有键值对都会被 rehash 至 ht[1] , 这时程序将 rehashidx 属性的值设为 -1 , 表示 rehash 操作已完成。
在rehash进行期间,每次对字典执行添加、删除、查找或者更新操作时,程序除了执行指定操作以外,还会顺带将ht[0]哈希表在rehashidx索引上的所有键值对rehash到ht[1]中,当rehash工作完成之后,程序将rehashidx属性的值+1。Redis 自己设计的数据存储结构 压缩列表 数组的优势占用一片连续的空间可以很好的利用CPU缓存访问数据。如果我们想要保留这种优势,又想节省存储空间(存储小于 20 个字节长度的字符串的时候,便会浪费部分存储空间。)可以对
数组进行压缩
。但遍历它的时候由于不知道每个元素的大小
是多少,因此也就无法计算出下一个节点的具体位置。这个时候我们可以给每个节点增加一个length
的属性。压缩列表是Redis为了节约内存而开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结枃。
当一个列表(list)只包含少量列表项,并且每个列表项要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做列表的底层实现。
当一个哈希(hash)只包含少量键值对,比且每个键值对的键和值要么就是小整数值,要么就是长度比较短的字符串,那么Redis就会使用压缩列表来做哈希的底层实现。
- 压缩列表是Redis为节约内存自己设计的一种顺序型数据结构。
- 压缩列表被用作
列表键和哈希键的
底层实现之一。 - 压缩列表可以包含多个节点,每个节点可以保存一个字节数组或者整数值。
- 添加新节点到压缩列表,或者从压缩列表中删除节点,可能会引发连锁更新操作,但这种操作出现的几率并不高。
如果 Redis 停机重启,PubSub 的消息是不会持久化的,毕竟 Redis 宕机就相当于一个消费者都没有,所有的消息直接被丢弃。(消费者挂掉后重启无法再接到旧消息)BitMap (
set
上的扩展。位图,其实也就是普通的字符串【 byte 数组】,用二进制表示,只有 0 和 1 两个数字。)用户统计每日用户的登录数/几天的活跃数。 Redis BitMap 的底层数据结构实际上是 String 类型,Redis 对于 String 类型有最大值限制不得超过 512M,即 2^32 次方 byte。(所以用户ID不能太大哦)- bitops 位图查找指令(字节索引,指定的位范围是8的整数)
- bitcount 位图统计指令
使用时间作为cacheKey,然后用户ID为offset,如果当日活跃过就设置为1。BITOP 命令支持 AND 、 OR 、 NOT 、 XOR 这四种操作中的任意一种参数
因为bit的值为 0或1,用户是否登录也可以用 0或1 来表示
setbit userlogin:20200618 101 1
我们把每天的用户登录信息记录到一个key中,值中的每个offset的值就是用户登录的标识
再调用bitcount userlogin:20200618
统计6.18且6.19活跃的用户数redis pipeline管道(非原子性) pipeline是非原子性的,它把一组命令打包,然后一次发送过去。管道中指令越多,效果越好。
setbit userlogin:20200619 101 1
BITOP AND test1 userlogin:20200618 userlogin:20200619
bitcount test1
1 次 pipeline(n条命令) = 1 次网络时间 + n 次命令时间
对于管道来说,连续的 write 操作根本没有耗时,之后第一个 read 操作会等待一个网络的来回开销,然后所有的响应消息就都已经回送到内核的读缓冲了,后续的 read 操作直接就可以从缓冲拿到结果,瞬间就返回了。事务(批处理原子性指令,不会被打断) redis事务就是一次性、顺序性、排他性的执行一个队列中的一系列命令。
- Redis中,单条命令是原子性执行的,但事务不保证原子性,且没有回滚。事务中任意命令执行失败,其余的命令仍会被执行。
WATCH XXX 查询记录版本号
... (查询剩余库存是否为0)
MULTI 开启事务
...
EXEC 提交事务,更新版本号
watch指令类似于乐观锁,在事务提交时,如果watch监控的多个KEY中任何KEY的值已经被其他客户端更改,则使用EXEC执行事务时,事务队列将不会被执行,同时返回Nullmulti-bulk应答以通知调用者事务执行失败。Redis Lua 脚本 在redis里面使用lua脚本主要用三个命令
- eval 用来直接执行lua脚本
- evalsha 根据缓存码执行脚本内容
- script load 把脚本加载到脚本缓存中,返回SHA1校验和。但不会立马执行
- 减少网络开销:多个请求通过脚本一次发送,减少网络延迟
- 原子操作:将脚本作为一个整体执行,中间不会插入其他命令,无需使用事务
- 复用:客户端发送的脚本永久存在redis中,其他客户端可以复用脚本
- 可嵌入性:可嵌入JAVA,C#等多种编程语言,支持不同操作系统跨平台交互
- Keys (批量删除,不能用在生产的环境中)
使用SCAN
命令替代。
- 复杂度虽然也是 O(n),通过游标分步进行不会阻塞线程;
- 有限制参数 COUNT ;
- 同 keys命令 一样提供模式匹配功能;
- 服务器不需要为游标保存状态,游标的唯一状态就是 scan 返回给客户端的游标整数;
SCAN cursor(游标) [MATCH pattern](要匹配的正则) [COUNT count](单次遍历的槽位)
Getset
命令用于设置指定 key 的值,并返回 key 的旧值。setnx
((SET if Not eXists) 在指定的 key 不存在时,为 key 设置指定的值。)
双重防死锁,使用setNx + getSet两个原子性方法
@Scheduled(cron="0 */1 * * * ?")
public void closeOrderTaskV3(){
long lockTimeout = Long.parseLong(PropertiesUtil.getProperty("lock.timeout","5000"));
Long setnxResult = RedisShardedPoolUtil.setnx(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK,String.valueOf(System.currentTimeMillis()+lockTimeout));
if(setnxResult != null && setnxResult.intValue() == 1){
closeOrder(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
}else{
//未获取到锁,继续判断,判断时间戳,看是否可以重置并获取到锁
String lockValueStr = RedisShardedPoolUtil.get(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
if(lockValueStr != null && System.currentTimeMillis() > Long.parseLong(lockValueStr)){
String getSetResult = RedisShardedPoolUtil.getSet(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK,String.valueOf(System.currentTimeMillis()+lockTimeout));
//返回给定的key的旧值,->旧值判断,是否可以获取锁
//当key没有旧值时,即key不存在时,返回nil ->获取锁
//这里我们set了一个新的value值,获取旧的值。
if(getSetResult == null || (getSetResult != null && StringUtils.equals(lockValueStr,getSetResult))){
//真正获取到锁
closeOrder(Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
}else{
log.info("没有获取到分布式锁:{}",Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
}
}else{
log.info("没有获取到分布式锁:{}",Const.REDIS_LOCK.CLOSE_ORDER_TASK_LOCK);
}
}
log.info("关闭订单定时任务结束");
}
- expire 用于设置 key 的过期时间,key 过期后将不再可用。
RDB
模式Redis备份默认方式,是通过快照完成的,当符合在Redis.conf配置文件中设置的条件时Redis会自动将内存中的所有数据进行快照并存储在硬盘上,完成数据备份。
- Redis使用
fork
函数复制一份当前进程(父进程)的副本(子进程),父进程继续接收并处理客户端发来的命令,而子进程开始将内存中的数据写入硬盘中的临时文件,当子进程写入完所有数据后会用该临时文件替换旧的RDB文件,至此一次快照操作完成。 - 执行fork的时操作系统会使用多进程写时复制(
copy-on-write
)策略,即fork函数发生的一刻父子进程共享同一内存数据,当父进程要更改其中某片数据时,操作系统会将该片数据复制一份以保证子进程的数据不受影响,所以新的RDB文件存储的是执行fork一刻的内存数据。aof每次都是写盘操作,毫米级别。没法比。
copyonwritefork()出来的子进程共享主进程的物理空间,当主子进程有内存写入操作时,read-only内存页发生中断,将触发的异常的内存页复制一份(其余的页还是共享主进程的)。2. 全持久化
AOF
模式开启AOF持久化后每执行一条会更改Redis中的数据的命令,Redis就会将该命令写入硬盘中的AOF文件。(处理请求是串行化的)
当程序对 AOF 日志文件进行写操作时,实际上是将内容写到了内核为文件描述符分配的一个内存缓存中,然后内核会异步将脏数据刷回到磁盘的。
- Linux的glibc提供了fsync(int fd)函数可以将指定的文件强制从内核缓存刷到磁盘。
在生产环境的服务器中,Redis 通常是每隔 1s 左右执行一次 fsync 操作。这是在数据安全性和性能之间做了一个折中,在保持高性能的同时,尽可能使得数据少丢失。(持久化操作主要在从节点进行)AOF rewrite - 重复的命令给去掉 4.0之前的做法效率很是低下,需要逐条命令对比。4.0开始的bgrewriteaof支持混合模式(也是就是rdb和aof一起用),直接将rdb持久化的方式来操作
将二进制内容覆盖到aof文件中
(rdb是二进制,所以很小),然后再有写入的话还是继续append追加到文件原始命令,等下次文件过大的时候再次rewrite(还是按照rdb持久化的方式将内容覆盖到aof中)。RDB-AOF混合持久化
混合持久化也是通过bgrewriteaof完成的,
fork出的子进程先将共享的内存副本全量以RDB的方式写入aof
。写完还是通知主进程,然后再将重写缓冲区的内容以AOF方式写入到文件,然后替换旧的aof文件。也就是说这种模式下的aof文件发生rewrite后前半部分是rdb格式(REDIS开头的二进制数据),后半部分是正常的aof追加的命令(重写缓冲区里的)。持久化配置:
RDB做冷备,AOF做数据恢复(数据更可靠)
数据恢复会优先看是否存在aof文件,若存在则先按照aof文件恢复,因为aof毕竟比rdb全。Redis的同步机制 Redis主从同步
打开aof的操作不是修改配置文件然后重启,而是先热修改让他生成aof,这次生成肯定是会带着内存中完整的数据的。然后再修改配置文件重启。
Redis的主从同步机制可以确保redis的master和slave之间的数据同步。按照同步内容的多少可以分为全同步和部分同步;按照同步的时机可以分为slave刚启动时的初始化同步和正常运行过程中的数据修改同步
CAP:网络分区发生时,一致性和可用性两难全。
快照同步(初始化同步) 内存的 buffer 是有限的,从节点将无法直接通过指令流来进行同步
(1)slave启动后向master发送同步指令SYNC,master接收到SYNC指令之后将调用该命令的处理函数
syncCommand()
进行同步处理;(2)在函数syncCommand中,将调用函数rdbSaveBackground启动一个备份进程用于数据同步,如果已经有一个备份进程在运行了,就不会再重新启动了。
(3)备份进程将执行函数rdbSave() 完成将redis的全部数据保存为rdb文件。
(4)在redis的时间事件函数serverCron(redis的时间处理函数是指它会定时被redis进行操作的函数)中,将对备份后的数据进行处理,在serverCron函数中将会检查备份进程是否已经执行完毕,如果备份进程已经完成备份,则调用函数backgroundSaveDoneHandler完成后续处理。
(5)在函数backgroundSaveDoneHandler中,首先更新master的各种状态,例如,备份成功还是失败,备份的时间等等。然后调用函数updateSlavesWaitingBgsave,将备份的rdb数据发送给等待的slave。
(6)在函数updateSlavesWaitingBgsave中,将遍历所有的等待此次备份的slave,将备份的rdb文件发送给每一个slave。另外,这里并不是立即就把数据发送过去,而是将为每个等待的slave注册写事件,并注册写事件的响应函数sendBulkToSlave,即当slave对应的socket能够发送数据时就调用函数sendBulkToSlave(),实际发送rdb文件的操作都在函数sendBulkToSlave中完成。
(7)sendBulkToSlave函数将把备份的rdb文件发送给slave。
增量同步 (数据修改同步) (1)master接收到一条用户的操作后,将调用函数call函数来执行具体的操作函数(此过程可参考另一文档《redis命令执行流程分析》),在该函数中首先通过proc执行操作函数,然后将判断操作是否需要扩散到各slave,如果需要则调用函数propagate()来完成此操作。
(2)propagate()函数完成将一个操作记录到aof文件中或者扩散到其他slave中;在该函数中通过调用feedAppendOnlyFile()将操作记录到aof中,通过调用replicationFeedSlaves()将操作扩散到各slave中。
(3)函数feedAppendOnlyFile()中主要保存操作到aof文件,在该函数中首先将操作转换成redis内部的协议格式,并以字符串的形式存储,然后将字符串存储的操作追加到aof文件后。
(4)函数replicationFeedSlaves()主要将操作扩散到每一个slave中;在该函数中将遍历自己下面挂的每一个slave,以此对每个slave进行如下两步的处理:将slave的数据库切换到本操作所对应的数据库(如果slave的数据库id与当前操作的数据id不一致时才进行此操作);将命令和参数按照redis的协议格式写入到slave的回复缓存中。写入切换数据库的命令时将调用addReply,写入命令和参数时将调用addReplyMultiBulkLen和addReplyBulk,函数addReplyMultiBulkLen和addReplyBulk最终也将调用函数addReply。
(5)在函数addReply中将调用prepareClientToWrite()设置slave的socket写入事件处理函数sendReplyToClient(通过函数aeCreateFileEvent进行设置),这样一旦slave对应的socket发送缓存中有空间写入数据,即调用sendReplyToClient进行处理。
(6)函数sendReplyToClient()的主要功能是将slave中要发送的数据通过socket发出去。
Redis 同步的是指令流,主节点会将那些对自己的状态产生修改性影响的指令记录在本地内存 buffer 中,然后异步将 buffer 中的指令同步到从节点,从节点一边执行同步的指令流来达到和主节点一样的状态,一遍向主节点反馈自己同步到哪里。
如果出现网络闪断或者命令丢失等异常情况,从节点之前保存了自身
已复制的偏移量和主节点的运行ID
主节点根据偏移量把复制积压缓冲区里的数据发送给从节点,保证主从复制进入正常状态。
集群 1:哨兵(Sentinel)模式
主从切换需要人工干预,费事费力,还会造成一段时间内服务不可用。必须有一个高可用方案来抵抗节点故障,当故障发生时可以自动进行从主切换,程序可以不用重启。也就是Redis集群。
文章图片
- 通过发送命令,让Redis服务器返回监控其运行状态,包括主服务器和从服务器。
- 当哨兵监测到master宕机,会自动将slave切换成master,然后通过发布订阅模式通知其他的从服务器,修改配置文件,让它们切换主机。(多个 sentinel 会选出一个 leader,具体的选举机制是依据 Raft 分布式一致性协议。)
假设主服务器宕机,哨兵1先检测到这个结果,系统并不会马上进行failover过程,仅仅是哨兵1主观的认为主服务器不可用,这个现象成为主观下线。当后面的哨兵也检测到主服务器不可用,并且数量达到一定值时,那么哨兵之间就会进行一次投票,投票的结果由一个哨兵发起,进行failover操作。切换成功后,就会通过发布订阅模式,让各个哨兵把自己监控的从服务器实现切换主机,这个过程称为客观下线。redis的集群脑裂:(sentinel集群无法感知到master,有两个出现)
- 连接到master的最少slave数量
- slave连接到master的最大延迟时间
文章图片
Codis 在设计上相比 Redis Cluster 官方集群方案要简单很多,因为它将分布式的问题交给了第三方 zk/etcd 去负责。
- 因为 Codis 中所有的 key分散在不同的 Redis 实例中,所以事务就不能再支持了
- Codis 因为增加了 Proxy 作为中转层,所有在网络开销上要比单个 Redis 大。
- Codis 的集群配置中心使用 zk 来实现。
Redis Sentinel 水平扩容牵涉到数据的迁移。迁移过程一方面要保证自己的业务是可用的,一方面要保证尽量不丢失数据所以数据能不迁移就尽量不迁移。
每个节点负责其中一部分槽位。槽位的信息存储于每个节点中,它不像 Codis,它不需要另外的分布式存储来存储节点槽位信息。Redis Cluster把数据集划分到多个节点上,
当 Redis Cluster 的客户端来连接集群时,它也会得到一份集群的槽位配置信息。这样当客户端要查找某个 key 时,可以直接定位到目标节点。
每个节点负责整个数据的一个子集
。采用 哈希算法 将 Redis 数据的 key 进行散列,通过 hash 函数,特定的 key会 映射 到特定的 Redis 节点上。-》最小配置 6 个节点以上(3 主 3 从),其中主节点提供读写操作,从节点作为备用节点,不提供请求,只作为故障转移使用。为什么Redis集群有16384个槽 https://github.com/antirez/redis/issues/2576
对于客户端请求的key,根据公式HASH_SLOT=CRC16(key) mod 16384,计算出映射到哪个分片上,然后Redis会去相应的节点进行操作(1)如果槽位为65536,发送心跳信息的消息头达8k,发送的心跳包过于庞大。
(2)redis的集群主节点数量基本不可能超过1000个。
(3)槽位越小,节点少的情况下,压缩比高
Redis主节点的配置信息中,它所负责的哈希槽是通过一张bitmap的形式来保存的,在传输过程中,会对bitmap进行压缩,但是如果bitmap的填充率slots / N很高的话(N表示节点数),bitmap的压缩率就很低。
如果节点数很少,而哈希槽数量很多的话,bitmap的压缩率就很低。
文章图片
Redis 常见问题解决 缓存雪崩
同一时刻大量缓存失效;
(1):
设置不同的缓存失效时间 (加随机值)
(2):限流降级 (通过加锁或者队列来控制读数据库写缓存的线程数量。)
(3):集群部署
缓存击穿
- 设置热点数据
永远不过期
。 加互斥锁
(缓存中没有数据,第1个进入的线程,获取锁并从数据库去取数据,没释放锁之前,其他并行进入的线程会等待100ms)
缓存穿透
布隆过滤器
一个对一个key进行k个hash算法
获取k个值,在比特数组中将这k个值散列后设定为1,然后查的时候如果特定的这几个位置都为1,那么布隆过滤器判断该key存在。(不存在漏报,可能会误判)缓存空对象
:访问数据库后返回为空,此时也将该空对象进行缓存。
布谷鸟过滤器源于布谷鸟Hash算法,布谷鸟Hash表有两张,分别两个Hash函数,当有新的数据插入的时候,它会计算出这个数据在两张表中对应的两个位置,这个数据一定会被存在这两个位置之一(表1或表2)。一旦发现其中一张表的位置被占,就将改位置原来的数据踢出,被踢出的数据就去另一张表找对应的位置。通过不断的踢出数据,最终所有数据都找到了自己的归宿。双写一致性(先更新数据库,再删缓存)
但仍会有数据不断的踢出,最终形成循环,总有一个数据一直没办法找到落脚的位置,这代表布谷Hash表走到了极限,需要将Hash算法优化或Hash表扩容。
- 先更新数据库,再更新缓存
不可靠,A写后B写B更新A更新,因为网络等原因,B却比A更早更新了缓存
- 先删缓存,再更新数据库 (
延时双删策略
)
(1)先淘汰缓存
(2)再写数据库(这两步和原来一样)
(3)休眠1秒(读数据业务逻辑的耗时),再次淘汰缓存
-
先更新数据库,再删缓存
(
facebook也在论文《Scaling Memcache at Facebook》中提出,他们用的也是先更新数据库,再删缓存的策略。删除失败,保障的重试机制)
- 更新数据库数据;
- 数据库会将操作信息写入binlog日志当中;
- 订阅程序提取出所需要的数据以及key;
- 另起一段非业务代码,获得该信息;
- 试删除缓存操作,发现删除失败;
- 将这些信息发送至消息队列;
- 重新从消息队列中获得该数据,重试操作;
- 分布式锁+时间戳 (setnx()建立锁,修改的value加时间戳,每次更新修改它)
- 消息队列
bigkeys
命令可以找到它- 4.0引入了
memory usage
命令(内存维度的抽样算法)和lazyfree
机制(异步删除)
redis-cli -h 127.0.0.1 -p 3306 -bigkeys -i 0.1
每隔100条scan命令休眠0.1s,ops不会剧烈抬升
热点Key
- 服务端缓存,二级缓存:即将热点数据缓存至服务端的内存中 (Redis的事件通知机制实现一致性)
备份热点Key
:即将热点Key+随机数,随机分配至Redis其他节点中。这样访问热点key的时候就不会全部命中到一台机器上了。redis-cli –hotkeys
就能找出热点Key
- 惰性删除 (unlink 丢给后台线程异步回收)
- 定期删除
redis采用惰性删除+定期删除
策略。(在进行get或setnx等操作时,先检查key是否过期;对指定个数个库的每一个库随机删除小于等于指定个数个过期key)
当某个key被设置了过期时间之后,客户端每次对该key的访问(读写)都会事先检测该key是否过期,如果过期就直接删除;但有一些键只访问一次,因此需要主动删除,默认情况下redis每秒检测10次,检测的对象是所有设置了过期时间的键集合,每次从这个集合中随机检测20个键查看他们是否过期,如果过期就直接删除,如果删除后还有超过25%的集合中的键已经过期,那么继续检测过期集合中的20个随机键进行删除。这样可以保证过期键最大只占所有设置了过期时间键的25%。淘汰机制-LRU淘汰算法(最近最久未使用)
- Java中的LRU实现方式: 基于 HashMap 和 双向链表实现 LRU
使用 HashMap 存储 key,而 HashMap 的 Value 记录需要缓存数据在 LRU 存储中的槽。
save(key, value)——首先在 HashMap 找到 key 对应的节点,(a)如果节点存在,更新节点的值,并把这个节点移动到队头。(b)如果不存在,需构造新的节点,并且尝试把节点塞到队头。(c)如果LRU空间不足,则通过 tail 淘汰掉队尾的节点,同时在 HashMap 中移除 key。 [新增key value的时候首先在链表结尾添加Node节点,如果超过LRU设置的阈值就淘汰队头的节点并删除掉HashMap中对应的节点。]
get(key)——通过 HashMap 找到 LRU 链表节点,因为根据LRU 原理,这个节点是最新访问的,所以要把节点插入到队头,然后返回缓存的值。 [访问key对应的值的时候把访问的Node节点移动到队尾即可。]
[修改key对应的值的时候先修改对应的Node中的值,然后把Node节点移动队尾。]
- Redis的LRU实现
给每个key增加一个额外的字段,这个字段占24bit,也就是最后一次被访问的时间戳。然后随机采样出5个key淘汰掉最旧的key,直到Redis占用内存小于maxmemory为止。
如果按照HashMap和双向链表实现,需要额外的存储存放 next 和 prev 指针,牺牲比较大的存储空间,显然是不划算的。Redis 实现限流
setnx
操作
10秒内限定20个请求,那么我们在setnx的时候可以设置过期时间10。(1-10秒的时候,无法统计2-11)- 数据结构
zset
限流涉及的最主要的就是滑动窗口
。value保持唯一,可以用UUID生成,而score用当前时间戳
表示,因为score我们可以用来计算当前时间戳之内有多少的请求数量。而zset(sorted set)
数据结构也提供了range
方法让我们可以很轻易的获取到2个时间戳内有多少请求
sorted set 当items内容大于64的时候同时使用了hash和skiplist两种设计实现。
- Redis的令牌桶算法
每访问一次请求的时候,可以从Redis中获取一个令牌,如果拿到令牌了,那就说明没超出限制。
依靠List的pop
来获取令牌。
Java的定时任务,定时往List
中push
令牌。
还可以使用redsi-cell
实现令牌桶算法
漏桶算法思路很简单,水(请求)先进入到漏桶里,漏桶以一定的速度出水(接口有响应速率),当水流入速度过大会直接溢出(访问频率超过接口响应速率),然后就拒绝请求,可以看出漏桶算法能强行限制数据的传输速率。跳跃表
- 新节点和
各层索引节点
逐一比较,确定原链表的插入位置。O(logN) - 把索引插入到原链表。O(1)
- 利用
抛硬币的随机
方式,决定新节点是否提升为上一级索引
。结果为“正”则提升并继续抛硬币,结果为“负”则停止。O(logN)
总体上,跳跃表插入操作的时间复杂度是O(logN),而这种数据结构所占空间是2N,既空间复杂度是 O(N)。它维持平衡的成本低,而二叉查找树多次插入删除后需要rebalance。
key的值不宜设置地过大,尽量保证简洁明了,减少对于内存的占用。通常来说,当一个单独存储的value值大于10kb的时候就会被认为是bigkey了。
- 对于hash,list,set,zset这类数据结构而言,尽量不要让其数目超过5000个。
- 删除bigkey可以结合redis自身提供的机制 异步 删除机制 。
- 优化big key
- 优化big key的原则就是string减少字符串长度,list、hash、set、zset等减少成员数
以hash类型举例来说,对于field过多的场景,可以根据field进行hash取模,生成一个新的key,
例如原来的
hash_key:{filed1:value, filed2:value, filed3:value ...},可以hash取模后形成如下key:value形式
hash_key:mod1:{filed1:value}
hash_key:mod2:{filed2:value}
- 禁止命令的设置
生产环境是禁用keys,flushall,flushdb这类命令
通过修改redis.conf中的SECURITY项,在里头新增以下几行,即可实现对危险指令的禁用
rename-command FLUSHALL ""
rename-command FLUSHDB ""
rename-command CONFIG""
rename-command KEYS""
拓展 RESP 文本序列化协议
Redis使用了浪费流量的文本序列化协议,但实现异常简单,且解析性能好。
内存回收机制
Redis 并不总是可以将空闲内存立即归还给操作系统。
如果当前 Redis 内存有 10G,当你删除了 1GB 的 key 后,再去观察内存,你会发现内存变化不会太大。原因是操作系统回收内存是以页为单位,如果这个页上只要有一个 key还在使用,那么它就不能被回收。
Redis 虽然无法保证立即回收已经删除的 key 的内存,但是它会重用那些尚未回收的空闲内存。cpu层面的缓存管理机制 【面试复习|敖丙思维导图-Redis】cpu将缓存分为了
L1,L2,L3
,其速度值大小为L3推荐阅读
- 11_性能测试|性能分析(动态代理失败导致缓存策略失效)
- 代理模式|SpringIOC 和AOP 的理解
- SpringMVC|Springboot 设置上传文件大小
- tcp流量控制和拥塞控制
- JAVA|Java(开始Java编程生涯的小指南)
- java|[多线程] | 实例演示三种创建多线程的方式,初识线程同步以及解决线程安全问题(超卖)
- MySQl|Mysql分页、SSM项目分页实战
- java|Spring Boot干货系列((三)启动原理解析 | 掘金技术征文)
- Web|高并发秒杀系统如何设计()