Антипаттерны проектирования

Антипаттерны проектирования

Вы освоили связи, нормальные формы и ER-диаграммы. Теперь — обратная сторона: типичные ошибки проектирования, которые встречаются в реальных проектах. Знание антипаттернов помогает их распознавать и избегать — или рефакторить уже существующие схемы.

1. «Бог-таблица» (God Table)

Одна таблица, которая хранит «всё подряд»: клиентов, заказы, контакты, настройки, историю — в одной плоской таблице с десятками колонок.

-- Антипаттерн: одна таблица на все случаи жизни
CREATE TABLE everything (
    id         INTEGER,
    type       TEXT,    -- 'customer', 'order', 'product', 'contact'
    name       TEXT,
    email      TEXT,    -- для клиентов
    amount     NUMERIC, -- для заказов
    price      NUMERIC, -- для товаров
    phone      TEXT,    -- для контактов
    ref_id     INTEGER  -- FK непонятно на что
);

Проблемы:

  • Большинство колонок NULL для каждой строки
  • ref_id не имеет ссылочной целостности
  • Запросы непонятны без знания значений колонки type
  • Невозможно добавить нормальные ограничения

Решение: отдельная таблица для каждой сущности. Если сущности имеют общие атрибуты — базовая таблица с 1:1 связью.

2. Хранение значений через запятую (CSV в колонке)

-- Антипаттерн: теги строкой
CREATE TABLE posts (
    id    SERIAL PRIMARY KEY,
    title TEXT,
    tags  TEXT   -- 'sql, database, postgresql'
);

INSERT INTO posts (title, tags) VALUES ('Intro SQL', 'sql,database,beginners');

Проблемы:

  • Нельзя найти посты с тегом sql без LIKE '%sql%' (медленно, ошибки с похожими словами)
  • Нельзя посчитать количество постов с тегом
  • Нельзя гарантировать уникальность тегов
  • Нарушает 1NF

Решение: промежуточная таблица post_tags:

CREATE TABLE tags (
    id   SERIAL PRIMARY KEY,
    name TEXT UNIQUE NOT NULL
);

CREATE TABLE post_tags (
    post_id INTEGER REFERENCES posts(id) ON DELETE CASCADE,
    tag_id  INTEGER REFERENCES tags(id)  ON DELETE CASCADE,
    PRIMARY KEY (post_id, tag_id)
);

Или массивы PostgreSQL, если простота важнее — но тогда создайте GIN-индекс:

tags TEXT[],
-- Быстрый поиск: CREATE INDEX ON posts USING GIN(tags);
-- Запрос: WHERE tags @> ARRAY['sql']

3. Повторяющиеся группы колонок

-- Антипаттерн: фиксированное число телефонов
CREATE TABLE contacts (
    id     SERIAL PRIMARY KEY,
    name   TEXT,
    phone1 TEXT,
    phone2 TEXT,
    phone3 TEXT
);

Что если нужен четвёртый? Менять схему. А если у кого-то нет телефона — NULL во всех трёх. Нарушает 1NF.

Решение: отдельная таблица:

CREATE TABLE contact_phones (
    contact_id INTEGER REFERENCES contacts(id),
    phone      TEXT NOT NULL,
    type       TEXT DEFAULT 'mobile',  -- 'mobile', 'work', 'home'
    PRIMARY KEY (contact_id, phone)
);

4. Использование строк для хранения всего подряд

-- Антипаттерн: всё в TEXT, нет типизации
CREATE TABLE settings (
    key   TEXT PRIMARY KEY,
    value TEXT    -- может быть числом, датой, JSON — непонятно
);

INSERT INTO settings VALUES ('max_retries', '5');
INSERT INTO settings VALUES ('created_at', '2024-01-15');
INSERT INTO settings VALUES ('config', '{"timeout": 30}');

Проблемы:

  • Нет валидации типов на уровне БД
  • Арифметика требует CAST('5'::INTEGER)
  • Сложно индексировать

Решение: типизированные колонки или JSONB с фиксированной структурой:

CREATE TABLE app_settings (
    key         TEXT PRIMARY KEY,
    int_value   INTEGER,
    text_value  TEXT,
    json_value  JSONB,
    updated_at  TIMESTAMPTZ DEFAULT NOW()
);

Или хранить всё в JSONB с документированной схемой.

5. Отсутствие индексов на FK

Одна из самых распространённых проблем производительности:

-- Таблица без индекса на FK
CREATE TABLE orders (
    id          SERIAL PRIMARY KEY,
    customer_id INTEGER REFERENCES customers(id),
    -- Нет: CREATE INDEX idx_orders_customer_id ON orders(customer_id);
    status      TEXT
);

-- Этот запрос — полное сканирование orders при каждом вызове:
SELECT * FROM orders WHERE customer_id = 42;

Решение: всегда добавлять индекс на FK-колонку после создания таблицы. Проверить отсутствующие индексы:

-- Найти FK без индексов
SELECT t.relname AS table_name, a.attname AS column_name
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = ANY(c.conkey)
WHERE c.contype = 'f'
  AND NOT EXISTS (
      SELECT 1 FROM pg_index i
      WHERE i.indrelid = t.oid
        AND a.attnum = ANY(i.indkey)
  );

6. Хранение вычисляемых данных без согласованности

-- Антипаттерн: total хранится как поле, но не синхронизируется
CREATE TABLE orders (
    id           SERIAL PRIMARY KEY,
    total_amount NUMERIC(10, 2),  -- денормализованный кэш
    item_count   INTEGER           -- денормализованный кэш
);

-- Если order_items изменятся — total_amount и item_count устареют

Проблемы:

  • Данные могут рассинхронизироваться
  • Кто отвечает за обновление: приложение, триггер, или никто?

Решение:

Вариант 1 — не хранить, всегда вычислять:

SELECT o.id, SUM(oi.unit_price * oi.quantity) AS total
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
GROUP BY o.id;

Вариант 2 — хранить с триггером или генерируемой колонкой:

-- Или использовать VIEW / MATERIALIZED VIEW для кэша

Вариант 3 — явно принять решение хранить снапшот (как price_snapshot в позиции заказа) и документировать, что это намеренная денормализация.

7. Игнорирование NULL-семантики

-- Антипаттерн: смешивание NULL и пустой строки
UPDATE users SET bio = '' WHERE bio IS NULL;

-- Антипаттерн: NOT IN с NULL
SELECT * FROM orders WHERE customer_id NOT IN (SELECT id FROM vip_customers);
-- Если vip_customers содержит хоть один NULL — результат пуст (ловушка)

Решение: быть последовательным — либо NULL, либо пустая строка, но не оба. Для NOT IN с подзапросами всегда фильтровать NULL:

SELECT * FROM orders
WHERE customer_id NOT IN (
    SELECT id FROM vip_customers WHERE id IS NOT NULL
);
-- Или лучше: NOT EXISTS

8. «Умная» первичный ключ

-- Антипаттерн: смысловой PK
CREATE TABLE products (
    sku  VARCHAR(20) PRIMARY KEY,  -- 'CAT-001-RED-M'
    name TEXT
);

Если формат артикула изменится (бизнес решил использовать другой формат), нужно обновить PK и все FK-ссылки. Это болезненно.

Решение: суррогатный PK + уникальный индекс для бизнес-ключа:

CREATE TABLE products (
    id   SERIAL PRIMARY KEY,
    sku  VARCHAR(20) UNIQUE NOT NULL,
    name TEXT
);

9. Использование FLOAT для денег

Мы упоминали это в модуле 9, но это настолько критичная ошибка, что стоит повторить в контексте проектирования:

-- Антипаттерн: деньги как FLOAT
CREATE TABLE transactions (
    id     SERIAL PRIMARY KEY,
    amount FLOAT  -- опасно!
);

INSERT INTO transactions (amount) VALUES (0.1 + 0.2);
-- Хранится 0.30000000000000004, а не 0.3

Ошибки накапливаются: после тысяч операций сумма расходится. Финансовые ревизии обнаруживают копейки/центы расхождения, которые невозможно объяснить.

-- Правильно: точный тип
CREATE TABLE transactions (
    id     SERIAL PRIMARY KEY,
    amount NUMERIC(15, 2)  -- точность: 15 цифр, 2 после запятой
);

-- Или хранить в копейках/центах как INTEGER:
amount_cents INTEGER  -- 100 = 1.00 рубль/доллар

Хранение в наименьших единицах (копейках) как INTEGER — популярный паттерн: нет округления совсем.

10. Отсутствие временных меток

-- Антипаттерн: нет created_at и updated_at
CREATE TABLE users (
    id    SERIAL PRIMARY KEY,
    email TEXT UNIQUE NOT NULL
);

Без временных меток невозможно ответить на вопросы: «Когда зарегистрировался этот пользователь?», «Что изменилось вчера?», «Когда последний раз обновлялась эта строка?»

Правило: у каждой таблицы с бизнес-данными должны быть created_at и (если данные обновляются) updated_at:

CREATE TABLE users (
    id         SERIAL PRIMARY KEY,
    email      TEXT UNIQUE NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Автоматическое обновление updated_at — через триггер или в приложении

TIMESTAMPTZ (с часовым поясом) — стандартный выбор: данные хранятся в UTC, отображаются в нужном часовом поясе.

Как распознать антипаттерны в чужой схеме

Диагностический чеклист для нового проекта:

-- 1. Найти таблицы без created_at
SELECT table_name FROM information_schema.tables t
WHERE table_schema = 'public'
  AND table_type = 'BASE TABLE'
  AND NOT EXISTS (
      SELECT 1 FROM information_schema.columns c
      WHERE c.table_name = t.table_name
        AND c.column_name = 'created_at'
  );

-- 2. Найти TEXT колонки с числами
SELECT table_name, column_name
FROM information_schema.columns
WHERE data_type = 'text'
  AND column_name IN ('price', 'amount', 'total', 'count', 'quantity');

-- 3. Найти FK без индекса (запрос из антипаттерна 5)

Такие запросы полезны при аудите унаследованных схем или code review.

Краткий итог

АнтипаттернСимптомРешение
Бог-таблицаТип строки в колонке typeОтдельные таблицы
CSV в колонкеLIKE '%tag%' в запросахПромежуточная таблица или массив
Повторяющиеся группыphone1, phone2, phone3Отдельная таблица
Всё в TEXTCAST повсюдуТипизированные колонки
FK без индексаМедленные JOINCREATE INDEX
Несинхронный кэшНесогласованные данныеVIEW или триггер
NULL-ловушкиNOT IN возвращает пустоNOT EXISTS
«Умный» PKСложные UPDATE при изменении форматаСуррогатный PK + UNIQUE

Что дальше

Вы освоили типы связей, нормальные формы, ER-диаграммы и антипаттерны. Финальный модуль — применим всё это на практике: спроектируем и реализуем полную схему базы данных для реального приложения.

Попробуйте интерактивную версию

Практические задачи, квизы и AI-наставник — бесплатный старт без карты

Перейти к практике