Evite duplicatas em operações concorrentes de verificação e inserção com uma restrição unique na chave lógica do artigo e INSERT ... ON CONFLICT. Quando também precisar da linha existente, use uma estratégia de recuperação compatível com o nível de isolamento da transação.

Este tutorial é para um desenvolvedor que trabalha no Terminal do macOS e precisa de uma função Node.js que crie um artigo sintético uma única vez e retorne sua linha para chamadas concorrentes. Os exemplos preservam o título do primeiro artigo. Eles usam a documentação atual do PostgreSQL e a documentação oficial do node-postgres.

Por que uma transação, sozinha, não evita duplicatas

Considere duas chamadas tentando criar um artigo com o slug paper-shapes. Cada uma primeiro verifica se esse slug existe e, em seguida, insere o artigo se a consulta não retornar nada. No nível de isolamento Read Committed padrão do PostgreSQL, uma consulta normal vê os dados confirmados até o snapshot da instrução. Portanto, ambas as verificações podem indicar que o artigo não existe. PostgreSQL: Isolamento de transações

Esta sequência ilustrativa mostra a condição de corrida no nível da aplicação:

Etapa Chamada A Chamada B
1 Verifica paper-shapes: inexistente
2 Verifica paper-shapes: inexistente
3 Insere o artigo
4 Tenta inserir o mesmo slug
5 Confirma a transação Tenta confirmar a transação

Sem uma restrição unique no slug, essas inserções podem criar linhas separadas. Uma chave primária em um ID gerado automaticamente não expressa a regra de que os slugs devem ser únicos. O PostgreSQL oferece suporte a restrições unique em uma coluna ou em uma combinação de colunas. PostgreSQL: Restrições

Envolver a verificação e a inserção em BEGIN e COMMIT não reserva o slug inexistente. Adicionar FOR UPDATE a uma consulta sem resultados também não resolve o problema: esse comando bloqueia as linhas recuperadas, então não há linha para bloquear quando o artigo não existe. Isso decorre do comportamento documentado de bloqueio de linhas do PostgreSQL. PostgreSQL: Bloqueio explícito

Defina exatamente o que conta como duplicata

Neste exemplo, um slug exato identifica um artigo. O título é conteúdo; portanto, uma segunda solicitação com o mesmo slug deve receber o artigo armazenado, sem substituir seu título.

Comece com uma instalação local existente do PostgreSQL, as ferramentas de linha de comando correspondentes e Node.js com npm. No Terminal do macOS, crie um banco de dados de demonstração novo e abra o prompt SQL:

createdb article_concurrency_demo
psql -d article_concurrency_demo

Execute este esquema uma vez no novo banco de dados:

CREATE TABLE articles (
    id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    slug text NOT NULL,
    title text NOT NULL,
    CONSTRAINT articles_slug_key UNIQUE (slug)
);

A restrição UNIQUE (slug) separada impõe a regra do artigo. NOT NULL também é importante: por padrão, as restrições unique comuns permitem vários valores nulos. O PostgreSQL cria automaticamente um índice unique para essa restrição. PostgreSQL: Restrições

Se estiver adaptando uma tabela existente, primeiro inspecione as chaves repetidas em vez de excluir linhas automaticamente. Esta consulta de diagnóstico usa o esquema acima e mantém todo o conteúdo intacto:

SELECT slug, count(*) AS row_count
FROM articles
GROUP BY slug
HAVING count(*) > 1;

Decida qual conteúdo armazenado manter antes de adicionar a restrição a uma tabela existente. Defina também a chave pretendida: se dois artigos puderem compartilhar um slug em coleções diferentes, a regra precisará de uma chave composta, como coleção mais slug. Mantenha a consulta e o alvo de conflito alinhados com essa mesma regra.

Escolha o comportamento de conflito necessário

Um conflito é um caso normal desta tarefa. Defina o que ele significa antes de escolher o SQL. O PostgreSQL permite ignorar a inserção conflitante ou atualizar a linha existente; RETURNING retorna as linhas realmente inseridas ou atualizadas. PostgreSQL: INSERT

Resultado necessário Abordagem Resultado retornado
Criar se não existir; caso contrário, ignorar ON CONFLICT (slug) DO NOTHING Com RETURNING, uma inserção ignorada não retorna nenhuma linha
Criar se não existir; caso contrário, substituir o título ON CONFLICT (slug) DO UPDATE Com RETURNING, a linha inserida ou atualizada
Criar se não existir; caso contrário, preservar e recuperar DO NOTHING e, em seguida, uma consulta separada Implementado na próxima seção

Para uma operação somente de inserção, esta instrução SQL completa funciona com o esquema de demonstração:

INSERT INTO articles (slug, title)
VALUES ('paper-shapes', 'Paper Shapes')
ON CONFLICT (slug) DO NOTHING
RETURNING id, slug, title;

Se a substituição do título for o comportamento pretendido, use esta instrução alternativa:

INSERT INTO articles (slug, title)
VALUES ('paper-shapes', 'Paper Shapes — Revised')
ON CONFLICT (slug) DO UPDATE
SET title = EXCLUDED.title
RETURNING id, slug, title;

Aqui, EXCLUDED.title se refere ao título proposto. Um DO UPDATE incondicional oferece o resultado documentado de inserção ou atualização atômica, sujeito a erros independentes e ao tratamento de falhas do nível de isolamento. Uma atualização condicional cuja condição WHERE rejeite a atualização pode não retornar nada. Escolha uma atualização quando for apropriado alterar o artigo armazenado. PostgreSQL: INSERT

Implemente criar ou retornar o existente em Read Committed

Há uma armadilha de visibilidade: DO NOTHING pode ignorar a inserção por causa de uma linha concorrente invisível ao snapshot da instrução. Como consequência, uma consulta alternativa na mesma instrução também pode não encontrá-la. Uma consulta separada em Read Committed recebe um snapshot novo. Essa é a base da implementação abaixo. PostgreSQL: Isolamento de transações

Saia do prompt SQL com \q. Em uma nova pasta de trabalho, instale a dependência do Node.js:

mkdir article-concurrency-demo
cd article-concurrency-demo
npm init -y
npm install pg

Salve o programa completo a seguir como demo.mjs. Ele pressupõe que o banco de dados e a tabela criados acima já existem. O pool usa o banco de dados especificado e as demais configurações padrão de conexão; o node-postgres oferece suporte a variáveis de ambiente de conexão do PostgreSQL quando as configurações locais forem diferentes. node-postgres: Conexão

import pg from 'pg';
import assert from 'node:assert/strict';

const { Pool } = pg;
const pool = new Pool({
  database: 'article_concurrency_demo',
  max: 8
});

async function getOrCreateArticle(slug, title) {
  const client = await pool.connect();
  try {
    await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');

    for (let attempt = 0; attempt < 5; attempt += 1) {
      const inserted = await client.query(`
        INSERT INTO articles (slug, title)
        VALUES ($1, $2)
        ON CONFLICT (slug) DO NOTHING
        RETURNING id, slug, title
      `, [slug, title]);

      if (inserted.rows.length === 1) {
        await client.query('COMMIT');
        return inserted.rows[0];
      }

      const existing = await client.query(`
        SELECT id, slug, title
        FROM articles
        WHERE slug = $1
        FOR UPDATE
      `, [slug]);

      if (existing.rows.length === 1) {
        await client.query('COMMIT');
        return existing.rows[0];
      }
    }

    throw new Error('Article kept disappearing; retry limit reached');
  } catch (error) {
    await client.query('ROLLBACK');
    throw error;
  } finally {
    client.release();
  }
}

try {
  const results = await Promise.allSettled(
    Array.from({ length: 8 }, () =>
      getOrCreateArticle('paper-shapes', 'Paper Shapes'))
  );

  const failures = results.filter(result => result.status === 'rejected');
  if (failures.length) {
    throw new AggregateError(failures.map(result => result.reason));
  }

  const rows = results.map(result => result.value);
  assert.equal(new Set(rows.map(row => row.id)).size, 1);

  const count = await pool.query(
    'SELECT count(*)::integer AS n FROM articles WHERE slug = $1',
    ['paper-shapes']
  );
  assert.equal(count.rows[0].n, 1);
  console.log({ calls: rows.length, matchingRows: count.rows[0].n });
} finally {
  await pool.end();
}

Execute-o nessa pasta:

node demo.mjs

Com a configuração indicada e sem outro processo alterando as linhas de demonstração, o resultado final esperado é:

{ calls: 8, matchingRows: 1 }

A sintaxe JavaScript do programa foi verificada com Node.js. A demonstração com o banco de dados não foi executada aqui porque as ferramentas do PostgreSQL não estavam disponíveis; o resultado acima é esperado.

Cada transação usa um único cliente obtido do pool para todas as instruções. O node-postgres exige isso explicitamente: enviar instruções de transação por meio de pool.query pode distribuí-las entre clientes diferentes. A consulta de verificação usa pool.query somente após o término das transações. node-postgres: Transações

O texto da consulta e os valores são enviados separadamente por meio de $1 e $2, seguindo a interface de consultas parametrizadas do driver. node-postgres: Consultas

O loop trata uma intercalação adicional ilustrativa: outra transação pode remover o artigo conflitante antes que a consulta consiga recuperá-lo. Se a consulta não encontrar nada, a tentativa de inserção é repetida, com cinco tentativas como limite explícito de exemplo. Se o limite for atingido, ocorre um erro em vez de retornar uma linha indefinida.

Depois de recuperada, FOR UPDATE impede que outra transação altere ou exclua a linha até o término desta transação. Ele protege um resultado existente; a restrição unique lida com inserções concorrentes. Após a confirmação da transação, outra transação pode alterar a linha novamente. Se o trabalho dependente no banco de dados precisar usar o artigo bloqueado, faça-o antes de COMMIT. PostgreSQL: Bloqueio explícito

Respeite os limites dos níveis de isolamento

O exemplo seleciona explicitamente Read Committed. Repeatable Read usa um snapshot da transação estabelecido pela primeira instrução substantiva, então outra consulta não pode simplesmente atualizá-lo. Serializable garante resultados confirmados equivalentes aos de uma execução serial, mas pode abortar transações e, nos casos documentados de sobreposição, ainda pode gerar violações de unicidade. MERGE também não oferece a mesma garantia de inserção ou atualização concorrente. PostgreSQL: Isolamento de transações

Para Repeatable Read ou Serializable, prepare-se para repetir a transação completa após o SQLSTATE 40001, incluindo as decisões da aplicação que geraram suas instruções. Repetir somente a última consulta não basta. O PostgreSQL também identifica 40P01 como uma falha de deadlock que pode permitir nova tentativa. PostgreSQL: Tratamento de falhas de serialização

Não trate automaticamente toda violação de unicidade 23505 como transitória. Ela pode representar um conflito persistente. Uma política de novas tentativas deve identificar a condição esperada, limitar o número de tentativas e informar quando o limite for atingido; o PostgreSQL não garante que as novas tentativas terão sucesso. A demonstração trata a colisão esperada de slug por meio de ON CONFLICT, enquanto outros erros são propagados. PostgreSQL: Tratamento de falhas de serialização

Para esta tarefa de criar ou retornar o artigo existente, a divisão é simples: a restrição do banco de dados expressa a identidade do artigo, a ação de conflito define o que fazer com um artigo existente, e a transação controla a recuperação e qualquer trabalho dependente. Mantenha essas escolhas alinhadas para que chamadas concorrentes compartilhem um único artigo sem substituir silenciosamente seu conteúdo.

Fontes