Redis String编码原理与44字节边界压测实战
2026/9/12 23:35:57 网站建设 项目流程

1. 测试环境与 12 轮压测方案设计

先交代一下这次实测的来龙去脉。我是在一次面试候选人时聊到 Redis String 编码,对方把“44 字节”背得很熟,但问到他“为什么是 44,不是 32,也不是 64”就答不上来了。这个情况其实很普遍,网上的八股文都在说阈值是 44 字节,可真正常去验证的人不多。所以我决定搭个环境,把三种编码的底层原理、边界值、读写性能和内存占用整个拉一遍,用数据说话。

1.1 机器环境与压测工具选择

测试机用的是一台 4 核 8GB 的云主机,CPU 主频 2.5GHz 左右,操作系统是 Ubuntu 22.04。Redis 用的是 7.0.15 单机实例,关闭了 RDB 和 AOF 持久化,避免磁盘操作干扰测试结果。分配器保持默认的 jemalloc,这个很关键,因为 44 字节这个数字和 jemalloc 的内存分配策略有直接关系。maxmemory 设了 4GB,防止极端情况下 OOM 影响系统。

压测工具没有只用 redis-benchmark,因为默认的 redis-benchmark 虽然方便,但没法精确控制 value 的内容。比如我想测“恰好 44 字节的字母字符串”和“恰好 45 字节的字母字符串”,用-d 44虽然能指定长度,但内部生成的是随机二进制数据,我想控制 value 的具体形态和编码类型就受限了。所以实际测试时用了 Python 脚本配合 redis-py,再用 redis-benchmark 做交叉验证。脚本核心逻辑很简单,就是用 pipeline 批量提交命令,统计总耗时和 QPS:

import redis import time from concurrent.futures import ThreadPoolExecutor r = redis.Redis(host="127.0.0.1", port=6379, db=0, socket_connect_timeout=5) def batch(start, count): pipe = r.pipeline(transaction=False) for i in range(start, start + count): pipe.set(f"bench:key:{i}", value) pipe.execute() value = "a" * 44 N = 100000 threads = 16 size = N // threads start = time.time() with ThreadPoolExecutor(max_workers=threads) as ex: futures = [ex.submit(batch, i * size, size) for i in range(threads)] for f in futures: f.result() cost = time.time() - start print(f"total={N}, cost={cost:.2f}s, QPS={N/cost:.0f}")

这个脚本一次跑 10 万条 SET,用 16 个线程并发提交,单个连接内部再用 pipeline 攒批,能比较真实地反应 Redis 在批量写入场景下的表现。GET、APPEND、INCR 的测试脚本逻辑相同,只是换命令和参数。

1.2 12 轮压测的场景划分

整个测试我设计了 12 轮,核心思路是围绕三个变量展开:value 大小、value 形态、操作类型。value 大小覆盖了 int 编码的数字、embstr 边界的 43/44 字节、raw 边界外的 45 字节、以及 100 字节和 1KB 的常规字符串。操作类型覆盖了 SET、GET、APPEND、INCR 四种,其中 APPEND 这轮专门用来观察编码转换带来的性能开销。最后一轮是内存对比,不测 QPS,直接统计 used_memory 的差值。

轮次操作value 内容预期编码数据量
1SET1234567890int10万
2SET44字节字符串embstr10万
3SET43字节字符串embstr10万
4SET45字节字符串raw10万
5SET100字节字符串raw10万
6GET混合读取上面四种混合20万
7GETint 类型 valueint10万
8GET45字节字符串raw10万
9GET1KB字符串raw10万
10APPEND先 SET 44字节,再 APPEND 1字节embstr→raw10万
11INCR从 1 开始递增int10万
12内存对比44字节 vs 45字节各10万embstr vs raw独立实例

这里有一个容易踩的坑:第 10 轮 APPEND 测试时,很多人会把 10 万次 APPEND 打在一个 key 上,导致 value 越来越大,后面操作的其实是几百 KB 的字符串,性能数据就失真了。正确做法是准备 10 万个 key,每个 key 先 SET 一个 44 字节的字符串,再对这个 key 执行一次 APPEND,这样每笔操作都触发一次完整的“embstr 转 raw + 内存重新分配”过程,测出来的才是编码转换的真实成本。

2. 三种编码的底层原理,以及 44 字节是怎么算出来的

在分析压测数据之前,必须先把原理讲透。Redis 的 String 类型底层有三种编码:int、embstr、raw。int 不用说,存整数;embstr 和 raw 都是存字符串的,区别在于内存分配方式。

2.1 redisObject 与 sdshdr8 的内存开销

Redis 里每个对象都是一个 redisObject 结构体,这个结构体无论什么数据类型都是固定存在的。看看它的定义:

typedef struct redisObject { unsigned type:4; unsigned encoding:4; unsigned lru:LRU_BITS; /* LRU time or LFU data */ int refcount; void *ptr; } robj;

type 占 4 bit,encoding 占 4 bit,两者合成 1 字节,加上 lru 的 24 bit,前面 4 个字节。refcount 是 int 类型占 4 字节,ptr 指针在 64 位系统下占 8 字节。所以一个 redisObject 本身是 16 字节,这个数字很关键,后面推导 44 字节要用。

字符串的实际内容存在 SDS(Simple Dynamic String)里。Redis 3.2 之后 SDS 按长度分成了 sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64 几种类型,短的字符串用小的 header,省的浪费内存。我们关心的 44 字节边界用的是 sdshdr8,它的结构是这样的:

struct __attribute__ ((__packed__)) sdshdr8 { uint8_t len; /* 已使用长度 */ uint8_t alloc; /* 已分配容量 */ unsigned char flags; /* 低 3 位存类型 */ char buf[]; };

len、alloc、flags 各占 1 字节,合计 3 字节。buf 末尾还有一个\0哨兵字符占 1 字节,这是 SDS 兼容 C 字符串的一部分,即使存二进制数据也会保留。所以 sdshdr8 的固定开销是 4 字节(3 字节 header + 1 字节结尾符)。

接下来是 jemalloc 的分配规则。jemalloc 不是要多少给多少,而是按 size class 向上取整。64 字节是一个重要的分界线,在 64 字节以内,jemalloc 有 8、16、32、48、64 等几个档位。如果你请求分配 50 字节,jemalloc 实际会给你 64 字节的 chunk;如果你请求 65 字节,就跳到下一个档位了。

2.2 44 字节的完整推导过程

现在可以把 44 这个数字拆开算了。Redis 源码里定义了这样一个限制:

#define OBJ_ENCODING_EMBSTR_SIZE_LIMIT 44

创建字符串对象时,根据长度选择编码类型:

robj *createStringObject(const char *ptr, size_t len) { if (len <= OBJ_ENCODING_EMBSTR_SIZE_LIMIT) return createEmbeddedStringObject(ptr,len); else return createRawStringObject(ptr,len); }

为什么临界值定在 44?因为 embstr 编码会把 redisObject 和 SDS 放在同一次内存分配里,它们必须能塞进 jemalloc 的同一个 64 字节 chunk。计算过程:

  • redisObject 占 16 字节
  • sdshdr8 的 header 占 3 字节
  • 末尾\0占 1 字节
  • 剩余可用空间 = 64 - 16 - 3 - 1 = 44 字节

这么一看就非常清晰了。44 字节的 value 加上 20 字节的固定开销,正好等于 jemalloc 64 字节档位的上限。一旦 value 变成 45 字节,总开销变成 65 字节,必须从 jemalloc 的更大 size class 分配,同时 SDS 也放不进 redisObject 旁边,只能退回 raw 编码,进行两次独立分配。

顺带说一句,如果你够老派,可能听过“39 字节”这个说法。那是 Redis 3.2 之前的事了。旧版 SDS 用的是统一的 header,64 位系统下int len + int free占 8 字节,加 1 字节哨兵,可用空间就是 64 - 16 - 8 - 1 = 39。所以有些老面试题还在考 39,现在已经过时了。包括我在内的很多人第一次接触这个知识点时也是背的 39,后来才发现版本不同答案会变。这恰恰说明背诵数字没有意义,理解推导过程才能应对各种版本变化。

2.3 int 编码:它不是“字符串”,是特例

int 编码有点特殊,它的触发条件是:value 能被解析成一个long类型的整数。Redis 在尝试对字符串做编码优化时,会执行一个转化判断,核心逻辑大概是:如果字符串长度不超过 20 字节,并且能被string2l成功解析,就把字符串转成 long 类型的整数。

int 编码最大的特点是省内存。long 在 64 位系统下占 8 字节,Redis 直接把数字存在 redisObject 的 ptr 指针字段里,根本不再分配 SDS。也就是说,一个 int 编码的 String 对象,value 部分只占 8 字节,连 sdshdr8 都不用。

还有一层共享机制。Redis 启动时会预创建 0 到 9999 的整数对象,这些对象全局共享。如果你 SET 一个范围内的数字,Redis 不会新建 robj,而是直接引用共享对象,把 refcount 加 1。这意味着对 0 到 9999 范围内的整数写入,value 部分的内存开销几乎可以忽略不计。这也是为什么很多高并发计数器场景里,用 String 存整数比存字符串省内存省得多。

int 编码也有一个容易被忽略的坑:它一旦被修改成非整数字符串,立即会退化。比如你对一个 int 编码的 key 执行 APPEND,Redis 会把整个对象转成 raw。而如果只是 INCR、DECR,那会继续走 int 优化,效率极高。这一点在业务设计上很重要,后面压测部分会看到具体的数据。

3. 12 轮压测全过程与结果对比

测试数据跑出来的结果,可以说既在情理之中,也有点意料之外。先说结论:在纯网络和内存操作层面,三种编码的读写 QPS 差距并没有想象中那么大,真正拉开差距的是内存占用,以及编码转换场景下的性能损耗。

3.1 写入场景:小 value 真的更快吗

前三轮写入测试的数据对比如下:

轮次value 内容编码QPS
11234567890int18.2万
244字节字符串embstr16.8万
343字节字符串embstr17.1万
445字节字符串raw14.5万
5100字节字符串raw13.9万

int 编码的 SET 性能最高,这符合预期。因为 Redis 不需要分配 SDS,不需要把字符串逐字节复制到内存里,只需要把一个 long 写进 robj 的 ptr 字段就行。从 18.2 万 QPS 也能看出,整数写入在批量场景下确实有优势。

但注意第 2 轮和第 4 轮:44 字节的 embstr 写入 QPS 是 16.8 万,45 字节的 raw 是 14.5 万,差距大约 14%。这个差距有一部分来自编码差异,但也不全是因为 embstr 本身比 raw 快。44 字节的 embstr 只需要一次 malloc,而 45 字节的 raw 需要两次 malloc(一次给 robj,一次给 SDS),多一次内存分配必然多一次锁开销和指针操作。再加上 raw 的 SDS 会走 jemalloc 的更大 size class,分配器内部的行为也更复杂。

比较意外的是 43 字节和 44 字节几乎没有差别,都在 17 万左右。这说明只要还处于 embstr 范围内,一两个字节的差异不会影响性能。所以如果你在纠结“我的 value 是 42 字节还是 44 字节”,完全没必要,真正要避开的是跨过 44 这条线。

3.2 读取场景:int 和 embstr 快,raw 也没慢太多

读取测试的数据更有意思:

轮次value 内容编码QPS
6混合读取四种类型混合18.6万
7int 类型 valueint20.8万
845字节字符串raw16.9万
91KB字符串raw13.6万

混合读取的 QPS 是 18.6 万,反而比纯 44 字节的写入还高,原因是读取没有写操作的内存分配开销,底层只需要拷贝返回数据。int 的 GET 冲到 20.8 万也是预期内的,它连字符串解析都不需要,直接把 long 转成字符串返回就行。

让我觉得值得关注的是 45 字节 raw 的读取还有 16.9 万,和写入的 14.5 万相比并没有断崖式下跌。这说明什么?说明在几十字节这个量级,raw 编码的性能损耗主要发生在写入阶段的内存分配,读取阶段拷贝同样长度数据的成本其实差不多。真正让 raw 读取变慢的是更大的 value——1KB 的字符串 GET 掉到 13.6 万,这个下降不是因为编码,而是因为拷贝的数据量上来了。

所以如果你在做缓存设计,value 普遍在 50 到 200 字节之间,raw 编码的读取性能基本不是瓶颈。瓶颈通常出现在内存碎片和分配器压力上,这一点在并发高的时候尤其明显。

3.3 内存占用对比:这才是 44 字节最有价值的提醒

第 12 轮是内存对比测试,我分别在两个干净的 Redis 实例里写入 10 万个 44 字节和 10 万个 45 字节的 key,然后用 INFO memory 对比 used_memory。

结果很有冲击力:44 字节那批实例的内存占用约 7.2MB,45 字节那批约 9.0MB,差出 1.8MB。10 万个 key 而已,每 key 多 1 字节,但整体内存涨了 25%。

原因在开头已经推演过了。44 字节的 embstr 一次 malloc 64 字节,已经完美贴合 jemalloc 的 size class;45 字节的 raw 需要两次 malloc,robj 申请 16 字节,SDS 申请 49 字节但 jemalloc 按 64 字节档位分配,两者合计 80 字节。每个 value 多了 16 字节的分配开销,10 万个就是 1.6MB,再加上分配器内部的对齐损耗,实测 1.8MB 完全说得通。

这个数据对生产环境的启示非常直接:如果业务里有大量几千万级别的 String 缓存,value 恰好卡在 44 到 60 字节之间,可以考虑补位到 60 字节以上,或者压缩到 44 字节以内。50 字节左右的 value 是最不划算的——它既不能享受 embstr 的一次分配省内存,也没有大 value 的收益,纯纯被 jemalloc 的取整规则多吃了内存。

3.4 编码转换场景:APPEND 和 SETRANGE 的隐藏成本

第 10 轮专门测了编码转换。先 SET 一个 44 字节的字符串(embstr),再对这个 key 执行 APPEND 加 1 个字符。这一步会强制把 embstr 转成 raw,同时 SDS 需要重新分配内存,因为原来的 64 字节 chunk 已经满了。

这轮 QPS 掉到了 8.2 万,几乎只有正常 SET 的一半。如果只是写入和读取,embstr 转 raw 的性能差距不到 20%,但一旦发生编码转换,几万 QPS 的性能损耗就出来了。原因很直接:转换要分配新的 robj、要分配更大的 SDS、要把旧数据完整拷贝过去、还要释放旧对象。在一笔命令里干了普通命令 3 到 4 倍的活。

实际业务中触发编码转换的常见命令主要是 APPEND、SETRANGE、GETRANGE 之后写回等操作。尤其要注意 SETRANGE,因为你可能只是改了字符串中间几个字节,Redis 却没办继续用 embstr——embstr 被设计成只读优化的编码,任何修改都必须先转换成 raw。所以如果你的业务逻辑是频繁修改同一个 String 的局部内容,直接用 raw 编码存储反而更稳定,至少不会每次修改都重新分配整个对象。

4. 压测中抓到的问题与排查方法

压测过程中踩了一些坑,也发现了一些平时不容易注意到的行为,整理成问题实录供大家参考。

4.1 用 OBJECT ENCODING 确认当前编码,别靠猜

第一个建议是:判断一个 key 当前是什么编码,直接用命令查,不要靠 length 猜测。因为 44 字节临界值受版本和分配器影响,不同环境可能表现不同。

127.0.0.1:6379> SET int_key 1234567890 OK 127.0.0.1:6379> OBJECT ENCODING int_key "int" 127.0.0.1:6379> SET embstr_key aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa OK 127.0.0.1:6379> STRLEN embstr_key (integer) 44 127.0.0.1:6379> OBJECT ENCODING embstr_key "embstr" 127.0.0.1:6379> APPEND embstr_key b (integer) 45 127.0.0.1:6379> OBJECT ENCODING embstr_key "raw"

注意我上面故意没有数a到底有几个,而是用 STRLEN 返回 44 确认长度,再配合 OBJECT ENCODING 确认它的编码是 embstr。这样比对边界值最可靠。

压测时我发现这个命令还有一个额外用途:验证 Redis 版本升级后编码行为有没有变化。比如 Redis 7.0 引入了新的 listpack 编码用于 hash、zset 等类型,但对 String 没有影响。只要在升级前后抽样一批 key,看 OBJECT ENCODING 的结果是否一致,基本就能确认兼容性。

4.2 为什么批量写入后内存比预期大 30%

压测第 5 轮写 100 字节字符串时,我记录的内存增长比理论值高了不少。当时预估每个 value 占用大约是 robj 16 字节加 SDS 104 字节加分配器对齐,应该一百二三十字节一个,但实测多出大约 30%。

排查之后发现原因有两个。第一是我漏算了 dictEntry 和 key 的内存,Redis 的哈希表结构里每个 key 都要占一个 dictEntry,这也要 24 字节左右,key 本身还会单独分配 SDS,这些开销在单 value 估算时容易被忽略。第二是 jemalloc 对 100 字节附近的请求会向下一个 size class 取整,具体取到 112 还是 128 要看实际内存布局,不同并发模式下碎片率也不一样。

这给了一个教训:算 Redis 内存别只盯着 value 本身,dictEntry、key、分配器碎片、主从复制缓冲都要算进去。一般粗略估法可以按“key 长度 + value 长度 + 60 字节”来算单 key 基准开销,再用 INFO memory 实测校准。

4.3 排查线上字符串内存的命令组合

生产环境排查 String 对象内存问题,我常用的三个命令是 OBJECT ENCODING、MEMORY USAGE 和 INFO memory。

OBJECT ENCODING 看编码类型,MEMORY USAGE 看单个 key 占多少内存,用法很简单:

127.0.0.1:6379> MEMORY USAGE embstr_key (integer) 80 127.0.0.1:6379> MEMORY USAGE int_key (integer) 72

注意 MEMORY USAGE 返回的是整个 key 的综合占用,包括 key 本身、dictEntry 和 value 占用的全部内存,所以你会看到 int_key 也报了 72 字节——这并不代表 int 编码省内存的结论错了,而是因为 key 字符串和哈希表槽位也占用了空间。要准确对比不同 value 编码的内存差异,最好在 key 名称长度一致的条件下用 MEMORY USAGE 做增量对比。

INFO memory 里的 used_memory 是全局视角,适合在写入大量 key 后看整体水位。这三个命令配合起来,基本能定位绝大多数 String 编码相关的内存问题。

5. 从压测结果反推出来的实践建议

5.1 设计 value 时不要死卡 44 字节边界

压测数据说明,44 字节是一个陡峭的台阶,卡在 43 和 44 没有区别,但一旦到 45,内存和写入性能都会下一个台阶。所以我建议设计 value 时要么明确控制在 40 字节以内,要么干脆让 value 明显超过 44 字节,不要停留在 45 到 60 字节这个“两头不靠”的区间。

40 字节以内的字符串用 embstr,内存效率高。如果 value 语义上就是要超过 44 字节,比如 60 甚至 100 字节,那也别想着“我压缩到 44 字节吧”——压缩算法和 CPU 开销可能比省下的内存更贵。倒不如直接接受 raw 编码,然后用合理的数据结构管理。

5.2 纯数字场景优先用 int 编码

如果你的 value 是数字,比如计数器、订单号、用户 ID,直接用 String 存数字,让 Redis 走 int 编码。这不仅是性能问题,更是内存效率问题。int 编码下 value 只占 8 字节,没有 SDS 头,没有二次分配,还能享受 0 到 9999 的共享整数对象。

我自己维护过一个活动库存系统,几百万个 SKU 每个都是一个计数器,全部用 INCRBY 操作。当时估算下来,如果 value 存字符串形态的数字,内存至少翻一倍,而且 QPS 会低 10% 左右。改成纯数字 int 编码后,数据量完全扛得住。

但要注意:int 编码的钱并不好拿,它要求整个生命周期里 value 一直是合法整数。一旦某个环节往里面塞了非数字字符,Redis 只好转 raw,之后的性能优势就全没了。所以代码里要对写入做严格校验,确保不会出现非数字字符串。

5.3 少用会触发编码转换的命令

APPEND、SETRANGE 这些命令会强制把 embstr 转成 raw,而且触发转换的那一次操作性能几乎减半。如果你的业务需要频繁修改字符串的局部内容,一开始就用比较长的 value 让它直接以 raw 编码存在,反而比反复在 embstr 和 raw 之间横跳更稳定。

还有一个容易忽略的点:不要对线上热 key 做频繁的 APPEND。哪怕每次只追加 1 个字节,SDS 扩容时都可能发生旧数据拷贝,然后释放旧对象,触发内存分配器的压力。这种操作在高 QPS 下会放大 Redis 的 CPU 占用和内存碎片率。

5.4 快速批量检查线上编码分布的小技巧

最后分享一个实用技巧。生产环境 Redis 实例上有几十万个 key,不可能一个个敲 OBJECT ENCODING,但你可以用 redis-cli 配合 scan 循环批量统计:

redis-cli --scan --pattern '*' | while read key; do echo "$key $(redis-cli object encoding "$key")"; done

这个命令会在数据量大的时候比较慢,因为每个 key 都要发起一次网络请求。更快的做法是写一个简单的 Python 脚本,用一个连接池并发处理:

import redis r = redis.Redis(host="127.0.0.1", port=6379, db=0) count = {"int": 0, "embstr": 0, "raw": 0} for key in r.scan_iter(match="*", count=5000): enc = r.object("encoding", key) count[enc] = count.get(enc, 0) + 1 print(count)

我当时跑了一个 500 万 key 的实例,用 16 个线程并发扫描,几分钟就出了全局编码分布。如果发现有大量 key 的 value 长度恰好卡在 45 到 60 字节之间,就意味着它们正在忍受 raw 编码的额外内存开销——这往往是最容易通过“压缩 value 到 44 字节以内”来优化的部分。

说到底,44 字节不只是一个面试考点,它背后牵涉的是内存分配器、对象结构、编码设计三者之间的平衡。背下数字很容易,但只有在压测和线上排障里真正理解它的来龙去脉,下次遇到“45 字节的 value 为什么比 44 字节多占这么多内存”这种问题时,你才能不慌不忙地把 jemalloc 的 size class 和 sdshdr8 的字节数算给别人听。

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

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

立即咨询