SELECT … FOR UPDATE en las transacciones de MongoDB

Transacciones ACID Multi-Document en Mongo 4.0 

¿Cómo puedo estar seguro de que los documentos que estoy leyendo dentro de una transacción no se modificarán en otra antes de que se ejecuten? 

Antes de responder a esta pregunta, debemos saber cómo funciona la concurrencia en MongoDB, dentro y fuera de las transacciones. 

Locking

MongoDB utiliza bloqueo multi-granularity que permite acceso compartido a un recurso, como a una base de datos o a una colección. 

Todas las lecturas y escrituras que forman parte de una transacción deberán tener un bloqueo exclusivo (IX) en la colección a la que están accediendo. A veces, esto no es posible porque otra operación tiene un bloqueo exclusivo o compartido (X o S) en esa colección, o en su base de datos. Por tanto, para evitar un posible deadlock la transacción espera 5 ms (o lo que indique el parámetro maxTransactionLockRequestTimeoutMillis) y se detiene.

Sin embargo, las operaciones que tienen un bloqueo exclusivo o compartido en una colección no son muy comunes, así que a continuación, vamos a ver qué es lo más probable que puede crear un conflicto en una transacción.

Conflictos en escritura y transacciones

En WiredTiger, una actualización se escribe en un documento y se detiene si se detecta un conflicto, como ocurrirá en otros casos, como cuando se supera el umbral de memoria disponible.

Fuera de las transacciones, dichas escrituras volverán a ejecutarse automáticamente dentro de MongoDB hasta que se ejecute con éxito.

El escenario es un poco diferente en el caso de las transacciones. Mira los siguientes ejemplos: 

En una transacción (t1) se puede dar un writeConflict si otra operación de escritura modifica el mismo documento (D1) después de que haya comenzado la transacción y antes de que se intente modificar por sí misma. Esto puede suceder porque la otra operación de escritura esté o no en una transacción. En la imagen de la izquierda se muestra como una declaración independiente, y en la imagen de la derecha como una segunda transacción (t2).

A continuación, se muestra el ejemplo de un error writeConflict visto desde MongoDB:

En el caso de que se genere un writeConflict dentro de una transacción, no se vuelve a intentar automáticamente, pero devolverá el error al driver.

Cuando se devuelve un writeConflict, el driver puede volver a intentar de forma segura toda la transacción utilizando el Callback API de transacciones que permite volver a intentarlo automáticamente en caso de TransientTransactionError (como en writeConflicts). 

Las operaciones de escritura fuera de la transacción se vuelve a intentar automáticamente, cuando sucede un writeConflict en un documento que se haya modificado dentro de una transacción hasta que tenga éxito.

Conflictos en operaciones de lectura

Ahora que sabemos lo que sucede cada vez que realizamos operaciones de escritura dentro de una transacción, podemos centrarnos en lo que sucede cuando realizamos operaciones de lectura.

Cuando se inicia una transacción con un Read Concern “snapshot”, tenemos la garantía de leer desde un punto consistente en el tiempo a nivel de clúster. Los datos que estamos leyendo dentro de la transacción no se ven afectados por ninguna operación de escritura que ocurra fuera de la transacción.

Por ejemplo, cuando la transacción t1 comienza, obtiene un snapshot de los datos, incluido el documento D1 que se pretende leer. Mientras tanto, la transacción t2 tiene su propio snapshot y elimina el documento D1. Sin embargo, la transacción t1 todavía puede leer el documento D1 porque se refiere a su snapshot y, por lo tanto, está aislada de otras transacciones que hayan realizado cambios.

Esto lo que esto significa, es que no podemos estar seguros de que después de leer los documentos dentro de una transacción, esos documentos no serán modificados por otra operación mientras dure nuestra transacción.

En el mundo relacional, esto se resuelve utilizando SELECT…FOR UPDATE Esta declaración permite bloquear las filas que estamos leyendo como si estuvieran actualizadas, evitando que otras operaciones las modifiquen o eliminen hasta que finalice nuestra transacción.

El mismo comportamiento se puede reproducir en MongoDB modificando un documento para que otras transacciones que intentan escribir en él reciban una excepción writeConflict.

¿Pero qué es lo que debemos actualizar? No podemos simplemente reescribir el valor de un campo existente con su valor actual porque MongoDB es eficiente y no modificará un documento si una escritura no cambia realmente el valor existente.

Considerando el siguiente ejemplo como un documento de una colección:

Mongo status true

Desarrollamos una actualización dentro de la transacción: 

Sin writeConflict

Con esto conseguimos que si nosotros no modificamos el documento (y el status está a true), otras operaciones pueden modificar el documento sin provocar un writeConflict. 

Emulando un bloqueo de escritura (Write Lock) durante una lectura

Para asegurarnos de que evitamos que otras operaciones modifiquen nuestro documento, debemos asegurarnos de que estamos escribiendo un nuevo valor en él. Por lo general, no tenemos un campo que podamos cambiar para que suceda una escritura, pero el esquema flexible de MongoDB nos facilita establecer un atributo que vamos a usar exclusivamente para adquirir nuestro «Write Lock».

La siguiente pregunta es ¿a qué valor debemos establecer nuestro nuevo atributo? Debe ser un valor que sepamos que será diferente a cualquier valor que ya esté en el campo. Ya tenemos un tipo de datos en MongoDB con esas propiedades: ObjectId. Dado que ObjectId se genera en base a una combinación de Unix Epoch, valores aleatorios y un contador, es extremadamente improbable que se genere el mismo valor más de una vez. Eso significa que cuando lo actualizamos, se establecerá el campo en un valor diferente. Una vez que se realiza la actualización, otras operaciones obtendrán un writeConflict.

En código se muestra de la siguiente forma:

La bueno de este método es que no se necesita ningún paso adicional para «desbloquear» el documento; sucederá automáticamente cuando ejecute o cancele la transacción. El valor del campo de bloqueo, en nuestro ejemplo myLock, puede ser cualquiera. No importa cuál sea el valor, siempre que cambie el valor existente. El uso de findOneAndUpdate también mantiene el número de viajes de ida y vuelta a la base de datos, ya que ambos actualizan el campo de bloqueo en el documento y lo devuelve a la aplicación.

El valor del campo de bloqueo no necesita ser leído por ninguna transacción. Sin embargo, puedes utilizar el campo de bloqueo para almacenar información sobre qué aplicación está bloqueando la cadena. 

Conclusión

La capacidad de bloquear documentos a través del soporte de transacciones de MongoDB es una poderosa ayuda para garantizar la consistencia. Recuerda que, aunque MongoDB soporta transacciones, todas las mejores prácticas existentes siguen siendo válidas y debes usar las transacciones solo cuando sea necesario. Siempre se debe aprovechar la flexibilidad que proporcionan los documentos en MongoDB que en muchos casos elimina la necesidad de usar transacciones.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *