一、 Redis基础
1. 数据类型
基本: String、List、Hash、Set、Zset
其他: HyperLogLog、Pub/Sub
2. keys和scan区别
keys: Redis是单线程的,keys指令会阻塞式的去扫指定的key列表,一般线上禁止使用。
scan: 可以无阻塞增量式获取指定的key列表,但是有一定的重复率,需要服务端去重,整体花费的时长较高。
二、 缓存雪崩、缓存击穿、缓存穿透
1. 缓存雪崩
描述:
同一时刻,缓存大量失效,导致大量请求直接访问数据库,造成数据库压力急剧增大。
解决:
1. 缓存的过期时间设置随机值。
2. 热点数据不过期,异步更新缓存,适用于不严格要求的场景。
3. 服务限流、熔断,降级。
2. 缓存击穿
描述:
某一时刻,单个热点key缓存失效,导致请求直接打到数据库,持续的高并发直接请求数据库。
解决:
1. 分布式锁,获取锁成功才执行数据库和写缓存操作。
2. 热点数据不过期。
3. 缓存穿透
描述:
访问的数据,既不在缓存中,也不在数据库中,导致每次请求都直接打到数据库。
解决:
1. 缓存空值或者默认值。
2. 业务逻辑前置。
3. bloom filter。
三、持久化
1. RDB(Redis Data Base)
1. 定义
RDB也就是内存快照,在指定的时间间隔内将内存中的数据集快照写入磁盘,RDB是以内存快照的形式持久化的,
每次都是从Redis中生成一个快照进行全量备份。
2. 备份过程
Redis将内存快照保存在dump.rdb二进制文件中。
(1)文件结构
RDB文件结构由五部分组成:
-
长度5字节的常量字符串。
-
4字节的version,标识RDB文件版本。
-
database:不定长度。
-
1字节的EOF常量,表示文件正文内容结束。
-
check_sum:8字节,保存校验和。
例:
以下是数据库0和数据库3有数据的情况:
(2)文件创建
可以通过save指令或者bgsave指令手动触发RDB持久化
-
save:会阻塞redis其他的操作,导致无法响应客户端,不建议使用。
-
bgsave:创建子线程,进行快照保存操作,不阻塞其他操作。
默认情况下,可以进行redis设置,让其“N秒内至少有M次改动”满足时自动保存数据。
save 60 10
: 60秒内至少有10个键被改动
Redis默认设置:
save 60 10000
save 300 10
save 900 1
(3)过程
Redis会fork一个子进程进行持久化,将数据写入一个临时文件中,持久化完成后替换旧的RDB文件。整个持久化过程中,
主进程(为客户端提供服务的进程)不参与IO操作,确保Redis的高性能。
整体流程图:
步骤:
-
父进程fork创建子进程,这个过程父进程是阻塞的。父进程fork后,bgsave返回命令信息不在阻塞父进程。
-
子进程对内存数据生成快照。
-
父进程期间内接收新的写操作,使用COW机制写入。
-
子进程文成快照写入,替换旧RDB文件,随后子进程退出。
fork:
- linux系统中,fork会产生一个和父进程完全相同的子进程,子进程与父进程所有的数据均一致。
- linux系统使用COW(Copy On Write)写时复制机制,fork子进程与父进程一般情况共同使用一段物理内存,
只有在进程空间中发生修改时,内存空间才会复制一份出来。
Redis持久化就是利用这项技术,在持久化时调用glibc函数fork子进程,在持久化过程中,当客户端的请求对内存中的数据进行
修改时,此时就会通过COW机制对数据段页面进行分离,也就是复制一块内存给主进程修改。父进程对内存的修改对于子进程是不可见
的,两者互不影响。
3. 优缺点
(1)优点
- 恢复速度快,适合全量备份的场景。
- 存储紧凑(二进制文件),节省空间。
(2)缺点
- 容易丢失数据,两次快照之间的数据容易丢失。
- RDB通过fork子进程对内存快照进行备份,是一个重量级操作,成本高。
2. AOF(Append Only File)
1. 定义
AOF是把所有对内存进行修改的指令(写操作)以独立日志文件的方式进行记录,当Redis重启时通过执行AOF文件中的命令
来恢复数据。类似binlog的原理,AOF可以解决数据持久化实时性问题。
2. 备份过程
(1)文件结构
写入AOF文件的所有命令都是以RESP格式进行保存的,是以纯文本的格式保存在AOF文件中。Redis客户端和服务端之间使用RESP的二进制文本协议进行通信。
(2)AOF过程
AOF持久化进行备份时,客户端所有的写命令都会被追加到AOF缓冲区中,缓冲区中的数据根据Redis配置的同步策略同步到磁盘上的AOF文件中,追加保存每次写操作到文件末尾。当AOF文件达到重写策略的阈值时,Redis会对AOF日志进行重写。Redis重启时,通过加载AOF文件来恢复数据。
文件写入(write)&文件同步(sync)
Linux系统为了提升性能,使用了页缓存。当我们将AOF缓冲区中的内容写到磁盘上时,此时没有真正的落盘,
而是在页缓存中,执行fsync/fdatasync命令来强制刷盘。
同步策略:
-
always:命令写入AOF缓冲区后立即调用系统write操作和fsync操作同步AOF文件,fsync完成后线程返回。这种情况下,每次写命令都会同步AOF文件,硬盘IO成为性能瓶颈。
-
no:命令写入缓冲区后,调用系统write操作,不对AOF进行fsync同步;同步由操作系统负责,通常30秒为一个周期。这种情况下,文件同步的时间不可控,且缓冲区中堆积的数据会更多。
-
everysec:命令写入缓存后调用write操作,write完成后线程返回;fsync同步文件操作由专门的线程每秒调用一次。这种方案。
(3)AOF重写
通过fork一个子进程,重写一个新的AOF文件,该次重写不是读取旧的AOF文件进行复制,而是读取内存中的Redis数据库,
重写一份AOF文件。
能够压缩AOF文件的原因:
-
过期的数据不再写入文件。
-
无效的命令不再写入文件,例如被重复设置的值或者被删除的。
-
多条命令合并为一条,防止单条命令过大造成缓存区溢出,会将命令拆分为多条。
问题一:为什么AOF重写时读取的是内存中的数据不是从现有的AOF文件中复制呢?
答:AOF文件是以追加写命令的形式进行保存的,这就导致大量冗余的指令存储,从而AOF文件过大。例如一个key被写入10000次,
最终却被删除了,这种情况不仅占用内存,而且导致恢复速度较慢。因此复制AOF文件的形式并不能的到最大限度的压缩文件结果。
(4)重写时机
-
当前AOF文件空间大于运行AOF重写时文件的最小体积,默认mb。
-
当前AOF相比上次AOF的增长率达到一定值时。
(5)AOF重写流程
-
bgrewriteaof触发重写,判断是否增在重写,存在等待其执行结束再执行;
-
主进程fork子进程,防止主进程阻塞影响服务;
-
子进程遍历Redis内存快照中数据写入临时AOF文件,同时将新的写指令写入AOF缓冲区&AOF重写缓冲区,前者是了写回旧的AOF文件,后者是为了后续刷新到临时AOF文件中,防止快照内存遍历时新的写入操作丢失;
-
子进程结束临时AOF文件写入后,通知主进程;
-
主进程将AOF重写缓冲区的数据写入临时AOF文件中;
-
主进程使用临时AOF文件替换旧AOF文件,完成重写过程;
3. 优缺点
(1)优点
-
数据备份完整,丢失数据的概率较低,适用于对数据完整性较高的场景。
-
日志文件可读,AOF可操作性强,通过操作日志进行修复。
(2)缺点
-
AOF日志文件较大,恢复速度较慢。
-
同步写操作频繁会带来性能压力。
四、过期策略&内存淘汰机制
Redis作为一个高性能数据库,可以直接从内存中获取数据,但是当数据量较大时,达到上限,想存入新的值时就会出现问题。
Redis提供了两种机制来解决:过期策略和内存淘汰机制。
1. 过期策略
我们在设置key时,可以为key指定一个过期时间,这个key是何时被删除的呢?
(1)定期删除
Redis默认每个100ms随机抽取一些设置过期时间的key,检查key是否过期,如果过期就将其删除。因为存储的key太多,
如果全盘扫描的话,十分消耗性能,因此通过随机的方式。可能存在大部分漏删的情况,通过惰性删除解决。
(2)惰性删除
惰性删除不再是Redis主动删除,而是客户端获取key的时候,检查key的是否已经过期,如果已经过期会删除这个key,
否则返回客户端。
惰性删除可以解决一些问题,但是哪些没有被随机到,也没有被客户端访问的key就会一直保留在数据库中,
长期会导致内存耗尽,因此需要内存淘汰机制处理。
2. 内存淘汰机制
当内存使用达到某个阈值时,就会触发内存淘汰,Redis提供了几种策略。
-
noeviction:当内存不足以容纳写入数据时,新写入会报错。(默认策略)
-
allkeys-lru:当内存不足以容纳新数据时,移除最近最少使用的key。
-
allkeys-random:当内存不足以容纳新数据时,随机移除某个key。
-
volatile-lru:当内存不足以容纳新数据时,在设置过期时间的key中,移除最近最少使用的key。
-
volatile-random:当内存不足以容纳新数据时,在设置过期时间的key中,随机移除某个key。
-
volatile-ttl:当内存不足以容纳新数据时,在设置过期时间的key中,移除最早过期的key。
根据自己的业务需求,如果使用Redis仅作为缓存,不进行DB持久化,推进allkeys-lru;如果作为缓存和DB持久化,推荐volatile-lru。
3. LRU算法
LRU(Least Recently Used),即最近最少使用,是一种缓存置换算法。核心思想是:如果一个数据在最近一段时间没有被使用到,
那么将来被使用到的概率也很小,所以选择淘汰掉。
代码实现:
public class LRUCache<k, v> {
//容量
private int capacity;
//当前有多少节点的统计
private int count;
//缓存节点
private Map<k, Node<k, v>> nodeMap;
private Node<k, v> head;
private Node<k, v> tail;
public LRUCache(int capacity) {
if (capacity < 1) {
throw new IllegalArgumentException(String.valueOf(capacity));
}
this.capacity = capacity;
this.nodeMap = new HashMap<>();
//初始化头节点和尾节点,利用哨兵模式减少判断头结点和尾节点为空的代码
Node headNode = new Node(null, null);
Node tailNode = new Node(null, null);
headNode.next = tailNode;
tailNode.pre = headNode;
this.head = headNode;
this.tail = tailNode;
}
public void put(k key, v value) {
Node<k, v> node = nodeMap.get(key);
if (node == null) {
if (count >= capacity) {
//先移除一个节点
removeNode();
}
node = new Node<>(key, value);
//添加节点
addNode(node);
} else {
//移动节点到头节点
moveNodeToHead(node);
}
}
public Node<k, v> get(k key) {
Node<k, v> node = nodeMap.get(key);
if (node != null) {
moveNodeToHead(node);
}
return node;
}
private void removeNode() {
Node node = tail.pre;
//从链表里面移除
removeFromList(node);
nodeMap.remove(node.key);
count--;
}
private void removeFromList(Node<k, v> node) {
Node pre = node.pre;
Node next = node.next;
pre.next = next;
next.pre = pre;
node.next = null;
node.pre = null;
}
private void addNode(Node<k, v> node) {
//添加节点到头部
addToHead(node);
nodeMap.put(node.key, node);
count++;
}
private void addToHead(Node<k, v> node) {
Node next = head.next;
next.pre = node;
node.next = next;
node.pre = head;
head.next = node;
}
public void moveNodeToHead(Node<k, v> node) {
//从链表里面移除
removeFromList(node);
//添加节点到头部
addToHead(node);
}
class Node<k, v> {
k key;
v value;
Node pre;
Node next;
public Node(k key, v value) {
this.key = key;
this.value = value;
}
}
}
LRU在Redis中的实现:
Redis使用近似LRU算法,通过随机采样淘汰数据,每次随机选择5个key,从中淘汰掉最近最少使用的key。Redis为了实现近似LRU
算法,给每个key额外增加一个24bit的字段,用来存储该key最后一次被访问的时间。
五、常见面试问题
1. Redis的同步机制,哨兵机制?
敬请期待!
2. Redis为什么这么快?
敬请期待!
3. Redis是单线程的?
敬请期待!
4. Bigkey问题的解决思路?
敬请期待!
5. 缓存和数据库一致性如何保证?
敬请期待!