GET /slots/availability, POST /slots/{id}/hold, POST /holds/{id}/confirm
and DELETE /holds/{id} on Laravel 12 and MySQL 8.
- availability cached for 10s behind a single-flight lock with a stale
copy, invalidated after commit on confirm and cancel
- oversell prevented by a conditional decrement, verified by the number
of affected rows rather than a preceding select
- idempotency enforced by a unique index on holds.idempotency_key
- active holds counted with an expires_at filter, so no background
cleanup is required for correctness
- 27 feature tests against MySQL, service documentation, Postman collection
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
268 lines
22 KiB
Markdown
268 lines
22 KiB
Markdown
# Как работает сервис
|
||
|
||
Документ описывает устройство API бронирования слотов: модель данных, поведение каждого эндпоинта и механизмы, обеспечивающие корректность под нагрузкой. Инструкции по запуску и примеры запросов — в [README](../README.md) в корне.
|
||
|
||
## Предметная область
|
||
|
||
**Слот** — окно с ограниченной вместимостью: складское окно, интервал доставки, время приёма. Имеет неизменную `capacity` и расходуемый остаток `remaining`.
|
||
|
||
**Холд** — временная заявка на одно место, живёт 5 минут. Место придержано, но ещё не продано.
|
||
|
||
**Подтверждение** — превращение холда в бронь. Только здесь место списывается окончательно.
|
||
|
||
Состояния холда:
|
||
|
||
```
|
||
┌──────────────► confirmed место списано
|
||
│ │
|
||
создан → held │ │ DELETE /holds/{id}
|
||
│ ▼
|
||
├──────────────► cancelled место возвращено
|
||
│
|
||
└── прошло 5 минут ► истёк статус в базе остаётся held,
|
||
но место больше не занимает
|
||
```
|
||
|
||
Истечение холда не меняет его статус в базе. Холд перестаёт занимать место в тот момент, когда `expires_at` уходит в прошлое, — это следует из условий запросов, а не из отдельной операции. Поэтому фоновая задача очистки не требуется для корректности.
|
||
|
||
## Инварианты
|
||
|
||
Свойства, которые сервис поддерживает при любой последовательности и конкурентности запросов:
|
||
|
||
```
|
||
capacity неизменна
|
||
remaining = capacity − количество подтверждённых броней
|
||
0 ≤ remaining ≤ capacity
|
||
доступно к бронированию = remaining − количество активных холдов
|
||
```
|
||
|
||
Оверсел — нарушение третьего условия. Механизмы, описанные ниже, существуют именно для его предотвращения.
|
||
|
||
## Схема данных
|
||
|
||
**`slots`**
|
||
|
||
| Колонка | Назначение |
|
||
|---|---|
|
||
| `starts_at`, `ends_at` | границы окна |
|
||
| `capacity` | вместимость, не меняется |
|
||
| `remaining` | остаток: вместимость минус подтверждённые брони |
|
||
|
||
Оба счётчика объявлены `unsignedInteger`: база не позволит записать отрицательное значение, даже если приложение ошибётся.
|
||
|
||
**`holds`**
|
||
|
||
| Колонка | Назначение |
|
||
|---|---|
|
||
| `slot_id` | внешний ключ на слот, каскадное удаление |
|
||
| `idempotency_key` | UUID с уникальным индексом |
|
||
| `status` | `held`, `confirmed`, `cancelled` |
|
||
| `expires_at` | момент истечения |
|
||
| `confirmed_at`, `cancelled_at` | отметки времени переходов |
|
||
|
||
Уникальный индекс на `idempotency_key` — не ограничение целостности «на всякий случай», а механизм идемпотентности: две одновременные вставки с одним ключом невозможны физически.
|
||
|
||
Составной индекс `(slot_id, status, expires_at)` покрывает единственный горячий запрос — подсчёт активных холдов слота.
|
||
|
||
Понятие «холд занимает место» определено один раз, в `Hold::scopeActive()`:
|
||
|
||
```php
|
||
$query->where('status', HoldStatus::Held)->where('expires_at', '>', now());
|
||
```
|
||
|
||
Это определение используют и проверка вместимости, и проекция доступности, поэтому разойтись они не могут.
|
||
|
||
## `GET /slots/availability`
|
||
|
||
Возвращает список слотов с числом мест, доступных к бронированию.
|
||
|
||
```json
|
||
[{"slot_id":1,"capacity":10,"remaining":10},{"slot_id":2,"capacity":5,"remaining":5}]
|
||
```
|
||
|
||
`remaining` в ответе — это остаток за вычетом активных холдов, то есть то, что клиент действительно может забронировать. Без вычета клиент видел бы свободные места и получал отказ при попытке их занять.
|
||
|
||
Проекция считается одним запросом:
|
||
|
||
```sql
|
||
select slots.id, slots.capacity, slots.remaining,
|
||
coalesce(active_holds.held_count, 0) as held_count
|
||
from slots
|
||
left join (
|
||
select slot_id, count(*) as held_count from holds
|
||
where status = 'held' and expires_at > ?
|
||
group by slot_id
|
||
) as active_holds on active_holds.slot_id = slots.id
|
||
order by slots.id
|
||
```
|
||
|
||
### Кеширование
|
||
|
||
Ответ кешируется на 10 секунд (`SLOT_AVAILABILITY_CACHE_TTL`, допустимый по требованиям диапазон 5–15).
|
||
|
||
Наивное кеширование через `Cache::remember` уязвимо к cache stampede: в момент истечения TTL все параллельные запросы одновременно обнаруживают промах и все идут в базу — пик нагрузки приходится на момент высокого трафика. Защита построена в три слоя:
|
||
|
||
**Single-flight.** Пересчитывает только процесс, захвативший `Cache::lock`. Внутри блокировки кеш перечитывается повторно: предыдущий владелец мог его уже заполнить.
|
||
|
||
**Stale-копия.** Хранится в шесть раз дольше основной записи. Процессы, не получившие блокировку, отдают её немедленно, не обращаясь к базе. Данные при этом устаревают на единицы секунд — для справочного списка это допустимо.
|
||
|
||
**Ограниченное ожидание.** На холодном кеше stale-копии нет: это первый запрос после старта либо момент сразу после инвалидации. Такой запрос ждёт владельца блокировки до трёх секунд и читает записанный им результат. Если не дождался — обращается к базе напрямую: чтение не должно завершаться ошибкой.
|
||
|
||
Инвалидация удаляет оба ключа — основной и stale, иначе после подтверждения брони клиенты продолжали бы получать устаревшие остатки.
|
||
|
||
## `POST /slots/{id}/hold`
|
||
|
||
Создаёт холд. Требует заголовок `Idempotency-Key` с корректным UUID, иначе `422`.
|
||
|
||
Последовательность:
|
||
|
||
```sql
|
||
select * from holds where idempotency_key = ? limit 1
|
||
select * from slots where slots.id = ? limit 1 for update
|
||
select count(*) from holds
|
||
where holds.slot_id = ? and status = 'held' and expires_at > ?
|
||
insert into holds (idempotency_key, status, expires_at, slot_id, ...) values (...)
|
||
```
|
||
|
||
Шаги 2–4 выполняются в транзакции.
|
||
|
||
### Идемпотентность
|
||
|
||
Повторный запрос с тем же ключом возвращает исходный холд и код `200` вместо `201`. Новая строка не создаётся.
|
||
|
||
Поиск по ключу перед вставкой — оптимизация для обычного повтора, не гарантия. Два одновременных запроса с одним ключом оба не найдут ничего и оба пойдут вставлять; второй получит нарушение уникального индекса. Исключение перехватывается, холд перечитывается и возвращается как результат. Гарантию даёт база, а не порядок операций в приложении.
|
||
|
||
Ключ, уже использованный для другого слота, приводит к `422`: молча вернуть найденный холд означало бы отдать клиенту бронь на слот, которого он не запрашивал.
|
||
|
||
### Проверка вместимости
|
||
|
||
Число доступных мест — это `remaining` минус количество активных холдов, то есть значение из двух таблиц. Одной атомарной операцией это не выражается, поэтому строка слота блокируется `SELECT ... FOR UPDATE`: конкурентные запросы по одному слоту выстраиваются в очередь, и посчитанное значение остаётся достоверным до конца транзакции. Блокировка затрагивает одну строку — бронирования разных слотов друг друга не задерживают.
|
||
|
||
Порядок обращений к базе внутри транзакции существен. MySQL на уровне изоляции REPEATABLE READ создаёт снимок данных при первом *неблокирующем* чтении; `SELECT ... FOR UPDATE` таким не является. Блокировка выполняется первой, поэтому снимок создаётся уже после её получения и подсчёт видит холды, вставленные конкурентом. Любое обычное чтение, добавленное перед блокировкой, зафиксировало бы снимок раньше и открыло бы окно для оверсела.
|
||
|
||
Создание холда не уменьшает `remaining` — место списывается только при подтверждении.
|
||
|
||
## `POST /holds/{id}/confirm`
|
||
|
||
Переводит холд в `confirmed` и списывает одно место.
|
||
|
||
```sql
|
||
select * from holds where holds.id = ? limit 1 for update
|
||
update slots set remaining = remaining - 1, updated_at = ?
|
||
where slots.id = ? and remaining > 0
|
||
update holds set status = ?, confirmed_at = ?, updated_at = ? where id = ?
|
||
```
|
||
|
||
Блокируется строка **холда**, а не слота: решение зависит от состояния самого холда, и блокировка нужна, чтобы два одновременных подтверждения одного холда не прошли проверки одновременно.
|
||
|
||
### Защита от оверсела
|
||
|
||
Предварительной проверки остатка нет. Условие находится в самом `UPDATE`, и результат определяется числом затронутых строк:
|
||
|
||
```php
|
||
$consumed = Slot::query()
|
||
->whereKey($hold->slot_id)
|
||
->where('remaining', '>', 0)
|
||
->decrement('remaining');
|
||
|
||
if ($consumed === 0) {
|
||
throw SlotBookingException::capacityExhausted($hold->slot_id);
|
||
}
|
||
```
|
||
|
||
Новое значение вычисляет база (`remaining = remaining - 1`), а не приложение, поэтому чужое списание невозможно затереть. Проверка и списание — одна операция, промежутка, в который мог бы вклиниться конкурент, не существует. Ноль затронутых строк означает, что мест не было, и приводит к `409`.
|
||
|
||
Этот приём предпочтительнее блокировки, когда условие выражается в том же запросе, который меняет данные: ожидания не возникает, конкуренты не выстраиваются в очередь.
|
||
|
||
Гарантия дублируется на уровне схемы: `remaining` объявлен без знака, поэтому даже при ошибке в приложении база отвергнет уход в минус.
|
||
|
||
### Идемпотентность и проверки состояния
|
||
|
||
Повторное подтверждение возвращает `200` и тот же результат; место списывается один раз.
|
||
|
||
Порядок проверок: сначала «уже подтверждён», затем «отменён или истёк». Подтверждённый холд с истёкшим `expires_at` — нормальная ситуация: бронь действительна, срок относился к периоду ожидания подтверждения. Проверка истечения, поставленная первой, ломала бы идемпотентность спустя пять минут после подтверждения.
|
||
|
||
### Инвалидация кеша
|
||
|
||
Кеш сбрасывается после фиксации транзакции, а не внутри неё. Сброс внутри оставлял бы окно, в котором читатель обращается к базе, видит ещё не зафиксированные значения и записывает в кеш устаревшие данные на весь TTL.
|
||
|
||
Создание холда кеш намеренно не инвалидирует: требования предписывают инвалидацию после подтверждения и отмены. Это согласуется и с назначением кеша — поток холдов сбрасывал бы его непрерывно, обнуляя смысл кеширования. Доступность носит справочный характер, авторитетная проверка выполняется при создании холда.
|
||
|
||
## `DELETE /holds/{id}`
|
||
|
||
Переводит холд в `cancelled`.
|
||
|
||
Место возвращается только при отмене подтверждённого холда, поскольку только подтверждение его списывало. Холд в статусе `held` при отмене перестаёт учитываться в активных, и слот становится доступен без изменения `remaining`. Инкремент дополнительно ограничен условием `remaining < capacity`, поэтому никакая последовательность отмен не увеличит остаток выше вместимости.
|
||
|
||
Повторная отмена возвращает `200`; место возвращается один раз.
|
||
|
||
## Конкурентность: два механизма
|
||
|
||
| Операция | Механизм | Причина выбора |
|
||
|---|---|---|
|
||
| `hold` | `SELECT ... FOR UPDATE` по строке слота | решение зависит от подсчёта в другой таблице, одним запросом не выражается |
|
||
| `confirm` | условие в `UPDATE`, проверка по числу затронутых строк | условие выражается в том же запросе, который меняет данные |
|
||
| идемпотентность | уникальный индекс | зазор между проверкой и вставкой средствами приложения не закрывается |
|
||
|
||
Проверено под реальной конкуренцией (20 параллельных запросов, сервер в 8 воркеров):
|
||
|
||
| Сценарий | Результат |
|
||
|---|---|
|
||
| 20 холдов на слот с одним местом | один `201`, девятнадцать `409`, одна строка в базе |
|
||
| 20 подтверждений за одно место | один `200`, девятнадцать `409`, `remaining` = 0 |
|
||
| 20 холдов с одинаковым ключом | один `201`, девятнадцать `200`, одна строка в базе |
|
||
|
||
## Формат ошибок
|
||
|
||
```json
|
||
{"message": "Slot 3 has no remaining capacity.", "reason": "capacity_exhausted"}
|
||
```
|
||
|
||
| `reason` | Код | Когда |
|
||
|---|---|---|
|
||
| `capacity_exhausted` | 409 | мест нет при создании или подтверждении |
|
||
| `hold_cancelled` | 409 | подтверждение отменённого холда |
|
||
| `hold_expired` | 409 | подтверждение истёкшего холда |
|
||
| `idempotency_key_reuse` | 422 | ключ уже использован для другого слота |
|
||
| `not_found` | 404 | слот или холд не существует |
|
||
|
||
Ошибки валидации отдаются в стандартном для Laravel формате с объектом `errors`.
|
||
|
||
Сервисный слой не работает с HTTP: он бросает `SlotBookingException` с машиночитаемым кодом, а сопоставление с кодами ответа описано в `bootstrap/app.php`. Тот же сервис можно вызвать из консольной команды или обработчика очереди без изменений.
|
||
|
||
## Реализационные решения
|
||
|
||
**Активные холды считаются запросом, а не хранятся счётчиком.** Фоновая очистка по условиям задачи не требуется, а счётчик без неё разошёлся бы с реальностью в момент, когда первый холд истекает молча. Фильтр по `expires_at` освобождает место автоматически и точно в срок.
|
||
|
||
**Кеш и блокировки хранятся в MySQL** (`CACHE_STORE=database`), поэтому проект поднимается без дополнительных сервисов. Код обращается только к фасаду `Cache`, так что переход на Redis — изменение одной переменной окружения.
|
||
|
||
**Префикс `api` у маршрутов отключён**, чтобы пути совпадали с указанными в задании. Маршруты при этом объявлены в `routes/api.php` и получают stateless-мидлвары группы `api`.
|
||
|
||
**Привязка моделей к маршрутам не используется**: сервису нужно читать строку внутри собственной транзакции с блокировкой, а мидлвар сделал бы это раньше и без неё.
|
||
|
||
**Ответы не заворачиваются в `data`**: формат доступности задан в требованиях как массив, остальные эндпоинты приведены к той же конвенции.
|
||
|
||
## Тесты
|
||
|
||
27 функциональных тестов, 90 проверок. Выполняются против MySQL, а не SQLite: под проверкой находятся `SELECT ... FOR UPDATE` и условный декремент, которые SQLite не воспроизводит.
|
||
|
||
Покрыто, помимо основных сценариев:
|
||
|
||
- невозможность оверсела при конкурентном подтверждении последнего места;
|
||
- отсутствие обращений к базе у запроса, проигравшего гонку за пересчёт кеша;
|
||
- истечение TTL кеша и его инвалидация после подтверждения;
|
||
- отсутствие инвалидации при создании холда;
|
||
- освобождение мест истёкшими и отменёнными холдами;
|
||
- идемпотентность создания, подтверждения и отмены;
|
||
- отказ при переиспользовании ключа для другого слота.
|
||
|
||
## Границы
|
||
|
||
Не реализовано, так как выходит за рамки задания:
|
||
|
||
- фоновая очистка истёкших холдов (для корректности не нужна, в продакшене — чтобы таблица не росла);
|
||
- аутентификация и привязка холда к пользователю;
|
||
- пагинация и фильтры доступности;
|
||
- ограничение частоты запросов, уведомления о смене статуса холда.
|
||
|
||
При высокой конкуренции на один популярный слот блокировка строки станет узким местом: запросы к нему выстроятся в очередь. В этом случае имеет смысл перейти к счётчику, уменьшающемуся уже при создании холда, и добавить возврат мест по истечении. Такая схема сложнее и требует фоновой очистки, поэтому при текущих требованиях выбрана простая.
|