ARTICLE DETAIL

资讯详情

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

Redis Hash实战指南:从内存编码到对象存储与架构选型

Redis Hash实战指南:从内存编码到对象存储与架构选型 最近跟团队里几个后端同学聊 Redis 数据类型发现一个很有意思的现象几乎所有面试题都会问五种基本类型但真到写代码的时候大家的第一反应却永远是String JSON。明明面对的是一个用户对象、一篇订单详情、一个商品快照用 Redis Hash 才是更合适的选择可实际生产环境里 Hash 的使用频率比我预想低得多。正好这两天在做用户中心的重构把一批接口从 MySQL 直查挪到了 Redis 缓存层期间把 Hash 从编码结构到集群选型又重新捋了一遍。这一轮踩完坑很多之前模棱两可的认知都落地了写一篇尽量少废话的深度笔记从内存编码讲到对象建模再聊到架构层面的取舍。文章不面向纯小白但我会把关键原理补足保证有一定 Redis 使用经验的朋友都能跟上并且看完能直接用在下一版设计里。1. Hash类型在Redis全家桶里的价值坐标要理解一个东西最好先搞明白它周边的邻居是谁。Redis 的 Hash 本质上是一个 key 下面挂了一张 field-value 的小表你可以把每个 field 理解成对象的一个属性每个 value 是属性值。这个模型对应到业务代码里几乎就是实体对象的天然映射。1.1 与String、List、Set、ZSet的边界感先拉一张表看清楚了类型底层组织方式典型使用场景与Hash的本质差异String单个key对应单个value缓存、计数器、session、分布式锁只能存一个值对象需要整体序列化List有序可重复元素集合消息队列、时间线、最新列表线性结构按索引访问不做字段级访问Set无序不可重复元素集合标签、去重、好友关系、抽奖池只关心元素本身不关心属性ZSet带score的有序集合排行榜、延时队列、滑动窗口排序是核心能力字段是附属品Hashkey下挂field-value对对象存储、属性动态更新、聚合计数字段级读改写是最大差异化这段对比里最值得琢磨的是 Hash 和 String 的边界。String 存对象的做法是整体序列化你需要把整个对象打平成 JSON 或者 protobuf 字节流写一次就是整体覆盖读一次就要整体反序列化。Hash 则把对象拆成 field 级别的独立单元格读一个属性只取一个 field改一个属性只碰一个 field。这个差异在并发更新场景下会被急剧放大。用一个String JSON存购物车A 线程改商品数量、B 线程改优惠券字段两个线程几乎必然发生读改写覆盖问题后写的那个会把先写的字段整个冲掉。Hash 按 field 更新天然规避了这一点因为每次只是对具体某个属性做原子操作。1.2 为什么说Hash是伪对象数据库Redis 官方把 Hash 设计出来的初衷其实就是模拟内存中的对象存储。早期 Memcached 时代大家存对象都是字符串级别每次修改要取出整个对象重新序列化放回去网络和 CPU 开销都大。Redis 引入 Hash 之后对象的一部分可以在服务端原地修改不用在客户端和应用服务器之间搬运完整数据这直接把内存 KV 存储升级成了一个有字段语义的轻量级对象库。我这里说的伪对象数据库不是贬义而是指它的定位介于 KV 和真正的文档数据库之间。它不提供二级索引的完整实现不支持嵌套对象也不做查询语法解析但它提供了 O(1) 的字段访问、字段级 TTL通过过期整个 key 实现、以及精细的内存控制。对大部分业务对象缓存来说这已经足够。真的需要复杂查询那应该上 MongoDB 或者 ES而不是在 Redis 里硬造轮子。2. 两种内存编码的切换逻辑别让hashtable拖垮你的内存这一节是很多教程会跳过但实际又很重要的部分。Hash 并不是从头到尾都用一种结构存储Redis 根据数据量和字段大小在两种编码之间切换OBJ_ENCODING_LISTPACK老版本是ZIPLIST和OBJ_ENCODING_HT。2.1 listpack 到底省在哪儿listpack 是一种紧凑线性数据结构所有 field 和 value 按顺序排在一块连续内存里每个节点用固定长度的 header 记录自身的长度信息解析时从后往前推即可定位节点。它没有指针、没有独立内存分配整块数据一次性分配、一次性释放。为什么这能省内存对比一下就明白了。hashtable编码每个 field-value 对至少有三个内存对象field 有一个sds、value 有一个sds、哈希桶里有一个dictEntry每个对象都自带内存分配头和对齐损耗。如果 field 很短、value 也很短结构开销甚至可能超过数据本身。listpack 把这一切压成一段连续字节省掉了所有指针和独立的分配头实测在几百个短 field 的小对象场景下内存占用可以减少 50% 以上。REIDS 7.0 之前Hash 小对象用 ZIPLIST REIDS 7.0 之后Hash 小对象用 LISTPACK统一替代 ziplist官方用 listpack 取代 ziplist 的核心原因是 ziplist 的连锁更新问题当某个节点的长度变化导致后续节点的 prevlen 字段需要重新扩容时可能引发级联的内存搬移最坏情况时间复杂度退化到 O(N²)。listpack 通过让每个节点只保存自身长度、不依赖前一个节点的长度从根上解决了这个问题。所以如果你在 7.x 版本里看到 Hash 的 encoding 显示为listpack别奇怪它就是这个版本之后的标配。2.2 编码升级的触发条件和实测表现两种编码的切换由配置项控制hash-max-listpack-entries 128 hash-max-listpack-value 64含义是当 Hash 的 field 数量不超过 128 个并且每个 field 和 value 的长度都不超过 64 字节时使用 listpack 编码否则转换为 hashtable。这两个参数缺一不可任何一个条件超了都会触发升级。升级之后的 hashtable 编码在访问性能上更有优势因为哈希表是真正的 O(1) 查找而 listpack 需要线性扫描。但是注意listpack 的线性扫描只在几百个 field 的规模下性能几乎无感。真正要关心的是内存我在压测环境里模拟过一组 500 个 field、每个 value 约 100 字节的用户画像 Hashlistpack 编码占用约 52KBhashtable 编码占用约 68KB差距接近 30%。当单 key 的 field 数量小、字段值短时listpack 是明显的内存赢家。但反过来如果你的 field 很大比如存了一段 base64 的图片缩略信息listpack 优势就没有了因为字段长度的编码头也要跟着变大。所以生产环境调优时不要盲目调大这两个参数要看你的实际数据分布。我见过有团队把 entries 调到 1024value 调到 256结果大对象全部走 hashtable小对象反而因为频繁升级导致内存碎片率上升。这个参数最好通过线上监控的内存节省比例来反向校准而不是拍脑袋改。2.3 关于big key的另一个隐藏视角说到 Hash 的 big key很多人只盯着 value 大小忽略了 field 数量过大同样是问题。一个 Hash 有几十万个 field就算每个 value 都很小它在 RDB 持久化、主从复制、集群迁移时的表现也很糟糕单个 key 序列化成一个巨大的对象传输和加载都会产生明显毛刺。这也是为什么除了 listpack 参数之外我更建议团队在架构上提前把大 Hash 拆开。比如用户扩展属性一个 key 存user:ext:{id}就够了别把全站用户的所有字段堆在一个 key 下面。拆分不是 Redis 的限制而是运维和稳定性层面的自我保护。3. 用Hash做对象存储字段设计和高频姿势回到标题里的对象存储。这里说的对象是业务对象不是 OSS 里的二进制对象。把 User、Order、Product 这类实体塞进 Redis 时每个业务对象对应一个 key对象的属性作为 field。3.1 对象字段建模一条命名的黄金法则先给一个最直观的对比。String 方案SET user:10001 {name:张三,age:30,city:杭州,vipLevel:5}Hash 方案HMSET user:10001 name 张三 age 30 city 杭州 vipLevel 5看起来只是形式差异但后续的操作行为完全不同只想改 VIP 等级String 要读出来、改 JSON、写回去Hash 直接HSET user:10001 vipLevel 6。只想拿 city 字段String 要把整个 JSON 读回来解析Hash 只回一个字段的值网络传输量少了一个量级。判断某个字段是否存在String 只能看整个 key 是否存在Hash 可以HEXISTS user:10001 vipLevel字段粒度判断。命名上我建议遵循业务对象:主键的格式比如user:10001、order:202405001。如果后续要加缓存维度可以在中间加一层如user:basic:10001和user:detail:10001这样同一个对象不同类型的属性拆到不同 key各走各的 TTL 和淘汰策略。千万不要用10001:user这种倒序命名扫描 key 的时候不仅费劲而且容易把主键范围弄混。3.2 浅更新与字段级TTL的设计思路Hash 支持对单个 field 做操作这在对象更新上的意义是浅更新。一个 50 个字段的订单对象前端只改了收货地址你只需要HSET order:202405001 address 北京市朝阳区xxx不用反序列化整个对象。在高频更新场景比如商品库存、用户积分、实时位置这个优势会被无限放大。我做过一个库存服务用 Hash 存sku:{id}field 是仓库编码value 是可用库存数。一次扣减就是一条HINCRBY命令比读改写快一个数量级也天然避免并发覆盖。字段级 TTL 目前 Redis 官方还没有直接支持TTL 只能挂在整个 key 上。但可以采用拆 key 的策略变相实现不同更新频率的字段拆到不同的 Hash key各自设置不同的 expire。比如用户基础信息设置了 7 天过期用户的实时登录状态单独一个 key设置 10 分钟过期。这个思路本质上是把字段生命周期从逻辑设计挪到了 key 维度从而绕开限制。3.3 二级索引和关联查询的取巧方案Redis 的 Hash 本身不提供二级索引但可以配合 Set 或 ZSet 在业务侧维护索引关系。最经典的组合是Hash 存属性 Set/ZSet 存索引# 存用户属性 HMSET user:10001 name 张三 age 30 city 杭州 # 存城市索引 SADD idx:city:杭州 10001 # 按注册时间排序的索引 ZADD idx:reg_ts 1710000000 10001查询杭州的用户有哪些先查SMEMBERS idx:city:杭州拿到 ID 列表再管道批量HMGET取详情。这种方式在小规模数据下性能很可观而且实现简单不用引入搜索组件。要注意索引的一致性问题写用户属性时索引的更新要放在同一个事务或者 Lua 脚本里避免属性更新成功、索引没更新的情况。我顺手分享一个自己常用的 Lua 脚本片段保证属性更新和索引维护原子执行-- KEYS[1] 用户Hash key -- KEYS[2] 城市索引key -- ARGV[1] 用户ID -- ARGV[2] 城市 -- ARGV[3] 年龄 if redis.call(EXISTS, KEYS[1]) 0 then redis.call(HSET, KEYS[1], city, ARGV[2], age, ARGV[3]) redis.call(SADD, KEYS[2], ARGV[1]) return 1 end return 03.4 对象序列化选型别为了省事把所有东西都塞JSONHash 的 field 和 value 本质上都是字符串但你在写入前怎么序列化会直接影响内存和 CPU。纯短字符串、数字类型的属性直接作为 field/value 存储不要 JSON 化。长文本属性可以考虑压缩后再存比如 gzip 或者 snappy看 CPU 成本和内存成本怎么权衡。内嵌对象先用 protobuf / msgpack 序列化成二进制再塞进 field。请在字段命名上加上_bin后缀方便排查时知道这个字段是二进制直接看会乱码。这里有一个常见的坑很多人图省事把整个对象 JSON 序列化之后当作 Hash 的单个 value 存入一个 Hash 只有一个 field。这样做的后果是你完全没有利用 Hash 的字段级能力内存上也退化成和 String 差不多的形态。一句话Hash 的价值在于拆你不拆就别用 Hash。4. 高频读写场景的标准姿势HSCAN、管道、小key的坑对象存储只是 Hash 的静态能力真正的实战功力要看在高频读写下能不能玩明白。这一节讲我在生产环境里经常踩的几个细节都是常规文档里不会写的东西。4.1 HSCAN 的正确打开方式cursor不是页码有 Big Key 需要遍历 Hash 的所有 field 时HGETALL会把所有数据一次性捞出来在大 field 数量下极容易阻塞 Redis 线程。正确操作是用HSCAN做增量遍历HSCAN user:10001 0 MATCH field* COUNT 200第一次调用传 cursor 为 0返回的第二个元素是下一次迭代要用的游标直到返回游标为 0 表示遍历完成。这里特别强调一个误区COUNT不是每次返回多少条的硬性限制而是告诉 Redis每次从哈希桶里扫描多少个槽位的提示。如果 listpack 编码HSCAN 一次就能返回全部 fieldCOUNT 大小无关紧要如果已经升级为 hashtable而且桶的数量很大COUNT 调大可能单次返回更多元素但每次遍历的耗时也非线性增加。我实测下来COUNT 200到COUNT 500之间是比较稳定的区间大于 1000 在某些场景下会因为单次扫描桶数过多造成明显延迟。在生产环境走 HSCAN 做数据迁移时配合游标在客户端循环执行就行。每次迭代之间不需要刻意 sleep因为增量遍历本身就是分步执行的只要一次迭代的命令不大对 Redis 的阻塞非常有限。4.2 批量操作的正确姿势管道胜过循环对多个用户做HMGET时新手最常见的问题是循环三万个用户每个发一次请求。这种方式 RTT 的浪费极其可观本地测 1000 次串行HMGET要几百毫秒用管道批量打包之后降到十毫秒级别。// 批量读取用户基本信息的管道姿势 ListObject results jedis.pipelined(redis - { for (Long userId : userIds) { redis.hmget(user: userId, name, age, city); } });管道的原理是把多条命令一次性写到 Redis服务端依次执行后合并返回省掉了每个命令的往返时延。注意两个边界管道内的命令数量不是越多越好单批建议控制在 1000 条以内避免返回结果一次性占用客户端大量内存管道也不能保证原子性中间某条命令失败不会影响其他命令执行如果需要原子性请用MULTI/EXEC事务或 Lua 脚本。4.3 小key大field的取舍什么时候别用HashHash 也不是万能的。如果单 key 只有两三个字段但字段值非常大比如存了序列化后的图片 base64这时候 Hash 的 listpack 优势荡然无存反而因为 field 的存在多了额外的元数据开销。这种场景下直接用 String 更合适。判断标准很简单单 key 字段数小于 5 且字段长度大于 1KB选 String字段数较多、字段短、访问频繁选 Hash。这个边界在 7.x 的 listpack 编码下尤其明显因为字段长度一旦超过hash-max-listpack-valuelistpack 编码会整体失效内存反而比 String 高。4.4 使用hash做计数器组和限频除了对象存储Hash 在聚合计数上的表现也很优秀。比如统计一个接口按分钟维度的调用次数HINCRBY metrics:api:getUser 202405011200 1又比如记录一个用户当天各个行为的发生次数HINCRBY user:action:10001 click 1 HINCRBY user:action:10001 share 2一个 Hash 聚合了一组计数器比给每个计数器单独建 String 能节省大量 key 数量Scan 时也更有条理。限频场景可以配合EXPIRE实现滑动窗口不过要精确的滑动窗口还是建议用 ZSetHash 更适合当天累计这种粗粒度计数。5. 架构选型与集群设计Hash在真实部署里的边界标题最后四个字是架构选型这意味着 Hash 不仅仅是一个 API 层面的数据结构它在不同部署架构下的表现差异非常大。我聊几个生产环境的真实场景。5.1 单机部署下的内存和淘汰策略考量单机 Redis 是入门最常见的部署方式这时候 Hash 的内存效率直接跟你的预算挂钩。前面已经算过listpack 编码能省内存所以单机场景下小字段 Hash 对内存友好。但注意淘汰策略如果用的是allkeys-lruHash 作为整体被淘汰不会只淘汰它的某些 field。如果你的对象里有热属性和冷属性之分冷属性全放在一个大 Hash 里会拖累整个 key 的活跃度。解决方案仍然是拆分热属性一个 key冷属性一个 key淘汰粒度细化。5.2 集群模式下的Hash使用与hash tag陷阱Redis Cluster 的 key 分布在 16384 个 slot 上多 key 操作要求这些 key 在同一个 slot 上。但一个 Hash 的所有 field 天然在同一个 key 上所以 Hash 内部的字段更新不受槽位限制这是 Hash 相对 String 多 key 操作的一个隐性优势跨多个 key 的批量读写你可以借助 hash tag 手动把归属 control 到同一个 slot。HMSET user:{10001} name 张三 HMSET order:{10001} orderId 202405001用花括号圈定 hash tag两个 key 的 slot 都由10001计算多 key 的 Lua 脚本和事务就能在集群模式下合法执行。这个技巧在迁移和扩展时尤其重要否则临到集群环境才发现跨 slot 命令被拒事故就大了。集群模式下还要关注另一个问题单个 Hash 的 field 数量过大会导致这个 key 的数据在节点间搬迁时变成巨大的传输单元。Redis Cluster 迁移的粒度是 key你要移动一个 500MB 的 Hash key迁移期间该 key 所在节点的大量读写都会受影响。所以即便官方对单个 value 的限制是 512MB我也不建议任何 Hash 膨胀到这个量级。生产实践中超过 10000 个 field 的 Hash 就应该考虑拆分或归档。5.3 Hash做中间件分布式锁、布隆标记、限流顺着热搜词里redis做中间件这个点多说几句。Hash 不只是业务对象容器在很多中间件方案里它都能找到自己的位置。分布式锁的经典实现是SETNX但想要可重入用 Hash 可以优雅解决# 加锁客户端ID存field重入次数存value HSET lock:order:202405001 client-A 1 # 重入 HINCRBY lock:order:202405001 client-A 1这样同一个客户端重入时次数累加释放锁时递减到 0 才删除。相比 String 锁Hash 把谁持有锁持有几次两个状态放在一个 key 里管理信息更完整也方便排查死锁。缓存穿透治理里Hash 也能做空值标记。布隆过滤器是标准答案但在业务量不大、不想引额外组件的场景可以用一个 Hash 记录已知不存在的keyHSET null:mark user:9527 1查询时先看这个 Hash 里有没有对应的 key有就直接返回空避免打到数据库。代价是这个 Hash 可能持续膨胀需要定时清理或者控制总量。5.4 Hash和OSS对象存储定位不是一回事注意区分原文里对象存储的含义这里指的是业务对象模型Object不是文件对象存储OSS。在我们的技术讨论范围内Redis Hash 面向的是热数据的字段级存取OSS 面向的是海量文件的持久化存储两者不是替代关系而是互补关系。我的建议是Redis Hash 放需要毫秒级访问的元数据和热数据OSS 放大文件本体。一个典型组合是 OSS 存图片Redis Hash 存图片的缩略图 URL、上传时间、访问统计等元数据读取时先打 Redis命中就直接返回没命中再去 OSS 获取后回填。5.5 持久化和复制机制下的Hash注意事项聊到架构层面持久化也绕不开。Hash 在 RDB 快照中的序列化在 7.x 之后更加紧凑因为 listpack 结构可以直接整块落盘在 AOF 重写时一个 Hash 的多个HSET命令会被合并为一条HMSET有效减小 AOF 体积。这个合并逻辑是 Redis 自动完成的对业务透明。但有一个坑要注意如果 Hash 非常大比如数十万 field无论是 RDB 快照还是 AOF 重写处理单个 key 的耗时都会明显地拖慢 fork 子进程或主线程的写操作。我遇到过一次 RDB 持久化延迟飙升排查后发现就是一个大 Hash 在序列化上消耗了好几秒。这类问题的解法不是调持久化参数而是前面反复建议的拆分把大 Hash 拆成多个小 Hash每个 key 的序列化时间自然降下来。主从复制的场景下Hash 的增量传播也有特殊性HINCRBY这类命令会原样传播到从节点从节点执行同样命令。如果业务有从节点读取的老旧数据问题要注意 Hash 的更新是字段级的从节点可能短暂出现字段间状态不一致。当然 Redis 的复制本身是最终一致的这个窗口一般非常短但如果你在从节点做的是跨字段的关联计算还是需要留意读取时的快照一致性设计。6. 几条接地气的实践清单这一节算是我把上述内容打散后的个人总结不太成体系但都是在实际项目里验证过的操作准则。每条背后都对应着真实踩过的坑整理成清单方便用。优先用 Hash 存对象但按字段访问频率拆 key。一个 user 对象拆成 basic、stats、ext 三个 Hash比一个大 Hash 更可控淘汰粒度和 TTL 都能分开管理。写多读少的高频字段用 Hash 的 HINCRBY 做原子累加。别用读改写这不是性能问题是并发正确性问题。big key 的判定标准不只是 value 体积。field 数量大于 10000 的 Hash不管 value 多小都要有拆分预案。可以用DEBUG OBJECT查看实际编码和内存占用来判断。7.0 以上的 listpack 编码才是香饽饽。升级 Redis 版本除了新特性更实际的收益是 ziplist 的连锁更新问题被 listpack 干掉了Hash 在短字段场景的内存表现更稳定。管道优先事务慎用Lua 兜底。大批量读取走 pipeline需要保证多条命令原子性时优先 LuaMULTI/EXEC只适合不需要依赖中间结果的简单事务因为 watch 机制和集群模式的限制都不少。集群环境记得用 hash tag 控制槽位。多 key 操作能不能成功取决于你有没有在 key 里加{}设计 key 命名时就该把业务关联性考虑进去。对象序列化别再 JSON 一把梭了。对单字段的短值直接存对嵌套对象用 protobuf/msgpack对需要 gzip 的字段单独压缩。序列化格式同样影响内存和读取性能。用 Hash 做中间件状态存储要设置上限和清理策略。比如空值标记 Hash、限流计数 Hash它们会持续增长必须有配套的过期或者裁剪逻辑否则 Redis 内存迟早被这些隐形状态拖垮。这套实践清单是我在多个项目里反复打磨出来的不一定对每一套业务都完全适用但作为基线参考是够用的。Hash 这个数据类型不复杂真正的复杂度在于什么场景该用、用到什么程度、边界在哪里这些判断打磨明白了Redis 的架构设计基本就有谱了。最后再分享一个小技巧。线上排查 Hash 的编码和内存时用DEBUG OBJECT能看到encoding:listpack还是encoding:hashtable但注意这个命令在集群模式某些沙箱环境会被禁。如果看不到就用MEMORY USAGE key观察单个 key 的实际内存配合 field 数量变化做趋势判断。我见过不少团队把MEMORY USAGE当监控指标却没意识到它的计算本身有 O(N) 成本在大 key 上不要高频调用只做周期采样就够了。
返回列表