Перейти к содержимому

ORM, драйверы и миграции в Go

Доступ к реляционной БД из Go — это слоёный пирог, и на собеседовании от вас ждут, что вы этот пирог различаете по слоям. Внизу лежит драйвер — код, говорящий по проволочному протоколу конкретной СУБД: github.com/jackc/pgx для PostgreSQL, github.com/go-sql-driver/mysql для MySQL, modernc.org/sqlite или mattn/go-sqlite3 для SQLite. Над драйвером — стандартный database/sql: это не ORM и даже не клиент БД, а абстракция «пул соединений + интерфейс driver.Driver». Она даёт Query/QueryRow/Exec/Begin, prepared statements, контексты и настройки пула (SetMaxOpenConns, SetMaxIdleConns, SetConnMaxLifetime, SetConnMaxIdleTime), но ничего не знает про структуры, схему и типы вашей БД. Выше — три конкурирующих подхода: тонкие хелперы (jmoiron/sqlx), построители запросов (Masterminds/squirrel, doug-martin/goqu, go-jet/jet), кодогенераторы (sqlc, ent, sqlboiler) и «настоящие» ORM (gorm.io/gorm, uptrace/bun, xorm).

Ключевая мысль, которую полезно проговорить: в Go нет ORM уровня Hibernate/Doctrine и по объективным причинам быть не может. Классический ORM опирается на рантайм-проксирование объектов (байткод-инструментация в JVM, магические методы в PHP), чтобы реализовать lazy loading, identity map, unit of work и dirty checking. В Go нет ни наследования реализации, ни возможности подменить структуру прокси-объектом; единственный рантайм-инструмент — рефлексия, а единственная альтернатива — кодогенерация. Поэтому Go-экосистема разошлась в две стороны: GORM/bun имитируют ORM через рефлексию и теги структур (ценой скорости и «магии»), а ent/sqlc/sqlboiler генерируют типобезопасный код на этапе сборки (ценой шага генерации в билде).

Второй большой блок темы — миграции схемы. Миграция — это версионированное, воспроизводимое, применяемое ровно один раз изменение схемы (а иногда и данных), лежащее в репозитории рядом с кодом. Инструменты в Go: golang-migrate/migrate, pressly/goose, ariga/atlas, amacneil/dbmate, rubenv/sql-migrate; из мира JVM в инфраструктуре часто соседствуют Flyway и Liquibase. Все они устроены одинаково: каталог файлов с монотонным префиксом версии, служебная таблица в самой БД с текущей версией, блокировка на время применения. Практическая разница между PostgreSQL и MySQL здесь принципиальна: в PostgreSQL DDL транзакционный, поэтому упавшую миграцию можно откатить целиком; в MySQL DDL вызывает неявный commit, и «половина применённой миграции» — штатная беда, ради которой существует флаг dirty в golang-migrate.

Третья вещь, которую спрашивают почти всегда, — как катить схему без даунтайма. Ответ один и тот же во всех компаниях: паттерн expand/contract (он же parallel change). Сначала добавляем новое (nullable-колонку, новую таблицу, новый индекс) так, чтобы старый код продолжал работать; потом выкатываем код, умеющий писать и читать в оба места; потом бэкфилл данных пачками; потом переключаем чтение; и только через несколько релизов удаляем старое. Никаких DROP COLUMN в том же релизе, где выкатывается код. Для тяжёлых ALTER в MySQL — ALGORITHM=INPLACE/INSTANT или внешние инструменты gh-ost/pt-online-schema-change.

Коротко. Это вопрос про ваш реальный процесс, и хороший ответ — это описание конвейера: миграции лежат SQL-файлами в репозитории сервиса, версионируются таймстемпом, применяются отдельным шагом CI/CD перед выкаткой кода (в Kubernetes — init-container или отдельная Job), состояние хранится в служебной таблице (schema_migrations у golang-migrate, goose_db_version у goose), тяжёлые ALTER на больших таблицах катаются через gh-ost, схема меняется только обратно совместимо по паттерну expand/contract.

Глубже. Что интервьюер хочет услышать и из чего строить ответ:

  1. Где живут миграции. В репозитории того сервиса, который владеет базой (одна база — один владелец). Файлы вида 20240517103000_add_orders_status.up.sql / .down.sql. Ревьюятся в том же PR, что и код.
  2. Кто и когда применяет. Не приложение при старте (иначе N реплик стартуют одновременно и дерутся за схему — хотя все нормальные инструменты берут блокировку), а отдельный шаг пайплайна: migrate -path ./migrations -database "$DSN" up в job’е, которая должна завершиться до rollout’а деплоймента.
  3. Специфика именно MySQL. DDL в MySQL вызывает неявный commit, транзакционного DDL нет (в MySQL 8.0 появился atomic DDL, но он гарантирует атомарность одного оператора на уровне словаря данных InnoDB, а не отката целого файла миграции). Следствие: файл миграции из пяти ALTERов может упасть на третьем и оставить схему в промежуточном состоянии. golang-migrate в этом случае помечает версию как dirty, и дальнейшие миграции не применяются, пока человек не разберётся руками и не сделает migrate force <version>. Практический вывод, который стоит озвучить: один DDL-оператор на файл миграции.
  4. Блокировки и онлайн-DDL. Любой ALTER берёт metadata lock, и если по таблице идёт долгая транзакция или долгий SELECT, ALTER встанет в очередь и заблокирует за собой все последующие запросы к таблице — классический способ уронить прод. Поэтому: lock_wait_timeout небольшой, ALTER’ы в окно низкой нагрузки, ALGORITHM=INSTANT где возможно (добавление колонки в конец — с 8.0.12, в произвольную позицию — с 8.0.29), ALGORITHM=INPLACE, LOCK=NONE для индексов, а для многогигабайтных таблиц — gh-ost (читает binlog, без триггеров, требует binlog_format=ROW) или pt-online-schema-change (копия таблицы + триггеры). Оба делают теневую копию и атомарный RENAME в конце, оба умеют дросселировать по лагу реплик.
  5. Реплики. На схему с репликацией смотрим отдельно: DDL едет по репликации и на реплике выполняется тем же временем, что и на мастере, создавая лаг. Отсюда — дросселирование и ночные окна.
  6. Данные. Бэкфилл больших объёмов — не миграцией, а отдельной идемпотентной джобой пачками по несколько тысяч строк с паузами, иначе получите гигантскую транзакцию, раздутый undo log и лаг репликации.

Типичная ошибка ответа — сказать «мы использовали AutoMigrate из GORM». Это допустимо в pet-проекте, но в проде AutoMigrate не удаляет колонки, не переименовывает, не версионируется и не ревьюится — так что если такое было, честно скажите «в проде так делать нельзя, потому что…».

Были ли случаи отката миграций? При каких обстоятельствах?

Заголовок раздела «Были ли случаи отката миграций? При каких обстоятельствах?»

Коротко. Честный ответ, который ценят: «настоящих откатов схемы в проде почти не бывает — почти всегда чинят накатом вперёд (roll forward), потому что down-миграция для DROP COLUMN не возвращает данные». Откаты реально случаются на dev/stage и в момент, когда миграция упала на середине и оставила схему в промежуточном состоянии.

Глубже. Как разложить ответ:

  • Почему down пишут, но почти не запускают. down-миграция симметрична по DDL, но не по данным: откат ALTER TABLE ... DROP COLUMN создаст колонку заново — пустую. Откат DROP TABLE вернёт пустую таблицу. Поэтому down держат для локальной разработки и для тестов миграций (накатить/откатить/накатить), а в проде действует правило: любая миграция должна быть обратно совместима с предыдущей версией кода, тогда откат нужен только для приложения, а схема остаётся на месте.
  • Когда откат всё же случается. (а) Миграция упала на середине в MySQL и golang-migrate выставил dirty=true — дальше руками: доводим до консистентного состояния и migrate force. (б) Обнаружили, что новый индекс сломал план запроса и нагрузка на CPU выросла — DROP INDEX (это безопасный, обратимый откат). (в) Добавили NOT NULL колонку с дефолтом на большой таблице и получили долгий лок — откатили. (г) Выкатили миграцию, которая переименовывает колонку, и старые поды (ещё не убитые rollout’ом) начали падать с «unknown column» — вот это классика, и лечится она не откатом, а тем, что переименований в один шаг не делают вообще.
  • Как правильно. Expand/contract: релиз N добавляет новое поле, релиз N+1 пишет в оба, релиз N+2 читает из нового, релиз N+3 удаляет старое. При таком процессе откат приложения на любом шаге безопасен без отката схемы.
  • Что снижает риск. Прогон миграций на копии продовой схемы в CI, линтер миграций (atlas migrate lint умеет детектировать деструктивные и блокирующие изменения), обязательный down в PR, ограничение lock_wait_timeout/statement_timeout на сессии мигратора, бэкап/снапшот перед деструктивными шагами.

Какие ORM для Go вы знаете? Какую считаете наиболее удачной и почему?

Заголовок раздела «Какие ORM для Go вы знаете? Какую считаете наиболее удачной и почему?»

Коротко. Из «полноценных» — GORM (gorm.io/gorm), ent (entgo.io/ent), bun (uptrace/bun), SQLBoiler, xorm, upper/db. Рядом стоят кодогенератор sqlc и построители запросов squirrel, goqu, jet — формально не ORM, но решают тот же класс задач. Наиболее удачным для нового сервиса я считаю ent (если нужна работа с объектной моделью и связями) или sqlc (если проект SQL-центричный) — оба дают типобезопасность на этапе компиляции, без рефлексии и без строковых имён полей.

Глубже. Разбор по инструментам:

  • GORM — самый популярный, самый «магический». Рефлексия + теги структур, хуки (BeforeCreate, AfterSave), soft delete через поле gorm.DeletedAt, Preload/Joins для связей, AutoMigrate. Плюсы: быстрый старт, огромное сообщество, есть всё. Минусы: цепочечный API не типобезопасен (Where("stauts = ?", x) — опечатка в имени колонки ловится только в рантайме), легко случайно получить N+1 через Preload, генерируемый SQL бывает неочевиден, повторное использование *gorm.DB без Session/WithContext приводит к «залипанию» условий между запросами.
  • ent (изначально из Facebook) — схема описывается на Go, кодогенератор выпускает типизированный клиент: client.User.Query().Where(user.AgeGT(18)).WithPosts().All(ctx). Опечатка в имени поля — ошибка компиляции. Хорошо ложится на графовые обходы связей, умеет генерировать миграции (интегрирован с Atlas), privacy-слой, хуки. Минус — обязательный go generate, свой DSL схемы и заметная кривая обучения.
  • bun (наследник go-pg, но поверх database/sql) — «SQL-first ORM»: API близок к SQL, есть relations, миграции, фикстуры, хорошая поддержка PostgreSQL и MySQL. Разумный компромисс между GORM и голым SQL.
  • SQLBoiler — database-first кодогенерация: подключаетесь к существующей БД, генерируете модели. Отлично, когда схема — источник истины и живёт вне Go-кода.
  • sqlc — строго говоря не ORM: вы пишете .sql файлы с запросами и аннотациями (-- name: GetUser :one), а sqlc парсит их вместе со схемой и генерирует типизированные Go-функции. Ошибки в SQL ловятся на этапе генерации. Умеет генерить под database/sql и под pgx. Мой любимый вариант, когда команда умеет в SQL.
  • squirrel / goqu / jet — построители запросов. Нужны там, где запрос собирается динамически (фильтры из UI), чтобы не клеить строки руками. jet вдобавок генерирует типизированную модель схемы.
  • xorm, upper/db, reform, pop — существуют, встречаются в легаси; на новых проектах их обычно не берут.

Формулировка, которая хорошо звучит на собеседовании: «Универсально удачного нет. Для CRUD-сервиса с богатой объектной моделью — ent; для сервиса, где SQL сложный и его хочется видеть глазами, — sqlc плюс pgx; GORM беру, когда важна скорость старта и команда его уже знает».

С какими ORM для Go вы работали в реальных проектах?

Заголовок раздела «С какими ORM для Go вы работали в реальных проектах?»

Коротко. Вопрос про личный опыт. Отвечать нужно конкретно: назвать 1–3 инструмента, для каждого — что за проект, почему выбрали, где инструмент подвёл и как это чинили. Общие слова «работал с GORM, всё нормально» — худший вариант ответа.

Глубже. Каркас хорошего ответа:

  1. Контекст. «Сервис заказов, PostgreSQL, ~40 таблиц, нагрузка ~2k RPS на чтение».
  2. Что использовали и почему. «GORM — потому что он уже был в кодовой базе и команда его знала» или «перешли с GORM на sqlc, потому что 30% времени ревью уходило на споры о том, какой SQL сгенерит цепочка».
  3. Конкретная боль и её решение — это то, что отличает опытного кандидата. Примеры реальных болей, о которых можно говорить предметно:
    • N+1 в GORM: Preload делает отдельный SELECT ... IN (...) на каждую связь; в цикле по родителям — катастрофа. Лечится Joins или одним запросом с ручным сшиванием.
    • Soft delete в GORM: gorm.DeletedAt незаметно добавляет WHERE deleted_at IS NULL во все запросы, включая те, где вы этого не ждёте; уникальные индексы перестают работать как задумано (удалённая строка занимает значение). Обходится частичным уникальным индексом WHERE deleted_at IS NULL.
    • Переиспользование *gorm.DB: без db.Session(&gorm.Session{}) условия из предыдущего запроса «прилипают» к следующему.
    • Нулевые значения: GORM по умолчанию не обновляет поля с zero value при Updates(struct) — надо Select или map[string]any.
    • Транзакции: где начинается tx и как он протаскивается через слои (обычно — через контекст либо через явный аргумент-Querier).
  4. Вывод. «Сейчас на новых сервисах беру X, потому что Y».

Если реального опыта с ORM нет — так и скажите, но добавьте: «работал с pgx и sqlx напрямую, ORM изучал на пет-проекте, вот что понял про его границы применимости». Честность здесь читается лучше выдуманного опыта: следующим вопросом всё равно спросят детали.

Какие задачи решает ORM? В каких случаях ее применение оправдано, а в каких — нет?

Заголовок раздела «Какие задачи решает ORM? В каких случаях ее применение оправдано, а в каких — нет?»

Коротко. ORM решает impedance mismatch между реляционной моделью и объектной: маппинг строк в структуры и обратно, генерацию CRUD-SQL, работу со связями, диалектонезависимость, часто ещё миграции, хуки, кэш и транзакционные абстракции. Оправдан на CRUD-нагруженных приложениях с большим количеством однотипных таблиц и на ранних стадиях продукта; не оправдан там, где запросы сложные и производительность критична — аналитика, отчёты, hot path с тонкой настройкой планов.

Глубже. Конкретный список задач, которые снимает ORM:

  • Маппинг результата в структуры и структур в параметры — иначе это руками rows.Scan(&a, &b, &c, ...) для каждой таблицы.
  • Генерация рутинного SQL: INSERT/UPDATE/DELETE/SELECT by PK, обработка RETURNING, upsert.
  • Связи: one-to-many, many-to-many через join-таблицу, eager loading, каскады.
  • Единообразие: soft delete, created_at/updated_at, оптимистичная блокировка через версионное поле, аудит через хуки.
  • Портируемость диалектов (на практике переоценённое преимущество: реальная миграция с MySQL на PostgreSQL почти никогда не сводится к смене драйвера).
  • Транзакции и unit of work — сгруппировать изменения и записать их одной транзакцией.
  • Миграции — у GORM/ent/bun встроены.

Когда оправдан: админки и внутренние сервисы, много таблиц с похожим CRUD, маленькая команда и жёсткие сроки, домен, где связи между сущностями важнее производительности отдельного запроса, прототипы.

Когда не оправдан: отчёты и аналитика с оконными функциями, CTE, GROUPING SETS — на ORM это пишется хуже, чем на SQL; hot path, где нужно контролировать индексы и план (EXPLAIN ANALYZE над сгенерированным SQL превращается в гадание); batch-операции — COPY в PostgreSQL на порядок быстрее, чем INSERT через ORM в цикле; работа со специфичными фичами СУБД (JSONB-операторы, tsvector, партиционирование, advisory locks, SKIP LOCKED для очередей); команда, которая хорошо знает SQL и плохо — конкретный ORM.

Здравая позиция, которую стоит озвучить: ORM и SQL не взаимоисключающи. Нормальная архитектура — репозиторный слой, где 80% простых операций идут через ORM/кодогенератор, а сложные запросы пишутся руками и живут рядом. Практически все ORM в Go дают «люк вниз»: db.Raw(...).Scan(&x) в GORM, client.QueryContext в ent, bun.NewRaw, а sqlc изначально устроен как «пишите SQL сами».

Из Go какую использовали библиотеку для работы с PostgreSQL?

Заголовок раздела «Из Go какую использовали библиотеку для работы с PostgreSQL?»

Коротко. github.com/jackc/pgx (сейчас v5) — де-факто стандарт для PostgreSQL в Go. Его можно использовать двумя способами: нативным API через pgxpool либо как обычный database/sql-драйвер через пакет pgx/stdlib. Альтернатива — github.com/lib/pq, но она официально в режиме поддержки (maintenance mode), и её README сам рекомендует переходить на pgx.

Глубже. За что берут именно pgx:

  • Нативный бинарный протокол. pgx умеет extended query protocol с бинарным форматом параметров и результатов — меньше конверсий текст↔тип, заметно быстрее на числах, timestamp’ах и bytea.
  • Типы PostgreSQL из коробки (пакет pgtype): numeric, uuid, jsonb, inet, диапазоны, массивы, композитные типы, hstore. С lib/pq для массивов приходится звать pq.Array.
  • CopyFrom — реализация протокола COPY, единственный вменяемый способ залить сотни тысяч строк.
  • SendBatch — pipelining нескольких запросов за один round-trip.
  • LISTEN/NOTIFY, отмена запросов по контексту, кэш prepared statements, статистика пула.
  • pgxpool — собственный пул, который знает про специфику PostgreSQL (в отличие от универсального пула database/sql), умеет BeforeAcquire/AfterRelease, health check, MinConns/MaxConnLifetimeJitter.

Минимальный рабочий пример с нативным API:

package main
import (
"context"
"log"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func main() {
ctx := context.Background()
cfg, err := pgxpool.ParseConfig("postgres://user:pass@localhost:5432/app?sslmode=disable")
if err != nil {
log.Fatal(err)
}
cfg.MaxConns = 20
cfg.MaxConnLifetime = 30 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
log.Fatal(err)
}
defer pool.Close()
var name string
err = pool.QueryRow(ctx, `SELECT name FROM users WHERE id = $1`, 42).Scan(&name)
if err != nil {
log.Fatal(err)
}
log.Println(name)
}

Когда всё-таки нужен database/sql (например, ORM или библиотека миграций умеет работать только с ним) — берётся тот же pgx, но как драйвер:

import (
"database/sql"
_ "github.com/jackc/pgx/v5/stdlib"
)
db, err := sql.Open("pgx", dsn)

Отдельно стоит упомянуть PgBouncer: при работе через него в режиме transaction pooling нужно отключать кэш prepared statements (default_query_exec_mode = QueryExecModeSimpleProtocol или statement_cache_capacity = 0), иначе получите ошибки «prepared statement already exists». Это хороший знак опытности, если сами про него вспомните.

Что такое миграции? Какие пакеты для миграций использовал в Go?

Заголовок раздела «Что такое миграции? Какие пакеты для миграций использовал в Go?»

Коротко. Миграция — это версионированное изменение схемы (иногда и данных) БД, хранящееся в репозитории как код, применяемое ровно один раз и в строгом порядке; факт применения фиксируется в служебной таблице внутри самой базы. В Go основные пакеты: golang-migrate/migrate, pressly/goose, ariga/atlas, amacneil/dbmate, rubenv/sql-migrate; у GORM и ent есть встроенные механизмы.

Глубже. Как это устроено изнутри — это и есть то, что проверяют вопросом. Все инструменты работают по одной схеме: каталог файлов вида <version>_<name>.up.sql / .down.sql, служебная таблица с текущей версией, блокировка на время работы (в PostgreSQL — advisory lock pg_advisory_lock, в MySQL — GET_LOCK), чтобы две параллельно стартовавшие реплики не начали катить одно и то же.

Различия, которые полезно знать:

  • golang-migrate/migrate — самый распространённый. Таблица schema_migrations с двумя колонками: version (одна строка, а не журнал!) и dirty (bool). Версия — обычно unix-таймстемп. Есть CLI и библиотечный API, куча драйверов источников (файлы, embed FS, S3, GitHub). Главная особенность и главная боль: при падении миграции ставится dirty = true, после чего инструмент отказывается работать, пока человек не разберётся и не сделает migrate force <version>. Хранится только текущая версия, истории применений нет.
  • pressly/goose — таблица goose_db_version как настоящий журнал (строка на каждую применённую версию). Умеет Go-миграции: файл на Go с функциями up/down, куда можно положить логику бэкфилла, недоступную в чистом SQL. Директивы в комментариях: -- +goose Up, -- +goose Down, -- +goose NO TRANSACTION (обязательна для CREATE INDEX CONCURRENTLY в PostgreSQL, который нельзя выполнить внутри транзакции), -- +goose StatementBegin/End для многострочных функций и триггеров.
  • ariga/atlas — принципиально другой подход: декларативный. Вы описываете желаемое состояние схемы (HCL, SQL-DDL или прямо ent-схема), Atlas сравнивает с текущим и сам генерирует diff-миграцию. Плюс сильный линтер: atlas migrate lint ловит деструктивные изменения, блокирующие таблицу ALTER’ы и нарушения обратной совместимости прямо в CI. Есть и классический versioned-режим.
  • amacneil/dbmate — независимый от языка CLI, хорош, когда в компании зоопарк языков и хочется один инструмент.
  • GORM AutoMigrate — не миграции в инженерном смысле: сравнивает структуры с таблицами и добивает недостающее. Не удаляет колонки, не переименовывает, не версионируется, не ревьюится. Годится для локальной разработки и тестов, не годится для прода.
  • ent — генерирует миграции через Atlas, поддерживает и «автомиграцию» в dev-режиме, и versioned-миграции для прода.

Про транзакционность: PostgreSQL поддерживает DDL внутри транзакции, поэтому golang-migrate и goose оборачивают каждый файл в транзакцию, и упавшая миграция откатывается целиком. MySQL этого не умеет (неявный commit на DDL), поэтому там правило «один DDL на файл» — не стилистика, а защита от dirty-состояния.

Оператор //go:embed избавляет от необходимости класть SQL-файлы рядом с бинарником:

import (
"database/sql"
"embed"
"errors"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database/postgres"
"github.com/golang-migrate/migrate/v4/source/iofs"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
func migrateUp(db *sql.DB) error {
src, err := iofs.New(migrationsFS, "migrations")
if err != nil {
return err
}
drv, err := postgres.WithInstance(db, &postgres.Config{})
if err != nil {
return err
}
m, err := migrate.NewWithInstance("iofs", src, "postgres", drv)
if err != nil {
return err
}
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return err
}
return nil
}

Коротко. См. выше вопрос «С какими ORM для Go вы работали в реальных проектах?» — это его сокращённая формулировка. Отличие в том, что здесь ждут просто перечисления, поэтому назовите список и сразу добавьте одну содержательную деталь по каждому, не дожидаясь уточняющего вопроса.

Глубже. Схема ответа на 30 секунд: «GORM — на двух сервисах, знаю его слабые места: soft delete, прилипающие условия без Session, N+1 на Preload. Смотрел ent — нравится типобезопасность и то, что опечатка в поле не компилируется. Сейчас предпочитаю sqlc с pgx: SQL пишу сам, а маппинг генерируется». Дальше молчите — интервьюер сам выберет, куда копать. Не перечисляйте инструменты, которых не открывали: следующий вопрос будет «а как там устроены транзакции?».

Используете ли вы ORM для работы с базой данных или пишете SQL-запросы напрямую?

Заголовок раздела «Используете ли вы ORM для работы с базой данных или пишете SQL-запросы напрямую?»

Коротко. Правильный ответ — не «или», а «и»: простой CRUD через ORM или кодогенератор, сложные запросы (агрегации, CTE, оконные функции, специфика СУБД) — руками, и всё это спрятано за интерфейсом репозитория, чтобы бизнес-логика не знала, что именно внутри.

Глубже. Аргументация в обе стороны, которую стоит уметь развернуть:

  • За прямой SQL. Полный контроль над планом и индексами; EXPLAIN ANALYZE работает с тем же текстом, что лежит в репозитории; нет скрытых запросов и N+1; доступны все фичи PostgreSQL/MySQL; ревьюер видит ровно то, что уйдёт в базу. Против — много ручного Scan, легко получить рассинхрон между структурой и запросом, динамические фильтры превращаются в клейку строк.
  • За ORM. Меньше boilerplate, единообразие, встроенные транзакции/хуки/миграции, быстрее онбординг новичка. Против — «магия», непредсказуемый SQL, N+1, деградация производительности, которую замечают только на проде.
  • Компромисс, который сейчас считается лучшей практикой в Go. sqlc: SQL пишете вы, он проверяется на этапе генерации против реальной схемы, а Go-обвязка (структуры, Scan, типы параметров) генерируется. Получаете и контроль, и типобезопасность, и отсутствие рефлексии в рантайме. Для динамических фильтров рядом ставится squirrel.
  • Ключевой архитектурный тезис. Что бы вы ни выбрали, доступ к БД изолируется в репозиторном слое за интерфейсом (type UserRepo interface { ByID(ctx, id) (*User, error) }). Тогда замена GORM на sqlc — это переписывание одного пакета, а не всего сервиса, и юнит-тесты домена не тянут базу.
  • О безопасности. «Пишем SQL напрямую» не означает конкатенацию строк. Всегда плейсхолдеры ($1 в PostgreSQL, ? в MySQL) — драйвер передаёт значения отдельно от текста запроса, и SQL-инъекция становится невозможной. Имена таблиц и колонок параметризовать нельзя, поэтому если они динамические — только whitelist, и это ровно тот случай, где построитель запросов лучше строк.

Коротко. Если под «полноценным» понимать Hibernate/Entity Framework с identity map, unit of work, dirty checking, lazy loading через прокси и JPQL — такого в Go нет. Ближе всего подходят GORM (по набору фич) и ent (по строгости и типобезопасности), но оба сознательно не реализуют часть классических паттернов, потому что язык не даёт для них инструментов.

Глубже. Почему именно не даёт:

  1. Нет наследования реализации и нет рантайм-проксирования. Lazy loading в Hibernate работает так: вместо вашего объекта подсовывается сгенерированный на лету подкласс-прокси, который при первом обращении к геттеру идёт в базу. В Go структура — это память фиксированного layout’а, подменить её нельзя, а перехватить обращение к полю невозможно в принципе (нет свойств/геттеров на уровне языка). Максимум — заставить пользователя звать метод, но это уже не «прозрачная ленивая загрузка».
  2. Нет байткод-инструментации. Dirty checking (автоматически понять, какие поля объекта изменились, и сгенерировать UPDATE только по ним) в JVM делается инструментацией. В Go остаётся либо хранить снапшот исходной структуры и сравнивать рефлексией (дорого), либо требовать явного указания полей.
  3. Философия языка против «магии». Идиоматичный Go предпочитает явное неявному; сообщество исторически холодно относится к рефлексивным фреймворкам, и это влияет на то, что библиотеки не идут по пути Hibernate.
  4. Дженерики (Go 1.18+) помогли, но не решили. Они убрали часть interface{} из API (у bun и ряда билдеров это заметно), однако проблему прозрачных прокси и dirty checking не снимают.

Что в итоге есть и насколько это «полноценно»:

  • GORM покрывает большинство пунктов чек-листа ORM: маппинг, ассоциации, eager loading, хуки, транзакции, soft delete, миграции, полиморфные связи. Нет identity map (два First(&u, 1) вернут две разные копии), нет unit of work и dirty checking в классическом виде. Всё держится на рефлексии, отсюда и накладные расходы, и потеря типобезопасности.
  • ent — вместо рефлексии кодогенерация: типизированные предикаты, обходы графа, eager loading через With*, хуки, privacy-политики, интеграция с Atlas для миграций. По духу это ближе к «schema-first ORM», чем к Hibernate, и на практике это плюс.
  • bun — «SQL-ориентированный ORM»: relations и модели есть, но API намеренно повторяет структуру SQL, поэтому lazy loading отсутствует по дизайну.

Формулировка для собеседования: «Полноценного в смысле JPA — нет, и это осознанный выбор экосистемы. В Go принято, чтобы SQL был виден, а маппинг — либо через рефлексию с тегами (GORM/bun), либо через кодогенерацию (ent/sqlc/sqlboiler). Из-за отсутствия рантайм-прокси lazy loading и dirty checking просто нереализуемы прозрачно, и большинство команд от этого только выигрывает: меньше скрытых запросов в базу».

  • Называть database/sql драйвером или ORM. Это абстракция над драйверами плюс пул соединений; сама она в базу говорить не умеет.
  • Говорить «мы катим миграции через AutoMigrate при старте приложения» без оговорок. Интервьюер сразу спросит про N реплик, про удаление колонок и про ревью изменений схемы — и ответить будет нечем.
  • Утверждать, что DDL в MySQL можно откатить транзакцией. Нельзя: DDL вызывает неявный commit; atomic DDL в 8.0 — про атомарность одного оператора, а не файла миграции.
  • Обещать «мы всегда откатываем миграции down-скриптом». Практика ровно обратная — чинят накатом вперёд, потому что down не возвращает удалённые данные.
  • Забывать про блокировки при ALTER на большой таблице: metadata lock в MySQL встаёт в очередь за долгой транзакцией и блокирует всё, что придёт после него. Это самый частый способ уронить прод миграцией.
  • Не знать про N+1 при Preload/eager loading и не уметь показать, как его обнаружить (логирование SQL, счётчик запросов на запрос, pg_stat_statements).
  • Считать «независимость от СУБД» реальным преимуществом ORM. Спросят пример настоящей миграции между СУБД — его не будет.
  • Говорить, что lib/pq — стандарт для PostgreSQL. Она в режиме поддержки; актуальный выбор — pgx.
  • Путать sqlx с ORM. sqlx — тонкая надстройка над database/sql (Get, Select, StructScan, NamedExec, sqlx.In), никакого маппинга связей и генерации SQL там нет.
  • Не настраивать пул: значения по умолчанию у database/sql — неограниченный MaxOpenConns и MaxIdleConns = 2, что на нагрузке даёт либо исчерпание max_connections в базе, либо постоянное пересоздание соединений.