Em sistemas que lidam com alta concorrência, como aplicações de comércio eletrônico ou plataformas de serviços financeiros, garantir a integridade dos dados é crucial. Imagine um cenário de compra em grupo para um produto com estoque limitado. Se um grupo é formado para duas pessoas e múltiplos clientes (B e C) tentam finalizar o pagamento simultaneamente, como evitar que ambos consigam "reservar" a última vaga, excedendo o limite do grupo?
O Laravel, através de seu ORM Eloquent, oferece mecanismos para implementar bloqueio pessimista no nível do banco de dados, ajudando a gerenciar esses cenários de concorrência. As duas principais funções para isso são sharedLock() e lockForUpdate().
Bloqueio Pessimista: sharedLock vs. lockForUpdate
Ambas as funções permietm aplicar bloqueios em linhas específicas do banco de dados, mas com comportamentos distintos:
sharedLock(): Traduz-se para a cláusula SQLLOCK IN SHARE MODE.lockForUpdate(): Traduz-se para a cláusula SQLFOR UPDATE.
A similaridade entre sharedLock e lockForUpdate é que ambos são utilizados dentro de transações para evitar que outras transações modifiquem (atualizem ou excluam) os dados bloqueados. A diferença crucial reside no comportamento de leitura:
sharedLock()(LOCK IN SHARE MODE): Permite que outras transações leiam as linhas bloqueadas (inclusive com outrosLOCK IN SHARE MODE), mas as impede de realizar qualquer alteração (UPDATEouDELETE) até que a transação detentora da trava seja concluída.lockForUpdate()(FOR UPDATE): É mais restritivo. Além de bloquear as modificações (UPDATEouDELETE), ele também impede que outras transações leiam as linhas bloqueadas usandoSELECT ... FOR UPDATEouSELECT ... LOCK IN SHARE MODE. Leituras "normais" (sem cláusulas de bloqueio explícitas) podem ver uma versão anterior ou a mais recente dos dados, dependendo do nível de isolamento da transação e do banco de dados. Este modo de bloqueio é ideal para situações onde se precisa ler um valor, processá-lo e então atualizá-lo, garantindo que nenhum outro processo o leia ou modifique nesse ínterim.
Em outras palavras, sharedLock bloqueia contra escritas, mas permite leituras concorrentes (com ou sem LOCK IN SHARE MODE), enquanto lockForUpdate bloqueia contra escritas e também contra leituras que tentem adquirir qualquer tipo de trava pessimista nas mesmas linhas.
Essa distinção é vital. Por exemplo, ao atualizar um contador, se duas transações lerem o mesmo valor inicial simultaneamente, incrementarem-no em sua lógica de aplicação e então tentarem persistir, a última a gravar sobrescreverá a primeira, resultando em perda de dados. lockForUpdate impede que isso aconteça, forçando uma serialização das operações de leitura-modificação-escrita.
Demonstração Prática no MySQL
Para entender o comportamento dos bloqueios, podemos simular em dois terminais do MySQL:
Terminal 1:
mysql> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)
mysql> SELECT * FROM produtos WHERE id = 5 FOR UPDATE;
+----+----------+----------+
| id | nome | estoque |
+----+----------+----------+
| 5 | Teclado | 10|
+----+----------+----------+
1 row in set (0.01 sec)
Neste ponto, a linha com id = 5 da tabela produtos está bloqueada para atualização.
Terminal 2:
mysql> START TRANSACTION;
Query OK, 0 rows affected (0.00 sec)
mysql> SELECT * FROM produtos WHERE id = 5 FOR UPDATE;
-- Este comando ficará aguardando...
O comando no Terminal 2 ficará bloqueado, aguardando que a transação no Terminal 1 seja concluída. Se tentarmos SELECT ... LOCK IN SHARE MODE no Terminal 2, o resultado seria o mesmo: bloqueio.
Terminal 2 (tentando sharedLock, ainda bloqueado pelo FOR UPDATE):
mysql> SELECT * FROM produtos WHERE id = 5 LOCK IN SHARE MODE;
-- Este comando também ficará aguardando...
Somente quando a transação no Terminal 1 for finalizada (com COMMIT; ou ROLLBACK;), a transação no Terminal 2 poderá prosseguir:
Terminal 1 (continuando):
mysql> COMMIT;
Query OK, 0 rows affected (0.00 sec)
Imediatamente após o COMMIT no Terminal 1, o Terminal 2 exibirá:
Terminal 2 (após commit do Terminal 1):
mysql> SELECT * FROM produtos WHERE id = 5 FOR UPDATE;
+----+----------+----------+
| id | nome | estoque |
+----+----------+----------+
| 5 | Teclado | 10|
+----+----------+----------+
1 row in set (15.23 sec) -- O tempo de espera será exibido
É crucial notar que os bloqueios pessimistas são eficazes apenas dentro de uma transação. Se as operações de bloqueio não estiverem aninhadas em uma transação, o bloqueio não terá efeito duradouro.
Testando Bloqueios Pessimistas no Laravel
Podemos replicar o cenário acima usando o Artisan Tinker do Laravel. Abra dois terminais e inicie o php artisan tinker em ambos.
Terminal Tinker 1:
DB::transaction(function () {
echo "Iniciando transação no Tinker 1...\n";
$produto = App\Models\Produto::where('id', 1)->lockForUpdate()->first();
if ($produto) {
echo "Produto {$produto->id} bloqueado no Tinker 1.\n";
// Simula uma operação demorada
sleep(10);
// Atualiza o estoque ou outro campo
$produto->estoque -= 1;
$produto->save();
echo "Produto {$produto->id} atualizado e transação aguardando commit.\n";
}
// A transação será comitada automaticamente no final da função
});
Terminal Tinker 2 (execute logo após o primeiro):
DB::transaction(function () {
echo "Iniciando transação no Tinker 2...\n";
$produto = App\Models\Produto::where('id', 1)->lockForUpdate()->first();
if ($produto) {
echo "Produto {$produto->id} bloqueado no Tinker 2.\n";
$produto->estoque -= 1;
$produto->save();
echo "Produto {$produto->id} atualizado e transação aguardando commit.\n";
}
});
Você observará que a execução no Terminal Tinker 2 ficará pausada na linha $produto = ...->lockForUpdate()->first(); até que a transação no Terminal Tinker 1 seja concluída (após o sleep(10) e o commit). Somente então a transação do Tinker 2 poderá prosseguir e adquirir a trava.
Lembre-se: o uso de lockForUpdate() fora de uma transação ativa não terá o efeito de bloqueio desejado, pois o bloqueio é imediatamente liberado.
Exemplo de Cenário Real: Processamento de Saques
Um caso de uso comum para lockForUpdate() é impedir saques duplicados ou processamento simultâneo de uma mesma solicitação.
use App\Models\SolicitacaoSaque;
use Illuminate\Support\Facades\DB;
// Supondo que $userId é o ID do usuário e estamos procurando uma solicitação pendente
DB::transaction(function () use ($userId) {
// Bloqueia a linha da solicitação de saque para evitar processamento simultâneo
$solicitacao = SolicitacaoSaque::where([
['usuario_id', $userId],
['status_saque', 'pendente'], // Status inicial
])
->lockForUpdate()
->first();
if ($solicitacao) {
// Altera o status para 'em processamento'
$solicitacao->status_saque = 'em_processamento';
$solicitacao->save();
// Lógica de processamento do saque, como interação com API de pagamento
// ...
// Se o processamento for bem-sucedido:
// $solicitacao->status_saque = 'concluido';
// $solicitacao->save();
// Ou em caso de falha:
// DB::rollBack(); // Ou ajustar status para 'falha'
} else {
// Nenhuma solicitação pendente encontrada ou já em processamento
// Pode-se lançar uma exceção ou retornar um erro
throw new \Exception("Nenhuma solicitação de saque pendente encontrada ou já em processamento.");
}
});
Neste exemplo, se duas tentativas de processar o mesmo saque pendente ocorrerem ao mesmo tempo, apenas uma conseguirá adquirir o bloqueio e prosseguir. A outra ficará esperando ou receberá uma exceção, dependendo da configuração de timeout do banco de dados ou da lógica implementada para lidar com o bloqueio.
Transações e Níveis de Isolamento
A eficácia dos bloqueios pessimistas está intrinsecamente ligada ao conceito de transações e aos níveis de isolamento transacional do banco de dados. Um nível de isolamento define como as operações de uma transação são protegidas de outras transações simultâneas.
Os quatro principais níveis de isolamento são:
- Read Uncommitted (Leitura Não Confirmada): Permite ler dados não confirmados por outras transações ("dirty reads"). Geralmente não usa bloqueio explícito.
- Read Committed (Leitura Confirmada): Permite ler apenas dados confirmados. Impede "dirty reads".
- Repeatable Read (Leitura Repetível): Garante que, dentro de uma transação, as leituras repetidas dos mesmos dados retornem os mesmos valores. Previne "dirty reads" e "non-repeatable reads". Este é o nível de isolamento padrão no MySQL.
- Serializable (Serializável): É o nível mais rigoroso, garantindo que as transações se executem como se fossem em série. Isso é feito com bloqueios estritos, prevenindo "dirty reads", "non-repeatable reads" e "phantom reads".
Quando você usa lockForUpdate ou sharedLock, você está explicitamente solicitando um bloqueio pessimista. Mesmo em níveis de isolamento mais flexíveis, essas cláusulas impõem um bloqueio forte para garantir a consistência dos dados nas linhas específicas que você selecionou para a operação crítica. No MySQL, com seu padrão Repeatable Read, os bloqueios explícitos como FOR UPDATE e LOCK IN SHARE MODE se integram a esse comportamento para fornecer garantias ainda mais fortes para as linhas selecionadas.