Простенький нагрузочный тест
Tuesday, 11 May 2010
Как я уже говорил, нагрузочное тестирование это весьма полезный инструмент в руках разработчика, ибо позволяет разобраться с проблемами производительности до вывода в релиз, а не после. Сегодня разберем простой пример нагрузочного тестирования для процессинга.
Поиск инструмента
Если бы речь не шла о процессинге, то тут и говорить было бы не о чем – товарищ msyu довольно подробно расписал как из access.log-а сделать нагрузочный тест. Да и по интернету валяется просто таки туча утилит, которые позволяют нагрузить сервер так, что мало не покажется.
Но у нас ведь особый случай – речь идет о POST запросах!!! И вот тут я впал в ступор на неприлично долгий период гугленья. Некоторые утилиты таки поддерживают пост-запросы, но как-то кривовато.
Например тот же apache bench позволяет брать post-данные из файла, но при этом сами данные для всех запросов одинаковые, как, впрочем, и урлы. Был еще pylot, написанный на питоне, который умел корректно досить пост запросами, но с ним как-то не сложилось. Да и весьма простенькая утилита, не в пример JMeter.
А все время пока гуглил и выспрашивал у знакомых, моя русская натура не давала мне покоя. Внутренний голос постоянно свербил “ну что ты паришься, неужели так сложно написать простенький скрипт отсылки пост запросов с данными из файла”.Но вдоволь нахлебавшись самописных утилит после 30-ти лет понимаешь что лучше потратить 10 часов на освоение чужого инструмента, чем полчаса на написание своей утилиты. Потому и гнал прочь мысли о создании самописной утилиты.
Наконец, когда мое терпение лопнуло, я в очередной раз набрел на статью Андрея Жукова и наконец-то прозрел. Я давно обнаружил в JMeter возможность создания одиночного POST запроса, но мне и в голову не приходило что этот инструмент настолько мощный, что позволяет вместо непосредственных значений указывать имена переменных, значения которых будут читаться из csv файла. За это – огромнейшее спасибо Андрею.
Генерация тестовых данных
Теперь можно вернуться к нашему тесту, а попутно я расскажу как я применил полученные знания. Вообще, первое что сделал, это написал такой скрипт для генерации тестовых данных:
import random, datetime terminal_count = 1000 terminal_transaction_count = 100 card_count = 300000 fuel_count = 7 amount_limit = 70 file = open("requests.csv","w") for transaction_id in xrange(1,terminal_count*transaction_count+1): terminal_id = random.randint( 1, terminal_count ) card_id = random.randint( 1, card_count ) fuel_id = random.randint( 1, fuel_count ) amount = random.randint( 1, amount_limit ) file.write("%d,%d,%d,%d" % ( terminal_id, card_id, fuel_id, amount ) ) file.close()
Немного коментариев к написанному коду. Константы terminal_count и terminal_transaction_count олицетворяют соответствующее требование ака “не менее 100 транзакций в сутки с каждого из 1000 терминалов”. Ну и чтобы Тут все банально, а вот далее начинается ловкость рук.
Но сгенерировав такой набор данных, мы получили банальный ддос, который мало соответствует реальности. А не соответствие реальности грозит бесполезностью тестирования, потому давайте подумаем чего тут не хватает:
1. Должны быть возвраты, скажем каждая 100-я операция.
2. Должны быть проблемы с нехваткой средств, скажем каждая 30-я операция. Пришел заправиться, а на карте всего 2 литра осталось. Естественно предоплата на 10 литров завершится неудачей.
3. Должны быть проблемы с кошелями. Хочу заправиться 92-ым, а на карточке только 95-й. Процессинг должен отвергнуть операцию сообщив о причине. Таких операций – каждая 50-я.
4. Должны быть карточки, которых вообще нет в базе. Каждая 200-я операция.
Теперь давайте думать над реализацией. Ну с пунктом 1 все просто – добавили проверку остатка от деления на 100 и можем писать возвраты. А вот как сделать так, чтобы мы знали что в базе гарантированно нет средств… Ну не залазить же в базу, чтобы узнать какой там текущий остаток с учетом всех нагенеренных транзакций.
Инженерная мысль подсказала решение в обход – можно списывать по литру-двум для тех транзакций, которые гарантированно должны пройти, и по 10000 – для тех, которые должны завершиться отказом. А чтобы первые прошли – добавим клиентам на каждый счет по 100 литров. Таким образом перед тестами базу придется соответствующим образом обновлять.
С третьим пунктом тоже своеобразная засада – гарантировать отсутствие кошеля тривиально, достаточно задать номер больше 8-ми, таких в базе ни у кого нет. А как гарантировать? Не хотелось бы лезть в базу чтобы выяснить у кого какие кошели – это усложнит генерацию теста. Но и добавлять всем какой-то кошель – тоже не выход, ибо это переводит тест из разряда реальных в синтетические.
А как все хорошо начиналось – простой тест, для простого интерфейса. Вот оно, отличие теории от практики.
Поразмышляв некоторое время, пришел к выводу что для актуальных тестов нужны данные из базы, а их можно получить двумя способами – либо при генерации начальных данных, либо пост-фактум дернув нужные данные из инициализированной базы.
Первый вариант мне не понравился. С одной стороны тестовые запросы и тестовые данные логично было бы засунуть в один скрипт, а вот с другой – хотелось бы меньше связности, даже если учесть что генерация данных выполняется за 5 минут.
SELECT cf.card_id, c.customer_id, cf.amount FROM cardfuel cf INNER JOIN card c ORDER BY rand() LIMIT 100000;
Такой запрос первым делом мне рассказал о следующей ошибке:
ERROR 126 (HY000): Incorrect key file for table 'C:\Windows\TEMP\#sql7bc_1_3.MYI '; try to repair it
Причина оказалась банальной более некуда – order by rand требовал создания временной таблички, и на диске C у меня тупо не хватило места. Я освободил еще 6 гигабайт, но и их не хватило. Чтение мануалов не добавило понимания, потому я перекинул temp каталог на диск D, и с ужасом увидел что мускул таки сожрал 31GB свободного места, правда на этот раз он не ругнулся и уверенно продолжал что-то считать.
Оставим этот баг на совести алгоритмистов мускула, ибо выполнения запроса я так и не дождался. Вернемся к протоколу, в котором customer_id не нужен, а стало быть достаточно запроса без джойна.
SELECT card_id, fuel_id, amount FROM cardfuel ORDER BY rand() LIMIT 100000;
Результат был получен в мгновение ока. Ну значит на этом запросе и остановимся. Получается теперь прийдется связать выборку из базы с питон скриптом и всеми условиями, которые расписаны выше.
import random, datetime, MySQLdb terminal_count = 1000 terminal_transaction_count = 100 card_count = 300000 fuel_count = 7 amount_limit = 70 file = open("requests.csv","w") db = MySQLdb.connect(host="localhost", user=options.user, passwd=options.password, db=options.database) cards = db.cursor() cards.execute("select card_id, fuel_id, amount from cardfuel order by rand() limit %d" % ( self.terminal_count*self.transaction_count ) ) counter = 0 for transaction_id in xrange(1,terminal_count*transaction_count+1): counter = counter + 1 terminal_id = random.randint( 1, self.terminal_count ) card = cards.fetchone() card_id = card[0] fuel_id = card[1] amount = card[2] if counter % 200 == 0: # Отсутствие карточки в базе - каждый 200-й запрос file.write("withdraw,%d,%d,%d,%d" % ( terminal_id, card_id + 1000000, fuel_id, 1 ) ) elif counter % 100 == 0: # Возврат средств - каждый 100-й запрос file.write("payback,%d,%d,%d,%d" % ( terminal_id, card_id + 1000000, fuel_id, 1 ) ) elif counter % 50 == 0: # Отсутствие нужного кошеля - каждый 50-й запрос file.write("withdraw,%d,%d,%d,%d" % ( terminal_id, card_id, fuel_id + 100, 1 ) elif counter % 30 == 0: # Недостача средств - каждый 30-й запрос file.write("withdraw,%d,%d,%d,%d" % ( terminal_id, card_id, fuel_id, amount + 10000 ) else: # Нормальные запросы file.write("withdraw,%d,%d,%d,%d" % ( terminal_id, card_id, fuel_id, random.randint(1,2) ) db.close() file.close()
Ну вот, в весьма упрощенном варианте код для генерации данных для запросов готов. Можно заняться созданием теста.
Создание теста
Чтобы получить требуемое, нам нужно создать поток, создать ридер данных из csv и создать запрос. Все делается как нельзя проще:
- Создаем CSV Data Set Config
- Создаем поток
- Добавлем в поток следующие listener-ы: “View Resuls in Table”, “Graph Results” и “Aggregate report”.
- Добавляем в поток “Logic Controller \ Loop Controller”
- Добавляем в loop controller “Samplers \ Http Request”
Как делается каждый из шагов скриншотить не буду – в тех двух статьях, на которые я ссылаюсь, картинок предостаточно. А вот настройки ридера и семплера – это пожалуйста.
Вот так выглядит ридер из csv:
А вот так выглядит сам HTTP Post запрос:
Ничего сложного, разработчики хорошо потрудились над JMeter. Теперь заходим в поток, ставим там 10 потоков, и loop count = forever, после чего жмем run и … и выкатываем глаза из орбит. Дык а что вы хотели, тюнинг еще никто не проводил.
Но!!! Теперь у нас есть генератор запросов и мы можем воспользоваться различным инструментарием, от мониторилок серверов и до самописного профайлера, который поможет нам найти узкие места в нашей программе. Кроме этого, мы наглядно будем видеть к чему приводять наши изменения, запуская тест после каждой доработки.
А еще, мы можем проверять работу нашего сервиса на различном количество потоков ( одновременных запросов ), но тут следует учесть тот факт, что для серьезного количества запросов потребуется несколько машин, ибо те же 100 потоков увалили мою машинку, которую я не считаю слабой.
На этом пожалуй все. Заметки по нюансам профилирования и тюнинга как нибудь в следующий раз.

