Миграция с MySQL на PostgreSQL: пошаговое руководство без простоев для production-систем
Введение: почему компании мигрируют с MySQL на PostgreSQL в 2026 году
К 2026 году PostgreSQL уверенно занимает первое место среди реляционных СУБД с открытым кодом по индексу популярности DB-Engines. Причины перехода с MySQL на PostgreSQL носят как технический, так и стратегический характер.
Во-первых, PostgreSQL предлагает более богатую систему типов: нативный JSONB с индексированием, массивы, диапазонные типы, полнотекстовый поиск и расширяемость через расширения (PostGIS, pgvector, TimescaleDB). MySQL здесь существенно уступает.
Во-вторых, PostgreSQL строго следует стандарту SQL. Это снижает число сюрпризов при сложных запросах: оконные функции, рекурсивные CTE, LATERAL JOIN — всё работает предсказуемо. MySQL исторически имел слабую поддержку этих конструкций и ряд странных умолчаний (например, отключённый по умолчанию строгий режим в старых версиях).
В-третьих, лицензионная политика Oracle вокруг MySQL заставляет компании задуматься о рисках вендор-локина. PostgreSQL лицензирован под собственной свободной лицензией без ограничений на коммерческое использование.
Наконец, облачные провайдеры (AWS Aurora PostgreSQL, Google AlloyDB, Azure Flexible Server) активно инвестируют именно в экосистему PostgreSQL, предлагая более зрелые managed-решения.
Главная сложность миграции — не разница в синтаксисе, а необходимость провести её без остановки сервиса. Production-системы не могут позволить себе даже час простоя. В этой статье разберём полный цикл zero-downtime миграции с MySQL на PostgreSQL: от оценки объёма работ до финального переключения трафика.
Оценка scope миграции
Перед тем как писать первую команду, нужно провести аудит текущей базы. Это самый важный и часто недооцениваемый этап.
Что нужно проинвентаризировать
- Типы данных:
TINYINT(1)используется в MySQL как булев тип — в PostgreSQL есть нативныйBOOLEAN.DATETIMEvsTIMESTAMP WITH TIME ZONE.ENUMв MySQL и PostgreSQL реализованы по-разному.UNSIGNED INTв PostgreSQL не существует. - Хранимые процедуры и функции: MySQL-процедуры написаны на диалекте, несовместимом с PL/pgSQL. Каждую процедуру придётся переписывать вручную.
- Триггеры: синтаксис принципиально отличается. В PostgreSQL триггер вызывает отдельную функцию, а не содержит логику внутри себя.
- Специфичный синтаксис:
GROUP BYв MySQL менее строгий,INSERT ... ON DUPLICATE KEY UPDATEзаменяется наINSERT ... ON CONFLICT,LIMIT x, yзаменяется наLIMIT y OFFSET x. - Кодировка:
utf8в MySQL на самом деле трёхбайтовый UTF-8. Настоящий четырёхбайтовый — этоutf8mb4. PostgreSQL использует стандартный UTF-8. - Collation: правила сортировки строк могут отличаться, что влияет на индексы и сортировку результатов.
Для быстрого подсчёта объёма работ запустите в MySQL:
-- Получить список всех таблиц с типами данных
SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, COLUMN_TYPE
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = 'your_db'
ORDER BY TABLE_NAME, ORDINAL_POSITION;
-- Хранимые процедуры
SELECT ROUTINE_NAME, ROUTINE_TYPE
FROM information_schema.ROUTINES
WHERE ROUTINE_SCHEMA = 'your_db';По итогам аудита создайте таблицу рисков: какие объекты требуют ручной доработки, какие мигрируют автоматически, какие несут наибольший риск регрессий.
Стратегия zero-downtime миграции
Для production-систем существуют две основные стратегии, которые можно комбинировать.
Dual-write (двойная запись)
Приложение одновременно пишет в обе базы — MySQL и PostgreSQL. Читает при этом из MySQL (старой базы). Данные накапливаются в PostgreSQL. После верификации чтение переключается на PostgreSQL, затем запись в MySQL отключается.
Плюсы: полный контроль на уровне приложения, простой откат. Минусы: нужно изменять код приложения, есть риск рассинхронизации при ошибках записи в одну из баз.
CDC (Change Data Capture)
Подход основан на чтении бинарного лога MySQL (binlog) и применении изменений к PostgreSQL в реальном времени. Не требует изменения кода приложения на первом этапе.
Типичный стек CDC-миграции:
- Сделать initial snapshot: перенести данные из MySQL в PostgreSQL через pgLoader или AWS DMS.
- Запустить CDC-агент (Debezium), который читает MySQL binlog и публикует события в Kafka.
- Kafka Consumer применяет изменения к PostgreSQL.
- После того как PostgreSQL догнал MySQL по данным — переключить трафик.
Комбинированная стратегия: используйте CDC для синхронизации данных + dual-write на финальном этапе как страховку перед cut-over.
Инструменты миграции: сравнение
pgLoader
pgLoader — специализированный инструмент для загрузки данных в PostgreSQL. Умеет конвертировать типы данных «на лету», работает быстро благодаря COPY-протоколу.
LOAD DATABASE
FROM mysql://user:password@mysql-host/source_db
INTO postgresql://user:password@pg-host/target_db
WITH include drop, create tables,
create indexes, reset sequences,
workers = 8, concurrency = 1
SET work_mem to '128MB',
maintenance_work_mem to '512MB'
ALTER SCHEMA 'source_db' RENAME TO 'public';pgLoader хорошо подходит для initial load, но для live CDC не предназначен.
AWS DMS (Database Migration Service)
Managed-сервис от Amazon. Поддерживает как full load, так и CDC-режим из MySQL в PostgreSQL (в том числе Aurora). Прост в настройке через UI, но имеет ограничения: не мигрирует хранимые процедуры, некоторые типы данных требуют ручной настройки маппинга. Оправдан, если вы уже работаете в AWS-экосистеме.
Debezium
Open-source CDC-платформа на базе Apache Kafka Connect. Читает MySQL binlog и публикует события изменений. Наиболее гибкий и production-proven инструмент для непрерывной синхронизации.
Самописные скрипты
Оправданы только для небольших баз или специфичной логики трансформации. Для больших production-систем слишком высок риск ошибок.
Рекомендация: для большинства проектов оптимальна связка pgLoader (initial load) + Debezium + Kafka (CDC). Если проект на AWS — рассмотрите DMS как альтернативу Debezium.
Миграция схемы: ключевые отличия
AUTO_INCREMENT vs SEQUENCES
В MySQL первичный ключ с автоинкрементом объявляется так:
CREATE TABLE orders (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
PRIMARY KEY (id)
);В PostgreSQL используется тип SERIAL или более современный GENERATED ALWAYS AS IDENTITY:
CREATE TABLE orders (
id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY
);
-- Или через отдельную последовательность:
CREATE SEQUENCE orders_id_seq;
CREATER TABLE orders (
id INTEGER NOT NULL DEFAULT nextval('orders_id_seq') PRIMARY KEY
);После initial load не забудьте сбросить sequence на правильное значение:
SELECT setval('orders_id_seq', (SELECT MAX(id) FROM orders));JSON vs JSONB
MySQL хранит JSON как текст с валидацией. PostgreSQL предлагает два типа: JSON (хранит как текст, парсит при каждом запросе) и JSONB (бинарный формат, поддерживает GIN-индексы). Для production всегда используйте JSONB:
-- MySQL
CREATE TABLE events (
payload JSON
);
-- PostgreSQL
CREATER TABLE events (
payload JSONB
);
-- Создание GIN-индекса для быстрого поиска по JSONB
CREATE INDEX idx_events_payload ON events USING GIN (payload);
-- Запрос по JSONB
SELECT * FROM events WHERE payload @> '{"type": "purchase"}';Другие критичные отличия типов данных
TINYINT(1)→BOOLEANDATETIME→TIMESTAMP(учитывайте timezone!)TEXT/MEDIUMTEXT/LONGTEXT→TEXT(в PostgreSQL нет ограничений на размер TEXT)UNSIGNED INT→ рассмотритеBIGINTилиNUMERICENUM('a','b')→ PostgreSQL ENUM (нужно создать тип) илиVARCHARс CHECK constraint
Синхронизация данных через CDC с Debezium
Убедитесь, что MySQL настроен для binlog-репликации:
# my.cnf
[mysqld]
server-id = 1
log_bin = /var/log/mysql/mysql-bin.log
binlog_format = ROW
binlog_row_image = FULL
expire_logs_days = 7Конфигурация Debezium MySQL Connector (публикуется в Kafka Connect):
{
"name": "mysql-source-connector",
"config": {
"connector.class": "io.debezium.connector.mysql.MySqlConnector",
"database.hostname": "mysql-host",
"database.port": "3306",
"database.user": "debezium",
"database.password": "secret",
"database.server.id": "184054",
"topic.prefix": "myapp",
"database.include.list": "source_db",
"schema.history.internal.kafka.bootstrap.servers": "kafka:9092",
"schema.history.internal.kafka.topic": "schema-changes.myapp",
"include.schema.changes": "true",
"snapshot.mode": "initial"
}
}Kafka Consumer на стороне PostgreSQL применяет изменения. Для этого можно использовать готовый JDBC Sink Connector или написать consumer на Go/Python, который транслирует Debezium-события в SQL-команды для PostgreSQL.
Запустить всё в Docker для разработки и тестирования:
version: '3.8'
services:
zookeeper:
image: confluentinc/cp-zookeeper:7.5.0
environment:
ZOOKEEPER_CLIENT_PORT: 2181
kafka:
image: confluentinc/cp-kafka:7.5.0
depends_on: [zookeeper]
environment:
KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
kafka-connect:
image: debezium/connect:2.5
depends_on: [kafka]
ports:
- "8083:8083"
environment:
BOOTSTRAP_SERVERS: kafka:9092
GROUP_ID: 1
CONFIG_STORAGE_TOPIC: connect_configs
OFFSET_STORAGE_TOPIC: connect_offsetsАдаптация приложения
Даже при использовании ORM придётся внести изменения. Основные проблемы:
Чувствительность к регистру идентификаторов
MySQL нечувствителен к регистру имён таблиц (на Windows/macOS). PostgreSQL чувствителен и приводит все идентификаторы к нижнему регистру. Если в коде есть SELECT * FROM Users — это будет работать в MySQL, но не в PostgreSQL (ожидается таблица users).
Строгий режим GROUP BY
PostgreSQL требует, чтобы все незагрегированные колонки в SELECT были в GROUP BY:
-- Работает в MySQL (нестрого), но не в PostgreSQL
SELECT user_id, email, COUNT(*) FROM orders GROUP BY user_id;
-- Правильно для PostgreSQL
SELECT user_id, MAX(email), COUNT(*) FROM orders GROUP BY user_id;INSERT ... ON CONFLICT
-- MySQL
INSERT INTO users (id, name) VALUES (1, 'Alice')
ON DUPLICATE KEY UPDATE name = VALUES(name);
-- PostgreSQL
INSERT INTO users (id, name) VALUES (1, 'Alice')
ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name;ORM-специфика
Если используете Laravel с Eloquent — после переключения на PostgreSQL проверьте все сырые запросы (DB::raw()). В Django убедитесь, что нет MySQL-специфичных аннотаций. В Go с GORM или sqlx проверьте плейсхолдеры: MySQL использует ?, PostgreSQL — $1, $2, ....
Для безопасной адаптации кода внедрите в CI/CD отдельный stage с запуском тестов против PostgreSQL ещё до финального переключения.
Тестирование миграции
Сравнение данных
После initial load и установившейся CDC-синхронизации необходимо верифицировать консистентность данных:
-- Сравнение количества строк
SELECT 'mysql' as source, COUNT(*) FROM orders
UNION ALL
SELECT 'postgres' as source, COUNT(*) FROM orders;
-- Сравнение контрольных сумм (выборочно)
SELECT MD5(CAST(id AS TEXT) || amount || status)
FROM orders
ORDER BY id
LIMIT 10000;Используйте инструменты типа pt-table-checksum (Percona Toolkit) адаптированно или напишите собственный скрипт сравнения, который выборочно сверяет записи по первичному ключу между двумя базами.
Нагрузочное тестирование
Перед cut-over проведите нагрузочный тест на PostgreSQL с реальным профилем трафика. Используйте инструменты: k6, Gatling или pgbench. Проверьте:
- Время отклика ключевых запросов (должно быть не хуже MySQL).
- Поведение при пиковой нагрузке.
- Использование памяти и соединений (
pg_stat_activity). - Наличие медленных запросов (
pg_stat_statements,auto_explain).
Финальное переключение (cut-over)
Cut-over — самый ответственный момент. Минимизируйте окно риска, тщательно подготовив план.
Cut-over план
- T-7 дней: CDC работает, лаг репликации стабильно меньше 1 секунды. Все тесты зелёные.
- T-1 день: уведомить команду. Убедиться, что есть дежурный DBA и backend-разработчик.
- T=0 (cut-over window): включить режим readonly на уровне приложения (feature flag или maintenance mode). Дождаться, пока CDC полностью применит отставшие события (лаг = 0). Переключить строку подключения в конфигурации (или через service discovery) на PostgreSQL. Снять readonly-режим. Проверить ключевые метрики и health-check.
- T+15 минут: мониторинг ошибок, времени отклика. Если всё ОК — успех.
Откат при проблемах
Если после переключения обнаружены критичные проблемы:
- Мгновенно переключить строку подключения обратно на MySQL.
- MySQL при этом остался в рабочем состоянии — данные за время работы на PostgreSQL будут потеряны, но это минуты.
- Если использовался dual-write — потерь данных нет вообще.
Держите MySQL в рабочем состоянии минимум 2 недели после успешного cut-over — на случай обнаружения скрытых проблем.
Пост-миграция: оптимизация и мониторинг
PostgreSQL требует иного подхода к обслуживанию, чем MySQL.
VACUUM и ANALYZE
PostgreSQL использует MVCC, из-за чего накапливаются «мёртвые» строки. Убедитесь, что autovacuum настроен правильно для вашего профиля нагрузки:
-- Проверить статистику autovacuum
SELECT relname, last_autovacuum, last_autoanalyze, n_dead_tup
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC;Индексы
Пересмотрите стратегию индексирования. PostgreSQL поддерживает: B-tree, Hash, GIN (для JSONB, массивов, полнотекста), GiST, BRIN (для временны́х рядов). Использование правильного типа индекса даёт значительный прирост производительности.
Мониторинг
Подключите pg_stat_statements для анализа топ медленных запросов. Настройте алерты на bloat таблиц, длительность транзакций, размер WAL. Prometheus + postgres_exporter — стандартный стек мониторинга для PostgreSQL в 2026 году.
Очистка
После 2 недель стабильной работы на PostgreSQL:
- Остановить CDC-пайплайн.
- Удалить Debezium коннектор и Kafka-топики миграции.
- Остановить MySQL-сервер.
- Удалить MySQL из инфраструктуры или перевести в archive-состояние.
Заключение: чеклист для успешной миграции
- ✅ Проведён полный аудит схемы: типы данных, хранимые процедуры, специфичный синтаксис.
- ✅ Определена стратегия: CDC (Debezium) + опциональный dual-write на финале.
- ✅ Настроен binlog в MySQL (ROW-формат).
- ✅ Initial load выполнен через pgLoader, sequences сброшены на правильные значения.
- ✅ CDC-синхронизация запущена, лаг мониторится.
- ✅ Схема PostgreSQL адаптирована: JSONB вместо JSON, IDENTITY вместо AUTO_INCREMENT, правильные типы данных.
- ✅ Код приложения адаптирован: плейсхолдеры, GROUP BY, ON CONFLICT, регистр идентификаторов.
- ✅ В CI/CD добавлен stage тестирования против PostgreSQL.
- ✅ Нагрузочный тест на PostgreSQL пройден с результатами не хуже MySQL.
- ✅ Cut-over план задокументирован и согласован командой.
- ✅ Откат протестирован в staging-окружении.
- ✅ После cut-over: настроен autovacuum, мониторинг, pg_stat_statements.
- ✅ MySQL оставлен в работе минимум 2 недели после успешного переключения.
Миграция с MySQL на PostgreSQL — это инвестиция, которая окупается: более богатая система типов, строгое следование стандарту SQL, активная экосистема расширений и сильная поддержка облачными провайдерами. При правильном планировании она полностью выполнима без единой минуты простоя для пользователей.
Технологии
Теги
Руслан Исмаилов
Senior Web / Backend разработчик. Senior web/backend разработчик с 9-летним опытом. Стек: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, микросервисы, CI/CD. Подробнее обо мне →