Однажды, много лет назад, пришел ко мне клиент, и слезно умолял поручил разобраться в одном чудесном проекте, и повысить скорость работы.
Вкратце, задача была такой — есть некий робот на С++, обдирающий HTML страницы, и собранное складывающий в БД (MySQL). С массой функционала и вебом на LAMP — но это к повествованию отношения не имеет.
Предыдущая команда умудрилась на 4-ядерном Xeon в облаке получить фантастическую скорость сбора аж в 2 страницы в секунду, при 100% утилизации CPU как сборщика, так и БД на отдельном таком же сервере.
К слову, поняв что они не справляются — «команда крепких профессионалов» из г. Бангалор сдалась и разбежалась, так что кроме горки исходников — «ничего! помимо бус» (С).
О тонкостях наведения порядка в PHP и в схеме БД поговорим как-нибудь в другой раз, приведу только один пример приехавшего к нам мастерства.
Приступаем к вскрытию
Столь серьезная загрузка БД меня заинтересовала в первую очередь. Включаю детальное логирование — и начинаю вырывать на себе волосы во всех местах вот оно.
Задачи из интерфейса разумеется складывались в БД, а робот 50 раз в секунду опрашивал — а не появилась ли новая задача? Причем данные естественно разложены так, как удобно интерфейсу, а не роботу. Итог — три inner join в запросе.
Тут же увеличиваю интервал на «раз в секунду». Убираю безумный запрос, то есть — добавляю новую табличку из трех полей и пишу триггера на таблицы из веба, чтобы заполнялось автоматом, и меняю на простой
select * from new_table where status = Pending
Новая картинка — сборщик по-прежнему занят на 100%, БД на 2%, теперь четыре страницы в секунду.
Берем в руки профилировщик
И внезапно выясняется, что 80% времени выполнения занимают чудные методы EnterCriticalSection и LeaveCriticalSection. А вызываются они (предсказуемо) из стандартного аллокатора одной известной компании.
Вечер перестает быть томным, а я понимаю что работы — много и переписывать придется от души.
И разумеется — парсить HTML быдлокодеры мои предшественники предпочитали пачкой регулярных выражений, ведь SAX — это так сложно.
Самое время ознакомиться — а что было улучшено до меня?
Об опасности premature optimizations мысленным лучом
Видя, что БД загружена на 100%, ребята были твердо уверены, что тормозит вставка в список новых URL для обработки.
Я даже затрудняюсь понять — чем они руководствовались, оптимизируя именно этот кусок кода. Но сам подход! У нас по идее тормозит вот тут, давайте мы затормозим еще.
Для этого, они придумали такие трюки:
- Очередь асинхронных запросов на insert
- Огромная HashMap в памяти, самописная, с giant lock, которая запоминала все пройденные URL в памяти. А так как это был сервер — то его после таких оптимизаций приходилось регулярно перезапускать. Очистку своего кэша они не доделали.
- Масса магических констант, например — для обработки следующей партии URL из БД беретсмя не более 400 записей. Почему 400? А подобрали.
- Количество «писателей» в БД было велико, и каждый пытался свою часть впихнуть в цикле, вдруг повезет.
И конечно же много других перлов было в наличии.
Вообще, наблюдать за эволюциями кода было весьма поучительно. Благо в запасливости не откажешь — все аккуратно закомментировано. Вот примерно так
void GodClass::PlaceToDB(const Foo* bar, ...) {
/* тут код с вариантом номер 1, закомментарен */
/* тут код с вариантом номер 2 - копипаст первого и немного изменений, закомментарен */
/* тут код с вариантом номер 3 - еще изменили, не забыв скопировать вариант номер два, закомментарен */
....
/* тут вариант номер N-1, уже ничего общего не имеет с первым вариантом, закомментарен */
// а тут наконец-то вариант рабочий
}
Что делал я
Разумеется, все трюки были немедленно выброшены, я вернул синхронную вставку, а в БД был повешен constraint, чтобы отсекал дубли (вместо плясок с giant lock и самописным hashmap).
Автоинкрементные поля также убрал, вместо них вставил UUID (для подсчета нового значения может приползать неявный lock table). Заодно серьезно уменьшил таблицу, а то по 20К на строчку — неудивительно что БД проседает.
Магические константы также убрал, вместо них сделал нормальный thread pool с общей очередью задач и отдельной ниткой заполнения очереди, чтобы не пустовала и не переполнялась.
Результат — 15 страниц в секунду.
Однако, повторное профилирование не показало прорывных улучшений. Конечно, ускорение в 7 раз за счет улучшения архитектуры — это тоже хлеб, но — мало. Ведь по сути все исходные косяки остались, я убрал только вусмерть заоптимизированные куски.
Регулярные выражения для разбора мегабайтных структурированных файлов — это плохо
Продолжаю изучать то, что сделано до меня, наслаждаюсь подходом неизвестных мне авторов.
Ме-то-ди-ка!
С грациозностью трактора ребята решали проблему доставания данных так (каждому действу свой набор регулярных выражений).
- Вырезали все комментарии в HTML
- Вырезали комментарии в JavaScript
- Вырезали теги script
- Вырезали теги style
- Вынули две цифры из head
- Вырезали все кроме body
- Теперь собрали все «a href» и вырезали их
- В body вырезали все ненужные div и table, а также картинки
- После чего убрали табличную разметку
- В оставшемся убирали теги p, strong, em, i, b, г и т. д.
- И наконец в оставшемся plain text достали еще три цифры
Удивительно с таким подходом, что оно хотя бы 2 страницы в секунду пережевывало.
Понятно, сами выражения после их тюнинга я не привожу — это огромная простыня нечитаемых закорючек.
Это еще не все — разумеется, была использована правильная библиотека boost, а все операции проводились над std::string (правильно — а куда еще HTML складывать? char* не концептуально! Только хардкор!). Вот отсюда и безумное количество реаллокаций памяти.
Беру char* и простенький парсер HTML в SAX-style, нужные цифры запоминаю, параллельно вытаскиваю URL. Два дня работы, и вот.
Результат — 200 страниц в секунду.
Уже приемлемо, но — мало. Всего в 100 раз.
Еще один подход к снаряду
Перехожу к результатам нового профилирования. Стало лучше, но аллокаций все еще много, и на первое место вылез почему-то бустовский to_lower().
Первое, что бросается в глаза — это могучий класс URL, цельнотянутый из Java. Ну правильно — ведь это С++, он по любому быстрее будет, подумаешь что аллокаторы разные. Так что пачка копий и substring() — наше индусское все. И конечно же to_lower прямо к URL::host применять ни-ни — надо на каждом сравнении и упоминании и непременно boost-ом.
Убираю чрезмерное употребление to_lower(), переписываю URL на char* без переаллокаций вместо std::string. Заодно оптимизирую пару циклов.
Результат — 300 страниц в секунду.
На этом закончил, ускорение было достигнуто в 150 раз, хотя еще были резервы для ускорения. И так убил больше 2х недель.
Выводы
Выводы как всегда — классика жанра. Используйте инструменты при оценке производительности, не выдумывайте из головы. Ширше (или ширее) пользуйтесь готовыми библиотеками, вместо закатывания солнца вручную.
И да пребудет с вами святой Коннектий высокий перформанс!
Автор: viklequick