Na última vez em que conversamos sobre a consistência dos dados, analisamos a diferença entre os níveis de isolamento de transações do ponto de vista do usuário e descobrimos por que isso é importante. Agora estamos começando a explorar como o PostgreSQL implementa isolamento de instantâneo e simultaneidade de várias versões.
Neste artigo, veremos como os dados são fisicamente dispostos em arquivos e páginas. Isso nos impede de discutir o isolamento, mas essa digressão é necessária para entender o que se segue. Precisamos descobrir como o armazenamento de dados é organizado em um nível baixo.
Relações
Se você olhar dentro de tabelas e índices, verifica-se que eles estão organizados de maneira semelhante. Ambos são objetos de banco de dados que contêm alguns dados que consistem em linhas.
Não há dúvida de que uma tabela consiste em linhas, mas isso é menos óbvio para um índice. No entanto, imagine uma árvore B: consiste em nós que contêm valores indexados e referências a outros nós ou linhas da tabela. São esses nós que podem ser considerados linhas de índice e, de fato, são.
Na verdade, mais alguns objetos são organizados de maneira semelhante: sequências (essencialmente tabelas de linha única) e visualizações materializadas (essencialmente, tabelas que lembram a consulta). E também há visualizações regulares, que não armazenam dados, mas são, em todos os outros sentidos, semelhantes às tabelas.
Todos esses objetos no PostgreSQL são chamados de
relação de palavras comuns. Essa palavra é extremamente imprópria porque é um termo da teoria relacional. Você pode desenhar um paralelo entre uma relação e uma tabela (exibição), mas certamente não entre uma relação e um índice. Mas aconteceu: a origem acadêmica do PostgreSQL se manifesta. Parece-me que foram as tabelas e visões que foram chamadas primeiro, e o restante aumentou com o tempo.
Para ser mais simples, discutiremos mais tabelas e índices, mas as outras
relações são organizadas exatamente da mesma maneira.
Garfos e arquivos
Geralmente vários
garfos correspondem a cada relação. Os garfos podem ter vários tipos e cada um deles contém um certo tipo de dados.
Se houver uma bifurcação, ela será representada primeiro pelo único
arquivo . O nome do arquivo é um identificador numérico, que pode ser anexado por uma finalização que corresponde ao nome do fork.
O arquivo aumenta gradualmente e quando seu tamanho atinge 1 GB, um novo arquivo da mesma bifurcação é criado (arquivos como esses às vezes são chamados de
segmentos ). O número ordinal do segmento é anexado no final do nome do arquivo.
A limitação de 1 GB do tamanho do arquivo surgiu historicamente para oferecer suporte a diferentes sistemas de arquivos, alguns dos quais não podem lidar com arquivos de tamanho maior. Você pode alterar essa limitação ao criar o PostgreSQL (
./configure --with-segsize
).
Portanto, vários arquivos no disco podem corresponder a uma relação. Por exemplo, para uma mesa pequena, haverá três delas.
Todos os arquivos de objetos que pertencem a um espaço de tabela e um banco de dados serão armazenados em um diretório. Você precisa ter isso em mente, pois os sistemas de arquivos geralmente não funcionam bem com um grande número de arquivos em um diretório.
Observe aqui que os arquivos, por sua vez, são divididos em
páginas (ou
blocos ), geralmente por 8 KB. Discutiremos um pouco mais a estrutura interna das páginas.

Agora vamos ver os tipos de garfos.
A
bifurcação principal são os próprios dados: a própria tabela e as linhas de índice. A bifurcação principal está disponível para quaisquer relações (exceto exibições que não contêm dados).
Os nomes dos arquivos da bifurcação principal consistem no único identificador numérico. Por exemplo, este é o caminho para a tabela que criamos na última vez:
=> SELECT pg_relation_filepath('accounts');
pg_relation_filepath ---------------------- base/41493/41496 (1 row)
De onde vêm esses identificadores? O diretório "base" corresponde ao espaço de tabela "pg_default". O próximo subdiretório, correspondente ao banco de dados, é onde o arquivo de interesse está localizado:
=> SELECT oid FROM pg_database WHERE datname = 'test';
oid ------- 41493 (1 row)
=> SELECT relfilenode FROM pg_class WHERE relname = 'accounts';
relfilenode ------------- 41496 (1 row)
O caminho é relativo, é especificado a partir do diretório de dados (PGDATA). Além disso, praticamente todos os caminhos no PostgreSQL são especificados a partir do PGDATA. Graças a isso, você pode mover o PGDATA com segurança para um local diferente - nada o restringe (exceto pode ser necessário definir o caminho para as bibliotecas em LD_LIBRARY_PATH).
Além disso, olhando para o sistema de arquivos:
postgres$ ls -l --time-style=+ /var/lib/postgresql/11/main/base/41493/41496
-rw------- 1 postgres postgres 8192 /var/lib/postgresql/11/main/base/41493/41496
A
bifurcação de inicialização está disponível apenas para tabelas não registradas (criadas com UNLOGGED especificado) e seus índices. Objetos como esses não são muito diferentes dos objetos regulares, exceto que as operações com eles não são registradas no log write-ahead (WAL). Por isso, é mais rápido trabalhar com eles, mas é impossível recuperar os dados no estado consistente em caso de falha. Portanto, durante uma recuperação, o PostgreSQL apenas remove todos os garfos desses objetos e grava o garfo de inicialização no lugar do garfo principal. Isso resulta em um objeto vazio. Discutiremos o log em detalhes, mas em outra série.
A tabela "accounts" é registrada e, portanto, não possui um fork de inicialização. Mas, para experimentar, podemos desativar o logoff:
=> ALTER TABLE accounts SET UNLOGGED; => SELECT pg_relation_filepath('accounts');
pg_relation_filepath ---------------------- base/41493/41507 (1 row)
O exemplo esclarece que a possibilidade de ativar e desativar o log on-line está associada à reescrita dos dados em arquivos com nomes diferentes.
Um fork de inicialização tem o mesmo nome que o fork principal, mas com o sufixo "_init":
postgres$ ls -l --time-style=+ /var/lib/postgresql/11/main/base/41493/41507_init
-rw------- 1 postgres postgres 0 /var/lib/postgresql/11/main/base/41493/41507_init
O
mapa de espaço livre é uma bifurcação que monitora a disponibilidade de espaço livre dentro das páginas. Esse espaço está mudando constantemente: diminui quando novas versões de linhas são adicionadas e aumenta durante a aspiração. O mapa de espaço livre é usado durante a inserção de novas versões de linha para encontrar rapidamente uma página adequada, na qual os dados a serem adicionados serão adequados.
O nome do mapa de espaço livre possui o sufixo "_fsm". Mas esse arquivo não aparece imediatamente, mas apenas quando necessário. A maneira mais fácil de conseguir isso é aspirar uma mesa (explicaremos o porquê quando chegar a hora):
=> VACUUM accounts;
postgres$ ls -l --time-style=+ /var/lib/postgresql/11/main/base/41493/41507_fsm
-rw------- 1 postgres postgres 24576 /var/lib/postgresql/11/main/base/41493/41507_fsm
O
mapa de visibilidade é uma bifurcação em que as páginas que contêm apenas versões de linha atualizadas são marcadas por um bit. Grosso modo, significa que quando uma transação tenta ler uma linha dessa página, a linha pode ser mostrada sem verificar sua visibilidade. Nos próximos artigos, discutiremos em detalhes como isso acontece.
postgres$ ls -l --time-style=+ /var/lib/postgresql/11/main/base/41493/41507_vm
-rw------- 1 postgres postgres 8192 /var/lib/postgresql/11/main/base/41493/41507_vm
Páginas
Como já mencionado, os arquivos são logicamente divididos em páginas.
Uma página geralmente tem o tamanho de 8 KB. O tamanho pode ser alterado dentro de certos limites (16 KB ou 32 KB), mas apenas durante a compilação (
./configure --with-blocksize
). Uma instância criada e executada pode funcionar apenas com páginas do mesmo tamanho.
Independentemente da bifurcação à qual os arquivos pertencem, o servidor os utiliza de maneira bastante semelhante. As páginas são lidas primeiro no cache do buffer, onde os processos podem lê-los e alterá-los; então, conforme a necessidade, eles são despejados de volta ao disco.
Cada página possui particionamento interno e, em geral, contém as seguintes partições:
0 + ----------------------------------- +
| cabeçalho |
24 + ----------------------------------- +
| matriz de ponteiros para versões de linha |
menor + ----------------------------------- +
| espaço livre |
superior + ----------------------------------- +
| versões de linha |
especial + ----------------------------------- +
| espaço especial |
tamanho da página + ----------------------------------- +
Você pode conhecer facilmente os tamanhos dessas partições usando a página de extensão "research";
=> CREATE EXTENSION pageinspect; => SELECT lower, upper, special, pagesize FROM page_header(get_raw_page('accounts',0));
lower | upper | special | pagesize -------+-------+---------+---------- 40 | 8016 | 8192 | 8192 (1 row)
Aqui estamos olhando o
cabeçalho da primeira página (zero) da tabela. Além dos tamanhos de outras áreas, o cabeçalho possui informações diferentes sobre a página, das quais ainda não estamos interessados.
Na parte inferior da página, há o
espaço especial , que está vazio neste caso. É usado apenas para índices, e mesmo não para todos eles. "Na parte inferior" aqui reflete o que está na foto; pode ser mais preciso dizer "em endereços altos".
Após o espaço especial, as
versões das linhas são localizadas, ou seja, esses mesmos dados que armazenamos na tabela, além de algumas informações internas.
No topo de uma página, logo após o cabeçalho, há o índice: a
matriz de ponteiros para versões de linha disponíveis na página.
É possível deixar
espaço livre entre versões de linha e ponteiros (esse espaço livre é mantido no mapa de espaço livre). Observe que não há fragmentação de memória dentro de uma página - todo o espaço livre é representado por uma área contígua.
Ponteiros
Por que os ponteiros para as versões de linha são necessários? O problema é que as linhas de índice devem, de alguma forma, fazer referência às versões de linha na tabela. É claro que a referência deve conter o número do arquivo, o número da página no arquivo e alguma indicação da versão da linha. Poderíamos usar o deslocamento desde o início da página como indicador, mas é inconveniente. Não poderíamos mover uma versão de linha dentro da página, pois isso quebraria as referências disponíveis. E isso resultaria na fragmentação do espaço dentro das páginas e em outras consequências problemáticas. Portanto, o índice faz referência ao número do ponteiro e o ponteiro faz referência ao local atual da versão da linha na página. E isso é endereçamento indireto.
Cada ponteiro ocupa exatamente quatro bytes e contém:
- uma referência para a versão da linha
- o tamanho desta versão de linha
- vários bytes para determinar o status da versão da linha
Formato de dados
O formato dos dados no disco é exatamente o mesmo que a representação dos dados na RAM. A página é lida no cache do buffer "como está", sem nenhuma conversão. Portanto, os arquivos de dados de uma plataforma se tornam incompatíveis com outras plataformas.
Por exemplo, na arquitetura X86, a ordenação de bytes é do bytes menos significativo para o mais significativo (little-endian), o z / Architecture usa a ordem inversa (big-endian) e, no ARM, a ordem pode ser trocada.
Muitas arquiteturas fornecem alinhamento de dados nos limites das palavras de máquina. Por exemplo, em um sistema x86 de 32 bits, os números inteiros (tipo "número inteiro", que ocupa 4 bytes) serão alinhados no limite de palavras de 4 bytes, da mesma maneira que os números de precisão dupla (tipo "precisão dupla" , que ocupa 8 bytes). E em um sistema de 64 bits, os números de precisão dupla serão alinhados no limite de palavras de 8 bytes. Este é mais um motivo de incompatibilidade.
Devido ao alinhamento, o tamanho da linha da tabela depende da ordem do campo. Normalmente, esse efeito não é muito perceptível, mas às vezes pode resultar em um crescimento significativo do tamanho. Por exemplo, se os campos dos tipos "char (1)" e "inteiro" forem intercalados, geralmente 3 bytes entre eles serão desperdiçados. Para mais detalhes, você pode ver a apresentação de Nikolay Shaplov "
Tuple internals ".
Versões de linha e TOAST
Vamos discutir detalhes da estrutura interna das versões de linha na próxima vez. Neste ponto, é importante apenas sabermos que cada versão deve caber completamente em uma página: o PostgreSQL não tem como "estender" a linha para a próxima página. A Técnica de armazenamento de atributos de grandes dimensões (TOAST) é usada em seu lugar. O próprio nome sugere que uma linha pode ser dividida em torradas.
Brincadeiras à parte, o TOAST implica várias estratégias. Podemos transmitir valores de atributos longos para uma tabela interna separada depois de dividi-los em pequenos pedaços de torrada. Outra opção é compactar um valor para que a versão da linha caiba em uma página comum. E podemos fazer as duas coisas: primeiro comprima e depois termine e transmita.
Para cada tabela principal, uma tabela TOAST separada pode ser criada, se necessário, uma para todos os atributos (junto com um índice). A disponibilidade de atributos potencialmente longos determina essa necessidade. Por exemplo, se uma tabela tiver uma coluna do tipo "numérico" ou "texto", a tabela TOAST será criada imediatamente, mesmo que valores longos não sejam usados.
Como uma tabela TOAST é essencialmente uma tabela regular, ela possui o mesmo conjunto de garfos. E isso dobra o número de arquivos que correspondem a uma tabela.
As estratégias iniciais são definidas pelos tipos de dados da coluna. Você pode vê-los usando o comando
\d+
no psql, mas como ele gera muitas outras informações, consultaremos o catálogo do sistema:
=> SELECT attname, atttypid::regtype, CASE attstorage WHEN 'p' THEN 'plain' WHEN 'e' THEN 'external' WHEN 'm' THEN 'main' WHEN 'x' THEN 'extended' END AS storage FROM pg_attribute WHERE attrelid = 'accounts'::regclass AND attnum > 0;
attname | atttypid | storage ---------+----------+---------- id | integer | plain number | text | extended client | text | extended amount | numeric | main (4 rows)
Os nomes das estratégias significam:
- O TOAST não é usado (usado para tipos de dados conhecidos por serem curtos, como "número inteiro").
- estendido - compressão e armazenamento em uma tabela TOAST separada são permitidos
- valores externos - longos são armazenados na tabela TOAST sem compactação.
- Os valores main - long são compactados primeiro e só entram na tabela TOAST se a compactação não ajudar.
Em geral, o algoritmo é o seguinte. O PostgreSQL pretende ter pelo menos quatro linhas em uma página. Portanto, se o tamanho da linha exceder um quarto da página, levando em consideração o cabeçalho (2040 bytes para uma página normal de 8 K), o TOAST deve ser aplicado a uma parte dos valores. Seguimos a ordem descrita abaixo e paramos assim que a linha não exceder mais o limite:
- Primeiro, analisamos os atributos com as estratégias "externas" e "estendidas", do atributo mais longo ao mais curto. Os atributos "estendidos" são compactados (se forem efetivos) e, se o valor em si exceder um quarto da página, ele entra imediatamente na tabela TOAST. Os atributos "externos" são processados da mesma maneira, mas não são compactados.
- Se após a primeira passagem, a versão da linha ainda não se encaixa na página, transmitimos os atributos restantes com as estratégias "externas" e "estendidas" para a tabela TOAST.
- Se isso também não ajudou, tentamos compactar os atributos com a estratégia "principal", mas os deixamos na página da tabela.
- E somente se, depois disso, a linha não for suficientemente curta, os atributos "principais" entrarão na tabela TOAST.
Às vezes, pode ser útil alterar a estratégia para determinadas colunas. Por exemplo, se for sabido antecipadamente que os dados em uma coluna não podem ser compactados, podemos definir a estratégia "externa" para isso, o que nos permite economizar tempo, evitando tentativas inúteis de compactação. Isso é feito da seguinte maneira:
=> ALTER TABLE accounts ALTER COLUMN number SET STORAGE external;
Re-executando a consulta, obtemos:
attname | atttypid | storage ---------+----------+---------- id | integer | plain number | text | external client | text | extended amount | numeric | main
As tabelas e índices TOAST estão localizados no esquema pg_toast separado e, portanto, geralmente não são visíveis. Para tabelas temporárias, o esquema "pg_toast_temp_
N " é usado da mesma forma que o usual "pg_temp_
N ".
Obviamente, se você gosta de ninguém, isso impedirá que você espie a mecânica interna do processo. Digamos, na tabela "contas", existem três atributos potencialmente longos e, portanto, deve haver uma tabela TOAST. Aqui está:
=> SELECT relnamespace::regnamespace, relname FROM pg_class WHERE oid = ( SELECT reltoastrelid FROM pg_class WHERE relname = 'accounts' );
relnamespace | relname --------------+---------------- pg_toast | pg_toast_33953 (1 row)
=> \d+ pg_toast.pg_toast_33953
TOAST table "pg_toast.pg_toast_33953" Column | Type | Storage ------------+---------+--------- chunk_id | oid | plain chunk_seq | integer | plain chunk_data | bytea | plain
É razoável que a estratégia "simples" seja aplicada às torradas nas quais a linha é cortada: não há um TOAST de segundo nível.
O PostgreSQL oculta melhor o índice, mas também não é difícil encontrar:
=> SELECT indexrelid::regclass FROM pg_index WHERE indrelid = ( SELECT oid FROM pg_class WHERE relname = 'pg_toast_33953' );
indexrelid ------------------------------- pg_toast.pg_toast_33953_index (1 row)
=> \d pg_toast.pg_toast_33953_index
Unlogged index "pg_toast.pg_toast_33953_index" Column | Type | Key? | Definition -----------+---------+------+------------ chunk_id | oid | yes | chunk_id chunk_seq | integer | yes | chunk_seq primary key, btree, for table "pg_toast.pg_toast_33953"
A coluna "cliente" usa a estratégia "estendida": seus valores serão compactados. Vamos verificar:
=> UPDATE accounts SET client = repeat('A',3000) WHERE id = 1; => SELECT * FROM pg_toast.pg_toast_33953;
chunk_id | chunk_seq | chunk_data ----------+-----------+------------ (0 rows)
Não há nada na tabela TOAST: os caracteres repetidos são compactados com precisão e, após a compactação, o valor se ajusta a uma página de tabela usual.
E agora deixe o nome do cliente consistir em caracteres aleatórios:
=> UPDATE accounts SET client = ( SELECT string_agg( chr(trunc(65+random()*26)::integer), '') FROM generate_series(1,3000) ) WHERE id = 1 RETURNING left(client,10) || '...' || right(client,10);
?column? ------------------------- TCKGKZZSLI...RHQIOLWRRX (1 row)
Essa sequência não pode ser compactada e entra na tabela TOAST:
=> SELECT chunk_id, chunk_seq, length(chunk_data), left(encode(chunk_data,'escape')::text, 10) || '...' || right(encode(chunk_data,'escape')::text, 10) FROM pg_toast.pg_toast_33953;
chunk_id | chunk_seq | length | ?column? ----------+-----------+--------+------------------------- 34000 | 0 | 2000 | TCKGKZZSLI...ZIPFLOXDIW 34000 | 1 | 1000 | DDXNNBQQYH...RHQIOLWRRX (2 rows)
Podemos ver que os dados são divididos em partes de 2000 bytes.
Quando um valor longo é acessado, o PostgreSQL automaticamente e de forma transparente para o aplicativo restaura o valor original e o retorna ao cliente.
Certamente, é bastante intensivo em recursos para compactar, desmembrar e depois restaurar. Portanto, armazenar dados massivos no PostgreSQL não é a melhor idéia, especialmente se eles são usados com freqüência e o uso não requer lógica transacional (por exemplo: digitalizações de documentos contábeis originais). Uma alternativa mais benéfica é armazenar esses dados em um sistema de arquivos com os nomes de arquivos armazenados no DBMS.
A tabela TOAST é usada apenas para acessar um valor longo. Além disso, sua própria simultaneidade de mutiversão é suportada para uma tabela TOAST: a menos que uma atualização de dados toque em um valor longo, uma nova versão de linha fará referência ao mesmo valor na tabela TOAST, e isso economizará espaço.
Observe que o TOAST funciona apenas para tabelas, mas não para índices. Isso impõe uma limitação no tamanho das chaves a serem indexadas.
Para mais detalhes da estrutura de dados interna, você pode ler a documentação .
Continue lendo .