More  

小編的世界 優質文選 資料

MySQL 數據庫內存管理初探-愛可生


2020年10月15日 - 資料小編 愛可生雲數據庫 
   

作者:xuty

本文來源:原創投稿 *愛可生開源社區出品,原創內容未經授權不得隨意使用,轉載請聯系小編並注明來源。

一、背景

經常在項目上碰到在沒有大並發活躍 SQL
的情況下,MySQL 所占用的物理內存遠大於 InnoDB_Buffer_Pool 的配置大小。我起初是懷疑被 performance_schema 吃掉了
或是 MySQL 存在內存泄露
,而後發現並非如此。是自己對於 MySQL
Linux
的內存管理不了解所致,因此本篇就來深入討論下,有何不對或者不嚴謹的地方歡迎提出~

先簡單說下個人對於 MySQL 內存分配的基礎認識,可能會存在部分認知偏差:

MySQL 的內存占用主要由兩部分組成,global_buffers
all_thread_buffers
,其中 global_buffers
為全局共享緩存,all_thread_buffers
為所有線程獨立緩存,如下圖所示:

global_buffers
:Sharing + InnoDB_Buffer_Pool

all_thread_buffers
:max_threads(當前活躍連接數) * (Thread memory)

其中 InnoDB_Buffer_Pool 是 MySQL 中內存占用中最大的一塊,為常駐內存
,也就說是不會釋放,除非 MySQL 進程退出。

而另外一塊比較吃內存的就是線程緩存。例如常見的 join_buffer、sort_buffer、read_buffer 等,通常與連接數成正比。即連接數越高,並發越高,線程緩存占用總量就越高,但是這類緩存往往會隨著連接關閉而釋放
,並非常駐內存。

二、內存高水位現象

CentOS Linux release 7.3.1611 (Core)

Server version: 5.7.27-log MySQL Community Server (GPL)

我們先做個小測試來觀察下 MySQL 的內存占用變化,首先關閉 performance_schema
innodb_buffer_pool_load_at_startup
,防止造成緩存干擾。然後將 innodb_buffer_pool
設置 100M,理論上 innodb_buffer_pool
的最大僅會占用 100M,可以通過 show engine innodb status G
進行查看。

通過 sysbench 創建一張 100W 的測試表,重啟 MySQL,觀察目前 MySQL 總共占用了 55536KB 物理內存,其中 innodb_buffer_pool
中占用了 432*16K=6912KB 內存,那麼我就算 MySQL 默認啟動後會占用 50MB 物理內存。

UID PID minflt/s majflt/s VSZ RSS %MEM Command997 11980 0.00 0.00 1240908 55536 0.69 mysqld----------------------BUFFER POOL AND MEMORY----------------------Total large memory allocated 107380736Dictionary memory allocated 116177Buffer pool size 6400Free buffers 5960Database pages 432

然後我們開始通過 sysbench 進行 select 壓測,從 4 線程開始壓測,4-8-16-32-64 逐步加大線程數,每次壓測 2min,最後觀察 MySQL 總物理內存占用大小變化情況。

從上圖可以看到,4 線程剛開始壓測的時候,內存占用飆升。主要是由於 innodb_buffer_pool
中大量湧入數據頁造成。而後加大線程數時,由於 innodb_buffer_pool
已經飽和達到 100M 上限,所以起伏不是很高。這塊內存上升的原因主要是由於 all_thread_buffers 增大造成,最後 64 線程壓測完,MySQL 總物理內存占用穩定在 194MB 左右,並且一直維持著,並沒有釋放還給操作系統。

壓測結束後,再次查看 innodb_buffer_pool
,可以看到 Free buffers
為空,100M 已經完全占滿。

----------------------BUFFER POOL AND MEMORY----------------------Total large memory allocated 107380736Dictionary memory allocated 120760Buffer pool size 6400Free buffers 0Database pages 5897Old database pages 2156

減去 innodb_buffer_pool
的 100M,以及 MySQL 剛啟動占用的 50M,還有 40MB+ 的內存占用,主要為 all_thread_buffers

通過這個測試可以看到,之前所理解的線程緩存隨著連接關閉而釋放
其實不太對。MySQL 並不會把這部分緩存還給操作系統
,而只是在 MySQL 內部釋放,然後重複使用。

我把這個現象稱為內存高水位現象
,因為與 Oracle 中高水位線概念非常類似。同樣的,MySQL 中當 ibd 文件被後,即使 delete 全表,也不會主動去釋放磁盤空間返還給操作系統,而是重複使用已釋放的磁盤空間,現象也非常一致。

PS:這裏 sysbench 壓測是走主鍵索引的單表 where 查詢,並不會申請 sort_buffer,join_buffer 等。所以單個會話申請的線程緩存比較少。因此最後總的線程緩存占用不是非常高,如果是壓複雜 SQL,內存占用應該會比較高。

三、Linux 進程內存分配

為了搞清楚 MySQL 經常出現內存高水位現象
的原因,先去查閱學習了 Linux 下相關的內存調用原理,具體內容總結如下:

上圖是 32 位用戶虛擬空間內存
的結構簡圖,由上到下分別是:

1. 只讀段:包括代碼和常量等;

2. 數據段:包括全局變量等;

3. 堆:包括動態分配的內存,從低地址開始向上增長;

4. 文件映射段:包括動態庫、共享內存等,從高地址開始向下增長;

5. 棧:包括局部變量和函數調用的上下文等。

其中
文件映射段
是我們討論的重點,它們的內存都是動態分配的。比如說,使用 C 標准庫的 malloc()
或者 mmap()
,就可以分別在
文件映射段
動態分配內存。

那麼這兩者有什麼區別呢?

malloc()
是 C 標准庫提供的內存分配函數,對應到系統調用上,有兩種實現方式,即 brk()
mmap()

1. brk 方式

對於小塊內存(<128K),C 標准庫使用 brk()
來分配。也就是通過移動堆頂的位置來分配內存。這些內存釋放後並不會立刻歸還系統,而是被緩存起來,重複使用。

優缺點:brk() 方式可以減少缺頁異常的發生,提高內存訪問效率。不過,由於這些內存沒有歸還系統,所以在內存工作繁忙時,頻繁的內存分配和釋放會造成內存碎片。

2. mmap 匿名映射方式

對於大塊內存(>128K),C 標准庫使用 mmap()
來分配,也就是在文件映射段找一塊空閑內存分配出去。mmap()
方式分配的內存,會在釋放時直接歸還系統,所以每次 mmap 都會發生缺頁異常。

優缺點:mmap()
方式可以將內存及時返回給系統,避免 OOM。但是工作繁忙時,頻繁的內存分配會導致大量的缺頁異常,使內核的管理負擔增大。這也是 malloc 只對大塊內存使用 mmap 的原因。

所謂的缺頁異常
是指進程申請內存後,只分配了虛擬內存。這些所申請的虛擬內存,只有在首次訪問時才會分配真正的物理內存,也就是通過缺頁異常進入內核中,再由內核
來分配物理內存(本質就是建立虛擬內存與物理內存的地址映射)。

brk() 方式申請的堆內存
由於釋放內存後並不會歸還給系統,所以下次申請內存時,並不需要發生缺頁異常。

mmap() 方式申請的動態內存會在釋放內存後直接歸還系統,所以下次申請內存時,會發生缺頁異常(增加內核態 CPU 開銷)。

C 語言跟內存申請相關的函數主要有 calloc, malloc, realloc 等。

malloc:根據內存申請大小,選擇在堆或文件映射段中分配連續內存,但是不會初始化內存,一般會再通過 memset 函數來初始化這塊內存。

calloc:與 malloc 類似,只不過會自動初始化這塊內存空間,每個字節置為 0。

realloc:可以對已申請的內存進行大小調整,同 malloc 一樣新申請的內存也是未初始化的。

四、Linux 內存分配器

上述所說的是 Linux 進程通過 C 標准庫中的內存分配函數 malloc 向系統申請內存,但是到真正與內核交互之間,其實還隔了一層,即內存分配管理器(memory allocator)
。常見的內存分配器包括:ptmalloc(Glibc)、tcmalloc(Google)、jemalloc(FreeBSD)。MySQL 默認使用的是 glibc 的 ptmalloc 作為內存分配器。

內存分配器采用的是內存池
的管理方式,處在用戶程序層和內核層之間,它響應用戶的分配請求,向操作系統申請內存,然後將其返回給用戶程序。

為了保持高效的分配,分配器通常會預先向操作系統申請一塊內存,當用戶程序申請和釋放內存的時候,分配器會將這些內存管理起來,並通過一些算法策略來判斷是否將其返回給操作系統。這樣做的最大好處就是可以避免用戶程序頻繁的調用系統來進行內存分配,使用戶程序在內存使用上更加高效快捷。

關於 ptmalloc 的內存分配原理,個人也不是非常了解,這裏就不班門弄斧了,有興趣的同學可以去看下華庭的《glibc 內存管理 ptmalloc 源代碼分析》文末鏈接

關於如何選擇這三種內存分配器,網上資料大多都是推薦摒棄 glibc 原生的 ptmalloc,而改用 jemalloc 或者 tcmalloc 作為默認分配器。因為 ptmalloc 的主要問題其實是內存浪費、內存碎片、以及加鎖導致的性能問題,而 jemalloc 與 tcmalloc 對於內存碎片、多線程處理優化的更好。

目前 jemalloc 應用於 Firefox、FaceBook 等,並且是 MariaDB、Redis、Tengine 默認推薦的內存分配器,而 tcmalloc 則應用於 WebKit、Chrome 等。

總體來說,MySQL 下更推薦使用 jemalloc 作為內存分配器,可以有效解決內存碎片與提高整體性能,有興趣的同學可以進一步測試下,本篇就不深入探究了。

五、MySQL 內存管理

接著我們再來看下 MySQL 內部是管理內存的,查閱大量資料後,發現我原先的理解不是很正確,之前我習慣性的把 MySQL 的內存劃分為 Innodb_buffer_pool、Sharing 、Thread memory 等三大類,但實際應該以 MySQL 的架構來劃分內存管理比較合理。即 Server 層
InnoDB 層(Engine 層)
,而這兩塊內存是由不同的方式進行管理的。

其中 Server 層是由 mem_root 來進行內存管理,包括 Sharing 與 Thead memory;而 InnoDB 層則主要由 Free List、LRU List、FLU List 等多個鏈表來統一管理 Innodb_buffer_pool。

4.1. Innodb_buffer_pool

MySQL 5.7 開始支持 Innodb_buffer_pool 動態調整大小,每個 buffer_pool_instance 都同樣個數的 chunk 組成,每個 chunk 內存大小為 innodb_buffer_pool_chunk_size,所以 Innodb_buffer_pool 以 innodb_buffer_pool_chunk_size 為基本單位進行動態增大和縮小。

可以看到,Innodb_buffer_pool 內存初始化是通過 mmap()
方式直接向操作系統申請內存,每次申請的大小為 innodb_buffer_pool_chunk_size,最終會申請 Innodb_buffer_pool_size 大小的文件映射段動態內存。這部分內存空間初始化後僅僅是虛擬內存,等真正使用時,才會分配物理內存。

根據之前 Linux 下內存分配原理,mmap()
方式申請的內存會在文件映射段分配內存,而且在釋放時會直接歸還系統。

仔細想下,Innodb_buffer_pool 的內存分配使用確實如此,當 Innodb_buffer_pool 初始化後,會慢慢被數據頁及索引頁等填充滿,然後就一直保持 Innodb_buffer_pool_size 大小左右的物理內存占用。除非是在線減少 Innodb_buffer_pool 或是關閉 MySQL 才會通過 munmap()
方式釋放內存,這裏的內存釋放是直接返回給操作系統。

Innodb_buffer_pool 的內存主要是通過 Free List、LRU List、FLU List、Unzip LRU List 等 4 個鏈表來進行管理分配。

Free List:緩存空閑頁

LRU List:緩存數據頁

FLU List:緩存所有

Unzip LRU List:緩存所有解壓頁

PS:源碼全局遍曆下來,只有 innodb_buffer_pool 與 online ddl 的內存管理是采用 mmap()
方式直接向操作系統申請內存分配,而不需要經過內存分配器。

4.2. mem_root

MySQL Server 層中廣泛使用 mem_root
結構體來管理內存,避免頻繁調用內存操作,提升性能,統一的分配和管理內存也可以防止發生內存泄漏:

MySQL 首先通過 init_alloc_root
函數初始化一塊較大的內存空間,實際上最終是通過 malloc 函數向內存分配器申請內存空間
,然後每次再調用 alloc_root 函數在這塊內存空間中分配出內存進行使用,其目的就是將多次零散的 malloc 操作合並成一次大的 malloc 操作,以提升性能。

剛開始我以為 MySQL Server 層是完全由一個 mem_root 結構體來管理所有的 Server 層內存,就像 Innodb_buffer_pool 一樣。後來發現並不是,不同的線程會產生不同的mem_root來管理各自的內存,不同的 mem_root 之間互相沒有影響。

Server 層的內存管理相較於 InnoDB 層來說複雜的多,也更容易產生內存碎片,很多 MySQL 內存問題都出自於此。

六、總結

下面簡單用一張圖來總結下 MySQL 的內存管理:

最後再來捋一下最初的疑問,為啥經常出現 MySQL 實際占用物理內存比 InnoDB_Buffer_Pool 的配置高很多而且不釋放的現象?

其實多占用的內存大多都是被內存分配器吃掉了。為了更高效的內存管理,內存分配器通常都會占著很多內存不釋放;當然還有另一部分原因是內存碎片,會導致內存分配器無法重新利用之前所申請的內存。

不過內存分配器並非永遠不釋放內存,而是需要達到某個閾值,它才會釋放一部分內存給操作系統,個中原理則需要大家去源碼中找了~

此次內存原理探索,其實一開始只是想知道 MySQL 內存占用虛高的原因,沒想到一步一步,越挖越深,從 MySQL 內存管理到 Linux 進程內存管理,再到內存管理器,加深了個人對於內存的理解。

  大家在看