$ sudo teach|
    $ sudo teach|
    IT school
  • Telegram
  • Партнёрам
  • Все курсы
$ sudo teach IT
OOO "SALEPROFIT"Контакты и реквизитыIT-Park Logo

Школа

  • Блог
  • Проверить сертификат

Сотрудничество

  • Стать учителем
  • Партнёрская программа
  • О проекте

Право

  • Оферта
  • Политика конфиденциальности

© 2023–2026 $ sudo teach IT™. All Rights Reserved. Public user contributions licensed under CC BY-SA 4.0 license with attribution required
TelegramGitHubYouTube
ГлавнаяБлогКэш и инвалидация на практике: write-through, write-back и дедупликация запросов

Кэш и инвалидация на практике: write-through, write-back и дедупликация запросов

$ sudo teach IT
·23 июля 2026 г.·10 мин·46
Кэш и инвалидация на практике: write-through, write-back и дедупликация запросов

Разберём рабочие паттерны кэширования для конкурентных обновлений и покажем, как предотвращать «шторм» при промахе кэша.

Содержание
1. Термины и модель: что именно кэшируем1.1. Кэш как копия с заданной семантикой1.2. Инвалидация vs вытеснение по TTL2. Стратегии записи: write-through и write-back2.1. Write-through: запись в кэш и источник одновременно2.2. Write-back: запись только в источник, а кэш догоняет позже3. Инвалидация: “почистить ключ” — недостаточно3.1. Два подхода к инвалидации3.2. Гонка чтения и записи: сценарий, который сложно увидеть4. Предотвращение “штормов”: дедупликация запросов (single-flight)4.1. Что такое single-flight4.2. Паттерн дедупликации поверх Redis/кэша5. Write-through + инвалидация + дедупликация: практический сценарий5.1. Модель данных для кэша5.2. Пример: дедупликация при промахе (JavaScript/TypeScript)5.3. Ограничения и улучшения6. Комбинации write-through/write-back с дедупликацией6.1. Write-through + дедупликация: меньше промахов, но lock всё равно нужен6.2. Write-back + дедупликация: критически важно6.3. Инвалидация как триггер штормов7. Подводные камни инвалидации и дедупликации7.1. Неправильный порядок действий при обновлении7.2. “Слепая” дедупликация без учёта версии7.3. Кэширование ошибок7.4. TTL и “края” событий8. Набор практических решений (checklist)

Кэширование в высоконагруженных системах — не про «ускорить на пару процентов». Это про контроль над поведением сервиса в моменты неопределённости: промахи кэша, всплески трафика, конкурентные обновления одних и тех же ключей, гонки между чтением и записью, эффекты каскадных ретраев. Именно тогда проявляется разница между «кэшем, который работает в тестах» и «кэшем, который держит прод».

В этой статье разберём три практических аспекта кэширования:

  1. Как выбирать стратегию записи: write-through vs write-back (и почему обе могут быть опасны при неправильной инвалидации).
  2. Как делать инвалидацию корректно: где ошибаются команды, которые «просто чистят ключи».
  3. Как предотвращать “шторм” промахов: паттерн дедупликации запросов (single-flight / request coalescing) и смежные техники.

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


1. Термины и модель: что именно кэшируем

Прежде чем выбирать паттерн, полезно зафиксировать, что кэш хранит и какие отношения должны соблюдаться между «истиной» (источник данных) и кэшем.

1.1. Кэш как копия с заданной семантикой

В большинстве приложений кэш — это:

  • либо копия данных с оговорённым временем жизни (TTL),
  • либо результат дорогой операции (например, сборка профиля),
  • либо агрегат/вычисление, обновляемое по событию.

Главный вопрос: насколько допустимо видеть устаревшие данные и на каком уровне.

  • Strong consistency требует обновлять все чтения синхронно — на практике дорого и не всегда возможно.
  • Eventual consistency допускает кратковременную рассинхронизацию, компенсируя её TTL/версией/событиями.

1.2. Инвалидация vs вытеснение по TTL

Есть два фундаментальных механизма:

  • TTL (expiration): кэш протухает сам. Простота, но возможны «гонки» при одновременном протухании.
  • Инвалидация: мы явным образом помечаем ключ как невалидный (удаляем, обновляем версию, публикуем событие).

Проблема начинается, когда комбинируют TTL и ручную инвалидацию, не понимая, что происходит при гонках.


2. Стратегии записи: write-through и write-back

2.1. Write-through: запись в кэш и источник одновременно

write-through означает, что при обновлении данных приложение делает:

  1. запись в источник (БД/сервис),
  2. синхронную/полусинхронную запись в кэш (или наоборот, но чаще источник и кэш должны завершиться «как часть одной логики»).

Плюсы:

  • кэш быстро отражает изменения,
  • меньше случаев чтения устаревшего.

Минусы:

  • выше задержка на запись (кэш становится частью критического пути),
  • при отказах кэша или проблемах с сетью можно получить каскадные эффекты.

Практический рецепт

Write-through чаще применяют, когда:

  • чтение в разы чаще записи,
  • кэш надёжен (например, локальный инстанс/кластер с хорошей отказоустойчивостью),
  • допускается увеличение latency на write.

Но важно: кэш не должен «переписать» более новые данные более старыми. Для этого часто используют версионирование/ETag/временные метки.

2.2. Write-back: запись только в источник, а кэш догоняет позже

write-back — это стратегия, при которой запись обновляет источник, а кэш либо:

  • не трогается, пока не будет промах/инвалидация,
  • обновляется лениво (например, при следующем чтении).

Плюсы:

  • запись дешевле,
  • меньше зависимости кэша в момент обновления.

Минусы:

  • риск чтения устаревших значений между обновлением в источнике и обновлением/инвалидацией кэша,
  • сложнее бороться с «штормом» после TTL/инвалидации.

Где чаще всего ломается write-back

Типичный сценарий:

  1. ключ протухает (TTL истёк) или его инвалидация сработала,
  2. приходит много запросов на чтение,
  3. каждый делает обращение к источнику (БД/сервису),
  4. источник перегружается и начинает отвечать медленно/с ошибками,
  5. клиенты ретраят, усиливая нагрузку.

Это и есть тот самый кэш-шторма при промахе кэша.


3. Инвалидация: “почистить ключ” — недостаточно

Инвалидация — это не действие «delete key». Это часть протокола согласования между источником и кэшем.

3.1. Два подхода к инвалидации

(A) Удаление (delete) ключа

После обновления данных вы удаляете запись в кэше:

  • просто,
  • эффективно устраняет устаревшие значения,
  • но может привести к шторму, если ключ протухает/удаляется массово.

(B) Версионирование (versioned keys)

Вместо удаления вы меняете «версию» ключа:

  • храните cache_version[userId],
  • формируете кэш-ключ как profile:{userId}:v{version},
  • при обновлении инкрементируете версию или публикуете событие.

Плюсы:

  • отсутствие гонок «удалили, пока кто-то читает»,
  • легче обеспечивать атомарность через CAS/Redis Lua,
  • можно держать несколько версий на время.

Минусы:

  • рост количества ключей (нужно GC/TTL на старые версии),
  • усложнение дизайна.

3.2. Гонка чтения и записи: сценарий, который сложно увидеть

Рассмотрим без версионирования:

  • Поток A делает обновление в БД.
  • Поток A удаляет ключ в кэше.
  • Поток B в этот момент читает ключ:

Варианты:

  1. B успел прочитать кэш до удаления — получит старое значение.
  2. B успел прочитать кэш после удаления — получит промах и пойдёт в БД.
  3. B пришёл между записью в БД и удалением из кэша — получит старое значение, если ключ ещё не удалён.

Если вы используете write-back с инвалидацией, критична порядковость событий: инвалидация должна отражать момент истины. На практике это означает либо транзакционную связь, либо «временной маркер» (версии/лейблы) и проверку при записи в кэш.


4. Предотвращение “штормов”: дедупликация запросов (single-flight)

Когда кэш промахнулся, особенно на горячих ключах, возникает проблема: параллельные одинаковые запросы умножаются в нагрузке на источник. Дедупликация решает это: один вычислитель на ключ, остальные ждут результат.

4.1. Что такое single-flight

Идея: при промахе кэша создаётся «координация» для конкретного ключа:

  • первый запрос запускает дорогое вычисление/чтение,
  • остальные запросы на тот же ключ не запускают повторно, а ждут тот же future/promise,
  • по завершении результата кэш заполняется, и все возвращают одно значение.

Подводные камни:

  • время ожидания (таймауты),
  • обработка ошибок (если вычисление упало — как отвечать всем?),
  • очистка ожиданий (чтобы не протечь память/локи).

4.2. Паттерн дедупликации поверх Redis/кэша

Есть несколько уровней реализации:

  1. Внутри процесса (локальный single-flight): хорошо для одного инстанса.
  2. В масштабе (распределённый single-flight): сложнее, но правильно для нескольких инстансов.

Для распределённого варианта обычно используют:

  • распределённый lock (Redis SET NX PX),
  • Pub/Sub или Streams для ожидания результата,
  • либо специальные конструкции в приложении (например, “promise registry” в shared store).

Ниже — рабочие примеры на уровне приложения с Redis как координацией.


5. Write-through + инвалидация + дедупликация: практический сценарий

Представим API: GET /profile/{userId} собирает профиль из БД (дорого), а POST /profile/{userId} обновляет данные.

5.1. Модель данных для кэша

  • key: profile:{userId}:v{version}
  • версию храним в отдельном ключе или в метаданных источника.
  • при обновлении данных увеличиваем версию и инвалидируем старую.

Если вы не хотите версионирование — можно делать delete, но тогда больше шансов словить гонки и шторм.

5.2. Пример: дедупликация при промахе (JavaScript/TypeScript)

Ниже пример логики на Node.js. Он показывает идею single-flight через Redis lock. Это не единственный путь, но он понятный и рабочий.

Утилиты: чтение/запись кэша и lock

code
import { createClient } from "redis";

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

const CACHE_TTL_SECONDS = 300;
const LOCK_TTL_MS = 5000;
const WAIT_POLL_MS = 50;

async function getCachedProfile(key: string) {
  const raw = await redis.get(key);
  if (!raw) return null;
  return JSON.parse(raw);
}

async function setCachedProfile(key: string, value: any) {
  await redis.set(key, JSON.stringify(value), { EX: CACHE_TTL_SECONDS });
}

async function acquireLock(lockKey: string, token: string) {
  // SET key value NX PX ttl
  const res = await redis.set(lockKey, token, { NX: true, PX: LOCK_TTL_MS });
  return res === "OK";
}

async function releaseLock(lockKey: string, token: string) {
  // Снять lock безопасно через Lua, проверив токен
  const lua = `
    if redis.call("get", KEYS[1]) == ARGV[1] then
      return redis.call("del", KEYS[1])
    else
      return 0
    end
  `;
  await redis.eval(lua, { keys: [lockKey], arguments: [token] });
}

Handler чтения с дедупликацией

code
function sleep(ms: number) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

async function waitForCache(key: string, timeoutMs: number) {
  const start = Date.now();
  while (Date.now() - start < timeoutMs) {
    const cached = await getCachedProfile(key);
    if (cached) return cached;
    await sleep(WAIT_POLL_MS);
  }
  return null;
}

async function computeProfileFromDB(userId: string) {
  // имитация дорогого вычисления
  await sleep(200);
  return { userId, name: "Alice", updatedAt: Date.now() };
}

export async function getProfile(userId: string) {
  // Предположим, version берётся из источника/метаданных
  const version = await redis.get(`profile_version:${userId}`) ?? "1";
  const cacheKey = `profile:${userId}:v${version}`;

  const cached = await getCachedProfile(cacheKey);
  if (cached) return cached;

  // Дедупликация: один вычислитель на ключ
  const lockKey = `lock:profile:${userId}:v${version}`;
  const token = `${process.pid}:${Date.now()}:${Math.random()}`;

  const hasLock = await acquireLock(lockKey, token);

  if (hasLock) {
    try {
      // Делаем реальную работу
      const value = await computeProfileFromDB(userId);
      await setCachedProfile(cacheKey, value);
      return value;
    } finally {
      await releaseLock(lockKey, token);
    }
  } else {
    // Другие запросы ждут, когда первый заполнит кэш
    const value = await waitForCache(cacheKey, 3000);
    if (value) return value;

    // Если таймаут — можно решать по стратегии:
    // 1) снова попытаться,
    // 2) один раз сделать вычисление,
    // 3) вернуть ошибку/статус.
    // Здесь — ещё одна попытка чтения напрямую (осторожно!)
    const fallback = await computeProfileFromDB(userId);
    await setCachedProfile(cacheKey, fallback);
    return fallback;
  }
}

Почему это работает

  • В момент промаха только один инстанс держит lock.
  • Остальные не стучат в БД — они ждут заполнения кэша.
  • TTL lock ограничивает зависшие вычисления.

5.3. Ограничения и улучшения

  1. Lock TTL должен покрывать worst-case время вычисления плюс запас. Иначе вы получите ситуацию, когда второй инстанс тоже начнёт вычисление.
  2. Поллинг (waitForCache) может добавить нагрузку. Более изящно — использовать Pub/Sub/Streams: первый вычислитель публикует событие, остальные ждут.
  3. Пустые значения. Если профиль отсутствует, лучше кэшировать “not found” с коротким TTL, иначе шторм при повторяющихся запросах на несуществующий ключ гарантирован.

6. Комбинации write-through/write-back с дедупликацией

Теперь свяжем стратегии записи с тем, как инвалидация и дедупликация влияют на поведение.

6.1. Write-through + дедупликация: меньше промахов, но lock всё равно нужен

Если при обновлении вы синхронно обновляете кэш, то промахов меньше. Однако промахи всё равно бывают:

  • ключи впервые запрашиваются после деплоя,
  • инвалидация отработала,
  • сбой кэша/сети потерял запись,
  • истёк TTL.

Значит single-flight остаётся полезным, просто реже включается.

6.2. Write-back + дедупликация: критически важно

При write-back вы сознательно позволяете кэшу быть устаревшим на коротком интервале. Если вы делаете инвалидацию через delete, то после инвалидации кэш гарантированно промахнётся и будет нужен single-flight, чтобы не устроить лавину на БД.

Версионирование может снизить число промахов (старую версию можно держать, пока новая не соберётся), но при первом запросе на новую версию всё равно будет промах.

6.3. Инвалидация как триггер штормов

Есть распространённая ошибка: вы удаляете много ключей разом (например, “очистим профиль пользователя во всех местах”). В момент массового удаления кэш станет пустым, и дедупликация по одному ключу не спасёт, если у вас миллион уникальных ключей одновременно.

Тут помогают:

  • плавные обновления (batching, rate limiting инвалидации),
  • версионирование вместо массового delete,
  • pre-warm горячих ключей (иногда),
  • корректный TTL: не синхронизировать экспирацию на одну секунду.

7. Подводные камни инвалидации и дедупликации

7.1. Неправильный порядок действий при обновлении

Если вы обновляете источник и затем удаляете кэш, но без гарантии, что запись в БД стала видна, то читатели могут:

  • увидеть старое значение (если кэш ещё не удалён),
  • или попасть в промах и загрузить старые данные (если кэш-вычисление читает из источника, который в процессе обновления ещё не завершён).

Решения:

  • версионирование: кэш-ключ соответствует версии данных,
  • транзакционная схема, где версия меняется атомарно с данными,
  • outbox pattern + фоновой публикацией событий.

7.2. “Слепая” дедупликация без учёта версии

Если вы дедуплицируете по userId, но версия могла смениться, то второй поток может ждать вычисления для старой версии и затем записать её обратно в кэш (если ключ одинаковый по логике). Важно, чтобы lock и кэш-ключ включали версию/инвалидационный маркер.

Правило: координация вычислений должна быть привязана к точной цели кэша, а не к абстрактному “профиль пользователя”.

7.3. Кэширование ошибок

Если вычисление падает (ошибка БД), лучше не кэшировать “ошибку навсегда”, но полезно:

  • быстро отвечать одинаковой ошибкой всем ожидающим,
  • кэшировать временный “fallback” или “circuit breaker” на короткий срок,
  • чтобы избежать повторного шторма, пока источник не восстановился.

7.4. TTL и “края” событий

Если TTL равен 300 секунд и вы удаляете ключи вручную, моменты протухания могут совпасть. Типичная техника — jitter: слегка рандомизировать TTL, чтобы экспирации распределялись.

Например: EX = baseTTL * (0.9 + random*0.2). Для больших систем это существенно снижает вероятность одновременного промаха.


8. Набор практических решений (checklist)

Ни

Войдите, чтобы поставить лайк и оставить комментарий.

Автор

$ sudo teach IT

Продолжите обучение

Все курсы
Python – для начинающих!

Python – для начинающих!

С нуля до профессионального уровня. Подходит для всех. Учитесь каждый день и овладейте самым популярным языком программирования.

Перейти к курсу
Простыми словами о защищенном соединении

Простыми словами о защищенном соединении

Базовый курс для обычных людей: что такое защищённое соединение, как оно устроено, что оно умеет и чего не умеет, как выбрать сервис и настроить его на своих устройствах.

Перейти к курсу
Ботостроение Telegram

Ботостроение Telegram

Лёгкий, быстрый и доступный способ познакомиться с миром ботостроения в Telegram. Видео, конспекты, практика и помощь – всё у нас на курсе.

Перейти к курсу
Сервис, который не падает - как построить безопасную зашифрованную сеть

Сервис, который не падает - как построить безопасную зашифрованную сеть

Практический курс по безопасности сети: как собрать собственный сервис защищённого доступа - от пустого сервера до подписки с автовыбором - и, что важнее, как потом не уронить его своими руками.

Перейти к курсу

Другие статьи

Python для аналитики на практике: как строить признаки и проверять их воспроизводимость
python

Python для аналитики на практике: как строить признаки и проверять их воспроизводимость

Поймём, где чаще всего ломается воспроизводимость (версии данных, фильтры, дрейф) и как оформить пайплайн признаков так, чтобы результаты совпадали между запусками и окружениями.

17 сентября 2026 г.
1050
FastAPI и фоновые задачи: очередь vs BackgroundTasks, idempotency и обработка ретраев
fastapi

FastAPI и фоновые задачи: очередь vs BackgroundTasks, idempotency и обработка ретраев

На примерах объясним, когда достаточно BackgroundTasks, а когда нужна отдельная очередь/воркер. Разберём, как обеспечить воспроизводимость выполнения, безопасные ретраи и корректные статусы задач в API.

2 августа 2026 г.
720
Создание Telegram бота в 2026 легко и просто! Полный курсы!
создание

Создание Telegram бота в 2026 легко и просто! Полный курсы!

19 июня 2026 г.
1431
Как выбрать, куда идти в IT: веб, боты, данные — без хаоса
выбрать

Как выбрать, куда идти в IT: веб, боты, данные — без хаоса

Разберём самые понятные направления для новичка (веб, боты, аналитика/данные) и чем они отличаются по шагам обучения. В конце вы получите простой план, как проверить, что вам подходит, и какой стартовый путь выбрать.

5 октября 2026 г.
580
Путь от README до демо: как собрать портфолио разработчика, чтобы его хотели смотреть
путь

Путь от README до демо: как собрать портфолио разработчика, чтобы его хотели смотреть

Разберём структуру репозитория, как упаковать проект в понятную историю (проблема → решение → запуск → метрики качества) и какие артефакты повышают доверие: скринкасты, архитектурная схема, чек-лист продакшн-готовности.

17 сентября 2026 г.
1090
Как понять, какой язык программирования учить первым: простой тест и выбор
понять

Как понять, какой язык программирования учить первым: простой тест и выбор

Помогу новичку выбрать первый язык без стресса: объясню варианты по целям (веб, боты, анализ данных), что учить в первую очередь и как не потратить месяцы впустую. В конце — краткий план старта на ближайшие недели.

2 октября 2026 г.
630

Комментарии

Станьте первым в обсуждении

Поделитесь мнением о статье или задайте вопрос автору

Содержание

1. Термины и модель: что именно кэшируем1.1. Кэш как копия с заданной семантикой1.2. Инвалидация vs вытеснение по TTL2. Стратегии записи: write-through и write-back2.1. Write-through: запись в кэш и источник одновременно2.2. Write-back: запись только в источник, а кэш догоняет позже3. Инвалидация: “почистить ключ” — недостаточно3.1. Два подхода к инвалидации3.2. Гонка чтения и записи: сценарий, который сложно увидеть4. Предотвращение “штормов”: дедупликация запросов (single-flight)4.1. Что такое single-flight4.2. Паттерн дедупликации поверх Redis/кэша5. Write-through + инвалидация + дедупликация: практический сценарий5.1. Модель данных для кэша5.2. Пример: дедупликация при промахе (JavaScript/TypeScript)5.3. Ограничения и улучшения6. Комбинации write-through/write-back с дедупликацией6.1. Write-through + дедупликация: меньше промахов, но lock всё равно нужен6.2. Write-back + дедупликация: критически важно6.3. Инвалидация как триггер штормов7. Подводные камни инвалидации и дедупликации7.1. Неправильный порядок действий при обновлении7.2. “Слепая” дедупликация без учёта версии7.3. Кэширование ошибок7.4. TTL и “края” событий8. Набор практических решений (checklist)