存占用過(guò)高排查:性能模式與緩存配置優(yōu)化)
1. 問(wèn)題現(xiàn)場(chǎng)與三個(gè)容易誤判的方向先交代一下背景。當(dāng)時(shí)手上有一臺(tái)云服務(wù)器配置不算高4核8G的樣子上面跑著一個(gè)MySQL 8.0實(shí)例外加幾個(gè)內(nèi)部用的Java服務(wù)。某天下午監(jiān)控群突然開始報(bào)警說(shuō)內(nèi)存使用率連續(xù)五分鐘超過(guò)90%。我登錄上去第一眼看到free -h的結(jié)果時(shí)心里確實(shí)咯噔了一下total used free shared buff/cache available Mem: 7.6G 7.2G 108M 1.2G 328M 236M Swap: 2.0G 1.9G 112MMySQL 一個(gè)進(jìn)程就吃掉了將近 6.8G 的 RES這還沒(méi)算其他服務(wù)。再往下查top排序后的結(jié)果更直觀——mysqld 的 RSS 名列前茅而且 swap 也已經(jīng)被吃掉了 1.9G。這種情況如果不處理下一步就是 OOMmysqld 直接被內(nèi)核殺掉那損失就不是一頓排查能補(bǔ)回來(lái)的了。很多人包括當(dāng)時(shí)的我遇到 MySQL 內(nèi)存占用高第一反應(yīng)通常是三個(gè)方向一是懷疑連接數(shù)暴漲。是不是有業(yè)務(wù)方寫了慢查詢或者連接池泄漏幾百上千個(gè)連接掛在上面把內(nèi)存吃光了。二是懷疑緩沖池配置問(wèn)題。是不是innodb_buffer_pool_size被人調(diào)成了一個(gè)明顯不合理的值比如小內(nèi)存機(jī)器上直接給了 6G。三是懷疑系統(tǒng)層面內(nèi)存泄漏。比如 MySQL 版本有 bug、glibc 的內(nèi)存碎片問(wèn)題、或者某些 SQL 觸發(fā)了異常分配。這三個(gè)方向確實(shí)都合理我也都順著查了一遍。但實(shí)際情況是連接數(shù)并不高峰值也不過(guò) 80 來(lái)個(gè)innodb_buffer_pool_size用的是默認(rèn)的 128M根本沒(méi)動(dòng)過(guò)版本也不是什么冷門 bug 版本。也就是說(shuō)這臺(tái) MySQL 的壓力完全談不上大但內(nèi)存占用就是一路漲到了物理內(nèi)存快扛不住的地步。我后來(lái)復(fù)盤覺(jué)得這恰恰是很多內(nèi)存排查最容易栽跟頭的地方你盯著幾個(gè)常見嫌疑點(diǎn)查了一圈發(fā)現(xiàn)都沒(méi)問(wèn)題于是懷疑是“玄學(xué)”或“泄漏”但實(shí)際上問(wèn)題一直躺在某個(gè)你默認(rèn)忽略的模塊里。2. 拆內(nèi)存去向MySQL 進(jìn)程里的內(nèi)存到底被誰(shuí)吃了要排查 MySQL 內(nèi)存問(wèn)題先得知道 MySQL 的內(nèi)存都花在哪些地方。我整理過(guò)一份清單基本覆蓋了絕大部分內(nèi)存去向排查時(shí)對(duì)著這個(gè)表逐項(xiàng)對(duì)照比瞎猜有效率得多。內(nèi)存去向說(shuō)明默認(rèn)配置參考InnoDB Buffer Pool緩存數(shù)據(jù)頁(yè)和索引頁(yè)MySQL 內(nèi)存占用的大頭8.0 默認(rèn) 128M生產(chǎn)上常見 4G~128G各 Session 私有內(nèi)存排序緩沖、連接緩沖、臨時(shí)表內(nèi)存等按連接數(shù)翻倍sort_buffer_size 默認(rèn) 256Kjoin_buffer_size 默認(rèn) 256Kread_buffer_size 默認(rèn) 128Kread_rnd_buffer_size 默認(rèn) 256Kbulk_insert_buffer_size 默認(rèn) 8Mmax_connections 默認(rèn) 151InnoDB 日志與內(nèi)部結(jié)構(gòu)redo log buffer、adaptive hash index、數(shù)據(jù)字典、鎖信息等redo log buffer 默認(rèn) 16M細(xì)節(jié)較多難以一一列出臨時(shí)表內(nèi)存臨時(shí)表由 tmp_table_size 和 max_heap_table_size 控制取兩者較小值倆默認(rèn)都是 16MPerformance Schema / 監(jiān)控采集內(nèi)存保存性能監(jiān)控?cái)?shù)據(jù)的內(nèi)部?jī)?nèi)存5.7 以上默認(rèn)啟用屬于隱性大戶performance_schema 默認(rèn) ON占用可達(dá)幾百 M 到 1G 以上線程與連接管理內(nèi)存每建一個(gè)連接都要分配線程棧和連接緩沖線程棧默認(rèn) 1M 左右thread_cache_size 默認(rèn) 88.0每連接額外開銷百 K 到數(shù) M各類全局緩存key_buffer_sizeMyISAM、table_open_cache、table_definition_cache、query cache8.0 已移除key_buffer_size 默認(rèn) 8Mtable_open_cache 默認(rèn) 4000這張表本身并不復(fù)雜但有個(gè)常見誤區(qū)必須點(diǎn)破很多人以為 MySQL 內(nèi)存占用主要就是innodb_buffer_pool_size查了一下發(fā)現(xiàn) buffer pool 沒(méi)調(diào)大就放心了。這完全不對(duì)。Buffer pool 只決定“InnoDB 緩存數(shù)據(jù)”的那部分內(nèi)存但 MySQL 進(jìn)程的總內(nèi)存是上述所有項(xiàng)的總和有些模塊的用量平時(shí)根本不會(huì)出現(xiàn)在官方默認(rèn)配置里卻能在特定場(chǎng)景下把內(nèi)存頂爆。我這次碰到的場(chǎng)景就是一個(gè)典型在這臺(tái)服務(wù)器上MySQL 用了默認(rèn) 128M 的 buffer pool進(jìn)程 RSS 卻逼近 7G。這中間差的 6G 多全來(lái)自其他幾項(xiàng)。我當(dāng)時(shí)順著排查發(fā)現(xiàn)真正的大頭有兩個(gè)一個(gè)是performance_schema在 8.0 上默認(rèn)開啟后吃掉了接近 1.2G 的內(nèi)存另一個(gè)是熱更了一個(gè)大版本后MySQL 在運(yùn)行時(shí)把一批表的 table cache 和 metadata 鎖信息全壓進(jìn)了內(nèi)存加上連接私有緩沖在低配機(jī)器上的疊乘效應(yīng)最終把 RSS 推到了恐怖的高度。如果你不希望靠猜可以借助一條組合查詢直接從 information_schema 和 performance_schema 里把內(nèi)存占用按模塊聚合出來(lái)這個(gè)我在下一節(jié)詳細(xì)展開。3. 用兩套查詢直接捅進(jìn)內(nèi)存內(nèi)部定位大頭的過(guò)程排查內(nèi)存我的習(xí)慣是先看系統(tǒng)側(cè)再看 MySQL 側(cè)。系統(tǒng)側(cè)主要看free -h、top -o %MEM、pidstat -r -p PID 1這些命令能確認(rèn) mysqld 確實(shí)在持續(xù)漲內(nèi)存排除了其他進(jìn)程干擾。但真正定位“內(nèi)存被誰(shuí)吃了”的關(guān)鍵是 MySQL 提供的性能視圖。3.1 查 Current Allocationperformance_schema 內(nèi)存聚合MySQL 5.7 之后performance_schema提供了內(nèi)存事件統(tǒng)計(jì)能按賬號(hào)、線程、事件類型把內(nèi)存分配情況聚合出來(lái)。先確認(rèn)當(dāng)前是否開啟SHOW GLOBAL VARIABLES LIKE performance_schemaON;如果返回 ON直接跑下面這條查詢就能看到當(dāng)前實(shí)例里內(nèi)存分配排在前面的事件類型SELECT EVENT_NAME, COUNT_ALLOC / 1024 / 1024 AS alloc_mb, COUNT_FREE / 1024 / 1024 AS free_mb, CURRENT_NUMBER_OF_BYTES_USED / 1024 / 1024 AS current_mb FROM performance_schema.memory_summary_global_by_event_name ORDER BY CURRENT_NUMBER_OF_BYTES_USED DESC LIMIT 20;我在這臺(tái)機(jī)器上跑出來(lái)的結(jié)果非常有意思排名靠前的幾項(xiàng)是EVENT_NAMEcurrent_mbmemory/innodb/buf_buf_pool128.0memory/performance_schema/table_handles約 680Mmemory/performance_schema/events_statements_*約 320Mmemory/sql/user_early_init約 200Mmemory/sql/THD::main_mem_root約 180Mperformance_schema自己那一堆表加起來(lái)直接占據(jù)了 1G 多的內(nèi)存完全超出了我一開始的預(yù)期。這也是我在實(shí)戰(zhàn)中總結(jié)出的一個(gè)重點(diǎn)在 8.0 默認(rèn)配置里performance_schema 不是“一個(gè)開關(guān)”而是一整套內(nèi)存消耗體系尤其當(dāng) table_handles 和 events_statements_history 開得很大時(shí)它完全可能比 buffer pool 更占內(nèi)存。3.2 查連接數(shù)與連接私有內(nèi)存接著我用下面的查詢確認(rèn)當(dāng)前連接數(shù)和每個(gè)連接的內(nèi)存開銷SHOW STATUS LIKE Threads_connected; SHOW STATUS LIKE Max_used_connections;-------------------------- | Variable_name | Value | -------------------------- | Threads_connected | 76 | --------------------------結(jié)合前面看到的內(nèi)存表每個(gè)連接的 THD 占用按 2M~5M 估算80 個(gè)連接也不過(guò) 400M。這說(shuō)明連接數(shù)不是主要矛盾但也不能忽略——如果哪天連接數(shù)沖到 500這一項(xiàng)就會(huì)成為壓垮內(nèi)存的幫兇在低配服務(wù)器上尤其危險(xiǎn)。3.3 查 table cache 的隱性占用MySQL 對(duì)打開的表會(huì)做緩存緩存對(duì)象包括表結(jié)構(gòu)、表句柄、元數(shù)據(jù)鎖等。你可以用這兩條 SQL 看當(dāng)前 cache 的情況SHOW GLOBAL STATUS LIKE Open_tables; SHOW GLOBAL STATUS LIKE Table_open_cache_overflows;當(dāng)Open_tables長(zhǎng)時(shí)間貼近table_open_cache上限且Table_open_cache_overflows一直在增長(zhǎng)說(shuō)明 MySQL 的 table cache 處于高水位運(yùn)行表結(jié)構(gòu)元數(shù)據(jù)和句柄占用的內(nèi)存會(huì)在不知不覺(jué)中上漲。3.4 系統(tǒng)側(cè)觀測(cè)的少見但有效的角度除了 MySQL 自帶視圖我還會(huì)做一件事連續(xù)幾次采樣/proc/pid/status里的 VmRSS 和 VmSwap再順手看一眼 slab 緩存。命令不復(fù)雜grep -E VmRSS|VmSwap /proc/$(pgrep mysqld)/status在壓力不變的情況下如果 VmRSS 只增不減那基本是內(nèi)存增長(zhǎng)型問(wèn)題而不是瞬時(shí)流量打出來(lái)的峰值。這一步的意義在于別急著改參數(shù)先把“漲”和“高”區(qū)分開。瞬時(shí)高和持續(xù)漲的處理策略完全不同。4. 重啟大法為什么救不了這臺(tái) MySQL8.0 緩沖池預(yù)熱機(jī)制很多人的第一反應(yīng)是“重啟一下就好了”。說(shuō)實(shí)話在那個(gè)場(chǎng)景下重啟確實(shí)能把內(nèi)存瞬間降下來(lái)但這不是一個(gè)可復(fù)現(xiàn)的長(zhǎng)期解法。原因有兩個(gè)4.1 8.0 的緩沖池預(yù)熱是“按需恢復(fù)”而非“崩潰恢復(fù)”MySQL 8.0 里innodb_buffer_pool_dump_at_shutdown和innodb_buffer_pool_load_at_startup默認(rèn)都是 ON。這意味著正常關(guān)機(jī)時(shí)MySQL 會(huì)把 buffer pool 里的頁(yè)地址記錄到磁盤文件下次啟動(dòng)時(shí)后臺(tái)線程會(huì)按這些記錄逐步把數(shù)據(jù)頁(yè)加載回內(nèi)存而不是一次全部加載。重啟后的內(nèi)存下降更多是因?yàn)?buffer pool 從 0 開始慢慢升溫、performance_schema 相關(guān)的緩存也還沒(méi)攤開。但這只是“暫時(shí)的失憶”隨著業(yè)務(wù)流量重新加載內(nèi)存還是會(huì)回到原來(lái)的水位。如果根因沒(méi)找到重啟只是幫你爭(zhēng)取到一段排查窗口期三五個(gè)小時(shí)后問(wèn)題依舊。4.2 重啟還會(huì)引入一個(gè)新的下跌風(fēng)險(xiǎn)緩存命中率下降這也算我踩過(guò)的坑。某次線上我為了臨時(shí)釋放內(nèi)存選擇重啟 MySQL結(jié)果隨后一小時(shí)主庫(kù)的 IO 壓力翻了將近兩倍大量熱點(diǎn)查詢從走內(nèi)存變成走磁盤業(yè)務(wù)響應(yīng)時(shí)間直接上升。重啟之前一定要意識(shí)到你釋放掉的不只是“多余內(nèi)存”還有 InnoDB 辛苦緩存的熱數(shù)據(jù)頁(yè)。所以我的建議是除非 OOM 在即、MySQL 已經(jīng)處于被殺的邊緣否則不要輕易重啟。先把內(nèi)存去向摸清楚把可回收的模塊回收掉把不可回收的部分用參數(shù)限制住這才是成年人該干的事。如果已經(jīng)到了非重啟不可的地步也有一點(diǎn)小技巧先溫柔地把某些內(nèi)存大戶臨時(shí)降下去比如把performance_schema相關(guān)的參數(shù)往低調(diào)再重啟同時(shí)觀察重啟后內(nèi)存的爬升斜率對(duì)比復(fù)發(fā)速度這能幫你判斷是哪塊配置在持續(xù)吞噬內(nèi)存。5. 落地參數(shù)調(diào)整三類問(wèn)題三類改法定位到根因之后我開始動(dòng)手調(diào)參。如果只是簡(jiǎn)單地把參數(shù)降下去很容易引發(fā)副作用所以我把內(nèi)存問(wèn)題按“可動(dòng)態(tài)回收”“需重啟生效”“需要長(zhǎng)期控制”三類分開處理。5.1 先做臨時(shí)回收kill 多余連接 清理表緩存排查過(guò)程中發(fā)現(xiàn)的連接私有內(nèi)存和 table cache 都屬于可動(dòng)態(tài)回收類型。具體做法找到并 kill 掉長(zhǎng)時(shí)間空閑的長(zhǎng)連接??捎眠@條 SQL 找出空閑超過(guò) 600 秒的連接SELECT id, user, host, db, command, time, state FROM information_schema.processlist WHERE command Sleep AND time 600;逐個(gè)確認(rèn)后KILL掉能立刻釋放掉一批 THD 內(nèi)存。我當(dāng)時(shí)清理了十來(lái)個(gè)閑置連接RSS 降了幾百 M。刷新表緩存釋放 table cache 占用的舊句柄。執(zhí)行FLUSH TABLES;這條命令會(huì)關(guān)閉當(dāng)前所有打開的表。注意它不是FLUSH TABLES WITH READ LOCK不會(huì)鎖庫(kù)但在業(yè)務(wù)高峰執(zhí)行仍會(huì)有短暫影響建議低峰操作。執(zhí)行后Open_tables會(huì)大幅下降緩存相關(guān)內(nèi)存會(huì)回落。如果連接私有緩沖積壓嚴(yán)重可以等低峰時(shí)把sort_buffer_size、join_buffer_size這些“按連接分配”的參數(shù)調(diào)低后讓新連接按新值分配。命令如下SET GLOBAL sort_buffer_size 2 * 1024 * 1024; SET GLOBAL join_buffer_size 2 * 1024 * 1024; SET GLOBAL read_buffer_size 1 * 1024 * 1024;這些值在 MySQL 8.0 默認(rèn)基礎(chǔ)上翻倍放寬已經(jīng)足夠應(yīng)付大多數(shù)中小業(yè)務(wù)對(duì)于低配服務(wù)器是合理的平衡。注意這些參數(shù)是 per-session 分配不是全局池子所以每調(diào)大 1M就意味著每個(gè)連接額外多吃 1M不是總共多吃 1M。5.2 動(dòng)刀 performance_schema關(guān)掉不必要的信息采集performance_schema在 MySQL 8.0 里默認(rèn)開啟但它收集的很多事件你平時(shí)根本用不到。在這些資源有限的環(huán)境里我建議把內(nèi)存消耗最重的幾個(gè)采集項(xiàng)收窄而不是全關(guān)。修改my.cnf后重啟生效[mysqld] performance_schema ON performance_schema_max_table_handles 2000 performance_schema_max_cond_instances 2000 performance_schema_max_mutex_instances 4000 performance_schema_max_rwlock_instances 2000 performance_schema_events_statements_history_size 64 performance_schema_events_transactions_history_size 16重點(diǎn)說(shuō)下performance_schema_max_table_handles。這個(gè)參數(shù)直接影響table_handles的數(shù)量上限8.0 里默認(rèn)可能是按table_open_cache的倍數(shù)自動(dòng)計(jì)算的在我的場(chǎng)景下它被自動(dòng)擴(kuò)得很大結(jié)果單這一塊就占了幾百 M。手動(dòng)收緊到 2000 后配合最大的events_statements_history縮小整個(gè) performance_schema 內(nèi)存能從 1.2G 壓到 300M 左右效果非常明顯。要強(qiáng)調(diào)的是如果你線上在用 MySQL 慢查詢?nèi)罩?、sys schema 或者監(jiān)控系統(tǒng)依賴 performance_schema 的數(shù)據(jù)不要貿(mào)然全關(guān)。收窄到夠用比一刀切更穩(wěn)妥。5.3 給 buffer pool 和 table cache 設(shè)定明確預(yù)算另一個(gè)需要啟動(dòng)時(shí)生效的參數(shù)就是把關(guān)鍵緩存限制在大致可控的范圍內(nèi)[mysqld] innodb_buffer_pool_size 2G innodb_buffer_pool_instances 2 table_open_cache 1000 table_definition_cache 500這里2G是我根據(jù)這臺(tái) 8G 機(jī)器算出來(lái)的。把這臺(tái)機(jī)器的內(nèi)存預(yù)算大致拆一下系統(tǒng)本身留 1G其他 Java 服務(wù)留 3GMySQL 占 4G 上下再留 1G 余量給突發(fā)流量和文件緩存最終把 InnoDB buffer pool 落到 2G~2.5G 比較合適。千萬(wàn)別按“默認(rèn) 128M”繼續(xù)裸奔也別大手一揮給到 6G——前者浪費(fèi)內(nèi)存后者直接 OOM。有一說(shuō)一innodb_buffer_pool_instances設(shè)成 2 是因?yàn)?8.0 里一個(gè)實(shí)例建議至少 1G2G 的池子拆 2 個(gè)實(shí)例是合理組合既能緩解并發(fā)掃描時(shí)的內(nèi)部鎖競(jìng)爭(zhēng)也不會(huì)因?yàn)閷?shí)例過(guò)多帶來(lái)額外管理開銷。5.4 不要照搬參數(shù)先壓測(cè)或觀察一個(gè)周期所有參數(shù)改完后不要一把梭重啟直接上生產(chǎn)。我的習(xí)慣是先在測(cè)試環(huán)境按同一套參數(shù)跑一遍業(yè)務(wù)自測(cè)至少覆蓋高峰時(shí)段的查詢類型觀察 mysqld 的 RSS 在重啟后 15 分鐘、1 小時(shí)、24 小時(shí)的爬升曲線判斷內(nèi)存是否穩(wěn)定收斂還是繼續(xù)單向上漲確認(rèn)沒(méi)問(wèn)題再在低峰期對(duì)生產(chǎn)實(shí)例分批重啟。6. 把一次排查沉淀成長(zhǎng)期手段監(jiān)控、基線與復(fù)盤改完參數(shù)、內(nèi)存穩(wěn)住之后這件事并沒(méi)有結(jié)束。真正有價(jià)值的成果是把這次排查變成一套可重復(fù)執(zhí)行的流程。我后來(lái)做了三件事推薦你也照做。6.1 給內(nèi)存加監(jiān)控和告警在云監(jiān)控或自建監(jiān)控里除了看系統(tǒng)內(nèi)存使用率還要把 mysqld 進(jìn)程的 RSS、swap 用量單獨(dú)拉出來(lái)監(jiān)控。我加了兩條規(guī)則mysqld 的 RSS 超過(guò)物理內(nèi)存的 70% 時(shí)告警swap 使用率超過(guò) 50% 且持續(xù) 10 分鐘以上告警。這兩條能讓我在 OOM 之前預(yù)留出排查窗口。順帶一提很多云廠商的內(nèi)存告警基于整機(jī)使用率但 MySQL 作為單進(jìn)程大戶進(jìn)程級(jí)監(jiān)控比整機(jī)監(jiān)控更早反映問(wèn)題。6.2 建立參數(shù)基線把改完后的參數(shù)組合連同當(dāng)前業(yè)務(wù)量級(jí)、連接數(shù)基線、內(nèi)存水位一起固化下來(lái)寫成一篇內(nèi)部文檔。這能省掉很多重復(fù)排查時(shí)間——下次再遇到內(nèi)存問(wèn)題先對(duì)比“當(dāng)前參數(shù) vs 基線參數(shù)”而不是從零開始逐項(xiàng)翻系統(tǒng)變量。6.3 復(fù)盤時(shí)區(qū)分“占內(nèi)存”和“耗內(nèi)存”這是整個(gè)排查中我最看重的心得。一個(gè) MySQL 實(shí)例內(nèi)存占用高不等于它在持續(xù)消耗內(nèi)存。buffer pool 大、performance_schema 大、table cache 大這些是“占”著內(nèi)存是緩存、是池子只要沒(méi)有持續(xù)泄漏或無(wú)限增長(zhǎng)就未必是問(wèn)題“耗”則是內(nèi)存隨時(shí)間單向上升、無(wú)法收斂比如連接泄漏、binlog 緩存異常、存儲(chǔ)過(guò)程遞歸等。如果一開始就區(qū)分清楚你就能少走不少?gòu)澛?。我這臺(tái)機(jī)器本質(zhì)上是“占”得太不合理——performance_schema 默認(rèn)參數(shù)把內(nèi)存空間撐爆了而不是業(yè)務(wù)把內(nèi)存“耗”光了。需要做的只是把這些模塊的預(yù)算重新規(guī)劃把不必要吃的內(nèi)存吐出來(lái)。按這套思路操作完我這邊 MySQL 的 RSS 最終穩(wěn)定在 3.2G 左右系統(tǒng)可用內(nèi)存恢復(fù)到了 3.5G連接數(shù)和業(yè)務(wù)表現(xiàn)一切正常?;仡^看這次排查并沒(méi)有用到什么高深技巧無(wú)非是把 MySQL 內(nèi)存去向拆細(xì)、逐個(gè)量化、再做預(yù)算分配。但就是這么一套“笨功夫”比很多玄學(xué)調(diào)參有用得多。