Picodata и PostgreSQL: где кончается совместимость

PostgreSQL-протокол — основной способ работы с Picodata: psql, pgx и psycopg подключаются к ней тем же кодом, что к PostgreSQL. Но совместимость частичная, и граница проходит не там, где ожидаешь. Живая матрица пяти клиентов против 59 SQL-конструкций, поведение pg_dump и мигратора, и почему у Picodata собственный JDBC-драйвер

Про Picodata часто говорят коротко: «она понимает протокол PostgreSQL». Это правда, и правда практичная — обычный psql подключается к кластеру без единого адаптера, а Go-код с pgx отличается от кода для PostgreSQL ровно одной строкой подключения. Но между «понимает протокол» и «ведёт себя как PostgreSQL» лежит расстояние, и знать его размер лучше до переезда, а не после.

Эта статья измеряет расстояние. Не по документации, а прогоном 59 SQL-конструкций через пять независимых клиентов на живом кластере.

Это вторая статья серии про Picodata, продолжение первой — про то, чем Picodata отличается от Tarantool. Все числа ниже — из стенда databases/picodata, подстенд pg-compat.

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

В статье

Как проверялось

Набор проб лежит в одном файле, и его исполняют пять раннеров: psql из образа PostgreSQL 18.4, Go с pgx/v5, Python с psycopg, Java со штатным драйвером org.postgresql и Java с фирменным io.picodata:picodata-jdbc. Каждый печатает строки одного формата — идентификатор пробы, вердикт, точное сообщение об ошибке.

Пять клиентов нужны не для солидности. Они разделяют два разных случая: конструкцию не понимает сервер или её не умеет отправить драйвер. Если пробу отвергли все — дело в Picodata; если один — дело в нём.

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

Контрольные пробы. Первые две пробы в наборе проверяют не Picodata, а сам раннер: одна заведомо валидна, вторая заведомо нет. Раннер, который не дал OK на первой и FAIL на второй, не различает исходы — например, если после первой же ошибки соединение осталось непригодным и всё дальнейшее посыпалось по инерции. Сборщик отчёта проверяет эту пару у каждого клиента и отказывается строить матрицу, если она не сошлась.

Пробы исполняются из файлов, а не из строк командной оболочки. Это не педантизм: на предварительной разведке проба LIKE 'too%', записанная через вложенные кавычки в docker exec sh -c, потеряла одинарные кавычки, превратилась в обращение к колонке с именем too% и вернула «column not found». Ещё немного — и в статье появилось бы утверждение, что Picodata не поддерживает LIKE. Поэтому раннер psql подаёт запрос на стандартный ввод, а остальные читают его из файла как строку.

Что работает

Сразу оговорю, что означает «работает», потому что разница существенная. Матрица из 59 проб отвечает только на вопрос, принимает ли сервер конструкцию: раннер выполняет запрос и смотрит на факт отказа. «Принято» здесь — это «не отвергнуто», а не «даёт тот же ответ, что PostgreSQL».

Совпадение ответов проверяется отдельным набором: одиннадцать пар запросов, сформулированных под обе схемы, результаты которых сравниваются построчно, плюс постусловия для DML — после INSERT строка читается, после UPDATE значение новое, после DELETE строки нет, после TRUNCATE таблица пуста. Совпадение засчитывается строго: оба запроса должны завершиться успешно (код возврата psql — ноль) и вернуть непустой результат, и только тогда сравниваются данные — без текста ошибок. Иначе одинаковая ошибка на обеих сторонах или два пустых ответа сошли бы за совпадение; что проверка их ловит, подтверждено отдельной пробой с заведомо несуществующей таблицей. В таком режиме все одиннадцать пар совпали, все семь постусловий выполнились — вывод прогона лежит в стенде, pg-compat/results.txt. Дальше «работает» означает именно это, и не больше: проверенное подмножество, а не вся принятая матрица.

Из 59 проб приняты 42:

  • соединения таблиц — JOIN, LEFT JOIN, подзапросы с IN, некоррелированный EXISTS;
  • множества — UNION, UNION ALL, EXCEPT, DISTINCT;
  • агрегация — GROUP BY … HAVING, sum, avg, min, max, count, count(DISTINCT …), string_agg;
  • CTE — обычный WITH (нерекурсивный);
  • выражения — CASE, COALESCE, CAST, LIKE, ILIKE, BETWEEN, IS NULL, конкатенация, substr, upper/lower;
  • DDL — CREATE TABLE, ALTER TABLE ADD COLUMN, CREATE INDEX, DROP INDEX, TRUNCATE, DROP TABLE;
  • DML — INSERT (в том числе многострочный), UPDATE, DELETE;
  • служебное — EXPLAIN, SET.

Набор достаточный, чтобы обычный прикладной код — прочитать, посчитать, записать — имел шанс переехать без переписывания. Именно шанс: проверенное подмножество отвечает так же, как PostgreSQL, но за пределами этих одиннадцати пар совпадение ответов стенд не гарантирует, и переезд всё равно требует прогона своих запросов на своих данных. Разрыв же начинается там, где код перестаёт быть обычным.

Что не работает и что это ломает

Семнадцать проб отвергнуты, и важно не то, что их семнадцать, а что именно за ними стоит.

Нет OFFSET. Классическая постраничная выдача LIMIT 20 OFFSET 40 не выполнится. Пагинацию придётся строить по ключу (WHERE id > последний_виденный), что для больших наборов и правильнее, но переписывать её придётся везде, где она была.

Нет системных каталогов. information_schema, pg_catalog.pg_tables, pg_class, current_database(), current_schema() — всё отвергается. Это ломает не запросы приложения, а всё, что строится на интроспекции: ORM, определяющие схему на старте, инструменты миграции, генераторы кода, административные панели.

ORDER BY только по тому, что выбрано. SELECT id FROM products ORDER BY views не пройдёт: column with name "views" not found. Сортировка работает по колонке из списка выборки, по её алиасу, по выражению над ней и по порядковому номеру — но не по колонке, которой в проекции нет. В PostgreSQL так можно, и запрос, годами работавший, здесь остановится.

Нет коррелированных подзапросов. EXISTS (SELECT 1 FROM q WHERE q.id = p.id) отвергается: внутренний запрос не видит алиас внешнего. Некоррелированный EXISTS при этом работает.

Нет RETURNING и нет ON CONFLICT. Два самых частых приёма современного кода записи: получить сгенерированный идентификатор одним запросом и сделать upsert одной операцией. Оба придётся разбирать на отдельные шаги.

Нет ALTER TABLE DROP COLUMN. Ответ прямой: unsupported DDL: ALTER TABLE _ DROP COLUMN is not yet supported. Колонку можно добавить, но не удалить — а значит, откат миграции, добавившей колонку, невыполним средствами SQL.

Нет WITH RECURSIVE, INTERSECT, SHOW, now().

Оконные функции — случай, где легко ошибиться в выводе. Проба row_number() OVER (ORDER BY id) по всей таблице падает, и напрашивается вывод «оконных функций нет». Он неверен: та же функция на подзапросе из 99 строк выполняется без замечаний. Смотреть надо на текст ошибки, а он про другое — Exceeded maximum number of rows (5000) in virtual table: 66751. Это лимит на материализацию промежуточной таблицы, а не отсутствие функции. А вот rank() действительно отсутствует, и говорится об этом прямо: window function rank does not exist. Две внешне одинаковые пробы, две разные причины — если бы мы записали только вердикт, а не сообщение, разницу бы потеряли.

Транзакции: худший вид несовместимости

Всё перечисленное выше — отказы. Приложение упадёт, разработчик увидит ошибку и починит. Но есть случай хуже, чем отказ.

Команды управления транзакциями Picodata принимает и не выполняет. Работа идёт в режиме autocommit, а BEGIN, COMMIT и ROLLBACK реализованы как заглушки. Выглядит это так:

BEGIN;
INSERT INTO tx_probe VALUES (1);
ROLLBACK;
SELECT count(*) FROM tx_probe;   -- 1, строка на месте

ROLLBACK отчитался успешно. Строка осталась. Код, который рассчитывает откатить изменения при ошибке, здесь не упадёт — он тихо оставит систему в состоянии, которого не должно было существовать. Такие вещи не находятся тестами на отказ; они находятся расследованием инцидента через полгода.

Эта проба вынесена в автоматическую проверку стенда отдельным шагом. И проверка устроена так, что упадёт, если однажды ROLLBACK начнёт откатывать по-настоящему: тогда сигнал будет означать не поломку стенда, а то, что поведение изменилось и текст этой статьи устарел.

Все клиенты равны, но драйверы — нет

Теперь результат, которого я не ожидал. У всех пяти клиентов совпал вердикт по каждой из 59 проб — «принято» или «отвергнуто». Ни одного расхождения: что отверг psql, то отвергли и pgx, и psycopg, и оба JDBC-драйвера. Речь именно о вердикте принятия, а не о сравнении данных: совпадение самих ответов — это отдельная проверка из раздела «Что работает».

Вывод простой и полезный: на уровне SQL граница совместимости проходит по серверу, и выбор драйвера её не сдвигает. Если конструкция не работает — она не заработает от смены клиента.

Тогда зачем Picodata собственный JDBC-драйвер, который она рекомендует для DBeaver? Ответ нашёлся не в SQL, а на уровень выше — в метаданных JDBC, тех самых, по которым графические клиенты строят дерево таблиц:

Вызов DatabaseMetaData PostgreSQL-JDBC 42.7.4 picodata-jdbc 5.1.0
getDatabaseProductName() PostgreSQL 15.0 Picodata 15.0
getTables() ошибка 1 таблица
getColumns() ошибка 7 колонок
getSchemas() ошибка 1
getPrimaryKeys() ошибка 1
getTypeInfo() ошибка 16 типов
supportsTransactions() true false

Штатный драйвер спотыкается на всех запросах метаданных — он спрашивает pg_catalog, которого нет. Практически это значит, что DBeaver со штатным драйвером не покажет дерево объектов: подключение будет, а навигации не будет.

Но самая интересная строка — последняя. Штатный драйвер на вопрос «поддерживаются ли транзакции» отвечает true, потому что видит перед собой «PostgreSQL 15.0» и отвечает за него. Фирменный отвечает false — честно. Любой инструмент, который спрашивает драйвер о возможностях вместо того, чтобы их проверить, со штатным драйвером получит неверную картину молча.

pg_dump и мигратор

Отсутствие каталогов и транзакций позволяет предсказать судьбу прикладных инструментов, но предсказание — не факт, поэтому оба проверены.

pg_dump 18.4 не работает. Он падает на самом первом запросе, даже не добравшись до схемы:

pg_dump: error: query failed: ERROR: sbroad: rule parsing error:
  SELECT pg_catalog.set_config('search_path', '', false);

Код возврата 1 в обоих режимах — и --schema-only, и --data-only. Резервное копирование средствами PostgreSQL отпадает целиком; у Picodata для этого свои механизмы.

Мигратор goose не работает — и ломается раньше, чем можно подумать. Дело не в тексте миграции: он падает на создании собственной таблицы версий, где используется id integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY. То есть инструмент не может даже завести учёт применённых миграций, не говоря о том, чтобы что-то применить. Ни таблицы миграции, ни журнала после попытки не появляется.

Попутно: метакоманды psql вроде \dt тоже не работают — они разворачиваются в запросы к pg_catalog. Список таблиц берётся из системного представления самой Picodata, _pico_table.

Чем платим

  • Совместимость покрывает код заметно лучше, чем инструментарий. Прикладные запросы в проверенной части переносятся почти без правок; ломается то, что смотрит на базу снаружи — дампы, миграторы, ORM с интроспекцией, GUI-навигация.
  • Проверять придётся своими запросами. Матрица показывает принимаемый синтаксис, а совпадение ответов подтверждено на ограниченном наборе. Для оценки переезда этого мало: нужен прогон собственных запросов на собственных данных, и стенд опубликован в том числе для этого.
  • Часть правок неизбежна и точечна: пагинация без OFFSET, запись без RETURNING и upsert-а, сортировка только по выбранным колонкам.
  • Транзакции требуют пересмотра логики, а не синтаксиса. Их отсутствие не выражается в ошибке компиляции или запроса — только в поведении.
  • Выбор драйвера не влияет на SQL, но влияет на инструменты. Для Java и DBeaver фирменный драйвер практически обязателен, и он тянется из собственного репозитория Picodata, а не из Maven Central.
  • Матрица привязана к версии. Всё перечисленное снято на Picodata 26.1.3; часть ограничений в сообщениях сервера прямо помечена как «not yet supported», то есть состав списка со временем меняется. Перед решением стоит перепроверить на своей версии — стенд для этого и опубликован.

Что дальше

Почему одни конструкции проходят, а другие упираются в лимит промежуточной таблицы, — вопрос устройства планировщика: статья про распределённый SQL Picodata. Как расширять кластер собственным кодом, когда SQL не хватает, — статья про плагины на Rust.

Тот же PostgreSQL-протокол в прикладном контексте — в статье про Tarantool и Picodata серии «Вычисления в оперативной памяти», где на нём построен перенос вычислений к данным.

Обсуждение в Telegram

Присоединиться →

Комментарии