wandres.dev
CONTADORES · el CRDT más simple

El coste: el estado crece con las réplicas, no con las operaciones

Un contador que ha recibido millones de incrementos ocupa lo mismo que uno recién creado, pero cada dispositivo nuevo añade una casilla que ya no se puede quitar, y en local-first los dispositivos no se dan de baja.

⏱ 19 min

El contador que hemos construido tiene una propiedad de escalado poco común y muy valiosa: su tamaño es completamente independiente del uso. Un millón de incrementos no ocupan más que uno, porque lo único que cambia es el número guardado en una casilla. Eso lo distingue de cualquier registro de operaciones, que crece con el tráfico y obliga a compactar, y explica por qué es la estructura convergente más barata que existe. La factura llega por otro lado, y en local-first llega con una insistencia que sorprende a quien no la esperaba: el estado crece con el número de réplicas que han escrito alguna vez, ese número no baja nunca, y una réplica no es un usuario ni un dispositivo sino cada instancia de almacenamiento que ha existido, incluidas las que se borraron, se reinstalaron o se restauraron desde una copia. Esta lección cuantifica el coste con números reales, explica por qué podar una casilla es exactamente la clase de operación que la construcción prohíbe, y recorre las cuatro técnicas que sí funcionan.

🎯 Al terminar esta lección sabrás
  • Medir el tamaño real del estado y saber de qué variable depende y de cuál no.
  • Entender por qué la población de réplicas crece de forma monótona con el tiempo en un sistema local-first.
  • Ver por qué eliminar una casilla exige una coordinación equivalente a la de la lección anterior.
  • Aplicar deltas, identificadores cortos y transposición del estado para reducir el coste sin romper la convergencia.

El tamaño no depende del número de operaciones

Conviene empezar midiendo en lugar de estimando, porque la intuición sobre el tamaño de estas estructuras suele fallar en la dirección optimista. El estado serializado de un contador creciente es un objeto con una entrada por réplica que haya escrito, y cada entrada cuesta la longitud del identificador más la del número más los separadores del formato.

function tamanoBytes(g) {
  return new TextEncoder().encode(JSON.stringify(g)).length;
}

let g = crear();
for (let i = 0; i < 1_000_000; i++) g = incrementar(g, "ana");
tamanoBytes(g); // 15 bytes: un millon de operaciones no ocupan nada

let ancho = crear();
for (let i = 0; i < 1000; i++) ancho = incrementar(ancho, crypto.randomUUID());
tamanoBytes(ancho); // unos 41 000 bytes: mil replicas, un solo incremento cada una

La asimetría es brutal y conviene fijarla en una frase: el coste no lo pagan las operaciones, lo pagan los escritores. Un contador tocado por un millón de operaciones de tres dispositivos ocupa quince bytes; un contador tocado una sola vez por mil dispositivos ocupa cuarenta kilobytes. Y esos cuarenta kilobytes son por contador: un modelo con diez mil contadores tocados por esas mil réplicas supera los cuatrocientos megabytes de metadatos, sin un solo byte de contenido.

Hay un matiz que casi siempre se pasa por alto al hacer estas cuentas y que cambia el diagnóstico por completo. La casilla de una réplica solo existe si esa réplica escribió en ese contador concreto, de modo que el coste real no es el número total de réplicas del sistema sino el número de réplicas que han escrito ese dato. En un modelo típico esa cifra es pequeñísima: las notas de una persona las escriben sus tres dispositivos y nadie más. El caso patológico son los datos que todo el mundo toca —el contador de reacciones de una publicación popular, el número de descargas de un recurso compartido—, donde el ancho del mapa tiende al censo completo de la población.

💡
La pregunta correcta no es cuántas réplicas hay, sino cuántas escriben cada dato

Antes de dimensionar nada, recorre tu modelo y anota junto a cada dato convergente el número esperado de escritores distintos a lo largo de toda su vida. Casi todo tu modelo tendrá números de una cifra y no necesita ninguna optimización jamás. Un puñado de campos tendrá números de cuatro o cinco cifras, y son esos, y solo esos, los que hay que rediseñar. Confundir el tamaño de la población con el ancho de los mapas lleva a optimizar prematuramente el noventa y nueve por ciento del modelo y a no ver el uno por ciento que de verdad va a doler.

Por qué la población de réplicas no para de crecer

En un sistema con servidores, el número de réplicas es un parámetro de operaciones: se decide, se despliega y se cambia con una hoja de cálculo delante. En local-first es una consecuencia del éxito del producto, y crece por una vía que además no admite marcha atrás. Cada instancia de almacenamiento que haya escrito alguna vez deja su casilla para siempre, y una instancia de almacenamiento no coincide con nada que el negocio sepa contar.

flowchart TD
U[una persona] --> D1[portatil]
U --> D2[telefono]
U --> D3[tableta]
D1 --> N1[perfil de trabajo]
D1 --> N2[perfil personal]
D2 --> R1[reinstalacion tras cambiar de terminal]
D3 --> R2[almacenamiento desalojado por el navegador]
N1 --> C[una casilla permanente cada uno]
N2 --> C
R1 --> C
R2 --> C
style C fill:#f38ba8,color:#11111b

Las vías por las que aparece una casilla nueva son más de las que nadie enumera a la primera. Un dispositivo nuevo, evidentemente. Pero también un navegador distinto en el mismo ordenador, porque el almacenamiento está aislado por origen y por perfil. Una reinstalación de la aplicación, porque el identificador anterior se generó y se guardó junto a los datos que se acaban de borrar. Un desalojo de almacenamiento por presión de cuota, que en la práctica es una reinstalación silenciosa. Una restauración desde una copia de seguridad antigua, que reintroduce una casilla que llevaba meses inactiva. Y una sesión privada, que crea una réplica destinada a desaparecer en cuanto se cierre la ventana pero cuyos incrementos, si llegaron a propagarse, sobreviven a la ventana para siempre.

El resultado es que la variable de escalado del sistema no es el número de usuarios activos sino el número acumulado de instalaciones a lo largo de la historia del producto, que crece de forma monótona incluso cuando el producto pierde usuarios. Es una magnitud que ningún panel de métricas muestra, que nadie presupuesta y que aparece por primera vez como un informe de que la aplicación tarda ocho segundos en abrir en cuentas antiguas.

Podar una casilla es una operación que baja

La reacción evidente es eliminar las casillas de las réplicas que ya no existen, y la lección anterior explica por qué no es una operación inocente. Quitar una entrada del mapa hace que el estado descienda en el orden del retículo, y todo el edificio de la convergencia dependía de que las escrituras solo subieran. La consecuencia es inmediata y ya la hemos visto dos veces: cualquier réplica que aún conserve el estado antiguo lo reintroducirá en la siguiente fusión, porque el máximo entre la casilla ausente, que vale cero, y la casilla antigua devuelve la antigua. La poda no falla ruidosamente: se deshace sola, igual que la resta de la tercera lección.

Peor todavía es la variante que parece más lista, que consiste en fundir la casilla de una réplica retirada dentro de la de otra que sigue activa para conservar el total. Eso viola la propiedad de casilla, y su fallo es de la clase que aparece meses después: si un mensaje rezagado de la réplica retirada vuelve a aparecer, su casilla se restaura y su aportación queda contada dos veces, una en su propia entrada y otra dentro de la casilla donde se fundió. El total deja de cuadrar y no hay ninguna manera de saber por qué.

Podar correctamente exige, por tanto, exactamente lo que la lección anterior identificaba como caro: un acuerdo. Hace falta saber que la réplica no volverá a escribir, y que todas las demás réplicas han visto ya su aportación y no conservan ninguna copia anterior. Eso es una decisión global sobre un conjunto que no está cerrado bajo la fusión, es decir, coordinación con todas sus consecuencias. Se puede hacer, y algunos sistemas lo hacen con un protocolo explícito de retirada, pero conviene llamarlo por su nombre en lugar de tratarlo como una tarea de mantenimiento.

Lo que sí se puede hacer

Descartada la poda barata, quedan cuatro técnicas que funcionan, se combinan entre sí y no tocan la corrección. La primera ataca el coste de transmisión y no el de almacenamiento: en lugar de enviar el estado completo, se envía solo la casilla que ha cambiado, aprovechando que un delta es un estado parcial perfectamente válido y se fusiona con la misma función.

// Delta: la escritura devuelve la unica casilla que cambio
function incrementarConDelta(g, replica, cantidad = 1) {
  const total = (g[replica] ?? 0) + cantidad;
  return { estado: { ...g, [replica]: total }, delta: { [replica]: total } };
}

// Un delta se aplica con la fusion de siempre y varios se agrupan antes de enviar
const aplicarDelta = fusionar;
const agrupar = (deltas) => deltas.reduce((a, d) => fusionar(a, d), {});

La segunda ataca el tamaño del identificador. Un UUID en texto cuesta treinta y ocho bytes por casilla, más que el resto de la entrada junta, y en un mapa de mil réplicas es la mayor parte del estado. La solución que usan las implementaciones serias es sustituirlo por un entero aleatorio corto generado al crear el almacén: con identificadores de cincuenta y tres bits y del orden de mil réplicas por documento, la probabilidad de colisión está por debajo de una entre diez mil millones, y el estado se reduce a la mitad sin ningún cambio en el álgebra.

La tercera es la que más rinde en modelos con muchos contadores y consiste en transponer el estado. En lugar de un mapa de réplicas por cada contador, se guarda un mapa de réplicas cuyo valor es el conjunto de todos los contadores que esa réplica ha tocado. El identificador aparece una sola vez para todo el documento en lugar de una vez por contador, y el resultado sigue siendo un retículo porque el máximo se aplica igual, ahora anidado.

// Transponer: un registro por replica con todos sus contadores dentro
function fusionarTranspuesto(a, b) {
  const salida = { ...a };
  for (const r of Object.keys(b)) {
    const juntos = { ...(salida[r] ?? {}) };
    for (const clave of Object.keys(b[r])) {
      juntos[clave] = Math.max(juntos[clave] ?? 0, b[r][clave]);
    }
    salida[r] = juntos;
  }
  return salida;
}

function valorDe(estado, clave) {
  return Object.values(estado).reduce((s, c) => s + (c[clave] ?? 0), 0);
}

La cuarta es cambiar de familia cuando los números lo pidan. Si un dato lo escriben decenas de miles de réplicas y solo una o dos veces cada una, el estado por réplica es la representación equivocada y conviene pasar a una estructura basada en operaciones con transporte de entrega exactamente una vez, o sencillamente a un agregado calculado en un servidor que actúe como caché compartida. Reconocer ese punto de cruce es parte del diseño y no una derrota.

📦

Deltas

Reducen el tráfico de forma drástica sin tocar el álgebra, porque un delta es un estado parcial y se fusiona con la misma función.

🔖

Identificadores cortos

Un entero aleatorio en lugar de un UUID en texto divide el estado por cuatro con un riesgo de colisión despreciable a esta escala.

🔄

Transposición

El identificador aparece una vez por documento en lugar de una vez por contador. Es la mayor ganancia en modelos con muchos campos.

🚪

Cambiar de familia

Cuando los escritores se cuentan por decenas de miles, el estado por réplica es la representación equivocada y hay que dejarla.

La variable de escalado de local-first es la identidad, no el tráfico

Hay una inversión de modelo de costes escondida en esta lección que conviene sacar a la luz, porque reorganiza la forma de dimensionar cualquier sistema de este tipo y no aparece en ninguna comparativa de librerías. En la arquitectura que todos hemos aprendido, el coste escala con las operaciones: más tráfico significa más filas, más registro, más disco, más copias de seguridad, y el número de máquinas que escriben es un parámetro que se controla desde el otro lado. En un sistema local-first con estructuras convergentes basadas en estado, ese eje prácticamente desaparece —un millón de incrementos cuestan veinte bytes— y aparece otro en su lugar: el coste escala con la población de escritores identificados. Y ahí está la ironía que hay que mirar de frente, porque no es un detalle sino una tensión estructural del enfoque: la propuesta de valor entera de local-first consiste en multiplicar los escritores autónomos, dar a cada dispositivo de cada persona plena autoridad para escribir sin pedir permiso, y esa es exactamente la variable que hace crecer el estado. El éxito del producto es el mecanismo que lo hace pesado. Peor aún, se trata de una variable que solo sube, por el mismo motivo algebraico que ha gobernado todo el nivel: dar de baja una identidad es una operación que desciende en el retículo, de modo que la población relevante no es la de réplicas activas sino la de réplicas que han existido alguna vez, una magnitud que crece incluso mientras el producto pierde usuarios y que ningún panel de métricas muestra. Esa es la razón profunda por la que el problema abierto más difícil de todo este campo no es la fusión, que está resuelta y demostrada desde hace más de una década, sino la recolección de basura de los metadatos causales: cómo olvidar información sobre participantes retirados sin romper la garantía de convergencia frente a un rezagado que podría reaparecer dentro de dos años con una copia de seguridad de un teléfono que ya nadie recuerda. Todo lo que verás en el resto del track paga este mismo impuesto con nombres distintos, y merece la pena tener el mapa antes de empezar: las lápidas de los conjuntos son casillas que no se pueden borrar, el historial de las secuencias de texto es una casilla por carácter escrito alguna vez, los vectores de versiones del nivel veintidós son literalmente el mismo mapa, y cada avance importante de las implementaciones de producción de los últimos años ha consistido en comprimir metadatos y no en mejorar algoritmos de fusión. La conclusión operativa es que la conversación honesta sobre si un sistema local-first es viable no trata de si convergerá, porque convergerá, sino de cuánto ocupará su contabilidad de identidades dentro de cinco años con la tasa real de instalaciones del producto. Ese cálculo se hace con una multiplicación y una hoja de papel, se puede hacer hoy, y es lo que separa un prototipo que impresiona en una demostración de un sistema que sigue abriendo en menos de un segundo cuando lleva tres años en manos de gente. Que todo esto se pueda ver entero en la estructura más simple del campo —un mapa de números que se fusiona tomando máximos— es la mejor razón para haberle dedicado un nivel completo: el contador no es un ejercicio preliminar, es el campo en miniatura, con su solución, su límite y su factura.

⚔️ Mide tu contabilidad de identidades
  1. Serializa un contador con un millón de operaciones de tres réplicas y otro con una operación de mil réplicas, y compara los tamaños.
  2. Recorre tu modelo y anota junto a cada dato convergente el número esperado de escritores distintos durante toda su vida.
  3. Enumera todas las vías por las que tu aplicación puede generar un identificador de réplica nuevo y estima la tasa anual de cada una.
  4. Intenta podar la casilla de una réplica retirada, fusiona con una copia antigua y comprueba que la casilla reaparece intacta.
  5. Implementa la versión con deltas y mide la reducción de tráfico en una sesión real de sincronización.
  6. Transpone el estado de un documento con muchos contadores y compara el tamaño serializado con el de la disposición original.