Manejar Condiciones de carrera En NodeJS Usando Mutex

Incluso si JavaScript es de un solo subproceso, puede haber problemas de concurrencia como condiciones de carrera debido a la asincronización. Este artículo tiene como objetivo contarte cómo manejé esto usando la exclusión mutua. Pero primero, recordemos qué es una condición de carrera antes de entrar en el problema y decirles cómo lo resolví.

Condición de carrera

Una condición de carrera se produce cuando varios procesos intentan acceder a un mismo recurso compartido y al menos uno de ellos intenta modificar su valor. Por ejemplo, imagine que tenemos un valor compartido a = 3 y dos procesos A y B. Imagine que el proceso A quiere agregar 5 al valor actual de a, mientras que el proceso B quiere agregar 2 a a solo si a < 5. Dependiendo del proceso que se ejecute primero, el resultado no será el esperado.Si el proceso A se ejecuta primero, el valor de a será 8, mientras que será 10 si el proceso B se ejecuta first.To evite las condiciones de carrera, utilizamos la exclusión mutua. Hablaré de ello con más detalle más adelante en este artículo.

Ahora permítanme explicar el problema que tuve.

Context

Uno de mis proyectos en Theodo consistió en construir una aplicación NodeJS que proponía múltiples eventos y concursos para sus clientes. También había una característica de membresía premium que permitía a los usuarios participar en eventos gratuitos. Para participar, los usuarios solo tienen que hacer clic en el botón de participación en la página del evento y recibirán una entrada. Las participaciones múltiples en el mismo evento se desautorizan deshabilitando el botón después del éxito de la participación.

Además, hay una comprobación de seguridad en el back-end para evitar que los usuarios con un pedido de pago existente obtengan otro ticket. Puede ver el código simplificado a continuación:

async function participateInFreeEvent(user: User, eventId: number): Promise<void> {const existOrder = await findOrder(eventId, user.id); if (!existOrder) { const order = buildNewOrder(eventId, user.id); createOrder(order.id, eventId, user.id); }}

Primero, busco en la base de datos un pedido existente relacionado con un usuario y un evento. Si no existe ningún orden, se crea un nuevo orden y se guarda en la base de datos. De lo contrario, no se hace nada.

Sin embargo, algunas personas lograron obtener varios tickets haciendo clic muchas veces rápidamente en este botón. Era un problema de condición de raza.

¿Cuál fue el problema exactamente ?

La condición anterior no era suficiente. De hecho, incluso si JavaScript es mono-threaded, no previene las condiciones de carrera. Cuando se trata de funciones asíncronas, el subproceso no bloquea su ejecución, pero ejecuta la siguiente línea que no depende de la llamada asíncrona o continúa la ejecución correspondiente a un evento de respuesta. Como resultado, dos ejecuciones diferentes pueden entrelazarse.

Tome el ejemplo a continuación: un usuario realiza 2 solicitudes consecutivas al back-end, y suponga que no tiene ningún pedido correspondiente a este evento. Como JavaScript es mono-threaded, la pila de ejecución será:

Pila de ejecución

Pero el orden de ejecución de las diferentes líneas de la función será:

Ejecución de código sin bloqueo

findOrder y createOrder son llamadas asíncronas ya que leen y escriben en la base de datos. En consecuencia, las dos solicitudes se entrelazarán. Como puede ver en la figura anterior, la segunda findOrder se ejecuta justo después de la primera solicitud. Por lo tanto, la segunda evaluación de !existOrder será true ya que la llamada se ha realizado antes de crear un pedido.

Conclusión: nuestro usuario recibirá 2 entradas.

Solución

Tuve que encontrar una forma de bloquear esta parte del código para ejecutar toda la función antes de permitir que otra solicitud ejecutara el mismo código, y así evitar las condiciones de carrera. Hice esto usando mutex, con la biblioteca async-mutex (puede instalarla ejecutando yarn add async-mutex).

Un mutex es un objeto de exclusión mutua que crea un recurso que se puede compartir entre varios subprocesos de un programa. El recurso puede verse como un bloqueo que solo un hilo puede adquirir. Si otro hilo quiere adquirir la cerradura, tiene que esperar hasta que se libere la cerradura. Pero tenga en cuenta que el bloqueo siempre debe liberarse eventualmente, sin importar lo que suceda durante la ejecución. De lo contrario, provocará bloqueos y su programa se bloqueará.

Para no ralentizar la compra de entradas, utilicé un mutex por usuario, que guardé en un Mapa. Si el usuario no está asignado a un mutex, se crea una nueva instancia en el mapa utilizando el id de usuario como clave, y luego usé el mutex de la siguiente manera :

import { Mutex, MutexInterface } from 'async-mutex';class PaymentService {private locks : Map<string, MutexInterface>;constructor() {this.locks = new Map();}public async participateInFreeEvent(user: User, eventId: number): Promise<void> { if (!this.locks.has(user.id)) { this.locks.set(user.id, new Mutex()); } this.locks .get(user.id) .acquire() .then(async (release) => { try { const existOrder = await findOrder(eventId, user.id); if (!existOrder) { const order = buildNewOrder(eventId, user.id); createOrder(order.id, eventId, user.id); } } catch (error) { } finally { release(); } }, ); }}

Puedes ver que con el bloque try-catch-finally, me aseguro de que el bloqueo siempre se libere. Ahora, la ejecución se verá como la siguiente figura:

Ejecución de código con bloqueo

De esta manera, un usuario solo podrá participar una vez.

Por supuesto, esta no es la única manera de resolver este tipo de problemas. Las transacciones también son muy útiles en este caso.

Además, tenga en cuenta que resolvió el problema en una sola instancia de servidor. Si tiene varios servidores, debe usar mutex distribuido para que todos los procesos sepan que el bloqueo se adquiere.

Deja una respuesta

Tu dirección de correo electrónico no será publicada.