返回

MySQL 8.0 升级后查询性能下降?参数绑定过多是元凶!

mysql

MySQL 8.0 升级后特定查询性能下降问题分析与解决

在将 MySQL 从 5.7 版本升级到 8.0 版本后,部分应用可能会遇到特定查询性能下降的问题。 当查询中绑定了大量参数时,这种性能下降尤为明显。 本文将深入分析此问题,并提供几种有效的解决方案,帮助开发者快速恢复 MySQL 8.0 的查询性能。

问题分析

MySQL 5.7 升级到 8.0 后,当使用预处理语句(Prepared Statements)并绑定大量参数时,特定查询的执行时间可能会大幅增加。 通过性能分析可以发现,时间主要消耗在执行阶段而非参数绑定阶段,同时 MySQL 进程 CPU 使用率显著升高。 将查询修改为非参数化(字符串拼接)方式执行,或者减少绑定参数数量,可以暂时规避此问题,这表明问题很可能与参数绑定机制的改变有关。

解决方案

以下是针对此问题的几种解决方案:

1. 调整 eq_range_index_dive_limit 参数

eq_range_index_dive_limit 系统变量控制着索引潜水(index diving)的深度,用于估算索引扫描的行数。 在 MySQL 8.0 中,由于优化器行为的变化,对于包含大量 IN 子句的查询,其默认设置可能导致优化器生成低效的执行计划。 适当调低此参数值可以强制优化器使用索引统计信息而不是进行深入的索引潜水,从而可能改善查询性能。

操作步骤:

  • 使用 MySQL 客户端连接到 MySQL 服务器。
  • 执行以下命令,调整 eq_range_index_dive_limit 参数的值:
SET GLOBAL eq_range_index_dive_limit = 10;

或者在 my.cnf 中持久化配置

[mysqld]
eq_range_index_dive_limit=10
  • 此参数可以在会话级别或者全局级别设置,建议先在会话级别进行测试,确认效果后再持久化到全局级别。

原理分析:
调低 eq_range_index_dive_limit 减少了优化器尝试使用索引潜水的次数。 当 IN 子句参数过多时,索引潜水成本非常高。 通过减少潜水次数,优化器会更快地决定使用索引而不是全表扫描或者其他低效方式。 值设置为 10 通常能提供一个较好的平衡点。

安全建议:
调整此参数可能会影响其他查询的执行计划。 建议在测试环境中充分验证后,再应用于生产环境。 如果设置过低可能会导致优化器走错索引,反而降低性能。 因此需要根据实际情况反复测试找到最佳值。

2. 使用临时表或派生表

对于包含大量参数的 IN 子句,可以考虑先将参数数据存放到临时表或派生表中,然后使用 JOIN 操作替代 IN 子句。 这种方式可以将参数数据的处理与主查询分离,使优化器能够生成更优的执行计划。

操作步骤:

  • 创建临时表或派生表,并将参数数据插入其中:
<?php
// ... 数据库连接信息
$connection = new mysqli(HOST, USER, PASS, DB_NAME);
$params = [];
for ($i = 0; $i < NUM_PARAMS; $i++) {
    $params[] = rand_string();
}
// 创建临时表
$connection->query("CREATE TEMPORARY TABLE temp_params (product VARCHAR(50) PRIMARY KEY)");

// 准备批量插入语句
$placeholders = implode(',', array_fill(0, count($params), '(?)'));
$insert_query = "INSERT INTO temp_params (product) VALUES " . $placeholders;

$stmt = $connection->prepare($insert_query);

// 将参数绑定为字符串
$types = str_repeat('s', count($params));
$stmt->bind_param($types, ...$params);
$stmt->execute();

// 使用临时表进行 JOIN 查询
$big_query = <<<SQL
    SELECT c.product, a.name, b.value
    FROM b
    INNER JOIN a ON b.a_id = a.id AND a.name IN ('1be6f9eb563f3bf85c78b4219bf09de9')
    INNER JOIN c on c.b_id = b.id
    INNER JOIN temp_params tp ON c.product = tp.product
SQL;

// ... 执行查询并处理结果

// 释放临时表
$connection->query("DROP TEMPORARY TABLE IF EXISTS temp_params");

或者派生表

<?php
// ... 数据库连接信息和参数生成

$big_query = <<<SQL
SELECT c.product, a.name, b.value
FROM b
INNER JOIN a ON b.a_id = a.id AND a.name IN ('1be6f9eb563f3bf85c78b4219bf09de9')
INNER JOIN c on c.b_id = b.id
INNER JOIN (
SQL;

$placeholders = [];
for ($i = 0; $i < NUM_PARAMS; $i++) {
     $big_query .=  $i>0?',':'';
    $big_query .=  "SELECT ? as product ";
    $placeholders[] = $params[$i];

}
$param_types = str_repeat('s',count($placeholders));
$big_query .= <<<SQL
    ) AS temp_params ON c.product = temp_params.product
SQL;

$connection = new mysqli(HOST, USER, PASS, DB_NAME);

$q = $connection->prepare($big_query);
$q->bind_param($param_types,...$placeholders);

$start_time = hrtime(true);
$q->execute(); 
$end_time = hrtime(true);

$total_time = ($end_time - $start_time) / 1000000000;

echo 'The total time for parameterized query using derived table is ' . $total_time . ' seconds.';

// ... 后续处理结果
?>
  • 执行上述 PHP 代码即可完成临时表的创建、数据插入和 JOIN 查询。

原理分析:
IN 子句中的值放入临时表或派生表,然后通过 JOIN 来筛选数据,这样可以避免优化器在处理大量 IN 值时可能出现的性能问题。 MySQL 可以更好地优化 JOIN 操作,尤其是当临时表上有索引时。 使用派生表可以避免显式地创建和删除临时表,但可能会对复杂查询的可读性产生影响。

安全建议:

  • 在使用临时表时,确保在查询完成后及时释放资源,避免连接资源泄漏。 PHP 代码中已包含释放临时表的语句。
  • 控制临时表或派生表的数据量,避免占用过多内存资源。
  • 尽量使用主键或唯一索引,加快数据查找速度。
  • 注意使用临时表有权限要求。

3. 升级到更高版本的 MySQL

这个问题在 MySQL 8.0.28 版本中比较明显, 较新的 MySQL 版本可能已经修复或优化了这个问题。 可以考虑升级到最新的稳定版本,例如 8.0.3x 或更高版本。

操作步骤:

  • 备份当前数据库。
  • 参考 MySQL 官方文档,执行数据库升级操作。
  • 升级完成后,重启 MySQL 服务并测试相关查询的性能。

原理分析:
MySQL 版本更新会包含错误修复和性能优化。 更高版本的 MySQL 对优化器和执行引擎做了改进, 可能会更好地处理大量参数的预处理语句。 通过升级数据库版本,可以直接利用这些改进来提升性能。

安全建议:

  • 在升级数据库版本前,务必进行完整备份,并充分测试兼容性,确保应用程序正常运行。
  • 建议在测试环境中模拟升级过程,并进行充分验证后再应用于生产环境。

4. 修改应用程序代码,避免使用大量参数绑定

如果业务逻辑允许,可以考虑修改应用程序代码,避免一次性绑定大量参数。 例如,可以将一个包含大量参数的查询拆分为多个小查询,或者采用其他数据访问方式。

操作步骤:

以下是分批查询的 PHP 代码示例:

<?php
// ... 数据库连接信息和参数生成

const BATCH_SIZE = 1000; // 定义每次批处理的参数数量
$total_params = count($params);
$results = [];
$connection = new mysqli(HOST, USER, PASS, DB_NAME);

for ($i = 0; $i < $total_params; $i += BATCH_SIZE) {
    $batch_params = array_slice($params, $i, BATCH_SIZE);
    $placeholders = implode(',', array_fill(0, count($batch_params), '?'));
    $big_query = <<<