postgresql-lateral-join/

В СУБД для работы с временными рядами (time series DBMS) есть такое понятие, как ASOF JOIN. Хотя PostgreSQL и не поддерживает данный синтаксис, он позволяет выполнять те же запросы при помощи LATERAL JOIN’ов . Давайте разберемся, что это за JOIN’ы такие, и какую проблему они решают.

Рассмотрим такой сценарий. Есть два временных ряда. Для примера пусть будут температура и влажность:

CREATE TABLE temperature (
ts TIMESTAMP NOT NULL ,
city TEXT NOT NULL ,
temperature INT NOT NULL ) ;

CREATE TABLE humidity (
ts TIMESTAMP NOT NULL ,
city TEXT NOT NULL ,
humidity INT NOT NULL ) ;

INSERT INTO temperature ( ts , city , temperature )
SELECT ts + ( INTERVAL ’60 minutes’ * random ( ) ) , city , 30 * random ( )
FROM generate_series ( ‘2022-01-01’ :: TIMESTAMP ,
‘2022-01-31’ , ‘1 day’ ) AS ts ,
unnest ( array [ ‘Moscow’ , ‘Berlin’ ] ) AS city;

INSERT INTO humidity ( ts , city , humidity )
SELECT ts + ( INTERVAL ’60 minutes’ * random ( ) ) , city , 100 * random ( )
FROM generate_series ( ‘2022-01-01’ :: TIMESTAMP ,
‘2022-01-31’ , ‘1 day’ ) AS ts ,
unnest ( array [ ‘Moscow’ , ‘Berlin’ ] ) AS city;

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

SELECT t . ts , t . city , t . temperature , h . humidity
FROM temperature AS t
LEFT JOIN humidity AS h ON t . ts = h . ts;

Временные метки разные и сделать JOIN по их равенству невозможно.

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

PostgreSQL позволяет решить эту задачу, используя в запросе ключевое слово LATERAL. Подробнее о нем можно прочитать в документации . Вкратце, LATERAL можно использовать в SELECT- и JOIN-частях запроса. Ключевое слово позволяет ссылаться на строки, извлеченные из таблиц остальной частью запроса. Обычные SELECT и JOIN так не умеют.

Вот пример запроса:

SELECT t . ts , t . city , t . temperature , h . humidity
FROM temperature AS t
LEFT JOIN LATERAL
( SELECT * FROM humidity
WHERE city = t . city AND ts <= t . ts
ORDER BY ts DESC LIMIT 1
) AS h ON TRUE
WHERE t . ts < ‘2022-01-05’

Попробуйте убрать ключевое слово LATERAL и посмотрите, что будет.

Убедимся, что запрос возвращает то, что нужно:

=# SELECT * FROM temperature WHERE ts < ‘2022-01-05’ ORDER BY ts, city;
ts             |  city  | temperature
—————————-+———+————-
2022-01-01 00:15:56.237508 | Berlin |           6
2022-01-01 00:32:47.457148 | Moscow |          29
2022-01-02 00:19:26.726612 | Berlin |           2
2022-01-02 00:44:55.010613 | Moscow |           6
2022-01-03 00:30:12.831369 | Moscow |           6
2022-01-03 00:50:51.026596 | Berlin |           2
2022-01-04 00:03:29.004641 | Berlin |          15
2022-01-04 00:38:16.257926 | Moscow |           9

=# SELECT * FROM humidity WHERE ts < ‘2022-01-05’ ORDER BY ts, city;
ts             |  city  | humidity
—————————-+———+———-
2022-01-01 00:04:29.034182 | Moscow |       79
2022-01-01 00:04:50.904393 | Berlin |       36
2022-01-02 00:00:31.940381 | Berlin |       46
2022-01-02 00:12:16.295859 | Moscow |       18
2022-01-03 00:26:22.054815 | Moscow |       43
2022-01-03 00:53:20.204302 | Berlin |       66
2022-01-04 00:30:30.095735 | Moscow |       97
2022-01-04 00:35:36.344991 | Berlin |       66

=# SELECT … приведенный выше запрос с LATERAL …

ts             |  city  | temperature | humidity
—————————-+———+————-+———-
2022-01-01 00:15:56.237508 | Berlin |           6 |       36
2022-01-01 00:32:47.457148 | Moscow |          29 |       79
2022-01-02 00:19:26.726612 | Berlin |           2 |       46
2022-01-02 00:44:55.010613 | Moscow |           6 |       18
2022-01-03 00:30:12.831369 | Moscow |           6 |       43
2022-01-03 00:50:51.026596 | Berlin |           2 |       46 <— !!!
2022-01-04 00:03:29.004641 | Berlin |          15 |       66
2022-01-04 00:38:16.257926 | Moscow |           9 |       97

Обратите внимание на третью строчку снизу. Влажность в Берлине была взята за второе января, а не за третье. Но если вы внимательно посмотрите на временные метки, то убедитесь, что результат верный.

И напоследок проверим, что такие запросы могут в индексы:

— Создадим индекс. В этом случае построить covering индекс не только
— как-то правильнее, но и ускоряет запрос на ~13% по сравнению
— с простым индексом.
CREATE INDEX humidity_idx ON humidity ( ts , city ) INCLUDE ( humidity ) ;

— Нужно добавить побольше данных, чтобы СУБД предпочла использовать
— индекс, а не сканировать закэшированные строки в памяти.
INSERT INTO temperature ( ts , city , temperature )
SELECT ts + ( INTERVAL ’60 minutes’ * random ( ) ) , city , 30 * random ( )
FROM generate_series ( ‘2010-01-01’ :: TIMESTAMP ,
‘2020-12-31’ , ‘1 hour’ ) AS ts ,
unnest ( array [ ‘Moscow’ , ‘Berlin’ ] ) AS city;

INSERT INTO humidity ( ts , city , humidity )
SELECT ts + ( INTERVAL ’60 minutes’ * random ( ) ) , city , 100 * random ( )
FROM generate_series ( ‘2010-01-01’ :: TIMESTAMP ,
‘2020-12-31’ , ‘1 hour’ ) AS ts ,
unnest ( array [ ‘Moscow’ , ‘Berlin’ ] ) AS city;

— Смотрим EXPLAIN (или EXPLAIN ANALYZE).
EXPLAIN SELECT t . ts , t . city , t . temperature , h . humidity
FROM temperature AS t
LEFT JOIN LATERAL
( SELECT * FROM humidity
WHERE city = t . city AND ts <= t . ts
ORDER BY ts DESC LIMIT 1
) AS h ON TRUE ;

Какой же план мы увидим? Что можно сказать об эффективности запроса? Ответы на эти вопросы я предлагаю получить вам самостоятельно, в качестве упражнения.

EnglishRussianUkrainian