Как стать автором
Обновить

Какая боль! Толпы против Веб — 2:0. Эпизод один — высокоскоростная очередь

Время на прочтение5 мин
Количество просмотров813
Тов. phpdude открыл блогом «PHP+MySQL оптимизация» интересную тему, захватывающую большую аудиторию. Расскажу я о том, как я Веб 2.0 убил два моих сервера (из-за моей глупости в т.ч.) и еще планирует поубивать.

Все началось осенью 2006 года, когда я решил открыть небольшой сайтик с 70 миллионами страничек. И нет, они не были бы сделаны по цепям Маркова, а именно были бы полезными. Почему 70 миллионов? Потому что столько .com/.net/.org доменов в тот момент я смог найти.

Ссылку на проект не буду давать — устал я там за Вами, товарищи Хаброчеловеки, мат чистить. Если очень хотите — посмотрите в предыдущем моем топике про «заработки на стартапах». Пойду рассказывать по историческому порядку, так что до того как я memcached'ом спасался от Heise DDoS, наверное уже во второй части дойду.

Итак, была у меня идея — найти самое редкоиспользуемое слово в Интернете. Для этого надо обойти 70 миллионов сайтов (список честно соскрэйплен с who.is, пытался я получить доступ к TLD Zone Access Program, но не пущают). Лингвисты, кстати, уже, наверное посмеялись над моей идеей найти самое редкое слово, ну да я тогда не знал много про слова.

Собственно, вопрос. Как организовать очередь? То есть начать с aaaa.com и закончить zzzz.com — нужно как-то ж это где-то сохранить? Ну MySQL понятно! id, url, status={'были';'еще не были'}

Понятно мне стало не сразу… почему мой комп так тупит… 72 миллиона записей MySQL не с очень большим энтузиазмом начал воспринимать. Транзакции-ли, InnoDB или MyISAM. Проблема еще и в том встала, что с каждым следующим INSERT — они становятся все медленнее.

Окей, кое-как сделали. Используем MyISAM, потому что InnoDB чего-то вообще плохо переварила такой объем. (Может я чего и не так сделал, но сути не меняет). Поехали, SELECT id,url WHERE status='еще не были'; UPDATE STATUS='пробуем'.

Ага, не так все просто. Транзакций-то нет, по 2-3-10 ботов на один и тот же адрес начинают ломится (одновременно происходит их SELECT, все получают один и тот же URL и одновременно за собой ставят новый статус, откидывая всех последующих, а сами идут вместе на тот адрес).

Вот тут я изобрел (точнее сказать — догадался для себя — изобрением это не назовешь) первый фокус оптимизации MySQL под высокой нагрузкой — для MyISAM без транзакций.

Добавляем поле rand, затем каждый thread генерит случайное число и делает СНАЧАЛА UPDATE… rand='92803423' WHERE (status='еще не были') AND (rand IS NULL) LIMIT 1, а потом уже SELECT WHERE rand='92803423'. Получаем полный ATOMIC — только один thread гарантированно получит эту запись без транзакций.

Окей, но скорость была более чем фиговой — месить 72 миллиона записей сотней тредов — эт не шутки для компа. В общем, mySQL ползала.

(не про mysql) Тогда я придумал, как мне казалось «гениальный» план — вынести все в файл прям где только одна строка — один url и будем делать так — flock, прыгаем (fseek) в случайное место в файле — мы оказываемся где-то в середине строки и читаем до символа \n, теперь читаем переводы на новую строку до первого alphanumeric символа. Отлично, читаем строку, сохраняем ее, делаем fseek в начало этой строки и забиваем ее символами \n, отпускаем flock. Затем раз в час делем grep чтобы убрать пустые строки из файла.

Казалось, что это все решило — все операции имеют практически время константное, даже добавка, ничего не растет хоть там 100 миллионов, хоть миллиард, MySQL же линейно увеличивает время операции с каждым новым элементом. Не подумал я, что I/O тут будет оооочень много. В общем, 100 thread'ов за месяц таких методик убили винчестер на сервере. Поставили новый, но надо было что-то придумать.


Тогда я подумал что у меня же треды идут по порядку и зачем им каждый раз обращаться к базе, если они могут взять 100 url сразу, удалить их, сделать их и только потом обращаться будем за новыми. Тут же сразу пришла в голову еще одна оптимизация — а именно — вместо id,url,status + SELECT/UPDATE я могу использовать временную таблицу с id,urls и SELECT/DELETE.

Собственно таблица выглядела так:
id; urls
«1»; «aaaa.com;aaaab.com;aaaac.com;aaaad.com....»
«2»; «aaa…
»720000"; «zzzzzxxx.com;zzzzy.com;zzzzzzz.com»


Это первый раз, когда я использовал в MySQL денормализацию, но выбора не было.

то есть второе поле было TEXT, который я потом делал split(';') в PHP. Почему 100, а не 1000? Эмпирическим путем. Нужно было рассчитывать, что скрипт, взяв 100 URL может и зависнуть на каком-то, может произойти segfault и еще что угодно, сервер перезагрузится, так что надо было лимитировать потери. Плюс если я замечал баг, мне приходилось же останавливать систему — делалось это методом kill -9.

100 url отрабатывались за 30 секунд в средем, так что потери в случае чего составляли бы максимум 10'000 url (100 x 100 тредов), но в реальности получалось не больше 1000. Для проекта-хобби нормально.

Тут Вы меня спросите — почему я придал такое значение чтобы два треда не дай Бог не схватили один и тот же URL, а потерю 10к url не считал проблемой? Потому что в первом случае я устрою легкую DoS-атаку удаленному сайту, чего не хотелось делать даже если это только 2, а если все 100 одновременно?

В общем, эта организация очереди более чем оправдала себя — MySQL отрабатывал все за очень шустрое время, периодические OPTIMIZE TABLE этому способствовали.

Частоты слов



Дальше встал вопрос — мне нужно было хранить кэш того, что я нашел, а точнее — список слов, что встретились на главной странице и их количество…

… чтобы потом анализировать и найти Священный Грааль — самое редкое слово, которое было бы настолько редким, что существовало бы только один раз на всех сайтах и то исчезло бы после прихода моего. Оказалось это слово «yoihj» — мой ник. Как и твой ник, читатель, и ники всех кто где-либо регистируется. Проще говоря, это оказались не-слова, а ники, опечатки и т.п.


… итак, хранить количество слов. Пошел очевидным путем.
url(varchar);word(varchar);count(int)

Жаль что моей школьной учительницы математики не было рядом — она бы мне линейкой по голове быстро объяснила, что 72 миллиона сайтов x 1000 слов на каждом — это 72 миллиарда записей, а я только что ныл про то, что MySQL 72 млн еле тянет и постоянно замедляется.

В общем-то, идея та же — я стал хранить в базе url; serialize($words). Потом позднее оно заменилось на json_encode($words), ибо боты уже были переписаны на Python и поддержка PHP Serialize хоть и была, но тормозила, так что JSON занял приоритетное место.

Собственно, до сих пор так это все и хранится — выдержало и набеги TechCrunch, и Heise.de и многих других.

Разумеется, это далеко не все, что пришлось сделать — там и свой SphinxSearch изобретал и memcached в качестве базы данных — кстати неплохо сработало. :) Но об этом в другой раз.

UPDATE
Забыл про финишный вариант (который сейчас).
Все сайты в базе — 72 млн записей, без индекса, кроме PRIMARY KEY=url(varchar),
а запрос SELECT… WHERE url>'kite.com' LIMIT 100, где kite.com — последний обойденный сайт,
время этого запроса константно по всей базе O(1),
update никакой вообще не делается,
обрабатываем эти сто в тредах, когда число живых тредов близится к 20 — Допустим этот SELECT выдает последним 'klara.com' — сохраняем куда-нить 'klara.com' — вот и делаем уже SELECT… WHERE url>'klara.com' LIMIT 100
Под php треды — это процессы, счет процессов через popen('ps aux | grep processname','r').
Просто сейчас уже боты переведены на Python все.


Йои Хаджи,
вид с Хабра
Теги:
Хабы:
Всего голосов 87: ↑70 и ↓17+53
Комментарии104

Публикации

Истории

Ближайшие события

25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань