Как остановить cache stampede через advisory lock
Как транзакционная advisory lock и повторная проверка кэша позволяют одному процессу выполнить тяжёлый расчёт, пока остальные ждут результат.
PostgreSQLБлокировкиКонкурентностьПроизводительностьМакс с раздражением закрывал вкладки с запросами. Виновник утреннего «шторма» был найден и обезврежен — монструозный отчет, который пожирал CPU, как голодный студент пельмени, был оптимизирован. Но Макс знал: это ненадолго.
В кабинете сидел Денис, TechLead аналитики, с выражением лица, которое появляется после фразы «Ну тут же простой запрос, что может пойти не так?»
— Оптимизация — это хорошо, — начал Денис, нервно крутя ручку. — Но бизнес будет юзать этот отчет постоянно. И вся дирекция будет заглядывать в него перед совещаниями. База снова ляжет, Макс. Я думаю, надо кешировать данные для отчета.
— Ну так сделайте, — Макс пожал плечами. — В чем проблема?
— В Cache Stampede, — вздохнул Денис. — Классика жанра. В 09:00 кэш пуст. Заходят 10 начальников. Код проверяет кэш — пусто. И все десять потоков одновременно запускают этот тяжелый расчет. Получается мы сами себя DDOS-им. Думаю надо городить Redis с Distributed Lock или очередь на RabbitMQ…
Макс поморщился, как от зубной боли.
— Денис, у тебя Postgres под капотом. Redis и очереди — отличные инструменты. Но не для каждого гвоздя нужен отбойный молоток. Твой Cache Stampede легко решается блокировкой в базе.
— Предлагаешь блокировать записи в таблице, пока рассчитываются данные?
— Ну зачем так грубо? — Вздохнул Макс. — Есть же Advisory Locks.
Он подошел к доске и набросал схему решения:
CREATE OR REPLACE FUNCTION get_heavy_report(p_date date)RETURNS jsonb AS $$DECLARE l_result jsonb;BEGIN -- 1. Оптимистичная проверка: вдруг уже готово? SELECT data INTO l_result FROM report_cache WHERE rep_date = p_date AND valid_until > clock_timestamp(); IF l_result IS NOT NULL THEN RETURN l_result; END IF;
-- 2. "Турникет". Кто успел первым — тот и молодец. -- Остальные встают в очередь на уровне ядра (легковесно!) -- функция с "_xact" гарантирует снятие блокировки при любом исходе транзакции PERFORM pg_advisory_xact_lock( hashtext('heavy_report'), p_date - date '2000-01-01' );
-- 3. Повторная проверка кэша (Самое важное!) -- Пока мы спали в очереди, кто-то уже всё сделал. SELECT data INTO l_result FROM report_cache WHERE rep_date = p_date AND valid_until > clock_timestamp(); IF l_result IS NOT NULL THEN -- Расчет не нужен. RETURN l_result; END IF;
-- 4. Если мы реально первые (или вообще единственные) — работаем. l_result := heavy_calculation_function(p_date);
-- 5. Сохраняем результаты для остальных INSERT INTO report_cache(rep_date, data, valid_until) VALUES (p_date, l_result, clock_timestamp() + interval '15 minutes') ON CONFLICT (rep_date) DO UPDATE SET data = EXCLUDED.data, valid_until = EXCLUDED.valid_until;
RETURN l_result;END;$$ LANGUAGE plpgsql;Итого:
-
первый поток считает,
-
остальные ждут,
-
после пробуждения — просто читают кэш,
Денис вчитывался в код, шевеля губами.
— Подожди… То есть второй поток утыкается в лок, засыпает, а когда просыпается — просто забирает готовое из таблицы? Без Redis? Без очередей?
— Именно, — Макс вернулся в кресло. — Это называется Advisory Lock. В отличие от блокировки строк (SELECT FOR UPDATE), он блокирует не данные, а намерение. Это джентльменское соглашение между процессами.
— А нагрузка?
— Небольшая, но не нулевая. Advisory locks хранятся в общей памяти и не создают строк или bloat, однако занимают слоты таблицы блокировок, а ожидающие соединения остаются заняты. Функция pg_advisory_xact_lock снимает блокировку при завершении транзакции, в том числе после ошибки. Поэтому тяжёлый расчёт не должен находиться внутри лишней длинной транзакции, а на уровне приложения нужны таймаут и обработка отмены.
— Двухчастный ключ здесь разделяет пространство блокировок по типу отчёта и точной дате. Все участники обязаны использовать тот же протокол: advisory lock сама по себе ничего не защищает от кода, который её игнорирует. И не забудь об инвалидировании кэша — без valid_until первая рассчитанная строка жила бы вечно.
Денис посмотрел на доску с тем выражением лица, с которым обычно смотрят на фокусы.
— Макс, это же… Это как очередь в туалет в поезде.
— Что? — Макс поперхнулся кофе.
— Ну смотри. Если занято — ты не пытаешься построить рядом новый туалет. И не ломишься внутрь вдесятером. Ты просто стоишь и ждешь, пока освободится. Только тут еще круче: когда дверь открывается, ты заходишь и видишь, что за тебя всё уже сделали, и уходишь довольный.
Макс рассмеялся:
— В туалете я предпочитаю все делать сам, но технически — в точку. Запомни: в нагруженных системах побеждает не тот, кто быстрее бежит, а тот, кто умеет грамотно ждать.