Материал подготовлен в рамках нового потока курса «SQL для разработчиков и аналитиков».

Недавно я увидел пост про набор запросов для собеседований, и все они были посвящены ответам задачи из бизнес-практики, решаемые через БД.

Сегодня этот пост уже скрыт за какой-то плашкой «подпишитесь, чтобы читать дальше», так что я даже не буду на него ссылаться, но один вопрос оттуда привлек мое внимание. Поскольку я не могу просто скопировать и вставить текст, воспроизведу его по памяти.

Дана таблица sessions с полями user_id, login_time и country_id. Нужно вывести все случаи, когда один и тот же аккаунт входил в систему более чем из одной страны в пределах двухчасового окна.

Идея в том, что это может быть инструментом для поиска потенциально взломанных аккаунтов: резкая смена страны за два часа выглядит подозрительно. Хотя в реальности такой сигнал не всегда надёжен — например, из-за использования VPN.

В решении из того поста предлагалось соединить таблицу sessions саму с собой, используя условие с неравенством. Мне кажется, можно сделать лучше…

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

=$ create table sessions (
    user_id int8,
    login_time timestamptz,
    country_id int8
);user_id == 3
CREATE TABLE

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

Пример данных. Мне понадобится как минимум три случая:

  1. пользователь, который никогда не менял страну — это будет user_id == 1

  2. пользователь, который сменил страну, но между входами прошло больше двух часов — это будет user_id == 2

  3. пользователь, который сменил страну и сделал это в пределах двух часов. Пусть это будет user_id == 3

Тогда данные для вставки будут такими:

=$ insert into sessions (user_id, login_time, country_id) values
    (1, '2025-05-01 12:34:56', 100),
    (1, '2025-05-01 13:34:56', 100),
    (1, '2025-05-01 19:34:56', 100),
    (2, '2025-05-01 12:34:56', 100),
    (2, '2025-05-01 19:34:56', 200),
    (3, '2025-05-01 12:34:56', 100),
    (3, '2025-05-01 13:34:56', 200);
INSERT 0 7

Теперь базовый подход с джойном (join, соединение) выглядел бы примерно так:

=$ select
    s_start.user_id,
    s_start.login_time,
    array_agg(distinct s_end.country_id) as countries
from
    sessions as s_start
    join sessions s_end on
        s_start.user_id = s_end.user_id and
        s_end.login_time between s_start.login_time and s_start.login_time + '2 hours'::interval
group by
    s_start.user_id, s_start.login_time
having
    count(distinct s_end.country_id) > 1;
 user_id |       login_time       | countries
---------+------------------------+-----------
       3 | 2025-05-01 12:34:56+02 | {100,200}
(1 row)

Выглядит довольно просто. Но можно ли сделать это за один проход по таблице sessions?

Думаю, да. С помощью оконных функций, а точнее — нестандартных определений фрейма…

В частности, в определении окна можно использовать что-то вроде такого: order by login_time range between current row and '2 hours'::interval following. Это задает рамку, которая учитывает все строки, у которых login_time находится между временем текущей строки и моментом на два часа позже. Вот как это работает:

=$ SELECT
    s.*,
    array_agg(login_time) over (partition by user_id order by login_time range between current row and '2 hours'::interval following)
FROM
    sessions s;
 user_id |       login_time       | country_id |                      array_agg
---------+------------------------+------------+-----------------------------------------------------
       1 | 2025-05-01 12:34:56+02 |        100 | {"2025-05-01 12:34:56+02","2025-05-01 13:34:56+02"}
       1 | 2025-05-01 13:34:56+02 |        100 | {"2025-05-01 13:34:56+02"}
       1 | 2025-05-01 19:34:56+02 |        100 | {"2025-05-01 19:34:56+02"}
       2 | 2025-05-01 12:34:56+02 |        100 | {"2025-05-01 12:34:56+02"}
       2 | 2025-05-01 19:34:56+02 |        200 | {"2025-05-01 19:34:56+02"}
       3 | 2025-05-01 12:34:56+02 |        100 | {"2025-05-01 12:34:56+02","2025-05-01 13:34:56+02"}
       3 | 2025-05-01 13:34:56+02 |        200 | {"2025-05-01 13:34:56+02"}
(7 rows)

Часть partition by user_id делает так, что для каждой строки учитываются только другие строки с тем же user_id.

В любом случае, имея это, мы можем изменить запрос так, чтобы он действительно показывал идентификаторы стран, а затем отфильтровать результаты так, чтобы остались только случаи с несколькими странами:

=$ SELECT
    s.user_id,
    s.login_time,
    array_agg(distinct s.country_id) over (partition by user_id order by login_time range between current row and '2 hours'::interval following) as countries
FROM
    sessions s;
ERROR:  DISTINCT is not implemented for window functions
LINE 4:     array_agg(distinct country_id) over (partition by user_i...
            ^

Это немного ломает мой план. Но ничего, давайте сделаем это через array_agg, без distinct, а убрать дубликаты сможем позже:

=$ SELECT
    s.user_id,
    s.login_time,
    array_agg(s.country_id) over (partition by user_id order by login_time range between current row and '2 hours'::interval following) as countries
FROM
    sessions s;
 user_id |       login_time       | countries
---------+------------------------+-----------
       1 | 2025-05-01 12:34:56+02 | {100,100}
       1 | 2025-05-01 13:34:56+02 | {100}
       1 | 2025-05-01 19:34:56+02 | {100}
       2 | 2025-05-01 12:34:56+02 | {100}
       2 | 2025-05-01 19:34:56+02 | {200}
       3 | 2025-05-01 12:34:56+02 | {100,200}
       3 | 2025-05-01 13:34:56+02 | {200}
(7 rows)

Сработало именно так, как и ожидалось, так что теперь давайте избавимся от повторяющихся стран. Сравнительно простой способ, по крайней мере для меня, — написать написать пользовательскую агрегатную функцию. Я понимаю, что это звучит страшновато, но на деле нужно всего лишь написать очень простую функцию и один оператор create aggregate:

=$ create function array_agg_unique( INOUT p_state int8[], IN p_newval int8 ) returns int8[] as $$
    select case
        when p_newval = any(p_state) then p_state
        else p_state || p_newval
    end;
$$ language sql;
CREATE FUNCTION
 
=$ create aggregate array_agg_unique( int8 ) (
    sfunc = array_agg_unique,
    stype = int8[],
    initcond = '{}'
);
CREATE AGGREGATE

Вот и всё. Теперь посмотрим, как работает новый агрегат с уникальными значениями:

=$ SELECT
    s.user_id,
    s.login_time,
    array_agg_unique(s.country_id) over (partition by user_id order by login_time range between current row and '2 hours'::interval following) as countries
FROM
    sessions s;
 user_id |       login_time       | countries 
---------+------------------------+-----------
       1 | 2025-05-01 12:34:56+02 | {100}
       1 | 2025-05-01 13:34:56+02 | {100}
       1 | 2025-05-01 19:34:56+02 | {100}
       2 | 2025-05-01 12:34:56+02 | {100}
       2 | 2025-05-01 19:34:56+02 | {200}
       3 | 2025-05-01 12:34:56+02 | {100,200}
       3 | 2025-05-01 13:34:56+02 | {200}
(7 rows)

А теперь фильтрация. Самый простой способ, на мой взгляд, — просто обернуть это в общее табличное выражение (CTE):

WITH base as (
    SELECT
        s.user_id,
        s.login_time,
        array_agg_unique(s.country_id) over (partition by user_id order by login_time range between current row and '2 hours'::interval following) as countries
    FROM
        sessions s
)
select *
from base
where array_upper(countries, 1) > 1;
 user_id |       login_time       | countries 
---------+------------------------+-----------
       3 | 2025-05-01 12:34:56+02 | {100,200}
(1 row)

Теперь вопрос в другом: действительно ли это быстрее, чем подход с join?

Для начала давайте посмотрим на обычный explain analyze для обоих запросов. Сначала вариант с join:

=$ explain analyze
select
    s_start.user_id,
    s_start.login_time,
    array_agg(distinct s_end.country_id) as countries
from
    sessions as s_start
    join sessions s_end on
        s_start.user_id = s_end.user_id and
        s_end.login_time between s_start.login_time and s_start.login_time + '2 hours'::interval
group by
    s_start.user_id, s_start.login_time
having
    count(distinct s_end.country_id) > 1;
                                                                 QUERY PLAN                                                                  
---------------------------------------------------------------------------------------------------------------------------------------------
 GroupAggregate  (cost=194.32..209.42 rows=183 width=48) (actual time=0.147..0.163 rows=1.00 loops=1)
   Group Key: s_start.user_id, s_start.login_time
   Filter: (count(DISTINCT s_end.country_id) > 1)
   Rows Removed by Filter: 6
   Buffers: shared hit=8
   ->  Sort  (cost=194.32..195.69 rows=549 width=24) (actual time=0.120..0.139 rows=9.00 loops=1)
         Sort Key: s_start.user_id, s_start.login_time, s_end.country_id
         Sort Method: quicksort  Memory: 25kB
         Buffers: shared hit=8
         ->  Hash Join  (cost=45.33..169.34 rows=549 width=24) (actual time=0.039..0.074 rows=9.00 loops=1)
               Hash Cond: (s_start.user_id = s_end.user_id)
               Join Filter: ((s_end.login_time >= s_start.login_time) AND (s_end.login_time <= (s_start.login_time + '02:00:00'::interval)))
               Rows Removed by Join Filter: 8
               Buffers: shared hit=2
               ->  Seq Scan on sessions s_start  (cost=0.00..25.70 rows=1570 width=16) (actual time=0.004..0.013 rows=7.00 loops=1)
                     Buffers: shared hit=1
               ->  Hash  (cost=25.70..25.70 rows=1570 width=24) (actual time=0.026..0.030 rows=7.00 loops=1)
                     Buckets: 2048  Batches: 1  Memory Usage: 17kB
                     Buffers: shared hit=1
                     ->  Seq Scan on sessions s_end  (cost=0.00..25.70 rows=1570 width=24) (actual time=0.004..0.013 rows=7.00 loops=1)
                           Buffers: shared hit=1
 Planning:
   Buffers: shared hit=156
 Planning Time: 0.435 ms
 Execution Time: 0.243 ms
(25 rows)

Итак. Здесь было выполнено два отдельных последовательных сканирования (Seq Scan) таблицы sessions — на строках 31 и 36. Думаю, если бы данных было больше, одно из них превратилось бы в набор сканирований по индексу (Index Scan). Сейчас это и проверим.

С другой стороны, мой подход с оконной функцией, фреймом и агрегатной функцией даёт такой explain analyze:

=$ explain analyze
WITH base as (
    SELECT
        s.user_id,
        s.login_time,
        array_agg_unique(s.country_id) over (partition by user_id order by login_time range between current row and '2 hours'::interval following) as countries
    FROM
        sessions s
)
select *
from base
where array_upper(countries, 1) > 1;
                                                            QUERY PLAN                                                             
-----------------------------------------------------------------------------------------------------------------------------------
 Subquery Scan on base  (cost=109.08..163.99 rows=523 width=48) (actual time=0.279..0.296 rows=1.00 loops=1)
   Filter: (array_upper(base.countries, 1) > 1)
   Rows Removed by Filter: 6
   Buffers: shared hit=50
   ->  WindowAgg  (cost=109.08..140.44 rows=1570 width=48) (actual time=0.221..0.283 rows=7.00 loops=1)
         Window: w1 AS (PARTITION BY s.user_id ORDER BY s.login_time RANGE BETWEEN CURRENT ROW AND '02:00:00'::interval FOLLOWING)
         Storage: Memory  Maximum Storage: 17kB
         Buffers: shared hit=50
         ->  Sort  (cost=109.04..112.96 rows=1570 width=24) (actual time=0.044..0.056 rows=7.00 loops=1)
               Sort Key: s.user_id, s.login_time
               Sort Method: quicksort  Memory: 25kB
               Buffers: shared hit=4
               ->  Seq Scan on sessions s  (cost=0.00..25.70 rows=1570 width=24) (actual time=0.006..0.015 rows=7.00 loops=1)
                     Buffers: shared hit=1
 Planning:
   Buffers: shared hit=63 dirtied=1
 Planning Time: 0.279 ms
 Execution Time: 0.385 ms
(18 rows)

Одно последовательное сканирование и немного дополнительной обработки.

Отлично. А теперь давайте добавим побольше данных. Скажем, 100 000 строк со случайными пользователями, временем входа и странами:

=$ insert into sessions (user_id, login_time, country_id)
select
    floor( 1 + random() * 50 ),
    now() - '3 months'::interval * random(),
    floor( 1 + random() * random() * random() * random() * random() * 5 )
from generate_series(1,100000);
INSERT 0 100000

Выражение с несколькими random() для страны просто сделало распределение country_id гораздо менее равномерным:

=$ select country_id, count(*) from sessions group by 1 order by 1;
 country_id | count 
------------+-------
          1 | 97623
          2 |  2119
          3 |   235
          4 |    23
        100 |     5
        200 |     2
(6 rows)

Чтобы всё было как следует проиндексировано, я добавлю индекс по (user_id, login_time):

=$ create index the_index on sessions (user_id, login_time);
CREATE INDEX

Отлично. А теперь момент истины: я запущу оба запроса — вариант с join и вариант с окном — по три раза каждый, возьму самый быстрый результат для каждого типа и посмотрю, как они себя ведут.

Самый быстрый запуск варианта с JOIN:

  QUERY PLAN                                                                              
----------------------------------------------------------------------------------------------------------------------------------------------------------------------
GroupAggregate  (cost=17.24..2143404.69 rows=33298 width=48) (actual time=154.160..3097.721 rows=6316.00 loops=1)
   Group Key: s_start.user_id, s_start.login_time
   Filter: (count(DISTINCT s_end.country_id) > 1)
   Rows Removed by Filter: 93690
   Buffers: shared hit=585882
   ->  Incremental Sort  (cost=17.24..1919272.77 rows=22263351 width=24) (actual time=151.612..2689.938 rows=285069.00 loops=1)
         Sort Key: s_start.user_id, s_start.login_time, s_end.country_id
         Presorted Key: s_start.user_id, s_start.login_time
         Full-sort Groups: 8578  Sort Method: quicksort  Average Memory: 26kB  Peak Memory: 26kB
         Buffers: shared hit=585882
         ->  Nested Loop  (cost=0.84..770705.97 rows=22263351 width=24) (actual time=151.285..1929.886 rows=285069.00 loops=1)
               Buffers: shared hit=585882
               ->  Index Only Scan using the_index on sessions s_start  (cost=0.42..3052.52 rows=100007 width=16) (actual time=0.011..130.035 rows=100007.00 loops=1)
                     Heap Fetches: 0
                     Index Searches: 1
                     Buffers: shared hit=387
               ->  Index Scan using the_index on sessions s_end  (cost=0.42..5.46 rows=222 width=24) (actual time=0.003..0.007 rows=2.85 loops=100007)
                     Index Cond: ((user_id = s_start.user_id) AND (login_time >= s_start.login_time) AND (login_time <= (s_start.login_time + '02:00:00'::interval)))
                     Index Searches: 100007
                     Buffers: shared hit=585495
 Planning Time: 0.145 ms
 JIT:
   Functions: 13
   Options: Inlining true, Optimization true, Expressions true, Deforming true
   Timing: Generation 0.490 ms (Deform 0.123 ms), Inlining 8.884 ms, Optimization 123.118 ms, Emission 19.230 ms, Total 151.722 ms
 Execution Time: 3106.287 ms
(26 rows)

А вот самый быстрый запуск запроса с пользовательским агрегатом и подходом на основе окна:

 QUERY PLAN                                                                      
-----------------------------------------------------------------------------------------------------------------------------------------------------
 Subquery Scan on base  (cost=49.51..8850.37 rows=33336 width=48) (actual time=0.890..957.652 rows=6316.00 loops=1)
   Filter: (array_upper(base.countries, 1) > 1)
   Rows Removed by Filter: 93691
   Buffers: shared hit=100241
   ->  WindowAgg  (cost=49.51..7350.26 rows=100007 width=48) (actual time=0.034..816.409 rows=100007.00 loops=1)
         Window: w1 AS (PARTITION BY s.user_id ORDER BY s.login_time RANGE BETWEEN CURRENT ROW AND '02:00:00'::interval FOLLOWING)
         Storage: Memory  Maximum Storage: 17kB
         Buffers: shared hit=100241
         ->  Index Scan using the_index on sessions s  (cost=0.42..5600.14 rows=100007 width=24) (actual time=0.011..169.658 rows=100007.00 loops=1)
               Index Searches: 1
               Buffers: shared hit=100241
 Planning Time: 0.068 ms
 Execution Time: 965.772 ms
(13 rows)

Отлично. Похоже, этот вариант примерно в три раза быстрее.

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

Если вам интересен сам подход к работе с данными, обратите внимание на курс «SQL для разработчиков и аналитиков». На нём разбирают не только синтаксис запросов, но и оконные функции, соединения, индексы, оптимизацию и особенности работы разных СУБД. Чтобы узнать, подойдет ли вам программа курса, пройдите короткий тест.

Для знакомства с форматом обучения и экспертами приходите на бесплатные уроки:

  • 31 марта в 20:00. «SQL: Оконные функции — когда GROUP BY уже не хватает». Записаться

  • 21 апреля в 20:00. «Функции в SQL: вычисления и переиспользование кода». Записаться