分析 MySQL 中的內存使用情況

瞭解如何可視化 MySQL 連接的內存使用情況。

作者:Benjamin Dicken

本文和封面來源:https://planetscale.com/blog/,愛可生開源社區翻譯。

本文約 3000 字,預計閱讀需要 10 分鐘。

引言

在考慮任何軟件的性能時,時間和空間之間都存在一個典型的權衡。 在評估 MySQL 查詢性能的過程中,我們經常關注執行時間(或查詢延遲)並將其作爲查詢性能的主要指標。 這是一個很好使用的指標,因爲最終我們希望儘快獲得查詢結果。

我最近發佈了一篇關於《如何識別和分析有問題的 MySQL 查詢》 的博客文章,其中討論的重點是衡量執行時間和行讀取方面的不良性能。 然而,在這次討論中,內存消耗很大程度上被忽略了。

儘管可能並不經常需要,但 MySQL 還具有內置機制,可以深入瞭解查詢使用了多少內存以及該內存的用途。 讓我們深入研究一下這個功能,看看如何實時監控 MySQL 連接的內存使用情況。

內存統計

在 MySQL 中,系統中有許多組件可以單獨檢測。 該 performance_schema.setup_instruments 表列出了每個組件,數量相當多:

SELECT count(*) FROM performance_schema.setup_instruments;

+----------+
| count(*) |
+----------+
| 1255     |
+----------+

此表中包含許多可用於內存分析的工具。 要查看可用的內容,請嘗試從表中進行選擇並按 進行過濾 memory/

SELECT name, documentation
  FROM performance_schema.setup_instruments
  WHERE name LIKE 'memory/%';

您應該會看到數百個結果。 其中每一個都代表不同類別的內存,可以在 MySQL 中單獨檢測。 其中一些類別包含一小段 documentation 描述該內存類別代表或用途的內容。 如果您只想查看具有非空值的內存類型 documentation,您可以運行:

SELECT name, documentation
  FROM performance_schema.setup_instruments
  WHERE name LIKE 'memory/%'
  AND documentation IS NOT NULL;

這些內存類別中的每一個都可以以幾種不同的粒度進行採樣。 不同級別的粒度存儲在多個表中:

SELECT table_name
  FROM information_schema.tables
  WHERE table_name LIKE '%memory_summary%'
  AND table_schema = 'performance_schema';

+-----------------------------------------+
| TABLE_NAME                              |
+-----------------------------------------+
| memory_summary_by_account_by_event_name |
| memory_summary_by_host_by_event_name    |
| memory_summary_by_thread_by_event_name  |
| memory_summary_by_user_by_event_name    |
| memory_summary_global_by_event_name     |
+-----------------------------------------+
  • memory_summary_by_account_by_event_name:根據帳戶彙總內存事件(帳戶是用戶和主機的組合)
  • memory_summary_by_host_by_event_name:以主機粒度彙總內存事件
  • memory_summary_by_thread_by_event_name:以 MySQL 線程粒度彙總內存事件
  • memory_summary_by_user_by_event_name:以用戶粒度彙總內存事件
  • memory_summary_global_by_event_name:內存統計的全局彙總

請注意,沒有針對每個查詢級別的內存使用情況進行特定跟蹤。 但是,這並不意味着我們無法分析查詢的內存使用情況! 爲了實現這一點,我們可以監視正在執行感興趣的查詢的任何連接上的內存使用情況。 因此,我們將重點使用表 memory_summary_by_thread_by_event_name,因爲 MySQL 連接和線程之間有一個方便的映射。

查找連接的用途

此時,您應該在命令行上設置兩個與 MySQL 服務器的單獨連接。 第一個是執行您想要監視內存使用情況的查詢的查詢。 第二個將用於監控目的。

在第一個連接上,運行這些查詢以獲取連接 ID 和線程 ID。

SET @cid = (SELECT CONNECTION_ID());
SET @tid = (SELECT thread_id
    FROM performance_schema.threads
    WHERE PROCESSLIST_ID=@cid);

然後獲取這些值。 當然,您的看起來可能與您在這裏看到的不同。

SELECT @cid, @tid;


+------+------+
| @cid | @tid |
+------+------+
|   49 |   89 |
+------+------+

接下來,執行一些您想要分析內存使用情況的長時間運行的查詢。 對於此示例,我將從包含 1 億行的表中執行一個大型操作,這應該需要一段時間,因爲在 alias 列上 SELECT 沒有索引:

SELECT alias FROM chat.message ORDER BY alias DESC LIMIT 100000;

現在,在執行過程中,切換到另一個控制檯連接並運行以下命令,將線程 ID 替換爲您的連接中的線程 ID:

SELECT
    event_name,
    current_number_of_bytes_used
FROM performance_schema.memory_summary_by_thread_by_event_name
WHERE thread_id = YOUR_THREAD_ID
ORDER BY current_number_of_bytes_used DESC

您應該看到與此類似的結果,儘管詳細信息很大程度上取決於您的查詢和數據:

+---------------------------------------+------------------------------+
| event_name                            | current_number_of_bytes_used |
+---------------------------------------+------------------------------+
| memory/sql/Filesort_buffer::sort_keys | 203488                       |
| memory/innodb/memory                  | 169800                       |
| memory/sql/THD::main_mem_root         | 46176                        |
| memory/innodb/ha_innodb               | 35936                        |
...

這指示執行此查詢時每個類別正在使用的內存量。 如果在執行另一個 SELECT alias... 查詢時多次運行此查詢,您可能會看到結果有所不同,因爲查詢的內存使用量在其整個執行過程中不一定是恆定的。 該查詢的每次執行都代表某個時刻的一個樣本。 因此,如果我們想了解使用情況如何隨時間變化,我們需要採集許多樣本。

memory/sql/Filesort_buffer::sort_keys 表中的 documentation 缺少 performance_schema.setup_instruments

SELECT name, documentation
    FROM performance_schema.setup_instruments
    WHERE name LIKE 'memory%sort_keys';

+---------------------------------------+---------------+
| name                                  | documentation |
+---------------------------------------+---------------+
| memory/sql/Filesort_buffer::sort_keys | <null>        |
+---------------------------------------+---------------+

然而,該名稱表明它是用於對文件中的數據進行排序的內存。 這是有道理的,因爲此查詢的大部分費用將用於對數據進行排序,以便可以按降序顯示。

隨着時間的推移收集使用情況

下一步,我們需要能夠對一段時間內的內存使用情況進行採樣。 對於短查詢,這不會那麼有用,因爲我們只能執行此查詢一次,或者在執行分析查詢時執行少量次。 這對於運行時間較長的查詢(需要數秒或數分鐘的查詢)更有用。 無論如何,這些都是我們想要分析的查詢類型,因爲這些查詢可能會使用大部分內存。

這可以完全用 SQL 實現並通過存儲過程調用。 然而,在這種情況下,我們使用 Python 中的單獨腳本來提供監控。

#!/usr/bin/env python3

import time
import MySQLdb
import argparse

MEM_QUERY='''
SELECT event_name, current_number_of_bytes_used
  FROM performance_schema.memory_summary_by_thread_by_event_name
  WHERE thread_id = %s
  ORDER BY current_number_of_bytes_used DESC LIMIT 4
'''

parser = argparse.ArgumentParser()
parser.add_argument('--thread-id', type=int, required=True)
args = parser.parse_args()

dbc = MySQLdb.connect(host='127.0.0.1', user='root', password='password')
c = dbc.cursor()

ms = 0
while(True):
    c.execute(MEM_QUERY, (args.thread_id,))
    results = c.fetchall()
    print(f'\n## Memory usage at time {ms} ##')
    for r in results:
        print(f'{r[0][7:]} -> {round(r[1]/1024,2)}Kb')
    ms+=250
    time.sleep(0.25)

這是對此類監控腳本的簡單首次嘗試。 總之,此代碼執行以下操作:

  • 通過命令行獲取要監控的提供的線程 ID
  • 設置與 MySQL 數據庫的連接
  • 每 250 毫秒執行一次查詢以獲取使用最多的 4 個內存類別並打印讀數

這可以根據您的分析需求以多種方式進行調整。 例如,調整對服務器的 ping 頻率或更改每次迭代列出的內存類別數量。 在執行查詢時運行此命令會提供如下結果:

...
## Memory usage at time 4250 ##
innodb/row0sel -> 25.22Kb
sql/String::value -> 16.07Kb
sql/user_var_entry -> 0.41Kb
innodb/memory -> 0.23Kb


## Memory usage at time 4500 ##
innodb/row0sel -> 25.22Kb
sql/String::value -> 16.07Kb
sql/user_var_entry -> 0.41Kb
innodb/memory -> 0.23Kb


## Memory usage at time 4750 ##
innodb/row0sel -> 25.22Kb
sql/String::value -> 16.07Kb
sql/user_var_entry -> 0.41Kb
innodb/memory -> 0.23Kb


## Memory usage at time 5000 ##
innodb/row0sel -> 25.22Kb
sql/String::value -> 16.07Kb
sql/user_var_entry -> 0.41Kb
innodb/memory -> 0.23Kb
...

這很棒,但有一些弱點。 很高興看到超過前 4 個內存使用類別的內容,但增加該數字會增加這個已經很大的輸出轉儲的大小。 如果有一種更簡單的方法可以通過一些可視化來一目瞭然地瞭解內存使用情況,那就太好了。 這可以通過讓腳本將結果轉儲到 CSV 或 JSON,然後在可視化工具中加載它們來完成。 更好的是,當數據流入時,我們可以繪製實時結果。 這提供了更新的視圖,並允許我們實時觀察正在發生的內存使用情況,所有這些都在一個工具中完成。

繪製內存使用情況

爲了使該工具更加有用並提供可視化,將進行一些更改。

  • 用戶將在命令行上提供連接ID,腳本將負責查找底層線程。
  • 腳本請求內存數據的頻率也可以通過命令行進行配置。
  • matplotlib 庫將用於生成內存使用情況的可視化。 這將包含一個堆棧圖,其中帶有顯示最高內存使用類別的圖例,並將保留過去 50 個樣本。

這是相當多的代碼,但爲了完整起見將其包含在此處。

#!/usr/bin/env python3

import matplotlib.pyplot as plt
import numpy as np
import MySQLdb
import argparse

MEM_QUERY='''
SELECT event_name, current_number_of_bytes_used
  FROM performance_schema.memory_summary_by_thread_by_event_name
  WHERE thread_id = %s
  ORDER BY event_name DESC'''

TID_QUERY='''
SELECT  thread_id
  FROM performance_schema.threads
  WHERE PROCESSLIST_ID=%s'''

class MemoryProfiler:

    def __init__(self):
        self.x = []
        self.y = []
        self.mem_labels = ['XXXXXXXXXXXXXXXXXXXXXXX']
        self.ms = 0
        self.color_sequence = ['#ffc59b', '#d4c9fe', '#a9dffe', '#a9ecb8',
                               '#fff1a8', '#fbbfc7', '#fd812d', '#a18bf5',
                               '#47b7f8', '#40d763', '#f2b600', '#ff7082']
        plt.rcParams['axes.xmargin'] = 0
        plt.rcParams['axes.ymargin'] = 0
        plt.rcParams["font.family"] = "inter"

    def update_xy_axis(self, results, frequency):
        self.ms += frequency
        self.x.append(self.ms)
        if (len(self.y) == 0):
            self.y = [[] for x in range(len(results))]
        for i in range(len(results)-1, -1, -1):
            usage = float(results[i][1]) / 1024
            self.y[i].append(usage)
        if (len(self.x) > 50):
            self.x.pop(0)
            for i in range(len(self.y)):
                self.y[i].pop(0)

    def update_labels(self, results):
        total_mem = sum(map(lambda e: e[1], results))
        self.mem_labels.clear()
        for i in range(len(results)-1, -1, -1):
            usage = float(results[i][1]) / 1024
            mem_type = results[i][0]
            # Remove 'memory/' from beginning of name for brevity
            mem_type = mem_type[7:]
            # Only show top memory users in legend
            if (usage < total_mem / 1024 / 50):
                mem_type = '_' + mem_type
            self.mem_labels.insert(0, mem_type)

    def draw_plot(self, plt):
        plt.clf()
        plt.stackplot(self.x, self.y, colors = self.color_sequence)
        plt.legend(labels=self.mem_labels, bbox_to_anchor=(1.04, 1), loc="upper left", borderaxespad=0)
        plt.xlabel("milliseconds since monitor began")
        plt.ylabel("Kilobytes of memory")

    def configure_plot(self, plt):
        plt.ion()
        fig = plt.figure(figsize=(12,5))
        plt.stackplot(self.x, self.y, colors=self.color_sequence)
        plt.legend(labels=self.mem_labels, bbox_to_anchor=(1.04, 1), loc="upper left", borderaxespad=0)
        plt.tight_layout(pad=4)
        return fig

    def start_visualization(self, database_connection, connection_id, frequency):
        c = database_connection.cursor();
        fig = self.configure_plot(plt)
        while(True):
            c.execute(MEM_QUERY, (connection_id,))
            results = c.fetchall()
            self.update_xy_axis(results, frequency)
            self.update_labels(results)
            self.draw_plot(plt)
            fig.canvas.draw_idle()
            fig.canvas.start_event_loop(frequency / 1000)

def get_command_line_args():
    '''
    Process arguments and return argparse object to caller.
    '''
    parser = argparse.ArgumentParser(description='Monitor MySQL query memory for a particular connection.')
    parser.add_argument('--connection-id', type=int, required=True,
                        help='The MySQL connection to monitor memory usage of')
    parser.add_argument('--frequency', type=float, default=500,
                        help='The frequency at which to ping for memory usage update in milliseconds')
    return parser.parse_args()

def get_thread_for_connection_id(database_connection, cid):
    '''
    Get a thread ID corresponding to the connection ID
    PARAMS
      database_connection - Database connection object
      cid - The connection ID to find the thread for
    '''
    c = database_connection.cursor()
    c.execute(TID_QUERY, (cid,))
    result = c.fetchone()
    return int(result[0])

def main():
    args = get_command_line_args()
    database_connection = MySQLdb.connect(host='127.0.0.1', user='root', password='password')
    connection_id = get_thread_for_connection_id(database_connection, args.connection_id)
    m = MemoryProfiler()
    m.start_visualization(database_connection, connection_id, args.frequency)
    connection.close()

if __name__ == "__main__":
    main()

有了這個,我們可以對MySQL查詢的執行進行詳細的監控。 要使用它,首先獲取要分析的連接的連接 ID:

SELECT CONNECTION_ID();

然後,執行以下命令將開始監視會話:

./monitor.py --connection-id YOUR_CONNECTION_ID --frequency 250

當對數據庫執行查詢時,我們可以觀察內存使用量的增加,並查看哪些類別的內存貢獻最大。

這種可視化還可以幫助我們清楚地看到哪些操作是佔用內存的。 例如,以下是用於 FULLTEXT 在大型表上創建索引的內存配置文件的片段:

內存使用量很大,並且在執行時會繼續增長到使用數百兆字節。

結論

儘管可能並不經常需要,但當需要詳細的查詢優化時,獲取詳細的內存使用信息的能力可能非常有價值。 這樣做可以揭示 MySQL 何時以及爲何會對系統造成內存壓力,或者是否需要對數據庫服務器進行內存升級。 MySQL 提供了許多原語,您可以在這些原語的基礎上爲您的查詢和工作負載開發分析工具。

更多技術文章,請訪問:https://opensource.actionsky.com/

關於 SQLE

SQLE 是一款全方位的 SQL 質量管理平臺,覆蓋開發至生產環境的 SQL 審覈和管理。支持主流的開源、商業、國產數據庫,爲開發和運維提供流程自動化能力,提升上線效率,提高數據質量。

SQLE 獲取

類型 地址
版本庫 https://github.com/actiontech/sqle
文檔 https://actiontech.github.io/sqle-docs/
發佈信息 https://github.com/actiontech/sqle/releases
數據審覈插件開發文檔 https://actiontech.github.io/sqle-docs/docs/dev-manual/plugins/howtouse
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章