
说实话Redis里最容易被低估的数据类型就是 Hash。大家天天用 String 存 JSON、用 List 做队列、用 Set 做去重但真到要存一个“对象”的时候反而犹豫了是先序列化成 JSON 丢给 String还是拆成几百个 Key我见过不少团队把用户信息塞进一个巨大的 JSON 字符串结果每次只改一个昵称都要整存整取接口慢了不说高并发下还经常出现互相覆盖的问题。这篇文章就把我用 Redis Hash 做对象存储的完整思路翻出来从内部编码讲到架构选型再把几个容易踩的坑一起说清楚。无论是刚学 Redis 的初学者还是正在排查慢查询的运维老手都能在里面找到点能直接用的东西。1. 为什么偏偏是 Hash先看清 Redis 的数据类型地图1.1 Hash 在 Redis 类型体系里的位置Redis 的基础数据类型有五种String、List、Set、ZSet、Hash。String 是最底层的通用结构List 擅长做消息队列和时间线Set 用来去重和求交集ZSet 带着分数做排行榜。而 Hash 经常被忽略其实它最适合表示“一个实体的多个属性”。你可以把 Hash 理解成一个两层嵌套的 map外层是 Redis 的 key内层是一组 field-value 对。跟 Java 的 HashMap 很像但有两个关键差异一是所有 field 和 value 都必须是字符串二是它天然支持对单个字段的读写和原子操作。这种“扁平属性集合”的特性让它成为对象缓存的天然载体。比如要存一个用户对象HSET user:1000 username zhangsan email zsexample.com age 28从 Redis 的角度看user:1000是一个 Hash里面有三个字段。这个结构非常贴近我们代码里的 POJO 或字典而不是一串需要反序列化的文本。1.2 对象存储的三种常见姿势我在代码评审里看过太多“存对象纠结症”总结下来无非三种做法第一种是String 序列化 JSON。把对象直接JSON.stringify后塞进 String。优点是实现无脑结构再复杂都能存缺点是更新单个字段时必须先把整个字符串读出来、反序列化、改字段、再序列化、写回去。如果两个线程同时改不同的字段后写的一方会覆盖先写的一方这就是典型的并发覆盖问题。第二种是一个属性一个 String key。比如user:1000:name、user:1000:email。这样单独更新某个属性确实容易了但 key 数量瞬间爆炸而且多个属性之间没有原子性——你想同时更新 name 和 email需要两步操作中间一旦出问题数据就对不上了。更重要的是以后想“取整个用户”得批量 GET代码难看管理也麻烦。第三种就是Hash。一个 key 对应一个对象对象的每个属性是一个 field。这样既支持单字段更新又不用担心 key 数量膨胀还保留了对整个对象批量操作的能力。所以从“对象存储”这个需求出发Hash 是比前两种结构更理性的选择。这里并不是否定 String后面第 4 章我会专门划清两者边界。2. Hash 的底细从 listpack 到 hashtable内存和性能的博弈2.1 内部编码到底是怎么变的很多文档只说“Hash 适合存对象”但你如果不理解它的内部编码踩坑是迟早的事。Redis 的 Hash 在底层有两种编码方式在 7.0 之前是小对象用ziplist7.0 之后换成了listpack当元素数量或者单个 value 长度超过阈值时会升级为hashtable。listpack/ziplist的本质是一块连续内存把 field 和 value 一个接一个地紧凑排列。它省去了大量指针和节点头部的开销所以小 Hash 内存占用非常低。但代价是读写是遍历型的时间复杂度 O(n)。好在 n 很小默认阈值为 128 个 field这点遍历开销完全可以忽略。hashtable就是真正的哈希表每个 field 通过哈希定位读写 O(1)。但每个节点要维护指针、元数据内存消耗明显更大。Redis 会在写入时自动判断超过阈值就升级。用命令可以随时查看当前 key 的编码OBJECT ENCODING user:1000 # 小对象返回 listpack # 大对象返回 hashtable2.2 阈值和长度怎么影响你的设计默认情况下Hash 的 field 数量超过 128 个或者某个 value 长度超过 64 字节编码就会从 listpack 升级为 hashtable。这个“64 字节”很多人会忽略但它直接影响你的字段设计。我做过一个用户标签系统每个用户存 20 个左右字段但某个标签值是 100 个字符的短文本结果这个 Hash 整体升级成了 hashtable内存一下子比预期涨了快一倍。后来我把长文本单独拆出去Hash 里只保留短值内存降了 40% 左右。如果你明确知道自己的 Hash 会长期保持小规模可以手动调大配置CONFIG SET hash-max-listpack-entries 256 CONFIG SET hash-max-listpack-value 128但这里有个重要提醒listpack 到 hashtable 的升级是单向的。如果一开始字段很多升级到了 hashtable即使后续把字段删到阈值以下也不会自动降级回 listpack。所以千万不要以为调大阈值就能随便浪要结合业务峰值来设置。2.3 实操一行命令观察编码变化我们可以做个简单实验先创建一个 10 个字段的小 HashHSET obj f1 v1 f2 v2 f3 v3 f4 v4 f5 v5 f6 v6 f7 v7 f8 v8 f9 v9 f10 v10 OBJECT ENCODING obj # listpack然后写入第 200 个字段HSET obj f199 v199 f200 v200 OBJECT ENCODING obj # hashtable当你看到hashtable的那一刻就要意识到这个 key 的内存使用已经变了。在生产环境排查内存问题时用redis-cli --bigkeys也能扫出来哪些 Hash 的 field 数量异常多这个下文会细说。3. 用 Hash 做对象存储的实操场景3.1 场景一用户信息缓存最常见的使用场景就是缓存用户基础信息。登录时频繁读个人页频繁读偶尔改个昵称、改个头像。用 Hash 来存读写逻辑非常顺。初始化用户信息HSET user:1001 username wangwu email wangexample.com gender male city 上海修改昵称不需要反序列化整个对象HSET user:1001 username wangwu2获取单个字段HGET user:1001 username获取全部字段HGETALL user:1001在一套用户服务里这个方案能非常自然地与 Mysql 行记录对应数据库表的一行就是 Redis 的一个 Hash key表的列对应 field。修改某个字段时先更新数据库再更新 Hash 里对应的 field代价远低于整个对象的重写。3.2 场景二购物车与计数器组合Hash 也很适合做购物车。key 是cart:用户IDfield 是商品 SKU IDvalue 是数量。HSET cart:123 sku_1001 1 HSET cart:123 sku_1002 2用户再加一件商品时用HINCRBY原子自增HINCRBY cart:123 sku_1001 1用户减少一件或移除商品HINCRBY cart:123 sku_1001 -1 HDEL cart:123 sku_1002这里面的核心优势是多个商品的数量变化互不干扰并且HINCRBY是原子操作不会出现“读-改-写”的并发问题。如果用字符串存整个购物车 JSON每次增减商品都要先取出整个 JSON 再计算在高并发加购场景下非常容易出错。类似的用法还有统计面板比如把每天的访问量拆成多个维度的计数器HSET stats:20250101 pv 0 uv 0 likes 0 HINCRBY stats:20250101 pv 1 HINCRBY stats:20250101 uv 13.3 场景三对象的部分更新PATCH 语义做 REST API 的人应该都很清楚 PATCH 和 PUT 的区别PUT 是整体替换PATCH 是局部更新。用 String 存对象时天然是 PUT 语义用 Hash 存对象天然是 PATCH 语义。比如前端只传了一个字段PATCH /user/1001 {nickname: 老张}在服务端用 Python 处理import redis r redis.Redis(host10.0.0.8, port6379, decode_responsesTrue) def update_user(user_id: int, fields: dict): key fuser:{user_id} # 只传入需要更新的字段 r.hset(key, mappingfields)你的业务逻辑里只需要把请求体里的非空字段挑出来映射到 Hash 的 field 上完全不碰其他字段。这在多团队协作、接口字段经常新增的场景下能减少大量不必要的序列化逻辑和 bug。3.4 封装一个简单的对象存储模块为了让团队里其他人也用得顺手我一般会封装一个非常薄的对象存取工具。以 Python 为例import redis import json r redis.Redis(host10.0.0.8, port6379, decode_responsesTrue) class RedisObjectStore: def __init__(self, redis_client): self.r redis_client def save(self, key: str, obj: dict): # 展平一层把字典变成 Hash 的 field-value self.r.hset(key, mappingobj) def get(self, key: str) - dict: data self.r.hgetall(key) # hgetall 拿到的是 bytes 或 str这里统一转成 dict return data or {} def update(self, key: str, fields: dict): self.r.hset(key, mappingfields) def delete_fields(self, key: str, *fields): self.r.hdel(key, *fields)使用的时候store RedisObjectStore(r) store.save(user:1001, {name: 张三, age: 30}) store.update(user:1001, {age: 31})要特别提醒一点这个封装只支持“扁平对象”。如果对象里还有嵌套结构比如{address: {city: 上海, district: 浦东}}不要硬把它拆成address.city这样的 field虽然也能实现但嵌套一深代码会变得非常难维护那种场景还是老实交给 JSON 字符串吧。4. Hash 与架构选型边界到底在哪里4.1 什么时候不要用 HashHash 不是万能的我自己也见过强行用 Hash 导致烂尾的案例。下面这几种情况建议不要选 Hash对象内部有复杂嵌套结构。一个用户对象里包含地址对象、订单列表、标签数组。这种结构如果强行压平到 Hash每一个嵌套层级都要通过字段命名来伪表达比如addr_city、order_0_id读写时还得手动组装毫无效率可言。这时候 String 存 JSON 反而更合适让 Redis 充当一个带过期时间的 Key-Value 缓存。字段数量不可控且会持续膨胀。比如把一段日志的 metadata 全塞进 Hash今天 10 个字段明天 20 个几个月后变成几万个 field。这不是对象这是一个大集合。大 Hash 会导致内存飙升、阻塞操作、持久化变慢后面第 5 章会讲具体危害。value 本身太大。Hash 的 field value 不建议放 base64 图片、长文本、文件内容。超过 64 字节就会触发编码升级超过几 KB 更是对内存和网络都不友好。大对象应该走文件存储/对象存储服务Redis 里最多放个访问地址。4.2 Hash 与 String JSON 的选型对照表我把选型时的核心比较点整理成下面这张表方便你直接对照维度Hash 存对象String JSON 存对象更新单个字段直接 HSETO(1)需要 GET → 反序列化 → 改值 → 序列化 → SET并发更新字段级隔离互相影响小整对象覆盖容易丢更新读取部分字段HGET 指定 field开销小必须 GET 整个 JSON 再解析读取整个对象HGETALL一次拿到所有字段GET 后反序列化整体读取嵌套结构困难需要人工展平天然支持任意层级内存开销小对象用紧凑编码省内存字符串额外存储 JSON 语法字符引号、括号字段动态扩展随加随用灵活需要修改序列化结构可读性命令行下 field 清晰可见一大串 JSON可读性差TTL 过期只能对整个 key 设置只能对 key 设置同样无法字段级过期从这张表能看出来Hash 的强项是高频部分更新和内存效率String JSON 的强项是表达复杂嵌套结构。项目里不是二选一黑白分明合理做法是读多写少且结构稳定的核心对象用 Hash结构复杂且整体读写的大文档用 String 存 JSON。4.3 从热词看架构联动缓存穿透、分布式锁、持久化与集群最近聊 Redis 的高频词里除了数据类型本身还有缓存穿透、分布式锁、持久化、集群这些架构话题。它们和 Hash 的选型并不是孤立的。先看缓存穿透。Hash 缓存对象时同样面临“请求了一个不存在的 ID”的问题。常见的防空策略是在 Hash 里写入一个空对象占位比如HSET user:9999 empty 1再设置短过期时间。因为 Hash 的过期只能作用在 key 上所以这种空占位和 String 逻辑上差不多但能保留对象结构的一致性。再看分布式锁。Redisson 实现分布式锁的底层就是 Hash 结构key 是锁名称field 是持有锁的线程标识value 是重入次数。加锁时HSET lock:order 55a3f1a1-thread-1 1重入时HINCRBY加一释放时HDEL或HINCRBY减到零。这里恰好说明 Hash 很适合做“一个 key 下多个维度的计数状态”但我要强调这是成熟框架的用法不建议自己照猫画虎容易在锁超时、误删锁的问题上翻车。持久化和集群方面Hash 本身在 RDB/AOF 持久化时和 String 一样都是作为 value 的一部分处理。但如果某个 Hash 变成 big keyRDB 导出时的序列化时间和 fork 阻塞时间都会显著增加。集群场景下Hash 的 key 决定了 slot同一个 Hash 的所有 field 一定落在同一个节点上所以它能保证对字段的多个操作在单节点内完成适合需要事务和原子操作的场景。但这也意味着单个 Hash 不能横向拆分到多个节点如果你有一个超大 Hash集群也救不了你只能业务层拆 key。5. 实战中踩过的坑Hash 的常见问题与排查技巧5.1 Big Key一个超过 10 万字段的 Hash我接手过一个项目别人用 Hash 存用户标签结果单个用户被打了上万标签key 里 field 数量飙到 10 万以上。表面上看只是内存占用高实际上危害特别明显HGETALL 一次要返回十几万条数据Redis 主线程被阻塞好几秒RDB 持久化时也要遍历这个大 Hash导致 fork 耗时增加大 key 在集群迁移、删除的时候也是灾难。排查大 key可以用自带的扫描工具redis-cli --bigkeys输出里会列出 field 数量最多的 Hash并估算内存占用。一旦确认是大 Hash不要直接 DELDEL 会阻塞主线程要用异步删除UNLINK user:1000如果是业务上必须保留的大 Hash就要考虑拆分。比如按时间维度拆成user:1000:202401、user:1000:202402或者按业务类型拆成多个 key。总之Hash 的 field 规模应该控制在可预测的范围内而不是无限增长。5.2 过期时间只能设在 Key 上别搞错Hash 支持给每个 field 单独设置过期时间吗答案是不支持。很多人第一次用 Hash 想给某个 field 加 TTL翻遍命令也没找到HEXPIRE直到 Redis 7.4 才引入了HEXPIRE等字段过期命令但如果你们的版本不够新就不要指望这个。在主流版本里过期只能作用于整个 key。遇到“对象里某个字段需要更短过期时间”的需求通常有两种解法。第一种是把需要独立过期的字段单独拆成一个 String key比如user:1000:temp_code给它设 10 分钟过期。第二种是用 Hash 存长期属性再单独用另一个 String 存临时状态两个 key 属于同一个业务对象通过约定好的后缀关联起来。5.3 Hash 原子操作与分布式锁的误区HSET 本身是原子操作HINCRBY也是原子操作但这不代表多个命令合在一起是原子的。很多人会写HSET cart:123 sku_1001 1 EXPIRE cart:123 3600期望“写入并设置过期”作为一个整体但其实这是两条命令中间任意一条失败都会导致状态不一致。如果这两步必须原子执行用 Lua 脚本local key KEYS[1] local field ARGV[1] local value ARGV[2] local ttl ARGV[3] redis.call(HSET, key, field, value) redis.call(EXPIRE, key, ttl)再提一个分布式锁的常见误区。网上有很多“用 Redis 实现分布式锁”的教程会把锁信息放到一个 Hash 里比如 hashKey 是锁名hashField 是线程 IDhashValue 是重入次数。这种做法在 Redisson 里确实是这么实现的但自己做的时候特别容易忘记校验“只有持有锁的线程才能释放锁”。如果不判断 field 是否属于当前线程就直接 HDEL很可能把别人刚获取的锁给删了。我的建议是如果你不是 Redisson 作者那种级别别自己写直接用现成库。5.4 工具选择与日常运维日常排查 Hash 问题我觉得有两类工具是必不可少的。第一类是可视化客户端。比如 Another Redis Desktop Manager、Redis Insight它们能直接看到某个 key 的 field 数、每个 field 的值大小比命令行一顿敲直观得多。看 Hash 是否异常先看 field 数量再看最大的几个 value基本心里就有数了。第二类是慢日志和监控命令。在 redis-cli 里执行CONFIG SET slowlog-log-slower-than 10000 SLOWLOG GET 20如果看到很多HGETALL命令耗时超过 10ms基本可以断定某个 Hash 已经太大了。此时再配合DEBUG OBJECT user:1000查看序列化长度和 encoding 类型定位问题很快。6. 个人体会Hash 是架构里的“储物柜”不是“保险箱”用 Hash 做了几年对象存储我最大的感受是它就像仓库里的储物柜每个柜子里分几格取用什么都很方便但你要是往一个柜子里塞太多东西柜门都关不上。Hash 最适合的是“字段数量有限、更新频繁、结构扁平”的核心业务对象。我刚接手一个高并发用户服务时一大半接口都在读写用户信息原来全部是 String 存 JSON后来把用户主信息迁到 Hash接口的 95 分位延迟从 80ms 降到了 20ms内存占用也降了差不多 30%。但如果当时用户对象里有大量嵌套结构这个改造就不会成功。最后分享一个小技巧用 Hash 存对象时field 的命名也要克制。比如用uname代替username用ct代替create_time在几十万用户、每个用户几十个字段的规模下省下的内存相当可观。但同样别走极端缩写到别人看不懂就是给自己挖坑。这个内容后续如果团队准备上 Redis 7.4还可以试试字段级过期特性能进一步补齐 Hash 的短板。但在那之前先把基础的数据类型边界搞清楚比追求新鲜命令更有价值。