百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术教程 > 正文

从Redis源码上来聊聊KV模型-Hash数据类型

mhr18 2024-12-14 11:10 16 浏览 0 评论

观其面

哈希结构大家也不陌生了,kv嘛,redis不就是kv模型嘛,那么Redis里面hash又是啥?

这里给大家说一下,就是套娃,用伪代码表示就是这样

java复制代码HashMap<String,HashMap<String,String>>

当然,实际的类型是泛型。

和String相比具体的区别就是这样

如果你用mysql数据库来表示的话

hash其实是field,value

然后每个hash对应一个表

如果放在Redis的话就是一个key里面放了很多个field,value

可以这样理解:

bash复制代码key field value

其实神简单,说多了还容易绕

应用场景

购物车实现

key:uid

field:商品id

value: 数量

  • 添加商品:HSET cart:{用户id} {商品id} 1
  • 添加数量:HINCRBY cart:{用户id} {商品id} 1
  • 商品总数:HLEN cart:{用户id}
  • 删除商品:HDEL cart:{用户id} {商品id}
  • 获取购物车所有商品:HGETALL cart:{用户id}

获取商品信息还得进数据库回表一次,拿到完整的商品信息

分布式锁实现

[Redis分布式锁深入分析 – Karos (wzl1.top)](https:// www.wzl1.top/2023/06/16/…)

之前我讲过,这里再简单说一下把

key为锁名,field是线程ID(加上服务的UUID),然后value是进入锁的次数

看一个加锁的Lua脚本吧,为什么用Lua脚本?我们后面细说。

lua复制代码    -- 如果Lock存在
    if redis.call("exists",KEYS[1]) ~= 0 then
        -- 如果不是自己的锁
        if redis.call("exists",KEYS[1],ARGS[1]) == 0 then
            -- 不是自己的锁,返回剩余时间
            return redis.call("pttl",KEYS[1]);
        end
        -- 如果是自己的锁就记录次数
        redis.call("hincrby",KEYS[1],ARGS[1],1);
        -- 延期
        redis.call("pexpire",KEYS[1],ARGS[2]);
    else
        -- 如果Lock不存在,那么就直接加上就可以了,hhh
        redis.call("hset",KEYS[1],ARGS[1],1);
        -- 设置默认延期
        redis.call("pexpire",KEYS[1],ARGS[2]);
    end
    return nil;

究其身

当数据量少的时候使用压缩链表(早期,Redis7.0以后完成的对ZipList的全部替代,都用LIstPack来实现了)

  • 如果哈希类型元素个数小于 512 个(默认值,可由 hash-max-ziplist-entries和hash-max-listpack-entries 配置),所有值小于 64 字节(默认值,可由 hash-max-ziplist-value 配置)的话,Redis 会使用压缩列表/ListPack作为 Hash 类型的底层数据结构;
  • 如果哈希类型元素不满足上面条件,Redis 会使用哈希表作为 Hash 类型的 底层数据结构。

哈希结构客户端哈希设计(基于Redis 7.0解释)

在之前我们讲过dict,dict是用于存储key和value的,但是这里我们客户端hash是将dictEntry的集合放入value,显然不能够用原来服务器的value来存储一个新的dict,你不知道客户端输入的是什么属性

呃呃呃,这上面几句话说的可能有点绕,看代码吧。

其实Redis的开发者确实考虑到了这一点,所以做了个区分,两个虽然都叫做dict,但是所属dir不一样

c复制代码// /deps/hiredis/dict.h	客户端源码
typedef struct dict {
    dictEntry **table;
    dictType *type;
    // 哈希表大小,桶的数量
    unsigned long size;
    // 哈希表大小掩码,用于计算索引值
    unsigned long sizemask;
    // 已有哈希节点数量
    unsigned long used;
    void *privdata;
} dict;

typedef struct dictEntry {
    void *key;
    void *val;
    struct dictEntry *next;
} dictEntry;

typedef struct dictType {
    unsigned int (*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);	// 析构
} dictType;

先说一下新增元素,RedisHash采用的是头插法

c复制代码/* Add an element to the target hash table */
static int dictAdd(dict *ht, void *key, void *val) {
    int index;
    dictEntry *entry;

    /* Get the index of the new element, or -1 if
     * the element already exists. */
    if ((index = _dictKeyIndex(ht, key)) == -1)
        return DICT_ERR;

    /* Allocates the memory and stores key */
    entry = hi_malloc(sizeof(*entry));
    if (entry == NULL)
        return DICT_ERR;
	// 执行头茬
    entry->next = ht->table[index];
    ht->table[index] = entry;

    /* Set the hash entry fields. */
    dictSetHashKey(ht, entry, key);
    dictSetHashVal(ht, entry, val);
    ht->used++;
    return DICT_OK;
}

对于Hash结构来说,有个扩容操作,同时会伴随rehash,我们来看看

c复制代码/* Expand or create the hashtable */
static int dictExpand(dict *ht, unsigned long size) {
    dict n; /* the new hashtable */
    unsigned long realsize = _dictNextPower(size), i;	// 通过该方法获取真实内存,扩大为比size大的第一个2^n

    /* the size is invalid if it is smaller than the number of
     * elements already inside the hashtable */
    if (ht->used > size)		// size不合法,如果哈希总节点数大于了size
        return DICT_ERR;
    
	// 对新的hash进行初始化
    _dictInit(&n, ht->type, ht->privdata);
    n.size = realsize;
    n.sizemask = realsize-1;
    n.table = hi_calloc(realsize,sizeof(dictEntry*));
    if (n.table == NULL)
        return DICT_ERR;

    /* Copy all the elements from the old to the new table:	把旧数据复制到新的哈希表中
     * note that if the old hash table is empty ht->size is zero,
     * so dictExpand just creates an hash table. */
    n.used = ht->used;
    for (i = 0; i < ht->size && ht->used > 0; i++) {
        dictEntry *he, *nextHe;

        if (ht->table[i] == NULL) continue;

        /* For each hash entry on this slot... */
        he = ht->table[i];
        while(he) {
            unsigned int h;

            nextHe = he->next;		// 先获取下一次循环需要的entry
            /* Get the new element index */	// 通过计算哈希值获取新的下标(索引计算),其实相当于对长度取余
            h = dictHashKey(ht, he->key) & n.sizemask;	
            // 头插
            he->next = n.table[h];
            n.table[h] = he;
            ht->used--;		// ht被使用的节点减少
            /* Pass to the next element */
            he = nextHe;	// 移步到下一个
        }
    }
    assert(ht->used == 0);
    hi_free(ht->table);

    /* Remap the new hashtable in the old */
    *ht = n;
    return DICT_OK;
}

为什么扩容是2^n

其实这里哈希表扩容和Java中HashMap扩容原理一样,我们来回忆一下

  • HashMap是如何扩容的
  • HashMap扩容依靠的是resize()方法,根据负载因子来计算临界值,达到临界值进行扩容。
  • 而resize()方法也是通过一个newTable进行操作的
  • Rehash如何确定新元素的位置

通过将hashcode()%length,当leng为2n2^n2n时,刚好等于hashcode()&(length-1)

但是对于Redis对象存储来说,具体的rehash的方法是拉链法,和JavaSE HashMap类似,同样使用,这样做只是为了加快计算(直接对内存庶几乎进行操作)

Redis服务端存储原理-Hash解析

ReHash

在之前,其实讲到了,Redis的存储原理,其中使用的dict中,直接便是dictEntry

c复制代码    dictEntry **ht_table[2];	// 第二个表用来进行rehash	
    /**
    *	dictEntry **ht_table[2]; <==>
    * 	typedef struct dictht{
    *		dictEntry **table;
    *	}dictht;
	*    ditcht ht[2];
    **/

之前我们说到第二个ht_table用来做rehash,是这样的,原理其实和上面也类似。

不过为了内存切换方便,每次rehash的时候,只需要将ht_table[0]和ht_table[1]进行交换即可,扩容完毕后直接使用,不用再换回去(类比JVM中幸存区From和幸存区To的交换)。

那么为什么服务端存储kv的哈希靠复制交换,而客户端确是直接新建一个dict N呢?

其实原因可想而知。

  • 服务器的ht数量是固定的,而hash的数据是由客户端操作,数量不定,如果采取复制的话浪费空间,要扩容的时候new一个就行,这一个其实不太重要,重要的在后面
  • 最重要的其实时,服务端一定会保证长连接状态,当数据量很大的时候,我们不可能花较长时间来进行rehash,所以我们采用两个哈希表进行渐进式rehash

渐进式ReHash

为了避免 rehash 在数据迁移过程中,因拷贝数据的耗时,影响 Redis 性能的情况,所以 Redis 采用了渐进式 rehash,也就是将数据的迁移的工作不再是一次性迁移完成,而是分多次迁移。

渐进式 rehash 步骤如下:

  • 给「哈希表 2」 分配空间;
  • 在 rehash 进行期间,每次哈希表元素进行新增、删除、查找或者更新操作时,Redis 除了会执行对应的操作之外,还会顺序将「哈希表 1 」中索引位置上的所有 key-value 迁移到「哈希表 2」 上
  • 随着处理客户端发起的哈希表操作请求数量越多,最终在某个时间点会把「哈希表 1 」的所有 key-value 迁移到「哈希表 2」,从而完成 rehash 操作。

这样就巧妙地把一次性大量数据迁移工作的开销,分摊到了多次处理请求的过程中,避免了一次性 rehash 的耗时操作。

在进行渐进式 rehash 的过程中,会有两个哈希表,所以在渐进式 rehash 进行期间,哈希表元素的删除、查找、更新等操作都会在这两个哈希表进行。

比如,查找一个 key 的值的话,先会在「哈希表 1」 里面进行查找,如果没找到,就会继续到哈希表 2 里面进行找到。

另外,在渐进式 rehash 进行期间,新增一个 key-value 时,会被保存到「哈希表 2 」里面,而「哈希表 1」 则不再进行任何添加操作,这样保证了「哈希表 1 」的 key-value 数量只会减少,随着 rehash 操作的完成,最终「哈希表 1 」就会变成空表。

ReHash触发条件

介绍了 rehash 那么多,还没说什么时情况下会触发 rehash 操作呢?

rehash 的触发条件跟**负载因子(load factor)**有关系。

负载因子可以通过下面这个公式计算:

触发 rehash 操作的条件,主要有两个:

  • 当负载因子大于等于 1 ,并且 Redis 没有在执行 bgsave 命令或者 bgrewiteaof 命令,也就是没有执行 RDB 快照或没有进行 AOF 重写的时候,就会进行 rehash 操作。
  • 当负载因子大于等于 5 时,此时说明哈希冲突非常严重了,不管有没有有在执行 RDB 快照或 AOF 重写,都会强制进行 rehash 操作。

服务端哈希扩容

在讲扩容源码之前,我们先来回顾之前讲的内容

c复制代码// dictEntry的实现很简单,就是个链表
struct dictEntry {
    void *key;
    union {
        void *val;			// 其他
        uint64_t u64;		// 整数
        int64_t s64;		// 整数
        double d;			// 双精度数值
    } v;
    struct dictEntry *next;     /* Next entry in the same hash bucket.  指向下一个hash值相同的entry*/
    void *metadata[];           /* An arbitrary number of bytes (starting at a
                                 * pointer-aligned address) of size as returned
                                 * by dictType's dictEntryMetadataBytes(). */
};


struct dict {
    dictType *type;

    dictEntry **ht_table[2];	// 第二个表用来进行rehash	
    /**
    *	dictEntry **ht_table[2]; <==>
    * 	typedef struct dictht{
    *		dictEntry **table;
    *	}dictht;
	*    ditcht ht[2];
    **/
    
    unsigned long ht_used[2];	
    
	// 用于保证rehash安全,如果值为-1,那么不能执行rehash
    long rehashidx;

    // redis6之后新加入,如果值>0则暂停rehash,保证rehash安全
    /* Keep small vars at end for optimal (minimal) struct padding */
    int16_t pauserehash; /* If >0 rehashing is paused (<0 indicates coding error) */
    
    // 哈希的内存指数
    signed char ht_size_exp[2]; 

    // 由dictType的dictEntryBytes定义的大小的任意字节数(从指针对齐的地址开始)。
    void *metadata[];          
};

说到这里,我们来看看扩容的源码,先来个宏定义

c复制代码#define dictIsRehashing(d) ((d)->rehashidx != -1)

再来一个获取新内存大小的源码

c复制代码static signed char _dictNextExp(unsigned long size)
{
    unsigned char e = DICT_HT_INITIAL_EXP;

    if (size >= LONG_MAX) return (8*sizeof(long)-1);
    // 这里采用的是用移位来计算,保证效率
    while(1) {
        if (((unsigned long)1<<e) >= size)
            return e;
        e++;
    }
}

一个判断是否运行扩容的"私有"函数

c复制代码/* ------------------------- private functions ------------------------------ */

/* Because we may need to allocate huge memory chunk at once when dict
 * expands, we will check this allocation is allowed or not if the dict
 * type has expandAllowed member function. */
static int dictTypeExpandAllowed(dict *d) {
    // 不允许返回1
    if (d->type->expandAllowed == NULL) return 1;
    // 这里的计算我们后面讲
    return d->type->expandAllowed(
                    DICTHT_SIZE(_dictNextExp(d->ht_used[0] + 1)) * sizeof(dictEntry*),
                    (double)d->ht_used[0] / DICTHT_SIZE(d->ht_size_exp[0]));
}

下面是扩容源码

c复制代码	/* Expand the hash table if needed */
static int _dictExpandIfNeeded(dict *d)
{
    /* Incremental rehashing already in progress. Return. */
    // 如果正在rehash,那么直接返回Ok
    if (dictIsRehashing(d)) return DICT_OK;

    /* If the hash table is empty expand it to the initial size. */
    // 如果哈希表是空的,那么就创建
    if (DICTHT_SIZE(d->ht_size_exp[0]) == 0) return dictExpand(d, DICT_HT_INITIAL_SIZE);

    /* If we reached the 1:1 ratio, and we are allowed to resize the hash
     * table (global setting) or we should avoid it but the ratio between
     * elements/buckets is over the "safe" threshold, we resize doubling
     * the number of buckets. */
    // 如果不被允许扩展就返回ok
    if (!dictTypeExpandAllowed(d))
        return DICT_OK;
    if ((dict_can_resize == DICT_RESIZE_ENABLE &&
         d->ht_used[0] >= DICTHT_SIZE(d->ht_size_exp[0])) ||
        (dict_can_resize != DICT_RESIZE_FORBID &&
         d->ht_used[0] / DICTHT_SIZE(d->ht_size_exp[0]) > dict_force_resize_ratio))
    {
        return dictExpand(d, d->ht_used[0] + 1);	// +1是为了避免和当前分配相同的内存
    }
    // 分配成功
    return DICT_OK;
}


/* Expand or create the hash table,
 * when malloc_failed is non-NULL, it'll avoid panic if malloc fails (in which case it'll be set to 1).
 * Returns DICT_OK if expand was performed, and DICT_ERR if skipped. */
int _dictExpand(dict *d, unsigned long size, int* malloc_failed)
{
    if (malloc_failed) *malloc_failed = 0;

    /* the size is invalid if it is smaller than the number of
     * elements already inside the hash table */
    if (dictIsRehashing(d) || d->ht_used[0] > size)		// 判断是否能够哈希,并且传入的size合法
        return DICT_ERR;

    /* the new hash table */
    dictEntry **new_ht_table;		// 创建一个新的哈希表
    unsigned long new_ht_used;
    signed char new_ht_size_exp = _dictNextExp(size);	// 计算log(新哈希表的长度)

    /* Detect overflows */
    size_t newsize = 1ul<<new_ht_size_exp;				// 计算长度
    if (newsize < size || newsize * sizeof(dictEntry*) < newsize)	// 如果比原来的还小
        return DICT_ERR;

    /* Rehashing to the same table size is not useful. */
    if (new_ht_size_exp == d->ht_size_exp[0]) return DICT_ERR;		// 如果和原来相同,没用

    /* Allocate the new hash table and initialize all pointers to NULL */	// 分配内存,并且memset all为NULL
    if (malloc_failed) {
        new_ht_table = ztrycalloc(newsize*sizeof(dictEntry*));
        *malloc_failed = new_ht_table == NULL;	// 判断是否分配成功
        if (*malloc_failed)
            return DICT_ERR;
    } else
        new_ht_table = zcalloc(newsize*sizeof(dictEntry*));

    new_ht_used = 0;		// 新的表还没有使用过

    /* Is this the first initialization? If so it's not really a rehashing
     * we just set the first hash table so that it can accept keys. */
    if (d->ht_table[0] == NULL) {				// 如果是第一次次创建,使用第一张表为哈希表
        d->ht_size_exp[0] = new_ht_size_exp;
        d->ht_used[0] = new_ht_used;
        d->ht_table[0] = new_ht_table;
        return DICT_OK;
    }
    /* Prepare a second hash table for incremental rehashing */
    // 使用第二张表进行rehash
    d->ht_size_exp[1] = new_ht_size_exp;
    d->ht_used[1] = new_ht_used;
    d->ht_table[1] = new_ht_table;
    // 表示当前表正在进行哈希中,后续不可以进行rehash,具体的判断在前面的宏定义内
    d->rehashidx = 0;
    return DICT_OK;
}



相关推荐

MYSQL数据同步(mysql数据同步方式)

java开发工程师在实际的开发经常会需要实现两台不同机器上的MySQL数据库的数据同步,要解决这个问题不难,无非就是mysql数据库的数据同步问题。但要看你是一次性的数据同步需求,还是定时数据同步,亦...

SpringBoot+Redis实现点赞收藏功能+定时同步数据库

由于点赞收藏都是高频率的操作,如果因此频繁地写入数据库会造成数据库压力比较大,因此采用redis来统计点赞收藏浏览量,之后定时一次性写入数据库中,缓解数据库地压力。一.大体思路设计redis中的储存结...

双11订单洪峰:Codis代理层如何扛住Redis集群搞不定的120万QPS?

双11订单洪峰下的技术挑战每年的双11购物节,都是对电商平台技术架构的极限考验。当零点钟声敲响,海量用户瞬间涌入,订单量呈指数级增长,系统需要承受每秒数十万甚至上百万次的请求。作为电商系统的核心组件之...

基于spring boot + MybatisPlus 商城管理系统的Java开源商城系统

前言Mall4j项目致力于为中小企业打造一个完整、易于维护的开源的电商系统,采用现阶段流行技术实现。后台管理系统包含商品管理、订单管理、运费模板、规格管理、会员管理、运营管理、内容管理、统计报表、权限...

商品券后价产品设计方案(显示券后价)

如何设计一套高效、准确且稳定的券后价计算系统,是电商产品设计中的关键挑战之一。本文详细介绍了商品券后价的产品设计方案,从背景目标、功能设计、系统实现逻辑到异常处理机制等多个方面进行了全面阐述。一、背景...

外观(门面)模式-Java实现(java 门面模式)

定义外观模式(FacadePattern),也叫门面模式,原始定义是:为了子系统中的一组接口提供统一的接口。定义一个更高级别的接口,使子系统更易于使用。大大降低应用程序的复杂度,提高了程序的可维护性...

Mall - 用 SpringBoot 实现一个电商系统

目前最为主流的Web开发技术,包括SpringBoot、MyBatis、MongoDB、Kibina、Docker、Vue等,都是开发者十分需要掌握的技术。有没有一个全面而又实际的项目,能把这...

腾讯云国际站:哪些工具能实现可视化运维?

本文由【云老大】TG@yunlaoda360撰写开源工具Grafana:开源的可视化平台,可与Prometheus、Elasticsearch、MySQL等多种数据源集成,将复杂监控数据转化...

系统稳定性保障全流程实战:事前、事中、事后 Java 代码详解

在互联网架构中,系统稳定性是生命线。本文基于“事前预防、事中管控、事后复盘”三阶段模型,结合Java实战代码,深度解析如何构建高可用系统,让你的服务稳如磐石!一、事前:未雨绸缪,筑牢防线1.发...

Java面试题:拆分微服务应该注意哪些地方方,如何拆分?

在拆分微服务时,需要综合考虑业务、技术和组织等多方面因素,以下是关键注意事项及拆分策略的详细说明:一、拆分注意事项1.业务边界清晰化单一职责原则:每个服务应专注于单一业务能力,例如订单服务仅处理订单...

软件性能调优全攻略:从瓶颈定位到工具应用

性能调优是软件测试中的重要环节,旨在提高系统的响应时间、吞吐量、并发能力、资源利用率,并降低系统崩溃或卡顿的风险。通常,性能调优涉及发现性能瓶颈、分析问题根因、优化代码和系统配置等步骤,调优之前需要先...

Docker Compose实战,多容器协同编排的利器,让开发部署更高效!

开篇导读你是否有过这样的经历?启动一个项目,数据库、Redis、Web服务得一个个敲dockerrun?想让别人复现你的开发环境,却得发一堆复杂的启动命令?明明都是容器,为什么不能“一键启动”所...

如何设计Agent的记忆系统(agent记忆方法)

最近看了一张画Agent记忆分类的图我觉得分类分的还可以,但是太浅了,于是就着它的逻辑,仔细得写了一下在不同的记忆层,该如何设计和选型先从流程,作用,实力和持续时间的这4个维度来解释一下这几种记忆:1...

不了解业务和技术术语怎么做好产品和项目?

基础技术术语术语分类解释API开发技术应用程序接口,不同系统间数据交互的协议(如支付接口、地图接口)。SDK开发工具软件开发工具包,包含API、文档和示例代码,帮助快速接入服务。RESTfulAPI...

Docker 架构详解与核心概念实战图解:一文读懂容器的前世今生

不懂Docker架构,你只是“用容器的人”;理解了它的底层逻辑,才能成为真正的高手!在学习Docker之前,很多同学可能会陷入一个误区:“反正我用dockerrun就能跑起服务,架构这种...

取消回复欢迎 发表评论: