
先说个我在项目里反复验证过的结论在 Redis 里存储同一逻辑实体的一组属性时Hash 往往比拆成多个 String 省内存而且省得很夸张。这不是面试题里的玄学而是底层存储结构决定的。很多人写业务时习惯性set user:1000:name xxx、set user:1000:email xxx一个用户四个字段就生成四个 key。等内存告警拉响、去 INFO memory 里看 used_memory 飙升的时候才意识到问题出在key 太多上。这篇文章我会把 Redis 里 String 和 Hash 的底层存储一路拆到字节级别讲清楚为什么 Hash 比 String 省内存、省在哪里、什么时候会失效以及我在生产环境里怎么根据数据特征做选型和调优。适合所有正在做 Redis 容量规划、或者想优化 Redis 内存成本的开发者。1. 先说一个反直觉的事实多个 String 的开销在你看不见的地方很多人觉得 String 就一对 key-value应该是最省内存的存储方式。但从 Redis 进程的角度看每一条 String key 都是一整套独立的元数据套餐全局哈希表要记录它键对象要描述它值对象要管理它内存分配器要单独给它划一块地。这些固定开销不会因为 value 只有 5 个字节就免掉。1.1 每个键值对背后站着 robj、dictEntry 和 SDSRedis 里所有 key 和 value 最终都要通过几个核心结构串起来。以一条set user:1000:name lucy为例进程内部大概是这样一张关系图redisDb 里的dict维护了全库 key 的索引每条 key 在 dict 里对应一个dictEntry。dictEntry在 64 位系统上占 24 字节8 字节 key 指针、8 字节 valueunion 的 v、8 字节 next 指针。key 本身是robjredisObject16 字节type 4bit、encoding 4bit、lru 24bit、refcount 4 字节、ptr 8 字节。key 的字符串内容存在 SDS 里。SDS 头部根据字符串长度从 3 字节到 17 字节不等还要算上内容本身和结尾的\0。这三层下来一个 14 字节的 key 光索引层就已经接近 60 字节。value 同理又是一份 robj 加 SDS 的开销。换句话说当你存一个user:1000:name - lucy时真正意义上数据只有 8 个字节但 Redis 为这条数据付出的结构性成本是数据的 5 到 10 倍。这还不是全部。全局 dict 为了控制负载因子会维持一个桶数组桶的数量通常是实际 key 数的 2 倍左右默认负载因子到 1 就扩容。桶数组本身占的内存也会均摊到每个 key 上这部分在INFO memory里算作 overhead。1.2 内存分配器让小东西变得很贵Redis 默认用 jemalloc 做内存分配。jemalloc 本身设计得很好但它对零散小对象的管理仍然有对齐和元数据开销。比如你申请一个 19 字节的 SDS 结构jemalloc 实际给的可能是一个 24 字节或 32 字节的 chunk多出来的 5 到 13 字节是对齐损失这对单个对象不算什么但当你铺了几百万个 key误差就不是小数了。String 方案最大的问题在于每个 key 和 value 都是独立的分配单元。一个用户四个字段就有八个左右的内存块在堆上飞来飞去。一旦 Redis 内存碎片率高mem_fragmentation_ratio 1.3这部分被浪费的内存会进一步放大。我在一个项目里见过碎片率 1.6几千万条小 String 直接多吃了好几个 GB。1.3 String 并非一无是处整数与共享对象的例外说句公道话String 有三种编码int、embstr、raw。如果 value 是整数Redis 用int编码直接把 long 值放在 robj 的 ptr 里不额外分配 SDS。而且小整数还有共享机制比如-1到10000范围的对象全局复用refcount 加一就行根本不占独立数据空间。所以严格说String 省内存不省取决于 value 形态。但业务里更多人存的是用户名、邮箱、城市这类字符串没法走整数优化。一旦回到字符串场景String 方案单个键值对的 overhead 就实打实地摆在桌面上。2. Hash 省内存的真正秘密listpack 与 hashtable 的两副面孔Hash 类型在 Redis 内部不是只有一种存储方式。它是因地制宜的小 Hash 用紧凑结构连续存放大 Hash 才切换成真正的哈希表。这种双轨设计才是 Hash 省内存的核心。2.1 小 Hash 的连续存储从 ziplist 到 listpack在老版本 Redis7.2 之前Hash 存储小对象时用 ziplist。ziplist 本质上是一块连续内存把 field-value 一对一对地顺序塞进去。它的结构大致是zlbytes | zltail | zllen | entry1 | entry2 | ... | zlend每个 entry 由prevlen encoding content组成prevlen是前一个 entry 的长度用于从尾部往头部遍历encoding标记数据是字符串还是整数以及长度。因为是连续内存没有指针没有 dictEntry没有独立的 robj每个 field-value 对只付出几个字节的信封成本。Redis 7.0 开始逐步用 listpack 替代 ziplist。listpack 同样是一块连续内存但每个 entry 不再记录前一个 entry 的长度而是记录自身长度backlen彻底解决了 ziplist 最让人头疼的级联更新问题。结构简化成total-bytes | num-elements | entry1 | entry2 | ... | 0xFFlistpack 的 entry 由encoding content backlen组成整体比 ziplist 更紧凑操作也更安全。现在 Redis 7.x 里的 Hash、List、ZSet 小对象走的都是 listpack。为什么小 Hash 查 field 不慢listpack 查找是线性的看起来 O(n)但 n 被限制在 128 以内而且连续内存对 CPU 缓存极友好一条 cache line 能扫好几个 entry实际比哈希表里的一堆指针跳跃还要快。Redis 在这里用空间和简单算法换到了更少的内存和更快的访问。2.2 大 Hash 的 hashtable 编码省内存的边界在哪当 Hash 不再小Redis 会把它转为真正的哈希表hashtable。触发条件由两个配置控制hash-max-listpack-entries默认 128field 数量超过它就走 hashtable。hash-max-listpack-value默认 64任何一个 field 或 value 长度超过 64 字节也走 hashtable。一旦切换到 hashtable每个 field-value 对就退化成和普通 String 类似的结构有独立的 dictEntry、独立的 robj、独立的 SDS。这时候 Hash 就不再有明显的内存优势了反而因为外层还有一个对象头和一个内层 dict可能比拆散的 String 还费内存。所以Hash 比 String 省内存有一个隐含前提字段数量适中、每个 field 和 value 都比较小。这也是为什么 Redis 给这个紧凑编码设了阈值——在省内存和高性能之间找一个平衡。2.3 顺带一提不只 HashList 和 ZSet 也在用这套设计这套小对象紧凑编码不是 Hash 专属。List 也用 listpack老版本是 quicklist 包裹 ziplist 节点ZSet 的成员少于阈值时同样用 listpack。Redis 的设计哲学在这里高度统一能用紧凑结构就尽量紧凑只有当数据规模大到线性访问不可接受时才切换到带索引的结构。理解了这一点再去看 zset-max-listpack-entries、list-max-listpack-size 这些配置就不会觉得陌生了。3. 用数据说话同一批数据String 和 Hash 的真实内存对比空谈原理没有说服力。我直接在本地 Redis 上做了一个可控实验用两万条用户资料数据分别按 String 方案和 Hash 方案写入对比used_memory的差值。3.1 可复现测试20000 个用户维度的压测脚本实验环境是 Redis 7.2单机单实例空库。用 Python 脚本写入数据每个用户包含四个字段name、email、age、city。import redis r redis.Redis(db1) def used_memory(): return r.info(sectionmemory)[used_memory] # String 方案每个字段独立 key for i in range(1, 20001): r.set(fuser:{i}:name, lucy) r.set(fuser:{i}:email, lucyexample.com) r.set(fuser:{i}:age, 28) r.set(fuser:{i}:city, beijing) print(String used_memory:, used_memory()) r.flushdb() # Hash 方案一个用户一个 key for i in range(1, 20001): r.hset(fuser:{i}, mapping{ name: lucy, email: lucyexample.com, age: 28, city: beijing }) print(Hash used_memory:, used_memory()) r.flushdb()跑完得到一组很有代表性的数字不同版本、不同系统会略有浮动但趋势一致方案key 数量used_memory平均单用户内存String 拆分80000约 9.8 MB约 512 字节Hash 聚合20000约 3.2 MB约 168 字节在这个实验里Hash 比 String 方案省了大概三分之二的内存。数据一模一样的lucybeijing这些值仅仅因为是存储结构不同最终占用的内存差了 6.6 MB这还只是两万条数据生产环境几千万条时差距是 GB 级的。3.2 结果解读与 overhead 拆解把差值拆开看String 方案的额外负担主要在三块key 的重复 indexing。String 方案有 80000 个 key全局 dict 里就得有 80000 个 dictEntry、80000 个 key 的 robj 和 SDS。Hash 方案只有 20000 个外层 keyfield 全部藏在 value 里不进全局 dict。value 的独立分配。String 方案每个字段都有独立 value每个 value 至少一个 robj 和一个 SDSHash 的 listpack 把所有 value 连续地装在同一块内存里不再为每个 value 单独分配。分配粒度与碎片。80000 对小对象在 jemalloc 里产生的对齐损失和碎片明显高于 20000 个 listpack 连续块。还有一个容易被忽略的点field 的名称也在 listpack 里连续存放。name、email、age、city这几个字段名总共也就 15 个字节但在 String 方案里每个 key 是 10000 个user:1:name、10000 个user:2:name……这串前缀user:和编号被无限重复存储。Hash 方案里外层 key 仍然要存user:1但 field 名只需要在每个用户的 listpack 里存一次。3.3 用 MEMORY USAGE 和 DEBUG OBJECT 定位单个 key 的开销想确认单个 key 的精确占用用MEMORY USAGE命令 MEMORY USAGE user:1000 (integer) 104这个 104 字节就是 user:1000 整个 hash 对象的占用包括外层的 robj、listpack 本体和数据。对比 String 方案分别查四个字段的 key MEMORY USAGE user:1000:name (integer) 88 MEMORY USAGE user:1000:email (integer) 96 MEMORY USAGE user:1000:age (integer) 72 MEMORY USAGE user:1000:city (integer) 88四项加起来 344 字节是 Hash 方案的三倍多。注意这是在小字段场景越大越接近但固定 overhead 的差距就在这里。DEBUG OBJECT可以看当前 key 的编码方式 DEBUG OBJECT user:1000 Value at:0x7f... refcount:1 encoding:listpack serializedlength:92 ...看到encoding:listpack就说明这个 hash 正在享受紧凑存储的红利如果看到encoding:hashtable那就要审视字段数量和 value 大小是不是超了阈值。4. 从原理落到优化配置参数与 field 设计的实操经验原理清楚了下一步就是怎么把这些知识变成实实在在的内存优化。这里不只是改配置更重要的是设计 field 和 value 的形态我在这部分踩过不少坑。4.1 hash-max-listpack-entries 和 hash-max-listpack-value 怎么调看配置用CONFIG GET hash* CONFIG GET hash* 1) hash-max-listpack-entries 2) 128 3) hash-max-listpack-value 4) 64entries控制字段数量上限value控制单个 field 或 value 的长度上限。两者是或的关系任意一个超了整个 hash 立即转 hashtable。我的调整经验是字段数量通常不用动。128 已经很合理。如果你一个 hash 里要放 500 个字段硬调大 entries 会让任何一次 HGET/HSET 都变成线性扫描写放大很严重反而把性能做没了。value 阈值可以根据业务提。比如业务里经常要存 100 字节左右的短文本默认 64 会导致 hash 很快转 hashtable。我习惯把hash-max-listpack-value调到 128 或 256让更多小对象留在 listpack 里。调大阈值不是没有成本。更大的 value 意味着 listpack 需要分配更大的连续内存块修改时重新分配和拷贝的开销也会变大而且大 value 在 listpack 里做线性匹配时消耗更高。建议只针对你有把握的字段长度去调别拍脑袋从 64 调到 4096。在 Redis 7.0 之前配置名是hash-max-ziplist-entries和hash-max-ziplist-value。如果你维护的是老集群先确认版本新版本里老的 ziplist 配置依然能识别但日志会提示你迁移到 listpack 配置。4.2 藏得最深的坑一个 65 字节的 value 毁掉整个紧凑编码这是我实际踩过的坑。当时做用户画像存储设计了一套 Hash 结构每个用户一个 key几十个字段都很小。上线后我用DEBUG OBJECT抽查发现某些大用户的 hash 已经变成hashtable编码。排查了很久发现是其中一个字段偶尔会存一段avatar_url加上域名参数后长度到了 70 字节。问题就在这里hash 的编码是全局一致的不是按字段单独选的。一个字段超长整个 hash 从 listpack 变为 hashtable其他几十个本来在紧凑区的小字段也跟着变成独立的 dictEntry 加 robj内存直接反弹。这个坑最隐蔽的地方在于它不会报错不会告警只会在内存曲线里悄悄抬升。定位方法也只能是定期扫DEBUG OBJECT看编码分布或者直接对线上数据做MEMORY USAGE抽样。为了避免这种一颗老鼠屎坏一锅汤我后来定了三条规矩所有 Hash 模型上线前检查每个字段可能的最大长度任何字段可能超过 64 字节或你配置的阈值要么把它拆出去单独存 String要么改用别的设计。如果你确实需要把一个偏大的对象塞进 Hash也可以选择用压缩后的字符串比如 gzip 后再存但除非万不得已别在存储层做这种投机宁可用 String 单独存大字段。加一个巡检小脚本定时统计各前缀 key 的编码分布一旦发现hashtable比例异常升高立刻预警。4.3 选 String 还是 Hash一张决策表实际操作中我不会为了省内存而无脑 Hash。经过这些年我总结出一张简单直接的决策表新项目做数据建模时直接照它套数据形态推荐类型核心原因独立的单键缓存token、session 内容、页面快照String语义单一能直接设置 TTL一个逻辑实体的固定属性组用户资料、商品参数、订单信息Hash字段级读写方便内存 overhead 聚在一个 key 里纯计数器、限流计数StringINCR/DECR 天然原子语义无可替代大文本、大 JSON、二进制内容StringHash 的紧凑编码对单个大 value 不友好还容易拖垮整个 hash需要精确控制每个字段过期时间的业务String或额外过期表标准 Redis 中 Hash 的 field 没有独立 TTL这里要特别强调一下第三种和第五种。计数器是 String 的主场你硬要用 Hash 的 HINCRBY 也不是不行但语义上绕。字段过期这个边界下面单独讲。5. 别把优化变成事故Hash 方案的边界与规避手段Hash 省内存是真的但它不是没有代价。生产环境里因为滥用 Hash翻车的事我见过不止一次把这些边界讲清楚你才敢放心用。5.1 大 Hash 是定时炸弹如何拆分listpack 编码的 hash 有 128 个字段的天然限制但 hashtable 编码的 hash 理论上可以塞进百万个字段。你可能会想既然 hashtable 查找是 O(1)存大一点也无所谓大错特错。一个百万字段的 hash单看 HGET 确实没问题但下面这些操作全是灾难HGETALL一次性返回几十万个字段网络包几十 MB客户端内存可能先被撑爆。删除整个 keyDEL 或过期淘汰时Redis 必须把这个大对象整体释放释放过程在主线程执行Redis 会卡住很久。持久化RDB/AOF对超大 key 的序列化也会造成明显的 CPU 尖峰。主从复制时大 key 在全量同步阶段会放大内存压力。我的建议是单个 Hash 的字段数量控制在 1000 以内更保险一点是 500 以内。如果业务确实有一对多关系比如一个用户的全部行为记录不应该塞进一个 hash而是考虑拆分例如按时间分桶user:1000:events:202401 - hash存这个月的行为 user:1000:events:202402 - hash存下个月的行为这样每个 hash 的字段数量都可控也能用 SCAN 并行处理。如果你在集群环境多个分桶 hash 之间还想保证同节点就要配合 hash tag见 5.3。5.2 field 没有 TTL 的替代方案Hash 一个公认的短板field 没有独立过期时间。不管 hash 设不设过期时间作用于的都是整个 key而不是某个 field。所以哪些字段需要单独过期的场景例如用户 token 30 天过期但昵称不过期Hash 直接做不了。常见替代方案有三种拆走需要过期的字段用单独的 String TTL 存。token 用set token:1000 xxx EX 2592000用户其他资料留在 hash 里。读的时候多取一次但语义最清晰。在 field 的 value 里带时间戳应用层自行判断过期。比如 field 值存{v:xxx,expire:1711000000}遇到过期值就当不存在后台可以定期清扫。缺点是取值和判断逻辑都在客户端麻烦且容易漏。升级 Redis 版本较新的 Redis 已经支持 Hash field 级别的过期命令可以用 HEXPIRE 这组命令做到 field 级 TTL。如果你的版本已经支持直接用官方能力不用自己造轮子但上生产前先确认主从版本都一致。我大多数项目选方案一因为它最直白出了问题也好排查。5.3 集群与 hash tagHash 的 field 分片技巧Redis Cluster 场景下key 会通过 CRC16 算 slot 并分布在多个节点。Hash 的 field 不受 slot 影响整个 hash 一定落在同一个节点这本来是好事。但如果你为了控制大 hash 而拆分出多个分桶 key比如上面的user:1000:events:202401这些 key 可能被分散到不同节点客户端每次聚合访问就要跨节点性能和一致性都会打折。解决办法是用 hash tagkey 里包含{}时Redis 只对花括号内的内容做 CRC16。我习惯把所有属于同一实体的分桶 key 写成{user:1000}:events:202401 {user:1000}:events:202402这样花括号里的user:1000决定 slot无论拆出多少个分桶它们一定落在同一节点上。代价是user:1000 相同的 key 会集中在同一个节点的某些 slot 上节点间数据分布可能不均匀。所以分桶数量别搞太夸张够用就好否则热点和大 key 会以另一种形式找上门。写在最后的实战体会从底层结构一路看下来你会发现 Redis 省内存的第一个字诀就是少建 key第二个字诀是让数据连续。Hash 的 listpack 编码同时做到了这两点所以它在对象型数据场景下对 String 的碾压一点都不奇怪。但这不代表 Hash 可以无脑用字段长度、字段数量、TTL 需求、集群分片这些边界决定了它是蜜糖还是砒霜。我个人的做法是每次建数据模型之前先问自己三个问题——这个逻辑实体真的会有很多属性吗属性单个最大长度会超过阈值吗有没有字段需要单独过期三个问题都清晰了用 Hash 才算是真正的优化而不是换个姿势埋雷。