ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Redis String编码深探:44字节边界与三大编码性能实测

Redis String编码深探:44字节边界与三大编码性能实测 前两天有个开发者带着一堆日志来找我说自己的 Redis 缓存接口延迟突然飙升他怀疑是 value 大小踩到了 44 字节的编码切换线“我把数据压到 44 字节以内了怎么还这么慢”我反问了一句44 这个数字到底是谁规定的它背后的代价是什么他答不上来。后来我拉了一轮压测发现他那个场景里 44 字节和 45 字节的差距大概只有 5%压根不是瓶颈。真正的问题是他在同一个 key 上反复 APPEND让 Redis 不断做编码升级和内存重分配。这事让我觉得挺有意思的。网上关于 Redis String 编码的文章十有八九都在强调“44 字节是 embstr 和 raw 的分界线”但很少有人告诉你这条线是怎么算出来的、三种编码在真实压测里差多少、以及业务上到底什么时候才值得关心它。所以我干脆自己搭环境做了 12 轮压测专门测 Redis String 三种编码int、embstr、raw在读写场景下的真实差距顺便把背后的内存分配逻辑一次讲清楚。1. 三种编码不是面试题是内存里的三种活法1.1 int 编码“零成本”保存数值先明确一点Redis 的 String 类型存储层面有三种编码int、embstr、raw。很多人只记住了名字不理解这仨在内存里的区别自然也就无法解释为什么压测数据会出现后面那些差异。int 编码是最特殊的。当你的 value 恰好是一个可以被解析为 long 类型的十进制整数时Redis 不会去分配一块独立的内存来保存这个字符串而是直接把数值存在 redisObject 的 ptr 指针字段里。也就是说它借用了指针本身那 8 个字节的空间把数字塞了进去。这样一来SET 一个整数 key 时Redis 不仅仅少了一次 malloc还少了一次字符串拷贝后续读取的时候也不用走 SDS 的解析流程就是一个纯粹的数字读取。这里有个容易踩的坑并不是“看起来像数字”的字符串都会走 int 编码。比如SET k 0123因为前导 0 不能被 parse 成合法的 longRedis 会直接放弃 int 编码转而保存为 embstr 或 raw。同理1.0、1这类也不是 int 编码的候选。1.2 embstr 与 raw44 字节边界背后其实是一次 mallocembstr 和 raw 都是用来保存字符串的但对内存的处理方式完全不同。embstr 是嵌入式字符串它的目标是把 redisObject 和底层的 SDSSimple Dynamic String结构体放在同一次内存分配里让它们在物理内存中紧挨着。这样只需要一次 malloc释放也只需要一次 free而且由于内存连续CPU 缓存的命中率也会更好。那为什么偏偏是 44 字节我算给你看。Redis 7.x 里 redisObject 固定占 16 字节然后 SDS 如果是 sdshdr8 类型它的头部是 3 字节字符串末尾还有 1 字节的\0结束符。把这几项加在一起16redisObject 3sdshdr8 头部 数据长度 1\0 64要让总大小正好落在 64 字节这个常见内存分配档位数据长度最大只能是 44。一旦 value 变成 45 字节总大小就成了 65超出了 64 字节的分配单元Redis 会判断继续用 embstr 已经拿不到“单次小内存分配”的优势于是直接改用 raw 编码把 redisObject 和 SDS 拆成两次独立的内存分配。所以 44 这个数字不是魔法它只是一个内存分配策略下的最优边界。不同版本的 Redis、不同的内存分配器这个值可能变化。死背 44 字节远不如理解边界背后的机制有用。1.3 编码是动态的别用静态思维理解还有一种常见的误解以为 key 一旦创建编码就固定了。实际上 Redis 的 String 编码是可以变的。举个例子127.0.0.1:6379 SET foo 1 OK 127.0.0.1:6379 OBJECT ENCODING foo int 127.0.0.1:6379 APPEND foo 2 (integer) 2 127.0.0.1:6379 OBJECT ENCODING foo raw一个原本是 int 编码的 key因为 APPEND 命令需要追加字符串Redis 把它升级成了 raw。在 Redis 的设计里embstr 被当成只读编码所有可能修改字符串内容的命令APPEND、SETRANGE都会先让它退化成 raw再执行修改逻辑。这种“动态升级”其实是有成本的我在压测时也专门观察了这个现象。2. 12 轮压测从零搭起工具、场景与变量控制2.1 为什么用 redis-benchmark 又要自写脚本“实测”最难的不是跑命令而是控制变量。Redis 官方自带的 redis-benchmark 有个让人头疼的限制它生成的 value 是随机字符串你没办法直接让它 SET 一个整数 value所以 int 编码的场景它盖不到。我在实际压测中把工具分成两组字符串相关的轮次使用 redis-benchmark指定-d控制 value 字节数分别覆盖 44、45、100、1000 等关键长度。整数相关的轮次使用自写的一个基于 asyncio 的小脚本保持同样的并发模型和请求量只是把 value 固定成一个合法的 long 整数从而确保进入 int 编码。这里要说明一下自写脚本在绝对吞吐上肯定不如 redis-benchmark 这种 C 程序所以我没有直接拿脚本的 QPS 去和 benchmark 的 QPS 做跨工具比较。int 相关的两轮数据只用于横向对比 int 编码内部的情况以及观察大致的性能水平。涉及跨编码对比时我优先使用了同一工具产生的结果。2.2 12 轮场景设计与测试参数环境是这样单节点 Redis 7.2部署在一台 4 核 8G 的云主机上关闭了 AOF 和 RDB 持久化避免磁盘写操作干扰 CPU。压测客户端和服务端在同一台机器上走回环网卡这样网络延迟的影响被降到最低。每轮固定请求量 100 万次并发连接数 50pipeline 关闭-P 1每轮重复 3 次取中位数。12 轮的具体设计如下轮次操作类型value 大小预期编码说明1INCR无intint 编码下的数值运算2SET整数 value8 字节左右int直接 SET 一个 long 型整数3SET44 字节embstr正好卡在边界上4GET44 字节embstr读取边界内字符串5SET45 字节raw刚超出边界 1 字节6GET45 字节raw刚超出边界的读取7SET100 字节raw中等长度字符串8GET100 字节raw中等长度字符串读取9SET1000 字节raw稍大 value10GET1000 字节raw稍大 value 读取11混合读写value 32~64 字节32~64 字节embstr 为主模拟小对象业务12混合读写value 256B~8KB256B~8KBraw 为主模拟含大文本业务第 11、12 轮我特意做成了混合读写读写 11key 范围随机。这样更贴近真实业务而不只是看单命令的极限吞吐。2.3 压测前必须做的一次“体检”压测里最容易犯的错误是不做预热和验证直接跑。我在正式测试前先往 Redis 里灌了一批和目标 value 相同大小的数据然后用OBJECT ENCODING抽查确认-d 44生成的字符串真的进了 embstr而不是因为额外前缀导致变成了 raw。这一步看着不起眼却直接决定后续数据是否有效。另外还确认了-d生成的字符串长度是否精确。redis-benchmark 的 value 里会带一段固定前缀中间填充随机字符实际总长度才是-d指定的字节数。如果不去验证很可能你以为在测 44 字节实际存进去的是 50 字节的 raw那后面的对比就全乱套了。3. 轮番实测数据差距真实存在但没到玄学程度3.1 完整数据表所有数据都是本机回环、100 万次请求、50 并发、三次取中位数的结果。QPS 单位是 ops/sec延迟单位是毫秒。轮次操作value 大小编码QPSavg 延迟p99 延迟1INCR-int1102300.450.82SET 整数8 Bint1081200.460.93SET44 Bembstr1038700.481.04GET44 Bembstr1098800.450.95SET45 Braw984500.511.16GET45 Braw1063400.471.07SET100 Braw958700.531.18GET100 Braw1042100.481.09SET1000 Braw826700.611.410GET1000 Braw873500.571.211混合读 1132~64 Bembstr 为主972300.521.112混合读 11256B~8KBraw 为主745800.671.6我不打算把这份数据包装成“标准答案”不同机器、不同 Redis 版本跑出来的绝对值肯定有差异。但这组数据是在同一台机器、同一套工具下跑出来的相对趋势用来对比三种编码的差距是有参考价值的。3.2 第一组结论embstr 与 raw 在边界上怎么拉开差距最直观的对比是第 3 轮和第 5 轮同样都是 SET44 字节的 embstr 能跑到 10.38 万 QPS45 字节刚切到 raw 就只有 9.84 万差距大概 5.2%。你可能觉得 5% 不小但相比网上某些文章渲染的“性能骤降”这个差距其实温和得多。有意思的是 GET 方向的差距更小。第 4 轮44 字节 GET和第 6 轮45 字节 GET之间QPS 差距只有 3% 左右。原因是读取操作对内存分配不敏感主要是把 SDS 里的数据打包发给客户端分配路径的差异被弱化了。所以第一个结论是embstr 换成 raw 确实有性能损失但主要体现在写入密集型场景且幅度大概在 5% 上下。这个量级在大多数业务里根本感知不到除非你是一个每秒写入几十万次且 value 正好反复横跳在边界上的热点 key。3.3 第二组结论int 编码到底快在哪再对比第 2 轮SET 整数和第 3 轮SET 44 字节int 编码的 QPS 是 10.81 万embstr 是 10.39 万差距大约 4%。INCR 由于是纯数值操作没有字符串解析跑到了 11.02 万是这几轮里最高的。int 编码的优势是“不上不下”不算大但稳定存在。原因在于 SET 整数时不需要分配 SDS也不用把数字格式化成字符命令执行的路径被压缩了一大截。但 Redis 的瓶颈从来不只是内存分配还包括网络读写、命令解析、字典查找这些固定开销。所以在单次操作的总耗时里int 省掉的那一部分被摊薄了最终体现在 QPS 上就只有几个百分点。3.4 第三组结论value 大小和混合读写才是大头真正让我意外的是 value 大小的影响。第 9 轮SET 1000 字节和第 3 轮SET 44 字节相比QPS 从 10.39 万降到了 8.27 万下降了大约 20%。而混合大 value 的第 12 轮QPS 跌到了 7.46 万比小 value 混合读写低了接近 23%。这说明一个问题如果你真想优化 Redis String 的性能优先考虑的是 value 大小和命令本身的形态而不是纠结 embstr 还是 raw。44 字节边界带来的 5% 差异在 1000 字节 value 带来的 20% 差距面前几乎可以忽略。4. 从数字往底层看差距是怎么被制造出来的4.1 SET 的分配路径差异页面上看到的 5%、4%、20%本质上是内存分配路径的差异。我画一条操作路径你就明白了。SET 整数int解析命令 - 找到或创建 dictEntry - 把整数直接塞进 redisObject 的 ptr 字段。没有 malloc没有字符串拷贝。SET 44 字节embstr解析命令 - 调用一次 malloc 申请 64 字节连续内存 - 写入 redisObject 头 - 写入 SDS 头 - 拷贝 44 字节数据 - 更新 dictEntry 指针。SET 45 字节raw解析命令 - 第一次 malloc 分配 redisObject - 第二次 malloc 分配 SDS这里至少 49 字节- redisObject 的 ptr 指向新 SDS - 拷贝数据 - 更新 dictEntry 指针。raw 比 embstr 多的那一次 malloc就是写入性能差距的主要来源。而 malloc 本身不是免费的它涉及锁竞争在多线程环境下、空闲链表查找、内存碎片整理等操作。虽然 Redis 主线程是单线程但底层分配器仍然可能存在复杂的元数据操作。这些成本在 1000 字节的场景下会被进一步放大因为大块内存的分配和回收更容易触发内存碎片管理。4.2 GET 的数据拷贝成本GET 操作实际上是两次拷贝一次是把 SDS 里的数据交给网络输出缓冲区另一次是发送给客户端时可能触发的系统调用和数据复制。在这个流程里编码类型的影响被大幅稀释真正的大头变成了数据长度。拿第 4 轮和第 10 轮对比GET 44 字节跑出 10.99 万 QPSGET 1000 字节只有 8.74 万差距约 20%。这个差距绝大部分来自网络发送的数据量而跟 SDS 是嵌在 redisObject 里还是单独分配关系不大。所以你可以把 GET 场景的优化重心完全放在 value 大小上。4.3 为什么 raw 在 1000 字节时更吃亏raw 编码到了 1000 字节还有一个隐藏问题SDS 头部会从 sdshdr8 升级到 sdshdr16 甚至更大。头部越大单次内存复制时 CPU cache 能覆盖的数据比例就越低。但最关键的还是数据拷贝量本身。从我压测数据看SET 1000 字节比 SET 45 字节下降了约 16%这部分可以解释为分配更大的内存块、SDS 头部升级、memcpy 耗时增加、以及内存访问局部性变差。每次操作多出的耗时可能只有几十微秒但在高并发下被放大成 QPS 差距。5. 回到业务哪些优化值得做哪些是自我感动5.1 OBJECT ENCODING别猜看一眼就知道排查线上问题时不要凭感觉判断某个 key 是什么编码。Redis 给了现成的命令127.0.0.1:6379 OBJECT ENCODING mykey embstr如果发现大量本应是小字符串的 key 变成了 raw常见原因是代码里对同一个 key 做了 APPEND、SETRANGE或者 value 包含了非数字的可解析前缀。这个时候与其纠结编码不如先去查代码里的写操作习惯。还有一个好用的命令是MEMORY USAGE mykey可以直接看到这个 key 实际占用的内存字节数。配合OBJECT ENCODING能很快判断出某个 key 是不是因为没走预期的编码而多吃了内存。5.2 值得做的优化基于这次压测我会推荐这几个方向压缩大 value。如果业务里有 JSON、HTML 片段这类文本压缩后再写入 Redis往往能减少 50% 以上的空间和网络开销性能收益比卡 44 字节明显得多。把长文本挪出 Redis。超过几 KB 的内容优先放对象存储或文件系统Redis 只存访问路径或摘要。第 12 轮的压测数据已经很说明问题大 value 的混合读写是最伤的。关注写密集的短 key。如果你有一个热点 key 每秒被写入几十万次并且 value 是可转成整数的状态值确保它保持 int 编码能省下一次可观的内存分配开销。这里的关键是不要对它执行字符串拼接类操作。5.3 不值得做的优化包括硬凑 44 字节有些优化在我看来是自我感动甚至是负优化为了进入 embstr刻意把 value 截断到 44 字节以内。这么做可能引入数据不完整、业务还要额外处理截断逻辑而换来的只是 5% 的写入性能提升。为了让所有 key 走 int 编码把原本可读性很好的字符串 ID 强行改成纯数字。一旦这个 ID 需要保留前导 0 或者被用作字符串拼接int 编码就不成立强行改造只会让代码变难看。只关注编码而忽略命令数量。一个明显的事实是把 10 次 SET 合并成 1 次 pipeline性能提升是几倍级别远大于编码层面的几个百分点。6. 压测中的坑位预警与我的收尾建议6.1 工具层的坑redis-benchmark 的 value 陷阱redis-benchmark 确实好用但有几个细节不处理会毁掉整轮压测。第一是-d指定的长度必须用OBJECT ENCODING验证不验证就等于盲跑。第二是-r这个参数它控制 key 的随机范围如果你不设-r所有请求都打在同一个 key 上Redis 会命中同一个 dictEntry内存分配次数会少很多测出来的 QPS 偏高且不符合真实业务。第三是-P默认是 1也就是没有 pipeline。如果你加了-P 16得到的结果体现的是批量操作吞吐不是单命令延迟这两者别混为一谈。第 2 轮SET 整数我用自写脚本而不是 redis-benchmark正是因为 benchmark 没法直接生成整数 value。用自定义客户端压测时要注意避免客户端成为瓶颈我在脚本里用 asyncio 配合 50 个连接每个连接发 2 万次请求共 100 万次确保客户端的处理能力远高于单实例 Redis 的吞吐上限。6.2 环境层的坑云主机与持久化干扰云主机上做压测最大的变量是 CPU steal。如果你用的是共享实例邻居可能随时抢 CPU 时间片导致某轮数据突然偏低。我的处理方法是每轮跑 3 次取中位数并且跑完一轮后立刻执行INFO stats里的total_commands_processed和instantaneous_ops_per_sec做交叉验证。持久化配置也必须统一。压测时我关闭了 AOF 和 RDB因为 fsync 和 fork 都会引入不可控的延迟。业务环境如果开着 AOF everysec实测 QPS 通常会比基准测试低 10%~20%这是正常现象不代表 Redis 变慢了。还有一个容易被忽略的点连接池预热。压测开始时如果连接是新建的Redis 要处理连接建立的开销前几千个请求会明显偏慢。建议正式测试前先跑 1 万次请求做预热让连接和内存分配都进入稳定状态。6.3 关于这个测试后续还能怎么延伸这次测的是单节点、单线程命令。后续你可以往几个方向延伸开启 Redis 6.0 之后的 io-threads看多线程 IO 对 GET 大 value 是否有明显提升。换用 memtier_benchmark它支持更细致的 value 分布配置适合做更接近业务的混合场景。测试 SETEX、SETNX、GETSET 这类变体命令看它们在不同编码下的表现是否和原生 SET/GET 一致。用INFO memstats或者MEMORY DOCTOR观察不同编码下的内存碎片率判断是否需要调整 maxmemory 策略。我个人在跑完这轮压测后的感受是Redis String 的编码机制可以作为理解内存分配的切入口但不该成为业务优化的执念。真正值得投入精力的永远是那些能带来数量级提升的手段比如减少大 value、合并命令、控制 key 数量而不是在 44 字节边缘反复试探。如果一定要记住一个数字记住“44 字节 一次 malloc 能装下的最大连续字符串”比死背一个分界点有用得多。
返回列表