CommunityEscrita e Ediçãogithub.com

PostgreSQL: Concorrência e Gravação

Estratégias de concorrência em PostgreSQL sem atualizações perdidas ou write skew: condição no UPDATE, SELECT FOR NO KEY UPDATE vs FOR UPDATE, restrições de exclusão, isolamento serializable com retry, filas de tarefas com SKIP LOCKED, lock_timeout e NOWAIT, locks consultivos e análise de deadlocks. Exemplos em Go (pgx e go-transaction-manager), regras focadas no PostgreSQL.

O que é PostgreSQL: Concorrência e Gravação?

Este skill aborda de forma abrangente o controle de concorrência em PostgreSQL, fornecendo estratégias para evitar atualizações perdidas, write skew e outros problemas comuns de acesso concorrente a dados. Ele descreve em detalhes técnicas como usar a cláusula `UPDATE ... WHERE` para garantir que uma condição ainda seja verdadeira, o uso de `SELECT FOR NO KEY UPDATE` para proteger leituras que precedem uma escrita na mesma linha, e restrições de exclusão (`EXCLUDE USING gist`) para evitar sobreposições de intervalos sem depender de transações serializáveis. O skill também cobre filas de tarefas com `SKIP LOCKED`, timeout de lock com `lock_timeout` e a instrução `NOWAIT`, locks consultivos (advisory locks) para sincronização entre processos, e a análise de deadlocks e bloqueios. As recomendações são práticas, incluem exemplos de código em Go usando as bibliotecas pgx e go-transaction-manager, e enfatizam que restrições no banco de dados são mais confiáveis do que lógicas no código da aplicação para manter invariantes. O público-alvo são desenvolvedores que trabalham com PostgreSQL e Go, que precisam implementar sistemas concorrentes de alta integridade.

Funciona com✓Claude Code~Codex CLI~Cursor
npx skills add https://github.com/scary-production/claude-plugins/tree/HEAD/plugins/go-postgres/skills/postgres-concurrency

Installed? Explore more Escrita e Edição skills: steipete/notion, langchain-ai/langchain, bytedance/podcast-generation · View all 6 →

Perguntar na sua IA favorita

Abre um novo chat com esta habilidade de agente já pré-carregada.

Documentação

Gravação Concorrente em PostgreSQL

Principal. Não faça "leu no código — verificou — gravou": no read committed isso perde alterações de outros. Escolha a ferramenta conforme o tipo de invariante:

InvarianteFerramenta
Alterar uma linha se a condição ainda for verdadeiraum UPDATE ... WHERE <condição> + verificação do número de linhas
Ler, decidir no código, gravar a mesma linhaSELECT ... FOR NO KEY UPDATE e gravação na mesma transação
Sem duplicataíndice único + INSERT ... ON CONFLICT
Intervalos não se sobrepõemEXCLUDE USING gist (btree_gist)
Regra sobre um conjunto de linhas que não pode ser expressa como restriçãoserializable para todos os participantes + retry
Fila de tarefasFOR UPDATE SKIP LOCKED
Um único manipulador para todospg_advisory_xact_lock

Uma restrição no banco de dados é mais confiável que código: ela mantém o invariante em qualquer nível de isolamento e com qualquer cliente. O código Go de transações e retry está no skill pgx-txmanager. A verificação de asserções está em CHECKS.md.

Níveis de Isolamento

  • Read committed (padrão): cada comando vê um snapshot recente. UPDATE em uma linha ocupada espera a transação alheia, depois relê a linha e verifica novamente o WHERE.
  • Repeatable read e serializable não releem: em caso de conflito, erro 40001 ("could not serialize access"). Serializable gera esse erro com mais frequência. Ao elevar o nível, implemente imediatamente o retry de toda a transação; caso contrário, o usuário receberá o erro. 40001 pode ocorrer até no COMMIT.
  • O snapshot do repeatable read é construído pelo primeiro comando após BEGIN, não pelo próprio BEGIN. O momento é importante — faça a leitura como primeira consulta.
  • O Serializable garante o resultado apenas entre transações serializable. Se você protege um invariante com ele, todas as transações que o tocam devem usar serializable.
  • Dentro de serializable, examine o EXPLAIN. Seq Scan aplica bloqueio predicativo na tabela inteira e aumenta a ocorrência de 40001; leitura por índice bloqueia apenas as linhas lidas e páginas do índice. Crie os índices necessários. Em tabelas pequenas, Seq Scan é aceitável.

Alterar se a Condição For Verdadeira: Condição no UPDATE

Passe para o banco a ação, não um valor pronto: SET sent = sent + 1, SET left = left - $1 WHERE left >= $1, WHERE id = $1 AND status = 'free'. Zero linhas afetadas (RowsAffected() == 0 ou pgx.ErrNoRows com RETURNING) significa que a condição não foi satisfeita ou a linha não existe. Isso é um erro de domínio, não sucesso.

tag, err := q.Exec(ctx,
	`UPDATE seats SET status = 'booked', customer_id = $2
	 WHERE id = $1 AND status = 'free'`,
	seatID, customerID,
)
if err != nil {
	return fmt.Errorf("book seat %d: %w", seatID, err)
}
if tag.RowsAffected() == 0 { // lugar já ocupado ou inexistente
	return fmt.Errorf("book seat %d: %w", seatID, ErrConflict)
}

Em repeatable read e serializable, o mesmo conflito resultará em 40001 e exigirá retry.

Ler e Gravar a Mesma Linha

SELECT ... FOR NO KEY UPDATE (modo na seção "Modo de Bloqueio de Linha") e gravação na mesma transação (trManager.Do). Em autocommit, o bloqueio é liberado imediatamente após o SELECT. Uma linha que ainda não existe não será protegida pelo bloqueio: a inserção de uma duplicata será impedida apenas por um índice único ou restrição.

Decisão Baseada em um Conjunto de Linhas (Write Skew)

"Funcionário está livre", "reserva cabe no intervalo", "ainda há um plantonista no turno": duas transações leem o mesmo snapshot, cada uma grava sua linha, e ambas cometem. No read committed, isso resulta em duas reservas sobrepostas. Repeatable read não resolve: ele detecta apenas gravação na mesma linha.

  • Decisão sobre linhas existentes — bloqueie (FOR NO KEY UPDATE / FOR UPDATE) exatamente as linhas que você está avaliando.
  • Decisão sobre ausência de linha ("não há reserva sobreposta") — não há o que bloquear. É necessária uma restrição:
    • duplicata por valor — índice único;

    • sobreposição de intervalos — restrição de exclusão (exclusion constraint). No read committed, a segunda transação aguardará a primeira e receberá 23P01, sem necessidade de serializable nem retries:

      CREATE EXTENSION IF NOT EXISTS btree_gist; -- trusted: requer privilégio CREATE no banco
      ALTER TABLE bookings ADD CONSTRAINT bookings_no_overlap
          EXCLUDE USING gist (resource_id WITH =, during WITH &&); -- during tstzrange
      

      Traduza 23P01 (exclusion_violation) para um erro de domínio de conflito.

  • Regra não expressável como restrição — serializable para todos os participantes e retry em 40001.

Modo de Bloqueio de Linha

Se você bloqueia uma linha para ler e atualizar, e não altera colunas com índice único usável para chave estrangeira (PK, chaves únicas; índices parciais e índices por expressão não contam), use FOR NO KEY UPDATE. Reserve FOR UPDATE para DELETE e alterações dessas colunas. Por quê: a verificação de chave estrangeira ao inserir uma linha filha aplica FOR KEY SHARE na linha pai. Ele conflita com FOR UPDATE e não conflita com FOR NO KEY UPDATE. Um FOR UPDATE desnecessário em uma linha pai (cliente, recurso, pedido) impede a inserção de linhas que a referenciam.

Fila de Tarefas

Vários workers buscam uma tarefa com um único comando:

UPDATE tasks
SET status = 'running', taken_at = now()
WHERE id = (
	SELECT id FROM tasks
	WHERE status = 'ready'
	ORDER BY id
	LIMIT 1
	FOR UPDATE SKIP LOCKED
)
RETURNING id
  • SKIP LOCKED pula linhas ocupadas, e os workers não esperam uns pelos outros. O par "SELECT ... FOR UPDATE SKIP LOCKED, depois UPDATE" em uma transação também é correto. SELECT sem bloqueio e UPDATE separado não são permitidos: dois workers pegariam a mesma tarefa.
  • ORDER BY na subconsulta — para ordem previsível.
  • O worker pode falhar com a tarefa em mãos. Prática: prazo de aluguel baseado em taken_at — tarefas em running por mais tempo que o prazo são consideradas prontas novamente.
  • SKIP LOCKED fornece uma visão inconsistente dos dados — use apenas para filas.

Não Ficar Preso em Bloqueio Alheio

Por padrão, uma consulta espera por um bloqueio indefinidamente, e o usuário vê o aplicativo travado.

  • SET LOCAL lock_timeout dentro da transação, não como parâmetro de sessão: a conexão do pool será usada por outras consultas depois. O timeout se aplica a cada espera por bloqueio, não à transação inteira.
  • UPDATE e DELETE não têm NOWAIT. Se for necessário um erro imediato, primeiro faça SELECT ... FOR UPDATE NOWAIT nas mesmas linhas, dentro da mesma transação.
  • NOWAIT e SKIP LOCKED afetam apenas linhas. Um bloqueio de tabela (por exemplo, ALTER TABLE) ainda será aguardado normalmente. Portanto, lock_timeout também é necessário com eles.
  • O erro nesses casos é 55P03 (lock_not_available). Repita toda a transação.
return s.trManager.Do(ctx, func(ctx context.Context) error {
	q := s.getter.DefaultTrOrDB(ctx, s.pool)
	// constante sem entrada; ajuste o valor conforme a operação
	if _, err := q.Exec(ctx, `SET LOCAL lock_timeout = '1s'`); err != nil {
		return err
	}
	return s.repo.MoveBooking(ctx, from, to)
})

A fila de espera não é justa. Novos FOR SHARE / FOR KEY SHARE podem ultrapassar um UPDATE que está esperando em uma linha quente, e um fluxo desses bloqueios pode mantê-lo esperando indefinidamente; a ordem entre os que esperam não é garantida. Para linhas quentes (contador compartilhado, saldo de produto popular), defina lock_timeout e repita a transação.

Análise de Travamento. Bloqueios de linha não são visíveis em pg_locks: o processo em espera aparece lá como aguardando o transactionid do detentor. Quem está bloqueando é revelado por pg_blocking_pids(pid) (não chame com frequência). Bloqueios de linha de uma tabela são mostrados pela extensão pgrowlocks do contrib (instalada por superusuário).

Locks Consultivos (Advisory Locks)

"Apenas um manipulador" (worker único de outbox, migrador) — pg_advisory_xact_lock dentro da transação: o lock é liberado automaticamente no commit ou rollback.

const advisoryOutboxRelay int64 = 1 // chave fixa do subsistema

err := s.trManager.Do(ctx, func(ctx context.Context) error {
	q := s.getter.DefaultTrOrDB(ctx, s.pool)
	if _, err := q.Exec(ctx, `SELECT pg_advisory_xact_lock($1)`, advisoryOutboxRelay); err != nil {
		return err
	}
	return s.relayBatch(ctx) // um lote; o lock será liberado junto com a transação
})
  • Lock de sessão pg_advisory_lock — apenas em conexão dedicada obtida com pool.Acquire; libere na mesma conexão tantas vezes quanto adquiriu. O lock de sessão não é liberado por COMMIT nem rollback, e a mesma sessão pode adquiri-lo novamente. No pool, a conexão retornará com o lock, um unlock pode cair em outra conexão, e o lock "vazará".
  • Não mantenha uma transação aberta por causa de um lock em um worker infinito: use xact-lock para cada lote de trabalho. Para um líder de longa duração, use lock de sessão em uma conexão dedicada.
  • A chave é uma constante numérica fixa por subsistema ou um par (int, int). Não use hashtext: a função não é documentada e o hash de 32 bits pode causar colisões.

Funções em Consultas

Uma função em plpgsql ou SQL que lê tabelas e é chamada em uma consulta próxima à leitura de tabelas deve ser marcada como STABLE. VOLATILE obtém um novo snapshot para cada consulta interna; no read committed, os dados da tabela e da função podem vir de momentos diferentes. IMMUTABLE é apenas para funções que não leem tabelas; VOLATILE para funções com efeitos colaterais.

Códigos de Erro

CódigoO quêO que fazer
40001 serialization_failureconflito em repeatable read / serializablerepetir toda a transação
40P01 deadlock_detecteddeadlock (bloqueio mútuo)repetir toda a transação; adquirir bloqueios sempre na mesma ordem
55P03 lock_not_availableNOWAIT, lock_timeoutrepetir toda a transação ou retornar "ocupado"
23505 unique_violationduplicataconflito de domínio; duplicata esperada — usar ON CONFLICT
23P01 exclusion_violationsobreposição por restrição de exclusãoconflito de domínio

Individual skills in this repo

This repo contains 4 individual skills — each has its own dedicated page.

scary-production/claude-plugins

Работа с PostgreSQL в Go через pgxpool (pgx v5) и avito-tech/go-transaction-manager: пул и устойчивый старт, структуры с тегами db и CollectRows, репозиторий через DefaultTrOrDB, транзакции через Do, propagation и savepoint, повтор транзакции при 40001/40P01/55P03, защита от SQL-инъекций, классификация ошибок и ON CONFLICT, batch и CopyFrom. Конкурентная запись и блокировки — скилл postgres-concurrency. PostgreSQL in Go via pgxpool and go-transaction-manager: pool, repositories, transactions, retries, errors, bulk loads.

scary-production/claude-plugins

SQL-миграции PostgreSQL: парные up/down-файлы, современные типы и ограничения (identity, text с CHECK, timestamptz, jsonb), откат каждой операции, добавление значения в enum, индексы на живых таблицах через CREATE INDEX CONCURRENTLY без блокировки записи, что требовать от мигратора, разбор «индекс создан, а план — Seq Scan». PostgreSQL migrations: paired up/down files, modern types, reversible changes, non-blocking indexes, migrator requirements.

scary-production/claude-plugins

Как найти, что тормозит в PostgreSQL, и доказать, что правка помогла. Профиль по pg_stat_statements, сравнение вариантов по числу страниц (BUFFERS), а не по секундам, разбор плана от места, где оценка строк впервые разошлась с фактом. N+1 из Go-цикла сворачиваем в один запрос. Правим условия, FK, статистику и память, а не боремся с планировщиком. Postgres query tuning: profile first, compare by buffers, fix row estimates, remove N+1.

scary-production/claude-plugins

Показывает стили строки состояния на примере или включает один из них (full, compact, bars, minimal) в настройках пользователя или проекта.

Habilidades Relacionadas