Файлы vs базы данных.
В последнее время все чаще нахожу в интернете статьи, которые расхваливают сайтовые движки, созданные без использования баз данных, т.е. сохраняющие данные в файлах. Непонятно почему, но народ истово верит в то, что именно такой подход (отказ от использования систем управления базами данных и хранение данных в файлах) дает гораздо большую производительность. Стали встречаться даже целые темы в программерских форумах, где сторонники файловых КМС бьются "до последней капли крови" с поклонниками "классического", СУБД-шного подхода.
Странно, но народ в упор не хочет видеть очевидное и с удовольствием витает в облаках собственных заблуждений. Быть может этот пост откроет некоторым глаза на истинное положение вещей. Или, по крайней мере, заставит задуматься...
Давайте рассмотрим процессы, проистекающие на сервере при работе КМС "на файлах" и сравним их с аналогичными процессами при использовании СУБД. В качестве примера «файлового подхода» я буду использовать движок этого блога (Lasto-blog-B), точнее работу его системы статистики. Естественно, точный алгоритм его работы мне не известен (т.к. автор скрывает исходники, шифруя все в байт-код с помощью Zend-Guard), но общие выводы можно сделать просто проанализировав содержимое файлов, хранящих статистические данные.
Кстати, все нижеописанное касается и других поделок от Lasto, которые собирают статистику — сплогов, нового варианта nano-CMS и т.д. Пользователям скриптов других производителей, также использующих файлы в качестве хранилища данных этот пост также должен быть интересен.
Итак, последовательность действий модуля сбора статистики Lasto-блога примерно такова: при обращении серфера к любой странице сайта запускается один и тот же скрипт, который должен собирать статистическую информацию: кто пришел (серфер или бот), откуда он пришел (сайт-источник), куда он пришел (страница нашего сайта) и т.д. Вся эта информация сохраняется в файл и позже, на ее основе, генерируются графики посещаемости по дням, «хит-парад» страниц сайта, сайтов-источников трафика и используемых для просмотра броузеров и прочая мега-полезная отчетность.
Принципы работы достаточно ясны и понятны, вся информация легко-доступна для любого скрипта и интереса не вызывает. Нам гораздо важнее узнать, насколько оптимально использовать для этой работы файлы и действительно ли это быстрее, чем работа с базой данных.
Особенность хранения данных Lasto-блогом такова, что статистика хранится в нескольких файлах:
- hits.http.db — данные о хитах (метка времени хита, IP-адрес источника, User-Agent клиента, хост источника, страница сайта к которой обратился клиент). Кстати, анализируя этот файл можно увидеть, какие страницы сайта проиндексированы какими поисковыми системами (были посещены поисковыми ботами) и когда это произошло. Странно, что Lasto не встроил какого-либо культурного просмотрщика и анализатора этих ценных данных. Или встроил? Но в таком случае, почему об этом нигде не сказано? Стоит задуматься...
- hits.rss.db — данные об обращениях RSS-ридеров. В данный момент нам эта инфа не интересна.
- hits.mem.db — а вот на этот файл стоит обратить особое внимание. Именно тут собрано самое интересное. В нем хранятся те данные, которые выводятся на странице статистики блога. Будем анализировать работу именно с этим файлом.
Содержимое его представляет собой «серилизованное» отображение многомерного PHP-массива. Структура такова:
- allsein — данные о заходах с поисковиков по разным запросам
- хост | искомая фраза
- дата, количество заходов
- ...
- хост | искомая фраза
- дата, количество заходов
- ...
- ...
- agent — данные о броузерах и ботах
- user-agent броузера
- дата, количество заходов
- ...
- user-agent броузера
- дата, количество заходов
- ...
- ...
- pages — данные о посещенных страницах
- страница
- дата, количество заходов
- ...
- страница
- дата, количество заходов
- ...
- ...
- rss_agent — данные о запросах от RSS-ридеров
- user-agent ридера
- дата, количество заходов
- ...
- user-agent ридера
- дата, количество заходов
- ...
- ...
- source — данные об источниках трафика
- URL страницы-источника
- дата, количество заходов
- ...
- URL страницы-источника
- дата, количество заходов
- ...
- ...
- a — общая статистика по ридерам, ботам и т.д. Нас это не интересует.
В качестве дат (видимо для оптимизации) используются целочисленные значения, равные количеству дней, прошедших с некой фиксированной даты (1 января 2000 года). В первом «разделе» (данных о поисковиках и искомых фразах) разделителем данных является символ вертикальной черты.
Таким образом, чтобы узнать, сколько человек посетило страницу stat.html в определенный день, нужно сперва вычислить сколько дней прошло с 1 января 2000 года до искомой даты, затем «заглянуть» в ячейку массива
$ar['pages']['/stat.html'][к-во дней]
Число, которое мы «увидим» в этой ячейке и будет искомым количеством посетителей нашей страницы.
С поисковиками немного сложнее. Нужно сперва собрать «заготовку», состоящую из домена поисковика и искомой фразы, разделенных двумя символами вертикальной черты, а затем просмотреть все элементы массива («раздел» allsein) на предмет ПОЛНОГО строкового равенства (совпадения). Согласитесь, задача не из быстрых.
Но эта задача для отображения статистики, а нас больше интересует ее сбор. Давайте посмотрим, как он осуществляется. Для примера возьмем серфера, пришедшего с поисковика по какому-то запросу.
Первое.
При обращении серфера к странице, скрипт собирает всю необходимую информацию (имя страницы, URL страницы-источника, user-agent, дата обращения и т.д.). Этот процесс происходит моментально и заострять внимание на нем не стоит. А вот дальше...
Второе.
Скрипт считывает в память ВЕСЬ файл статистики, не взирая на размер (если посещаемость высокая или статистика собирается за большой срок, то это могут быть десятки или даже сотни мегабайт). И не просто считывает, а делает его «ансерилизацию», если можно этот процесс так назвать. Для тех, кому не совсем понятно, о чем тут идет речь, приведу краткое упрощенное пояснение.
Язык PHP содержит пару функций (serialize и unserialize), предназначенных для преобразования массива в строку и обратно. Первая склеивает все элементы массива (разделяя их спец-символами с технической информацией) и возвращает полученную текстовую строку. Вторая делает обратное преобразование из строки в массив.
Назначение этих функций — упростить передачу массивов по каналам связи, равно как и их сохранение на носителях. Прикол в том, что unserialize восстанавливает точную копию исходного массива, даже если он многомерный, даже если его элементы представляют собой тоже массивы, которые в свою очередь тоже содержат массивы... и т.д. Просто одной командой вы можете превратить ваш супер запутанный массив в текст, а второй точно также вернуть все обратно. Очень удобная возможность.
Но есть и подводный камень. Обе эти операции (особенно вторая) достаточно ресурсоемкие, т.к. приходится парсить (разбирать) огромный объем текста, выискивая в нем те самые спец-символы, по ним восстанавливать значения исходных ячеек и клеить их в результирующий массив. Короче, работы тут много.
Но вернемся к нашей теме. Итак, скрипт загружает текстовый файл и преобразует его в массив. Если вы внимательно прочли предыдущий абзац, то уже должны понимать, что при небольшом количестве элементов в массиве операция проходит относительно быстро. Ну а если это статистика за месяц и при этом с поисковиков в день приходит по несколько сотен человек, да еще по тысяче различных запросов, то получается весьма нехилый объем работы.
Третье.
Скрипт должен найти ячейку с данными по определенному в первом пункте поисковику и запросу. Я уже описывал, как именно это происходит и вы уже убедились, что это тоже не быстрая операция, т.к. она требует перебора части (а в худшем случае всех) элементов массива и для каждого из них необходимо сделать сравнение достаточно длинных текстовых строк.
Тут можно немного оптимизировать процесс и сделать обращение вида
$ar['allsein']['google.com||вася пупкин']
В этом случае для сравнения строк будут использованы встроенные средства языка (библиотечные функции), которые работают гораздо быстрее самих PHP-шных скриптов, но все равно операцию эту быстрой не назовешь.
Четвертое.
Если искомая ячейка существует и найдена, то значение в ней увеличивается на единицу. Если же такой ячейки нет, то ее необходимо создать и поместить в нее единицу. Случаи когда с данной поисковой машины еще не было посетителей и нужно создавать целую ветку массива рассматривать не будем. Нам и описанного вполне хватит.
Пятое.
Серилизация (склейка элементов массива в строку) и перезапись (опять же) ВСЕГО файла статистики.
Я описал не самый худший вариант, т.к. тут затронута обработка данных только из одного «раздела» статистики (а ведь скрипт еще должен скорректировать данные и в остальных), не описан блок, который проверяет доступность сайта для данного юзер-агента и хоста-источника (а он жрет времени тоже немало), не учтены потери на выделение/освобождение памяти и копирование целых веток массива, если автор решил (для упрощения программирования) отделить часть массива например так:
$as = $ar['allsein']; // выделяем данные о заходах с искалок в отдельный массив
Все это уже не столь важно. Главное то, что ПРИ ЛЮБОМ ХИТЕ (даже переходе серфера с одной страницы сайта на другую) происходят ВСЕ описанные операции — чтение, разбор, поиск, склейка, запись и выполняются они над ВСЕМ содержимым файла статистики. А быстрыми их никак уж не назовешь.
Да, да, я знаю о кэшировании файлов. Но и тут не все так просто. За счет кэширования вы выиграете часть времени на чтении/сохранении файла статистики, но как быть с массивом? Его-то никто не прокэширует. А значит парсинг текста и построение массива, равно как и последующая склейка все равно будут выполняться при каждом обращении к скрипту (сайту).
К тому же не стоит забывать, что пока одна копия запущенного скрипта копается в вашем (пусть и прокэшированном) файле, все другие будут тихо и мирно курить в сторонке. Другими словами, пока идет обработка одного посетителя, все остальные будут ждать ибо для них доступ к файлу статистики окажется заблокированным. Очередь, понимаешь...
Теперь взглянем, как эта же задача была бы решена с использованием БД.
Первое на что хотелось бы обратить внимание — это тот факт, что подобную структуру не удастся (по крайней мере целиком) уложить в одну таблицу, а это значит будут использованы несколько связанных таблиц (реляционная структура). Для данного примера также будем рассматривать работу только с одним «разделом» статистики — данных о поисковых запросах.
При нормальном (читай «грамотном») подходе тут будут использованы аж три таблицы: в первой будут храниться хосты поисковиков (по одному на запись), во второй — запросы с хостов (каждый запрос в отдельной записи), ну и в третьей — собственно количество посетителей по дням для каждой из искалок по каждому из запросов. Естественно между ними есть жесткая связь типа «один со многими», т.е. каждой записи из первой таблицы соответствуют несколько записей из второй, каждой из которых соответствуют несколько записей из третьей. Кажется сложным? Нисколько! :-)
Структура, например, такая:
Таблица se_hosts -------------------- ID1 — уникальный числовой номер (используется для связи таблиц) Host — имя хоста поисковика Таблица se_query --------------------- ID2 — уникальный числовой номер (используется для связи таблиц) SH_ID — уникальный номер для ссылки на первую таблицу Query — текст искомой фразы Таблица se_hits ------------------- Q_ID — уникальный номер для ссылки на вторую таблицу Date — кво-дней с 1 января 2000 года Hits — количество хитов за этот день
Естественно первая и третья таблицы будут иметь индексы по первым двум полям, а вторая — по всем. Куда ж без них :-)
Итак, серфер пришел, что происходит в скрипте.
Первое.
Сбор информации о серфере. В точности соответствует первому пункту из предыдущего описания.
Второе.
Происходит соединение с БД. Делается это вызовом всего двух функций. По времени — молниеносно.
Третье.
Скрипт должен получить данные из базы о количестве хитов «за сегодня». Вот этот процесс мы и препарируем.
Сразу скажу, что все операции будут выполнены функциями из ОТКОМПИЛИРОВАННЫХ библиотек PHP, поэтому скорость их работы будет максимальной. Ни один скрипт тут рядом не стоял. Но суть не в этом, а в том, что количество этих операций будет намного меньшим. На ОЧЕНЬ много!
Итак, сперва производим поиск в первой таблице.
Если не использовать индексы, то для выполнения этой операции придется провести сравнение имен хоста с содержимым поля Host первой таблицы для части (а в худшем случае всех) записей. И хотя это будет гораздо быстрее чем сканирование массива (за счет работы библиотечных функций) все равно потребует много времени. А вот индексы помогут «надавить на кнопку «турбо».
Еще раз отвлекусь и расскажу немного об индексах.
Индексный файл содержит копию проиндексированного поля (или полей) из таблицы БД, но в отсортированном виде. Благодаря этому можно использовать так называемый бинарный поиск. Работает он так.
Весь диапазон значений (количество записей в таблице) делится пополам и сравнение искомого значения происходит со значением поля сразу из середины таблицы. Таким образом, одной операцией сравнения мы сразу определяем в какой из половин таблицы находится нужная нам запись, т.е. избавляемся сразу ровно от половины заведомо ложных сравнений. Затем оставшийся диапазон (половина таблицы) снова делится пополам и все повторяется до тех пор, пока мы не найдем нужную запись, либо убедимся, что ее нет.
Для примера, в таблице из 100 записей для поиска любого значения в наихудшем случае понадобится всего 7 сравнений. Быстрее почти в 15 раз!!!! И, повторюсь, это самый ХУДШИЙ случай. В среднем же прирост тридцатикратный!
Благодаря тому, что мы проиндексировали поле с именем хоста, мы находим нужную запись в 15 раз быстрее, чем в массиве (буду брать наихудший вариант).
Как нетрудно догадаться поиск во второй таблице нам даст уже экономию времени в 15*10 = 150 раз!!!! Я тут умножил на 10, а не на 15, т.к. во второй таблице при поиске будет использоваться фильтрация по SH_ID (нам же нужны фразы только с найденного хоста, а не все подряд).
Ну и третья таблица — ускорение в 150*10 = 1500 раз!!! И это только на поиске.
Есть естественно и потери. Буду до конца честным и расскажу о них.
Для работы механизма БД, система должна будет загрузить целиком в память три файла индексов. Но, в отличии от ранее рассмотренного варианта работы файлового движка, здесь размер этих файлов в десятки раз меньше. И главное их не нужно парсить, т.к. размер записи в них фиксированный. Т.е. для перехода к нужной записи достаточно просто размер заголовка файла добавить к произведению размера записи в байтах на номер записи и получаем точное смещение от начала файла. Одно сложение и одно умножение — просто ведь. И главное - умопомрачительно быстро!
Четвертое.
После того как мы нашли с помощью индексов нужную запись, ее требуется загрузить в память. Внимание! Нам нужно загрузить ТОЛЬКО ОДНУ ЗАПИСЬ, а вовсе не всю таблицу. В нашем случае размер записи (для третьей таблицы) составляет 12 байт, ведь там только три поля — Q_ID, Date и Hits, каждое из которых представляет собой 32-битное число и занимает 4 байта. Естественно, размер записей в первых двух таблицах (а их нам тоже придется загружать в процессе поиска) немного больше, но все равно он измеряется десятками байт, а не мегабайт!
Запись считали, увеличили на единицу и снова записали НА ТОЖЕ МЕСТО в файле таблицы. Все!
Поскольку записи в нашей третьей таблице (в данном случае) также будут иметь фиксированную длину, то поиск нужной внутри файла также сведется к одному сложению и одному умножению, а уж прочесть и снова записать на тоже место 12 байт — милисекундное дело (даже с учетом автоматической коррекции индексного файла). Да еще про кэширование вспомним :-)
Кстати, если ваш сайт достаточно посещаем, то файл статистики будет расти как на дрожжах и каждое обращение к скрипту будет вызывать чтение/парсинг/поиск/сохранение всех этих десятков мегабайт, а при использовании БД при любом количестве посетителей нам все так же будет нужно читать всего несколько сотен байт (три записи из таблиц), а записать - только 12 байт. Подумайте об этом на досуге.
Ну и конечно не забудьте о том, что поиск можно производить не в каждой таблице по отдельности, а сразу во всех, одной командой:
SELECT Hits FROM se_hosts, se_query, se_hits WHERE ID1 = SH_ID AND ID2 = Q_ID AND Date = 3085
что еще даст еще больший выигрыш, т.к. записи из первых двух таблиц не нужно будет передавать в скрипт, а мотор СУБД обработает их сам и очень-очень быстро.
Что же в итоге?
А итог таков, что пусть мы и поимеем потери на чтении/сохранении индексных файлов все равно получаем примерно тысячекратный прирост в скорости в сравнении с файловым движком. И это при том, что случай был рассмотрен достаточно простой. В реальных же реляционных СУБД, где работают десятки (а то и сотни) связанных между собой таблиц этот выигрыш измеряется уже далеко не тысячами.
Ну и последний гвоздь в гроб файловых КМС.
Задумайтесь вот над чем. В мире существуют тысячи компаний и фирм, которые пишут разные (часто весьма специализированные и уж совсем не дешевые) моторы для сайтов. И все они (моторы) работают ТОЛЬКО с БД. Так неужели во всех этих компаниях работают одни дураки? :-)