¿Cómo resuelves conflictos de sincronización: last-write-wins vs CRDTs vs fusión manual?
Dos dispositivos editan el mismo registro sin conexión — la ingeniería interesante empieza en el momento en que ambos vuelven a estar en línea y no coinciden.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
¿Cómo resuelves conflictos de sincronización: last-write-wins vs CRDTs vs fusión manual?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Qué es realmente un conflicto
Dada la forma offline-first de la lección anterior, un conflicto ocurre cuando dos escritores — digamos, un teléfono y una tablet con la misma cuenta iniciada — hacen cada uno una escritura local optimista sobre el mismo registro mientras ambos están sin conexión o aislados entre sí, y solo descubren la colisión cuando ambas mutaciones llegan al servidor (o cuando se descarga la versión del servidor y no coincide con lo que el dispositivo esperaba). La pregunta no es si ocurrirán conflictos — con escritores concurrentes y redes poco confiables, ocurrirán — sino qué hace tu función de fusión cuando pasan.
#Last-write-wins: simple, y fácil de hacer mal
El arreglo ingenuo es etiquetar cada escritura con una marca de tiempo y dejar que gane la más alta. Funciona genuinamente bien para una enorme porción de casos reales — un usuario editando su propio nombre visible en un dispositivo rara vez choca con algo. Pero esa marca de tiempo tiene que venir de algún lugar confiable: el reloj de pared del dispositivo se desvía, y un teléfono con el reloj cinco minutos adelantado sobreescribirá silenciosamente la edición más reciente de un colega y nunca se lo dirá a nadie. El arreglo que usan la mayoría de los sistemas en producción es una versión monótona asignada por el servidor en lugar de la hora del dispositivo — el servidor, no el teléfono, decide el orden.
data class Note(val id: String,val body: String,val serverVersion: Long)/*** El servidor es la única fuente de verdad sobre "cuál escritura es más nueva"* — nunca confíes en el reloj de pared del dispositivo para esta comparación.*/fun resolveLastWriteWins(local: Note, remote: Note): Note =if (remote.serverVersion >= local.serverVersion) remote else local
Incluso hecho correctamente, el verdadero costo de LWW no es el algoritmo — es que descarta silenciosamente la edición de un usuario. No hay notificación, ni historial, ni oportunidad de recuperar la escritura perdedora a menos que la guardes por separado en un log de deshacer.
#Vectores de versión: saber cuándo puedes siquiera confiar en LWW
Un único número de versión te dice "más nuevo o más viejo" pero no "concurrente". Dos dispositivos pueden cada uno subir un registro de la versión 3 a la 4 de forma independiente — mismo número de versión, contenido genuinamente distinto, y ninguno deriva causalmente del otro. Un vector de versión (un contador por dispositivo/réplica) te permite distinguir "B siguió causalmente a A, es seguro simplemente tomar B" de "A y B son concurrentes, necesitas una política de fusión real" — que es precisamente la señal de la que dependen tanto los CRDTs como la fusión manual para saber cuándo hacen falta.
#CRDTs: funciones de fusión matemáticamente garantizadas para converger
Un CRDT (Conflict-free Replicated Data Type) es una estructura de datos cuya operación de fusión es conmutativa, asociativa e idempotente — lo cual significa que dos réplicas cualesquiera que hayan visto el mismo conjunto de actualizaciones, en cualquier orden, convergen automáticamente al mismo valor, sin coordinación y sin arbitraje del servidor. El más simple y útil es un G-Counter (contador de solo incremento): cada réplica mantiene su propio conteo de incrementos, y fusionar dos réplicas es solo un máximo elemento a elemento.
data class GCounter(val counts: Map<String, Long> = emptyMap()) {fun increment(replicaId: String): GCounter =copy(counts = counts + (replicaId to (counts[replicaId] ?: 0L) + 1L))fun value(): Long = counts.values.sum()fun merge(other: GCounter): GCounter {val keys = counts.keys + other.counts.keysval merged = keys.associateWith { key ->maxOf(counts[key] ?: 0L, other.counts[key] ?: 0L)}return GCounter(merged)}}
Dos teléfonos pueden incrementar cada uno un "contador de likes" sin conexión, fusionarse en cualquier orden, en cualquier dispositivo, y llegar al mismo total con cero conflictos — porque la fusión nunca tiene que elegir un ganador, solo toma el máximo por réplica. Esto se generaliza a PN-Counters (incremento y decremento), OR-Sets (agregar/quitar con tombstones para que un remove no sea resucitado por un add tardío) y LWW-Registers (un envoltorio CRDT sobre el enfoque de marca de tiempo anterior, hecho seguro añadiendo un ID de réplica como desempate). Los editores de texto colaborativos reales — el propio editor colaborativo basado en Yjs de este mismo codebase es un ejemplo funcionando — usan CRDTs exactamente por esta razón: cada pulsación de tecla de cada participante se fusiona de forma determinista sin un bloqueo central.
#Fusión manual: cuando el dato es intención, no un valor
Los CRDTs funcionan porque las operaciones tienen semántica de fusión bien definida — los contadores suman, los conjuntos se unen. No ayudan cuando dos ediciones representan intenciones humanas conflictivas que ningún algoritmo puede arbitrar: un usuario cambió la dirección de envío en su teléfono a 'trabajo' mientras la cambiaba en su laptop a 'casa de mamá'. Ninguna es más correcta. Aquí el movimiento honesto es mostrarle ambas versiones al usuario — una pantalla de conflicto, similar en espíritu a los marcadores de conflicto de Git — y dejar que elija o fusione a mano, en lugar de adivinar en silencio y enviar el paquete al lugar equivocado.
🧠 Check your understanding
0/1 · 0/1 answered1. ¿Por qué un único número de versión (a diferencia de un vector de versión) no siempre puede decirte si dos ediciones son concurrentes o si una sigue causalmente a la otra?