ARTICLE DETAIL

资讯详情

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

Redis Hash底层原理与实战:从编码到渐进式rehash

Redis Hash底层原理与实战:从编码到渐进式rehash 扯了多年的RedisHash这个数据结构我一直觉得是被很多人低估的类型。一说Redis数据类型String、List、Set、ZSet能聊半天轮到Hash往往是“哦就是存个对象用的”然后就没有然后了。真到面试或者线上排查问题时才突然发现自己对Hash的底层原理、适用边界、内存表现其实没吃透。这篇文章打算把Redis的核心数据结构Hash一次性聊透。我会从底层编码讲到渐进式rehash从常用命令讲到Spring Boot里的实操案例最后再整理一些我自己踩过的坑。目标很明确看完之后你不仅能知道Hash是什么还能在选型、优化、排查问题时想清楚“为什么”。先提醒一句这篇文章不是官方文档的翻译。我会尽量用工程项目的实际视角来写适合刚入门想系统梳理的读者也适合写了好几年业务代码但一直没深究过底层机制的同学。1. Hash到底是什么结构1.1 一张用户信息表引发的思考假设你现在要缓存一个用户的信息包括uid、昵称、年龄、城市、签名。最直觉的做法是用String类型把整个对象序列化成JSON然后以user:1001为key存进去。读的时候取出来反序列化写的时候改哪个字段就整个对象重新序列化。这个方案在小规模、低频率更新的场景下完全没问题。可一旦更新频繁问题就来了。用户改个昵称你要把一整条大的JSON字符串读出来、反序列化、改字段、再序列化、写回去。不仅网络开销变大还容易在并发写的情况下覆盖掉其他字段的更新。这时候Hash就有它不可替代的价值。Hash在Redis里的模型很简单一个key下面是多个field-value对。也就是说user:1001这个key下面可以同时有nickname - 张三、age - 25、city - 上海这样的映射。它天然就是为“对象的字段级存取”设计的你只更新一个field时根本不用动其他field语义清晰性能也高。很多人第一次接触Hash时容易把它类比成关系数据库里的一行记录。这个类比大方向是对的但严谨地说Hash更像一个内嵌在Redis key里的二级哈希表——外层key定位对象内层field定位对象的某个属性。如果你熟悉Java可以把它直接理解为一个MapString, MapString, String在大多数客户端SDK中它的API接口也确实是这样暴露的。1.2 底层编码与渐进式rehashHash底层在Redis 7.0之后主要有两种编码方式listpack和hashtable。7.0之前对应的名字是ziplist本质上是一回事优化的方向都是把小的数据结构压缩成连续的内存块。先说listpack编码。当Hash里的field数量比较少、每个field和value的长度也比较短时Redis会选择用listpack来存。listpack是一块连续的内存空间每个元素紧密排列没有额外的指针、没有malloc碎片。它的优势显而易见内存占用极低而且因为内存连续性好遍历时的缓存命中率也高。你可以想想一个最多5个字段、每个字段加起来不超过几十字节的对象如果用真正的哈希表去存光节点指针和内存分配器带来的额外消耗可能就比数据本身还要大。当条件不满足时也就是field数量超过hash-max-listpack-entries默认值128个或者某个field的key或value长度超过hash-max-listpack-value默认值64字节Redis就会把这个Hash从listpack转换成hashtable。hashtable才是真正意义上我们平时说的哈希表它支持O(1)的field查找但代价是每个节点都包含指针、哈希值等额外元信息内存开销明显更大。这个转换是一次性的转换后就回不去了除非你手动把key删掉重新写入。所以设计field数量多的Hash对象时要提前想清楚这个对象到底会膨胀到什么量级不要以为Redis会一直替你压缩着。再说hashtable的rehash过程。Redis扩容时不会像Java的HashMap那样一次性把所有旧桶的数据重新哈希到新桶里因为Redis是单线程模型一次性扩一个大哈希表可能阻塞整个服务。于是它采用了渐进式rehash扩容时先申请新数组然后把旧数组里的元素分批、分多次地搬过去。每执行一次增删改查操作都会顺带搬一部分旧节点此外后台定时任务也在持续搬。这样一来一个大的rehash过程被分摊到了很多次正常请求中对延迟的影响变得很不明显。但这里面有个值得警惕的细节在rehash进行中读写操作需要同时访问新旧两个hash表查找某个field时先查新表查不到再去旧表。这个机制平时看不太出来但如果你在某个超大Hash上执行HGETALL或HKEYS而且恰好赶上rehash阶段那阻塞性地遍历可能会比平时慢得多。所以线上对Hash进行全量操作时要时刻把“渐进式rehash”这个背景记在心里。2. Hash的常用命令与实操要点2.1 高频命令速查表Redis Hash的命令数量不少但真正在业务里高频使用的其实就那么几个。我先把命令整理成一个速查表方便你复制到自己的笔记里。命令作用复杂度使用场景HSET key field value设置单个字段的值field不存在则新建O(1)写入或更新字段HGET key field获取单个字段的值O(1)读取单个属性HMSET key field value [field value ...]批量设置多个字段O(N)N为字段数批量写入对象HMGET key field [field ...]批量获取多个字段O(N)N为字段数只读对象的部分属性HDEL key field [field ...]删除一个或多个字段O(N)N为删除字段数删除对象的某个属性HEXISTS key field判断字段是否存在O(1)逻辑判断HLEN key获取Hash中字段的数量O(1)判断对象大小HKEYS key获取所有字段名O(N)需要所有field名时HVALS key获取所有字段值O(N)只关注值集合时HGETALL key获取所有字段和值O(N)获取整个对象慎用大keyHINCRBY key field increment给整数字段增加指定值O(1)计数器、库存等场景HINCRBYFLOAT key field increment给浮点字段增加值O(1)金额、评分等场景HSCAN key cursor [MATCH pattern]游标式遍历O(N)但每次有限安全遍历避免阻塞这个表格里我特别想标注一下HMSET。Redis 4.0之后官方其实已经不推荐使用HMSET了因为HSET本身支持可变参数HSET key f1 v1 f2 v2 f3 v3和HMSET key f1 v1 f2 v2 f3 v3效果完全一样。不过很多老项目还在用HMSET这个不影响使用只是新项目里没必要再做区分。2.2 命令行模拟用户信息缓存全流程纸上谈兵没有意义直接用命令行跑一遍完整的用户缓存操作看看Hash的数据持久性、局部更新和原子计数是怎么配合的。第一步写入一个新用户的基本信息 HSET user:1001 nickname 张三 age 25 city 上海 signature 码农一个 (integer) 4这里返回4表示成功新增了4个字段。如果某个field已经存在HSET会覆盖旧值并返回0所以返回值能帮你判断是新增还是更新这个特性在某些幂等设计里非常有用。第二步读取部分字段而不是全部 HMGET user:1001 nickname city 1) 张三 2) 上海注意这里我只取nickname和cityage和signature根本不出现在结果里。这就是Hash相对String存JSON的核心优势——按需读取不把整个对象拉出来。第三步做一次局部更新 HSET user:1001 age 26 (integer) 0返回0因为age字段已经存在只是更新了值。这个操作不会影响nickname、city、signature数据库层面不需要锁Redis单线程也不会出现字段覆盖的问题。第四步统计字段数量和判断字段存在性 HLEN user:1001 (integer) 4 HEXISTS user:1001 age (integer) 1 HEXISTS user:1001 email (integer) 0第五步删掉一个字段比如用户清空了签名 HDEL user:1001 signature (integer) 1第六步利用HINCRBY做计数。比如用户登录一次登录次数加1 HSET user:1001 login_count 0 (integer) 1 HINCRBY user:1001 login_count 1 (integer) 1 HINCRBY user:1001 login_count 1 (integer) 2这个操作的原子性由Redis单线程天然保证多个客户端并发执行也不会出现丢计数的问题。这个特性在分布式场景里很有价值后面讲计数器时会再展开。如果只是想快速看一眼整个对象可以用HGETALL HGETALL user:1001 1) nickname 2) 张三 3) age 4) 26 5) city 6) 上海 7) login_count 8) 2返回值是field、value交替排列的。看起来很方便但必须提醒生产环境如果这个Hash的field数量很多HGETALL会一次性把所有数据都取回来轻则占用大量内存和带宽重则阻塞Redis线程导致其他请求变慢。正确做法是用HSCAN分批遍历后面我会专门讲。2.3 什么场景该选Hash而不是String这是我在很多团队里被反复问到的问题。数据都用String存JSON不就行了吗为什么非要单独搞一个Hash要回答这个问题得从读写模式来看。String存JSON最典型的痛点是“读多写少但每次写都要全量覆盖”。比如一个用户对象有10个字段前端改了个头像后端逻辑如果还是先GET再SET整个JSON那一次更新就变成了两次网络往返还有并发覆盖的风险。更糟糕的是如果两个请求分别改了不同字段但都按GET-SET方式操作后写的那次会把另一个请求的修改直接冲掉。Hash的做法则完全不同。每次更新只操作自己要改的那个field其他字段天然不受影响。读的时候也不必把整个对象查出来只要按需取两个字段就行。还有一个容易忽略的点是过期时间。String存JSON时整个key只有一个TTLHash整体也只有一个TTL但单个field没有独立的过期时间。这意味着如果你需要“对象内部字段各自有不同的过期策略”那Hash并不是合适的选择Semantics上就不是为这个设计的。这时候可以拆成多个独立key或者用String加lazy delete逻辑得靠应用层去实现。另外还要对比一下Hash和多个独立String。从内存角度来说用多个String存一个对象的多个属性每个key本身就有几十字节的开销加上Redis内部的dict entry、过期时间等元数据攒多了很可观。Hash把这些属性收拢到一个key里listpack编码下内存优势非常明显。但一旦转成hashtable每个field又成了独立的dict entry内存优势就逐渐消失。这个内存分界线我建议你记下来如果你一个对象有十几个字段、字段值都比较短用Hash大概率比散落的多个String更省内存如果字段特别多或者字段值特别长Hash反而不一定占便宜。3. 实战Spring Boot中操作Redis Hash3.1 环境准备与基础配置理论聊了不少接下来落到工程实践。最常见的组合是Spring Boot加Spring Data Redis这也是Java后端最普及的技术栈。我直接用一套可复用的配置来讲你照着就能跑。先启动Redis。如果你本地不方便装Redis可以直接用Docker一条命令搞定docker run -d --name redis-local -p 6379:6379 redis:7.2 --appendonly yes这里我特意加了--appendonly yes开启AOF持久化。开发环境无所谓但如果你在测试Hash的计数、锁等功能时不小心把容器删了至少数据还能从AOF恢复省得每次重启都从头造数据。Spring Boot项目里引入依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency需要说明一下版本选择。Spring Boot 2.x对应Lettuce连接器Spring Boot 3.x也是默认Lettuce。Lettuce基于Netty线程安全性能足够不需要像Jedis那样额外搞连接池当然如果你有特殊需求也可以配Jedis。然后写一个配置类核心是解决序列化问题Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); // 使用String序列化key和Hash的field避免二进制乱码 StringRedisSerializer stringSerializer new StringRedisSerializer(); Jackson2JsonRedisSerializerObject jsonSerializer new Jackson2JsonRedisSerializer(Object.class); template.setKeySerializer(stringSerializer); template.setValueSerializer(jsonSerializer); template.setHashKeySerializer(stringSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }这里有个非常典型的大坑如果你不设置HashKey和HashValue的序列化方式Spring Boot默认会使用JDK序列化。结果就是你在Redis客户端里看到的一堆形如\xAC\xED\x00\x05t\x00\x05nickname的乱码既没法调试也会白白浪费大量内存。看到这种乱码基本可以断定就是序列化器没配好。3.2 用户信息缓存的完整代码演示配置好了之后写一个操作Hash的服务。先说清楚这里直接用StringRedisTemplate其实更简单因为它key和value默认就是String序列化。但为了让你以后遇到对象存储时知道怎么处理我今要用RedisTemplate配合泛型转换来演示。先定义一个简单的用户对象public class UserProfile { private String nickname; private Integer age; private String city; private String signature; // getter/setter省略 }然后写一个面向业务的服务Service public class UserCacheService { Autowired private RedisTemplateString, Object redisTemplate; private static final String USER_KEY_PREFIX user:; // 写入用户信息分多个field存储 public void saveUserProfile(Long uid, UserProfile profile) { String key USER_KEY_PREFIX uid; HashOperationsString, Object, Object hashOps redisTemplate.opsForHash(); hashOps.put(key, nickname, profile.getNickname()); hashOps.put(key, age, profile.getAge()); hashOps.put(key, city, profile.getCity()); hashOps.put(key, signature, profile.getSignature()); } // 读取单个属性 public Object getUserField(Long uid, String field) { HashOperationsString, Object, Object hashOps redisTemplate.opsForHash(); return hashOps.get(USER_KEY_PREFIX uid, field); } // 批量读取多个属性 public ListObject getUserFields(Long uid, ListString fields) { HashOperationsString, Object, Object hashOps redisTemplate.opsForHash(); return hashOps.multiGet(USER_KEY_PREFIX uid, fields); } // 局部更新某个属性 public void updateUserField(Long uid, String field, Object value) { HashOperationsString, Object, Object hashOps redisTemplate.opsForHash(); hashOps.put(USER_KEY_PREFIX uid, field, value); } // 使用HINCRBY做原子计数 public long incrementLoginCount(Long uid) { HashOperationsString, Object, Object hashOps redisTemplate.opsForHash(); return hashOps.increment(USER_KEY_PREFIX uid, login_count, 1); } }这段代码你直接拿过去就能用。几个细节提一下我特意把key前缀user:单独定义成常量不要散落在各个方法里。否则以后改前缀要全局搜索替换很容易漏。HashOperations.increment的返回值是Long如果field的值原来不是纯数字它会抛异常。更新字段时用put内部会调用HSET命令。field不存在就新建存在就覆盖语义非常自然。如果是在内存或写操作频繁的场景下用redisTemplate.opsForHash()每次都要拼接key略显繁琐。Spring还提供了BoundHashOperations可以绑定一个key后续操作不再需要重复传keyBoundHashOperationsString, Object, Object boundOps redisTemplate.boundHashOps(user:1001); boundOps.put(nickname, 李四); boundOps.get(age);这种写法更适合在一个线程或方法里多次操作同一个Hash对象的场景代码会清爽不少。3.3 Hash在分布式锁与计数器中的玩法聊到Hash时绕不开一个叫作Redisson的框架。很多人都是通过Redisson知道Redis Hash还能实现可重入分布式锁的其实本质就是用Hash结构来记录当前持锁线程的信息。我记得Redisson的锁方案大概是这样key是锁的名字field是线程IDvalue是重入次数。加锁时执行HSET可重入时执行HINCRBY解锁时HDEL或DECR。之所以选择Hash而不是简单的SETNX是因为锁需要支持重入还得记录是谁持有这把锁释放时也要判断线程身份这些信息必须用一个结构装起来。如果你对分布式锁有兴趣Redisson已经把它封装得很好了直接引入依赖用就行不需要自己手写锁逻辑。我想说的是理解Hash之后再看Redisson的实现源码就没有任何神秘感了——它不过是用Hash天然支持“线程维度计数”这个特性把锁语义落地而已。除此之外Hash用于计数器的场景也很常见。比如一个对象有浏览数、点赞数、收藏数用三个独立String key存储散落且不好管理。放进一个Hash里三个字段分别维护一个对象一个key既有聚合语义又能独立更新。用HINCRBY做递增递减原子性有保障不需要应用层加锁。3.4 面试里关于Hash的几个高频考点因为热搜词里出现了大量和“Redis面试题”相关的内容我在这里顺便把Hash方向最常被问到的几个问题拎出来答一遍平时面试前默背一遍也够用了。第一个问题Hash和Java的HashMap有什么区别这个比较容易答。HashMap是进程内的Redis Hash是跨进程、跨机器的多个客户端可以并发访问。HashMap的扩容是创建新数组、全量rehashRedis是渐进式rehash不阻塞服务。HashMap不安全需要加锁或用ConcurrentHashMapRedis单线程的原子性让它天然线程安全。第二个问题为什么Redis小对象用listpack编码因为小的Hash如果用hashtable每个field都要单独分配内存还得存指针、哈希值内存浪费严重。listpack把数据连续存放压缩内存占用也提升了CPU缓存的局部性。第三个问题Hash的field可以设置过期时间吗不能。Hash整体可以设置EXPIRE但field本身没有TTL。如果面试官继续问那要实现字段过期怎么办你就说拆成多个key或者应用层惰性删除、定时清理。这几个点虽然看起来基础但很能看出一个人是不是真的看过源码。背答案没用最好自己用redis-cli实际体验一遍listpack转hashtable观察一下内存和性能的变化。4. 常见问题与排查技巧实录4.1 HGETALL大Key阻塞怎么办我遇到过一个真实案例。当时一个业务把用户的设备信息整个存进了Hash一个用户的device_list字段数量随着时间越积越多到了几万个field。某一天做了一个批量任务遍历所有用户并执行HGETALL统计信息然后Redis的慢查询日志就爆了最慢的一条操作卡了200多毫秒。因为Redis是单线程那几秒钟时间里所有的读请求都受到了影响。这个问题的根源是用了全量命令。HGETALL、HKEYS、HVALS都是“一次取全部”的命令复杂度O(N)。当N大到一定程度单线程模型下就会拖垮整个Redis实例。正确做法是用HSCAN游标式遍历。HSCAN每次只返回一部分数据并给你一个游标你拿着这个游标继续迭代直到游标回到0为止。下面是一个命令行示例 HSCAN user:1001 0 COUNT 100 1) 123 2) 1) field1 2) v1 3) field2 4) v2返回值第一项是下一次迭代的游标第二项是field-value交替排列的数据。把游标作为下一个HSCAN的参数继续调用一直到返回的游标是0就遍历完了。在Java里可以通过ScanOptions来实现ScanOptions scanOptions ScanOptions.scanOptions().match(*).count(100).build(); CursorMap.EntryObject, Object cursor redisTemplate.opsForHash().scan(key, scanOptions); while (cursor.hasNext()) { Map.EntryObject, Object entry cursor.next(); // 处理单个field } cursor.close();count(100)表示每次迭代大概取100个元素左右但这个数字不是硬性保证只是给底层一个提示。迭代期间如果Hash被其他客户端修改了会出现重复或漏掉的情况这是游标遍历固有的行为业务里要容忍这种最终一致性的读。4.2 内存优化field数量设计要有红线Hash的内存表现取决于它正处于哪种编码。listpack编码下数据是紧凑排列的内存非常友好。可一旦field超过128个或单个value超过64字节它就会切换到hashtable。hashtable的好处是读写快坏处是每个field都占用一个dict entry每个entry都有指针、哈希值等元信息内存消耗明显上升。所以设计Hash时我一般建议把field数量控制在100以内value长度尽量不超过64字节。如果你确实要存储大对象、长文本就别硬塞进Hash当value了拆开存String配合业务读取会更好。另外field的名字也别设计得太长。一个field名字30字节一万个field就是30万字节的额外开销日积月累在热key场景下会非常可观。4.3 Spring Data Redis中increment()报错的那点事热搜词里有一条很具体的报错java中redis使用redistemplate的increment()报错不是integer or out of range。这个我太熟悉了基本上就是序列化问题。increment()命令要求field对应的value必须是能用字符串表示的64位有符号整数。如果你写入时用的是Jackson或JDK序列化器存进去的就不是纯数字字符串而是一段带类型信息的二进制序列化数据执行HINCRBY时Redis解析不了自然就报“not integer or out of range”。排查思路很直接先用命令行HGET key field看一眼存进去的值到底长什么样。如果看到一坨\xAC\xED开头的东西说明是JDK序列化的锅如果是一个带引号的字符串说明是JSON序列化器的锅。解决方法是保证写入和递增操作使用同一个序列化器而且这个序列化器能让数字保持为纯字符串。最省心的做法是直接用StringRedisTemplate对数字字段用opsForHash().increment()操作。如果你必须用自定义RedisTemplate就把HashKey和HashValue的序列化器都设置成StringRedisSerializer或者在代码里显式把value转成String后再写入。一句话总结凡是涉及数值自增自减的Hash字段写入值和递增操作必须走同一个序列化体系别一个用对象序列化一个用String否则早晚踩坑。4.4 扩容与rehash对延迟的影响hashtable在rehash时虽然采用渐进式设计但也不是完全没有性能波动。当Hash从listpack转成hashtable的那一刻或者hashtable扩容的那一瞬间Redis内存里会发生一次申请新数组、建立新哈希表的动作。如果这个Hash特别大比如几十万个field它的转换过程也不是瞬时完成的渐进搬移期间的一次性内存申请可能让内存峰值瞬时升高。在这个背景下如果你管理着一批超大Hash比如上万个field最好在运维层面监控一下Redis的used_memory和慢查询日志。一旦发现有rehash导致的偶发延迟可以评估是否值得把大Hash拆分成多个小Hash比如按业务维度或时间维度拆分把一个原对象拆成多个子对象或者用中间层索引把这些子对象串起来。这也是“大key治理”中非常重要的一环。4.5 关于Hash使用边界的个人经验写了这么多最后分享一点我个人的实践判断算不上标准答案但长期用下来确实能少踩坑。第一Hash适合存储“结构稳定、字段数量固定且较少”的对象。比如用户资料、商品详情、订单摘要字段基本不变读写都以字段为单位用Hash很顺手。第二Hash适合做聚合统计。点赞数、浏览数、收藏数这类多个子计数器放一个Hash里整体管理很舒服配合HINCRBY原子操作也放心。第三Hash不适合作为“大附件容器”。如果你想把整个列表、整篇文章甚至文件分片塞进Hash趁早换掉。Redis的Hash是内存数据结构不是拿来存大对象的。大而长的value用String或者干脆交给对象存储系统处理。第四Hash不要替代数据库做复杂查询。它只支持field维度的O(1)读取要做范围查询、排序、多条件组合那是另一个数据结构的职责范畴别硬用Hash去实现。第五对Hash设置TTL时要特别想清楚。整体过期意味着对象所有字段一起失效如果你需要“字段A保留30天字段B保留7天”Hash做不到拆分成多个key反而是更合理的思路。我在实际使用中还有一个习惯所有Hash的key统一加前缀比如user:、product:、order:并且field命名也遵循一套规范。这样在Redis Desktop Manager这类可视化工具里看键列表时心里一目了然排查问题时也能秒定位。千万别图省事key起得乱七八糟等线上环境有几百个key的时候你就知道什么叫后悔了。Redis Hash这个结构技术上不算复杂但把它用好的关键在于理解它的存储原理和边界。越是基础的东西越值得沉下心拆开看。希望这篇长文能帮你把Hash从“听说过、用过”变成“用得明白、讲得清楚”。
返回列表