kb.erickguedes.com
Node.js: Backend JavaScript em Produção

Clustering e Performance

Aula 6 de 7

Clustering

Node.js é single-thread, mas podemos usar múltiplos processos com o módulo cluster para aproveitar todos os CPUs.

Cluster Module

const cluster = require('cluster');
const http = require('http');
const os = require('os');

const numCPUs = os.cpus().length;

if (cluster.isPrimary) {
  console.log(`Primary ${process.pid} rodando`);

  // Fork workers
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  cluster.on('exit', (worker, code, signal) => {
    console.log(`Worker ${worker.process.pid} morreu. Reiniciando...`);
    cluster.fork(); // Auto-healing
  });

  cluster.on('message', (worker, msg) => {
    console.log(`Mensagem do worker ${worker.id}:`, msg);
  });
} else {
  // Workers compartilham a porta TCP
  http.createServer((req, res) => {
    // Enviar mensagem ao primary
    process.send({ type: 'request', pid: process.pid });

    res.writeHead(200);
    res.end(`Worker ${process.pid} processou a requisição\n`);
  }).listen(3000);

  console.log(`Worker ${process.pid} iniciado`);
}

PM2 (Production Process Manager)

npm install -g pm2
# Iniciar em modo cluster
pm2 start src/server.js -i max
pm2 start src/server.js -i 4  # 4 instâncias

# Gerenciar
pm2 list
pm2 status
pm2 logs
pm2 monit
pm2 restart all
pm2 reload all  # zero downtime
pm2 stop app
pm2 delete app
// ecosystem.config.cjs
module.exports = {
  apps: [{
    name: 'api',
    script: 'src/server.js',
    instances: 'max',
    exec_mode: 'cluster',
    env: {
      NODE_ENV: 'production',
      PORT: 3000
    },
    env_production: {
      NODE_ENV: 'production'
    },
    max_memory_restart: '1G',
    error_file: './logs/err.log',
    out_file: './logs/out.log',
    merge_logs: true,
    log_date_format: 'YYYY-MM-DD HH:mm:ss',
    watch: false,
    max_restarts: 10,
    restart_delay: 4000
  }]
};

Load Balancing

O módulo cluster distribui requisições entre workers usando round-robin (no Linux) ou delegando ao SO (no Windows).

// Estratégia round-robin (forçar)
cluster.schedulingPolicy = cluster.SCHED_RR;

Performance Profiling

Node.js --prof

# Gerar perfil
node --prof src/server.js

# Analisar
node --prof-process isolate-*.log > profiled.txt

0x Clinic.js

npm install -g 0x

# Flamegraph
0x src/server.js

# Doctor (análise de performance)
npx clinic doctor -- node src/server.js

# Bubbleprof (latência)
npx clinic bubbleprof -- node src/server.js

# Flame (flamegraph detalhado)
npx clinic flame -- node src/server.js

Memory Leaks

// Detectar vazamentos com heap snapshot
const v8 = require('v8');

// Criar snapshot
const snapshot = v8.getHeapSnapshot();
snapshot.pipe(require('fs').createWriteStream('heap.heapsnapshot'));

// Monitorar memória
setInterval(() => {
  const usage = process.memoryUsage();
  console.log({
    rss: `${(usage.rss / 1024 / 1024).toFixed(2)} MB`,
    heapTotal: `${(usage.heapTotal / 1024 / 1024).toFixed(2)} MB`,
    heapUsed: `${(usage.heapUsed / 1024 / 1024).toFixed(2)} MB`,
    external: `${(usage.external / 1024 / 1024).toFixed(2)} MB`
  });
}, 5000);

// Causa comum de leak: listeners não removidos
process.on('warning', (warning) => {
  if (warning.name === 'MaxListenersExceededWarning') {
    console.error('Possível leak de memória!');
  }
});

Compressão

npm install compression
const compression = require('compression');
const express = require('express');
const app = express();

app.use(compression({
  level: 6, // zlib level (0-9)
  threshold: 1024, // mínimo 1KB para comprimir
  filter: (req, res) => {
    if (req.headers['x-no-compression']) return false;
    return compression.filter(req, res);
  }
}));

Connection Pooling

PostgreSQL (pg-pool)

const { Pool } = require('pg');

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20, // máximo de conexões no pool
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000
});

async function query(text, params) {
  const start = Date.now();
  const res = await pool.query(text, params);
  const duration = Date.now() - start;
  console.log('Query executada', { text, duration, rows: res.rowCount });
  return res;
}

Prisma Connection Pool

const { PrismaClient } = require('@prisma/client');

const prisma = new PrismaClient({
  datasources: {
    db: {
      url: process.env.DATABASE_URL
    }
  },
  log: ['query', 'info', 'warn', 'error'],
  // Config do pool (configurado via DATABASE_URL)
  // Postgres: ?connection_limit=20&pool_timeout=10
});

Redis Caching

npm install ioredis
const Redis = require('ioredis');
const redis = new Redis({
  host: process.env.REDIS_HOST || 'localhost',
  port: 6379,
  maxRetriesPerRequest: 3,
  enableReadyCheck: true
});

redis.on('connect', () => console.log('Redis conectado'));
redis.on('error', (err) => console.error('Redis erro:', err));

// Cache middleware
function cacheMiddleware(ttl = 60) {
  return async (req, res, next) => {
    const key = `cache:${req.originalUrl}`;

    try {
      const cached = await redis.get(key);
      if (cached) {
        console.log('Cache HIT:', key);
        return res.json(JSON.parse(cached));
      }

      console.log('Cache MISS:', key);
      const originalJson = res.json.bind(res);

      res.json = (body) => {
        redis.setex(key, ttl, JSON.stringify(body));
        originalJson(body);
      };

      next();
    } catch (err) {
      next(err);
    }
  };
}

// Uso
app.get('/users', cacheMiddleware(30), async (req, res) => {
  const users = await prisma.user.findMany();
  res.json(users);
});
// Invalidação de cache
async function invalidateCache(pattern) {
  const keys = await redis.keys(pattern);
  if (keys.length > 0) {
    await redis.del(keys);
    console.log(`Cache invalidado: ${pattern} (${keys.length} chaves)`);
  }
}

Lab: Exercício - API Otimizada com Cluster e Cache

// src/server.js
const cluster = require('cluster');
const os = require('os');

if (cluster.isPrimary) {
  const numCPUs = os.cpus().length;
  console.log(`Primary iniciando ${numCPUs} workers`);

  for (let i = 0; i < numCPUs; i++) cluster.fork();

  cluster.on('exit', (worker) => {
    console.log(`Worker ${worker.process.pid} reiniciado`);
    cluster.fork();
  });
} else {
  require('./app');
}
// src/app.js
const express = require('express');
const compression = require('compression');
const Redis = require('ioredis');
const { PrismaClient } = require('@prisma/client');

const app = express();
const redis = new Redis({ host: process.env.REDIS_HOST });
const prisma = new PrismaClient();

app.use(compression());
app.use(express.json());

const cache = (ttl = 60) => async (req, res, next) => {
  const key = `cache:${req.originalUrl}`;
  const cached = await redis.get(key);
  if (cached) return res.json(JSON.parse(cached));
  const send = res.json.bind(res);
  res.json = (body) => {
    redis.setex(key, ttl, JSON.stringify(body));
    send(body);
  };
  next();
};

app.get('/users', cache(30), async (req, res) => {
  const users = await prisma.user.findMany();
  res.json(users);
});

app.listen(process.env.PORT || 3000);
# Iniciar com PM2
pm2 start ecosystem.config.cjs

# Monitorar
pm2 monit
pm2 logs api --lines 100

# Testar carga
npx autocannon -c 100 -d 10 http://localhost:3000/users

Use PM2 em modo cluster para multi-threading real. Combine Redis cache com compression para reduzir latência. Clinic.js ajuda a identificar gargalos com flamegraphs. Memory leaks comuns: listeners não removidos, referências em closures, e cache sem expiração.