planeta jupiter planeta tierra

Locks en Symfony: que dos procesos no hagan lo mismo a la vez

· 6 min de lectura · Symfony

Un candado cerrado sobre la ventana de un documento
nave extraterrestre
Un candado cerrado sobre la ventana de un documento

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/finally no 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 UPDATE es 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 autoRelease activado (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?

← Volver al blog

Mi hija de 11 años me ayudó a crear el diseño de este portfolio.