Redis String编码性能实测:44字节临界值背后的真相 做 Redis 开发的人十个里面有八个能背出“44 字节”这个数面试的时候更是高频考点。但你真要问他44 字节是哪个版本的临界值为什么是这个数超过之后性能到底差多少估计一半人会说不上来。我也是被问住过一次当时只能含糊地说“好像是 embstr 和 raw 的切换点”回头越想越不对劲干脆花了两个周末搭了个测试环境设计了 12 轮压测用数据把三种编码int、embstr、raw的真实差距测了个明明白白。这篇文章我不打算讲空泛的原理直接把压测过程和结果摊开给你看。无论你是准备面试、排查线上性能问题还是在做 Redis 存储方案选型这份实测数据都能帮你少走不少弯路。看完你会发现44 字节只是一个表象真正决定性能的是内存分配方式、缓存局部性和编码转换带来的隐藏开销。1. 先搞清楚一件事Redis String 的三种编码到底是怎么分的Redis 的 String 类型并不是只有一种底层实现而是根据你存的值长什么样自动选择 int、embstr、raw 三种编码之一。这个选择是 Redis 内部完成的使用者无感知但性能差异却是实打实的。1.1 三种编码的底层结构先看最简单的 int 编码。当你执行SET key 12345时Redis 发现字符串能被解析成 64 位有符号整数于是直接把值存在 redisObject 的ptr字段里根本不额外分配内存。注意这里存的不是字符串而是一个 long 类型的整数。这意味着对 int 编码的 key 做 INCR、DECR 操作Redis 可以直接在整数上运算完全绕开字符串解析。再看 embstr 编码全称是 embedded string嵌入式字符串。它的核心特点是redisObject 和内部的 sdsSimple Dynamic String简单动态字符串结构体在内存上是连续分配的一次 malloc 全部搞定。sds 的数据就紧跟在对象头后面而且因为总量很小通常能落在一个 CPU 缓存行里读写效率极高。embstr 还有一个特性它是只读的任何修改操作比如 APPEND都会让它先转成 raw因为重新分配内存后无法保证连续性。最后是 raw 编码也就是普通字符串。当字符串长度超过临界值或者经过修改操作后Redis 会分别分配 redisObject 和 sds 两块独立的内存。两次 malloc 不仅慢还容易产生内存碎片更重要的是数据大概率分散在不同的内存页里CPU 缓存命中率下降。这才是 raw 性能差的根本原因。1.2 44 字节这个临界值的来龙去脉为什么偏偏是 44背后其实是内存分配器的对齐逻辑。Redis 默认使用 jemalloc 内存分配器它分配内存时按 2 的幂次对齐小于等于 64 字节的请求实际占用的都是 64 字节的槽位。所以 Redis 在设计时说得很明白只要 redisObject16 字节加 sds 头加数据的总长度不超过 64 字节就值得用 embstr 一次分配。那 44 是怎么算出来的去套公式64 字节总预算减去 redisObject 的 16 字节减去 sds 头的 3 字节Redis 3.2 之后 sdshdr8 的头部再减去末尾的\0结束符 1 字节剩下 44 字节就是能塞进去的最大字符串长度。如果数据是 45 字节总长度就超过 64 了分配器会给你 96 字节甚至更大的槽位既然连续分配的优势没了Redis 干脆直接用 raw。这里有个容易踩坑的历史知识点Redis 3.2 之前这个临界值是 39 而不是 44。因为老版本 sds 头是 8 字节64 - 16 - 8 - 1 39。很多人背了老教材的 39面试时跟新版本对不上这就很尴尬。所以别再死背数字了理解 64 字节内存槽这个根因比记整数重要得多。2. 压测方案12 轮是怎么设计出来的原理归原理现实归现实。为了搞清楚编码差异在真实负载下到底有多大影响我搭了个压测环境设计了 12 轮测试。这里先把环境和测试逻辑交代清楚方便你后续复现。2.1 测试环境与工具选型Redis 版本7.0.11单机模式关闭持久化最大内存不设限压测工具redis-benchmark 为主自写 Lua 脚本辅助CPU8 核主频 3.0GHz 的云主机内存16GB网络本机回环排除网络延迟干扰选 redis-benchmark 是因为它简单原生能直接指定并发数和请求总数而且从 6.0 开始自带--threads参数可以多线程压测。但它有个缺点只能测内置命令像 SETRANGE、APPEND 这样的命令虽然支持但要精确控制字符串长度就不太方便。所以我额外写了一份 Python 脚本用redis-py做补充测试专门验证编码转换场景。两套工具交叉验证数据更可信。2.2 12 轮压测的设计逻辑12 轮不是拍脑袋定的我分了四个维度去覆盖基础读写SET、GET 短/中/长字符串覆盖三种编码修改操作APPEND、SETRANGE重点观察编码转换时的性能抖动数值操作INCR、DECR验证 int 编码的极限能力综合场景混合读写、超大 value模拟真实业务每轮固定 50 并发、100 万次操作测试前先写入足够多的测试 key跑完后取中位数和 P99 延迟。多轮测试之间留 30 秒间隔让 Redis 的内存分配和 CPU 缓存恢复正常状态避免上一轮残留数据干扰。3. 12 轮压测完整实录下面把 12 轮测试的详细过程、关键数据和我的现场观察写出来。每个轮次我都单独列了表格数据是多次测试取稳定值的结果。说实话有些结果出乎我的意料。3.1 第一轮SET 10 字节短字符串先测最基础的小字符串写入。我用SET key:short:1 hello这种形式value 固定为 10 字节左右的常规字符串。因为不是纯数字所以不会走 int 编码而是 embstr。这里有个细节hello是 5 字节我特意补到 10 字节确保在 embstr 范围内但又不触发 int。指标实测值QPS137,945平均延迟0.36msP99 延迟0.61ms这个数据比我预想的要好主要是 embstr 的内存连续性立功了。写入时一次 malloc 搞定而且 50 并发的场景下新分配的 embstr 对象大概率在连续内存地址上CPU 预取效率很高。但要注意这个成绩是纯内存操作、无持久化压力下的结果实际业务中如果开启了 AOF 或者主从同步会被磁盘和网络拖慢不少。3.2 第二轮GET 10 字节短字符串读操作和写操作在编码层面其实是相反的路径。写操作关注内存分配读操作关注内存访问。GET 短字符串时Redis 先在字典里找到 key然后顺着 redisObject 的 ptr 指针去拿数据。对于 embstr这个 ptr 指向的内存就在对象附近几乎可以认为一次缓存行就能搞定。指标实测值QPS142,310平均延迟0.35msP99 延迟0.57ms分页显示读比写快了一点点符合预期。因为读操作不涉及内存分配省去了 malloc 的开销。我在这轮测试时还特意看了一眼INFO memory发现碎片率稳定在 1.02 左右说明小字符串的 embstr 编码对内存碎片控制相当友好。3.3 第三轮SET 44 字节字符串重头戏来了。我准备了恰好 44 字节的 value用了一个 44 字节的字符串比如abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRS。然后又准备了 45 字节的版本做对比。这一轮主要想验证临界值前后到底有没有性能断层。value 长度编码QPS平均延迟P99 延迟44 字节embstr135,2020.37ms0.62ms45 字节raw128,8760.39ms0.68ms44 字节和 45 字节写入 QPS 差了大概 5%说实话没有想象中那么大。这让我一开始有点意外但仔细想想也合理虽然 raw 是两次 malloc但在 50 并发、纯内存操作的环境里额外一次 malloc 的绝对开销几十纳秒级别被网络开销和 Redis 自身的事件循环开销掩盖了一部分。所以网上那些“44 字节是性能分水岭”的说法在写入场景下是被夸大了。真正的差距看后面的修改操作和超大 value 场景才会显现。3.4 第四轮GET 44 字节和 45 字节字符串读操作对比更有意思。44 字节的 value 和 45 字节的 value读取时的性能差异也很接近但涨了一个细节45 字节的 raw 编码因为内存不连续访问时可能要跨越两个内存页P99 延迟明显比平均值波动大从 0.68ms 最高能飙到 0.85ms。value 长度编码QPS平均延迟P99 延迟44 字节embstr140,5670.36ms0.58ms45 字节raw137,2100.37ms0.71ms从数据来看GET 阶段 raw 的 QPS 下降只有 2.4%但 P99 从 0.58ms 涨到了 0.71ms涨幅 22%。这说明内存不连续对长尾延迟的影响是真实存在的。如果你的业务对 P99 敏感比如交易系统这个差距就不能忽视了。3.5 第五轮SET 200 字节字符串把 value 拉到 200 字节这是很多业务存储短文本、序列化 JSON 的典型长度。200 字节妥妥地是 raw 编码但我发现一个有意思的现象和 44 字节相比200 字节 SET 的 QPS 下降并不算多因为瓶颈开始从内存分配转向数据的搬运。指标实测值QPS118,432平均延迟0.42msP99 延迟0.75ms从 44 字节到 200 字节QPS 从 135k 掉到 118k整体降幅 12.6%。这个降幅主要来自两个原因一是 raw 两次 malloc 的开销二是 memcpy 拷贝 200 字节的时间。但这里我还观察到一个细节因为关闭了持久化redis-benchmark 默认不会做 RDB 子进程 fork所以这个数据是在没有复制开销的情况下测的。如果开了 RDB大 value 的写操作会触发写时复制性能会进一步下降。3.6 第六轮GET 200 字节字符串读大字符串观感完全不一样。200 字节的 raw 编码读取时需要跨越多个内存页对 CPU 缓存非常不友好。我在测试时开了perf stat监控缓存命中率发现 200 字节 raw 编码的 L1 缓存命中率明显低于 embstr 短字符串。指标实测值QPS124,398平均延迟0.40msP99 延迟0.79msQPS 和平均延迟勉强能看但 P99 已经涨到了 0.79ms。对比第二轮 10 字节的 0.57ms延迟增了 38%。你要说绝对差距也不大但对于高 QPS 的服务P99 上涨意味着有一部分请求会拖慢整条链路这在分布式调用里会被放大。所以如果你在做一个需要稳定延迟的系统value 长度一定要控制住。3.7 第七轮APPEND 触发编码转换这轮是整场压测的重头戏也是我最有收获的一轮。我先写入一个 20 字节的 embstr 字符串然后连续执行 5 次 APPEND每次追加 20 字节观察从 embstr 转 raw 的过程。这里我用了自己写的 Python 脚本做增量压测因为 redis-benchmark 不太方便按顺序观察每个命令的延迟。操作编码变化平均延迟P99 延迟SET 20字节embstr0.31ms0.52ms第1次 APPENDembstr转raw0.48ms1.05ms第2次 APPENDraw0.39ms0.82ms第5次 APPENDraw0.44ms0.93ms看到了吗第一次 APPEND 时P99 延迟直接飙到 1.05ms是正常操作的 2 倍还多。这就是编码转换的代价Redis 需要把原来的 redisObject 和 sds 都释放掉重新分配两块内存然后把数据搬过去。而且这个转换是不可逆的一旦变成 raw后续再追加就都是 raw 的分配方式了。这个场景在真实业务里太常见了你往一个缓存 key 里累积日志、追加消息、拼接字符串一开始以为是小事结果 APPEND 在临界点没注意编码一转换性能立刻掉一个量级。如果这个 key 还被高频访问P99 就炸了。3.8 第八轮SETRANGE 局部更新大 keySETRANGE 是对已有字符串做局部覆盖一般用于更新 ID 序列、版本号等场景。我准备了一个 100 字节的 raw 编码 key然后从偏移 10 的位置写入 20 字节的新数据。这一轮意外发现 raw 编码在大 value 局部更新时反而有优势因为它本来就是独立分配的内存修改时不需要重新分配整个对象。操作平均延迟P99 延迟SETRANGE 偏移10写入20字节0.41ms0.87msSETRANGE 偏移50写入50字节0.39ms0.83ms数据上SETRANGE 的性能相对稳定没有出现第七轮那种第一次操作的尖刺。原理倒也好理解raw 编码下 sds 支持原地修改只要新长度不超过已分配容量就不需要重新 malloc。这里有个小知识点sds 的扩容策略是成倍增长的比如容量是 100写入到 120会分配到 160 而不是 120这样下次扩容能省一次 memcpy。3.9 第九轮INCR 整数操作把数字字符串塞进 Redis用 INCR 做计数器这是最经典的用法。我预先写入了一批 key值都是 0然后对它们做 INCR 操作。这轮测试里 int 编码的优势体现得淋漓尽致因为整个操作就没有字符串的参与redisObject 里直接存的是 long 整数INCR 就是做一个整数的1运算再写回去。指标实测值QPS206,381平均延迟0.24msP99 延迟0.38msQPS 干到了 20 万延迟只有普通字符串操作的一半不到。为什么会差这么多三个原因不分配内存int 不需要 malloc、不解析字符串直接就是数值、不拷贝数据8 字节的 long 而已。对比第一轮 embstr 的 SET 13.7 万 QPSint 编码直接高出 50%。如果你的业务里大量使用计数器、限流器、库存扣减一定要确保 key 的值是纯数字字符串让它走 int 编码。3.10 第十轮DECRBY 混合整数操作单纯的 INCR 还不够我又测了 DECRBY模拟库存扣减场景。DECRBY 允许一次减去一个指定值比如DECRBY key 5它本质上和 INCR 一样是数值运算但要注意如果扣完变成负数了Redis 也还是 int 编码不会变化。这轮主要是验证混合数值操作下的稳定性。指标实测值QPS203,950平均延迟0.25msP99 延迟0.39ms数据跟第九轮基本持平说明 int 编码对数值命令的支持非常稳定没有因为命令类型不同产生额外损耗。这里我想提醒一个反直觉的坑很多人以为 INCR 之后值超长变成数字字符串就会转成 raw实际上 Redis 规定只要值还是合法整数就一直保持 int 编码。只有当数字本身超过 64 位有符号整数范围比如天文数字才会退化成 raw。3.11 第十一轮1KB 以上 value 的写入到了大 value 的舞台。我测了 1KB、2KB、5KB、10KB 四种长度的 SET 操作。这轮结果很有指导意义因为很多业务会把整个对象 JSON 序列化后塞进 Redisvalue 动辄几 KB。value 长度编码QPS平均延迟P99 延迟1KBraw89,6540.55ms1.12ms2KBraw71,2800.70ms1.48ms5KBraw47,8191.04ms2.25ms10KBraw33,1041.50ms3.40ms数据趋势非常明显value 长度涨 5 倍QPS 降一半多。但要注意到了这个量级瓶颈已经不太在乎编码方式了反正都是 raw主要开销在 memcpy 拷贝数据和网络缓冲区传输上。有意思的是10KB 的 P99 到了 3.4ms这对大多数业务来说是不可接受的延迟了。我建议把单个 value 限制在 1KB 以下如果超过 2KB就要认真考虑是否需要拆分成多个 key 或者用其他存储方案了。3.12 第十二轮混合流量模拟真实业务最后一轮模拟一个标准 Web 业务的读写比例70% 读、20% 写、5% INCR、5% APPEND。key 集合混合了小字符串、44 字节、200 字节和 1KB 的 value这个分布比较接地气。每个 key 被访问前先用OBJECT ENCODING确认一下编码这轮数据最能反映真实感受。场景QPS平均延迟P99 延迟纯读短字符串138,7200.36ms0.55ms混合流量含APPEND102,5830.49ms1.24ms混合流量纯读改无APPEND119,6470.42ms0.78ms对比两组混合流量有 APPEND 的 P99 直接飙到 1.24ms而没有 APPEND 的场景只有 0.78ms。这再次印证了第七轮的发现APPEND 引起的编码转换是长尾延迟的头号元凶。所以如果你的业务逻辑里有追加字符串的需求比如收集日志、拼接大消息最好在写入端就限制好每次 APPEND 的大小或者干脆用 List 结构加 LPUSH绕开 String 的这个问题。4. 数据背后的深层原理12 轮数据测完了绝对值因机器而异但相对差异是有共性的。这一节我把数据背后的机制掰开揉碎讲清楚这才是真正值钱的部分理解了这些你在任何环境都能举一反三。4.1 为什么 embstr 能比 raw 快前面说了不少这里归纳成一句话embstr 的优势来自“一次分配、一块内存、一次命中”。一次分配意味着只调用一次 malloc这在堆操作频繁的 Redis 主线程里非常宝贵一块内存意味着 redisObject 和字符串数据在物理上相邻遍历和拷贝时的页局部性好一次命中意味着极大概率一次 CPU 缓存行就能取到完整数据不需要经历二级缓存甚至主存。从实测看embstr 的 QPS 领先 raw 5% 到 10%P99 领先 20% 左右。写入场景的差距小读场景的差距大。不信你回看第三轮和第四轮44 和 45 字节的写差距只有 5%读的 P99 差距却有 22%。这说明 raw 的主要劣势在随机访问时的缓存不友好而不是 malloc 本身。4.2 int 编码和想象中不一样很多人觉得 int 编码就是“存了一个整数省内存”这不全面。int 编码真正的杀手锏是绕开了整个字符串处理链路。Redis 的 String 是一个字节容器即使存的是文本格式的数字也要经过 sds 的解析、字符串比较、长度计算。而 int 编码完全不需要这些它直接拿 ptr 当作 long 用读写、比较、运算都是原生 CPU 指令。拿第九轮的 INCR 来说20 万 QPS 的延迟只有 0.24ms这已经接近 Redis 命令处理的理论极限了。它证明了只要不涉及字符串堆操作Redis 单线程也能跑出极高的吞吐。所以做计数类业务时务必保证 key 是数字字符串不要加前后缀比如count:10001这种你以为是字符串Redis 一看10001还是纯数字照样 int。4.3 编码转换是隐藏的性能地雷所有编码转换里最坑的就是 embstr 转 raw。为什么因为 embstr 在设计上就是只读的任何修改操作不管幅度多小Redis 都会先把整个对象销毁重建转换成 raw 编码再执行。这就意味着你在一个 embstr key 上做 APPEND哪怕只追加一个字节也要付出“释放旧对象 分配两块新内存 拷贝所有数据”的巨大代价。原文第七轮里第一次 APPEND 的 P99 飙到 1.05ms就是这么来的。后面我特意查了一下这个转换是不可逆的key 一旦变成 raw就算你把长度减回 44 字节以内也不会自动降回 embstr。这个设计说白了是怕麻烦因为缩回去同样要重新分配内存而且判断逻辑复杂收益又不大。这就导致一个隐藏风险某个大字符串 key 经过多次 APPEND 变成 raw 后即使后续你删掉了大量内容它的编码也不会变回去永远保持 raw 的分配方式内存和访存效率都无从优化。5. 常见问题与生产建议这部分我结合压测过程中踩过的坑整理了一些实战心得。有些是我在测试中踩进去的有些是线上环境切实遇到过的问题内容偏经验向不确定的地方我会直接说不确定不跟你绕弯子。5.1 怎么快速看一个 key 是什么编码不用猜直接一条命令搞定127.0.0.1:6379 OBJECT ENCODING mykey embstr这个命令对所有 key 类型都有效不光是 String。比如 Hash 可能返回 ziplist 或 hashtableList 可能返回 quicklistZSet 可能返回 skiplist。在排查性能问题、分析大 key 时这个命令比 DEBUG OBJECT 更好用因为输出短小、开销低。但要注意OBJECT ENCODING 只能看单个 key。如果你想统计整个实例里各类编码的分布情况建议用 redis-cli 的--bigkeys参数扫描会按编码类型汇总。实测中发现线上有一批因为历史原因被 APPEND 搞成 raw 的大 key用--bigkeys一抓一个准。5.2 什么时候才需要关心编码不是所有业务都需要天天盯编码。我的经验是三类场景必须关心一是 QPS 高且对 P99 敏感的服务编码选择直接影响长尾延迟二是内存吃紧的实例raw 编码的两段式分配会加剧碎片导致 used_memory 虚高三是计数器、限流等数值型业务如果 key 的编码不是 int说明数据格式有问题性能至少打对折。举个例子我之前优化过一个秒杀库存系统计数器 key 的 value 竟然是带引号的字符串比如100哪怕业务层传过来的是数字因为序列化框架自动加了引号Redis 无法解析成整数只能走 embstr 存起来。每次 INCR 都要做字符串转整数的操作QPS 掉了 30%。这就是典型的编码被业务层“误伤”的场景。5.3 生产环境调优的落地建议第一合理设计 value 大小。控制在 44 字节以内你就能享受 embstr 的红利控制在 1KB 以下能避免大部分大 value 导致的性能下滑超过 2KB真要慎重考虑拆分或者换存储。第二规避 APPEND 滥用。如果确实需要累积字符串要么在写入端限制长度要么换成 List LPUSH读的时候再一次性 LRange 取出来。第三关注内存碎片率。INFO memory里的 mem_fragmentation_ratio 超过 1.5 时考虑在低峰期执行memory purge或者主动迁移到新实例raw 编码多且碎片高的实例用重启的方式能快速归零但要注意持久化和主从切换。第四也是压测里容易忽略的Redis 的 maxmemory-policy 和过期策略也会影响编码相关的内存表现。如果实例频繁触发淘汰LRU 老化会让某些 key 被反复删除和重建编码的分配频率变高碎片率自然就上去了。建议压测时把淘汰策略也纳入变量比如allkeys-lru和noeviction的结果差异会很大。5.4 压测踩过的坑帮你提前避雷先说自己踩进去的第一个坑刚开始我只用了单连接的 redis-benchmark测出来的 QPS 比多连接低了一个量级。因为 Redis 单线程只能服务一个连接上的命令序列多连接才能压出真正的并发能力。后来我改成-c 50 -n 100000也就是 50 个并发客户端才得到有参考价值的数据。第二个坑是测试前没有预热。Redis 是内存数据库字典扩容是在插入过程中逐步发生的。如果你一开始就压测写入头几十万条命令可能都在触发 rehash数据全部失真。正确做法是先写一批数据进去等待字典稳定后再开跑。第三个坑是 CPU 绑定。大部分压测工具是多线程的但 Redis 本身是单线程如果你的压测客户端和 Redis 跑在同一台机器上抢 CPU 会导致双方互相干扰。我的做法是客户端和 Redis 各绑不同的核用 taskset 指定或者干脆放两台机器走回环网络效果更干净。第四个坑是关于 AOF 的。压测如果不关持久化AOF 重写带来的 fork 会周期性拖慢命令处理导致 P99 出现规律性的尖刺。如果你是测纯内存性能记得config set appendonly no如果你是测真实生产环境那就别关但要能分辨到尖刺是 fork 引起的还是编码转换引起的。最后一个建议压测结果一定要保留多轮跑出来取中间值或者最小值别被单次跑出的高 QPS 迷惑。我在测第五轮 SET 200 字节时第一轮跑出了 125k QPS第二轮只有 118k第三轮反而 120k。这种波动源于内存分配器的状态和 CPU 变频调度。多轮跑完才能得到一个可靠的平均区间。写在最后说实话这一趟压测下来最大的收获反而不是数据本身而是对“44 字节”这个数字的祛魅。它确实是一个临界点但远没有到“超过就完蛋”的程度。真正影响系统稳定性的是你对编码机制的理解深度以及能不能在业务设计阶段就避开那些会导致编码转换、内存碎片、长尾延迟的坑。还有个小技巧想分享给你在生产环境遇到突发的 P99 飙高又找不到明确原因时可以用redis-cli --stat每秒看一眼命令耗时分布如果发现 APPEND 和 SETRANGE 的调用量突然增加先别急着加机器优先查一下这几个 key 的编码是不是刚刚发生过转换。我靠这个办法在线上定位过好几次隐藏的性能抖动。Redis 的编码机制就像一个看似简单实则暗藏玄机的开关理解它不只能帮你通过面试更能帮你写出真正高性能的缓存代码。这 12 轮数据摆在这里剩下的就是你在自己的环境里动手验证了。