Redis Hash 类型详解(命令、内部编码、关键源码、使用场景)
注:源码基于 Redis 7.x(listpack 命名),旧版本对应 ziplist。
Redis 整个数据库(每个 db)就是一个全局字典 dict(一张大哈希表),所有 key 都挂在这张全局哈希表里,key 永远是 String(SDS),value 指向一个 redisObject。当 value 的类型是 hash 时,这个 redisObject 内部再嵌套一个哈希结构。Hash 里的键值对称为 field 和 value ,且 field、value 都只能是 String(SDS),不能嵌套 list/hash 等其他类型。
一、Hash 数据模型
1.1 两层结构
全局 dict(redisDb.dict)
├── key: "user:1" ──► redisObject(type=hash)
│ ├── field: "name" ──► value: "zhangsan"
│ ├── field: "age" ──► value: "20"
│ └── field: "email" ──► value: "a@b.com"
├── key: "user:2" ──► redisObject(type=hash)
└── key: "counter" ──► redisObject(type=string)
1.2 与 String 存对象的对比
| 方案 | 写法 | 缺点 |
|---|---|---|
| String 存 JSON | set user:1 '{"name":"zs","age":20}' |
改一个字段要整个读出、反序列化、写回 |
| 多个 String key | set user:1:name zs、set user:1:age 20 |
key 数量爆炸,内存开销大 |
| Hash | hset user:1 name zs age 20 |
field 可单独读写,同 key 聚合,内存紧凑 |
二、Hash 常用命令
2.1 HSET 设置 field
HSET key field value [field value ...]
-
在 key 下设置一个或多个 field-value;
-
返回值是"新增"的 field 数量(更新已存在的 field 不计入);
-
Redis 4.0.0 起 HSET 支持多字段,HMSET 已废弃,统一用 HSET。
127.0.0.1:6379> hset user:1 name zhangsan age 20
(integer) 2 # 新增 2 个 field
127.0.0.1:6379> hset user:1 age 21
(integer) 0 # age 已存在,是更新,新增数为 0
2.2 HGET 获取 field
HGET key field
2.3 HDEL 删除 field
HDEL key field [field ...]
删除指定 field,返回实际删除的 field 个数;删除整个 key 用 DEL。
2.4 HLEN 统计 field 个数
HLEN key
返回 field 数量,key 不存在返回 0。
2.5 HMGET 批量获取
HMGET key field [field ...]
返回值顺序与传入 field 顺序一一对应,不存在的返回 nil。
2.6 HEXISTS 判断 field 是否存在
HEXISTS key field
返回 1/0,一次只能判断一个 field(EXISTS 可一次判断多个 key)。
2.7 HSETNX 不存在才设置
HSETNX key field value
field 不存在才设置,成功返回 1,已存在返回 0。
2.8 HKEYS / HVALS / HGETALL 全量获取
HKEYS key # 所有 field
HVALS key # 所有 value
HGETALL key # field、value 交替返回:field1, value1, field2, value2 ...
三者都是 O(N) ,大 hash 会阻塞单线程;需要遍历大 hash 时改用 HSCAN(游标分批迭代):
HSCAN key cursor [MATCH pattern] [COUNT count]
2.9 HINCRBY / HINCRBYFLOAT 数值自增
HINCRBY key field increment # 整数加减
HINCRBYFLOAT key field increment # 浮点加减
- value 必须是整数且不超过 8 字节整型范围(HINCRBY),否则报错;
- 没有 HDECRBY,减法用负数 increment;
- field 不存在时按 0 起步;HINCRBYFLOAT 有 double 精度问题。
2.10 HSTRLEN 计算 value 字节长度
HSTRLEN key field
返回 value 字节长度(3.2 引入),不存在返回 0。中文 UTF-8 下一个汉字占 3 字节。
2.11 时间复杂度汇总
| 命令 | 时间复杂度 |
|---|---|
| HGET / HSET / HEXISTS / HSTRLEN / HINCRBY / HSETNX | O(1) |
| HDEL / HMGET | O(k),k 为 field 个数 |
| HLEN | O(1) |
| HKEYS / HVALS / HGETALL | O(N),大 hash 阻塞 |
| HSCAN(每次调用) | O(1)(COUNT 个元素) |
三、Hash 的内部编码与关键源码
3.1 两种编码
| 编码 | 适用 | OBJECT ENCODING 返回 |
|---|---|---|
| listpack(紧凑列表) | 小 hash | "listpack" |
| hashtable(哈希表) | 大 hash | "hashtable" |
⚠️ 版本差异 :7.0 之前小 hash 用 ziplist (配置
hash-max-ziplist-entries/value);7.0 起替换为 listpack (配置改名hash-max-listpack-entries/value)。
3.2 编码转换条件(两个条件必须同时满足)
# 元素个数 ≤ 128(默认,不是 512)
hash-max-listpack-entries 128
# 每个 field/value 的长度 ≤ 64 字节(默认)
hash-max-listpack-value 64
任一条件不满足即升级为 hashtable;转换单向,不会因删数据自动退回。
3.3 源码入口:redisObject
每个 key 的 value 都指向一个 redisObject(server.h):
typedef struct redisObject {
unsigned type:4; // 类型:OBJ_STRING / OBJ_LIST / OBJ_HASH ...
unsigned encoding:4; // 编码:OBJ_ENCODING_LISTPACK / OBJ_ENCODING_HT ...
unsigned lru:LRU_BITS; // LRU 时钟 / LFU 计数
int refcount; // 引用计数(内存共享/回收)
void *ptr; // 指向真正的底层数据结构
} robj;
Hash 对象:type = OBJ_HASH;小 hash 时 encoding = OBJ_ENCODING_LISTPACK、ptr 指向 listpack;大 hash 时 encoding = OBJ_ENCODING_HT、ptr 指向 dict。
3.4 小 hash:listpack 内存布局
listpack 是一整块连续内存,整体布局如下(listpack.c 头部注释):
<total-bytes 4字节> <num-elements 2字节> <element-1> ... <element-N> <end 0xFF>
每个 entry:
<encoding> <content> <backlen>
encoding:内容类型与长度(整数/字符串、占多少字节);content:实际内容;backlen:本 entry 自身的总长度,使遍历可以从尾部倒着读(这也是它取代 ziplist 的关键------不记录前驱长度,消除连锁更新)。
Hash 用 listpack 存储时,field 和 value 成对相邻存放:
[field1][value1][field2][value2] ...
HGET 时从 lpFirst 开始,一次跳两个 entry(field、value)线性扫描,直到 field 匹配。
3.5 大 hash:dict 结构(哈希桶 + 链地址法)
大 hash 的 ptr 指向 dict(dict.h):
typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2]; // 两张哈希表!rehash 时新旧表共存
long rehashidx; // 渐进式 rehash 进度,-1 表示未在 rehash
int16_t pauserehash;
} dict;
typedef struct dictht {
dictEntry **table; // 哈希桶数组(桶内存放 entry 链表头指针)
unsigned long size; // 桶数量,永远是 2^n
unsigned long sizemask; // = size - 1,用 (hash & sizemask) 取桶下标
unsigned long used; // 已有 entry 数
} dictht;
typedef struct dictEntry {
void *key; // field(SDS)
union {
void *val;
uint64_t u64;
int64_t s64;
double d;
} v; // value(hash 时存 SDS)
struct dictEntry *next; // 链地址法:哈希冲突时挂链表
} dictEntry;
内存示意:
hash(field) & sizemask
┌────────────┐
│ table[0] │──► entry(key=age,val=20) ──► NULL
│ table[1] │──► entry(key=name,val=zs) ──► entry(key=city,val=bj) ──► NULL ← 冲突挂链
│ table[2] │
│ ... │
└────────────┘
为什么桶数一定是 2^n :取模 % size 用位运算 hash & (size-1) 代替,一次与操作即可定位桶下标。
3.6 渐进式 rehash 源码
扩容时 ht[1] 分配为第一个 >= 2*used 的 2^n 大小,然后迁移分摊到后续每次操作中,避免一次性搬运百万 entry 阻塞:
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;
/* 跳过空桶 */
while(d->ht[0].table[d->rehashidx] == NULL) {
d->rehashidx++;
if (--empty_visits == 0) return 1;
}
/* 把当前桶的整条链表搬到 ht[1] */
de = d->ht[0].table[d->rehashidx];
while(de) {
nextde = de->next;
dictAdd(d->ht[1], de->key, de->v.val);
dictFreeKey(d, de);
de = nextde;
d->ht[0].used--;
d->ht[1].used++;
}
d->ht[0].table[d->rehashidx] = NULL;
d->rehashidx++;
}
/* 全部迁完,ht[1] 变成 ht[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;
}
rehash 期间的查找:先查 ht0,查不到再查 ht1,且每次操作顺带迁移一个桶:
dictEntry *dictFind(dict *d, const void *key) {
dictEntry *he;
uint64_t h, idx, table;
if (dictSize(d) == 0) return NULL;
if (dictIsRehashing(d)) _dictRehashStep(d); // 顺带搬一个桶
h = dictHashKey(d, key);
for (table = 0; table <= 1; table++) { // 两张表都查
idx = h & d->ht[table].sizemask;
he = d->ht[table].table[idx];
while(he) {
if (key==he->key || dictCompareKeys(d,key,he->key))
return he;
he = he->next;
}
if (!dictIsRehashing(d)) return NULL;
}
return NULL;
}
扩容触发条件(负载因子 = used/size):
- 未执行 BGSAVE/BGREWRITEAOF 时,负载因子 ≥ 1 扩容;
- 正在做持久化(COW 期间尽量不搬)时,负载因子 ≥ 5 才扩容。
3.7 HSET 命令源码:编码判断与升级
HSET 入口(t_hash.c,简化版):
void hsetCommand(client *c) {
int i, created = 0;
robj *o;
if ((c->argc % 2) == 1) { addReplyErrorArity(c); return; } // field/value 必须成对
/* key 不存在则创建一个 listpack 编码的空 hash */
if ((o = hashTypeLookupWriteOrCreate(c, c->argv[1])) == NULL) return;
hashTypeTryConversion(o, c->argv, 2, c->argc-1);
/* 每对 field-value 调用一次 hashTypeSet,返回新增数 */
for (i = 2; i < c->argc; i += 2)
created += !hashTypeSet(o, c->argv[i]->ptr,
c->argv[i+1]->ptr, HASH_SET_COPY);
addReplyLongLong(c, created); // 返回的是"新增"数,与命令语义一致
}
核心写入函数(两条编码分支 + 升级判断都在这):
int hashTypeSet(robj *o, sds field, sds value, int flags) {
int update = 0;
if (o->encoding == OBJ_ENCODING_LISTPACK) {
unsigned char *zl = o->ptr;
unsigned char *fptr = lpFirst(zl);
unsigned char *vptr = (fptr ? lpNext(zl, fptr) : NULL);
/* ① 线性扫描,找已存在的 field */
while (fptr != NULL) {
if (hashTypeListpackFieldCompare(zl, fptr, field, 0)) {
zl = lpReplace(zl, vptr, (unsigned char*)value, sdslen(value)); // 覆盖 value
update = 1;
break;
}
fptr = lpNext(zl, vptr);
if (fptr == NULL) break;
vptr = lpNext(zl, fptr);
}
/* ② 没找到,在尾部追加 field、value 两个 entry */
if (!update) {
zl = lpAppend(zl, (unsigned char*)field, sdslen(field));
zl = lpAppend(zl, (unsigned char*)value, sdslen(value));
o->ptr = zl;
}
/* ③ 写完后检查阈值,超任一条件就升级为 hashtable */
if (hashTypeLength(o) > server.hash_max_listpack_entries)
hashTypeConvert(o, OBJ_ENCODING_HASHTABLE);
else if (sdslen(field) > server.hash_max_listpack_value ||
sdslen(value) > server.hash_max_listpack_value)
hashTypeConvert(o, OBJ_ENCODING_HASHTABLE);
} else if (o->encoding == OBJ_ENCODING_HASHTABLE) {
/* 哈希表分支:哈希查找 O(1) */
dictEntry *de = dictFind(o->ptr, field);
if (de) {
dictSetVal(o->ptr, de, sdsdup(value)); // 覆盖
update = 1;
} else {
dictAdd(o->ptr, sdsdup(field), sdsdup(value)); // 新增
}
}
return update; // 0=新增,1=更新
}
升级时把 listpack 里的成对 entry 逐个搬进新建的 dict:
void hashTypeConvertListpackToHashtable(robj *o) {
unsigned char *zl = o->ptr;
unsigned char *p = lpFirst(zl);
dict *d = dictCreate(&hashDictType);
dictExpand(d, lpLength(zl) / 2); // 预分配桶数
while (p) {
sds field = hashTypeGetFromListpack(o, p, NULL, NULL);
p = lpNext(zl, p);
sds value = hashTypeGetFromListpack(o, p, NULL, NULL);
p = lpNext(zl, p);
dictAdd(d, field, value); // field/value 成对加入
}
lpFree(zl);
o->ptr = d;
o->encoding = OBJ_ENCODING_HASHTABLE; // 切换编码
}
3.8 HGET 查找(两个分支)
void hgetCommand(client *c) {
robj *o = lookupKeyReadOrReply(c, c->argv[1], shared.null[c->resp]);
if (o == NULL) return;
if (checkType(c, o, OBJ_HASH)) return;
sds v = hashTypeGetValue(o, c->argv[2]->ptr);
if (v == NULL) addReplyNull(c);
else addReplyBulkCBuffer(c, v, sdslen(v));
}
底层差异:
- listpack 分支 :
lpFirst → lpNext顺序扫描,field/value 两两比较,O(N); - hashtable 分支 :
dictFind哈希定位桶 + 链表比较,平均 O(1)。
这也解释了阈值为什么存在:元素少、值短时,线性扫描的成本可接受且 listpack 省内存;元素多了以后 O(N) 扫描不可接受,必须换成哈希表。
3.9 两种编码对比
| 对比项 | listpack | hashtable(dict) |
|---|---|---|
| 内存布局 | 一整块连续内存,无指针开销 | 独立 dictEntry + 多次内存分配,指针开销大 |
| 查找 | O(N) 线性扫描 | O(1) 哈希 |
| 适用 | 小、短、紧凑 | 大、多、需要快速查找 |
| 扩容 | 超阈值整体转换 | 渐进式 rehash,不阻塞 |
| 7.0 替代 | ziplist(消除连锁更新) | 一直是 dict |
四、使用场景
4.1 描述一个对象的多个属性
hset user:1 name zhangsan age 20 city beijing email a@b.com
hget user:1 name
hset user:1 age 21 # 只改一个字段,无需动其他字段
4.2 不同对象的同一属性(聚合统计)
# 每篇文章的浏览量:field=文章id,value=浏览量
hincrby article:views 1001 1
hincrby article:views 1002 1
hget article:views 1001
# 购物车:key=用户,field=商品id,value=数量
hset cart:user:1 goods:100 2
hincrby cart:user:1 goods:100 -1
4.3 注意事项
- 7.4 之前不能对单个 field 单独设置过期时间 ,
EXPIRE只能作用在整个 key;7.4 新增HEXPIRE/HTTL/HPERSIST支持 field 级过期; - 防止大 key:field 数量建议控制在几千以内,HGETALL 会阻塞,遍历用 HSCAN;
- field 数量上限 2³²−1。