Архитектура
Политика кэширования 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-age | stale-while-revalidate | Scope | Пример |
|---|---|---|---|---|
| Статические справочники | 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=N | TTL для 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
- Дополнительная логика в мутационных эндпоинтах
Связанные решения
- ADR-007: $fetch.create для HTTP запросов — клиентские запросы
- ADR-012: Nuxt 4 Server Backend — серверная архитектура
- Standard: Backend API Design — стандарты API