web-dev-qa-db-fra.com

GROUP BY une colonne, tout en triant par une autre dans PostgreSQL

Comment puis-je GROUP BY Une colonne, tout en triant seulement par une autre.

J'essaie de faire ce qui suit:

SELECT dbId,retreivalTime 
    FROM FileItems 
    WHERE sourceSite='something' 
    GROUP BY seriesName 
    ORDER BY retreivalTime DESC 
    LIMIT 100 
    OFFSET 0;

Je veux sélectionner les derniers /n/éléments de FileItems, dans l'ordre décroissant, avec les lignes filtrées par DISTINCT valeurs de seriesName. La requête ci-dessus génère des erreurs ERROR: column "fileitems.dbid" must appear in the GROUP BY clause or be used in an aggregate function. J'ai besoin de la valeur dbid afin de prendre ensuite la sortie de cette requête, et JOIN sur la table source pour obtenir le reste des colonnes que je n'étais pas.

Notez qu'il s'agit essentiellement de la gestalt de la question ci-dessous, avec beaucoup de détails étrangers supprimés pour plus de clarté.


Question d'origine

J'ai un système que je migre de sqlite3 vers PostgreSQL, car j'ai largement dépassé sqlite:

    SELECT
            d.dbId,
            d.dlState,
            d.sourceSite,
        [snip a bunch of rows]
            d.note

    FROM FileItems AS d
        JOIN
            ( SELECT dbId
                FROM FileItems
                WHERE sourceSite='{something}'
                GROUP BY seriesName
                ORDER BY MAX(retreivalTime) DESC
                LIMIT 100
                OFFSET 0
            ) AS di
            ON  di.dbId = d.dbId
    ORDER BY d.retreivalTime DESC;

Fondamentalement, je veux sélectionner les derniers n DISTINCT éléments de la base de données, où la contrainte distincte est sur une colonne, et le tri l'ordre est sur une autre colonne.

Malheureusement, la requête ci-dessus, bien qu'elle fonctionne correctement dans sqlite, génère des erreurs dans PostgreSQL avec l'erreur psycopg2.ProgrammingError: column "fileitems.dbid" must appear in the GROUP BY clause or be used in an aggregate function.

Malheureusement, tout en ajoutant dbId à la clause GROUP BY résout le problème (par exemple GROUP BY seriesName,dbId), Cela signifie que le filtrage distinct sur les résultats de la requête ne fonctionne plus, puisque dbid est le clé primaire de la base de données, et en tant que telles toutes les valeurs sont distinctes.

De la lecture de la documentation Postgres , il y a SELECT DISTINCT ON ({nnn}), mais cela nécessite que les résultats retournés soient triés par {nnn}.

Par conséquent, pour faire ce que je voudrais via SELECT DISTINCT ON, Je dois interroger tous les DISTINCT {nnn} Et leur MAX(retreivalTime), trier à nouveau par retreivalTime plutôt que {nnn}, puis prenez le plus grand 100 et interrogez en utilisant ceux contre la table pour obtenir le reste des lignes, que je ' j'aimerais éviter, car la base de données a ~ 175 Ko lignes et ~ 14 Ko valeurs distinctes dans la colonne seriesName, je ne veux que les 100 dernières, et cette requête est quelque peu critique en termes de performances (j'ai besoin de temps de requête <1/2 seconde).

Mon hypothèse naïve est que la base de données doit simplement itérer sur chaque ligne dans l'ordre décroissant de retreivalTime, et s'arrêter simplement une fois qu'elle a vu les éléments LIMIT, donc une requête de table complète n'est pas idéale, mais je ne prétends pas vraiment comprendre comment le système de base de données est optimisé en interne, et je me trompe peut-être complètement.

FWIW, I do utilise parfois des valeurs OFFSET différentes, mais de longs délais de requête dans les cas où l'offset> ~ 500 est tout à fait acceptable. Fondamentalement, OFFSET est un mécanisme de pagination merdique qui me permet de m'en tirer sans avoir à consacrer des curseurs de défilement à chaque connexion, et je vais probablement le revisiter à un moment donné.


Ref - Question que j'ai posée il y a un mois et qui a conduit à cette requête .


Ok, plus de notes:

    SELECT
            d.dbId,
            d.dlState,
            d.sourceSite,
        [snip a bunch of rows]
            d.note

    FROM FileItems AS d
        JOIN
            ( SELECT seriesName, MAX(retreivalTime) AS max_retreivalTime
                FROM FileItems
                WHERE sourceSite='{something}'
                GROUP BY seriesName
                ORDER BY max_retreivalTime DESC
                LIMIT %s
                OFFSET %s
            ) AS di
            ON  di.seriesName = d.seriesName AND di.max_retreivalTime = d.retreivalTime
    ORDER BY d.retreivalTime DESC;

Fonctionne correctement pour la requête comme décrit, mais si je supprime la clause GROUP BY, Elle échoue (elle est facultative dans mon application).

psycopg2.ProgrammingError: column "FileItems.seriesname" must appear in the GROUP BY clause or be used in an aggregate function

Je pense que je ne comprends pas fondamentalement comment les sous-requêtes fonctionnent dans PostgreSQL. Où est-ce que je vais mal? J'avais l'impression qu'une sous-requête est simplement une fonction en ligne, où les résultats sont simplement introduits dans la requête principale.

8
Fake Name

Lignes cohérentes

La question importante qui ne semble pas encore sur votre radar:
À partir de chaque ensemble de lignes pour le même seriesName, voulez-vous les colonnes de un ligne, ou juste tout les valeurs de plusieurs lignes (qui peuvent ou non aller de pair)?

Votre réponse fait ce dernier, vous combinez le maximum dbid avec le maximum retreivaltime, qui peut provenir d'une ligne différente.

Pour obtenir cohérent lignes, utilisez DISTINCT ON Et enveloppez-le dans une sous-requête pour classer le résultat différemment:

SELECT * FROM (
   SELECT DISTINCT ON (seriesName)
          dbid, seriesName, retreivaltime
   FROM   FileItems
   WHERE  sourceSite = 'mk' 
   ORDER  BY seriesName, retreivaltime DESC NULLS LAST  -- latest retreivaltime
   ) sub
ORDER BY retreivaltime DESC NULLS LAST
LIMIT  100;

Détails pour DISTINCT ON:

A part: devrait probablement être retrievalTime, ou mieux encore: retrieval_time. Les identificateurs de casse mixte non cotés sont une source courante de confusion dans Postgres.

Meilleures performances avec rCTE

Puisque nous avons affaire à une grande table ici, nous aurions besoin d'une requête qui peut utiliser un index, ce qui n'est pas le cas pour la requête ci-dessus (sauf pour WHERE sourceSite = 'mk')

En y regardant de plus près, votre problème semble être un cas particulier de balayage d'index lâche . Postgres ne prend pas en charge nativement les analyses d'index lâches, mais il peut être émulé avec un CTE récursif . Il y a un exemple de code pour le cas simple dans le wiki Postgres.

Réponse associée sur SO avec des solutions plus avancées, explication, violon:

Votre cas est cependant plus complexe. Mais je pense que j'ai trouvé une variante pour le faire fonctionner pour vous. Construire sur cet index (sans WHERE sourceSite = 'mk')

CREATE INDEX mi_special_full_idx ON MangaItems
(retreivaltime DESC NULLS LAST, seriesName DESC NULLS LAST, dbid)

Ou (avec WHERE sourceSite = 'mk')

CREATE INDEX mi_special_granulated_idx ON MangaItems
(sourceSite, retreivaltime DESC NULLS LAST, seriesName DESC NULLS LAST, dbid)

Le premier index peut être utilisé pour les deux requêtes, mais n'est pas entièrement efficace avec la condition WHERE supplémentaire. Le deuxième index est d'une utilité très limitée pour la première requête. Étant donné que vous disposez des deux variantes de la requête, pensez à créer les deux index.

J'ai ajouté dbid à la fin pour permettre Index uniquement scans .

Cette requête avec un CTE récursif utilise l'index. J'ai testé avec Postgres 9.3 et cela fonctionne pour moi: pas de scan séquentiel, tout index seulement scans:

WITH RECURSIVE cte AS (
   (
   SELECT dbid, seriesName, retreivaltime, 1 AS rn, ARRAY[seriesName] AS arr
   FROM   MangaItems
   WHERE  sourceSite = 'mk'
   ORDER  BY retreivaltime DESC NULLS LAST, seriesName DESC NULLS LAST
   LIMIT  1
   )
   UNION ALL
   SELECT i.dbid, i.seriesName, i.retreivaltime, c.rn + 1, c.arr || i.seriesName
   FROM   cte c
   ,      LATERAL (
      SELECT dbid, seriesName, retreivaltime
      FROM   MangaItems
      WHERE (retreivaltime, seriesName) < (c.retreivaltime, c.seriesName)
      AND    sourceSite = 'mk'  -- repeat condition!
      AND    seriesName <> ALL(c.arr)
      ORDER  BY retreivaltime DESC NULLS LAST, seriesName DESC NULLS LAST
      LIMIT  1
      ) i
   WHERE  c.rn < 101
   )
SELECT dbid
FROM   cte
ORDER  BY rn;

Vous avez besoin d'inclure seriesName dans ORDER BY, Puisque retreivaltime n'est pas unique. "Presque" unique est toujours pas unique.

Explique

  • La requête non récursive commence par la dernière ligne.

  • La requête récursive ajoute la ligne suivante avec un seriesName qui n'est pas dans la liste, etc., jusqu'à ce que nous ayons 100 lignes.

  • Les parties essentielles sont la condition JOIN(b.retreivaltime, b.seriesName) < (c.retreivaltime, c.seriesName) Et la clause ORDER BYORDER BY retreivaltime DESC NULLS LAST, seriesName DESC NULLS LAST. Les deux correspondent à l'ordre de tri de l'index, ce qui permet à la magie de se produire.

  • Collecte de seriesName dans un tableau pour éliminer les doublons. Le coût de b.seriesName <> ALL(c.foo_arr) augmente progressivement avec le nombre de lignes, mais pour seulement 100 lignes, il reste bon marché.

  • Renvoyer simplement dbid comme expliqué dans les commentaires.

Alternative avec index partiels:

Nous avons déjà été confrontés à des problèmes similaires. Voici une solution complète hautement optimisée basée sur des index partiels et une fonction de bouclage:

Probablement le moyen le plus rapide (sauf pour une vue matérialisée) s'il est bien fait. Mais plus complexe.

Vue matérialisée

Comme vous n'avez pas beaucoup d'opérations d'écriture et qu'elles ne sont pas critiques en termes de performances comme indiqué dans les commentaires (devrait être dans la question), save les n premières lignes pré-calculées dans une vue matérialisée et actualisez-la après les modifications pertinentes de la table sous-jacente. Basez plutôt vos requêtes critiques sur les performances sur la vue matérialisée.

  • Pourrait être juste un mv "mince" des derniers 1000 dbid ou plus. Dans la requête, rejoignez la table d'origine. Par exemple, si le contenu est parfois mis à jour, mais les n premières lignes peuvent rester inchangées.

  • Ou un mv "gras" avec des rangées entières pour revenir. Plus rapide encore. A besoin d'être rafraîchi plus souvent, évidemment.

Détails dans le manuel ici et ici .

9
Erwin Brandstetter

Ok, j'ai lu plus les documents, et maintenant je comprends le problème au moins un peu mieux.

Fondamentalement, ce qui se passe est qu'il existe plusieurs valeurs possibles pour dbid à la suite du GROUP BY seriesName agrégation. Avec SQLite et MySQL, apparemment le moteur de base de données en choisit juste un au hasard (ce qui est tout à fait correct dans mon application).

Cependant, PostgreSQL est beaucoup plus conservateur, donc plutôt que de choisir une valeur aléatoire, il génère une erreur.

Un moyen simple de faire fonctionner cette requête consiste à appliquer une fonction d'agrégation à la valeur appropriée:

SELECT MAX(dbid) AS mdbid, seriesName, MAX(retreivaltime) AS mrt
    FROM MangaItems 
    WHERE sourceSite='mk' 
    GROUP BY seriesName
    ORDER BY mrt DESC 
    LIMIT 100 
    OFFSET 0;

Cela rend la sortie de la requête pleinement qualifiée et la requête fonctionne maintenant.

5
Fake Name

Eh bien, j'ai fini par utiliser une logique procédurale en dehors de la base de données pour accomplir ce que je voulais faire.

Fondamentalement, 99% du temps, je veux que le dernier  100 200 résultats. Le planificateur de requêtes ne semble pas être optimisé pour cela, et si la valeur de OFFSET est grande, mon filtre procédural sera beaucoup plus lent.

Quoi qu'il en soit, j'ai utilisé un curseur nommé pour parcourir manuellement les lignes de la base de données, récupérant les lignes par groupes de quelques centaines. Je les filtre ensuite par distinction dans mon code d'application et ferme le curseur immédiatement après avoir accumulé le nombre de résultats distincts que je voulais.

Le code mako (essentiellement python). Beaucoup d'instructions de débogage restantes.

<%def name="fetchMangaItems(flags='', limit=100, offset=0, distinct=False, tableKey=None, seriesName=None)">
    <%
        if distinct and seriesName:
            raise ValueError("Cannot filter for distinct on a single series!")

        if flags:
            raise ValueError("TODO: Implement flag filtering!")

        whereStr, queryAdditionalArgs = buildWhereQuery(tableKey, None, seriesName=seriesName)
        params = Tuple(queryAdditionalArgs)


        anonCur = sqlCon.cursor()
        anonCur.execute("BEGIN;")

        cur = sqlCon.cursor(name='test-cursor-1')
        cur.arraysize = 250
        query = '''

            SELECT
                    dbId,
                    dlState,
                    sourceSite,
                    sourceUrl,
                    retreivalTime,
                    sourceId,
                    seriesName,
                    fileName,
                    originName,
                    downloadPath,
                    flags,
                    tags,
                    note

            FROM MangaItems
            {query}
            ORDER BY retreivalTime DESC;'''.format(query=whereStr)

        start = time.time()
        print("time", start)
        print("Query = ", query)
        print("params = ", params)
        print("tableKey = ", tableKey)

        ret = cur.execute(query, params)
        print("Cursor ret = ", ret)
        # for item in cur:
        #   print("Row", item)

        seenItems = []
        rowsBuf = cur.fetchmany()

        rowsRead = 0

        while len(seenItems) < offset:
            if not rowsBuf:
                rowsBuf = cur.fetchmany()
            row = rowsBuf.pop(0)
            rowsRead += 1
            if row[6] not in seenItems or not distinct:
                seenItems.append(row[6])

        retRows = []

        while len(seenItems) < offset+limit:
            if not rowsBuf:
                rowsBuf = cur.fetchmany()
            row = rowsBuf.pop(0)
            rowsRead += 1
            if row[6] not in seenItems or not distinct:
                retRows.append(row)
                seenItems.append(row[6])

        cur.close()
        anonCur.execute("COMMIT;")

        print("duration", time.time()-start)
        print("Rows used", rowsRead)
        print("Query complete!")

        return retRows
    %>

</%def>

Ceci récupère actuellement la dernière 100 200 éléments de série distincts dans 115 ~ 80 millisecondes (le temps le plus court est lorsque vous utilisez une connexion locale, plutôt qu'un TCP), tout en traitant environ 1500 lignes.

Venez commentaires:

  • Les lignes sont lues par blocs de 250.
  • buildWhereQuery est mon propre générateur de requêtes dynamiques. Oui, c'est une horrible idée. Oui, je connais SQLalchemy et al. J'ai écrit le mien parce que A. c'est un projet personnel que je ne m'attends pas à utiliser en dehors de mon réseau local domestique, et B. C'est une excellente façon d'apprendre SQL.
  • Je peux considérer que le basculement entre les deux mécanismes de requête dépend de la valeur de l'offset. Il semble que lorsque le décalage est> 1000 et que je filtre des éléments distincts, cette approche commence à dépasser le temps requis pour des procédures comme celles de la réponse de @ ErwinBrandstetter.
  • @ La réponse d'ErwinBrandstetter est toujours une meilleure solution générale. Ce n'est mieux que dans un cas très spécifique.
  • J'ai dû utiliser deux curseurs, pour une raison étrange. Vous ne pouvez pas créer un curseur nommé sauf si vous êtes dans une transaction, mais vous ne pouvez pas démarrer une transaction sans curseur (note - c'est avec le mode autocommitoff). Je dois instancier un curseur anonyme, émettre du SQL (juste un BEGIN, ici), créer mon curseur nommé, l'utiliser, le fermer et enfin valider avec le curseur anonyme.
  • Cela pourrait probablement être fait entièrement en PL/pgSQL, et le résultat serait probablement encore plus rapide, mais je connais bien mieux python.
1
Fake Name