Быстрое агрегирование в PostgreSQL
Возьмем такую таблицу:
CREATE table test (a integer, b integer);
INSERT INTO test (SELECT q.a, generate_series(1,10000) AS b FROM (SELECT generate_series(1,1000) AS a) q);
В ней 10 миллионов строк, но всего 1000 вариантов значений колонки a
. И мы хотим выбрать эту тысячу вариантов значений колонки a
.
Сделаем по этой колонке индекс — в надежде, что это нам поможет:
CREATE INDEX index_a_on_test ON test (a);
И выбираем двумя самыми очевидными способами.
=> \timing
Timing is on.
=> SELECT a FROM test GROUP BY a;
Time: 2310.102 ms
=> SELECT DISTINCT a FROM test;
Time: 2354.694 ms
Так себе результат для выборки тысячи чиселок. Почему так долго? Да потому что эти запросы проходят по всем 10 миллионам строк в таблице, а потом агрегируют их. Все равно что | uniq
в bash’е написать.
=> EXPLAIN SELECT DISTINCT a FROM test;
QUERY PLAN
----------------------------------------------------------------------
HashAggregate (cost=169248.00..169258.00 rows=1000 width=4)
Group Key: a
-> Seq Scan on test (cost=0.00..144248.00 rows=10000000 width=4)
Наличие индекса никак не помогает. Но есть одна хитрость. Если мы сделаем SELECT a FROM test ORDER BY a LIMIT 1
— база моментально выдаст самое маленькое значение a
— 1 — использовав при этом индекс. Дальше мы можем сделать SELECT a FROM test WHERE a > 1 ORDER BY a LIMIT 1
— и база снова моментально прыгнет по индексу и выдаст 2. Правда, делать тысячу запросов в базу — так себе идея. Но мы можем сделать то же самое, используя рекурсивный запрос:
=> WITH RECURSIVE rec AS (
SELECT a FROM (
SELECT a FROM test ORDER BY a LIMIT 1
) t1 UNION ALL (
SELECT (
SELECT a
FROM test
WHERE a > rec.a
ORDER BY a
LIMIT 1
) AS a
FROM rec
WHERE rec.a IS NOT NULL
LIMIT 1
)
)
SELECT * FROM rec
Time: 23.440 ms
Да, я тоже ненавижу рекурсивные запросы. Но иногда без черной магии не обойтись.
Мне могут возразить, что мои проблемы в этом случае из-за того, что у меня база денормализована, и я не вынес колонку a
в отдельную таблицу. Тогда то же самое можно бы было сделать при помощи SELECT a FROM a_table
.
Но такой фокус пройдет только тогда, когда a
— это действительно отдельная сущность, а не просто поле. Представим, что в таблице test
мы хранили не чиселки, а сотрудников, а колонка a
— это организации, в которых они работают. Тогда все хорошо, но только потому, что организации не исчезают, когда мы удаляем из них сотрудников.
Другое дело — если бы мы хранили в таблице все тех же сотрудников, но в колонке a
были бы их имена. И мы бы хотели узнать, сколько у нас всего уникальных имен. В этом случае содержать отдельную таблицу с именами — это то еще развлечение. Попробуйте забавы ради придумать, как избежать race condition’а, когда в параллельных транзакциях удалется последний Олег и добавляется новый.
(ответ: при помощи уникального индекса и ON CONFLICT DO NOTHING
)