Redis数据类型实测:编码切换、内存开销与选型避坑指南

发布时间:2026/10/13 4:37:40
Redis数据类型实测:编码切换、内存开销与选型避坑指南 做这份 Redis 数据类型验证报告之前我已经用 Redis 跑了好几年业务。真要说会哪几种类型String、Hash、Set、List、ZSet 谁都能聊两句可线上该出问题还是出问题。最典型的就是 WRONGTYPE同一个 key有人当字符串存有人当哈希去取翻代码查半天才发现当初选类型根本不是出于设计只是顺手。另一个促使我做完整验证的原因是内存数据量差不多有的 key 只占几百字节有的却能吃掉几十 MB光靠文档拍脑袋根本解释不了。于是我把 Redis 7.2 装进独立环境逐个类型测命令行为、内部编码、阈值切换和内存变化整理成验证报告。这篇文把验证思路、实测结果和排坑经验全部摊开适合正在做 Redis 选型、或者被线上类型问题折腾过的人参考。1. 动手之前先定位清楚验证报告到底要覆盖哪些点1.1 类型验证不是命令能用而是验证边界行为很多人理解的数据类型验证就是把文档里的命令都执行一遍看到输出符合预期就算完事。我做完第一轮测试后发现这种方式对生产环境的帮助非常有限。真正影响线上稳定性的从来不是这个类型支持哪些命令而是几个文档里一笔带过的角落内部编码什么时候切换、切换之后是否可逆、大 key 对阻塞耗时的影响、不同 Redis 版本之间行为差异这些东西在博客里说得模棱两可必须自己实测才敢写进结论。所以我把验证报告拆成了四层。第一层命令语义层确认每个类型对外表现和文档一致比如 List 的阻塞读取、Set 的随机弹出、ZSet 的同分排序规则。第二层内部编码层用 OBJECT ENCODING 观察编码变化搞清楚阈值在哪里。第三层内存层用 MEMORY USAGE 和 INFO 量化开销。第四层异常层记录 WRONGTYPE、O(N) 命令、事务里的类型错误实际会发生什么。报告真正的价值在第 2 到第 4 层因为这几层不实测光凭感觉很容易翻车。还有一个私心这份报告要能复制。我把环境、版本、配置、命令全部记录下来任何人拿着报告在相同版本上跑一遍应该能复现同样的结果。如果只是得出一堆这个类型很好用的结论那还不如直接去翻官方文档。1.2 验证环境怎么搭版本、工具和数据来源Redis 的底层实现变化很快验证报告的版本前提必须写清楚。我这边用的实例是 Redis 7.2单机部署、默认配置测试期关掉 AOF 和持久化避免磁盘刷写影响性能读数。跑命令主要用 redis-cli批量生成数据用简单的 Python 脚本调 redis-py压测用 redis-benchmark 做参考但没有把 benchmark 的数字当成绝对结论单机单线程下数据太容易受机器影响。项目我的取值说明Redis 版本7.2.x影响编码名称和配置项报告同时标注与 6.x/8.x 的差异部署方式单机单实例验证行为与时序不涉及集群分片数据量每类型从 1 条测到百万条覆盖编码阈值前后的行为工具redis-cli、redis-py、redis-benchmark交互验证用 cli批量构造数据用脚本需要提醒的是如果你线上还在跑 Redis 5.x 或 6.x很多配置项叫 ziplist 而不是 listpackOBJECT ENCODING 的输出也可能不一样。验证报告如果要用于线上环境最好的做法是在和生产同版本的测试实例上重新跑一遍不要直接拿文档结论套。2. 五种基础类型实测编码、阈值和复杂度2.1 String 的三种编码int、embstr、rawString 看着是最没技术含量的类型实际上编码细节最多。验证脚本里我先 SET 一个纯数字再 SET 一个短字符串再 SET 一个长字符串然后用 OBJECT ENCODING 逐个看。结果和我预期一致纯数字走 int长度不超过 44 字节的字符串走 embstr超过 44 字节走 raw。127.0.0.1:6379 SET counter 10086 OK 127.0.0.1:6379 OBJECT ENCODING counter int 127.0.0.1:6379 SET greeting hello redis OK 127.0.0.1:6379 OBJECT ENCODING greeting embstr 127.0.0.1:6379 SET long_text 这里是一段超过44字节的字符串内容用来观察编码切换 OK 127.0.0.1:6379 OBJECT ENCODING long_text raw这个 44 字节的门槛是 64 位系统上的常见结果背后是 redisObject 和 SDS 头部加总之后刚好跨过了一个内存分配阈值小于它时对象和字符串可以一起分配大于它就得分两次分配。实测更有价值的是另一个现象只要对 embstr 做一次修改比如 APPEND 一个字符编码立刻变 raw而且不会因为之后把内容删短就退回 embstr。所以如果你有一个会被频繁追加的字符串它从一开始就是 raw不用纠结优化空间。String 的验证里我还确认了 INCR 族的原子性。文档说 INCR 是原子的实际测试中我用两个并发循环对同一个 key 做 INCR最终结果等于两个循环次数之和不会丢更新。这个结论意味着计数器场景必须用 INCR而不是 GETSET 拼出来的伪原子操作后者在并发下一定会出问题。内存方面我批量写入了 100 万个长度为 10 的字符串 key用 INFO memory 观察前后差值大约占 100 多 MB。这个数字包含 key 字典、redisObject、SDS 和 value 本身的开销远不止 100 万字节。很多人以为每个 key 就存了 10 个字节实际开销大得多这也是为什么类型选型报告里内存这一节必须放前面的原因。2.2 List 的分块机制从 ziplist 到 listpack 的年代List 验证时我特别关注了底层存储结构。早年间 List 用双向链表节点开销大后来改成 quicklist用多个 ziplist 分块存储到了 Redis 7.x块内部换成了 listpack。我这边 7.2 环境上小列表的 OBJECT ENCODING 返回 listpack数据量持续加大后会变成 quicklist 分块结构不同版本对编码名的叫法会有差异网上很多写着 ziplist 的文章都是老黄历直接参考容易误判。用一组实测数据看 List 的行为LPUSH 进去 10 万个元素后LRANGE 0 1 能立刻取出头元素但 LINDEX 取中间某一位置耗时随着列表长度线性变差。文档里 LINDEX 的复杂度是 O(N)这个 N 在 quicklist 结构下虽然不会逐字节遍历但直觉上就是你离头越远越慢。如果业务上有随机下标访问的诉求List 不是合适选择应该考虑 Hash 或者 ZSet按 ID 取。List 作为队列的话我验证了 LPUSH BRPOP 的阻塞语义。BRPOP 会一直阻塞到有元素或者超时多个消费者同时阻塞时新消息会交给先阻塞的那个。这个行为对任务队列很关键因为消费者重启之后可能把没有处理完的任务留在队列里需要额外的确认机制才能避免重复消费。很多团队用 List 做消息队列数据量小的时候挺顺消息积累到几十万条以后队列尾部的大 key 会让异常处理变得很棘手。2.3 Hash 的拥塞切换listpack 与 hashtableHash 是对象数据缓存的首选可我见过太多把 Hash 用坏了的例子。验证从触发条件入手默认配置下字段数不超过 128 且每个字段值不超过 64 字节时Hash 用 listpack 紧凑存储只要字段数超过 128或者某个字段值超过 64 字节整个 key 就升级成 hashtable。# 前 128 个字段写完后 127.0.0.1:6379 OBJECT ENCODING profile:001 listpack # 第 129 个字段加进来 127.0.0.1:6379 HSET profile:001 f:129 x 127.0.0.1:6379 OBJECT ENCODING profile:001 hashtable这个切换最坑的地方是不可逆。我在验证脚本里试过把 Hash 加到 200 个字段触发 hashtable然后删到只剩 10 个字段OBJECT ENCODING 依然是 hashtable。也就是说只要在业务高峰期不小心让某个 Hash 越过阈值后面即使数据恢复正常它也回不到紧凑态内存和读写开销都保持在高位。正确的做法是在设计阶段就估算单 key 的字段规模超过 128 就要考虑拆分而不是指望运行期自动优化。命令复杂度上HGET/HSET 在 listpack 下接近 O(1)在 hashtable 下也是 O(1)但 HGETALL 无论哪种编码都是 O(N)。验证时我对一个 50 万字段的大 Hash 执行 HGETALL单条命令阻塞时间超过了 200ms。这个测试提醒我如果想整体读出来做缓存重建大 Hash 真是灾难。更合理的姿势是 HSCAN 分段拉取或者把大字段拆成多个小 Hash。2.4 Set 的 intset 升级加一个字符串就变脸Set 的验证数据非常直观。默认配置 set-max-intset-entries 为 512当集合里全部是整数且数量不超过 512 时内部用 intset一旦数量超过 512或者加入了一个非整数元素编码立刻切成 hashtable。从内存看切换前后能差出一个数量级几百个整数时几 KB 就够变成 hashtable 之后同样数量的元素内存会明显上涨。更需要注意的是这个升级的滞后性。我在测试里加了一个字符串编码变 hashtable再把字符串删掉集合又回到只剩整数但编码不会退回 intset。这个特性和 Hash 很像Redis 只提供由小变大的自动升级不提供由大变小的自动降级。很多写完就忘的 Set key其实全都以 hashtable 形态在内存里躺着等内存告警才发现已经晚了。命令行为方面SADD/SREM/SISMEMBER 都是 O(1)适合做快速去重判断但 SMEMBERS 是 O(N)大集合慎用要遍历就老老实实走 SSCAN。SPOP 和 SRANDMEMBER 的差别也值得写进报告SPOP 会从集合里移除元素SRANDMEMBER 不会抽奖场景选错命令就会把数据抽没了。另一点是 SPOP 在集合被并发写入时删除顺序不稳定需要保证随机性时要么限制并发要么接受近似随机。2.5 ZSet 的双结构listpack 是小身材skiplist 是大场面ZSet 是五种基础类型里结构最复杂的。小数据时它用 listpack 把 member 和 score 按顺序紧凑存起来超过阈值后它同时维护一个 skiplist 和一个 dictskiplist 负责按 score 排序dict 负责按 member 找 score。验证时最容易观察到的就是 OBJECT ENCODING 在 128 个 member 前后的变化。我在 ZSet 里批量写入 100 万个 member 后做了几个实测ZADD 单条写入耗时随规模增加不明显因为 O(log N)ZRANGE 输出前 100 名很快ZSCORE 查任意 member 也快靠的是那份 dict。但 ZSet 内存开销显著高于 Hash 和 Set每个 member 在 skiplist 和 dict 里都有节点。如果只是做去重或者简单的成员关系不要去碰 ZSet它的写放大和读放大都更大。排序规则也值得验证同 score 的成员按字典序排列。这在排行榜场景里直接决定名次如果两个用户分数相同谁的名字在字典序前面谁排前面。我把这条单独记了一行因为产品经常会质疑为什么我和他同分他排我前面有了实测记录就能直接给出答案。延迟队列的话ZRANGEBYSCORE 加 ZREM 的组合要注意两点一是取和删要原子二是 score 用毫秒级时间戳的话同一毫秒内进来的任务会按字典序而不是按入队顺序执行需要自己处理一下这个细节。3. 扩展类型的验证心得有些类型是伪装者3.1 Bitmap披着 String 外衣的位图工具Bitmap 不是一个独立的数据类型底层就是 String。我第一次用 TYPE 验证某个 SETBIT 过的 key返回 stringOBJECT ENCODING 是 raw当时愣了一下。确认之后反而更踏实String 的过期、持久化机制对它全部生效。SETBIT 可以按位操作最大 offset 到 2^32-1也就是单个 key 最多能表达 512MB 的位图。我用 SETBIT 把第 1000 万个 bit 置 1然后用 MEMORY USAGE 看这个 key 占用了多少大约是 1000 万除以 8等于 125 万字节1.2MB 左右。这个计算非常直观所以 Bitmap 适合按用户 ID 做签到、在线状态、渠道标记这类场景一位一用户内存可控。离线统计用 BITCOUNT 就能拿到总人数跨天统计可以用 BITOP 做 OR/AND。实际使用中要注意 offset 别用普通 32 位整数超过 2^31 就可能踩到负数坑BITFIELD 可以对位做更精细的计数但先要确认值是按无符号还是带符号解释的。3.2 HyperLogLog12KB 内存换一个低误差估计值HyperLogLog 也是 String 类型但它的内存基本恒定大约 12KB。验证时我用 Python 随机生成了 100 万个不同的 userid 全部 PFADD 进去PFCOUNT 返回了约 99.2 万误差 0.8% 左右和官方宣称的标准误差 0.81% 吻合。这个数据说明HLL 用来做 UV 这类量级统计完全够用但要是想精确回答到底有多少个它给不了。HLL 有两个容易踩的坑。第一它只能回答大约多少个不重复元素不能返回每个元素是什么所以不要指望拉出明细。第二PFADD 对重复元素是幂等的同一个 userid 加多少次都只算一个这个特性对 UV 天然友好但如果你需要同时知道 PV 和 UV就得分别记录。验证环境里我没有刻意调整稀疏编码转稠密编码的参数如果业务基数特别大可以观察一下内存是否还稳定在 12KB 附近有大偏差再深入查。3.3 GEOZSet 加了一层编码协议GEO 的底层就是 ZSet加了经纬度编码成 52 位 geohash 作为 score。验证时我用 GEOADD 存了几组坐标再用 TYPE 查看返回 zsetOBJECT ENCODING 的表现也跟普通 ZSet 一致。这意味着 ZSet 的所有特性它都有同时也继承了 ZSet 的毛病同一个 key 里元素过多时内存和写入开销都会上去。距离查询建议用 GEOSEARCH 而不是老的 GEORADIUS前者是 Redis 6.2 之后推荐的方向参数更清晰。实测里 GEOSEARCH 默认返回结果不排序要带 BYRADIUS 和 ASC 才会按距离排序。另外geohash 是有精度的距离很近的两点可能落在同一个格子边界点判断要依赖返回的坐标做二次过滤。做附近的人这类功能时我习惯在代码里用经纬度先做个粗略的矩形预筛再交给 Redis 算精排减少大范围搜索的开销。3.4 Stream比 List 更像队列的独立类型Stream 在 Redis 5.0 才出现验证时我花的时间最多。它用 XADD 追加消息用 XREADGROUP 配合消费组读取TYPE 返回 stream。要区分它和 List 队列最好的办法是观察消费组机制消息投递到消费者后在 XPENDING 里能看到未确认记录直到 XACK 才会被标记完成。我把一个消费者杀掉前故意不 ACK重启后用 XAUTOCLAIM 还是能捞回那些未确认消息这个能力是 List 队列给不了的。Stream 的 XTRIM 策略也实测了。写测试脚本持续 XADD 并带 MAXLEN ~ 1000Redis 会近似裁剪老消息近似是出于性能考虑消息数不会精确等于 1000。如果业务要求严格的容量上限要么接受近似裁剪要么自己维护 XDEL 的删除水位。内存上Stream 底层是 radix tree 加 listpack单条消息较小时压缩率不错但消费者组的 PEL 会积压大量未确认消息每个 pending 项都有自己的元数据消费不及时会让内存悄悄涨。说到 Stream 和 Kafka 的对比我在报告里专门强调了一句Stream 适合做单机规模的消息队列不支持多分区和水平扩展也不是为高吞吐持久化事件流设计的。把 Kafka 的活硬派给它一定会撞到单 key 容量和消费性能的天花板。4. 把验证方法沉淀成脚本全库类型体检流水线4.1 用 TYPE OBJECT ENCODING 做类型普查验证单个类型不难难的是给一个存量库做整体体检。我写了个 Python 脚本用 SCAN 遍历所有 key对每个 key 执行 TYPE、OBJECT ENCODING 和 MEMORY USAGE统计出类型分布和编码分布再把内存超过阈值的地方列出来。SCAN 比 KEYS 安全得多游标式遍历不会一次性打爆 Redis但也别在高峰期跑得太快COUNT 参数控制好节奏。import redis r redis.Redis(host127.0.0.1, port6379, db0, decode_responsesTrue) type_stat {} encoding_stat {} big_keys [] for key in r.scan_iter(match*, count500): try: t r.type(key) enc r.object(ENCODING, key) mem r.memory_usage(key, samples2) or 0 except redis.ResponseError: continue type_stat[t] type_stat.get(t, 0) 1 encoding_stat.setdefault(t, set()).add(enc) if mem 10 * 1024 * 1024: big_keys.append((key, t, mem)) print(type_stat:, type_stat) print(encoding_stat:, {k: sorted(v) for k, v in encoding_stat.items()}) big_keys.sort(keylambda x: x[2], reverseTrue) for key, t, mem in big_keys[:20]: print(key, t, mem)redis-cli 自带的 --bigkeys 也能做类似的事原理就是 SCAN 加针对各类型的长度命令比如 STRLEN、LLEN、HLEN、SCARD、ZCARD。它的输出能告诉我哪个 key 最大但不会告诉我这个 key 的类型编码是否健康。我的脚本补充了 OBJECT ENCODING 的统计这一项很有用如果一套库里的 Hash 几乎全是 hashtable说明不少 key 已经越过紧凑编码阈值要么业务字段确实大要么当初设计没估算好。4.2 用 MEMORY USAGE 定位内存大头MEMORY USAGE 可以测单个 key 的内存占用支持 SAMPLES 参数做抽样估算。对超大 key全量计算可能很费时采样数调成 5 就能获得精度不错的结果。我用它把线上几个可疑的 key 都量了一遍结果证明很多看着不大的数据在 Redis 里的真实开销是逻辑数据的 3 到 5 倍。原因是 key 名称、redisObject 头部、SDS 头部、dict 节点、以及可能的 skiplist 节点都会额外占内存说 Redis 是内存吞噬者真不是夸张。如果内存告警了先 INFO memory 看 used_memory 和 mem_fragmentation_ratio再按类型统计内存贡献。我习惯用一个计数脚本把每个 key 的 MEMORY USAGE 累计到类型维度这样能快速回答到底是哪个类型吃的内存最多。注意 MEMORY USAGE 对不存在的 key 会返回空脚本里要做空值兜底。还有一个容易误判的RDB 加载后内存可能比预期的少因为持久化文件做了压缩这个别拿来当内存优化成功的证据。4.3 不同版本下的配置参数核对验证报告必须包含配置参数的核对否则换个版本结论就作废。Redis 7.x 里和数据类型编码直接相关的参数是这组我整理成表放在报告附录里。配置参数默认值作用hash-max-listpack-entries128Hash 字段数超过该值后转 hashtablehash-max-listpack-value64Hash 单字段值超过该字节数后转 hashtableset-max-intset-entries512Set 整数元素超过该值后转 hashtablezset-max-listpack-entries128ZSet 成员数超过该值后转 skiplistzset-max-listpack-value64ZSet 单成员 value 超过该字节数后转 skiplistlist-max-listpack-size128List 单个分块节点的元素上限老版本里这些参数名字是 ziplist 而不是 listpackRedis 8.0 则彻底移除了 ziplist 实现listpack 成为主要选择。参数不同OBJECT ENCODING 的输出也会变。有人拿着 6.x 的结论指导 7.x 环境结果发现编码名对不上其实就是版本差异导致的。我在报告里固定写清楚了验证版本并且为线上环境单独留了一个按当前版本重跑确认的步骤这个习惯在排查问题时帮我省了很多事。5. 验证过程中踩过的坑和排查手册5.1 高频错误与排查速查表验证过程中自然踩了不少坑很多是可以在报告阶段提前发现的。下面这张速查表是从我自己的踩坑记录里整理出来的基本覆盖了数据类型相关的日常事故。现象根因排查命令 / 处理WRONGTYPE Operation against a key holding the wrong kind of value同名 key 被不同命令以不同类型使用TYPE 确认现状代码里搜 SET/HSET/ZADD 对该 key 的写入路径写入突然变慢key 越过紧凑编码阈值转成 hashtable/skiplist或触发大 key 的大命令OBJECT ENCODING、MEMORY USAGE、SLOWLOG GET实例阻塞KEYS 全量遍历字典或大 key 执行 DEL换 SCAN大 key 用 UNLINK 异步释放内存居高不下编码不可逆曾经越过阈值的大 key 无法自动缩回拆分 key、重建对象、设定写入前预估消费组 pending 无限增长消费者没调用 XACK 或处理失败XPENDING、XAUTOCLAIM 重投补 ACK这里面最容易被忽视的是第一条。我在验证环境里故意把同一个 key 先 SET 再 LPUSH第二个操作直接抛 WRONGTYPE。线上出现这种错误时第一反应不要是加判断而是先定位这个 key 的身份为什么变了。常见原因是字符串拼接出来的 key 命名冲突比如用户 ID 和订单 ID 共用一个前缀。类型验证报告能提前暴露这些问题前提是你愿意在生产前把所有 key 的用途登记一份而不是临时靠报错推代码。5.2 类型、事务和原子性之间的那笔账MULTI/EXEC 里如果某个命令操作了错误类型Redis 并不会回滚整个事务。我在验证脚本里构造了一个场景事务里先 SET 一个 key再对这个 key 做 LPUSH然后 INCR 另一个计数器最后 EXEC。执行结果是 SET 和 INCR 都成功了只有 LPUSH 报错事务里没有回滚。很多人把它当要么都成功要么都不成功的容器这是个认知误区。如果需要类型正确才执行这种原子判断正确姿势是用 Lua 脚本。脚本在 Redis 里是原子的可以先用 TYPE 检查再决定操作脚本里任何一步出错都不会继续执行后面的代码。另一个常用手段是 WATCH 加 MULTI但 WATCH 只能保证 key 没被修改不能保证类型正确所以我在报告里建议涉及类型检查的写操作一律走 Lua别靠事务兜底。5.3 日常巡检怎么做才能不踩雷验证结束后我把巡检做成了每周一次的小任务跑一次上文的全库类型体检脚本记录类型分布、编码分布、Top 大 key 清单和上周对比再看 SLOWLOG 里有没有因大 key 引发的慢命令INFO commandstats 里重点看 HGETALL、SMEMBERS、LRANGE 这类 O(N) 命令的调用量是否异常上涨。发现异常后一般按两条线处理先确认是不是数据规模自然增长触发了编码切换再决定是调整业务设计拆 key还是调大 max-listpack 参数。调参数只是把阈值往后推真正的解法是别让一个 key 承担过重的职责。这条原则放在 Hash、Set、ZSet 上都成立也应该是类型验证报告希望传达的核心。6. 报告之外我留下的几条实操习惯验证报告写完一个月后再回头看真正留下的其实不是文档本身而是几个已经融进日常开发流程的习惯。第一新建缓存 key 之前先问三个问题这个 key 会被多少种命令操作单 key 最大规模是多少查询复杂度是 O(1) 还是 O(N)三个问题都回答得上类型基本不会选错。第二每次上线前对新增缓存结构跑一次 OBJECT ENCODING 和 MEMORY USAGE确定它落在预期编码里数据大了之后有什么转换路径心里有数。第三写进代码的 key 命名带类型语义比如用户基本信息是 Hash用户关注列表是 Set避免同前缀互相覆盖。最后分享一个验证时的小技巧在独立的测试实例上把 listpack 阈值调得很小用几百条数据就能快速模拟出线上几十万条数据才会触发的编码切换不用真往测试库灌几百万条数据。这个办法后来排查内存问题时反复用到。类型验证这件事一次做完不算完每次版本升级、每次数据规模上一个台阶都值得重新跑一遍。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询