Миграция интернет-магазина с MySQL на PostgreSQL

Почему клиент решил мигрировать

Интернет-магазин бытовой электроники «ЭлектроМир» работал на MySQL 5.7 с 2018 года. База данных выросла до 85 ГБ, каталог содержал 1.8 миллиона товаров, ежедневная аудитория — 120 000 уникальных посетителей. Пока магазин был небольшим, MySQL справлялся, но с ростом начались проблемы.

Клиент обратился к нам со списком конкретных болевых точек:

  • Репликация ломается раз в месяц. MySQL использует отдельный журнал для репликации (binlog), который не связан с журналом InnoDB. При высокой нагрузке slave-реплика отставала на часы, а при сбое приходилось пересобирать её с нуля — процесс на 6 часов.
  • Сложные запросы работают медленно. Аналитические запросы с подзапросами и GROUP BY исполнялись до 45 секунд. Оптимизатор MySQL не мог самостоятельно выбрать оптимальный план — разработчикам приходилось вручную переписывать запросы.
  • JSON-данные хранятся неэффективно. Характеристики товаров хранились в JSON-колонках MySQL, но без поддержки индексов по отдельным полям JSON. Поиск по характеристикам выполнял full table scan.
  • Нет полноценного полнотекстового поиска на русском. MySQL Full-Text Search не поддерживает русскую морфологию из коробки.

После анализа мы рекомендовали миграцию на PostgreSQL 16 и обосновали это технически.

Технические преимущества PostgreSQL

Мы подготовили для технического директора «ЭлектроМир» детальное сравнение MySQL и PostgreSQL по ключевым параметрам, влияющим именно на их сценарий использования:

ПараметрMySQL 5.7PostgreSQL 16Влияние на ЭлектроМир
РепликацияЛогическая (binlog), отдельный журналФизическая (WAL), единый журналСтабильность: WAL = crash recovery = replication
Оптимизатор запросовБазовый, без гистограмм (в 5.7)Полноценный cost-based с гистограммамиСложные запросы выполняются быстрее без ручного тюнинга
JSONJSON тип, ограниченная индексацияJSONB + GIN-индексыПоиск по характеристикам товаров через индекс
Полнотекстовый поискНет русской морфологииВстроенный с поддержкой русскогоПоиск товаров с учётом словоформ
Стандарт SQLОтклонения от стандартаСтрогое соответствие SQL:2011Предсказуемое поведение, меньше багов
Параллельные запросыОдин поток на запросПараллельное выполнениеАналитика и отчёты в разы быстрее

Ключевой аргумент — архитектурный. PostgreSQL использует единый WAL (Write-Ahead Log) как для восстановления после сбоев, так и для репликации. Это делает репликацию принципиально стабильнее: если сервер может восстановиться после crash, значит реплика гарантированно синхронизируется. В MySQL два отдельных журнала (InnoDB redo log + binlog) требуют двухфазного коммита между собой, что создаёт архитектурную точку отказа.

План миграции и подготовка

Мы разработали план миграции с нулевым простоем, разделённый на 5 фаз. Общая длительность — 4 недели:

# Фаза 1 (неделя 1): Подготовка
# - Аудит MySQL-схемы: типы данных, триггеры, хранимые процедуры
# - Установка PostgreSQL 16 на отдельный сервер
# - Маппинг типов данных MySQL → PostgreSQL

# Фаза 2 (неделя 2): Миграция схемы и данных
# - Конвертация DDL (CREATE TABLE)
# - Перенос данных через pgloader
# - Верификация целостности

# Фаза 3 (неделя 3): Миграция логики
# - Переписывание хранимых процедур
# - Адаптация ORM-запросов в приложении
# - Оптимизация индексов

# Фаза 4 (неделя 4): Тестирование
# - Нагрузочное тестирование
# - A/B сравнение MySQL vs PostgreSQL
# - Подготовка rollback-плана

# Фаза 5 (суббота, 02:00): Переключение
# - Остановка записи в MySQL
# - Финальная синхронизация
# - Переключение приложения на PostgreSQL
# - Мониторинг 24 часа

Маппинг типов данных

Не все типы MySQL имеют прямые аналоги в PostgreSQL. Мы составили таблицу соответствий:

MySQLPostgreSQLПримечания
INT AUTO_INCREMENTSERIAL / GENERATED ALWAYS AS IDENTITYIDENTITY — современный стандарт SQL
TINYINT(1)BOOLEANMySQL не имеет настоящего boolean
DATETIMETIMESTAMP WITHOUT TIME ZONEИли TIMESTAMPTZ для UTC
DOUBLEDOUBLE PRECISIONПрямое соответствие
TEXT / LONGTEXTTEXTPostgreSQL TEXT без ограничений
ENUM('a','b','c')CREATE TYPE ... AS ENUMPostgreSQL ENUM — отдельный тип
JSONJSONBJSONB — бинарный, с индексацией
UNSIGNED INTINT + CHECK constraintPostgreSQL не имеет UNSIGNED

Миграция данных через pgloader

Для переноса 85 ГБ данных мы использовали pgloader — специализированный инструмент для миграции из MySQL в PostgreSQL. Он автоматически конвертирует типы данных, переносит индексы и constraints:

-- Конфигурация pgloader
-- /etc/pgloader/electromir.load

LOAD DATABASE
    FROM mysql://readonly:password@mysql-server:3306/electromir
    INTO postgresql://migrate:password@pg-server:5432/electromir

WITH include drop, create tables, create indexes,
     reset sequences, downcase identifiers,
     batch rows = 10000, batch size = 100MB,
     prefetch rows = 10000

SET PostgreSQL PARAMETERS
    maintenance_work_mem to '2GB',
    work_mem to '512MB'

CAST type tinyint to boolean using tinyint-to-boolean,
     type int when unsigned to bigint,
     type datetime to timestamptz

ALTER SCHEMA 'electromir' RENAME TO 'public'

INCLUDING ONLY TABLE NAMES MATCHING
    ~/products/, ~/orders/, ~/users/, ~/categories/,
    ~/order_items/, ~/reviews/, ~/inventory/
;

Миграция 85 ГБ заняла 2 часа 14 минут. После переноса мы запустили скрипт верификации, который сравнил количество строк и контрольные суммы для каждой таблицы:

-- Верификация после миграции
-- Скрипт сравнивает количество строк MySQL vs PostgreSQL

-- MySQL:
SELECT 'products' AS tbl, COUNT(*) AS cnt FROM products
UNION ALL
SELECT 'orders', COUNT(*) FROM orders
UNION ALL
SELECT 'users', COUNT(*) FROM users;

-- PostgreSQL (должно совпасть):
SELECT 'products' AS tbl, COUNT(*) AS cnt FROM products
UNION ALL
SELECT 'orders', COUNT(*) FROM orders
UNION ALL
SELECT 'users', COUNT(*) FROM users;

-- Результат:
--   tbl      | mysql_cnt | pg_cnt   | match
-- -----------+-----------+----------+-------
--  products  | 1,847,293 | 1,847,293 | OK
--  orders    | 8,234,019 | 8,234,019 | OK
--  users     |   892,445 |   892,445 | OK

Оптимизация под PostgreSQL

После переноса данных мы провели оптимизацию, используя преимущества PostgreSQL, недоступные в MySQL.

JSONB-индексы для характеристик товаров

Характеристики товаров хранились в JSON-колонке specs. В MySQL поиск по ним выполнял полное сканирование таблицы. В PostgreSQL мы создали GIN-индекс на JSONB:

-- Создаём GIN-индекс на JSONB-колонку
CREATE INDEX CONCURRENTLY idx_products_specs
    ON products USING gin(specs jsonb_path_ops);

-- Запрос: найти все ноутбуки с RAM >= 16 ГБ и SSD >= 512 ГБ
-- MySQL (5.7): 32 секунды — full table scan
-- PostgreSQL: 15 мс — GIN index scan

SELECT id, name, price, specs
FROM products
WHERE specs @> '{"ram_gb": 16}'
  AND (specs->>'ssd_gb')::int >= 512
  AND category_id = 42
ORDER BY price
LIMIT 50;

-- EXPLAIN показывает:
-- Bitmap Heap Scan on products
--   -> BitmapAnd
--     -> Bitmap Index Scan on idx_products_specs
--     -> Bitmap Index Scan on idx_products_category

Полнотекстовый поиск с русской морфологией

В MySQL поиск 'ноутбук' не находил товар с описанием 'лучшие ноутбуки'. В PostgreSQL мы настроили полноценный поиск с русской морфологией:

-- Создание поискового индекса с русской конфигурацией
ALTER TABLE products ADD COLUMN search_tsv tsvector
    GENERATED ALWAYS AS (
        setweight(to_tsvector('russian', coalesce(name, '')), 'A') ||
        setweight(to_tsvector('russian', coalesce(description, '')), 'B')
    ) STORED;

CREATE INDEX CONCURRENTLY idx_products_fts
    ON products USING gin(search_tsv);

-- Поиск: "беспроводные наушники с шумоподавлением"
-- Находит: наушники, наушник, беспроводной, беспроводная...
SELECT id, name, price,
       ts_rank(search_tsv, q) AS rank
FROM products,
     websearch_to_tsquery('russian', 'беспроводные наушники шумоподавление') q
WHERE search_tsv @@ q
ORDER BY rank DESC
LIMIT 20;

Оптимизация сложных аналитических запросов

Один из аналитических запросов — отчёт по продажам с группировкой по категориям и временным периодам — выполнялся в MySQL 45 секунд. PostgreSQL-оптимизатор с гистограммами и параллельным выполнением справился за 3 секунды:

-- Отчёт: продажи по категориям за последний квартал
-- MySQL 5.7: 45 секунд (один поток, плохой план)
-- PostgreSQL 16: 3 секунды (параллельное выполнение, hash join)

EXPLAIN (ANALYZE, BUFFERS)
SELECT
    c.name AS category,
    date_trunc('week', o.created_at) AS week,
    COUNT(DISTINCT o.id) AS orders,
    SUM(oi.quantity) AS items_sold,
    SUM(oi.price * oi.quantity) AS revenue
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN products p ON p.id = oi.product_id
JOIN categories c ON c.id = p.category_id
WHERE o.created_at >= NOW() - INTERVAL '3 months'
  AND o.status = 'completed'
GROUP BY c.name, date_trunc('week', o.created_at)
ORDER BY week DESC, revenue DESC;

-- PostgreSQL план:
-- Gather Merge (actual rows=847)
--   Workers Planned: 4
--   Workers Launched: 4
--   -> Sort
--     -> Parallel Hash Join
--       -> Parallel Seq Scan on order_items
--       -> Hash
--         -> Index Scan on orders

Настройка репликации и отказоустойчивости

Одной из главных причин миграции была нестабильная репликация MySQL. В PostgreSQL мы настроили потоковую репликацию на основе WAL — тот же журнал, который используется для crash recovery:

# Конфигурация primary (postgresql.conf)
wal_level = replica
max_wal_senders = 5
wal_keep_size = 2GB
synchronous_commit = on
synchronous_standby_names = 'replica1'

# pg_hba.conf — разрешаем подключение реплики
host replication replicator 10.0.2.0/24 scram-sha-256

# На реплике: инициализация
pg_basebackup -h primary-server -D /var/lib/postgresql/16/main \
    -U replicator -P -R -Xs

# -R создаёт standby.signal и настраивает primary_conninfo
# После запуска реплика автоматически синхронизируется

За 2 месяца эксплуатации: 0 сбоев репликации (против 2 сбоев в месяц на MySQL). Отставание реплики стабильно менее 100 мс, что позволяет направлять на неё все read-запросы.

Переключение и результаты

Переключение мы провели в субботу ночью, в окно минимального трафика. Процедура заняла 18 минут — от остановки записи в MySQL до первого ответа PostgreSQL:

# Хронология переключения (время МСК)

02:00 — Активация maintenance-страницы
02:02 — SET GLOBAL read_only = ON на MySQL
02:03 — Финальный дамп новых данных (delta: 1247 строк за 2 часа)
02:05 — Импорт delta в PostgreSQL
02:08 — Верификация: подсчёт строк, контрольные суммы
02:12 — Переключение connection string в приложении
02:14 — Рестарт application servers (4 инстанса)
02:16 — Smoke tests: оформление заказа, поиск, каталог
02:18 — Отключение maintenance-страницы
02:18 — Мониторинг: все метрики в норме

Итоговые результаты миграции за первый месяц на PostgreSQL:

МетрикаMySQL 5.7PostgreSQL 16Улучшение
Средняя загрузка страницы каталога1.8 сек0.4 секв 4.5 раза
Поиск товаров3.2 сек0.12 секв 26 раз
Аналитический отчёт (квартал)45 сек3 секв 15 раз
Фильтрация по характеристикам (JSON)32 сек0.015 секв 2100 раз
Сбои репликации в месяц20
Отставание реплики (среднее)15-180 сек< 0.1 сек
Размер БД на диске85 ГБ62 ГБ-27%

Клиент отметил рост конверсии на 18% за первый месяц — прямое следствие ускорения каталога и поиска. Стоимость проекта: 2.1 млн рублей за 4 недели работы команды из 3 инженеров. Подробнее о миграции баз данных читайте на itfresh.ru.

Часто задаваемые вопросы

Полностью без простоя — сложно, но можно минимизировать его до нескольких минут. Мы используем поэтапный подход: основные данные переносятся заранее, а в момент переключения мигрируется только delta — изменения за последние часы. В нашем случае простой составил 18 минут в ночное окно.
PostgreSQL использует единый WAL-журнал и для восстановления после сбоев, и для репликации. MySQL использует два отдельных журнала (InnoDB redo log + binlog) с двухфазным коммитом. Это архитектурная разница: в PostgreSQL репликация 'бесплатна' как побочный эффект надёжного журналирования.
PostgreSQL JSONB хранит данные в бинарном формате, что позволяет создавать GIN-индексы и выполнять поиск по вложенным ключам через индекс. MySQL JSON хранит текст и при поиске по JSON-полям выполняет полное сканирование таблицы. Для таблиц свыше 100 000 строк разница может составлять тысячи раз.
Частично — да. Основные отличия: MySQL использует обратные кавычки для идентификаторов, PostgreSQL — двойные. MySQL GROUP_CONCAT заменяется на STRING_AGG. MySQL IFNULL — на COALESCE. MySQL AUTO_INCREMENT — на SERIAL или IDENTITY. Мы подготовили скрипт автоматической конвертации, покрывающий 80% случаев.
Если у вас простое веб-приложение с CRUD-операциями, нет сложных запросов, JSON-данных и потребности в полнотекстовом поиске — MySQL будет работать не хуже. Миграция оправдана при росте нагрузки, усложнении запросов, потребности в стабильной репликации или специфических возможностях PostgreSQL (JSONB, FTS, расширения).

Нужна помощь с проектом?

Специалисты АйТи Фреш помогут с архитектурой, DevOps, безопасностью и разработкой — 15+ лет опыта

📞 Связаться с нами
#миграция MySQL PostgreSQL#переход на PostgreSQL#MySQL vs PostgreSQL#миграция базы данных#pgloader миграция#PostgreSQL преимущества#оптимизация базы данных#SQL миграция
Комментарии 0

Оставить комментарий

загрузка...