Архитектура

Политика кэширования REST API

Контекст

REST API должен эффективно использовать кэширование для:

  • Снижения нагрузки на сервер и базу данных
  • Уменьшения задержки для клиентов
  • Оптимизации трафика между клиентом и сервером
  • Улучшения UX за счёт быстрых ответов

Необходимо определить стратегии кэширования для различных типов данных и сценариев использования.

Решение

Использовать многоуровневую стратегию кэширования с применением HTTP-заголовков и встроенных механизмов Nitro/Nuxt:

Уровни кэширования

┌─────────────────────────────────────────────────────────┐
│                    Клиент (Browser)                     │
│              HTTP Cache (Cache-Control)                 │
└─────────────────────────────────────────────────────────┘
                           │
┌─────────────────────────────────────────────────────────┐
│                     CDN / Proxy                         │
│         Surrogate-Control, s-maxage                     │
└─────────────────────────────────────────────────────────┘
                           │
┌─────────────────────────────────────────────────────────┐
│                  Nuxt Server (Nitro)                    │
│           cachedEventHandler, defineCachedFunction      │
└─────────────────────────────────────────────────────────┘
                           │
┌─────────────────────────────────────────────────────────┐
│                      Database                           │
│                   (Prisma/PostgreSQL)                   │
└─────────────────────────────────────────────────────────┘

Стратегии кэширования по типу данных

1. Статические справочники (Static Reference Data)

Данные, которые меняются редко (категории, страны, валюты):

// server/api/references/countries.get.ts
export default cachedEventHandler(async (event) => {
  const countries = await prisma.country.findMany({
    orderBy: { name: 'asc' }
  })
  return countries
}, {
  // Кэш на сервере на 1 час
  maxAge: 60 * 60,
  // Позволяем stale-while-revalidate на 5 минут
  staleMaxAge: 60 * 5,
  // Уникальный ключ для инвалидации
  name: 'countries-list'
})

HTTP-заголовки:

Cache-Control: public, max-age=3600, stale-while-revalidate=300

2. Часто запрашиваемые данные (Hot Data)

Данные с высокой частотой запросов, но периодическими изменениями:

// server/api/products/popular.get.ts
export default cachedEventHandler(async (event) => {
  const products = await prisma.product.findMany({
    where: { isPublished: true },
    orderBy: { viewCount: 'desc' },
    take: 20
  })
  return products
}, {
  // Кэш на 5 минут
  maxAge: 60 * 5,
  // Stale данные на 1 минуту пока обновляется
  staleMaxAge: 60,
  // Уникальный ключ
  name: 'popular-products'
})

HTTP-заголовки:

Cache-Control: public, max-age=300, stale-while-revalidate=60

3. Пользовательские данные (User-Specific Data)

Данные, специфичные для пользователя (профиль, настройки):

// server/api/users/me.get.ts
export default defineEventHandler(async (event) => {
  const userId = event.context.userId

  // Используем ETag для условных запросов
  const user = await prisma.user.findUnique({
    where: { id: userId },
    select: { id: true, email: true, name: true, updatedAt: true }
  })

  if (!user) {
    throw createError({ statusCode: 404 })
  }

  // ETag на основе updatedAt
  const etag = `"user-${user.id}-${user.updatedAt.getTime()}"`

  // Проверяем If-None-Match
  const ifNoneMatch = getHeader(event, 'if-none-match')
  if (ifNoneMatch === etag) {
    setResponseStatus(event, 304)
    return null
  }

  // Устанавливаем заголовки
  setHeader(event, 'ETag', etag)
  setHeader(event, 'Cache-Control', 'private, max-age=0, must-revalidate')

  return user
})

HTTP-заголовки:

Cache-Control: private, max-age=0, must-revalidate
ETag: "user-123-1706000000000"

4. Динамические данные (No Cache)

Данные, требующие актуальности (статусы заказов, real-time данные):

// server/api/orders/[id]/status.get.ts
export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')

  const order = await prisma.order.findUnique({
    where: { id },
    select: { id: true, status: true, updatedAt: true }
  })

  // Запрещаем кэширование
  setHeader(event, 'Cache-Control', 'no-store, no-cache, must-revalidate')
  setHeader(event, 'Pragma', 'no-cache')

  return order
})

HTTP-заголовки:

Cache-Control: no-store, no-cache, must-revalidate
Pragma: no-cache

Матрица стратегий кэширования

Тип данныхmax-agestale-while-revalidateScopeПример
Статические справочники1 час5 минpublic/api/references/*
Популярный контент5 мин1 минpublic/api/products/popular
Списки с фильтрами1 мин30 секpublic/api/products?category=X
Пользовательские данные0-private/api/users/me
Real-time данные--no-store/api/orders/:id/status

Серверное кэширование (Nitro)

cachedEventHandler

Для кэширования целых эндпоинтов:

// server/api/catalog/categories.get.ts
export default cachedEventHandler(async (event) => {
  return await prisma.category.findMany({
    include: { _count: { select: { products: true } } }
  })
}, {
  maxAge: 60 * 30,           // 30 минут
  staleMaxAge: 60 * 5,       // 5 минут stale
  name: 'categories',        // Ключ для инвалидации
  getKey: (event) => 'all'   // Единый ключ для всех запросов
})

defineCachedFunction

Для кэширования вычислений внутри handler:

// server/utils/cached-functions.ts
export const getCachedStats = defineCachedFunction(
  async (period: 'day' | 'week' | 'month') => {
    return await prisma.$queryRaw`
      SELECT COUNT(*) as total, SUM(amount) as revenue
      FROM orders
      WHERE created_at > ${getStartDate(period)}
    `
  },
  {
    maxAge: 60 * 15,           // 15 минут
    name: 'order-stats',
    getKey: (period) => period // Разные ключи для разных периодов
  }
)

// server/api/dashboard/stats.get.ts
export default defineEventHandler(async (event) => {
  const query = getQuery(event)
  const period = (query.period as string) || 'day'

  return await getCachedStats(period)
})

Кэширование с учётом query-параметров

// server/api/products.get.ts
export default cachedEventHandler(async (event) => {
  const query = getQuery(event)
  const { page, limit, category, sort } = query

  return await prisma.product.findMany({
    where: category ? { categoryId: category as string } : undefined,
    skip: ((Number(page) || 1) - 1) * (Number(limit) || 20),
    take: Number(limit) || 20,
    orderBy: getSortOrder(sort as string)
  })
}, {
  maxAge: 60,
  // Ключ включает все query-параметры
  getKey: (event) => {
    const query = getQuery(event)
    return `${query.page}-${query.limit}-${query.category}-${query.sort}`
  }
})

Инвалидация кэша

1. Автоматическая инвалидация по TTL

Кэш автоматически истекает по maxAge:

cachedEventHandler(handler, {
  maxAge: 60 * 5 // Автоинвалидация через 5 минут
})

2. Ручная инвалидация через API

// server/api/admin/cache/invalidate.post.ts
export default defineEventHandler(async (event) => {
  const { key } = await readBody(event)

  // Инвалидация конкретного ключа
  await useStorage('cache').removeItem(`nitro:handlers:${key}`)

  return { success: true, invalidated: key }
})

3. Инвалидация при мутациях

// server/api/products.post.ts
export default defineEventHandler(async (event) => {
  const body = await readBody(event)

  const product = await prisma.product.create({ data: body })

  // Инвалидируем связанные кэши
  const storage = useStorage('cache')
  await Promise.all([
    storage.removeItem('nitro:handlers:products-list'),
    storage.removeItem('nitro:handlers:popular-products'),
    storage.removeItem(`nitro:handlers:category-${product.categoryId}`)
  ])

  return product
})

4. Паттерн Event-based инвалидации

// server/utils/cache-invalidation.ts
export async function invalidateProductCaches(productId: string, categoryId: string) {
  const storage = useStorage('cache')

  const keysToInvalidate = [
    'nitro:handlers:products-list',
    'nitro:handlers:popular-products',
    `nitro:handlers:product-${productId}`,
    `nitro:handlers:category-${categoryId}-products`
  ]

  await Promise.all(
    keysToInvalidate.map(key => storage.removeItem(key))
  )
}

// server/api/products/[id].put.ts
export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')
  const body = await readBody(event)

  const product = await prisma.product.update({
    where: { id },
    data: body
  })

  // Инвалидируем кэши
  await invalidateProductCaches(id!, product.categoryId)

  return product
})

HTTP-заголовки кэширования

Cache-Control директивы

ДирективаОписаниеИспользование
publicКэш разрешён на CDN и проксиПубличные данные
privateТолько браузерный кэшПользовательские данные
max-age=NВремя жизни в секундахВсегда указывать
s-maxage=NTTL для shared caches (CDN)CDN с другим TTL
no-cacheТребует revalidationУсловное кэширование
no-storeПолный запрет кэшированияЧувствительные данные
must-revalidateОбязательная проверка после TTLКритичные данные
stale-while-revalidate=NОтдать stale пока обновляетсяУлучшение UX
stale-if-error=NОтдать stale при ошибкеFault tolerance

Примеры Cache-Control

// server/utils/cache-headers.ts
export const CacheProfiles = {
  // Статические данные — агрессивное кэширование
  static: 'public, max-age=86400, stale-while-revalidate=3600',

  // Часто обновляемые — короткий TTL
  dynamic: 'public, max-age=60, stale-while-revalidate=30',

  // Приватные данные — только браузер
  private: 'private, max-age=0, must-revalidate',

  // Real-time — без кэша
  realtime: 'no-store, no-cache, must-revalidate',

  // CDN-оптимизированные
  cdn: 'public, max-age=60, s-maxage=300, stale-while-revalidate=60'
} as const

// Использование
setHeader(event, 'Cache-Control', CacheProfiles.dynamic)

Vary Header

Указывает, какие заголовки влияют на кэш:

// server/api/products.get.ts
export default defineEventHandler(async (event) => {
  // Разный ответ в зависимости от Accept-Language
  const lang = getHeader(event, 'accept-language')?.split(',')[0] || 'en'

  const products = await getProductsForLocale(lang)

  // Vary сообщает кэшу учитывать Accept-Language
  setHeader(event, 'Vary', 'Accept-Language, Accept-Encoding')
  setHeader(event, 'Cache-Control', 'public, max-age=300')

  return products
})

ETag и условные запросы

Реализация ETag

// server/utils/etag.ts
import { createHash } from 'crypto'

export function generateETag(data: unknown): string {
  const hash = createHash('md5')
    .update(JSON.stringify(data))
    .digest('hex')
  return `"${hash}"`
}

export function handleConditionalRequest(
  event: H3Event,
  data: unknown,
  etag: string
): boolean {
  const ifNoneMatch = getHeader(event, 'if-none-match')

  if (ifNoneMatch === etag) {
    setResponseStatus(event, 304)
    return true // Прервать обработку
  }

  setHeader(event, 'ETag', etag)
  return false
}
// server/api/products/[id].get.ts
export default defineEventHandler(async (event) => {
  const id = getRouterParam(event, 'id')

  const product = await prisma.product.findUnique({
    where: { id }
  })

  if (!product) {
    throw createError({ statusCode: 404 })
  }

  const etag = generateETag(product)

  if (handleConditionalRequest(event, product, etag)) {
    return null // 304 Not Modified
  }

  setHeader(event, 'Cache-Control', 'public, max-age=60')

  return product
})

Конфигурация хранилища кэша

Development (Memory)

// nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    storage: {
      cache: {
        driver: 'memory'
      }
    }
  }
})

Production (Redis)

// nuxt.config.ts
export default defineNuxtConfig({
  nitro: {
    storage: {
      cache: {
        driver: 'redis',
        host: process.env.REDIS_HOST,
        port: Number(process.env.REDIS_PORT) || 6379,
        password: process.env.REDIS_PASSWORD
      }
    }
  }
})

Последствия

Положительные

  • Значительное снижение нагрузки на БД для справочных данных
  • Улучшение времени ответа за счёт stale-while-revalidate
  • Гибкая стратегия под разные типы данных
  • Поддержка CDN через правильные заголовки
  • Условные запросы экономят трафик

Отрицательные

  • Сложность инвалидации при связанных данных
  • Риск показа устаревших данных
  • Необходимость Redis для production
  • Дополнительная логика в мутационных эндпоинтах

Связанные решения