MySQL 5.7 与 5.6:起初索引使用错误,但 "automagically" 数周后修复

MySQL 5.7 vs 5.6: Index usage wrong at first, but "automagically" fixed weeks later

上下文:

我有一个 MySQL 5.6 作为主控,有两个副本,其中一个也是 MySQL 5.6 实例,另一个是 MySQL 5.7。繁重的查询均匀分布在两个副本上。

5.6 副本已启动 运行 大约 2 个月,5.7 较新,运行 大约两周。

使用 AWS RDS。

奇怪的行为:

刚创建后,我注意到由于“错误的索引使用”,一些查询在 5.7 时要慢得多,如比较两个版本的 EXPLAIN 结果所示。甚至一些具有 USE INDEX 子句的查询似乎对 5.7 数据库没有影响。

考虑到它是一个全新的副本,我想:“也许索引统计信息不是最新的?”。

不是这样的。我有一个例程在主数据库上对系统上的每个 table 运行“分析 table”,所以索引统计应该没问题。我可以通过检查副本 mysql.innodb_index_stats.

上的 last_update 来确认“analyse table”语句已成功复制

但几天过去了,我这边没有任何工作,错误的查询开始使用正确的索引!全部固定。没有完成工作。疯狂的一天。

问题:

  1. 什么会影响特定查询执行计划的选择?索引统计数据不是唯一的吗?

  2. 是否有任何“caching/warming up”的东西,超过两周的实际工作可以让 Optimiser 改变主意?

  3. “分析table”是否足以保证两个查询具有相同的执行计划?

  4. 还有什么可以解释在执行两周后,MySQL 5.7 开始以与 MySQL 5.6 相同的方式工作?

请记住,这两个数据库都是来自相同来源的副本,接收均匀分布的流量。

SQL 示例:

以下查询(这是一个混淆版本)使用了 BAD 索引,一天后它使用了 GOOD 索引。就像魔法™一样。

SELECT sale.number,
       seller.id,
       seller.name,
       sale.id,
       COALESCE(billed_amount, 0),
       COALESCE(billing.tax_id, '---'),
       billing.date,
       CASE COALESCE(sale.seller_name_2, '') WHEN '' THEN sale.seller_name_1 ELSE sale.seller_name_2 END,
       sale.business_id,
       sale.business_name,
       sale.total
FROM app_billing billing
         JOIN app_sale sale ON billing.sale_id = sale.id
         JOIN app_seller seller ON seller.id = sale.seller_id
WHERE billing.tenant_id = 515
  AND billing.removed = FALSE
  AND billing.date BETWEEN '2020-08-01' AND '2020-08-31'
  AND sale.status = 2
  AND sale.seller_id IN (368);

-- MySQL 5.7 (BAD, really bad estimate and counter intuitive decision [should definitely be a range scan])
-- 1st step: There are only one seller with ID 368, so thats right.
-- 2nd step: 692 is pretty accurate, there are 695 sales for the seller ID 368.
-- 3rd step: This is tricky. There are a total of 270776 billing records that matches the 695 sale_ids from the previous step. 65% of the matches, have only one correspondence, but the remaining 35% have between 1000 and 5000 correspondences. Average would be 1360.
-- Estimated total number of processed records: 942000~.
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
| 1 | SIMPLE | seller  | NULL | const | PRIMARY                                | PRIMARY       | 4 | const   | 1   | 100  | NULL |
| 1 | SIMPLE | sale    | NULL | ref   | PRIMARY,idx_seller_id                  | idx_seller_id | 4 | const   | 692 | 10   | Using where |
| 1 | SIMPLE | billing | NULL | ref   | idx_sale_id,idx_tenant_id_removed_date | idx_sale_id   | 4 | sale.id | 2   | 2.16 | Using where |

-- MySQL 5.6 (GOOD, using range scan)
-- 1st step: There are only one seller with ID 368, so thats right.
-- 2nd step: 421 is almost perfect. There are actually 422 billing records across the date '2020-08-01' AND '2020-08-31' for the tenant_id 515 that arent removed.
-- 3rd step: That's right. There's only one sale correspondence per billing,
-- Estimated total number of processed records: 421.
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
| 1 | SIMPLE | seller  | const  | PRIMARY                                | PRIMARY                    | 4 | const           | 1   | NULL |
| 1 | SIMPLE | billing | range  | idx_sale_id,idx_tenant_id_removed_date | idx_tenant_id_removed_date | 9 | NULL            | 421 | Using index condition |
| 1 | SIMPLE | sale    | eq_ref | PRIMARY,idx_seller_id                  | PRIMARY                    | 4 | billing.sale_id | 1   | Using where |

MySQL 5.7 及更高版本受到查询优化器 heisenbug 的严重影响,这使得它无法预测地选择一个非常糟糕的执行计划。听起来你好像犯规了。到目前为止,我在 每个 5.7 安装上都看到了这种不一致的、不可预测的 table 行为(比我希望列举的要多)。

关于您的具体问题:

  1. 只有统计数据应该有所作为。但是,当 table 中有大量索引(超过大约 10 个)时,查询计划也总是容易混淆。然后就是我上面提到的heisenbug

  2. 没有,但是table统计一直在后台维护。听起来它终于得到了正确的数据分布估计 - 最终。

  3. 它应该——但通常不是。您可能需要查看以下设置:

    innodb_stats_persistent_sample_pages innodb_stats_persistent

  4. optimizer_switch 设置。您可能想尝试在 5.7 上将其设置为您在 5.6 上设置的相同标志。它有时会有所帮助(但不会太频繁)。

有很多原因导致统计数据可能略有不同,从而导致不同的解释计划。

由于 WHERE 子句测试多个 table 中的列,优化器不一定知道从哪个 table 开始。有时会选错table.

与其追逐统计数据和优化器的选择,不如改进索引,从而希望索引足够快以解决问题:

billing:  (tenant_id, sale_id, removed, date, tax_id)
billing:  (removed, tenant_id, date, sale_id, tax_id)
sale:  (status, seller_id)

无论决定从哪个 table 开始,这组索引都应该有所帮助。每个索引中列的顺序很重要。 (有一些变化同样有效。)

优化器可能“做错事”的原因示例。

  • 如果 billing.tenant_id = 515 只出现在一行中,那么开始 billing 可能会很快——获取一行,然后加入以从另一行中获取行 table.
  • 同样,如果sale.status = 2不经常出现,sale将是更好的首选。

没有实现上述目标的统计数据。有什么:“5000 行中有 about 100 个不同的 tenent_id 值。”也就是说,优化器将假定 tenant_id=515 出现在 50 行中。

注:我说的是“关于”。这是因为 ANALYZE 不会(假设一个大型 InnoDB table)查看 table 中的每个值。相反,它需要一个样本。 (在文档中查找“潜水次数”。)

一些可能导致主副本行为不同的事情

  • 上次更新统计数据是什么时候?没有同步更新。
  • 统计数据涉及“随机”探测。因此可能会出现轻微(或大)的变化。
  • 在某些情况下,统计信息的微小变化可能会导致所选查询计划的巨大差异——通常在 table 扫描和使用索引之间。优化器 尝试 在性能上没有太大差异的地方进行这种量子变化,但它可能是错误的。
  • 可能不同的 SELECTs 在不同的副本上。 SELECTs 可以与通过复制进行的写入交互。这可能导致块分裂(等)发生在 and/or table 不同位置的不同时间。这会间接影响统计数据。
  • 等等