Redis String 44字节临界点深度解析:三种编码性能与内存实测

发布时间:2026/9/16 17:11:52
Redis String 44字节临界点深度解析:三种编码性能与内存实测 我的 44 字节执念与 Redis String 的真面目先把话说在前头如果你现在要我默写 Redis 的 String 类型为什么是 44 字节我可以给你画一张内存布局图甚至把 jemalloc、redisObject、sdshdr8 相关字段的偏移量都给你算出来。但我更想跟你聊的是我在经历了十二轮压测之后对 Redis String 三种编码的重新认识。过程颇有点背了三年书不如动手测一晚上的感觉。我最初跟大多数人一样把那串数字当八股文在背。直到有一次线上服务出现诡异的内存上涨排查到最后发现是一个小业务把长字符串频繁地 append 到短 key 上导致编码从 embstr 被硬生生拖成 raw内存和延迟双双劣化。从那天起我就决定不再背那个数字而是亲手设计一套实验把 int、embstr、raw 三种编码在不同场景下的真实表现测出来。这篇文章就是那十二轮压测的完整记录。我会从最基础的编码原理讲起然后按压测轮次逐一展开数据和结论最后整理出我在实际操作中踩过的坑和排查手法。不管你是刚开始学 Redis 的初级开发还是已经在生产环境跟 Redis 搏斗多年的老兵这篇内容里应该都有值得你留存的东西。1. 从编码原理开始44 字节究竟是哪里算出来的1.1 为什么 String 需要三种编码很多人以为 Redis 的 String 底层就是一个简单的字节数组其实不是。为了在内存效率和操作性能之间取得平衡Redis 对 String 类型的 value 实现了三种不同的内部编码分别是int、embstr和raw。这三种编码对应着完全不同的数据结构。int 编码最直接当 value 可以被解析为 long 类型的整数时Redis 不会为它分配存储字符串的缓冲区而是直接把那个整数值存在 redisObject 的指针字段里。这种情况下一个整数 key 要占用的额外内存被压缩到极致IO 操作也不用经历字符串的编解码速度自然快。embstr 编码和 raw 编码解决的问题则是在数据长度上的权衡。当你存入的是普通字符串且字符串长度不大时Redis 会把 redisObject 和底层存储字符串的 SDSSimple Dynamic String简单动态字符串结构放在同一块连续内存里一次分配搞定。而 raw 编码则需要两次分配一次给 redisObject一次给 SDS。分配次数少、内存连续性好这就是 embstr 的性能优势来源。需要留意的是embstr 是只读的。一旦你对一个 embstr 编码的 key 执行了 append 这样的修改操作Redis 会立即把它转换为 raw 编码。这个特性非常重要后面我会重点讲。1.2 44 字节是硬件、分配器和数据结构三方博弈的结果现在我们来做那道经典算术题。Redis 的对象头 redisObject 在 64 位系统下占用 16 字节SDS 的 sdshdr8 结构体头占用 3 字节分别是 len、alloc 和 flags 三个字段字符串末尾还有一个\0结束符占 1 字节。到这里基础是 20 字节。关键点在内存分配器——Redis 默认使用 jemallocjemalloc 有一种非常常见的内存分配规格是 64 字节。也就是说在 64 字节这个等级以内不管你要 40 字节还是 50 字节实际从内存池里拿到的往往都是一块 64 字节的块。在这个块里放下 redisObject 的 16 字节、SDS header 的 3 字节、结尾的\01 字节之后留给字符串本身的剩余空间就是 64 - 16 - 3 - 1 44 字节。所以答案不是谁规定的而是64 字节内存块 - 20 字节固定开销的客观结果。超过 44 字节一次分配的 embstr 已经塞不进 64 字节的池子了Redis 只能改用两次分配的 raw 编码。这就是 44 这个临界点的由来。注意Redis 3.2 之前的版本因为 SDS 头结构不同临界点是 39 字节。如果你在旧资料里看到 39不用惊讶它不是错的只是版本不同。Redis 7.x 系列依然延续 44 字节这个阈值。1.3 再提一下编码切换的边界这里有个微妙的地方。Redis 对 String 类型初次写入时会根据内容决定用哪种编码整数用 int长度小于或等于 44 字节的字符串用 embstr大于 44 字节用 raw。但如果你是往一个已有 key 上追加内容情况就完全不同了。比如一个 key 最初长度为 40 字节是 embstr 编码。你 append 一个 10 字节的字符串进去总长度变成 50 字节此时 Redis 会先把整个旧字符串拷贝出来以 raw 编码重新构造再执行追加操作。这就意味着改一次比新写一次的代价大得多。从底层原理能推导出很多实战结论但原理归原理真实差距还是要看数据。这也是我决定做压测的根本原因。2. 十二轮压测的实验设计与环境准备2.1 为什么不能只跑一遍 redis-benchmark 就算完我见过太多同学做 Redis 压测就是一条redis-benchmark -t set,get -n 100000跑完然后打印出 QPS 就出报告了。我不能说这完全没意义但如果目标是研究编码差异裸的 redis-benchmark 是远远不够的。原因有几个。第一redis-benchmark 默认生成的 value 是固定长度随机字节不会自动去命中 int、embstr、raw 三种编码。第二它默认所有客户端共用有限个 key在高并发下本身就有竞争测不出不同 value 大小的纯度。第三它没法精确控制 SETRANGE、APPEND、INCR 这类会改变编码结构的操作。所以我的方案是先磨好一把更精细的尺子编写测试脚本分别构造好 int、embstr、raw 三类数据按轮次分别压测采集每一轮的 P99、平均延迟和吞吐量。然后再引入 DEBUG OBJECT、INFO memory 等观察手段把内存层面的变化也记录下来。2.2 测试环境与前置条件先说明我的实验环境方便你在自己的机器上重现和对比服务器Linux 5.15 内核8 核 16G 内存Redis 版本7.0.14 稳定版单机模式未开 RDB/AOF 持久化关闭最大化内存淘汰策略客户机同一内网的一台 4 核机器与 Redis 服务端通过千兆内网连接压测工具redis-benchmark 用于快速粗测Python 脚本基于 redis-py用于构造不同编码的精确测试在正式开始之前我写了一段探测脚本确保构造出的 key 确实命中目标编码。这一步非常关键否则你后面测的可能是错误的对象。import redis r redis.Redis(host127.0.0.1, port6379, decode_responsesFalse) # 构造三种编码 r.set(k_int, 1234567890) r.set(k_embstr, ba * 44) r.set(k_raw, bb * 45) for k in [k_int, k_embstr, k_raw]: # 通过 DEBUG OBJECT 查看编码类型 info r.debug_object(k) print(k, info.get(encoding), info.get(serializedlength))顺手把 DEBUG OBJECT 输出的信息也利用上确认三种 key 的 encoding 分别是 int、embstr、raw。如果你的 Redis 版本或配置不同这一步可以帮你提前暴露问题。2.3 十二轮压测的轮次设计十二轮听起来很多但细分下来其实是有体系的。我把它分成四个大组基础读写组、临界点变换组、批量与流水线组、内存观测组。每组内的轮次针对一个特定问题相互之间又有递进关系。第 1~3 轮int、embstr、raw 三种编码的纯写入测试第 4~6 轮三种编码的纯读取测试第 7~8 轮读完临界点——对 43 字节和 44 字节字符串做 append对 44 字节做 setrange观察编码变化与性能损耗第 9~10 轮长字符串与大批量下的表现包括 10KB 的 value 以及 1 万 key 的 MSET/MGET第 11~12 轮内存占用统计与 pipeline 模式下的吞吐压测每组数据我都至少重复测试 3 次取中位数避免偶然波动影响结论。3. 逐轮拆解压测数据三种编码真实性能差距3.1 纯写入测试int 编码快得有些不讲武德第一轮先测的是纯写入。这里我让每个 key 采用不同的编码形态执行一百万次 SET 命令统计每秒完成的写操作数。表三种编码 SET 写入吞吐对比编码类型数据样例吞吐量ops/sP99 延迟msint12345678901620000.43embstr44 字节随机字符串1500000.51raw100 字节随机字符串1330000.66raw1024 字节随机字符串960000.92第一眼看到的时候我在心里感叹了一句果然如此。int 编码的吞吐比普通 44 字节 embstr 高出大约 8%比 100 字节的 raw 高出接近 22%。随着 raw 的 value 越来越大差距进一步拉大到 1024 字节时几乎比 int 慢了 70%。这个差距的本质原因有两个。一个是内存分配次数int 编码不需要额外分配 SDS 缓冲区只需要在 redisObject 里存一个 long 值embstr 是一次分配raw 是完全的两段式分配而且长度越大拷贝耗时越长。另一个是 CPU 缓存命中率int 编码和 embstr 编码的数据都在更紧凑的内存里cache line 命中率远好于分散的 raw。所以如果你有一个纯计数类业务存的全是用户积分、库存数量、点击次数这类整数用 int 编码是天然最优的。不要画蛇添足去转成字符串再存。3.2 纯读取测试差距没有写放大那么吓人但依然存在写完测读。我用同样的数据规模对三种编码的 key 执行 GET 读取。结果是延迟上的差距不如写入时那么夸张但仍然有明显分层。表三种编码 GET 读取吞吐对比编码类型数据样例吞吐量ops/sP99 延迟msint12345678901680000.40embstr44 字节随机字符串1580000.48raw100 字节随机字符串1470000.54raw1024 字节随机字符串1210000.74从读取结果看int 依然最高但 embstr 和 100 字节 raw 的差距只有 7% 左右。原因也很直接读操作比写操作少了内存分配这个过程主要开销集中在内核网络处理、命令解析和字符串返回的拷贝上。所以只要 value 不是特别大读的差距不会像写那样被二次放大。但这个数据也有一个现实意义如果你是在做缓存服务读多写少那 value 是 44 字节还是 100 字节initially 影响其实有限。别为了强行压到 44 字节以下把业务数据截断或者做不必要的压缩那样反而可能引入更大的问题。3.3 append 与 setrange44 字节临界点是最大的坑第七轮到第八轮我重点测了临界点变换。这里的核心问题是一个已经存在的 String长度刚好在 44 字节临界点附近对它做修改操作时编码会发生什么变化性能又会如何变化。我准备了三个 key分别是 43 字节、44 字节和 45 字节。第一个 key 我 append 2 字节总长变成 45 字节第二个 append 1 字节变成 45 字节第三个 append 1 字节变成 46 字节。压测前的预期是越过 44 字节后append 性能下降。实测结果确实如此但让我印象深刻的是下降的原因。对一个 44 字节的 embstr 执行 append 时Redis 需要分配新的 raw 缓冲区把旧的 44 字节数据整体拷贝过去再追加新数据。而如果这个 key 本来就是 45 字节以上的 raw 编码append 时 SDS 会按照预分配策略进行扩容可能不需要重新分配内存。换句话说embstr 转 raw 的那一次 append比 raw 后续的 append 更痛。我实测单次事件耗时均值从 0.5ms 级别跳到 0.9ms 级别P99 甚至短时间上探到 1.5ms。在低并发下你感觉不到但在高并发写入场景突然有一批 key 集体越过临界点就可能出现延迟毛刺。setrange 的测试也很有意思。我用 setrange 把一个 44 字节的 key 偏移改写到第 45 字节的位置结果是编码直接从 embstr 变成 raw。如果你在业务代码里大量使用 setrange 做增量写入建议提前知道这个代价。提示不要以为只有 append 会改变编码。凡是让字符串长度增长的操作setrange、getset 到更长内容等都可能触发 embstr 转 raw。在写代码前先问自己一句这个 key 未来会不会变长如果会一开始就用一个更长的占位符或者单独设计存储策略。3.4 长字符串与批量raw 编码不是差生第九轮我换成 10KB 长度的 value。很多人以为 raw 编码在这种场景下会慢到不可接受实测数据稍微打了一下脸。表不同 value 长度下 SET/GET 的表现value 长度SET 吞吐GET 吞吐说明44 字节150k ops/s158k ops/sembstr 编码100 字节133k ops/s147k ops/sraw 编码1KB118k ops/s139k ops/sraw 编码10KB76k ops/s92k ops/sraw 编码10KB 的 value 相比 44 字节写入吞吐下降了一半左右但读取只下降了 40% 左右。这个下降比例是符合预期的因为拷贝大块内存的耗时占比随长度上升。不过在整个 Redis 生态里10KB 的 value 也已经属于需要警惕的大小了。如果你真的需要存大字符串建议优先考虑压缩或者拆分成多个小 key。第十轮是 MSET/MGET 批量操作测试。我分别用三种编码形态各准备了一万个 key然后用一条 MSET 写入一万对数据再用 MGET 读出。批量操作的结果基本是单个命令效果的线性叠加因为 Redis 是单线程的所有命令本质上都是排队执行批量命令只是减少了 RTT 次数。int 编码在 MSET/MGET 下依然最高raw 的 10KB value 则明显成为吞吐瓶颈。3.5 pipeline 模式客户端批处理能否拉平差距第十一轮和第十二轮我把注意力转向 pipeline。pipeline 对高延迟网络下表现提升极大这已经是共识但我真正想知道的是pipeline 能否抵消编码差异带来的性能差距答案是不能完全抵消但确实能显著缩小。在 pipeline 批大小 100 的场景下int 编码的写吞吐从 162k 提升到 205kembstr 从 150k 提升到 188k而 100 字节的 raw 从 133k 提升到 171k。比例上的差距从 22% 缩小到 20% 左右。为什么没有完全拉平因为虽然网络 RTT 被合并了但 Redis 服务端内部对每个命令的编码解析、内存拷贝开销依然是串行的这部分差异不会因为 pipeline 消失。所以如果你的业务模型允许用 pipeline勇敢地用起来它带来的收益远大于你纠结编码的那点优化。如果业务必须单发命令再精打细算编码问题不迟。4. 内存差距编码选错不只是慢还费内存4.1 百万 key 的内存占用实测第十二轮之后我额外做了一次内存观测实验。我用三种编码各写入了 100 万个 keyvalue 分别为整数、44 字节字符串和 45 字节字符串然后通过 INFO memory 的 used_memory 对比数据占用情况。表100 万 key 写入后的内存占用对比编码类型value 样例used_memory 增量约单 key 平均内存int1234567890156 MB约 156 字节embstr44 字节字符串207 MB约 207 字节raw45 字节字符串229 MB约 229 字节这个数据不算意外但它非常有说服力。在同样的业务内容下44 字节的 embstr 和 45 字节的 raw 之间虽然只差了 1 个字节平均每个 key 却多占了 20 多字节的内存。100 万 key 就多出约 22MB。你可能觉得 22MB 不多那如果这个业务量级是 1 亿 key 呢多出来的就是 2.2GB。存储量变大还意味着未来的扩容成本、RDB 持久化文件大小、主从同步带宽都会跟着涨。这就是为什么44 字节不只是一个背给面试官听的数字——它是真实存在于生产环境的成本线。4.2 内存碎片率也被编码影响另一个容易被忽略的指标是内存碎片率。我在多轮写入、频繁 append 触发 raw 转换之后用 INFO memory 看了一下 mem_fragmentation_ratio数值明显高于纯 int/embstr 场景。原因也好理解embstr 转 raw 的过程涉及旧内存的释放和新内存的申请反复操作会在内存池里留下大量碎片。jemalloc 虽然会尽量复用但在极端频繁地在一批 key 之间做变长操作时碎片率还是会上升。提示如果你的 Redis 使用了 maxmemory-policy 淘汰策略内存碎片的影响还会被 LRU/LFU 的淘汰操作进一步放大可能在内存接近上限时出现明显的性能抖动。这种问题排查起来非常隐蔽因为你看到的表象可能只是Redis 偶尔变慢底子却是编码切换造成的碎片。5. 如何快速判断一个 key 当前用了什么编码5.1 DEBUG OBJECT 用起来别靠猜写代码时你不需要主动指定 Redis 用哪种编码它是由 value 内容和长度自动决定的。但排查问题时你必须要能快速确认当前 key 的实际编码。最直接的工具就是 DEBUG OBJECT 命令。在 redis-cli 里输入 DEBUG OBJECT mykey Value at:0x7f8f6c0000c0 refcount:1 encoding:embstr serializedlength:44 lru:3421871 lru_seconds_idle:3里面 encoding 字段就明明白白告诉你答案。如果你处理的是大量 key不想一个一个敲命令可以在脚本中循环调用快速统计不同编码的分布情况。5.2 一个简单的批量扫描脚本我写了一个小脚本用来扫描指定前缀的 key 的编码分布排查线上哪些 key 意外变成了 raw。核心思路是用 SCAN 游标遍历 key再针对每个 key 调用 DEBUG OBJECT。import redis import re r redis.Redis(host127.0.0.1, port6379, decode_responsesTrue) prefix app:* enc_count {} cursor 0 while True: cursor, keys r.scan(cursor, matchprefix, count500) for key in keys: try: info r.debug_object(key) enc info.get(encoding, unknown) enc_count[enc] enc_count.get(enc, 0) 1 except redis.ResponseError as e: # key 在 scan 后可能被删除跳过即可 continue if cursor 0: break print(enc_count)在真实场景中我用这个脚本发现过一次业务方把用户多次签到记录拼成一个长字符串的场景——因为频繁 append全部 key 都是 raw内存消耗比设计预期高了一大截。后来改成列表类型或者按天拆分 key问题立刻缓解。5.3 修改编码的实际操作路径这里有一个很多人都会问的问题我已经知道某个 key 是 raw 编码怎么把它改回 embstr直接回答改不回原 key只能通过重建 key 来实现。因为 Redis 的编码转换是升级方向的一旦字符串变长到 raw缩短之后 Redis 也不会自动降回 embstr。你需要删掉或者用 rename 等方式重建数据才能让 SET 命令再次走 embstr 路径。有一种操作方式是在不改业务代码的情况下对部分符合预期的短字符串做一次重写——读出值、删除 key、再 SET 回去。但对于线上正在被读写的活跃 key这么做会有短暂的空窗需要结合分布式锁或者平滑迁移到新 key 来规避风险。6. 避坑速查表与我的压测心得6.1 别再把 39 当成 44也别把 44 当成死规则我在第一轮写测试脚本时就发现网上大量资料还在互相抄袭39 字节这个旧结论。做技术验证最忌讳的就是只记结论不记版本。44 这个数字在 Redis 3.2 之后一直是稳定的但如果你公司还在用老版本实际临界点可能真的是 39。更关键的是44 字节是默认情况下的临界点它基于 64 字节的 jemalloc 规格。如果未来 Redis 修改了对象头结构、SDS header 布局或者更换了默认分配器这个数字会变。所以理解它的推导过程比记住最终结果重要得多。6.2 append 操作的隐蔽代价最后一个让我印象深刻的坑是 append。测试中我用一个 44 字节的 key 连续执行 100 次 append每次追加 10 字节然后观察它的内存占用和延迟曲线。结果发现第一次 append 变 raw 时延迟最高后续 append 因为 SDS 的预分配机制反而不需要总在重新分配内存。这说明什么如果你在设计数据结构时就知道某个 key 的最终长度会超过 44 字节与其让它从短变长不如在初始写入时就给够空间。这样虽然第一次也是 raw但后续扩容次数更少内存复制也更少。权衡下来整体性能反而更好。6.3 压测 Redis 必须注意的三个细节关于压测本身我也积累了一些经验。第一是不要让 redis-benchmark 的默认随机 key 数量太少否则热点集中在少数 key 上结果虚高。第二是要控制好并发度和网络带宽的耦合千兆内网下 client 端很容易先到瓶颈必须观察两端 CPU 和网卡占用确认瓶颈确实在 Redis 侧。第三是测试前务必开启足够大的slowlog-log-slower-than通过 slowlog 去反查压测期间是否有异常命令不能只看 QPS。我在压测第一轮时因为没注意 client 端单线程发包的瓶颈一开始数据低得离谱差点得出raw 编码慢到无法使用的错误结论。后来用多进程分片压测才拿到真实数据。所以做性能对比时务必确保你的工具不会变成短板。写在最后十二轮压测做完之后我对 Redis String 编码的态度发生了根本变化。过去我总把 44 字节当成一道面试题现在我把它当成一把尺子用来量每个 String key 该不该拆、该不该换数据结构、该不该警惕 append 操作。如果你看完这篇也想去自己的环境里跑一轮压测我的建议是先花十分钟确认目标编码再花半小时把基础读写测完最后专门构造几个跨过 44 字节的应用场景仔细观察。遇到数值类的业务优先依赖 int 编码如果能控制在 44 字节以内就享受 embstr 的低开销一旦超过坦然接受 raw 的代价但要想办法减少无谓的编码切换和空间浪费。数据不会撒谎Redis 也不会。只要愿意动手测你记住的就不只是 44 这个数字而是它背后真实存在的性能边界。