Red Hot Chili Python

Опыт использования MongoDB для подсчета статистики

«  Время, UTC, GMT и много проблем   ::   Contents   ::   Создание безопасного WYSIWYG-редактора  »

Опыт использования MongoDB для подсчета статистики

Warning

Этот пост находится в процессе написания. Прошу его не читать. Когда я закончу – пост попадёт в вашу рсс-ленту (и я наверняка напишу о нём на всякие хабры).

Позволю себе рассказать о нашем опыте внедрения БД MongoDB в самый, что ни на есть, продакшн.

Немного о себе

Говоря “мы” я имею в виду Prom.ua, Tiu.ru, Satu.kz, Deal.by и еще различные сайты в других странах.

В данный момент у нас в среднем около миллиона pageview в день (11 в секунду, днем – 20-25) на Украинском и Русском сайтах, на остальных – поменьше. MongoDB у нас уже использовалась для того, чтоб подсказывать поисковые запросы (вы начинаете набирать, а оно “подсказывает”).

MongoDB была установлена в реплика сете на двух машинах (и третяя – арбитр) и работала себе с минимальными нагрузками.

Задача и текущая архитектура

Задача (в общем ее описании) – считать количество хитов и хостов для каждого сайта компании (они в основном маленькие, но их очень много), а точнее – они уже считаются при помощи PostgreSQL и Redis, но было решено вынести эту функциональность в MongoDB, чтоб существенно облегчить базу, не зависеть от Redis, который иногда проще всего починить сделав FLUSHALL и потеряв статистику за день, а также немного расширить функциональность. Также для продуктов аналогично (на всякий случай). О задаче отображения статистики – ниже.

Итак, при заходе на компанию вам показывается картинка размером 1x1 пикселей, которая ведёт на специальный адрес, который достаёт из сессии специальный идентификатор, по которому можно понять, что вы – один и тот же пользователь на разных страничках. Особого смысла нас обманывать я не вижу, ну и обнаружить накрутчиков будет не сложно, потому никакой специальной защиты не делали.

Далее имеется два хранилища: PostgreSQL и Redis. В Redis хранятся значения для каждой компании “за сегодня”, куда по ключам webstats.12345.hits и webstats.12345.hosts (если это уникальный пользователь) добавляются значения. Соответственно есть ключ, в котром хранится сет идентификаторов посетителей для компании за сегодня (ну, чтоб хосты считать).

В PostgreSQL имеется табличка daily_webstats, в которой находятся записи вида:

company_id, dt, hits, hosts

за вчера и раньше.

То есть в PostgreSQL’е хранятся записи за вчера и раньше, которые хранят за определенный день информацию по хитам и хостам для каждой компании.

Каждую ночь выбирается статистика из Redis в PostgreSQL и сбрасываются кеши.

Выборки

Посмотрим, какие у нас присутствуют выборки:

  1. В личном кабинете у компании рисуется график с её статистикой за последние 280 дней (или около того). Соответственно выборка идёт из PostgreSQL из таблички daily_webstats.
  2. Под этим графиком рисуется маленькая табличка, в которой показаны хиты и хосты для периодов за сегодня, вчера, позавчера, 7 дней, 30 дней, всё время. Тут делается sql-запрос SELECT sum(...) ....
  3. Также есть страничка, на которой показывается общая статистика среди всех компаний за периоды сегодя, вчера, позавчера, 7 дней, 30 дней. Реализация тоже была через sum() по таблички статистики.

Текущие проблемы

Ну, главной причиной миграции оказалось то, что дамп нашей базы начал занимать около 27 Гб, и сисадмин спросил, нельзя ли куда-то это вынести (при том, что уже давно поглядывали на вынесение статистики в MongoDB). При этом две таблички статистики занимали около 13 Гб.

Todo

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

Следующей проблемой являлся Redis. Это прекрасная БД, но иногда она подглючивает (хотя, вроде, в последних релизах лучше), и статистика уже стала довольно важной информацией, которую терять критично (если редису помогает только FLUSH ALL).

Note

Насчет Redis уже пришел в комментарии наш админ под ник-неймом Petrovich. Так что о вопросах глючности (или просто о причинах, почему избавиться хотели) – прошу туда.

Также планировалось показывать рейтинг компаний не только за 30 дней, а еще за год и за всё время.

Еще не помешала бы возможность сортировки по хитам, к примеру. Говоря короче, SQL здесь уже был слишком накладным, да и вообще считать каждый раз сумму – плохой тон. Здесь явно пригодился бы MapReduce.

Архитектура решения при помощи MongoDB

В MongoDB было решено всё сделать так: коллекция dailywebstats, в которой хранится информация так же, как и в PostgreSQL, только еще информация за сегодня. Документы выглядят как-то так:

{
    "_id" : ObjectId("4ddcebc10dbdbd351a000000"),
    "c_id" : 1,
    "hits" : 4,
    "hosts" : 1,
    "dt" : ISODate("2008-05-14T00:00:00Z")
}

Трекер пишет информацию прямо туда. Одна коллекция для подсчета уникальных посетителей (там всё просто, id компании и соответствующий ей tracker_id).

Для выборки статистики за сегодня, вчера и позавчера (короче, за период в один день) можно делать запросы прямо в эту коллекцию. Вот, к примеру, компании по популярности за сегодня (если сегодня 01.01.2012):

..code-block:: javascript

var today = new Date(Date.UTC(2012, 1-1, 1)); // не забываем о js и его месяцах 0-11 db.dailywebstats.find({dt: today}).sort({hosts: -1, hits: -1}).limit(20)

По ночам будем считать статистику за 7 дней, 30 дней, год и всё время. Для этого напишем соответствующий MapReduce.

Я пропущу MapReduce для полного пересчета, а сразу перейду к еженочного пересчета статистики, так как map и reduce почти такие же.

В MongoDB, начиная с версии 1.8 (ну, нестабильной 1.7), присутствует опция out: { reduce: 'collectionName' }. Это благая новость и бальзам на душу тем, кто делает инкриментальный mapReduce. Как раз то, что нам нужно.

К примеру, алгоритм для обновления статистики за 30 дней. Мы сделаем две операции MapReduce: в первой выберем за день сегодня - 32 дня (почему 32? Потому что задача выполняется на следующий день, после “сегодня”, потому то, куда писалась статистика за “сегодня” уже вчера :-) и сделаем emit для каждого такого данного со знаком минус, а потом сделаем MapReduce за период вчера, только теперь будем делать emit со наком плюс.

m_general = (u"""
function() {
    var r = {hits: %(op)s * this.hits, hosts: %(op)s * this.hosts};
    emit(this.c_id, r);
};
""")
m_add = Code(m_general % dict(op=u"1"))
m_sub = Code(m_general % dict(op=u"-1"))

r = Code(u"""
function(k, vals) {
    var ret_val = {hits: 0, hosts: 0};
    for (var i=0; i < vals.length; i++) {
        var val = vals[i];
        ret_val.hits += val.hits;
        ret_val.hosts += val.hosts;
    };
    return ret_val;
};
""")

Дальше, думаю, всё понятно.

Для выборки статистики по популярности за 7 дней, к примеру, будем делать:

db.dailywebstats_7_days.find().sort({hosts: -1, hits: -1})

Ах да, забыл упомянуть, для каждого периода будет своя коллекция (впрочем, читатель и так мог догадаться).

Неудобство MapReduce в MongoDB

Здесь стоит рассказать об одном, на мой вгляд, минусе и неудобстве MapReduce в MongoDB. Дело в том, что результат MapReduce выглядит примерно вот так:

> db.dailywebstats_7_days.findOne()
{ "_id" : 1, "value" : { "hits" : 83, "hosts" : 44 } }

То есть результат всегда выглядит как _id (ключ emit’а) и value. MongoDB по-другому не умеет, потому в нашем случае приходится писать преобразователь в единый формат. Я решил преобразовывать статистику из dailywebsats к виду {_id: , {value: {hits: , hosts:}}}. Хорошо, что в питоне присутствует прекрасная библиотека itertools.

Надеюсь, в будущем в MongoDB что-то придумают, потому что хранить везде лишний {value:} таки накладно. Ну и в моем случае переименование _id в c_id было бы просто спасением прозрачности и единости кода.

Импортирование статистики, общий план миграции и первый провал (fail)

Остановимся немного на процессе импортирование существующей статистики. План был прост: пока еще сайт работает на старой версии мы запускаем скрипт, который импортирует статистику из PostgreSQL. Затем этот же скрипт строит индекс (ну, чтоб быстрее было). Потом поднимаем новый трекер (который уже на Mongo), переключаем на него (теперь статистика будет считаться в Mongo, а отображаться еще из Redis/PostgreSQL). Затем успешно обновляем сайт, после чего доимпортируем статистику из Redis.

Заметили неувязочку в этом хитром плане? Я – нет. Так вот, MongoDb у нас уже используется для подсказке в строке поиска (собирается статистика поиска и подсказывается как у гугла и яндекса). По-умолчанию монго строит индексы, останавливая все остальные операции БД. И об этом-то я и забыл. Самое обидное, что операцию построения индекса и убить-то сложно (если не ошибаюсь, операция не отображается в db.currentOp()). Существует опция построения индекса в фоне, она блокирует БД на запись примерно на 90%, так что тоже есть опасность того, что вы получите залоченую БД.

Затем, замеряв скорость импортирования с предварительным построением индексов я обнаружил, что она всё равно упирается в питон (те же 20000 в секунду где-то, точно не помню), а этого вполне достаточно.

Так я сам для себя сделал правило: сначала ensure_index, затем – импортирование кучи данных. Потому что скорости импортирования обычно хватит с головой.

Кстати, если скорости импортирования скрипта мало – сделайте скрипту возможность принимать параметр --skip и --limit. Скорость увеличится пропорционально кол-ву таких процессов. Каждый процесс лочит базу на запись примерно на 30%, потому больше трех мы не запускали (ну и на самом деле мы не пользовались skip и limit, а просто для разных стран и БД запускали скрипты параллельно).

Трекер, MongoDB и Gevent

Стоит отдельно рассказать о работе трекера. Трекер – это отдельное веб-приложение, отдающее ту самую картинку 1x1 пиксель. Протестировав (еще давно), было решено запустить его в один физический поток (чисто чтоб экономить процессор), используя прекрасную библиотеку, делающую потоки “зелеными” gevent. Так вот у MongoDB и его питоновского драйвера pymongo есть некоторые проблемы при работе с gevent. Долго рассказывать что к чему (да и в транке вроде бы уже дела идут к лучшему) не буду, а лишь скажу, что на каждый новый зеленый поток будет создаваться новое соединение через сокет.

Решение – в конце операции (перед тем, как осталось уже отдать только пиксель) сделать:

conn.end_request()

Этот код вернет ваш коннект обратно в очередь внутри монго-драйвера.

Ах да. Еще стоит упомянуть, что на сервере, где у вас будет подобный трекер, который (потенциально) будет одновременно держать кучу коннектов – не забудьте увеличить ulimit.

Индексы

Отдельного внимания стоит уделить индексам. Итак, коллекции dailywebstats с документами вида:

{
    "_id" : ObjectId("4ddcebc10dbdbd351a000000"),
    "c_id" : 1,
    "hits" : 4,
    "hosts" : 1,
    "dt" : ISODate("2008-05-14T00:00:00Z")
}

Todo

  • конфигурация монги (replica set)
  • размеры индексов
  • оправдаться по поводу индексов на _id
  • статистика сколько людей
  • mongostat
  • решение архивировать статистику
  • UTC
  • характеристики железа, ssd, план завода второй монги, “медленной”

«  Время, UTC, GMT и много проблем   ::   Contents   ::   Создание безопасного WYSIWYG-редактора  »

blog comments powered by Disqus