Si alguna vez te ha aparecido en los logs el error 1213 Deadlock found when trying to get lock; try restarting transaction, es que 2 transacciones de MySQL se han quedado esperando la una a la otra. MySQL lo detecta al momento, deshace una de las 2 y deja seguir a la otra.
En esta entrada vamos a provocar un deadlock, reintentar la transacción con el parámetro attempts de DB::transaction() y medir qué evita más fallos, reintentar o bloquear siempre en el mismo orden. Además, veremos un caso que antes de Laravel 13.34 acababa peor que un deadlock: un Cache::lock() dentro de una transacción que se tragaba el error y dejaba los datos a medias. Todo está probado, en el momento de crear esta entrada, con Laravel 13.36, PHP 8.5 y MySQL 8.4 en Laravel Sail.
Provocar un deadlock con 2 transferencias
Vamos a usar un modelo Account, cuya tabla accounts guarda el nombre (name) y el saldo (balance). Créalo con su migración con php artisan make:model Account -m, abre la migración que se acaba de crear y cambia sus métodos por los siguientes:
public function up(): void
{
Schema::create('accounts', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->integer('balance');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('accounts');
}
Partimos de 5 cuentas con 1.000 cada una y de un comando que transfiere 100 de una cuenta a otra. Crea el comando con php artisan make:command TransferMoney y cambia el contenido de app/Console/Commands/TransferMoney.php por el siguiente:
<?php
namespace App\Console\Commands;
use App\Models\Account;
use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
#[Signature('accounts:transfer {from} {to} {--pause=2}')]
#[Description('Transfiere 100 de una cuenta a otra dentro de una transacción')]
class TransferMoney extends Command
{
public function handle(): void
{
$attempt = 0;
DB::transaction(function () use (&$attempt) {
$attempt++;
$from = Account::lockForUpdate()->findOrFail($this->argument('from'));
$this->line("Intento {$attempt}: cuenta {$from->id} bloqueada");
sleep((int) $this->option('pause'));
$to = Account::lockForUpdate()->findOrFail($this->argument('to'));
$from->decrement('balance', 100);
$to->increment('balance', 100);
});
$this->info("Transferencia completada en el intento {$attempt}");
}
}
Si te fijas, la transacción bloquea primero la cuenta de origen con lockForUpdate(), que añade FOR UPDATE a la consulta, y después la de destino. Entre los 2 bloqueos hay una pausa de 2 s para provocar el deadlock siempre; en una aplicación real sería cualquier trabajo entre las 2 consultas. La variable $attempt cuenta las veces que se ejecuta la transacción.
Abre 2 terminales. En la primera, lanza una transferencia de la cuenta 1 a la 2 y, antes de que pasen 2 s, lanza en la segunda la transferencia contraria:
php artisan accounts:transfer 1 2
php artisan accounts:transfer 2 1
La primera termina bien y la segunda falla con este error:
Intento 1: cuenta 2 bloqueada
Illuminate\Database\QueryException
SQLSTATE[40001]: Serialization failure: 1213 Deadlock found when trying to get lock; try restarting transaction (Connection: mysql, Host: mysql, Port: 3306, Database: laravel, SQL: select * from `accounts` where `accounts`.`id` = 1 limit 1 for update)
Qué hace MySQL con la transacción
La primera transacción tiene bloqueada la cuenta 1 y espera la 2. La segunda tiene la 2 y espera la 1. Ninguna puede avanzar, así que, en cuanto la segunda pide la cuenta 1, MySQL detecta el ciclo y elige una víctima: según su documentación, la transacción que menos filas ha insertado, modificado o borrado. Aquí están empatadas y en nuestras pruebas ha caído siempre la segunda. MySQL la deshace entera, no solo la consulta que ha fallado, y la otra sigue. Por eso los saldos quedan en 900 y 1.100: solo se ha aplicado la primera transferencia.
Para saber qué consultas han chocado, MySQL guarda el último deadlock, y lo podemos ver con SHOW ENGINE INNODB STATUS. Este comando necesita un usuario con el privilegio PROCESS, como root (el usuario sail no lo tiene). Esta es la parte que nos interesa:
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 2600, ACTIVE 2 sec starting index read
select * from `accounts` where `accounts`.`id` = '2' limit 1 for update
*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 41 page no 4 n bits 72 index PRIMARY of table `laravel`.`accounts` trx id 2600 lock_mode X locks rec but not gap
3: len 8; hex 4375656e74612031; asc Cuenta 1;;
*** (2) TRANSACTION:
TRANSACTION 2601, ACTIVE 2 sec starting index read
select * from `accounts` where `accounts`.`id` = '1' limit 1 for update
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 41 page no 4 n bits 72 index PRIMARY of table `laravel`.`accounts` trx id 2601 lock_mode X locks rec but not gap
3: len 8; hex 4375656e74612032; asc Cuenta 2;;
*** WE ROLL BACK TRANSACTION (2)
Cada transacción aparece con la consulta que esperaba y la fila que tenía bloqueada, en la que se ve el nombre de la cuenta. La última línea dice cuál ha deshecho. Si los deadlocks se repiten en producción, al activar la variable innodb_print_all_deadlocks MySQL los escribe todos en su log de errores.
Reintentar la transacción con attempts
Como MySQL ha deshecho la transacción entera, se puede repetir desde el principio sin riesgo. Es lo que recomienda la documentación de MySQL y lo que hace Laravel con el segundo parámetro de DB::transaction(). Abre el comando y cambia el cierre de la transacción por el siguiente:
}, attempts: 3);
Con las mismas 2 terminales, ahora la segunda termina así:
Intento 1: cuenta 2 bloqueada
Intento 2: cuenta 2 bloqueada
Transferencia completada en el intento 2
Laravel ha recibido el deadlock, ha deshecho la transacción y ha vuelto a ejecutar el closure, así que las 2 transferencias se aplican. En el código de DB::transaction() hay 4 detalles que conviene conocer:
attemptses el número total de intentos, contando el primero. Con el valor por defecto, 1, no se repite nada.- Solo se repite si el error es de concurrencia: un deadlock, un error con el código
40001o el 1205 de espera de bloqueo agotada, que vimos en la entrada sobre añadir un índice a una tabla grande. Y se repite al momento, sin pausa. - Se repite el closure entero. Si dentro envías un correo o llamas a una API, se hará en cada intento.
- Solo se reintenta la transacción de fuera. Si la nuestra va dentro de otra, Laravel lanza una
DeadlockExceptionsin repetir nada, porque MySQL ha deshecho también la de fuera. Lo hemos comprobado lanzando el comando dentro de otroDB::transaction().
Cuántos fallos evita reintentar
Con tráfico real, cuántos deadlocks salen depende de la concurrencia, así que lo hemos medido: 4 procesos a la vez hacen 500 transferencias cada uno, 2.000 en total, entre las 5 cuentas al azar y sin pausas. Esto es lo que sale:
- Sin reintentos, MySQL detecta unos 100 deadlocks y fallan otras tantas transferencias, un 5 %.
- Con
attempts: 2, MySQL detecta entre 100 y 125 deadlocks, pero solo fallan unas 7 transferencias. - Con
attempts: 3, fallan 0 o 1, y unas 8 transferencias necesitan el tercer intento.
Lo más interesante es que los reintentos no quitan ningún deadlock: salen los mismos o algunos más, porque una transacción repetida puede volver a chocar. Lo que bajan son los fallos, pero sin garantía de llegar a 0. Y el saldo total de las 5 cuentas sigue en 5.000 en todas las ejecuciones, también sin reintentos, porque MySQL deshace cada víctima entera.
Bloquear siempre en el mismo orden
El deadlock existe porque una transferencia bloquea la cuenta 1 y después la 2, y la otra al revés. Si las 2 bloquean en el mismo orden, la segunda espera a que termine la primera y no se forma el ciclo. En el comando, cambia los 2 lockForUpdate() por una sola consulta que bloquea las 2 cuentas ordenadas por id:
$accounts = Account::whereKey([$this->argument('from'), $this->argument('to')])
->orderBy('id')
->lockForUpdate()
->get()
->keyBy('id');
$this->line("Intento {$attempt}: cuentas {$accounts->keys()->implode(' y ')} bloqueadas");
sleep((int) $this->option('pause'));
$accounts[$this->argument('from')]->decrement('balance', 100);
$accounts[$this->argument('to')]->increment('balance', 100);
La consulta que lanza es esta:
select * from `accounts` where `accounts`.`id` in (2, 1) order by `id` asc for update
MySQL recorre la clave primaria en orden y bloquea las filas según las lee, así que las 2 transferencias empiezan por la cuenta 1. Con las 2 terminales, la segunda espera un segundo y medio y hace su transferencia en el primer intento. Con los 4 procesos, MySQL no detecta ni un deadlock en las 3 ejecuciones.
Lo mismo vale para cualquier transacción que bloquee varias filas, aunque sean de tablas distintas: si todas las bloquean en el mismo orden, esperan en cola en lugar de chocar. De todas formas, deja attempts para los casos que no controlas.
Cache::lock() dentro de una transacción
Un proyecto nuevo de Laravel 13 trae CACHE_STORE=database en el .env, así que Cache::lock() guarda sus locks en la tabla cache_locks con la misma conexión que el resto de la aplicación. Dentro de una transacción, el INSERT del lock forma parte de ella y puede acabar en un deadlock. Para verlo, vamos a ejecutar este código en 2 procesos de php artisan tinker: primero el proceso 2 y, medio segundo después, el 1:
// Proceso 1
DB::transaction(function () {
Cache::lock('receipts', 10)->block(5, function () {
sleep(1);
Account::lockForUpdate()->find(1);
});
});
// Proceso 2
DB::transaction(function () {
Account::lockForUpdate()->find(1)->decrement('balance', 100);
sleep(2);
Cache::lock('receipts', 10)->block(5, function () {
Account::find(2)->increment('balance', 100);
});
}, attempts: 3);
El proceso 2 bloquea la cuenta 1 y le resta 100. El proceso 1 reserva el lock receipts y se queda esperando la cuenta 1. Cuando el 2 pide el lock, se forma el ciclo y MySQL deshace la transacción del proceso 2, con la resta incluida.
Hasta Laravel 13.33, DatabaseLock capturaba cualquier error del INSERT del lock, también el deadlock, como si el lock lo tuviera otro proceso. Entonces block() volvía a intentarlo y lo conseguía cuando el proceso 1 terminaba. El closure seguía, pero ya sin transacción, así que la suma se guardaba sola. Con Laravel 13.33, el proceso 2 termina así en las 3 ejecuciones:
PDOException There is no active transaction.
Los saldos quedan en 1.000 y 1.100. La resta se ha perdido con el deadlock y la suma se ha guardado: han aparecido 100 que no salen de ninguna cuenta. Y como el error final no es de concurrencia, attempts: 3 no repite nada.
Laravel 13.34, del 29 de septiembre de 2026, lo corrige con la PR #61708: si el lock se pide dentro de una transacción, DatabaseLock relanza el deadlock, y DB::transaction() deshace la transacción y la repite. Con Laravel 13.34 y 13.36, los saldos quedan en 900 y 1.100. En el momento de crear esta entrada, Laravel 12.70 no lleva esta corrección.
Si todavía no puedes actualizar, pide el lock antes de abrir la transacción. El proceso 2 quedaría así:
Cache::lock('receipts', 10)->block(5, function () {
DB::transaction(function () {
Account::lockForUpdate()->find(1)->decrement('balance', 100);
sleep(2);
Account::find(2)->increment('balance', 100);
}, attempts: 3);
});
Así el INSERT del lock queda fuera de la transacción, y con Laravel 13.33 los 2 procesos terminan bien y sin deadlocks.
A partir de ahora, si te sale un 1213 en producción, ya sabes qué buscar: 2 transacciones que bloquean las mismas filas en distinto orden. Ordena los bloqueos y deja attempts para lo que se escape. Espero que te sirva. Para ver cuándo usar lockForUpdate(), tienes la entrada sobre bloqueos efectivos en Laravel, y si quieres seguir aprendiendo a evitar los problemas de concurrencia, no te pierdas el curso Race Conditions.
Curso Laravel 12
Completo 2026
El único curso 100% actualizado que incluye Laravel 12, Livewire 3, Vue 3, React 19 e Inertia 2. Aprende con proyectos reales y las últimas funcionalidades.
star Incluido en cualquier suscripción