作者: 張遠,騰訊CDB高級工程師; 餘成真,微盟DBA負責人

首發: 「老葉茶館」微信公衆號

  1. 背景

微盟是中小企業雲端商業及營銷解決方案提供商,客戶營銷活動天天有,7月17日是某客戶的超級營銷日,老闆要求全力護航,不允許任何故障。而營銷活動前幾天陸續收到幾條騰訊雲CDB實例OOM的短信報警。因爲用雲數據庫,處理這個報警對於DBA來說太easy,騰訊DBA團隊接管了。騰訊DBA大神們拿出各種經驗來揣測原因,但收效甚微,MySQL OOM還在繼續報警。爲了保證線上穩定性以及營銷活動的順利開展,騰訊 CDB 的兄弟們和我們深度合作去重現問題,一起開啓了問題排查之旅。

  1. 現象

在併發場景下跑一系列的SQL後,內存不斷上漲,超出實例內存15G多,最終會導致實例OOM。

  1. 問題排查過程

過程:

反饋VIP羣騰訊CDB技術團隊接手(懷疑SQL問題)----開啓數據庫審計----排除無“特殊”SQL----開啓performance_schema----源碼分析

3.1 server層內存佔用分析

騰訊雲CDB支持通過全局status變量total_server_memory_used來展示server層內存佔用情況。從server層看內存才使用4G,server層內存並沒有異常。

同時,騰訊雲CDB實例通過show full processlist可以查看各個線程在server層的內存佔用情況,也沒有發現異常。

3.2 線下SQL分析

通過sql審計等方式,以及業務開發同學提供的SQL語句。經過逐個分析,SQL沒有join都比較簡單,索引使用也都很合理。同時線下進行單個sql測試以及併發場景測試,也沒有發現內存異常的情況。

3.3 官方OOM相關bug分析

在SQL沒有發現明顯異常的情況下,CDB內核團隊試圖從官方bug着手,看能否發現蛛絲馬跡。我們使用的是CDB 5.7版本,CDB也會定期更新merge一些官方嚴重的bug。從官方release notes發現瞭如下可疑bug

  1. InnoDB:  A dangling pointer caused a memory leak. (Bug #28693568)

  2. A query employing a dynamic range and an index merge could use more memory than expected. (Bug #89953, Bug #27659490)

首先看看第一個bug Bug#28693568 , 這個是MySQL一個指針沒有及時釋放導致的內存泄漏問題,經過分析這個bug是MySQL官方中間版本引入的,CDB 5.7並不存在此問題。

commit 2a07e8d69f35a94fb0133011e3ea84ca6072171d

Author: Tor Didriksen <[email protected]>

Date:   Mon Sep 24 13:16:23 2018 +0200

Bug#28693568 MEMORY LEAK IN EMBEDDED SERVER

Backport patch from 8.0 to avoid dangling char* pointer for filepath.

Change-Id: I049dceaec9103b9ba93e58732b92c412459ead7a

再來看第二個bug Bug#27659490 ,這個bug主要是index-merge時導致使用了大量的MEM_ROOT內存。MEM_ROOT是每個連接thd上的內存,屬於server層的內存。我們通過以下兩個兩點,可以確定並不是這個bug導致我們的問題。

  1. 前面分析過的sql查詢計劃發現並沒有使用index-merge.

  2. 通過前面server層內存使用分析,內存問題並不在server層,而這個bug的內存問題在server層。

commit 78e067e69ab7afa9f02b03e1c3f398e4da4d7377

Author: Kailasnath Nagarkar <[email protected]>

Date:   Thu Aug 30 17:19:34 2018 +0530

Bug #27659490 : SELECT USING DYNAMIC RANGE AND INDEX

MERGE USE TOO MUCH MEMORY(OOM)

Issue:

While creating a handler object, index-merge access

creates it in statement MEM_ROOT.

However when this is used with "Dynamic range access method",

as range optimizer gets invoked multiple times, mysql ends up

consuming a lot of memory.

Solution:

Instead of using statement MEM_ROOT to allocate the handler

object, use the local MEM_ROOT of the range optimizer which

gets destroyed at the end of range optimizer's  usage.

注:在即將發佈的CDB新版本中會修復此問題 Bug#27659490

3.4 開啓 performance_schema

在前面排查無果的情況下,我們配合CDB內核同學開啓performance_schema來監控整體內存使用情況。注意開啓performance_schema會有3%左右的性能損耗,這個需要結合具體業務情況來決定是否可以開啓performance_schema。

開啓後發現除buffer_pool外,engine層內存佔用最多的是memory/innodb/row0sel和memory/innodb/mem0mem

3.5 深入源碼

接着從源碼角度分析內存分配來源

3.5.1 memory/innodb/row0sel

memory/innodb/row0sel主要有4個地方分配內存,前兩個都跟fetch cache有關。第四個是空間索引rtree,我們的場景沒有空間索引,這個可以忽略。至此,問題應該大概率和fetch_cache相關,那麼,什麼是 fetch_cache,爲什麼要引入 fetch_cache呢?

MySQL中 Server 層與 Engine 之間的是以 row 爲單位進行交互的,engine 將記錄返回給 server 層,server 層對 engine 的行數據進行相應的計算,然後緩存或發送至客戶端,爲了減少交互過程所需要的時間,MySQL 做了兩個優化:

  1. 如果同一個查詢語句連續取出了 MYSQL_FETCH_CACHE_THRESHOLD(4) 條記錄,則會調用函數 row_sel_enqueue_cache_row_for_mysql 將 MYSQL_FETCH_CACHE_SIZE(8) 記錄緩存至 prebuilt->fetch_cache 中,在隨後的 prebuilt->n_fetch_cached 次交互中,都會從prebuilt->fetch_cache 中直接取數據返回到 server 層,那麼問題來了,即使是用戶只需要 4 條數據,Engine 層也會將 MYSQL_FETCH_CACHE_SIZE 條數據放入 fetch_cache 中,造成了不必要的緩存使用。另外, 5.7 可以根據用戶的設置來調整緩存用戶記錄的條數;

  2. Engine 取出數據後,會將 cursor 的位置保存起來,當取下一條數據時,會嘗試恢復 cursor 的位置,成功則並繼續取下一條數據,否則會重新定位 cursor 的位置,從而通過保存 cursor 位置的方法可以減少 server 層 & engine 層交互的時間;

所以,fetch_cache在滿足一定的條件下用於緩存innodb層的記錄,這樣server層從engine取記錄時可以直接從fetch_cache取記錄,提升取數據的效率,每個打開的table在engine層都可以對應到一個fetch_cache.

  1. sel_col_prefetch_buf_alloc

column->prefetch_buf = static_cast<sel_buf_t*>(

ut_malloc_nokey(SEL_MAX_N_PREFETCH * sizeof(sel_buf_t)));

  1. row_sel_prefetch_cache_init

sz = UT_ARR_SIZE(prebuilt->fetch_cache) * (prebuilt->mysql_row_len + 8);

ptr = static_cast<byte*>(ut_malloc_nokey(sz));

  1. row_search_mvcc

if (end_range_cache == NULL) {

end_range_cache = static_cast<byte*>(

ut_malloc_nokey(prebuilt->mysql_row_len));

}

  1. row_count_rtree_recs

ulint bufsize = ut_max(UNIV_PAGE_SIZE, prebuilt->mysql_row_len);

buf = static_cast<byte*>(ut_malloc_nokey(bufsize));

3.5.2 memory/innodb/mem0mem

memory/innnodb/mem0mem主要兩個地方分配內存,但這兩塊是engine層內存分配的基礎函數,被調用的地方非常多。

1. mem_strdup/mem_strdupl

2. mem_heap_create_block_func

3.6 Index Condition pushdown & fetch_cache

經過抓取堆棧分析,發現分配fetch_cache的堆棧如下

row_sel_fetch_last_buf最後會調用row_sel_prefetch_cache_init分配fetch_cache.

在這裏我們看到了prebuild->inx_cond這個條件,這個是 https://dev.mysql.com/doc/refman/5.6/en/index-condition-pushdown-optimization.html ,簡稱ICP,這裏就不展開了。從代碼可以看到,我們的場景下當使用ICP時會去分配fetch_cache.

因此我們嘗試關閉ICP,修改optimizer_switch將index_condition_pushdown設置爲off。經過驗證關閉ICP後,內存使用下降了很多

同時從performance_schema中也觀察到memory/innodb/row0sel內存佔用比之前也明顯下降。                           

至此,經過測試關閉ICP後內存上漲不再那麼多,也不會導致OOM,同時業務上在關閉ICP後發現性能影響幾乎不受影響是可以接受的。但CDB內核團隊仍然堅持和研發一起測試,希望找根本原因。

3.7 根本原因

經過一系列的源碼調試和分析發現, fetch_cache等佔用的內存需要在關閉表(ha_innodb::close)的時候才釋放。對於業務中的某張表的其單行記錄大小(prebuilt->mysql_row_len)爲11458,其fetch_cache大小(prebuilt->mysql_row_len + 8)*8=91728。而另外一張表其單行記錄大小(prebuilt->mysql_row_len)更是達到了58565,其fetch_cache大小(prebuilt->mysql_row_len + 8)*8=548584。

如以下表結構中包含了較多的varchar超長字段。

而我們的業務也分庫分表了,這個表基本都是分表的形式存在,也就是說這個實例中絕大多數表都是這樣的結構。

我們再來看是什麼決定了fetch_cache的總個數。經過分析源碼,在engine層每個table對象會有一個row_prebuild_t對象,每個row_prebuild_t對象對應一個fetch_cache。我們得出fetch_cache總個數跟table_open_cache和table_open_cache_instances有關。

fetch_cache總個數=min(table_open_cache, table_open_cache_instances*總表數)

在本實例中總表數有兩千左右,本例子中 table_open_cache 相關的參數如下

show variables like '%table_open%';

+----------------------------+-------+

| Variable_name              | Value |

+----------------------------+-------+

| table_open_cache           | 102400 |

| table_open_cache_instances | 64    |

+----------------------------+-------+

我們以最大的fetch_cache大小548584爲例子來估算出fetch_cache最大總大小爲52G。

min(102400, 64*2000) * 548584 = 52G

另外在engine層每個table對象的row_prebuild_t也會從mem_heap中分配內存,大約7k左右。這個mem_heap就是前面performance_schema中看的memory/innodb/mem0mem中的部分。

這塊的內存總佔用爲600M左右,這個同performance_schema中看到memory/innodb/mem0mem部分量級相當。

min(102400, 64*2000) * 7k=680M

因此我們得出結論,線上建議將table_open_cache改爲10240同時將table_open_cache_instances改爲8或16將有效減少內存使用。

3.8 一些疑問

3.8.1 線下同樣sql測試爲什麼沒能重現問題?

線下測試僅僅是將用戶的一些特定sql進行測試,雖然也增加了併發的場景。但因爲是分庫分表,因此只取了個別分表進行測試,所以構造的例子中涉及的表數僅是個位數。

min(table_open_cache, table_open_cache_instances*總表數)

回到前面的公式,這個內存佔用是依賴於測試的總表數的,因此線下測試沒能復現內存異常的場景。

3.8.2 之前自建實例爲什麼不會OOM?

騰訊雲上對CDB實例的內存是有規格限制的,超出規格會OOM。而本例中隨着打開表數增長table_open_cache內存佔用會超實例規格,從而OOM。而自建實例對內存沒有限制,取決於主機內存大小,而table_open_cache的內存佔用並不是內存泄漏,在達到頂峯後會內存將不再增長,而這個頂峯並沒有達到主機內存上限,因此沒有OOM。

  1. 總結

對於CDB實例OOM的問題,原因多種多樣。有些是MySQL bug導致,例如表較多時查詢information_schema導致OOM。有些是用戶使用方法導致,例如用戶使用超多union的SQL導致SQL解析overflow而OOM。騰訊雲CDB提供了各種內存診斷方法,包括前面介紹的server層內存使用統計,Performance_schema內存使用統計等等。最後,騰訊CDB技術團隊是雲上最堅固的防線,爲雲上業務保駕護航。

騰訊數據庫技術團隊對內支持QQ空間、微信紅包、騰訊廣告、騰訊音樂、騰訊新聞等公司自研業務,對外在騰訊雲上依託於CBS+CFS的底座,支持TencentDB相關產品,如CynosDB、CDB、CTSDB、MongoDB、CES等 。騰訊數據庫技術團隊專注於持續優化數據庫內核和架構能力,提升數據庫性能和穩定性,爲騰訊自研業務和騰訊雲客戶提供“省心、放心”的數據庫服務。此公衆號旨在和廣大數據庫技術愛好者一起推廣和分享數據庫領域專業知識,希望對大家有所幫助。

相關文章