Redis Hash类型实战:从对象存储到分布式锁的全面指南
发布时间:2026/10/12 0:37:34 锦皓数字建站

开头先聊个实际场景。你有一段用户信息要缓存最省事的做法是什么很多人会直接SET user:1001 {name:张三,age:25}把整个 JSON 字符串丢进去。等想改其中一个字段的时候整条数据取出来、反序列化、改完再序列化塞回去繁琐不说并发写的时候还容易互相覆盖。这就是我为什么一直觉得Redis 6 里的 hash 类型是被用得最不到位的一个——它天生就是干这个的很多人却拿 string 硬扛。这篇我不打算按官方文档给你抄一遍命令而是从实际使用出发把 hash 的类型边界、底层编码、高频命令、业务场景和踩坑经验揉在一起讲适合刚开始用 Redis 的人也适合用了两三年但主要停留在 get/set 水平的朋友。1. 为什么说 hash 是对象存储的第一选择1.1 与 string 相比hash 省下的不只是代码先看一个最简单的对比。假设你要缓存商品信息包含标题、价格、库存三个字段。用 string 的方式是SET product:2001 {title:机械键盘,price:599,stock:30}读取某个字段时Redis 客户端需要把整个字符串返回给应用层应用通过 JSON 解析拿到目标字段。修改一个字段时得先 GET 再 SET两步操作不保证原子。如果中间有另一个请求改了别的字段你的旧数据回写就可能把它覆盖掉。用 hash 的方式是HSET product:2001 title 机械键盘 price 599 stock 30读取价格只需要HGET product:2001 priceRedis 在服务端直接返回目标字段。修改库存只需要HINCRBY product:2001 stock -1一步完成天然原子。这就是 hash 最核心的价值它在 Redis 里保存的是一个字段集合Redis 能在这个集合上做字段级操作。string 是整存整取hash 是按字段存取数据粒度完全不同。实际业务里用户信息、商品信息、配置项、会话数据这些天然是一个对象多个属性的模型用 hash 最贴合直觉。从内存角度说小规模 hash 用的是紧凑编码这个后面专门讲当字段数量少、字段值短的时候内存占用甚至比一条完整 JSON 字符串还低。string 存 JSON 并没有很多人想象中那么省内存因为 JSON 里的键名、引号、花括号都是真实存储的开销。1.2 与 list/set/zset 的边界hash 不是集合类型很多人容易把 hash 和另外三个类型搞混主要是因为它叫哈希但它在结构上最接近的对象是关系型数据库里的一行记录而不是集合。list是有序队列适合消息队列、时间线、最近列表核心操作是 LPUSH/LPOP 这种两端进出。set是无序去重集合适合标签、好友关系、去重场景核心操作是 SADD/SISMEMBER。zset是有序集合适合排行榜、延迟队列核心操作是 ZADD/ZRANGEBYSCORE。hash是 field-value 映射适合一个 key 下挂多个属性核心操作是 HSET/HGET。区分的关键问题是你要存的数据是一组同类型的东西还是一个物体身上的多个维度前者的答案是 list/set/zset后者的答案是 hash。把商品标签存成 hash 的 field 就属于典型的类型滥用用 set 才是对的反过来把用户头像路径塞进 set 也没意义它本来就是用户对象的一个属性。我在项目里见过最典型的问题是有人把整个购物车商品列表用 string 存成 JSON 数组每次加购都要全量改写。这种数据本质上是一个用户 ID 对应多个商品和数量恰好就是 hash 的天然模型——key 是用户 IDfield 是商品 IDvalue 是数量。后面我会展开讲这个场景。2. 高频命令实操从 HSET 到 Redis 6.2 的新面孔2.1 字段级读写HSET/HGET/HMGET/HDEL 的组合习惯命令本身不复杂但有一些使用习惯值得养成。基础写入读取# 写入多个字段注意 HSET 在 Redis 4.0 之后支持多对 field-value HSET user:1001 name zhangsan age 25 city beijing # 读取单个字段 HGET user:1001 name # 读取多个字段 HMGET user:1001 name age # 读取全部字段谨慎使用 HGETALL user:1001 # 删除字段 HDEL user:1001 city # 返回字段数量 HLEN user:1001 # 判断字段是否存在 HEXISTS user:1001 name这里有一个容易被忽略的细节HMSET 在功能上已经被 HSET 完全覆盖了。Redis 4.0 之前 HSET 只能写一个字段所以才有 HMSET 处理多字段。4.0 之后 HSET 支持多字段官方文档已经不建议在新代码里用 HMSET。Redis 6 里它还保留着但如果你在维护老代码碰到 HMSET 可以顺手改成 HSET命令语义完全兼容还能少记一个。另一个建议是批量读取字段时用 HMGET不要循环 HGET。一次网络往返和 N 次网络往返的差距在跨机房场景下很明显。同样写入多个字段用一条 HSET 而不是多条。字段名设计上hash 的 field 也是存在内存里的不要太长。比如用name而不是user_name_text。这个细节在大规模缓存下能省下可观的内存后面我会算一笔账。2.2 字段级计数器HINCRBY 比想象中好用hash 的 field value 其实只是一个字符串但 Redis 提供了一系列把字符串当作数字处理的命令# 整数增量 HINCRBY user:1001 score 10 # 浮点增量 HINCRBYFLOAT product:2001 rating 0.5这里的语义是fieldscore当前存储的是可解析的十进制整数HINCRBY 会把它加 10 后写回。整个过程在 Redis 服务端完成不存在先读后写的竞态窗口这就是它能在计数场景下保证原子性的原因。对比一下 string 的INCRhash 的 HINCRBY 多了一个维度——你可以按用户维度、按商品维度、按日期维度去维护多个计数器。举个例子一个 UGC 平台要统计每个作者的文章总阅读量、按天阅读量、当月阅读量一个author:{id}:read_count的 hash 就能全部放进去HINCRBY author:1001 total 1 HINCRBY author:1001 20250115 1 HINCRBY author:1001 202501 1这样可以一次取出多个维度的值做报表不相关的指标之间互不干扰。要注意如果 field value 本身不是合法的数字格式HINCRBY 会返回 WRONGTYPE 错误。所以存看起来是数字的字符串没问题但千万别把带单位的内容比如5件存进去再用 HINCRBY。2.3 Redis 6.2 新增的 HRANDFIELD一个容易被忽略的命令先说个大背景Redis 6.0 的 hash 类型在命令层面其实没有大改动。6.0 的主要变化集中在 IO 多线程、ACL 权限控制、RESP3 协议。hash 本身的命令升级主要在 6.2 版本其中最有意思的是HRANDFIELD。# 随机返回一个字段名 HRANDFIELD user:1001 # 随机返回 3 个字段名 HRANDFIELD user:1001 3 # 随机返回 3 个字段名附带对应的值 HRANDFIELD user:1001 3 WITHVALUES这个方法看起来不起眼但做随机抽取类业务非常顺手。比如秒杀的随机抽奖、任务随机分配、头条推荐的候选池采样只要把候选对象 ID 放进一个 hash 的 field 里一行命令就能完成随机选取。需要注意HRANDFIELD的 COUNT 参数支持正负两种语义。正数表示返回不重复的字段如果 hash 字段数少于请求数返回实际数量负数表示允许重复会精确返回这个数量适合用于需要固定样本数的场景。这个细节和 set 的SRANDMEMBER是同一个设计逻辑理解了负数的语义就不会选错。另外如果你在旧版本上维护代码6.2 之前没有 HRANDFIELD只能用 HKEYS 拿到全部字段后到应用层随机。字段一多HKEYS 的 O(N) 开销就很可观。所以说这个命令是 6.2 给 hash 场景补的一块短板。2.4 可视化客户端看 hash排查问题的重要手段这次热搜里有好几个关键词是 Redis 可视化客户端比如 RedisDesktopManager、Another Redis Desktop Manager。很多人的印象是这些工具只能当数据库浏览器的替代品其实对排查 hash 问题特别有用。用 RDM 打开一个 hash key界面会直接展示成表格field 和 value 一列列排开能看到每个字段的占用情况。我排查线上问题时第一个动作经常就是开 RDM 双击那个 hash key看它到底有多少字段、字段值是不是有异常的大字符串。但这里有个隐患大多数可视化客户端展示 hash 时背后执行的是 HGETALL。HGETALL 的时间复杂度是 O(N)如果一个 hash 有几十万字段客户端会直接卡住甚至拖累 Redis 主线程。所以查看大 key 时我习惯先看 key 基本信息确认命中的是 small key 再双击避免工具本身触发事故。3. 底层结构ziplist 何时自动升级为 hashtable3.1 两种编码方式的内存模型对比Redis 6 中一个 hash 对象内部有两种编码方式开发时不需要手动指定Redis 根据数据规模自动切换。ziplist压缩列表把字段名和字段值按顺序紧凑地排列在一块连续内存里每个元素之间用特殊标记隔开。查询时按顺序遍历所以查询时间复杂度是 O(N)但优点是内存占用极低没有指针和哈希桶的开销。hashtable哈希表每个字段单独分配一个 entry通过哈希函数映射到桶里查询平均 O(1)但每个 entry 需要额外维护指针、元信息内存开销更大。在 Redis 6 中一个小规模的 hash比如 10 个字段、每个字段值几十字节用 ziplist 存储可能只占几百字节如果用 hashtable光是每个 entry 的指针和元数据就可能让内存翻好几倍。这就是为什么 Redis 对小 hash 优先使用 ziplist——绝大多数业务里的对象属性都不多采用紧凑编码能显著降低内存占比。3.2 触发升级的阈值参数与控制建议Redis 6 提供了两个配置参数控制 hash 的编码切换hash-max-ziplist-entries默认 512表示字段数量超过 512 个时升级为 hashtablehash-max-ziplist-value默认 64表示任意字段名或字段值长度超过 64 字节时升级为 hashtable两个条件是或的关系任一条件超过阈值整个 hash 就会从 ziplist 转换为 hashtable。转换是一次性且不可逆的。一旦升级成 hashtable即使后面把字段删到 500 以下、值也缩短了它也不会降回 ziplist。这个设计意味着什么如果你的业务 hash 按约定控制在 500 字段以内、字段值不超过 64 字节就能一直享受紧凑内存。如果你知道某个 hash 会长期超过这个规模反而不要一味调大参数——ziplist 查询是 O(N)字段上千之后每次 HGET 都要遍历性能不一定比得上 hashtable。生产环境适当调大这两个参数的前提是单个字段的访问频率不高、字段数量可控。盲目调到几万等于把一个哈希表退化成了链表得不偿失。3.3 为什么 Redis 7 换成了 listpack我在 Redis 7 发布后注意到一个变化hash 的小编码从 ziplist 换成了 listpack。这可能让你在查资料时感到困惑因为 Redis 7 里已经找不到hash-max-ziplist-entries这个配置名了取而代之的是hash-max-listpack-entries。从原理上说listpack 和 ziplist 都是紧凑的线性存储结构但 ziplist 内部维护了前后节点的指针信息在链式更新时可能出现级联扩容问题。listpack 去掉了这些级联更新的负担结构更紧凑。这个变化说明 Redis 团队在设计上承认了小 hash 用紧凑编码是对的同时在优化它的实现的稳定性。所以如果你是在 Redis 7 上学习看到的是 listpack 相关的配置参数但原理仍然适用小对象紧凑存储大对象转哈希表阈值可配置转换不可逆。这套思路在 Redis 6 和 Redis 7 里是一致的。4. 四类典型业务场景hash 的价值落地4.1 对象属性缓存别再拿 JSON 硬扛最主流的用法就是缓存那些一个 ID 对应多个属性的对象数据。我用 Python 客户端举个最常见的模式import redis r redis.Redis(host127.0.0.1, port6379, db0) def get_user_info(user_id): key fuser:{user_id} # 直接从 hash 读取需要展示的全部字段 fields r.hmget(key, [name, age, gender, avatar]) # 如果命中缓存直接映射并返回 if fields[0] is not None: return { name: fields[0], age: fields[1], gender: fields[2], avatar: fields[3] } # 未命中则回源数据库并重建缓存 info load_user_from_mysql(user_id) r.hset(key, mappinginfo) r.expire(key, 3600) return info写这个模式时有几个细节值得注意第一HMGET 一次性取出需要的字段。即使 hash 里有 20 个字段只展示 4 个就只取这 4 个避免 HGETALL 把无关字段全部拉回。第二写入用 HSET 的 mapping 参数一次性建好缓存。第三一定设置过期时间hash 做缓存也要 TTL防止死数据占着内存不释放。另一个反直觉的地方有些字段是读多写少的有些是写多读少的hash 允许你独立更新其中一个字段而不影响其他字段。比如用户头像更新了只需要HSET user:1001 avatar new_url用户的昵称、等级、积分都安然无恙。如果是 JSON 字符串缓存一次头像更新等于全量覆盖中间任何一个字段的并发写都可能导致丢更新。4.2 购物车与选中状态field 是天然的商品 ID购物车是一个 hash 用得极其贴切的场景。每个用户有一个购物车 key每个商品 ID 是 field购买数量是 value# 用户 5001 加购商品 3001数量 2 HSET cart:5001 3001 2 # 修改数量 HINCRBY cart:5001 3001 1 # 移除商品 HDEL cart:5001 3001 # 查询整个购物车 HGETALL cart:5001 # 购物车商品数量 HLEN cart:5001这个模型比SET cart:5001 [{goods_id:3001,num:2}]这种方式好在哪首先改数量是原子操作不用担心两个请求同时加购同一商品时互相覆盖其次判断某个商品是否在购物车里用HEXISTS cart:5001 3001一步完成不需要把整个 JSON 解析成数组再 in 一遍最后过期时间也可以按用户维度设置比如 7 天未登录清空购物车。类似的还有选中状态、收藏列表、表单填写进度这些场景只要映射关系是一个主体 ID 对应多个条目 ID 附加数值hash 基本都是最优解。4.3 多维计数聚合一个 key 管一组计数器前面提到的按作者统计各维度阅读量就是 hash 做多维计数的典型案例。另一个常见场景是活动维度下的 PV/UV/分享数/点击数HINCRBY activity:1001 pv 1 HINCRBY activity:1001 share 1 HINCRBY activity:1001 click 1这些计数器都是独立字段互不阻塞随时可以 HGETALL 一次拉出来做展示。相比维护多个 string keyhash 把这些相关计数器组织在同一个 key 下管理成本更低业务语义也更清晰。这个场景可以关联到热搜里出现的redis incr不准。其实单实例 Redis 的 INCR 是原子的不存在不准你遇到不准多半是高并发下用 GET 出来后在应用层 再 SET 回去或者用了不同的 key 前缀导致统计口径不一致。HINCRBY 也一样只要你不做先读后写就不会丢计数。记住一句话凡是和加一/减一相关的操作都交给服务端的命令完成不要拿到客户端计算再写回。4.4 分布式锁里的隐形 hashRedisson 的重入锁实现很多人学分布式锁都停留在SETNX但如果你用过 Redisson 这个 Java 客户端会发现它的可重入锁底层用的是 hash——key 是锁资源名field 是持有锁的线程唯一标识value 是重入计数。这个设计非常聪明一个线程可以多次加同一把锁每次加锁 value 加一每次释放减一到 0 才真正释放。同时由于 field 携带了线程标识不同线程不能互相解开对方的锁。如果用 set 存一个简单 key根本表达不了同一个客户端重入了多少次这个信息。普通 set 锁在可重入场景下的实现方式通常是 ThreadLocal 记账但跨实例和客户端节点时ThreadLocal 只在本地生效无法协调全局。hash 把重入次数放在 Redis 服务端不同节点上的线程都能读到同一个计数这才是分布式可重入锁的正解。如果你自己实现分布式锁可以借鉴这个思路HSET lock:order:2001 owner:{thread_id} count 1每次重入时HINCRBY lock:order:2001 count 1释放时HDEL或HINCRBY减一。注意还得配合过期时间处理死锁以及用 Lua 脚本保证判断持有者 释放这两步原子执行。这个就是 hash 在实际工程架构里更深层的一次体现比存 JSON 有意思多了。5. 实战中的坑大 key、HGETALL 与字段设计5.1 大 key 是头号风险hash 用得越多越容易攒出大 key。常见成因是用日期作为 key 前缀把当天全部用户行为都塞进同一个 hash或者字段名设计不当导致 hash 字段数无限增长。大 key 的危害很直接HGETALL、HKEYS 这类 O(N) 命令会阻塞 Redis 主线程其他请求排队DEL 一个大 key删除操作本身对内存回收的耗时也很可观。Redis 4.0 之后有 UNLINK 异步命令可以缓解主从复制、AOF 重写时大 key 会造成更大的内存与带宽压力如果网络抖动导致部分重同步大 key 的序列化和传输会显著拉长故障恢复时间我排查线上问题时最常用的是redis-cli --bigkeys它会扫描整个实例按类型统计最大的 key 并报告。但这个命令也有坑它默认扫描所有 key生产环境慎用最好在从节点或者低峰期跑否则大实例扫描本身就会带来一定负载。发现大 hash 之后通常的解法有两个方向。一是拆分 key把 user:1001 这种按业务模块拆成多个 hash比如用户基础信息一个 key用户行为统计一个 key。二是换存储方式如果字段数量确实非常大而且主要是写入、很少整体读取可以考虑用 string 分片或者换到专门的列式/宽表存储不要什么数据都往 Redis hash 里堆。5.2 HSCAN 替代 HGETALL游标遍历的正确姿势当 hash 比较小的时候HGETALL 没问题。但发现 hash 字段数已经几千甚至几万之后就别再用 HGETALL 了改用 HSCAN 游标遍历# 从游标 0 开始每次取 20 个字段 HSCAN user:1001 0 COUNT 20返回结果中第一个值是新游标。如果新游标是 0表示遍历完成如果不是 0把新游标作为下一次 HSCAN 的输入继续取直到游标回到 0。HSCAN 的设计目的是把一次 O(N) 的大操作拆成多次小操作每次只返回一部分字段避免长时间占用服务端线程。有一点要提醒HSCAN 的 COUNT 参数只是一个建议值不代表每次一定精确返回多少条。Redis 会根据哈希表的实际结构和扩容情况自行决定。另外在遍历期间如果有字段被增删同一个字段可能被返回多次也可能漏掉这种现象叫不可靠游标语义。所以 HSCAN 适用于全量导出、统计和迁移不适用于严格的恰好一次的业务消费。要在遍历过程中做数据清洗最好先快照到临时结构再处理。实际使用中我建议把 HSCAN 封装成脚本脚本内部循环判断游标是否为 0这样上层只需要调用一次封装好的函数。Python 客户端可以这样写def hscan_all(r, key, batch100): cursor 0 while True: cursor, data r.hscan(key, cursorcursor, countbatch) for field, value in data.items(): yield field, value if cursor 0: break注意这是安全的全量遍历不是高性能的全量遍历。如果数据量特别大可以考虑用多进程/多线程从多个游标位置并发遍历但并发遍历在字段变更频繁时会更容易出现重复/遗漏需要根据业务容忍度权衡。5.3 字段命名与 value 序列化的内存账hash 的内存占用由三部分组成key 本身、field 本身、value 本身。很多人只盯着 value 的大小忽略了 field 的长度。举个例子你在 hash 里存用户行为数据field 是20250115_click_article_3001_uid_5002长度 30 多个字节如果一百万个用户每人一个这样的 field光 field 字符串就是 3000 多万字节约 30MB 以上。换成20250115:3001这种格式分别用不同的 hash 和 field 表达维度内存能省下非常可观的数量。value 那侧的问题更常见有人把 JSON 也塞进 hash 的 value。比如HSET user:1001 extra {ip:1.2.3.4,ua:Mozilla/5.0...}。这就有点本末倒置了。hash 的价值在于字段级操作你把一个 JSON 字符串整个塞进一个 field那这个 field 依然无法被原子修改改动任何一点都要整体重写和用 string 存 JSON 没区别。如果字段本身就是个小对象我一般会拆成多个 field而不是塞一个 JSON。比如用户扩展信息有手机型号、登录 IP、注册渠道三个 field 分开存改动和读取都灵活。如果确实需要整体存储一段结构化内容那就老老实实用 string。hash 的每个字段都应该是一个最小业务原子属性而不是一个小容器。另外序列化方式也会影响内存。hash 的 value 最终以字符串形式保存。如果客户端使用 JDK 原生序列化会产生大量类名、描述信息膨胀率很高。建议用 JSON 或 MessagePack 序列化。前者可读性高后者体积小、性能好。对内存敏感的线上环境我测试过同样一个小对象MessagePack 比 JDK 序列化能省 30% 到 50% 的空间这个差距在大规模缓存场景下不容忽视。5.4 主从与持久化叠加 hash 大 key 的影响很多人设置主从部署之后觉得数据安全了却忽略了大 key 对复制链路的影响。热搜里有 docker 安装 redis 主从、redis 哨兵集群、持久化这些关键词说明不少人正在搭集群环境那这个坑更需要提前知道。hash 大 key 在主从同步时不管用什么持久化策略问题都会放大AOF 重写会把所有键重新遍历一遍把这个 hash 序列化到临时文件。这是一个大内存读 大文件写的过程如果大 key 占用几个 GBAOF 重写期间内存瞬时翻倍极易触发内存上限RDB 全量同步从节点需要拿到完整数据文件大 hash 会导致传输时间变长断线重连时的部分同步也可能因为复制积压缓冲区被大 key 撑爆而被迫全量重同步主从切换后新的主节点在写命令重放时对一个大 hash 执行 HGETALL 或 HSET 大量字段也会拖慢整个切换流程所以凡是涉及主从/哨兵/持久化的部署我在设计阶段就会刻意控制单 hash 的规模。我的习惯是一个大字段集合的 hash 默认控制在 1000 字段以内字段值平均长度不超过 100 字节。一旦某个 key 接近阈值就考虑拆 key、拆字段或者换存储。这个阈值不是 Redis 官方的而是我在实践中权衡了查询性能、内存开销、复制压力之后形成的自我约束你可以根据自己的业务数据量调整但一定要有类似的规模意识。还有一个注意点开启持久化后删除大 key 的时机。就算用 UNLINK 异步删除异步线程回收内存时AOF 文件里如果还残留对应的大 key 数据重写时依然会有瞬时压力。所以清理大 key 最好和 AOF 重写错开时间别同时操作。最后说点实践经验我在实际项目里用过 hash 存用户资料、购物车、运营配置、分维度计数器也踩过大 key、HGETALL、序列化膨胀的坑。整体感受是hash 是 Redis 里最容易学、也最容易用错的数据类型。容易学是因为命令简单HSET/HGET/HINCRBY 半天就能上手容易用错是因为它的适用模型比较清晰但很多人不习惯为对象的每个属性单独建字段下意识就想塞 JSON。一个小技巧送给你生产环境上线前用redis-cli --bigkeys和INFO memory分别跑一次。前者看有没有不该长大的 hash后者算算内存里的平均键大小。如果发现某类 hash 的字段数开始逼近配置阈值提前加监控不要等它升级成 hashtable 才发现内存和查询性能都不正常了。hash 的学习曲线不算陡但真正用好它靠的是对字段粒度的理解。下次再需要缓存一个对象时先别急着SET key value想一下你要不要独立更新它的某个属性、要不要单独读取它的某个属性。只要有其中任何一个需求答案基本就是HSET那一套了。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。