Делаем ленту обновлений на MongoDB + PHP

Вы, конечно, много раз видели ленту обновлений на фейсбуке и твиттере. В двух словах - это список событий, которые произошли недвано на сайте и касаются Вас (чаще всего это деятельность Ваших друзей). Это очень удобный инструмент информирования пользователей и неотъемлемая часть современных социальных проектов. А как обстоит дело с реализацией?
На первый взгляд все очень просто, а на практике все сложно. Давайте разберемся детально (на примере MongoDB + PHP, заодно посмотрим на эту новую шумную СУБД).
Что такое эта “лента обновлений”?

На этом скрине Вы видите ленту обновлений или “news feed” с фейсбука (facebook.com). Там представлены все события Ваших друзей.
Как будем делать? На первый взгляд все кажется довольно просто. У нас есть ограниченный набор действий (загрузить фото, обновить статус и т.п.). Для реализации подобной ленты новостей нужно выбрать все такие действия для каждого друга и отсортировать их по времени. Но сразу же становится видна недальновидность такого подхода:
- Очень тяжелые выборки в нескольких таблицах (выбрать все фотки, все статусы и т.п.)
- Требуется дополнительная сортировка и агрегирование данных на стороне системы (а не СУБД)
- Кеширование будет неоправданным, т.к. сделает ленту неактуальной
- Решение не масштабируется (не возможен шардинг)
Проведем небольшой анализ:
- Новые события в ленте могут появляться довольно динамично и сразу должны становиться доступны друзьям (загрузил фотку - друзья сразу об этом узнали). Тем не менее короткие задержки тут не критичны (в пределах минут).
- Продвинутый функционал ленты обычно включает в себя управление приватностью (кто из друзей и какие действия может видеть)
- Решение должно горизонтально масштабироваться. Учитывая то, что в больших системах количество генерируемых событий будет огромным, это самый важный момент
Архитектурное решение
Для начала уйдем от необходимости собирать все события с разных таблиц. Создадим одну таблицу типа “обновления” и будем складывать туда все события с такой информацией:
- кто совершил действие (автор)
- время действия
- доп. данные о действии (напр., список загруженных фоток)
Для генерации ленты обновлений нам нужно выбрать из этой таблицы все действия, совершенные друзьями пользователя:
SELECT * FROM updates WHERE user_id IN (1,2,3,4) ORDER BY time DESC
Тут 1,2,3,4 - это ID друзей пользователя
Уже лучше. Сортировку можно делать прямо на стороне СУБД, не нужно делать дополнительную агрегацию. Но остаются следующие проблемы:
- Таблица не шардится (ее невозможно разделить по какому-либо критерию для горизонтального масштабирования ввиду неопределенности списков друзей)
- Учитывая возможный большой размер таблицы, запросы к ней могут быть очень медленными
- Если понадобится учитывать приватность сообщений, запросы будут крайне тяжелыми
Опять не подойдет, думаем дальше:
Генерация пользовательской ленты
Необходимо устранить всякую записимость генерации ленты от количества данных и сложности функционала. Это возможно только тогда, когда мы для каждого пользователя генерируем ленту на лету. Т.е. при очередном событии делаем следующее:
- Выбераем список друзей пользователя
- Проверяем правила приватности для каждого из них
- Вставляем событие в персональную ленту тем, кому нужно
В этом случае мы избавляемся от проблемы медленной генерации ленты, т.к. ее теперь не нужно генерировать. Мы просто читаем ее из СУБД (элементарный запрос, никаких преобразований). Самое важное - пользовательская лента теперь на 100% персональная (из нее даже можно что-нибудь удалить, не затронув остальных пользователей).
Но теперь при каждом событии будет выполняться довольно много операций проверок и вставки события в нужную ленту. Это может очень замедлить работу сайта. Выход очень простой. Все эти действия очень хорошо подходят для выполения на фоне (смотрите очереди сообщений). Например:
- Пользователь загружает фотку
- Обрабатываем его запрос, сохраняем фото и отправляем пользователю ответ
- В фоне (асинхронно) выполняем обработку события и обновляем ленты его друзей
Платформа
Для управления лентами нам понадобятся простые операции работы со списками: добавить/удалить элемент, сортировка и возможно фильтрация.
Понятно, что использовать тяжелую СУБД, такую как MySQL, нецелесообразно для таких простых операций. Очень хорошо в этом случае подойдет, например, Redis или же MongoDB.
В этом примере мы рассмотрим реализацию на основе MongoDB. Для хранения ленты будем использовать внутренний массив документа. В качестве клиентской реализации используем PHPMongo.
Для выполнения задач на фоне можно использовать любую удобную систему очередей (например, RabbitMQ).
Пример
Для начала опишем главную функцию - вывод ленты обновлений (функцию get_feed() опишем позже):
<?
# Получаем ленту
# $user_id содержит идентификатор текущего пользователя
$feed = get_feed($user_id);
echo "<h3>Что делают Ваши друзья</h3>";
# Выводим все события из ленты
foreach ( $feed as $feed_item )
{
# Время, имя пользователя и текст события
echo "{$feed_item['time']} :: {$feed_item['user_name']} {$feed_item['text']}<br />";
}
Обновление ленты
Теперь посмотрим, как будет выглядет типичное действие, которое обновляет ленты друзей пользователя. Например, пользователь меняет свой статус. Обработчик будет выглядеть следующим образом:
<?
$status = $_POST['status'];
# коннектимся к Mongo (по умолчанию - localhost:27017)
$m = new Mongo();
# Обновляем статус пользователя (БД user, коллекция status)
# "'upsert' => true" - вставить запись, если ее нет
$m->user->status->update(
array('user_id' => $user_id),
array('$set' => array('status' => $status)),
array('upsert' => true)
);
# Регистрируем событие для лент (реализация дальше)
update_feed($user_id, 'обновил статус: ' . $status );
Регистрация события
В предыдущем кусочке кода функция update_feed() регистрировала событие для добавления в ленты друзей. Реализация этой функции будет следующая:
<?
function update_feed($user_id, $text )
{
# Получаем список друзей пользователя
$friends = get_friends($user_id);
$m = new Mongo();
# Вставляем событие каждому другу в ленту
foreach ( $friends as $id )
{
# Получаем имя друга
$user_name = get_user_name($id);
# $data = array(
'time' => date('H:i (m.d)'),
'user_name' => $user_name,
'text' => $text
);
# Вставляем событие в массив Mongo-объекта "list"
# БД user, коллекция feed
$m->user->feed->update(
array('user_id' => $user_id),
array('$push' => array('list' => $data)),
array('upsert' => true)
);
}
}
Чтение ленты
Осталось описать функцию чтения ленты для пользователя:
<?
function get_feed($user_id)
{
$m = new Mongo();
# Выбираем события из БД
$data = $m->user->feed->findOne( array('user_id' => $user_id) );
return $data['list'];
}
Некоторые замечания
Нужно следить, чтобы количество элементов в массиве не росло очень сильно (например, сотни тысяч), иначе объекты будут очень тяжелыми. Массивы необходимо периодически обрезать (пока в MongoDB нет удобного механизма это делать - надеюсь, появится).
Для реализации приватности необходимо в функцию update_feed() добавить функционал по проверке прав доступа для каждого пользователя.
В MongoDB пока довольно ограниченный набор функционала с вложенными массивами, поэтому фильтрацию ленты (если понадобится) придется делать на стороне PHP. Будем следить за развитием MongoDB.
Не забудьте поставить индексы на колонки по которым делаете выборки в Mongo (как это делать?).


Гарно написано, як тут використувується RabbitMQ ?
@Джек
Что именно Вас интересует в плане использования очередей?
Хорошая статья. Меня тоже заинтересовал RabbitDB, можно ли с мопошью него сделать систему распределенной обработки задач. Например есть несколько серверов для непосредственной обработки, которые все настроены на один и тот же источник сообщений, в jms это называется “topic” еще не знаю как это определяется в Reddit. В принципе и сам вижу, что такое возможно реализовать, но можно ли гарантировать равномерное распределение нагрузки на каждый из “рабочих” серверов.
P.S. Каптча в форме коментов - просто окорбление, при неправильном вводе весь введенный текст улетает в ничто, приходится перенабирать
@Andrey Kopachevsky
Андрей, распределение нагрузки в RabbitMQ есть, детали лучше уточнять тут: http://www.rabbitmq.com/faq.html#scale-balance
Да, уже натнулся на эту ссылку, по моему там говорится о кластеризации самой системы передачи сообщений, в любом случае Rabbit уже нравится, более легкая штука чем JMS
Den, а что исходя из ваших тестов (вы ведь и с тем и с тем уже работали) производительней — Redis или MongoDB?
@Андрей
Конечно Redis быстрее, но ведь это не прямой конкурент MongoDB, т.к. они на порядок отличаются по уровню функционала.
А почему бы не реализовать на каждого пользователя по очереди в RabbitMQ,а брокер бы сам доставлял нужную запись во френдленту каждого юзера?
@<fb:name linked=”false” useyou=”false” uid=”1130004039″>Frenky Andrey</fb:name>
? В статье рассматривается только стратегия хранения данных и доступа к ним. Система очередей нужна только для асинхронизации процесса наполнения лент. Ее можно и не использовать.
Уточните, “почему бы не сделать так” по сравнению с чем
@Den Golotyuk
Я имею ввиду хранить все френдленты не в монго ДБ а в RabbitMQ
Это не удачная идея. Во-первых механизмы сокрытия, фильтрации и сортировки будет практически невозможно реализовать. Во вторых сам принцип функционирования систем управления очередями не подходит под принцип работы ленты, что уже заставит Вас вставлять костыли на каждом углу.
знайшов непоганий бенчмарк щодо редіс:
http://www.ezwebsitemonitoring.com/blog/php-benchmark-memcached-with-pecl-memcache-php-memcached-redis-with-predis-rediska-part-2
http://github.com/toymachine/libredis/blob/master/test.php
поки що цікавить чи реалізує libredis весь протокол