19. Базы данных в Go
«Интерфейсы позволяют скрыть сложности реализации и сосредоточиться на поведении системы».
— Брайан Керниган
Архитектура подсистемы database/sql
Пакет database/sql реализует потокобезопасный пул соединений поверх абстрактного интерфейса драйвера (database/sql/driver):
flowchart TD
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
App["Go Application Goroutines"]:::entry --> SQLDB["sql.DB (Менеджер пула соединений)"]:::accent
SQLDB --> Pool{"Свободные соединения в freeConn?"}:::process
Pool -->|"Да"| Reuse["Переиспользование существующего сокета"]:::success
Pool -->|"Нет, но < MaxOpenConns"| NewConn["Вызов driver.OpenConnector -> Новый TCP сокет"]:::accent
Pool -->|"Нет, и достигнут MaxOpenConns"| WaitQueue["Блокировка горутины в очереди ожидания"]:::process
Reuse --> Driver["Драйвер СУБД (pgx / pq / go-sql-driver/mysql)"]:::process
NewConn --> Driver
Driver --> DBMS["СУБД (PostgreSQL / MySQL / SQLite)"]:::success
Жизненный цикл соединений в пуле sql.DB
flowchart LR
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
Alloc["Создание соединения"]:::entry --> InUse["Использование (Query/Exec)"]:::accent
InUse --> Release["Освобождение (rows.Close / Exec done)"]:::process
Release --> CheckIdle{"Число idle < SetMaxIdleConns?"}:::process
CheckIdle -->|"Да"| IdlePool["Пул свободных сокетов (freeConn)"]:::success
CheckIdle -->|"Нет"| CloseConn["Немедленное закрытие TCP сокета"]:::warning
IdlePool --> Timeout{"Возраст > SetConnMaxLifetime ИЛИ простаивает > SetConnMaxIdleTime?"}:::warning
Timeout -->|"Да"| Cleanup["Фоновое закрытие и утилизация"]:::warning
Навигатор по вопросам главы
- Как работает database/sql?
- Что такое пул соединений (sql.DB)?
- Как обрабатывать ошибки (sql.ErrNoRows)?
- Как делать транзакции (sql.Tx)?
- Что такое подготовленные выражения (prepared statements)?
- Как избежать SQL-инъекций?
- Как работать с NULL значениями (sql.NullString)?
- Как выполнять SELECT ... FOR UPDATE?
- Как работает sql.Stmt и его кэширование?
- Как настроить SetMaxOpenConns, SetMaxIdleConns, SetConnMaxLifetime?
- Как работать с миграциями (golang-migrate, goose)?
- Как ORM (GORM) работает под капотом?
- Когда ORM, а когда чистый SQL?
- Как тестировать код, работающий с базой данных (testcontainers, sqlmock)?
- Как обрабатывать таймауты в database/sql?
- Как работать с context.Context и БД?
- Как реализовать unit of work паттерн?
- Как работать с sqlx?
- Как выполнять batch-вставки?
- Как работать с БД в конкурентной среде?
- Как реализовать ретраи (retry) для транзакций?
- Как pq (драйвер PostgreSQL) обрабатывает уведомления (LISTEN/NOTIFY)?
- Как работать с pgx драйвером и его преимущества?
- Как реализовать connection pool для специфичных нужд?
- Что такое timeout и deadline для запроса к БД?
- Как профилировать запросы к БД?
- Как opentelemetry и tracing интегрируются с database/sql?
- Как работать с clickhouse-go и mongo-driver?
- Как реализовать паттерн Repository?
- Как использовать sqlmock для тестирования без БД?
- Как реализовать read-write splitting (routing на реплику)?
- Как обрабатывать connection refused и другие сетевые ошибки?
- Что такое pipelining в Redis и как его реализовать в Go?
- Как работать с go-redis/redis и его кластерным клиентом?
- Как реализовать кэширование с помощью Redis в Go?
- Как реализовать распределенную блокировку с помощью Redis?
- Что такое caching patterns (cache-aside, read-through, write-through)?
- Как работать с большими бинарными данными (BLOBs) через database/sql?
- Как обрабатывать sql.ErrTxDone?
- Как database/sql управляет жизненным циклом соединения?
- Как эволюция версий database/sql влияет на написание драйверов?
- Как работать с несколькими активными *sql.Tx одновременно?
- Как настроить connection draining для graceful shutdown?
- В чем разница между Exec, Query, QueryRow с точки зрения использования соединений?
- Как database/sql работает с драйверами, которые реализуют Pinger?
- Как реализовать sharding на уровне приложения с помощью нескольких sql.DB?
- Как контролировать staleness данных при чтении с реплик (replication lag)?
- Что такое connection pool с точки зрения безопасности потоков в sql.DB?
- Как реализовать Circuit Breaker для базы данных?
Как работает database/sql?
Краткий ответ
Пакет database/sql предоставляет единый интерфейс для работы с реляционными БД. Он не содержит реализаций драйверов, а только определяет абстракции: открытие соединений через Open, выполнение запросов, транзакции, управление пулом. Драйвер (например, pgx, pq, mysql) должен реализовать интерфейс driver.Driver. sql.DB – это пул соединений, безопасный для параллельного использования, не представляет отдельное соединение. Методы Query/Exec берут соединение из пула, выполняют операцию и возвращают его обратно.
Развёрнутый ответ
Архитектура database/sql построена на разделении интерфейса и реализации. Через функцию sql.Open(driverName, dataSourceName) регистрируется драйвер (через database/sql/driver). Фактически Open возвращает *sql.DB — управляющую структуру, которая содержит пул свободных и занятых соединений, максимальное число открытых соединений, таймауты и прочее.
Методы:
Query(query, args...)- возвращает*sql.Rows, надо закрыть с помощьюrows.Close().QueryRow(query, args...)- выполняет запрос, ожидающий ровно одну строку, возвращает*sql.Row.Exec(query, args...)- для запросов без возврата строк (INSERT/UPDATE/DELETE), возвращаетsql.Result.
Пул управляется с помощью SetMaxOpenConns, SetMaxIdleConns, SetConnMaxLifetime. По умолчанию MaxOpenConns = 0 (без ограничений), что может привести к исчерпанию ресурсов. Все методы безопасны для одновременных горутин, извлечение соединения из пула и его освобождение защищено мьютексом.
Под капотом (internals)
sql.DBхранитfreeConn(свободные соединения) в связном списке, защищённомmu.connRequests— очередь каналов для горутин, ожидающих соединение.- Драйверы реализуют интерфейс
driver.Conn,driver.Connector,driver.Stmtи др. С версии Go 1.10 появилась поддержкаdriver.Connector, позволяющая передавать контекст при установлении соединения. sql.DB.conn(): при вызове метода сначала проверяются свободные соединения; если нет и число открытых меньше MaxOpen, запускаетсяopenNewConnection. Иначе горутина ставится в очередьconnRequestsи может ждать до истечения контекста.- Все операции над пулом (
putConn,conn) выполняются атомарно с учётомmaxLifetime. Периодически запускается фоновый cleaner, удаляющий просроченные idle-соединения.
Типичные ошибки кандидатов
- Неограниченное число открытых соединений
Объяснение: Без вызоваSetMaxOpenConnsпри высокой нагрузке может открыться слишком много соединений к БД, что приведёт к ошибке "too many clients". Исправление: всегда задавайте лимиты:db.SetMaxOpenConns(25). - Утечка строк (
sql.Rows)
Объяснение: Если забыть вызватьrows.Close(), соединение не будет возвращено в пул. Это быстро истощает пул и блокирует другие запросы. Исправление: используйтеdefer rows.Close()сразу после проверки ошибки. - Использование
QueryRowбез обработкиScan
Объяснение:QueryRowне возвращает ошибку немедленно; ошибка возникает приScan. Если не проверить ошибку послеScan, можно пропуститьsql.ErrNoRows. Исправление: всегда обрабатывайтеerr := row.Scan(&...).
Возможные дополнительные вопросы интервьюера
Как database/sql поддерживает контексты?
Начиная с Go 1.8, методы QueryContext, ExecContext, BeginTx принимают контекст. Это позволяет отменять долгие запросы и пробрасывать дедлайны через драйвер, если он поддерживает context.Context (например, pgx). При отмене контекста прерывается ожидание в очереди пула и выполнение самого запроса на стороне БД (через CancelRequest).
Можно ли использовать sql.DB в тестах без мокирования?
Да, например, с помощью testcontainers-go или временного докер-контейнера. Это даёт реальное окружение, но требует чистки данных. Альтернатива – sqlmock для изоляции без реальной БД.
Что такое пул соединений (sql.DB)?
Краткий ответ
sql.DB — это не отдельное соединение, а пул (набор) соединений с базой данных. Он хранит открытые (возможно, несколько) TCP-соединения, управляет их переиспользованием, максимальным количеством, временем жизни и ожиданием при отсутствии свободных. Потокобезопасен. Предназначен для долгоживущего использования (например, глобальная переменная).
Развёрнутый ответ
Пул минимизирует накладные расходы на установку нового соединения (TCP-рукопожатие, TLS, аутентификация) за счёт сохранения открытых idle-соединений. Основные параметры пула:
MaxOpenConns— максимальное число одновременно открытых соединений (включая занятые и свободные).0= без ограничений.MaxIdleConns— сколько неиспользуемых соединений сохранять в пуле. По умолчанию равно 2.ConnMaxLifetime— максимальное время жизни соединения с момента создания, по истечении которого оно закрывается.ConnMaxIdleTime— сколько соединение может оставаться в idle-пуле до закрытия.
Алгоритм выдачи соединения:
- Если есть свободное годное соединение — сразу отдать.
- Иначе если текущее число открытых соединений <
MaxOpenConns— открыть новое. - Иначе ожидать, пока другая горутина вернёт соединение в пул, с учётом таймаута контекста.
Под капотом
type DB struct {
mu sync.Mutex
freeConn []*driverConn
connRequests map[uint64]chan connRequest
numOpen int
// ...
}
При возвращении (putConn) соединение помещается в freeConn и проверяется очередь ожидающих. Закрытие состарившихся соединений делает фоновый cleaner (раз в минуту). Cleaner также реагирует на ConnMaxLifetime и ConnMaxIdleTime, закрывая соединения, которые вышли за лимит.
Типичные ошибки кандидатов
- Создание нового
sql.DBна каждый запрос
Объяснение: Это порождает отдельный пул и лавину соединений. Исправление: создавайте глобальный*sql.DB, один экземпляр на весь процесс. - Неправильная настройка
MaxIdleConns= 0
Объяснение: Если не сохранять idle-соединения, каждый запрос будет открывать новое, теряя преимущество пула. Исправление: устанавливайтеMaxIdleConnsпримерно равнымMaxOpenConnsили хотя бы 10. - Игнорирование
ConnMaxLifetime
Объяснение: Без этого соединения могут жить вечно, и при обрыве со стороны БД/прокси они станут "мёртвыми". Это вызывает ошибки при первом использовании. Исправление: задайтеSetConnMaxLifetime(5 * time.Minute).
Возможные дополнительные вопросы интервьюера
Как пул обрабатывает таймауты и ошибки соединения?
При неудачном запросе драйвер может пометить соединение как "плохое" (метод driver.ErrBadConn). database/sql не возвращает плохое соединение в пул, а закрывает его и создаёт новое. Это прозрачно для пользователя.
Что произойдёт при превышении MaxOpenConns?
Горутина будет заблокирована в ожидании свободного соединения до истечения контекста запроса. Если контекст отменён, возвращается ошибка ctx.Err().
Как обрабатывать ошибки (sql.ErrNoRows)?
Краткий ответ
sql.ErrNoRows — ожидаемая ошибка, означающая, что QueryRow или QueryRowContext не вернули ни одной строки. Её нужно проверять явно: if errors.Is(err, sql.ErrNoRows) { ... }. Ошибку возвращает Scan(), а не QueryRow. Игнорирование приводит к панике или логическим ошибкам. Отличайте отсутствие строк от других ошибок выполнения запроса.
Развёрнутый ответ
Идиоматический паттерн:
var name string
err := db.QueryRowContext(ctx, "SELECT name FROM users WHERE id=$1", id).Scan(&name)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
// запись не найдена – возможно, это ожидаемый случай
return "", nil
}
return "", fmt.Errorf("query failed: %w", err)
}
Для Query (rows.Next()) sql.ErrNoRows не возникает, просто rows.Next() вернёт false.
sql.ErrNoRows – это sentinel-ошибка, определённая на уровне пакета. Сравнение через == допустимо, но с errors.Is универсальнее (на случай обёрток). Если запрос должен обязательно вернуть ровно одну строку (например, по первичному ключу), отсутствие строки – исключительная ситуация, и её стоит возвращать как ошибку.
Под капотом
Внутри sql.Row.Scan вызывает row.ctx.driverConn и использует низкоуровневый драйвер. Если драйвер вернул пустой набор строк, database/sql проверяет количество рядов: если 0, генерируется sql.ErrNoRows. Для sql.Rows ошибка ErrNoRows не сохраняется, используется счётчик.
Типичные ошибки кандидатов
- Не проверять
errпослеScanвообще
Объяснение: Если забыть проверить ошибку, переменная останется нулевым значением, что может привести к молчаливому некорректному поведению. Всегда обрабатывайте возвратScan. - Спутывать
sql.ErrNoRowsс ошибкой подключения
Объяснение: Например,pqможет вернутьdriver.ErrBadConn, что не равноsql.ErrNoRows. Нужно различать временные сетевые ошибки и отсутствие данных, повторяя запрос при необходимости.
Возможные дополнительные вопросы интервьюера
Как обработать случай, когда допустимо отсутствие строки и нужно вернуть указатель nil?
var found *model.User
err := row.Scan(&found.ID, ...)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
Почему QueryRow не возвращает ошибку сразу, а только при Scan?
Это сделано для удобства цепочки вызовов: db.QueryRow(...).Scan(...). Отложенная ошибка позволяет писать компактный код, но требует дисциплины проверки.
Как делать транзакции (sql.Tx)?
Краткий ответ
Транзакции начинаются вызовом db.BeginTx(ctx, opts) или db.Begin(). Возвращается *sql.Tx, на котором можно выполнять запросы теми же методами Exec, Query. Для фиксации вызывается tx.Commit(), для отката — tx.Rollback(). Важно всегда вызывать Rollback() через defer и игнорировать ошибку sql.ErrTxDone после успешного Commit. Контекст управляет таймаутом всей транзакции.
Развёрнутый ответ
Пример безопасной транзакции:
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() // no-op после Commit
_, err = tx.ExecContext(ctx, "UPDATE accounts SET balance = balance - $1 WHERE id=$2", amount, fromID)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx, "UPDATE accounts SET balance = balance + $1 WHERE id=$2", amount, toID)
if err != nil {
return err
}
return tx.Commit()
defer tx.Rollback() безопасен: если транзакция закоммичена, Rollback вернёт sql.ErrTxDone, которую можно игнорировать. Контекст, переданный в BeginTx, продолжает действовать на все последующие запросы. Можно также использовать *sql.Tx внутри того же контекста с методами QueryRowContext и т.п. При использовании BeginTx с опциями можно задать уровень изоляции (для PostgreSQL через sql.TxOptions{Isolation: sql.LevelSerializable}).
Под капотом
BeginTx берёт соединение из пула и оставляет его за транзакцией до вызова Commit/Rollback. Все запросы в рамках sql.Tx используют это единственное соединение. После завершения транзакции соединение возвращается в пул. Если контекст отменён во время транзакции, выполняется откат.
Типичные ошибки кандидатов
- Забытый
Rollbackпри ошибке
Объяснение: Если не вызватьRollbackдо выхода из функции, транзакция может висеть открытой до таймаута, удерживая блокировки. Используйтеdefer tx.Rollback(). - Повторное использование
*sql.Txпосле завершения
Объяснение: ПослеCommitилиRollbackобъект*sql.Txстановится недействительным; дальнейшие вызовы вернутsql.ErrTxDone. Необходимо создавать новую транзакцию. - Использование
Beginбез контекста
Объяснение: СтарыйBegin()не позволяет задать таймаут или отмену. ПредпочтительнееBeginTx(ctx, nil).
Возможные дополнительные вопросы интервьюера
Как реализовать вложенные транзакции в Go?
Стандартный sql.Tx не поддерживает вложенность. Можно эмулировать через savepoints: выполнить SAVEPOINT sp1 в сыром SQL и откатываться к ним. Также можно написать обёртку, управляющую savepoint-ами.
Что будет, если соединение разорвётся во время транзакции?
Следующий запрос вернёт driver.ErrBadConn, database/sql пометит соединение недействительным и попытается переоткрыть. Но поскольку транзакция привязана к конкретному соединению, произойдет откат и *sql.Tx станет бесполезным; вызывающая сторона получит ошибку, и транзакцию надо повторить.
Что такое подготовленные выражения (prepared statements)?
Краткий ответ
Подготовленное выражение — это предварительно скомпилированный на сервере БД SQL-запрос с заполнителями ($1, ?, :name). Пакет database/sql позволяет создать *sql.Stmt через db.Prepare, затем многократно выполнять его с разными параметрами. Это ускоряет выполнение одинаковых запросов и предотвращает SQL-инъекции за счёт разделения кода и данных. sql.DB также кэширует автоматически подготовленные запросы.
Развёрнутый ответ
stmt, err := db.Prepare("INSERT INTO users(name, email) VALUES($1, $2)")
if err != nil { ... }
defer stmt.Close()
exec, err := stmt.ExecContext(ctx, "Alice", "alice@example.com")
Каждое выполнение повторно использует скомпилированный план, что снижает накладные расходы. Подготовленные выражения привязаны к соединению; в Go они могут быть переиспользованы для разных соединений благодаря механизму драйвера или повторной подготовке на лету. Драйвер должен поддерживать Prepare. Некоторые драйверы (например, pgx по умолчанию) используют протокол, в котором подготовленное выражение создаётся на сервере автоматически.
Под капотом
При вызове db.Prepare:
- Из пула берётся соединение.
- На нём вызывается
driver.Conn.Prepare, который отправляет на серверPREPARE. - Возвращается
*sql.Stmt, хранящий связь с драйвернымdriver.Stmt.
При выполненииstmt.Execпул выбирает свободное соединение. Если это то же самое соединение, что и при подготовке, используется существующий серверный statement. Если другое — драйвер либо использует кэшированное описание, либо повторно вызываетPrepareна новом соединении. В Go 1.10 появилась поддержкаdriver.Connector, позволяющая кэшировать дескриптор подготовленного выражения на стороне клиента.
Типичные ошибки кандидатов
- Забытый
stmt.Close()
Объяснение: Не закрытыйStmtможет удерживать ресурсы сервера. Используйтеdefer stmt.Close(). - Никогда не использовать подготовленные выражения
Объяснение: Для простых единичных запросов преимущество мало, но при повторяющихся операциях (например, пакетных вставках) заметно ускоряет работу. В то же время слишком частая подготовка может создать множество серверных объектов. - Неправильное кэширование: полагаться на дефолтное кэширование
db.Query
Объяснение:sql.DBподдерживает кэш подготовленных запросов дляQuery/Exec, но управляется неявно. При серьёзной нагрузке лучше явно управлятьPrepare.
Возможные дополнительные вопросы интервьюера
В чём разница между подготовленным выражением на уровне БД и параметризованным запросом?
Параметризованный запрос (например, db.Query("SELECT ... WHERE id=$1", id)) драйвер может реализовать без настоящего серверного PREPARE, используя протокольное связывание параметров на лету (extended query в PostgreSQL). Это даёт защиту от инъекций, но план может компилироваться каждый раз.
Когда подготовленные выражения могут быть вредны?
При очень динамических запросах с разными структурами они порождают много серверных объектов, засоряя память БД. Также некоторые БД (MySQL) могут замедлить выполнение, если план устарел после изменения статистики. В таких случаях лучше использовать немедленное выполнение.
Как избежать SQL-инъекций?
Краткий ответ
Используйте параметризованные запросы (плейсхолдеры) — НИКОГДА не конкатенируйте строки с пользовательским вводом. Пакет database/sql с драйверами гарантирует экранирование параметров через $1, ?. Все динамические идентификаторы (имена таблиц/колонок) необходимо валидировать по белому списку. Для LIKE-запросов экранируйте спецсимволы, но данные передавайте через параметр. Сторонние билдеры запросов (squirrel, dbr) также должны генерировать параметризованный SQL.
Развёрнутый ответ
Пример уязвимого кода: fmt.Sprintf("SELECT * FROM users WHERE name = '%s'", input) — злоумышленник передаст ' OR 1=1 --. Правильно:
db.Query("SELECT * FROM users WHERE name = $1", input)
Для динамической сортировки:
allowedColumns := map[string]bool{"id": true, "name": true}
if !allowedColumns[orderBy] {
return errors.New("invalid column")
}
query := fmt.Sprintf("SELECT * FROM users ORDER BY %s", orderBy) // безопасно после валидации
Клиентские библиотеки типа sqlx поддерживают именованные параметры, которые безопасны.
Под капотом
Драйвер (например, pq для PostgreSQL) преобразует $1 в PREPARE ... EXECUTE с раздельной передачей параметров. Параметры сериализуются в бинарном протоколе, поэтому инъекция невозможна: данные никогда не интерпретируются как часть SQL-команды.
Типичные ошибки кандидатов
- Конкатенация для оператора IN
Объяснение:query := fmt.Sprintf("SELECT ... WHERE id IN (%s)", strings.Join(ids, ","))— еслиidsне проверены, может быть инъекция. Используйте параметризованный IN черезANYв PostgreSQL или библиотеки-expand. - Динамическое формирование имён таблиц без белого списка
Объяснение: Даже с параметрами имена таблиц нельзя параметризовать. Только валидация по заранее заданному списку.
Возможные дополнительные вопросы интервьюера
Как предотвратить second-order injection?
Всегда экранируйте данные, даже если они пришли из другой части вашей системы. Не доверяйте данным из базы при построении запросов — также используйте параметры.
Есть ли ситуации, когда конкатенация неизбежна?
Только для DDL-запросов (нехарактерно для прикладного кода). Если необходимо, нужно жёстко контролировать ввод, используя заранее определённые константы и белый список.
Как работать с NULL значениями (sql.NullString)?
Краткий ответ
Go не имеет встроенного представления NULL, поэтому database/sql предоставляет типы: sql.NullString, sql.NullInt64, sql.NullFloat64, sql.NullBool, sql.NullTime. Каждый содержит поле Valid bool и значение. При сканировании, если столбец NULL, Valid будет false, а значение — нулевое. Также можно использовать указатели на базовые типы: *string — NULL превратится в nil, но это менее удобно для различения отсутствия и нулевого значения.
Развёрнутый ответ
var name sql.NullString
err := db.QueryRow("SELECT name FROM users WHERE id=$1", id).Scan(&name)
if name.Valid {
fmt.Println(name.String)
} else {
fmt.Println("name is NULL")
}
Альтернатива со сканированием в указатель: var name *string; row.Scan(&name). Преимущество NullString в том, что оно может быть использовано в моделях предметной области и автоматически маппиться через ORM. Проблема: при использовании NullString в JSON-сериализации нужно писать кастомный MarshalJSON, чтобы не выводить лишние поля.
Под капотом
Драйвер возвращает значение и индикатор NULL. database/sql вызывает Nullable методы. Scan для NullString реализует интерфейс Scanner, анализирует флаг и присваивает Valid/String.
Типичные ошибки кандидатов
- Использование
*stringбез проверки на nil
Объяснение: Если столбец может быть NULL, указатель будет nil, обращение к*nameприведёт к панике. Нужно явно проверять. - Неправильная сериализация
sql.NullStringв JSON
Объяснение: По умолчаниюencoding/jsonвыводит{"String": "...", "Valid": true}, что не соответствует ожидаемой модели. Нужно реализоватьMarshalJSON/UnmarshalJSON, либо использовать кастомный тип, внедряющийNullString.
Возможные дополнительные вопросы интервьюера
Как обрабатывать NULL в пакетных вставках?
Использовать тот же sql.NullString в структурах, передаваемых через sqlx или ORM. Также можно использовать interface{} и специальную логику, но это опасно.
Что насчёт sql.NullTime и временных поясов?
Драйвер возвращает время в UTC, NullTime.Time хранит time.Time. Проблем с часовыми поясами нет, но при выводе следует учитывать локаль.
Как выполнять SELECT ... FOR UPDATE?
Краткий ответ
Чтобы заблокировать строки для последующего изменения, используйте запрос с SELECT ... FOR UPDATE. В Go это делается через tx.QueryContext(ctx, "SELECT ... FROM table WHERE id = $1 FOR UPDATE", id). Такой запрос должен выполняться в транзакции с уровнем изоляции, который поддерживает блокировку строк (Read Committed и выше). После получения строки нужно выполнить UPDATE и COMMIT в той же транзакции. sql.Tx автоматически удерживает блокировку до фиксации/отката.
Развёрнутый ответ
tx, _ := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
defer tx.Rollback()
var balance float64
err := tx.QueryRowContext(ctx, `SELECT balance FROM accounts WHERE id=$1 FOR UPDATE`, id).Scan(&balance)
if err != nil {
return err
}
// бизнес-логика
_, err = tx.ExecContext(ctx, `UPDATE accounts SET balance = $1 WHERE id=$2`, newBalance, id)
if err != nil {
return err
}
return tx.Commit()
Важно использовать то же соединение (через *sql.Tx), иначе блокировка не будет удерживаться. Не выполняйте длительную бизнес-логику между SELECT FOR UPDATE и COMMIT, чтобы не держать блокировки.
Под капотом
На уровне драйвера FOR UPDATE — это обычный запрос, блокировка поддерживается сервером. *sql.Tx не освобождает соединение до завершения транзакции, так что сессия БД сохраняет блокировку. При отмене контекста сервер откатит транзакцию и снимет блокировку.
Типичные ошибки кандидатов
- Выполнение SELECT FOR UPDATE вне транзакции
Объяснение: В некоторых БД это допустимо, но блокировка держится только на время чтения, что не гарантирует атомарность. Всегда выполняйте в явной транзакции. - Долгое удержание блокировки из-за внешних вызовов
Объяснение: Сетевой запрос между выборкой и изменением может вызвать длительную блокировку и дедлоки. Минимизируйте логику внутри транзакционного блока.
Возможные дополнительные вопросы интервьюера
Что делать, если строка уже заблокирована другой транзакцией?
Запрос будет ожидать освобождения блокировки или истечения таймаута. В pgx можно установить statement_timeout через контекст. При дедлоке БД вернёт ошибку, которую нужно обработать и повторить транзакцию.
Как реализовать оптимистичную блокировку в Go?
Используйте поле версии в таблице. При обновлении: UPDATE ... SET version=version+1 WHERE version=$currentVersion. Если версия не совпадает — обновление затронуло 0 строк, значит конфликт.
Как работает sql.Stmt и его кэширование?
Краткий ответ
sql.Stmt — представление подготовленного выражения, связанного с пулом. При создании через db.Prepare драйвер создаёт серверный prepared statement. При выполнении Stmt.Exec/Query пул ищет свободное соединение и либо использует уже существующий серверный statement на этом соединении, либо (если соединение новое) заново подготавливает и кэширует. sql.DB также внутренне кэширует подготовленные запросы для методов Query/Exec, используя conn.Stmt.
Развёрнутый ответ
stmt, _ := db.Prepare("SELECT name FROM users WHERE id=$1")
defer stmt.Close()
// Первый вызов — берёт соединение, проверяет, есть ли готовый statement
var name string
stmt.QueryRowContext(ctx, 1).Scan(&name)
Автоматическое кэширование db.Query-x работает так: при каждом вызове Query пакет ищет в кэше ранее подготовленное выражение для данной строки запроса на этом соединении. Если найдено — использует его без повторной подготовки. Это ускоряет частые запросы. Но автоматический кэш может устаревать при изменении структуры таблиц; Stmt следует явно закрывать для освобождения ресурсов.
Под капотом
sql.DB хранит map[string]*driverConn кэша запросов? На самом деле кэш находится на уровне соединения: driverConn имеет map[string]driver.Stmt. При query проверяется conn.stmtCache[query]. Если нет, происходит подготовка на этом соединении и сохранение. При закрытии соединения все его statement-ы освобождаются. Пакет также отслеживает, не был ли закрыт пользовательский *sql.Stmt, чтобы не использовать устаревшее.
Типичные ошибки кандидатов
- Явный
Prepareдля каждого вызова
Объяснение: СоздаватьStmt, вызыватьExec, и тут жеClose— теряется смысл. Создавайте один раз и переиспользуйте. - Игнорирование освобождения ресурсов БД
Объяснение: Не закрытыйStmtможет расходовать память на сервере (особенно в PostgreSQL). После длительного использования обязательноClose. - Предположение, что
Stmtпривязан к конкретному соединению
Объяснение:Stmtбезопасен для конкурентного использования и может менять соединения, но это добавляет скрытые затраты на повторную подготовку.
Возможные дополнительные вопросы интервьюера
Как Stmt взаимодействует с транзакциями?
Внутри транзакции можно создать tx.Prepare, который подготовит statement в контексте конкретной транзакции. После завершения транзакции этот Stmt нельзя использовать.
Почему в логах PostgreSQL видно много PREPARE запросов?
Это нормально, когда пул соединений меняет соединения для Stmt или используется автоматическое кэширование без единого Prepare. Можно оптимизировать, явно подготавливая на общем sql.DB до начала работы.
Как настроить SetMaxOpenConns, SetMaxIdleConns, SetConnMaxLifetime?
Краткий ответ
SetMaxOpenConns(n) ограничивает суммарное количество открытых соединений. SetMaxIdleConns(m) указывает, сколько простаивающих соединений сохранять; обычно равно или чуть меньше MaxOpen. SetConnMaxLifetime(d) задаёт максимальное время жизни соединения с момента создания. Настройки следует применять сразу после sql.Open. Типовые значения для веб-сервиса: MaxOpen=25..100, MaxIdle=25, ConnMaxLifetime=5мин. Все методы потокобезопасны.
Развёрнутый ответ
db, _ := sql.Open("pgx", dsn)
db.SetMaxOpenConns(50)
db.SetMaxIdleConns(50)
db.SetConnMaxLifetime(5 * time.Minute)
db.SetConnMaxIdleTime(1 * time.Minute) // Go 1.15+
MaxOpenConns= 0 означает неограниченное число соединений – опасная практика.MaxIdleConnsпо умолчанию 2. Если соединение становится idle, а число idle превышает лимит, оно закрывается.ConnMaxLifetimeпомогает ротировать соединения, что полезно при балансировке нагрузки или принудительном обновлении DNS.ConnMaxIdleTime— новое в Go 1.15, закрывает соединения, которые долго не использовались.
Под капотом
Функции просто устанавливают поля *DB под мьютексом. Внутренний cleaner-горутина (один на DB) каждые 30 секунд проходит по свободным соединениям и закрывает те, что превысили лимиты. При этом проверяется также общее количество idle.
Типичные ошибки кандидатов
- MaxIdleConns > MaxOpenConns
Объяснение: Система не сможет создать больше MaxOpen, значит лишние idle-места никогда не заполнятся, но ошибки не будет. Лучше устанавливать MaxIdle ≈ MaxOpen или чуть меньше. - Очень большой ConnMaxLifetime (0 — без ограничения)
Объяснение: Соединения могут стать недоступными из-за таймаутов на сетевых устройствах, а пул будет пытаться их использовать, что приведёт к ошибкам. Рекомендуется ограничить время жизни. - Вызов настроек после начала использования
Объяснение: Изменение лимитов на лету допустимо, но если уже открыто больше соединений, лишние не закроются мгновенно (только после возврата в пул). Лучше конфигурировать до использования.
Возможные дополнительные вопросы интервьюера
Как правильно подобрать MaxOpenConns?
Исходите из ресурсов БД и числа параллельных запросов. Для типового REST API с 4-ядерным сервером 25–50 соединений часто достаточно. Мониторьте количество ожидающих (DB.Stats()).
Зачем нужен SetConnMaxIdleTime?
Чтобы избежать накопления idle-соединений при резких спадах нагрузки, освобождая ресурсы БД. Без него idle-соединения могут жить вечно, даже при низком MaxIdle.
Как работать с миграциями (golang-migrate, goose)?
Краткий ответ
Миграции управляют версионностью схемы БД. Инструменты golang-migrate/migrate и pressly/goose поддерживают последовательное применение версий (вверх/вниз). Миграции пишутся в SQL-файлах (чистый SQL) и накатываются с помощью CLI или программно. Оба инструмента сохраняют состояние в таблице schema_migrations или goose_db_version. Выполнение миграций блокируется (locking) для предотвращения конкурентного наката.
Развёрнутый ответ
Пример с golang-migrate:
m, err := migrate.New("file://migrations", "postgres://localhost/db?sslmode=disable")
m.Up()
Файлы миграций: 1_create_users.up.sql, 1_create_users.down.sql.
goose можно внедрить в приложение:
goose.Up(db.DB, "migrations")
Миграции фиксируются внутри транзакции, если драйвер поддерживает. В goose можно писать миграции на Go (функции goose.Go), что позволяет сложную логику переноса данных.
Под капотом
golang-migrate использует БД-транзакцию для применения одного файла миграции. При старте проверяет текущую версию из таблицы-состояния и применяет отсутствующие. Захватывает advisory lock (PostgreSQL) для синхронизации. goose по умолчанию выполняет каждый шаг вне транзакции (для совместимости с DDL), но можно включить транзакционность через параметры.
Типичные ошибки кандидатов
- Накат миграций вручную в обход инструментов
Объяснение: Теряется история, невозможен откат. Все изменения должны быть в системе миграций. - Отсутствие блокировки при параллельном старте
Объяснение: Несколько экземпляров приложения могут одновременно попытаться накатить миграции, что приведёт к конфликтам. Нужен глобальный локи (advisory lock). - Несогласованные имена файлов
Объяснение: Оба инструмента сортируют файлы лексикографически. Типичная проблема — пропуски номеров или повтор версий.
Возможные дополнительные вопросы интервьюера
Как автоматизировать прогон миграций в CI/CD?
Добавить шаг, вызывающий утилиту миграции против временной БД, или использовать контейнер с последующей очисткой. Лучше подготавливать контейнер с тестовой БД, накатывать миграции и проверять результат.
Как откатить миграцию на production?
Осторожно: migrate down удаляет столбцы/таблицы, что может привести к потере данных. Обычно предпочтительнее накатить новую миграцию, восстанавливающую старую схему с сохранением данных.
Как ORM (GORM) работает под капотом?
Краткий ответ
GORM — популярная ORM, надстраивающаяся над database/sql. В основе лежит *gorm.DB, который оборачивает *sql.DB и добавляет слой построения запросов, маппинг структур в строки, управление транзакциями, хуки, ассоциации. Ключевые компоненты: Statement, содержащий SQL-строитель (clause), Schema для метаданных модели, Callbacks — цепочки обработки перед CRUD. GORM использует экранирование параметров через placeholder для предотвращения инъекций, но динамические части (имена таблиц) надо контролировать.
Развёрнутый ответ
db.Where("id = ?", id).First(&user)
GORM преобразует это в SELECT * FROM users WHERE id = ? LIMIT 1, применяя параметры через драйвер. Внутри вызывается db.Session(&Session{...}), конструируется Stmt, затем выполняется через db.ConnPool (пул транзакций). Поддерживаются хуки BeforeCreate, AfterFind; они регистрируются в глобальной мапе callbacks. Миграции (AutoMigrate) генерируют DDL на основе описания моделей.
Версия v2 использует Statement.ReflectValue для заполнения полей через рефлексию, что добавляет накладные расходы, но удобно. GORM также реализует ленивую загрузку ассоциаций (через отдельные запросы).
Под капотом
gorm.Openсоздаёт пул диалектов разных БД; при вызовеFindстроитсяclause.Clauseиз частей (SELECT,FROM,WHERE,LIMIT).- Цепочка вызовов строит
*gorm.DB, хранящую ошибку. Выполнение происходит при вызове методовFind,Createи т.п. - GORM переопределяет интерфейс
ConnPoolдля поддержки транзакций: если активна транзакция, запросы направляются в*sql.Tx, иначе в*sql.DB.
Типичные ошибки кандидатов
- N+1 запрос для ассоциаций
Объяснение: По умолчаниюPreloadне используется, что ведёт к отдельному запросу на каждую запись. Используйтеdb.Preload("Orders").Find(&users). - Игнорирование ошибок db.Statement
Объяснение: После цепочкиWhere, которая может накопить ошибку, результат вызоваFindпродолжает выполняться, но может вернуть неверный результат. Всегда проверяйтеdb.Error. - Массовое обновление через
Saveс пустыми полями
Объяснение:Saveобновляет все поля, даже если они не были установлены, перезаписывая нулевыми значениями. ИспользуйтеUpdatesс картой или селективными полями.
Возможные дополнительные вопросы интервьюера
Как GORM управляет транзакциями?
Можно использовать db.Transaction(func(tx *gorm.DB) error {...}) — внутри транзакции создаётся новый экземпляр *gorm.DB, использующий *sql.Tx. Автоматический rollback/commit. Также есть ручной db.Begin().
Есть ли смысл использовать GORM на высоконагруженных системах?
При неоптимальном использовании — нет. Но с предзагрузкой, батчингом (CreateInBatches), нативными запросами и отключением хуков можно достичь производительности, близкой к чистому sqlx.
Когда ORM, а когда чистый SQL?
Краткий ответ
ORM удобен для быстрой разработки CRUD-приложений, автоматического маппинга, миграций. Чистый SQL (database/sql или sqlx) даёт полный контроль над запросами, лучшую производительность, предсказуемое поведение БД. Выбор зависит от сложности запросов, требований к производительности, опыта команды. При сложных аналитических запросах, тонкой настройке блокировок и высокой нагрузке предпочтителен чистый SQL. При множестве простых сущностей — ORM.
Развёрнутый ответ
Использовать ORM, когда:
- проект начинается быстро, прототипирование.
- много стандартных операций CRUD, простые связи.
- команда недостаточно знает SQL.
- нужно абстрагироваться от конкретной СУБД.
Использовать чистый SQL, когда:
- запросы сложные, с оконными функциями, рекурсивными CTE.
- нужна точная оптимизация (индексы, планировщик).
- требуется минимум накладных расходов — высокая нагрузка.
- используется специфичная функциональность конкретной БД.
- важна прозрачность происходящего (легче отлаживать).
Гибридный подход: sqlx + самописные маппинги даёт удобство работы со структурами без магии ORM. Я часто рекомендую sqlx для большинства проектов, переходя к ORM только для админок и простых микросервисов.
Под капотом
ORM добавляет уровень рефлексии, построения запросов, хуков — это замедляет выполнение. Но компилятор запросов GORM может кэшировать шаблоны. Чистый SQL использует прямое выполнение через драйвер, без оверхеда. Однако разработка типовой логики (вставка/обновление нескольких таблиц) требует больше кода.
Типичные ошибки кандидатов
- Использование ORM везде без анализа
Объяснение: ORM не подходит для отчётов с JOIN многих таблиц и агрегациями, приводя к неэффективным запросам и огромному потреблению памяти. - Полное отрицание ORM
Объяснение: Отказ от ORM в простых проектах увеличивает время разработки boilerplate-кода. Разумный баланс снижает затраты.
Возможные дополнительные вопросы интервьюера
Как перейти с ORM на чистый SQL в существующем проекте?
Постепенно: обернуть старый код репозиториями, новые методы реализовывать на чистом SQL, заменяя вызовы ORM по мере необходимости.
Использовали бы вы ORM в высоконагруженном микросервисе с 50k RPS?
Скорее нет, но если ORM позволяет выполнять батч-операции и нативные запросы без генерации кода, возможно для простых частей. Решающим фактором является бенчмаркинг конкретного решения.
Как тестировать код, работающий с базой данных (testcontainers, sqlmock)?
Краткий ответ
Два основных подхода: интеграционные тесты с реальной БД через Docker-контейнеры (testcontainers-go) и модульные тесты-заглушки с помощью sqlmock для изоляции от БД. Первый вариант даёт полную уверенность, но медленнее; второй — быстрый и изолированный, но требует мокинга всех SQL-запросов. Комбинация позволяет покрыть разные слои.
Развёрнутый ответ
testcontainers:
container, _ := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: "postgres:14-alpine",
ExposedPorts: []string{"5432/tcp"},
Env: map[string]string{"POSTGRES_PASSWORD": "pass"},
WaitingFor: wait.ForLog("database system is ready"),
},
Started: true,
})
// получаем хост/порт, создаём *sql.DB
defer container.Terminate(ctx)
Позволяет проверить реальное поведение, включая миграции и особенности драйвера.
sqlmock:
db, mock, _ := sqlmock.New()
defer db.Close()
mock.ExpectQuery("SELECT (.+) FROM users WHERE id=\\$1").WithArgs(1).
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Alice"))
// альтернативный вариант
var name string
err := db.QueryRow("SELECT name FROM users WHERE id=$1", 1).Scan(&name)
assert.NoError(t, err)
assert.NoError(t, mock.ExpectationsWereMet())
Отлично для юнит-тестов логики обработки ошибок, но не тестирует взаимодействие с БД.
Под капотом
sqlmock реализует интерфейс sql/driver, перехватывая все вызовы. Использует регулярные выражения для сопоставления запросов, что может быть громоздко. testcontainers запускает реальный контейнер через API Docker, предоставляя параметры подключения.
Типичные ошибки кандидатов
- Тестирование только с sqlmock на критической логике
Объяснение: Не проверяются реальные SQL-синтаксис и поведение; можно упустить несовместимость с версией БД. Хотя бы один интеграционный тест с реальной БД обязателен. - Сложные регулярки в sqlmock, не учитывающие форматирование SQL
Объяснение: Пробелы, переносы строк приводят к несрабатыванию ожиданий. Используйтеregexp.QuoteMetaили допускайте произвольные пробелы. - Забытый
defer container.Terminate
Объяснение: Утечка контейнеров расходует ресурсы CI. Всегда завершайте контейнер.
Возможные дополнительные вопросы интервьюера
Как протестировать миграции?
Используйте testcontainers, запустите контейнер с чистой БД, накатите миграции и проверьте схему (например, через information_schema).
Как замокать sql.DB без sqlmock?
Можно создать интерфейс с методами Query, Exec и подменить реализацию. Но database/sql не спроектирован для простого мокирования на уровне DB, проще обернуть в свой тип.
Как обрабатывать таймауты в database/sql?
Краткий ответ
Таймауты устанавливаются через переданный контекст с дедлайном: db.QueryContext(ctx, ...), tx.ExecContext(ctx, ...). Если контекст отменён, database/sql отменяет ожидание в пуле соединений и, если драйвер поддерживает, посылает серверу сигнал отмены. Кроме того, можно настроить statement_timeout на уровне сессии через параметры подключения. Главный инструмент — context.WithTimeout.
Развёрнутый ответ
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
rows, err := db.QueryContext(ctx, "SELECT pg_sleep(5)")
// err будет содержать context.DeadlineExceeded, если запрос занимает >2с.
Для тонкой настройки в PostgreSQL драйвер pgx поддерживает опцию statement_timeout в строке подключения: postgres://user:pass@host/db?statement_timeout=2000. Важно различать таймаут на уровне соединения (dial) и таймаут выполнения запроса.
Под капотом
При вызове QueryContext контекст сохраняется. Внутренняя функция db.conn проверяет ctx.Done(), и в случае отмены возвращает ctx.Err() сразу из очереди ожидания. Если соединение уже выделено, драйвер через метод driver.StmtExecContext (с поддержкой контекста) может вызвать CancelRequest в PostgreSQL, чтобы прервать долгий запрос на сервере.
Типичные ошибки кандидатов
- Установка таймаута только на клиенте, без учёта возможности отмены на сервере
Объяснение: Отмена контекста не всегда прерывает серверный запрос (зависит от драйвера). Безstatement_timeoutна уровне БД сервер может продолжать выполнение. Лучше комбинировать. - Использование
context.Background()для запросов к БД
Объяснение: Запрос без таймаута может «подвиснуть» и никогда не освободить соединение. Всегда оборачивайте вWithTimeoutхотя бы на 30 секунд.
Возможные дополнительные вопросы интервьюера
Как обработать таймаут и повторить запрос?
При получении context.DeadlineExceeded можно сделать retry с новым контекстом, но осторожно, чтобы не усугубить. Обычно таймаут свидетельствует о проблемах, и повтор бесполезен. Если таймаут вызван перегрузкой, можно использовать exponential backoff и ограниченное число попыток.
Как глобально задать таймауты для всех запросов?
Обернуть sql.DB в кастомный тип, добавляющий контекст с таймаутом автоматически, или использовать middleware (в HTTP-серверах).
Как работать с context.Context и БД?
Краткий ответ
Все методы database/sql теперь имеют варианты с префиксом Context: QueryContext, ExecContext, BeginTx и т.д. Рекомендуется использовать исключительно их, передавая контекст запроса. Контекст управляет временем ожидания соединения из пула, временем выполнения запроса и позволяет прервать операцию. Кроме того, через контекст можно пробрасывать значения для tracing-библиотек (span). Context должен исходить от входящего HTTP-запроса или корневого с таймаутом.
Развёрнутый ответ
func GetUser(ctx context.Context, db *sql.DB, id int) (*User, error) {
// ctx может содержать таймаут, deadline, трассировку
row := db.QueryRowContext(ctx, "SELECT ... WHERE id=$1", id)
// ...
}
Контекст никогда не следует хранить в структурах — передавайте явно от вызова к вызову. Внутри транзакции переданный контекст применяется ко всем последующим операциям (tx.QueryRowContext). При порождении горутины для параллельных запросов допустимо создать новый контекст с таймаутом от исходного.
Под капотом
db.QueryRowContext вызывает db.conn(ctx, ...), где ctx используется для двух целей: ожидания свободного соединения (через select case <-ctx.Done()) и непосредственно при driver.StmtQueryContext. Если драйвер не поддерживает контекст, database/sql эмулирует отмену через закрытие соединения (bad connection).
Типичные ошибки кандидатов
- Хранение
context.Contextв структуре
Объяснение: Это антипаттерн, контекст живёт только в рамках запроса. Нарушается идиоматичность, сложнее отследить время жизни. Передавайте аргументом. - Игнорирование контекста при
Begin
Объяснение:db.Begin()без контекста не даёт отмены транзакции по таймауту. Всегда используйтеBeginTx(ctx, nil). - Создание нового
context.Background()без нужды
Объяснение: Разрывает цепочку отмены и tracing. Если запрос инициирован пользователем, пробрасывайте его контекст.
Возможные дополнительные вопросы интервьюера
Как передавать трейсинг через контекст в БД?
Через opentelemetry или другие библиотеки добавляют span в контекст, и драйверы/обёртки (otelsql, pgx с трассировкой) автоматически создают дочерние спаны на каждый запрос.
Можно ли создать контекст с таймаутом, который управляет несколькими последовательными запросами?
Да, контекст с дедлайном применяется ко всем запросам, использующим его. В транзакции это удобно: BeginTx(ctxWithTimeout, nil) — все операции отменятся по истечению времени.
Как реализовать "unit of work" паттерн?
Краткий ответ
Unit of Work (UoW) отслеживает изменения объектов в рамках бизнес-операции и фиксирует их атомарно. В Go реализуется через транзакцию sql.Tx и репозитории, которые принимают UoW-интерфейс (например, *sql.Tx напрямую или абстракцию DB), гарантируя все операции в одной сессии. Менеджер транзакций управляет началом/концом UoW. Пример: передача *gorm.DB от одного репозитория к другому в рамках одной транзакции.
Развёрнутый ответ
type UnitOfWork interface {
BeginTx(ctx context.Context) error
Commit() error
Rollback()
// предоставляет интерфейс для репозиториев
DB() *sql.DB // или *sql.Tx
}
type UoW struct {
tx *sql.Tx
db *sql.DB
}
func (u *UoW) BeginTx(ctx context.Context) error {
tx, err := u.db.BeginTx(ctx, nil)
u.tx = tx
return err
}
func (u *UoW) DB() Executor { return u.tx } // Executor интерфейс с Exec/Query
Репозитории принимают Executor (интерфейс с методами ExecContext/QueryContext). Вне транзакции передаётся *sql.DB, внутри — *sql.Tx. Это классический подход в DDD.
Под капотом
Транзакция sql.Tx реализует интерфейс, аналогичный *sql.DB, поэтому репозиторию безразлично, с чем работать. Менеджер транзакций (UnitOfWorkManager) управляет жизненным циклом транзакции, может использовать context.Context для автоматического отката при панике или отмене.
Типичные ошибки кандидатов
- Смешивание нескольких транзакций в рамках одного UoW
Объяснение: Открытие новой транзакции внутри UoW ломает атомарность. Все изменения должны быть в одной. - Неправильное управление
*sql.Txпосле Commit
Объяснение: ПослеCommitтранзакция недействительна; последующее использование приведёт к ошибке. UoW должен блокировать дальнейшие операции. - Забытый Rollback при ошибках
Объяснение: Хотяdefer tx.Rollback()в методах частично спасает, лучше, чтобы UoW сам гарантировал откат при провале бизнес-операции.
Возможные дополнительные вопросы интервьюера
Как совместить UoW с паттерном Repository без циклических зависимостей?
Репозитории должны принимать фабрику/провайдер сессии, которая внутри UoW возвращает транзакционное соединение. Или используют инъекцию через контекст: ctx = context.WithValue(ctx, txKey, tx).
Нужен ли UoW, если используется одна таблица?
UoW не обязателен, но придерживаться единой архитектуры для consistency проще. Даже для одной таблицы UoW обеспечивает транзакционное поведение при нескольких запросах.
Как работать с sqlx?
Краткий ответ
sqlx — обёртка над database/sql, добавляющая автоматическое сканирование строк в структуры, поддержку именованных параметров, удобные методы Select, Get, NamedExec. Работает с теми же драйверами, без магии ORM. Позволяет писать чистый SQL, но меньше бойлерплейта для сканирования.
Развёрнутый ответ
type User struct {
ID int `db:"id"`
Name string `db:"name"`
}
var users []User
err := sqlx.SelectContext(ctx, db, &users, "SELECT id, name FROM users WHERE active=$1", true)
sqlx.DB и sqlx.Tx расширяют стандартные типы, полностью совместимы. Поддержка In() для расширения IN-запросов, PrepareNamed. Структуры сопоставляются по тегам db или именам полей.
Под капотом
sqlx использует рефлексию для сопоставления столбцов и полей, кэширует метаданные. При Select создаётся слайс, заполняемый строками через rows.StructScan. Он не заменяет sql.DB, а встраивает его, поэтому все настройки пула остаются.
Типичные ошибки кандидатов
- Использование
sqlxбез теговdb, когда имена колонок не совпадают
Объяснение: Если поле называетсяUserName, а колонкаuser_name, без тегаdb:"user_name"маппинг провалится (будет пустое значение). Используйте теги. - Незакрытие
sqlx.Rowsпри работе с ручным сканированием
Объяснение: Хотяsqlxдобавляет удобства, исходныеRowsвсё ещё требуют закрытия. Но при использованииSelect/Getзакрытие происходит автоматически.
Возможные дополнительные вопросы интервьюера
Можно ли использовать sqlx вместе с ORM?
Да, можно иметь отдельные репозитории на sqlx для сложных отчётов, а ORM для простых CRUD. Они оба могут использовать один пул *sql.DB.
Как обрабатывать транзакции в sqlx?
sqlx.BeginTxx(ctx, nil) возвращает *sqlx.Tx, который ведёт себя как *sql.Tx с дополнительными методами Select, Get и т.д.
Как выполнять batch-вставки?
Краткий ответ
Массовая вставка через Exec с множеством параметров в одном запросе (например, INSERT INTO t(a,b) VALUES ($1,$2),($3,$4)...), использование вспомогательных функций для построения bulk SQL. В pgx есть эффективный CopyFrom, реализующий протокол PostgreSQL COPY. Альтернатива — подготовленный Stmt и многократный Exec в цикле, но это медленнее. Batch-вставка существенно снижает время за счёт одного коммита и уменьшения round-trips.
Развёрнутый ответ
Способ с sqlx и NamedExec:
users := []User{...}
query := `INSERT INTO users (name, email) VALUES (:name, :email)`
_, err := db.NamedExec(query, users)
Способ с генерацией многострочного VALUES:
valueStrings := make([]string, 0, len(items))
valueArgs := make([]interface{}, 0, len(items)*2)
for i, item := range items {
valueStrings = append(valueStrings, fmt.Sprintf("($%d, $%d)", i*2+1, i*2+2))
valueArgs = append(valueArgs, item.Name, item.Email)
}
query := `INSERT INTO users(name, email) VALUES ` + strings.Join(valueStrings, ",")
db.Exec(query, valueArgs...)
Для pgx лучший способ:
copyFrom := pgx.CopyFromSlice(len(users), func(i int) ([]interface{}, error) {
return []interface{}{users[i].Name, users[i].Email}, nil
})
_, err := conn.CopyFrom(ctx, pgx.Identifier{"users"}, []string{"name", "email"}, copyFrom)
Под капотом
pgx.CopyFrom использует бинарный протокол COPY, что минимизирует оверхед. Многострочный INSERT компилируется в один PREPARE и выполняется за один round-trip. Цикл с Exec генерирует множество команд и возвратов статуса.
Типичные ошибки кандидатов
- Пакетная вставка по одной записи в цикле с отдельной транзакцией
Объяснение: КаждыйExec— round-trip; база данных работает медленно. Заворачивайте цикл в одну транзакцию и, если возможно, используйте массовый запрос. - Игнорирование ограничений на размер запроса
Объяснение: PostgreSQL имеет ограничение на число параметров (по умолчанию 65535). Слишком большой batch вызовет ошибку. Разбивайте на порции по 1000–5000 записей. - Использование
fmt.Sprintfдля построения запроса без экранирования
Объяснение: При построении многострочного запроса параметры всё равно подставляются через плейсхолдеры, так что это безопасно. Но если добавлять строки напрямую, возникнет инъекция.
Возможные дополнительные вопросы интервьюера
Как обрабатывать ошибки в середине batch-вставки?
В одном SQL-запросе с многострочным VALUES ошибка откатывает всю вставку. Если необходима частичная вставка, используйте вставку по одной записи в рамках одной транзакции с SAVEPOINT между ними.
Сравните CopyFrom и batch INSERT по производительности.
COPY быстрее, так как использует путь минимального протокола, избегает парсинга и планирования на каждую строку. Но COPY чувствителен к ошибкам — одна ошибка отменяет весь процесс. Batch INSERT с небольшими батчами более устойчив.
Как работать с БД в конкурентной среде?
Краткий ответ
*sql.DB безопасен для параллельного использования из множества горутин. Пул соединений сам управляет доступом. Однако необходимо следить за блокировками на уровне БД, не допускать дедлоков, использовать короткие транзакции, контролировать число одновременных операций (ограничение MaxOpenConns). Для конкурентного выполнения нескольких запросов на одном соединении это невозможно — каждое соединение обрабатывает последовательные запросы. Паттерн fan-out: несколько горутин получают соединения из одного пула и выполняют запросы параллельно.
Развёрнутый ответ
var wg sync.WaitGroup
for i := range ids {
wg.Add(1)
go func(id int) {
defer wg.Done()
db.QueryRowContext(ctx, "SELECT ...", id).Scan(&...)
}(ids[i])
}
wg.Wait()
Важно: *sql.Conn (может понадобиться для long-lived операций) не должен передаваться между горутинами без синхронизации, но *sql.DB — можно. Для одного соединения можно использовать db.Conn(ctx), возвращающую эксклюзивное соединение.
Под капотом
Пул внутри DB защищён мьютексом. Выдача и возврат соединений синхронизированы, поэтому множество горутин не создают гонку данных. Запросы выполняются на выделенных соединениях. Если соединений недостаточно, горутины блокируются.
Типичные ошибки кандидатов
- Превышение
MaxOpenConnsпри всплеске горутин
Объяснение: Все горутины блокируются в ожидании соединений, и может случиться каскадный таймаут. Ограничивайте параллелизм с помощью семафора (semaphore.Weighted) или настройкой worker pool. - Использование одной транзакции в нескольких горутинах
Объяснение:*sql.Txне безопасен для параллельного использования. Все операции внутри транзакции должны быть последовательными в одной горутине. - Игнорирование контекста в горутинах
Объяснение: Если родительский контекст отменён, горутины должны прекратить работу. Пробрасывайте контекст.
Возможные дополнительные вопросы интервьюера
Как реализовать connection pinning?
Используя db.Conn(ctx), получаете *sql.Conn, который можно использовать для нескольких операций, сохраняя одно и то же TCP-соединение (нужно для PostgreSQL LISTEN/NOTIFY, временных таблиц).
Как отладить дедлок в БД при конкурентных запросах?
Логировать текст запросов, время начала транзакций. В PostgreSQL можно посмотреть через pg_locks. В Go добавить метрики времени ожидания соединений.
Как реализовать ретраи (retry) для транзакций?
Краткий ответ
Ретрай нужен для повторения транзакции при ошибках сериализации (serialization failure), deadlock'ах или временных сетевых сбоях. Реализуется через обёртку с циклом, фиксированным/экспоненциальным бек-оффом и ограничением числа попыток. Ключевое: транзакция должна быть идемпотентной или содержать всю бизнес-логику внутри цикла. PostgreSQL возвращает код ошибки 40001 (serialization_failure) и 40P01 (deadlock_detected), которые явно указывают на повтор.
Развёрнутый ответ
func withRetry(ctx context.Context, db *sql.DB, maxRetries int, fn func(*sql.Tx) error) error {
for i := 0; i < maxRetries; i++ {
tx, _ := db.BeginTx(ctx, nil)
defer tx.Rollback()
err := fn(tx)
if err == nil {
return tx.Commit()
}
if isRetryable(err) {
time.Sleep(backoff(i))
continue
}
return err
}
return errors.New("retries exhausted")
}
isRetryable может проверять pq.Error с кодом 40001 или serialization_failure. Обратите внимание, что перед повторной попыткой необходимо заново вычислить все данные, так как предыдущая транзакция откатилась.
Под капотом
Драйвер pq или pgx при ошибке возвращает *pq.Error, содержащую поле Code. Для определения retryable можно прверить err.(*pq.Error).Code == "40001". database/sql не предоставляет встроенного ретрая.
Типичные ошибки кандидатов
- Повтор без перевычисления данных
Объяснение: Если транзакция откатилась, все прочитанные данные могли измениться. Нельзя повторно использовать старый снимок. Нужно заново прочитать из БД внутриfn. - Бесконечный ретрай без бек-оффа
Объяснение: Может вызвать шторм повторных попыток, ухудшающих деградацию. Всегда используйте задержки и максимальное число попыток. - Ретрай любой ошибки (
sql.ErrNoRows, нарушение constraint)
Объяснение: Повтор имеет смысл только для transient ошибок. Бизнес-ошибки (нарушение уникальности) приведут к бесконечному циклу. Фильтруйте по типам.
Возможные дополнительные вопросы интервьюера
Как ретрай связан с таймаутами транзакции?
Клиентский таймаут (контекст) должен охватывать все попытки с учётом времени бек-оффа, либо на каждой итерации создавать новый контекст с оставшимся временем.
Как протестировать ретрай?
С помощью sqlmock можно задать последовательность ExpectBegin, ExpectExec с ошибкой deadlock, а затем успешное выполнение.
Как pq (драйвер PostgreSQL) обрабатывает уведомления (LISTEN/NOTIFY)?
Краткий ответ
Драйвер pq предоставляет pq.Listener для получения асинхронных уведомлений из канала через LISTEN. Он поддерживает переподключение при обрыве. Для использования нужно создать listener := pq.NewListener(dsn, minReconn, maxReconn, callback), затем listener.Listen("channel"). Уведомления доставляются в callback-функцию. Важно, что прослушивание занимает отдельное соединение, которое не должно использоваться для обычных запросов.
Развёрнутый ответ
listener := pq.NewListener(connString, 5*time.Second, time.Minute, func(ev pq.ListenerEventType, err error) { ... })
err := listener.Listen("my_channel")
for {
select {
case n := <-listener.Notify:
log.Printf("Received: %s", n.Extra)
}
}
pq.Listener внутри запускает горутину, которая устанавливает соединение, выполняет LISTEN, и в цикле читает уведомления. При разрыве соединения автоматически переподключается и переподписывается на каналы. Также есть ListenNotifyConnector с поддержкой database/sql (но требует отдельного соединения).
Под капотом
pq.Listener использует низкоуровневый net.Conn, обходит sql.DB, потому что ему нужно долгоживущее выделенное соединение. Он отправляет LISTEN и читает ответы сервера, идентифицируя сообщения типа A (Notification). Внутренний цикл переподключения с экспоненциальной задержкой.
Типичные ошибки кандидатов
- Использование одного соединения и для LISTEN, и для обычных запросов
Объяснение: LISTEN блокирует соединение; попытка выполнить запрос приведёт к таймауту или неопределённому поведению. Выделите отдельное соединение. - Не обрабатывать ошибки переподключения
Объяснение: При длительном отключении listener перестанет получать уведомления, callback может вызваться с ошибкой, но вышестоящий код должен реагировать (например, пересоздать listener). - Забыть вызвать
listener.Close()
Объяснение: Утечка горутины и соединения.
Возможные дополнительные вопросы интервьюера
Как реализовать LISTEN/NOTIFY с помощью pgx?
pgx имеет встроенную поддержку через conn.Conn().WaitForNotification(ctx) с использованием собственного протокола, что эффективнее.
Гарантирует ли NOTIFY доставку?
Нет, это асинхронная очередь; сообщения теряются, если нет активного слушателя. Для гарантированной доставки нужна таблица-очередь.
Как работать с pgx драйвером и его преимущества?
Краткий ответ
pgx — быстрый, низкоуровневый драйвер PostgreSQL. Преимущества: поддержка расширенного протокола, бинарное кодирование параметров, нативный LISTEN/NOTIFY, CopyFrom, управление таймаутами/отменой без использования database/sql. Может работать как отдельный драйвер через database/sql (с помощью pgx/stdlib) или напрямую через *pgx.Conn / *pgxpool.Pool. Последний даёт максимальную производительность и контроль.
Развёрнутый ответ
Использование через database/sql:
import _ "github.com/jackc/pgx/v5/stdlib"
db, _ := sql.Open("pgx", "postgres://...")
Нативное использование:
pool, _ := pgxpool.New(context.Background(), connString)
defer pool.Close()
row := pool.QueryRow(ctx, "SELECT name FROM users WHERE id=$1", id)
Преимущества:
- Бинарный протокол — меньше сериализационных накладок.
- Поддержка JSONB напрямую в
pgtype. - Эффективная работа с большими объёмами данных через
CopyFrom. - Встроенная поддержка advisory locks,
LISTEN/NOTIFY. - Пул соединений собственный (
pgxpool), оптимизированный.
Под капотом
pgx управляет соединением сам, используя пул с минимумом аллокаций. При работе через database/sql часть преимуществ теряется из-за абстракции. Пул pgxpool реализует неблокирующее ожидание, статистику, health check.
Типичные ошибки кандидатов
- Использование
database/sql-обёртки без необходимости
Объяснение: Если проект полностью на PostgreSQL, прямойpgxpoolдаст лучшую производительность и меньше накладных расходов. Используйтеdatabase/sqlтолько если нужна абстракция для нескольких БД. - Игнорирование настроек
pgxpool(MaxConns)
Объяснение: Аналогично необходимо ограничивать число соединений, иначе исчерпание ресурсов БД. - Неправильная работа с
pgtype
Объяснение: При сканировании в кастомные типы нужно понимать, как регистрировать типы вpgtype.
Возможные дополнительные вопросы интервьюера
Как pgx реализует отмену запроса?
При отмене контекста pgx отправляет специальное CancelRequest-сообщение, прерывая запрос на сервере.
Стоит ли использовать pgx в production?
Да, это самый производительный драйвер для Go, широко используется. Slack, Facebook (раньше) применяли.
Как реализовать "connection pool" для специфичных нужд?
Краткий ответ
Иногда требуется пул с нестандартным поведением: приоритетное обслуживание, sticky-соединения, маршрутизация read/write, ограничение числа соединений для конкретного пользователя БД. Можно написать обёртку над sql.DB с собственной логикой диспетчеризации или использовать несколько sql.DB с различными настройками. В pgxpool также можно реализовать кастомный пул, имплементируя интерфейс, если нужно больше контроля.
Развёрнутый ответ
Пример: Read/Write splitting pool:
type Pool struct {
writer *sql.DB
reader *sql.DB
}
func (p *Pool) Write() *sql.DB { return p.writer }
func (p *Pool) Read() *sql.DB { return p.reader }
Для более тонкого управления (sticky connections на основе хэша) можно использовать sync.Map для сопоставления ключа и соединения, но нужно аккуратно возвращать в пул. Альтернатива — использовать database/sql пул с кастомным Connector, который может переопределять открытие соединений и их валидацию.
Под капотом
Интерфейс driver.Connector позволяет переопределить Connect(context.Context) (driver.Conn, error). Это можно использовать для цепочки декорирования соединений, добавляя логику выбора БД.
Типичные ошибки кандидатов
- Собственный пул без обработки таймаутов и переподключений
Объяснение: Реализация полноценного пула сложна, легко допустить гонки и утечки. Лучше использовать встроенный пулsql.DB/pgxpool, добавляя логику поверх. - Попытка привязать горутину к конкретному соединению без использования
db.Conn
Объяснение: Используйтеdb.Conn(ctx)для выделения эксклюзивного соединения, иначе пул может вернуть другое. Для sticky-сессий это безопасный путь.
Возможные дополнительные вопросы интервьюера
Как эмулировать connection pinning для транзакций с уровнем изоляции?
Используйте db.Conn(ctx) внутри транзакционного блока: conn, _ := db.Conn(ctx); tx, _ := conn.BeginTx(ctx, opts).
Можно ли использовать sql.DB как client-side connection pool для Cassandra?
Нет, sql.DB рассчитан на реляционные БД. Для Cassandra используйте gocql или cqlc.
Что такое "timeout" и "deadline" для запроса к БД?
Краткий ответ
Таймаут — максимальное время выполнения всей операции, включая ожидание соединения и сам запрос. Deadline — конкретный момент времени, до которого операция должна завершиться. В Go это реализуется через context.WithTimeout / WithDeadline. Передача такого контекста в ExecContext/QueryContext гарантирует, что если время истекло, операция прервётся. Таймаут также можно задать на стороне сервера (statement_timeout), что важно для длительных запросов.
Развёрнутый ответ
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(3*time.Second))
defer cancel()
rows, err := db.QueryContext(ctx, "SELECT ...")
На практике комбинируют: клиентский таймаут + statement_timeout=2s в DSN. Это гарантирует прерывание как на клиенте, так и на сервере, если сеть нестабильна. Без клиентского таймаута запрос может зависнуть в очереди пула навсегда.
Под капотом
Контекст с дедлайном при пробуждении канала ctx.Done() вызывает отмену операции. Если соединение ещё не выделено, горутина просыпается и возвращает context.DeadlineExceeded. Если запрос выполняется, драйвер может послать CancelRequest (PostgreSQL) или закрыть соединение (MySQL).
Типичные ошибки кандидатов
- Таймаут только на клиенте, без
statement_timeout
Объяснение: Клиент может успеть отменить ожидание, но сервер продолжает выполнять тяжёлый запрос, потребляя ресурсы. Всегда ставьте ограничения на сервере. - Смешивание
SetConnMaxLifetimeс дедлайном
Объяснение:ConnMaxLifetimeуправляет пересозданием соединений, а не таймаутом запроса. Не пытайтесь его использовать для ограничения длительности запроса.
Возможные дополнительные вопросы интервьюера
Как глобально применить таймаут ко всем запросам без передачи контекста?
Это невозможно без обёртки, потому что все методы требуют контекст. Решение — написать middleware, который оборачивает *sql.DB и добавляет контекст с таймаутом.
Что произойдёт, если контекст уже просрочен до вызова ExecContext?
Метод сразу вернёт ошибку context.DeadlineExceeded, не обращаясь к БД.
Как профилировать запросы к БД?
Краткий ответ
Профилирование запросов включает: логирование медленных запросов (slow log PostgreSQL), трейсинг с помощью OpenTelemetry, сбор метрик через expvar или Prometheus. На уровне Go можно анализировать статистику пула через db.Stats(), трейсить вызовы с помощью обёртки otelsql или sqlcommenter. Для детального анализа используйте EXPLAIN ANALYZE запросов.
Развёрнутый ответ
Включение метрик:
stats := db.Stats()
log.Printf("Open: %d, Idle: %d, InUse: %d, WaitCount: %d", stats.OpenConnections, stats.Idle, stats.InUse, stats.WaitCount)
Эти метрики можно экспортировать для Prometheus. OpenTelemetry инструментирует драйверы для отслеживания продолжительности запросов, создания спанов. Также можно вручную замерить время:
start := time.Now()
db.QueryContext(ctx, query)
elapsed := time.Since(start)
Под капотом
db.Stats() возвращает структуру с атомарными счётчиками. OpenTelemetry интеграция требует регистрации драйвера с otelsql.Register(), который оборачивает соединение и добавляет следы.
Типичные ошибки кандидатов
- Игнорирование метрик ожидания соединений (
WaitCount)
Объяснение: Это индикатор перегрузки пула; если растёт, нужно увеличиватьMaxOpenConnsили оптимизировать запросы. - Логирование каждого запроса на production
Объяснение: Высокий объём логов ухудшит производительность. Используйте выборку (sampling) или логируйте только медленные запросы. - Профилирование без учёта транзакционных накладных
Объяснение: Запросы в транзакциях могут задерживаться из-за блокировок, это нужно измерять отдельно.
Возможные дополнительные вопросы интервьюера
Как интегрировать slow-query лог PostgreSQL с трейсингом?
Можно написать pg-расширение или использовать auto_explain, который пишет планы в лог, а затем парсить их и отправлять в систему трейсинга. Также можно поставить триггер на pg_stat_statements.
Показывает ли db.Stats() сколько соединений заблокировано в ожидании?
WaitCount и WaitDuration сообщают кумулятивные данные об ожиданиях, но не текущее количество.
Как opentelemetry и tracing интегрируются с database/sql?
Краткий ответ
Интеграция достигается через обёртку драйвера с помощью otelsql (OpenTelemetry SQL) или аналогичных библиотек. Они регистрируют новый драйвер, который оборачивает базовый, и автоматически создают спаны для Exec, Query, транзакций. В контексте передаются traceparent, span context. Это позволяет видеть запросы в распределённой трассировке. Также можно вручную добавлять атрибуты, например, текст запроса, ошибки.
Развёрнутый ответ
import (
"github.com/XSAM/otelsql"
semconv "go.opentelemetry.io/otel/semconv/v1.20.0"
)
driverName, err := otelsql.Register("postgres",
otelsql.WithAttributes(semconv.DBSystemPostgreSQL))
db, err := sql.Open(driverName, dsn)
Все вызовы QueryContext будут создавать SPAN с именем sql:query или подобным. Можно добавить WithSQLCommenter, чтобы внедрять комментарии с trace-id в SQL (для корреляции на стороне сервера БД).
Под капотом
otelsql реализует driver.Driver, driver.Conn и другие интерфейсы, делегируя реальному драйверу, но окружая каждый вызов спанами. При создании соединения он оборачивает и driver.Stmt для поддержки prepared statements.
Типичные ошибки кандидатов
- Забыть зарегистрировать глобальный TracerProvider
Объяснение: OpenTelemetry требует настроенный провайдер; без него спаны не будут экспортироваться. - Чрезмерная детализация спанов (параметры запроса)
Объяснение: Параметры могут содержать чувствительные данные. НастройтеWithAttributesтолько для безопасных атрибутов. - Использование разных драйверных имён и путаница
Объяснение: При регистрации возвращается новое имя драйвера, его нужно использовать вsql.Open. Старое имя не будет иметь трассировки.
Возможные дополнительные вопросы интервьюера
Как связать спаны БД с HTTP-запросами?
Контекст трейсинга передаётся автоматически от HTTP-сервера к вызову БД, если используется propagation.
Можно ли интегрировать pgx без обёртки?
pgx имеет собственный трейсинг через интерфейс Tracer. Можно реализовать его, интегрируя с OpenTelemetry, что более эффективно.
Как работать с clickhouse-go и mongo-driver?
Краткий ответ
ClickHouse Go клиент (clickhouse-go/v2) поддерживает database/sql драйвер, но также собственный интерфейс. В отличие от OLTP, ClickHouse оптимизирован на вставку блоками, поэтому используйте Exec с пакетной вставкой или нативный conn.PrepareBatch. MongoDB Go драйвер (mongo-driver) не совместим с database/sql, используется собственный API: mongo.Connect, работа с коллекциями, BSON. Оба клиента потокобезопасны и поддерживают контексты.
Развёрнутый ответ
ClickHouse через database/sql:
import "github.com/ClickHouse/clickhouse-go/v2"
db := sql.Open("clickhouse", "tcp://127.0.0.1:9000?debug=true")
rows, _ := db.Query("SELECT ...")
Но лучшая практика — драйвер с native интерфейсом для пакетной вставки:
conn := clickhouse.Open(&clickhouse.Options{Addr: []string{"127.0.0.1:9000"}})
batch, _ := conn.PrepareBatch(ctx, "INSERT INTO table")
batch.Append(v1, v2)
batch.Send()
MongoDB:
client, _ := mongo.Connect(ctx, options.Client().ApplyURI("mongodb://localhost:27017"))
coll := client.Database("db").Collection("users")
coll.InsertOne(ctx, bson.D{{Key: "name", Value: "Alice"}})
Под капотом
clickhouse-go драйвер под database/sql эмулирует интерфейс, но из-за особенностей вставки предпочтительнее нативный. MongoDB драйвер управляет пулом соединений самостоятельно, пул настраивается через options.Client(). Оба используют бинарные протоколы.
Типичные ошибки кандидатов
- Использование построчных INSERT в ClickHouse как в PostgreSQL
Объяснение: ClickHouse плохо подходит для частых одиночных вставок, нужны батчи. Неверный паттерн вставки ведёт к огромному количеству партов и деградации. - Неправильное закрытие клиента MongoDB
Объяснение:client.Disconnect(ctx)нужно вызывать при остановке приложения, иначе открытые соединения могут висеть. - Неучёт особенностей BSON-сериализации
Объяснение: Порядок полей в BSON важен, struct tagsbson:"..."обязательны для корректного маппинга.
Возможные дополнительные вопросы интервьюера
Как выполнить репликацию запросов в ClickHouse?
Драйвер позволяет указать несколько хостов, между ними распределяются соединения. Для чтения с реплик можно использовать маршрутизацию.
Как управлять транзакциями в MongoDB?
MongoDB поддерживает транзакции через client.UseSession, оборачивая операции в session.WithTransaction.
Как реализовать паттерн "Repository"?
Краткий ответ
Репозиторий — абстракция над хранилищем, инкапсулирующая запросы и скрывающая детали работы с БД. В Go репозиторий — это структура, хранящая *sql.DB, *sqlx.DB или другую сессию, и реализующая интерфейс с методами типа FindByID, Save. Это отделяет бизнес-логику от SQL, упрощает тестирование (замена на mock-репозиторий). Важно, чтобы репозиторий не содержал бизнес-правил.
Развёрнутый ответ
type UserRepository interface {
FindByID(ctx context.Context, id int) (*User, error)
Save(ctx context.Context, u *User) error
}
type userRepo struct {
db *sql.DB
}
func (r *userRepo) FindByID(ctx context.Context, id int) (*User, error) {
row := r.db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id=$1", id)
// ...
}
Использование интерфейса позволяет в тестах подставить mock-реализацию, не зависящую от БД. Репозиторий может также принимать *sql.Tx для участия в транзакциях.
Под капотом
Паттерн Repository — часть DDD. Он отделяет слой сохранения, но не должен быть тонкой обёрткой над ORM — может содержать оптимизированные запросы для агрегатов.
Типичные ошибки кандидатов
- Размещение бизнес-логики внутри репозитория
Объяснение: Репозиторий должен только читать/писать, не делать проверки, не отправлять email. Это нарушает разделение ответственности. - Жёсткая привязка к типу
*sql.DBвместо интерфейса
Объяснение: Затрудняет подмену для тестов и работы с транзакциями. Используйте интерфейсExecer,Queryer. - Методы, возвращающие запросный билдер (как в ORM)
Объяснение: Протекание абстракции — клиент не должен знать о деталях построения запроса. Метод должен закрывать всю логику внутри.
Возможные дополнительные вопросы интервьюера
Как репозиторий взаимодействует с Unit of Work?
Обычно UoW предоставляет транзакцию репозиториям. Например, accountRepo.Save(tx, account), где tx — текущая транзакция. Или через context: ctx = context.WithValue(ctx, txKey, tx), и репозиторий извлекает её.
Стоит ли делать репозиторий глобальным singleton?
Лучше внедрять зависимость через конструктор, чтобы тесты могли создавать новые экземпляры.
Как использовать sqlmock для тестирования без БД?
Краткий ответ
sqlmock — реализация драйвера SQL, перехватывающая все вызовы и позволяющая задавать ожидания (expectations). Создаётся sqlmock.New() возвращает *sql.DB и Sqlmock. Ожидания настраиваются в порядке вызовов: ExpectQuery, ExpectExec. После выполнения теста ExpectationsWereMet() проверяет, все ли ожидания выполнены. Это идеальный инструмент для юнит-тестов слоя репозиториев.
Развёрнутый ответ
db, mock, _ := sqlmock.New()
defer db.Close()
repo := NewUserRepo(db)
mock.ExpectExec("INSERT INTO users").WithArgs("Alice").WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectQuery("SELECT (.+) FROM users WHERE id = ?").WithArgs(1).WillReturnRows(sqlmock.NewRows([]string{"id", "name"}).AddRow(1, "Alice"))
// выполнение кода репозитория
err := repo.Save(ctx, &User{Name:"Alice"})
assert.NoError(t, err)
assert.NoError(t, mock.ExpectationsWereMet())
Можно также имитировать ошибки: WillReturnError(errors.New("connection refused")).
Под капотом
sqlmock использует интерфейс driver.Driver, driver.Conn и driver.Stmt. При ExpectQuery запоминается регулярное выражение для запроса и аргументы. При фактическом вызове он проверяет соответствие и возвращает предопределённые строки или ошибки.
Типичные ошибки кандидатов
- Слишком жёсткие регулярки для запроса
Объяснение: Пробелы и форматирование ломают совпадение. Используйтеregexp.QuoteMetaили.с гибким количеством пробелов. - Несовпадение порядка ожиданий
Объяснение:sqlmockтребует строгого порядка вызовов, соответствующего порядку в коде. Иногда трудно поддерживать. - Не проверять
ExpectationsWereMet
Объяснение: Пропуск этой проверки может скрыть неиспользуемые запросы или неожиданные вызовы.
Возможные дополнительные вопросы интервьюера
Как протестировать транзакции через sqlmock?
Можно ожидать ExpectBegin, затем запросы, ExpectCommit или ExpectRollback. sqlmock поддерживает это.
Можно ли замокать многопоточные запросы?
sqlmock потокобезопасен, но ожидания должны быть чётко определены с учётом конкурентности. Лучше тестировать с помощью testcontainers.
Как реализовать "read-write splitting" (routing на реплику)?
Краткий ответ
Разнесение чтения и записи: запросы на чтение отправляются на реплики (read-only), запросы на запись — на мастер. В Go это можно сделать, имея два пула: *sql.DB для мастера и один или несколько для реплик. Роутинг определяют на основе метода SQL: SELECT — на реплику, остальные — на мастер. Для распределения между репликами можно использовать стратегию round-robin, случайный выбор или по хэшу.
Развёрнутый ответ
type DBCluster struct {
master *sql.DB
replicas []*sql.DB
rrIdx uint32
}
func (c *DBCluster) Slave() *sql.DB {
// round-robin
idx := atomic.AddUint32(&c.rrIdx, 1) % uint32(len(c.replicas))
return c.replicas[idx]
}
В методах репозитория: func (r *repo) Get(ctx, id) { r.cluster.Slave().QueryRowContext(ctx, "SELECT ...") }. Для транзакций всегда используется мастер. Нужно учитывать replication lag: сразу после записи чтение с реплики может вернуть старые данные (stale reads). Для свежих данных — использовать мастер.
Под капотом
Никакой магии; просто несколько экземпляров sql.DB. Можно реализовать адаптер, удовлетворяющий интерфейсу Queryer/Execer, который автоматически выбирает нужный пул на основе типа запроса (анализ первого слова SQL). Но это хрупко; явное указание «reader/writer» надёжнее.
Типичные ошибки кандидатов
- Чтение с реплики сразу после записи без учёта lag
Объяснение: Пользователь может не увидеть свои изменения. Используйте чтение с мастера для критичных данных или устанавливайте контекст с флагом «consistent read». - Использование одного пула для мастера и реплик
Объяснение: Балансировка между мастером и репликами через обычную DNS не контролируется приложением. Явное разделение даёт гибкость. - Отказоустойчивость: если реплика недоступна, возвращать ошибку, а не пробовать другую
Объяснение: Нужен fallback на следующую реплику, чтобы увеличить доступность.
Возможные дополнительные вопросы интервьюера
Как проверить актуальность данных с реплики?
Использовать запрос отставания pg_last_wal_receive_lsn() или мониторинг lag. При критичности применять мастер.
Можно ли реализовать split на уровне драйвера?
Некоторые драйверы (например, pgxmock для тестов) или кастомный Connector могут маршрутизировать, но обычно проще на уровне приложения.
Как обрабатывать connection refused и другие сетевые ошибки?
Краткий ответ
Сетевые ошибки обрабатываются через ретрай с бек-оффом для transient ошибок. database/sql при обнаружении driver.ErrBadConn автоматически переоткрывает соединение и повторяет операцию на новом. Но при connection refused при первой попытке подключения пул не поможет. Нужно на уровне приложения при старте дождаться доступности БД (retry loop), а для выполнения запросов использовать retry при ошибках net.Error с Timeout() или Temporary(). Также полезно настроить Circuit Breaker.
Развёрнутый ответ
func waitForDB(ctx context.Context, db *sql.DB) error {
for {
err := db.PingContext(ctx)
if err == nil { return nil }
if isTemporary(err) {
time.Sleep(1*time.Second)
continue
}
return err
}
}
Во время работы при получении connection reset by peer драйвер может вернуть driver.ErrBadConn, и транзакции повторяются с новой попыткой. Пользовательский код тоже может проверять err и при необходимости повторять (для read-запросов).
Под капотом
Внутри database/sql метод driverConn после ошибки может вызвать conn.Close() и вернуть driver.ErrBadConn, что заставляет пул открыть новое соединение и повторить операцию до двух раз (см. maxBadConnRetries = 2). Но это относится только к запросам, а не к начальному открытию.
Типичные ошибки кандидатов
- Паника приложения, если БД недоступна при старте
Объяснение: Лучше пережидать с retry, чтобы приложение стартовало при восстановлении БД без перезапуска. - Повтор без различения temporary ошибок
Объяснение: Повторять ошибку «access denied» бессмысленно. Проверяйтеnet.OpErrorили коды ошибок БД. - Использование бесконечного retry без таймаута
Объяснение: Возможно зависание. Ограничивайте общее время.
Возможные дополнительные вопросы интервьюера
Как database/sql обрабатывает разрыв соединения во время выполнения запроса?
Если запрос провалился с ошибкой сети, соединение помечается плохим, и при следующем использовании будет пересоздано. Сам запрос не повторяется автоматически — об этом заботится вызывающий код.
Нужно ли вручную проверять db.Ping() перед каждым запросом?
Нет, это бессмысленно и замедляет. Пул сам управляет здоровьем.
Что такое "pipelining" в Redis и как его реализовать в Go?
Краткий ответ
Pipelining — отправка нескольких команд на сервер Redis без ожидания ответа на каждую по отдельности, затем чтение всех ответов разом. Это уменьшает latency за счёт меньшего числа round-trips. В Go клиент go-redis/redis поддерживает Pipe(): pipe := client.Pipeline(), добавляем команды, затем pipe.Exec(ctx). Получить результаты можно через pipe.Exec() возвращающий []Cmder.
Развёрнутый ответ
pipe := client.Pipeline()
incr := pipe.Incr(ctx, "counter")
pipe.Expire(ctx, "counter", time.Hour)
cmds, err := pipe.Exec(ctx)
fmt.Println(incr.Val(), cmds[1].Name())
Можно использовать TxPipeline для атомарной пайплайновой транзакции (MULTI/EXEC) с теми же преимуществами. Pipeline не является транзакцией: команды могут перемежаться с другими клиентами.
Под капотом
Клиент отправляет команды в буфер сокета, затем считывает ответы в порядке FIFO. При Exec() вызывается writeCmd для всех накопленных команд без промежуточной синхронизации.
Типичные ошибки кандидатов
- Не проверять ошибки отдельных команд после
Exec()
Объяснение: Некоторые команды могут завершиться с ошибкой, не обработанной при добавлении в pipeline. Нужно проверятьcmd.Err(). - Использование pipeline для связанных команд, ожидая транзакционности
Объяснение: Pipeline не атомарен; для атомарности нуженTxPipelineили Lua скрипт.
Возможные дополнительные вопросы интервьюера
Как измерить выигрыш от pipeline?
Через бенчмарки: сравнить время 1000 отдельных INCR и 1000 команд в pipeline. Разница особенно заметна при удалённом хосте.
Можно ли pipeline использовать в кластерном клиенте?
Да, но команды автоматически маршрутизируются по правильным узлам; pipeline будет выполняться не атомарно между шардами.
Как работать с go-redis/redis и его кластерным клиентом?
Краткий ответ
go-redis — де-факто стандартный клиент для Redis в Go. Предоставляет redis.NewClient для standalone/sentinel и redis.NewClusterClient для кластера. Кластерный клиент автоматически определяет шард по ключу, перенаправляет запросы при MOVED/ASK, поддерживает pipeline и транзакции на одном узле. Оба клиента безопасны для конкурентного использования и управляют пулом соединений.
Развёрнутый ответ
client := redis.NewClient(&redis.Options{Addr: "localhost:6379"})
defer client.Close()
val, err := client.Get(ctx, "key").Result()
Кластер:
clusterClient := redis.NewClusterClient(&redis.ClusterOptions{Addrs: []string{":7001", ":7002"}})
err := clusterClient.Set(ctx, "key", "value", 0).Err()
Кластерный клиент внутри хранит мапу слотов -> адресов узлов, периодически обновляет топологию. При MOVED обновляет карту и повторяет запрос.
Под капотом
go-redis использует коннектор-пул с минимумом аллокаций. Для кластера реализован clusterClient, который держит отдельных *redis.Client для каждого узла. Внутренние горутины переодически обновляют топологию через CLUSTER SLOTS.
Типичные ошибки кандидатов
- Использование обычного клиента для кластера
Объяснение: Обычный клиент не поддерживает перенаправление, запросы к другим шардам приведут кMOVEDошибке. НуженNewClusterClient. - Игнорирование
Close()
Объяснение: Утечка соединений. Всегда закрывайте клиент при остановке. - Не обрабатывать ошибку
ErrClosedпри конкурентном закрытии
Объяснение: Может возникнуть при graceful shutdown; её можно игнорировать или специально обработать.
Возможные дополнительные вопросы интервьюера
Как работает пул соединений go-redis?
Пул настраивается через PoolSize, MinIdleConns. Используется канал для свободных соединений, поддерживается проверка здоровья.
Как отслеживать время выполнения команд?
Можно добавить AddHook для логирования/метрик, например, прометеевый Hook.
Как реализовать кэширование с помощью Redis в Go?
Краткий ответ
Типовой паттерн: перед запросом к БД проверить Redis-кэш. Если есть (cache hit) — вернуть данные; если нет (cache miss) — загрузить из БД, сохранить в Redis с TTL и вернуть. Важно управлять инвалидацией (TTL или явная инвалидация при изменении данных). Для кэширования структуры сериализуются в JSON/pb/gob. Необходимо предусмотреть защиту от dogpile-эффекта при истечении кэша.
Развёрнутый ответ
func (s *Service) GetUser(ctx context.Context, id int) (*User, error) {
key := fmt.Sprintf("user:%d", id)
data, err := s.redis.Get(ctx, key).Bytes()
if err == nil {
var user User
json.Unmarshal(data, &user)
return &user, nil
}
// cache miss
user, err := s.repo.FindByID(ctx, id)
if err != nil { return nil, err }
if user == nil { return nil, nil }
b, _ := json.Marshal(user)
s.redis.Set(ctx, key, b, 10*time.Minute)
return user, nil
}
Для предотвращения лавины при истечении кэша можно использовать блокировку (mutex) на время загрузки, либо подход "probabilistic cache refresh".
Под капотом
go-redis предоставляет атомарный SET с NX и GET. Всё построено поверх TCP-соединений.
Типичные ошибки кандидатов
- Игнорирование инвалидации кэша
Объяснение: Обновление данных в БД без удаления кэша приводит к long stale reads. Нужно либо TTL, либо manual invalidation. - Dogpile/stampede на горячих ключах
Объяснение: Множество запросов одновременно пытаются загрузить данные из БД, перегружая её. Решение: блокировка или singleflight. - Кэширование больших объектов без сжатия
Объяснение: Redis хранит в памяти, большие значения замедляют и дороги. Используйте сжатие или храните ссылки.
Возможные дополнительные вопросы интервьюера
Как реализовать двухуровневый кэш (local + Redis)?
Можно использовать ristretto или bigcache в памяти приложения, синхронизируя с Redis через pub/sub для инвалидации.
Что такое cache-aside?
Стратегия, где приложение само управляет кэшем: читает из кэша, при промахе загружает из БД и кладёт в кэш.
Как реализовать распределенную блокировку с помощью Redis?
Краткий ответ
Использование SET key value NX PX <ttl> для захвата блокировки. Ключом обычно является имя ресурса, значением — уникальный идентификатор экземпляра (для безопасного освобождения). При отпускании блокировки используем Lua-скрипт, проверяющий, что значение совпадает, чтобы не удалить чужую блокировку. Альтернативно — Redlock для кластерной отказоустойчивости.
Развёрнутый ответ
func acquireLock(ctx context.Context, client *redis.Client, key string, ttl time.Duration) (string, bool, error) {
token := uuid.New().String()
ok, err := client.SetNX(ctx, key, token, ttl).Result()
return token, ok, err
}
func releaseLock(ctx context.Context, client *redis.Client, key, token string) error {
script := redis.NewScript(`
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end`)
return script.Run(ctx, client, []string{key}, token).Err()
}
Для Redlock используют несколько изолированных Redis-узлов и получают большинство подтверждений.
Под капотом
SetNX атомарен. Lua-скрипт выполняется атомарно на сервере. Для защиты от долгой блокировки из-за GC или паузы нужно достаточно большой TTL и, возможно, механизм продления (watchdog).
Типичные ошибки кандидатов
- Освобождение блокировки без проверки владельца
Объяснение: Если клиент A потерял блокировку по таймауту, а клиент B захватил её, A может удалить блокировку B. Используйте Lua-скрипт с проверкой значения. - Слишком маленький TTL
Объяснение: Блокировка может истечь до завершения работы, что нарушит эксклюзивность. Оценивайте время операции и добавляйте запас. - Использование single Redis, как единственной точки отказа
Объяснение: При сбое Redis блокировка недоступна. Redlock решает это ценой сложности.
Возможные дополнительные вопросы интервьюера
Как реализовать блокировку с автоматическим продлением?
Запускаете горутину, которая периодически продлевает TTL (EXPIRE), пока основная операция не завершится. Но нужно аккуратно с завершением горутины.
Какие альтернативы Redis-блокировкам?
Consul, etcd, Zookeeper (leader election), или блокировки на уровне БД (advisory locks PostgreSQL).
Что такое "caching patterns" (cache-aside, read-through, write-through)?
Краткий ответ
Основные стратегии:
- Cache-aside (ленивая загрузка): приложение само проверяет кэш; при промахе загружает из БД и сохраняет в кэш. Инвалидация — по записи удаляем ключ из кэша.
- Read-through: кэш-провайдер сам загружает данные из БД при промахе, клиент не знает о БД.
- Write-through: запись синхронно идёт в кэш и БД одновременно, гарантируя консистентность.
- Write-behind (write-back): запись в кэш, асинхронно сбрасывается в БД.
Развёрнутый ответ
Cache-aside — самый популярный в Go: просто используем Redis перед запросом к sql.DB. Read-through обычно реализуется через кэширующие прокси. Write-through эмулируется обновлением кэша сразу после успешной записи в БД. Комбинация cache-aside для чтения и write-invalidate для записи хорошо работает.
Под капотом
Паттерны реализуются на уровне приложения или через внешний кэш-сервер (Redis, Memcached). Нет встроенной поддержки.
Типичные ошибки кандидатов
- Путаница между read-through и cache-aside
Объяснение: Read-through скрывает источник данных от клиента, cache-aside — нет. - Использование write-behind без уверенности в доставке
Объяснение: Потеря асинхронной записи ведёт к потере данных. Нужна надёжная очередь.
Возможные дополнительные вопросы интервьюера
Какой паттерн выбрать для микросервисов?
Обычно cache-aside с TTL и инвалидацией при изменениях. Это просто и контролируемо.
Как обработать cache stampede при cache-aside?
Можно использовать singleflight для объединения запросов или построить блокировку на ключ во время загрузки.
Как работать с большими бинарными данными (BLOBs) через database/sql?
Краткий ответ
Для больших BLOB (большие тексты, изображения) лучше не читать целиком в память, а использовать потоковую обработку через io.Reader. В database/sql можно сканировать столбец в []byte, но для больших данных это неэффективно. Драйверы PostgreSQL поддерживают pgx.LargeObjects или Bytea с чанками. В целом, для гигабайтных файлов лучше хранить в объектом хранилище (S3), а в БД — ссылку.
Развёрнутый ответ
var data []byte
err := db.QueryRow("SELECT file_data FROM files WHERE id=$1", id).Scan(&data)
Для потоковой загрузки из Bytea можно через драйвер получить io.Reader, но стандартные средства ограничены. pgx позволяет сканировать в pgtype.Bytea, но лишь в memory. Большие данные следует передавать через COPY или chunked.
Для вставки:
_, err := db.Exec("INSERT INTO files(data) VALUES ($1)", largeBlob)
Но лучше сохранять файл на диск и передавать командой \lo_import.
Под капотом
Передача []byte через pq или pgx копирует весь слайс в память, что может вызвать OOM. pgx имеет LargeObjects API для работы с OID и потокового чтения/записи.
Типичные ошибки кандидатов
- Хранение файлов в BLOB без чанкирования
Объяснение: БД не предназначена для больших бинарных файлов, это замедляет бэкапы и запросы. - Чтение BLOB в
[]byteи обработка в памяти
Объяснение: При многопоточном доступе легко исчерпать RAM. Используйте потоковую передачу клиенту.
Возможные дополнительные вопросы интервьюера
Как потоково отдать BLOB клиенту через HTTP?
С помощью pgx открыть LargeObject, получить io.Reader и через io.Copy передать в http.ResponseWriter.
Что использовать для хранения миллиардов картинок?
Определённо объектное хранилище (S3/MinIO) с URL в БД.
Как обрабатывать sql.ErrTxDone?
Краткий ответ
sql.ErrTxDone возвращается при попытке использовать транзакцию после её завершения (commit или rollback). Это штатная ситуация, если вызывается defer tx.Rollback(). Нужно проверять: если ошибка равна sql.ErrTxDone, просто игнорировать, иначе обрабатывать. Обычно эту ошибку возвращает Rollback после успешного Commit.
Развёрнутый ответ
func doTx() error {
tx, _ := db.Begin()
defer func() {
if err := tx.Rollback(); err != nil && !errors.Is(err, sql.ErrTxDone) {
log.Println("rollback error:", err)
}
}()
// ... работа
return tx.Commit()
}
При двойном вызове Commit также будет ErrTxDone.
Под капотом
ErrTxDone — обычная sentinel-ошибка, определённая в пакете. Возникает, когда внутренняя структура *Tx уже marked as done.
Типичные ошибки кандидатов
- Логирование
sql.ErrTxDoneкак критической ошибки
Объяснение: Это повышает уровень тревоги. Нужно отфильтровывать. - Повторное использование транзакции после ошибки
Объяснение: ПослеCommit/Rollbackтранзакция завершена, все последующие вызовы будут возвращатьErrTxDone. Создавайте новую.
Возможные дополнительные вопросы интервьюера
Гарантирует ли defer tx.Rollback() безопасность при панике?
Да, defer выполнится, и транзакция откатится, если не была закоммичена. ErrTxDone поглощается.
Как database/sql управляет жизненным циклом соединения?
Краткий ответ
Жизненный цикл: открытие (db.conn), использование (Exec/Query), возврат в пул idle или закрытие. Соединение может быть уничтожено по истечении ConnMaxLifetime, ConnMaxIdleTime, при обнаружении фатальной ошибки драйвера либо принудительно при закрытии *sql.DB. Пул постоянно сканирует idle-соединения cleaner-ом. Занятое соединение освобождается при закрытии Rows или Stmt, либо после завершения транзакции.
Развёрнутый ответ
- Создание: через
driver.Driver.OpenилиConnector. - Валидация: если драйвер реализует
driver.Pinger, используетсяPingдля проверки здоровья. - Закрытие: при вызове
db.Close()все свободные соединения закрываются, ожидающие запросы терминируются; занятые соединения не обрываются до их возврата.
Под капотом
db.connector.Connect(ctx) вызывается при необходимости открыть новое соединение. Внутренняя структура driverConn содержит driver.Conn, finalClose метод. При db.Close() устанавливается флаг, и последний закрывающийся driverConn вызывает реальное освобождение ресурса.
Типичные ошибки кандидатов
- Вызов
db.Close()до завершения всех горутин
Объяснение: Это приводит к тому, что долгие запросы получат ошибку закрытого пула. Нужен graceful shutdown. - Не обрабатывать ошибку
driver.ErrBadConnв драйвере
Объяснение: Это сигнал для пула закрыть соединение; драйвер должен возвращать её, если соединение умерло.
Возможные дополнительные вопросы интервьюера
Что происходит при закрытии sql.DB с активными транзакциями?
Транзакции не прерываются сразу, но при завершении не смогут вернуть соединение в пул (пул уже закрыт). Рекомендуется дождаться завершения.
Как эволюция версий database/sql влияет на написание драйверов?
Краткий ответ
Ключевые вехи: Go 1.1 добавил поддержку подключений с таймаутами. Go 1.5 — поддержка driver.StmtQueryContext, ExecContext. Go 1.8 — контексты везде. Go 1.10 — driver.Connector для расширенной конфигурации соединений. Go 1.13 — NullTime и улучшенная поддержка time.Time. Поддержка RowsColumnTypeScanType и т.д. Современный драйвер должен реализовывать опциональные интерфейсы с контекстом, иначе database/sql эмулирует через закрытие соединения.
Развёрнутый ответ
Драйверы (pgx, mysql) активно используют Connector для передачи параметров, таких как таймауты, TLS. Поддержка SessionResetter для совместного использования сессий.
Под капотом
database/sql проверяет, реализует ли драйвер интерфейсы типа QueryerContext, и если нет, оборачивает в блокирующий вызов, который не может быть отменён через контекст, что неэффективно.
Типичные ошибки кандидатов
- Использование устаревших драйверов без контекстной поддержки
Объяснение: Нет возможности отменить запрос; приложение может зависнуть. - Неправильная реализация Connector в кастомном драйвере
Объяснение: Игнорирование контекста вConnectнарушает таймауты.
Возможные дополнительные вопросы интервьюера
Как обеспечить обратную совместимость драйвера?
Проверять версию Go и опциональные интерфейсы через compile-time asserts.
Как работать с несколькими активными *sql.Tx одновременно?
Краткий ответ
Технически можно иметь несколько транзакций из одного sql.DB, каждая из которых закрепляет за собой отдельное соединение. Это позволяет параллельную обработку. Однако нужно следить за дедлоками на уровне БД и не смешивать их. Каждый *sql.Tx независим, но должен использоваться только в одной горутине последовательно. Управляйте ими через отдельные переменные.
Развёрнутый ответ
tx1, _ := db.Begin()
tx2, _ := db.Begin()
// используем tx1 и tx2 параллельно, но каждую в своей горутине
Это возможно, пока MaxOpenConns >= 2. Каждая транзакция занимает одно соединение. Если соединений не хватит, Begin заблокируется.
Под капотом
Begin берёт соединение и удерживает его. Вторая транзакция требует ещё одно соединение. Пул управляет этим, предоставляя при наличии.
Типичные ошибки кандидатов
- Использование
tx1иtx2в разных горутинах без синхронизации
Объяснение:*sql.Txнебезопасен для одновременного использования, может возникнуть гонка. Каждая горутина должна иметь свою. - Превышение
MaxOpenConnsиз-за множества транзакций
Объяснение: Если открытых транзакций больше, чем MaxOpenConns, все блокируются в ожидании, что может привести к взаимной блокировке (если транзакциям нужны дополнительные соединения).
Возможные дополнительные вопросы интервьюера
Можно ли использовать одно соединение для двух последовательных транзакций?
Да, после Commit соединение возвращается в пул, и следующая транзакция может получить его снова. Но это не гарантировано.
Как настроить "connection draining" для graceful shutdown?
Краткий ответ
Connection draining — процесс мягкого завершения соединений без обрыва активных запросов. Для sql.DB нужно: запретить новые соединения/запросы (через middleware или маркер), дождаться завершения текущих (WaitGroup или счётчик), затем закрыть пул. Пул сам по себе не имеет встроенного draining. Обычно ловят сигнал ОС, устанавливают флаг closing, после чего новые запросы не принимаются, а активные заканчиваются в течение таймаута.
Развёрнутый ответ
var closing int32
func wrapDB(db *sql.DB) *DB {
return &DB{db: db}
}
func (w *DB) QueryContext(ctx context.Context, query string, args ...interface{}) (*sql.Rows, error) {
if atomic.LoadInt32(&closing) == 1 {
return nil, errors.New("shutting down")
}
return w.db.QueryContext(ctx, query, args...)
}
Дополнительно отслеживать количество активных запросов, чтобы дождаться их завершения. Альтернативно можно использовать server.Shutdown для HTTP, и отдельный WaitGroup для горутин, обращающихся к БД.
Под капотом
В sql.DB нет прямого draining API. Закрытие db.Close() немедленно закрывает свободные соединения и заставляет все ожидающие горутины вернуть ошибку.
Типичные ошибки кандидатов
- Игнорирование ожидания завершения запросов
Объяснение: Закрытие пула убивает долгие запросы, что может вызвать ошибки у клиентов. Нужен дедлайн ожидания. - Продолжение приёма новых запросов после сигнала shutdown
Объяснение: Это бесконечно затягивает остановку.
Возможные дополнительные вопросы интервьюера
Как совместить draining с Kubernetes readiness probe?
При получении SIGTERM снимаете readiness (пробой начинает фейлиться), трафик перестаёт идти, затем ждёте завершения незаконченных запросов.
В чем разница между Exec, Query, QueryRow с точки зрения использования соединений?
Краткий ответ
Exec: выполняет запрос без возврата строк; возвращаетResult. Соединение используется до завершения выполнения, затем отпускается сразу (если нетRows).Query: возвращает*Rows, соединение удерживается, пока все строки не будут прочитаны илиRows.Close()не вызван. Необходимо закрывать.QueryRow: выполняется какQuery, но ожидается одна строка. Внутренне оборачиваетQuery, закрываетRowsпослеScan. Соединение удерживается доScan.
Развёрнутый ответ
QueryRow можно использовать без явного закрытия — он сам закроет Rows, но ошибка отложена до Scan. Exec быстрее завершает соединение.
Под капотом
Все они вызывают db.conn(ctx), чтобы получить driverConn, затем делегируют драйверу. Query запоминает rows и после закрытия возвращает соединение в пул. Если rows не закрыты, соединение висит.
Типичные ошибки кандидатов
- Забытый
rows.Close()приQuery
Объяснение: Соединение не возвращается, пока rows не закрыты или не прочитаны до конца. - Предположение, что
QueryRowвозвращает ошибку сразу
Объяснение: Нужно проверить ошибку послеScan.
Возможные дополнительные вопросы интервьюера
Можно ли использовать Exec для запросов, возвращающих данные?
Нет, Exec не поддерживает возврат строк. Попытка приведёт к ошибке драйвера.
Как database/sql работает с драйверами, которые реализуют Pinger?
Краткий ответ
Интерфейс driver.Pinger (метод Ping(context.Context) error) позволяет пулу проверять здоровье соединения. При извлечении соединения из idle-пула database/sql может вызвать Ping, чтобы убедиться, что оно живо. Если Ping возвращает ошибку, соединение закрывается, и берётся следующее. Это повышает надёжность, особенно после сетевых сбоев. Многие драйверы (pgx, pq) реализуют Pinger.
Развёрнутый ответ
Проверка происходит не при каждом запросе, а по эвристике (если соединение было idle больше секунды). Пользовательский db.PingContext() также использует Pinger, если доступен.
Под капотом
При вызове conn(ctx) пул проверяет needReset и время последнего использования. Если соединение «старое», запускается conn.ping(ctx), который делегирует Ping, если драйвер реализует, иначе просто Exec пустого запроса. Если Ping возвращает ErrBadConn, соединение уничтожается.
Типичные ошибки кандидатов
- Реализация Pinger без реальной проверки соединения
Объяснение: ЕслиPingвсегда возвращает nil, мёртвые соединения не будут обнаружены, что приведёт к ошибкам при первом запросе. - Настройка слишком частой проверки через снижение времени idle
Объяснение: Вызывает нагрузку на БД. Лучше положиться наConnMaxLifetime.
Возможные дополнительные вопросы интервьюера
Как database/sql обрабатывает ситуацию, когда драйвер не имеет Pinger?
Использует простой запрос ; или SELECT 1. Это менее эффективно, так как проходит весь путь запроса.
Как реализовать "sharding" на уровне приложения с помощью нескольких sql.DB?
Краткий ответ
Шардирование на уровне приложения: клиент знает шард для каждого ключа (по диапазону ID, хэшу). Создаются отдельные *sql.DB для каждого шарда, хранятся в карте или слайсе. Маршрутизация при каждом запросе: getShard(key).Query(...). Для кросс-шардовых запросов необходимо агрегировать результаты вручную. Перебалансировка шардов решается виртуальными шардами или consistent hashing.
Развёрнутый ответ
type ShardCluster struct {
shards map[string]*sql.DB
}
func (s *ShardCluster) Shard(userID int) *sql.DB {
shardIdx := userID % len(s.shards)
return s.shards[strconv.Itoa(shardIdx)]
}
Ключевые моменты: миграции на каждом шарде; транзакции внутри одного шарда.
Под капотом
Это архитектурное решение, database/sql не участвует в шардировании.
Типичные ошибки кандидатов
- Жёсткое соответствие шарда числу экземпляров БД
Объяснение: При добавлении шарда потребуется перехеширование всех данных. Используйте виртуальные шарды (много виртуальных на физический). - Кросс-шардовые join-ы
Объяснение: Это сложно и медленно в приложении. Лучше избегать, проектируя схему без связей через шарды.
Возможные дополнительные вопросы интервьюера
Как обрабатывать отказ шарда?
Circuit breaker на конкретный шард, реплики для чтения, резервный механизм.
Как контролировать "staleness" данных при чтении с реплик (replication lag)?
Краткий ответ
Staleness контролируется через:
- измерение лага (pg_last_wal_replay_lsn), сравнение с LSN записи;
- отправка запросов, требующих актуальности, на мастер;
- использование таймаута: если lag < порога, разрешить чтение с реплики;
- для критичных операций после записи — чтение с мастера;
- использование синхронной репликации в PostgreSQL гарантирует отсутствие лага для фиксации.
Развёрнутый ответ
if operation.isWrite {
useMaster()
return
}
lag := getReplicaLag()
if lag < maxAcceptableLag {
useReplica()
} else {
useMaster()
}
Также можно сохранять LSN после записи и ждать, пока реплика догонит.
Под капотом
В PostgreSQL запросить SELECT replay_lag FROM pg_stat_replication или функцию pg_last_wal_replay_lsn().
Типичные ошибки кандидатов
- Игнорирование staleness для операций чтения своих записей
Объяснение: Пользователь после создания записи сразу её не видит. - Ориентация только на временной лаг, без учёта размера WAL
Возможные дополнительные вопросы интервьюера
Как гарантировать свежесть без мастера?
Ждать, пока реплика применит WAL до нужного LSN, но это может задержать ответ.
Что такое "connection pool" с точки зрения безопасности потоков в sql.DB?
Краткий ответ
Пул соединений sql.DB полностью потокобезопасен. Все публичные методы защищены внутренним мьютексом. Горутины могут безопасно вызывать Exec, Query, Begin параллельно. sql.Tx не является потокобезопасным. Пул использует блокировки для согласования доступа к свободным соединениям, но минимизирует время удержания.
Развёрнутый ответ
Благодаря потокобезопасности один *sql.DB разделяется между всеми обработчиками HTTP-запросов. Внутри пул использует sync.Mutex, а не RWMutex, поскольку операции короткие. Избегайте копирования sql.DB.
Под капотом
Структура DB содержит mu sync.Mutex, freeConn []*driverConn, connRequests map[uint64]chan connRequest. При conn() горутина захватывает мьютекс, проверяет свободные соединения, при необходимости встаёт в очередь и отпускает мьютекс, ожидая на своём канале.
Типичные ошибки кандидатов
- Создание
*sql.DBна каждый запрос
Объяснение: Каждый экземпляр имеет свой пул; это лишние соединения. Глобальный один экземпляр. - Игнорирование
MaxOpenConnsв конкурентной среде
Объяснение: Без лимита может открыться тысячи соединений, что превысит лимиты БД.
Возможные дополнительные вопросы интервьюера
Как sql.DB обеспечивает fairness при ожидании соединений?
Использует очередь запросов, не гарантирует строгий FIFO, но порядок сохраняется примерно.
Как реализовать Circuit Breaker для базы данных?
Краткий ответ
Circuit Breaker — паттерн, предотвращающий каскадные отказы. При превышении порога ошибок запросы к БД временно не выполняются, сразу возвращая ошибку. Реализуется с помощью библиотек (sony/gobreaker, afex/hystrix-go) или вручную. Обычно оборачивают вызовы db.ExecContext/QueryContext. Может быть один на весь sql.DB или раздельный для каждого шарда/реплики.
Развёрнутый ответ
var cb = gobreaker.NewCircuitBreaker(gobreaker.Settings{Name: "DB", Timeout: 5*time.Second, ReadyToTrip: func(counts gobreaker.Counts) bool {
return counts.ConsecutiveFailures > 5
}})
func queryWithBreaker(ctx context.Context, db *sql.DB, query string, args ...interface{}) error {
_, err := cb.Execute(func() (interface{}, error) {
return nil, db.QueryRowContext(ctx, query, args...).Scan(...)
})
return err
}
При разрыве цепи все запросы возвращают gobreaker.ErrOpenState.
Под капотом
Библиотеки используют атомарные счётчики и таймеры.
Типичные ошибки кандидатов
- Применение Circuit Breaker без различения transient ошибок и бизнес-ошибок
Объяснение: Обычныйsql.ErrNoRowsне должен триггерить разрыв. Фильтруйте ошибки. - Слишком агрессивные настройки (малый порог)
Объяснение: Всплеск ошибок из-за таймаута сети приведёт к разрыву, хотя БД могла быстро восстановиться.
Возможные дополнительные вопросы интервьюера
Как протестировать Circuit Breaker?
Используя sqlmock, имитируя серию ошибок и проверяя, что дальнейшие запросы не доходят до mock'а.
Что делать, когда цепь разомкнута?
Возвращать fallback-ответ (например, данные из кэша) или ошибку с HTTP 503.