> Как хранится история переходов статусов платежей? (Go)

Уровень: senior · Роль: backend · Язык: Go · Категория: Технические вопросы

Компании: InDrive

Стек: Go

> Пример ответа

Короткий ответ

История переходов статусов платежей хранится в отдельной таблице payment_status_history с полями: payment_id, from_status, to_status, changed_at, actor_id, reason_code и, при необходимости, metadata (JSONB). Это классический event sourcing для агрегата платежа: текущий статус - денормализованное значение в основной таблице, а полная цепочка переходов - в журнале. Такой подход обеспечивает аудит, возможность восстановления состояния и отладки.

Подробное объяснение

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

  • Иммутабельность: записи в history не обновляются и не удаляются, только добавляются.
  • Ссылочная целостность: внешний ключ на payment_id с каскадным удалением или без него (зависит от требований к retention).
  • Индексация: составной индекс (payment_id, created_at) для быстрой выборки цепочки, отдельный индекс по created_at для запросов по времени.
  • Транзакционность: обновление текущего статуса и вставка записи в history должны происходить в одной транзакции, чтобы избежать рассинхронизации.

Альтернативные подходы:

  • Хранение в JSONB-массиве внутри записи платежа - проще, но неудобно для аналитики и запросов по конкретным переходам.
  • Отдельный event store (например, на базе Kafka или специализированной БД) - избыточно для большинства платёжных систем, но оправдано при высоких требованиях к аудиту.

Выбор между ними зависит от частоты запросов к истории, объёма данных и требований к консистентности.

На практике

В Go-проектах с PostgreSQL типичная схема выглядит так:

SQL
CREATE TABLE payment_status_history (
id BIGSERIAL PRIMARY KEY,
payment_id UUID NOT NULL REFERENCES payments(id),
from_status VARCHAR(32) NOT NULL,
to_status VARCHAR(32) NOT NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
actor_id UUID, -- кто инициировал переход (пользователь, система, cron)
reason_code VARCHAR(64), -- машинный код причины (например, 'timeout', 'manual_review')
metadata JSONB -- произвольные данные: IP, user_agent, доп. контекст
);
CREATE INDEX idx_payment_history_payment_id_created_at
ON payment_status_history (payment_id, changed_at);

В коде переход выполняется в одной транзакции:

GO
func (s *PaymentService) TransitionStatus(ctx context.Context, paymentID uuid.UUID, newStatus string, actorID uuid.UUID, reason string) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var currentStatus string
err = tx.QueryRowContext(ctx,
`SELECT status FROM payments WHERE id = $1 FOR UPDATE`, paymentID,
).Scan(&currentStatus)
if err != nil {
return err
}
if !isValidTransition(currentStatus, newStatus) {
return ErrInvalidTransition
}
_, err = tx.ExecContext(ctx,
`UPDATE payments SET status = $1, updated_at = now() WHERE id = $2`,
newStatus, paymentID,
)
if err != nil {
return err
}
_, err = tx.ExecContext(ctx,
`INSERT INTO payment_status_history (payment_id, from_status, to_status, actor_id, reason_code)
VALUES ($1, $2, $3, $4, $5)`,
paymentID, currentStatus, newStatus, actorID, reason,
)
if err != nil {
return err
}
return tx.Commit()
}

Важные детали:

  • SELECT ... FOR UPDATE блокирует строку платежа, предотвращая гонки при параллельных переходах.
  • Валидация допустимости перехода (например, pending -> completed, но не refunded -> completed) должна выполняться до записи.
  • Для высоконагруженных систем можно использовать оптимистичную блокировку через version-поле, но для платёжных операций пессимистичная блокировка надёжнее.

Как отвечать на собеседовании

Начни с краткого описания схемы таблицы, затем переходи к транзакционности и конкурентности. Подчеркни, что история - это неизменяемый журнал, а не просто набор записей. Упомяни trade-off между простотой (JSONB) и аналитическими возможностями (отдельная таблица). Если спросят про масштабирование - скажи про партиционирование по changed_at (например, по месяцам) и про то, что старые записи можно архивировать. Хорошо показать понимание, зачем нужен actor_id и reason_code - это критично для аудита и разбора инцидентов.

Что проверяет интервьюер

  • Понимание разницы между текущим состоянием и историей изменений.
  • Умение проектировать схему с учётом целостности данных (транзакции, внешние ключи, индексы).
  • Осознание проблем конкурентного доступа и способов их решения.
  • Практический опыт: знание, что хранить в metadata, как обрабатывать переходы без промежуточных статусов.
  • Способность обосновать выбор между реляционной таблицей и документной моделью.

Типичные ошибки

  • Хранение истории в том же поле, что и текущий статус - например, в виде массива в JSONB. Это усложняет запросы и нарушает нормализацию.
  • Отсутствие транзакции при обновлении статуса и записи в history - приводит к рассинхронизации при сбоях.
  • Игнорирование гонок: два параллельных запроса могут перевести платеж из одного статуса в два разных, если не использовать блокировки или версионирование.
  • Ненужные данные в history: например, хранение полного payload платежа в каждой записи - раздувает таблицу без пользы.
  • Удаление записей истории при удалении платежа - нарушает аудит. Лучше использовать мягкое удаление или не удалять вовсе.

> ГОТОВЫ К СЛЕДУЮЩЕМУ СОБЕСЕДОВАНИЮ?

Запустите тренировочную сессию с ИИ и получите детальную обратную связь, чтобы увереннее проходить реальные интервью