Как многие внимательные читатели блога успели заметить :-), утром после вывешивания тизера наш замечательный сервис сделал странное лицо и отказался работать, пока тизер не сняли. Соответственно, вчера и сегодня мы выясняли, что же такое с сервисом случилось. Вроде бы выяснили :-). Я нахожусь в интересном состоянии психики, когда непонятно, почему в четверг все было так плохо и безнадежно, потому что сейчас все выглядит таким простым и очевидным... Впрочем, правильность наших догадок подтвердит только следующий тизер.
Сначала цифры для статистики. Хотя, как потом будет понятно, значат они не так уж и много.
Мы работаем на кластере из 4 машин (CPU Xeon, 4 ядра, 2.3 ГГц), на которых крутится lighttpd, Django и memcached. За ними стоит 1 сервер БД, на котором крутится MySQL. Люди шли к нам нормально всю ночь и утро, и поток в 55 запросов/сек на одну машину мы выдерживали. Потом, где-то между 10 и 11 часами нам стало плохеть, в пике трафик поднялся до больше 300 запросов/сек на машину, и после этого... м-да :-(.
Проблема первого рода
В течение всего четверга у участников процесса было много самых разных идей, но к середине сегодняшнего дня одна выкристаллизовалась отдельно, и я теперь практически уверен, что она стала главной причиной такого плохого выступления.
Проблема нашлась в использовании сессий (не их самих, а в том, как мы их приготовили). Это те самые стандартные сессии, которые есть в Джанго. И вообще-то, в Джанго они сделаны достаточно умно — хранятся они в БД, но если в приложении в них ничего не записывать, то они не трогают БД вообще. К сожалению, у нас они использовались довольно интересным способом. Мы записываем в них сообщения для пользователя, которые показываются один раз и удаляются. Для этого в процессоре контекста у нас был такой код:
messages = request.session['messages']
request.session['messages'] = [] # ← строчка-убийца!!!
Вторая строчка — это очистка сообщений, которая суть запись в сессию. А раз выполнялось это в процессоре контекста, то отрабатывало это на каждый запрос.
Не совсем на каждый, правда, а только тогда, когда к нам приходил новый пользователь. Однако поскольку это был тизер на морде Яндекса, то у нас, собственно, практически все пользователи были новыми.
Впрочем, вы можете возмутиться, что даже с потоком в 1200 запросов/секунду MySQL на хорошем железе должен был справляться. Этому помешали две особенности таблицы сессий:
- она, как и все таблицы в нашей базе, использовала InnoDB в качестве движка
- ключ этой таблицы — md5-хеш, а не автоинкрементное целое
Если я ничего не путаю, таблицы InnoDB при добавлении каждой новой записи физически перестраиваются на диске в соответствии с порядком, заданным ключом. От этого, видимо, они очень быстро читаются. Собственно, в обычной ситуации с автоинкрементным целым ключом это тоже не вызывает проблем, потому что новая запись добавляется в конец, где и должна быть по порядку. Но раз у нас ключ по сути своей случаен, то каждая новая запись попадала куда-то в середину, что вызывало перестройку таблицы. Перестраивается она фрагментами, что при таких "хаотичных" вставках приводит к фрагментации и все большему и быстрому замедлению записи с ростом таблицы. Админы говорят, что в худшие времена жизни сервиса вставка одной записи в сессии занимала по 6-7 секунд!
В итоге, с какого-то момент скорость роста числа новых запросов обогнала скорость их обработки. Все попытки очухать сервис были, следовательно, обречены на провал, потому что чем дальше, тем медленнее он работал. Это, кстати, то, что я имел в виду о бессмысленности показателей: с таким якорем мерять скорость бессмысленно.
Из всего этого мы извлекли два урока (конечно же, "очевидных"):
- не стоит создавать сессии без нужды
- никогда не стоит хранить джанговские сессии в InnoDB-таблице
Кстати... Горькая ирония этой истории заключается в том, что по факту эту систему сообщений мы сейчас нигде не используем. То есть, сервис убила фича, которой... нет.
Проблемы второго рода
Возвратили сервис к жизни мы, в основном, тем, что запихали сессии в таблицу на памяти (ну и сняли тизер еще :-) ). Но в памяти, кстати, сессии тоже не зажили: там банально кончились подо что-то место, и вечером сервис снова лежал. Так что их пришлось вернуть обратно на диск. А сейчас мы и вовсе открутили ту "строчку-убийцу".
Однако загруженная база позволила выявить другие проблемы. Они, хоть и близко не стоят по серьезности к первой, но исправлять их мы все равно будем, потому что рано или поздно с ростом посещаемости мы все равно до них доберемся.
Проблемы, в порядке вспоминания, такие:
"Dog-pile" эффект. Индексная страница состоит практически целиком из закешированных кусочков, каждый из которых, при этом, считается относительно тяжелым запросом. Когда кеш протухает, следующий же запрос вызывает его подновление. И пока это подновление не закончится, все следующие запросы тоже увидят протухший кеш и начнут выполнять те же самые запросы на подновление. Если время подновления достаточно длинное, а запросов достаточно много, то они создают дополнительную нагрузку, тем самым еще увеличивая время подновления и ухудшая ситуацию все дальше и дальше.
Мы не используем пока еще чтение с реплик, что сняло бы нагрузку с убитого записями в сессии мастера, и, возможно, мы бы протянули дольше :-).
Еще у нас на индексе была одна выборка с join'ом четырех таблиц, одна из которых — самая здоровенная таблица базы :-).
Стратегия
Вчера все выглядело очень плохо: мы упираемся в базу на запись, а это в мире реляционных СУБД означает, что масштабироваться экстенсивно (то есть добавить машин) нельзя. Надо что-то переписывать, как-то искусственно пилить базу, что тяжело и плохо. Однако сегодня, открутив запись в сессии, мы перевернули ситуацию с голову на ноги. На тестовом стенде, состоящем из одного фронтенда и одного бэкенда с базой мы грузим фронтенд на load около 80, при этом база загружена где-то на 2-2,5. То есть упираемся мы теперь не в нее. А вот фронтендов можно просто добавить, причем, как мне кажется, в базу мы теперь упремся еще нескоро. Запас попробуем оценить во вторник, когда попробуем пострелять по системе из двух фронтендов и базы.
А проблемы второго рода будем решать так:
- Для предотвращения dog-pile эффекта с кешем возьмем, видимо, MintCache.
- Я взялся писать бэкенд "mysql_cluster", который позволит использовать реплики. Пока мне кажется, что получится написать именно внешнюю подключаемую штуку, без хаков кода Джанго.
- Запросы будем дальше оптимизировать денормализацией, как и делали раньше.
Следите за новыми сериями!
Комментарии: 38 (особо ценных: 1)
Как в Яндексе относятся к PostgeSQL?
Супер! Конечно, сочувствую что так всё "трудно" получилось, но опыт твой, Иван, бесценен.
Кстати, ты не думал, что "куда" - наверно самый высоко нагруженный сервис на джанго в мире? Хотя я может просто не знаю других...
Следующую серию жду с нетерпением! Надеюсь, что на она будет в стиле - "а за-то у нас что-то положительное":)
Зачем вообще писать сессии в БД, а не только в memcached, например? Если их все-таки надо хранить, почему не отложить запись, потом вытягивать из memcached несколько сразу и insert'ит пачками?
Трогают. По крайней мере запрашивают текущее состояние из базы каждый раз. Мне пришлось унаследовать contrib.SessionMiddleware, чтобы работать с сессиями только в админке.
Все-таки одно из главных преимуществ Джанго, и питона вообще - что можно взять класс, сделать наследника, переопределив один-два метода, и получить ту функциональность, которая нужна. Правда, иногда бывает непросто это все подвязать обратно, но за все надо платить :)
Мне очень понравился дефолтный механизм хранения сессий, реализованный в Rails 2.0
Все данные хранятся в пользовательских куках. Там же хранится контрольна сумма от них, полученная с использованием хранящегося на сервере секретного ключа. ИМХО, этот подход разом решает все любые проблемы стабильности и производительности, связанные с сессиями. Никаких серверных ресурсов, кроме CPU не потребляется, так же нет необходиомсти организовывать параллельный доступ к сессиям в кластере веб-серверов.
Иван, я только что попытался запостить довольно длинный текст и забыл ввести email. Wordpress ругнулся, но формы для постинга уже не было. Кнопка "взад" привела к очистке формы. Обидно.
Вкратце о чем я писал: попробуйте сделать тестовый сервер на PostgreSQL. Сделайте ему гигабайтный кэш, ежеполучасный vacuum и vacuum analyze каждые 6 часов. Погоняйте его в стрессовом режиме пару дней и вы поймете, что скрипач, т.е. MySQL - не нужен. Не слушайте воплей про "медленный постгрес" и "быстрый мускуль", просто попробуйте.
Несколько ответов.
По поводу зачем сессии в базе. Да просто потому что это самое простое и очевидное решение. В куки не влезает больше 4K. Кеш в памяти подвержен протуханиям, а значит пользователи будут нежданно-негаданно терять настройки какие-нибудь. Сессии в базе этих недостатков лишены. И ими все пользуются. Потому что в контент-проектах в нормальных условиях, они нагрузки не создают вообще никакой, и нет смысла ее оптимизировать. У нас все упало, не потому что "сессии в базе", а потому что сложились три фактора: InnoDB, постоянный поток новых пользователей, и новая сессия на каждый GET.
На самом деле, более быстрые сессии иногда нужны. Если у вас очень посещаемый интернет-магазин, например. Там сессии обычно нужно подновлять на каждый запрос, и они не постоянные, а живут часа два. Тогда кеш — замечательный вариант. В Джанге, кстати, есть кеш-бэкенд для сессий.
По поводу PostgreSQL. Поверьте, я бы с радостью :-). Мне он тоже нравится. Но Яндекс давно (и успешно) работает с MySQL. Все админы умеют "готовить" именно его. Другая БД только внесет дополнительную инфраструктурную нагрузку, причем без каких-либо выходных гарантий. Наверное смысл использовать PostgreSQL был бы, если бы лично я а) умел бы им заниматься и б) хотел бы тратить на это время. Ни то, ни другое не верно, поэтому я просто полагаюсь на выбор наших админов, что им удобней.
Это если у юзера кука есть. А если нет, то и лазить незачем. Если кука есть, то да, делается select. Но и это не insert.
Мы решили по-другому сделать: просто унести админку на отдельную машину, а на боевом кластере вообще отключить ее из настроек.
Прошу прощения. Тут я не властен над ситуацией пока, к сожалению.
О нет, конечно нет. Наверное Pownce самый нагруженный. Ну или один из самых. "Куда" пока вообще никак не нагруженный: 12 человек идущих на событие — это у нас много :-). Вот допишем функционал немножко, подрекламируемся, тогда можно будет думать про "самость".
А почему сообщения пользователям не хранили сразу в стандартном джанговском messages? Или требовалось слать сообщения анонимам тоже?
А, это хороший вопрос, да. Просто у нас используется яндексовая авторизация, которая по разным причинам никак не связана с джанговской, поэтому и сообщения пришлось сделать свои. Впрочем, "сделать свои" — это неверно. Мы просто писали их в сессию, и все, это даже никакой подсистемой назвать нельзя. Понадобятся, вернем их обратно, просто перед той "строчкой убийцей" надо будет поставить "if not messages", и это решит проблему.
По поводу “gog-pile” эффекта. если мне не изменяет память, то для memcached не так давно появилась возможность помечать ключ как уже обновляемую и все остальные запросы используя эту отметку смогут не дергать генерацию.
Тоже хотел спросить про “gog-pile” эффекта, а обновлять кеш только при изменении данных не как нельзя?
денормализация денормализации рознь
вот прямо берете и дублируете поля в таблицах? и пишете это все в базу два раза? или все таки есть ограничения?
хотелось бы послушать как еще от джойов надо избавляться, у меня в голове чтото ничего путнего кроме выборки в массив и потом длинных WHERE IN () с пробеганием по рекордсетам, не рождается
Нет, дублирования полей у нас нет. Самая частая денормализация — запись в виде полей предрассчитанных агрегатных значений.
Конкретный пример. Есть четрые таблицы:
Нужно сделать выборку событий и отсортировать по количеству идущих на них людей. В классическом подходе это выливается в join этих четырех таблиц, чтобы собрать всех пользователей и связать их с событиями. На join делается group by по id события и count() по всему остальному. Это очень тяжелый запрос, потому что таблица связи пользователей и сеансов — очевидно самая длинная в базе.
Денормализация здесь заключается в том, что в таблицу сеансов добавлено поле с количеством идущих туда людей, которое обновляется при каждом присоединении или отказе человека идти на сеанс. Соответственно, можно делать join только двух таблиц — событий и сеансов — и суммировать это количество с группировкой по событиям.
Сразу видно, как это оптимизировать еще дальше при желании: завести поле количества идущих и в событии тоже, и при присоединениях/отсоединениях обновлять и его тоже. Тогда в выборке останется одна таблица, исчезнет group by и все будет вообще быстро.
Кстати сказать, именно из-за этого хранения агрегатных значений как воздух нужны транзакции. Они исключают ситуации, когда исходные данные успешно поменялись, а агрегатное значение на их основе почему-то не обновилось.
А зачем в таблице сессий первичный ключ не автоинкрементное целое, а случайный хэш?
Ключ сессии хранится в куке пользователя, по которой он и идентифицируется. Ключ должен быть случайным, чтобы никто не смог представиться другим человеком просто подставляя по порядку числа себе в куку.
Вообще, наверное можно в таблице сессий действительно сделать и автоинкрементный ключ, а идентификатор сессии ключом не делать, и тогда все будет быстро. Просто на такие грабли до сих пор никто не наступал, вот и нужды не было.
Можно класть в куку идентификатор сессии и случайный ключ, md5 которого хранится в таблице сессий.
При чтении проверять сначала существование сессии с идентификатором, потом соответствие хэша ключу.
Это будет затягивать чтение из таблицы, но для вас же критична вставка.
Я примерно так и имел в виду. Проблема только в том, что придется делать это вручную, а не пользоваться тем, что в Джанго уже и так работает. Поэтому мы остановились на просто преобразовании таблицы в MyISAM.
это же классическая ситуация.
и почему-то очень много народу на нее попадает.
читайте доки о том как база хранит записи, обязательно !
самый нагруженный - это curse.com
Эта история напомнила мне о взрыве америкосовского космического шатла.
После его взрыва специалисты долго искали причину взрыва и когда она была обнаружена, у них была примерно такая же рекция как и у вас ;)
Дело было в маленькой програмке, которая отсчитывала время до запуска шатла. После старта шатла эта прога продолжала свою уже никому ненужную работу. Во время набора высоты прога вызвала ошибку, типа переполнения стека (точно не помню), эта ошибка в свою очередь вызвала сбой в системе стабилизации корабля. Чтобы вернуть шатл в казалось бы потерянное вертикальное положение, компьютер изменил угол направления турбин, что в свою очередь вывело шатл из вертикального положения и он в последствии взорвался....
Казалось бы такая мелочь как счетчик, а последствия катострофальные...
Хм. Неужели в яндекс отсутствуют стресс и лоад тесты?
Почему? Конечно присутствуют. Другое дело, что тестовый стенд отличается все же от боевого кластера и тестовые данные отличаются от реальных пользователей. Все узкие места, которых мы ожидали, мы, вроде, оттюнили. Но пролемы-то как раз возникают там, где их не ожидают :-).
У меня есть рабочая теория, что мы просто недогрузили базу несколькими фронтендами и до такого эффекта не дожили. Плюс, кажется, таблица сессий во время стрельб не дорастала до того размера, когда начинала реально тормозить.
Иван,
Ну как же так? :)
Помню, ещё во время нашей работы в ТелефонюРу, у тебя была замечательная программа [HyperStress] собственного написания, которая должна была найти описанную проблему.
Не поверю, что ты не проводил стресс тестирования своего детища не на тестовом кластере.
Неужели стресс тестирование live сервера не вскрыло описанной проблемы?
PS: Сейчас сам нахожусь на этапе выпуска проекта на рынок, очень интересно как найти "строчку-убийцу" до "появления тизера" :)
Раз в комментариях столько знатоков БД, то какие можете посоветовать книжки, по оптимизации работы с ней. Интересуют как настройки базы (в меньшей степени), так и запросы может что-то в эту тему по устройству БД. Предполагается использовать MySQL. Мои знания в этой области можно оценить как ооочень новичковые, знаю базовый SQL, немного про индексы и слова "sargable/non-sargable" и "execution plan" :)
Спасибо!
Да, была такая программка :-). Их вообще таких много, в Яндексе она, например, называется "Танк Т-34" или типа того :-).
Но стрельбы по боевым серверам действительно не проводились. По той банальной причине, что на них работают другие проекты. То есть этот кластер выделен не специально для "Куда", мы туда просто селимся и все.
Про "как найти строчку-убийцу" могу посоветовать только то, что надо таки стараться сделать тестовый стенд максимально похожим на боевой и имитировать запросы максимально похожими на реальные.
Мы давно с траблой сессий столкнулись. В итоге переписали middleware, чтобы хранил сессии в файлах. Раскинутых по дирректориям первых букв хэша (чтоб FS количеством файлов не напрягалась). В итоге никаких проблем с записью, ну и нет лишней нагрузки на БД.
Иван, некто Джаред недавно выпустил скрипт кэширования, о котором уже многим известно. Возможно что некоторые наработки оттуда тоже можно применить.
Я его немножко модифицировал, чтобы он был более гибким, правда все это работает в связке с nginx-ом, а у вас там lighttpd
Речь, видимо, об этой штуке: http://superjared.com/projects/static-generator/? Да, я ее видел. Но как там в конце страницы и написано, это для нашего проекта не подходит: у нас практически нет страниц, которые можно кешировать целиком. Да и такая большая скорость пока просто не нужна.
Если у них оно хранится в БД, а не LDAP или еще чем-либо — можете рассказать, как решали проблему с тем, что Django не умеет по-человечески общаться более чем с одной базой? Возможно, есть какое-то красивое решение?
Авторизация, в общем-то, никак не связана с умением или неумением общаться с несколькими базами. Яндексовая авторизация делается у нас через HTTP-запрос к серверу авторизации. Если бы там была БД, то ничто не помешало бы коннектиться туда стандартными питоновскими средствами. Ведь совершенно не обязательно пользоваться для этого джанговским ORM'ом.
Михаил:
Да, был такой случай. Но не с шаттлом, а с европейской ракетой Ariane 5.
Особо ценный комментарий
По поводу innodb и таблицы сессий. Если проблема с рандомным праймари ключем мешает, то просто добавьте поле fake_id INT UNSIGNED auto_increment и сделайте его праймари-ключем. Ваш же session_id (или как там его зовут) сделайте просто unique key и будет счастье.
http://www.webscalingblog.com/rails/optimized-version-of-rails-mysql-session-store-is-available.html - я вот тут немного описывал проблемы и варианты решения связанные с сессиями в рельсах. Поглядите - может что-то интересное будет. Насчет myisam - не делайте этого! Там table lock yа каждую вставку и, что еще хуже, на каждое удаление. Результат - при ростущем трафике таблица сессий лочится и наступает коллапс.
Лично я на данный момент вижу идеальным хранилищем сессий mysql 5.1 с auto_increment + crc32(session_id) и отбрасыванием пустых сессий при записи.
Вообще, да, тоже плохо. Спасибо за напоминание про table lock :-). Я вот все больше думаю теперь про файловое хранилище. Благо в Джанго оно есть уже написанное.
Недавно просматривал тесты как раз по хранению сессий в рельсах - файловое хранилище не рекомендовано для использование как самое тормозное, особенно при возрастании нагрузок.
Иногда сбои решаются хорошим и крепким пинком, а иногда подробным изучением и полным шоком после установления причны, мне больше нравится первый способ ))