ARTICLE DETAIL

资讯详情

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

Redis Hash类型底层编码与实战优化指南

Redis Hash类型底层编码与实战优化指南 很多人聊Redis的value类型张口就是String、Hash、List、Set、ZSet五兄弟但一问到“hash底层到底是什么编码”“什么时候该用hash什么时候不该用”现场往往就冷下来了。我这些年排查线上问题、做缓存治理发现hash才是日常被低估最多的类型——存对象、做计数分组、实现分布式锁它全都能扛但用不好也最容易踩坑。这篇文章就专门把hash的value类型和它的编码方式讲透从ziplist/listpack到hashtable再配上一套可以直接抄的实操方案适合正在用Redis做缓存的人、准备面试的人也适合想省内存但不知道从哪下手的人。先说清楚一个容易混淆的点业务上说的“hash”是一种数据结构key下面挂着一堆field-value对而Redis底层说的“哈希表hashtable”只是hash类型的一种编码实现。这两者不是一回事很多教程把它们混在一起讲导致看的人越看越乱。下面我按“先业务、后底层、再实战”的顺序拆开聊。1. 为什么hash值得单独拿出来聊1.1 业务里最常见的几种hash用法hash在业务里出镜率极高我随便举几个实际场景。对象缓存是最典型的。拿用户信息来说一个用户有昵称、等级、积分、注册时间、最近登录时间一堆字段。如果用String存就得把整个对象序列化成一大段JSON或者二进制每次改一个积分字段要把整个对象读出来、反序列化、改掉、再序列化、再写回去——CPU和带宽都白花。用hash就清爽得多用户ID当key字段名当field字段值当value改积分就HINCRBY改昵称就HSET完全不影响其他字段。购物车也是hash的经典舞台。以用户ID为key商品ID为field商品数量为value加购、改数量、清空某个商品命令都是现成的。比起用List存一堆商品ID再去查详情不知道省了多少次网络请求。做分布式锁的Redisson底层也是hash。它在hash里记录“哪个线程持有锁”和“重入了几次”这样锁的可重入、锁续期、释放锁才能做得精确。很多人只知道Redisson用了hash却没想过为什么其实就是因为hash天然支持“一个key下挂多字段”的语义——锁的持有者是一个字段重入次数是另一个字段一次锁操作可以原子地更新它们。还有一类是计数器分组。比如按天统计每个接口的调用次数可以建一个key叫stat:20240601每个接口路径是一个field调用次数是value每天一个key第二天自然切新key。这种场景用String做计数器只能一个接口一个keykey数量会爆炸用hash一个key就全装下了还能用HGETALL一次性把当天的统计结果拉出来。1.2 先分清业务hash和底层hashtable编码很多初学者看源码或看面试题时会懵“hash类型底层不是哈希表吗为什么还有编码方式”这里要掰开揉碎讲清楚。Redis里每个key的value在真正存储时并不一定按你想象的结构原样存。Redis对同一个数据类型准备了多种内部编码encoding目的是在不同数据规模下都尽量省内存、保性能。hash类型在底层有两类编码一类是紧凑列表7.0之前叫ziplist7.0之后叫listpack一类才是真正的hashtable哈希表。换句话说业务层的hash是一个逻辑结构底层编码是物理实现。字段少、值小的时候Redis用紧凑列表硬存省内存字段多、值大了再切换成hashtable保证读写还是O(1)。这个“先紧凑、再切换”的设计是Redis内存优化非常核心的思路理解它对后面排查内存问题特别有帮助。1.3 和String类型相比hash到底划算在哪这问题面试常问实际选型时也绕不开。对比分三块看。内存方面字段少的小hash用listpack/ziplist存每个字段的额外开销很小整体比String存JSON要省。但如果字段很多、字段名很长hash要把每个字段名都存一份此时String存JSON反而更省。所以网上说“hash一定省内存”是不严谨的得看字段数量和字段名长度。操作粒度方面hash可以只读一个字段、只改一个字段String一次操作就得整存整取。这点在实际业务里体验差异巨大——更新一个用户的积分String方案会产生读放大和写放大hash方案几乎不产生额外流量。过期策略方面String整个key过期很方便hash只能对key整体设置过期时间没法单独让某个field过期。很多业务在这块踩坑想把session的某个字段设成5分钟过期结果整个hash也没了。这种情况要么拆key要么在字段值里塞时间戳由应用层判断。2. hash的两种编码方式紧凑还是扩散2.1 认识Redis的object encoding机制在深入hash编码前先认识一个命令OBJECT ENCODING。它返回一个key当前内部编码是排查一切Redis存储问题的基础工具。比如执行OBJECT ENCODING myhash返回可能是listpack也可能是hashtable取决于这个hash当前的数据形态。Redis所有数据类型都有这类编码切换机制但hash可能是切换得最频繁、最容易被忽视的一个。我用个生活化的类比就像行李箱打包。东西少的时候你把衣物一件件压实、塞进一个小包空间利用率最高东西多了塞不下了才换成带分层隔断的大箱子拿取方便但箱体本身就占地方。Redis对hash也是这套逻辑小数据用紧凑的“压缩包”大数据才换“工具箱”。2.2 ziplist时代为什么Redis用紧凑列表存小hash7.0之前小hash的编码是ziplist。这个结构在Redis里资历很老本质是一块连续内存上面按顺序排列着一串元素每个元素可以是字符串或整数。ziplist最绝的是它不维护指针元素之间靠“前一个元素的长度”和“自己的长度”推算位置内存里几乎没有任何冗余。一个只存两三个字段的hash用hashtable存的话光两个entry的指针、键值、哈希表桶位就要吃掉一大块内存用ziplist可能几十个字节就搞定了。对小hash来说ziplist的内存优势能达到数量级。但ziplist有个著名痛点级联更新。因为每个元素开头要记录“前一个元素的长度”prevlen如果中间某个元素变长后面所有元素的前置长度字段都可能跟着变Redis只能一个个往后挪内存。最坏情况下一次插入触发O(N)的连锁移动。数据量小的时候无所谓数据量大了就是灾难。这也是为什么它只适合小hash而不是所有hash。2.3 listpack取代ziplist修复级联更新问题7.0开始Redis把小hash的默认编码从ziplist换成了listpack。很多老资料还讲ziplist看得人一头雾水其实两者定位相同listpack就是ziplist的继任者。listpack的设计目标是彻底干掉级联更新。它的每个元素不再记录“前一个元素多长”而是记录“当前这个元素总共多长”这样单个元素长度变化就不会波及前后邻居。代价是整体多了些长度字段的重复信息但换来的是更新操作最坏情况从O(N)降到了O(1)。对小hash这种读写频繁的场景来说这个交换非常值。在我实际观察里7.0之后新写的小hash基本都是listpack旧版本创建的ziplist在迁移升级后也不会自动转成listpack要等key被重写或重建才会变。所以你线上如果看到某个老key还是ziplist别慌这是正常现象。2.4 hashtable字段多、值大时的最终形态当hash长到一定规模Redis就把它切成标准的hashtable编码。这个结构就是教科书里的哈希表一个数组加一串链表通过哈希函数把field映射到桶位理想情况下读写都是O(1)。hashtable的好处是字段多了以后查询依然快field的增删改不会引发级联移动。坏处是内存开销大每个field都要额外存指针、哈希表桶位、链表节点Redis内部对hashtable还做了渐进式rehash也就是扩容时不是一次性全部搬迁而是一次操作搬几个桶分批完成避免大key搬迁时阻塞服务。这里有个认知要纠正很多人以为hashtable编码的hash才是“正常的hash”listpack只是临时态。实际上对绝大多数业务hashlistpack才是常态它既能省内存字段个数在几百以内时性能也完全不差。真正该转hashtable的是那种字段特别多、单个field特别大的hash转过去之后空间换时间才有意义。2.5 一张表看懂listpack与hashtable的取舍对比维度listpack含ziplisthashtable内存占用连续内存、无指针小数据极省每个字段有额外指针和桶位开销内存大读写性能字段少时接近O(1)但字段多了要遍历字段多时稳定O(1)更新性能元素长度变化不影响邻居listpack哈希操作本身O(1)rehash时有批量搬迁适用规模字段少、值小默认128个/64字节内字段多、值大超过阈值后自动切换遍历方式顺序遍历无哈希冲突问题遍历顺序和哈希桶位置有关不稳定这张表在面试时可以直接甩出来当结论。实际项目里如果你的hash字段数长期在几十个以下、单个value在几十字节以内listpack就是最优解不用人为干预。3. 编码切换的临界点从listpack到hashtable3.1 转换阈值怎么看两个核心配置hash从listpack转hashtable由两个配置决定。7.0之前这两个配置叫hash-max-ziplist-entries和hash-max-ziplist-value7.0之后改名为hash-max-listpack-entries和hash-max-listpack-value。默认值分别是128和64。含义是当hash的字段数不超过128、且每个字段名和字段值的长度都不超过64字节时用listpack一旦字段数超过128或任何一个字段名/值的字节长度超过64编码立即切换为hashtable。注意“且”这个字两个条件必须同时满足。哪怕字段总数只有10个但其中一个value是300字节的JSON串这个hash照样被转成hashtable。实际业务里最常见的触发切换原因不是字段太多而是有人把一个大字符串塞进了hash字段。比如在hash里存一段用户头像Base64、一坨日志文本、一个完整JSON对象单个value动辄几百上千字节直接把hash干穿成hashtable内存飙升。这个问题我在第5节会专门讲怎么治。3.2 用object encoding实测一轮编码变化光看配置不直观我带着你在redis-cli里实测一遍。先创建一个字段少、值小的hash127.0.0.1:6379 HSET user:001 name tom age 18 city beijing (integer) 3 127.0.0.1:6379 OBJECT ENCODING user:001 listpack能看到返回了listpack说明字段数量和长度都在阈值内。接下来我往同一个key里塞一个超长字段127.0.0.1:6379 HSET user:001 bio 一段超过64字节的文本 (integer) 1 127.0.0.1:6379 OBJECT ENCODING user:001 hashtable注意整个key的编码变成了hashtable不只是那个长字段变了。这就是hash编码切换的特点一荣俱荣一损俱损一个字段超长全key升级。再看字段数量触发的场景不用手动敲128条命令写个循环for i in $(seq 1 200); do redis-cli HSET big:hash field:$i value:$i /dev/null; done redis-cli OBJECT ENCODING big:hash写入200个字段后返回同样是hashtable。这个实测教会我们一件事写hash前先估算一下字段数和最大字段长度不要让编码在不知不觉中切换。3.3 配置改动的坑已存在的key不会自动转换有些同学看到这里会问那我直接把hash-max-listpack-entries调大比如调到1024小hash不就不会转hashtable了吗想法很好但有个关键细节修改配置只对“新建的key”生效对已经存在的key不生效。也就是说你线上已经有一批被转换成hashtable的hash调完配置它们还是hashtable不会自动回流到listpack。要让它们变回紧凑编码只能把key删掉或者重写比如先RENAME再重新写入或者直接删了让业务重建。另外调大阈值不全是好事。listpack本质是连续内存里的顺序存储字段数从128调到1024意味着单个hash可以膨胀到上千个字段此时listpack的查询性能会明显退化因为查找一个field需要在线性结构里扫描。我见过有人图省内存把阈值调到5000结果有个大hash的每次HGET都慢了几毫秒得不偿失。阈值不是越大越好要跟着业务规模走。我的建议是如果hash字段数常年稳定在50个以内就别动配置默认128已经够用如果字段数确实多优先考虑业务拆分而不是把配置顶上去。4. hash命令实操与场景落地4.1 高频命令速览与使用建议hash的命令不多但细节不少。我把高频命令整理成一张表。命令作用备注HSET key field value [field value ...]设置一个或多个字段4.0后完全取代HMSETHGET key field获取单个字段字段不存在返回nilHMGET key field1 field2 ...获取多个字段不存在的字段返回nilHDEL key field [field ...]删除一个或多个字段返回实际删除个数HEXISTS key field判断字段是否存在存在返回1否则0HLEN key统计字段数量比HKEYS再数一遍快得多HKEYS key获取所有字段名大hash慎用HVALS key获取所有字段值大hash慎用HGETALL key获取所有字段和值大hash慎用详见4.3HINCRBY key field increment字段值增加整数原子操作HINCRBYFLOAT key field increment字段值增加浮点数原子操作HSCAN key cursor [MATCH pattern] [COUNT count]增量遍历字段大hash专用这里有个很实用的经验很多人还在用HMSET其实Redis 4.0以后HMSET已经没存在意义了HSET完全兼容它还能一次设多个字段、返回新增字段个数。新代码直接全用HSET就行不用纠结。还有个小坑HEXISTS返回的是1或0但很多人拿它当布尔值用没问题HLEN在hash不存在时返回0在key存在但不是hash时返回错误。写代码时catch住这种类型错误别让Redis的异常把业务逻辑带崩。4.2 HINCRBY做计数器分组hash做计数器分组的优势用HINCRBY才能体现得最彻底。比如统计每个接口的调用次数我习惯这样设计HSET stat:20240601 /order/create 0 HINCRBY stat:20240601 /order/create 1第一次调用设个初始值0后面每次调用加1。这个设计的好处有两个一是所有接口的统计都收敛在一个key里监控平台拉数据时一条HGETALL就全出来了二是HINCRBY是原子操作即使并发下也不会丢计数。很多人用String做同样的事会面临key数量爆炸的问题比如几百个接口一天就要几百个key一周就是几千个。用hash按天聚合一年也才365个key管理成本完全不是一个量级。需要注意的一点HINCRBY操作的值必须是整数如果字段值已经被存成非数字的字符串比如“abc”执行HINCRBY会直接报错。写业务代码时要么提前初始化要么catch住这个异常避免一个脏数据把计数链路打挂。4.3 读取注意HGETALL不是万能的这是hash使用里最容易被忽视的性能隐患。HGETALL一次把所有字段和值都返回给客户端数据量大时会有两个问题。第一是网络传输一个200字段的hash每个字段名加值差不多几十字节总响应也能到十几KB在高并发下带宽消耗明显第二是Redis单线程模型下的耗时生成这个大响应本身要时间如果key又是热点key会导致后续命令排队变长拖慢整个实例。我一直跟团队强调的原则是能HMGET就不要HGETALL能HSCAN就不要HGETALL。读取少量已知字段时直接HMGETHMGET user:001 name age需要遍历全部字段做统计或迁移时用HSCAN配合游标import redis r redis.Redis(host127.0.0.1, port6379) cursor 0 while True: cursor, data r.hscan(stat:20240601, cursorcursor, count100) for field, value in data.items(): print(field, value) if cursor 0: break这段代码是redis-py里HSCAN的标准用法每次只取100个字段不会一次性拉爆内存。HSCAN的妙处在于它返回一个游标传回去继续迭代直到游标回到0表示遍历完成。4.4 字段级过期hash做不到的事hash的字段不能单独设置过期时间这是它的能力边界。很多人做缓存时想给hash里的某个field设TTL结果发现根本没有这种命令只能给整个key设EXPIRE。如果业务真的需要字段级过期我试过两种替代方案。方案一是拆key把需要单独过期的字段拆成独立的String key比如用户头像单独设过期时间用户其他信息放hash。缺点是碎片化多了一些key要管理。方案二是在字段值里写时间戳把value存成“过期时间戳 真实数据”比如1699999999:someValue读取时用Lua脚本或应用层判断时间戳是否过期过期就当字段不存在。这个方案不影响hash的紧凑结构但要自己在代码里处理过期逻辑还要小心时间戳解析的边界——时间戳不能带特殊分隔符格式要固定。我个人的习惯是能拆key尽量拆key时间戳方案只适合字段不多、过期逻辑简单的场景。原因是时间戳方案容易出bug比如多个服务对时间格式理解不一致小坑不断。5. 实战对比用hash做对象缓存5.1 一个典型的用户信息缓存纸上谈兵没意思我直接用一个真实项目里最常见的用户信息缓存来对比。假设用户对象有10个字段uid、name、level、exp、gold、vip_level、register_time、last_login_time、avatar_url、bio。用String方案一般会把整个对象序列化成JSON大概这样{uid:1001,name:sam,level:30,exp:1024,gold:500,vip_level:3,register_time:2021-05-01 10:00:00,last_login_time:2024-06-01 12:00:00,avatar_url:https://cdn.example.com/a.png,bio:hello}这段JSON往少了说也有300字节。用hash方案就是这个结构HSET user:1001 name sam level 30 exp 1024 gold 500 vip_level 3 register_time 2021-05-01 10:00:00 last_login_time 2024-06-01 12:00:00 avatar_url https://cdn.example.com/a.png bio hello注意avatar_url和bio这两个字段URL加一段文本很容易超过64字节一旦超了hash就会被转成hashtable。所以这个例子非常适合用来观察编码切换对内存的真实影响。5.2 写入与更新的推荐套路写入用户信息时我的习惯是先判断key是否存在存在就走更新逻辑不存在就初始化。用HSETNX可以避免覆盖HSETNX user:1001 name sam HSET user:1001 level 30 gold 500HSETNX只在字段不存在时才写入对敏感字段做保护。整体更新用一次多字段HSETHSET user:1001 level 31 gold 600 vip_level 4更新部分字段时最好的习惯是只传要改的字段不要每次把整个对象重新塞一遍。这样既减少网络流量也避免触发不必要的编码变化——如果你每次都把avatar_url这种长字符串写一遍hash很容易被推向hashtable。读取侧要看业务需要。展示用户基本信息只要name和level就用HMGET取两个字段。后台管理系统需要看全部字段才用HGETALL。实时性要求高的积分变动用HINCRBY直接改不要读出再写回。5.3 StringJSON和hash各能省多少内存这个对比我没法给出精确数字因为不同Redis版本、不同字段分布差异很大。但按我实测的经验可以给个量级概念。在7.0环境下一个10字段、字段名较短、value较短的小hash用listpack存内存占用大概在160到200字节之间同样内容用String存JSON光JSON文本就300字节还要加上String类型本身的key开销整体多出30%到50%内存。字段越多、字段名越短hash的优势越明显。但反过来如果hash的字段名很长比如一堆业务含义复杂的字段名每个20字节以上且value普遍很短listpack里每个字段名都要完整存一遍内存优势就会被吃掉。另一种极端是单个value特别大hash被迫转到hashtable哈希表的指针和桶开销会让hash反而比String更费内存。所以真正的结论是字段少、字段名短、value小用hash字段多、字段名长或者value很大用StringJSON可能更合适。没有银弹只能在具体场景里量一量。5.4 优化建议别把JSON串塞进hash字段这是我见过最多人犯的错误拿hash当String用往某个字段里塞一整个JSON对象。比如HSET user:1001 profile {name:sam,level:30,exp:1024,vip_level:3}这个JSON一旦超过64字节整个hash从listpack变hashtable内存开销立刻上涨。而且这个字段的内部结构对Redis完全不可见Redis没法在字段层面做任何优化你还不如直接用String。正确做法是把JSON里的字段打散成hash的独立字段。如果JSON里有嵌套结构比如地址、爱好列表可以用分隔符拼成字段名比如address:city、hobby:1或者干脆把嵌套部分单独用另一个key存。打散之后每个字段才能享受紧凑编码也才能用HINCRBY做计数、用HMGET做部分读取。如果某些字段实在没法打散比如一个必须整体读写的配置项那就别放进hash单独用String存。hash只放那些语义独立、适合字段级操作的属性两者各干各的互不干扰。6. 常见问题与排查实录6.1 小hash内存却很大先检查encoding我排查过不少“Redis内存无故上涨”的问题最后发现元凶都是hash被转成了hashtable。有个案例印象很深业务同学建了一批用户hash每个hash只有10来个字段但其中一个字段存的是用户头像URLURL带缓存参数后长度到了100多字节结果这上百万个key全部变成hashtable内存一下子多出来好几个G。排查套路很简单三步走。第一步用OBJECT ENCODING user:001看这个hash当前是什么编码第二步如果返回hashtable用DEBUG OBJECT user:001看这个key的serializedlength和实际内存占用和同规模listpack的key对比就知道多花多少第三步翻日志和代码找到是谁往hash里写了超长字段。修复动作一般是改代码把长字段从hash里挪出去然后删掉旧key重建。如果key是热点不能直接删就设个短暂的TTL让缓存自然过期重建。6.2 大hash操作慢HSCAN与拆分大hash慢的问题表现在两个方面一是HGETALL返回慢、带宽占用高二是针对单个字段的HGET也可能慢原因是这个key已经退化成hashtablerehash过程里有大量搬迁或者hash里的某个字段触发了需要遍历的路径。遇到大hash第一反应应该是用HSCAN替代HGETALL做全量操作前面代码示例已经给了。第二反应是拆key把一个上万的字段的hash按业务维度拆成多个小hash比如用户信息hash按用户ID段拆统计hash按天拆。拆key不仅能恢复listpack的紧凑编码还能避免单个key的流量热点。还有一招给热点大hash做本地缓存用定时任务把它们定期拉到应用内存里读多写少的场景很有效。但要注意缓存一致性最好配合版本号或时间戳判断。6.3 删除大hash要注意UNLINK很多人删除大key时直接用DEL这在Redis 4.0之前是没办法的事但4.0以后有了UNLINK可以异步删除。DEL删除一个几十万字段的hashtable单线程里要回收大量内存可能阻塞服务几十毫秒UNLINK把释放内存的动作丢给后台线程主线程几乎无感。我现在处理大hash清理一律用UNLINK除了释放速度快还能避免主线程卡顿。如果Redis版本低于4.0就只能用DEL这种时候我建议先分批把字段删掉比如用HSCAN循环后HDEL再DEL空壳key虽然慢但至少不阻塞。另外大key的内存碎片问题也不容忽视。哈希表释放后内存分配器不一定能立刻把空间归还给操作系统表现为Redis内存降不下来。这时候用MEMORY PURGE手动整理或者等操作系统自动回收不要一看内存没降就慌。6.4 速查表hash问题排查清单我把常遇到的hash问题整理成一张速查表排查时对着看能省不少时间。现象可能原因排查命令/工具处理思路小hash占用内存大编码被切换为hashtableOBJECT ENCODING找出超阈值字段拆走长字段重建keyHGETALL响应慢大key、网络包过大redis-cli --bigkeys改用HSCAN分页或拆分key删除hash时毛刺DEL同步阻塞慢日志改用UNLINK异步删除节点间内存不均大hash无法均匀分布redis-cli --bigkeys按业务维度拆hash避免单key过大字段更新丢失多个服务并发HSET覆盖业务日志用HSETNX、Lua脚本保证原子性修改配置后编码没变已存在key不自动转换OBJECT ENCODING删key或重写别指望配置即时生效内存降不下来内存碎片或淘汰延迟MEMORY PURGE手动整理或等待后台回收这张表我打印过一份贴在工位上排查线上问题时真能救命。最后再说点个人体会。我用了很久的Redishash一直是我心里“性价比”最高的类型——它把业务语义和底层存储优化结合得非常好的。但正因为它设计精巧用的时候更要清楚边界知道自己存的字段长什么样知道编码会在什么条件下切换知道什么时候该用listpack的紧凑、什么时候该用hashtable的稳定。如果你现在正准备优化Redis内存我的建议很直接先写个脚本把线上所有hash的OBJECT ENCODING扫一遍把那些“字段不多但编码已是hashtable”的key全找出来优先处理它们往往能挖出好几G的冗余内存。这个动作成本低、收益明显我一个人靠这个办法帮团队省过不少实例扩容费用。
返回列表