Материалы / Истории / Как остановить cache stampede через advisory lock
История

Как остановить cache stampede через advisory lock

Как транзакционная advisory lock и повторная проверка кэша позволяют одному процессу выполнить тяжёлый расчёт, пока остальные ждут результат.

Макс с раздражением закрывал вкладки с запросами. Виновник утреннего «шторма» был найден и обезврежен — монструозный отчет, который пожирал 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 первая рассчитанная строка жила бы вечно.

Денис посмотрел на доску с тем выражением лица, с которым обычно смотрят на фокусы.

— Макс, это же… Это как очередь в туалет в поезде.

— Что? — Макс поперхнулся кофе.

— Ну смотри. Если занято — ты не пытаешься построить рядом новый туалет. И не ломишься внутрь вдесятером. Ты просто стоишь и ждешь, пока освободится. Только тут еще круче: когда дверь открывается, ты заходишь и видишь, что за тебя всё уже сделали, и уходишь довольный.

Макс рассмеялся:

— В туалете я предпочитаю все делать сам, но технически — в точку. Запомни: в нагруженных системах побеждает не тот, кто быстрее бежит, а тот, кто умеет грамотно ждать.