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

Эти решения можно разделить на две большие группы: реактивные и превентивные.


Стратегия 1: Блокировка (Mutex) — «Впускать по одному!»

Это самое прямолинейное решение, особенно в контексте кеширования.

Как это работает:

Когда происходит cache miss, первый же запрос, обнаруживший это, устанавливает эксклюзивную блокировку (mutex или lock) на данный ключ кеша. Теперь он единственный, кто имеет право сходить в БД за новыми данными. Все остальные запросы, приходящие следом, “упираются” в эту блокировку. Они не создают нагрузку на БД, а просто ждут, пока “избранный” закончит работу, положит свежие данные в кеш (и снимет замок).

Плюсы: Гарантированно защищает систему (БД) от лавины запросов (Dogpile Effect).
Минусы: Может значительно увеличить время ответа (latency) для “ждущих” запросов, так как они вынуждены простаивать в ожидании, когда обновится кеш.

Пример: Паттерн "Блокировка" (Mutex) на Java

Вот как мог бы выглядеть упрощенный код сервиса, который использует блокировку.
Обратите внимание на паттерн Double-Checked Locking — он критически важен.

// Представим, что у нас есть некий сервис для работы с кешем
// и провайдер блокировок, который может выдать замок по уникальному ключу.

public class DataService {

    private final Cache<String, String> cache = new SimpleCache();
    private final LockProvider<String> lockProvider = new KeyBasedLockProvider();
    private final Database database = new SlowDatabase();

    public String getDataWithLock(String key) {
        // 1. Сначала просто проверяем кеш. В 99% случаев данные будут здесь.
        String data = cache.get(key);
        if (data != null) {
            return data;
        }

        // 2. Данных в кеше нет. Получаем блокировку, уникальную для этого ключа.
        // Только один поток сможет пройти дальше.
        Lock keyLock = lockProvider.getLockForKey(key);
        keyLock.lock();

        try {
            // 3. CRITICAL: Повторная проверка кеша (Double-Checked Locking).
            //> Пока мы ждали блокировку, другой поток мог уже сделать всю работу.
            //> Эта проверка предотвращает лишние запросы в базу данных.
            data = cache.get(key);
            if (data != null) {
                return data;
            }

            // 4. Мы первый и единственный поток, который добрался сюда.
            // Идем в базу за "дорогими" данными.
            data = database.fetchExpensiveData(key);

            // 5. Кладём данные в кеш для других потоков.
            cache.put(key, data, 60); // Кешируем на 60 секунд
            return data;

        } finally {
            // 6. В любом случае освобождаем блокировку, чтобы другие могли работать.
            keyLock.unlock();
        }
    }
}

Этот код наглядно показывает, как только один "избранный" поток выполняет тяжелую работу, в то время как остальные терпеливо ждут, не создавая нагрузки на базу данных.


Стратегия 2: Использование устаревших данных (Stale-while-revalidate)

«Вот вам вчерашняя газета, сегодняшняя уже в пути!»

Это более элегантный и дружелюбный к пользователю подход.

Как это работает:

Когда кеш истекает, система не удаляет старые данные. Вместо этого она отдает их первому же пришедшему запросу, но помечает их как “устаревшие”. Одновременно, в фоновом режиме, она запускает единственный асинхронный процесс для генерации новых данных.

Все последующие запросы продолжают мгновенно получать старые данные, пока фоновый процесс не обновит кеш.

Плюсы: Пользователь получает ответ мгновенно, не замечая процесса обновления.
Минусы: Какой-то промежуток времени пользователь видит не самые актуальные данные.

Пример: Паттерн "Stale-while-revalidate" на Java

Этот подход немного сложнее, так как нам нужно хранить в кеше время "протухания" данных.

// Вспомогательный класс для хранения данных вместе с мета-информацией
class CacheEntry {
    String data;
    long expiryTimestamp; // Время, когда данные считаются устаревшими

    public boolean isStale() {
        return System.currentTimeMillis() > expiryTimestamp;
    }
}

public class DataServiceWithStale {
    private final Cache<String, CacheEntry> cache = new SimpleCache();
    private final Database database = new SlowDatabase();
    // Используем пул потоков для фонового обновления
    private final ExecutorService backgroundUpdater = Executors.newFixedThreadPool(10);

    public String getDataWithStale(String key) {
        CacheEntry entry = cache.get(key);

        if (entry == null) {
            // Если в кеше совсем ничего нет, то первому потоку придется синхронно загрузить данные.
            // (Можно скомбинировать с подходом блокировки из примера 1)
            String data = database.fetchExpensiveData(key);
            cache.put(key, new CacheEntry(data, System.currentTimeMillis() + 60000));
            return data;
        }

        if (entry.isStale()) {
            //> Ключевой момент: данные устарели, но мы не ждем!
            try {
                // 1. Запускаем асинхронную задачу в фоновом потоке для обновления кеша.
                //> Этот вызов не блокирует основной поток.
                backgroundUpdater.submit(() -> {
                    String newData = database.fetchExpensiveData(key);
                    cache.put(key, new CacheEntry(newData, System.currentTimeMillis() + 60000));
                });
            } catch (Exception e) {
                // Ло��ируем ошибку, но не даем ей сломать основной запрос
            }
        }

        // 2. В любом случае немедленно возвращаем данные (свежие или устаревшие).
        //> Пользователь получает ответ мгновенно!
        return entry.data;
    }
}

Мы жертвуем свежестью данных ради скорости ответа и защиты системы от перегрузок.


Стратегия 3: Превентивная - “Джиттер” (Jitter)

«Больше трёх не собираться!»

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

Как это работает:

Вместо того чтобы устанавливать жесткий TTL (например, ровно 60 секунд), система добавляет небольшую случайную величину (jitter). Например, TTL устанавливается в диапазоне от 55 до 65 секунд. Это “размазывает” моменты истечения кеша во времени, делая маловероятным, что тысячи ключей истекут в одну и ту же миллисекунду.

Плюсы: Значительно снижает вероятность возникновения проблемы.
Минусы: Не является стопроцентной гарантией.

Пример: Добавление "Джиттера" (Jitter) при записи в кеш

Это не алгоритм получения данных, а модификация алгоритма их записи.

public class CacheServiceWithJitter {

    private final Cache<String, String> cache = new SimpleCache();
    private final Random random = new Random();

    // Базовое время жизни кеша в секундах
    private static final int BASE_TTL_SECONDS = 300; // 5 минут
    // Максимальное отклонение от базового TTL в секундах (например, +/- 10%)
    private static final int JITTER_WINDOW_SECONDS = 30; // +/- 30 секунд

    public void saveData(String key, String data) {
        // Вычисляем случайное отклонение.
        // nextInt(61) даст число от 0 до 60. Вычитаем 30, чтобы получить диапазон от -30 до +30.
        int jitter = random.nextInt(JITTER_WINDOW_SECONDS * 2 + 1) - JITTER_WINDOW_SECONDS;

        long finalTtl = BASE_TTL_SECONDS + jitter;

        System.out.println("Кешируем ключ '" + key + "' на " + finalTtl + " секунд.");
        cache.put(key, data, finalTtl);
    }
}

Этот простой прием является эффективной превентивной мерой против Dogpile Effect.

А что там с масштабированием реплик? Об этом в следующей статье.