Saltar al contenido principal

Un log, dos trabajos: cómo una réplica y un nuevo propietario se ponen al día de la misma forma

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

Ponerse al día reproduciendo el log — un único op-log ordenado por revisión alimenta por igual a réplicas y nuevos propietarios

Dos eventos de aspecto muy diferente en un almacén distribuido resultan ser el mismo problema por debajo. El primero: una réplica se queda atrás de su primario — una pausa de GC, un disco lento, una breve partición de red — y ahora sostiene una copia obsoleta de una partición que debe eventualmente igualar. El segundo: una partición se reasigna a un nodo que nunca la ha tenido, y ese nodo debe adquirir el conjunto de datos entero del propietario actual. Uno es lag de replicación; el otro es migración. Tienen disparadores diferentes, operadores diferentes, historias de fallo diferentes.

Pero la pregunta que cada uno tiene que responder es idéntica: ¿cómo converjo a la verdad actual de esta partición sin congelar las escrituras mientras lo hago? Zaris responde a ambos con un único mecanismo — un log de operaciones ordenado por revisión mantenido por segmento, reproducido desde un punto conocido. Este post trata de ese log: cómo se construye en el camino de escritura, cómo se acota para que no pueda crecer sin límite, cómo se reproduce, y por qué el exacto mismo sustrato lleva tanto a una réplica rezagada como a un propietario completamente nuevo a la convergencia.

La revisión es la espina dorsal​

Todo empieza con un único contador. Cada segmento — la unidad de almacenamiento particionado de Zaris — posee una revisión monótona en su SegmentStore:

private long _storeRevision = 0;
public Revision CurrentRevision => new(_storeRevision);

Cada mutación la incrementa. Un Put, un Delete, un cambio de TTL — cada uno toma la siguiente revisión vía Interlocked.Increment(ref _storeRevision) mientras mantiene el stripe lock de esa clave, y en la misma sección crítica registra la operación:

revision = new Revision(Interlocked.Increment(ref _storeRevision));
// ... apply to the live map ...
AppendOperationLog(revision, key, ReplicationOperation.Put, record);

Así que la revisión no es una marca de tiempo ni un número de versión que gestionas — es un número de secuencia propiedad del almacén, estrictamente creciente, estampado en cada cambio en orden de commit. Ese ordenamiento es todo el truco. Si sabes la revisión que una copia vio por última vez, sabes exactamente qué le falta: todo lo estampado por encima.

La entrada del log en sí es deliberadamente pequeña y plana:

[MessagePackObject]
public sealed class SegmentOperationLogEntry
{
[Key(0)] public long Revision { get; set; }
[Key(1)] public string Key { get; set; }
[Key(2)] public ReplicationOperation Operation { get; set; } // Put | Delete
[Key(3)] public StoredRecord Record { get; set; }
}

El log es un ConcurrentDictionary<long, SegmentOperationLogEntry> indexado por revisión — un write-ahead log de solo-anexado, indexado por revisión. Un delete es una entrada de primera clase, no una ausencia; esto importa, porque "la clave X se eliminó en la revisión 1202" es información que una copia obsoleta debe reproducir, y no puedes representar una eliminación simplemente no enviando algo.

Snapshot, luego la cola​

No puedes reproducir un log desde el principio de los tiempos — sería ilimitado, y la mayoría de él está sobrescrito hace mucho. Así que el ponerse al día siempre son dos partes: un snapshot del conjunto en vivo a partir de alguna revisión S, seguido de la cola del log, cada operación estampada después de S.

var (snapshotRevision, snapshotEntries) = _store.CreateSegmentSnapshot(segmentId);
// ... later, the delta ...
var operations = _store.GetSegmentOperationsAfter(segmentId, snapshotRevision.Value);

El snapshot es el grueso de la data; la cola es el puñado de escrituras que aterrizaron mientras el snapshot se estaba construyendo y enviando. El consumidor aplica el snapshot primero, luego reproduce la cola en orden de revisión, y ha llegado a la revisión S + n.

Hay una sutileza aquí que separa una implementación correcta de una esperanzada. Entre capturar el snapshot en S y terminar el stream, siguen aterrizando nuevas escrituras. El lado que sirve maneja esto con un bucle de drenado acotado — vuelve a consultar la cola después de la última revisión que ya envió, y repite hasta que una pasada vuelve vacía, de modo que la entrega refleja el verdadero estado actual de la fuente en lugar de un punto congelado hace segundos. Cualquier escritura que llegue después de la pasada final de drenado viaja por el camino ordinario de replicación en vivo, y el mensaje de finalización lleva una marca de agua AckedHighRevision para que la fuente pueda reemitir cualquier cosa por encima de ella. El desgarro entre "snapshot" y "en vivo" se cierra deliberadamente, no se da por supuesto.

El log está acotado — y eso cambia el contrato​

Un WAL indexado por revisión que creciera para siempre sería una fuga de memoria con pasos extra. El op-log de Zaris está acotado: un objetivo de retención blando de 512 entradas y un tope duro de 200.000, recortado oportunísticamente una vez que el conteo en vivo cruza el doble del objetivo blando (histéresis, para que el recorte no esté en cada escritura). El recorte elimina el rango de revisión contiguo más antiguo y publica un suelo de recorte — la marca de agua por debajo de la cual las operaciones se han ido para siempre:

public long OpLogTrimFloor => Volatile.Read(ref _opLogTrimWatermark);

Acotar el log crea un nuevo modo de fallo, y el diseño honesto está en cómo se maneja en lugar de ocultarlo. Si un consumidor pide la cola después de la revisión S, pero S está ahora por debajo del suelo de recorte, las operaciones que necesita ya no existen — el rango (S, floor] fue recortado. Una lectura ingenua devolvería silenciosamente una cola parcial y el consumidor convergería a una copia corrupta, llena de huecos. Zaris se niega a hacer eso. La lectura guardada devuelve la cola solo si puede servirla sin huecos:

// returns false if `after` < trim floor — the delta would have a hole
if (!_localStore.TryGetSegmentOperationsAfter(seg, request.SnapshotRevision, out var finalOps))
{
response.Aborted = true;
response.AbortReason = "FinalDeltaBelowTrimFloor";
// ... release fence, unpin ...
}

Un consumidor que recibe este aborto no cojea — tira su ancla obsoleta y reinicia desde un snapshot fresco, cuya revisión es actual por definición. "Por debajo del suelo de recorte" es una señal sonora y recuperable, nunca un hueco silencioso. Esa es la garantía de delta-sin-huecos, y es lo que permite que el log se mantenga pequeño sin mentir jamás sobre lo que contiene.

Para evitar que un ponerse-al-día que está en curso sea recortado por debajo de sus pies, el lado que sirve fija el suelo en la revisión del snapshot durante toda la vida de la transferencia:

_store.PinSegmentTrim(segmentId, snapshot.Revision.Value);  // released in finally

Un pin mantiene el suelo de recorte en o por debajo del punto del snapshot para que la cola que un consumidor todavía necesita se retenga. El recorte nunca cruza el pin activo más bajo. El tope y el pin juntos significan que el log es pequeño en estado estacionario pero garantizado-completo exactamente para las transferencias que dependen de él.

Puesta al día de réplica: el camino directo​

Cuando una réplica necesita resincronizarse, ReplicaSyncRequestHandler ejecuta el modelo snapshot-luego-cola directamente. El orden de las primeras líneas es todo el argumento de corrección:

  1. ArmSegmentOpLog(segmentId) — garantizar que el log está registrando antes de que se tome el ancla (más sobre por qué abajo).
  2. CreateSegmentSnapshot(segmentId) — capturar el conjunto en vivo en la revisión S.
  3. PinSegmentTrim(segmentId, S) — fijar el suelo para que la cola sobreviva el stream.
  4. Transmitir el snapshot como chunks de ReplicaSyncChunkKind.Snapshot, luego la cola como chunks de ReplicaSyncChunkKind.Replay.
  5. Ejecutar el bucle de drenado acotado hasta que una pasada de la cola devuelva vacío.

En el lado de la aplicación, cada chunk se enruta a un procesador por segmento que aplica las entradas del snapshot, luego reproduce las operaciones estrictamente en orden de revisión — un delete llama al camino de delete del almacén local, todo lo demás escribe el registro:

foreach (var op in chunk.Operations.OrderBy(o => o.Revision))
{
if (op.Operation == ReplicationOperation.Delete)
_localStore.DeleteRecord(op.Key, op.Record);
else
_localStore.SaveRecord(op.Key, op.Record);
}

El orden importa porque la misma clave puede aparecer varias veces en una cola — set, delete, set de nuevo. Reproducir en orden de revisión reproduce la secuencia exacta del primario; reproducir fuera de orden podría resucitar una clave eliminada o revertir una escritura más nueva.

Migración: los mismos raíles, con una valla​

Un propietario completamente nuevo no obtiene un motor diferente — obtiene el mismo, más una puerta de conmutación. La migración son dos fases. La fase de grueso (SegmentMigrationRequestHandler) arma el log, toma el snapshot, fija el suelo de recorte, y transmite los registros — movimientos de apertura idénticos a la sincronización de réplica. Luego, una vez que el nuevo propietario ha aplicado el conjunto de grueso, pide el delta final anclado en la revisión del snapshot que se le dio:

public class SegmentMigrationFinalDeltaRequest
{
public ushort SegmentId { get; set; }
public long SnapshotRevision { get; set; } // the anchor
// ...
}

La respuesta de la fuente a esa solicitud es donde la migración añade lo único que la replicación no necesita. Primero activa una valla de escritura, captura la revisión de la valla, y luego construye el delta vía la misma lectura de cola guardada — de modo que cada operación en el delta final está en o por debajo de la valla, porque las escrituras ahora están retenidas:

EngageMigrationWriteFence(seg);
var fenceRevision = GetSegmentCurrentRevision(seg);
if (!_localStore.TryGetSegmentOperationsAfter(seg, request.SnapshotRevision, out var finalOps))
{
// below trim floor -> abort, owner re-drives from a fresh snapshot
}
response.Operations = finalOps.ToList();
response.SourceDigest = ComputeSegmentLiveDigest(seg);

La respuesta también lleva un SourceDigest. El nuevo propietario reproduce las operaciones del delta final — de nuevo OrderBy(o => o.Revision), de nuevo enrutando deletes y puts por separado — y completa la conmutación solo si su propio digest recalculado es igual al de la fuente. Si los contenidos no coinciden bit por bit, la conmutación no ocurre; el propietario reconduce. La valla hace que el delta final sea finito y en reposo; el digest hace que la entrega sea verificable.

Así que la replicación y la migración difieren en exactamente dos lugares, y por lo demás son una sola base de código: la migración añade una valla de escritura y una puerta de conmutación por igualdad de digest; la replicación lee la cola bajo un pin con un bucle de drenado. El log, el snapshot, el ordenamiento por revisión, el contrato del suelo de recorte — todo compartido.

Por qué la reproducción usa el camino de escritura "raw"​

Un detalle que vale la pena destacar: el ponerse al día no reproduce a través del Put/Delete normal que usaría un cliente. Usa PutRaw / DeleteRaw, que preservan la revisión y la versión de ítem de la fuente en lugar de reestampadrlas. Un Put normal asignaría una versión fresca como si la escritura se originara localmente; eso haría que las versiones de la réplica divergieran de las del primario y rompería la comparación de digest de la que depende la migración. El camino raw avanza el contador de revisión local al valor entrante (Interlocked.Exchange(ref _storeRevision, Math.Max(_storeRevision, record.Revision.Value))) en lugar de inventar uno nuevo. La reproducción es una reproducción fiel, no una reoriginación — la copia termina no solo con la misma data sino con el mismo linaje de versiones, que es lo que hace que "¿coinciden nuestros digests?" sea una pregunta significativa.

La optimización honesta: saltarse el log cuando nadie escucha​

Anexar al op-log no es gratis — es una asignación y una segunda inserción de diccionario en cada escritura, tocando líneas de caché compartidas en el camino caliente. Si un segmento no tiene réplica y este nodo es su único primario, nada reproducirá jamás ese log, así que Zaris puede saltarse el anexado. Esto se envía, y está acotado con cuidado.

La obligación es un flag por segmento, seguro-por-defecto activado:

private long _logObligation = 1;  // DEFAULT ON — safe by default

Se deriva de la topología, no de un archivo de configuración. En cada aplicación del mapa de particiones, RefreshOpLogObligations() lo recalcula por segmento: obligation = !(noReplicas && selfIsPrimary). El log se salta solo cuando la partición provablemente no tiene réplica y este nodo es su único primario; una entrada faltante o ambigua recae en activado. No hay interruptor de operador que pueda desactivarlo incorrectamente — sigue la forma real del clúster.

El salto es una línea en la parte superior del anexado:

if (Volatile.Read(ref _logObligation) == 0 && _trimPins.IsEmpty) return;

Nota la segunda cláusula: incluso si el flag dice "sin obligación", un pin de recorte activo fuerza el logging activado. Si una transferencia está en vuelo, su pin mantiene el log registrando independientemente de la topología.

La parte que hace esto provablemente seguro es la barrera arm-before-snapshot. Cada consumidor arma el log inmediatamente antes de capturar su ancla de snapshot:

public void ArmOpLog()
{
Interlocked.Exchange(ref _logObligation, 1);
foreach (var stripe in _keyLocks) lock (stripe) { } // happens-before barrier
}

Fijar el flag y luego barrer todos los locks de stripe de clave establece una arista happens-before: cada escritura que asigna una revisión después del arm observará la obligación y registrará. Así que cualquier escritura que se saltó el log necesariamente hizo commit antes del arm, lo que significa que su revisión está en o por debajo de la revisión del snapshot S — y por tanto la lleva el snapshot, no la cola. Las escrituras saltadas no se pierden; simplemente se entregan como parte del conjunto de grueso en lugar del delta. Desactivar la obligación se rechaza mientras cualquier pin esté activo, cerrando el otro lado de la ventana.

Es la clase de optimización que solo vale la pena enviar si puedes enunciar exactamente por qué es segura. Aquí el argumento es un único invariante — arma antes de anclar — y tanto el camino de servicio de sincronización-de-réplica como el de migración lo obedecen en las dos líneas antes de tomar el snapshot.

Un sustrato, ganado​

El atractivo de un sustrato compartido no es la elegancia por sí misma. Es que el razonamiento difícil — el ordenamiento por revisión, el desgarro snapshot/cola, el contrato de delta-sin-huecos contra un log acotado, la reproducción fiel que preserva versiones — se hace una vez y luego es confiado por cada consumidor. Una réplica rezagada y un propietario recién asignado parecen eventos operacionales diferentes, pero hacen la misma pregunta y obtienen la misma respuesta del mismo código. La migración superpone una valla y una comprobación de digest encima; la replicación superpone un pin y un bucle de drenado. Ninguno reinventa el log, y ninguno puede desviarse de la corrección del otro.

El log se mantiene pequeño porque está acotado y fijado solo cuando se necesita; se mantiene honesto porque un delta por debajo del suelo de recorte aborta a un snapshot fresco en lugar de devolver un hueco; y se mantiene barato porque la única escritura que nadie reproducirá jamás es la única escritura que se le permite saltar — provablemente, bajo una barrera, y activado por defecto en todas partes.