Evitar actualizaciones perdidas en Node.js y MongoDB durante escrituras concurrentes
Aprenda cómo las actualizaciones condicionales atómicas, el bloqueo optimista basado en versiones, las respuestas 409 y las transacciones impiden que las escrituras concurrentes en MongoDB descarten datos sin dejar rastro.
Dos personas presionan Guardar en el mismo registro; ambas solicitudes devuelven 200 OK, y los cambios de una de las personas desaparecen sin dejar rastro. Nada se cae, nada se registra, y el código responsable parece perfectamente razonable al leerse solicitud por solicitud. Este artículo explica por qué ocurren estas actualizaciones perdidas en un backend típico de Node.js y MongoDB, y le proporciona una serie de herramientas para evitarlas: actualizaciones condicionales atómicas, bloqueo optimista basado en versiones, respuestas 409 Conflict, transacciones y pruebas que reproducen efectivamente la situación de competencia.
Un escenario realista
Tomemos un panel de administración para una tienda en línea. Actualmente, un producto tiene un precio de 100 dólares y 10 unidades en stock.
Un empleado en Estados Unidos abre el producto y reduce su precio a 90 dólares. Casi al mismo tiempo, un colega en Europa abre el mismo producto y establece su stock en 8. Ambos cargaron el producto antes de guardar sus cambios, por lo que ambos están editando la misma versión obsoleta. Ese punto de partida compartido es donde comienzan los problemas.
La brecha de lectura-modificación-escritura
Una forma común de realizar cada edición es cargar el documento, modificar una propiedad en memoria y guardarla. La primera solicitud cambia el precio. (El fragmento mostrado contiene un product.price = 90; duplicado después de la llamada a save(); ignórelo, ya que solo sirve para ilustrar el patrón.)
const product = await Product.findById(productId);
product.price = 90;
await product.save();product.price = 90;
La segunda solicitud hace lo mismo con el campo de stock:
const product = await Product.findById(productId);
product.stock = 8;
await product.save();
Cada solicitud lee el estado anterior, modifica su copia local y la escribe de vuelta. findById() no es el culpable. El peligro radica en el intervalo entre leer los datos y escribirlos de nuevo, ya que otra solicitud puede modificar el mismo registro durante ese tiempo. Lo que se escriba por último prevalecerá, y cualquier cambio realizado en el intervalo puede ser sobrescrito: una actualización perdida.
¿Hasta qué punto lo maneja ya Mongoose?
Es importante ser preciso sobre cuándo ocurre este problema. Cuando se llama a save() en un documento Mongoose existente, Mongoose envía solo las rutas que se modificaron, en formato $set. Así, en el ejemplo anterior, donde las dos solicitudes afectan campos diferentes, por lo general ambas ediciones de precio y stock sobrevivirían. La actualización perdida se vuelve real cuando:
- ambas solicitudes modifican el mismo campo (dos administradores editando el precio);
- el nuevo valor se calcula a partir del anterior (
product.stock = product.stock - 1), por lo que el segundo usuario trabaja con un número desactualizado; - tu API acepta el documento completo del cliente, como en un manejador típico de
PUT, y escribe de vuelta cada campo, incluidos aquellos que otro usuario acaba de modificar.
Otros ODMs, controladores en bruto y ORM de SQL se comportan de manera diferente, así que no dependas del seguimiento de cambios no confirmados como estrategia de concurrencia. Considera que los patrones leer-modificar-escribir son inseguros por defecto y elige deliberadamente una de las herramientas mencionadas a continuación.
Partir de la invariante
Antes de elegir una técnica, decide qué es lo que nunca debe salir mal. La respuesta varía según el dominio:
- En cuanto al inventario: las existencias nunca deben ser negativas.
Esa regla de negocio, y no un patrón preferido, debe determinar la solución técnica.
Actualizaciones atómicas: deja que la base de datos realice el cambio
Si estás modificando un único campo, a menudo no es necesario leer el documento en absoluto. Envía a la base de datos exactamente el cambio que pretendes realizar. Establecer el precio se convierte en una llamada updateOne con un parámetro $set:
await Product.updateOne(
{ _id: productId },
{
$set: {
price: 90
}
}
);
La edición del inventario es igualmente independiente:
await Product.updateOne(
{ _id: productId },
{
$set: {
stock: 8
}
}
);
Cada operación ahora describe su intención real en lugar de enviar una copia antigua del documento de vuelta al servidor. MongoDB aplica cada actualización a un único documento de forma atómica, por lo que dos operaciones de este tipo en campos diferentes no pueden anularse mutuamente.
Ponga la regla de negocio dentro de la actualización
El patrón se vuelve más poderoso cuando incluye la condición directamente en la consulta. Supongamos que queda un único ticket. El flujo tradicional lee el stock, lo verifica en el código de la aplicación y luego lo disminuye, lo que deja espacio para que otro comprador aproveche la oportunidad. En cambio, haga que el filtro exprese la regla y deje que $inc realice el cambio en la misma operación:
const result = await Product.updateOne(
{
_id: productId,
stock: { $gt: 0 }
},
{
$inc: {
stock: -1
}
}
);
Si la actualización modifica un documento, significa que había stock disponible en el momento en que se ejecutó la operación. Si no modifica nada, alguna otra solicitud ya se ha llevado la última unidad, y puede informar al usuario de que está agotado. No existe un intervalo entre la verificación y la escritura, ya que son el mismo paso. Este es uno de los patrones de concurrencia más simples y eficaces disponibles, y funciona para contadores, cuotas, reservas de asientos y cualquier regla que pueda expresarse como un filtro de consulta.
Bloqueo optimista para ediciones de larga duración
Las actualizaciones atómicas no pueden abarcar todos los casos. Imagine que un empleado abre una configuración de producto extensa, pasa cinco minutos ajustando varios campos y luego la guarda. Mientras tanto, un colega ya ha guardado los cambios en el mismo producto. El primer empleado no debería sobrescribir la versión más reciente sin saber que existe.
La solución estándar es almacenar un número de versión en el documento:
Product
Price: $100
Stock: 10
Version: 7
Ambos usuarios cargan la versión 7. El usuario A guarda primero, y la versión pasa a ser 8. El usuario B sigue teniendo la versión 7, por lo que su guardado debe significar “aplicar esto solo si el producto sigue estando en la versión 7”. En MongoDB se expresa eso colocando la versión esperada en el filtro e incrementándola en la misma actualización:
const result = await Product.updateOne(
{
_id: productId,
version: currentVersion
},
{
$set: {
price: newPrice
},
$inc: {
version: 1
}
}
);
if (result.modifiedCount === 0) {
return res.status(409).json({
message: "This product was updated by another user."
});
}
Si el filtro ya no coincide, no se escribe nada y el manejador devuelve un conflicto en lugar de descartar silenciosamente el trabajo realizado. Esto representa un control de concurrencia optimista: se asume que los conflictos son raros, no se mantienen bloqueos mientras el usuario edita, y se detecta la colisión en el momento de la escritura.
Dos mejoras hacen que esto sea más robusto en la práctica. Primero, modifiedCount === 0 también ocurre cuando el producto no existe en absoluto, por lo que verificar matchedCount o realizar una búsqueda adicional permite devolver 404 para un producto faltante y 409 únicamente en caso de un verdadero conflicto de versiones. Segundo, si se utilizan documentos de Mongoose en lugar de updateOne, hay que considerar la opción optimisticConcurrency a nivel de esquema; de lo contrario, la clave __v integrada en Mongoose solo se usa para proteger ciertas operaciones con arrays, no cada guardado.
Por qué el estado correcto es 409 Conflict
Una discrepancia en versiones no constituye una falla del servidor. La API está funcionando correctamente y la solicitud está bien formada; simplemente hay un conflicto con el estado actual del recurso. 409 Conflict comunica exactamente eso y permite que el cliente reaccione de manera adecuada:
- recargar la versión más reciente;
- mostrar al usuario qué cambios se han producido desde que comenzó;
- permitirle combinar sus ediciones o intentarlo de nuevo;
- aplicar un manejo de conflictos específico para el producto.
Cualquiera que sea la acción que realice la interfaz de usuario, el principio es el mismo: nunca destruir el trabajo de otra persona sin avisar a nadie.
Transacciones: cuando varias escrituras deben tener éxito simultáneamente
Ahora considere realizar un pedido. Esto podría implicar crear el documento del pedido, reservar inventario y registrar información relacionada como pagos o entradas de auditoría. Si las dos primeras operaciones tienen éxito y la tercera falla, el sistema queda en un estado de proceso inconcluso.
Cuando varias operaciones deben completarse todas juntas o fallar todas, una transacción le brinda esa atomicidad. Conceptualmente, se inicia una transacción, se realizan las escrituras correspondientes y luego se confirma; si alguna de las etapas necesarias falla, se realiza un rollback y ninguna de las escrituras surte efecto. En MongoDB, las transacciones multi-documento requieren un conjunto de réplicas o un clúster shardado y se ejecutan a través de una sesión de cliente.
No obstante, las transacciones no constituyen una solución universal para los problemas relacionados con concurrencia. Cuestan más, pueden fallar debido a conflictos de escritura y requieren lógica de intentos repetidos. Úsalas cuando la operación empresarial realmente necesite consistencia de tipo todo-o-nada, y prefiere una sola actualización condicional cuando baste con ella.
Elegir la herramienta adecuada
En lugar de empezar preguntándote “¿deberíamos usar el bloqueo optimista?”, comienza con “¿qué tipo de fallo estamos intentando evitar?”:
- Cambios en un único campo o condicionales, como disminuir las existencias solo cuando están disponibles: utiliza una actualización atómica.
- Ediciones obsoletas por parte de usuarios que trabajan con datos antiguos, como dos administradores editando el mismo producto: utiliza el control de concurrencia optimista.
- Varias operaciones de escritura que deben tener éxito o fallar juntas, como cambios en pedidos, inventario y cuentas: utiliza una transacción.
Ningún patrón único sirve para todos los sistemas, y muchas implementaciones reales combinan dos de ellos.
Reproducir la carrera en pruebas
Una prueba en la que el usuario A actualiza un producto y recibe una respuesta de éxito no demuestra nada sobre la concurrencia. Las pruebas normales ejecutan las operaciones una tras otra, y precisamente por eso estos errores sobreviven a ellas. Es necesario crear la situación de carrera para poder probarla.
En el caso del inventario, comience con un stock establecido en 1 e inicie 100 intentos de compra simultáneamente, por ejemplo con Promise.all. El resultado esperado es que solo una reserva tenga éxito y las otras 99 sean rechazadas correctamente, dejando el stock en cero y no en un valor negativo.
Para el bloqueo optimista, establezca la versión en 10 y envíe varias actualizaciones que indiquen todas la versión 10. Debería ver que una tiene éxito y aumenta la versión, mientras que las demás reciben conflictos en lugar de sobrescribir los datos más recientes.
Qué vigilar en producción
Después del despliegue, haga que estas señales sean visibles en sus métricas y registros:
- respuestas
409 Conflict; - actualizaciones condicionales que no coincidieron con nada;
- reintentos y cancelaciones de transacciones;
- bloqueos y competencia por bloqueos;
- movimientos inesperados de inventario;
- operaciones duplicadas;
- otros errores relacionados con la concurrencia.
Un aumento repentino en los conflictos suele indicar algo más profundo: un registro de temperaturas elevadas, un patrón de tráfico inusual, clientes que intentan nuevamente las operaciones de forma demasiado agresiva, o una nueva función que genera más competencia de la esperada. En particular, las operaciones duplicadas suelen gestionarse mejor con claves de idempotencia, tema abordado en nuestra guía sobre endpoints POST idempotentes.
La concurrencia es el caso normal
Las situaciones de competencia no se deben realmente a que dos personas hagan clic al mismo tiempo. Surgen cada vez que varios actores pueden modificar un estado compartido: usuarios, instancias de API, trabajadores en segundo plano, consumidores de colas, tareas programadas, webhooks y otros servicios. A cualquier escala significativa, el acceso concurrente es algo habitual y no excepcional.
Por lo tanto, no se pregunte si dos solicitudes podrían llegar al mismo código al mismo tiempo; asuma que sí lo harán. Un hábito útil de revisión es preguntarse, para cada actualización, cuál sería el resultado si se ejecutaran dos copias de ella simultáneamente. Si el diseño responde a esto claramente, está en buenas condiciones. Si la respuesta honesta es “ojalá la segunda solicitud no cause problemas”, el código necesita ser revisado nuevamente.
Puntos clave
- Una actualización perdida ocurre cuando un cambio válido es sobrescrito por otro generado a partir de datos obsoletos, generalmente mediante un proceso de leer-modificar-escribir.
- Preferir actualizaciones atómicas y condicionales que incluyan la regla de negocio en el filtro.
- Utilizar un campo de versión y una respuesta
409 Conflictpara detectar ediciones obsoletas en lugar de sobrescribirlas. - Recurrir a transacciones solo cuando múltiples escrituras deben confirmarse o anularse juntas.
Lecturas relacionadas
- Seis patrones de integración para conectar servicios Node.js de forma confiable — Conozca los patrones fundamentales detrás de integraciones robustas en Node.js: solicitud-respuesta, polling, webhooks, clave API, autenticación JWT y OAuth, reintentos con retroceso temporal y mapeo de datos.
- Controlar la concurrencia en Node.js: Evitando colapsos de la API con p-map y Bottleneck — Aprenda cómo combinar p-map y Bottleneck en Node.js para prevenir errores de límite de velocidad y sobrecarga del sistema al controlar la concurrencia y el tiempo de las solicitudes.
- Construyendo tuberías de agregación de MongoDB con $match, $group y $lookup — Aprenda cómo las etapas de agregación de MongoDB filtran, agrupan, remodelan, unen y ordenan documentos, y cómo enlazarlas en una tubería que responda a preguntas reales de informes.