El peso oculto: un metadato por cada carácter que existió
Un CRDT de secuencia conserva un registro por cada carácter escrito alguna vez, incluidos los borrados, y esos metadatos acaban dominando la memoria residente y el tiempo de carga del documento.
Hasta aquí el track ha presentado los CRDT de secuencia como la respuesta madura al problema del texto colaborativo, y en el plano de la corrección lo son: convergen sin coordinación, toleran particiones arbitrarias y no necesitan un servidor que ordene nada. Pero hay una factura que esa corrección firma en silencio y que solo aparece cuando el documento deja de ser un ejemplo de laboratorio y pasa a ser un archivo con dos años de historia. La estructura no guarda el texto: guarda la genealogía completa de cada carácter que alguien tecleó alguna vez, incluidos los que ya nadie ve porque se borraron. Esa genealogía es exactamente lo que hace posible fusionar sin preguntar, y también lo que convierte un documento de cien kilobytes de texto en una estructura de decenas de megabytes que tarda segundos en abrirse. Esta lección diagnostica esa factura con precisión, porque el algoritmo del nivel no se entiende como una optimización sino como una respuesta directa a ella.
- Reconstruir por qué un CRDT de secuencia necesita identidad estable por elemento y no puede usar índices.
- Entender por qué los elementos borrados no se pueden eliminar y qué papel juega la lápida.
- Separar el coste de memoria residente del coste de carga, que tienen causas distintas.
- Formular el diagnóstico en términos de qué se guarda, no de qué tan bien está implementado.
Un identificador por cada carácter que existió
El punto de partida es una imposibilidad que ya conoces de niveles anteriores y que conviene volver a mirar con calma. Una posición numérica dentro de un texto no sobrevive a la concurrencia: si una réplica inserta en la posición cuarenta mientras otra borra el carácter diez, la posición cuarenta de la primera ya no designa el mismo hueco cuando la segunda aplica el cambio. La única forma de que dos réplicas hablen del mismo sitio sin coordinarse es dejar de nombrar el sitio por su índice y nombrarlo por la identidad de sus vecinos, identidades que ninguna edición posterior puede alterar.
De esa exigencia sale la forma canónica de estos algoritmos. Cada carácter insertado recibe un identificador único y global —típicamente la identidad del agente más un contador— y guarda, además del carácter, una referencia a aquello que lo situaba: el elemento a su izquierda, el elemento a su derecha, o el elemento del que desciende, según la familia. Esos punteros no son adorno: son la definición del orden, y el orden es lo único que garantiza que dos réplicas que han visto las mismas operaciones muestren el mismo texto.
La consecuencia que interesa aquí es de contabilidad, y conviene enunciarla sin rodeos: la unidad de identidad de un CRDT de secuencia es el carácter. No la edición, no la frase, no la sesión. El carácter. Un usuario que escribe durante una hora produce del orden de miles de identidades globales, cada una de las cuales tendrá que existir mientras exista el documento, y ese ritmo de generación es el que fija la pendiente de todo lo que viene después.
// Un elemento tipico de un CRDT de secuencia
const elemento = {
id: { agente: "a7", seq: 10432 }, // identidad global e inmutable
izquierda: { agente: "b1", seq: 88 }, // vecino en el momento de insertar
derecha: { agente: "a7", seq: 10431 },
contenido: "h", // un solo caracter
borrado: false, // lapida cuando pasa a true
};
La aritmética es la parte incómoda. El campo contenido ocupa un byte para texto latino; todo lo demás son identificadores de varios bytes cada uno, más los punteros de la estructura que los sostiene en memoria. La relación entre carga útil y contabilidad no es de un uno por ciento ni de un diez: es una inversión completa, con el metadato ocupando la mayor parte y el texto siendo casi un residuo. Y esta desproporción no depende de la habilidad del implementador, porque no viene del código sino de lo que el algoritmo necesita para funcionar.
Contabilidad aproximada de un elemento sin comprimir
contenido ................ 1 byte
identidad propia ......... agente mas contador
vecino izquierdo ......... agente mas contador
vecino derecho ........... agente mas contador
bandera de borrado ....... 1 bit, redondeado a byte
enlaces de la estructura . punteros del arbol o de la lista
el caracter es la parte mas pequena por un margen amplio
Conviene subrayar que esta cuenta no describe una implementación concreta sino una familia entera. Cambian los detalles —hay familias que guardan un solo vecino en lugar de dos, otras que sustituyen los punteros por un identificador de origen y una profundidad en un árbol— y no cambia la conclusión, porque todas necesitan lo mismo: una identidad que nadie pueda reasignar y una forma de expresar el orden relativo que no dependa de posiciones. Ese requisito es el que fija el suelo del coste, y ninguna elección de representación lo baja por debajo de él.
Ninguna biblioteca real guarda un objeto por carácter. Cuando alguien escribe una frase seguida, todos esos caracteres comparten agente, llevan contadores consecutivos y están colocados uno tras otro, de modo que la estructura puede representarlos como un único tramo con un contador de longitud. Esa codificación por tramos es la diferencia entre lo inviable y lo aceptable, y explica por qué escribir texto nuevo es barato. Lo que no arregla es el caso contrario: cuando alguien vuelve sobre lo escrito y edita en medio, cada intervención parte un tramo en dos, y un documento con años de revisiones se fragmenta hasta que los tramos vuelven a tener longitud pequeña.
La lápida que nadie puede tirar
El segundo componente del problema es más sutil y es el que sorprende a quien viene de estructuras convencionales. Cuando el usuario borra un carácter, el elemento no desaparece de la estructura: se marca. Sigue ahí, con su identificador, sus punteros y su bandera de borrado, ocupando espacio para siempre. A ese elemento marcado se le llama lápida, y su permanencia no es una pereza de implementación sino una necesidad estructural.
Conviene resistir el impulso de tratar esto como un defecto corregible, porque no lo es dentro de este marco. La lápida es la forma que tiene el algoritmo de decir que la posición sigue existiendo aunque su contenido ya no se muestre, y esa distinción entre posición y contenido es justamente lo que permite fusionar sin coordinación.
La razón está en la concurrencia. Otra réplica puede haber insertado un carácter cuya posición se define en relación con el elemento que acabas de borrar. Si eliminas el elemento de verdad, esa inserción que llega tarde se queda sin ancla y no hay forma de colocarla; el algoritmo pierde la propiedad que lo justificaba. Peor todavía: sin la lápida, una réplica no puede distinguir entre un elemento que borró y un elemento que nunca llegó a recibir, y esa ambigüedad rompe la convergencia. La lápida es la prueba documental de que algo existió, y el algoritmo la necesita para siempre porque nunca sabe si queda alguien que aún hable de ella.
Documento de un ensayo tras dos anos de trabajo
texto visible ................ 120 000 caracteres
caracteres insertados alguna vez 900 000
caracteres borrados y aun presentes 780 000
proporcion de lapidas ............ cerca del 87 por ciento
Ese perfil no es un caso extremo inventado para asustar: es el perfil normal de la escritura humana. Nadie redacta en una sola pasada. Se escribe una frase, se borra media, se reescribe, se mueve un párrafo, se recortan tres páginas de una versión anterior. El texto final es la punta visible de un volumen de ediciones que puede ser cinco o diez veces mayor, y el CRDT conserva todo ese volumen porque conserva el hecho de que cada uno de esos caracteres ocupó una posición concreta en un momento concreto.
Hay además un agravante que las cuentas anteriores no recogen y que empeora con el tiempo de vida del documento. Las lápidas no solo ocupan: fragmentan. Un tramo comprimido que representaba doscientos caracteres consecutivos deja de poder representarse como un tramo en cuanto alguien borra el carácter número cien, porque la bandera de borrado ya no es uniforme a lo largo del tramo. Cada intervención en medio de texto antiguo parte una unidad de compresión en dos, y la escritura real consiste precisamente en intervenir una y otra vez sobre lo ya escrito. La estructura se degrada, entonces, exactamente en proporción al cuidado con que se ha revisado el documento, que es una propiedad perversa: el texto mejor trabajado es el que peor se comporta.
Se entiende así por qué las estrategias de mitigación habituales no resuelven el problema de fondo. Comprimir el formato en disco reduce lo que ocupa el archivo pero no lo que ocupa la estructura viva ni el trabajo de levantarla. Recolectar lápidas exige un acuerdo global sobre qué ha visto todo el mundo, y ese acuerdo es justo lo que un sistema sin autoridad central no tiene. Truncar la historia funciona y es lo que muchos equipos acaban haciendo, pero es tirar información real a cambio de seguir arrancando en un tiempo aceptable, y quien lo hace suele descubrir tarde que había funciones de producto —comparar versiones, atribuir autoría, deshacer más allá de la sesión— que dependían de lo que acaba de borrar.
flowchart LR V[texto visible que ve el usuario] --> S[estructura del CRDT en memoria] H[historia completa de inserciones y borrados] --> S S --> E[millones de elementos con id vecinos y lapida] E --> C1[memoria residente dominada por el metadato] E --> C2[carga desde disco proporcional a la historia] style E fill:#f38ba8,color:#11111b style C1 fill:#f9e2af,color:#11111b style C2 fill:#f9e2af,color:#11111b
Dos costes distintos que conviene no confundir
El diagrama separa dos consecuencias que suelen mencionarse juntas y que tienen causas diferentes, hasta el punto de que una puede mejorarse sin tocar la otra. Distinguirlas es lo que permite entender qué ataca exactamente el algoritmo de este nivel.
El primero es la memoria residente en estado estable, es decir, cuánta RAM ocupa el documento simplemente por estar abierto mientras el usuario escribe. Aquí el problema es que la estructura viva debe mantener todos los elementos, incluidas las lápidas, porque cualquiera de ellos puede ser el ancla de una operación que llegue en el próximo segundo. No hay poda posible sin acordar con todos los participantes que ya nadie va a referirse a ellos, y ese acuerdo es precisamente lo que un sistema sin servidor central no puede dar por supuesto.
El segundo es el tiempo de carga desde disco, y su causa es distinta: no es cuánto ocupa, sino cuánto trabajo hay que hacer para reconstruirlo. Abrir un documento no consiste en leer bytes, sino en volver a levantar la estructura de índices que permite responder a la pregunta cuál es el elemento en la posición mil. Ese trabajo crece con el número total de elementos que existieron, no con el tamaño del texto visible, y por eso un documento pequeño con historia larga se abre despacio.
Uno y otro se manifiestan en momentos distintos y responden a palancas distintas, y por eso conviene medirlos por separado desde el principio. La memoria residente se cobra de forma continua y depende sobre todo de cuánta historia acumulada tenga el documento; se puede aliviar con codificaciones más compactas, y esa vía tiene rendimientos decrecientes pero reales. El tiempo de carga se cobra en un instante concreto y depende de cuánto trabajo de reconstrucción haya que hacer; se puede aliviar guardando instantáneas del estado ya materializado, y esa vía introduce a su vez el problema de mantener las instantáneas al día y decidir cada cuánto se toman. Que las dos mitigaciones sean tan distintas es la mejor señal de que se trata de dos problemas y no de uno.
Un tercer coste, menos citado pero muy visible en aplicaciones que sincronizan a menudo, es el ancho de banda del intercambio inicial. Cuando un dispositivo nuevo se une, recibe la estructura completa y no el texto, de modo que la transferencia también es proporcional a la historia y no a lo que el usuario va a ver. En redes móviles ese detalle decide si abrir un documento en el teléfono es viable o no, y suele descubrirse en producción porque el desarrollo ocurre siempre sobre red local.
Identidad por elemento
Ineludible: sin identificadores estables no hay forma de nombrar una posición que sobreviva a la edición concurrente.
Lápidas permanentes
Ineludible mientras alguien pueda llegar tarde: sin ellas se pierde el ancla de las inserciones y la convergencia se rompe.
Memoria en reposo
Se paga aunque nadie edite ni sincronice, porque la estructura viva no puede prescindir de nada.
Carga desde disco
Se paga en cada apertura y crece con la historia total, no con el texto que el usuario acabará viendo.
La tercera tarjeta y la cuarta describen el mismo dato desde dos ángulos y por eso se confunden con frecuencia, pero se comportan de forma independiente. Una implementación puede reducir mucho la memoria residente con una codificación agresiva y no mejorar nada el tiempo de apertura, porque descomprimir es trabajo adicional. Y al revés: guardar una instantánea del estado materializado acelera la apertura y no baja ni un byte la memoria en reposo. Cualquier evaluación que dé una sola cifra está mezclando dos ejes que responden a palancas opuestas.
Merece la pena subrayar dónde duele cada uno, porque el usuario los percibe de forma muy distinta. La memoria residente es un impuesto silencioso: se nota en dispositivos modestos, en pestañas que el sistema operativo descarta y en aplicaciones que abren varios documentos a la vez. El tiempo de carga, en cambio, es visible y se cobra en el peor momento posible, que es justo cuando alguien quiere empezar a trabajar. Una aplicación local-first se vende con la promesa de que abrir un documento es instantáneo porque no hay red de por medio; que tarde igual que una aplicación en la nube destruye esa promesa aunque la red no tenga nada que ver.
La medición que casi todo el mundo hace primero consiste en aplicar cien mil inserciones seguidas de un solo autor y comprobar que el resultado es rápido. Lo es, y no significa nada: ese caso es el mejor posible para la codificación por tramos, no genera lápidas y no ejercita ninguna concurrencia. El perfil que hay que medir es el que se parece a la realidad: historia larga, muchos borrados intercalados, ramas que se separan durante horas o días y luego se fusionan. Si tu medición no incluye esos tres elementos, no está midiendo el coste del que trata esta lección.
Dónde estaba el error de encuadre
Con el diagnóstico completo se puede formular la pregunta correcta, que no es cómo hacer que el CRDT ocupe menos. Durante años la comunidad trató el problema como una cuestión de representación: comprimir mejor los identificadores, agrupar tramos de forma más agresiva, elegir estructuras de índice más compactas. Todo eso funciona y todo eso importa, pero ninguna de esas mejoras toca la premisa que genera el coste, que es que la estructura de metadatos se considera el documento y por tanto tiene que estar viva y completa en todo momento.
Fíjate en la forma exacta de esa premisa, porque es donde está el punto de apoyo. Nadie decidió nunca que los metadatos fueran el documento; simplemente resultó que la estructura que permitía fusionar era también la que permitía leer el texto, y al ser la misma nunca hizo falta preguntarse si debía serlo. La fusión y la lectura acabaron compartiendo representación por conveniencia histórica, y esa coincidencia se solidificó hasta parecer una necesidad. Todo el nivel consiste en deshacerla.
Conviene detenerse en la confusión de categorías que sostiene todo el problema, porque una vez vista ya no se puede dejar de ver. Los identificadores, los punteros a vecinos y las lápidas no son información sobre el texto: son el andamio que el algoritmo necesitó para colocar correctamente cada carácter frente a las ediciones concurrentes que había en vuelo en ese momento. Ese andamio tiene una utilidad extraordinaria mientras hay concurrencia real que resolver, y exactamente cero utilidad cuando ya no la hay. Si una inserción se hizo hace catorce meses, todas las réplicas la han visto, nadie va a producir una operación concurrente con ella nunca más y su posición relativa está decidida para siempre, entonces el andamio que la colocó no está sosteniendo nada. Sigue en memoria por una razón puramente estructural: porque el algoritmo no separó jamás el hecho de que ocurrió una edición del aparato que hizo falta para integrarla, y al no separarlos se ve obligado a conservar el segundo con la misma permanencia que el primero. Aquí está el error de encuadre, y es un error de diseño de datos, no de rendimiento. El hecho es permanente; el andamio es circunstancial. Un CRDT de secuencia convencional los funde en una sola estructura y por eso paga la permanencia de ambos. La consecuencia práctica de reconocer esa distinción es enorme, porque abre una posibilidad que la formulación anterior ni siquiera dejaba enunciar: si el andamio se puede reconstruir a partir de los hechos, entonces no hace falta guardarlo, hace falta poder generarlo bajo demanda y tirarlo cuando la fusión ha terminado. La memoria en estado estable pasaría a ser proporcional al texto visible y no a la historia; la carga desde disco dejaría de ser una reconstrucción de la estructura entera y pasaría a ser la lectura de una lista; y el coste del andamio se cobraría únicamente durante la fusión, que es el único momento en que sirve para algo. Eso es exactamente lo que hace el algoritmo de las próximas cuatro lecciones, y por eso su contribución no se describe bien como una optimización: es una redefinición de qué merece ser persistido.
- Toma un documento real con historia larga —un capítulo, un informe, un artículo— y cuenta sus caracteres visibles.
- Estima cuántos caracteres se insertaron alguna vez durante su redacción y calcula la proporción de lápidas resultante.
- Carga ese documento en la biblioteca de CRDT que uses y mide la memoria residente antes y después de abrirlo.
- Mide el tiempo de carga desde disco y compáralo con el tiempo de leer el mismo texto como archivo plano.
- Repite las dos mediciones con un documento escrito de un tirón y del mismo tamaño visible, y explica la diferencia.
- Escribe en dos líneas cuál de los dos costes te afecta más en tu producto y por qué, antes de seguir con la lección siguiente.