Tienes un comando en el cron cada cinco minutos. Un día tarda seis, y ahora hay dos ejecuciones a la vez mandando los mismos correos. Un lock es un cartel de «ocupado» que todos los procesos pueden ver: el primero que llega lo cuelga, el resto se da la vuelta. Symfony lo trae en un componente y son cuatro líneas.
Qué es exactamente
Un lock no bloquea código, bloquea un nombre. Tú eliges una cadena que
identifique el recurso —'envio-newsletter',
'factura-847'— y el componente garantiza que solo un proceso a la vez consigue
ese nombre. Los demás reciben false y deciden qué hacen: esperar o irse.
Eso funciona entre procesos distintos: dos peticiones web, dos workers de Messenger, dos ejecuciones del mismo cron. Es justo lo que no puede hacer una variable de PHP, porque cada proceso tiene la suya.
Instalación
composer require symfony/lock
Flex te deja el .env preparado con un almacén de ficheros, que es el valor
razonable para empezar:
# .env
LOCK_DSN=flock
# config/packages/lock.yaml
framework:
lock: '%env(LOCK_DSN)%'
El ejemplo corto: un comando que no se solapa
Este es el 90 % de los casos y ni siquiera hace falta tocar el componente: la consola trae un trait.
namespace App\Command;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Command\LockableTrait;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(name: 'app:send-newsletter')]
final class SendNewsletterCommand extends Command
{
use LockableTrait;
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
if (!$this->lock()) {
$io->note('Ya hay un envío en marcha. Me voy.');
return Command::SUCCESS;
}
// ... aquí el trabajo, tranquilo, sin compañía
$this->release();
return Command::SUCCESS;
}
}
El nombre del lock es el del comando, así que no hay nada que inventar. Y fíjate en el
return Command::SUCCESS del caso ocupado: salir con error haría que el cron te
mandara un correo de fallo cada cinco minutos cuando en realidad todo va bien.
El caso general: LockFactory
Fuera de un comando, inyectas la fábrica y creas el lock con el nombre que quieras:
namespace App\Service;
use Symfony\Component\Lock\LockFactory;
final class InvoiceGenerator
{
public function __construct(
private LockFactory $lockFactory,
) {
}
public function generate(Invoice $invoice): void
{
$lock = $this->lockFactory->createLock('invoice_' . $invoice->getId());
if (!$lock->acquire()) {
return; // otro proceso ya está con esta factura
}
try {
$this->renderPdf($invoice);
$this->upload($invoice);
} finally {
$lock->release();
}
}
}
Dos detalles que valen el artículo entero:
- El nombre lleva el id. Así se bloquea esa factura y no todas: dos facturas distintas siguen generándose en paralelo.
- El
try/finallyno es adorno. Si el render lanza una excepción y no liberas, el cartel de «ocupado» se queda colgado hasta que caduque.
Por defecto acquire() no espera: o lo pilla o devuelve false. Si lo
que quieres es hacer cola, pásale true y el proceso se queda esperando su
turno:
$lock->acquire(true); // espera hasta que se libere
Esperar tiene sentido cuando el trabajo hay que hacerlo sí o sí (cobrar, escribir un asiento). Si el trabajo lo puede hacer el otro proceso, no esperes: vete.
El TTL, que es lo que sorprende
Los locks caducan. Por defecto a los 300 segundos, y es a propósito: si un proceso muere sin liberar, alguien tiene que poder seguir trabajando. El problema es cuando tu tarea dura más que eso, porque entonces el lock se suelta solo y entra un segundo proceso mientras el primero sigue.
Dos maneras de arreglarlo. Subir el TTL al crearlo:
$lock = $this->lockFactory->createLock('import_catalog', ttl: 3600);
O, mejor para trabajos largos de duración desconocida, ir renovándolo mientras trabajas:
foreach ($products as $i => $product) {
$this->import($product);
if (0 === $i % 100) {
$lock->refresh(); // otros 300 segundos
}
}
Dónde se guarda el cartel
El lock vive en algún sitio compartido, y ahí está la decisión importante:
| DSN | Dónde guarda | Cuándo |
|---|---|---|
flock |
Ficheros en el disco local | Un solo servidor. Es el que trae de serie. |
semaphore |
Memoria del sistema (ext. sysvsem) | Un solo servidor, procesos de la misma máquina. |
redis://… |
Redis | Varios servidores. Lo habitual en producción repartida. |
%env(DATABASE_URL)% |
Una tabla en tu base de datos | Varios servidores y no quieres montar un Redis. |
Y el aviso: flock solo se entiende consigo mismo. Con dos
máquinas detrás de un balanceador, cada una tiene sus ficheros y las dos creerán que el
recurso está libre. Es el fallo clásico, y aparece el día que escalas, no el día que
escribes el código.
Cuándo usar un lock
- Un cron que puede solaparse. Importaciones, envíos, informes: cualquier cosa cuya duración no controlas del todo.
- Un recurso que es uno y no dos. Un fichero que se reescribe, una API externa que no admite llamadas en paralelo, un directorio de trabajo.
- Varios workers de Messenger sobre lo mismo. Si consumes una cola con cuatro procesos, dos mensajes del mismo pedido pueden caer a la vez.
- Un proceso que no debe repetirse por entidad. Generar el PDF de la factura 847 una sola vez, aunque lleguen tres peticiones.
Y cuándo no
Aquí es donde se usa mal. Un lock no es la herramienta si:
- Todo ocurre dentro de la base de datos. Restar stock, incrementar un
contador: una transacción con
SELECT … FOR UPDATEes más corta, más segura y no deja carteles colgados. El lock es para lo que la base de datos no ve. - Es un doble clic en un formulario. Eso no es concurrencia entre procesos, es la misma acción repetida: lo que quieres es una clave de idempotencia.
- Quieres limitar la frecuencia. «Como mucho tres veces por minuto» no es un lock, es rate limiting.
- Quieres que el trabajo se haga una vez y ya. Un lock impide que se haga a la vez, no que se haga dos veces seguidas. Si eso te importa, hace falta además una marca persistente de «esto ya está hecho».
Lo que se rompe si no lo sabes
- Sin
try/finally, un error deja el recurso bloqueado. Hasta que venza el TTL, nadie más entra. - Un lock no revierte nada. Si el proceso muere a medias, lo que se escribió en base de datos sigue escrito. Eso lo arregla una transacción, no el lock.
- El nombre identifica al recurso, no al proceso. Si metes en el nombre algo que cambia entre ejecuciones —la hora, el PID— cada una cogerá su propio lock y no habrá bloqueado nada.
- Ojo con el TTL corto en trabajos largos. Es la forma silenciosa de volver a tener dos procesos a la vez, justo lo que querías evitar.
- El objeto libera al destruirse. Con
autoReleaseactivado (lo normal), el lock se suelta al acabar el script. Guardar el objeto en una propiedad y perder la referencia por el camino tiene efectos raros.
Y ya está
Para un comando, LockableTrait y a otra cosa. Para lo demás,
LockFactory, un nombre que identifique bien el recurso y un
try/finally. Y antes de escribir nada, la pregunta de siempre: ¿esto es de
verdad concurrencia entre procesos, o es una transacción de base de datos con otro
nombre?