20 вопросов
INNER JOIN - только совпадающие строки; LEFT JOIN - все из левой + совпадения справа (NULL при отсутствии); RIGHT JOIN, FULL OUTER JOIN, CROSS JOIN. В Go пишут сырой SQL или через builder (squirrel, goqu). Параметры через плейсхолдеры $1, $2 (Postgres) или ? (MySQL), передают в db.QueryContext, db.ExecContext.
rows, err := db.QueryContext(ctx,
"SELECT u.name, o.amount FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE u.id = $1", id)WHERE фильтрует строки до группировки и не может использовать агрегатные функции. HAVING фильтрует группы после GROUP BY и может использовать COUNT, SUM, AVG и т.д. Порядок выполнения: FROM - WHERE - GROUP BY - агрегаты - HAVING - ORDER BY - LIMIT.
SELECT department_id, COUNT(*) FROM employees
WHERE salary > 50000 GROUP BY department_id HAVING COUNT(*) > 5COUNT, SUM, AVG, MIN, MAX - стандартные. В Postgres: STRING_AGG для склейки строк. Результат сканируют в переменные через row.Scan или rows.Scan. Для одной строки - db.QueryRowContext. Агрегаты без GROUP BY возвращают одну строку.
var count int
err := db.QueryRowContext(ctx, "SELECT COUNT(*) FROM users WHERE active = $1", true).Scan(&count)GROUP BY группирует строки по одинаковым значениям указанных столбцов. В SELECT допустимы только столбцы из GROUP BY или агрегатные функции. Иначе СУБД выдает ошибку (или неоднозначность в MySQL без ONLY_FULL_GROUP_BY). В Go запросы с GROUP BY пишут как обычный SQL, сканируют в структуры или переменные.
SELECT status, COUNT(*) FROM orders GROUP BY statusПодзапрос - SELECT внутри другого запроса (в WHERE, FROM, SELECT). IN (SELECT ...), EXISTS (SELECT 1 ...), скалярный подзапрос в SELECT. В Go передают как одну строку SQL с плейсхолдерами; вложенность и параметры задают аккуратно. Коррелированный подзапрос выполняется для каждой строки внешнего запроса - может быть медленным.
rows, err := db.QueryContext(ctx,
"SELECT * FROM users WHERE id IN (SELECT user_id FROM orders WHERE total > $1)", minTotal)Выполняют EXPLAIN (ANALYZE) SELECT ... и читают результат как обычный запрос. В коде можно включить логирование запросов (pgx с логгером) или выполнять EXPLAIN вручную при отладке. План показывает тип доступа (seq scan, index scan), оценку строк, стоимость. Важно для поиска полных сканов и отсутствующих индексов.
rows, _ := db.QueryContext(ctx, "EXPLAIN ANALYZE SELECT * FROM users WHERE email = $1", email)
// разбор вывода планаОконные функции (ROW_NUMBER, RANK, SUM OVER (PARTITION BY ... ORDER BY ...)) вычисляют значение по "окну" строк без схлопывания в одну. Поддерживаются в Postgres, MySQL 8+, SQL Server. В Go пишут сырой SQL с оконными функциями, результат сканируют в структуры; иногда нужны отдельные поля для номера строки и агрегата по окну.
SELECT id, name, ROW_NUMBER() OVER (PARTITION BY dept_id ORDER BY salary DESC) AS rn FROM employeesCTE - именованное временное множество: WITH name AS (SELECT ...) SELECT ... FROM name. Улучшает читаемость, позволяет рекурсию (WITH RECURSIVE). В Go передают один большой запрос с WITH; параметры - через плейсхолдеры. Удобно для многошаговых выборок и рекурсивных структур (деревья).
q := "WITH recent AS (SELECT * FROM orders WHERE created_at > $1) SELECT u.name, COUNT(r.id) FROM users u LEFT JOIN recent r ON u.id = r.user_id GROUP BY u.id, u.name"
rows, err := db.QueryContext(ctx, q, since)Prepared statement - запрос разобран один раз, параметры подставляются безопасно. Защита от SQL-инъекций и переиспользование плана. В Go: db.PrepareContext(ctx, "SELECT ... WHERE id = $1"), затем stmt.QueryContext или ExecContext с аргументами. sql.DB по умолчанию кеширует подготовленные запросы. Не подставлять пользовательский ввод в строку запроса - только через аргументы.
stmt, err := db.PrepareContext(ctx, "SELECT name FROM users WHERE id = $1")
defer stmt.Close()
var name string
err = stmt.QueryRowContext(ctx, id).Scan(&name)VIEW - именованный запрос (виртуальная таблица). Данные не хранятся отдельно, при обращении выполняется лежащий SELECT. В Go обращаются к VIEW как к таблице: SELECT * FROM active_users WHERE .... Материализованное представление (MATERIALIZED VIEW) хранит результат и обновляется по расписанию - чтение быстрее, данные могут быть не самыми свежими.
rows, err := db.QueryContext(ctx, "SELECT * FROM active_users LIMIT 10")Триггер - процедура в БД, выполняемая при INSERT/UPDATE/DELETE. Используют для аудита, денормализации, проверок. В приложении на Go логику часто выносят в код (события, очереди), чтобы тестировать и версионировать. Триггеры удобны для гарантированной консистентности на уровне БД и для legacy-систем.
-- в БД: CREATE TRIGGER ... AFTER UPDATE ON orders FOR EACH ROW EXECUTE ...Foreign key гарантирует ссылочную целостность: значение столбца должно существовать в другой таблице. ON DELETE/UPDATE: CASCADE, SET NULL, RESTRICT. В Go миграции (goose, golang-migrate) создают ограничения; приложение получает ошибки при нарушении (например, вставка несуществующего user_id). Индекс на столбце FK улучшает производительность JOIN и CASCADE.
ALTER TABLE orders ADD CONSTRAINT fk_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADEPRIMARY KEY (уникальность + NOT NULL), UNIQUE, NOT NULL, CHECK (условие на значение), FOREIGN KEY, DEFAULT. В Go схему задают миграциями; при нарушении СУБД возвращает ошибку, ее обрабатывают в коде. CHECK полезен для статусов, диапазонов (price >= 0).
CHECK (status IN ('draft','paid','shipped'))
CHECK (price >= 0)NULL - отсутствие значения. Сравнение с NULL дает NULL (не true/false): нужны IS NULL, IS NOT NULL. Агрегаты игнорируют NULL. В Go сканируют в sql.NullString, sql.NullInt64 или указатели (*string). COALESCE(col, default) в SQL подставляет значение по умолчанию. При маппинге в структуры удобны типы с указателями или sql.Null*.
var name sql.NullString
err := row.Scan(&name)
if name.Valid { use(name.String) }OFFSET заставляет БД "пройти" первые N строк, поэтому при больших OFFSET запрос замедляется. Для пагинации предпочтительна курсорная: WHERE id > :last_id ORDER BY id LIMIT 20. В Go хранят last_id с предыдущей страницы и передают в запрос. Стабильная производительность и нет "прыжков" при появлении новых строк.
rows, err := db.QueryContext(ctx,
"SELECT * FROM orders WHERE id > $1 ORDER BY id LIMIT $2", lastID, pageSize)Вместо номера страницы используют курсор - значение ключа последней полученной строки. Следующая страница: WHERE (created_at, id) < (:last_created, :last_id) ORDER BY created_at DESC, id DESC LIMIT 20. В Go передают last_created и last_id с предыдущего ответа. Нельзя перейти на произвольную страницу по номеру, только вперед/назад. Подходит для бесконечной ленты и API.
q := "SELECT * FROM events WHERE (created_at, id) < ($1, $2) ORDER BY created_at DESC, id DESC LIMIT $3"
rows, _ := db.QueryContext(ctx, q, lastCreated, lastID, limit)В Postgres 11+ ADD COLUMN с DEFAULT без переписывания таблицы (для не-volatile default). Без DEFAULT или с NOT NULL может потребоваться полное переписывание. Подход: добавить колонку nullable без default, заполнить батчами в фоне, затем добавить NOT NULL/default. В Go миграции делают по шагам; для больших таблиц используют CREATE INDEX CONCURRENTLY и избегают долгих блокировок.
-- шаг 1: ADD COLUMN new_col TYPE NULL
-- шаг 2: батчевое обновление в приложении
-- шаг 3: ALTER COLUMN new_col SET NOT NULL1) EXPLAIN ANALYZE - план и фактические строки. 2) Искать full scan - добавить индекс под WHERE/ORDER BY/JOIN. 3) Уменьшить выборку (нужные столбцы), убрать лишние JOIN. 4) Разбить запрос (CTE, батчи). 5) Обновить статистику (ANALYZE в Postgres). 6) В коде: пул соединений, таймауты, избегать N+1 (один запрос с IN или JOIN вместо цикла запросов).
// вместо N+1:
ids := extractIDs(users)
rows, _ := db.QueryContext(ctx, "SELECT * FROM orders WHERE user_id = ANY($1)", pq.Array(ids))N+1: один запрос за списком сущностей и N запросов за связями (например, заказы по каждому пользователю). Решение: один запрос с JOIN или два запроса - список id и один SELECT ... WHERE id IN (...). В Go собирают id, затем db.QueryContext(ctx, "SELECT ... WHERE user_id = ANY($1)", pq.Array(ids)) и мапят по user_id. ORM (GORM) решают через Preload.
users, _ := getUsers(ctx, db)
ids := userIDs(users)
orders, _ := getOrdersByUserIDs(ctx, db, ids)
byUser := groupOrdersByUserID(orders)LATERAL позволяет подзапросу в FROM ссылаться на столбцы предыдущих таблиц в том же FROM; подзапрос выполняется для каждой строки. Удобно для "top-N по группе" без оконных функций. В Go пишут обычный SQL с LATERAL.
q := "SELECT u.name, o.* FROM users u, LATERAL (SELECT * FROM orders WHERE user_id = u.id ORDER BY created_at DESC LIMIT 5) o"
rows, err := db.QueryContext(ctx, q)