Как оптимизировать очень медленный SELECT с помощью LEFT JOIN для больших таблиц

14

Я гуглил, занимался самообразованием и искал решение в течение нескольких часов, но безуспешно. Я нашел несколько подобных вопросов здесь, но не этот случай.

Мои таблицы:

  • человек (~ 10 млн рядов)
  • атрибуты (местоположение, возраст, ...)
  • связи (M: M) между людьми и атрибутами (~ 40M строк)

Полный дамп ~ 280МБ

Ситуация: Я пытаюсь выбрать все идентификаторы лиц ( person_id) из некоторых мест ( location.attribute_value BETWEEN 3000 AND 7000), пола ( gender.attribute_value = 1), рожденного в несколько лет ( bornyear.attribute_value BETWEEN 1980 AND 2000) и имеющего цвет глаз ( eyecolor.attribute_value IN (2,3)).

Это мой запрос на ведьм 3 ~ 4 мин. и я хотел бы оптимизировать:

SELECT person_id
FROM person
    LEFT JOIN attribute location ON location.attribute_type_id = 1 AND location.person_id = person.person_id
    LEFT JOIN attribute gender ON gender.attribute_type_id = 2 AND gender.person_id = person.person_id
    LEFT JOIN attribute bornyear ON bornyear.attribute_type_id = 3 AND bornyear.person_id = person.person_id
    LEFT JOIN attribute eyecolor ON eyecolor.attribute_type_id = 4 AND eyecolor.person_id = person.person_id
WHERE 1
    AND location.attribute_value BETWEEN 3000 AND 7000
    AND gender.attribute_value = 1
    AND bornyear.attribute_value BETWEEN 1980 AND 2000
    AND eyecolor.attribute_value IN (2,3)
LIMIT 100000;

Результат:

+-----------+
| person_id |
+-----------+
|       233 |
|       605 |
|       ... |
|   8702599 |
|   8703617 |
+-----------+
100000 rows in set (3 min 42.77 sec)

Объясните расширенный:

+----+-------------+----------+--------+---------------------------------------------+-----------------+---------+--------------------------+---------+----------+--------------------------+
| id | select_type | table    | type   | possible_keys                               | key             | key_len | ref                      | rows    | filtered | Extra                    |
+----+-------------+----------+--------+---------------------------------------------+-----------------+---------+--------------------------+---------+----------+--------------------------+
|  1 | SIMPLE      | bornyear | range  | attribute_type_id,attribute_value,person_id | attribute_value | 5       | NULL                     | 1265229 |   100.00 | Using where              |
|  1 | SIMPLE      | location | ref    | attribute_type_id,attribute_value,person_id | person_id       | 5       | test1.bornyear.person_id |       4 |   100.00 | Using where              |
|  1 | SIMPLE      | eyecolor | ref    | attribute_type_id,attribute_value,person_id | person_id       | 5       | test1.bornyear.person_id |       4 |   100.00 | Using where              |
|  1 | SIMPLE      | gender   | ref    | attribute_type_id,attribute_value,person_id | person_id       | 5       | test1.eyecolor.person_id |       4 |   100.00 | Using where              |
|  1 | SIMPLE      | person   | eq_ref | PRIMARY                                     | PRIMARY         | 4       | test1.location.person_id |       1 |   100.00 | Using where; Using index |
+----+-------------+----------+--------+---------------------------------------------+-----------------+---------+--------------------------+---------+----------+--------------------------+
5 rows in set, 1 warning (0.02 sec)

Профилирование:

+------------------------------+-----------+
| Status                       | Duration  |
+------------------------------+-----------+
| Sending data                 |  3.069452 |
| Waiting for query cache lock |  0.000017 |
| Sending data                 |  2.968915 |
| Waiting for query cache lock |  0.000019 |
| Sending data                 |  3.042468 |
| Waiting for query cache lock |  0.000043 |
| Sending data                 |  3.264984 |
| Waiting for query cache lock |  0.000017 |
| Sending data                 |  2.823919 |
| Waiting for query cache lock |  0.000038 |
| Sending data                 |  2.863903 |
| Waiting for query cache lock |  0.000014 |
| Sending data                 |  2.971079 |
| Waiting for query cache lock |  0.000020 |
| Sending data                 |  3.053197 |
| Waiting for query cache lock |  0.000087 |
| Sending data                 |  3.099053 |
| Waiting for query cache lock |  0.000035 |
| Sending data                 |  3.064186 |
| Waiting for query cache lock |  0.000017 |
| Sending data                 |  2.939404 |
| Waiting for query cache lock |  0.000018 |
| Sending data                 |  3.440288 |
| Waiting for query cache lock |  0.000086 |
| Sending data                 |  3.115798 |
| Waiting for query cache lock |  0.000068 |
| Sending data                 |  3.075427 |
| Waiting for query cache lock |  0.000072 |
| Sending data                 |  3.658319 |
| Waiting for query cache lock |  0.000061 |
| Sending data                 |  3.335427 |
| Waiting for query cache lock |  0.000049 |
| Sending data                 |  3.319430 |
| Waiting for query cache lock |  0.000061 |
| Sending data                 |  3.496563 |
| Waiting for query cache lock |  0.000029 |
| Sending data                 |  3.017041 |
| Waiting for query cache lock |  0.000032 |
| Sending data                 |  3.132841 |
| Waiting for query cache lock |  0.000050 |
| Sending data                 |  2.901310 |
| Waiting for query cache lock |  0.000016 |
| Sending data                 |  3.107269 |
| Waiting for query cache lock |  0.000062 |
| Sending data                 |  2.937373 |
| Waiting for query cache lock |  0.000016 |
| Sending data                 |  3.097082 |
| Waiting for query cache lock |  0.000261 |
| Sending data                 |  3.026108 |
| Waiting for query cache lock |  0.000026 |
| Sending data                 |  3.089760 |
| Waiting for query cache lock |  0.000041 |
| Sending data                 |  3.012763 |
| Waiting for query cache lock |  0.000021 |
| Sending data                 |  3.069694 |
| Waiting for query cache lock |  0.000046 |
| Sending data                 |  3.591908 |
| Waiting for query cache lock |  0.000060 |
| Sending data                 |  3.526693 |
| Waiting for query cache lock |  0.000076 |
| Sending data                 |  3.772659 |
| Waiting for query cache lock |  0.000069 |
| Sending data                 |  3.346089 |
| Waiting for query cache lock |  0.000245 |
| Sending data                 |  3.300460 |
| Waiting for query cache lock |  0.000019 |
| Sending data                 |  3.135361 |
| Waiting for query cache lock |  0.000021 |
| Sending data                 |  2.909447 |
| Waiting for query cache lock |  0.000039 |
| Sending data                 |  3.337561 |
| Waiting for query cache lock |  0.000140 |
| Sending data                 |  3.138180 |
| Waiting for query cache lock |  0.000090 |
| Sending data                 |  3.060687 |
| Waiting for query cache lock |  0.000085 |
| Sending data                 |  2.938677 |
| Waiting for query cache lock |  0.000041 |
| Sending data                 |  2.977974 |
| Waiting for query cache lock |  0.000872 |
| Sending data                 |  2.918640 |
| Waiting for query cache lock |  0.000036 |
| Sending data                 |  2.975842 |
| Waiting for query cache lock |  0.000051 |
| Sending data                 |  2.918988 |
| Waiting for query cache lock |  0.000021 |
| Sending data                 |  2.943810 |
| Waiting for query cache lock |  0.000061 |
| Sending data                 |  3.330211 |
| Waiting for query cache lock |  0.000025 |
| Sending data                 |  3.411236 |
| Waiting for query cache lock |  0.000023 |
| Sending data                 | 23.339035 |
| end                          |  0.000807 |
| query end                    |  0.000023 |
| closing tables               |  0.000325 |
| freeing items                |  0.001217 |
| logging slow query           |  0.000007 |
| logging slow query           |  0.000011 |
| cleaning up                  |  0.000104 |
+------------------------------+-----------+
100 rows in set (0.00 sec)

Структуры таблиц:

CREATE TABLE `attribute` (
  `attribute_id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `attribute_type_id` int(11) unsigned DEFAULT NULL,
  `attribute_value` int(6) DEFAULT NULL,
  `person_id` int(11) unsigned DEFAULT NULL,
  PRIMARY KEY (`attribute_id`),
  KEY `attribute_type_id` (`attribute_type_id`),
  KEY `attribute_value` (`attribute_value`),
  KEY `person_id` (`person_id`)
) ENGINE=MyISAM AUTO_INCREMENT=40000001 DEFAULT CHARSET=utf8;

CREATE TABLE `person` (
  `person_id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `person_name` text CHARACTER SET latin1,
  PRIMARY KEY (`person_id`)
) ENGINE=MyISAM AUTO_INCREMENT=20000001 DEFAULT CHARSET=utf8;

Запрос был выполнен на виртуальном сервере DigitalOcean с SSD и 1 ГБ оперативной памяти.

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

Мартин
источник
4
Это цена, которую вы платите за дизайн EAV. Вы можете попробовать составной индекс наattribute (person_id, attribute_type_id, attribute_value)
Mustaccio
1
Я хотел бы попробовать добавить эти индексы: (attribute_type_id, attribute_value, person_id)и (attribute_type_id, person_id, attribute_value)
ypercubeᵀᴹ
5
И используй InnoDB, выбрасывай MyISAM. Это 2015, MyiSAM давно умер.
ypercubeᵀᴹ
2
Во-первых, избавьтесь от левого соединения, оно не действует, так как вы используете все таблицы в условии WHERE, эффективно превращая все соединения в соединения INNER (оптимизатор должен понимать и оптимизировать это, но лучше не усложнять его. ). Второе - отключите кеш запросов, если у вас нет веских причин для его использования (= вы проверили его и измерили, что он вам помогает)
jkavalik
2
OT: не странно ли, что вы используете LIMIT без ORDER BY? Это вернет несколько случайных 100000 строк?
ibre5041

Ответы:

7

Выберите несколько атрибутов для включения person. Индексируйте их в нескольких комбинациях - используйте составные индексы, а не одноколонные индексы.

По сути, это единственный выход из EAV-sucks-at-performance, где вы находитесь.

Вот еще обсуждение: http://mysql.rjweb.org/doc.php/eav, включая предложение использовать JSON вместо таблицы ключ-значение.

Рик Джеймс
источник
3

Добавить значения в attributeдля:

  • (person_id, attribute_type_id, attribute_value) и
  • (attribute_type_id, attribute_value, person_id)

объяснение

С вашим текущим дизайном EXPLAINожидает, что ваш запрос будет проверять 1,265,229 * 4 * 4 * 4 = 80,974,656строки в attribute. Вы можете уменьшить это число, добавив составной индекс на attributeдля (person_id, attribute_type_id). Используя этот индекс, ваш запрос будет проверять только 1, а не 4 строки для каждого из location, eyecolorи gender.

Вы можете расширить этот индекс , чтобы включить , attribute_type_valueа также: (person_id, attribute_type_id, attribute_value). Это превратит этот индекс в покрывающий индекс для этого запроса, что также должно повысить производительность.

Кроме того, добавление индекса (attribute_type_id, attribute_value, person_id)(снова включающего индекс покрытия person_id) должно повысить производительность по сравнению с использованием только индекса, в attribute_valueкотором нужно исследовать больше строк. В этом случае это будет первый шаг в вашем объяснении: выбор диапазона из bornyear.

Использование этих двух индексов уменьшило время выполнения вашего запроса в моей системе с ~ 2,0 с до ~ 0,2 с, а вывод объяснения выглядел следующим образом:

+----+-------------+----------+--------+-------------------------------------+-------------------+---------+--------------------------------+---------+----------+--------------------------+
| id | select_type | table    | type   | possible_keys                       | key               | key_len | ref                            |    rows | filtered | Extra                    |
+----+-------------+----------+--------+-------------------------------------+-------------------+---------+--------------------------------+---------+----------+--------------------------+
|  1 | SIMPLE      | bornyear | range  | person_type_value,type_value_person | type_value_person |       9 |                                | 1861881 |   100.00 | Using where; Using index |
|  1 | SIMPLE      | location | ref    | person_type_value,type_value_person | person_type_value |       8 | bornyear.person_id,const       |       1 |   100.00 | Using where; Using index |
|  1 | SIMPLE      | eyecolor | ref    | person_type_value,type_value_person | person_type_value |       8 | bornyear.person_id,const       |       1 |   100.00 | Using where; Using index |
|  1 | SIMPLE      | gender   | ref    | person_type_value,type_value_person | person_type_value |      13 | bornyear.person_id,const,const |       1 |   100.00 | Using index              |
|  1 | SIMPLE      | person   | eq_ref | PRIMARY                             | PRIMARY           |       4 | bornyear.person_id             |       1 |   100.00 | Using index              |
+----+-------------+----------+--------+-------------------------------------+-------------------+---------+--------------------------------+---------+----------+--------------------------+
wolfgangwalther
источник
1
Спасибо за исчерпывающий ответ и объяснение. Я сделал все, что вы упомянули, но запрос все еще занимает ~ 2 мин. Пожалуйста, какой тип таблицы (innodb, myisam) вы используете и какой точный запрос выполнен?
Мартин
1
Помимо добавления индексов, я использовал те же данные и определения, что и вы, поэтому я использовал MyISAM. Я изменил первую строку вашего запроса, SELECT person.person_idпотому что иначе он не запустился бы, очевидно. Вы сделали ANALYZE TABLE attributeпосле добавления Indedes? Возможно, вы захотите добавить свой новый EXPLAINвывод (после добавления индексов) к вашему вопросу.
wolfgangwalther
3

Я предполагаю, что могут быть проблемы с дизайном базы данных.

Вы используете так называемый дизайн Entity-Attribute-Value, который часто работает плохо, ну, по дизайну.

Есть ли у вас какие-либо предложения по улучшению этой ситуации, пожалуйста?

Классическим реляционным способом разработки этого было бы создание отдельной таблицы для каждого атрибута. В общем, вы можете иметь эти отдельные таблицы: location, gender, bornyear, eyecolor.

Следующее зависит от того, всегда ли определенные атрибуты определены для человека или нет. И может ли человек иметь только одно значение атрибута. Например, обычно человек имеет только один пол. В вашем текущем дизайне ничто не мешает вам добавить три строки для одного и того же человека с разными значениями пола в них. Вы также можете установить значение пола не 1 или 2, а какое-то число, которое не имеет смысла, например 987, и в базе данных нет ограничений, которые могли бы его предотвратить. Но это еще одна отдельная проблема поддержания целостности данных с помощью дизайна EAV.

Если вы всегда знаете пол человека, то не имеет смысла помещать его в отдельную таблицу, и лучше иметь GenderIDв personтаблице ненулевой столбец , который будет внешним ключом для таблицы поиска со списком все возможные полы и их имена. Если вы знаете пол человека большую часть времени, но не всегда, вы можете сделать этот столбец обнуляемым и установить его, NULLкогда информация недоступна. Если в большинстве случаев пол человека неизвестен, то может быть лучше иметь отдельную таблицу, genderкоторая ссылается на person1: 1 и содержит строки только для людей с известным полом.

Аналогичные соображения применимы к eyecolorи bornyear- у человека вряд ли будет два значения для eyecolorили bornyear.

Если человек может иметь несколько значений для атрибута, то вы определенно поместите его в отдельную таблицу. Например, человек нередко имеет несколько адресов (домашний, рабочий, почтовый, праздничный и т. Д.), Поэтому вы должны перечислить их все в таблице location. Таблицы personи locationбудут связаны 1: М.


Или просто отрегулировать выбор выше?

Если использовать дизайн EAV, то я бы по крайней мере сделал следующее.

  • Набор столбцов attribute_type_id, attribute_value, person_idк NOT NULL.
  • Установите внешний ключ, который связывает attribute.person_idс person.person_id.
  • Создайте один индекс на три столбца (attribute_type_id, attribute_value, person_id). Порядок столбцов здесь важен.
  • Насколько я знаю, MyISAM не поддерживает внешние ключи, поэтому не используйте его, вместо этого используйте InnoDB.

Я бы написал запрос так. Используйте INNERвместо LEFTобъединений и явно напишите подзапрос для каждого атрибута, чтобы дать оптимизатору все шансы использовать индекс.

SELECT person.person_id
FROM
    person
    INNER JOIN
    (
        SELECT attribute.person_id
        FROM attribute
        WHERE attribute_type_id = 1
            AND location.attribute_value BETWEEN 3000 AND 7000
    ) AS location ON location.person_id = person.person_id
    INNER JOIN
    (
        SELECT attribute.person_id
        FROM attribute
        WHERE attribute_type_id = 2
            AND location.attribute_value = 1
    ) AS gender ON gender.person_id = person.person_id
    INNER JOIN
    (
        SELECT attribute.person_id
        FROM attribute
        WHERE attribute_type_id = 3
            AND location.attribute_value BETWEEN 1980 AND 2000
    ) AS bornyear ON bornyear.person_id = person.person_id
    INNER JOIN
    (
        SELECT attribute.person_id
        FROM attribute
        WHERE attribute_type_id = 4
            AND location.attribute_value IN (2, 3)
    ) AS eyecolor ON eyecolor.person_id = person.person_id
LIMIT 100000;

Кроме того , он может быть стоит секционирования в attributeтаблице attribute_type_id.

Владимир Баранов
источник
Производительность предостережение: JOIN ( SELECT ... )не оптимизировать хорошо. JOINingнапрямую к столу работает лучше (но все же проблематично).
Рик Джеймс
2

Я надеюсь, что нашел достаточное решение. Это вдохновлено этой статьей .

Короткий ответ:

  1. Я создал 1 таблицу со всеми атрибутами. Один столбец для одного атрибута. Плюс столбец первичного ключа.
  2. Значения атрибутов хранятся в текстовых ячейках (для полнотекстового поиска) в CSV-подобном формате.
  3. Созданы полнотекстовые индексы. Перед этим важно установить ft_min_word_len=1(для MyISAM) в [mysqld]разделе и innodb_ft_min_token_size=1(для InnoDb) в my.cnfфайле, перезапустить службу MySQL.
  4. Пример поиска: SELECT * FROM person_index WHERE MATCH(attribute_1) AGAINST("123 456 789" IN BOOLEAN MODE) LIMIT 1000где 123, 456a 789- идентификаторы, с которыми должны были связаться люди attribute_1. Этот запрос занял менее 1 сек.

Подробный ответ:

Шаг 1. Создание таблицы с полнотекстовыми индексами. InnoDb поддерживает полнотекстовые индексы из MySQL 5.7, поэтому, если вы используете 5.5 или 5.6, вы должны использовать MyISAM. Иногда это даже быстрее для поиска FT, чем InnoDb.

CREATE TABLE `person_attribute_ft` (
  `person_id` int(11) NOT NULL,
  `attr_1` text,
  `attr_2` text,
  `attr_3` text,
  `attr_4` text,
  PRIMARY KEY (`person_id`),
  FULLTEXT KEY `attr_1` (`attr_1`),
  FULLTEXT KEY `attr_2` (`attr_2`),
  FULLTEXT KEY `attr_3` (`attr_3`),
  FULLTEXT KEY `attr_4` (`attr_4`),
  FULLTEXT KEY `attr_12` (`attr_1`,`attr_2`)
) ENGINE=MyISAM DEFAULT CHARSET=utf8

Шаг 2. Вставьте данные из таблицы EAV (entity-attribute-value). Например, указанный в вопросе это может быть сделано с 1 простым SQL:

INSERT IGNORE INTO `person_attribute_ft`
SELECT
    p.person_id,
    (SELECT GROUP_CONCAT(a.attribute_value SEPARATOR ' ') FROM attribute a WHERE a.attribute_type_id = 1 AND a.person_id = p.person_id LIMIT 10) attr_1,
    (SELECT GROUP_CONCAT(a.attribute_value SEPARATOR ' ') FROM attribute a WHERE a.attribute_type_id = 2 AND a.person_id = p.person_id LIMIT 10) attr_2,
    (SELECT GROUP_CONCAT(a.attribute_value SEPARATOR ' ') FROM attribute a WHERE a.attribute_type_id = 3 AND a.person_id = p.person_id LIMIT 10) attr_3,
    (SELECT GROUP_CONCAT(a.attribute_value SEPARATOR ' ') FROM attribute a WHERE a.attribute_type_id = 4 AND a.person_id = p.person_id LIMIT 10) attr_4
FROM person p

Результат должен быть примерно таким:

mysql> select * from person_attribute_ft limit 10;
+-----------+--------+--------+--------+--------+
| person_id | attr_1 | attr_2 | attr_3 | attr_4 |
+-----------+--------+--------+--------+--------+
|         1 | 541    | 2      | 1927   | 3      |
|         2 | 2862   | 2      | 1939   | 4      |
|         3 | 6573   | 2      | 1904   | 2      |
|         4 | 2432   | 1      | 2005   | 2      |
|         5 | 2208   | 1      | 1995   | 4      |
|         6 | 8388   | 2      | 1973   | 1      |
|         7 | 107    | 2      | 1909   | 4      |
|         8 | 5161   | 1      | 2005   | 1      |
|         9 | 8022   | 2      | 1953   | 4      |
|        10 | 4801   | 2      | 1900   | 3      |
+-----------+--------+--------+--------+--------+
10 rows in set (0.00 sec)

Шаг 3. Выберите из таблицы запрос:

mysql> SELECT SQL_NO_CACHE *
    -> FROM `person_attribute_ft`
    -> WHERE 1 AND MATCH(attr_1) AGAINST ("3000 3001 3002 3003 3004 3005 3006 3007" IN BOOLEAN MODE)
    -> AND MATCH(attr_2) AGAINST ("1" IN BOOLEAN MODE)
    -> AND MATCH(attr_3) AGAINST ("1980 1981 1982 1983 1984" IN BOOLEAN MODE)
    -> AND MATCH(attr_4) AGAINST ("2,3" IN BOOLEAN MODE)
    -> LIMIT 10000;
+-----------+--------+--------+--------+--------+
| person_id | attr_1 | attr_2 | attr_3 | attr_4 |
+-----------+--------+--------+--------+--------+
|     12131 | 3002   | 1      | 1982   | 2      |
|     51315 | 3007   | 1      | 1984   | 2      |
|    147283 | 3001   | 1      | 1984   | 2      |
|    350086 | 3005   | 1      | 1982   | 3      |
|    423907 | 3004   | 1      | 1982   | 3      |
... many rows ...
|   9423907 | 3004   | 1      | 1982   | 3      |
|   9461892 | 3007   | 1      | 1982   | 2      |
|   9516361 | 3006   | 1      | 1980   | 2      |
|   9813933 | 3005   | 1      | 1982   | 2      |
|   9986892 | 3003   | 1      | 1981   | 2      |
+-----------+--------+--------+--------+--------+
90 rows in set (0.17 sec)

Запрос выбирает все строки:

  • сопоставление хотя бы одного из этих идентификаторов в attr_1:3000, 3001, 3002, 3003, 3004, 3005, 3006 or 3007
  • И в то же время соответствие 1в attr_2(этот столбец представляет пол, поэтому, если это решение было настроено, оно должно быть smallint(1)с простым индексом и т. Д ...)
  • И в то же время соответствует по крайней мере один из 1980, 1981, 1982, 1983 or 1984вattr_3
  • И в то же время совпадая 2или 3вattr_4

Вывод:

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

Надеюсь, это кому-нибудь поможет.

Мартин
источник
1
Я считаю очень маловероятным, что этот дизайн будет работать лучше, чем ваш оригинальный дизайн с составными индексами. Какие тесты вы делали, чтобы сравнить их?
ypercubeᵀᴹ
0

Попробуйте использовать подсказки индекса запроса, которые выглядят уместно

Mysql Index Hints

Мухаммед Муаззам
источник
1
Подсказки могут помочь одной версии запроса, но затем повредить другую. Обратите внимание, что Оптимизатор выбрал bornyear как лучшую первую таблицу, вероятно, потому что, если отфильтрованы самые нежелательные строки.
Рик Джеймс