MySQL 使用 jemalloc 进行内存分配,报错的原因是 MySQL 进程的 VMA 数量大于操作系统上限

这里先介绍几个前序概念

虚拟内存区域 VMA

Linux进程通过vma进行管理,每个进程都有一个结构体中维护一个vma链表,其中每个vma节点对应一段连续的进程内存。这里的连续是指在进程空间中连续,物理空间中不一定连续。如果进程申请一段内存,则内核会给进程增加vma节点

/proc/pid/maps

/proc/pid/maps 记录了进程的虚拟内存使用情况

举个例子,进程b.out的maps如下,每一行代表一个VMA(删除了一部分重复的行

  • 第一列,如00400000-00401000
    • 虚拟空间的起始和终止地址
  • 第二列,如rw-p
    • VMA的权限,前三位rwx分别代表可读、可写、可执行,“-”代表没有该权限;第四位p/s代表私有/共享段
  • 第三列,如00021000
  • 第四列,如fd:01
    • 映射文件所属设备好,匿名映射为0
  • 第五列,如1049982
    • 映射文件所属节点号,匿名映射为0
  • 第六列,如/u01/b.out /usr/lib64/libstdc++.so.6.0.19 [stack]
    • 映射文件名,[heap]代表堆,[stack]代表栈

vm.max_map_count

max_map_count 是一个进程内存能拥有的VMA最大数量

当进程达到了VMA上限但又只能释放少量的内存给其他的内核进程使用时,操作系统会抛出内存不足的错误

Error in munmap(): Cannot allocate memory 就是触发了这个错误

操作系统 vm.max_map_count=65530

执行以下代码,可以复现munmap无法分配内存的错误

因为是连续分配的,操作系统会合并成一个VMA,下图可以看出,/proc/pid/maps 文件中多了一个VMA

另有两个已存在的VMA被修改

130566 + 500 + 4 = 131070 = 65536 * 2 正好是程序中申请的内存大小

下面再用munmap每隔一个VM_SIZE释放一个VM_SIZE,将原本连续的虚拟内存空间变得不连续,这样就会形成65536个VMA,再加上本来存在的若干个VMA,超过了操作系统设定的VMA上限 65530

实际执行时,VMA数量到达65530时,再执行munmap就会报错

MySQL 使用 jemalloc 分配内存,jemalloc 默认采用mmap()/munmap()分配和释放内存,已经验证 jemalloc 在 max_map_count较小时会触发无法分配内存的异常

使用同样场景验证 glibc malloc 是否存在同样问题,glibc malloc 分配 128k 以上内存是默认使用mmap,128k以下时默认使用sbrk,所以这里把VM_SIZE 改为 129k

image.png

上图可以看出,除了申请新的VMA,heap空间也增长了

重点在于申请了65531个VM_SIZE的[heap]空间,这部分空间由sbrk()申请

mmap() 和 sbrk()

malloc 申请小于 128k 内存时,使用 sbrk() 分配,大于 128k 默认使用 mmap()

同时,mmap() 分配内存最多65536次,超过后使用 sbrk() 分配

mmap()在虚拟地址空间中找一块空闲地址分配,分配的内存可以被随意释放

sbrk() 将指向数据段的最高地址的_edata指针上推,释放时将_edata下推;显然,sbrk()无法随意释放内存,释放一块内存时,必须把比它地址高的内存全部释放

如图,初始时 _edata 在 heap 最下方,分配A时 _edata 推到 A 下方,分配 B/ C时 _edata 推到 B/C 下方

释放 C 时,再将 _edata 上推到 B 下方,但是在释放 B 之前释放 A 只能讲 A 的内存块标记为未使用,供下次分配,不能移动 _edata

显然,释放sbrk()申请的内存时,不会增加VMA数量,所以 glibc malloc 不会使 VMA 数量超过 max_map_count,不会触发上述问题

问题的原因很明显,VMA 数量超过 max_map_count,调大 max_map_count 可以简单粗暴的解决这个问题

jemalloc 在大多数情况下比 glibc malloc 性能更好,但也不能适用全部场景。业务场景合适时适用 glibc malloc 也可以规避这个问题