Каталог на WordPress получает товары из учётной системы по API: один запрос — один товар. Полный обмен из 293 товаров шёл около 25 минут — 12 товаров в минуту, запись одного товара в среднем 5 секунд. Сам WordPress тут ни при чём: на каждом сохранении тема обходила весь каталог. После лечения стало 104 товара в минуту и 0,56 секунды на запись.
Это первая часть цикла «Массовый импорт в WordPress». Он о том, что ломается, когда записи создаёт и обновляет не редактор руками, а программа сотнями: скорость, поля ACF, картинки, корзина. Примеры — из обмена с 1С, но те же грабли ждут любой импорт из CSV, фида или чужого API.
Шаг 1. Найти медленные запросы по логу nginx
Медленный лог PHP-FPM оказался бесполезен: он писался в файл с правами root и оказался пуст. Время ответа есть в логе nginx, если оно добавлено в формат записи (rt=$request_time). Если его нет — добавьте, это пригодится не раз.
docker logs --since 60m site-nginx-1 2>&1 | awk '
match($0, /rt=[0-9.]+/) { rt = substr($0, RSTART+3, RLENGTH-3) + 0
if (rt >= 2) print rt, $0 }' | sort -rn | head -20
Наверху списка — сплошь POST /wp-json/1c/products/<id> по 5–8 секунд. Медленным было именно сохранение товара.
Шаг 2. Разложить сохранение по хукам
При сохранении записи WordPress вызывает всё, что висит на save_post, — у темы и у плагинов. Вместо догадок можно вызвать каждый обработчик по отдельности и замерить время и число запросов к базе. Делать это только на копии сайта: обработчики по-настоящему пишут в базу.
define('DISABLE_WP_CRON', true);
require '/var/www/wp-load.php';
global $wp_filter, $wpdb;
$id = (int) $argv[1];
$post = get_post($id);
foreach ($wp_filter['save_post']->callbacks as $prio => $list) {
foreach ($list as $cb) {
$f = $cb['function'];
$name = is_string($f) ? $f : (is_array($f) ? (is_object($f[0]) ? get_class($f[0]) : $f[0]) . '::' . $f[1] : 'замыкание');
$q0 = $wpdb->num_queries;
$t0 = microtime(true);
call_user_func_array($f, array_slice([$id, $post, true], 0, (int) $cb['accepted_args']));
printf("%4d %-45s %6.3f с %4d запросов\n", $prio, $name, microtime(true) - $t0, $wpdb->num_queries - $q0);
}
}
Результат на одном товаре:
| Обработчик | Время | Запросов |
|---|---|---|
fitsProduct — совместимость товаров | 4,07 с из 4,59 | 490 |
updateTyreField | 0,485 с | — |
| все остальные | около нуля | — |
Замечание: DISABLE_WP_CRON до загрузки WordPress обязателен. Иначе скрипт-замер разбудит крон и заодно запустит какую-нибудь просроченную боевую задачу.
Причина: обход всего каталога ради одной проверки
У товара есть мета со списком совместимых товаров. Когда товар сохраняется, надо обновить списки у тех, кто на него ссылается. Код делал это в лоб:
$all = get_posts(['post_type' => 'katalog', 'numberposts' => -1, 'meta_key' => 'order', /* … */]);
foreach ($all as $p) {
$fits = get_post_meta($p->ID, 'fitsProduct', true);
if (is_array($fits) && array_key_exists($post_id, $fits)) {
// пересобрать список у этого товара
}
}
То есть каждое сохранение читало мету всего каталога (список был у 246 товаров), а на сохраняемый ссылались 80. Пока товары правят руками по одному, это незаметно. Обмен присылает их сотнями подряд — и каждое сохранение платит за весь каталог.
Лечение: спросить базу, кто ссылается
Мета хранится сериализованным массивом, ключи которого — ID товаров. Ключ 17 в сериализованном виде выглядит как i:17;, и его можно найти в базе одним запросом:
$needle = '%' . $wpdb->esc_like('i:' . (int) $post_id . ';') . '%';
$candidates = $wpdb->get_col($wpdb->prepare(
"SELECT pm.post_id FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE pm.meta_key = 'fitsProduct' AND pm.meta_value LIKE %s
AND p.post_type = 'katalog' AND p.post_status = 'publish' AND p.ID <> %d",
$needle, $post_id
));
foreach ($candidates as $cid) {
$fits = get_post_meta($cid, 'fitsProduct', true);
if (is_array($fits) && array_key_exists($post_id, $fits)) { // решение — как раньше
// …
}
}
Две вещи здесь важнее самого запроса.
LIKE— только предварительный отбор.i:17;может встретиться и в значении, а не в ключе. Поэтому решение принимает прежняя проверкаarray_key_exists: лишний кандидат безвреден, а нужного запрос не пропустит. Перед этим стоит убедиться, что мета везде — сериализованный массив (у нас 246 из 246).- Фильтры старого обхода — буква в букву. В прежнем
get_postsстоялmeta_key => 'order', и из-за него товары без поляorderвообще не попадали в обход — их списки никогда не чистились. Если «заодно» убрать это условие, поменяется поведение, а не только скорость. На втором сайте с той же темой такого фильтра не было — и там его нельзя было добавлять.
Обход сократился, но сохранение всё ещё шло секундами: вторая половина обработчика пересобирала списки у каждого из 80 адресатов, и там сидела причина куда интереснее. О ней — во второй части: как запись в базу внутри цикла отключает кэш запросов WordPress целиком.
Почему это не просто «медленно». Пятисекундная запись держит воркер PHP-FPM. Полный обмен — это 25 минут подряд занятых воркеров, а если в пуле нет таймаута на запрос, один зависший вызов не умрёт никогда (как это положило сайт на 5 часов 40 минут).
Импорт или обмен с 1С идёт часами?
Пришлите сайт и лог — найду обработчик, который платит за весь каталог на каждом товаре.
// загружаю комментарии…