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,它的三种取值要分开来看:
已在 Swap,务必避免
略大是运行本身的开销
可能频繁触发内存淘汰
要特别警惕 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(伙伴)算法 的基本思路。
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 的内存分配机制,这样做 不一定 在性能和安全上就更优——减少碎片,和优化整体性能,是两件要分开权衡的事。
五、参考资料
- Try Redis, redis.io
- How to Monitor Redis Performance Metrics, Datadog
- A Scalable Concurrent malloc(3) Implementation for FreeBSD, Jason Evans
- Memory optimization, redis.io