Решение проблемы «duplicate key value violates unique constraint «pg_class_relname_nsp_index»» в PostgreSQL

от автора

Хочу поделиться решением проблемы «duplicate key value violates unique constraint «pg_class_relname_nsp_index»» в PostgreSQL, с которой я столкнулся при добавлении новых секций в секционированные таблицы, с использованием распараллеливания.

Суть проблемы заключается в дублировании имен для индексов секций, автоматически генерируемых сервером Postgres в одновременно исполняемых пользовательских параллельных процессах, для исходных двух и более секционированных таблиц, независимых друг от друга, но имеющих длинные имена, с совпадающей, в значительной мере, начальной частью.

Первоначальной моей реакцией на возникшую ситуацию, была простая попытка заменить слишком длинные имена таблиц на более короткие. А этот exception я посчитал внутренним багом Postgres. Но далее, аналогичная ситуация возникала вновь и вновь, с другими парами, более коротких имен, длиной около 30 символов (а это в два с лишним раза меньше текущего ограничения Postgres в 63 символа). Причем ошибка имела нестабильный характер: на тестовом стенде можно было получить удачную сборку, а при дальнейшем развертывании на продуктивном стенде сборка оказывалась неудачной. Можно, конечно, рассуждать о том, что незачем использовать такие длинные имена — что можно принять условное внутрипроектное соглашение о непревышении какого-то выбранного порога для имен. Но 30 символов — это мало. В свое время, в Oracle (в теперь уже старых версиях), ограничение в 30 символов, меня лично, угнетало. В ряде случаев, достаточно длинные имена не ухудшают читабельность проекта, а улучшают ее, внося большую информативность, особенно, в реальных базах данных с большим количеством сущностей, в которых непросто ориентироваться (тем более, по прошествии длительного времени). Кроме того, я считаю, что подбор приемлемого сокращения для технического имени часто оказывается более сложным занятием, чем простой выбор расширенного наименования. И, в конце концов, выбирая наименования для создаваемых таблиц, мы закладываем основу для последующего зависимого функционала приложения — очевидно, что замена наименований — это болезненная и нежелательная процедура. Т.о. мотивации для поиска решения проблемы у меня было достаточно.

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

Для начала, посмотрим, что это за unique constraint:

На то, что дублирование возникает в именах индексов это пока явно не указывает — это же уникальное ограничение может сработать и при регистрации в pg_catalog.pg_class других объектов БД (в первую очередь, таблиц):

select      relkind, *  from      pg_catalog.pg_class  where      not (relkind = any('{i, I}'::char[]))

В обсуждениях встречается похожая ошибка, с которой можно спутать нашу: duplicate key value violates unique constraint «pg_type_typname_nsp_index», — она, как раз, связана с дублированием в именах создаваемых таблиц, и также связана с параллельным исполнением. Она возникает на другом уровне — при регистрации в системном каталоге типа для создаваемой таблицы:

Мне представляется, что попытка регистрации одних и тех же таблиц в параллельных сессиях — это явно не проблема уровня СУБД — это изначально «неправильное» распараллеливание инструкций DDL со стороны пользователя (или со стороны того middleware, которое пользователь использует). В нашем же случае, на первый взгляд, распараллеливание «правильное» — по исходным условиям, таблицы разные, физически друг от друга не зависят, и могут создаваться в независимых параллельных сессиях.

Итак, запланируем создание секционированных таблиц со следующими именами:

  • the_table_with_a_really_long_descriptive_name_1

  • the_table_with_a_really_long_descriptive_name_2

  • the_table_with_a_really_long_descriptive_name_3

В каждую таблицу будет добавлен индекс:

create table     the_table_with_a_really_long_descriptive_name_1 (         key_field integer         , some_attribute integer     )     partition by          list(key_field) ;  create index i_the_table_with_a_really_long_descriptive_name_1$some_attribute      on the_table_with_a_really_long_descriptive_name_1 (         some_attribute     ) ;

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

Составим тестовый shell-скрипт с psql, в котором наши таблицы будут создаваться в цикле, с добавлением трех секций в каждую таблицу. Для сравнения, добавим также генерацию секционированных таблиц с более короткими именами. В конце скрипта выведем из системного каталога перечень созданных индексов.

#!/bin/bash  pg_connection_string="postgresql://postgres@localhost:5432/postgres"  function create_partitioned_table() {     echo "         \set ON_ERROR_STOP true         \         drop table if exists              the_table_with_a_short_name_$1         ;                  create table             the_table_with_a_short_name_$1 (                 key_field integer                 , some_attribute integer             )             partition by                  list(key_field)         ;                  create index i_the_table_with_a_short_name_$1$some_attribute              on the_table_with_a_short_name_$1 (                 some_attribute             )         ;         \         do \$plsql\$         begin             for i in 1..3 loop                 execute                     format(                         \$dml\$                         create table                              the_table_with_a_short_name_$1\$partition%s                          partition of                              the_table_with_a_short_name_$1                         for values                             in (%s)                         \$dml\$                         , i                         , i                     )                 ;             end loop             ;         end         \$plsql\$         ;         \         drop table if exists              the_table_with_a_really_long_descriptive_name_$1         ;                  create table             the_table_with_a_really_long_descriptive_name_$1 (                 key_field integer                 , some_attribute integer             )             partition by                  list(key_field)         ;                  create index i_the_table_with_a_really_long_descriptive_name_$1$some_attribute              on the_table_with_a_really_long_descriptive_name_$1 (                 some_attribute             )         ;         \         do \$plsql\$         begin             for i in 1..3 loop                 execute                     format(                         \$dml\$                         create table                              the_table_with_a_really_long_descriptive_name_$1\$partition%s                          partition of                              the_table_with_a_really_long_descriptive_name_$1                         for values                             in (%s)                         \$dml\$                         , i                         , i                     )                 ;             end loop             ;         end         \$plsql\$         ;     " |     psql \         --dbname=$pg_connection_string \         --quiet     sleep 1 }  execute_in_parallel="$1" # specify "true" to execute in parallel  for i in {1..3} do     if [[ $execute_in_parallel = "true" ]]; then          create_partitioned_table $i & # execution in the background     else         create_partitioned_table $i     fi done  wait  psql \     --dbname=$pg_connection_string \     --command="\di"  echo "All done"

Первый запуск, с последовательным исполнением, показывает результат автоматической генерации со стороны Postgres: у имен физических индексов для секций, составляемых на основе имени логического секционированного индекса и имени секции, отбрасывается префикс «i_», и, при превышении лимита по длине (63 символа), вырезается некая внутренняя часть (где-то, в конце второй трети), а также добавляется постфикс «_idx[N]».

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

Как видно, проблема возникает при составлении имен для внутренних объектов, которые, в итоге, не укладываются в действующее ограничение по длине; пользователь при этом составляет наименования объектов в рамках действующего ограничения и ничего не нарушает. В то же время, exception не возникает из-за какого-то бага Postgres — при последовательном исполнении проблема не возникает, а при распараллеливании требуется необходимое упорядочивание для инструкций по добавлению секций в таблицы с такими проблемными именами — т.е. другого решения, которое можно было бы реализовать внутри СУБД, думаю, нет. Еще стоит отметить, что проблема не возникает при параллельном добавлении секций в одну таблицу — проблема именно с независимыми друг от друга двумя и более таблицами, — т.к. автогенерация индексов для секций происходит в рамках единой для пользователя инструкции по добавлению секции (create table … partition of … или alter table … attach partition …), которая требует эксклюзивной блокировки головной таблицы и, соответственно, появления дубликатов не происходит.

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

create or replace view v_orderliness_logical_dependency as with      partitioned_table as (         select              n.nspname as schema_name             , t.relname as table_name             , t.oid as table_id         from              pg_catalog.pg_class t         join pg_catalog.pg_namespace n             on n.oid = t.relnamespace         where             t.relkind = 'p'::"char"     )     , name_length_limit as (         select              s.setting::integer / 2 - 2 as max_len         from              pg_catalog.pg_settings s         where              s.name = 'max_identifier_length'     )     , orderliness_logical_dependency as (         select             t.schema_name             , t.group_name             , array_agg(t.table_id order by t.table_name) as table_seq         from (             select                  t.schema_name                 , t.table_name                 , t.table_id                 , left(t.table_name, n.max_len) as group_name             from                  partitioned_table t             cross join name_length_limit n         ) t         group by             t.schema_name             , t.group_name         having              count(*) > 1     )     , dependency_group_item as (         select              dep.schema_name             , dep.group_name             , t.table_id             , pt.table_name             , t.ordinal_num         from              orderliness_logical_dependency dep         cross join lateral unnest(dep.table_seq)              with ordinality as t(table_id, ordinal_num)         join partitioned_table pt              on pt.table_id = t.table_id     ) select     forward_table.schema_name     , forward_table.table_name as forward_table_name     , forward_table.table_id as forward_table_id     , following_table.table_name as following_table_name     , following_table.table_id as following_table_id from      dependency_group_item forward_table join dependency_group_item following_table     on following_table.schema_name = forward_table.schema_name     and following_table.group_name = forward_table.group_name     and following_table.ordinal_num > forward_table.ordinal_num ;

Подбираются пары секционированных таблиц, имена которых совпадают по первым 29 символам. Далее, в каждой такой паре, на основе сортировки по имени, первая таблица условно определяется как главная, вторая — как зависимая.

Для наших трех таблиц мы получили полный набор логических связей: вторая и третья таблицы зависят от первой, третья зависит от второй.

Используя полученные логические зависимости, создадим конечное представление, с распределением секционированных таблиц по т.н. «цепочкам обработки».

create or replace view v_partitioned_table_processing_chain as with recursive      table_dependency as (         select              forward_table_id as primary_table_id             , following_table_id as dependent_table_id         from              v_orderliness_logical_dependency     )     , selected_table as (         select              n.nspname as schema_name             , t.relname as table_name             , t.oid as table_id         from              pg_catalog.pg_class t         join pg_catalog.pg_namespace n             on n.oid = t.relnamespace         where             t.relkind = 'p'::"char"     )     , target_table as (         select              t.table_id             , dep.primary_table_id             , case                 when dep.primary_table_id is not null then 1                 else 0             end as dep_level         from              selected_table t         left join table_dependency dep              on dep.dependent_table_id = t.table_id         union all         select             t.table_id             , dep.primary_table_id             , t.dep_level + 1 as dep_level          from              target_table t         join table_dependency dep              on dep.dependent_table_id = t.primary_table_id     ) select      t.schema_name     , t.table_name     , t.table_id     , t.dep_level     , coalesce(         t.primary_table_id         , t.table_id     ) as safe_parallel_processing_chain_id from (     select          st.schema_name         , st.table_name         , t.table_id         , t.dep_level         , t.primary_table_id         , row_number()             over (                 partition by                      t.table_id                 order by                      t.dep_level desc             )              as ordinal_number     from          target_table t     join selected_table st          on st.table_id = t.table_id ) t  where      t.ordinal_number = 1 order by      dep_level     , table_name     ;

Вывод данных из этого представления показывает, что исходные таблицы распределились по четырем «цепочкам обработки»: три проблемные таблицы объединились в одну общую цепочку, которая должна обрабатываться в рамках одного потока; а каждая из оставшихся таблиц с короткими именами получила свою индивидуальную цепочку, каждая из которых будет обрабатываться отдельно.

Модифицируем первоначальный тестовый shell-скрипт, внедрив в него новую логику распараллеливания:

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

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

#!/bin/bash  pg_connection_string="postgresql://postgres@localhost:5432/postgres"  function create_partitioned_table() {     echo "         \set ON_ERROR_STOP true         \         drop table if exists              the_table_with_a_short_name_$1         ;                  create table             the_table_with_a_short_name_$1 (                 key_field integer                 , some_attribute integer             )             partition by                  list(key_field)         ;                  create index i_the_table_with_a_short_name_$1$some_attribute              on the_table_with_a_short_name_$1 (                 some_attribute             )         ;         \         drop table if exists              the_table_with_a_really_long_descriptive_name_$1         ;                  create table             the_table_with_a_really_long_descriptive_name_$1 (                 key_field integer                 , some_attribute integer             )             partition by                  list(key_field)         ;                  create index i_the_table_with_a_really_long_descriptive_name_$1$some_attribute              on the_table_with_a_really_long_descriptive_name_$1 (                 some_attribute             )         ;     " |     psql \         --dbname=$pg_connection_string \         --quiet     sleep 1 }  function create_table_partitions() {     echo "         \set ON_ERROR_STOP true         \         do \$plsql\$         declare              l_table_rec record;         begin             for l_table_rec in (                 select                      table_name                 from                      v_partitioned_table_processing_chain                 where                      safe_parallel_processing_chain_id = $1                 order by                      dep_level             )              loop                 <<partitions_creation>>                 for i in 1..3 loop                     execute                         format(                             \$dml\$                             create table                                  %s\$partition%s                              partition of                                  %s                             for values                                 in (%s)                             \$dml\$                             , l_table_rec.table_name                             , i                             , l_table_rec.table_name                             , i                         )                     ;                 end loop                 ;             end loop             ;         end         \$plsql\$         ;     " |     psql \         --dbname=$pg_connection_string \         --quiet     sleep 1 }  execute_in_parallel="$1" # specify "true" to execute in parallel  for i in {1..3} do     create_partitioned_table $i done  temp_dir="$(mktemp -d)"  processing_chains="$temp_dir/processing_chains"  echo "     select distinct         safe_parallel_processing_chain_id     from         v_partitioned_table_processing_chain " | \ psql $pg_connection_string \     --tuples-only \     --no-align \     --field-separator=$'\t' \     > $processing_chains  while IFS=$'\n\t' read -r safe_parallel_processing_chain_id; do     if [[ $execute_in_parallel = "true" ]]; then          create_table_partitions $safe_parallel_processing_chain_id & # execution in the background     else         create_table_partitions $safe_parallel_processing_chain_id     fi done < $processing_chains  wait  rm -r "$temp_dir"  psql \     --dbname=$pg_connection_string \     --command="\di"  echo "All done"

В итоге, при запуске в параллельном режиме, ошибка больше не возникает, и мы получаем корректный результат:


ссылка на оригинал статьи https://habr.com/ru/articles/872990/