Redis · 内存管理

什么是 Redis 内存碎片?
理解 Redis 的内存分配机制与 jemalloc

Xiaoqing 2023 年 12 月 24 日 约 10 分钟阅读

Redis(Remote Dictionary Server)是一种内存数据库,拥有卓越的性能。单机 Redis 的 TPS 可达数万,而 MySQL 这样的关系型数据库通常只有数百,相差数十倍。它高性能的底层原理,我总结为四个字——「榨优弃弱」。

榨优,是把 CPU 和内存用到极致;弃弱,是放弃外存这种低效率的 I/O。CPU 的利用靠 I/O 多路复用这样的高效模型来保证,对使用者基本透明;但内存不一样——操作命令会直接改变内存结构,使用不当就会引发数据交换(Swap)、命中率下降等问题,严重拖累性能。而 内存碎片,正是这类不可忽视的内存问题之一。

一、Redis 内存碎片的表现

内存碎片,详称「内存空间碎片化」。出现碎片时,内存被分散成大小不一的小块。把这些碎片当作逻辑上的整体,也许足以满足一次新的分配请求;但单独的某一块物理碎片,却没有足够的连续空间来满足需求——于是分配失败。

内存被切割成大小不一的碎片示意图
空闲内存被切割成互不相邻的碎片:逻辑上够用,物理上却装不下。

当出现碎片,实际可用的内存就会小于物理上的空闲空间。Redis 是内存密集型程序,内存不足时,要么和外存做数据交换(Swap),带来频繁磁盘 I/O、严重降低性能;要么按淘汰策略清理老数据,可能误伤有用数据,导致命中率下降。

为了及时发现内存问题,Redis 提供了便捷的监控命令:

> info memory

可能的输出(节选):

used_memory: 184791688(已使用逻辑内存,字节)
used_memory_rss: 197242880(已使用物理内存 RSS,字节)
mem_fragmentation_ratio: 1.07(碎片比率 = used_memory_rss / used_memory)
mem_allocator: jemalloc-5.2.1(内存分配器)

其中和碎片关系最大的是 mem_fragmentation_ratio,它的三种取值要分开来看:

<1
物理内存 < 逻辑内存
已在 Swap,务必避免
≈1.1
理想情况
略大是运行本身的开销
≈2+
碎片问题
可能频繁触发内存淘汰

要特别警惕 ratio < 1:它意味着物理内存已经不够,Redis 只能靠外存来兜底存储,通常是 maxmemory 未设置或设得过大所致,此时运行效率很低。

二、内存碎片的应对方法

内存是影响 Redis 性能的主要因素,它在实现上就考虑了如何高效管理:既要尽量低的分配成本,又要尽量高的利用率。为此,Redis 虽基于 C 语言开发,却没有用标准的 glibc malloc,而是选择了碎片率更低的 jemalloc

jemalloc(Jason Evans Malloc)是一种高效的用户态内存分配器,和操作系统的分配器互补——它管理的大块内存,首先要从操作系统申请而来。BSD、Firefox、Facebook、Redis 都用它来解决多线程环境下的内存分配性能与碎片问题。

有人会疑惑:程序申请多少就分配多少,一个接一个分下去,利用率不该是 100% 吗?

理想情况确实如此。但已分配的空间不会一直被占用,旧内存回收后,会在内存里留下「空洞」,形成碎片。尤其当小空洞越攒越多时,利用率可能不足 50%。这些游离在已分配内存之外的碎片,称为 外部内存碎片

已分配内存之间留下空洞,形成外部碎片
回收留下的「空洞」散落在已分配块之间——外部碎片。

外部碎片可以转化为 内部碎片。如果提前把整个空间划成固定大小、整整齐齐的几块,来了请求就发一块,那么已分配空间的外部就不再有碎片,浪费转移到了块的内部。利用率仍然不高,但多余的空间已经预留给了请求者,下次要用就能直接拿,省去了再次申请,反而提升了效率。

固定大小分块,浪费转移到块内部
把浪费「关」进固定块内部——内部碎片,换来的是可复用的确定性。

可碎片仍没根治:按固定大小切几块,往往匹配不上大多数请求,甚至远超请求大小,造成浪费。我们需要「个性化」,但又不能太随意——随意意味着更高的管理成本。于是要在可控成本下做适度的个性化。这可以参考服装行业:一个款式分 XS、S、M、L 多个尺码,我们挑能穿下的最小那个。这正是 Buddy(伙伴)算法 的基本思路。

像服装尺码一样把内存分成 XS/S/M/L 多个规格
像挑尺码一样,取「能装下的最小规格」。

Buddy 算法把空间分成多个档次,相邻档次大小相差两倍。申请时先找最匹配的规格;若没有剩余,就升级到更大一级,直到成功。升级拿到的空间往往超出需求数倍,为避免浪费要对它 切分,得到略大于需求的标准规格,切剩的部分回到对应规格的空闲列表。

Buddy 算法把大块逐级切分成小块
大块被逐级切分——但一味切分,大对象就会无块可用。

一味切分,大内存块会不断变小,怎么保证大对象还能分到内存?这就要靠 Buddy 算法的核心——合并。相邻且同规格的空闲块互为「伙伴」,当一块被释放,就检查它的伙伴是否也空闲;若是,两者合并升级为更大规格,还能继续向上合并,直到最大。分解与合并同为一体,二者组合,整个流程才能循环不息地运转下去。

Buddy 分解的最小粒度是 Page 内存页,一般 4KB。可小于 4KB 的对象需求不少,分配的却仍是整个 4KB Page,页内就会有空闲,形成内部碎片。Slab 算法 着重优化了小对象分配:它用 slab_partial 维护「未完全用满」的内存块,新的请求优先复用这些已分配空间,把能用上的价值尽量收集起来。

三、Redis 内置的应对方法:jemalloc

进化到 Slab,碎片问题已基本解决:按需分配、尽量复用。但随着处理器走向多核,又冒出新的性能问题——多个处理器同时申请内存时,需要用排他锁来保持有序竞争,锁争用 带来的额外开销,让分配性能下降。

新一代分配器应运而生,jemalloc 就是其一。多核锁争用的根源是锁的范围过大,jemalloc 于是缩小锁粒度:为每个线程引入私有的分配区域 Arena(分区)。当线程自有的 Arena 空间充足时,线程之间就无需交互、无需加锁。jemalloc 不仅针对多核做了优化,还综合了 Slab 等前辈在碎片治理上的经验——于是既有随核数增长的分配性能,又有优秀的碎片整合能力。

Redis 选择 jemalloc,意味着它天生就带有 高性能且低碎片 的内存管理能力。那当碎片真的出现、mem_fragmentation_ratio 远大于 1 时,对我们又有什么实际意义?

四、Redis 内存碎片,需要处理吗?

我们担心碎片,是怕被占用的物理内存无法服务新的分配请求,最终让 Redis 无法响应客户端。但 Redis 内置的 jemalloc 能把已占用的空闲内存做到极致复用。所以 Redis 官方也建议:可以采取「鸵鸟算法」,什么都不做。

只要有足够的可用物理内存,并且设置了合适的 maxmemory,即便 mem_fragmentation_ratio 偏大,后续的内存分配也是安全的。
# 设定内存上限与淘汰策略,是「鸵鸟算法」成立的前提
maxmemory 4gb
maxmemory-policy allkeys-lru

回顾外部碎片的产生,它来自频繁的分配与回收。也就是说,如果避免频繁变更 Redis 的键值对,就能在用户侧缩小碎片比例。但结合 Redis 的内存分配机制,这样做 不一定 在性能和安全上就更优——减少碎片,和优化整体性能,是两件要分开权衡的事。

五、参考资料

  1. Try Redis, redis.io
  2. How to Monitor Redis Performance Metrics, Datadog
  3. A Scalable Concurrent malloc(3) Implementation for FreeBSD, Jason Evans
  4. Memory optimization, redis.io
返回全部文章