web-dev-qa-db-fra.com

Colonnes de mois et d'année séparées, ou date avec jour toujours défini sur 1?

Je construis une base de données avec Postgres où il y aura beaucoup de regroupements de choses par month et year, mais jamais par date.

  • Je pourrais créer des colonnes entières month et year et les utiliser.
  • Ou je pourrais avoir un month_year et définissez toujours day sur 1.

Le premier semble un peu plus simple et plus clair si quelqu'un regarde les données, mais le second est agréable en ce qu'il utilise un type approprié.

15
David N. Welton

Personnellement, si c'est une date, ou peut être une date, je suggère de toujours la stocker comme une seule. C'est juste plus facile de travailler avec en règle générale.

  • Une date est de 4 octets.
  • Un smallint fait 2 octets (nous en avons besoin de deux)
    • ... 2 octets: un petit entier par an
    • ... 2 octets: un petit en mois

Vous pouvez avoir une date qui prendra en charge le jour si vous en avez besoin, ou une smallint pour l'année et le mois qui ne prendra jamais en charge la précision supplémentaire.

Exemples de données

Regardons maintenant un exemple. Créons 1 million de dates pour notre échantillon. Cela représente environ 5 000 lignes pendant 200 ans entre 1901 et 2100. Chaque année devrait avoir quelque chose pour chaque mois.

CREATE TABLE foo
AS
  SELECT
    x,
    make_date(year,month,1)::date AS date,
    year::smallint,
    month::smallint
  FROM generate_series(1,1e6) AS gs(x)
  CROSS JOIN LATERAL CAST(trunc(random()*12+1+x-x) AS int) AS month
  CROSS JOIN LATERAL CAST(trunc(random()*200+1901+x-x) AS int) AS year
;
CREATE INDEX ON foo(date);
CREATE INDEX ON foo (year,month);
VACUUM FULL ANALYZE foo;

Essai

Simple WHERE

Maintenant, nous pouvons tester ces théories de ne pas utiliser de date .. J'ai exécuté chacune de ces quelques fois afin de réchauffer les choses.

EXPLAIN ANALYZE SELECT * FROM foo WHERE date = '2014-1-1'
                                                        QUERY PLAN                                                        
--------------------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on foo  (cost=11.56..1265.16 rows=405 width=14) (actual time=0.164..0.751 rows=454 loops=1)
   Recheck Cond: (date = '2014-04-01'::date)
   Heap Blocks: exact=439
   ->  Bitmap Index Scan on foo_date_idx  (cost=0.00..11.46 rows=405 width=0) (actual time=0.090..0.090 rows=454 loops=1)
         Index Cond: (date = '2014-04-01'::date)
 Planning time: 0.090 ms
 Execution time: 0.795 ms

Maintenant, essayons l'autre méthode avec eux séparément

EXPLAIN ANALYZE SELECT * FROM foo WHERE year = 2014 AND month = 1;
                                                           QUERY PLAN                                                           
--------------------------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on foo  (cost=12.75..1312.06 rows=422 width=14) (actual time=0.139..0.707 rows=379 loops=1)
   Recheck Cond: ((year = 2014) AND (month = 1))
   Heap Blocks: exact=362
   ->  Bitmap Index Scan on foo_year_month_idx  (cost=0.00..12.64 rows=422 width=0) (actual time=0.079..0.079 rows=379 loops=1)
         Index Cond: ((year = 2014) AND (month = 1))
 Planning time: 0.086 ms
 Execution time: 0.749 ms
(7 rows)

En toute honnêteté, ils ne sont pas tous 0,749 ... certains sont un peu plus ou moins, mais cela n'a pas d'importance. Ils sont tous relativement les mêmes. Ce n'est tout simplement pas nécessaire.

Dans un mois

Maintenant, amusons-nous avec cela. Disons que vous voulez trouver tous les intervalles dans un délai d'un mois à partir de janvier 2014 (le même mois que nous avons utilisé ci-dessus).

EXPLAIN ANALYZE
  SELECT *
  FROM foo
  WHERE date
    BETWEEN
      ('2014-1-1'::date - '1 month'::interval)::date 
      AND ('2014-1-1'::date + '1 month'::interval)::date;
                                                        QUERY PLAN                                                         
---------------------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on foo  (cost=21.27..2310.97 rows=863 width=14) (actual time=0.384..1.644 rows=1226 loops=1)
   Recheck Cond: ((date >= '2013-12-01'::date) AND (date <= '2014-02-01'::date))
   Heap Blocks: exact=1083
   ->  Bitmap Index Scan on foo_date_idx  (cost=0.00..21.06 rows=863 width=0) (actual time=0.208..0.208 rows=1226 loops=1)
         Index Cond: ((date >= '2013-12-01'::date) AND (date <= '2014-02-01'::date))
 Planning time: 0.104 ms
 Execution time: 1.727 ms
(7 rows)

Comparez cela à la méthode combinée

EXPLAIN ANALYZE
  SELECT *
  FROM foo
  WHERE year = 2013 AND month = 12
    OR ( year = 2014 AND ( month = 1 OR month = 2) );

                                                                 QUERY PLAN                                                                 
--------------------------------------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on foo  (cost=38.79..2999.66 rows=1203 width=14) (actual time=0.664..2.291 rows=1226 loops=1)
   Recheck Cond: (((year = 2013) AND (month = 12)) OR (((year = 2014) AND (month = 1)) OR ((year = 2014) AND (month = 2))))
   Heap Blocks: exact=1083
   ->  BitmapOr  (cost=38.79..38.79 rows=1237 width=0) (actual time=0.479..0.479 rows=0 loops=1)
         ->  Bitmap Index Scan on foo_year_month_idx  (cost=0.00..12.64 rows=421 width=0) (actual time=0.112..0.112 rows=402 loops=1)
               Index Cond: ((year = 2013) AND (month = 12))
         ->  BitmapOr  (cost=25.60..25.60 rows=816 width=0) (actual time=0.218..0.218 rows=0 loops=1)
               ->  Bitmap Index Scan on foo_year_month_idx  (cost=0.00..12.62 rows=420 width=0) (actual time=0.108..0.108 rows=423 loops=1)
                     Index Cond: ((year = 2014) AND (month = 1))
               ->  Bitmap Index Scan on foo_year_month_idx  (cost=0.00..12.38 rows=395 width=0) (actual time=0.108..0.108 rows=401 loops=1)
                     Index Cond: ((year = 2014) AND (month = 2))
 Planning time: 0.256 ms
 Execution time: 2.421 ms
(13 rows)

C'est à la fois plus lent et plus laid.

GROUP BY/ORDER BY

Méthode combinée,

EXPLAIN ANALYZE
  SELECT date, count(*)
  FROM foo
  GROUP BY date
  ORDER BY date;
                                                        QUERY PLAN                                                        
--------------------------------------------------------------------------------------------------------------------------
 Sort  (cost=20564.75..20570.75 rows=2400 width=4) (actual time=286.749..286.841 rows=2400 loops=1)
   Sort Key: date
   Sort Method: quicksort  Memory: 209kB
   ->  HashAggregate  (cost=20406.00..20430.00 rows=2400 width=4) (actual time=285.978..286.301 rows=2400 loops=1)
         Group Key: date
         ->  Seq Scan on foo  (cost=0.00..15406.00 rows=1000000 width=4) (actual time=0.012..70.582 rows=1000000 loops=1)
 Planning time: 0.094 ms
 Execution time: 286.971 ms
(8 rows)

Et encore une fois avec la méthode composite

EXPLAIN ANALYZE
  SELECT year, month, count(*)
  FROM foo
  GROUP BY year, month
  ORDER BY year, month;
                                                        QUERY PLAN                                                        
--------------------------------------------------------------------------------------------------------------------------
 Sort  (cost=23064.75..23070.75 rows=2400 width=4) (actual time=336.826..336.908 rows=2400 loops=1)
   Sort Key: year, month
   Sort Method: quicksort  Memory: 209kB
   ->  HashAggregate  (cost=22906.00..22930.00 rows=2400 width=4) (actual time=335.757..336.060 rows=2400 loops=1)
         Group Key: year, month
         ->  Seq Scan on foo  (cost=0.00..15406.00 rows=1000000 width=4) (actual time=0.010..70.468 rows=1000000 loops=1)
 Planning time: 0.098 ms
 Execution time: 337.027 ms
(8 rows)

Conclusion

En général, laissez les gens intelligents faire le travail acharné. Datemath est difficile, mes clients ne me paient pas assez. J'avais l'habitude de faire ces tests. J'avais du mal à conclure que je pouvais obtenir de meilleurs résultats que date. J'ai arrêté d'essayer.

MISES À JOUR

@a_horse_with_no_name suggéré pour mon dans un délai d'un mois test WHERE (year, month) between (2013, 12) and (2014,2). À mon avis, bien que cool, c'est une requête plus complexe et je préfère l'éviter sauf s'il y a un gain. Hélas, il était encore plus lent bien qu'il soit proche - ce qui est plus à retenir de ce test. Cela n'a pas beaucoup d'importance.

EXPLAIN ANALYZE
  SELECT *
  FROM foo
  WHERE (year, month) between (2013, 12) and (2014,2);

                                                              QUERY PLAN                                                              
--------------------------------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on foo  (cost=5287.16..15670.20 rows=248852 width=14) (actual time=0.753..2.157 rows=1226 loops=1)
   Recheck Cond: ((ROW(year, month) >= ROW(2013, 12)) AND (ROW(year, month) <= ROW(2014, 2)))
   Heap Blocks: exact=1083
   ->  Bitmap Index Scan on foo_year_month_idx  (cost=0.00..5224.95 rows=248852 width=0) (actual time=0.550..0.550 rows=1226 loops=1)
         Index Cond: ((ROW(year, month) >= ROW(2013, 12)) AND (ROW(year, month) <= ROW(2014, 2)))
 Planning time: 0.099 ms
 Execution time: 2.249 ms
(7 rows)
17
Evan Carroll

Comme alternative à la méthode proposée par Evan Carroll, que je considère probablement comme la meilleure option, j'ai utilisé à certaines occasions (et pas spécialement lors de l'utilisation de PostgreSQL) juste un year_month colonne, de type INTEGER (4 octets), calculée comme

 year_month = year * 100 + month

Autrement dit, vous encodez le mois sur les deux chiffres décimaux les plus à droite (chiffre 0 et chiffre 1) du nombre entier et l'année sur les chiffres 2 à 5 (ou plus, si nécessaire).

C'est, dans une certaine mesure, une alternative du pauvre à la construction de votre propre year_month type et opérateurs. Il a quelques avantages, principalement la "clarté de l'intention", et quelques économies d'espace (pas dans PostgreSQL, je pense), ainsi que quelques inconvénients, par rapport à deux colonnes distinctes.

Vous pouvez garantir que les valeurs sont valides en ajoutant simplement un

CHECK ((year_date % 100) BETWEEN 1 AND 12)   /*  % = modulus operator */

Vous pouvez avoir une clause WHERE ressemblant à:

year_month BETWEEN 201610 and 201702 

et cela fonctionne efficacement (si le year_month la colonne est bien indexée, bien sûr).

Vous pouvez regrouper par year_month de la même manière que vous pourriez le faire avec une date et avec la même efficacité (au moins).

Si vous devez séparer year et month, le calcul est simple:

month = year_month % 100    -- % is modulus operator
year  = year_month / 100    -- / is integer division 

Ce qui est peu pratique: si vous voulez ajouter 15 mois à un year_month vous devez calculer (si je n'ai pas fait d'erreur ou de négligence):

year_month + delta (months) = ...

    /* intermediate calculations */
    year = year_month/100 + delta/12    /* years we had + new years */
           + (year_month % 100 + delta%12) / 12  /* extra months make 1 more year? */
    month = ((year_month%10) + (delta%12) - 1) % 12 + 1

/* final result */
... = year * 100 + month

Si vous ne faites pas attention, cela peut être sujet à des erreurs.

Si vous souhaitez obtenir le nombre de mois entre deux year_months, vous devez effectuer des calculs similaires. C'est (avec beaucoup de simplifications) ce qui se passe vraiment sous le capot avec l'arithmétique des dates, qui nous est heureusement caché par des fonctions et des opérateurs déjà définis.

Si vous avez besoin de beaucoup de ces opérations, utilisez year_month n'est pas trop pratique. Si vous ne le faites pas, c'est une façon très claire de clarifier votre intention.


Comme alternative, vous pouvez définir un year_month tapez et définissez un opérateur year_month + interval, ainsi qu'un autre year_month - year_month ... et masquer les calculs. En fait, je n'ai jamais fait une telle utilisation au point d'en ressentir le besoin dans la pratique. Un date-date vous cache en fait quelque chose de similaire.

5
joanolo

Comme alternative à la méthode de joanolo =) (désolé j'étais occupé mais je voulais écrire ceci)

BIT JOY

Nous allons faire la même chose, mais avec des bits. Un int4 Dans PostgreSQL est un entier signé, allant de -2147483648 à +2147483647

Voici un aperçu de notre structure.

               bit                
----------------------------------
 YYYYYYYYYYYYYYYYYYYYYYYYYYYYMMMM

Mois de stockage.

  • Un mois nécessite 12 options pow(2,4) est 4 bits .
  • Le reste que nous consacrons à l'année, 32-4 = 28 bits .

Voici notre bitmap de l'endroit où les mois sont stockés.

               bit                
----------------------------------
 00000000000000000000000000001111

Mois, 1er janvier - 12 décembre

               bit                
----------------------------------
 00000000000000000000000000000001
               bit                
----------------------------------
 00000000000000000000000000001100

Années. Les 28 bits restants nous permettent de stocker nos informations sur l'année

SELECT (pow(2,28)-1)::int;
   int4    
-----------
 268435455
(1 row)

À ce stade, nous devons décider comment nous voulons procéder. Pour nos besoins, nous pourrions utiliser un décalage statique, si nous avons seulement besoin de couvrir 5 000 AD, nous pourrions revenir à 268,430,455 BC Qui couvre à peu près l'intégralité de Mésozoïque et tout ce qui est utile de se déplacer vers l'avant.

SELECT (pow(2,28)-1)::int4::bit(32) << 4;
               year               
----------------------------------
 11111111111111111111111111110000

Et maintenant, nous avons les rudiments de notre type, qui expireront dans 2 700 ans.

Commençons donc à créer certaines fonctions.

CREATE DOMAIN year_month AS int4;

CREATE OR REPLACE FUNCTION to_year_month (cstring text)
RETURNS year_month
AS $$
  SELECT (
    ( ((date[1]::int4 - 5000) * -1)::bit(32) << 4 )
    | date[2]::int4::bit(32)
  )::year_month
  FROM regexp_split_to_array(cstring,'-(?=\d{1,2}$)')
    AS t(date)
$$
LANGUAGE sql
IMMUTABLE;

CREATE OR REPLACE FUNCTION year_month_to_text (ym year_month)
RETURNS text
AS $$
  SELECT ((ym::bit(32) >>4)::int4 * -1 + 5000)::text ||
  '-' ||
  (ym::bit(32) <<28 >>28)::int4::text
$$ LANGUAGE sql
IMMUTABLE;

Un test rapide montre que cela fonctionne ..

SELECT year_month_to_text( to_year_month('2014-12') );
SELECT year_month_to_text( to_year_month('-5000-10') );
SELECT year_month_to_text( to_year_month('-8000-10') );
SELECT year_month_to_text( to_year_month('-84398-10') );

Nous avons maintenant des fonctions que nous pouvons utiliser sur nos types binaires.

Nous aurions pu couper un bit de plus de la partie signée, enregistrer l'année comme positive, puis la faire trier naturellement comme un entier signé. Si la vitesse était une priorité plus élevée que l'espace de stockage, ce serait la voie que nous emprunterions. Mais pour l'instant, nous avons une date qui fonctionne avec le Mésozoïque.

Je peux mettre à jour plus tard avec ça, juste pour le plaisir.

4
Evan Carroll