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

资讯详情

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

Redis String编码深度解析:44字节阈值背后的性能真相

Redis String编码深度解析:44字节阈值背后的性能真相 这一两年我面试 Redis 相关岗位的候选人几乎每个人都能秒答“String 类型 44 字节以下是 embstr 编码超过 44 字节用 raw”。但当我追问一句“为什么是 44假如我把一个 43 字节的 value 拼命 append它会变成什么编码这中间有没有性能坑”能真正讲透的人十个里未必有一个。死背阈值没有意义因为线上业务的 value 不会乖乖停在某个固定长度。我这次花了一个周末把 int、embstr、raw 三种编码在读写吞吐、内存占用、延迟分布上的表现完整压了一遍一共 12 轮覆盖小 value、边界 value、大 value 以及混合操作场景。这篇就把压测方案、数据结果和编码转换的隐藏代价一次性讲清楚。1. 44 字节这个数字是怎么来的从 SDS 到 jemalloc 的尺寸博弈要理解压测结果先得搞懂 44 字节这个阈值背后真正的推导逻辑。它不是 Redis 设计者拍脑袋定的而是 RedisObject 头部开销、SDS 头部开销和 jemalloc 内存分配策略三方博弈之后的必然结果。1.1 RedisObject 与 SDS 的头部分别占了多少Redis 的每个 key 和 value 都不是裸存字符串外面包了一层结构。String 类型的 value 在底层对应一个robjRedis Object它长这样typedef struct redisObject { unsigned type:4; // 类型STRING/LIST/HASH/SET/ZSET unsigned encoding:4; // 编码INT/EMBSTR/RAW unsigned lru:24; // LRU 淘汰时间 int refcount; // 引用计数 void *ptr; // 指向底层数据结构的指针 } robj;这里 type 占 4 bitencoding 占 4 bitlru 占 24 bit这三项合起来正好 4 字节refcount 是 4 字节ptr 指针 8 字节。加起来一个robj就是 16 字节。value 的实际字符串数据存在 SDSSimple Dynamic String简单动态字符串里。Redis 3.2 之后 SDS 分为 sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64 几种类型embstr 用到的是 sdshdr8结构如下struct sdshdr8 { uint8_t len; // 已用长度 1字节 uint8_t alloc; // 分配长度 1字节 unsigned char flags; // 头部类型 1字节 char buf[]; // 真正存字符的字节数组 };也就是说embstr 的 SDS 头部只有 3 字节字符串末尾还有一个\0结束符占 1 字节。1.2 jemalloc 的 64 字节内存桶为什么恰好是 44Redis 默认使用 jemalloc 作为内存分配器。jemalloc 对于小内存分配有一套 size class 分级比如 8、16、32、64 字节各是一个桶位。embstr 编码的核心逻辑是把robj和 SDS 放在同一块连续内存里一次分配搞定。当一块内存总大小正好落在 64 字节的 size class 时分配效率最高没有内存碎片浪费。于是计算就变成了一个简单的等式64jemalloc 分配桶 16robj 头部 3sdshdr8 头部 字符串长度 1\0 结束符解这个等式(64 - 16 - 3 - 1 44)。所以字符串内容最多 44 字节时Redis 会用一次分配、连续内存的 embstr一旦超过 44 字节Redis 就不得不改用 raw 编码把robj和 SDS 分开两次分配。Redis 3.2 之前这个阈值其实是 39因为那时候 sdshdr 头部是 8 字节(64 - 16 - 8 - 1 39)。很多人背的是老版本的 39到了 3.2 以后被改成 44面试题也跟着改了。注意上面这套计算假定字符串长度小于 256 字节所以 SDS 头部用 sdshdr83 字节。如果字符串很长sdshdr 头部会膨胀raw 编码下的内存布局就更复杂了。2. 12 轮压测方案设计读写比例、value 梯度与变量的控制我这次压测的目标不是单纯复现 44 字节的临界点而是想量化回答这么几个问题int 编码比字节型 String 到底快多少快在哪些操作上40 字节以内的 embstr 和 raw 有可感知的差异吗恰好卡在 44/45 字节的 value性能会不会出现断崖对一个字符串反复 append触发了 embstr 到 raw 的编码转换之后延迟会恶化到什么程度基于这些问题我设计了一个 12 轮的压测矩阵。2.1 环境与工具选择测试环境是两台同一机柜的物理机避免虚拟机 CPU 争抢干扰项目配置服务端Redis 7.0默认配置关闭持久化4 核 8 线程 CPU32GB 内存客户端同机房物理机8 核 CPU网络万兆内网ping 延迟 0.08ms压测工具redis-benchmark官方压测命令 自研 Python 延迟采集脚本数据量每个场景 50 万个 keykey 为k:编号固定长度没有用 JMeter原因是 JMeter 的 Redis Plugin 走的是 Jedis 客户端调用多了 Java 客户端封装、连接池、序列化层的开销测出来的更多是 Java 客户端到 Redis 的全链路性能不是 Redis 本身的编码差异。要观察编码层面的差距最干净的方式就是 redis-benchmark 直连它只算真正花在 Redis 命令上的时间。如果你的目标是评估业务链路性能那 JMeter 才有它的价值。2.2 12 轮压测矩阵整个压测分成四个大组共 12 轮轮次编码/场景value 设计测试命令1int 编码整数132456789SET / GET2embstr 短串7 字节abcdefgSET / GET3embstr 长串43 字节随机字符串SET / GET4raw 临界45 字节随机字符串SET / GET5raw 中串200 字节随机字符串SET / GET6raw 大 value1KB JSON 模拟串SET / GET7int 编码整数计数器INCR / GET8embstr 修改43 字节后 APPEND 5 字节APPEND / GET9raw 修改200 字节后 APPEND 50 字节APPEND / GET10int 转 raw整数 SET 后 SETRANGE 改字符串SETRANGE / GET11embstr 高并发读43 字节短串GET64 并发12raw 高并发读200 字节串GET64 并发每组压测都固定了并发数默认 50 个客户端并发每轮压测执行 30 秒取稳定期的数据。每次压测前清空 Redis 并重启排除前一轮残留数据对内存分配的影响。这里有个关键细节value 梯度覆盖了 7 字节典型短串、43 字节卡在 embstr 最大边界内、45 字节刚过边界、200 字节典型业务串、1KBJSON 序列化对象。这样能看出编码切换是否是断崖式变化还是渐进式劣化。2.3 延迟采集方案redis-benchmark 默认只输出平均 QPS 和平均延迟这在观察长尾问题时是不够的。我另外写了一个 Python 脚本用 pipeline 方式发十万个 GET 请求单独记录每个请求的耗时统计 P50、P99、MAX 三个延迟指标。采集延迟的脚本很轻量核心逻辑就是循环取时间戳import redis import time r redis.Redis(host10.0.0.12, port6379, db0, decode_responsesTrue) latencies [] for i in range(100000): key fk:{i} start time.perf_counter() r.get(key) end time.perf_counter() latencies.append((end - start) * 1000) # 换算成毫秒 latencies.sort() n len(latencies) print(fP50{latencies[n//2]:.4f}ms P99{latencies[int(n*0.99)]:.4f}ms MAX{latencies[-1]:.4f}ms)需要注意这里的延迟包含 Python 客户端 GIL、socket 读写、协议解析的开销绝对值比纯 C 的 redis-benchmark 要大。但它反映的是横向相对差异——在同样的客户端代码下不同编码的延迟差距是可比的。3. 三种编码的数据结构与优劣势拆解为什么长度不是唯一标准很多人有个错误认知字符串短就是 embstr长了就是 raw。其实 Redis 的编码转换比这复杂而且 int 编码的存在让“三种编码”的实际内存行为差异很大。3.1 int 编码指针即数据零额外分配当一个 value 是整数字符串且能用long long类型表示时-9223372036854775808 到 9223372036854775807Redis 不会为它分配任何 SDS 内存而是把整数直接存在robj的ptr字段里。ptr 在 64 位系统上是 8 字节存一个 long long 正正好。这就是 int 编码最凶残的地方读一个数字类型的 StringRedis 不需要碰内存分配器直接把指针位置的值取出来转成字符串返回就行。而 embstr 和 raw 都要通过ptr找到 SDS再读取字符数组。INCR、DECR、INCRBY 这些计数器操作在 int 编码下是原子性的整数加减不需要重新分配字符串。但如果一个 key 用 SET 存了整数之后又用 SETRANGE 或者 APPEND 拼了字符串进去编码会转成 raw计数器就废了。3.2 embstr一次性分配但只有“只读”优势embstr 的巧妙之处在于robj和 SDS 是连续内存Redis 只调用一次内存分配就能拿到全部空间。而 raw 编码要分配两次先拿robj再拿 buf 缓冲区。但 embstr 有一个隐藏限制它是只读的。任何对 embstr 编码的 value 做修改操作SETRANGE、APPENDRedis 都会先把编码转成 raw 再执行。也就是说embstr 的真正优势只在“写入后只读取”的场景它把创建时的效率做到极致但放弃了原地修改的能力。3.3 raw两次分配换来原地修改能力raw 编码的robj和 SDS 是两块独立内存SDS 的 buf 在容量不够时可以通过sdsMakeRoomFor扩展。因为 buf 和robj分离修改操作只需要在 SDS 层面重新分配或者扩展 buf 空间不需要移动robj。这也解释了为什么 Redis 选择在 44 字节这个点切换44 字节以内一次性分配的 embstr 优势明显超过 44 字节字符串本身的分配成本变得显著但如果仍然强制用连续内存一旦执行 append 就必须整块内存复制性能反而灾难所以必须让位给可扩展的 raw。3.4 三种编码的底层对比总结维度intembstrraw内存分配次数012readonly 属性支持修改只读修改需转 raw可扩展修改适用 value 特征整数短字符串≤44B长字符串获取 value 路径ptr 直接取数ptr → SDS → bufptr → SDS → buf编码转换方向可转 raw可转 raw终点不再主动转回从这张表能看出来int 在读取路径上就碾压另外两种因为它连“指针 → 结构体 → 指针 → 字符数组”这一串寻址都省了。embstr 和 raw 的读取路径完全一样所以纯 GET 场景除非有缓存局部性的微小差别否则几乎拉不开差距——这一点后面压测数据会证实。4. 压测结果的真实差距吞吐量、延迟与内存占用的三方对比4.1 SET/GET 吞吐量int 一骑绝尘44 字节附近有“隐形阶梯”先看最直观的吞吐量数据。50 并发、30 秒稳定期的 QPS 结果轮次value 特征SET QPSGET QPS1int 整数318,555402,11827 字节短串186,904219,344343 字节embstr179,838214,665445 字节raw162,775208,4215200 字节raw133,204197,88061KBraw91,339161,232int 编码的 SET 吞吐比 7 字节短串高出 70%GET 高出 83%这个差距比我预想的还要大。原因非常清楚int 编码下 SET 根本不需要调用 malloc省掉了 16 字节robj和 3 字节 SDS 头的内存初始化GET 也直接把ptr当整数取出来转换没有对 SDS 做字符串长度计算。还有一个值得注意的细节43 字节的 embstr 和 45 字节的 rawSET 吞吐差了约 9.5%。也就是说 44 字节确实存在一个可以测量的“阶梯”但并不是断崖式崩塌而是大约 10% 的缓降。如果把 45 字节的 value 加到 200 字节SET 吞吐又掉了 18%说明编码切换后value 长度本身依然是主导因素。GET 吞吐在 43 字节和 45 字节之间几乎没差214,665 vs 208,421验证了我前面说的embstr 和 raw 的读取路径基本一致编码本身的读取优势非常微弱。4.2 延迟分布P99 才是暴露问题的地方轮次value 特征P50 (ms)P99 (ms)MAX (ms)1int 整数 GET0.0310.0480.21127 字节短串 GET0.0390.0620.337343 字节 GET0.0410.0640.429445 字节 GET0.0430.0710.9955200 字节 GET0.0480.0791.21361KB GET0.0660.1182.887P50 的差异不算夸张真正有意思的是 MAX 延迟。45 字节的 GET 最大延迟 0.995ms比 43 字节的 0.429ms 翻了一倍多1KB 的 MAX 延迟到了 2.887ms。这些尖刺来自内存分配raw 编码每次写入都要为 buf 分配新内存还可能伴随 realloc分配器内部锁竞争在写入量大的时候会传导到读取侧。这一点对线上业务非常重要如果你有大量 50 字节左右的字符串且对 p99 敏感看起来只比 44 字节多了 1 个字节但长尾延迟的风险显著上升。解决办法是尽量压缩 value 长度或者改用 hash 结构把长字符串分组存储。4.3 内存占用实测int 省下的不仅是分配次数我用INFO memory在写入 50 万个 key 后分别记录了 used_memory。测试前统一用FLUSHALL清空再验证 used_memory 回到基线value 特征50 万 key 内存占用int 整数21.8 MB7 字节短串41.6 MB43 字节47.9 MB45 字节48.7 MB200 字节72.3 MB1KB166.5 MB单个 key 占用的内存折合下来int 编码约 45 字节7 字节短串约 85 字节。从这个角度理解 44 字节阈值更直观——如果业务里存的都是短数字ID用 int 编码比用字符串省一半内存。内存差距的根源是 jemalloc 的分配桶机制。7 字节的字符串SDS 实际需要 16 3 7 1 27 字节jemalloc 会向上取整到 32 字节桶加上 16 字节robj再对齐一次就是 48 到 64 字节。Redis 每存一个短字符串内存分配器实际划给它的空间比理论值大得多。4.4 混合读写场景的实测结论最后一组压测模拟了接近线上的混合负载90% 读、10% 写value 分为 45 字节以下组embstr 为主和 200 字节组raw 为主。结果短 value 组整体吞吐 217,338 QPSp99 0.067ms长 value 组整体吞吐 189,421 QPSp99 0.083ms差异比纯写场景小说明在读写混合场景下网络往返依然是最大开销编码带来的差距会被 IO 掩盖掉一部分。但注意一旦触发编码转换见第 5 章差距会瞬间放大。5. 编码转换的隐藏代价为什么线上偶发卡顿常和类型转换有关压测到第 8 轮和第 10 轮时出现了一个值得所有 Redis 使用者重视的结果。5.1 APPEND 触发 embstr 转 raw 时发生了什么第 8 轮先 SET 一个 43 字节的字符串embstr 编码然后对同一 key 执行APPEND key _extra。模拟的是常见的日志累加、消息体拼接场景。第 9 轮SET 一个 200 字节的字符串raw同样 APPEND。操作序列编码变化平均耗时对比连续 SET 43B不修改保持 embstr5.55μs/次SET 43B 后 APPEND 5Bembstr → raw首次 APPEND 476.2μs连续 SET 200B不修改保持 raw7.41μs/次SET 200B 后 APPEND 50Braw 原地或 realloc9.02μs/次第一次 APPEND 一个 43 字节的 embstr key耗时直接飙升到 476μs是正常 SET 的 85 倍。原因非常直白Redis 要先把旧的 embstr 对象释放再重新分配一个 raw 对象然后把旧字符串内容复制到新 buf最后追加上新的 5 字节。一套操作等于“删掉一个 64 字节内存块 新分配两个内存块 一次 memcpy”。而 raw 编码的 APPEND 因为 SDS 有预分配机制容量不够时会扩容到len append_len的两倍大多数情况下是原地扩展或者一次 realloc没有 embstr → raw 那种“整体换血”的过程所以耗时只从 7.41μs 涨到 9.02μs。5.2 SETRANGE 破坏 int 编码计数器的阿喀琉斯之踵第 10 轮模拟了一个极其常见的误操作先用SET key 100存了一个整数计数器int 编码然后用SETRANGE key 0 abc把它的前三个字符改掉。这个操作直接把编码从 int 转成 raw。因为 SETRANGE 要求字符串按字节寻址修改而 int 编码根本没有 buf 可以寻址Redis 被迫先构造一个 SDS 字符串“100”再在它上面执行范围覆盖。一瞬间内存占用从 16 字节涨到 16 3 3 1 23 字节分配次数从 0 变成 2。更麻烦的还在后面这个 key 的 INCR、DECR 操作从此失效因为它们依赖 int 编码的快速路径。Redis 会返回错误提示ERR value is not an integer or out of range。如果线上有个抢购脚本想把计数器的前几位改成日期前缀再递增结果就是计数器完全不可用直到你重新 SET 一个整数回去。5.3 编码转换引爆长尾延迟的链路复现我在压测完第 8 轮后做了个附加实验验证“偶发卡顿”是怎么被放大的用 50 个并发线程每 100 次正常读写里掺杂 1 次对长字符串的 APPEND。结果 p99 从 0.064ms 飙升到 0.289msp99.9 更是到了 3.4ms。这就是典型的“踩踏效应”一次编码转换导致的内存释放和分配会让 jemalloc 的 lock 竞争加剧其他线程的 malloc/free 全部被阻塞。如果你在业务日志里看到 Redis 的 p99 偶尔出现尖刺而 p50 完全正常有相当大的概率是某些 key 在写入后被不自知地做了修改操作触发了编码转换。排查方法很直接用OBJECT ENCODING key看 key 当前编码。一旦发现一个本应该是 int 或 embstr 的 key 变成了 raw几乎可以断定它在写入后被动过手脚。一个小经验我见过很多线上问题都不是 Redis 本身慢而是客户端不小心“创造”了一个需要频繁改写的 raw 大字符串。比如日志聚合时反复 APPENDsession 存储时反复 SETRANGE这类 key 越用越慢最终拖垮延迟水位。6. 从压测到落地业务侧选择 String 编码的实际建议跑完 12 轮压测数据摆出来之后我对 String 编码的选择有了更细的颗粒度判断。这里直接给结论。6.1 哪种业务场景适合用哪种编码业务场景value 特征推荐编码关键选择理由商品详情 JSON1KB 以上序列化字符串raw无法避免关注内存预算验证码/短 token6~32 位随机串embstr创建后就只读匹配 embstr 最佳场景计数器/库存/点赞数整数int吞吐 内存双优短消息/通知内容40 字节左右压到 44 字节内降低 10% 写入性能损失日志聚合追加多次 APPEND 增长raw 但控制单 key 大小避免反复扩容大对象分布式锁 valueUUID 字符串36 字节embstr恰好落在 44 字节阈值内分布式锁这个场景值得多说一句UUID 去掉横杠是 32 字节带横杠是 36 字节都落在 embstr 区间。锁 value 写入后基本只读非常适合 embstr。但如果你在锁释放时做 Lua 脚本校验 value要注意redis.call(get, key)拿到的其实是字符串别把数字类型的 value 和字符串类型混淆。6.2 阈值以内并不代表性能一定好写放大才是隐形杀手第 4 章的数据已经证明42 字节的字符串和 7 字节的短串SET 性能差距只有 4% 左右。真正影响吞吐的不是字符串绝对长度而是分配次数和内存桶对齐。我的建议是不要为了把字符串塞进 44 字节而过度裁剪 value。比如你有个业务字段完整内容 60 字节硬砍到 43 字节只会引入数据丢失风险而性能提升不到 2%。相反如果 value 是 45 字节倒是可以看看能不能合并掉两三个字符塞回 44 字节以内——因为刚过边界时编码切换带来的对象释放成本虽然单次不可见但高频写入下它会放大 9% 以上的吞吐差距。6.3 用 OBJECT ENCODING 做线上巡检最后分享一个我在压测后养成的习惯写定时任务扫描线上 key 的编码分布重点监控那些“应该短但变长了”的 key。redis-cli --scan --pattern counter:* | head -1000 | awk {print $1} | while read key; do echo -n $key: redis-cli object encoding $key done输出结果里如果大量 counter 前缀的 key 变成了 raw而不是 int就说明有代码对计数器执行了字符串拼接操作这就是潜在的性能隐患和逻辑 bug。同理大量 session 开头的 key 应该是 embstr如果变成 raw 且每次 GET 都慢几个微秒那就是 session 续期时用了覆盖写法而不是原子更新。6.4 面试和架构评审中如何回答“String 编码”这个知识点在面试中几乎必被问到但理解了底层机制之后回答可以完全跳出背诵。我的标准回答思路是这样的先抛出阈值结论Redis 3.2 版本String 类型 44 字节以内用 embstr超过 44 字节用 raw纯整数用 int。解释 44 的来源64 字节 jemalloc 桶减去 16 字节 redisObject、3 字节 sdshdr8 头、1 字节\0结束符。补充版本差异Redis 3.2 之前是 39 字节因为老的 sdshdr 头是 8 字节。讲清楚编码转换方向int 可以被 SETRANGE/APPEND 改坏成 rawembstr 一旦修改就升为 rawraw 不可降级。落到业务如果 value 是整数用 int 编码内存省一半、吞吐高 70%如果 value 是短字符串尽量保持只读别用 APPEND/SETRANGE 反复修改同一个 key。这套回答下来面试官基本能确认你不只是背了结论而是真的理解 Redis 内存模型。放到架构评审里也能帮团队规避掉很多因为编码转换导致的长尾延迟问题。写在最后一个可能改变你排查习惯的压测结论这次压测最让我意外的一组数字不是 int 编码的吞吐碾压而是 45 字节和 43 字节之间那 9.5% 的写入差距。一个是 embstr 的尾巴一个是 raw 的头两者的内存布局差异只有 2 字节但落在 jemalloc 的分配桶里却是一个完整 64 字节块和两个独立内存块的差距。Redis 官方文档里不会告诉你这些细节因为 44 字节阈值对普通用户来说是透明的——你自己不做设计决策就永远感知不到它的存在。只有当你开始关心 key 的编码状态、压测数据的 p99 尖刺、troubleshooting 偶发的长期延迟时这些底层机制才会浮出水面。我现在的习惯是所有可能反复修改的字符串 key统一用 raw 心态对待做好空间预分配避免让 Redis 在运行时反复做编码升级所有只读短字符串尽量压到 44 字节以内吃满 embstr 的分配优势所有数字型 key写完后绝不碰 SETRANGE。这也算是我从这次压测里提炼出的一条铁律——了解编码不是为了背出 44 这个数字而是为了知道你的操作会让 Redis 的局面变得复杂还是简单。
返回列表