在 第 1 篇的编码表里,Hash 有两种形态,字段少的时候是 listpack,字段一多或者某个 value 一长就掉到 hashtable。掉下去之后,底下就是 Redis 自己实现的一个哈希表,代码里叫 dict。
dict 表面上就是个普通的哈希表,真正值得讲的是它怎么扩容。如果表里有几百万个节点,一次性搬完会把 Redis 主线程卡住,所以它把搬迁拆散了做,这就是渐进式 Rehash。
dict 的结构
c
typedef struct dict {
dictType *type; // 一组操作函数
void *privdata;
dictht ht[2]; // 注意是两个哈希表
long rehashidx; // 不在 rehash 时是 -1
} dict;
typedef struct dictht {
dictEntry **table; // 桶数组,每个元素是一条链表的头
unsigned long size; // 桶的数量,一定是 2 的幂
unsigned long sizemask; // size - 1
unsigned long used; // 已有的节点数
} dictht;
typedef struct dictEntry {
void *key;
union { void *val; uint64_t u64; int64_t s64; double d; } v;
struct dictEntry *next; // 拉链,指向下一个冲突节点
} dictEntry;
三个东西要留意:
ht是长度 2 的数组,ht[0]平时用,ht[1]只在 rehash 的时候才分配。有第二个表,就是为了 rehashsize一定是 2 的幂,这样算下标可以用hash & sizemask代替取模,位运算快rehashidx是进度指针,-1 表示没在 rehash
哈希冲突
两个 key 算出来的下标一样,就叫冲突。Redis 用的是链地址法,冲突的节点挂在同一个桶的链表上:
text
table[5] ──► entry(K1) ──► entry(K7) ──► entry(K3) ──► NULL
新节点插在链表头部,不是尾部。原因是 dictEntry 只有 next 没有 prev,也没有尾指针,头插一步搞定是 O(1),尾插得先走到链表末尾,是 O(n)。
链表长了会退化。如果攻击者能构造出一堆哈希值相同的 key,所有节点挤在一条链上,查找就从 O(1) 变成 O(n)。所以 Redis 4.0 之后哈希函数换成了 SipHash,它对这种碰撞攻击不敏感。
拉链法的桶数够多、哈希函数够散,链就不会长,每次查找差不多就是算一次哈希、比一次 key。真正麻烦的不是冲突,是冲突多到需要扩容。
扩容和缩容
负载因子(load factor)就是 used / size,用来衡量桶的拥挤程度。
扩容的触发条件在 _dictExpandIfNeeded 里:
c
if (d->ht[0].used >= d->ht[0].size &&
(dict_can_resize ||
d->ht[0].used / d->ht[0].size > dict_force_resize_ratio)) // 5
{
dictExpand(d, d->ht[0].used * 2);
}
翻译一下:只要用掉的节点数不小于桶数(负载因子 ≥ 1),正常情况下就扩容。但有个前提 dict_can_resize,它在有子进程跑 BGSAVE 或 BGREWRITEAOF 的时候是 0。
为什么这时候要压住?BGSAVE 会 fork 一个子进程,父子进程共享内存页,父进程任何写操作都会触发写时复制。扩容要改 table 指针、搬大量数据,会搅动很多内存页,把共享的页复制成两份,内存占用一下子涨上去。所以有子进程在跑的时候,负载因子要涨到 5 才会强行扩容(这就是 dict_force_resize_ratio 的作用)。
扩容到多大:used * 2 之后往上取最近的 2 的幂。比如 used 是 1000,目标就是 2048 个桶。
缩容的触发条件不一样,是负载因子小于 0.1:
text
used / size < 0.1 → 缩容
缩容到第一个不小于 used 的 2 的幂。扩容在插入路径上顺手检查,缩容则是靠 serverCron 定时任务里的 tryResizeHashTables 定期看一眼,因为删除操作不像插入那么频繁。
渐进式 Rehash
问题出在搬迁的量上。假设 ht[0] 有 400 万个节点,现在要扩到 800 万个桶,把 400 万个节点一条条重新算哈希、挂到新表上,这个操作可能要几百毫秒。Redis 是单线程处理命令的,这几百毫秒里所有请求都得等,线上就是一次明显的卡顿。
Redis 的解法是把这次搬迁摊到后续的每一次操作里,每次搬一点。
整个过程分几步:
text
1. 给 ht[1] 分配空间,大小是第一个 >= used*2 的 2 的幂
rehashidx 置 0
2. 每次对 dict 做增删改查,干完正事顺手搬一个桶:
把 ht[0].table[rehashidx] 这条链上的所有节点搬到 ht[1]
然后 rehashidx++
3. 期间写入的新节点,一律进 ht[1],ht[0] 只减不增
4. 查找时先在 ht[0] 找,找不到再去 ht[1] 找
5. ht[0] 搬空了,rehashidx = -1
把 ht[1] 设成 ht[0],原来的 ht[1] 清零
第 3 步是关键。rehash 期间如果有新节点往 ht[0] 写,ht[0] 就永远搬不完。所以规则定死了:rehash 期间 ht[0] 只读不写,所有新增都进 ht[1]。
第 4 步是查找要查两个表的代价。一个 key 可能在老表里还没搬走,也可能已经在新表里了,两边都得找一遍。不过这个代价是有界的,最多查两次。
光靠操作驱动不够
如果某个 dict 建完之后长期没被访问,就没人触发第 2 步,rehash 停在半路,ht[0] 和 ht[1] 两个表同时占着内存,等于内存翻倍还多。
所以还有一条兜底路径。serverCron 会周期性调用 databasesCron,里面有个 incrementallyRehash,它调用 dictRehashMilliseconds(1),意思是主动搬 1 毫秒的活。这样即使没有请求,rehash 也会往前走。
搬运的最小单位是一个桶。dictRehash 每次最多扫 n * 10 个空桶就停下,避免连续碰到一大片空桶(这在缩容后很常见)时卡在原地太久。
大 Hash 的代价
回到 Hash 的编码选择。一个 Hash 字段少的时候是 listpack,省内存;一旦超过阈值变成 hashtable,代价就上来了。
两个地方会显内存:
- hashtable 编码下,每个字段都是一个独立的
dictEntry,加上 key、value 各自的 robj,光指针和对象头就是几十字节。同样的数据,listpack 里是挨着存的一大块,没有这些开销 - rehash 期间
ht[0]和ht[1]同时在,两个桶数组一起占内存,峰值能到平时的两倍
所以别把一个 Hash 当成大集合用。字段到了几万、几十万,HGET 是快,但内存和遇到扩容时的压力都不划算,这种场景要拆 key,或者换更合适的结构。
存对象用 String 还是 Hash
这个选择经常要做。同一个用户对象,可以整体序列化成 JSON 存 String,也可以拆成字段存 Hash:
shell
SET user:1 '{"name":"tom","age":18,"city":"sh"}'
HSET user:1 name tom age 18 city sh
| 维度 | String | Hash |
|---|---|---|
| 读写粒度 | 整个对象 | 单个字段 |
| 改一个字段 | 读出来、改、整个写回 | 一条 HSET |
| 字段级过期 | 做不到(除了拆 key) | 最近才支持,见下 |
| 字段多时内存 | 拆成多 key 的话,每字段都有 key 开销 | listpack 下字段挨着存,省 |
| 字段少时内存 | 一个 key,直接 | 多一层 robj,略多 |
判断的逻辑:
- 对象整个读、整个写,不怎么改单个字段,用 String。缓存场景大部分是这种
- 要频繁改某个字段,或者要按字段读,用 Hash。不然每次改一个字段都要把整个对象反序列化、改完再序列化写回,白白浪费 CPU
- 别用
user:1:name、user:1:age这样把一个对象拆成一堆 String key。每个 key 都是一个dictEntry加一个 robj,字段一多内存就上去了。这种情况应该用一个 Hash - 反过来,字段只有两三个、又不需要字段级操作,直接存一个 String 更省事
字段级过期是个例外情况。Redis 7.4 之前,Hash 不能给单个字段设 TTL,只能整个 key 过期。7.4 加了 HEXPIRE、HTTL 这一组命令才支持。如果你用的是 7.4 之前的版本,又需要"对象里某个字段单独过期",那就只能把它拆成独立的 String key。
ZSet 也同时用了跳表和 dict,不过那里 dict 是为了按 member 查 score,见ZSet 那篇。