Задача звучит просто: проверить, что при отмене заказа создаётся возврат. Открываете базу стенда — сорок таблиц с именами вроде order_items и idempotency_keys, и непонятно, где искать. Ориентироваться помогает схема — описание базы: какие есть таблицы, какие в них колонки и типы, что обязательно и какая таблица на какую ссылается. Пишут её разработчики, а читают все: видно, где лежат нужные данные, какое поле нельзя оставить пустым и что удалится вместе с заказом.
Двумя таблицами связь «многие ко многим» не записывается: в колонке помещается одно значение. Поэтому между ними появляется третья таблица с парой ключей — и со своим полем 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 действует внутри базы; шаг во внешний сервис не откатывается, там проверяют повторы.
Что почитать дальше
- Что такое база данных и таблицы — с чего начинается схема: строки, столбцы, ключ.
- JOIN: собрать данные из нескольких таблиц — как ходить по связям из схемы.
- INSERT, UPDATE, DELETE и транзакции — BEGIN, COMMIT и ROLLBACK руками на стенде.
- NULL и типы данных: где все спотыкаются — что будет, когда колонка разрешает пустое значение.