Схема WordPress тримається на трьох таблицях. wp_posts зберігає весь контент незалежно від його природи: звичайний пост, сторінка, вкладення медіабібліотеки, ревізія, пункт меню, перевикористовуваний блок і шаблон блокової теми лежать поруч і розрізняються колонкою post_type. Звідси й назва основного індексу таблиці, type_status_date: він складається з post_type, post_status, post_date, ID, бо майже будь-яка вибірка починається з фільтра за типом і статусом. Через ревізії сотня статей легко перетворюється на десятки тисяч рядків; обмежує це константа WP_POST_REVISIONS.
Усе, що не вписалось у фіксований набір колонок wp_posts, іде в wp_postmeta. Це класичний EAV: рядок з post_id, meta_key і meta_value типу longtext. За гнучкість доводиться платити. Індекс є на post_id і на meta_key (з префіксом 191 символ), а на самому значенні його немає, бо longtext без префікса індексувати не можна. Кожна умова в meta_query додає окремий JOIN на ту саму таблицю, а orderby => meta_value_num змушує MySQL робити CAST і сортувати без індексу. На блозі цього не видно. На каталозі в десятки тисяч товарів з фільтрами по ціні й наявності саме тут сторінка починає віддаватись секундами.
wp_options виглядає нудно: option_name з унікальним ключем, option_value типу longtext і колонка autoload. Уся сіль у третій колонці. На старті запиту wp_load_alloptions() виконує один SELECT option_name, option_value FROM wp_options WHERE autoload IN (...), серіалізує результат у масив і кладе в обʼєктний кеш під ключем alloptions. Після цього get_option() для autoload-опції в базу не йде взагалі, а для решти робить окремий запит. У WordPress 6.6 стару пару yes/no замінив набір on, off, auto, auto-on, auto-off: значення auto означає, що рішення приймає ядро, і нові опції більші за 150 КБ автоматично отримують auto-off (поріг змінюється фільтром wp_max_autoloaded_option_size).
Сам механізм дешевий, дорогим його робить сумарний обсяг. Кожен плагін, який поклав туди лог імпорту, кеш перевірки ліцензії чи серіалізований список тисячі товарів, додає байти до блоку, що читається, передається по мережі й розпаковується unserialize() при кожному хіті: на фронтенді, в admin-ajax, у REST, у кроні. Коли сума переходить за 800 КБ, Site Health показує попередження, і це вже відчутні мілісекунди до будь-якої вашої логіки. З персистентним обʼєктним кешем додається ще один ефект: запис у будь-яку autoload-опцію інвалідує ключ alloptions цілком, тому плагін, що пише лічильник на кожному перегляді, гарантує повне перечитування опцій у наступному запиті.
Transients живуть у тій самій таблиці й тому регулярно опиняються в списку винуватців. set_transient('key', $value, 900) створює _transient_key і _transient_timeout_key, обидва з вимкненим autoload. А виклик без третього аргументу створює безстроковий transient з autoload увімкненим, і кеш каталогу на пів мегабайта назавжди оселяється в alloptions. Прострочені transients видаляються при читанні через get_transient() або щоденним кроном delete_expired_transients, тож те, що ніхто більше не читає, лежить у базі до наступного запуску крону. Остання деталь, на якій ловлять: якщо на сайті працює Redis або Memcached, transients зберігаються в обʼєктному кеші, а не в wp_options, тому порада «почистіть transients у базі» там просто нічого не змінює.
global $wpdb;
// Скільки важить те, що читається при КОЖНОМУ запиті до сайту
// (після WP 6.6 значень autoload стало пʼять, старі yes/no лишились у базі)
$bytes = (int) $wpdb->get_var(
"SELECT SUM(LENGTH(option_value)) FROM {$wpdb->options}
WHERE autoload IN ('yes', 'on', 'auto', 'auto-on')"
); // понад 800 КБ і Site Health уже показує попередження
// Хто саме роздув alloptions
$heaviest = $wpdb->get_results(
"SELECT option_name, LENGTH(option_value) AS bytes FROM {$wpdb->options}
WHERE autoload IN ('yes', 'on', 'auto', 'auto-on')
ORDER BY bytes DESC LIMIT 20"
);
// Пастка: без expiration transient створюється з увімкненим autoload
set_transient('shop_top_products', $products); // autoload on
set_transient('shop_top_products', $products, 15 * MINUTE_IN_SECONDS); // autoload off
// Лог імпорту читають раз на тиждень: третій аргумент вимикає autoload
// у ВЖЕ наявної опції; add_option() задає autoload лише при створенні
update_option('myplugin_import_log', $log, false);
// postmeta: перший get_post_meta() тягне всі мета поста одним запитом
$price = get_post_meta($postId, '_price', true);
$sku = get_post_meta($postId, '_sku', true); // вже з кешу, запиту немає
// А ось тут індекс не врятує: meta_value має тип longtext, кожна умова дає JOIN
$query = new WP_Query([
'post_type' => 'product',
'meta_key' => '_price',
'orderby' => 'meta_value_num', // CAST(meta_value AS SIGNED) + filesort
'meta_query' => [['key' => '_stock_status', 'value' => 'instock']],
]);
Назвіть цифру: Site Health попереджає, коли autoload-опції перевищують приблизно 800 КБ, і поясніть, що це десятки мілісекунд на розпакування масиву в кожному запиті, включно з admin-ajax і REST. Одразу покажіть запит, яким ви знайдете двадцять найбільших рядків.