web-dev-qa-db-fra.com

Comment optimiser SELECT très lent avec LEFT JOINs sur de grandes tables

J'étais sur Google, autodidacte et à la recherche d'une solution pendant des heures mais sans chance. J'ai trouvé quelques questions similaires ici, mais pas ce cas.

Mes tables:

  • personnes (~ 10 millions de lignes)
  • attributs (lieu, âge, ...)
  • liens (M: M) entre les personnes et les attributs (~ 40M lignes)

vidage complet ~ 280 Mo

Situation: J'essaie de sélectionner tous les identifiants des personnes (person_id) À partir de certains emplacements (location.attribute_value BETWEEN 3000 AND 7000), Étant un sexe (gender.attribute_value = 1), Né quelques années (bornyear.attribute_value BETWEEN 1980 AND 2000) Et ayant la couleur de certains yeux (eyecolor.attribute_value IN (2,3)).

C'est ma requête qui a pris 3 ~ 4 min. et j'aimerais optimiser:

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;

Résultat:

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

Expliquez étendu:

+----+-------------+----------+--------+---------------------------------------------+-----------------+---------+--------------------------+---------+----------+--------------------------+
| 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)

Profilage:

+------------------------------+-----------+
| 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)

Structures des tables:

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;

La requête avait été effectuée sur le serveur virtuel DigitalOcean avec SSD et 1 Go de RAM.

Je suppose qu'il peut y avoir un problème avec la conception de la base de données. Avez-vous des suggestions pour mieux concevoir cette situation, s'il vous plaît? Ou tout simplement pour ajuster la sélection ci-dessus?

15
Martin

J'espère avoir trouvé une solution suffisante. Il est inspiré par cet article .

Réponse courte:

  1. J'ai créé 1 table avec tous les attributs. Une colonne pour un attribut. Plus la colonne de clé primaire.
  2. Les valeurs d'attribut sont stockées dans des cellules de texte (pour la recherche en texte intégral) au format CSV.
  3. Création d'index en texte intégral. Avant cela, il est important de définir ft_min_Word_len=1 (Pour MyISAM) dans la section [mysqld] Et innodb_ft_min_token_size=1 (Pour InnoDb) dans le fichier my.cnf, Redémarrez le service mysql.
  4. Exemple de recherche: SELECT * FROM person_index WHERE MATCH(attribute_1) AGAINST("123 456 789" IN BOOLEAN MODE) LIMIT 1000123, 456 A 789 Sont des identifiants que les personnes devraient avoir associées dans attribute_1. Cette requête a pris moins de 1 seconde.

Réponse détaillée:

Étape 1. Création d'une table avec des index fulltext. InnoDb prend en charge les index de texte intégral de MySQL 5.7, donc si vous utilisez 5.5 ou 5.6, vous devez utiliser MyISAM. C'est parfois encore plus rapide pour la recherche FT qu'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

Étape 2. Insérez les données de la table EAV (entity-attribute-value). Par exemple indiqué en question cela peut se faire avec 1 SQL simple:

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

Le résultat devrait être quelque chose comme ceci:

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)

Étape 3. Sélectionnez dans le tableau avec une requête comme celle-ci:

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)

La requête sélectionne toutes les lignes:

  • correspondant à au moins un de ces ID dans attr_1: 3000, 3001, 3002, 3003, 3004, 3005, 3006 or 3007
  • ET en même temps faire correspondre 1 Dans attr_2 (Cette colonne représente gender donc si cette solution a été personnalisée, elle devrait être smallint(1) avec index simple, etc ...)
  • ET en même temps correspondant à au moins l'un des 1980, 1981, 1982, 1983 or 1984 Dans attr_3
  • ET en même temps correspondant à 2 Ou 3 Dans attr_4

Conclusion:

Je sais que cette solution n'est pas parfaite et idéale pour de nombreuses situations, mais peut être utilisée comme une bonne alternative pour la conception de tables EAV.

J'espère que ça va aider quelqu'un.

3
Martin

Choisissez un quelques attributs à inclure dans person. Indexez-les en quelques combinaisons - utilisez des index composites, pas des index à colonne unique.

C'est essentiellement le seul moyen de sortir de l'EAV-sucks-at-performance, qui est là où vous êtes.

Voici plus de discussion: http://mysql.rjweb.org/doc.php/eav y compris une suggestion d'utiliser JSON au lieu de la table de valeurs-clés.

8
Rick James

Je suppose qu'il peut y avoir un problème avec la conception de la base de données.

Vous utilisez une conception dite Entité-Attribut-Valeur, qui fonctionne souvent mal, bien, par conception.

Avez-vous des suggestions pour mieux concevoir cette situation, s'il vous plaît?

La manière relationnelle classique de concevoir cela serait de créer une table distincte pour chaque attribut. En général, vous pouvez avoir ces tables distinctes: location, gender, bornyear, eyecolor.

Les éléments suivants varient selon que certains attributs sont toujours définis pour une personne ou non. Et, si une personne ne peut avoir qu'une seule valeur d'un attribut. Par exemple, la personne n'a généralement qu'un seul sexe. Dans votre conception actuelle, rien ne vous empêche d'ajouter trois lignes pour la même personne avec des valeurs de sexe différentes. Vous pouvez également définir une valeur de genre non pas à 1 ou 2, mais à un nombre qui n'a pas de sens, comme 987 et aucune contrainte dans la base de données ne l'empêcherait. Mais, c'est une autre question distincte de maintenir l'intégrité des données avec la conception EAV.

Si vous connaissez toujours le sexe de la personne, cela n'a aucun sens de le placer dans un tableau séparé et il est préférable d'avoir une colonne non nulle GenderID dans le tableau person, ce qui être une clé étrangère de la table de recherche avec la liste de tous les genres possibles et leurs noms. Si vous connaissez le sexe de la personne la plupart du temps, mais pas toujours, vous pouvez rendre cette colonne annulable et la définir sur NULL lorsque les informations ne sont pas disponibles. Si la plupart du temps, le sexe de la personne n'est pas connu, il peut être préférable d'avoir une table distincte gender qui relie à person 1: 1 et ne comporte des lignes que pour les personnes qui ont une le genre.

Des considérations similaires s'appliquent à eyecolor et bornyear - il est peu probable que la personne ait deux valeurs pour un eyecolor ou bornyear.

S'il est possible pour une personne d'avoir plusieurs valeurs pour un attribut, alors vous le mettriez certainement dans une table séparée. Par exemple, il n'est pas rare qu'une personne ait plusieurs adresses (domicile, travail, courrier, vacances, etc.), vous devez donc toutes les répertorier dans un tableau location. Les tables person et location seraient liées 1: M.


Ou tout simplement pour ajuster la sélection ci-dessus?

Si vous utilisez la conception EAV, je ferais au moins ce qui suit.

  • Définir les colonnes attribute_type_id, attribute_value, person_id à NOT NULL.
  • Configurer une clé étrangère qui relie attribute.person_id avec person.person_id.
  • Créez un index sur trois colonnes (attribute_type_id, attribute_value, person_id). L'ordre des colonnes est important ici.
  • Pour autant que je sache, MyISAM n'honore pas les clés étrangères, alors ne l'utilisez pas, utilisez plutôt InnoDB.

J'écrirais la requête comme ceci. Utilisez INNER au lieu de LEFT jointures et écrivez explicitement une sous-requête pour chaque attribut pour donner à l'optimiseur toutes les chances d'utiliser l'index.

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;

En outre, cela peut valoir la peine partitionnement la table attribute par attribute_type_id.

3
Vladimir Baranov

Ajoutez des indéces à attribute pour:

  • (person_id, attribute_type_id, attribute_value) et
  • (attribute_type_id, attribute_value, person_id)

Explication

Avec votre conception actuelle, EXPLAIN s'attend à ce que votre requête examine 1,265,229 * 4 * 4 * 4 = 80,974,656 lignes dans attribute. Vous pouvez réduire ce nombre en ajoutant un index composite sur attribute pour (person_id, attribute_type_id). En utilisant cet index, votre requête examinera seulement 1 au lieu de 4 lignes pour chacun des location, eyecolor et gender.

Vous pouvez étendre cet index pour inclure attribute_type_value ainsi que: (person_id, attribute_type_id, attribute_value). Cela transformerait cet index en index de couverture pour cette requête, ce qui devrait également améliorer les performances.

Ajout d'un index sur (attribute_type_id, attribute_value, person_id) (encore un indice de couverture en incluant person_id) devrait améliorer les performances par rapport à la simple utilisation d'un index sur attribute_value où d'autres lignes devraient être examinées. Dans ce cas, cela accélérera la première étape de votre explication: la sélection d'une plage parmi bornyear.

L'utilisation de ces deux indéces a réduit le temps d'exécution de votre requête sur mon système de ~ 2,0 s à ~ 0,2 s avec la sortie d'explication ressemblant à ceci:

+----+-------------+----------+--------+-------------------------------------+-------------------+---------+--------------------------------+---------+----------+--------------------------+
| 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              |
+----+-------------+----------+--------+-------------------------------------+-------------------+---------+--------------------------------+---------+----------+--------------------------+
3
wolfgangwalther

Essayez d'utiliser des indices d'index de requête qui semblent appropriés

Conseils d'index Mysql

0
Muhammad Muazzam