后端面试系列(一)- Redis数据篇

132 阅读9分钟

为什么写面试系列?
源自今年五月尝试找工作,一个半月最后收获了0 offer。有几家大厂如阿里字节都到了HR面,但最后都挂在了横向上(个人猜的),自己虽然科班出身,对整个计算机知识有了解,但未曾系统的做一次总结。所以一方面是为了梳理自己的知识点,另一方面是为了明年的金三银四做准备。

整个系列大概涵盖什么
常见的中间件系列如 redis, mysql, kafka&rabbitMq. 计算机基础知识系列如 计算机网络, 操作系统. 以及对应的编程语言基础, 因为我工作以来主用语言是java, 所以会写一些jvm相关的。最后作为一名工作4年以上的菜鸟,也会分享一些整理的场景设计题。平时工作较忙,加上自己也是边学习边写,争取每两到三周更新一篇文章。

注意
写这个系列的初衷是为了将自己的知识梳理一遍,以及分享一些个人拙见,养成写博客的习惯,有些内容不一定准确。另外主要是面试系列,所以不会对每个知识点的细节分析的很清楚,主要是分享一些关键点。其他博客上也都有,请大家手下留情,不喜勿喷。若有不对处,感谢大家指正。

一. Redis的数据存储类型

在redis中,所有数据都是以key-value键值对的形式保存的,redis有一个名叫键空间的地方存储了所有的键值对,保存数据的结构是RedisObject。其中, key的类型一定是字符串,而常见的value类型有五种,分别是 字符串,列表,Hash, set, sorted set. 每种存储类型都由多种底层数据结构实现,redis会依据存储的具体内容而自动做最优选择。

  1. 字符串 - 当存储的字符是整数时,redis会使用整型来存储字符串,若是一般的字符串,则会用simple dynamic string (简称SDS)来存储,由于redis是用C语言编写的,所以SDS相比较C语言里的字符串,主要区别在于多了一个free字段,和一个length字段。主要解决了C语言字符串的溢出问题,以及常数时间内获取字符串长度的问题。另外length字段的新增解决了C语言字符串依靠 \n 来识别字符串结束的限制,该限制导致C语言字符串只能存储文本数据。因此SDS可以存储任何二进制数据。

  2. 列表 - 在早期的redis版本中(为什么说早期,因为redis最新版本优化了底层数据结构,但我还没来得及细读,所以这里都是写的旧版本的数据结构,下文同理),当列表存储的元素小于512个,且每个元素的size小于64字节时,用的是压缩列表,否则,用的是双向链表。无论哪种,redis都支持对列表进行双向操作。

  3. Hash - hash里结构就是一个个key-value,所以元素不能重复。我常说hash里就是一个大key里包含很多个小key。如果存储元素的个数小于512个,每个元素的size小于64字节时,用的是压缩列表(同列表一样),否则用的是哈希表。hash有一个缺点是只能对外层key设一个过期时间,内层所有key共享外层的过期时间,无法单独设置。

  4. Set - set是一个无序集合,里面的元素不能重复。如果元素个数小于512个,且每个元素都是整数,底层会用整数集合来实现,否则就用哈希表。

  5. ZSet - sorted set是一个有序集合,每个元素都有一个score值,redis依据score值从小到大排序,当有序集合里的元素小于128个,且每个元素的size小于64字节时,用压缩列表,否则用的是哈希表 + 跳表结合的方式来实现。

二. Redis的常见使用场景

作为一款使用内存的数据库中间件,redis兼具着高性能,高可用(集群模式)的特点,所以绝大多数场景下都是将redis作为关系型数据库的热点数据缓存,当然还有一些特殊场景。

  1. 字符串 - 缓存简单对象(如JSON串),常规计数(如统计单位时间内下载量),全局ID自增,分布式锁等。
  2. 列表 - 本人项目中几乎没用过这种数据结构,不过结合其双端链表的特点,可以用作队列,栈等场景吧。
  3. Hash - 购物车,如外层key就是用户ID,内层key就是一个个商品id,内层value就是一些商品基本信息
  4. Set - 收藏列表,点赞列表,关注列表等。由于两个set之间可以直接进行并交集等操作,所以常用于聚合计算场景,如共同关注等。
  5. ZSet - 鉴于其有序的特性,常用于小型排行榜。

三. 详细说明Redis跳表的实现

在redis中,跳表是用来实现ZSet的底层结构之一,当有序集合里的元素大于128个,或者有一个元素的大小大于64字节时,redis就会采用跳跃表 + 哈希表的形式来存储数据。

3.1 什么是跳跃表?

跳跃表是一种数据结构,它支持O(logn)级别的查找,典型的空间换时间方法。下面是跳表的数据结构以及节点的数据结构,有一个头尾节点,level表示最高的那一层级,length是整个跳表的长度。

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;
    int level;
} zskiplist;

typedef struct zskiplistNode {
    //Zset 对象的元素值
    sds ele;
    //元素权重值
    double score;
    //后向指针
    struct zskiplistNode *backward;
  
    //节点的level数组,保存每层上的前向指针和跨度
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned long span;
    } level[];
} zskiplistNode;

跳表是一个多层级的链式结构,理想状况下,上一层的节点数量是下面一层的一半。redis中的跳表有一个头结点,里面有指向不同层级节点的指针。其中每个节点都有一个forward前向指针,方便进行倒序查询(redis中跳表支持从后向前查询)。其中跨度(span)就是在多层次之间进行查询时,方便得知该元素在整个集合中的排位,而无需遍历最底层级别。

3.2 有了跳表,实现zset时为什么要用一个dict(哈希表)?

简单来说,哈希表用来查找数据 - 分数的对应关系,而跳表适合用来根据 分数查找数据。两个的使用场景存在细微差别。因为在zset中,数据是不允许重复的,但分数允许重复。
用哈希表可以常数时间复杂度上根据成员查找分数(如果只用跳表就是O(logn))。如果只用哈希表,就无法实现范围查询。

四. Redis高级数据结构

除了上述五种数据结构外,在redis中,还支持bitMap, hyperloglog,stream和 geo 4种高级数据结构。

4.1 bitMap

位图其实是一种常见的数据结构,redis对其也做了支持。本质上它就是一连串二进制数组,因为bit是存储数据的最小单位,所以常用于大量数据的存储,去重,计算,优势就是节省空间。
常用场景: 如存储一个用户 一年内的登录情况,就可以将key设为用户id,value是一个512长度的位图,每一天就是一位,登录了设为1,没登录设为0。redis支持使用BITCOUNT key start end 命令来统计值为1的个数,就能在只占用3字节的情况下统计出用户的登录情况。

再比如统计连续7天登录的用户数,可以将key设为日期,每一个用户ID作为value,这一天登录了为1,没有登录为0, 假设有1亿用户,一个key-value 也只需要 1000000000/8/1024/1024 = 12M 内存,占用的空间非常少。那么现在要计算7天连续登录的用户数,可以用BITOP操作对多个key进行位运算(这里应该用 与),最后将得到的结果保存到一个新的位图中,再对该位图做BITCOUNT命令。

再比如10亿QQ号去重,如果用常规的int来存储QQ号,一个int是4字节,那么10亿QQ号就需要约4G内存来存储,然后去重。但是用bitMap的话,一位是1bit,大约节省了4*8= 32倍空间,只需要约125M空间就能存储了。那具体怎么用呢?如一个用户的QQ号是 8888999910一共10位,那就只需要找到第8888999910这个位置,然后设置为1就行。最终使用BITCOUNT统计所有为1的offset。

Redis是如何实现BitMap的?
redis内部使用String倒装作为bitMap的底层实现,因为String本身会保存为二进制的字节数组,所以天然适合做位图。倒过来是为了无需更改后面顺序就能往后加位数。
知道offset,redis怎么查找该位呢? 如现在有个offset是14,那么用14/8=1得到该数组的下标,14%8 = 6得到该位在数组中的下标,最终得到14在第二个数组的第6位。

4.2 hyperloglog

hyperloglog支持不精确的去重技术功能,有误差。它的优点是:当元素的数量或者体积非常大时,计算基数的内存空间总是固定的,大约12KB。常用于网页UV,PV等的统计。

4.3 stream

redis原来使用LIST作为消息队列,但它只支持简单的操作,有很多缺点,如

  1. 只支持单个消费者消费,消费完就从List出队了,不支持多个消费者消费同一条消息
  2. 没有全局ID,需要发布者自己生成全局ID。

基于此,在redis 5.0中,新增了stream作为消息队列,支持消息的持久化,支持自动生成全局唯一ID,支持消费者组模式等。但我没用过redis作为消息队列中间件,基本还是用kafka或者rabbitmq,所以这里没有深入了解了。

4.4 geo

用于地理位置的统计,没怎么了解过