7. Ограничения целостности данных
Последний рубеж обороны данных
Одна из самых распространенных и опасных иллюзий, с которой бэкенд-разработчики сталкиваются при переходе к микросервисной архитектуре — это вера в то, что прикладной код полностью и единолично управляет состоянием системы. Инженер проектирует красивую доменную модель (Domain-Driven Design), пишет аккуратные валидаторы структур на Go, покрывает всё модульными тестами и искренне полагает, что в базу данных физически не могут просочиться некорректные или противоречивые байты.
В реальной жизни высоконагруженного продакшена это опасное заблуждение. В распределенной среде десятки независимых экземпляров сервиса (подов в Kubernetes) параллельно и асинхронно мутируют общее состояние. Никакие проверки в оперативной памяти одного процесса Go не способны предотвратить состояние гонки (Race Condition), когда два запроса одновременно решают, что условие соблюдено. Без бескомпромиссных системных барьеров в самом ядре СУБД повреждение данных (Data Corruption) становится лишь вопросом времени.
Ограничения целостности (Constraints) — это непреложные математические инварианты на уровне движка базы данных, гарантирующие корректность данных до того, как транзакция запишет измененные страницы в память и журнал на диске. Это финальный, фундаментальный рубеж обороны любой информационной системы.
В предыдущей главе мы подробно разобрали первичные и внешние ключи (6. Первичные и внешние ключи). В этой статье мы сосредоточимся на трех остальных столпах декларативной защиты: NOT NULL, UNIQUE и CHECK, заглянем в их физическую реализацию и выработаем идиоматичные паттерны обработки ошибок в Go.
1. NOT NULL: Отсутствие пустоты
Ограничение NOT NULL категорически запрещает запись специального маркера неизвестности (NULL) в определенный столбец. С точки зрения классической теории Эдгара Кодда, это базовая защита целостности домена (Domain Integrity).
Но какова физическая цена этой проверки на уровне «железа»?
2. UNIQUE: Больше, чем просто правило
Ограничение UNIQUE гарантирует, что в таблице не может существовать двух строк с идентичным значением указанной колонки (или составной группы колонок). Это стандартное решение для email-адресов пользователей, номеров телефонов, артикулов товаров или ключей идемпотентности (Idempotency Keys).
Многие разработчики ошибочно полагают, что UNIQUE — это просто логическое правило, аналогичное валидации в коде. Однако с точки зрения архитектуры СУБД это ресурсоемкий физический объект.
3. CHECK: Пользовательские предикаты на уровне CPU
Ограничение CHECK позволяет задать произвольное логическое выражение (предикат), которое обязано возвращать TRUE (или NULL) для каждой строки:
CHECK (balance >= 0)— математическая гарантия невозможности уйти в отрицательный баланс при финансовых операциях.CHECK (role IN ('admin', 'user', 'guest'))— строгий контроль перечислимого домена без необходимости заводить отдельную справочную таблицу.CHECK (start_date < end_date)— валидация согласованности связанных временных меток.
Предикаты CHECK вычисляются исключительно в пространстве пользователя (User Space) процесса СУБД в оперативной памяти до записи данных в буфер (Buffer Pool). Они требуют лишь нескольких десятков тактов CPU и абсолютно бесплатны с точки зрения операций дискового ввода-вывода (I/O). Поэтому критические бизнес-инварианты (например, баланс >= 0) всегда должны дублироваться ограничением CHECK в базе данных.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
Request["Входящий INSERT/UPDATE"]:::warning --> ValidateNN["1. Проверка NOT NULL<br>Битовые операции"]
ValidateNN --> ValidateCheck["2. Вычисление CHECK<br>CPU предикаты"]
ValidateCheck --> ValidateUnique["3. Проверка UNIQUE<br>Поиск по B-Tree индексу"]
ValidateUnique --> ValidateFK["4. Проверка Foreign Key<br>Поиск в других таблицах"]
ValidateFK --> Write["Запись в WAL и Heap"]
Архитектура: Иллюзия App-level валидации и Race Conditions
Фундаментальный концептуальный сдвиг, необходимый Senior-инженеру — осознание опасности проблемы TOCTOU (Time-of-Check to Time-of-Use) при конкурентной обработке запросов.
Посмотрим на классический антипаттерн регистрации пользователя, кочующий из одного учебного проекта в другой: «Сначала проверим в базе через SELECT, свободен ли email. Если свободен — создадим пользователя».
// АНТИПАТТЕРН: Уязвимо к Race Condition
func registerUserBad(ctx context.Context, db *sql.DB, email string) error {
var exists bool
// 1. TIME OF CHECK (Время проверки)
err := db.QueryRowContext(ctx, "SELECT exists(SELECT 1 FROM users WHERE email = $1)", email).Scan(&exists)
if err != nil { return err }
if exists {
return errors.New("email уже занят")
}
// [!] СЮДА ВСТРАИВАЕТСЯ ГОНКА [!]
// Если две горутины (или два разных сервиса) проверят email одновременно,
// обе получат exists = false и пойдут выполнять INSERT.
// 2. TIME OF USE (Время использования)
_, err = db.ExecContext(ctx, "INSERT INTO users (email) VALUES ($1)", email)
return err
}
Если в схеме базы данных отсутствует ограничение UNIQUE, два одновременных HTTP-запроса от пользователя (например, двойной клик в браузере или параллельные вебхуки) одновременно пройдут проверку exists == false. В итоге в таблице появятся две разные записи с абсолютно одинаковым адресом электронной почты. Аутентификация сломается, а целостность системы будет скомпрометирована.
Idiomatic Go: Delegation to Database (Делегирование базе)
В духе философии Mechanical Sympathy надежное решение заключается в том, чтобы полностью ликвидировать предварительный холостой запрос (SELECT).
Мы перекладываем бремя проверки на плечи реляционного ядра СУБД: выполняем прямой «слепой» INSERT и перехватываем низкоуровневую ошибку нарушения констрейнта. Это не только на 100% защищает от Race Conditions, но и экономит целый сетевой раундтрип (Network Round Trip) между бэкендом и базой.
// ИДИОМАТИЧНЫЙ ПОДХОД: Пусть база данных защищает сама себя
package main
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/jackc/pgx/v5/pgconn"
)
const pgErrUniqueViolation = "23505" // SQLSTATE для Postgres
var ErrEmailTaken = errors.New("email уже занят")
func registerUserGood(ctx context.Context, db *sql.DB, email string) error {
// Сразу делаем INSERT. Ограничение UNIQUE в БД не пропустит дубликат.
query := `INSERT INTO users (email) VALUES ($1) RETURNING id`
var newID int64
err := db.QueryRowContext(ctx, query, email).Scan(&newID)
if err != nil {
// Проверяем, является ли ошибка нарушением уникальности
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
if pgErr.Code == pgErrUniqueViolation {
// В pgErr.ConstraintName также лежит имя нарушенного индекса,
// если нужно отличить уникальность email от уникальности телефона.
return ErrEmailTaken
}
}
// Логируем или возвращаем другие ошибки (таймауты, сбои сети)
return fmt.Errorf("ошибка вставки в БД: %w", err)
}
return nil
}
Итог
- Ограничения целостности — это математические инварианты, выполняемые атомарно внутри ядра СУБД до фиксации транзакции.
NOT NULLиCHECKчрезвычайно дешевы: они проверяются на уровне битовых масок и арифметики CPU, не генерируя дополнительного дискового ввода-вывода.UNIQUEвсегда влечет за собой создание полноценного B-Tree индекса. Это гарантия уникальности ценой дополнительной дисковой памяти и расходов на сопровождение дерева при каждой операции записи.- В конкурентном Go-бэкенде никогда не полагайтесь на паттерн
SELECT-then-INSERT. Доверяйте проверку уникальности СУБД и элегантно перехватывайте ошибку с кодом23505.
Мы изучили механизмы защиты данных от некорректных значений. Но в реляционной теории существует особое метафизическое состояние, способное сломать привычную булеву логику и запутать даже опытного разработчика. В следующей статье мы познакомимся с самой коварной концепцией SQL: переходим к 8. NULL и трехзначная логика.