Твиттер на основе MemcacheDB и PHP
В статье Key=value (ключ=значение) базы данных мы рассмотрели системы сохранения/чтения данных по ключу и подходы для реализации простых задач. Сейчас рассмотрим все детали и принципы на живом примере простого клона твиттера на основе решения memcacheDB.
Установка
Для установки предварительно необходимо установить libevent и berkleyDB. Пакет libevent практически всегда присутствует в пакетных репозитариях, поэтому ставить его вручную врядли придется. Если Вы обладатель Ubuntu, все выглядит так:
sudo apt-get install libevent1 libevent-dev
Далее устанавливаем berkleyDB. Качаем исходники по этой ссылке, и:
tar -xvf db-4.7.25.tar.gz
cd db-4.7.25/build_unix
../dist/configure
make
sudo make install
Теперь устанавливаем сам memcachedb. Качаем исходники тут, и делаем так:
tar -xvf memcachedb-1.2.0.tar.gz
cd memcachedb-1.2.0
./configure
make
sudo make install
Запускаем сервер:
memcachedb -d -u root -f twitter.db -N
По умолчанию сервер слушает порт 21201. После подключения телнетом можно набрать команду stats (поддерживается протоколом memcached), и Вы должны увидеть подобную картину:
telnet localhost 21201
Trying 127.0.0.1…
Connected to localhost.
Escape character is ‘^]’.
stats
STAT pid 11964
STAT uptime 5942
STAT time 1240929323
STAT version 1.2.0
STAT pointer_size 32
STAT rusage_user 0.052003
STAT rusage_system 0.100006
STAT ibuffer_size 512
STAT curr_connections 2
STAT total_connections 4
STAT connection_structures 3
STAT cmd_get 0
STAT cmd_set 0
STAT get_hits 0
STAT get_misses 0
STAT bytes_read 23
STAT bytes_written 395
STAT threads 1
END
Подключение на PHP
Поскольку memcacheDB поодерживает протокол memcached, то для работы нам подойдет стандартное расширение pecl mamcache:
$m = new Memcache();
$m->addServer(’localhost’, ‘21201′);
$m->set(’test’, true);
var_dump($m->get(’test’));
После выполнения этого скрипта, вы увидите строку: string(1) “1″. Теперь, когда предварительные настройки готовы, перейдем к реализации.
Регистрация и авторизация
Некоторые общие принципы:
- Мы будем хранить все данные в одной таблице, поэтому нам понадобится использовать подход пространства имен на уровне приложения. Суть его будет заключаться в том, что название ключей будет иметь формат “<пространство>:<ключ>” (например, auth:34 или auth:login.vasya)
- Иногда необходимо будет делать выборку по уникальным колонкам, не только по основному ключу. Для решения подобной задачи необходимо просто сохранить пару “<другой ключ> = <основной ключ>” (например, user_login=user_id)
Для регистрации воспользуемся следующей логикой: таблица авторизации будет состоять из трех колонок (user_id, login, password), в нашей БД данные будут храниться в виде: user_id=login,password. Логин и пароль будем хранить в виде ассоциативного массива. Поле user_id уникально и должно увеличиваться на 1 при каждой новой записи, поэтому организуем счетчик в отдельном ключе базы данных. При регистрации и авторизации мы будем делать выборку по логину, поэтому будем сохранять еще одну пару: логин=user_id. Собственно реализация:
$mdb = new Memcache();
$mdb->addServer('localhost', '21201');
function signup( $login, $password )
{
global $mdb;
// Проверяем не занят ли логин
if ( $mdb->get('auth:login.' . $login) )
{
throw new Exception('Login already in use');
}
// Получаем новое значение счетчика user_id
$user_id = 1 + (int)$mdb->get('auth:user_id:autoincrement');
// Сохраняем данные нового пользователя
$mdb->set('auth:' . $user_id, array(
'login' => $login,
'password' => md5($password))
);
// Сохраняем user_id по ключу логина, что-бы можно было делать по нему выборку
$mdb->set('auth:login.' . $login, $user_id);
// Записываем новое значение счетчика user_id
$mdb->set('auth:user_id:autoincrement', $user_id);
return $user_id;
}
$new_user_id = signup('vasya', '12345');
echo $new_user_id;
Первый запуск этого кода вернет user_id зарегистрированного пользоватея (должно быть “1″), второй раз вызовет исключение, т.к. логин будет уже занят. Авторизация будет выглядеть следующим образом:
function signin( $login, $password )
{
global $mdb;
// Пытаемся получить данные по ключу логина
if ( !$user_id = $mdb->get('auth:login.' . $login) )
{
throw new Exception('User not found');
}
// Получаем данные по ключу
$user_data = $mdb->get('auth:' . $user_id);
if ( $user_data['password'] != md5($password) )
{
throw new Exception('Authentication failed, try more accurately');
}
// Наверняка нужно вот это
// Это установка в сессию ID пользователя
// Релазиция - вольная, например:
// session::set('user_id', $user_id);
return true;
}
if ( signin('vasya', 12345) )
{
echo 'Signed in successfully!';
}
Фоловеры и фоловинг (followers & following)
Фоловеры - это, в терминах твиттера, те люди, сообщения которых я вижу в своей ленте. Фоловинг - список людей, которые видят мои сообщения в своей ленте. Нам понадобится для этих двух сущностей два пространства (followers и following). Для кажлого пользователя будет по одной записи в обоих пространства. Поскольку нам придется хранить списки, то за основу возьмем массивы PHP. Опишем функцию подключения к новому пользователю (в терминах твиттера: мы становимся его фоловером, а он попадает в наш фоловинг список):
function add_following( $owner_id, $user_id )
{
global $mdb;
// Получаем список фолловинга, заносим туда нового пользователя и сохраняем
$following = $mdb->get('following:' . $owner_id) or $following = array();
$following[] = $user_id;
$mdb->set('following:' . $owner_id, $following);
// Добавляем себя в списко фоловеров юзера
$followers = $mdb->get('followers:' . $user_id) or $followers = array();
$followers[] = $owner_id;
$mdb->set('followers:' . $user_id, $followers);
}
function get_following( $owner_id )
{
global $mdb;
return (array)$mdb->get('following:' . $owner_id);
}
function get_followers( $owner_id )
{
global $mdb;
return (array)$mdb->get('followers:' . $owner_id);
}
Что-бы проверить на работоспособность наше приложение, давайте выполним следующий код:
add_following(1, 2);
add_following(1, 3);
add_following(2, 3);print_r(get_following(1));
print_r(get_followers(3));
Он должен вернуть то, что показано ниже. В коде мы добавили в ленту пользователю 1 пользователя 2 и 3, а пользователю 2 добавили пользователя 3. Соответсвенно у пользователя 1 в ленте “фоловинг” будет 2 и 3, а пользователя 3 в ленте “фоловеров” будет 1 и 2:
Array
(
[0] => 2
[1] => 3
)
Array
(
[0] => 1
[1] => 2
)
Лента сообщений (самое интересное)
Что представляет из себя лента сообщений? Это хронологический список сообщений всех людей из моего списка “фоловинг” (те, за кем я “слежу”). Решение, когда мы для каждого пользователя храним список его сообщений, а потом делаем тяжелый SQL запрос типа SELECT…WHERE user_id IN(…) - не масштабируется (горизонтально), к тому же не эффективно. Для разработки масштабируемого решения, нужно решить одну задачу - изолировать записи и чтения для одного человека на один автономный узел. Тогда, увеличивая количество узлов, мы будем увеличивать нагрузочную способность приложения. Как же изолировать? Принцип таков: если кто-то пишет сообщение, то его нужно записать каждому человеку, который “следит” за пишущим. Большое количество записей, неэффективное использование жесткого диска скажете Вы? Масштабируемые решения в некоторых случаях (я бы сказал в ощутимом количестве случаев) менее эффекивны на локальном уровне - это цена масштабируемости, хотя она во всех случаях на порядки ниже той, которую придется платить за суперсервер с 32 процессорами…
И так, реализация. Сообщения пользователей мы будем хранить по ключу message_id (оно будем autoincrement) с такими полями: author_id, message, created_time. Для каждого пользователя будем хранить ленту сообщений (назовем это пространство “feed”) в виде списка message_id, работающего по принципу стека (ограничимся 50 сообщениями, самые старые будем удалять, что-бы не хранить огромную историю):
function add_message( $author_id, $message )
{
global $mdb;
// Получаем новое значение счетчика message_id
$message_id = 1 + (int)$mdb->get('messages:message_id:autoincrement');
// Сохраняем данные нового пользователя
$mdb->set('messages:' . $message_id, array(
'author_id' => $author_id,
'message' => $message,
'created_time' => time())
);
// Записываем новое значение счетчика user_id
$mdb->set('messages:message_id:autoincrement', $message_id);
// Добавляем это сообщение автору (свое сообщение он будем видеть)
$feed = $mdb->get('feed:' . $author_id) or $feed = array();
array_unshift($feed, $message_id);
$feed = array_slice($feed, 0, 50);
$mdb->set('feed:' . $author_id, $feed);
// Добавляем это сообщение всем из списка followers, а также автору
if ( $following = $mdb->get('followers:' . $author_id) )
{
foreach ( $following as $user_id )
{
$feed = $mdb->get('feed:' . $user_id) or $feed = array();
array_unshift($feed, $message_id);
$feed = array_slice($feed, 0, 50);
$mdb->set('feed:' . $user_id, $feed);
}
}
return $user_id;
}
function get_feed( $owner_id )
{
global $mdb;
return $mdb->get('feed:' . $owner_id);
}
function clear_feed( $owner_id )
{
$mdb->set('feed:' . $owner_id, array());
}
function get_message( $message_id )
{
global $mdb;
return $mdb->get('messages:' . $message_id);
}
function get_user( $user_id )
{
global $mdb;
return $mdb->get('auth:' . $user_id);
}
add_message(3, 'Hello, world! I am testing new twitter client, powered by memcacheDB');
print_r(get_feed(2));
В результате Вы должны увидеть список ID сообщений (array(0 => 1)) - если учесть, что пользователь 3 находится у пользователя 2 в списке following (мы делали это выше).
А теперь давай симулируем нескольких пользователей, которые пишут сообщения за небольшой промежуток времени, что-бы оценочно взглянуть на эффективность такого решения (естественно с погрешностью):
$users_count = 1000; // Количетсво содаваемых пользователей
$following_per_user = 10; // Количество людей в фиде для каждого пользователя
$messages_per_user = 20; // Количество сообщений у каждого пользователя
$start = microtime(true);
for ( $i = 0; $i < $users_count; $i++ )
{
$user_ids[] = signup( 'user' . $i, '12345' );
}
echo "We have created {$users_count} in " . (microtime(true) - $start) . "s\n";
$start = microtime(true);
foreach ( $user_ids as $user_id )
{
for ( $i = 0; $i < $following_per_user; $i++ )
{
add_following($user_id, $user_ids[array_rand($user_ids)]);
}
}
echo "We have organized {$following_per_user} followers per user in " . (microtime(true) - $start) . "s\n";
$start = microtime(true);
foreach ( $user_ids as $user_id )
{
for ( $i = 0; $i < $messages_per_user; $i++ )
{
add_message($user_id, 'some random message: ' . md5(microtime(true) . $i));
}
}
echo "We have generated {$messages_per_user} messages per user in " . (microtime(true) - $start) . "s\n";
На моем рабочем компьютере core 2 duo 2.4, 2 GB DDR2, SCSI HDD (с учетом других 10 запущеных приложений) цифры такие:
We have created 1000 in 0.293426036835s
We have organized 10 followers per user in 2.74228906631s
We have generated 20 messages per user in 44.6299879551s
Это значит, что пропускная способность такой системы на подобном компьютере следующая:
- 3500 регистраций пользователей за секунду, или
- добавление 3600 фоловеров за секунду, или
- добавление 450 сообщений в секунду со средним размером ленты в 10 человек
И после всего этого, у Вас есть горизонтально масштабируемое решение!
Вы можете скачать полный исходный код со всеми функциями и бенчмарком


Очень хорошая статья и вообще весь ресурс вместе. Большое вам спасибо!
Кстати очень хорошие у вас результаты получились, у меня на 2 x 2.8 quad core, 2GB следующие:
We have created 1000 in 0.658823013306ms
We have organized 10 followers per user in 3.69324588776ms
We have generated 20 messages per user in 64.5362420082ms
@gamerk
Большое спасибо!
Там кстати одна неточность есть, результаты в секундах а не милисекундах, но это и так, думаю, понятно…
Извините, но Вы наступаете на грабли. В Ваших скриптах есть очень много узких мест, которые подвержены “состоянию гонки”.
Начнем с начала.
В коде авторизации Вы проверяете наличие логина запросом get и на основании полученных данных делаете вывод о доступности логина для регистрации, после чего получаете (тоже не атомарно) следующий id и уже после этого сохраняете новый логин. А в это время другой пользователь спрашивает запросом get такой же логин и получает тоже положительный ответ. Пролетит тот пользователь, для которого позже отработает запись скрипт. Правильно - попытаться сделать add, и в случае успеха предпринимать дальнейшие действия. Тогда скрипт отработает только для одного пользователя, с гарантией.
Дальше, у как Вы делаете инкремент? Инкремент делается при помощи api функции increment атомарно. В противном случае никто опять же не гарантирует Вам уникальность ID. Причем если вероятность одновременной регистраци двух пользователей с одним логином довольно низка, то просто вероятность одновременной регистрации двух пользователей довольно высока. В итоге у обоих будет один ID.
Далее, с фолловерами тоже ерунда выходит. Пока Вы будете вытаскивать массив, добавлять туда нового фолловера и записывать массив обратно, кто-то еще может сделать то же самое - в итоге кто-то не добавится. Я бы предложил вариант посложней, но правильнее. Используйте append для сериализованных представленийб разделенных каким-либо делимитером. Тогда Вы получите атомарное добавление фолловеров (то же самое работает на прочих списках). В этом случае возникает другая проблема - как удалить элементы списка? Есть пара вариантов - либо при добавлении элемента перед ним писать “+” а при удалении “-”, либо использовать еще один ключ для отображения операций удаления/восстановления. Если заботитесь о производительности - можно полученные таким образом данные уже после атомарного добавления в “черновой” ключ полученные суммарные данные преобразовывать в готовый объект (в массив например), ну или запустить в фоне демона, который будет это делать, доставая задачи из очереди (например memcscheq) - тоже вариант. В общем, подумайте об атомарности, если речь идет о высоконагруженном проекте, то она необходима, а если нет - то нет необходимости так изгаляться с key-value.
@kaa
Спасибо большое за детальный анализ! Очень полезная информация.
Конечно статья предусматривалась, как базовый пример, но представленная Вами информация носит ценный характер в практическом применении.
Планирую написать материал об атомарности и блокировке (о которой Вы не упомянули) и посвятить этому отдельную тему.
А о блокировке не упомянул специально - потому что считаю ее не очень хорошим решением. Конечно, если без нее никак - то надо использовать блокировку. А вот например скажите, как Вы думаете, процедура установки блокировки должна сама ждать, когда разблокируется, или оставить это на программиста?
@kaa
Согласен, блокировка несколько затруднительна в реализации и использовании.
Не уверен, что понял вопрос, но:
Естественно блокировка должна устанавливаться и сниматься на уровне платформы. Программист должен иметь возможность управлять блокировкой но не описывать ее логику постоянно. Мало того, это очень специфическая система, т.к. учитывать необходимо различные ситуации - например, клиент поставил блокировку и умер (решается таймаутом блокирования и флагами деятельности/сна).
При всем этом, конечно, лучше обойтись по максимуму атомарными операциями, т.к. блокировка на порядок сложнее в реализации, причем потребует доп. ресурсов.
Ну, простой пример с использованием memcached.
Делаем простой мутекс, таким образом - прежде, чем совершить блокирующую операцию, пытаемся установить блокировку командой add (если уже установлена - отлуп). Вопрос в чем собственно - есть два варианта поведения функции mutex_set() - в цикле просить блокировку какое-то время или вернуть сразу отлупб а циклом опроса пусть заботится программист? Как бы Вы сделали (или читатели этого сайта)? Или может имеет смысл делать 2 функции - одна сразу отлуп вернет, другая попытается получить блокировку?
Кстати, по поводу времени жизни - просто в конфиге определяем константу времени жизни, и устанавливаем параметр ttl функции add в это число - тогда мутекс сам упадет через указанное время по-любому.
@kaa
Интересный вопрос.
Мне кажется циклом опроса должна заботиться платформа, а не разработчик (т.е. для реализации блокируемого метода add необходимо написать обертку со своим циклом опроса). Кроме этого, можно операцию реакции на разблокировку выносить в отдельный поток или асинхронную задачу.
Но уже сложновато получается. Как Вы верно сказали, тут лучше пользоваться встроенными атомарными операциями, либо же не извращаться.
Можно порассуждать.
Попробуем исходить из постулата, что самое простое решение и есть верное.
Рассмотрим случаи, когда нам нужно подождать разблокировки, а когда необходимо отбросить заблокированную операцию и двигаться дальше.
1. Подождать разблокировки нам надо, если у нас простое пользовательское приложение, и пользователь ждет реакции системы. В случае, если блокировка не снимется в какой-то критичный промежуток времени (например 10 секунд), пользователю все равно вернется ошибка, но это - экстремальный случай. В этом случае функция установки блокировки должна ждать.
2. Предположим, происходит какая-то автоматизированная обработка данных, требующая неатомарных операций (например, мы решили написать свой поисковик, и некий индексатор перестраивает содержимое ключа для определенного слова (словоформы). Эти данные - некий хеш, в котором ключи - это страницы, а значение - например вес слова на этих страницах. Тогда нам надо достать массив, перестроить, положить обратно. Если поисковый робот организован так, что работает несколько процессов (возможно даже, на разных машинах), то возможно состояние гонки, и надо блокировать операцию. Но может выйти так, что один и тот же ключ пытаются апдейтить несколько процессов. В этом случае вполне можно поставить задачу в очередь и не ждать освобождения блокировки, апдейтить следующее слово. Потому что ожидание в данном случае может оказаться дороже.
Таким образом, возможно, понадобятся обе функции, но скорей всего в разных задачах. Значит можно определить константу, которая задаст поведение мутекса.
Мне так кажется.
@kaa
Очень интересная идея с добавлением в список с помощью append, до этого решал это проблему с помощью блокировок.
Попробывал реализовать ваш вариант на практике, получилось плохо. Вы сами пробывали?
Пробовал. Получилось нормально. А в чем у Вас возникла проблема?
Вообще, если бы не способность nginx работать с мемкешом напрямую, я бы склонился к использованию редиса - у него сильно больше возможностей при не меньшем быстродействии. В данный момент размышляю о переходе на редис в части динамики, с использованием мемкеша только для кеширования целых блоков под nginx непосредственно