Voltar para o Blog

COUNT(*) lento no PostgreSQL: contagem rápida com TypeScript

Entenda o papel do MVCC, do visibility map e do VACUUM no custo do count(*) e implemente cinco estratégias de contagem, com benchmark e testes, para escolher a certa em cada tela.

COUNT(*) lento no PostgreSQL: contagem rápida com TypeScript

Quase toda tela de listagem tem aquele número no topo: "1.284.392 pedidos encontrados". Ele parece inofensivo, mas em tabelas grandes do PostgreSQL o SELECT count(*) que gera esse número costuma ser uma das consultas mais caras da página, muitas vezes mais cara do que buscar os próprios registros exibidos.

O assunto voltou à discussão nesta semana. Um texto da ParadeDB sobre como deixar contagens rápidas, publicado no Hacker News com o título "Solving Postgres' Count Problem", continuava na primeira página em 30 de outubro de 2026. O artigo trata de busca textual com uma extensão específica, mas a pergunta de fundo vale para qualquer aplicação: por que contar linhas no PostgreSQL é lento e o que dá para fazer a respeito sem trocar de banco?

Neste tutorial vamos responder isso na prática, com um projeto em Node.js e TypeScript que cria uma tabela de pedidos com um milhão de linhas, mede cinco formas diferentes de obter a contagem e mostra, com EXPLAIN (ANALYZE, BUFFERS), o que acontece dentro do banco em cada caso. No final você terá um módulo pronto para escolher a estratégia certa para cada tela.

O problema: por que contar linhas é caro no PostgreSQL

O PostgreSQL usa MVCC, o controle de concorrência por múltiplas versões. Cada UPDATE cria uma nova versão da linha e cada DELETE apenas marca a versão como removida para as transações futuras. Por isso o banco não guarda um "total de linhas" confiável: duas transações abertas ao mesmo tempo podem enxergar quantidades diferentes de linhas na mesma tabela, e as duas estão corretas.

A consequência é que um count(*) exato precisa olhar cada linha candidata e decidir se ela é visível para a transação atual. Em uma tabela de dezenas de milhões de linhas isso significa ler muitas páginas do heap, que é o arquivo principal da tabela. Mesmo com cache quente, a consulta cresce linearmente com o volume, e em disco frio ela pode levar segundos.

Existe um atalho importante, o index-only scan. Se a consulta só precisa de colunas que estão em um índice, o PostgreSQL pode responder lendo apenas o índice, desde que saiba que as páginas do heap correspondentes estão marcadas como "todas visíveis" no visibility map. Esse mapa é mantido pelo VACUUM. Quando muitas páginas foram alteradas desde o último VACUUM, o atalho deixa de valer e o banco volta a consultar o heap, ou desiste do índice e faz uma leitura sequencial.

O que vamos construir

Vamos montar um pequeno projeto com um script de carga, um módulo de contagem e um benchmark. O módulo oferece cinco funções: contagem exata, contagem limitada (o famoso "1.000+"), estimativa da tabela inteira a partir do catálogo pg_class, estimativa com filtro a partir do planner via EXPLAIN e contagem por uma tabela de contadores mantida por triggers.

O benchmark roda as cinco estratégias em três momentos: logo depois de um VACUUM, depois de atualizar cerca de 20% da tabela e depois de um novo VACUUM. A ideia é ver com números reais, no seu ambiente, como o estado da tabela muda o custo da contagem exata, e como as alternativas se comportam.

Pré-requisitos e versões

Você vai precisar de Node.js 24 LTS ou mais recente, porque o projeto executa arquivos TypeScript diretamente com o suporte nativo a type stripping do Node.js, sem etapa de build. Também é necessário um PostgreSQL 17 de desenvolvimento, que pode ser local, em contêiner, no Amazon RDS ou no Aurora PostgreSQL compatível com a versão 17.

As versões usadas na escrita, consultadas em 30/10/2026, foram Node.js 24.22.2, TypeScript 7.0.2, pg 8.23.1, @types/pg 8.23.1, @types/node 24.19.2 e PostgreSQL 17.10. A versão menor mais recente da linha 17 listada no site do PostgreSQL na mesma data era a 17.11, e as técnicas do artigo não dependem de recursos novos: transition tables em triggers existem desde o PostgreSQL 10.

  • Node.js 24 LTS ou superior
  • PostgreSQL 17 (banco de desenvolvimento, nunca produção)
  • npm
  • Opcional: Docker, para subir o banco localmente

Se quiser subir o banco com Docker, o comando abaixo usa a imagem oficial postgres:17. Ele não foi executado no ambiente em que este texto foi escrito, que usou binários do PostgreSQL 17.10 rodando localmente.

docker run --name pg17-contagem -e POSTGRES_PASSWORD=troque-esta-senha -e POSTGRES_DB=contagem -p 5432:5432 -d postgres:17

Arquitetura da solução

A arquitetura é propositalmente simples. Uma tabela pedidos recebe os dados, com um índice composto em (status, criado_em) que permite index-only scans quando o filtro é por status. Ao lado dela fica a tabela pedidos_contagem, com uma linha por status, atualizada por três triggers de instrução (FOR EACH STATEMENT) que usam transition tables para processar todas as linhas afetadas de uma vez.

Do lado da aplicação, o módulo src/contagem.ts expõe uma função por estratégia e aceita tanto um pool quanto um client de transação do pg. Assim a mesma função serve para a API e para os testes, que rodam dentro de uma transação descartada com ROLLBACK para não sujar a base.

Estrutura de diretórios

contagem-postgres/
├── .env.example
├── package.json
├── tsconfig.json
├── sql/
│   ├── 001_schema.sql
│   ├── 002_seed.sql
│   └── 003_contador.sql
├── src/
│   ├── benchmark.ts
│   ├── contagem.ts
│   ├── db.ts
│   └── setup.ts
└── test/
    └── contagem.test.ts

Preparando o ambiente

Crie a pasta do projeto e adicione os arquivos de configuração. O package.json fixa as versões usadas no teste e define scripts para carga, benchmark, checagem de tipos e testes. Repare que não existe script de build: o Node.js 24 remove as anotações de tipo na hora de executar, e o tsc entra só para checar os tipos.

Arquivo: package.json

{
  "name": "contagem-postgres",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=24"
  },
  "scripts": {
    "db:setup": "node src/setup.ts",
    "bench": "node src/benchmark.ts",
    "typecheck": "tsc --noEmit",
    "test": "node --test \"test/**/*.test.ts\""
  },
  "dependencies": {
    "pg": "8.23.1"
  },
  "devDependencies": {
    "@types/node": "24.19.2",
    "@types/pg": "8.23.1",
    "typescript": "7.0.2"
  }
}

O tsconfig.json liga allowImportingTsExtensions, porque com o type stripping nativo os imports precisam apontar para arquivos .ts, e erasableSyntaxOnly, que impede o uso de recursos do TypeScript que geram código, como enum, que o Node.js não consegue apenas apagar.

Arquivo: tsconfig.json

{
  "compilerOptions": {
    "target": "es2024",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src", "test"]
}

As variáveis de ambiente ficam em um .env.example. Copie para .env e ajuste a URL. A variável PERMITIR_RESET existe porque o script de setup apaga e recria a tabela; ela é uma trava simples contra rodar o script, sem querer, apontando para um banco que importa.

Arquivo: .env.example

# Conexão com um banco de DESENVOLVIMENTO (o setup apaga e recria a tabela pedidos)
DATABASE_URL=postgres://postgres:troque-esta-senha@localhost:5432/contagem
# Quantidade de linhas geradas pelo setup
SEED_ROWS=1000000
# Proteção: o setup só roda com este valor igual a "sim"
PERMITIR_RESET=nao

Instale as dependências:

npm install

Passo 1: schema e índice

O schema cria a tabela de pedidos com uma coluna observacao propositalmente larga. Ela torna cada linha maior e deixa clara a diferença entre ler o heap inteiro e ler apenas o índice, que é bem mais compacto. O índice em (status, criado_em) cobre o filtro por status e também serviria para ordenar a listagem por data.

Arquivo: sql/001_schema.sql

DROP TABLE IF EXISTS pedidos_contagem;
DROP TABLE IF EXISTS pedidos;

CREATE TABLE pedidos (
  id             bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  cliente_id     integer     NOT NULL,
  status         text        NOT NULL
                 CHECK (status IN ('pendente', 'pago', 'enviado', 'cancelado')),
  criado_em      timestamptz NOT NULL DEFAULT now(),
  valor_centavos integer     NOT NULL,
  observacao     text
);

CREATE INDEX pedidos_status_criado_em_idx ON pedidos (status, criado_em);

Passo 2: carga de dados

A carga usa generate_series para gerar a quantidade de linhas definida em SEED_ROWS, com uma distribuição desigual de status: cerca de 60% pagos, 25% enviados, 10% pendentes e 5% cancelados. A quantidade chega como parâmetro $1, e não concatenada na string, como em qualquer SQL vindo da aplicação.

Arquivo: sql/002_seed.sql

INSERT INTO pedidos (cliente_id, status, criado_em, valor_centavos, observacao)
SELECT
  1 + floor(random() * 50000)::int,
  CASE
    WHEN r < 0.10 THEN 'pendente'
    WHEN r < 0.70 THEN 'pago'
    WHEN r < 0.95 THEN 'enviado'
    ELSE 'cancelado'
  END,
  now() - random() * interval '365 days',
  100 + floor(random() * 100000)::int,
  repeat('observação de exemplo ', 8)
FROM (
  SELECT random() AS r
  FROM generate_series(1, $1::int)
) AS gerador;

Passo 3: contador mantido por triggers

A estratégia de contador troca custo de leitura por custo de escrita. Em vez de contar na hora da consulta, cada INSERT, UPDATE ou DELETE em pedidos ajusta a tabela pedidos_contagem. Usamos triggers de instrução com transition tables, então um INSERT de cem mil linhas dispara a função uma única vez, com um GROUP BY sobre as linhas novas, em vez de cem mil execuções de um trigger por linha.

A documentação do CREATE TRIGGER explica que vários eventos podem ser combinados com OR, exceto quando transition tables são solicitadas. Por isso há três triggers apontando para a mesma função, cada um declarando só as tabelas de transição que fazem sentido para o seu evento. No UPDATE, a função soma +1 para o status novo e -1 para o antigo, e descarta os grupos com saldo zero, então atualizar o valor de um pedido sem mudar o status não toca no contador.

O ORDER BY status nas três operações faz as transações tentarem bloquear as linhas do contador sempre na mesma ordem. É uma mitigação para reduzir o risco de deadlock entre instruções que mexem em vários status ao mesmo tempo, não uma garantia formal, e vale observar o log do banco sob carga real.

Arquivo: sql/003_contador.sql

CREATE TABLE pedidos_contagem (
  status text   PRIMARY KEY,
  total  bigint NOT NULL
);

CREATE OR REPLACE FUNCTION pedidos_contagem_aplicar() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
  IF TG_OP = 'INSERT' THEN
    INSERT INTO pedidos_contagem AS c (status, total)
    SELECT status, count(*) FROM novos GROUP BY status ORDER BY status
    ON CONFLICT (status) DO UPDATE SET total = c.total + EXCLUDED.total;
  ELSIF TG_OP = 'DELETE' THEN
    INSERT INTO pedidos_contagem AS c (status, total)
    SELECT status, -count(*) FROM antigos GROUP BY status ORDER BY status
    ON CONFLICT (status) DO UPDATE SET total = c.total + EXCLUDED.total;
  ELSIF TG_OP = 'UPDATE' THEN
    INSERT INTO pedidos_contagem AS c (status, total)
    SELECT status, sum(delta)
      FROM (
        SELECT status, 1 AS delta FROM novos
        UNION ALL
        SELECT status, -1 AS delta FROM antigos
      ) AS mudancas
     GROUP BY status
    HAVING sum(delta) <> 0
     ORDER BY status
    ON CONFLICT (status) DO UPDATE SET total = c.total + EXCLUDED.total;
  END IF;
  RETURN NULL;
END;
$$;

CREATE TRIGGER pedidos_contagem_insert
  AFTER INSERT ON pedidos
  REFERENCING NEW TABLE AS novos
  FOR EACH STATEMENT EXECUTE FUNCTION pedidos_contagem_aplicar();

CREATE TRIGGER pedidos_contagem_update
  AFTER UPDATE ON pedidos
  REFERENCING OLD TABLE AS antigos NEW TABLE AS novos
  FOR EACH STATEMENT EXECUTE FUNCTION pedidos_contagem_aplicar();

CREATE TRIGGER pedidos_contagem_delete
  AFTER DELETE ON pedidos
  REFERENCING OLD TABLE AS antigos
  FOR EACH STATEMENT EXECUTE FUNCTION pedidos_contagem_aplicar();

INSERT INTO pedidos_contagem (status, total)
SELECT status, count(*) FROM pedidos GROUP BY status;

O último comando do arquivo popula o contador com os valores atuais. Para que nenhuma escrita escape entre essa contagem inicial e a criação dos triggers, o script de setup executa tudo dentro de uma transação que começa com LOCK TABLE pedidos IN SHARE ROW EXCLUSIVE MODE, bloqueando escritas concorrentes até o COMMIT.

Passo 4: conexão e módulo de contagem

O arquivo de conexão cria o pool a partir de DATABASE_URL e exporta o tipo Queryable, que aceita tanto o pool quanto um client já dentro de uma transação.

Arquivo: src/db.ts

import pg from 'pg';

export type Queryable = pg.Pool | pg.PoolClient;

export function criarPool(): pg.Pool {
  const connectionString = process.env.DATABASE_URL;
  if (!connectionString) {
    throw new Error('Defina DATABASE_URL (veja .env.example).');
  }
  return new pg.Pool({ connectionString, max: 5 });
}

O módulo de contagem concentra as cinco estratégias. A contagem exata usa duas consultas separadas, com e sem filtro, em vez de um WHERE ($1 IS NULL OR status = $1), que costuma atrapalhar o planner. O PostgreSQL devolve count(*) como bigint, que o driver pg entrega como string para não perder precisão, por isso as funções convertem com Number, seguro enquanto os totais ficarem abaixo de Number.MAX_SAFE_INTEGER.

A contagem limitada pede limite + 1 linhas em uma subconsulta com LIMIT e conta o resultado. Se vierem mais linhas do que o limite, a interface pode exibir "1.000+" sem nunca percorrer o resto da tabela. É a estratégia mais subestimada da lista, porque resolve a maior parte das telas de busca com um custo que não cresce com o tamanho da tabela.

A estimativa da tabela segue a fórmula descrita na wiki do PostgreSQL e na documentação de estimativa de linhas do planner: a densidade reltuples / relpages medida no último VACUUM ou ANALYZE, multiplicada pelo número atual de páginas. Quando reltuples vale -1, a tabela nunca foi analisada e a função devolve null em vez de inventar um número.

Para contagens filtradas, a estimativa vem do próprio planner. A função roda EXPLAIN (FORMAT JSON) com o filtro parametrizado e lê o campo Plan Rows do nó raiz. O número vem das estatísticas coletadas pelo ANALYZE, então funciona bem para filtros simples em colunas com estatísticas boas e fica pior em combinações de colunas correlacionadas.

Arquivo: src/contagem.ts

import type { Queryable } from './db.ts';

export type Status = 'pendente' | 'pago' | 'enviado' | 'cancelado';

export interface ContagemLimitada {
  total: number;
  passouDoLimite: boolean;
}

export async function contarExato(db: Queryable, status?: Status): Promise<number> {
  const resultado = status
    ? await db.query<{ total: string }>(
        'SELECT count(*) AS total FROM pedidos WHERE status = $1',
        [status],
      )
    : await db.query<{ total: string }>('SELECT count(*) AS total FROM pedidos');
  return Number(resultado.rows[0].total);
}

export async function contarLimitado(
  db: Queryable,
  status: Status,
  limite: number,
): Promise<ContagemLimitada> {
  const resultado = await db.query<{ total: string }>(
    `SELECT count(*) AS total
       FROM (SELECT 1 FROM pedidos WHERE status = $1 LIMIT $2) AS amostra`,
    [status, limite + 1],
  );
  const total = Number(resultado.rows[0].total);
  return { total: Math.min(total, limite), passouDoLimite: total > limite };
}

export async function estimarTotalDaTabela(db: Queryable): Promise<number | null> {
  const resultado = await db.query<{ estimativa: string | null }>(
    `SELECT CASE
              WHEN c.reltuples < 0 THEN NULL
              WHEN c.relpages = 0 THEN 0
              ELSE round(
                (c.reltuples / c.relpages)
                * (pg_relation_size(c.oid) / current_setting('block_size')::int)
              )
            END AS estimativa
       FROM pg_class AS c
      WHERE c.oid = 'public.pedidos'::regclass`,
  );
  const valor = resultado.rows[0]?.estimativa;
  return valor === null || valor === undefined ? null : Number(valor);
}

interface PlanoJson {
  Plan: { 'Plan Rows': number };
}

export async function estimarComFiltro(db: Queryable, status: Status): Promise<number> {
  const resultado = await db.query<{ 'QUERY PLAN': PlanoJson[] }>(
    'EXPLAIN (FORMAT JSON) SELECT 1 FROM pedidos WHERE status = $1',
    [status],
  );
  return resultado.rows[0]['QUERY PLAN'][0].Plan['Plan Rows'];
}

export async function contarPeloContador(db: Queryable, status?: Status): Promise<number> {
  const resultado = status
    ? await db.query<{ total: string }>(
        'SELECT coalesce(sum(total), 0) AS total FROM pedidos_contagem WHERE status = $1',
        [status],
      )
    : await db.query<{ total: string }>(
        'SELECT coalesce(sum(total), 0) AS total FROM pedidos_contagem',
      );
  return Number(resultado.rows[0].total);
}

export interface Visibilidade {
  paginas: number;
  paginasTodasVisiveis: number;
}

export async function lerVisibilidade(db: Queryable): Promise<Visibilidade> {
  const resultado = await db.query<{ relpages: number; relallvisible: number }>(
    `SELECT relpages, relallvisible
       FROM pg_class
      WHERE oid = 'public.pedidos'::regclass`,
  );
  const linha = resultado.rows[0];
  return { paginas: linha.relpages, paginasTodasVisiveis: linha.relallvisible };
}

Passo 5: script de setup

O setup junta as peças: recria o schema, carrega os dados, roda VACUUM (ANALYZE) para preencher o visibility map e as estatísticas, e instala o contador dentro da transação com lock. Em caso de erro, faz ROLLBACK e devolve a conexão ao pool.

Arquivo: src/setup.ts

import { readFile } from 'node:fs/promises';
import { criarPool } from './db.ts';

if (process.env.PERMITIR_RESET !== 'sim') {
  console.error('Recusado: este script apaga a tabela pedidos. Use PERMITIR_RESET=sim em um banco de desenvolvimento.');
  process.exit(1);
}

const linhas = Number(process.env.SEED_ROWS ?? '1000000');
if (!Number.isInteger(linhas) || linhas <= 0) {
  throw new Error('SEED_ROWS precisa ser um inteiro positivo.');
}

const lerSql = (nome: string) => readFile(new URL(`../sql/${nome}`, import.meta.url), 'utf8');

const pool = criarPool();
const client = await pool.connect();
try {
  console.log('Criando schema');
  await client.query(await lerSql('001_schema.sql'));

  console.log(`Inserindo ${linhas} pedidos`);
  const inicio = performance.now();
  await client.query(await lerSql('002_seed.sql'), [linhas]);
  console.log(`Carga concluída em ${((performance.now() - inicio) / 1000).toFixed(1)} s`);

  console.log('Rodando VACUUM (ANALYZE)');
  await client.query('VACUUM (ANALYZE) pedidos');

  console.log('Instalando contador por trigger');
  await client.query('BEGIN');
  await client.query('LOCK TABLE pedidos IN SHARE ROW EXCLUSIVE MODE');
  await client.query(await lerSql('003_contador.sql'));
  await client.query('COMMIT');

  console.log('Pronto.');
} catch (erro) {
  await client.query('ROLLBACK').catch(() => undefined);
  throw erro;
} finally {
  client.release();
  await pool.end();
}

Passo 6: benchmark

O benchmark mede a mediana de cinco execuções de cada estratégia e imprime o plano da contagem exata filtrada. Entre as rodadas ele atualiza um em cada cinco pedidos, alterando só o valor, o que cria novas versões das linhas e tira páginas do estado "todas visíveis", e depois roda um novo VACUUM.

Arquivo: src/benchmark.ts

import { criarPool, type Queryable } from './db.ts';
import {
  contarExato,
  contarLimitado,
  contarPeloContador,
  estimarComFiltro,
  estimarTotalDaTabela,
  lerVisibilidade,
} from './contagem.ts';

const REPETICOES = 5;

async function medir(nome: string, fn: () => Promise<unknown>): Promise<void> {
  const tempos: number[] = [];
  let ultimo: unknown;
  for (let i = 0; i < REPETICOES; i++) {
    const inicio = performance.now();
    ultimo = await fn();
    tempos.push(performance.now() - inicio);
  }
  tempos.sort((a, b) => a - b);
  const mediana = tempos[Math.floor(tempos.length / 2)];
  console.log(`${nome.padEnd(42)} ${mediana.toFixed(1).padStart(9)} ms  -> ${JSON.stringify(ultimo)}`);
}

async function planoDaContagem(db: Queryable): Promise<void> {
  const resultado = await db.query<{ 'QUERY PLAN': string }>(
    `EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
     SELECT count(*) FROM pedidos WHERE status = 'pago'`,
  );
  for (const linha of resultado.rows) {
    console.log(`    ${linha['QUERY PLAN']}`);
  }
}

async function rodada(db: Queryable, titulo: string): Promise<void> {
  const vis = await lerVisibilidade(db);
  console.log(`
=== ${titulo} (páginas: ${vis.paginas}, todas visíveis: ${vis.paginasTodasVisiveis}) ===`);
  await medir('count(*) exato, tabela inteira', () => contarExato(db));
  await medir("count(*) exato, status = 'pago'", () => contarExato(db, 'pago'));
  await medir("contagem limitada a 1000, 'pago'", () => contarLimitado(db, 'pago', 1000));
  await medir('estimativa da tabela (pg_class)', () => estimarTotalDaTabela(db));
  await medir("estimativa do planner, 'pago'", () => estimarComFiltro(db, 'pago'));
  await medir("contador por trigger, 'pago'", () => contarPeloContador(db, 'pago'));
  console.log("  Plano do count(*) com status = 'pago':");
  await planoDaContagem(db);
}

const pool = criarPool();
try {
  await rodada(pool, 'Depois do VACUUM');

  console.log('
Atualizando ~20% dos pedidos (sem VACUUM depois)');
  await pool.query(
    `UPDATE pedidos SET valor_centavos = valor_centavos + 1 WHERE id % 5 = 0`,
  );
  await rodada(pool, 'Depois do UPDATE, antes do VACUUM');

  await pool.query('VACUUM (ANALYZE) pedidos');
  await rodada(pool, 'Depois de um novo VACUUM');
} finally {
  await pool.end();
}

Executando o projeto

Com o banco no ar e as variáveis exportadas, rode o setup e depois o benchmark. O setup com um milhão de linhas levou entre 4 e 7 segundos de carga nas execuções de teste; o tempo no seu ambiente vai depender de disco, memória e configuração.

export DATABASE_URL=postgres://postgres:troque-esta-senha@localhost:5432/contagem
export SEED_ROWS=1000000
export PERMITIR_RESET=sim
npm run db:setup
npm run bench

A tabela abaixo resume a mediana de cinco execuções obtida em uma única rodada local, com PostgreSQL 17.10, configuração padrão e cache quente. Trate esses números como ilustração do comportamento, não como benchmark: eles mudam com hardware, shared_buffers, paralelismo e com o próprio sorteio de dados.

Estratégia (status = 'pago')Após VACUUMApós UPDATE de 20%Após novo VACUUM
count(*) exato, tabela inteira26,6 ms171,7 ms61,4 ms
count(*) exato com filtro21,7 ms55,4 ms35,8 ms
Contagem limitada a 1.0000,6 ms0,8 ms0,4 ms
Estimativa pg_class (tabela)0,1 ms0,5 ms0,1 ms
Estimativa do planner (filtro)0,1 ms0,1 ms0,2 ms
Contador por trigger0,1 ms0,2 ms0,2 ms

Logo depois do primeiro VACUUM, a contagem filtrada usou Parallel Index Only Scan com apenas 116 heap fetches, ou seja, quase nenhuma ida ao heap. Depois do UPDATE, o planner trocou para Parallel Seq Scan e passou a ler a tabela inteira, e a contagem da tabela completa ficou mais de seis vezes mais lenta. O novo VACUUM trouxe de volta o index-only scan com zero heap fetches, mas o tempo não voltou ao patamar inicial, porque a tabela e o índice cresceram com as versões criadas pelo UPDATE.

Dois detalhes da saída merecem atenção. O primeiro é que, depois do UPDATE e antes do VACUUM, a estimativa de pg_class subiu para cerca de 1,2 milhão de linhas, porque a tabela ganhou páginas com versões mortas e a fórmula multiplica a densidade antiga pelo tamanho novo; o valor exato continuava sendo um milhão. O segundo é que o próprio relallvisible impresso continuou igual, já que ele também é uma estimativa atualizada só por VACUUM, ANALYZE e alguns comandos de DDL.

Já a estimativa do planner para o filtro ficou em torno de 723 mil linhas depois do UPDATE, contra cerca de 599 mil reais, e voltou para perto de 598 mil depois do VACUUM (ANALYZE). O contador por trigger, por sua vez, manteve o valor exato o tempo todo, porque é atualizado na mesma transação de cada escrita.

Testes

Os testes usam o test runner nativo do Node.js executando TypeScript diretamente. Cada teste que escreve na tabela roda dentro de uma transação finalizada com ROLLBACK, o que permite validar o contador contra a contagem exata sem alterar os dados do benchmark. Isso funciona porque os triggers rodam na mesma transação e o contador fica visível para ela antes do ROLLBACK.

Arquivo: test/contagem.test.ts

import { after, before, test } from 'node:test';
import assert from 'node:assert/strict';
import type pg from 'pg';
import { criarPool } from '../src/db.ts';
import {
  contarExato,
  contarLimitado,
  contarPeloContador,
  estimarTotalDaTabela,
  type Status,
} from '../src/contagem.ts';

const TODOS: Status[] = ['pendente', 'pago', 'enviado', 'cancelado'];
let pool: pg.Pool;

before(() => {
  pool = criarPool();
});

after(async () => {
  await pool.end();
});

async function emTransacaoDescartavel(fn: (client: pg.PoolClient) => Promise<void>): Promise<void> {
  const client = await pool.connect();
  try {
    await client.query('BEGIN');
    await fn(client);
  } finally {
    await client.query('ROLLBACK');
    client.release();
  }
}

test('contador por trigger acompanha INSERT, UPDATE e DELETE', async () => {
  await emTransacaoDescartavel(async (client) => {
    await client.query(
      `INSERT INTO pedidos (cliente_id, status, valor_centavos)
       VALUES (1, 'pendente', 1000), (2, 'pendente', 2000), (3, 'pago', 3000)`,
    );
    await client.query(
      `UPDATE pedidos SET status = 'cancelado'
        WHERE id = (SELECT max(id) FROM pedidos WHERE status = 'pendente')`,
    );
    await client.query(
      `DELETE FROM pedidos
        WHERE id = (SELECT max(id) FROM pedidos WHERE status = 'pago')`,
    );
    for (const status of TODOS) {
      assert.equal(
        await contarPeloContador(client, status),
        await contarExato(client, status),
        `divergência no status ${status}`,
      );
    }
    assert.equal(await contarPeloContador(client), await contarExato(client));
  });
});

test('UPDATE que não muda o status não altera o contador', async () => {
  await emTransacaoDescartavel(async (client) => {
    const antes = await contarPeloContador(client, 'pago');
    await client.query(
      `UPDATE pedidos SET valor_centavos = valor_centavos + 1
        WHERE id IN (SELECT id FROM pedidos WHERE status = 'pago' LIMIT 100)`,
    );
    assert.equal(await contarPeloContador(client, 'pago'), antes);
  });
});

test('contagem limitada informa quando passou do limite', async () => {
  const exato = await contarExato(pool, 'cancelado');
  const limitada = await contarLimitado(pool, 'cancelado', 10);
  assert.equal(limitada.total, Math.min(exato, 10));
  assert.equal(limitada.passouDoLimite, exato > 10);
});

test('estimativa da tabela fica perto do valor exato depois do ANALYZE', async () => {
  await pool.query('ANALYZE pedidos');
  const exato = await contarExato(pool);
  const estimativa = await estimarTotalDaTabela(pool);
  assert.notEqual(estimativa, null);
  const erroRelativo = Math.abs((estimativa ?? 0) - exato) / Math.max(exato, 1);
  assert.ok(erroRelativo < 0.05, `erro relativo ${erroRelativo.toFixed(4)} acima de 5%`);
});

Rode a checagem de tipos e os testes, com o banco populado pelo setup:

npm run typecheck
npm test

No ambiente de escrita, tsc --noEmit terminou sem erros e os quatro testes passaram: o contador acompanhou INSERT, UPDATE e DELETE, um UPDATE sem mudança de status não alterou o contador, a contagem limitada sinalizou corretamente o limite e a estimativa da tabela ficou a menos de 5% do valor exato depois do ANALYZE.

Erros comuns

O erro mais comum é confiar no index-only scan sem cuidar do VACUUM. Se o autovacuum está atrasado em uma tabela muito escrita, o visibility map fica desatualizado e a contagem "rápida" vira leitura sequencial sem nenhum aviso. Acompanhe n_dead_tup e last_autovacuum em pg_stat_user_tables e ajuste os parâmetros de autovacuum por tabela quando necessário.

Outro tropeço frequente é usar reltuples direto, sem tratar o valor -1, que a documentação do catálogo pg_class define como "contagem desconhecida" para tabelas nunca analisadas. Exibir -1 ou zero para o usuário é pior do que exibir "calculando". Em tabelas particionadas, lembre também que a estimativa precisa somar as partições, porque a tabela pai não guarda as linhas.

No contador por trigger, cuidado com TRUNCATE: ele não dispara triggers de DELETE, e o contador ficaria errado. Se a sua aplicação usa TRUNCATE, crie também um trigger AFTER TRUNCATE que zere a tabela de contadores, ou faça a recontagem como parte da rotina que executa o TRUNCATE.

Por fim, ao adaptar o projeto, verifique que os imports locais terminam em .ts. Sem a extensão, o Node.js não encontra o módulo ao executar com type stripping, porque a resolução de módulos ES exige o caminho completo do arquivo. Com module e moduleResolution em nodenext, o tsc também costuma apontar esse problema durante o npm run typecheck.

Segurança

Todas as consultas que recebem valores externos usam parâmetros posicionais do pg, inclusive o EXPLAIN da estimativa com filtro. Nunca monte o filtro concatenando strings vindas da requisição, nem mesmo dentro de um EXPLAIN: a concatenação abre espaço para SQL injection da mesma forma que em qualquer outra consulta. O tipo Status no TypeScript ajuda, mas não substitui a validação na borda da API, já que os tipos somem em tempo de execução.

Não deixe credenciais em código ou no repositório: o .env.example traz apenas um placeholder, e em ambientes AWS a senha deve vir de um gerenciador de segredos. Para expor contagens a usuários, considere também o que o número revela. Um total exato de pedidos de outro cliente, por exemplo, pode ser informação sensível, e a contagem precisa respeitar as mesmas regras de autorização que a listagem.

Performance e custos

A escolha da estratégia depende do quanto a tela precisa de exatidão. Para paginação e buscas, a contagem limitada resolve a maior parte dos casos com custo quase constante. Para dashboards que mostram ordens de grandeza, as estimativas do catálogo e do planner são praticamente gratuitas. A contagem exata fica para relatórios e processos que realmente precisam do número certo, de preferência com filtros cobertos por índice e tabelas bem vacuumadas.

O contador por trigger é o único que entrega número exato com leitura constante, mas cobra na escrita. Cada instrução que altera pedidos faz um INSERT ON CONFLICT extra, e transações concorrentes que mexem no mesmo status ficam enfileiradas no lock da linha do contador até o COMMIT. Em tabelas com muitas escritas simultâneas, isso pode virar um gargalo maior do que a contagem que você queria evitar.

Quando esse gargalo aparecer, uma alternativa conhecida é trocar a atualização da linha única por uma tabela de deltas: cada escrita insere uma linha com +1 ou -1, sem disputar lock, e um job periódico consolida os deltas. A leitura passa a somar o valor consolidado com os deltas pendentes. É mais código e mais uma rotina para operar, então só vale a pena quando a contenção estiver medida.

Vale olhar também para o tipo de UPDATE que a sua aplicação faz. No benchmark, pg_stat_user_tables mostrou zero atualizações HOT (heap-only tuple), porque as páginas estavam cheias depois da carga e cada nova versão foi para outra página, ganhando também uma nova entrada no índice. A documentação sobre HOT explica que reduzir o fillfactor da tabela deixa espaço livre nas páginas e aumenta a chance de atualizações HOT quando nenhuma coluna indexada muda, o que diminui o crescimento do índice.

Em ambientes gerenciados como RDS e Aurora, cada leitura sequencial grande também consome I/O e CPU da instância, o que se traduz em custo e em menos folga para as outras consultas. Trocar um count(*) executado a cada carregamento de página por uma contagem limitada ou estimada tende a ser uma das otimizações de melhor custo-benefício de um sistema com listagens.

Limitações

As medições deste artigo vieram de uma única execução local, com um milhão de linhas e cache quente, e servem para mostrar tendências, não para prever tempos de produção. O comando Docker e a execução em RDS ou Aurora não foram testados no ambiente de escrita, que usou binários do PostgreSQL 17.10 rodando localmente.

O contador cobre apenas a agregação por status. Se a tela precisa de contagens por combinações arbitrárias de filtros, nenhuma tabela de contadores vai acompanhar todas as combinações, e o caminho volta a ser a contagem limitada, a estimativa do planner ou uma estrutura analítica separada. Para volumes realmente grandes de eventos, um banco colunar como o ClickHouse pode ser a ferramenta mais adequada para esse tipo de agregação.

Conclusão

O count(*) lento não é um defeito do PostgreSQL, e sim o preço do MVCC: o banco precisa decidir, linha por linha, o que cada transação pode enxergar. Entender o papel do visibility map e do VACUUM explica por que a mesma consulta pode ser rápida hoje e lenta amanhã, como vimos quando um UPDATE em 20% da tabela fez o planner abandonar o index-only scan.

A saída é parar de tratar a contagem como uma coisa só. Com o módulo deste tutorial, cada tela escolhe o que precisa: número exato quando é indispensável, "1.000+" para buscas, estimativa para dashboards e contador por trigger para os poucos totais que precisam ser exatos e instantâneos. O ganho aparece tanto no tempo de resposta quanto na carga do banco.

Referências

Precisa implementar isso na sua empresa?

Quer aplicar essas práticas de engenharia, dados ou inteligência artificial no seu produto? Solicite um diagnóstico técnico sem compromisso.

Solicitar orçamento