Перейти к основному содержимому

Индекс указывает на несуществующую строку таблицы (ERROR: heap tid from index tuple)

Симптомы

При работе клиентских процессов возникают ошибки вида:

type=client backend ERROR:  heap tid from index tuple ({table_page},{line_pointer}) points past end of heap page line pointer array at offset {ix_off} of block {ix_page} in index {index_name}

Где:

  • ({table_page},{line_pointer}) – указывает на страницу таблицы и строку (tid);
  • (offset {ix_off} of block {ix_page} in index {index_name}) – указывает на индекс, страницу индекса, номер указателя на странице.

Возможны другие сообщения, которые сигнализируют о неконсистентности индекса:

heap tid from index tuple (%u,%u) points to unused heap page item at offset %u of block %u in index \"%s\
heap tid from index tuple (%u,%u) points to heap-only tuple at offset %u of block %u in index \"%s\

Ошибки выше могут проявиться и на пользовательских объектах (для запросов UPDATE/INSERT), и на объектах системного каталога (для разных SQL-запросов).

Ошибка фиксировалась на версиях Pangolin от 6.1.9 до 6.5.2.

Краткое описание дефекта

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

Условия обнаружения ошибки

Чтобы вызывался код, сообщающий об ошибке, нужно следующее стечение обстоятельств:

  • происходит вставка в заполненный блок индекса;
  • в этом блоке есть ссылки на мертвые версии строк в таблице;
  • к этим строкам ранее обращались запросы, которые пометили соответствующие строки индекса флагом dead.

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

Причины

Проблема проявляется при большой DML/DDL-нагрузке. Сценарий ее появления является достаточно редким.

Описание стандартного алгоритма работа очистки

Вакуум (автоочистка) работает по трехэтапному алгоритму:

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

    1. Если попытка удалась, то происходит активная вычистка страницы (pruning): находятся удаленные строки которые ушли за горизонт видимости. Такие строки, точнее их указатели из заголовка (line pointer) помечаются статусом LP_DEAD а адреса этих строк (их ctid) собираются в массив вплоть до определенного лимита. Также находятся все хот цепочки приводящие к мертвой строке. Все ctid такой цепочки так-же помещаются в список на очистку, а соответствующие указатели помечаются статусом LP_DEAD. Когда лимит достигнут, либо просканирована вся таблица, собранный массив ctid передается на следующий этап (см. п.2) – очистку индексов.

    2. Если страницу не удалось эксклюзивно заблокировать, то происходит сбор строк уже имеющих статус LP_DEAD. Такие строки могли образоваться либо в результате оборванного предыдущего вакуума, либо в результате схлопывания HOT-цепочек.

  2. Каждый индекс сканируется поблочно от начала до конца в физическом порядке. В каждом блоке индекса строки так же перебираются от первой до последней. Каждая строчка в индексе, точнее ctid в таблице, на который она указывает, сопоставляется со списком мертвых строк таблицы из п.1, и если такое соответствие находится, то строка удаляется из индекса.

  3. Когда все индексы почищены, таблица опять сканируется и все строки из списка составленного в п.1, а точнее их указатели из заголовка (line pointer) помечаются статусом LP_UNUSED. При этом происходит попытка усечения списка line pointer: если в конце списка есть диапазон line pointer имеющих статус LP_UNUSED, то список усекается на длину этого диапазона. Если все line pointer страницы имеют такой статус, то оставляется один line pointer со статусом LP_UNUSED.

Если к этому моменту еще не вся таблица оказалась просканирована, то процесс п.1-п.3 повторяется до конца таблицы.

Согласованность состояния индекса

При этом поддерживается правило (на уровне кода) что новые версии строк (tuple), которые вставляются в таблицу, не могут занять слоты блоков таблицы со статусом LP_DEAD. Такие слоты могут только освобождаться вакуумом. Для новых строк либо выделяются новые слоты, либо могут переиспользоваться слоты со статусом LP_UNUSED.

Таким образом индексы могут указывать на три вида слотов в таблицах:

  • LP_NORMAL – соответствуют обычным живым версиям строк;
  • LP_REDIRECT – соответствует строке которая подверглась HOT-обновлениям. Такой указатель пересылает на другой указатель. Но в конце цепочки должна быть живая версия строки;
  • LP_DEAD – это строки которые уже были отмечены мертвыми текущим либо предыдущим прерванным вакуумом, либо в процессе схлопывания хот цепочек.

Ситуация в которой запись индекса ссылается на лайн пойнтер в странице со статусом LP_UNUSED является невалидной и алгоритм вакуума описанный выше должен гарантировать невозможность такой ситуации.

В процессе исправления в коде simple deletion (см. далее) была добавлена проверка на корректность указателей которые удаляются.

Проверка происходит по такому стеку вызовов:

  • index_delete_check_htid;
  • heap_index_delete_tuples;
  • table_index_delete_tuples;
  • btdelitems_delete_check;
  • btsimpledel_pass;
  • btdelete_or_dedup_one_page;
  • btfindinsertloc;
  • btdoinsert;
  • btinsert;
  • index_insert;
  • ExecInsertIndexTuples;
  • ExecInsert;
  • ExecModifyTable;
  • ExecProcNodeFirst;
  • ExecProcNode;
  • ExecutePlan;
  • standard_ExecutorRun;
  • pgaudit_ExecutorRun_hook;
  • ExecutorRun;
  • SPIpquery.

В частности проверяется что указатели не указывают на line pointer в статусе LP_UNUSED и они не указывают на line pointer имеющие индекс больше максимально доступного на странице таблицы (за пределами массива line pointer).

Что такое simple deletion и где именно происходит проверка существующих указателей

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

  • simple deletion – строки индекса помеченные флагом dead (не путать со статусом LP_DEAD для указателей в страницах таблиц) удаляются. При этом статус dead строки индекса получают если ранее был запрос который по этой строке индекса прошел в таблицу и нашел там мертвую версию строки. Предусмотрен только такой ленивый механизм. Таким образом в индексе могут существовать строки, которые указывают на мертвые версии строк в таблице, но не помеченные флагом dead в индексе. Это возможно если к ним ранее не обращался ни один запрос. Такие строки не буду вычищены механизмом simple deletion;
  • bottom up deletion – удаляются дубликаты, которые образуются в результате обновлений строк по другим (чем данный индекс) индексируемым колонкам. В этом случае в соответствии с MVCC происходит порождение новой строки таблицы и добавление строк во все индексы, даже не затронутые обновлением напрямую. Такая ситуация называется version churn и механизм bottom up deletion призван с этим бороться;
  • deduplication – в процессе него, строки индекса имеющие одинаковый ключ индексирования объединяются в одну строку и вместо указателя на строку таблицы ctid, в такой записи хранится массив указателей на строки таблицы: tids. Таким образом достигается экономия места за счет заголовков индексных строк.

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

Проблема сплитов во время работы вакуума

Для алгоритма вакуума описанного выше, особую опасность представляют расщепления (split) страниц индекса. Как уже упоминалось, индекс сканируется в физическом порядке последовательно от начала до конца. Никаких предварительных блокировок не происходит. В процессе вакуума индекса блокируется только та страница, которая в данный момент очищается. Если в процессе сканирования индекса одна из еще не обработанных страниц расщепится так, что второй блок будет указывать на страницы индекса до того места на котором сейчас работает вакуум, то вакуум (в силу последовательного характера работы) такую страницу уже не посетит.

Для новых данных в индексе могут выделяться новые блоки либо переиспользоваться существующие. Индексные блоки связаны между собой в логически связанную структуру при помощи указателей. Однако если индексный блок в процессе работа полностью освобождается, то он помечается как свободный и исключается из логической структуры индекса. Далее по мере необходимости такой блок может быть снова подключен в произвольное место индекса. Таким образом упорядоченность блоков в файлах индекса может быть никак не связана с упорядоченностью блоков с логической структурой индекса.

Это может привести к не очистке соответствующих строк из индекса и соответствующим проблемам. Однако в алгоритм вакуума и расщепления в оригинальном PostgreSQL встроена защита. Она работает так:

  1. Каждый процесс сканирования вакуумом индекса получает свой vacuum cycle id. Это счетчик в разделяемой памяти, который инкрементируется в коде вакуума индекса когда он начинается. Полученное значение закрепляется за данной обработкой данного индекса. Оно передается везде по дереву вызовов методов вакуума. С другой стороны в разделяемой памяти сохраняется и поддерживается список отображающий все работающие в данный момент вакуумы индексов (используется просто oid индекса) на vacuum cycle id.
  2. Код расщепления учитывает этот список, и в момент расщепления производит поиск по нему. Если там находится индекс, по которому делается расщепление, то соответствующий ему vacuum cycle id сохраняется в заголовках страниц по цепочке сплита: оригинальной странице и той на которую она разделилась.
  3. В свою очередь код вакуума страниц индекса проверяет значение vacuum cycle id на обрабатываемой странице и сравнивает его со своим. Если значение совпадает, то происходит backtracing: помимо текущей страницы, вакуум проходит по цепочке на все страницы отмеченные соответствующим vacuum cycle id. Однако есть одна оптимизация: цепочка просматривается только на номера блоков которые меньше чем текущая позиция сканирования индекса. Это сделано в расчете на то, что до более поздних страниц, вакуум дойдет в порядке обычного сканирования (а не через backtracing).

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

Выше приводилось описание оригинальной функциональности. В коде Pangolin уже добавлены доработки связанные с глобальными индексами. В оригинальной версии PostgreSQL, только один процесс вакуума может обрабатывать один индекс. Разные процессы вакуума могут одновременно работать над разными индексами (в том числе и одной таблицы), но каждый индекс обрабатывается отдельным процессом. Однако в Pangolin были добавлены глобальные индексы. Глобальный индекс один на всю таблицу. Процессы вакуума, запущенные на разных партициях, могут одновременно очищать один общий глобальный индекс. Чтобы исключить разрушение блоков и уменьшить взаимное блокирование процессами вакуума, была добавлена отложенная обработка. Когда вакуум обрабатывает блок индекса он пытается получить на него в неблокирующем режиме (nowait) эксклюзивную блокировку. Если попытка была успешна, то блок обрабатывается вакуумом. Если она не удалась, то это может свидетельствовать о том что над этим блоком в данный момент идет работа, и блок добавляется в специальный список: deferred list и пропускается. Такие блоки обрабатываются отдельно после основного прохода вакуума: когда все блоки индекса просканированы, вакуум повторно возвращается к блокам из списка отложенных и делает новую попытку их очистить, в надежде на то, что они уже разблокировались. При этом для их обработки используется тот же оригинальный код, который избегает переходов вперед при обработке расщепленных блоков.

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

Решение/обходное решение

Выполнить REINDEX индекса, упоминаемого в сообщении.