Оптимизация медленности COUNT(DISTINCT) даже с покрывающими индексами

У нас есть таблица в MySql с примерно 30 миллионами записей, следующая структура таблицы

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 (DISTINCT subscriber_id) из запроса, после чего получил огромный прирост производительности, я получаю результаты примерно через 1,5 секунды, раньше это занимало от 50 секунд до 1 минуты. Но мне нужно отдельное количество subscriber_id из запроса

Ниже объясняется, когда я удаляю COUNT(DISTINCT subscriber_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 секунды каждый, но первый запрос обрабатывает около 350 тысяч записей, а другие имеют дело только с 2 тысячами строк.

Я мог бы решить свою проблему с производительностью, опустив COUNT(DISTINCT...), но мне нужны эти значения. Есть ли способ реорганизовать мой запрос или добавить индекс или что-то еще, чтобы получить значения COUNT(DISTINCT...), но намного быстрее?

ОБНОВЛЕНИЕ следующая информация касается распределения данных в приведенной выше таблице.

на 1 домен 1 кампания 20 log_types 1к-200к подписчиков

Приведенный выше запрос я выполняю для домена с более чем 180 000 подписчиков.


person Rams    schedule 16.03.2015    source источник
comment
Почему бы и нет AND (log_type = 'EMAIL_OPENED' OR log_type = 'EMAIL_SENT' OR log_type = 'EMAIL_CLICKED')   -  person Tschallacka    schedule 16.03.2015
comment
удалить весь индекс и просто создать групповой индекс для (domain,campaign_id,log_type,log_time)   -  person Ashok sri    schedule 16.03.2015
comment
Попробуйте добавить ORDER BY NULL после каждого GROUP BY, это может избавиться от файловой сортировки.   -  person GarethD    schedule 16.03.2015
comment
Ваш EXPLAIN дает понять, что ваш составной индекс используется по назначению. Вот что можно попробовать: 1) изменить COUNT(subscriber_id) на COUNT(*) и посмотреть, улучшится ли производительность. 2) попробуйте избавиться от COUNT(DISTINCT subscriber_id) и посмотрите, улучшится ли производительность. Запустите каждый из трех подзапросов, которые вы комбинируете с UNION ALL, и посмотрите, не уступает ли один из них производительности по сравнению с двумя другими. Пожалуйста, обновите свой вопрос с результатами этих тестов.   -  person O. Jones    schedule 16.03.2015
comment
Попробуйте GROUP BY HOUR( CONVERT_TZ(log_time,'+00:00','+05:30')) вместо столбца набора результатов log_date в формате %l %p. Числовая группировка может облегчить обработку.   -  person O. Jones    schedule 16.03.2015
comment
@GarethD Я пытался, как вы упомянули, ORDER BY NULL, к сожалению, я не добился лучшей производительности.   -  person Rams    schedule 16.03.2015
comment
@OllieJones Я обновил свой вопрос в соответствии с вашим комментарием.   -  person Rams    schedule 17.03.2015
comment
Ага! Это операция DISTINCT замедляет работу.   -  person O. Jones    schedule 17.03.2015
comment
Это просто мое понимание того, что происходит внутри двигателя. Это может вызвать некоторые идеи. Ваш индекс помогает быстро найти эти 350 тыс. строк среди 30 млн. Затем движок должен прочитать все эти 350 тысяч строк, чтобы сгруппировать и подсчитать их. Когда нет DISTINCT: для GROUP их, движок сортирует 350 тысяч строк по результату функции DATE_FORMAT, а затем проходит по результату сортировки и подсчитывает строки в любом порядке их появления. Когда вы добавляете DISTINCT, движок должен снова сортировать внутри каждой группы. Вид вложенной сортировки. Судя по всему, с этим справляются неэффективно.   -  person Vladimir Baranov    schedule 19.03.2015
comment
Вам действительно нужно количество разных подписчиков в 3 часа ночи за месяц? И другой номер для 4 утра? Это кажется странным способом нарезки данных.   -  person Rick James    schedule 20.03.2015
comment
Вы понимаете, что вы на 86399 секунд больше, чем ровно на один месяц? Похоже на ошибку.   -  person Rick James    schedule 20.03.2015
comment
Согласны ли вы, что значение COUNT(DISTINCT subscriber_id) будет меньше примерно на 1 %? Если это так (и вы исправите BETWEEN), я могу объяснить, как пересмотреть запрос (и другие вещи), чтобы ускорить его как минимум в 100 раз.   -  person Rick James    schedule 20.03.2015
comment
Сколько времени займет запрос, если вы перепишете его с использованием IN (то есть без union) и удалите count(distinct)?   -  person Gordon Linoff    schedule 21.03.2015
comment
@GordonLinoff Если я удалю количество (отличных), тогда запрос будет таким быстрым. нет большой разницы между IN и UNION.   -  person Rams    schedule 23.03.2015


Ответы (6)


Если запрос без 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;

Для этого по-прежнему требуются данные другого типа, но это может помочь.

person Gordon Linoff    schedule 21.03.2015
comment
Я думаю, что вы не выиграете от индекса во внутреннем запросе, если вы группируете по log_date, которое является вычисляемым полем. MySQL не сможет использовать определенный индекс для сортировки и фильтрации subscriber_id. Таким образом, группировка по subscriber_id будет эквивалентна подсчету отдельных подписчиков с точки зрения производительности. - person Adam; 21.03.2015
comment
@Адам. . . (1) Он будет использовать индекс, но не так полно, как в запросах OP с union. Я не знаю распределения данных, так что это может быть достаточно избирательно. В последнем абзаце я попытался предположить, что может понадобиться версия с union all. (2) Хотя выходные данные одинаковы, базовые методы различаются, и count(distinct) может быть медленнее, чем две агрегации. - person Gordon Linoff; 21.03.2015
comment
Конечно, я не думал, что ваш запрос будет менее эффективным. Он будет использовать индекс (campaign_id,domain,log_type,log_time) для сопоставления строк с условием where и группировкой по типу_журнала. Но я думаю, что более медленная часть запроса по-прежнему заключается в том, что (вычисленная) log_date не является частью индекса, и поэтому подсчет/группировка подписчиков будет медленным, поскольку он не получит прибыль от индекса. Все было бы иначе, если бы log_date был частью структуры таблицы. - person Adam; 21.03.2015
comment
@GordonLinoff Я попробовал запрос, как вы упомянули, но не получил никакого увеличения производительности. Я упомянул о распределении данных таблицы в своем вопросе. пожалуйста, проверьте. - person Rams; 23.03.2015
comment
@рамс . . . Будет ли производительность намного выше, если у вас будет только log_type = 'EMAIL_SENT' вместо IN? Я пытаюсь понять, насколько важно использовать индекс для всех условий. - person Gordon Linoff; 24.03.2015
comment
@GordonLinoff нет, даже если я использую log_type = 'EMAIL_SENT' , это занимает много времени, потому что есть больше записей, совпадающих с log_type = 'EMAIL_SENT' и с другим subscriber_id. - person Rams; 24.03.2015

Чтобы ответить на ваш вопрос:

Есть ли способ реорганизовать мой запрос или добавить индекс или что-то еще, чтобы получить значения 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 последним.

В запросе используйте новый столбец в WHERE и в GROUP BY. Убедитесь, что вы включили все необходимые столбцы в GROUP BY: и log_type, и log_time_hour.

Вам нужны и COUNT, и COUNT(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
person Vladimir Baranov    schedule 19.03.2015

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').

person jarlh    schedule 16.03.2015
comment
Я пробовал таким образом, у меня не было лучшей производительности. Спасибо. - person Rams; 16.03.2015
comment
Почему это помогает? Этот ответ бесполезен без дополнительных объяснений. Вы даже не указали, что вы изменили. - person GarethD; 16.03.2015
comment
Хорошо, производительность не улучшилась... Я ожидаю несколько более быстрого выполнения, так как таблица читается меньше раз. В любом случае, это облегчит понимание и поддержку кода... - person jarlh; 16.03.2015

Я бы попробовал другие порядки индекса, который вы используете, перемещая Subscriber_id вокруг, и посмотрите, каков эффект. Возможно, вы можете получить лучшие результаты, переместив столбцы с большей кардинальностью.

Сначала я подумал, что может использоваться только часть индекса (вообще не попадая в subscriber_id). Если он не может использовать subscriber_id, то перемещение его вверх по дереву индексов заставит его работать медленнее, что, по крайней мере, скажет вам, что он не может его использовать.

Я не могу придумать, с чем еще можно поиграть.

person juacala    schedule 19.03.2015

  1. subscriber_id бесполезен в вашем ключе, потому что вы группируете по вычисляемому полю вне ключа (log_date) перед подсчетом отдельных подписчиков. Это объясняет, почему это так медленно, потому что MySQL должен сортировать и фильтровать повторяющихся подписчиков без использования ключа.

  2. Может быть ошибка с вашим условием log_time: у вас должно быть преобразование часового пояса, противоположное вашему выбору (т.е. '+05:30','+00:00'), но оно не будет иметь серьезного влияния на время вашего запроса.

  3. Вы можете избежать «объединения всех», выполнив log_type IN (...) и сгруппировав log_type, log_date

Лучшим эффективным решением было бы добавить поле середины часа в вашу схему базы данных и установить там одно из 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;

Это даст вам подсчет для каждого часа в середине, используя все преимущества вашего индекса.

person Adam    schedule 20.03.2015

У меня была очень похожая проблема, размещенная здесь, на SO, и я получил большую помощь. Вот ветка: запрос медленного подсчета MySQL MyISAM(), несмотря на покрытие индекса

Короче говоря, я обнаружил, что моя проблема НИЧЕГО не связана с запросом или индексами, а ВСЁ связана с тем, как я настроил таблицы и MySQL. Мой точно такой же запрос стал намного быстрее, когда я:

  1. Перешел на InnoDB (который вы уже используете)
  2. Переключил CHARSET на ASCII. Если вам не нужен utf8, он занимает в 3 раза больше места (и времени на поиск).
  3. Сделайте размер каждого столбца как можно меньшим, по возможности не нулевым.
  4. Увеличен размер буферного пула MySQL InnoDB. Многие рекомендуют увеличить его до 70% вашей оперативной памяти, если это выделенная машина.
  5. Я отсортировал свою таблицу по индексу покрытия, записал ее с помощью SELECT INTO OUTFILE, а затем снова вставил в новую таблицу. Это физически сортирует все записи в порядке поиска.

Я понятия не имею, какое из этих изменений решило мою проблему (потому что я был ненаучен и не пробовал их по одному), но это сделало мои запросы в 50-100 раз быстрее. YMMV.

person hpf    schedule 26.03.2015