Saltar al contenido principal

El techo de memoria: por qué un nodo de Zaris nunca crece sin límite

· 10 min de lectura
Clustron Team
Ingeniería de Sistemas Distribuidos

Un nodo de Zaris manteniendo el uso de memoria en una banda tranquila por debajo de su techo, expulsando al 80% hasta el 70% para que las escrituras nunca crucen el límite

Aquí hay una pregunta que suena simple y no lo es: ¿qué ocurre cuando un almacén en memoria se queda sin memoria?

La respuesta ingenua es "se cae" —el proceso trepa hasta que el sistema operativo lo mata. La respuesta un poco menos ingenua es "rechaza escrituras". Zaris no hace ninguna de las dos por defecto. Un nodo de Zaris es un almacén en memoria, pero deliberadamente no es uno ilimitado: cada nodo es un buffer acotado con un techo duro, y cuando los datos que posee se acercan a ese techo el nodo expulsa sus datos más fríos para hacer espacio. Las escrituras siguen teniendo éxito; el límite se sostiene; el proceso no se hincha. El nodo se comporta como una caché, porque por debajo, lo es.

Cada nodo es un buffer acotado​

Un nodo de Zaris no crece sin límite. Cada nodo contabiliza continuamente el tamaño de los datos que posee activamente y mantiene ese total corriente por debajo de un techo configurado. La contabilidad nunca es opcional —un nodo es siempre un buffer acotado. Lo único que configuras es qué tan grande es el techo y, en principio, qué hace el nodo cuando lo alcanza.

El techo por defecto es 1 GiB de datos poseídos activamente por nodo. No hay, por diseño, un ajuste "ilimitado". Si no estableces un techo, obtienes el valor por defecto —nunca obtienes accidentalmente el infinito.

Una sutileza importa para cómo razonas sobre la huella de un clúster: el techo cubre los datos que un nodo posee activamente —las copias primarias que sirve a los clientes. Las copias de réplica que un nodo sostiene para otras particiones se rastrean por separado y no cuentan contra el techo de propietario activo. Así que la memoria residente real de un nodo es su working set poseído más cualesquiera copias de réplica que lleve para sus pares; el techo gobierna lo primero, no la suma.

Dos formas de mantener la línea​

Hay exactamente dos cosas que un buffer acotado puede hacer cuando una escritura lo empujaría más allá de su techo: descartar algo viejo para hacer espacio, o rechazar la nueva escritura.

ModoComportamiento en el techoUna escritura que lo excedería
Expulsión habilitada (por defecto)Expulsa datos más viejos para hacer espacioAdmitida — se descartan los datos más fríos
Expulsión deshabilitadaRechaza datos nuevosRechazada con KvStatus.CapacityExceeded

Con la expulsión activada, un nodo es una caché: bajo presión descarta los datos menos valiosos para que las escrituras sigan fluyendo. Con la expulsión desactivada, es un almacén de rechazo-en-capacidad que protege los datos existentes rechazando las nuevas escrituras.

Aquí está la parte honesta. En la versión actual un nodo siempre se ejecuta con la expulsión habilitada, usando la política LRU. El modo sin-expulsión y las políticas alternativas (LFU, consciente de TTL) existen en el motor, pero todavía no son seleccionables mediante configuración. La consecuencia práctica es que CapacityExceeded —el estado de rechazo-en-capacidad— es esencialmente inalcanzable hoy: un nodo lleno expulsa en lugar de rechazar. Es un estado real y tipado contra el que puedes escribir código, pero bajo la política LRU siempre activa normalmente no lo verás. La única perilla de memoria que realmente controlas ahora mismo es el tamaño del propio techo, memory.maxSizeBytes.

La corrida de expulsión monta una banda tranquila​

La ingeniería interesante no es que un nodo expulse —es cómo, porque un diseño ingenuo de "expulsar en cada escritura una vez que estás lleno" se descontrola miserablemente. Imagina un nodo sentado exactamente en su techo: cada escritura entrante dispararía una expulsión para hacer espacio para sí misma, y pagarías ese costo en la ruta caliente para siempre.

Zaris evita eso con dos movimientos. Primero, la expulsión la impulsa un bucle en segundo plano, no la escritura individual —la escritura que empuja a un nodo por encima del borde se admite, y el bucle recupera espacio poco después, fuera de la ruta de solicitud. Segundo, el bucle usa histéresis: un disparador alto y un objetivo más bajo, de modo que recupera en ráfagas y luego se queda tranquilo en lugar de dispararse continuamente en el borde.

  • Disparador: una corrida comienza cuando el uso activo sube por encima del 80% del techo.
  • Objetivo: una vez iniciada, la corrida sigue expulsando hasta que aproximadamente el 30% del techo esté libre de nuevo —hasta que el uso caiga de vuelta a alrededor del 70%.
  • Cadencia: el bucle despierta en un intervalo fijo (un segundo por defecto) y muestrea un conjunto acotado de ítems candidatos cada ciclo.

Como el objetivo post-expulsión (70%) se sitúa estrictamente por debajo del disparador (80%), cada corrida deja caer el uso a una banda tranquila en lugar de detenerse justo en el punto que la re-dispararía de inmediato. Ese es el diente de sierra de la imagen principal: el uso trepa al 80%, una corrida lo limpia de vuelta al 70%, y trepa de nuevo —nunca presionado contra el techo, nunca descontrolándose con una expulsión por escritura.

La política de expulsión en sí es LRU —least recently used (menos usado recientemente)— y es muestreada, no estricta. En lugar de mantener una lista ordenada globalmente de cada clave (que costaría memoria y contención de locks en la ruta caliente), Zaris estampa señales aproximadas de recencia en el lugar, en la ruta de lectura y, cuando una corrida se dispara, muestrea un pequeño conjunto de candidatos y descarta los de menor puntuación. Es el mismo enfoque muestreado que usa Redis, y el mismo compromiso: renuncias al orden perfecto de "expulsar la única clave más vieja" a cambio de una expulsión que cuesta casi nada mantener y nunca necesita un índice separado.

El otro techo: tamaño por ítem​

El techo de memoria general es un límite sobre el total. Hay un segundo límite independiente sobre cualquier ítem individual: por defecto un valor no puede exceder 4 MiB. Esto se comprueba en cada escritura sin importar el modo de expulsión, y es donde una escritura alcanza un rechazo genuinamente alcanzable.

La distinción importa porque los dos fallos significan cosas opuestas y quieren respuestas opuestas:

var result = await client.PutAsync("report:2026-07", payload);

if (!result.IsSuccess)
{
switch (result.Status)
{
case KvStatus.ItemTooLarge:
// This one value is too big. No amount of eviction helps —
// split it, or store a reference to it elsewhere.
break;

case KvStatus.CapacityExceeded:
// The node is full and eviction is disabled (not the default).
// Retry later or shed load.
break;
}
}

ItemTooLarge es un resultado vivo y alcanzable en todos los modos —la expulsión no puede hacer espacio para un valor que está por encima del tope por ítem, porque el tope no es sobre espacio libre, es sobre un valor patológico. CapacityExceeded, como se cubrió arriba, solo aparece en el modo sin-expulsión que todavía no puedes seleccionar. Tratarlos idénticamente es un bug esperando a ocurrir: reintentar una escritura ItemTooLarge para siempre nunca tendrá éxito.

Un matiz relacionado vive en la frontera entre este tope por ítem y el runtime de .NET: los valores de aproximadamente 85.000 bytes en adelante aterrizan en el Large Object Heap, así que subir maxItemSizeBytes para admitir valores grandes es una decisión deliberada con consecuencias de GC, no un dial gratuito. Si estás actualizando en caliente valores grandes y planos, trocearlos en piezas sub-LOH es el mecanismo que evita que el LOH se agite —una característica separada con sus propios compromisos, que desglosamos en Almacenar objetos grandes sin agitar el heap.

Qué configuras, y qué no​

El techo de memoria vive en la configuración del almacén bajo la sección memory:

{
"memory": {
"maxSizeBytes": 2147483648
}
}

Eso son 2 GiB. Establece maxSizeBytes en null, o déjalo fuera, y obtienes el valor por defecto de 1 GiB. En la versión actual este es el ajuste de memoria que controlas —la política LRU, los umbrales de histéresis del 80%/70%, y la cadencia de 1 segundo son valores por defecto fijos, no perillas. Vale la pena tener claro esto: los artículos del blog y el motor describen un espacio de políticas más rico del que la superficie de configuración expone actualmente, y este artículo describe lo que realmente puedes establecer hoy.

Qué significa para dimensionar un clúster​

Como cada nodo es un buffer acotado, la capacidad total de un almacén es aproximadamente el techo por nodo multiplicado por el número de particiones que un nodo posee, ajustado por el factor de replicación (las réplicas sostienen copias adicionales que viven fuera del techo de propietario activo). Dos consecuencias prácticas se desprenden de eso:

  • Dimensiona el techo a tu working set más margen de expulsión, no a tus datos totales. Un nodo ejecuta una caché LRU siempre activa hoy, así que los datos fríos se descartan bajo presión en lugar de rechazarse. Si tu working set cabe por debajo de ~70% del techo con espacio para que la banda respire, la expulsión se mantiene tranquila y el conjunto caliente permanece residente. Si dimensionas el techo por debajo de tu working set, has construido una caché que está constantemente expulsando datos que está a punto de necesitar de nuevo —correcto, pero lento, y el diente de sierra se convierte en una trituración.
  • No olvides la huella de las réplicas. El techo gobierna los datos poseídos, pero la RAM real de un nodo son los datos poseídos más las copias de réplica. Cuando aprovisionas memoria por pod o por máquina, presupuesta para ambos, o un nodo dimensionado "a su techo" aún correrá más caliente de lo que el techo implica.

Para la aritmética de capacidad en el contexto de particiones, factor de replicación y conteos de pods, Dimensionar un clúster de Zaris para durabilidad en Kubernetes recorre el cálculo completo.

La conclusión​

Un almacén en memoria que crece sin límite no es una característica —es una interrupción esperando un pico de tráfico. Zaris trata cada nodo como un buffer acotado con un techo real (1 GiB por defecto, sin opción ilimitada), una caché LRU muestreada siempre activa por debajo, y una banda de histéresis que recupera espacio en ráfagas tranquilas en lugar de descontrolarse con una expulsión por escritura en el borde. El techo total y el tope por ítem son dos límites distintos con dos estados tipados distintos, y el que realmente es alcanzable hoy —ItemTooLarge— quiere una respuesta distinta de un bucle de reintento. Dimensiona el techo a tu working set, presupuesta para las copias de réplica encima, y el nodo mantiene su línea sin que lo vigiles.

El modelo completo, incluyendo las políticas que el motor lleva pero todavía no expone, está en Memoria y expulsión; los campos y valores por defecto exactos están en la referencia de configuración, y cada estado de capacidad está catalogado en la referencia de estados y excepciones.