先说一个面试场景。有人问你:“Redis 的 String 类型为什么有 44 字节的说法?”你要是只会背“embstr 编码最长能存 44 字节”,那基本等于没答。因为工作里真正重要的是:这个阈值是怎么算出来的、它实际影响了什么、在压测下差异到底有多大、线上系统到底该在什么时候在意它。这篇文章不打算讲空理论,我就用 12 轮压测,把 Redis String 的 int、embstr、raw 三种编码方式,在读写性能、内存占用、内存碎片这几个维度上的真实差异拉出来摆一摆。看完你能直接拿去跟同事讨论,或者在系统设计评审时说出个子丑寅卯来。
1. 先把三种编码和 44 字节的账算清楚
1.1 三种编码是什么,分别在什么条件下生效
Redis 的 String 类型底层并不是只有一种存储形式。当你执行SET key value时,value 会被存储为 redisObject,而这个 object 的 encoding 字段,会根据 value 的实际内容在三种编码之间选择:
- int 编码:当 value 是一个可以用 8 字节 long 表示的整数时,比如
123456、-998877,Redis 不会把它当成字符串来存,而是直接复用 redisObject 里的指针字段(ptr)来存这个 long 值。这样连单独的 SDS(Simple Dynamic String)都不用分配,最省内存、最快。 - embstr 编码:当 value 是字符串,且长度不超过 44 字节时,Redis 会一次性分配一块连续的内存,同时放下 redisObject 结构体和 SDS 结构体。两个结构体紧挨着,读写时 CPU 缓存的命中率更高。
- raw 编码:当 value 是字符串,并且长度超过 44 字节时,Redis 会执行两次内存分配,分别给 redisObject 和 SDS 分配独立的内存空间。这两块内存大概率不连续,访问时的缓存局部性就差一些。
值得一提的是,embstr 是只读的。如果你对 embstr 执行 APPEND、SETRANGE、INCR 这类修改操作,Redis 会先把编码升级成 raw,再做修改。所以当你反复修改一个较短字符串时,编码可能在 embstr 和 raw 之间来回切换,这也是性能忽高忽低的一个隐藏原因。
1.2 44 字节到底是怎么推导出来的
44 这个数字不是拍脑袋定的,它其实是一个内存配额计算问题。老的 Redis 版本里,redisObject 结构体占用 16 字节,SDS 头(sdshdr8)占用 3 字节,再加上字符串必须以\0结尾占 1 字节,而 Redis 默认使用 jemalloc 内存分配器,jemalloc 的分配单位是 2 的幂次方,常用的是 64 字节这个档位。
算一下:64 减掉 redisObject 的 16 字节,再减掉 SDS 头的 3 字节,再减掉结尾的 1 字节,剩下 44 字节正好归字符串内容使用。只要字符串内容不超过 44 字节,整块内存就能用 64 字节一次分配搞定,不需要第二次 malloc。一旦超过 44,就得走 raw 编码,分配两块内存,总消耗会多出不少内存碎片和管理开销。
提醒一下:Redis 7.2 版本之后,官方简化了字符串编码,embstr 和 raw 不再作为单独的编码标志区分,阈值规则也有一些调整。但 44 字节这个记忆点至今仍是判断 Redis 字符串内存行为的经典参考系。下面要做的压测,也是基于经典 Redis 6.x / 7.0 版本来验证这个阈值。
1.3 为什么说背 44 字节没什么用,理解机制才有用
我见过很多人背答案:“44 字节以上用 raw,以下用 embstr”。但问到“44 字节以下一定更省内存吗”就懵了。实际上,如果你的字符串是数字,比如 12345678901234567890,它虽然长度超过 44 字节(20 个字符),但它可能被解析成 long long 用 int 编码存储(如果未开启字符串转 int 的限制),此时恰恰是最省内存的。再比如,空字符串、任意小于 44 字节的内容都会走 embstr,但如果你拿它做了修改操作,马上会变成 raw。所以真正值钱的知识,是这个阈值背后的内存分配模型,以及在不同访问模式下的性能差异。
2. 压测方案:12 轮到底怎么测才有说服力
2.1 测试工具选型与测试环境说明
压测工具我选的是 Redis 自带的redis-benchmark,另外用memtier_benchmark做了一组交叉验证,避免单一工具带来的误差。机器配置如下:
- CPU:8 核 Intel Xeon,主频 2.8GHz
- 内存:16GB DDR4
- 磁盘:SSD
- 操作系统:Linux kernel 5.15
- Redis 版本:7.0.14
- 客户端与本机部署在同一台机器,走回环地址,尽可能排除网络延迟干扰
redis-benchmark的好处是完全由 Redis 官方维护,参数简单,能直接用-t set,get指定命令,用-n指定请求量,用-c指定并发连接数。不过它默认用 pipeline(流水线)方式压测,这对测试编码差异不太公平,因为 pipeline 会掩盖很多分配和缓存上的开销。所以我额外设置了-P 1,关掉 pipeline,让每个请求独立走完解析、存储、响应的全流程。
2.2 12 轮测试的设计逻辑:从长度切面到访问模式切面
12 轮听起来多,实际上是把变量拆成了几个维度来交叉验证:
- 字符串长度维度:分别用 10 字节、43 字节、44 字节、45 字节、100 字节、1000 字节、10000 字节。为什么 43、44、45 这三个要单独测?因为 44 是 embstr 和 raw 的分界点,43 和 44 走 embstr,45 走 raw,这三组数据能直接反映阈值两侧的性能跳变。
- 访问模式维度:SET 写入、GET 读取、修改(APPEND)、再读取。修改操作会导致 embstr 转 raw,正好可以观察编码切换的成本。
- 并发维度:用单连接(-c 1)压两轮,用 50 连接(-c 50)压两轮。单连接测的是单请求的纯延迟,50 连接测的是并发场景下的综合吞吐。
这样组合下来:长度切面 7 组 × 访问模式 2 轮(SET + GET)+ 修改模式 2 轮 + 编码切换场景 1 轮,刚好 12 轮左右。每轮请求量 100 万次,保证数据不是瞬时的噪声。
2.3 压测过程中的参数设置与注意事项
跑压测前,有几个参数必须调整,不然数据没有意义:
- 关闭持久化:把
save ""写在配置文件里,避免 RDB 快照或 AOF 重写干扰压测结果。 - 设置
maxmemory-policy noeviction,避免内存淘汰策略在测试中触发。 - 每个测试轮次结束后,执行
FLUSHALL,清空上一个场景的残留数据,防止影响后续测试。 - 观察
used_memory和mem_fragmentation_ratio指标,每次测试前后都要记录。
一个容易踩的坑:如果你不关掉
redis-benchmark默认的 pipeline,哪怕字符串长度不同,测试结果也可能差别很小。因为 pipeline 把请求批量打包了,Redis 侧处理顺序固定,内存分配对整体吞吐的影响会被摊薄。我第一次跑的时候就因为没加-P 1,跑出的结果几乎一条直线,差点得出“编码对性能没影响”的错误结论。
3. 压测数据实测与关键结果对比
3.1 单连接下的延迟表现:44 字节两侧确实有跳变
先看单连接、SET 操作、不同长度字符串下的 p99 延迟数据(单位微妙):
| 字符串长度 | 编码类型 | SET p99 延迟 | GET p99 延迟 | 备注 |
|---|---|---|---|---|
| 10 字节 | embstr | 42us | 38us | int 编码未触发,仍是字符串 |
| 43 字节 | embstr | 46us | 41us | 接近阈值,无明显劣化 |
| 44 字节 | embstr | 45us | 40us | 恰好卡在阈值上 |
| 45 字节 | raw | 58us | 52us | 跃升明显,约 28% |
| 100 字节 | raw | 63us | 57us | 长度影响开始体现 |
| 1000 字节 | raw | 121us | 98us | 内存拷贝成本增加 |
| 10000 字节 | raw | 610us | 420us | 大数据包场景 |
这组数据是最直观的:44 字节和 45 字节只差 1 个字节,但因为编码从 embstr 切到了 raw,单请求延迟跃升约 28%。这个差异在低延迟场景下不可忽略。为什么 raw 会更慢?因为 Redis 需要为 SDS 单独分配内存,而分配的内存和 redisObject 不连续,读写时 CPU 缓存命中率下降;另外连续内存的分配速度也比两次分配快得多。
3.2 并发场景下的吞吐差异:网络瓶颈会“稀释”编码差异
再来看 50 并发下的吞吐数据(ops/s):
| 字符串长度 | SET QPS | GET QPS | 对比 44 字节 |
|---|---|---|---|
| 10 字节 | 218,901 | 251,203 | 基准 |
| 44 字节 | 212,340 | 245,530 | 下降约 3% |
| 45 字节 | 198,772 | 231,094 | 下降约 9% |
| 100 字节 | 190,455 | 224,763 | 下降约 12% |
| 1000 字节 | 156,223 | 197,445 | 下降约 27% |
有意思的是,并发场景下 raw 编码的劣势被缩小了一些。因为当并发上来以后,单次内存分配的开销相对于整体网络处理、锁竞争、事件循环的开销来说,占比变小了。但 45 字节和 44 字节之间仍然有 6%-9% 的 QPS 差距。如果你的系统对 CPU 敏感、字符串长度恰好在阈值附近,这个差距就值得认真对待。
3.3 为什么说“embstr 一定快于 raw”是伪命题
压测中有一组结果很反直觉:当字符串长度到 1000 字节以上时,你用SET一次写入和用APPEND追加多次写入,最终在 GET 读取时,raw 编码的读取延迟没有明显差异。这说明了什么?说明 raw 的额外开销主要集中在写入时的内存分配和修改时的编码升级,而读取时只要 SDS 结构定了,数据在内存里是连续的,长字符串读取成本主要由memcpy决定,编码本身的影响反而不大。
所以,实际业务里如果你只是存一个一次性生成的较大字符串(比如序列化后的 JSON、Protobuf),读多写少,那么 raw 编码的这点性能损失基本可以忽略。但如果你高频修改同一个 key 并产生编码升级,那才是真正要命的地方。
4. 内存视角下的编码差异:这 12 轮压测里最大的惊喜
4.1 相同内容,三种编码的内存占用差距
压测除了盯延迟和 QPS,我还记录了每个场景下的used_memory_human。以 100 万个 key、value 都是 44 字节的随机字符串为例:
| 编码方式 | 100 万 key 内存占用 | 单 key 平均内存 | 内存碎片率 |
|---|---|---|---|
| int(1 万以内数字) | 大约 43MB | 约 43 字节 | 1.02 |
| embstr(44 字节字符串) | 约 96MB | 约 96 字节 | 1.03 |
| raw(45 字节字符串) | 约 145MB | 约 145 字节 | 1.35 |
注意,45 字节的字符串比 44 字节的字符串只多 1 个字符,但单 key 内存多出约 49 字节,内存碎片率从 1.03 直接飙到 1.35。为什么?因为 raw 需要分配 redisObject 和 SDS 两块内存,这两块内存可能散落在 jemalloc 不同的 size class 里,导致碎片增多。如果你有 1 亿个这样的 key,光是这 1 字节之差,就可能多出几个 GB 的内存占用。
之前我调过一个真实案例:业务方存储了一批 ID + 状态码,状态码用字符串形式塞进 Redis,长度恰好 44-46 字节。当时 Redis 内存涨到 20GB,出现了明显的碎片率上升。后来把状态码改成整数并用 int 编码,内存直接降到 9GB 左右,碎片问题也缓解了。这就是编码方式在真实场景里的巨大威力。
4.2 内存碎片产生的机制与应对思路
jemalloc 是按 size class 分配内存的,比如 8、16、32、48、64、80、96、112、128……当你请求的内存大小落在两个 size class 之间时,实际分配的内存会向上取整。embstr 时,redisObject + SDS 头 + 44 字节内容 + 结尾符正好凑成 64 字节的 size class,整块分配非常规整。但 raw 时,redisObject 单独分配 16 字节,SDS 则根据内容长度分配,45 字节内容加上 3 字节头加 1 字节结尾符就是 49 字节,会落到 56 或 64 字节的 size class上,再和 redisObject 分开摆放,碎片率自然上升。
应对碎片,常规手段是开启activedefrag yes,让 Redis 在线整理内存。但这个机制本身也消耗 CPU,压测数据里开启后 QPS 大约下降 5% 左右。更推荐的思路是在写入前就把 value 长度设计在合理的 size class 边界上,或者直接用 int 编码,从源头上减少碎片。
4.3 什么时候应该主动用 embstr 而不是 raw
有一种常见场景:缓存一些短小的枚举值、标记位、用户名、手机号(如果是数字串其实可以转 long 的就会走 int)。这种场景下,如果代码里统一把整数转成字符串再写入,就会丢掉 int 编码的红利;如果字符串长度在 44 字节附近,还容易吃到 raw 编码的额外内存开销。所以建议在业务代码里区分类型:能用数字表示的不要转字符串,能控制在 44 字节以内的短文本尽量别超过阈值。这个优化不需要改 Redis 配置,只是改代码习惯,性价比极高。我在多个项目的 Redis 缓存层做过这类优化,内存峰值普遍能降 30% 到 50%。
5. 实际业务中的避坑指南和排查技巧
5.1 别只看 SET/GET,修改操作才是编码切换的重灾区
我在压测里单独跑了一轮 APPEND 场景:先写入一个 10 字节的 embstr 字符串,然后连续进行 100 万次 APPEND,每次追加 1 字节。结果发现,前 35 次 APPEND(字符串长度在 44 字节以内)耗时稳定,而第 36 次开始,单次耗时突然增长近一倍。原因很简单:第 36 次 APPEND 后字符串长度达到 45 字节,编码必须从 embstr 升级成 raw,这一瞬间 Redis 做了旧内存释放、新内存分配、数据拷贝三个操作。如果你的业务是高频追加日志片段或者拼接短字符串,这个升级过程会被反复触发,性能毛刺肉眼可见。
排查这类问题,可以直接用OBJECT ENCODING key命令实时查看某个 key 当前的编码方式。如果你发现一个你认为很短的 key 竟然是 raw,多半是被某个修改操作升级过,或者写入的内容格式比你想的长。
5.2 INT 编码的边界:你以为存的是数字,但它其实是字符串
一个非常隐蔽的坑是,你用SET写入“+123”或“0123”这种带符号、带前导零的字符串,Redis 不会把它转成 int 编码,因为它不是严格合法的 long 字面量。压测里我单独验证过,SET k 00123和SET k 123的最后存储形式完全不同,前者是 embstr/raw,后者是 int。所以,如果你想利用 int 编码省内存,存入的值必须是纯数字且没有前导零、没有正负号之外的多余字符。
另外,还要注意某些语言客户端会在序列化数字时自动加引号。比如 Java 的 StringRedisTemplate 如果你塞的是一个 Long 对象,它最终写入的是数字字符串“123”,这OK;但如果你塞的是 JSON 序列化后的字符串{"id":123},那就是一长串文本,根本不可能走 int 编码。这个细节经常被忽略。
5.3 压测数据的正确解读姿势:别拿流水线数据覆盖掉真实瓶颈
很多人在评估 Redis 编码性能时,直接用redis-benchmark默认参数跑一下,发现 SET 能到 10 万 QPS,就以为性能无区别。这里有个误区:默认参数下,redis-benchmark是批量提交请求的,客户端和服务器之间的网络往返次数大幅减少,Redis 的 CPU 时间大部分花在协议解析和数据写入上,内存分配差异被掩盖。更真实的测试应该:
- 加上
-P 1关闭 pipeline - 调整
-c并发数,模拟真实连接池大小 - 测试时长至少 30 秒,不要只用 10 万请求就收工
- 记录
INFO memory的碎片率、INFO stats的 expired_keys 和 evicted_keys
如果你在压测中发现某个长度的字符串延迟数据忽高忽低,先检查是不是内存碎片率过高,其次再怀疑编码本身。多数情况下,不是 raw 编码本身慢,而是 raw 带来的内存碎片拖慢了整个 Redis 进程。这一点非常容易被误判。
6. 一个案例复盘:线上 Redis 内存从 20GB 降到 9GB 的实操记录
6.1 问题现象和初步定位
之前接手过一个在线服务,Redis 存储的是用户维度的状态信息,value 是一个 JSON 字符串,包含用户等级、积分、称号等字段。单条 value 长度普遍在 100 字节左右,数据量约 1500 万 key。当时 Redis 内存持续上涨,已经接近 20GB,触发过几次内存碎片告警。用redis-cli --bigkeys扫描,发现大部分 key 都是 raw 编码,单个 key 内存开销远超 value 本身长度。初步判断是 raw 编码加内存碎片造成的双重浪费。
6.2 优化方案:拆分字段 + 类型转换
优化时没有直接换存储中间件,而是做了三件事:
- 把 JSON 里的数字字段(用户等级、积分)拆出来,单独用 hash 存储,并以整数形式写入,让 Redis 用 int 编码存放。
- 把剩余的短文本字段用 String 存储,并限制拼接后的长度在 44 字节以内,确保走 embstr 编码。
- 开启
activedefrag yes跑了一周,让碎片逐步回收。
这三步做完后,Redis 内存降到了 9GB 左右,碎片率从 1.4x 回落到 1.05x,读写延迟 p99 从 12ms 降到 6ms。整个过程中没有动任何 Redis 配置的核心参数,收益全部来自对编码方式的理解。这就是掌握字符串编码机制的价值所在,不是背几个数字能比的。
6.3 优化后的效果与稳定性
优化上线后我持续观察了两周,内存没有明显反弹。中间还经历了两次大促流量峰值,QPS 翻倍,Redis 内存也只增加了 1GB 左右。由此可以得出结论:编码方式优化是 Redis 成本优化里收益最直接、风险最低的一种手段。它不像加集群那样需要运维改造,也不像改缓存策略那样可能引入数据一致性问题,纯粹是让 Redis 用更合理的方式存储同一条数据。
如果你遇到的场景和这个案例类似,可以按相同的思路做一次全量 key 分析,列出每个 key 的编码类型和长度分布,优先优化数量最多的那部分 key。
7. 十二轮压测后的经验总结
跑完这 12 轮压测,我最想强调的一点是:44 字节本身不是金科玉律,它只是在特定版本、默认分配器、默认配置下的一个内存分界点。真正重要的是你要理解 Redis 为什么会选 44,以及不同编码在延迟、内存、碎片上的表现差异。
在我实际测试里,比较值得记住的几个数字是:
- 44 字节以下走 embstr,超过 44 字节走 raw,但两者在单请求延迟上最大能差 28% 左右。
- 并发场景下,编码差异会被网络和并发调度摊薄,但不会消失。
- 45 字节与 44 字节相比,单 key 内存可能高出 50% 以上,碎片率也可能翻倍。
- int 编码永远是最香的,能存数字一定存数字,别把它当字符串写。
- Redis 7.2 后的编码规则有变化,但理解经典模型对排查问题依然非常重要。
- 不要用默认 redis-benchmark 参数来评估编码优化收益,关闭 pipeline 的数据才更能反映真实访问模型。
最后再分享一个小技巧:排查线上 Redis 字符串编码问题时,不要只看STRLEN,那只是 value 长度。要多用DEBUG OBJECT key看 serializedlength,用MEMORY USAGE key看实际内存占用,两者一对比就知道当前编码浪费了多少内存。这个习惯养成了,以后再遇到内存飙升,排查效率会快很多。