WHERE фильтрует строки до группировки, а HAVING фильтрует группы после того, как посчитаны агрегаты. INNER JOIN оставляет только строки, у которых нашлась пара с обеих сторон, а LEFT JOIN сохраняет каждую строку левой таблицы и заполняет правую сторону NULL.
**Агрегатам место в HAVING**: WHERE count(*) > 5 даёт ошибку: когда выполняется WHERE, групп ещё нет. Все неагрегатные условия пишите в WHERE, тогда до группировки доходит меньше строк.
**WHERE по правой таблице отменяет LEFT JOIN**: WHERE o.status = 'paid' выкидывает строки с NULL, и запрос молча превращается во внутреннее соединение. Такое условие ставьте в ON.
**После LEFT JOIN нужен count(o.id), а не count(*)**: count(*) считает строку с NULL и показывает 1 для клиента без заказов.
GoMiddle
Базы данных и SQL в Go
Пул database/sql, транзакции, NULL-значения, pgx, миграции
Закрыто вопросов
0 / 24
- 0Повторить
- 2Часто спрашивают
Вопросы
**sql.Open вообще не ходит в сеть.** Он создаёт *sql.DB (пул, в котором поначалу ноль соединений), а первое соединение открывает ваш первый запрос или Ping.
*sql.DB является пулом, а не соединением, и им безопасно пользоваться из многих горутин. Откройте его один раз в main и передавайте дальше.
Ошибку он вернёт только на неизвестное имя драйвера (забытый пустой импорт вроде _ "github.com/jackc/pgx/v5/stdlib") или на DSN, который не удалось разобрать, но никогда на мёртвый хост.
db.PingContext(ctx) с коротким таймаутом открывает одно настоящее соединение, поэтому битая конфигурация падает на старте, а не на первом запросе.
sql.Open на каждый запрос выбрасывает пул и каждый раз платит за новое рукопожатие с базой; без Close он ещё и оставляет соединения висеть.
Exec нужен для запросов, которые не возвращают строк, Query для набора строк, а QueryRow для не более чем одной строки. DELETE через Query держит соединение из пула, пока не закрыт *sql.Rows (а его никто не закрывает), поэтому каждый вызов теряет соединение, пока пул не опустеет.
**Exec**: INSERT, UPDATE, DELETE, DDL. Он отпускает соединение до возврата, а sql.Result даёт RowsAffected(). Драйверы Postgres не поддерживают LastInsertId: добавьте RETURNING id и используйте QueryRow.
**Query**: строки, которыми владеете вы: defer rows.Close() строкой ниже проверки ошибки, затем цикл и проверка rows.Err().
**QueryRow**: Scan сам закрывает строку, а пустой результат приходит как sql.ErrNoRows.
Утечку выдаёт db.Stats(): InUse застыл на максимуме, а WaitCount растёт.
**QueryRow сам не возвращает ошибку, поэтому откладывает её, и Scan возвращает сентинел sql.ErrNoRows.** Переведите его один раз, на границе репозитория, в собственный ErrUserNotFound через errors.Is.
Проверка: if errors.Is(err, sql.ErrNoRows) { return ErrUserNotFound }; хендлер отдаёт на это 404, а слои выше никогда не импортируют ошибки database/sql.
Именно errors.Is, а не ==: промежуточные слои оборачивают ошибку через %w.
Её даёт только QueryRow(...).Scan. Пустой результат Query не считается ошибкой: rows.Next() просто вернёт false.
COUNT(*) всегда возвращает строку, поэтому сентинела оттуда не будет: там отсутствие строки считается обычным исходом, а ноль и есть ответ.
**В Go-шном string нет места для NULL**, поэтому Scan падает с converting NULL to string is unsupported. Сканируйте в указатель или в sql.Null[T], либо убирайте NULL прямо в SQL через COALESCE.
***string**: драйвер кладёт nil, если пришёл NULL, и в JSON это превращается в null.
**sql.NullString, sql.NullTime**: значение плюс флаг Valid; обобщённый sql.Null[T] (Go 1.22) подходит для любого типа.
Запись NULL: передайте аргументом nil-указатель или sql.Null[T] с Valid: false, и драйвер отправит NULL.
**COALESCE(discount, 0)** делает колонку ненулевой в этом запросе, и только этот вариант меняет смысл данных: NULL-скидка, ставшая 0, читается как «скидки нет», а не «неизвестно».
**После цикла не хватает rows.Err().** Next возвращает false и когда строки кончились, и когда обход сломался, поэтому соединение, оборвавшееся на 4000-й строке, выглядит ровно как нормальное завершение.
Строки приходят пачками прямо во время обхода, поэтому сетевой сбой или серверный таймаут случается внутри цикла, а не в QueryContext.
Ошибка лежит на самом *sql.Rows, и узнать о ней можно только через rows.Err(). Ошибка Scan означает другое: одну строку, которую не удалось преобразовать.
Форма, которая верна всегда: проверить ошибку запроса, defer rows.Close(), проверять Scan внутри цикла, а после него добавить if err := rows.Err(); err != nil.
Заодно: проигнорированная ошибка fmt.Fprintf точно так же прячет переполненный диск.
Линтер rowserrcheck в golangci-lint находит все такие места; go vet этого не проверяет.
ACID: транзакция выполняется целиком или никак, сохраняет ограничения, изолирована от параллельных и после коммита переживает падение. Уровни изоляции меняют аномалии на откаты: Read Committed допускает больше всего, а Serializable не допускает ни одной.
ACID
Atomicity (атомарность): коммитятся все запросы или ни один; сбой посередине откатывает всё.
Consistency (согласованность): ограничения, внешние ключи и проверки выполняются до и после каждой транзакции.
Isolation (изоляция): параллельные транзакции не видят недоделанную работу друг друга; уровень говорит, насколько строго.
Durability (долговечность): как только COMMIT вернулся, изменение лежит в журнале предзаписи (WAL) и переживёт падение.
Уровни и аномалии
Read Committed (по умолчанию в Postgres) показывает каждому запросу то, что было закоммичено до его начала. Допускает неповторяющееся чтение, фантомы и потерянные обновления: два цикла «прочитал, изменил, записал» могут затереть друг друга.
Repeatable Read (по умолчанию в MySQL InnoDB): один снимок на всю транзакцию, поэтому неповторяющегося чтения нет. Стандарт SQL на этом уровне допускает фантомы; снимки Postgres скрывают и их, и конфликтующее обновление откатывают с 40001, а не теряют, но перекос записи (write skew) остаётся.
Serializable: результат совпадает с каким-то последовательным порядком. Postgres распознаёт опасные шаблоны и откатывает одну из транзакций с 40001, поэтому вызывающий код обязан повторять.
Грязного чтения в Postgres не бывает: Read Uncommitted ведёт себя как Read Committed.
На практике
Write skew: два врача проверяют, что дежурит кто-то другой, и оба уходят с дежурства. Каждое чтение было верным; остановит это только Serializable или явная блокировка.
В Go: уровень выбирается на транзакцию: db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true}) для согласованного отчёта.
Индекс позволяет найти строки, не читая всю таблицу; B-tree служит индексом по умолчанию для равенства, диапазонов и ORDER BY. Составной индекс (a, b) обслуживает запросы, которые фильтруют сначала по a, а EXPLAIN ANALYZE показывает, использовался ли он и куда ушло время.
Типы
B-tree: =, <, BETWEEN, ORDER BY, LIKE 'abc%' (при не-C коллации индексу нужен text_pattern_ops). Правильный выбор в большинстве случаев.
GIN: много значений в одной строке: jsonb, массивы, полнотекстовый поиск, LIKE '%abc%' с pg_trgm.
GiST: для геометрии и диапазонов, BRIN подходит для огромных append-only таблиц, упорядоченных по времени, Hash годится только для равенства.
Частичные (WHERE status = 'pending') и покрывающие (INCLUDE (total), для index-only scan) индексы держат индекс маленьким или избавляют от чтения таблицы.
Составные индексы
Записи отсортированы по первой колонке, затем по второй, как в телефонной книге. (user_id, created_at) обслуживает user_id = ? и user_id = ? AND created_at > ?, но не одно created_at > ?. Сначала идут колонки с равенством, а последней идёт колонка диапазона или сортировки.
В Postgres 18 появился skip scan: он может использовать такой индекс без ведущей колонки, если у неё мало различных значений. Проектировать в расчёте на него не стоит.
Каждый индекс замедляет каждую запись. Удаляйте те, у которых pg_stat_user_indexes.idx_scan остаётся нулём.
Как читать EXPLAIN ANALYZE
Он выполняет запрос, поэтому UPDATE или DELETE оборачивайте в BEGIN ... ROLLBACK.
Читайте от самого вложенного узла наружу; actual time указан на один проход, так что умножайте на loops.
Тревожные признаки: Seq Scan по большой таблице, который возвращает мало строк, большое Rows Removed by Filter и оценка строк, далёкая от фактической (устаревшая статистика, лечится ANALYZE).
Ранний return после первого ExecContext не откатывает транзакцию, и она продолжает держать соединение из пула и блокировки строк; а ошибка tx.Commit() проигнорирована, поэтому неудавшийся коммит выдаётся за оформленный заказ.
Утечка: открытый *sql.Tx владеет соединением до Commit или Rollback. database/sql откатывает его, когда отменяется ctx, переданный в BeginTx, поэтому с контекстом запроса транзакция висит до конца запроса, а с context.Background() висит вечно. После нескольких таких случаев пул пуст.
Коммит: именно в Commit Postgres сообщает об отложенном ограничении, ошибке сериализации (40001) или оборванном соединении. Игнорируя его ошибку, вы рапортуете об успехе для заказа, которого нет.
Исправление в две строки: defer tx.Rollback() сразу после BeginTx покрывает все пути с ошибкой и панику; после успешного коммита это no-op, возвращающий sql.ErrTxDone. И затем return tx.Commit().
Значения передаются плейсхолдерами, а идентификаторы нет. $1 делает текст поиска безопасным, а имя колонки в ORDER BY безопасно, только если вы выбираете его из собственного белого списка.
Почему плейсхолдер работает: это не экранирование. Текст SQL и значение уходят разными сообщениями протокола: сервер разбирает запрос до того, как увидит значение, и q = "x' OR 1=1 --" остаётся просто строкой. Это про расширенный протокол; в simple-протоколе или при клиентской интерполяции драйвер экранирует сам; это безопасно, но механизм другой.
Идентификаторы подставить нельзя: имена таблиц, колонок и ASC/DESC. Сопоставляйте слова API с SQL сами: map[string]string{"newest": "created_at DESC"}, а на всё, чего нет в карте, отвечайте 400.
**% и _ в q** по-прежнему работают как шаблоны LIKE. Это не инъекция, но если пользователи должны искать эти символы буквально, их нужно экранировать.
Маркер зависит от драйвера: $1 в Postgres, ? в MySQL и SQLite.
SetMaxOpenConns ограничивает число соединений (по умолчанию без лимита), SetMaxIdleConns держит часть из них открытыми между всплесками (по умолчанию 2), а SetConnMaxLifetime их пересоздаёт. Лимит подбирайте из бюджета соединений базы, общего на все поды, и проверяйте через db.Stats().
По умолчанию лимита нет: на пике каждая горутина открывает свой бэкенд, пока Postgres не ответит sorry, too many clients already. Лимит защищает базу: max_connections делят все поды, кроны и тот, у кого открыт psql.
Слишком низкий лимит создаёт очередь внутри Go: p99 растёт, а база простаивает. Это время видно в Stats().WaitCount и WaitDuration; ни одна метрика базы его не покажет.
**MaxIdleConns меньше MaxOpenConns**: пул закрывает соединения и переподключается между всплесками; выдаёт это растущий Stats().MaxIdleClosed. Обычно их делают равными.
**SetConnMaxIdleTime должен быть короче любого idle-таймаута прокси или балансировщика, чтобы не выдавалось уже закрытое им соединение. SetConnMaxLifetime пересоздаёт соединения по возрасту: подхватывает смену DNS и failover и укладывается в максимальный возраст соединения, который задаёт прокси или сервер.
Прикидка**: пропускная способность примерно равна числу соединений, делённому на среднее время запроса: десять соединений по 5 мс дают около 2000 запросов в секунду. Если соединений сильно больше числа ядер базы, новые соединения добавляют конкуренцию, а не скорость.
С QueryContext отмена заставляет database/sql перестать ждать, а драйвер попросить Postgres отменить запрос; с обычным Query ничего не подключено, и запрос продолжает работать. Отмена срабатывает не гарантированно, поэтому statement_timeout служит серверным потолком, который не потеряется.
Что делает драйвер: pgx прерывает чтение, отправляет запрос отмены по отдельному соединению и закрывает занятое, так что пул теряет это соединение. lib/pq тоже отправляет запрос отмены.
Бэкенд останавливается в ближайшей точке прерывания, поэтому запрос, застрявший в сортировке, сперва доделывает этот шаг. Без отмены Postgres замечает пропавшего клиента только при попытке отправить строки или через client_connection_check_interval (Postgres 14+).
Давайте каждому запросу свой бюджет: context.WithTimeout(ctx, 500*time.Millisecond); ожидание свободного соединения из пула входит в него.
**Задайте statement_timeout** на роль или в DSN, чтобы погиб и запрос, чья отмена потерялась, и запрос из инструмента, где контекста нет вовсе.
Пусть функции репозитория принимают маленький интерфейс, которому удовлетворяют и *sql.DB, и *sql.Tx, а у сервиса будет один InTx(ctx, func(q DBTX) error), который начинает, коммитит и откатывает транзакцию. Границу задаёт сервис, но сам *sql.Tx он никогда не трогает.
**Почему не передать *sql.DB**: хелпер, принимающий *sql.DB, внутри транзакции работает на втором соединении и вне её. Он не видит незакоммиченных строк транзакции и встаёт на строках, которые она заблокировала.
Дедлок пула: каждому запросу теперь нужно два соединения. При MaxOpenConns(n) n транзакций, каждая из которых ждёт второе соединение, намертво вешают сервис, и детектор дедлоков базы этого не видит.
**DBTX**: ExecContext, QueryContext, QueryRowContext. Его реализуют *sql.DB, *sql.Tx и *sql.Conn, и sqlc генерирует DBTX такой же формы. Теперь система типов не даст хелперу выйти за пределы транзакции.
**Транзакция в context.Context**: в некоторых кодовых базах tx кладут в ctx, и репозитории достают его оттуда. Это работает, но зависимость невидима, а вызов, потерявший ctx, молча выполняется вне транзакции.
database/sql предоставляет стандартный API с пулом и Scan; pgx является драйвером Postgres с более богатым собственным API; sqlx убирает рутину со Scan; sqlc генерирует типизированный Go из написанного вами SQL; GORM пишет SQL за вас. Для Postgres обычный выбор по умолчанию: pgx вместе с sqlc.
**pgx**: работает через database/sql (пакет stdlib) или напрямую с pgxpool. Нативный режим добавляет CopyFrom, батчи, LISTEN/NOTIFY, массивы и jsonb без обёрток и кэш подготовленных запросов на каждом соединении. lib/pq по-прежнему работает, но новый код берёт pgx.
**sqlx**: Get, Select, NamedExec и In поверх того же *sql.DB. Маппинг в структуры идёт по тегам db:"..." и ломается в рантайме, а не при компиляции.
**sqlc**: вы пишете .sql-файлы, а он генерирует методы, сверенные со схемой, поэтому переименованная колонка ломает сборку. Платой за это становятся шаг генерации и неудобные динамические запросы, например с необязательными фильтрами.
GORM: быстрее всего пишется для CRUD-экранов. Платой за это становятся запросы, которые никто не ревьюил: preload, превращающийся в N+1, условия soft delete, дописанные в каждый WHERE.
Чего не меняет ни один из них: размер пула, контексты, границы транзакций и работа с NULL остаются на вас.
N+1 означает один запрос за списком, а затем ещё по одному запросу на каждый элемент внутри цикла. Каждый запрос быстрый; дорого обходятся round trip'ы: 100 заказов дают 101 последовательный поход в базу, и по 2 мс на каждый выходит 200 мс.
База выглядит невиновной: каждый вызов быстрый, план хороший. Время уходит на сеть и протокол, по разу на итерацию, потому что каждый запрос ждёт предыдущий.
Два запроса вместо N+1: выберите родителей, соберите их id, выполните SELECT ... WHERE order_id = ANY($1), сложите результат в map[int64][]Item и разложите детей за один проход. Два запроса при любом размере страницы.
**JOIN: один round trip вместо двух, но колонки родителя приходят по разу на каждого ребёнка: плохой размен, если родитель широкий или дочерних коллекций две.
Почему это проходит ревью**: проблема растёт вместе с размером страницы, и тестовая фикстура из пяти строк её не показывает. Следите за числом запросов на один запрос к API в трейсах.
INSERT на каждую строку тратит время на 200 000 round trip'ов, а не на работу базы. Для массовой загрузки берите COPY (CopyFrom в pgx) или многострочные INSERT пачками (у них потолок 65 535 параметров на запрос).
**COPY передаёт все строки одним потоком в одном запросе, без разбора и планирования на каждую строку. Выигрыш на порядок по сравнению с построчными вставками считается обычным делом.
ON CONFLICT он не умеет.** Обычная схема: COPY во временную таблицу, а затем один INSERT ... SELECT ... ON CONFLICT DO UPDATE для слияния.
Потолок параметров: на трёх колонках это около 21 845 строк на INSERT, а прирост скорости и так выполаживается на пачках в 1000–5000 строк.
**unnest** обходит потолок: INSERT ... SELECT * FROM unnest($1::bigint[], $2::text[], $3::timestamptz[]) передаёт три параметра-массива для любого числа строк.
Пессимистичная блокировка берёт лок через SELECT ... FOR UPDATE, и остальные ждут; оптимистичная ничего не блокирует и ловит параллельное изменение при записи по колонке version. FOR UPDATE SKIP LOCKED превращает таблицу в очередь задач: воркер берёт строки, которые никто не держит.
Оптимистичная: UPDATE ... SET version = version + 1 WHERE id = $1 AND version = $2; ноль затронутых строк значит, что кто-то сохранил раньше, поэтому перечитайте или ответьте 409. Лучше всего подходит, когда конфликты редки или правка растягивается на время, пока пользователь думает (держать лок столько невозможно).
Пессимистичная: FOR UPDATE внутри короткой транзакции, когда конфликты часты или повтор дорог, например для баланса. NOWAIT сразу падает с 55P03, вместо того чтобы ждать.
**SKIP LOCKED пропускает строки, заблокированные другими транзакциями, а не ждёт их, поэтому N воркеров забирают N разных пачек без конкуренции.
Забрать и закоммитить**: пометьте задачи как running одним коротким запросом и обрабатывайте их вне транзакции: длинная транзакция держит соединение и мешает вакууму. Отдельный «уборщик» возвращает задачи со слишком старым locked_at, потому что упавший воркер оставляет их в running.
Каждая транзакция блокирует строки в порядке перевода, поэтому A→B и B→A держат по одной блокировке и ждут друг друга; Postgres находит цикл через deadlock_timeout (1 с) и откатывает одну из них. Берите блокировки в одном фиксированном порядке (отсортируйте id), и цикл не сложится.
Порядок можно задать одним запросом: WHERE id = ANY($1) ORDER BY id FOR UPDATE. Если блокировки берут разные запросы, сначала отсортируйте id в Go: порядок в коде определяет порядок захвата блокировок в базе.
Часто явная блокировка не нужна: два UPDATE accounts SET balance = balance - $1 WHERE id = $2 AND balance >= $1 в порядке id берут те же блокировки строк и проверяют инвариант в SQL; RowsAffected() == 0 значит, что денег не хватает.
Держите транзакции короткими: никаких сетевых вызовов, пока держите блокировки: каждая лишняя миллисекунда расширяет окно для цикла.
Небольшой ограниченный ретрай на 40P01 всё равно оставьте (внешние ключи или другая нагрузка ещё могут дать дедлок), но ретрай прячет ошибку порядка, а не исправляет её.
Вставка в аудит идёт через s.db, а не через tx, поэтому берёт из пула второе соединение и коммитится сама по себе. Под нагрузкой каждый вызов держит соединение своей транзакции и ждёт ещё одно, пул истощается, а запись аудита переживает откат.
Два соединения на вызов: *sql.Tx привязан к одному соединению до Commit или Rollback, а s.db.ExecContext просит у пула ещё одно. При SetMaxOpenConns(N) и N параллельных вызовах все N держат транзакцию и ждут свободного соединения: db.Stats().WaitCount растёт, запросы падают по дедлайну контекста, а Postgres при этом простаивает.
Нет атомарности: запрос в аудит выполняется в режиме autocommit и сразу становится постоянным. Если Commit потом упадёт или функция выйдет раньше, в логе останется заказ, которого нет; второе соединение даже не видит незакоммиченную строку заказа.
Исправление: все запросы одной единицы работы идут через tx. Если репозитории принимают маленький интерфейс DBTX (ExecContext, QueryContext, QueryRowContext), который реализуют и *sql.DB, и *sql.Tx, передать не тот хендл становится сложнее.
Как поймать: гоняйте интеграционные тесты с SetMaxOpenConns(1): ошибочная версия зависнет на первом же вызове. На ревью любой s.db между BeginTx и Commit считается тревожным признаком.
Подготовленный запрос живёт на одном серверном соединении. *sql.Stmt, созданный из *sql.DB, заново готовится на каждом соединении пула, куда попадёт, а за пулером в transaction-режиме соединение, где он готовился, не совпадает с тем, на котором он выполняется.
Цена в пуле: 50 соединений означают до 50 серверных копий на один *sql.Stmt, а когда знакомые ему соединения заняты, вызов готовит запрос заново на другом. Этот лишний round trip и даёт рост задержки.
PgBouncer в transaction-режиме выдаёт каждой транзакции любое свободное серверное соединение, поэтому именованный запрос, подготовленный на одном, отсутствует на следующем: prepared statement "..." does not exist. PgBouncer 1.21+ умеет отслеживать подготовленные запросы протокольного уровня через max_prepared_statements; для более старых конфигураций нужен pgx в режиме QueryExecModeExec или simple protocol.
Быстрее всё равно не было: db.QueryContext(ctx, sql, args...) и так параметризован, а pgx по умолчанию кэширует подготовленные запросы на каждом соединении.
**Когда Prepare окупается**: внутри *sql.Tx или *sql.Conn, где соединение фиксировано, для запроса, выполняемого много раз. Закрывайте его: это серверный объект.
Postgres не обновляет строку на месте: UPDATE пишет новую версию, а старую помечает мёртвой, и транзакция видит версии, разрешённые её снимком. Вакуум удаляет мёртвую версию, лишь когда её не видит ни одна открытая транзакция, так что одна старая транзакция останавливает очистку во всей базе.
Версии строк
Каждая версия несёт xmin (транзакцию, которая её создала) и xmax (ту, что её удалила или заменила). Снимок говорит, какие транзакции считать закоммиченными, поэтому читатели никогда не блокируют писателей, а писатели не блокируют читателей.
DELETE только выставляет xmax. UPDATE работает как удаление плюс вставка, и каждый индекс получает новую запись, если только это не HOT-обновление: ни одна индексированная колонка не изменилась, и новая версия помещается на ту же страницу.
Очистка
Autovacuum помечает мёртвые версии как место для повторного использования и обновляет статистику. Место переиспользуется, а не возвращается ОС; чтобы ужать раздутую таблицу, нужен VACUUM FULL (эксклюзивная блокировка) или pg_repack.
Ещё вакуум «замораживает» старые строки, чтобы 32-битный счётчик транзакций мог безопасно переполниться по кругу. База, которая слишком долго не может вакуумиться, ради самозащиты перестаёт принимать записи.
Одна долгая транзакция
Горизонт очистки задаёт самый старый снимок, открытый где угодно. Двухчасовой отчёт или забытая транзакция держит его, и тем временем в каждой нагруженной таблице копятся мёртвые версии: индексы растут, сканирования замедляются.
В Go обычным виновником оказывается *sql.Tx, который так и не дошёл до Commit или Rollback. В pg_stat_activity он виден как idle in transaction; idle_in_transaction_session_timeout его убивает, а backend_xmin вместе с xact_start показывают, кто держит горизонт.
Вынести отчёты на реплику помогает, но при включённом hot_standby_feedback долгие запросы реплики снова держат горизонт мастера; при выключенном их просто отменяют.
Повторяйте всю транзакцию, а не упавший запрос: ошибка сериализации откатывает всё, поэтому функция заново запускается с BeginTx и заново читает все значения. Всё, что имеет внешний эффект (письмо, HTTP-вызов, списание с карты), должно оставаться вне цикла.
Почему это происходит: на этом уровне Postgres не блокирует читателей. Он даёт обеим транзакциям отработать и откатывает одну, часто на COMMIT, если их чтения и записи не могли получиться ни из какого последовательного порядка. Это штатный трафик, а не баг.
Ограничьте цикл: три-пять попыток с коротким случайным backoff вокруг функции, которая выполняет транзакцию целиком. Если переиспользовать значение, прочитанное в упавшей попытке, вернётся та самая аномалия, ради которой подняли уровень изоляции.
Повторяйте только то, что имеет смысл повторять: 40001 и 40P01; никогда 23505 (нарушение уникальности): она каждый раз упадёт одинаково.
Побочные эффекты отправляйте через outbox: запишите письмо или событие в таблицу в той же транзакции, а отправит его отдельный процесс-релей после коммита. Вызов изнутри цикла случится по разу на каждую попытку.
Expand, migrate, contract: во время rolling-деплоя старый и новый код работают с одной базой, и каждый шаг схемы должен подходить обоим. Переименование превращается в «добавить колонку, писать в обе, перелить, переключить чтение, удалить» за несколько релизов, и каждую миграцию применяют до раскатки релиза.
Expand и contract
Релиз 1 добавляет nullable-колонку full_name, пишет в обе колонки и всё ещё читает name. Добавление nullable-колонки без волатильного значения по умолчанию не переписывает таблицу.
Переливайте данные пачками из отдельной задачи, а не внутри миграции: получасовой UPDATE держит свои блокировки, блокирует следующий деплой и не может продолжить с места обрыва.
Релиз 2 читает full_name и всё ещё пишет в обе; релиз 3 перестаёт писать name; и только после этого миграция её удаляет.
Простой устраивают блокировки
Даже мгновенный ALTER TABLE требует эксклюзивной блокировки, встаёт в очередь за долгим SELECT, а за ним в очередь встают все остальные запросы. Сначала выполните SET LOCAL lock_timeout = '3s' (простой SET переживает транзакцию goose и протекает в следующие миграции) и повторяйте.
CREATE INDEX CONCURRENTLY не блокирует запись, но не может выполняться внутри транзакции: вынесите его в отдельный файл с -- +goose NO TRANSACTION.
NOT NULL на большой таблице: добавьте CHECK (col IS NOT NULL) NOT VALID, выполните VALIDATE CONSTRAINT, и тогда SET NOT NULL обойдётся без полного сканирования, затем удалите CHECK.
Инструменты
goose и golang-migrate применяют пронумерованные файлы по одному разу и запоминают версию. Запускайте их одним шагом перед раскаткой (задачей в CI или Kubernetes Job), а не из каждого пода при старте.
Down-миграции нужны для разработки. В продакшене откатываются вперёд новой миграцией, потому что записанные с тех пор данные уже не «отписать».
Репликация держит копии данных на нескольких серверах ради отказоустойчивости и масштабирования чтения; шардирование делит данные по ключу между серверами, что масштабирует запись. Синхронная репликация заставляет COMMIT ждать реплику: при переключении ничего не теряется, но растёт задержка.
Репликация
Один мастер принимает записи; реплики проигрывают его WAL и обслуживают чтение. Запись это не масштабирует, ведь каждая реплика применяет каждую запись.
Асинхронная (по умолчанию в Postgres): коммит возвращается сразу, реплики отстают на миллисекунды или минуты, и при переключении можно потерять последние подтверждённые транзакции.
Синхронная: synchronous_standby_names = 'ANY 1 (r1, r2)' заставляет коммит ждать любую из двух реплик, поэтому одна медленная реплика не тормозит запись. Выбирайте её там, где потеря подтверждённой записи недопустима (платежи, учётные книги проводок).
Шардирование
Всё решает ключ шардирования. tenant_id держит данные и транзакции арендатора на одном шарде; неудачный ключ даёт горячие шарды и межшардовые запросы.
Межшардовые JOIN и транзакции становятся дорогими или невозможными, а решардинг превращается в отдельный проект. Раскладывайте много логических шардов по меньшему числу серверов, чтобы при росте переезжали шарды целиком, а не строки.
Сначала исчерпайте дешёвые варианты: индексы, сервер помощнее, реплики для чтения, партиционирование таблиц или готовая система вроде Citus или Vitess.
В Go
Держите два пула, primary и replica типа *sql.DB, и выбирайте на каждый запрос. Сразу после записи читайте данные этого пользователя с мастера, иначе отставание реплики покажет ему устаревшие данные.
Шардирование сводится к map[shardID]*sql.DB и маршрутизации каждого вызова по ключу, поэтому ключ должен доходить до каждого метода репозитория.