Módulo 8: Escalado y Optimización del Rendimiento
Visión General del Módulo
Duración: 8-10 horas
Nivel: Avanzado
Prerrequisitos: Módulo 7 completado
Objetivo: Escalar relés Nostr para atender a millones de usuarios con un rendimiento óptimo
📋 Objetivos de Aprendizaje
Al final de este módulo, serás capaz de:
- ✅ Implementar estrategias de escalado horizontal
- ✅ Diseñar y desplegar capas de caché
- ✅ Optimizar consultas e indexación de bases de datos
- ✅ Implementar balanceo de carga y failover
- ✅ Construir distribución de eventos estilo CDN
- ✅ Monitorear y optimizar cuellos de botella de rendimiento
- ✅ Implementar agrupación de conexiones y gestión de recursos
- ✅ Diseñar para la distribución geográfica
📚 Consideraciones de rendimiento a nivel de protocolo
Estrategias de optimización basadas en NIPs
Comprender cómo distintos NIPs afectan el rendimiento del relé:
| NIP | Impacto en el rendimiento | Estrategia de optimización | Coste de recursos |
|---|---|---|---|
| NIP-01 | Alto (núcleo) | Optimizar la validación de eventos | CPU: Medio, I/O: Alto |
| NIP-02 | Bajo | Cachear listas de contactos | Memoria: Baja |
| NIP-09 | Medio | Procesar eliminaciones de forma asíncrona | I/O: Medio |
| NIP-11 | Despreciable | Servir archivo estático | Memoria: Mínima |
| NIP-13 | Alto | Verificación de PoW | CPU: Alto |
| NIP-42 | Medio | Gestión del estado de autenticación | Memoria: Media |
| NIP-45 | Medio-Alto | Optimización de consultas COUNT | CPU: Medio, I/O: Medio |
| NIP-50 | Muy alto | Índices de búsqueda de texto completo | I/O: Muy alto, Memoria: Alta |
| NIP-51 | Bajo-Medio | Caché de listas | Memoria: Baja |
| NIP-57 | Bajo | Reenviar a LNURL | Red: Baja |
| NIP-65 | Bajo | Cachear listas de relés del usuario | Memoria: Baja |
Optimización del procesamiento de eventos por kind
// Optimizar el procesamiento según las características del evento
class PerformanceOptimizedEventHandler {
constructor() {
this.hotCache = new LRU(10000); // Eventos recientes rápidos
this.statsTracker = new EventStatsTracker();
}
async processEvent(event) {
const kind = event.kind;
const startTime = performance.now();
// Enrutar al manejador optimizado según el rango de kind
let result;
if (kind >= 20000 && kind < 30000) {
// Efímeros: omitir almacenamiento, solo difundir
result = await this.handleEphemeral(event);
} else if (kind >= 10000 && kind < 20000 || kind === 0 || kind === 3) {
// Reemplazables: eliminar el antiguo, almacenar el nuevo
result = await this.handleReplaceable(event);
} else if (kind >= 30000 && kind < 40000) {
// Reemplazables parametrizados: comprobar etiqueta d
result = await this.handleParameterizedReplaceable(event);
} else {
// Eventos regulares: almacenamiento estándar
result = await this.handleRegular(event);
}
// Registrar métricas de rendimiento
const duration = performance.now() - startTime;
this.statsTracker.record(kind, duration);
return result;
}
async handleEphemeral(event) {
// Sin acceso a la base de datos: solo difusión
// Rendimiento: ~0.1ms por evento
await this.broadcastToSubscribers(event);
return { stored: false, broadcasted: true, ttl: 0 };
}
async handleReplaceable(event) {
// Un DELETE + INSERT
// Rendimiento: ~1-2ms por evento
const key = `${event.pubkey}:${event.kind}`;
// Consultar primero la caché
const cached = this.hotCache.get(key);
if (cached && cached.created_at >= event.created_at) {
return { stored: false, reason: 'older_than_cached' };
}
// Usar UPSERT para un reemplazo atómico
await this.db.query(`
INSERT INTO events (id, pubkey, kind, created_at, content, tags, sig)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (pubkey, kind)
DO UPDATE SET
id = EXCLUDED.id,
created_at = EXCLUDED.created_at,
content = EXCLUDED.content,
tags = EXCLUDED.tags,
sig = EXCLUDED.sig
WHERE EXCLUDED.created_at > events.created_at
`, [event.id, event.pubkey, event.kind, event.created_at,
event.content, JSON.stringify(event.tags), event.sig]);
this.hotCache.set(key, event);
await this.broadcastToSubscribers(event);
return { stored: true, broadcasted: true };
}
async handleParameterizedReplaceable(event) {
// Extraer la etiqueta d para el reemplazo parametrizado
const dTag = event.tags.find(t => t[0] === 'd')?.[1] || '';
const key = `${event.pubkey}:${event.kind}:${dTag}`;
// Similar a reemplazable, pero con etiqueta d
await this.db.query(`
INSERT INTO events (id, pubkey, kind, d_tag, created_at, content, tags, sig)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (pubkey, kind, d_tag)
DO UPDATE SET
id = EXCLUDED.id,
created_at = EXCLUDED.created_at,
content = EXCLUDED.content,
tags = EXCLUDED.tags,
sig = EXCLUDED.sig
WHERE EXCLUDED.created_at > events.created_at
`, [event.id, event.pubkey, event.kind, dTag, event.created_at,
event.content, JSON.stringify(event.tags), event.sig]);
this.hotCache.set(key, event);
await this.broadcastToSubscribers(event);
return { stored: true, broadcasted: true };
}
async handleRegular(event) {
// INSERT estándar: el caso más común
// Rendimiento: ~0.5-1ms por evento con índices adecuados
try {
await this.db.query(`
INSERT INTO events (id, pubkey, kind, created_at, content, tags, sig)
VALUES ($1, $2, $3, $4, $5, $6, $7)
`, [event.id, event.pubkey, event.kind, event.created_at,
event.content, JSON.stringify(event.tags), event.sig]);
// Cachear eventos calientes (típicamente kinds 1 y 7)
if (event.kind === 1 || event.kind === 7) {
this.hotCache.set(event.id, event);
}
await this.broadcastToSubscribers(event);
return { stored: true, broadcasted: true };
} catch (err) {
if (err.code === '23505') { // clave duplicada
return { stored: false, reason: 'duplicate' };
}
throw err;
}
}
}
Estrategias de optimización de filtros
// Optimizar planes de consulta según las características del filtro
class SmartFilterExecutor {
constructor(db) {
this.db = db;
this.queryCache = new LRU(1000);
}
async executeFilter(filter, limit = 100) {
// Analizar el filtro para elegir la estrategia de ejecución óptima
const strategy = this.analyzeFilter(filter);
switch (strategy.type) {
case 'ids':
// Búsqueda directa por ID: la más rápida (0.1ms por evento)
return await this.executeIdQuery(filter.ids, limit);
case 'authors_recent':
// Autor + rango temporal: muy rápida con el índice adecuado (1-2ms)
return await this.executeAuthorRecentQuery(
filter.authors,
filter.since || 0,
filter.kinds,
limit
);
case 'kinds_only':
// Consulta solo por kind: velocidad moderada (5-10ms)
return await this.executeKindQuery(filter.kinds, limit);
case 'complex_tag':
// Filtrado complejo de etiquetas: más lento (10-50ms)
return await this.executeTagQuery(filter, limit);
case 'full_scan':
// Último recurso: usar límite de COUNT (50-200ms)
return await this.executeFullScanQuery(filter, Math.min(limit, 100));
default:
return await this.executeGenericQuery(filter, limit);
}
}
analyzeFilter(filter) {
// Puntuar distintas estrategias de consulta
const scores = {
hasIds: filter.ids && filter.ids.length > 0,
hasAuthors: filter.authors && filter.authors.length > 0,
hasKinds: filter.kinds && filter.kinds.length > 0,
hasTimeRange: filter.since || filter.until,
hasTags: Object.keys(filter).some(k => k.startsWith('#')),
authorCount: filter.authors?.length || 0,
kindCount: filter.kinds?.length || 0
};
// Optimizar para patrones comunes
if (scores.hasIds) {
return { type: 'ids', complexity: 1 };
}
if (scores.hasAuthors && scores.authorCount <= 10 && scores.hasTimeRange) {
return { type: 'authors_recent', complexity: 2 };
}
if (scores.hasKinds && !scores.hasAuthors && !scores.hasTags) {
return { type: 'kinds_only', complexity: 3 };
}
if (scores.hasTags) {
return { type: 'complex_tag', complexity: 4 };
}
return { type: 'full_scan', complexity: 5 };
}
async executeIdQuery(ids, limit) {
// Usar ANY para búsqueda por lotes de IDs
return await this.db.query(`
SELECT * FROM events
WHERE id = ANY($1::text[])
LIMIT $2
`, [ids, limit]);
}
async executeAuthorRecentQuery(authors, since, kinds, limit) {
// Optimizado para consultas de timeline (las más comunes)
// Índice: (pubkey, created_at DESC, kind)
let query = `
SELECT * FROM events
WHERE pubkey = ANY($1::text[])
AND created_at >= $2
`;
const params = [authors, since];
if (kinds && kinds.length > 0) {
query += ` AND kind = ANY($3::integer[])`;
params.push(kinds);
}
query += ` ORDER BY created_at DESC LIMIT $${params.length + 1}`;
params.push(limit);
return await this.db.query(query, params);
}
}
Agrupación de mensajes para mayor eficiencia
// Agrupar mensajes para reducir el overhead de WebSocket
class EfficientMessageBatcher {
constructor(ws, options = {}) {
this.ws = ws;
this.batchSize = options.batchSize || 10;
this.batchTimeout = options.batchTimeout || 10; // ms
this.pending = [];
this.timer = null;
}
sendEvent(subscriptionId, event) {
this.pending.push(['EVENT', subscriptionId, event]);
if (this.pending.length >= this.batchSize) {
this.flush();
} else if (!this.timer) {
this.timer = setTimeout(() => this.flush(), this.batchTimeout);
}
}
flush() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
if (this.pending.length === 0) return;
// Enviar todos los mensajes pendientes en una sola escritura
const messages = this.pending.map(msg => JSON.stringify(msg)).join('\n');
this.ws.send(messages);
this.pending = [];
}
sendImmediate(message) {
this.flush();
this.ws.send(JSON.stringify(message));
}
}
Optimización de consultas COUNT (NIP-45)
// Implementación eficiente de COUNT usando HyperLogLog
class OptimizedCountHandler {
constructor(db) {
this.db = db;
this.countCache = new LRU(500);
this.hllCache = new Map(); // Almacenar sketches de HyperLogLog
}
async handleCount(filter) {
const cacheKey = this.getCacheKey(filter);
const cached = this.countCache.get(cacheKey);
if (cached && Date.now() - cached.timestamp < 60000) {
return { count: cached.count };
}
// Usar recuento aproximado para conjuntos de resultados grandes
const estimate = await this.estimateCount(filter);
if (estimate > 10000) {
// Usar HyperLogLog para recuentos muy grandes
const hll = await this.getHyperLogLog(filter);
const count = hll.count();
this.countCache.set(cacheKey, { count, timestamp: Date.now() });
return { count, approximate: true };
}
// Usar recuento exacto para conjuntos de resultados más pequeños
const result = await this.db.query(
this.buildCountQuery(filter)
);
const count = parseInt(result.rows[0].count);
this.countCache.set(cacheKey, { count, timestamp: Date.now() });
return { count };
}
async estimateCount(filter) {
// Estimación rápida usando estadísticas de la tabla
const stats = await this.db.query(`
SELECT reltuples::bigint AS estimate
FROM pg_class
WHERE relname = 'events'
`);
return stats.rows[0].estimate;
}
buildCountQuery(filter) {
// Construir consulta COUNT optimizada
let sql = 'SELECT COUNT(*) FROM events WHERE 1=1';
const params = [];
if (filter.ids) {
params.push(filter.ids);
sql += ` AND id = ANY($${params.length}::text[])`;
}
if (filter.authors) {
params.push(filter.authors);
sql += ` AND pubkey = ANY($${params.length}::text[])`;
}
if (filter.kinds) {
params.push(filter.kinds);
sql += ` AND kind = ANY($${params.length}::integer[])`;
}
if (filter.since) {
params.push(filter.since);
sql += ` AND created_at >= $${params.length}`;
}
if (filter.until) {
params.push(filter.until);
sql += ` AND created_at <= $${params.length}`;
}
return { sql, params };
}
}
Alivio de carga consciente del protocolo
// Rechazar carga de forma inteligente durante tráfico alto
class ProtocolAwareLoadShedder {
constructor(options = {}) {
this.maxConcurrentReqs = options.maxConcurrentReqs || 1000;
this.maxSubscriptionsPerClient = options.maxSubsPerClient || 20;
this.activeRequests = 0;
this.clientSubscriptions = new Map();
}
async handleRequest(clientId, message, handler) {
const [type, ...args] = message;
// Distintas estrategias de alivio de carga por tipo de mensaje
switch (type) {
case 'EVENT':
return await this.handleEventWithBackpressure(clientId, args[0], handler);
case 'REQ':
return await this.handleReqWithLimit(clientId, args[0], args.slice(1), handler);
case 'COUNT':
return await this.handleCountWithThrottle(clientId, args[0], args[1], handler);
case 'CLOSE':
return await this.handleClose(clientId, args[0], handler);
default:
return await handler(message);
}
}
async handleEventWithBackpressure(clientId, event, handler) {
if (this.activeRequests > this.maxConcurrentReqs) {
// Rechazar kinds de evento no críticos durante sobrecarga
const criticalKinds = [0, 3, 10002]; // Metadatos, contactos, lista de relés
if (!criticalKinds.includes(event.kind)) {
return ['OK', event.id, false, 'error: relay overloaded, try again later'];
}
}
this.activeRequests++;
try {
return await handler(event);
} finally {
this.activeRequests--;
}
}
async handleReqWithLimit(clientId, subId, filters, handler) {
// Seguir las suscripciones por cliente
const subs = this.clientSubscriptions.get(clientId) || new Set();
if (subs.size >= this.maxSubscriptionsPerClient && !subs.has(subId)) {
return ['CLOSED', subId, 'error: too many subscriptions'];
}
// Rechazar consultas demasiado amplias durante carga alta
if (this.activeRequests > this.maxConcurrentReqs * 0.8) {
for (const filter of filters) {
if (this.isTooExpensive(filter)) {
return ['CLOSED', subId, 'error: query too expensive, add more filters'];
}
}
}
subs.add(subId);
this.clientSubscriptions.set(clientId, subs);
return await handler(subId, filters);
}
isTooExpensive(filter) {
// Detectar consultas costosas
const hasNoFilters = !filter.ids && !filter.authors && !filter.kinds;
const hasVeryBroadTimeRange = filter.since &&
(Date.now() / 1000 - filter.since) > 86400 * 30; // 30 días
const hasNoLimit = !filter.limit || filter.limit > 1000;
return hasNoFilters || (hasVeryBroadTimeRange && hasNoLimit);
}
}
8.1 Arquitectura de escalado horizontal
Despliegue de relé con múltiples instancias
graph TB
LB[Balanceador de carga<br/>HAProxy/Nginx]
R1[Instancia de relé 1]
R2[Instancia de relé 2]
R3[Instancia de relé 3]
R4[Instancia de relé N]
REDIS[Cluster Redis<br/>Pub/Sub + Caché]
PG_MASTER[PostgreSQL maestro<br/>Operaciones de escritura]
PG_REPLICA1[PostgreSQL réplica 1<br/>Operaciones de lectura]
PG_REPLICA2[PostgreSQL réplica 2<br/>Operaciones de lectura]
LB --> R1
LB --> R2
LB --> R3
LB --> R4
R1 --> REDIS
R2 --> REDIS
R3 --> REDIS
R4 --> REDIS
R1 --> PG_MASTER
R2 --> PG_MASTER
R3 --> PG_MASTER
R4 --> PG_MASTER
R1 --> PG_REPLICA1
R2 --> PG_REPLICA1
R3 --> PG_REPLICA2
R4 --> PG_REPLICA2
PG_MASTER -.->|Replicación| PG_REPLICA1
PG_MASTER -.->|Replicación| PG_REPLICA2
style LB fill:#667eea,stroke:#fff,color:#fff
style REDIS fill:#f093fb,stroke:#fff,color:#fff
style PG_MASTER fill:#4facfe,stroke:#fff,color:#fff
style PG_REPLICA1 fill:#43e97b,stroke:#fff,color:#fff
style PG_REPLICA2 fill:#43e97b,stroke:#fff,color:#fff
Diseño de relé sin estado
class StatelessRelay {
constructor(config) {
this.config = config;
// Estado compartido vía Redis
this.redis = new Redis.Cluster(config.redisNodes);
// Pool de conexiones a la base de datos
this.dbPool = this.createDatabasePool(config.database);
// Pub/Sub para difundir eventos entre instancias
this.pubsub = new RedisPubSub(config.redisNodes);
}
createDatabasePool(dbConfig) {
const { Pool } = require('pg');
return new Pool({
// Conexión al maestro (para escrituras)
master: {
host: dbConfig.master.host,
port: dbConfig.master.port,
database: dbConfig.database,
user: dbConfig.user,
password: dbConfig.password,
max: 20,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
},
// Conexiones a réplicas (para lecturas)
replicas: dbConfig.replicas.map(replica => ({
host: replica.host,
port: replica.port,
database: dbConfig.database,
user: dbConfig.user,
password: dbConfig.password,
max: 50,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
}))
});
}
async publishEvent(event) {
// Escribir en la base de datos maestra
await this.dbPool.master.query(
'INSERT INTO events (id, pubkey, created_at, kind, tags, content, sig) VALUES ($1, $2, $3, $4, $5, $6, $7)',
[event.id, event.pubkey, event.created_at, event.kind, JSON.stringify(event.tags), event.content, event.sig]
);
// Difundir a todas las instancias del relé vía Redis Pub/Sub
await this.pubsub.publish('new_event', JSON.stringify(event));
// Cachear el evento para recuperación rápida
await this.redis.setex(`event:${event.id}`, 3600, JSON.stringify(event));
}
async queryEvents(filter) {
// Usar réplica de lectura para las consultas
const replica = this.dbPool.getRandomReplica();
const events = await replica.query(this.buildQuery(filter));
return events.rows;
}
subscribeToNewEvents(callback) {
// Suscribirse a eventos nuevos de todas las instancias
this.pubsub.subscribe('new_event', (message) => {
const event = JSON.parse(message);
callback(event);
});
}
}
Afinidad de sesión frente a sesiones persistentes (sticky sessions)
// Configuración de HAProxy para balanceo de carga de WebSocket
class LoadBalancerConfig {
static generateHAProxyConfig(instances) {
return `
global
maxconn 100000
log stdout format raw local0
defaults
log global
mode http
timeout connect 5s
timeout client 7d
timeout server 7d
option httplog
frontend nostr_relay
bind *:443 ssl crt /etc/ssl/cert.pem
# Limitación de tasa
stick-table type ip size 1m expire 10s store http_req_rate(10s)
http-request track-sc0 src
http-request deny if { sc_http_req_rate(0) gt 100 }
# Detección de WebSocket
acl is_websocket hdr(Upgrade) -i WebSocket
acl is_websocket hdr_beg(Host) -i ws
# Usar hashing consistente para conexiones WebSocket
# Esto garantiza que el mismo cliente se conecte al mismo backend
use_backend nostr_backends if is_websocket
backend nostr_backends
balance source # Sesiones persistentes basadas en IP
hash-type consistent
# Comprobaciones de salud
option httpchk GET /health
${instances.map((instance, i) =>
`server relay${i + 1} ${instance.host}:${instance.port} check inter 5s fall 3 rise 2`
).join('\n ')}
`;
}
}
8.2 Estrategias de caché
Arquitectura de caché en múltiples capas
class MultiLayerCache {
constructor(config) {
// L1: Caché en memoria (la más rápida, la más pequeña)
this.l1Cache = new LRUCache({
max: 10000,
ttl: 60000, // 1 minuto
updateAgeOnGet: true
});
// L2: Caché Redis (rápida, compartida entre instancias)
this.l2Cache = new Redis.Cluster(config.redisNodes);
// L3: Base de datos (más lenta, persistente)
this.l3Storage = config.database;
}
async get(key, fetcher) {
// Intentar caché L1
let value = this.l1Cache.get(key);
if (value !== undefined) {
return { value, source: 'L1' };
}
// Intentar caché L2
value = await this.l2Cache.get(key);
if (value !== null) {
// Guardar en L1 para la próxima vez
this.l1Cache.set(key, value);
return { value: JSON.parse(value), source: 'L2' };
}
// Obtener de L3 (base de datos)
value = await fetcher();
if (value !== null) {
// Guardar en ambas cachés
this.l1Cache.set(key, value);
await this.l2Cache.setex(key, 3600, JSON.stringify(value));
return { value, source: 'L3' };
}
return { value: null, source: 'MISS' };
}
async set(key, value, ttl = 3600) {
// Escritura a través de todas las capas de caché
this.l1Cache.set(key, value);
await this.l2Cache.setex(key, ttl, JSON.stringify(value));
}
async invalidate(key) {
// Invalidar todas las capas de caché
this.l1Cache.delete(key);
await this.l2Cache.del(key);
}
async invalidatePattern(pattern) {
// Invalidar por patrón (p. ej., "user:123:*")
const keys = await this.l2Cache.keys(pattern);
if (keys.length > 0) {
await this.l2Cache.del(...keys);
}
// La caché L1 no admite coincidencia por patrón, así que se vacía
this.l1Cache.clear();
}
}
Caché inteligente de eventos
class EventCacheManager {
constructor(cache) {
this.cache = cache;
}
async cacheEvent(event) {
// Cachear el evento individual
await this.cache.set(`event:${event.id}`, event, 3600);
// Cachear por kind para filtrado rápido
await this.cacheEventByKind(event);
// Cachear por autor
await this.cacheEventByAuthor(event);
// Cachear eventos referenciados
await this.cacheReferencedEvents(event);
}
async cacheEventByKind(event) {
const key = `events:kind:${event.kind}`;
// Añadir a un sorted set (ordenado por created_at)
await this.cache.l2Cache.zadd(
key,
event.created_at,
event.id
);
// Conservar solo eventos recientes (los últimos 1000)
await this.cache.l2Cache.zremrangebyrank(key, 0, -1001);
await this.cache.l2Cache.expire(key, 3600);
}
async cacheEventByAuthor(event) {
const key = `events:author:${event.pubkey}`;
await this.cache.l2Cache.zadd(
key,
event.created_at,
event.id
);
// Conservar solo los 500 más recientes por autor
await this.cache.l2Cache.zremrangebyrank(key, 0, -501);
await this.cache.l2Cache.expire(key, 7200);
}
async cacheReferencedEvents(event) {
// Cachear relaciones de hilo
const eTags = event.tags.filter(t => t[0] === 'e');
for (const [, eventId] of eTags) {
const key = `event:${eventId}:replies`;
await this.cache.l2Cache.sadd(key, event.id);
await this.cache.l2Cache.expire(key, 3600);
}
}
async getEventsByFilter(filter) {
const cacheKey = this.generateCacheKey(filter);
// Intentar obtener el resultado cacheado
const cached = await this.cache.get(
cacheKey,
() => null // No obtener si no está en caché
);
if (cached.value !== null) {
return cached;
}
// Si hay patrones de filtro específicos, intentar usar índices cacheados
if (filter.kinds && filter.kinds.length === 1) {
return await this.getEventsByKindFromCache(filter);
}
if (filter.authors && filter.authors.length === 1) {
return await this.getEventsByAuthorFromCache(filter);
}
return { value: null, source: 'MISS' };
}
async getEventsByKindFromCache(filter) {
const kind = filter.kinds[0];
const key = `events:kind:${kind}`;
// Obtener IDs de evento del sorted set
const eventIds = await this.cache.l2Cache.zrevrange(
key,
0,
(filter.limit || 100) - 1
);
if (eventIds.length === 0) {
return { value: null, source: 'MISS' };
}
// Obtener eventos de la caché
const events = await Promise.all(
eventIds.map(id => this.cache.get(`event:${id}`, () => null))
);
const validEvents = events
.filter(e => e.value !== null)
.map(e => e.value);
if (validEvents.length > 0) {
return { value: validEvents, source: 'L2_INDEX' };
}
return { value: null, source: 'MISS' };
}
generateCacheKey(filter) {
return `filter:${JSON.stringify(filter)}`;
}
}
Caché de perfiles y metadatos
class ProfileCache {
constructor(cache) {
this.cache = cache;
this.profileTTL = 3600; // 1 hora
}
async getProfile(pubkey) {
return await this.cache.get(
`profile:${pubkey}`,
async () => {
// Obtener de la base de datos
const result = await db.query(
`SELECT * FROM events
WHERE pubkey = $1 AND kind = 0
ORDER BY created_at DESC
LIMIT 1`,
[pubkey]
);
if (result.rows.length > 0) {
return JSON.parse(result.rows[0].content);
}
return null;
}
);
}
async updateProfile(pubkey, profile) {
await this.cache.set(`profile:${pubkey}`, profile, this.profileTTL);
}
async batchGetProfiles(pubkeys) {
// Usar Redis MGET para recuperación por lotes
const keys = pubkeys.map(pk => `profile:${pk}`);
const cached = await this.cache.l2Cache.mget(...keys);
const results = new Map();
const missing = [];
cached.forEach((value, index) => {
const pubkey = pubkeys[index];
if (value !== null) {
results.set(pubkey, JSON.parse(value));
} else {
missing.push(pubkey);
}
});
// Obtener perfiles faltantes de la base de datos
if (missing.length > 0) {
const dbResults = await db.query(
`SELECT DISTINCT ON (pubkey) pubkey, content
FROM events
WHERE pubkey = ANY($1) AND kind = 0
ORDER BY pubkey, created_at DESC`,
[missing]
);
for (const row of dbResults.rows) {
const profile = JSON.parse(row.content);
results.set(row.pubkey, profile);
// Cachear para la próxima vez
await this.cache.set(
`profile:${row.pubkey}`,
profile,
this.profileTTL
);
}
}
return results;
}
}
8.3 Optimización de la base de datos
Ajuste del rendimiento de consultas
-- Analizar el rendimiento de la consulta
EXPLAIN ANALYZE
SELECT * FROM events
WHERE kind = 1
AND created_at > extract(epoch from now() - interval '24 hours')
ORDER BY created_at DESC
LIMIT 100;
-- Crear índice cubriente para consultas comunes
CREATE INDEX CONCURRENTLY idx_events_kind_created_covering
ON events (kind, created_at DESC)
INCLUDE (id, pubkey, tags, content, sig);
-- Particionar tablas grandes por tiempo
CREATE TABLE events_2024_01 PARTITION OF events
FOR VALUES FROM ('2024-01-01') TO ('2024-02-01');
CREATE TABLE events_2024_02 PARTITION OF events
FOR VALUES FROM ('2024-02-01') TO ('2024-03-01');
-- Ajuste de autovacuum para tablas de alta escritura
ALTER TABLE events SET (
autovacuum_vacuum_scale_factor = 0.01,
autovacuum_analyze_scale_factor = 0.01,
autovacuum_vacuum_cost_delay = 10
);
Agrupación de conexiones (connection pooling)
class OptimizedConnectionPool {
constructor(config) {
const { Pool } = require('pg');
// Pool maestro para escrituras
this.masterPool = new Pool({
host: config.master.host,
port: config.master.port,
database: config.database,
user: config.user,
password: config.password,
// Ajustes de pool optimizados
max: 20, // Máximo de conexiones
min: 5, // Mínimo de conexiones inactivas
idleTimeoutMillis: 30000, // Cerrar inactivas tras 30s
connectionTimeoutMillis: 2000, // Fallar rápido
maxUses: 7500, // Reciclar tras 7500 usos
// Tiempo máximo de sentencia
statement_timeout: 10000, // 10s de tiempo máximo de consulta
// Nombre de aplicación para el monitoreo
application_name: 'nostr_relay_master'
});
// Pools de réplica para lecturas (round-robin)
this.replicaPools = config.replicas.map((replica, i) => new Pool({
host: replica.host,
port: replica.port,
database: config.database,
user: config.user,
password: config.password,
max: 50, // Más conexiones para lecturas
min: 10,
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
maxUses: 7500,
statement_timeout: 10000,
application_name: `nostr_relay_replica_${i + 1}`
}));
this.replicaIndex = 0;
}
async write(query, params) {
const client = await this.masterPool.connect();
try {
return await client.query(query, params);
} finally {
client.release();
}
}
async read(query, params) {
// Balanceo round-robin entre réplicas
const pool = this.replicaPools[this.replicaIndex];
this.replicaIndex = (this.replicaIndex + 1) % this.replicaPools.length;
const client = await pool.connect();
try {
return await client.query(query, params);
} finally {
client.release();
}
}
async transaction(callback) {
const client = await this.masterPool.connect();
try {
await client.query('BEGIN');
const result = await callback(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async close() {
await this.masterPool.end();
await Promise.all(this.replicaPools.map(pool => pool.end()));
}
}
Vistas materializadas para analítica
-- Vista materializada de eventos populares (se refresca periódicamente)
CREATE MATERIALIZED VIEW popular_events AS
SELECT
e.id,
e.pubkey,
e.content,
e.created_at,
COUNT(r.id) as reaction_count
FROM events e
LEFT JOIN events r ON r.kind = 7 AND r.tags @> jsonb_build_array(jsonb_build_array('e', e.id))
WHERE e.kind = 1
AND e.created_at > extract(epoch from now() - interval '7 days')
GROUP BY e.id, e.pubkey, e.content, e.created_at
HAVING COUNT(r.id) > 10
ORDER BY reaction_count DESC
LIMIT 1000;
CREATE INDEX idx_popular_events_reactions ON popular_events(reaction_count DESC);
-- Estrategia de refresco (puede hacerse en segundo plano)
REFRESH MATERIALIZED VIEW CONCURRENTLY popular_events;
8.4 Distribución de eventos estilo CDN
Distribución geográfica
class GeographicRelayCluster {
constructor(config) {
this.regions = new Map();
// Definir clústeres regionales de relés
config.regions.forEach(region => {
this.regions.set(region.name, {
name: region.name,
location: region.location,
relays: region.relays,
latency: region.latency
});
});
}
async routeRequest(clientIP) {
// Usar GeoIP para determinar la ubicación del cliente
const clientLocation = await this.getGeoLocation(clientIP);
// Encontrar la región más cercana
const nearestRegion = this.findNearestRegion(clientLocation);
// Devolver las URLs de relé de esa región
return this.regions.get(nearestRegion).relays;
}
findNearestRegion(clientLocation) {
let nearest = null;
let minDistance = Infinity;
for (const [name, region] of this.regions) {
const distance = this.calculateDistance(
clientLocation,
region.location
);
if (distance < minDistance) {
minDistance = distance;
nearest = name;
}
}
return nearest;
}
calculateDistance(loc1, loc2) {
// Fórmula de Haversine para distancia geográfica
const R = 6371; // Radio de la Tierra en km
const dLat = this.toRad(loc2.lat - loc1.lat);
const dLon = this.toRad(loc2.lon - loc1.lon);
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(this.toRad(loc1.lat)) * Math.cos(this.toRad(loc2.lat)) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
toRad(degrees) {
return degrees * (Math.PI / 180);
}
}
Estrategia de replicación de eventos
class EventReplicationManager {
constructor(config) {
this.localRegion = config.localRegion;
this.remoteRegions = config.remoteRegions;
this.replicationQueue = new Queue('event_replication');
}
async replicateEvent(event) {
// Almacenar primero en local (lo más rápido)
await this.storeLocally(event);
// Encolar la replicación hacia otras regiones
await this.queueReplication(event);
}
async storeLocally(event) {
// Almacenar en la base de datos local
await db.query(
'INSERT INTO events (...) VALUES (...)',
[event.id, event.pubkey, /* ... */]
);
// Cachear en local
await cache.set(`event:${event.id}`, event);
}
async queueReplication(event) {
// Añadir a la cola de replicación para cada región remota
for (const region of this.remoteRegions) {
await this.replicationQueue.add({
event,
targetRegion: region,
priority: this.getReplicationPriority(event)
});
}
}
getReplicationPriority(event) {
// Mayor prioridad para tipos de evento importantes
const priorityByKind = {
0: 10, // Metadatos - alta prioridad
1: 5, // Notas - prioridad media
3: 10, // Contactos - alta prioridad
7: 2, // Reacciones - prioridad más baja
};
return priorityByKind[event.kind] || 5;
}
async processReplicationQueue() {
this.replicationQueue.process(async (job) => {
const { event, targetRegion } = job.data;
try {
// Enviar evento a la región destino
await this.sendToRegion(event, targetRegion);
} catch (error) {
// Reintentar con retroceso exponencial
if (job.attemptsMade < 5) {
throw error; // Se reintentará
}
console.error(`Failed to replicate to ${targetRegion}:`, error);
}
});
}
async sendToRegion(event, region) {
const response = await fetch(`${region.apiEndpoint}/replicate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Replication-Token': region.token
},
body: JSON.stringify(event)
});
if (!response.ok) {
throw new Error(`Replication failed: ${response.statusText}`);
}
}
}
8.5 Monitoreo del rendimiento
Panel de métricas en tiempo real
class PerformanceMonitor {
constructor(prometheus) {
this.metrics = {
// Métricas de latencia
queryLatency: new prometheus.Histogram({
name: 'nostr_query_latency_seconds',
help: 'Query execution latency',
labelNames: ['operation', 'cache_hit'],
buckets: [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5]
}),
// Métricas de rendimiento (throughput)
eventsPerSecond: new prometheus.Gauge({
name: 'nostr_events_per_second',
help: 'Events processed per second'
}),
queriesPerSecond: new prometheus.Gauge({
name: 'nostr_queries_per_second',
help: 'Queries executed per second'
}),
// Métricas de caché
cacheHitRate: new prometheus.Gauge({
name: 'nostr_cache_hit_rate',
help: 'Cache hit rate percentage',
labelNames: ['layer']
}),
// Métricas de base de datos
dbConnectionPool: new prometheus.Gauge({
name: 'nostr_db_connections',
help: 'Database connection pool status',
labelNames: ['pool', 'state']
}),
// Utilización de recursos
cpuUtilization: new prometheus.Gauge({
name: 'nostr_cpu_utilization_percent',
help: 'CPU utilization percentage'
}),
memoryUtilization: new prometheus.Gauge({
name: 'nostr_memory_utilization_bytes',
help: 'Memory utilization in bytes',
labelNames: ['type']
}),
// Métricas de WebSocket
activeWebSockets: new prometheus.Gauge({
name: 'nostr_active_websockets',
help: 'Number of active WebSocket connections'
}),
websocketMessageRate: new prometheus.Counter({
name: 'nostr_websocket_messages_total',
help: 'Total WebSocket messages',
labelNames: ['direction', 'type']
})
};
this.startCollection();
}
startCollection() {
// Recolectar métricas cada segundo
setInterval(() => {
this.collectSystemMetrics();
this.collectCacheMetrics();
this.collectDatabaseMetrics();
}, 1000);
}
collectSystemMetrics() {
const usage = process.cpuUsage();
const mem = process.memoryUsage();
this.metrics.cpuUtilization.set(
(usage.user + usage.system) / 1000000
);
this.metrics.memoryUtilization.set(
{ type: 'heap_used' },
mem.heapUsed
);
this.metrics.memoryUtilization.set(
{ type: 'rss' },
mem.rss
);
}
async collectCacheMetrics() {
// Estadísticas de caché L1
const l1Stats = cache.l1Cache.size / cache.l1Cache.max;
this.metrics.cacheHitRate.set({ layer: 'L1' }, l1Stats * 100);
// Estadísticas de caché L2 (Redis)
const info = await cache.l2Cache.info('stats');
const hitRate = this.parseRedisHitRate(info);
this.metrics.cacheHitRate.set({ layer: 'L2' }, hitRate);
}
collectDatabaseMetrics() {
// Estadísticas del pool de conexiones
this.metrics.dbConnectionPool.set(
{ pool: 'master', state: 'total' },
dbPool.masterPool.totalCount
);
this.metrics.dbConnectionPool.set(
{ pool: 'master', state: 'idle' },
dbPool.masterPool.idleCount
);
this.metrics.dbConnectionPool.set(
{ pool: 'master', state: 'waiting' },
dbPool.masterPool.waitingCount
);
}
recordQuery(operation, duration, cacheHit) {
this.metrics.queryLatency.observe(
{ operation, cache_hit: cacheHit ? 'true' : 'false' },
duration
);
}
parseRedisHitRate(info) {
const match = info.match(/keyspace_hits:(\d+).*keyspace_misses:(\d+)/s);
if (match) {
const hits = parseInt(match[1]);
const misses = parseInt(match[2]);
return (hits / (hits + misses)) * 100;
}
return 0;
}
}
Reglas de alerta
# prometheus-alerts.yml
groups:
- name: nostr_relay_alerts
interval: 30s
rules:
# Alta latencia de consultas
- alert: HighQueryLatency
expr: histogram_quantile(0.95, nostr_query_latency_seconds) > 1
for: 5m
labels:
severity: warning
annotations:
summary: "High query latency detected"
description: "95th percentile query latency is above 1s"
# Baja tasa de aciertos de caché
- alert: LowCacheHitRate
expr: nostr_cache_hit_rate{layer="L2"} < 70
for: 10m
labels:
severity: warning
annotations:
summary: "Low cache hit rate"
description: "L2 cache hit rate is below 70%"
# Agotamiento del pool de conexiones de la base de datos
- alert: DatabasePoolExhaustion
expr: nostr_db_connections{pool="master",state="waiting"} > 5
for: 2m
labels:
severity: critical
annotations:
summary: "Database connection pool exhausted"
description: "More than 5 connections waiting"
# Alta utilización de CPU
- alert: HighCPUUtilization
expr: nostr_cpu_utilization_percent > 80
for: 5m
labels:
severity: warning
annotations:
summary: "High CPU utilization"
description: "CPU utilization is above 80%"
# Presión de memoria
- alert: HighMemoryUsage
expr: nostr_memory_utilization_bytes{type="rss"} > 8e9
for: 5m
labels:
severity: warning
annotations:
summary: "High memory usage"
description: "Memory usage is above 8GB"
# Caída en la tasa de procesamiento de eventos
- alert: LowEventProcessingRate
expr: rate(nostr_events_received_total[5m]) < 10
for: 10m
labels:
severity: info
annotations:
summary: "Low event processing rate"
description: "Events per second dropped below 10"
8.6 Pruebas de carga y benchmarking
Suite de pruebas de rendimiento
const WebSocket = require('ws');
const { performance } = require('perf_hooks');
class RelayLoadTester {
constructor(config) {
this.relayUrl = config.relayUrl;
this.concurrentConnections = config.concurrentConnections || 1000;
this.testDuration = config.testDuration || 60000; // 1 minuto
this.eventRate = config.eventRate || 100; // eventos por segundo
}
async runLoadTest() {
console.log(`Starting load test:`);
console.log(`- Relay: ${this.relayUrl}`);
console.log(`- Connections: ${this.concurrentConnections}`);
console.log(`- Duration: ${this.testDuration}ms`);
console.log(`- Event rate: ${this.eventRate}/s`);
const results = {
connections: {
attempted: 0,
successful: 0,
failed: 0
},
events: {
sent: 0,
accepted: 0,
rejected: 0
},
queries: {
sent: 0,
responses: 0,
avgLatency: 0
},
errors: []
};
// Crear conexiones
const connections = await this.createConnections(results);
// Ejecutar la prueba
await this.runTest(connections, results);
// Limpieza
connections.forEach(ws => ws.close());
return this.generateReport(results);
}
async createConnections(results) {
const connections = [];
for (let i = 0; i < this.concurrentConnections; i++) {
try {
const ws = new WebSocket(this.relayUrl);
await new Promise((resolve, reject) => {
ws.on('open', () => {
results.connections.successful++;
resolve();
});
ws.on('error', (error) => {
results.connections.failed++;
results.errors.push(error.message);
reject(error);
});
setTimeout(() => reject(new Error('Connection timeout')), 5000);
});
connections.push(ws);
results.connections.attempted++;
} catch (error) {
// Continuar con las conexiones disponibles
}
}
return connections;
}
async runTest(connections, results) {
const startTime = performance.now();
const endTime = startTime + this.testDuration;
// Trabajadores de publicación de eventos
const publishInterval = 1000 / this.eventRate;
const publishTimer = setInterval(() => {
if (performance.now() > endTime) {
clearInterval(publishTimer);
return;
}
const ws = connections[Math.floor(Math.random() * connections.length)];
this.publishTestEvent(ws, results);
}, publishInterval);
// Trabajadores de consultas
const queryInterval = setInterval(() => {
if (performance.now() > endTime) {
clearInterval(queryInterval);
return;
}
const ws = connections[Math.floor(Math.random() * connections.length)];
this.sendTestQuery(ws, results);
}, 100);
// Esperar la duración de la prueba
await new Promise(resolve => setTimeout(resolve, this.testDuration));
clearInterval(publishTimer);
clearInterval(queryInterval);
}
publishTestEvent(ws, results) {
const event = this.generateTestEvent();
const startTime = performance.now();
ws.send(JSON.stringify(['EVENT', event]));
results.events.sent++;
// Escuchar la respuesta OK
const onMessage = (data) => {
const msg = JSON.parse(data);
if (msg[0] === 'OK' && msg[1] === event.id) {
const latency = performance.now() - startTime;
if (msg[2] === true) {
results.events.accepted++;
} else {
results.events.rejected++;
}
ws.off('message', onMessage);
}
};
ws.on('message', onMessage);
}
sendTestQuery(ws, results) {
const subId = `test_${Date.now()}_${Math.random()}`;
const startTime = performance.now();
let eventCount = 0;
ws.send(JSON.stringify(['REQ', subId, { kinds: [1], limit: 10 }]));
results.queries.sent++;
const onMessage = (data) => {
const msg = JSON.parse(data);
if (msg[0] === 'EVENT' && msg[1] === subId) {
eventCount++;
}
if (msg[0] === 'EOSE' && msg[1] === subId) {
const latency = performance.now() - startTime;
results.queries.responses++;
results.queries.avgLatency =
(results.queries.avgLatency * (results.queries.responses - 1) + latency) /
results.queries.responses;
// Cerrar suscripción
ws.send(JSON.stringify(['CLOSE', subId]));
ws.off('message', onMessage);
}
};
ws.on('message', onMessage);
}
generateTestEvent() {
const { generatePrivateKey, getPublicKey, finishEvent } = require('nostr-tools');
const privateKey = generatePrivateKey();
const publicKey = getPublicKey(privateKey);
return finishEvent({
kind: 1,
created_at: Math.floor(Date.now() / 1000),
tags: [],
content: `Load test event ${Date.now()}`
}, privateKey);
}
generateReport(results) {
const connectionSuccessRate =
(results.connections.successful / results.connections.attempted) * 100;
const eventAcceptRate =
(results.events.accepted / results.events.sent) * 100;
return {
summary: {
connectionSuccessRate: connectionSuccessRate.toFixed(2) + '%',
totalEventsSent: results.events.sent,
eventAcceptRate: eventAcceptRate.toFixed(2) + '%',
averageQueryLatency: results.queries.avgLatency.toFixed(2) + 'ms',
queriesPerSecond: (results.queries.sent / (this.testDuration / 1000)).toFixed(2)
},
details: results,
recommendations: this.generateRecommendations(results)
};
}
generateRecommendations(results) {
const recommendations = [];
if (results.connections.successful / results.connections.attempted < 0.95) {
recommendations.push('Consider increasing max connections or connection timeout');
}
if (results.events.accepted / results.events.sent < 0.95) {
recommendations.push('High event rejection rate - check validation rules');
}
if (results.queries.avgLatency > 100) {
recommendations.push('High query latency - optimize database indexes or add caching');
}
return recommendations;
}
}
// Uso
const tester = new RelayLoadTester({
relayUrl: 'ws://localhost:8080',
concurrentConnections: 1000,
testDuration: 60000,
eventRate: 100
});
tester.runLoadTest().then(report => {
console.log('\nLoad Test Report:');
console.log(JSON.stringify(report, null, 2));
});
8.7 Ejercicios prácticos
Ejercicio 1: Implementar escalado horizontal
Configura un despliegue de relé con múltiples instancias: 1. Despliega 3 instancias de relé 2. Configura Redis Pub/Sub para la difusión de eventos 3. Configura el balanceador de carga HAProxy 4. Prueba escenarios de failover
Ejercicio 2: Construir caché en múltiples capas
Implementa una caché completa: 1. Configura una caché LRU en memoria (L1) 2. Configura un clúster Redis (L2) 3. Implementa el patrón cache-aside 4. Mide las tasas de acierto de caché
Ejercicio 3: Optimizar el rendimiento de la base de datos
Mejora la eficiencia de la base de datos: 1. Analiza consultas lentas con EXPLAIN 2. Añade índices cubrientes 3. Implementa réplicas de lectura 4. Configura agrupación de conexiones
Ejercicio 4: Pruebas de carga
Realiza pruebas de rendimiento: 1. Ejecuta una prueba de carga con 1000 conexiones concurrentes 2. Mide la latencia a distintos niveles de carga 3. Identifica cuellos de botella 4. Optimiza según los resultados
Ejercicio 5: Distribución geográfica
Configura un despliegue multi-región: 1. Despliega relés en 3 regiones geográficas 2. Implementa geo-enrutamiento 3. Configura la replicación de eventos 4. Mide la latencia entre regiones
📝 Cuestionario del Módulo 8
-
¿Cuál es el principal beneficio de un diseño de relé sin estado?
Respuesta
El diseño sin estado permite el escalado horizontal al eliminar la dependencia del estado local. Cualquier instancia puede atender cualquier solicitud, lo que habilita el balanceo de carga y la adición o eliminación sencilla de instancias sin migrar sesiones. -
¿Por qué usar múltiples capas de caché (L1, L2, L3)?
Respuesta
Cada capa intercambia velocidad por capacidad: L1 (en memoria) es la más rápida pero la más pequeña, L2 (Redis) es rápida y se comparte entre instancias, L3 (base de datos) es más lenta pero persistente. La caché en múltiples capas maximiza la tasa de aciertos y minimiza la latencia. -
¿Cuál es la ventaja de las réplicas de lectura frente a solo escalar la base de datos maestra?
Respuesta
Las réplicas de lectura descargan las consultas de lectura del maestro, permitiéndole centrarse en las escrituras. Esto ofrece mejor rendimiento de escritura, escalado horizontal de lecturas y distribución geográfica para lecturas de baja latencia. -
¿Cómo mejora el rendimiento la replicación de eventos entre regiones?
Respuesta
La replicación regional reduce la latencia al servir eventos desde servidores geográficamente más cercanos. También aporta redundancia y permite a los usuarios consultar su región local sin esperar a transferencias de datos entre regiones. -
¿Qué métricas son las más importantes para identificar cuellos de botella de rendimiento?
Respuesta
Latencia de consultas (p95, p99), tasas de acierto de caché, utilización del pool de conexiones de la base de datos, uso de CPU/memoria y eventos/consultas por segundo. Revelan si los cuellos de botella están en la base de datos, la caché, el cómputo o la red.
🎯 Punto de control del Módulo 8
Antes de pasar al Módulo 9, asegúrate de haber:
- Desplegado un clúster de relés con escalado horizontal
- Implementado caché en múltiples capas con tasas de acierto medibles
- Configurado réplicas de lectura y agrupación de conexiones
- Configurado balanceo de carga con failover
- Implementado monitoreo de rendimiento completo
- Realizado pruebas de carga y optimizado cuellos de botella
- Documentado la arquitectura de escalado y los runbooks
- Alcanzado las métricas de rendimiento objetivo (latencia p95 < 100ms)
📚 Recursos adicionales
- Replicación de PostgreSQL
- Tutorial de Redis Cluster
- Guía de configuración de HAProxy
- Mejores prácticas de Prometheus
- Agrupación de conexiones de bases de datos
- Herramientas de pruebas de carga
- Patrones de arquitectura CDN
💬 Discusión de la comunidad
Únete a nuestro Discord para hablar del Módulo 8: - Comparte tus estrategias de escalado y resultados - Obtén ayuda con la optimización del rendimiento - Debate metodologías de pruebas de carga - Colabora en benchmarking
¡Felicidades!
Has dominado el escalado y la optimización del rendimiento de relés Nostr. Ahora puedes atender a millones de usuarios con un rendimiento óptimo. ¡Listo para el Módulo 9: Mejores prácticas de seguridad!