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:
| Invariante | Ferramenta |
|---|---|
| Alterar uma linha se a condição ainda for verdadeira | um UPDATE ... WHERE <condição> + verificação do número de linhas |
| Ler, decidir no código, gravar a mesma linha | SELECT ... FOR NO KEY UPDATE e gravação na mesma transação |
| Sem duplicata | índice único + INSERT ... ON CONFLICT |
| Intervalos não se sobrepõem | EXCLUDE USING gist (btree_gist) |
| Regra sobre um conjunto de linhas que não pode ser expressa como restrição | serializable para todos os participantes + retry |
| Fila de tarefas | FOR UPDATE SKIP LOCKED |
| Um único manipulador para todos | pg_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.
UPDATEem uma linha ocupada espera a transação alheia, depois relê a linha e verifica novamente oWHERE. - 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.40001pode ocorrer até noCOMMIT. - O snapshot do repeatable read é construído pelo primeiro comando após
BEGIN, não pelo próprioBEGIN. 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 tstzrangeTraduza
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 LOCKEDpula linhas ocupadas, e os workers não esperam uns pelos outros. O par "SELECT ... FOR UPDATE SKIP LOCKED, depoisUPDATE" 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 BYna 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 emrunningpor mais tempo que o prazo são consideradas prontas novamente. SKIP LOCKEDfornece 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_timeoutdentro 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çaSELECT ... FOR UPDATE NOWAITnas mesmas linhas, dentro da mesma transação. NOWAITeSKIP LOCKEDafetam apenas linhas. Um bloqueio de tabela (por exemplo,ALTER TABLE) ainda será aguardado normalmente. Portanto,lock_timeouttambé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 compool.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 usehashtext: 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ódigo | O quê | O que fazer |
|---|---|---|
40001 serialization_failure | conflito em repeatable read / serializable | repetir toda a transação |
40P01 deadlock_detected | deadlock (bloqueio mútuo) | repetir toda a transação; adquirir bloqueios sempre na mesma ordem |
55P03 lock_not_available | NOWAIT, lock_timeout | repetir toda a transação ou retornar "ocupado" |
23505 unique_violation | duplicata | conflito de domínio; duplicata esperada — usar ON CONFLICT |
23P01 exclusion_violation | sobreposição por restrição de exclusão | conflito de domínio |