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.