尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Redis 44字节边界深度拆解:embstr与raw编码的压测对比与选型指南

Redis 44字节边界深度拆解:embstr与raw编码的压测对比与选型指南 先说个我自己的观察44 字节这个数字几乎每个背过 Redis 面试题的人都能说出来但真被问到“这个边界到底是怎么算出来的”“embstr 和 raw 实际性能差多少”的时候大部分人就开始含糊了。我前段时间专门腾出一天时间把 int、embstr、raw 三种编码放在同一套环境里跑了 12 轮压测从写入、读取、修改到内存占用全部记录了一遍。这篇文章就把完整的实验设计和数据结果摊开来讲顺便聊聊哪些场景下 44 字节值得关注哪些场景下你完全可以不 care 它。如果你是准备面试的开发者或者正在做 Redis 存储选型、排查线上延迟抖动这篇文章应该能帮你少走一些弯路。1. 44 字节不是拍脑袋定的它来自 redisObject 与 sds 的内存账本1.1 Redis 的 String 其实有三种形态很多人在初步接触 Redis 时会默认 String 就是一种简单的字符串结构但用object encoding命令查看时会发现同样一个 key存整数、短字符串、长字符串背后的编码可能是完全不同的。Redis 对 String 类型的内部实现主要分成三种编码编码名称适用场景存储方式int整数编码数字型字符串直接用 8 字节 long 存储连 SDS 都不建embstr嵌入式字符串长度较短的字符串redisObject 与 SDS 连续分配在同一块内存raw原始字符串长度较长的字符串redisObject 与 SDS 分开两次分配你可以直接在命令行验证127.0.0.1:6379 SET num 100 OK 127.0.0.1:6379 OBJECT ENCODING num int 127.0.0.1:6379 SET short_str hello OK 127.0.0.1:6379 OBJECT ENCODING short_str embstr 127.0.0.1:6379 SET long_str some string longer than 44 bytes...... OK 127.0.0.1:6379 OBJECT ENCODING long_str raw这个设计逻辑很直观对不同长度、不同内容特征的数据采用不同的内存布局本质上是在“分配效率”和“访问效率”之间做折中。整数用定长存储最省短字符串连续分配一次搞定CPU 缓存友好长字符串如果硬要连续分配反而可能导致大块内存难以分配所以拆成两次独立分配更灵活。1.2 44 字节是怎么算出来的先说结论44 字节这个阈值的计算跟 Redis 的redisObject头部大小和SDS头部大小直接相关。在 Redis 3.2 之前这个临界值其实是 39 字节3.2 之后 SDS 结构调整把短字符串的头部压缩成了更小的sdshdr8临界值才从 39 涨到 44。具体可以这样拆redisObject固定 16 字节包含 type、encoding、lru、refcount、ptr 等字段。Redis 在创建 embstr 时会尝试一次性分配一块 64 字节的内存。这 64 字节里先扣掉 16 字节的redisObject剩下 48 字节给 SDS。对于长度不超过 255 的字符串SDS 使用sdshdr8头占用 3 字节另外结尾还要一个\0空字符占 1 字节。所以真正留给字符串内容的长度就是64 - 16 - 3 - 1 44 字节。为什么偏偏是 64 字节因为 Redis 默认的内存分配器是 jemalloc它在 64 字节这个档位上的分配策略最划算。一次从分配器手里拿 64 字节既不会产生太多内部碎片又能保证 redisObject 和 SDS 连续存放在一起访问时能更好地利用 CPU 缓存行。当字符串内容长度超过 44 字节时Redis 判断这块内存已经塞不进 pre-allocated 的 64 字节空间了于是放弃 embstr改用 raw 编码进行两次独立分配。这就是 44 字节这个数字的完整来历。1.3 只背结论最容易踩的三个误区面试里关于 44 字节最常见的误解有三个我直接在这里拆掉第一个误区认为 Redis String 最大长度就是 44 字节。这是完全错误的44 字节只是 embstr 和 raw 之间的切换阈值String 类型最大能存 512MB超过 44 字节只是从 embstr 切换成 raw 编码功能上没有任何限制。第二个误区认为 embstr 一定比 raw 快。从我的压测结果看读写性能上 embstr 确实有优势但这个优势在读取场景下并不明显真正拉开差距的是修改类操作和内存占用。这点后面用数据说明。第三个误区认为 embstr 可以一直追加内容。实际上 embstr 被设计成只读的短字符串一旦对它执行APPEND或SETRANGE命令Redis 会先把 embstr 升级成 raw再执行追加操作。这个升级过程听起来简单实际上的 CPU 开销和内存复制成本远比大多数人想象中高。2. 12 轮压测是怎么设计的为什么不用 redis-benchmark 一把梭2.1 测试目标与实验变量这次压测要回答的核心问题其实有三个三种编码在读写性能上到底有多大差距44 字节边界附近性能和内存会不会出现明显跳变修改类操作INCR、APPEND对三种编码的影响如何所以我把实验设计成 3 种编码 × 4 类操作 12 轮压测。编码覆盖 int、embstr、raw操作覆盖写入SET、读取GET、修改int 用 INCRembstr 和 raw 用 APPEND、内存占用观测四类。为什么不直接拿redis-benchmark一把梭因为redis-benchmark更适合测 Redis 服务端的极限吞吐但对 value 类型和长度的控制比较粗糙而且不方便在每轮测试后立刻采集内存快照。我需要精确控制写入的数据样本还要记录每轮操作的 P99 延迟所以直接用 Python 脚本基于redis-py写了一套简易压测循环每轮循环 20 万次操作记录总耗时、QPS 和延迟分布。2.2 测试环境与参数为了保证数据可复现我把环境参数列在这里项目配置CPUIntel(R) Xeon(R) Platinum 8269CY8 核内存16 GBRedis 版本6.2.6单机单实例连接方式回环地址单连接复用持久化关闭 RDB 和 AOF压测规模每轮 20 万次操作这里有个容易被忽略的点我把 RDB 和 AOF 都关掉了。因为压测的目标是单纯对比编码差异如果开启持久化fork 子进程进行 RDB 快照时的磁盘 IO 和系统停顿会严重污染延迟数据尤其是 P99 这类尾延迟指标很容易被后台进程干扰。实际业务中当然要开持久化但实验环境下先排除干扰项这是压测的基本素养。2.3 三类样本的构造方式压测数据样本直接决定了结论有没有意义我分别构造了三类样本int 样本固定 key 名称value 是自增整数模拟计数器、ID 或状态码场景。embstr 样本value 是一个 20 字节左右的短字符串模拟验证码、短 token、用户昵称。raw 样本value 是一个 100 字节左右的字符串模拟序列化后的 JSON 对象或较长的缓存内容。另外我额外加了一组边界对照样本44 字节字符串和 45 字节字符串专门用来观察临界值的跳变。脚本核心逻辑大致长这样import redis import time import statistics r redis.Redis(host127.0.0.1, port6379, db0) def run_benchmark(key, value, commandset, rounds200000): latencies [] start time.time() for i in range(rounds): t0 time.time() if command set: r.set(key, value) elif command get: r.get(key) elif command incr: r.incr(key) elif command append: r.append(key, x) latencies.append((time.time() - t0) * 1000) total time.time() - start qps rounds / total p99 sorted(latencies)[int(len(latencies) * 0.99)] return qps, p99注意这里的延迟统计是在客户端视角计算的包含了一次完整 RTT。因为所有测试都走回环地址网络开销很小主要反映的是 Redis 服务端的处理能力和编码差异带来的影响。如果你想在局域网环境复测记得要把网络本身的 RTT 也考虑进去否则绝对数值会变大但相对趋势应该是一致的。3. 实测数据真正的差距集中在哪类操作上3.1 第一组SET 写入性能编码QPS次/秒P99 延迟msint125,0000.095embstr118,0000.120raw97,0000.185第一轮 SET 压测的结果其实已经能说明一些问题了。int 编码的写入性能确实一骑绝尘因为它连 SDS 都不用建直接把 long 值塞进 redisObject 的指针字段里省掉了字符串初始化和内存拷贝。embstr 比 int 慢一点但差距在 6% 左右因为多数情况下一次malloc就能搞定连续内存分配的开销也不大。raw 编码的 QPS 掉到了 97,000比 embstr 低了约 18%P99 延迟也从 0.12ms 涨到了 0.185ms。原因是 raw 需要两次内存分配先创建 redisObject再独立创建 SDS而且 value 内容越长memcpy拷贝的数据量越大。这一轮的数据告诉我写入场景下编码差异确实存在但并没有到“一个天一个地”的程度真正值得担心的反而是 p99 延迟的抬升因为延迟抖动会直接影响上游服务的超时率。3.2 第二组GET 读取性能编码QPS次/秒P99 延迟msint122,0000.100embstr115,0000.122raw104,0000.160读取这组数据比我预想的要“温和”一些。int 依然是第一embstr 和 raw 之间的差距也比写入时要小raw 的 QPS 只比 embstr 低了约 10%。原因在于读取操作的主要成本是哈希查找和把 value 拷贝回客户端编码对查找阶段的性能影响其实不大真正的开销在 SDS 的内容拷贝上。这里有一个细节值得展开如果你的 value 长度只有几十字节raw 和 embstr 的读取差距几乎可以忽略但如果 value 长度是几十 KBraw 的读取性能会随着 value 长度增加而明显劣化。因为每次 GET 都要把整个字符串内容通过网络发送给客户端数据量越大拷贝和序列化成本越高。所以这轮数据其实是给后面的“大 value 场景”埋了一个伏笔小字符串下编码差异温和大 value 下问题会被数量级地放大。3.3 第三组修改类操作INCR 与 APPEND编码操作QPS次/秒P99 延迟msintINCR120,0000.110embstrAPPEND67,0000.260rawAPPEND86,0000.200这组数据是 12 轮压测中最有意思的也是最颠覆直觉的。int INCR 的表现自然不用说这就是 Redis 做计数器、限流器的最强场景单线程原子自增性能几乎没有损耗。重点看 embstr APPENDQPS 直接掉到 67,000比 embstr 的 SET 写入慢了将近一半P99 延迟也飙到了 0.26ms。原因就是前面提到的编码升级机制。对 embstr 执行 APPEND 时Redis 会先分配一个新的 raw 结构把旧字符串内容完整拷贝过去再拼接新追加的内容然后释放旧的 embstr 内存。这个“先拷贝再追加再释放”的过程比直接在 raw 上追加要昂贵得多。相比之下raw APPEND 的 QPS 是 86,000虽然也不低但没有 embstr 那种“带着镣铐跳舞”的额外损耗。也就是说如果你有一个 key 需要反复追加内容初始状态下即便它很短也不应该让它长时间停留在 embstr 编码下频繁变更——第一次 APPEND 就会触发升级而那次升级的成本会被算进首次操作的延迟里在高并发下会形成明显的毛刺。3.4 第四组内存占用的量级变化压测完性能之后我还测了一组内存数据。方式是往 Redis 里写入 20 万个 keykey 名称固定为 16 字节的随机字符串分别记录三种编码下的INFO memory中的used_memory增量编码value 长度20 万 key 内存增量int8 字节约 52 MBembstr44 字节约 89 MBembstr45 字节约 91 MBraw45 字节约 123 MB这里最直观的结论是从 44 字节跨到 45 字节value 本身只多了 1 个字节但如果编码从 embstr 切到 raw内存增量直接多了 32MB 左右。这 1 个字节的代价是超过 35% 的内存膨胀。原因在于 embstr 用一次 64 字节分配就搞定了 redisObject SDS分配器内部是紧凑的而 raw 需要两次独立分配redisObject 和 SDS 各自对齐到 jemalloc 的分配档位redisObject 本身虽然只有 16 字节但分配器会给它一个最小的可用块比如 32 字节SDS 头部加内容再加结尾符又独立对齐一次碎片和填充空间就成倍增加了。所以从纯内存角度讲44 字节边界之所以重要不是因为 Redis 的 String“在 44 字节存不下”而是跨越这个边界之后每多存一个短 key 的内存成本会显著变高。Redis 官方文档里经常强调“use hashes when possible”本质就是在利用小字段聚合来规避这类单 value 的内存膨胀。4. 什么情况下 44 字节的边界会真的咬人4.1 只读业务边界影响可以忽略如果你的线上场景是典型的读多写少缓存比如把用户信息、商品信息缓存进 Redisvalue 很小读操作占了绝大部分那么我可以直说44 字节边界对你的业务影响几乎可以忽略。因为从第 3 节的数据可以看出纯读取场景下 raw 和 embstr 的 QPS 差距只有 10% 左右绝对数值在十万级 QPS 下其实感受不到明显差别。真正影响业务体感的是网络带宽、连接数、慢查询和大 key 阻塞而不是这 10% 的编码开销。在这种场景下你花很多精力去设计 value 长度让每个字符串恰好小于 44 字节说实话性价比很低。4.2 写放大与频繁追加必须警惕编码升级真正需要警惕 44 字节边界的场景是写放大操作。什么叫写放大就是一次逻辑上的修改实际上要复制整个 value造成额外的内存分配、拷贝和释放。典型例子是APPEND和SETRANGE。如果某个 key 初始是 embstr 编码第一次 APPEND 会强制升级到 raw这个升级过程会复制一遍旧值再加上新内容的拷贝。如果这个 key 在一个高并发循环里被反复追加那么每次追加都涉及大块内存的重新分配和拷贝CPU 占用和延迟毛刺都会非常明显。除了 APPEND还有一个更隐蔽的场景如果业务代码里对同一个 key 交替执行 SET 不同长度的 value比如一会儿写入 40 字节的短值一会儿写入 100 字节的长值Redis 就会在 embstr 和 raw 之间反复切换。每次切换都是一次旧结构释放、新结构分配的过程长此以往还会加重 jemalloc 的内存碎片化问题导致used_memory保持低位但mem_fragmentation_ratio异常飙高。4.3 大 key 才是隐藏杀手顺着上面的思路继续往下推如果一个 value 因为编码升级或者其他原因膨胀到了几百 KB 甚至几 MB那问题就已经不是编码层面了而是大 key 问题。大 key 的危害体现在几个层面一是单次读写延迟明显上升因为 Redis 主线程是单线程事件循环一个大 value 的网络传输和内存拷贝会阻塞其他所有命令的执行导致全局延迟抖动二是在做 RDB 快照或 AOF 重写时大 key 会让 fork 出来的子进程在持久化时出现明显停顿三是主从复制时大 key 的传输会占用大量带宽拖慢从节点的同步进度。这里多提一句现在不少 AI 应用会把大模型的 prompt 模板、中间推理结果或检索上下文缓存到 Redis 里这些内容动辄就是几十 KB 甚至上百 KB如果直接用 String 裸存大 value 的尾延迟就会直接影响整个链路的 P99。我在压测中验证过value 从 100 字节涨到 10KBGET 的 P99 延迟会从 0.16ms 飙到 2ms 以上这个量级的变化对线上接口的稳定性影响非常大。对这种场景要么在写入前做压缩要么拆分成 Hash 分片存储避免单个 value 过大。5. 选型落地把编码原理变成能执行的规则5.1 什么场景可以放心用 String看完数据之后很多人容易产生一种“String 是不是不行了”的错觉其实不是。String 类型在以下场景依然是最优解短 ID、验证码、短信码、tokenvalue 很小写多读多用 String 最直接。计数器、限流器、库存扣减int 编码 INCR/DECR 原子操作性能无敌。分布式锁用SET key value NX EX实现简单可靠没必要为了炫技引入其他结构。短生命周期缓存比如接口防重、临时状态标记String 天然适合。在这些场景里44 字节的边界和编码切换问题基本不会出现或者出现了也无伤大雅。面试时如果被问到“Redis 分布式锁怎么实现”可以直接用 String NX EX 回答再说一句“锁的 value 建议用唯一标识释放时用 Lua 脚本比对后删除”就已经是很完整的答案了。5.2 什么场景建议换结构如果你评估下来发现业务场景符合下面几条那就要考虑跳出 String 的惯性思维大量的小字段对象需要单独读写比如用户资料的多个字段、配置中心的多个配置项。如果用 String每个字段一个 key几十万个 key 的内存开销非常大改成 Hash用一个小 key 存多个 field内存效率会高很多。尤其是 Hash 内部采用 listpack 编码时小字段的存储密度远高于零散的 String key。单个 value 特别大且会被频繁读取比如上面提到的大模型上下文缓存。建议要么压缩后写入要么拆成多个分片 key 再聚合读取避免单 value 过大拖垮主线程。需要排序、范围查询或时间序列能力String 根本无法高效支持这类操作简单场景可以用 ZSet时间序列场景可以用 TS 模块不要硬用 String 客户端排序。我把选型方向整理成了一张简易参考表业务特征推荐结构原因短字符串需要频繁读写String简单直接embstr 开销低数字需要原子自增String (int)INCR 原子操作性能最佳一个对象的多个字段Hash单 key 多 field内存紧凑大 value 缓存10KB压缩后 String / 分片 Hash降低网络传输和主线程阻塞排行榜、TopNZSet原生范围排序能力消息队列、延迟队列List / Stream支持阻塞读和消费组5.3 动手自查线上 Redis 怎么查编码和内存如果你不想只凭理论推断想直接看看自己线上的 key 到底是什么编码、占了多少内存有几个命令非常实用。第一个是OBJECT ENCODING key直接返回这个 key 的 value 编码类型是 int、embstr 还是 raw。第二个是DEBUG OBJECT key返回值里包含serializedlength和lru_seconds_idle等信息。serializedlength能大致反映这个 key 的序列化后大小可以用来排查大 key。第三个是redis-cli --bigkeys它会扫描整个实例统计每个类型下最大的 key 和最多的元素是线上排查大 key 的第一利器。另外要养成看内存碎片的习惯。执行INFO memory重点关注used_memory、used_memory_rss和mem_fragmentation_ratio。如果mem_fragmentation_ratio长期大于 1.5说明内存碎片化比较严重可能需要考虑重启实例或更换内存分配策略如果used_memory涨得不明显但 RSS 持续走高那大概率是大量小对象频繁创建释放导致的碎片累积这时候你就要回头看看是不是有不少短 key 在反复切换编码。最后再分享一个小技巧如果你维护的 Redis 集群里有大量短 key可以定期抽样执行redis-cli --no-raw --bigkeys再配合DEBUG OBJECT看几个可疑 key 的serializedlength基本就能判断出哪些业务在做低效的 String 存储。我之前就是靠这个方法把一个业务线上 3GB 的内存消耗降到了 1.8GB——光是把一批 45 字节以上的短字符串改成 Hash 小字段存储就省了将近 40% 内存。这种优化不需要改业务逻辑只需要调整序列化方式和存储结构投入产出比非常高。
返回列表