Muchas claves, pocos viajes de ida y vuelta: cómo Zaris hace fan-out de una operación masiva
Pídele a un almacén distribuido una clave y el costo lo domina un solo número: el viaje de ida y vuelta al nodo que la posee. La búsqueda en sí es nanosegundos; la red es microsegundos. Ahora pide mil claves. Si iteras y llamas a Get mil veces, pagas ese viaje de ida y vuelta mil veces, en serie, y el almacén gasta casi todo el tiempo de reloj sentado ocioso esperando paquetes. Las claves podrían estar repartidas sobre cuatro nodos que podrían haber respondido en paralelo —pero un bucle ingenuo nunca les da la oportunidad.
Zaris tiene tres APIs masivas —GetManyAsync, PutManyAsync, DeleteManyAsync— y un ExecuteBatchAsync mixto por debajo de ellas, y toda su razón de existir es convertir "mil claves" en "una solicitud por nodo propietario, emitidas en paralelo". Este artículo es la ruta de fan-out de principio a fin: cómo el cliente agrupa claves por propietario, por qué las cuatro APIs colapsan sobre un único motor, cómo un campo Index mantiene las respuestas en orden a través de dos capas de concurrencia, y por qué —a diferencia de una transacción multiclave— una operación masiva es por ítem y el éxito parcial es normal.
El modelo de costos: viajes de ida y vuelta, no operaciones
Lo que una API masiva combate no es la CPU. Un GET de clave única en un nodo propietario es una búsqueda en diccionario —el piso de latencia es un microsegundo de trabajo real dentro de diez de plomería. La parte cara es la plomería: la solicitud tiene que cruzar la red al propietario y la respuesta tiene que volver. Llama a ese tiempo de ida y vuelta R, típicamente decenas a cientos de microsegundos en una red de datacenter, órdenes de magnitud más que la búsqueda.
Un bucle en serie de N claves cuesta aproximadamente N × R, porque cada llamada espera a que el paquete de la anterior vuelva antes de enviar la siguiente. Esa es la trampa. El arreglo no es una búsqueda más rápida —es dejar de pagar R por clave. Si las N claves viven en M nodos propietarios, el piso es R pagado M veces concurrentemente, es decir, alrededor de un R en total sin importar cuántas claves sostenga cada nodo. Para mil claves en cuatro nodos esa es la diferencia entre mil viajes de ida y vuelta en serie y, en efecto, uno. Todo el diseño de abajo existe para colapsar N × R a ~R.
Un motor: todo es un lote de ítems
Lo primero sorprendente en el cliente de clúster es que GetManyAsync, PutManyAsync y DeleteManyAsync no son tres implementaciones separadas. Cada una es una fina capa (shim) que convierte sus argumentos en una lista de KvBatchItems y los entrega al único método que hace el trabajo real, ExecuteBatchAsync. Aquí está GetManyAsync haciendo exactamente eso:
var list = keys.Select((k, i) => new KvBatchItem
{
Index = i, // caller's position — the correlation key
Op = KvBatchOp.Get,
Key = k,
GetOptions = options?.ToGetOptions()
}).ToList();
var resp = await ExecuteBatchAsync<T>(
new KvBatchRequest
{
Items = list,
DefaultConsistency = options?.Consistency ?? Defaults.DefaultConsistency
}, ct).ConfigureAwait(false);
PutManyAsync y DeleteManyAsync hacen lo mismo con KvBatchOp.Put y KvBatchOp.Delete. Así que la distinción que los ejemplos trazan entre Bulk (un tipo de operación a la vez) y Batch (una mezcla de PUT / GET / DELETE en una llamada) es una conveniencia de superficie: los ayudantes masivos son solo un lote donde cada ítem resulta compartir una operación. Hay exactamente un motor de fan-out, y es agnóstico a la operación.
Nota Index = i. Cada ítem se estampa con su posición en la lista del llamador antes de que nada se agrupe, reordene o envíe. Ese sello es lo que sobrevive a los dos saltos concurrentes por venir y permite que la respuesta final se devuelva en orden. Agárrate a él.
El fan-out: agrupar por propietario, una solicitud cada uno
Aquí está el corazón de todo el artículo —el despacho de lotes del cliente de clúster, esencialmente completo:
var table = _routing;
var buckets = new Dictionary<ZarisRemoteClient, List<KvBatchItem>>();
foreach (var item in batch.Items)
{
var seg = SegmentMapper.ComputeSegmentId(item.Key, table.TotalSegments);
var client = GetClientForSegment(seg, table);
if (!buckets.TryGetValue(client, out var list))
buckets[client] = list = new List<KvBatchItem>();
list.Add(item);
}
var tasks = buckets.Select(kv =>
kv.Key.ExecuteBatchAsync<T>(new KvBatchRequest
{
Items = kv.Value,
DefaultConsistency = batch.DefaultConsistency
}, ct));
var perOwner = await Task.WhenAll(tasks).ConfigureAwait(false);
return new KvBatchResponse
{
Results = perOwner.SelectMany(r => r.Results)
.OrderBy(r => r.Index)
.ToList()
};
Léelo como cuatro movimientos:
-
Resuelve cada clave a su propietario, localmente.
SegmentMapper.ComputeSegmentIdes la misma función de ubicación que usa la ruta de clave única —CRC16 sobre el hash-tag, módulo el conteo de segmentos— yGetClientForSegmentmapea ese segmento a la conexión ya abierta de su nodo propietario. Sin proxy, sin coordinador: el cliente computa la propiedad él mismo, exactamente como se describe en un salto al propietario. La diferencia es que aquí lo hace para cada clave del conjunto a la vez. -
Agrupa. Las claves poseídas por el mismo nodo se amontonan en la lista de ese nodo. Mil claves repartidas sobre cuatro propietarios se convierten en cuatro listas. Aquí es donde
Noperaciones se convierten enMsolicitudes. -
Despacha concurrentemente.
buckets.Select(...)construye una llamadaExecuteBatchAsyncpor propietario yTask.WhenAlldeja que todas vuelen a la vez. Cuatro nodos responden en paralelo; el tiempo de reloj es la respuesta del nodo más lento, no la suma. -
Recombina en orden. Cada propietario devuelve su porción de resultados;
SelectManylos aplana yOrderBy(r => r.Index)restaura el orden original del llamador. El llamador recibe de vuelta una lista cuya posición i es la respuesta para la clave i, aunque las claves viajaron a nodos distintos y volvieron en el orden que sea que la red entregó.
Esa es la afirmación completa de amortización hecha concreta: el número de viajes de ida y vuelta es igual al número de nodos propietarios distintos en el conjunto de claves, no al número de claves.
Dentro de un propietario: concurrente de nuevo, ordenado de nuevo
Cada sub-lote por propietario aterriza en el BatchCommandHandler de ese nodo. Tampoco ejecuta los ítems uno tras otro —los ejecuta todos a la vez y luego reordena:
var items = batchReq.Request.Items.Cast<KvBatchItemWire>().ToArray();
// Execute every item in the sub-batch concurrently.
var tasks = items.Select(item => ExecuteItemAsync(item)).ToArray();
var results = await Task.WhenAll(tasks).ConfigureAwait(false);
return new ZarisResponse
{
Payload = new BatchResponsePayload
{
Response = new KvBatchResponse
{
Results = results.OrderBy(r => r.Index).ToList()
}
}
};
ExecuteItemAsync es un pequeño switch sobre la operación que despacha cada ítem al almacén —GetRawAsync, PutRawAsync o DeleteRawAsync— así que un lote mixto de lecturas y escrituras se maneja ítem por ítem dentro de la misma solicitud. (Para los routers en proceso y de un solo nodo la misma forma aparece como un Parallel.ForEachAsync sobre las claves, acotado por MaxDegreeOfParallelism —una opción MaxParallelism o Environment.ProcessorCount por defecto— que es por qué las lecturas escalan con los núcleos: un sub-lote gordo mantiene ocupado cada núcleo en lugar de uno.)
Así que el sello Index se gana el sustento dos veces. La concurrencia desordena el orden en el nodo, así que el handler ordena por Index antes de responder. El fan-out desordena el orden en el cliente, así que el cliente ordena por Index después de fusionar. El llamador nunca ve ninguno de los dos desórdenes. Un pequeño entero, llevado en cada ítem desde el momento en que se construye la solicitud, es el mecanismo entero que hace seguro exponer "lánzalo todo al clúster en paralelo" como una lista ordenada.
Una operación masiva no es una transacción
Esta es la distinción que la gente más a menudo entiende mal, así que vale la pena enunciarla con nitidez. Cada ítem en un lote lleva su propio resultado. El tipo de resultado por ítem registra el éxito, el estado y —para las operaciones que los tienen— la revisión, la versión y el TTL de esa única clave:
private static KvBatchItemResult MapKvToBatch(int index, KvBatchOp op, KvResult r) => new()
{
Index = index,
Op = op,
Success = r.IsSuccess,
Status = r.Status, // e.g. Success, NotFound, Moved
Revision = r.Revision?.Value,
ItemVersion = r.Version?.Version,
Ttl = r.TimeToLive
};
No hay punto de confirmación compartido. Un GetMany sobre cien claves donde treinta no existen devuelve setenta resultados Success y treinta resultados NotFound, intercalados por índice —no un error, no una lista vacía. Un PutMany donde el segmento de una clave está a mitad de migración devuelve ese único ítem con estado Moved y el resto Success. El éxito parcial es la forma normal y esperada de la respuesta, y se espera que el llamador inspeccione cada ítem.
Eso es exactamente lo opuesto de una transacción multiclave, que es todo-o-nada por construcción: un commit de dos fases coordinado por el cliente sobre versiones optimistas donde o cada clave se mueve o ninguna lo hace. Las dos características parecen similares desde fuera —ambas toman muchas claves en una llamada— y están construidas para necesidades opuestas. Recurre a un lote cuando las claves son independientes y quieres throughput; recurre a una transacción cuando un invariante abarca las claves y un resultado a medio aplicar lo corrompería. Zaris las mantiene como APIs separadas precisamente para que la semántica nunca sea ambigua: un lote nunca revierte silenciosamente, y una transacción nunca confirma silenciosamente a medias.
Sobrevivir a una propiedad que se mueve a mitad de lote
Una operación masiva es un blanco más amplio que un solo GET para el problema de que la propiedad cambie por debajo. Entre el momento en que el cliente agrupa una clave al nodo A y el momento en que el nodo A maneja el sub-lote, un cut-over de partición puede haber movido ese segmento al nodo B. El nodo A responderá esos ítems con estado Moved en lugar de adivinar.
El cliente de clúster envuelve todo el fan-out en un reintento acotado exactamente para esto:
public override async Task<KvBatchResponse> ExecuteBatchAsync<T>(
KvBatchRequest batch, CancellationToken ct = default)
{
return await RetryHelper.RetryAsync(
() => ExecuteBatchClusterAsync<T>(batch, ct),
maxAttempts: 2,
backoffMillis: 100,
logger: _logger).ConfigureAwait(false);
}
En un reintento la tabla de enrutamiento usualmente se ha refrescado, así que el re-agrupamiento envía las claves movidas a su nuevo propietario. El reintento es deliberadamente superficial —dos intentos, un backoff corto— porque el objetivo es aguantar una tabla de enrutamiento que está momentáneamente obsoleta, no empapelar un nodo que realmente está caído. Esta es la misma disciplina descrita en reintentos bajo churn de propiedad, aplicada a todo un conjunto de claves a la vez: el reintento re-ejecuta el agrupamiento, no solo el envío, así que cada intento refleja la propiedad más reciente en lugar de reproducir un plan obsoleto.
Un bug real, porque la forma de cable tiene dos variantes
Vale la pena mostrar un lugar donde esta ruta se rompió, porque dice algo sobre las operaciones masivas específicamente. El valor de un resultado GET puede llegar por el cable en dos formas —envuelto en un sobre ZarisEntry, o como un valor serializado en bruto— dependiendo de cómo se almacenó (ver lo que cruza el cable). El decodificador original de GET masivo asumía solo la forma envuelta y lanzaba una excepción con la en bruto.
Para un solo GET eso es un fallo de una clave. Para un GetMany era catastrófico de forma sutil: cada clave en el lote fallaba, porque la única codificación no manejada lanzaba antes de que se decodificara cualquier resultado. Una corrida de certificación lo marcó como "fallos de get masivo", y el arreglo fue enrutar la decodificación a través de un ayudante compartido de codificación-dual que maneja ambas formas:
// Decode through the shared helper (handles both the ZarisEntry-wrapper and
// the raw-value forms the server can emit). The old inline code assumed only
// the wrapper and threw, failing every key of a bulk GET.
var obj = KvBatchItemResultExtensions.GetValue<T>(r);
La lección que generaliza: en una ruta masiva, un defecto en la maquinaria por ítem compartida se multiplica por el tamaño del lote, y un error que es una molestia para una clave es una interrupción para mil. El modelo de resultado por ítem es lo que contiene el radio de explosión —una vez que la decodificación es correcta, un valor malformado falla un ítem y deja los otros 999 intactos.
Qué compra el fan-out
Junta las piezas y la forma de la victoria es clara. Una llamada masiva estampa cada ítem con su índice, resuelve el propietario de cada clave con la misma aritmética de ubicación que usa la ruta de clave única, agrupa las claves para que N operaciones se conviertan en una solicitud por nodo propietario, dispara esas solicitudes concurrentemente, y reensambla las respuestas en el orden del llamador —sobreviviendo a una tabla de enrutamiento que cambia en pleno vuelo y reportando el destino de cada clave independientemente. El conteo de viajes de ida y vuelta cae del número de claves al número de nodos, que es el único número que alguna vez iba a importar.
Nada de ello requirió una nueva ruta de almacenamiento o un nuevo registro de cable. Las APIs masivas son una capa de planificación sobre las primitivas que Zaris ya tiene: la ubicación consciente de particiones que le dice al cliente quién posee qué, el contrato por registro que cada resultado de ítem reutiliza, y la ejecución núcleo-paralela que mastica un sub-lote una vez que aterriza. La parte difícil de ir rápido en muchas claves nunca fueron las claves —era negarse a pagar por la red una clave a la vez.