Оптимизация запроса

Wicked

Новичок
Оптимизация запроса

Требуется: выводить пользователю аналог френдленты с постами, отсортированными по дате.
Упрощенный вариант запроса, который это делает (limit - для второй страницы):
[sql]select
`id`,
`title`
from
`ext_feeder_item`
where
`ext_feeder_item`.`feed_id` in (31,1291, ... /* всего 346 id-шников фидов */ )
order by ext_feeder_item.pub_date DESC
limit 10, 10;[/sql]
Код:
+----+-------------+-----------------+-------+---------------+-------------+---------+------+-------+-----------------------------+
| id | select_type | table           | type  | possible_keys | key         | key_len | ref  | rows  | Extra                       |
+----+-------------+-----------------+-------+---------------+-------------+---------+------+-------+-----------------------------+
|  1 | SIMPLE      | ext_feeder_item | range | feed_id_idx   | feed_id_idx | 8       | NULL | 32165 | Using where; Using filesort |
+----+-------------+-----------------+-------+---------------+-------------+---------+------+-------+-----------------------------+
Как видно, делает он свою работу неприемлемо с точки зрения производительности (у рассматриваемого юзера всего около 32K постов во френдленте - не сильно много).

Насколько я понимаю структуру индексов MySQL (в частности, деревьев B-tree), они не предназначена для выборок типа
where key_part_1 in (много разных значений) order by key_part_2, поэтому индекс (`feed_id`, `pub_date`) игнорируется.
То же самое можно сказать про where key_part_2 in (...) order by key_part_1.

Поэтому у меня напрашивается решение - для каждого юзера кэшировать некоторую дату, отделяющую первые 100 сообщений по всем его френдам-фидам. Таким образом можно будет использовать запрос типа
[sql]select
`id`,
`title`
from
`ext_feeder_item`
where
`ext_feeder_item`.`feed_id` in ( 31, 1291, . . ./* всего 346 id-шников фидов */ )
and
ext_feeder_item.pub_date > '2008-01-22 00:00:00'
order by ext_feeder_item.pub_date DESC
limit 10, 10;[/sql]который дает куда более хорошие результаты для первых десяти страниц (без лимита этот запрос выгребает 100 записей):
Код:
+----+-------------+-----------------+-------+------------------------+-------------------+---------+------+------+-----------------------------+
| id | select_type | table           | type  | possible_keys          | key               | key_len | ref  | rows | Extra                       |
+----+-------------+-----------------+-------+------------------------+-------------------+---------+------+------+-----------------------------+
|  1 | SIMPLE      | ext_feeder_item | range | ..., feed_id__pub_date | feed_id__pub_date | 17      | NULL |  259 | Using where; Using filesort |
+----+-------------+-----------------+-------+------------------------+-------------------+---------+------+------+-----------------------------+
Но меня смущает несколько моментов...
1) эту дату нужно обновлять когда происходит одно из событий:
1.1) добавление нового поста в любом из фидов юзера - не обязательно делать сразу, т.к. если эта дата будет меньше актуального, то скоростные характеристики упадут совсем чуть-чуть.
1.2) добавление нового фида - как в 1.1.
1.3) удаление фида - надо делать сразу, т.к. сохраненная дата будет больше, чем нужно, и будет, например, обрезать кол-во постов до 90, а не до 100.
1.4) ...?
2) файлсорт будет реально ссыпаться на диск, т.к. в оригинальном запросе достаются поля типа TEXT.
3) эта дата дает хороший прирост скорости только на первых 10 страницах. Дальше - опа :)
3.1) чтобы сделать это работающим на всем множестве запросов, придется таких дат запоминать побольше (для данного юзера - 32k/100 = 320 штук), причем их нужно будет обновлять уже синхронно. И когда будет добавляться пост в какой-нибудь популярный фид, это может выливаться каждый раз в кол-во обновлений = 320 дат на юзера * 1000 юзеров = 320к... А это слишком дофига, чтобы делать это на каждый чих.

Есть какие-нибудь мысли, как это можно сделать более-менее красиво и быстро с точки зрения производительности как вставок-обновлений, так и выборок?
 

Mols

Новичок
Из того как поставлена задача... думаю надо обновлять эти даты раз в день по крону. Естественно во время максимальной разгрузки серва. А для того, чтобы учесть возможную проблему с удалением фида придётся прикручивать проверялку которая будет перестраивать дату. Здесь я бы подумал над
1. возможно не стоит перестраивать дату если пользователь не онлайн (например поставить какой нить флаг для этого пользователя который будет означать, что надо проверить для этого пользователя актуальность дат когда он залогинится. Ну и чистить эти флаги при перестройке дат по крону)
2. если произошло событие которое требует срочной перестройки дат - проверить count() который вернёт запрос со старой датой (может же быть такое, что было добавлено больше записей чем удалено)

Да и вообще... может стоит эти даты перестраивать только когда юзер логинится. И при этом учитывать что нить типа "если последний раз дата перестраивалась меньше суток(2-х,3-х ?) назад и не взведён флаг для проверки актуальности дат => ничего не делать"
 

FractalizeR

Новичок
Насколько я понимаю структуру индексов MySQL (в частности, деревьев B-tree), они не предназначена для выборок типа
where key_part_1 in (много разных значений) order by key_part_2, поэтому индекс (`feed_id`, `pub_date`) игнорируется.
То же самое можно сказать про where key_part_2 in (...) order by key_part_1.
Это не совсем так.
Если многостолбцовый индекс организован следующим образом:
(key_part_1, key_part_2), то where key_part_1 in (...) будет использовать индекс, а where key_part_2 in (...) - не будет.
 

Wicked

Новичок
FractalizeR
Ну да, немного не то написал. Индекс игнорируется не целиком, а только его pub_date составляющая. Это само собой.

Ermitazh
В этом не самом худшем случае (есть юзеры с тысачами фидов, а у этого всего 350) - несколько секунд. И ситуация будет только усугубляться со временем.

Mols
1. возможно не стоит перестраивать дату если пользователь не онлайн (например поставить какой нить флаг для этого пользователя который будет означать, что надо проверить для этого пользователя актуальность дат когда он залогинится. Ну и чистить эти флаги при перестройке дат по крону)
Да, Nail подсказал нечто похожее, что будет работать вообще по запросу. Типа мы храним (в персистент хранилище) только крайнюю дату для первой страницы. А для второй страницы и далее уже можно высчитывать на лету... На первый взгляд было все ок, а сейчас мне почему-то так не кажется. Я обмозгую и попозже отпишусь.

С кроном нужно тоже аккуратно поступать, потому что некоторые извращенцы добавляют сотни и тысячи фидов за раз (через opml) :)

2. если произошло событие которое требует срочной перестройки дат - проверить count() который вернёт запрос со старой датой (может же быть такое, что было добавлено больше записей чем удалено)
Тоже разумно :)
 

vovik

Новичок
Я бы предложил в исходном запросе явно указать использование индекса по pubdate. Или даже (pubdate, feed_id) - не нужен будет лишний lookup.
Получится сканирование с конца по дате, 10 записей должны найтись достаточно быстро. По крайней мере, для первой страницы. Чем страница больше, тем медленнее будет :)

Мускуль вообще не любит использовать индекс по сортировочному полю в конструкции order by .. limit. Хотя иногда это очччень оправдно.
 

Gas

может по одной?
Мускуль вообще не любит использовать индекс по сортировочному полю в конструкции order by .. limit
как раз наоборот

Wicked
вопрос не простой, тут думать нужно :)
Может стоит искать людей, кто хорошо копал исходники LJ, там же схема похожая.
 

nail

Новичок
Лучше всего в background'е генерить такую таблицу:
(
for_user_id,
item_id
pub_date
)

Запрос превратится в "select item_id from ... where for_user_id=? order by pub_date"
Она будет небольшая - в ней одни числа.
pub_date старше 2 месяцев сносим в архив.
 

vovik

Новичок
Автор оригинала: Gas
как раз наоборот
Ээээ ... Как насчет привести пример ? Где есть where, order by, limit и мускул выбирает индекс сортировки, а не индекс какого-нить поля из where ?

Может, конечно, в последнее время что-то изменилось. Я с мускулом не общался последние полгода-год (и впредь не желаю :)).

А автору топика может помочь use index. А может и не помочь. Надо просто проверить :)
 

Gas

может по одной?
vovik
у тебя речь шла о "в конструкции order by .. limit", без where. Если where присутствует - да, используется индекс по where, mysql не сравнивает cardinality этого индекса относительно общего количества rows и не делает предположений что лучше order или where.

впредь не желаю
не сомневаюсь :) В той базе что ты используешь - оптимизатор умнее?
 

vovik

Новичок
Автор оригинала: Gas
vovik
у тебя речь шла о "в конструкции order by .. limit", без where. Если where присутствует - да, используется индекс по where, mysql не сравнивает cardinality этого индекса относительно общего количества rows и не делает предположений что лучше order или where.


не сомневаюсь :) В той базе что ты используешь - оптимизатор умнее?
Про "без where" - это уже твои догадки, этого я не говорил. Но признаю, не совсем ясно выразился. Речь шла о запросе автора топика, я его имел в виду. Помогает это есс-но в случаях, когда селективность условия в where маленькая.

А оптимизатор умнее был у всех других субд, с которыми я более-менее плотно сталкивался :) Это постгрес, сиквел сервер, оракл. Хотя последние две конечно сравнивать с ним некорректно. Они хороших денег стоят, было бы странно если б они уступали мускулу в чем-то :)
 

Centaur

Новичок
Re: Оптимизация запроса

Значит так. Есть таблица постов. Пост характеризуется собственным id’шником (тип неважен, primary key), id’шником фида (тип неважен), датой (datetime) и контентом (тип и количество полей контента неважно).

Требуется вытащить посты в порядке убывания даты, отфильтровав по id’шникам фидов и разбив на страницы.

Если решать задачу без страниц, то запрос будет примерно такой:

[sql]select id
from posts
where feed_id in (...)
order by pub_date desc;[/sql]

Думаю, лучшее, что можно придумать в такой структуре (без денормализации) — это индекс по pub_date. А фильтрация пусть делается тупо в цикле. То есть, план запроса: scan по индексу, using where.

Со страницами, соответственно, добавляется limit $start, $postsPerPage. Проблема: из-за фильтрации по фидам при запросе какой-нибудь давнишней страницы движку придётся много выкинуть.

Чтобы ему помочь встать сразу на нужное место в индексе, нужно вместо номеров постов использовать сразу даты. То есть юзер приносит с собой (в GET-параметре) дату, скажем, earlierThan=2008-02-06T17:57Z, мы её подставляем в запрос:

[sql]select id, pub_date
from posts
where feed in (...)
and pub_date <= ?
order by pub_date desc
limit 0, 11;[/sql]

10 постов показываем, из 11-го выдёргиваем дату и генерируем из неё следующую ссылку. План запроса — range scan по индексу, using where.

Проблема 1: ломается обратное листание.

Решение: для обратного листания даём ссылку c параметром laterThan=2008-02-06T17:57Z, запрос:

[sql]select id, pub_date
from posts
where feed in (...)
and pub_date > ?
order by pub_date asc
limit 0, 11;[/sql]

Проблема 2: ломается листание через несколько страниц. Как по мне — ну и хрен с ним. Если не хочет читать ленту линейно, дадим ему поле ввода, пусть вобьёт туда дату, мы ему покажем ленту на тот момент.



Вариант с денормализацией: пусть у нас есть таблица подписки и пусть в ней для каждого юзера лежат id’ы постов, на которые он подписан.

[sql]create table posts (
id int,
feed_id int,
pub_date datetime,
content text,
primary key pk_posts (id));
create table feed_subscriptions (
user_id int,
feed_id int);
create table subscriptions (
user_id int,
feed_id int,
post_id int,
pub_date datetime);
create index idx_subscriptions_bydate
on subscriptions
(user_id, pub_date);[/sql]

Тогда показ ленты выглядит так:

[sql]select post_id, content
from subscriptions s
inner join posts p on s.post_id = p.id
where s.user_id = ?
order by pub_date desc;[/sql]
(лимиты и условия на дату по вкусу)

План запроса: 1) range scan по индексу idx_subscriptions_bydate, 2) join: unique scan по индексу pk_posts.

Поддержка таблицы subscriptions в случаях написания нового поста и добавления/удаления подписок оставляется читателю в качестве упражнения :)
 

nail

Новичок
Добавлю только, что в первом варианте эффективность будет зависеть от данных: если фидов - тысячи, а юзер подписан на 2-3, то сканироваться будет очень много (лишних) строк.
Поэтому смотрите на реальных данных что показывает SHOW STATUS LIKE 'Handler%' после запроса (поле rows в explain тут не показатель).
 

Gas

может по одной?
А во втором случае как раз то и самое сложное поддерживать subscriptions, о чём Wicked уже и говорил.
 

Centaur

Новичок
Автор оригинала: Gas
А во втором случае как раз то и самое сложное поддерживать subscriptions, о чём Wicked уже и говорил.
Юзер подписался на фид:
[sql]insert into feed_subscriptions (user_id, feed_id)
values ($user_id, $feed_id);
insert into subscriptions (user_id, feed_id, post_id, pub_date)
select $user_id, $feed_id, post_id, pub_date
from posts
where feed_id = $feed_id;[/sql]
Добавляется столько записей, сколько постов в подписанном фиде. Действительно, если фидов много и постов в них тоже много, получаем много^2 = до фига. Можем разделить работу на части по датам — в первую очередь вытащить, скажем, посты за последние два дня, а остальное оставить на потом — когда запросят старую ленту. Поскольку вряд ли кто-то пишет тысячи постов в день, то при каждом обращении записей будет просто много.

Юзер отписался от фида:
[sql]delete from subscriptions
where user_id = $user_id
and feed_id = $feed_id;
delete from feed_subscriptions
where user_id = $user_id
and feed_id = $feed_id;[/sql]
Удаляется столько записей, сколько постов в отписанном фиде. Опять-таки измерять. Или отфильтровывать старые подписки при выводе, а удаление отложить на крон.

В фиде появился новый пост:
[sql]insert into subscriptions (user_id, feed_id, post_id, pub_date)
select user_id, $feed_id, $post_id, $pub_date
from feed_subscriptions
where feed_id = $feed_id;[/sql]
Нужен будет индекс по feed_subscriptions (feed_id). Количество добавляемых записей равно числу подписчиков (friend-of) — это будут мелочи, в худшем 20000, в среднем 20.
 

Wicked

Новичок
Автор оригинала: vovik
Я бы предложил в исходном запросе явно указать использование индекса по pubdate. Или даже (pubdate, feed_id) - не нужен будет лишний lookup.
Получится сканирование с конца по дате, 10 записей должны найтись достаточно быстро. По крайней мере, для первой страницы. Чем страница больше, тем медленнее будет :)

Мускуль вообще не любит использовать индекс по сортировочному полю в конструкции order by .. limit. Хотя иногда это очччень оправдно.
В моем случае это будет неоправданно, потому что для среднестатистического юзера селективность feed_id будет оочень низкая (например, юзер подписан на 100 фидов из миллиона в системе), и > 99% просканированной информации придется отбрасывать. При росте номера страниц отбрасывать придется еще больше, что на последней странице приведет тупо к фулскану...

-~{}~ 07.02.08 20:55:

Может стоит искать людей, кто хорошо копал исходники LJ, там же схема похожая.
у тебя нигде не завалялось? :)

А оптимизатор умнее был у всех других субд, с которыми я более-менее плотно сталкивался Это постгрес
Постгрес я тоже проверил на аналогичном запросе. Он выиграл у муси: Для первых страниц он использовал top-N heapsort алгоритм. Для старниц из середины - квиксорт (как и в случае с filesort'ом в MySQL при условии, что он не ссыпется на диск (ввиду типов полей)). Так что в худшем случае на горизонте маячат ровно те же проблемы.

PS: Я, кстати, всех обманул... В in() айдишников фидов не 346, а всего 175. Косяк возник из-за того, что group_concat() обрезает строку до 1024 симоволов, а значения я генерил именно им :) Но принципиально на предмет разговора это не влияет.

-~{}~ 08.02.08 13:01:

Centaur
В твоем варианте есть еще такая проблема - объем информации :)

1000000 пользователей * (10 тегов / пользователь) * (20 фидов / тег) * (1000 постов / фид) * ((4 + 4 + 4 + 8) байт) * 2 (ибо индекс еще) = 8 000 000 000 000 байт :)

8 ТБ - не сильно ли дофига? :) Данные может быть несколько завышены, но все же...

-~{}~ 08.02.08 13:05:

PS: теги - это типа как теги в google reader'е, которые сами могут показывать агрегированную информацию лежащих в них фидов.
 

fixxxer

К.О.
Партнер клуба
ммм. а в дальшейшем масштабирование вообще будет? =)
 

nail

Новичок
Wicked

А дальше шардинг по юзерам.
Плюс очень старые записи можно в отдельную таблицу убирать.

P.S.
миллиона активных пользователей у вас все равно не будет :)
 

fixxxer

К.О.
Партнер клуба
так вот я к тому и клоню, что если делать шардинг по юзерам то все равно надо сразу денормализовать
 

Wicked

Новичок
Вообщем, я пока остановился на следующем варианте, который не требует никаких дополнительных затрат в виде денормализованных данных. И работает за приемлемое время в довольно многих случаях.

Итак...

Процесс можно описать как итерационный поиск (с довольно простыми итерациями) той самой сотни или тысячи постов, близких по времени к постам с искомой страницы, по которым уже не жалко будет сделать оригинальный запрос (добавив только "and pub_date between $start and $end").

Для простоты примем систему отчета времени от now() в обратную сторону (1 февраля будет находиться на отметке 7 дней).

Определим функцию PN(f, pub_date_start, pub_date_end) кол-ва постов из фидов f, входящих в промежуток [pub_date_start, pub_date_end]. При фиксированном pub_date_start и f функция PN монотонно возрастает при возрастании pub_date_end. При фиксированном pub_date_end и f она монотонно убывает при возрастании pub_date_start.

Тогда функция PN вполне подходит для находжения методом хорд промежутка [pub_date_start1, pub_date_end1], в который входят искомые записи.

Рассмотрим пример нахождения 201й страницы записей (2000..2010) из 47595:

Первая итерация у нас особая, потому что нужно выбирать дополнительную информацию.

Код:
select count(`items`.`feed_id`),
       min(`items`.`pub_date`),
       max(`items`.`pub_date`)
from `items`
     inner join `ext_feeder_user_feed` as `uf`
       on (`uf`.`feed_id` = `items`.`feed_id`)
where `uf`.`feeder_id` = '70861';
| 47595 | "2007-08-17 02:44:00" | "2008-01-22 06:58:18" |
Записи с номерами: 0..47595
Итого мы знаем, что между "2007-08-17 02:44:00" и "2008-01-22 06:58:18" у нас 47595 записей.
Считаем, что это около 400 постов в сутки.
Соотв-но, посты с номерами 2000-2010 должны быть где-то за последних 5 дней, т.е. где-то после "2008-01-17 00:00:00".
И так на каждой итерации - подправляем одну из двух дат, участвующих в ограничении (но не обе сразу).

Код:
select count(`items`.`feed_id`) from `items` inner join `ext_feeder_user_feed` as `uf` on (`uf`.`feed_id` = `items`.`feed_id`)
where `uf`.`feeder_id` = '70861' and "[u]2008-01-17 00:00:00[/u]" < pub_date and pub_date < "2008-01-22 06:58:18";
| 22662 |
Записи с номерами: 0..22662
 
select count(`items`.`feed_id`) from `items` inner join `ext_feeder_user_feed` as `uf` on (`uf`.`feed_id` = `items`.`feed_id`)
where `uf`.`feeder_id` = '70861' and "[u]2008-01-21 00:00:00[/u]" < pub_date and pub_date < "2008-01-22 06:58:18";
| 5946 |
Записи с номерами: 0..5946

select count(`items`.`feed_id`) from `items` inner join `ext_feeder_user_feed` as `uf` on (`uf`.`feed_id` = `items`.`feed_id`)
where `uf`.`feeder_id` = '70861' and "[u]2008-01-22 00:00:00[/u]" < pub_date and pub_date < "2008-01-22 06:58:18";
| 1393 |
Записи с номерами: 0..1393

select count(`items`.`feed_id`) from `items` inner join `ext_feeder_user_feed` as `uf` on (`uf`.`feed_id` = `items`.`feed_id`)
where `uf`.`feeder_id` = '70861' and "[u]2008-01-21 18:00:00[/u]" < pub_date and pub_date < "[u]2008-01-22 00:00:00[/u]";
| 976 |
Записи с номерами: 1393..1393+976 == 1393..2369

select count(`items`.`feed_id`) from `items` inner join `ext_feeder_user_feed` as `uf` on (`uf`.`feed_id` = `items`.`feed_id`)
where `uf`.`feeder_id` = '70861' and "[u]2008-01-21 21:00:00[/u]" < pub_date and pub_date < "2008-01-22 00:00:00";
| 483 |
Записи с номерами: 1393+976-483..1393+976 == 1886..2369

select count(`items`.`feed_id`) from `items` inner join `ext_feeder_user_feed` as `uf` on (`uf`.`feed_id` = `items`.`feed_id`)
where `uf`.`feeder_id` = '70861' and "2008-01-21 21:00:00" < pub_date and pub_date < "[u]2008-01-21 22:00:00[/u]";
| 161 |
Записи с номерами: 1393+976-483..1393+976-483+161 == 1886..2047
Полагаем, что мы нашли достаточно узкий диапазон дат, чтобы по нему теперь искать как попало:

Код:
select * from `items` where `feed_id` in (31,...,47341) and "2008-01-21 21:00:00" < pub_date
and pub_date < "2008-01-21 22:00:00" order by `pub_date` desc limit /* 2000-1886 = */ 114, 10;
-~{}~ 08.02.08 17:54:

Код:
mysql> explain select count(`items`.`feed_id`) from `items`
    -> inner join `ext_feeder_user_feed` as `uf` on (`uf`.`feed_id` = `items`.`feed_id`)
    -> where `uf`.`feeder_id` = '70861' and "2008-01-21 00:00:00" < pub_date and pub_date < "2008-01-22 06:58:18";
+----+-------------+-------+------+-----  -+--------------------+---------+---------------------+------+--------------------------+
| id | select_type | table | type | poss   | key                | key_len | ref                 | rows | Extra                    |
+----+-------------+-------+------+-----  -+--------------------+---------+---------------------+------+--------------------------+
|  1 | SIMPLE      | uf    | ref  | ext_   | feeder_id__feed_id | 8       | const               |  345 | Using index              |
|  1 | SIMPLE      | items | ref  | ext_   | feed_id__pub_date  | 4       | kia_test.uf.feed_id |   54 | Using where; Using index |
+----+-------------+-------+------+-----  -+--------------------+---------+---------------------+------+--------------------------+

mysql> explain select * from `items` where `feed_id` in (31,...,47341) and "2008-01-21 21:00:00" < pub_date
    -> and pub_date < "2008-01-21 22:00:00" order by `pub_date` desc limit /* 2000-1886 = */ 114, 10;
+----+-------------+-------+-------+-----  -+-------------------+---------+------+------+-----------------------------+
| id | select_type | table | type  | poss   | key               | key_len | ref  | rows | Extra                       |
+----+-------------+-------+-------+-----  -+-------------------+---------+------+------+-----------------------------+
|  1 | SIMPLE      | items | range | ext_   | feed_id__pub_date | 13      | NULL | 2830 | Using where; Using filesort |
+----+-------------+-------+-------+-----  -+-------------------+---------+------+------+-----------------------------+
Вроде не сильно плохо...
 
Сверху