正在加载,请稍候…

Node.js Performance Optimization: Profiling, Clustering, and Caching

Learn how to identify and fix Node.js performance bottlenecks. Covers CPU profiling, memory leaks, event loop blocking, clustering, Redis caching, and database query optimization.

Node.js Performance Optimization: Profiling, Clustering, and Caching

Node.js can handle tens of thousands of concurrent connections — but only if you avoid common pitfalls. This guide covers how to identify bottlenecks and fix them.

Understanding the Event Loop

Node.js uses a single-threaded event loop. This is its superpower for I/O-heavy workloads, but also its Achilles' heel for CPU-heavy tasks.

// ✅ Non-blocking — Event loop stays free
app.get('/users', async (req, res) => {
  const users = await db.query('SELECT * FROM users');  // I/O, yields to event loop
  res.json(users);
});

// ❌ Blocking — Event loop is frozen for the duration
app.get('/compute', (req, res) => {
  const result = heavyComputation(); // Blocks ALL other requests!
  res.json(result);
});

Detecting Event Loop Lag

const { monitorEventLoopDelay } = require('perf_hooks');

const h = monitorEventLoopDelay({ resolution: 20 });
h.enable();

setInterval(() => {
  console.log({
    min: h.min / 1e6 + 'ms',
    max: h.max / 1e6 + 'ms',
    mean: h.mean / 1e6 + 'ms',
    p99: h.percentile(99) / 1e6 + 'ms',
  });
  h.reset();
}, 5000);

If P99 is consistently > 100ms, you have event loop blocking.

CPU Profiling

Built-in Node.js Profiler

# Start with profiling enabled
node --prof app.js

# After some load, kill the process
# Process the profile file
node --prof-process isolate-0xXXXXXX-v8.log > profile.txt

# Look for "Bottom up" section to find hot functions

Clinic.js — Visual Profiling

npm install -g clinic

# Profile for bottlenecks
clinic doctor -- node app.js

# Detailed flame graph
clinic flame -- node app.js

# Event loop delays
clinic bubbleprof -- node app.js

CPU-Intensive Work: Worker Threads

// worker-thread.js
const { parentPort, workerData } = require('worker_threads');

function heavyCompute(data) {
  // Expensive CPU work here
  let result = 0;
  for (let i = 0; i < data.iterations; i++) {
    result += Math.sqrt(i);
  }
  return result;
}

parentPort.postMessage(heavyCompute(workerData));
// main.js
const { Worker } = require('worker_threads');

function runInWorker(data) {
  return new Promise((resolve, reject) => {
    const worker = new Worker('./worker-thread.js', { workerData: data });
    worker.on('message', resolve);
    worker.on('error', reject);
    worker.on('exit', (code) => {
      if (code !== 0) reject(new Error(`Worker exited with code ${code}`));
    });
  });
}

// This no longer blocks the event loop
app.get('/compute', async (req, res) => {
  const result = await runInWorker({ iterations: 1_000_000 });
  res.json({ result });
});

Clustering: Use All CPU Cores

// cluster.js
const cluster = require('cluster');
const os = require('os');
const numCPUs = os.cpus().length;

if (cluster.isPrimary) {
  console.log(`Primary ${process.pid} running`);
  console.log(`Forking ${numCPUs} workers...`);
  
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }
  
  cluster.on('exit', (worker, code, signal) => {
    console.log(`Worker ${worker.process.pid} died. Restarting...`);
    cluster.fork(); // Auto-restart
  });
} else {
  // Workers run the actual server
  const app = require('./app');
  app.listen(3000, () => {
    console.log(`Worker ${process.pid} started`);
  });
}

In production, use PM2 instead:

npm install -g pm2

# Start in cluster mode (auto-detects CPU count)
pm2 start app.js -i max

# Monitor
pm2 monit
pm2 logs

Caching with Redis

// cache.ts
import { createClient } from 'redis';

const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();

// Cache middleware
export function cache(ttlSeconds = 60) {
  return async (req, res, next) => {
    const key = `cache:${req.originalUrl}`;
    
    const cached = await redis.get(key);
    if (cached) {
      res.setHeader('X-Cache', 'HIT');
      return res.json(JSON.parse(cached));
    }
    
    // Override res.json to cache the response
    const originalJson = res.json.bind(res);
    res.json = async (data) => {
      await redis.setEx(key, ttlSeconds, JSON.stringify(data));
      res.setHeader('X-Cache', 'MISS');
      return originalJson(data);
    };
    
    next();
  };
}

// Usage
app.get('/products', cache(300), getProducts); // Cache 5 minutes
app.get('/user/:id', cache(60), getUserById);  // Cache 1 minute

Cache Invalidation

// Delete cache when data changes
async function updateProduct(id, data) {
  await db.update('products', id, data);
  
  // Invalidate related caches
  await redis.del(`cache:/products/${id}`);
  await redis.del('cache:/products');  // Also invalidate list
}

// Pattern-based invalidation
const keys = await redis.keys('cache:/products*');
if (keys.length) await redis.del(keys);

Database Query Optimization

N+1 Problem — The #1 Performance Killer

// ❌ N+1 Problem — 1 query to get orders + N queries for each user
const orders = await Order.findAll();  // 1 query
for (const order of orders) {
  const user = await User.findById(order.userId);  // N queries!
  order.user = user;
}

// ✅ Solve with JOIN or populate
const orders = await Order.findAll({
  include: [{ model: User, attributes: ['name', 'email'] }]
}); // 1 query (or 2 with separate SELECT)

// ✅ Or batch load with DataLoader
const DataLoader = require('dataloader');
const userLoader = new DataLoader(async (ids) => {
  const users = await User.findAll({ where: { id: ids } });
  return ids.map(id => users.find(u => u.id === id));
});

// Now batches automatically
const user = await userLoader.load(order.userId); // Batched!

Database Connection Pooling

// PostgreSQL with pg
const { Pool } = require('pg');

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20,           // Max connections in pool
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
});

// All queries share the pool (no reconnect overhead)
const result = await pool.query('SELECT * FROM users WHERE id = $1', [userId]);

HTTP Response Optimization

Compression

import compression from 'compression';

// Compress responses > 1kb
app.use(compression({
  level: 6,          // 1-9, higher = more compression but more CPU
  threshold: 1024,   // Only compress if > 1kb
  filter: (req, res) => {
    // Don't compress SSE streams
    if (req.headers['accept'] === 'text/event-stream') return false;
    return compression.filter(req, res);
  }
}));

Streaming Large Responses

// ❌ Loads entire result into memory
app.get('/export', async (req, res) => {
  const allUsers = await User.findAll(); // Could be millions!
  res.json(allUsers);
});

// ✅ Stream the response
app.get('/export', async (req, res) => {
  res.setHeader('Content-Type', 'application/json');
  res.write('[');
  
  let first = true;
  const stream = User.findAllStream(); // Cursor-based streaming
  
  for await (const user of stream) {
    if (!first) res.write(',');
    res.write(JSON.stringify(user));
    first = false;
  }
  
  res.write(']');
  res.end();
});

Memory Leak Detection

// Monitor memory usage
setInterval(() => {
  const used = process.memoryUsage();
  console.log({
    rss: Math.round(used.rss / 1024 / 1024) + 'MB',
    heapTotal: Math.round(used.heapTotal / 1024 / 1024) + 'MB',
    heapUsed: Math.round(used.heapUsed / 1024 / 1024) + 'MB',
    external: Math.round(used.external / 1024 / 1024) + 'MB',
  });
}, 30000);

Common Memory Leak Causes

// ❌ Leak: Event listeners not removed
class Server {
  constructor() {
    process.on('message', this.handleMessage.bind(this));
    // This reference is never cleaned up!
  }
}

// ✅ Track and remove listeners
class Server {
  start() {
    this.messageHandler = this.handleMessage.bind(this);
    process.on('message', this.messageHandler);
  }
  
  stop() {
    process.off('message', this.messageHandler);
  }
}

// ❌ Leak: Growing cache without eviction
const cache = new Map();
function cacheData(key, value) {
  cache.set(key, value); // Grows forever!
}

// ✅ Use LRU cache with max size
import LRU from 'lru-cache';
const cache = new LRU({ max: 500, ttl: 1000 * 60 * 5 });

Performance Checklist

  • No synchronous file operations in request handlers (fs.readFileSyncfs.promises.readFile)
  • No blocking loops in request handlers (move to worker threads)
  • Database queries use indexes (run EXPLAIN ANALYZE on slow queries)
  • N+1 queries eliminated (use JOINs or DataLoader)
  • Connection pooling configured for databases
  • Responses compressed with gzip/brotli
  • Redis caching on expensive, frequently-read endpoints
  • Cluster mode / PM2 for multi-core utilization
  • Memory usage monitored, no unbounded growth

→ Benchmark your tools and algorithms with the Benchmark Builder.