← все задачи
Async Python · задача 10 из 10
Кэш и одновременные промахи
Продвинутый
20–25 минут
Lockкэшгонки
Условие
Есть дорогая асинхронная функция получения данных и кэш. Сделайте обёртку, которая при одновременных обращениях к одному ключу выполнит дорогой вызов один раз, а остальные дождутся его результата.
Что требуется
- Значение вычисляется один раз на ключ, даже если запросов сто
- Разные ключи не блокируют друг друга
- При ошибке вычисления ожидающие получают её, а не зависают навсегда
Пример
value = await cached_fetch("user:42")
# 100 одновременных вызовов -> 1 поход в базу, 99 ждут его результата
Сначала уточните
Вопросы до кода — половина оценки. Молча начать печатать хуже, чем задать два вопроса.
- Кэш локальный (в памяти процесса) или общий (Redis)? От этого зависит, где нужна блокировка
- Нужен ли TTL и допустимо ли отдавать слегка устаревшее значение, пока обновляется новое?
- Что делать при ошибке: кэшировать неудачу на короткое время или пробовать снова каждому?
Показать решение Скрыть решение
Решение
import asyncio
class SingleFlightCache:
def __init__(self, fetch):
self._fetch = fetch
self._values = {}
self._in_flight = {} # ключ -> задача, которая его вычисляет
async def get(self, key):
if key in self._values:
return self._values[key]
task = self._in_flight.get(key)
if task is None:
# создаём задачу синхронно: до первого await никто не вклинится
task = asyncio.create_task(self._load(key))
self._in_flight[key] = task
return await asyncio.shield(task)
async def _load(self, key):
try:
value = await self._fetch(key)
self._values[key] = value
return value
finally:
self._in_flight.pop(key, None)
Почему так
Почему обычной проверки «нет в кэше — сходи в базу» недостаточно
- Между промахом и записью в кэш есть await — за это время сто корутин успеют промахнуться и пойти в базу
- Это и называется cache stampede: популярный ключ протух, и вся нагрузка разом ушла в базу
- Особенно больно при рестарте: кэш пуст, а трафик прежний
Почему задача в словаре, а не Lock на ключ
- Задача — это и блокировка, и место для результата: ожидающим достаточно await по ней
- С Lock после его освобождения все ждавшие пошли бы проверять кэш заново — лишний код и лишние гонки
- Словарь блокировок ещё и надо чистить, иначе он растёт по числу ключей
Почему create_task, а потом shield
- create_task между проверкой и записью в словарь выполняется без await — вклиниться туда невозможно, поэтому гонки нет
- shield защищает общую задачу от отмены: если один из ожидающих отвалится по таймауту, остальные не потеряют вычисление
- finally снимает ключ из in_flight даже при ошибке — иначе следующий запрос вечно ждал бы мёртвую задачу
Что спросят дальше
- Спросят про несколько процессов: локальный словарь не спасёт, нужен распределённый лок в Redis с TTL
- Спросят про TTL и «протухание»: приём stale-while-revalidate — отдаём старое, обновляем в фоне
- Спросят про ошибки: если не кэшировать неудачу хотя бы на секунду, упавшая база получит тот же шквал запросов
Тема пройдена
Это была последняя задача темы «Async Python». Возьмите следующую тему или вернитесь к разобранным через неделю — на собеседовании важно вспомнить, а не узнать.