一次原本用于比较不同内存分配器的基准测试,却暴露出存储过程执行时意料之外的内存变化。这个现象值得关注,因为操作系统看到的 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 或其他实现,应保证以下条件一致:
- 使用同一个 Percona Server 版本、配置文件和数据目录快照。
- 固定连接数、存储过程参数、调用次数和两轮测试之间的等待时间。
- 确认服务器进程实际加载了目标分配器,而不是只设置了一个未生效的环境变量。
- 同时记录 RSS、虚拟内存、MySQL 内存 instrument、吞吐量和延迟。
- 每组测试重启服务器,避免上一组测试的 arena、缓存和高水位影响下一组。
可以使用下面的命令检查进程加载的动态库:
PID=$(pgrep -xo mysqld)
grep -E 'jemalloc|tcmalloc|libc' "/proc/${PID}/maps" | awk '{print $6}' | sort -u
如何判断值得继续排查
更有说服力的异常信号是:在输入和并发保持不变时,每轮调用都带来近似线性的内存增长;关闭相关会话后仍不回落;performance_schema 中某类分配持续增加;最终伴随交换、延迟恶化或 OOM 风险。
相反,如果内存在预热后进入稳定区间,后续调用复用了已有空间,那么高 RSS 本身未必是泄漏。上线前应使用接近真实的过程体、参数分布和连接池生命周期重复实验,并为实例保留足够的操作系统内存余量。存储过程内存问题的排查重点不是寻找一个漂亮的单点数字,而是建立对象生命周期、分配器行为和进程指标之间能够重复验证的关系。