postgrespro / pg_pathman

Partitioning tool for PostgreSQL
Other
580 stars 67 forks source link

Неверная ширина строки в плане и возможно неверный план #108

Closed NikitinNikolay closed 7 years ago

NikitinNikolay commented 7 years ago

Здравствуйте!

Нашёл баг при вычислении ширины возвращаемых строк, и возможно второй при формировании плана. У меня это всё проявляется на Red Hat Server 6.7, Postresql 9.6.3 и pg_pathman 1.4.2. Стенд делается следующими командами:

drop table if exists public.tab_parent cascade;
drop table if exists public.tab_child cascade;

create table public.tab_parent 
(
  parent_id numeric not null,
  parent_data varchar(100),
  partition_id numeric not null,
  primary key (parent_id)
);

create table public.tab_child 
(
  child_id numeric not null,
  parent_id numeric not null,
  child_data varchar(100),
  partition_id numeric not null,
  primary key (child_id)
);

create index ix_tab_child_parent on public.tab_child(parent_id);

select public.create_range_partitions(c.oid, 'partition_id', 1, 1, 3)
from pg_class c
inner join pg_namespace n on c.relnamespace = n.oid and n.nspname = 'public'
where c.relname in ('tab_parent', 'tab_child');

insert into public.tab_parent (parent_id, parent_data, partition_id)
select a, gen_random_bytes(25), trunc(random() * 3) + 1
from generate_series(1, 1000000) a;

insert into public.tab_child (child_id, parent_id, child_data, partition_id)
select parent_id * 20 + a, parent_id, gen_random_bytes(25), partition_id
from public.tab_parent, generate_series(1, 20) a
where random() < 0.1;

analyze public.tab_parent;
analyze public.tab_parent_1;
analyze public.tab_parent_2;
analyze public.tab_parent_3;

analyze public.tab_child;
analyze public.tab_child_1;
analyze public.tab_child_2;
analyze public.tab_child_3;

explain (analyse, verbose, buffers)
select *
from public.tab_parent p
inner join public.tab_child c on c.partition_id = p.partition_id and c.parent_id = p.parent_id 
where p.partition_id in (2, 3)
limit 100;

Вывод плана запроса:

"Limit  (cost=5.20..29.40 rows=100 width=596) (actual time=0.100..0.259 rows=100 loops=1)"
"  Output: p.parent_id, p.parent_data, p.partition_id, c.child_id, c.parent_id, c.child_data, c.partition_id"
"  Buffers: shared hit=20"
"  ->  Merge Join  (cost=5.20..161291.40 rows=666439 width=596) (actual time=0.099..0.254 rows=100 loops=1)"
"        Output: p.parent_id, p.parent_data, p.partition_id, c.child_id, c.parent_id, c.child_data, c.partition_id"
"        Merge Cond: (p.parent_id = c.parent_id)"
"        Join Filter: (p.partition_id = c.partition_id)"
"        Buffers: shared hit=20"
"        ->  Merge Append  (cost=0.85..28614.80 rows=666738 width=84) (actual time=0.048..0.070 rows=52 loops=1)"
"              Sort Key: p.parent_id"
"              Buffers: shared hit=8"
"              ->  Index Scan using tab_parent_2_pkey on public.tab_parent_2 p  (cost=0.42..14289.93 rows=332925 width=84) (actual time=0.039..0.045 rows=22 loops=1)"
"                    Output: p.parent_id, p.parent_data, p.partition_id"
"                    Filter: (p.partition_id = ANY ('{2,3}'::numeric[]))"
"                    Buffers: shared hit=4"
"              ->  Index Scan using tab_parent_3_pkey on public.tab_parent_3 p_1  (cost=0.42..14324.87 rows=333813 width=84) (actual time=0.008..0.016 rows=31 loops=1)"
"                    Output: p_1.parent_id, p_1.parent_data, p_1.partition_id"
"                    Filter: (p_1.partition_id = ANY ('{2,3}'::numeric[]))"
"                    Buffers: shared hit=4"
"        ->  Materialize  (cost=1.30..101023.04 rows=1999317 width=90) (actual time=0.048..0.129 rows=142 loops=1)"
"              Output: c.child_id, c.parent_id, c.child_data, c.partition_id"
"              Buffers: shared hit=12"
"              ->  Merge Append  (cost=1.30..96024.75 rows=1999317 width=90) (actual time=0.047..0.114 rows=142 loops=1)"
"                    Sort Key: c.parent_id"
"                    Buffers: shared hit=12"
"                    ->  Index Scan using tab_child_1_parent_id_idx on public.tab_child_1 c  (cost=0.42..31697.12 rows=666997 width=90) (actual time=0.024..0.031 rows=43 loops=1)"
"                          Output: c.child_id, c.parent_id, c.child_data, c.partition_id"
"                          Buffers: shared hit=4"
"                    ->  Index Scan using tab_child_2_parent_id_idx on public.tab_child_2 c_1  (cost=0.42..32334.17 rows=665736 width=90) (actual time=0.011..0.017 rows=46 loops=1)"
"                          Output: c_1.child_id, c_1.parent_id, c_1.child_data, c_1.partition_id"
"                          Buffers: shared hit=4"
"                    ->  Index Scan using tab_child_3_parent_id_idx on public.tab_child_3 c_2  (cost=0.42..31993.44 rows=666584 width=90) (actual time=0.010..0.019 rows=55 loops=1)"
"                          Output: c_2.child_id, c_2.parent_id, c_2.child_data, c_2.partition_id"
"                          Buffers: shared hit=4"
"Planning time: 1.448 ms"
"Execution time: 0.300 ms"

Первый баг: Хорошо видно, что у Merge Append width=84, у Materialize width=90, а у шага который их соединяет Merge Join width=596. Без pg_pathman ширина результирующей строки равна сумме ширин строк предшествующих шагов. А вот pg_pathman похоже в качестве слагаемых почему-то берёт теоретические размеры записей из родительских таблиц tab_parent и tab_child.

Второй возможный баг: Почему-то таблица tab_child присоединяется к первой через шаги Merge Append, Materialize и Merge Join и делает полный перебор партиций, хотя мне кажется тут был бы более уместен Ваш шаг RuntimeAppend, но он почему-то не выбирается. К сожалению, я не знаю как оттрассировать в постгрес выбор плана и не могу определить причину почему выбирается именно этот план.

С уважением, Никитин Николай.

funbringer commented 7 years ago

Добрый день, @NikitinNikolay

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

Второй возможный баг: Почему-то таблица tab_child присоединяется к первой через шаги Merge Append, Materialize и Merge Join и делает полный перебор партиций, хотя мне кажется тут был бы более уместен Ваш шаг RuntimeAppend, но он почему-то не выбирается.

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

explain (analyse, costs off)
select *
from public.tab_parent p
inner join public.tab_child c on c.partition_id = p.partition_id and c.parent_id = p.parent_id 
where p.partition_id in (2, 3)
limit 100;
                                                            QUERY PLAN                                                            
----------------------------------------------------------------------------------------------------------------------------------
 Limit (actual time=0.106..0.640 rows=100 loops=1)
   ->  Merge Join (actual time=0.104..0.627 rows=100 loops=1)
         Merge Cond: (p.parent_id = c.parent_id)
         Join Filter: (p.partition_id = c.partition_id)
         ->  Merge Append (actual time=0.046..0.123 rows=53 loops=1)
               Sort Key: p.parent_id
               ->  Index Scan using tab_parent_2_pkey on tab_parent_2 p (actual time=0.027..0.051 rows=27 loops=1)
                     Filter: (partition_id = ANY ('{2,3}'::numeric[]))
               ->  Index Scan using tab_parent_3_pkey on tab_parent_3 p_1 (actual time=0.018..0.046 rows=27 loops=1)
                     Filter: (partition_id = ANY ('{2,3}'::numeric[]))
         ->  Materialize (actual time=0.050..0.283 rows=156 loops=1)
               ->  Merge Append (actual time=0.047..0.241 rows=156 loops=1)
                     Sort Key: c.parent_id
                     ->  Index Scan using tab_child_1_parent_id_idx on tab_child_1 c (actual time=0.015..0.039 rows=57 loops=1)
                     ->  Index Scan using tab_child_2_parent_id_idx on tab_child_2 c_1 (actual time=0.014..0.039 rows=43 loops=1)
                     ->  Index Scan using tab_child_3_parent_id_idx on tab_child_3 c_2 (actual time=0.014..0.045 rows=58 loops=1)
 Planning time: 1.305 ms
 Execution time: 0.767 ms
(18 rows)

/* отключаем MergeJoin в целях эксперимента */
set enable_mergejoin = f;

explain (analyse, costs off)
select *
from public.tab_parent p
inner join public.tab_child c on c.partition_id = p.partition_id and c.parent_id = p.parent_id 
where p.partition_id in (2, 3)
limit 100;
                                                         QUERY PLAN                                                         
----------------------------------------------------------------------------------------------------------------------------
 Limit (actual time=0.064..0.902 rows=100 loops=1)
   ->  Nested Loop (actual time=0.063..0.891 rows=100 loops=1)
         ->  Append (actual time=0.015..0.054 rows=52 loops=1)
               ->  Seq Scan on tab_parent_2 p (actual time=0.015..0.049 rows=52 loops=1)
                     Filter: (partition_id = ANY ('{2,3}'::numeric[]))
               ->  Seq Scan on tab_parent_3 p_1 (never executed)
                     Filter: (partition_id = ANY ('{2,3}'::numeric[]))
         ->  Custom Scan (RuntimeAppend) (actual time=0.009..0.011 rows=2 loops=52)
               Prune by: (p.partition_id = c.partition_id)
               ->  Index Scan using tab_child_2_parent_id_idx on tab_child_2 c_1 (actual time=0.008..0.010 rows=2 loops=52)
                     Index Cond: (parent_id = p.parent_id)
                     Filter: (p.partition_id = partition_id)
 Planning time: 0.953 ms
 Execution time: 0.981 ms
(14 rows)

Можно заметить, что времена планирования и исполнения довольно похожи: они определенно не различаются на порядок. Тем не менее, у обоих планов наблюдается довольно сильный разброс по времени, поэтому имеет смысл вынести запрос в скрипт и пропустить через pgbench:

# MergeJoin
transaction type: bench.sql
scaling factor: 1
query mode: simple
number of clients: 1
number of threads: 1
duration: 200 s
number of transactions actually processed: 135644
latency average = 1.473 ms
latency stddev = 0.157 ms
tps = 678.215636 (including connections establishing)
tps = 678.221930 (excluding connections establishing)

# NestedLoop + RuntimeAppend
transaction type: bench.sql
scaling factor: 1
query mode: simple
number of clients: 1
number of threads: 1
duration: 200 s
number of transactions actually processed: 126413
latency average = 1.581 ms
latency stddev = 0.242 ms
tps = 632.059027 (including connections establishing)
tps = 632.063123 (excluding connections establishing)

Можно заметить, что комбинация NestedLoop + RuntimeAppend на этот раз проявила себя хуже, чем MergeJoin, хотя, честно говоря, при таком времени исполнения запроса это абсолютно не важно, сгодился бы любой план. Разница практически не видна.

Возможно, вы упростили какой-то реальный кейс, в котором у вас выбирается неудачный план? Если так, то будет лучше, если вы предоставите нам исходные данные (кол-во партиций, кол-во строк, возможно, что-нибудь еще).

А вот pg_pathman похоже в качестве слагаемых почему-то берёт теоретические размеры записей из родительских таблиц tab_parent и tab_child.

Абсолютно верно, и мы не видим способа эта исправить (по крайней мере, пока не видим). При обычном наследовании считается средняя длина для всех партиций, в то время как pathman вынужден пользоваться стандартной оценкой для одиночной таблицы.

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

NikitinNikolay commented 7 years ago

Здравствуйте, Дмитрий!

Начну всё же с размера записи. Главная таблица пустая, там нет реальной статистики и размер записи там максимальный. Нельзя ли как-то получить и подставить средний размер из наследников, сам постгрес же это как-то делает? Потому что размер записи по идее должен влиять на расчётную потребную память, и расчётное же количество читаемых с диска блоков.

По поводу плана, во-первых большое спасибо за set enable_mergejoin = f;, как-то не додумался я до такого. Баг я предположил исходя из ораклового опыта, там проход по партициям для партиционированных таблиц используется очень часто для оптимизированных запросов: шаги Partititon Single Scan и Partition Range Scan. Даже исходя из логики: таблицу партиционируют, когда она или её индексы перестают вмещаться в буфер бд. То есть все обычные планы постгрес на реальной бд должны уйти в дисковые чтения. Нашёл я баг, но он оказался не ваш :)

В статистике выполнения видно

"Limit  (cost=0.42..62.51 rows=100 width=596) (actual time=0.081..0.691 rows=100 loops=1)"
"  Output: p.parent_id, p.parent_data, p.partition_id, c.child_id, c.parent_id, c.child_data, c.partition_id"
"  Buffers: shared hit=200 read=6"
"  ->  Nested Loop  (cost=0.42..413762.63 rows=666439 width=596) (actual time=0.079..0.679 rows=100 loops=1)"
"        Output: p.parent_id, p.parent_data, p.partition_id, c.child_id, c.parent_id, c.child_data, c.partition_id"
"        Buffers: shared hit=200 read=6"
"        ->  Append  (cost=0.00..17931.22 rows=666738 width=84) (actual time=0.015..0.029 rows=52 loops=1)"
"              Buffers: shared read=1"
"              ->  Seq Scan on public.tab_parent_2 p  (cost=0.00..8953.56 rows=332925 width=84) (actual time=0.014..0.023 rows=52 loops=1)"
"                    Output: p.parent_id, p.parent_data, p.partition_id"
"                    Filter: (p.partition_id = ANY ('{2,3}'::numeric[]))"
"                    Buffers: shared read=1"
"              ->  Seq Scan on public.tab_parent_3 p_1  (cost=0.00..8977.66 rows=333813 width=84) (never executed)"
"                    Output: p_1.parent_id, p_1.parent_data, p_1.partition_id"
"                    Filter: (p_1.partition_id = ANY ('{2,3}'::numeric[]))"
"        ->  Custom Scan (RuntimeAppend)  (cost=0.42..0.56 rows=3 width=90) (actual time=0.008..0.009 rows=2 loops=52)"
"              Output: c.child_id, c.parent_id, c.child_data, c.partition_id"
"              Prune by: (p.partition_id = c.partition_id)"
"              Buffers: shared hit=200 read=5"
"              ->  Index Scan using tab_child_2_parent_id_idx on public.tab_child_2 c_1  (cost=0.42..0.56 rows=3 width=90) (actual time=0.008..0.008 rows=2 loops=52)"
"                    Output: c_1.child_id, c_1.parent_id, c_1.child_data, c_1.partition_id"
"                    Index Cond: (c_1.parent_id = p.parent_id)"
"                    Filter: (p.partition_id = c_1.partition_id)"
"                    Buffers: shared hit=200 read=5"
"Planning time: 2.150 ms"
"Execution time: 0.733 ms"

Что прогноз количества записей у шага Nested Loop 666439, это количество умножатеся на максимальную стоимость Custom Scan 0.56 и получается громадная цифра стоимости, которая и исключает план из рассмотрения. А реальное количество возвращённых шагом записей 100, ошибка в 6667 раз. То есть шаги в плане при работе берут нужное им количество записей, но прогноз почему-то катастрофически ошибается с количеством записей.

Так всё-таки может можно что-то сделать с размером записи?

funbringer commented 7 years ago

Нельзя ли как-то получить и подставить средний размер из наследников, сам постгрес же это как-то делает?

Мы не можем этого сделать, потому что постгрес сам вычисляет и заполняет эти оценки при планировании. Проблема нашего расширения на 90% в том, что постгрес делает, а не в том, что он не делает. Мы не можем вклиниться и отключить или переопределить это поведение. Если мы подменим оценки, то наши планы получат "нездоровое" преимущество перед стандартными планами, которые запланировал сам постгрес.

Что прогноз количества записей у шага Nested Loop 666439, это количество умножатеся на максимальную стоимость Custom Scan 0.56 и получается громадная цифра стоимости, которая и исключает план из рассмотрения.

Во-первых, оценка (чья? NestLoop?) считается не путем умножения 666439 на 0.56. В данном случае для NestLoop она будет считаться примерно так:

startup_cost +
cpu_per_tuple * outer_path_rows * inner_path_rows +
output_rows * cpu_per_output_row;

Во-вторых, можно заметить, что основная стоимость NestLoop берется из оценки верхнего Append, который чудовищно ошибается в количестве возвращаемых строк:

Append  (rows=666738) (rows=52)

И это не удивительно, потому что limit никак не влияет на оценку Append. К слову, pg_pathman никак не влияет на оценку верхнего Append, это можно проверить, выключив pg_pathman и выполнив запрос над public.tab_parent с limit 100. Оценка по-прежнему будет неадекватной.

NikitinNikolay commented 7 years ago

Мы не можем этого сделать, потому что постгрес сам вычисляет и заполняет эти оценки при планировании. Проблема нашего расширения на 90% в том, что постгрес делает, а не в том, что он не делает. Мы не можем вклиниться и отключить или переопределить это поведение. Если мы подменим оценки, то наши планы получат "нездоровое" преимущество перед стандартными планами, которые запланировал сам постгрес.

То есть вы не рулите вычислением стоимостей и размеров строк для шага RuntimeAppend? Жалко, что размер строки нельзя поправить. Ладно, закрываю баг.

funbringer commented 7 years ago

То есть вы не рулите вычислением стоимостей и размеров строк для шага RuntimeAppend

Как-то у вас все причудливо перемешалось. Нет, стоимость и количество строк RuntimeAppend мы оцениваем сами, и как раз для этого узла все оценки будут более-менее правильными, потому что мы располагаем правильной статистикой для всех партиций.

NikitinNikolay commented 7 years ago

Да, точно, извините. В шаге RuntimeAppend всё нормально. А вот шаг Nested Loop берёт ширину строк непонятно откуда.

funbringer commented 7 years ago

А вот шаг Nested Loop берёт ширину строк непонятно откуда.

К сожалению :( Все равно спасибо вам за диалог, есть повод подумать над оценками. Может быть, мы найдем какой-нибудь способ подправить ширину.

funbringer commented 7 years ago

Добавлю небольшое пояснение:

Nested Loop берет оценки ширины строки из родительских таблиц, которые в плане не видны. Предполагаемая ширина вычисляется еще до того, как был составлен хоть какой-нибудь план. Так как мы выключаем наследование, для вычисления ширины соединения используются оценки ширины родительских таблиц. А для Append используется совсем другой метод оценки, который берет среднее по детям.

В результате для Nested Loop ширина вычисляется без участия дочерних планов.

Если интересно, могу указать на место в коде постгреса: ф-ция build_join_rel. Начиная с 9.6, оценка ширины лежит в joinrel->reltarget->width. В указанной функции происходит следующее:

/*
 * Create a new tlist containing just the vars that need to be output from
 * this join (ie, are needed for higher joinclauses or final output).
 *
 * NOTE: the tlist order for a join rel will depend on which pair of outer
 * and inner rels we first try to build it from.  But the contents should
 * be the same regardless.
 */
build_joinrel_tlist(root, joinrel, outer_rel);
build_joinrel_tlist(root, joinrel, inner_rel);

Для соединения составляется target list (т.е. колонки, в данном случае это Output: p.parent_id, p.parent_data, p.partition_id, c.child_id, c.parent_id, c.child_data, c.partition_id), который выводится из target lists левого (outer rel) и правого (inner rel) отношений. Именно в этот момент и происходит оценка ширины (складываются значения всех колонок, включаемых в соединение):

/* Is it still needed above this joinrel? */
ndx = var->varattno - baserel->min_attr;
if (bms_nonempty_difference(baserel->attr_needed[ndx], relids))
{
    /* Yup, add it to the output */
    joinrel->reltarget->exprs = lappend(joinrel->reltarget->exprs, var);
    /* Vars have cost zero, so no need to adjust reltarget->cost */
    joinrel->reltarget->width += baserel->attr_widths[ndx];
}