别急着判定内存泄漏:复现 Percona Server 存储过程的内存增长

2026-07-31 17 预计阅读时间: 1 分钟
来源: percona.com AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:7 分钟

一次原本用于比较不同内存分配器的基准测试,却暴露出存储过程执行时意料之外的内存变化。这个现象值得关注,因为操作系统看到的 RSS、MySQL 内部统计的已分配字节,以及真正无法回收的内存,并不是同一个指标。仅凭“调用结束后 RSS 没降”就判断发生泄漏,很容易得出错误结论。

先区分三种内存现象

观察 Percona Server for MySQL 的存储过程时,至少要拆开以下三层:

  • MySQL 对象生命周期:存储过程的局部变量、执行上下文、临时表和语句缓冲区可能属于单次调用、会话或服务器级对象。
  • 内存分配器行为free() 之后,分配器可能把内存留在线程缓存或 arena 中,等待后续请求复用,而不是立即归还操作系统。
  • 操作系统指标:RSS 表示进程当前驻留的物理页,但不能直接说明这些页是否仍被 MySQL 对象引用。

因此,RSS 保持高位可能是缓存、碎片化或会话仍然存活,也可能确实是持续增长的问题。关键不是看一次调用前后的两个数字,而是观察相同负载经过多轮运行后是否趋于平台期。

建立一个可重复的存储过程负载

下面是一个可以改造的最小实验。它在存储过程中反复创建字符串并计算摘要,用于放大局部变量和表达式求值产生的内存活动。请只在测试实例运行,并根据实例的 max_allowed_packet 和可用内存调整参数。

CREATE DATABASE IF NOT EXISTS sp_mem_lab;
USE sp_mem_lab;

DROP PROCEDURE IF EXISTS burn_memory;
DELIMITER //
CREATE PROCEDURE burn_memory(IN rounds INT, IN payload_bytes INT)
BEGIN
    DECLARE i INT DEFAULT 0;
    DECLARE payload LONGTEXT;
    DECLARE digest CHAR(64);

    WHILE i < rounds DO
        SET payload = REPEAT('x', payload_bytes);
        SET digest = SHA2(payload, 256);
        SET payload = NULL;
        SET i = i + 1;
    END WHILE;

    SELECT digest AS final_digest;
END//
DELIMITER ;

CALL burn_memory(100, 1048576);

这里的负载并不是对原调查场景的精确复刻,而是一种实践方法:固定过程定义、调用次数和输入大小,再替换服务器版本或分配器。不要同时修改缓冲池、并发数和连接模型,否则结果很难归因。

同时采集 MySQL 统计与进程 RSS

如果 performance_schema 的内存 instrument 已启用,可以先查看服务器内部当前分配量和高水位:

SELECT
    EVENT_NAME,
    CURRENT_NUMBER_OF_BYTES_USED AS current_bytes,
    HIGH_NUMBER_OF_BYTES_USED AS high_bytes
FROM performance_schema.memory_summary_global_by_event_name
WHERE CURRENT_NUMBER_OF_BYTES_USED > 0
ORDER BY CURRENT_NUMBER_OF_BYTES_USED DESC
LIMIT 20;

不同版本暴露的 instrument 可能不同,某些分配也不会被完整记录,所以这张表应与操作系统指标配合使用。下面的脚本在同一客户端连接中执行多次调用,并在连接存活及断开后分别记录 mysqld 的 RSS。

运行前设置连接参数;生产环境不要把密码直接写进命令行,可以使用 mysql_config_editor 配置登录路径。

#!/usr/bin/env bash
set -euo pipefail

MYSQL=(mysql --login-path=local --batch --skip-column-names)
MYSQLD_PID=$(pgrep -xo mysqld || true)

if [[ -z "${MYSQLD_PID}" ]]; then
  echo "mysqld process not found" >&2
  exit 1
fi

rss_kb() {
  ps -o rss= -p "${MYSQLD_PID}" | tr -d ' '
}

echo "RSS before workload: $(rss_kb) KB"

{
  echo "USE sp_mem_lab;"
  for _ in $(seq 1 20); do
    echo "CALL burn_memory(100, 1048576);"
  done
  echo "DO SLEEP(20);"
} | "${MYSQL[@]}" >/dev/null &
CLIENT_PID=$!

sleep 5
echo "RSS while connection is alive: $(rss_kb) KB"

wait "${CLIENT_PID}"
echo "RSS after disconnect: $(rss_kb) KB"

建议先运行三至五轮预热,再记录后续轮次。首次执行可能包含过程解析、缓存填充和代码路径初始化,把它直接当成稳定状态会放大误差。

比较分配器时要锁定变量

如果实验目标是比较系统分配器、jemalloc 或其他实现,应保证以下条件一致:

  1. 使用同一个 Percona Server 版本、配置文件和数据目录快照。
  2. 固定连接数、存储过程参数、调用次数和两轮测试之间的等待时间。
  3. 确认服务器进程实际加载了目标分配器,而不是只设置了一个未生效的环境变量。
  4. 同时记录 RSS、虚拟内存、MySQL 内存 instrument、吞吐量和延迟。
  5. 每组测试重启服务器,避免上一组测试的 arena、缓存和高水位影响下一组。

可以使用下面的命令检查进程加载的动态库:

PID=$(pgrep -xo mysqld)
grep -E 'jemalloc|tcmalloc|libc' "/proc/${PID}/maps" | awk '{print $6}' | sort -u

如何判断值得继续排查

更有说服力的异常信号是:在输入和并发保持不变时,每轮调用都带来近似线性的内存增长;关闭相关会话后仍不回落;performance_schema 中某类分配持续增加;最终伴随交换、延迟恶化或 OOM 风险。

相反,如果内存在预热后进入稳定区间,后续调用复用了已有空间,那么高 RSS 本身未必是泄漏。上线前应使用接近真实的过程体、参数分布和连接池生命周期重复实验,并为实例保留足够的操作系统内存余量。存储过程内存问题的排查重点不是寻找一个漂亮的单点数字,而是建立对象生命周期、分配器行为和进程指标之间能够重复验证的关系。


相关推荐