PostgreSQL CookbookUse casesДвижок цен и промо
0 / 63 (0%)

Движок цен и промо

У Brew раздолье акций: латте дорожает с февраля, промокод SUMMER действует всё лето, осенью его сменяет AUTUMN. И тут всплывает класс багов, который данными в приложении не лечится. Менеджер по ошибке завёл напитку две цены на один и тот же день — касса не знает, какую брать. Маркетолог запустил тот же промокод SUMMER с наслаивающимся окном — скидка применяется дважды или не применяется вовсе, зависит от того, какую строку выберет планировщик. Это не «плохой запрос», а отсутствующий инвариант: «у напитка ровно одна цена в любой момент» и «окна одного промокода не пересекаются». Такие правила нельзя держать в коде сервиса — их проверяет каждый, кто пишет в таблицу, и рано или поздно кто-то проверить забудет. Инвариант должен жить в схеме.

Postgres умеет держать «эти интервалы не пересекаются» прямо в CREATE TABLE.

Temporal PK (PG18): у напитка одна цена в каждый момент

PG18 добавил в первичный ключ слово WITHOUT OVERLAPS. Цены напитка живут в таблице price_periods, где интервал действия — это столбец типа tstzrange:

sql
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_gistCREATE EXTENSION IF NOT EXISTS btree_gist.

Классический EXCLUDE (до PG18): тот же запрет на промо

WITHOUT OVERLAPS — новинка, но сама гарантия «без пересечений» жила в Postgres задолго до 18-й версии, через ограничение-исключение EXCLUDE USING gist (см. модуль 02 про ограничения и EXCLUDE). Окна промокодов лежат в promo_windows:

sql
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):

sql
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):

plaintext
Ось времени.  Границы [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 OVERLAPSEXCLUDE USING gist
ВерсияPG18 и новеезадолго до PG18
Где живётвнутри PRIMARY KEYотдельным ограничением таблицы
ЗаписьPRIMARY KEY (drink_id, valid WITHOUT OVERLAPS)EXCLUDE USING gist (code WITH =, span WITH &&)
Условиескаляр на =, range — без пересечений (неявно)пишешь явно: WITH = и WITH &&
Опораbtree_gistbtree_gist
На пересечении23P0123P01
Когда братьновая таблица, инвариант «одна строка на момент» прямо в ключесуществующая таблица или несколько 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) — выбираем фичу, а не инструмент.

Запуск

sh
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-engine

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

plaintext
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-пагинации до неявных приведений типов, которые убивают индекс, — и разберём, как каждую распознать и выпрямить.

·Модуль 11

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

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

/ вы пытались открыть
Use cases / Движок цен и промо