Каталог на 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,59490
updateTyreField0,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С идёт часами?

Пришлите сайт и лог — найду обработчик, который платит за весь каталог на каждом товаре.

· полезно?
АЧ
Алексей Чикун веб-разработчик, команда TRIOHEAD

Делаю сайты и сервисы целиком: от вёрстки до сервера. Пишу о том, что проверил на своих проектах.

Комментарии

// загружаю комментарии…

Комментарии публикуются после проверки. Ссылки и HTML не принимаются.