在做 KV 存储项目时,数组和红黑树都能完成键值对的增删改查,但如果目标是更快地根据 key 定位 value,那么Hash基本是绕不开的一种实现方式。
Hash 在 KV 存储中的核心价值很直接:
把“根据 key 找 value”这件事,尽量从遍历查找变成快速定位。对于 KV 系统来说,最常见的操作其实就是:
SET key valueGET keyDEL keyMOD key valueEXIST key如果底层采用哈希表来组织数据,就可以先通过 key 算出一个槽位,再去槽位里找对应节点。这样一来,KV 存储就从“顺序找”变成了“先定位、再局部查找”。而在这一套实现里,协议层也专门加上了
HSET/HGET/HDEL/HMOD/HEXIST这组命令,用来把网络层收到的字符串命令分发到哈希存储逻辑中。
一、KV 存储为什么适合用 Hash
先把这个问题想清楚:
KV 存储的本质,就是维护大量的key-value映射关系。既然查找时总是先给出 key,那么最自然的思路就是想办法把 key 尽快映射到某个固定位置,这正是哈希表擅长的事情。
当前这套实现里,哈希表的整体结构可以概括成两层:
第一层是桶数组,也就是一组槽位;
第二层是链表节点,用于处理多个 key 映射到同一个槽位时的冲突问题。头文件里把节点定义成hashnode_t,表定义成hashtable_t/kvs_hash_t,其中桶数组nodes的类型是hashnode_t **,说明每个槽位存的不是一个完整对象,而是某条链表的头指针。
可以先看最关键的数据结构:
typedef struct hashnode_s { char key[MAX_KEY_LEN]; char value[MAX_VALUE_LEN]; struct hashnode_s *next; } hashnode_t; typedef struct hashtable_s { hashnode_t **nodes; // 桶数组,每个元素都是链表头指针 int max_slots; // 哈希表最大槽位数 int count; // 当前已存储元素个数 pthread_mutex_t lock; // 并发访问时的互斥锁 } hashtable_t;这里最值得注意的是next。它说明这不是开放地址法,而是拉链法。也就是说,多个 key 如果哈希到同一个位置,不是往后探测,而是直接挂到同一个桶对应的链表上。对于 KV 存储项目来说,这种写法实现起来直观,也很适合先把逻辑跑通。
二、Hash 在 KV 中是怎么存数据的:从 key 到桶,再到节点
Hash 表真正工作的第一步,是先把 key 变成一个槽位编号。当前实现里的哈希函数比较简单:把 key 中每个字符的 ASCII 值累加起来,再对表大小取模。
static int _hash(char *key, int size) { if (!key) return -1; int sum = 0; int i = 0; while (key[i] != 0) { sum += key[i]; i++; } return sum % size; // 取模后得到桶下标 }给这段代码加一句最通俗的解释:
同一个 key 每次都会被算到同一个桶里。
比如 key 是"Teacher",那么_hash("Teacher", 1024)会得到一个固定的下标。之后无论是HSET Teacher Charon,还是HGET Teacher,都会先算出同一个桶位置,再进入这个桶对应的链表里继续操作。
有了下标之后,插入新节点前还要先创建节点。当前实现专门写了一个_create_node(),它的作用是申请一个新节点,并把 key、value 拷贝进去:
hashnode_t *_create_node(char *key, char *value) { hashnode_t *node = (hashnode_t*)malloc(sizeof(hashnode_t)); if (!node) return NULL; strncpy(node->key, key, MAX_KEY_LEN); // 拷贝 key strncpy(node->value, value, MAX_VALUE_LEN); // 拷贝 value node->next = NULL; // 初始时不指向其他节点 return node; }这一段其实非常像在做一个最小版的 KV 节点封装:
一个节点里保存 key、value,再通过next串成冲突链表。这样后面不管是插入、查询还是删除,操作的对象就不再是零散的字符串,而是统一的哈希节点。
三、KV 的核心操作是怎么落到 Hash 表上的
真正能体现“Hash 在 KV 存储中怎么用”的,其实就是这几个操作:插入、查询、删除、存在性判断。只要把这几个操作理顺了,整套逻辑就清楚了。
1)插入:SET/HSET
插入函数的核心思路是:
- 先算 key 对应的桶下标
- 到对应桶的链表里检查 key 是否已经存在
- 不存在就创建新节点
- 采用头插法挂到桶链表前面
代码主干如下:
int put_kv_hashtable(hashtable_t *hash, char *key, char *value) { if (!hash || !key || !value) return -1; int idx = _hash(key, MAX_TABLE_SIZE); pthread_mutex_lock(&hash->lock); // 加锁,保护并发访问 hashnode_t *node = hash->nodes[idx]; while (node != NULL) { if (strcmp(node->key, key) == 0) { pthread_mutex_unlock(&hash->lock); return 1; // 已存在 } node = node->next; } hashnode_t *new_node = _create_node(key, value); new_node->next = hash->nodes[idx]; // 头插法 hash->nodes[idx] = new_node; hash->count++; pthread_mutex_unlock(&hash->lock); return 0; }这里最关键的有两个点。
第一个点是先查重再插入。
因为 KV 存储里同一个 key 一般不能重复插入成两份数据,所以在真正创建节点前,要先遍历当前桶,看这个 key 有没有已经存在。
第二个点是头插法。new_node->next = hash->nodes[idx]; hash->nodes[idx] = new_node;的意思就是把新节点插到链表最前面。这样写实现简单,而且时间复杂度稳定,对初学阶段的 KV 项目很合适。
2)查询:GET/HGET
查询逻辑其实更直白:先算桶,再遍历桶链表,逐个比较 key。找到就返回 value,找不到就返回NULL。
char *get_kv_hashtable(hashtable_t *hash, char *key) { if (!hash || !key) return NULL; int idx = _hash(key, MAX_TABLE_SIZE); pthread_mutex_lock(&hash->lock); hashnode_t *node = hash->nodes[idx]; while (node != NULL) { if (strcmp(node->key, key) == 0) { pthread_mutex_unlock(&hash->lock); return node->value; // 找到后直接返回 value } node = node->next; } pthread_mutex_unlock(&hash->lock); return NULL; // 没找到 }这一段非常能体现 Hash 在 KV 里的定位方式:
不是全表扫描,而是先用哈希函数缩小范围,再只在一个桶里找。
这也是为什么哈希表会成为 KV 场景里的经典选择之一。因为对大量GET请求来说,查找范围被先压缩到了某个具体槽位,后面的链表遍历只是局部操作。
3)删除与存在性判断:DEL/HDEL、EXIST/HEXIST
删除稍微比查询复杂一点,因为它不仅要找到节点,还要把节点从链表里摘掉。当前实现把删除分成两种情况:
- 要删除的是头节点
- 要删除的是链表中间节点
代码主干如下:
int delete_kv_hashtable(hashtable_t *hash, char *key) { if (!hash || !key) return -2; int idx = _hash(key, MAX_TABLE_SIZE); pthread_mutex_lock(&hash->lock); hashnode_t *head = hash->nodes[idx]; if (head == NULL) return -1; // 桶为空,说明不存在 // 情况1:删除头节点 if (strcmp(head->key, key) == 0) { hashnode_t *tmp = head->next; hash->nodes[idx] = tmp; free(head); hash->count--; pthread_mutex_unlock(&hash->lock); return 0; } // 情况2:删除中间节点 hashnode_t *cur = head; while (cur->next != NULL) { if (strcmp(cur->next->key, key) == 0) break; cur = cur->next; } if (cur->next == NULL) { pthread_mutex_unlock(&hash->lock); return -1; // 没找到 } hashnode_t *tmp = cur->next; cur->next = tmp->next; free(tmp); hash->count--; pthread_mutex_unlock(&hash->lock); return 0; }这段代码很适合作为 KV 删除逻辑的一个典型例子。
因为它不只是“找到就删”,而是必须先考虑链表链接关系,否则很容易删掉节点后把整条桶链表弄断。
而存在性判断exist则更简单,它其实是对get的复用:先查一下,只要能拿到 value,就说明 key 存在。
int exist_kv_hashtable(hashtable_t *hash, char *key) { char *value = get_kv_hashtable(hash, key); if (value) return 1; // 找到 else return 0; // 没找到 }这里也能看出一个很典型的工程思路:
能复用查询逻辑,就不要再单独写一套“存在性查找”。
四、Hash 如何真正接入 KV 协议层
只有底层哈希表还不够,KV 存储项目里更关键的是:网络层收到的命令,最终要怎么落到哈希存储上。
当前协议层里已经把哈希相关命令加入了命令表和分发枚举中,包括:
HSETHGETHDELHMODHEXIST
服务端收到文本命令后,会先通过kvs_split_token()按空格拆分,再在kvs_filter_protocol()里识别命令字,并把请求转交给对应的哈希接口。
例如HGET的分发逻辑是:
case KVS_CMD_HGET: { char *result = kvs_hash_get(&global_hash, key); if (result == NULL) { length = sprintf(response, "NO EXIST\r\n"); } else { length = sprintf(response, "%s\r\n", result); } break; }这段代码说明协议层真正做的事只有两步:
- 调用底层哈希表去查
- 把底层返回结果翻译成客户端能看懂的响应字符串
同样,HSET、HDEL、HMOD、HEXIST也是这个思路:
协议层不直接关心桶数组、链表冲突这些细节,它只负责把“字符串命令”映射成“存储操作”。而哈希表则专心处理 key 和 value 的组织方式。
另外,在引擎初始化阶段,哈希表也被当成和数组、红黑树同级的底层存储组件统一初始化:
#if ENABLE_HASH memset(&global_hash, 0 , sizeof(kvs_hash_t)); kvs_hash_create(&global_hash); #endif这一步的意义在于:
让哈希表真正从“一个数据结构”变成“KV 存储引擎的一部分”。
也就是说,它不再只是单独测试的容器,而是已经和协议解析、命令分发、响应返回连成了一整条链路。
总结
在 KV 存储项目里,Hash 的价值并不抽象,它解决的是一个非常具体的问题:
如何更快地根据 key 找到 value。
这套实现的整体思路可以总结成一句话:
用哈希函数先把 key 映射到桶,再用链表处理冲突,最后通过协议层把 HSET/HGET/HDEL/HMOD/HEXIST 这些命令接到哈希表操作上。
从实现角度看,最关键的点有四个:
- 用
nodes维护桶数组,用hashnode_t维护桶内链表 - 用
_hash()完成 key 到桶下标的映射 - 用链表遍历实现插入、查询、删除和存在性判断
- 在协议层中把文本命令映射到哈希表接口,真正接入 KV 存储流程
所以,Hash 在 KV 存储中的作用,不只是“查得快”这么简单,更重要的是它非常适合做一套围绕 key 组织数据的基础存储结构。对于一个正在逐步完善的 KV 项目来说,它往往是从“能存”走向“更适合查”的关键一步。
0voice · GitHub