**用 string 类型作为底层数据结构实现的一种统计二值状态的数据类型**。由 0 和 1 状态表现的二进制位的 bit 数组。**位图本质是数组,它是基于 string 数据类型的按位的操作**。该数组由多个二进制位组成,每个二进制位都对应一个偏移量(我们可以称之为一个索引或者位格)。Bitmap 支持的最大位数是 2^32 位,它可以极大的节约存储空间,使用 512M 内存就可以存储多大 42.9 亿的字节信息(2^32 = 4294967296)。
```sh
# colin 一周内签到的数据
SETBIT colin 1 1
SETBIT colin 2 1
SETBIT colin 3 1
SETBIT colin 5 1
# jeck 一周内签到的数据
SETBIT jeck 1 1
SETBIT jeck 3 1
# colin 在一周内一共签到的天数:结果为 4
BITCOUNT colin
# jeck 在一周内一共签到的天数:结果为 2
BITCOUNT jeck
# colin 和 jeck 在一周内同一天同时签到的总天数
BITOP and temp colin jeck
# 结果为 2
BITCOUNT temp
```
```h
# sds.h 源码
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* 已用的字节长度,即当前字符数组的长度,使我们在获取字符串长度的时候可以在 O(1)情况下拿到,而不是像 C 那样需要遍历一遍字符串 */
uint8_t alloc; /* 字符串最大字节长度,即当前字符数组总共分配的内存大小 */
unsigned char flags; /* 用来表示 SDS 的类型;即当前字符数组的属性,用来表示到底是 sdshdr8 还是 sdshdr16 等 */
char buf[]; /* 真正有效的字符串数据,长度由 alloc 控制;即表示字符串数组,字符串真正的值 */
};
```
> len:表示 SDS 的长度,使得在获取字符串长度的时候可以在 O(1) 情况下得到,而不是像 C 语言那样需要遍历一遍字符串
>
> alloc:可以用来计算 free,就是字符串已经分配的未使用的空间,有了这个值就可以引入预分配空间的算法了,而不用去考虑内存分配的问题
>
> buf:表示字符串数组,真正有效的字符串数据
###### Redis 中字符串的实现,SDS 有多种结构:
- sdshdr5:2^5=32 byte
- sdshdr8:2 ^ 8=256 byte
- sdshdr16:2 ^ 16=65536 byte = 64 KB
- sdshdr32:2 ^ 32 byte = 4 GB
- sdshdr64:2^64 byte =17179869184G,用于存储不同的长度的字符串。
###### Redis 为什么重新设计一个 SDS 数据结构?
C 语言没有 Java 里面的 String 类型,只能是靠自己的 char[] 来实现,字符串在 C 语言中的存储方式,想要获取上图字符串 "Redis" 的长度,需要从头开始遍历,直到遇到 `\0` 为止。所以,Redis 没有直接使用 C 语言传统的字符串标识,而是自己构建了一种名为简单动态字符串 SDS(simple dynamic string)的抽象类型,并将 SDS 作为 Redis 的默认字符串。
| - | C 语言 | SDS |
| 字符串长度处理 | 需要从头开始遍历,直到遇到 '\0' 为止,时间复杂度 O(N) | 记录当前字符串的长度,直接读取即可,时间复杂度 O(1) |
| 内存重新分配 | 分配内存空间超过后,会导致数组下标越级或者内存分配溢出 | 空间预分配:SDS 修改后,len 长度小于 1M,那么将会额外分配与 len 相同长度的未使用空间。如果修改后长度大于 1M,那么将分配1M的使用空间。 |
| 惰性空间释放:SDS 缩短时并不会回收多余的内存空间,而是使用 free 字段将多出来的空间记录下来。如果后续有变更操作,直接使用 free 中记录的空间,减少了内存的分配。 | ||
| 二进制安全 | 二进制数据并不是规则的字符串格式,可能会包含一些特殊的字符,比如 '\0' 等。前面提到过,C 语言中字符串遇到 '\0' 会结束,那 '\0' 之后的数据就读取不上了 | 根据 len 长度来判断字符串结束的,二进制安全的问题就解决了 |
> Redis 启动时会预先建立 10000 个分别存储 0~9999 的 redisObject 变量,作为共享对象,这就意味着如果 set 字符串的键值在 0~10000 之间的话,则可以**直接指向共享对象,而不需要再建立新对象,此时键值不占空间!**
```c
# object.c 源码
len = sdslen(s);
# 字符串长度小于等于 20,且字符串转成 long 类型成功
if (len <= 20 && string2l(s,len,&value)) {
if ((server.maxmemory == 0 ||
!(server.maxmemory_policy & MAXMEMORY_FLAG_NO_SHARED_INTEGERS)) &&
value >= 0 &&
value < OBJ_SHARED_INTEGERS)
{
# 配置 maxmemory,且值在 10000 以内,直接使用共享对象。OBJ_SHARED_INTEGERS 在 server.h 中定义 [#define OBJ_SHARED_INTEGERS 10000]
decrRefCount(o);
incrRefCount(shared.integers[value]);
return shared.integers[value];
} else {
# 否则转换成 int 或 embstr
if (o->encoding == OBJ_ENCODING_RAW) {
sdsfree(o->ptr);
o->encoding = OBJ_ENCODING_INT;
o->ptr = (void*) value;
return o;
} else if (o->encoding == OBJ_ENCODING_EMBSTR) {
decrRefCount(o);
return createStringObjectFromLongLongForValue(value);
}
}
}
```
**EMBSTR 编码格式:**
对于长度小于 44 的字符串,Redis 对键值采用 OBJ_ENCODING_EMBSTR 方式,EMBSTR 顾名思义即:embedded string,表示嵌入式的 string。从内存结构上来讲,即字符串 sds 结构体与其对应的 redisObject 对象分配在同一块连续的内存空间,字符串 sds 嵌入在 redisObject 对象之中一样。
```c
# object.c 源码
#define OBJ_ENCODING_EMBSTR_SIZE_LIMIT 44
robj *createStringObject(const char *ptr, size_t len) {
if (len <= OBJ_ENCODING_EMBSTR_SIZE_LIMIT)
# 长度小于 44 的字符串,将其转换成 OBJ_ENCODING_EMBSTR 格式
return createEmbeddedStringObject(ptr,len);
else
# 长度超过 44 的字符串,将其转换成 OBJ_ENCODING_RAW 格式
return createRawStringObject(ptr,len);
}
```
**RAW 编码格式:**
当字符串的键值是长度大于 44 的超长字符串时,Redis 则会将键值的内部编码方式改为 OBJ_ENCODING_RAW 格式,这与 OBJ_ENCODING_EMBSTR 编码方式的不同之处在于,此时动态字符串 sds 的内存与其依赖的 redisObject 的内存不再连续。
```c
# object.c 源码
#define OBJ_ENCODING_EMBSTR_SIZE_LIMIT 44
robj *createStringObject(const char *ptr, size_t len) {
if (len <= OBJ_ENCODING_EMBSTR_SIZE_LIMIT)
# 长度小于 44 的字符串,将其转换成 OBJ_ENCODING_EMBSTR 格式
return createEmbeddedStringObject(ptr,len);
else
# 长度超过 44 的字符串,将其转换成 OBJ_ENCODING_RAW 格式
return createRawStringObject(ptr,len);
}
```
> 对于某些字符串,其长度没有超过 44,但格式却是 raw,其原因为:
>
> - 对于 embstr,由于其实现的是只读的,因此在对 embstr 对象进行修改时,都会先转成 raw,然后再进行修改。因此,只要是修改 embstr 对象,修改后的对象一定是 raw 的,无论是否达到了 44 个字节。
###### 总结
**Redis 内部会根据用户给的不同键值而使用不同的编码格式,自适应地选择较优化的内部编码格式,而这一切对用户完全透明**。只有整数才会使用 int,如果是浮点数, Redis 内部其实先将浮点数转化为字符串值,然后再保存。embstr 与 raw 类型底层的数据结构其实都是 SDS (简单动态字符串,Redis 内部定义 sdshdr 一种结构)。
| 类型 | 说明 |
| ------ | ------------------------------------------------------------ |
| INT | Long 类型整数时,RedisObject 中的 ptr 指针直接赋值为整数数据,不再额外的指针再指向整数了,节省了指针的空间开销。 |
| EMBSTR | 当保存的是字符串数据且字符串小于等于 44 字节时,embstr 类型将会调用内存分配函数,只分配一块连续的内存空间,空间中依次包含 redisObject 与 sdshdr 两个数据结构,让元数据、指针和 SDS 是一块连续的内存区域,这样就可以避免内存碎片 |
| RAW | 当字符串大于 44 字节时,SDS 的数据量变多变大了,SDS 和 RedisObject 布局分家各自过,会给 SDS 分配更多的空间并用指针指向 SDS 结构,raw 类型将会调用两次内存分配函数,分配两块内存空间,一块用于包含 redisObject 结构,而另一块用于包含 sdshdr 结构 |
> Hash 类型键的字段个数 小于 hash-max-ziplist-entries 并且每个字段名和字段值的长度 小于 hash-max-ziplist-value 时,Redis 才会使用 OBJ_ENCODING_ZIPLIST 来存储该键,前述条件任意一个不满足则会转换为 OBJ_ENCODING_HT 的编码方式
>
> 1. 哈希对象保存的键值对数量小于 512 个(默认值);
> 2. 所有的键值对的健和值的字符串长度都小于等于 64 byte(默认值,一个英文字母一个字节)时用 ziplist,反之用 hashtable;
> 3. ziplist 升级到 hashtable 可以,反过来降级不可以。
> 4. 一旦从压缩列表转为了哈希表,Hash 类型就会一直用哈希表进行保存而不会再转回压缩列表了。
> 5. 在节省内存空间方面哈希表就没有压缩列表高效了。
压缩列表各组成部分的详细说明
```c
# ziplist.c 压缩列表节点结构
typedef struct zlentry {
unsigned int prevrawlensize; /* 上一个链表节点占用的长度(字节数) */
unsigned int prevrawlen; /* 存储上一个链表节点的常数数值所需要得字节数 */
unsigned int lensize; /* 存储当前链表节点长度数值所需要的字节数 */
unsigned int len; /* 当前链表节点占用的长度 */
unsigned int headersize; /* 当前链表节点的头部大小(prevrawlensize + lensize),即非数据域的大小 */
unsigned char encoding; /* 当前节点的编码格式 */
unsigned char *p; /* 当前节点的指针,压缩链表以字符串的形式保存,该指针指向当前节点的起始位置 */
} zlentry;
```
压缩列表 zlentry 节点结构:每个 zlentry 由前一个节点的长度 、encoding 和 entry-data 三部分组成;
- 前节点: (前节点占用的内存字节数)表示前 1 个 zlentry 的长度,prev_len 有两种取值情况:1 字节或 5 字节 。取值 1 字节时,表示上一个 entry 的长度小于 254 字节。虽然 1 字节的值能表示的数值范围是 0 ~ 255,但是压缩列表中 zlend 的取值默认是 255,因此,就默认用 255 表示整个压缩列表的结束,其他表示长度的地方就不能再用 255 这个值了。所以,当上一个 entry 长度小于 254 字节时,prev_len 取值为 1 字节,否则,就取值为 5 字节。
- encoding: 记录节点的 content 保存数据的类型和长度。
- content: 保存实际数据内容
压缩列表的遍历:
- 压缩列表的遍历:通过指向表尾节点的位置指针 p1,减去节点的 previous_entry_length,得到前一个节点起始地址的指针。如此循环,从表尾遍历到表头节点。从表尾向表头遍历操作就是使用这一原理实现的,只要我们拥有了一个指向某个节点起始地址的指针,那么通过这个指针以及这个节点的 previous_entry_length 属性程序就可以一直向前一个节点回溯,最终到达压缩列表的表头节点。
在存在链表的前提下,为什么要有压缩列表?
1. 普通的双向链表会有两个指针,在存储数据很小的情况下,我们存储的实际数据的大小可能还没有指针占用的内存大,得不偿失 。ziplist 是一个特殊的双向链表没有维护双向指针:prev 和 next;而是存储上一个 entry 的长度和当前 entry 的长度,通过长度推算下一个元素在什么地方。牺牲读取的性能,获得高效的存储空间,因为(简短字符串的情况)存储指针比存储 entry 长度更费内存。这是典型的“时间换空间”。
2. 链表在内存中一般是不连续的,遍历相对比较慢,而 ziplist 可以很好的解决这个问题,普通数组的遍历是根据数组里存储的数据类型来找到下一个元素的(例如 int 类型的数组访问下一个元素时每次只需要移动一个 sizeof(int) 就行),但是 ziplist 的每个节点的长度是可以不一样的,而我们面对不同长度的节点又不可能直接 sizeof(entry),所以 ziplist 只好将一些必要的偏移量信息记录在了每一个节点里,使之能跳到上一个节点或下一个节点。
3. 头节点里有头节点,同时还有一个参数 len,和 string 类型提到的 SDS 类似,这里是用来记录链表长度的。因此获取链表长度时不用再遍历整个链表, 直接拿到 len 值就可以了,这个时间复杂度是 O(1)
普通链表:
**ziplist 存取情况**:
###### hashtable 源码分析
在 Redis 中,hashtable 被称为字典(dictionary),它是一个数组 + 链表的结构。每一个键值对都是一个 dictEntry。
```c
# t_hash.c 源码
void hsetCommand(client *c) {
int i, created = 0;
robj *o;
if ((c->argc % 2) == 1) {
addReplyErrorFormat(c,"wrong number of arguments for '%s' command",c->cmd->name);
return;
}
if ((o = hashTypeLookupWriteOrCreate(c,c->argv[1])) == NULL) return;
# ※※※※※※※※源码在下面※※※※※※※※※※
hashTypeTryConversion(o,c->argv,2,c->argc-1);
for (i = 2; i < c->argc; i += 2)
created += !hashTypeSet(o,c->argv[i]->ptr,c->argv[i+1]->ptr,HASH_SET_COPY);
/* HMSET (deprecated) and HSET return value is different. */
char *cmdname = c->argv[0]->ptr;
if (cmdname[1] == 's' || cmdname[1] == 'S') {
/* HSET */
addReplyLongLong(c, created);
} else {
/* HMSET */
addReply(c, shared.ok);
}
signalModifiedKey(c,c->db,c->argv[1]);
notifyKeyspaceEvent(NOTIFY_HASH,"hset",c->argv[1],c->db->id);
server.dirty += (c->argc - 2)/2;
}
```
每个键值对都会有一个 dictEntry。OBJ_ENCODING_HT 这种编码方式内部才是真正的哈希表结构,或称为字典结构,其可以实现 O(1) 复杂度的读写操作,因此效率很高。在 Redis 内部,从 OBJ_ENCODING_HT 类型到底层真正的散列表数据结构是一层层嵌套下去的,组织关系见面图:
```c
# dict.h 源码
# dictEntry:哈希条目
typedef struct dictEntry {
void *key;
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v;
struct dictEntry *next;
} dictEntry;
typedef struct dictType {
uint64_t (*hashFunction)(const void *key);
void *(*keyDup)(void *privdata, const void *key);
void *(*valDup)(void *privdata, const void *obj);
int (*keyCompare)(void *privdata, const void *key1, const void *key2);
void (*keyDestructor)(void *privdata, void *key);
void (*valDestructor)(void *privdata, void *obj);
int (*expandAllowed)(size_t moreMem, double usedRatio);
} dictType;
# dictht:哈希表
typedef struct dictht {
dictEntry **table;
unsigned long size;
unsigned long sizemask;
unsigned long used;
} dictht;
# dict:字典
typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2];
long rehashidx; /* rehashing not in progress if rehashidx == -1 */
int16_t pauserehash; /* If >0 rehashing is paused (<0 indicates coding error) */
} dict;
```
```c
# t_hash.c 源码
void hashTypeTryConversion(robj *o, robj **argv, int start, int end) {
int i;
size_t sum = 0;
if (o->encoding != OBJ_ENCODING_ZIPLIST) return;
for (i = start; i <= end; i++) {
if (!sdsEncodedObject(argv[i]))
continue;
size_t len = sdslen(argv[i]->ptr);
if (len > server.hash_max_ziplist_value) {
# 超过设置阈值 hash_max_ziplist_value,则编码格式转换成 OBJ_ENCODING_HT
hashTypeConvert(o, OBJ_ENCODING_HT);
return;
}
sum += len;
}
if (!ziplistSafeToAdd(o->ptr, sum))
hashTypeConvert(o, OBJ_ENCODING_HT);
}
```
> 在低版本的 Redis 中,list 采用的底层数据结构是 ziplist + linkedList
>
> 在高版本的 Redis 中,list 采用的底层数据结构是 quicklist,它替换了 ziplist + linkedList,而 quicklist 也用到了 ziplist
>
> quicklist 实际上是 zipList 和 linkedList 的混合体,它将 linkedList 按段切分,每一段使用 zipList 来紧凑存储,多个 zipList 之间使用双向指针串接起来。
ziplist 的两种压缩配置。通过 `config get list*` 可以查看。
- **list-compress-depth**
表示一个 quicklist 两端不被压缩的节点个数。这里的节点是指 quicklist 双向链表的节点,而不是指 ziplist 里面的数据项个数。
> 参数 list-compress-depth 的取值含义如下:
>
> - **0: 是个特殊值,表示都不压缩。这是Redis的默认值。**
>
> - 1: 表示 quicklist 两端各有 1 个节点不压缩,中间的节点压缩。
>
> - 2: 表示 quicklist 两端各有 2 个节点不压缩,中间的节点压缩。
>
> - 3: 表示 quicklist 两端各有 3 个节点不压缩,中间的节点压缩。
- **list-max-ziplist-size**
当取正值的时候,表示按照数据项个数来限定每个 quicklist 节点上的 ziplist 长度。比如,当这个参数配置成 5 的时候,表示每个 quicklist 节点的 ziplist 最多包含 5 个数据项。当取负值的时候,表示按照占用字节数来限定每个 quicklist 节点上的 ziplist 长度。这时,它只能取 -1 到 -5 这五个值
> 每个值含义如下:
>
> - -5: 每个 quicklist 节点上的 ziplist 大小不能超过 64 Kb。(注:1kb => 1024 bytes)
>- -4: 每个 quicklist 节点上的 ziplist 大小不能超过 32 Kb。
> - -3: 每个 quicklist 节点上的 ziplist 大小不能超过 16 Kb。
>- **-2: 每个 quicklist 节点上的 ziplist 大小不能超过 8 Kb。(-2是Redis给出的默认值)**
> - -1: 每个 quicklist 节点上的 ziplist 大小不能超过 4 Kb。
```c
# quicklist.h 源码
typedef struct quicklist {
quicklistNode *head; /* 指向双向列表的表头 */
quicklistNode *tail; /* 指向双向列表的表尾 */
unsigned long count; /* 所有 ziplist 中一共存了多少个元素 */
unsigned long len; /* 双向链表的长度,node 的数量 */
int fill : QL_FILL_BITS; /* fill factor for individual nodes */
unsigned int compress : QL_COMP_BITS; /* 压缩深度,0:不压缩 */
unsigned int bookmark_count: QL_BM_BITS;
quicklistBookmark bookmarks[];
} quicklist;
typedef struct quicklistNode {
struct quicklistNode *prev; /* 前一个节点 */
struct quicklistNode *next; /* 后一个节点 */
unsigned char *zl; /* 指向实际的 ziplist,一个 ziplist 可以存放多个元素 */
unsigned int sz; /* 当前 ziplist 占用多少字节 */
unsigned int count : 16; /* 当前 ziplist 中存储了多少个元素,占 16bit,最大 65536 个 */
unsigned int encoding : 2; /* 是否采用了 LZF 压缩算法压缩节点,RAW==1 or LZF==2 */
unsigned int container : 2; /* NONE==1 or ZIPLIST==2。未来可能支持其它结构存储 */
unsigned int recompress : 1; /* 当前 ziplist 是不是已经被解压出来作临时使用 */
unsigned int attempted_compress : 1; /* 测试用 */
unsigned int extra : 10; /* 预留给未来使用 */
} quicklistNode;
```
###### 不同数据类型对应的底层编码格式
- string
> int:8个字节的长整型。
>
> embstr:小于等于 44 个字节的字符串。
>
> raw:大于 44 个字节的字符串。
>
> Redis 会根据当前值的类型和长度决定使用哪种内部编码实现。
- hash
> ziplist(压缩列表):当哈希类型元素个数小于 hash-max-ziplist-entries 配置(默认 512 个)、同时所有值都小于 hash-max-ziplist-value 配置(默认64 字节)时,Redis 会使用 ziplist 作为哈希的内部实现,ziplist 使用更加紧凑的结构实现多个元素的连续存储,所以在节省内存方面比 hashtable 更加优秀。
>
> hashtable(哈希表):当哈希类型无法满足 ziplist 的条件时,Redis 会使用 hashtable 作为哈希的内部实现,因为此时 ziplist 的读写效率会下降,而 hashtable 的读写时间复杂度为 O(1)。
- list
> ziplist(压缩列表):当列表的元素个数小于 list-max-ziplist-entries 配置(默认 512 个),同时列表中每个元素的值都小于 list-max-ziplist-value 配置时(默认 64 字节),Redis 会选用 ziplist 来作为列表的内部实现来减少内存的使 用。
>
> linkedlist(链表):当列表类型无法满足 ziplist 的条件时,Redis 会使用 linkedlist 作为列表的内部实现。quicklist ziplist 和 linkedlist 的结合以 ziplist 为节点的链表(linkedlist),Redis 高版本使用功能 quiklist,低版本使用 linkedlist。
- set
> intset(整数集合):当集合中的元素都是整数且元素个数小于 set-max- intset-entries 配置(默认 512 个)时,Redis 会选用 intset 来作为集合的内部实现,从而减少内存的使用。
>
> hashtable(哈希表):当集合类型无法满足 intset 的条件时,Redis 会使用 hashtable 作为集合的内部实现。
- zset
> ziplist(压缩列表):当有序集合的元素个数小于 zset-max-ziplist- entries 配置(默认 128 个),同时每个元素的值都小于 zset-max-ziplist-value 配置(默认 64 字节)时,Redis 会用 ziplist 来作为有序集合的内部实现,ziplist 可以有效减少内存的使用。
>
> skiplist(跳跃表):当 ziplist 条件不满足时,有序集合会使用 skiplist 作为内部实现,因为此时 ziplist 的读写效率会下降。
###### redis 数据类型以及数据结构的时间复杂度
| 名称 | 时间复杂度 |
| -------- | ---------- |
| 哈希表 | O(1) |
| 跳表 | O(logN) |
| 双向链表 | O(N) |
| 压缩列表 | O(N) |
| 整数数组 | O(N) |
###### redis 数据类型与编码
| 类型 | 编码 | 对象 |
| REDIS_STRING | REDIS_ENCODING_INT | 使用整数值实现的字符串对象 |
| REDIS_ENCODING_EMBSTR | 使用 embstr 编码的简单动态字符串实现的字符串对象 | |
| REDIS_ENCODING_RAW | 使用简单动态字符串实现的字符串对象 | |
| REDIS_LIST | REDIS_ENCODING_ZIPLIST | 使用压缩列表实现的列表对象 |
| REDIS_ENCODING_LINKEDLIST | 使用双端链表实现的列表对象 | |
| REDIS_HASH | REDIS_ENCODING_ZIPLIST | 使用压缩列表实现的哈希对象 |
| REOIS_ENCODING_HT | 使用字典实现的哈希对象 | |
| REDIS_SET | REDIS_ENCODING_INTSET | 使用整数集合实现的集合对象 |
| REDIS_ENCODING_HT | 使用字典实现的集合对象 | |
| REDIS_ZSET | REDIS_ENCODING_ZIPLIST | 使用压缩列表实现的有序集合对象 |
| REDIS_ENCODING_SKIPLIST | 使用跳跃表和字典实现的有序集合对象 |
##### 跳表的时间复杂度
- 首先每一级索引我们提升了 2 倍的跨度,那就是减少了 2 倍的步数,所以是 n/2、n/4、n/8 以此类推;
- 第 k 级索引结点的个数就是 n/(2^k);
- 假设索引有 h 级, 最高的索引有 2 个结点;n/(2^h) = 2,,从这个公式我们可以求得 h = log2(N)-1;
- 所以最后得出跳表的时间复杂度是 **O(logN)**
##### 跳表的空间复杂度
- 首先原始链表长度为 n
- 如果索引是每 2 个结点有一个索引结点,每层索引的结点数:n/2,n/4,n/8……, 8,4,2 以此类推;
- 或者所以是每 3 个结点有一个索引结点,每层索引的结点数:n/3,n/9,n/27……,9,3,1 以此类推;
- 所以空间复杂度是 O(n);
##### 跳表的特点
- 跳表是一个最典型的空间换时间解决方案,而且只有在数据量较大的情况下才能体现出来优势。而且应该是读多写少的情况下才能使用,所以它的适用范围应该还是比较有限的
- 维护成本相对要高,新增或者删除时需要把所有索引都更新一遍;
- 在新增和删除的过程中的更新,时间复杂度也是 O(log n)。
---
### 布隆过滤器
#### 布隆过滤器定义
布隆过滤器(Bloom Filter)是 1970 年由布隆提出的。
**它实际上是一个很长的二进制数组加一些列随机 hash 算法映射函数,主要用于判断一个元素是否在集合中。即为由一个初始值都为 0 的 bit 数组和多个哈希表函数构成,主要用于快速判断某个数据是否存在。本质就是判断具体数据是否存在一个大的集合中。布隆过滤器是一种类似 set 的数据结构,只是统计结果不太准确。**
通常我们会遇到很多要判断一个元素是否在某个集合中的业务场景,一般想到的是将集合中所有元素保存起来,然后通过比较确定。链表、树、散列表(又叫哈希表,Hash table)等等数据结构都是这种思路。但是随着集合中元素的增加,我们需要的存储空间也会呈现线性增长,最终达到瓶颈。同时检索速度也越来越慢,上述三种结构的检索时间复杂度分别为O(n)、O(logn)、O(1)。这个时候,布隆过滤器(Bloom Filter)就应运而生。
##### 散列(hash)函数的特点
- 如果两个散列值是不相同的(根据同一个 hash 函数计算),那么这两个散列值的原始输入也是不相同的。这个特性是散列函数具有确定性的结果,具有这种性质的散列函数称为单向散列函数。
- 散列函数的输入和输出不是唯一对应关系的,如果两个散列值此相同,两个输入值很可能是相同的,但也可能不同,这种情况称为散列碰撞(collision)
- 用 hash 表存储大数据量时,空间效率还是很低,当只有一个 hash 函数时,还很容易发生哈希碰撞。
##### 布隆过滤器的操作
- **添加 key 时**:为了尽量地地址不冲突,会使用多个 hash 函数对 key 进行 hash 运算,得到一个整数索引值,然后对位数组长度进行取模运算,得到一个位置。每个 hash 函数都会得到一个不同的位置,再把位数组中的这几个位置都置为 1,就完成了添加操作。
- **查询 key 时:**向布隆过滤器查询某个 key 是否存在时,先把这个 key 通过相同的多个 hash 函数进行运算,查看位数组对应的位置是否都为 1,只要其中有一个位置的值是 0,就说明布隆过滤器中这个 key 不存在,但如果位数组对应的位置都是 1,则说明这个 key 极有可能存在于在布隆过滤器中。因为这些位置的 1 可能是因为其它的 key 存在导致的,也就是发生了哈希冲突。例如,在添加字符串“wmyskxz”数据之后,很明显位数组在下标为 1、3、5 这几个位置的 1 是因为第一次添加字符串“wmyskxz”而导致的;此时我们查询一个没有添加过的不存在的字符串“inexistent-key”,它有可能计算后的位数组的下标也分别为 1、3、5 这个几个位置,而这几个位置都是 1,这就发生了 hash 冲突,就发生了误判
##### 布隆过滤器的存储过程
当有变量被加入到集合时,通过 N 个映射函数将这个变量映射成位图中的 N 个点,把他们置为 1(假定有两个变量都通过 3 个映射函数)。
查询某个变量的时候,我们只要看这些点是不是都为 1,就可以大概率知道集合中有没有这个变量了。
如果这些点中,有任何一个为 0,则被查询变量**一定不存在**;
如果这些点都为 1,则被查询的变量**很可能存在**。**因为,映射函数本身就是散列函数,散列函数时会有碰撞的**。
> 正是基于布隆过滤器的快速检测的特性,我们可以把数据写入数据库时,使用布隆过滤器做个标记。当缓存缺失后,应用查询数据库时,可以通过查询布隆过滤器快速判断数据是否存在。如果不存在,就不用再去数据库中查询了。这样一来,即使发生了缓存穿透,大量请求只会查询 redis 和布隆过滤器,而不会积压到数据库,也就不会影响数据库的正常运行。布隆过滤器可以使用 redis 实现,本身就能承担较大的并发访问压力。
- 误判问题,但是概率小,key 接受,不能从布隆过滤器删除
- 全部合法的 key 都要存放入过滤器和 redis 中,否则数据就是返回 null
##### Redis 布隆过滤器代码测试
```java
public class RedisBloomFilterDemo {
public static final int _1W = 10000;
// 布隆过滤器里预计要插入多少数据
public static int size = 100 * _1W;
// 误判率,它越小误判的个数也就越少,但误判率越小,哈希函数越多,计算耗时越长,性能越低,0.03 是最优选择
public static double fpp = 0.03;
// redisson
static RedissonClient redissonClient = null;
// redis 版内置的布隆过滤器
static RBloomFilter rBloomFilter = null;
@Resource
RedisTemplate redisTemplate;
static {
// redisson 配置
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379").setDatabase(0);
// 构造 redisson
redissonClient = Redisson.create(config);
// 通过 redisson 构造 rBloomFilter
rBloomFilter = redissonClient.getBloomFilter("phoneListBloomFilter",new StringCodec());
rBloomFilter.tryInit(size,fpp);
// 1.测试布隆过滤器有 + redis 有
rBloomFilter.add("10086");
redissonClient.getBucket("10086",new StringCodec()).set("chinamobile10086");
// 2.测试布隆过滤器有 + redis 无
//rBloomFilter.add("10087");
//3 测试布隆过滤器无 + redis 无
}
private static String getPhoneListById(String IDNumber){
String result = null;
if (IDNumber == null) {
return null;
}
// 1.先去布隆过滤器中查询
if (rBloomFilter.contains(IDNumber)){
// 2.布隆过滤器判断数据存在,再去 redis 中读取数据
RBucket
代码演示
```java
// 采用定时器将参与聚划算活动的特价商品新增进入redis中
@Service
@Slf4j
public class JHSTaskService {
@Autowired
private RedisTemplate redisTemplate;
@PostConstruct
public void initJHS(){
log.info(" 启动定时器淘宝聚划算功能模拟 .........." + DateUtil.now());
new Thread(()->{
// 模拟定时器,定时把数据库的特价商品,刷新到 redis 中
while (true){
// 模拟从数据库读取 100 件特价商品,用于加载到聚划算的页面中
List
MySQL的主从复制将经过如下步骤:
1. 当 master 主服务器上的数据发生改变时,则将其改变写入二进制事件日志文件中;
2. salve 从服务器会在一定时间间隔内对 master 主服务器上的二进制日志进行探测,探测其是否发生过改变,如果探测到 master 主服务器的二进制事件日志发生了改变,则开始一个 I/O Thread 请求 master 二进制事件日志;
2. 同时 master 主服务器为每个 I/O Thread 启动一个 dump Thread,用于向其发送二进制事件日志;
2. slave 从服务器将接收到的二进制事件日志保存至自己本地的中继日志文件中;
2. salve 从服务器将启动 SQL Thread 从中继日志中读取二进制日志,将数据存放到本地,使得本地数据和主服务器保持一致;
2. 最后 I/O Thread 和 SQL Thread 将进入睡眠状态,等待下一次被唤醒;
| 操作顺序 | 是否并发请求 | 潜在问题 | 现象 | 应对方案 |
| 先删缓存再更新数据库 | 无 | 缓存删除成功,但数据库更新失败 | 应用从数据库读到旧数据 | 重试数据库更新 |
| 有 | 缓存删除后,尚未更新数据库,有并发读请求 | 并发请求从数据库读到旧值,并且更新到缓存,导致后续请求都读取旧值 | 延迟双删 | |
| 先更新数据库,再删除缓存 | 无 | 数据库更新成功,但缓存删除失败 | 应用从缓存读到旧数据 | 重试缓存删除 |
| 有 | 数据库更新成功后,尚未删除缓存,有并发读请求 | 并发请求从缓存中读到旧值 | 等待缓存删除完成,期间会有不一致数据短暂存在 |
在 NIO 模式中,一切都是非阻塞的:
- accept() 方法是非阻塞的,如果没有客户端连接,就返回 error
- read() 方法是非阻塞的,如果 read() 方法读取不到数据就返回 error,如果读取到数据时只阻塞 read() 方法读数据的时间
在 NIO 模式中,只有一个线程:当一个客户端与服务端进行连接,这个 socket 就会加入到一个数组中,隔一段时间遍历一次,看这个 socket 的 read() 方法能否读到数据,这样一个线程就能处理多个客户端的连接和读取了。
NIO 成功的解决了 BIO 需要开启多线程的问题,NIO 中一个线程就能解决多个 socket,但是还存在 2 个问题:
- 这个模型在客户端少的时候十分好用,但是客户端如果很多,比如有 1 万个客户端进行连接,那么每次循环就要遍历 1 万个 socket,如果一万个 socket 中只有 10 个 socket 有数据,也会遍历一万个 socket,就会做很多无用功,每次遍历遇到 read 返回 -1 时仍然是一次浪费资源的系统调用。
- 遍历过程是在用户态进行的,用户态判断 socket 是否有数据还是调用内核的 read() 方法实现的,这就涉及到用户态和内核态的切换,每遍历一个就要切换一次,开销很大因为这些问题的存在。
NIO 的特点:
- 优点:不会阻塞在内核的等待数据过程,每次发起的 I/O 请求可以立即返回,不用阻塞等待,实时性较好。
- 缺点:轮询将会不断地询问内核,这将占用大量的 CPU 时间,系统资源利用率较低,所以一般 Web 服务器不使用这种 I/O 模型。
I/O multiplexing 这里面的 multiplexing 指的其实是在单个线程通过记录跟踪每一个 Sock(I/O流) 的状态来同时管理多个 I/O 流。目的是尽量多的提高服务器的吞吐能力。
文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。
I/O 多路复用就是将用户 socket 对应的**文件描述符**注册进 epoll,然后 epoll 帮你监听哪些 socket 上有消息到达,这样就避免了大量的无用操作。此时的 socket 应该采用非阻塞模式。这样,整个过程只在调用 select、poll、epoll 这些调用的时候才会阻塞,收发客户消息是不会阻塞的,整个进程或者线程就被充分利用起来,这就是事件驱动,所谓的 reactor 反应模式。
Redis 是跑在单线程中的,所有的操作都是按照顺序线性执行的,但是由于读写操作等待用户输入或输出都是阻塞的,所以 I/O 操作在一般情况下往往不能直接返回,这会因为某一文件的 I/O 阻塞而导致整个进程无法对其它客户提供服务,而 I/O 多路复用就是为了解决这个问题而出现的。
所谓 I/O 多路复用机制,就是说通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或写就绪),能够通知程序进行相应的读写操作。这种机制的使用需要 select 、 poll 、 epoll 来配合。多个连接共用一个阻塞对象,应用程序只需要在一个阻塞对象上等待,无需阻塞等待所有连接。当某条连接有新的数据可以处理时,操作系统通知应用程序,线程从阻塞状态返回,开始进行业务处理。
Redis 基于 Reactor 模式开发了网络事件处理器,这个处理器被称为文件事件处理器。它的组成结构为4部分:
1. 多个套接字
2. IO多路复用程序
3. 文件事件分派器
4. 事件处理器
> **因为文件事件分派器队列的消费是单线程的,所以 Redis 才叫单线程模型**。
>
> 文件事件处理器使用 I/O 多路复用(multiplexing)程序来同时监听多个套接字,并根据套接字目前执行的任务来为套接字关联不同的事件处理器。
>
> 当被监听的套接字准备好执行连接应答(accept)、读取 (read)、写入 (write)、关闭(close)等操作时,与操作相对应的文件事件就会产生, 这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。
>
> 虽然文件事件处理器以单线程方式运行,但通过使用 I/O 多路复用程序来监听多个套接字,文件事件处理器既实现了高性能的网络通信模型,又可以很好地与 Redis 服务器中其他同样以单线程方式运行的模块进行对接,这保持了 Redis 内部单线程设计的简单性。即文件事件分派器队列的消费是单线程的,所以Redis才叫单线程模型。
>
> 多路:多个客户端连接(连接就是套接字描述符,即 socket 或者 channel)。
>
> 复用:复用一个或几个线程。也就是说一个或一组线程处理多个 TCP 连接,使用单进程就能够实现同时处理多个客户端的连接。即一个服务端进程可以同时处理多个套接字描述符。
从代码中可以看出,select 系统调用后,返回了一个置位后的 &rset,这样用户态只需进行很简单的二进制比较,就能很快知道哪些 socket 需要 read 数据,有效提高了效率
**select 的优点**:select 其实就是把 NIO 中用户态要遍历的文件描述符数组(我们的每一个 socket 链接,安装进 ArrayList 里面的那个)拷贝到了内核态,让内核态来遍历,因为用户态判断 socket 是否有数据还是要调用内核态的,所有拷贝到内核态后,这样遍历判断的时候就不用一直用户态和内核态频繁切换了。
**select 存在的问题**:
1. bitmap 最大 1024 位,一个进程最多只能处理 1024 个客户端
2. &rset 不可重用,每次 socket 有数据就相应的位会被置位
3. 文件描述符数组拷贝到了内核态(只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知)),仍然有开销。select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。(可优化为不复制)
4. select 并没有通知用户态哪一个 socket 有数据,仍然需要 O(n) 的遍历。select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。
> 综上,select 方式的小总结,既做到了一个线程处理多个客户端连接(文件描述符),又减少了系统调用的开销(多个文件描述符只有一次 select 的系统调用 + N次就绪状态的文件描述符的 read 系统调用。
poll 的优点:
1. poll 使用 pollfd 数组来代替 select 中的 bitmap,数组没有 1024 的限制,可以一次管理更多的 client。它和 select 的主要区别就是,去掉了 select 只能监听 1024 个文件描述符的限制。
2. 当 pollfds 数组中有事件发生,相应的 revents 置位为 1,遍历的时候又置位回零,实现了pollfd 数组的重用。
poll 解决了 select 缺点中的前两条,其本质原理还是 select 的方法,还存在 select 中原来的问题
1. pollfds 数组拷贝到了内核态,仍然有开销
2. poll 并没有通知用户态哪一个 socket 有数据,仍然需要 O(n) 的遍历
| int epoll_create(int size) | 参数size并不是限制了epoll所能监听的描述符最大个数,只是对内核初始分配内部数据结构的一个建议 |
| ------------------------------------------------------------ | ------------------------------------------------------------ |
| int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event) | 见上图 |
| int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout) | 等待epfd上的io事件,最多返回maxevents个事件。参数events用来从内核得到事件的集合,maxevents告之内核这个events有多大。 |
epoll 的时间通知机制:
1. 当有网卡上有数据到达了,首先会放到 DMA中(内存中的一个 buffer,网卡可以直接访问这个数据区域)
2. 网卡向 cpu 发起中断,让 cpu 先处理网卡的事
3. 中断号在内存中会绑定一个回调,哪个 socket 中有数据,回调函数就把哪个 socket 放入就绪链表中
**Redis 保有三种 /IO 多路复用函数,select 作为保底方案**