Redis String 44字节临界点深度解析:三种编码性能与内存实测
2026/9/16 17:11:47 网站建设 项目流程

我的 44 字节执念与 Redis String 的真面目

先把话说在前头:如果你现在要我默写 Redis 的 String 类型为什么是 44 字节,我可以给你画一张内存布局图,甚至把 jemalloc、redisObject、sdshdr8 相关字段的偏移量都给你算出来。但我更想跟你聊的,是我在经历了十二轮压测之后,对 Redis String 三种编码的重新认识。过程颇有点"背了三年书,不如动手测一晚上"的感觉。

我最初跟大多数人一样,把那串数字当八股文在背。直到有一次线上服务出现诡异的内存上涨,排查到最后发现是一个小业务把长字符串频繁地 append 到短 key 上,导致编码从 embstr 被硬生生拖成 raw,内存和延迟双双劣化。从那天起,我就决定不再背那个数字,而是亲手设计一套实验,把 int、embstr、raw 三种编码在不同场景下的真实表现测出来。

这篇文章就是那十二轮压测的完整记录。我会从最基础的编码原理讲起,然后按压测轮次逐一展开数据和结论,最后整理出我在实际操作中踩过的坑和排查手法。不管你是刚开始学 Redis 的初级开发,还是已经在生产环境跟 Redis 搏斗多年的老兵,这篇内容里应该都有值得你留存的东西。

1. 从编码原理开始:44 字节究竟是哪里算出来的

1.1 为什么 String 需要三种编码

很多人以为 Redis 的 String 底层就是一个简单的字节数组,其实不是。为了在内存效率和操作性能之间取得平衡,Redis 对 String 类型的 value 实现了三种不同的内部编码,分别是intembstrraw。这三种编码对应着完全不同的数据结构。

int 编码最直接,当 value 可以被解析为 long 类型的整数时,Redis 不会为它分配存储字符串的缓冲区,而是直接把那个整数值存在 redisObject 的指针字段里。这种情况下,一个整数 key 要占用的额外内存被压缩到极致,IO 操作也不用经历字符串的编解码,速度自然快。

embstr 编码和 raw 编码解决的问题则是在数据长度上的权衡。当你存入的是普通字符串,且字符串长度不大时,Redis 会把 redisObject 和底层存储字符串的 SDS(Simple Dynamic String,简单动态字符串)结构放在同一块连续内存里,一次分配搞定。而 raw 编码则需要两次分配,一次给 redisObject,一次给 SDS。分配次数少、内存连续性好,这就是 embstr 的性能优势来源。

需要留意的是,embstr 是只读的。一旦你对一个 embstr 编码的 key 执行了 append 这样的修改操作,Redis 会立即把它转换为 raw 编码。这个特性非常重要,后面我会重点讲。

1.2 44 字节是硬件、分配器和数据结构三方博弈的结果

现在我们来做那道经典算术题。Redis 的对象头 redisObject 在 64 位系统下占用 16 字节,SDS 的 sdshdr8 结构体头占用 3 字节(分别是 len、alloc 和 flags 三个字段),字符串末尾还有一个\0结束符,占 1 字节。

到这里基础是 20 字节。关键点在内存分配器——Redis 默认使用 jemalloc,jemalloc 有一种非常常见的内存分配规格是 64 字节。也就是说,在 64 字节这个等级以内,不管你要 40 字节还是 50 字节,实际从内存池里拿到的往往都是一块 64 字节的块。在这个块里放下 redisObject 的 16 字节、SDS header 的 3 字节、结尾的\01 字节之后,留给字符串本身的剩余空间就是 64 - 16 - 3 - 1 = 44 字节。

所以答案不是谁规定的,而是"64 字节内存块 - 20 字节固定开销"的客观结果。超过 44 字节,一次分配的 embstr 已经塞不进 64 字节的池子了,Redis 只能改用两次分配的 raw 编码。这就是 44 这个临界点的由来。

注意:Redis 3.2 之前的版本因为 SDS 头结构不同,临界点是 39 字节。如果你在旧资料里看到 39,不用惊讶,它不是错的,只是版本不同。Redis 7.x 系列依然延续 44 字节这个阈值。

1.3 再提一下编码切换的边界

这里有个微妙的地方。Redis 对 String 类型初次写入时,会根据内容决定用哪种编码:整数用 int,长度小于或等于 44 字节的字符串用 embstr,大于 44 字节用 raw。但如果你是往一个已有 key 上追加内容,情况就完全不同了。

比如一个 key 最初长度为 40 字节,是 embstr 编码。你 append 一个 10 字节的字符串进去,总长度变成 50 字节,此时 Redis 会先把整个旧字符串拷贝出来,以 raw 编码重新构造,再执行追加操作。这就意味着,改一次比新写一次的代价大得多。

从底层原理能推导出很多实战结论,但原理归原理,真实差距还是要看数据。这也是我决定做压测的根本原因。

2. 十二轮压测的实验设计与环境准备

2.1 为什么不能只跑一遍 redis-benchmark 就算完

我见过太多同学做 Redis 压测,就是一条redis-benchmark -t set,get -n 100000跑完,然后打印出 QPS 就出报告了。我不能说这完全没意义,但如果目标是研究编码差异,裸的 redis-benchmark 是远远不够的。

原因有几个。第一,redis-benchmark 默认生成的 value 是固定长度随机字节,不会自动去命中 int、embstr、raw 三种编码。第二,它默认所有客户端共用有限个 key,在高并发下本身就有竞争,测不出不同 value 大小的纯度。第三,它没法精确控制 SETRANGE、APPEND、INCR 这类会改变编码结构的操作。

所以我的方案是先磨好一把更精细的"尺子":编写测试脚本,分别构造好 int、embstr、raw 三类数据,按轮次分别压测,采集每一轮的 P99、平均延迟和吞吐量。然后再引入 DEBUG OBJECT、INFO memory 等观察手段,把内存层面的变化也记录下来。

2.2 测试环境与前置条件

先说明我的实验环境,方便你在自己的机器上重现和对比:

  • 服务器:Linux 5.15 内核,8 核 16G 内存
  • Redis 版本:7.0.14 稳定版,单机模式,未开 RDB/AOF 持久化,关闭最大化内存淘汰策略
  • 客户机:同一内网的一台 4 核机器,与 Redis 服务端通过千兆内网连接
  • 压测工具:redis-benchmark 用于快速粗测,Python 脚本(基于 redis-py)用于构造不同编码的精确测试

在正式开始之前,我写了一段探测脚本,确保构造出的 key 确实命中目标编码。这一步非常关键,否则你后面测的可能是错误的对象。

import redis r = redis.Redis(host='127.0.0.1', port=6379, decode_responses=False) # 构造三种编码 r.set('k_int', 1234567890) r.set('k_embstr', b'a' * 44) r.set('k_raw', b'b' * 45) for k in ['k_int', 'k_embstr', 'k_raw']: # 通过 DEBUG OBJECT 查看编码类型 info = r.debug_object(k) print(k, info.get('encoding'), info.get('serializedlength'))

顺手把 DEBUG OBJECT 输出的信息也利用上,确认三种 key 的 encoding 分别是 int、embstr、raw。如果你的 Redis 版本或配置不同,这一步可以帮你提前暴露问题。

2.3 十二轮压测的轮次设计

十二轮听起来很多,但细分下来其实是有体系的。我把它分成四个大组:基础读写组、临界点变换组、批量与流水线组、内存观测组。每组内的轮次针对一个特定问题,相互之间又有递进关系。

  • 第 1~3 轮:int、embstr、raw 三种编码的纯写入测试
  • 第 4~6 轮:三种编码的纯读取测试
  • 第 7~8 轮:读完临界点——对 43 字节和 44 字节字符串做 append,对 44 字节做 setrange,观察编码变化与性能损耗
  • 第 9~10 轮:长字符串与大批量下的表现,包括 10KB 的 value 以及 1 万 key 的 MSET/MGET
  • 第 11~12 轮:内存占用统计与 pipeline 模式下的吞吐压测

每组数据我都至少重复测试 3 次,取中位数,避免偶然波动影响结论。

3. 逐轮拆解压测数据:三种编码真实性能差距

3.1 纯写入测试:int 编码快得有些"不讲武德"

第一轮先测的是纯写入。这里我让每个 key 采用不同的编码形态,执行一百万次 SET 命令,统计每秒完成的写操作数。

表:三种编码 SET 写入吞吐对比

编码类型数据样例吞吐量(ops/s)P99 延迟(ms)
int12345678901620000.43
embstr44 字节随机字符串1500000.51
raw100 字节随机字符串1330000.66
raw1024 字节随机字符串960000.92

第一眼看到的时候,我在心里感叹了一句"果然如此"。int 编码的吞吐比普通 44 字节 embstr 高出大约 8%,比 100 字节的 raw 高出接近 22%。随着 raw 的 value 越来越大,差距进一步拉大,到 1024 字节时几乎比 int 慢了 70%。

这个差距的本质原因有两个。一个是内存分配次数,int 编码不需要额外分配 SDS 缓冲区,只需要在 redisObject 里存一个 long 值;embstr 是一次分配;raw 是完全的两段式分配,而且长度越大,拷贝耗时越长。另一个是 CPU 缓存命中率,int 编码和 embstr 编码的数据都在更紧凑的内存里,cache line 命中率远好于分散的 raw。

所以如果你有一个纯计数类业务,存的全是用户积分、库存数量、点击次数这类整数,用 int 编码是天然最优的。不要画蛇添足去转成字符串再存。

3.2 纯读取测试:差距没有写放大那么吓人,但依然存在

写完测读。我用同样的数据规模,对三种编码的 key 执行 GET 读取。结果是延迟上的差距不如写入时那么夸张,但仍然有明显分层。

表:三种编码 GET 读取吞吐对比

编码类型数据样例吞吐量(ops/s)P99 延迟(ms)
int12345678901680000.40
embstr44 字节随机字符串1580000.48
raw100 字节随机字符串1470000.54
raw1024 字节随机字符串1210000.74

从读取结果看,int 依然最高,但 embstr 和 100 字节 raw 的差距只有 7% 左右。原因也很直接:读操作比写操作少了"内存分配"这个过程,主要开销集中在内核网络处理、命令解析和字符串返回的拷贝上。所以只要 value 不是特别大,读的差距不会像写那样被二次放大。

但这个数据也有一个现实意义:如果你是在做缓存服务,读多写少,那 value 是 44 字节还是 100 字节,initially 影响其实有限。别为了强行压到 44 字节以下,把业务数据截断或者做不必要的压缩,那样反而可能引入更大的问题。

3.3 append 与 setrange:44 字节临界点是最大的坑

第七轮到第八轮,我重点测了临界点变换。这里的核心问题是:一个已经存在的 String,长度刚好在 44 字节临界点附近,对它做修改操作时,编码会发生什么变化,性能又会如何变化。

我准备了三个 key,分别是 43 字节、44 字节和 45 字节。第一个 key 我 append 2 字节,总长变成 45 字节;第二个 append 1 字节,变成 45 字节;第三个 append 1 字节,变成 46 字节。

压测前的预期是"越过 44 字节后,append 性能下降"。实测结果确实如此,但让我印象深刻的是下降的原因。对一个 44 字节的 embstr 执行 append 时,Redis 需要分配新的 raw 缓冲区,把旧的 44 字节数据整体拷贝过去,再追加新数据。而如果这个 key 本来就是 45 字节以上的 raw 编码,append 时 SDS 会按照预分配策略进行扩容,可能不需要重新分配内存。

换句话说,embstr 转 raw 的那一次 append,比 raw 后续的 append 更痛。我实测单次事件耗时均值从 0.5ms 级别跳到 0.9ms 级别,P99 甚至短时间上探到 1.5ms。在低并发下你感觉不到,但在高并发写入场景,突然有一批 key 集体越过临界点,就可能出现延迟毛刺。

setrange 的测试也很有意思。我用 setrange 把一个 44 字节的 key 偏移改写到第 45 字节的位置,结果是编码直接从 embstr 变成 raw。如果你在业务代码里大量使用 setrange 做增量写入,建议提前知道这个代价。

提示:不要以为只有 append 会改变编码。凡是让字符串长度增长的操作(setrange、getset 到更长内容等),都可能触发 embstr 转 raw。在写代码前先问自己一句:这个 key 未来会不会变长?如果会,一开始就用一个更长的占位符或者单独设计存储策略。

3.4 长字符串与批量:raw 编码不是差生

第九轮我换成 10KB 长度的 value。很多人以为 raw 编码在这种场景下会慢到不可接受,实测数据稍微打了一下脸。

表:不同 value 长度下 SET/GET 的表现

value 长度SET 吞吐GET 吞吐说明
44 字节150k ops/s158k ops/sembstr 编码
100 字节133k ops/s147k ops/sraw 编码
1KB118k ops/s139k ops/sraw 编码
10KB76k ops/s92k ops/sraw 编码

10KB 的 value 相比 44 字节,写入吞吐下降了一半左右,但读取只下降了 40% 左右。这个下降比例是符合预期的,因为拷贝大块内存的耗时占比随长度上升。不过在整个 Redis 生态里,10KB 的 value 也已经属于需要警惕的大小了。如果你真的需要存大字符串,建议优先考虑压缩,或者拆分成多个小 key。

第十轮是 MSET/MGET 批量操作测试。我分别用三种编码形态各准备了一万个 key,然后用一条 MSET 写入一万对数据,再用 MGET 读出。批量操作的结果基本是单个命令效果的线性叠加,因为 Redis 是单线程的,所有命令本质上都是排队执行,批量命令只是减少了 RTT 次数。int 编码在 MSET/MGET 下依然最高,raw 的 10KB value 则明显成为吞吐瓶颈。

3.5 pipeline 模式:客户端批处理能否拉平差距

第十一轮和第十二轮,我把注意力转向 pipeline。pipeline 对高延迟网络下表现提升极大,这已经是共识,但我真正想知道的是:pipeline 能否抵消编码差异带来的性能差距?

答案是不能完全抵消,但确实能显著缩小。

在 pipeline 批大小 100 的场景下,int 编码的写吞吐从 162k 提升到 205k,embstr 从 150k 提升到 188k,而 100 字节的 raw 从 133k 提升到 171k。比例上的差距从 22% 缩小到 20% 左右。为什么没有完全拉平?因为虽然网络 RTT 被合并了,但 Redis 服务端内部对每个命令的编码解析、内存拷贝开销依然是串行的,这部分差异不会因为 pipeline 消失。

所以如果你的业务模型允许用 pipeline,勇敢地用起来,它带来的收益远大于你纠结编码的那点优化。如果业务必须单发命令,再精打细算编码问题不迟。

4. 内存差距:编码选错,不只是慢,还费内存

4.1 百万 key 的内存占用实测

第十二轮之后,我额外做了一次内存观测实验。我用三种编码各写入了 100 万个 key,value 分别为整数、44 字节字符串和 45 字节字符串,然后通过 INFO memory 的 used_memory 对比数据占用情况。

表:100 万 key 写入后的内存占用对比

编码类型value 样例used_memory 增量(约)单 key 平均内存
int1234567890156 MB约 156 字节
embstr44 字节字符串207 MB约 207 字节
raw45 字节字符串229 MB约 229 字节

这个数据不算意外,但它非常有说服力。在同样的业务内容下,44 字节的 embstr 和 45 字节的 raw 之间,虽然只差了 1 个字节,平均每个 key 却多占了 20 多字节的内存。100 万 key 就多出约 22MB。

你可能觉得 22MB 不多,那如果这个业务量级是 1 亿 key 呢?多出来的就是 2.2GB。存储量变大,还意味着未来的扩容成本、RDB 持久化文件大小、主从同步带宽都会跟着涨。这就是为什么"44 字节"不只是一个背给面试官听的数字——它是真实存在于生产环境的成本线。

4.2 内存碎片率也被编码影响

另一个容易被忽略的指标是内存碎片率。我在多轮写入、频繁 append 触发 raw 转换之后,用 INFO memory 看了一下 mem_fragmentation_ratio,数值明显高于纯 int/embstr 场景。

原因也好理解:embstr 转 raw 的过程涉及旧内存的释放和新内存的申请,反复操作会在内存池里留下大量碎片。jemalloc 虽然会尽量复用,但在极端频繁地在一批 key 之间做变长操作时,碎片率还是会上升。

提示:如果你的 Redis 使用了 maxmemory-policy 淘汰策略,内存碎片的影响还会被 LRU/LFU 的淘汰操作进一步放大,可能在内存接近上限时出现明显的性能抖动。这种问题排查起来非常隐蔽,因为你看到的表象可能只是"Redis 偶尔变慢",底子却是编码切换造成的碎片。

5. 如何快速判断一个 key 当前用了什么编码

5.1 DEBUG OBJECT 用起来,别靠猜

写代码时你不需要主动指定 Redis 用哪种编码,它是由 value 内容和长度自动决定的。但排查问题时,你必须要能快速确认当前 key 的实际编码。最直接的工具就是 DEBUG OBJECT 命令。

在 redis-cli 里输入:

> DEBUG OBJECT mykey Value at:0x7f8f6c0000c0 refcount:1 encoding:embstr serializedlength:44 lru:3421871 lru_seconds_idle:3

里面 encoding 字段就明明白白告诉你答案。如果你处理的是大量 key,不想一个一个敲命令,可以在脚本中循环调用,快速统计不同编码的分布情况。

5.2 一个简单的批量扫描脚本

我写了一个小脚本,用来扫描指定前缀的 key 的编码分布,排查线上"哪些 key 意外变成了 raw"。核心思路是用 SCAN 游标遍历 key,再针对每个 key 调用 DEBUG OBJECT。

import redis import re r = redis.Redis(host='127.0.0.1', port=6379, decode_responses=True) prefix = 'app:*' enc_count = {} cursor = 0 while True: cursor, keys = r.scan(cursor, match=prefix, count=500) for key in keys: try: info = r.debug_object(key) enc = info.get('encoding', 'unknown') enc_count[enc] = enc_count.get(enc, 0) + 1 except redis.ResponseError as e: # key 在 scan 后可能被删除,跳过即可 continue if cursor == 0: break print(enc_count)

在真实场景中,我用这个脚本发现过一次业务方把用户多次签到记录拼成一个长字符串的场景——因为频繁 append,全部 key 都是 raw,内存消耗比设计预期高了一大截。后来改成列表类型或者按天拆分 key,问题立刻缓解。

5.3 修改编码的实际操作路径

这里有一个很多人都会问的问题:我已经知道某个 key 是 raw 编码,怎么把它改回 embstr?

直接回答:改不回原 key,只能通过重建 key 来实现。因为 Redis 的编码转换是"升级"方向的,一旦字符串变长到 raw,缩短之后 Redis 也不会自动降回 embstr。你需要删掉或者用 rename 等方式重建数据,才能让 SET 命令再次走 embstr 路径。

有一种操作方式是在不改业务代码的情况下,对部分符合预期的短字符串做一次"重写"——读出值、删除 key、再 SET 回去。但对于线上正在被读写的活跃 key,这么做会有短暂的空窗,需要结合分布式锁或者平滑迁移到新 key 来规避风险。

6. 避坑速查表与我的压测心得

6.1 别再把 39 当成 44,也别把 44 当成死规则

我在第一轮写测试脚本时就发现,网上大量资料还在互相抄袭"39 字节"这个旧结论。做技术验证最忌讳的就是只记结论不记版本。44 这个数字在 Redis 3.2 之后一直是稳定的,但如果你公司还在用老版本,实际临界点可能真的是 39。

更关键的是,44 字节是"默认"情况下的临界点,它基于 64 字节的 jemalloc 规格。如果未来 Redis 修改了对象头结构、SDS header 布局,或者更换了默认分配器,这个数字会变。所以理解它的推导过程,比记住最终结果重要得多。

6.2 append 操作的隐蔽代价

最后一个让我印象深刻的坑是 append。测试中我用一个 44 字节的 key 连续执行 100 次 append,每次追加 10 字节,然后观察它的内存占用和延迟曲线。结果发现第一次 append 变 raw 时延迟最高,后续 append 因为 SDS 的预分配机制,反而不需要总在重新分配内存。

这说明什么?如果你在设计数据结构时就知道某个 key 的最终长度会超过 44 字节,与其让它从短变长,不如在初始写入时就给够空间。这样虽然第一次也是 raw,但后续扩容次数更少,内存复制也更少。权衡下来,整体性能反而更好。

6.3 压测 Redis 必须注意的三个细节

关于压测本身,我也积累了一些经验。第一是不要让 redis-benchmark 的默认随机 key 数量太少,否则热点集中在少数 key 上,结果虚高。第二是要控制好并发度和网络带宽的耦合,千兆内网下 client 端很容易先到瓶颈,必须观察两端 CPU 和网卡占用,确认瓶颈确实在 Redis 侧。第三是测试前务必开启足够大的slowlog-log-slower-than,通过 slowlog 去反查压测期间是否有异常命令,不能只看 QPS。

我在压测第一轮时,因为没注意 client 端单线程发包的瓶颈,一开始数据低得离谱,差点得出"raw 编码慢到无法使用"的错误结论。后来用多进程分片压测,才拿到真实数据。所以做性能对比时,务必确保你的工具不会变成短板。

写在最后

十二轮压测做完之后,我对 Redis String 编码的态度发生了根本变化。过去我总把 44 字节当成一道面试题,现在我把它当成一把尺子,用来量每个 String key 该不该拆、该不该换数据结构、该不该警惕 append 操作。

如果你看完这篇也想去自己的环境里跑一轮压测,我的建议是先花十分钟确认目标编码,再花半小时把基础读写测完,最后专门构造几个跨过 44 字节的应用场景仔细观察。遇到数值类的业务,优先依赖 int 编码;如果能控制在 44 字节以内,就享受 embstr 的低开销;一旦超过,坦然接受 raw 的代价,但要想办法减少无谓的编码切换和空间浪费。

数据不会撒谎,Redis 也不会。只要愿意动手测,你记住的就不只是 44 这个数字,而是它背后真实存在的性能边界。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询