Недавнее обнаружение
Из всего, что я пробовал, я заменил свой профиль JMeter на пользовательский JavaScript, который поочередно поразил каждую из моих конечных точек API в бесконечном l oop, а затем запустил этот скрипт параллельно в разных браузерах (один Firefox, один Chrome, один Safari) - чтобы попытаться исключить проблемы, связанные со всеми моими соединениями, исходящими из одного и того же источника (один и тот же пользовательский агент, одни и те же файлы cookie, один и тот же идентификатор сеанса и т. д. c)
Когда я это сделал, я заметил, что все мои проблемы исчезли. Запросы выполнялись параллельно, и приложение было намного более отзывчивым, чем JMeter, по вашему мнению,
Мне кажется невозможным, что JMeter будет сериализовать запросы, поскольку это де-факто стандарт для нагрузочного тестирования. Поэтому я начал пытаться воспроизвести поведение
В попытке воссоздать JMeter я создал следующие два PHP сценария, которые (надеюсь) имитировали мое приложение Yii:
медленно. php
<?php
session_start();
$_SESSION['some'] = 'value';
// Yii is calling session_write_close() almost immediately after
// the session is initialized, but to try and exacerbate issues,
// I've commented it out:
// session_write_close();
$dsn = "mysql:host=localhost;dbname=platypus;unix_socket=/tmp/mysql.sock";
$pdo = new PDO($dsn, "user", "password");
// Yii was using whatever the default persistence behavior was,
// but to try and exacerbate issues I set this flag:
$pdo->setAttribute(PDO::ATTR_PERSISTENT, true);
// Simulate a query running for 1 second by issuing a 1-second sleep
$pdo->query("DO SLEEP(1)");
echo "Done";
быстро. php
<?php
session_start();
$_SESSION['some'] = 'value';
$dsn = "mysql:host=localhost;dbname=platypus;unix_socket=/tmp/mysql.sock";
$pdo = new PDO($dsn, "user", "password");
$pdo->setAttribute(PDO::ATTR_PERSISTENT, true);
// Simulate a query running for 0.1 seconds
$pdo->query("DO SLEEP(0.1)");
echo "Done";
Бег JMeter против этих двух новых конечных точек не было сериализации запросов. Все бежало параллельно. fast. php всегда возвращаются через 100-150 мс и медленно. php всегда возвращаются через 1000-1050 мс, даже когда я масштабировал до 3, 4 и 5 потоков. Я был в состоянии наблюдать, как все разваливается в 11 потоках, но это потому, что я превысил число рабочих потоков в PHP
Итак, подведем итог:
- Эта проблема возникает только при профилировании моего API с помощью JMeter и не присуща самому приложению
- Проблема не просто в ошибке JMeter, а в том, как она связана на мое приложение или Yii 1.1
- Я пытался, но не смог придумать минимальный случай повторения
Несмотря на то, что при профилировании с другими инструментами не было проблемы, многие люди ответили и дал много полезной информации:
- Избегайте постоянных соединений в PHP ( может вызывать множественные запросы на совместное использование соединения, вероятно, нет)
- Избегать сеанса блокировка путем вызова
session_write_close()
как можно раньше - Убедитесь, что у вас достаточно PHP рабочих потоков для обработки количества одновременных подключений
- MySQL полностью поддерживает параллельные запросы (если оборудование может справиться с этим)
- Остерегайтесь блокировки таблиц (любая транзакция с оператором
UPDATE
может потенциально заблокировать таблицы) - MyISAM выполняет блокировку на уровне таблицы вместо блокировки на уровне строки
Original Post
Я унаследовал веб-приложение и пытаюсь разобраться в его профиле производительности, чтобы начать его оптимизацию по скорости.
Одна вещь, которую я довольно рано заметили, что запросы к серверу сериализуются. Например, предположим, что у меня есть три конечные точки с временем отклика, например, так:
/api/endpoint1 --> 50ms
/api/endpoint2 --> 1000ms
/api/endpoint3 --> 100ms
Если я попал в одну конечную точку, я измеряю соответствующее время отклика. Но когда я настраиваю сценарий, чтобы он ударил все 3 сразу, я иногда вижу что-то вроде следующего:
endpoint1: 50ms
endpoint2: 1050ms
endpoint3: 1150ms
Ясно, что вызов к конечной точке 3 был поставлен в очередь и ждал, пока вызов к конечной точке 2 завершится sh до того, как он получил ответ.
Моя первая мысль была о том, что это должно быть тривиально решено с помощью многопоточности, поэтому я взглянул на конфигурацию сервера. Диспетчер процессов PHP -FPM был установлен на «Dynami c» с «start_servers», равным 1, «max_children», равным 5, и «max_spare_servers», равным 2. Для целей тестирования я поменял его на «stati c», поэтому что 5 PHP процессов останутся открытыми для параллельной обработки соединений (больше, чем 3 для числа конечных точек, которые я выполняю, поэтому они должны иметь возможность обрабатывать одновременно)
Это не повлияло на производительность, поэтому я посмотрел на мой nginx конфиг. «worker_processes» был установлен в 1 с «worker_connections», установленным в 1024. Я знаю, что nginx использует модель события l oop, поэтому он не должен блокироваться, пока он ждет ответа от PHP -FPM. Но на всякий случай , я увеличил "worker_processes" до 5
Тем не менее, никакого эффекта. Итак, затем я посмотрел на базу данных. Все 3 конечные точки должны были попасть в базу данных, и я знаю, что время отклика в 1000 мс в основном тратится на ожидание долгосрочного запроса к базе данных. Я попытался установить "thread_pool-size" на 5, а также в MySQL REPL, который я установил "innodb_parallel_read_threads" и "mysqlx_min_worker_threads" на 5
Тем не менее, мои запросы были сериализованы. Когда я вошел в систему MySQL REPL и набрал show processlist;
во время работы моего скрипта (используя значение true * l oop для многократного попадания в эти 3 конечные точки API), я заметил, что к веб-приложению всегда было только одно соединение user
К сожалению, я не уверен, что моя проблема связана с базой данных (не допускается более одного подключения), с PHP -FPM (не обрабатывается более одного запроса одновременно) или с nginx (не пересылать более одного запроса одновременно PHP -FPM). Я также не уверен, как выяснить, какой из них действует как узкое место
Обновление
Оглядываясь назад, я обнаружил этот пост , который, кажется, предполагает, что MySQL не поддерживает параллельные запросы от одного и того же пользователя (например, от пользователя веб-приложения)
Это правда? Конечно, такой вездесущий механизм баз данных не будет иметь такого недостатка в производительности, особенно учитывая, как часто он используется с AWS для масштабируемых приложений. Я понимаю, что для простых запросов «чтение с диска» распараллеливание их запросов не повысит производительность, поскольку им просто придется стоять в очереди в ожидании дискового ввода-вывода, но современные базы данных имеют кэш-память в памяти, и большинство действительно медленные операции, такие как сортировка файлов, обычно происходят в памяти. Нет причин, по которым связанный с диском запрос не может выполняться параллельно (сделать запрос на диск и начать ожидание ввода-вывода), пока связанный с процессором запрос занят сортировкой таблицы в ОЗУ. Переключение контекста может немного замедлить связанные с процессором запросы, но если замедление их с 1000 мс до 1200 мс означает, что мой 5 мс запрос может быть выполнен за 5 мс, думаю, это того стоит.
Мои запросы
Вот запросы для моих 3 конечных точек. Обратите внимание, что перечисленные временные интервалы - это время отклика для полного конвейера HTTP (от запроса браузера к ответу), так что это включает в себя служебные данные от nginx и PHP плюс любые пост- обработка запроса выполнена в PHP. Тем не менее, запрос в конечной точке 2 составляет 99% времени выполнения и блокирует базу данных, так что конечные точки 1 и 3 ставятся в очередь вместо быстрого возврата.
конечная точка1 (50 мс)
SELECT * FROM Widget WHERE id = 1 LIMIT 1
(Обратите внимание, что 50 мс - это полное время ответа для конечной точки, а не время выполнения запроса. Этот запрос явно имеет порядок микросекунд)
endpoint2 ( 1000 мс)
USE platypus;
SELECT `t`.`(49 fields)` AS `t0_cX`,
`site`.`(29 fields)` AS `t2_cX`,
`customer`.`(26 fields)` AS `t4_cX`,
`domain`.`(20 fields)` AS `t6_c0`,
`domain-general_settings`.`(18 fields)` AS `t8_cX`,
`domain-access_settings`.`(17 fields)` AS `t9_cX`,
`customer-general_settings`.`(18 fields)` AS `t10_cX`,
`customer-access_settings`.`(17 fields)` AS `t11_cX`,
`site-general_settings`.`(18 fields)` AS `t12_cX`,
`site-access_settings`.`(17 fields)` AS `t13_cX`,
`backup_broadcast`.`(49 fields)` AS `t14_cX`,
`playlists`.`(11 fields)` AS `t16_cX`,
`section`.`(10 fields)` AS `t17_cX`,
`video`.`(16 fields)` AS `t18_cX`,
`general_settings`.`(18 fields)` AS `t19_cX`,
`access_settings`.`(17 fields)` AS `t20_cX`,
FROM `broadcast` `t`
LEFT OUTER JOIN `site` `site`
ON ( `t`.`site_id` = `site`.`id` )
LEFT OUTER JOIN `customer` `customer`
ON ( `site`.`customer_id` = `customer`.`id` )
LEFT OUTER JOIN `domain` `domain`
ON ( `customer`.`domain_id` = `domain`.`id` )
LEFT OUTER JOIN `generalsettings` `domain-general_settings`
ON ( `domain`.`general_settings_id` =
`domain-general_settings`.`id` )
LEFT OUTER JOIN `accesssettings` `domain-access_settings`
ON
( `domain`.`access_settings_id` = `domain-access_settings`.`id` )
LEFT OUTER JOIN `generalsettings` `customer-general_settings`
ON ( `customer`.`general_settings_id` =
`customer-general_settings`.`id` )
LEFT OUTER JOIN `accesssettings` `customer-access_settings`
ON ( `customer`.`access_settings_id` =
`customer-access_settings`.`id` )
LEFT OUTER JOIN `generalsettings` `site-general_settings`
ON ( `site`.`general_settings_id` =
`site-general_settings`.`id` )
LEFT OUTER JOIN `accesssettings` `site-access_settings`
ON ( `site`.`access_settings_id` =
`site-access_settings`.`id` )
LEFT OUTER JOIN `broadcast` `backup_broadcast`
ON ( `t`.`backup_broadcast_id` = `backup_broadcast`.`id` )
AND ( backup_broadcast.deletion IS NULL )
LEFT OUTER JOIN `playlist_broadcast` `playlists_playlists`
ON ( `t`.`id` = `playlists_playlists`.`broadcast_id` )
LEFT OUTER JOIN `playlist` `playlists`
ON
( `playlists`.`id` = `playlists_playlists`.`playlist_id` )
LEFT OUTER JOIN `section` `section`
ON ( `t`.`section_id` = `section`.`id` )
LEFT OUTER JOIN `video` `video`
ON ( `t`.`video_id` = `video`.`id` )
AND ( video.deletion IS NULL )
LEFT OUTER JOIN `generalsettings` `general_settings`
ON ( `t`.`general_settings_id` = `general_settings`.`id` )
LEFT OUTER JOIN `accesssettings` `access_settings`
ON ( `t`.`access_settings_id` = `access_settings`.`id` )
WHERE
(
(
t.id IN (
SELECT `broadcast`.id FROM broadcast
LEFT JOIN `mediashare` `shares`
ON ( `shares`.`media_id` = `broadcast`.`id` )
AND `shares`.media_type = 'Broadcast'
WHERE
(
(
broadcast.site_id IN(
'489', '488', '253', '1083', '407'
)
OR
shares.site_id IN(
'489', '488', '253', '1083', '407'
)
)
)
)
)
AND
(
(
(
(t.deletion IS NULL)
)
)
AND
(
IF(
t.backup_mode IS NULL,
t.status,
IF(
t.backup_mode = 'broadcast',
backup_broadcast.status,
IF(
t.backup_mode = 'embed',
IF(
t.backup_embed_status,
t.backup_embed_status,
IF(
'2020-01-08 16:34:52' < t.date,
1,
IF(
t.date > Date_sub(
'2020-01-08 16:34:52',
INTERVAL IF(t.expected_duration IS NULL, 10800, t.expected_duration) second
),
10,
12
)
)
),
t.status
)
)
) != 0
)
)
)
LIMIT 10;
Этот запрос выполняется приблизительно 1000 мс, но PHP для конечной точки чрезвычайно прост (запустите запрос, верните результаты как JSON) и добавьте только пара миллисекунд служебных данных
конечная точка 3 (100 мс)
SELECT * FROM platypus.Broadcast
WHERE deletion IS NULL
AND site_id IN (SELECT id FROM platypus.Site
WHERE deletion IS NULL
AND customer_id = 7);
Здесь есть дополнительная проверка на стороне PHP, которая заставляет эту конечную точку принимать 100 мс. SQL, как вы можете видеть, все еще довольно прост.
Создание операторов таблицы
Поскольку в StackOverflow существует ограничение длины записи, я не могу показать CREATE TABLE для каждой таблицы. коснулся конечной точки 2, но я могу показать хотя бы одну таблицу. Другие используют тот же механизм.
CREATE TABLE `Widget` (
`id` int unsigned NOT NULL AUTO_INCREMENT,
`widget_name` varchar(255) NOT NULL,
`widget_description` varchar(255) NOT NULL,
`status` varchar(255) NOT NULL,
`date_created` datetime NOT NULL,
`date_modified` datetime NOT NULL,
`auto_play` varchar(255) NOT NULL,
`on_load_show` varchar(255) NOT NULL,
`widget_content_source` varchar(255) NOT NULL,
`associated_sites` text NOT NULL,
`author_id` int NOT NULL,
`associated_sections` text,
`after_date` datetime DEFAULT NULL,
`before_date` datetime DEFAULT NULL,
`show_playlists` varchar(255) CHARACTER SET utf8 COLLATE utf8_bin NOT NULL,
`is_classic` tinyint(1) NOT NULL,
`default_site` int unsigned DEFAULT NULL,
`auth_code_url` varchar(255) DEFAULT NULL,
`widget_layout_id` int unsigned DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `fk_Widget_widget_layout_id_WidgetLayout_id` (`widget_layout_id`),
CONSTRAINT `fk_Widget_widget_layout_id_WidgetLayout_id` FOREIGN KEY (`widget_layout_id`) REFERENCES `WidgetLayout` (`id`) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB AUTO_INCREMENT=1412 DEFAULT CHARSET=utf8
Примечание
Обратите внимание, что конечная точка 2 даже не касается таблицы виджетов, но конечная точка 1 (которая касается ТОЛЬКО таблицы виджетов) также ставится в очередь , Это исключает возможность блокировки таблицы.
При просмотре списка процессов в MySQL пользователь базы данных устанавливает только одно соединение с базой данных. Следовательно, проблема может быть l ie в моей конфигурации PHP.
Объяснение для запроса 2
Прилагается запрос EXPLAIN SELECT ...
для конечной точки 2
Упрощенные эксперименты
Чтобы попытаться определить, где разваливается параллельный конвейер, я создал два простых сценария:
сон. php
<?php
sleep(5);
echo "Done sleeping";
возвращение. php
<?php
echo "That's all";
Делать это (спать в PHP) и Выполнение моего сценария, чтобы поразить эти две конечные точки с помощью трех потоков, я не увидел никаких проблем. возврат. php всегда возвращался через ~ 11 миллисекунд, несмотря на сон. php, принимая в среднем 5066. Затем я попытался сделать сон в MySQL:
сон. php
<?php
$pdo = new PDO("...", "user", "pass");
$pdo->query("DO SLEEP(5)");
echo "Done sleeping";
Это, снова , не было никаких проблем , Спящая конечная точка не блокировала не спящую.
Это означает, что проблема не существует на уровне nginx, PHP или PDO, но существует должен быть какой-то блокировкой таблицы или строки. Я собираюсь снова включить общий журнал запросов и сканировать каждый запрос, который выполняется, чтобы увидеть, смогу ли я выяснить, что происходит.
Окончательное обновление
Если Вы прокрутите вверх до «Недавнего обнаружения» в верхней части этого поста, и вы заметите, что я изменил свое понимание проблемы.
У меня не было проблемы с распараллеливанием, но с JMeter. Мне не удалось создать простой случай воспроизведения, но теперь я знаю, что проблема связана не с моим приложением, а с тем, как я его профилирую.