Движок цен и промо
У Brew раздолье акций: латте дорожает с февраля, промокод SUMMER действует всё
лето, осенью его сменяет AUTUMN. И тут всплывает класс багов, который данными в
приложении не лечится. Менеджер по ошибке завёл напитку две цены на один и тот
же день — касса не знает, какую брать. Маркетолог запустил тот же промокод
SUMMER с наслаивающимся окном — скидка применяется дважды или не применяется
вовсе, зависит от того, какую строку выберет планировщик. Это не «плохой запрос»,
а отсутствующий инвариант: «у напитка ровно одна цена в любой момент» и «окна
одного промокода не пересекаются». Такие правила нельзя держать в коде сервиса —
их проверяет каждый, кто пишет в таблицу, и рано или поздно кто-то проверить
забудет. Инвариант должен жить в схеме.
Postgres умеет держать «эти интервалы не пересекаются» прямо в CREATE TABLE.
Temporal PK (PG18): у напитка одна цена в каждый момент
PG18 добавил в первичный ключ слово WITHOUT OVERLAPS. Цены напитка живут в
таблице price_periods, где интервал действия — это столбец типа tstzrange:
CREATE TABLE price_periods (
drink_id bigint NOT NULL,
price_cents bigint NOT NULL CHECK (price_cents > 0),
valid tstzrange NOT NULL,
PRIMARY KEY (drink_id, valid WITHOUT OVERLAPS)
);Ключ читается так: «для одного drink_id диапазоны valid не должны
пересекаться». Скалярная часть (drink_id) сравнивается на равенство, а
range-часть (valid) — на пересечение. Два смежных периода ([01-01, 02-01)
и [02-01, 03-01)) принимаются: границы у tstzrange полуоткрытые [from, to),
так что общий конец 02-01 в пересечение не считается. А период [01-15, 02-15),
который накрывает оба, отбивается ошибкой exclusion_violation — SQLSTATE
23P01. Это современный способ PG18 сказать «никаких двух цен на один момент»
одной строкой в определении таблицы.
Чтобы сравнивать скалярный drink_id на равенство внутри gist-ключа, нужно
расширение btree_gist — CREATE EXTENSION IF NOT EXISTS btree_gist.
Классический EXCLUDE (до PG18): тот же запрет на промо
WITHOUT OVERLAPS — новинка, но сама гарантия «без пересечений» жила в Postgres
задолго до 18-й версии, через ограничение-исключение EXCLUDE USING gist
(см. модуль 02 про ограничения и EXCLUDE). Окна промокодов лежат в
promo_windows:
CREATE TABLE promo_windows (
code text NOT NULL,
span tstzrange NOT NULL,
EXCLUDE USING gist (code WITH =, span WITH &&)
);Читается симметрично temporal-ключу: запрещены две строки, у которых code
равны (WITH =) и span пересекаются (WITH &&). Тот же SUMMER с
наслаивающимся окном отбивается тем же 23P01. А другой код AUTUMN с
ровно тем же окном проходит — ограничение смотрит на пару «код + окно», а не на
окно отдельно. Это и есть до-PG18-форма того же инварианта; EXCLUDE тоже
опирается на btree_gist (равенство по code). Полезно знать обе формы: на
существующей таблице без temporal-ключа ты починишь пересечения именно через
EXCLUDE.
RETURNING old/new (PG18): аудит цены без отдельного SELECT
Когда цену периода меняют, в журнал хочется записать «было → стало». До PG18 это
два захода: SELECT старой цены, потом UPDATE, либо UPDATE ... RETURNING
только новой и догадка про старую. PG18 даёт в RETURNING псевдотаблицы
old и new — один UPDATE отдаёт обе версии строки сразу (мы уже видели
этот приём в 03-05):
UPDATE price_periods
SET price_cents = $1
WHERE drink_id = 1 AND valid = tstzrange('2025-02-01', '2025-03-01')
RETURNING old.price_cents, new.price_cents;Один statement возвращает и старую, и новую цену — этим и наполняем строку
price_audit, без отдельного SELECT до и после.
Полуоткрытые интервалы и две формы запрета
Вся механика держится на одном свойстве tstzrange — границы [from, to)
полуоткрыты, конец в интервал не входит. На оси времени это видно сразу (периоды
цены напитка #1):
Ось времени. Границы [from, to): конец НЕ входит.
[01-01 ──────── 02-01) 3.00 ✓ принят
[02-01 ──────── 03-01) 3.20 ✓ принят
смежные: точку 02-01 держит только правый период → пересечения нет
[01-15 ──────────── 02-15) 9.99 ✗ 23P01
перекрывает оба → exclusion_violationДва смежных периода делят границу 02-01, но не конфликтуют: левый отдал эту точку,
правый её взял. Третий период физически накрывает оба — и его отбивает 23P01. Тот
же запрет существует в Postgres в двух формах:
temporal PK WITHOUT OVERLAPS | EXCLUDE USING gist | |
|---|---|---|
| Версия | PG18 и новее | задолго до PG18 |
| Где живёт | внутри PRIMARY KEY | отдельным ограничением таблицы |
| Запись | PRIMARY KEY (drink_id, valid WITHOUT OVERLAPS) | EXCLUDE USING gist (code WITH =, span WITH &&) |
| Условие | скаляр на =, range — без пересечений (неявно) | пишешь явно: WITH = и WITH && |
| Опора | btree_gist | btree_gist |
| На пересечении | 23P01 | 23P01 |
| Когда брать | новая таблица, инвариант «одна строка на момент» прямо в ключе | существующая таблица или несколько range-условий |
Что показывает наш код
cmd/demo/main.go собирает три лабораторных стола — price_periods,
promo_windows, price_audit (базовые таблицы не трогаем), — и прогоняет три сцены.
Сперва кладёт два смежных периода цены (приняты) и третий, перекрывающий, —
temporal PK отбивает его 23P01. Потом то же на промо: SUMMER с
наслаиванием отбит, AUTUMN с тем же окном принят. Наконец поднимает цену
второго периода одним UPDATE ... RETURNING old.price_cents, new.price_cents и
кладёт «было → стало» в price_audit. Функция outcome переводит ошибку
вставки в короткую метку — OK или SQLSTATE 23P01.
Юнит — escape-hatch на raw-pgx (есть go.mod, но нет sqlc): sqlc v1.30.0 не
парсит DDL с WITHOUT OVERLAPS и не понимает RETURNING old/new (та же причина,
что в 03-05) — выбираем фичу, а не инструмент.
Запуск
docker compose up -d
make lecture L=10-use-cases/10-02-price-and-promo-engine T=db-reset
make lecture L=10-use-cases/10-02-price-and-promo-engineT=run — режим по умолчанию, его можно не писать. Изнутри каталога юнита короче:
make db-reset, затем make run. А make test гоняет интеграционный тест с
ассертами — это капстон, у него проверка зелёная только когда все три сцены дают
ровно тот вывод, что ниже.
1) Temporal PK (PG18): у одного напитка не пересекаются периоды цены.
период цены напитка #1 цена результат
[2025-01-01, 2025-02-01) 3.00 OK (принято)
[2025-02-01, 2025-03-01) 3.20 OK (принято)
[2025-01-15, 2025-02-15) 9.99 отбито, SQLSTATE 23P01
2) Классический EXCLUDE (до PG18): то же для окон промо-кода.
промо-код окно результат
SUMMER [2025-06-01, 2025-09-01) OK (принято)
SUMMER [2025-08-01, 2025-10-01) отбито, SQLSTATE 23P01
AUTUMN [2025-08-01, 2025-10-01) OK (принято)
3) RETURNING old/new (PG18): меняем цену и пишем аудит без отдельного SELECT.
период [2025-02-01, 2025-03-01): цена 3.20 → 3.40 (одним UPDATE ... RETURNING old/new)
аудит: 1 запись, было 3.20 → стало 3.40Первая сцена: два смежных периода легли, перекрывающий отбит 23P01 —
temporal PK не дал напитку две цены на один день. Вторая: тот же SUMMER с
наслоением отбит тем же 23P01, а AUTUMN с идентичным окном принят — ключ
смотрит на пару «код + окно». Третья: один UPDATE поднял цену 3.20 → 3.40 и
сразу вернул обе версии через old/new, аудит наполнен без отдельного SELECT.
Заборчик
- Оба запрета опираются на
btree_gist. На песочнице расширение ставится одной строкой, но в проде помни, от каких расширений ты зависишь: их надо иметь в каждой среде и при миграции кластера.EXCLUDE/gist при записи дороже обычного btree — на каждой вставке индекс проверяет пересечения, и на огромных горячих таблицах это заметная цена. Держи такие ограничения там, где пересечения реально надо ловить, а не везде. - Полуоткрытость диапазона — не деталь, а суть.
tstzrangeпо умолчанию[from, to), конец не входит, поэтому[.., 02-01)и[02-01, ..)не пересекаются и оба влезают рядом. Будь границы замкнутыми[from, to], смежные периоды делили бы точку02-01и второй отбился бы — проверяй форму границ при заведении периодов. RETURNING old/new— не полноценный аудит-трейл. Это удобно для «было → стало» в моменте, но здесь приложение само решило записать строку вprice_audit; поменяет кто-то цену в обход этого кода — журнал промолчит. Настоящий неотключаемый аудит — это триггер + история на стороне БД, территория 09-05, а неRETURNING.
Что забрать с собой
Инварианты вида «эти интервалы не пересекаются» держи в схеме, а не в коде
сервиса: их проверяет каждый писатель, и забыть проверку нельзя. PG18 даёт для
этого temporal-ключ — PRIMARY KEY (drink_id, valid WITHOUT OVERLAPS) на
tstzrange-столбце; до PG18 тот же запрет делали ограничением-исключением
EXCLUDE USING gist (code WITH =, span WITH &&). Обе формы дают 23P01 на
пересечении и обе опираются на btree_gist; смежные полуоткрытые диапазоны не
конфликтуют. А RETURNING old.* / new.* (PG18) возвращает «было → стало» одним
UPDATE — удобно, но это не замена настоящему аудиту.
Дальше — последний капстон 10-03: клиника анти-паттернов приложения. Соберём в
одном месте типовые грабли, на которые наступает код против Postgres — от N+1 и
OFFSET-пагинации до неявных приведений типов, которые убивают индекс, — и
разберём, как каждую распознать и выпрямить.