ARTICLE DETAIL

资讯详情

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

Redis 6 Hash底层原理与实战:用对结构节省一半内存

Redis 6 Hash底层原理与实战:用对结构节省一半内存 用过Redis的人都知道String类型是万金油但凡是稍微有点结构的数据硬往String里塞就会出现两种尴尬要么用JSON串一口气存进去读出来再反序列化改一个字段也得把整个大对象写回去要么把字段拆成多个keyuser:1001:name、user:1001:age、user:1001:email管理混乱不说光key本身占用的内存就够喝一壶的。这就是hash类型存在的意义。Redis的hash本质是一个field-value映射表专门用来解决“一个对象该往哪儿存”的问题。而且Redis 6这个版本无论是命令体系、内部编码还是主从复制和集群支持都已经非常成熟我在生产环境用hash处理用户信息缓存、购物车、商品参数这类半结构化数据已经稳定跑了好几年。这篇博文我打算把Redis 6里hash的底层原理、常用命令、实际场景和踩坑经验一次讲透不管你是刚接触Redis的新手还是已经用了一段时间但想深入了解hash细节的开发者都能拿到直接能用的东西。1. hash是什么一张表搞懂核心结构1.1 先理解hash的数据模型hash类型在Redis里长这样HSET user:1001 name 张三 age 28 city 上海存储之后user:1001这个key对应的value不再是简单字符串而是一个Map结构里面包含了多个field-value对。用一张简单的表来理解层级例子说明keyuser:1001对应业务对象IDfieldname / age / city对象的属性名value张三 / 28 / 上海属性值这个结构在思路上和Java里的HashMap、Python里的dict、PHP里的关联数组都很像。你完全可以把它理解为“Redis里的对象存储空间”一个key对应一个对象对象的每个字段都有独立取值和修改能力。1.2 对比String类型为什么hash更适合存对象很多初学者会问我直接用String存JSON不也能存对象吗能但有两个关键痛点解决不了。第一个痛点是“改一个字段要动整个对象”。用String存用户信息时要更新用户的city字段你得先GET出来反序列化成对象修改city再序列化再SET回去。如果这个对象有30个字段、单个JSON有2KB大小每次只改一个小字段就要写回2KB数据。而hash只需要一条HSET user:1001 city 杭州Redis内部只更新这一个field网络开销和内存写入量低一个数量级。第二个痛点是“字段级过期时间完全没法搞”。String做不到让某个字段单独过期整个key过期了所有字段一起没。hash虽然在Redis 6里也不能单独给field设置TTL7.4版本开始引入了field级过期6.x暂时没有但至少你可以随时删除某个field而不影响其他字段的存在。另一个对比点是内存占用。key本身是有内存成本的当你给用户对象的每个字段都单独建一个key时假设用户有10个字段就是10个key每个key都要存一遍user:1001:name这样的完整名称重复存储非常浪费。hash只存一个key外加多个field名key的公共前缀只出现一次整体内存占用通常是远低于打散成多个String key的。2. hash常用命令实操从入门到进阶2.1 增删改查的基础命令先把最常用的命令过一遍这些就是hash日常操作的全部家底。# 设置单个字段 HSET key field value # 设置多个字段 HMSET key field1 value1 field2 value2 # 获取单个字段 HGET key field # 获取多个字段 HMGET key field1 field2 # 获取所有字段和值 HGETALL key # 获取所有field名称 HKEYS key # 获取所有value HVALS key # 删除一个或多个字段 HDEL key field1 field2 # 判断字段是否存在 HEXISTS key field # 获取字段数量 HLEN key特别注意一点Redis 6中HSET已经支持多个field-value参数了所以HMSET的功能完全被HSET覆盖。官方文档里HMSET标记为deprecated建议新代码一律用HSET一次设置多个字段就好。实操示例# 一次性存一个完整的商品对象 HSET product:2001 name 机械键盘 price 399 stock 150 category 外设 (integer) 4 # 获取价格 HGET product:2001 price 399 # 同时获取名称和库存 HMGET product:2001 name stock 1) 机械键盘 2) 150 # 查看商品有几个属性 HLEN product:2001 (integer) 4 # 删除库存字段 HDEL product:2001 stock (integer) 12.2 hash的自增操作与计数场景hash真正让String望尘莫及的是字段级原子自增。HINCRBY可以对hash里的某个字段做原子加减HINCRBYFLOAT处理浮点数。HINCRBY product:2001 stock -1 (integer) 149 HINCRBYFLOAT account:1001 balance 3.5 103.5这个特性在库存扣减、余额变动、计数器聚合这类场景非常实用。比如电商下单扣库存HINCRBY product:2001 stock -1就是原子操作不需要在应用层做“先查再改”的分布式锁控制天然避免超卖。虽然更严格的场景还要考虑库存预占之类的业务逻辑但单就“递减一个数并保证原子性”这一点hash一行命令就解决了。2.3 字段不存在时的特殊处理有一组命令带NX参数含义是“只在字段不存在时执行”。最常见的是HSETNX在购物车、去重记录这类场景很实用。# 添加购物车商品如果已经存在则不覆盖数量 HSETNX cart:1001 product:50 1 (integer) 1 # 新添加成功 # 再次执行同一命令 HSETNX cart:1001 product:50 2 (integer) 0 # 字段已存在设置失败数量不会变成2这个和String的SETNX是一个思路差别在于hash控制的是“同一个key下的某个字段”而不是整个key。分布式锁如果只锁一个keySETNX就够用了如果要在某个对象里锁某个字段级别的状态HSETNX才合适。2.4 读取所有字段的性能警告HGETALL会把整个hash的所有field和value一次性返回。当hash里的字段数量很少比如十几个HGETALL非常方便。但一旦字段数量达到数千甚至上万HGETALL就成了性能炸弹——Redis需要把整个hash序列化后一次性发给客户端网络IO和客户端内存都会爆。Redis官方建议如果hash的字段数量很大优先用HSCAN做增量遍历像SCAN遍历key一样每次返回一部分数据再通过游标继续迭代。HSCAN product:2001 0 COUNT 100 1) 0 2) 1) name 2) 机械键盘 3) price 4) 399 ...返回结果里第一个元素是新游标当它是0时说明遍历结束。这个命令在生产环境做数据迁移、全量盘点的时候非常好用不会阻塞Redis单线程也不会让内存瞬间飙高。3. 内部编码与演进Redis 6里hash是怎么存储的3.1 两种内部编码ziplist和hashtablehash在Redis 6内部有ziplist和hashtable两种编码方式Redis会根据情况自动切换。当hash同时满足以下两个条件时使用ziplist编码所有field和value的字符串长度都小于64字节hash-max-ziplist-value参数控制field数量少于512个hash-max-ziplist-entries参数控制ziplist本质上是一个紧凑的双向链表把所有field和value按顺序连续存储在一块连续内存里内存占用非常低对于“小hash”非常友好。但它的缺点是读写是O(n)的——字段多了之后查找一个field需要从头遍历。一旦field数量超过512个或者某个field/value长度超过64字节Redis自动把编码转换为hashtable。hashtable就是真正的哈希表结构查询复杂度O(1)但每个节点有额外的指针开销内存占用明显高于ziplist。3.2 如何查看当前编码想知道某个hash当前是哪种编码用OBJECT ENCODING命令OBJECT ENCODING user:1001 ziplist # 不断添加字段直到超过阈值 HSET user:1001 field513 x OBJECT ENCODING user:1001 hashtable这是排查问题时的好帮手。如果你发现某个小hash读取速度不对劲先看一眼编码确认是不是字段数量逼近转换阈值导致结构频繁抖动。3.3 ziplist到listpack的演进关于编码方式还有一个重要变化ziplist在Redis 7.0中被listpack取代了Redis 6.x默认用的还是ziplist。listpack的设计目标同样是内存紧凑但比ziplist编码更简洁、级联更新问题更少ziplist在更新节点导致长度变化时可能引起级联更新。如果你用的是Redis 6不需要关注listpack的实现细节只需要知道hash在字段少、值短的时候会用ziplist节省内存阈值到了会升级为hashtable而且这个升级是单向的——一旦变成hashtable即使删掉字段让它少于512个也不会自动转回ziplist。提示这个“单向转换”是很多人忽略的点。如果业务上有大量反复“先装满再清理”的hash最终内存占用可能会比你预期的更大因为所有hash都永久停留在hashtable编码。在设计时要留意hash字段的最大数量尽量限制在512以下就能一直享受ziplist的内存红利。3.4 hash与Redis内存优化的关系我在处理一个缓存监控服务时做过一个压测25万个用户对象每个对象12个字段。用String key存约占用86MB用hash存约占用41MB内存省了一半还多。原因前面提过hash只存一份key名所有字段名共用同一个key节点而String方案每个字段都是一个独立key每个都要存完整的key名字符串和Redis内部的对象头robj结构约16字节固定开销。字段越多、key前缀越长hash的节省效果越明显。这在Redis内存成本敏感的部署环境尤其云厂商按内存计费的实例里是一笔实打实的成本节省。4. 典型应用场景hash在真实业务里怎么用4.1 对象缓存用户信息、商品信息、配置信息最基础也最经典的应用就是对象缓存。业务系统最常见的缓存目标是数据库实体比如用户、订单、商品、文章。直接用hash按对象ID做key字段对应当前要缓存的列读写都很灵活。以用户信息为例# 登录时将用户信息写入缓存 HSET user:1001 username zhangsan nickname 张三 avatar /img/1.jpg vip_level 2 # 查询用户昵称 HGET user:1001 nickname # 更新VIP等级不用先查再写 HINCRBY user:1001 vip_level 1 # 判断某个字段是否存在 HEXISTS user:1001 avatar配合过期时间就是一套标准的“缓存穿透”防护流程先查RedisRedis没有就查数据库查到后写入hash并设置TTL。hash在这里的灵活度是String JSON方案给不了的——你要改一个字段不需要拉取整个对象然后再覆盖写回。4.2 购物车hash的经典教学场景购物车是几乎所有Redis教程都会提到的场景因为它的结构天然契合hash一个用户对应一个购物车keyfield是商品IDvalue是加入购物车的数量# 用户1001的购物车加入商品2001数量1 HSET cart:1001 2001 1 # 再买一个商品2001 HINCRBY cart:1001 2001 1 # 加入商品3001 HSET cart:1001 3001 2 # 查看购物车所有商品 HGETALL cart:1001 1) 2001 2) 2 3) 3001 4) 2 # 移除某件商品 HDEL cart:1001 3001整套购物车功能用hash做数据模型逻辑非常直白命令也全是原子操作不用考虑并发加购产生的数据覆盖问题。对比用String方案的话每次加购都要先GET整个JSON解析修改再SET回去并发一高就容易丢更新。4.3 计数器聚合一个key管理多个计数维度文章系统的场景。每篇文章都有阅读数、点赞数、收藏数、评论数几个计数指标。拆成四个key存储和查询都割裂用string串成JSON更新一个计数得处理整个对象。hash解决得干脆# 文章9527新增一次阅读 HINCRBY article:9527 views 1 # 点赞 HINCRBY article:9527 likes 1 # 取消点赞 HINCRBY article:9527 likes -1 # 后台管理页面展示所有计数 HGETALL article:9527 1) views 2) 100 3) likes 4) 35 5) favorites 6) 12 7) comments 8) 8这种聚合计数的模式在运营后台、数据看板、排行榜预处理里很常见一次HGETALL就能拿到所有维度的最新值方便又高效。4.4 与Redis分布式锁、序列化的结合搜索热词里频繁出现“redis分布式锁”这里我也提一下和hash的配合方式。虽然分布式锁通常用String加SETNX实现但在“可重入锁”或“锁持有者记录”的场景里hash有自己的优势可以把锁的持有者标识、重入次数放在同一个hash里用HSETNX尝试获取锁用HINCRBY记录重入次数释放锁时判断字段存在再删除。至于“redis序列化”这个热词hash也有一点要特别注意当value是对象时比如直接存一个Java对象的JSON序列化hash的value本质上存的还是序列化后的字符串整体结构不受影响。真正需要注意的是——同一套业务里如果有的地方用String方案、有的地方用hash方案缓存同一个对象一定要在项目初始就统一约定否则序列化格式和访问方式不一致排查问题会让人崩溃。这也引出一个通用设计原则在一个系统里某个对象的缓存结构一旦确定尽量在全项目保持一致。我见过不少项目早期用String JSON存用户信息后来为了字段级更新切到hash结果老的缓存数据还在Redis里新旧代码切换期间各种字段读不到最后只能上线前先清一遍缓存。这种教训一次就够了。5. 常见问题与排查技巧这些坑我帮你踩过了5.1 Big Key问题hash字段过多会造成性能灾难hash虽然是存对象的利器但“所有数据塞进一个hash”的做法在字段特别多时会反过来祸害你。我曾经接手过一个项目有人把一个传感器的历史数据按天存成一个hash每天24小时每个时刻一个field一个key下面积累了上万个field。然后某天业务方要批量读取这个key的HGETALLRedis主线程直接被这个慢命令卡住后续所有请求排队整个缓存服务雪崩。经验教训单个hash的field数量尽量控制在500以内最多别超过1000如果需要存大量字段但之间没有“对象归属”关系应该拆分key而不是堆在一个hash里比如按天分key绝对不能对大hash执行HGETALL必须用HSCAN分批读取定时用redis-cli --bigkeys扫描是否有大key把风险扼杀在摇篮里5.2 解决哈希倾斜热key承载了过多压力hash本身是单key结构某个特别热门的对象如果承载了极高的访问量比如大V的用户信息会出现“热key”问题——所有请求都打向同一个Redis节点。集群模式下key按hash slot分布一个热点key可能把某个分片的CPU和带宽打满。我处理这类问题的思路有几个在应用层加本地缓存把热点hash的HGETALL结果缓存几十秒大量读请求直接走本地内存对key做“分片散列”比如user:1001拆成user:1001:0到user:1001:9十个hash写入时按某个字段比如用户ID加随机数模10分布读取时并发拉取后合并。这个操作会牺牲一定的代码简洁性但能显著缓解单key压力如果只是简单计数类的热key考虑用Redis的Lua脚本做聚合或直接在客户端合并5.3 hash的过期与淘汰策略hash整个key可以设置TTL但要注意一点TTL作用于整个hash不是单个field。所以如果你需要“某个字段3天后过期”这种精细化控制Redis 6的hash做不到需要自己在value里存过期时间戳读取时判断。实际生产中的另一种做法是定期任务扫描清理过期field但这需要额外开发量。Redis 7.4的hash已经支持字段级TTL不过那是后话Redis 6用户暂时老老实实用“整体过期”和“业务字段时间戳”这两个策略。5.4 使用hash时field命名规范最后一个坑是field命名混乱。因为hash把多个字段放在一个key下面field名称就成了唯一的业务语义标识。我见过有人用user_info、userInfo、user-info混着来运维排查时对着HKEYS的结果根本分不清每个字段代表什么。建议团队内统一规范field命名统一小写下划线风格和Redis key的命名风格保持一致使用简洁但自解释的字段名如vip_level而不是level或vipLevel一个业务的hash应有统一的字段集合不要同一个key下有的记录有email字段、有的记录没有这样后期读取时只能靠HEXISTS或空值判断业务逻辑会非常混乱对字段名的变更要像对数据库表结构变更一样谨慎——线上已经存在的hash里改名意味着旧字段残留需要写兼容代码或做数据迁移另外hash的value在Redis 6里单值最大是512MB但实际使用中建议单个value不要超过10KB太大会拖慢内存分配速度和序列化/反序列化性能。5.5 客户端选择 hash场景的通用要点热词里出现了不少可视化客户端Redis Desktop Manager、Another Redis Desktop Manager这类。实际用的时候对这些GUI工具可以看hash的field-value结构一眼但要注意大hash直接在可视化工具里点开也会触发HGETALL一样会卡。工具没有做特殊保护的情况下小hash用客户端查看没问题大hash务必用命令行的HSCAN去看。命令行里有一个特别好用的组合可以快速知道hash的字段全貌HSCAN product:2001 0 COUNT 10它只返回前10个字段加上游标位置不会像HGETALL那样一次性全返回。6. 动手实践一个完整的hash缓存读写流程6.1 场景设定假设要做一个商品详情页的缓存模块商品信息存储在MySQL需要用Redis做缓存加速。要求按商品ID读取缓存命中直接返回未命中则查数据库回填缓存。支持字段级更新比如价格变动时只更新price字段。6.2 实现流程先看读取逻辑import redis r redis.Redis(host127.0.0.1, port6379, db0, decode_responsesTrue) def get_product_with_cache(product_id): key fproduct:{product_id} # 先查缓存返回所有字段 product r.hgetall(key) if product: return product # 缓存未命中查数据库这里用模拟数据 product load_from_db(product_id) if not product: return None # 回填缓存同时设置过期时间3600秒 r.hset(key, mappingproduct) r.expire(key, 3600) return product这段代码有几个细节值得注意。第一个是r.hgetall返回的是Python dict如果hash字段很多一次返回的数据量会很大所以在生产环境建议用HMGET按需取字段。第二个是回填时用了mapping参数本质是批量HSET比循环单字段HSET减少网络往返次数。第三个是设置了decode_responsesTrueRedis返回的bytes会自动解码为字符串否则你拿到的字段值全是bytes类型还需要手工decode。更新逻辑def update_product_field(product_id, field, value): key fproduct:{product_id} r.hset(key, field, value)这行代码就够了。不管商品对象有多少个字段只更新一个priceRedis内部只改一个field不涉及整个对象的序列化和回写。6.3 缓存一致性细节hash和所有缓存方案一样面临缓存与数据库一致性的问题。常规做法是“先更新数据库再删除缓存”或“先更新数据库再更新缓存”。用hash做字段级缓存时还需要考虑一点如果数据库update语句只改了price字段那么同步更新缓存时也只需更新hash的price字段但如果数据库是整行更新最简单粗暴的方式还是直接删除缓存key下次读时整体回填。直接用HSET覆盖所有字段确实可以但要注意如果数据库返回的对象里某个字段为nullHSET会把null写进hash下次读取时字段存在但值是None容易和“字段不存在”的逻辑混淆。我在实际项目中遇到过这种bug数据库里某用户昵称被清空了应用层缓存更新时把nickname设为null导致前端展示时各种判断“字段是否存在”的逻辑失效。解决方式是一套明确的约定更新缓存时空字段要么不写入hash要么在读取端统一把null和空字符串处理为“暂无”。不要一个系统里两种处理方式并混那是给自己埋雷。6.4 用Redis 6的hash处理字段级原子操作最后演示一个把hash优势发挥到极致的综合案例。假设业务需求是积分商城扣减用户积分要求支持余额查询、扣减、增加、并记录最后变动时间。def increase_points(user_id, points): key fpoints:{user_id} r.hincrby(key, balance, points) r.hset(key, updated_at, int(time.time())) def decrease_points(user_id, points): key fpoints:{user_id} # 判断足够才扣减避免出现负数 if int(r.hget(key, balance)) points: raise ValueError(余额不足) r.hincrby(key, balance, -points) r.hset(key, updated_at, int(time.time()))注意decrease_points里先查后改不是原子的如果并发扣减两个订单存在超扣风险。真正的原子做法是用Lua脚本local balance tonumber(redis.call(HGET, KEYS[1], balance)) local points tonumber(ARGV[1]) if balance points then return -1 end redis.call(HINCRBY, KEYS[1], balance, -points) redis.call(HSET, KEYS[1], updated_at, ARGV[2]) return balance - points这个Lua脚本在Redis 6里执行是原子性的整个判断和扣减过程不会被其他命令插入。hash在这里的好处依然是“余额”和“变动时间”两个维度放在同一个key下维护成本低读取时一次HGETALL就能同时拿到两个字段业务层不用做多key聚合。7. 最后再聊几句经验我在生产环境用Redis hash好几年最大的感触是hash不是一种高深的数据结构但它是对“对象型数据”最自然的建模方式。只要你要缓存的东西有属性、有字段、需要局部更新hash基本上就是最优解。选型的时候别犹豫优先hash真遇到字段特别多或者访问特别集中的情况再考虑拆分和本地缓存。还有一个小的实践技巧分享给用Python和Java联调场景的朋友跨语言访问Redis hash时field的命名不要混用驼峰和下划线。客户端语言之间有自己默认的序列化方式但field名最终都会变成字节串命名不统一会直接导致不同语言看到的字段集合对不上。这个坑不起眼排查起来却非常费时间。如果条件允许强烈建议在项目初期就定一套hash字段命名规范最好能写进代码评审检查项。一个数据结构本身不复杂真正让项目复杂起来的是无数个“当初觉得没关系”的小选择累积出来的混乱。hash用好了你的Redis内存能省一半更新效率提升一个量级排查问题的时候也更清爽。
返回列表