带有索引查找和过滤器的嵌套循环内部联接速度较慢

Nested loop inner join with index lookup and filter is slow

提问人:Dan Gravell 提问时间:10/18/2023 最后编辑:Dan Gravell 更新时间:10/20/2023 访问量:69

问:

我在MySQL中运行了这个查询:

SELECT
    count(*)
FROM
    library AS l
    JOIN plays AS p ON p.user_id = l.user_id AND
    l.path = p.path
WHERE
    l.user_id = 20977 AND
    p.time >= '2022-10-17';

运行 EXPLAIN ANALYZE 时:

| -> Aggregate: count(0)  (cost=1085653.55 rows=6692) (actual time=12576.265..12576.266 rows=1 loops=1)
    -> Nested loop inner join  (cost=1084984.37 rows=6692) (actual time=40.604..12566.569 rows=56757 loops=1)
        -> Index lookup on l using user_id_2 (user_id=20977)  (cost=116747.95 rows=106784) (actual time=13.153..3783.204 rows=59631 loops=1)
        -> Filter: ((p.user_id = 20977) and (p.`time` >= TIMESTAMP'2022-10-17 00:00:00'))  (cost=8.24 rows=0) (actual time=0.135..0.147 rows=1 loops=59631)
            -> Index lookup on p using path (path=l.`path`)  (cost=8.24 rows=8) (actual time=0.090..0.146 rows=1 loops=59631)
 |
1 row in set (12.76 sec)

我显然想让它更快!

表定义

CREATE TABLE `library` (
  `user_id` int NOT NULL,
  `name` varchar(20) COLLATE utf8mb4_general_ci NOT NULL,
  `path` varchar(512) COLLATE utf8mb4_general_ci NOT NULL,
  `title` varchar(512) COLLATE utf8mb4_general_ci NOT NULL,
  `created` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `edited` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  `db_id` int NOT NULL,
  `tag` varchar(64) COLLATE utf8mb4_general_ci NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

CREATE TABLE `plays` (
  `user_id` int DEFAULT NULL,
  `name` varchar(20) CHARACTER SET utf8 DEFAULT NULL,
  `path` varchar(512) COLLATE utf8mb4_general_ci DEFAULT NULL,
  `time` datetime DEFAULT CURRENT_TIMESTAMP,
  `play_id` int NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

ALTER TABLE `library`
  ADD PRIMARY KEY (`db_id`),
  ADD KEY `user_id_loc` (`user_id`,`name`,`path`(191)),
  ADD KEY `edited` (`edited`),
  ADD KEY `created` (`created`),
  ADD KEY `title` (`title`),
  ADD KEY `user_id` (`user_id`),
  ADD INDEX `user_id_by_title` (`user_id`, `title`);

ALTER TABLE `plays`
  ADD PRIMARY KEY (`play_id`),
  ADD KEY `user_id` (`user_id`,`name`,`path`(255)),
  ADD KEY `user_id_2` (`user_id`,`name`),
  ADD KEY `time` (`time`),
  ADD KEY `path` (`path`),
  ADD KEY `user_id_3` (`user_id`,`name`,`path`,`time`);

看起来杀手是循环超过 59631 行。

索引会让它更快吗?(user_id, time)

有趣的是,该索引实际上是 上的索引,而不是普通索引。我不确定为什么选择,因为查询中没有使用。user_id_2(user_id, title)user_iduser_id_2title

mysql 联接 sql-execution-plan

评论

0赞 Bill Karwin 10/18/2023
您的代码存在一些错误 ()。我无法运行它来尝试重现该问题。这让我怀疑是否有任何代码与您的查询相关。请发布您遇到问题的实际代码。或者更好的是,制作一个 dbfiddle。ERROR 1072 (42000): Key column 'id' doesn't exist in table library
0赞 Dan Gravell 10/18/2023
@BillKarwin对不起。事实是列名是 .但是,我之前在 SO 上发布了一个问题,其中很多反馈都是关于列命名的,所以我试图查找并替换列名称,但我错过了一个。我会解决这个问题,所以现在就全部了。iduser_id
0赞 Dan Gravell 10/18/2023
@TheImpaler 畸形是什么意思?我以为你的意思是某种语法错误,但它对我来说运行正常(MySQL)。然而,你提到“默默地皈依”让我认为你的意思是它不是最佳的,而不是畸形的。那么你说修复它是什么意思呢?条件应按原样显示 - 表中的行只应恢复 .你的意思是它不应该是因为隐含的标准吗?同意 - 但我只是想使用我所拥有的查询 - 如果这是速度变慢的原因 - 太好了!playsp.time >= '2022-10-17'LEFT JOIN
0赞 The Impaler 10/18/2023
@DanGravell 是的,in不应该在那里。它会误导您的分析。从优化器的角度来看,外部连接(需要优化的选项较少)和内部连接(优化途径较多)之间存在很大差异。LEFTLEFT JOIN
1赞 Bill Karwin 10/18/2023
连接问题不会让MySQL感到困惑。当它检测到条件使左外联接充当内部联接时,它会自动将其优化为内部联接。参见 dev.mysql.com/doc/refman/8.0/en/outer-join-optimization.html

答:

2赞 Bill Karwin 10/18/2023 #1

我测试了您的查询,并在每个表中尝试了不同的索引。

ALTER TABLE library ADD KEY bk1 (user_id, path); 

ALTER TABLE plays ADD KEY bk2 (user_id, path, time); 

EXPLAIN SELECT
    COUNT(*)
FROM
    library AS l USE INDEX (bk1)
    JOIN plays AS p USE INDEX (bk2)
      ON p.user_id = l.user_id 
      AND l.path = p.path
WHERE
    l.user_id = 20977 
    AND p.time >= '2022-10-17';

+----+-------------+-------+------------+------+---------------+------+---------+-------------------+------+----------+--------------------------+
| id | select_type | table | partitions | type | possible_keys | key  | key_len | ref               | rows | filtered | Extra                    |
+----+-------------+-------+------------+------+---------------+------+---------+-------------------+------+----------+--------------------------+
|  1 | SIMPLE      | l     | NULL       | ref  | bk1           | bk1  | 4       | const             |    1 |   100.00 | Using index              |
|  1 | SIMPLE      | p     | NULL       | ref  | bk2           | bk2  | 2056    | const,test.l.path |    1 |   100.00 | Using where; Using index |
+----+-------------+-------+------------+------+---------------+------+---------+-------------------+------+----------+--------------------------+

EXPLAIN 报告每一行中的注释“使用索引”表明,它从两个表的覆盖索引中获益。

我没有使用前缀索引语法,因为这会破坏覆盖索引优化。在现代MySQL版本上,没有必要使用此示例的前缀索引,因为它们默认为支持3072字节索引的InnoDB行格式,而不是默认情况下仅支持768字节索引的旧MySQL。

在我的测试中,我测试的表中没有行,因此我不得不使用索引提示来让优化器选择我的新索引。在具有大量行的表中,优化程序可能会自行选择新索引。

评论

0赞 Dan Gravell 10/18/2023
我现在正在创建要测试的索引。这是否有效,因为连接在两个新索引之间使用了具有相同前缀的索引 - - 然后对 排序 ,以便时间比较非常快?(user_id, path, ...)bk2time
0赞 Bill Karwin 10/18/2023
覆盖索引优化只是两个索引包含查询所需的所有列,因此MySQL可以跳过从表中读取行。索引中列的顺序很重要。用于相等条件的列是第一位(最左边),然后是用于范围条件的一列,然后是覆盖索引所需的更多列。
1赞 Bill Karwin 10/18/2023
您可能喜欢我的演讲“如何设计索引,真的”视频
0赞 Dan Gravell 10/18/2023
谢谢 - 电话簿类比很好地描述了我如何理解这一点,但我没有考虑过还包括查询所需的其他列。如果在时间之后有第二列是范围查询,例如持续时间,该怎么办?这是否要求另一个索引,例如查询将能够轻松地从 bk2 转移到 bk3,因为它已经有了“前缀”?ALTER TABLE plays ADD KEY bk3 (user_id, path, duration);(user_id, path)
1赞 Bill Karwin 10/18/2023
是的,理想情况下,优化程序将为每个查询选择最佳索引。您可能需要具有某些共同列的多个索引来支持不同的查询。不幸的是,在实践中,有时优化器会混淆索引与常见的前导列,并选错了索引。因此,我们有时需要使用索引提示语法(除非绝对必要,否则我尽量避免使用)。
0赞 Rick James 10/19/2023 #2

删除这些,它们挡住了路和/或冗余:

l: `user_id` (`user_id`),
p: `user_id` (`user_id`,`name`,`path`(255)),
p: `user_id_2` (`user_id`,`name`),

添加以下内容:

l:  INDEX(user_id,  path)
p:  INDEX(user_id,  path, time)
p:  INDEX(user_id,  time, path)   -- see below

更改(MySQL 5.7/8.0 不再需要前缀 kludge):

l:  `user_id_loc` (`user_id`,`name`,`path`)  -- tossing 191

尽量避免测试 中不同表中的列。WHERE

我第一次看到

    WHERE  l.user_id = 20977
      AND  p.time >= '2022-10-17';

并认为这是问题的症结所在。但后来我看到你没有打开并且表格[部分]连接起来.INDEX(user_id, time)puser_id

建议(以避免我的混淆)您进行以下更改:

    WHERE  l.user_id = 20977   -- >
    WHERE  p.user_id = 20977

优化器应该足够聪明,能够意识到这一点,然后使用

p:  INDEX(user_id,  time, path)   -- as mentioned above

但是,一旦完成此操作,查询将折叠到

SELECT COUNT(DISTINCT user_id, path)
    FROM plays
    WHERE  user_id = 20977
      AND  time >= '2022-10-17';

我认为它会说“覆盖索引跳过扫描以进行重复数据删除”,以表明它实际上并没有扫描所有 60K 行,而是跳过索引!plays

但是,如果有些“播放”在“库”中没有相应的条目,则计数将因缺少用户播放组合的数量而增加。

当表同时具有 .:INDEX(a), INDEX(a,b)

  • 当查询只需要 时,则任一索引都将起作用。(a)
  • 当查询需要时,优化器可能会选择它,因为它较小,而没有意识到更大的索引会更好。(a,b)(a)

出于这个原因,我建议一些.DROPs

另一个原因是去掉“前缀”索引(),它要么适得其反,要么不再需要。DROPspath(255)

评论

0赞 Dan Gravell 10/19/2023
删除这些索引 - 但这些索引被其他查询使用......所以我真的不能就这样放弃它们。不过,我将继续测试您的其他建议。
0赞 Dan Gravell 10/20/2023
我试过这个,但看起来它没有使用正确的索引(由于您上面建议的附加索引,我删除了@BillKarwin建议的语句 - 我不确定应该在哪里使用它)。结果,它需要将近 7 秒 - explain.depesz.com/s/htQ1USE INDEX
0赞 Rick James 10/20/2023
@DanGravell - 除了我的答案之外,我还谈到了你的两条评论。
0赞 Dan Gravell 10/20/2023
谢谢。正如我所说,我不能删除它们,因为它们是其他查询所必需的。我再次尝试使用 USE INDEX,但我不确定您建议应该在哪里使用 - 我只能看到 FROM 和 JOIN 语句作为可能的位置。INDEX(user_id, time, path)
0赞 Dan Gravell 10/21/2023
我还尝试了替代查询 - 但这花费了双倍的时间。它似乎只使用最短的索引。我将其更改为使用 但仍然需要 4 秒。SELECT COUNT(DISTINCT...user_idINDEX(user_id, time, path)