Рейтинг пользователей на чистом SQL
Решил написать небольшой мануальчик по SQL. В нем рассматривается задача, которую я подсмотрел у Кирилла Мокевнина. Возможно, кому-то из вас ее дадут на собеседовании.
Задача следующая: сделать на сайте систему рейтинга. Пользователям начисляют баллы за разные достижения, и нужно вывести топ-100 пользователей. Позиция в рейтинге должна изменяться мгновенно. Например, кто-то получил 200 баллов и сразу оказался на вершине таблицы. Предполагается, что имеется только реляционная база данных, все остальное – по желанию.
Эта задача мне очень понравилась. Она лаконичная, но ее можно наращивать очень долго. Удивился тому, в комментариях – а у Кирилла довольно большая аудитория – почти никто не решил ее силами SQL. Большинство предлагали какие-то кэши, редисы-кафки, Firebase и прочую ахинею. Дело не в том, что это плохие инструменты. Наоборот, в нужных местах Редис и Кафка изумительны. Просто в данном случае их выбор ничем не обоснован.
Итак, давайте решим задачу силами ванильного Postgres. Чтобы разогреться, сначала сделаем небольшое послабление – предположим, что рейтинг нужно обновлять не мгновенно, а раз в час. Позже мы сделаем все как надо.
Подготовим таблицу пользователей:
create table users(
id serial primary key,
name text not null
);
Вставим в нее 100 тысяч случайных людей:
insert into users
select n, format('User %s', n)
from generate_series(1, 100000) as seq(n);
Создадим таблицу для начислений баллов. Она хранит код пользователя, баллы и поле reason – опциональный комментарий за что эти баллы.
create table user_points(
user_id integer not null,
points integer not null dеfault 0,
reason text
);
Начислим каждому пользователю десять раз случайное число баллов:
insert into user_points(user_id, points, reason)
select
n / 10 + 1,
(random() * n)::int % 100,
format('iteration %s', n)
from
generate_series(1, 1000000) as seq(n);
Объявим материализованную вьюху. Ее запрос выбирает сумму баллов с группировкой по коду пользователя:
create materialized view mv_user_rating as
select
user_id, sum(points) as total_points
from user_points
group by user_id;
Обновим ее:
refresh materialized view mv_user_rating;
Добавим индекс по убыванию общего числа баллов и обновим его:
create index idx_mv_total_points on mv_user_rating
using btree (total_points desc);
analyze mv_user_rating;
Теперь к сути: выберем топ-100 записей из вьюхи по убыванию суммы баллов. Подцепим джоином пользователей, выберем их имена. Запрос:
select
u.id as user_id,
u.name as user_name,
mv.total_points as total_points
from
mv_user_rating mv,
users u
where
mv.user_id = u.id
order by
mv.total_points desc
limit
100;
Частичный результат:
┌─────────┬────────────┬────────┐
│ user_id │ user_name │ points │
├─────────┼────────────┼────────┤
│ 96 │ User 96 │ 1225 │
│ 45 │ User 45 │ 1185 │
│ 85 │ User 85 │ 1169 │
│ 10 │ User 10 │ 1140 │
│ 33 │ User 33 │ 1138 │
│ 80 │ User 80 │ 1135 │
│ 48 │ User 48 │ 1133 │
│ 53 │ User 53 │ 1121 │
│ 11 │ User 11 │ 1119 │
│ 91 │ User 91 │ 1099 │
│ 79 │ User 79 │ 1096 │
│ 56 │ User 56 │ 1090 │
│ 72 │ User 72 │ 1089 │
│ 31 │ User 31 │ 1088 │
│ 40 │ User 40 │ 1072 │
│ 97 │ User 97 │ 1070 │
│ 65 │ User 65 │ 1068 │
│ 42 │ User 42 │ 1058 │
│ 43 │ User 43 │ 1057 │
│ 78 │ User 78 │ 1054 │
│ 63 │ User 63 │ 1053 │
│ 54 │ User 54 │ 1049 │
│ 93 │ User 93 │ 1031 │
│ 29 │ User 29 │ 1029 │
│ 51 │ User 51 │ 1028 │
│ 100 │ User 100 │ 1027 │
│ 98 │ User 98 │ 1021 │
│ 69 │ User 69 │ 1014 │
│ 28 │ User 28 │ 1003 │
│ 67 │ User 67 │ 994 │
│ 60 │ User 60 │ 990 │
│ 21 │ User 21 │ 987 │
│ 58 │ User 58 │ 986 │
│ 26 │ User 26 │ 984 │
Будет ли он тормозить? Посмотрим план:
explain analyze
select
u.id as user_id,
u.name as user_name,
mv.total_points as total_points
from
mv_user_rating mv,
users u
where
mv.user_id = u.id
order by
mv.total_points desc
limit
100;
├────────────────────────────────────────
│ Limit (cost=0.58..38.88 rows=100 width
│ -> Nested Loop (cost=0.58..38292.36
│ -> Index Scan using idx_mv_tot
│ -> Index Scan using users_pkey
│ Index Cond: (id = mv.user
│ Planning Time: 0.286 ms
│ Execution Time: 0.368 ms
└────────────────────────────────────────
Обе таблицы попадают в индексы, стоимость – копейки. Поэтому ответ – не будет.
Теперь дело за тем, как обновлять вьюху. У материализованных вьюх особенность:
во время обновления она недоступна. Можно обновлять их параллельно при помощи
refresh materialized view concurrently. Это медленней, зато не блокирует
чтение. Попытаемся:
refresh materialized view concurrently mv_user_rating;
ERROR: cannot refresh materialized view "public.mv_user_rating" concurrently
HINT: Create a unique index with no WHERE clause
on one or more columns of the materialized view.
Что такое? Дело в том, что для concurrently нужен хотя бы один уникальный индекс. По нему обновление вьюхи отслеживает свой прогресс. Добавим такой индекс:
create unique index idx_uq_mv_user_rating_user_id
on mv_user_rating(user_id);
Теперь параллельное обновление работает:
refresh materialized view concurrently mv_user_rating;
Как сделать обновление регулярным? Поможет стороннее расширение pg_cron. Из
коробки его нет, но оно доступно во всех пакетах и установлено почти у всех
облачных провайдеров. После установки включите его:
create extension pg_cron;
Добавьте задачу на расписание:
SELECT cron.schedule(
'cron_job_refresh_user_rating',
'refresh materialized view concurrently mv_user_rating;'
);
В результате каждый час вьюха будет обновляться параллельно. Для проверки регулярных задач и их истории служат специальные таблицы.
Это был прогрев. Напомню, что мы сделали послабление: обновляем рейтинг каждый час, а не мгновенно. Пора это исправить.
Вьюха становится не нужна, ее можно удалить. Вместо нее создайте таблицу аналогичной структуры:
create table user_total_points(
user_id integer primary key,
total_points integer not null dеfault 0
);
Чтобы начислить пользователю баллы и одновременно изменить его рейтинг, нужно
сделать два действия. Первое – записать баллы в таблицу user_points как мы
делали это раньше. Второе – если в итоговой таблице есть пользователь, прибавить
баллы к тем, что есть. Если нет – вписать туда пользователя и начальные баллы.
На этом месте говорят “триггеры”, и зря. Триггеры – слишком сложный инструмент. Они слишком строгие, и порой это неудобно, например в разработке, в тестах, в моделировании ситуаций. Ниже мы решим задачу без триггеров.
Предположим, пользователю 999 начислено 100 баллов. Первый запрос будет таким:
insert into user_points(user_id, points)
values (999, 100);
Второй – посложнее. Это UPSERT в таблицу рейтинга, который либо вставляет данные, либо обновляет их:
insert into user_total_points(user_id, total_points)
values (999, 100)
on conflict(user_id)
do update
set total_points = user_total_points.total_points
+ excluded.total_points;
Все это делается в транзакции, чтобы не допустить частичных изменений.
begin;
-- query 1;
-- query 2;
commit;
Выполним транзакцию два раза. В истории баллов будет две записи по 100, а в таблице рейтинга – их сумма 200.
select * from user_points;
┌─────────┬────────┬────────┐
│ user_id │ points │ reason │
├─────────┼────────┼────────┤
│ 999 │ 100 │ <null> │
│ 999 │ 100 │ <null> │
└─────────┴────────┴────────┘
select * from user_total_points;
┌─────────┬──────────────┐
│ user_id │ total_points │
├─────────┼──────────────┤
│ 999 │ 200 │
└─────────┴──────────────┘
Транзакцию можно опустить, если переписать запрос на CTE. У таких запросов особенность – все они видят один снимок данных и выполняются атомарно. Это удобно, потому что транзакцию begin/end легко потерять, а в случае CTE это невозможно.
with
step_1 as (
insert into user_points(user_id, points)
values (999, 100)
)
insert into user_total_points(user_id, total_points)
values (999, 100)
on conflict(user_id)
do update
set total_points = user_total_points.total_points
+ excluded.total_points;
Выполним его три раза, и в таблице рейтинга окажется значение 500:
┌─────────┬────────┬────────┐
│ user_id │ points │ reason │
├─────────┼────────┼────────┤
│ 999 │ 100 │ <null> │
│ 999 │ 100 │ <null> │
│ 999 │ 100 │ <null> │
│ 999 │ 100 │ <null> │
│ 999 │ 100 │ <null> │
└─────────┴────────┴────────┘
select * from user_total_points;
┌─────────┬──────────────┐
│ user_id │ total_points │
├─────────┼──────────────┤
│ 999 │ 500 │
└─────────┴──────────────┘
Если в базу пишут клиенты из разных систем, им будет неудобно таскать за собой этот запрос. Сделаем так: напишем функцию, которая принимает код пользователя и сколько баллов добавить. Функция возвращает суммарные баллы после всех изменений. Вот она:
create or replace function func_add_points
(user_id integer, points integer)
returns integer
as $$
with step_1 as (
insert into user_points(user_id, points)
values (user_id, points)
)
insert into user_total_points(user_id, total_points)
values (user_id, points)
on conflict(user_id) do update
set total_points = user_total_points.total_points
+ excluded.total_points
returning
total_points
$$
language sql strict parallel safe;
Теперь клиенты вызывают функцию select func_add_points(1234, 500), а что
внутри – их не касается. Функцию удобно вызывать из psql, если это баш-скрипт.
Вот как накинуть баллы – и одновременно изменить рейтинг – некоторым пользователям из диапазона:
select
n as user_id,
func_add_points(n, n * 5)
as total
from
generate_series(25, 50) seq(n);
┌─────────┬───────┐
│ user_id │ total │
├─────────┼───────┤
│ 25 │ 125 │
│ 26 │ 130 │
│ 27 │ 135 │
│ 28 │ 140 │
│ 29 │ 145 │
│ 30 │ 150 │
│ 31 │ 155 │
│ 32 │ 160 │
│ 33 │ 165 │
│ 34 │ 170 │
│ 35 │ 175 │
│ 36 │ 180 │
│ 37 │ 185 │
│ 38 │ 190 │
│ 39 │ 195 │
│ 40 │ 200 │
│ 41 │ 205 │
│ 42 │ 210 │
│ 43 │ 215 │
│ 44 │ 220 │
│ 45 │ 225 │
│ 46 │ 230 │
│ 47 │ 235 │
│ 48 │ 240 │
│ 49 │ 245 │
│ 50 │ 250 │
└─────────┴───────┘
Функцию можно вызывать параллельно. Давайте накинем баллов пользователю 1003 и посмотрим на результат:
select
user_id,
points,
func_add_points(user_id, points)
as total
from (values
(1003, 3),
(1003, 2),
(1003, 1),
(1003, 5),
(1003, 7))
as vals(user_id, points);
┌─────────┬────────┬────────┐
│ user_id │ points │ total │
├─────────┼────────┼────────┤
│ 1003 │ 3 │ 3 │
│ 1003 │ 2 │ 5 │
│ 1003 │ 1 │ 6 │
│ 1003 │ 5 │ 11 │
│ 1003 │ 7 │ 18 │
└─────────┴────────┴────────┘
Видим, что итоговая сумма приращивалась каждый раз правильно.
Теперь делаем то же самое, что и со вьюхой. Добавим индекс по убыванию баллов:
create index idx_total_points on user_total_points
using btree (total_points desc);
Выберем из таблицы рейтинга топ-100 записей, приклеим пользователей и готово:
select
u.id as user_id,
u.name as user_name,
total.total_points as total_points
from
user_total_points total,
users u
where
total.user_id = u.id
order by
total.total_points desc
limit
100;
┌─────────┬───────────┬───────┐
│ user_id │ user_name │ total │
├─────────┼───────────┼───────┤
│ 999 │ User 999 │ 1500 │
│ 50 │ User 50 │ 250 │
│ 49 │ User 49 │ 245 │
│ 48 │ User 48 │ 240 │
│ 47 │ User 47 │ 235 │
│ 46 │ User 46 │ 230 │
│ 45 │ User 45 │ 225 │
│ 44 │ User 44 │ 220 │
│ 43 │ User 43 │ 215 │
│ 42 │ User 42 │ 210 │
│ 41 │ User 41 │ 205 │
│ 40 │ User 40 │ 200 │
│ 39 │ User 39 │ 195 │
│ 38 │ User 38 │ 190 │
│ 37 │ User 37 │ 185 │
│ 36 │ User 36 │ 180 │
│ 35 │ User 35 │ 175 │
│ 34 │ User 34 │ 170 │
│ 33 │ User 33 │ 165 │
│ 32 │ User 32 │ 160 │
│ 31 │ User 31 │ 155 │
│ 30 │ User 30 │ 150 │
│ 29 │ User 29 │ 145 │
│ 28 │ User 28 │ 140 │
│ 27 │ User 27 │ 135 │
│ 26 │ User 26 │ 130 │
│ 25 │ User 25 │ 125 │
└─────────┴───────────┴───────┘
Одно из улучшений вот в чем. Выше у каждого пользователя разное число баллов, и нет случаев, когда двое участников делят одно место. Давайте добавим пользователю 49 пять баллов, чтобы уравнять его с пользователем 50:
select func_add_points(49, 5);
┌─────────────────┐
│ func_add_points │
├─────────────────┤
│ 250 │
└─────────────────┘
Их место будет неоднозначно: мы сортируем по убыванию суммы баллов, но если значения равны, порядок записей не гарантируется. Чтобы участники не спорили, кто на втором, а кто на третьем месте, вычислим плотный ранг. Это оконная функция, которая учитывает поля с одинаковым значением. Обычный ранг нумерует записи подряд, а плотный – с учетом повторов. Запрос:
select
u.id as user_id,
u.name as user_name,
total.total_points as total_points,
dense_rank() over w as rank
from
user_total_points total,
users u
where
total.user_id = u.id
window
w as (order by total_points desc)
order by
total_points desc
limit
100;
Результат:
┌─────────┬───────────┬───────┬──────┐
│ user_id │ user_name │ total │ rank │
├─────────┼───────────┼───────┼──────┤
│ 999 │ User 999 │ 1500 │ 1 │
│ 50 │ User 50 │ 250 │ 2 │
│ 49 │ User 49 │ 250 │ 2 │
│ 48 │ User 48 │ 240 │ 3 │
│ 47 │ User 47 │ 235 │ 4 │
│ 46 │ User 46 │ 230 │ 5 │
│ 45 │ User 45 │ 225 │ 6 │
│ 44 │ User 44 │ 220 │ 7 │
│ 43 │ User 43 │ 215 │ 8 │
│ 42 │ User 42 │ 210 │ 9 │
│ 41 │ User 41 │ 205 │ 10 │
│ 40 │ User 40 │ 200 │ 11 │
│ 39 │ User 39 │ 195 │ 12 │
│ 38 │ User 38 │ 190 │ 13 │
│ 37 │ User 37 │ 185 │ 14 │
│ 36 │ User 36 │ 180 │ 15 │
│ 35 │ User 35 │ 175 │ 16 │
│ 34 │ User 34 │ 170 │ 17 │
Теперь участники 49 и 50 делят второе место, и неоднозначность ушла.
Собственно, вот как решить эту задачу силами SQL. Не понадобились кафки, распределенные кэши и все остальное. Схема простая и быстрая, ее легко объяснить.
Любите Постгрес! Учите Пострес! Всем любви и добра.
Нашли ошибку? Выделите мышкой и нажмите Ctrl/⌘+Enter