ACID, изоляция транзакций, N+1, Redis
Транзакции и целостность на стороне БД, типичные ошибки при работе с ней из Go, и Redis как слой кэширования сверху. Ключевые факты ниже проверены реальным Postgres-контейнером, а не пересказаны по памяти.
ACID и уровни изоляции транзакций
ACID — четыре свойства, которые СУБД гарантирует для транзакции: Atomicity (транзакция выполняется целиком или не выполняется совсем — не бывает "наполовину применённой" транзакции), Consistency (база переходит из одного валидного состояния в другое валидное, не нарушая ограничений вроде внешних ключей), Isolation (конкурентные транзакции не видят чужих незавершённых изменений друг друга — насколько именно "не видят", зависит от уровня изоляции ниже), Durability (после успешного COMMIT данные гарантированно сохранены, даже если сразу после этого сервер упадёт).
Уровни изоляции идут от самого слабого к самому строгому, и каждый следующий закрывает ещё одну аномалию, которую предыдущий допускал:
- Read Uncommitted — теоретически позволяет видеть чужие незакоммиченные изменения (грязное чтение); на практике почти нигде не используется, в том числе Postgres реализует этот уровень так же строго, как Read Committed.
- Read Committed (дефолт в PostgreSQL) — видны только уже закоммиченные чужие изменения, но если внутри ОДНОЙ своей транзакции прочитать одну и ту же строку дважды, а между чтениями кто-то другой успеет закоммитить её изменение — второе чтение честно покажет уже новое значение. Это называется неповторяемым чтением, и на этом уровне оно возможно.
- Repeatable Read — повторное чтение той же строки внутри транзакции гарантированно стабильно от начала до конца транзакции, неповторяемое чтение здесь уже невозможно.
- Serializable — максимальные гарантии: итоговый результат эквивалентен какому-то последовательному порядку выполнения транзакций. Цена — конфликтующая транзакция может получить ошибку сериализации в конце и должна сама уметь повторить попытку; это прямой компромен между строгостью гарантий и пропускной способностью под конкурентной нагрузкой.
Три классические аномалии чтения, от слабой к сильной: грязное чтение (видим чужие ещё не закоммиченные данные) → неповторяемое чтение (один и тот же SELECT той же строки в рамках транзакции дважды даёт разные значения) → фантомное чтение (тот же запрос с условием WHERE даёт разное КОЛИЧЕСТВО строк между двумя выполнениями — появились или пропали целые строки, а не изменилось значение уже прочитанной).
SELECT ... FOR UPDATE блокирует выбранные строки на запись до конца транзакции. Без него — классический баг конкурентного списания остатка: две транзакции ОДНОВРЕМЕННО читают один и тот же остаток товара (скажем, 5 штук), обе видят "хватает", обе независимо списывают единицу — и в результате в БД остаётся значение, посчитанное только от одного из двух списаний, второе теряется.
Итоговый остаток в БД оказывается больше нуля, хотя по факту продано больше, чем было в наличии, — race condition на классическом паттерне "прочитать → проверить → обновить", где между чтением и обновлением нет ничего, что помешало бы второй транзакции вклиниться с тем же самым устаревшим прочитанным значением.
Нормализация, DDL, триггеры, VIEW, процедуры
Три нормальные формы решают три конкретных практических проблемы, а не абстрактную "правильность" схемы. 1NF — требует, чтобы в каждой ячейке было одно атомарное значение, а не список внутри строки (tags = 'красный,большой' нарушает 1NF — из такой ячейки нельзя эффективно найти "все товары с тегом красный" без парсинга строки).
2NF актуальна там, где первичный ключ составной: каждая обычная колонка должна зависеть от ВСЕГО составного ключа целиком, а не только от его части. 3NF запрещает транзитивные зависимости — например, если email клиента хранится прямо в таблице заказов (а не только customer_id, через который его можно получить джойном), то при смене email клиента придётся обновлять его во всех старых заказах, и велик риск получить рассинхронизацию, если забыть один из них.
DDL (CREATE/ALTER/DROP TABLE) описывает СТРУКТУРУ данных, в отличие от DML (SELECT/INSERT/UPDATE/DELETE), которая работает с самими данными внутри уже существующей структуры. REFERENCES объявляет внешний ключ и защищает сразу от двух вещей: от вставки строки, ссылающейся на несуществующую запись в другой таблице, и от удаления строки, на которую кто-то ещё ссылается — пока явно не указан ON DELETE CASCADE (тогда удаление каскадно потянет за собой все ссылающиеся строки) или ON DELETE SET NULL.
Триггер — код, который СУБД выполняет САМА при наступлении события (INSERT/UPDATE/DELETE) без явного вызова из приложения — классический пример: автоматическое обновление колонки updated_at при любом UPDATE строки, чтобы про это не нужно было помнить в каждом месте кода, которое меняет таблицу.
VIEW не хранит собственных данных — это просто сохранённый под именем SELECT, который пересчитывается заново при каждом обращении к нему, в отличие от MATERIALIZED VIEW, которая реально хранит результат на диске до явной команды REFRESH. Хранимая процедура — код, живущий внутри самой БД и вызываемый через CALL; в отличие от обычной FUNCTION, процедура может сама управлять транзакциями (делать свой COMMIT/ROLLBACK посередине выполнения).
Ловушка, которая реально ловит людей при ручной проверке триггеров с updated_at: функция now() в PostgreSQL возвращает не "текущий момент времени", а момент НАЧАЛА транзакции — и это значение остаётся одинаковым для всех вызовов now() внутри одной multi-statement транзакции, сколько бы реального времени она ни выполнялась. Проверено на реальном Postgres:
BEGIN;
SELECT now(); --> 19:12:56.406281
SELECT pg_sleep(1); -- ждём секунду
SELECT now(); --> 19:12:56.406281 (то же самое значение!)
SELECT clock_timestamp(); --> 19:12:57.408973 (а вот это — реально прошедшая секунда)
COMMIT;
Если внутри теста или триггера нужно честно замерить реально прошедшее время (а не момент старта транзакции) — нужен clock_timestamp(), а не now(). Это не баг, а документированное поведение: now() — это "время транзакции" по определению стандарта SQL, специально сделанное стабильным внутри одной транзакции для предсказуемости.
Go и БД: database/sql, N+1, миграции
database/sql — стандартный интерфейс для работы с любой SQL-базой; конкретный драйвер (pgx, lib/pq для Postgres) подключается side-effect импортом (import _ "github.com/lib/pq") и регистрирует себя в этом интерфейсе, сам код запросов остаётся драйвер-независимым. Пакет сам управляет пулом соединений — важная практическая деталь: SetMaxOpenConns не стоит ставить выше реального лимита самой БД (у PostgreSQL по умолчанию max_connections обычно 100, и это ОБЩИЙ лимит на ВСЕ клиентские сервисы разом, не персональный на каждый из них — если несколько сервисов независимо выставят себе большие лимиты, они вместе легко упрутся в потолок самой БД).
sql.ErrNoRows — это ОЖИДАЕМЫЙ, штатный случай ("запрос выполнился успешно, просто подходящей строки не нашлось"), а не ошибка соединения или синтаксиса; проверяется через errors.Is(err, sql.ErrNoRows), а не через голое сравнение текста ошибки.
Проблема N+1 — самая частая находка на код-ревью, которую видно даже без запуска, если знать, что искать: один запрос за списком сущностей, а потом ЕЩЁ N отдельных запросов в цикле за связанными данными к каждой из них, вместо одного запроса со JOIN или WHERE id = ANY($1). Разница в количестве обращений к БД видна прямо на счётчике запросов:
На трёх заказах (у двух из них общий клиент Аня) наивный вариант делает 4 обращения к БД, а батчинг через id = ANY(...) — всего 2, независимо от того, 3 заказа или 3000: разница растёт линейно с размером списка, и именно поэтому N+1 — не теоретическая придирка, а реальная причина деградации при росте данных.
Забытый rows.Close() после db.Query — соединение из пула не возвращается обратно, оно продолжает считаться занятым до тех пор, пока сборщик мусора когда-нибудь не приберёт объект Rows (а это не гарантировано быстро). При накоплении таких утечек — особенно из-за раннего return при ошибке без defer rows.Close() сразу после успешного Query — пул соединений постепенно исчерпывается, и новые запросы начинают блокироваться в ожидании свободного соединения, хотя с самой БД всё в порядке.
Redis: структуры данных, кэширование, TTL
Redis — это не просто "быстрый key-value", а набор РАЗНЫХ структур данных под разные задачи, и выбор структуры — это выбор набора операций, а не только формы хранения. string — простое значение или счётчик (атомарный INCR/DECR без гонок, даже при конкурентных обращениях). hash — объект с несколькими именованными полями под одним ключом (удобно хранить сразу все поля пользователя одной структурой, не городя отдельные ключи на каждое поле).
list — очередь или стек с быстрыми операциями на обоих концах (LPUSH/RPUSH/LPOP/RPOP). set — набор уникальных значений с быстрой проверкой принадлежности (SISMEMBER за примерно константное время, без перебора). sorted set — то же самое, что set, но с числовым score у каждого элемента, и элементы автоматически поддерживаются отсортированными по этому score — классическое применение — лидерборды и очереди с приоритетом.
Паттерн cache-aside — самый распространённый способ подружить кэш и основную БД: проверить кэш → если промах (miss), прочитать из БД → положить прочитанное в кэш с TTL → отдать результат вызывающему коду. При ИЗМЕНЕНИИ данных в БД кэш обычно не обновляют на месте новым значением, а ЯВНО удаляют устаревший ключ (инвалидация) — следующее чтение снова словит miss и честно перечитает уже актуальные данные из БД, что проще и надёжнее, чем пытаться синхронно поддерживать кэш в актуальном состоянии при каждом изменении.
TTL — не единственный и не универсальный способ инвалидации. Он хорошо подходит для данных, которые не страшно показать слегка устаревшими какое-то короткое время (каталог товаров, список категорий) — там истечение срока само по себе решает проблему протухания.
Для данных, где устаревшее значение — это реальный баг, а не мелкая неточность (баланс счёта, статус заказа, доступность товара прямо перед оплатой), полагаться только на TTL нельзя: нужна ЯВНАЯ инвалидация кэша сразу в момент изменения соответствующих данных в БД, а не ожидание, пока TTL истечёт сам по себе — иначе в окне между реальным изменением и истечением TTL кэш будет отдавать заведомо неверный ответ.