Redis数据结构
SDS
redis的String没有使用C语言的字符串来表示,而是自己构建了名为SDS(simple dynamic string)的结构体。
SDS定义
typedef struct sdshdr {
// buf数组中已使用字节长度
int len;
// buf数组中未使用字节长度
int free;
// 保存字符串的字节数组
char buf[];
} sdshdr;
相较于C字符串,使用SDS的好处
C字符串实际上是长度为n+1的字符数组,n为字符长度,最后一位总是空字符'\0',用于标记一个字符串的结束。
- 以常数复杂度获取字符串长度 因为C字符串是一个数组,获取字符串长度需要整个遍历,时间复杂度为O(N)。
而SDS维护了len属性,可直接获取长度,时间复杂度O(1)。这确保了获取字符串长度不会成为redis的性能瓶颈。
- 杜绝缓冲区溢出 针对C字符串,API不安全,可能造成缓冲区溢出。例如C语言中字符串拼接函数strcat,其假定用户在执行这个函数时,已经分配了足够多的内存。
假设字符串s1和s2在内存中紧邻,在s1后拼接字,若事先未给s1申请内存,其拼接的数据将溢出到s2所在空间。
而SDS维护了长度,在长度不足时会自动扩容
- 减少修改字符串长度时内存分配开销 C字符串长度变更都要重新申请、释放内存。
SDS维护了free属性,避免频繁申请内存。
扩容机制:
- 若len小于1MB,free的长度与len的长度相同,那么SDS的buf数组长度为len+free+1B('\0')字节。注意,buf数组也是以空字符结尾,这样可以利用C内置的字符串函数。
- 若len大于1MB,free的长度为1MB,buf数组长度len+1MB+1B
惰性空间释放: 若字符串长度缩小,则free属性增大,len熟悉减少,并未立即释放内存,以供未来的字符串增长使用。当有需要时,可以使用SDS的API释放这些内存。
- 二进制安全 因为C字符串以'\0'来表示字符串结尾,所以不能保存文件数据。
而SDS用len判断字符串结尾。
双向链表
redis中的List底层实现之一就是双向链表。
双向链表定义
typedef struct listNode {
// 前置节点
struct listNode *prev;
// 后置节点
struct listNode *next;
// 节点的值
void *value;
} listNode;
typedef struct list {
// 表头节点
listNode *head;
// 表尾节点
listNode *tail;
// 链表节点数量
unsigned long len;
// 除此之外还有节点复制、释放、比对函数,略。
}
双向链表比较简单,这里不多赘述。
哈希表
Hash、Set的底层实现之一就是哈希表。
哈希表定义
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
// 总是等于size-1
unsigned long used;
} dictht;
typedef struct dict {
// 类型特定函数
// 保存了一组用于操作特定类型键值对的函数
dictType *type;
// 用于保存给type属性特定函数的可选参数
void *privdata;
// 哈希表
dictht ht[2];
// rehash索引,当rehash不在进行时,值为-1
int trehashidx;
} dict;
rehash
由dict结构可知,字典用长度为2的哈希表数组ht来存储哈希表。ht[0]用与保存数据,ht[1]用于在rehash时扩容复制。 rehash机制如下:
- 扩容或所容,扩容:为哈希表ht[1]分配空间,大小为第一个大于等于h[0].used(以有结点数量)*2的2^n。缩容:h[1]大小为第一个大于等于h[0].used的2^n
- 将ht[0]中的键重新哈希映射迁移到ht[1]
- 迁移完毕后,释放ht[0],将h[1]置为h[0],并在ht[1]重新创建一个空哈希表,为下次rehash做准备。
rehash的时机
负载因子 = 以保存节点数量/哈希表大小
load_factor = ht[0].used / ht[0].size
- 服务器当前没有执行BGSAVE命令或BGREWRITEAOF命令,并且负载因子大于等于1.
- 服务器当前正在执行BGSAVE命令或BGREWRITEAOF命令,并且负载因子大于等于5.
BGSAVE:Redis Bgsave 命令用于在后台异步保存当前数据库的数据到磁盘。 BGSAVE 命令执行之后立即返回 OK ,然后 Redis fork 出一个新子进程,原来的 Redis 进程(父进程)继续处理客户端请求,而子进程则负责将数据保存到磁盘,然后退出。
BGREWRITEAOF:Redis Bgrewriteaof 命令用于异步执行一个 AOF(AppendOnly File) 文件重写操作。重写会创建一个当前 AOF 文件的体积优化版本。AOF 重写由 Redis 自行触发, BGREWRITEAOF 仅仅用于手动触发重写操作。
大多数操作系统都采用写时复制技术来优化子进程的使用效率,所以在子进程存在期间调高触发扩容所需的负载因子,尽量避免在子进程存在期间进行扩容。
另外,当哈希表负载因子小于0.1时,自动开始对哈希表执行收缩操作。
渐进式rehash
若哈希表数据很多,一次性将所有键值对全部迁移到ht[1]必然会影响性能。所以在rehash进行期间,每次哈希表的CRUD操作会顺带完成部分迁移操作,这样rehash的迁移工作均摊到每次CRUD操作上。当然,在rehash期间,若没有命令执行,也会以定时任务的方式一部分一部分地进行rehash,每次执行不会超过1ms,以免造成性能影响。
skipList
结构体
redis只在两个地方用到了跳表,一个是实现有序集合SortedSet,另一个是在集群节点中用作内部数据结构。
跳表平均时间复杂度O(logN),最坏复杂度O(N)
跳表主要由zskiplist和zskiplistNode两个结构组成
typedef struct zskiplist{
// 表头结点和末尾节点
struct zskiplistNode *head, *tail;
// 表中结点数量
unsigned long length;
// 表中节点中最大的层数
int level;
} zskiplist;
typedef struct zskiplistNode {
// 后退指针
struct zskiplistNode *backword;
// 分值
double score;
// 成员对象,redisObject类型
robj *obj;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;
下图即跳表的实现,描述了zskiplist、zskiplistNode以及层之间的关系
上面对结构体有了大致了解,下面对结构体中的核心概念做详细说明
层
上述结构体中的level[],层即跳表中结点间跳跃的入口。每个节点可以有多个层来指向其他的节点。
每次创建一个跳表节点时,程序根据幂次定律(越大的数出现的概率越小)随机生成一个介于1和32之间的值作为level数组的大小。
其中首节点不存储元素值,是一个层大小为32的空节点。
每一层指向下一个它的同一层。比如L1指向下一个带L1层的结点,L2指向下一个带L2层的节点。又因为层是基于幂次定律随机生成,即越高层越难生成,从而形成一种越低层越连续,越高层越跳越的结构。
层中有两个属性前进指针level[i].forword和跨度level[i].span。前进指针即指向下一个结点的指针。下面详细说下跨度
跨度
层的跨度(level[i].span)即两个结点间距离。
到某一个节点的跨度和即为排名。同时因为跨度的存储,按照排名查询时可以机遇当前可跳转的层及跨度跳跃查询而不用依次遍历。可以说基于前进节点和跨度的机制,跳表实现了rank的快速查询。
后退指针、分数和成员
- 后退指针backward属性,指向前一个结点,便于当前节点向前查找
- 分值score属性,是一个double类型的浮点数,跳表节点顺序按分值从小到大排序
- 成员obj属性,即member,它指向一个字符串对象,其内容就是就是一个sds值。obj结构体类型是redisObject,redis用于存储对象的结构体。
ziplist
压缩列表是List、Hash底层实现之一。当列表只有少量数据且每个元素是小整数值或短字符串,那么redis就会使用压缩列表做list或hash的底层实现。
压缩列表是一种为节约内存和效率而开发的顺序性数据结构。因为其存储内容是连续的,数据从内存加载到缓存也比较快。
下面总结点其他的。。。
缓存雪崩、击穿、穿透
缓存雪崩
大量缓存数据同时过期或缓存实例发生故障,导致大量请求无法得到处理,大量请求直接打到数据库,导致数据库压力激增。
应对方案:
- 提前预防。在做系统设计时,要考虑到可能遇到的缓存雪崩,避免大量缓存同时过期;保证redis实例的高可用。
- 服务监控及降级。遇到相关服务、数据库CPU告警,利用限流组件进行限流(比如限流框架sentinal)。
缓存击穿
与缓存雪崩相似,针对某个访问非常频繁的热点数据过期,导致访问该热点数据的请求全部打到数据库。
应对方案:也是在设计时提前预防。这类数据根业务场景有关,最好就不要加失效时间了。
缓存穿透
数据在缓存和数据库中都不存在,请求依然都会打到数据库上。比如恶意攻击。
应对方案:利用布隆过滤器。比如redis层有redisson的,Java层有guava、hutool等布隆过滤器的实现。
更新中。。。