替代 COUNT 用于 innodb 以防止表扫描?

Posted

技术标签:

【中文标题】替代 COUNT 用于 innodb 以防止表扫描?【英文标题】:Alternative to COUNT for innodb to prevent table scan? 【发布时间】:2015-06-09 05:27:10 【问题描述】:

我已经设法整理出一个符合我需要的查询,尽管它比我希望的要复杂。但是,对于表的大小,查询比它应该的要慢(0.17s)。原因,基于下面提供的EXPLAIN,是因为在innodb 引擎的WHERE 子句中有COUNTmeta_relationships 表进行表扫描。

查询:

SELECT
posts.post_id,posts.post_name,
GROUP_CONCAT(IF(meta_data.type = 'category', meta.meta_name,null)) AS category,
GROUP_CONCAT(IF(meta_data.type = 'tag', meta.meta_name,null)) AS tag
FROM posts
RIGHT JOIN meta_relationships ON (posts.post_id = meta_relationships.object_id)
LEFT JOIN meta_data ON meta_relationships.meta_data_id = meta_data.meta_data_id
LEFT JOIN meta ON meta_data.meta_id = meta.meta_id
WHERE meta.meta_name = computers AND meta_relationships.object_id 
NOT IN (SELECT meta_relationships.object_id FROM meta_relationships
        GROUP BY meta_relationships.object_id HAVING count(*) > 1)
GROUP BY meta_relationships.object_id

此特定查询选择仅具有 computers 类别的帖子。 count > 1 的目的是排除包含computers/hardwarecomputers/software 等的帖子。选择的类别越多,计数越高。

理想情况下,我想让它像这样运行:

WHERE meta.meta_name IN ('computers') AND meta_relationships.meta_order IN (0)

WHERE meta.meta_name IN ('computers','software') 
AND meta_relationships.meta_order IN (0,1)

等等。

但不幸的是这不起作用,因为它没有考虑到可能存在meta_relationships.meta_order = 2。

我试过了……

WHERE meta.meta_name IN ('computers')
GROUP BY meta_relationships.meta_order
HAVING meta_relationships.meta_order IN (0) AND meta_relationships.meta_order NOT IN (1)

但它没有返回正确的行数。

解释:

id  select_type   table               type    possible_keys          key               key_len ref                                   rows   Extra   
1   PRIMARY       meta                ref     PRIMARY,idx_meta_name  idx_meta_name     602     const                                 1      Using where; Using index; Using temporary; Using filesort
1   PRIMARY       meta_data           ref     PRIMARY,idx_meta_id    idx_meta_id       8       database.meta.meta_id                 1  
1   PRIMARY       meta_relationships  ref     idx_meta_data_id       idx_meta_data_id  8       database.meta_data.meta_data_id       11     Using where
1   PRIMARY       posts               eq_ref  PRIMARY                PRIMARY           4       database.meta_relationships.object_id 1  
2   MATERIALIZED  meta_relationships  index   NULL                   idx_object_id     4       NULL                                  14679  Using index

表/索引: 此表包含类别和标签名称。索引: 主键 (meta_id)、键 idx_meta_name (meta_name)元数据 此表包含有关类别和标签的附加数据,例如类型(类别或标签)、描述、父级、计数。索引: 主键 (meta_data_id)、键 idx_meta_id (meta_id)meta_relationships 这是一个连接/查找表。它包含posts_id 的外键、meta_data_id 的外键,还包含类别的顺序。索引: 主键(relationship_id),键idx_object_idobject_id),键idx_meta_data_idmeta_data_id

计数允许我只选择具有正确类别级别的帖子。例如,计算机类别的帖子只有计算机类别,但也有计算机/硬件的帖子。计数过滤掉包含这些额外类别的帖子。我希望这是有道理的。 我相信优化查询的关键是完全摆脱COUNTCOUNT 的替代方法可能是使用 meta_relationships.meta_ordermeta_data.parentmeta_relationships 表将快速增长,并且以当前大小(约 15K 行)我希望在 100 秒而不是 10 秒内实现执行时间。 由于每个类别/标签的WHERE 子句中需要有多个条件,因此首选针对动态查询优化的任何答案。 我用sample data 创建了一个IDE。

如何优化此查询?

编辑:

我一直无法找到解决这个问题的最佳方案。这实际上是 smcjones 建议改进索引的组合,我建议为此执行 EXPLAIN 并查看 EXPLAIN Output Format 然后将索引更改为能够为您提供最佳性能的任何内容。 此外,hpf 建议在总计数中添加另一列也有很大帮助。最后,在更改索引后,我最终使用了这个查询。

SELECT posts.post_id,posts.post_name,
GROUP_CONCAT(IF(meta_data.type = 'category', meta.meta_name,null)) AS category,
GROUP_CONCAT(IF(meta_data.type = 'tag', meta.meta_name,null)) AS tag
FROM posts
JOIN meta_relationships ON meta_relationships.object_id = posts.post_id
JOIN meta_data ON meta_relationships.meta_data_id = meta_data.meta_data_id
JOIN meta ON meta_data.meta_id = meta.meta_id
WHERE posts.meta_count = 2
GROUP BY posts.post_id
HAVING category = 'category,subcategory'

除去COUNT 后,最大的性能杀手是GROUP BYORDER BY,但索引是你最好的朋友。我了解到,在做GROUP BY 时,WHERE 子句非常重要,越具体越好。

【问题讨论】:

能否为每个表提供SHOW CREATE TABLE tablename,尤其是meta_relation,以便我们查看索引是由什么组成的。 解释(英文)NOT IN的目的;这就是表扫描的位置。 (你很幸运——在旧版本中,它的运行速度会非常慢。) @RickJames - 其目的是消除任何具有多个类别或标签的object_id 对我来说有点像meta。您要选择最多有一个标签的帖子吗? @LeGEC - 在示例查询中是的,但我需要能够选择任意数量的类别。 【参考方案1】:

看看这是否给你正确的答案,可能更快:

SELECT  p.post_id, p.post_name,
        GROUP_CONCAT(IF(md.type = 'category', meta.meta_name, null)) AS category,
        GROUP_CONCAT(IF(md.type = 'tag', meta.meta_name, null)) AS tag
    FROM  
      ( SELECT  object_id
            FROM  meta_relation
            GROUP BY  object_id
            HAVING  count(*) = 1 
      ) AS x
    JOIN  meta_relation AS mr ON mr.object_id = x.object_id
    JOIN  posts AS p ON p.post_id = mr.object_id
    JOIN  meta_data AS md ON mr.meta_data_id = md.meta_data_id
    JOIN  meta ON md.meta_id = meta.meta_id
    WHERE  meta.meta_name = ?
    GROUP BY  mr.object_id 

【讨论】:

不幸的是,这个查询在 0.16 秒时只比我的查询快一点。它还会读取meta_relation 中的所有行。 我想不出在不阅读所有行的情况下进行 HAVING 的方法。或者至少是您似乎拥有的一个索引的所有行,因为它说“使用索引”。 哦,我还有一个想法——但这取决于WHERE meta.meta_name = ? 的选择性;是吗? WHERE meta.meta_name = ? 可以包含多个类别和标签。【参考方案2】:

既然 HAVING 似乎是问题所在,您可以改为在帖子表中创建一个标志字段并使用它吗?如果我正确理解了查询,那么您正在尝试查找只有一个 meta_relationship 链接的帖子。如果您在您的帖子表中创建了一个字段,该字段要么是该帖子的元关系的计数,要么是一个布尔标志,用于表示是否只有一个,并且当然对其进行索引,那可能会快得多。如果帖子被编辑,这将涉及更新字段。

所以,考虑一下:

在帖子表中添加一个名为“num_meta_rel”的新字段。它可以是一个未签名的 tinyint,只要您的任何一篇文章的标签都不会超过 255 个。

像这样更新字段:

UPDATE posts
SET num_meta_rel=(SELECT COUNT(object_id) from meta_relationships WHERE object_id=posts.post_id);

此查询需要一些时间才能运行,但一旦完成,您就可以预先计算所有计数。请注意,这可以通过连接更好地完成,但 SQLite (Ideone) 只允许子查询。

现在,您像这样重写您的查询:

SELECT
posts.post_id,posts.post_name,
GROUP_CONCAT(IF(meta_data.type = 'category', meta.meta_name,null)) AS category,
GROUP_CONCAT(IF(meta_data.type = 'tag', meta.meta_name,null)) AS tag
FROM posts
RIGHT JOIN meta_relationships ON (posts.post_id = meta_relationships.object_id)
LEFT JOIN meta_data ON meta_relationships.meta_data_id = meta_data.meta_data_id
LEFT JOIN meta ON meta_data.meta_id = meta.meta_id
WHERE meta.meta_name = computers AND posts.num_meta_rel=1
GROUP BY meta_relationships.object_id

如果我做对了,可运行代码在这里:http://ideone.com/ZZiKgx

请注意,如果帖子有与之关联的新标签,此解决方案要求您更新 num_meta_rel(选择一个更好的名称,那个名称太糟糕了...)。但这应该比一遍又一遍地扫描整个表格要快得多。

【讨论】:

这不是真正基于类别/标签的计数,计数允许我只选择具有正确类别级别的帖子。例如,计算机类别的帖子只有计算机类别,但也有计算机/硬件的帖子。计数过滤掉包含这些额外类别的帖子。我希望这是有道理的。 对。问题是子查询:SELECT meta_relationships.object_id FROM meta_relationships GROUP BY meta_relationships.object_id HAVING count(*) > 1。这需要读取整个表(或索引)并对其进行分组,然后是计数 >1 的所有行被丢弃。有关更多详细信息,请参阅我上面的编辑。 很高兴您能理解这个问题。我对有另一个专栏要更新并不感到兴奋,但如果它有效,这可能是要走的路。 meta_relationships.meta_order 实际上的功能与您提议的方式大致相同。但是当我执行WHERE meta.meta_name = computers AND meta_relationships.meta_order=1 时,它仍然会返回带有计算机类别的每一行,因为也可能存在 meta_order = 2。理想情况下,这是我想让它工作的方式。目前无法测试您的解决方案。 只有当您只想匹配“计算机”(或您要查找的任何标签)始终位于第一个位置的行时,使用 meta_order 才有效。在这种情况下,您可以使用 OUTER JOIN 在位置 1 中查找“计算机”的 id,在位置 2 中查找 NULL(这意味着没有其他标签。 永远排在第一位,第二个类别排在第二位,以此类推【参考方案3】:

结合优化查询AND优化您的表,您将获得快速查询。但是,如果没有优化的表,您将无法进行快速查询。

这一点我怎么强调都不为过:如果您的表结构正确且索引数量正确,那么您不应该在 GROUP BY...HAVING 之类的查询上遇到任何全表读取,除非您是故意这样做的.

根据您的示例,我创建了this SQLFiddle。

将其与 SQLFiddle #2 进行比较,我在其中添加了索引并针对 meta.meta_naame 添加了 UNIQUE 索引。

根据我的测试,Fiddle #2 更快。

优化您的查询

这个查询让我发疯了,即使在我提出索引是优化它的最佳方式之后也是如此。尽管我仍然认为该表是提高性能的最大机会,但似乎必须有更好的方法在 mysql 中运行此查询。我在解决这个问题后得到了启示,并使用了以下查询(见in SQLFiddle #3):

SELECT posts.post_id,posts.post_name,posts.post_title,posts.post_description,posts.date,meta.meta_name
   FROM posts
   LEFT JOIN meta_relationships ON meta_relationships.object_id = posts.post_id
   LEFT JOIN meta_data ON meta_relationships.meta_data_id = meta_data.meta_data_id
   LEFT JOIN meta ON meta_data.meta_id = meta.meta_id
   WHERE meta.meta_name = 'animals'
   GROUP BY meta_relationships.object_id
   HAVING sum(meta_relationships.object_id) = min(meta_relationships.object_id);

GROUP BY 上的HAVING sum() = min() 应该检查每种类型是否有多个记录。显然,每次记录出现时,总和都会增加更多。 (编辑:在随后的测试中,这似乎与count(meta_relationships.object_id) = 1 具有相同的影响。哦,重点是我相信您可以删除子查询并获得相同的结果)。

我想明确一点,如果对我提供给您的查询进行任何优化,您不会注意到太多,除非 WHERE meta.meta_name = 'animals' 部分正在查询索引(最好是唯一索引,因为我怀疑您需要的不仅仅是其中之一,它将防止意外重复数据)。

所以,不是这样的表格:

CREATE TABLE meta_data (
  meta_data_id BIGINT,
  meta_id BIGINT,
  type VARCHAR(50),
  description VARCHAR(200),
  parent BIGINT,
  count BIGINT);

您应该确保像这样添加主键和索引:

CREATE TABLE meta_data (
  meta_data_id BIGINT,
  meta_id BIGINT,
  type VARCHAR(50),
  description VARCHAR(200),
  parent BIGINT,
  count BIGINT,
  PRIMARY KEY (meta_data_id,meta_id),
  INDEX ix_meta_id (meta_id)
);

不要过度,但每个表都应该有一个主键,并且任何时候你聚合或查询一个特定的值,都应该有索引。

当不使用索引时,MySQL 将遍历表的每一行,直到找到您想要的。在您这样一个有限的示例中,这不会花费太长时间(尽管它仍然明显慢),但是当您添加数千或更多记录时,这将变得异常痛苦。

以后,在查看您的查询时,请尝试确定您的全表扫描发生在哪里,并查看该列上是否有索引。一个好的起点是您在聚合或使用 WHERE 语法的任何地方。

关于count 列的注释

我还没有发现将count 列放入表中会有帮助。它可能导致一些非常严重的完整性问题。如果一个表被适当优化,它应该很容易使用count() 并获取当前计数。如果你想把它放在一个表中,你可以使用VIEW,尽管这不是最有效的拉取方式。

count 列放入表中的问题是您需要使用TRIGGER 或更糟的应用程序逻辑来更新该计数。随着您的程序向外扩展,逻辑可能会丢失或被埋没。添加该列是与规范化的偏差,当发生这种情况时,应该有一个非常很好的理由。

关于是否有曾经这样做的充分理由存在一些争论,但我认为我最好不要参与这种争论,因为双方都有很大的争论。相反,我会选择一个小得多的战斗,并说在这个用例中,我认为这给您带来的麻烦多于好处,因此它可能值得进行 A/B 测试。

【讨论】:

虽然我同意你的观点,索引需要改进,但数据库足够小,不会产生太大的影响。我认为显着提高速度的关键是优化查询本身并防止表扫描。 @EternalHour 你不够了解,无法做出这样的陈述。无论“优化”如何,索引(用于 yanks 的索引)都会阻止表扫描。 @DavidSoussan - 我倾向于同意你的观点,因为优化器会考虑“索引”来执行查询。但不管你认为我知道什么,我确实已经建立了索引(与建议的相同),但我仍然不相信这是导致性能问题的原因。 我不确定您的 SQL 是否正确,因为如果您尝试将 PRIMARY KEY 放在 meta_data.meta_data_id 上,那么它将失败,因为三行共享“ID 10”。更新我的答案以反映这样的事情。 如果您在第一次运行时按照我对发球台的说明进行操作,则您的索引未优化。您需要针对meta.meta_nameUNIQUE 约束。否则,当您搜索“动物”或“娱乐”或“计算机”或其他任何内容时,该表将不可避免地进行全表扫描。【参考方案4】:

很遗憾,我无法测试性能,

但请使用您的真实数据尝试我的查询:

http://sqlfiddle.com/#!9/81b29/13

SELECT
posts.post_id,posts.post_name,
GROUP_CONCAT(IF(meta_data.type = 'category', meta.meta_name,null)) AS category,
GROUP_CONCAT(IF(meta_data.type = 'tag', meta.meta_name,null)) AS tag
FROM posts
INNER JOIN (
  SELECT meta_relationships.object_id
   FROM meta_relationships 
   GROUP BY meta_relationships.object_id 
   HAVING count(*) < 3
  ) mr ON mr.object_id = posts.post_id
LEFT JOIN meta_relationships ON mr.object_id = meta_relationships.object_id
LEFT JOIN meta_data ON meta_relationships.meta_data_id = meta_data.meta_data_id
INNER JOIN (
  SELECT * 
  FROM meta
  WHERE  meta.meta_name = 'health'
  ) meta ON meta_data.meta_id = meta.meta_id
GROUP BY posts.post_id

【讨论】:

感谢亚历克斯的回答。这个查询其实更快@0.11s,但是还是有表扫描:( 您能提供更多数据用于小提琴和调试吗? 当然,您需要什么数据?在小提琴中,您可以看到meta_relationships 有 18 行。如果您执行EXPLAIN,它显示已读取 18 行(表扫描),这就是我要避免的。该查询返回 2 行,因此我希望在 EXPLAIN 中看到 meta_relationships 的 2 行。 我只需要更多数据样本来查看速度改进(如果有的话) 那张表中有 15,000 行,我无法将它们添加到小提琴中。如果可以避免表扫描,性能会自动提高。【参考方案5】:

使用

sum(1)

而不是

count(*)

【讨论】:

这样的结果是一样的,但是慢了一点。

以上是关于替代 COUNT 用于 innodb 以防止表扫描?的主要内容,如果未能解决你的问题,请参考以下文章

MySQL 5.7下InnoDB对COUNT(*)的优化

给你100万条数据的一张表,你将如何查询优化?

CentOSMysql性能分析

MYSQL

ThinkPHP5查询当前表引擎,以及InnoDB表引擎下count(*)查询效率低的问题

mysql innodb select count 优化解决方案