Redis基础概念,数据结构以及使用场景

128 阅读8分钟

Redis基础概念

Redis与MySQL

  • Redis是kv键值对数据库,数据存储在内存中,读写性能好,适用于做缓存。
  • MySQL是关系型数据库,数据存储在磁盘中,读写性能较低,提供事务,索引,范围查询等机制,适用于存储大量数据以及复杂查询场景。

Reids使用场景

缓存,分布式锁,分布式session,消息队列,排行榜,计数器,社交场景。

Redis为什么快?

  1. 数据基于内存存储,存取数据时没有磁盘IO开销。
  2. 单线程执行读写命令,避免线程切换和竞争。
  3. 网络事件处理模型使用了IO多路复用(epoll函数)。
  4. 提供许多优化后的数据结构。

Redis数据类型

Redis数据类型包括String,List,Hash,Set,ZSet,bitmap,HyperLogLog,GEO等。

Redis的kv键值对

  • key类型为sds动态字符串,value类型为上述数据类型之一。
  • redis底层就是一个hash表,其中dictEntry是redis中存储键值对的基本单元,这个结构保存了指向key和value的指针,指针指向的是RedisObject。
  • Redis中所有数据类型在底层都被封装为RedisObject。
typedef struct redisObject {
    unsigned type:4;        // 数据类型(4 位),如String
    unsigned encoding:4;    // 数据编码(4 位),如sds
    unsigned lru:LRU_BITS;  // LRU 时间或 LFU 数据(24 位)
    int refcount;           // 引用计数,为0时对象会被释放
    void *ptr;              // 指向实际数据的指针
} robj;

Redis数据类型与数据结构

image.png

String

底层数据结构为SDS,即动态字符串。由于Redis是用C语言写的,而C语言中的字符串底层是char数组,使用\0结束符记录数组结束位置,这会导致许多问题:

  • 获取长度需要遍历到结束符,时间复杂度为On。
  • 字符中不能有\0字符,所以只能保存文本不能保存图片等二进制数据。
  • 修改时不会自动扩容,可能导致内存溢出。
struct sdshdr {
    int len;    // 字符串长度(已使用的字节数)
    int free;   // 剩余可用空间的字节数
    char buf[]; // 实际存储字符串的字节数组
};

SDS结构如上所示,它有以下优点:

  • len记录了字符串长度,获取长度时间复杂度为O1。
  • free记录了已分配空间,修改时可以判断内存是否足够,若不足则自动扩容。
  • sds还有空间预分配机制和惰性释放机制。
    • 扩容时Redis不仅会为sds分配修改必要的空间,还会为其预分配空间,避免频繁的内存分配。
    • 缩容时Redis也不会直接释放内存,而是将不使用的内存记录下来,下次需要扩容时直接使用。

String的应用场景包括:缓存对象(序列化),分布式锁,分布式session,计数器(value可以存储整数)。

List

底层使用双向链表+压缩列表(数据量少时)。

  • 为什么使用压缩链表
    • 链表节点在内存中不连续,无法利用CPU缓存(遍历慢),且前后指针占用空间大(数据量小时不划算)。
    • 压缩列表占用连续内存空间,能高效利用CPU缓存,且不保存前后指针,只保存前一个节点长度,节省内存(注意与数组的区别在于数组的每个值占用固定大小的空间)。
  • 压缩列表结构如下
    // 表头
    zlbytes:压缩列表的总字节数。
    zltail:记录表尾距离表头的偏移量。 
    zllen:记录包含节点数量。
    // 节点1
    entry1
    // 节点2
    entry2
    // 结束标志
    zlend
    
  • 压缩列表节点结构如下
    prevlen:记录前一个节点长度实现从后向前遍历(当前节点知道前一个节点长度就可以计算出其位置)。
    encoding:记录当前节点数据类型和长度。
    data:记录当前节点实际数据。
    // 注意prevlen和encoding字段的编码方式根据存储的数据类型(整数或字符串)和长度动态调整。
    
  • 压缩列表为什么不能保存过多元素
    • 数据量大时查询效率降低,因为需要不断向后计算节点位置,平均查询复杂度为On。
    • 数据量大时修改效率降低,因为需要移动大量元素。
  • 连锁更新问题
    • 压缩列表更新元素时导致当前节点长度变大(重新分配空间),后续元素的位置都要移动,同时后续元素记录了prevlen,故后续元素也可能变大从而导致继续重新分配空间。
  • quickList
    • 双向链表+压缩列表,即链表中每一个元素是压缩列表,通过控制每个链表节点中的压缩列表大小来规避连锁更新问题,即控制连锁更新的节点数。
  • listpack
    • 连锁更新问题是因为zipList记录了前一个节点的长度,一个节点的更新可能影响后续节点,所以listpack不记录前一个节点的长度,而是记录当前节点的长度,当前节点的更新只会影响他自己的长度而不会影响别的节点。

List可用于实现简单的消息队列

  • 使用lpush+rpop保证消息顺序。
  • 使用brpop实现阻塞读取(读线程直到队列中有数据才读)。
  • 生成唯一id处理重复消息。
  • 使用brpoplpush保证消息可靠性,即从列表1读取消息后再插入列表2,作为备份。
  • 但是List不支持多个消费者消费同一个消息(使用发布订阅模式可实现)。

Hash

Hash底层是HashTable+ziplist(数据量少时)。

  • HashTable实际就是一个数组,数组中每个元素是一个哈希桶,使用拉链法来解决哈希冲突。
  • Redis会定义两个Hash表,一个用来实际存数据,一个用来扩容。
    • 扩容时给表2分配2倍表1的空间,将表1数据渐进式地迁移到表2,释放表1空间。
    • 渐进式的迁移数据指rehash期间,每次hash表进行操作时都会将对应索引位置上的kv迁移到表2,直到完成迁移,这个过程中表1和表2同时在工作,即查数据时先去表1查再去表2查。

Hash的使用场景: 哈希表可实现购物车,用户id+购物车id为key,商品id为Hash表的key,商品数量为Hash表的value。

Set

Set底层是HashTable+整数数组(元素较少并且都是整数类型时)。

整数数组元素唯一,有序,可以使用二分查找。

Set的使用场景:

  • 点赞操作(key为文章id,value为用户id,set中存在用户id说明用户点赞过,且用户id不重复,即只能点赞一次)。
  • 共同好友(key为用户id,value为好友id,对两个set取交集,但是注意取交集操作时间复杂度高,可能会阻塞redis,故一般在客户端完成)。

ZSet

ZSet底层是跳表+ziplist(元素较少时)。

  • 跳表是可以实现二分查找的有序链表。
    • 链表本身无法进行二分查找,故提取出链表中关键节点(索引)形成上层链表,先在上层链表查找,再进入下层链表查找。
    • 跳表的优点是查数据快(Ologn),缺点是更新时需要更新索引,维护成本高,空间复杂度On。

image.png

  • 跳表节点如何实现多层级:

    • 跳表节点保存了元素值,权重值,前后指针;同时有一个level数组,数组中每一个元素代表了跳表的一层(当前节点),另外还记录了跨度,用来计算某个节点的排位。
    • 跳表相邻两层的节点数量会影响跳表的查询性能,最理想的比例是2:1。但是如果新增节点时强行调整节点比例的话会带来额外开销,所以Redis在创建节点时随机生成每个节点的层数。
  • 为什么用跳表而不是用平衡树:

    • 从内存占用来说,跳表更灵活,平衡树每个节点固定使用两个指针,但是跳表每个节点平均使用的指针更少。
    • 在做范围查找时跳表更简单。
    • 平衡树的插入和删除需要旋转操作,而跳表只需要修改相邻节点指针。

ZSet的使用场景:

  • zset每个value前有score,用于排序。可实现排行榜,最新列表(按时间排序)。
  • zset还可以实现延迟队列。
    • 将任务添加到zset中,score为任务的执行时间戳,value为任务信息。
    • 定期从zset中获取score小于当前时间戳的任务并执行。
    • 将执行过的任务删除。

其他数据类型

  • bitmap:一串连续的二进制数组,通过偏移量定位元素,以比特为单位保存二值数据,非常节省空间,适用于一些大数据量的二值统计场景。bitmap可实现签到统计。bitmap+数组可以实现布隆过滤器。
  • HyperLogLog:底层由String实现,在输入元素的数量非常大时,计算基数所需空间是固定的,但是有微小误差。Hyperloglog可实现网页uv统计。
  • GEO:底层是zset,即每个值之前加上经纬度来表示位置。