Optimización de Backends Fastify para Aplicaciones de Alta Carga: Soluciones Prácticas
Construir backends escalables y de alto rendimiento exige una comprensión profunda de las compensaciones arquitectónicas. En este artículo, exploraremos nuestra experiencia desarrollando una aplicación de alta carga utilizando Fastify, centrándonos en estrategias para minimizar la carga de la base de datos, identificar usuarios de manera efectiva sin autenticación tradicional y proteger contra solicitudes maliciosas, aprovechando herramientas como los filtros de Bloom y las operaciones asíncronas.
Estrategias de Generación de IDs y Gestión Eficiente de Datos
Al diseñar sistemas que manejan un gran número de entidades, como usuarios o imágenes, la elección del formato del identificador (ID) se vuelve de vital importancia. Los GUIDs o UUIDs estándar, si bien garantizan la unicidad, pueden ser excesivamente intensivos en recursos para la indexación de bases de datos y el procesamiento del backend. Para reducir esta sobrecarga, implementamos un formato de ID híbrido: 19bc19e00ab-eh5kkeh99q7. La primera parte representa una marca de tiempo Unix convertida a hexadecimal, y la segunda es una secuencia aleatoria de caracteres. El guion sirve puramente para mejorar la legibilidad.
Aquí hay una función de ejemplo para generar dicho ID:
function generateID(): string {
const timestamp = Date.now().toString(16);
const randomPart = generateRandomSequence( 11 );
return timestamp + '-' + randomPart;
}
Este enfoque proporciona un ID "condicionalmente incremental", ya que su parte inicial cambia con cada milisegundo, lo que contribuye a una indexación de base de datos más eficiente. Un beneficio adicional es la capacidad de decodificar la fecha de creación de la entidad directamente desde el ID, eliminando la necesidad de un campo created_at separado en las tablas. Esto reduce el volumen de datos almacenados y el número de operaciones de escritura, aunque impone pequeñas limitaciones en la conveniencia de construir consultas basadas en fechas.
Escalado con Filtros de Bloom y Autenticación sin Contraseña
Desarrollar una aplicación diseñada para miles de sesiones concurrentes y decenas de miles de imágenes requiere enfoques que minimicen el consumo de CPU y memoria. Las consultas SQL tradicionales para verificar imágenes vistas o usuarios existentes se convierten rápidamente en un cuello de botella. En este contexto, los filtros de Bloom desempeñan un papel inestimable.
Un filtro de Bloom es una estructura de datos probabilística que puede determinar de manera eficiente (por ejemplo, con un 99.9999% de probabilidad) si un elemento no está en un conjunto. Su principal ventaja es un consumo de memoria y CPU extremadamente bajo en comparación con las estructuras de datos clásicas al verificar la pertenencia. Las aplicaciones principales de los filtros de Bloom en nuestro proyecto incluyen:
- Seguimiento de Imágenes Vistas: Para cada usuario, se mantiene un filtro de Bloom que almacena los IDs de las imágenes ya mostradas. Esto permite verificaciones rápidas para saber si una imagen ya ha sido presentada al usuario, eliminando duplicados de nuevos lotes.
- Identificación de Usuarios Existentes: Al iniciar el backend, todos los IDs de usuario registrados se cargan en un filtro de Bloom global. Esto proporciona una verificación instantánea de si un
device_identrante ya es conocido por el sistema.
Para mejorar la comodidad del usuario y simplificar la moderación de las tiendas de aplicaciones, se implementó un sistema de identificación sin autenticación explícita. Cuando se instala la aplicación, se genera un device_id único, que luego se utiliza para identificar al usuario en el backend. Para proteger contra la generación de device_ids falsos y el abuso, se emplea un proceso de verificación en varias etapas:
- Validación de Esquema JSON de Fastify: La validación inicial del formato
device_idocurre a nivel de Fastify utilizando un esquema JSON que defineminLength,maxLengthy unpattern.
```javascript
fastify.post( '/backend', {
schema: {
body: {
type: 'object',
required: [ 'device_id' ],
properties: {
device_id: {
type: 'string',
minLength: 23,
maxLength: 23,
pattern: '^[a-z0-9]{11}-[a-z0-9]{11}$'
}
}
}
}
} );
```
- Filtro de Bloom de Usuarios: Después de pasar el esquema de Fastify, el
device_idse verifica contra el filtro de Bloom de usuarios existentes. - Verificación de la Corrección del ID:
* Fecha de Creación: La primera parte del ID, que codifica la marca de tiempo, debe caer dentro del período entre el lanzamiento del proyecto y la fecha actual.
* Entropía de la Parte Aleatoria: La segunda parte del ID, que representa una secuencia aleatoria, se verifica en cuanto a su entropía. Esto evita el uso de IDs simples y predecibles (por ejemplo, aaaaaaaaaaaa). La entropía debe estar por encima de un cierto umbral (por ejemplo, 2).
```javascript
function getEntropyString( str: string ): number {
const frequencies = new Map();
for (const char of str) {
frequencies.set(char, (frequencies.get(char) || 0) + 1); }
return [...frequencies.values()].reduce((acc, count) => {
const p = count / str.length;
return acc - p * Math.log2(p);
},
0);
}
```
- Inserción Asíncrona de Usuarios: Si el ID se considera nuevo y válido, se añade al filtro de Bloom de usuarios existentes, y luego se envía una consulta
INSERTde forma asíncrona a la base de datos. Omitirawaitevita bloquear el procesamiento de la solicitud, reduciendo significativamente la carga del backend. Este enfoque acepta una probabilidad extremadamente baja de perder nuevos datos de usuario durante un reinicio inesperado del backend, lo cual es una compensación aceptable para este tipo de aplicación, ya que la "pérdida" simplemente significa reiniciar el historial de deslizamientos para ese usuario.
Optimización de la Interacción con el Cliente y Protección de la API
La interacción eficiente entre la aplicación móvil y el backend es de vital importancia para el rendimiento. Para servir contenido estático, como imágenes, se utiliza Nginx, operando en un dominio separado. Esto descarga al máximo el servidor Fastify, permitiéndole centrarse en la lógica de negocio, y posibilita un almacenamiento en caché de contenido efectivo.
La aplicación móvil recibe imágenes en lotes de 10. Los IDs de estas imágenes también se añaden a un filtro de Bloom de "Imágenes Vistas" específico para cada usuario. Este enfoque minimiza el número de solicitudes API: la aplicación carga dos lotes al inicio y luego solicita un nuevo lote después de cada 10 "me gusta". Esto asegura un flujo de contenido continuo mientras el usuario interactúa con el lote actual.
Para proteger la API de cargas excesivas y abusos, se implementa la limitación de velocidad (rate limiting) utilizando el plugin rateLimit para Fastify. Dependiendo de la arquitectura, puede usar Redis para el almacenamiento distribuido de datos u operar en la memoria de la aplicación Node.js para instancias únicas.
Un aspecto importante es también la validación de las reacciones del usuario (me gusta/no me gusta), que se envían en lotes de 10. Este proceso implica varios niveles de validación:
- Validación de Esquema de Fastify: El ID de usuario, el ID de imagen y la reacción en sí (como un
enum'like' o 'dislike') se verifican contra un esquema JSON. - Filtro de Bloom de Usuarios: Confirmación de la existencia del
user_ida través del filtro de Bloom de "Usuarios Existentes". - Filtro de Bloom de Imágenes Vistas: Verificación de que el
image_idfue realmente mostrado al usuario actual, utilizando su filtro de Bloom personal de "Imágenes Vistas". - Verificación de Autenticidad del Lote de Reacciones: Se implementa una función,
trustLikeInBatch10, para analizar patrones dentro de un lote de 10 reacciones. Si todas las reacciones son idénticas (todos "me gusta" o todos "no me gusta") o si hay una alternancia excesivamente frecuente (por ejemplo, 8 o más cambios), el lote se considera poco fiable y se ignora. Esto ayuda a filtrar acciones automatizadas o maliciosas.
```javascript
function trustLikeInBatch10( batch: string[] ): boolean {
if( batch.length != feedLimit ) return false;
const likes = batch.filter( v => v === 'like' ).length;
if( likes === 0 || likes === feedLimit ) return false;
const switches = batch.slice(1).reduce( ( acc, val, i ) => acc + ( val !== batch[ i ] ? 1 : 0 ), 0 );
if( switches >= 8 ) return false;
return true;
}
```
- Escrituras Asíncronas de Reacciones: Similar a los nuevos usuarios, las reacciones se escriben de forma asíncrona en la base de datos sin esperar la finalización de la operación y sin usar restricciones de
FOREIGN KEY. Este enfoque, aunque poco convencional, reduce significativamente la carga de la base de datos durante flujos de datos de alta intensidad, sacrificando la consistencia estricta por el rendimiento. En este contexto, la posible pérdida de algunas reacciones no es crítica para las estadísticas generales y el sistema de recomendación.
Para mejorar la seguridad y prevenir exploits relacionados con los detalles de validación de la API, los errores generados por Fastify debido a discrepancias en el esquema JSON se enmascaran. En lugar de una descripción detallada del problema, el backend devuelve una respuesta genérica. Esto dificulta que los atacantes realicen ingeniería inversa de los esquemas de datos.
fastify.setErrorHandler((
error: FastifyError,
request: FastifyRequest,
reply: FastifyReply ) =>
{
console.error( 'setErrorHandler', error );
countErrorStatistics();
reply.code(200).send( 'Hello World!' );
});
La aplicación cliente debe estar preparada para manejar tales respuestas genéricas, lo cual forma parte de la estrategia de seguridad general.
Aprovechando TypeScript para la Fiabilidad del Código
El uso de TypeScript mejora significativamente la fiabilidad y mantenibilidad del código. Específicamente, al trabajar con conjuntos fijos de estados de cadena (por ejemplo, estados de imagen), se puede crear elegantemente un tipo a partir de un array de cadenas. Esto asegura un tipado estricto para las variables, eliminando errores relacionados con erratas o el uso de valores inválidos.
// Usually it's like this:
const imageStateArray1 = [ 'new', 'broken', 'gpt', 'ready', 'download', 'done', 'bad' ];
type tpImageState1 = 'new' | 'broken' | 'gpt' | 'ready' | 'download' | 'done' | 'bad';
// or better yet:
const imageStateArray2 = [ 'new', 'broken', 'gpt', 'ready', 'download', 'done', 'bad' ] as const;
type tpImageState2 = ( typeof imageStateArray2 )[ number ];
El segundo enfoque, utilizando as const y (typeof array)[number], es más preferible ya que infiere automáticamente el tipo literal del array, asegurando la sincronización entre los valores del array y su tipo sin duplicación.
Conclusiones Clave
- El uso de filtros de Bloom reduce significativamente la carga de la base de datos al verificar la existencia de elementos, lo cual es de vital importancia para sistemas de alta carga.
- La generación de IDs personalizados, incorporando una marca de tiempo, permite incrustar metadatos y optimizar la indexación, así como eliminar campos
created_atredundantes. - Las operaciones de escritura asíncronas en la base de datos y la omisión de restricciones de
FOREIGN KEYaumentan el rendimiento del backend a costa de una posible, pero aceptable, pérdida de datos durante fallos en aplicaciones de alta intensidad. - La validación detallada de las solicitudes entrantes a nivel de Fastify y las verificaciones de entropía de datos protegen la API contra abusos y datos mal formados.
- El enmascaramiento de errores de validación de Fastify mejora la seguridad de la API al ocultar la estructura interna de los esquemas y dificultar la ingeniería inversa para posibles atacantes.
— Editorial Team
Aún no hay comentarios.