Linha vs coluna: como bancos de dados armazenam dados

Linha vs coluna testado com 3 milhões de linhas em PostgreSQL, CSV e Parquet: bytes lidos, compressão e quais consultas rodam mais rápido.

Linha vs coluna

Um armazenamento orientado a linhas (row store), como o PostgreSQL, guarda todos os valores de um registro lado a lado no disco. Um armazenamento orientado a colunas (column store), como BigQuery, Snowflake, Redshift, ClickHouse ou um arquivo Parquet, guarda lado a lado todos os valores de uma coluna. Essa única escolha decide quantos bytes uma consulta precisa ler, quão bem os dados se comprimem e quão barato é alterar um único registro.

Este guia trata desse layout físico, a ordem dos bytes no disco. Se você chegou aqui procurando sobre planilhas: uma linha vai da esquerda para a direita, uma coluna vai de cima para baixo, e o resto desta página é sobre bancos de dados. Para deixar as diferenças concretas, montamos a mesma tabela de pedidos com 3.000.000 de linhas de três formas (uma tabela heap no PostgreSQL, um arquivo CSV, ou seja, texto separado por vírgulas, e um arquivo Parquet), rodamos as mesmas consultas nas três e colocamos os números nas tabelas abaixo.

O que significa "linha vs coluna" no armazenamento de bancos de dados?

O armazenamento orientado a linhas grava os valores de um registro um após o outro, depois o próximo registro. O armazenamento orientado a colunas grava todos os valores da primeira coluna, depois todos os valores da segunda coluna, e assim por diante. A documentação do SQL Server, da Microsoft, define da mesma forma: um rowstore guarda os dados linha a linha, um columnstore guarda coluna a coluna.

Aqui está uma pequena tabela de pedidos nos dois layouts. A tabela lógica é a mesma. Só muda a ordem dos bytes no disco.

ROW STORE (one page holds whole rows)
[101 | 7 | paid | 20.00] [102 | 7 | paid | 35.00] [103 | 9 | refunded | 40.00]

COLUMN STORE (one row group holds one chunk per column)
order_id: [101, 102, 103]
customer_id: [7, 7, 9]
status: [paid, paid, refunded]
total: [20.00, 35.00, 40.00]

Uma consulta como a de baixo precisa de duas das quatro colunas. No layout em colunas, ela lê o bloco de customer_id e o bloco de total e pula o resto. No layout em linhas, cada linha lida carrega order_id e status junto, de carona.

SELECT customer_id, SUM(total)
FROM orders
GROUP BY customer_id;

As engines reais adicionam cabeçalhos de página, bitmaps de nulos, metadados, índices e compressão por cima desse desenho. O resto do artigo olha esses detalhes com arquivos reais.

Como row stores e column stores organizam os bytes no disco?

O PostgreSQL guarda uma tabela como um array de páginas de tamanho fixo, normalmente de 8 kB, e cada item de uma página é uma linha. O Parquet guarda uma tabela como row groups (grupos de linhas), e cada row group contém um bloco contínuo por coluna.

No lado do PostgreSQL, toda tabela é um array de páginas de 8 kB. Uma página começa com um cabeçalho de 24 bytes, depois um ponteiro de 4 bytes por item, e então as próprias linhas. Cada linha tem seu próprio cabeçalho de 23 bytes antes dos valores das colunas. Para ler a coluna amount da linha 2.000.000, o PostgreSQL lê a página inteira de 8 kB em que essa linha está.

No lado do Parquet, o formato de arquivo divide a tabela em row groups, e cada row group contém um column chunk (bloco de coluna) por coluna. Os leitores primeiro leem os metadados no rodapé para encontrar os blocos de que precisam e depois buscam só esses. A especificação do Parquet recomenda row groups de 512 MB a 1 GB e páginas de dados de cerca de 8 KB.

É assim que um leitor de Parquet responde a uma consulta sobre três das nossas sete colunas:

How Parquet reads only needed columns

Nossa tabela de pedidos com 3.000.000 de linhas (sete colunas: order_id, customer_id, order_date, status, amount, category e uma string notes de 40 caracteres) ficou assim:

Armazenamento

Tamanho em disco

Unidade de armazenamento

Tabela heap do PostgreSQL 15

316,21 MB (40.475 páginas de 8.192 bytes)

Página de 8 kB com linhas inteiras

Índice de chave primária do PostgreSQL

64,28 MB a mais

Páginas de B-tree

Arquivo CSV

265,64 MB

Uma linha de texto por registro

Arquivo Parquet (ZSTD)

91,85 MB

25 row groups, cada um com um bloco por coluna

O arquivo Parquet é 3,4 vezes menor que a tabela do PostgreSQL para as mesmas linhas (316,21 / 91,85 = 3,44). A razão está nas duas próximas seções: um column store lê menos bytes e comprime cada coluna separadamente.

Por que um column store lê menos dados em uma consulta analítica?

Um column store lê só as colunas que a consulta cita, enquanto um row store lê páginas inteiras que carregam todas as colunas. No nosso teste, o PostgreSQL leu todas as 40.475 páginas (316 MB) para responder a uma agregação de três colunas, enquanto essas mesmas três colunas ocupam 20,7 MB do arquivo Parquet.

A consulta que cronometramos soma um mês de pedidos por cliente:

SELECT customer_id, SUM(amount) AS total
FROM orders
WHERE order_date >= DATE '2024-06-01'
AND order_date < DATE '2024-07-01'
GROUP BY customer_id
ORDER BY total DESC
LIMIT 5;

Ela usa três das sete colunas. O plano do PostgreSQL mostra o que ele precisou ler para responder:

Parallel Seq Scan on orders
Filter: ((order_date >= '2024-06-01'::date) AND (order_date < '2024-07-01'::date))
Rows Removed by Filter: 918042
Buffers: shared hit=3233 read=37242
Execution Time: 250.521 ms

Shared hit mais read dá 3.233 + 37.242 = 40.475 buffers. São todas as páginas da tabela, porque a coluna notes e as outras colunas que a consulta ignora dividem as páginas com as que ela precisa. No arquivo Parquet, os três column chunks necessários somam 20.678.569 bytes de 91.850.195 (20.678.569 / 91.850.195 = 22,5%). O resto do arquivo, principalmente a coluna notes, nunca é lido.

Estes são os tempos medianos para a mesma consulta na mesma máquina (64 CPUs, 247 GiB de RAM). O DuckDB 1.5.5 rodou as consultas no CSV e no Parquet; o PostgreSQL 15.19 rodou a tabela heap com dois workers paralelos. Os resultados bateram nos três.

Consulta

CSV via DuckDB

Tabela heap do PostgreSQL

Parquet via DuckDB

Agregação de junho (3 de 7 colunas)

352,45 ms

204,01 ms

31,82 ms

count(*) sobre 3.000.000 de linhas

254,80 ms

101,43 ms

0,49 ms

O count no Parquet leva menos de um milissegundo porque o número de linhas está nos metadados do arquivo. O PostgreSQL precisa varrer a tabela para contar as linhas ativas. São duas engines diferentes, então a diferença mistura o layout com o design da engine, mas os números de buffers acima mostram a parte do layout: um lado leu 316 MB, o outro leu cerca de 21 MB.

Por que dados em colunas comprimem melhor?

Dados em colunas comprimem melhor porque os valores vizinhos no disco vêm da mesma coluna, então compartilham o tipo e muitas vezes se repetem. Uma coluna de status com quatro valores possíveis encolhe muito mais do que uma página que mistura ids, datas, texto e valores monetários.

Os números por coluna do nosso arquivo Parquet deixam isso visível. O tamanho "codificado" é depois da etapa de codificação do próprio Parquet (codificações de dicionário e do tipo run-length) e antes da compressão ZSTD; a especificação do Parquet trata codificação e compressão como etapas separadas e permite escolher a compressão por coluna.

Coluna

Valores brutos

Tamanho codificado

Depois do ZSTD

Redução

status (4 valores distintos)

3.000.000 de strings

1.138.943 bytes

15.431 bytes

74x a partir do codificado

category (8 valores distintos)

3.000.000 de strings

1.514.951 bytes

20.944 bytes

72x a partir do codificado

order_id (sequencial)

24.000.000 bytes

24.000.775 bytes

3.084.597 bytes

7,8x

amount (centavos aleatórios)

24.000.000 bytes

24.000.775 bytes

9.947.683 bytes

2,4x

notes (texto aleatório de 40 caracteres)

132.000.000 bytes

132.000.825 bytes

68.016.030 bytes

1,9x

Duas coisas chamam atenção. Primeiro, a coluna status já estava em 1,1 MB antes da compressão, porque a codificação trocou 3.000.000 de strings por pequenos códigos de dicionário. Segundo, a coluna notes, aleatória, quase não encolhe e acaba sendo a maior parte do arquivo (68.016.030 / 91.850.195 = 74%). O armazenamento em colunas ajuda mais em colunas com repetições ou padrões, e menos em texto aleatório.

Os warehouses usam as mesmas ideias. O formato Capacitor do BigQuery usa run-length encoding, codificação de dicionário, técnicas de bit-vector e frame-of-reference, e coleta estatísticas de cada coluna enquanto codifica. A Citus reportou uma taxa de compressão de 8,02x para sua tabela colunar no PostgreSQL, em um dataset de teste próprio, e a Microsoft cita até 7x de compressão para índices columnstore do SQL Server em relação ao rowstore. São números dos fornecedores em datasets dos fornecedores, então o número com os seus dados pode cair em qualquer ponto dessa faixa.

Por que leituras e atualizações de uma única linha são baratas em um row store e caras em um column store?

Um row store encontra um registro por meio de um índice e lê uma página que já tem todas as colunas desse registro. Um column store precisa acessar um bloco por coluna para remontar o mesmo registro, e guarda os dados em unidades comprimidas e ordenadas que não podem ser editadas no lugar.

Nosso teste mostra o lado da leitura. Buscando um pedido pela chave primária:

SELECT * FROM orders WHERE order_id = 1234567;

Armazenamento

Tempo mediano

O que aconteceu

Heap do PostgreSQL + chave primária

0,06 ms

Index scan, 4 buffers acessados

Parquet via DuckDB

8,64 ms

Sem índice; o leitor localiza o row group e depois busca a linha nos 7 column chunks

CSV via DuckDB

615,29 ms

Leitura completa do arquivo

O plano do PostgreSQL para essa consulta lê quatro páginas de 8 kB no total: três páginas do índice e uma da tabela. Uma atualização de uma única linha na mesma tabela levou 1,06 ms no nosso teste.

Os column stores lidam com escritas colocando-as em uma área intermediária e depois reescrevendo as unidades em segundo plano:

Por isso faz sentido manter a aplicação em um row store e os relatórios em um column store, com algo movendo os dados entre eles em lotes.

Como particionamento e clustering reduzem a varredura em um warehouse?

O particionamento divide uma tabela em grandes blocos por uma coluna (normalmente uma data), e um filtro nessa coluna permite que o warehouse pule partições inteiras. O clustering ordena os dados dentro de cada partição por até algumas colunas a mais, para que o warehouse também pule blocos cujos valores mínimo e máximo ficam fora do filtro.

No BigQuery, uma tabela particionada pode ser dividida por horário de ingestão, por uma coluna de data ou timestamp, ou por um intervalo de inteiros, e uma tabela só pode ter uma coluna de particionamento. Cada tabela pode ter até 10.000 partições. Quando uma consulta filtra pela coluna de particionamento, o BigQuery lê só as partições correspondentes.

Uma tabela clusterizada ordena os blocos de armazenamento dentro de cada partição por até quatro colunas, e a ordem em que você as lista importa. A documentação dá este exemplo, com uma tabela de vendas particionada por dia e clusterizada por customer_id:

SELECT SUM(totalSale)
FROM `mydataset.ClusteredSalesData`
WHERE customer_id BETWEEN 20000 AND 23000
AND DATE(timestamp) = "2016-05-01";

O BigQuery descarta o bloco B3 porque a data dele não corresponde, descarta o bloco B1 porque a faixa de customer_id dele não se sobrepõe e varre só as colunas timestamp, customer_id e totalSale nos blocos B2 e B4. Dois níveis de descarte acontecem antes de qualquer column chunk ser lido:

How BigQuery skips days and blocks


Snowflake e Redshift fazem a mesma coisa, com outros nomes e outras unidades:

Warehouse

Unidade grossa

Unidade fina e regra de descarte

Limites na documentação

BigQuery

Partição (uma coluna)

Blocos de armazenamento clusterizados, ordenados por até 4 colunas

10.000 partições por tabela, 4 colunas de clustering

Snowflake

Micro-partitions de 50 a 500 MB sem compressão, criadas automaticamente

Os metadados guardam a faixa de valores por coluna; o pruning descarta primeiro micro-partitions, depois colunas

Chave de clustering de 3 ou 4 colunas recomendada

Redshift

Ordenação pela sort key

Blocos de disco de 1 MB com mínimo e máximo por bloco; no exemplo da AWS, um filtro de um mês sobre cinco anos de dados ordenados por data pode pular até 98% dos blocos

Sort key intercalada de até 8 colunas

ClickHouse

Partição (opcional)

Parts ordenadas com um índice esparso, uma entrada por grânulo de 8.192 linhas, por padrão

A documentação desaconselha particionamento muito fino; o ORDER BY é a principal alavanca

Arquivo Parquet

Row group

Column chunk, localizado pelos metadados do rodapé

A especificação recomenda row groups de 512 MB a 1 GB

Particionar por dia e clusterizar pela coluna mais usada em filtros é a configuração que a documentação do BigQuery sugere quando uma tabela recebe atualizações frequentes ao longo de um período amplo. O custo do clustering aparece no lado da escrita: a documentação do Snowflake diz que reclusterizar uma tabela com muito DML consome mais créditos, porque cada reclustering reescreve micro-partitions.

Um mesmo banco pode ser orientado a linhas e a colunas ao mesmo tempo?

Sim. Várias engines mantêm um layout em linhas para transações e um layout em colunas para analytics ao mesmo tempo, seja como uma segunda cópia dos dados, seja como uma escolha por tabela.

Sistema

Como combina os dois

Detalhe da documentação

Citus Columnar (PostgreSQL)

Criar uma tabela com USING columnar ao lado das tabelas heap normais

No teste deles: 444 s no heap contra 18 s no colunar para uma agregação; na época, as tabelas colunares não suportavam UPDATE nem DELETE

pg_duckdb (PostgreSQL)

Extensão que roda SQL analítico no motor colunar do DuckDB dentro do PostgreSQL

Suporta PostgreSQL 14 a 18; as tabelas heap continuam como estão

SQL Server

Índice columnstore não clusterizado em uma tabela rowstore

Rowgroups de 1.048.576 linhas; pequenas escritas vão primeiro para o deltastore

Oracle Database In-Memory

Formatos em linha e em coluna mantidos ao mesmo tempo

As unidades em coluna são comprimidas e somente leitura; o DML passa pelo buffer cache baseado em linhas

SingleStore

Escolha por tabela entre rowstore em memória e columnstore em disco

A documentação indica rowstore para tabelas com muitas atualizações e columnstore para tabelas carregadas em lote

MySQL HeatWave

Acelerador de consultas em memória acoplado a um banco MySQL

A proposta da Oracle é analytics sobre dados transacionais sem uma cópia separada de ETL (extract, transform, load)

O padrão da Citus mostra bem como isso é usado: a partição do mês atual continua como tabela heap para que a aplicação possa atualizá-la, e as partições mais antigas são convertidas para colunar, com armazenamento barato e varreduras rápidas.

Onde ELT e CDC entram entre o PostgreSQL ou MySQL e um warehouse colunar?

Uma ferramenta de ELT (extract, load e depois transform dentro do warehouse) lê as mudanças do write-ahead log do row store, junta essas mudanças em arquivos e carrega esses arquivos no warehouse em lotes. Isso combina com o caminho de escrita do column store: poucas cargas grandes em vez de muitas escritas de uma linha.

Change data capture (CDC) significa ler o próprio log de mudanças do banco em vez de consultar as tabelas. No PostgreSQL, a Erathos lê o WAL (write-ahead log), o que exige wal_level definido como logical e REPLICA IDENTITY FULL nas tabelas em que você quer o estado completo de antes e depois de updates e deletes. No MySQL, ela lê o binlog em formato ROW com imagens FULL das linhas. Os dois conectores oferecem dois modos de snapshot: initial (copia a tabela uma vez e depois transmite as mudanças) e no_data (transmite só as mudanças).

O lado da carga funciona em lotes por design. A Erathos extrai os dados, grava em arquivos temporários em um bucket na nuvem, carrega o warehouse com um comando COPY ou o equivalente do destino e apaga os arquivos temporários quando o job termina. Um COPY de um arquivo em lote é o tipo de escrita para o qual BigQuery e Redshift foram feitos, e fica longe das cotas de DML por comando citadas acima.

Este é o caminho de uma mudança em uma única linha até uma carga no warehouse:

How CDC batches row changes

No destino, a Erathos carrega em BigQuery e Redshift, com pipelines incrementais por padrão e um tipo de atualização por tabela: lote, incremental por cursor ou CDC. Há um guia passo a passo de MySQL CDC para BigQuery se você quiser ver a configuração de ponta a ponta.

O que é mais rápido: armazenamento em linhas ou em colunas?

Nenhum dos dois é mais rápido em geral; depende do formato da consulta. O armazenamento em linhas vence quando a consulta precisa de um registro ou de uma faixa pequena, com todas as colunas. O armazenamento em colunas vence quando a consulta varre muitas linhas e precisa de poucas colunas.

Nossos números, em uma única máquina, resumem isso:

Formato da consulta

Heap do PostgreSQL

Parquet via DuckDB

Vencedor

Um registro pela chave primária

0,06 ms

8,64 ms

Row store, 144x

Atualização de uma linha

1,06 ms

Reescrita de uma unidade comprimida

Row store

Soma de um mês, 3 de 7 colunas

204,01 ms

31,82 ms

Column store, 6,4x

count(*)

101,43 ms

0,49 ms

Column store, pelos metadados

A orientação da Microsoft para o SQL Server traça a mesma linha: rowstore para buscas pontuais e faixas pequenas em cargas transacionais, columnstore para grandes varreduras em cargas de data warehouse. Para um conjunto maior de números entre engines, o ClickBench publica tempos de consultas analíticas para PostgreSQL, DuckDB, ClickHouse, BigQuery, Snowflake, Redshift e configurações com Parquet. Compare apenas linhas com o mesmo tipo de máquina, porque o ranking mistura hardware e datas.

Perguntas frequentes

O PostgreSQL é orientado a linhas ou a colunas?

A linhas. As tabelas heap padrão do PostgreSQL guardam as linhas como itens em páginas de tamanho fixo, normalmente de 8 kB. Extensões como Citus Columnar e pg_duckdb adicionam uma opção colunar, mas a tabela padrão continua orientada a linhas.

O BigQuery é colunar?

Sim. O BigQuery converte os dados carregados para seu próprio formato colunar, o Capacitor, que codifica cada coluna separadamente e guarda níveis de definição e repetição, para conseguir remontar registros aninhados apenas com as colunas que a consulta pede.

O Parquet é orientado a linhas ou a colunas?

A colunas. A especificação descreve o Parquet como um formato de arquivo de dados orientado a colunas: um arquivo contém row groups, um row group contém exatamente um bloco por coluna e um bloco contém páginas. No nosso arquivo de 3.000.000 de linhas, uma consulta sobre três de sete colunas precisou de 20,7 MB dos 91,9 MB do arquivo.

O CSV é orientado a linhas?

Sim, no sentido simples de que um arquivo CSV tem uma linha de texto por registro, com todos os campos em ordem. Ele guarda tudo como texto puro, sem os blocos por coluna, os metadados de rodapé ou a compressão do Parquet. No nosso teste, as mesmas linhas ocuparam 265,64 MB em CSV e 91,85 MB em Parquet, e um count(*) levou 254,80 ms no CSV contra 0,49 ms no Parquet.

SQL é um banco de dados colunar?

Não. SQL é uma linguagem de consulta. O mesmo SQL roda em row stores (PostgreSQL, MySQL) e em column stores (BigQuery, Snowflake, Redshift, ClickHouse). O layout de armazenamento é uma escolha da engine, e não dá para identificá-lo pela consulta.

O que é mais rápido, um banco orientado a linhas ou um banco colunar?

Depende da consulta. No nosso teste, uma busca pela chave primária foi 144 vezes mais rápida na tabela heap do PostgreSQL (0,06 ms contra 8,64 ms), e uma agregação de um mês sobre três colunas foi 6,4 vezes mais rápida no Parquet (31,82 ms contra 204,01 ms).

Um banco pode ser orientado a linhas e a colunas ao mesmo tempo?

Sim. O SQL Server pode adicionar um índice columnstore a uma tabela rowstore, o Oracle Database In-Memory mantém os dois formatos ao mesmo tempo e o PostgreSQL ganha uma opção colunar com Citus Columnar ou pg_duckdb.

Leve os dados do seu row store para um column store

Se a sua aplicação roda em PostgreSQL ou MySQL e seus relatórios rodam em BigQuery ou Redshift, o trabalho no meio é capturar as mudanças linha a linha e carregá-las em lotes. Teste a Erathos grátis por 14 dias e configure um pipeline de CDC do seu banco para o seu warehouse.