ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Redis Hash内部编码详解:listpack与hashtable切换实践

Redis Hash内部编码详解:listpack与hashtable切换实践 1. 先从 value 类型和内部编码说起1.1 一次对象缓存设计让我重新审视 Hash接手过一个用户中心服务原来的做法很简单把用户资料整个序列化成 JSON 字符串丢进 Rediskey 是 user:5678value 是{name:张三,age:30,city:北京}这样一大串。刚上线毫无压力等到业务量起来问题就全出来了。用户改一次手机号我们就得执行一次完整的“GET、反序列化、改字段、序列化、SET”一个字段的变动要搬运整个对象如果用户连续修改资料并发场景下还容易互相覆盖丢更新。后来我把这部分数据全部迁到了 Redis 的 hash 类型。每个用户用一个 key属性就是 hash 里的 field属性值就是 field 对应的 value。改 age 就执行HINCRBY user:5678 age 1改手机号就执行HSET user:5678 phone 138xxxx其他字段完全不受影响。上线之后效果非常明显接口耗时下来了内存占用也比原来存 JSON 字符串低了不少。这个案例让我意识到一件事Redis 的 value 类型选择不能只看“语义上像不像”还得理解每个类型底层是怎么存储的。hash 之所以适合对象型数据除了天然的“字段-值”结构更关键的是它内部有编码方式上的设计——小数据量用紧凑结构省内存大数据量切换成标准哈希表保性能。这篇就把 hash 的 value 类型和编码方式一次讲透结合我实际踩过的坑给你一套能直接参考的选型思路。1.2 数据类型的逻辑结构与内存中的编码方式先说清楚两个概念数据类型和编码方式不是一回事。数据类型是你用命令行、用客户端看到的逻辑结构string 就是字符串hash 就是 field-value 字典编码方式是 Redis 内存里真正存放这些数据时使用的底层数据结构。同一个 hash执行 OBJECT ENCODING 命令可能返回 listpack也可能返回 hashtable两者在内存布局、读写性能上差别很大。Redis 设计这种“一类型多编码”的思路本质是在内存和 CPU 之间做取舍。小对象如果用标准哈希表每个字段都要额外承担指针、对象头、桶位空置这些开销一个只有几十个字段的小 hash 可能白白多耗成百上千字节而紧凑结构虽然某些操作是 O(n)但数据量小的时候 n 可以忽略不计缓存命中率还高。等数据量涨上去紧凑结构写入代价变大Redis 就自动换成哈希表。这个过程对客户端完全透明但理解它的人在做数据建模、排查内存问题时会明显更有底。2. hash 的两种内部编码方式2.1 listpack 和 ziplist小 hash 的省内存秘密Redis 7.0 之前小 hash 用的是 ziplist 编码。ziplist 是一整块连续内存所有元素挨个排列每个节点除了真实数据还要记录前一个节点的长度prevlen和一段编码信息。这样做的好处是可以通过 prevlen 从尾部向前遍历也方便定位前一个元素。对于一个小的 hash每个 field 和 value 会变成 ziplist 里的两个相邻节点整体看就是一个压缩的线性列表。ziplist 最大的优点是内存紧凑没有多余指针没有对象头没有散列桶空位数据紧挨着放能省就省。缺点也藏在设计里往中间插入或者删除节点时如果前一个节点的长度发生变化后面一串节点的 prevlen 都要跟着更新也就是所谓的“级联更新”。字段少时无所谓字段一旦多起来一次插入可能引发连锁内存移动写入性能明显劣化。Redis 7.0 开始官方引入 listpack 来逐步取代 ziplist。listpack 的每个节点只保存自己的长度不再依赖前一个节点的长度从设计上消除了级联更新的风险。也就是说新版本里你创建的小 hash执行 OBJECT ENCODING 返回的是 listpack 而不是 ziplist。你在 API 层面完全感知不到这个变化命令照用但底层的健壮性更好了。升级到 Redis 7 之后老数据依旧能读只是新写入的小对象会优先以 listpack 编码。2.2 hashtable字段变大之后的正式形态当 hash 的字段数量超过阈值或者某个字段名/字段值的长度超过阈值Redis 就会把内部结构切换为 hashtable。hashtable 就是经典散列表用链表法解决冲突每个字段对应 dictEntry 里的一个键值对指针指向实际的键和值对象。hashtable 的优势是读写复杂度 O(1)不管字段是 200 个还是 2 万个HGET 都只需要一次哈希计算、一次桶查找。缺点是内存开销大得多。可以粗略估算下64 位系统下一个 dictEntry 大约 24 字节指向的 key 和 value 还有 redisObject 之类的基础开销哪怕字段名和值都只有几个字节一个字段也可能吞掉五六十甚至七八十个字节。字段数量少的时候无所谓字段一多哈希表反而是更合理的选择。hashtable 还有一层隐藏行为扩容和缩容时会触发 rehash。Redis 采用渐进式 rehash把一次大迁移拆成多次在服务请求的间隙逐步完成。不过如果某个时刻写入特别密集rehash 还是可能带来 CPU 抖动。这个问题在线上出现过后面排查部分会专门说。2.3 编码切换的触发条件与配置参数旧版本里控制 hash 从 ziplist 切换为 hashtable 的是这两个参数hash-max-ziplist-entries 128 hash-max-ziplist-value 64Redis 7.0 之后参数改为hash-max-listpack-entries 128 hash-max-listpack-value 64含义保持一致第一个是 hash 最多能容纳的字段数量第二个是单个字段名或字段值允许的最大字节数。只要字段数量超过 entries 阈值或者任意字段名/字段值的长度超过 value 阈值这个 hash 就会被升格成 hashtable。两个条件满足任意一个就会触发不是非等同时满足。这里必须提醒一个容易踩的坑阈值单位是字节不是字符数。中文字符在 UTF-8 编码下通常占 3 个字节一个字段值只要超过二十来个汉字就可能直接把编码从紧凑结构顶到 hashtable。测试环境用拼音、英文模拟也许没问题线上真实中文数据一上来很多“莫名其妙变成 hashtable”的 key 就是栽在这里。3. 实操验证编码切换过程3.1 OBJECT ENCODING 一眼看清内部结构想知道某个 hash 当前的编码方式不需要猜一条命令就能看到127.0.0.1:6379 HSET book:1001 title Redis实战 price 79 (integer) 2 127.0.0.1:6379 OBJECT ENCODING book:1001 listpackRedis 7.x 环境返回 listpackRedis 6.x 及更早版本返回 ziplist含义一样这个 hash 还在紧凑编码状态。当返回结果变成 hashtable说明已经升格为标准哈希表。OBJECT ENCODING 同样适用于 string、list、set、zset是排查内存问题时首先要掌握的命令。线上想批量检查大量 key 的编码应该用 SCAN 循环配合 OBJECT ENCODING而不是一把 KEYS 全部拉出来。SCAN 每次取一批对实例的影响小得多不会出现一次性全量扫描把 Redis 卡住的问题。3.2 字段数量和字段长度哪一个先触顶我做过一个简单测试用来验证切换边界。先创建一个只有几个短字段的 hash然后不断加字段127.0.0.1:6379 HSET user:1001 f1 v1 f2 v2 (integer) 2 127.0.0.1:6379 OBJECT ENCODING user:1001 listpack只要字段数量不超过 hash-max-listpack-entries每个字段名和值都短于 hash-max-listpack-value编码就一直是紧凑型。继续灌字段灌到超过 128 个再查127.0.0.1:6379 OBJECT ENCODING user:1001 hashtable另一种情况也试过字段很少但某一个值特别长。比如一个只有 3 个字段的 hash塞一个 200 字节的长文本进去结果照样切到 hashtable。这说明控制切换的两条线是“或”的关系任何一条越线都会触发升格。这个规律在做数据建模的时候非常有用。如果你希望对象长期留在紧凑编码里就得同时控制字段数量和字段长度。有些团队习惯把特别长的文本从 hash 里拆出去放到独立 string key 或者专门的对象存储hash 里只留短字段就是为了保住内存优势。3.3 实际中容易忽略的一点切换是单向的一旦编码从紧凑结构切换成 hashtable就算你把字段删到只剩几个Redis 也不会自动“缩回去”变回 listpack。这是 Redis 的既定行为它不想在每次写入后都去判断“现在是不是又变小了”频繁升降编码会带来额外复杂度和性能开销。所以你会遇到这种局面一个 hash 曾经有 500 个字段后来清理到只剩 10 个短字段OBJECT ENCODING 依然返回 hashtable。如果对内存占用比较敏感想让它恢复紧凑编码只能删掉整个 key 或者改名后重新灌一遍数据让 Redis 按当前配置重新编码。这个细节在官方文档里描述得很隐晦不实际踩一次很容易忽略。4. hash 的实际应用场景4.1 对象数据局部更新比整串 JSON 省心得多回到开头的用户资料场景。对象型数据的特点是“整体是一个逻辑单位但局部字段会频繁变化”。用 string 存 JSON每次改一个字段都要把整串取出来、反序列化、改完再写回去网络开销大并发更新还容易丢数据。换成 hash 以后每个字段独立HSET 改一个字段不会影响其他字段HINCRBY 可以直接给积分、余额这种数字字段加值HDEL 可以单独删除某个属性。商品信息、文章详情、配置中心、设备状态这类数据都可以套这个模式。hash 天然就是“对象/字典”的映射字段名对应属性字段值对应属性值。读取的时候按需用 HMGET 只取某几个字段不一定非要 HGETALL 全量拉这样能减少不少网络传输和序列化开销。4.2 购物车、会话和分组计数器hash 的另一个高频场景是购物车。key 用用户 ID字段是商品 SKU值是数量。用户加购就HSET cart:10086 sku_12345 1多买一件就HINCRBY cart:10086 sku_12345 1结算时 HGETALL 扫一遍下单成功后 HDEL 清除。这个模型和数据库里“一订单多商品”的关系结构比省掉了大量中间操作天然就是一个聚合视图。Web 会话存储也常选 hash。一个 session key字段放 uid、登录时间、token、最后活跃时间过期时间对整个 hash 设置 EXPIRE到期整体失效。还有一些计数器聚合需求比如按渠道统计 PV渠道 ID 作为字段PV 数值作为字段值HINCRBY pv:report channelA 1一路累加不需要为每个渠道单独建 key。需要记住一个硬限制hash 不支持字段级过期。你只能对整个 key 设置 EXPIRE没法让某个字段 X 小时后自动消失。想做字段级 TTL 的话要么把过期时间戳存进字段值由业务逻辑判断要么把不同过期粒度的数据拆成多个 key按场景取舍。4.3 序列化和分布式锁与 hash 的关系热词里出现了“redis序列化”“redis分布式锁”顺带把这两个和 hash 的关联讲清楚。序列化影响编码方式是因为序列化的产物最终要作为 value 落进内存。用 Spring Data Redis 时如果配置了 JDK 序列化每个对象会变成一大串带类型信息的字节字段值很容易突破 64 字节阈值紧凑编码就很难保住。换成 JSON 序列化器或者让业务字段尽量以短字符串和整数为主内存占用会明显下降。这背后不是玄学就是字节长度实实在在地压着编码切换的线。分布式锁常规实现是用 string 配合SET key value NX PX但以 Redisson 为代表的可重入锁在 Redis 里用的正是 hashkey 是锁名字段是持有锁的线程 ID值是重入次数。加锁就是 HSET解锁会先判断字段对应关系再 HDEL依靠一个小 hash 实现可重入控制。可见 hash 不只是用来存业务对象很多中间件内部的巧妙设计也依赖它。理解它的编码方式和命令语义之后遇到类似需求你也能自己组装出合理的数据结构。5. 常见问题与排查经验5.1 字段不能单独设置过期时间这是 hash 最典型的限制之一。业务上如果希望每个字段分别过期用 hash 会很难受。比较常见的替代方案有两种一种是把每个逻辑实体拆成独立 key用 string 存完整快照在 key 上设 EXPIRE适合过期粒度比较粗的场景另一种是在 hash 字段值里同时放“数据本身 过期时间戳”读取时先比较时间戳再决定是否返回有效数据适合过期粒度细但能接受读逻辑复杂一点的场景。我实际遇到过一个案例埋点数据要求每个属性分别保留 30 天最初设计用 hash 存储很快发现字段过期能力缺失最后改成多 key 方案。遇到这种情况不要和 Redis 的数据模型硬顶调整建模方式往往更省事。5.2 HGETALL 与大 key 问题hash 最大的风险是变成 big key。一个几万字段的大哈希执行 HGETALL 会一次性返回全部数据产生超大响应包轻则拖慢当前请求重则阻塞 Redis 单线程影响同一实例上的所有其他操作。排查线上问题时我一般先用 HLEN 看字段数量再用 MEMORY USAGE 看实际内存占用确认是不是大 key 之后改成 HSCAN 分批遍历字段而不是一次全取。如果只是需要某几个字段HMGET 也比 HGETALL 稳得多。很多同学习惯了 HGETALL 把整个哈希取回业务层再自己挑数据小 key 没感觉大 key 就是灾难。按需读取是个低成本高收益的习惯对象型数据更应该坚持“取用所需”而不是“全量搬走”。5.3 编码切换带来的性能波动hashtable 在扩容、缩容时会触发渐进式 rehash字段数很大的 hash 在扩容期间会有一段时间的 CPU 消耗。如果线上突然出现某个 Redis 实例 CPU 抖动排除了慢查询和热点 key 之后可以检查一下是不是有大量 hash 集中在某个时间点从紧凑编码切换到了 hashtable或者某个超大 hash 正好在扩容。另一个与之相关的坑是配置参数被调得过大。有人为了让对象一直保持紧凑编码把 hash-max-listpack-entries 改成几千。表面看内存是省了可一旦真的写入那么多字段紧凑编码下的写操作会引发大量内存移动写入延迟反而远高于 hashtable。紧凑编码的定位是“小对象省内存”不是“强行把大对象塞进小结构”。迁移版本的时候也要注意Redis 7.0 之前的 ziplist 参数已经更名为 listpack 参数直接把老配置搬到新版本是无效的。6. 最后补充几个我长期在用的经验有几个习惯是踩过几次坑之后才养成的写在这里供你参考。第一不管什么业务场景新建 hash 之前先估算一下字段数量和字段长度会不会逼近 128 和 64 这条线。如果会就提前评估 hashtable 编码下的内存占用和读写性能别等上线后突然切换触发不必要的性能波动。第二排查内存问题时把 OBJECT ENCODING 当成常规手段配合 HLEN、MEMORY USAGE、HSCAN 一起用。先用 OBJECT ENCODING 判断哪些 key 还是紧凑编码、哪些已经变成 hashtable再用 HLEN 和 MEMORY USAGE 确认体量用 HSCAN 分批遍历大哈希很快就能定位问题。第三hash 的编码阈值不是越大越好也不是越小越好。字段少但读取频繁的场景紧凑编码的缓存命中率优势明显字段多但写入频繁的场景哈希表反而是正确选择。配置前先想清楚业务形态比盲目调参可靠得多。从理解 value 类型到理解编码方式看起来只是多了一层底层知识实际上会影响你的内存预算、读写延迟和线上排障效率。hash 作为 Redis 里最像“对象”的类型用好了能省很多事用错了也会带来不少麻烦。希望这篇拆解能让你在做数据建模时多想一层少走一些弯路。
返回列表