Учебник веб-разработки
Разделы учебника
На этой странице

VIII. Системы хранения

Постепенный переход с 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_INCREMENTidentity/sequenceПеренесённые IDs и следующий ID после переноса
unsigned integerБолее широкий тип или CHECKВерхнюю границу, JS safe integer
DECIMALnumericНе терять точность через JS number для денег
DATETIMEtimestamp либо timestamptz по смыслуИсходную timezone и отсутствие offset
JSONjsonb либо jsonСтруктуру, JSON null/SQL NULL, порядок/дубли ключей
zero dateЯвный nullable/ошибка данныхНе превращать молча в «сегодня»
case-insensitive collationВыбранная collation/нормализацияПоиск, порядок и UNIQUE на существующих значениях

Текст, boolean, enum, foreign keys и ON DELETE тоже требуют решения. Timestamptz представляет момент времени, а не сохраняет исходное название таймзоны. Bigint драйвер может вернуть строкой: нельзя без проверки переводить в number. Объём, кодировка и ограничения важнее совпадения имён колонок.

Учебный перенос и сверка

  1. Создайте отдельные исходную и целевую базы с пятью записями: Unicode, nullable поле, большая сумма numeric, timestamp и пограничный ID. Сохраните schema и контракт данных; не используйте production dump.
  2. Экспортируйте детерминированно по ID. Преобразуйте явно по карте типов, сохраните отчёт rejected rows. Импорт выполняйте транзакционно или повторяемыми batch с учётом уже перенесённых IDs.
  3. Сверьте count, набор IDs, FK и нормализованные значения. Hash сравнивает выбранную каноническую форму, не магически эквивалентный SQL двух СУБД.
  4. Сбросьте sequence выше max(id), вставьте новую строку без явного ID.
  5. Запустите одинаковые контрактные случаи функций 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.