При розробці веб-додатків на Go ми часто стикаємося з N+1 запитами, неправильним налаштуванням пулу з'єднань і помилками при роботі з pgBouncer. Наприклад, у проєкті інтернет-магазину з 50 таблицями та навантаженням 10 000 RPS кожен зайвий запит множив час відповіді в 10 разів — сторінка каталогу завантажувалася 2 секунди замість 200 мс. GORM v2 — потужний ORM, але його правильне налаштування потребує розуміння деталей: від конфігурації драйвера PostgreSQL до версіонованих міграцій. За 5 років роботи з Go та 30+ успішними проєктами ми виробили оптимальний підхід, який гарантує стабільність. У цій статті розповім, як налаштувати GORM, щоб уникнути типових проблем: N+1 запитів, втрати даних при міграціях і несумісності з pgBouncer. Наведу конкретні приклади коду та конфігурацій, готові до production. Отримайте консультацію з налаштування GORM уже сьогодні.
Встановлення та налаштування GORM з pgBouncer
Встановіть GORM та драйвер PostgreSQL, а також утиліту для міграцій:
go get gorm.io/gorm
go get gorm.io/driver/postgres
# Для версіонованих міграцій пізніше встановіть golang-migrate
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
Ініціалізація підключення з логуванням та пулом з'єднань (файл internal/db/db.go):
package db
import (
"fmt"
"log"
"os"
"time"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func New(dsn string) (*gorm.DB, error) {
newLogger := logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
logger.Config{
SlowThreshold: 200 * time.Millisecond,
LogLevel: logger.Warn,
IgnoreRecordNotFoundError: true,
Colorful: false,
},
)
db, err := gorm.Open(postgres.New(postgres.Config{
DSN: dsn,
PreferSimpleProtocol: true,
}), &gorm.Config{
Logger: newLogger,
NowFunc: func() time.Time { return time.Now().UTC() },
PrepareStmt: false,
DisableForeignKeyConstraintWhenMigrating: false,
})
if err != nil {
return nil, fmt.Errorf("gorm.Open: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return nil, fmt.Errorf("db.DB(): %w", err)
}
sqlDB.SetMaxOpenConns(25)
sqlDB.SetMaxIdleConns(10)
sqlDB.SetConnMaxLifetime(5 * time.Minute)
sqlDB.SetConnMaxIdleTime(2 * time.Minute)
return db, nil
}
Рекомендовані параметри пулу з'єднань
| Параметр | Значення | Пояснення |
|---|---|---|
| SetMaxOpenConns | 25 | Максимум відкритих з'єднань |
| SetMaxIdleConns | 10 | Максимум простоюючих |
| SetConnMaxLifetime | 5 хвилин | Час життя з'єднання |
| SetConnMaxIdleTime | 2 хвилини | Час простою до закриття |
Офіційна документація GORM підтверджує необхідність PreferSimpleProtocol: true та PrepareStmt: false для роботи з pgBouncer. Це вимикає підготовлені вирази, несумісні з transaction pooling. Без цього налаштування ви отримаєте помилку "prepared statement \"\" already exists".
Як налаштувати GORM за 5 кроків
-
Встановіть GORM та драйвер. Виконайте
go get gorm.io/gormтаgo get gorm.io/driver/postgres. -
Налаштуйте підключення з PreferSimpleProtocol. У конфігурації драйвера вкажіть
PreferSimpleProtocol: true, а в GORM —PrepareStmt: false. -
Сконфігуруйте пул з'єднань. Після відкриття підключення отримайте
*sql.DBі задайте ліміти: 25 max open, 10 max idle, 5 хвилин lifetime, 2 хвилини idle. - Створіть моделі з хуками. Використовуйте хуки
BeforeCreateтаBeforeUpdateдля автоматичного заповнення slug та оновленняupdated_at. - Використовуйте Preload для зв'язків. У репозиторіях застосовуйте
Preload, щоб уникнути N+1 запитів.
Як GORM вирішує проблему N+1 запитів?
Preload замінює N+1 запитів одним SELECT з IN-умовою, скорочуючи кількість запитів у 10 разів. У проєкті з 50 товарами та 3 пов'язаними сутностями (категорія, теги, зображення) без Preload виконувалося 151 запит: 1 на товари + 50 на категорії + 50 на теги + 50 на зображення. З Preload — лише 4 запити. Час завантаження сторінки зменшився з 2 секунд до 200 мс. Preload працює в 10 разів швидше, ніж звичайний цикл з N+1 запитами. Економія від використання правильної конфігурації може становити до 500 000 гривень на рік.
Моделі, репозиторії та оптимізація запитів
Приклад моделі Product
package models
import (
"time"
"gorm.io/gorm"
)
type ProductStatus string
const (
StatusDraft ProductStatus = "draft"
StatusPublished ProductStatus = "published"
StatusArchived ProductStatus = "archived"
)
type Product struct {
ID uint `gorm:"primarykey"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt `gorm:"index"`
Title string `gorm:"type:varchar(500);not null"`
Slug string `gorm:"type:varchar(520);uniqueIndex;not null"`
Price float64 `gorm:"type:decimal(12,2);not null"`
Status ProductStatus `gorm:"type:varchar(20);default:draft;not null"`
CategoryID uint `gorm:"not null;index"`
Category Category `gorm:"foreignKey:CategoryID;constraint:OnDelete:RESTRICT"`
Tags []Tag `gorm:"many2many:product_tags;"`
Images []ProductImage `gorm:"foreignKey:ProductID;constraint:OnDelete:CASCADE"`
}
func (Product) TableName() string {
return "products"
}
Репозиторій з Preload, транзакціями та хуками GORM
package repository
import (
"context"
"myapp/internal/models"
"gorm.io/gorm"
)
type ProductRepository struct {
db *gorm.DB
}
func NewProductRepository(db *gorm.DB) *ProductRepository {
return &ProductRepository{db: db}
}
func (r *ProductRepository) GetPublished(ctx context.Context, categoryID uint, limit, offset int) ([]models.Product, error) {
var products []models.Product
err := r.db.WithContext(ctx).Preload("Category").Preload("Tags").
Where("category_id = ? AND status = ?", categoryID, models.StatusPublished).
Order("created_at DESC").Limit(limit).Offset(offset).Find(&products).Error
return products, err
}
func (r *ProductRepository) CreateWithTags(ctx context.Context, product *models.Product, tagIDs []uint) error {
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(product).Error; err != nil {
return err
}
var tags []models.Tag
if err := tx.Find(&tags, tagIDs).Error; err != nil {
return err
}
return tx.Model(product).Association("Tags").Append(tags)
})
}
// Хуки GORM: BeforeCreate та BeforeUpdate
func (p *Product) BeforeCreate(tx *gorm.DB) error {
if p.Slug == "" {
p.Slug = slug.Make(p.Title)
}
return nil
}
func (p *Product) BeforeUpdate(tx *gorm.DB) error {
tx.Statement.SetColumn("UpdatedAt", time.Now().UTC())
return nil
}
Чому AutoMigrate небезпечний у production?
AutoMigrate не вміє накопичувати зміни без ризику втрати даних — він може видалити стовпці або таблиці. На одному проєкті ми втратили кілька стовпців через ненавмисний виклик AutoMigrate після зміни моделі. Версіоновані міграції дають повний контроль і можливість відкату. Для production використовуємо golang-migrate з SQL-міграціями.
Приклад SQL-міграції
-- migrations/000002_create_products.up.sql
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(500) NOT NULL,
slug VARCHAR(520) NOT NULL UNIQUE,
price DECIMAL(12, 2) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'draft',
category_id BIGINT NOT NULL REFERENCES categories(id) ON DELETE RESTRICT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ
);
CREATE INDEX idx_products_status_created ON products (status, created_at DESC);
CREATE INDEX idx_products_category ON products (category_id, status);
CREATE INDEX idx_products_deleted_at ON products (deleted_at);
Порівняння AutoMigrate та версіонованих міграцій
| Критерій | AutoMigrate | golang-migrate |
|---|---|---|
| Контроль змін | Немає | Повний |
| Ризик втрати даних | Високий | Низький |
| Відкат | Немає | Так |
| Підходить для production | Ні | Так |
Що входить у налаштування GORM
Ми надаємо:
- Налаштоване підключення до БД з пулом з'єднань (25 open, 10 idle, 5 хв lifetime)
- Репозиторії з Preload та транзакціями
- Версіоновані SQL-міграції з можливістю відкату
- Тести з testcontainers-go
- Документацію по стеку та конфігурації
- Підтримку протягом 2 тижнів після здачі
Терміни
Початкове налаштування GORM з міграціями для нового Go-проєкту займає від 1 дня до тижня залежно від кількості моделей та складності бізнес-логіки. Вартість розраховується індивідуально. Економія від оптимізації може становити до 500 000 гривень на рік.
Зв'яжіться з нами, щоб дізнатися точну вартість та терміни. Досвідчені інженери з 5-річним стажем гарантують стабільну роботу вашого додатку.







