Redis学习-hash类型基本知识与底层实现
一、基本知识
1、基本操作
hash一共有15个指令,下面我们来执行以下所有的指令,看看它的效果和作用。
具体指令说明可以参考官方文档说明:http://www.redis.cn/commands.html#hash
127.0.0.1:6379> hset redis string good // 设置redis,string项为good
(integer) 1
127.0.0.1:6379> hget redis string // 获取redis的string项
"good"
127.0.0.1:6379> hmset redis hash notgood set verygood
OK
127.0.0.1:6379> hmget redis string hash
1) "good"
2) "notgood"
127.0.0.1:6379> hlen redis // 获取redis所有项的数量
(integer) 3
127.0.0.1:6379> hvals redis // 获取redis所有项的值
1) "good"
2) "notgood"
3) "verygood"
127.0.0.1:6379> hgetall redis // 获取redis的全部项和值
1) "string"
2) "good"
3) "hash"
4) "notgood"
5) "set"
6) "verygood"
127.0.0.1:6379> hset redis num 1
(integer) 1
127.0.0.1:6379> hincrby redis num 1 // 增加整数项num的值
(integer) 2
127.0.0.1:6379> hget redis num
"2"
127.0.0.1:6379> hexists redis num // 判断redis的num项是否存在
(integer) 1
127.0.0.1:6379> hdel redis num // 删除redis的num项
(integer) 1
127.0.0.1:6379> hget redis num
(nil)
127.0.0.1:6379> hstrlen redis hash // 获取reids的hash项的值长度
(integer) 7
2、hash存储结构对象的好处
string类型和hash类型在实际项目中都可以用于存储一个结构对象,常用的方法有
方法 | 优点 | 缺点 |
---|---|---|
string类型,对象id为key,对象json序列化字符串为value | 1、可以一次性获取对象的全部属性 2、内存占用小 | 1、修改一个属性需要全部更新 |
string类型,对象id加属性名为key,属性值为value | 1、修改单一属性值方便 2、每个属性都可以有独自的过期时间 | 1、要为每个属性创建redisObject,内存占用大 2、获取全部属性麻烦 |
hash类型,对象id为key,属性为feild,属性值为value | 1、可以获取单一属性,也可以获取全部属性 2、修改单一属性方便 3、占用内存小 | 1、所有属性共用一个过期时间 |
3、hash整合数据的优缺点
string类型和hash类型在实际项目中也可以用于统计数据,因为两个类型都带有incr方法,同样的它们各有各的优缺点。
方法 | 优点 | 缺点 |
---|---|---|
string类型,统计类型加对象id为key,统计值为value | 1、结构简单,容易实现 2、每个统计对象都有独自的过期时间 | 1、获取统计集麻烦,统计项分散 |
hash类型,统计类型为key,对象为feild,统计值为value | 1、结构较为简单,容易实现 2、获取所有统计信息容易 3、一个统计类型一个过期时间 | 1、统计过大过量的数据,获取需要花费时间 |
4、注意事项
- hash类型的value只能存储字符串,不能存储其他类型
- 每个hash可以存储2的32次方减一个键值对
- hash不可存储大量对象
- hgetall操作可以获取全部属性,如果field过多,遍历效率会过低
二、底层实现
1、redisObject
前面讲解 string类型 的时候已经讲过redisObject各个属性的作用,这里我们就直接看一下刚刚设置的hash键redis的对象属性。
127.0.0.1:6379> type redis // 类型hash
hash
127.0.0.1:6379> object encoding redis // 这里编码采用了ziplist
"ziplist"
127.0.0.1:6379> object refcount redis // 引用计数为1
(integer) 1
127.0.0.1:6379> object idletime redis
(integer) 1272
127.0.0.1:6379> hset redis big64 gooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooood // 设置一个超过64长度的值
(integer) 1
127.0.0.1:6379> object encoding redis // 这里编码变为hashtable
"hashtable"
2、编码转换
redis给hash类型设置了两种编码方式,一种是OBJ_ENCODING_ZIPLIST-- 压缩链表,一种是OBJ_ENCODING_HT – 字典。在一般情况下,redis都会采用压缩链表的方式进行编码,但是当下列两种情况任意一种发生,redis都会转换编码方式,将压缩链表转换为字典
①hash对象保存的键值对的键或值的大小大于hash_max_ziplist_value
②hash对象保存的键值对数量大于hash_max_ziplist_entries
redis会在两个地方检验是否进行编码转换:
①每次set前,都先判断客户端传入的参数列表中是否有大于hash_max_ziplist_value,有一个就转换编码
②在set之后,判断键值对数量是否大于hash_max_ziplist_entries,是的话就转换编码
/* redis6.2.5中,hash_max_ziplist_value 与 hash_max_ziplist_entries 的配置 */
createSizeTConfig("hash-max-ziplist-entries", NULL, MODIFIABLE_CONFIG, 0, LONG_MAX, server.hash_max_ziplist_entries, 512, INTEGER_CONFIG, NULL, NULL),
createSizeTConfig("hash-max-ziplist-value", NULL, MODIFIABLE_CONFIG, 0, LONG_MAX, server.hash_max_ziplist_value, 64, MEMORY_CONFIG, NULL, NULL),
3、ziplist – 压缩列表
前面讲了,redis对hash有两种编码,一种就是压缩列表,下面我们就对压缩列表进一步了解。
3.1 压缩列表的结构
ziplist是一种顺序型链表,它的一般结构如下所示:
|zlbytes | zltail | zllen | entry | entry | … | entry | zlend|
字段 | 类型 | 说明 |
---|---|---|
zlbytes | uint32_t | 压缩列表占用的内存字节数 |
zltail | uint32_t | 表尾节点与列表起始地址的偏移字节量 |
zllen | uint16_t | 小于UINT16_MAX下的节点数量 |
entry | 节点 | 压缩列表的实际节点 |
zlend | uint8_t | 压缩列表结尾标志,0xFF=255 |
下面看一下实际节点entry的结构,包括三个属性,
| prevlen | encoding | entry-data |
字段 | 说明 |
---|---|
prevlen | 记录前一节点的长度,便于程序指针运算获取前一节点的地址,实现表尾到表头的查询,1字节或者5字节 |
encoding | 记录保存数据的类型与长度,ziplist可以保持整数和string,具体编码可以查看ziplist.c的说明 |
entry-data | 记录保存的值 |
3.2 压缩列表的连锁更新
压缩列表的entry中的prevlen记录了前一节点的长度,当前一节点的长度小于254字节的时候,它会使用一个字节的空间保存这个值,否则使用5字节的空间保存这个值。因为这个机制,在极端的情况下,有可能导致连锁更新。
考虑下面两种情况:
- 在压缩列表中,有连续的多个节点长度介于250-253字节,这个时候在前面插入一个大于254字节的新节点,后面的节点prevlen都会变成5字节,引发连锁更新
- 在压缩列表中,小于254字节节点后面跟有连续的多个节点长度介于254-257字节,这个时候删除该节点后的第一个节点,后面的节点prevlen都会变成1字节,引发连锁更新
不管删除还是插入操作,都有可能引发连锁更新,最坏的情况下,可能执行N次更新,而每次更新的复杂度o(N),使得整个连锁更新复杂度达到o(N^2)。
但是我们注意到,实际应用中发生连锁更新的可能性是比较低的,正常情况下删除与插入操作的复杂度都是o(N)。
4、dict – 字典
redis中的字典应用比较广泛,不仅仅用在hash类型的实现,还用在数据库键值对存储、过期键记录等。一个优秀的字典,增删改查的速度都非常快,平均在o(1)的复杂度。下面我们来看一下redis的字典实现。
4.1 字典的结构
字段 | 说明 |
---|---|
type | dictType的指针,dictType是用于保存特定类型键值对的函数 |
privdata | 保存传输特定函数的可选参数 |
ht | 哈希表数组,ht[0]是当前使用哈希表,ht[1]重新散列使用哈希表 |
rehashidx | 目前重新散列的进度,正常情况下为-1 |
pauserehash | 重新散列是否暂停 |
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;
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;
4.2 哈希表的结构
redis的哈希存储也是数组链表的结构,在发生冲突的情况下,会将新的节点接到对应索引的老节点后面,使得索引所对应的数据是一个链表结构,链表里都是通过哈希算法计算得到该索引的对象。哈希表的结构包括下面4个字段。
字段 | 说明 |
---|---|
table | 哈希表数组 |
size | 哈希表大小 |
sizemask | 哈希表大小掩码,用于计算索引值 |
used | 哈希表已有节点数量,会用于计算负载因子,判断是否重新散列 |
/* This is our hash table structure. Every dictionary has two of this as we
* implement incremental rehashing, for the old to the new table. */
typedef struct dictht {
dictEntry **table;
unsigned long size;
unsigned long sizemask;
unsigned long used;
} dictht;
4.3 哈希节点
typedef struct dictEntry {
void *key; /* 保存键 */
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v; /* 保存值,值可以是一个指针,一个uint64_t整数,或int64_t整数 */
struct dictEntry *next; /* 指向链表的下一个节点,用于解决键冲突 */
} dictEntry;
4.4 字典重新散列 – rehash
redis的重新散列是通过负载因子来判定的,负载因子的大小 = used / size, 当负载因子太大或太小的情况下,会对哈希表的大小进行对应的扩展或收缩。
- 当服务器没有执行持久化命令的情况下,负载因子大于等于1,执行扩展rehash
- 如果服务器正在执行持久化命令,为节约内存,避免同时操作,负载因子要大于等于5,才会执行扩展rehash
redis的rehash并不是一次性做完的,而是分多次渐进的进行的,主要是因为当redis的hash键值对数量过于庞大,一次性执行,可能会导致服务器阻塞一段时间不能服务。
rehash具体流程:
- 为ht[1]分配空间,空间大小为min(2^n) > h[0].used
- 维护rehashindex,将它的值设置为0,开始rehash
- 每执行一步rehash,rehashindex增1
- 全部执行完,将rehashindex置为-1,将ht[0]释放,ht[1]改为ht[0],生成一个新的ht[1]
/* rehash执行ms毫秒,每次执行100步--dictRehash(d,100),最后返回rehash步数 */
int dictRehashMilliseconds(dict *d, int ms) {
if (d->pauserehash > 0) return 0;
long long start = timeInMilliseconds();
int rehashes = 0;
while(dictRehash(d,100)) {
rehashes += 100;
if (timeInMilliseconds()-start > ms) break;
}
return rehashes;
}
/* 执行N步rehash。如果还有未转移的key,则返回1,否则返回0
* 如果原hash表中的空位置太多,则最多访问10*n次空位置,避免该函数执行时间过长造成阻塞 */
int dictRehash(dict *d, int n) {
int empty_visits = n*10; /* 最大空位置访问数量 */
if (!dictIsRehashing(d)) return 0;
while(n-- && d->ht[0].used != 0) {
dictEntry *de, *nextde;
assert(d->ht[0].size > (unsigned long)d->rehashidx);
/* 找到对应索引 */
while(d->ht[0].table[d->rehashidx] == NULL) {
d->rehashidx++;
if (--empty_visits == 0) return 1;
}
de = d->ht[0].table[d->rehashidx];
/* 转移该索引下的所有链表节点 */
while(de) {
uint64_t h;
nextde = de->next;
/* Get the index in the new hash table */
h = dictHashKey(d, de->key) & d->ht[1].sizemask;
de->next = d->ht[1].table[h];
d->ht[1].table[h] = de;
d->ht[0].used--;
d->ht[1].used++;
de = nextde;
}
d->ht[0].table[d->rehashidx] = NULL;
d->rehashidx++;
}
/* 检测是否所有的key已经转移,是返回0 */
if (d->ht[0].used == 0) {
zfree(d->ht[0].table);
d->ht[0] = d->ht[1];
_dictReset(&d->ht[1]);
d->rehashidx = -1;
return 0;
}
return 1;
}
5、创建hash
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;
}