Перейти к основному содержимому

Интервью по System Design. Александр Поломодов (Тинькофф)

· 54 мин. чтения

Сегодня мы разберём пример системного проектирования на интервью, в котором кандидат и интервьюер совместно проектируют архитектуру сервиса бронирования отелей. Кандидат демонстрирует методичный подход: от уточнения функциональных и нефункциональных требований, построения диаграммы контекста C4, до декомпозиции на сервисов с выбором стратегий хранения данных, шардирования, обеспечения транзакционной целостности и обработки повторных запросов. Интервьюер кандидат выделяет за глубокое понимание предметной области, хорошее владение архитектурными паттернами и способность самостоятельно выявлять ключевые проблемы, такие как динамическое ценообразование, овербукинг и идемпотентность операций.

Вопрос 4. Какова ожидаемая масштабируемость системы — количество отелей и номерного фонда?

Таймкод: 00:12:32

Ответ собеседника: Правильный. Ориентируются на рынок России: примерно 20 тысяч отелей и около 1 миллиона номеров (округлённо).

Правильный ответ:

Масштаб системы — один из ключевых входных параметров для проектирования архитектуры, выбора базы данных, стратегии кэширования и шардирования.

Данные по России

По состоянию на 2023–2024 год в России зарегистрировано порядка 20 000–23 000 средств размещения (отели, хостелы, гостиницы, базы отдыха). Общий номерной фонд составляет приблизительно 900 000–1 100 000 номеров. Эти цифры разумно округлять до 20K отелей и 1M номеров для инженерных оценок.

Данные по миру (для контекста)

Глобально насчитывается около 700 000–800 000 отелей с номерным фондом порядка 17–18 миллионов номеров. Если система планируется как международная платформа (аналог Booking.com или Ostrovok), архитектура должна закладывать масштаб на порядок больше.

Почему это важно для архитектуры

  • База данных: 1 миллион номеров — это относительно небольшой объём данных, который помещается в одну таблицу PostgreSQL без шардирования. Но если учитывать бронирования, историю цен, доступность по дням — объём растёт на порядки. Например, если для каждого номера хранить доступность на 365 дней вперёд, это уже 365 миллионов строк в таблице availability.
  • Кэширование: при 20K отелей и высокой читающей нагрузке (поиск, просмотр карточек отелей) кэш на базе Redis становится критически важным.
  • Гео-распределение: если покрытие — вся Россия, имеет смысл размещать инстансы в нескольких регионах (Москва, Петербург, Новосибирск) для снижения латентности.
  • Бронирования: при 1M номеров и среднем заполнении 60%, в любой момент времени активно около 600K бронирований. Пиковые нагрузки (праздники, сезон) могут создавать кратковременные всплески записи.

Инженерная оценка

Для российского рынка разумно закладывать:

  • 20 000–25 000 отелей
  • 1 000 000–1 200 000 номеров
  • До 500 000–1 000 000 активных бронирований одновременно в пиковые периоды
  • Хранение доступности на 1–2 года вперёд

Эти цифры позволяют обоснованно выбирать технологический стек и оценивать нагрузку на каждую подсистему.

Вопрос 5. Какие нефункциональные требования и оценки нагрузки закладываются в систему (RPS, заполняемость, длительность бронирования, воронка конверсии)?

Таймкод: 00:13:21

Ответ собеседника: Правильный. Средняя заполняемость — 60%, средняя длительность бронирования — 3 дня. Воронка: 100% смотрят отели, 10% доходят до страницы бронирования, 1% бронирует. Получилось ~200K забронированных номеров в день (~2 RPS), с пиками до 50 RPS на бронирование и до 5000 RPS на просмотр отелей.

Правильный ответ:

Оценка нагрузки — фундамент для проектирования архитектуры. Правильно собранная воронка позволяет вывести RPS для каждого сервиса.

Воронка конверсии

Классическая воронка для отельных агрегаторов:

  • 100% — поиск / просмотр списка отелей (read-heavy)
  • 10–20% — переход на карточку конкретного отеля (read-heavy)
  • 3–5% — выбор дат и просмотр доступных номеров (read-heavy + availability check)
  • 1–2% — начало процесса бронирования (write)
  • 0.5–1% — подтверждённое бронирование (write + payment)

Вывод RPS из бизнес-метрик

Исходные данные: 1 000 000 номеров, заполняемость 60%, средняя длительность 3 дня.

Количество бронирований в день:

600 000 занятых номеров / 3 дня = 200 000 бронирований/день
200 000 / 86 400 секунд ≈ 2.3 RPS (равномерно)

С учётом неравномерности (пиковые часы 10:00–22:00, 80% трафика):

200 000 × 0.8 / (12 × 3600) ≈ 3.7 RPS в пиковые часы

С учётом сезонности (праздники, лето — ×5–10):

Бронирование: 3.7 × 10 ≈ 37–50 RPS (write)

Чтение (просмотр отелей) — по воронке:

50 RPS (бронирование) × 100 (коэффициент воронки) ≈ 5000 RPS (read)

Итоговые оценки

МетрикаСреднееПик
Просмотр списка отелей~600 RPS~5000 RPS
Просмотр карточки отеля~60 RPS~500 RPS
Проверка доступности~20 RPS~200 RPS
Создание бронирования~2 RPS~50 RPS
Оплата~1 RPS~30 RPS

Нефункциональные требования

  • Доступность (Availability): 99.9% для сервиса бронирования (потеря ≈ 8.7 часа/год), 99.95% для оплаты. Для чтения (просмотр отелей) допустимо 99.5% с деградацией через кэш.
  • Latency: P95 < 200ms для чтения, P95 < 500ms для бронирования, P95 < 2s для оплаты (включая внешние провайдеры).
  • Consistency: строгая консистентность для бронирования и оплаты (нельзя продать номер дважды). Eventual consistency допустима для поиска и каталога.
  • Данные: хранение истории бронирований минимум 2–3 года, данных о доступности — 1–2 года вперёд.
  • Гео: если покрытие — Россия, латентность < 100ms для 90% пользователей при размещении в 2–3 регионах.

Практический совет

На практике оценки кандидата корректны. Важно понимать, что read/write соотношение здесь примерно 100:1, что диктует архитектуру с мощным кэшированием на чтении и акцентом на consistency на записи.

Вопрос 6. Какова архитектура уровня API и баз данных для системы отелей и бронирования? Какие технологии предлагаются для кэширования и хранения данных?

Таймкод: 00:20:06

Ответ собеседника: Правильный. Предлагается сервисная архитектура с Load Balancer (nginx, round-robin) и API Gateway. Hotel и Room API объединены в один сервис. Для кэширования — Redis. Для БД отелей — master-replica (master для записи, replica для чтения). Объём небольшой (20K отелей, 1M номеров), одной БД достаточно. Для бронирований — PostgreSQL с транзакционной целостностью. Шардинг отложен.

Правильный ответ:

Уровень API

Разумно начинать с единого API Gateway, который маршрутизирует запросы к внутренним сервисам:

Client → CDN/Load Balancer → API Gateway → [Hotel Service, Booking Service, Search Service, Payment Service]
  • API Gateway (Kong, Ambassador, или самописный на Go): аутентификация, rate-limiting, маршрутизация, агрегация запросов.
  • Load Balancer: nginx/HAProxy с round-robin или least-connections. Для гео-распределения — Anycast DNS или GeoDNS.
  • Hotel Service и Room Service логично объединить на старте, так как данные тесно связаны и читаются вместе. Разделение имеет смысл, когда команда растёт или когда нагрузка на номера (availability) значительно превышает нагрузку на статику отелей.

Кэширование

Redis — правильный выбор для кэширования. Стратегия:

  • Карточки отелей (название, описание, фото, удобства): TTL 1–6 часов. Инвалидация при изменении данных отеля. Ключ: hotel:{id}.
  • Доступность номеров (availability по дням): TTL 5–15 минут. Ключ: availability:{room_id}:{date}. Это самый критичный кэш — при бронировании необходима инвалидация.
  • Результаты поиска: TTL 1–5 минут. Ключ: хеш параметров поиска.
  • Rate limiting: Redis с алгоритмом sliding window.
// Пример кэширования карточки отеля
func (s *HotelService) GetHotel(ctx context.Context, id int64) (*Hotel, error) {
cacheKey := fmt.Sprintf("hotel:%d", id)

// Пробуем кэш
var hotel Hotel
if err := s.cache.GetJSON(ctx, cacheKey, &hotel); err == nil {
return &hotel, nil
}

// Кэш пуст — идём в БД
hotel, err := s.db.GetHotel(ctx, id)
if err != nil {
return nil, err
}

// Пишем в кэш с TTL 1 час
s.cache.SetJSON(ctx, cacheKey, hotel, time.Hour)
return &hotel, nil
}

База данных отелей (Hotel DB)

Объём данных действительно небольшой: 20K отелей + 1M номеров + фото/описания. Один инстанс PostgreSQL с одной-двумя read-replica покрывает нагрузку.

  • Master: запись (админка, обновление данных отелей)
  • Replica: чтение (API для пользователей)

Индексы: hotels(city_id, stars), rooms(hotel_id, type), hotel_amenities(hotel_id, amenity_id).

База данных бронирований (Booking DB)

PostgreSQL — оптимальный выбор:

  • ACID-транзакции критичны для бронирования (нельзя продать номер дважды)
  • Поддержка SELECT ... FOR UPDATE для пессимистичных блокировок
  • Уникальные констрейнты для предотвращения дублирования
-- Таблица бронирований с защитой от двойного бронирования
CREATE TABLE bookings (
id BIGSERIAL PRIMARY KEY,
room_id BIGINT NOT NULL REFERENCES rooms(id),
user_id BIGINT NOT NULL,
check_in DATE NOT NULL,
check_out DATE NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'pending',
total_price DECIMAL(10,2) NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
-- Запрещаем пересечение бронирований для одного номера
EXCLUDE USING gist (
room_id WITH =,
daterange(check_in, check_out, '[)') WITH &&
) WHERE (status NOT IN ('cancelled', 'expired'))
);

CREATE INDEX idx_bookings_room_dates ON bookings (room_id, check_in, check_out);
CREATE INDEX idx_bookings_user ON bookings (user_id);

Почему шардинг можно отложить

При 200K бронированиях в день за год получается ~73M записей. PostgreSQL справляется с таблицами в сотни миллионов строк при правильном индексировании и партиционировании. Шардинг потребуется при масштабировании на международный рынок или при необходимости гео-распределения данных.

Партиционирование вместо шардинга

Для Booking DB разумно использовать нативное партиционирование PostgreSQL по дате:

CREATE TABLE bookings (
...
) PARTITION BY RANGE (check_in);

CREATE TABLE bookings_2024_q1 PARTITION OF bookings
FOR VALUES FROM ('2024-01-01') TO ('2024-04-01');
CREATE TABLE bookings_2024_q2 PARTITION OF bookings
FOR VALUES FROM ('2024-04-01') TO ('2024-07-01');

Это даёт производительность, близкую к шардингу, без сложности распределённой системы.

Вопрос 7. Как обрабатываются операции с деньгами и оплатой бронирования? Есть ли внешняя платёжная система?

Таймкод: 00:26:45

Ответ собеседника: Правильный. Предполагается использование внешней платёжной системы (Payment Service Provider), с которой сервис бронирования взаимодействует для проведения оплат.

Правильный ответ:

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

Архитектура платёжного потока

Booking Service → Payment Service → PSP (Stripe / ЮKassa / CloudPayments)

Webhook Callback

Обновление статуса бронирования

Ключевые принципы

1. Идемпотентность платежей

Каждый запрос на оплату должен содержать уникальный ключ идемпотентности, чтобы при повторных запросах (таймаут, retry) не происходило двойного списания.

type PaymentRequest struct {
BookingID int64 `json:"booking_id"`
Amount int64 `json:"amount"` // в копейках/центах
Currency string `json:"currency"` // "RUB"
IdempotencyKey string `json:"idempotency_key"` // UUID
PaymentMethodID string `json:"payment_method_id"`
ReturnURL string `json:"return_url"`
}

type PaymentResponse struct {
PaymentID string `json:"payment_id"`
Status string `json:"status"` // "pending", "succeeded", "failed"
ConfirmationURL string `json:"confirmation_url,omitempty"` // 3DS redirect
}

func (s *PaymentService) CreatePayment(ctx context.Context, req PaymentRequest) (*PaymentResponse, error) {
// Проверяем, был ли уже платёж с таким ключом
existing, err := s.paymentRepo.GetByIdempotencyKey(ctx, req.IdempotencyKey)
if err == nil {
return existing.ToResponse(), nil // Уже обработан — возвращаем результат
}

// Создаём платёж в PSP
pspResp, err := s.pspClient.CreatePayment(ctx, psp.PaymentParams{
Amount: req.Amount,
Currency: req.Currency,
IdempotencyKey: req.IdempotencyKey,
Description: fmt.Sprintf("Booking #%d", req.BookingID),
ReturnURL: req.ReturnURL,
})
if err != nil {
return nil, fmt.Errorf("psp create payment: %w", err)
}

// Сохраняем платёж в БД
payment := &Payment{
BookingID: req.BookingID,
PSPID: pspResp.ID,
Amount: req.Amount,
Currency: req.Currency,
Status: PaymentStatusPending,
IdempotencyKey: req.IdempotencyKey,
}
if err := s.paymentRepo.Create(ctx, payment); err != nil {
return nil, fmt.Errorf("save payment: %w", err)
}

return &PaymentResponse{
PaymentID: payment.ID,
Status: string(payment.Status),
ConfirmationURL: pspResp.ConfirmationURL,
}, nil
}

2. Webhook-колбэк от PSP

Платёжная система асинхронно уведомляет о результате. Webhook должен быть:

  • Идемпотентным (повторные вызовы не меняют результат)
  • Верифицированным (проверка подписи)
  • Быстрым (не выполнять тяжёлой логики в обработчике)
func (s *PaymentService) HandleWebhook(ctx context.Context, payload []byte, signature string) error {
// Проверяем подпись
if !s.pspClient.VerifyWebhook(payload, signature) {
return ErrInvalidSignature
}

event, err := s.pspClient.ParseEvent(payload)
if err != nil {
return fmt.Errorf("parse event: %w", err)
}

return s.db.RunTx(ctx, func(tx *sql.Tx) error {
payment, err := s.paymentRepo.GetByPSPID(ctx, tx, event.PaymentID)
if err != nil {
return err
}

// Идемпотентность: если статус уже финальный — выходим
if payment.Status == PaymentStatusSucceeded || payment.Status == PaymentStatusFailed {
return nil
}

switch event.Type {
case "payment.succeeded":
payment.Status = PaymentStatusSucceeded
payment.PaidAt = time.Now()
if err := s.paymentRepo.Update(ctx, tx, payment); err != nil {
return err
}
// Подтверждаем бронирование
return s.bookingRepo.Confirm(ctx, tx, payment.BookingID)

case "payment.failed":
payment.Status = PaymentStatusFailed
payment.FailReason = event.FailureReason
if err := s.paymentRepo.Update(ctx, tx, payment); err != nil {
return err
}
// Отменяем бронирование, освобождаем номер
return s.bookingRepo.Cancel(ctx, tx, payment.BookingID, "payment_failed")
}
return nil
})
}

3. Статусная модель

Бронирование и платёж проходят через конечный автомат состояний:

Booking: pending → confirmed → checked_in → checked_out

cancelled

Payment: pending → processing → succeeded
↓ ↓
failed refunded

4. Выбор PSP для России

Для российского рынка основные варианты:

  • ЮKassa (Яндекс) — широкое покрытие, поддержка СБП, карт, электронных кошельков
  • CloudPayments — удобная API, поддержка рекуррентных платежей
  • SberPay / Тинькофф — если нужна интеграция с конкретным банком

Для международного рынка — Stripe или Adyen.

5. Безопасность

  • Никогда не хранить данные карт на своих серверах (PCI DSS compliance)
  • Использовать токенизацию через PSP
  • Все операции с деньгами — только через HTTPS
  • Логировать все платёжные операции для аудита
  • Хранить суммы в минимальных единицах (копейки/центы) в целочисленном типе

6. Возвраты (Refunds)

Возврат — отдельная операция, привязанная к исходному платежу:

func (s *PaymentService) RefundPayment(ctx context.Context, bookingID int64, amount int64) error {
payment, err := s.paymentRepo.GetByBookingID(ctx, bookingID)
if err != nil {
return err
}
if payment.Status != PaymentStatusSucceeded {
return ErrPaymentNotSucceeded
}

refundAmount := amount
if refundAmount == 0 {
refundAmount = payment.Amount // полный возврат
}

refund, err := s.pspClient.Refund(ctx, payment.PSPID, refundAmount)
if err != nil {
return fmt.Errorf("psp refund: %w", err)
}

return s.refundRepo.Create(ctx, &Refund{
PaymentID: payment.ID,
Amount: refundAmount,
PSPID: refund.ID,
Status: RefundStatusProcessing,
})
}

7. Сверка (Reconciliation)

Регулярный (ежедневный) процесс сверки платежей между внутренней БД и PSP:

func (s *PaymentService) Reconcile(ctx context.Context, date time.Time) error {
// Загружаем все платежи за день из PSP
pspPayments, err := s.pspClient.ListPayments(ctx, date)
if err != nil {
return err
}

// Загружаем все платежи за день из нашей БД
localPayments, err := s.paymentRepo.ListByDate(ctx, date)
if err != nil {
return err
}

// Сравниваем и находим расхождения
discrepancies := findDiscrepancies(localPayments, pspPayments)
if len(discrepancies) > 0 {
s.alert.Send(fmt.Sprintf("Payment reconciliation: %d discrepancies", len(discrepancies)))
}
return nil
}

Это защищает от потери платежов, расхождений в суммах и ошибок интеграции.

Вопрос 8. Как реализовать повторные попытки (retry) при взаимодействии с внешней платёжной системой и как обрабатывать сценарий overbooking?

Таймкод: 00:27:26

Ответ собеседника: Правильный. Предлагается outbox-паттерн с локальной БД для хранения состояний платежей и retry-логики. Overbooking предлагается настраивать на уровне типов комнат через коэффициент — больше для стандартных, меньше для премиальных.

Правильный ответ:

Retry-стратегия для платежей

При взаимодействии с внешними системами retry — необходимость, но он должен быть осмысленным.

1. Exponential backoff с jitter

func (s *PaymentService) CreatePaymentWithRetry(ctx context.Context, req PaymentRequest) (*PaymentResponse, error) {
const maxRetries = 3
baseDelay := 100 * time.Millisecond

var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
// Exponential backoff с jitter
delay := baseDelay * time.Duration(1<<uint(attempt-1))
jitter := time.Duration(rand.Int63n(int64(delay / 2)))
time.Sleep(delay + jitter)
}

resp, err := s.CreatePayment(ctx, req)
if err == nil {
return resp, nil
}

lastErr = err

// Не ретраим клиентские ошибки
if errors.Is(err, ErrInvalidRequest) || errors.Is(err, ErrInsufficientFunds) {
return nil, err
}

// Ретраим только временные ошибки (таймаут, 503, network error)
if !isRetriable(err) {
return nil, err
}
}

return nil, fmt.Errorf("payment failed after %d retries: %w", maxRetries, lastErr)
}

func isRetriable(err error) bool {
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
return true
}
var pspErr *psp.Error
if errors.As(err, &pspErr) {
return pspErr.Code == psp.ErrCodeTimeout || pspErr.Code == psp.ErrCodeServiceUnavailable
}
return false
}

2. Outbox-паттерн для гарантированной доставки

Кандидат верно указал на outbox — это ключевой паттерн для надёжного взаимодействия с внешними системами.

// Таблица outbox
CREATE TABLE payment_outbox (
id BIGSERIAL PRIMARY KEY,
booking_id BIGINT NOT NULL,
idempotency_key VARCHAR(64) UNIQUE NOT NULL,
payload JSONB NOT NULL,
status VARCHAR(20) DEFAULT 'pending', -- pending, processing, completed, failed
attempt_count INT DEFAULT 0,
next_attempt_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_payment_outbox_status_next_attempt
ON payment_outbox (status, next_attempt_at)
WHERE status IN ('pending', 'failed');
// Отправка через outbox
func (s *PaymentService) CreatePayment(ctx context.Context, req PaymentRequest) error {
return s.db.RunTx(ctx, func(tx *sql.Tx) error {
// 1. Создаём бронирование
booking := &Booking{
RoomID: req.RoomID,
UserID: req.UserID,
CheckIn: req.CheckIn,
CheckOut: req.CheckOut,
Status: BookingStatusPending,
TotalPrice: req.Amount,
}
if err := s.bookingRepo.Create(ctx, tx, booking); err != nil {
return err
}

// 2. Пишем в outbox (в той же транзакции!)
outbox := &PaymentOutbox{
BookingID: booking.ID,
IdempotencyKey: req.IdempotencyKey,
Payload: mustMarshal(req),
Status: OutboxStatusPending,
}
if err := s.outboxRepo.Create(ctx, tx, outbox); err != nil {
return err
}

return nil
})
}

// Worker, который обрабатывает outbox
func (s *PaymentService) ProcessOutbox(ctx context.Context) error {
items, err := s.outboxRepo.GetPending(ctx, 100)
if err != nil {
return err
}

for _, item := range items {
if err := s.processOutboxItem(ctx, item); err != nil {
s.log.Error("outbox processing failed", "item_id", item.ID, "err", err)
}
}
return nil
}

func (s *PaymentService) processOutboxItem(ctx context.Context, item *PaymentOutbox) error {
// Помечаем как processing
if err := s.outboxRepo.MarkProcessing(ctx, item.ID); err != nil {
return err
}

var req PaymentRequest
if err := json.Unmarshal(item.Payload, &req); err != nil {
return s.outboxRepo.MarkFailed(ctx, item.ID, "invalid payload")
}

// Вызываем PSP
resp, err := s.pspClient.CreatePayment(ctx, toPSPParams(req))
if err != nil {
// Увеличиваем счётчик попыток, откладываем
nextAttempt := time.Now() + time.Duration(1<<uint(item.AttemptCount))*time.Second
return s.outboxRepo.MarkFailedWithRetry(ctx, item.ID, err.Error(), nextAttempt)
}

// Успех — помечаем completed
return s.outboxRepo.MarkCompleted(ctx, item.ID, resp.PSPID)
}

3. Overbooking-стратегия

Overbooking — реальная практика в отельном бизнесе. Коэффициент зависит от типа номера и прогнозируемого процента no-show.

-- Настройка overbooking по типам номеров
CREATE TABLE room_type_overbooking (
room_type_id BIGINT PRIMARY KEY REFERENCES room_types(id),
overbooking_pct DECIMAL(5,2) NOT NULL DEFAULT 0, -- например, 10.00 = 10%
max_overbooking INT NOT NULL DEFAULT 0 -- жёсткий лимит
);

-- Пример данных:
-- Standard: overbooking_pct = 15%, max_overbooking = 5
-- Deluxe: overbooking_pct = 10%, max_overbooking = 3
-- Suite: overbooking_pct = 5%, max_overbooking = 1
-- Penthouse: overbooking_pct = 0%, max_overbooking = 0
// Проверка доступности с учётом overbooking
func (s *BookingService) CheckAvailability(ctx context.Context, roomTypeID int64, checkIn, checkOut time.Time) (int, error) {
// Физическое количество номеров этого типа
totalRooms, err := s.roomRepo.CountByType(ctx, roomTypeID)
if err != nil {
return 0, err
}

// Настройки overbooking
ob, err := s.overbookingRepo.GetByRoomType(ctx, roomTypeID)
if err != nil {
return 0, err
}

// Разрешённое количество с учётом overbooking
allowedRooms := totalRooms + int(math.Floor(float64(totalRooms)*ob.OverbookingPct/100))
if ob.MaxOverbooking > 0 && allowedRooms > totalRooms+ob.MaxOverbooking {
allowedRooms = totalRooms + ob.MaxOverbooking
}

// Текущее количество активных бронирований на эти даты
bookedRooms, err := s.bookingRepo.CountActiveForDates(ctx, roomTypeID, checkIn, checkOut)
if err != nil {
return 0, err
}

available := allowedRooms - bookedRooms
if available < 0 {
available = 0
}
return available, nil
}

4. Обработка overbooking при заселении

Если фактически гостей больше, чем номеров, нужен план действий:

type OverbookingResolution struct {
BookingID int64
Action string // "upgrade", "relocate", "compensation"
NewRoomID *int64 // при upgrade/relocate
Compensation int64 // сумма компенсации в копейках
}

func (s *BookingService) HandleOverbooking(ctx context.Context, hotelID int64, date time.Time) ([]OverbookingResolution, error) {
// Находим все бронирования на дату сверх физического количества
overbooked, err := s.bookingRepo.GetOverbooked(ctx, hotelID, date)
if err != nil {
return nil, err
}

var resolutions []OverbookingResolution
for _, booking := range overbooked {
// Приоритет 1: бесплатный upgrade на более высокую категорию
upgradedRoom, err := s.roomRepo.FindUpgrade(ctx, booking.RoomID)
if err == nil && upgradedRoom != nil {
resolutions = append(resolutions, OverbookingResolution{
BookingID: booking.ID,
Action: "upgrade",
NewRoomID: &upgradedRoom.ID,
})
continue
}

// Приоритет 2: переселение в партнёрский отель
partnerRoom, err := s.partnerService.FindAlternative(ctx, hotelID, booking)
if err == nil && partnerRoom != nil {
resolutions = append(resolutions, OverbookingResolution{
BookingID: booking.ID,
Action: "relocate",
NewRoomID: &partnerRoom.ID,
Compensation: 500000, // 5000 руб компенсация
})
continue
}

// Приоритет 3: полная компенсация + отмена
resolutions = append(resolutions, OverbookingResolution{
BookingID: booking.ID,
Action: "compensation",
Compensation: booking.TotalPrice * 2, // двойная компенсация
})
}
return resolutions, nil
}

5. Мониторинг overbooking

Критически важно отслеживать метрики:

// Метрики для мониторинга
var (
overbookingRate = promauto.NewGaugeVec(prometheus.GaugeOpts{
Name: "hotel_overbooking_rate",
Help: "Current overbooking rate by hotel and room type",
}, []string{"hotel_id", "room_type"})

overbookingResolutions = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "hotel_overbooking_resolutions_total",
Help: "Total overbooking resolutions by action type",
}, []string{"action"})
)

Рекомендуемые алерты: если фактический overbooking превышает 5% от общего числа бронирований за месяц — сигнал к пересмотру коэффициентов.

Вопрос 9. Какова доменная модель системы бронирования? Какие сущности и связи между ними выделены?

Таймкод: 00:30:10

Ответ собеседника: Правильный. Выделены: RoomType (привязка к отелю, базовая ценя, количество номеров, overbooking), Room (привязка к RoomType, статус), Booking (UserID, HotelId, RoomTypeId, даты, цена, статус). RoomType и Room связаны внешним ключом. Количество доступных номеров пересчитывается в транзакции.

Правильный ответ:

Доменная модель — основа всей системы. Кандидат верно выделил ключевые сущности, но модель можно расширить для большей гибкости.

Основные сущности

1. Hotel — отель с общими характеристиками

CREATE TABLE hotels (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
description TEXT,
address TEXT NOT NULL,
city_id INT NOT NULL REFERENCES cities(id),
stars INT CHECK (stars BETWEEN 1 AND 5),
latitude DECIMAL(10, 8),
longitude DECIMAL(11, 8),
check_in_time TIME DEFAULT '14:00',
check_out_time TIME DEFAULT '12:00',
status VARCHAR(20) DEFAULT 'active', -- active, inactive, suspended
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_hotels_city ON hotels (city_id);
CREATE INDEX idx_hotels_location ON hotels USING gist (
ll_to_earth(latitude, longitude)
);

2. RoomType — категория номера (Standard, Deluxe, Suite)

CREATE TABLE room_types (
id BIGSERIAL PRIMARY KEY,
hotel_id BIGINT NOT NULL REFERENCES hotels(id),
name VARCHAR(100) NOT NULL, -- "Стандарт двухместный"
description TEXT,
base_price INT NOT NULL, -- в копейках
max_occupancy INT NOT NULL DEFAULT 2,
size_sqm DECIMAL(5,1),
bed_type VARCHAR(50), -- "king", "twin", "queen"
overbooking_pct DECIMAL(5,2) DEFAULT 0,
max_overbooking INT DEFAULT 0,
amenities JSONB DEFAULT '[]', -- ["wifi", "minibar", "balcony"]
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_room_types_hotel ON room_types (hotel_id);

3. Room — физический номер в отеле

CREATE TABLE rooms (
id BIGSERIAL PRIMARY KEY,
hotel_id BIGINT NOT NULL REFERENCES hotels(id),
room_type_id BIGINT NOT NULL REFERENCES room_types(id),
room_number VARCHAR(20) NOT NULL, -- "305", "A-12"
floor INT,
status VARCHAR(20) DEFAULT 'available', -- available, occupied, cleaning, maintenance, out_of_order
is_smoking BOOLEAN DEFAULT FALSE,
is_accessible BOOLEAN DEFAULT FALSE, -- доступность для маломобильных
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),

UNIQUE (hotel_id, room_number)
);

CREATE INDEX idx_rooms_hotel_type ON rooms (hotel_id, room_type_id);
CREATE INDEX idx_rooms_status ON rooms (status);

4. Booking — бронирование

CREATE TABLE bookings (
id BIGSERIAL PRIMARY KEY,
hotel_id BIGINT NOT NULL REFERENCES hotels(id),
room_type_id BIGINT NOT NULL REFERENCES room_types(id),
room_id BIGINT REFERENCES rooms(id), -- NULL до назначения конкретного номера
user_id BIGINT NOT NULL,
check_in DATE NOT NULL,
check_out DATE NOT NULL,
guests_count INT NOT NULL DEFAULT 1,
total_price INT NOT NULL, -- в копейтах
currency VARCHAR(3) DEFAULT 'RUB',
status VARCHAR(20) DEFAULT 'pending', -- pending, confirmed, checked_in, checked_out, cancelled, no_show
source VARCHAR(30) DEFAULT 'web', -- web, mobile, api, phone
special_requests TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
cancelled_at TIMESTAMPTZ,
cancel_reason VARCHAR(100),

CHECK (check_out > check_in),
CHECK (guests_count > 0)
);

CREATE INDEX idx_bookings_hotel_dates ON bookings (hotel_id, check_in, check_out);
CREATE INDEX idx_bookings_user ON bookings (user_id);
CREATE INDEX idx_bookings_status ON bookings (status);
CREATE INDEX idx_bookings_room ON books (room_id) WHERE room_id IS NOT NULL;

5. PriceCalendar — цены по дням (динамическое ценообразование)

CREATE TABLE price_calendar (
id BIGSERIAL PRIMARY KEY,
room_type_id BIGINT NOT NULL REFERENCES room_types(id),
date DATE NOT NULL,
price INT NOT NULL, -- в копейтах
is_available BOOLEAN DEFAULT TRUE,
min_stay INT DEFAULT 1, -- минимальная длительность
max_stay INT DEFAULT 30,
closed_arrival BOOLEAN DEFAULT FALSE, -- нельзя заехать в этот день
closed_departure BOOLEAN DEFAULT FALSE, -- нельзя выехать в этот день

UNIQUE (room_type_id, date)
);

CREATE INDEX idx_price_calendar_lookup ON price_calendar (room_type_id, date);

6. Payment — платёж

CREATE TABLE payments (
id BIGSERIAL PRIMARY KEY,
booking_id BIGINT NOT NULL REFERENCES bookings(id),
psp_id VARCHAR(100), -- ID платежа в PSP
amount INT NOT NULL,
currency VARCHAR(3) DEFAULT 'RUB',
status VARCHAR(20) DEFAULT 'pending', -- pending, processing, succeeded, failed, refunded
idempotency_key VARCHAR(64) UNIQUE NOT NULL,
paid_at TIMESTAMPTZ,
fail_reason TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_payments_booking ON payments (booking_id);
CREATE INDEX idx_payments_psp ON payments (psp_id);

Связи между сущностями

Hotel 1───* RoomType 1───* Room
│ │
│ │
└──* Booking ──────────────┘

└──* Payment

RoomType 1───* PriceCalendar

Пересчёт доступности

Кандидент упомянул пересчёт в транзакции. Вот как это может выглядеть:

func (s *RoomService) UpdateRoomStatus(ctx context.Context, roomID int64, newStatus string) error {
return s.db.RunTx(ctx, func(tx *sql.Tx) error {
// Обновляем статус комнаты
oldStatus, err := s.roomRepo.UpdateStatus(ctx, tx, roomID, newStatus)
if err != nil {
return err
}

// Если статус влияет на доступность — пересчитываем
wasAvailable := oldStatus == "available"
isAvailable := newStatus == "available"

if wasAvailable != isAvailable {
room, err := s.roomRepo.GetByID(ctx, tx, roomID)
if err != nil {
return err
}

delta := 1
if !isAvailable {
delta = -1
}

// Обновляем счётчик в RoomType
if err := s.roomTypeRepo.UpdateAvailableCount(ctx, tx, room.RoomTypeID, delta); err != nil {
return err
}

// Инвалидируем кэш доступности
s.cache.InvalidateAvailability(ctx, room.RoomTypeID)
}

return nil
})
}

Важное замечание по архитектуре

Хранение available_count в RoomType — это денормализация для быстрого чтения. Истину всегда нужно проверять через подсчёт реальных комнат и бронирований:

-- Проверка реальной доступности
SELECT
rt.id,
rt.name,
COUNT(r.id) FILTER (WHERE r.status = 'available') AS available_rooms,
COUNT(b.id) FILTER (
WHERE b.status IN ('confirmed', 'checked_in')
AND b.check_in < $check_out
AND b.check_out > $check_in
) AS booked_rooms,
COUNT(r.id) FILTER (WHERE r.status = 'available') -
COUNT(b.id) FILTER (
WHERE b.status IN ('confirmed', 'checked_in')
AND b.check_in < $check_out
AND b.check_out > $check_in
) AS actual_available
FROM room_types rt
JOIN rooms r ON r.room_type_id = rt.id
LEFT JOIN bookings b ON b.room_type_id = rt.id
WHERE rt.hotel_id = $hotel_id
GROUP BY rt.id, rt.name;

Это защищает от рассинхронизации счётчиков и используется для фоновой сверки.

Вопрос 10. Как обеспечить синхронизацию данных между сервисом управления отелями и сервисом бронирования при изменении комнат и типов номеров?

Таймкод: 00:35:50

Ответ собеседника: Правильный. Предлагается шина данных (Message Queue). При изменении в Management System данные записываются в БД и отправляется сообщение в шину. На стороне сервиса бронирования джоб (Room Type Sync Job) слушает шину и синхронизирует локальную таблицу Room Types. Сервис бронирования не ходит напрямую в сервис отелей.

Правильный ответ:

Синхронизация между сервисами — классическая проблема распределённых систем. Кандидат верно выбрал асинхронную шину данных, но детали реализации критически важны.

Архитектура синхронизации

Management Service → DB + Outbox → Message Broker → Booking Service → Local DB

Search Service (индексация)

1. Outbox-паттерн на стороне Management Service

Нельзя просто «записать в БД и отправить в очередь» — это приводит к потере сообщений при сбоях. Нужен outbox:

CREATE TABLE room_events_outbox (
id BIGSERIAL PRIMARY KEY,
event_id UUID UNIQUE NOT NULL DEFAULT gen_random_uuid(),
event_type VARCHAR(50) NOT NULL, -- room_type.created, room_type.updated, room.created, room.status_changed
aggregate_id BIGINT NOT NULL, -- ID RoomType или Room
payload JSONB NOT NULL,
status VARCHAR(20) DEFAULT 'pending', -- pending, published, failed
created_at TIMESTAMPTZ DEFAULT NOW(),
published_at TIMESTAMPTZ
);

CREATE INDEX idx_room_events_outbox_pending ON room_events_outbox (status, created_at) WHERE status = 'pending';
// В сервисе управления отелями
func (s *ManagementService) UpdateRoomType(ctx context.Context, req UpdateRoomTypeRequest) error {
return s.db.RunTx(ctx, func(tx *sql.Tx) error {
// 1. Обновляем данные
roomType, err := s.roomTypeRepo.Update(ctx, tx, req)
if err != nil {
return err
}

// 2. Пишем событие в outbox (в той же транзакции!)
event := &RoomEventOutbox{
EventType: "room_type.updated",
AggregateID: roomType.ID,
Payload: mustMarshal(RoomTypeUpdatedEvent{
RoomTypeID: roomType.ID,
HotelID: roomType.HotelID,
Name: roomType.Name,
BasePrice: roomType.BasePrice,
MaxOccupancy: roomType.MaxOccupancy,
Amenities: roomType.Amenities,
OverbookingPct: roomType.OverbookingPct,
UpdatedAt: roomType.UpdatedAt,
}),
}
return s.outboxRepo.Create(ctx, tx, event)
})
}

// Relay — фоновая горутина, которая публикует события из outbox в брокер
func (s *ManagementService) StartOutboxRelay(ctx context.Context) {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()

for {
select {
case <-ctx.Done():
return
case <-ticker.C:
events, err := s.outboxRepo.GetPending(ctx, 100)
if err != nil {
s.log.Error("failed to get pending events", "err", err)
continue
}

for _, event := range events {
err := s.broker.Publish(ctx, "room.events", event.Payload, kafka.MessageKey(
strconv.FormatInt(event.AggregateID, 10),
))
if err != nil {
s.log.Error("failed to publish event", "event_id", event.ID, "err", err)
s.outboxRepo.MarkFailed(ctx, event.ID)
continue
}
s.outboxRepo.MarkPublished(ctx, event.ID)
}
}
}
}

2. Consumer на стороне Booking Service

type RoomEventHandler struct {
roomTypeRepo *RoomTypeRepo
cache *redis.Client
log *slog.Logger
}

func (h *RoomEventHandler) HandleRoomTypeUpdated(ctx context.Context, event RoomTypeUpdatedEvent) error {
return h.roomTypeRepo.Upsert(ctx, LocalRoomType{
ID: event.RoomTypeID,
HotelID: event.HotelID,
Name: event.Name,
BasePrice: event.BasePrice,
MaxOccupancy: event.MaxOccupancy,
Amenities: event.Amenities,
OverbookingPct: event.OverbookingPct,
SyncedAt: time.Now(),
})
}

func (h *RoomEventHandler) HandleRoomCreated(ctx context.Context, event RoomCreatedEvent) error {
// Добавляем комнату и увеличиваем счётчик
return h.db.RunTx(ctx, func(tx *sql.Tx) error {
if err := h.roomRepo.Create(ctx, tx, LocalRoom{
ID: event.RoomID,
HotelID: event.HotelID,
RoomTypeID: event.RoomTypeID,
RoomNumber: event.RoomNumber,
Status: event.Status,
}); err != nil {
return err
}
return h.roomTypeRepo.IncrementTotalRooms(ctx, tx, event.RoomTypeID)
})
}

func (h *RoomEventHandler) HandleRoomStatusChanged(ctx context.Context, event RoomStatusChangedEvent) error {
return h.db.RunTx(ctx, func(tx *sql.Tx) error {
oldStatus, err := h.roomRepo.UpdateStatus(ctx, tx, event.RoomID, event.NewStatus)
if err != nil {
return err
}

// Пересчитываем available_count
wasAvailable := oldStatus == "available"
isAvailable := event.NewStatus == "available"

if wasAvailable != isAvailable {
delta := -1
if isAvailable {
delta = 1
}
if err := h.roomTypeRepo.UpdateAvailableCount(ctx, tx, event.RoomTypeID, delta); err != nil {
return err
}
}

// Инвалидируем кэш доступности
h.cache.Delete(ctx, fmt.Sprintf("availability:%d:*", event.RoomTypeID))

return nil
})
}

3. Выбор брокера

Для шины данных есть несколько вариантов:

  • Kafka — лучший выбор для event sourcing, гарантия порядка сообщений в партиции, возможность ребалансировки consumer group. Подходит, если нужна история событий и возможность переиграть события.
  • RabbitMQ — проще в эксплуатации, хорош для RPC-паттернов и простых очередей. Но нет гарантии строгого порядка при множественных consumer'ов.
  • NATS JetStream — лёгкий, быстрый, но меньше экосистемы.

Для синхронизации данных между сервисами Kafka предпочтительнее: ключ сообщения — room_type_id, что гарантирует порядок обработки событий для одного типа номера.

4. Идемпотентность consumer'а

Consumer должен корректно обрабатывать дубликаты сообщений:

func (h *RoomEventHandler) HandleWithIdempotency(ctx context.Context, eventID string, handler func() error) error {
// Проверяем, было ли событие уже обработанo
exists, err := h.processedRepo.Exists(ctx, eventID)
if err != nil {
return err
}
if exists {
return nil // Уже обработано — пропускаем
}

// Обрабатываем и помечаем как обработанное
if err := handler(); err != nil {
return err
}

return h.processedRepo.MarkProcessed(ctx, eventID)
}

5. Обработка рассинхронизации

Фоновая задача сверки:

func (s *SyncService) Reconcile(ctx context.Context) error {
// Получаем все room_types из локальной БД
localTypes, err := s.localRepo.GetAllRoomTypes(ctx)
if err != nil {
return err
}

for _, local := range localTypes {
// Запрашиваем актуальные данные из Management Service
remote, err := s.managementClient.GetRoomType(ctx, local.ID)
if err != nil {
s.log.Warn("failed to fetch remote room type", "id", local.ID, "err", err)
continue
}

// Сравниваем и при расхождении — исправляем
if !local.Equals(remote) {
s.log.Info("room type drift detected", "id", local.ID)
s.eventHandler.HandleRoomTypeUpdated(ctx, remote.ToEvent())
}
}
return nil
}

6. Порядок событий

Критически важно соблюдать порядок событий для одной сущности. В Kafka это достигается использованием одного и того же ключа партиционирования:

msg := kafka.Message{
Key: []byte(strconv.FormatInt(event.AggregateID, 10)), // room_type_id
Value: event.Payload,
Topic: "room.events",
}

Все события для одного room_type_id попадут в одну партицию и будут обработаны строго последовательно.

Такой подход обеспечивает конечную консистентность (eventual consistency) между сервисами, при этом сервис бронирования полностью автономен и может работать даже при временной недоступности сервиса управления.

Вопрос 11. Как обеспечить транзакционную целостность и конкурентность при бронировании? Какой уровень изоляции транзакций предлагается?

Таймкод: 00:39:23

Ответ собеседника: Правильный. Предлагается реляционная БД (ACID). Рассматривается Serializable, но отмечается риск снижения производительности. Предлагается начать с более простого подхода. Сценарий: создание Booking со статусом pending → оплата через PSP → обновление статуса на paid.

Правильный ответ:

Конкурентное бронирование — одна из самых сложных задач в проектировании системы бронирования. Ключевой вопрос: как не продать один номер дважды.

Уровни изоляции PostgreSQL и их применимость

УровеньDirty ReadNon-Repeatable ReadPhantom ReadSerialization Anomaly
Read UncommittedВозможнаВозможнаВозможнаВозможна
Read Committed (default)НетВозможнаВозможнаВозможна
Repeatable ReadНетНетНет*Возможна
SerializableНетНетНетНет

В PostgreSQL Repeatable Read также защищает от phantom read.

Почему Serializable — плохой выбор

Serializable обеспечивает строгую консистентность, но работает через оптимистичную блокировку: при конфликте транзакция откатывается и должна быть повторена. При 50 RPS на бронирование количество конфликтов и повторов будет огромным, что убьёт производительность.

Оптимальный подход: SELECT FOR UPDATE (пессимистичная блокировка)

func (s *BookingService) CreateBooking(ctx context.Context, req CreateBookingRequest) (*Booking, error) {
var booking *Booking

err := s.db.RunTx(ctx, func(tx *sql.Tx) error {
// 1. Блокируем строку room_type для обновления
// Это предотвращает одновременное бронирование последних доступных номеров
available, err := s.roomTypeRepo.GetAvailableForUpdate(ctx, tx, req.RoomTypeID, req.CheckIn, req.CheckOut)
if err != nil {
return err
}

if available <= 0 {
return ErrNoRoomsAvailable
}

// 2. Находим конкретный свободный номер и блокируем его
room, err := s.roomRepo.FindAvailableAndLock(ctx, tx, req.RoomTypeID, req.CheckIn, req.CheckOut)
if err != nil {
return err
}

if room == nil {
return ErrNoRoomsAvailable
}

// 3. Рассчитываем цену
totalPrice, err := s.pricingService.Calculate(ctx, tx, req.RoomTypeID, req.CheckIn, req.CheckOut)
if err != nil {
return err
}

// 4. Создаём бронирование
booking = &Booking{
HotelID: req.HotelID,
RoomTypeID: req.RoomTypeID,
RoomID: &room.ID,
UserID: req.UserID,
CheckIn: req.CheckIn,
CheckOut: req.CheckOut,
GuestsCount: req.GuestsCount,
TotalPrice: totalPrice,
Currency: "RUB",
Status: BookingStatusPending,
ExpireAt: time.Now().Add(15 * time.Minute), // TTL на оплату
}
if err := s.bookingRepo.Create(ctx, tx, booking); err != nil {
return err
}

// 5. Уменьшаем счётчик доступных номеров
if err := s.roomTypeRepo.DecrementAvailable(ctx, tx, req.RoomTypeID, req.CheckIn, req.CheckOut); err != nil {
return err
}

return nil
})

if err != nil {
return nil, err
}

return booking, nil
}

Запрос с блокировкой

-- Находим свободный номер и блокируем его
SELECT r.id, r.room_number
FROM rooms r
WHERE r.room_type_id = $1
AND r.status = 'available'
AND r.id NOT IN (
SELECT b.room_id
FROM bookings b
WHERE b.room_id IS NOT NULL
AND b.status IN ('confirmed', 'checked_in')
AND b.check_in < $3 -- check_out
AND b.check_out > $2 -- check_in
)
LIMIT 1
FOR UPDATE SKIP LOCKED; -- SKIP LOCKED позволяет другим транзакциям
-- пропустить уже заблокированные строки

FOR UPDATE SKIP LOCKED — ключевая конструкция. Без SKIP LOCKED транзакции будут ждать освобождения блокировки. С SKIP LOCKED — переходят к следующей доступной строке, что критически важно для конкурентности.

Альтернатива: оптимистичная блокировка

Если конфликты редки (что характерно для рынка с 20K отелей и низкой плотностью бронирований на один номер), оптимистичная блокировка может быть эффективнее:

CREATE TABLE room_availability (
room_type_id BIGINT NOT NULL REFERENCES room_types(id),
date DATE NOT NULL,
total_rooms INT NOT NULL,
booked_rooms INT NOT NULL DEFAULT 0,
version INT NOT NULL DEFAULT 1, -- для оптимистичной блокировки

PRIMARY KEY (room_type_id, date)
);
func (s *BookingService) BookWithOptimisticLock(ctx context.Context, req CreateBookingRequest) (*Booking, error) {
maxRetries := 3

for attempt := 0; attempt < maxRetries; attempt++ {
booking, err := s.tryBook(ctx, req)
if err == nil {
return booking, nil
}
if !errors.Is(err, ErrOptimisticLockConflict) {
return nil, err
}
// Конфликт — повторяем
time.Sleep(time.Duration(rand.Intn(50)) * time.Millisecond)
}

return nil, ErrBookingConflict
}

func (s *BookingService) tryBook(ctx context.Context, req CreateBookingRequest) (*Booking, error) {
return s.db.RunTx(ctx, func(tx *sql.Tx) error {
// Читаем текущую доступность с версией
availabilities, err := s.availabilityRepo.GetForDateRange(ctx, tx, req.RoomTypeID, req.CheckIn, req.CheckOut)
if err != nil {
return err
}

// Проверяем, что есть места на все даты
versions := make(map[string]int)
for _, a := range availabilities {
if a.BookedRooms >= a.TotalRooms {
return ErrNoRoomsAvailable
}
versions[fmt.Sprintf("%d:%s", a.RoomTypeID, a.Date)] = a.Version
}

// Увеличиваем booked_rooms с проверкой версии
for _, a := range availabilities {
updated, err := s.availabilityRepo.IncrementBookedIfVersionMatch(ctx, tx, a.RoomTypeID, a.Date, a.Version)
if err != nil {
return err
}
if !updated {
return ErrOptimisticLockConflict
}
}

// Создаём бронирование
booking := &Booking{ /* ... */ }
return s.bookingRepo.Create(ctx, tx, booking)
})
}

TTL на неоплаченные бронирования

Кандидент верно описал паттерн pending → paid. Важно добавить expiration:

// Worker, который откладывает просроченные бронирования
func (s *BookingService) ExpirePendingBookings(ctx context.Context) error {
expired, err := s.bookingRepo.GetExpiredPending(ctx, time.Now())
if err != nil {
return err
}

for _, booking := range expired {
if err := s.cancelBooking(ctx, booking, "payment_timeout"); err != nil {
s.log.Error("failed to expire booking", "booking_id", booking.ID, "err", err)
}
}
return nil
}

func (s *BookingService) cancelBooking(ctx context.Context, booking *Booking, reason string) error {
return s.db.RunTx(ctx, func(tx *sql.Tx) error {
// Меняем статус
if err := s.bookingRepo.UpdateStatus(ctx, tx, booking.ID, BookingStatusCancelled, reason); err != nil {
return err
}

// Возвращаем номер в доступность
return s.availabilityRepo.DecrementBooked(ctx, tx, booking.RoomTypeID, booking.CheckIn, booking.CheckOut)
})
}

Рекомендуемый уровень изоляции

Для бронирования в PostgreSQL оптимален Read Committed + SELECT ... FOR UPDATE:

  • Read Committed — достаточный уровень, когда блокировка обеспечивается явно через FOR UPDATE
  • Не создаёт избыточных накладных расходов Serializable
  • FOR UPDATE гарантирует, что две конкурирующие транзакции не забронируют один номер
  • SKIP LOCKED обеспечивает высокую конкурентность

Serializable стоит рассматривать только если бизнес-логика требует сложных инвариантов, которые нельзя обеспечить явными блокировками.

Вопрос 12. Как реализовать поиск доступных номеров по датам и типам комнат перед бронированием? Как обеспечить корректный подсчёт свободных номеров с учётом коэффициента overbooking?

Таймкод: 00:41:37

Ответ собеседника: Правильный. Используется таблица Room Types, синхронизированная через шину данных. Запрос: TotalCount минус забронированные за период, умноженное на overbooking-коэффициент. При бронировании в одной транзакции создаётся Reservation и обновляется BookedCount, что гарантирует консистентность.

Правильный ответ:

Поиск доступности — самый частый запрос в системе. Он должен быть быстрым и корректным.

Модель данных для подсчёта доступности

-- Агрегированная таблица доступности (денормализованная для быстрого чтения)
CREATE TABLE room_availability (
room_type_id BIGINT NOT NULL REFERENCES room_types(id),
date DATE NOT NULL,
total_rooms INT NOT NULL DEFAULT 0,
booked_rooms INT NOT NULL DEFAULT 0,
overbooking_pct DECIMAL(5,2) NOT NULL DEFAULT 0,

PRIMARY KEY (room_type_id, date)
);

CREATE INDEX idx_availability_date ON room_availability (date);

Запрос доступности по диапазону дат

-- Поиск доступных номеров для room_type на период
-- Результат: минимальное количество свободных номеров за весь период
-- (потому что нужен один номер на весь срок проживания)
WITH params AS (
SELECT
$1::BIGINT AS room_type_id,
$2::DATE AS check_in,
$3::DATE AS check_out
)
SELECT
ra.room_type_id,
MIN(
ra.total_rooms
+ FLOOR(ra.total_rooms * ra.overbooking_p / 100) -- overbooking
- ra.booked_rooms -- уже забронировано
) AS available_rooms
FROM room_availability ra, params p
WHERE ra.room_type_id = p.room_type_id
AND ra.date >= p.check_in
AND ra.date < p.check_out
GROUP BY ra.room_type_id;

Важный нюанс: MIN вместо суммы

Кандидат упомял вычитание booked из total. Ключевой момент — для бронирования на период нужен один номер на все дни проживания. Поэтому берётся MIN по всем дням диапазона, а не сумма. Если хотя бы на один день нет свободных номеров — бронирование на весь период невозможно.

Полный поиск отелей с доступностью

-- Поиск отелей в городе с доступными номерами на даты
WITH params AS (
SELECT
$1::INT AS city_id,
$2::DATE AS check_in,
$3::DATE AS check_out,
$4::INT AS guests_count
),
-- Считаем доступность по каждому room_type
availability AS (
SELECT
rt.id AS room_type_id,
rt.hotel_id,
rt.name,
rt.base_price,
rt.max_occupancy,
MIN(
ra.total_rooms
+ FLOOR(ra.total_rooms * rt.overbooking_pct / 100)
- ra.booked_rooms
) AS available_rooms,
-- Цена за весь период (сумма дневных цен)
SUM(pc.price) AS total_price,
COUNT(DISTINCT ra.date) AS nights_count
FROM room_types rt
JOIN hotels h ON h.id = rt.hotel_id
JOIN room_availability ra ON ra.room_type_id = rt.id
JOIN price_calendar pc ON pc.room_type_id = rt.id AND pc.date = ra.date
CROSS JOIN params p
WHERE h.city_id = p.city_id
AND rt.max_occupancy >= p.guests_count
AND ra.date >= p.check_in
AND ra.date < p.check_out
AND pc.is_available = TRUE
GROUP BY rt.id, rt.hotel_id, rt.name, rt.base_price, rt.max_occupancy
HAVING MIN(
ra.total_rooms
+ FLOOR(ra.total_rooms * rt.overbooking_pct / 100)
- ra.booked_rooms
) > 0
)
SELECT * FROM availability
ORDER BY total_price ASC
LIMIT 50 OFFSET 0;

Обновление доступности при бронировании

func (s *BookingService) CreateBooking(ctx context.Context, req CreateBookingRequest) (*Booking, error) {
var booking *Booking

err := s.db.RunTx(ctx, func(tx *sql.Tx) error {
// 1. Проверяем доступность с блокировкой
available, err := s.availabilityRepo.CheckAndLock(
ctx, tx, req.RoomTypeID, req.CheckIn, req.CheckOut,
)
if err != nil {
return err
}
if available <= 0 {
return ErrNoRoomsAvailable
}

// 2. Увеличиваем booked_rooms на каждый день диапазона
if err := s.availabilityRepo.IncrementBooked(
ctx, tx, req.RoomTypeID, req.CheckIn, req.CheckOut,
); err != nil {
return err
}

// 3. Создаём бронирование
booking = &Booking{
HotelID: req.HotelID,
RoomTypeID: req.RoomTypeID,
UserID: req.UserID,
CheckIn: req.CheckIn,
CheckOut: req.CheckOut,
TotalPrice: req.TotalPrice,
Status: BookingStatusPending,
ExpireAt: time.Now().Add(15 * time.Minute),
}
if err := s.bookingRepo.Create(ctx, tx, booking); err != nil {
return err
}

return nil
})

if err != nil {
return nil, err
}

// Инвалидируем кэш доступности
s.cache.InvalidatePattern(ctx, fmt.Sprintf("availability:%d:*", req.RoomTypeID))

return booking, nil
}
-- Проверка и блокировка доступности
CREATE OR REPLACE FUNCTION check_availability(
p_room_type_id BIGINT,
p_check_in DATE,
p_check_out DATE
) RETURNS INT AS $$
DECLARE
v_available INT;
BEGIN
SELECT MIN(
total_rooms
+ FLOOR(total_rooms * overbooking_pct / 100)
- booked_rooms
) INTO v_available
FROM room_availability
WHERE room_type_id = p_room_type_id
AND date >= p_check_in
AND date < p_check_out
FOR UPDATE; -- Блокируем строки

RETURN GREATEST(v_available, 0);
END;
$$ LANGUAGE plpgsql;

-- Увеличение booked_rooms
UPDATE room_availability
SET booked_rooms = booked_rooms + 1
WHERE room_type_id = $1
AND date >= $2
AND date < $3;

Кэширование доступности

Поскольку поиск — частая операция, кэширование критично:

func (s *SearchService) GetAvailability(ctx context.Context, hotelID int64, checkIn, checkOut time.Time) ([]RoomTypeAvailability, error) {
cacheKey := fmt.Sprintf("availability:hotel:%d:%s:%s", hotelID, checkIn.Format("2006-01-02"), checkOut.Format("2006-01-02"))

// Пробуем кэш
var result []RoomTypeAvailability
if err := s.cache.GetJSON(ctx, cacheKey, &result); err == nil {
return result, nil
}

// Кэш пуст — идём в БД
result, err := s.availabilityRepo.GetForHotel(ctx, hotelID, checkIn, checkOut)
if err != nil {
return nil, err
}

// Кэшируем на 30 секунд (короткий TTL для актуальности)
s.cache.SetJSON(ctx, cacheKey, result, 30*time.Second)

return result, nil
}

Инвалидация кэша

Кэш доступности должен инвалидироваться:

  • При создании бронирования (через событие из outbox)
  • При отмене бронирования
  • При изменении статуса комнаты (maintenance → available)
  • Фоново — каждые 30 секунд (компромисс между актуальностью и нагрузкой)

Защита от рассинхронизации

Денормализованная таблица room_availability может рассинхронизироваться. Фоновая задача сверки:

func (s *ReconcileService) ReconcileAvailability(ctx context.Context, roomTypeID int64, date time.Time) error {
// Считаем реальное количество бронирований
var actualBooked int
err := s.db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM bookings
WHERE room_type_id = $1
AND check_in <= $2
AND check_out > $2
AND status IN ('confirmed', 'checked_in')
`, roomTypeID, date).Scan(&actualBooked)
if err != nil {
return err
}

// Обновляем, если есть расхождение
_, err = s.db.ExecContext(ctx, `
UPDATE room_availability
SET booked_rooms = $1
WHERE room_type_id = $2 AND date = $3 AND booked_rooms != $1
`, actualBooked, roomTypeID, date)

return err
}

Эта задача запускается раз в минуту для всех активных комбинаций room_type + date.

Вопрос 13. Какой уровень изоляции транзакций предлагается для обеспечения целостности данных при бронировании? Как обрабатываются конфликты параллельных транзакций?

Таймкод: 00:46:47

Ответ собеседника: Правильный. Предлагается оптимистичная блокировка (snapshot при старте, ошибка при конфликте, retry). Отмечается, что при высокой конкурентности оптимистичные блокировки неэффективны и лучше работают пессимистичные. Упомянут опыт с MySQL, где пришлось реализовать пессимистичные блокировки на уровне приложения.

Правильный ответ:

Кандидат верно описал оба подхода и их компромиссы. Разберём детально.

Оптимистичная vs пессимистичная блокировка: когда что выбирать

Оптимистичная блокировка эффективна, когда конфликты редки. В контексте бронирования отелей: при 20K отелей и 1M номеров вероятность того, что два пользователя одновременно бронируют один и тот же тип номера в одном отеле на одни даты — относительно низкая. Но для популярных отелей в пиковые даты (Новый год, майские праздники) конфликты могут быть частыми.

Пессимистичная блокировка эффективна, когда конфликты часты — блокирует запись заранее, не давая другим транзакциям начать бронирование того же ресурса.

Рекомендуемая стратегия: гибридная

type BookingStrategy int

const (
StrategyOptimistic BookingStrategy = iota
StrategyPessimistic
)

func (s *BookingService) CreateBooking(ctx context.Context, req CreateBookingRequest) (*Booking, error) {
// Выбираем стратегию на основе загрузки
strategy := s.selectStrategy(ctx, req.RoomTypeID, req.CheckIn)

switch strategy {
case StrategyPessimistic:
return s.createBookingPessimistic(ctx, req)
default:
return s.createBookingOptimistic(ctx, req)
}
}

func (s *BookingService) selectStrategy(ctx context.Context, roomTypeID int64, date time.Time) BookingStrategy {
// Если заполняемость > 80% — используем пессимистичную блокировку
occupancy, _ := s.occupancyRepo.GetOccupancyRate(ctx, roomTypeID, date)
if occupancy > 0.8 {
return StrategyPessimistic
}
return StrategyOptimistic
}

Реализация оптимистичной блокировки

func (s *BookingService) createBookingOptimistic(ctx context.Context, req CreateBookingRequest) (*Booking, error) {
const maxRetries = 3

for attempt := 0; attempt < maxRetries; attempt++ {
booking, err := s.tryBookOptimistic(ctx, req)
if err == nil {
return booking, nil
}
if !errors.Is(err, ErrSerializationFailure) {
return nil, err
}

// Экспоненциальная задержка с jitter
delay := time.Duration(math.Pow(2, float64(attempt))) * 10 * time.Millisecond
jitter := time.Duration(rand.Int63n(int64(delay / 2)))
time.Sleep(delay + jitter)
}

// После исчерпания попыток — переключаемся на пессимистичную
return s.createBookingPessimistic(ctx, req)
}

func (s *BookingService) tryBookOptimistic(ctx context.Context, req CreateBookingRequest) (*Booking, error) {
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelSerializable,
})
if err != nil {
return nil, err
}
defer tx.Rollback()

// Проверяем доступность (читаем без блокировки)
var available int
err = tx.QueryRowContext(ctx, `
SELECT MIN(total_rooms + FLOOR(total_rooms * overbooking_pct / 100) - booked_rooms)
FROM room_availability
WHERE room_type_id = $1 AND date >= $2 AND date < $3
`, req.RoomTypeID, req.CheckIn, req.CheckOut).Scan(&available)
if err != nil {
return nil, err
}
if available <= 0 {
return nil, ErrNoRoomsAvailable
}

// Увеличиваем booked_rooms
_, err = tx.ExecContext(ctx, `
UPDATE room_availability
SET booked_rooms = booked_rooms + 1, version = version + 1
WHERE room_type_id = $1 AND date >= $2 AND date < $3
`, req.RoomTypeID, req.CheckIn, req.CheckOut)
if err != nil {
return nil, err
}

// Создаём бронирование
booking := &Booking{ /* ... */ }
if err := s.bookingRepo.Create(ctx, tx, booking); err != nil {
return nil, err
}

// Коммит — здесь PostgreSQL проверит конфликты сериализации
if err := tx.Commit(); err != nil {
if isSerializationFailure(err) {
return nil, ErrSerializationFailure
}
return nil, err
}

return booking, nil
}

func isSerializationFailure(err error) bool {
var pgErr *pq.Error
if errors.As(err, &pgErr) {
// 40001 = serialization_failure
return pgErr.Code == "40001"
}
return false
}

Реализация пессимистичной блокировки

func (s *BookingService) createBookingPessimistic(ctx context.Context, req CreateBookingRequest) (*Booking, error) {
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{
Isolation: sql.LevelReadCommitted, // Достаточно при явных блокировках
})
if err != nil {
return nil, err
}
defer tx.Rollback()

// Блокируем строки доступности
var available int
err = tx.QueryRowContext(ctx, `
SELECT MIN(total_rooms + FLOOR(total_rooms * overbooking_pct / 100) - booked_rooms)
FROM room_availability
WHERE room_type_id = $1 AND date >= $2 AND date < $3
FOR UPDATE -- Пессимистичная блокировка
`, req.RoomTypeID, req.CheckIn, req.CheckOut).Scan(&available)
if err != nil {
return nil, err
}
if available <= 0 {
return nil, ErrNoRoomsAvailable
}

// Увеличиваем booked_rooms
_, err = tx.ExecContext(ctx, `
UPDATE room_availability
SET booked_rooms = booked_rooms + 1
WHERE room_type_id = $1 AND date >= $2 AND date < $3
`, req.RoomTypeID, req.CheckIn, req.CheckOut)
if err != nil {
return nil, err
}

// Создаём бронирование
booking := &Booking{ /* ... */ }
if err := s.bookingRepo.Create(ctx, tx, booking); err != nil {
return nil, err
}

if err := tx.Commit(); err != nil {
return nil, err
}

return booking, nil
}

Сравнение подходов для бронирования отелей

КритерийОптимистичнаяПессимистичная
Производительность при низкой конкурентностиВысокаяСредняя
Производительность при высокой конкурентностиНизкая (много retry)Средняя (очередь на блокировках)
Сложность реализацииСредняяНизкая
Риск взаимоблокировокНетДа (нужен порядок блокировок)
Подходит дляРазреженные конфликтыЧастые конфликты на одни ресурсы

Рекомендация

Для отельного бронирования оптимальный подход:

  • По умолчанию: пессимистичная блокировка (FOR UPDATE) на уровне ReadCommitted
  • Обоснование: бронирование — операция записи с обязательной проверкой перед записью. Пессимистичная блокировка даёт предсказуемое поведение без retry-логики
  • FOR UPDATE SKIP LOCKED позволяет параллельно бронировать разные типы номеров без взаимных блокировок
  • Оптимистичную блокировку стоит использовать для операций, где конфликты действительно редки (например, обновление профиля пользователя)

Вопрос 14. Какие распределённые базы данных рассматриваются для обеспечения транзакционной целостности при масштабировании? Какой подход к шардингу предлагается?

Таймкод: 00:49:54

Ответ собеседника: Правильный. Упоминаются Spanner DB и Yandex Database как распределённые системы с Serializable транзакциями. Предлагается шардинг по Hotel ID через consistent hashing. Шардинг по временным промежуткам не подходит из-за неравномерности нагрузки. Упоминается конфигурация для быстрого получения ключа шарда.

Правильный ответ:

Распределённые БД с ACID

Кандидат верно упомянул Spanner и Yandex Database. Разберём варианты:

  • Google Cloud Spanner — глобально распределённая БД с внешней консистентностью (TrueTime API). Поддерживает Serializable транзакции между шардами. Минус: дорогая, vendor lock-in.
  • Yandex Database (YDB) — аналог Spanner от Yandex, поддерживает ACID транзакции, горизонтальное масштабирование. Хороший выбор для российского рынка.
  • CockroachDB — open-source аналог Spanner, совместимый с PostgreSQL wire protocol. Поддерживает Serializable транзакции между узлами.
  • TiDB — горизонтально масштабируемая БД, совместимая с MySQL. Поддерживает ACID через Percolator-модель транзакций.
  • Vitess — слой шардинга поверх MySQL. Не поддерживает кросс-шард транзакции, но обеспечивает шардинг прозрачно.

Для текущего масштаба (Россия, 20K отелей) эти системы избыточны. PostgreSQL с партиционированием покрывает потребности. Но если планируется международный масштаб — стоит рассмотреть CockroachDB или YDB.

Стратегия шардинга

Кандидат верно предложил шардинг по Hotel ID — это оптимальный выбор для отельной системы.

Почему Hotel ID — хороший ключ шардинга

  • Большинство запросов привязаны к конкретному отелю (поиск номеров, бронирование, управление)
  • Транзакции бронирования затрагивают один отель — кросс-шард транзакции не нужны
  • Данные распределяются относительно равномерно (нет хотспота, если использовать хеширование)
  • Простая маршрутизация: shard = hash(hotel_id) % num_shards

Почему шардинг по дате — плохой выбор

  • Неравномерная нагрузка: бронирования на ближайшие даты гораздо чаще, чем на дальние
  • Сезонность: лето и праздники создают хотспоты на определённые диапазоны дат
  • Кросс-шард запросы: бронирование на 7 дней может затронуть 2 шарда (конец и начало месяца)
  • Сложность обслуживания: добавление нового шарда для нового года

Реализация шардинга по Hotel ID

type ShardRouter struct {
shards []*sql.DB
config *ShardConfig
}

func NewShardRouter(config *ShardConfig) *ShardRouter {
shards := make([]*sql.DB, config.NumShards)
for i := 0; i < config.NumShards; i++ {
shards[i] = connectToShard(config.ShardDSNs[i])
}
return &ShardRouter{shards: shards, config: config}
}

func (r *ShardRouter) GetShard(hotelID int64) *sql.DB {
// Consistent hashing для минимизации перемещений при добавлении шардов
shardIndex := int(hotelID % int64(len(r.shards)))
return r.shards[shardIndex]
}

func (r *ShardRouter) GetShardByKey(shardKey string) *sql.DB {
// Для запросов без hotel_id (например, поиск по пользователю)
// используем хеш от ключа
h := fnv.New32a()
h.Write([]byte(shardKey))
shardIndex := int(h.Sum32()) % len(r.shards)
return r.shards[shardIndex]
}

// Пример использования в сервисе
func (s *BookingService) CreateBooking(ctx context.Context, req CreateBookingRequest) (*Booking, error) {
db := s.shardRouter.GetShard(req.HotelID)

tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return nil, err
}
defer tx.Rollback()

// ... логика бронирования ...

return booking, nil
}

Конфигурация шардов

sharding:
strategy: "hotel_id_hash"
num_shards: 4
shards:
- dsn: "postgres://shard1:5432/booking"
hotel_ids: "mod 4 == 0"
- dsn: "postgres://shard2:5432/booking"
hotel_ids: "mod 4 == 1"
- dsn: "postgres://shard3:5432/booking"
hotel_ids: "mod 4 == 2"
- dsn: "postgres://shard4:5432/booking"
hotel_ids: "mod 4 == 3"

Consistent Hashing для масштабирования

При добавлении нового шарда простой hash % N требует перемещения большинства данных. Consistent hashing минимизирует перемещения:

import "github.com/serialx/hashring"

type ConsistentShardRouter struct {
ring *hashring.HashRing
shards map[string]*sql.DB
}

func NewConsistentShardRouter(shardNames []string, shardDBs map[string]*sql.DB) *ConsistentShardRouter {
ring := hashring.New(shardNames)
return &ConsistentShardRouter{ring: ring, shards: shardDBs}
}

func (r *ConsistentShardRouter) GetShard(hotelID int64) *sql.DB {
shardName, _ := r.ring.GetNode(strconv.FormatInt(hotelID, 10))
return r.shards[shardName]
}

Кросс-шард запросы

Некоторые запросы не содержат hotel_id (например, «мои бронирования» по user_id). Варианты решения:

  1. Денормализация: хранить копию бронирований в отдельной таблице, шардированной по user_id
  2. Scatter-gather: выполнить запрос на всех шардах и агрегировать результаты
  3. Materialized view: отдельная база для аналитических запросов (Elasticsearch, ClickHouse)
// Scatter-gather для поиска бронирований пользователя
func (s *BookingService) GetUserBookings(ctx context.Context, userID int64) ([]Booking, error) {
var allBookings []Booking
var mu sync.Mutex
var wg sync.WaitGroup
errChan := make(chan error, len(s.shardRouter.AllShards()))

for _, shard := range s.shardRouter.AllShards() {
wg.Add(1)
go func(db *sql.DB) {
defer wg.Done()

bookings, err := s.bookingRepo.GetByUserID(ctx, db, userID)
if err != nil {
errChan <- err
return
}

mu.Lock()
allBookings = append(allBookings, bookings...)
mu.Unlock()
}(shard)
}

wg.Wait()
close(errChan)

if len(errChan) > 0 {
return nil, <-errChan
}

// Сортируем по дате создания
sort.Slice(allBookings, func(i, j int) bool {
return allBookings[i].CreatedAt.After(allBookings[j].CreatedAt)
})

return allBookings, nil
}

Когда начинать шардинг

Для текущего масштаба (20K отелей, 1M номеров, 200K бронирований/день):

  • Сейчас: один PostgreSQL + read replica + партиционирование по дате
  • ~100K отелей: 4 шарда по hotel_id
  • ~1M отелей: 16-32 шарда + consistent hashing

Шардинг — это усложнение архитектуры. Начинать стоит только когда один инстанс PostgreSQL не справляется с нагрузкой или когда требуется гео-распределение данных.

Вопрос 15. Как обеспечить идемпотентность при бронировании и избежать двойного списания/двойной покупки в случае повторных запросов от клиента?

Таймкод: 00:55:27

Ответ собеседника: Правильный. Предлагается генерировать на клиенте UUID v4 при каждой попытке бронирования. Сервер проверяет, существует ли бронь с таким ID. Если да — возвращает существующий результат, а не создаёт новую. Это обеспечивает идемпотентность и защищает от двойного бронирования.

Правильный ответ:

Идемпотентность — критическое требование для любых финансовых операций. Кандидат верно описал базовый подход, но есть нюансы.

Генерация ключа идемпотентности

UUID v4 — рабочий вариант, но лучше использовать детерминистический ключ, основанный на бизнес-параметрах. Это позволяет клиенту повторно отправить тот же ключ при retry и серверу понять, что это та же самая попытка бронирования.

// Детерминистический ключ идемпотентности
func GenerateIdempotencyKey(userID, roomTypeID int64, checkIn, checkOut time.Time, nonce string) string {
// nonce — случайная строка, генерируемая клиентом при начале процесса бронирования
// позволяет отличить две попытки бронирования одного и того же номера тем же пользователем
h := sha256.New()
fmt.Fprintf(h, "%d:%d:%s:%s:%s", userID, roomTypeID, checkIn.Format("2006-01-02"), checkOut.Format("2006-01-02"), nonce)
return hex.EncodeToString(h.Sum(nil))
}

// Или проще — клиент генерирует UUID и передаёт его в заголовке
// X-Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000

Реализация идемпотентного бронирования

type BookingService struct {
db *sql.DB
idempotency *IdempotencyStore
}

func (s *BookingService) CreateBooking(ctx context.Context, req CreateBookingRequest) (*BookingResponse, error) {
// 1. Проверяем идемпотентность
existing, err := s.idempotency.GetResponse(ctx, req.IdempotencyKey)
if err == nil {
// Запрос с таким ключом уже обработан — возвращаем сохранённый результат
return existing, nil
}
if !errors.Is(err, ErrIdempotencyKeyNotFound) {
return nil, err
}

// 2. Проверяем, не обрабатывается ли запрос прямо сейчас
locked, err := s.idempotency.AcquireLock(ctx, req.IdempotencyKey, 30*time.Second)
if err != nil {
return nil, err
}
if !locked {
// Другой запрос с таким ключом уже обрабатывается
return nil, ErrDuplicateRequest
}
defer s.idempotency.ReleaseLock(ctx, req.IdempotencyKey)

// 3. Выполняем бронирование
booking, err := s.doBooking(ctx, req)

// 4. Сохраняем результат (успех или ошибка) для повторных запросов
response := &BookingResponse{
BookingID: booking.ID,
Status: booking.Status,
Error: errToString(err),
}
if saveErr := s.idempotency.SaveResponse(ctx, req.IdempotencyKey, response, 24*time.Hour); saveErr != nil {
s.log.Error("failed to save idempotency response", "key", req.IdempotencyKey, "err", saveErr)
}

if err != nil {
return nil, err
}

return response, nil
}

Хранилище идемпотентности

CREATE TABLE idempotency_keys (
key VARCHAR(64) PRIMARY KEY,
status VARCHAR(20) NOT NULL, -- 'processing', 'completed'
response JSONB, -- сериализованный ответ
booking_id BIGINT REFERENCES bookings(id),
created_at TIMESTAMPTZ DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL
);

CREATE INDEX idx_idempotency_expires ON idempotency_keys (expires_at);
// Реализация на Redis для скорости
type RedisIdempotencyStore struct {
client *redis.Client
}

func (s *RedisIdempotencyStore) AcquireLock(ctx context.Context, key string, ttl time.Duration) (bool, error) {
return s.client.SetNX(ctx, "idempotency:lock:"+key, "1", ttl).Result()
}

func (s *RedisIdempotencyStore) GetResponse(ctx context.Context, key string) (*BookingResponse, error) {
data, err := s.client.Get(ctx, "idempotency:response:"+key).Bytes()
if errors.Is(err, redis.Nil) {
return nil, ErrIdempotencyKeyNotFound
}
if err != nil {
return nil, err
}

var resp BookingResponse
if err := json.Unmarshal(data, &resp); err != nil {
return nil, err
}
return &resp, nil
}

func (s *RedisIdempotencyStore) SaveResponse(ctx context.Context, key string, resp *BookingResponse, ttl time.Duration) error {
data, err := json.Marshal(resp)
if err != nil {
return err
}
return s.client.Set(ctx, "idempotency:response:"+key, data, ttl).Err()
}

Идемпотентность на уровне базы данных

Ключевой момент: идемпотентность должна быть обеспечена не только на уровне приложения, но и на уровне БД:

-- Уникальный индекс на ключ идемпотентности в таблице бронирований
ALTER TABLE bookings ADD COLUMN idempotency_key VARCHAR(64) UNIQUE;

-- При вставке дубликат ключа вызовет ошибку, а не создаст второй booking
INSERT INTO bookings (hotel_id, room_type_id, user_id, check_in, check_out, total_price, status, idempotency_key)
VALUES ($1, $2, $3, $4, $5, $6, 'pending', $7)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id;
func (s *BookingService) doBooking(ctx context.Context, req CreateBookingRequest) (*Booking, error) {
tx, err := s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return nil, err
}
defer tx.Rollback()

// ... проверка доступности, блокировка ...

// Вставляем с ON CONFLICT — если idempotency_key уже есть, вернётся 0 строк
var bookingID int64
err = tx.QueryRowContext(ctx, `
INSERT INTO bookings (hotel_id, room_type_id, user_id, check_in, check_out, total_price, status, idempotency_key)
VALUES ($1, $2, $3, $4, $5, $6, 'pending', $7)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id
`, req.HotelID, req.RoomTypeID, req.UserID, req.CheckIn, req.CheckOut, req.TotalPrice, req.IdempotencyKey).Scan(&bookingID)

if errors.Is(err, sql.ErrNoRows) {
// Конфликт — бронирование с таким ключом уже существует
// Получаем существующее бронирование
var booking Booking
err = tx.QueryRowContext(ctx, `
SELECT id, status, total_price FROM bookings WHERE idempotency_key = $1
`, req.IdempotencyKey).Scan(&booking.ID, &booking.Status, &booking.TotalPrice)
if err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, err
}
return &booking, nil
}
if err != nil {
return nil, err
}

// ... обновление доступности ...

if err := tx.Commit(); err != nil {
return nil, err
}

return &Booking{ID: bookingID, Status: BookingStatusPending}, nil
}

Идемпотентность платежей

Платёж — самая критичная операция. Здесь идемпотентность должна быть на трёх уровнях:

  1. Клиент: отправляет Idempotency-Key заголовок
  2. Сервис: проверяет ключ в Redis/БД
  3. PSP: передаёт ключ в платёжную систему
func (s *PaymentService) CreatePayment(ctx context.Context, req PaymentRequest) (*PaymentResponse, error) {
// Проверяем идемпотентность
existing, err := s.idempotency.GetPaymentResponse(ctx, req.IdempotencyKey)
if err == nil {
return existing, nil
}

// Вызываем PSP с тем же ключом идемпотентности
pspResp, err := s.pspClient.CreatePayment(ctx, psp.PaymentParams{
Amount: req.Amount,
Currency: req.Currency,
IdempotencyKey: req.IdempotencyKey, // Передаём в PSP!
Description: fmt.Sprintf("Booking #%d", req.BookingID),
})

// Сохраняем результат
resp := &PaymentResponse{PaymentID: pspResp.ID, Status: pspResp.Status}
s.idempotency.SavePaymentResponse(ctx, req.IdempotencyKey, resp, 7*24*time.Hour)

return resp, nil
}

Важные нюансы

  1. TTL на ключи идемпотентности: 24 часа для бронирования, 7 дней для платежей (PSP обычно хранит ключи 30 дней)
  2. Ключ должен быть уникальным для попытки, а не для операции: если пользователь хочет забронировать тот же номер повторно — это новая попытка с новым ключом. Для этого используется nonce.
  3. Обработка ошибок: если первая попытка завершилась ошибкой, повторный запрос с тем же ключом должен вернуть ту же ошибку (или повторить операцию — зависит от бизнес-логики)
  4. Race condition: между проверкой ключа и вставкой может прийти параллельный запрос. Защита — ON CONFLICT на уровне БД + distributed lock на ключ.

Вопрос 16. Как именно обеспечить идемпотентность повторных запросов при нестабильном интернете? Если после разрыва сети пользователь повторно нажимает кнопку, будет ли генерироваться новый UUID на фронтенде и как это поможет?

Таймкод: 01:17:37

Ответ собеседника: Правильный. Объяснено на примере мобильного банка: при нажатии кнопки формируется объект запроса, сохраняемый в state клиента. Если ответ не получен — state не сбрасывается. При повторном нажатии отправляется тот же запрос с тем же идентификатором, а не новый. Сервер распознаёт дубль. Дополнительно — guards на фронтенде для предотвращения повторной отправки.

Правильный ответ:

Кандидат верно описал ключевой принцип: идентификатор идемпотентности должен быть привязан к бизнес-операции, а не к HTTP-запросу. Разберём детально.

Проблема: новый UUID при каждом нажатии

Если фронтенд генерирует новый UUID при каждом нажатии кнопки, идемпотентность не работает:

Нажатие 1: UUID = "aaa-111" → запрос ушёл, ответ не получен
Нажатие 2: UUID = "bbb-222" → НОВЫЙ запрос → НОВОЕ бронирование → ДВОЙНОЕ СПИСАНИЕ

Решение: идентификатор привязан к операции

Идентификатор должен генерироваться один раз при начале процесса бронирования и сохраняться до получения результата.

Реализация на фронтенде (TypeScript/React)

interface BookingRequest {
idempotencyKey: string;
hotelId: number;
roomTypeId: number;
checkIn: string;
checkOut: string;
guestsCount: number;
}

class BookingService {
private currentRequest: BookingRequest | null = null;
private isProcessing: boolean = false;

// Генерируем ключ ОДИН раз при начале процесса бронирования
startBooking(params: Omit<BookingRequest, 'idempotencyKey'>): BookingRequest {
if (this.currentRequest) {
// Если запрос уже в обработке — возвращаем тот же
return this.currentRequest;
}

const request: BookingRequest = {
...params,
idempotencyKey: crypto.randomUUID(), // Генерируем ОДИН раз
};

this.currentRequest = request;
return request;
}

async submitBooking(request: BookingRequest): Promise<BookingResponse> {
if (this.isProcessing) {
throw new Error('Booking already in progress');
}

this.isProcessing = true;

try {
const response = await fetch('/api/bookings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Idempotency-Key': request.idempotencyKey,
},
body: JSON.stringify(request),
// Важно: не используем AbortController с коротким таймаутом
// Лучше дать серверу время обработать
});

const result = await response.json();

// Очищаем состояние только после успешного ответа
this.currentRequest = null;
this.isProcessing = false;

return result;
} catch (error) {
// Ошибка сети — НЕ сбрасываем currentRequest
// При повторной попытке будет использован тот же idempotencyKey
this.isProcessing = false;
throw error;
}
}

// Повторная попытка — использует тот же ключ
async retryBooking(): Promise<BookingResponse> {
if (!this.currentRequest) {
throw new Error('No booking to retry');
}
return this.submitBooking(this.currentRequest);
}
}

// Использование в компоненте
function BookingButton() {
const bookingService = useMemo(() => new BookingService(), []);
const [status, setStatus] = useState<'idle' | 'pending' | 'success' | 'error'>('idle');

const handleBook = async () => {
try {
setStatus('pending');

// Начинаем бронирование — генерируем ключ
const request = bookingService.startBooking({
hotelId: 123,
roomTypeId: 456,
checkIn: '2024-07-01',
checkOut: '2024-07-05',
guestsCount: 2,
});

// Отправляем
const response = await bookingService.submitBooking(request);
setStatus('success');
} catch (error) {
// Ошибка сети — можно показать кнопку "Повторить"
setStatus('error');
}
};

const handleRetry = async () => {
try {
setStatus('pending');
// Повторная попытка — тот же idempotencyKey
const response = await bookingService.retryBooking();
setStatus('success');
} catch (error) {
setStatus('error');
}
};

return (
<div>
{status === 'idle' && <button onClick={handleBook}>Забронировать</button>}
{status === 'pending' && <button disabled>Бронирование...</button>}
{status === 'error' && <button onClick={handleRetry}>Повторить</button>}
{status === 'success' && <span>Забронировано!</span>}
</div>
);
}

Сторожевые механизмы (Guards)

// Guard для предотвращения повторной отправки
function useBookingGuard() {
const [isSubmitting, setIsSubmitting] = useState(false);
const idempotencyKeyRef = useRef<string | null>(null);

const submitBooking = async (params: BookingParams) => {
// Guard 1: не отправляем, если уже в процессе
if (isSubmitting) {
console.log('Booking already in progress, ignoring duplicate click');
return;
}

// Guard 2: используем тот же ключ, если он уже есть
if (!idempotencyKeyRef.current) {
idempotencyKeyRef.current = crypto.randomUUID();
}

setIsSubmitting(true);

try {
const response = await api.bookings.create({
...params,
idempotencyKey: idempotencyKeyRef.current,
});
return response;
} catch (error) {
// Не сбрасываем idempotencyKeyRef — для retry
throw error;
} finally {
setIsSubmitting(false);
}
};

return { submitBooking, isSubmitting };
}

Обработка на сервере: полный цикл

func (s *BookingService) CreateBooking(ctx context.Context, req CreateBookingRequest) (*BookingResponse, error) {
// 1. Быстрая проверка в Redis
cached, err := s.redis.Get(ctx, "idempotency:"+req.IdempotencyKey).Result()
if err == nil {
var resp BookingResponse
if json.Unmarshal([]byte(cached), &resp) == nil {
return &resp, nil // Уже обработано
}
}

// 2. Проверяем, не обрабатывается ли прямо сейчас
locked, err := s.redis.SetNX(ctx, "idempotency:lock:"+req.IdempotencyKey, "1", 30*time.Second).Result()
if err != nil || !locked {
return nil, ErrDuplicateRequest // Другой запрос с таким ключом в обработке
}
defer s.redis.Del(ctx, "idempotency:lock:"+req.IdempotencyKey)

// 3. Проверяем в БД (на случай падения Redis)
existing, err := s.bookingRepo.GetByIdempotencyKey(ctx, req.IdempotencyKey)
if err == nil {
resp := existing.ToResponse()
s.cacheIdempotencyResponse(ctx, req.IdempotencyKey, resp)
return resp, nil
}

// 4. Выполняем бронирование
booking, err := s.doBooking(ctx, req)

// 5. Сохраняем результат
var resp *BookingResponse
if err != nil {
resp = &BookingResponse{Error: err.Error()}
} else {
resp = booking.ToResponse()
}
s.cacheIdempotencyResponse(ctx, req.IdempotencyKey, resp)

if err != nil {
return nil, err
}
return resp, nil
}

Сценарии и поведение

СценарийПоведение
Первый запрос, успехСоздаётся бронирование, результат кэшируется
Первый запрос, ошибка сетиКлиент повторяет с тем же ключом → сервер создаёт бронирование
Два параллельных запроса с одним ключомПервый создаёт бронирование, второй получает DuplicateRequest или ждёт
Повторный запрос после успехаСервер возвращает кэшированный результат
Запрос с новым ключом, те же параметрыСоздаётся новое бронирование (это другая попытка)

Ключевой вывод

Идемпотентность — это не свойство сервера или клиента по отдельности. Это контракт между ними: клиент обязуется повторять тот же ключ для одной операции, сервер обязуется обрабатывать повтор ключа как дубль. Без этого контракта ни клиентский guard, ни серверная проверка не спасут от двойного бронирования.

Вопрос 17. Почему на интервью не использовались поведенческие диаграммы (sequence diagrams) для иллюстрации реального flow взаимодействия компонентов, а сразу обсуждалась структура БД и шардирование?

Таймкод: 01:20:31

Ответ собеседника: Правильный. Sequence diagrams не рисовались из-за нехватки времени. Использовалось текстовое описание взаимодействий (стрелочки на C4-диаграммах). На таких интервью sequence diagrams обычно не рисуют, но при углублённом проектировании сложных сценариев они могут быть полезны.

Правильный ответ:

Кандидат верно отметил прагматичный подход. Разберём, почему так происходит и когда sequence diagrams действительно необходимы.

Типы диаграмм и их назначение

На архитектурных интервью обычно используют несколько уровней:

C4 Model — стандарт для архитектурных интервью

C4 предлагает 4 уровня детализации:

  1. Context Diagram — система как чёрный ящик, внешние пользователи и системы. Показывает, кто с кем взаимодействует.
  2. Container Diagram — основные сервисы, базы данных, очереди. Показывает технологический стек.
  3. Component Diagram — компоненты внутри каждого контейнера. Показывает внутреннюю структуру сервисов.
  4. Code Diagram — классы, интерфейсы. Редко используется на интервью.

На интервью обычно доходят до 2-3 уровня. Sequence diagram — это уже 4-й уровень, который детализирует конкретный сценарий.

Почему sequence diagrams редко рисуют на интервью

  • Ограничение времени: типовое интервью — 60-90 минут. За это время нужно обсудить архитектуру, выбор технологий, масштабирование, отказоустойчивость. Sequence diagram на каждый сценарий съел бы всё время.
  • Текстовое описание достаточно: «Клиент → API Gateway → Booking Service → DB → событие в Kafka → Payment Service» — это уже sequence diagram в текстовом виде.
  • Фокус на решениях: интервьюер хочет услышать, КАК кандидат принимает решения, а не видеть, как он рисует стрелочки.

Когда sequence diagrams критически важны

Есть сценарии, где текстового описания недостаточно и sequence diagram необходим:

1. Сценарий бронирования с оплатой

User Frontend API GW Booking Svc DB Payment Svc PSP Kafka
| | | | | | | |
|--Забронировать-->| | | | | |
| |---------->| | | | | |
| | |--------->| | | | |
| | | |--Check availability---->| | |
| | | |<-Available (lock row)--->| | |
| | | |--INSERT booking (pending)->| | |
| | | |--Commit tx-------->| | |
| | |<---------| | | | |
| |<---------|--201+key | | | | |
|<-OK-----| | | | | | |
| | | | |--Create payment-->| |
| | | | | |--Pay----->| |
| | | | | |<-Callback-| |
| | | | | |--Publish event---->|
| | | | | | | |--->Booking Svc
| | | | | | | | (confirm)

2. Сценарий сбоя оплаты (retry + timeout)

Booking Svc PSP Kafka Payment Worker Scheduler
| | | | |
|--Pay----->| | | |
|<-Timeout--| | | |
|--Retry--->| | | |
|<-Timeout--| | | |
|--Retry--->| | | |
|<-Timeout--| | | |
|--Publish: payment_timeout------>| |
| | |--Event----->| |
| | | |--Check PSP---->|
| | | |<--Still pending-|
| | | | |--Expire booking-->

Как рисовать sequence diagrams на интервью

Если интервьюер просит детализировать сценарий, можно использовать упрощённый формат прямо в тексте:

1. Client → POST /bookings {idempotencyKey, hotelId, roomTypeId, dates}
2. API Gateway → auth, rate-limit → Booking Service
3. Booking Service → BEGIN TX
4. Booking Service → SELECT ... FOR UPDATE (room_availability)
5. Booking Service → UPDATE booked_rooms + 1
6. Booking Service → INSERT booking (pending)
7. Booking Service → COMMIT TX
8. Booking Service → Publish "booking.created" to Kafka
9. Booking Service → 201 Created {bookingId, idempotencyKey}
10. Payment Service ← Kafka "booking.created"
11. Payment Service → PSP.createPayment(idempotencyKey)
12. PSP → 3DS redirect → User
13. User → confirms payment
14. PSP → webhook → Payment Service
15. Payment Service → UPDATE booking (confirmed)

Это по сути sequence diagram в текстовом виде — занимает 2 минуты вместо 10 на рисование.

Рекомендация для подготовки

При подготовке к архитектурным интервью стоит иметь в голове 3-4 ключевых sequence diagram:

  1. Счастливый путь бронирования (happy path)
  2. Бронирование с конфликтом (два пользователя, один номер)
  3. Сбой оплаты (retry, timeout, cancellation)
  4. Отмена бронирования (refund, availability restore)

Их можно описать текстом за 30-60 секунд каждый, что демонстрирует глубокое понимание системы без необходимости рисовать.

Вопрос 18. Как система с предложенным дизайном будет работать при изменении начальных требований (например, добавление частичной предоплаты, динамического ценообразования, увеличение нагрузки на порядки)?

Таймкод: 01:22:13

Ответ собеседника: Правильный. При изменении требований добавляются новые компоненты и логика. Кандидат сам подсветил overbooking, retry-логику, динамическое ценообразование. Разделение на независимые сервисы позволяет добавлять функциональность модульно.

Правильный ответ:

Способность системы адаптироваться к изменяющимся требованиям — ключевой критерий качества архитектуры. Разберём конкретные сценарии изменений.

1. Добавление частичной предоплаты

Текущий дизайн: полная оплата при бронировании. Новое требование: предоплата 30%, остаток при заезде.

Что меняется:

-- Расширяем таблицу платежей
ALTER TABLE payments ADD COLUMN payment_type VARCHAR(20) DEFAULT 'full'; -- 'full', 'deposit', 'final'
ALTER TABLE payments ADD COLUMN parent_payment_id BIGINT REFERENCES payments(id);

-- Новая статусная модель бронирования
-- pending → deposit_paid → fully_paid → confirmed → checked_in
-- ↓
-- cancelled (возврат депозита по политике)
type PaymentPolicy struct {
DepositPercent int // 30
DepositDeadline time.Duration // до заезда
CancellationFee int // штраф за отмену в копейках
}

func (s *BookingService) CreateBookingWithDeposit(ctx context.Context, req CreateBookingRequest) (*BookingResponse, error) {
return s.db.RunTx(ctx, func(tx *sql.Tx) error {
// ... проверка доступности ...

totalPrice := calculateTotalPrice(req)
depositAmount := totalPrice * s.policy.DepositPercent / 100

booking := &Booking{
TotalPrice: totalPrice,
DepositAmount: depositAmount,
Status: BookingStatusPendingDeposit,
DepositDeadline: time.Now().Add(s.policy.DepositDeadline),
}

if err := s.bookingRepo.Create(ctx, tx, booking); err != nil {
return err
}

// Создаём платёж на депозит
depositPayment := &Payment{
BookingID: booking.ID,
Amount: depositAmount,
PaymentType: "deposit",
Status: PaymentStatusPending,
}

return s.paymentRepo.Create(ctx, tx, depositPayment)
})
}

// Второй платёж (остаток) — при заезде или по расписанию
func (s *BookingService) ChargeFinalPayment(ctx context.Context, bookingID int64) error {
return s.db.RunTx(ctx, func(tx *sql.Tx) error {
booking, err := s.bookingRepo.GetByID(ctx, tx, bookingID)
errIf(err)

finalAmount := booking.TotalPrice - booking.DepositAmount

payment := &Payment{
BookingID: booking.ID,
Amount: finalAmount,
PaymentType: "final",
Status: PaymentStatusPending,
}

return s.paymentRepo.Create(ctx, tx, payment)
})
}

Влияние на архитектуру:

  • Новый статус в конечном автомате бронирования
  • Scheduler для напоминаний об оплате остатка
  • Логика возврата депозита при отмене (зависит от политики)
  • Изменение webhook-обработчика: deposit_succeeded → другой набор действий

2. Динамическое ценообразование

Текущий дизайн: базовая цена в room_types.base_price. Новое требование: цена зависит от дня недели, заполняемости, сезона, спроса.

Что меняется:

-- Таблица ценовых правил
CREATE TABLE pricing_rules (
id BIGSERIAL PRIMARY KEY,
room_type_id BIGINT NOT NULL REFERENCES room_types(id),
rule_type VARCHAR(30) NOT NULL, -- 'seasonal', 'occupancy', 'day_of_week', 'demand', 'promo'
priority INT NOT NULL DEFAULT 0, -- приоритет применения
condition JSONB NOT NULL, -- условие применения
adjustment JSONB NOT NULL, -- тип и величина корректировки
valid_from DATE,
valid_to DATE,
is_active BOOLEAN DEFAULT TRUE
);

-- Примеры правил:
-- {"type": "occupancy", "condition": {"min_occupancy_pct": 80}, "adjustment": {"type": "multiply", "value": 1.2}}
-- {"type": "day_of_week", "condition": {"days": [5, 6]}, "adjustment": {"type": "add", "value": 200000}}
-- {"type": "seasonal", "condition": {"date_range": ["2024-12-20", "2025-01-10"]}, "adjustment": {"type": "multiply", "value": 1.5}}
type PricingEngine struct {
rulesRepo *PricingRulesRepo
}

func (e *PricingEngine) CalculatePrice(ctx context.Context, roomTypeID int64, checkIn, checkOut time.Time, occupancyRate float64) (int, error) {
basePrice, err := e.rulesRepo.GetBasePrice(ctx, roomTypeID)
errIf(err)

// Загружаем активные правила, отсортированные по приоритету
rules, err := e.rulesRepo.GetActiveRules(ctx, roomTypeID, checkIn, checkOut)
errIf(err)

totalPrice := 0
for d := checkIn; d.Before(checkOut); d = d.AddDate(0, 0, 1) {
dayPrice := basePrice

for _, rule := range rules {
if e.matchesRule(rule, d, occupancyRate) {
dayPrice = e.applyAdjustment(dayPrice, rule.Adjustment)
}
}

totalPrice += dayPrice
}

return totalPrice, nil
}

func (e *PricingEngine) matchesRule(rule PricingRule, date time.Time, occupancyRate float64) bool {
switch rule.RuleType {
case "occupancy":
minOccupancy := rule.Condition["min_occ_pct"].(float64)
return occupancyRate >= minOccupancy
case "day_of_week":
days := rule.Condition["days"].([]int)
weekday := int(date.Weekday())
return contains(days, weekday)
case "seasonal":
from := rule.Condition["date_from"].(time.Time)
to := rule.Condition["date_to"].(time.Time)
return !date.Before(from) && !date.After(to)
}
return false
}

Влияние на архитектуру:

  • Новый сервис Pricing Service (или модуль внутри Booking Service)
  • Кэширование рассчитанных цен (TTL — минуты, не часы)
  • Пересчёт цен при изменении заполняемости (событие из Booking Service)
  • A/B-тестирование ценовых правил

3. Увеличение нагрузки на порядок (×10)

Текущие оценки: 50 RPS на бронирование, 5000 RPS на чтение. Новые: 500 RPS и 50000 RPS.

Что меняется:

Чтение (50000 RPS):

  • Redis Cluster вместо одного инстанса (шардинг по ключу)
  • CDN для статики (фото отелей, описания)
  • Read Replicas: 1 → 5-10
  • Вынос поиска в Elasticsearch (полнотекстовый поиск + фильтры)
// Мультиуровневое кэширование
func (s *SearchService) SearchHotels(ctx context.Context, query SearchQuery) (*SearchResult, error) {
cacheKey := query.CacheKey()

// L1: In-memory cache (per-instance, 1-5 секунд)
if result := s.l1Cache.Get(cacheKey); result != nil {
return result, nil
}

// L2: Redis Cluster (30 секунд)
if result, err := s.l2Cache.Get(ctx, cacheKey); err == nil {
s.l1Cache.Set(cacheKey, result, 3*time.Second)
return result, nil
}

// L3: Elasticsearch
result, err := s.elastic.Search(ctx, query)
if err != nil {
return nil, err
}

s.l2Cache.Set(ctx, cacheKey, result, 30*time.Second)
s.l1Cache.Set(cacheKey, result, 3*time.Second)
return result, nil
}

Запись (500 RPS):

  • Шардинг Booking DB по hotel_id (4-8 шардов)
  • Партиционирование по дате (уже заложено)
  • Асинхронная обработка некритичных операций (email-уведомления, аналитика)
  • Connection pooling (pgx с настройкой max_connections)

4. Добавление лояльности и промокодов

CREATE TABLE promo_codes (
id BIGSERIAL PRIMARY KEY,
code VARCHAR(50) UNIQUE NOT NULL,
discount_type VARCHAR(20) NOT NULL, -- 'percentage', 'fixed'
discount_value INT NOT NULL, -- процент или сумма в копейках
max_uses INT,
used_count INT DEFAULT 0,
valid_from TIMESTAMPTZ,
valid_to TIMESTAMPTZ,
min_order_amount INT DEFAULT 0,
applicable_hotels INT[] DEFAULT '{}', -- пустой массив = все отели
is_active BOOLEAN DEFAULT TRUE
);

CREATE TABLE loyalty_accounts (
user_id BIGINT PRIMARY KEY,
points_balance INT DEFAULT 0,
tier VARCHAR(20) DEFAULT 'bronze', -- bronze, silver, gold, platinum
created_at TIMESTAMPTZ DEFAULT NOW()
);

Принцип адаптивности

Ключевой принцип, который демонстрирует кандидат: разделение на независимые сервисы с чёткими интерфейсами позволяет вносить изменения локально:

  • Динамическое ценообразование → новый Pricing Service, Booking Service вызывает его через API
  • Частичная предоплата → изменение в Payment Service и статусной модели Booking
  • Рост нагрузки → масштарование конкретных сервисов, а не всей системы
  • Лояльность → новый Loyalty Service, интеграция через события

Каждое изменение затрагивает 1-2 сервиса, а не всю систему целиком. Это результат правильного разделения на bounded contexts: Hotel Management, Booking, Payment, Search — каждый сосвоей ответственностью и независимой эволюцией.