ok,直接进入主题,今天要讲的Jedis操作Redis的Hash类型,我们以操作带动理解的形式进行展开。
private void setHashValue(String key){
//设置值,field的为HashMap中的Key :hset key field value
String field = "otherKey";
Long hset0 = getJedis().hset(key, field, "可以存对象");
//hget 读取单个field的值
String hget = getJedis().hget(key, field);
//直接修改已存在的值 如果field已存在则执行修改,并返回0
Long hset1 = getJedis().hset(key, field, "hash为散列类型");
//hexists 判断field是否存在 :hexists key field
Boolean hexists = getJedis().hexists(key, field);
//hsetex 如果field不存在则添加, 已存在则不会修改值, 可用来添加要求不重复的field
Long hsetnx = getJedis().hsetnx(key, "id", "3");
Long id = getJedis().hsetnx(key, "id", "hsetnx");
//hmset 批量设置或获取field-value :hmset key field value [field value ...]
Map<String, String> msets = new HashMap<>(10);
msets.put("color", "red");
msets.put("width", "100");
msets.put("height", "80");
String hmset = getJedis().hmset(key, msets);
//hmget key field [field ...]
List<String> mget = getJedis().mget(key, "color", "width", "height");
String string = Arrays.toString(mget.toArray());
//hincrBy 在整数类型值上增加, 返回修改后的值 :hincrby key field
// hincrbyfloat key field
Long hincrBy = getJedis().hincrBy(key, field, 4);
//hlen 读取field数量
Long hlen = getJedis().hlen(key);
//hkeys 读取所有field
Set<String> hkeys = getJedis().hkeys(key);
//hvals 读取所有值
List<String> hvals = getJedis().hvals(key);
//hgetAll 获取所有键值对
Map<String, String> map = getJedis().hgetAll(key);
//hdel 删除field
Long hdel = getJedis().hdel(key);
//删除多个field
Long hdel1 = getJedis().hdel(key, "color", "width", "height");
/**
* SETEX 是一个原子性(atomic)操作,
关联值和设置生存时间两个动作会在同一时间内完成,
该命令在 Redis 用作缓存时,非常实用。
SET key value
EXPIRE key seconds # 设置生存时间
*/
String value = getJedis().setex(key, 1000, "value");
}
哈希类型是指键值本身又是一个键值对结构 即 Hash类型的Value为自己的一种ZipMap,当Hash对象存的value值不多的情况下,Redis 会采用节约内存空间的方式 压缩列表 来存储Value,而不真正使用HashMap结构,这个时候值对应的redisObject 的 encoding 为 ziplist,当存的Value 越来越多,Redis会采用 字典来存储Value,此时的encoding 为 ht。
在上一篇有提到过Redis有五种对象数据类型,十种的对象编码格式,而哈希对象 对应的type则为 #define OBJ_HASH 4 。
对象编码则为:
REDIS_HASH REDIS_ENCODING_ZIPLIST 使用压缩列表实现的哈希对象。
REDIS_HASH REDIS_ENCODING_HT 使用字典实现的哈希对象。
其对应的OBJECT ENCODING 对不同编码的输出为:
压缩列表 REDIS_ENCODING_ZIPLIST "ziplist"
字典 REDIS_ENCODING_HT "hashtable"
这样的话,我们就知道Redis的Hash对象的编码 为ziplist和hashtable, 压缩空间 其实是 使用ziplist编码,使用这个编码的满足
- 所有的键和值的字符串大小 小于64字节
- 键值对的数量小于521个
64和512分别表示的是字节和个数,当然这是可以通过下面的两个属性在redis.conf配置中修改的。使用ziplist编码的时候,保存同一键值对的两个节点通常会挨在一起,键节点在前,值节点在后(线性紧凑格式存储)。
## 当哈希值具有一个内存有效的数据结构时,哈希值使用一个内存有效的数据结构进行编码
条目数量少,且最大的条目不超过给定的条目
阈值。可以使用以下指令配置这些阈值。
hash-max-ziplist-value 64
hash-max-ziplist-entries 512
不满足上面两个条件的都会使用hashtable编码。Hash类型是String为key映射field_value为Value的映射表,hash的增、删操作都是O(1)的,特别适合存储对象,相对于直接将对象每一个字段转化成单个字符串来说,将对象存储在hash类型中更加的省去空间,并存取简单。为什么可省内存空间?
因为hash对象在创建的时候用的是zipmap(small hash)实现存储,zipmap不是hashtable,比起正常的hash 对象来说还可以节省hash本身创建需要的元数据的开销。事实上,zipmap的增、删、查都是O(n)的,但是在field数量不多的情况下,因为还是很快可以当做是O(1)的。当Field的数量多起来后超过限制后,Redis会自动将zipmap转化成正常的Hash实现。当然所说的限制也是可以这个限制可以在redis.conf配置文件中指定 的,如下:
#配置字段最多64个
hash-max-zipmap-entries 64
#配置value最大为512字节
hash-max-zipmap-value 512
需要注意的是Redis提供了接口(hgetall)可以直接取到全部的属性数据,但是如果内部Map的成员很多,那么涉及到遍历整个内部Map的操作,由于Redis单线程模型的缘故,这个遍历操作可能会比较耗时,而令其它客户端的请求完全不响应。
三、看底层操作原理
居然上面谈及hashtable,很明显hashtabl 比起其他来说就是有探索的必要,让我们看看?:
hashtable:一个hashtable由1个dict结构、2个dictht结构、1个dictEntry指针数组(称为bucket)和多个dictEntry结构组成。正常情况下,即hashtable没有进行rehash时,各部分关系如下图所示:
图上的bucket是一个数组,数组的每个元素都是指向dictEntry结构的指针。Redis中bucket数组的大小计算规则如下:
大于dictEntry的、最小的2^n。
例如,如果有1000个dictEntry,那么bucket大小为1024;如果有1500个dictEntry,则bucket大小为2048。
我们对着上面的图来分别解析dict、dictht 、dictEntry 这三个的结构:
1.Redis 字典所使用的哈希表由 dict 结构定义:
一般来说,通过使用dictht和dictEntry结构,便可以实现普通哈希表的功能;但是Redis的实现中,在dictht结构的上层,还有一个dict结构。
# Redis 会为用途不同的字典设置不同的类型特定函数.类型特定函数,
#type属性和privdata属性是为了适应不同类型的键值对,用于创建多态字典。
typedef struct dict {
//type指向一个 `dictType` 结构的指针,
//每个` dictType` 结构保存了一簇用于操作特作特定类型键值对的函数,、
dictType *type;
//私有数据
void *privdata;
//哈希表
dictht ht[2];
//记录了 rehash 当前的进度,如果没有进行 rehash, 值就为-1.
int rehashdx;
} dict;
我们上面有提到hashtable有一个dict、两个dictht、一个dictEntry。因为一个dict 包含了ht,ht是一个包含两个项(两个哈希表)的数组,每个项指向一个dicht结构,Redis的哈希会有1个dict、2个dictht结构的原因。通常情况下,所有的数据都是存在放dict的 ht[0]中,ht[1]只在rehash的时候使用。dict进行rehash操作的时候,将ht[0]中的所有数据rehash到ht[1]中。然后将ht[1]赋值给ht[0],并清空ht[1]。(既然提到了Rehash,那么就让Rehash在文章的最后来和大家见面~)
2.Redis 字典所使用的哈希表由 dict.h/dictht 结构定义:
# 哈希表数组
typedef struct dictht {
//table属性是一个指针,指向bucket;
dictEntry **table;
//size属性记录了哈希表的大小,即bucket的大小;
unsigned long size;
//哈希表大小掩码,用于计算索引值,
//sizemask属性的值总是为size-1,这个属性和哈希值一起决定一个键在table中存储的位置。
unsigned long sizemask;
//used记录了已使用的dictEntry的数量,也是该哈希表已有节点的数量;
unsigned long used;
}
3.dictEntry结构用于保存键值对,结构定义如下:
typedef struct dictEntry{
//key:键值对中的键
void *key;
union{
//键值对中的值,使用union(即共用体)实现,存储的内容既可能是一个指向值的指针,
//也可能是64位整型,或无符号64位整型;
void *val;
uint64_tu64;
int64_ts64;
}v;
#next:指向下一个dictEntry,用于解决哈希冲突问题
//在64位系统中,一个dictEntry对象占24字节(key/val/next各占8字节)
struct dictEntry *next;
}dictEntry;
dictEntry哈希表节点让我们知道redis 是dictEntry来作为存储空间的,每一个key都是唯一的,其实存储的key是String字符串通过hash算法计算过后转化成的hash值,之后在dictEntry里面找到对应的位置。这么讲来hash值就有可能一样,那么这么解决hash冲突呢?Redis采用了链地址法。
在插入一条新的数据时,会进行哈希值的计算,如果出现了hash值相同的情况,Redis 中采用了链地址法(separate chaining)来解决键冲突。每个哈希表节点都有一个next 指针,多个哈希表节点可以使用next 构成一个单向链表,被分配到同一个索引上的多个节点可以使用这个单向链表连接起来解决hash值冲突的问题。
四、Rehash(重新散列)的一些事情
当对hash表的操作越来越多的时候,hash表保持的键值对会逐渐发生改变,为了让hash表的负载因子保持在一个合适的范围内,需要使用Rehash(重新散列)来对hash表的大小进行扩展或者压缩。Rehash有三步,一起来看看:
第一步、分配内存空间
需要拓展/压缩的话,我们得需要知道hash表的内存是怎么分配空间的,为字典的 ht[1]
哈希表分配空间,空间的大小取决于要执行的操作,以及 ht0]
当前包含的键值对数量( used 属性值)如下?
1.拓展操作,ht[1]分配的空间大小为第一个大于等于ht[0].used*2的n次幂
2.压缩操作,那么ht[1] 的大小为第一个大于等于 ht[0].used *2的n次幂
第二步、数据转移
接下来看看数据转移,既然已经将ht[1]拓展了,要是需要将ht[0]数据转移到ht[1]应该是怎么样的呢?
将保存在 ht[0]
中所有键值对 rehash
到 ht[1]
上面: 任何事指的是重新计算键的哈希值和索引值,然后键键值对放到 ht[1]
哈希表的指定位置。
第三步、释放空间
然后就是如何释放ht[0] 的空间呢?当 ht[0]
包含的所有键值对都迁移到了 ht[1]
之后, 释放 ht[0]
, 再将 ht[1]
设置为 ht[0]
,并在 ht[1]
后面创建一个空白的哈希表。如下?:
以上的拓展和压缩是在数据量很少的时候,可以直接将键值对rehash 到ht[1]中,但是实际的开发中,数据量是巨大的,所以会采用渐进式rehash来拓展数据或者压缩数据。不是一次性,集中的就搞完,而是分多次,渐进式来处理数据的。
渐进式rehash 的详细步骤:
- 为ht[1] 分配空间,让字典同时持有ht[0]和ht[1]两个哈希表。
- 在字典中维持一个索引计数器变量rehashidx,并将它的值设置为0,表示rehash 开始。
- 在rehash 进行期间,每次对字典执行CRUD操作时,程序除了执行指定的操作以外,还会将 ht[0] 在
rehashidx
索引上的所有键值对 rehash 到 ht[1] 中,同时将rehashidx
加 1。 - 当ht[0]中所有数据转移到ht[1]中时(
ht[0]
所有的键值对全部 rehash 到ht[1]
上),rehashidx 会被设置成-1,表示rehash 结束。 - 采用渐进式rehash 的好处在于它采取分而治之的方式,避免了集中式rehash 带来的庞大计算量。在渐进式 rehash 执行期间,新添加到字典的键值对一律保存到
ht[1]
里,不会对ht[0]
做添加操作,这一措施保证了ht[0]
只减不增,并随着 rehash 进行, 最终编程空表。
五、hash类型的使用场景
Hash类型主要存储用户信息,存储用户信息,存储用户信息,重要的事情说三遍。为什么呢?
Hash可以将每个用户的id定义为键后缀,多对field-value对应每个用户的属性。
原生的String和序列化字符串类型都不好, 一来是占用键多,二来 要通过序列化和反序列化操作来存储,要序列化和反序列化所有的字段比较麻烦,而哈希结构比起String来缓存信息就比较直观和操作简单了。并且说一点,Redis的hash 省内存哇,上面涛涛不绝的讲那么多,不是白讲的。正儿八经的比较一下三种类型存储用户信息的优缺点,如下?:
- 原生字符串类型:每个属性一个键
优点:简单直观,每个属性都支持更新操作。
缺点:占用过多的键,内存占用量较大,同时用户信息内聚性比较差,所以此种方案一般不会在生产环境使用。
- 序列化字符串类型:将用户信息序列化后用一个键保存。
优点:简化编程,如果合理的使用序列化可以提高内存的使用效率。
缺点:序列化和反序列化有一定的开销,同时每次更新属性都需要把全部数据取出进行反序列化,更新后再序列化到Redis中。
- 哈希类型:每个用户属性使用一对field-value,但是只用一个键保存。
优点:简单直观,如果使用合理可以减少内存空间的使用。
缺点:要控制哈希在ziplist和hashtable两种内部编码的转换,hashtable会消耗更多内存。
六、Bucket存储空间
Bucket用来存储对象的存储空间,是海量对象服务Massive Object Service(简称MOS)中的一个存储空间的形象称呼,是存储对象的容器。现在已经被称为对象存储服务(ObjectStorage Service,简称OBS)。
那这里指的对象是我们常说的对象吗?说是也是,说不是也不是,请听下面分解:
这里的 对象 是存储在OBS的基本数据单位,用户上传的数据以对象的形式存储咋OBS的一个或者多个桶中。
对象的属性包括以下几部分:
- Key: 对象的名称,为经过UTF-8编码的长度大于0且不超过1024的字符序列,一个桶里的每个对象必须拥有唯一的对象名称。用户可使用桶名+对象名来存储和获取对应的对象。
- Metadata: 对象元数据,用来描述对象的信息。元数据又可分为系统元数据和用户元数据。系统元数据由OBS系统产生,在处理对象数据时使用。系统元数据包括:Date,Content-length, last-modify, Content-MD5等。用户元数据由用户上传对象时指定,是用户自己对对象的一些描述信息。这些元数据以键值对(Key-value)的形式随http头域一起上传到OBS系统。
- Version ID: 对象的版本号。用户上传对象时,可能不希望覆盖相同对象名称的旧的对象版本。OBS提供对象的多版本控制功能。默认情况下,桶没有设置多版本功能。用户可设置桶的多版本状态,用来开启或暂停桶的多版本功能。
对象存储是一种非常扁平化的存储方式,桶中存储的对象都在同一个逻辑层级,不像文件系统那样有一个很多层级的文件结构。在OBS中,桶的命名是全局唯一的。每个桶在创建时都会生成默认的桶ACL(AccessControl List),桶ACL列表的每项包含了对被授权用户授予什么样的权限,如读权限(READ)、写权限(WRITE)、完全控制权限(FULL_CONTROL)等。用户只有对桶有相应的权限,才可以对桶进行操作,如创建、删除、查询、设置桶ACL等。一个用户最多可创建100个桶,但每个桶中存放的对象的数量和大小总和没有限制,用户不需要考虑数据的可扩展性。
上面的ACL是指对象的接入权限控制列表。对象的每次接入都需要校验该权限控制列表,以实现对象的安全接入。每个对象在创建时都会生成默认的ACL, ACL列表的每项包含了对被授权用户授予什么样的权限,如读权限(READ)、写权限(WRITE)、完全控制权限(FULL_CONTROL)等。对存在的对象也可以调用更改ACL接口生成新的ACL。
Bucket的命名规范:
- 只能包含小写字母、数字、"-"、"."。
- 只能以数字或字母开头。
- 长度要求不少于3个字符,并且不能超过63个字符。
- 不能是IP地址。
- 不能以"-"结尾。 不可以包括有两个相邻的"."。
- "."和"-"不能相邻,如"my-.bucket"和"my.-bucket "都是非法的。
七、有关数据的分区和分桶:
hash分区 (散列分区)为通过指定分区编号来均匀分布数据的一种分区类型,因为通过在I/O设备上进行散列分区,使得这些分区大小一致。也就是只命名分区名称,这样均匀进行数据分布。分区是表的部分列的集合,可以为频繁使用的数据建立分区,这样查找分区中的数据时就不需要扫描全表,这对于提高查找效率很有帮助。分区是一种根据“分区列”(partition column)的值对表进行粗略划分的机制。
对于每一个表或者分区,可以进一步细分成桶,桶是对数据进行更细粒度的划分。默认时对某一列进行hash,使用hashcode对 桶的个数求模取余,确定哪一条记录进入哪一个桶。
桶是通过对指定列进行哈希计算来实现的,通过哈希值将一个列名下的数据切分为一组桶,并使每个桶对应于该列名下的一个存储文件。
分区和分桶最大的区别就是分桶随机分割数据库,分区是非随机分割数据库。
因为分桶是按照列的哈希函数进行分割的,相对比较平均;而分区是按照列的值来进行分割的,容易造成数据倾斜。
其次两者的另一个区别就是分桶是对应不同的文件(细粒度),分区是对应不同的文件夹(粗粒度)。