← все задачи

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». Возьмите следующую тему или вернитесь к разобранным через неделю — на собеседовании важно вспомнить, а не узнать.