MVCC-3. Versões de linhas

Então, analisamos as questões relacionadas a isolamento, e fizemos uma digressão sobre a organização de dados em nível baixo. E finalmente chegamos ao mais interessante — às versões de linhas.

Cabeçalho

Como já mencionamos, cada linha pode estar presente simultaneamente no banco de dados em várias versões. É necessário distinguir uma versão da outra. Para isso, cada versão possui duas marcas que definem o "tempo" de validade dessa versão (xmin e xmax). Colocamos entre aspas — porque não se usa o tempo como tal, mas sim um contador especial que aumenta. E esse contador é o número da transação.

(Como sempre, na verdade tudo é mais complicado: os números das transações não podem aumentar constantemente devido à limitação do contador. Mas vamos discutir esses detalhes mais a fundo quando chegarmos à questão de congelamento.)

Quando uma linha é criada, o valor de xmin é definido como o número da transação que executou o comando INSERT, e xmax não é preenchido.

Quando uma linha é excluída, o valor de xmax da versão atual é marcado com o número da transação que realizou o DELETE.

Quando uma linha é alterada pelo comando UPDATE, na verdade, duas operações são executadas: DELETE e INSERT. Na versão atual da linha, xmax é definido como o número da transação que executou o UPDATE. Em seguida, uma nova versão da mesma linha é criada; o valor de xmin desta nova versão coincide com o valor de xmax da versão anterior.

Os campos xmin e xmax fazem parte do cabeçalho da versão da linha. Além desses campos, o cabeçalho contém outros, como:

  • infomask — uma série de bits que definem as propriedades desta versão. Existem bastante opções; as principais delas vamos examinar gradualmente.
  • ctid — uma referência à próxima versão, mais nova, da mesma linha. Na versão mais nova, ctid se refere a essa mesma versão. O número tem o formato (x,y), onde x é o número da página e y é o número sequencial do ponteiro no array.
  • um bitmap de valores indefinidos — indica as colunas desta versão que contêm um valor indefinido (NULL). NULL não é um dos valores normais dos tipos de dados, por isso, o indicador precisa ser armazenado separadamente.

Como resultado, o cabeçalho acaba sendo bastante grande — no mínimo 23 bytes para cada versão de linha, e geralmente mais devido ao bitmap de NULLs. Se a tabela é "estreita" (ou seja, contém poucas colunas), os custos adicionais podem superar a informação útil.

Inserção

Vamos explorar mais detalhadamente como são realizadas operações com strings em baixo nível, começando pela inserção.

Para experimentos, criaremos uma nova tabela com duas colunas e um índice em uma delas:

=> CREATE TABLE t(
  id serial,
  s text
);
=> CREATE INDEX ON t(s);

Vamos inserir uma linha, começando com a transação.

=> BEGIN;
=> INSERT INTO t(s) VALUES ('FOO');

Aqui está o número da nossa transação atual:

=> SELECT txid_current();
 txid_current 
--------------
         3664
(1 row)

Vamos dar uma olhada no conteúdo da página. A função heap_page_items da extensão pageinspect permite obter informações sobre ponteiros e versões de linhas:

=> SELECT * FROM heap_page_items(get_raw_page('t',0)) gx
-[ RECORD 1 ]-------------------
lp          | 1
lp_off      | 8160
lp_flags    | 1
lp_len      | 32
t_xmin      | 3664
t_xmax      | 0
t_field3    | 0
t_ctid      | (0,1)
t_infomask2 | 2
t_infomask  | 2050
t_hoff      | 24
t_bits      | 
t_oid       | 
t_data      | x0100000009464f4f

Note que a palavra heap (montão) no PostgreSQL se refere a tabelas. Esta é mais uma estranha utilização do termo — um montão é uma estrutura de dados conhecida , que não tem nada a ver com tabelas. Aqui esta palavra é utilizada no sentido de ‘tudo em um montão’, ao contrário de índices ordenados.A função mostra os dados ‘como estão’, em um formato difícil de entender. Para simplificar, vamos deixar apenas parte das informações e decifrá-las:

=> SELECT '(0,'||lp||')' AS ctid, CASE lp_flags WHEN 0 THEN 'unused' WHEN 1 THEN 'normal' WHEN 2 THEN 'redirect to '||lp_off WHEN 3 THEN 'dead' END AS state, t_xmin as xmin, t_xmax as xmax, (t_infomask & 256) > 0 AS xmin_commited, (t_infomask & 512) > 0 AS xmin_aborted, (t_infomask & 1024) > 0 AS xmax_commited, (t_infomask & 2048) > 0 AS xmax_aborted, t_ctid FROM heap_page_items(get_raw_page('t',0)) gx

-[ RECORD 1 ]-+-------
ctid          | (0,1)
state         | normal
xmin          | 3664
xmax          | 0
xmin_commited | f
xmin_aborted  | f
xmax_commited | f
xmax_aborted  | t
t_ctid        | (0,1)
Aqui está o que fizemos:

Adicionamos um zero ao número do ponteiro para trazê-lo ao mesmo formato que t_ctid: (número da página, número do ponteiro).

  • Deciframos o estado do ponteiro lp_flags. Aqui está ‘normal’ — isso significa que o ponteiro realmente aponta para a versão da linha. Outros valores serão analisados mais tarde.
  • De todos os bits de informação, destacamos por enquanto apenas dois pares. Os bits xmin_committed e xmin_aborted indicam se a transação com o número xmin foi confirmada (ou cancelada). Dois bits análogos se referem à transação com o número xmax.
  • Dos todos os bits de informação, destacamos até agora apenas dois pares. Os bits xmin_committed e xmin_aborted indicam se a transação com o número xmin foi concluída (ou cancelada). Dois bits semelhantes referem-se à transação com o número xmax.

O que vemos? Ao inserir uma linha em uma tabela, aparecerá um indicador com o número 1, referenciando a primeira e única versão da linha.

Na versão da linha, o campo xmin é preenchido com o número da transação atual. A transação ainda está ativa, portanto, ambos os bits xmin_committed e xmin_aborted não estão definidos.

O campo ctid da versão da linha se refere à mesma linha. Isso significa que não existe uma versão mais nova.

O campo xmax é preenchido com um número fictício 0, já que esta versão da linha não foi excluída e é válida. As transações não prestarão atenção a esse número, pois o bit xmax_aborted está definido.

Vamos dar mais um passo para melhorar a legibilidade, adicionando bits informativos aos números das transações. E criaremos uma função, já que essa consulta precisará ser usada novamente:

=> CREATE FUNCTION heap_page(relname text, pageno integer)
RETURNS TABLE(ctid tid, state text, xmin text, xmax text, t_ctid tid)
AS $$
SELECT (pageno, lp)::text::tid AS ctid,
       CASE lp_flags
         WHEN 0 THEN 'unused'
         WHEN 1 THEN 'normal'
         WHEN 2 THEN 'redirect to ' || lp_off
         WHEN 3 THEN 'dead'
       END AS state,
       t_xmin || CASE
         WHEN (t_infomask & 256) > 0 THEN ' (c)'
         WHEN (t_infomask & 512) > 0 THEN ' (a)'
         ELSE ''
       END AS xmin,
       t_xmax || CASE
         WHEN (t_infomask & 1024) > 0 THEN ' (c)'
         WHEN (t_infomask & 2048) > 0 THEN ' (a)'
         ELSE ''
       END AS xmax,
       t_ctid
FROM heap_page_items(get_raw_page(relname, pageno))
ORDER BY lp;
$$ LANGUAGE SQL;

Assim, fica muito mais claro o que está acontecendo no cabeçalho da versão da linha:

=> SELECT * FROM heap_page('t', 0);
 ctid  | state  | xmin | xmax  | t_ctid 
-------+--------+------+-------+--------
 (0,1) | normal | 3664 | 0 (a) | (0,1)
(1 row)

Informações semelhantes, mas significativamente menos detalhadas, podem ser obtidas a partir da própria tabela, usando os pseudocolunas xmin e xmax:

=> SELECT xmin, xmax, * FROM t;
 xmin | xmax | id |  s  
------+------+----+-----
 3664 |    0 |  1 | FOO
(1 row)

Confirmação

Ao concluir com sucesso uma transação, é necessário lembrar seu status — marcá-la como confirmada. Para isso, utiliza-se uma estrutura chamada XACT (e até a versão 10, era chamada de CLOG (commit log), e esse nome ainda pode aparecer em diversos lugares).

XACT não é uma tabela do catálogo do sistema; são arquivos no diretório PGDATA/pg_xact. Neles, para cada transação, são reservados dois bits: committed e aborted — exatamente como no cabeçalho da versão da linha. Essa informação é dividida em vários arquivos exclusivamente por conveniência, e retornaremos a essa questão quando tratarmos da congelamento. E o trabalho com esses arquivos é feito página por página, assim como com todos os outros.

Assim, ao finalizar uma transação no XACT, é definido um bit committed para essa transação. E isso é tudo que acontece durante a finalização (embora ainda não tenhamos falado sobre o log de pré-registro).

Quando alguma outra transação acessar a página da tabela que acabamos de observar, ela terá que responder a algumas perguntas.

  1. A transação xmin foi finalizada? Se não, a versão da linha criada não deve ser visível.
    Essa verificação é feita ao examinar outra estrutura, que está localizada na memória compartilhada da instância, chamada ProcArray. Nela está a lista de todos os processos ativos, e para cada um é indicado o número de sua transação atual (ativa).
  2. Se foi finalizada, como — por um commit ou por um rollback? Se por rollback, então a versão da linha também não deve ser visível.
    É para isso que o XACT é necessário. Mas, embora as últimas páginas do XACT sejam mantidas em buffers na memória, verificar o XACT a cada vez é custoso. Assim, o status da transação determinado uma vez é gravado nos bits xmin_committed e xmin_aborted da versão da linha. Se um desses bits estiver definido, o estado da transação xmin é considerado conhecido e a próxima transação não precisará consultar o XACT.

Por que esses bits não são definidos pela própria transação que realiza a inserção? Quando ocorre a inserção, a transação ainda não sabe se será bem-sucedida. E no momento da finalização, já não está claro quais exatamente linhas em quais páginas foram modificadas. Pode haver muitas dessas páginas, e não é vantajoso lembrá-las. Além disso, parte das páginas pode ter sido removida do cache para o disco; ler estas novamente para alterar os bits significaria desacelerar significativamente a finalização.

O lado oposto da economia é que, após as modificações, qualquer transação (mesmo uma que realiza uma simples leitura — SELECT) pode começar a modificar páginas de dados no cache.

Portanto, vamos gravar a alteração.

=> COMMIT;

Nada mudou na página (mas sabemos que o status da transação já foi gravado no XACT):

=> SELECT * FROM heap_page('t', 0);
 ctid  | state  | xmin | xmax  | t_ctid 
-------+--------+------+-------+--------
 (0,1) | normal | 3664 | 0 (a) | (0,1)
(1 row)

Agora, a transação que primeiro acessou a página deve determinar o status da transação xmin e gravá-lo nos bits informativos:

=> SELECT * FROM t;
 id |  s  
----+-----
  1 | FOO
(1 linha)

=> SELECT * FROM heap_page('t', 0);
 ctid  | estado  |   xmin   | xmax  | t_ctid 
-------+--------+----------+-------+--------
 (0,1) | normal | 3664 (c) | 0 (a) | (0,1)
(1 linha)

Remoção

Ao excluir uma linha, o número da transação de exclusão atual é registrado no campo xmax, e o bit xmax_aborted é redefinido.

Observe que o valor xmax, correspondente à transação ativa, atua como um bloqueio da linha. Se outra transação tentar atualizar ou excluir esta linha, ela terá que esperar a conclusão da transação xmax. Falaremos mais sobre bloqueios mais tarde. Por enquanto, basta notar que o número de bloqueios de linha não tem limites. Eles não ocupam espaço na memória e o desempenho do sistema não é afetado pela quantidade. No entanto, transações "longas" têm outras desvantagens, mas falaremos sobre isso depois.

Vamos excluir a linha.

=> BEGIN;
=> DELETE FROM t;
=> SELECT txid_current();
 txid_current 
--------------
         3665
(1 linha)

Podemos ver que o número da transação foi registrado no campo xmax, mas os bits informativos não estão configurados:

=> SELECT * FROM heap_page('t', 0);
 ctid  | estado  |   xmin   | xmax | t_ctid 
-------+--------+----------+------+--------
 (0,1) | normal | 3664 (c) | 3665 | (0,1)
(1 linha)

Cancelamento

O cancelamento de alterações funciona de maneira semelhante à confirmação, exceto que na transação XACT é configurado o bit aborted. O cancelamento é executado tão rapidamente quanto a confirmação. Embora o comando seja chamado ROLLBACK, as alterações não são revertidas: tudo o que a transação conseguiu alterar nas páginas de dados permanece inalterado.

=> ROLLBACK;
=> SELECT * FROM heap_page('t',0);
 ctid  | estado  |   xmin   | xmax | t_ctid 
-------+--------+----------+------+--------
 (0,1) | normal | 3664 (c) | 3665 | (0,1)
(1 linha)

Ao acessar a página, o status será verificado e o bit de dica xmax_aborted será definido na versão da linha. O número xmax permanece na página, mas ninguém o analisará.

=> SELECT * FROM t;
 id |  s  
----+-----
  1 | FOO
(1 linha)

=> SELECT * FROM heap_page('t', 0);
 ctid  | estado  |   xmin   |   xmax   | t_ctid 
-------+--------+----------+----------+--------
 (0,1) | normal | 3664 (c) | 3665 (a) | (0,1)
(1 linha)

Atualizar

A atualização funciona como se primeiramente tivesse ocorrido a exclusão da versão atual da linha e, em seguida, a inserção de uma nova.

=> BEGIN;
=> UPDATE t SET s = 'BAR';
=> SELECT txid_current();
 txid_current 
--------------
         3666
(1 linha)

A consulta retorna uma linha (nova versão):

=> SELECT * FROM t;
 id |  s  
----+-----
  1 | BAR
(1 linha)

Mas na página vemos ambas as versões:

=> SELECT * FROM heap_page('t', 0);
 ctid  | estado  |   xmin   | xmax  | t_ctid 
-------+--------+----------+-------+--------
 (0,1) | normal | 3664 (c) | 3666  | (0,2)
 (0,2) | normal | 3666     | 0 (a) | (0,2)
(2 linhas)

A versão excluída é marcada com o número da transação atual no campo xmax. Além disso, esse valor é registrado sobre o anterior, uma vez que a transação anterior foi cancelada. E o bit xmax_aborted foi redefinido, pois o status da transação atual ainda é desconhecido.

A primeira versão da linha agora aponta para a segunda (campo t_ctid), como se fosse mais nova.

Na página de índice, aparece um segundo ponteiro e uma segunda linha, referindo-se à segunda versão na página de tabela.

Assim como na remoção, o valor xmax na primeira versão da linha serve como um sinal de que a linha está bloqueada.

E assim concluímos a transação.

=> COMMIT;

Índices

Até agora, falamos apenas sobre páginas de tabela. Mas o que acontece dentro dos índices?

As informações nas páginas de índice dependem muito do tipo específico de índice. E mesmo entre um único tipo de índice, podem haver diferentes tipos de páginas. Por exemplo, uma árvore B possui uma página de metadados e páginas 'normais'.

No entanto, geralmente, a página contém um array de ponteiros para linhas e as próprias linhas (assim como na página de tabela). Além disso, no final da página, é reservado espaço para dados especiais.

As linhas em índices também podem ter estruturas muito diferentes dependendo do tipo de índice. Por exemplo, para uma árvore B, as linhas relacionadas às páginas folhadas contêm o valor da chave de indexação e um link (ctid) para a linha correspondente na tabela. De modo geral, o índice pode ser estruturado de maneira completamente diferente.

O ponto mais importante é que em índices de qualquer tipo não há versões de linhas. Ou pode-se considerar que cada linha é representada por exatamente uma versão. Em outras palavras, no cabeçalho da linha de índice, não há campos xmin e xmax. Pode-se considerar que os links do índice apontam para todas as versões de tabela das linhas — assim, para descobrir qual versão a transação verá, é preciso olhar na tabela. (Como de costume, isso não é toda a verdade. Em alguns casos, o mapa de visibilidade permite otimizar o processo, mas discutiremos isso mais adiante.)

Assim, na página do índice, encontramos ponteiros para ambas as versões, tanto a atual quanto a antiga:

=> SELECT itemoffset, ctid FROM bt_page_items('t_s_idx',1);
 itemoffset | ctid  
------------+-------
          1 | (0,2)
          2 | (0,1)
(2 linhas)

Transações virtuais

Na prática, o PostgreSQL usa uma otimização que permite 'economizar' números de transações.

Se uma transação apenas lê dados, ela não afeta a visibilidade das versões de linhas. Portanto, inicialmente, o processo gerenciador atribui um número virtual à transação (virtual xid). O número consiste no identificador do processo e em um número sequencial.

A emissão deste número não requer sincronização entre todos os processos e, por isso, é realizada muito rapidamente. A outra razão para o uso de números virtuais será abordada quando falarmos sobre congelamento.

Números virtuais não são considerados em snapshots de dados.

Em diferentes momentos, podem existir transações virtuais no sistema com números que já foram usados, e isso é normal. No entanto, esse número não pode ser registrado nas páginas de dados, pois na próxima vez que a página for acessada, ele pode perder todo o sentido.

=> INÍCIO;
=> SELECIONE txid_current_if_assigned();
 txid_current_if_assigned 
--------------------------
                         
(1 linha)

Se a transação começar a modificar dados, ela recebe um número de transação real e único.

=> ATUALIZAR contas DEFINIR quantidade = quantidade - 1.00;
=> SELECIONE txid_current_if_assigned();
 txid_current_if_assigned 
--------------------------
                     3667
(1 linha)

=> COMMIT;

Transações aninhadas

Pontos de salvamento

No SQL, são definidos pontos de salvamento (savepoint), que permitem revertem parte das operações de transação, sem interromper totalmente. No entanto, isso não se encaixa no esquema apresentado acima, pois o status da transação é único para todas as suas modificações, e fisicamente nenhum dado é revertido.

Para implementar tal funcionalidade, uma transação com ponto de salvamento é dividida em várias transações aninhadas (subtransação), cujo status pode ser gerido separadamente.

Transações aninhadas possuem seu próprio número (maior que o número da transação principal). O status das transações aninhadas é registrado normalmente em XACT, no entanto, o status final depende do status da transação principal: se ela for cancelada, todas as transações aninhadas também serão canceladas.

Informações sobre a aninhamento das transações são armazenadas em arquivos no diretório PGDATA/pg_subtrans. O acesso aos arquivos é feito através de buffers na memória compartilhada da instância, organizados da mesma forma que os buffers XACT.

Não confunda transações aninhadas com transações autônomas. As transações autônomas não dependem umas das outras, enquanto as aninhadas dependem. Não existem transações autônomas no PostgreSQL comum, e talvez isso seja para melhor: na prática, elas são necessárias muito, mas muito raramente, e sua presença em outros SGBDs leva a abusos dos quais todos acabam sofrendo.

Vamos limpar a tabela, iniciar a transação e inserir uma linha:

=> TRUNCATE TABLE t;
=> BEGIN;
=> INSERT INTO t(s) VALUES ('FOO');
=> SELECT txid_current();
 txid_current 
--------------
         3669
(1 row)

=> SELECT xmin, xmax, * FROM t;
 xmin | xmax | id |  s  
------+------+----+-----
 3669 |    0 |  2 | FOO
(1 row)

=> SELECT * FROM heap_page('t', 0);
 ctid  | state  | xmin | xmax  | t_ctid 
-------+--------+------+-------+--------
 (0,1) | normal | 3669 | 0 (a) | (0,1)
(1 row)

Agora vamos definir um ponto de salvaguarda e inserir mais uma linha.

=> SAVEPOINT sp;
=> INSERT INTO t(s) VALUES ('XYZ');
=> SELECT txid_current();
 txid_current 
--------------
         3669
(1 row)

Observe que a função txid_current() retorna o número da transação principal e não da aninhada.

=> SELECT xmin, xmax, * FROM t;
 xmin | xmax | id |  s  
------+------+----+-----
 3669 |    0 |  2 | FOO
 3670 |    0 |  3 | XYZ
(2 rows)

=> SELECT * FROM heap_page('t', 0);
 ctid  | state  | xmin | xmax  | t_ctid 
-------+--------+------+-------+--------
 (0,1) | normal | 3669 | 0 (a) | (0,1)
 (0,2) | normal | 3670 | 0 (a) | (0,2)
(2 rows)

Vamos voltar ao ponto de salvaguarda e inserir a terceira linha.

=> ROLLBACK TO sp;
=> INSERT INTO t(s) VALUES ('BAR');
=> SELECT xmin, xmax, * FROM t;
 xmin | xmax | id |  s  
------+------+----+-----
 3669 |    0 |  2 | FOO
 3671 |    0 |  4 | BAR
(2 rows)

=> SELECT * FROM heap_page('t', 0);
 ctid  | state  |   xmin   | xmax  | t_ctid 
-------+--------+----------+-------+--------
 (0,1) | normal | 3669     | 0 (a) | (0,1)
 (0,2) | normal | 3670 (a) | 0 (a) | (0,2)
 (0,3) | normal | 3671     | 0 (a) | (0,3)
(3 rows)

Na página, continuamos vendo a linha adicionada pela transação aninhada cancelada.

Confirmamos as mudanças.

=> COMMIT;
=> SELECT xmin, xmax, * FROM t;
 xmin | xmax | id |  s  
------+------+----+-----
 3669 |    0 |  2 | FOO
 3671 |    0 |  4 | BAR
(2 rows)

=> SELECT * FROM heap_page('t', 0);
 ctid  | state  |   xmin   | xmax  | t_ctid 
-------+--------+----------+-------+--------
 (0,1) | normal | 3669 (c) | 0 (a) | (0,1)
 (0,2) | normal | 3670 (a) | 0 (a) | (0,2)
 (0,3) | normal | 3671 (c) | 0 (a) | (0,3)
(3 rows)

Agora é evidente que cada transação aninhada tem seu próprio status.

Note que transações aninhadas não podem ser usadas explicitamente em SQL, ou seja, não se pode iniciar uma nova transação sem antes finalizar a atual. Esse mecanismo é ativado implicitamente ao usar pontos de salvaguarda, além de ser usado na manipulação de exceções PL/pgSQL e em outros casos mais exóticos.

=> BEGIN;
BEGIN
=> BEGIN;
WARNING:  there is already a transaction in progress
BEGIN
=> COMMIT;
COMMIT
=> COMMIT;
WARNING:  there is no transaction in progress
COMMIT

Erros e atomicidade das operações

O que acontece se ocorrer um erro durante a execução de uma operação? Por exemplo, assim:

=> BEGIN;
=> SELECT * FROM t;
 id |  s  
----+-----
  2 | FOO
  4 | BAR
(2 rows)

=> UPDATE t SET s = repeat('X', 1/(id-4));
ERROR:  division by zero

Ocorreu um erro. Agora a transação é considerada interrompida e nenhuma operação nela é permitida:

=> SELECT * FROM t;
ERROR:  current transaction is aborted, commands ignored until end of transaction block

E mesmo que se tente confirmar as alterações, o PostgreSQL relatará um cancelamento:

=> COMMIT;
ROLLBACK

Por que não é possível continuar a execução da transação após uma falha? O fato é que o erro pode ter ocorrido de tal maneira que teríamos acesso a parte das alterações — a atomicidade não apenas da transação, mas do operador, teria sido violada. Como em nosso exemplo, onde o operador conseguiu atualizar uma linha antes do erro:

=> SELECT * FROM heap_page('t', 0);
 ctid  | estado  |   xmin   | xmax  | t_ctid 
-------+--------+----------+-------+--------
 (0,1) | normal | 3669 (c) | 3672  | (0,4)
 (0,2) | normal | 3670 (a) | 0 (a) | (0,2)
 (0,3) | normal | 3671 (c) | 0 (a) | (0,3)
 (0,4) | normal | 3672     | 0 (a) | (0,4)
(4 linhas)

Devo dizer que o psql possui um modo que, de fato, permite continuar o trabalho da transação após uma falha, como se as ações do operador com erro fossem revertidas.

=> set ON_ERROR_ROLLBACK on
=> BEGIN;
=> SELECT * FROM t;
 id |  s  
----+-----
  2 | FOO
  4 | BAR
(2 rows)

=> UPDATE t SET s = repeat('X', 1/(id-4));
ERROR:  division by zero

=> SELECT * FROM t;
 id |  s  
----+-----
  2 | FOO
  4 | BAR
(2 rows)

=> COMMIT;

Não é difícil adivinhar que, nesse modo, o psql na verdade coloca um ponto de salvamento implícito antes de cada comando, e em caso de falha, inicia a reversão para esse ponto. Esse modo não é usado por padrão, pois a configuração de pontos de salvamento (mesmo sem revertê-los) implica custos operacionais significativos.

Continuação.

Fonte: habr.com

Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS 🔥 Compre hospedagem confiável para sites com proteção contra DDoS, servidores VPS VDS | ProHoster