☰
Redis Hash类型详解:编码机制、命令陷阱与内存优化实践
2026/10/13 8:22:46 网站建设 项目流程

先说一个我经手的真实问题。某个系统用Redis的String类型存用户资料,每次改昵称都是GET一大串JSON,改完SET回去。用户量一上去,带宽和延迟都开始告急,更糟的是两个人同时改同一个用户的不同字段,后写覆盖前写。后来我把这几个大JSON搬进Redis的hash类型,每个字段单独存取,问题立刻缓解。这篇内容就把hash类型掰开揉碎讲清楚,包括它背后的编码机制、核心命令的返回值陷阱、内存优化细节,以及我在生产环境里踩过的一系列坑。如果你是刚接触Redis,或者是已经用了很久但主要把Redis当String字典用的人,这篇文章会帮你把hash用得更明白。

1. 先理解hash到底是什么:它和String差别在哪

1.1 从使用习惯上理解:它就是你内存里的小Map

Redis的hash类型,从使用角度看,就是一个field-value映射表。我经常跟别人说,你可以把它理解成“带key的小型对象”。比如用户1001的姓名、年龄、城市这些属性,在hash里就是三个field。传统做法是把整个对象序列化成JSON塞进一个String键,现在你可以把每个属性单独放在hash的一个field里。

用String存对象,读出来的是完整序列化字符串,改任意一个字段都要整体覆盖。用hash存对象,你可以只读取一个字段,也可以只修改一个字段。这种粒度上的差异,在“读多改少”或者“改多读少”的场景里很重要。

具体点说,String模式下更新一个属性等于做一次读、一次反序列化、改内存对象、再序列化、再写回。hash模式下直接HSET key field value,Redis内部只改动目标field,其他field完全不受影响。

还有一个优势容易被忽略:hash天然可以聚合多个字段的原子更新,比如HSET key field1 v1 field2 v2这条指令是一次性的,中途不会有其他命令插入。而String做跨字段事务更新,必须借助Lua脚本或者WATCH/MULTI,实现成本更高。

1.2 底层不是一块铁板:listpack与hashtable

hash在Redis内部不是只有一种存储结构,而是分成小对象的紧凑编码和大对象的哈希表编码。

在Redis 7.0及以前,小hash用的是ziplist,中文常翻译成压缩列表。7.0之后逐渐用listpack替代ziplist,7.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 -5

HINCRBY只能用于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 key,Redis需要维护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_responses=True) 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 key:

OBJECT ENCODING user:1001 "listpack"

这个命令在生产环境可以使用,但它也要访问对象内部,频繁调用会带来额外开销。建议调试时用一两次就好,不要写进定时任务。

4. 实战场景设计:怎么把hash用在刀刃上

4.1 场景一:用户资料和对象属性缓存

这是hash最常见的应用。用户信息、商品信息、配置项,这类“一个key对应多个属性”的数据,都适合hash。

我的设计经验是,field命名要尽量稳定。field就相当于数据库里的列,一旦上线很难随意改。一个典型的用户hash:

HSET 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每个指标都要单独key,hash则用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 什么时候不适合用hash

hash虽好,但不是所有场景都该用它。我总结了几种反面情况。

第一,字段之间关系是集合或列表。用户的好友列表、一个商品的标签集合,这些天然是数组型,用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:1001

MEMORY 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还直接。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询