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

ADR-0001 · Хранилище материализованных лент#

Статус:    принят · 2026-09-16
Метод:     D-01 v1.1 · рыночный снимок 2026-09-16 · шаблон ADR v1.2
Участники: команды «лента» и «платформа»

Учебная запись: часть прогона E-02, система вымышлена. Форма и рассуждение настоящие.

Предварительно: решение, у которого нет документа#

Прежде чем выбирать хранилище, надо решить материализовать ленты или нет — собирать при записи или при чтении. Это центральное решение кейса, и в наборе нет документа, который его покрывает:

Документ Почему не он
D-01 Отвечает «где лежит», а не «считать заранее или на лету»
D-02 Границы; материализация границ не меняет
D-03 Ближе всего, но материализованная лента — не кэш: её потеря не промах, а необходимость пересборки, и у неё свои требования к долговечности
D-04 Как разговаривают, а не что считают заранее

Записано как находка 1 прогона. Решение принято вне метода — по CASE-02: из соотношения 100 чтений на 1 запись следует переносить работу на запись, из хвоста подписок следует гибрид (обычные авторы раскладываются заранее, аккаунты с огромной аудиторией подмешиваются при чтении). Ниже выбирается хранилище при условии, что ленты материализуются.

Контекст#

Вход — 00-context.md. Ответы на семь вопросов D-01 v1.1:

Основная нагрузка на это хранилище — четыре запроса:

Запрос Ключ Пик Цель p99
Q1 Страница ленты, курсорная user_id + курсор 12 000/с 90 мс
Q2 Вставить идентификатор поста в ленту подписчика, с обрезкой по длине user_id 17 400/с —
Q3 Собрать ленту заново для пользователя user_id редко секунды
Q4 Удалить пост из всех лент — не делаем —

Q4 отсутствует намеренно: И2 закрывается фильтрацией при чтении по признаку в хранилище постов, а не вычисткой миллионов лент.

Соотношение и профиль — и здесь первая неожиданность. На уровне системы чтений в сто раз больше записей. Но для этого хранилища профиль пишущий: в пике 12 000 чтений против 17 400 вставок [расчёт]. Материализация переносит работу на запись — это её смысл, — и хранилище лент видит ровно перевёрнутую картину. Читать системное соотношение как соотношение для каждого хранилища — ошибка, и D-01 нигде не предупреждает о ней.

Объём — и он оказался параметром, а не данностью. Длина материализованной ленты задаёт расход памяти линейно:

Длина ленты В памяти С накладными
1 000 идентификаторов 160 ГБ 320 ГБ
200 32 ГБ 64 ГБ
40 (одна страница) 6 ГБ 13 ГБ

Выбор длины определяется тем, как глубоко пользователи листают. Этой цифры в начале не было — в разделе 8 контекста она значилась как «есть в метриках, не выгружали». Правило мастер-шаблона v4.0 сработало: ось «объём и стоимость владения» здесь различает кандидатов, значит [нет данных] в ней блокирует решение. Метрику выгрузили — час работы:

92 % открытий не уходят дальше второй страницы (40 идентификаторов); 99 % — дальше пятой (100) [замер, выгрузка 2026-09-16].

Отсюда длина 200 идентификаторов: покрывает 99 % открытий с запасом, глубже — досбор из хранилища постов, что при одном проценте обращений приемлемо. Расход памяти — 64 ГБ, а не 320.

Что обязано быть атомарным. Ничего. Вставка в один список атомарна сама по себе; согласованность между лентами не требуется ни одним инвариантом.

Допустимая свежесть. Отставание до 10 секунд [продукт].

Эксплуатация. Платформенная команда, выделенное дежурство. В эксплуатации уже PostgreSQL, Redis, Kafka, Kubernetes [орг].

Ограничения поставщика. Управляемый сервис нашего провайдера; лицензия Redis нас не связывает, поскольку мы ничего не поставляем — но снимок рынка нужен, см. ниже.

Дисквалификаторы#

Ни один не сработал — и это главный контраст с E-01, где сработали три, все организационные.

Дисквалификатор D-01 Здесь
Нет управляемой версии, эксплуатировать некому Платформенная команда есть, дежурство выделено
Лицензия несовместима с моделью поставки Мы ничего не поставляем; управляемый сервис
Не даёт нужной транзакционной границы Атомарность не требуется вовсе
Жёсткое ограничение платформы конфликтует с моделью Записи мелкие, лимиты не задеты
Не проходит по юрисдикции хранения Идентификаторы постов — не персональные данные
Команда не умеет и не планирует учиться Платформенная команда учится по профилю работы

Отсюда следствие для метода: в E-01 дисквалификаторы сделали всю работу и матрица не понадобилась. Здесь не сработал ни один, и решение целиком легло на сравнение. Оба исхода документ предусматривает — но лишь теперь видно, что это действительно два разных режима его работы, а не отклонение.

Рассмотренные варианты#

Нулевой вариант — расширить имеющееся: Redis уже в эксплуатации.

Ось Управляемый Redis (нулевой) Управляемый Valkey Cassandra Таблица в PostgreSQL
Форма данных Упорядоченный набор идентификаторов с обрезкой по длине — родная структура [док] То же [док] Wide-column: строка на пользователя, колонки по времени — тоже родная [док] Таблица (user_id, post_id, ts) с индексом
Паттерны доступа Q1–Q3 выражаются; обрезка одной операцией [док] То же Q1–Q3 выражаются; обрезка — отдельная задача Выражаются; Q2 при 17 400/с даст нагрузку на индекс
Профиль нагрузки 17 400 вставок/с на кластере из нескольких узлов — в пределах [сторонний] То же Профиль записи — то, ради чего Cassandra и создана [сторонний] 17 400 вставок/с в основную БД — конкурирует с постами [замер]
Объём и рост 64 ГБ в памяти, линейно от длины ленты [расчёт] То же 64 ГБ на диске, память не нужна Плюс 64 ГБ к 0,7 ТБ основной БД
Транзакционные границы Не требуются Не требуются Не требуются Есть, но не нужны
Требуемая согласованность Итоговая достаточна То же То же Строгая — избыточна
Гибкость запросов Только по ключу — достаточно То же Только по ключу раздела Произвольный SQL — не нужен
Операционная стоимость Уже в эксплуатации, дежурство настроено [орг] Управляемая версия есть, опыта нет [орг] Нет в эксплуатации; платформенная команда способна освоить, но это квартал [орг] Уже в эксплуатации, но добавляет нагрузку туда, где узкое место [замер]
Стоимость владения на единицу нагрузки 64 ГБ памяти ≈ на порядок дороже того же объёма на SSD [тариф] То же 64 ГБ на SSD — дешевле, но плюс узлы и квартал на освоение [тариф] Прирост к имеющемуся кластеру [тариф]

Пометка [тариф] — местный словарь этого решения: цена из прейскуранта провайдера, запрошенная в день принятия. Отношение памяти к диску (порядок величины) устойчиво у всех крупных провайдеров, абсолютные цифры — нет.

Решение#

Управляемый Redis, длина материализованной ленты — 200 идентификаторов.

Обоснование#

Дисквалификаторов не было, поэтому решали оси. Перевесили две.

Операционная стоимость. Redis уже в эксплуатации: дежурство, мониторинг, обновления и опыт существуют. Cassandra выигрывает по стоимости владения — диск против памяти, — но выигрыш в 64 ГБ не окупает квартал на освоение и второй операционный контур. Здесь сработала эвристика D-01 «при ничьей — то, что уже есть в инфраструктуре»; строго говоря ничьей не было, но разрыв по стоимости оказался меньше, чем цена нового контура.

Объём перестал быть аргументом после выгрузки метрики. При 320 ГБ Cassandra выглядела бы иначе — разница в цене памяти и диска на таком объёме сопоставима с зарплатой инженера. При 64 ГБ она уходит в шум. Решение изменила не архитектура, а один час работы с метриками, и это стоит записать: длина ленты — параметр, определяющий стоимость линейно, и он подбирается по данным, а не выбирается круглым числом.

Valkey отвергнут не по существу, а по отсутствию причины: он эквивалентен Redis по всем осям, а мы уже эксплуатируем Redis. Переход имел бы смысл при смене лицензионных условий — записано в реестр с условием пересмотра.

PostgreSQL отвергнут по единственной оси, зато решающей: 17 400 вставок в секунду пошли бы в базу, которая уже является узким местом чтения постов.

Самая дорогая будущая граница#

Между хранилищем лент и хранилищем постов. Лента ссылается на посты идентификаторами, и это сознательно: изменение или удаление поста не требует правки миллионов лент (И2 закрывается фильтрацией при чтении). Но обратная сторона — каждое открытие ленты требует досбора постов, то есть эти два хранилища связаны на каждом чтении. Если когда-нибудь ленту захотят отдавать без обращения к постам, придётся денормализовать — и вот тогда удаление поста станет дорогим. Это та линия, по которой изменение обойдётся дороже всего.

Последствия#

  • Ленты — производные данные, и их потеря допустима, но пересборка обязана существовать как рабочий механизм, а не как идея. 10 млн лент по 200 идентификаторов восстанавливаются из постов и подписок; время пересборки надо замерить до того, как оно понадобится.
  • Профиль этого хранилища пишущий, значит настройки, мониторинг и алерты строятся вокруг записи, а не чтения — противоположно интуиции, которую даёт системное соотношение 100:1.
  • Один процент открытий уходит глубже 200 идентификаторов и требует досбора из постов. Этот путь должен работать и быть измеряемым, иначе он деградирует незаметно.
  • Обрезка списка при каждой вставке — не оптимизация, а условие удержания объёма. Отсутствие обрезки превращает 64 ГБ в неограниченный рост.
  • Память дороже диска на порядок: длина ленты становится статьёй бюджета, и её изменение надо согласовывать как изменение стоимости, а не как настройку.

Условия пересмотра#

  • Доля открытий глубже второй страницы выросла — длина ленты подбиралась под 92 %/99 %.
  • Число активных пользователей выросло на порядок: 64 ГБ превратятся в 640 ГБ, и разница между памятью и диском снова станет решающей.
  • Изменились лицензионные условия Redis у нашего провайдера — Valkey лежит в реестре как готовая замена.
  • Появилась потребность в запросах к ленте не по ключу пользователя.
  • Пересборка лент перестала укладываться в приемлемое время.

Связанные ADR#

Зависимые: ADR-0002 (границы), ADR-0003 (раздача поста), ADR-0004 (кэш и чтение). Отвергнутые варианты и условия пересмотра — в constraints.md.