DEV Community

Андрей Викулов (VProger)
Андрей Викулов (VProger)

Posted on • Originally published at viku-lov.ru on

PostgreSQL для веб-разработчика: миграция с MySQL, JSON, оптимизация

PostgreSQL для веб-разработчика: миграция с MySQL, JSON, оптимизация

PostgreSQL для веб-разработчика

Введение

PostgreSQL всё чаще появляется в веб-проектах, где раньше безальтернативно использовался MySQL. Не потому что «модно», а потому что задачи усложнились: JSON-данные, аналитика, сложные выборки, отчёты, очереди, агрегации. В таких сценариях MySQL начинает «скрипеть».

Эта статья — не про внутренности PostgreSQL и не для DBA. Это практический гайд для веб-разработчика, который пишет backend на PHP, Node.js или Python и хочет нормально работать с PostgreSQL , а не бояться его.

Почему я пишу эту статью

Я несколько раз делал миграции с MySQL на PostgreSQL в реальных проектах: интернет-магазины, внутренние сервисы, API, аналитика. И каждый раз видел одни и те же грабли: неправильные типы, отсутствие индексов, страх EXPLAIN ANALYZE, попытки использовать PostgreSQL «как MySQL».

Хочу сэкономить тебе пару ночей и один нервный срыв.

Кому адресовано

  • веб-разработчикам с опытом MySQL / SQLite
  • тем, кто выбирает БД для нового проекта
  • тем, кто уже использует PostgreSQL, но «по минимуму»

Чего здесь не будет

  • теории реляционных БД
  • архитектуры PostgreSQL
  • тюнинга shared_buffers на 40 страниц
  • DBA-магии

PostgreSQL vs MySQL в 2026

| Критерий | PostgreSQL | MySQL |

| -------------- | ---------------------------- | ----------------- |

| Типы данных | Богатые (JSONB, ARRAY, UUID) | Базовые |

| JSON | JSONB + индексы | JSON без индексов |

| Аналитика | Window Functions, CTE | Ограниченно |

| Расширяемость | EXTENSION | Почти нет |

| Лицензия | PostgreSQL License | GPL / коммерция |

| Full-text | Встроенный | Слабее |

| Когда выбирать | Сложные запросы, аналитика | Простой CRUD |

Коротко:

  • CRUD-блог, простой сайт — MySQL;
  • API, аналитика, JSON, отчёты — PostgreSQL.

Установка PostgreSQL

Ubuntu / Debian


sudo apt update

sudo apt install postgresql postgresql-contrib

Enter fullscreen mode Exit fullscreen mode

CentOS / Rocky Linux


sudo dnf install postgresql-server postgresql-contrib

sudo postgresql-setup --initdb

sudo systemctl enable --now postgresql

Enter fullscreen mode Exit fullscreen mode

macOS (Homebrew)


brew install postgresql@16

brew services start postgresql@16

Enter fullscreen mode Exit fullscreen mode

Windows (WSL2)

  1. Установи Ubuntu в WSL2
  2. Далее — как для Ubuntu
  3. Работай только внутри WSL , не из-под Windows.

Первое подключение


sudo -u postgres psql

Enter fullscreen mode Exit fullscreen mode

Типичная ошибка:


psql: FATAL: role "user" does not exist

Enter fullscreen mode Exit fullscreen mode

Решение:


sudo -u postgres createuser --interactive

Enter fullscreen mode Exit fullscreen mode

Типы данных: отличия от MySQL

INTEGER, SERIAL vs AUTO_INCREMENT


CREATE TABLE users (

id SERIAL PRIMARY KEY,

email TEXT NOT NULL

);

Enter fullscreen mode Exit fullscreen mode

SERIAL — это sugar над sequence. В PostgreSQL это нормально.

VARCHAR vs TEXT


email TEXT

Enter fullscreen mode Exit fullscreen mode

В PostgreSQL нет разницы по производительности между ними. TEXT — твой друг.

BOOLEAN vs TINYINT(1)


is\_active BOOLEAN DEFAULT true

Enter fullscreen mode Exit fullscreen mode

Без 0/1. Человечно.

JSONB


CREATE TABLE products (

id SERIAL,

attrs JSONB

);

SELECT \*

FROM products

WHERE attrs @> '{"color": "red"}';

Enter fullscreen mode Exit fullscreen mode

JSONB индексируется и реально работает. Подробно: снипет «JSONB и GIN-индекс».

ARRAY


tags TEXT[];

SELECT \*

FROM articles

WHERE 'postgres' = ANY(tags);

Enter fullscreen mode Exit fullscreen mode

Массивы прямо в БД — иногда очень удобно.

UUID


CREATE EXTENSION IF NOT EXISTS "uuid-ossp";

CREATE TABLE events (

id UUID DEFAULT uuid\_generate\_v4() PRIMARY KEY,

name TEXT

);

Enter fullscreen mode Exit fullscreen mode

TIMESTAMPTZ vs TIMESTAMP


created\_at TIMESTAMPTZ DEFAULT now()

Enter fullscreen mode Exit fullscreen mode

Всегда используй TIMESTAMPTZ. Внутри хранится UTC, локаль выставляется на уровне приложения.


Фичи PostgreSQL, которых нет в MySQL

Window Functions


SELECT

product\_id,

category\_id,

RANK() OVER (PARTITION BY category\_id ORDER BY sales DESC)

FROM products;

Enter fullscreen mode Exit fullscreen mode

Без подзапросов и боли.

CTE (WITH)


WITH paid\_orders AS (

SELECT \* FROM orders WHERE status = 'paid'

)

SELECT COUNT(\*) FROM paid\_orders;

Enter fullscreen mode Exit fullscreen mode

Читаемо и удобно.

Full-Text Search


SELECT \*

FROM articles

WHERE to\_tsvector(title || ' ' || body)

@@ plainto\_tsquery('postgres');

Enter fullscreen mode Exit fullscreen mode

LISTEN / NOTIFY


LISTEN order\_created;

NOTIFY order\_created, '{"id":123}';

Enter fullscreen mode Exit fullscreen mode

Простейшая очередь без Kafka.


Практические примеры

Пример 1: e-commerce


SELECT

o.id,

SUM(oi.price \* oi.qty) AS total

FROM orders o

JOIN order\_items oi ON oi.order\_id = o.id

GROUP BY o.id;

Enter fullscreen mode Exit fullscreen mode

Пример 2: поиск + фильтр


SELECT \*

FROM products

WHERE to\_tsvector(name)

@@ plainto\_tsquery('iphone')

AND price < 50000;

Enter fullscreen mode Exit fullscreen mode

Пример 3: JSON


SELECT \*

FROM products

WHERE attrs->>'brand' = 'Apple';

Enter fullscreen mode Exit fullscreen mode

Пример 4: Window Functions


SELECT \*

FROM (

SELECT

\*,

ROW\_NUMBER() OVER (PARTITION BY category ORDER BY sales DESC) AS rn

FROM products

) t

WHERE rn <= 3;

Enter fullscreen mode Exit fullscreen mode

Пример 5: отчёт по продажам за период (CTE)


WITH daily\_totals AS (

SELECT

date\_trunc('day', created\_at) AS day,

SUM(total) AS revenue

FROM orders

WHERE status = 'paid'

AND created\_at >= now() - interval '30 days'

GROUP BY date\_trunc('day', created\_at)

)

SELECT

day::date,

revenue,

SUM(revenue) OVER (ORDER BY day) AS revenue\_cumulative

FROM daily\_totals

ORDER BY day;

Enter fullscreen mode Exit fullscreen mode

Пример 6: выборка с JSONB и агрегацией


-- Товары с атрибутом «цвет» и средняя цена по бренду

SELECT

attrs->>'brand' AS brand,

COUNT(\*) AS cnt,

ROUND(AVG((attrs->>'price')::numeric), 2) AS avg\_price

FROM products

WHERE attrs ? 'color'

GROUP BY attrs->>'brand'

HAVING COUNT(\*) > 5;

Enter fullscreen mode Exit fullscreen mode

Миграция с MySQL на PostgreSQL

Подготовка


mysqldump db > dump.sql

Enter fullscreen mode Exit fullscreen mode

Основные правки схемы

  • AUTO_INCREMENT → SERIAL
  • VARCHAR(255) → TEXT
  • TINYINT(1) → BOOLEAN

Способ А: pgloader (рекомендую)


pgloader mysql://user:pass@localhost/db postgresql://user:pass@localhost/db

Enter fullscreen mode Exit fullscreen mode

Подходит для больших баз.

Способ Б: SQL-конвертер

Работает, но требует ручной доработки.

Проверка данных


SELECT COUNT(\*) FROM users;

Enter fullscreen mode Exit fullscreen mode

Миграция схемы в Laravel (PostgreSQL)

После переноса данных приложение должно создавать таблицы под Postgres. Пример миграции:


// database/migrations/xxxx\_create\_products\_table.php

Schema::create('products', function (Blueprint $table) {

$table->id();

$table->string('name');

$table->jsonb('attrs')->nullable(); // не json(), а jsonb()

$table->boolean('is\_active')->default(true);

$table->timestamps();

});

// Индекс по JSONB для поиска

DB::statement('CREATE INDEX idx\_products\_attrs\_gin ON products USING GIN (attrs)');

Enter fullscreen mode Exit fullscreen mode

Откат

Всегда держи MySQL read-only , пока не проверил всё.


ORM и клиенты

Node.js (pg — нативный клиент)

Готовый снипет с Pool и параметризованными запросами: PostgreSQL: Node.js (pg) — Pool и запросы.


const { Pool } = require('pg');

const pool = new Pool({

host: 'localhost',

database: 'myapp',

user: 'app\_user',

password: process.env.DB\_PASSWORD,

});

// Параметризованный запрос (защита от SQL-инъекций)

const res = await pool.query(

'SELECT \* FROM users WHERE id = $1',

[userId]

);

// JSONB-поле

const products = await pool.query(

SELECT \* FROM products WHERE attrs @> $1,

[JSON.stringify({ color: 'red' })]

);

Enter fullscreen mode Exit fullscreen mode

Prisma (схема под PostgreSQL):


generator client {

provider = "prisma-client-js"

}

datasource db {

provider = "postgresql"

url = env("DATABASE\_URL")

}

model User {

id Int @id @default(autoincrement())

email String

}

Enter fullscreen mode Exit fullscreen mode

TypeORM — аналогично, в ormconfig укажи type: 'postgres'.

Python (psycopg2)


import psycopg2

from psycopg2.extras import RealDictCursor

conn = psycopg2.connect(

host="localhost", dbname="myapp",

user="app\_user", password="secret"

)

with conn.cursor(cursor\_factory=RealDictCursor) as cur:

cur.execute(

"SELECT \* FROM orders WHERE created\_at > %s",

(datetime.now() - timedelta(days=7),)

)

rows = cur.fetchall()

Enter fullscreen mode Exit fullscreen mode

PHP

  • Doctrine — driver: pdo_pgsql, типы как в документации.
  • Laravel Eloquent — в .env ставишь DB_CONNECTION=pgsql, миграции те же, для JSONB используй $table->jsonb('attrs').

ORM vs raw SQL

  • CRUD → ORM
  • отчёты, аналитика → сырой SQL

EXPLAIN ANALYZE

Перед тем как оптимизировать — замерь. Пошаговый снипет: EXPLAIN ANALYZE — как найти медленный запрос. Запрос:


EXPLAIN (ANALYZE, BUFFERS)

SELECT \* FROM orders WHERE created\_at > now() - interval '7 days';

Enter fullscreen mode Exit fullscreen mode

Пример вывода без индекса (плохо):


Seq Scan on orders (cost=0.00..1234.00 rows=5000 width=80) (actual time=0.05..89.22 rows=4823 loops=1)

Filter: (created\_at > (now() - '7 days'::interval))

Rows Removed by Filter: 95177

Planning Time: 0.12 ms

Execution Time: 91.45 ms

Enter fullscreen mode Exit fullscreen mode

Seq Scan по всей таблице, отфильтровано 95k строк — дорого.

После создания индекса CREATE INDEX idx_orders_created_at ON orders(created_at);:


Index Scan using idx\_orders\_created\_at on orders (cost=0.42..312.18 rows=5000 width=80) (actual time=0.03..8.11 rows=4823 loops=1)

Index Cond: (created\_at > (now() - '7 days'::interval))

Planning Time: 0.08 ms

Execution Time: 9.02 ms

Enter fullscreen mode Exit fullscreen mode

Index Scan — в разы быстрее. Ориентируйся на:

  • Seq Scan на больших таблицах — кандидат на индекс.
  • Index Scan / Index Only Scan — хорошо.
  • Если rows в плане сильно отличается от actual rows — обнови статистику: ANALYZE orders;.

Резервные копии

Готовые команды и варианты форматов: снипет «pg_dump и pg_restore».

pg_dump


pg\_dump db > backup.sql

pg\_dump -s db > schema.sql

Enter fullscreen mode Exit fullscreen mode

pg_restore


pg\_restore -d db backup.dump

Enter fullscreen mode Exit fullscreen mode

Cron


0 3 \* pg\_dump db | gzip > /backup/db.sql.gz

Enter fullscreen mode Exit fullscreen mode

S3 (пример)


aws s3 cp backup.sql.gz s3://my-backups/

Enter fullscreen mode Exit fullscreen mode

Мониторинг и оптимизация

Логи медленных запросов

В postgresql.conf или через ALTER SYSTEM:


ALTER SYSTEM SET log\_min\_duration\_statement = 500; -- логировать запросы дольше 500 ms

Enter fullscreen mode Exit fullscreen mode

Индексы


CREATE INDEX idx\_orders\_created\_at ON orders(created\_at);

Enter fullscreen mode Exit fullscreen mode

Нет индекса на created_at в большой таблице = боль.

VACUUM

PostgreSQL не чистит всё за тебя идеально. Важен автовакуум и периодический ANALYZE.

Размеры


SELECT pg\_database\_size('db');

SELECT pg\_table\_size('orders');

Enter fullscreen mode Exit fullscreen mode

Типичные ошибки

  • забыли COMMIT или долго держим транзакцию открытой;
  • дедлоки при конкурентных транзакциях;
  • сортировка и фильтрация по полям без индекса;
  • хранение структурированных данных в JSON вместо нормальной таблицы;
  • TRUNCATE ... CASCADE без понимания, что удалится.

Итог

PostgreSQL — это не «сложная БД для избранных».

Это рабочий инструмент для веб-разработчика, если использовать его по назначению.

Если проект перерастает CRUD — PostgreSQL почти всегда даёт больше возможностей и меньше костылей.

Главное — не пытайся писать под PostgreSQL как под MySQL.

Read more on viku-lov.ru

Top comments (0)