Постепенный переход с PHP и MySQL/MariaDB
Перенос языка, СУБД и слоя доступа — три независимых изменения. PHP с параметризованным SQL может быть хорошим решением; PostgreSQL и TypeORM не делают неизвестные требования правильными. Сначала зафиксируйте контракт одной функции и измерьте проблему, которую решает переход.
Жизненный цикл запроса
В обычном PHP-FPM запрос получает окружение исполнения, а worker process
может обслуживать много запросов. Не переносите это обобщение на все PHP runtimes:
долго живущие приложения тоже существуют. В Node один процесс обычно принимает
много запросов, которые перемежаются на await; module-level Map живёт между ними.
Общий mutable currentUser создаст утечку контекста. Передавайте request context
явно, закрывайте ресурсы и не блокируйте event loop тяжёлым синхронным вычислением.
Исходник схемы
flowchart LR Old[PHP и прежняя БД] --> Contract[Контракт одной функции] Contract --> JS[TypeScript и прежняя БД] JS --> PG[Учебный перенос в PostgreSQL] PG --> ORM[Сравнение SQL и ORM]
Это порядок снижения числа одновременно меняющихся факторов, а не обязательный план для каждого проекта. Strapi — отдельный CMS API; импорт контента Atmanki не был автоматическим переводом PHP-кода или MySQL-базы.
Одна функция на двух языках
Контракт: получить title положительного ID; отсутствующая запись — null; неверный ID отвергается. PDO использует драйвер исходной базы, параметры передаются отдельно от SQL. Это пример функции, не полный web endpoint:
function publicationTitle(PDO $db, int $id): ?string {
if ($id < 1) throw new InvalidArgumentException('BAD_ID');
$query = $db->prepare('SELECT title FROM publication WHERE id = :id');
$query->execute(['id' => $id]);
$value = $query->fetchColumn();
return $value === false ? null : (string) $value;
}
Для PostgreSQL-драйвера pg аналогичная TypeScript функция:
import type { Pool } from "pg";
export async function publicationTitle(db: Pool, id: number): Promise<string | null> {
if (!Number.isSafeInteger(id) || id < 1) throw new Error("BAD_ID");
const result = await db.query<{ title: string }>("SELECT title FROM publication WHERE id = $1", [
id,
]);
return result.rows[0]?.title ?? null;
}
Generic здесь обещает форму ответа компилятору, не проверяет данные runtime.
NOT NULL у title и проверенный schema contract нужны отдельно. Для MySQL JS
драйвера placeholder и форма результата другие; не заменяйте :id на $1
и не считайте это переносом СУБД. Внешний string ID сначала разбирается и
валидируется. Тип PHP-аргумента тоже не заменяет контракт HTTP-входа.
Версия через repository TypeORM уже есть в лаборатории. Сравните отсутствие строки, ошибки соединения, Unicode title и запрет доступа. Ошибка БД не должна превращаться в null. Зафиксируйте настоящий SQL каждого варианта, pool size, timeout и закрытие приложения.
Карта переноса данных
| Исходное значение | Решение в PostgreSQL | Что проверить |
|---|---|---|
| AUTO_INCREMENT | identity/sequence | Перенесённые IDs и следующий ID после переноса |
| unsigned integer | Более широкий тип или CHECK | Верхнюю границу, JS safe integer |
| DECIMAL | numeric | Не терять точность через JS number для денег |
| DATETIME | timestamp либо timestamptz по смыслу | Исходную timezone и отсутствие offset |
| JSON | jsonb либо json | Структуру, JSON null/SQL NULL, порядок/дубли ключей |
| zero date | Явный nullable/ошибка данных | Не превращать молча в «сегодня» |
| case-insensitive collation | Выбранная collation/нормализация | Поиск, порядок и UNIQUE на существующих значениях |
Текст, boolean, enum, foreign keys и ON DELETE тоже требуют решения. Timestamptz представляет момент времени, а не сохраняет исходное название таймзоны. Bigint драйвер может вернуть строкой: нельзя без проверки переводить в number. Объём, кодировка и ограничения важнее совпадения имён колонок.
Учебный перенос и сверка
- Создайте отдельные исходную и целевую базы с пятью записями: Unicode, nullable поле, большая сумма numeric, timestamp и пограничный ID. Сохраните schema и контракт данных; не используйте production dump.
- Экспортируйте детерминированно по ID. Преобразуйте явно по карте типов, сохраните отчёт rejected rows. Импорт выполняйте транзакционно или повторяемыми batch с учётом уже перенесённых IDs.
- Сверьте count, набор IDs, FK и нормализованные значения. Hash сравнивает выбранную каноническую форму, не магически эквивалентный SQL двух СУБД.
- Сбросьте sequence выше max(id), вставьте новую строку без явного ID.
- Запустите одинаковые контрактные случаи функций PHP/TS/ORM и сравните бизнес-результат. Планы SQL сравнивайте отдельно, они не обязаны совпадать.
Следующий пример сверяет нормализованные учебные записи в Node:
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
function fingerprint(rows) {
const canonical = rows
.map((row) => [String(row.id), row.title, row.amount, row.instant])
.sort((a, b) => (BigInt(a[0]) < BigInt(b[0]) ? -1 : BigInt(a[0]) > BigInt(b[0]) ? 1 : 0));
return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
}
const source = [{ id: "1", title: "Урок", amount: "10.00", instant: "2026-01-01T00:00:00.000Z" }];
const target = [{ ...source[0], id: 1 }];
assert.equal(fingerprint(source), fingerprint(target));
assert.notEqual(fingerprint(source), fingerprint([{ ...target[0], amount: "10.01" }]));
console.log("Canonical comparison passed");
Контракт fingerprint требует заранее нормализованных decimal/instant и уникальных IDs. Равное количество строк не обнаружит замену содержимого; hash тоже не проверяет поля, которые вы забыли включить. Для большого набора сравнивайте партиции и сохраняйте причины отличий, а не только общий hash.
Записи во время переноса и cutover
Пробный snapshot не переносит новые изменения. Выберите maintenance window с остановкой записи либо контролируемую доставку изменений с checkpoint. Наивный dual write «сначала старая БД, потом новая» не атомарен: второй запрос может отказать. Нужны источник истины, журнал изменений, порядок, dedup и reconciliation. Shadow reads сравнивают результат, но не проверяют безопасную запись.
Перед cutover дождитесь выбранного watermark, сверки и проверки чтения/записи. Переключите один контролируемый путь и наблюдайте. Если новая БД уже приняла новые записи, откат connection string без обратного переноса потеряет их. Опишите границу отката заранее. Schema migration внутри PostgreSQL и перенос между СУБД — разные операции.
Redis добавляют после измерения: кешируемый запрос, ключ, TTL, invalidation, отказ и источник истины. Он не заменяет PostgreSQL для связей и транзакций. Приёмка: карта типов, результаты функции, отчёт сверки, новая запись после переноса, сценарий частичного отказа и обоснованная граница cutover/rollback. Реальный перенос PHP/MySQL здесь не выполнен: это отдельная лаборатория, не заявление о миграции существующего приложения.
Источники: PDO prepare, параметры node-postgres, типы PostgreSQL 17.