Redis Hash底层揭秘:listpack与hashtable切换及渐进式rehash实战
做后端这几年Redis的Hash估计是我用得最多的数据结构了。缓存用户信息、存购物车、做计数器聚合几乎每天都离不开。但真要问一句“Redis的Hash底层到底是怎么存的”很多人会愣一下——它不是一张哈希表吗还有啥可说的。还真有而且坑不少。同一个HGET命令数据量小的时候走的是内存极度紧凑的listpack数据量一大就整体切换成真正的哈希表。这个切换过程、切换条件、以及切换之后带来的性能差异直接决定了线上服务能不能扛住流量。我之前就遇到过因为对大Key执行HGETALL导致实例卡顿的故障那次排查下来才真正开始认真读Hash相关的源码。这篇文章就把Redis的Hash底层实现掰开揉碎讲清楚从内存布局到渐进式rehash再到配置调优和排障经验一次性讲透。1. 一个命令背后藏着两套完全不同的数据结构Hash类型对外表现是field-value的键值对集合用起来就像一个小型Map。但在Redis内部为了同时兼顾内存效率和访问性能同一个Hash对象在不同阶段使用了两种截然不同的存储编码listpack老版本是ziplist和hashtable。这两种编码不是能互相替换的平等方案而是有明确的分工和切换规则。理解这套规则是理解Hash实现的第一步。1.1 为什么Redis非要搞两种编码先把场景想清楚一个Hash对象有的只有三五个字段每个字段就存一个几十字节的字符串有的却是几百万字段的巨型Map每天承受海量读写。如果所有Hash都用正经哈希表存那么哪怕只有一个字段也要维护一张完整的哈希表结构。光是一张空的哈希表就要分配一堆桶位指针数组再加上每个entry的指针开销小对象的额外内存开销可能比实际数据还要大。Redis作为内存数据库每一字节都很金贵这种浪费是不能接受的。反过来如果所有Hash都用压缩结构那几百万字段的Hash每次查找都要在这个紧凑但线性的结构里从头扫到尾哪怕每个entry只有几十字节一轮HGET的复杂度也是O(n)高并发下必然成为性能瓶颈。所以Redis的选择是小对象用listpack省内存、访问也不慢因为小大对象用hashtable保证O(1)访问和扩展能力。这就是典型的“分治策略”用两套结构去覆盖不同规模的数据。1.2 编码切换的判定规则在Redis 7.0及之后的版本里编码切换由两个配置控制# 当Hash的字段数量超过该值时转为hashtable hash-max-listpack-entries 128 # 当单个字段名或字段值的字节长度超过该值时转为hashtable hash-max-listpack-value 64触发转换的时机是写入操作。每次执行HSET这类命令时Redis会先判断两个条件这个字段加进去之后Hash里的字段总数是不是超过了hash-max-listpack-entries或者本次写入的field名、value的字节长度是不是超过了hash-max-listpack-value只要命中其中一条这个Hash对象就会从listpack整体转换为hashtable编码。注意这里用的词是“转换”一旦转过去就回不来了。Redis的编码转换是单向的即使后续你把字段删得只剩一个它也不会从hashtable转回listpack。这一点让不少人踩过坑后面处理大Key时会重点说。还有一个细节容易忽略创建Hash时如果第一条写入的field或value已经超过hash-max-listpack-valueRedis会直接创建hashtable编码压根不经过listpack阶段。这说明判断不只发生在转换时创建时同样生效。想确认线上某个key当前用了什么编码一条命令就行OBJECT ENCODING user:profile:10001返回listpack或hashtable一目了然。2. 小数据先握手listpack紧凑编码的实战拆解listpack这个结构是Redis 7.0起替代ziplist实现Hash、ZSet、List小数据量编码的后继者。很多老文章还在写ziplist其实新版Redis源码里Hash已经全面切到listpack了。理解listpack的内存布局才能回答“它凭什么省内存”这个问题。2.1 listpack的内存布局到底长什么样listpack本质上是一段连续的内存空间整体布局分为三部分头部、entry数组、尾部结束标记。头部固定占6字节前4字节记录整个listpack的总字节数后2字节记录元素个数。末尾用一个固定的0xFF字节标记结束。每个entry不是简单的“字段名字段值”摆一起而是分成三块encoding描述后面data的类型和长度是可变长编码短字符串用更少的字节表示长度信息data实际存储的数据可能是字段名也可能是字段值backlen用于记录这个entry自身占用的字节数是listpack能从尾部向前遍历的关键Hash对象在listpack里的物理排列是交替的一个字段名entry跟着一个字段值entry再跟着下一个字段名entry如此往复。数据量小的时候整个对象就是一块连续内存Redis把它当成一个字符串对象来管理扩缩容时做一次内存重分配极其高效。注意listpack的查找方式是线性扫描。因为触发转换阈值默认是128个字段最坏情况下一次HGET也就扫描128对entry每个entry最多64字节整体内存访问跨度不大CPU缓存友好实际耗时可忽略。这也是为什么listpack能在“省内存”和“查询效率”之间取得平衡。2.2 为什么listpack能省内存代价是什么省内存的关键在于没有“指针”。链式结构每个节点都要带上指向下一个节点的指针64位系统下一个指针就是8字节Redis的hashtable更是每个entry不仅带next指针桶数组本身还要占空间。而listpack里一个entry只保留必要的数据和两个长度信息长度信息还用了可变长编码——数字小的时候一两个字节就描述完了完全没有指针的固定开销。我们来对比一下一个只有两个字段的小Hash假设字段名各5字节字段值各10字节数据本身30字节。listpack模式下加上头尾和若干entry的元数据总共也就五六十字节。而hashtable模式下先分一张容量为4的桶数组一个指针对齐后占8字节4个桶就是32字节两个dictEntry各占32字节左右还有key和value的字符串对象头各占16字节以上总体轻则160字节起步数据稍多就奔着200多字节去了。这个差距在小对象上非常可观。不过省内存也有代价。listpack的插入和删除尤其是非尾部操作需要移动后续的所有entry来腾出或填补空间最坏情况是O(n)的内存拷贝。而且它没有索引查找只能遍历。这就是为什么它只能用于“小数据”。2.3 从ziplist到listpack一段存储优化的演变如果翻旧版Redis文档或老文章你会看到大量关于ziplist的讨论。ziplist有一个著名的痛点每个entry带有prevlen字段记录前一个entry的长度长度超过254字节时prevlen本身要扩展成5字节。一旦某个entry的长度变化可能导致后续entry的prevlen跟着变大进而引起连锁的内存重分配这就是“连锁更新”问题。最坏情况下一次插入操作可能触发整体多次重分配性能出现毛刺。listpack正是为了解决这个问题设计的。它在entry尾部引入backlen来记录当前entry自身长度而不是前一个entry的长度。这样一来修改某个entry的长度只会影响它自己再也不会向后传导。代价是listpack失去了从后往前遍历时直接跳过长度的能力——不过它本来就不承担复杂遍历任务这个取舍非常划算。我自己在实际测试中的体会是listpack这种改版确实稳定得多起码不用再担心“某个大value插入后触发连锁更新导致耗时飙升”这种老ziplist才会出现的诡异问题了。3. 高并发主战场hashtable实现与渐进式rehash当Hash的字段规模突破了阈值Redis会把整块listpack数据一次性重建为hashtable结构。从这一刻起查询复杂度从O(n)变成了O(1)但随之而来的是更复杂的扩容与迁移问题。3.1 三件套dict、dictht与dictEntryRedis自身维护的哈希表不是普通的单表结构而是由三个层次组成。最底层的是dictEntry一个标准的哈希表节点typedef struct dictEntry { void *key; // 字段名 union { void *val; // 字段值 uint64_t u64; int64_t s64; double d; } v; struct dictEntry *next; // 链地址法解决冲突 } dictEntry;中间层是dictht真正存放桶数组的结构体typedef struct dictht { dictEntry **table; // 桶数组每个元素指向一个链表头 unsigned long size; // 桶数组大小始终是2的幂 unsigned long sizemask; // 掩码等于 size-1用于计算索引 unsigned long used; // 已存放的entry数量 } dictht;最外层是dicttypedef struct dict { dictType *type; void *privdata; dictht ht[2]; // 两张哈希表为渐进式rehash准备 long rehashidx; // rehash进行到哪个桶-1表示没有在rehash unsigned long iterators; } dict;这里有两个很关键的设计。一个是dictht存了size和sizemask因为哈希表的容量固定为2的幂索引可以直接用hash sizemask位运算得到比取模快一个量级。另一个是ht[2]——正常情况下只用ht[0]ht[1]只在扩容或缩容期间临时承载数据迁移这也是渐进式rehash能够成立的结构基础。3.2 哈希冲突、负载因子与扩容时机Redis对Hash的field计算哈希值后通过hash sizemask定位到桶位。多个字段落到同一个桶时用链表把它们串起来这就是链地址法。在数据分布均匀的前提下每个桶的链表长度接近1查询就是一次哈希计算加一次指针跳转时间复杂度接近真正的O(1)。但链表再短也是额外开销。如果哈希表装满了还不扩容大量entry挤在同一个桶里链表越来越长查询效率就会明显劣化。Redis用负载因子衡量表的充盈程度负载因子 used / size扩容和缩容的触发时机非常明确当负载因子大于等于1且当前没有子进程在执行持久化任务时触发扩容当负载因子大于等于5时无论是否有子进程强制扩容当负载因子小于0.1时触发缩容扩容时新表的大小取“第一个大于等于used * 2的2的幂”。比如used是100扩容后size就是256。缩容时则取“第一个大于等于used的2的幂”尽量让表的大小贴合实际数据量不浪费内存。这个“带条件扩容”的设计也有讲究。执行BGSAVE或BGREWRITEAOF时Redis会 fork 子进程利用写时复制技术生成持久化文件。如果此时主进程大量扩容哈希表就会申请新内存并触发大量内存页复制导致子进程的写时复制压力激增搞不好直接把内存打爆。所以Redis在负载因子较低时选择忍一忍等持久化结束再扩容只有在负载因子达到5的极端情况下才强制扩容。3.3 渐进式rehash的完整工作过程Redis的rehash和普通哈希表的“一次性全量重建”不同它把迁移打散到每一次操作中避免一次性拷贝大量数据导致服务阻塞。整个过程分三步。第一步分配新表。扩容时往ht[1]里分配一块更大的桶数组同时把rehashidx从-1改为0标记rehash开始。第二步分批迁移。每执行一次增删改查命令Redis会调用_dictRehashStep迁移一个桶位的数据把这个桶位链表上的所有entry重新计算哈希值挂到ht[1]对应的桶里。除了命令驱动的主动迁移后台的serverCron定时任务也会在每次执行时批量迁移一部分桶确保即使某个key长期无人访问rehash也能持续推进不会被饿死。第三步收尾切换。当rehashidx增长到ht[0]的桶数组大小时说明所有entry都搬到了ht[1]。此时释放ht[0]的桶数组把ht[1]直接赋值给ht[0]ht[1]重置为空表rehashidx恢复为-1。整个rehash周期结束。看起来简单但要特别注意渐进式rehash不是瞬间完成的中间存在一个非常长的“双表共存”窗口期这个期间的读写处理逻辑和平时完全不同。3.4 rehash期间的查找与删除为什么这么特殊双表共存期最核心的规则有三条新增的entry一律写入ht[1]写进旧表只会增加后续迁移的工作量查找field时先在ht[0]找没找到再去ht[1]找因为ht[0]里那些未被迁移的bucket仍然有数据删除field时两张表都要检查因为同一个key可能一部分数据在旧表、一部分已经在新表这就带来一个操作层面的体验rehash期间某些字段的访问会多一次哈希计算和一次指针跳转。不过这个开销极小而且分摊到每个命令上用户几乎感知不到。我当初读源码时对sizemask的设计印象很深。因为两张表的大小都是2的幂所以同一个哈希值用ht[0]-sizemask和ht[1]-sizemask分别做一次与运算就能得到两个表中的桶位完全不用重新计算哈希函数rehash的迁移成本就被压缩到最低。4. 配置参数与业务调优了解了底层实现配置文件里的那些参数就不再是死数字了。它们背后的取舍关系才是调优的价值所在。4.1 核心配置项详解先捋一下和Hash直接相关的配置配置项默认值作用hash-max-listpack-entries128字段数超过该值编码从listpack转为hashtablehash-max-listpack-value64单个字段名或字段值字节长度超过该值编码转为hashtable这两个参数在Redis配置文件里通常和List、ZSet的相关参数放在一起。在生产环境查看和修改# 查看当前生效值 config get hash-max-listpack-entries config get hash-max-listpack-value # 运行期动态修改重启失效 config set hash-max-listpack-entries 256注意config set虽然能动态修改但已经存在的key不会立即切换编码。列表这类结构都有list-max-listpack-entries类似参数而Hash只有在后续写入触发条件、或重建key时才可能重新走listpack编码。4.2 场景驱动的参数调整思路参数设成多少取决于你的业务里Hash的典型规模。如果你的业务是“小而多”的模型——比如给每个用户建一个Hash存最近浏览记录每个Hash就十来个字段字段值也不长这种场景listpack是绝对的主力可以把hash-max-listpack-entries调大到512甚至1024尽量让更多Hash停留在listpack编码内存能省一大截。但前提是把单字段的hash-max-listpack-value控制住别让某个用户塞了一篇长文进去直接把整个Hash拖入hashtable。反过来的场景也常见某个Hash注定要存大量字段或者字段值动辄几百字节。这时候就算调大entries阈值它也会因为value超限提前转成hashtable。与其让编码在两种结构之间反复横跳不如一开始就接受hashtable编码把精力放在控制单个Hash的总大小上避免大Key风险。还有一种容易被忽视的情况一个Hash内部如果有大量字段的值都很小但字段数量刚好在阈值边界附近波动。业务上每次插入都可能触发一次全量编码转换——这个转换不是渐进式的而是把整块listpack遍历一遍逐条插入hashtable整个过程在单线程里执行。如果这个Hash字段数已经接近阈值而你的写入QPS又很高就可能出现周期性的延迟毛刺。这种情况下要么把entries阈值调大让更多Hash稳定在listpack要么干脆调小让Hash尽早转正避免反复踩在边界上。4.3 监控指标如何判断当前编码是否合理线上排查时我一般分三步判断Hash编码是否健康。第一步抽样检查。定期扫描一批热点Hash key执行OBJECT ENCODING统计listpack和hashtable的占比。如果绝大多数key都是listpack说明整体内存控制得不错。第二步看内存构成。INFO memory里的used_memory和used_memory_rss差距如果过大说明内存碎片比较严重。hashtable编码下每个entry都要独立分配内存高频增删会导致碎片率上升listpack的整体连续内存分配反而碎片更少。第三步看慢日志。SLOWLOG GET里如果频繁出现操作小Hash却耗时异常的记录优先怀疑是不是Hash在写入过程中触发了编码转换把耗时吞了进去。这种问题光看平均耗时是看不出来的必须看长尾。经验之谈不要盲目把所有Hash都转成hashtable。线上一个用户维度的Hash平均30个字段、每个字段值约40字节如果全部用hashtable内存大约会多花60%。对于主要以内存计价的Redis集群来说这是一个相当可观的成本。5. 高频故障与排查经验底层机制理解了最终还是得回到实战。这一节把我在线上踩过、也帮别人处理过的Hash相关问题集中列出来每一个都有血泪教训在里面。5.1 大Key引发的连锁问题Hash最常见的事故就是大Key。一个大Key不是突然出现的往往是因为写入逻辑没加限制某个Hash的字段数一路涨到几十万甚至上百万。一旦这种Key出现问题接踵而至。先说内存层面。百万字段的Hash光entry的指针和元数据就能吃掉上百MB内存加上字段名和字段值本身的存储轻轻松松上GB。集群的槽位是按key粒度分布的一个大Key会把几百MB的数据全压到一台实例上造成严重的数据倾斜。再说操作层面。HGETALL是Hash的经典“毒药”它会一次性取出所有字段和值构造一个巨型回复缓冲区然后通过网络发给客户端。这个操作在单线程模型中光是遍历和序列化就要卡住主线程几十毫秒甚至更久如果并发触发Redis直接进入阻塞状态整个实例的P99飙升。处理大Key的正确姿势是把操作切碎。需要遍历大Hash时用HSCAN代替HGETALL每次取固定数量的字段HSCAN large_hash 0 COUNT 100然后根据返回的游标继续迭代。需要删除大Key时用UNLINK代替DEL让内存在后台线程回收不阻塞主线程。如果是非核心数据也可以利用过期时间让它自然淘汰。5.2 编码转换不可逆早规划早省心前面提到过Hash从listpack转到hashtable是单向的。这就带来一个管理上的麻烦如果业务一开始没有规划好字段数和字段大小等到Hash已经变成hashtable内存已经上去了再把字段删掉也回不到listpack内存自然也不会掉下来。我遇到过一个真实案例某个活动配置Hash上线时字段不多活动运营中期不断往里面加配置项某个字段值不小心写了个几百字节的JSON片段编码立刻从listpack转成hashtable即使后来把这个大字段删了Hash也一直保持hashtable编码。几十个字段的小Hash白白多占好几倍内存。这个问题的根治方法是预防。写HSET之前先在业务层面对字段值和filed长度做校验超过阈值就拆分或换存储。已经变成hashtable的key如果确认数据可以重刷最快的办法是直接删除后重新写入让新写入的流程有机会走listpack编码。5.3 哈希碰撞与安全性问题Redis的hashtable是可能被恶意攻击的。如果哈希函数的输出有规律攻击者可以构造大量哈希值相同的field让它们全都落到同一个桶里链表无限变长查找复杂度退化成O(n)。这本质上就是哈希碰撞拒绝服务攻击的思路。Redis对这个问题做了防御默认哈希函数采用SipHash。这个算法带密钥密钥在实例启动时随机生成攻击者无法预知具体哈希结果也就无法定向构造同桶数据。除非你在编译Redis时显式使用--enable-murmurhash之类参数切到老算法或者在自己扩展的模块里用了不可靠的哈希实现否则线上默认配置下这个风险很低。不过自己写Lua脚本往Hash里灌数据时还是要小心。即便哈希算法安全大量相同前缀的field在hashtable里也会因为桶位分布不均匀导致局部链表偏长。如果业务注定要写入海量相似命名的field适当在field里加一些业务随机后缀让哈希分布更均匀是个成本很低的优化手段。5.4 内存碎片与高并发写入hashtable编码下每次新增字段都要为dictEntry和字符串对象分配独立内存块。高并发写入和删除会让内存分配器默认jemalloc频繁回收和分配小块内存产生碎片。碎片率高了以后明明used_memory显示几GBused_memory_rss却已经十几GB了机器内存告急。遇到这种情况几个手段按顺序试检查INFO memory的mem_fragmentation_ratio如果持续高于1.5需要考虑重启或迁移节点让内存池重置检查业务模型如果Hash数据有明显的潮汐特征白天写入、夜间大批量删除可以考虑错峰删除减少短时间内的分配释放压力配置层面给Hash字段值统一使用更紧凑的序列化格式比如用数字、短字符串减少小对象数量另外如果你在用Redis Cluster大字段横跨节点的场景虽然少但大Key造成的数据倾斜会把单个节点的带宽打满这种情况主要靠限制单key字段数上限和值大小来兜底。6. 从源码到实战Hash选型与设计建议讲完内部实现和常见故障最后沉淀几条可以直接落地的设计原则。6.1 什么样的数据适合用HashHash适合的场景有鲜明的特征一类key对应多个子属性子属性数量可控且需要独立读写。典型的就是用户信息HSET user:profile:10001 name 张三 age 28 city 上海这种模型下Hash比用JSON字符串存整个对象好得多。你可以只修改城市字段而不需要重写整个对象也不需要先把整个JSON取回来解析再序列化写回去省了网络传输和CPU开销。另一个高频场景是计数聚合。用Hash把多个计数项放在一起利用HINCRBY原子自增比用多个String key更省内存也便于管理。比如按日期维度存储各渠道的访问量HSET stats:2025-06-01 wechat 15230 app 98210 web 32400 HINCRBY stats:2025-06-01 app 16.2 字段数量的合理边界Hash字段数的合理边界并不是“越大越好”。根据底层实现最理想的区间是字段数控制在hash-max-listpack-entries以下同时每个字段值控制在hash-max-listpack-value以下让对象停留在listpack编码。具体数字实践中大概是字段数超过5000的Hash基本已经进入hashtable编码性能还凑合但大Key风险开始上升字段数超过10000必须评估是否拆分比如按业务维度拆成多个Hash或者按用户ID做分片字段数超过50000几乎肯定会有HSCAN遍历需求要提前设计好批量处理方案如果你能通过业务拆解把单Hash字段数压在500以内同时控制单字段值在几百字节内Redis跑起来会非常轻松内存和性能都能兼顾。6.3 Hash vs String vs 独立Key很多人在建模时会纠结具体信息到底用Hash存还是用JSON序列化后塞进String还是拆成多个独立Key。我的选择逻辑很简单需要强类型子属性、要独立读写某个字段、子属性数量明确——用Hash整个对象一次性写入、一次性读取、内部结构复杂嵌套JSON——用String存序列化内容更简单每个属性都有独立生命周期、需要单独过期——拆成多个String Key配合TTL需要特别强调的是哈希表里每个field的过期时间依附于整个Key的过期时间做不到“单个field单独过期”。如果你有这个需求Hash就无能为力得拆Key或者额外维护过期索引。6.4 扫尾的实践心得从listpack的紧凑布局到hashtable的双表rehashRedis的Hash实现本质上是“内存效率和访问性能的平衡艺术”。我读源码最大的感受就是每一个看似聪明的设计背后都有一个真实世界的痛点。listpack解决的是内存浪费渐进式rehash解决的是扩容阻塞SipHash解决的是安全问题。实际做项目时我给自己定了几条铁律写Hash前先估算字段数和值大小明确会不会突破listpack阈值线上定一个字段数告警阈值超过就触发HSCAN巡检绝对不在大Hash上执行HGETALL和DEL。这几条铁律帮我躲掉了好几次潜在的事故。如果你正在优化Redis内存占用我建议第一步就是去线上扫一遍Hash key的编码分布。你会发现很多看起来不起眼的小Hash因为某一次超长value的写入早就悄悄切换成了hashtable白白浪费了大量内存。这种问题用OBJECT ENCODING一查一个准修复起来也快——重写这个key让它重新走一次listpack就完事了。