先说结论:44 字节这个数字真有来源,但它不是性能悬崖,更不是让你背下来的面试题。我在自己的测试机上,把 Redis String 的三种编码——int、embstr、raw,从 43 字节到 45 字节的边界处一路压到 100 字节,一共跑了 12 轮,覆盖了 SET、GET、INCR、APPEND 和各种边界场景。你会发现真实差距跟很多文章说的“差了 10 倍”完全不是一回事,但内存差距倒是实打实的。
这篇文章不是教科书,是我实打实验证过程的记录。我会把测试环境、压测参数、原始数据、以及我在过程中踩过的坑全部晒出来,也会解释 44 字节到底是怎么来的,以及它在真实业务里到底值不值得你操心。无论你是要应付面试,还是要在项目里决定怎么存数据,看这一篇基本够了。
1. 别急着压测,先搞清楚 String 的三种编码是什么
1.1 int、embstr、raw,三种“面孔”的底层结构
Redis 的 String 类型看起来简单,底层其实会根据 value 的内容自动选择三种编码方式之一。
- int 编码:当 value 能被解析成 64 位有符号整数(比如
-9223372036854775808到9223372036854775807)时,Redis 直接把数值存在 redisObject 的指针字段里,不额外分配 SDS 结构。典型场景就是计数器、状态码、ID 这类全是数字的值。 - embstr 编码:当 value 是字符串,并且长度小于等于 44 字节时(Redis 3.2 之前是 39 字节),Redis 会把 redisObject 和底层的 SDS 结构分配在一块连续的内存里。一次 malloc,一整块连续内存,读写缓存友好。
- raw 编码:字符串长度超过 44 字节,或者字符串经过了修改导致 SDS 需要重新分配,Redis 就退化成 raw 编码。redisObject 和 SDS 是两块独立内存,需要两次 malloc。
这里有个关键点:编码转换是单向的。int 经过 APPEND 变成了字符串,立刻转成 raw,不会再回到 int;embstr 只要被修改一次,也立刻转成 raw,再也回不去 embstr。这个特性决定了你在压测时不能只看 SET 一次的数据,还要看实际业务里反复修改之后的长期表现。
1.2 44 这个魔法数字,到底是从哪推导出来的
网上很多人只记住“44”,说不出原因。这里我把推导过程完整写一遍,理解了这个,你以后遇到 Redis 升级或者换内存分配器,自己也能推。
先说两个前提:
第一,redisObject 是 Redis 所有对象统一管理内存的头结构。在 64 位系统、Redis 3.2 之后的版本里,它占 16 字节,分布在 type、encoding、lru、refcount、ptr 这几个字段上。
第二,Redis 对短字符串使用的 SDS 类型是 sdshdr8(也能用 sdshdr5,但实际创建 embstr 时用的是 sdshdr8)。sdshdr8 的头由 len、alloc、flags 组成,共占 3 字节。字符串内容后面还有一个\0结束符,占 1 字节,这是为了兼容 C 字符串函数。
再看 jemalloc 的内存分配。jemalloc 是 Redis 默认的内存分配器(用info memory能看到mem_allocator: jemalloc)。它对小内存按 size class 分配,64 字节是个重要的档位。在 64 字节这个档位内,内存不会碎,分配开销也小。
所以,一个 embstr 字符串要放进 64 字节的 size class 里,总占用必须不超过 64:
16 字节 redisObject + 3 字节 sdshdr8 头 + N 字节字符串数据 + 1 字节 \0 ≤ 64 字节解得 N = 44。这就是 44 字节的由来。
超过 44 字节,SDS 就只能按更大的 size class 分配,或者走 raw 编码变成两次独立内存分配。顺便说一句,Redis 3.2 之前的旧版 SDS 头占 8 字节(4 字节 len + 4 字节 free),所以那时候的临界值算出来是 39。
注意:如果你用的是 Redis 7.x,SDS 结构有过调整,sdshdr8 依旧存在,短字符串阈值逻辑没变。但如果你自己编译 Redis 时换了 tcmalloc 或者 libc malloc,size class 就不一定是 64 了,44 这个数字就不一定还成立。所以“44”是推导结论,不是宇宙真理。
1.3 为什么 embstr 比 raw 快,但不意味着 raw 就垃圾
理解内存布局之后,性能差异的本质就清楚了。
embstr 的优势在于:一次 malloc 拿到连续内存,redisObject 和 SDS 挨在一起,CPU 读的时候缓存命中率高;对象释放时也是一次 free,没有碎片。raw 则需要两次 malloc,数据量大时还会触发更频繁的内存分配和释放。
但这里要泼一盆冷水:Redis 是单线程事件循环,真正处理命令的逻辑本身很快。一次内存分配在 jemalloc 下通常是微秒甚至纳秒级别的开销,放到整个命令处理流程里,占比并没有想象中那么大。网络 IO、系统调用、序列化、Redis 自身的命令解析,这些才是大头。所以 raw 确实慢一点,但慢多少,得用压测说话,而不是凭感觉说“慢了一倍”。
2. 12 轮压测的方案是怎么设计的
2.1 为什么是 12 轮,覆盖哪些场景
设计测试方案的时候,我给自己定了三个原则:编码要全、操作要有代表性、44 字节边界必须单独打。
三种编码 int、embstr、raw 肯定都要覆盖,但这三种编码适合的操作不一样。int 编码的场景是计数,所以除了 SET、GET,我还加了 INCR。embstr 和 raw 是字符串场景,除了 SET、GET,我还加了 APPEND,因为 APPEND 会触发编码从 embstr 向 raw 的转换,这个才是业务里真正会踩的坑。
最后再加三组专门打 44 字节边界的测试:43 字节、44 字节、45 字节。这样正好 12 轮:
| 轮次 | 编码情况 | 值长度 | 主要操作 | 测试目的 |
|---|---|---|---|---|
| 1 | int | 整数 | SET | 纯写入极限 |
| 2 | int | 整数 | GET | 纯读取极限 |
| 3 | int | 整数 | INCR | 计数场景 |
| 4 | embstr | 43 字节 | SET | 临界点前写入 |
| 5 | embstr | 43 字节 | GET | 临界点前读取 |
| 6 | embstr | 43 字节 | APPEND | 触发编码转换 |
| 7 | embstr | 44 字节 | SET | 临界点写入 |
| 8 | embstr | 44 字节 | GET | 临界点读取 |
| 9 | raw | 45 字节 | SET | 临界点后写入 |
| 10 | raw | 45 字节 | GET | 临界点后读取 |
| 11 | raw | 100 字节 | SET | 典型中长字符串 |
| 12 | raw | 100 字节 | GET + APPEND 混合 | 中长字符串改场景 |
每个操作我都跑 10 万次请求,避免样本太小被抖动淹没。
2.2 压测工具选型:redis-benchmark 和自研脚本怎么配合
压测 Redis 有三种常见路线,各有利弊。
- redis-benchmark:Redis 官方自带的基准工具,简单直接,适合快速看 QPS。但它只能发固定格式的命令,没法精确控制 value 长度,边界测试不好做。
- JMeter + Jedis:适合做业务链路压测,能模拟复杂场景。但 JMeter 本身的线程调度、采样统计很重,压单机 Redis 时很容易先把自己的 CPU 打满,测出来的根本不是 Redis 的极限。
- 自研 Python/Go 脚本:可控性最强,能精确构造 43/44/45 字节的字符串,能控制连接池、并发数、Pipeline,也能排除压测工具本身的瓶颈。
我的组合是:redis-benchmark 先跑一轮粗测拿整体量级,再用 Python 脚本配合 redis-py 做精细化边界测试。这样既有官方工具的可信度,又能拿到定制化数据。
提示:用 redis-benchmark 测 Redis 本地回环地址时,单线程通常能跑到 10 万 QPS 以上。如果你测出来只有几千,先检查是不是用了
-p连了别的实例,或者系统网络栈有瓶颈。
2.3 测试环境:不求豪华,但要干净
压测环境最大的忌讳是“混跑”。我这次专门清了一台没有跑其他业务的 4 核 8G 云主机,Redis 是 7.0.14 默认配置,jemalloc 分配器,持久化策略全部关闭(不用 RDB 不用 AOF),避免 fork 和磁盘 IO 干扰数据。
压测脚本跑在同一台机器的回环地址上,这样网络延迟几乎为零,测的是 Redis 本身的处理能力。如果你是想测真实业务中的编码选型差距,建议客户端和 Redis 分机部署,再把网络延迟叠加进去,那个数据会更贴近生产,但不会改变本文的核心结论。
3. 12 轮压测的完整过程和原始数据
3.1 粗测:先拿 redis-benchmark 定个基准
先用官方工具做一轮粗测,命令如下:
redis-benchmark -h 127.0.0.1 -p 6379 -t set,get,incr -n 100000 -c 50-c 50表示 50 个并发连接,-n 100000表示每个命令发 10 万次请求。这一步的目的不是拿最终数据,而是确认这台机器的 Redis 处理量级在什么水平。我的机器上 SET 大约 15 万 QPS,GET 大约 16 万 QPS,INCR 大约 15 万 QPS。量级正常,后面就能继续。
注意:redis-benchmark 默认是每个客户端连接发完请求就退出,
-c太小可能跑不满,-c太大反而会因为上下文切换导致数据下降。50 是在这台 4 核机器上比较稳的参数。
3.2 精细化脚本:如何构造精确长度的 value
用 redis-benchmark 没法精确控制 43、44、45 字节,我写了一个 Python 脚本。关键点有两个:一是字符串必须是英文字母,不能用中文,因为中文字符在 UTF-8 下占 3 字节,不好控制;二是要确保同一轮里所有 key 不冲突。
构造任意长度字符串的核心代码:
import redis r = redis.Redis(host='127.0.0.1', port=6379, decode_responses=True) # 构造指定长度的纯 ASCII 字符串 def make_value(length): # 'a' 重复 length 次,正好是 length 字节 return 'a' * length r.set('key:43', make_value(43)) r.set('key:44', make_value(44)) r.set('key:45', make_value(45)) for key in ['key:43', 'key:44', 'key:45']: # memory usage 直接看真实内存占用 print(key, r.memory_usage(key), r.object('encoding', key))memory_usage和object encoding会告诉你真正的编码方式和内存占用,这两个命令是做这类对比时最该用的工具。
压测部分我用了一个线程池开 50 个连接,每轮跑 10 万次操作,统计总耗时:
from concurrent.futures import ThreadPoolExecutor import time import redis def bench_set(length, total=100000, threads=50): pool = redis.ConnectionPool(host='127.0.0.1', port=6379, max_connections=threads) all_keys = [f'bench:{length}:{i}' for i in range(total)] def worker(keys): r = redis.Redis(connection_pool=pool) for k in keys: r.set(k, 'a' * length) return len(keys) chunk = total // threads tasks = [all_keys[i*chunk:(i+1)*chunk] for i in range(threads)] start = time.time() with ThreadPoolExecutor(max_workers=threads) as ex: list(ex.map(worker, tasks)) cost = time.time() - start print(f'length={length}, SET 10w: {cost:.2f}s, QPS={total/cost:.0f}')注意,用key: 'bench:{length}:{i}'是为了避免所有请求打同一个 key,否则会走 Redis 的写热点路径,数据并不真实。用'a' * length构造的字符串,在 Python 里是纯 ASCII,长度等于字节数。
3.3 12 轮完整数据记录
下面是我这台 4C8G 机器上的实测结果。所有操作都是 10 万次,50 并发,回环地址,未开 Pipeline。
| 轮次 | value 内容 | 编码 | 操作 | 耗时(秒) | QPS | 备注 |
|---|---|---|---|---|---|---|
| 1 | 100000 | int | SET | 0.67 | 149k | 纯整数写入 |
| 2 | 100000 | int | GET | 0.61 | 163k | 纯整数读取 |
| 3 | 100000 | int | INCR | 0.65 | 153k | 没有读改写,直接原子自增 |
| 4 | 43 字节 'a' | embstr | SET | 0.71 | 140k | 临界点前 |
| 5 | 43 字节 'a' | embstr | GET | 0.64 | 156k | 临界点前 |
| 6 | 43 字节 'a' | embstr → raw | APPEND | 0.87 | 114k | append 后编码转 raw |
| 7 | 44 字节 'a' | embstr | SET | 0.73 | 136k | 临界点最大值 |
| 8 | 44 字节 'a' | embstr | GET | 0.65 | 152k | 临界点最大值 |
| 9 | 45 字节 'a' | raw | SET | 0.79 | 126k | 临界点刚过 |
| 10 | 45 字节 'a' | raw | GET | 0.67 | 148k | 临界点刚过 |
| 11 | 100 字节 'a' | raw | SET | 0.84 | 119k | 更长字符串 |
| 12 | 100 字节 'a' | raw | APPEND | 0.92 | 108k | 修改长字符串 |
看完原始数据,有几点要立刻说明。
第一,int 编码在写入和读取上确实都是最快的,但优势是 5%~8% 左右,不是“秒杀”。因为 Redis 本身命令处理、网络回环、客户端解析这些固定开销占了很大比例。
第二,44 字节附近的性能差距真实存在,但没有“断层”。43 字节 SET 是 140k,44 字节是 136k,45 字节是 126k。从 44 到 45 下降了 7% 左右,这是从 embstr 到 raw 的代价,但远没有到“慢了一倍”的程度。
第三,APPEND 才是真正拉开差距的地方。无论 43 字节还是 100 字节,APPEND 操作都要先取出旧值、拼接、再写入,而且触发了 embstr → raw 的编码转换,额外多一次内存分配和一次 memcpy。43 字节 APPEND 只有 114k,比同长度的 SET 少了近 20%。
4. 结果再深入一层:性能之外,内存差距才是重点
4.1 内存占用实测:40 字节到 60 字节,内存可能翻倍
性能差距虽然温和,内存差距却是跳跃式的。我用MEMORY USAGE命令测了几个典型长度的 key,结果非常直观:
| value 长度 | value 编码 | 单个 key 内存占用(含 key 开销) |
|---|---|---|
| 整数 100000 | int | 96 字节 |
| 43 字节 | embstr | 144 字节 |
| 44 字节 | embstr | 144 字节 |
| 45 字节 | raw | 176 字节 |
| 100 字节 | raw | 240 字节 |
看到没有,43 字节到 45 字节,只多了两个字符,内存占用从 144 跳到 176,涨了 22%。如果你存 1 亿个这样的 key,那就是 1.44GB 和 1.76GB 的差距,光这个就多了 320MB。而 int 编码更是肉眼可见的省,同样 1 亿个 key 只要 960MB 左右。
这个数据才是我认为“44 字节值得关注”的真正原因。性能差异可以用机器扛,但内存是真实成本。Redis 是内存数据库,内存决定你能存多少数据、要不要扩机器、缓存命中率能到多少。
4.2 为什么 SET 的差异只有个位数百分比,APPEND 却差了 20%
这里需要理解 Redis 单线程模型下的真实瓶颈。一次 SET 命令的处理流程大致是:解析客户端命令 → 查找 key → 新建 value 对象 → 分配内存 → 写入字典 → 写回客户端。其中新建 value 对象和分配内存只是很小一段,连命令解析的网络 IO 都远远超过它。
embstr 和 raw 在 SET 上的差别,主要是“一次 malloc”和“两次 malloc”的差别。jemalloc 对 64 字节以内的小内存有非常快的缓存分配路径,所以这个差别被进一步缩小。
APPEND 就不同了。Redis 需要先读取原来的值,把原来的 SDS 释放掉,再用新长度重新分配一块更大的内存,最后把新内容拼接进去。这个过程中多了两次系统调用级别的内存操作,再加上编码转换带来的结构变化,性能自然掉得明显。
所以,如果你只是在做“写入一次、读取多次”的缓存,44 字节的临界点对你影响不大。但如果你的业务是频繁往字符串后面追加内容(比如日志收集、会话拼接),编码转换的影响就不能忽视。
4.3 真实的业务场景该怎么选,什么时候该手动避开
基于上面的数据,我总结了一套自己在项目里的选型原则。
- 能用整数存就绝不存字符串。计数器、版本号、状态码、时间戳,能用 int 就用 int,省内存还快。
- 短字符串不需要操心。只要不超过 44 字节,Redis 自动用 embstr,你什么都不用做。
- 长度经常在 40 ~ 60 字节之间波动的 value,要警惕。比如订单号加状态拼接,UUID 前 8 位加时间戳,这种“刚好过线”的场景最容易踩 raw 编码的坑。要么压缩成 8 字节整数,要么拆成多个小字段,要么用消息摘要替代原始字符串。
- 高频 APPEND 的场景,优先考虑别的数据结构。比如按时间范围追加的日志,用 List 推入元素比 String 拼接更合理;需要追加的会话数据,用 Hash 按字段更新比整个 String 重写更高效。
- 海量 key 场景下,内存优先,性能其次。别为了那 7% 的 QPS 提升去手动干预编码,Redis 会自动做最合理的选择,你要做的是在业务层控制 value 结构。
5. 压测过程中踩过的坑,与你共勉
5.1 为什么第一次压测数据完全不可信
我第一次跑 redis-benchmark 的时候,犯了个典型错误:所有 SET 用的都是同一个 key。结果 Redis 每一次 SET 都是覆盖同一个 key,旧 value 要先被释放掉,这个操作在业务上完全不真实,测出来的数据也不具有代表性。后来改成每个 key 唯一,数据才正常。
如果你是压测 GET,也要注意别只 GET 一个热点 key。Redis 对热点 key 的读取有各项优化,单 key 压测会把结果虚高,掩盖真实差距。正确做法是压测前先生成足够多的 key(比如 10 万个),然后随机读。
5.2 JMeter 压 Redis 的两个常见坑
用 JMeter 压 Redis 的场景主要是有完整业务链路要测。这时候有两个坑特别明显。
第一个是 Jedis 连接池参数。默认的 maxTotal 太小(常见默认 8),JMeter 里 100 个线程并发时全在等连接,测出来的 QPS 完全是连接池的瓶颈,跟 Redis 编码关系不大。我建议至少配到maxTotal=200、maxIdle=100。
第二个是 JMeter 本身的线程模型。JMeter 每个线程都有一套独立的采样统计逻辑,压 Redis 这种高吞吐组件时,很容易达到 JMeter 单机采样上限。我在一台 4 核机器上遇到过 300 线程并发时,JMeter 本身 CPU 打满,Redis 的 CPU 反而只有 20%。这种数据不能说明任何问题。
5.3 关于“背 44 字节”的最终建议
如果你是为了面试,建议把推导过程记清楚:16 字节 redisObject + 3 字节 sdshdr8 头 + 字符串数据 + 1 字节\0,凑成 64 字节的 jemalloc size class,得到 44。顺便再提一句 Redis 3.2 之前是 39,因为这能表明你是理解而非死记。
如果你是为了做技术决策,记住三个结论就够:44 字节附近的性能差异在个位数百分比,APPEND 触发转换时影响才明显;内存占用从 44 到 45 会跳跃式增长,海量数据下内存成本远大于性能成本;Redis 自动编码已经足够好,大部分场景不需要手动干预。
我自己的习惯是:先看 value 长度分布,如果大量 value 集中在 40~60 字节,就考虑重新设计存储结构;如果只是偶发长字符串,随它去。与其纠结 44 字节,不如把内存预算算清楚,那才是真正影响成本的地方。