Антипаттерны проектирования
Антипаттерны проектирования
Вы освоили связи, нормальные формы и 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 | Отдельная таблица |
| Всё в TEXT | CAST повсюду | Типизированные колонки |
| FK без индекса | Медленные JOIN | CREATE INDEX |
| Несинхронный кэш | Несогласованные данные | VIEW или триггер |
| NULL-ловушки | NOT IN возвращает пусто | NOT EXISTS |
| «Умный» PK | Сложные UPDATE при изменении формата | Суррогатный PK + UNIQUE |
Что дальше
Вы освоили типы связей, нормальные формы, ER-диаграммы и антипаттерны. Финальный модуль — применим всё это на практике: спроектируем и реализуем полную схему базы данных для реального приложения.