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.
Вопросы и ответы
Заголовок раздела «Вопросы и ответы»Как на проекте работали миграции в MySQL?
Заголовок раздела «Как на проекте работали миграции в MySQL?»Коротко. Это вопрос про ваш реальный процесс, и хороший ответ — это описание конвейера: миграции лежат SQL-файлами в репозитории сервиса, версионируются таймстемпом, применяются отдельным шагом CI/CD перед выкаткой кода (в Kubernetes — init-container или отдельная Job), состояние хранится в служебной таблице (schema_migrations у golang-migrate, goose_db_version у goose), тяжёлые ALTER на больших таблицах катаются через gh-ost, схема меняется только обратно совместимо по паттерну expand/contract.
Глубже. Что интервьюер хочет услышать и из чего строить ответ:
- Где живут миграции. В репозитории того сервиса, который владеет базой (одна база — один владелец). Файлы вида
20240517103000_add_orders_status.up.sql/.down.sql. Ревьюятся в том же PR, что и код. - Кто и когда применяет. Не приложение при старте (иначе N реплик стартуют одновременно и дерутся за схему — хотя все нормальные инструменты берут блокировку), а отдельный шаг пайплайна:
migrate -path ./migrations -database "$DSN" upв job’е, которая должна завершиться до rollout’а деплоймента. - Специфика именно MySQL. DDL в MySQL вызывает неявный commit, транзакционного DDL нет (в MySQL 8.0 появился atomic DDL, но он гарантирует атомарность одного оператора на уровне словаря данных InnoDB, а не отката целого файла миграции). Следствие: файл миграции из пяти
ALTERов может упасть на третьем и оставить схему в промежуточном состоянии.golang-migrateв этом случае помечает версию какdirty, и дальнейшие миграции не применяются, пока человек не разберётся руками и не сделаетmigrate force <version>. Практический вывод, который стоит озвучить: один DDL-оператор на файл миграции. - Блокировки и онлайн-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в конце, оба умеют дросселировать по лагу реплик. - Реплики. На схему с репликацией смотрим отдельно: DDL едет по репликации и на реплике выполняется тем же временем, что и на мастере, создавая лаг. Отсюда — дросселирование и ночные окна.
- Данные. Бэкфилл больших объёмов — не миграцией, а отдельной идемпотентной джобой пачками по несколько тысяч строк с паузами, иначе получите гигантскую транзакцию, раздутый 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, всё нормально» — худший вариант ответа.
Глубже. Каркас хорошего ответа:
- Контекст. «Сервис заказов, PostgreSQL, ~40 таблиц, нагрузка ~2k RPS на чтение».
- Что использовали и почему. «GORM — потому что он уже был в кодовой базе и команда его знала» или «перешли с GORM на sqlc, потому что 30% времени ревью уходило на споры о том, какой SQL сгенерит цепочка».
- Конкретная боль и её решение — это то, что отличает опытного кандидата. Примеры реальных болей, о которых можно говорить предметно:
- 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).
- N+1 в GORM:
- Вывод. «Сейчас на новых сервисах беру 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/*.sqlvar 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 пользовался?
Заголовок раздела «Какими ORM пользовался?»Коротко. См. выше вопрос «С какими 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, и это ровно тот случай, где построитель запросов лучше строк.
Есть ли полноценный ORM в Go?
Заголовок раздела «Есть ли полноценный ORM в Go?»Коротко. Если под «полноценным» понимать Hibernate/Entity Framework с identity map, unit of work, dirty checking, lazy loading через прокси и JPQL — такого в Go нет. Ближе всего подходят GORM (по набору фич) и ent (по строгости и типобезопасности), но оба сознательно не реализуют часть классических паттернов, потому что язык не даёт для них инструментов.
Глубже. Почему именно не даёт:
- Нет наследования реализации и нет рантайм-проксирования. Lazy loading в Hibernate работает так: вместо вашего объекта подсовывается сгенерированный на лету подкласс-прокси, который при первом обращении к геттеру идёт в базу. В Go структура — это память фиксированного layout’а, подменить её нельзя, а перехватить обращение к полю невозможно в принципе (нет свойств/геттеров на уровне языка). Максимум — заставить пользователя звать метод, но это уже не «прозрачная ленивая загрузка».
- Нет байткод-инструментации. Dirty checking (автоматически понять, какие поля объекта изменились, и сгенерировать
UPDATEтолько по ним) в JVM делается инструментацией. В Go остаётся либо хранить снапшот исходной структуры и сравнивать рефлексией (дорого), либо требовать явного указания полей. - Философия языка против «магии». Идиоматичный Go предпочитает явное неявному; сообщество исторически холодно относится к рефлексивным фреймворкам, и это влияет на то, что библиотеки не идут по пути Hibernate.
- Дженерики (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в базе, либо постоянное пересоздание соединений.
Что почитать
Заголовок раздела «Что почитать»- Документация
database/sqlиTutorial: Accessing a relational database— https://pkg.go.dev/database/sql, https://go.dev/doc/tutorial/database-access jackc/pgxv5: README,pgxpool,CopyFrom, режимы выполнения запросов — https://github.com/jackc/pgxgolang-migrate/migrate(в частности FAQ проdirtyи про блокировки) — https://github.com/golang-migrate/migrate, иpressly/goose— https://github.com/pressly/goose- MySQL 8.0 Reference Manual: «Online DDL Operations» и «Atomic Data Definition Statement Support» — https://dev.mysql.com/doc/refman/8.0/en/innodb-online-ddl-operations.html
gh-ost— миграции больших таблиц MySQL без триггеров — https://github.com/github/gh-ostentgo.io(документация ent) иsqlc.dev— два основных кодогенераторных подхода в Go — https://entgo.io/docs/getting-started, https://docs.sqlc.dev