System design — это путь от требований к архитектуре, где каждое решение обосновано цифрами и ограничениями. Для сервиса онлайн-записи главное узкое место — не нагрузка, а корректность: два клиента не должны занять одно время, и эту гарантию надёжнее всего отдать базе данных через ограничение EXCLUDE. Уведомления через outbox, кэш расписания, реплики и секционирование добавляются по мере роста, когда этого требуют метрики.
После материала вы сможете
- сформулировать функциональные и нефункциональные требования и оценить нагрузку «на салфетке» с явными допущениями
- спроектировать API и модель данных записи с защитой от двойного бронирования: ограничение
EXCLUDE,Idempotency-Keyи короткая транзакция - доставлять уведомления через transactional outbox и воркеры с
FOR UPDATE SKIP LOCKED - обосновать, когда подключать кэш, реплики чтения и секционирование и что оставить за рамками MVP
Что знать заранее
Проверено на
PostgreSQL 17 (17.11, postgres:17-alpine)Docker 29
Сверено с документацией: 11 октября 2026 г.
Содержание
Задача и требования
Задача: спроектировать сервис онлайн-записи для салонов красоты и частных клиник Алматы. Клиент выбирает услугу, мастера или врача и время, получает подтверждение и напоминание; администратор видит расписание и меняет его. Это учебный пример: салоны, нагрузки и цифры в нём условные. Разбор идёт по шагам классического system design, а решения, касающиеся данных, проверены на стенде PostgreSQL 17.
Функциональные требования описывают, что система делает:
- Клиент видит услуги салона, мастеров и свободное время на выбранную дату.
- Клиент записывается без регистрации: имя, телефон, услуга, мастер, время.
- Клиент и салон получают подтверждение; клиенту приходит напоминание перед визитом.
- Клиент отменяет запись по ссылке; администратор отменяет и переносит записи.
- Администратор задаёт рабочие часы и перерывы мастеров.
Нефункциональные требования описывают свойства системы. Их формулируют измеримо, иначе их нельзя проверить. Значения ниже — учебные целевые уровни, их согласуют с заказчиком:
Корректность
- Требование
- Ни одной пересекающейся подтверждённой записи у мастера
- Почему так
- Двойная бронь — прямой ущерб: клиент пришёл, а кресло занято
Доступность записи
- Требование
- 99,9% успешных запросов за месяц
- Почему так
- Это около 43 минут простоя в месяц — терпимо для салона, но не больше
Задержка
- Требование
- p95 «свободных слотов» до 300 мс, создания записи — до 500 мс
- Почему так
- Виджет открывают с телефона; долгий ответ — брошенная запись
Уведомления
- Требование
- Доставка за минуты, без дублей для клиента
- Почему так
- Можно отправлять асинхронно и развязать запись с SMS-шлюзом
Данные
- Требование
- Телефоны клиентов видит только свой салон
- Почему так
- Это персональные данные: изоляция арендаторов и шифрование при передаче
Сознательно за рамками первой версии: онлайн-оплата (её вы добавите в упражнении), программы лояльности, каталог салонов для поиска и интеграции с учётными системами.
Оценка нагрузки «на салфетке»
Оценка «на салфетке» (back-of-the-envelope) отвечает на один вопрос: какого порядка нагрузка — десятки запросов в секунду или десятки тысяч. От ответа зависит, хватит ли одного сервера базы или нужен кластер. Ошибка в два-три раза допустима; важно записать допущения, чтобы их можно было проверить и поправить.
Точек на платформе
- Допущение или формула
- допущение
- Результат
- 1 000 салонов и клиник
Мастеров и врачей
- Допущение или формула
- 1 000 × 6
- Результат
- 6 000
Записей в сутки
- Допущение или формула
- 6 000 × 10 записей на мастера
- Результат
- 60 000
Средний поток записей
- Допущение или формула
- 60 000 / 86 400 с
- Результат
- ≈ 0,7 в секунду
Пик записей
- Допущение или формула
- × 10: утро понедельника, акции
- Результат
- ≈ 7 в секунду
Просмотров расписания
- Допущение или формула
- 30 на одну запись: даты, мастера, обновления
- Результат
- 1,8 млн в сутки
Пик чтения
- Допущение или формула
- 1,8 млн / 86 400 × 10
- Результат
- ≈ 210 в секунду
Уведомлений
- Допущение или формула
- 3 на запись: подтверждение и два напоминания
- Результат
- 180 000 в сутки
Рост таблицы bookings
- Допущение или формула
- 60 000 × 365 × 0,6 КБ
- Результат
- ≈ 13 ГБ в год
Размер строки мы не выдумывали, а замерили на стенде из следующего раздела: 100 000 синтетических записей вместе с индексами заняли около 290 байт на строку. В оценку заложен двойной запас на реальные данные и фрагментацию — 0,6 КБ.
-- Замер: сколько места занимает строка bookings вместе с индексами (100 000 записей)
INSERT INTO masters (salon_id, name)
SELECT 1, 'Мастер ' || g FROM generate_series(3, 1000) g;
INSERT INTO bookings (salon_id, master_id, service_id, client_phone, during, idempotency_key)
SELECT 1, m, 1, '+7700' || lpad((m * 100 + d)::text, 7, '0'),
tstzrange(timestamptz '2026-11-01 10:00+05' + (d - 1) * interval '1 day',
timestamptz '2026-11-01 11:00+05' + (d - 1) * interval '1 day'),
gen_random_uuid()
FROM generate_series(1, 1000) m, generate_series(1, 100) d;
VACUUM ANALYZE bookings;
SELECT count(*) AS rows,
pg_size_pretty(pg_table_size('bookings')) AS table,
pg_size_pretty(pg_indexes_size('bookings')) AS indexes,
pg_total_relation_size('bookings') / count(*) AS bytes_per_row
FROM bookings; rows | table | indexes | bytes_per_row
--------+-------+---------+---------------
100007 | 14 MB | 15 MB | 293
(1 row)Выводы. Чтений в 30 раз больше, чем записей, — значит, удешевлять стоит чтение расписания: индекс, затем кэш. Пиковые 7 записей и около 210 чтений в секунду — по нашему опыту, посильная нагрузка для одного сервера PostgreSQL среднего размера; подтверждают это нагрузочным тестом, а не оценкой. 13 ГБ в год — не повод секционировать таблицу в первый год. Настоящая сложность в другом: в пик два клиента выбирают одно и то же популярное время.
API и модель данных
API описывает ресурсы и действия над ними. Правила именования, формат ошибок и пагинацию подробно разбираем в уроке о проектировании API; здесь — только решения, важные для записи.
GET /v1/salons/{id}/services
- Что делает
- Услуги и их длительность
- Важное
- Читается часто, меняется редко — кандидат в кэш
GET /v1/salons/{id}/slots?master_id=&service_id=&date=
- Что делает
- Свободные начала на дату
- Важное
- Вычисляется из рабочих часов и записей
POST /v1/bookings
- Что делает
- Создать запись
- Важное
- Заголовок
Idempotency-Key; ответы201,409,422
GET /v1/bookings/{id}
- Что делает
- Статус записи
- Важное
- Сразу после создания читается с основной базы
POST /v1/bookings/{id}/cancel
- Что делает
- Отменить запись
- Важное
- Переход статуса, история сохраняется
PUT /v1/masters/{id}/schedule
- Что делает
- Рабочие часы мастера
- Важное
- Сбрасывает кэш слотов этого мастера
POST /v1/bookings HTTP/1.1
Host: api.example.com
Content-Type: application/json
Idempotency-Key: 6f1c2a8e-0000-4000-8000-000000000001
{"salon_id": 1, "master_id": 1, "service_id": 1,
"starts_at": "2026-10-20T10:00:00+05:00", "client_phone": "+77000000001"}
# слот свободен
HTTP/1.1 201 Created
Location: /v1/bookings/1
# слот уже занят
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{"type": "https://api.example.com/problems/slot-taken",
"title": "Это время уже занято", "status": 409}В этом контракте три решения. Клиент передаёт только начало, а конец сервер вычисляет из длительности услуги — иначе подделанный запрос займёт мастера на 10 минут вместо часа. Ключ идемпотентности клиент генерирует один раз на попытку записи: если ответ потерялся в мобильной сети и запрос повторился, сервер вернёт ту же запись, а не создаст вторую. Так устроены, например, API AWS, где это называется client request identifier; стандартизация заголовка Idempotency-Key в IETF остановилась на черновике. Занятый слот — это 409 Conflict: по RFC 9110 запрос конфликтует с текущим состоянием ресурса; тело ошибки — в формате Problem Details (RFC 9457).
Теперь модель данных. Поднимем стенд: PostgreSQL 17 в Docker, SQL-файлы урока лежат в каталоге sql/.
# каталог sql/ с файлами урока; пароль test — только для одноразового локального контейнера
docker run -d --rm --name kb-booking-pg -e POSTGRES_PASSWORD=test \
-v "$PWD/sql:/sql" postgres:17-alpine
docker exec -i -e PGTZ=Asia/Almaty kb-booking-pg psql -U postgres -v ON_ERROR_STOP=1 -f /sql/01-schema.sql
docker exec -i -e PGTZ=Asia/Almaty kb-booking-pg psql -U postgres -v ON_ERROR_STOP=1 -f /sql/02-seed.sql
# интерактивные сессии для опытов с конкуренцией — каждая в своём терминале
docker exec -it -e PGTZ=Asia/Almaty kb-booking-pg psql -U postgresMSYS_NO_PATHCONV=1-- Модель данных сервиса записи (PostgreSQL 17)
CREATE EXTENSION IF NOT EXISTS btree_gist; -- «=» по bigint внутри GiST-ограничения
CREATE TABLE salons (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL,
tz text NOT NULL DEFAULT 'Asia/Almaty'
);
CREATE TABLE masters (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
salon_id bigint NOT NULL REFERENCES salons (id),
name text NOT NULL
);
CREATE TABLE services (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
salon_id bigint NOT NULL REFERENCES salons (id),
name text NOT NULL,
duration interval NOT NULL CHECK (duration > interval '0')
);
CREATE TABLE bookings (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
salon_id bigint NOT NULL REFERENCES salons (id),
master_id bigint NOT NULL REFERENCES masters (id),
service_id bigint NOT NULL REFERENCES services (id),
client_phone text NOT NULL,
during tstzrange NOT NULL CHECK (NOT isempty(during)),
status text NOT NULL DEFAULT 'confirmed'
CHECK (status IN ('confirmed', 'cancelled')),
idempotency_key uuid NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now(),
-- у одного мастера не бывает двух подтверждённых записей с пересечением времени
CONSTRAINT bookings_no_overlap
EXCLUDE USING gist (master_id WITH =, during WITH &&)
WHERE (status = 'confirmed')
);
-- Исходящие события: пишутся в той же транзакции, что и запись
CREATE TABLE outbox (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
topic text NOT NULL,
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz
);
CREATE INDEX outbox_pending ON outbox (id) WHERE processed_at IS NULL;INSERT INTO salons (name) VALUES ('Салон «Пример»');
INSERT INTO masters (salon_id, name) VALUES (1, 'Мастер 1'), (1, 'Мастер 2');
INSERT INTO services (salon_id, name, duration) VALUES (1, 'Стрижка', interval '60 minutes');during tstzrange вместо двух столбцов
- Зачем
- Интервал — один тип с оператором пересечения
&&; полуоткрытый[10:00, 11:00)позволяет следующей записи начаться ровно в 11:00
timestamptz и tz у салона
- Зачем
- Время хранится абсолютным и показывается в поясе салона; смещение
+05не зашито в код
status вместо удаления
- Зачем
- Отмена — переход статуса: история для аналитики и споров сохраняется
idempotency_key UNIQUE
- Зачем
- База сама отсекает повтор того же запроса
EXCLUDE … WHERE (status = 'confirmed')
- Зачем
- Пересечения запрещены только у действующих записей: отменённая не держит слот
Таблица outbox
- Зачем
- Событие для уведомлений записывается атомарно с бронью
Базовая архитектура
Начинаем с самой простой схемы, которая выполняет требования: один развёртываемый сервис API (модульный монолит), одна база PostgreSQL, очередь и воркеры для уведомлений. Микросервисы при семи записях в секунду добавят сетевые сбои и распределённые транзакции, не решив ни одной реальной проблемы; сравнение подходов — в уроке о модульном монолите и микросервисах.
- 01КлиентыВиджет записи на сайте салона, кабинет администратора, бот в мессенджере. Ходят только в API.
- 02API-сервисМодульный монолит: каталог, расписание, бронирования, уведомления. Без состояния — экземпляры добавляются за балансировщиком.
- 03PostgreSQLИсточник истины: записи, расписание, outbox. Ограничение EXCLUDE — последний рубеж против двойной брони.
- 04Асинхронный контурРелей читает outbox и ставит задачи в очередь; воркеры шлют SMS и сообщения с повторами.
- 05Внешние каналыSMS-шлюз, мессенджеры, почта. Медленные и ненадёжные — поэтому вне пути запроса.
Путь одной записи:
Клиент открывает виджет
GET /slots: API читает рабочие часы и записи мастера (позже — из кэша).Клиент нажимает «Записаться»
POST /v1/bookingsсIdempotency-Key. API проверяет входные данные и вычисляет конец интервала по длительности услуги.Одна транзакция в PostgreSQL
Вставка в
bookingsи вoutbox. Если интервал пересекается с чужой записью, ограничениеEXCLUDEеё отклоняет и API отвечает409.Ответ клиенту
201 Createdсразу послеCOMMIT, не дожидаясь SMS.Асинхронно
Релей переносит событие из
outboxв очередь; воркер отправляет подтверждение и планирует напоминания.
- 01Виджет
- 02API
- 03PostgreSQL: бронь + outbox
- 04Релей
- 05Очередь
- 06SMS и мессенджеры
Узкое место: двойное бронирование
Наивная реализация — «проверить, свободно ли, и вставить». Между проверкой и вставкой проходит время, и две транзакции видят один и тот же свободный слот. Почему так происходит на уровнях изоляции PostgreSQL и какие есть три способа защиты, разобрано в уроке о транзакциях. Для сервиса записи выбираем ограничение в схеме: его нельзя забыть в одном из обработчиков, и оно работает при любом числе экземпляров API.
Вот что API выполняет на POST /v1/bookings: вставку записи и события одной транзакцией. ON CONFLICT (idempotency_key) DO NOTHING превращает повтор того же запроса в пустую операцию.
-- Бронирование: запись и событие для уведомлений — одной транзакцией
BEGIN;
WITH b AS (
INSERT INTO bookings (salon_id, master_id, service_id, client_phone, during, idempotency_key)
VALUES (1, 1, 1, '+77000000001',
tstzrange('2026-10-20 10:00+05', '2026-10-20 11:00+05'),
'6f1c2a8e-0000-4000-8000-000000000001')
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id, master_id, during
)
INSERT INTO outbox (topic, payload)
SELECT 'booking.created',
jsonb_build_object('booking_id', id, 'master_id', master_id, 'starts_at', lower(during))
FROM b;
COMMIT;$ docker exec -i -e PGTZ=Asia/Almaty kb-booking-pg psql -U postgres -f /sql/03-book.sql # первый запрос
BEGIN
INSERT 0 1
COMMIT
$ docker exec -i -e PGTZ=Asia/Almaty kb-booking-pg psql -U postgres -f /sql/03-book.sql # повтор с тем же ключом
BEGIN
INSERT 0 0
COMMITПолучив INSERT 0 0, API находит существующую запись по ключу и возвращает её с тем же 201. Если по тому же ключу пришли другие параметры, например другое время, это ошибка клиента: AWS в таком случае возвращает ошибку валидации, мы отвечаем 422.
Теперь главное — конкурентная бронь. Две сессии psql играют роль двух экземпляров API. Сессия A записывает клиента на 12:00–13:00 и держит транзакцию открытой 4 секунды, как медленный обработчик. Через секунду сессия B пытается записать другого клиента к тому же мастеру на 12:30–13:30. Третья сессия C наблюдает.
-- Сессия A: запись на 12:00–13:00, транзакция остаётся открытой
A=# BEGIN;
BEGIN
A=# WITH b AS (INSERT INTO bookings … '2026-10-20 12:00+05', '2026-10-20 13:00+05' …) INSERT INTO outbox …;
INSERT 0 1
A=# SELECT pg_sleep(4);
-- Через секунду сессия B: тот же мастер, 12:30–13:30
B=# \timing on
B=# BEGIN;
BEGIN
B=# WITH b AS (INSERT INTO bookings … '2026-10-20 12:30+05', '2026-10-20 13:30+05' …) INSERT INTO outbox …;
-- B ждёт. Сессия C смотрит, кого именно:
C=# SELECT pid, wait_event_type, wait_event, pg_blocking_pids(pid) AS blocked_by
C-# FROM pg_stat_activity WHERE wait_event_type = 'Lock';
pid | wait_event_type | wait_event | blocked_by
-----+-----------------+---------------+------------
113 | Lock | transactionid | {106}
(1 row)
-- Пауза A закончилась
A=# COMMIT;
COMMIT
-- B дождалась ответа:
ERROR: conflicting key value violates exclusion constraint "bookings_no_overlap"
DETAIL: Key (master_id, during)=(1, ["2026-10-20 12:30:00+05","2026-10-20 13:30:00+05")) conflicts with existing key (master_id, during)=(1, ["2026-10-20 12:00:00+05","2026-10-20 13:00:00+05")).
Time: 3063.304 ms (00:03.063)
B=# \echo :LAST_ERROR_SQLSTATE
23P01
B=# ROLLBACK;
ROLLBACKЧто произошло. B не получила ошибку сразу: незафиксированная запись A ещё могла откатиться, поэтому B ждала её решения около 3 секунд, а pg_blocking_pids показал, кого именно. Как только A зафиксировала транзакцию, B получила 23P01 — нарушение ограничения исключения. API превращает этот код в 409 Conflict и предлагает соседние слоты. Событие B в outbox тоже не появилось: оно было в той же транзакции.
=# SELECT id, client_phone, during FROM bookings WHERE master_id = 1 ORDER BY id;
id | client_phone | during
----+--------------+-----------------------------------------------------
1 | +77000000001 | ["2026-10-20 10:00:00+05","2026-10-20 11:00:00+05")
3 | +77000000002 | ["2026-10-20 12:00:00+05","2026-10-20 13:00:00+05")
(2 rows)
=# SELECT id, topic, payload FROM outbox ORDER BY id;
id | topic | payload
----+-----------------+-----------------------------------------------------------------------------
1 | booking.created | {"master_id": 1, "starts_at": "2026-10-20T10:00:00+05:00", "booking_id": 1}
2 | booking.created | {"booking_id": 3}
(2 rows)Обратный сценарий мы тоже проверили: если A делает ROLLBACK, ожидающая B через те же примерно 3 секунды успешно вставляет свою запись. Отмена работает через частичное ограничение: после UPDATE … SET status = 'cancelled' слот снова свободен, и запись на 12:30 проходит.
-- Ловушка: ON CONFLICT без цели глушит и нарушение EXCLUDE
INSERT INTO bookings (salon_id, master_id, service_id, client_phone, during, idempotency_key)
VALUES (1, 1, 1, '+77000000004', tstzrange('2026-10-20 12:15+05', '2026-10-20 13:15+05'),
'6f1c2a8e-0000-4000-8000-00000000000c')
ON CONFLICT DO NOTHING;
INSERT INTO bookings (salon_id, master_id, service_id, client_phone, during, idempotency_key)
VALUES (1, 1, 1, '+77000000004', tstzrange('2026-10-20 12:15+05', '2026-10-20 13:15+05'),
'6f1c2a8e-0000-4000-8000-00000000000c')
ON CONFLICT (idempotency_key) DO NOTHING;-- без цели конфликта: пересечение молча проглочено
INSERT 0 0
-- с целью (idempotency_key): нарушение EXCLUDE видно
ERROR: conflicting key value violates exclusion constraint "bookings_no_overlap"
DETAIL: Key (master_id, during)=(1, ["2026-10-20 12:15:00+05","2026-10-20 13:15:00+05")) conflicts with existing key (master_id, during)=(1, ["2026-10-20 12:00:00+05","2026-10-20 13:00:00+05")).Ожидание B не бесплатное: пока A держит транзакцию, все записи к этому мастеру на пересекающееся время стоят в очереди. Поэтому транзакция брони должна быть короткой — никаких HTTP-вызовов к SMS-шлюзу или платёжной системе между BEGIN и COMMIT.
Уведомления: outbox и очередь
Подтверждение нужно отправить ровно тогда, когда запись действительно сохранена. Если API сначала делает COMMIT, а потом кладёт задачу в очередь Redis, процесс может упасть между этими шагами: запись есть, SMS нет. Если наоборот — SMS ушло, а транзакция откатилась. Это проблема двойной записи (dual write): две независимые системы нельзя обновить атомарно.
Шаблон transactional outbox решает её средствами одной базы: событие пишется в таблицу outbox в той же транзакции, что и запись, — это уже делает 03-book.sql. Отдельный процесс-релей читает необработанные события и публикует их в очередь. Модели доставки и outbox подробно — в уроке о событийной архитектуре и очередях.
-- Один проход воркера outbox: забрать пачку, опубликовать в очередь, подтвердить
BEGIN;
WITH batch AS (
SELECT id
FROM outbox
WHERE processed_at IS NULL
ORDER BY id
LIMIT 100
FOR UPDATE SKIP LOCKED -- строки, занятые другим воркером, пропускаем
)
UPDATE outbox o
SET processed_at = now()
FROM batch
WHERE o.id = batch.id
RETURNING o.id, o.topic, o.payload;
-- приложение публикует полученные события в очередь:
-- успех → COMMIT, ошибка → ROLLBACK (строки снова станут доступны)
COMMIT;FOR UPDATE SKIP LOCKED позволяет запустить несколько релеев параллельно: строки, которые уже взял другой воркер, пропускаются, а не ждут. Документация PostgreSQL описывает этот режим как подходящий для нескольких потребителей таблицы-очереди и непригодный для обычных запросов — он сознательно показывает неполную картину. Проверка: два воркера с пачкой по 2 события, первый держит транзакцию 3 секунды.
-- Воркер 1: пачка по 2, держит транзакцию 3 секунды
id | topic
----+-----------------
1 | booking.created
2 | booking.created
(2 rows)
-- Воркер 2, запущен через секунду: не ждёт, берёт следующие строки
id | topic
----+------------------
3 | booking.reminder
4 | booking.reminder
(2 rows)Гарантия здесь — «как минимум один раз» (at-least-once): если релей опубликовал событие, но упал до COMMIT, после перезапуска он опубликует его снова. Поэтому получатель должен быть идемпотентным: воркер отправки отмечает «подтверждение для записи 1 отправлено» и пропускает дубль. Как это сделать на BullMQ — jobId, повторы с backoff, «мёртвые» задачи — показано в уроке о фоновых задачах.
- Напоминания за сутки и за 2 часа — отложенные задачи очереди с моментом запуска, вычисленным от
lower(during). - При отмене или переносе воркер перепроверяет статус записи перед отправкой: это проще, чем искать и удалять задачи в очереди.
- Обработанные строки
outboxудаляет или архивирует периодическая задача, иначе таблица растёт бесконечно.
Масштабирование: кэш, реплики, секционирование
По оценке нагрузки чтений расписания в 30 раз больше, чем записей. Начнём с самого запроса свободных слотов: сетка начал с шагом 30 минут минус пересечения с действующими записями. В учебной версии рабочие часы заданы константами; в реальной системе они берутся из таблицы смен с перерывами.
-- Свободные начала услуги длительностью 60 минут у мастера 1 на 20 октября (шаг 30 минут)
SELECT to_char(s, 'HH24:MI') AS starts_at
FROM generate_series(timestamptz '2026-10-20 10:00+05',
timestamptz '2026-10-20 18:00+05',
interval '30 minutes') AS s
WHERE NOT EXISTS (
SELECT 1
FROM bookings b
WHERE b.master_id = 1
AND b.status = 'confirmed'
AND b.during && tstzrange(s, s + interval '60 minutes')
)
ORDER BY s; starts_at
-----------
11:00
11:30
13:30
14:00
14:30
15:00
15:30
16:00
16:30
17:00
17:30
18:00
(12 rows)Ограничение EXCLUDE создаёт GiST-индекс по (master_id, during), и этот же индекс обслуживает поиск пересечений: на стенде EXPLAIN показал Index Only Scan using bookings_no_overlap. Отдельный индекс для слотов не нужен.
Кэш расписания. Ответ /slots для пары «мастер + дата» удобно кэшировать по схеме cache-aside с ключом вида slots:{master_id}:{date}, коротким TTL и сбросом по событию: релей при booking.created и booking.cancelled удаляет ключ этого мастера и даты. Устаревший кэш здесь не опасен, и это ключевое архитектурное свойство: кэш может показать занятый слот свободным, но бронь всё равно проверит база, и клиент получит 409 с другим временем. Стратегии инвалидации и защита от лавины промахов — в уроке о кэшировании и инвалидации.
Реплики чтения. Потоковая репликация PostgreSQL по умолчанию асинхронна, и документация называет данные на hot standby «в конечном счёте согласованными» (eventually consistent) с основным сервером. Отсюда правило маршрутизации:
Свободные слоты, каталог услуг
- Куда
- Реплика или кэш
- Почему
- Отставание на доли секунды допустимо: окончательная проверка — на основной базе
Создание, отмена, перенос записи
- Куда
- Только основная
- Почему
- Реплика принимает только чтение, ограничение проверяется на основной
«Моя запись» сразу после создания
- Куда
- Основная
- Почему
- Иначе клиент может не увидеть только что созданную запись (read-your-writes)
Отчёты и выгрузки для салона
- Куда
- Отдельная реплика
- Почему
- Тяжёлые запросы не мешают записи
Секционирование. Таблицу делят на секции, когда она мешает обслуживанию: долго идёт VACUUM, старые данные нужно быстро удалять целиком, индексы не помещаются в память. При 13 ГБ в год до этого далеко, но ключ секционирования стоит понять заранее: самый очевидный вариант «по месяцам» ломает защиту от двойной брони.
-- 1) Секционирование по времени: ограничение «без пересечений» так не объявить
CREATE TABLE bookings_by_month (
salon_id bigint NOT NULL,
master_id bigint NOT NULL,
starts_at timestamptz NOT NULL,
during tstzrange NOT NULL,
EXCLUDE USING gist (master_id WITH =, during WITH &&)
) PARTITION BY RANGE (starts_at);
-- 2) Секционирование по салону (арендатору): ключ входит в ограничение через «=»
CREATE TABLE bookings_by_salon (
salon_id bigint NOT NULL,
master_id bigint NOT NULL,
during tstzrange NOT NULL,
EXCLUDE USING gist (salon_id WITH =, master_id WITH =, during WITH &&)
) PARTITION BY HASH (salon_id);
CREATE TABLE bookings_by_salon_0 PARTITION OF bookings_by_salon FOR VALUES WITH (MODULUS 4, REMAINDER 0);
CREATE TABLE bookings_by_salon_1 PARTITION OF bookings_by_salon FOR VALUES WITH (MODULUS 4, REMAINDER 1);
CREATE TABLE bookings_by_salon_2 PARTITION OF bookings_by_salon FOR VALUES WITH (MODULUS 4, REMAINDER 2);
CREATE TABLE bookings_by_salon_3 PARTITION OF bookings_by_salon FOR VALUES WITH (MODULUS 4, REMAINDER 3);
INSERT INTO bookings_by_salon VALUES (7, 1, tstzrange('2026-10-20 10:00+05', '2026-10-20 11:00+05'));
INSERT INTO bookings_by_salon VALUES (7, 1, tstzrange('2026-10-20 10:30+05', '2026-10-20 11:30+05'));-- 1) bookings_by_month
ERROR: unique constraint on partitioned table must include all partitioning columns
DETAIL: EXCLUDE constraint on table "bookings_by_month" lacks column "starts_at" which is part of the partition key.
-- 2) bookings_by_salon: таблица и 4 секции созданы, первая запись — INSERT 0 1, вторая:
ERROR: conflicting key value violates exclusion constraint "bookings_by_salon_3_salon_id_master_id_during_excl"
DETAIL: Key (salon_id, master_id, during)=(7, 1, ["2026-10-20 10:30:00+05","2026-10-20 11:30:00+05")) conflicts with existing key (salon_id, master_id, during)=(7, 1, ["2026-10-20 10:00:00+05","2026-10-20 11:00:00+05")).Документация PostgreSQL 17 объясняет причину: ограничение исключения на секционированной таблице должно включать все столбцы ключа секционирования и сравнивать их на равенство, а не через &&. Секционирование по салону, то есть по арендатору, это правило выполняет: все записи мастера лежат в одной секции, и вторая пересекающаяся запись отклонена. Старые записи при таком ключе уходят в архив периодической задачей. Модели изоляции арендаторов — в уроке про multi-tenant SaaS.
GiST-индекс ограничения
- Когда включать
- С первого дня
- Что даёт
- Защита от двойной брони и быстрый поиск пересечений
Новые экземпляры API
- Когда включать
- Процессор API в пике у предела
- Что даёт
- Линейный рост пропускной способности: API без состояния
Кэш слотов
- Когда включать
- p95
/slotsвыходит за требование, база занята чтением - Что даёт
- Снимает основную часть чтений с базы
Реплика чтения
- Когда включать
- После кэша база всё ещё упирается в чтение или нужны отчёты
- Что даёт
- Разделение чтения и записи
Секционирование
- Когда включать
- Таблица мешает обслуживанию или нужна быстрая очистка
- Что даёт
- HASH по
salon_idсохраняет ограничение
Наблюдаемость, отказы, компромиссы и MVP
Метрики, без которых систему не эксплуатировать; общий подход к логам, метрикам и алертам — в уроке о мониторинге и логировании:
- Число броней в минуту и доля ответов
409: рост доли означает ажиотаж на популярное время или устаревший кэш слотов. - p95 задержки
/slotsиPOST /v1/bookings— сверка с нефункциональными требованиями. - Время ожидания блокировок и транзакции дольше секунды — признак, что в транзакцию брони попал медленный вызов.
- Отставание outbox: число необработанных событий и возраст самого старого.
- Доля неудачных отправок по каналам и число «мёртвых» задач в очереди.
- Отставание реплики, если с неё читают.
-- Метрика «отставание outbox»: сколько событий ждёт и сколько ждёт самое старое
SELECT count(*) AS pending,
coalesce(now() - min(created_at), interval '0') AS oldest_age
FROM outbox
WHERE processed_at IS NULL; pending | oldest_age
---------+----------------
1 | 00:00:03.34143
(1 row)Алерт по возрасту самого старого события надёжнее, чем по их числу: в час пик очередь законно растёт, а событие, которое ждёт 10 минут, означает, что релей стоит.
Основная PostgreSQL
- Что видит клиент
- Запись недоступна,
503 - Что делаем
- Переключение на standby; при асинхронной репликации последние транзакции могут потеряться — компромисс фиксируется явно
Кэш Redis
- Что видит клиент
- Слоты открываются чуть медленнее
- Что делаем
- Запасной путь — чтение из базы: кэш не источник истины
Релей или очередь
- Что видит клиент
- Запись работает, SMS задерживаются
- Что делаем
- События копятся в
outboxи уходят после восстановления
SMS-шлюз
- Что видит клиент
- Подтверждение приходит позже
- Что делаем
- Повторы с растущей паузой и случайным разбросом (jitter), резервный канал
Повтор запроса клиентом
- Что видит клиент
- Ничего
- Что делаем
Idempotency-Keyвозвращает ту же запись
Экземпляр API
- Что видит клиент
- Часть запросов падает, клиент повторяет
- Что делаем
- Балансировщик выводит экземпляр по проверке готовности
О потере транзакций документация PostgreSQL предупреждает прямо: при асинхронной репликации и падении основного сервера часть подтверждённых транзакций может не успеть дойти до standby. Синхронная репликация закрывает этот риск ценой задержки каждого COMMIT. Для записи в салон, по нашему опыту, обычно достаточно асинхронной реплики и резервных копий с непрерывным архивом WAL, но это решение принимают вместе с заказчиком.
Ограничение EXCLUDE в базе
- Получаем
- Гарантия без гонок при любом числе экземпляров API
- Платим
- Часть логики брони живёт в схеме, миграции сложнее
Модульный монолит
- Получаем
- Один деплой, обычные транзакции
- Платим
- Масштабируется целиком, а не по модулям
Outbox вместо прямой отправки
- Получаем
- Уведомление не теряется и не уходит без записи
- Платим
- Задержка в секунды и доставка «как минимум один раз»
Кэш слотов с TTL
- Получаем
- Дешёвое чтение в пик
- Платим
- Клиент иногда видит уже занятый слот и получает
409
Асинхронная реплика
- Получаем
- Чтение и отчёты без нагрузки на основную
- Платим
- Отставание и риск потерять последние транзакции при аварии
Что входит в MVP:
- Одна база PostgreSQL с ограничением
EXCLUDE,idempotency_keyи резервными копиями - Сервис API без состояния, минимум два экземпляра за балансировщиком
- Outbox и один релей; подтверждения и напоминания с повторами
- Метрики броней, доли
409, задержек и отставания outbox; алерт на возраст outbox - Нагрузочный тест сценария «20 клиентов на один слот» до запуска
- Позже, по сигналам метрик: кэш слотов, реплика, секционирование, синхронная реплика
Следующий шаг — записать ключевые решения разбора в виде ADR: почему ограничение в базе, почему outbox, какие риски асинхронной реплики приняты. Как оформлять такие записи, рассказано в уроке об основах архитектуры ПО.
Как проверено10 · пройдено1 · не запускалосьсверено 11 октября 2026 г.
- запущено, работает
sql/01-schema.sql, sql/02-seed.sql (схема с EXCLUDE … WHERE, idempotency_key UNIQUE, outbox)
psql -v ON_ERROR_STOP=1 в postgres:17-alpine (PostgreSQL 17.11), чистый контейнер; RU- и EN-версии файлов (sql-en: переведены только комментарии и строки) прогнаны полностью
- запущено, работает
sql/03-book.sql — идемпотентная бронь + outbox
первый запуск INSERT 0 1, повтор с тем же ключом INSERT 0 0
- запущено, работает
Транскрипт конкурентной брони A/B/C
две параллельные сессии psql (A держит транзакцию pg_sleep(4)) + третья смотрит pg_stat_activity: B ждала ~3,06 с (Lock/transactionid), после COMMIT A — ошибка 23P01; в outbox только событие A. Вариант с ROLLBACK A: B вставила запись, SQLSTATE 00000. Длинные INSERT в транскрипте сокращены, вывод реальный
- запущено, работает
sql/20-gotcha-on-conflict.sql
ON CONFLICT DO NOTHING без цели — INSERT 0 0 при пересечении; с целью (idempotency_key) — 23P01
- запущено, работает
sql/22-free-slots.sql и EXPLAIN
12 свободных начал при записях 10:00–11:00 и 12:30–13:30 (12:00 отменена); EXPLAIN — Index Only Scan using bookings_no_overlap
- запущено, работает
sql/30-outbox-worker.sql + демо двух воркеров (LIMIT 2, пауза)
воркер 1 взял id 1–2 и держал транзакцию 3 с, воркер 2 без ожидания взял id 3–4; рабочая версия с LIMIT 100 обработала оставшуюся строку
- запущено, работает
sql/33-outbox-lag.sql
pending 1, oldest_age ≈ 3,3 с
- запущено, работает
sql/40-partitioning.sql
RANGE по starts_at — ошибка «unique constraint on partitioned table must include all partitioning columns»; HASH по salon_id с (salon_id WITH =, …) — создано, пересечение отклонено 23P01
- запущено, работает
sql/50-size.sql — замер размера строки
100 000 синтетических записей: 293 байта на строку с индексами (таблица 14 MB, индексы 14–15 MB)
- запущено, работает
Решения упражнений: 60-ex-room.sql, 70-ex-prepay.sql
кабинет: второй врач на тот же кабинет — 23P01 bookings_room_no_overlap; предоплата: истёкший холд блокирует слот (23P01) до UPDATE … expired (UPDATE 1), затем вставка проходит; повтор платежа с тем же provider_payment_id — INSERT 0 0
- не запускалось
HTTP-контракт POST /v1/bookings
иллюстрация контракта, не исполняемый код; коды 201/409/422 и problem+json сверены с RFC 9110 и RFC 9457
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.