Почему мы перестали собирать персональную ленту SQL-запросами
- воскресенье, 23 августа 2026 г. в 00:00:04
Представьте пользователя, который подписан на несколько сотен тегов. Каждый раз, когда он открывает приложение, персональная лента должна собраться за десятки миллисекунд. На первый взгляд задача решается обычным SQL-запросом с несколькими JOIN. Мы тоже начали именно с этого подхода.
Но когда количество пользователей, подписок и публикаций выросло до миллионов, даже хорошо оптимизированные запросы перестали укладываться в требования по скорости. Тогда мы отказались от сборки ленты «на лету» и начали хранить её в готовом виде.
Привет, Хабр! Меня зовут Антон Анисимов, я backend-разработчик в Спортсе”. Мы делаем спортивное медиа с новостями, редакционным и пользовательским контентом. В разработке в основном используем микросервисы на Go.
В этой статье расскажу, почему мы отказались от SQL для формирования персональной ленты, как организовали её хранение в MongoDB и какие компромиссы пришлось принять.
Для пользователей мы предлагаем разные варианты лент контента:
редакционная лента – составляется вручную редакторами сайта, никакой дополнительной механики, показываем ровно то, что выбрал редактор;
рекомендательная лента – составляется ML-сервисом, который анализирует историю просмотров, лайки/дизлайки, понравившиеся темы. Со стороны бэкенда логика простая – отображаем то, что выдал ML-сервис;
персональная лента – составляется на основе подписок пользователя. Формируется бэкендом с учётом подписок пользователя.
Именно о персональной ленте дальше и пойдёт речь. Выглядит она примерно так:

Механика:
пользователь подписывается на теги — метки для объединения контента по определённой теме: команде, турниру или игроку;
редакторы публикуют новости и статьи, указывают теги;
пользователь открывает свою ленту и видит контент по интересующим его тегам;
пользователь видит контент в хронологическом порядке.
На первый взгляд задача выглядит простой. Но есть одна проблема: пользователь может быть подписан на сотни тегов, а на популярные теги подписаны тысячи пользователей. С похожей проблемой сталкиваются разработчики многих популярных соцсетей, и каждый решает её по-своему.
На Хабре это очень похоже на раздел «Моя лента».
Данные у нас хранятся в реляционной базе. Основные таблицы:
теги
пользователи
новости
И таблицы связей:
подписки пользователей на теги
теги новостей

На самом деле, пользователь также может подписываться на теги, блоги и авторов. В ленте могут быть как новости, так и статьи. Для простоты пока рассмотрим только новости по тегам.
В простом случае задачу можно решить SQL-запросом с JOIN. Мы сначала попробовали именно этот вариант. Пока данных было немного, такой подход работал нормально. Но по мере роста количества пользователей, подписок и публикаций даже индексированные запросы стали работать медленно.
Чтобы было понятнее, почему обычный JOIN перестал нас устраивать, вот порядок наших данных:
новостей – 2M+
тегов – 200K+
пользователей – 5M+
связей тегов и новостей – 5M+
связей тегов и пользователей – 16M+
При таком объёме данных даже индексы не решают проблему полностью. При этом чтение сильно преобладает над записью. Есть пользователи с сотнями и даже тысячами подписок. Собирать такую ленту на лету будет сложно. В результате запрос получается тяжёлым: большое количество JOIN, выборок и сортировка.
Стало понятно, что дальнейшая оптимизация самого SQL-запроса уже не решит проблему. Мы решили заранее собирать пользовательские ленты и хранить их в готовом виде.
Следующий вопрос оказался не менее важным: где хранить готовые ленты?
Мы стараемся не расширять стек технологий без необходимости. Каждая новая база данных – это дополнительная инфраструктура, мониторинг, резервное копирование и экспертиза внутри команды.
Поэтому сначала рассматривали решения на тех технологиях, которые уже используем.
Redis мы не рассматривали как основное хранилище. Для нашей задачи было важно не только быстро читать готовые ленты, но и массово обновлять их по событиям, выполняя выборки по тегам и пакетные изменения. MySQL тоже подошёл бы, но модель данных получалась менее естественной. Пользовательская лента – это по сути один документ с массивом подписок и массивом идентификаторов контента.
Поэтому мы остановились на MongoDB. Она уже использовалась в нашей инфраструктуре.
{ _id: 123, tags: [12, 35, …], docs: [ { id: 75 }, { id: 64 } ] }
Такая модель хорошо подходит под наш сценарий: одна пользовательская лента хранится в одном документе.
Ленты запрашиваются по авторизованному пользователю, поэтому каждая лента хранится в одном документе.
У нас микросервисная архитектура. Есть сервис, который отдаёт содержимое новостей по id, поэтому в лентах мы храним только идентификаторы – это экономит место.
Что мы получили:
получение ленты теперь сводится к чтению одного документа MongoDB;
нагрузка перенеслась с чтения на обработку событий;
пользователь получает ленту практически мгновенно;
цена такого решения – более сложное обновление данных и eventual consistency.

Такое ускорение не бывает бесплатным: основная сложность теперь переносится с чтения на обновление готовых лент.
Чтобы упростить задачу, мы ввели несколько ограничений:
Во-первых, пользователи редко долистывают ленту до конца. Обычно смотрят только первые N новостей, поэтому мы храним только последние записи. Лимит фиксированный, но при необходимости его можно увеличить. Мы собираем метрику по глубине просмотра ленты, которую используем для дальнейшего развития продукта.
Во-вторых, мы не храним ленты для всех пользователей. Только для тех, кто недавно заходил или может зайти.
Для этого сделали отдельный API: клиент вызывает его во время авторизации пользователя. Если готовой ленты нет, сервис начинает её сборку. Сборка выполняется в отдельной горутине, таким образом API только инициирует сборку и сразу возвращает ответ клиенту. Также у нас есть очистка старых лент, если пользователь долго не открывает ленту.

После того как чтение стало максимально простым, основная сложность переместилась в обновление данных.

В момент обновления контентные сервисы отправляют события в очередь (RabbitMQ), а сервис лент читает их и обновляет данные.
События, которые влияют на ленту:
создание новости
отложенная публикация новости
редактирование новости (изменение тегов)
удаление новости
подписка или отписка пользователя от тегов
При изменении новости сначала находим все ленты, где она есть, и удаляем её.
db.feed.updateMany( { docs: { $elemMatch: { id: 75 } } }, { $pull: { docs: { id: 75 } } } )
Затем определяем, в какие ленты она должна попасть. Для этого используем сохранённые теги пользователей.
db.feed.find({ tags: { $in: [23, 35] } })
Каждую ленту обновляем отдельно: добавляем новость и обрезаем список до лимита. Обновления выполняем пакетами через bulkWrite.
db.feed.bulkWrite([ { updateOne: { filter: { _id: 123 }, update: { $set: { docs: [ { "id": 75 }, { "id": 64 } ] } } } } ])
Мы выбрали стратегию «удалить и добавить заново». Определять точечные изменения в ленте оказалось слишком сложно из-за множества пересечений, а сами изменения новостей происходят нечасто. Из недостатков такого подхода – обновление одной популярной новости может привести к обновлению десятков или даже сотен тысяч пользовательских лент.
Обновление публикаций – не единственный сценарий. Лента меняется и тогда, когда пользователь пересматривает свои подписки.
Если пользователь подписывается или отписывается от тегов, мы полностью пересобираем его ленту.
Причина та же: сложно корректно определить все изменения. Например, новость может остаться в ленте из-за другого тега, или после удаления новостей ленту нужно дозаполнить.
К счастью, такие события происходят редко.
Получение ленты сводится к чтению одного документа MongoDB и возврату нужного диапазона docs.
Ещё одна задача – не хранить ленты пользователей, которые давно перестали пользоваться сервисом.
Чтобы удалять неиспользуемые ленты, добавили expire-индекс.
В документе хранится дата последнего просмотра:
{ _id: 123, tags: [12, 35], docs: [...], visitedAt: "2026-03-05 00:00:00" }
Индекс:
db.feed.createIndex( { visitedAt: 1 }, { expireAfterSeconds: 30 * 24 * 60 * 60 } )
Если ленту не открывали 30 дней, MongoDB удаляет её автоматически.
До этого момента мы рассматривали только новости. Но со временем персональная лента стала включать и пользовательский контент.
Помимо новостей появились блоги и посты. Пользователь может подписываться:
на теги (те же самые, что и в новостях)
на блоги (посты могут быть в блоге и без блога)
на авторов (может быть несколько)
Пример документа:
{ _id: 123, tags: [12, 35], blogs: [34, 48], authors: [234, 456], docs: [ { id: 75, type: "news", publishedAt: "2026-02-05" }, { id: 88, type: "post", publishedAt: "2026-02-01" } ] }
При сборке ленты мы берём новости и посты по блогам, тегам и авторам, объединяем их, сортируем по дате и сохраняем последние N документов.
События для постов обрабатываются аналогично:
создание поста
редактирование поста
удаление поста
подписка/отписка от автора
подписка/отписка от блога
Единственная разница – выборка идёт не только по тегам, но и по авторам и блогам.
После запуска такой схемы в проде стало понятно, что основным узким местом становится обработка событий. Одна публикация могла запускать обновление десятков или сотен тысяч пользовательских лент.
Мы используем несколько приёмов:
обрабатываем большие списки подписок батчами
обновляем ленты параллельно небольшими группами
используем мьютексы, чтобы избежать одновременного обновления одного документа
В итоге архитектура получилась со своими плюсами и ограничениями.
Если посмотреть на архитектуру целиком, то мы фактически обменяли дорогие операции чтения на более сложные обновления. Для нашей нагрузки этот компромисс оказался оправданным: чтений значительно больше, чем изменений, поэтому мы предпочли усложнить обновление данных, чтобы максимально упростить и ускорить чтение.
Но универсального решения здесь нет – архитектура сильно зависит от характера нагрузки, требований к актуальности данных и количества подписок.
А какой подход к построению персональных лент используете вы? Особенно интересно, как вы решаете проблему популярных тегов и большого количества подписчиков.