Перенос с MS SQL Server на PostgreSQL ломается не на таблицах и не на объёме данных. Ломается он в пяти местах: в T-SQL-логике хранимых процедур, в сравнении и сортировке строк, в датах и времени, в автоинкрементных ключах после загрузки и в том, как две СУБД ведут себя при одновременной работе многих сессий. Всё остальное — типы, ключи, индексы — переносится почти формально.
Эта статья — инженерный разбор различий: что именно отличается, чем это проявляется и как проверить. Это не гарантия, что всё заработает: версию SQL Server и состав объектов нужно смотреть на конкретной системе. Общую схему переноса с этапами, ценами и порядком работ см. на странице миграции MS SQL на PostgreSQL.
Что на самом деле меняется при переходе с MS SQL на PostgreSQL
Различия удобно разложить по слоям: на нижних всё просто, на верхних приходится читать код и мерить.
| Слой | SQL Server | PostgreSQL | Что это значит при переносе |
|---|---|---|---|
| Данные | Типы T-SQL, IDENTITY, правила сортировки на уровне сервера, базы и столбца | Свой набор типов, последовательности, правила сортировки выбираются при создании базы | Типы сопоставляются таблицей, но регистр и сортировку решают заранее |
| SQL | Диалект T-SQL: TOP, APPLY, MERGE, OUTPUT | Диалект ближе к стандарту: LIMIT, LATERAL, RETURNING | Запросы приложения переписываются, ORM закрывает только часть |
| Процедурный код | Процедуры T-SQL с несколькими наборами результатов и своей обработкой ошибок | Функции и процедуры на PL/pgSQL | Конвертер даёт черновик, смысл проверяет человек |
| Конкурентность | Блокировки на строках и страницах, хинты, снимки по настройке | Многоверсионность (MVCC): читатели не блокируют писателей | Хинты уходят, нужна автоочистка и повтор транзакций при ошибках |
| Эксплуатация | SQL Server Agent, BACKUP DATABASE, проверка подлинности Windows | Планировщик снаружи или расширение, pg_dump и архив журнала, ролевой доступ | Резервные копии .bak в PostgreSQL не восстанавливаются, процедуры эксплуатации пишутся заново |
Типы данных: чем заменить DATETIME, BIT, MONEY и UNIQUEIDENTIFIER
Основная часть типов сопоставляется напрямую: int, bigint, smallint, numeric, date, xml имеют одноимённые аналоги. Остальное — в таблице.
| Тип в MS SQL | Тип в PostgreSQL | На что обратить внимание |
|---|---|---|
DATETIME, SMALLDATETIME | timestamp | Без часового пояса, как и в источнике. Значения DATETIME лежат с шагом около трёх миллисекунд, и эти хвосты переедут как есть |
DATETIME2(n) | timestamp(n) | Точность до микросекунд; сравнения по «равно» осторожнее с округлением на стороне приложения |
DATETIMEOFFSET | timestamptz | Хранится момент времени, исходное смещение не сохраняется |
BIT | boolean | Условия = 1 и = 0 дают ошибку типов, их переписывают на true и false |
MONEY, SMALLMONEY | numeric(19,4) | Тип money в PostgreSQL зависит от настройки локали, поэтому деньги хранят в numeric |
NVARCHAR(n), VARCHAR(n), (MAX) | varchar(n), text | Длина в PostgreSQL считается в символах. Разницы по скорости между varchar(n) и text нет, ограничение длины оставляют ради проверки данных |
UNIQUEIDENTIFIER | uuid | NEWID() заменяется на gen_random_uuid(), он встроен в новых версиях; NEWSEQUENTIALID() прямого аналога не имеет |
ROWVERSION | нет | Оптимистичную блокировку на этом поле переписывают на явный столбец версии |
Если в базе есть HIERARCHYID, GEOGRAPHY, SQL_VARIANT или типы CLR, прямого переноса нет. Для иерархий и геоданных есть расширения (ltree, PostGIS), но решение принимают по тому, какие функции типа реально вызывает код.
Строки и регистр: почему поиск по имени перестал находить записи
Самое частое расхождение после переноса — регистр. Типовые правила сортировки SQL Server нечувствительны к регистру (в названии правила обычно есть CI), поэтому WHERE name = 'ромашка' находит «Ромашка» и «РОМАШКА». В PostgreSQL сравнение по умолчанию регистрозависимо. Данные при этом не меняются, меняется результат запроса.
Условный пример (не из практики заказчика): отчёт группирует продажи по названию контрагента. В SQL Server «ООО Ромашка» и «ООО РОМАШКА» складывались в одну строку, в PostgreSQL выходит две. Число строк в таблицах сходится, а итоги по группам нет.
Варианты решения, у каждого свои ограничения:
- привести данные и запросы к одному регистру:
lower()в запросе плюс индекс по выражению; - расширение
citextдля столбцов, где регистр не нужен; - недетерминированные правила сортировки на ICU (с версии 12): они дают сравнение без учёта регистра, но с ограничениями, например по оператору
LIKE; сверяем с документацией вашей версии; ILIKEдля поиска по шаблону.
Ещё несколько расхождений в строках:
- Пробелы в конце. SQL Server при сравнении игнорирует хвостовые пробелы, для
varcharиtextв PostgreSQL они значимы. ФункцияLENпробелы не считала,length()считает. - Шаблоны
LIKE. Конструкции с квадратными скобками ([a-z],[^0-9]) в PostgreSQL не работают, их заменяют регулярными выражениями (~) илиSIMILAR TO. - Склейка строк. Оператор
+заменяется на||илиconcat(). Различие в NULL:||с NULL даёт NULL,concat()NULL пропускает. - Имена объектов. PostgreSQL приводит имена без кавычек к нижнему регистру. Если инструмент перенёс «ВерблюжийРегистр» в кавычках, придётся брать в кавычки имя в каждом запросе. Практичнее с самого начала перейти на имена в нижнем регистре.
- Сортировка. Порядок строк зависит от правил, заданных при создании базы, и от версии библиотеки операционной системы или ICU. После обновления ОС индексы по текстовым столбцам, возможно, придётся перестроить. Кодировку и правила сортировки выбирают до создания базы: потом их не поменять без пересоздания.
NULL и пустые строки: где поведение расходится
В отличие от Oracle, SQL Server и PostgreSQL различают пустую строку и NULL одинаково, поэтому тут сюрпризов меньше. Но расхождения есть, и все они тихие.
- Порядок сортировки. В SQL Server NULL при сортировке по возрастанию идут первыми, в PostgreSQL — последними. Пользователь видит перевёрнутый список. Для сохранения поведения пишут
ORDER BY x NULLS FIRST. - Ограничения уникальности. Индекс
UNIQUEв SQL Server допускает единственное значение NULL, в PostgreSQL — любое число. Ограничения, которые защищали от дублей с пустым значением, перестают защищать. В новых версиях естьNULLS NOT DISTINCT, но версию нужно проверить. ISNULLиCOALESCE.ISNULLвозвращает тип первого аргумента,COALESCEподбирает общий тип. На строках разной длины и числах разной точности результат может отличаться.- Сравнение с NULL. Если приложение запускало код с
SET ANSI_NULLS OFF, условия= NULLпридётся переписывать наIS NOT DISTINCT FROMилиIS NULL.
Даты и часовые пояса: GETDATE(), DATEDIFF и timestamptz
В SQL Server даты чаще всего хранятся без пояса, как локальное время сервера. Безопаснее всего переносить их в timestamp, а не в timestamptz: значения остаются теми же. Перейти на timestamptz можно позже, отдельным решением, когда понятно, в каком поясе писали данные.
- Текущее время.
GETDATE()заменяется наnow()илиcurrent_timestamp, но они возвращаютtimestamptzи фиксируются на начало транзакции. Если нужно время вызова, берутclock_timestamp(). Для времени по UTC:now() AT TIME ZONE 'UTC'. DATEDIFF. Считает не прошедшее время, а число пересечённых границ:DATEDIFF(day, ...)между 23:59 и 00:01 следующих суток даёт единицу. В PostgreSQL вычитание дат даёт интервал или целое число суток, и результат на пограничных случаях другой.- Начало месяца. Классический приём
DATEADD(month, DATEDIFF(month, 0, d), 0)заменяется наdate_trunc('month', d). Приведение числа к дате в духеCAST(0 AS datetime)в PostgreSQL не работает, пустую дату выражают через NULL или явное значение. AT TIME ZONE. Конструкция есть в обеих СУБД, но ведёт себя по-разному: в PostgreSQL она превращаетtimestampвtimestamptzи обратно. Каждое место использования проверяют отдельно.
IDENTITY и последовательности: что сломается после загрузки данных
Столбцу IDENTITY в PostgreSQL соответствует столбец GENERATED ... AS IDENTITY (с версии 10) или обычная последовательность. Основная ловушка — загрузка данных вместе с готовыми ключами: COPY и INSERT с явными значениями последовательность не сдвигают. Первая же вставка из приложения получит ключ 1 и упадёт на нарушении уникальности.
Порядок действий после загрузки:
- для каждой таблицы с автоключом найти последовательность через
pg_get_serial_sequence(); - выставить её на максимум существующего ключа:
setval(pg_get_serial_sequence('orders', 'id'), max(id))из той же таблицы; - для пустых таблиц последовательность оставить как есть;
- сверить значения последовательностей со скриптом перед каждой репетицией и перед боевым переключением.
Остальные соответствия: SET IDENTITY_INSERT ON заменяется столбцом GENERATED BY DEFAULT или вставкой с OVERRIDING SYSTEM VALUE; SCOPE_IDENTITY() и @@IDENTITY — фразой RETURNING id; DBCC CHECKIDENT — установкой значения последовательности. Нумерацию «без пропусков» на последовательностях строить нельзя в обеих СУБД: откат транзакции значение не возвращает.
TOP, постраничная выборка, APPLY, MERGE и OUTPUT: что переписывается в запросах
Синтаксис, который в приложении придётся менять почти наверняка. ORM закрывает часть случаев, но SQL-вставки в код и отчёты остаются.
| В T-SQL | В PostgreSQL | Примечание |
|---|---|---|
SELECT TOP 10 ... ORDER BY x | SELECT ... ORDER BY x LIMIT 10 | Версия FETCH FIRST 10 ROWS ONLY работает в обеих СУБД; TOP ... WITH TIES заменяется на FETCH FIRST ... WITH TIES в новых версиях |
OFFSET 40 ROWS FETCH NEXT 20 ROWS ONLY | LIMIT 20 OFFSET 40 или та же стандартная форма | Без ORDER BY страницы нестабильны; на больших таблицах OFFSET читает и выбрасывает пропущенное |
CROSS APPLY / OUTER APPLY | CROSS JOIN LATERAL / LEFT JOIN LATERAL ... ON true | Замена на обычное соединение теряет строки с пустым подзапросом |
MERGE | INSERT ... ON CONFLICT DO UPDATE, MERGE в версиях с 15 | Поведение при конкурентных вставках различается, проверяют под нагрузкой |
OUTPUT inserted.id | RETURNING id | Для UPDATE и DELETE то же самое; псевдотаблицы deleted нет, старые значения берут иначе |
UPDATE t SET ... FROM t JOIN s ON ... | UPDATE t SET ... FROM s WHERE t.id = s.id | Таблицу-цель в FROM не повторяют: иначе получится самосоединение |
DELETE t FROM t JOIN s ON ... | DELETE FROM t USING s WHERE ... | Та же логика, что и у UPDATE |
STRING_AGG(x, ',') WITHIN GROUP (ORDER BY y) | string_agg(x, ',' ORDER BY y) | FOR XML PATH('') для склейки заменяется на string_agg |
TRY_CAST, TRY_CONVERT | нет прямого аналога | Пишут функцию с обработкой ошибки или проверку формата до приведения |
Для постраничного вывода на больших таблицах лучше «клавишная» выборка: WHERE (created, id) > (:c, :i) ORDER BY created, id LIMIT 20. Она не читает пропущенные страницы и не «плывёт» при вставках.
Хранимые процедуры T-SQL и PL/pgSQL: процедуры, транзакции, курсоры, временные таблицы
Здесь работа самая объёмная, и здесь автоматический конвертер заметнее всего ошибается. Ниже — места, где логика расходится по смыслу, а не по синтаксису.
Процедуры и функции
В T-SQL процедура может вернуть несколько наборов результатов, и приложение читает их по очереди. В PostgreSQL такого поведения нет: функция возвращает один набор (RETURNS TABLE, SETOF) либо курсоры. Команда CREATE PROCEDURE и вызов CALL появились в версии 11 и позволяют управлять транзакцией внутри. Приложение, которое ждёт несколько наборов из одного вызова, переписывают на несколько вызовов.
Транзакции и ошибки
Это самое неочевидное различие. Если в SQL Server внутри открытой транзакции команда упала, транзакция может продолжаться (при XACT_ABORT OFF). В PostgreSQL после любой ошибки вся транзакция считается проваленной, пока её не откатят целиком или до точки сохранения. Блок BEGIN ... EXCEPTION ... END в PL/pgSQL создаёт вложенную подтранзакцию, и использование его в цикле заметно удорожает выполнение. Завершить транзакцию изнутри можно только в процедуре, вызванной на верхнем уровне и вне блока с обработчиком исключений.
Коды ошибок тоже другие. Приложение, которое проверяет номера SQL Server (1205 — взаимная блокировка, 2627 и 2601 — нарушение уникальности), переписывается на коды SQLSTATE: 40P01 — взаимная блокировка, 23505 — нарушение уникальности, 23503 — внешний ключ, 40001 — сбой сериализации.
Временные таблицы и табличные переменные
Табличных переменных в PostgreSQL нет, вместо них временные таблицы (CREATE TEMP TABLE) либо массивы и общие табличные выражения. Локальная временная таблица T-SQL живёт до конца процедуры, а в PostgreSQL — до конца сессии, если не задано ON COMMIT DROP. С пулом соединений это даёт конфликты имён между вызовами. Глобальных временных таблиц нет. Автоочистка временные таблицы не обрабатывает, поэтому для больших нужен ручной ANALYZE.
Курсоры и динамический SQL
Конструкции DECLARE c CURSOR ... FETCH NEXT ... @@FETCH_STATUS заменяются циклом FOR rec IN SELECT ... LOOP, а чаще — одним запросом. EXEC(@sql) и sp_executesql заменяются на EXECUTE format(...) USING ...: имена объектов вставляют через %I, значения передают параметрами, иначе остаётся риск внедрения SQL.
XML и JSON
JSON в SQL Server чаще всего лежит в строковом столбце и читается функциями OPENJSON, JSON_VALUE. В PostgreSQL для него есть тип jsonb с операторами ->, ->> и функциями jsonb_array_elements, jsonb_to_recordset. Методы XML .value() и .nodes() заменяются на xpath() и xmltable; поддержка XPath там ограничена первой версией языка, что для сложных выражений приходится учитывать.
Что вообще выходит за рамки SQL
xp_cmdshell, сборки CLR,OPENROWSET,BULK INSERT— прямых аналогов нет, функциональность переносится в приложение или во внешний скрипт, а загрузка файлов делается черезCOPY;- связанные серверы заменяются расширениями-обёртками внешних данных, их доступность в вашей сборке проверяем;
- полнотекстовый поиск
CONTAINSиFREETEXTзаменяется наtsvectorиtsqueryс индексом GIN; для русского языка есть встроенная конфигурация; - задания SQL Server Agent переезжают на
pg_cronили на планировщик ОС, пакеты SSIS и отчёты SSRS — в другие средства, и эта работа сопоставима с переносом самой базы.
Блокировки, MVCC и VACUUM: почему конкурентная работа ведёт себя иначе
В SQL Server при настройках по умолчанию читающая сессия ставит краткие блокировки и может ждать пишущую. В PostgreSQL используется многоверсионность: читатели не блокируют писателей и наоборот, каждая сессия видит согласованный снимок. Для приложения это обычно хорошая новость, но у неё есть цена и несколько нюансов.
- Старые версии строк. Каждое изменение создаёт новую версию строки, старая остаётся до очистки. Очистку выполняет автоочистка (
autovacuum), а долгая открытая транзакция, в том числе «зависшая» в состоянииidle in transaction, мешает ей и раздувает таблицы. После массовых загрузок и удалений нуженANALYZE, иногдаVACUUM; место таблице автоочистка почти не возвращает. Для сжатия естьVACUUM FULL, блокирующий таблицу, и внешние инструменты перепаковки. - Уровни изоляции. По умолчанию, как и в SQL Server, чтение подтверждённых данных.
READ UNCOMMITTEDв PostgreSQL ведёт себя какREAD COMMITTED: грязного чтения нет. НаREPEATABLE READиSERIALIZABLEтранзакция может получить сбой сериализации (40001), и приложение должно уметь повторять её целиком. - Хинты блокировок.
NOLOCKубирают,UPDLOCKзаменяют наSELECT ... FOR UPDATE,READPAST— наFOR UPDATE SKIP LOCKED. Прикладные блокировкиsp_getapplockзаменяются на консультативные блокировки (pg_advisory_xact_lock).
Индексы, планировщик и статистика: почему запрос после переноса медленнее
Запрос, который летал на SQL Server, после переноса может выполняться иначе. Это нормально: планировщик другой, и хинтов для принудительного плана нет. Что менять в первую очередь:
- Кластеризованный индекс. В PostgreSQL таблица хранится кучей, порядка строк по ключу нет. Команда
CLUSTERупорядочивает таблицу один раз и порядок не поддерживает. Запросы, рассчитанные на физический порядок, перестраивают индексами. - Покрывающие и фильтруемые индексы. Столбцы
INCLUDEподдерживаются с версии 11, фильтруемые индексы заменяются частичными (WHEREв индексе). Покрывающий индекс даёт сканирование только по индексу лишь тогда, когда автоочистка поддерживает карту видимости. - Индексированные представления. Заменяются материализованными представлениями, которые обновляются командой, а не автоматически. Это решение по расписанию свежести данных.
- Статистика. После загрузки выполняют
ANALYZE. Для коррелирующих столбцов есть расширенная статистика (CREATE STATISTICS). Планы читают черезEXPLAIN (ANALYZE, BUFFERS), нагрузку находят черезpg_stat_statements.
Поэтому после переноса обязателен прогон реального профиля запросов, снятого до переноса, а не только проверка правильности данных.
Как проверить перенос: сверка и чек-лист после загрузки
Перенос верен, когда проверено четыре вещи: данные, результаты, поведение и время. Таблица ниже — минимум для приёмки. Все проверки автоматизируют и запускают при каждой репетиции переноса, а не один раз перед переключением.
| Что сверяем | Как | Признак готовности |
|---|---|---|
| Число строк | Подсчёт по каждой таблице на обеих СУБД | Совпадение; любое расхождение объяснено письменно |
| Суммы и границы | Суммы по числовым столбцам, минимум и максимум по датам, число NULL по столбцам | Совпадение после приведения к одному формату округления |
| Контрольные суммы | Хэш строки, собранной в явном формате на обеих сторонах | Совпадение; форматы чисел и дат заданы явно, иначе хэши разойдутся без причины |
| Последовательности | Значение последовательности против максимума ключа | Следующий ключ не меньше максимума |
| Отчёты | Ключевые отчёты на одинаковых входных данных, сравнение построчно с явной сортировкой | Результаты совпадают; порядок строк проверен отдельным пунктом |
| Процедуры | Вызов с набором входных значений, сравнение выходов и побочных эффектов | Совпадение на нормальных и краевых данных |
| Время | Список ключевых запросов до и после на одних данных | Нет деградации сверх согласованного порога; пороги записаны до замера |
| Восстановление | Учебное восстановление из копии с замером времени | Администратор заказчика восстановил копию сам |
Что делаем мы и с чего начать
Мы не обещаем конвертер «в один клик»: переносим схему и данные, читаем и переписываем процедуры, собираем сверку и гоняем нагрузку на тестовом контуре. Исходники и скрипты остаются у вас, работаем в вашем контуре. Проект начинается с обследования: 200 000 ₽ за одну базу и одно-два приложения, 350 000 ₽ за несколько систем, с вычетом из следующего этапа. На выходе — инвентаризация объектов и потребителей, отчёт о совместимости и вилка бюджета. Похожие задачи: миграция на PostgreSQL, переход с Oracle, разбор задачи с названным сроком.
Частые вопросы
Можно ли перенести MS SQL на PostgreSQL конвертером в один клик?
Схему и данные в основном можно, процедурный код только частично. Конвертер переносит синтаксис, но не смысл: порядок сортировки, поведение NULL и транзакций приходится читать и проверять руками. Мы автоматического решения «в один клик» не обещаем.
Как в PostgreSQL сделать аналог TOP и постраничной выборки?
Для первых строк используют LIMIT или стандартную форму FETCH FIRST n ROWS ONLY, для страниц LIMIT с OFFSET. На больших таблицах OFFSET читает и выбрасывает пропущенное, поэтому для длинных списков лучше выборка «после последнего ключа».
Почему в PostgreSQL запрос не находит запись, которую SQL Server находил?
Скорее всего, из-за регистра: типовые правила сортировки SQL Server его не учитывают, а PostgreSQL по умолчанию учитывает. Решения: единый регистр, расширение citext, недетерминированные правила сортировки на ICU или индекс по lower(). Выбор зависит от версии и ограничений.
Почему после переноса новые записи не вставляются с ошибкой дубликата ключа?
Данные залиты с явными ключами, а последовательность осталась на начальном значении. Её нужно выставить на максимум существующего ключа. Это входит в обязательную проверку после каждой загрузки.
Нужно ли запускать VACUUM вручную?
Обычно нет: автоочистка включена по умолчанию. Вручную и осознанно ею занимаются после массовых загрузок и удалений: выполняют ANALYZE, а при раздувании таблиц разбирают причины, чаще всего долгие открытые транзакции.
Чем заменить хинт NOLOCK?
Просто убрать: в PostgreSQL читатели не блокируют писателей, а грязного чтения нет. Если хинт защищал от ожидания блокировки, проверяют, ради чего он стоял. Очереди на READPAST переписывают на FOR UPDATE SKIP LOCKED.
Чем заменить SQL Server Agent, SSIS и SSRS?
Прямых аналогов нет. Задания переносят на pg_cron или планировщик операционной системы, пакеты SSIS заменяют скриптами или другим средством загрузки, отчёты SSRS переносят в другое средство отчётности. Объём видно на обследовании.
Как перенести базу 1С с MS SQL на PostgreSQL?
Платформа 1С работает с PostgreSQL, и информационная база переносится средствами платформы: выгрузкой и загрузкой. Версию и сборку PostgreSQL сверяют с документацией вашей версии платформы. Внешние обмены и задания вокруг базы переносятся отдельно.