← PHP para aplicações reais
10 / 11 · 50 MIN

Transações, concorrência e recuperação

Define quem confirma o trabalho e decide como tratar conflitos, falhas parciais e resultados desconhecidos.

A transação precisa de um responsável

Uma função de repositório pode ser chamada isoladamente ou dentro de uma operação maior. Se chamar beginTransaction quando a ligação já tem uma transação, não cria automaticamente uma transação aninhada. Define quem inicia, confirma e reverte o trabalho. Uma função que participa na transação do chamador não deve confirmar a operação inteira sem autorização desse contrato. inTransaction ajuda a observar estado, mas não identifica por si só o proprietário. Num catch, tentar rollBack sem transação ativa pode lançar outra exceção e esconder a falha original. Regista separadamente uma eventual falha de limpeza e preserva a causa que originou a recuperação.

Erro numa instrução e falha do lote

No laboratório SQLite, uma violação de unicidade com o comportamento ABORT habitual desfaz a instrução que falhou, não necessariamente as instruções anteriores da transação. Se capturas o erro e confirmas o restante, podes persistir um lote parcial. Começa pela regra do negócio: um lote de três entradas tem de ser aceite integralmente ou pode ter rejeições individuais? Para a primeira regra, o responsável reverte o lote quando uma entrada falha. Para a segunda, define resultados por entrada e mecanismos adequados, incluindo savepoints quando suportados. Não generalizes o comportamento de SQLite a PostgreSQL ou MySQL sem testar o motor, a configuração e a política de erros.

Savepoints não antecipam a confirmação externa

Imagina que a operação insere A, cria um savepoint e tenta inserir B. ROLLBACK TO pode voltar a esse ponto sem eliminar A. Libertar um savepoint interno não confirma a transação externa; um rollback externo posterior ainda pode eliminar ambos os efeitos. Os nomes de savepoints e o seu ciclo de vida pertencem ao desenho da operação, não devem ser interpolados a partir de entrada arbitrária. Mantém estes mecanismos locais curtos e compreensíveis. Uma base em memória por ligação também não serve para testar disputa entre dois workers: duas ligações sqlite::memory: independentes observam bases diferentes. Para ensaiar bloqueio, o fixture usa duas ligações ao mesmo ficheiro temporário.

Concorrência e repetição têm contratos diferentes

Um UPDATE com id e versão esperada pode detetar que a decisão foi calculada sobre estado antigo. Se o primeiro worker incrementa a versão, o segundo pode afetar zero linhas. Reler e reavaliar é diferente de retirar a condição e forçar a escrita. A idempotência resolve outro problema: reconhecer repetições equivalentes do mesmo pedido. Guarda uma chave com o conteúdo normalizado e o resultado na mesma transação do efeito quando esse desenho é aplicável. Reutilizar a chave com conteúdo incompatível deve ter uma resposta definida. Perder a ligação durante commit pode deixar o resultado desconhecido; não significa automaticamente rollback nem autoriza um novo efeito com outra chave.

Laboratório: aceitar o lote inteiro

O exemplo recebe as chaves 1, 2 e 1. A terceira entrada viola a chave primária; o contrato exige que nenhuma entrada do lote fique persistida. A função que começa a transação também decide o rollback. Prevê a saída zero e compara-a com uma variante que captura a exceção dentro do ciclo e confirma o resto: essa variante viola o contrato do exercício. A criação da tabela ocorre antes da transação de dados. Isto evita ensinar que DDL se comporta de forma idêntica em todos os motores. O exercício demonstra rollback local em SQLite e não reproduz falhas de rede, replicação ou confirmação distribuída.

Resumo: diagnosticar antes de repetir

Para investigar um batch falhado, identifica o responsável pela transação, o ponto do erro, o estado durável conhecido e a regra de aceitação. Distingue rejeição permanente, disputa transitória, conflito de versão e resultado de confirmação desconhecido. Cada classe pede uma decisão diferente. Uma repetição limitada pode ajudar numa disputa transitória; não corrige conteúdo inválido e não resolve sozinha uma confirmação ambígua. Recolhe identificadores e estados suficientes para reconciliação sem expor dados de clientes. Liga esta aula à outbox e ao suporte de produção: confirmar dados locais não garante que um sistema externo recebeu uma mensagem. Os testes de integração devem exercer o driver e o motor efetivamente usados pela aplicação.

<?php
declare(strict_types=1);
$db = new PDO('sqlite::memory:', null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
$db->exec('CREATE TABLE entries(id INTEGER PRIMARY KEY)');
$db->beginTransaction();
try {
    $insert = $db->prepare('INSERT INTO entries(id) VALUES(:id)');
    foreach ([1, 2, 1] as $id) {
        $insert->execute(['id' => $id]);
    }
    $db->commit();
} catch (PDOException $failure) {
    if ($db->inTransaction()) {
        $db->rollBack();
    }
    // The laboratory observes the rejected batch; a service must report failure.
}
echo $db->query('SELECT COUNT(*) FROM entries')->fetchColumn();
NA PRÁTICA

Um lote com uma chave repetida é rejeitado integralmente; capturar o erro de SQL não basta para garantir essa política.

Armadilhas comuns

Não confirmar transações do chamador, assumir rollback total após qualquer erro, nem testar concorrência com bases independentes em memória.

Tópicos relacionados: Transações e falhas parciais em PDO · JSON, contratos e falhas explícitas · Streams, CSV e importações controladas

Leva esta ideia contigo

Propriedade, aceitação do lote e estratégia de recuperação devem ser explícitas e testadas no motor utilizado.

Criar conta

Referência: SQLite: lang_transaction · PHP 8.5 reference; DR PHP 2026.3; new fixtures executed on PHP 8.4.4 / SQLite 3.51.2