Redis基础概念
Redis与MySQL
- Redis是kv键值对数据库,数据存储在内存中,读写性能好,适用于做缓存。
- MySQL是关系型数据库,数据存储在磁盘中,读写性能较低,提供事务,索引,范围查询等机制,适用于存储大量数据以及复杂查询场景。
Reids使用场景
缓存,分布式锁,分布式session,消息队列,排行榜,计数器,社交场景。
Redis为什么快?
- 数据基于内存存储,存取数据时没有磁盘IO开销。
- 单线程执行读写命令,避免线程切换和竞争。
- 网络事件处理模型使用了IO多路复用(epoll函数)。
- 提供许多优化后的数据结构。
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数据类型与数据结构
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。
-
跳表节点如何实现多层级:
- 跳表节点保存了元素值,权重值,前后指针;同时有一个level数组,数组中每一个元素代表了跳表的一层(当前节点),另外还记录了跨度,用来计算某个节点的排位。
- 跳表相邻两层的节点数量会影响跳表的查询性能,最理想的比例是2:1。但是如果新增节点时强行调整节点比例的话会带来额外开销,所以Redis在创建节点时随机生成每个节点的层数。
-
为什么用跳表而不是用平衡树:
- 从内存占用来说,跳表更灵活,平衡树每个节点固定使用两个指针,但是跳表每个节点平均使用的指针更少。
- 在做范围查找时跳表更简单。
- 平衡树的插入和删除需要旋转操作,而跳表只需要修改相邻节点指针。
ZSet的使用场景:
- zset每个value前有score,用于排序。可实现排行榜,最新列表(按时间排序)。
- zset还可以实现延迟队列。
- 将任务添加到zset中,score为任务的执行时间戳,value为任务信息。
- 定期从zset中获取score小于当前时间戳的任务并执行。
- 将执行过的任务删除。
其他数据类型
- bitmap:一串连续的二进制数组,通过偏移量定位元素,以比特为单位保存二值数据,非常节省空间,适用于一些大数据量的二值统计场景。bitmap可实现签到统计。bitmap+数组可以实现布隆过滤器。
- HyperLogLog:底层由String实现,在输入元素的数量非常大时,计算基数所需空间是固定的,但是有微小误差。Hyperloglog可实现网页uv统计。
- GEO:底层是zset,即每个值之前加上经纬度来表示位置。