Тов. 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 ползала.
Тогда я подумал что у меня же треды идут по порядку и зачем им каждый раз обращаться к базе, если они могут взять 100 url сразу, удалить их, сделать их и только потом обращаться будем за новыми. Тут же сразу пришла в голову еще одна оптимизация — а именно — вместо id,url,status + SELECT/UPDATE я могу использовать временную таблицу с id,urls и SELECT/DELETE.
Собственно таблица выглядела так:
Это первый раз, когда я использовал в 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 этому способствовали.
Дальше встал вопрос — мне нужно было хранить кэш того, что я нашел, а точнее — список слов, что встретились на главной странице и их количество…
… итак, хранить количество слов. Пошел очевидным путем.
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 все.
Йои Хаджи,
вид с Хабра
Все началось осенью 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 все.
Йои Хаджи,
вид с Хабра