Colunas separadas de mês e ano ou data com dia sempre definida como 1?

15

Estou construindo um banco de dados com o Postgres, onde haverá um monte de coisas agrupadas por monthe year, mas nunca pelo date.

  • Eu poderia criar números inteiros monthe yearcolunas e usá-los.
  • Ou eu poderia ter uma month_yearcoluna e sempre definir o day1.

O primeiro parece um pouco mais simples e mais claro se alguém estiver olhando para os dados, mas o segundo é bom porque usa um tipo adequado.

David N. Welton
fonte
1
Ou você pode criar seu próprio tipo de dados monthque contém dois números inteiros. Mas eu acho que se você nunca, nunca precisar o dia do mês, usando dois inteiros é provavelmente mais fácil
a_horse_with_no_name
1
Você deve declarar o intervalo possível de datas, o número possível de linhas, o que está tentando otimizar (armazenamento, desempenho, segurança, simplicidade?) E (como sempre) sua versão do Postgres.
Erwin Brandstetter 03/03
Relacionado: Como você calcula a matemática que ignora o ano?
Erwin Brandstetter 03/03

Respostas:

17

Pessoalmente, se é uma data, ou pode ser uma data, sugiro sempre armazená-lo como um. É mais fácil trabalhar com isso como regra geral.

  • Uma data é de 4 bytes.
  • Um smallint é de 2 bytes (precisamos de dois)
    • ... 2 bytes: um pequeno por ano
    • ... 2 bytes: um pequeno por mês

Você pode ter uma data que suportará o dia, se você precisar, ou uma smallintpara o ano e o mês, que nunca suportará a precisão extra.

Dados de amostra

Vamos ver um exemplo agora. Vamos criar 1 milhão de datas para nossa amostra. São aproximadamente 5.000 linhas por 200 anos entre 1901 e 2100. Todo ano deve ter algo para todo mês.

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;

Teste

Simples WHERE

Agora podemos testar essas teorias de não usar data. Corri cada uma delas algumas vezes para aquecer as coisas.

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

Agora, vamos tentar o outro método com eles separados

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)

Para ser justo, nem todos são 0,749. Alguns são um pouco mais ou menos, mas isso não importa. Eles são todos relativamente iguais. Simplesmente não é necessário.

Dentro de um mês

Agora, vamos nos divertir com isso. Digamos que você queira encontrar todos os intervalos dentro de 1 mês a partir de janeiro de 2014 (o mesmo mês que usamos acima).

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)

Compare isso com o método combinado

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)

É mais lento e mais feio.

GROUP BY/ORDER BY

Método combinado,

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)

E novamente com o método composto

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)

Conclusão

Geralmente, deixe as pessoas inteligentes fazerem o trabalho duro. Datemath é difícil, meus clientes não me pagam o suficiente. Eu costumava fazer esses testes. Eu estava duro para concluir que poderia obter melhores resultados do que date. Eu parei de tentar.

ATUALIZAÇÕES

@a_horse_with_no_name sugerido para o meu teste dentro de um mêsWHERE (year, month) between (2013, 12) and (2014,2) . Na minha opinião, apesar de legal, é uma consulta mais complexa e prefiro evitá-la, a menos que haja um ganho. Infelizmente, ainda era mais lento, apesar de estar próximo - o que é mais difícil de tirar deste teste. Simplesmente não importa muito.

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)
Evan Carroll
fonte
4
Ao contrário de outros RDBMS (consulte a página 45 de use-the-index-luke.com/blog/2013-07/… ), o Postgres também suporta totalmente o acesso ao índice com valores de linha: stackoverflow.com/a/34291099/939860 Mas isso é um aparte, concordo plenamente: dateé o caminho a percorrer na maioria dos casos.
Erwin Brandstetter 03/03
5

Como alternativa ao método proposto por Evan Carroll, que considero provavelmente a melhor opção, usei em algumas ocasiões (e não especialmente ao usar o PostgreSQL) apenas uma year_monthcoluna do tipo INTEGER(4 bytes), calculada como

 year_month = year * 100 + month

Ou seja, você codifica o mês nos dois dígitos decimais mais à direita (dígito 0 e dígito 1) do número inteiro e o ano nos dígitos 2 a 5 (ou mais, se necessário).

Esta é, até certo ponto, a alternativa de um homem pobre para criar seu próprio year_monthtipo e operadores. Ele tem algumas vantagens, principalmente "clareza de intenção", e algumas economias de espaço (não no PostgreSQL, eu acho), e também alguns inconvenientes, por ter duas colunas separadas.

Você pode garantir que os valores sejam válidos apenas adicionando um

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

Você pode ter uma WHEREcláusula parecida com:

year_month BETWEEN 201610 and 201702 

e funciona de forma eficiente (se a year_monthcoluna estiver adequadamente indexada, é claro).

Você pode agrupar year_monthda mesma maneira que faria com uma data e com a mesma eficiência (pelo menos).

Se você precisar separar yeare month, o cálculo é direto:

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

O que é inconveniente : se você deseja adicionar 15 meses a um, year_monthprecisa calcular (se não cometi um erro ou supervisão):

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

Se você não tomar cuidado, isso pode ser propenso a erros.

Se você deseja obter o número de meses entre dois meses, precisa fazer alguns cálculos semelhantes. É isso (com muitas simplificações) o que realmente acontece nos bastidores da aritmética das datas, que felizmente está escondido de nós por meio de funções e operadores já definidos.

Se você precisar de muitas dessas operações, o uso year_monthnão é muito prático. Caso contrário, é uma maneira muito clara de deixar clara sua intenção.


Como alternativa, você pode definir um year_monthtipo e definir um operador year_month+ intervale também outro year_month- year_month... e ocultar os cálculos. Na verdade, nunca fiz um uso tão pesado que senti a necessidade na prática. A date- datena verdade está escondendo algo parecido.

joanolo
fonte
1
Eu escrevi ainda outra maneira de fazer isso =) aproveite.
Evan Carroll
Eu aprecio o how-to, bem como os prós e contras.
phunehehe
4

Como alternativa ao método de joanolo =) (desculpe, eu estava ocupado, mas queria escrever isso)

ALEGRIA BIT

Nós vamos fazer a mesma coisa, mas com bits. Um int4no PostgreSQL é um número inteiro assinado, variando de -2147483648 a +2147483647

Aqui está uma visão geral da nossa estrutura.

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

Armazenando mês.

  • Um mês exige 12 opções pow(2,4)e 4 bits .
  • O resto que dedicamos ao ano, 32-4 = 28 bits .

Aqui está o nosso mapa de bits de onde os meses são armazenados.

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

Meses, 1 de janeiro a 12 de dezembro

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

Anos. Os 28 bits restantes nos permitem armazenar nossas informações do ano

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

Neste ponto, precisamos decidir como queremos fazer isso. Para nossos propósitos, poderíamos usar um deslocamento estático; se precisarmos cobrir apenas 5.000 dC, poderíamos voltar para o 268,430,455 BCque abrange praticamente todo o Mesozóico e tudo o que é útil para avançar.

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

E agora temos os rudimentos do nosso tipo, que devem expirar em 2.700 anos.

Então, vamos trabalhar para fazer algumas funções.

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;

Um teste rápido mostra esse funcionamento ..

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') );

Agora temos funções que podemos usar em nossos tipos binários.

Poderíamos ter cortado mais um pouco da parte assinada, armazenado o ano como positivo e, depois, classificado naturalmente como um int assinado. Se a velocidade fosse uma prioridade mais alta que o espaço de armazenamento, essa seria a rota que seguimos. Mas, por enquanto, temos uma data que funciona com o mesozóico.

Posso atualizar mais tarde com isso, apenas por diversão.

Evan Carroll
fonte
As faixas ainda não são possíveis, analisarei isso mais tarde.
Evan Carroll
Eu acho que "otimizar ao máximo" faria todo o sentido quando você também faria todas as funções no "nível C baixo". Você economiza o penúltimo bit e o penúltimo nanossegundo ;-) De qualquer forma, alegre! (Ainda me lembro BCD Não necessariamente com alegria..)
joanolo