La última escritura gana: cuándo basta y cuándo destruye datos
La política de resolución de conflictos más extendida del software no se eligió casi nunca de forma deliberada: es lo que ocurre por defecto cuando dos escrituras concurrentes caen sobre el mismo registro y nadie decidió nada. Esta lección somete esa política a examen serio, empezando por precisar qué significa exactamente el último cuando los relojes de dos máquinas no coinciden y el orden de llegada no es el orden de intención. Establece después el eje que decide si el defecto es aceptable o catastrófico, que no es la importancia del dato sino la diferencia entre reemplazar un valor completo y editar parcialmente una estructura compartida, y desarrolla las tres alternativas realistas: bloqueo optimista con versión, fusión a nivel de campo con marca de tiempo por celda y modelado por operaciones en lugar de por estados. Termina con el criterio de diseño que permite decidir sin ceremonia cuál de las tres corresponde a cada recurso.
La última escritura gana es la política de conflictos por omisión de la industria, y su ubicuidad no viene de que alguien la haya defendido sino de que es lo que pasa cuando nadie decide nada. Dos personas editan el mismo registro, las dos envían el objeto completo, el servidor aplica ambas en el orden en que las recibe y la segunda borra silenciosamente el trabajo de la primera. Nadie ve un error, no hay excepción, los registros de la aplicación están limpios y la métrica de fiabilidad no se inmuta. Simplemente hay un trabajo que existió y ya no existe, y la persona que lo hizo se enterará dentro de tres días, si es que se entera. Lo que hace este defecto especialmente interesante desde el punto de vista del diseño es que no siempre es un defecto: hay casos donde es la política correcta, barata y suficiente. La competencia profesional consiste en saber distinguir esos casos de los otros antes de que un cliente te enseñe la diferencia.
- Precisar qué significa la última escritura cuando ni el reloj ni el orden de llegada son fiables.
- Usar el eje de reemplazo total frente a edición parcial para decidir si la política por defecto es aceptable.
- Implementar bloqueo optimista con número de versión y tratar correctamente el conflicto en la interfaz.
- Comparar fusión por campo, marca de tiempo por celda y modelado por operaciones según el coste que imponen.
Qué significa exactamente ganar
La expresión esconde una ambigüedad que hay que deshacer antes de razonar. Ganar puede querer decir tres cosas distintas y ninguna coincide con la intuición de la persona que edita. Puede significar la que llegó la última al servidor, que depende de la latencia de cada cliente y por tanto de la calidad de su red. Puede significar la que lleva la marca de tiempo más alta, que depende de la sincronización de relojes entre máquinas que en la práctica derivan decenas o centenares de milisegundos. Y puede significar la que se comprometió la última en la base de datos, que depende del planificador de transacciones. En un sistema real las tres nociones producen resultados diferentes, y la persona que editó primero pero tiene mejor conexión puede perfectamente pisar a la que editó después.
// Escritura ciega: envia el objeto entero y no dice de que version parte.
await api.guardar({ id, titulo, descripcion, etiquetas, precio })
// Ana carga v7, cambia el precio.
// Bruno carga v7, cambia la descripcion.
// Ana guarda: el registro pasa a v8 con precio nuevo y descripcion vieja.
// Bruno guarda: el registro pasa a v9 con descripcion nueva y precio VIEJO.
// El cambio de Ana ha desaparecido y nadie ha visto un error.
La ambigüedad no es una curiosidad teórica: determina qué garantía puedes ofrecer y a quién. Si el criterio es el orden de llegada, tu política de conflictos depende de la calidad de la red de cada cliente, y estarás favorecendo sistemáticamente a los usuarios con mejor conexión, que es una forma involuntaria y bastante indefendible de discriminación técnica. Si el criterio es la marca de tiempo del cliente, dependes de relojes que ni están sincronizados ni son monótonos, porque un ajuste del sistema puede retroceder el reloj y hacer que una escritura posterior lleve una marca anterior. Y si el criterio es el instante de compromiso en la base de datos, tienes al menos una definición coherente, pero es una definición que ningún usuario puede predecir ni observar. Ninguna de las tres coincide con lo que la persona entiende por la última, que es siempre la que yo hice después de ver lo que había.
Hay una consecuencia estadística que suele pasar desapercibida y que explica por qué el defecto sobrevive a las pruebas manuales. La ventana de riesgo no es el instante del envío sino todo el tiempo que el formulario permanece abierto, que en una edición cuidadosa puede ser de varios minutos. Comparada con los cientos de milisegundos que dura la ventana de una carrera de lectura, la ventana de una escritura perdida es tres o cuatro órdenes de magnitud mayor, y por eso este fallo, siendo mucho menos frecuente por sesión, acaba ocurriendo con regularidad en cualquier producto con varios editores por recurso.
Ese ejemplo contiene la clave del asunto y conviene enunciarla sin rodeos: el daño no lo causa la concurrencia, lo causa que cada cliente envía el objeto completo cuando en realidad solo quiso cambiar un campo. Bruno no tenía intención alguna sobre el precio; su intención era la descripción. Al mandar el objeto entero, convirtió sin saberlo un valor que había leído en un valor que estaba afirmando, y afirmó el precio antiguo con la misma fuerza con la que afirmó su descripción nueva. El formulario que envía todos sus campos es, desde este punto de vista, un dispositivo que transforma lecturas en escrituras, y esa transformación es la que fabrica el conflicto donde no lo había.
Casi todos los fallos de un sistema tienen un síntoma inmediato: una excepción, un código de estado, un tiempo de espera agotado. La escritura perdida no tiene ninguno. Ambas peticiones devolvieron doscientos, ambos clientes mostraron confirmación, la base de datos está íntegra y todas las restricciones se cumplen. El único testigo del fallo es una persona que vuelve tres días después, encuentra el precio antiguo y concluye que la aplicación no guardó lo que escribió. Por eso este defecto no aparece jamás en un panel de errores y por eso hay que detectarlo por diseño en vez de por observación: en el momento en que decides que un recurso admite escrituras concurrentes, ya has decidido si vas a perder datos.
El eje que decide: reemplazar frente a editar
La pregunta útil no es si el dato es importante, porque casi todos lo son, sino cuál es la naturaleza de la operación que el usuario cree estar haciendo. Hay operaciones de reemplazo total, en las que el valor nuevo no guarda relación con el anterior y quien escribe tiene autoridad completa sobre él, y hay operaciones de edición parcial, en las que el valor nuevo se calculó a partir del que se leyó y por tanto depende de que ese punto de partida siga vigente. Para las primeras, la última escritura gana no solo es aceptable: es exactamente la semántica deseada. Para las segundas es una máquina de destruir trabajo.
Aceptable: preferencia personal
Tema claro u oscuro, idioma, densidad de la lista. Un solo autor, valor independiente del anterior, coste de perder una escritura prácticamente nulo. Versionar aquí es sobreingeniería.
Aceptable: telemetría y presencia
Última conexión, posición del cursor, estado en línea. El valor más reciente es el único que interesa y la historia carece de valor. La política por defecto es aquí el algoritmo correcto.
Peligroso: documento compartido
Descripción, notas, cualquier texto que varias personas editan. El texto nuevo es una función del viejo, y sustituirlo entero destruye trabajo ajeno de forma invisible.
Prohibido: acumuladores
Saldo, inventario, contador de asistentes. Leer, sumar y escribir desde dos clientes pierde una de las dos sumas. Esto no se versiona, se convierte en una operación atómica de incremento en el servidor.
// Tres formas de expresar el mismo cambio, con tres semanticas distintas.
// 1. Estado completo: afirma veinte valores cuando el usuario decidio uno.
await api.guardar(id, productoEntero)
// 2. Estado parcial: afirma solo lo decidido. El conflicto se estrecha
// a los casos en que dos personas tocan literalmente el mismo campo.
await api.parchear(id, { precio: 1290 })
// 3. Operacion: no afirma un valor, describe un cambio. Si conmuta,
// el orden deja de importar y el conflicto desaparece por completo.
await api.aplicar(id, { tipo: "incrementar", campo: "stock", delta: -1 })
El último caso merece un subrayado porque su remedio no es el mismo que el de los otros dos. Un acumulador no necesita detección de conflictos: necesita dejar de enviarse como estado. Si el cliente manda suma uno en lugar de mandar el valor once, la concurrencia deja de ser un problema porque las dos operaciones conmutan y el servidor puede aplicarlas en cualquier orden con el mismo resultado. Es el mismo giro conceptual que sostiene la familia de estructuras convergentes que se estudió en el nivel diecinueve, aplicado aquí a la escala mucho más humilde de un campo numérico, y es la solución más barata y más robusta de las tres que veremos.
Versión, marca de tiempo y operaciones
Antes de entrar en el remedio general conviene descartar el que casi todo el mundo propone primero, que es el bloqueo pesimista: reservar el registro al abrir el formulario y liberarlo al guardar. Funciona, es fácil de explicar y en aplicaciones internas de uso escaso puede ser la elección correcta. Sus tres costes, en cambio, son severos en cualquier contexto abierto. Exige un ciclo de vida del bloqueo, con lo que hay que decidir qué pasa cuando alguien cierra el portátil con el registro reservado, y toda respuesta a esa pregunta es un compromiso incómodo entre bloquear para siempre y liberar demasiado pronto. Convierte una operación que era de un solo viaje en tres, con lo que la latencia percibida empeora incluso cuando no hay nadie más editando. Y sobre todo penaliza el caso común, que es que nadie compita, para protegerse del caso raro, que es que alguien compita, lo cual es una mala apuesta salvo que la contención medida sea alta de verdad.
El bloqueo optimista es el remedio general para la edición parcial y consiste en algo tan simple como exigir que quien escribe declare de qué punto de partida viene. El servidor compara la versión declarada con la que tiene almacenada y rechaza la escritura si no coinciden. No hay bloqueo real, no hay espera, no hay recurso retenido: hay una condición de escritura. El coste es una columna entera y una rama de error nueva; el beneficio es que la escritura perdida deja de ser silenciosa y pasa a ser un conflicto explícito que alguien puede resolver.
// El cliente declara de que version parte.
const r = await api.guardar({ id, version: 7, campos: { precio: 1290 } })
if (r.status === 409) {
const actual = await api.leer(id) // trae la version 8
mostrarConflicto({ mia: borrador, suya: actual })
}
// La condicion vive en la escritura, no en un candado previo.
const sql = `
UPDATE producto SET precio = $1, version = version + 1
WHERE id = $2 AND version = $3
`
const filas = await db.ejecutar(sql, [precio, id, versionBase])
// cero filas afectadas significa conflicto:
// alguien escribio entre tu lectura y tu escritura
if (filas === 0) throw new Conflicto(id, versionBase)
Conviene notar que la condición viaja dentro de la propia sentencia de escritura y no en una comprobación previa, porque una comprobación previa seguida de una escritura vuelve a abrir exactamente la ventana que se quería cerrar: entre el momento de leer la versión y el de escribirla, otro cliente puede colarse. Ese detalle, que parece de estilo, es la diferencia entre una garantía y una ilusión de garantía, y su versión general aparece en cualquier lenguaje que ofrezca operaciones de comparar e intercambiar. Lo mismo vale para los documentos: la escritura condicional se expresa con una etiqueta de entidad y una cabecera de precondición, y también ahí la clave es que la condición y el efecto sean la misma operación indivisible ante el motor de almacenamiento.
Detectar el conflicto es la parte fácil; decidir qué hacer con él es donde se juega la calidad del producto. La respuesta perezosa consiste en mostrar un aviso que dice que el registro cambió y obligar a recargar, lo que traslada íntegro el coste a la persona y le hace perder su borrador. La respuesta correcta empieza por observar que la mayoría de los conflictos no son conflictos reales: si Ana tocó el precio y Bruno la descripción, los dos cambios son compatibles y una fusión por campo los integra sin preguntar nada. Solo cuando dos personas editan el mismo campo hace falta intervención humana, y entonces lo mínimo aceptable es mostrar ambos valores y dejar elegir, no descartar uno de los dos por decreto.
flowchart TD A[cliente envia cambio con version base] --> B[servidor compara con la version guardada] B -->|coinciden| C[aplica y sube la version] B -->|no coinciden| D[responde conflicto con el estado actual] D --> E[el cliente calcula que campos difieren de verdad] E -->|campos disjuntos| F[fusion automatica y reintento] E -->|mismo campo| G[pedir decision a la persona] style C fill:#a6e3a1,color:#11111b style G fill:#f9e2af,color:#11111b
Hay una decisión de producto escondida en ese punto que conviene tomar de forma explícita y no dejar al criterio de quien implemente la pantalla. Cuando dos personas editan el mismo campo, el sistema puede pedir que se elija, puede quedarse con la más reciente y avisar, o puede conservar ambas versiones y dejar la resolución para después. La primera opción es la más segura y la más molesta; la segunda es cómoda y admite pérdida; la tercera es la única que nunca destruye trabajo, y es la que emplean las herramientas que se toman en serio la colaboración, que guardan la versión desplazada en un historial recuperable en lugar de descartarla. Esa tercera vía cambia por completo la conversación con el usuario, porque permite decir que nada de lo que escribió se ha perdido, solo se ha movido, y esa afirmación es la que separa una herramienta en la que se confía de una en la que no.
Si tienes que elegir un mecanismo de detección, prefiere un contador entero que se incrementa en el servidor a cualquier cosa que dependa de un reloj. El contador es monótono por construcción, no deriva, no retrocede con un ajuste horario, no exige sincronización entre máquinas y produce comparaciones exactas en lugar de aproximadas. Además admite una lectura diagnóstica inmediata: la distancia entre la versión que trae el cliente y la que hay guardada te dice cuántas escrituras se colaron entre su lectura y su intento, y esa cifra es la mejor métrica disponible del grado de contención real de un recurso. Las marcas de tiempo solo son preferibles cuando necesitas fusionar por celda sin un punto central que ordene, y entonces lo correcto no es el reloj de pared sino un reloj lógico o híbrido.
Un matiz que ahorra rediseños: la unidad que se versiona debe coincidir con la unidad que el usuario percibe como una cosa. Si una entidad se almacena repartida en tres tablas y el formulario la edita como un todo, versionar cada tabla por separado producirá conflictos parciales que nadie sabrá interpretar y permitirá guardar media entidad. A la inversa, si una tabla contiene lo que en realidad son dos objetos independientes que se editan en pantallas distintas, una sola versión los acoplará y generará conflictos falsos entre personas que no se estorbaban. El criterio no es el esquema físico sino la frontera de edición, y coincide con la noción de unidad de coherencia que la última lección de este nivel convertirá en el primer punto del protocolo.
La marca de tiempo por celda es la tercera vía y ocupa un lugar intermedio. En lugar de versionar el registro entero se guarda para cada campo el instante de su última modificación, y al fusionar dos versiones se toma campo a campo el valor más reciente. Es lo que hacen por dentro varios motores de sincronización y bases de datos distribuidas, y tiene la virtud de no molestar nunca al usuario. Tiene también dos defectos que hay que aceptar con los ojos abiertos: depende de relojes que derivan, lo que exige relojes lógicos o híbridos si se quiere seriedad, y puede producir estados que ningún autor escribió jamás, mezclando la mitad de una edición con la mitad de otra. Para campos independientes es una bendición; para campos con invariantes entre ellos, como un descuento que debe ser menor que un precio, es una fuente de incoherencias.
Si se retiran los detalles de implementación, las tres alternativas de esta lección son la misma idea vista desde tres distancias, y esa idea es que un cliente debería enviar lo que quiso hacer y no el retrato completo del mundo tal y como lo vio hace treinta segundos. Un formulario que manda sus veinte campos está afirmando veinte valores cuando su autor solo decidió uno, y esos diecinueve valores parásitos son literalmente la materia prima de la que están hechas las escrituras perdidas. Reducir el envío al campo tocado no elimina el conflicto, pero lo estrecha hasta el punto en que la inmensa mayoría de los casos concurrentes se vuelven disjuntos y fusionables sin preguntar. Sustituir el valor por la operación, cuando la operación conmuta, lo elimina del todo. Añadir la versión no evita nada por sí misma, pero convierte una pérdida invisible en un evento observable, y en ingeniería la diferencia entre un fallo silencioso y un fallo ruidoso suele ser más valiosa que la diferencia entre fallar y no fallar, porque un fallo ruidoso se puede medir, priorizar y arreglar. De ahí el orden de preferencia que conviene aplicar sin dramatismo cada vez que se diseña una escritura: primero pregúntate si la operación conmuta, porque si conmuta no tienes un problema de concurrencia sino un problema de modelado que ya sabes resolver; si no conmuta, reduce el alcance de lo que envías hasta que solo queden los campos que el usuario decidió de verdad; y solo entonces, sobre esa superficie mínima, añade la versión que hará explícito lo poco que quede. Quien invierte ese orden acaba construyendo una elaborada maquinaria de detección de conflictos para conflictos que su propio formulario estaba fabricando.
- Reproduce la escritura perdida con dos pestañas sobre el mismo registro. Documenta que ninguna de las dos recibe error y que el sistema de observabilidad no registra nada.
- Añade una columna de versión y la condición en la sentencia de actualización. Comprueba que la segunda escritura afecta a cero filas y devuelve conflicto.
- Implementa la comparación de campos en el cliente y funde automáticamente cuando los conjuntos modificados sean disjuntos. Mide qué porcentaje de tus conflictos reales desaparece con esa sola regla.
- Reduce el cuerpo de la petición a los campos realmente tocados por el formulario y vuelve a medir la frecuencia de conflicto. Contrasta el resultado con el del punto anterior.
- Toma un contador de tu aplicación, convierte la escritura de valor en una operación de incremento en el servidor y demuestra con dos clientes simultáneos que ya no se pierde ninguna suma.
- Clasifica cinco recursos de tu dominio en las cuatro categorías de las tarjetas y escribe para cada uno una frase que justifique la política elegida. Guarda ese documento junto al esquema de datos.