La generación de identificadores únicos y monótonos es un problema fundamental en sistemas distribuidos, exacerbado por la migración de bases de datos relacionales con soporte nativo de secuencias a entornos NoSQL. La tesis central es que la mayoría de los requisitos percibidos para secuencias (como la monotonicidad global estricta o la ausencia de gaps) son, en realidad, menos críticos de lo que se asume inicialmente. Al relajar estas restricciones, es posible diseñar una solución de generación de IDs distribuida que priorice la disponibilidad, la baja latencia y la simplicidad operativa, transformando un problema de coordinación distribuida complejo en una implementación eficiente y robusta.
Históricamente, las bases de datos relacionales han abstraído la complejidad de la generación de secuencias, ofreciendo contadores fiables y fáciles de usar. Sin embargo, en arquitecturas de microservicios y bases de datos NoSQL, esta funcionalidad debe ser reimplementada. Soluciones como UUIDs o IDs tipo Snowflake introducen sus propias complejidades (dispersión de índices, gestión de IDs de worker, sincronización de relojes), que a menudo no se alinean con los requisitos de compatibilidad y simplicidad operativa en migraciones a gran escala. La necesidad de una solución que sea un reemplazo directo, con baja latencia y alta disponibilidad, sin introducir una carga operativa significativa, impulsa este diseño.
Arquitectura del Sistema
El servicio de secuencias se estructura en tres capas principales: DynamoDB como fuente de verdad, una capa de caché en el lado del servidor y clientes 'gruesos' (thick clients) con caché local. Cada secuencia se almacena como un ítem en DynamoDB, con el nombre de la secuencia como clave y el valor actual como un tipo 'Number'. La actualización del contador se realiza mediante una operación atómica de 'conditional update' en DynamoDB, utilizando 'UpdateExpression' y 'ConditionExpression' para asegurar que cada bloque de secuencias se asigne una única vez, evitando duplicados sin necesidad de bloqueos distribuidos explícitos.
La capa de caché del servidor mantiene bloques de secuencias pre-obtenidas en memoria. Cada instancia del servicio asigna bloques no solapados de DynamoDB, garantizando la unicidad global pero permitiendo que los valores no sean estrictamente monótonos entre instancias. Se optó por una caché en memoria local en lugar de una caché externa compartida (como Redis) para evitar reintroducir latencia de red y puntos de fallo adicionales. Los clientes 'gruesos' son librerías embebidas en las aplicaciones que mantienen su propia caché local de secuencias. La mayoría de las solicitudes de secuencia se atienden directamente desde esta caché en memoria del cliente, eliminando llamadas de red. Cuando la caché del cliente se agota, solicita un nuevo bloque al servicio de secuencias. El servicio, a su vez, recarga su propia caché desde DynamoDB de forma asíncrona y en bloques grandes (500-1000 secuencias) para minimizar las operaciones de escritura en la base de datos.
La pieza clave para la eficiencia es un algoritmo de 'sliding window' que se ejecuta en el cliente. Este algoritmo estima continuamente la tasa de consumo de secuencias y predice cuándo la caché local se agotará. Basándose en esta predicción, el cliente inicia una recarga asíncrona de secuencias del servicio antes de que la caché se vacíe. Esto asegura que las solicitudes de los usuarios rara vez se bloqueen esperando una operación de red o de base de datos. La configuración de las secuencias (valor inicial, incremento, dirección, tamaño de bloque) se gestiona de forma centralizada en el servidor a través de un almacén de configuración dinámica, permitiendo ajustes sin redepliegues de clientes.
Flujo de Solicitud de Secuencia (Escenarios)
- 1 Aplicación Cliente Solicita la siguiente secuencia para un contador específico.
- 2 Caché Local (Cliente) Verifica si hay secuencias disponibles. Si hay, devuelve el valor y registra ...
- 3 Algoritmo Ventana Deslizante (Cliente) Calcula la tasa de consumo y el umbral de recarga. Si el umbral se cruza, ini...
- 4 Servicio de Secuencias Recibe solicitud de recarga. Verifica su caché en memoria.
- 5 Caché en Memoria (Servidor) Si hay secuencias, las devuelve al cliente y activa recarga asíncrona desde D...
- 6 DynamoDB Si ambas cachés están bajas, el servicio realiza un 'conditional update' atóm...
- 7 Servicio de Secuencias Devuelve el bloque de secuencias al cliente.
- 8 Aplicación Cliente Almacena el nuevo bloque en su caché local y continúa sirviendo.
| Capa | Tecnología | Justificación |
|---|---|---|
| storage | DynamoDB | Fuente de verdad para el estado actual de cada contador de secuencia, garantizando unicidad mediante 'conditional writes'. vs Bases de datos relacionales (MySQL, PostgreSQL), Otros NoSQL (Cassandra, MongoDB) Uso de 'Number' type para el valor del contador. 'UpdateExpression' y 'ConditionExpression' para incrementos atómicos. |
| cache | Caché en memoria (Servidor) | Almacena bloques de secuencias pre-obtenidas para reducir la carga en DynamoDB y mejorar la latencia para los clientes. vs Redis, Valkey, Memcached Cada instancia del servicio mantiene su propia caché no compartida para evitar latencia de red y dependencias externas. |
| cache | Caché en memoria (Cliente - Thick Client SDK) | Elimina la mayoría de las llamadas de red para la generación de secuencias, sirviendo valores directamente desde la memoria de la aplicación. vs Llamadas directas al servicio de secuencias, Cachés compartidas externas Implementado como un SDK embebido, con su propio algoritmo de ventana deslizante para recargas asíncronas. |
| data-processing | Algoritmo de Ventana Deslizante (Sliding Window) | Predice la tasa de consumo de secuencias para activar recargas asíncronas de la caché del cliente y del servidor, optimizando el uso de recursos y minimizando la latencia. vs Umbrales fijos de recarga, Recargas reactivas (al agotarse la caché) Parámetros ajustables: 'window_size', 'sample_interval', 'buffer_seconds', 'min_threshold'. |
| orchestration | Configuración Dinámica Centralizada | Almacena y gestiona los parámetros de configuración de cada secuencia (incremento, tamaño de bloque, límites de caché) de forma centralizada, permitiendo ajustes en tiempo real sin redepliegues. vs Archivos de configuración estáticos, Configuración embebida en el código del cliente |
Trade-offs
Ganancias
- ▲ Disponibilidad
- ▲▲ Latencia
- ▲ Costo Operacional
- ▲ Simplicidad de Migración
Costes
- △ Gaps en secuencias
- △ Monotonicidad global estricta
public synchronized int calculateRefillThreshold() {
double ratePerSecond = (double) totalInWindow / windowSizeSeconds;
int dynamicThreshold = (int) (ratePerSecond * bufferSeconds);
return Math.max(dynamicThreshold, minThreshold);
}public synchronized long next() {
rateCalculator.recordAllocations(1);
int remaining = cachedValues.length - cachePosition;
int threshold = rateCalculator.calculateRefillThreshold();
if (remaining <= threshold && !refillInProgress) {
triggerBackgroundRefill();
}
if (cachePosition >= cachedValues.length) {
blockingRefill();
}
return cachedValues[cachePosition++];
}Fundamentos Teóricos
Este diseño se alinea con los principios de sistemas distribuidos que priorizan la disponibilidad y la tolerancia a particiones sobre la consistencia estricta, un concepto central en el teorema CAP. Al aceptar 'gaps' en las secuencias y relajar la monotonicidad global estricta, el sistema evita la necesidad de protocolos de consenso costosos como Paxos o Raft, que introducirían latencia y complejidad operacionales significativas. La elección de DynamoDB con 'conditional writes' para la asignación de bloques refleja el uso de operaciones atómicas ligeras para garantizar la unicidad, un patrón común en bases de datos NoSQL que ofrecen consistencia eventual o consistencia fuerte en operaciones específicas.
El uso de un algoritmo de 'sliding window' para la predicción de la tasa de consumo y la recarga asíncrona de cachés es una aplicación práctica de técnicas de control de flujo y gestión de recursos en sistemas distribuidos. Estos algoritmos son fundamentales para optimizar el rendimiento y la resiliencia, asegurando que los recursos (en este caso, bloques de secuencias) estén disponibles antes de ser necesarios, minimizando así la latencia percibida por el usuario. La estrategia de caché de dos niveles (cliente y servidor) es un patrón de diseño bien establecido para mejorar la disponibilidad y reducir la carga en la fuente de verdad, similar a las arquitecturas de CDN o las cachés multinivel en sistemas de archivos distribuidos.