PostgreSQL из Node.js
PostgreSQL можно использовать в Node.js напрямую через драйвер pg или через ORM: Prisma, TypeORM, Sequelize и другие библиотеки.
Драйвер предоставляет прямой доступ к SQL, а ORM добавляет модели, связи, миграции и API более высокого уровня.
Независимо от выбранного подхода приложение должно:
- хранить строку подключения вне исходного кода;
- использовать пул соединений;
- передавать пользовательские значения через параметры;
- управлять схемой базы данных с помощью миграций;
- выполнять все команды транзакции на одном соединении;
- корректно освобождать соединения;
- обрабатывать ошибки по коду, а не по тексту;
- не возвращать клиенту внутренние сообщения PostgreSQL.
Пример переменной окружения:
DATABASE_URL=postgresql://app_user:secret@localhost:5432/app_dbФайл .env с настоящими паролями не следует добавлять в Git:
.env
.env.*
!.env.exampleБезопасный шаблон .env.example:
DATABASE_URL=postgresql://USER:PASSWORD@HOST:5432/DATABASEДрайвер pg и ORM
Драйвер pg
pg, также известный как node-postgres, — низкоуровневый драйвер PostgreSQL для Node.js. Приложение самостоятельно формирует SQL-запросы и обрабатывает результаты.
Установка:
npm install pgПример:
import pg from 'pg';
const { Pool } = pg;
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
const result = await pool.query(
`SELECT id, email, created_at
FROM users
WHERE id = $1`,
[42],
);
console.log(result.rows[0]);Преимущества pg:
- полный контроль над SQL;
- доступны все возможности PostgreSQL;
- легко использовать CTE, оконные функции и сложные соединения;
- проще анализировать фактически выполняемый запрос;
- минимум скрытого поведения;
- удобно оптимизировать запросы через
EXPLAIN.
Недостатки pg:
- SQL необходимо писать самостоятельно;
- результаты нужно вручную преобразовывать в объекты приложения;
- связи между сущностями загружаются вручную;
- появляется повторяющийся CRUD-код;
- миграции требуют отдельного инструмента или собственной системы;
- схема базы и типы TypeScript могут расходиться.
ORM
ORM — Object-Relational Mapping — слой, который сопоставляет таблицы базы данных с моделями или объектами приложения.
Условный ORM-запрос:
const user = await orm.user.findUnique({
where: { id: 42 },
include: { posts: true },
});ORM обычно предоставляет:
- описание моделей;
- типизированный API запросов;
- загрузку связей;
- миграции;
- валидацию структуры данных;
- хуки или события жизненного цикла;
- генерацию TypeScript-типов.
Преимущества ORM:
- меньше повторяющегося CRUD-кода;
- удобное описание связей;
- единый стиль работы с данными;
- встроенные миграции;
- хорошая интеграция с TypeScript;
- быстрое создание стандартных бизнес-приложений.
Недостатки ORM:
- дополнительный уровень абстракции;
- необходимо знать особенности конкретной ORM;
- сложные SQL-запросы не всегда удобно выражать через ORM API;
- возможна проблема N+1;
- автоматически созданный SQL может быть неоптимальным;
- ORM не отменяет необходимость знать SQL, индексы и транзакции.
Сравнение подходов
| Ситуация | Рекомендуемый подход |
|---|---|
| Нужен полный контроль над SQL | pg |
| Много сложных и аналитических запросов | pg или raw SQL в ORM |
| Типовое CRUD-приложение | ORM |
| Важна строгая типизация | Prisma |
| Проект построен на классах и декораторах | TypeORM |
| Нужна классическая ORM с моделями | Sequelize |
| Небольшой сервис с несколькими запросами | pg |
| Команда хорошо знает SQL | pg или гибридный подход |
Драйвер и ORM можно сочетать. Например, Prisma может использоваться для стандартных CRUD-операций, а сложный отчёт — выполняться через параметризованный raw SQL.
Подключение к PostgreSQL
Строка подключения обычно имеет формат:
postgresql://USER:PASSWORD@HOST:PORT/DATABASEПример:
postgresql://app_user:secret@localhost:5432/app_dbЕсли имя пользователя, пароль или имя базы содержат специальные символы, их необходимо URL-кодировать.
Одиночное соединение через Client
Client представляет одно физическое соединение:
import pg from 'pg';
const { Client } = pg;
const client = new Client({
connectionString: process.env.DATABASE_URL,
});
await client.connect();
try {
const result = await client.query(
'SELECT NOW() AS current_time',
);
console.log(result.rows[0]);
} finally {
await client.end();
}Одиночный клиент подходит для:
- коротких служебных скриптов;
- миграций;
- утилит командной строки;
- одноразовых операций.
Для серверного приложения обычно используется пул.
Пул соединений
Создание нового соединения требует времени и ресурсов. Пул соединений поддерживает ограниченное количество открытых соединений и повторно использует их для разных запросов.
Создание пула
import pg from 'pg';
const { Pool } = pg;
export const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
});
pool.on('error', (error) => {
console.error(
'Неожиданная ошибка свободного соединения PostgreSQL',
error,
);
});Основные параметры:
| Параметр | Назначение |
|---|---|
max |
Максимальное число клиентов в пуле |
idleTimeoutMillis |
Время до закрытия неиспользуемого клиента |
connectionTimeoutMillis |
Максимальное ожидание соединения |
allowExitOnIdle |
Разрешает процессу завершиться при свободных соединениях |
Пул создают один раз на процесс, а не для каждого HTTP-запроса.
Размер пула нужно рассчитывать для всей инфраструктуры. Если запущено 20 экземпляров приложения с max: 10, суммарно они могут открыть до 200 соединений.
Следует учитывать:
max_connectionsPostgreSQL;- число экземпляров приложения;
- фоновые обработчики;
- административный резерв;
- другие сервисы, использующие ту же базу.
Выполнение одиночного запроса
Для независимого запроса удобно использовать pool.query():
const result = await pool.query(
`SELECT id, title, published_at
FROM posts
WHERE author_id = $1
ORDER BY published_at DESC
LIMIT $2`,
[authorId, 20],
);Пул автоматически:
- получает свободного клиента;
- выполняет запрос;
- возвращает клиента в пул.
Получение клиента вручную
Для транзакции или последовательности операций, привязанных к одной сессии, клиент получают вручную:
const client = await pool.connect();
try {
const result = await client.query(
'SELECT current_database()',
);
console.log(result.rows[0]);
} finally {
client.release();
}client.release() обязательно вызывают в finally.
Если не возвращать клиенты, пул постепенно исчерпается, после чего новые запросы будут ожидать свободное соединение.
Проверка подключения
export async function checkDatabaseConnection() {
const result = await pool.query(
`SELECT
current_database() AS database,
NOW() AS connected_at`,
);
return result.rows[0];
}Если приложение не может работать без базы, такую проверку можно выполнить перед запуском HTTP-сервера.
Завершение пула
async function shutdown(signal) {
console.log(`Получен сигнал ${signal}`);
try {
await pool.end();
process.exit(0);
} catch (error) {
console.error('Не удалось закрыть пул', error);
process.exit(1);
}
}
process.once('SIGINT', () => shutdown('SIGINT'));
process.once('SIGTERM', () => shutdown('SIGTERM'));В полноценном сервере сначала прекращают принимать новые запросы, затем завершают активные операции и только после этого закрывают пул.
Параметризованные запросы
Пользовательские значения нельзя вставлять в SQL с помощью конкатенации или шаблонных строк.
Небезопасный вариант:
const result = await pool.query(
`SELECT * FROM users WHERE email = '${email}'`,
);Безопасный вариант:
const result = await pool.query(
`SELECT id, email
FROM users
WHERE email = $1`,
[email],
);Параметры PostgreSQL обозначаются как $1, $2, $3:
const result = await pool.query(
`SELECT id, email
FROM users
WHERE status = $1
AND created_at >= $2
ORDER BY created_at DESC
LIMIT $3`,
['active', fromDate, 50],
);Параметрами можно передавать значения, но обычно нельзя передавать:
- имя таблицы;
- имя колонки;
- направление сортировки;
- SQL-оператор.
Динамические идентификаторы следует выбирать из белого списка:
const allowedSortFields = new Set([
'created_at',
'email',
'name',
]);
const sortField = allowedSortFields.has(requestedSort)
? requestedSort
: 'created_at';
const direction =
requestedDirection === 'asc' ? 'ASC' : 'DESC';
const result = await pool.query(
`SELECT id, email, name
FROM users
ORDER BY ${sortField} ${direction}
LIMIT $1`,
[20],
);Этот пример безопасен только потому, что sortField и direction формируются из заранее определённого набора значений.
CRUD через pg
Создание записи
export async function createUser({ email, name }) {
const result = await pool.query(
`INSERT INTO users (email, name)
VALUES ($1, $2)
RETURNING
id,
email,
name,
created_at AS "createdAt"`,
[email, name],
);
return result.rows[0];
}Чтение записи
export async function findUserById(id) {
const result = await pool.query(
`SELECT
id,
email,
name,
created_at AS "createdAt"
FROM users
WHERE id = $1`,
[id],
);
return result.rows[0] ?? null;
}Обновление записи
export async function updateUserName(id, name) {
const result = await pool.query(
`UPDATE users
SET
name = $2,
updated_at = NOW()
WHERE id = $1
RETURNING
id,
email,
name,
updated_at AS "updatedAt"`,
[id, name],
);
return result.rows[0] ?? null;
}Удаление записи
export async function deleteUser(id) {
const result = await pool.query(
`DELETE FROM users
WHERE id = $1
RETURNING id`,
[id],
);
return result.rowCount > 0;
}Конструкция RETURNING позволяет получить созданную, изменённую или удалённую строку без дополнительного SELECT.
ORM Prisma
Prisma использует декларативную схему и генерирует клиент для работы с базой.
Установка
npm install @prisma/client
npm install --save-dev prisma
npx prisma initОписание моделей
Файл prisma/schema.prisma:
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
posts Post[]
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("users")
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
authorId Int @map("author_id")
author User @relation(
fields: [authorId],
references: [id],
onDelete: Cascade
)
@@index([authorId])
@@map("posts")
}@map связывает поле Prisma с колонкой PostgreSQL, а @@map — модель с таблицей.
Это позволяет использовать:
JavaScript: createdAt
PostgreSQL: created_atPrisma Client
import { PrismaClient } from '@prisma/client';
export const prisma = new PrismaClient({
log: ['warn', 'error'],
});В долгоживущем серверном процессе обычно создают один экземпляр клиента, а не новый экземпляр на каждый запрос.
CRUD
Создание:
const user = await prisma.user.create({
data: {
email: 'user@example.com',
name: 'Анна',
},
});Поиск:
const user = await prisma.user.findUnique({
where: {
email: 'user@example.com',
},
});Фильтрация и сортировка:
const users = await prisma.user.findMany({
where: {
name: {
contains: 'Ан',
mode: 'insensitive',
},
},
orderBy: {
createdAt: 'desc',
},
take: 20,
});Обновление:
const user = await prisma.user.update({
where: {
id: 42,
},
data: {
name: 'Новое имя',
},
});Удаление:
await prisma.user.delete({
where: {
id: 42,
},
});Загрузка связей
const user = await prisma.user.findUnique({
where: {
id: 42,
},
select: {
id: true,
email: true,
posts: {
select: {
id: true,
title: true,
},
orderBy: {
id: 'desc',
},
},
},
});Явный select помогает не загружать лишние поля и вложенные связи.
ORM TypeORM
TypeORM поддерживает шаблоны Data Mapper и Active Record. В TypeScript-проектах модели часто описываются классами и декораторами.
Установка
npm install typeorm pg reflect-metadataДля декораторов требуется настройка TypeScript:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}Сущность
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
OneToMany,
} from 'typeorm';
@Entity({ name: 'users' })
export class User {
@PrimaryGeneratedColumn()
id!: number;
@Column({ unique: true })
email!: string;
@Column({ nullable: true })
name!: string | null;
@CreateDateColumn({
name: 'created_at',
type: 'timestamptz',
})
createdAt!: Date;
@OneToMany(() => Post, (post) => post.author)
posts!: Post[];
}Связанная сущность:
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
Index,
} from 'typeorm';
@Entity({ name: 'posts' })
export class Post {
@PrimaryGeneratedColumn()
id!: number;
@Column()
title!: string;
@Index()
@Column({ name: 'author_id' })
authorId!: number;
@ManyToOne(() => User, (user) => user.posts, {
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'author_id' })
author!: User;
}Подключение
import 'reflect-metadata';
import { DataSource } from 'typeorm';
export const dataSource = new DataSource({
type: 'postgres',
url: process.env.DATABASE_URL,
entities: [User, Post],
migrations: ['dist/migrations/*.js'],
synchronize: false,
logging: false,
});
await dataSource.initialize();В рабочем окружении обычно используют synchronize: false. Автоматическая синхронизация не заменяет контролируемые миграции.
Репозиторий
const userRepository = dataSource.getRepository(User);
const user = userRepository.create({
email: 'user@example.com',
name: 'Анна',
});
await userRepository.save(user);
const found = await userRepository.findOne({
where: {
id: user.id,
},
relations: {
posts: true,
},
});Для сложных условий доступен QueryBuilder:
const users = await userRepository
.createQueryBuilder('user')
.leftJoinAndSelect('user.posts', 'post')
.where('user.created_at >= :from', { from })
.andWhere('post.published_at IS NOT NULL')
.orderBy('user.created_at', 'DESC')
.take(20)
.getMany();ORM Sequelize
Sequelize — ORM для Node.js, поддерживающая PostgreSQL и другие SQL-базы.
Установка
npm install sequelize pgПодключение
import { Sequelize, DataTypes } from 'sequelize';
export const sequelize = new Sequelize(
process.env.DATABASE_URL,
{
dialect: 'postgres',
logging: false,
pool: {
max: 10,
min: 0,
acquire: 5_000,
idle: 30_000,
},
},
);
await sequelize.authenticate();Модель
export const User = sequelize.define(
'User',
{
id: {
type: DataTypes.INTEGER,
autoIncrement: true,
primaryKey: true,
},
email: {
type: DataTypes.STRING,
allowNull: false,
unique: true,
validate: {
isEmail: true,
},
},
name: {
type: DataTypes.STRING,
allowNull: true,
},
},
{
tableName: 'users',
underscored: true,
timestamps: true,
},
);CRUD
const user = await User.create({
email: 'user@example.com',
name: 'Анна',
});
const found = await User.findByPk(user.id);
await user.update({
name: 'Новое имя',
});
await user.destroy();Методы вроде sync({ alter: true }) удобны для локальных экспериментов, но не должны заменять проверяемые миграции рабочей базы.
Миграции схемы БД
Миграция — версионированное изменение структуры базы данных.
Миграции могут:
- создавать и удалять таблицы;
- добавлять и изменять колонки;
- создавать индексы;
- добавлять ограничения;
- изменять типы;
- переносить или преобразовывать данные.
Миграции должны:
- храниться в Git;
- выполняться в определённом порядке;
- проходить ревью;
- проверяться на тестовой базе;
- учитывать размер таблиц и блокировки;
- запускаться контролируемым этапом развёртывания.
Миграции Prisma
Создание и применение миграции во время разработки:
npx prisma migrate dev --name create-users-and-postsПрименение готовых миграций в рабочем окружении:
npx prisma migrate deployПроверка состояния:
npx prisma migrate statusmigrate dev предназначена для разработки. В production обычно применяется migrate deploy.
Миграции TypeORM
Создание миграции на основе изменений сущностей:
npx typeorm migration:generate \
./src/migrations/AddUsers \
-d ./src/data-source.tsСоздание пустой миграции:
npx typeorm migration:create \
./src/migrations/AddUserStatusПрименение:
npx typeorm migration:run \
-d ./dist/data-source.jsОткат последней миграции:
npx typeorm migration:revert \
-d ./dist/data-source.jsСгенерированный SQL необходимо проверять перед применением.
Миграции Sequelize
Установка CLI:
npm install --save-dev sequelize-cliСоздание миграции:
npx sequelize-cli migration:generate \
--name create-usersПрименение:
npx sequelize-cli db:migrateОткат:
npx sequelize-cli db:migrate:undoБезопасное изменение рабочей схемы
Добавление обязательной колонки в большую таблицу лучше выполнять поэтапно.
Сначала добавить необязательную колонку:
ALTER TABLE users
ADD COLUMN status text;Затем заполнить существующие строки:
UPDATE users
SET status = 'active'
WHERE status IS NULL;После проверки добавить значение по умолчанию и NOT NULL:
ALTER TABLE users
ALTER COLUMN status SET DEFAULT 'active';
ALTER TABLE users
ALTER COLUMN status SET NOT NULL;Для большой таблицы заполнение данных обычно выполняют порциями.
Создание индекса в рабочей системе может потребовать:
CREATE INDEX CONCURRENTLY users_status_idx
ON users(status);CREATE INDEX CONCURRENTLY нельзя выполнять внутри обычного транзакционного блока.
Если приложение запущено в нескольких экземплярах, не следует разрешать каждому экземпляру одновременно запускать миграции. Их обычно выполняют отдельной задачей развёртывания.
Модели и связи в ORM
Связь один к одному
Пример: у пользователя есть один профиль.
users.id 1 ─── 1 profiles.user_idВ базе данных внешний ключ на стороне профиля должен быть уникальным:
CREATE TABLE profiles (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id bigint NOT NULL UNIQUE
REFERENCES users(id)
ON DELETE CASCADE,
bio text
);UNIQUE на user_id превращает обычную связь многие-к-одному в один-к-одному.
Связь один ко многим
Один пользователь создаёт много публикаций:
users.id 1 ─── N posts.author_idCREATE TABLE posts (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
author_id bigint NOT NULL
REFERENCES users(id),
title text NOT NULL
);
CREATE INDEX posts_author_id_idx
ON posts(author_id);PostgreSQL автоматически создаёт индексы для PRIMARY KEY и UNIQUE, но не создаёт индекс для каждого внешнего ключа.
Индекс posts.author_id полезен для:
- получения публикаций автора;
- выполнения
JOIN; - проверки связанных строк при изменении родителя;
- удаления или обновления пользователя.
Связь многие ко многим
Публикация может иметь много тегов, а тег может относиться к разным публикациям:
posts 1 ─── N post_tags N ─── 1 tagsCREATE TABLE post_tags (
post_id bigint NOT NULL
REFERENCES posts(id)
ON DELETE CASCADE,
tag_id bigint NOT NULL
REFERENCES tags(id)
ON DELETE CASCADE,
PRIMARY KEY (post_id, tag_id)
);
CREATE INDEX post_tags_tag_id_idx
ON post_tags(tag_id);Если связь имеет собственные атрибуты, её лучше моделировать как отдельную сущность:
post_id
tag_id
assigned_at
assigned_by
positionПоведение при удалении
| Настройка | Поведение |
|---|---|
ON DELETE RESTRICT |
Запрещает удаление родителя |
ON DELETE CASCADE |
Удаляет зависимые строки |
ON DELETE SET NULL |
Обнуляет внешний ключ |
ON DELETE NO ACTION |
Проверяет ограничение по правилам PostgreSQL |
CASCADE следует использовать осознанно: удаление одной строки может затронуть большой граф связанных данных.
Проблема N+1
Проблема N+1 возникает, когда приложение:
- выполняет один запрос для получения 100 пользователей;
- выполняет ещё 100 запросов для получения публикаций каждого пользователя.
Решения:
JOIN;- ORM
include; - пакетная загрузка;
- отдельный запрос по набору идентификаторов;
- DataLoader в GraphQL-приложениях.
Не следует автоматически загружать все связи. Это может создать тяжёлый SQL-запрос и большой ответ.
Транзакции через pg
Транзакция объединяет несколько операций в единое целое: либо фиксируются все изменения, либо не фиксируется ни одно.
Основной шаблон
export async function transferMoney(
fromAccountId,
toAccountId,
amount,
) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const debitResult = await client.query(
`UPDATE accounts
SET balance = balance - $2
WHERE id = $1
AND balance >= $2
RETURNING balance`,
[fromAccountId, amount],
);
if (debitResult.rowCount === 0) {
throw new Error(
'Недостаточно средств или счёт не найден',
);
}
const creditResult = await client.query(
`UPDATE accounts
SET balance = balance + $2
WHERE id = $1
RETURNING balance`,
[toAccountId, amount],
);
if (creditResult.rowCount === 0) {
throw new Error('Счёт получателя не найден');
}
await client.query(
`INSERT INTO transfers (
from_account_id,
to_account_id,
amount
)
VALUES ($1, $2, $3)`,
[fromAccountId, toAccountId, amount],
);
await client.query('COMMIT');
return {
fromBalance: debitResult.rows[0].balance,
toBalance: creditResult.rows[0].balance,
};
} catch (error) {
await client.query('ROLLBACK').catch(
(rollbackError) => {
console.error('Ошибка ROLLBACK', rollbackError);
},
);
throw error;
} finally {
client.release();
}
}Все команды транзакции должны выполняться через один объект client.
Неправильно:
const client = await pool.connect();
await client.query('BEGIN');
await pool.query('UPDATE accounts ...');
await client.query('COMMIT');pool.query() может использовать другое соединение, которое не участвует в транзакции.
Уровни изоляции
await client.query(
'BEGIN ISOLATION LEVEL SERIALIZABLE',
);Основные уровни PostgreSQL:
READ COMMITTED— значение по умолчанию; каждая команда видит данные, зафиксированные до её начала;REPEATABLE READ— транзакция работает со стабильным снимком;SERIALIZABLE— наиболее строгий уровень, но возможны конфликты, требующие повтора транзакции.
Блокировка строк
Если изменение зависит от текущего состояния строки, может потребоваться блокировка:
const result = await client.query(
`SELECT id, balance
FROM accounts
WHERE id = $1
FOR UPDATE`,
[accountId],
);FOR UPDATE блокирует выбранные строки от конкурирующих изменений до завершения транзакции.
Транзакции следует делать короткими. Внутри транзакции не рекомендуется:
- ожидать пользовательский ввод;
- отправлять email;
- вызывать медленный внешний API;
- выполнять длительные вычисления.
Транзакции в Prisma
Интерактивная транзакция:
const result = await prisma.$transaction(async (tx) => {
const user = await tx.user.create({
data: {
email: 'user@example.com',
},
});
const post = await tx.post.create({
data: {
title: 'Первая публикация',
authorId: user.id,
},
});
return {
user,
post,
};
});Внутри callback необходимо использовать tx, а не глобальный объект prisma.
Последовательность независимых ORM-операций также можно передать массивом:
const [user, post] = await prisma.$transaction([
prisma.user.create({
data: {
email: 'user@example.com',
},
}),
prisma.post.update({
where: {
id: 10,
},
data: {
title: 'Обновлённый заголовок',
},
}),
]);Транзакции в TypeORM
const result = await dataSource.transaction(
async (manager) => {
const userRepository = manager.getRepository(User);
const postRepository = manager.getRepository(Post);
const user = await userRepository.save({
email: 'user@example.com',
});
const post = await postRepository.save({
title: 'Первая публикация',
authorId: user.id,
});
return {
user,
post,
};
},
);Внутри транзакции нужно использовать переданный manager, а не обычные глобальные репозитории.
Транзакции в Sequelize
const result = await sequelize.transaction(
async (transaction) => {
const user = await User.create(
{
email: 'user@example.com',
},
{
transaction,
},
);
const post = await Post.create(
{
title: 'Первая публикация',
authorId: user.id,
},
{
transaction,
},
);
return {
user,
post,
};
},
);Если callback завершается успешно, Sequelize фиксирует транзакцию. Если callback выбрасывает исключение, транзакция откатывается.
Обработка ошибок PostgreSQL
Ошибки PostgreSQL следует распознавать по пятисимвольному коду SQLSTATE, а не по тексту сообщения.
Частые коды:
| SQLSTATE | Значение |
|---|---|
23505 |
Нарушение UNIQUE |
23503 |
Нарушение внешнего ключа |
23502 |
Нарушение NOT NULL |
23514 |
Нарушение CHECK |
22P02 |
Некорректное представление типа |
40001 |
Ошибка сериализации |
40P01 |
Взаимная блокировка |
42P01 |
Таблица не существует |
42703 |
Колонка не существует |
57014 |
Выполнение команды отменено |
Обработка ошибки pg
try {
await pool.query(
`INSERT INTO users (email)
VALUES ($1)`,
[email],
);
} catch (error) {
if (
error.code === '23505' &&
error.constraint === 'users_email_key'
) {
throw new ConflictError(
'Пользователь с таким email уже существует',
);
}
throw error;
}Объект ошибки может содержать:
code— SQLSTATE;constraint— имя ограничения;table— таблица;column— колонка;detail— дополнительные сведения;severity— уровень ошибки.
Сырой объект ошибки не следует возвращать клиенту. Он может раскрывать:
- структуру схемы;
- имена таблиц и ограничений;
- фрагменты SQL;
- значения данных;
- внутреннее устройство приложения.
Преобразование ошибки в HTTP-ответ
export function mapDatabaseError(error) {
switch (error.code) {
case '23505':
return {
status: 409,
code: 'RESOURCE_ALREADY_EXISTS',
message: 'Запись с такими данными уже существует',
};
case '23503':
return {
status: 409,
code: 'RELATED_RESOURCE_CONFLICT',
message: 'Операция нарушает связь между данными',
};
case '23502':
case '23514':
case '22P02':
return {
status: 400,
code: 'INVALID_DATA',
message: 'Переданы некорректные данные',
};
case '57014':
return {
status: 503,
code: 'DATABASE_TIMEOUT',
message: 'База данных не успела выполнить запрос',
};
default:
return {
status: 500,
code: 'INTERNAL_ERROR',
message: 'Внутренняя ошибка',
};
}
}Ограничение вместо предварительного SELECT
Такой код не гарантирует уникальность:
const existing = await pool.query(
'SELECT id FROM users WHERE email = $1',
[email],
);
if (existing.rowCount === 0) {
await pool.query(
'INSERT INTO users (email) VALUES ($1)',
[email],
);
}Между SELECT и INSERT другая транзакция может вставить такую же запись.
Надёжный подход:
- создать ограничение
UNIQUE; - выполнить
INSERT; - обработать ошибку
23505.
ALTER TABLE users
ADD CONSTRAINT users_email_key UNIQUE (email);База данных должна быть окончательным механизмом проверки целостности.
Обработка ошибок Prisma
Prisma использует собственные коды известных ошибок.
Пример нарушения уникальности:
import { Prisma } from '@prisma/client';
try {
await prisma.user.create({
data: {
email,
},
});
} catch (error) {
if (
error instanceof
Prisma.PrismaClientKnownRequestError &&
error.code === 'P2002'
) {
throw new ConflictError('Email уже используется');
}
throw error;
}ORM-ошибка может оборачивать исходную ошибку PostgreSQL, поэтому при журналировании полезно сохранять:
- код ORM;
- код SQLSTATE, если он доступен;
- имя операции;
- идентификатор запроса;
- длительность запроса.
Секретные параметры и персональные данные в журнал записывать не следует.
Обработка ошибок TypeORM
TypeORM предоставляет QueryFailedError:
import { QueryFailedError } from 'typeorm';
try {
await userRepository.save(user);
} catch (error) {
if (error instanceof QueryFailedError) {
const driverError = (
error as QueryFailedError & {
driverError: {
code?: string;
};
}
).driverError;
if (driverError.code === '23505') {
throw new ConflictError(
'Email уже используется',
);
}
}
throw error;
}Обработка ошибок Sequelize
Sequelize предоставляет специализированные классы:
import { UniqueConstraintError } from 'sequelize';
try {
await User.create({
email,
});
} catch (error) {
if (error instanceof UniqueConstraintError) {
throw new ConflictError(
'Email уже используется',
);
}
throw error;
}Повтор транзакций
При уровне SERIALIZABLE транзакция может завершиться ошибкой 40001. PostgreSQL также может завершить одну из транзакций ошибкой 40P01 при взаимной блокировке.
Такие транзакции иногда можно повторить:
const RETRYABLE_CODES = new Set([
'40001',
'40P01',
]);
export async function withTransaction(
work,
maxAttempts = 3,
) {
for (
let attempt = 1;
attempt <= maxAttempts;
attempt += 1
) {
const client = await pool.connect();
try {
await client.query('BEGIN');
const result = await work(client);
await client.query('COMMIT');
return result;
} catch (error) {
await client
.query('ROLLBACK')
.catch(() => {});
const shouldRetry =
RETRYABLE_CODES.has(error.code) &&
attempt < maxAttempts;
if (!shouldRetry) {
throw error;
}
} finally {
client.release();
}
}
throw new Error('Транзакция не была выполнена');
}Повторять следует только операции, безопасные для повторного выполнения.
Если внутри операции отправляется письмо или выполняется внешний платёж, повтор транзакции не должен случайно повторять внешнее действие. Для таких случаев используют идемпотентность или шаблон transactional outbox.
Тайм-ауты запросов
Запрос не должен выполняться неограниченно долго.
Тайм-аут можно установить внутри транзакции:
const client = await pool.connect();
try {
await client.query('BEGIN');
await client.query(
"SET LOCAL statement_timeout = '3s'",
);
const result = await client.query(
`SELECT id, email
FROM users
WHERE status = $1`,
['active'],
);
await client.query('COMMIT');
return result.rows;
} catch (error) {
await client
.query('ROLLBACK')
.catch(() => {});
throw error;
} finally {
client.release();
}SET LOCAL действует только до конца текущей транзакции.
Полезные параметры PostgreSQL:
statement_timeout— максимальное время команды;lock_timeout— максимальное ожидание блокировки;idle_in_transaction_session_timeout— максимальное время бездействия внутри открытой транзакции.
Типы PostgreSQL и JavaScript
Некоторые типы требуют дополнительного внимания.
bigint
JavaScript Number не может точно представить все значения PostgreSQL bigint. Поэтому драйвер может вернуть такое значение строкой:
{
id: '9223372036854775807'
}Не следует автоматически выполнять:
const id = Number(row.id);Для больших значений можно использовать строку или BigInt:
const id = BigInt(row.id);numeric
numeric также часто возвращается строкой, поскольку десятичное число может иметь точность, недоступную обычному Number.
{
amount: '1234567890.123456'
}Для денежных значений часто используют:
numericи специализированную decimal-библиотеку;- целое количество минимальных денежных единиц;
- строку на границе слоя хранения.
Имена колонок
PostgreSQL обычно использует snake_case:
SELECT created_at
FROM users;JavaScript-приложение может ожидать camelCase. Через pg можно использовать псевдоним:
SELECT created_at AS "createdAt"
FROM users;TLS и секреты
При удалённом подключении к PostgreSQL обычно требуется TLS.
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl:
process.env.NODE_ENV === 'production'
? {
rejectUnauthorized: true,
}
: false,
});Если поставщик базы предоставляет корневой сертификат:
import fs from 'node:fs';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
ssl: {
ca: fs.readFileSync(
process.env.PG_CA_FILE,
'utf8',
),
rejectUnauthorized: true,
},
});Настройка:
rejectUnauthorized: falseотключает проверку сертификата и снижает безопасность соединения. Её не следует использовать как универсальное production-решение.
В логи нельзя записывать:
- строку подключения;
- пароль;
- содержимое токена;
- чувствительные параметры SQL;
- персональные данные без необходимости.
Производительность
Не выбирать лишние поля
Вместо:
SELECT *
FROM users;лучше явно перечислить колонки:
SELECT id, email, name
FROM users;Ограничивать результат
const result = await pool.query(
`SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT $3`,
[cursorDate, cursorId, 50],
);Для больших наборов keyset pagination обычно устойчивее большого OFFSET.
Анализировать запросы
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, email
FROM users
WHERE email = 'user@example.com';ANALYZE фактически выполняет запрос. Для UPDATE, DELETE и других изменяющих команд это нужно учитывать.
Наблюдать за пулом
У pg.Pool доступны основные показатели:
const metrics = {
total: pool.totalCount,
idle: pool.idleCount,
waiting: pool.waitingCount,
};Постоянный рост waitingCount может указывать на:
- утечку клиентов;
- долгие запросы;
- слишком маленький пул;
- перегрузку PostgreSQL;
- длительные транзакции.
Размер пула нельзя бесконечно увеличивать. Большое количество параллельных запросов может ухудшить производительность базы.
Частые ошибки
Создание пула на каждый запрос
Неправильно:
app.get('/users', async (req, res) => {
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
const result = await pool.query(
'SELECT id FROM users',
);
await pool.end();
res.json(result.rows);
});Пул должен создаваться один раз и переиспользоваться.
Клиент не возвращается в пул
Неправильно:
const client = await pool.connect();
const result = await client.query(
'SELECT id FROM users',
);
return result.rows;Правильно:
const client = await pool.connect();
try {
const result = await client.query(
'SELECT id FROM users',
);
return result.rows;
} finally {
client.release();
}Использование разных соединений в транзакции
Все команды от BEGIN до COMMIT или ROLLBACK должны выполняться через один клиент.
Автоматическая синхронизация production-схемы
TypeORM synchronize и Sequelize sync({ alter: true }) не заменяют миграции.
Неограниченная загрузка связей
Глубокий include или eager loading может создать тяжёлый запрос и огромный результат.
Проверка уникальности только в приложении
Предварительный SELECT не защищает от конкурентных вставок. Необходимо ограничение UNIQUE.
Длительные транзакции
Открытая транзакция удерживает ресурсы и блокировки. Внешние запросы и продолжительные операции следует выносить за её пределы.
Слишком большой пул
Размер пула необходимо рассчитывать с учётом всех экземпляров приложения, фоновых задач и лимита PostgreSQL.
Краткий чек-лист
Перед выпуском Node.js-приложения с PostgreSQL следует проверить:
DATABASE_URLхранится в секретах;- рабочее удалённое соединение использует проверяемый TLS;
- на один процесс создаётся один контролируемый пул;
- суммарный размер пулов соответствует лимиту PostgreSQL;
- пользовательские значения передаются параметрами;
- динамические имена колонок ограничены белым списком;
- схема изменяется через миграции;
- миграции проходят ревью и тестирование;
- транзакция использует одно соединение;
- клиент возвращается в пул через
finally; - запросы имеют тайм-ауты;
- целостность защищена ограничениями базы;
- ошибки распознаются по SQLSTATE или коду ORM;
- внутренние ошибки не возвращаются клиенту;
- отслеживаются медленные запросы и состояние пула;
- приложение корректно закрывает соединения.
Итог
pg подходит, когда нужен прозрачный SQL, полный контроль над запросами и прямой доступ к возможностям PostgreSQL.
Prisma удобна для типизированной разработки и декларативной схемы. TypeORM хорошо вписывается в проекты с классами и декораторами. Sequelize предоставляет традиционный ORM-подход с моделями и экземплярами записей.
Выбор библиотеки не отменяет основных требований к работе с базой данных. Надёжная интеграция строится на параметризованных запросах, разумном пуле соединений, версионированных миграциях, коротких транзакциях, ограничениях на уровне PostgreSQL и системной обработке ошибок.