PostgreSQL: Configuração para importação massiva - 160.000.000 de registros em minutos

O Governo brasileiro mantém uma publicação mensal dos dados do CNPJ - cadastro nacional de pessoa jurídica. Para novembro de 2024, são 35 arquivos compactados em format ".zip" (link para download no final do texto)

Um dos nossos clientes, nos trouxe a demanda para melhorar sua aplicaçao que mantém um banco de dados local atualziado com os dados do CNPJ.

O banco do nosso cliente atende outras aplicações internas e deveria ser mantido em um servidor Microsoft SQL Server. Fizemos a manutenção do código com algumas melhorias e houve algum ganho no tempo de atualização dos dados - mas ainda levava algumas horas. Não exatamente um problema para um dado atualizado a cada 4 ou 5 semanas.

A dificuldade da importação tanto pelo tamanho dos arquivos quanto peloo tempo de importação, foi pauta de algumas discussões dentro do time da Necto. Então decidimos fazer outros testes mesmo já tendo entergue uma solução para o nosso cliente.

A necto há anos usa o PostgreSQL como banco de dados, é nossa primeira escolha sempre que possível. É um banco de dados robusto, de código aberto, e que não perde em nada para outros SGDBs do mercado. E resolvemos fazer alguns testes com o PostgreSQL para importar os dados do CNPJ.

O comando COPY do PostgreSQL

Os dados do CNPJ são disponibilizados em arquivos CSV compactados em formato ZIP. Hoje (2024) são 35 arquivos ZIP, que cotem algo em torno de 170.000.000 de registros - CNPJs únicos em torno de ~60.000.000.

Com os dados em mãos, usamos o comando `COPY` do PostgreSQL

COPY moves data between PostgreSQL tables and standard file-system files. COPY TO copies the contents of a table to a file, while COPY FROM copies data from a file to a table (appending the data to whatever is in the table already). COPY TO can also copy the results of a SELECT query.
source: https://www.postgresql.org/docs/16/sql-copy.html
COPY table_name [ ( column_name [, ...] ) ]
FROM { 'filename' | PROGRAM 'command' | STDIN }
[ [ WITH ] ( option [, ...] ) ]
[ WHERE condition ]

COPY { table_name [ ( column_name [, ...] ) ] | ( query ) }
TO { 'filename' | PROGRAM 'command' | STDOUT }
[ [ WITH ] ( option [, ...] ) ]

Algumas configurações foram feitas na tentativa de melhorar a performance da importação.

São muitas possibilidade de configuração do Postgresql, algumas devem ser feitas na inicialização do PostgreSQL outras podem ser feitas em tempo de execução.

Veja aqui uma lista completa dos parâmetros que são possíveis alterar para execução do PostgreSQL (https://www.postgresql.org/docs/16/runtime-config.html).

No caso de importações massivas de atenção a configuração do "WAL" (Write ahed log - https://www.postgresql.org/docs/16/wal-configuration.html)

PostgreSQL Config Settings

autovacuum.......................off
checkpoint_completion_target............0.9
checkpoint_timeout...............900 s
constraint_exclusion.......partition
default_statistics_target........100
effective_cache_size..........524288 kB
effective_io_concurrency...........1
fsync............................off
full_page_writes.................off
huge_pages.......................try
maintenance_work_mem...........65536 kB
max_wal_size....................5120 MB
min_wal_size....................1024 MB
random_page_cost...................4
shared_buffers.................98304 kB
synchronous_commit................on
wal_buffers.....................2048 kB
work_mem........................4096 kB

Estes parâmetros podem ser definidos no arquivo de configuração do PostgreSQL. Como estamos usando uma imagem docker, passamos como parâmetro no `command` do docker-compose .

# docker compose file
# PostgreSQL config settings before start.

postgis16:
    image: postgis/postgis:16-3.5-alpine
    mem_limit: 2.5GB  # limit the memory used by PostgreSQL container
    mem_reservation: 1.5GB
    shm_size: '1gb'   # its needed to build indexes and Primary Keys.
    ... # other
    volumes:
      - data-files:/usr/src/data_files/
    container_name: postgis16

    command:
      - "postgres"
      - "-c"
      - "autovacuum=OFF"
      - "-c"
      - "min_wal_size=1GB"
      - "-c"
      - "max_wal_size=5GB"
      - "-c"
      - "shared_buffers=768MB"
      - "-c"
      - "fsync=OFF"
      - "-c"
      - "full_page_writes=off"
      - "-c"
      - "checkpoint_timeout=15min" # min must be lowercase

Outros parâmetros, são alterados em tempo de execução ...

# python code, before start import
# PostgreSQL config settings runtime

cur.execute("ALTER DATABASE cnpj SET constraint_exclusion='OFF';")
cur.execute("ALTER DATABASE cnpj SET maintenance_work_mem='512MB';")
cur.execute("ALTER DATABASE cnpj SET synchronous_commit='OFF';")
cur.execute("ALTER DATABASE cnpj SET work_mem='4GB';")
cur.execute("SELECT pg_reload_conf();")

e ainda, alguns outros logo antes da execução dos comandos SQL como o `INSERT INTO`.

ALTER TABLE cnae DISABLE TRIGGER ALL;

Results

Abaixo os resultados e o tempo de importação em 2 maquinas distintas.

As especificações estão abaixo.

Database Created
Table Number of Records
cnae 1,358
motivo 60
municipio 5,570
pais 254
natureza_juridica 89
qualificacao 67
simples 41,093,712
empresa 60,294,472
estabelecimento 63,333,636
socio 24,933,508
Setup
CPU Memory Tempo (min)
3.1 GHz Dual-Core Intel Core i7 16 GB 1867 MHz DDR3 39
2.60 GHz Intel i5 * 12 32 GB 3200 MHz DDR4 21

Python Code and Data Corrections

Tivemos problemas durante a criação dos índices, e tivemos que altera a "shared memory" do docker - `shm_size: '1gb'` - veja no arquivo "docker-compose" no repositório.

O Python foi usado principalmente como um orquestrador, mas também faz algumas pequenas correções no dado e nome dos arquivos. O primeiro é devido ao um registro de CNPJ duplicado, o que impedia a criação da PK. O o outro é que alguns nomes de arquivos originais tem um caracter `$` no meio do nome.

Ainda há um problema de codificação em um dos arquivos CSV, que perdura entre as verões publicadas, então o Python executa um script Perl - `perl -i -pe 's/\\x00//g' {fle}` para corrigir o problema em tempo de execução.

perl -i -pe ‘s/\\x00//g’ {fle}

Escrever em Python nos deu maior maleabilidade para controlar o processo todo. Mas sim, seria possível fazer tudo somente usando PSQL.

Repository: https://github.com/znc-sistemas/cnpj_import

For more details, check out: https://www.postgresql.org/docs/16/runtime-config-resource.html.