O EIP-8188 adiciona um carimbo de data/hora visível por consenso a cada conta e slot de armazenamento, registrando quando cada um sofreu a última mutação. Suas regras de precificação usam esse campo para cobrar mais pela gravação no estado inativo de gravação, mas o campo em si é a parte que importa aqui porque fornece a cada cliente o mesmo sinal para qual estado sofreu mutação recentemente (quente) e qual não sofreu mutação há muito tempo (frio). O EIP não exige deliberadamente nenhuma arquitetura de armazenamento. Ele apenas entrega aos clientes os metadados e deixa em aberto o que fazer com eles.
Este artigo pega esse sinal e mede uma coisa que um cliente pode fazer com ele: separar fisicamente o estado frio do quente, mantendo um conjunto menor de trabalho a quente no banco de dados principal e armazenando o restante frio em um arquivo simples mais barato. A questão é quanto disco realmente economiza em um nó real e qual forma de fazer a separação vence.
Executamos um experimento de três etapas em um nó real da mainnet go-ethereum (geth) para ver quanto disco você economiza ao extrair o estado “frio” do banco de dados quente. Isso descreve o que cada etapa faz e o que realmente mediu.
As três etapas:
- Linha de base. O nó geth normal.
- Injeção de período. Marque cada conta e slot com a data em que foi usado pela última vez, para que possamos saber o que está frio.
- Mova o estado inativo para fora. Puxe as partes frias do banco de dados principal para um arquivo simples, reduzindo o que o nó precisa manter quente.
Tudo abaixo é medido em um diretório de dados: mainnet no bloco 19.999.256.
Como geth armazena o estado
O estado é cada conta (saldo, nonce, código, raiz de armazenamento) mais todos os slots de armazenamento do contrato. geth o mantém duas vezes dentro de seu armazenamento de valores-chave (PebbleDB).
1. Merkle-Patricia Trie (MPT). A árvore autenticada cujo hash raiz é a raiz de estado do bloco.
account trie (root hash = stateRoot)
|
(branch)
/ \
(extension) (branch)
| / \
(branch) (leaf) (leaf)
/ \
(leaf) (leaf)
every contract account's storage is its own trie of the same shape.
2. O instantâneo. Uma cópia simples de chave-valor apenas das folhas, para que uma leitura não precise percorrer a árvore.
accountHash -> account
accountHash + slotHash -> slot value
Etapa 1: linha de base
O nó no bloco 19.999.256. Os tamanhos são bytes lógicos (soma do conteúdo do registro), a menos que indicado. O PebbleDB compactado em disco físico tem cerca de 251,75 GB.
| componente | contar | tamanho |
|---|---|---|
| experimentar nós (conta) | 334,7 milhões | 38,81GB |
| testar nós (armazenamento) | 1.560,7 milhões | 109,33GB |
| tente total | 1.895,4 milhões de nós | 148,13GB |
| instantâneo, conta (chaves/valores) | – | 7,58/3,77GB |
| instantâneo, armazenamento (chaves/valores) | – | 69,74/13,32GB |
| total do instantâneo | ~1,15 B de registros | 101,38GB |
Etapa 2: injeção menstrual
Para cada conta e slot de armazenamento, armazenamos o período mais recente em que foi gravado. Isso vai apenas para o instantâneo. Um “período” é apenas uma janela de tempo, aqui 1.314.000 blocos (cerca de seis meses). Uma folha é inativa se não tiver sido escrita há pelo menos minAge períodos (aqui são 2, ou seja, 1 ano de inatividade no total).
Neste experimento, usamos Xatu como fonte de dados primária.
access-history source injector snapshot (pebble)
+---------------------+ (key, block) +-------------+ read -> set period -> write
| addr A wrote @ blk | --------------> | period = | +---------------------------+
| slot S wrote @ blk | | (blk - fork)| ->| accountHash -> (acct, P) |
| ... | | / perLen | | slotHash -> (value, P) |
+---------------------+ +-------------+ +---------------------------+
(only ever raises a period)
ComputePeriod(block) = (block - forkBlock) / blocksPerPeriod. Com forkBlock 17.371.256 e blocksPerPeriod 1.314.000, o head fica no período 2. A atualização só aumenta o período do registro, então a fonte pode emitir diferenças em qualquer ordem, em lotes, com novas tentativas.
Como é armazenado e por que quase não custa nada. O período é um campo RLP final opcional. Quando é 0 (o registro nunca foi gravado no intervalo rastreado), os bytes são idênticos a um registro legado, portanto, apenas os registros gravados recentemente aumentam.
account record:
before: RLP( nonce, balance, storageRoot, codeHash )
after: RLP( nonce, balance, storageRoot, codeHash, period ) (+1 to 2 bytes)
storage slot record:
before: RLP("value") a plain string
after: RLP( "value", period ) a 2-item list (+2 to 3 bytes)
Custo (etapa 1 a etapa 2).
| tentar | instantâneo | |
|---|---|---|
| mudar | nenhum, byte idêntico | +0,2 a 0,3 GB (em aproximadamente 32,5 milhões de contas mais slots gravados recentemente) |
| como % | 0% | menos de 0,5% do instantâneo de 101 GB |
Etapa 3: remover o estado inativo
Agora que cada folha carrega um último período usado (etapa 2), podemos extrair as partes frias do estado do banco de dados principal para um arquivo simples, deixando para o nó um conjunto menor de trabalho a quente.
O que movemos
Nós procuramos subárvores frias: um nó cujas folhas abaixo estão inativas (currentPeriod - leafPeriod >= 2usando os períodos da etapa 2). Quando encontramos uma, pegamos a maior subárvore fria possível, sob dois limites:
- um boné em sua altura, para que uma subárvore nunca fique muito grande. A altura é contada a partir das folhas, então uma folha tem altura 1, um galho diretamente acima das folhas tem altura 2 e assim por diante, com no máximo 16 ^ (altura-1) folhas sob uma subárvore dessa altura.
- um chão de altura 2, então nunca movemos uma única folha fria sozinha. Mover uma única folha custa um stub de 17 bytes mais um registro de arquivo e não economiza nenhum interior, uma perda líquida.
BEFORE (in PebbleDB) AFTER
N (cold subtree root) N -> 17-byte stub --+
/ \ |
(branch)(branch) interior nodes subtree gone from |
/ \ / \ (deleted) PebbleDB v
leaf leaf leaf leaf (all inactive) nodearchive (flat file)
+------------------------+
| (leaf)(leaf)(leaf) ... |
+------------------------+
O que armazenamos e lendo de volta
Para cada subárvore movida escrevemos seu só folhas para o arquivo simples e substitua toda a subárvore por um ponteiro de 17 bytes:
- O stub, 17 bytes no PebbleDB:
(0x00 marker | fileOffset:8 | size:8). O primeiro byte de um nó de teste real é0xc0ou superior, então0x00nunca pode ser confundido com um. O deslocamento e o tamanho colocam os registros desta subárvore no arquivo. - O arquivo deixa apenas: um registro RLP por folha,
(pathToLeaf, leafValue). Não há nós internos, apenas as folhas e seus caminhos relativos. O interior é excluído do PebbleDB. - Lendo de volta: carregue os registros, reinsira cada um
(path, value)em um novo mini-trie. A subárvore reconstruída é idêntica à original e seu hash deve corresponder ao esperado pelo stub, o que também funciona como uma verificação de corrupção.
access a cold leaf:
stub(offset, size) -> read records -> rebuild subtree in memory -> use it
Por que apenas sai: a alternativa ingênua
A mudança ingênua copia cada nó frio no arquivo simples como está, com ramificações internas e tudo. Ele libera o máximo do banco de dados principal, porque realoca tudo frio, mas o arquivo simples paga por isso. Medimos ambos no mesmo datadir, em bytes de valor lógico para que os dois sejam contados da mesma maneira:
| ingênuo: cada nó frio, estrutura completa | nossa: subárvores frias, apenas folhas (cap 3) | |
|---|---|---|
| o que aparece no arquivo simples | toda a subárvore, interior e tudo | apenas as folhas, interior reconstruído no acesso |
| canhotos escritos | 316,3 milhões | 239,6 milhões |
| tentativa diminui de 148,1 GB para | 32,7 GB (-115,5) | 58,7 GB (-89,4) |
| arquivo simples | 162,39GB | 82,96GB cru / 41,58GB comprimido |
| disco total líquido | +46,93GB (sobe) | -6,43GB cru / -47,81 GB comprimido |
A abordagem ingênua libera mais do banco de dados principal (-115 GB de tentativa contra nossos -89 GB), mas seu arquivo simples é de 162 GB, maior do que o que foi liberado, então o disco total vai acima em cerca de 47 GB. Armazenar folhas apenas mantém o arquivo simples pequeno o suficiente para que o disco total seja abaixo. Abandonar o interior e reconstruí-lo no acesso é o truque.
Encontrando as subárvores frias
Uma passagem de streaming percorre a tentativa e o instantâneo com carimbo de ponto lado a lado, rolando a resposta totalmente inativa das folhas. Quando uma subárvore fria está completa e dentro do piso e do topo, ela materializa a subárvore, escreve os registros das folhas, prepara o toco e exclui o interior, tudo na mesma passagem. A verificação do hash reconstruído é executada antes que qualquer coisa seja excluída, portanto, uma incompatibilidade anula aquela subárvore em vez de corromper o estado.
Compactando o arquivo
O arquivo simples está bruto no disco, mas é bem compactado. Comprimido em pedaços de aproximadamente 1 MB, um quadro zstd por pedaço mais uma pequena tabela de deslocamento, ele cai cerca de metade e ainda permite reconstruir uma única subárvore descompactando apenas seu pedaço. A compactação é a maior alavanca em todo o pipeline, portanto, os resultados abaixo relatam tanto o arquivo bruto quanto o compactado. Duas coisas que não funcionaram: compactar cada folha ou subárvore por conta própria, porque os blocos são muito pequenos para o zstd encontrar qualquer coisa, e um dicionário compartilhado treinado em registros de amostra, que movia o número em alguns pontos e às vezes piorava. Você precisa compactar muitas subárvores juntas.
Escolhendo a altura do boné
O piso de 2 significa cada tampa move as mesmas folhas. O limite apenas altera a forma como essas folhas são agrupadas: um limite mais alto mescla uma região fria em uma grande subárvore em vez de muitas subárvores pequenas, o que deixa menos stubs de 17 bytes para trás e exclui mais interior, de modo que o banco de dados principal diminui ainda mais. Varremos a tampa de 2 para 5, piso fixado em 2:
| altura da tampa | tente redução | arquivo (bruto) | arquivo (compactado) | disco de rede (bruto) | disco de rede (compactado) | subárvores movidas | reconstrução do pior caso (folhas) |
|---|---|---|---|---|---|---|---|
| 2 | -66,93 GB (-45,2%) | 63,35 GB | 31,39GB | -3,58 GB (-1,4%) | -35,54 GB (-14,1%) | 295,1 milhões | 16 |
| 3 | -95,86 GB (-64,7%) | 82,96GB | 41,58GB | -12,90 GB (-5,1%) | -54,28 GB (-21,6%) | 239,6 milhões | 73 |
| 4 | -107,37 GB (-72,5%) | 89,19GB | 44,79GB | -18,18 GB (-7,2%) | -62,58 GB (-24,9%) | 158,4 milhões | 295 |
| 5 | -110,03 GB (-74,3%) | 89,98GB | 45,16GB | -20,05 GB (-8,0%) | -64,87 GB (-25,8%) | 116,4 milhões | 1118 |
As duas porcentagens usam linhas de base diferentes. A redução da tentativa é contra a experimente o tamanho (148,1 GB da etapa 1), já que a mudança exclui apenas os nós de teste e deixa o instantâneo em paz. O disco líquido colunas estão contra o total Pegada em disco de 251,75 GB.
Um limite mais alto sempre economiza mais disco, mas os ganhos diminuem rapidamente, cada passo valendo cerca de metade do anterior, enquanto a reconstrução do pior caso cresce no sentido contrário:
Esta é a verdadeira desvantagem na escolha de um limite. Um limite maior reduz ainda mais o disco, mas cada acesso que atinge o estado frio reconstrói toda a subárvore em que chega, e uma subárvore maior é uma reconstrução mais lenta. O Cap 2 reconstrói no máximo 16 folhas, o Cap 5 até mais de mil. Portanto, o ponto ideal não é o limite mais profundo. Cap 3 é o melhor equilíbrio: armazena a maior parte da economia de disco (-54,28 GB de um possível -64,87 GB no limite mais profundo), enquanto mantém a reconstrução pequena, no máximo 73 folhas. O Cap 4 troca outros 8 GB por uma reconstrução aproximadamente quatro vezes maior, e o Cap 5 não economiza quase nada para uma reconstrução após mil folhas.
Resumo de ponta a ponta
STEP 1 baseline STEP 2 period inject STEP 3 move inactive out
--------------- -------------------- ------------------------
trie 148.1 GB ---> trie 148.1 GB (same) -> trie ~59 GB + 240 M stubs
snapshot 101.4 GB ---> snapshot 101.6 GB (+0.3) -> snapshot 101.6 GB (same)
no timestamps every leaf has its nodearchive 83 GB raw
last-used period (42 GB compressed)
| Linha de base da etapa 1 | Etapa 2 pós-injeção | Etapa 3 pós-mudança (piso 2/cap 3, melhor equilíbrio) | |
|---|---|---|---|
| experimente nós | 1.895,4 milhões | 1.895,4 milhões | 788,0 milhões (-58%) |
| instantâneo | 101,38GB | ~101,6 GB (+menos de 0,5%) | ~101,6GB |
| arquivo externo | – | – | 82,96 GB brutos / 41,58GB zstd |
| PebbleDB (físico, compactado) | 251,75GB | 251,75GB | 155,89GB (-95,9GB) |
| disco total líquido vs linha de base | – | ~+0,3 GB (+0,1%) | -12,90 GB (-5,1%) bruto / -54,28 GB (-21,6%) compactado |
O que tiramos disso:
- A mudança reduz o banco de dados quente, excluindo nós internos frios e realocando folhas frias. Com a regra de piso 2/cap 3, o PebbleDB cai 95,9 GB e as folhas caem em um arquivo simples, você pode estacionar em um armazenamento mais barato.
- O total no disco ainda diminui após a contagem do arquivo: -12,90 GB brutos e -54,28 GB (cerca de 22%) com a compactação fragmentada. O preço é reconstruir uma pequena subárvore em um raro acesso de estado frio, no máximo 73 folhas neste limite.
- A altura do limite é uma compensação entre o tamanho do disco e o custo de reconstrução. Um limite mais profundo libera mais disco, mas aumenta a reconstrução do pior caso, e os ganhos do disco diminuem rapidamente enquanto o custo de reconstrução aumenta. O limite 3 é o melhor equilíbrio, o limite 4 é mais agressivo (mais disco, maior reconstrução) e o limite 5 mal vale a pena.
Perguntas abertas
Desempenho sob cargas de trabalho reais. Tudo acima é uma medição estática da pegada. O que não mede é o custo de desempenho da modificação do estado frio. Cada acerto em uma subárvore fria paga uma reconstrução: leia os registros folha, reconstrua a subárvore na memória e verifique o hash antes de retornar. Isso é barato em uma tampa rasa, no máximo 73 folhas na tampa 3, mas cresce com a tampa, até cerca de 300 folhas na tampa 4 e mais de mil na tampa 5, e isso acontece em cada acesso a uma subárvore fria. Esta é a principal razão pela qual um limite mais profundo não é automaticamente melhor, apesar de economizar mais disco.
Portanto, o vencedor da pegada não é automaticamente o vencedor da carga de trabalho. Um projeto que economiza mais disco ainda pode perder se um padrão de acesso comum continuar alcançando subárvores frias e pagando pela reconstrução. O experimento que ainda não realizamos é reproduzir blocos reais da rede principal, além de alguns padrões adversários que acessam deliberadamente o estado frio. A compactação adiciona uma segunda camada aqui, já que um acerto em um pedaço compactado também compensa uma descompactação desse pedaço. Portanto, os números apresentados neste documento devem servir apenas como referência para a área ocupada pelo armazenamento, e não para o desempenho.
Fontesethresear



