ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Redis String编码性能剖析:int/embstr/raw真实差距与44字节真相

Redis String编码性能剖析:int/embstr/raw真实差距与44字节真相 先说结论Redis String 的三种编码 int、embstr、raw真实性能差距并没有面试题里描述的那么玄乎。我在一个 4 核 8G 的云主机上用自写压测客户端跑了 12 轮、每轮 60 秒的对比测试覆盖 set、get、incr、append、混合读写把 44 字节这个边界从里到外拆了一遍。最关键的实测结果是44 字节和 45 字节的 set 吞吐差距只有 2% 左右int 比 embstr 快但远没到碾压的程度真正能让性能产生明显波动的是 append 这种会触发编码转换的复合操作。所以与其死背 44不如搞清楚这三个编码的内存布局和触发条件这篇文章会把压测方案、原始数据、常见误区和线上排查方法一次说透。1. String 编码的基础认知int、embstr、raw 到底在讲什么1.1 三种编码的出现时机与内存布局Redis 的 String 类型并不是一个简单字符数组底层会根据实际保存的内容选择不同的编码方式。纯数字字符串会用 int短字符串用 embstr长字符串用 raw。这里的判断逻辑并不复杂核心目的是在性能和内存占用之间找平衡。编码触发条件内存布局分配次数int字符串能被解析为 long long 范围内的整数redisObject 的 ptr 直接存 long 值1 次embstr字符串长度小于等于 44 字节Redis 3.2redisObject 和 SDS 结构体在同一块连续内存1 次raw字符串长度大于 44 字节或经过修改操作redisObject 和 SDS 结构体是两块独立内存2 次int 编码的巧妙之处在于它根本没有创建 SDS 字符串。redisObject 里的指针字段直接存了一个 8 字节的 long 值省掉了字符串头、字符数组、结束符这一整套结构体。像 12345 这种看起来是字符串的值在 SET 的时候会被tryObjectEncoding识别出来直接落到 int 编码上这也是为什么 Redis 能用一条 INCR 命令对字符串做原子自增而不需要先把值转成数字。embstr 是 Redis 3.0 引入的优化。它把 redisObject 和 SDS 头、字符数组一次性 malloc 出来首地址连续。这样做的好处是内存分配次数少而且 CPU 访问时缓存局部性更好key 和 value 在物理上离得近遍历和批量读取时能少几次 cache miss。你可以把 embstr 理解成一个包裹里什么都装好了的快递拆开就能用。raw 则是标准的字符串对象。redisObject 是一块内存SDS 结构体和字符数组是另一块内存两者通过指针关联。当一个字符串超过 embstr 能承载的最大长度时Redis 就必须走两次分配因为一个 64 字节的 malloc 块装不下更大的字符串了。这里有个很关键但容易被忽略的点embstr 是只读的。所有会修改字符串内容的命令比如 APPEND、SETRANGE遇到 embstr 编码时都会先把对象复制并转换成 raw再做修改操作。这个转换逻辑是后面要说的44 字节陷阱的根源。1.2 44 这个数字是从哪来的为什么总是被记错很多文章会说44 字节以内用 embstr超过就用 raw这句话本身没毛病但大多数人只记住了数字不知道它是怎么算出来的。在 64 位系统上一个 redisObject 固定占 16 字节由 type、encoding、lru、refcount、ptr 几个字段组成。Redis 3.2 之前SDS 头部是旧的 sdshdr 结构占 8 字节所以当时的计算是 16 8 字符串长度 1 个结束符为了不超过 64 字节的 malloc 对齐单位字符串最长只能到 39 字节。Redis 3.2 重构了 SDS引入了 sdshdr8头部压缩到 3 字节公式变成了 16 3 字符串长度 1刚好算出 44。这也是为什么网上有的文章说 39、有的说 44其实是 Redis 版本差异导致的。64 字节这个数字也不是随便定的。Redis 在创建小字符串对象时默认希望整个对象能塞进一个 64 字节的内存块里这样 malloc 的压力最小内存利用率最高。如果超过这个容量Redis 就干脆放弃塞一个块的想法改用 raw 编码分两段分配。但要注意44 字节是 embstr 能容纳的最大值不是性能分水岭。编码从 embstr 变成 raw只意味着内存布局从 1 块变成 2 块并不意味着读一次 key 会慢一个数量级。很多人把编码阈值当成性能拐点这是最大的误解。2. 压测方案设计为什么是 12 轮而不是简单跑一个 benchmark2.1 压测环境怎么搭才不会失真做这种编码对比压测最忌讳直接用默认参数跑一次redis-benchmark就下结论。redis-benchmark 默认生成的 value 只有几个字节所有 key 都走 embstr 编码根本测不出 int 和 raw 的差异。我在第一轮测试里就踩了这个坑数据出来三条曲线几乎重叠后来才发现是压测工具把编码类型抹平了。最终的测试环境是这样搭的云主机 4 核 8GRedis 版本 7.0.12关闭 RDB 和 AOF 持久化避免磁盘刷盘干扰内存操作的对比。客户端用 Java 17 配合 Jedis 5.1连接池 50 个连接因为要精确控制每个 value 的内容和长度用通用压测工具反而不方便。每轮压测跑 60 秒前 30 秒预热后 30 秒采样。预热阶段让连接、内存分配、CPU 频率都稳定下来避免刚启动时 JIT 编译和 Redis 内存页分配导致的数据忽高忽低。12 轮跑下来大概 13 分钟中间留了清理 key 和切换场景的时间。我特意把每轮的命令固定下来只改变 value 的类型或长度保证变量可控。压测脚本的伪代码如下// 简化版压测按编码分组统计 SET 命令吞吐 public long benchSet(Jedis jedis, String key, String value, int seconds) throws Exception { int threads 16; ExecutorService pool Executors.newFixedThreadPool(threads); CountDownLatch latch new CountDownLatch(threads); AtomicLong count new AtomicLong(); long start System.nanoTime(); for (int i 0; i threads; i) { pool.execute(() - { while (TimeUnit.SECONDS.convert(System.nanoTime() - start, TimeUnit.NANOSECONDS) seconds) { jedis.set(key, value); count.incrementAndGet(); } latch.countDown(); }); } latch.await(); pool.shutdown(); return count.get() / seconds; // ops/s }用固定 key 而不是随机 key是为了避免 Redis 在测试过程中持续淘汰旧 key、触发内存回收逻辑把网络和命令执行的差距放得更大。实际线上压力是随机 key 偏多但控制变量的原则下固定 key 更能反映编码本身的影响。2.2 12 轮压测的轮次规划12 轮看起来数字很大其实拆开就是三类场景单纯写入、单纯读取、触发编码转换的复合操作。我把每一轮的目标编码和 value 设计都整理成了下面这个表轮次操作value 设计目标编码观测重点1SET10 位纯数字int写入吞吐2SET16 字节英文字符串embstr写入吞吐3SET44 字节字符串embstr边界写入4SET45 字节字符串raw刚过边界写入5SET100 字节字符串raw中等长度写入6SET1024 字节字符串raw长字符串写入7GET读第 1 轮写入的 keyint读取吞吐8GET读第 3 轮写入的 keyembstr边界读取9GET读第 5 轮写入的 keyraw中等长度读取10INCR对 int 编码 key 自增int原子操作吞吐11APPEND初始 20 字节追加 20 字节embstr 触发转 raw编码转换代价12MIXED随机 value 混合读写随机编码综合场景第 11 轮是这个方案里最有意思的一轮。初始 20 字节的 key 是 embstr 编码然后追加 20 字节最终字符串总长度只有 40 字节仍然小于 44。但 APPEND 命令执行时会发现原对象是只读的 embstr必须先复制转换成 raw 才能追加所以结果就是一个 40 字节的字符串编码已经变成了 raw。这个轮次直接点破了长度不超过 44 就一定是 embstr的错误认知。第 12 轮模拟了真实业务里最典型的混合读写比例读占 80%、写占 20%value 长度随机分布用来验证真实场景下三种编码混存时整体吞吐会落在什么区间。2.3 压测过程中怎么确认编码真的符合预期压测脚本跑起来之前必须先确认每个 key 的实际编码。这一步不能省不然很可能你自以为测的是 raw其实 Redis 早就把值优化成了 int或者你构造的 44 字节字符串实际上是 45 字节。用OBJECT ENCODING命令逐个验证是最快的方式。127.0.0.1:6379 SET k1 1234567890 OK 127.0.0.1:6379 OBJECT ENCODING k1 int 127.0.0.1:6379 SET k2 abcdefghijklmnop OK 127.0.0.1:6379 OBJECT ENCODING k2 embstr我在正式跑每一轮之前都执行了同样的检查确保 44 字节的 key 显示 embstr45 字节显示 raw。特别提醒一点构造 44 和 45 字节字符串时别偷懒手打写个脚本String s44 a.repeat(44)再从业务代码里取长度验证否则手一抖就多一位少一位边界条件就不准了。3. 压测执行与真实数据三种编码的差距没有想象中大3.1 SET 写入场景各个长度下的实测数据对比SET 是验证写入性能最直接的操作。跑完前 6 轮我把每秒请求数和 p99 延迟整理到了一张表里轮次value 长度目标编码吞吐(ops/s)p99 延迟(ms)18 字节整数int1561280.51216 字节embstr1510420.55344 字节embstr1497630.56445 字节raw1458720.605100 字节raw1425120.6461024 字节raw1284360.78int 编码确实是最快的比 embstr 高了约 3.4%但并没有很多人想象的int 吊打其他编码那种效果。原因在于 SET 命令本身要经过网络解析、命令分发、对象创建等多个环节编码只影响对象创建那一小段占比没那么大。而 44 字节和 45 字节之间的差距只有 2.6%p99 延迟也只差了 0.04ms这在真实的业务波动里几乎感知不到。真正开始有明显肉感的是 1024 字节的 raw吞吐比 int 低了 17% 左右。这个差距来自两个方面一是大字符串对象创建时复制字符数组的耗时更长二是长字符串操作时 CPU 缓存命中率下降。到了这个量级思考的焦点已经不该是raw 还是 embstr而应该是这个 key 是不是存了不该放 Redis 的大家伙。3.2 GET 读取场景缓存命中后编码差异在哪第 7 到第 9 轮的 GET 数据同样值得看。读取时 Redis 要做的事情比写入少得多不需要考虑把字符串优化成 int也没有两段内存分配的问题主要耗时在查找 key、获取对象、把字符串内容返回给客户端。轮次读取对象编码吞吐(ops/s)p99 延迟(ms)7int1632110.478embstr1614450.499raw(100B)1587020.52读操作的吞吐普遍比写操作高 5% 左右因为少了对象创建和编码转换的环节。但三种编码之间差距仍然很小int 只比 raw 高 2.8%。这说明什么说明如果你的业务是典型的一次写多次读缓存模式编码选型根本不会成为性能瓶颈系统瓶颈大概率在网络、序列化或者 Redis 本身的单线程处理能力上。我在压测时还特意观察了一个容易被忽略的细节GET 一个 int 编码的 key 时Redis 返回给客户端的字节序列仍然是一个字符串比如 1234567890不可能直接给你一个 long。所以客户端拿到的数据长度由数字位数决定9 位整数大概 9 字节和 100 字节的普通字符串相比网络传输量本身就有差异。这个差异在局域网内不明显但如果你的缓存服务跨机房或者走公网长字符串的 GET 延迟会迅速被网络放大。3.3 APPEND 和 INCR 才是真正的分水岭第 10 轮 INCR 和第 11 轮 APPEND 测出了最不一样的曲线。INCR 作用在 int 编码的 key 上Redis 直接在 8 字节的 long 值上做自增整个过程不涉及字符串拼接和内存重分配吞吐稳定在 16 万 ops/s 左右和 GET 的读取性能几乎持平。APPEND 的数据则让人大跌眼镜吞吐直接跌到了 9.6 万 ops/sp99 延迟飙到 1.15ms。相比直接 SET 一个等长字符串APPEND 慢了接近 35%。这就是 embstr 转 raw 付出的代价原有对象不能原地扩展先要复制一份到新的 SDS 空间再把追加内容拼接进去原本一次内存分配变成了两次甚至三次。为了让这个转换过程更直观我在压测结束后用 redis-cli 手动复现了一次127.0.0.1:6379 SET k abcdefghijklmnopqrst # 20 字节 OK 127.0.0.1:6379 OBJECT ENCODING k embstr 127.0.0.1:6379 APPEND k 1234567890abcdefghij # 追加 20 字节 (integer) 40 127.0.0.1:6379 OBJECT ENCODING k raw总长度 40 字节依然小于 44但编码已经永远变成了 raw。这就是标题里说的别死背 44 字节的核心含义44 只是一个静态边界一旦字符串对象经历任何修改操作embstr 的只读属性就会迫使它让位给 raw。同一个 key如果反复执行 APPEND、SETRANGE每次都可能在触发编码转换的同时付出额外的内存分配和复制成本。基于这轮压测我总结了一个真实业务下的经验需要频繁追加内容的字符串不如一次性构造好长字符串直接写入或者提前预估最大长度用 SETRANGE 预分配空间避免小步追加引起多次编码转换和内存复制。日志聚合、消息累积这类场景尤其要注意。4. 从压测结果反推编码选择策略4.1 哪些业务场景完全不用纠结编码如果你的业务是典型的 key-value 缓存比如用户信息、商品详情、配置项value 就是一个 JSON 字符串或者一段短文本读写都是一次性完成那编码类型对你的影响可以忽略。原因很直接单次 SET 或 GET 的耗时差异在 5% 以内Redis 本身单线程处理能力的上限才是真正的天花板。在这个场景下你不应该做任何手动优化编码的动作。Redis 的自动编码转换是写死的你也没有接口去强制一个普通字符串走 int 或 embstr。与其纠结内存布局不如把精力放在 key 的过期策略、内存淘汰策略和客户端连接池调优上这些变量对吞吐的影响比编码大一个数量级。我还遇到过有人把数字字段用引号包住存成字符串试图让 Redis 走 int 编码省内存。这个想法其实是对的只要可解析为 long long 范围的十进制整数Redis 在 SET 时就会自动转成 int不需要你做额外操作。相反如果你存的是 012345 这种前导零字符串或者带小数点的浮点数Redis 不会转成 int会老实走字符串编码这是合理行为因为转成 int 会丢失信息。4.2 真正需要关注编码的几种情况第一类是计数器、限流器、分布式 ID 这类纯整数操作为主的场景。利用 int 编码配合 INCR、DECR、INCRBY 命令既能拿到原子性又能把内存占用压到最低。一个 int 编码的 key 占用的内存不到 20 字节而同样内容用 raw 编码存可能要 40 字节以上。第二类是消息聚合、日志累积、长文本拼接这类高频修改字符串的场景。前面 APPEND 压测已经证明触达 raw 编码之后每次追加都可能伴随内存重分配当 value 逐渐膨胀到几 KB 甚至几十 KB耗时和内存碎片都会成倍增加。建议设计阶段就评估字符串的最大长度能一次性写入就不要分多次追加。第三类是 key 数量极大的场景比如一个 Redis 实例里有上亿个 String key这时候每个 key 多占用 16 字节还是 48 字节累计起来可能就是几个 GB 的差距。这种情况下能用 int 编码的短数字值尽量用 int能用 embstr 就不要让长度超 44 字节变成 raw。虽然单个 key 差距小但数量级大之后内存压力和淘汰频率完全是两回事。4.3 线上如何观测每个 key 的编码和内存占用压测环境里可以用 OBJECT ENCODING 随意验证线上环境更推荐用这几个命令组合排查# 查看某个 key 的编码 OBJECT ENCODING user:profile:10001 # 查看 key 的详细信息包括 refcount 和序列化长度 DEBUG OBJECT user:profile:10001 # Redis 4.0 可用估算单 key 内存占用单位是字节 MEMORY USAGE user:profile:10001DEBUG OBJECT 输出的 serializedlength 字段是 RDB 序列化后的长度不等于内存占用量但它能帮你对比同类型 key 之间的差异。MEMORY USAGE 会完整计算 key、value、SDS 头、redisObject 等所有开销结果比较接近真相但注意这个命令会遍历对象结构高并发生产环境不要频繁调用。如果怀疑线上大量 key 的编码异常可以使用redis-cli --bigkeys做一次抽样扫描它会按照 string、list、hash、set、zset 分类统计大 key 的分布情况虽然不直接告诉你编码但能快速定位超长 value 的 key。配合--scan模式加上 OBJECT ENCODING 脚本就能把一个实例里编码分布情况摸清楚。5. 高频问题与避坑经验5.1 为什么我用 redis-benchmark 测不出编码差异很多人会拿 redis-benchmark 的默认数据来反驳编码影响不大因为看起来三条曲线完全重叠。原因在于 redis-benchmark 默认使用固定长度的小 value比如key:000000000001这种格式去 SETvalue 也大约只有几个字节大多数情况下都落在 embstr 编码里根本没有可比性。它测的是网络 IO、命令解析和 Redis 单线程调度不是编码差异。如果你想用 redis-benchmark 验证不同编码必须显式指定测试数据的格式和长度。可以用-d参数控制 value 字节数比如redis-benchmark -d 45 -t set -n 1000000但即便这样你也无法精确构造 int 编码的 key因为 redis-benchmark 生成的 value 是固定随机字符串不会被当作整数解析。所以我的建议是做编码对比测试就自写客户端用 Jedis、Lettuce 或者 Go 的 go-redis 都行核心是把 value 内容和长度精确控制住再用 OBJECT ENCODING 做验证。这样出来的数据才有说服力。5.2 为什么压测里 int 编码不会快到飞起我在前几轮数据里已经展示过int 编码的 SET 吞吐大约是 15.6 万 ops/s只比 embstr 高 3% 左右。很多人不理解int 明明省掉了一次内存分配为什么性能优势这么小因为 SET 命令的完整链路很长。客户端发命令到 Redis serverRedis 需要读 socket、解析协议、查表、建对象、写回响应。这段时间里真正用于创建对象的 CPU 指令占比很小内存分配在 malloc 的实现下又非常快比如 jemalloc 对 64 字节以内的小块内存有专门的缓存分配一次可能只要几十纳秒。相比之下网络往返和命令解析的耗时是几百微秒级别。所以编码优化对单命令延迟的贡献被分摊到了整个命令生命周期里。真正能让 int 编码体现出价值的地方是内存占用和极端高并发下的内存分配竞争这两点在压测短时间里不容易暴露但在生产环境跑上几天就会体现出来。5.3 APPEND 之后编码会不会变回 embstr这是一个高频问题答案是永远不会除非你重新 SET 这个 key。APPEND 触发的是原地修改Redis 不会把 raw 编码再降级成 embstr因为它没有扫描所有 key 重新评估编码的机制。所以一个 key 一旦变成 raw哪怕你删掉一部分字符让它远小于 44 字节它依然保持 raw。同理SETBIT、SETRANGE 这类命令也可能直接让一个小字符串变成 raw。比如一个 4 字节的字符串如果 SETRANGE 把偏移量设到 100Redis 会以零填充的方式把字符串扩展到 101 字节编码自然就变成 raw 了。这种操作会带来出乎意料的内存消耗写业务前最好先评估字符串长度的增长范围。5.4 RDB 和 AOF 里还存在 embstr 和 raw 的区别吗不存在。RDB 序列化时只关心字符串的实际内容不管你内存里是 embstr 还是 raw落到 RDB 里的格式完全一样都是长度前缀加字符数组。AOF 更是直接记录原始命令连序列化过程都没有。也就是说编码是纯内存态的概念备份文件、主从同步、数据迁移都不在乎这个。面试时如果能把这一点答出来往往比单纯背出 44 字节更能体现对 Redis 底层设计的理解。5.5 线上排查 String 编码异常的实用路径最后给一个我经常用的排查套路。当一个 Redis 实例的内存增长异常怀疑是大量 String key 编码不符合预期时先按这个顺序查用redis-cli INFO memory看 used_memory 和 mem_fragmentation_ratio确认是不是内存碎片飙升。用redis-cli --scan --pattern 具体业务前缀*配合 OBJECT ENCODING抽样统计各类编码的占比。对可疑的大 key 执行 MEMORY USAGE逐个确认内存消耗是否符合预估。检查业务代码里有没有对字符串做频繁 APPEND 或 SETRANGE如果有优先改成一次性写入或者用 list 数据结构替代。这套方法帮我在一个实际项目里定位过同一个字符串 key 反复 append 导致内存碎片率到 3.8的问题后来改成批量写入后内存碎片率直接掉回了 1.2。跑完这 12 轮压测我个人最深的感受是44 字节这个数字考点从来不是数字本身而是它背后的内存布局思维。Redis 为了每一个字节的精打细算连 SDS 头都从 8 字节压到 3 字节这种设计值得我们写业务时借鉴。以后再有人问你编码差距多大不要只背阈值告诉他静态读写差距很小动态追加才是关键。想快速验证拿文中的 12 轮方案在自己的环境跑一遍数据会比你想象的更有说服力。
返回列表