Laravel — Garantizando datos únicos con Atomic Locks
Atomic Locks: puede ser tu mejor amigo o tu peor enemigo.
La situación
Imagine la siguiente situación: ha implementado un punto final, que ha registrado en alguna plataforma externa como punto final de recepción de webhooks. Este punto final puede, por ejemplo, recibir datos del pedido, como el destinatario, la dirección de facturación y toda la información relacionada con el pedido (productos, pesos, precios, lo que sea).
Ahora, cuando desee procesar los datos de este pedido, puede imaginar que tendrá que haber algo de lógica. Primero, formateamos los datos en una estructura uniforme con la que podemos manejar toda la lógica sucesiva.
Luego, querremos verificar si el pedido ya existe dentro de nuestra base de datos. Por supuesto, podemos comprobar si ya hemos guardado el pedido antes comprobando el identificador que utiliza la plataforma externa. Si simplemente realizamos lo siguiente:
…usted puede empezar a ver el problema. Estas plataformas externas nos envían webhooks para casi cualquier cosa que puedas imaginar.
Pedido inicializado, pedido creado, pedido pagado, dirección de envío actualizada, pedido completado, ¡lo cual es bueno!
A veces, estos eventos pueden seguirse tan rápido que suceden en el mismo segundo, o incluso en los mismos milisegundos.
¡UH-oh! Ahora puede imaginar que mientras hacemos esta consulta y recibimos otro webhook, otro de esos webhooks ya estaba en proceso de procesamiento.
Esta consulta dirá “no, aún no existe” y el pedido se creará como un duplicado dentro de nuestro sistema, ya que ninguno de los dos webhooks reconoció la ID del pedido externo.
Esta es una situación de la vida real con la que nos hemos encontrado muchas veces al implementar integraciones con grandes plataformas de comercio electrónico en la nube como Shopify y Lightspeed, pero es aplicable a muchas otras situaciones de la vida real fuera del comercio electrónico (o webhooks, para ese asunto).
¿Entonces, como lo arreglamos?
El primer pensamiento que podría tener (como lo hicimos nosotros) es el bloqueo de la base de datos. Solo dale un DB::transaction y YourModel::lockForUpdate de la siguiente manera:
Esta lógica procesará el primer webhook y, mientras se ejecuta el código, le indicará al siguiente “póngase en fila, por favor”. En la próxima llamada se le dirá “póngase en línea, por favor”, y cada llamada se manejará una por una. Problema resuelto, ¿verdad? ¡No más pedidos duplicados!
Si se ha encontrado con este problema antes, notará inmediatamente lo que sucederá aquí.
- ¿Qué sucede si recibimos 20 webhooks a la vez para el mismo pedido?
- ¿Qué sucede si necesitamos procesar miles de llamadas de webhook en una hora, y mucho menos en un minuto?
La “línea” de llamadas que esperan ser procesadas será tan larga que se encontrará con condiciones de carrera, donde al final, cada llamada tendrá un tiempo de espera de su servidor web, y lo más probable es que la base de datos también agote el tiempo de espera de las llamadas. .
No solo no obtendrá ningún pedido duplicado de esta manera, sino que no obtendrá ningún dato del pedido en absoluto. Entonces, cuál es la solución…?
Nuestro salvador: Atomic Locks
Desde Laravel 7 en adelante, hay una función llamada “Atomic Locks” (bloqueos atómicos). Esto se integra con su controlador de caché existente, aunque recomendaría enfáticamente usar uno rápido. Laravel proporciona soporte integrado para Redis, que se adapta perfectamente a nuestras necesidades (¡es rápido!).
De esta forma, tenemos una forma de definir una cerradura, o mejor dicho, muchas cerraduras, definidas por una llave. Esto es perfecto para nuestro escenario, ya que nuestro requisito es que los pedidos sean siempre únicos en función de su ID de pedido externo.
Considere el siguiente ejemplo:
¡Ya casi llegamos!
La base de datos no se bloqueará (y, por lo tanto, no generará una cola). En su lugar, estamos creando un bloqueo único en Redis para este ID de pedido específico, y otros webhooks de ID de pedido pasarán sin problemas.
Ahora queda un paso. ¿Qué pasa si llega una segunda llamada de webhook para el mismo pedido, mientras que no queríamos procesar la primera, y la segunda era la que realmente es relevante?
En este caso, el segundo webhook obtendrá un error, ya que no pudo adquirir el bloqueo.
La solución es mantener el bloqueo durante el tiempo que su lógica necesite (en nuestro ejemplo, usaré solo 1 segundo).
- Si no puede obtener un bloqueo, lo intentará de nuevo después de un segundo.
- Si el bloqueo se adquirió con éxito, podemos procesar el pedido y luego liberar el bloqueo:
¡Hecho!
Ahora, las únicas amenazas restantes son cuando su lógica tarda más de un segundo, lo que puede resolverse con la lógica recursiva de intento de captura que se muestra arriba, o cuando se alcanza el tiempo de espera configurado (generalmente 60 segundos para servidores web como Nginx o Apache) (después de 60 intentos de 1 segundo).
Por ejemplo, no ejecutando sus procesos externos de manera asíncrona usando trabajos. Aparte de eso, ahora podemos procesar 20 webhooks en el mismo segundo para un pedido (tendrán su propia “línea de cola”), así como 1000 ID de pedido únicos a la vez. Auge.
Gracias por llegar hasta aquí, si encuentras esto útil no olvides aplaudir 👍🏼suscribirse para recibir más contenido.
Si le interesa, puede echar un vistazo a algunos de los otros artículos que he escrito recientemente sobre AWS y Laravel: