Hace un par de semanas me topé con un problema de concurrencia que resultaba en una base de datos inconsistente. Esta semana me topé con otro problema de concurrencia, eso sí, esta vez el código era de un cliente que maneja más de 5000 usuarios activos y un error como este lo puede dejar en bancarrota. ¿Quieres saber cómo lo resolví? Sigue leyendo ...
Para los que estudiamos computación o estamos metidos en el mundo de la programación, tenemos ese recuerdo de que alguna vez nos explicaron que cuando hay varios pedazos de código accediendo a la misma información de manera simultánea (concurrencia) se puede tener problemas de persistencia en los datos. Bueno, en estricto rigor, solo si alguno de los códigos está actualizado la información.
¿Cuántas veces has tenido que proteger tu información por problemas de concurrencia?
Personalmente, aparte de las tareas de los ramos computines donde tenía que simular este comportamiento de manera forzada, nunca lo había considerado.
Llevo más de un año y medio trabajando en Platanus y hasta dos semanas atrás no me había topado con este problema. Sorprendentemente en las últimas dos semanas los he tenido que resolver dos veces: ¿Acaso estaba haciendo algo mal?.
Esperate un poquito... explícame el problema de nuevo
Digamos que tengo una app para mandar plata (dinero) fuera del país (remesas), en esta app tenemos un usuario (Juan) que carga su cuenta con $100.000. Es decir, él me deposita a mí (la empresa) sus $100.000 y yo le permito enviar esa plata fuera del país a través de la app.
Juan se pone vivaracho y abre dos pestañas del navegador al mismo tiempo, selecciona dos destinatarios distintos, crea un envío por $60.000 a cada uno y finalmente confirma ambos envíos al mismo tiempo.
Entonces, mi aplicación recibe dos solicitudes (pedazos de código) que quieren validar si el usuario tiene suficiente plata. Esta responde que "sí tiene suficiente plata" para ambos casos porque $60.000 es evidentemente menor a $100.000.
Luego, mi app procesa cada una de las solicitudes y les resta la plata que utilizó el usuario en cada envío. Finalmente, la app obtiene registro con saldo negativo (-$20.000) para Juan ... Houston we have a problem o —cabros quedó la caga—.
La siguiente imágen muestra un resumen de lo que sucede.
Ahora que entendemos cuál es el problema ¿cómo lo resolvemos?
En computación existe un concepto para bloquear el acceso a un recurso. En este caso bloquear el acceso a un fila de la base de datos. Ese concepto se conoce como un lock. Una llave. Un guardia en la entrada de la disco que solo deja que baile una persona a la vez. Bien mala la disco por lo demás. Pero muy efectiva en que no se agarren a combos.
En Platanus usamos Rails así que la explicación y ejemplo serán usando este framework.
En Rails existen dos tipos de locks: los optimistas y los pesimistas. Yo te voy a explicar el segundo porque el primero depende de cuánta fe le tengas a tu código (no es tan así pero bueno). Los locks de Rails llaman a los locks de la base de datos que estés usando, por lo que, en realidad los locks son de bases de datos como Postgres y MySql.
Los locks pesimistas permiten bloquear el acceso a una fila de la base de datos en distintos niveles. Te preguntarás, ¿y qué significa eso?. Significa que dependiendo de lo que quieras, podrías permitir que mientras un pedazo de código esté accediendo a un recurso (como la cuenta de balance de Juan) ningún otro pedazo de código pueda acceder a ese mismo recurso.
Solución del problema
Ahora, para el problema con el vivaracho de Juan nos vamos a poner cuáticos y no vamos a permitir que dos pedazos de código puedan actualizar y leer el recurso al mismo tiempo. En este caso el recurso será la cuenta de balance.
Entonces, digamos que tenemos un método que modifica el saldo de la cuenta (esto es una simplificación de la realidad claramente). El código de esto se vería algo así:
En este método se llama al método with_lock que entrega un bloque dentro de una transacción con el recurso (la cuenta de balance) lockeada.
Por lo tanto, si hay dos solicitudes que quieren ejecutar este método simultáneamente, alguna de las dos solicitudes (digamos la primera) ingresa al bloque obteniendo el lock. Mientras tanto la segunda solicitud se queda esperando en esa línea (línea 3) de código hasta tener acceso al lock. Es decir, cuando la primera solicitud sale del bloque.
Volviendo al ejemplo de Juan...
Con este método, Juan solo realizaría la primera transferencia ya que, cuando la segunda tenga acceso al lock, su balance ya habrá sido actualizado y su saldo será de $40.000. En la implementación sin un lock, su saldo podría ser de $100.000 para ambas solicitudes.
Bueno, ¿y qué tiene que ver el tramposo de Juan con mi problema original?. Todo. La verdad es que el problema era exactamente ese. Un usuario tramposo creó dos envíos de plata al mismo tiempo y a pesar de que teníamos implementado una especie de lock manual el usuario pudo hacer ambas transferencias.
Este problema es bastante rebuscado porque solo sucede cuando dos procesos/solicitudes/pedazos de código quieren acceder a un recurso al mismo tiempo. De todas maneras, es importante tenerlo considerado porque puede tener consecuencias enormes. Imagínate si 1000 de los 5000 usuarios activos se dan cuenta de que pueden hacer esto, la aplicación empezaría a perder mucha plata o incluso pudiendo quedar sin liquidez y finalmente en bancarrota.
Entonces, ¿cuándo tengo que usar locks?
Siempre que tengamos plata involucrada tenemos que tener ojo en cómo se accede a esa información y cómo se actualiza. Por lo que, el caso más evidente es para las transacciones de plata. Sobre todo si los movimientos de plata son gatillados directamente por los usuarios (siempre deberíamos asumir que hay un vivaracho Juan entre ellos). Pero también existen otros casos.
Hay que pensar en escenarios en que la información se puede modificar desde distintos puntos al mismo tiempo. Por ejemplo, yo me topé con este problema haciendo una gema que implementa una cola (queue).
En el caso de una cola, si se tiene más de un método que modifica el estado de la cola vamos a tener problemas. Por ejemplo, un método que agrega un nuevo nodo en la primera posición y un método que saca el nodo de la primera posición. Si dos pedazos de código ejecutan al mismo tiempo estos métodos podríamos tener problemas de consistencia. En este caso un lock para la cola soluciona el problema, ya que solo el método que accede al lock puede modificar la cola mientras el otro método debe esperar que el lock sea liberado.
La gema se llama rails-queue-it por si la quieres ver.
En conclusión, es importante identificar cuando podrían haber posibles problemas de consistencia. Recomiendo siempre preguntarte si ¿es posible que dos requests intenten leer/modificar un mismo dato y ejecutar cierta lógica simultáneamente que sería distinta si los requests llegarán uno después del otro?. Si la respuesta es sí, entonces necesitamos locks. De esta manera se puede prevenir datos inconsistentes como sería en el caso de la gema o pérdidas de dinero en el caso de usuarios como Juan.