Перейти к содержанию

Кэширование

Что кэшируется

Ключ (префикс tme:v2:) Значение TTL
json:body:page:{mode}:{channel}:{position} Готовый ответ /v1/body TTL страницы (CACHE_MODE: 0 / 10 / 120 с)
page:{mode}:{channel}:{position} Разобранная страница (сырой dict в виде JSON) TTL страницы; для последней страницы при включённой ленте: max(page TTL, FEED_STALE_TTL)
more:{mode}:{channel}:{direction}:{position} Готовый ответ /v1/more TTL страницы
preview:{channel} Разобранное превью или null для неизвестных каналов PREVIEW_CACHE_TTL (1 ч), 60 с для неизвестных
feed:popularity Отсортированное множество канал → оценка — (затухание обеспечивает рефрешер)
feed:refresher-leader Токен рефрешера-лидера 30 с, продлевается каждые 5 с

{mode} — это режим парсера, поэтому экземпляры с full и simplified могут делить один Redis, не смешивая результаты. Имена каналов приводятся к нижнему регистру.

Каждое значение хранится вместе со временем записи ("<unix time>\n<payload>" в Redis), что позволяет вызывающему коду применять собственные правила свежести поверх срока жизни в бэкенде — именно так работает stale-while-revalidate в ленте.

Уровни

У каждого воркера есть кэш внутри процесса. Если задан REDIS_URL, это первый уровень (L1) перед Redis / Valkey (L2), общим для всех воркеров и серверов; без Redis это единственный уровень. Сервисы в любом случае видят один Cache (TwoLevelCache или MemoryCache в cache.py).

Повторный запрос на том же воркере 2.0.0 Сейчас (попадание в L1)
/v1/body, Redis в 90 мс (Upstash) 95 мс 0.10 мс
/v1/body, Redis на том же хосте 0.35 мс 0.10 мс
/v1/body, без Redis 0.15 мс 0.10 мс

Данные по каждому эндпоинту — в разделе Производительность.

Уровень внутри процесса

telegram_me_native.LocalCache: Rust, на конкурентном кэше moka.

  • LRU, ограниченный реальной памятью. Запись весит свой ключ, хранимое значение и 512 байт служебных данных (запись moka и её узлы в хеш-таблице, LRU и таймерах: ~390 запрошенных байт, измерено считающим аллокатором, плюс округление malloc). Сразу после каждой записи общий объём остаётся в пределах MEMORY_CACHE_MAX_MB; rust/telegram-me-native/tests/cache_memory.rs проверяет, что учтённое покрывает реально выделенное. Каждая новая запись принимается (без фильтра допуска TinyLFU): только что загруженную страницу вот-вот прочитают снова.
  • Сжатые значения. zstd уровня 1 (libzstd, C): отрендеренная страница занимает 12–43 КиБ вместо 44–233 КиБ в UTF-8, а чтение обратно (распаковка сразу в новый bytes) занимает ~40 мкс для страницы в 95 КБ. Значения, которые не сжимаются, хранятся как есть.
  • Срок жизни у каждой записи через Expiry из moka: каждая запись живёт свой TTL. Истёкшие записи никогда не возвращаются и удаляются колесом таймеров moka примерно в течение секунды, читают их или нет.
  • GIL не отпускается. Чтение и запись занимают десятки микросекунд и выполняются в event loop. Если GIL отпустить, он уходит потоку разбора, занятому в pydantic, и event loop ждёт его возвращения: при двух нагружающих CPU потоках Python чтение заняло 6.3 мс (p50) вместо 38 мкс.

Уровень Redis

Команды прерываются по тайм-ауту через REDIS_TIMEOUT (0.5 с). Три ошибки подряд в течение 10 секунд размыкают circuit breaker: после этого Redis обходится стороной 30 секунд, так что сбой стоит нескольких медленных запросов на воркер, а не одного на каждый запрос. Одиночная ошибка, например медленное TLS-подключение к удалённому Redis или потерянный пакет, его не размыкает, а любая команда, дошедшая до Redis, сбрасывает счётчик. Ответы разбирает hiredis (C).

Запрос ленты или превью выполняет до 100 обращений к кэшу одновременно, поэтому при пуле соединений (2 × TELEGRAM_MAX_CONCURRENCY на воркер, не меньше 32) всплеск ждёт свободного соединения до REDIS_TIMEOUT. Команда, не получившая соединения, считается промахом; исчерпание пула — локальное ограничение, поэтому circuit breaker не размыкается.

Как уровни работают вместе

  • Чтение: сначала L1. При промахе одно обращение к Redis (конвейер из GET и PTTL) возвращает запись и её оставшийся TTL; L1 хранит копию этот оставшийся TTL, но не дольше MEMORY_CACHE_MAX_TTL (10 с). Одновременные промахи по одному ключу делят одно чтение из Redis. Копия никогда не заменяет более новую запись, сделанную в L1, пока чтение было в пути.
  • Запись: в оба уровня, с одним и тем же временем сохранения. L1 хранит запись min(TTL, MEMORY_CACHE_MAX_TTL).
  • Время сохранения: копия сохраняет время, когда запись была сохранена впервые, поэтому у записи одинаковый возраст на всех серверах и stale-while-revalidate в ленте работает без изменений.
  • Redis недоступен или circuit breaker разомкнут: L1 работает один. Чтения, не попавшие в L1, — промахи; записи идут только в L1, на полный TTL, потому что общей копии, которую можно было бы забрать вместо них, нет.

Зачем MEMORY_CACHE_MAX_TTL. Два сервера (и воркер ленты) делят Redis, но не свои L1, и ничто не сообщает серверу, что другой записал более новую запись. Для большинства ключей устаревание и так ограничено их TTL, и это приемлемо: отрендеренная страница живёт TTL страницы на всех уровнях. Однако последняя страница канала хранится в течение stale-окна ленты (FEED_STALE_TTL, 15 мин), а свежей считается только в течение TTL страницы (10 с) или FEED_FRESH_TTL (60 с). Без ограничения сервер держал бы свою старую копию до 15 минут и, когда она устареет, заново загружал бы страницу с t.me вместо того, чтобы прочитать копию, которую рефрешер (или другой сервер) только что положил в Redis; с воркером ленты API-серверы игнорировали бы его работу. С ограничением сервер видит более новую запись в Redis не позже чем через 10 секунд после её записи. Цена — не больше одного чтения из Redis на ключ и воркер каждые 10 секунд, для ключей, которые читают.

На обоих уровнях значения — байты UTF-8. Хранение JSON как str стоило до четырёх байт на символ (одно-единственное эмодзи расширяет всю строку) и кодирования при каждом попадании в кэш.

Заранее отрендеренные ответы

Для /v1/body и /v1/more в кэше лежит итоговый JSON. Загруженная страница один раз разбирается, проверяется и сериализуется; каждое попадание в кэш отдаётся как байты. Это делает попадания в кэш примерно в 5 раз дешевле, чем повторная валидация разобранной страницы на каждый запрос (см. Производительность).

Объединение запросов

Одинаковые одновременные запросы выполняют работу один раз: один запрос к t.me и один рендеринг, куда бы они ни пришли. Части, описанные ниже, измерены в разделе Производительность; ядро планирования (таблица полётов, шлюз к источнику, точки обновления) написано на Rust в sched.rs, а сервисы на Python только подключают его.

Внутри воркера

SingleFlight ключует выполняемую работу по ключу кэша — отдельно для разобранных страниц, отрендеренных тел, отрендеренных постов, фрагментов /v1/more и превью. Пока у t.me запрашивается страница, все остальные запросы к ней ждут эту одну задачу вместо того, чтобы отправлять свои, — всплеск из 1000 запросов к некэшированному каналу порождает один запрос к источнику. Отключившийся клиент не отменяет общую задачу. Поскольку REST и GraphQL работают через одни и те же сервисы, GET /v1/body/durov и GraphQL-запрос body(channel: "durov") тоже делят загрузку и рендеринг; /v1/post/durov/531 и /v1/body/durov?position=531 делят страницу вокруг поста 531; лента и запрос одного канала делят последнюю страницу канала; одинаковые запросы ленты собираются, ранжируются и рендерятся один раз.

Перед сравнением ключи нормализуются: имена каналов не зависят от регистра (Durov и durov — один ключ, в лентах тоже), а отсутствующая позиция — это позиция 0. Страницы вокруг разных позиций не считаются взаимозаменяемыми, даже если содержат один и тот же пост: каждая загрузка страницы несёт свои токены view для каждого поста (а t.me немного по-разному рендерит footer и пересылки вокруг якоря), так что выдача одной вместо другой изменила бы ответы.

Между воркерами и серверами

У каждого воркера granian на каждом сервере свои single flight, так что сами по себе они загружают истёкший горячий ключ по разу на воркер и сервер — 6 раз при двух серверах по три воркера. С Redis воркер, который ведёт загрузку локально, сначала берёт аренду (lease) tme:v2:lease:<key> за одно обращение, которое заодно читает закэшированное значение (Lua-скрипт: SET NX PX плюс GET):

  • закэшированное значение уже свежее (его только что записал другой процесс): оно используется, копия остаётся в кэше внутри процесса, без загрузки;
  • аренда получена: загрузить, записать в кэш, освободить аренду;
  • аренду держит другой процесс: опрашивать с рандомизированной нарастающей паузой (от 25 мс до 250 мс), пытаясь взять аренду в каждом раунде, пока результат держателя не окажется в кэше. Держатель, упавший с доменной ошибкой (не найдено, разбор, источник), оставляет эту ошибку в аренде на 2 с, так что ожидающие — и запросы, пришедшие тем временем, — завершаются так же, а не повторяют все запрос к t.me.

Аренда живёт 3 с, и держатель продлевает её каждую секунду, пока идёт загрузка, так что держателя, который упал или потерял Redis, заменяют в течение 3 с, сколько бы t.me ни отвечал живому. Никто не ждёт дольше 2 × TELEGRAM_TIMEOUT + 3 секунд в сумме и никто не ждёт Redis дольше REDIS_TIMEOUT на команду: когда Redis медленный, недоступен или его circuit breaker разомкнут, воркер просто загружает сам. Локальный single flight при этом продолжает работать, так что сбой стоит одной загрузки на воркер (как до появления аренды), а не лавины запросов. SHARED_COALESCING=false отключает аренду. Без Redis координироваться не через что, и каждый воркер объединяет запросы сам.

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

Раннее обновление

Каждая запись получает точку обновления в последних EARLY_REFRESH_WINDOW (10 %) своей свежести, вычисленную от времени сохранения, так что все читатели во всех процессах сходятся в ней, а записи, сохранённые вместе (такт рефрешера), обновляются вразброс. Первый запрос, который читает страницу, отрендеренное тело, отрендеренный пост или запись /v1/more после этой точки, по-прежнему получает ответ из кэша и запускает фоновое обновление; следующие чтения той же записи в этом процессе больше не просят его, а само обновление объединяется, как любая загрузка. Поэтому горячий ключ загружается примерно раз за TTL и никогда не истекает под нагрузкой — ни лавины промахов, ни ожидания t.me. Вероятностная схема вроде XFetch была опробована и отвергнута: при сотнях чтений в секунду она обновляет примерно за ln(reads per window) окон до истечения, удваивая трафик к источнику как раз для самых горячих ключей. Длинные TTL (превью, stale-окно ленты) также сокращаются на случайную часть окна, чтобы записи, сделанные вместе, не истекали вместе.

Защита источника

  • Честное ограничение конкурентности. Каждый воркер отправляет к t.me не больше TELEGRAM_MAX_CONCURRENCY (32) запросов одновременно. Свободные слоты раздаются по кругу между потоками — каналом или целым запросом ленты или превью, — а не в порядке поступления, так что лента из 100 каналов не заставляет запрос одного канала ждать за ней. Один поток занимает не больше трёх четвертей слотов. Фоновые обновления занимают не больше половины и получают слот, только пока не ждёт ни один запрос пользователя; пользователь, присоединившийся к обновлению, которое ещё ждёт слот, повышает его до пользовательского приоритета.
  • Ограниченные очереди, сброс нагрузки. Слот ждут не больше UPSTREAM_QUEUE_LIMIT запросов (по умолчанию 16 на слот), а работу с источником любого рода — не больше MAX_WAITING_REQUESTS (4096) запросов на воркер. Сверх этого запрос получает 503 с Retry-After (оценённым по длине очереди и тому, как долго удерживается слот) вместо бесконечной очереди; запрос ленты или превью пропускает сброшенные каналы. Фоновая работа сверх лимитов молча отбрасывается.
  • Неизвестные каналы кэшируются как null на 60 с.
  • Кэширование на edge Cloudflare включается через Cache-Control (см. Обратный прокси).