ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Redis hash从入门到实战:底层编码、核心命令与性能优化

Redis hash从入门到实战:底层编码、核心命令与性能优化 从Redis 6.0开始很多人升级后的目光都被多线程IO、ACL、客户端缓存这几个新特性吸走了反而忽略了一个每天都在用、但经常被用变形的数据类型——hash。Redis的hash类型说简单也简单就是一个field-value结构但它在实际项目中承担的角色远比“另一个map”要重得多用户信息缓存、购物车计数、订单明细、分布式锁重入、甚至很多高并发下的热点数据治理都能靠它实现。我用了这么多年Redis回头看踩过的坑里有很大一部分是和hash的使用姿势有关的。这篇我就结合Redis 6的实际环境把hash的底层原理、常用命令、实战设计、性能排查完整捋一遍希望能帮你少走一些弯路。1. hash是什么为什么说它是对象存储的天然载体1.1 从底层编码看hash的两种形态Redis的hash类型在官方文档里的定义是string类型的field和value的映射表适合存储对象。这个定义很准确但只说了一半。另一半在底层实现Redis 6里一个hash键根据数据规模会使用两种不同的内部编码方式一种是紧凑的listpackRedis 6.0以前叫ziplist一种是标准的hashtable。listpack本质上是一段连续内存把字段名、字段值、长度信息按顺序紧凑排列。它的优点是内存占用低读取时顺序扫描即可在字段数量少、值很小的情况下性能并不差。但缺点是字段多了、值大了以后顺序扫描的开销会明显上升。所以Redis设置了一个自动转换阈值当hash中的字段数量超过hash-max-listpack-entries或者某个字段值长度超过hash-max-listpack-value时这个key会自动从listpack转为hashtable。hashtable就是典型的哈希表结构字段多的时候查询复杂度是O(1)但每个节点有额外指针和内存分配开销。我们可以通过object encoding命令查看当前hash键的编码类型。127.0.0.1:6379 hset user:1001 name tom age 18 (integer) 2 127.0.0.1:6379 object encoding user:1001 listpackRedis 6中默认的hash-max-listpack-entries是128hash-max-listpack-value是64。也就是说如果字段数量在128以内且每个字段和值的长度都不超过64字节就保持listpack一旦超了就转为hashtable这个转换是不可逆的。很多线上排查的“内存突然变大”问题往往就是某些hash键字段值长度超过了阈值编码发生切换导致内存翻倍。这一点在后面的性能章节我再详细展开。理解了底层编码就明白为什么hash适合做对象存储了它天然具备“按字段名取属性值”的能力不像一个大的JSON字符串需要整体读出来再解析。对象本身往往就是一组有名字的字段hash把这个对应关系原封不动地表达出来。1.2 为什么对象缓存不使用String直接存JSON很多同学在刚接触缓存的时候习惯把所有对象都用set user:1001 {name:tom,age:18}这样的方式存成JSON字符串。这种做法没有错在对象整体更新、整体读取、很少修改单个字段的场景下String存JSON反而简单粗暴、通用性最强。但如果你的业务是“每次只改年龄”或“每次只取名字”String方案就会变得很别扭你需要先把整个JSON取出来反序列化成对象修改字段后再序列化再写回去。这个读改写的过程在高并发下很容易丢更新还浪费了网络带宽。hash的方式就不一样了字段就是Redis hash里的field你可以只hset user:1001 age 19只hget user:1001 name。Redis本身保证这是原子的不需要你在应用层做任何加锁或CAS。从网络角度说传输的数据量也少得多大对象场景下这个优势会被放得更大。我把这两种方式的关键差异整理成一个表方便你心里有个数。维度String存JSONhash存字段整体写入性能每次写全量JSON性能一般只写变更字段性能更高单字段读取性能需要读出全量再解析直接读单个field单字段原子更新需要读改写非原子HINCRBY/HSET原生原子过期控制key级过期简单key级过期无法对field单独设置内存占用偶尔JSON冗余整体略高listpack编码下非常紧凑实现复杂度需要序列化框架需要field与对象属性的映射从我的实践经验看如果你的对象字段超过三五个、且存在单字段高频读写或按字段更新的需求hash是更优解。如果对象是一次性写入、经常整体读取、很少修改String JSON反而更省事。没有绝对的好坏只是适用场景不同。Redis热词里经常出现序列化相关的坑很多其实都发生在hash这种字段化存储上比如用Jackson/Gson把对象序列化成hash时嵌套对象该怎么处理后面我也会聊到。2. 和hash打交道的核心命令这些足够覆盖日常开发2.1 基础增删改查必须搞清楚的写操作语义hash的命令入门门槛不高但有几个细节容易被忽略。先来看最基础的增删改查我在命令行里直接演示。# 设置单个字段 127.0.0.1:6379 hset user:1001 name tom (integer) 1 # 同时设置多个字段 127.0.0.1:6379 hmset user:1001 age 18 city beijing OK # 获取单个字段 127.0.0.1:6379 hget user:1001 name tom # 获取多个字段 127.0.0.1:6379 hmget user:1001 name age 1) tom 2) 18 # 判断字段是否存在 127.0.0.1:6379 hexists user:1001 name (integer) 1 # 删除字段 127.0.0.1:6379 hdel user:1001 city (integer) 1这里有几个要点需要特别注意。hset在对已存在的字段设置值时返回0表示没有新增字段而是覆盖了旧值如果字段不存在则返回1。这个返回值常被用来判断“是不是第一次写入”实现类似MySQL的INSERT ON DUPLICATE KEY UPDATE的效果但要注意Redis内部并没有upsert这种概念只是旧字段被覆盖了而已。hmset是hset的批量版本但从Redis 4开始hset已经支持多个field-value参数因此hmset在业务代码里完全可以被hset替代官方也慢慢弱化它的存在感。紧接着还有一个容易踩的坑hgetall会把一个key下的所有字段和值都拉出来。字段少的时候没问题一旦hash变成了大key比如一个用户对象挂了上百个字段hgetall就成了性能杀手。原因在于Redis是单线程模型执行hgetall期间会阻塞整个Redis实例字段越多阻塞时间越长。所以我在生产环境里会明确要求禁止使用hgetall获取大hash的全部字段必须明确业务需要哪些字段用hmget按需取非要全量遍历时用hscan增量取。还有hscan很多人把它和hgetall混为一谈实际上hscan是游标式遍历可以分批返回字段可以在遍历过程中对key进行修改具体用法后面讲大key排查时会详细展开。对于字段很少的小hashhgetall完全没问题但你需要知道这行命令背后有风险。2.2 数字字段的原子操作是计数场景的常青树hash里还有一个其他数据类型羡慕的能力就是对字段值做原子自增自减。假设你有一个商品库存hash127.0.0.1:6379 hset sku:2001 stock 100 (integer) 1 127.0.0.1:6379 hincrby sku:2001 stock -1 (integer) 99hincrby会把字段stock的值减1整个过程是原子的不需要担心并发覆盖。除了整数hincrbyfloat还能对浮点数做自增。127.0.0.1:6379 hset account:2001 balance 100.5 (integer) 1 127.0.0.1:6379 hincrbyfloat account:2001 balance 0.5 101这两个命令特别适合做购物车数量变更、积分累计、库存扣减、实时统计等场景。尤其配合Lua脚本时可以实现“在hash里扣减库存并返回是否成功”这种原子操作。因为hash里的字段天然就是一个命名空间用不同的field存储不同商品可以极大减少key数量。比如购物车场景常规方案是每个用户一个key每个商品ID一个fieldfield的值是数量这样整个购物车只有一个hash key而不是每个商品一个key无论是内存开销还是Redis的key管理压力都会小很多。这个场景我在第三部分会详细写。2.3 命名和写入细节容易被忽视的隐性成本hash的命令使用上有几个隐性成本常年被忽视。第一个是field的命名长度hash里的field和value一样都是字符串field越长listpack编码下占用的连续内存就越大hashtable编码下需要计算的hash和节点存储也就越多。所以field设计要尽可能短但短到失去可读性就得不偿失。一般我会建议在业务上下文明确的情况下用简短的业务缩写比如商品ID直接用sku:1001而不是product_id_1001。第二是批量命令的切片问题。hmset/hmget一次可以传很多字段但Redis命令本身有网络包大小限制而且一次命令执行时间也不能太长。如果字段数量特别大比如一次hmget上千个字段Redis处理时间会显著上升。我们要学会通过mget/hmget的切片控制分多批跑或者用pipeline合并。关于pipeline我后面会专门说。第三是过期时间的问题。hash没有field级别的过期功能整个key只有一个TTL。如果你需要让某个字段自动过期常规做法是用一个独立的hash记录写入时间配合定时任务扫描清理或者干脆用String类型存这个字段。很多人一上来就想让hash里的field自动过期这是不现实的设计上就得规避。第四是空hash问题。当hash里最后一个字段被hdel删除后这个hash key会自动消失。这是Redis的回收机制正常情况下没有问题但如果你依赖key存在性判断对象是否存在要注意可能你只是想清空部分字段结果key没了后续hget返回nil。这个也符合预期但容易让测试人员误以为是bug。3. 从项目实战看hash的高频应用场景3.1 对象缓存hash最经典的主场对象缓存是hash最经典的应用没有之一。假设我们要缓存用户信息用户对象有uid、name、level、score四个字段。用hash存就是hset user:1001 name tom level 1 score 2000。查询时如果业务只需要展示用户名可以只hget user:1001 name不需要把整个用户对象拉出来反序列化。但这里有一个对象设计上的问题很多新手会卡住如果用户对象里有嵌套结构比如profile字段本身又是一个JSON对象该不该用hash的field去存这个嵌套对象我的经验是嵌套对象单独存成一个hash或者把该字段序列化成JSON字符串存进hash的value。原因是hash本身是两层结构field这一层没有支持嵌套的“二级field”强行把一个对象压成字符串存进value虽然能work但这部分就无法利用hash的字段级操作了。比如用户信息里的收货地址也是个JSON结构你存入user:1001的fieldaddress里想修改地址的城市名就只能读出整个地址字符串再解析再写回性能和原子性都不理想。更合理的方式是单独给地址建一个hash用address:1001当key城市、街道、手机号当field然后把user:1001里的address字段只存一个引用标识或者干脆不存。在缓存读取的时序上我强烈建议设计一个统一的对象缓存工具类封装hash的读写对上层业务屏蔽命令细节。这个工具要做三件事读取时判断字段是否存在为空则加载DB并回填缓存更新时先更新DB再更新hash中的对应字段删除时直接把整个key删掉。这种封装能减少团队里到处写Redis命令导致的脏代码也方便统一加监控埋点。实际操作中我还会在写入时额外维护一个类似updatedAt的字段便于后续排查缓存和DB不一致的时间窗口。3.2 购物车和计数器一个hash顶一片String key购物车是一个非常典型的hash使用场景。对每个用户维护一个hash key比如cart:1001商品ID作为field商品数量作为value。用户加购商品时就是hincrby cart:1001 2002 1减少就是hincrby cart:1001 2002 -1查询购物车明细用hgetall cart:1001需要注意大key问题一般购物车字段数量不会太多或hscan分页。相比给每个用户每个商品建立一个String keyhash的优势非常明显key数量少内存占用低一个购物车对象可以用del一次清空也方便统计某用户购物车里的商品总数。同样计数器场景也能用hash达到类似的效果。比如一个活动页面需要记录每个用户每天的点击次数可以用incr给每个用户建一个key但更好的方案是hincrby user:1001 click:20250601 1把用户ID当key日期当field这样同一个用户的多天计数都在一个hash里后续查最近几天点击情况只要hmget几个field就行。当然如果用户量极大且每个用户只维护少量天数也可以反过来把日期当keyuserID当field。设计哪种从性能角度都可行关键是结合你的主要查询路径是“查某用户的历史记录”还是“查某天的全量用户”它们对应的key-field设计方向刚好是反的。hash做计数还有一个隐藏好处hincrby天然支持负数减库存时不会出现负数因为它只做自增运算能否出现负数完全由业务控制。如果业务要求“不允许扣成负数”我们可以搭配Lua脚本先hget再判断再hincrby在脚本里保证原子性。这比用String型incr再额外判断返回值要清晰得多。3.3 分布式锁里的hash身影重入锁和锁续期很多文章讲Redis分布式锁张口就是setnx加过期时间这是最基础的版本。但如果你需要支持可重入锁或者需要给锁增加一些元信息hash就派上用场了。比如Redisson的可重入锁实现就用了hash结构key是锁的名称field是持有锁的线程标识value是重入次数。具体做法是每次加锁先判断锁是否存在不存在则用hset lock:order:1001 threadId 1并设置过期时间如果存在但field相同则hincrby lock:order:1001 threadId 1表示重入一次解锁时则hincrby lock:order:1001 threadId -1当计数值降到0时del整个key。这个过程配合Lua脚本可以在Redis服务端原子完成避免并发问题。这种方式比单纯的String型锁多了一个“计数维度”应用场景很有价值。我自己在项目里也遇到过类似需求同一个请求线程在多个方法里尝试获取同一把锁如果锁不支持重入就可能造成自死锁。用hash实现重入锁后锁的持有次数由hash的value字段表达既避免了String锁的非重入问题也能清楚看到当前锁被谁持有、重入了几次。需要提醒的是分布式锁的过期时间续期也很重要如果业务执行时间超过锁的TTL锁会被自动释放这时候可以通过Lua脚本读取hash里的线程标识如果还是当前线程持有就pexpire续期。这就是所谓的看门狗机制核心逻辑都离不开hash里的field信息。3.4 缓存治理与序列化Java客户端里最容易翻车的地方讲hash就不得不提序列化。在Redis命令行里所有field和value都是字节序列但到了Java、Go这些客户端里我们通常都会配置序列化器。热词里反复出现“redis序列化”和“redis序列化 乱码”其实大多数都和hash有关。以Spring Data Redis为例如果全局为hash的field和value配置了JDK序列化器那么你用redisTemplate.opsForHash().put(user:1001, name, tom)最终写入Redis的命令不是hset user:1001 name tom而是hset user:1001 \xAC\xAC... \xED\x00...一长串二进制乱码。这样不仅浪费内存还会导致其他语言客户端完全读不懂这个key。正确的做法是给hash的field和value单独配置String序列化器通常就是StringRedisSerializer如果value想要存对象可以选择JSON序列化器但要注意此时对象需要能正确反序列化成目标类型。我在实践中确认过最省心的方案是key使用String序列化器hash的field使用String序列化器value根据业务自行决定如果是简单对象就用JSON字符串。如果确实需要将一个Java对象整体序列化到hash的某个字段中建议单独使用GenericJackson2JsonRedisSerializer或自定义的序列化工具但必须提前约定好类型的存储方式不然反序列化时容易撞上类型信息丢失的问题。序列化这个环节建议在项目初期就通过redis-cli --raw检查一下Redis里实际存的字节内容别等到线上查数据发现都是乱码才后悔。4. hash的性能、内存与高并发注意事项4.1 编码选择为什么listpack转hashtable后内存会翻倍前面提到hash底层有listpack和hashtable两种编码。listpack编码下所有数据都挤在一块连续内存里没有多余指针内存利用率极高hashtable编码下每个键值对都是一个节点每个节点还要额外存储链表指针和哈希表结构内存开销会明显增加。因此当编码从listpack切到hashtable内存可能直接翻倍甚至更多尤其是在value值都很小的情况下额外指针的占比会非常大。Redis 6里控制这个动作的配置项是hash-max-listpack-entries和hash-max-listpack-value。在生产环境如果业务上的hash字段数量或value长度不会大幅波动我会根据实际情况调整这两个参数。比如我知道某个hash字段数量最多50个、值都比较短那完全可以把hash-max-listpack-entries保持在默认值128没必要改。但如果业务上hash字段数量可能长期在几百个那就不能为了省内存硬把阈值调高否则listpack变成巨大的连续块每次更新都可能触发内存复制得不偿失。更合适的做法是接受hashtable并使用合理的field命名长度来降低内存。要查看当前key的编码方式用object encoding命令要估算一个key的内存占用Redis 4.0以后提供了memory usage命令127.0.0.1:6379 memory usage user:1001 (integer) 73这个数值可以帮助我们对比不同编码下的内存变化但要注意它不是绝对精确而且对一个包含大量field的hash执行时也可能带来一定开销生产环境慎用。另外redis-cli --bigkeys会扫描整个实例按数据类型找出“大key”样例hash类型会展示较大field数量的key这对巡检很有帮助。4.2 大keyhash最大的敌人hash字段数量膨胀到一定程度就会变成大key。这也是Redis运维中非常常见的问题一个用户hash字段几十个没问题但如果把一周的登录记录、一天的用户行为都塞进同一个hash光字段数量可能就几十万操作时延迟飙升、内存分配频繁、主从同步压力大甚至BGSAVE都可能变慢。大key的解决方案常规思路是分片或拆分。先说拆把不相关的字段按业务维度拆成多个hash key比如用户基础信息和用户行为记录分开每个key只保留一类字段。比如一个订单hash可能包括订单状态、支付时间、物流公司、物流单号等但如果业务上经常只更新物流相关字段就应该拆成order:1001:base和order:1001:logistics两个hash避免所有更新都独占整个key的写锁。另一个思路是hash分片也叫hash slot。当一个key的field数量即使按业务维度拆分后仍可能很大比如某个活动的参与用户计数一个key下可能几十万field这种时候可以把原来的一个hash拆成多个分片hash比如根据用户ID的hash值取模分成16个分片每个分片key负责部分field。查询时先定位分片再操作。分片会增加客户端复杂度但能显著降低单key压力。在Redis cluster模式下分片还有助于让数据分布更均匀避免单节点热点。还有一个常见的做法是限制hash的field数量比如约定一个hash最多1000个field超过就新建一个keykey名里带上序号。这个方法适合日志、流水、事件这类持续增长的数据查询时需要知道具体在哪个分片。4.3 高并发读写hash和缓存一致性怎么平衡高并发场景下hash做缓存最核心的矛盾在于业务需要先更新数据库再更新缓存还是先更新缓存再更新数据库我的建议是缓存里存的是数据库的投影必须把数据库当作数据源hash的更新应该在数据库事务成功后再执行。但这个时序不能保证绝对一致因为缓存更新也可能失败。更稳妥的方案是cache aside读的时候先读hashmiss了就从DB加载并回填写的时候先写DB再删除缓存key让下次读的时候回填。这个删除动作要留意“先删缓存再写DB”和“先写DB再删缓存”的坑比较推荐的是“先写DB再删缓存”虽然是两个步骤中间有一条极短的窗口可能读到旧值但至少不会出现长期脏数据。如果对一致性要求更高可以使用canal这类binlog监听工具把数据库变更异步同步到Redis的hash中这样业务代码里连更新缓存的动作都可以省略。高并发下还有一个hash专门优势值得提因为hash可以在不整体读出的情况下更新单字段所以缓存更新的网络包非常小、操作时间短对Redis单线程模型特别友好。如果是String存JSON一个用户对象更新一个字段就得写整个对象持久化文件、网络传输、命令执行的时间都会显著增加。因此高并发系统里把热点对象缓存在hash里反而能降低Redis的CPU和带宽消耗当然前提是field设计合理。我自己在实际压测中遇到过这样的场景一个商品详情页每秒几万次请求每次请求需要读商品名称、价格、库存三个字段。如果用String JSON每次请求都要把整个商品JSON拉到客户端再解析改用hash后一次hmget三个字段只返回三个小字符串响应体和RT都下降了一个量级。这类优化不需要换架构只是把数据类型换掉收益却非常直接。5. 常见问题排查与进阶建议5.1 典型问题速查表照着排查能省不少事我在维护Redis的过程中把遇到过的一些hash相关的高频问题整理成了表格方便你在故障时直接对照。问题现象常见原因排查方法解决方案Redis内存突然翻倍listpack转hashtable字段值长度超阈值object encoding key查看编码调整value长度或字段数量避免反复转换单个命令延迟飙升hgetall拉取大hash所有字段redis-cli --bigkeys扫大key改用hmget按需取或用hscan分页客户端看到二进制乱码hash field/value使用了JDK默认序列化器redis-cli --raw查看原始数据使用StringRedisSerializer或JSON序列化器并发下库存或计数不准确读取后修改不是原子操作使用了getset检查是否用了hincrby改为hincrby或Lua原子操作hash key意外消失最后一个字段被hdel删除key自动回收检查业务代码是否有删除动作如需要保留空key单独维护占位字段锁无法重入或提前释放使用了不支持重入的String型锁或没有续期查看锁实现代码使用hash存储线程标识和重入次数大key导致主从延迟hash字段数量过大同步时RDB/AOF压力大观察慢日志和同步延迟按业务拆分或分片hash这张表是我个人的使用经验总结不代表所有场景都适用但排查思路是通用的。只要问题定位到hash类型先确认编码、字段数量、value长度、序列化配置这四个维度大部分问题都能找到答案。5.2 实测经验hash字段数量和内存怎么评估从内存评估的角度我通常会给新项目一个默认约束单个hash的field数量不要超过1000单个field和value尽量不要超过200字节。这样即使保持listpack编码也能在一个合理的内存范围之内。如果业务必须超过这个值就要评估是否需要拆分。具体内存占用除了memory usage命令也可以参考Redis官方文档中的估算公式但公式会随版本变化实际最好通过实验数据验证。我做过一个小实验创建一个10万字段的hash每个field和value长度都约8字节内存占用大约在15MB左右而同样的数据拆成100个hash每个1000字段总体内存反而会高一些因为每个key的开销和hashtable的元数据会重复。所以拆key并不总是能省内存它主要是为了降低单key操作延迟和避免阻塞。内存和性能是两回事设计时要分清楚当前瓶颈是哪一个。在field数量的监控上我建议定时任务扫描所有hash size比较大的key比如用hscan或hstrlen批量统计一旦发现field数量超阈值就告警。这个做法比等--bigkeys跑一遍再人工看要主动得多。另外在日志里也可以加上hash的field数量埋点比如每次写hash后异步上报一个样本这样后续排查某个key是什么时候开始变大的会容易很多。5.3 让hash操作更高效的三个小习惯最后分享几个我在代码里沉淀下来的小习惯它们很轻量但能让hash操作更高效。第一是批量操作尽量走pipeline。如果业务需要一次更新多个hash的多个字段或者读取多个key中的几个field用pipeline把多个命令打包一起发能极大减少RTT。比如购物车结算时需要同时读取多个购物车hash如果循环一个个hgetall或hmget慢得让人心慌。用pipeline后整个批量读取只消耗一次网络往返。这在高并发服务里效果非常明显。第二是原子性要求高的场景用Lua脚本。Redis 6里Lua脚本的编写和使用都很成熟可以在一次脚本里完成多个hash操作。比如扣减库存、校验状态、记录日志一个Lua脚本搞定整个脚本原子执行不会有并发进程插进来。热词里也专门出现过“redis lua”说明这个话题确实是大家关心的。实际编码时要注意Lua脚本里调的key要提前用KEYS数组传进去不要在脚本里用字符串拼key否则Redis Cluster下会报跨slot错误线上运行也会有问题。第三是设计field时预留扩展位。有人在设计hash时会犯一个错field只表达当前需求完全不考虑后续扩展。比如一开始只存价格field就叫price过两天要存一个原价、一个成交价就又得加字段。其实hash加字段成本很低field命名不用吝啬短但语义清晰最好。可能的话用前后缀区分业务维度比如cnt_20250601表示某天计数比直接20250601更不容易和其他字段混淆。hash的好处是加字段不会影响已有字段所以设计时可以稍微大胆一点但别太臃肿。我这里最后想特别提一下刚开始用hash的时候很容易把组织和规范方面的力气省掉觉得Redis就是KV随手怎么存都行。但一旦对象字段多起来、访问频次高起来脏数据、大key、序列化问题都会找上门。多花一点时间在hash的设计上后面运维和开发都能轻松很多。另外Redis 6之后官方对hash的优化虽然没有翻天覆地的新命令但稳定性已经很好了我们在生产环境大规模使用的经验也验证了这一点。如果你在项目里已经遇到了String JSON存储的痛点不妨试着把高频读写的对象切到hash上很可能会有眼前一亮的感觉。
返回列表