《Redis 8.10.3 深度剖析》系列 ·第 10 章 Hash 一:listpack、hashtable 与 LISTPACK_EX
第 9 章把对象层拆成两张表------type × encoding 的矩阵,和 metabits × 8 个槽位 的寻址协议。这一章开始钻进矩阵里的第一格:OBJ_HASH。
选 Hash 打头,不是因为它最简单,恰恰相反。在 8.10 的九种逻辑类型里,Hash 是唯一一个拥有五个编码 的类型(src/t_hash.c 全文 6613 行,五个 OBJ_ENCODING_* 各有独立实现体),也是唯一一个把"过期"从键级下放到字段级的类型。LISTPACK_EX 这个编码在整个 Redis 里只属于 Hash,entry 这个内存布局也只属于 Hash。更关键的是,Hash 是唯一一个"编码选择"同时受三条正交轴支配的类型:
- 字段集合是否共享 ------
TMPL_LP/TMPL_ARRAY把字段名抽出来放进全局注册表,每个键只存 value 数组; - 字段是否有 TTL ------
LISTPACK_EX在 listpack 里给每个字段多塞一个 TTL 元素,HT则给每个 entry 多塞 16 字节的ExpireMeta; - 是否还是紧凑容器 ------
hash-max-listpack-entries与hash-max-listpack-value两道闸。
前两条轴在 Redis 7.4 之前都不存在。7.4 加了 HFE(Hash Field Expiration),8.0 加了 hash template,8.10 把字段存储从 sds key + sds value 改成单次分配的 Entry。三条轴叠在一起,就有了五个编码和十九种转换组合。
这一章只讲前三个编码------LISTPACK、LISTPACK_EX、HT。TMPL_LP / TMPL_ARRAY 留给第 11 章,因为它们的主战场是"共享 schema 的注册表",跟本章的"字段存储与字段过期"是两个不同的问题域。但在必要处会点出它们的交界面,尤其是 hashTypeSetExInit 里那条"TMPL 必须先转成 LISTPACK_EX 才能设 TTL"的分支(src/t_hash.c:2283-2287)。
1. 五个编码与三条轴
先把五个编码的判据摆出来。这不是一张可以背下来的表,每个格子背后都有一处代码在做决定。
| 编码 | o->ptr 指向 |
字段名存哪 | 字段 TTL 支持 | 触发条件 |
|---|---|---|---|---|
OBJ_ENCODING_LISTPACK |
unsigned char *(listpack) |
键内联,[f0,v0,f1,v1,...] |
否 | 新建空 hash 的默认编码 |
OBJ_ENCODING_LISTPACK_EX |
listpackEx * |
键内联,[f0,v0,t0,...] |
是 | 对 LISTPACK 编码的 hash 执行 HEXPIRE 家族或 HSETEX |
OBJ_ENCODING_HT |
dict * |
Entry 内联(field sds) |
是(可选) | 字段数 > hash-max-listpack-entries,或某字段/值长度 > hash-max-listpack-value |
OBJ_ENCODING_TMPL_LP |
unsigned char *(listpack) |
全局 template | 否 | 字段集合命中/新建 template,且能塞进 listpack |
OBJ_ENCODING_TMPL_ARRAY |
hashTemplateArray * |
全局 template | 否 | 同上,但 listpack 塞不下 |
三条轴怎么读这张表:
轴一(列"字段名存哪") 决定"字段名重复了几份"。LISTPACK、LISTPACK_EX、HT 都是每个键各存一份字段名;TMPL_* 是同一份字段名被成千上万个键共享。这是纯粹的"值密度"权衡------如果 100 万个键都有 {id, name, price, stock} 四个字段,HT 要为每个键各分配一份 "id"、"name"...... 这些字符串的内容完全相同,却各占一块内存。TMPL_* 把这四份字符串收缩成一份。
轴二(列"字段 TTL 支持") 决定"过期信息怎么附在字段上"。LISTPACK、TMPL_* 完全不支持------不是"支持但没设置",是结构上放不下 。listpack 是紧凑字节流,没有给单元素挂元数据的位置;template 是全局共享的,字段 TTL 是每个键私有的,两者天然冲突。所以 hashTypeGetMinExpire 对这两个编码直接返回 EB_EXPIRE_TIME_INVALID(src/t_hash.c:3789-3792、3795-3796、3819-3820)。
轴三(列"触发条件") 决定"什么时候往上跳"。这条轴是本章第 5 节到第 9 节的主线。
值得单独拎出来说的是:LISTPACK_EX 没有回程票 。第 9 章已经从"转换目标"的角度统计过------listpackEx 作为转换目标只出现在三处(src/t_hash.c:2282、2287、2994、2917),全部是"从下往上"。也就是说:
TMPL_LP ──┐
TMPL_ARRAY┼──> LISTPACK ──> LISTPACK_EX ──> HT
└────────────────────────────> HT
一旦某个 hash 上出现过一个字段的 TTL,它就永久停在 LISTPACK_EX 或 HT,哪怕之后所有字段都被 HPERSIST 持久化、hashTypeIsFieldsWithExpire 返回 0(src/t_hash.c:3842-3843)。这个设计选择的代价是"每 key 多 24 字节的 listpackEx 头 + 每字段多一个 TTL 元素",收益是"避免了一次可能永远不再需要的转换,以及一次 lpDeleteRange 全表重排"。
hpersistCommand 的 LISTPACK_EX 分支(src/t_hash.c:6524-6565)确实只做 listpackExUpdateExpiry(hashObj, field, fptr, vptr, HASH_LP_NO_TTL) 这一件事,不会在"最后一个 TTL 被清掉"时触发降级。这一点第 9 章已从编码矩阵角度论证过,这里从"用户可观测行为"再确认一次:
ruby
127.0.0.1:6379> HSET h a 1 b 2
(integer) 2
127.0.0.1:6379> OBJECT ENCODING h
"listpack"
127.0.0.1:6379> HEXPIRE h 100 FIELDS 1 a
1) (integer) 1
127.0.0.1:6379> OBJECT ENCODING h
"listpackex"
127.0.0.1:6379> HPERSIST h FIELDS 1 a
1) (integer) 1
127.0.0.1:6379> OBJECT ENCODING h
"listpackex"
127.0.0.1:6379> OBJECT ENCODING h
"listpackex"
第三次 OBJECT ENCODING 依然是 listpackex。这个行为是刻意的,不是遗漏------hashTypeConvertListpackEx 只接受 OBJ_ENCODING_HT 作为目标(src/t_hash.c:3048-3050、3099-3101),传别的值直接 serverPanic。
2. entry:为什么 HT 不再存两个 sds
在 7.x 里,hashtable 编码的 hash,dict 的 key 是一个 sds(字段名),val 是另一个 sds(字段值)。一次 HSET h f v 在 HT 编码下会产生:
- 一个
dictEntry(24 字节) - 一个 field sds 分配(
sdshdr83 字节 + 内容 + 1) - 一个 value sds 分配(同上)
三次 zmalloc,三个不相邻的地址。HGET 要做的是 dictFind → 取 key sds → 取 val sds → 两次指针解引用。在字段名和值都很短的常见场景(HSET user:1 name "Tom"),三次分配加起来不到 64 字节,却跨越三个 cache line。
8.10 换成了 Entry。src/entry.h:10-19 的注释开宗明义:
An Entry represents a packed field-value pair with optional expiration metadata. Currently it is used only by Hash.
There are 3 different formats for the "entry". In all cases, the "entry" pointer points into the allocation and is identical to the "field" sds pointer.
"pointer points into the allocation and is identical to the field sds pointer" 这句是整个设计的地基。Entry * 不是指向一个结构体的开头,而是指向自己的 field sds 的头 (sdshdr5 / sdshdr8 的第一个字节)。entryGetField 的实现就一行(src/entry.h:131-136):
c
static inline sds entryGetField(const Entry *entry) {
/* Note: The Entry pointer is identical to the field sds pointer.
* This is a fundamental design assumption verified by the implementation. */
return (sds)entry;
}
一个裸类型转换,零开销。之所以能做到,是因为 sds 的头里已经存了 len 和 alloc,不需要额外的字段来标记"这个 entry 有多长"------长度信息寄生在 field 的 sds 头里。
2.1 三种形态
src/entry.h 的注释画了三张图,对应三种互斥的布局。
Type 1:sdshdr5 field + 内嵌 value
lua
entry
|
+---------V------------+----------------------------+
| Field | Value |
| sdshdr5 | "foo" \0 | sdshdr8 "bar" \0 (padding) |
+---------+------------+----------------------------+
sdshdr5 的头只有 1 字节 (src/sds.h,高 3 位存 type、低 5 位存 len),所以字段名最长 31 字节。这一字节里没有 aux bit 的空间------SDS_TYPE_BITS 是 3,31 >> 3 = 3,sdshdr5 的 type 位已经用满,剩下 5 位全给了 len。
没有 aux bit,就没法存"这个 entry 有没有 TTL"这个信息 。所以 Type 1 天然不支持过期。entryCalcFieldSdsType 的这段(src/entry.c:132-138):
c
static inline int entryCalcFieldSdsType(size_t fieldLen, int hasExpiry) {
int sdsType = sdsReqType(fieldLen);
if (sdsType == SDS_TYPE_5 && hasExpiry) {
sdsType = SDS_TYPE_8;
}
return sdsType;
}
判据只有一条:要 TTL、又本该用 sdshdr5 的字段,强制升到 sdshdr8。这一步会让字段名的头从 1 字节涨到 3 字节(sdshdr8 是 len:8 + alloc:8 + flags:8),净多 2 字节。用 2 字节换"能挂 TTL",这笔账在任何场景下都划算------ExpireMeta 本身是 16 字节。
Type 2:sdshdr8 field + 内嵌 value + 可选 TTL
lua
entry
|
+--------------+---------V------------+----------------------------+
| Expire (opt) | Field | Value |
| ExpireMeta | sdshdr8 | "foo" \0 | sdshdr8 "bar" \0 (padding) |
+--------------+---------+------------+----------------------------+
sdshdr8 的 flags 字节里,高 3 位是 type,剩下 5 位空着------8.10 用其中 2 位存 entry 的 aux flag(src/sds.h 的 aux bit API,第 5 章讲过)。src/entry.c:25-35:
c
enum {
/* SDS aux flag. If set, it indicates that the entry has TTL metadata set. */
FIELD_SDS_AUX_BIT_ENTRY_HAS_EXPIRY = 0,
/* SDS aux flag. If set, it indicates that the entry has an embedded value
* pointer located in memory before the embedded field. If unset, the entry
* instead has an embedded value located after the embedded field. */
FIELD_SDS_AUX_BIT_ENTRY_HAS_VALUE_PTR = 1,
FIELD_SDS_AUX_BIT_ENTRY_MAX
};
static_assert(FIELD_SDS_AUX_BIT_ENTRY_MAX + SDS_TYPE_BITS < sizeof(char) * CHAR_BIT,
"too many sds bits are used for entry metadata");
static_assert 的算式是 2 + 3 < 8。它守的是"SDS 的 aux bit 不能吃到 type 位"这条线:flags 字节高 3 位是 type,剩 5 位可用,entry 目前只用 2 位,还留了 3 位余量给其他类型的 aux 用途(第 5 章)。
Type 3:field 内嵌 + value 是指针
javascript
entry
|
+--------------+---------------+----------V----------+--------+
| Expire (opt) | Value | Field | / / / /|
| ExpireMeta | sds (pointer) | sdshdr8+ | "foo" \0 |/ / / / |
+--------------+-------+-------+----------+----------+--------+
|
+-> sds value
当 field + value 加起来超过 128 字节时,value 不再内嵌,改成在 field 前面放一个 8 字节的 sds 指针,指向一块独立分配的 value。这时候 entry 的分配是 [ExpireMeta?][sds ptr][field hdr][field content],而 value 在另一块内存里。
2.2 entryGetAllocPtr:从 Entry * 倒推分配起点
三种形态下,"这块内存从哪开始"是三个不同的答案。src/entry.c:78-83:
c
void *entryGetAllocPtr(const Entry *entry) {
char *buf = sdsAllocPtr(entryGetField(entry));
if (entryHasValuePtr(entry)) buf -= sizeof(sds);
if (entryHasExpiry(entry)) buf -= sizeof(ExpireMeta);
return buf;
}
buf 一开始是 field 的 sds 头的地址(也就是 entry 本身),然后向前 减 8(如果 value 是指针)、再减 16(如果有 TTL)。这个顺序不能反------布局是 [ExpireMeta][value ptr][field],所以从 field 往前退,先遇到 value ptr,再遇到 ExpireMeta。如果代码写成先减 ExpireMeta 再减 sds,算出来的地址就错了 8 字节。
entryGetAllocPtr 是整个 entry API 里唯一能拿到分配起点的地方 ,也是 entryFree、entryMemUsage、entryRefExpiryMeta 三个函数的基础:
c
/* src/entry.h:127-130 */
static inline ExpireMeta *entryRefExpiryMeta(Entry *entry) {
return entryHasExpiry(entry) ? (ExpireMeta *)entryGetAllocPtr(entry) : NULL;
}
这个函数被 hentryGetExpireMeta 包了一层(src/t_hash.c:5909-5912),再通过 EbucketsType.getExpireMeta 函数指针交给 ebuckets 库。也就是说,每次 ebuckets 要读一个字段的 TTL,都要走一遍 entryGetAllocPtr ------两次 aux bit 读取加一次减法。第 8 章讲过 ebGetExpireTime 在热点路径上,这里补上另一半:getExpireMeta 的回调开销。
2.3 一次 HSET user:1 name Tom 的真实字节账
把上面的规则套到具体例子上。HSET user:1 name "Tom",假设 hash 已是 HT 编码、之前没有 TTL:
- field =
"name",4 字节 →sdsReqType(4)=SDS_TYPE_5(阈值 32) - 有 TTL 吗?
entryCreate(field, value, ENTRY_TAKE_VALUE, &usable),entryWriteInfo.flags不含ENTRY_HAS_EXPIRY,所以expirySize = 0 embdFieldType=SDS_TYPE_5,embdFieldSize=sdsReqSize(4, SDS_TYPE_5)= 1 + 4 + 1 = 6- value =
"Tom",3 字节 →embdValueSize=sdsReqSize(3, SDS_TYPE_8)= 3 + 3 + 1 = 7 allocSize = 6 + 0 = 6,6 + 7 = 13 <= 128→isEmbdNewVal = 1,allocSize = 13- 分配 13 字节 → jemalloc 落到 16 字节 size class
- 布局:
[sdshdr5 "name" \0][sdshdr8 "Tom" \0],其中第二个sdshdr8的 3 字节头里len=3、alloc=3
对比 7.x:dictEntry 24 + field sds 分配(6 字节 → 16 class)+ value sds 分配(7 字节 → 16 class)= 56 字节,3 次分配 。8.10:16 字节,1 次分配 ,加上 8 字节的 dictEntryNoValue(no_value = 1,见 src/t_hash.c:95;实际上 keys_are_odd = 1 时 key 直接存在桶指针里,连 dictEntry 都省了,第 6 章讲过这个技巧)。
这个差距是本章所有性能讨论的前提。HT 编码在 8.10 不再是"比 listpack 慢的备用方案",它和 listpack 的差距只剩"没有紧凑的字节流"这一项。
3. EMBED_VALUE_MAX_ALLOC_SIZE = 128 是怎么定的
src/entry.h:87:
c
/* The maximum allocation size we want to use for entries with embedded values. */
#define EMBED_VALUE_MAX_ALLOC_SIZE 128
判据在 setEntryWriteInfo(src/entry.c:141-187):
c
/* Start with field and expiry */
size_t allocSize = info->embdFieldSize + info->expirySize;
if (unlikely(!value)) {
info->newEntryAllocSize = allocSize;
return;
}
/* Decide whether to embed the value or use a pointer */
if (allocSize + info->embdValueSize <= EMBED_VALUE_MAX_ALLOC_SIZE) {
info->isEmbdNewVal = 1;
allocSize += info->embdValueSize;
} else {
info->isEmbdNewVal = 0;
allocSize += sizeof(sds);
if (info->embdFieldType == SDS_TYPE_5) {
info->embdFieldType = SDS_TYPE_8;
info->embdFieldSize = sdsReqSize(fieldLen, info->embdFieldType);
allocSize = info->embdFieldSize + info->expirySize + sizeof(sds);
}
}
3.1 为什么是 128
jemalloc 的 size class 在 64 之后是 80, 96, 112, 128, 160, 192, 256, 320, 384, 448, 512, 640, 768, 1024, 1280, ...。128 是一个精确的 class 边界。
如果一个 entry 的"field + value"总需求是 129 字节,内嵌方案的分配会落到 160 class,浪费 31 字节;如果用指针,分配是 field(6) + 8(ptr) = 14 → 16 class,value 另分配 129 → 160 class,总共 176 字节 + 2 次分配。
也就是说 129 到 128 这个 1 字节的差距,成本是 "16 字节 + 1 次 zmalloc + HGET 时多一次指针解引用"。反过来,如果需求是 128 字节,内嵌方案正好占满 128 class,零浪费;指针方案要 16 + 128 = 144 字节。
边界定在 128,意味着所有 <= 128 的内嵌方案都恰好不跨 class 。这个选择的正确性依赖 jemalloc 的 class 表,不是从 Redis 的代码结构推出来的------换个 allocator(比如 glibc malloc 的 request2size,128 之后直接跳到 136)就未必成立。这是 8.10 里少数几个"写死了 allocator 参数"的地方之一,和 KEY_SIZE_TO_INCLUDE_EXPIRE_THRESHOLD = 128(第 9 章 §17)是同一种手法,但那个的 128 是为了对齐 cache line,这里的 128 是为了对齐 size class。
3.2 指针分支为什么强制 SDS_TYPE_8
c
/* Embed field only (>= SDS_TYPE_8 to encode value ptr flag).
* [ExpireMeta | Value Ptr | Field hdr8 "foo"\0]
*/
Type 3 的"value 是指针"这个事实,必须存在某个地方 。唯一的位置是 field 的 sds flags 里的 aux bit FIELD_SDS_AUX_BIT_ENTRY_HAS_VALUE_PTR(src/entry.c:38-40):
c
static inline int entryHasValuePtr(const Entry *entry) {
return sdsGetAuxBit(entryGetField(entry), FIELD_SDS_AUX_BIT_ENTRY_HAS_VALUE_PTR);
}
而 sdshdr5 没有 aux bit。所以只要走指针分支,field 就必须升到 sdshdr8 以上。注意上面那段代码有一个细节:它先 算了 allocSize += sizeof(sds),然后 才判断要不要升级 field 类型并重算 allocSize。重算时用的是 info->embdFieldSize + info->expirySize + sizeof(sds),而不是 allocSize += 2------因为 sdsReqSize(fieldLen, SDS_TYPE_8) 可能比 sdsReqSize(fieldLen, SDS_TYPE_5) 大不止 2(sdshdr8 是 3 字节头,sdshdr5 是 1 字节,差 2;但两者都带 1 字节 \0,所以确实恒定差 2)。这两写法等价,重算更直观。
3.3 内嵌 value 固定用 SDS_TYPE_8 的真正原因
src/entry.c:153-155:
c
/* Calculate value allocation size (Always use SDS_TYPE_8 for embedded values) */
size_t valueLen = value ? sdslen(value) : 0;
info->embdValueSize = value ? sdsReqSize(valueLen, SDS_TYPE_8) : 0;
为什么不按 sdsReqType(valueLen) 自适应?因为内嵌 value 的长度不需要用 sds 头来存------它可以从 entry 的总分配长度反推:
c
/* src/entry.c:66-75 */
sds entryGetValue(const Entry *entry) {
if (entryHasValuePtr(entry)) {
return *entryGetValueRef(entry);
} else {
/* Skip field content, field null terminator and value sds8 hdr. */
size_t offset = sdslen(entryGetField(entry)) + 1 + sdsHdrSize(SDS_TYPE_8);
return (sds)((char *)entry + offset);
}
}
offset = field 内容长度 + 1(\0)+ 3(sdshdr8 头),这个偏移是编译期常量表达式 ,只依赖 field 的 len。如果 value 用 sdshdr5(1 字节头),sdsHdrSize 就要变成条件分支。
更重要的是:sdshdr8 的 alloc 字段被当作 padding 的载体 。注释里三次出现 (padding):
arduino
| sdshdr8 | "foo" \0 | sdshdr8 "bar" \0 (padding) |
entryCreate 分配的 allocSize 是按 sdsReqSize 算出来的精确值,但 jemalloc 会给出更大的 usable size(13 → 16)。这多出来的 3 字节就躺在 value 的 sdshdr8 里,alloc 字段记着"我这个 sds 的真实 alloc 是 3",而实际可写空间是 3 + padding。这一点在 entryWriteOver 里被利用:
c
/* src/entry.c:289-298 */
} else {
/* Update embedded value in-place - always copy content */
sds oldValue = entryGetValue(e);
/* Use the old value's allocation size */
size_t valueSize = sdsAllocSize(oldValue);
sdsnewplacement(sdsAllocPtr(oldValue), valueSize, SDS_TYPE_8, value, sdslen(value));
valueSize = sdsAllocSize(oldValue) ------ 用的是旧的 value 的 alloc 字段 ,不是新 value 的需求大小。也就是说,原地更新时新 value 可以有 oldAlloc 那么长,只要不超。
这带来一个反直觉的结论:EMBED_VALUE_MAX_ALLOC_SIZE = 128 只在"新建 entry"时生效 。一个 entry 如果最初以 120 字节的 value 建成(内嵌,占 128 class),之后 HSET 一个 124 字节的 value,原地更新是允许的(needsNewAlloc 见下节),此时 entry 的实际内容长度超过了 128,但仍在同一个 128 class 内。
4. entryUpdate:原地还是重分配
entryUpdate(src/entry.c:304-375)是 HT 编码 HSET 的核心。它的决策逻辑在 needsNewAlloc(src/entry.c:252-277):
c
static inline int needsNewAlloc(Entry *e,
EntryWriteInfo *info,
int isUpdateValue,
int expiryAddRemove)
{
/* if we need to add/remove expiration metadata */
if (expiryAddRemove)
return 1;
/* If not updating value, no need to allocate new entry */
if (!isUpdateValue)
return 0;
/* If value embedding <> pointer changed */
if (info->isEmbdNewVal != entryHasEmbeddedValue(e))
return 1;
/* If old & new are both pointers, no need to allocate new entry */
if (!info->isEmbdNewVal)
return 0;
/* Check if new embedded value fits in old allocation */
size_t oldAllocSize = sdsAllocSize(entryGetValue(e));
size_t newReqSize = info->embdValueSize;
return !((newReqSize <= oldAllocSize) && (newReqSize >= oldAllocSize * 3 / 4));
}
四条判据,按顺序:
判据一:TTL 有无发生变化 → 一定重分配。因为 TTL 的 16 字节 ExpireMeta 在 allocation 的最前面,加上或去掉它,整个布局的起点就变了。entry 指针(指向 field 头)相对分配起点的偏移也跟着变。这不是"能不能装下"的问题,是"记住的偏移量失效"的问题。
判据二:isUpdateValue == 0 → 不必重分配。hashTypeSetExpiryHT 的第一条路径看起来正是这个形状:
c
/* src/t_hash.c:2161-2163 */
ssize_t usableDiff;
entryNew = entryUpdate(oldEntry, NULL, ENTRY_HAS_EXPIRY, &usableDiff);
但它同时满足"TTL 从无到有"和"value 不变",判据一先触发(expiryAddRemove = 1)返回 1,所以这里会 重分配。判据二只在 expiryAddRemove == 0 时才轮到,而 entryUpdate 开头(src/entry.c:313-319)已经 early return 拦住了这类调用:
c
/* Early return if nothing changes */
if (!isUpdateVal && !expiryAddRemove) {
if (usableDiff)
*usableDiff = 0;
if (flags & ENTRY_TAKE_VALUE)
sdsfree(value);
return entry;
}
能走到 needsNewAlloc 的调用因此必然满足 isUpdateVal || expiryAddRemove:前者为假时分两种情况------expiryAddRemove 为真则判据一返回 1;expiryAddRemove 为假则被 early return 拦住,根本到不了这里。判据二是一个不可达的防御。
这不妨碍正确性,但说明这段代码是从更通用的 entry API 演化来的------它考虑了"调用方只想改 expiry 而不改 value"的情形,而这个情形在 hash 里总伴随着 expiryAddRemove。
判据三:value 的内嵌/指针模式变了 → 重分配。info->isEmbdNewVal 是按新 value 的长度重新算的(setEntryWriteInfo)。如果旧 entry 是内嵌、新 value 长到走了指针分支,布局从 [field][value] 变成 [ptr][field],必须重建。
判据四:新旧都是指针 → 不重分配。这部分留着下节讲。
判据五(默认):新旧都是内嵌 → 看新需求是否落在 [oldAlloc * 3/4, oldAlloc] 区间内。
4.1 为什么下界是 3/4 而不是 1/2
这个 3/4 是整个函数里最容易被忽略、也最值得琢磨的一个常数。
假设没有下界(只要求 newReqSize <= oldAllocSize)。一个 entry 建成 128 字节的 value,之后被反复 HSET 成 1 字节的 value:它的 allocation 一直是 128 class,内容只用 16 字节,112 字节永久浪费 。这笔浪费也不会自己消失------needsNewAlloc 在"变小"方向上没有判据,永远不会触发收缩。
加上 3/4 下界后,newReqSize < oldAlloc * 3/4 就触发重分配,把 entry 缩到合适的 class。这保证了"浪费不超过 25%"。
为什么不是 1/2?取一个真实的抖动序列:value 在 "1"(sdsReqSize(1, SDS_TYPE_8) = 7)与 "1234567890"(14)之间交替。
- 7 → 14:
newReqSize <= oldAllocSize是"不重分配"的必要条件之一,14 <= 7为假,整个&&为假,!false为真 → 重分配(扩); - 14 → 7:
7 <= 14真,但7 >= 10.5假 → 重分配(缩)。
两个方向都重分配,每次 HSET 都是一轮 zmalloc + memcpy + zfree。
3/4 挡不住这个抖动------它只约束"缩"这一侧,而"扩"必然重分配,分配器不支持原地扩容。真正起作用的场景是收缩的锁存:value 从 128 缩到 100(带内)不重分配,因为 100 随时可能回到 128,缩了就要再扩;缩到 50(带外)才缩,因为回不去 128 的概率低,而 128 class 长期装 50 字节不划算。
更精确地说,3/4 定义了滞回带的宽度 :[0.75 * old, old]。在这个区间内,value 怎么变都不重分配。这和第 9 章讲的 list 的 2 倍滞回带(4096 / 8192)是同一个思路的不同参数化------list 的滞回带用于 O(N) 的容器迁移,entry 的滞回带用于 O(1) 的分配。
4.2 entryWriteOver 里的一个隐藏依赖
c
/* src/entry.c:289-298 */
sds oldValue = entryGetValue(e);
/* Use the old value's allocation size */
size_t valueSize = sdsAllocSize(oldValue);
sdsnewplacement(sdsAllocPtr(oldValue), valueSize, SDS_TYPE_8, value, sdslen(value));
sdsAllocSize(oldValue) 读的是 oldValue 的 alloc 字段。但这个 alloc 字段在 entry 内嵌场景下是由 sdsReqSize(valueLen, SDS_TYPE_8) 写进去的 ,也就是"请求的大小",不是"jemalloc 给的大小"。所以 valueSize 会比真实可用空间小。
后果:entryUpdate 认为"最多能原地写 alloc 字节",但实际上可以写更多(padding)。这意味着 needsNewAlloc 的判据会更保守------某些本可以原地更新的情况被判成需要重分配。这是一个安全但保守的偏差,方向正确(宁可多分配一次,不可越界写)。
如果反过来(alloc 被写成 jemalloc 的 usable size),sdsnewplacement 就会往 padding 里写,而 padding 在 entry 的分配块内部,安全。但那样 sdsAllocSize 就与"逻辑大小"脱钩,entryMemUsage 的记账会虚高。8.10 选了保守的那一侧。
5. hashTypeSet 的五个分支
hashTypeSet(1899-2130)是 hash 写路径总入口。五个 if/else if 对应五个编码,返回 0 表示新增、1 表示更新。
flags 有四位(1889-1895):
c
#define HASH_SET_TAKE_FIELD (1<<0)
#define HASH_SET_TAKE_VALUE (1<<1)
#define HASH_SET_KEEP_TTL (1<<2)
/* Skip hashTypeSet's auto-conversion to template. A command adding N fields sets
* this and converts once at the end instead. */
#define HASH_SET_NO_TEMPLATE_CONVERT (1<<3)
static_assert(HASH_SET_TAKE_VALUE == ENTRY_TAKE_VALUE, ...)(1897)------两个常量必须同值,因为 HT 分支直接把 flags & HASH_SET_TAKE_VALUE 当 ENTRY_TAKE_VALUE 传给 entryCreate。
5.1 LISTPACK 分支
c
if (o->encoding == OBJ_ENCODING_LISTPACK) {
unsigned char *zl, *fptr, *vptr;
zl = o->ptr;
fptr = lpFirst(zl);
if (fptr != NULL) {
fptr = lpFind(zl, fptr, (unsigned char*)field, sdslen(field), 1);
if (fptr != NULL) {
vptr = lpNext(zl, fptr);
serverAssert(vptr != NULL);
zl = lpReplace(zl, &vptr, (unsigned char*)value, sdslen(value));
update = 1;
}
}
if (!update) {
listpackEntry entries[2] = {
{.sval = (unsigned char*) field, .slen = sdslen(field)},
{.sval = (unsigned char*) value, .slen = sdslen(value)},
};
zl = lpBatchAppend(zl, entries, 2);
}
o->ptr = zl;
lpFind 的最后一个参数 skip = 1 表示"比较一次、跳过一个、再比较"。lpFindCbInternal 的实现(src/listpack.c:895-940):
c
while (p) {
if (skipcnt == 0) {
...
if (unlikely(cmp(lp, p, user, value, ll) == 0))
return p;
skipcnt = skip;
p += entry_size;
} else {
skipcnt--;
p = lpSkip(p);
}
skipcnt 倒计数:比较后置成 skip,然后 skip 次走 lpSkip。skip = 1 时比较周期是 2,对齐二元组 [f0,v0,f1,v1,...],只在 f 上比。
新增用 lpBatchAppend 而非两次 lpAppend:一次编码两个元素、一次 realloc、一次 header 更新。
5.2 LISTPACK_EX 分支:KEEP_TTL 的双向逻辑
c
} else if (o->encoding == OBJ_ENCODING_LISTPACK_EX) {
unsigned char *fptr = NULL, *vptr = NULL, *tptr = NULL;
listpackEx *lpt = o->ptr;
long long expireTime = HASH_LP_NO_TTL;
fptr = lpFirst(lpt->lp);
if (fptr != NULL) {
fptr = lpFind(lpt->lp, fptr, (unsigned char*)field, sdslen(field), 2);
if (fptr != NULL) {
vptr = lpNext(lpt->lp, fptr);
serverAssert(vptr != NULL);
lpt->lp = lpReplace(lpt->lp, &vptr, (unsigned char *) value, sdslen(value));
update = 1;
fptr = lpPrev(lpt->lp, vptr);
serverAssert(fptr != NULL);
tptr = lpNext(lpt->lp, vptr);
serverAssert(tptr && lpGetIntegerValue(tptr, &expireTime));
if (flags & HASH_SET_KEEP_TTL) {
/* keep old field along with TTL */
} else if (expireTime != HASH_LP_NO_TTL) {
/* re-insert field and override TTL */
listpackExUpdateExpiry(o, field, fptr, vptr, HASH_LP_NO_TTL);
}
}
}
lpFind(..., 2),比较周期 3,对齐三元组。
lpReplace 之后的两行指针重算容易看漏:
c
fptr = lpPrev(lpt->lp, vptr);
tptr = lpNext(lpt->lp, vptr);
lpReplace 可能 realloc 并改写 value 的编码(原来的整数编码 13 变成字符串 "13" 字节数不同),后续元素位置全变。lpReplace 只通过 &vptr 更新了 value 指针,fptr 和 tptr 没有。所以从 vptr 出发,lpPrev 拿 field、lpNext 拿 ttl。前提是 lpReplace 不改变元素相对顺序(它的实现是"删原元素、在原位置插新元素",前后邻居不变,只是绝对地址变)。
if (KEEP_TTL) 空分支 + else if (有 TTL) 的组合,语义是:
KEEP_TTL |
原有 TTL | 结果 |
|---|---|---|
| 有 | 有 | TTL 保留,字段位置不变 |
| 有 | 无 | 无操作 |
| 无 | 有 | listpackExUpdateExpiry(..., HASH_LP_NO_TTL),TTL 清掉,字段可能移到"无 TTL 区" |
| 无 | 无 | 无操作 |
HSET 不传 KEEP_TTL,所以普通 HSET 会清掉字段 TTL。 这与键级 SET key val 清掉键 TTL 是同一语义,是刻意对齐。证据在 HINCRBYFLOAT 的传播重写(4861-4869):
c
/* The KEEPTTL flag is used to make sure the field TTL is preserved. */
robj *newobj;
newobj = createRawStringObject(buf,len);
rewriteClientCommandVector(c, 7, shared.hsetex, c->argv[1], shared.keepttl,
shared.fields, shared.integers[1], c->argv[2], newobj);
HINCRBYFLOAT 执行时用 HASH_SET_TAKE_VALUE | HASH_SET_KEEP_TTL(4852),保留了 TTL。如果传播成不带 KEEPTTL 的 HSETEX,从库上 TTL 就丢了。这个细节说明"HSET 清 TTL"是团队明确知道并依赖的行为,不是实现副作用。
5.3 HT 分支:两次 ebuckets 往返
c
} else if (o->encoding == OBJ_ENCODING_HT) {
dict *ht = o->ptr;
dictEntryLink bucket, link = dictFindLink(ht, field, &bucket);
size_t *alloc_size = htGetMetadataSize(ht);
uint32_t newEntryFlags = flags & HASH_SET_TAKE_VALUE;
flags &= ~HASH_SET_TAKE_VALUE;
if (link == NULL) {
size_t usable;
Entry *newEntry = entryCreate(field, value, newEntryFlags, &usable);
dictSetKeyAtLink(ht, newEntry, &bucket, 1);
*alloc_size += usable;
} else {
Entry *oldEntry = dictGetKey(*link);
uint64_t oldExpireAt = entryGetExpiry(oldEntry);
uint64_t newExpireAt = EB_EXPIRE_TIME_INVALID;
if (oldExpireAt != EB_EXPIRE_TIME_INVALID) {
hfieldPersist(o, oldEntry);
if (flags & HASH_SET_KEEP_TTL) {
newExpireAt = oldExpireAt;
newEntryFlags |= ENTRY_HAS_EXPIRY;
}
}
ssize_t usableDiff;
Entry *newEntry = entryUpdate(oldEntry, value, newEntryFlags, &usableDiff);
if (newEntry != oldEntry)
dictSetKeyAtLink(ht, newEntry, &link, 0);
if (newExpireAt != EB_EXPIRE_TIME_INVALID) {
dict *d = o->ptr;
htMetadataEx *dictExpireMeta = htGetMetadataEx(d);
ebAdd(&dictExpireMeta->hfe, &hashFieldExpireBucketsType, newEntry, newExpireAt);
}
*alloc_size += usableDiff;
update = 1;
}
}
KEEP_TTL 在这里比 listpack 版重得多,因为 entryUpdate 可能 realloc。四步顺序不能换:
- 先摘
hfieldPersist(o, oldEntry)→ebRemove。必须在entryUpdate之前 ,因为entryUpdate可能entryFree(oldEntry),而 ebuckets 的链表节点就在 entry 的ExpireMeta里。 - 再改
entryUpdate,newEntryFlags |= ENTRY_HAS_EXPIRY保证新 entry 预留 16 字节。 - 改指针
dictSetKeyAtLink,字典的 key 是指向 entry 的指针,搬家了就得改。 - 重插
ebAdd(..., newEntry, newExpireAt),用新地址。
ebRemove 和 ebAdd 之间必须完成 entry 替换,否则 ebuckets 内部持有已释放的地址。
代价:一次带 KEEP_TTL 的 HSET 在 HT 编码上是 2 次 ebuckets 操作 + 可能的 1 次 zmalloc 。而 HSETEX ... KEEPTTL 之后还会走 hashTypeSetEx,把 TTL 再摘一次、插一次(2178-2180、2214-2215)------两次 ebuckets 往返 。中间那次 ebAdd 是纯浪费,因为紧接着就被 ebRemove 了。
5.4 尾部:两道转换检查
c
cleanup:
if (flags & HASH_SET_TAKE_FIELD && field) sdsfree(field);
if (flags & HASH_SET_TAKE_VALUE && value) sdsfree(value);
if (!(flags & HASH_SET_NO_TEMPLATE_CONVERT) &&
server.hash_min_template_entries > 0)
{
hashTypeTryConvertToTemplate(o, server.hash_min_template_entries,
server.hash_max_template_entries, NULL);
}
return update;
而 LISTPACK 和 LISTPACK_EX 分支各自的尾部有:
c
/* Check if the listpack needs to be converted to a hash table */
if (hashTypeLength(o, 0) > server.hash_max_listpack_entries)
hashTypeConvert(db, o, OBJ_ENCODING_HT);
第二个参数是 subtractExpiredFields = 0,不减已过期字段。这个选择是对的:转换判据应该看"容器里实际装了多少字段"。如果减掉过期字段,一个装满 600 个过期字段的 listpack 会认为自己是 0 个字段,永远不转 HT,而它的实际字节数已经很大。
也正因此这条检查是 O(1) 的------hashTypeLength(o, 0) 在 LISTPACK_EX 上是 lpLength / 3,不触发 listpackExExpireDryRun 的全表扫描。
6. LISTPACK_EX 的三元组布局与"TTL 有序"不变式
listpackEx 是 Hash 独有的结构(src/server.h:3941-3948):
c
typedef struct listpackEx {
ExpireMeta meta; /* For registering to ebuckets */
void *lp;
} listpackEx;
24 字节:ExpireMeta 16 + 指针 8。meta 的作用是把这个 hash 注册到全局 db->subexpires,lp 是真正的 listpack。
listpack 内部的布局从二元组变成三元组:
ini
LISTPACK: [f0][v0][f1][v1][f2][v2] ...
LISTPACK_EX: [f0][v0][t0][f1][v1][t1][f2][v2][t2] ...
t 是 TTL 的绝对毫秒时间戳 ,用 listpack 的整数编码存。HASH_LP_NO_TTL = 0(src/t_hash.c:1237)表示"这个字段没有 TTL"。
6.1 不变式:有 TTL 的按 TTL 升序在前,无 TTL 的在最后
这是整章最重要的一条。它不是"排序",因为 t = 0 作为"无 TTL"的哨兵,如果按数值自然排序,0 应该排在最前面。实际布局恰好相反:无 TTL 的全部沉到尾部。
实现靠的不是比较函数,而是两条不对称的写规则:
规则一(追加无 TTL 字段):直接 append 到尾部。
c
/* src/t_hash.c:1390-1397 */
static void listpackExAddInternal(robj *o, listpackEntry ent[3]) {
listpackEx *lpt = o->ptr;
/* Shortcut, just append at the end if this is a non-volatile field. */
if (ent[2].lval == HASH_LP_NO_TTL) {
lpt->lp = lpBatchAppend(lpt->lp, ent, 3);
return;
}
规则二(插入有 TTL 字段):找到"第一个 TTL >= 新 TTL 的字段"或"第一个无 TTL 的字段",插到它前面。
c
/* src/t_hash.c:1399-1412 */
struct lpFingArgs r = {
.max_to_search = UINT64_MAX,
.expire_time = ent[2].lval,
};
/* Check if there is a field with a larger TTL. */
lpFindCb(lpt->lp, NULL, &r, cbFindInListpack, 0);
/* If list is empty or there is no field with a larger TTL, result will be
* NULL. Otherwise, just insert before the found item.*/
if (r.p)
lpt->lp = lpBatchInsert(lpt->lp, r.p, LP_BEFORE, ent, 3, NULL);
else
lpt->lp = lpBatchAppend(lpt->lp, ent, 3);
}
"第一个无 TTL 的字段"这个条件藏在 cbFindInListpack 里(src/t_hash.c:1263-1289):
c
if (r->index % 3 == 1) {
r->fptr = p; /* First item of the tuple. */
} else if (r->index % 3 == 0) {
serverAssert(!s);
/* Third item of a tuple is expiry time */
if (slen == HASH_LP_NO_TTL || (uint64_t) slen >= r->expire_time) {
r->p = r->fptr;
return 0; /* Break the loop and return */
}
r->expired++;
r->max_to_search--;
}
slen == HASH_LP_NO_TTL 是短路的第一项。所以遇到第一个无 TTL 字段就停,把新字段插在它前面。因为无 TTL 字段全在尾部(归纳成立),这等价于"插在所有有 TTL 字段之后"。
6.2 归纳证明
初始状态:空 listpack,不变式平凡成立。
操作一(append 无 TTL):无 TTL 字段追加到尾部,仍在尾部区,不变式保持。
操作二(插入有 TTL,TTL = T):
- 若 listpack 为空 → append,只有一个有 TTL 字段,成立。
- 若所有字段都有 TTL → 扫描找到第一个
>= T的字段,插在它前面。插入后:前面的都< T,新字段= T,后面的都>= T。有序。无 TTL 区为空,成立。 - 若存在无 TTL 字段 → 扫描在第一个无 TTL 字段处停(
slen == HASH_LP_NO_TTL短路)。此时要么已经遇到过>= T的有 TTL 字段(那更早停),要么一路>= T都不成立(所有有 TTL 字段都< T),停在第一个无 TTL 处。插入后:有 TTL 区仍然升序且新增的T最大,无 TTL 区紧随其后。成立。
操作三(改 TTL) :listpackExUpdateExpiry 先删后插,等价于"操作二 + 一次删除",删除不破坏有序性。
操作四(HPERSIST) :listpackExUpdateExpiry(..., HASH_LP_NO_TTL),先删再有 TTL=0,走"操作一"的 append。字段从有 TTL 区移到尾部。成立。
6.3 三条推论
推论一:listpackExGetMinExpire 是 O(1)
c
/* src/t_hash.c:1307-1326 */
static uint64_t listpackExGetMinExpire(robj *o) {
serverAssert(o->encoding == OBJ_ENCODING_LISTPACK_EX);
long long expireAt;
unsigned char *fptr;
listpackEx *lpt = o->ptr;
/* As fields are ordered by expire time, first field will have the smallest
* expiry time. Third element is the expiry time of the first field */
fptr = lpSeek(lpt->lp, 2);
if (fptr != NULL) {
serverAssert(lpGetIntegerValue(fptr, &expireAt));
/* Check if this is a non-volatile field. */
if (expireAt != HASH_LP_NO_TTL)
return expireAt;
}
return EB_EXPIRE_TIME_INVALID;
}
lpSeek(lp, 2) 拿索引 2 的元素(第三个)。lpSeek 的实现(src/listpack.c):
c
unsigned char *lpSeek(unsigned char *lp, long index) {
int forward = 1;
uint32_t numele = lpGetNumElements(lp);
if (numele != LP_HDR_NUMELE_UNKNOWN) {
if (index < 0) index = (long)numele+index;
if (index < 0) return NULL;
if (index >= (long)numele) return NULL;
if (index > (long)numele/2) {
forward = 0;
index = (long)numele - index - 1;
}
} else {
if (index < 0) { forward = 0; index = -index - 1; }
}
if (forward)
return lpNextN(lp, lpFirst(lp), index);
else
return lpPrevN(lp, lpLast(lp), index);
}
index = 2,numele/2 >= 2(只要元素数 >= 4),所以 forward = 1,从头部走 2 步。O(1),与字段数无关。
如果第一个字段是无 TTL 的(expireAt == 0),返回 EB_EXPIRE_TIME_INVALID。按不变式,第一个字段无 TTL 意味着整个 listpack 都没有有 TTL 的字段(无 TTL 都在尾部,第一个就是尾部的第一个,说明有 TTL 区为空)。这个返回是正确的。
推论二:listpackExExpire 只需从头部删连续段
c
/* src/t_hash.c:1340-1371 */
while (ptr != NULL && (info->itemsExpired < info->maxToExpire)) {
...
/* Fields are ordered by expiry time. If we reached to a non-expired
* or a non-volatile field, we know rest is not yet expired. */
if (val == HASH_LP_NO_TTL || (uint64_t) val > info->now)
break;
...
info->itemsExpired++;
expired++;
}
if (expired) {
...
lpt->lp = lpDeleteRange(lpt->lp, 0, expired * 3);
一旦遇到"未过期"或"无 TTL",直接 break,因为后面的 TTL 都 >= 当前值。删除是 lpDeleteRange(lp, 0, expired*3),从索引 0 删连续 expired*3 个元素。
推论三:lpFind 的 skip = 2 让字段查找从 O(N) 变成 O(N/3)
hashTypeGetFromListpack(src/t_hash.c:1632)、hashTypeSet 的 ex 分支(1941)、hashTypeDelete(2380)、hpersistCommand(6536)、httlGenericCommand(6183)...... 所有按字段名查找的地方都用 skip = 2。三元组布局下,字段名每 3 个元素出现一次,skip = 2 精确对齐。
如果错用 skip = 1(从二元组代码复制过来),比较会落在 [f, t, f, t, ...] 上------v 和 t 也会被当成字段名比较。误命中的概率取决于数据,但 t 是整数编码,lpFindCmp 对整数编码有特殊处理(src/listpack.c:966-987):如果搜索串能转成整数且值相等,就命中。所以一个 TTL 恰好等于某个字段名的数值表示时,会返回 TTL 的位置,然后 lpNext 拿到下一个字段名当 value------数据错乱。这是一个"看起来只是性能问题、实际是正确性问题"的参数。
7. 维护"有序"的三条写路径
7.1 listpackExAddInternal 的短路
上一节已经贴了代码。这里补一个反例:如果去掉 ent[2].lval == HASH_LP_NO_TTL 这个短路,让无 TTL 字段也走 lpFindCb 定位,会发生什么?
r.expire_time = 0。cbFindInListpack 的条件是 slen == HASH_LP_NO_TTL || slen >= 0。第一项对第一个 遇到的 TTL 元素就成立(如果它是 0),第二项对任何 TTL 元素都成立(slen >= 0 恒真)。所以扫描会在第一个三元组就停下,r.p 指向第一个字段。新字段被插到最前面。
改完之后,lpSeek(lp, 2) 读到的是这个新字段的 TTL = 0 → listpackExGetMinExpire 返回 EB_EXPIRE_TIME_INVALID → 整个 hash 被认为"没有字段带 TTL" → 全局 subexpires 里把它摘掉。但 hash 里明明还有别的字段带 TTL,这些字段再也不会被主动过期,只能靠惰性访问。
这个反例说明:短路不是优化,是正确性的一部分。而且它短路的条件是"新字段无 TTL",恰好和"无 TTL 沉底"这条不变式绑定在一起------两个看似独立的决定互相支撑。
7.2 listpackExUpdateExpiry:删了再插
c
/* src/t_hash.c:1430-1471 精简 */
static void listpackExUpdateExpiry(robj *o, sds field,
unsigned char *fptr, unsigned char *vptr,
uint64_t expire_at) {
unsigned int slen = 0;
long long val = 0;
unsigned char tmp[512] = {0};
unsigned char *valstr;
sds tmpval = NULL;
listpackEx *lpt = o->ptr;
/* Copy value */
valstr = lpGetValue(vptr, &slen, &val);
if (valstr) {
/* Normally, item length is limited by 'hash-max-listpack-value'. It is
* unlikely, but it might be larger than sizeof(tmp). */
if (slen > sizeof(tmp)) tmpval = sdsnewlen(valstr, slen);
else memcpy(tmp, valstr, slen);
}
/* Delete field name, value and expiry time */
lpt->lp = lpDeleteRangeWithEntry(lpt->lp, &fptr, 3);
listpackEntry ent[3] = {{0}};
ent[0].sval = (unsigned char*) field;
ent[0].slen = sdslen(field);
if (valstr) { ent[1].sval = tmpval ? (unsigned char *) tmpval : tmp; ent[1].slen = slen; }
else { ent[1].lval = val; }
ent[2].lval = expire_at;
listpackExAddInternal(o, ent);
sdsfree(tmpval);
}
五个坑:
一、tmp[512] 的栈缓冲。 hash-max-listpack-value 默认 64,正常不触发堆路径。配成 100000 后,每次 HEXPIRE 一个长 value 字段都多一次 sdsnewlen/sdsfree。这不是崩溃风险,是性能悬崖。
二、lpGetValue 的返回值约定。 非 NULL 表示 string 编码(slen 有效、val 无意义),NULL 表示整数编码(val 有效、slen 被设成 0)。所以 if (valstr) ... else ... 是在保留原来的编码形态。不这么做,一个整数编码的 value 会被写成字符串------语义等价,但字节数变(整数 1-9 字节 vs 字符串 1+len+1),破坏"改 TTL 不改数据布局"的直觉。
三、删除后的指针全部失效。 lpDeleteRangeWithEntry 内部会 realloc + lpShrinkToFit,lpt->lp 变了,fptr/vptr 也全失效。代码里 field 是调用方传的 sds(不在 listpack 内)、tmpval/tmp 是外部缓冲,都没有悬垂问题。这个"只保留外部引用"的纪律是这段代码能工作的前提。
四、&fptr 的 out 参数被丢弃。 函数返回时把 *p 更新成删除位置的下一个元素(或 NULL),这里只用返回值。后续若误用 fptr 会读到错误位置。
五、listpackEntry ent[3] = {{0}} 的初始化。 listpackEntry 是 24 字节的 struct,不是 union:
c
typedef struct {
unsigned char *sval; /* 字符串时使用 */
uint32_t slen;
long long lval; /* 整数时 sval 为 NULL,用 lval */
} listpackEntry;
所谓"要么字符串要么整数"靠 sval == NULL 这个约定 实现,不是存储重叠。{{0}} 触发聚合初始化:外层大括号对应数组,内层 {0} 初始化第一个元素,其余成员和剩余元素按 C11 6.7.9p21 零初始化。所以三个 entry 的 sval 全是 NULL,lpEncodeEntries 会全按整数处理------除非后面显式赋 sval。
六、两次 realloc。 lpDeleteRangeWithEntry 末尾的 lpShrinkToFit(src/listpack.c:1481)必然 realloc 到更小;listpackExAddInternal 里的 lpBatchInsert 按第 7.2 节的三段条件走,变小必 realloc、变大视 lp_malloc_size 而定。一次 HEXPIRE 最少 1 次、最多 2 次 realloc,中间位置的字段被 memmove 两次。
这就是 LISTPACK_EX 写路径比 LISTPACK 慢一倍的根本原因。LISTPACK 的 HSET 更新走 lpInsert 的 LP_REPLACE 分支(src/listpack.c):
c
uint64_t new_listpack_bytes = old_listpack_bytes + enclen + backlen_size - replaced_len;
/* Realloc before: we need more room. */
if (new_listpack_bytes > old_listpack_bytes && new_listpack_bytes > lp_malloc_size(lp)) {
if ((lp = lp_realloc(lp,new_listpack_bytes)) == NULL) return NULL;
dst = lp + poff;
}
if (where == LP_BEFORE) {
memmove(dst+enclen+backlen_size,dst,old_listpack_bytes-poff);
} else { /* LP_REPLACE. */
memmove(dst+enclen+backlen_size, dst+replaced_len,
old_listpack_bytes-poff-replaced_len);
}
/* Realloc after: we need to free space. */
if (new_listpack_bytes < old_listpack_bytes) {
if ((lp = lp_realloc(lp,new_listpack_bytes)) == NULL) return NULL;
dst = lp + poff;
}
变大时只有超过 lp_malloc_size 才 realloc (jemalloc 的 size class 已留余量),变小时一定 realloc (精确到 new_listpack_bytes),长度恰好相等时两个条件都不成立、memmove 长度为 0,完全原地。
所以改一个字段的 value 在 LISTPACK 上:长度不变 → 零 realloc(最常见,比如 HSET h counter 1 反复执行);长度变小 → 1 次;长度变大且在 class 内 → 零。
7.3 hashTypeSet 的 KEEP_TTL 分支
第 5.2 节讲过代码。这里补一个语义细节:走 listpackExUpdateExpiry(o, field, fptr, vptr, HASH_LP_NO_TTL) 时,field 参数是调用方传的 sds ,而不是 listpack 里的那份。两者内容相同(因为 lpFind 刚匹配过),但地址不同。listpackExUpdateExpiry 用 sdslen(field) 和 field 的内容重新写入,所以没问题。
但如果调用方传的 field 是 c->argv[2]->ptr(命令参数),而 listpack 里的字段名是整数编码(比如字段名是 "123",被 lpStringToInt64 转成整数编码存储),会发生什么?lpFind 的 lpFindCmp 会正确处理------它先尝试把搜索串转成整数(src/listpack.c:970-979),匹配成功。然后 listpackExUpdateExpiry 用字符串 "123" 重新写入,字段名从整数编码变成字符串编码,多占 2 字节。字段名在 listpack 里的编码形态可能因为一次 HEXPIRE 而改变。
这个行为不是 bug,但会让"改 TTL 不改数据布局"的直觉失效 2 字节。listpackExExpire 的删除计数用的是 expired * 3(元素个数),不是字节数,所以不受影响。
8. cbFindInListpack 的计数协议
lpFindCb 是 listpack 提供的"带回调的线性扫描"(src/listpack.c:942-946)。它比 lpFind 更通用------lpFind 只做"按键查找",lpFindCb 允许调用方在遍历过程中自己决定停在哪里。
hash 用它做了两件事:给新 TTL 找插入位置 (第 7.1 节)、数有多少字段已过期 。两个用途共用一套参数结构(src/t_hash.c:1251-1258):
c
struct lpFingArgs {
uint64_t max_to_search; /* [in] Max number of tuples to search */
uint64_t expire_time; /* [in] Find the tuple that has a TTL larger than expire_time */
unsigned char *p; /* [out] First item of the tuple that has a TTL larger than expire_time */
int expired; /* [out] Number of tuples that have TTLs less than expire_time */
int index; /* Internally used */
unsigned char *fptr; /* Internally used, temp ptr */
};
七个字段里有三个是"同一个参数在不同用途下的不同读法",这是本章最需要小心的结构体。逐字段拆:
8.1 index 的起始值与 %3 周期
c
/* src/t_hash.c:1263-1289 */
static int cbFindInListpack(const unsigned char *lp, unsigned char *p,
void *user, unsigned char *s, long long slen)
{
(void) lp;
struct lpFingArgs *r = user;
r->index++;
if (r->max_to_search == 0)
return 0; /* Break the loop and return */
if (r->index % 3 == 1) {
r->fptr = p; /* First item of the tuple. */
} else if (r->index % 3 == 0) {
serverAssert(!s);
/* Third item of a tuple is expiry time */
if (slen == HASH_LP_NO_TTL || (uint64_t) slen >= r->expire_time) {
r->p = r->fptr;
return 0; /* Break the loop and return */
}
r->expired++;
r->max_to_search--;
}
return 1;
}
r->index++ 是前置自增 ,所以第一次进回调时 index 已经是 1。index % 3 == 1 对应第 1、4、7...... 个元素(field),% 3 == 2 对应 value,% 3 == 0 对应 TTL。如果写成 index++(后置),第一次进回调 index 是 0,0 % 3 == 0 会被判成 TTL------serverAssert(!s) 立刻炸。
r->fptr 在每次遇到 field 时更新,在遇到 TTL 时才使用。所以 r->p = r->fptr 拿到的是"当前这个 TTL 所属的那个 field"。这个"跨回调的状态传递"依赖 listpack 的线性遍历顺序------lpFindCb 保证从左到右、不跳元素(这里 skip = 0)。
8.2 max_to_search 与 expired 的两种读法
listpackExExpireDryRun(src/t_hash.c:1292-1304)只关心 expired:
c
struct lpFingArgs r = {
.max_to_search = UINT64_MAX,
.expire_time = commandTimeSnapshot(),
};
lpFindCb(lpt->lp, NULL, &r, cbFindInListpack, 0);
return r.expired;
max_to_search = UINT64_MAX,expire_time = now。回调跑到第一个"TTL >= now 或 TTL == 0"的三元组就 return 0 停下,r.expired 就是已过期字段数。这个函数是 O(过期字段数 + 1),不是 O(字段总数)------因为有序性保证"未过期的"都排在"已过期的"后面。
listpackExAddInternal 只关心 p:
c
struct lpFingArgs r = {
.max_to_search = UINT64_MAX,
.expire_time = ent[2].lval,
};
lpFindCb(lpt->lp, NULL, &r, cbFindInListpack, 0);
同样是 max_to_search = UINT64_MAX,expire_time = 新 TTL。跑到第一个"TTL >= 新 TTL 或 TTL == 0"就停,r.p 指向该三元组的 field。
listpackExGetMinExpire 不用这套(它走 lpSeek)。所以 max_to_search 在 hash 的两个调用点里都是 UINT64_MAX------这个字段看起来是为了"限流"设计的,但没被用上。
看一下 expired 与 max_to_search 的关系:每发现一个过期三元组,expired++ 且 max_to_search--。所以 max_to_search 是"最多数几个过期字段"的配额。如果传 N,回调数到第 N+1 个过期字段时 max_to_search 已经是 0,下一次进回调(index++ 之后)就 return 0,但此时已经越过了第 N+1 个三元组的 field 元素 ------r.p 没被设置。这是个潜在的死角,但两个调用方都不传有限值,所以不会被触发。
8.3 slen == HASH_LP_NO_TTL 放在短路的第一位
if (slen == HASH_LP_NO_TTL || (uint64_t) slen >= r->expire_time)。两个条件都要读一遍才能理解为什么要这个顺序。
slen 是什么?当 lpGetWithSize 返回 value == NULL 时,slen 被设成元素解码出来的整数值(src/listpack.c:909 的 lpGetWithSize(p, &ll, NULL, &entry_size),第二个参数是 int64_t *count)。所以对整数编码的元素,slen 就是那个整数。TTL 是整数编码(lpInsertInteger / lpBatchInsert 的 lval 分支),所以 slen 就是 TTL 的毫秒值。
但**HASH_LP_NO_TTL 是 0,而 0 也是"1970-01-01 00:00:00 UTC"**。所以 slen == 0 有两种可能:无 TTL,或者真的设了一个 1970 年的绝对时间。第二个条件 (uint64_t) slen >= r->expire_time 在 slen == 0 时通常为假(r->expire_time 是当前时间或新 TTL,都远大于 0)。所以如果只写第二个条件,无 TTL 字段会被当成"过期字段"计数,expired++。
HPEXPIREAT key 0 FIELDS 1 f 会被 parseExpireTime 接受吗?val = 0,unit == UNIT_MILLISECONDS,val > HFE_MAX_ABS_TIME_MSEC - basetime 不成立(basetime = 0),所以 val += 0,expire = 0。然后 hashTypeSetExpiryListpack 里:
c
if (unlikely(checkAlreadyExpired(expireAt))) {
propagateHashFieldDeletion(ex->db, ex->key->ptr, field, sdslen(field));
hashTypeDelete(ex->hashObj, field);
server.stat_expired_subkeys++;
return HSETEX_DELETED;
}
checkAlreadyExpired(0) 为真,字段被删除 ,不会写进 listpack。所以 t = 0 在 listpack 里确实只表示"无 TTL",第二个含义不可达。这是"用一个合法值当哨兵"的经典做法,靠写路径的过滤保证哨兵不被污染。
对比 HT 侧的哨兵:EB_EXPIRE_TIME_INVALID = EB_EXPIRE_TIME_MAX + 1 = 0xFFFFFFFFFFFF + 1(第 8 章)。这是超出合法范围的 值,所以不需要写路径过滤------任何合法 TTL 都不可能等于它。entryGetExpiry 直接比对(src/entry.c:88-97),没有歧义。
两个编码用两种哨兵策略,各有各的代价:
LISTPACK_EX |
HT |
|
|---|---|---|
| 哨兵值 | 0(合法值) |
0x1000000000000(超范围) |
| 保护机制 | 写路径拒绝 + 有序性把它隔离到尾部 | 值域本身 |
| 内存代价 | 0(0 的整数编码是 1 字节) |
0(ExpireMeta.trash 位标记"无 TTL") |
| 可表示的 TTL 下限 | 1(1970-01-01 00:00:00.001) |
0 |
LISTPACK_EX 丢掉了"TTL = 0"这个可表示值,代价是 1 毫秒的精度,可以忽略。但如果将来要支持"1970 年之前的绝对时间"(负数 TTL),这个哨兵就会冲突------不过 parseExpireTime 已经拒绝了 val < 0(src/t_hash.c:4375-4378)。
8.4 serverAssert(!s) 与"TTL 必须是整数编码"
c
} else if (r->index % 3 == 0) {
serverAssert(!s);
s 是 lpGetWithSize 返回的字符串指针:非 NULL 表示字符串编码。所以这个断言在说:TTL 元素一定是整数编码。
这个不变式由三条写路径共同保证:
listpackExAddNew的ent[2].lval = expireAt(src/t_hash.c:1421),sval为 NULL →lpEncodeEntries走整数分支;hashTypeConvertListpack转LISTPACK_EX时用lpInsertInteger(o->ptr, HASH_LP_NO_TTL, ...)(src/t_hash.c:3003);hashTypeConvertTmplToListpackOrHT用lpAppendInteger(new_lp, HASH_LP_NO_TTL)(src/t_hash.c:2934)。
如果有人在 listpackExUpdateExpiry 里把 ent[2] 误写成 { .sval = ..., .slen = ... },TTL 会变成字符串编码,这个断言在 debug 构建下立刻炸;release 构建下 slen 会是 0(字符串编码时 lpGetWithSize 的 count 参数不被写入),于是所有 TTL 被当成"无 TTL"------静默的错误数据。
这就是为什么 8.10 在热路径上还留着 serverAssert:它守的不是"边界条件",是编码形态的不变式。这类断言在 release 构建里被编译掉,但 debug 构建和 CI 会把它们跑一遍。
9. HT 的两次 ebuckets 注册
第 8 章讲过 ebuckets 的两种形态和 estore 的 Fenwick 槽位索引。这一节看 Hash 怎么把自己的字段过期挂到那套机制上。
9.1 htMetadataEx 的 32 字节与字段别名
c
/* src/server.h:4020-4028 */
typedef struct htMetadataEx {
size_t alloc_size; /* Total memory used for keys and values */
ExpireMeta expireMeta; /* embedded ExpireMeta in dict. To be used in order to
register the hash in the subexpires DB with next
minimum hash-field to expire. */
ebuckets hfe; /* DS of Hash Fields Expiration, associated to each hash */
} htMetadataEx;
8 + 16 + 8 = 32 字节。三层语义:alloc_size 是这个 hash 里所有 entry 的 usable 之和;expireMeta 是这个 hash 自己 在全局 db->subexpires 里的位置;hfe 是这个 hash 私有的字段过期 DS。
注意层级:hfe 装的是字段,expireMeta 装的是 hash 自己。 一个 hash 在 db->subexpires 里只占一个位置,那个位置的时间是"这个 hash 里最早过期的字段什么时候过期"。
两个访问函数共用同一个地址(src/server.h:4031-4037):
c
static inline htMetadataEx *htGetMetadataEx(dict *d) { return (htMetadataEx *)dictMetadata(d); }
static inline size_t *htGetMetadataSize(dict *d) { return (size_t *)dictMetadata(d); }
dictMetadata(d) 是 &(d)->metadata,而 struct dict 最后一个成员是柔性数组 void *metadata[]。因为 alloc_size 是第一个字段,两种解释指向同一个地址。
这个别名技巧在 hashTypeSet 的 HT 分支里体现得很清楚(1978):size_t *alloc_size = htGetMetadataSize(ht);,之后无论走 entryCreate 还是 entryUpdate 都只 *alloc_size += ...。把一个 32 字节结构的指针窄化成 8 字节字段的指针,减少一次基址计算------收益很小,但让代码读起来像"这个 hash 有一个计数器"。
代价是类型安全:htGetMetadataSize 的返回值不能用来判断"有没有 HFE 元数据"。只有 isDictWithMetaHFE 能(161-163),判据是 d->type == &entryHashDictTypeWithHFE 的指针相等 。所以"有 HFE"不占任何存储位------它完全由 d->type 表达。这也意味着无法在不换 dictType 的情况下摘掉 HFE,第 9 章说的"没有回程票"在 HT 侧的具体形态就是这个。
9.2 从 8 字节扩到 32 字节
新建的 hash 用 entryHashDictType(87-97),dictMetadataBytes 返回 sizeof(size_t) = 8(252-255)。第一次给字段设 TTL 时,hashTypeSetExInit 走这条路(2288-2302):
c
if (!isDictWithMetaHFE(ht)) {
dictTypeAddMeta(&ht, &entryHashDictTypeWithHFE);
htMetadataEx *m = htGetMetadataEx(ht);
o->ptr = ht; /* 必须回写! */
m->hfe = ebCreate();
m->expireMeta.trash = 1;
}
dictTypeAddMeta(src/dict.c:212-223):
c
void dictTypeAddMeta(dict **d, dictType *typeWithMeta) {
dictType toCmp = *typeWithMeta;
toCmp.dictMetadataBytes = (*d)->type->dictMetadataBytes;
toCmp.onDictRelease = (*d)->type->onDictRelease;
assert(memcmp((*d)->type, &toCmp, sizeof(dictType)) == 0);
*d = zrealloc(*d, sizeof(dict) + typeWithMeta->dictMetadataBytes(*d));
(*d)->type = typeWithMeta;
}
三个细节:
一、o->ptr = ht 不是冗余。 dictTypeAddMeta 收 dict **,内部 zrealloc 后 *d 变了地址;而 ht 只是 o->ptr 的一份拷贝。漏了这一行 o->ptr 就是悬垂指针,下一次访问立刻 UAF。
二、assert(memcmp(...)) 的比较技巧。 两个 dictType 只有 dictMetadataBytes 和 onDictRelease 不同(对比 87-111:WithHFE 版多了 onDictRelease = hashDictWithExpireOnRelease)。所以先把这两个字段从新 type 覆盖成旧的,再整块 memcmp。它守的是"两种 dictType 只在元数据能力上不同"这个不变式。
三、zrealloc 只扩头部,不碰桶数组。 sizeof(dict) 是 56(type 8 + ht_table2 16 + ht_used 16 + rehashidx 8 + pauserehash 4 + ht_size_exp 2 + pauseAutoResize 2),加 8 字节元数据 = 64 正好一个 cache line;加 32 = 88,落到 jemalloc 的 96 class。所以"给一个几万字段的 HT hash 加第一个字段 TTL"是 O(1),不是 O(N)------只搬 56 字节的头部,桶数组一个指针都不动。
9.3 onDictRelease 与 dictRelease 的顺序
c
/* src/t_hash.c:263-267 */
static void hashDictWithExpireOnRelease(dict *d) {
htMetadataEx *dictExpireMeta = htGetMetadataEx(d);
ebDestroy(&dictExpireMeta->hfe, &hashFieldExpireBucketsType, NULL);
}
ebDestroy 释放私有 DS 的内部结构(list 节点或 rax 树),但不释放 entry ------entry 由 dictEntryDestructor 逐个释放。
顺序不能想当然。dictRelease 的实现(src/dict.c):
c
void dictRelease(dict *d)
{
if (dictIsRehashing(d) && d->type->rehashingCompleted)
d->type->rehashingCompleted(d);
if (d->type->bucketChanged)
d->type->bucketChanged(d, -(long long)dictBuckets(d));
if (d->type->onDictRelease)
d->type->onDictRelease(d);
_dictClear(d,0,NULL);
_dictClear(d,1,NULL);
zfree(d);
}
onDictRelease 在 _dictClear 之前 。所以 ebDestroy 执行时所有 ExpireMeta 都还活着,ebuckets 的 list 模式遍历链表完全安全。反过来(先清桶再回调)就会踩已释放内存------ExpireMeta 是链表节点,next 指针就存在 entry 的分配块里。
代价是 ebDestroy 必须把 DS 里的节点摘干净,否则 _dictClear 之后 hfe 里留下悬垂指针。第三个参数 deletedItemsCbCtx = NULL 表示"不要回调,直接拆结构"。
对比 dictEmpty(src/dict.c)------它不走 onDictRelease,只清桶、保留 dict 结构。所以 hashTypeFree 的 DEBUG 块(3862-3865)用 dictEmpty 逐 entry 跑析构、检查 alloc_size 归零,然后 dictRelease 再跑 onDictRelease。两次遍历,第二次面对空 DS。
9.4 dictEntryDestructor 的记账减法
c
/* src/t_hash.c:232-250 */
static void dictEntryDestructor(dict *d, void *entry) {
size_t usable;
size_t *alloc_size = htGetMetadataSize(d);
if (entryGetExpiry(entry) != EB_EXPIRE_TIME_INVALID) {
htMetadataEx *dictExpireMeta = htGetMetadataEx(d);
ebRemove(&dictExpireMeta->hfe, &hashFieldExpireBucketsType, entry);
}
entryFree(entry, &usable);
*alloc_size -= usable;
/* Don't have to update global HFE DS. It's unnecessary. ... In the worst case
* scenario, the hash will be efficiently updated later by an active-expire
* operation, or it will be removed by the hash's dbGenericDelete() function. */
}
entryGetExpiry(entry) != EB_EXPIRE_TIME_INVALID 这个判断在 trash 为真时会返回 INVALID(src/entry.c:92-96),所以已经标记为"垃圾"的 entry 会跳过 ebRemove------这是第 8 章"trash 一比特两用"在 hash 侧的落点。
*alloc_size -= usable 是配对的减法。usable 由 entryFree 的 out 参数给出,值等于 entryMemUsage(entry)(113-119):sdsAllocSize(field) + sdsAllocSize(value) + (指针 ? 8) + (TTL ? 16)。而 entryCreate 返回的 usable 也是同一个函数算的,所以 += / -= 严格配对。
这个"加减法配对"的脆弱性解释了 hashTypeFree 里那个 debugServerAssert(*htGetMetadataSize(o->ptr) == 0)(3864)------DEBUG 构建下检查"所有 entry 删完后计数器归零"。这是唯一能在 CI 里发现记账漂移的地方。
10. hashTypeSetEx 的三段式协议
一个 HEXPIRE key 100 FIELDS 1000 f1 ... f1000 要为 1000 个字段设 TTL。如果每个字段都独立更新全局 subexpires,代价是 1000 次 estoreUpdate------每次都要在 rax 里删一个 key、插一个 key、更新 Fenwick 的两个槽位。8.10 把"更新全局 DS"抽出来做一次。协议写在 165-178:
sql
* 1. Call hashTypeSetExInit() to initialize the HashTypeSetEx struct.
* 2. Call hashTypeSetEx() one time or more, for each field/expiration update.
* 3. Call hashTypeSetExDone() for notification and update of global HFE.
10.1 两个"最小值"的语义分工
c
typedef struct HashTypeSetEx {
ExpireSetCond expireSetCond; /* [XX | NX | GT | LT] */
uint64_t minExpire; /* if uninit EB_EXPIRE_TIME_INVALID */
redisDb *db;
robj *key, *hashObj;
uint64_t minExpireFields; /* Trace updated fields and their previous/new
* minimum expiration time. */
client *c;
const char *cmd;
} HashTypeSetEx;
minExpire |
minExpireFields |
|
|---|---|---|
| 何时取 | hashTypeSetExInit 里一次 |
每次 hashTypeSetEx 里累积 |
| 范围 | 整个 hash 原有字段 | 本次命令涉及的字段(旧 TTL 和新 TTL 都算) |
| 准确性 | 可能不准(来自 expireMeta,有 4000ms 容差) |
精确(来自字段自身 TTL) |
| 用途 | 判断"全局 DS 里的值会不会变" | 判断"本次操作会不会产生更早的时间" |
minExpire 来自 hashTypeGetMinExpire(o, 0)(2306),accurate = 0 走 expireMeta。minExpireFields 的累积在四处,LISTPACK_EX 版和 HT 版对称(1507-1508 / 1520-1521 / 2183-2184 / 2211-2212):
c
if (ex->minExpireFields > prevExpire) ex->minExpireFields = prevExpire; /* 旧 TTL */
if (ex->minExpireFields > expireAt) ex->minExpireFields = expireAt; /* 新 TTL */
旧 TTL 只在"字段原来就有 TTL"时才记录 ------无 TTL 字段的"旧 TTL"不是有效时间。新 TTL 的记录在 checkAlreadyExpired 之后------一个已被删除的字段不需要考虑它的过期时间。
10.2 hashTypeSetExDone 的四道关卡
c
void hashTypeSetExDone(HashTypeSetEx *ex) {
if (hashTypeLength(ex->hashObj, 0) == 0) return; /* 关卡一 */
if ((ex->minExpire < ex->minExpireFields)) return; /* 关卡二 */
uint64_t newMinExpire = hashTypeGetMinExpire(ex->hashObj, 1 /*accurate*/);
uint64_t diff = (ex->minExpire > newMinExpire) ?
(ex->minExpire - newMinExpire) : (newMinExpire - ex->minExpire);
if (diff < HASH_NEW_EXPIRE_DIFF_THRESHOLD) return; /* 关卡三 */
int slot = getKeySlot(ex->key->ptr);
if (ex->minExpire != EB_EXPIRE_TIME_INVALID) { /* 关卡四 */
if (newMinExpire != EB_EXPIRE_TIME_INVALID)
estoreUpdate(ex->db->subexpires, slot, ex->hashObj, newMinExpire);
else
estoreRemove(ex->db->subexpires, slot, ex->hashObj);
} else {
if (newMinExpire != EB_EXPIRE_TIME_INVALID)
estoreAdd(ex->db->subexpires, slot, ex->hashObj, newMinExpire);
}
}
关卡一 :所有字段都在本次操作里被删了。hash 变空,上层会 dbDelete,不需要在全局 DS 留条目。若上层因故没删,active expire 跑到它时发现 itemsExpired == 0 会 estoreRemove 自愈(3714-3717)。
关卡二 :minExpire < minExpireFields 意味着"本次涉及的字段的最小旧/新 TTL 都比全局记录大"。此时:
- 若本次把某些 TTL 推后了,新全局最小值可能变大------但变大不需要立刻更新(晚一点过期允许);
- 若本次把某些 TTL 提前 了,
minExpireFields会包含那个提前值,就不满足这个不等式。
所以不等式成立时,新的真实最小值 >= minExpire,全局 DS 记的仍然是"不晚于真实最小值"的,不会漏过期。
关卡三 :HASH_NEW_EXPIRE_DIFF_THRESHOLD(23):
c
#define HASH_NEW_EXPIRE_DIFF_THRESHOLD max(4000, 1<<EB_BUCKET_KEY_PRECISION)
EB_BUCKET_KEY_PRECISION = 0,所以是 4000 毫秒 。名字容易误读成"批处理阈值"("累积 4000 个字段再刷"),实际是时间容差。
后果:全局 DS 记的时间可能比真实最小值晚最多 4 秒 。这 4 秒里 active expire 不会访问这个 hash,字段不会被主动过期;但惰性过期照常工作。所以 4000 的语义是主动过期的最大延迟 。4 秒约等于 40 个 serverCron 周期(默认 hz=10),足够让其他 hash 的过期任务跑完。
关卡四 :三种 estore* 的组合。
minExpire(旧) |
newMinExpire |
动作 |
|---|---|---|
| 有效 | 有效 | estoreUpdate(rax key 变,位置可能变) |
| 有效 | INVALID | estoreRemove(所有字段不再有 TTL) |
| INVALID | 有效 | estoreAdd(第一次有 TTL,注册) |
| INVALID | INVALID | 不可达(关卡三已拦,diff = 0) |
10.3 省掉了什么
1000 字段的 HEXPIRE:不做批处理是 1000 次 estoreUpdate;做批处理最多 1 次。estoreUpdate 的成本是 rax 删 + 插 + 两次 fwTreeUpdate(单机模式下 Fenwick 是空指针检查,无操作),约几百纳秒,1000 次就是几百微秒。
而 1000 次 ebAdd(每个字段进 hash 私有 hfe)无法省------那才是真正的数据结构维护。所以三段式协议把"O(N) 的全局索引更新"压到 O(1),剩下"O(N) 的私有 DS 更新"。N=1000 时是 1:1000 的削减。
10.4 hashTypeSetExInit 的两个转换分支
c
if (o->encoding == OBJ_ENCODING_LISTPACK) {
hashTypeConvert(c->db, o, OBJ_ENCODING_LISTPACK_EX);
} else if (o->encoding == OBJ_ENCODING_TMPL_LP ||
o->encoding == OBJ_ENCODING_TMPL_ARRAY)
{
/* Prepare template key: Convert to LISTPACK_EX if it fits, else HT */
hashTypeConvert(c->db, o, OBJ_ENCODING_LISTPACK_EX);
} else if (o->encoding == OBJ_ENCODING_HT) { ... }
TMPL_* 分支调同一个目标编码,但实际落点可能是 HT。原因在 hashTypeConvertTmplToListpackOrHT(2882-2885):
c
int target_enc = hashTypeCanConvertTmplToListpack(o) ? lp_enc : OBJ_ENCODING_HT;
hashTypeCanConvertTmplToListpack(2856-2876)检查四件事:字段数 <= hash_max_listpack_entries、所有字段名长度 <= hash_max_listpack_value、所有 value 长度 <= 同一限制(TMPL_ARRAY 时)、lpSafeToAdd。任一不满足就落 HT。
所以"TMPL 转 LISTPACK_EX"这个名字是误导的------它是"试着 转 LISTPACK_EX,不行就转 HT"。注释写明了(2878-2881):"'with_hfe' implies 'HT with hfe'"。
而它的 LISTPACK_EX 分支会给每个字段都 append 一个 HASH_LP_NO_TTL (2933-2934),之后由 hashTypeSetEx 逐个设 TTL。这些 TTL 是同一批的(命令给的是同一个 expireAt),所以插入顺序影响布局:第一个字段插到最前面,第二个因为 slen >= T(相等)也插到最前面......结果是后插入的排在前面 。所有 TTL 相同,任何顺序都是合法升序,不变式不被破坏------但这也说明同 TTL 的字段之间没有稳定顺序 ,HGETALL 的返回顺序可能因为一次 HEXPIRE 而改变。
11. 条件矩阵:两个 hashTypeSetExpiry* 的逐行对照
hashTypeSetEx 按编码分派到两个函数(src/t_hash.c:2228-2257)。签名不同------listpack 版要 fptr/vptr/tptr,HT 版只要 field。原因是 listpack 分支已在上面做过一次 lpFind(2233-2235)把三个指针拿到了;HT 版的 dictFindLink 放在 hashTypeSetExpiryHT 内部(2137),因为它需要 dictEntryLink 用于 dictSetKeyAtLink。
两者的条件判定完全一致 ,改法完全不一致 。先看 HT 版的核心段(src/t_hash.c:2143-2196):
c
if (expireAt == EB_EXPIRE_TIME_INVALID) { /* PERSIST */
if (entryGetExpiry(oldEntry) == EB_EXPIRE_TIME_INVALID)
return HSETEX_NO_CONDITION_MET;
hfieldPersist(exInfo->hashObj, oldEntry);
return HSETEX_OK;
}
if (!entryHasExpiry(oldEntry)) { /* 字段无 ExpireMeta */
/* For fields without expiry, LT condition is considered valid */
if (exInfo->expireSetCond & (HFE_XX | HFE_GT))
return HSETEX_NO_CONDITION_MET;
entryNew = entryUpdate(oldEntry, NULL, ENTRY_HAS_EXPIRY, &usableDiff);
*alloc_size += usableDiff;
} else { /* 字段有 ExpireMeta */
uint64_t prevExpire = entryGetExpiry(oldEntry);
if (prevExpire != EB_EXPIRE_TIME_INVALID) {
if (((exInfo->expireSetCond == HFE_GT) && (prevExpire >= expireAt)) ||
((exInfo->expireSetCond == HFE_LT) && (prevExpire <= expireAt)) ||
(exInfo->expireSetCond == HFE_NX) )
return HSETEX_NO_CONDITION_MET;
if (prevExpire == expireAt) return HSETEX_OK;
ebRemove(&dm->hfe, &hashFieldExpireBucketsType, oldEntry);
if (exInfo->minExpireFields > prevExpire)
exInfo->minExpireFields = prevExpire;
} else {
if (exInfo->expireSetCond & (HFE_XX | HFE_GT))
return HSETEX_NO_CONDITION_MET;
}
entryNew = oldEntry;
}
listpack 版(src/t_hash.c:1474-1525)的差异只有五处,逐条对照:
| 步骤 | HT 版 | LISTPACK_EX 版 |
|---|---|---|
| 读旧 TTL | entryGetExpiry(oldEntry) |
lpGetIntegerValue(tptr, &expireTime) 再判 != HASH_LP_NO_TTL |
| "有 ExpireMeta 但无 TTL" | 单独一个 else 分支 |
合并进 prevExpire == INVALID,逻辑等价 |
| 加 ExpireMeta | entryUpdate(..., ENTRY_HAS_EXPIRY, ...) 重分配 |
不适用(listpack 每字段都有 t 元素) |
| 摘旧 TTL | ebRemove(&dm->hfe, ...) |
无(listpack 无私有 ebuckets) |
| 写入新 TTL | ebAdd(&dm->hfe, ..., entryNew, expireAt) |
listpackExUpdateExpiry(...) 删+插 |
HT 版分三个状态(无 ExpireMeta / 有 ExpireMeta 无 TTL / 有 ExpireMeta 有 TTL),LISTPACK_EX 版只有两个(有 TTL / 无 TTL)。因为 listpack 的"有无 ExpireMeta"不是一个可选状态------三元组里永远有 t 元素。
11.1 四条件的完整矩阵
| 条件 | 字段原有 TTL | 结果 | 理由 |
|---|---|---|---|
NX |
有 | 拒绝 | 存在性判断 |
NX |
无 | 设置 | |
XX |
有 | 设置 | |
XX |
无 | 拒绝 | |
GT |
有 | 仅当 expireAt > prevExpire |
|
GT |
无 | 拒绝 | 无穷大没有更晚的 |
LT |
有 | 仅当 expireAt < prevExpire |
|
LT |
无 | 设置 | 无限视为无穷大 |
| 无条件 | 任意 | 设置 | |
PERSIST |
有 | 摘 TTL | |
PERSIST |
无 | 拒绝 |
代码里 HFE_GT / HFE_LT / HFE_NX 用 == 比较而不是位与(exInfo->expireSetCond == HFE_GT),因为 parseHashCommandArgs 已经用 __builtin_popcount(...) > 1 保证最多一个条件位(src/t_hash.c:6096-6098)。而 HFE_XX | HFE_GT 那一处用位与,是为了同时覆盖两种条件------它们在"无 TTL"场景下行为相同。
LT 对无 TTL 字段"视为成立"、GT 拒绝,注释里两处重复同一句话(1497、2157),只提 LT 不提 GT。这个决定与键级 EXPIRE 不一致:键级 GT 对无 TTL 键成立(getExpire 返回 -1,new > -1)。字段级选了保守的"拒绝"------"没有 TTL"不是一个时间点,无法比较大小。
代价是用户必须记住:给无 TTL 字段设 TTL 只能用无条件版本或 NX/LT,GT/XX 会返回 0。
11.2 PERSIST 的双重表示
HPERSIST 有两条实现路径:独立命令 hpersistCommand(6452)和 hashTypeSetEx(..., EB_EXPIRE_TIME_INVALID, ...)(HGETEX ... PERSIST 的内部路径,5122-5125)。
EB_EXPIRE_TIME_INVALID 作为 expireAt 的哨兵表示"摘 TTL"。这与 LISTPACK_EX 的 HASH_LP_NO_TTL = 0 是两个不同的哨兵,分别在两个编码里表示"无 TTL":
| 意图 | hashTypeSetEx 输入 |
LISTPACK_EX 内部 | HT 内部 |
|---|---|---|---|
| 设 TTL = T | T(毫秒时间戳) |
t = T |
entryGetExpiry = T |
| 摘 TTL | EB_EXPIRE_TIME_INVALID |
t = 0 |
entryHasExpiry = 0 或 trash = 1 |
两者之间的转换只发生在 hashTypeSetExpiryListpack 内部 (1492 的 listpackExUpdateExpiry(..., HASH_LP_NO_TTL))。别处都不该做这个转换,因为 0 在 HT 侧是"1970-01-01 过期"而非"无 TTL"。
双哨兵解释了一个看似冗余的分支------hashTypeIsExpired 必须按编码分别判断(1528-1542):
c
if (o->encoding == OBJ_ENCODING_LISTPACK_EX) {
if (expireAt == HASH_LP_NO_TTL) return 0;
} else if (o->encoding == OBJ_ENCODING_HT) {
if (expireAt == EB_EXPIRE_TIME_INVALID) return 0;
} else {
serverPanic("Unknown encoding: %d", o->encoding);
}
return (mstime_t) expireAt < commandTimeSnapshot();
两个 if 做同一件事、比对不同的值。统一哨兵能省掉这段分支,代价是每个无 TTL 字段多 8 字节(0x1000000000000 需要 9 字节的 listpack 整数编码,0 只需 1 字节)。对一个 128 字段的 LISTPACK_EX,这是 1KB 的差别------超过 listpack 本身大小的一半。用 8 字节/字段换一个 if,8.10 选了省内存。
11.3 checkAlreadyExpired 的位置与从库语义
两个函数都在条件检查之后 、写入之前调用它:
c
/* src/t_hash.c:1511-1518 */
if (unlikely(checkAlreadyExpired(expireAt))) {
propagateHashFieldDeletion(ex->db, ex->key->ptr, field, sdslen(field));
hashTypeDelete(ex->hashObj, field);
server.stat_expired_subkeys++;
return HSETEX_DELETED;
}
位置很关键:放在条件检查之后 ,意味着条件不满足时不删字段。HEXPIRE key -1 NX FIELDS 1 f 对一个已有 TTL 的字段返回 NO_CONDITION_MET,而不是删掉字段。
checkAlreadyExpired 的实现(src/expire.c:652-660):
c
int checkAlreadyExpired(long long when) {
/* EXPIRE with negative TTL, or EXPIREAT with a timestamp into the past
* should never be executed as a DEL when load the AOF or in the context
* of a slave instance. */
if (server.current_client && server.current_client->flags & CLIENT_MASTER) return 0;
return (when <= commandTimeSnapshot() && !server.loading && !server.masterhost);
}
两个 && 正是"不要替我删"的两个场景:server.loading(时钟不可信)、server.masterhost(删除权在主库)。第一个 if 更彻底------当前客户端是主库的复制连接时直接返回 0。
所以在从库上,unlikely(checkAlreadyExpired(...)) 恒为假,代码继续往下真的把过去的 TTL 写进 listpack 。之后 hashTypeIsExpired 认为它过期,hashTypeGetValue 在从库上返回 GETF_EXPIRED 但不删(1758)。整条链路自洽:从库"看得见过期"但"不敢删",等主库发 HDEL。
可观测的后果:HLEN 可能包含已过期但未删的字段 。hlenCommand 用 hashTypeLength(o, 0)(5300,不减过期),HGETALL 用 hashTypeLength(o, 1)(5349,减)。同一个 hash 上两者可能不一致。这是刻意的:HLEN 回答"容器里有几个条目",HGETALL 回答"你能看到几个"。主库上差异通常很小(主动过期会清理),从库上可以很大。
12. 惰性过期的七道闸与 hfeFlags 矩阵
hashTypeGetValue(1692-1799)是读路径总入口,也是惰性过期的唯一实现点。它的控制流是一串"要不要真的删"的判断,每道对应一个不能删的场景。
12.1 四种返回态
| 返回值 | 含义 |
|---|---|
GETF_OK |
字段有效,vstr/vlen 或 vll 已填 |
GETF_NOT_FOUND |
字段不存在 |
GETF_EXPIRED |
逻辑上过期(可能已惰性删除,也可能没删) |
GETF_EXPIRED_HASH |
字段是最后一个,整个 hash 已被 dbDelete |
后两者的区别不是"删没删",而是"hash 还在不在 "。调用方必须区分。hsetnxCommand(3909-3913):
c
/* Field expired and in turn hash deleted. Create new one! */
if (isHashDeleted) {
robj *o = createHashObject();
kv = dbAdd(c->db,c->argv[1],&o);
}
HSETNX 发现字段不存在有两种情况:从来没有过(hash 还在),或者刚被惰性过期且是最后一个(hash 已被删)。后者要重建 hash 并把 kv 指向新的。hincrbyCommand(4765-4771)同样处理。
12.2 七道闸的顺序
c
/* src/t_hash.c:1742-1764 */
if ((server.allow_access_expired) ||
(*expiredAt >= (uint64_t) commandTimeSnapshot()) ||
(hfeFlags & HFE_LAZY_ACCESS_EXPIRED))
return GETF_OK;
if (server.masterhost || server.cluster_enabled) {
if (server.current_client && (server.current_client->flags & CLIENT_MASTER))
return GETF_OK;
/* For replica, if user client, then act as if expired, but don't delete! */
if (server.masterhost) return GETF_EXPIRED;
}
if ((server.loading) ||
(hfeFlags & HFE_LAZY_AVOID_FIELD_DEL) ||
(isPausedActionsWithUpdate(PAUSE_ACTION_EXPIRE)))
return GETF_EXPIRED;
七道闸,按顺序:
server.allow_access_expired------ 调试开关(DEBUG SET-ACTIVE-EXPIRE),让服务器把过期对象当有效。它同时影响entryIsExpired(src/entry.c:100-101)和hashTypeIsExpired(1529),全链路。*expiredAt >= commandTimeSnapshot()------ 没到期。用>=而非>,恰好等于当前时刻算"未过期"。HFE_LAZY_ACCESS_EXPIRED------ 调用方显式要求"我要看过期字段"。server.masterhost || server.cluster_enabled------ 从库/集群特殊分支。若是主库的复制连接 (CLIENT_MASTER),返回 OK;若是普通客户端 ,返回GETF_EXPIRED但不删 。注释那句 "act as if expired, but don't delete!" 就是全部语义。集群模式下masterhost为空,走不进第二个if,继续往下。server.loading------ RDB/AOF 加载期间不删。加载时的时间是"文件生成时间",用它判断会误删大量数据。HFE_LAZY_AVOID_FIELD_DEL------ 只报告过期,不删。用于HGETEX/HGETDEL这类"先看清楚再决定"的命令。isPausedActionsWithUpdate(PAUSE_ACTION_EXPIRE)------CLIENT PAUSE ... WRITE或DEBUG PAUSE-CRON期间不删,避免暂停期间产生写。
七道之后才真的删(1766-1778):
c
serverAssert(hashTypeDelete(o, field) == 1);
propagateHashFieldDeletion(db, key, field, sdslen(field));
server.stat_expired_subkeys++;
if (!(hfeFlags & HFE_LAZY_NO_UPDATE_KEYSIZES)) {
uint64_t l = hashTypeLength(o, 0);
updateKeysizesHist(db, OBJ_HASH, l+1, l);
}
serverAssert(hashTypeDelete(o, field) == 1) 在说"刚找到过这个字段,删除必须成功"。中间没有别的代码能改 hash,所以成立------除非 keyspace notification 的回调改了 hash,这正是 KSN_INVALIDATE_KVOBJ(src/server.h:4156)存在的原因。
12.3 七个标志位与调用矩阵
c
/* src/server.h:4045-4054 */
#define HFE_LAZY_EXPIRE (0) /* Delete expired field, and if last field also the hash */
#define HFE_LAZY_AVOID_FIELD_DEL (1<<0) /* Avoid deleting expired field */
#define HFE_LAZY_AVOID_HASH_DEL (1<<1) /* Avoid deleting hash if the field is the last one */
#define HFE_LAZY_NO_NOTIFICATION (1<<2) /* Do not send notification */
#define HFE_LAZY_NO_SIGNAL (1<<3) /* Do not send signal */
#define HFE_LAZY_ACCESS_EXPIRED (1<<4) /* Avoid lazy expire and allow access to expired fields */
#define HFE_LAZY_NO_UPDATE_KEYSIZES (1<<5) /* If field lazy deleted, avoid updating keysizes histogram */
#define HFE_LAZY_NO_UPDATE_ALLOCSIZES (1<<6) /* ... avoid updating slot allocation sizes */
HFE_LAZY_EXPIRE 是 0------默认行为就是"删字段,最后一个就删 hash,发通知,发信号,更新直方图和分配统计"。
HGETEX / HGETDEL 用全套关闭(4993-4997、5109-5113):
c
const int flags = HFE_LAZY_NO_NOTIFICATION |
HFE_LAZY_NO_SIGNAL |
HFE_LAZY_AVOID_HASH_DEL |
HFE_LAZY_NO_UPDATE_KEYSIZES |
HFE_LAZY_NO_UPDATE_ALLOCSIZES;
五个位全开,但 HFE_LAZY_AVOID_FIELD_DEL 没开 ------字段会 被删。因为这两个命令是"批量处理 N 个字段",若每个字段都发通知、signalModifiedKey、更直方图,N 个字段就是 5N 次副作用。命令自己在循环结束后统一做一次(5032-5047、5159-5207)。
AVOID_HASH_DEL 而非 AVOID_FIELD_DEL 的选择很关键:字段要删(否则 HGETDEL 没法工作),但 hash 不能删------循环还在跑,o 指针还要用。等循环结束后由命令自己 dbDelete(5054-5058)。
| 调用点 | flags |
|---|---|
hashTypeExists(HSETNX/HEXISTS) |
HFE_LAZY_EXPIRE |
hincrby / hincrbyfloat |
HFE_LAZY_EXPIRE |
hgetexCommand / hgetdelCommand |
五开,无 AVOID_FIELD_DEL |
hsetexCommand 的 FXX/FNX 预检查 |
五开,无 AVOID_FIELD_DEL |
hrandfieldWithCountCommand |
不走本函数,走 hashTypeExpireIfNeeded |
HRANDFIELD 的特殊性(5557-5561):
c
/* Delete all expired fields. If the entire hash got deleted then return empty array. */
if (hashTypeExpireIfNeeded(c->db, hash)) {
addReply(c, shared.emptyarray);
return;
}
它是先全量清理,再随机采样 。因为 lpRandomPair / dictGetFairRandomKey 不知道哪些字段过期了,采到过期字段就得重采,可能无限循环。先清理,之后采样都是有效字段。
副作用是:HRANDFIELD 是读命令却会触发写 (删过期字段、可能 dbDelete)。只读从库上不会执行(hashTypeExpireIfNeeded 检查 server.masterhost,3766)。注释也提醒(3746-3753):
c
/* Delete all expired fields in hash if needed (Currently used only by HRANDFIELD)
*
* NOTICE: If we call this function in other places, we should consider the slot
* migration scenario, where we don't want to delete expired fields. */
"slot migration 时不要删过期字段"------迁移中的 key 被临时冻结,删了会让主从数据不一致。
13. 主动过期:hashTypeExpire 与配额传递
惰性过期只在有人访问时工作。一批字段过期后再没人碰,就永久占内存。主动过期补这个洞。
全局的 activeExpireCycle 遍历 db->subexpires------注意是 subexpires 不是 expires。expires 装键级 TTL,subexpires 装"有字段过期的 hash 的键"。两个 ebuckets 实例,同一套算法。
13.1 从全局到单 hash
subexpires 里每个条目是 robj *,ExpireMeta 嵌在 listpackEx->meta 或 htMetadataEx->expireMeta 里。时间值是"这个 hash 里最早过期的字段什么时候过期"。
到期后 estoreActiveExpire 调到 hashTypeExpire(3669):
c
uint64_t hashTypeExpire(redisDb *db, kvobj *o, uint32_t *quota, int updateSubexpires, int activeEx) {
uint64_t noExpireLeftRes = EB_EXPIRE_TIME_INVALID;
/* Skip allocation entirely when subkey notifications are disabled. */
fieldvec fvexpired;
vec *vexpired = isSubkeyNotifyEnabled(NOTIFY_HASH) ?
fieldvecInit(&fvexpired, FIELDS_STACK_SIZE) : NULL;
OnFieldExpireCtx onFieldExpireCtx = { .hashObj = o, .db = db, .activeEx = activeEx, .vexpired = vexpired };
ExpireInfo info = (ExpireInfo) {
.maxToExpire = *quota,
.now = commandTimeSnapshot(),
.ctx = &onFieldExpireCtx,
.itemsExpired = 0};
if (o->encoding == OBJ_ENCODING_LISTPACK_EX) {
listpackExExpire(db, o, &info);
} else {
serverAssert(o->encoding == OBJ_ENCODING_HT);
dict *d = o->ptr;
htMetadataEx *dictExpireMeta = htGetMetadataEx(d);
info.onExpireItem = onFieldExpire;
ebExpire(&dictExpireMeta->hfe, &hashFieldExpireBucketsType, &info);
}
*quota -= info.itemsExpired;
quota 是入参兼出参 。调用方给一个总配额(每轮最多删多少字段),hashTypeExpire 删完把用掉的减掉,下一个 hash 拿到剩余配额。这个"配额指针"避免了"每个 hash 都用满配额"导致的单轮超时。配额用完后 info.maxToExpire = 0,两个过期函数都立刻返回。
13.2 fieldvec 的 16 元素栈缓冲
c
#define FIELDS_STACK_SIZE 16
typedef struct fieldvec { vec v; void *buf[FIELDS_STACK_SIZE]; } fieldvec;
static inline vec *fieldvecInit(fieldvec *fv, size_t cap) {
vecInit(&fv->v, fv->buf, FIELDS_STACK_SIZE);
vecReserve(&fv->v, cap);
return &fv->v;
}
vec 是 8.10 引入的小型动态数组,构造时给一块栈缓冲,元素数不超过它就不堆分配。fieldvec 内嵌 16 个 void *(128 字节)。vecInit 绑定栈缓冲,vecReserve(cap) 预留堆空间------cap > 16 时立刻分配,cap <= 16 时什么都不做。
hashTypeExpire 传 cap = 16,所以永远不堆分配 ,除非过期字段超过 16 个(vecPush 自动扩容)。
isSubkeyNotifyEnabled 为假时 vexpired = NULL,整个 fieldvec 连初始化都不做。它的实现(src/notify.c:290-297)第二行是快速路径:
c
if (moduleHasSubscribersForKeyspaceEventWithSubkeys(type)) return 1;
if (dictSize(server.pubsub_patterns) == 0 && kvstoreSize(server.pubsub_channels) == 0)
return 0;
return (server.notify_keyspace_events & type) && (server.notify_keyspace_events & (...));
没配 pubsub 的服务器在这里就返回 0,后续所有通知逻辑全部跳过。这是最常见的生产配置,也是这条快速路径存在的理由。
13.3 两条过期路径的差异
LISTPACK_EX |
HT |
|
|---|---|---|
| 入口函数 | listpackExExpire |
ebExpire |
| 遍历方式 | 从 listpack 头部顺序扫 | ebuckets 的 rax/list 遍历 |
| 停止条件 | 遇到未过期或无 TTL 就 break |
ebuckets 内部判断 |
| 删除方式 | 批量 lpDeleteRange(lp, 0, n*3) |
每项回调 onFieldExpire → hashTypeDelete |
| 通知收集 | 扫描循环里 vecPush |
onFieldExpire 里 vecPush |
listpackExExpire 的批量删除是它比 HT 快的地方:一次 lpDeleteRange 删掉所有过期三元组,一次 lpShrinkToFit,一次 header 更新。HT 是每字段一次 dictDelete + 一次 ebRemove。
但批量删除带来一个限制:listpackExExpire 无法在"删了一半"时停下 。它的循环条件是 info->itemsExpired < info->maxToExpire,配额用完就停,而 lpDeleteRange 在循环之后 才执行(1377),所以中途 break 时已删的正好是 expired 个。精度是够的,只是删除粒度不同。
13.4 estoreRemove + estoreAdd 而不是 estoreUpdate
c
/* src/t_hash.c:3703-3731 */
if (info.itemsExpired) {
...
if (updateSubexpires) {
slot = getKeySlot(keystr);
estoreRemove(db->subexpires, slot, o);
}
if (hashTypeLength(o, 0) == 0) {
notifyKeyspaceEvent(NOTIFY_GENERIC, "del", key, db->id);
dbDelete(db, key);
noExpireLeftRes = 0;
deleted = 1;
} else {
if ((updateSubexpires) && (info.nextExpireTime != EB_EXPIRE_TIME_INVALID))
estoreAdd(db->subexpires, slot, o, info.nextExpireTime);
}
keyModified(NULL, db, key, deleted ? NULL : o, 1);
先 estoreRemove 再 estoreAdd,不是 estoreUpdate。因为 ebExpire 内部 已经把条目从私有 hfe 里摘了,但私有 hfe 和全局 subexpires 是两个不同的 ebuckets,estoreRemove 摘的是全局的,两个操作必须分开。
info.nextExpireTime 由两个过期函数填。listpackExExpire 的最后两行(1386-1387)用 hashTypeGetMinExpire(kv, 1)------accurate = 1,走 listpackExGetMinExpire,O(1)。ebExpire 的由 ebuckets 自己填,它知道摘下这些项之后下一个最近的是谁。
updateSubexpires 的两个取值:主动过期和 hashTypeExpireIfNeeded 传 1(保持全局 DS 最新),slot migration 期间传 0(迁移中的 key 被冻结,不能改全局 DS)。
sds keystr = kvobjGetKey(o) 有个隐含前提:o 必须是 iskvobj。调用方传的都是从 db 取出的真 kvobj,所以成立。
14. hashTypeLength 的四种算法,与那个会崩的断言
hashTypeLength 有五个分支,第二个参数决定"要不要减去已过期字段"(2443-2484,节选关键分支):
c
unsigned long hashTypeLength(const robj *o, int subtractExpiredFields) {
unsigned long length = ULONG_MAX;
if (subtractExpiredFields && server.allow_access_expired)
subtractExpiredFields = 0;
if (o->encoding == OBJ_ENCODING_LISTPACK) {
length = lpLength(o->ptr) / 2;
} else if (o->encoding == OBJ_ENCODING_LISTPACK_EX) {
listpackEx *lpt = o->ptr;
length = lpLength(lpt->lp) / 3;
if (subtractExpiredFields && lpt->meta.trash == 0)
length -= listpackExExpireDryRun(o);
} else if (o->encoding == OBJ_ENCODING_HT) {
uint64_t expiredItems = 0;
dict *d = (dict*)o->ptr;
if (subtractExpiredFields && isDictWithMetaHFE(d)) {
htMetadataEx *meta = htGetMetadataEx(d);
/* If dict registered in global HFE DS */
if (meta->expireMeta.trash == 0)
expiredItems = ebExpireDryRun(meta->hfe,
&hashFieldExpireBucketsType,
commandTimeSnapshot());
}
length = dictSize(d) - expiredItems;
} else if (o->encoding == OBJ_ENCODING_TMPL_LP) {
unsigned long n = lpLength(o->ptr);
serverAssert(n >= 1);
length = n - 1; /* 第一项是 template id */
} else if (o->encoding == OBJ_ENCODING_TMPL_ARRAY) {
length = ((hashTemplateArray *)o->ptr)->field_count;
} else {
serverPanic("Unknown hash encoding");
}
return length;
}
lpLength / 2、/ 3、n - 1 都是 O(1)------lpLength 读 header 的 numele。只有 subtractExpiredFields 那条路是 O(过期字段数)。
14.1 trash == 0 这个条件的真实含义
两个分支都写着 trash == 0 才减。trash 在第 9 章是"对象是否在 ebuckets 里",在第 8 章是"垃圾标记 + 重入保护",这里是第三种用法:"这个 hash 是否已注册到全局 subexpires"。
对 LISTPACK_EX:listpackExCreate 设 trash = 1(1241),转换时也不清;只有 estoreAdd 会清零。所以 trash == 1 意味着"从未注册"。这种状态下不调用 listpackExExpireDryRun(O(过期字段数) 的全表扫描)。
为什么不调用?因为"从未注册"通常意味着"刚转换完、还没有字段真的过期"。这是基于概率的优化 :绝大多数情况下 trash == 1 时确实没有过期字段,省掉扫描纯赚。
它不是正确性上的优化。要制造反例(trash == 1 且有过期字段)需要绕过 hashTypeSetExDone,唯一的绕行路径是 HPERSIST------但它只清掉 TTL,不制造过期字段。所以反例不可达,用 trash 当代理是安全的。
14.2 serverAssert(count == length) 的隐患
genericHgetallCommand 结尾(5444-5450):
c
/* Make sure we returned the right number of elements. */
if (flags & OBJ_HASH_KEY && flags & OBJ_HASH_VALUE) count /= 2;
serverAssert(count == length);
}
length 来自 hashTypeLength(o, 1)(5349),count 来自实际发出的回复数。对 HT 编码,两者用的是两套不同的过期判据:
ebExpireDryRun遍历私有hfe,数expireTime <= now的;- batch 循环遍历
dict,数entryGetExpiry(e) < now的(5397-5401)。
一个用 <= 一个用 <。恰好等于 now 的字段,一个算过期,另一个不算。 如果有一个字段的 TTL 精确等于 commandTimeSnapshot(),二者差 1,断言炸。
实际不会炸,因为两处读的 now 是同一个缓存值。但 ebExpireDryRun 内部的比较到底是 <= 还是 <,决定了这个差是否存在------第 8 章记过 ebuckets.c:2273-2277 的单测用 + (ebIsList(eb) ? -1 : 0) 补偿 list/rax 对 expireTime == now 的处理不一致。测试在迁就实现。
更值得警惕的是另一种 不一致:length 用 trash == 0 做闸,count 没有这个闸。若 trash == 1 且有已过期字段,length 不减、count 跳过,count < length,断言炸。这个组合同样不可达(第一次设 TTL 必然注册),但它揭示了脆弱性------length 的正确性依赖全局 DS 的注册状态,count 不依赖。两者一致靠"注册状态总是同步"这个隐含不变式。
14.3 迭代器的三指针协议
hashTypeIterator 对 LISTPACK_EX 维护三个游标(2589-2628):
c
fptr = hi->fptr; vptr = hi->vptr; tptr = hi->tptr;
if (fptr == NULL) {
fptr = lpFirst(zl);
} else {
serverAssert(tptr != NULL);
fptr = lpNext(zl, tptr); /* 前进靠 tptr,不是 vptr */
}
if (fptr == NULL) return C_ERR;
while (fptr != NULL) {
vptr = lpNext(zl, fptr);
tptr = lpNext(zl, vptr);
serverAssert(tptr && lpGetIntegerValue(tptr, &expire_time));
if (!skipExpiredFields || !hashTypeIsExpired(hi->subject, expire_time))
break;
fptr = lpNext(zl, tptr);
}
if (fptr == NULL) return C_ERR;
hi->fptr = fptr; hi->vptr = vptr; hi->tptr = tptr;
hi->expire_time = (expire_time != HASH_LP_NO_TTL) ? (uint64_t) expire_time : EB_EXPIRE_TIME_INVALID;
前进靠 tptr(三元组的第三个元素才是"这一组的末尾"),不是 vptr。while 循环处理 skipExpiredFields,最坏情况是"跳过 N 个连续过期字段"O(N)------但有 TTL 有序性,过期的都在前面,所以遍历开始时会连续跳过一批,之后稳定。
hi->expire_time 的类型转换是关键 :HASH_LP_NO_TTL(0)被转成 EB_EXPIRE_TIME_INVALID。这一步让上层不需要区分编码------hashTypeCurrentObjectNewEntry 直接用这个值(2833):
c
int hasExpiry = (hi->expire_time != EB_EXPIRE_TIME_INVALID);
忘了这一步转换,expire_time = 0 会被当成"有过期时间",然后 entryCreate(..., ENTRY_HAS_EXPIRY) 给每个字段分配 16 字节的 ExpireMeta。一个从 LISTPACK_EX 转到 HT 的 hash,所有无 TTL 字段都会带上 ExpireMeta,每个多 16 字节。128 字段就是 2KB。
14.4 HSCAN 为什么不用这个迭代器
hscanCommand(5474-5488)只有 15 行,全部逻辑在 scanGenericCommand。SCAN 系列不能用 hashTypeIterator,因为游标语义要求"跨调用可恢复"------迭代器的游标是指针,HSCAN 的游标要能序列化成字符串、下次从稳定位置继续。listpack 的指针在 lpInsert 之后失效,所以 HSCAN 对 listpack 用索引 当游标,对 HT 用 dictScan 的反向游标(第 6 章)。
这意味着 HSCAN 不保证 看到所有字段:扫描期间有人 HDEL 前面的字段,索引会偏移,某些字段可能被跳过或重复。这是 SCAN 系列的固有语义。
HSCAN 也不跳过过期字段 (第 18.1 节陷阱九),与 HGETALL 不一致。
15. hsetCommand 的三条优化与一次 O(n²) 回避
hsetCommand(src/t_hash.c:3986-4045)看起来很短,但里面有三层优化叠在一起。
15.1 第一层:hashTypeTryConversion 只看本次命令
c
hashTypeTryConversion(c->db, kv, c->argv, 2, c->argc-1);
它的第一个判据(1561-1602):
c
if (!tmpl_lp) {
/* Check field count limit for regular listpack. */
size_t new_fields = (end - start + 1) / 2;
if (new_fields > server.hash_max_listpack_entries) {
hashTypeConvert(db, o, OBJ_ENCODING_HT);
dictExpand(o->ptr, new_fields);
return;
}
}
(end - start + 1) / 2 是本次命令的字段数,不是"现有字段数 + 新增数"。
为什么这样就够?因为 hashTypeSet 的尾部还有一道(第 5.4 节):
c
if (hashTypeLength(o, 0) > server.hash_max_listpack_entries)
hashTypeConvert(db, o, OBJ_ENCODING_HT);
两道闸的分工:第一道处理"一次性塞进来太多字段"(避免先写 600 个再转),第二道处理"慢慢积累超过阈值"(每次写完检查)。
第一道还有一句 dictExpand(o->ptr, new_fields)------转换后立刻预分配足够大的桶数组,避免后续插入触发多次 rehash。没有这一句,hashTypeConvert 只按当前元素数 expand,之后每插一个新元素都可能触发扩容。
15.2 第二层:hashTypeBuildFreshListpack 的 O(n) 快路径
c
int numfields = (c->argc - 2) / 2;
/* The fresh-build fast path pays for a transient dedup dict, which only pays
* off once the field count is large enough; 5 fields is a reasonable
* threshold, below it the per-field loop is cheaper. */
if (kv->encoding == OBJ_ENCODING_LISTPACK && numfields >= 5 && lpLength(kv->ptr) == 0) {
created = hashTypeBuildFreshListpack(kv, c->argv + 2, numfields);
} else {
for (i = 2; i < c->argc; i += 2)
created += !hashTypeSet(c->db, kv, c->argv[i]->ptr, c->argv[i+1]->ptr, HASH_SET_COPY | HASH_SET_NO_TEMPLATE_CONVERT);
}
三个条件:LISTPACK 编码、numfields >= 5、listpack 为空。第三个最关键------只有全新 的 hash 才能一次 lpBatchAppend 写完。
hashTypeBuildFreshListpack(3943-3984)的核心是一个临时 dict 做去重:
c
dict *slots = dictCreate(&sdsReplyDictType);
dictExpand(slots, numfields);
int n = 0;
for (int j = 0; j < numfields; j++) {
sds field = argv[j*2]->ptr, value = argv[j*2+1]->ptr;
dictEntry *existing, *de = dictAddRaw(slots, field, &existing);
int slot;
if (de != NULL) { /* new field -> new slot */
slot = n++;
dictSetUnsignedIntegerVal(de, slot);
pairs[slot*2].sval = (unsigned char*)field;
pairs[slot*2].slen = sdslen(field);
} else { /* duplicate -> first-seen slot */
slot = (int)dictGetUnsignedIntegerVal(existing);
}
pairs[slot*2+1].sval = (unsigned char*)value; /* last value wins */
pairs[slot*2+1].slen = sdslen(value);
}
dictRelease(slots);
o->ptr = lpBatchAppend(o->ptr, pairs, (unsigned long)n * 2);
field sds -> pair 数组下标。第一次出现的字段占一个新槽(保持 argv 顺序),重复的字段复用同一个槽、覆盖 value(last-wins)。最后一次 lpBatchAppend 写完所有唯一字段。
三处内存技巧:sdsReplyDictType 借用 argv 的 sds(不复制、不释放);下标存在 dictEntry 的 value 里(dictSetUnsignedIntegerVal,不额外分配);pairs 在 <= 128 个字段时用栈上的 pairs_stack[2 * HSET_LP_STACK_PAIRS],即 2 * 128 * 24 = 6144 字节,超过才堆分配。
慢路径的 O(n²) 从哪来 :每个 hashTypeSet 在 LISTPACK 分支做一次 lpFind,从 lpFirst 开始线性扫描。第 i 个字段插入时 listpack 里已有 2i 个元素,lpFind 最多扫 i 个字段,总代价 sum(i) = n²/2。n=4 时 8 次比较,n=100 时 5000 次。阈值定在 5,大约是"临时 dict 的创建/销毁(约 200 字节分配 + 一次 dictExpand)抵得上 5 次 lpFind"的平衡点。注释自己说 "5 fields is a reasonable threshold"------经验值,不是测出来的精确分界。
15.3 第三层:HASH_SET_NO_TEMPLATE_CONVERT
hashTypeSet 的尾部有自动转 template 的逻辑(第 5.4 节)。如果每个字段都检查一次,一个 100 字段的 HSET 会做 100 次 hashTypeTryConvertToTemplate,每次都要 hashTemplateGetOrCreateWithHash(dict 查找 + 可能的创建)。
加 NO_TEMPLATE_CONVERT 后,循环里不检查,循环外统一做一次(4016-4017):
c
/* Convert to template once after all fields are set, rather than per-field,
* to avoid a template lookup for each field set. */
hashTypeTryConvertToTemplate(kv, server.hash_min_template_entries,
server.hash_max_template_entries, NULL);
前提是"转换只依赖最终状态,不依赖中间状态"。成立,因为 template 的判据是"字段集合完全匹配",与写入顺序无关。
副作用:如果 hash_min_template_entries 很小(比如 2),一个 100 字段的 HSET 在循环里反复"字段数不够",最后一次转换才成功 。不加这个标志的话,第一次达到阈值时就转换了,后面的字段会在 TMPL_LP 上继续写。两条路径的最终编码相同,但中途不同。
15.4 HGETALL 的批处理预取
c
/* src/t_hash.c:5363-5427 */
#define HGETALL_BATCH 16
Entry *batch_entry[HGETALL_BATCH];
sds batch_val[HGETALL_BATCH];
while (1) {
int batch_count = 0;
while (batch_count < HGETALL_BATCH) { /* 阶段一:只收指针 */
dictEntry *de = dictNext(&di);
if (!de) break;
Entry *e = dictGetKey(de);
batch_entry[batch_count++] = e;
redis_prefetch_read(e);
}
if (batch_count == 0) break;
int valid_count = 0;
for (int i = 0; i < batch_count; i++) { /* 阶段二:读过期与 value 指针 */
Entry *e = batch_entry[i];
if (skip_expired) {
uint64_t expire_time = entryGetExpiry(e);
if (expire_time != EB_EXPIRE_TIME_INVALID &&
(mstime_t)expire_time < commandTimeSnapshot())
continue;
}
batch_entry[valid_count] = e;
if (flags & OBJ_HASH_VALUE) {
sds val = entryGetValue(e);
batch_val[valid_count] = val;
redis_prefetch_read(val);
}
valid_count++;
}
for (int i = 0; i < valid_count; i++) { /* 阶段三:发回复 */ }
}
三阶段的目的是把指针追逐批量化 。HGETALL 的数据访问链是 dict 桶 → dictEntry → Entry → value sds,四层跳转,每层都可能 cache miss。阶段一不解引用,只把 Entry * 收进数组并发 redis_prefetch_read,所以预取有效(不会因为等数据而阻塞);阶段二 Entry 已在 cache 里,读 expire、读 value 指针,对 value 再预取;阶段三两者都热了,写回复。
redis_prefetch_read 是 __builtin_prefetch(p, 0, 3) 的封装,x86 上是 PREFETCHT0,ARM 上是 PRFM PLDL1KEEP。value 是链上最后一个 miss,预取收益最大。
对 LISTPACK 编码不需要这套------listpack 是一块连续内存,字段和值都在里面,硬件预取器自己能跟上顺序访问。那个 serverAssert(count == length) 就是在这里被验证的(第 14.2 节)。
16. 五种 HFE 命令的传播重写
HFE 命令的复制不是"原样转发"。HEXPIRE key 100 FIELDS 3 a b c 在从库上执行时,时钟、字段是否存在、条件是否满足都可能不同。主库必须把"实际操作了什么"翻译成确定性的命令。
16.1 四类字段,四条出路
hexpireGenericCommand 的注释(6259-6281)把规则写得最清楚:
markdown
* 1. Field's expiration time updated successfully - Propagate it to replica as
* part of the HPEXPIREAT command.
* 2. The field got deleted since the time is in the past - propagate also HDEL
* command to delete the field. Also remove the field from the propagated
* HPEXPIREAT command.
* 3. Condition not met for the field - Remove the field from the propagated
* HPEXPIREAT command.
* 4. Field doesn't exists - Remove the field from propagated HPEXPIREAT command.
*
* If none of the provided fields match option #1 ... then avoid propagating the
* HPEXPIREAT command to the replica.
对应代码(6326-6410):
c
for (int i = 0; i < args.fieldCount; i++) {
int fieldPos = args.firstFieldPos + i;
sds field = c->argv[fieldPos]->ptr;
SetExRes res = hashTypeSetEx(hashObj, field, args.expireTime, &exCtx);
if (res == HSETEX_OK) vecPush(vupdated, c->argv[fieldPos]);
else if (res == HSETEX_DELETED) vecPush(vdeleted, c->argv[fieldPos]);
if (unlikely(res != HSETEX_OK)) {
if (fieldsToRemove == NULL)
fieldsToRemove = zmalloc(sizeof(int) * (args.fieldCount > 0 ? args.fieldCount : 1));
fieldsToRemove[removeCount++] = fieldPos;
fieldsNotSet = 1;
}
addReplyLongLong(c, res);
}
hashTypeSetExDone(&exCtx);
...
if (vecSize(vupdated) == 0) {
vecRelease(vupdated); vecRelease(vdeleted);
preventCommandPropagation(c);
zfree(fieldsToRemove);
return;
}
/* Rewrite to canonical HPEXPIREAT command */
if (c->cmd->proc != hpexpireatCommand) {
rewriteClientCommandArgument(c, 0, shared.hpexpireat);
robj *expireTimeObj = createStringObjectFromLongLong(args.expireTime);
rewriteClientCommandArgument(c, args.expireTimePos, expireTimeObj);
decrRefCount(expireTimeObj);
}
/* For partial failures, remove failed fields from the original command */
if (fieldsNotSet) {
for (int i = removeCount - 1; i >= 0; i--) {
rewriteClientCommandArgument(c, fieldsToRemove[i], NULL);
}
robj *newFieldCount = createStringObjectFromLongLong(vecSize(vupdated));
rewriteClientCommandArgument(c, args.fieldsPos + 1, newFieldCount);
decrRefCount(newFieldCount);
}
三个关键动作:
一、命令名换成 HPEXPIREAT。 HEXPIRE(相对秒)、HPEXPIRE(相对毫秒)、HEXPIREAT(绝对秒)都归一化成绝对毫秒。从库不需要自己的时钟。
二、时间参数换成绝对毫秒。 args.expireTimePos 是 parseExpireTime 解析时记下的位置(初始为 2,4473 在解析到时间时更新)。
三、倒序删除失败字段。 必须倒序------rewriteClientCommandArgument(c, pos, NULL) 会从 argv 删掉第 pos 个元素,后面的元素整体前移。正序删除会导致后续的 pos 全部偏移,删错位置。这个模式在 HGETDEL 里也出现(5045-5046 连续两次 rewriteClientCommandArgument(c, 2, NULL))。
rewriteClientCommandArgument(c, args.fieldsPos + 1, newFieldCount) 更新 numfields 参数,新值 vecSize(vupdated)。
16.2 五种命令的重写表
| 原命令 | 传播成 | 代码位置 |
|---|---|---|
HEXPIRE / HPEXPIRE / HEXPIREAT |
HPEXPIREAT |
6385-6391 |
HPEXPIREAT |
原样(去掉失败字段) | 6394-6401 |
HPERSIST |
原样(确定性) | 无重写 |
HGETDEL |
HDEL |
5044-5046 |
HGETEX ... EX/PX/EXAT/PXAT |
HPEXPIREAT |
5179-5200 |
HGETEX ... PERSIST |
HPERSIST |
5169-5178 |
HSETEX ... EX/PX/EXAT |
HSETEX ... PXAT |
4685-4694 |
HINCRBYFLOAT |
HSETEX ... KEEPTTL FIELDS 1 f v |
4867-4868 |
HGETEX 的 PERSIST 分支(5169-5198)手工拼命令:
c
canonical_argc = 4 + num_fields;
canonical_argv = zmalloc(sizeof(robj*) * canonical_argc);
canonical_argv[idx++] = shared.hpersist;
incrRefCount(shared.hpersist);
canonical_argv[idx++] = c->argv[1]; /* key */
incrRefCount(c->argv[1]);
canonical_argv[idx++] = shared.fields;
incrRefCount(shared.fields);
canonical_argv[idx++] = createStringObjectFromLongLong(num_fields);
for (int i = 0; i < num_fields; i++) {
canonical_argv[idx++] = c->argv[first_field_pos + i];
incrRefCount(c->argv[first_field_pos + i]);
}
replaceClientCommandVector(c, canonical_argc, canonical_argv);
注意它拼的是 num_fields(命令里的总数),不是 vecSize(vupdated)------条件不满足的字段也被拼进去了。
这是一个故意放宽 :HPERSIST 对没有 TTL 的字段返回 -1,对不存在的字段返回 -2,都无害。精确裁剪要重新算 argc、重新分配数组,复杂度高。
而 HPEXPIREAT 分支必须精确裁剪(6394-6401),因为把条件不满足的字段也传播过去,会让从库设置一个主库没有的 TTL------数据不一致 。这就是"放宽"与"必须精确"的分界线:幂等且无副作用的操作可以放宽,有副作用的必须精确。
incrRefCount 在借用 argv 时必须做,因为 replaceClientCommandVector 之后原 c->argv 会被 decrRefCount。漏了就提前释放。
16.3 HSETEX 的 PXAT 归一化与提前返回
c
if (vecSize(vdeleted)) {
/* If fields are deleted due to timestamp is being in the past, hdel's
* are already propagated. No need to propagate the command itself. */
preventCommandPropagation(c);
} else if (set_expiry && !(flags & HFE_PXAT)) {
rewriteClientCommandArgument(c, expire_time_pos - 1, shared.pxat);
robj *expire = createStringObjectFromLongLong(expire_time);
rewriteClientCommandArgument(c, expire_time_pos, expire);
decrRefCount(expire);
}
expire_time_pos - 1 是单位关键字(EX/PX/EXAT/PXAT)的位置,expire_time_pos 是时间值的位置。!(flags & HFE_PXAT) 短路:原命令已是 PXAT 就不用改。
第一条分支的语义:只要有字段因为"时间在过去"被删了,整个 HSETEX 就不传播------那些 HDEL 已经通过 propagateHashFieldDeletion 单独发出去了。"设值"也跟着取消,因为字段最终不存在,传播 HDEL 就够了。
16.4 propagateHashFieldDeletion 的 enterExecutionUnit
c
/* src/t_hash.c:5938-5957 */
static void propagateHashFieldDeletion(redisDb *db, sds key, char *field, size_t fieldLen) {
robj *argv[] = {
shared.hdel,
createStringObject((char*) key, sdslen(key)),
createStringObject(field, fieldLen)
};
enterExecutionUnit(1, 0);
int prev_replication_allowed = server.replication_allowed;
server.replication_allowed = 1;
alsoPropagate(db->id,argv, 3, PROPAGATE_AOF|PROPAGATE_REPL);
server.replication_allowed = prev_replication_allowed;
exitExecutionUnit();
postExecutionUnitOperations();
decrRefCount(argv[1]);
decrRefCount(argv[2]);
}
enterExecutionUnit / exitExecutionUnit 是第 3 章的执行单元机制,把多个 alsoPropagate 打包成一个原子单元。
server.replication_allowed = 1 的临时打开是必要的:某些上下文(RDB 加载、脚本内部)它是 0,不打开的话 alsoPropagate 会被静默丢弃。一个过期字段的删除如果丢了,从库永远不知道该删。
postExecutionUnitOperations() 在 exitExecutionUnit 之后立刻调用,把刚打包的命令发出去。所以一次 hashTypeExpire 删 N 个字段,产生 N 个独立的执行单元 ,不是一个包含 N 个 HDEL 的单元。
代价:一次主动过期删 1000 个字段,产生 1000 个执行单元、1000 条 HDEL、1000 次复制流写入。listpackExExpire 的批量删除只优化了内存操作,没优化传播。
17. 双哨兵与内存记账的四处不一致
17.1 hashTypeAllocSize 的五个分支
c
/* src/t_hash.c:2486-2510 */
size_t hashTypeAllocSize(const robj *o) {
size_t size = 0;
if (o->encoding == OBJ_ENCODING_LISTPACK) {
size = lpBytes(o->ptr);
} else if (o->encoding == OBJ_ENCODING_LISTPACK_EX) {
listpackEx *lpt = o->ptr;
size = sizeof(listpackEx) + lpBytes(lpt->lp);
} else if (o->encoding == OBJ_ENCODING_HT) {
dict *d = o->ptr;
size += sizeof(dict) + dictMemUsage(d) + *htGetMetadataSize(d);
} else if (o->encoding == OBJ_ENCODING_TMPL_LP) {
size = lpBytes(o->ptr);
} else if (o->encoding == OBJ_ENCODING_TMPL_ARRAY) { ... }
这个函数报的是请求大小之和 ,不是实际占用。四处的差距:
| 位置 | 报的 | 实际 | 差 |
|---|---|---|---|
lpBytes(lp) |
listpack 头里记录的字节数 | jemalloc 按 size class 取整 | 系统性低估 0-25%(100 字节占 112) |
sizeof(listpackEx) |
24 | zcalloc 单次分配,落 32 class |
+8 |
sizeof(dict) |
56 | zmalloc(56 + metasize),HFE 时 88 → 96 class |
+8 |
dictMemUsage(d) |
dictSize * dictEntryMemUsage(no_value) + 桶数组 |
keys_are_odd 时 key 内联在桶指针里,连 dictEntryNoValue 都不分配 |
高估 dictSize * 8 |
第一行是第 9 章说过的"accumulates requested size"的自洽低估。第四行是有意的保守------dictMemUsage 自己的注释承认这是 "a strict over-estimate still (inline-stored keys allocate nothing), but no longer charges the value slot that a no_value dict never has"。
keys_are_odd = 1(src/t_hash.c:96)意味着 field sds 指针的最低位是 1,dict 把它直接存在桶指针里。所以 HT 编码的 MEMORY USAGE 在一个"所有 key 都内联"的 hash 上会高估 dictSize * 8 字节左右。
17.2 htMetadataEx.alloc_size 是另一套账
hashTypeAllocSize 的 HT 分支用 *htGetMetadataSize(d)(= alloc_size),而 alloc_size 是 entryCreate / entryFree 时 += usable / -= usable 累加出来的。usable = entryMemUsage(entry):
c
size_t entryMemUsage(Entry *entry) {
size_t size = sdsAllocSize(entryGetField(entry));
size += sdsAllocSize(entryGetValue(entry));
if (entryHasValuePtr(entry)) size += sizeof(sds);
if (entryHasExpiry(entry)) size += sizeof(ExpireMeta);
return size;
}
sdsAllocSize 返回 sdsHdrSize(type) + alloc + 1。所以这套账是"每个 sds 的请求大小之和 + 指针 + ExpireMeta",不含 jemalloc 的取整 。一个 entry 的逻辑大小 13 字节,实际占 16,alloc_size 记 13。
于是 MEMORY USAGE 对 HT hash 的报数是 56 + dictSize*8 + 桶数组 + Σ(entry 逻辑大小),真实占用是 96 + Σ(dictEntry 或内联) + 桶数组(取整) + Σ(entry 的 size class)。两个方向都有偏差,且不能相互抵消。
17.3 hashTemplatePerKeyMemoryShare 为什么不进 kvobjAllocSize
c
/* src/t_hash.c:2512-2523 */
/* Per-key share of the template's fixed memory usage, split across referencing keys.
* MEMORY USAGE only, deliberately NOT part of kvobjAllocSize: per-slot/db
* alloc-size stats add a key's size on insert and subtract it on delete, and
* assume it only changes when that key itself is touched. This share changes
* when OTHER keys drop/add the same template (key_refcount moves) with no hook
* updating this key, so folding it into kvobjAllocSize would drift the slot
* totals and trigger assert in dbgAssertAllocSizePerSlot. */
size_t hashTemplatePerKeyMemoryShare(const robj *o) {
hashTemplate *tmpl = hashTypeGetTemplate((robj *)o);
serverAssert(tmpl->key_refcount > 0);
return tmpl->mem_size / tmpl->key_refcount;
}
这是本章最重要的一条记账原则:所有进入 kvobjAllocSize 的量,必须在"只有这个 key 被改动时"才变化。
kvobjAllocSize 的结果被 updateSlotAllocSize(db, slot, kv, oldsize, newsize) 用来维护"每个 slot 的总分配字节数",更新方式是"删除时减旧值、插入时加新值"------这假设这个 key 的大小只在它自己被触摸时改变。
hashTemplatePerKeyMemoryShare 违反这个假设:当别的 key 引用/释放同一个 template 时,key_refcount 变了,这个 key 的分摊份额也变了,但没有任何代码去更新那个 key 的 slot 统计。结果就是 slot 总数漂移,dbgAssertAllocSizePerSlot 断言失败。
所以它被排除在 kvobjAllocSize 之外,只在 MEMORY USAGE 里用(objectCommand 单独调用)。注释里那句 "would drift the slot totals and trigger assert" 就是这条原则的实证。
这个例子说明 kvobjAllocSize 不是"内存占用量"的抽象,而是"可增量维护的内存占用量"的抽象。两者在共享结构面前分道扬镳。
17.4 双哨兵的完整映射表
| 层 | "无 TTL" 的表示 | "没有过期元数据" 的表示 |
|---|---|---|
| listpack 内容 | t = 0(HASH_LP_NO_TTL) |
不可能(三元组总有 t) |
listpackEx |
meta.trash = 1 且不在全局 DS |
同上 |
Entry |
entryHasExpiry() == 0 |
同上 |
Entry 的 ExpireMeta |
expireMeta->trash = 1 → entryGetExpiry 返回 INVALID |
同上 |
hashTypeSetEx 输入 |
EB_EXPIRE_TIME_INVALID |
不适用 |
ExpireMeta 在 ebuckets 里 |
EB_EXPIRE_TIME_INVALID |
trash = 1 |
六层,五个不同的值/标志。任何一个转换写错,都会产生"看起来有 TTL 但实际没有"或反之的状态。最容易出错的是第 1 行到第 4 行的转换:listpackExGetMinExpire 里 if (expireAt != HASH_LP_NO_TTL) return expireAt;(1321-1322),漏了这个判断,一个"全无 TTL"的 LISTPACK_EX 会返回 0,然后 estoreAdd(db->subexpires, slot, o, 0) 把 hash 注册到"1970 年就该过期"的位置。active expire 每个周期都会访问它,发现 itemsExpired == 0,estoreRemove + 重新评估。无限循环,每次 cron 都白跑。
18. 陷阱、与 7.x 的差异、小结
18.1 九条陷阱
一、listpackExUpdateExpiry 的 tmp[512] 在 hash-max-listpack-value > 512 时退化。 默认 64 不触发;配成 10000 后每次 HEXPIRE 长 value 字段都多一次 sdsnewlen/sdsfree。
二、hashTypeTryConversion 的 (end-start+1)/2 是本次命令的字段数 ,不是总字段数。真正的"超阈值"由 hashTypeSet 尾部的 hashTypeLength(o, 0) > hash_max_listpack_entries 兜住。读这段不能以为它是总量检查。
三、hashTypeSet 的 HT 分支在 KEEP_TTL 下做了两次 ebuckets 往返。 hfieldPersist + ebAdd 之后,hashTypeSetExpiryHT 又 ebRemove + ebAdd。中间那次 ebAdd 是纯浪费。优化空间存在,但改起来要动 hashTypeSet 的接口------需要区分"保留 TTL 但稍后重设"和"保留 TTL 且不再改"两种 KEEP_TTL。
四、hashTypeLength(o, 1) 的正确性依赖全局 DS 注册状态。 trash == 1 时不减过期字段,而 genericHgetallCommand 的 serverAssert(count == length) 依赖这个减法准确。一致性靠"设 TTL 必然注册"这个不变式维持。
五、hashTypeConvertListpackEx 在 db == NULL 时不碰 estore。 代码里 if (db && lpt->meta.trash != 1) 才 estoreRemove,minExpire 也只在那分支里赋值。所以后面 if (minExpire != EB_EXPIRE_TIME_INVALID) estoreAdd(db->subexpires, ...) 不会空指针解引用。这是"用变量的初始值当哨兵"防住 NULL 的手法,很隐蔽。
六、LT 对无 TTL 字段成立、GT 拒绝------与键级 EXPIRE 不一致。 键级 GT 对无 TTL 键成立(-1 < new),字段级拒绝。写客户端库时容易踩。
七、HGETALL 的 serverAssert(count == length) 依赖"两套过期判据一致"。 ebExpireDryRun 与 batch 循环的 entryGetExpiry < now 在 expireTime == now 这个边界上可能不一致(第 8 章记过 ebuckets.c:2273-2277 的测试补偿)。
八、listpackExExpire 的 lpDeleteRange(lp, 0, expired*3) 在 numele == UNKNOWN 时退化。 元素数超过 LP_HDR_NUMELE_UNKNOWN(65535)后 header 不再记录元素数,lpDeleteRange 会逐元素删。一个 LISTPACK_EX 最多 128 字段 × 3 = 384 个元素,远不到;把 hash-max-listpack-entries 配到 22000 以上才可能。
九、HSCAN 不跳过过期字段。 scanGenericCommand 直接用 hashTypeCurrentObject 取数据,没有 skipExpiredFields。所以 HSCAN 可能返回已过期的字段,与 HGETALL 不一致。这是 SCAN 系列"不保证一致的快照"语义的延伸。
18.2 与 7.x 的差异
| 7.4 | 8.10 | |
|---|---|---|
| HT 编码的字段存储 | sds key + sds val 两个独立分配 |
Entry 单次分配,field 与 value 内嵌 |
每次 HSET 的分配次数(短字段短值) |
3(dictEntry + 2 个 sds) |
1(Entry),且 dictEntry 常被内联省掉 |
EMBED_VALUE_MAX_ALLOC_SIZE |
无(无 entry 概念) | 128 |
LISTPACK_EX 的三元组排序 |
同(7.4 已引入) | 同 |
TMPL_LP / TMPL_ARRAY |
无 | 有(第 11 章) |
HGETALL 的批处理预取 |
无 | HGETALL_BATCH 16 三阶段 |
hsetCommand 的 fast path |
无 | hashTypeBuildFreshListpack(阈值 5) |
dict->metadata 柔性数组 |
有(6.0 引入,供 set/zset 用) | 扩展到 hash 的 htMetadataEx |
最重要的一行是第二行:HT 编码从"比 listpack 慢的兜底方案"变成"分配次数与 listpack 相当" 。7.x 里 hash-max-listpack-entries 的调优核心是"尽量避免转 HT,因为 HT 的内存和分配开销都大"。8.10 里这个权衡变了------HT 的每字段开销降到 16-24 字节(Entry + 内联 dictEntry),而 listpack 的每字段开销约 2 * (1 + len + 1)。对短字段(< 8 字节)listpack 仍然省,但差距从"3 倍"缩到"1.5 倍"。
18.3 本章涉及文件清单
| 文件 | 本章关注点 |
|---|---|
src/t_hash.c(6613 行) |
五个编码、HFE 三段式协议、五个 HFE 命令、传播重写 |
src/entry.h(141 行) |
Entry 三形态、aux bit 编号、entryGetAllocPtr |
src/entry.c(408 行) |
setEntryWriteInfo、needsNewAlloc、entryWriteNew/Over |
src/listpack.c |
lpFindCbInternal 的 skip、lpBatchInsert、lpDeleteRange*、lpSeek |
src/server.h |
htMetadataEx、HFE_LAZY_*、listpackEx、HASH_SET_* |
src/dict.c |
dictTypeAddMeta、dictRelease 回调顺序、dictShrinkIfNeeded |
src/expire.c |
checkAlreadyExpired |
src/notify.c |
isSubkeyNotifyEnabled |
src/object.c |
kvobjAllocSize 对 OBJ_HASH 的分派 |
18.4 验证方式
bash
# 编码迁移(无回程票)
redis-cli HSET h a 1 b 2
redis-cli OBJECT ENCODING h # listpack
redis-cli HEXPIRE h 100 FIELDS 1 a
redis-cli OBJECT ENCODING h # listpackex
redis-cli HPERSIST h FIELDS 1 a
redis-cli OBJECT ENCODING h # 仍然是 listpackex
# TTL 有序性:HGETALL 按 TTL 升序返回,无 TTL 的在最后
redis-cli HSET h a 1 b 2 c 3
redis-cli HPEXPIREAT h 3000 FIELDS 1 a
redis-cli HPEXPIREAT h 1000 FIELDS 1 b
redis-cli HPEXPIREAT h 2000 FIELDS 1 c
redis-cli --no-raw HGETALL h # b, c, a, 然后是剩余无 TTL 字段
# HSET 清 TTL
redis-cli HEXPIRE h 100 FIELDS 1 a
redis-cli HSET h a 9
redis-cli HPTTL h FIELDS 1 a # -1
# LT 对无 TTL 字段成立,GT 拒绝
redis-cli HSET h x 1
redis-cli HEXPIRE h 100 LT FIELDS 1 x # 1
redis-cli HSET h y 1
redis-cli HEXPIRE h 100 GT FIELDS 1 y # 0
单测在 tests/unit/type/hash*.tcl 和 tests/unit/type/hash-field-expire.tcl;ebuckets 的 dry-run 边界在 src/ebuckets.c 的 EB_TEST 块(2273 附近)。
18.5 小结
本章从 Entry 的内存布局出发,走完了 Hash 三个编码的全部写路径、读路径和过期路径。三条结论值得带走:
-
Entry让 HT 编码不再是"兜底方案"。 单次分配 + aux bit 编码 + 128 字节内嵌阈值,把每字段开销从 56 字节压到 16 字节。这解释了为什么 8.10 敢让hashTypeTryConversion更积极地转 HT------转过去的代价小多了。 -
LISTPACK_EX的"TTL 有序"是靠三条写规则维护的不变式,不是靠排序。 无 TTL 的用 0 当哨兵却排在最后,靠的是"遇到第一个无 TTL 就停"这条短路。它同时支撑了lpSeek(lp,2)的 O(1) 最小 TTL、listpackExExpire的批量删除、以及skip=2的字段查找。破坏任何一条,整套机制失效。 -
HFE 的正确性建立在"全局 DS 只会晚不会早"这个方向性上。
HASH_NEW_EXPIRE_DIFF_THRESHOLD = 4000允许全局记录比真实最小值晚 4 秒,因为晚只意味着"主动过期迟一点",而惰性过期兜住了正确性。反过来如果允许"早",active expire 会去处理没有过期字段的 hash,白跑还产生estoreRemove/estoreAdd抖动。
18.6 下一章预告
第 11 章讲 TMPL_LP 和 TMPL_ARRAY。核心是全局注册表------同一个字段集合被成千上万个键共享,字段名只存一份。本章已三次碰到它们:
hashTypeTryConversion的tmpl_lp分支用OBJ_ENCODING_TMPL_ARRAY作目标(1563),不是HT;hashTypeSetExInit把TMPL_*强行转成LISTPACK_EX才能设 TTL(2283-2287),因为 template 结构上放不下字段 TTL;hashTemplatePerKeyMemoryShare为什么不能进kvobjAllocSize(第 17.3 节),这是 template 的引用计数与 slot 统计的冲突。
第 11 章要回答:htemplates->by_fields 和 htemplates->by_id 两张表怎么协作、key_refcount 与 hold_refcount 为什么必须分开、BIO lazyfree 线程批量释放 key 时"不能碰注册表"的约束怎么用 bio_pending_drops 绕开、HIMPORT PREPARE 的 per-client fieldset 如何把"每次 SET 都要排序字段 + 查注册表"降到 O(1)。