PostgreSQL CookbookUse casesКапстон: собери схему Brew
0 / 63 (0%)

Капстон: собери схему Brew

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

Каждую из этих проблем мы уже разбирали по отдельности. Капстон собирает их в один маленький подсервис и показывает, как они работают вместе.

Бизнес-правило живёт в БД, а не в приложении

Программа лояльности — это две таблицы. cap_members хранит члена клуба: id, email, уровень (tier), баланс. cap_ledger — журнал начислений: кто, сколько, за что. И вся механика лояльности — это аккуратный выбор типов и ограничений на этих двух таблицах.

Деньги — это balance_cents bigint: баланс в копейках целым числом (модуль 01). Никакого numeric для денег в горячем пути и тем более float: 15.00 ₽ — это 1500, складывается и сравнивается точно. Форматирование «копейки → рубли» делает приложение на выводе.

Дальше — ограничения, которые превращают правило бизнеса в гарантию БД (модуль 02): PRIMARY KEY на id, UNIQUE на email (один человек — один аккаунт), CHECK (tier IN ('bronze','silver','gold')) (уровень только из набора), CHECK (balance_cents >= 0) (в минус не уходим), NOT NULL на обязательных полях. cap_ledger.member_id — внешний ключ REFERENCES cap_members(id): начисление не может висеть на несуществующем члене клуба.

Смысл в том, что эти проверки стоят в базе, а не в одном из сервисов. У Brew два бэкенда пишут в одну базу; правило, зашитое только в Go-коде одного из них, второй обойдёт. Ограничение в схеме отбивает мусор от любого писателя — даже от ручного psql в три часа ночи.

Гонка за один баланс и петля ретрая

Последняя сцена капстона — два процесса трогают один баланс одновременно. Бариста жмёт «начислить бонус» под SERIALIZABLE, а ночной job в ту же секунду капает проценты. Конфликт здесь не случайный: демо подводит его детерминированно (как 05-05), чтобы 40001 падал на каждом прогоне в одном и том же месте:

plaintext
Старт: баланс Алисы 15.00 ₽
 
  ① бонус: BEGIN, читает баланс 15.00
  ② проценты: +1.00, COMMIT ──► баланс 16.00          (ночной job коммитит первым)
  ③ бонус: пишет +5.00 поверх снимка 15.00, COMMIT ──► ✗ 40001 (read/write-конфликт)
  ④ withRetry: открывает свежий снимок, читает 16.00
  ⑤ бонус: пишет +5.00, COMMIT ──► ✓ 21.00
 
  21.00 = 15.00 + 1.00 (проценты) + 5.00 (бонус) — ничего не потеряно

SERIALIZABLE ловит, что снимок бонуса устарел между чтением и записью, и отбивает транзакцию 40001. Петля withRetry повторяет её на свежем снимке — и баланс сходится до копейки, хотя оба процесса дрались за одну строку.

Что показывает наш код

Это escape-hatch-юнит на raw-pgx (есть go.mod, но sqlc тут не протагонист): капстон строит схему DDL'ом, читает планы через EXPLAIN и крутит ретрай-петлю — для этого нужна интерактивность, которой sqlc не даёт. Работаем на лабораторных столах cap_members / cap_ledger: buildSchema делает DROP ... CASCADE + CREATE, поэтому сборка идемпотентна, а схему Brew мы не трогаем.

cmd/demo/main.go проходит подсервис из конца в конец пятью шагами. (1) Собирает схему. (2) Заводит трёх членов клуба через INSERT ... RETURNING id — id генерит GENERATED ALWAYS AS IDENTITY, и его возвращает сам INSERT, без второго запроса (модуль 03). (3) Бросает в базу четыре заведомо плохие записи и печатает не текст ошибки, а SQLSTATE каждого отказа. (4) Сравнивает план точечной выборки EXPLAIN-ом до и после CREATE INDEX. (5) Начисляет Алисе бонус под SERIALIZABLE и переживает конфликт сериализации через ретрай.

Конфликт в шаге 5 создаётся детерминированно (как в 05-05): после того как транзакция бонуса прочитала баланс, nightlyInterest синхронно вклинивается отдельной транзакцией — «ночное начисление процентов +1.00 ₽» — и коммитит первой. Пара read/write-зависимостей даёт 40001, и петля withRetry повторяет бонус на свежем снимке.

Запуск

sh
docker compose up -d
make lecture L=10-use-cases/10-01-build-the-brew-schema-capstone T=db-reset
make lecture L=10-use-cases/10-01-build-the-brew-schema-capstone

T=run — режим по умолчанию, его можно не писать. Изнутри каталога юнита короче: make db-reset, затем make run. А make test прогоняет интеграционный тест с ассертами против песочницы — он проверяет, что финальный баланс действительно сходится и что отказы дают именно те SQLSTATE, что напечатаны ниже.

plaintext
1) Схема собрана: cap_members (типы + PK + UNIQUE + CHECK), cap_ledger (FK на члена).
 
2) CRUD с RETURNING: завели трёх членов клуба, id вернул сам INSERT:
   id  email               уровень  баланс, ₽
   1   alice@brew.example  gold     15.00
   2   bob@brew.example    silver   3.00
   3   carol@brew.example  bronze   0.00
 
3) Ограничения отбивают мусор (печатаем SQLSTATE, а не текст ошибки):
   попытка                               результат
   дубль email (UNIQUE)                  отбито, SQLSTATE 23505
   уровень вне набора (CHECK tier)       отбито, SQLSTATE 23514
   отрицательный баланс (CHECK balance)  отбито, SQLSTATE 23514
   запись на несуществующего члена (FK)  отбито, SQLSTATE 23503
 
4) Индекс, проверенный планом (EXPLAIN до и после CREATE INDEX):
   до индекса:    выборка по member_id=2 → Seq Scan
   после индекса: та же выборка        → Index Scan
 
5) Транзакция с ретраем на 40001 (начисление бонуса под SERIALIZABLE):
   старт: баланс Алисы 15.00 ₽
   (параллельно: ночное начисление +1.00 ₽ закоммитило первым — конфликт назревает)
   попытка 1: прочитал 15.00 ₽, пишу +5.00 ₽
   ↻ упало: 40001 (serialization_failure) — повторяю на свежем снимке
   попытка 2: прочитал 16.00 ₽, пишу +5.00 ₽
   ✓ COMMIT успешен за 2 попытки; итог 21.00 ₽ (старт +1.00 от процентов +5.00 бонус)

Шаги 1–3 читаются прямо: трёх членов завели, id вернул INSERT, а четыре плохие записи отбиты — 23505 за дубль email, 23514 за уровень вне набора и за отрицательный баланс, 23503 за начисление на несуществующего члена. Эти коды — контракт: приложение ловит их и показывает человеку понятную ошибку, а не падает.

В шаге 4 та же выборка WHERE member_id = 2 до CREATE INDEX идёт Seq Scan'ом (база читает все 20003 строки журнала), а после — Index Scan'ом (находит три строки Бориса по индексу). В шаге 5 первая попытка прочитала 15.00, но пока она писала, проценты уже закоммитили — база отбивает её 40001; вторая попытка видит свежие 16.00 и коммитит 21.00. Старт 15.00 + 1.00 процентов + 5.00 бонус = 21.00: ничего не потерялось, хотя два процесса трогали баланс одновременно.

Заборчик

  • Ретрай-петля под SERIALIZABLE обязательна, а не опциональна. База вправе отбить транзакцию 40001 в любой момент, и без withRetry ты просто потеряешь начисление — это часть контракта уровня изоляции (мы разбирали его в 05-04 и катали ту же петлю в 05-05). В проде такую петлю обычно даёт обёртка над пулом или ORM, но логика та же: лови 40001, открывай свежую транзакцию, повторяй.
  • EXPLAIN объясняет план одного запроса — и только. Он не говорит, здоров ли индекс в целом, не растёт ли таблица, успевает ли autovacuum. Это вопросы здоровья БД: их смотрят по pg_stat_statements и системным представлениям, и это территория твоего DBA, а не нашего демо (мы трогали EXPLAIN в модуле 06).
  • Селективность индекса решает, выиграет ли он. Здесь member_id отбирает три строки из двадцати тысяч, поэтому индекс выигрывает; на низкоселективном столбце он мог бы и не пригодиться.
  • Лабораторные cap_* — упрощение ради изоляции капстона. Настоящие базовые таблицы Brew (orders, outbox, drinks) мы здесь не трогаем, чтобы DROP не задел таблицы, которые в 10-05 уезжают в CDC.

Что забрать с собой

Маленький подсервис лояльности прошёл через весь курс. Деньги — bigint-копейки (01). Правило бизнеса живёт ограничением PK/UNIQUE/CHECK/FK/NOT NULL в самой БД, поэтому мусор не попадёт внутрь, кто бы его ни писал (02). INSERT ... RETURNING отдаёт сгенерированный id одним запросом (03). Точечную выборку решает индекс, и EXPLAIN это доказывает — Seq Scan до, Index Scan после (06). А конкурентную запах одного баланса страхует SERIALIZABLE + обязательная петля ретрая на 40001, и баланс сходится до копейки (05). Связать это воедино — и есть навык: отдельные приёмы складываются в систему, где плохим данным и потерянным начислениям просто нет места.

Дальше — второй капстон, 10-02: движок цен и промо. Там мы соберём расчёт итоговой цены заказа со скидками и акциями — снова на стыке типов, ограничений и запросов, но уже вокруг другой бизнес-задачи: как посчитать, сколько с клиента взять, и не дать промокоду увести цену в минус.

·Модуль 11

Этот урок ещё впереди

Курс изучается по порядку — чтобы открыть этот шаг, сначала завершите предыдущие. Так контекст накапливается без пропусков.

/ вы пытались открыть
Use cases / Капстон: собери схему Brew