День, когда WordPress подавился метадатой

Сегодняшняя история програмистская. Кому компьютеры скучны, можете смело пропускать.

Как все знают, у меня есть отдельный блог на boku.ru. Записи туда по большей части копируются отсюда – в виде исключения я пару раз выложил там скучные рассказы, чтобы включить их в архив, но не афишировать.

Блог сделан на WordPress. Копирование записей устроено так: специальный скрипт генерирует RSS из дневника на Diary, а плагин FeedWordPress забирает RSS и импортирует в WordPress.

Категории при этом сохраняются, внутренние ссылки мой собственный плагин заменяет на местные, а если пост на дайри изменился – изменяется и пост на boku.ru, так что всё устроено достаточно удобно.

Но есть проблема. (далее)

Код FeedWordPress выглядит примерно так:

function WhatToDo(post)
  localPost = FindLocalCopy(post);
  if localPost==null then
    //New post!
    AddPostMeta(localPost, 'syndication_item_hash', post.Hash);
    return doCreateNewPost;
  else
  if not FindMeta(localPost, 'syndication_item_hash', post.Hash) then
    //Post changed!
    AddPostMeta(localPost, 'syndication_item_hash', post.Hash);
    return doUpdatePost;
  else
    //No changes.
    return doNothing;

Если пост на дайри не менялся, FeedWordPress не будет заново его импортировать и не создаст новой ревизии местного поста. Это хорошо. Но перед тем, как записать пост в базу, WordPress прогоняет его через ряд плагинов, которые меняют его содержание:
Пост на дайри (из RSS) –> (замена ссылок на местные) —-> (исправление форматирования) –> Пост на boku.ru

Бывает, что какой-то из плагинов сбоит, и преобразует пост неправильно. Тогда я начинаю искать, в чём дело. Чтобы разобраться, мне нужно импортировать пост снова и снова, пока я не найду ошибку.

Но как это сделать? Ведь пост уже импортирован, и с точки зрения FeedWordPress, его содержимое не менялось (на дайри он остался тем же).

Для этой цели я влез в файлы FeedWordPress, и временно покромсал описанную выше процедуру. Она стала выглядеть так:

function WhatToDo(post)
  localPost = FindLocalCopy(post);
  if localPost==null then
    //New post!
    AddPostMeta(localPost, 'syndication_item_hash', post.Hash);
    return doCreateNewPost;
  else
  if not FindMeta(localPost, 'syndication_item_hash', post.Hash) then
    //Hack: Post is always changed!
    AddPostMeta(localPost, 'syndication_item_hash', post.Hash);
    return doUpdatePost;
    //TODO: Restore normal version.

Менялся пост или нет, мы всегда импортируем его заново. Конечно, при этом создаётся новая ревизия и захламляется база, но подумаешь, мне же ненадолго… А старые ревизии поста легко удалить.

Поправив таким образом FeedWordPress, я залил новые файлы на сервер и стал искать баг в своих плагинах. И нашёл. Исправил. Убедился, что теперь посты преобразуются правильно. Всё сохранил, применил, закрыл… а отключить хак забыл.

И ушёл.

Вторая половина этой истории началась через месяц, когда я зашёл на блог на boku.ru. Все страницы с последними постами не работали.

Вместо них отображался белый экран. Не работала даже консоль админа, из которой посты можно удалить. В логах сервера появлялась ошибка:

php error: maximum memory allocation exceeded

Какой-то из скриптов жрёт память? Но почему? Что я менял?

И тут я вспомнил, что забыл отключить хак.

Но постойте, а что такого? Проверки раз в полчаса – это 48 проверок в день, жалкие полторы тысячи ревизий за месяц. WordPress может обслуживать десятки тысяч постов, для MySQL лишние несколько тысяч ревизий – пустяк.

Если я напишу ещё полторы тысячи постов – вордпресс даже не поперхнётся. А полторы тысячи ревизий вывели его из строя?

Да ну! Не так он написан.

Тогда почему любая страница, которая обращается к последним постам – вылетает с переполнением памяти? База данных находится на диске – что вообще вордпресс грузит в память?

Метадату.

Каждый раз, загружая очередной пост для печати, вордпресс делает примерно следующее:

rows = exec_sql('SELECT * FROM post_metadata WHERE post_id=id');
while rows.MoveNext() do
  metadata[rows['name']]=rows['value']

То есть, читает из базы все относящиеся к посту записи метадаты и загружает их для быстрого доступа в память.

Обычно таких записей 8-10, иногда до 15 – мелочи, в общем.
У последних записей в моём блоге их было по 60 000.

Ничего удивительного, что обращаясь к этим записям, вордпресс падал. Он не рассчитан на 60 000 записей метадаты у поста. Удивительно, откуда эти записи взялись.

Я открыл таблицу phpMyAdmin-ом, и увидел, что все они – это копии параметра syndication_permalink. Тогда всё стало ясно.

Описанная выше функция WhatToDo решает, что делать с постом из RSS – добавить новый, обновить существующий или пропустить. При этом она регистрирует syndication_permalink, чтобы второй раз не обновлять одно и то же.

Да, импорт RSS происходит лишь раз в полчаса, 48 раз в сутки, и каждый пост импортируется лишь однажды – но функция проверки WhatToDo вызывается десятки раз за процедуру одного импорта. Только однажды её результат имеет значение, поэтому ревизий в базе действительно создано лишь полторы тысячи – но при каждом вызове она добавляет syndication_permalink, и этих пермалинков, совершенно одинаковых, у одного поста набираются десятки тысяч.

Ирония: вордпресс мог бы вынести десятки тысяч постов – но не десятки тысяч свойств поста.

Как всё это чинить?
Итак, испорчена таблица post_metadata: в ней для некоторых постов некоторые записи продублированы десятки тысяч раз. Нужно удалить дубли, но оставить по одной копии каждой записи.

После некоторой возни и гуглинга сотворился следующий манёвр:

CREATE TABLE `keep_ids` AS (
  SELECT MIN(`rowid`) AS `rowid` FROM `post_metadata` GROUP BY `postid`, `name`, `value`
)

Этим запросом мы находим все цепочки дублей (записей с одинаковыми данными в полях postid, name и value), и в каждой выбираем наименьший номер записи. Таким образом, мы получаем по одной копии каждой уникальной записи. Эти копии надо сохранить, а всё остальное удалить.

ALTER TABLE `keep_ids` ADD UNIQUE INDEX `rowid` (`rowid`)

Это чтобы операции с новой таблицей были быстрыми – сейчас понадобится.

DELETE FROM `post_metadata` WHERE `rowid` NOT IN (SELECT `rowid` FROM `keep_ids`)

Удаляем все записи из исходной таблицы, которые не вошли в наш “список на сохранение”. Если б в `keep_ids` не было индексов, мы бы тут завязли на несколько минут, а так – только секунд.

Ну и, наконец, удаляем временную таблицу:

DROP TABLE `keep_ids`

Победа! Число записей в post_metadata резко падает с сотен тысяч до 13 000 и блог снова работает нормально.

Названия таблиц и полей в примерах условны, и не соответствуют настоящим названиям в базе вордпресс. Код написан на условном языке, а код SQL может быть не совсем правильным, но передаёт общую мысль.

Один комментарий

  1. hlidskalph
    10 August, 2012 в 14:54 | Ссылка

    От оно как было… Сводки с полей сражений.

    Ответьте hlidskalph: Я передумал

    Если хотите, можно залогиниться.

    *