免费获取学习方案
ARTICLE DETAIL

资讯详情

深耕编程基础知识与建站技术分享的一线实战洞察。

Redis String 编码详解:44字节边界背后的内存账与压测实践

Redis String 编码详解:44字节边界背后的内存账与压测实践 上周排查线上 Redis 内存翻倍真是被一组 String 搞到焦头烂额。RDB 从 2.8GB 涨到 5.1GBredis-cli --bigkeys一跑全是几十上百 KB 的长字符串。同事第一反应是这些 key 肯定超过 44 字节了从 embstr 变 raw内存当然涨。我追问了一句44 是怎么来的为什么不是 39、不是 50他愣了几秒然后我们俩都沉默了。这个看似背下来就行的数字背后其实是一整套内存账。所以我干脆搭了一个独立测试环境把 Redis String 的 int、embstr、raw 三种编码从 1 字节到 10KB 一共 12 个长度梯度各压了一百多万次写入把内存占用、吞吐、延迟全部拉出来对比了一遍。这篇文章就是那次测试的完整记录适合所有被八股文坑过、又想真正搞懂 String 编码的开发者。1. 44 字节这个数字背后是一笔内存账1.1 RedisObject 与 SDS先看这笔账的分子在 Redis 里任何 value 都不是一个裸字符串它外面包了一层robjRedis Object。robj结构体在 64 位系统上固定占 16 字节长这样typedef struct redisObject { unsigned type:4; unsigned encoding:4; unsigned lru:LRU_BITS; /* 24 bits */ int refcount; void *ptr; } robj;type 占 4 bitencoding 占 4 bitlru 占 24 bit这三块合起来正好 4 字节加上 refcount 4 字节和 ptr 指针 8 字节一共 16 字节。字符串实际内容存在 SDSSimple Dynamic String里。Redis 3.2 之后 SDS 分成sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64几种区别是长度字段占几个字节。其中sdshdr8的定义是struct __attribute__ ((__packed__)) sdshdr8 { uint8_t len; /* 已用长度 */ uint8_t alloc; /* 已分配长度 */ unsigned char flags; /* 低 3 位存类型高 5 位预留 */ char buf[]; };len1 字节、alloc1 字节、flags1 字节一共 3 字节头。注意这里用了packed防止对齐填充否则头会变成 4 字节甚至更多边界就完全不是 44 了。1.2 jemalloc 的 64 字节这笔账的分母Redis 默认用 jemalloc 做内存分配器。jemalloc 对小内存分配有一套 size class尺寸分级64 位系统下常见的小类有 8、16、32、48、64、80、96、112……字节。申请一块内存时实际拿到的可用空间是向上取整到最近的 size class而不是要多少给多少。embstr 编码的策略是把 RedisObject 和 SDS 头、SDS 数据、结尾的\0放到同一次 malloc里整体占一块连续内存。这一块的大小就是RedisObject 16 字节 sdshdr8 头 3 字节 数据长度 结尾 \0 1 字节如果想让它正好落在 jemalloc 的 64 字节 size class 里数据长度最大就是64 - 16 - 3 - 1 44这就是 44 字节的出处。超过 44 字节163len1 64会跳到 80 甚至更大的分配单位一次分配的优势不再明显Redis 就改用 raw 编码做两次分配了。1.3 从 39 到 44Redis 3.2 改了什么很多老文章会说39 字节还说得有板有眼。那是因为 Redis 3.2 之前的 SDS 头不是紧凑结构老版本 SDS 头是struct sdshdr { int len; int free; char buf[]; };len4 字节 free4 字节 8 字节头。按同一公式64 - 16 - 8 - 1 39所以 3.2 之前 embstr 的最大长度是 39。3.2 引入动态 SDS 头之后小字符串用 3 字节头阈值才变成 44。网上大量内容把这两个版本混在一起讲越讲越乱。还有个容易混淆的点embstr 内嵌的 SDS 头是写死的sdshdr8不是sdshdr5。sdshdr5虽然只有 1 字节头更省空间但createEmbeddedStringObject源码里直接用了struct sdshdr8。如果 embstr 用 sdshdr5阈值会变成 64 - 16 - 1 - 1 46但实际不是。所以背44之前得先知道这个数字来自一个固定 3 字节头的假设。我这次测试用的是 Redis 7.2.4OBJ_ENCODING_EMBSTR_SIZE_LIMIT这个宏依然是 44。也就是说即使 Redis 版本迭代到了 7.x只要 SDS 头结构不变44 这个边界就不会变。1.4 字节与字符不是一回事中文场景最容易算错44 这个数字单位是字节不是字符。如果 value 是英文1 字符 1 字节如果是中文UTF-81 个字 3 字节44 字节只够存 14 个汉字。也就是说一个看似没多长的中文 key value可能早就越过 embstr 边界变成 raw 了。我见过一个很典型的误判线上有个 key 存 20 个汉字有人觉得也就 20 个字符肯定 embstr结果OBJECT ENCODING一眼全是 raw。因为 20 * 3 60 字节早超了。凡是涉及多字节字符集的场景判断编码之前先把字符数乘上字符集系数再套 44 这个线。2. int、embstr、raw 三种编码的实际行为2.1 int把数字塞进指针位置连对象成本都省了三种编码里最特殊的是 int。当 value 是能被解析为 long long 的整数时Redis 不会创建 SDS而是直接把这个整数值存在robj.ptr字段里。这里的ptr是个void *64 位系统上占 8 字节。一个整数在 long long 范围内也最多 8 字节所以 Redis 直接把数值强转成指针存进去#define OBJ_REAL_PTR(o) ((void*)((long)o))这意味着 int 编码下value 不占用额外的堆内存分配SDS 头不存在数据区不存在连\0都不需要。整个 value 的成本就是那个 robj 的 16 字节而且 robj 本身可能还被共享比如 0-9999 范围内的整数有共享对象池。我在测试机上验证127.0.0.1:6379 SET int:1 123456 OK 127.0.0.1:6379 OBJECT ENCODING int:1 int注意浮点数、带前导零的数字、超出 2^63-1 的大数都不会走 int。123.45不行0123也不行。Redis 判断整数用的是string2ll不允许前导零不允许小数点只允许可选的负号。2.2 embstr一次分配对象和内容连坐embstr 的目的是减少内存分配次数。它把robj、SDS 头、字符串数据、结尾\0放进同一次 malloc 返回的一块内存里。只要数据长度 ≤ 44 字节整块内存一次拿到释放时也一次释放不会产生内存碎片。好处很明显分配次数从 raw 的 2 次降为 1 次。局部性好Object 和字符串数据紧挨着CPU 缓存友好。释放时没有碎片化问题。但 embstr 也有一个坑它是只读的。对 embstr 做任何修改操作比如 APPEND、SETRANGERedis 不会原地改而是直接把它转成 raw再走 raw 的修改流程。这一点在 4.4 节我会用压测数据单独说。2.3 raw两次分配的代价换来了什么当字符串超过 44 字节Redis 选择 raw 编码。raw 的意思是robj单独分配一块内存SDS 再单独分配一块内存robj.ptr指向 SDS 的 buf。从数据读取的角度看raw 没有变慢多少多一次指针跳转在 ns 级别现代 CPU 分支预测几乎无感。真正的代价在写入和内存写入时两次 malloc释放时两次 free。两次分配出来的内存块不一定连续碎片率高时内存膨胀明显。每次覆盖写一个大 value等于旧 SDS 释放 新 SDS 重新分配成本是 O(数据长度) 的拷贝 分配器开销。长时间跑高并发写入raw 编码占比高的实例MEMORY FRAGMENTATION往往比全 embstr 实例高一截。这也是我后来看info memory里mem_fragmentation_ratio才发现的。2.4 编码切换的触发时机比你想象的更频繁很多人以为编码是 set 时一次定死的实际上很多命令会触发编码切换SET k 123存成 intSET k abc存成 embstr。INCR k要求当前值必须能被解析成整数。如果是 int直接加速度极快如果是 embstr 存的数字Redis 会解析、加 1、再尝试存回 int。APPEND k x时如果拼完的新长度 ≤ 44 且仍是合法整数不可能追加字符后基本就不是整数了直接变 raw。SETRANGE k 0 x修改后同样会重新决定编码。SET k 999999999999999999999999超出 64 位整数范围会存成 embstr而不是 int因为解析不了 long long。最容易被忽略的是整数长度超过 19 位2^63-1 9223372036854775807后编码直接跳成 embstrINCR 再往上加就会报错。这个边界在生产环境容易踩因为很多业务流水号是 20 位起步。3. 压测设计12 轮梯度覆盖编码边界3.1 测试环境与 Redis 版本服务器2 核 4G 云主机Linux 5.15Redis 7.2.4默认 jemalloc客户端本机回环redis-benchmark 一个自定义 Python 脚本数据量每组 100 万个 keyKey 规则统一用mem:i格式保证 key 开销完全一致压测命令主要用 SET、GET另加一组 APPEND 场景为什么用 7.2.4因为这是目前生产环境最常见的稳定版本之一而且 7.x 之后 SDS 结构没再变过结果对 6.x 和 5.x 同样有参考意义。3.2 为什么选这 12 个长度很多压测文章只选 1KB、10KB 这种大数字测出来除了raw 就是比 embstr 慢之外什么也说明不了。这次我刻意把长度梯度放在编码边界和 SDS 类型切换点上轮次value 长度对应编码选这个长度的理由11 字节int/embstrint 与 embstr 的最小场景25 字节int/embstr常见短状态值310 字节int/embstr常见 ID420 字节embstr短 token543 字节embstr距离 44 只差 1644 字节embstr临界值能塞进去的最大 embstr745 字节raw刚过临界值864 字节raw越过但方向明确9128 字节raw常见短消息10256 字节raw跨入 sdshdr16 范围111024 字节raw常规业务大 value1210 KBraw接近大 key 预警阈值43、44、45 这三轮是重点。只看一头一尾的差距你会以为 raw 是洪水猛兽但真正有信息量的是 44 和 45 之间那 1 字节带来的跳变。3.3 压测方式与脚本redis-benchmark对 int 编码无力因为-d参数只会生成随机字符串不会让 value 变成整数。所以我采用双轨制embstr 和 raw 场景用redis-benchmark50 个并发连接100 万次请求int 场景用 Python 脚本写入固定整数值只做量级对照。redis-benchmark 命令示例redis-benchmark -t set,get -n 1000000 -c 50 -P 1 -d 64 \ -r 1000000 -h 127.0.0.1 -p 6379 --threads 1int 对照脚本import redis r redis.Redis(host127.0.0.1, port6379, decode_responsesTrue) def bench_int_write(count1_000_000): pipe r.pipeline(transactionFalse) for i in range(count): pipe.set(fint:{i}, 123456) if i % 1000 0: pipe.execute() pipe.execute()我特意用-P 1而不是高 pipeline。因为 pipeline 会大幅掩盖服务端的内存分配开销而我想测的是编码本身带来的差异不是网络调优后的极限值。4. 实测结果同样一个 key差距体现在这四张表里4.1 内存占用对比从 44 到 45 字节跳了一个台阶最直观、也最容易被低估的是内存差异。我统计了每组 100 万 key 写入后的used_memory增量value 内容长度编码100 万 key 总内存增量123456整数6 字节int约 66 MBaaaa...44 字节44 字节embstr约 113 MBaaaa...45 字节45 字节raw约 129 MB随机串1 KBraw约 1.12 GB看 44 到 45 这一行只多了 1 字节的数据整体内存却多了约 16 MB100 万 key 平均每个多 16 字节。这多出来的部分不是数据本身而是 SDS 结构从内嵌变成独立分配后新增了一个分配单元。再对比 int 和 44 字节 embstr同样的普通短值int 比 embstr 省了约 47 MB。这就是为什么我建议能用整数表示的字段状态码、计数、枚举就尽量用整数哪怕它是一个字符串类型的 key。注意绝对数字依赖测试机的 jemalloc size class不同版本可能有几个字节的小幅出入但相对关系在任何环境都是稳定的。4.2 写入吞吐对比分配次数少CPU 时间就少100 万次 SET50 连接pipeline1value 长度编码SET 吞吐ops/s6 字节int 脚本int约 118,00044 字节embstr约 107,00045 字节raw约 102,0001 KBraw约 94,00010 KBraw约 41,000int 比 embstr 快大约 10%embstr 比刚过线的 raw 快大约 5%。单独看百分比不大但注意这是在纯内存、回环网络、无持久化压力的环境已经是 Redis 最理想的状态。真实业务中如果这 5% 的差距发生在每天数十亿次写入的缓存层省下的 CPU 是相当可观的。而且 raw 的额外 malloc 会提高内存碎片率碎片率一高内存是实打实多花出去的。10KB 的吞吐掉到 4 万原因不是编码而是单次数据拷贝量与内存分配量上去了。这个级别的主要矛盾已经从怎么分配变成了拷贝多少数据。4.3 GET 延迟与 P99网络掩盖了大部分真相读操作的延迟差异比写操作小得多。因为读只需要一次已分配内存的访问不涉及 mallocvalue 长度编码GET 平均msGET P99ms6 字节int 脚本int0.340.5144 字节embstr0.370.5845 字节raw0.390.631 KBraw0.410.8210 KBraw0.771.74从 P99 看raw 在 1KB 以内没有明显劣化因为一次指针跳转对 cache line 的冲击几乎可忽略。到了 10KBP99 飙到 1.7ms 主要是数据拷贝量和网络包大小决定的跟编码已经没有关系。所以结论要分层读场景下44 和 45 的差距几乎无感写场景下差距真实存在但有限内存场景下差距是结构性的、立刻可见的。网上很多文章把三种情况混一起说才会给人一种raw 慢到不能碰的错觉。4.4 一个意外发现APPEND 把 embstr 拉回 raw 的隐藏开销压测到一半我想顺手看看 APPEND 对编码的影响结果发现一个很容易被忽略的坑127.0.0.1:6379 SET append:k aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa (44 个 a) 127.0.0.1:6379 OBJECT ENCODING append:k embstr 127.0.0.1:6379 APPEND append:k b (integer) 45 127.0.0.1:6379 OBJECT ENCODING append:k raw从 44 字节 append 1 个字节变 45 字节编码立刻从 embstr 切到 raw。这意味着embstr 对象被废弃重新分配一个 raw 对象原数据要完整拷贝到新的 SDS 中旧内存释放新内存分配。如果业务代码是循环APPEND拼字符串每 append 一次就是一次 O(n) 拷贝。假设一个字符串从 44 字节被 append 到 10KB中间经历的拷贝总量是平方级增长的而不是线性。我单独跑了一组循环 APPEND 的压测从 40 字节开始连续 APPEND 到 1KB100 万次操作耗时比同长度直接SET高出约 30%。原理不复杂但这说明一个常见编码选择问题——能用一次 SET 解决的不要用多次 APPEND 攒。5. 压测之后回到工程应用5.1 别再背 44要背的是分配次数压测数据看下来44 本身不是一个性能魔法数字。它真正的意义是帮助我们理解 Redis 的设计思路小对象尽量一次分配大对象两次分配也没关系但写操作要注意数据和上下文是否匹配。面试或者日常沟通时与其背44 字节是 embstr 和 raw 的分界线不如说清楚44 是从 robj 16 字节、SDS 头 3 字节、jemalloc 64B size class 推导出来的最大内嵌长度。能讲清楚推导比记住数字更有价值。5.2 大 Value 到底该用 String 还是 Hash压测 10KB 字符串时我顺便对比了同体积 Hash 的行为。虽然 Redis 7.2 的 Hash 在字段很多时会从 listpack 转成 hashtable但里面每个字段值是独立小对象内存碎片的粒度要小得多。如果一个大 String 是结构化数据JSON、protobuf、对象序列化且经常需要修改其中一部分强烈建议拆成 Hash。理由是修改单个字段不用重写整个大字符串内存分配粒度小不容易产生大的内存尖峰对大 key 迁移更友好。如果是一个确实无法拆的大文本文件内容、长报告用 String raw 是合理的但要接受覆盖写的成本并且监控它的 key 大小避免拖垮持久化和主从同步。5.3 用 OBJECT ENCODING 扫一遍线上的编码分布看完压测我写了个小脚本批量扫描线上实例的编码分布。线上几百 GB 数据不可能逐 key 手工查下面这个脚本把统计逻辑跑完import redis r redis.Redis(host127.0.0.1, port6379, decode_responsesTrue, socket_timeout5) stats {int: 0, embstr: 0, raw: 0, other: 0} keys_buffer [] # scan_iter 一次拿 500 个 key攒一批后 pipeline 查 encoding for key in r.scan_iter(match*, count500): keys_buffer.append(key) if len(keys_buffer) 500: pipe r.pipeline(transactionFalse) for k in keys_buffer: pipe.object(encoding, k) encodings pipe.execute() for enc in encodings: stats[enc.decode() if isinstance(enc, bytes) else enc] 1 keys_buffer [] if keys_buffer: pipe r.pipeline(transactionFalse) for k in keys_buffer: pipe.object(encoding, k) encodings pipe.execute() for enc in encodings: stats[enc.decode() if isinstance(enc, bytes) else enc] 1 print(stats)注意不要对线上的KEYS *一定要用scan_iter。大批量OBJECT ENCODING虽然比KEYS温和也要避开业务高峰最好在从节点执行。实测跑完我们发现线上实例 raw 占比高达 68%其中大部分是超过 44 字节的中文文本。真相大白之后我们把一批高频访问字段拆成了 Hash内存下降 22%写入 CPU 也降了 8 个百分点。5.4 三条可以直接带走的小建议第一能存整数就存整数。状态位、类型、计数这类字段用 int 编码能省一大块内存还能让 INCR/DECR 走最快路径。第二字符串拼接用SET一次性写不要用循环APPEND攒尤其注意 44 字节这个临界点跨过去要重建整个对象。第三不要让大 Value 集中在少数几个 key 上。即使是 raw 编码只要单个 key 超过几十 KB就必须纳入大 key 监控否则节点迁移和持久化都会给你颜色看。压测做完了该背的数字依然值得背但更重要的是知道它从哪来、能用来判断什么。下次线上内存再涨先跑一遍OBJECT ENCODING统计再看一眼mem_fragmentation_ratio至少能把编码问题和数据量问题一眼分开。
返回列表