Если вы когда-либо наблюдали, как ваша база данных проседает под нагрузкой, в то время как кэш остаётся нетронутым и недостаточно используемым, вы знаете, что это боль. Я был в такой ситуации — наблюдал, как пулы соединений достигают предела, время запросов растёт до нескольких секунд, а пользователи смотрят на вращающиеся индикаторы, которые так и не завершаются. Проблема в том, что стратегия кэширования, которая отлично выглядела на доске, развалилась в production.
Кэширование — это не чёрная магия. Это больше похоже на приправу в рецепте — используйте её неправильно, и вы испортите блюдо. Используйте её правильно, и никто даже не вспомнит, что база данных существует.
Позвольте мне рассказать вам о трёх шаблонах кэширования, которые действительно работают в реальном мире, с кодом, который не заставит вашего старшего инженера морщиться во время code review.
Почему кэширование важно (и почему вы, вероятно, делаете это неправильно)
Прежде чем мы углубимся в шаблоны, давайте будем честными: кэширование — это то, где большинство разработчиков впервые сталкиваются с синдромом «работает на моей машине». Один промах кэша в 3 часа ночи может вызвать каскад запросов к базе данных, из-за которого ваша панель мониторинга будет выглядеть как крах фондового рынка.
Основная проблема заключается в том, что кэширование не является необязательным — оно необходимо для масштабирования. Но большинство людей относятся к кэшированию как к второстепенному. «Давайте кэшируем это позже», — говорят они, прямо перед тем, как время отклика их API достигнет 2 секунд в Чёрную пятницу.
Низкая задержка ⚡] B -->|Нет| D[Запрос к базе данных] D --> E[Сохранение в кэш] E --> F[Возврат данных
Более высокая задержка ⚠️] style C fill:#90EE90 style F fill:#FFB6C6
Реальность проста: каждый промах кэша — это обращение к базе данных. Каждое обращение к базе данных — это задержка, которую вы не сможете вернуть. Цель? Максимизировать попадания, минимизировать обращения.
Шаблон 1: Кэширование по требованию (Lazy Loading)
Давайте начнём с MVP шаблонов кэширования. Кэширование по требованию — это то, к чему вы обращаетесь, когда хотите максимальный контроль и минимальную сложность. Думайте об этом как о шаблоне, который говорит: «Приложение, ты главный».
Вот как это работает: ваше приложение сначала проверяет кэш. Если данные существуют (попадание в кэш), отлично — верните их немедленно. Если их нет (промах кэша), извлеките из базы данных, сохраните в кэше на следующий раз и верните пользователю.
Когда кэширование по требованию работает лучше всего
Этот шаблон отлично подходит для определённых сценариев:
- Нагрузки с большим количеством операций чтения: большая часть вашего трафика — это операции чтения, поэтому кэширование чтений имеет смысл.
- Непоredсказуемые шаблоны доступа: вы не знаете, какие данные будут запрашивать пользователи, поэтому кэшируете только то, что фактически используется.
- Терпимость к последующей согласованности: устаревшие данные приемлемы, пока они не устаревают навсегда.
- Потребность в гибкости: вы хотите полный контроль над тем, что кэшируется и когда.
Реализация в реальном мире на Node.js
Позвольте мне показать вам, как это выглядит в производственном коде. Представьте, что вы создаёте службу профилей пользователей:
const Redis = require('redis');
const client = Redis.createClient();
await client.connect();
class UserProfileCache {
constructor(ttl = 3600) {
this.ttl = ttl; // TTL в секундах (1 час)
}
cacheKey(userId) {
return `user:profile:${userId}`;
}
async getUser(userId) {
const key = this.cacheKey(userId);
// Шаг 1: Сначала попробовать кэш
const cached = await client.get(key);
if (cached) {
console.log(`✓ Попадание в кэш для пользователя ${userId}`);
return JSON.parse(cached);
}
// Шаг 2: Промах кэша — запрос к базе данных
console.log(`✗ Промах кэша для пользователя ${userId} — запрос к базе данных`);
const user = await this.fetchFromDatabase(userId);
// Шаг 3: Сохранение в кэш для будущих запросов
if (user) {
await client.setEx(key, this.ttl, JSON.stringify(user));
console.log(`↻ Кэширован пользователь ${userId} на ${this.ttl} секунд`);
}
return user;
}
async fetchFromDatabase(userId) {
// Имитация запроса к базе данных (на самом деле это был бы ваш клиент БД)
return {
id: userId,
name: 'John Doe',
email: '[email protected]',
lastSeen: new Date()
};
}
}
// Использование
const userCache = new UserProfileCache(3600);
const user = await userCache.getUser('user123');
// Первый вызов: запрос к базе данных, кэширование результата
// Второй вызов в течение 1 часа: возврат из кэша мгновенно
Ловушка кэширования по требованию: «топовое» кэширование
Здесь всё становится интереснее. Представьте, что ваш кэш истекает для популярного профиля пользователя как раз в тот момент, когда одновременно поступают 50 запросов. Все 50 думают, что произошёл промах кэша, и одновременно обращаются к базе данных. Это называется «топовое» кэширование, и это не очень хорошо.
Решение? Кэшировать нулевые результаты с более коротким TTL:
async getUser(userId) {
const key = this.cacheKey(userId);
const cached = await client.get(key);
if (cached !== null) {
// Это охватывает как реальные данные, так и нулевые значения
const data = cached === 'NULL' ? null : JSON.parse(cached);
return data;
}
const user = await this.fetchFromDatabase(userId);
// Кэшировать null на 5 минут, чтобы предотвратить «топовое» кэширование
const valueToCache = user ? JSON.stringify(user) : 'NULL';
await client.setEx(key, user ? 3600 : 300, valueToCache);
return user;
}
Теперь несуществующие пользователи также кэшируются (на более короткий период), предотвращая обращение вашей базы данных к повторяющимся запросам для несуществующих пользователей.
Шаблон 2: Кэширование с записью через (Write-Through Caching)
Теперь давайте поговорим о записях. Кэширование по требованию прекрасно справляется с операциями чтения, но что насчёт обновлений? Кэширование с записью через обеспечивает синхронизацию вашего кэша и базы данных, записывая в оба одновременно.
Философия кэширования с записью через
При записи через каждая операция записи затрагивает два места: кэш и базу данных. Оба происходят вместе, оба успешны или оба терпят неудачу. Это синхронно, безопасно и медленнее — но в правильных сценариях компромисс стоит того.
Кэш устарел] F -->|Да| H[Возврат успеха] style D fill:#FFB6C6 style G fill:#FFE4B5 style H fill:#90EE90
Когда использовать кэширование с записью через
Кэширование с записью через — это ваш шаблон, когда:
- Вы создаёте приложения с большим количеством операций записи, где одни и те же данные часто обновляются.
- Точность кэша критична — устаревшие данные могут вызвать реальные проблемы.
- Вы хотите максимально возможный коэффициент попаданий в кэш.
- Накладные расходы на синхронные записи приемлемы.
Реализация: обновление профиля пользователя
class WriteThroughUserCache {
constructor(ttl = 3600) {
this.ttl = ttl;
}
cacheKey(userId) {
return `user:profile:${userId}`;
}
async updateUser(userId, userData) {
const key = this.cacheKey(userId);
try {
// Шаг 1: Сначала запись в базу данных
// Почему? Если БД терпит неудачу, мы не трогаем кэш
await this.writeToDatabase(userId, userData);
// Шаг 2: Только если БД успешна, обновить кэш
await client.setEx(key, this.ttl, JSON
