Объединение двух больших таблиц postgres с использованием int8range, не масштабирующихся хорошо
Я хотел бы присоединиться к информации таблицы IP-маршрутизации к информации IP-адреса. Я использую Amazon RDS, что означает, что я не могу использовать расширение Postgres ip4r, и поэтому вместо этого я использую int8range для представления диапазонов IP-адресов с индексами gist.
Мои таблицы выглядят следующим образом:
=> \d routing_details
Table "public.routing_details"
Column | Type | Modifiers
----------+-----------+-----------
asn | text |
netblock | text |
range | int8range |
Indexes:
"idx_routing_details_netblock" btree (netblock)
"idx_routing_details_range" gist (range)
=> \d netblock_details
Table "public.netblock_details"
Column | Type | Modifiers
------------+-----------+-----------
range | int8range |
name | text |
country | text |
source | text |
Indexes:
"idx_netblock_details_range" gist (range)
Полная таблица routing_details содержит только под 600K строк, а netblock_details содержит около 8,25M строк. В обеих таблицах есть перекрывающиеся диапазоны, но для каждого диапазона в таблице routing_details я хочу получить самое лучшее (наименьшее) соответствие из таблицы netblock_details.
Я придумал два разных запроса, которые, я думаю, вернут точные данные, используя функции окна, а один - с помощью DISTINCT ON:
EXPLAIN SELECT DISTINCT ON (r.netblock) *
FROM routing_details r JOIN netblock_details n ON r.range <@ n.range
ORDER BY r.netblock, upper(n.range) - lower(n.range);
QUERY PLAN
QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------
Unique (cost=118452809778.47..118477166326.22 rows=581300 width=91)
Output: r.asn, r.netblock, r.range, n.range, n.name, n.country, r.netblock, ((upper(n.range) - lower(n.range)))
-> Sort (cost=118452809778.47..118464988052.34 rows=4871309551 width=91)
Output: r.asn, r.netblock, r.range, n.range, n.name, n.country, r.netblock, ((upper(n.range) - lower(n.range)))
Sort Key: r.netblock, ((upper(n.range) - lower(n.range)))
-> Nested Loop (cost=0.00..115920727265.53 rows=4871309551 width=91)
Output: r.asn, r.netblock, r.range, n.range, n.name, n.country, r.netblock, (upper(n.range) - lower(n.range))
Join Filter: (r.range <@ n.range)
-> Seq Scan on public.routing_details r (cost=0.00..11458.96 rows=592496 width=43)
Output: r.asn, r.netblock, r.range
-> Materialize (cost=0.00..277082.12 rows=8221675 width=48)
Output: n.range, n.name, n.country
-> Seq Scan on public.netblock_details n (cost=0.00..163712.75 rows=8221675 width=48)
Output: n.range, n.name, n.country
(14 rows) -> Seq Scan on netblock_details n (cost=0.00..163712.75 rows=8221675 width=48)
EXPLAIN VERBOSE SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY r.range ORDER BY UPPER(n.range) - LOWER(n.range)) AS rank
FROM routing_details r JOIN netblock_details n ON r.range <@ n.range
) a WHERE rank = 1 ORDER BY netblock;
QUERY PLAN
---------------------------------------------------------------------------------------------------------------------------------------------------
Sort (cost=118620775630.16..118620836521.53 rows=24356548 width=99)
Output: a.asn, a.netblock, a.range, a.range_1, a.name, a.country, a.rank
Sort Key: a.netblock
-> Subquery Scan on a (cost=118416274956.83..118611127338.87 rows=24356548 width=99)
Output: a.asn, a.netblock, a.range, a.range_1, a.name, a.country, a.rank
Filter: (a.rank = 1)
-> WindowAgg (cost=118416274956.83..118550235969.49 rows=4871309551 width=91)
Output: r.asn, r.netblock, r.range, n.range, n.name, n.country, row_number() OVER (?), ((upper(n.range) - lower(n.range))), r.range
-> Sort (cost=118416274956.83..118428453230.71 rows=4871309551 width=91)
Output: ((upper(n.range) - lower(n.range))), r.range, r.asn, r.netblock, n.range, n.name, n.country
Sort Key: r.range, ((upper(n.range) - lower(n.range)))
-> Nested Loop (cost=0.00..115884192443.90 rows=4871309551 width=91)
Output: (upper(n.range) - lower(n.range)), r.range, r.asn, r.netblock, n.range, n.name, n.country
Join Filter: (r.range <@ n.range)
-> Seq Scan on public.routing_details r (cost=0.00..11458.96 rows=592496 width=43)
Output: r.asn, r.netblock, r.range
-> Materialize (cost=0.00..277082.12 rows=8221675 width=48)
Output: n.range, n.name, n.country
-> Seq Scan on public.netblock_details n (cost=0.00..163712.75 rows=8221675 width=48)
Output: n.range, n.name, n.country
(20 rows)
DISTINCT ON выглядит немного более эффективным, поэтому я продолжил его. Когда я запускаю запрос в отношении полного набора данных, я получаю ошибку на диске после примерно 24-часового ожидания. Я создал таблицу routing_details_small с подмножеством из N строк полной таблицы routing_details, чтобы попытаться понять, что происходит.
При N = 1000
=> EXPLAIN ANALYZE SELECT DISTINCT ON (r.netblock) *
-> FROM routing_details_small r JOIN netblock_details n ON r.range <@ n.range
-> ORDER BY r.netblock, upper(n.range) - lower(n.range);
QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Unique (cost=4411888.68..4453012.20 rows=999 width=90) (actual time=124.094..133.720 rows=999 loops=1)
-> Sort (cost=4411888.68..4432450.44 rows=8224705 width=90) (actual time=124.091..128.560 rows=4172 loops=1)
Sort Key: r.netblock, ((upper(n.range) - lower(n.range)))
Sort Method: external sort Disk: 608kB
-> Nested Loop (cost=0.41..1780498.29 rows=8224705 width=90) (actual time=0.080..101.518 rows=4172 loops=1)
-> Seq Scan on routing_details_small r (cost=0.00..20.00 rows=1000 width=42) (actual time=0.007..1.037 rows=1000 loops=1)
-> Index Scan using idx_netblock_details_range on netblock_details n (cost=0.41..1307.55 rows=41124 width=48) (actual time=0.063..0.089 rows=4 loops=1000)
Index Cond: (r.range <@ range)
Total runtime: 134.999 ms
(9 rows)
С N = 100000
=> EXPLAIN ANALYZE SELECT DISTINCT ON (r.netblock) *
-> FROM routing_details_small r JOIN netblock_details n ON r.range <@ n.range
-> ORDER BY r.netblock, upper(n.range) - lower(n.range);
QUERY PLAN
-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Unique (cost=654922588.98..659034941.48 rows=200 width=144) (actual time=28252.677..29487.380 rows=98992 loops=1)
-> Sort (cost=654922588.98..656978765.23 rows=822470500 width=144) (actual time=28252.673..28926.703 rows=454856 loops=1)
Sort Key: r.netblock, ((upper(n.range) - lower(n.range)))
Sort Method: external merge Disk: 64488kB
-> Nested Loop (cost=0.41..119890431.75 rows=822470500 width=144) (actual time=0.079..24951.038 rows=454856 loops=1)
-> Seq Scan on routing_details_small r (cost=0.00..1935.00 rows=100000 width=96) (actual time=0.007..110.457 rows=100000 loops=1)
-> Index Scan using idx_netblock_details_range on netblock_details n (cost=0.41..725.96 rows=41124 width=48) (actual time=0.067..0.235 rows=5 loops=100000)
Index Cond: (r.range <@ range)
Total runtime: 29596.667 ms
(9 rows)
С N = 250000
=> EXPLAIN ANALYZE SELECT DISTINCT ON (r.netblock) *
-> FROM routing_details_small r JOIN netblock_details n ON r.range <@ n.range
-> ORDER BY r.netblock, upper(n.range) - lower(n.range);
QUERY PLAN
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Unique (cost=1651822953.55..1662103834.80 rows=200 width=144) (actual time=185835.443..190143.266 rows=247655 loops=1)
-> Sort (cost=1651822953.55..1656963394.18 rows=2056176250 width=144) (actual time=185835.439..188779.279 rows=1103850 loops=1)
Sort Key: r.netblock, ((upper(n.range) - lower(n.range)))
Sort Method: external merge Disk: 155288kB
-> Nested Loop (cost=0.28..300651962.46 rows=2056176250 width=144) (actual time=19.325..177403.913 rows=1103850 loops=1)
-> Seq Scan on netblock_details n (cost=0.00..163743.05 rows=8224705 width=48) (actual time=0.007..8160.346 rows=8224705 loops=1)
-> Index Scan using idx_routing_details_small_range on routing_details_small r (cost=0.28..22.16 rows=1250 width=96) (actual time=0.018..0.018 rows=0 loops=8224705)
Index Cond: (range <@ n.range)
Total runtime: 190413.912 ms
(9 rows)
Против полной таблицы с 600 тыс. строк запрос выходит из строя после 24 часов с ошибкой об исчерпывании дискового пространства, которое предположительно вызвано шаром внешнего слияния. Таким образом, этот запрос работает хорошо и очень быстро с небольшой таблицей routing_details, но масштабируется очень плохо.
Предложения о том, как улучшить мой запрос или, возможно, даже изменения схемы, я мог бы сделать так, чтобы этот запрос работал эффективно на полном наборе данных?
Ответы
Ответ 1
Я думал первоначально о боковом соединении, как и в других предложенных подходах (например, последний запрос Эрвина Брандстретера, где он использует простой тип данных int8
и простые индексы), но не хотел писать его в ответ, потому что я думал, что это не очень эффективно. Когда вы пытаетесь найти все диапазоны netblock
, которые покрывают заданный диапазон, индекс не очень помогает.
Я повторю запрос Erwin Brandstetter:
SELECT * -- only select columns you need to make it faster
FROM routing_details r
, LATERAL (
SELECT *
FROM netblock_details n
WHERE n.ip_min <= r.ip_min
AND n.ip_max >= r.ip_max
ORDER BY n.ip_max - n.ip_min
LIMIT 1
) n;
Когда у вас есть индекс в netblock_details, например:
CREATE INDEX netblock_details_ip_min_max_idx ON netblock_details
(ip_min, ip_max DESC NULLS LAST);
вы можете быстро (в O(logN)
) найти начальную точку сканирования в таблице netblock_details
- либо максимум n.ip_min
, который меньше, чем r.ip_min
, либо минимальный n.ip_max
, который больше, чем r.ip_max
. Но тогда вам придется сканировать/читать остальную часть индекса/таблицы, а для каждой строки делать вторую часть проверки и фильтровать большинство строк.
Другими словами, этот индекс помогает быстро найти начальную строку, которая удовлетворяет первым критериям поиска: n.ip_min <= r.ip_min
, но затем вы продолжите чтение всех строк, удовлетворяющих этим критериям, и для каждой такой строки выполните вторую проверку n.ip_max >= r.ip_max
. В среднем (если данные имеют равномерное распределение), вам нужно будет прочитать половину строк таблицы netblock_details
. Оптимизатор может сначала использовать индекс для поиска n.ip_max >= r.ip_max
, а затем применить второй фильтр n.ip_min <= r.ip_min
, но вы не можете использовать этот индекс для одновременного применения обоих фильтров.
Конечный результат:
для каждой строки из routing_details
мы будем читать половину строк из netblock_details
. 600K * 4M = 2 400 000 000 000 строк.
Это в 2 раза лучше, чем декартово произведение. Вы можете увидеть это число (декартово произведение) на выходе EXPLAIN ANALYZE
в вопросе.
592,496 * 8,221,675 = 4,871,309,550,800
Неудивительно, что ваши запросы заканчиваются на диске при попытке материализовать и сортировать это.
Общий процесс высокого уровня для достижения конечного результата:
-
присоединяйтесь к двум таблицам (не найдя лучшего совпадения). В худшем случае это декартово произведение, в лучшем случае все покрытия охватывают от netblock_details
для каждого диапазона от routing_details
. Вы сказали, что в netblock_details
есть несколько записей для каждой записи в routing_details
, что угодно от 3 до 10. Таким образом, результат этого объединения может иметь ~ 6M строк (не слишком много)
-
упорядочить/разделить результат объединения по диапазонам routing_details
и для каждого такого диапазона найти наилучший (наименьший) диапазон покрытия от netblock_details
.
Моя идея - отменить запрос. Вместо того, чтобы находить все диапазоны покрытия от большего netblock_details
для каждой строки из меньшей таблицы routing_details
, я предлагаю найти все меньшие диапазоны от меньшего routing_details
для каждой строки от большего netblock_details
.
Двухэтапный процесс
Для каждой строки из более крупного netblock_details
найдите все диапазоны от routing_details
, которые находятся внутри диапазона netblock
.
Я бы использовал следующую схему в запросах. Я добавил первичный ключ ID
в обе таблицы.
CREATE TABLE routing_details (
ID int
,ip_min int8
,ip_max int8
,asn text
,netblock text
);
CREATE TABLE netblock_details (
ID int
,ip_min int8
,ip_max int8
,name text
,country text
,source text
);
SELECT
netblock_details.ID AS n_ID
,netblock_details.ip_max - netblock_details.ip_min AS n_length
,r.ID AS r_ID
FROM
netblock_details
INNER JOIN LATERAL
(
SELECT routing_details.ID
FROM routing_details
WHERE
routing_details.ip_min >= netblock_details.ip_min
AND routing_details.ip_min <= netblock_details.ip_max
-- note how routing_details.ip_min is limited from both sides
-- this would make it possible to scan only (hopefully) small
-- portion of the table instead of full or half table
AND routing_details.ip_max <= netblock_details.ip_max
-- this clause ensures that the whole routing range
-- is inside the netblock range
) AS r ON true
Нам нужен индекс на routing_details
на (ip_min, ip_max)
. Главное здесь - индекс на ip_min
. Наличие второго столбца в индексе помогает, устраняя необходимость выполнять поиск значения ip_max
; это не помогает в поиске дерева.
Обратите внимание, что в боковом подзапросе нет LIMIT
. Это еще не окончательный результат. Этот запрос должен возвращать строки ~ 6M. Сохраните этот результат во временной таблице.
Добавьте индекс во временную таблицу на (r_ID, n_length, n_ID)
. n_ID
снова просто удалить дополнительные запросы. Нам нужен индекс, чтобы избежать сортировки данных для каждого r_ID
.
Заключительный шаг
Для каждой строки из routing_details
найдите n_ID
с наименьшим n_length
. Теперь мы можем использовать боковое соединение в "правильном" порядке. Здесь temp
таблица является результатом предыдущего шага с индексом.
SELECT
routing_details.*
,t.n_ID
,netblock_details.*
FROM
routing_details
INNER JOIN LATERAL
(
SELECT temp.n_ID
FROM temp
WHERE temp.r_ID = routing_details.ID
ORDER BY temp.n_length
LIMIT 1
) AS t ON true
INNER JOIN netblock_details ON netblock_details.ID = t.n_ID
Здесь в подзапросе должен быть поиск индекса, а не сканирование. Оптимизатор должен быть достаточно умным, чтобы выполнять поиск и возвращать первый найденный результат из-за LIMIT 1
, поэтому у вас будет 600K запросов индекса в таблице темпов строки 6M.
Оригинальный ответ (я сохраню его только для диаграммы диапазонов):
Можете ли вы "обмануть"?
Если я правильно понял ваш запрос, для каждого routing_details.range
вы хотите найти наименьший netblock_details.range
, который покрывает/больше, чем routing_details.range
.
В следующем примере, где r
- диапазон маршрутизации, а n1, ..., n8
- диапазоны netblock, правильный ответ n5
.
|---|
n1
|------------------|
n2
|---------------|
n3
|-----|
n4
|------------------|
n5
|--------------------------------------|
n6
|---------------------------|
n7
|-----|
n8
|------------|
r
start end
n.start <= r.start AND n.end >= r.end
order by n.length
limit 1
Ваш запрос который занял 14 часов, показывает, что сканирование индекса заняло 6 мс, но сортировка по длине диапазона заняла 80 мс.
При таком поиске нет простого 1D-упорядочения данных. Вы используете n.start
вместе с n.end
и вместе с n.length
. Но, возможно, ваши данные не являются общими, или это нормально, чтобы вернуть несколько иной результат.
Например, если было бы хорошо вернуть n6
, это может работать намного быстрее. n6
- это диапазон покрытия, который имеет наибольший start
:
n.start <= r.start AND n.end >= r.end
order by n.start desc
limit 1
Или вы можете пойти за n7
, у которого самый маленький end
:
n.start <= r.start AND n.end >= r.end
order by n.end
limit 1
Этот вид поиска будет использовать простой индекс на n.start
(или n.end
) без дополнительной сортировки.
Второй, совершенно другой подход. Насколько большой/длинный диапазон? Если они относительно короткие (несколько чисел), вы можете попытаться сохранить их как явный список целых чисел, а не диапазон.
Например, диапазон [5-8]
будет сохранен как 4 строки: (5, 6, 7, 8)
. С этой моделью хранения может быть проще найти пересечения диапазонов.
Ответ 2
У меня нет действительно хорошего ответа для вас, потому что я не знаком с индексами gist, но я заинтересован, поэтому я немного посмотрел на ваш план объяснений. Выделялось пару вещей:
1) Ваш план использует объединение вложенного цикла, даже в примере 250K. После этого вы просматриваете большую таблицу и выполняете поиск на более мелкой. Это означает, что он делает 8 миллионов запросов индекса на меньшую таблицу, занимая более 148 секунд. Странно, что это значительно замедляется с увеличением размера таблицы routing_details_small
. Как я уже сказал, я не знаком с индексами gist, но я бы экспериментировал с set enable_nestloop to false;
, чтобы увидеть, можете ли вы заставить его выполнить какое-то сортированное объединение/хеш-соединение.
2) Различия выполняются в конце. Это занимает довольно небольшую часть времени (~ 11 секунд), но это также означает, что вы можете делать немного дополнительную работу. Похоже, что ядро приносит результирующее количество записей вниз от более 1 миллиона до 250 К, поэтому я бы экспериментировал с этим раньше. Я не уверен, если вы получаете дубликаты, потому что в таблице routing_details_small
есть несколько записей для netblock
, или что таблица netblock_details
имеет несколько совпадений для данного netblock. Если первое, вы можете присоединиться к подзапросу с уникальными сведениями о маршрутизации. Если последнее, попробуйте следующее:
3). Сопоставив предыдущие два наблюдения, вы можете попробовать выполнить частичное соединение (присоединение к подзапросу) из seq-сканирования на routing_details_small. Это должно привести только к сканированию индекса 600K. Что-то вроде (при условии postgres 9.4):
SELECT * FROM routing_details_small r,
LATERAL (SELECT * FROM netblock_details n WHERE r.range <@ n.range LIMIT 1) nb;
Ответ 3
Используйте LATERAL
join (найдите наименьшее совпадение в строке в routing_details
):
Конструкция текущего БД
Единственный актуальный индекс для этих запросов:
"idx_netblock_details_range" gist (range)
Другие индексы здесь неактуальны.
Query
SELECT * -- only select columns you need to make it faster
FROM routing_details r
, LATERAL (
SELECT *
FROM netblock_details n
WHERE n.range @> r.range
ORDER BY upper(n.range) - lower(n.range)
LIMIT 1
) n
SQL Fiddle с более реалистичными тестовыми данными.
Как и в ваших исходных запросах, из результата удаляются строки из routing_details
без какого-либо совпадения в netblock_details
.
Производительность зависит от распределения данных. Это должно быть лучше со многими матчами. DISTINCT ON
может выиграть всего лишь с несколькими мастями в строке в routing_details
, но для большого сортировки ему нужно много work_mem
. Сделайте большой объем запросов на 200 МБ. Используйте SET LOCAL
в той же транзакции:
Этот запрос не потребуется столько памяти сортировки. В отличие от DISTINCT ON
, вы не должны видеть, как Postgres меняет на диск для сортировки с допустимой настройкой на уровне work_mem
на полпути. В строке EXPLAIN ANALYZE
нет такой строки:
Sort Method: external merge Disk: 155288kB
удаp >
Упрощенная конструкция DB
Во втором взгляде я проверил упрощенную конструкцию с равными столбцами int8
для нижней и верхней границ вместо типов диапазонов и простого индекса btree:
CREATE TABLE routing_details ( -- SMALL table
ip_min int8
, ip_max int8
, asn text
, netblock text
);
CREATE TABLE netblock_details ( -- BIG table
ip_min int8
, ip_max int8
, name text
, country text
, source text
);
CREATE INDEX netblock_details_ip_min_max_idx ON netblock_details
(ip_min, ip_max DESC NULLS LAST);
Сортировка второго столбца индекса DESC NULLS LAST
необходима!
Query
SELECT * -- only select columns you need to make it faster
FROM routing_details r
, LATERAL (
SELECT *
FROM netblock_details n
WHERE n.ip_min <= r.ip_min
AND n.ip_max >= r.ip_max
ORDER BY n.ip_max - n.ip_min
LIMIT 1
) n;
Такая же базовая техника. В моих тестах это было в 3 раза быстрее, чем первый подход. Но все еще недостаточно быстро для миллионов строк.
SQL Fiddle.
Подробное объяснение метода (примеры с индексами b-дерева, но принцип запроса аналогичен для индекса GiST):
И для варианта DISTINCT ON
:
Расширенное решение
Выше решения линейно строятся с номерами строк в routing_details
, но ухудшаются с количеством совпадений в netblock_details
. Наконец, он вернулся ко мне: мы решили это раньше на dba.SE с более сложным подходом, обеспечивающим в основном превосходную производительность:
frequency
в связанном ответе играет роль ip_max - n.ip_min
/upper(range) - lower(range)
здесь.
Ответ 4
Я не знаю, выполняется ли это на реальных данных. Выбор кандидата сжимается во внутренний цикл, который мне кажется хорошим. При тестировании он дал два сканирования индекса (плюс один для анти-соединения), избегая окончательной сортировки/уникальности. Кажется, он дает эквивалентные результаты.
-- EXPLAIN ANALYZE
SELECT *
FROM routing_details r
JOIN netblock_details n ON r.range <@ n.range
-- We want the smallest overlapping range
-- Use "Not exists" to suppress overlapping ranges
-- that are larger than n
-- (this should cause an antijoin)
WHERE NOT EXISTS(
SELECT * FROM netblock_details nx
WHERE r.range <@ nx.range -- should enclose r
AND n.range <> nx.range -- but differ from n
AND (nx.range <@ n.range -- and overlap n, or be larger
OR upper(nx.range) - lower(nx.range) < upper(n.range) - lower(n.range)
OR (upper(nx.range) - lower(nx.range) = upper(n.range) - lower(n.range) AND lower(nx.range) > lower(n.range) )
)
)
ORDER BY r.netblock
-- not needed any more
-- , upper(n.range) - lower(n.range)
;
UPDATE: (FWIW) в качестве бонуса, мой тестовый набор данных
CREATE Table routing_details
( asn text
, netblock text
, range int8range
);
-- Indexes:
CREATE INDEX idx_routing_details_netblock ON routing_details (netblock);
CREATE INDEX idx_routing_details_range ON routing_details USING gist(range) ;
CREATE Table netblock_details
( range int8range
, name text
, country text
, source text
);
-- Indexes:
CREATE INDEX idx_netblock_details_range ON netblock_details USING gist(range);
-- the smaller table
INSERT INTO routing_details(range,netblock)
SELECT int8range(gs, gs+13), 'block_' || gs::text
FROM generate_series(0,1000000, 11) gs
;
-- the larger table
INSERT INTO netblock_details(range,name)
SELECT int8range(gs, gs+17), 'name_' || gs::text
FROM generate_series(0,1000000, 17) gs
;
INSERT INTO netblock_details(range,name)
SELECT int8range(gs, gs+19), 'name_' || gs::text
FROM generate_series(0,1000000, 19) gs
;
INSERT INTO netblock_details(range,name)
SELECT int8range(gs, gs+23), 'name_' || gs::text
FROM generate_series(0,1000000, 23) gs
;
VACUUM ANALYZE routing_details;
VACUUM ANALYZE netblock_details;