【问题标题】:Optimizing COUNT(DISTINCT) slowness even with covering indexes即使覆盖索引也优化 COUNT(DISTINCT) 慢
【发布时间】:2015-05-18 13:24:31
【问题描述】:

我们在MySql中有一个大约3000万条记录的表,下面是表结构

CREATE TABLE `campaign_logs` (
  `domain` varchar(50) DEFAULT NULL,
  `campaign_id` varchar(50) DEFAULT NULL,
  `subscriber_id` varchar(50) DEFAULT NULL,
  `message` varchar(21000) DEFAULT NULL,
  `log_time` datetime DEFAULT NULL,
  `log_type` varchar(50) DEFAULT NULL,
  `level` varchar(50) DEFAULT NULL,
  `campaign_name` varchar(500) DEFAULT NULL,
  KEY `subscriber_id_index` (`subscriber_id`),
  KEY `log_type_index` (`log_type`),
  KEY `log_time_index` (`log_time`),
  KEY `campid_domain_logtype_logtime_subid_index` (`campaign_id`,`domain`,`log_type`,`log_time`,`subscriber_id`),
  KEY `domain_logtype_logtime_index` (`domain`,`log_type`,`log_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 |

以下是我的查询

我正在做 UNION ALL 而不是使用 IN 操作

SELECT log_type,
       DATE_FORMAT(CONVERT_TZ(log_time,'+00:00','+05:30'),'%l %p') AS log_date,
       count(DISTINCT subscriber_id) AS COUNT,
       COUNT(subscriber_id) AS total
FROM stats.campaign_logs USE INDEX(campid_domain_logtype_logtime_subid_index)
WHERE DOMAIN='xxx'
  AND campaign_id='123'
  AND log_type = 'EMAIL_OPENED'
  AND log_time BETWEEN CONVERT_TZ('2015-02-01 00:00:00','+00:00','+05:30') AND CONVERT_TZ('2015-03-01 23:59:58','+00:00','+05:30')
GROUP BY log_date

UNION ALL

SELECT log_type,
       DATE_FORMAT(CONVERT_TZ(log_time,'+00:00','+05:30'),'%l %p') AS log_date,
       COUNT(DISTINCT subscriber_id) AS COUNT,
            COUNT(subscriber_id) AS total
FROM stats.campaign_logs USE INDEX(campid_domain_logtype_logtime_subid_index)
WHERE DOMAIN='xxx'
  AND campaign_id='123'
  AND log_type = 'EMAIL_SENT'
  AND log_time BETWEEN CONVERT_TZ('2015-02-01 00:00:00','+00:00','+05:30') AND CONVERT_TZ('2015-03-01 23:59:58','+00:00','+05:30')
GROUP BY log_date

UNION ALL

SELECT log_type,
       DATE_FORMAT(CONVERT_TZ(log_time,'+00:00','+05:30'),'%l %p') AS log_date,
       COUNT(DISTINCT subscriber_id) AS COUNT,
            COUNT(subscriber_id) AS total
FROM stats.campaign_logs USE INDEX(campid_domain_logtype_logtime_subid_index)
WHERE DOMAIN='xxx'
  AND campaign_id='123'
  AND log_type = 'EMAIL_CLICKED'
  AND log_time BETWEEN CONVERT_TZ('2015-02-01 00:00:00','+00:00','+05:30') AND CONVERT_TZ('2015-03-01 23:59:58','+00:00','+05:30')
GROUP BY log_date,

以下是我的解释声明

+----+--------------+---------------+-------+-------------------------------------------+-------------------------------------------+---------+------+--------+------------------------------------------+
| id | select_type  | table         | type  | possible_keys                             | key                                       | key_len | ref  | rows   | Extra                                    |
+----+--------------+---------------+-------+-------------------------------------------+-------------------------------------------+---------+------+--------+------------------------------------------+
|  1 | PRIMARY      | campaign_logs | range | campid_domain_logtype_logtime_subid_index | campid_domain_logtype_logtime_subid_index | 468     | NULL |  55074 | Using where; Using index; Using filesort |
|  2 | UNION        | campaign_logs | range | campid_domain_logtype_logtime_subid_index | campid_domain_logtype_logtime_subid_index | 468     | NULL | 330578 | Using where; Using index; Using filesort |
|  3 | UNION        | campaign_logs | range | campid_domain_logtype_logtime_subid_index | campid_domain_logtype_logtime_subid_index | 468     | NULL |   1589 | Using where; Using index; Using filesort |
| NULL | UNION RESULT | <union1,2,3>  | ALL   | NULL                                      | NULL                                      | NULL    | NULL |   NULL |                                          |
+----+--------------+---------------+-------+-------------------------------------------+-------------------------------------------+---------+------+--------+------------------------------------------+
  1. 我将 COUNT(subscriber_id) 更改为 COUNT(*) 并没有观察到性能提升。

2.我从查询中删除了 COUNT(DISTINCTsubscriber_id),然后我得到了巨大的 性能提升,我在大约 1.5 秒内得到结果,以前是 需要 50 秒 - 1 分钟。但我需要从查询中区分订阅者 ID 计数

以下是我从查询中删除 COUNT(DISTINCTsubscriber_id) 时的说明

+----+--------------+---------------+-------+-------------------------------------------+-------------------------------------------+---------+------+--------+-----------------------------------------------------------+
| id | select_type  | table         | type  | possible_keys                             | key                                       | key_len | ref  | rows   | Extra                                                     |
+----+--------------+---------------+-------+-------------------------------------------+-------------------------------------------+---------+------+--------+-----------------------------------------------------------+
|  1 | PRIMARY      | campaign_logs | range | campid_domain_logtype_logtime_subid_index | campid_domain_logtype_logtime_subid_index | 468     | NULL |  55074 | Using where; Using index; Using temporary; Using filesort |
|  2 | UNION        | campaign_logs | range | campid_domain_logtype_logtime_subid_index | campid_domain_logtype_logtime_subid_index | 468     | NULL | 330578 | Using where; Using index; Using temporary; Using filesort |
|  3 | UNION        | campaign_logs | range | campid_domain_logtype_logtime_subid_index | campid_domain_logtype_logtime_subid_index | 468     | NULL |   1589 | Using where; Using index; Using temporary; Using filesort |
| NULL | UNION RESULT | <union1,2,3>  | ALL   | NULL                                      | NULL                                      | NULL    | NULL |   NULL |                                                           |
+----+--------------+---------------+-------+-------------------------------------------+-------------------------------------------+---------+------+--------+-----------------------------------------------------------+
  1. 我通过删除 UNION ALL 分别运行了三个查询。一个查询耗时 32 秒,其他查询耗时 1.5 秒,但第一个查询处理大约 350K 条记录,而其他查询仅处理 2k 行

我可以通过省略 COUNT(DISTINCT...) 来解决我的性能问题,但我需要这些值。有没有办法重构我的查询,或添加索引或其他东西,以获取 COUNT(DISTINCT...) 值,但要快得多?

更新 以下信息是关于上表的数据分布

对于 1 个域 1 个广告系列 20 个日志类型 1k-200k 订阅者

我正在运行的上述查询,域有 180k+ 订阅者。

【问题讨论】:

  • 为什么不AND (log_type = 'EMAIL_OPENED' OR log_type = 'EMAIL_SENT' OR log_type = 'EMAIL_CLICKED')
  • 删除所有索引,只为 (domain,campaign_id,log_type,log_time) 创建一个组索引
  • 尝试在每个GROUP BY 之后添加ORDER BY NULL 这可能会消除文件排序。
  • 您的EXPLAIN 清楚地表明您的复合索引正在按您的意图使用。一些事情要尝试:1)将COUNT(subscriber_id)更改为COUNT(*),看看性能是否有所提高。 2)尝试摆脱COUNT(DISTINCT subscriber_id),看看性能是否有所提高。运行与UNION ALL 组合的三个子查询中的每一个,看看其中一个的性能是否比另外两个差。请使用这些测试的结果更新您的问题。
  • 这只是我对引擎内部情况的理解。它可能会激发一些想法。您的索引有助于在 30M 中快速找到这 350K 行。然后引擎必须读取所有这些 350K 行来对它们进行分组和计数。当没有 DISTINCT: 到 GROUP 它们时,引擎会根据 DATE_FORMAT 函数的结果对 350K 行进行排序,然后逐步遍历排序结果并以它们出现的任何顺序对行进行计数。当您添加DISTINCT 时,引擎必须在每个组内再次排序。一种嵌套排序。显然,这没有得到有效处理。

标签: mysql sql aggregate-functions query-performance mysql-variables


【解决方案1】:

如果没有count(distinct) 的查询速度更快,也许你可以做嵌套聚合:

SELECT log_type, log_date,
       count(*) AS COUNT, sum(cnt) AS total
FROM (SELECT log_type,
             DATE_FORMAT(CONVERT_TZ(log_time,'+00:00','+05:30'),'%l %p') AS log_date,
             subscriber_id, count(*) as cnt
      FROM stats.campaign_logs USE INDEX(campid_domain_logtype_logtime_subid_index)
      WHERE DOMAIN = 'xxx' AND
            campaign_id = '123' AND
            log_type IN ('EMAIL_SENT', 'EMAIL_OPENED', 'EMAIL_CLICKED') AND
            log_time BETWEEN CONVERT_TZ('2015-02-01 00:00:00','+00:00','+05:30') AND 
                             CONVERT_TZ('2015-03-01 23:59:58','+00:00','+05:30')
      GROUP BY log_type, log_date, subscriber_id
     ) l
GROUP BY logtype, log_date;

如果运气好的话,这将需要 2-3 秒而不是 50 秒。但是,您可能需要将其分解为子查询,以获得完整的性能。因此,如果这没有显着的性能提升,请将in 更改回= 类型之一。如果可行,那么union all 可能是必要的。

编辑:

另一种尝试是使用变量来枚举group by之前的值:

SELECT log_type, log_date, count(*) as cnt,
       SUM(rn = 1) as sub_cnt
FROM (SELECT log_type,
             DATE_FORMAT(CONVERT_TZ(log_time,'+00:00','+05:30'),'%l %p') AS log_date,
             subscriber_id,
             (@rn := if(@clt = concat_ws(':', campaign_id, log_type, log_time), @rn + 1,
                        if(@clt := concat_ws(':', campaign_id, log_type, log_time), 1, 1)
                       )
              ) as rn
      FROM stats.campaign_logs USE INDEX(campid_domain_logtype_logtime_subid_index) CROSS JOIN
           (SELECT @rn := 0)
      WHERE DOMAIN = 'xxx' AND
            campaign_id = '123' AND
            log_type IN ('EMAIL_SENT', 'EMAIL_OPENED', 'EMAIL_CLICKED') AND
            log_time BETWEEN CONVERT_TZ('2015-02-01 00:00:00', '+00:00', '+05:30') AND 
                             CONVERT_TZ('2015-03-01 23:59:58', '+00:00', '+05:30')
      ORDER BY log_type, log_date, subscriber_id
     ) t
GROUP BY log_type, log_date;

这仍然需要另一种数据,但它可能会有所帮助。

【讨论】:

  • 我认为如果您按 log_date 分组,您将不会从内部查询中的索引中受益,这是一个计算字段。 MySQL 将无法使用定义的索引对subscriber_id 进行排序和过滤。因此,按subscriber_id 分组相当于根据性能计算不同的订阅者。
  • @亚当。 . . (1) 它将使用索引,只是不像使用union 的 OPs 查询那样完全。我不知道数据的分布,所以这可能有足够的选择性。在最后一段中,我试图建议可能需要带有union all 的版本。 (2) 虽然输出相同,但底层方法不同,count(distinct) 可能比两个聚合慢。
  • 当然,我没想到您的查询效率会降低。它将使用索引 (campaign_id,domain,log_type,log_time) 将行与 where 条件匹配并按 log_type 分组。但我认为查询中较慢的部分仍然是(计算的)log_date 不是索引的一部分,因此计数/分组订阅者会很慢,因为它不会从索引中获利。如果 log_date 是表结构的一部分,情况会有所不同。
  • @GordonLinoff 我尝试了您提到的查询,但性能没有任何提高。我在我的问题中提到了表格的数据分布。请检查。
  • @rams 。 . .如果您只有 log_type = 'EMAIL_SENT' 而不是 IN ,性能会更快吗?我试图弄清楚在所有条件下使用索引的重要性。
【解决方案2】:

回答你的问题:

有没有办法重构我的查询,或添加索引或其他东西,以 获取 COUNT(DISTINCT...) 值,但要快得多?

是的,不要按计算字段分组(不要按函数结果分组)。相反,预先计算它,将其保存到持久列中,并将此持久列包含到索引中。

我会尝试执行以下操作,看看它是否会显着改变性能。

1) 简化查询,专注于某一部分。 三个中只留下一个运行时间最长的SELECT,在调整期间去掉UNION。优化最长的SELECT 后,添加更多并检查完整查询的工作方式。

2) 按函数结果分组并不能让引擎有效地使用索引。 使用此函数的结果向表中添加另一列(起初是暂时的,只是为了检查想法)。据我所知,您希望按 1 小时分组,因此添加列 log_time_hour datetime 并将其设置为 log_time 舍入/截断到最接近的小时(保留日期部分)。

使用新列添加索引:(domain, campaign_id, log_type, log_time_hour, subscriber_id)。索引中前三列的顺序无关紧要(因为您使用相等比较查询中的某个常量,而不是范围),但要使它们与查询中的顺序相同。或者,更好的是,在索引定义和查询中按选择性顺序制作它们。如果您有 100,000 活动、1000 域和 3 日志类型,则按以下顺序排列它们:campaign_id, domain, log_type。这应该没什么关系,但值得检查。 log_time_hour 必须在索引定义中排在第四位,subscriber_id 在最后。

在查询中使用WHEREGROUP BY 中的新列。确保在GROUP BY 中包含所有需要的列:log_typelog_time_hour

您需要COUNTCOUNT(DISTINCT) 吗?先只留下COUNT 并测量性能。只留下COUNT(DISTINCT) 并测量性能。保留两者并衡量性能。看看他们是如何比较的。

SELECT log_type,
       log_time_hour,
       count(DISTINCT subscriber_id) AS distinct_total,
       COUNT(subscriber_id) AS total
FROM stats.campaign_logs
WHERE DOMAIN='xxx'
  AND campaign_id='123'
  AND log_type = 'EMAIL_OPENED'
  AND log_time_hour >= '2015-02-01 00:00:00' 
  AND log_time_hour <  '2015-03-02 00:00:00'
GROUP BY log_type, log_time_hour

【讨论】:

    【解决方案3】:
    SELECT log_type,
           DATE_FORMAT(CONVERT_TZ(log_time,'+00:00','+05:30'),'%l %p') AS log_date,
           count(DISTINCT subscriber_id) AS COUNT,
           COUNT(subscriber_id) AS total
    FROM stats.campaign_logs USE INDEX(campid_domain_logtype_logtime_subid_index)
    WHERE DOMAIN='xxx'
      AND campaign_id='123'
      AND log_time BETWEEN CONVERT_TZ('2015-02-01 00:00:00','+00:00','+05:30') AND CONVERT_TZ('2015-03-01 23:59:58','+00:00','+05:30')
    GROUP BY log_type, log_date
    

    如果需要,添加AND log_type IN ('EMAIL_OPENED', 'EMAIL_SENT', 'EMAIL_CLICKED')

    【讨论】:

    • 我试过这种方式,没有得到更好的性能。谢谢
    • 为什么会有帮助?这个答案是没有用的,没有额外的解释。你甚至没有指出你改变了什么。
    • 好的,没有提高性能...我希望执行速度更快,因为读取表的次数更少。无论如何,它会让代码更容易理解和维护......
    【解决方案4】:

    我会尝试对您正在使用的索引进行其他排序,移动subscriber_id,看看效果如何。通过将具有更高基数的列向上移动可以获得更好的结果。

    起初,我认为它可能只使用了索引的一部分(根本没有访问subscriber_id)。如果它不能使用subscriber_id,那么将它向上移动到索引树会导致它运行得更慢,这至少会告诉你它不能使用它。

    我想不出还有什么可以玩的。

    【讨论】:

      【解决方案5】:
      1. subscriber_id 在您的密钥中没有用,因为您在计算不同订阅者之前按密钥外部的计算字段 (log_date) 进行分组。它解释了为什么这么慢,因为 MySQL 必须在不使用密钥的情况下对重复的订阅者进行排序和过滤。

      2. 您的 log_time 条件可能有错误:您的选择应该有相反的时区转换(即'+05:30','+00:00'),但它不会对您的查询时间有任何重大影响。

      3. 您可以通过log_type IN (...)log_type, log_date 分组来避免“全部联合”

      最有效的解决方案是在您的数据库架构中添加一个 mid-hour 字段,并在其中设置一天中 48 个中午的时间之一(并注意中午时区)。所以你可以在campaign_id,domain,log_type,log_mid_hour,subscriber_id上使用索引

      这将是相当多余的,但会提高速度。

      所以这应该会导致您的表中进行一些初始化: 小心:不要在生产台上测试这个

      ALTER TABLE campaign_logs
         ADD COLUMN log_mid_hour TINYINT AFTER log_time;
      
      UPDATE campaign_logs SET log_mid_hour=2*HOUR(log_time)+IF(MINUTE(log_time)>29,1,0);
      
      ALTER TABLE campaign_logs
      ADD INDEX(`campaign_id`,`domain`,`log_time`,`log_type`,`log_mid_hour`,`subscriber_id`);
      

      您还必须在脚本中设置 log_mid_hour 以供将来记录。

      您的查询将变为(对于 11 小时的时间转换)

      SELECT log_type,
         MOD(log_mid_hour+11, 48) tz_log_mid_hour,
         COUNT(DISTINCT subscriber_id) AS COUNT,
         COUNT(subscriber_id) AS total
      FROM stats.campaign_logs
      WHERE DOMAIN='xxx'
         AND campaign_id='123'
         AND log_type IN('EMAIL_SENT', 'EMAIL_OPENED','EMAIL_CLICKED')
         AND log_time BETWEEN CONVERT_TZ('2015-02-01 00:00:00','+05:30','+00:00')   
         AND CONVERT_TZ('2015-03-01 23:59:58','+05:30','+00:00')
      GROUP BY log_type, log_mid_hour;
      

      这将为您提供每个中午的计数,以充分利用您的索引。

      【讨论】:

        【解决方案6】:

        我有一个非常相似的问题,在 SO 上发布,并得到了一些很大的帮助。这是线程:MySQL MyISAM slow count() query despite covering index

        简而言之,我发现我的问题与查询或索引无关,而与我设置表和 MySQL 的方式有关。当我执行以下操作时,我完全相同的查询变得更快:

        1. 切换到 InnoDB(您已经在使用)
        2. 将 CHARSET 切换为 ASCII。如果您不需要 utf8,则需要 3 倍的空间(以及搜索时间)。
        3. 使每个列的大小尽可能小,尽可能不为空。
        4. 增加了 MySQL 的 InnoDB 缓冲池大小。如果这是一台专用机器,许多建议是将其增加到 RAM 的 70%。
        5. 我按覆盖索引对表进行排序,通过 SELECT INTO OUTFILE 将其写出,然后将其重新插入到新表中。这会按搜索顺序对所有记录进行物理排序。

        我不知道这些更改中的哪一个解决了我的问题(因为我不科学并且没有一次尝试一个),但它使我的查询速度提高了 50-100 倍。 YMMV。

        【讨论】:

          猜你喜欢
          • 2021-09-17
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          • 2020-05-28
          • 2016-08-14
          • 1970-01-01
          • 1970-01-01
          • 1970-01-01
          相关资源
          最近更新 更多