接手一个维护了四五年的内部服务时我翻代码仓库满屏都是hmset。服务本身的逻辑倒没什么大问题就是这些 Redis 操作看起来特别复古。当时我身边几个同事的说法是能用不就行了改它干嘛但 Redis 官方文档早就把hmset标成了 deprecatedPython 的 redis-py 客户端也在较新版本里给这个方法加上了废弃提示。如果你在搜索引擎里把命令名顺手打成 hmse大概率还是会找到hmset的相关内容但真正落到项目里这个命令确实应该退休了。这篇文章记录的就是一次很普通的代码改造在 Python 项目里把hmset全面迁移到hset。我会把这件事拆成几层讲清楚为什么会有这次迁移两个命令到底差在哪代码具体怎么改、怎么验证以及我实际踩过的几个坑。不管你是刚接触 Redis 的 Python 新手还是正在给老项目做技术债清理的开发者这个过程都应该能直接参考。先说明一点hmset和hset写入 Redis 之后哈希类型的数据存储形态是完全一样的所以这次迁移本质上改的是代码里的 API 调用习惯不涉及任何存量数据搬移。1. 为什么会有这次迁移hmset 与 hset 的前世今生1.1 一个被标记废弃的命令早期的 Redis 里哈希类型有两个写入命令HSET和HMSET。HSET一次只能设置一个字段比如HSET user:1001 name 张三HMSET则支持一次设置多个字段比如HMSET user:1001 name 张三 age 28 city 上海。在那个阶段这种划分是有道理的单字段写入是高频操作批量写入是另一个需求两个命令各管一摊API 语义也算清晰。问题出在后来。Redis 官方在 4.0.0 版本对HSET做了扩展让它支持一次传入多个 field-value 对。这意味着HSET的能力完整覆盖了HMSET于是官方文档直接给HMSET打上了 deprecated 标记并明确建议开发者改用HSET。官方没有选择立刻移除这个命令主要是为了照顾老客户端、老脚本给迁移留足时间。但不删除不代表推荐用这个区别很多人没在意项目里的hmset就这么一代传一代一直传到今天。我在不少开源项目和公司内部代码里都见过这种场景代码是 2017 年甚至更早写的Redis 服务早就升到 6.x、7.x 了但代码里批量写哈希还在用hmset。问起原因多半是当时就这么写的后面没人动过。这就是典型的技术债单看每个调用都没问题但整体代码风格和 Redis 当前版本明显脱节。1.2 Redis 4.0 之后 hset 能力补齐从 Redis 4.0.0 开始HSET的完整命令格式变成了HSET key field value [field value ...]注意最后的中括号它表示 field-value 对可以重复出现。也就是说HSET user:1001 name 张三 age 28现在是合法的效果和HMSET user:1001 name 张三 age 28完全一样。多字段能力补齐之后HMSET的存在意义就被彻底抽空了。官方文档在介绍HMSET时开头就写自 Redis 4.0.0 起此命令被视为已废弃建议直接看HSET的说明。这类能力合并在软件演进里很常见。早期 API 把单数和复数场景拆成两个入口后来发现复数入口覆盖单数入口后保留两套反而增加了学习和维护成本于是统一到一个命令上。Redis 团队处理HSET和HMSET用的就是这个思路保留兼容但明确引导新代码走同一个命令。1.3 存量代码为什么迟迟不改既然官方从 4.0 就开始标记废弃到如今 Redis 都出到 7.x 了为什么还有大量项目在用hmset我自己分析下来主要是三个原因。第一个原因是能用。HMSET在服务端没有被移除redis-py 客户端也没有强制报错代码跑得好好的业务方没有感知自然没人动它。第二个原因是教程惯性。你随便搜一下python redis 哈希很多博客、教程、技术问答里给的批量写入示例还是hmset。新人在看这些资料学 Redis写出来的代码自然也是hmset。这就形成了某种循环老代码影响新代码新代码又变成了别人的老代码。第三个原因是改动成本不好评估。hmset在代码里往往不是一两个点而是散布在很多业务模块里。单个替换很简单但要把所有调用点找全、改完、验证再配合发布流程工作量就上来了。对一个以业务迭代优先的团队来说这种纯技术改造很容易被排在后面。但这三个理由在 Redis 版本持续迭代、代码审查工具越来越严格的背景下慢慢站不住脚了。新同事每次看到hmset都要问一句为什么不用hset解释成本也是成本代码规范检查工具里如果配了废弃 API 检测hmset就是一个常年亮着的告警。所以我说早改比晚改好现在改的成本远远低于未来某天被迫改的成本。2. 核心差异解析不只是少个 m2.1 命令层面的行为与返回值差异HSET和HMSET最表面上的区别是命令名差一个M但真正需要注意的差异在返回值。我先放一个对照表对比项HSETHMSET单字段写入支持支持多字段写入Redis 4.0 起支持一直支持返回值本次新增字段的数量整数固定返回 OK官方状态推荐使用deprecated建议改用 HSET这个返回值差异很容易被忽略但实际影响很大。举个例子在 redis-cli 里执行redis-cli HSET user:1001 name 张三 age 28 (integer) 2返回的2表示这两个字段都是这次新加进去的。如果字段已经存在返回的整数会相应变小如果所有字段都已存在返回值是0。而HMSET的执行结果永远是OK你无法从返回值判断这次操作到底新增了几个字段、更新了几个字段。从语义上看HSET的返回值更有信息量也更符合命令做了一件事返回结果告诉你做到了什么程度的理念。HMSET的OK其实只代表命令执行成功信息量很单薄。这也是官方推荐用HSET的原因之一。2.2 redis-py 里 hset 的三种调用姿势在 Python 的 redis-py 客户端里hset的方法签名比很多老教程写的要灵活得多。常规的三种调用方式如下。第一种是单字段写入import redis r redis.Redis(hostlocalhost, port6379, decode_responsesTrue) # 单字段写入 r.hset(user:1001, name, 张三)第二种是批量写入通过mapping参数传一个字典。这是迁移hmset时最常用、改动最小的写法# 多字段写入等价于旧代码里的 r.hmset(user:1001, {...}) r.hset(user:1001, mapping{ name: 张三, age: 28, city: 上海 })第三种是混合写法key/value和mapping同时传r.hset(user:1001, name, 张三, mapping{age: 28, city: 上海})第三种方式适合确定要更新某个核心字段顺便带上其他字段的场景日常用到的机会相对少一些。重点是第二种mapping参数传字典和旧代码hmset(key, dict)几乎是逐字对应迁移成本极低。我在改造时大部分位置就是简单地把hmset改成hset把原来的位置参数改成mapping字典逻辑完全不用动。2.3 返回值差异带来的业务影响前面说了命令层面对比这里具体到 Python 代码里看影响。旧代码里hmset返回的是True或Falseredis-py 把命令返回的OK转成了布尔值。老代码里常见的判断是这样if r.hmset(user:1001, {name: 张三, age: 28}): print(写入成功)注意hmset在 redis-py 里的返回值是True只要命令执行没报错就是True所以这个if判断基本是摆设恒为真。迁移成hset之后返回值变成了整数added_count r.hset(user:1001, mapping{name: 张三, age: 28}) print(added_count) # 2表示实际新增了 2 个字段如果旧代码里有依赖hmset返回真值的逻辑直接替换成hset后if r.hset(...)仍然能正常工作因为整数0是假值非零是真值。但语义就有点扭曲了hset返回0表示没有新增字段所有字段都已存在这时候走False分支其实和业务预期不一定匹配。我的建议是迁移时不要机械替换顺手看一下这个调用的返回值有没有被使用。如果只是写入后什么都不判断直接忽略返回值即可如果业务上有写入失败要重试之类的判断应该明确改成if r.hset(...) 0:或者用 try-except 捕获异常。这次迁移除了解掉技术债也是重新审视旧逻辑的好机会。3. 实操迁移从 hmset 到 hset 的完整改造流程3.1 环境准备与版本确认动手前先确认两件事redis-py 客户端的版本以及 Redis 服务端的版本。pip show redis看输出里的 Version 字段。较新版本的 redis-py3.4.0 之后的版本里hset已经完整支持mapping参数可以直接用来替代hmset。如果你的项目还在用特别老的 redis-py比如 2.x 时代hset可能不支持mapping那就得先升级 redis-pypip install -U redisRedis 服务端版本用下面命令确认redis-server --version实际操作中Redis 服务端只要不低于 4.0HSET命令就支持多字段写入。就算客户端和服务端版本都偏老只要升级到合理版本这一步就没有障碍。我在改造前还特意在本地用 redis-py 跑了个最小示例确认hset的mapping参数行为符合预期再开始全局替换。这种先小范围验证再全量动手的习惯能省掉很多返工时间。3.2 定位所有 hmset 调用点找调用点最直接的方式是全局搜索。在项目根目录下执行grep -rn --include*.py hmset ./如果你想把搜索范围收窄到真正的调用而不是注释或者字符串里的单词可以用这个更精确的表达式grep -rn --include*.py -E \.hmset\( ./先统计一下总量心里有个底grep -rn --include*.py -E \.hmset\( ./ | wc -l我在实际项目里遇到过调用点分散在十几个文件的情况有工具类、有业务模块、有定时任务脚本。不管你用命令行还是 IDE 的全局搜索第一步一定是把清单拉全。除了.py文件我还会顺手搜一下.md、.rst文档和测试用例里的hmset文档里的例子同样需要更新不然新同事看文档又学会了旧写法。3.3 三种常见场景的批量替换我这次迁移遇到的调用场景无非三种逐个说一下改法。场景一函数式批量写入。旧代码是这样r.hmset(user:1001, { name: 张三, age: 28, city: 上海 })改成r.hset(user:1001, mapping{ name: 张三, age: 28, city: 上海 })这种最简单把方法名换成hset原字典变成mapping的参数值。如果你的字典是通过变量传进来的改动更小user_data {name: 张三, age: 28, city: 上海} r.hset(user:1001, mappinguser_data)场景二先构造字典再写入。老代码经常这样写data {} data[name] 张三 data[age] 28 if some_condition: data[city] 上海 r.hmset(user:1001, data)改成hset后逻辑完全不变只是把最后一行换成r.hset(user:1001, mappingdata)场景三循环里逐字段写入。这类代码我遇到不多但确实存在for field, value in user_data.items(): r.hset(user:1001, field, value)这里没啥好说的redis-py 的hset单字段写法本来就支持保持原样即可。唯一提醒一点如果user_data字典很大且这些字段确实是一次性写入同一个哈希建议改成一次hset批量写入能明显减少网络往返次数。这个优化与本次迁移无关属于顺手做的小改进。3.4 迁移后的正确性验证替换完之后不能直接上生产验证这步不能省。我用的验证方案是在测试环境跑一遍完整的数据写入流程然后对比迁移前后写入的内容是否一致。最简单的方式是用hgetall读取整个哈希跟预期字典比对expected_data { name: 张三, age: 28, city: 上海 } r.hset(user:1001, mappingexpected_data) actual_data r.hgetall(user:1001) assert actual_data expected_data, f数据不一致: {actual_data} print(验证通过)如果你不想直接改业务代码可以写一个独立脚本同时往两个不同的 key 写入同一份数据一份用老的hmset写法一份用新的hset写法然后对比两个 key 的hgetall结果old_key test:old new_key test:new data {name: 张三, age: 28, city: 上海} r.hmset(old_key, data) r.hset(new_key, mappingdata) assert r.hgetall(old_key) r.hgetall(new_key) print(新旧写法结果一致)这种对比式验证更直观能直接证明hset和hmset写出来的数据没有差别。如果项目里有现成的接口测试或者单元测试跑一遍全量回归是非常值得的尤其是处理那些写入之后立刻读取的业务链路。4. 常见问题与避坑经验实录4.1 空 mapping 直接报错迁移后我遇到的第一个坑就是空字典。旧代码里hmset传入空字典时redis-py 不会发命令也不会报错实际上在 redis-py 实现里空 mapping 会被当作无效操作跳过但hset在同样情况下会直接抛出异常这是迁移后最容易踩中的坑。举个例子user_data {} r.hset(user:1001, mappinguser_data)这段代码会向 Redis 发送一个缺少 field 的HSET命令服务端直接返回ERR wrong number of arguments for hset command在 redis-py 里表现为redis.exceptions.ResponseError。所以迁移时如果调用点上方的数据有可能为空一定要加个判断if user_data: r.hset(user:1001, mappinguser_data)或者用 try-except 包住但我的习惯是优先加判断因为空 mapping 本来就意味着没有要写入的字段这个分支根本不需要执行写操作。4.2 管道操作里返回值时机在高并发场景下批量操作通常会放进 Pipeline。旧代码长这样pipe r.pipeline() pipe.hmset(user:1001, {name: 张三, age: 28}) pipe.expire(user:1001, 3600) pipe.execute()迁移后pipe r.pipeline() pipe.hset(user:1001, mapping{name: 张三, age: 28}) pipe.expire(user:1001, 3600) pipe.execute()这里命令本身没有差异但返回值时机要清楚在 Pipeline 里pipe.hset(...)返回的是 Pipeline 对象不是真正的命令结果。只有执行pipe.execute()之后返回的才是一个列表里面按顺序放着每条命令的结果。所以如果后续逻辑需要判断hset新增了多少字段不能直接看pipe.hset(...)的返回值得从execute()的结果里取results pipe.execute() # results[0] 就是 hset 的返回值 added_count results[0]这个点其实和hmset迁移本身无关但我在改造时看到不少同事在 Pipeline 里拿返回值拿到一半就卡住了顺手记在这里。4.3 存量数据到底要不要动这是每个听到从 hmset 迁移到 hset的人都会问的问题Redis 里已经用hmset写进去的存量数据需要迁移吗答案是不需要。原因前面也说过底层数据结构完全一样。Redis 的哈希类型不会记录这个哈希是用哪个命令写进去的HMSET写出来的存储结构和HSET写出来的没有任何区别。可视化工具 Redis Desktop Manager、另一个 Redis Desktop Manager 里看这两个命令写入的哈希也看不出任何不同。所以这次迁移只改代码不动数据。有一种情况例外如果你的迁移不只是换命令名而是想把数据从一个 key 搬到另一个 key比如重新规划 key 的命名空间那就需要写迁移脚本。这种场景跟hmset/hset本身关系不大重点是别用hgetall一把梭把整个大哈希读进内存而是用hscan_iter分批读取、批量写入避免大 key 迁移时把进程内存和网络带宽打爆。4.4 旧版本 redis-py 的兼容性最后提醒一下依赖版本问题。如果你的项目环境比较老旧比如 Python 2.7 时代残留的服务redis-py 版本可能停留在 2.x 甚至更低。这时候直接写r.hset(key, mapping...)可能根本跑不通因为老版本里hset还不支持mapping参数。碰到这种情况我的建议是分两步走先升级 redis-py 到新版本再做hmset到hset的替换。升级 redis-py 的兼容性风险不算大因为这个库的 API 整体变化不大最需要注意的是decode_responses参数的默认行为在新版本里仍然是关闭的这个不会变。升级前看一眼项目里有没有用到特别冷门的客户端方法没有的话直接升就行。如果你因为某些原因暂时升不了 redis-py又想把代码里的hmset清理掉那就只能用单字段hset循环写入来模拟但这样会多出多次网络往返不推荐。相比起来升级依赖才是正路。我在实际项目里还加了一道保险在 CI 流程里加一条 grep 检查发现hmset就直接报错防止以后有人再往代码里写旧命令。这个步骤极其便宜作用却很实在就像给代码仓库立了一个规矩。如果你也在做类似的迁移强烈建议顺手加上。这次迁移做完我最大的体会是技术债清理看着繁琐但真正拆解下来每一步都不复杂。关键是把为什么改想明白把坑提前排掉剩下的就是用工具把重复替换做干净。如果你手头也有用了很久的老项目不妨打开全局搜索看看里面有没有hmset的影子。