← назад к разделу

Задача звучит просто: проверить, что при отмене заказа создаётся возврат. Открываете базу стенда — сорок таблиц с именами вроде order_items и idempotency_keys, и непонятно, где искать. Ориентироваться помогает схема — описание базы: какие есть таблицы, какие в них колонки и типы, что обязательно и какая таблица на какую ссылается. Пишут её разработчики, а читают все: видно, где лежат нужные данные, какое поле нельзя оставить пустым и что удалится вместе с заказом.

в заказе много товаров, товар — во многих заказах ordersidstatusord-07PAIDord-21NEW productsidtitleprd-01мышьprd-03стол ?в колонку помещается одно значение —ссылку на «много товаров» записать некуда order_items — связующая таблицаorder_idproduct_idqtyord-07prd-012ord-07prd-031ord-21prd-015 order_idproduct_idдве связи «один ко многим» вместо одной «многие ко многим»

Двумя таблицами связь «многие ко многим» не записывается: в колонке помещается одно значение. Поэтому между ними появляется третья таблица с парой ключей — и со своим полем quantity, которое относится к паре, а не к заказу и не к товару.

Схема — это правила, а не данные

Таблицу не рисуют мышкой — её объявляют запросом, и он остаётся её описанием: клиент показывает его вкладкой DDL. Вот так объявлена orders в песочнице курса:

CREATE TABLE orders (
    id            VARCHAR(36) PRIMARY KEY,
    customer_id   VARCHAR(36) NOT NULL,
    seller_id     VARCHAR(36) NOT NULL,
    status        VARCHAR(32) NOT NULL,
    currency      VARCHAR(3) NOT NULL,
    total_amount  NUMERIC(15,2) NOT NULL,
    created_at    TIMESTAMP NOT NULL,
    paid_at       TIMESTAMP
);

Читается построчно: имя, тип, ограничения. NOT NULL — поле обязательное, строку без него база не примет. А paid_at объявлена без него — подсказка: заказ без даты оплаты существует, и это состояние надо проверить.

ALTER TABLE orders ADD COLUMN cancelled_at TIMESTAMP; меняет существующую таблицу: добавляет колонку, ставит или снимает ограничение. DROP TABLE orders; удаляет её вместе со строками, без вопросов и без корзины: чинится это только из резервной копии. Эти команды мы читаем, а не запускаем: схему стенда меняет разработчик.

Ключи: первичный, суррогатный, внешний

На строку надо как-то сослаться. Первичный ключ — колонка, значение которой уникально и не пусто: по нему строка находится однозначно. Почти всегда это ничего не означающий idсуррогатный ключ, UUID или число, которое база выдаёт сама. Взять вместо него почту заманчиво, но почта меняется, а на ключ уже ссылаются другие таблицы.

Внешний ключ — обещание, что значение в колонке существует в другой таблице:

ALTER TABLE payments
    ADD CONSTRAINT fk_payments_order
    FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE;

Отсюда две вещи. Платёж с несуществующим order_id в базу не попадёт — откажет сама база, даже если приложение попросит. И хвост ON DELETE решает судьбу связанных строк: CASCADE — удаляют заказ, молча исчезают его платежи и позиции; RESTRICT — база не даст удалить заказ, пока на него ссылаются. Ровно здесь рождается дефект «удалили товар — из старых заказов пропали позиции».

Откуда берётся третья таблица

В заказе много товаров, и один товар лежит во многих заказах — двумя таблицами это не записать. Поэтому появляется третья, связующая таблица: в песочнице это order_items, где строка означает «в заказе ord-07 товар prd-01, две штуки».

Заодно там лежат поля пары: quantity и unit_price — цена на момент покупки. Поэтому подорожание не переписывает историю: поменяли цену в products — старые order_items обязаны остаться прежними.

Сумма заказа лежит в двух местах: в orders.total_amount и в позициях. Расхождение находит один запрос, пустой результат значит «сходится»:

живой пример

SELECT o.id, o.total_amount, sum(i.unit_price * i.quantity) AS items_total
FROM orders o
JOIN order_items i ON i.order_id = o.id
GROUP BY o.id, o.total_amount
HAVING sum(i.unit_price * i.quantity) <> o.total_amount;
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Неделя бесплатно →

Типы данных и CHAR против VARCHAR

Тип колонки — граница, которую выбрали до вас, а проверять вам. VARCHAR(255) — готовая пара проверок на 255 и 256 символов, NUMERIC(15,2) — вопрос про третий знак после запятой, INT — счётчик упрётся в 2 147 483 647. Деньги держат в NUMERIC, а не в FLOAT: дробные типы хранят приблизительно, и на копейках это вылезает в итогах.

Строковых типов два. VARCHAR(3) хранит ровно записанное: RUB — три символа. CHAR(3) хранит фиксированную длину и добивает короткие значения пробелами: RU превратится в RU␣. Из-за этого в выгрузках появляются хвосты пробелов, а сравнение с 'RU' в разных базах ведёт себя по-разному. Поэтому по умолчанию берут VARCHAR, а CHAR оставляют полям строго фиксированной длины — коду валюты или страны.

Представление: у запроса появилось имя

«Посмотри витрину оплаченных заказов» звучит так, будто такая таблица есть, а в схеме её нет. Скорее всего, речь о представлении — сохранённом под именем запросе: обращаются к нему как к таблице, но данных он не хранит, при каждом чтении выполняется его SELECT.

CREATE VIEW paid_orders AS
SELECT id, customer_id, total_amount, paid_at
FROM orders
WHERE status = 'PAID';

SELECT * FROM paid_orders WHERE paid_at >= DATE '2026-01-01';

Заводят их ради двух вещей. Соединение шести таблиц пишут один раз, а читают все — и в отчётах перестаёт расходиться, что считать оплаченным. Вторая — права: доступ дают к представлению без колонки с почтой, не открывая таблицу.

Данные в представлении всегда свежие — этим оно отличается от материализованного, которое хранит посчитанный результат и обновляется по расписанию. Если «в базе есть, а в витрине нет», сначала спрашивают, когда её обновляли: отставание на час бывает не дефектом, а устройством.

ACID: почему оплата не зависает наполовину

Оплата — не одно действие: строка в payments, смена статуса заказа, запись события. Упади процесс посередине — заказ окажется оплачен деньгами, но не статусом. От этого защищает транзакция, и её гарантии описывают четырьмя буквами.

Атомарность — шаги применяются все или ни один, на середине база не остановится. Согласованность — зафиксировать можно только состояние, не нарушающее правил схемы: платёж на несуществующий заказ не пройдёт из-за внешнего ключа. Изолированность — параллельные транзакции не видят половинчатых изменений друг друга: двое покупателей последнего товара встают в очередь, а не забирают его оба. Долговечность — после COMMIT данные переживут перезапуск сервиса и выключение машины.

Граница у гарантий одна — сама база. Шаг, ушедший во внешний платёжный сервис, откатить нельзя: деньги списаны там, строки нет здесь. Отсюда проверки: платёж прошёл, а ответ не дошёл; клиент повторил запрос. В песочнице на это намекает idempotency_keys — она и нужна, чтобы повтор не создал второй заказ.

Коротко

  • Схема — правила, а не данные: CREATE TABLE объявляет колонки и ограничения, ALTER меняет, DROP сносит со строками.
  • NOT NULL показывает обязательные поля, тип задаёт границы проверок: деньги — NUMERIC, не FLOAT, а CHAR добивает значение пробелами.
  • Первичный ключ берут суррогатным — он не меняется, потому что ничего не значит; ON DELETE у внешнего ключа говорит, что исчезнет вместе с родительской строкой.
  • Связь «многие ко многим» живёт в третьей таблице, там же поля пары — количество и цена на момент покупки.
  • Представление всегда отдаёт свежие данные, материализованное хранит результат и отстаёт до обновления.
  • ACID действует внутри базы; шаг во внешний сервис не откатывается, там проверяют повторы.

Что почитать дальше