Объединение 2 больших таблиц postgres с использованием int8range не хорошо масштабируется

Я хотел бы присоединиться к информации таблицы маршрутизации IP к информации whois IP. Я использую RDS Amazon, что означает, что я не могу использовать Postgres ip4r расширение, и поэтому я вместо этого использую int8range типы для представления диапазонов IP-адресов, с суть индексы.

мои таблицы выглядят так:

=> 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.25 M строк. Есть перекрывающиеся диапазоны в обеих таблицах, но для каждого диапазона в таблице routing_details я хочу получить одно лучшее (наименьшее) совпадение из таблицы netblock_details.

Я придумал 2 разных запроса, которые, я думаю, вернут точные данные, один с помощью оконных функций и один с помощью 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)

The 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)

против полной таблицы с 600k строками запрос завершается с ошибкой примерно через 24 часа после запуска дискового пространства, что, по-видимому, вызвано внешним шагом слияния. Так это запрос работает хорошо и очень быстро с небольшой таблицей routing_details, но масштабируется очень плохо.

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

4 ответов


я изначально думал о боковом соединении, как и в других предложенных подходах (например, последний запрос Эрвина Брандштеттера, где он использует простой 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 что внутри


у меня нет действительно хорошего ответа для вас, потому что я не знаком с индексами gist, но мне интересно, поэтому я немного посмотрел на ваш план объяснения. Несколько вещей выделялись:

1) Ваш план использует вложенное соединение цикла, даже в Примере 250K. Это seq, сканирующий большую таблицу и выполняющий поиск на меньшей. Это означает, что он выполняет 8 миллионов поисков индекса на меньшем столе, занимая более 148 секунд. Мне странно, что это замедляется. значительно с увеличением размера routing_details_small таблица. Как я уже сказал, Я не знаком с индексами gist, но я бы экспериментировал с set enable_nestloop to false; чтобы увидеть, можете ли вы заставить его сделать какое-то сортированное слияние/хэш-соединение.

2) в конце выполняется distinct. Это занимает довольно малую часть времени (~11 секунд), но это также означает, что вы можете делать немного дополнительной работы. Похоже, что distinct снижает результирующее количество записей с более чем 1 миллиона до 250K, поэтому я бы экспериментировал с его попыткой раньше. Я не уверен, что вы получаете дубликаты, потому что в 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;


использовать LATERAL вступить (найти наименьший матч в строке в routing_details):

текущий дизайн БД

единственным соответствующим индексом для этих запросов является:

"idx_netblock_details_range" gist (range)

другие индексы здесь не имеют значения.

запрос

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 - но он должен большое of work_mem для большого рода. Сделайте это около 200 МБ для большого запроса. Использовать SET LOCAL в то же транзакция:

запрос будет не нужно как можно больше сортировать память. В отличие от DISTINCT ON вы не должны видеть замену Postgres на диск для сортировки с наполовину приличной настройкой для work_mem. Так что нет такой строки в EXPLAIN ANALYZE выход:

Sort Method: external merge Disk: 155288kB

проще DB дизайн

на второй взгляд, я протестировал упрощенный дизайн с помощью plain 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 важно!

запрос

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 Играть на скрипке.


подробное объяснение метода (примеры с индексами b-дерева, но принцип запроса аналогичен для индекса GiST):

и DISTINCT ON вариант:

передовая решение

над решениями масштабируется линейно с числовыми строками в routing_details, но ухудшается с количеством матчей в netblock_details. Наконец до меня дошло: у нас есть решил это раньше на dba.SE с более сложным подходом, дающим в значительной степени превосходную производительность:

frequency в связанном ответ играет роль ip_max - n.ip_min / upper(range) - lower(range) здесь.


Я не знаю, работает ли это на реальных данных. Отбор кандидатов втиснут во внутреннюю петлю, что мне кажется хорошим. При тестировании он дал два сканирования индекса (плюс один для анти-соединения), избегая окончательной сортировки/уникальности. Похоже, это дает эквивалентные результаты.

-- 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;