很多 Redis 入门材料把 String 解释成「一个能存字符串、数字、二进制的类型」,写到 SET 和 GET 就结束了。这个说法没错,但它把重点带偏了:String 在底层从来不是一种结构,而是三种编码共用的对外接口——同一个 SET 命令,存一个数字、一个短字符串、一个长字符串,落到内存里的形状完全不同。
理解这层「同命令不同编码」的设计,能解释线上几类常见的疑问:为什么 INCR 比 SET 快那么多?为什么一个看起来不大的 JSON 写进去,INFO memory 里 used_memory 涨得比预期多 40%?为什么 SETBIT foo 100000000 1 会瞬间吃掉十几兆内存?这些问题在 SET/GET 层面找不到答案,必须下沉到编码层。
String 不是字符串,是一种值类型接口
Redis 里所有值的外壳都是 redisObject。它的结构大致是 type、encoding、lru、refcount、ptr 五个字段。type 决定对外是 String、List 还是 Hash;encoding 决定底层用什么数据结构存。
String 这个 type 拥有的 encoding 有三种:
- int:值是 long 范围内的整数时,直接存在 redisObject 的 ptr 字段里(指针字段被复用为值容器),没有任何额外分配。
- embstr(embedded string):短字符串,redisObject 头和 SDS 头加字符数组被一次性分配在一块连续内存里,只调一次 malloc。
- raw:长字符串,或被任何写命令碰过的 embstr,redisObject 和 SDS 分两次 malloc,靠指针关联。
编码选择发生在 SET 命令构造值对象时,不是运行期动态决定的——这一点很多人会误解,以为 Redis 会根据访问模式自适应。
三种编码在内存里长什么样
把三种编码画在一起对比,差别立刻清晰:int 编码没有独立数据块,ptr 字段本身就是值;embstr 把头和数据挤在一块连续内存;raw 多一次 malloc,多一次指针跳转。
这个差别看起来细微,但当你的 key 数量到了千万级,单条多一次 malloc、多一个指针字段的代价,乘以基数就会被放大成几百 MB 的额外内存。
embstr 的 44 字节是怎么来的
embstr 阈值 OBJ_ENCODING_EMBSTR_SIZE_LIMIT 当前是 44 字节。这个数字不是拍脑袋定的,是 jemalloc size class 的副产物。
Redis 假设 embstr 这块连续内存里要装下:redisObject 头(约 16 字节)+ sdshdr8 头(3 字节)+ 字符内容 + 结束符。当字符内容不超过 44 字节时,总大小恰好不超过 64 字节,正好卡进 jemalloc 的 64 字节 size class,不会产生内部碎片。
阈值历史上从 32 调到过 44,调大的动机就是把 64 字节这个 size class 吃满。这种工程细节是 Redis 内存优化值得读的部分——不是某个算法多巧妙,而是它把通用内存分配器的特性算进了设计里。
但 embstr 有一个关键约束:它是只读的。任何修改命令——APPEND、INCR、SETRANGE、SETRANGE——碰到 embstr 都会先把它升级成 raw。原因是 embstr 那块连续内存如果原地扩容会破坏 size class,所以 Redis 选择直接转 raw 而不是 realloc。
计数器场景为什么要用 int 编码
线上经常能看到两类写法:
# 写法 A
SET counter:uid 1
INCR counter:uid
# 写法 B
SET counter:uid "1"
INCR counter:uid
两条命令看起来一样,但 A 走的是 int 编码,B 因为引号或客户端序列化方式,value 可能是字符串 "1",落到了 embstr。后续 INCR 时,embstr 被升级成 raw,多一次 malloc 和 free。
差距不只是这一次升级。int 编码的 INCR 是原地加法,连 SDS 都不碰;raw 编码每次 INCR 都要走 SDS 的修改路径,字符串长度可能变化,sdsresize 会触发。在 QPS 高的计数器场景,这个差距会以 CPU 和内存碎片的形式显现。
判断方法很简单,用 OBJECT ENCODING counter:uid 看一眼,是 int 就是健康的,是 embstr 或 raw 就说明初始化姿势有问题。
为什么存 JSON 会让内存比预期大 40%
String 是接口层最常用的存储类型,也是 Redis 内存超预期最常见的来源。一个典型场景:
- 业务用 String 存用户画像 JSON,单条约 200 字节。
- 假设一千万用户,理论占用 ≈ 2 GB。
- 实际 used_memory 是 2.8 GB 左右。
差出来的 800 MB 不是碎片,是结构开销:
- 200 字节超过 embstr 阈值(44),所有 value 走 raw 编码。
- 每个 raw 对象有 redisObject 头(约 16 字节)+ SDS 头(3 字节起)+ 真实数据 + 两次 malloc 的对齐开销。
- jemalloc 256 字节 size class,单条 200 字节实际占 256 字节。
- 256 × 10^7 ≈ 2.56 GB,加上 key 本身的开销就到了 2.8 GB。
这层成本不是 Redis 的问题,是用法的问题。同样的画像数据如果拆成 Hash,让字段级更新走 listpack 或 hashtable,单条开销能下降 30% 以上。关键判断点是:你的 JSON 是「整存整取」还是「按字段更新」——前者用 String 没问题,后者就该换 Hash。
几个反直觉的边界情况
大 offset 的 SETBIT。 BitMap 在 Redis 里就是 String,SETBIT key offset value 在底层是扩容 SDS。如果 offset 跳到 1 亿,会瞬间申请约 12 MB 连续内存,并且这个内存还会被全零初始化。线上出过事故的命令之一。
APPEND 看起来无害。 APPEND 对长字符串做追加,看起来是 O(n),但每次都触发 sdsresize,背后是 realloc。频繁追加日志类数据用 String 是反模式,应该用 List。
GET 不修改编码,但很多命令会。 INCR、DECR、INCRBY、APPEND、SETRANGE、SETRANGE 都会触发 embstr → raw 的升级。SET 在覆盖时则会按新 value 重新选编码,这反而是干净的。
判断框架
把上面的内容收成几个可复用的判断:
- 计数器、原子累加:必须保证 value 是 int 编码,初始化姿势要审。
- 整存整取的小对象(< 44 字节):String + embstr 是最优选择。
- 整存整取的大对象(≥ 44 字节):String 仍然可用,但要算 jemalloc size class 的对齐开销。
- 按字段更新的对象:换成 Hash,让 listpack 或 hashtable 接管。
- BitMap:评估最大 offset 后再做决定,别让 SETBIT 推着你扩容。
- 任何线上 String 类型的内存异常,先用
OBJECT ENCODING和MEMORY USAGE抽样看编码,不要直接调 maxmemory-policy。
String 看起来最简单,但 Redis 的内存治理大部分时候就是从这一层开始的。
关于十三Tech
我是十三,All in AI Agent 方向的架构师,专注 AI 工程实践。
我相信 AI 是程序员的最佳搭档,也希望帮助每一位开发者更好地驾驭 AI。
如果你想继续跟完这套「图解 Redis」,欢迎关注公众号 「十三Tech」。后续会按数据结构、底层机制、持久化、高可用和实战排查这条线更新。

