Доверяем ли мы качеству OSS PostgreSQL? — выглядит он достойно. А насколько безопасны фичи, добавленные вендором? Это вопрос, поскольку код закрытый. Сила бренда — это хорошо, но хотелось бы иметь объективное подтверждение.
Здесь я рассказываю о небольшом изобретении — модуле pg_pathcheck. Он выводит на новый уровень контроль надёжности СУБД — позволяет проверять качество кода на стороне клиента. А конкретно, на его серверах и нагрузке проверить, не остаётся ли внутри оптимизатора так называемые dangling pointers — указателей вникуда. Это может не приводить к проблемам, пока оптимизатор не трогает эти указатели, но вот если таки тронет...
Предыстория
Эта история началась в июле 2021 года. В то время я дорабатывал решение для шардинга на базе postgres_fdw. На этапе тестирования инженеры прислали пример запроса, который периодически падал в SEGFAULT. Заглянув в план, я понял, что что-то здесь идёт не так:
Append -> Nested Loop Output: data_1.b Join Filter: (g1.a = g2.a) ... -> Materialize Output: g2.a, data_2.b -> Hash Join Output: g2.a, data_2.b Hash Cond: (data_2.b = g2.a) ...
Первый очевидный вопрос — как нода Materialize оказалась непосредственным потомком Append? Второй, уже поинтереснее, — как Append умудряется объединять источники с разной шириной кортежа? Неудивительно, что запрос падает в SEGFAULT; и при этом он ещё и нестабилен — иногда выдаёт совершенно нормальный план.
Задача казалась теоретически невозможной — оптимизатор так не работает. После нескольких дней отладки выяснилось, что виноваты dangling pointers. При построении одного из альтернативных Append-путей оптимизатор добавляет в pathlist дочернего узла плана более дешёвый вариант оператора и вытесняет им прежний. Но ранее построенный Append продолжает ссылаться на этот, уже освобождённый, участок памяти. На следующем шаге планирования аллокатор выдаёт ровно эту же ячейку под Path вышестоящего оператора JOIN — и мы получаем план, который выглядит полной бессмыслицей.
И вот теперь, чтобы предотвращать подобные ситуации и появился pg_pathcheck.
Как использовать
Применение максимально просто. Будучи установлен в систему любым из стандартных для расширений PostgreSQL способом, достаточно загрузить его командой LOAD 'pg_pathcheck', если вы хотите ограничить его влияние отдельным процессом, либо указать в shared_preload_libraries, если нужно использовать в каждом процессе каждой БД кластера.
После загрузки модуль будет для каждого запроса проверять внутренние структуры оптимизатора на наличие dangling pointers. Можно настроить проверку только итоговой структуры дерева плана с помощью GUC pg_pathcheck.end_walk, либо скрупулёзно контролировать все промежуточные стадии процесса планирования, задействовав GUC pg_pathcheck.stage_checks.
При возникновении проблем в лог будут выводиться сообщение вида:
WARNING: pg_pathcheck: invalid NodeTag T_SeqScan in pathlist, rel {c, unnamed_subquery} DETAIL: pathlist contents: [0] T_NestPath; [1] T_SeqScan INVALID HINT: query: EXPLAIN (COSTS OFF) Sort Sort Key: c.conname -> Nested Loop Semi Join Join Filter: (c.confrelid = ('1'::oid)) -> Seq Scan on pg_constraint c -> Append -> Result -> Result
Сначала выводится проблема (в данном случае по адресу памяти, где должна находиться внутренняя структура оптимизатора Path, было обнаружено что-то, что интерпретируется как структура SeqScan, относящаяся к нодам финального плана запроса. В разделе DETAIL выводится состояние структуры pathlist - вариантов плана запроса, доступных оптимизатору на данный момент. В примере видно, что первый элемент - NestPath, который представляет собой заготовку для ноды плана NestLoop. В качестве HINT приводится проблемный запрос.
Этой информации должно быть достаточно для начала анализа потенциальных проблем в коде СУБД. Для уменьшения шума в логах рекомендуется сначала (если есть такая возможность) применить патч, фиксирующий существующее пока слабое место в community-версии PostgreSQL.
На сегодняшний день имеется нативная версия модуля для master ветки PostgreSQL (там имеются все необходимые инструменты для инспектирования плана) и портированная версия для Postgres 17 и 18 - она слегка лимитирована в силу ограниченного набора хуков оптимизатора, однако выполняет задачу в достаточном для большинства кейсов объёме.
Откуда берутся dangling pointers (немного Postgres Internals)
PostgreSQL строит pathlist каждого отношения инкрементально через add_path(). Когда вновь прибывший путь доминирует над существующим — дешевле по всем значимым измерениям (startup cost, total cost, pathkeys, parallel-safety) — доминируемый path может быть освобождён немедленно.
Поскольку оптимизатор строит план запроса методом «снизу вверх», сначала полностью готовятся pathlists нижележащих операций (например, сканирования), а затем строятся pathlists вышестоящих операций (например, Append) со ссылками на конкретные элементы pathlists нижних нод.
Всё хорошо до того момента, когда оптимизатор, строя Path вышестоящей операции, решает, что можно улучшить план, добавив что-то в pathlist ниже. Тогда возникает вероятность, что в нижележащем pathlist будет вытеснен path, на который уже есть ссылки выше по дереву плана. Если это происходит — получаем dangling pointer.
Пример такого поведения есть и в ядре самого Postgres. Минимальный воспроизводимый случай выглядит так:
DROP TYPE IF EXISTS insenum CASCADE; CREATE TYPE insenum AS enum ('L1', 'L2'); EXPLAIN (COSTS OFF) SELECT enumlabel, CASE WHEN enumsortorder > 20 THEN NULL ELSE enumsortorder END AS so FROM pg_enum WHERE enumtypid = 'insenum'::regtype ORDER BY enumsortorder;
С загруженным pg_pathcheck увидим предупреждение:
WARNING: pg_pathcheck: invalid NodeTag T_SeqScan in pathlist, rel {pg_enum} DETAIL: pathlist contents: [0] T_ProjectionPath; [1] T_SeqScan INVALID Sort Sort Key: enumsortorder -> Seq Scan on pg_enum Filter: (enumtypid = '16590'::oid)
Суть в следующем: path, представляющий сканирование таблицы pg_enum, при определённых условиях попадает и в ordered_rel — отношение, представляющее отсортированный результат запроса. В определённый момент, когда в ordered_rel появляется новый path PP3, старый общий PP2 вытесняется и освобождается логикой add_path() — но input_rel->pathlist по-прежнему хранит указатель на освобождённый чанк:
input_rel {pg_enum} ordered_rel ┌──────────────────┐ ┌──────────────────┐ │ pathlist: │ │ pathlist: │ │ [0] → PP1 │ │ [0] → PP2 ◄────┐ │ [1] → PP2 ─────────── SHARED ──────────────────────── ┘ └──────────────────┘ └──────────────────┘ input_rel {pg_enum} ordered_rel ┌──────────────────┐ ┌──────────────────┐ │ pathlist: │ │ pathlist: │ │ [0] → PP1 │ │ [0] → PP3 │ │ [1] → ??? ◄─── dangling ───╳╳╳ │ │ └──────────────────┘ pfree'd └──────────────────┘ chunk
Почему это всё-таки работает
В ванильном Postgres этот пример не вызывает видимых проблем благодаря скрытому инварианту: Postgres никогда не сканирует pathlist input_rel, если уже строится ordered_rel — вместо этого он использует прямые ссылки на cheapest_* paths. Dangling pointer создаётся, но никто не разыменовывает его до того момента, когда per-query memory context сбрасывается в конце оператора.
Расширения, однако, часто несут собственную внутреннюю логику и могут нуждаться в полном обходе pathlist — что и триггерит ошибку. Ещё сложнее дело обстоит в разработчиками форков - они могут не знать о "скрытых контрактах", заложенных в оптимизатор. Добавляя новые степени свободы своими фичами они могут триггерить скрытые до времени ошибки. И не факт, что все они будут найдены и исправлены до отправки в прод.
Небольшой walker
pg_pathcheck — это примерно 600 строк на C. Расширение регистрирует два хука планировщика: create_upper_paths_hook для запоминания верхнего PlannerInfo и planner_shutdown_hook для собственно проверки.
Walker обходит каждый Path, достижимый из верхнего PlannerInfo. Это означает массивы upper_rels[], каждый элемент simple_rel_array с его опциональным subquery subroot, все join-отношения, собранные во время динамического программирования, различные параллельные RelOptInfo (unique_rel, grouped_rel, part_rels), а также — внутри составных Path-нод — все встроенные поля sub-path (outerjoinpath, innerjoinpath, subpath, subpaths, bitmapqual, …). Хеш посещённых указателей гарантирует линейный обход.
На каждом указателе запускаются две проверки. Первая — whitelist NodeTag: если path->type не является одним из известных тегов семейства Path, память либо заполнена 0x7F (освобождена, ещё не переиспользована), либо переаллоцирована под какой-то другой тип ноды. Вторая проверка, для base- и join-отношений, — соответствие parent: path->parent для path в rel->pathlist должен быть равен самому rel. Несовпадение ловит aliasing в рамках одного size class — освобождённый чанк, который был переиспользован под другой валидный Path, принадлежащий совершенно другому отношению. Проверка тега в этом случае проходит, но владелец — не тот.
При срабатывании проверки расширение выдаёт отчёт с настраиваемым elevel (WARNING / ERROR / PANIC, управляется через pg_pathcheck.elevel). Отчёт называет отношение, слот, в котором сидит подозрительный указатель, полное содержимое pathlist (с аннотацией каждого элемента по типу ноды) и — через debug_query_string в hint — запрос, который спровоцировал находку.
Особенности аллокатора
aset.c в PostgreSQL использует size-классы со степенями двойки. Path занимает 80 байт, что попадает в 128-байтный класс. Туда же попадают:
ProjectionPath(96),SortPath(88),MaterialPath(88);NestPath,AppendPath(112);среди Plan-нод:
SeqScan(112),BitmapHeapScan(120),NestLoop(128),Hash(128),Result(128),Gather(128) и примерно дюжина других сканов.
Когда Path освобождается, его слот возвращается во freelist 128-байтного класса. Следующий makeNode(<Something>) внутри планировщика подхватывает ровно этот адрес. Старый dangling pointer, который был невидим во время планирования, теперь указывает на вполне живой Path или Plan-ноду — с валидным, но неверным NodeTag, и с полями, предназначенными для совершенно другого типа объекта.
Именно поэтому CLOBBER_FREED_MEMORY сам по себе недостаточен для обнаружения проблемы: паттерн заполнения перезаписывается реаллокацией прежде, чем какой-либо walker успеет его увидеть. Из примерно 4000 находок в моём полном прогоне тестов количество указателей с сырым заполнением 0x7F7F7F7F равно нулю. Похоже, память переиспользуется полностью.
Valgrind ловит use-after-free в момент разыменования — а в нашем случае ничто не разыменовывает протухший указатель при нормальном выполнении. Поэтому структурный walker, проверяющий семантические инварианты pathlist — «этот указатель должен ссылаться на живой Path, принадлежащий данному отношению» — является правильным инструментом для use-after-free, который остальная часть программы достаточно дисциплинирована, чтобы не дёргать. Байтовые инструменты ловят только разыменование, а не саму висячую ссылку.
Кому это может пригодиться
Следующие категории разработчиков могут найти этот код полезным.
Разработчики ядра PostgreSQL. На pgsql-hackers обсуждается как минимум три варианта решения — reference-counted paths, флаг used и локальные memory context — и дискуссия ходит по кругу вокруг вопроса, стоит ли формализовать негласный контракт. Этот dataset может сделать обсуждение более практическим.
Авторы расширений — особенно те, кто пишет custom-scan providers, FDW, фичи оптимизации или инструменты инспекции плана — получают инструмент для sanity check.
Мейнтейнеры форков находятся в зоне наибольшего риска. Форки модифицируют планировщик агрессивнее, чем это могут делать расширения, и релизят по расписанию, не всегда синхронизированному с PostgreSQL master. Прогон pg_pathcheck на тестовом наборе форка покажет, сохраняет ли ваш код инварианты, на которые ядро неявно полагается.
Отделы сопровождения СУБД на стороне клиента. Имея доступ к непосредственно данным в тестовом контуре компании, можно на живой системе проверять скрытые проблемы. И речь не только о `pg_pathcheck` - таким же способом можно интегрировать провеку инвариантов и в других частях кода ядра, был бы запрос.
Что дальше
Данный модуль не вещь в себе, а скорее демонстратор технологии, как вообще можно объективно оценивать надёжность OSS кода не предоставляя в обязательном порядке вендору доступ к данным. Развивать его можно в разных направлениях. Наиболее очевидное - изучить Assert различных инвариантов в коде PostgreSQL и делать подобные проверки из раширения, проходя по всем закоулкам внутренних структур системы.
Для примера, имплементируя фичу Self Join Elimination мы обнаружили (и исправили) следующее. В процессе планирования оптимизатор копирует части дерева запроса (parse tree) и в некоторых из них остаются ссылки на уже удалённые (попруненые) части плана. Хорошо, когда такая копия дерева уже не используется - оно будет почищено перед фазой выполнения запроса. А вот если в какой-то момент обращение к ней таки произойдёт?
Так что пробуйте, находите проблемы и отправляйте репорты. Вместе сделаем наш код более стабильным!
THE END.
28 апреля 2026, Мадрид, Испания.