Redis常见面试题

144 阅读7分钟

Redis的外部数据结构?

基础

  • String: 内部实现SDS、缓存功能、计数器、共享用户Session。
  • List: 有序列表、消息队列、评论列表
  • Hash: KV结构、不能存嵌套对象、场景少。
  • Set: 无序集合自动去重、分布式环境下去重、交集并集操作。
  • Sorted Set: 去重有序、排行榜、带权重的队列。 高级一点
  • HyperLogLog:供不精确的去重计数功能,比较适合用来做大规模数据的去重统计,例如统计 UV。
  • Geo:可以用来保存地理位置并作位置距离计算或者根据半径计算位置等、附近的人、计算最优地图路径。
  • Pub/Sub:订阅发布功能、消息队列。
  • Redis Module
  • BloomFilter: 位图是支持按 bit 位来存储信息,可以用来实现布隆过滤器(BloomFilter)。
  • RedisSearch
  • Redis-ML
  • Pipeline:批量执行一组指令、一次性返回全部结果,减少频繁的请求应答。

Redis的内部数据结构

  • dict
  • sds
  • ziplist
  • quicklist
  • skiplist 这里主要是内部实现,具体的话这里不展开展,有兴趣可以自己查阅。

大量Key同一时间过期会有问题么?

Key过期之间过于集中的话,Redis可能出现短暂的卡顿。严重的话会出现缓存雪崩。可以在过期时间加上一个随机值,使过期时间分散一些。

Redis为什么是单线程的?

因为CPU不是Redis的瓶颈,Redis的瓶颈是内存和网络带宽,单线程容易实现,并且CPU不会成为瓶颈,那就用单线程。

为什么Redis这么快?

  1. 完全基于内存。
  2. 数据结构简单,对数据的操作也简单。
  3. 单线程避免线程上下文切换开销。也不用去考虑各种锁的问题,不存在加锁和释放锁。
  4. 使用I/O多路复用模型,非阻塞IO。
  5. 底层模型不同,底层实现方式和客户端通信协议不一样。Redis自己构建了VM机制,一般的系统调用函数的,会浪费一定时间去移动和请求。

分布式锁?

高性能加锁释放。 可以使用阻塞和非阻塞锁。 不能出现死锁 高可用 setnx原子命令来获取锁。 过期时间避免死锁。 释放的时候要判断锁是不是自己的。 lua脚本判断。 阻塞锁自旋转 然后sleep防止cpu空转。 集群保证可用性。

keys线上会有什么问题?

keys可以扫描指定的key模式列表。由于Redis是单线程的,Keys指令会阻塞一段时间,线上服务会停顿直到指令执行完毕。
scan指令可以无阻塞的提取出指定模式的Key列表,但是会有一定的重复概率,在客户端做一次去重就行。总体时间比keys要长。

对于 SCAN 这类增量式迭代命令来说, 因为在对键进行增量式迭代的过程中, 键可能会被修改, 所以增量式迭代命令只能对被返回的元素提供有限的保证 。

Redis做消息队列

使用list结构作为队列,rpush生产消息,lpop消费消息。当pop没有消息的时候,需要sleep一会防止CPU空转。

list还有个指令叫blpop,在没有消息的时候,它会阻塞住直到消息到来。

一次生产多次消费?

使用pub/sub主题订阅者模式,可以实现1:N的消息队列。

缺点:消费者下线的情况下,生产的消息会丢失,得使用专业的消息队列如RocketMQ。

Redis 延时队列

使用sortedset,拿时间戳作为score,消息内容作为key调用zadd来生产消息,消费者用zrangebyscore指令获取N秒之前的数据轮询进行处理。

Redis持久化?

RDB镜像持久化。AOF增量持久化。 在redis实例重启时,会使用RDB持久化文件重新构建内存,再使用AOF重放近期的操作指令来同步。

同步突然宕机会怎么样?

取决于AOF日志sync属性的配置, sync开启每指令都会sync。 关闭的话是每秒写一次,最多丢失1s的数据。

RDB的原理

fork and cow。fork是指redis通过创建子进程来进行RDB操作,cow指的是copy on write,子进程创建后,父子进程共享数据段,父进程继续提供读写服务,写脏的页面数据会逐渐和子进程分离开来。

Redis主从同步?

Redis可以使用主从同步,从从同步。第一次同步时,主节点做一次bgsave,并同时将后续修改操作记录到内存buffer,待完成后将RDB文件全量同步到复制节点,复制节点接受完成后将RDB镜像加载到内存。加载完成后,再通知主节点将期间修改的操作记录同步到复制节点进行重放就完成了同步过程。后续的增量数据通过AOF日志同步即可,有点类似数据库的binlog。

Redis集群高可用怎么保证,原理是什么?

Redis Sentinal 着眼于高可用,在master宕机时会自动将slave提升为master,继续提供服务。

Redis Cluster 着眼于扩展性,在单个redis内存不足时,使用Cluster进行分片存储。

单机会有瓶颈,如何解决?

集群的部署方式也就是Redis cluster,并且是主从同步读写分离,类似Mysql的主从同步,Redis cluster 支撑 N 个 Redis master node,每个master node都可以挂载多个 slave node。

这样整个 Redis 就可以横向扩容了。如果你要支撑更大数据量的缓存,那就横向扩容更多的 master 节点,每个 master 节点就能存放更多的数据了。

Redis哨兵集群

哨兵必须用三个实例去保证自己的健壮性的,哨兵+主从并不能保证数据不丢失,但是可以保证集群的高可用。
现在有master+哨兵s1+哨兵s2.master宕机了,s1和s2两个哨兵只要有一个认为你宕机了就切换了,并且会选举出一个哨兵去执行故障,但是这个时候也需要大多数哨兵都是运行的。

内存淘汰机制?

  • noeviction:返回错误当内存限制达到并且客户端尝试执行会让更多内存被使用的命令(大部分的写入指令,但DEL和几个例外)

  • allkeys-lru: 尝试回收最少使用的键(LRU),使得新添加的数据有空间存放。

  • volatile-lru: 尝试回收最少使用的键(LRU),但仅限于在过期集合的键,使得新添加的数据有空间存放。

  • allkeys-random: 回收随机的键使得新添加的数据有空间存放。

  • volatile-random: 回收随机的键使得新添加的数据有空间存放,但仅限于在过期集合的键。

  • volatile-ttl: 回收在过期集合的键,并且优先回收存活时间(TTL)较短的键,使得新添加的数据有空间存放。

如果没有键满足回收的前提条件的话,策略volatile-lru, volatile-random以及volatile-ttl就和noeviction 差不多了。

手写LRU ?

leetcode-cn.com/problems/lr…

import java.util.HashMap;

public class LRUCache {

    class Node {

        //节点的next指针
        public Node next;

        //节点的prev指针
        public Node prev;

        public int key, value;

        public Node(int key, int value) {
            this.key = key;
            this.value = value;
        }
    }

    class DoubleLinkList {

        //头结点
        private Node head;
        //尾节点
        private Node tail;

        private int size;

        public DoubleLinkList() {
            head = new Node(0, 0);
            tail = new Node(0, 0);
            head.next = tail;
            tail.prev = head;
            this.size = 0;
        }

        //在链表头部添加节点
        public void addFirst(Node node) {
            node.prev = head;
            node.next = head.next;
            head.next.prev = node;
            head.next = node;
            size++;
        }

        //删除链表中指定节点,该节点一定存在
        public void remove(Node node) {
            node.prev.next = node.next;
            node.next.prev = node.prev;
            size--;
        }

        //删除链表中最后一个节点,并返回该节点,时间O(1)
        public Node removeLast() {
            if (head.next == tail) {
                return null;
            }

            Node last = tail.prev;
            remove(last);
            return last;
        }

        //返回链表长度
        public int size() {
            return size;
        }
    }

    private HashMap<Integer, Node> hashMap;

    private DoubleLinkList cache;

    private int cap;

    public LRUCache(int capacity) {
        hashMap = new HashMap<>();
        cache = new DoubleLinkList();
        this.cap = capacity;
    }

    //将某个 key 提升为最近使用的
    private void makeRecently(int key) {
        Node node = hashMap.get(key);
        //先删除该节点
        cache.remove(node);
        //将该节点添加到队头
        cache.addFirst(node);
    }

    //添加最近使用的元素
    private void addRecently(int key, int val) {
        Node newNode = new Node(key, val);
        //在链表头部添加节点
        cache.addFirst(newNode);
        //在map里添加该节点
        hashMap.put(key, newNode);
    }

    //删除某一个 key
    private void deleteKey(int key) {
        Node node = hashMap.get(key);
        cache.remove(node);
        hashMap.remove(key);
    }

    //删除最久未使用的元素
    private void removeLeastRecently() {
        //链表的最后一个元素,就是最久未使用的
        Node node = cache.removeLast();
        hashMap.remove(node.key);
    }

    public int get(int key) {
        if (!hashMap.containsKey(key)) {
            return -1;
        }

        makeRecently(key);
        return hashMap.get(key).value;
    }

    public void put(int key, int val) {
        //若key已存在
        if (hashMap.containsKey(key)) {
            deleteKey(key);
            addRecently(key, val);
            return;
        }

        if (cap == cache.size()) {
            removeLeastRecently();
        }

        addRecently(key, val);

    }

}