首页 / 资讯中心 / 文章详情

Redis Hash类型详解:编码机制、命令陷阱与内存优化实践

Redis Hash类型详解:编码机制、命令陷阱与内存优化实践 ★ FEATURED ARTICLE
先说一个我经手的真实问题。某个系统用Redis的String类型存用户资料每次改昵称都是GET一大串JSON改完SET回去。用户量一上去带宽和延迟都开始告急更糟的是两个人同时改同一个用户的不同字段后写覆盖前写。后来我把这几个大JSON搬进Redis的hash类型每个字段单独存取问题立刻缓解。这篇内容就把hash类型掰开揉碎讲清楚包括它背后的编码机制、核心命令的返回值陷阱、内存优化细节以及我在生产环境里踩过的一系列坑。如果你是刚接触Redis或者是已经用了很久但主要把Redis当String字典用的人这篇文章会帮你把hash用得更明白。1. 先理解hash到底是什么它和String差别在哪1.1 从使用习惯上理解它就是你内存里的小MapRedis的hash类型从使用角度看就是一个field-value映射表。我经常跟别人说你可以把它理解成“带key的小型对象”。比如用户1001的姓名、年龄、城市这些属性在hash里就是三个field。传统做法是把整个对象序列化成JSON塞进一个String键现在你可以把每个属性单独放在hash的一个field里。用String存对象读出来的是完整序列化字符串改任意一个字段都要整体覆盖。用hash存对象你可以只读取一个字段也可以只修改一个字段。这种粒度上的差异在“读多改少”或者“改多读少”的场景里很重要。具体点说String模式下更新一个属性等于做一次读、一次反序列化、改内存对象、再序列化、再写回。hash模式下直接HSET key field valueRedis内部只改动目标field其他field完全不受影响。还有一个优势容易被忽略hash天然可以聚合多个字段的原子更新比如HSET key field1 v1 field2 v2这条指令是一次性的中途不会有其他命令插入。而String做跨字段事务更新必须借助Lua脚本或者WATCH/MULTI实现成本更高。1.2 底层不是一块铁板listpack与hashtablehash在Redis内部不是只有一种存储结构而是分成小对象的紧凑编码和大对象的哈希表编码。在Redis 7.0及以前小hash用的是ziplist中文常翻译成压缩列表。7.0之后逐渐用listpack替代ziplist7.2版本的配置项就已经完全改成hash-max-listpack-entries和hash-max-listpack-value。新版本创建小hash默认走listpack老版本你听到的hash-max-ziplist-entries、hash-max-ziplist-value就对应这两个配置。默认规则可以这样理解当hash的field数量小于等于128个并且每个field名和value的长度都不超过64字节时Redis把它放进一个连续内存块通过紧凑排列节省内存。一旦field数量超过128或者某个field的key/value长度超过64字节就会转换为hashtable。listpack和hashtable的区别可以类比成“手工整理的行李袋”和“带隔层的收纳柜”。行李袋把所有东西按顺序塞在一起查找时需要从头挨个比对所以field一多就慢收纳柜为每个条目分配独立格子查找时直接定位快但每个格子都有额外占位损耗。hash会根据膨胀程度自动切换。这里有个重要的经验编码转换是单向的。当hash从listpack变成hashtable之后即使你把字段删到只剩两个、value也都变成短字符串它也不会自动退回listpack。Redis这么做是为了避免频繁转换带来的性能抖动但副作用是如果你曾经把hash撑大过后续内存不会自动回收。所以生产上尽量避免在同一个key上“先大量写入再精简”的操作。1.3 为什么大多数场景下hash比String存JSON更合适我梳理了一张对比表非常适合你在技术评审里直接使用对比维度String存JSONhash存储对象修改单个字段需整体读改写存在并发覆盖风险只操作目标field天然并发安全读取单个字段需读取整个JSON并解析直接HGET取一个field内存占用大量小字段每个key都有完整元数据较费多个field共享key元数据较省序列化成本每次读写都有序列化/反序列化无整体序列化字段按字符串存取嵌套结构支持天然支持复杂嵌套不推荐需要自己拍平或再拆key批量更新需要脚本或事务HSET一次传多个field当然hash不是万能的。如果你的业务本身就是“整体读取一个完整对象”而且数据有嵌套层级比如订单包含商品列表、收货地址、折扣信息那String放JSON反而更自然。hash适合字段固定、读改频繁、彼此独立的扁平结构。判断标准我习惯看两点第一是否有按字段单独更新的需求第二对象字段数量是否处于几十的量级。两个条件都满足就优先考虑hash。2. 命令拆解每个命令的用途和返回值陷阱2.1 写入与读取HSET、HGET、HMSET、HMGET先看最常用的两组命令。写入用HSET读取用HGET。HSET user:1001 name Alice age 30 city Hangzhou这条命令一次设置三个field。注意返回值返回的是“本次新增了多少个field”如果两个命令里某个field已经存在这次只是覆盖覆盖的字段不会计入返回。我第一次踩这个坑是在做缓存初始化判断。当时习惯用HSET的返回值1还是0来判断key是否存在结果发现当某个field已存在、仅覆盖时返回0但操作其实是成功的。后来改用EXISTS key判断整个hash是否存在或者用HEXISTS key field判断某个字段是否存在就不会再误会。HGET key field返回指定字段的字符串值。如果field不存在返回nil。客户端里一般表现为None或者null需要区分“该字段不存在”和“该字段值为空字符串”。在解析业务数据时不要把nil当作空字符串处理。很多老教程会写HMSET key field1 value1 field2 value2。这个命令在Redis 4.0以后就显得多余7.0开始标记为不推荐新代码里没必要再用。因为HSET本身就支持多field写入HMSET能做的一切HSET都能做。读取端对应的HMGET key field1 field2仍然有用它可以一次取出多个字段减少网络往返。HMGET user:1001 name age city 1) Alice 2) 30 3) Hangzhou这个命令我常用在服务端批量给前端返回用户基础资料几个字段一次取完比循环HGET省了大量请求。2.2 删除与存在判断HDEL、HEXISTS、HLEN删除字段用HDEL key field...返回的是实际删除的字段个数。如果删了不存在的field它的返回值不会把不存在的东西算进去。如果一次传入多个field其中只有一部分存在返回值只统计那些存在并删除掉的field。HEXISTS key field返回1或0适合用来做幂等判断。比如记录用户是否已经领取过某个权益可以用HSETNX key coupon_001 pending这样的原子命令只有该field不存在时才会写入避免并发导致重复操作。HLEN key返回当前hash的field数量。小hash直接用没问题大hash下HLEN也是O(1)因为它内部会维护字段数量。但要注意HKEYS和HVALS不是O(1)它们会遍历整个hash大key下可能阻塞Redis单线程后面会详细讲。2.3 数字自增HINCRBY、HINCRBYFLOAT当一个field要频繁做加减计数时不要在客户端“读出来、加1、写回去”那样并发下一定会丢数字。Redis提供原子自增命令HINCRBY user:1001 points 10 HINCRBY user:1001 points -5HINCRBY只能用于64位有符号整数范围内的加减。如果统计的数据是浮点数比如金额、分数用HINCRBYFLOAT。这里有一个容易让新客户端栽跟头的点Redis命令返回的永远是字符串HINCRBY在终端里显示(integer) 10只是终端格式化协议层返回的是10。在Java、Python这类强类型客户端里不同库的处理方式不一样。有的客户端会帮你转成整数有的不会。不要假设返回值一定是数字类型安全做法是在业务层规定好类型转换。HINCRBYFLOAT的精度问题也要留意。它内部使用的是长双精度浮点计算但Redis把结果转成字符串时只保留17位有效数字某些场景下会有极小的精度误差。涉及金额计算我建议把金额放大成整数再操作比如用分而不是元当单位。2.4 全量获取与游标遍历HGETALL、HSCAN如果你要一次取出hash的全部字段HGETALL最简单。问题是它简单但不安全。当一个hash有成千上万个字段时HGETALL会一次性把所有field-value返回内存占用和网络传输都可能非常大Redis在生成响应时还会阻塞其他命令。我在生产里看到一个hash存了30万个field业务启动时用HGETALL预加载结果Redis监控里出现明显的命令执行延迟其他读写都跟着卡顿。后来改成HSCAN分批遍历。HSCAN user:1001 0 COUNT 500这个命令的第一个参数是游标返回结果里带一个新的游标当返回的游标为“0”时表示遍历结束。COUNT只是给Redis一个提示实际返回数量可能多于或少于500。HSCAN跟SCAN一样遍历过程中数据发生变化时不保证每个字段都被精确返回所以它适合做“大致扫一遍”不适合做对一致性要求极高的全量快照。还有一个经验如果业务真的需要反复读整个hash可以考虑拆分key把大hash拆成多个小hash每个小hash字段数控制在1000以内。这样无论HGETALL还是HSCAN压力都可控。3. 实现原理与内存优化为什么hash能省钱3.1 编码切换的阈值和代价继续深入底层。hash使用listpack时所有field和value被一个接一个放在连续区域里。默认情况下字段超过128个或单个字段长度超过64字节就转成hashtable。这两个数值可以通过配置调整config set hash-max-listpack-entries 256 config set hash-max-listpack-value 128调高阈值能让更多hash继续使用紧凑编码节省内存但会增加读写耗时。原因是listpack查找时只能线性扫描字段越多扫描越慢。调低阈值则相反更快但更费内存。生产环境里我不建议随便调这两个参数除非你做了压测。有个折中的经验如果你明确知道某个hash的field数量会长期在几百个可以直接使用hashtable与其让它频繁在编码边缘抖动不如一开始就让它稳定地走hashtable如果数量很小且长度很短保持默认listpack就好。关于编码转换时的“代价”从listpack转换成hashtable时Redis需要把原有数据重新拷贝、分配内存并构造哈希索引。这个过程可能不是瞬间完成的因为数据量越大转换耗时越长。当这个操作发生在一个大key上时Redis单线程会被卡住。所以在生产环境里不要创建那种“刚开始很小某天突然变成几十万字段”的hash它的第一次膨胀会对业务造成可见的延迟。3.2 为什么小hash优先用hash而不是String内存上的收益很有意思。String类型在Redis里每个key都是一个独立字典条目包含key、value、指针、过期时间等元数据。当你有100个用户每个用户有3个字段时如果每个字段分别作为一个String keyRedis需要维护300个key的元数据还要承担相应的hashtable开销。而存成一个hash只需要一个key的元数据内部100个字段通过紧凑列表保存整个对象的额外开销会小很多。用实际数据说话。假设一个用户对象有三个字段昵称、年龄、性别。用三个String键一个用户至少会产生三份独立的SDSSH数据结构、三份dictEntry、三份robj。用hash的listpack则是一份整体连续内存。在小数据量下这种优势可能只有几十字节但乘以1000万用户就变成了几百MB甚至GB级别的差别。需要注意这种省内存的结论有一个前提字段数量要小。当hash转换为hashtable后每个field也会成为独立的dictEntry虽然仍然共享同一个key的元数据但每个field都要维护自己的key指针、value指针、哈希索引。这时候跟多个String相比节省的主要是外层key的元数据优势会缩小。所以不能一概而论要记住“小hash省内存大hash未必”。3.3 写入方式对性能的影响一次多字段还是多次单字段很多人写代码时不在意HSET的调用方式其实这里也有性能讲究。# 第一种循环单字段写入 HSET user:1001 name Alice HSET user:1001 age 30 HSET user:1001 city Hangzhou # 第二种一条命令多字段写入 HSET user:1001 name Alice age 30 city Hangzhou第二种只有一次网络往返Redis内部也只需要处理一条命令效率高得多。尤其在批量初始化时可能从几百毫秒降到几毫秒。有人会问一次HSET传入很多字段会不会阻塞Redis只要字段总数在合理范围内比如几十个完全没问题。真正要担心的是那些超长命令比如单条命令携带几十MB数据。字段多但总量小对Redis是友好的。因为listpack的插入本来就是顺序追加一条命令批量插入比多条命令反复触发编码检查要更平坦。客户端代码里Python的redis-py推荐用mapping参数import redis r redis.Redis(decode_responsesTrue) r.hset(user:1001, mapping{ name: Alice, age: 30, city: Hangzhou })注意老版本redis-py的hset只能传单个field和value新版才支持mapping。如果你在维护老项目记得先确认客户端版本避免把新写法带到旧环境里直接报错。3.4 版本演进对hash的利与弊Redis 7.0开始listpack正式成为小hash的默认编码代替了ziplist。listpack比ziplist的设计更简单修复了一些级联更新导致的性能问题。所谓级联更新是指在ziplist中如果某个entry变大导致后面所有entry位移连锁反应可能拖慢写入。listpack每个entry独立记录长度减少了这种连锁更新的影响。Redis 7.4和8.0在hash的底层没有翻天覆地的变化但要注意升级后的行为差异。如果你从6.x升级到7.x原来使用ziplist编码的hash在写操作时会自动转成listpack吗答案是不会立刻自动转换而是当你执行写操作触发重编码时才会按新配置重新组织。有些运维同学升级后看到内存没降下来以为是没生效其实是因为没有触发编码重建。如果你对底层编码有洁癖想主动检查某个hash当前是什么编码可以用OBJECT ENCODING keyOBJECT ENCODING user:1001 listpack这个命令在生产环境可以使用但它也要访问对象内部频繁调用会带来额外开销。建议调试时用一两次就好不要写进定时任务。4. 实战场景设计怎么把hash用在刀刃上4.1 场景一用户资料和对象属性缓存这是hash最常见的应用。用户信息、商品信息、配置项这类“一个key对应多个属性”的数据都适合hash。我的设计经验是field命名要尽量稳定。field就相当于数据库里的列一旦上线很难随意改。一个典型的用户hashHSET user:1001 base:name Alice base:age 30 stat:follows 120 stat:fans 3999这里我用冒号做了前缀分区base表示基础属性stat表示统计属性。好处是后续可以统一HGETALL哪些前缀字段也方便阅读。但这要求客户端在取数时自己拼接前缀代码里会多一层约定。如果属性里包含大字符串比如用户自我介绍写了1万字的文本把它放进同一个hash就很不合适因为它会触发hash从listpack转换到hashtable进而影响其他小字段的存储形态。我倾向于把大字段单独用String存或者给这个hash配置更大的listpack-value阈值。4.2 场景二实时计数器和聚合统计另一个高频场景是聚合统计。电商里商品的点赞数、收藏数、销量数视频的播放数据都可以放到一个hash里。商品ID作为key点赞、收藏、播放作为独立field利用HINCRBY做原子自增。HINCRBY product:8848 likes 1 HINCRBY product:8848 play_count 1这种写法的好处是多个统计指标聚合在一起只需要一次网络连接就能全部取回也减少了Redis中的key数量。和String计数器相比String每个指标都要单独keyhash则用field做维度划分更紧凑。需要注意并发热点问题。如果同一个商品的播放数每秒被高频写入几千次Redis单线程处理HINCRBY本身没问题但如果你在这个hash里还保存了商品详情字段业务读取整个hash时热点写入和读取会互相影响。我的建议是把高频更新的统计字段和低频修改的详情字段拆成两个hash一个负责细节一个负责计数器隔离不同热度的数据。4.3 场景三会话/临时缓存一些网站会用hash保存用户会话信息比如登录态、过期标记、最近访问时间。因为同一个会话ID下也有多个属性而且更新频繁hash很合适。会话场景里必须考虑过期机制。Redis对整个key可以设置TTL但不能对单个field设置过期时间。如果所有会话字段的过期周期一致直接给hash key设置TTL就行EXPIRE session:8888 1800每次用户活跃时如果重新设置了TTL这个hash就能一直保持。如果需要部分field失效比如验证码5分钟过期、登录态30分钟过期我的做法是把field设计成带时间戳的比如code:20250101再加一个定时任务扫描清理。也可以用更简单的方式把不同过期时长的数据拆到不同的hash或者不同的key各设各的TTL。4.4 什么时候不适合用hashhash虽好但不是所有场景都该用它。我总结了几种反面情况。第一字段之间关系是集合或列表。用户的好友列表、一个商品的标签集合这些天然是数组型用hash存value会变成“tag1: 1、tag2: 1”这种变形查询和遍历都很别扭。Redis的Set和List本身就是干这个的不要硬塞进hash。第二数据是深层嵌套结构。比如订单下面有商品列表每个商品又有SKU数组强行拍平成hash会让字段名变成地狱级别order:123:item:1:sku:2:price。这种数据结构直接用String放JSON或者用Key-Value组合设计多个key比hash清晰得多。第三只关心全局对象的整体序列化不关心内部字段。如果业务逻辑永远是把整个对象读出来、整体写回去String的GET/SET反而更高效因为hash的每个字段存取看上去方便但需要维护很多field代码复杂度增加。一个决策方法先问自己两个问题“我是否需要频繁操作对象的某个字段”以及“对象字段是否扁平固定”两个都答“是”选hash如果都不确定先用String等出现按字段更新需求再迁移也不迟。4.5 集群环境下的hash使用提醒Redis Cluster模式下key会通过CRC16算法映射到16384个slot。hash作为一个整体只会算一次key的slot所有field都保存在同一个节点上。这对hash是好消息因为不用担心跨节点取字段。但这里存在两个风险点。第一如果一个hash过大单一节点会成为热点其他节点空闲整个集群的负载不均衡。比如把所有热门用户都放进一个用户hash这个key就会集中在一个节点。第二大hash的迁移会非常痛苦。Cluster做槽迁移时如果某个槽中包含几十MB的大key迁移时间会被单key卡住。我在实际运维里见过一个300MB的hash导致槽迁移持续了十几分钟期间相关命令全部阻塞。经验是给hash设计一个容量上限。单hash最好控制在几千个field以内单key内存占用控制在几十MB以下。如果业务体量明确会超过这个规模要么用分片键比如把用户hash按地域或ID段拆成多个key要么干脆换方案。5. 高频问题与排查技巧实录5.1 大field的“小hash”为什么会卡顿有个用户反馈说自己的hash只有20个字段但某个字段存的是Base64图片长度达到几百KB。这个hash在读取时偶尔会让Redis延迟跳变。问题就在于listpack的线性扫描。本来20个字段扫描很快但由于某个value非常大Redis编码判断从listpack转成了hashtable。hashtable在写入慢其实不慢真正慢的是大value的内存分配和网络传输。更重要的是当这个巨大的value在listpack里时每次写入或删除其他field都可能因为重新分配内存而复制这个大value成本被放大。所以我的规则是任何单个field的value都控制在10KB以内。超过这个量级单独放String。否则你会在某一天发现一个看似很小的hash因为内部藏了一个大字段拖慢了一连串操作。5.2 field单独过期做不到怎么办前面的会话场景提过。这里单独说一种我用过的替代方案字段值里存过期时间戳从应用层判断。比如要做一个“每个验证码只能使用一次5分钟内有效”的需求。我用HSET user:1001 captcha 8848 HSET user:1001 captcha_ts 1735689600校验时先取captcha_ts如果当前时间减去它是5分钟内并且验证码匹配就允许通过然后立即用HDEL删除这两个field。这个方案风险在于如果你忘了删captcha_ts下一次校验会看到旧时间。所以删除时要一起删。另一种常用方案是给整个hash设置TTL比如每天一个key利用日期做key后缀。user:1001:20250115这样只要TTL设为48小时旧的key自然消失新数据自动落入新key。这个方案在处理“滚动窗口”类统计时非常实用。5.3 HGETALL和HKEYS在大key上的阻塞问题大key遇到全量命令是Redis生产环境常见事故。很多同学在测试环境用一个几千字段的hash执行HGETALL没感觉换到生产环境一个hash几十万字段HGETALL会导致Redis单线程忙活几百毫秒甚至几秒期间所有读写排队。判断大key用Redis自带的排查工具redis-cli --bigkeys它会扫描整个实例以字节数和元素数量为标准找出最大的key并报告每个类型最大的key信息。这个命令本身也会遍历所有key建议在低峰期执行。如果需要更精准地查看单个hash的编码和内存MEMORY USAGE user:1001 OBJECT ENCODING user:1001MEMORY USAGE返回的是这个key占用的字节数。命令会实际遍历hash大key上也要谨慎使用。我的建议是先用HLEN看字段数如果HLEN返回的数字已经很大就不要再去跑HGETALL了直接用HSCAN分批消费数据或者先在业务层面拆分。5.4 并发写同一个hash别忽略热点和锁hash的单个字段更新是原子的HSET一个field写入时不会被其他命令插队。但“原子”不等于“事务”。如果你要对多个字段做联动更新比如同时改用户的余额和修改时间两个HSET命令之间可能插入其他命令导致中间状态被读到。跨字段一致性要求高的场景可以用WATCH key配合事务WATCH user:1001 HGET user:1001 balance ... 调用端计算新余额 ... MULTI HSET user:1001 balance new_balance update_time now EXEC如果期间key被其他客户端修改EXEC会返回nil然后重试。这种模式比裸HSET安全但要注意在热点上过度使用WATCH会造成大量重试反而影响性能。更好的做法是把复杂的多字段更新放进Lua脚本Redis保证脚本执行期间不会被其他命令插入。脚本里用redis.call(hset, ...)既原子又能表达业务逻辑。5.5 HSET返回0就是失败没那么简单这个问题值得单独拎出来说。因为新手很容易被误导。HSET product:123 stock 50 (integer) 1 # 第一次新增返回1 HSET product:123 stock 50 (integer) 0 # 第二次覆盖返回0两次都成功了第二次返回0只是提示“该字段已存在本次是更新”。所以不要根据返回值判断命令是否执行成功异常才是失败。如果你真的想知道某个字段有没有存在过用HEXISTS。5.6 从String转hash时的迁移坑业务迁移时很多人写脚本把String里的JSON拆成hash。这个动作在线上做要小心。我见过一个系统直接在Redis上用老Controller循环执行HSET迁移期间线上读写新旧两边导致数据不一致。稳妥的三步走第一步新hash写入全新字段名或带上版本前缀比如user:1001:v2第二步双写一段时间让新hash积累最新数据第三步读逻辑切换到新hash再异步删除旧key。如果把迁移放在应用启动时一次性做要预估Redis的负载尽量使用Pipeline分批提交避免大循环逐条命令拖垮主线程。6. 我的一点使用心得最后说几句个人经验不算是教程但在实际项目里帮了我不少。hash适合做“对象”不适合做“表格”。当你把它当对象存时设计好field名控制好field数量它非常趁手。当你试图把所有业务数据都塞进一个hash时大key、热key、迁移难这些麻烦会接踵而来。我在设计新项目时会在文档里直接写清楚哪个key是hash单key最高字段数多少value最大长度多少预计总量多大。这些约束看起来是条条框框但正是它们让hash在长期运行中保持稳定。毕竟Redis的hash本身不会限制你乱来能限制乱来的是使用者的规范和监控。如果你现在正用String存一堆JSON不妨挑一个高频改字段的业务先试点转hash。你会发现代码变得更简单网络开销变小连带着“改了一个字段要重写整个对象”这种逻辑也消失了。这个改动的收益往往比优化SQL还直接。
阅读完成 · 觉得有帮助?
咨询建站