ARTICLE DETAIL

资讯详情

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

Redis从hmset到hset平滑迁移实践与避坑指南

Redis从hmset到hset平滑迁移实践与避坑指南 最近在整理项目代码的时候发现一堆 Redis 操作代码里还躺着几十处 hmset 调用。用 redis-py 跑测试控制台里全是 DeprecationWarning看得我密集恐惧症都犯了。hmset 这个命令在 Redis 4.0 之后就被官方标记为废弃功能上完全被 hset 合并只是当时为了让大家平滑过渡才多留了一个名字。但 Python 生态里的 redis-py 对废弃接口的警告做得比较明显而且随着客户端版本升级保不齐哪个大版本就直接把这个方法删了。这篇文章就专门聊聊怎么用 Python 操作 Redis 时把项目代码从 hmset 稳妥地迁到 hset以及迁移过程中我实际踩到的一些坑和总结出来的排查思路。不管你是刚把 redis-py 升级上来看到警告还是想在代码 review 时把历史债务清一清这篇应该都适合你。先简单交代一下背景。Hash 是 Redis 的五大基本数据类型之一特别适合存储对象属性、用户资料、商品详情这种“一个 key 下面挂一堆字段”的场景。hmset 和 hset 都属于 Hash 类型操作命令老项目里用 hmset 批量写字段很常见。但官方在 Redis 4.0 里把 hmset 标记成 deprecated 之后整个 Python 生态的工具链都在往 hset 上收敛。这篇文章就是围绕这个迁移过程展开从命令差异、客户端实现、改造步骤、排查手段到上线注意事项一条龙讲清楚。1. 迁移背景与方案选型思路1.1 为什么 hmset 会被标记为废弃要搞懂这次迁移先得知道 hmset 是怎么被“优化”掉的。Redis 早期版本里hset 命令只支持设置单个字段语法是HSET key field value。你要是想给一个 hash 同时设置多个字段就得要么写好几条 hset要么用 hmset 这个专用命令。hmset 的语法是HMSET key field value [field value ...]目的就是解决“批量设置字段”这个需求。问题出在 Redis 4.0。这个版本给 hset 增加了多字段能力现在的语法可以写成HSET key field value [field value ...]和 hmset 的能力完全重叠了。官方又不希望命令集无限膨胀于是就把 hmset 标记为 deprecated推荐大家统一使用 hset。这是很典型的命令收敛策略能用一套 API 解决的就不保留两套。这里有个容易忽略的细节hmset 并没有被立刻删除直到现在 Redis 7.x 里它还能跑。也就是说你在很老的项目里继续用 hmset短期内也不会报错。但“能跑”和“该用”是两回事。一方面官方文档已经把 hmset 挪到了 deprecated 区域另一方面 redis-py 这种主流客户端会给出废弃警告第三方库也在逐步清理相关实现。早点迁移是给未来的升级扫清障碍。1.2 迁移前的环境盘点与影响面评估动手改代码之前我建议先花十分钟做一次环境盘点。这一步做扎实了后面改造会非常顺。你需要确认四件事Redis 服务端版本。最简单的方式是在 redis-cli 里执行INFO server看redis_version字段。如果服务端版本小于 4.0那这次迁移方案要完全不一样因为新版 hset 的多字段特性在旧服务端上根本不起作用。redis-py 客户端版本。在 Python 环境里执行pip show redis或者在项目依赖文件里查一下。redis-py 2.x、3.x、4.x、5.x 对 hset 多字段的支持程度不一样越新的版本对 mapping 参数的支持越完整。项目里 hmset 的调用规模和位置。用全局搜索先把所有调用点拉出来按模块、按写入频率分类搞清楚哪些是核心链路哪些是低频任务。是否存在第三方封装。如果你的项目用了 django-redis、celery 的 redis 后端、或者是自己封装的 cache 工具类那 hmset 可能藏在封装层里只搜业务代码会漏掉。做完盘点你会得到一个清晰的改造边界。我的经验是如果服务端和客户端都是 4.0 以上那这次迁移就是个“查找-替换微调”的体力活如果有老版本组件参与就得考虑兼容方案后面第 4 章会专门讲。2. 核心差异解析hmset 与 hset 的底层逻辑2.1 Redis 命令层面的差异对比先看最直观的命令层面对比。用 redis-cli 实际操作一下你就能感受到两者的关系127.0.0.1:6379 HSET user:1 name tom (integer) 1 127.0.0.1:6379 HSET user:1 age 18 name tom (integer) 1 127.0.0.1:6379 HMSET user:1 age 18 name tom OK命令本身可以互相替换吗基本可以。但有一个差异非常关键返回值不一样。hmset 执行成功后固定返回 OK而 hset 返回的是“本次操作实际新增的字段数量”。如果你用HSET user:1 age 18 age 18这种重复字段返回值会是 0因为字段已经存在且没有新增。对于大多数业务来说我们不关心这个返回值但如果你在代码里判断了hmset的返回值或者用返回值做逻辑分支迁移时一定要同步处理。再补充一个兼容性细节Redis 4.0 之前hset只能接收一个 field-value 对传入多个会直接报错而 hmset 天生支持多字段。所以老的客户端封装里hset 和 hmset 的分工是明确的。这也解释了为什么有些历史代码非要绕一圈用 hmset因为在那个时代 hset 确实能力不够。我把两者主要的差异整理成了一张表方便你对照对比项HMSETHSET基本语法HMSET key field value [field value ...]HSET key field value [field value ...]返回值固定返回 OK返回新增字段数量Redis 4.0 前支持多字段支持不支持只能单个字段Redis 4.0 后多字段能力支持支持与 hmset 等价官方状态已废弃deprecated推荐使用适用场景兼容老代码所有 Hash 写入场景2.2 redis-py 客户端中的实现差异Python 操作 Redis绝大多数场景用的是 redis-py 这个库。在 redis-py 里hmset 和 hset 的封装路径已经悄悄发生了变化。老代码常见的写法是这样的import redis r redis.Redis(host127.0.0.1, port6379, db0) # 旧写法hmset r.hmset(user:1, {name: tom, age: 18})新推荐写法是这样的import redis r redis.Redis(host127.0.0.1, port6379, db0) # 新写法hset 配合 mapping 参数 r.hset(user:1, mapping{name: tom, age: 18})在 redis-py 4.x 版本里你调用r.hmset(...)时底层其实是转成了 hset 命令发送给 Redis同时抛出一个 DeprecationWarning。源码里大致是这样的逻辑hmset 方法内部把参数重新组装成 mapping 形式再调用 hset 去执行。换句话说说从命令层面看redis-py 4.x 已经替你做了一部分迁移工作但代价就是你每次调用都会带着警告运行。到了 redis-py 5.x官方进一步强化了这种引导。如果你还在用 hmset警告依然存在而且文档里已经明确写了应该使用 hset 加 mapping 参数。我个人的建议是别等客户端大版本彻底删除 hmset 之后再动现在就改成本最低。还有一点需要留意。redis-py 中 hset 有两种调用形态# 形态一单字段写入直接传 field 和 value r.hset(user:1, name, tom) # 形态二多字段写入用 mapping 参数传字典 r.hset(user:1, mapping{name: tom, age: 18})单字段场景不要硬套 mapping。r.hset(user:1, name, tom)这种写法更直接少构造一个字典代码也更清晰。2.3 序列化与类型处理注意事项hash 操作里最容易出问题的是 value 的类型。Redis 本身只会存字符串但 Python 传入的可能是数字、布尔值、列表、字典甚至 datetime 对象。redis-py 默认会把参数编码成字节串数字和布尔值会自动转成字符串这个过程比较友好。比如你传 age18存进去的是18取出来也是18如果业务里要做数值计算记得自己 int() 一下。但字典、列表这种复合类型就麻烦了。直接r.hset(user:1, mapping{info: {city: sh}})会抛出异常因为 redis-py 不知道该怎么把一个字典编码成字符串。老项目里如果用过hmset存复合类型通常都会在业务层先做json.dumps()。迁移到 hset 时序列化逻辑保持原样即可不要因为换了命令就顺手改了序列化方案否则线上数据格式会不兼容。我见过一个真实案例原代码用hmset存入一个 JSON 字符串字段名是data迁移的人图省事直接传了 Python 字典结果 redis-py 把字典转成了{city: sh}这种 Python 风格的字符串前端解析 JSON 直接报错。所以迁移时命令可以换序列化路径千万别乱动。3. 迁移实操从 hmset 到 hset 的完整改造流程3.1 快速定位代码中所有 hmset 调用点改造第一步把项目里所有 hmset 调用点找出来。我习惯用命令行全局搜又快又准。在项目根目录执行grep -rn hmset --include*.py .这个命令会把所有.py文件里包含 hmset 的行都列出来带文件名和行号。如果你的项目混用了其他语言比如 Java、Go也可以把路径指到对应目录或者直接把--include*.py去掉搜全项目。搜完之后我建议再额外做一次针对“字符串拼接命令”的搜索因为有些人写代码不喜欢用客户端封装而是通过execute_command或者 Lua 脚本直接拼命令# 也有这种写法容易漏掉 r.execute_command(HMSET, user:1, name, tom, age, 18)这部分 grep 搜hmset字符串本身也能覆盖到但如果你是把命令放在配置项或者常量里还需要看上下文确认。我的习惯是搜索结果出来之后逐个打开对应文件确认这行代码确实是 Redis 的 hmset 操作而不是注释、日志文本或者业务字符串。定位完成后按模块或写入频率排个优先级。核心链路上的先改边缘逻辑后改这样每改完一个模块你都能在小范围内验证一次不会攒一个超大的 diff 导致 review 困难。3.2 分场景改造与代码示例接下来是重头戏按不同场景写改造代码。我总结了四个出现频率最高的场景直接给出前后对照。场景一单字段写入。这种直接用 hmset 属于典型的“杀鸡用牛刀”原来可能只是为了统一写法。改造最简单# 旧代码 r.hmset(user:1, {name: tom}) # 新代码 r.hset(user:1, name, tom)场景二多字段写入用字典一次性提交。这是最常见的用法改造时把字典挪到 mapping 参数里user_info {name: tom, age: 18, city: shanghai} # 旧代码 r.hmset(user:1, user_info) # 新代码 r.hset(user:1, mappinguser_info)场景三value 是复合类型需要先序列化。保持原有序列化方式只改命令入口import json user_info { name: tom, address: json.dumps({city: shanghai, street: nanjing}), } # 旧代码 r.hmset(user:1, user_info) # 新代码 r.hset(user:1, mappinguser_info)场景四批量写入多个 hash key。通常在初始化数据、缓存预热或者批量导入时会用到。这里要注意不要在循环里一次一次调 hset那样会有大量网络往返。正确姿势是用 pipeline 批量提交import redis r redis.Redis(host127.0.0.1, port6379, db0) users [ {user:1, {name: tom, age: 18}}, {user:2, {name: jerry, age: 20}}, ] # 新代码pipeline 批量写 pipe r.pipeline(transactionFalse) for key, mapping in users: pipe.hset(key, mappingmapping) pipe.execute()transactionFalse表示这些命令不打包成事务执行只是减少网络往返。如果你的业务场景要求同一批 hash 写入要么全成功要么全失败那就用transactionTrue。不过说实话缓存场景下很少用事务具体看业务约束。还有一个细节如果你在改造过程中需要同时兼容 Redis 3.x 和 Redis 4.x那 mapping 这种写法就不能直接用在低版本服务端。这时候兼容方案有两种一种是判断服务端版本另一种是干脆在低版本下循环单字段 hset。这段我会在第 4 章详细展开。3.3 迁移后的验证与回归测试代码改完不能直接上线得做一轮验证。我一般按三个层面来功能验证、数据一致性验证、性能回归。功能验证很简单跑一遍项目现有的单元测试和集成测试。如果你的团队测试覆盖度不错这一步能挡住大部分低级错误。但很多老项目测试覆盖稀烂所以我还会写一段小的验证脚本专门对比迁移前后写入的数据是否一致。下面是我常用的验证脚本思路import redis import json r redis.Redis(host127.0.0.1, port6379, db1) # 模拟迁移前用 hmset 写入的数据 r.delete(user:old) r.hmset(user:old, {name: tom, age: 18, info: json.dumps({city: sh})}) # 模拟迁移后用 hset 写入的数据 r.delete(user:new) r.hset(user:new, mapping{name: tom, age: 18, info: json.dumps({city: sh})}) # 对比两个 key 的 hash 内容 old_data r.hgetall(user:old) new_data r.hgetall(user:new) print(old:, old_data) print(new:, new_data) assert old_data new_data, 数据不一致这里有一点要注意hgetall返回的 key 和 value 都是字节串对比时不用额外 decode直接比字节串是等效的。如果业务代码里有 decode 逻辑测试时可以等值判断字节串也可以decode(utf-8)之后再比取决于你想验证的层次。性能回归也不能跳过。hset 和 hmset 从命令执行效率上几乎没有差别真正影响性能的是客户端封装和网络交互方式。为了保险我会用timeit快速测一下同样 1 万次写入的耗时差异尤其是在用了 pipeline 之后耗时应该比旧的逐条 hmset 更低。如果出现性能劣化优先检查是不是无意中把多字段循环成了单字段写入。import timeit import redis r redis.Redis(host127.0.0.1, port6379, db1) payload {ffield_{i}: i for i in range(100)} def old_write(): for _ in range(100): r.hmset(bench:old, payload) def new_write(): for _ in range(100): r.hset(bench:new, mappingpayload) print(hmset:, timeit.timeit(old_write, number10)) print(hset :, timeit.timeit(new_write, number10))如果线上数据量很大不建议直接在压测环境跑完整流量。先在测试环境把迁移后的代码跑一个晚上观察慢查询日志和异常日志确认没问题了再灰度上线。4. 常见问题与排查技巧实录4.1 迁移后字段丢失或写入失败的原因定位实际迁移中我遇到最多的几类问题基本都是细节导致的。第一类字段值里有None。Python 的None在 redis-py 里无法直接编码会抛异常。原来的 hmset 也一样会抛所以如果你之前没事现在突然挂了说明你把之前靠某种方式“过滤掉”的 None 值带进来了。解决方案是在构造 mapping 之前做一次数据清洗把 None 值剔除或者转成空字符串。user_info {name: tom, age: None} # 会抛异常 r.hset(user:1, mappinguser_info) # 先清洗再写入 clean_info {k: v for k, v in user_info.items() if v is not None} r.hset(user:1, mappingclean_info)第二类字段名不是字符串。Redis hash 的 field 理论上可以是任意字符串Python 端如果传了 int 类型的字段名redis-py 会帮你转成字符串。但如果传的是元组、列表这种不可哈希或无法简单编码的类型就会出问题。迁移时尽量保持字段名是 str别给自己埋坑。第三类客户端版本太老。某些 redis-py 旧版本比如 2.x的hset方法签名里没有mapping参数你照葫芦画瓢写r.hset(key, mapping...)会直接报TypeError。解决方案是升级客户端版本或者退回成循环单字段 hset 的写法for field, value in mapping.items(): r.hset(key, field, value)第四类Redis 服务端版本太老。这个和第 2 章说的兼容性有关。如果服务端是 Redis 3.xHSET key field1 val1 field2 val2这样的多字段命令会直接报ERR wrong number of arguments for hset command。这个报错特征很明显看到它就知道是服务端版本不支持。4.2 老版本 Redis 服务端的兼容性处理现在还在用 Redis 3.x 的项目不算多但也不是没有。如果你的服务端版本低于 4.0hset 多字段能力不存在只能走兼容方案。最简单可靠的方式是在代码里根据服务端版本做分支判断import redis r redis.Redis(host127.0.0.1, port6379, db0) # 取一次服务端版本缓存起来 server_version r.info(server)[redis_version] def m_hash_set(key, mapping): major_version int(server_version.split(.)[0]) if major_version 4: r.hset(key, mappingmapping) else: for field, value in mapping.items(): r.hset(key, field, value)这样写的好处是老服务端能用新服务端也享受多字段的原子性。坏处是低版本分支在循环里逐条写如果字段很多会有多次网络往返。可以降级用 pipeline 改善def m_hash_set(key, mapping): major_version int(server_version.split(.)[0]) if major_version 4: r.hset(key, mappingmapping) else: pipe r.pipeline(transactionFalse) for field, value in mapping.items(): pipe.hset(key, field, value) pipe.execute()其实大多数项目在迁移前已经升级到了 Redis 6.x 或 7.x兼容分支可能一辈子走不到。但加这个判断的成本很低风险却小很多。如果你的 Redis 版本由基础设施团队统一管理我建议还是先做升级再改代码这样代码层面会干净很多。4.3 如何优雅处理 DeprecationWarning迁移过程中最直观的“催债信号”就是控制台里的 DeprecationWarning。它的典型长相是这样的DeprecationWarning: hmset is deprecated. Use hset(mapping...) instead. r.hmset(user:1, user_info)这个警告在 redis-py 4.x 里是通过warnings.warn抛出来的。Python 默认不会把所有警告都打印出来但如果你在测试框架或者启动脚本里把 warnings 设置为default那控制台就会被刷屏。处理策略就一条把代码里的 hmset 全部改掉改完警告自然消失。千万不要图省事在代码开头加import warnings warnings.filterwarnings(ignore, categoryDeprecationWarning)这种做法能把所有类似警告都屏蔽掉看着是安静了实际上是把问题延后了。客户端真正删除 hmset 的那一天你的项目会在毫无预兆的情况下突然挂掉。如果是第三方库内部还在用 hmset导致你的项目里出现无法消除的警告那先确认这个库是否还有新版有新版就升级依赖没有新版就提 issue或者在确认无风险的前提下只针对那个模块做局部过滤不要全局屏蔽。另外pytest 场景下你可以通过pytest.ini配置把警告变成错误倒逼你尽快清理filterwarnings error::DeprecationWarning不过在存量代码很大的项目里我不建议一上来就这么干否则测试集可能直接红一片。可以先用-W error::DeprecationWarning跑一个小模块逐步清理。4.4 上线监控与应急回滚建议迁移完成后的上线过程不要一把梭全量发布。我们当时的做法是先切一个边缘模块观察一段时间再逐步扩大。监控项主要看三个Redis 慢查询日志。hset 和 hmset 在同等条件下执行开销几乎一样如果慢查询突然变多先查是不是 pipeline 用法不对导致命令被拆成了大量小请求。应用异常日志。重点看是否有ResponseError、TypeError这类异常。大部分写入失败都会在日志里留下痕迹。数据抽查。上线后每小时用HGETALL抽几个 key 看数据格式尤其是那些有嵌套 JSON 结构的字段确认序列化路径没有被意外改动。回滚预案方面hash 的写入命令 hset 和 hmset 在最终写入格式上是完全兼容的不存在新命令写出来的数据旧代码读不了的问题。这一点是这次迁移很幸运的地方回滚不需要做数据修复只需要把应用代码切回旧版本即可。但要注意一个边界如果你的迁移过程中顺手调整了序列化方式比如原来存 JSON 字符串现在直接存 dict那回滚就会出现数据格式不一致。所以迁移时要坚持“只换命令不换序列化”把风险控制在最小范围。最后再分享一个容易漏的细节这次迁移踩过的坑不少要我说最值得记下来的不是命令本身而是“别只改业务代码忘了改测试代码和文档”。我当时花了快一个小时把业务里的 hmset 全部改完结果 CI 一跑发现好几个测试用例里 mock 断言还在验证 hmset 被调用mock 的assert_called_once_with全部失败。改测试代码的耐心甚至比改业务代码还多。如果你项目里还有那种直接断言 Redis 调用的单元测试记得同步搜索一下assert_..._called_with相关的部分。再就是项目文档、接口说明、README 里如果提到了写入 hash 的示例一并更新掉。文档里的老代码最容易误导后来人也最容易被人忽略。迁移这类事情看起来简单真正做起来全是细节。希望这篇文章能帮你把细节都提前踩平少走点弯路。
返回列表