Redis为什么这么快?
- Redis是单线程的,避免了多线程的上下文切换和并发控制开销;
- Redis大部分操作时基于内存,读写数据不需要磁盘I/O,所以速度非常快;
- Redis采用了I/O多路复用机制,提高了网络I/O并发性;
- Redis提供高效的数据结构,如跳跃表、哈希表等;
基础数据结构
SDS
Redis的简单动态字符串SDS是可变的,遵循C字符串以1字节空字符结尾,最大长度为512M。
SDS为什么使用1字节空字符结尾呢?
使用1字节空字符结尾可重用C字符串的部分函数。
结构定义
SDS底层使用一个字节数组保存字符串内容,通过len属性可O(1)的复杂度获取字符串长度。
struct sdshdr{
//字符串长度,即buf[]已使用字节数
int len;
//buf[]剩余字节数
int free;
//字节数组,用于保存字符串内容
char buf[];
};
内存分配策略
SDS采用空间预分配和惰性空间释放来优化SDS的内存分配次数(n次 → 最多n次)。
- 空间预分配
空间预分配用于优化字符串的增长操作。当修改SDS需要扩展内存空间时,不仅会分配所需的空间,还会根据len属性分配额外的未使用空间。
- 如果修改SDS后,len < 1MB,将分配和len同样大小的未使用空间;
- 如果修改SDS后,len > 1MB,将分配1MB的未使用空间;
- 惰性空间释放
惰性空间释放用于优化SDS缩短操作。当缩短SDS时,程序不立即回收未使用的空间,使用free记录未使用空间长度,等待将来使用。(也可调用函数手动释放空间)
SDS和C字符串的区别
- 获取字符串长度
- C字符串需要遍历整个字符串计数统计长度,时间复杂度为O(n);
- SDS只需要获取sdshdr.len即可,时间复杂度为O(1);
- 缓冲区溢出
- C字符串不记录自身长度,在进行修改时如果没有分配足够的内存可能造成缓冲区溢出;
- SDS在修改时会先根据sdshdr.free属性校验内存是否足够,如果不够会先进行扩容,再执行修改操作;
- 二进制安全
- C字符串除末尾之外不能包含空字符,否则最先被读入的空字符会被误认为是字符串结尾;(所以C字符串只能保存文本数据,不能用于保存二进制数据)
- SDS通过sdshdr.len判断字符串是否结束,可以用于保存二进制数据。
- 内存分配次数
- C字符串每次修改操作都需要进行内存重分配;
- SDS需要最多n次内存重分配;
链表
特点
- 双向链表:获取某个节点的前驱节点和后继节点复杂度O(1);
- 无环:头节点前驱指针和尾节点后继指针指向NULL;
- 插入和删除快,时间复杂度O(1);查找慢,时间复杂度O(n);
结构定义
节点定义:
typedef struct listNode{
//前驱节点指针
struct listNode *prev;
//后继节点指针
struct listNode *next;
//节点值
void *value;
}listNode;
链表定义:
typedef struct list {
//头节点
listNode *head;
//尾节点
listNode *tail;
//复制节点值函数
void *(*dup)(void *ptr);
//释放节点值函数
void (*free)(void *ptr);
//比对节点值函数
int (*match)(void *ptr, void *key);
//节点数量
unsigned long len;
} list;
字典
特点
字典用于保存键值对,包含的每个键都是唯一的。
结构定义
字典ht[2]是一个包含两个哈希表的数组,一般情况下只使用ht[0],只有在rehash时会使用ht[1]。dict.rehashidx记录目前rehash进度。
typedef struct dict {
//特定类型操作函数
dictType *type;
//私有数据(传给特定类型操作函数的参数)
void *privdata;
//哈希表
dictht ht[2];
//rehash索引,没有在进行rehash时rehashidx=-1
long rehashidx;
} dict;
哈希表底层基于一个dictEntry数组实现,每一项保存一个键值对,哈希到同一个数组项的节点通过next连接。
typedef struct dictht {
//哈希表数组
dictEntry **table;
//哈希表大小
unsigned long size;
//哈希表大小掩码,用于哈希函数计算索引值,sizemask=size-1
unsigned long sizemask;
//已有节点数量
unsigned long used;
} dictht;
typedef struct dictEntry {
//键
void *key;
//值
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
//下一个节点
struct dictEntry *next;
} dictEntry;
哈希过程
- 根据哈希函数计算键值对键的哈希值;
- 根据哈希值对哈希表掩码dictht.sizemask取模计算索引值;
- 根据索引值将键值对放入哈希表数组的对应索引位置上;
如何解决哈希冲突?
使用链地址法解决哈希冲突。每个哈希表节点dictEntry都保存一个next指针,得分配到同一个数组项的节点通过next指针连接成一个单向链表。
rehash过程
- 为ht[1]分配空间:如果是扩容操作,ht[1]大小等于第一个≥ht[0].used*2的2n;如果是收缩操作,ht[1]大小等于第一个≥ht[0].used的2n;
- 将ht[0]上的所有键值对rehash(重新计算键的哈希值和索引值)后放入ht[1];——渐进式rehash:rehash期间每次对字典执行增/删/改/查操作都会将ht[0]在rehashidx索引上的所有键值对rehash到ht[1],rehashidx++;
- 当ht[0]上的所有键值对都迁移到ht[1]后,释放ht[0],将ht[1]设置为ht[0],新建一个ht[1]为下次rehash做准备;
1. 什么时候会触发rehash?
根据哈希表的负载因子(dictht.used/dictht.size),在哈希表过大或过小时会触发rehash:
- 当服务器没有在执行BGSAVE/BGWRITEAOF命令时,哈希表的负载因子≥1;
- 当服务器正在执行BGSAVE/BGWRITEAOF命令时,哈希表的负载因子≥5;
2. 渐进式rehash的好处
将rehash操作分摊到字典的每个增/删/改/查操作上,避免集中rehash带来庞大的计算量而导致服务器停顿。
3. rehash过程中的查找/插入操作
- 查找:在rehash过程中会同时使用ht[0]和ht[1],如果要查找某个key,会先在ht[0]中查找,如果没找到,继续在ht[1]中查找。
- 插入:在rehash过程中新增的键值对会被保存到ht[1],保证了ht[0]的键值对数量只减不增。
跳跃表
特点
跳跃表基于分值从小到大排序,查找的过程近似二分查找。
结构定义
跳跃表基于有序链表实现,通过在链表的基础上增加多级索引提升查找的效率。跳跃表每一层都是一个链表,最底层链表包含所有元素,链表的每个节点包含2个指针,一个forward指针指向同一链表中该节点的下一个节点,一个backward指向同一链表中该节点的前一个节点。
typedef struct zskiplist {
//头节点、尾节点
struct zskiplistNode *header, *tail;
//跳跃表长度(包含的节点数量)
unsigned long length;
//跳跃表内层数最大的节点的层数
int level;
} zskiplist;