Sus trabajos de Laravel correrán dos veces. Prevéalo.
La entrega al menos una vez significa que todo trabajo en cola acaba ejecutándose más de una vez. Qué rompe eso, por qué los reintentos no son la única causa, y cómo blindar un trabajo.
La cola de Laravel le da entrega al menos una vez. Casi todos los equipos leen eso como "reintenta cuando falla" y siguen adelante, que es la mitad. La otra mitad es que un trabajo que tuvo éxito puede ejecutarse otra vez, y ninguna cantidad de manejo cuidadoso de errores lo evita.
Cuatro maneras de que un trabajo corra dos veces
El reintento que usted configuró. Un trabajo lanza una excepción, la cola lo reintenta. Obvio, y el único que todo el mundo tiene en cuenta.
El tiempo de espera mal puesto. El trabajo tarda más que el tiempo de espera configurado. El worker es terminado, el mensaje no se confirma, otro worker lo recoge - mientras el primero quizá siga terminando su trabajo.
El despliegue. Un worker está sosteniendo un trabajo cuando el código cambia debajo. Se reinicia; el mensaje vuelve a la cola. El trabajo hizo casi todo lo suyo antes del reinicio, y se va a hacer entero otra vez.
La infraestructura. Se cae una conexión entre "el trabajo terminó" y "la confirmación llegó al broker". El trabajo ocurrió. La cola no lo sabe.
Solo el primero está bajo su control. Los otros tres son la razón de que una cola sea al menos una vez y no exactamente una vez, y de que la entrega exactamente una vez no sea algo que se pueda configurar.
Cuánto cuesta esto de verdad
Un correo enviado dos veces es una vergüenza. Todo lo de abajo es peor:
- Un pago cobrado dos veces, que es una devolución, un hilo de soporte y un cliente que no vuelve.
- Un webhook entregado dos veces a un socio cuyo propio sistema tampoco es idempotente.
- Un nivel de stock descontado dos veces, que es un producto vendido de más.
- Un número de factura asignado dos veces, que en algunas jurisdicciones es un problema contable con un regulador enganchado.
El último deja de ser hipotético en cuanto una cola empieza a escribir registros en el libro mayor de otra persona. Un trabajo duplicado contra una API de contabilidad es una factura duplicada en una contabilidad real, y quien tiene que deshacerla no trabaja para usted.
El patrón es el mismo en todos: el efecto ocurrió fuera de su base de datos, así que una transacción no pudo deshacerlo.
El arreglo que no funciona
Este es el código que encontramos más a menudo, y es una carrera:
public function handle(): void
{
if ($this->order->refresh()->paid_at !== null) {
return; // ya está hecho, saltar
}
$charge = $this->gateway->charge($this->order); // ← los dos llegan aquí
$this->order->update(['paid_at' => now()]);
}Dos workers leen la fila antes de que ninguno escriba. Los dos ven null, los
dos cobran. Comprobar y luego actuar no es una guarda, es una ventana más
estrecha - y los duplicados de cola llegan exactamente en las condiciones que
estrechan ventanas.
Guardas que sí aguantan
La regla es que la unicidad tiene que hacerla cumplir algo con lo que no se pueda competir: la base de datos, o el tercero.
Una restricción de unicidad haciendo el trabajo. Deje que la inserción falle en lugar de preguntar antes:
public function handle(): void
{
try {
$payment = Payment::create([
'order_id' => $this->order->id,
'idempotency_key' => $this->key, // índice único
]);
} catch (UniqueConstraintViolationException) {
return; // este lo tiene otro worker
}
$this->gateway->charge($this->order, $this->key);
}Una clave de idempotencia que hace cumplir el proveedor. Toda API de pagos seria acepta una. Dos peticiones idénticas con la misma clave producen un cobro y dos respuestas idénticas. Es la garantía más fuerte disponible, porque aguanta incluso si lo que falló fue su propia base de datos.
Una actualización condicional. Que la transición de estado sea el bloqueo:
$claimed = Order::where('id', $this->order->id)
->whereNull('paid_at')
->update(['paid_at' => now()]); // devuelve filas afectadas
if ($claimed === 0) {
return; // la transicionó otro
}Una sola sentencia, decidida por la base de datos. El worker que recibe 1 es
el dueño del trabajo.
Una clave generada al despachar, no dentro del trabajo. Esto importa y se pasa por alto con facilidad: si el trabajo calcula su clave de idempotencia a partir de la hora actual o de un valor aleatorio, dos ejecuciones del mismo trabajo producen dos claves y la guarda no se dispara nunca. La clave es parte de la carga del trabajo, creada una vez, cuando el trabajo se despacha.
Ya que está ahí dentro
Dos ajustes y una costumbre, los tres baratos:
Ponga el tiempo de espera por debajo del retardo de reintento. Si un trabajo puede correr 90 segundos y se reintenta a los 60, se ha garantizado ejecuciones concurrentes del mismo trabajo.
Use retroceso, no un retardo fijo. public $backoff = [10, 60, 300]; - la
caída de un tercero no mejora porque la golpee tres veces en treinta segundos.
No reintente lo que no puede salir bien. Un fallo de validación reintentado cinco veces son cinco fallos idénticos y un retraso para todo lo que venga detrás en la cola. Lance algo que el trabajo trate como fatal, y déjelo fallar al primer intento.
El último tramo: alguien tiene que estar mirando
Todo lo anterior no sirve de nada si los fallos son invisibles. El fallo de
producción más frecuente que nos llaman a mirar es una tabla failed_jobs con
miles de filas que nadie ha leído jamás.
Eso no es un fallo de la cola. El trabajo falló, el framework lo registró exactamente como está documentado, y no había nada enganchado al registro. Conecte los fallos a lo que su equipo ya vigila, alerte sobre la tasa y no sobre el evento, y ponga un latido en la propia cola para que un worker que se ha parado se distinga de una cola que simplemente está vacía.
Una cola que se puede dejar sola no es una que nunca falla. Es una que le avisa cuando falla, y que no hace daño cuando se repite.
Si la suya es hoy de las otras, ese es el trabajo.
