Redis数据结构之hash

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 注意事项

  1. 7.4 之前不能对单个 field 单独设置过期时间 ,EXPIRE 只能作用在整个 key;7.4 新增 HEXPIRE/HTTL/HPERSIST 支持 field 级过期;
  2. 防止大 key:field 数量建议控制在几千以内,HGETALL 会阻塞,遍历用 HSCAN;
  3. field 数量上限 2³²−1。
相关推荐
2601_967987371 小时前
SvelteKit Superforms 中 SPA 模式下页面重定向问题的分析与解决
spa·1024程序员节·sveltekit·superforms·重定向问题·jsonschema
ERP管理软件1 小时前
【致远】致远协同提示当前安装版本已过期。
1024程序员节·当前版本已过期
传感器与混合集成电路1 小时前
多计算节点怎么融合?全国产通用融合计算机三子卡架构与任务配置方法
嵌入式硬件·1024程序员节
HAPPY酷1 小时前
C++ 死锁调试全流程实操手册:从复现到 GDB 定位
1024程序员节
xiangyun611 小时前
C++ priority_queue 底层:手撕二叉堆与 Top K 问题
c++·算法·面试·stl·1024程序员节
qetfw1 小时前
Linux 文件清理:find、mtime 与安全传参验证
linux·shell·1024程序员节
liubinkaixin1 小时前
一炉硬质合金的温度曲线,卡在几十根补偿导线上
物联网·1024程序员节
蓝祁子yoky1 小时前
Qt学习日记 Day8:事件系统
1024程序员节
xianghongtao01161 小时前
04_ToM2Vec_解读报告
1024程序员节