PostgreSQL Antipatterns : une histoire de développement itératif de la recherche par nom, ou « Optimisation va-et-vient »

Des milliers de managers des bureaux de vente Ă  travers le pays enregistrent dans notre systĂšme CRM dix mille contacts chaque jour — des faits de communication avec des clients potentiels ou dĂ©jĂ  en relation avec nous. Pour cela, il faut d'abord trouver ce client, et de prĂ©fĂ©rence trĂšs rapidement. Cela se fait le plus souvent par le nom.

Il n'est donc pas surprenant qu'en examinant encore une fois les requĂȘtes "lourdes" sur l'une des bases de donnĂ©es les plus sollicitĂ©es — notre propre compte corporate de SBS, j'ai trouvĂ© "en tĂȘte" une requĂȘte pour la recherche "rapide" par nom pour les fiches d'organisations.

De plus, une enquĂȘte plus approfondie a rĂ©vĂ©lĂ© un exemple intĂ©ressant d'abord d'optimisation, puis de dĂ©gradation des performances de la requĂȘte lors de son dĂ©veloppement successif par plusieurs Ă©quipes, chacune agissant uniquement avec les meilleures intentions.

0 : que voulait l'utilisateur

PostgreSQL Antipatterns : une histoire de développement itératif de la recherche par nom, ou « Optimisation va-et-vient »[KDPV d'ici]

Que signifie gĂ©nĂ©ralement un "search rapide" par nom pour l'utilisateur ? Cela ne se traduit presque jamais par une vĂ©ritable recherche par sous-chaĂźne, comme ... LIKE '%rose%' — car alors les rĂ©sultats incluraient non seulement 'Roselie' et 'Magasin Rose', mais aussi 'Grose' et mĂȘme 'Maison de Grands-Morose'.

L'utilisateur sous-entend, Ă  un niveau pratique, que vous lui fournirez une recherche par le dĂ©but du mot dans le nom et montrerez plus pertinent ce qui commence par ce qui a Ă©tĂ© saisi. Et vous le ferez pratiquement instantanĂ©ment — lors de la saisie par sous-chaĂźne.

1 : limitons la tĂąche

Et encore moins, l'utilisateur ne saisira spĂ©cifiquement pas 'rose magasin', pour que chaque mot nĂ©cessite une recherche de prĂ©fixe. Non, il est beaucoup plus simple pour l'utilisateur de rĂ©agir Ă  la suggestion rapide pour le dernier mot, plutĂŽt que de "ne pas finir" les prĂ©cĂ©dents — regardez comment cela fonctionne avec n'importe quel moteur de recherche.

En gĂ©nĂ©ral, bien formuler les exigences de la tĂąche — c'est plus de la moitiĂ© de la solution. Parfois, une analyse attentive d'un use case peut avoir un impact considĂ©rable sur le rĂ©sultat..

Que fait donc le développeur abstrait ?

1.0 : moteur de recherche externe

Oh, la recherche c'est compliquĂ©, je n'ai vraiment pas envie de m'en occuper — donnons cela Ă  devops ! Qu'ils nous dĂ©ploient un systĂšme de recherche externe relativement Ă  BD : Sphinx, ElasticSearch,


Une option fonctionnelle, bien que laborieuse en termes de synchronisation et d'efficacité des changements. Mais ce n'est pas notre cas, car la recherche est effectuée pour chaque client uniquement dans le cadre des données de son compte. Et les données sont suffisamment variables - si à l'instant un manager a ajouté une carte 'Magasin Rose', dans 5 à 10 secondes, il peut déjà se souvenir qu'il a oublié d'indiquer l'email et vouloir la retrouver et la corriger.

Donc, faisons une recherche « directement dans la base ». Heureusement, PostgreSQL nous permet de le faire, et pas d'une seule maniÚre - nous allons les examiner.

1.1 : « sous-chaĂźne » honnĂȘte

Accrochons-nous au mot « sous-chaßne ». Car il existe un excellent module pg_trgm! Mais ensuite, il sera nécessaire de bien trier.

Essayons de prendre pour la simplicité de la modélisation une table comme celle-ci :

CREATE TABLE firms(
  id
    serial
      PRIMARY KEY
, name
    text
);

Nous y incorporons 7,8 millions d'enregistrements d'organisations réelles et nous indexons :

CREATE EXTENSION pg_trgm;
CREATE INDEX ON firms USING gin(lower(name) gin_trgm_ops);

Nous allons rechercher pour le premier sous-ensemble :

SELECT
  *
FROM
  firms
WHERE
  lower(name) ~ ('(^|s)' || 'rose')
ORDER BY
  lower(name) ~ ('^' || 'rose') DESC -- en premier "ceux qui commencent par"
, lower(name) -- le reste par ordre alphabétique
LIMIT 10;

PostgreSQL Antipatterns : une histoire de développement itératif de la recherche par nom, ou « Optimisation va-et-vient »
[voir sur explain.tensor.ru]

Eh bien, c'est
 26 ms, 31 Mo de données lues et plus de 1,7 K d'enregistrements filtrés - pour 10 recherchés. Les frais généraux sont trop élevés, ne peut-on pas faire plus efficacement ?

1.2 : recherche dans le texte ? c'est une FTS !

En effet, PostgreSQL offre un trĂšs puissant mĂ©canisme de recherche textuelle complĂšte (Full Text Search), y compris la possibilitĂ© de recherche par prĂ©fixe. Une excellente option, il n'est mĂȘme pas nĂ©cessaire d'installer des extensions ! Essayons :

CREATE INDEX ON firms USING gin(to_tsvector('simple'::regconfig, lower(name)));

SELECT
  *
FROM
  firms
WHERE
  to_tsvector('simple'::regconfig, lower(name)) @@ to_tsquery('simple', 'rose:*')
ORDER BY
  lower(name) ~ ('^' || 'rose') DESC
, lower(name)
LIMIT 10;

PostgreSQL Antipatterns : une histoire de développement itératif de la recherche par nom, ou « Optimisation va-et-vient »
[voir sur explain.tensor.ru]

Ici, la parallĂ©lisation de l'exĂ©cution de la requĂȘte nous a un peu aidĂ©s, rĂ©duisant le temps de moitiĂ© Ă  11 ms. Et nous avons dĂ» lire 1,5 fois moins - seulement 20 Mo. LĂ , moins c'est mieux, car plus nous lisons de volume, plus il y a de chances d'obtenir un cache miss, et chaque page de donnĂ©es lue de plus depuis le disque est un frein potentiel pour la requĂȘte.

1.3 : mais tout de mĂȘme LIKE ?

La requĂȘte prĂ©cĂ©dente est bonne, mais si elle est exĂ©cutĂ©e des centaines de milliers de fois par jour, cela s'accumulera dĂ©jĂ  2 To lectures de donnĂ©es. Au mieux, depuis la mĂ©moire, mais si ça ne marche pas, depuis le disque. Alors, essayons de le rendre plus petit.

Rappelons que l'utilisateur souhaite voir d'abord «ceux qui commencent par ...». AprÚs tout, c'est du pur recherche par préfixe à l'aide de text_pattern_ops! Et seulement si nous n'avons «pas assez» de 10 enregistrements recherchés, il faudra les lire avec une recherche FTS :

CREATE INDEX ON firms(lower(name) text_pattern_ops);

SELECT
  *
FROM
  firms
WHERE
  lower(name) LIKE ('rose' || '%')
LIMIT 10;

PostgreSQL Antipatterns : une histoire de développement itératif de la recherche par nom, ou « Optimisation va-et-vient »
[voir sur explain.tensor.ru]

Excellentes performances — seulement 0.05ms et un peu plus de 100Ko lu ! Mais nous avons oubliĂ© le tri par nom, afin que l'utilisateur ne se perde pas dans les rĂ©sultats :

SELECT
  *
FROM
  firms
WHERE
  lower(name) LIKE ('rose' || '%')
ORDER BY
  lower(name)
LIMIT 10;

PostgreSQL Antipatterns : une histoire de développement itératif de la recherche par nom, ou « Optimisation va-et-vient »
[voir sur explain.tensor.ru]

Oh, ce n'est dĂ©jĂ  plus si beau — il semble qu'il y ait un index, mais le tri passe Ă  cĂŽtĂ©... C'est dĂ©jĂ  beaucoup plus efficace que l'option prĂ©cĂ©dente, mais...

1.4 : «affiner à la main»

Mais il existe un index qui permet de faire des recherches par plage et de bien utiliser le tri — un btree ordinaire!

CREATE INDEX ON firms(lower(name));

Cependant, la requĂȘte devra ĂȘtre «construite manuellement» :

SELECT
  *
FROM
  firms
WHERE
  lower(name) >= 'rose' AND
  lower(name) <= ('rose' || chr(65535)) -- pour UTF8, pour un octet - chr(255)
ORDER BY
   lower(name)
LIMIT 10;

PostgreSQL Antipatterns : une histoire de développement itératif de la recherche par nom, ou « Optimisation va-et-vient »
[voir sur explain.tensor.ru]

Parfait — le tri fonctionne et la consommation des ressources reste «microscopique» , mille fois plus efficace qu'un «FTS pur»! Il reste Ă  rassembler en une seule requĂȘte :

(
  SELECT
    *
  FROM
    firms
  WHERE
    lower(name) >= 'rose' AND
    lower(name) <= ('rose' || chr(65535)) -- pour UTF8, pour les encodages Ă  un octet - chr(255)
  ORDER BY
     lower(name)
  LIMIT 10
)
UNION ALL
(
  SELECT
    *
  FROM
    firms
  WHERE
    to_tsvector('simple'::regconfig, lower(name)) @@ to_tsquery('simple', 'rose:*') AND
    lower(name) NOT LIKE ('rose' || '%') -- «commençant par» nous l'avons déjà trouvé ci-dessus
  ORDER BY
    lower(name) ~ ('^' || 'rose') DESC -- utilisons le mĂȘme tri pour ne PAS utiliser l'index btree
  , lower(name)
  LIMIT 10
)
LIMIT 10;

Je ferai remarquer que la deuxiĂšme sous-requĂȘte s'exĂ©cute uniquement si la premiĂšre a retournĂ© moins que prĂ©vu dernier LIMIT du nombre de lignes. J'ai dĂ©jĂ  Ă©crit Ă  propos de cette mĂ©thode d'optimisation des requĂȘtes plus tĂŽt.

Eh bien, nous avons maintenant Ă  la fois btree et gin sur la table, mais statistiquement il s'avĂšre que moins de 10 % des requĂȘtes atteignent l'exĂ©cution du deuxiĂšme bloc. Cela signifie qu'avec ces contraintes typiques connues Ă  l'avance pour la tĂąche, nous avons pu rĂ©duire la consommation totale des ressources du serveur presque de mille fois !

1.5* : nous ferons sans la lime

Au-dessus LIKE nous nous sommes heurtés à un mauvais tri. Mais on peut « le remettre sur le droit chemin » en utilisant l'opérateur USING :

Par dĂ©faut, cela implique ASC. De plus, on peut spĂ©cifier le nom d'un opĂ©rateur de tri particulier dans la clause USING. L'opĂ©rateur de tri doit ĂȘtre un membre de la famille des opĂ©rateurs B-arbre « infĂ©rieur » ou « supĂ©rieur ». ASC gĂ©nĂ©ralement Ă©quivalent Ă  USING < et DESC gĂ©nĂ©ralement Ă©quivalent Ă  USING >.

Dans notre cas, « inférieur » signifie ~<~:

SELECT
  *
FROM
  firms
WHERE
  lower(name) LIKE ('rose' || '%')
ORDER BY
  lower(name) USING ~<~
LIMIT 10;

PostgreSQL Antipatterns : une histoire de développement itératif de la recherche par nom, ou « Optimisation va-et-vient »
[voir sur explain.tensor.ru]

2: comment les requĂȘtes « s’oxydent »

Maintenant, nous laissons notre requĂȘte « infuser » pendant six mois Ă  un an, et nous dĂ©couvrons avec Ă©tonnement qu'elle est Ă  nouveau « en haut » avec des indicateurs cumulĂ©s de « stockage » de mĂ©moire quotidienne (buffers shared hit) dans 5,5 To — c'est-Ă -dire encore plus que ce qu'il y avait au dĂ©part.

Non, bien sĂ»r, notre entreprise a Ă©galement progressĂ©, et la charge a augmentĂ©, mais pas Ă  ce point ! Cela signifie qu'il y a quelque chose de louche ici — cherchons Ă  comprendre.

2.1: la naissance du pagination

À un moment donnĂ©, une autre Ă©quipe de dĂ©veloppeurs a voulu faire en sorte qu'Ă  partir d'une recherche rapide en sous-texte, on puisse « sauter » vers le registre avec les mĂȘmes rĂ©sultats mais Ă©tendus. Et quel registre sans navigation par pages ? Enclenchons cela !

( ... LIMIT  + 10)
UNION ALL
( ... LIMIT  + 10)
LIMIT 10 OFFSET ;

Désormais, on pouvait afficher sans contrainte pour le développeur le registre des résultats de recherche avec un chargement « par pages ».

Bien sĂ»r, en rĂ©alitĂ©, pour chaque page de donnĂ©es suivante, il faut lire de plus en plus (tout de la fois prĂ©cĂ©dente que nous Ă©cartons, plus le « morceau » nĂ©cessaire) — ce qui est manifestement un antipattern. Et il aurait Ă©tĂ© prĂ©fĂ©rable de dĂ©marrer la recherche Ă  l'itĂ©ration suivante Ă  partir de la clĂ© enregistrĂ©e dans l'interface, mais cela sera pour une autre fois.

2.2: envie d'exotisme

À un certain moment, le dĂ©veloppeur a eu envie de diversifier l'Ă©chantillon rĂ©sultant avec des donnĂ©es d'une autre table, ce qui a amenĂ© toute la requĂȘte prĂ©cĂ©dente Ă  ĂȘtre envoyĂ©e dans un CTE :

WITH q AS (
  ...
  LIMIT  + 10
)
SELECT
  *
, (SELECT ...) sub_query -- une requĂȘte vers la table liĂ©e
FROM
  q
LIMIT 10 OFFSET ;

Et mĂȘme ainsi — pas mal, car la sous-requĂȘte est calculĂ©e seulement pour 10 enregistrements retournĂ©s, si ce n'Ă©tait pour ...

2.3: DISTINCT, absurde et impitoyable

Au cours de ce processus d'Ă©volution, dans le deuxiĂšme sous-requĂȘte a Ă©tĂ© perdu NOT LIKE la condition. On comprend que aprĂšs cela, UNION ALL a commencĂ© Ă  retourner certaines entrĂ©es deux fois — d'abord trouvĂ© par le dĂ©but de la chaĂźne, puis Ă  nouveau — par le dĂ©but du premier mot de cette chaĂźne. En thĂ©orie, tous les enregistrements de la deuxiĂšme sous-requĂȘte pouvaient correspondre aux enregistrements de la premiĂšre.

Que fait le développeur au lieu de rechercher la cause?.. Pas de problÚme!

  • doublons la taille des Ă©chantillons originaux
  • appliquons DISTINCT, afin d'obtenir uniquement un exemplaire de chaque ligne

WITH q AS (
  ( ... LIMIT  + 10)
  UNION ALL
  ( ... LIMIT  + 10)
  LIMIT  + 10
)
SELECT DISTINCT
  *
, (SELECT ...) sub_query
FROM
  q
LIMIT 10 OFFSET ;

Ainsi, il est clair que le rĂ©sultat, au final, est exactement le mĂȘme, mais la probabilitĂ© de « passer Ă  cĂŽtĂ© » dans la 2Ăšme sous-requĂȘte CTE a considĂ©rablement augmentĂ©, et mĂȘme sans cela, est clairement plus lisible.

Mais ce n'est pas le plus triste. Puisque le dĂ©veloppeur a demandĂ© de sĂ©lectionner DISTINCT non pas par des champs spĂ©cifiques, mais sur tous les champs les enregistrements, alors le champ sub_query — le rĂ©sultat de la sous-requĂȘte — y est Ă©galement automatiquement inclus. Maintenant, pour exĂ©cuter DISTINCT, la base a dĂ» exĂ©cuter dĂ©jĂ  pas 10 sous-requĂȘtes, mais toutes + 10!

2.4 : la coopération avant tout !

C'est ainsi que les dĂ©veloppeurs vivaient — pas de soucis, car dans le registre, « ajuster » Ă  des valeurs significatives de N, avec un ralentissement chronique de l'obtention de chaque « page » suivante, le patience de l'utilisateur ne suffisait clairement pas.

Jusqu'Ă  ce que des dĂ©veloppeurs d'un autre dĂ©partement viennent les voir, et souhaitent utiliser cette mĂ©thode si pratique pour une recherche itĂ©rative — c'est-Ă -dire prendre un morceau d'un Ă©chantillon, filtrer par des conditions supplĂ©mentaires, afficher le rĂ©sultat, puis le morceau suivant (ce que nous atteignons dans notre cas en augmentant N), et ainsi de suite jusqu'Ă  ce que nous remplissions l'Ă©cran.

En gĂ©nĂ©ral, dans l'exemple capturĂ© N a atteint des valeurs proches de 17K, et au total, au cours de la journĂ©e, pas moins de 4K de ces requĂȘtes ont Ă©tĂ© exĂ©cutĂ©es « en chaĂźne ». Les derniĂšres d'entre elles scannaient dĂ©jĂ  Ă  1 Go de mĂ©moire Ă  chaque itĂ©ration


Au total

PostgreSQL Antipatterns : une histoire de développement itératif de la recherche par nom, ou « Optimisation va-et-vient »

Source : habr.com

Acheter un hĂ©bergement fiable pour les sites avec protection DDoS, serveurs VPS VDS đŸ”„ Acheter un hĂ©bergement fiable pour les sites avec protection DDoS, serveurs VPS VDS | ProHoster