十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Redis String编码原理与44字节边界压测实战

Redis String编码原理与44字节边界压测实战 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 批量提交命令统计总耗时和 QPSimport redis import time from concurrent.futures import ThreadPoolExecutor r redis.Redis(host127.0.0.1, port6379, db0, socket_connect_timeout5) def batch(start, count): pipe r.pipeline(transactionFalse) for i in range(start, start count): pipe.set(fbench:key:{i}, value) pipe.execute() value a * 44 N 100000 threads 16 size N // threads start time.time() with ThreadPoolExecutor(max_workersthreads) 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(ftotal{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 bitencoding 占 4 bit两者合成 1 字节加上 lru 的 24 bit前面 4 个字节。refcount 是 int 类型占 4 字节ptr 指针在 64 位系统下占 8 字节。所以一个 redisObject 本身是 16 字节这个数字很关键后面推导 44 字节要用。字符串的实际内容存在 SDSSimple 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 用的是统一的 header64 位系统下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 执行 APPENDRedis 会把整个对象转成 raw。而如果只是 INCR、DECR那会继续走 int 优化效率极高。这一点在业务设计上很重要后面压测部分会看到具体的数据。3. 12 轮压测全过程与结果对比测试数据跑出来的结果可以说既在情理之中也有点意料之外。先说结论在纯网络和内存操作层面三种编码的读写 QPS 差距并没有想象中那么大真正拉开差距的是内存占用以及编码转换场景下的性能损耗。3.1 写入场景小 value 真的更快吗前三轮写入测试的数据对比如下轮次value 内容编码QPS11234567890int18.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 内容编码QPS6混合读取四种类型混合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.2MB45 字节那批约 9.0MB差出 1.8MB。10 万个 key 而已每 key 多 1 字节但整体内存涨了 25%。原因在开头已经推演过了。44 字节的 embstr 一次 malloc 64 字节已经完美贴合 jemalloc 的 size class45 字节的 raw 需要两次 mallocrobj 申请 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(host127.0.0.1, port6379, db0) count {int: 0, embstr: 0, raw: 0} for key in r.scan_iter(match*, count5000): 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 的字节数算给别人听。
返回列表