Проектирование баз данных
Моделируйте сущности, ключи, индексы, деньги и время так, чтобы схема пережила годы изменений.
За 30 секунд
Проектирование базы данных — это решение, какие таблицы нужны, как они связаны и какие правила должна обеспечивать сама база. Хорошая схема делает правильные данные простыми, а неправильные — невозможными, и сохраняет запросы быстрыми по мере роста данных. Код легко переписать; данные и схемы живут годами, поэтому именно здесь тщательное обдумывание окупается больше всего.
Почему я это использую
- Я трачу на проектирование схемы реальное время, потому что почти каждый серьёзный баг, который я видел в коммерческих системах, восходит к данным, которые база допустила, а бизнес — нет.
- Я проектирую с расчётом на изменения: у товаров появляются варианты, у заказов — валюты, однотенантные приложения становятся мультитенантными, и хорошая схема поглощает это миграциями, а не переписыванием.
- Я поручаю базе данных обеспечивать те правила, в которых она сильна (внешние ключи, уникальные ограничения, NOT NULL, check), чтобы баги приложения не могли тихо испортить данные.
- Я отношусь к деньгам, времени и идентификаторам как к особым случаям с фиксированными правилами, потому что ошибки там дорогие и их трудно исправить.
Как это работает
01Сущности и связи
Начинайте с бизнеса, а не с таблиц. Перечислите существительные (клиент, товар, заказ, платёж) и глаголы, которые их связывают (клиент оформляет заказы, заказ содержит позиции). Каждое важное существительное становится таблицей — сущностью. Затем определите кардинальность каждой связи. Один-ко-многим встречается чаще всего: у одного клиента много заказов, поэтому в orders появляется внешний ключ customer_id. Многие-ко-многим, как товары и категории, требует связующей таблицы (product_categories) с обоими внешними ключами и составным первичным ключом. Один-к-одному встречается редко и обычно означает необязательные дополнительные данные, например профиль клиента. Внешние ключи заставляют базу отказываться от строк-сирот; поведение ON DELETE выбирайте осознанно: RESTRICT для бизнес-записей, CASCADE — только для настоящих дочерних записей, например позиций черновой корзины.
- Существительные становятся таблицами, глаголы — связями.
- 1:N: внешний ключ на стороне «многих».
- N:M: связующая таблица с составным ключом.
- Называйте единообразно: таблицы во множественном числе, внешние ключи singular_id, snake_case.
02Нормализация и денормализация
Нормализация означает хранить каждый факт один раз. На практике цель — третья нормальная форма (3NF): каждый столбец зависит от ключа, от всего ключа и ни от чего, кроме ключа. Если email клиента скопирован в каждый заказ, его изменение означает обновление многих строк, и какие-то обязательно будут пропущены. Денормализация — это намеренное копирование данных ради скорости или истории. Два случая не просто допустимы, а обязательны. Снимки: позиция заказа должна хранить название товара и цену за единицу на момент покупки, потому что товар потом изменится. Read-модели: кэшированный order_count у клиентов или поисковый индекс. Нормализуйте по умолчанию, денормализуйте осознанно и документируйте, кто поддерживает копию в актуальном состоянии — триггер, джоба или приложение.
03Ключи: естественные и суррогатные, UUID и bigint
Естественный ключ — значение из реального мира, например email или SKU; суррогатный ключ — бессмысленный сгенерированный id. Естественные значения меняются (люди меняют email, SKU переименовывают), поэтому используйте суррогатный первичный ключ, а естественное значение защищайте ограничением UNIQUE. Для суррогатного ключа bigint с автоинкрементом компактен (8 байт), быстр и сохраняет порядок вставок в B-tree, но id легко угадать, и генерировать их должна база. UUID (16 байт) можно генерировать где угодно, что подходит распределённым системам, офлайн-клиентам и публичным URL. Случайные UUIDv4 разбрасывают вставки по всему индексу и на масштабе вредят производительности записи; упорядоченные по времени UUIDv7 это исправляют. В PostgreSQL 18 есть встроенная функция uuidv7(); в остальных случаях генерируйте v7 в приложении.
- bigint: внутренние таблицы, join'ы, большой объём записи.
- UUIDv7: публичные id, создание офлайн или в нескольких системах.
- Никогда не раскрывайте последовательные id там, где перебор — это риск.
- Естественные ключи держите как UNIQUE-столбцы, а не как первичные ключи.
04Стратегия индексов
Индекс — это отсортированная копия некоторых столбцов, которая позволяет базе находить строки без сканирования всей таблицы, как предметный указатель в конце книги. У каждого первичного ключа и уникального ограничения он есть. Добавляйте другие под запросы, которые реально выполняются: внешние ключи, используемые в join'ах, столбцы из частых WHERE и ORDER BY. В составном индексе порядок столбцов важен. Правило левого префикса гласит: индекс по (customer_id, created_at) помогает запросам с фильтром по customer_id или по customer_id и диапазону дат, но не по одному created_at. Ставьте столбцы с равенством первыми, а столбцы диапазона или сортировки — последними. Индексы не бесплатны: каждый замедляет запись и занимает память, поэтому удаляйте неиспользуемые. Всегда проверяйте через EXPLAIN.
-- "latest orders of a customer" in one index scan
CREATE INDEX orders_customer_created_idx
ON orders (customer_id, created_at DESC);
EXPLAIN
SELECT id, total_minor, status
FROM orders
WHERE customer_id = 42
ORDER BY created_at DESC
LIMIT 20;
05Деньги и время
Никогда не храните деньги в столбце с плавающей точкой: в двоичных float 0.1 + 0.2 не равно 0.3, а ошибки округления накапливаются на тысячах заказов. Используйте целые минорные единицы (центы) в bigint или DECIMAL/NUMERIC с фиксированным масштабом и всегда храните код валюты (ISO 4217, например EUR или USD) рядом с суммой. Цены, скидки, налоги и итоги храните в самом заказе, чтобы история никогда не менялась. Моменты времени храните в UTC. timestamptz в PostgreSQL хранит UTC и конвертирует при отображении; в MySQL у DATETIME нет часового пояса, поэтому храните UTC по соглашению и избегайте TIMESTAMP для дат в далёком будущем, потому что он заканчивается в 2038 году. Для будущих локальных событий, например бронирования на 10:00 в каком-то городе, храните локальное время плюс имя часового пояса IANA, например Asia/Yerevan, потому что правила часовых поясов могут меняться.
- Суммы: bigint в минорных единицах или NUMERIC(19,4), никогда float.
- Всегда храните валюту рядом с суммой.
- Моменты времени — в UTC; будущие локальные события — как локальное время + зона IANA.
06Мягкое удаление и безопасные миграции
Мягкое удаление помечает строку как удалённую (метка времени deleted_at) вместо её физического удаления, так что её можно восстановить и сохранить историю. Цена: каждый запрос должен её отфильтровывать, а уникальные ограничения должны игнорировать удалённые строки. В PostgreSQL это решает частичный уникальный индекс; в MySQL используйте генерируемый столбец. Применяйте мягкое удаление для пользовательского контента, а не для всего подряд; для аудита часто чище append-only таблица истории, а законы о приватности могут требовать настоящего удаления. Миграции меняют живую схему, поэтому делайте их обратно совместимыми с работающим кодом по схеме expand and contract: добавьте новый nullable-столбец, задеплойте код, который пишет в оба, заполните данные пачками, переключите чтение, а затем удалите старый столбец в следующем релизе. Большие индексы стройте без блокировки записи (CREATE INDEX CONCURRENTLY в PostgreSQL).
-- PostgreSQL: email unique only among non-deleted customers
CREATE UNIQUE INDEX customers_email_live_uq
ON customers (lower(email))
WHERE deleted_at IS NULL;
-- expand step: safe on a live table
ALTER TABLE orders ADD COLUMN currency char(3);
-- backfill in batches, then in a later release:
-- ALTER TABLE orders ALTER COLUMN currency SET NOT NULL;
07Пример: базовая схема e-commerce
Вот компактное ядро, которое показывает все идеи вместе. Товары хранят общую информацию; варианты — то, что реально продаётся (размер, цвет, SKU, цена). Заказы принадлежат клиенту, хранят свою валюту и итоги и имеют статус с ограничением CHECK. Позиции заказа ссылаются на вариант, но также хранят снимок названия и цены за единицу, так что заказ, распечатанный через пять лет, по-прежнему покажет, сколько заплатил клиент. Платежи вынесены в отдельную таблицу, потому что у одного заказа может быть несколько попыток, возвратов или частичных списаний. Вокруг этого ядра вы добавили бы адреса (тоже со снимком в заказе), движения склада, применённые промоакции и таблицу истории статусов для аудита.
CREATE TABLE products (id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY, name text NOT NULL);
CREATE TABLE variants (
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
product_id bigint NOT NULL REFERENCES products(id),
sku text NOT NULL UNIQUE,
price_minor bigint NOT NULL CHECK (price_minor >= 0));
CREATE TABLE orders (
id bigint PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
customer_id bigint NOT NULL REFERENCES customers(id),
status text NOT NULL CHECK (status IN ('pending','paid','shipped','cancelled','refunded')),
currency char(3) NOT NULL,
total_minor bigint NOT NULL,
created_at timestamptz NOT NULL DEFAULT now());
CREATE TABLE order_items (
order_id bigint NOT NULL REFERENCES orders(id),
variant_id bigint NOT NULL REFERENCES variants(id),
name_snapshot text NOT NULL,
unit_price_minor bigint NOT NULL,
qty int NOT NULL CHECK (qty > 0),
PRIMARY KEY (order_id, variant_id));
Когда применять
- Перед первой миграцией любого продукта, который хранит бизнес-данные.
- При добавлении крупной функциональности, например вариантов, мультивалютности, мультитенантности или программы лояльности.
- Когда запросы замедляются или в отчётах появляются несоответствия данных.
- При ревью pull request'ов, которые затрагивают миграции.
Когда не стоит
- Одноразовые прототипы и спайки, где быстрая схема годится, пока никто не выкатывает это в прод.
- Не перемоделируйте редкие будущие случаи; проектируйте под следующий реалистичный шаг и держите миграции дешёвыми.
Альтернативы
- Документные базы данных (MongoDB)
Гибкие вложенные документы; хороши для данных, похожих на контент, слабее для реляционных правил и транзакций по нескольким строкам.
- JSON-столбцы в реляционной базе
JSONB или JSON для действительно переменных атрибутов, при этом основные поля остаются реляционными.
- Event sourcing
Каждое изменение хранится как событие, а состояние выводится из них; мощно для доменов с большим объёмом аудита, но намного сложнее.
Лучшие практики
- Обеспечивайте правила в базе: NOT NULL, внешние ключи, ограничения UNIQUE и CHECK.
- Используйте суррогатные первичные ключи, а естественные ключи держите уникальными.
- Создавайте индексы под реальные запросы, со столбцами равенства первыми в составных индексах.
- Храните деньги как целые числа или десятичные с фиксированной точностью вместе с кодом валюты.
- Храните моменты времени в UTC и делайте снимки цен и адресов в заказах.
- Пишите миграции expand-and-contract, безопасные для работающего кода.
- Называйте таблицы, столбцы и ограничения единообразно.
Частые ошибки
- Хранить деньги как FLOAT или DOUBLE.
- Читать текущую цену товара для старых заказов вместо снимка.
- Отсутствие индексов на внешних ключах, из-за чего join'ы и удаления медленные.
- Добавить уникальное ограничение, которое ломается, как только появляются мягко удалённые строки.
- Запускать блокирующую миграцию на большой таблице в часы пик.
Глубже
08Моделирование деревьев: adjacency list, materialised path и closure table
Категории, ветки комментариев, оргструктуры и папки — это деревья, и в SQL есть несколько способов их хранить. Adjacency list даёт каждой строке parent_id: просто, перемещать узел дёшево, а с рекурсивными CTE (WITH RECURSIVE, поддерживаются в PostgreSQL и MySQL 8) читать поддерево вполне нормально при умеренной глубине. Materialised path хранит полный путь строкой, например /1/7/42/; поиск всех потомков — быстрый поиск по префиксу, но перенос ветки переписывает все пути под ней. Расширение ltree в PostgreSQL делает этот паттерн полноценным. Closure table хранит каждую пару предок–потомок с глубиной в отдельной таблице; чтение любого поддерева или цепочки предков становится одним индексированным join ценой большего числа строк и большей работы при вставке и переносе. Nested sets быстры на чтение, но мучительны при обновлении, и сегодня редко того стоят. Выбирайте по доминирующей операции: частые переносы — в пользу adjacency list, тяжёлые чтения поддеревьев — в пользу путей или closure table.
CREATE TABLE category_paths (
ancestor_id bigint NOT NULL REFERENCES categories(id),
descendant_id bigint NOT NULL REFERENCES categories(id),
depth int NOT NULL,
PRIMARY KEY (ancestor_id, descendant_id)
);
-- all products in category 7 and every subcategory, one join
SELECT p.*
FROM category_paths cp
JOIN products p ON p.category_id = cp.descendant_id
WHERE cp.ancestor_id = 7;
09Целостность при конкурентности: ограничения, upsert и оптимистичные блокировки
Проверки в приложении вида «если email не существует, вставь его» — это гонки: два запроса могут оба пройти проверку и оба вставить запись. Только база данных может атомарно обеспечивать правила, поэтому держите их там. UNIQUE-ограничение плюс upsert (INSERT ... ON CONFLICT в PostgreSQL, ON DUPLICATE KEY UPDATE в MySQL) превращает дубликаты в определённый результат. Для внешних повторов, например webhook оплаты или двойного клика по оформлению заказа, храните idempotency key с уникальным ограничением и при повторе возвращайте сохранённый результат. Exclusion constraints в PostgreSQL могут запрещать пересекающиеся диапазоны — это правильный способ предотвратить двойное бронирование. Для записей, которые редактируют люди, оптимистичная блокировка позволяет не держать блокировки, пока кто-то печатает: заведите колонку version, обновляйте WHERE version = прочитанное значение и считайте ноль затронутых строк конфликтом, который нужно показать пользователю. Пессимистичные блокировки (SELECT ... FOR UPDATE) подходят для коротких горячих путей вроде списания остатков.
-- optimistic lock: succeeds only if nobody changed the row since we read version 7
UPDATE products
SET price_cents = 1990, version = version + 1
WHERE id = 42 AND version = 7;
-- 0 rows affected => someone else saved first; reload and ask the user
-- PostgreSQL: no two bookings of a room may overlap
CREATE EXTENSION IF NOT EXISTS btree_gist;
ALTER TABLE bookings ADD CONSTRAINT no_overlap
EXCLUDE USING gist (room_id WITH =, during WITH &&);
10Рост: партиционирование, retention и архивирование
Большинству таблиц партиционирование никогда не понадобится. Таблицам, в которые в основном дописывают и которые разрастаются до сотен миллионов строк — события, логи, журналы аудита или старые заказы, — рано или поздно понадобится. Партиционирование делит одну логическую таблицу на физические части по ключу, обычно по диапазону времени на месяц. Запросы с фильтром по этому ключу трогают только подходящие партиции (partition pruning), индексы остаются достаточно маленькими, чтобы помещаться в память, а retention становится мгновенным: отсоединение или удаление старой партиции заменяет DELETE миллионов строк, который раздул бы таблицу и завалил репликацию. Правила, которые нужно знать: первичный ключ и каждое уникальное ограничение должны включать ключ партиционирования — и в PostgreSQL, и в MySQL; запросы без фильтра по ключу сканируют все партиции; слишком много партиций замедляет планирование, поэтому сотни — нормально, десятки тысяч — нет. Создавайте будущие партиции заранее плановой задачей или расширением вроде pg_partman. Отсоединённые партиции архивируйте в более дешёвое хранилище, если их нужно сохранить.
CREATE TABLE events (
id bigint GENERATED ALWAYS AS IDENTITY,
created_at timestamptz NOT NULL,
type text NOT NULL,
payload jsonb,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
CREATE TABLE events_2026_11 PARTITION OF events
FOR VALUES FROM ('2026-11-01') TO ('2026-12-01');
-- retention: instant, no row-by-row delete
ALTER TABLE events DETACH PARTITION events_2025_11;
DROP TABLE events_2025_11;
11Паттерны, которые воюют с базой: полиморфные связи, EAV и ссылки между тенантами
Некоторые популярные паттерны тихо жертвуют целостностью. Полиморфные ассоциации, например комментарии с commentable_type и commentable_id, не могут иметь foreign key, поэтому сироты и опечатки остаются незамеченными. Альтернативы: по одному nullable foreign key на каждый тип родителя с CHECK, что задан ровно один, или отдельная связующая таблица на каждого родителя. Таблицы entity-attribute-value (entity_id, attribute, value) выглядят гибкими, но превращают каждое чтение во множество join и хранят каждое значение как текст; JSONB-колонка для изменчивой части с настоящими колонками для того, по чему вы фильтруете, почти всегда лучше. В мультитенантных схемах foreign key только по customer_id не мешает заказу ссылаться на клиента другого тенанта. Сделайте тенант частью ключа: уникальное ограничение на (tenant_id, id) у родителя и составной foreign key (tenant_id, customer_id) у потомка. Тогда ссылки между тенантами невозможны по построению, а не благодаря код-ревью.
ALTER TABLE customers ADD CONSTRAINT uq_customers_tenant_id UNIQUE (tenant_id, id);
ALTER TABLE orders ADD CONSTRAINT fk_orders_customer_same_tenant
FOREIGN KEY (tenant_id, customer_id) REFERENCES customers (tenant_id, id);
-- exclusive arc instead of a polymorphic pair
ALTER TABLE comments ADD CONSTRAINT one_parent CHECK (
num_nonnulls(post_id, product_id) = 1
);
Реальные ситуации
Дубли заказов из-за двойных кликов и повторов
Проблема
Нескольким клиентам каждую неделю списывают деньги дважды. Логи показывают два запроса оформления заказа с разницей в миллисекунды, и оба проходят проверку «уже заказано?» в коде приложения.
Как решить
Перенесите гарантию в базу данных. Клиент генерирует idempotency key (UUID) при загрузке формы оформления и отправляет его с каждой попыткой. Добавьте в orders колонку idempotency_key с UNIQUE-ограничением или отдельную таблицу идемпотентности с ключом, хэшем запроса и ответом. Вставляйте заказ и ключ в одной транзакции; конкурентный дубль падает на ограничении, и обработчик возвращает сохранённый результат вместо создания второго заказа. Передавайте тот же ключ платёжному провайдеру, поскольку большинство из них поддерживают идемпотентные запросы. Из вежливости блокируйте кнопку после первого клика, но никогда на это не полагайтесь. Храните ключи как минимум в течение окна повторов, обычно 24 часа или больше.
Таблица событий на 400 миллионов строк тормозит всё
Проблема
Таблица событий росла годами. Ночные DELETE старых строк идут часами, реплики отстают, бэкапы огромные, а дашборды по свежим данным медленные.
Как решить
Партиционируйте по месяцам по created_at — для этого первичный ключ должен включать created_at. Создайте новую партиционированную таблицу, пишите в обе или переключите запись в спокойный момент и догрузите последние месяцы пакетами; старая история может оставаться в легаси-таблице, пока не устареет. Замените ночной DELETE задачей, которая отсоединяет и удаляет партиции старше срока хранения, при необходимости сначала архивируя их в дешёвое хранилище. Дашборды с фильтром по дате теперь трогают одну-две маленькие партиции. Для тяжёлых агрегатов добавьте ежедневную сводную таблицу, обновляемую задачей, чтобы дашборды вообще перестали сканировать сырые события. Создавайте партицию следующего месяца автоматически за несколько недель.
Каждой категории товаров нужны свои атрибуты
Проблема
Обуви нужны размер и полнота, вину — год урожая и сорт винограда, электронике — напряжение. Команда спорит: колонка на каждый атрибут, EAV-таблица или JSON.
Как решить
Разделите атрибуты по тому, как они используются. Всё, что есть у каждого товара или по чему вы делаете join, сортируете или ставите ограничения (цена, остаток, бренд, статус), остаётся настоящей колонкой. Длинный хвост уходит в JSONB-колонку (или JSON в MySQL), валидируется схемой приложения для каждой категории и индексируется GIN там, где вы фильтруете по вхождению. Определите допустимые атрибуты каждой категории в таблице определений атрибутов, чтобы админка и валидация управлялись данными. Если какой-то атрибут становится центральным для поиска или сортировки, повысьте его до generated column с обычным индексом. Избегайте EAV: он множит join, теряет типы и делает простые фильтры медленными. Фасетный поиск по атрибутам всё равно обычно место поискового движка.
Шпаргалка
- Сделать дубликаты невозможными
ALTER TABLE orders ADD CONSTRAINT uq_orders_idem UNIQUE (idempotency_key);- Upsert в PostgreSQL
INSERT INTO prices (sku, cents) VALUES ('A1', 990) ON CONFLICT (sku) DO UPDATE SET cents = EXCLUDED.cents;- Upsert в MySQL 8
INSERT INTO prices (sku, cents) VALUES ('A1', 990) AS new ON DUPLICATE KEY UPDATE cents = new.cents;- Проверка оптимистичной блокировки
UPDATE t SET ..., version = version + 1 WHERE id = ? AND version = ?; -- 0 rows = conflict- Без пересекающихся бронирований (PostgreSQL)
EXCLUDE USING gist (room_id WITH =, during WITH &&)- Foreign key в пределах тенанта
FOREIGN KEY (tenant_id, customer_id) REFERENCES customers (tenant_id, id)- Задан ровно один родитель
CHECK (num_nonnulls(post_id, product_id) = 1) -- PostgreSQL- Ограничить колонку статуса
CHECK (status IN ('pending', 'paid', 'shipped', 'cancelled'))- Keyset-пагинация (стабильная и быстрая)
WHERE (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT 20- Мгновенно удалить старые данные
ALTER TABLE events DETACH PARTITION events_2025_11; DROP TABLE events_2025_11;
В моих реальных проектах
- На e-commerce платформе зоотоваров я работал примерно с 90 моделями в 12 доменных папках, где ценообразование смоделировано через value objects, чтобы поддерживать продажу упаковками и на вес.
- В мультитенантном POS SaaS со 181 моделью Prisma каждый запрос несёт явный company id, а расширение Prisma выбрасывает ошибку, если tenant scope отсутствует.
- В том же SaaS ключи идемпотентности для партнёрских заказов и дедуплицированные команды офлайн-синхронизации сохраняют данные согласованными при повторных запросах.
- Платформа бронирования туров, в которую я внёс вклад, поддерживает мультивалютные цены в USD, EUR и AMD с многоуровневым кэшированием курсов.
Вопросы и ответы
Q1Когда вы бы денормализовали схему?
Я нормализую по умолчанию и денормализую по двум причинам. Первая — история: заказ должен сохранить название товара, цену, налог и адрес такими, какими они были в момент покупки, поэтому они намеренно копируются в заказ и его позиции. Вторая — производительность чтения: счётчик, заранее посчитанный итог или отчётная таблица, когда join живых данных слишком медленный. В обоих случаях я решаю, кто поддерживает копию в корректном состоянии — транзакция в рамках той же записи, триггер или фоновая джоба, — и можно ли её пересобрать из источника истины. Денормализация без владельца — это то, с чего отчёты начинают расходиться.
Q2UUID или автоинкрементный bigint в качестве первичного ключа?
Зависит от того, где создаются id и кто их видит. Столбцы bigint identity занимают 8 байт, быстрые и вставляются по порядку, что сохраняет B-tree индексы компактными; они отлично подходят для внутренних таблиц. Их минусы — их можно угадать, и они должны приходить из базы. UUID могут генерировать клиенты, офлайн-устройства или другие сервисы, и они не раскрывают количество записей в URL. Случайные значения UUIDv4 вредят производительности вставки на больших таблицах, потому что попадают в разные места индекса, поэтому я предпочитаю упорядоченные по времени UUIDv7. Распространённый компромисс — внутренний первичный ключ bigint плюс уникальный публичный UUID для API.
Q3Как хранить деньги и цены?
Никогда не в формате с плавающей точкой, потому что двоичные float не могут точно представить большинство десятичных дробей, и ошибки накапливаются. Я использую целые минорные единицы в bigint, например центы, или DECIMAL/NUMERIC с фиксированной точностью, и всегда храню ISO-код валюты рядом с суммой. Округление происходит в одном месте по задокументированному правилу. Заказы хранят собственные цены за единицу, скидки, налоги и итоги на момент покупки, так что последующее изменение цены никогда не меняет историю. Для мультивалютности я храню сумму в валюте, которой заплатил клиент, плюс использованный курс обмена, а не только сконвертированное значение.
Q4Как провести миграцию на большой продакшен-таблице без простоя?
Я использую expand and contract, чтобы во время деплоя работали и старый, и новый код. Expand: добавляю новые столбцы как nullable или с константным значением по умолчанию — большинство современных баз делают это без перезаписи таблицы — и создаю индексы без блокировки записи через CREATE INDEX CONCURRENTLY в PostgreSQL или online DDL в MySQL. Затем деплою код, который пишет в оба места, заполняю старые строки небольшими пачками и переключаю чтение. Contract: добавляю NOT NULL или ограничения, когда данные чистые, и удаляю старые столбцы в следующем релизе. Ещё я ставлю lock timeout, чтобы миграция быстро падала, а не вставала в очередь за длинными транзакциями.
Q5Каковы плюсы и минусы мягкого удаления?
Мягкое удаление ставит метку времени deleted_at вместо удаления строки. Плюсы — простая отмена, сохранённые ссылки из других таблиц и простой журнал аудита. Минусы вполне реальны: каждый запрос должен исключать удалённые строки, и если забыть об этом, данные утекают; уникальные ограничения должны игнорировать удалённые строки, для чего нужны частичные индексы или генерируемые столбцы; таблицы растут бесконечно; а законы о приватности вроде GDPR могут требовать настоящего стирания. Я использую мягкое удаление для пользовательского контента, который люди могут захотеть вернуть, а для всего остального — настоящее удаление плюс таблицу аудита или истории. Scope'ы фреймворка помогают, но не защищают сырой SQL и отчёты.
Q6Как вы выбираете, какие индексы создавать?
Исходя из запросов, а не из столбцов. Я составляю список частых и критичных запросов, смотрю на их WHERE, JOIN и ORDER BY и проектирую индексы под них. Внешним ключам, используемым в join'ах, индекс нужен почти всегда. В составных индексах я ставлю фильтры равенства первыми, а столбец диапазона или сортировки — последним из-за правила левого префикса, а иногда включаю дополнительные столбцы, чтобы запрос обслуживался только индексом. Затем проверяю через EXPLAIN на реалистичных данных. Слежу и за обратной стороной: каждый индекс замедляет вставки и обновления и занимает память, поэтому я удаляю индексы, которые, судя по статистике использования, никогда не читаются.
Вопросы уровня senior
S1Как бы вы спроектировали балансы для кошелька или системы бонусных баллов?
Я никогда не храню баланс как единственную истину. Источник истины — append-only журнал (ledger): каждое движение — строка со счётом, суммой со знаком в минорных единицах, валютой, типом, ссылкой и idempotency key, а строки никогда не обновляются и не удаляются; исправления — это новые сторнирующие записи. Для переводов я использую двойную запись: две строки с суммой ноль, записанные в одной транзакции. Текущий баланс — производная величина, но суммировать миллионы строк на каждый запрос медленно, поэтому я держу колонку balance на счёте и обновляю её в той же транзакции, что и вставку в журнал, с блокировкой строки или условным обновлением вроде WHERE balance + amount >= 0, чтобы не допустить ухода в минус. Периодическая задача сверяет кэшированные балансы с суммами журнала. Компромисс — больше строк и более строгие пути записи в обмен на полный аудиторский след и возможность объяснить каждое число.
S2Когда одной реляционной базы перестаёт хватать и что вы пробуете в первую очередь?
Гораздо позже, чем думает большинство команд. Прежде чем добавлять системы, я по порядку исчерпываю дешёвые шаги: чиню запросы и индексы по реальным данным slow query; добавляю пул соединений; масштабирую сервер вертикально, ведь современные машины с большой памятью и NVMe-дисками тянут многое; добавляю кэш для горячих чтений с понятной инвалидацией; переношу на реплики чтения, которые терпят небольшое отставание, например отчёты; и партиционирую несколько огромных append-only таблиц. Затем смотрю на форму нагрузки. Аналитике место в колоночном хранилище, полнотекстовому и фасетному поиску — в поисковом движке, очередям — в очереди. Шардирование основной базы — последнее средство, потому что оно усложняет каждый запрос, транзакцию и миграцию. Когда оно действительно нужно, шардирование по тенанту обычно даёт самую чистую границу, часто через расширение или managed-сервис, а не через самописный код маршрутизации.
S3Как моделировать данные, которые должны оставаться аудируемыми годами и при этом соответствовать запросам на удаление?
Я отделяю бизнес-запись от персональных данных. Заказы, счета и записи журнала неизменяемы и сохраняют снимок того, что им нужно, — цены, налоги, платёжный адрес, — потому что бухгалтерское законодательство обычно требует хранить их годами. История изменений фиксируется либо в append-only таблице истории для каждой сущности, которую пишут триггеры или приложение, либо через system-versioned таблицы там, где их поддерживает база, как MariaDB. Event sourcing — крайний вариант; он даёт идеальный аудиторский след, но усложняет запросы и изменения схемы, поэтому я использую его только для ключевых финансовых потоков. Для запросов на удаление персональные данные живут в как можно меньшем числе мест и подключаются по id; тогда удаление означает анонимизацию этих строк или удаление персонального ключа шифрования, после чего зашифрованные копии становятся нечитаемыми. Там, где действует законное требование хранения, оно побеждает, и я документирую, какие данные под него попадают.
Глоссарий
- Adjacency list
- Хранение дерева, при котором у каждой строки есть parent_id, указывающий на родительскую строку.
- Closure table
- Таблица со всеми парами предок–потомок дерева, благодаря которой запрос поддерева — это один join.
- Idempotency key
- Уникальный id, сгенерированный клиентом, по которому сервер узнаёт повторный запрос и безопасно его игнорирует.
- Оптимистичная блокировка
- Обнаружение конфликтующих правок через колонку version в момент записи вместо блокировки строк при чтении.
- Exclusion constraint
- Ограничение PostgreSQL, запрещающее любые две строки с конфликтующими значениями, например пересекающимися интервалами времени.
- Partition pruning
- Планировщик пропускает партиции, в которых не может быть подходящих строк, исходя из фильтра запроса.
- Полиморфная ассоциация
- Одна пара колонок type и id, указывающая на строки в разных таблицах; у неё не может быть foreign key.
- EAV
- Entity-attribute-value: каждый атрибут хранится отдельной строкой. Гибко, но медленно и со слабой типизацией.
- Keyset-пагинация
- Постраничный вывод по значениям сортировки последней строки вместо OFFSET, чтобы глубокие страницы оставались быстрыми.
- Exclusive arc
- Несколько nullable foreign key в одной таблице с CHECK, что заполнен ровно один.
Что изучить дальше
Есть идея продукта?Давайте создадим его.
Расскажите о своей идее, продукте или команде. Обычно отвечаю в течение одного рабочего дня.