如何优化具有多个外连接到大表、group by 和 order by 子句的查询的执行计划?

How to optimize execution plan for query with multiple outer joins to huge tables, group by and order by clauses?

我有以下数据库(简化版):

CREATE TABLE `tracking` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `manufacture` varchar(100) NOT NULL,
  `date_last_activity` datetime NOT NULL,
  `date_created` datetime NOT NULL,
  `date_updated` datetime NOT NULL,
  PRIMARY KEY (`id`),
  KEY `manufacture` (`manufacture`),
  KEY `manufacture_date_last_activity` (`manufacture`, `date_last_activity`),
  KEY `date_last_activity` (`date_last_activity`),
) ENGINE=InnoDB AUTO_INCREMENT=401353 DEFAULT CHARSET=utf8

CREATE TABLE `tracking_items` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `tracking_id` int(11) NOT NULL,
  `tracking_object_id` varchar(100) NOT NULL,
  `tracking_type` int(11) NOT NULL COMMENT 'Its used to specify the type of each item, e.g. car, bike, etc',
  `date_created` datetime NOT NULL,
  `date_updated` datetime NOT NULL,
  PRIMARY KEY (`id`),
  KEY `tracking_id` (`tracking_id`),
  KEY `tracking_object_id` (`tracking_object_id`),
  KEY `tracking_id_tracking_object_id` (`tracking_id`,`tracking_object_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1299995 DEFAULT CHARSET=utf8

CREATE TABLE `cars` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `car_id` varchar(255) NOT NULL COMMENT 'It must be VARCHAR, because the data is coming from external source.',
  `manufacture` varchar(255) NOT NULL,
  `car_text` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL,
  `date_order` datetime NOT NULL,
  `date_created` datetime NOT NULL,
  `date_updated` datetime NOT NULL,
  `deleted` tinyint(4) NOT NULL DEFAULT '0',
  PRIMARY KEY (`id`),
  UNIQUE KEY `car_id` (`car_id`),
  KEY `sort_field` (`date_order`)
) ENGINE=InnoDB AUTO_INCREMENT=150000025 DEFAULT CHARSET=utf8

这是我的 "problematic" 查询,运行速度极慢。

SELECT sql_no_cache `t`.*,
       count(`t`.`id`) AS `cnt_filtered_items`
FROM `tracking` AS `t`
INNER JOIN `tracking_items` AS `ti` ON (`ti`.`tracking_id` = `t`.`id`)
LEFT JOIN `cars` AS `c` ON (`c`.`car_id` = `ti`.`tracking_object_id`
                            AND `ti`.`tracking_type` = 1)
LEFT JOIN `bikes` AS `b` ON (`b`.`bike_id` = `ti`.`tracking_object_id`
                            AND `ti`.`tracking_type` = 2)
LEFT JOIN `trucks` AS `tr` ON (`tr`.`truck_id` = `ti`.`tracking_object_id`
                            AND `ti`.`tracking_type` = 3)
WHERE (`t`.`manufacture` IN('1256703406078',
                            '9600048390403',
                            '1533405067830'))
  AND (`c`.`car_text` LIKE '%europe%'
       OR `b`.`bike_text` LIKE '%europe%'
       OR `tr`.`truck_text` LIKE '%europe%')
GROUP BY `t`.`id`
ORDER BY `t`.`date_last_activity` ASC,
         `t`.`id` ASC
LIMIT 15

这是上述查询 EXPLAIN 的结果:

+----+-------------+-------+--------+-----------------------------------------------------------------------+-------------+---------+-----------------------------+---------+----------------------------------------------+
| id | select_type | table |  type  |                             possible_keys                             |     key     | key_len |             ref             |  rows   |                    extra                     |
+----+-------------+-------+--------+-----------------------------------------------------------------------+-------------+---------+-----------------------------+---------+----------------------------------------------+
|  1 | SIMPLE      | t     | index  | PRIMARY,manufacture,manufacture_date_last_activity,date_last_activity | PRIMARY     |       4 | NULL                        | 400,000 | Using where; Using temporary; Using filesort |
|  1 | SIMPLE      | ti    | ref    | tracking_id,tracking_object_id,tracking_id_tracking_object_id         | tracking_id |       4 | table.t.id                  |       1 | NULL                                         |
|  1 | SIMPLE      | c     | eq_ref | car_id                                                                | car_id      |     767 | table.ti.tracking_object_id |       1 | Using where                                  |
|  1 | SIMPLE      | b     | eq_ref | bike_id                                                               | bike_id     |     767 | table.ti.tracking_object_id |       1 | Using where                                  |
|  1 | SIMPLE      | t     | eq_ref | truck_id                                                              | truck_id    |     767 | table.ti.tracking_object_id |       1 | Using where                                  |
+----+-------------+-------+--------+-----------------------------------------------------------------------+-------------+---------+-----------------------------+---------+----------------------------------------------+

这个查询试图解决的问题是什么?

基本上,我需要找到 tracking table 中可能与 tracking_items (1:n) 中的记录关联的所有记录,其中 tracking_items 中的每条记录可能与左连接 table 中的记录相关联。过滤条件是查询中的关键部分。

我上面的查询有什么问题?

当有 order bygroup by 子句时,查询运行得非常慢,例如完成上述配置需要 10-15 秒。但是,如果我省略这些子句中的任何一个,查询会 运行 非常快(~0.2 秒)。

我已经尝试了什么?

  1. 我尝试使用 FULLTEXT 索引,但它没有太大帮助,因为 LIKE 语句评估的结果被 JOINs 使用索引缩小.
  2. 我试过使用WHERE EXISTS (...)来查找left中是否有记录加入tables,但不幸的是没有任何运气。

关于这些 table 之间关系的一些注释:

tracking -> tracking_items (1:n)
tracking_items -> cars (1:1)
tracking_items -> bikes (1:1)
tracking_items -> trucks (1:1)

所以,我正在寻找优化该查询的方法。

EXPLAIN 显示您正在对跟踪 table 进行索引扫描(type 列中的 "index")。索引扫描与 table 扫描的成本几乎一样,尤其是当扫描的索引是 PRIMARY 索引时。

rows 列还显示此索引扫描正在检查 > 355K 行(由于此数字只是粗略估计,它实际上正在检查所有 400K 行)。

你有 t.manufacture 的索引吗?我看到在 possible keys 中命名的两个索引可能包含该列(我不能仅根据索引名称确定),但由于某种原因优化器没有使用它们。也许您搜索的值集与 table 中的每一行都匹配。

如果 manufacture 值的列表旨在匹配 table 的子集,那么您可能需要向优化器提供提示以使其使用最佳索引。 https://dev.mysql.com/doc/refman/5.6/en/index-hints.html

使用LIKE '%word%' 模式匹配永远不能使用索引,并且必须在每一行上评估模式匹配。请参阅我的演示文稿,Full Text Search Throwdown

您的 IN(...) 列表中有多少项? MySQL 有时会遇到很长的列表问题。参见 https://dev.mysql.com/doc/refman/5.6/en/range-optimization.html#equality-range-optimization

P.S.: 当你问一个查询优化问题时,你应该总是包括查询中引用的每个 table 的 SHOW CREATE TABLE 输出,所以回答的人不会必须猜测您当前拥有的索引、数据类型和约束条件。

如果我的猜测是正确的并且 carsbikestrucks 彼此独立(即特定的预聚合结果将仅包含来自其中一个的数据).您最好联合三个更简单的子查询(每个子查询一个)。

虽然您不能对涉及前导通配符的 LIKE 做太多索引;将其拆分为 UNIONed 查询可以避免为所有 carsbikes 匹配项评估 p.fb_message LIKE '%Europe%' OR p.fb_from_name LIKE '%Europe%,以及为所有 b 和 [=18] 评估 c 条件=] 匹配,依此类推。

我不确定它是否有效,如何在 ON 子句中对每个 table(汽车、自行车和卡车)应用过滤器,在加入之前,它应该过滤掉行?

ALTER TABLE cars ADD FULLTEXT(car_text)

然后尝试

select  sql_no_cache
        `t`.*,  -- If you are not using all, spell out the list
        count(`t`.`id`) as `cnt_filtered_items`  -- This does not make sense
                         -- and is possibly delivering an inflated value
    from  `tracking` as `t`
    inner join  `tracking_items` as `ti`  ON (`ti`.`tracking_id` = `t`.`id`)
    join   -- not LEFT JOIN
         `cars` as `c`  ON `c`.`car_id` = `ti`.`tracking_object_id`
                                     AND  `ti`.`tracking_type` = 1 
    where  `t`.`manufacture` in('1256703406078', '9600048390403', '1533405067830')
      AND  MATCH(c.car_text)  AGAINST('+europe' IN BOOLEAN MODE)
    group by  `t`.`id`    -- I don't know if this is necessary
    order by  `t`.`date_last_activity` asc, `t`.`id` asc
    limit  15;

看看它是否会正确给你一个suitable 15 cars.

如果看起来没问题,那么将三者结合起来:

SELECT  sql_no_cache
        t2.*,
        -- COUNT(*)  -- this is probably broken
    FROM (
        ( SELECT t.id FROM ... cars ... )  -- the query above
        UNION ALL     -- unless you need UNION DISTINCT
        ( SELECT t.id FROM ... bikes ... )
        UNION ALL
        ( SELECT t.id FROM ... trucks ... )
         ) AS u
    JOIN tracking AS t2  ON t2.id = u.id
    ORDER BY t2.date_last_activity, t2.id
    LIMIT 15;

注意里面的SELECTs只送t.id,不送t.*

需要另一个索引:

ti:  (tracking_type, tracking_object_id)   -- in either order

索引

当你有 INDEX(a,b) 时,你不需要 INDEX(a)。 (这对有问题的查询没有帮助,但会帮助磁盘 space 和 INSERT 性能。)

当我看到 PRIMARY KEY(id), UNIQUE(x) 时,我会寻找任何好的理由不摆脱 id 并更改为 PRIMARY KEY(x)。除非架构的 'simplification' 中有重要内容,否则这样的更改会有所帮助。是的,car_id 很笨重,等等,但它很大 table 并且额外的查找(从索引 BTree 到数据 BTree)正在受到伤害,等等。

我认为 KEYsort_field(date_order) 不太可能被使用。要么放弃它(节省几 GB),要么以某种有用的方式组合它。让我们看看您认为它可能有用的查询。 (同样,与这个问题没有直接关系的建议。)

重新评论

我对我的公式做了一些实质性的修改。

我的公式有4个GROUP BYs,3个在'derived' table(即FROM ( ... UNION ... )),1个在外面。由于外部限制为 3*15 行,因此我不担心那里的性能。

进一步注意派生的 table 仅提供 t.id,然后重新探测 tracking 以获取其他列。这让派生的 table 运行 快得多,但以额外的 JOIN 为代价。

请详细说明COUNT(t.id)的意图;它在我的公式中不起作用,我不知道它在计算什么。

我不得不摆脱 ORs;它们是次要的性能杀手。 (第一杀手是LIKE '%...'。)

When there's order by and group by clauses the query runs extremely slow, e.g. 10-15 seconds to complete for the above configuration. However, if I omit any of these clauses, the query is running pretty quick (~0.2 seconds).

这很有趣...一般来说,我所知道的最好的优化技术是充分利用临时 tables,听起来它在这里非常有效。所以你首先要创建临时 table:

create temporary table tracking_ungrouped (
    key (id)
)
select sql_no_cache `t`.*
from `tracking` as `t` 
inner join `tracking_items` as `ti` on (`ti`.`tracking_id` = `t`.`id`)
    left join `cars` as `c` on (`c`.`car_id` = `ti`.`tracking_object_id` AND `ti`.`tracking_type` = 1)
    left join `bikes` as `b` on (`b`.`bike_id` = `ti`.`tracking_object_id` AND `ti`.`tracking_type` = 2)    
    left join `trucks` as `tr` on (`tr`.`truck_id` = `ti`.`tracking_object_id` AND `ti`.`tracking_type` = 3)
where 
    (`t`.`manufacture` in('1256703406078', '9600048390403', '1533405067830')) and 
    (`c`.`car_text` like '%europe%' or `b`.`bike_text` like '%europe%' or `tr`.`truck_text` like '%europe%');

然后查询你需要的结果:

select t.*, count(`t`.`id`) as `cnt_filtered_items`
from tracking_ungrouped t
group by `t`.`id` 
order by `t`.`date_last_activity` asc, `t`.`id` asc 
limit 15;

首先:您的查询对字符串内容做出了假设,这是不应该的。 car_text like '%europe%' 可能表示什么?也许是 'Sold in Europe only' 之类的东西?还是Sold outside Europe only?具有矛盾含义的两个可能的字符串。因此,如果您在字符串中找到 europe 后假设了某种含义,那么您应该能够将此知识引入数据库中 - 例如,使用欧洲国旗或区域代码。

无论如何,您正在显示某些跟踪及其欧洲运输计数。所以 select 跟踪,select 运输很重要。您可以在 SELECT 子句或 FROM 子句中包含运输计数的聚合子查询。

SELECT 子句中的子查询:

select
  t.*,
  (
    select count(*)
    from tracking_items ti
    where ti.tracking_id = t.id
    and (tracking_type, tracking_object_id) in
    (
      select 1, car_id from cars where car_text like '%europe%'
      union all
      select 2, bike_id from bikes where bike_text like '%europe%'
      union all
      select 3, truck_id from trucks where truck_text like '%europe%'
    )
from tracking t
where manufacture in ('1256703406078', '9600048390403', '1533405067830')
order by date_last_activity, id;

FROM 子句中的子查询:

select
  t.*, agg.total
from tracking t
left join
(
  select tracking_id, count(*) as total
  from tracking_items ti
  and (tracking_type, tracking_object_id) in
  (
    select 1, car_id from cars where car_text like '%europe%'
    union all
    select 2, bike_id from bikes where bike_text like '%europe%'
    union all
    select 3, truck_id from trucks where truck_text like '%europe%'
  )
  group by tracking_id
) agg on agg.tracking_id = t.id
where manufacture in ('1256703406078', '9600048390403', '1533405067830')
order by date_last_activity, id;

索引:

  • 跟踪(制造商,date_last_activity,id)
  • tracking_items(tracking_id, tracking_type, tracking_object_id)
  • 汽车(car_text, car_id)
  • 自行车(bike_text, bike_id)
  • 卡车(truck_text, truck_id)

有时 MySQL 在简单连接上比在其他任何东西上都更强,因此可能值得尝试盲目连接运输记录,然后再看看它是汽车、自行车还是卡车:

select
  t.*, agg.total
from tracking t
left join
(
  select
    tracking_id,
    sum((ti.tracking_type = 1 and c.car_text like '%europe%')
        or
        (ti.tracking_type = 2 and b.bike_text like '%europe%')
        or
        (ti.tracking_type = 3 and t.truck_text like '%europe%')
       ) as total
  from tracking_items ti
  left join cars c on c.car_id = ti.tracking_object_id
  left join bikes b on c.bike_id = ti.tracking_object_id
  left join trucks t on t.truck_id = ti.tracking_object_id
  group by tracking_id
) agg on agg.tracking_id = t.id
where manufacture in ('1256703406078', '9600048390403', '1533405067830')
order by date_last_activity, id;

Bill Karwin 建议如果查询使用前导列为 manufacture 的索引,则查询可能会执行得更好。我同意这个建议。特别是如果那是非常有选择性的。

我还注意到我们正在做 GROUP BY t.id,其中 id 是 table 的主键。

SELECT 列表中没有引用除 tracking 之外的任何 table 的列。

这表明我们真的只对 returning 来自 t 的行感兴趣,而不对由于多个外部连接而创建重复行感兴趣。

如果 tracking_itembikes、[=24= 中有多个匹配行,COUNT() 聚合似乎有可能 return 膨胀计数],trucks。如果有来自汽车的三行匹配行和来自自行车的四行匹配行,...... COUNT() 聚合将 return 值为 12,而不是 7。(或者数据中可能有一些保证这样就不会出现多个匹配行。)

如果 manufacture 非常有选择性,并且 return 是 tracking 中相当小的一组行,如果查询可以使用索引...

并且因为我们没有 returning 来自除 tracking 之外的任何 table 的任何列,除了计数或相关项目之外......

我很想测试 SELECT 列表中的相关子查询,以获取计数,并使用 HAVING 子句过滤掉计数为零的行。

像这样:

SELECT SQL_NO_CACHE `t`.*
     , ( ( SELECT COUNT(1)
             FROM `tracking_items` `tic`
             JOIN `cars` `c`
               ON `c`.`car_id`           = `tic`.`tracking_object_id`
              AND `c`.`car_text`      LIKE '%europe%'
            WHERE `tic`.`tracking_id`    = `t`.`id`
              AND `tic`.`tracking_type`  = 1
         )
       + ( SELECT COUNT(1)
             FROM `tracking_items` `tib`
             JOIN `bikes` `b`
               ON `b`.`bike_id`          = `tib`.`tracking_object_id` 
              AND `b`.`bike_text`     LIKE '%europe%'
            WHERE `tib`.`tracking_id`    = `t`.`id`
              AND `tib`.`tracking_type`  = 2
         )
       + ( SELECT COUNT(1)
             FROM `tracking_items` `tit`
             JOIN `trucks` `tr`
               ON `tr`.`truck_id`        = `tit`.`tracking_object_id`
              AND `tr`.`truck_text`   LIKE '%europe%'
            WHERE `tit`.`tracking_id`    = `t`.`id`
              AND `tit`.`tracking_type`  = 3
         ) 
       ) AS cnt_filtered_items
  FROM `tracking` `t`
 WHERE `t`.`manufacture` IN ('1256703406078', '9600048390403', '1533405067830')
HAVING cnt_filtered_items > 0
 ORDER
    BY `t`.`date_last_activity` ASC
     , `t`.`id` ASC

我们希望查询可以有效地利用 tracking 上的索引,前导列为 manufacture

而在 tracking_items table 上,我们需要一个前导列为 typetracking_id 的索引。在该索引中包含 tracking_object_id 意味着可以从索引中满足查询,而无需访问基础页面。

对于 carsbikestrucks table,查询应使用前导列为 car_idbike_idtruck_id 分别。无法绕过匹配字符串的 car_textbike_texttruck_text 列的扫描……我们能做的最好的事情就是缩小需要执行该检查的行数.

这种方法(只是外部查询中的 tracking table)应该消除对 GROUP BY 的需要,识别和折叠重复行所需的工作。

BUT 这种方法用相关的子查询代替连接,最适合有 SMALL 行数的查询 return由外部查询编辑。这些子查询针对外部查询处理的 每个 行执行。这些子查询必须有可用的 suitable 索引。即使进行了调整,大型集的性能仍然可能很差。

这仍然给我们留下了 ORDER BY.

的 "Using filesort" 操作

如果相关项目的计数应该是乘积而不是加法的乘积,我们可以调整查询来实现这一点。 (我们必须处理零的 return,并且需要更改 HAVING 子句中的条件。)

如果不需要 return 相关项的 COUNT(),那么我很想将相关子查询从 SELECT 列表向下移动到 EXISTS WHERE 子句中的谓词。


附加说明:支持 Rick James 关于索引的评论...似乎定义了冗余索引。即

KEY `manufacture` (`manufacture`)
KEY `manufacture_date_last_activity` (`manufacture`, `date_last_activity`)

单例列上的索引不是必需的,因为有另一个索引将该列作为前导列。

任何可以有效利用 manufacture 索引的查询都将能够有效利用 manufacture_date_last_activity 索引。也就是说,可以去掉manufacture索引。

tracking_itemstable也是如此,这两个索引:

KEY `tracking_id` (`tracking_id`)
KEY `tracking_id_tracking_object_id` (`tracking_id`,`tracking_object_id`)

可以删除 tracking_id 索引,因为它是多余的。

对于上面的查询,我建议添加一个覆盖索引:

KEY `tracking_items_IX3` (`tracking_id`,`tracking_type`,`tracking_object_id`)

-或者- 至少,一个非覆盖索引,其中这两列领先:

KEY `tracking_items_IX3` (`tracking_id`,`tracking_type`)
SELECT t.*
FROM (SELECT * FROM tracking WHERE manufacture 
                IN('1256703406078','9600048390403','1533405067830')) t
INNER JOIN (SELECT tracking_id, tracking_object_id, tracking_type FROM tracking_items
    WHERE tracking_type IN (1,2,3)) ti 
    ON (ti.tracking_id = t.id)
LEFT JOIN (SELECT car_id, FROM cars WHERE car_text LIKE '%europe%') c 
ON (c.car_id = ti.tracking_object_id AND ti.tracking_type = 1)
    LEFT JOIN (SELECT bike_id FROM bikes WHERE bike_text LIKE '%europe%') b 
ON (b.bike_id = ti.tracking_object_id AND ti.tracking_type = 2)
    LEFT JOIN (SELECT truck_id FROM trucks WHERE truck_text LIKE '%europe%') tr 
ON (tr.truck_id = ti.tracking_object_id AND ti.tracking_type = 3)
    ORDER BY t.date_last_activity ASC, t.id ASC

子查询在连接时执行得更快,如果它们要过滤掉大量记录。

tracking table 的子查询将过滤掉很多其他不需要的 manufacture 并导致更小的 table t 被加入。

类似地应用了 tracking_items table 的条件,因为我们只对 [=56 感兴趣=] 1,2 和 3;创建一个更小的 table ti。如果有很多tracking_objects,你甚至可以在这个子查询中添加跟踪对象过滤器。

tables 汽车、自行车、卡车 的类似方法及其各自的条件 包含欧洲的文本 帮助我们分别创建更小的 tables c,b,tr

还删除了 t.id 组,因为 t.id 是唯一的,我们正在对其执行内部连接和左连接或结果 table,因为没有必要。

最后,我只从每个 tables 中选择 所需的列,这也将减少内存 space 和 运行 时间的负载。

希望这对您有所帮助。请让我知道您的反馈和 运行 统计数据。