La sous-requête SELECT DISTINCT ON utilise un plan inefficace

8

J'ai une table progresses(contient de l'ordre de centaines de milliers d'enregistrements actuellement):

    Column     |            Type             |                        Modifiers                        
---------------+-----------------------------+---------------------------------------------------------
 id            | integer                     | not null default nextval('progresses_id_seq'::regclass)
 lesson_id     | integer                     | 
 user_id       | integer                     | 
 created_at    | timestamp without time zone | 
 deleted_at    | timestamp without time zone | 
Indexes:
    "progresses_pkey" PRIMARY KEY, btree (id)
    "index_progresses_on_deleted_at" btree (deleted_at)
    "index_progresses_on_lesson_id" btree (lesson_id)
    "index_progresses_on_user_id" btree (user_id)

et une vue v_latest_progressesqui interroge les plus récents progresspar user_idet lesson_id:

SELECT DISTINCT ON (progresses.user_id, progresses.lesson_id)
  progresses.id AS progress_id,
  progresses.lesson_id,
  progresses.user_id,
  progresses.created_at,
  progresses.deleted_at
 FROM progresses
WHERE progresses.deleted_at IS NULL
ORDER BY progresses.user_id, progresses.lesson_id, progresses.created_at DESC;

Un utilisateur peut avoir de nombreux progrès pour une leçon donnée, mais nous souhaitons souvent demander un ensemble des progrès récemment créés pour un ensemble donné d'utilisateurs ou de leçons (ou une combinaison des deux).

La vue le v_latest_progressesfait bien et est même performante lorsque je spécifie un ensemble de user_ids:

# EXPLAIN SELECT "v_latest_progresses".* FROM "v_latest_progresses" WHERE "v_latest_progresses"."user_id" IN ([the same list of ids given by the subquery in the second example below]);
                                                                               QUERY PLAN                                                                                                                                         
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
 Unique  (cost=526.68..528.66 rows=36 width=57)
   ->  Sort  (cost=526.68..527.34 rows=265 width=57)
         Sort Key: progresses.user_id, progresses.lesson_id, progresses.created_at
         ->  Index Scan using index_progresses_on_user_id on progresses  (cost=0.47..516.01 rows=265 width=57)
               Index Cond: (user_id = ANY ('{ [the above list of user ids] }'::integer[]))
               Filter: (deleted_at IS NULL)
(6 rows)

Cependant, si j'essaie de faire la même requête en remplaçant l'ensemble de user_ids par une sous-requête, cela devient très inefficace:

# EXPLAIN SELECT "v_latest_progresses".* FROM "v_latest_progresses" WHERE "v_latest_progresses"."user_id" IN (SELECT "users"."id" FROM "users" WHERE "users"."company_id"=44);
                                             QUERY PLAN                                              
-----------------------------------------------------------------------------------------------------
 Merge Semi Join  (cost=69879.08..72636.12 rows=19984 width=57)
   Merge Cond: (progresses.user_id = users.id)
   ->  Unique  (cost=69843.45..72100.80 rows=39969 width=57)
         ->  Sort  (cost=69843.45..70595.90 rows=300980 width=57)
               Sort Key: progresses.user_id, progresses.lesson_id, progresses.created_at
               ->  Seq Scan on progresses  (cost=0.00..31136.31 rows=300980 width=57)
                     Filter: (deleted_at IS NULL)
   ->  Sort  (cost=35.63..35.66 rows=10 width=4)
         Sort Key: users.id
         ->  Index Scan using index_users_on_company_id on users  (cost=0.42..35.46 rows=10 width=4)
               Index Cond: (company_id = 44)
(11 rows)

Ce que j'essaie de comprendre, c'est pourquoi PostgreSQL veut effectuer la DISTINCTrequête sur la progressestable entière avant qu'elle ne soit filtrée par la sous-requête dans le deuxième exemple.

Quelqu'un aurait-il des conseils pour améliorer cette requête?

Aaron
la source

Réponses:

11

Aaron,

Dans mes travaux récents, j'ai étudié des questions similaires avec PostgreSQL. PostgreSQL est presque toujours assez bon pour générer le bon plan de requête, mais il n'est pas toujours parfait.

Quelques suggestions simples seraient de vous assurer d'exécuter un ANALYZEsur votre progressestable pour vous assurer que vous avez des statistiques mises à jour, mais cela n'est pas garanti pour résoudre vos problèmes!

Pour des raisons qui sont probablement trop longues pour ce post, j'ai trouvé des comportements étranges dans la collecte de statistiques ANALYZEet le planificateur de requêtes qui peuvent devoir être résolus à long terme. À court terme, l'astuce consiste à réécrire votre requête pour essayer de pirater le plan de requête que vous souhaitez.

Sans avoir accès à vos données pour les tests, je ferai les deux suggestions suivantes.

1) Utiliser ARRAY()

PostgreSQL traite les tableaux et les ensembles d'enregistrements différemment dans son planificateur de requêtes. Parfois, vous vous retrouverez avec un plan de requête identique. Dans ce cas, comme dans beaucoup de mes cas, vous ne le faites pas.

Dans votre requête d'origine, vous aviez:

EXPLAIN SELECT "v_latest_progresses".* FROM "v_latest_progresses" 
WHERE "v_latest_progresses"."user_id" 
IN (SELECT "users"."id" FROM "users" WHERE "users"."company_id"=44);

Dans un premier temps pour essayer de le réparer, essayez

EXPLAIN SELECT "v_latest_progresses".* FROM "v_latest_progresses" 
WHERE "v_latest_progresses"."user_id" =
ANY(ARRAY(SELECT "users"."id" FROM "users" WHERE "users"."company_id"=44));

Notez le changement de la sous-requête de INà =ANY(ARRAY()).

2) Utiliser des CTE

Une autre astuce consiste à forcer des optimisations distinctes, si ma première suggestion ne fonctionne pas. Je sais que beaucoup de gens utilisent cette astuce, car les requêtes au sein d'un CTE sont optimisées et matérialisées séparément de la requête principale.

EXPLAIN 
WITH user_selection AS(
  SELECT "users"."id" FROM "users" WHERE "users"."company_id"=44
)
SELECT "v_latest_progresses".* FROM "v_latest_progresses" 
WHERE "v_latest_progresses"."user_id" =
ANY(ARRAY(SELECT "id" FROM user_selection));

Essentiellement, en créant le CTE user_selectionà l'aide de la WITHclause, vous demandez à PostgreSQL d'effectuer une optimisation distincte sur la sous-requête

SELECT "users"."id" FROM "users" WHERE "users"."company_id"=44

puis matérialiser ces résultats. J'ai ensuite, encore une fois, utilisé l' =ANY(ARRAY())expression pour essayer de manipuler manuellement le plan.

Dans ces cas, vous ne pouvez probablement pas vous fier uniquement aux résultats de EXPLAIN, car il pensait déjà avoir trouvé la solution la moins coûteuse. Assurez-vous d'exécuter un EXPLAIN (ANALYZE,BUFFERS)...pour savoir ce qu'il en coûte réellement en termes de temps et de lecture de pages.

Chris
la source
En fait, votre première suggestion fait des merveilles. Le coût de cette requête est 144.07..144.6, bien au-dessous des 70 000 que j'obtiens! Merci beaucoup.
Aaron
1
Ha! Heureux d'avoir pu aider. J'ai beaucoup de mal à résoudre ces problèmes de «piratage de plan de requête»; c'est un peu d'art en plus de la science.
Chris
J'ai appris des astuces à gauche et à droite au fil des ans pour que les bases de données fassent ce que je veux et je dois dire que cela a été l'une des situations les plus étranges que j'ai rencontrées. C'est vraiment un art. J'apprécie vraiment votre explication bien pensée!
Aaron