Как делать11 октября 2026 г.14 мин чтения
Архитектура ПОШаг 8 из 9

System design на примере: сервис записи и масштабирование

Коротко

System design — это путь от требований к архитектуре, где каждое решение обосновано цифрами и ограничениями. Для сервиса онлайн-записи главное узкое место — не нагрузка, а корректность: два клиента не должны занять одно время, и эту гарантию надёжнее всего отдать базе данных через ограничение EXCLUDE. Уведомления через outbox, кэш расписания, реплики и секционирование добавляются по мере роста, когда этого требуют метрики.

Пошаговый урокУровень: ПродвинутыйПроектирование системПрактика: ~3–6 ч

После материала вы сможете

  • сформулировать функциональные и нефункциональные требования и оценить нагрузку «на салфетке» с явными допущениями
  • спроектировать API и модель данных записи с защитой от двойного бронирования: ограничение EXCLUDE, Idempotency-Key и короткая транзакция
  • доставлять уведомления через transactional outbox и воркеры с FOR UPDATE SKIP LOCKED
  • обосновать, когда подключать кэш, реплики чтения и секционирование и что оставить за рамками MVP
Содержание
  1. 01Задача и требования
  2. 02Оценка нагрузки «на салфетке»
  3. 03API и модель данных
  4. 04Базовая архитектура
  5. 05Узкое место: двойное бронирование
  6. 06Уведомления: outbox и очередь
  7. 07Масштабирование: кэш, реплики, секционирование
  8. 08Наблюдаемость, отказы, компромиссы и MVP

Задача и требования

Задача: спроектировать сервис онлайн-записи для салонов красоты и частных клиник Алматы. Клиент выбирает услугу, мастера или врача и время, получает подтверждение и напоминание; администратор видит расписание и меняет его. Это учебный пример: салоны, нагрузки и цифры в нём условные. Разбор идёт по шагам классического system design, а решения, касающиеся данных, проверены на стенде PostgreSQL 17.

ТребованияОценка нагрузкиAPI и данныеАрхитектураУзкие места
Что строимКак строим
Шаги разбора: сначала договориться о задаче, потом рисовать схему

Функциональные требования описывают, что система делает:

  • Клиент видит услуги салона, мастеров и свободное время на выбранную дату.
  • Клиент записывается без регистрации: имя, телефон, услуга, мастер, время.
  • Клиент и салон получают подтверждение; клиенту приходит напоминание перед визитом.
  • Клиент отменяет запись по ссылке; администратор отменяет и переносит записи.
  • Администратор задаёт рабочие часы и перерывы мастеров.

Нефункциональные требования описывают свойства системы. Их формулируют измеримо, иначе их нельзя проверить. Значения ниже — учебные целевые уровни, их согласуют с заказчиком:

Корректность

Требование
Ни одной пересекающейся подтверждённой записи у мастера
Почему так
Двойная бронь — прямой ущерб: клиент пришёл, а кресло занято

Доступность записи

Требование
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 КБ.

sql/50-size.sql
-- Замер: сколько места занимает строка 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;
Замер: 1 000 мастеров по 100 записей, затем размер таблицы и индексов
terminal
  rows  | table | indexes | bytes_per_row
--------+-------+---------+---------------
 100007 | 14 MB | 15 MB   |           293
(1 row)
Реальный вывод: 100 000 строк замера плюс 7 записей из предыдущих опытов

Выводы. Чтений в 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

Что делает
Рабочие часы мастера
Важное
Сбрасывает кэш слотов этого мастера
HTTP
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/.

bash
# каталог 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 postgres
Стенд. В Git Bash на Windows для путей внутри контейнера задайте MSYS_NO_PATHCONV=1
sql/01-schema.sql
-- Модель данных сервиса записи (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;
Схема: салоны, мастера, услуги, записи с ограничением исключения и таблица outbox
sql/02-seed.sql
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');
Тестовые данные: один салон, два мастера, услуга на 60 минут

during tstzrange вместо двух столбцов

Зачем
Интервал — один тип с оператором пересечения &&; полуоткрытый [10:00, 11:00) позволяет следующей записи начаться ровно в 11:00

timestamptz и tz у салона

Зачем
Время хранится абсолютным и показывается в поясе салона; смещение +05 не зашито в код

status вместо удаления

Зачем
Отмена — переход статуса: история для аналитики и споров сохраняется

idempotency_key UNIQUE

Зачем
База сама отсекает повтор того же запроса

EXCLUDE … WHERE (status = 'confirmed')

Зачем
Пересечения запрещены только у действующих записей: отменённая не держит слот

Таблица outbox

Зачем
Событие для уведомлений записывается атомарно с бронью

Базовая архитектура

Начинаем с самой простой схемы, которая выполняет требования: один развёртываемый сервис API (модульный монолит), одна база PostgreSQL, очередь и воркеры для уведомлений. Микросервисы при семи записях в секунду добавят сетевые сбои и распределённые транзакции, не решив ни одной реальной проблемы; сравнение подходов — в уроке о модульном монолите и микросервисах.

  1. 01КлиентыВиджет записи на сайте салона, кабинет администратора, бот в мессенджере. Ходят только в API.
  2. 02API-сервисМодульный монолит: каталог, расписание, бронирования, уведомления. Без состояния — экземпляры добавляются за балансировщиком.
  3. 03PostgreSQLИсточник истины: записи, расписание, outbox. Ограничение EXCLUDE — последний рубеж против двойной брони.
  4. 04Асинхронный контурРелей читает outbox и ставит задачи в очередь; воркеры шлют SMS и сообщения с повторами.
  5. 05Внешние каналыSMS-шлюз, мессенджеры, почта. Медленные и ненадёжные — поэтому вне пути запроса.
Слои системы: путь брони синхронен только до PostgreSQL

Путь одной записи:

  1. Клиент открывает виджет

    GET /slots: API читает рабочие часы и записи мастера (позже — из кэша).

  2. Клиент нажимает «Записаться»

    POST /v1/bookings с Idempotency-Key. API проверяет входные данные и вычисляет конец интервала по длительности услуги.

  3. Одна транзакция в PostgreSQL

    Вставка в bookings и в outbox. Если интервал пересекается с чужой записью, ограничение EXCLUDE её отклоняет и API отвечает 409.

  4. Ответ клиенту

    201 Created сразу после COMMIT, не дожидаясь SMS.

  5. Асинхронно

    Релей переносит событие из outbox в очередь; воркер отправляет подтверждение и планирует напоминания.

  1. 01Виджет
  2. 02API
  3. 03PostgreSQL: бронь + outbox
  4. 04Релей
  5. 05Очередь
  6. 06SMS и мессенджеры
Синхронная часть заканчивается на COMMIT; уведомления идут в фоне

Узкое место: двойное бронирование

Наивная реализация — «проверить, свободно ли, и вставить». Между проверкой и вставкой проходит время, и две транзакции видят один и тот же свободный слот. Почему так происходит на уровнях изоляции PostgreSQL и какие есть три способа защиты, разобрано в уроке о транзакциях. Для сервиса записи выбираем ограничение в схеме: его нельзя забыть в одном из обработчиков, и оно работает при любом числе экземпляров API.

Вот что API выполняет на POST /v1/bookings: вставку записи и события одной транзакцией. ON CONFLICT (idempotency_key) DO NOTHING превращает повтор того же запроса в пустую операцию.

sql/03-book.sql
-- Бронирование: запись и событие для уведомлений — одной транзакцией
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;
Бронирование: идемпотентная вставка и событие outbox в одной транзакции
terminal
$ 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 наблюдает.

terminal
-- Сессия 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
Реальный вывод трёх сессий; INSERT — тот же, что в 03-book.sql, с другим временем и ключом и без ON CONFLICT, длинные строки сокращены

Что произошло. B не получила ошибку сразу: незафиксированная запись A ещё могла откатиться, поэтому B ждала её решения около 3 секунд, а pg_blocking_pids показал, кого именно. Как только A зафиксировала транзакцию, B получила 23P01 — нарушение ограничения исключения. API превращает этот код в 409 Conflict и предлагает соседние слоты. Событие B в outbox тоже не появилось: оно было в той же транзакции.

terminal
=# 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)
Реальный вывод после опыта: на 12:00 одна запись и одно событие для неё

Обратный сценарий мы тоже проверили: если A делает ROLLBACK, ожидающая B через те же примерно 3 секунды успешно вставляет свою запись. Отмена работает через частичное ограничение: после UPDATE … SET status = 'cancelled' слот снова свободен, и запись на 12:30 проходит.

sql/20-gotcha-on-conflict.sql
-- Ловушка: 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;
Две вставки на пересекающееся время 12:15–13:15
terminal
-- без цели конфликта: пересечение молча проглочено
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")).
Реальный вывод: без цели пересечение проглочено, с целью — ошибка 23P01

Ожидание B не бесплатное: пока A держит транзакцию, все записи к этому мастеру на пересекающееся время стоят в очереди. Поэтому транзакция брони должна быть короткой — никаких HTTP-вызовов к SMS-шлюзу или платёжной системе между BEGIN и COMMIT.

Уведомления: outbox и очередь

Подтверждение нужно отправить ровно тогда, когда запись действительно сохранена. Если API сначала делает COMMIT, а потом кладёт задачу в очередь Redis, процесс может упасть между этими шагами: запись есть, SMS нет. Если наоборот — SMS ушло, а транзакция откатилась. Это проблема двойной записи (dual write): две независимые системы нельзя обновить атомарно.

Шаблон transactional outbox решает её средствами одной базы: событие пишется в таблицу outbox в той же транзакции, что и запись, — это уже делает 03-book.sql. Отдельный процесс-релей читает необработанные события и публикует их в очередь. Модели доставки и outbox подробно — в уроке о событийной архитектуре и очередях.

Транзакция брониСтрока в outboxРелейОчередь задачОтправка SMS
Атомарно в PostgreSQLАсинхронно, с повторами
Событие не может появиться без записи и не может потеряться после неё
sql/30-outbox-worker.sql
-- Один проход воркера 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 секунды.

terminal
-- Воркер 1: пачка по 2, держит транзакцию 3 секунды
 id |      topic
----+-----------------
  1 | booking.created
  2 | booking.created
(2 rows)
-- Воркер 2, запущен через секунду: не ждёт, берёт следующие строки
 id |      topic
----+------------------
  3 | booking.reminder
  4 | booking.reminder
(2 rows)
Реальный вывод демо-версии с LIMIT 2 и паузой: второй воркер не ждал первого

Гарантия здесь — «как минимум один раз» (at-least-once): если релей опубликовал событие, но упал до COMMIT, после перезапуска он опубликует его снова. Поэтому получатель должен быть идемпотентным: воркер отправки отмечает «подтверждение для записи 1 отправлено» и пропускает дубль. Как это сделать на BullMQ — jobId, повторы с backoff, «мёртвые» задачи — показано в уроке о фоновых задачах.

  • Напоминания за сутки и за 2 часа — отложенные задачи очереди с моментом запуска, вычисленным от lower(during).
  • При отмене или переносе воркер перепроверяет статус записи перед отправкой: это проще, чем искать и удалять задачи в очереди.
  • Обработанные строки outbox удаляет или архивирует периодическая задача, иначе таблица растёт бесконечно.

Масштабирование: кэш, реплики, секционирование

По оценке нагрузки чтений расписания в 30 раз больше, чем записей. Начнём с самого запроса свободных слотов: сетка начал с шагом 30 минут минус пересечения с действующими записями. В учебной версии рабочие часы заданы константами; в реальной системе они берутся из таблицы смен с перерывами.

sql/22-free-slots.sql
-- Свободные начала услуги длительностью 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;
Свободные слоты: generate_series и NOT EXISTS по оператору пересечения
terminal
 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)
Реальный вывод: заняты 10:00–11:00 и 12:30–13:30, запись на 12:00 к этому моменту отменена

Ограничение 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 ГБ в год до этого далеко, но ключ секционирования стоит понять заранее: самый очевидный вариант «по месяцам» ломает защиту от двойной брони.

sql/40-partitioning.sql
-- 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'));
Два варианта секционирования таблицы записей
terminal
-- 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: число необработанных событий и возраст самого старого.
  • Доля неудачных отправок по каналам и число «мёртвых» задач в очереди.
  • Отставание реплики, если с неё читают.
sql/33-outbox-lag.sql
-- Метрика «отставание outbox»: сколько событий ждёт и сколько ждёт самое старое
SELECT count(*)                         AS pending,
       coalesce(now() - min(created_at), interval '0') AS oldest_age
FROM outbox
WHERE processed_at IS NULL;
Отставание outbox — запрос для экспорта метрики
terminal
 pending |   oldest_age
---------+----------------
       1 | 00:00:03.34143
(1 row)
Реальный вывод на стенде: одно событие ждёт около 3 секунд

Алерт по возрасту самого старого события надёжнее, чем по их числу: в час пик очередь законно растёт, а событие, которое ждёт 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.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.