Группировка серийных постов, близких по времени

в 15:39, , рубрики: mysql, метки:

Добрый день!В проекте столкнулся со следующей задачей: есть новостная лента фотографий, постить в которую пользователи могут только по одной фотографии, а отображать их нужно вместе в виде галереи. Иными словами, все строки выборки нужно логически объединить в несколько «временных окон» по каждому автору и использовать это при отображении.Напрашивается группировать следующие один за одним посты, однако это не подходит: если два пользователя параллельно и неспешно аплоадят сотню фотографий — в ленту они добавляются поочерёдно, и при просмотре посты будут неприятно чередоваться.За решением на MySQL

Постановка задачи

Сразу оговорюсь что группировать посты при выборке — неправильно: очень желательно чтобы группы изображений оставались статичными и ничто ниоткуда не отваливалось. Таким образом, каждый пост должен явно относиться к какой-то «группе», визуально представляемой галереей.Решение — не панацея, но есть круг задач где именно этот подход может пригодиться.Сначала оформим таблицу для экспериментов:

CREATE TABLE `feed`(
 `id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
 `tm` INT UNSIGNED NOT NULL COMMENT 'timestamp',
 `user_id` INT UNSIGNED NOT NULL COMMENT 'author id',
 `image` VARCHAR(255) NOT NULL COMMENT 'posted image filename',
 `group` INT UNSIGNED NULL DEFAULT NULL COMMENT 'post group',
 PRIMARY KEY(`id`),
 INDEX(`user_id`),
 INDEX(`tm`,`group`)
 );

Таблица `feed` — это список постов. У каждого поста есть время добавления tm, ссылка на автора user_id, собственно картинка, а также мы добавляем специальный столбец group который и позволяет группировать изображения в галерею. При добавлении новой записи group=NULL.

Вариант неправильный

Сначала кажется: выбираем самый свежий пост, дальше выбираем посты того же юзера в радиусе одного часа, и присваиваем им всем group= id-первого-поста. Только в этом случае каждый пост, окажется, будет принадлежать только своей группе. Нет, не подходит :)

Группировка

Сперва нужно определить критерий временнОй близости постов:

SET @granularity:=60*60;

Так, все посты в пределах одного часа группируются в одну галерею.Далее совершаем следующий логический ход: даём каждому посту стать «основой» для группы:

SELECT `g`.`id` AS `group`
FROM `feed` `g`;

И такая группа будет содержать строки в часовом радиусе от «основы» (разница времени — в пределах одного часа):

SELECT `g`.`id` AS `group`, `f`.*
FROM `feed` `g`
    CROSS JOIN `feed` `f`
    ON (`f`.`user_id` = `g`.`user_id` 
        AND `f`.`tm` BETWEEN `g`.`tm`-@granularity AND `g`.`tm`
    )

Так, теперь у каждой строки есть ряд кандидатов в основы. Критерий выбора: выбираем в качестве «основы» пост, содержащий наибольшее число постов в своём часовом радиусе. Чтобы не напрягать MySQL лишними вычислениями — вместо радиуса используем критерий `f`.`tm` BETWEEN `g`.`tm`-@granularity AND `g`.`tm`: тогда основа с самым широким составом будем иметь наибольший `id`:

SELECT MAX(`g`.`id`) AS `group`, `f`.*
FROM `feed` `g`
    CROSS JOIN `feed` `f`
    ON (`f`.`user_id` = `g`.`user_id` 
        AND `f`.`tm` BETWEEN `g`.`tm`-@granularity AND `g`.`tm`
    )
GROUP BY `f`.`id`

Так, мы выбрали все строки и для каждой из них задали группу.Отмечу, что здесь есть нюанс. При отображении такой ленты мы теперь будем сортировать по `group` DESC. Тогда если в приведённом выше коде используется функция MAX() то в сортировке ленты самая свежая «группа» (получившая последнее обновление) будет прыгать в самый верх. Это поведение можно легко изменить: тогда мы получаем постоянные группы, такие, что элементы не могут перемещаться из одной в другую: для этого достаточно использовать функцию MIN(): основой тогда всегда становится самый старый пост, и группа может лишь дополняться новыми поступающими фотографиями:

SELECT MIN(`g`.`id`) AS `group`, `f`.*
FROM `feed` `g`
    CROSS JOIN `feed` `f`
    ON (`f`.`user_id` = `g`.`user_id` 
        AND `f`.`tm` BETWEEN `g`.`tm` AND `g`.`tm`+@granularity
    )
GROUP BY `f`.`id`

Теперь нам нужно обновить таблицу по результатам этого запроса: задать значение столбца `group`. MySQL не позволяет обновлять таблицу из которой производится чтение в одном UPDATE запросе, поэтому приходится сначала перенести нашу выборку во временную таблицу:

CREATE TEMPORARY TABLE `_feedg`
SELECT MAX(`g`.`id`) AS `group`, `f`.`id`
FROM `feed` `g`
    CROSS JOIN `feed` `f`
    ON (`f`.`user_id` = `g`.`user_id` 
        AND `f`.`tm` BETWEEN `g`.`tm`-@granularity AND `g`.`tm`
    )
WHERE `f`.`group` IS NULL 
    OR `f`.`tm` >= (UNIX_TIMESTAMP()-2*@granularity)
GROUP BY `f`.`id`;

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

UPDATE `feed` `f` CROSS JOIN `_feedg` `g` USING(`id`)
SET `f`.`group` = `g`.`group`;

Запрос желательно выполнять после каждой вставки, но для оптимизации можно это делать по крону, оставив часть работы на алгоритм вывода.

Выборки

Теперь, как грамотно осуществить выборку из такой таблицы?Если `group` проставлен для всех строк — тогда

SELECT * 
FROM `feed`
ORDER BY `group` DESC, `tm` DESC;

Однако в случае если приведённый выше запрос запускается по крону и, следовательно, у нас есть часть строк для которых group=NULL, часть логики вывода должна возлагаться на скрипт-рендерер, а выборку следует делать так:

SELECT * 
FROM `feed`
ORDER BY `group` IS NULL, `group` DESC, `tm` DESC;

Ссылки

Мой вопрос на stackoverflow: Stackoverflow: Grouping serial posts in a user feed. Здесь можно полюбоваться как это делается в Oracle с использованием «временных окон».SQLfiddle, поиграться: SQLfiddleНадеюсь, я был полезен :)

Автор: o_O_Tync

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js