1.取最新N个数据的操作(利用list最新放redis,其他数据从数据库直接读取)
2.排行榜(利用sorted set,将你要排序的值设置成 sorted set的score,将具体的数据设置成相应的value,只需增加数据,redis自动排序)
3.需要精准设定过期时间的应用(sorted set的score值设置成过期时间的时间戳,那么就可以简单地通过过期时间排序,定时清除过期数据)
4.计数器(命令原子性,你可以轻松地利用 incr,decr等命令来构建计数器系统。)
5.缓存
6.实时消息系统(发布与订阅)
7.队列系统(使用 list 可以构建队列系统,使用 sorted set 甚至可以构建有优先级的队列系统)
8.数据排重(Set)
9.实时系统
...
动态字符串:
Redis没有使用传统的C字符串,构建简单的动态字符串(simple dynamic string,sds),并将其作为redis的默认字符串表示。
sds字符串的内存结构:
/*
* 保存字符串对象的结构
*/
struct sdshdr {
// buf 中已占用空间的长度
int len;
// buf 中剩余可用空间的长度
int free;
// 数据空间
char buf[];
};
示例:
优点:
1. C语言使用长度为N+1的字符数组来表示长度为N的字符串,并且字符数组的最后一个元素总是空字符‘\0’。因为C字符串并不记录自身的长度信息,所以获取长度需要遍历整个字符串数组,时间复杂度为O(N),而Redis的动态字符串则记录了其长度,时间复杂度降为O(1),因为字符串键底层使用SDS来实现,所以对一个字符串反复STRLEN,也不会造成性能影响。
2. C字符串容易造成缓冲区溢出,比如s1 = "reids"; s2 = "mongodb";在内存中s1和s2字符串紧挨着分配,而此时执行了strcat(s1, "good");good字符串数据将溢出到s2所在空间,导致s2保存的内容被意外更改了。而SDS字符串空间分配策略会杜绝发生缓冲区溢出可能性:当SDS的API要对SDS串进行修改时,API会先检查SDS空间是否满足修改需求,如果不满足的话API会自动将SDS空间扩展至执行修改所需的大小,然后才执行修改操作。
/*
* 将给定字符串 t 追加到 sds 的末尾
*
* 返回值
* sds :追加成功返回新 sds ,失败返回 NULL
*
* 复杂度
* T = O(N)
*/
sds sdscat(sds s, const char *t) {
return sdscatlen(s, t, strlen(t));
}
sds sdscatlen(sds s, const void *t, size_t len) {
struct sdshdr *sh;
// 原有字符串长度
size_t curlen = sdslen(s);
// 扩展 sds 空间
// T = O(N)
s = sdsMakeRoomFor(s,len);
// 内存不足?直接返回
if (s == NULL) return NULL;
// 复制 t 中的内容到字符串后部
// T = O(N)
sh = (void*) (s-(sizeof(struct sdshdr)));
memcpy(s+curlen, t, len);
// 更新属性
sh->len = curlen+len;
sh->free = sh->free-len;
// 添加新结尾符号
s[curlen+len] = '\0';
// 返回新 sds
return s;
}
3. C字符串长度和底层数组的长度之间存在关联性,每次增长或缩短一个C字符串,程序都要对内存进行重分配操作:
1)如果程序执行的是字符串增长操作,在执行操作之前,需要通过内存重分配扩展底层数组大小,如果忘记操作则会缓冲区溢出。
2)如果程序执行的是字符串缩短操作,在执行操作之前,需要通过内存重分配释放不再使用的内存空间,如果忘记操作则会内存泄露。
而SDS则使用free属性记录未使用空间解除C字符串长度和底层数组的长度之间的关联性。
空间预分配
用于优化字符串的增长操作,当SDS的API对一个SDS进行修改,并且需要对SDS的空间进行扩展的时候,程序不仅会为SDS分配必须的空间,还会为SDS分配额外的空间。
分配策略
+ 如果对SDS进行修改后,SDS的长度将小于1MB,程序分配和len属性同样大小的未使用空间;
+ 如果对SDS进行修改后,SDS的长度将大于等于1MB,程序分配1MB的未使用空间。
惰性空间释放
用于优化字符串的缩短操作,当SDS的API对一个SDS进行缩短时,程序并不立即使用内存重分配来回收多出来的字节,而是使用free属性将这些字节的数量记录起来,并等待下次使用。
4. C字符串的字符必须符合某种编码,出字符串末尾外,字符串里面不能包含空字符,否则最先被读取的空字符被认为是字符串的结尾标识,这些限制使C字符串仅限于文本数据,而不能处理图片,音频,视频,压缩文件等数据。SDS的buf数组是字节数组,redis用它保存二进制安全数据,但该数组末尾也遵循C字符串空字符结尾惯例,从而方便使用C语言的库函数。
链表:
链表的内存结构:
/*
* 双端链表节点
*/
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;
示例:
字典:
字典是一种用于保存键值对的抽象数据结构。字典的内存结构:
/*
* 哈希表
*
* 每个字典都使用两个哈希表,从而实现渐进式 rehash 。
*/
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
// 哈希表大小掩码,用于计算索引值
// 总是等于 size - 1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
} dictht;
/*
* 哈希表节点
*/
typedef struct dictEntry {
// 键
void *key;
// 值
union {
void *val;
uint64_t u64;
int64_t s64;
} v;
// 指向下个哈希表节点,形成链表
struct dictEntry *next;
} dictEntry;
/*
* 字典
*/
typedef struct dict {
// 类型特定函数
dictType *type;
// 私有数据
void *privdata;
// 哈希表
dictht ht[2];
// rehash 索引
// 当 rehash 不在进行时,值为 -1
int rehashidx; /* rehashing not in progress if rehashidx == -1 */
// 目前正在运行的安全迭代器的数量
int iterators; /* number of iterators currently running */
} dict;
示例:
哈希算法
当要将一个新的键值添加到字典里面时,程序需要先根据键值对的键计算出哈希值和索引值,然后再根据索引值,将包含新键值对的哈希表节点放到指定索引上。redis目前使用Murmurhash2算法来计算哈希值(参考链接Murmurhash算法参考)。
hash = dict->type->hashFunction(key);
index = hash & dict->ht[x].sizemask;
解决键冲突
1)当有两个以上的键被分配到哈希数组同一个索引上时,我们称发生了哈希冲突,redis的哈希表使用链地址法解决键冲突。通过dictEntry结构中的*next属性指针实现。
2)随着操作的不断执行,哈希表保存的键值对会逐渐的增多或减少,为了让哈希表的负载因子维持在一个合理的范围内,当保存的键值对数量太多或太少时,程序需要对哈希表的大小进行相应的扩展和收缩,这个工作通过rehash来实现。
重哈希步骤:
+ 为字典的ht[1]哈希表分配空间,这个哈希表空间的大小取决于要执行的操作,以及ht[0]当前所包含的键值对数量。
- 如果执行的是扩展操作,那么ht[1] = 第一个大于等于 ht[0].used * 2 的 2^n;
- 如果执行的是收缩操作,那么ht[1] = 第一个大于等于 ht[0].used 的 2^n;
+ 将保存在ht[0] 上的所有键值对rehash到ht[1]上。
即重新执行计算哈希值和索引值,将键值对放到ht[1]指定索引位置上。
+ 当ht[0]上的值全部迁移完毕,释放ht[0],将ht[1]设置为ht[0],并在ht[1]上创建空白哈希表,为下一次rehash做准备。
重哈希举例:
字典当前存储如上图,此时进行扩展操作,ht[0].used的当前值为4, 4 * 2 = 8,而8恰好是第一个大于等于4*2的2的n次幂,所以程序会将ht[1]哈希表的大小设置为8。分配后如下图:
将ht[0]包含的键值对重哈希到ht[1]哈希表上后,如下图所示:
释放ht[0],将ht[1]设置为ht[0],然后为ht[1]创建一个空白哈希表,如下图所示。执行完毕后,哈希表的大小也由原来的4变成8了。
哈希表的扩展与收缩:
扩展操作执行条件:
1)当服务器目前没有执行BGSAVE或BGREWRITEAOF命令,并且哈希表的负载因子大于等于1.
2)当服务器正在执行BGSAVE或BGREWRITEAOF命令,并且哈希表的负载因子大于等于5.
负载因子load_factor = ht[0].used / ht[0].size;执行BGSAVE或BGREWRITEAOF命令时,服务器存在子进程,需要提高扩展操作所需的负载因子,从而尽量避免在rehash操作,减少不必要的内存写入。
收缩操作执行条件:
哈希表的负载因子小于0.1.
redis的rehash采用渐进式重哈希,避免一次性,集中式的重哈希操作导致的服务器阻塞。通过字典结构中的rehashidx字段实现。
渐进式重哈希步骤:
1) 当ht[1]哈希表分配空间后,将字典中的rehashidx字段设置为0.重哈希操作正式开始,
2) 在重哈希操作执行时,每次对字典进行CRUD操作时,除了执行相应操作外,会顺带将ht[0]上在reashidx索引上的所有键值对迁移到ht[1]上,当本次rehash完成时,程序将字典中的rehashidx属性的值加1,
3) 随着字典操作的不断执行,最终在某个时间点,ht[0]的所有键值对被迁移到ht[1]上,这是程序将字典中的rehashidx属性设为-1.表示rehash操作完成。
跳跃表
跳跃表是一种有序的数据结构,通过在每个节点中维护多个指向其他节点的指针,从而实现快速访问。
被用于有序集合的底层实现。
跳跃表的内存结构
/*
* 跳跃表节点
*/
typedef struct zskiplistNode {
// 成员对象
robj *obj;
// 分值
double score;
// 后退指针
struct zskiplistNode *backward;
// 层
struct zskiplistLevel {
// 前进指针
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;
/*
* 跳跃表
*/
typedef struct zskiplist {
// 表头节点和表尾节点
struct zskiplistNode *header, *tail;
// 表中节点的数量
unsigned long length;
// 表中层数最大的节点的层数
int level;
} zskiplist;
示例:
在同一个跳跃表中,各个节点保存的成员对象必须是唯一的,但是各个节点保存的分值却可以是相同的,分值相同的节点将按照成员对象在字典中的大小来进行排序。
整数集合
当一个集合中只包含整数值的元素,并且这个集合的元素数量不多时,redis就会使用整数集合作为集合键的底层实现。
整数集合的内存结构
typedef struct intset {
// 编码方式
uint32_t encoding;
// 集合包含的元素数量
uint32_t length;
// 保存元素的数组(有序不重复)
int8_t contents[];
} intset;
虽然将contents属性声明为int8_t类型的数组,但是实际上并不保存int8_t类型的值,真正类型取决于encoding的值。
- 如果encoding属性的值是INTSET_ENC_INT16,那么contents属性就是一个int16_t类型的数组。[-32768 ~ 32767]
- 如果encoding属性的值是INTSET_ENC_INT32,那么contents属性就是一个int32_t类型的数组。[-2^32 ~ 2^32 - 1]
- 如果encoding属性的值是INTSET_ENC_INT64,那么contents属性就是一个int64_t类型的数组。
示例
整数集合升级
当新元素类型比整数集合现有元素的类型都要长时,整数集合先进行升级,然后才将新元素添加 到整数集合里。
1)根据新元素的类型,扩展整数集合底层contents空间大小,并为新元素分配空间;
2)将底层数组现有所有元素都转换成新元素类型,并将转换后的元素放入正确的位置,保证有序性;
3)将新元素添加到contents里。
每次新增元素都有可能引起升级,每次升级都要对底层所有元素进行类型转换,所以向整数集合添加新元素时间复杂度是O(N)。
优点:提升灵活性(避免C类型错误),节约内存
整数集合不支持降级
压缩列表
当一个列表只包含少量的列表项,并且每个列表项要么是小整数值,要么是长度比较短的字符串,那么redis就使用压缩列表作列表的底层实现,哈希键亦是如此。
压缩列表是redis为节约内存开发的,是由一系列特殊编码的连续内存块组成的顺序型数据结构。
可使用object encoding命令查看底层实现类型。
压缩列表内存结构
/*
空白 ziplist 示例图
area |<---- ziplist header ---->|<-- end -->|
size 4 bytes 4 bytes 2 bytes 1 byte
+---------+--------+-------+-----------+
component | zlbytes | zltail | zllen | zlend |
| | | | |
value | 1011 | 1010 | 0 | 1111 1111 |
+---------+--------+-------+-----------+
^
|
ZIPLIST_ENTRY_HEAD
&
address ZIPLIST_ENTRY_TAIL
&
ZIPLIST_ENTRY_END
非空 ziplist 示例图
area |<---- ziplist header ---->|<----------- entries ------------->|<-end->|
size 4 bytes 4 bytes 2 bytes ? ? ? ? 1 byte
+---------+--------+-------+--------+--------+--------+--------+-------+
component | zlbytes | zltail | zllen | entry1 | entry2 | ... | entryN | zlend |
+---------+--------+-------+--------+--------+--------+--------+-------+
^ ^ ^
address | | |
ZIPLIST_ENTRY_HEAD | ZIPLIST_ENTRY_END
|
ZIPLIST_ENTRY_TAIL
*/
示例
entry结构:
prev_entry_bytes_length:
表示上个节点所占的字节数,即上个节点的长度,如果需要跳到上个节点,而已知道当前节点的首地址p,上个节点的首地址prev = p-prev_entry_bytes_length
根据编码方式的不同,prev_entry_bytes_length可能占1 bytes或5 bytes:
encoding与length:1 bytes:如果上个节点的长度小于254,那么就只需要1个字节;
5 bytes:如果上个节点的长度大于等于254,那么就将第一个字节设为254(1111 1110),然后接下来的4个字节保存实际的长度值;
ziplist的编码类型分为字符串、整数
encoding的前两个比特位用来判断编码类型是字符串或整数:
00, 01, 10表示contents中保存着字符串
11表示contents中保存着整数
连锁更新
添加和删除节点都会造成连锁更新,造成性能影响,但是真正造成性能问题的几率非常低,首先需要恰好有多个连续、长度介于250~253byte之间的结点,才有可能引发,其次即使出现,更新的数量不多时也不会造成性能影响,压缩列表存储空间连续便于载入内存,能减少保存指针对内存的消耗,因此它被用于list,set等的基础实现,例如:
list-max-ziplist-entries 512 #最大的元素个数
list-max-ziplist-value 64 #字符串的最大长度
对象
redis使用对象来表示数据库中的键和值,每次我们在数据库中创建一个新的键值对的时候,我们至少要创建两个对象。一个是键对象,一个是值对象。
可使用type命令查看对象类型。
对象的内存结构
typedef struct redisObject {
// 类型
unsigned type:4;
// 编码
unsigned encoding:4;
// 对象最后一次被访问的时间
unsigned lru:REDIS_LRU_BITS; /* lru time (relative to server.lruclock) */
// 引用计数(引用计数实现内存回收机制)
int refcount;
// 指向实际值的指针
void *ptr;
} robj;
raw字符串对象示例
embstr字符串对象示例
embstr编码是专门用于保存短字符串的优化解决方案,用于创建长度小于等于32字节的字符串值,raw编码创建字符串对象时,会调用两次内存分配函数来分别创建redisObject和sdshdr结构,而embstr仅调用一次,分配连续空间来分别创建redisObject和sdshdr结构。内存释放亦是如此,而且embstr更容易载入内存。
ziplist存储的list对象示例
链表存储的list对象示例
#编码转换条件 ziplist -> list
list-max-ziplist-entries 512 #最大的元素个数
list-max-ziplist-value 64 #字符串的最大长度
ziplist存储的哈希对象示例
字典存储的哈希对象示例
#编码转换条件 ziplist -> dict
hash-max-ziplist-entries 512 #最大的元素个数
hash-max-ziplist-value 64 #字符串的最大长度
inset编码的集合对象示例
hashtable编码的集合对象示例
#编码转换条件 intset-> dict
intset:集合元素保存的所有元素都是整数,保存的元素数量不超过512个。
set-max-ziplist-entries 512 #最大的元素个数
ziplist存储的有序集合对象示例
skiplist存储的有序集合对象示例
#编码转换条件 ziplist -> skiplist
zset-max-ziplist-entries 128 #最大的元素个数
zset-max-ziplist-value 64 #字符串的最大长度
/*
* 有序集合
*/
typedef struct zset {
// 字典,键为成员,值为分值
// 用于支持 O(1) 复杂度的按成员取分值操作
dict *dict;
// 跳跃表,按分值排序成员
// 用于支持平均复杂度为 O(log N) 的按分值定位成员操作
// 以及范围操作
zskiplist *zsl;
} zset;
redis会共享0~9999的字符串对象,命令object idletime可以查看对象的空转时长。
推荐阅读 >> 《redis设计与实现》