Saltar al contenido principal

Un salto al propietario: cómo el cliente de Zaris enruta una clave

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

Cómo el cliente de Zaris enruta una clave a su propietario

Hay dos formas honestas de construir un cliente de caché distribuida. La primera es elegir cualquier nodo, enviarle la solicitud, y dejar que el clúster la reenvíe internamente a quien posea la clave —simple en el cliente, pero cada solicitud que aterriza en el nodo equivocado paga un salto de red adicional dentro del clúster. La segunda es hacer al cliente lo bastante listo como para saber qué nodo posee la clave antes de enviar nada, para que el primer socket que toque sea el que realmente puede responder. Zaris toma la segunda vía. Un GET sale de tu proceso ya dirigido al nodo que sostiene la clave —sin proxy, sin salto de reenvío, sin coordinador en el medio.

Este artículo es la vista interna de cómo funciona eso: la función que convierte una clave en un propietario, la tabla de búsqueda que el cliente mantiene en memoria, y —la parte que es fácil de equivocar— cómo el cliente se mantiene correcto cuando la propiedad se mueve mientras tu tráfico está en vuelo.

La afirmación, enunciada con precisión​

Cuando llamas a client.GetAsync("user:42"), el cliente hace tres cosas antes de que ningún byte toque la red:

  1. Hace hash de la clave a un segmento. Una función pura mapea la cadena de la clave a uno de los segmentos del clúster.
  2. Busca el propietario del segmento. Un arreglo en memoria mapea ese segmento directamente a la única conexión que alcanza su nodo propietario activo.
  3. Envía directo-al-propietario. La solicitud va por esa conexión y ninguna otra.

Ningún paso consulta un router central. El paso 1 es aritmética, los pasos 2 y 3 son un índice de arreglo y una escritura de socket. El cliente sostiene una conexión por nodo y una pequeña tabla de enrutamiento, y ese es todo el aparato. La ingeniería interesante no es la ruta feliz —es mantener esa tabla honesta.

Paso 1 — la clave se convierte en un segmento​

Zaris hace hash de las claves exactamente del modo en que lo hace un cliente de Redis Cluster, a propósito. La función de ubicación vive en SegmentMapper, y su comentario de documentación es contundente sobre por qué se ve como se ve:

// Maps a key to its segment. Placement is aligned to the Redis Cluster slot model so a Redis cluster client
// (which routes by CRC16(key) % 16384) and Zaris agree on where a key lives: the key's Redis slot is
// computed the exact Redis way (XMODEM CRC16 over the hash-tag, mod 16384), then the 16384 slots are mapped
// proportionally onto the cluster's segments.
// This is the SINGLE shared placement function used by both the client and the server.

Esa última línea es la que más importa. No es "el cliente computa la ubicación de una forma y el servidor la valida de otra". Es el mismo código, compilado en el ensamblado del cliente y el ensamblado del servidor, produciendo el mismo segmento para la misma clave cada vez. Si el cliente y el servidor discreparan sobre dónde vive una clave, cada escritura sería un lanzamiento de moneda entre aterrizar en el propietario y aterrizar en un nodo que tiene que rechazarla. Compartir la función hace esa clase de bug irrepresentable.

La computación en sí son dos movimientos:

public static ushort ComputeSegmentId(string key, int shardCount, ...)
{
var slot = ComputeSlot(key); // CRC16(hash-tag) % 16384
return SlotToSegment(slot, shardCount); // proportional slot -> segment
}

// The Redis Cluster key slot: XMODEM CRC16 over the hash-tag (or the whole key), mod 16384.
public static int ComputeSlot(string key)
{
var (start, len) = HashTagRange(key);
return Crc16(key, start, len) % RedisSlots; // RedisSlots = 16384
}

Primero la clave se reduce a un slot en el rango fijo [0, 16384) usando XMODEM CRC16 —el mismo polinomio CRC16-CCITT (0x1021, init 0x0000) que usa Redis. Luego los 16384 slots se mapean proporcionalmente sobre cuantos segmentos tenga realmente el clúster:

public static ushort SlotToSegment(int slot, int shardCount)
=> (ushort)((long)slot * shardCount / RedisSlots);

Dos slots pueden caer en el mismo segmento; un segmento es un rango contiguo de slots. Esa alineación es lo que permite a Zaris responder CLUSTER SLOTS correctamente para un cliente de Redis Cluster mientras sigue enrutando por su propio modelo de segmentos internamente —las dos vistas son la misma vista a distintas resoluciones.

Hash tags: decidir qué se co-ubica​

Hay una escotilla de escape deliberada en el hashing de claves. Si una clave contiene un grupo {...} con al menos un carácter dentro de las llaves, solo los bytes dentro de las llaves se hashean:

// Redis hash tag: if the key contains "{...}" with at least one char between the braces, only that
// substring is hashed (so "{u1}:a" and "{u1}:b" co-locate). Otherwise the whole key is hashed.

Así es como fuerzas claves relacionadas al mismo segmento. {u1}:profile y {u1}:sessions ambas hashean sobre u1, así que comparten un segmento y por tanto un propietario —que es exactamente lo que necesitas para una transacción multiclave o cualquier operación que tenga que tocar varias claves atómicamente en un nodo. Las claves sin un tag se reparten por todo el espacio de claves. El cliente y el servidor coinciden en esto también, porque es el mismo HashTagRange en ambos lados.

Un detalle más que vale la pena notar para la ruta caliente: el bucle CRC16 tiene una ruta rápida todo-ASCII que hashea los caracteres como bytes sin asignación, y solo recurre a una codificación UTF-8 para claves no-ASCII. Enrutar cada operación significa que esto corre en cada operación, así que se escribió para no asignar en el caso común.

Paso 2 — el segmento se convierte en una conexión​

Ahora el cliente tiene un número de segmento. Necesita el nodo que actualmente lo posee. El cliente mantiene una RoutingTable inmutable cuyos campos son toda la historia:

// one live connection per node, keyed by node id
public ImmutableDictionary<string, ZarisRemoteClient> Clients { get; init; }

// segment -> the active-owner client it routes to (the precomputed answer)
public ZarisRemoteClient?[] SegmentMap { get; init; }

// segment -> partition, and partition -> owning node id
public ushort[] SegmentToPartition { get; init; }
public ImmutableDictionary<int, string?> RuntimeOwners { get; init; }

La elección de diseño clave es SegmentMap. Es un arreglo, indexado por segmento, cuyas entradas están ya resueltas a la conexión del propietario de ese segmento. Enrutar una clave no es un recorrido de grafo de segmento a partición a nodo a conexión en el momento de la solicitud —esa resolución se hace de antemano cuando se construye la tabla, así que el costo por solicitud es un único índice de arreglo:

internal ZarisRemoteClient GetClientForKey(string key)
{
var table = _routing;
return GetClientForSegment(
SegmentMapper.ComputeSegmentId(key, table.TotalSegments),
table);
}

private static ZarisRemoteClient GetClientForSegment(ushort segment, RoutingTable table)
{
if (segment >= table.SegmentMap.Length)
throw new InvalidOperationException($"No client mapped for segment {segment} — map may be mid-update, retry");

if (table.SegmentMap[segment] is not { } client)
{
var partitionId = segment < table.SegmentToPartition.Length
? table.SegmentToPartition[segment] : -1;
throw new PartitionUnavailableException(partitionId, segment);
}
return client;
}

Clients sostiene exactamente un ZarisRemoteClient —una conexión— por nodo. El cliente no abre un socket fresco por solicitud o por clave; multiplexa todo el tráfico de un nodo sobre la única conexión de ese nodo, y SegmentMap simplemente apunta muchos segmentos al mismo objeto de conexión (la igualdad por referencia los deduplica cuando el cliente necesita el conjunto distinto de propietarios activos, por ejemplo para hacer fan-out de un escaneo). Así que el costo de memoria de la consciencia de particiones es una conexión por nodo más un par de arreglos dimensionados al conteo de segmentos —no crece con el número de claves o solicitudes.

El beneficio es que no hay nodo en el clúster cuyo trabajo sea recibir solicitudes mal enrutadas y reenviarlas. Cada solicitud bien enrutada la sirve el primer nodo que alcanza. Eso elimina todo un nivel del presupuesto de latencia y, igual de importante, de la superficie de fallo —no puedes perder una solicitud en un salto de reenvío que no existe. (La pieza compañera sobre el piso de latencia recorre lo que sí queda en el presupuesto una vez que el salto de proxy se ha ido.)

Paso 3 — el mapa se mueve por debajo​

Aquí está la parte difícil. La tabla de enrutamiento es una caché de un hecho —"el segmento S es propiedad del nodo N"— que el clúster puede cambiar en cualquier momento. Un propietario puede fallar y una réplica es promovida; una partición puede migrarse para hacer crecer o drenar un nodo. En el instante en que eso ocurre, el SegmentMap[S] del cliente apunta a una conexión que ya no es el propietario. Un cliente que confiara en su tabla ciegamente enviaría escrituras a un nodo que correctamente las rechazará.

Zaris mantiene la tabla honesta de tres formas, en orden de preferencia.

Push — el servidor le dice al cliente. Cuando la propiedad cambia, el clúster emite un RuntimeOwnershipChangedEvent, y el cliente tiene un handler suscrito a él:

public Task HandleAsync(IServerEvent @event)
{
if (@event is RuntimeOwnershipChangedEvent runtimeEvent)
_router.TryUpdateRuntimeOwnership(runtimeEvent);
return Task.CompletedTask;
}

TryUpdateRuntimeOwnership no reconstruye toda la tabla. Clona el arreglo SegmentMap, re-apunta solo los segmentos cuya partición realmente cambió de propietario, e intercambia la tabla inmutable con una única asignación de referencia. Los lectores nunca ven una tabla a medio actualizar —o leen la vieja o la nueva, nunca una mezcla rota. Esto es enrutamiento copy-on-write: el caso común (nada cambió) no cuesta nada, y un cambio toca solo los segmentos afectados.

Poll — un respaldo lento. Un temporizador refresca el mapa del clúster cada diez segundos sin importar nada, así que incluso si un evento push se pierde (una reconexión, una notificación dejada caer), la vista del cliente se auto-sana dentro de un intervalo:

private readonly TimeSpan _pollInterval = TimeSpan.FromSeconds(10);

React — la propia solicitud corrige el mapa. La última línea de defensa es la solicitud que enruta mal. Si el cliente envía a un nodo que ya no es el propietario, el nodo no lo reenvía silenciosamente —devuelve un estado que le dice al cliente que su mapa está obsoleto, y el ayudante de reintento refresca e intenta de nuevo:

private static bool RequiresMapRefresh(KvStatus status)
=> status == KvStatus.Moved
|| status == KvStatus.ReplicaWriteRejected
// Ownership is mid-handoff: the client must force-fetch the updated
// ownership and retry against the node that now owns the segment.
|| status == KvStatus.OwnershipChanging;

Cuando una operación reintentable vuelve Moved, OwnershipChanging o ReplicaWriteRejected, el cliente llama a su gancho de refresco y reintenta —un bucle acotado de unos pocos intentos con un backoff corto, no un giro sin fin. Hay una lección sutil horneada en el código actual aquí: una versión anterior refrescaba el mapa en cada intento de reintento, lo que bajo churn intenso de nodos convertía una sola operación fallida en docenas de refrescos todos serializados detrás de un lock. El arreglo fue refrescar solo cuando el estado realmente dice "tu mapa está mal", no meramente porque un intento falló. El tratamiento más profundo de esa disciplina de reintentos es su propio artículo —reintentos bajo churn de propiedad— pero la versión de una línea es: un mapa obsoleto es una condición auto-correctiva, y la corrección es barata precisamente porque es rara y dirigida.

Los tres mecanismos se superponen: el push hace el caso común instantáneo, el poll hace que un push perdido sea eventualmente consistente, y el reintento reactivo hace invisible cualquier obsolescencia residual para tu llamada —solo toma un viaje de ida y vuelta extra en el momento en que la propiedad se movió, y es correcto desde la siguiente llamada en adelante.

¿Por qué hacerlo en el cliente en absoluto?​

El enrutamiento consciente de particiones del lado del cliente cuesta algo. El cliente tiene que llevar un modelo del clúster, mantenerlo fresco, y enviar la misma lógica de ubicación que usa el servidor. Un diseño basado en proxy empuja todo eso al clúster y deja que el cliente siga siendo tonto. Entonces, ¿por qué pagar?

Porque la alternativa paga en cada solicitud individual, para siempre. Un proxy o un diseño de "envía a cualquier lado, lo reenviaremos" añade un salto a la ruta caliente de cada operación —un salto que es pura sobrecarga, presente incluso cuando nada ha fallado y nada se está moviendo. Zaris paga el costo del enrutamiento una vez, por adelantado, y solo de nuevo cuando la propiedad realmente cambia— lo que, en un clúster saludable, es casi nunca. El estado estable es un índice de arreglo y una escritura de socket al nodo correcto. Esa es la razón entera por la que las lecturas escalan con tus núcleos en lugar de embotellarse en un nivel intermedio: no hay nivel intermedio donde embotellarse.

Y compone con todo lo demás. La misma resolución GetClientForKey es por la que un key-watch, un incremento de contador, un compare-and-swap y una operación de estructura de datos de clave única enrutan todos, así que todos heredan la entrega directa-al-propietario y el mismo mapa auto-sanante. El cliente no es una tubería tonta con enrutamiento atornillado; el enrutamiento es la cosa que el cliente es.

Los compromisos honestos​

El enrutamiento directo-al-propietario no está libre de bordes, y vale la pena nombrarlos.

  • El cliente debe alcanzar cada nodo. Como no hay proxy, el cliente abre una conexión a cada nodo al que enruta. En una red plana eso es un no-problema; a través de una frontera —digamos un cliente fuera de Kubernetes— esos nodos tienen que ser direccionables individualmente. Ese es exactamente el problema que el direccionamiento de clientes externos existe para resolver, y es una consecuencia directa de que no haya una única puerta de entrada.
  • Un breve retraso del mapa es posible, no una pérdida. En la ventana entre que la propiedad se mueve y que el cliente se entera, una solicitud puede enrutar al propietario viejo. Se rechaza y reintenta, no se mal-aplica —la ruta reactiva de arriba garantiza que el reintento aterrice en el propietario real. Pagas un viaje de ida y vuelta, no una respuesta errónea.
  • La función de ubicación es un contrato de compatibilidad. Como el cliente y el servidor comparten SegmentMapper, no puedes cambiar cómo las claves hashean a segmentos sin que ambos lados coincidan —que es precisamente por qué el hashing se alineó al modelo de slots estable de Redis en lugar de a un esquema interno que podría derivar.

Enunciado sin rodeos: el cliente de Zaris sabe dónde viven tus claves, mantiene ese conocimiento actual sin que lo pienses, y gasta un salto de red —el que realmente hace el trabajo— en cada operación. La astucia no está en la ruta rápida, que es deliberadamente aburrida. Está en los tres mecanismos silenciosos que mantienen correcta la ruta rápida mientras el clúster se reconfigura por debajo.