Saltar al contenido

Volver a proyectos

Concurrencia

Race conditions en pagos: locks distribuidos en una arquitectura serverless

· 7 min

En una arquitectura serverless, cada solicitud puede ser atendida por una instancia distinta del proceso, lo cual es excelente para escalar, pero rompe cualquier suposición de que dos solicitudes del mismo usuario van a ser vistas por el mismo trozo de memoria. Esto importa mucho cuando las solicitudes en cuestión involucran un cobro con tarjeta o una llamada paga a un servicio de IA.

Ilustración de un candado

El problema: dos clics, dos cobros

Un doble clic en un botón de upgrade de plan, o un reintento tras una respuesta lenta, podía disparar dos solicitudes de mutación de suscripción casi simultáneas. Como el cobro proporcional se factura de inmediato al cambiar de plan, esas dos solicitudes concurrentes corrían el riesgo de terminar en dos cobros reales en el proveedor de pagos por la misma acción del usuario.

Por qué un lock en memoria no resuelve el problema

La solución obvia, bloquear la operación con un mutex en memoria mientras se ejecuta, solo funciona si las dos solicitudes concurrentes caen en la misma instancia del proceso. En producción, bajo una arquitectura distribuida, eso no está garantizado: las dos podían ser atendidas en instancias distintas, cada una con su propia memoria aislada, y el lock simplemente no existiría para la segunda llamada.

La solución: lock atómico en la base de datos

En vez de depender de memoria compartida, el lock pasó a vivir en la base de datos, el único recurso que todas las instancias realmente comparten. Un único INSERT atómico con resolución de conflicto condicional (solo cede el lock si el anterior ya expiró) funciona como mutex por usuario: la segunda solicitud concurrente simplemente falla al adquirir el lock y se rechaza antes de acercarse al proveedor de pagos. El mismo patrón se reutilizó para bloquear el asistente de IA, evitando que un usuario disparara múltiples llamadas pagas en paralelo al hacer clic en "enviar" de nuevo durante una respuesta todavía en curso, con renovación del lock durante generaciones largas y una ruta explícita de cancelación.

El rate limiting también tuvo que volverse distribuido

El mismo problema apareció en el limitador de intentos de inicio de sesión: un limitador en memoria, por instancia, es trivialmente evadible en una arquitectura distribuida, el atacante solo necesita que los intentos caigan en instancias distintas. Los flujos de autenticación pasaron a usar un limitador de tasa con estado en la base de datos, compartido entre todas las instancias; los flujos de bajo riesgo siguieron usando la versión en memoria, más barata.

Resultado

Sin cobros duplicados, sin llamadas pagas duplicadas a la IA, y los intentos de fuerza bruta distribuida contra el login pasaron a chocar con un límite real, todo sin agregar ninguna pieza nueva de infraestructura, solo reutilizando la base de datos que ya estaba en el camino de cada solicitud como mutex compartido.