深度解析内存数据库五大数据结构的底层实现

作者:梅琳marlin2026.11.06 21:03浏览量:0

简介:本文系统梳理主流内存键值数据库五大数据结构的底层实现思路,重点拆解字符串对象 int、embstr、raw 三种内部编码的触发条件、内存布局与转换规则,并结合共享整数对象池、简单动态字符串等关键设计,帮助读者掌握内存效率优化的底层逻辑,为缓存选型、容量评估与性能调优提供扎实依据。

一、为什么要研究底层编码

内存数据库将全部数据驻留在内存之中,内存既是性能优势的来源,也是成本的主要构成。同样的业务数据,采用不同的内部表示,内存占用可能相差数倍,读写路径的缓存局部性也会有明显差异。

因此,主流内存键值数据库普遍在“对外统一的数据类型接口”与“对内各异的具体表示”之间建立了一层编码(encoding)机制:高层看是字符串、哈希、列表、集合、有序集合五大数据结构;底层则依据值的类型、长度与元素特征,自动挑选最节省内存、访问效率最高的编码。理解这层机制,是做容量评估、性能调优与内存异常排查的基本功——为什么一个看似很小的键可能占用远超直觉的内存?为什么两个内容相似的键编码却不同?答案都藏在编码规则之中。

二、“一套接口,多种编码”的总体设计

五大数据结构各自对应一组候选编码,典型的对应关系如下:

  • 字符串(string):int、embstr、raw 三种编码;
  • 哈希(hash):元素少且短时使用紧凑的压缩列表式布局,超过阈值后转为真正的哈希表;
  • 列表(list):采用“双向链表+紧凑节点”的混合结构,兼顾两端高频操作与内存密度;
  • 集合(set):元素全为整数且数量不多时使用整数集合编码,否则升级为哈希表;
  • 有序集合(zset):小规模时使用紧凑列表编码,规模增大后升级为“跳表+哈希表”组合,同时保障范围查询与点查效率。

这套设计的共同思想可以概括为一句话:小数据用紧凑、连续、低开销的表示;大数据用支持高效增删与查找的常规结构。阈值通常由“元素数量”与“单个元素长度”两个维度共同决定。下面以最基础、使用最广泛的字符串对象为例,完整拆解这一机制。

三、字符串对象的三种内部编码

3.1 int 编码:把指针字段当整数用

当字符串对象保存的值本身是一个整数,且该整数能被 64 位有符号长整型(long)表示时,对象采用 int 编码。此时系统不会为字符串内容分配任何额外空间,而是直接把整数值存入对象结构中的指针字段(把原本指向数据的指针转换为长整型数值),并将编码标记设置为 int。一个 long 占用 8 字节,可表示的数值上界约为 2^63−1。

这里有一个容易被忽略的约束:把这个值当作字符串看待时,其长度不能超过 20。原因并不神秘——long 的最大值是 9223372036854775807,共 19 位数字;考虑负号,字符串形式最长为 20 个字符。一旦超过这个长度,数值必然超出 long 的表示范围,自然无法走 int 编码。这个看似奇怪的校验,本质上是“能否安全转换”的精确边界。

int 编码的收益不止于省掉字符串数据的内存,还引出一项更激进的优化——共享整数对象池。系统默认会预先创建 10000 个整数值对象(对应 0 到 9999),所有满足条件的键直接引用这些共享对象。这意味着,如果有多个不同的键,其值落在这一区间内,它们事实上全部指向同一个对象,通过引用计数统一管理生命周期。对于计数器、状态位、等级、评分等大量使用小整数的业务场景,这一设计能把“每个键一份对象”的开销压缩为“全局一份、多处引用”,节省效果非常可观。

示意性的验证方式如下(不同实现的命令语法略有差异):

  1. # 写入一个整数值
  2. > SET number "1234"
  3. OK
  4. # 查看键 number 的内部编码
  5. > OBJECT ENCODING number
  6. "int"

由于 “1234” 是可用 long 表示的整数,编码结果为 int。

3.2 embstr 编码:一次分配的短字符串

当字符串长度不超过 44 字节(早期版本阈值为 39 字节)时,采用 embstr 编码。它的核心特征是:对象头(包含类型、编码、引用计数、数据指针等元信息)与字符串数据头被放进同一块连续内存,通过一次内存分配完成。

相比下面要讲的 raw,embstr 有三个明显优势:

  1. 分配次数减半:从两次内存分配降为一次,分配与释放的系统开销同步降低;
  2. 缓存友好:元数据与数据物理相邻,读取一次即可同时取得,减少缓存未命中;
  3. 内存紧凑:整块连续空间产生的碎片更少。

关于 44 这个阈值,业界一种被广泛接受的解释是内存分配器按固定档位供给内存:对象头占 16 字节,短字符串的数据头占 3 字节,结尾符占 1 字节,合计 20 字节,44 = 64 − 20,恰好凑满 64 字节的分配档位,一个字节都不浪费;早期版本的数据头为 8 字节,故阈值为 64 − 16 − 8 − 1 = 39。

还需注意 embstr 的一个重要特性:它是只读的。对 embstr 编码的字符串执行任何修改操作,都会使其整体转换为 raw 编码,而非原地修改。因为重新分配一整块连续内存并搬移数据的代价,通常高于直接切换编码。这也提示我们:会被反复修改的短字符串,首次修改后就固定为 raw,不必期待它再回到 embstr。

3.3 raw 编码:为长字符串而生

当字符串长度超过 44 字节时,采用 raw 编码:对象结构与字符串数据分别进行两次内存分配,二者在内存中并不保证相邻。

表面看 raw 更“浪费”,但它换来了灵活性:数据部分可以独立扩容、缩容,而不牵动对象头;配合下述动态字符串的预分配策略,长字符串的追加写入无需频繁整体搬移。存放长文本、序列化后的结构化数据、图片字节流等大对象时,raw 是必然且合理的选择。

3.4 编码之间的转换规则

  • int 编码的值一旦变为非数字内容,或超出 long 表示范围,将转换为 raw;
  • embstr 在任何修改操作后整体转为 raw,且不可逆;
  • 编码转换整体呈“单向升级”态势,一般不会自动回退到更紧凑的编码;若确有需要,通常要删除并重建键。

四、真正的载体:简单动态字符串

无论 embstr 还是 raw,字符串数据的实际载体都是“简单动态字符串”。相比 C 语言原生以 ‘\0’ 结尾的字符数组,它增加了一个记录已用长度、已分配长度与类型标记的头部,带来四项关键收益:

  1. O(1) 获取长度:直接读取头部字段,无需遍历整个字符串;
  2. 二进制安全:以记录的长度而非终止符判断内容边界,可存放图片、压缩数据、序列化对象等包含 ‘\0’ 的内容;
  3. 减少内存重分配:空间预分配——扩容时额外多预留一段空间;惰性释放——缩短时不立即归还内存,留待后续复用;
  4. 越界保护:任何修改前都会校验剩余空间是否充足,从机制上杜绝缓冲区溢出。

更进一步,头部结构本身也按字符串长度分档:极短的字符串使用 1 字节级别的长度字段,更长的才启用更宽的字段,在元数据开销上继续“斤斤计较”。

五、给工程实践的启示

  1. 键与值尽量短小:落在 44 字节以内的值可享受一次分配与缓存友好;键的命名在可读性允许的范围内也应紧凑;
  2. 用足小整数红利:计数器、限流阈值、状态码等场景天然命中共享整数对象池,内存开销趋近于零;
  3. 谨慎对待大值:超长字符串会放大分配开销并阻塞处理线程,必要时拆分为哈希的多个字段,或采用对象存储保存大对象、内存仅存索引的组合方案;
  4. 用编码观察做容量复核:上线前抽样检查键的内部编码,能提前发现“本可紧凑却很松散”的内存浪费,为容量规划提供第一手数据。

六、结语

从 int 的“指针复用”,到共享整数池的“以引用换空间”,再到 embstr 的“一次分配、连续布局”,五大数据结构的底层实现处处体现着内存数据库“每一字节都要精打细算”的工程哲学。无论是自建部署,还是依托百度智能云等云平台的计算与存储资源构建内存服务,掌握这些编码规则,都能帮助我们在选型、容量评估与性能调优中做出有依据的判断——这正是从“会用”走向“精通”的分水岭。