JS Tower
Node.jsМодуль 5: Базы данных

Оптимизация запросов

Индексы, пагинация, кэширование

Цель урока

В этом уроке ты научишься:

  • Создавать индексы
  • Реализовывать пагинацию
  • Кэшировать данные

Индексы

MongoDB

// В схеме
const userSchema = new mongoose.Schema({
  email: { type: String, unique: true, index: true },
  name: String,
  createdAt: Date
});

// Составной индекс
userSchema.index({ name: 1, createdAt: -1 });

// Текстовый индекс
userSchema.index({ name: 'text', bio: 'text' });

// Программно
await User.collection.createIndex({ email: 1 });

PostgreSQL (Prisma)

model User {
  id    Int    @id @default(autoincrement())
  email String @unique
  name  String

  @@index([name])
  @@index([createdAt(sort: Desc)])
}

Когда создавать индексы

  • Поля, по которым часто ищут
  • Поля для сортировки
  • Внешние ключи

Пагинация

Offset-based

// MongoDB
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
const skip = (page - 1) * limit;

const users = await User.find()
  .skip(skip)
  .limit(limit)
  .sort({ createdAt: -1 });

const total = await User.countDocuments();

res.json({
  data: users,
  meta: {
    page,
    limit,
    total,
    pages: Math.ceil(total / limit)
  }
});

Cursor-based (эффективнее)

// MongoDB
const cursor = req.query.cursor;
const limit = parseInt(req.query.limit) || 10;

const query = cursor 
  ? { _id: { $lt: cursor } }
  : {};

const users = await User.find(query)
  .sort({ _id: -1 })
  .limit(limit + 1);

const hasMore = users.length > limit;
if (hasMore) users.pop();

res.json({
  data: users,
  meta: {
    nextCursor: hasMore ? users[users.length - 1]._id : null,
    hasMore
  }
});

Кэширование с Redis

Установка

npm install redis

Подключение

const redis = require('redis');

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

client.on('error', (err) => console.error('Redis error:', err));

await client.connect();

Кэширование запросов

async function getUser(id) {
  const cacheKey = `user:${id}`;
  
  // Проверяем кэш
  const cached = await client.get(cacheKey);
  if (cached) {
    return JSON.parse(cached);
  }
  
  // Запрос к БД
  const user = await User.findById(id);
  
  // Сохраняем в кэш (TTL 1 час)
  await client.setEx(cacheKey, 3600, JSON.stringify(user));
  
  return user;
}

// Инвалидация
async function updateUser(id, data) {
  const user = await User.findByIdAndUpdate(id, data, { new: true });
  await client.del(`user:${id}`);
  return user;
}

Middleware для кэширования

function cache(ttl = 60) {
  return async (req, res, next) => {
    const key = `cache:${req.originalUrl}`;
    
    const cached = await client.get(key);
    if (cached) {
      return res.json(JSON.parse(cached));
    }
    
    // Перехватываем res.json
    const originalJson = res.json.bind(res);
    res.json = async (data) => {
      await client.setEx(key, ttl, JSON.stringify(data));
      return originalJson(data);
    };
    
    next();
  };
}

// Использование
app.get('/api/users', cache(300), async (req, res) => {
  const users = await User.find();
  res.json(users);
});

Оптимизация запросов

Select нужных полей

// Плохо
const users = await User.find();

// Хорошо
const users = await User.find().select('name email');

Lean для чтения

// Возвращает plain объекты (быстрее)
const users = await User.find().lean();

Batch операции

// Плохо
for (const id of ids) {
  await User.findByIdAndUpdate(id, { status: 'active' });
}

// Хорошо
await User.updateMany(
  { _id: { $in: ids } },
  { status: 'active' }
);

Explain для анализа

const explanation = await User.find({ email: 'test@example.com' })
  .explain('executionStats');

console.log(explanation.executionStats);

Практика

Задание: API с пагинацией и кэшем

Задача: Создай endpoint с пагинацией и кэшированием.

Решение:

app.get('/api/posts', cache(60), async (req, res) => {
  const page = parseInt(req.query.page) || 1;
  const limit = parseInt(req.query.limit) || 10;
  
  const [posts, total] = await Promise.all([
    Post.find()
      .populate('author', 'name')
      .sort({ createdAt: -1 })
      .skip((page - 1) * limit)
      .limit(limit)
      .lean(),
    Post.countDocuments()
  ]);
  
  res.json({
    data: posts,
    meta: { page, limit, total, pages: Math.ceil(total / limit) }
  });
});

Проверь себя

  1. Когда использовать cursor-based пагинацию?
  2. Что делает метод lean()?
  3. Как инвалидировать кэш?