El coste: la capa mutable que devuelve el problema del consenso
Si nada se puede actualizar en su sitio, hace falta una capa aparte que diga cuál es la versión actual, y esa capa mutable vuelve a traer coordinación, conflictos y todo aquello de lo que se había escapado.
Las tres lecciones anteriores han presentado un modelo que parece resolverlo casi todo: nombres autoverificables, inmutabilidad estructural, deduplicación gratuita y una red donde no hace falta confiar en nadie. Toca ahora el reverso, y no es un matiz menor sino la mitad del problema. Los datos reales cambian: un documento se edita, una lista crece, una configuración se sustituye. Si cada versión tiene un nombre distinto, alguien tiene que responder a la pregunta de cuál de todos esos nombres es el vigente ahora. Esa pregunta no se puede contestar con un resumen, porque su respuesta cambia con el tiempo y el resumen es precisamente lo que no cambia. Hace falta una capa aparte, mutable, que asocie un nombre estable con un nombre de contenido variable. Y en cuanto aparece esa capa reaparecen, íntegros, la coordinación, los conflictos de escritura concurrente y todas las dificultades del consenso distribuido.
- Entender por qué la actualización en el sitio es imposible por construcción y no por limitación de implementación.
- Reconocer la capa de nombres mutables como componente inevitable y saber qué formas adopta.
- Ver con precisión qué problemas de consenso reaparecen y por qué son los mismos de siempre.
- Aprender a colocar la frontera entre lo inmutable y lo mutable para minimizar la coordinación necesaria.
Nada se actualiza en su sitio, y eso tiene consecuencias
La imposibilidad es total y conviene enunciarla sin suavizarla. En un almacén por contenido no existe la operación de modificar: solo existe escribir algo nuevo, que produce un nombre nuevo. Editar un carácter de un documento de un megabyte no toca el documento antiguo, sino que crea un documento distinto con un nombre sin ninguna relación con el anterior. Nada en el almacén registra que el segundo sea sucesor del primero; para el almacén son dos bloques sin parentesco.
De ahí salen dos costes distintos que suelen confundirse. El primero es de espacio y es el más visible: si cada versión se guarda entera, la historia crece de forma cuadrática con el número de ediciones. Los sistemas serios lo mitigan con estructuras persistentes: el documento se representa como un árbol de bloques y una edición pequeña reescribe solo el camino desde la hoja modificada hasta la raíz, compartiendo todo lo demás con la versión anterior. El coste pasa a ser logarítmico y el problema deja de ser grave.
// Editar un arbol persistente: solo se reescribe el camino a la raiz
function editar(raiz, camino, valorNuevo) {
if (camino.length === 0) return guardar(valorNuevo);
const [paso, ...resto] = camino;
const hijoNuevo = editar(raiz.hijos[paso], resto, valorNuevo);
const hijos = { ...raiz.hijos, [paso]: hijoNuevo }; // los demas se comparten
return guardar(hijos); // nombre nuevo de la raiz
}
El segundo coste es el que importa de verdad y no se mitiga con ninguna estructura de datos: el nombre de la raíz cambia en cada edición, y por tanto todo el que quisiera seguir el documento tiene que enterarse del nombre nuevo. La referencia por contenido, que era una virtud porque no se rompía nunca, se convierte aquí en un inconveniente porque tampoco se actualiza nunca. Quien tenga un nombre antiguo lo seguirá resolviendo con éxito para siempre, y obtendrá una versión obsoleta sin ningún indicio de que lo sea.
Merece la pena insistir en lo desagradable que resulta ese modo de fallo comparado con el del nombrado por ubicación, porque es contraintuitivo. Cuando una ruta deja de existir, se recibe un error y se sabe que algo va mal. Cuando un nombre por contenido queda obsoleto, se recibe una respuesta perfecta, verificada y silenciosamente antigua. El sistema no tiene forma de distinguir a alguien que quiere consultar una versión histórica a propósito de alguien que cree estar leyendo lo último, porque la petición es literalmente la misma. La obsolescencia no es un fallo detectable dentro de esta capa, y ese es precisamente el hueco que la siguiente viene a tapar.
La capa que dice cuál es la versión actual
La solución es siempre la misma en todos los sistemas que han abordado el problema, y consiste en admitir una segunda capa de nombres que sí son mutables. Un nombre de esa capa no se calcula a partir de nada: se asigna, y su valor es un nombre de contenido que puede cambiar con el tiempo. El almacén sigue siendo inmutable y verificable; encima de él vive un pequeño diccionario mutable que apunta a la raíz vigente.
flowchart TB M[nombre mutable estable] --> R1[raiz version uno] M -.-> R2[raiz version dos] R2 --> B1[bloque compartido] R1 --> B1 R2 --> B2[bloque nuevo] subgraph inmutable R1 R2 B1 B2 end style M fill:#f38ba8,color:#11111b style B1 fill:#a6e3a1,color:#11111b
Ese diagrama es el plano de casi cualquier sistema de este tipo, y lo importante de él es la línea de puntos: es el único sitio donde algo cambia, y por tanto es el único sitio donde puede haber conflicto. Todo lo que hay dentro del recuadro es inmutable, replicable sin coordinación y verificable por cualquiera. Toda la dificultad se ha concentrado en una flecha.
Referencias de control de versiones
Una rama es un nombre que apunta a un identificador de confirmación y se mueve al avanzar. Los objetos no se tocan nunca.
Etiquetas de registros de artefactos
Una etiqueta legible apunta a un resumen de manifiesto, y puede reasignarse a otro sin que el manifiesto anterior cambie.
Registros de nombres firmados
Una clave pública publica, firmado, cuál es el nombre de contenido vigente para ella, con número de secuencia y caducidad.
Un simple diccionario en el servidor
La forma más común y menos vistosa: una tabla con una fila por documento y el nombre de su raíz actual.
Las cuatro variantes se parecen más de lo que sugieren sus nombres, y su parecido es instructivo. En las cuatro, el elemento mutable es minúsculo: unos pocos bytes por entrada. En las cuatro, el elemento mutable es lo único que necesita autoridad, ordenación o firma. Y en las cuatro, el sistema completo hereda de esa capa —y solo de ella— sus propiedades de consistencia, su disponibilidad y sus modos de fallo.
La tercera variante merece un desarrollo aparte porque es la única que funciona sin ningún servidor de confianza y es, por tanto, la que aparece en las arquitecturas locales. La idea es que el nombre estable sea una clave pública y que su valor se publique como un registro firmado por la clave privada correspondiente, con un número de secuencia creciente y una caducidad. Cualquiera puede comprobar la firma, cualquiera puede replicar el registro y cualquiera puede quedarse con el número de secuencia más alto que haya visto. No hace falta confiar en quien reparte el registro, solo en quien lo firma.
// Un puntero mutable autoverificable: firma, secuencia y caducidad
function publicar(clavePrivada, raiz, secuencia) {
const registro = { raiz, secuencia, expira: ahora() + 24 * 3600 };
return { registro, firma: firmar(clavePrivada, serializar(registro)) };
}
function aceptar(clavePublica, entrada, mejorVisto) {
if (!verificarFirma(clavePublica, entrada)) return mejorVisto;
if (entrada.registro.expira < ahora()) return mejorVisto;
return entrada.registro.secuencia > mejorVisto.secuencia
? entrada.registro : mejorVisto; // gana la secuencia mas alta
}
Ese esquema resuelve la autenticidad sin autoridad, pero conviene ver qué no resuelve, porque es exactamente el residuo del que trata la sección siguiente. No impide que alguien retenga el registro más reciente y sirva uno anterior que también está firmado y todavía no ha caducado, motivo por el cual la caducidad es imprescindible y su duración es un compromiso entre frescura y disponibilidad. Y no dice nada sobre qué ocurre si dos dispositivos con la misma clave publican secuencias iguales con raíces distintas, que es el caso de un usuario con un portátil y un teléfono editando sin conexión.
Por qué esa capa devuelve el problema del consenso
Aquí conviene ser preciso, porque el argumento se malinterpreta con facilidad en las dos direcciones. Lo que ocurre no es que el direccionamiento por contenido no sirva de nada, ni que haya resuelto el consenso. Lo que ocurre es que ha separado limpiamente el problema en dos partes de tamaño muy distinto, y la parte difícil sigue siendo difícil exactamente en la misma medida en que lo era.
Si dos escritores actualizan a la vez el mismo nombre mutable, hay que decidir qué significa eso, y todas las opciones son las de siempre. Se puede exigir un coordinador que serialice, y entonces se pierde disponibilidad cuando ese coordinador no está. Se puede aplicar la regla de que gana la última escritura, y entonces se pierde silenciosamente el trabajo del perdedor, además de necesitar un reloj en el que nadie confía del todo. Se puede permitir que el nombre apunte a varias raíces simultáneas y resolver después, que es lo que hace un control de versiones al declarar una divergencia. Se puede usar un registro replicado con quórum. O se puede restringir el problema hasta que desaparezca, dando a cada escritor su propio nombre mutable del que solo él escribe.
// Actualizar la capa mutable exige lo mismo que siempre: comparar e intercambiar
async function avanzar(ref, raizEsperada, raizNueva) {
const ok = await registro.compararEIntercambiar(ref, raizEsperada, raizNueva);
if (!ok) throw new Error('otro escritor se adelanto: hay que fusionar');
return raizNueva;
}
Conviene decirlo explícitamente porque es un error frecuente en diseños nuevos. Que los nombres de contenido sean únicos y verificables no ayuda en absoluto a decidir cuál de dos actualizaciones concurrentes debe prevalecer, porque esa es una pregunta sobre intención y sobre orden, no sobre integridad. Un sistema por contenido con una capa mutable mal diseñada pierde datos igual de bien que cualquier otro, con la particularidad amarga de que los datos perdidos siguen íntegros y verificables en el almacén, simplemente inalcanzables porque nadie apunta a ellos.
Dónde poner la frontera entre lo inmutable y lo mutable
Aceptado que la capa mutable es inevitable, la decisión de diseño que queda es de dónde pasa la frontera, y esa decisión determina cuánta coordinación necesita el sistema para funcionar. El principio operativo es sencillo de enunciar y difícil de aplicar: cuanto menos haya del lado mutable y cuantos menos escritores tenga cada entrada, menos consenso hace falta.
La primera palanca es el número de escritores por entrada. Un nombre mutable con un solo escritor legítimo no necesita consenso de ningún tipo: su dueño lo actualiza en orden y quien lo lee solo tiene que comprobar la firma y quedarse con el número de secuencia más alto que haya visto. Las arquitecturas locales suelen explotar esto dando a cada dispositivo o a cada usuario su propia raíz, de modo que la escritura nunca compite, y trasladando la reconciliación a la lectura, donde se combinan varias raíces con las técnicas de fusión de niveles anteriores.
La segunda palanca es la frecuencia. Cada actualización del puntero es un evento que hay que propagar; si el puntero cambia con cada pulsación de teclado, la capa mutable se convierte en el cuello de botella y en el generador de conflictos. Agrupar ediciones y publicar raíces con menos frecuencia reduce el tráfico de coordinación, a costa de aumentar la ventana en la que dos participantes ven estados distintos.
La tercera palanca es la granularidad, y es la que más a menudo se elige mal. Un único puntero para toda la aplicación convierte cualquier edición en un conflicto potencial con cualquier otra, aunque toquen documentos que no tienen nada que ver. Un puntero por documento, o por documento y por dispositivo, hace que dos personas trabajando en cosas distintas no se crucen jamás. La regla práctica es que la granularidad del puntero debe coincidir con la unidad sobre la que la gente edita de forma independiente, y descubrir cuál es esa unidad es una cuestión de dominio, no de infraestructura.
# El mismo patron, tres sistemas: mucho inmutable, un puntero minusculo
git update-ref refs/heads/main <resumen-de-confirmacion>
docker tag registro/app@sha256:<resumen> registro/app:produccion
nix-env --set /nix/store/<resumen>-perfil
Aquí está lo que hay que llevarse del nivel entero, y merece formularse con cuidado porque es fácil quedarse con la versión desilusionada. Alguien que siga el argumento hasta aquí puede concluir que todo el aparato ha sido un rodeo: se prometió un mundo sin coordinación y se acaba con un puntero mutable que necesita exactamente el mismo consenso que una fila de una base de datos. Esa lectura confunde eliminar un problema con reducir su superficie, y son cosas muy distintas en ingeniería. Antes del cambio, cualquier byte del sistema podía ser objeto de una escritura concurrente, y por tanto cualquier byte necesitaba en principio bloqueo, ordenación, resolución de conflictos y una historia que auditar. Después, el noventa y nueve coma nueve por ciento del volumen de datos es inmutable, se replica sin permiso, se cachea sin invalidación, se verifica sin autoridad y no puede entrar en conflicto con nada nunca; y toda la dificultad restante vive en unos pocos bytes por documento que se pueden tratar con la técnica más cara disponible precisamente porque son pocos. Ese es el patrón, y aparece muy lejos de aquí: no se resuelven los problemas difíciles, se les reduce el área hasta que resulte asequible atacarlos con la herramienta cara. Un compilador no elimina el estado mutable, lo confina a regiones declaradas. Un sistema de tipos no elimina los efectos, los concentra en las firmas que los admiten. Una arquitectura de eventos no elimina el estado, lo concentra en las proyecciones. En todos los casos el movimiento es idéntico y la métrica de éxito también: no es cuánta dificultad has hecho desaparecer, sino qué fracción del sistema queda fuera de su alcance. Y de ahí sale el criterio práctico con el que evaluar cualquier diseño de este tipo, que conviene aplicar sin piedad al propio: mide qué proporción de tus datos vive del lado inmutable y cuántos escritores tiene cada entrada del lado mutable. Si la respuesta es que casi todo es inmutable y casi todas las entradas tienen un solo escritor, tienes un sistema que funciona sin conexión y se sincroniza cuando puede. Si la respuesta es que hay un puntero global que todos actualizan, has construido una base de datos centralizada con pasos intermedios y habrás pagado la complejidad del direccionamiento por contenido sin cobrar su beneficio.
- Enumera las entidades de un sistema que conozcas y clasifica cada una como contenido inmutable o como puntero mutable.
- Cuenta cuántos escritores legítimos tiene cada puntero de tu lista y márcalos por encima de uno.
- Elige uno de los que tenga varios escritores y diseña cómo darle a cada uno su propia raíz, con fusión en la lectura.
- Estima el volumen de bytes a cada lado de la frontera y calcula la proporción.
- Simula una partición de red y anota qué operaciones siguen funcionando y cuáles se bloquean, y comprueba que solo las mutables.
- Escribe qué pasa en tu diseño cuando dos raíces concurrentes sobreviven, y quién decide y cuándo.