Single Flight для PHP: элегантное решение проблемы Cache Stampede

← Вернуться к статьям

Вступление

В высоконагруженных приложениях мы часто сталкиваемся с ситуацией, когда множество запросов одновременно пытаются получить одни и те же данные. Представьте: ваш популярный API получает 1000 одновременных запросов к одной и той же дорогостоящей операции — загрузка данных из внешнего сервиса, сложный SQL-запрос или генерация отчета. Что происходит?

Проблема Cache Stampede: когда кеш истекает, все запросы одновременно начинают выполнять дорогостоящую операцию, создавая лавину нагрузки на базу данных или внешние сервисы.

Сегодня я расскажу о паттерне Single Flight и его реализации на PHP, которая позволяет элегантно решить эту проблему.

Что такое Single Flight?

Single Flight (также известный как Request Coalescing или Call Deduplication) — это паттерн, который гарантирует, что одна и та же операция выполнится только один раз, даже если несколько процессов или потоков запросят её одновременно.

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

  1. Первый запрос получает блокировку и начинает выполнение операции
  2. Последующие запросы с тем же ключом ждут результата первого
  3. Результат кешируется и возвращается всем ожидающим запросам
  4. Следующие запросы получают закешированный результат без повторного выполнения
Визуализация работы Single Flight
Запрос 1 → [Блокировка] → Выполнение → Результат → Кеширование
Запрос 2 → [Ожидание...]           ↗
Запрос 3 → [Ожидание...]         ↗
Запрос 4 → [Ожидание...]       ↗

Архитектура библиотеки

Библиотека построена на нескольких ключевых компонентах:

1. StoreInterface — абстракция хранилища

PHP
interface StoreInterface
{
    public function set(string $key, mixed $value, ?int $ttl = null): bool;
    public function get(string $key): mixed;
    public function has(string $key): bool;
    public function delete(string $key): bool;
    public function clear(): bool;
}

Интерфейс позволяет легко создавать собственные реализации хранилищ. Из коробки доступны:

2. SingleFlight — основной класс

Главный класс библиотеки, использующий:

3. Group — обертка для удобства

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

Разбор кода: как это работает внутри

Давайте посмотрим на ключевой метод do():

PHP - Метод do()
public function do(string $key, callable $fn, float $timeout = 30.0, ?int $ttl = null): mixed
{
    // Шаг 1: Быстрая проверка кеша
    if ($this->store->has($key)) {
        $cached = $this->store->get($key);
        if (isset($cached['type']) && $cached['type'] === 'result') {
            return $cached['value'];
        }
    }

    // Шаг 2: Получение блокировки
    $lock = $this->lockFactory->createLock('lock:' . $key, $timeout);

    if ($lock->acquire(true)) {
        // Шаг 3: Double-check после получения блокировки
        if ($this->store->has($key)) {
            $cached = $this->store->get($key);
            if (isset($cached['type']) && $cached['type'] === 'result') {
                $lock->release();
                return $cached['value'];
            }
        }

        try {
            // Шаг 4: Выполнение операции
            $result = $fn();

            // Шаг 5: Кеширование результата
            $ttlToUse = $ttl ?? $this->defaultTtl;
            $this->store->set(
                $key,
                ['type' => 'result', 'value' => $result],
                $ttlToUse > 0 ? $ttlToUse : null
            );

            return $result;
        } finally {
            $lock->release();
        }
    }
}

Почему Double-Check Pattern?

Заметили двойную проверку кеша? Это критически важная оптимизация:

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

Это классический паттерн Double-Checked Locking, адаптированный для нашей задачи.

Примеры использования

Пример 1: Защита от дублирования запросов к API

PHP
use SingleFlight\SingleFlight;
use SingleFlight\Store\RedisStore;
use Predis\Client;

$redis = new Client('tcp://127.0.0.1:6379');
$store = new RedisStore($redis, 'api:');
$sf = new SingleFlight($store);

// Даже если 1000 пользователей одновременно запросят данные,
// реальный запрос к API выполнится только один раз
$userData = $sf->do('user-profile-' . $userId, function() use ($userId) {
    // Дорогостоящий запрос к внешнему API
    return $externalApi->fetchUserProfile($userId);
}, 30.0, 300); // таймаут 30 сек, кеш на 5 минут

Пример 2: Генерация тяжелых отчетов

PHP
$report = $sf->do('monthly-report-' . $month, function() use ($month) {
    // Сложная выборка из БД + обработка
    $data = $db->query("
        SELECT
            u.id, u.name,
            SUM(o.amount) as total,
            COUNT(o.id) as order_count
        FROM users u
        JOIN orders o ON o.user_id = u.id
        WHERE DATE_FORMAT(o.created_at, '%Y-%m') = ?
        GROUP BY u.id
        ORDER BY total DESC
    ", [$month]);

    // Дополнительная обработка
    return processReportData($data);
}, 60.0, 3600); // таймаут 60 сек, кеш на 1 час

Пример 3: Использование с Group

PHP
use SingleFlight\Group;

$group = new Group(null, $store);

[$result, $wasExecuted] = $group->do('expensive-operation', function() {
    sleep(5); // Тяжелая операция
    return fetchDataFromSlowService();
});

if ($wasExecuted) {
    // Логируем только реальные выполнения
    logger()->info("Operation was actually executed");
    metrics()->increment('real_executions');
} else {
    // Получили из кеша
    metrics()->increment('cache_hits');
}

Хранилища: MemoryStore vs RedisStore

MemoryStore: быстро, но локально

PHP
use SingleFlight\Store\MemoryStore;

$store = new MemoryStore();
$sf = new SingleFlight($store);

⚡ Плюсы

  • Максимальная скорость (работа в памяти)
  • Нет внешних зависимостей
  • Идеален для CLI-скриптов и воркеров

⚠️ Минусы

  • Не работает между процессами
  • Данные теряются при перезапуске
  • Не подходит для веб-приложений с несколькими воркерами

Когда использовать MemoryStore:

RedisStore: масштабируемо и надежно

PHP
use SingleFlight\Store\RedisStore;
use Predis\Client;

$redis = new Client([
    'scheme' => 'tcp',
    'host'   => '127.0.0.1',
    'port'   => 6379,
    'timeout' => 2.0,
]);

$store = new RedisStore($redis, 'myapp:sf:');
$sf = new SingleFlight($store);

🌐 Плюсы

  • Работает между процессами и серверами
  • Персистентность данных
  • Встроенная поддержка TTL
  • Масштабируемость через Redis Cluster

⚠️ Минусы

  • Чуть медленнее MemoryStore (сетевые запросы)
  • Требует настройки Redis
  • Дополнительные ресурсы

Когда использовать RedisStore:

Реальные кейсы применения

Кейс 1: E-commerce — расчет популярных товаров

PHP
class PopularProductsService
{
    public function __construct(
        private SingleFlight $singleFlight,
        private ProductRepository $products
    ) {}

    public function getPopularProducts(string $category, int $limit = 10): array
    {
        return $this->singleFlight->do(
            "popular-products:{$category}:{$limit}",
            fn() => $this->calculatePopularProducts($category, $limit),
            30.0,
            600 // Кеш на 10 минут
        );
    }

    private function calculatePopularProducts(string $category, int $limit): array
    {
        // Сложный запрос с аналитикой
        return $this->products->getByPopularity($category, $limit);
    }
}

// Даже при 10000 одновременных запросов к главной странице,
// расчет популярных товаров выполнится только один раз

Кейс 2: Social Network — лента новостей

PHP
class NewsFeedService
{
    public function getUserFeed(int $userId): array
    {
        return $this->singleFlight->do(
            "feed:user:{$userId}",
            function() use ($userId) {
                // Агрегация постов от друзей
                $friendIds = $this->getFriendIds($userId);
                $posts = $this->getPosts($friendIds);
                $enriched = $this->enrichWithMedia($posts);

                return $this->sortByRelevance($enriched);
            },
            45.0,
            300
        );
    }
}

Производительность

Бенчмарк: 1000 одновременных запросов

Метрика Без Single Flight С Single Flight (RedisStore)
Время выполнения ~50 секунд ~3 секунды
Запросов к БД 1000 1
Нагрузка на БД 100% 1%
Overhead Redis - ~10ms на запрос

Рекомендации по производительности

  1. Используйте подходящий Store:
    • CLI/Workers → MemoryStore
    • Web/API → RedisStore
  2. Настраивайте TTL разумно:
    • Слишком большой → устаревшие данные
    • Слишком маленький → частые пересчеты
  3. Используйте префиксы:
    PHP
    $userStore = new RedisStore($redis, 'users:');
    $productsStore = new RedisStore($redis, 'products:');
  4. Мониторьте cache hit ratio:
    PHP
    [$result, $wasExecuted] = $group->do('key', $fn);
    
    if ($wasExecuted) {
        metrics()->increment('singleflight.miss');
    } else {
        metrics()->increment('singleflight.hit');
    }

Интеграция с популярными фреймворками

Laravel

PHP - app/Providers/SingleFlightServiceProvider.php
use SingleFlight\SingleFlight;
use SingleFlight\Store\RedisStore;

class SingleFlightServiceProvider extends ServiceProvider
{
    public function register()
    {
        $this->app->singleton(SingleFlight::class, function ($app) {
            $redis = $app->make('redis')->connection();
            $store = new RedisStore($redis, config('singleflight.prefix'));

            return new SingleFlight(
                $store,
                null,
                config('singleflight.default_ttl', 300)
            );
        });
    }
}

// Использование
class UserController extends Controller
{
    public function show(int $id, SingleFlight $sf)
    {
        $user = $sf->do("user:{$id}", function() use ($id) {
            return User::with('posts', 'comments')->find($id);
        }, 30.0, 600);

        return response()->json($user);
    }
}

Symfony

YAML - config/services.yaml
services:
    SingleFlight\Store\RedisStore:
        arguments:
            $redis: '@snc_redis.default'
            $prefix: 'sf:'

    SingleFlight\SingleFlight:
        arguments:
            $store: '@SingleFlight\Store\RedisStore'
            $defaultTtl: 300

Плюсы и минусы

✅ Плюсы

  • Защита от Cache Stampede — автоматическая дедупликация запросов
  • Снижение нагрузки — уменьшение запросов к БД/API в разы
  • Простота использования — минимальный boilerplate, понятный API
  • Гибкость — легко расширяемая архитектура, разные хранилища
  • Межпроцессная синхронизация — работает с PHP-FPM между серверами
  • Production-ready — обработка ошибок, TTL support, тесты

⚠️ Минусы и ограничения

  • Зависимость от Symfony Lock — требует Symfony Lock Component
  • Redis для межпроцессного взаимодействия — нужна дополнительная инфраструктура
  • Overhead блокировок — ~10-50ms на операцию, не для микрооптимизаций
  • Сериализация — JSON в Redis, ограничения на типы данных
  • Таймауты — нужно правильно настраивать timeout для операций

Когда НЕ использовать

Сравнение с другими решениями

Решение Cache Stampede Межпроцессная Простота TTL
Single Flight ✅ (Redis)
Laravel Cache ⚠️ (частично)
Простой мьютекс
Symfony Lock ⚠️ (вручную) ⚠️
Без защиты - ⚠️

Заключение

Single Flight — это мощный паттерн для оптимизации высоконагруженных приложений. Библиотека предоставляет:

Когда использовать:

Установка

Bash
composer require xman12/single-flight

Полезные ссылки

Оптимизируйте свой API с LightBox

Используйте Mock API для параллельной разработки и тестирования, а Single Flight для оптимизации производительности в продакшене!

Попробовать LightBox API бесплатно →

Вопросы, предложения и pull request'ы приветствуются!
Использовали Single Flight в своих проектах? Поделитесь опытом в комментариях!

#php #redis #performance #cache #highload #patterns #architecture

← Вернуться к статьям