Базы данных

Миграция с MySQL на PostgreSQL: пошаговое руководство без простоев для production-систем

Ruslan Ismailov Опубликовано 14 мин чтения
М

Введение: почему компании мигрируют с 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. DATETIME vs TIMESTAMP 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-миграции:

  1. Сделать initial snapshot: перенести данные из MySQL в PostgreSQL через pgLoader или AWS DMS.
  2. Запустить CDC-агент (Debezium), который читает MySQL binlog и публикует события в Kafka.
  3. Kafka Consumer применяет изменения к PostgreSQL.
  4. После того как 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) → BOOLEAN
  • DATETIME → TIMESTAMP (учитывайте timezone!)
  • TEXT / MEDIUMTEXT / LONGTEXT → TEXT (в PostgreSQL нет ограничений на размер TEXT)
  • UNSIGNED INT → рассмотрите BIGINT или NUMERIC
  • ENUM('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 план

  1. T-7 дней: CDC работает, лаг репликации стабильно меньше 1 секунды. Все тесты зелёные.
  2. T-1 день: уведомить команду. Убедиться, что есть дежурный DBA и backend-разработчик.
  3. T=0 (cut-over window): включить режим readonly на уровне приложения (feature flag или maintenance mode). Дождаться, пока CDC полностью применит отставшие события (лаг = 0). Переключить строку подключения в конфигурации (или через service discovery) на PostgreSQL. Снять readonly-режим. Проверить ключевые метрики и health-check.
  4. 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. Подробнее обо мне →